Android 上的 ptrace 实践

前篇

dlopen

获取 dlopen 有两种方法:

  1. 从 linker 获取 __loader_dlopen (或者 __dl___loader_dlopen),需要提供 caller_addr
  2. 从 libdl.so 获取 dlopen

详细代码:

https://github.com/5ec1cff/ptrace-examples/tree/android

实现了注入到任意进程,调用 __loader_dlopen 打开指定路径的 lib ,或者调用 __loader_dlclose 关闭指定 handle 的 lib 。目前只支持 x86-64 和 arm64 。

对齐问题

一开始总是得到 SIGSEGV ,并且 fault addr 为 0 。出现问题的时候注入信号并 detach ,观察 crash dump :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
05-02 13:13:59.534 18907 18907 F DEBUG   : *** *** *** *** *** *** *** *** *** *** *** *** *** *** *** ***
05-02 13:13:59.534 18907 18907 F DEBUG : Build fingerprint: 'Android/sdk_phone64_x86_64/emulator64_x86_64:12/SE1A.220621.001/8752307:userdebug/test-keys'
05-02 13:13:59.534 18907 18907 F DEBUG : Revision: '0'
05-02 13:13:59.534 18907 18907 F DEBUG : ABI: 'x86_64'
05-02 13:13:59.534 18907 18907 F DEBUG : Timestamp: 2023-05-02 13:13:59.436192500+0000
05-02 13:13:59.535 18907 18907 F DEBUG : Process uptime: 0s
05-02 13:13:59.535 18907 18907 F DEBUG : Cmdline: zygote64
05-02 13:13:59.535 18907 18907 F DEBUG : pid: 17413, tid: 17413, name: main >>> zygote64 <<<
05-02 13:13:59.535 18907 18907 F DEBUG : uid: 0
05-02 13:13:59.535 18907 18907 F DEBUG : signal 11 (SIGSEGV), code 128 (SI_KERNEL), fault addr 0x0
05-02 13:13:59.535 18907 18907 F DEBUG : rax 0000000000000000 rbx 00007ffe94362c77 rcx 0000000000000000 rdx 0000000000000000
05-02 13:13:59.536 18907 18907 F DEBUG : r8 0000000000000000 r9 0000000000000028 r10 0000000000000000 r11 0000000000000246
05-02 13:13:59.536 18907 18907 F DEBUG : r12 0000000000000002 r13 00007ffe94362dd0 r14 00007b1f79c6f8b8 r15 0000000000000000
05-02 13:13:59.536 18907 18907 F DEBUG : rdi 00007ffe94362c77 rsi 0000000000000002
05-02 13:13:59.536 18907 18907 F DEBUG : rbp 0000000000000000 rsp 00007ffe94361ad7 rip 00007b1f79b75d1d
05-02 13:13:59.536 18907 18907 F DEBUG : backtrace:
05-02 13:13:59.536 18907 18907 F DEBUG : #00 pc 0000000000048d1d /apex/com.android.runtime/bin/linker64 (__dl__Z9do_dlopenPKciPK17android_dlextinfoPKv+29) (BuildId: 82dabb4f8aa58c9aa33c9c6fcabc92a7)
05-02 13:13:59.536 18907 18907 F DEBUG : #01 pc 00000000000444f9 /apex/com.android.runtime/bin/linker64 (__loader_dlopen+57) (BuildId: 82dabb4f8aa58c9aa33c9c6fcabc92a7)
05-02 13:13:59.537 18907 18907 F DEBUG : #02 pc 00000000000b3aab /apex/com.android.runtime/lib64/bionic/libc.so (__ppoll+11) (BuildId: 5db8d317d3741b337ef046540bbdd0f7)
05-02 13:13:59.537 18907 18907 F DEBUG : #03 pc 6f6c2f617461642f <unknown>

gdb 反编译看一看:

1
2
3
4
disas __dl__Z9do_dlopenPKciPK17android_dlextinfoPKv
Dump of assembler code for function __dl__Z9do_dlopenPKciPK17android_dlextinfoPKv:
...
0x0000000000048d1d <+29>: movaps %xmm0,0x40(%rsp)

movaps 是什么指令?问一问万能的 ChatGPT:

1
movaps是x86-64汇编语言中的一条指令,用于将数据块从一个位置移动到另一个位置。该指令可以在操作数和目的地操作数都是128位XMM寄存器时使用,它将操作数寄存器中128位的值复制到目标操作数寄存器中,覆盖其128位的值。"movaps"表示“移动对齐的128位数据”,如果任意一个操作数不是128位对齐的话,该指令将引发异常情况。

也就是说,这条指令要求 rsp 是 16 字节对齐的,而我们往栈上写入了字符串后, rsp 并不是对齐的。

实践表明,ptrace 停止后当前的 rsp 也未必就是 128 位对齐的,因此修改代码,在我们调用前将 rsp 调整为比它小的最大的 128 位对齐的地址。

但是为什么 linker64 的函数需要堆栈对齐,而我们在 libc 停下的地方却没有对齐呢?

1
2
3
4
5
bool make_call(int pid, void* addr, struct user_regs_struct &regs, void** result) {
// ...
regs.rsp = regs.rsp & (~0xf); // here
regs.rax = (unsigned long long) addr;
if (!ptrace_set_regs(pid, regs)) {

libdl:

1
2
3
4
5
6
7
8
9
10
11
(gdb) disas dlopen
Dump of assembler code for function dlopen:
0x0000000000001b50 <+0>: mov (%rsp),%rdx
0x0000000000001b54 <+4>: jmp 0x1ce0 <__loader_dlopen@plt>
End of assembler dump.
(gdb) disas 0x1ce0
Dump of assembler code for function __loader_dlopen@plt:
0x0000000000001ce0 <+0>: jmp *0x11ca(%rip) # 0x2eb0 <__loader_dlopen@got.plt>
0x0000000000001ce6 <+6>: push $0x1
0x0000000000001ceb <+11>: jmp 0x1cc0
End of assembler dump.

权限问题

解决了上面的问题,dlopen 总算不会出现 SIGSEGV 了,但是注入 zygote 返回的 handle 是 0 。

1
2
05-02 13:45:45.136 26460 26460 D linker  : ... dlopen failed: library "/data/local/tmp/libinject-lib.so" not found
05-02 13:45:45.132 26460 26460 W main : type=1400 audit(0.0:59): avc: denied { search } for name="tmp" dev="dm-5" ino=65538 scontext=u:r:zygote:s0 tcontext=u:object_r:shell_data_file:s0 tclass=dir permissive=0

看起来我们放在 /data/local/tmp 会导致 zygote 无法访问。

linker 日志: setprop debug.ld.all dlopen 并重启 zygote 。

那么先临时关闭 SELinux :

1
2
3
4
5
6
7
8
9
10
setenforce 0
injector open `pidof zygote64` /data/local/tmp/libinject-lib.so
dlopen /data/local/tmp/libinject-lib.so on 27974

found linker in maps, base=0x74a3bf78a000,path=/apex/com.android.runtime/bin/linker64
dlopen_addr: 0x74a3bf7ce4c0
rsp=0x7ffdf5619708
string pushed to 0x7ffdf5619680 (size=33)
rip=0x74a3a9d08aaa instruction: 972fffff0013d48
handle: 0x91b6696f12f223bf

这样我们的代码可以正常执行:

1
2
3
05-02 13:59:21.475 27974 27974 D linker  : ... dlopen calling constructors: realpath="/data/local/tmp/libinject-lib.so", soname="libinject-lib.so", handle=0x91b6696f12f223bf
05-02 13:59:21.475 27974 27974 D pt-injector: injected
05-02 13:59:21.475 27974 27974 D linker : ... dlopen successful: realpath="/data/local/tmp/libinject-lib.so", soname="libinject-lib.so", handle=0x91b6696f12f223bf

那么有什么方法可以在开启 selinux 的情况下执行呢?可以考虑修改 selinux 规则,或者换个位置存放,比如放在 /dev ,context 改为 u:object_r:system_file:s0

1
2
3
4
5
6
05-03 01:29:45.954  3237  3237 D linker  : ... dlopen calling constructors: realpath="/dev/libinject-lib.so", soname="libinject-lib.so", handle=0x17a87dbf82e7009f
05-03 01:29:45.954 3237 3237 D pt-injector: injected
05-03 01:29:45.954 3237 3237 D linker : ... dlopen successful: realpath="/dev/libinject-lib.so", soname="libinject-lib.so", handle=0x17a87dbf82e7009f
^C
130|emulator64_x86_64:/data/local/tmp # getenforce
Enforcing

这样注入到 init 也是没问题的。

linker 日志

LinkerLogger 会在 dlopen 和 dlsym 的时候刷新,设置了系统属性 debug.ld.* 之后,app 进程都可以得到动态更新,注入 dlopen 会产生 linker 日志,但是注入到 zygote 进行 dlopen 却不会发生更新,进而无法产生日志,不得不重启。

实际上只有进程是 dumpable 的时候才会发生更新:

https://android.googlesource.com/platform/bionic/+/ebd654640abdc3f048f0a676741b79dd3d23b766/linker/linker_logger.cpp#89

模拟器是 debug 构建的系统,因此 app 进程都是 dumpable 的,不过 zygote 自身仍然不是 dumpable 的。

我们可以用 ptrace 注入系统调用查看或修改 dumpable ,这样 debug.ld 属性就可以被 zygote 读取了:

1
2
3
4
5
6
7
8
9
10
11
12
./injector get-dumpable `pidof zygote64`
get dumpable for 365
rip=0x74d24725eaaa instruction: 972fffff0013d48
dumpable:0x0
./injector set-dumpable `pidof zygote64` 1
set dumpable to 1 for 365
rip=0x74d24725eaaa instruction: 972fffff0013d48
result:0x0
./injector get-dumpable `pidof zygote64`
get dumpable for 365
rip=0x74d24725eaaa instruction: 972fffff0013d48
dumpable:0x1

另外,init 的日志比较特殊,在我的 AVD 上,无法通过 logcat --pid 1 获取 init 的日志,而实际上 init 仍然会往 logd 写入日志,只是 pid 为 0 ,我们无法通过 logcat --pid 0 获取 init 的日志。更加奇怪的是,debug.ld 也无法影响 init 的 linker ,也就是无法产生 linker 日志,尽管 init 本来就是 dumpable 的,可能和 init 自身的 linker 有关。

考虑到 init 是一个非常特殊的进程,它的死亡会直接导致 kernel panic ,因此 ptrace 注入它还是要小心谨慎。

其他指令集

arm64-v8a

函数调用

函数调用的汇编代码:

1
2
blr x9
brk #0x1

编译成机器码就是 0x20, 0x1, 0x3f, 0xd6, 0x20, 0x0, 0x20, 0xd4 或者 0xd4200020d63f0120

其中 brk #0x1 是从 __builtin_trap() 得来的,cpu 到这条指令断下来之后似乎不会增加 pc 的值。

https://stackoverflow.com/questions/11345371/how-do-i-set-a-software-breakpoint-on-an-arm-processor

https://stackoverflow.com/questions/44949124/absolute-jump-with-a-pc-relative-data-source-aarch64

arm64 的调用约定:x0~x7 作为函数参数,x0 也作为返回值。由于 x9 是被调用方保存的寄存器,因此选择它来传递函数的绝对地址。

系统调用

1
svc 0

机器码:0x1, 0x0, 0x0, 0xd4 或者 0xd4000001

参数:x0~x5 ,返回值是 x0 ,系统调用号 NR 是 x8 。

https://syscall.sh/

下面是直接注入当前 shell 的效果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
u0_a212@192.168.1.xxx ~
$ ./injector open $$ $PWD/libinject-lib.so; logcat --pid $$
dlopen /data/data/com.termux/files/home/libinject-lib.so on 26496

found linker in maps, base=0x701ef49000,path=/apex/com.android.runtime/bin/linker64
dlopen_addr: 0x701ef7a190
sp=0x7ffa8d84e0
string pushed to 0x7ffa8d84ae (size=50)
pc=0x701d9c1c94 instruction: b140041fd4000001
handle: 0xfcff7941be05802d
--------- beginning of main
05-03 20:23:54.309 26496 26496 D pt-injector: injected
^C
u0_a212@192.168.1.xxx ~
$ ./injector close $$ 0xfcff7941be05802d; logcat --pid $$
dlclose 0xfcff7941be05802d on 26496

found linker in maps, base=0x701ef49000,path=/apex/com.android.runtime/bin/linker64
dlclose_addr: 0x701ef7a2d8
pc=0x701d9c1c94 instruction: b140041fd4000001
--------- beginning of main
05-03 20:23:54.309 26496 26496 D pt-injector: injected
05-03 20:24:08.152 26496 26496 D pt-injector: closed

armeabi-v7a

arm 的适配应该是最艰难的。

https://developer.android.com/ndk/guides/abis?hl=zh-cn#v7a

断点

__builtin_trap:

1
defe            udf     #254    ; 0xfe

直接执行并不产生 trap ,而是得到一个 SIGILL :

1
2
3
4
5
6
7
8
u0_a212@192.168.1.144 ~/codes
$ strace ../bkpt_exe
execve("../bkpt_exe", ["../bkpt_exe"], 0xb400007268e0e000 /* 47 vars */ <unfinished ...>
[ Process PID=19460 runs in 32 bit mode. ]
<... execve resumed>) = 0
--- SIGILL {si_signo=SIGILL, si_code=ILL_ILLOPC, si_addr=0x11128} ---
+++ killed by SIGILL +++
Illegal instruction

https://developer.arm.com/documentation/dui0801/h/A32-and-T32-Instructions/UDF
https://stackoverflow.com/questions/11345371/how-do-i-set-a-software-breakpoint-on-an-arm-processor

bkpt #0

1
e1200070        bkpt    0x0000

得到 Trap :

1
2
3
4
5
6
7
8
u0_a212@192.168.1.144 ~/codes
$ strace ../bkpt_exe
execve("../bkpt_exe", ["../bkpt_exe"], 0xb40000775740e000 /* 47 vars */ <unfinished ...>
[ Process PID=19616 runs in 32 bit mode. ]
<... execve resumed>) = 0
--- SIGTRAP {si_signo=SIGTRAP, si_code=TRAP_BRKPT, si_addr=0x110f8} ---
+++ killed by SIGTRAP +++
Trap

此外观察 si_addr 发现两者都不会越过断点。

https://developer.arm.com/documentation/dui0473/m/arm-and-thumb-instructions/bkpt

thumb 模式

arm 上有 arm 和 thumb 两种指令集,根据跳转地址的最后一位来区分并切换 cpu 工作状态。Android 上 armeabi-v7a 默认都是编译为 thumb 指令集,那么 pc 寄存器里面的实际值是什么呢?

使用 debuggerd 观察 32 位 arm 进程的堆栈:

1
2
3
"pal.androidterm" sysTid=24586
#00 pc 0009c2fc /apex/com.android.runtime/lib/bionic/libc.so!libc.so (offset 0x9b000) (__epoll_pwait+20) (BuildId: edc23b5a08cb25fcac190e6392a4d537)
#01 pc 0006dbc9 /apex/com.android.runtime/lib/bionic/libc.so!libc.so (offset 0x6c000) (epoll_wait+16) (BuildId: edc23b5a08cb25fcac190e6392a4d537)

libc.so 的代码是 thumb 指令集的,看起来 pc 寄存器保存的还是实际的地址,也就是只有偶数,而堆栈回溯可以看到奇数地址实际上是因为这个地址不是来自 pc ,而是来自栈上。

……

实际上 Android 大部分进程都运行在 thumb 模式,对于 arm 模式和 thumb 模式,我们需要不同的 shellcode ,而上面生成的是 arm 模式的机器码。

为了确保一致性,我直接用 NDK 编译。

1
2
3
4
5
6
7
enable_language(ASM)

if(ANDROID_ABI STREQUAL "armeabi-v7a")
message("current abi is armeabi-v7a")
add_executable(libshellcode.so shellcode/armeabi-v7a.s)
target_link_libraries(libshellcode.so -nostdlib -static)
endif()

https://stackoverflow.com/questions/22396214/understanding-this-part-arm-assembly-code

下面的代码加上了 .thumb_func 确保生成 thumb 代码。

1
2
3
4
5
6
7
8
9
10
11
        .global _start

.text
.thumb_func
_start:
call_code:
blx r4
break_code:
bkpt #0
svc_code:
svc 0
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
$ arm-linux-gnueabi-objdump -d /mnt/f/works/ptinjector/app/build/intermediates/merged_native_libs/debug/out/lib/armeabi-
v7a/libshellcode.so

/mnt/f/works/ptinjector/app/build/intermediates/merged_native_libs/debug/out/lib/armeabi-v7a/libshellcode.so: file format elf32-littlearm


Disassembly of section .text:

000110f8 <_start>:
110f8: 47a0 blx r4

000110fa <break_code>:
110fa: be00 bkpt 0x0000

000110fc <svc_code>:
110fc: df00 svc 0

$ dd if=/mnt/f/works/ptinjector/app/build/intermediates/merged_native_libs/debug/out/lib/armeabi-v7a/libshellcode.so skip=$((0xf8)) count=16 bs=1c |xxd
00000000: a047 00be 00df 411d 0000 0061 6561 6269 .G....A....aeabi

最终 shellcode (注释的代码为 arm 模式的,暂不考虑支持):

1
2
3
4
5
// const unsigned long CODE_FOR_CALL[] = { 0x34ff2fe1lu, 0x700020e1lu }; // blx r4; bkpt #0
const unsigned long CODE_FOR_CALL[] = { 0xbe0047a0lu }; // blx r4; bkpt #0
// const unsigned long CODE_FOR_SYSCALL[] = { 0x000000eflu }; // svc 0
const unsigned long CODE_FOR_SYSCALL[] = { 0xdf00lu }; // svc 0
// const auto CALL_EXPECTED_OFFSET = 4;

至于进程运行在什么模式,可以通过 cpsr 寄存器的 T 标志(第五位)判断:

https://developer.arm.com/documentation/den0013/d/ARM-Processor-Modes-and-Registers/Registers/Program-Status-Registers

1
#define ARM_IS_THUMB(regs) (((regs).ARM_cpsr & (1 << 5)) != 0)

实践表明,通过 ptrace 修改 cpsr 的 T 标志不会使进程切换模式,因此如果要同时支持 thumb 和 arm ,必须写两套代码。

x86

说到底 32 位就是坑,比起 armeabi-v7 ,还是 x86 更加坑。难怪大家都不想兼容 32 位。

调用约定

x86 上有三种调用约定:stdcall, cdecl, fastcall 。其中只有 fastcall 会通过寄存器传参,而且只是少得可怜的两个。Android 作为 类 unix 系统,应该用 cdecl 。不过说到底这三种规范的区别只是在于栈平衡的维护者到底是 caller 还是 callee ,而我们的注入调用其实不需要纠结维护栈平衡的问题——反正都有备份。

其他架构上我们只要往寄存器传参,基本能满足大部分需求,然而在 x86 ,你需要通过栈传参。

syscall

x86 的 syscall 也非常骚,所有的系统调用都通过 vdso 的 __kernel_vsyscall 执行,比如下面的 poll :

1
2
3
4
5
"main" sysTid=24552
#00 pc 00000b89 [vdso] (__kernel_vsyscall+9)
#01 pc 000cdeb6 /apex/com.android.runtime/lib/bionic/libc.so (__ppoll+38) (BuildId: 57f4eb5e1df9fa8bd21032ad6be9823f)
#02 pc 00083119 /apex/com.android.runtime/lib/bionic/libc.so (poll+105) (BuildId: 57f4eb5e1df9fa8bd21032ad6be9823f)
#03 pc 00027733 /apex/com.android.art/lib/libjavacore.so (Linux_poll(_JNIEnv*, _jobject*, _jobjectArray*, int)+595) (BuildId: c7f6465a601f9e86c20e3630f4fa90e6)

这样就显现出了我们之前做法的弊端。我们之前都是通过 ptrace 替换进程停下来的位置 pc 处指令执行任意代码,一般来说进程都会停在某个长期阻塞的 syscall 上。现在在 x86 上,这个位置总是 vdso ,如果我们替换了这里的指令,然后又调用某个会进行系统调用的函数(显然,dlopen 会进行若干系统调用),就导致函数调用中的 syscall 错误地执行了我们的指令。

使用受控内存?

因此我们需要思考解决方法,一个显然的想法就是:我们需要一块自己管理的内存。

在这种想法下,不管是写入指令、写入数据、调用使用的栈,都在我们所管理的内存上,这样就避免了借用 tracee 程序自身资源导致的问题。

不过我们仍然需要某种方法创建受控内存,比如远程调用 mmap ,如果程序真的需要在 tracee 创建可控的内存区域,必须通过上面的「不安全」方法实现。这样给编码也带来的麻烦:我们需要实现两套代码。

因此在实践这个方法之前,我想首先尝试另一种方法实现函数调用。

模拟 call

上面的想法是对任意进程考虑的,也就是假定 tracee 可能是静态链接的程序,可能无法找到 libc, linker 等我们能利用的代码,而设计的通用方法,我们不得不自己写 shellcode 到进程中。

然而大部分情况下,我们要注入的就是一个动态链接的程序,并且假定它没有对自身或者其他动态链接库进行修改。这时候我们可以利用的东西还是很多的。

也许我们不写 shellcode 也能实现函数调用。

想法是,直接修改 pc 到要调用的函数地址,并且返回地址写一个非法地址,这样函数调用返回的时候就能通过 SIGSEGV 截获。

但是当我真正实现这个方法的时候发现有很多问题。我写了一个用上面的方法调用 mmap 映射一块只读内存的程序,在 x86 和 x64 上测试注入 zygote 的结果:

在 x86 上,当我们安排好栈上的调用参数和返回地址,修改好 pc ,ptrace continue 的时候,下一次停止发现它得到一个 SIGTRAP ,原因是 pc 并没有前进,反而回退了一个字节,刚好是 0xcc 。

在 x64 上,看上去是得到了 SIGSEGV ,但是 pc 回退了 4 个字节,也不是我预定的那个非法地址。此时 single step 不管怎么走都无法前进。

以上两个现象出现在 zygote 中,而 x64 下普通 app 进程这么调用是正常的。x86 尚未测试。

我怀疑是系统调用重启的一些副作用,有待进一步调查。

参考

数据模型(LP32 ILP32 LP64 LLP64 ILP64 ) - lsgxeva - 博客园

Calling convention - Wikipedia

[原创]常见函数调用约定(x86、x64、arm、arm64)-软件逆向-看雪论坛-安全社区|安全招聘|bbs.pediy.com

x86-64 下函数调用及栈帧原理 - 知乎

bionic 的 syscall 实现(不同架构写在不同目录下,使用汇编):

1
bionic/libc/arch-XXXXX/bionic/syscall.S