SVC_Hook

基于seccomp+sigaction的Android通用svc hook方案-看雪
前置知识Linux系统调用

说明:
本文内容主要来源于看雪社区用户“珍惜Any”大佬的分享,标题为《[原创]基于seccomp+sigaction的Android通用svc hook方案》。文章链接为:https://bbs.kanxue.com/thread-277544.htm
本文是笔者对该分享内容的学习笔记,绝大部分内容并非原创,而是对原文的整理和总结。目的是为了便于个人学习和理解,同时也希望对其他有相同学习需求的读者有所帮助。
如果本文内容侵犯了任何版权或权益,请及时联系我,我将立即采取相应措施进行删除道歉处理。


众所周知,目前各大APP的安全模块几乎都会使用自实现的libc函数,如open,read等函数,通过自实现svc方式来实现系统调用。

因此我们如果想要hook系统调用,只能通过扫描厂商自实现的代码段,定位svc指令所在地址,再通过inline hook方式来进行hook操作,但是这种方式需要涉及内存修改,很容易被检测到内存篡改行为。

利用ptrace+seccomp

介绍

ptrace

ptrace是linux 提供的调试函数,IDA ,LLDB等调试器都是通过ptrace去实现的。
这个函数里面有很多action 每个action都包含一个功能,比如注入进程,暂停,修改寄存器等常用功能。
ptrace当注入当前进程的时候是不需要root。如果注入非自己的进程是需要root才可以。调用注入的时候选择一个pid即可。

ptrace可以在任何内存地方下断点,修改对应位置的数据。

ptrace的权限非常高。ptrace还可以调试内核态。

所以也可以用来处理svc的参数和返回值。

ptrace具体API说明官方文档如下:

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

seccomp

Seccomp是Linux的一种安全机制,android 8.1以上使用了Seccomp
正常情况下,程序可以使用所有系统调用。
通过 seccomp,可以禁用某些系统调用,减少系统的暴露面,当开启了Seccomp的进程在此调用的时候会变走异常的回调。

之前B佬的文章里面便采用了frida+seccomp的方式去做的svc拦截。也是很好的思路,帖子地址
Seccomp的过滤模式有两种(strict&filter),

strict

仅允许四个函数的系统调用(read,write,exit,rt_sigreturn)
如果一旦使用了其他的syscall 则会收到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”;
//通过prctl函数设置seccomp的模式为strict
printf(“Calling prctl() to set seccomp strict mode…\n”);
 
prctl(PR_SET_SECCOMP, SECCOMP_MODE_STRICT);
 
printf(“Writing to an already open file…\n”);
//尝试写入
write(output, val, strlen(val)+1);
printf(“Trying to open file for reading…\n”);
 
//设置完毕seccomp以后再次尝试open (因为设置了secomp的模式是strict,所以这行代码直接sign -9 信号)
int input = open(“output.txt”, O_RDONLY);
printf(“You will not see this message — the process will be killed first\n”);
}

filter(BPF)

BPF是一种过滤模式,当某进程调用了svc以后,如果发现当前sysnum是我们进行过滤的sysnum,首先会进入我们自己写的BPF规则,进行判断该系统调用是否被运行调用,应该怎么进行处理。

常用的指令如下

BPF_LD   // BPF_LDX加载指令
BPF_ST   // BPF_STX存储指令
BPF_ALU  // 计算指令
BPF_JMP  // 跳转指令
BPF_RET  // 返回指令 (结束指令)
BPF_MISC // 其他指令

指令之间可以相加或者相减,来完成一条JUMP操作,这块挺复杂的。具体就不详细去说了。

如果对这块规则感兴趣可以看一本书《Linux内核观测技术BPF》,老外写的,里面很详细的介绍了BPF得使用规则。

包括如何配合Seccomp去做系统调用的拦截和trace。
我们通过一个例子来看一下Seccomp-BPF的特性

struct sock_filter filter[] = {
    BPF_STMT(BPF_LD  | BPF_W   | BPF_ABS , offsetof(struct seccomp_data, nr)),
    BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K   , _NR_openat, 0, 1),
    BPF_STMT(BPF_RET | BPF_K   , SECCOMP_RET_TRACE),
    BPF_STMT(BPF_RET | BPF_K   , SECCOMP_RET_ALLOW),
};
struct sock_filter filter[]= {
    BPF_STMT (BPF_LD | BPF_W   | BPF_ABS , offsetof(struct seccomp_data, arch)),
    BPF_JUMP (BPF_JMP| BPF_JEQ | BPF_K   , AUDIT_ARCH_X86_64 , 1 , 0 )
};
  • nr: system call number (architecture-dependent)
  • arch: identifies architecture
    • Constants defined in <linux/audit.h>
      • AUDIT_ARCH_X86_64, AUDIT_ARCH_I386,
      • AUDIT_ARCH_ARM , etc.
  • instruction_pointer: CPU instruction pointer
  • args: system call arguments
    • System calls have maximum of six arguments
    • Number of elements used depends on system call

在上述代码中,有两个filter。
第一个filter一共有4条BPF指令。
第一条BPF指令,代表取 seccomp_data 中的nr字段,nr的意思是下一条BPF指令的判断条件是系统调用号。
第二条BPF指令,第二个参数是openat系统调用,
第三个参数数字N,表示如果系统调用是openat,跳过N条BPF指令,第四个参数数字N,标识如果系统调用不是openat,跳过N条BPF指令。按照这个逻辑,如果是系统调用openat,则跳过0条指令,执行第三条BPF。如果不是openat,则跳过1条指令,执行第四条BPF。第二个filter同理。

通过ptrace去实现svc的参数/返回值的修改。

整体笔记大概如下:

  1. fork出一个子进程,子进程 PTRACE_ATTACH 到被调试进程上
  2. 子进程监听并处理被调试进程的事件
  3. 被调试线程抛出信号,不同状态该如何处理
  4. 寄存器的偏移量数组
  5. 读取和修改 调试器内部缓存中 被调试进程的寄存器内容
  6. 将修改后的寄存器值 实际写入到被调试进程的寄存器中

tips:Tracee(被调试进程/线程)Tracer(调试进程/线程)

fork出一个子进程,子进程 PTRACE_ATTACH 到被调试进程上

首先先fork出来一条线程,用于跟踪main进程。开启死循环。
当使用ptrace的时候需要区分调试线程(tracer)和被调试线程(tracee),他们是两条线程。

设置可被ptrace attach -> 获取主进程PID -> 创建子进程 ─┬─子进程( 调试线程 )-> 子进程尝试attach到父进程上 -> 进入循环,监听 ptrace 事件,等待SIGCONT 信号
└─父进程(被调试线程)-> 初始化 seccomp 系统调用过滤器,设置对于父进程的过滤规则

int trace_current_process(int sdkVersion) {
    ALOGE("start trace_current_process ");

    // 设置当前进程为可 dumpable,这是 ptrace 能够 attach 的前提之一
    // PR_SET_DUMPABLE 控制进程在发生某些事件(如崩溃)时是否生成核心转储文件
    // 设置为 1 表示允许生成核心转储,同时也允许其他进程 ptrace 本进程
    prctl(PR_SET_DUMPABLE, 1, 0, 0, 0);

    // 获取当前主进程的 PID
    mainProcessPid = getpid();

    // 创建一个子进程
    pid_t child = fork();

    // fork() 调用失败处理
    if (child < 0) {
        ALOGE("ptrace svc  fork() error ");
        // 返回负的错误码
        return -errno;
    }

    // 初始化追踪器结构体,get_tracer 的具体实现未在此处给出
    // 参数可能包括:父追踪器,目标PID,是否是首次追踪等
    Tracer *first = get_tracer(nullptr, mainProcessPid, true);

    // 子进程执行的代码块
    if (child == 0) {
        // 子进程尝试 attach 到主进程(父进程)
        // PTRACE_ATTACH:附加到由 pid 指定的进程,使其成为当前进程的被跟踪者。
        int status = ptrace(PTRACE_ATTACH, mainProcessPid, NULL, NULL);
        if (status != 0) {
            // attach 失败
            ALOGE(">>>>>>>>> error: attach target process %d ", status);
            // 返回负的错误码
            return -errno;
        }

        // 设置追踪器状态,可能表示等待 SIGCONT 信号
        first->wait_sigcont = true;

        // 子进程进入事件循环,持续监听 ptrace 事件
        // 理论上,这个循环会一直运行,直到被外部终止或发生错误
        // event_loop() 的返回值将作为子进程的退出码
        exit(event_loop());
    } else { 
        // 父进程(主进程)执行的代码块
        enable_syscall_filtering(first);
    }

    // 主进程执行到此,表示初始化完成(子进程已开始追踪或正在尝试追踪)
    return 0;
}

当发现svc指令的一个回调。也就是(SIGTRAP | 0x80),这个时候开始执行调试线程逻辑。被调试线程进入等待状态。

通过调用ptrace提供的api进行attch,调试进程是一个while true 死循环。这样就可以一直监听被调试线程的状态,调试线程通过linux waitpid函数进行处理和回调,等待被调试线程进入指定回调。

子进程监听并处理被调试进程的事件

while (true) { // 调试器持续运行,等待和处理被调试进程的事件
        int tracee_status;
        Tracer *tracee;
        int signal;
        pid_t pid;
        free_terminated_tracees();
        //等待任意子进程(`-1` 表示等待任意子进程)的状态变化
        pid = waitpid(-1, &tracee_status, 0);
        if (pid < 0) { // 错误处理
            ALOGE(">>>>>>>>>> !!!!! error: waitpid() %d  %s", pid, strerror(errno))
            if (errno != ECHILD) {
                return EXIT_FAILURE;
            }
            break;
        }
        // 根据子进程的 `pid` 获取对应的 `Tracer` 对象
        tracee = get_tracer(nullptr, pid, true);
        assert(tracee != nullptr);
        
        // 处理被调试进程的状态变化事件
        signal = handle_tracee_event(tracee, tracee_status);
        // 重新启动被调试进程
        (void) restart_tracee(tracee, signal);
    }
    ALOGE("<<<<<<<<<<<< listening was error ,main listener stop !!")
    return last_exit_status;
}

被调试线程抛出信号,不同状态该如何处理

主要包含如下几种状态。包括正常退出,异常退出,结束,或者进入系统调用等。

  • 正常退出->被跟踪者进程 正常执行结束->释放当前(被跟踪者)
  • 异常退出->提取导致退出的信号编号->释放(被跟踪者)
  • 被调试者暂停->判断是否是因为svc调用暂停->处理参数和返回值

代码如下

if (WIFEXITED(tracee_status)) { 
        // W IF EXITED  检查被调试进程是否正常退出
        // W EXIT STATUS取得子进程exit()返回的结束代码
        last_exit_status = WEXITSTATUS(tracee_status);
        // ALOGI("normal exit -> [%d] exit with status: %d  ", tracee->pid, tracee_status);
        // 被跟踪者进程 正常执行结束,释放当前(被跟踪者)
        terminate_tracee(tracee);
  } else if (WIFSIGNALED(tracee_status)) { 
        // W IF SIGNALED 检查 `tracee_status` 是否表示子进程因接收信号而退出。
        // 如果子进程因信号退出,提取导致退出的信号编号
        int signNum = WTERMSIG(tracee_status);
        ALOGE("[%d]  process exit with signal: 终止信号 = %d  异常原因 = %s ",
              tracee->pid,
              signNum,
              strsignal(signNum)
        )
        // 释放(被跟踪者)
        terminate_tracee(tracee);
  } else if (WIFSTOPPED(tracee_status)) { 
        // W IF STOPPED 检查被调试进程是否被暂停
        signal = (tracee_status & 0xfff00) >> 8;
        switch (signal) {
            //svc
            case SIGTRAP | 0x80:
            // 被调试线程调用svc,开始处理参数和返回值
            ...

当参数或者返回值处理完毕以后,通过给调试线程,调用ptrace设置被调试线程的启动PTRACE_SYSCALL事件

(当被调试进程执行了某些SIGTRAP 事件,程序就会进入暂停,这个时候调试线程开始处理对应的逻辑。常用的恢复暂停事件有两个,PTRACE_SYSCALL和PTRACE_CONT ,可以理解成一个是调试的单步执行,一个是继续执行。PTRACE_SYSCALL方式重新启动被调试线程以后,下次遇到SVC的before和after还会继续暂停。)

也就是当svc 执行之前(before)执行之后(after) 被调试线程都会暂停。

寄存器的偏移量数组

先把每个版本不同的寄存器进行匹配。用来区分LR,SP,PC等常用寄存器。
定义不同架构(ARM EABI 和 ARM64)下寄存器偏移量的数组。在调试器或系统调用拦截工具中,能够通过统一的方式访问和操作不同架构下的寄存器
代码分为两部分,分别针对 ARM EABIARM64 架构。
https://zhuanlan.zhihu.com/p/42486116
http://blog.chinaunix.net/uid-25564582-id-5852920.html

#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]),
    
    [FRAME_POINTER]     = USER_REGS_OFFSET(regs[29]),
    //64位30是LR寄存器
    [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]),
};

读取和修改 调试器内部缓存中 被调试进程的寄存器内容

// 读取指定 `Tracer` 对象的某个寄存器的值。
word_t peek_reg(const Tracer *Tracer, RegVersion version, Reg reg) {
    word_t result;
    // 确保传入的版本号 `version` 在有效范围内
    assert(version < NB_REG_VERSION);
    
    // 使用 `REG` 宏读取指定寄存器的值
    result = REG(Tracer, version, reg);
 
    // 如果调试器运行在 64 位内核上,但被调试的进程是 32 位的,则只保留寄存器值的低 32 位。
    if (is_32on64_mode(Tracer))
        result &= 0xFFFFFFFF;
    // 返回读取到的寄存器值。
    return result;
}
 
// 修改寄存器的内容方法,value标识的是指针
void poke_reg(Tracer *Tracer, Reg reg, word_t value) {
    // 在修改寄存器之前,先调用 `peek_reg` 检查当前寄存器的值是否已经等于目标值。如果相等,则无需修改,直接返回
    if (peek_reg(Tracer, CURRENT, reg) == value)
        //相等直接返回
        return;
    // 使用 `REG` 宏将指定寄存器的值设置为 `value`
    REG(Tracer, CURRENT, reg) = value;
    //设置 Tracer 对象的 _regs_were_changed 标志为 true,表示寄存器的值已经被修改
    Tracer->_regs_were_changed = true;
}

poke_reg 函数的作用是修改 调试器内部缓存 中的寄存器值。这个修改只影响调试器对寄存器值的记录,而不会直接修改被调试进程(tracee)的实际寄存器值。

将修改后的寄存器值 实际写入到被调试进程的寄存器中

而下文中的ptrace(PTRACE_SETREGSET) 是一个系统调用,用于将修改后的寄存器值 实际写入到被调试进程的寄存器中。这个操作会直接影响被调试进程的运行状态。

将修改完毕的寄存器内容保存到数组里面,最后通过ptrace PTRACE_SETREGSET action进行寄存器的set

// 要写入的寄存器内容的地址,这里指向 `current_sysnum`,它存储了新的系统调用号。
regs.iov_base = &current_sysnum;
// 要写入的数据长度
regs.iov_len = sizeof(current_sysnum);

// 在调试器和被调试进程之间进行交互,`PTRACE_SETREGSET`表示设置寄存器的内容,NT_ARM_SYSTEM_CALL表示系统调用号,&regs表示要写入的寄存器内容
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;
}

修改被调试进程的寄存器内容,已达到修改svc参数和返回结果的目的。

这个思路确实是可以实现svc的参数和返回值的修改。但是存在问题

效率太低,调试线程和被调试线程本身是两条线程,主要通过线程间交互进行传递消息,而且被attch的进行会进行大量的暂停,甚至本身的libc去调用svc的时候也会进行暂停。导致程序卡顿。

当时为了解决这个问题也花了很久查了很多资料。

使用Seccomp+ptrace劫持svc

为了解决这个效率低的问题,看了很多开源框架,比如Strace也在使用ptrace进行svc的跟踪。一个打印Syscall调用方法的插件,可以很清楚的打印全部的系统调用,比如文件相关类型函数、网络相关类型的函数,等...他同样也可以用在安卓上面, 具体使用方式,国内资料比较多,可以去看一下。
Strace是怎么解决的?用的就是Seccomp+ptrace去做拦截,我们只需要关注我们需要进行拦截的函数即可。比如常见的IO函数,access,openat,open,fstart等即可。而不需要关注别的系统调用。

启用seccomp并劫持before

seccomp初始化完毕以后,我们只需要在ptrace PTRACE_SETOPTIONS的时候加上PTRACE_O_TRACESECCOMP参数即可。

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);
 
//尝试开启ptrace+seccomp
status = ptrace(PTRACE_SETOPTIONS, tracee->pid, NULL,default_ptrace_options | PTRACE_O_TRACESECCOMP);

这样一来当目标App调用了被我们拦截的系统调用的时候就会走如下case。

case SIGTRAP | PTRACE_EVENT_SECCOMP << 8:

我们直接在这个执行上面的流程设置参数,也是没问题的。但是这个时候又来一个问题:Seccomp只能处理svc的before ,也就是当svc执行之前进入到这个case,不能处理after。

劫持after

因为修改svc的返回结果必须在after里面处理,有人可能会问了为什么要处理返回结果呢?文件重定向只需要处理参数就行了,参数完全可以在before里面进行处理。为啥还要处理after呢?
答:做指纹mock时候需要在after里面处理,比如socket常见的通讯函数,recv,recvfrom,recvmsg。他们都是在原始函数调用完毕以后把数据参数放到一个数组里面,如果在before处理这个数组肯定是NULL。只有在函数执行完毕以后才会将数组的内容进行赋值

比如通过netlinker去获取设备指纹:Android netlink&svc 获取 Mac方法深入分析-看雪

这也是大厂的贯通套路,这种指纹想要去mock很难,特别是用内联svc的方式去获取。但是用了ptrace想去修改的话就很简单了。代码如下,先通过peek_reg寄存器把数据读到手,然后在把数据处理完毕以后在poke_reg回去

void NetlinkMacHandler::netlinkHandler_recv(Tracer *tracee) {
    // 读取 `tracee` 的寄存器值,这里读取的是系统调用的返回值。
    ssize_t bytes_read = TEMP_FAILURE_RETRY(peek_reg(tracee, CURRENT, SYSARG_RESULT));
    if (bytes_read > 0) {
        // 获取系统调用的参数 SYSARG_2,对应为buff内容
        word_t buff = peek_reg(tracee, CURRENT, SYSARG_2);
        // 获取系统调用的参数 SYSARG_3,对应为buff长度
        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;
        }
        // 将 `tempBuff` 的内容重新解释为 `nlmsghdr` 类型的指针 `hdr`
        auto *hdr = reinterpret_cast<nlmsghdr *>(tempBuff);
        // 将解析后的 Netlink 数据包(`hdr`)和读取的字节数(`bytes_read`)传递给回调函数进行进一步处理
        NetlinkMacHandler::handler_mac_callback_svc(tracee,hdr, bytes_read);
        //将数据写入覆盖掉原来的数据
        write_data(tracee, buff, tempBuff, size);
    }
}

为了解决ptrace+seccomp不能处理after的问题想了好久 ,卡了我几个月之久。后来通过问VA作者发现一个很不错开源的项目就是proot。项目地址->https://github.com/proot-me/proot

proot的解决方案也很简单,只需要一行即可。

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

修改SP寄存器,让这个方法二次进入,一次修改参数,一次修改返回结果即可。

简单介绍一下proot这个项目。他就完全符合我们的需求,处理逻辑也类似。
例如,在Linux里面有一个chroot函数,这个函数可以修改root用户根目录的位置,但是这个函数需要root权限才可以用。
而我想把/data/zhenxi/路径变成Linux的根目录。而不是最原始的/。想要达到类似chroot的效果就可以使用proot项目,将proot编译好以后,直接启动就可以在免root的环境下劫持svc调用,对执行的文件路径进行替换和处理。
tips:proot并不是调用chroot函数,而是劫持svc调用读取文件路径的函数并修改达到类似于chroot函数的效果。
当然我说的这些也是proot的一小部分功能,更重要的功能是利用ptrace+seccomp实现沙盒文件限制的逻辑。

搞定以后就需要进行注入了,如何把拦截和修改的功能注入到目标App里面。
因为Linux特性,ptrace只针对当前进程才可以免root进行attch

注入方式:

Xposed注入So:

优点非入侵式,不需要修改apk签名,只在onload里面进行attch当前线程,可实现全部进程的svc修改和mock

包括文件监听,过检测等。

二次打包注入:

入侵式,优点就是可以在免root环境下进行attch,直接把So打进去,然后加载即可,缺点就是修改签名需要绕过,不过绕过更简单了。

直接通过SVC的IO重定向把 原始的apk放到任意私有路径,当对方读取/data/app/包名/base.apk的时候直接把参数替换成原始的apk路径即可这种方式对抗企业壳的重打包检测依然有效。

使用场景:

文件读取监听&合规检测:

很多app会去读取大量别的app私有目录,比如去遍历/data/data/xxx/下的文件路径,获取读取SD卡下的其他文件。这些都是不合规或者存在安全隐患问题,用SVC文件文件监听的方式把对方读取的路径打印出来,可以快速的去分析对方app是否合规,是否存在安全隐患。打印效果如下,读取哪些文件也是一清二楚:

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/

PASS环境检测:

Root&magisk

因为一切文件读取最终底层读取都是SVC函数去读取,我们只需要写个sandbox

把我们认为的问题关键目录直接全部都进行PASS即可,当目标App去读到这个路径以后我们将方法的路径设置成一个不存在的路径即可。

代码如下

else if (strstr(result, "magisk")) {
    //直接包含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")) {
    //su结尾的root文件都直接干掉
    result = NULL;
}else if (strstr(result, "zygisk")) {
    result = NULL;
}else if (strstr(result, "/data/adb/")) {
    //这个文件里面包含很多magisk相关的,比如模块的list /data/adb/modules/
    //https://github.com/LSPosed/NativeDetector/blob/master/app/src/main/jni/activity.cpp
    result = NULL;
}

当然这些还是不够,还有/proc/mounts里面也有一堆magisk文件特征。这个文件可以里面也有一堆特征。

可以在执行之前先生成一份,然后当发现读取到我们需要IO重定向的路径直接修改成我们生成的文件即可。

DebugCheck:

还有一些反调试文件,stat status wchan这些也都是在ptrace attch之前进行mock一份,然后IO重定向到新生成的文件路径即可。

MapsCheck:

maps io重定向的话,不能提前生成,必须在目标app读取之前进行创建,因为maps 是不断变化的。以防万一有人扫描maps去检测。

在svc的before里面调试线程去创建,然后修改被调试线程的读取路径。

AppSign:

很多大厂或者壳子检测签名无非几种,java层检测和native检测,这些完全可以Hook 在注入的时候顺便把sandhook等框架一起注入。

在配合ptrace+seccomp,Java层的话就是Hook获取签名的那几个方法,然后记得把内存的变量也需要通过反射的方式去set上去。

不能只Hook方法。这块需要过掉9.0反射限制,可以参考LSP的绕过反射限制代码项目。

native检测大部分都是svc openat去读取文件,然后把apk当成zip进行解压缩,解析,去计算apk的签名文件。判断是否正确

这种方式很简单绕过,只需要去在他读取/data/app/xxx/base.apk的时候,我们把他指向原始包即可绕过。

沙箱的打磨&实践:

有时候我们经常需要分析一个So, 看看这个So里面读取了哪些文件,做了哪些事情,调用了哪些Java方法

我以前挺喜欢用unidbg的,但是发现痛点太多,就是需要补环境,有很多So会调用高版本api,我记得我以前用的时候,只支持23和26版本的SDK,这个时候如果去补环境,真的很累,特别是很多So会去扫描大量的系统文件。这些系统文件都是unidbg里面没有的,不如我们直接在安卓系统上直接运行这个So

我们可以先搞个helloword 然后先启动我们ptrace进行attch。

在配合sandhook和jnitraceforcpp(https://github.com/w296488320/JnitraceForCpp)
这个jnitraceforcpp不是frida的jnitrace,是我以前空闲的时候写的,代码没多少行,但是Hook了全部的jniEnv里面的方法,对方不管调用了什么我们这边都可以进行打印。hook方法如下

HOOK_JNI(env, CallObjectMethodV)
HOOK_JNI(env, CallBooleanMethodV)
HOOK_JNI(env, CallByteMethodV)
...
HOOK_JNI(env, GetStaticFloatField)
HOOK_JNI(env, GetStaticDoubleField)

//常用的字符串操作函数
HOOK_JNI(env, NewStringUTF)
HOOK_JNI(env, GetStringUTFChars)

//HOOK_JNI(env, FindClass)
HOOK_JNI(env, ToReflectedMethod)
...

把这方法全部进行hook以后,再把对方的So加载进来,对方调用了什么方法,做了哪些事情一目了然

而且最重要的是不需要去补环境。分析效率很高。需要处理什么直接Hook即可。

指纹的对抗:

很多大厂会去获取设备指纹,Java层那些方法不多说,直接hook就行。(Java 层的代码运行在沙箱环境中,对系统资源的访问受到严格限制。例如,Java 层无法直接访问底层的系统属性、设备标识符(如 UUID、Android ID)和网络接口)。而Native 层的代码可以直接调用系统调用(syscall),访问底层的系统资源。

核心的都是在native层去处理,system_property_get&read,bootid,UUID,反射内存android id,netlinker获取网卡

还有一些就是内联svc调用读文件函数也是可以获取到网卡信息,比如

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

build.prop popen读取一些设备 如 /sys/devices/soc0/serial_number类似这种

还有execve去获取一些设备信息,这些通过svc的IO重定向很容易就可以实现mock和pass。

当读取的时候我们生产一份新得,指向到新生成的文件即可。

无痕Hook的实现思路:

现在很多native hook思路都是inlinehook ,Got表 ,异常hook(异常信号劫持hook)。

这些思路都是很好的思路,各有各的好处,但是都是有特征,比如inlinehook crc检测很容易就检测出来,并且

有很多大厂会用shellcode进行绕过,我们能去修改这段指令跳转到这个某个函数,他当然也可以修改回来。

他只需要把某个方法的指令换成原始的指令,这样就可以防止被inlinehook Hook(他需要获取原始指令,可以解析本地的So文件,解析text段,得到最真实的指令信息,保存,然后在函数执行之前在set回内存,都是很好的办法,还有的干脆直接服务端配置一个服务,直接服务端拉取某个函数的正确指令,都是很好的思路) 当然对抗这也不是没办法,我只需要在hook完毕以后再把内存设置成可读,不可写,然后Hook mprotect ,不让他调用mprotect,这样就可以被shellcode绕过。

说的有点多,重点说一下无痕Hook的实现思路,ptrace有一个很重要的功能就是下断点,我们只需要在指定内存下断点,当方法执行到这里以后,直接通过ptrace修改PC寄存器。跳转到到指定函数即可,把参数也带过去,因为是执行阶段才会修改,而且是直接修改的寄存器,不存在修改指令。所以无需担心指令的crc检测不过问题

测试在64位还是很稳定的,32位的话总有问题,很多32位程序走的是64位的sysnum 。一直报bad syscall num 。原因未知。一直在采坑,还没填上去。不过还好大部分app都是64位的。

结束语:

文章还是挺长的,很感谢能看到最后,上述的svc拦截核心代码大部分都能在proot项目里面找到,需要自己移植到android上面 。

如何实现:

当返回规则设置为「SECCOMP_RET_TRAP」,目标系统调用时seccomp会产生一个SIGSYS系统信号并软中断,这时就可以通过捕获这个SIGSYS信号获得svc调用和打印具体参数。
使用Frida的API「Process.setExceptionHandler」即可捕获异常并在自己写的回调中进行数据处理。

// 异常处理
Process.setExceptionHandler(function (details) {
    const current_off = details.context.pc - 4;
    // 判断是否是seccomp导致的异常 读取opcode 010000d4 == svc 0
    if (details.message == "system error" && details.type == "system" && hex(ptr(current_off).readByteArray(4)) == "010000d4") {
        // 上锁避免多线程问题
        lock(syscall_thread_ptr)
        // 获取x8寄存器中的调用号
        const nr = details.context.x8.toString(10);
        let loginfo = "\n=================="
        loginfo += `\nSVC[${syscalls[nr][1]}|${nr}] ==> PC:${addrToString(current_off)} P${Process.id}-T${Process.getCurrentThreadId()}`
        // 构造线程syscall调用参数
        const args = Memory.alloc(7 * 8)
        args.writePointer(details.context.x8)
        let args_reg_arr = {}
        for (let index = 0; index < 6; index++) {
            eval(`args.add(8 * (index + 1)).writePointer(details.context.x${index})`)
            eval(`args_reg_arr["arg${index}"] = details.context.x${index}`)
        }
        // 获取手动堆栈信息
        loginfo += "\n" + stacktrace(ptr(current_off), details.context.fp, details.context.sp).map(addrToString).join('\n')
        // 打印传参
        loginfo += "\nargs = " + JSON.stringify(args_reg_arr)
        // 调用线程syscall 赋值x0寄存器
        details.context.x0 = call_task(syscall_thread_ptr, args, 0)
        loginfo += "\nret = " + details.context.x0.toString()
        // 打印信息
        call_thread_log(loginfo)
        // 解锁
        unlock(syscall_thread_ptr)
        return true;
    }
    return false;
})

使用实现my_open中SVC调用的方式,由于本身采用内联汇编的形式,代码地址分配不固定。另外还可以采取动态分配内存,存储并执行代码的方法。使得Inline Hook变的更加困难。

但是,这并不能阻止Seccomp-BPF的拦截方案。因为Seccomp-BPF提供了一个钩子函数,在SVC系统调用执行之前会进入到这个函数,对系统调用进行检查,并做业务逻辑的修改和绕过。故任何SVC调用,都无法逃离Seccomp-BPF的魔掌

那么,Seccomp-BPF在Android应用攻击中是怎么做的呢?举例如下。

有的APP为了保障自身的安全,不允许APP动态调试、二次签名、运行在框架环境等。采用的方案一般使用open系统调用的方式打开特定文件检查有无异常特征,例如/proc/self/map或者/proc/self/status等。有些开发者比较注重安全,对open采用了svc的实现方式。但是利用Seccomp-BPF完全可以对open操作进行拦截,重定位参数路径,完成逻辑的绕过

如何检测seccomp

1. 基于prctl调用的检测

prctl方法也是一个系统调用。使用Seccomp-BPF前,会调用prctl,并传入特定参数作为前置条件。。

prctl(PR_SET_NO_NEW_PRIVS, 1);

为了检测这种方式。我们同样可以调用prctl,传入PR_GET_NO_NEW_PRIVS。根据返回值来判断是否NO_NEW_PRIVS被设置为1。

if(prctl(PR_GET_NO_NEW_PRIVS, 0, 0, 0, 0) == 1) { do something… }

但是该方案有个缺陷,因为prctl本身是个系统调用。所以仍然可以被Seccomp-BPF拦截修改进而绕过。

2. 基于Signal的捕获检测

将SECCOMP_RET_TRAP传入BPF指令中,当执行该指令时,将会产生SIGSYS信号。我们可以提前通过sigaction方法来注册SIGSYS信号,当捕获到信号时,会执行对应的handler方法。在正常情况下,都是可以正常接收到信号,并执行handler方法。但是如果该进程已经提前使用了Seccomp-BPF,将会无法正常接收到信号,handler方法也不会执行。所以可以通过这种逻辑来判断该应用是否处在Seccomp-BPF环境中。

不过该方法依然有被绕过的可能性,攻击者可以通过Hook的方式,拿到handler方法地址并主动去执行。则可以绕过校验。

3. 基于status文件的检测方案

当调用图 4中的代码时,会在对应进程的status文件中留下痕迹。如图 5所示,NoNewPrivs对应的值变为1。
assets/SVC_Hook/file-20250507113609217.png

我们可以使用open打开/proc/self/status文件,检测该值是否为1。当然,open系统调用,我们之前说过了,可以被绕过。但这并不意味着防御到此结束,我们依然可以通过open返回的句柄值,来进一步验证句柄的合法性。

待看:

基于seccomp+sigaction的Android通用svc hook方案-看雪
Frida-Sigaction-Seccomp实现对Android APP系统调用的拦截-看雪
Frida 过检测通用思路之一-看雪

参考:

SVC的TraceHook沙箱的实现&无痕Hook实现思路-看雪
分享一个Android通用svc跟踪以及hook方案——Frida-Seccomp-看雪
Seccomp技术在Android应用中的滥用与防护-看雪
批量检测android app的so中是否有svc调用-看雪

posted @ 2025-05-08 15:44  方北七  阅读(344)  评论(0)    收藏  举报