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:

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