A Universal SVC Hook for Android Using seccomp + sigaction
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 Universal SVC Hook for Android Using seccomp + sigaction
Preface
As everyone knows, the security modules in most major apps now use self-implemented libc functions — open, read, and so on — issuing syscalls through hand-rolled svc instructions. So to hook syscalls, the usual route is to scan the vendor’s self-implemented code segment, locate the address of each svc instruction, and inline-hook it. But that approach requires modifying memory, which is easy to catch as memory tampering.
This post uses seccomp to watch syscalls instead, in order to hijack svc calls.
What is seccomp
Seccomp is a Linux kernel security module that lets a process restrict which syscalls it can make, improving the process’s security and reliability. Seccomp provides a lightweight form of process isolation — it can constrain a process’s capabilities without affecting the OS’s overall function. It’s one of the widely used security mechanisms in modern containers and virtualization.
Seccomp’s main flow is to use the prctl() syscall in the process to install a filtering rule set — the “filter” — which defines the syscall types and arguments the process is allowed to use. When the process makes a syscall, the filter intercepts it and validates it against the rule set. If the syscall doesn’t conform, seccomp rejects it and terminates the process.
Seccomp filters come in two types: global filters and thread filters. The global filter is installed at prctl() time and applies to all threads in the process. The thread filter is installed at thread creation and only applies to that thread. Usually you only need the global filter, since it can be applied to all threads at process creation.
The filter can be specified with BPF program macros in C, or with the seccomp() library function in SECCOMP_MODE_FILTER mode. Both require the filter rule set to be specified in binary form and passed to prctl() to enable it. Here’s a simple example of applying a filter globally with BPF macros:
#include <stddef.h>
#include <linux/seccomp.h>
#include <sys/prctl.h>
#define FILTER_SYSCALLS \
BPF_STMT(BPF_LD + BPF_W + BPF_ABS, offsetof(struct seccomp_data, nr)), \
BPF_JUMP(BPF_JMP + BPF_JEQ + BPF_K, __NR_getpid, 0, 1), \
BPF_STMT(BPF_RET + BPF_K, SECCOMP_RET_ALLOW), \
BPF_STMT(BPF_RET + BPF_K, SECCOMP_RET_TRACE)
int main(int argc, char *argv[]) {
struct sock_fprog filter = {
.len = sizeof(FILTER_SYSCALLS) / sizeof(FILTER_SYSCALLS[0]),
.filter = FILTER_SYSCALLS,
};
if (prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &filter) < 0) {
perror("prctl");
return 1;
}
return 0;
}
In this example, the filter only allows the getpid() syscall. If the process tries any other syscall, seccomp rejects it and terminates the process. This defines the filter with BPF macros; you can also write the equivalent with the seccomp() library function — for example, using seccomp_rule_add() to define more complex matching conditions.
A universal svc hook
The seccomp-based svc-hook writeups you can find online fall roughly into two categories:
- Using fork + ptrace to catch seccomp’s
SECCOMP_RET_TRACEsignal and hijack the svc call. This works fine on standalone executables, but in an app environment ptrace is no longer usable — it tends to trigger all sorts of exception signals, and a ptrace environment is easy for major apps’ security modules to detect. - Using Frida + seccomp: catch the
SECCOMP_RET_TRAPsignal viaProcess.setExceptionHandler. To avoid the hook recursing infinitely (an svc call inside the hook gets filtered again and re-emitsSECCOMP_RET_TRAP), that project spawns a new thread to dodge it — but this gets messy when handling multi-threaded or multi-process tasks.
The universal svc-hook approach in this post is similar to the second one, mainly catching the SECCOMP_RET_TRAP signal to implement the hook.
Intercepting the syscall signal
The seccomp filter emits SECCOMP_RET_TRAP to trigger an interrupt. Code:
struct sock_filter filter[] = {
BPF_STMT(BPF_LD + BPF_W + BPF_ABS, offsetof(struct seccomp_data, nr)),//read the syscall number
BPF_JUMP(BPF_JMP + BPF_JEQ + BPF_K, __NR_openat, 0, 1), //check whether it equals __NR_openat
BPF_STMT(BPF_RET + BPF_K, SECCOMP_RET_TRAP), //if so, trigger the SECCOMP_RET_TRAP signal
BPF_STMT(BPF_RET + BPF_K, SECCOMP_RET_ALLOW), //if not, allow.
};
struct sock_fprog prog;
prog.filter = filter2;
prog.len = (unsigned short) (sizeof(filter) / sizeof(filter[0]));
if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) == -1) {
perror("prctl(PR_SET_NO_NEW_PRIVS)");
return 1;
}
if (prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &prog) == -1) {
perror("when setting seccomp filter");
return 1;
}
seccomp_data is a struct passed to the BPF program as an argument to specify the syscall. Its definition lives in linux/seccomp.h, typically:
struct seccomp_data {
int nr;
__u32 arch;
__u64 instruction_pointer;
__u64 args[6];
};
The fields mean:
nr: the syscall number — the number of the syscall currently being invoked, generally between 0 andMAX_SYSCALL.arch: the processor architecture.instruction_pointer: the instruction pointer, pointing at the address of the instruction being executed.args: the syscall arguments — the values of up to six arguments are stored here.
When using a seccomp filter, these members let you access and manipulate the process’s syscalls, filtering or modifying them to restrict specific syscalls.
Signal handling
When seccomp emits SECCOMP_RET_TRAP, it triggers the program’s blocking mechanism; the system raises a SIGSYS signal and puts the original program in a temporary blocked state. So we can register a SIGSYS handler with sigaction, return the processed result, and achieve the hook. Code:
struct sigaction sa;
sigset_t sigset;
sigfillset(&sigset);
sa.sa_sigaction = sig_handler;
sa.sa_mask = sigset;
sa.sa_flags = SA_SIGINFO;
if (sigaction(SIGSYS, &sa, NULL) == -1) {
LOGE("sigaction init failed.\n");
return 1;
}
void sig_handler(int signo, siginfo_t *info, void *data) {
int my_signo = info->si_signo;
printf("sig_handler2, signo: %x\n", my_signo);
unsigned long sysno = ((ucontext_t *) data)->uc_mcontext.arm_r7;
unsigned long arg0 = ((ucontext_t *) data)->uc_mcontext.arm_r0;
unsigned long arg1 = ((ucontext_t *) data)->uc_mcontext.arm_r1;
unsigned long arg2 = ((ucontext_t *) data)->uc_mcontext.arm_r2;
unsigned long arg3 = ((ucontext_t *) data)->uc_mcontext.arm_r3;
unsigned long arg4 = ((ucontext_t *) data)->uc_mcontext.arm_r4;
}
Avoiding the infinite loop
In the example above we want to hook the openat svc, so inside sig_handler we need to call openat again with the modified arguments, then return the result to the main process so it can continue.
But there’s a problem: calling openat inside sig_handler passes through the seccomp filter again, re-triggering SECCOMP_RET_TRAP, re-entering sig_handler… an infinite loop.
So we need to refine the filter so it can tell which svc calls come from sig_handler and which come from the main process itself. There are a few approaches:
seccomp_data’sinstruction_pointeris the return address after the svc call. We can use our own self-implemented svc insidesig_handler, and have the filter check whether the return address is inside our svc function.- Simpler: when
sig_handlermakes its svc call, pass an extraMAGICargument, and have the filter check whether that extra argument equalsMAGIC— if so, useSECCOMP_RET_ALLOW, otherwise triggerSECCOMP_RET_TRAP. The filter:
#define SECMAGIC 0xdeadbeef
struct sock_filter filter2[] = {
BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, nr)),
BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_close, 0, 2),
BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, args[2])),
BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, SECMAGIC, 0, 1),
BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW),
BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_TRAP)
};
The handling logic in sig_handler:
#define SECMAGIC 0xdeadbeef
void sig_handler(int signo, siginfo_t *info, void *data) {
int my_signo = info->si_signo;
LOGD("sig_handler2, signo: %x\n", my_signo);
printRegs(data);
unsigned long sysno = ((ucontext_t *) data)->uc_mcontext.arm_r7;
unsigned long arg0 = ((ucontext_t *) data)->uc_mcontext.arm_r0;
unsigned long arg1 = ((ucontext_t *) data)->uc_mcontext.arm_r1;
unsigned long arg2 = ((ucontext_t *) data)->uc_mcontext.arm_r2;
unsigned long arg3 = ((ucontext_t *) data)->uc_mcontext.arm_r3;
unsigned long arg4 = ((ucontext_t *) data)->uc_mcontext.arm_r4;
switch (sysno) {
case __NR_close:
LOGD("[close]fd: %ld\n", arg0);
int close_fd;
close_fd = syscall(__NR_close, arg0, 0, SECMAGIC);
LOGD("[close]close ret: %d\n", close_fd);
((ucontext_t *) data)->uc_mcontext.arm_r0 = close_fd;
break;
default:
break;
}
}
Questions are welcome — comment below.
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