juliangrtz.me

Defeating a banking app's anti-RE measures

Wed Oct 15, 2025

Introduction

I’m currently working on a tool to detect anti-reverse-engineering techniques in 64-bit iOS applications. It’s based on LIEF and Capstone. An appropriate way to test this tool is to apply it on actual applications found in the wild such as banking apps or large online games. Banking apps traditionally employ various measures to defeat reverse-engineering, including obfuscation, debugging detections and jailbreak detections – both on Android and on iOS. For testing purposes, I picked an app of a financial institution I’ve already “experimented with” in the past. Of course, the app’s name shall not be publicly disclosed. Let’s call it VIBA instead: Very Insecure Banking App.

Protections

VIBA was poorly protected in the past and could be manipulated by using a few lines of JavaScript code and Frida. Nowadays it makes heavy use of

  • obfuscation: constant unfolding, junk code and function joining (rather weak techniques),
  • anti-jailbreak methods by trying to open/access/(f)stat64 certain files (common in the industry),
  • string encryption with various XOR ciphers (the strings can be dumped at runtime!),
  • anti-dylib-injection strategies like _dyld_get_image_name and _dyld_image_count (managable but annoying),
  • anti-Frida measures by scanning for files like /usr/sbin/frida-server and trying to open a socket to it (annoying),
  • debugger checks using ptrace, sysctl and other APIs (syscalls, not so fun)
  • etc.

Checks usually result in two branches: good or bad. The control flow is not obfuscated at all. If the bad branch is taken the app finds colorful ways to deny its execution: call exit (both via libc and syscalls), abort, raise, pthread_kill, fill the SP register with junk etc. In other words: If you try to open the app on a jailbroken device or even on a non-jailbroken device with an attached Frida gadget it will “crash” immediately.

decider function

1 :: Gathering information

At the beginning I simply dragged the decrypted VIBA executable into IDA. Immediately noticable was the large start function. IDA wasn’t even able to display its graph, I had to edit IDA’s config to display it. Sadly, it’s just a huge monotonous basic block, nothing too interesting. Further investigation revealed that the function’s main purpose is to decrypt strings, mainly by using basic XOR ciphers like this:

_BYTE *__fastcall decryptString(
    _BYTE *a1,
    _BYTE *a2,
    __int64 a3,
    __int64 a4,
    unsigned __int64 a5
) {
    _BYTE *v8 = a1;
    if (a1 != a2)
    {
        unsigned __int64 v5 = a4 - a3;
        do
        {
            char v6 = *a1 - 1;
            *a1 = v6;
            *a1++ = *(_BYTE *)(a3 + a5 % v5) ^ v6;
            a5 = a5 % v5 + 1;
        } while (a1 != a2);
    }
    return v8;
}

The parameters can be interpreted as follows:

  • a1 = start of buffer (the encrypted string, in-place).
  • a2 = end of buffer (pointer to last+1).
  • a3..a4 = address range of a key/keystream.
  • a5 = initial index into that key/keystream.

Knowing this, one could decrypt the strings statically using an IDAPython script. I chose a simpler approach and dumped them with IDA while the debugger was attached to the process. I don’t know why the app doesn’t perform security checks before or during the start function using __attribute__((constructor)), for example. Here are a few interesting strings I discovered:

  • “What is the meaning of life?” (no, this is not a joke)
  • “_TtC18REDACTED24AppInitializationUtility”
  • “_TtC12REDACTEDFoundation23MoneyDataToDomainMapper”
  • “_TtC8REDACTEDLoginAPI12LoginAPIInit”

Then I searched for strings such as “Frida”, “Cydia”, “Sileo”, “/bin/bash” etc. to see if there are any obvious protections present. Nothing was found. Thus, I suspected that sensitive strings like these must be encoded and/or encrypted with a different method. Debugging the app confirmed this:

frida check

The string gets reconstructed with a call to snprintf and can be simply intercepted with a debugger or with Frida by hooking it. Indeed, hooking libc functions was an integral part of reversing these protections.

2 :: Fighting against SVCs

The main binary contains about 4350 raw syscalls. Yes, you read that correctly, 4350. Technically, these aren’t even allowed in a public app according to Apple’s App Review Guidelines:

2.5 Software Requirements

2.5.1 Apps may only use public APIs and must run on the currently shipping OS.

Apple doesn’t seem to care, many self-protecting apps make use of raw syscalls. In my opinion, relying on raw syscall invocation in user code is inherently brittle because Apple might change syscall numbers and the ABIs. Anyways, initially I thought they’d be all sorts of anti-reversing shenanigans but it turned out they’re just exit and ptrace calls. For example, a ptrace call looks like this:

frida check

I figured that out by scanning the binary statically for SVC instructions and capturing MOV X16, #imm instructions right before the syscall. If syscalls are heavily used one should also try to obfuscate the way the syscall number gets passed into X16 as much as possible. Otherwise the attacker can simply use the approach I chose. In fact, a well-known game does this which was previously analyzed by Romain Thomas, the author of LIEF.

Getting rid of these was easy: Just replace all exits with NOPs and all ptraces with MOV X0, -1s.

3 :: Fighting against protections

Let me not babble too much and just show a few portions of the relevant code which assesses whether the environment the app is running in is secure or not.

bool __fastcall isPortOpened(__int16 port)
{
   // variables
  v2 = socket(2, 1, 0);
 // junk
  if ( v2 == -1 )
    return 1;
   // junk
  do
  {
    v3->sa_len = v25;
    v3 = (v3 + 1);
  }
  while ( v24-- );
  v5 = v14 ? 3 : v20;
  v26.sa_family = v5;
  *&v26.sa_data[2] = 0;
  v6 = **v17[0];
  *v26.sa_data = (port << 8);
  *__error() = 0;
  v7 = bind(v2, &v26, 0x10u);
   // junk, essentially:
   // if bind succeeded return 0, otherwise return 1
}

Gets called with 27042 (frida-server), 22 (SSH) and a few other jailbreak-related ports.

bool checkInfoPlistHashes()
{
 // variables
  v9 = -[NSBundle infoDictionary](v10, "infoDictionary");
  *&v11 = "CFBundleIdentifier";
  *(&v11 + 1) = "hash1"; // com.xxx.xxx
  *&v13 = "CFBundleExecutable";
  *(&v13 + 1) = "hash2"; // BankName
  *&v15 = "CFBundleName";
  *(&v15 + 1) = "hash3"; // BankName
  *&v17 = "DTCompiler";
  *(&v17 + 1) = "hash4"; // com.apple.compilers.llvm.clang.1_0
  checksha1(&v7, v9);
  checksha1(&v5, v9);
  checksha1(&v3, v9);
  return checksha1(&v1, v9);
}

Checks the integrity of the Info.plist file. A similar check exists for pretty much all of the files present in the app’s installation directory.

__int64 fridaGadgets_check_sub_103E8E18C()
{
 // variables
  v0 = objc_retainAutoreleasedReturnValue((id)deobf_string_sub_103E6690C((__int64)&libFridaAgentDylib, 20));
  v1 = objc_retainAutoreleasedReturnValue((id)deobf_string_sub_103E6690C((__int64)&fridaGadgetDylib, 18));
  v2 = objc_retainAutoreleasedReturnValue(+[NSArray arrayWithObjects:count:](&OBJC_CLASS___NSArray, "arrayWithObjects:count:", v23, 2));
  v3 = check_dyld_sub_103E8ABF0(v2);
  v4 = 0xB00B;
  // junk
  if ( v3 != v4 )
    return 0xBAAD;
  // junk
  deobf_string_sub_103E66578((__int64)&frameworksFridaGadgetDylib, 28, (__int64)v22);
  return access_sub_103E9A478((__int64)v22, 0);
}

Checks the presence of Frida gadgets. In many other places 0xB00B indicates that the security check passed and 0xBAAD that it failed. Not a very clever way as it exposes pretty much all of the functions involved in the checks. A simple text search for MOV (w|x).*, 0xBAAD yields them.

__int64 zzzzLiberty_sub_103E91BD4()
{
 // variables

  v0 = objc_retainAutorelease((id)objc_retainAutoreleasedReturnValue((id)deobf_string_sub_103E6690C(
                                                                           (__int64)&zzzzLiberty,
                                                                           59)));
  v1 = dlopen((const char *)-[__CFString UTF8String](v0, "UTF8String"), 4);
  v5 = 0xB00B;
  if ( v1 )
  {
    v6 = 0xBAAD;
    dlclose(v1);
    v2 = &v6;
  }
  else
  {
    v2 = &v5;
  }
  return (unsigned int)*v2;
}

Checks the presence of the zzzzLiberty tweak using dlopen.

__int64 writeFile_sub_103E8E45C()
{
 // variables, junk
  if ( (unsigned int)+[NSString instancesRespondToSelector:](
                       &OBJC_CLASS___NSString,
                       "instancesRespondToSelector:",
                       "writeToFile:atomically:encoding:error:") )
  {
    v13 = class_getInstanceMethod(
            (Class)+[NSString class](&OBJC_CLASS___NSString, "class"),
            "writeToFile:atomically:encoding:error:");
    if ( _dladdr(v13) != v1 )
      return 0xBAAD;
  }
 
  if ( (unsigned int)+[UIApplication instancesRespondToSelector:](
                       &OBJC_CLASS___UIApplication,
                       "instancesRespondToSelector:",
                       "canOpenURL:") )
  {
    v18 = class_getInstanceMethod((Class)+[UIApplication class](&OBJC_CLASS___UIApplication, "class"), "canOpenURL:");
    if ( _dladdr(v18) != v9 )
      return 0xBAAD;
  }
  if ( (unsigned int)+[NSFileManager instancesRespondToSelector:](
                       &OBJC_CLASS___NSFileManager,
                       "instancesRespondToSelector:",
                       "isReadableFileAtPath:") )
  {
    v19 = class_getInstanceMethod(
            (Class)+[NSFileManager class](&OBJC_CLASS___NSFileManager, "class"),
            "isReadableFileAtPath:");
    if ( _dladdr(v19) != v3 )
      return 0xBAAD;
  }
  if ( (unsigned int)+[NSFileManager instancesRespondToSelector:](
                       &OBJC_CLASS___NSFileManager,
                       "instancesRespondToSelector:",
                       "isExecutableFileAtPath:") )
  {
    v21 = class_getInstanceMethod(
            (Class)+[NSFileManager class](&OBJC_CLASS___NSFileManager, "class"),
            "isExecutableFileAtPath:");
    if ( _dladdr(v21) != v5 )
      return 0xBAAD;
  }
  if ( !(unsigned int)+[NSFileManager respondsToSelector:](
                        &OBJC_CLASS___NSFileManager,
                        "respondsToSelector:",
                        "removeItemAtPath:error:")
    || (v23 = class_getInstanceMethod(
                (Class)+[NSFileManager class](&OBJC_CLASS___NSFileManager, "class"),
                "removeItemAtPath:error:"),
        _dladdr(v23) == v7) )
  {
    if ( !(unsigned int)+[NSFileManager respondsToSelector:](
                          &OBJC_CLASS___NSFileManager,
                          "respondsToSelector:",
                          "moveItemAtURL:toURL:error:")
      || (v24 = class_getInstanceMethod(
                  (Class)+[NSFileManager class](&OBJC_CLASS___NSFileManager, "class"),
                  "moveItemAtURL:toURL:error:"),
          _dladdr(v24) == v1) )
    {
      if ( !(unsigned int)+[NSFileManager respondsToSelector:](
                            &OBJC_CLASS___NSFileManager,
                            "respondsToSelector:",
                            "fileExistsAtPath:")
        || (v25 = class_getInstanceMethod(
                    (Class)+[NSFileManager class](&OBJC_CLASS___NSFileManager, "class"),
                    "fileExistsAtPath:"),
            _dladdr(v25) == v3) )
      {
        if ( !(unsigned int)+[NSDictionary respondsToSelector:](
                              &OBJC_CLASS___NSDictionary,
                              "respondsToSelector:",
                              "dictionaryWithContentsOfFile:") )
          return v27;
        v26 = class_getClassMethod(
                (Class)+[NSDictionary class](&OBJC_CLASS___NSDictionary, "class"),
                "dictionaryWithContentsOfFile:");
        if ( _dladdr(v26) == v5 )
          return v27;
        else
          return v31;
      }
      else
      {
        return v32;
      }
    }
    else
    {
      return 0xBAAD;
    }
  }
  else
  {
    return 0xBAAD;
  }
}

Various file system-related jailbreak checks using ObjC. These are rather annoying to deal with as it’s often not clear which libc functions actually get called underneath.

There are way more checks than that, the ones above are just examples. All of these were trivial to bypass using Frida’s Interceptor.replace() API because the app uses precisely two values to determine if a check passed or not. No integrity checks using CRC32, for example, are present!

Conclusion

Runtime Application Self-Protection (RASP) methods in modern mobile applications are not perfect. As long as the attacker has enough resources, reverse-engineering with the right approach and tools will always allow defeating these increasingly heavy protections. Even though the herein presented app severely improved its security, it was not enough and could be bypassed with relative ease after a few days of work.