After jailbreaking my old iPhone 6s using checkra1n, I wanted to check if the Banque Postale app was able to detect the jailbreak.

I launched the app and immediately got this message:

Jailbreak detection

“Votre téléphone ne répond pas aux exigences de sécurité de la Banque Postale.” (Your phone does not meet the security requirements of the Banque Postale.)

Since I was locked out, I decided to analyze the app to see exactly how it was detecting the jailbreak environment.

Analysis

I used one of the many IPA-dumping tools available to dump (and decrypt) the Business app from my iPhone.

I ended up with a 16.6MB decrypted IPA file. Since an IPA is just a zip archive, I unzipped it to take a look at the contents.

% unzip Business_2.3.000.ipa
% tree Payload
Payload
└── Business.app
    ├── [email protected]
    ├── Business // The main executable binary
    ├── Frameworks
    │   ├── AFNetworking.framework
    │   ├── BlackAndWhiteClientiOSApi.framework
    │   ├── sdkta.framework
    │   └── tazSDK.framework
    ├── Info.plist
    ├── config.xml
    └── www
        ├── cordova.js
        ├── index.html
        ├── main.5beb1742b411a7d3.js // The main JavaScript bundle
        ├── plugins
        │   ├── cordova-plugin-jailbreak-detection
        │   │   └── www
        │   │       └── jailbreakdetection.js // The JavaScript bridge for the jailbreak detection plugin
        │   └── ...
        └── assets
            └── ...

89 directories, 341 files

I searched for the “jailbreak” string in the Business.app folder using ripgrep and found some interesting results in Payload/Business.app/www/main.5beb1742b411a7d3.js.

% rg --binary "Votre téléphone ne répond pas aux exigences de sécurité de la Banque Postale"
Payload/Business.app/www/main.5beb1742b411a7d3.js
45838:        e.EFF(5, "Votre téléphone ne répond pas aux exigences de sécurité de la Banque Postale.");

This JavaScript file is minified and obfuscated. I ran it through an online JavaScript deobfuscator to make it readable (see main.js).

The function responsible for displaying the warning is nF:

function nF(n, p) {
  if (n & 1) {
    e.j41(0, "div", 63);
    e.nrm(1, "span", 64);
    e.k0s();
    e.j41(2, "div", 65)(3, "div", 66)(4, "p", 67);
    e.EFF(5, "Votre téléphone ne répond pas aux exigences de sécurité de la Banque Postale.");
    e.k0s()()();
  }
}

By following the cross-references for this function, I found it’s registered as a template for a lightbox in the q5a-root component.

e.DNE(142, nF, 6, 0, "ng-template", null, 3, e.C5r);

This template is triggered by the jailBreakLightbox.open() method. Looking for where this is called leads to controlerJailBreakIOS():

controlerJailBreakIOS() {
  this.jailbreakDetection.isJailbroken().subscribe(t => {
    if (t) {
      this.jailBreakLightbox.open();
    }
  });
}

This controller is triggered when the device is ready. Here is the relevant part of the initialization flow:

onDeviceReady() {
  // ... initialization code ...
  var t = this;
  this.codeMediaService.init();
  if (this.webviewCleaner) {
    this.webviewCleaner.clearCookies().then(() => {
      this.logger.info("Cookies supprimés!");
    }).catch(v => {
      this.logger.error("Erreur: Plugin WebviewCleaner - clear cookies", v);
    });
  } else {
    this.logger.error("Erreur: Plugin WebviewCleaner non trouvé !!!");
  }
  this.deviceUtilsService.setStatusBarOverlayWebView(true);
  setTimeout((0, yl.A)(function* () {
    navigator.splashscreen.hide();
    yield t.deviceUtilsService.setStatusBarOverlayWebView(true);
  }), 251);
  if (this.deviceService.isIOS()) {
    // ... other iOS specific setup ...
    this.wkwebviewInjectCookie.injectCookie("labanquepostale.fr/dummy").then(() => {
      this.logger.info("Cookies wkwebview OK");
    }).catch(v => {
      this.logger.error("Cookies wkwebview KO", v);
    });
    if (this.mobileAccessibility) {
      this.mobileAccessibility.isVoiceOverRunning().then(v => {
        if (v) {
          this.renderer.setAttribute(document.body, "data-tb-accessible", "voiceover");
          this.renderer.setAttribute(document.body, "data-tb-animate", "inactif");
          this.renderer.addClass(document.body, "access-voiceover");
        }
      });
    }
    this.controlerJailBreakIOS();
  }
  this.renderer.removeClass(document.body, "device-desktop");
  const a = this.deviceUtilsService.getSpecificCssClassForDevice().split(" ");
  for (const v of a) {
    this.renderer.addClass(document.body, v);
  }
  this.pushNotif.initialiser();
  this.deeplinkHandler();
  this.deviceReadyService.setDeviceReady();
}
controlerJailBreakIOS() {
  this.jailbreakDetection.isJailbroken().subscribe(t => {
    if (t) {
      this.jailBreakLightbox.open();
    }
  });
}

let ha = (() => {
  var n;
  (n = class extends vt.IM {
    isJailbroken() { }
  }).ɵfac = (() => {
    let o;
    return function (a) {
      return (o ||= e.xGo(n))(a || n);
    };
  })();
  n.ɵprov = e.jDH({
    token: n,
    factory: n.ɵfac
  });
  let p = n;
  (0, bt.Cg)([(0, vt.tp)({
    observable: true,
    successIndex: 0,
    errorIndex: 1
  })], p.prototype, "isJailbroken", null);
  p = (0, bt.Cg)([(0, vt.k_)({
    pluginName: "JailbreakDetection",
    plugin: "cordova-plugin-jailbreak-detection",
    pluginRef: "jailbreakdetection"
  })], p);
  return p;
})();

So the logic flows like this: when the app launches on iOS, it calls controlerJailBreakIOS(). This function calls a service named jailbreakDetection. The isJailbroken method inside that service is decorated with Cordova plugin metadata, indicating it bridges to native code.

The Payload/Business.app/config.xml confirms the app uses the cordova-plugin-jailbreak-detection plugin:

<feature name="JailbreakDetection">
    <param name="ios-package" value="JailbreakDetection" />
    <param name="onload" value="true" />
</feature>

The plugin is set to load on app startup (onload="true"), which means the jailbreak detection logic will be available as soon as the app is launched.

The JavaScript bridge (Payload/Business.app/www/plugins/cordova-plugin-jailbreak-detection/www/jailbreakdetection.js) simply forwards the call to the native iOS layer:

cordova.define("cordova-plugin-jailbreak-detection.JailbreakDetection", function(require, exports, module) {
    var exec = require("cordova/exec");
    var JailbreakDetection = function () {
        this.name = "JailbreakDetection";
    };
    JailbreakDetection.prototype.isJailbroken = function (successCallback, failureCallback) {
        exec(successCallback, failureCallback, "JailbreakDetection", "isJailbroken", []);
    };
    module.exports = new JailbreakDetection();
});

It’s a thin wrapper that forwards the isJailbroken() call to native iOS code via Cordova’s exec() function.

Note: cordova-plugin-jailbreak-detection is a Cordova plugin that provides jailbreak detection functionality for iOS devices. It exposes a JavaScript API that allows the app to check if the device is jailbroken, it’s source code is available on GitHub.

To see the actual detection logic, I reversed the Business binary. The native method -[JailbreakDetection isJailbroken:] acts as a wrapper that calls the main logic -[JailbreakDetection jailbroken] and returns the result to JavaScript.

void __cdecl -[JailbreakDetection isJailbroken:](JailbreakDetection *self, SEL a2, id command)
{
    id vCommand;
    id vPluginResult;
    id vDelegate;
    id vCallbackId;
    BOOL vIsJailbroken;

    vCommand = objc_retain(command);

    vIsJailbroken = -[JailbreakDetection jailbroken](self, "jailbroken");

    vPluginResult = objc_retainAutoreleasedReturnValue(+[CDVPluginResult resultWithStatus:messageAsBool:](&OBJC_CLASS___CDVPluginResult, "resultWithStatus:messageAsBool:", 1, vIsJailbroken));
    
    vDelegate = objc_retainAutoreleasedReturnValue(-[CDVPlugin commandDelegate](self, "commandDelegate"));
    
    vCallbackId = objc_retainAutoreleasedReturnValue(objc_msgSend(vCommand, "callbackId"));

    -[CDVCommandDelegate sendPluginResult:callbackId:](vDelegate, "sendPluginResult:callbackId:", vPluginResult, vCallbackId);

    objc_release(vCallbackId);
    objc_release(vDelegate);
    objc_release(vPluginResult);
    objc_release(vCommand);
}
BOOL __cdecl -[JailbreakDetection jailbroken](JailbreakDetection *self, SEL a2)
{
    NSFileManager *fm;
    NSError *error = nil;
    BOOL result = 1;

    fm = objc_retainAutoreleasedReturnValue(+[NSFileManager defaultManager](&OBJC_CLASS___NSFileManager, "defaultManager"));
    if (-[NSFileManager fileExistsAtPath:](fm, "fileExistsAtPath:", CFSTR("/Applications/Cydia.app")))
    {
        objc_release(fm);
        return 1;
    }
    objc_release(fm);

    fm = objc_retainAutoreleasedReturnValue(+[NSFileManager defaultManager](&OBJC_CLASS___NSFileManager, "defaultManager"));
    if (-[NSFileManager fileExistsAtPath:](fm, "fileExistsAtPath:", CFSTR("/Library/MobileSubstrate/MobileSubstrate.dylib")))
    {
        objc_release(fm);
        return 1;
    }
    objc_release(fm);

    fm = objc_retainAutoreleasedReturnValue(+[NSFileManager defaultManager](&OBJC_CLASS___NSFileManager, "defaultManager"));
    if (-[NSFileManager fileExistsAtPath:](fm, "fileExistsAtPath:", CFSTR("/bin/bash")))
    {
        objc_release(fm);
        return 1;
    }
    objc_release(fm);

    fm = objc_retainAutoreleasedReturnValue(+[NSFileManager defaultManager](&OBJC_CLASS___NSFileManager, "defaultManager"));
    if (-[NSFileManager fileExistsAtPath:](fm, "fileExistsAtPath:", CFSTR("/usr/sbin/sshd")))
    {
        objc_release(fm);
        return 1;
    }
    objc_release(fm);

    fm = objc_retainAutoreleasedReturnValue(+[NSFileManager defaultManager](&OBJC_CLASS___NSFileManager, "defaultManager"));
    if (-[NSFileManager fileExistsAtPath:](fm, "fileExistsAtPath:", CFSTR("/etc/apt")))
    {
        objc_release(fm);
        return 1;
    }
    objc_release(fm);

    objc_msgSend(CFSTR("Jailbreak test"), "writeToFile:atomically:encoding:error:",CFSTR("/private/jailbreaktest.txt"), 1, 4, &error);
    fm = objc_retainAutoreleasedReturnValue(+[NSFileManager defaultManager](&OBJC_CLASS___NSFileManager, "defaultManager"));
    -[NSFileManager removeItemAtPath:error:](fm, "removeItemAtPath:error:", CFSTR("/private/jailbreaktest.txt"), 0);
    objc_release(fm);
    if (error)
    {
        UIApplication *app;
        NSURL *url;
        app = objc_retainAutoreleasedReturnValue(+[UIApplication sharedApplication](&OBJC_CLASS___UIApplication, "sharedApplication"));
        url = objc_retainAutoreleasedReturnValue(+[NSURL URLWithString:](&OBJC_CLASS___NSURL, "URLWithString:", CFSTR("cydia://package/com.example.package")));
        result = -[UIApplication canOpenURL:](app, "canOpenURL:", url);
        objc_release(url);
        objc_release(app);
    }
    return result;
}

The implementation checks for common jailbreak artifacts:

  • File existence checks
    • /Applications/Cydia.app
    • /Library/MobileSubstrate/MobileSubstrate.dylib
    • /bin/bash
    • /usr/sbin/sshd
    • /etc/apt
  • Write permissions in /private
  • URL scheme check: cydia://package/...

Bypassing the detection

Since the checks are straightforward, we can bypass them easily using Frida. The goal is to hook the jailbroken method and force it to return 0 (false).

Here is a simple Frida script based on this snippet:

// Resolve the address of -[JailbreakDetection jailbroken]
var resolver = new ApiResolver('objc');
var matches = resolver.enumerateMatchesSync("-[JailbreakDetection jailbroken]");

if (matches.length > 0 ) {
    // The address found, set a hook
    Interceptor.attach(matches[0]["address"], {
        onLeave: function(retval) {
            retval.replace(0); // Always return 0
        }    
    });
    console.log("-[JailbreakDetection jailbroken] hooked!");
} else {
    // The address not found
    console.log("Can't find the address of -[JailbreakDetection jailbroken]");
}

Now, run the app with Frida:

% frida -H 192.168.1.31 -f fr.labanquepostale.pmo.mescomptes -l bypass_jb.js
     ____
    / _  |   Frida 17.7.0 - A world-class dynamic instrumentation toolkit
   | (_| |
    > _  |   Commands:
   /_/ |_|       help      -> Displays the help system
   . . . .       object?   -> Display information about 'object'
   . . . .       exit/quit -> Exit
   . . . .
   . . . .   More info at https://frida.re/docs/home/
   . . . .
   . . . .   Connected to 192.168.1.31 (id=[email protected])
Spawning `fr.labanquepostale.pmo.mescomptes`...
-[JailbreakDetection jailbroken] hooked!
Spawned `fr.labanquepostale.pmo.mescomptes`. Resuming main thread!
[Remote::fr.labanquepostale.pmo.mescomptes ]->

With the hook active, the app believes the device is not jailbroken.

Jailbreak detection bypassed!

If you’re interested in an alternative approach, I’ve also created a Tweak using Logos and Theos to bypass the jailbreak detection.

An interesting update

Later, I updated the iPhone to the latest iOS version supported by the device (iOS 15.8.6) and jailbroke it using Dopamine instead of checkra1n.

When I launched the Banque Postale app this time, the warning did not appear, even without Frida.

I ran the checks manually on the filesystem:

6s:~ root# ls /bin/ | grep bash
6s:~ root# ls /usr/sbin/ | grep sshd
6s:~ root# ls /etc/ | grep apt
6s:~ root# ls /Library/ | grep MobileSubstrate
6s:~ root#

None of the files or directories the app looks for exist on this newer setup. Cydia isn’t installed by default (Sileo is used instead), and the paths for tools like bash or sshd have changed in modern jailbreaks.

To confirm, I used a quick Frida script to log the return value:

var resolver = new ApiResolver('objc');
var matches = resolver.enumerateMatches("-[JailbreakDetection jailbroken]");
if (matches.length > 0) {
    Interceptor.attach(matches[0].address, {
        onLeave: function(retval) {
            console.log("-[JailbreakDetection jailbroken] returned: " + retval);
        }    
    });
}

Result:

Spawning `fr.labanquepostale.pmo.mescomptes`...
-[JailbreakDetection jailbroken] hooked!
Spawned `fr.labanquepostale.pmo.mescomptes`. Resuming main thread!
[Remote::fr.labanquepostale.pmo.mescomptes ]-> -[JailbreakDetection jailbroken] returned: 0x0

It turns out the 12-year-old source code of this jailbreak detection plugin is just completely outdated. It fails to detect modern jailbreak environments purely because it looks for artifacts that no longer exist.

Time to update your code base :p