Abyss: Intercepting SVC Syscalls on Android with BPF and Seccomp

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.

Abyss: Intercepting SVC Syscalls on Android with BPF and Seccomp

From top to bottom on Android — with no root, no bootloader unlock, no flashing — the last piece of an application-level interception framework: SVC syscall interception.

GitHub: https://www.github.com/iofomo/abyss

Abyss overview

Our virtualization product has to run on ordinary Android phones, so we needed an app-level interception framework that spans every layer, top to bottom. Abyss, as the interception layer for the system SVC instruction, is our ultimate bottom-layer solution.

Source: https://github.com/iofomo/abyss/tree/main/svcer

01. Background

Seccomp (Secure Computing Mode):

Seccomp is a Linux kernel security feature for restricting the syscalls a process may execute. It filters syscalls to prevent malicious programs from performing dangerous operations. Seccomp is usually combined with BPF to express more flexible filter rules.

BPF (Berkeley Packet Filter):

BPF is a kernel technology originally built for network packet filtering and later extended to a much wider set of uses, including syscall filtering. A BPF program runs in the kernel, where it can inspect and filter syscalls.

02. Main flow

First, configure the BPF rules. Below we set up interception rules for the target syscall numbers and pass everything else through — this lets us intercept only the syscalls we care about (i.e. the functions), improving both interception efficiency and stability.

static void doInitSyscallNumberFilter(struct sock_filter* filter, unsigned short& i) {
    // Load syscall number into accumulator
    filter[i++] = BPF_STMT(BPF_LD | BPF_W | BPF_ABS, (offsetof(struct seccomp_data, nr)));
    // config target syscall
    // add more syscall here ...
//    filter[i++] = BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_openat, 5, 0);
//    filter[i++] = BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_getcwd, 4, 0);
//    filter[i++] = BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_chdir, 3, 0);
//    filter[i++] = BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_execve, 2, 0);
    filter[i++] = BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_openat, 1, 0);

    filter[i++] = BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW);
}

Then we need to filter out some system libraries and our own library, to avoid a write dead-loop:

  • Filtering our own implementation library [required]
  • Filtering vdso [required]
  • Filtering linker [optional, performance]
  • Filtering libc [optional, performance]

We parse the library address ranges from the process maps and configure rules that skip syscalls originating from those ranges.

static void doInitSyscallLibFilterByAddr(struct sock_filter* filter, unsigned short& i, const uintptr_t& start, const uintptr_t& end) {
    // Load syscall lib into accumulator
#if defined(__arm__)
    filter[i++] = BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, instruction_pointer));
    filter[i++] = BPF_JUMP(BPF_JMP | BPF_JGE | BPF_K, start, 0, 2);
    filter[i++] = BPF_JUMP(BPF_JMP | BPF_JGE | BPF_K, end, 1, 0);
    filter[i++] = BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW);
#else // __aarch64__
    filter[i++] = BPF_STMT(BPF_LD | BPF_W | BPF_ABS, (offsetof(struct seccomp_data, instruction_pointer) + 4));
    filter[i++] = BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, (uint32_t)(start >> 32), 0, 4);
    filter[i++] = BPF_STMT(BPF_LD | BPF_W | BPF_ABS, (offsetof(struct seccomp_data, instruction_pointer)));
    filter[i++] = BPF_JUMP(BPF_JMP | BPF_JGE | BPF_K, (uint32_t)start, 0, 2);
    filter[i++] = BPF_JUMP(BPF_JMP | BPF_JGE | BPF_K, (uint32_t)end, 1, 0);
    filter[i++] = BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW);
#endif
}

Next, apply the configuration:

struct sigaction act = { 0 };
act.sa_flags = SA_SIGINFO | SA_NODEFER;
act.sa_sigaction = handleSignalAction;
struct sigaction old_sa = {};

ret = sigaction(SIGSYS, &act, &old_sa);
if (0 != ret) {
    LOGSVCE("sigaction: %d, %d, %s", ret, errno, strerror(errno))
    ::free(filter);
    __ASSERT(0)
    return -11;
}

// Unmask SIGSYS
sigset_t mask;
if (sigemptyset(&mask) || sigaddset(&mask, SIGSYS) ||
    sigprocmask(SIG_UNBLOCK, &mask, nullptr)
    ) {
    LOGSVCE("sigprocmask: %d, %d, %s", ret, errno, strerror(errno))
    ::free(filter);
    __ASSERT(0)
    return -12;
}

struct sock_fprog prog = {
    .len = filterCount,
    .filter = filter,
};

// set to self process
ret = prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0);
if (0 != ret) {
    LOGSVCE("PR_SET_NO_NEW_PRIVS: %d, %d, %s", ret, errno, strerror(errno))
    ::free(filter);
    __ASSERT(0)
    return -13;
}

// set seccomp to kernel
ret = prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &prog);
if (0 != ret) {
    LOGSVCE("PR_SET_SECCOMP: %d, %d, %s", ret, errno, strerror(errno))
    ::free(filter);
    __ASSERT(0)
    return -14;
}

Finally, implement the post-interception handling:

static void handleSignalAction(int signo, siginfo_t* info, void* context) {
    if (!info || !context || signo != SIGSYS || info->si_code != SYS_SECCOMP) {
        LOGSVCW("signal: signo=%d, code=%d, errno=%d, call_addr=%p, arch=0x%x, syscall=0x%x,%s",
              info->si_signo, info->si_code, info->si_errno, info->si_call_addr, info->si_arch,
              info->si_syscall, SvcerDumper::index2name(info->si_syscall)
              )
        return;
    }

    ucontext_t *uc = reinterpret_cast<ucontext_t *>(context);
  
    intptr_t rc = SvcerSyscall::Call(SECCOMP_SYSCALL(uc),
                                   SECCOMP_PARM1(uc),
                                   SECCOMP_PARM2(uc),
                                   SECCOMP_PARM3(uc),
                                   SECCOMP_PARM4(uc),
                                   SECCOMP_PARM5(uc),
                                   SECCOMP_PARM6(uc)
    );
    SvcerSyscall::PutValueInUcontext(rc, uc);
}

03. Wrappers

For convenience, I wrapped logging interfaces around some basic syscalls.

  1. Add the syscall numbers to intercept (everyday logging):
SvcerDumper::addDump(SVCER_SYSCALL_execve);
SvcerDumper::addDump(SVCER_SYSCALL_execveat);
SvcerDumper::addDump(SVCER_SYSCALL_open);
SvcerDumper::addDump(SVCER_SYSCALL_openat);
SvcerDumper::addAll();

SvcerHooker::init(ESvcerHookerMode_IgnoreAll, "libifmamts.so");
  1. Register callbacks for the syscalls to intercept:
// register here
for (int i=SVCER_SYSCALL_None; i<SVCER_SYSCALL_Max; ++i) {
    SvcerHooker::registerCallback((TSVCER_SYSCALL_Type)i, handleSvcerHookerCallback);
}

// implement here

static void handleKonkerSvcerHookerCallback(int sn, SvcerHookerArgument* arg/*Not NULL*/) {
    switch (sn) {
    case __NR_statfs:// int statfs(const char* path, struct statfs* result);
    case __NR_truncate:// typedef int truncate(const char *filename, off_t len);
    case __NR_chdir:// int chdir(const char *path);
    {
        const char* pathname = (const char*)arg->getArgument1();
        char memString[512];
        if (memString == KonkerFixer::fixDataPath(pathname, memString)) {
            LOGSVCI("fixer, %s: %s", SvcerDumper::index2name(sn), __PRINTSTR(pathname))
            arg->setArgument1((intptr_t)memString);
        }
        arg->doSyscall();
        return;
    }
    default:
        LOGSVCI("ignore, %s", SvcerDumper::index2name(sn))
        break;
    }
    arg->doSyscall();
}
  1. Initialization:
// set the libraries to filter and our own library name
SvcerHooker::init(ESvcerHookerMode_IgnoreVdso|ESvcerHookerMode_IgnoreLibc|ESvcerHookerMode_IgnoreLinker, "libdemo.so");

04. Appendix

Extra modules:

The framework implements the most basic detection emulation — for example, when __NR_rt_sigaction and __NR_prctl are queried for their configuration, the return values are restored.

Reference projects:

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

https://github.com/termux/proot

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