A ptrace+seccomp Sandbox for Inline SVC Hooks

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.


A ptrace+seccomp Sandbox for Inline SVC Hooks

A couple of years ago I needed a sandbox for work. Hooking libc handles the ordinary Linux I/O functions — hook openat and most files can be processed (VirtualApp-style I/O redirection). But one thing bugged me for half a year: how to intercept and handle inline SVC instructions / raw syscalls.

Big apps, for their own protection, make heavy use of inline SVC to call system functions — precisely so they can’t be hooked. So the real problem became: how do you redirect I/O done through a raw SVC instruction? Material on this was scarce; most of it covers the basics rather than how to actually hook and modify, and the other common approach is rebuilding the OS from source. That doesn’t work here, because big apps use their own real-device libraries — mostly Google-series devices, which get flagged as risky devices quickly. You can mock another mainstream phone model by changing device properties, but that has a downside: some apps call vendor services (some vendors ship their own system services), and those can’t be mocked, which easily crashes the app. After a lot of Googling, asking around, and reading code, I settled on a mature approach: ptrace + seccomp. The two are inseparable.

Background

What is an SVC instruction? What is a syscall?

In Linux, memory is split into user space and kernel space. Code you write runs in user space; when Linux handles files or performs interrupt-driven I/O, it enters kernel space.

A syscall is the entry point into kernel space. The syscall function is a small piece of assembly that issues the SVC instruction.

When an ARM CPU encounters an SVC instruction, it traps — the classic 0x80 interrupt — and begins executing kernel logic. The program pauses while the kernel does its work, which is how the kernel stays safe from user-space interference.

The Linux kernel exposes many functions — the common file functions like openat and execve — all callable via the SVC instruction, differing only in the syscall number and arguments.

A function executed through SVC can’t be inline-hooked, which is exactly why it raises the security bar.

To summarize: SVC is an ARM instruction; the syscall function is a libc function whose implementation uses the SVC instruction.

The 32-bit and 64-bit syscall implementations:

32-bit:

raw_syscall:
        MOV             R12, SP
        STMFD           SP!, {R4-R7}
        MOV             R7, R0
        MOV             R0, R1
        MOV             R1, R2
        MOV             R2, R3
        LDMIA           R12, {R3-R6}
        SVC             0
        LDMFD           SP!, {R4-R7}
        mov             pc, lr

64-bit:

raw_syscall:
        MOV             X8, X0
        MOV             X0, X1
        MOV             X1, X2
        MOV             X2, X3
        MOV             X3, X4
        MOV             X4, X5
        MOV             X5, X6
        SVC             0
        RET

What are ptrace and seccomp?

ptrace

ptrace is Linux’s debugging interface — IDA, LLDB, and other debuggers are built on it. It has many actions, each a feature: injecting into a process, pausing it, modifying registers, and so on.

Injecting into your own process doesn’t need root; injecting into another process does. You just pick a pid.

ptrace can set a breakpoint anywhere in memory and modify the data at any location. Its privileges are very high — it can even debug kernel space — so it’s also useful for handling SVC arguments and return values.

The official API reference:

https://man7.org/linux/man-pages/man2/ptrace.2.html

seccomp

Seccomp is a Linux security mechanism; Android 8.1 and above use it. Its main job is to restrict calling certain system functions directly through syscall. A seccomp-enabled process takes an exception callback when it hits a restricted syscall.

There are two filtering modes, strict and filter.

strict

Strict mode only allows four syscalls: read, write, exit, rt_sigreturn. Using any other syscall triggers SIGKILL.

#include <fcntl.h>
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <linux/seccomp.h>
#include <sys/prctl.h>

int main(int argc, char **argv)
{
int output = open("output.txt", O_WRONLY);
const char *val = "test";
// set seccomp to strict mode via prctl
printf("Calling prctl() to set seccomp strict mode...\n");

prctl(PR_SET_SECCOMP, SECCOMP_MODE_STRICT);

printf("Writing to an already open file...\n");
// try to write
write(output, val, strlen(val)+1);
printf("Trying to open file for reading...\n");

// open again after enabling seccomp (strict mode kills this line with signal -9)
int input = open("output.txt", O_RDONLY);
printf("You will not see this message — the process will be killed first\n");
}
filter (BPF)

Also called seccomp-bpf. BPF filtering only exists on newer Linux kernels. When a process issues an SVC, if the syscall number matches one we’re filtering, execution first enters our own BPF rules, which decide whether the syscall is allowed and how to handle it. The common instructions:

BPF_LD, BPF_LDX   load instructions
BPF_ST, BPF_STX   store instructions
BPF_ALU           arithmetic instructions
BPF_JMP           jump instructions
BPF_RET           return instructions (terminators)
BPF_MISC          misc instructions

Instructions can be added or subtracted to build a JUMP — it gets complicated, so I’ll skip the details. If you want the full picture, the book Linux Observability with BPF covers BPF rules in depth, including how to combine them with seccomp to intercept and trace syscalls.

The build

ptrace alone

Following the idea above, my first version used ptrace to modify SVC arguments and return values. First fork a thread to trace the main process, then run an endless loop. With ptrace you must distinguish the tracer and the tracee — two different threads.

/*
 * use a forked process to attach to the main process
 */
int trace_current_process(int sdkVersion) {
    ALOGE("start trace_current_process ");
    prctl(PR_SET_DUMPABLE, 1, 0, 0, 0);
    mainProcessPid = getpid();
    pid_t child = fork();
    if (child < 0) {
        ALOGE("ptrace svc  fork() error ");
        return -errno;
    }
    //init first tracer
    Tracer *first = get_tracer(nullptr, mainProcessPid, true);
    if (child == 0) {
        // attch main pid
        int status = ptrace(PTRACE_ATTACH, mainProcessPid, NULL, NULL);
        if (status != 0) {
            //attach failed
            ALOGE(">>>>>>>>> error: attach target process %d ", status);
            return -errno;
        }
        first->wait_sigcont = true;
        // run the endless loop; since it keeps listening, this process should never exit
        exit(event_loop());
    } else {
        //init seccomp by main process
        //the seccomp filtering rule is intended only for the current process
        enable_syscall_filtering(first);
    }
    return 0;
}

When an SVC callback fires — (SIGTRAP | 0x80) — the tracer runs its logic while the tracee waits. The tracer attaches via ptrace and runs a while true loop, listening for the tracee’s state via waitpid and waiting for the tracee to enter the designated callback.

while (true) {
        int tracee_status;
        Tracer *tracee;
        int signal;
        pid_t pid;
        free_terminated_tracees();
        //-1 all thread
        pid = waitpid(-1, &tracee_status, 0);
        if (pid < 0) {
            ALOGE(">>>>>>>>>> !!!!! error: waitpid() %d  %s", pid, strerror(errno))
            if (errno != ECHILD) {
                return EXIT_FAILURE;
            }
            break;
        }
        tracee = get_tracer(nullptr, pid, true);
        assert(tracee != nullptr);

        //handle action
        signal = handle_tracee_event(tracee, tracee_status);
        //restart
        (void) restart_tracee(tracee, signal);
    }
    ALOGE("<<<<<<<<<<<< listening was error ,main listener stop !!")
    return last_exit_status;
}

The main loop handles several states — normal exit, signal exit, and stopped (e.g. entering a syscall):

if (WIFEXITED(tracee_status)) {
        //WEXITSTATUS gets the exit code returned by the child's exit()
        last_exit_status = WEXITSTATUS(tracee_status);
        //ALOGI("normal exit -> [%d] exit with status: %d  ", tracee->pid, tracee_status);
        //the tracee exited normally; release it
        terminate_tracee(tracee);
  } else if (WIFSIGNALED(tracee_status)) {
        int signNum = WTERMSIG(tracee_status);
        //the tracee exited due to a signal
        ALOGE("[%d]  process exit with signal: signal = %d   reason = %s ",
              tracee->pid,
              signNum,
              strsignal(signNum)
        )
        terminate_tracee(tracee);
  } else if (WIFSTOPPED(tracee_status)) {
        signal = (tracee_status & 0xfff00) >> 8;
        switch (signal) {
            //svc
            case SIGTRAP | 0x80:
            //the tracee called svc; handle the arguments and return value
            ...

Once the arguments or return value are handled, the tracer uses ptrace to set the tracee to restart on PTRACE_SYSCALL events. (When the tracee hits certain SIGTRAP events it pauses, and the tracer handles the logic.) There are two common resume events, PTRACE_SYSCALL and PTRACE_CONT — roughly “single-step execution” vs “continue”. Restarting the tracee with PTRACE_SYSCALL means the next SVC before/after will pause again.

In other words, the tracee pauses both before and after each SVC executes.

First, match the registers for each architecture to distinguish LR, SP, PC, and the rest:

#elif defined(ARCH_ARM_EABI)
static off_t reg_offset[] = {
        [SYSARG_NUM]    = USER_REGS_OFFSET(uregs[7]),
        [SYSARG_1]      = USER_REGS_OFFSET(uregs[0]),
        [SYSARG_2]      = USER_REGS_OFFSET(uregs[1]),
        [SYSARG_3]      = USER_REGS_OFFSET(uregs[2]),
        [SYSARG_4]      = USER_REGS_OFFSET(uregs[3]),
        [SYSARG_5]      = USER_REGS_OFFSET(uregs[4]),
        [SYSARG_6]      = USER_REGS_OFFSET(uregs[5]),
        [SYSARG_RESULT] = USER_REGS_OFFSET(uregs[0]),
        [FRAME_POINTER] = USER_REGS_OFFSET(uregs[12]),
        [STACK_POINTER] = USER_REGS_OFFSET(uregs[13]),
        [LINK_REGISTER] = USER_REGS_OFFSET(uregs[14]),
        [INSTR_POINTER] = USER_REGS_OFFSET(uregs[15]),
        [USERARG_1]     = USER_REGS_OFFSET(uregs[0]),

};
#elif defined(ARCH_ARM64)
#undef  USER_REGS_OFFSET
#define USER_REGS_OFFSET(reg_name) offsetof(struct user_regs_struct, reg_name)

static off_t reg_offset[] = {
[SYSARG_NUM]    = USER_REGS_OFFSET(regs[8]),
[SYSARG_1]      = USER_REGS_OFFSET(regs[0]),
[SYSARG_2]      = USER_REGS_OFFSET(regs[1]),
[SYSARG_3]      = USER_REGS_OFFSET(regs[2]),
[SYSARG_4]      = USER_REGS_OFFSET(regs[3]),
[SYSARG_5]      = USER_REGS_OFFSET(regs[4]),
[SYSARG_6]      = USER_REGS_OFFSET(regs[5]),
[SYSARG_RESULT] = USER_REGS_OFFSET(regs[0]),
//https://zhuanlan.zhihu.com/p/42486116
//http://blog.chinaunix.net/uid-25564582-id-5852920.html
[FRAME_POINTER]     = USER_REGS_OFFSET(regs[29]),
//in 64-bit, reg 30 is the LR register
[LINK_REGISTER]     = USER_REGS_OFFSET(regs[30]),
[STACK_POINTER] = USER_REGS_OFFSET(sp),
[INSTR_POINTER] = USER_REGS_OFFSET(pc),
[USERARG_1]     = USER_REGS_OFFSET(regs[0]),
};

Now we can read the register contents and decide whether the path is one we want to redirect:

/**
 * Return the *cached* value of the given @Tracers' @reg.
 * Returns the cached value of the given @Tracers' @reg.
 */
word_t peek_reg(const Tracer *Tracer, RegVersion version, Reg reg) {
    word_t result;

    assert(version < NB_REG_VERSION);

    result = REG(Tracer, version, reg);

    /* Use only the 32 least significant bits (LSB) when running
     * 32-bit processes on a 64-bit kernel.  */
    if (is_32on64_mode(Tracer))
        result &= 0xFFFFFFFF;

    return result;
}

/**
 * Set the *cached* value of the given @Tracers' @reg.
 *
 * Modify the register contents; value is the pointer.
 */
void poke_reg(Tracer *Tracer, Reg reg, word_t value) {
    // check equality before setting
    if (peek_reg(Tracer, CURRENT, reg) == value)
        // if equal, return immediately
        return;

    REG(Tracer, CURRENT, reg) = value;
    // mark it as modified
    Tracer->_regs_were_changed = true;
}

Save the modified register contents into an array, then set the registers with the PTRACE_SETREGSET action:

regs.iov_base = &current_sysnum;
regs.iov_len = sizeof(current_sysnum);

status = ptrace(PTRACE_SETREGSET, Tracer->pid, NT_ARM_SYSTEM_CALL, &regs);
if (status < 0) {
	//note(Tracer, WARNING, SYSTEM, "can't set the syscall number");
	return status;
}

Modifying the tracee’s registers is how we change the SVC arguments and return result. This does work for modifying SVC arguments and return values — but it has a problem.

It’s too slow. The tracer and tracee are two separate threads that communicate via inter-thread messaging, and the attached process pauses constantly — even libc’s own SVC calls cause pauses, making the whole thing laggy. Solving that took a long time and a lot of digging.

seccomp + ptrace

To fix the slowness, I studied open-source frameworks. strace also uses ptrace to trace syscalls — a tool that prints every syscall, file-related and network-related alike, and it works on Android too. How does strace solve it? With seccomp + ptrace: we only need to watch the functions we care about — the common I/O functions like access, openat, open, fstat — and ignore everything else.

After seccomp is initialized, we just add the PTRACE_O_TRACESECCOMP flag to the ptrace PTRACE_SETOPTIONS call:

const unsigned long default_ptrace_options = (
        PTRACE_O_TRACESYSGOOD|
        PTRACE_O_TRACEFORK |
        PTRACE_O_TRACEVFORK |
        PTRACE_O_TRACEVFORKDONE |
        PTRACE_O_TRACEEXEC |
        PTRACE_O_TRACECLONE |
        PTRACE_O_TRACEEXIT);

// try to enable ptrace+seccomp
status = ptrace(PTRACE_SETOPTIONS, tracee->pid, NULL,default_ptrace_options | PTRACE_O_TRACESECCOMP);

Now when the target app calls one of our intercepted syscalls, it hits this case:

case SIGTRAP | PTRACE_EVENT_SECCOMP << 8:

Setting parameters inside this case works fine — but a new problem appears: seccomp can only handle the SVC “before”, i.e. it enters this case before the SVC executes, not after.

But modifying the SVC return value must happen in the “after” phase. Why do we need the return value at all? File redirection only needs the arguments, right?

The arguments can be handled in the before phase, so why bother with after?

Answer: fingerprint mocking needs the after phase. The common socket functions — recv, recvfrom, recvmsg — fill their data into a buffer only after the original function returns. In the before phase that buffer is still NULL; only after execution does it hold the data. A good example is fetching device fingerprints over netlink, which I covered before. This is a standard big-app pattern, and mocking that fingerprint is hard — especially when it’s fetched via inline SVC. But with ptrace it’s simple: read the data with peek_reg, process it, and write it back with poke_reg.

void NetlinkMacHandler::netlinkHandler_recv(Tracer *tracee) {
    ssize_t bytes_read = TEMP_FAILURE_RETRY(peek_reg(tracee, CURRENT, SYSARG_RESULT));
    if (bytes_read > 0) {
        word_t buff = peek_reg(tracee, CURRENT, SYSARG_2);
        //buffer length
        auto size = (size_t) peek_reg(tracee, CURRENT, SYSARG_3);
        char tempBuff[size];
        int readStr_ret = read_data(tracee, tempBuff, buff, size);
        if (readStr_ret != 0) {
            LOGE("svc netlink handler read_string error  %s", strerror(errno))
            return;
        }
        auto *hdr = reinterpret_cast<nlmsghdr *>(tempBuff);
        //netlink packet structure
        NetlinkMacHandler::handler_mac_callback_svc(tracee,hdr, bytes_read);
        //overwrite the original data with the new data
        write_data(tracee, buff, tempBuff, size);
    }
}

The “can’t handle after” problem stalled me for months. Then I found an excellent open-source project through the VirtualApp author: proot.

https://github.com/proot-me/proot

proot’s solution is one line — genius really is about inspiration:

poke_reg(tracee, STACK_POINTER, peek_reg(tracee, ORIGINAL, STACK_POINTER));

Modify the SP register to make the function enter twice — once to change the arguments, once to change the return result.

A quick word on proot, since it fits our needs exactly and its logic is the same. Linux has chroot, which changes the root directory but requires root. proot is a compiled binary that lets you restrict and use a directory — say /data/zhenxi/ as the root instead of / — without root, using the same ptrace + seccomp technique to replace and handle the executed file paths. The code I showed above all comes from proot. What I described is just a small part of proot; its more important capability is building sandboxed file restrictions with ptrace + seccomp.

Once that’s solved, the next question is injection: how to get the interception and modification logic into the target app. Because of how ptrace works, attach is root-free only for the current process.

Injection methods

Xposed-injected .so

Non-invasive: no need to modify the APK signature. Just attach the current thread in onLoad, and you get SVC modification and mocking across all processes — file monitoring, detection bypass, and so on.

Repackaging

Invasive, but it lets you attach without root: pack the .so directly into the APK and load it. The downside is you have to bypass the signature check — which is easy here. Just use SVC I/O redirection to place the original APK in a private path, and when the app reads /data/app/<package>/base.apk, swap the argument to the original APK path. This still works against enterprise shell repackaging detection.

Use cases

File-read monitoring & compliance checks

Many apps read other apps’ private directories — traversing /data/data/xxx/, reading files on the SD card — which is non-compliant or a security risk. Printing the paths being read via SVC file monitoring lets you quickly analyze whether an app is compliant and where the risks are. The output shows exactly which files are read:

2022-06-04 15:31:06.910 13927-13960/  I/Zhenxi: io sandbox  /vendor/lib64/hw/ -> /vendor/lib64/hw/
2022-06-04 15:31:06.910 13951-13951/? I/Zhenxi: io sandbox  /vendor/lib64/hw/ -> /vendor/lib64/hw/
2022-06-04 15:31:06.910 13927-13960/  I/Zhenxi: io sandbox  /vendor/lib64/hw/android.hardware.graphics.mapper@4.0-impl-qti-display.so -> /vendor/lib64/hw/android.hardware.graphics.mapper@4.0-impl-qti-display.so
2022-06-04 15:31:06.910 13927-13960/  I/Zhenxi: io sandbox  /vendor/lib64/hw/android.hardware.graphics.mapper@4.0-impl-qti-display.so -> /vendor/lib64/hw/android.hardware.graphics.mapper@4.0-impl-qti-display.so
2022-06-04 15:31:06.911 13951-13951/? I/Zhenxi: io sandbox  /vendor/lib64/hw/android.hardware.graphics.mapper@4.0-impl-qti-display.so -> /vendor/lib64/hw/android.hardware.graphics.mapper@4.0-impl-qti-display.so
2022-06-04 15:31:06.911 13951-13951/? I/Zhenxi: io sandbox  /proc/self/fd/109 -> /proc/self/fd/109
2022-06-04 15:31:06.911 13927-13960/  I/Zhenxi: io sandbox  /proc/self/maps -> /proc/self/maps
2022-06-04 15:31:06.911 13951-13951/? I/Zhenxi: io sandbox  /data/user/0/ /app_virtual_devices/START_UP_0/data/nativeCache/dev_maps_13951_13951 -> /data/user/0/ /app_virtual_devices/START_UP_0/data/nativeCache/dev_maps_13951_13951
2022-06-04 15:31:06.916 13927-13960/  I/Zhenxi: io sandbox  libadreno_utils.so -> libadreno_utils.so
2022-06-04 15:31:06.916 13927-13960/  I/Zhenxi: io sandbox  /proc/self/maps -> /proc/self/maps
2022-06-04 15:31:06.916 13951-13951/? I/Zhenxi: io sandbox  /data/user/0/ /app_virtual_devices/START_UP_0/data/nativeCache/dev_maps_13951_13951 -> /data/user/0/ /app_virtual_devices/START_UP_0/data/nativeCache/dev_maps_13951_13951
2022-06-04 15:31:06.930 13927-13960/  I/Zhenxi: io sandbox  /data/user_de/0/ /code_cache/com.android.opengl.shaders_cache -> /data/user/0/ /app_virtual_devices/START_UP_0/user_de/code_cache/com.android.opengl.shaders_cache
2022-06-04 15:31:06.930 13951-13951/? I/Zhenxi: io sandbox  /data/user/0/ /app_virtual_devices/START_UP_0/user_de/code_cache/com.android.opengl.shaders_cache -> /data/user/0/ /app_virtual_devices/START_UP_0/user_de/code_cache/com.android.opengl.shaders_cache
2022-06-04 15:31:06.961 13927-13960/  I/Zhenxi: io sandbox  libboost.so -> libboost.so
2022-06-04 15:31:06.962 13927-13960/  I/Zhenxi: io sandbox  /system/lib64/libboost.so -> /system/lib64/libboost.so
2022-06-04 15:31:06.962 13951-13951/? I/Zhenxi: io sandbox  /system/lib64/libboost.so -> /system/lib64/libboost.so
2022-06-04 15:31:06.962 13951-13951/? I/Zhenxi: io sandbox  /proc/self/fd/110 -> /proc/self/fd/110
2022-06-04 15:31:06.962 13927-13960/  I/Zhenxi: io sandbox  /proc/self/maps -> /proc/self/maps
2022-06-04 15:31:06.962 13951-13951/? I/Zhenxi: io sandbox  /data/user/0/ /app_virtual_devices/START_UP_0/data/nativeCache/dev_maps_13951_13951 -> /data/user/0/ /app_virtual_devices/START_UP_0/data/nativeCache/dev_maps_13951_13951
2022-06-04 15:31:06.967 13927-13960/  I/Zhenxi: io sandbox  /proc/13927/cmdline -> /proc/13927/cmdline
2022-06-04 15:31:06.967 13951-13951/? I/Zhenxi: io sandbox  /proc/13927/cmdline -> /proc/13927/cmdline
2022-06-04 15:31:06.967 13927-13960/  I/Zhenxi: io sandbox  /data/system/migt/migt -> /data/system/migt/migt
2022-06-04 15:31:06.967 13951-13951/? I/Zhenxi: io sandbox  /data/system/migt/migt -> /data/system/migt/migt

... ...

Passing environment checks

Root & Magisk

Every file read ultimately goes through an SVC call, so we just write a sandbox that “passes” every suspicious key directory. When the target app reads such a path, we point it at a non-existent path instead:

    else if (strstr(result, "magisk")) {
        //drop anything containing magisk
        result = NULL;
    } else if (strstr(result, "edxposed")) {
        result = NULL;
    } else if (strstr(result, "edxp")) {
        result = NULL;
    }else if (strstr(result, "lsposed")) {
        result = NULL;
    } else if (strstr(result, "libriru") || strstr(result, "/riru")) {
        result = NULL;
    } else if (strstr(result, "sandhook")) {
        result = NULL;
    } else if (endsWith(result, "/su")) {
        //drop root files ending in su
        result = NULL;
    }else if (strstr(result, "zygisk")) {
        result = NULL;
    }else if (strstr(result, "/data/adb/")) {
        //this directory contains a lot of magisk-related files, e.g. the module list /data/adb/modules/
        //https://github.com/LSPosed/NativeDetector/blob/master/app/src/main/jni/activity.cpp
        result = NULL;
    }

That’s not enough, though — /proc/mounts also has a pile of Magisk signatures. Generate a clean copy ahead of time, and when a read of a path we redirect is detected, swap it to our generated file.

Debug check

Anti-debug files like stat, status, wchan are also mocked ahead of time (before the ptrace attach) and then I/O-redirected to the newly generated file paths.

Maps check

maps I/O redirection can’t be generated ahead of time — it must be created right before the target app reads it, because maps changes constantly. Create it in the SVC “before” phase from the tracer, then modify the tracee’s read path.

App signature

Signature checks are mostly Java-layer or native-layer. Java-layer: hook the methods that fetch the signature (and remember to set the in-memory variable via reflection too — you can’t just hook the method; this needs the Android 9.0 reflection restriction bypass, which the LSPosed codebase shows how to do).

Native-layer checks mostly use SVC openat to read the file, then unzip the APK and compute its signature. Bypassing this is easy: when it reads /data/app/xxx/base.apk, point it at the original package instead.

Sandbox polish & practice

I often need to analyze a .so to see which files it reads, what it does, and which Java methods it calls. I used to like unidbg, but its pain point is environment stubbing — many .so files call high-version APIs, and back then it only supported SDK 23 and 26, so stubbing was exhausting, especially for .so files that scan lots of system files unidbg doesn’t have. Better to just run the .so directly on a real Android system.

Start with a hello-world, launch our ptrace attach first, then combine it with SandHook and jnitraceforcpp:

https://github.com/w296488320/JnitraceForCpp

This jnitraceforcpp isn’t frida’s jnitrace — I wrote it in my spare time. It’s not many lines, but it hooks every method in the jniEnv, so whatever the target calls, we can print it. The hooks look like this:

 HOOK_JNI(env, CallObjectMethodV)
 HOOK_JNI(env, CallBooleanMethodV)
 HOOK_JNI(env, CallByteMethodV)
 HOOK_JNI(env, CallCharMethodV)
 HOOK_JNI(env, CallShortMethodV)
 HOOK_JNI(env, CallIntMethodV)
 HOOK_JNI(env, CallLongMethodV)
 HOOK_JNI(env, CallFloatMethodV)
 HOOK_JNI(env, CallDoubleMethodV)
 HOOK_JNI(env, CallVoidMethodV)

 HOOK_JNI(env, CallStaticObjectMethodV)
 HOOK_JNI(env, CallStaticBooleanMethodV)
 HOOK_JNI(env, CallStaticByteMethodV)
 HOOK_JNI(env, CallStaticCharMethodV)
 HOOK_JNI(env, CallStaticShortMethodV)
 HOOK_JNI(env, CallStaticIntMethodV)
 HOOK_JNI(env, CallStaticLongMethodV)
 HOOK_JNI(env, CallStaticFloatMethodV)
 HOOK_JNI(env, CallStaticDoubleMethodV)
 HOOK_JNI(env, CallStaticVoidMethodV)

 HOOK_JNI(env, GetObjectField)
 HOOK_JNI(env, GetBooleanField)
 HOOK_JNI(env, GetByteField)
 HOOK_JNI(env, GetCharField)
 HOOK_JNI(env, GetShortField)
 HOOK_JNI(env, GetIntField)
 HOOK_JNI(env, GetLongField)
 HOOK_JNI(env, GetFloatField)
 HOOK_JNI(env, GetDoubleField)

 HOOK_JNI(env, GetStaticObjectField)
 HOOK_JNI(env, GetStaticBooleanField)
 HOOK_JNI(env, GetStaticByteField)
 HOOK_JNI(env, GetStaticCharField)
 HOOK_JNI(env, GetStaticShortField)
 HOOK_JNI(env, GetStaticIntField)
 HOOK_JNI(env, GetStaticLongField)
 HOOK_JNI(env, GetStaticFloatField)
 HOOK_JNI(env, GetStaticDoubleField)
 //common string functions
 HOOK_JNI(env, NewStringUTF)
 HOOK_JNI(env, GetStringUTFChars)
 //HOOK_JNI(env, FindClass)
 HOOK_JNI(env, ToReflectedMethod)
 HOOK_JNI(env, FromReflectedMethod)
 HOOK_JNI(env, GetFieldID)
 HOOK_JNI(env, GetStaticFieldID)
 HOOK_JNI(env, NewObjectV)

And some common string functions:

    HOOK_SYMBOL_DOBBY(handle, strstr);
    HOOK_SYMBOL_DOBBY(handle, strcmp);
    HOOK_SYMBOL_DOBBY(handle, strcpy);
    HOOK_SYMBOL_DOBBY(handle, strdup);
    HOOK_SYMBOL_DOBBY(handle, strxfrm);
//    HOOK_SYMBOL_DOBBY(handle, memcpy);

//    HOOK_SYMBOL_DOBBY(handle, sprintf);
//    HOOK_SYMBOL_DOBBY(handle, printf);
//    HOOK_SYMBOL_DOBBY(handle, snprintf);
//    HOOK_SYMBOL_DOBBY(handle, vsnprintf);

Hook all of these, then load the target .so — what it calls and what it does becomes obvious. Best of all, no environment stubbing needed, so analysis is fast: just hook whatever you need to handle.

Fingerprint countermeasures

Big apps fetch device fingerprints. The Java-layer methods are straightforward — just hook them. The core is in the native layer: system_property_get and read, boot_id, UUID, the reflected in-memory Android ID, the MAC via netlink, and inline SVC reads of files that also expose the network card, such as:

/sys/class/net/wlan0/address and /sys/devices/virtual/net/wlan0/address

plus build.prop, popen reads of devices like /sys/devices/soc0/serial_number, and execve calls that gather device info. All of these are easy to mock and pass with SVC I/O redirection — when a read happens, generate a fresh copy and point it at the newly generated file.

Traceless hooking

Most native hooking today is inline hook, GOT, or exception hooking (signal-handler hijacking). Each has merits, but all have signatures. Inline hook is easily caught by CRC checks, and many big apps bypass it with shellcode — we can rewrite an instruction to jump to a function, so they can rewrite it back. All they need is the original instruction, which they can get by parsing the local .so file, extracting the real instruction info from the text section, saving it, and setting it back into memory before the function runs — or even pulling the correct instruction from a server. Countering that isn’t impossible either: after hooking, set the memory read-only, non-writable, and hook mprotect so it can’t be called — that defeats the shellcode approach.

But let’s get to the main point: the traceless hook. ptrace has an important feature — setting breakpoints. Set a breakpoint at a chosen address, and when execution reaches it, use ptrace to modify the PC register and jump to the target function, passing the arguments along.

Because the modification happens only at execution time, and it’s a register change rather than an instruction change, there’s no instruction to CRC-check. It’s stable on 64-bit. On 32-bit there are still problems — many 32-bit programs use 64-bit syscall numbers and keep reporting “bad syscall num” — a pitfall I haven’t filled in yet. Fortunately most apps are 64-bit.

Closing

This got long — thanks for reading to the end. Most of the core SVC-interception code above can be found in the proot project, and needs porting to Android.


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