In the previous article, I explored the jailbreak detection mechanism of the Banque Postale Business iOS app. This time, I’ll take a look at the regular Banque Postale iOS app, which is a bit more complex.

Instead of a classic Cordova app, this one is built using React Native.

Dealing with Hermes bytecode

After dumping the app from the iPhone, I listed the files and found the main.jsbundle file. This is the React Native bundle containing the JavaScript code of the app. Usually, React Native code is packaged as standard JavaScript, but modern versions often use Hermes.

Hermes is an open-source JavaScript engine optimized for React Native. Instead of shipping a full JS parser and compiler, Hermes compiles JavaScript into bytecode ahead of time. This makes the app start faster and use less memory, but it also makes reverse engineering harder because the code isn’t plain text.

When I tried to use a standard decompiler, it failed immediately:

% npx react-native-decompiler -i Payload/AppMobileJusteDigit.app/main.jsbundle -o ./output
Reading file...
[!] No modules were found!
[!] Possible reasons:
[!] - The bundle is a Hermes/binary file (ex. Facebook, Instagram). These files are not supported

Since standard tools wouldn’t work, I had to use a specialized tool for Hermes bytecode. I used hermes_rs:

git clone https://github.com/Pilfer/hermes_rs
cargo run --bin bytecode Payload/AppMobileJusteDigit.app/main.jsbundle  > bytecode_output.txt

This successfully disassembled the bytecode into a human-readable format.

Analyzing the bytecode

Searching through the disassembly, I found the function responsible for triggering the security checks. Here is the raw bytecode dump of informerJailBreak:

Function<informerJailBreak>(1 params, 53 registers, 0 symbols):
0	GetEnvironment  r0,  0
1	LoadFromEnvironment  r1,  r0,  3
2	GetByIdShort  r2,  r1,  1,  "default"
3	GetById  r1,  r2,  2,  "isJailBroken"
4	Call1  r6,  r1,  r2
5	JmpFalseLong  L1, r0 # If result is false, jump to L1 (skip checks)
6	LoadFromEnvironment  r2,  r0,  0
7	LoadFromEnvironment  r3,  r0,  1
8	LoadConstUInt8  r1,  9
		L4:
9	GetByVal  r1,  r3,  r1
10	LoadConstUndefined  r3
11	Call2  r1,  r2,  r3,  r1
12	GetById  r1,  r1,  3,  "getOS"
13	Call1  r2,  r1,  r3
14	LoadConstString  r1,  "ios"
		L3:
15	JStrictEqualLong  L2, r0 # If OS is iOS, jump to L2
16	LoadFromEnvironment  r24,  r0,  4
... (Android specific RootBeer checks) ...
122	LoadFromEnvironment  r5,  r0,  4
123	GetById  r4,  r5,  4,  "info"
124	LoadFromEnvironment  r0,  r0,  3
125	GetByIdShort  r1,  r0,  1,  "default"
126	GetById  r0,  r1,  17,  "jailBrokenMessage"
127	Call1  r43,  r0,  r1
128	LoadConstString  r45,  "IOS JAILBREAK : "
129	LoadConstString  r44,  " -jailBrokenMessage : "
130	LoadConstString  r42,  " -jail-monkey : "
131	Mov  r46,  r5
132	Mov  r41,  r6
133	Call  r0,  r4,  6
134	LoadConstUndefined  r0
135	Ret  r0

Here’s a breakdown of the logic:

  • Line 3-4: It imports a module (labeled default) and calls isJailBroken().
  • Line 5: If isJailBroken returns false, the function exits immediately (jumps to L1).
  • Line 12-15: If it returns true, it checks the Operating System.
  • Line 16+: If Android, it proceeds to perform extensive RootBeer checks (checking for su, busybox, etc.).
  • Line 122+: If iOS, it grabs a jailBrokenMessage and logs it.

The key component here is the default export imported at the beginning. Looking at the module definition in the bytecode ($FUNC_24883), we can see it bridges to native code:

Function<$FUNC_24883>(8 params, 18 registers, 3 symbols): # Type: SmallFunctionHeader - funcID: 24883 (239 bytes @ 6647442)

0	CreateEnvironment  r3
1	LoadParam  r2,  6
2	CreateClosure  r0,  r3,  Function<getJailMonkey>
3	StoreToEnvironment  r3,  2,  r0
4	GetGlobalObject  r0
5	TryGetById  r5,  r0,  1,  "Object"
		L2:
6	GetByIdShort  r4,  r5,  2,  "defineProperty"
7	NewObject  r1
8	LoadConstTrue  r0
		L1:
9	PutNewOwnByIdShort  r1,  r0,  "value"
10	LoadConstString  r0,  "__esModule"
11	Call4  r0,  r4,  r5,  r2,  r0,  r1
12	LoadConstUndefined  r0
13	PutById  r2,  r0,  1,  "default"
14	LoadParam  r4,  7
15	LoadConstZero  r1
16	GetByVal  r4,  r4,  r1
17	LoadParam  r1,  2
18	Call2  r4,  r1,  r0,  r4
19	StoreToEnvironment  r3,  0,  r4
20	LoadConstString  r1,  "JailMonkey native module is not available, check your native dependencies have linked correctly and ensure your app has been rebuilt"
21	StoreToEnvironment  r3,  1,  r1
22	GetById  r1,  r4,  3,  "NativeModules"
23	GetById  r1,  r1,  4,  "JailMonkey"
24	LoadConstNull  r5
25	Eq  r1,  r1,  r5
26	NewObject  r1
27	CreateClosure  r6,  r3,  Function<jailBrokenMessage>
28	PutNewOwnById  r1,  r6,  "jailBrokenMessage"
29	CreateClosure  r6,  r3,  Function<isJailBroken>
30	PutNewOwnById  r1,  r6,  "isJailBroken"
31	GetById  r4,  r4,  3,  "NativeModules"
32	GetById  r6,  r4,  4,  "JailMonkey"
33	Eq  r7,  r6,  r5
34	LoadConstUndefined  r4
35	JmpTrue  L1, r0 # Go to Addr: 9,  r7
36	GetById  r4,  r6,  5,  "rootedDetectionMethods"
37	JNotEqual  L2, r0 # Go to Addr: 6,  r4,  r5
38	NewObject  r4
39	PutNewOwnById  r1,  r4,  "androidRootedDetectionMethods"
40	CreateClosure  r4,  r3,  Function<hookDetected>
41	PutNewOwnById  r1,  r4,  "hookDetected"
42	CreateClosure  r4,  r3,  Function<canMockLocation>
43	PutNewOwnById  r1,  r4,  "canMockLocation"
44	CreateClosure  r4,  r3,  Function<trustFall>
45	PutNewOwnById  r1,  r4,  "trustFall"
46	CreateClosure  r4,  r3,  Function<isOnExternalStorage>
47	PutNewOwnById  r1,  r4,  "isOnExternalStorage"
48	CreateClosure  r4,  r3,  Function<isDebuggedMode>
49	PutNewOwnById  r1,  r4,  "isDebuggedMode"
50	CreateClosure  r4,  r3,  Function<isDevelopmentSettingsMode>
51	PutNewOwnById  r1,  r4,  "isDevelopmentSettingsMode"
52	CreateClosure  r3,  r3,  Function<AdbEnabled>
53	PutNewOwnById  r1,  r3,  "AdbEnabled"
54	PutById  r2,  r1,  1,  "default"
55	Ret  r0
// Reconstructed logic from bytecode
var JailMonkey = NativeModules.JailMonkey;
module.exports = {
    isJailBroken: function() { return JailMonkey.isJailBroken; },
    jailBrokenMessage: function() { return JailMonkey.jailBrokenMessage; },
    // ... other exports like hookDetected, canMockLocation
};

The string JailMonkey gives it away. The app uses the popular JailMonkey library.

How React Native and JailMonkey work

React Native code runs in a JavaScript thread (Hermes), but it doesn’t have direct access to the iOS file system or system APIs. To perform sensitive checks (like detecting jailbreaks), it must “bridge” over to the native iOS side (Objective-C/Swift).

When the JavaScript code calls JailMonkey.isJailBroken, it triggers a native method in the compiled iOS binary. This native method performs the actual checks and returns a boolean (YES/NO) back to the JavaScript layer.

Analyzing the native implementation

By extracting the JailMonkey class from the binary and comparing it to the open-source repository, we can see exactly what it checks.

Based on the source code, here is the relevant source code for the detection logic:

- (NSArray *)pathsToCheck
{
    return @[
        @"/.bootstrapped_electra",
        @"/.cydia_no_stash",
        @"/.installed_unc0ver",
        @"/Applications/Cydia.app",
        @"/Applications/FakeCarrier.app",
        @"/Applications/Icy.app",
        @"/Applications/Sileo.app",
        @"/bin/bash",
        @"/bin/sh",
        @"/etc/apt",
        @"/private/var/lib/apt",
        @"/private/var/lib/cydia",
        @"/usr/bin/cycript",
        @"/usr/bin/sshd",
        @"/usr/sbin/sshd",
        @"/var/lib/cydia",
        @"/var/cache/apt",
        @"/var/checkra1n.dmg",
        // ... (long list of paths)
    ];
}

- (NSArray *)schemesToCheck
{
    return @[
        @"cydia://package/com.example.package",
        @"sileo://package/com.example.package",
        @"filza://package/com.example.package"
    ];
}

- (NSArray *)dylibsToCheck
{
    return @[
        @"Substrate",
        @"FridaGadget",
        @"libhooker",
        @"libsubstitute.dylib"
    ];
}

The main isJailBroken method simply runs these checks:

- (BOOL)isJailBroken{
    // ... Simulator check ...
    
    return [self checkPaths] || 
           [self checkSchemes] || 
           [self canViolateSandbox] || 
           [self canFork] || 
           [self checkSymlinks] || 
           [self checkDylibs];
}

Here are the checks it performs:

  • File existence (checkPaths): Looks for common jailbreak files and directories (Cydia, Sileo, unc0ver, bash, sshd, apt).
  • URL Schemes (checkSchemes): Checks if the app can open URLs associated with package managers (like cydia://).
  • Dynamic Libraries (checkDylibs): Scans loaded libraries for known injection frameworks (Substrate, Frida, Cycript).
  • Sandbox violation (canViolateSandbox): Attempts to write a file to a restricted location (/private).
  • Fork (canFork): Attempts to fork the process. This is usually restricted on non-jailbroken iOS devices.

Conclusion: outdated again

Just like the Business app, this detection logic is completely outdated.

I tested the app on an iPhone running iOS 15.8.6 jailbroken with Dopamine. Modern rootless jailbreaks (like Dopamine) change the file system structure significantly. They typically operate in a sandboxed environment (e.g., /var/jb/) rather than the system root.

The pathsToCheck array in JailMonkey is looking for Cydia.app in /Applications/ or bash in /bin/. On a clean Dopamine install, these paths simply do not exist. Additionally, Dopamine’s built-in concealment features often hide the presence of dynamic libraries like libhooker from standard _dyld_image_count scans used by the checkDylibs method.

The result? The isJailBroken method returns NO, and the app runs perfectly fine without any modification or Frida bypass required.

It seems 12-year-old detection logic isn’t just a problem for the “Business” app; the main app is relying on equally ancient assumptions about the iOS file system.

Time to update your code base-again :p