在Android中开发eBPF程序学习总结(三)
在研究uprobe的过程中,发现了Linux内核一个好用的功能。
本来是打算研究一下,怎么写uprobe的代码,写好后怎么部署,然后又是怎么和相应的程序对应上的。但是资料太少了,基本上都是写使用bpftrace或者bcc的例子,但是都不是我想要的,后面考虑研究一下bpftrace或者bcc的源码。
不过在这个过程中,却发现了一个Linux系统内置的uprobe插桩的功能。
一般在/sys/kernel/debug/tracing/
目录下,有一个uprobe_events
文件,在Android设备下,没有debug
目录,所以路径一般为: /sys/kernel/tracing/uprobe_events
那么我们怎么通过这个文件进行uprobe插桩呢?
首先,我们写一个测试代码:
1 |
|
一个很简单的,使用C语言开发的Hello World程序,编译一下:$ gcc test.c -o /tmp/test
接着,我们再写一个脚本:
1 |
|
把这个脚本运行起来,接着,我们再开一个终端,运行一下/tmp/test
,随后我们就能看到前一个终端里有输出了:
1 | root@ubuntu:~# /tmp/test.sh |
接下来,我来对这个解释一下,这个过程中我做的事情:
- 首先使用pwntools计算出/tmp/test的main函数的地址
- 因为我的测试环境是arm64的Linux,所以参数寄存器是
x0, x1......
,如果是amd64架构的,参数寄存器就是di, si, dx......
p /tmp/test:$ADDR %x0 %x1
的含意就是在/tmp/test程序的ADDR地址处进行插桩,插入的代码目的是输出第一个参数和第二个参数的值,所以我们可以从结果中看到arg1=0x1 arg2=0xffffe00fb1d8
,也就是说argc=0x1, argv = 0xffffe00fb1d8
- 当我们把上面的语句写入到
uprobe_events
中后,将会在events/uprobes
目录下生成相应的事件目录,默认情况下是以p_(filename)_(addr)
的形式命名,所以,在当前测试环境中,这个目录的路径为:/sys/kernel/debug/tracing/events/uprobes/p_test_0x76c/
- 把1写入到上面这个目录的enable文件中,表示激活该事件,接着就是把1写入到
tracing_on
,激活内核的日志跟踪功能。 - 最后,我们就能从
/sys/kernel/debug/tracing/trace_pipe
目录中看到相关的输出了。
再来看看输出的数据格式:
1 | test-3326935, 监控到的程序名-该程序的pid |
当我发现Linux内核功能,我是很惊讶的,竟然能这么容易的监控到任意程序的指定地址的信息,就是不知道对于一个程序来说,是否能发现自己被uprobe插桩了。
接着,我就继续深入的研究了该功能,看看使用场景如何。
自定义事件名
事件名我们是可以自定义的,比如,我只要把事件语句改为:"p:test_main /tmp/test:$ADDR %x0 %x1"
。
那么事件名就为test_main
了,生成的相应目录就是/sys/kernel/debug/tracing/events/uprobes/test_main/
。
输出字符串
通过研究发现,可以使用-/+
加上offset
,加上(addr)
来输出指定地址的内存,然后加上:type
来指定该数据的类型,并且该操作是可以嵌套的,所以是可以输出任意类型的结构体的。
比如我把事件语句改为: p:test_main /tmp/test:$ADDR %x0 %x1 +0(%x1):x64 +0(+0(%x1)):string
我们可以看看现在的输出:
1 | root@ubuntu:~# /tmp/test.sh |
0xffffff3cfef8
地址的内存为0xffffff3d06ea
,而0xffffff3d06ea
地址的内容为字符串:/tmp/test
,也就是argv[1]
的内容了。
返回值插桩
事件语句的开始是p,表示对当前地址进行插桩,但是如果换成r,那么就是对返回地址进行插桩,比如:r:test_main /tmp/test:0x7d4 %x0
0x7d4
为main函数的ret指令的地址,然后得到的输出为:
1 | /tmp/test.sh "r:test_main /tmp/test:0x7d4 %x0"1 |
数据中多了一个:从当前地址0xaaaac4fa07d4
要返回到地址0xffffa1239e10
。
libc库插桩
libc库的插桩跟普通程序没啥区别,比如,一般https请求都是通过SSL_write
和SSL_read
来进行对明文的读写,从socket抓包,抓到的肯定是看不懂的密文。但是从SSL_write
和SSL_read
的第二个参数来抓取,得到的就是明文了。
我们来测试一下,一般curl使用的库都是:/lib/aarch64-linux-gnu/libssl.so.1.1
。
所以我们首先需要使用pwntools从这个libc库中获取到SSL_write
和SSL_read
的地址,但是SSL_read又不同,因为函数入口点buf数据是无用的,需要该函数调用结束后,里面才有有效数据,但是在ret返回的时候,没有寄存器储存buf的地址,目前也没找到办法在函数入口的地方定义一个变量,然后返回的时候再取。
接着,我把libssl.so丢入了ida,找到了SSL_read
函数:
1 | __int64 __fastcall SSL_read(__int64 a1, __int64 a2, int a3) |
通过SSL_read
函数,我找到了sub_34830
函数:
1 | __int64 __fastcall sub_34830(__int64 a1, __int64 a2, __int64 a3, _QWORD *a4) |
查看调用ctx->method->ssl_read
的汇编代码:
1 | .text:00000000000348A4 loc_348A4 ; CODE XREF: sub_34830+68↑j |
我们能发现,buf被储存在了X22寄存器里,然后当调用完ctx->method->ssl_read
,这个时候X22寄存器里就是有效的明文数据了,所以我们可以把uprobe插在0x348C4
,然后我们以字符串输出寄存器X22,这就是明文数据了。
最后我们可以得到以下事件语句:
1 | ADDR=`python3 -c 'from pwn import ELF,context;context.log_level="error";e=ELF("/lib/aarch64-linux-gnu/libssl.so.1.1");print(hex(e.symbols["SSL_write"]))'` |
然后启动我们的脚本,再另一个终端里使用curl访问百度,我们可以得到以下输出:
1 | root@ubuntu:~# /tmp/test.sh |
实际应用场景
普通程序
Android设备上的ssl库是/system/lib64/libssl.so
,如果使用该库,那么uprobe插桩的思路跟上面的例子讲的一样。
某信APP
研究中发现,插桩了libssl.so
,但是却没有办法得到Chrome或者某信的流量。经过一番研究,我发现了这篇文章:自动定位webview中的SLL_read和SSL_write
原来某信用的是webview,其libc位于:/data/data/com.xxxx/app_xwalk_4317/extracted_xwalkcore/libxwebcore.so
随后就把这个libc掏出来,丢入IDA,根据上面这篇文章中所说的,去定位SSL_write
和SSL_read
。
然后就能成功获取到流量了:
1 | ./uprobe_test.sh |
解密某信通信流量
上面的例子中,能抓到的都是在某信中访问HTTPS网页的流量,那发消息的流量呢?经过我一番搜索,发现其通信流量是使用Java_xxx_MMProtocalJni_pack
函数来加密的,但是相关资料很少,估计都被公关掉了。
我就自能自行逆向了,但是没有调试环境,这代码也很难逆,就在我陷入僵局的时候,我发现了一个compressBound
函数,再其之后还有一个compress2
函数:
1 | ...... |
然后我就在该函数下插入uprobe,打印a1变量,果然,这个就是我们发送的消息的明文:
1 | 比如我向好像发送`Test123`消息,可以看到: |
其他
Linux内核自带的uprobe事件,可以让我们不需要写任何代码,就监控系统用户态的函数调用,打印数据,功能虽然单一,但十分强大。也就是说,最开始我是想研究ecapture,现在如果我想实现类似的功能,我根本不需要写bpf程序,也不需要安卓高版本内核,不需要/system目录的可写权限,只需要一个root用户的权限,我就能监控任意程序的代码了。
后续我考虑研究是否能对其进行扩展,还有,我们自己写的uprobe是如何加载的。
参考
在Android中开发eBPF程序学习总结(三)