Frida in Practice: Intercepting sub_xxxx Functions

About the author. I’m Colin Xiao — an independent reverse engineer, mostly working solo on contract jobs. I dig into compiled code of every kind, including but not limited to: Android and iOS apps, x86/x64/ARM native binaries, packers and obfuscators, DRM and license checks, network protocols and APIs, crypto and signature algorithms, malware, game binaries, and embedded/IoT firmware. I also handle large-scale data collection and vulnerability research. If you have any project in mind, I’d be glad to take it on — whatever it is, as long as it’s within my wheelhouse. Just reach out. You can reach me on X at @colinxiao_lab or on Telegram at https://t.me/ColinXiao666.

This research is published for educational and defensive purposes. The target sample is anonymized throughout.


Frida in Practice: Intercepting sub_xxxx Functions

This is the third post in a hands-on Frida series, covering how to intercept sub_xxxx-style functions and how to replace and call the original.

sub_xxxx names appear in IDA because the binary has no symbols — IDA shows sub_xxxx, where xxxx is the function’s address. In the second post we got a first taste of the Interceptor, but for custom functions with stripped symbols there’s no name to hook. Let’s work through it. Define a static function add that sums its two arguments and returns the result; call it with 1 and 1, and it prints 1 + 1 = 2:

static int add(int num1, int num2){
    
    int sum = num1 + num2;
    return sum;
}

int sum = add(1, 1);
NSLog(@"1 + 1 = %d", sum);

After compiling, strip the symbols:

strip -x .../Products/Debug-iphoneos/CrackMe.app/CrackMe

Now IDA shows only sub_xxxx names — no real function name. Time to write the Frida script. get_func_addr is a helper I defined to resolve a function address: two arguments — the module name and the address seen in IDA. To find the address, I usually set the base to 0 in IDA and locate the target function; its address is the offset. Then Interceptor.attach() hooks it.

function get_func_addr(module, offset) {

   var base_addr = Module.findBaseAddress(module);
   console.log("base_addr: " + base_addr);

   console.log(hexdump(ptr(base_addr), {
            length: 16,
            header: true,
            ansi: true
        }))

   var func_addr = base_addr.add(offset);
   if (Process.arch == 'arm')
      return func_addr.add(1);  // +1 for 32-bit (Thumb) addresses
   else
      return func_addr;
}

var func_addr = get_func_addr('CrackMe', 0x6684);
console.log('func_addr: ' + func_addr);

console.log(hexdump(ptr(func_addr), {
            length: 16,
            header: true,
            ansi: true
        }))

Interceptor.attach(ptr(func_addr), {
   onEnter: function(args) {

      console.log("onEnter");
      var num1 = args[0];
      var num2 = args[1];

      console.log("num1: " + num1);
      console.log("num2: " + num2);

   },
   onLeave: function(retval) {

      console.log("onLeave");
      retval.replace(3);  // Replace the return value with 3
   }
});

Attach frida to the process and load test.js:

frida -U -l test.js CrackMe

Sometimes the target function fires immediately at process startup, and attaching injects too late. In that case, quit the process and launch it with -f — which spawns it and injects right at startup. Note the name here is the bundle ID:

frida -U -l test.js -f com.example.CrackMe

This enters interactive mode; type %resume to let the process continue (otherwise it gets force-killed shortly). To avoid typing %resume every time, add --no-pause so the process isn’t paused on launch:

frida -U -l test2.js -f com.example.CrackMe --no-pause

The script prints the base address, the bytes at the base, the add function address, and the bytes at add:

base_addr: 0x100a14000
            0  1  2  3  4  5  6  7  8  9  A  B  C  D  E  F  0123456789ABCDEF
100a14000  cf fa ed fe 0c 00 00 01 00 00 00 00 02 00 00 00  ................
func_addr: 0x100a1a684
            0  1  2  3  4  5  6  7  8  9  A  B  C  D  E  F  0123456789ABCDEF
100a1a684  ff 43 00 d1 e0 0f 00 b9 e1 0b 00 b9 e0 0f 40 b9  .C............@.

Why print those bytes? To verify the addresses. The base’s cf fa ed fe is the Mach-O magic (MH_MAGIC_64), confirming it’s the executable. Printing add’s bytes verifies against the hex machine code in IDA — if they differ, the resolved address is wrong; if they match, it’s correct. The IDA view:

Machine code in IDA (sub_6684, shown as add since symbols are kept here)

When add fires, the app prints 1 + 1 = 3, since the return value was replaced with 3.

Interceptor.attach() can’t stop the original from running. Sometimes you don’t want to run the original — or only run it when a condition holds. Use Interceptor.replace() for that. For example, replace add; the NativeFunction’s three ints are return type, first arg, second arg:

var addPtr = get_func_addr('CrackMe', 0x6684);
var add = new NativeFunction(addPtr, 'int', ['int', 'int']);

// Replace the function
Interceptor.replace(add, new NativeCallback(function(num1, num2) {

	if((num1 == 1) && (num2 == 1)){
		console.log("1+1");
	}
    // Call the original function
    return add(num1, num2);
    //return add(2, 3); // or replace the arguments
}, 'int', ['int', 'int']));

Sometimes you need to intercept system functions — say open:

var openPtr = Module.getExportByName(null, 'open');
var open = new NativeFunction(openPtr, 'int', ['pointer', 'int']);

Interceptor.replace(openPtr, new NativeCallback(function (pathPtr, flags) {
  var path = pathPtr.readUtf8String();
  console.log('Opening "' + path + '"');
  var fd = open(pathPtr, flags);
  console.log('Got fd: ' + fd);
  return fd;
}, 'int', ['pointer', 'int']));

Colin Xiao — I take on contract reverse-engineering work, and writeups like this are how I show what I can do. If you need help with anything in this space — including but not limited to the areas I cover above — I’m available. No project is too big or too small — if it’s reverse engineering and I can do it, I’ll take it. DM me on X (@colinxiao_lab) or Telegram (@ColinXiao666).

Get in touch