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 callsisJailBroken(). - Line 5: If
isJailBrokenreturnsfalse, the function exits immediately (jumps toL1). - 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
jailBrokenMessageand 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 (likecydia://). - 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