Pwn作為CTF比賽中的必考題型,用一句話概括就是難以入門。
Pwn確實難,難點主要在於知識點不系統、考點太高深、對新手不友好。
其實只要我們多做多練,了解出題套路,掌握答題思路,還是能夠快速入門的。
i 春秋論壇作家「SkYe231」表哥總結了幾種Pwn入門棧溢出題目的解題思路,旨在為大家提供更多的學習方法與技能技巧,文章僅供學習參考。
PS:棧溢出入門題目可能不局限下面總結的,諸如利用棧溢出修改局部變量觸發某些條件getshell等沒有總結到位,感興趣的小伙伴可以在i春秋官網進行系統學習。
Web端看課體驗更佳,看課地址:https://www.ichunqiu.com/newRelease/webaqgcs

ip根據題目位數不同分別代指eip或rip ,bp及sp同理。以下小結均是基於程序為動態鏈接。
完整后門&溢出長度大於等於ip
非常基礎入門題目,用於了解學習棧溢出控制寄存器ip ,關鍵代碼如下:
int vul() { char s; // [esp+Ch] [ebp-6Ch] puts("input:"); gets(&s); //棧溢出漏洞,溢出長度不限 return puts("OK,Bye!"); }
通過分析找到后門函數,且這個后門是一個完整可用的后門,就是只要我們調用這個后門函數,不需要其他額外的操作就能getshell,通常是system('/bin/sh'),write(1,'flag',32)等形式。
int getshell() { return system("/bin/sh"); }
思路:
有完整可用后門的情況下,只要溢出長度能夠覆蓋ip即可。直接利用棧溢出將getshell函數地址寫入到eip位置,結束當前函數后就會去運行getshell函數payload結構:
payload = [填充] + [get_shell_addr]
殘缺后門&溢出長度遠大於ip
棧溢出漏洞函數如下(和上一個小結一樣):
int vul() { char s; // [esp+Ch] [ebp-6Ch] puts("input:"); gets(&s); //棧溢出漏洞,溢出長度不限 return puts("OK,Bye!"); }
這里指的遠大於ip,意思是可以溢出覆蓋除了ip以外的3個以上的(一般情況,實際情況實際分析)機器字長(32位4 bit;64位8bit)。
殘缺后門的形式多種多樣:提供諸如system等關鍵函數;flag、/bin/sh等字符串;某些gadget等。這里就整理前兩種情況,剛好對應上面的完整后門。
提供關鍵函數&字符串
一般給出的是system函數,在ida中一般呈現形式:
int getshell() { return system("echo no no no!!!"); }
這樣就能通過system.plt調用system函數。
字符串/bin/sh可以在ida中字符串界面可以查詢到(shift+f12),存放在bss區,在源碼中通過定義變量來提供。通過地址來調用這個字符串參數。
思路:
對比完整后門發現這里是將函數和參數分割,但是兩者都在程序中,所以可以通過棧溢出自行構造完整后門,這里就涉及傳參方式方面知識:32位棧傳參;64位前6個參數寄存器傳參,后續更多參數遵循棧傳參。
自行構造完整后門(調用鏈)對溢出長度有要求了,至少要能溢出除ip以外再多2個機器字長,用於寫入調用步驟。
32位程序:
payload=[填充]+[system@plt]+[4bit填充]+[參數]
64位程序:
64位系統寄存器傳參需要用到gadget,可以使用ROPgadget查找程序乃至libc庫(需要泄露基地址)。一般來說前兩個參數rdi rsi的gadget比較常見容易得到的。
payload=[填充]+[pop_rdi_ret]+[參數]+[system@plt]
提供關鍵函數
關鍵函數不單單指的system、puts、read、write等我們用於寫入讀取的輔助函數一定程度上也算是關鍵函數,但是為了方便總結這里的關鍵函數指的是system這類函數。
system函數在ida中呈現方式同上小結。與上面小結相比缺少的是字符串/bin/sh,對比完整后門來說就只剩下system。
這里結合上面小結對比缺少一個/bin/sh,那就先構造一個輸入的利用鏈將字符串輸入到內存,然后再和上面一樣構造system利用鏈,這里就涉及ROP利用思想。system利用鏈不再重復,與上面小結一致。輸入利用鏈構根據題目出現輸入函數不同而實際分析,一般用的都是read,也有scanf、gets 。
根據溢出長度不同還能細分為兩種情況:一次性利用鏈;ROP利用鏈。
一次性利用鏈
這種情況適用於溢出長度比較大,一般能有7個機器字長左右,具體情況具體分析。這種利用的思想是用輸入函數read將/bin/sh寫入到bss段后調用system('/bin/sh') 。
32位程序:
payload=[填充]+[read@plt]+[system@plt]+[參數1]+[binsh寫入地址]+[參數3]+[binsh寫入地址]
64位程序很少用,因為寄存器傳參需要用到gadget,這樣會導致需要非常大的溢出空間,構造起來和32位思想一樣不再重復。
ROP利用鏈
和一次性利用鏈思想上最大區別就是運行完自構寫入函數后,返回main(或其他)函數,相當於讓程序重新運行一次,有第二次利用漏洞機會,寫入getshell利用鏈。
相比之下優勢:payload長度變短,有效應對溢出長度不足情況。
32位程序:
payload1=[填充]+[read@plt]+[main]+[參數1]+[binsh寫入地址]+[參數3] payload2=[填充]+[system@plt]+[4bit填充]+[binsh寫入地址]
64位程序:
pop_rdi都會有單獨的gadget,rsi多數以pop_rsi_r15形式出現,rdx少有gadget,這里假設gadget情況如下,實際做題實際調整:
- pop_rdi_ret
- pop_rsi_r15_ret
- pop_rdx_ret
payload1=[填充]+[pop_rdi]+[參數1]+[pop_rdi_r15]+[binsh寫入地址]+[8bit填充]+[pop_rdx]+[參數3]+[read@plt] payload2=[填充]+[pop_rdi]+[binsh寫入地址]+[system@plt]
可以看到payload1還是長的,且rdx gadget不一定有。這是可以嘗試不傳第三個參數,用寄存器rdx的原值,這個值有可能符合需要(若需要控制前三個參數了解一下ret2csu),payload簡化如下:
payload1=[填充]+[pop_rdi]+[參數1]+[pop_rdi_r15]+[binsh寫入地址]+[8bit填充]+[read@plt] payload2=[填充]+[pop_rdi]+[binsh寫入地址]+[system@plt]
提供字符串
就是給/bin/sh,其實這樣等於沒給,需要用到的函數都需要去libc中找,等同於沒有后門,故這里不小結,思路和下面無后門一樣。
無后門&溢出長度遠大於ip
從這種題目開始就接近實際題目了。首先是無后門,也就是既沒有system也沒有/bin/sh等條件,只有一個棧溢出的漏洞。這個小結中溢出長度定義是遠大於ip,就繼續沿用上面的漏洞函數gets,實際中更多的是用read函數輸入。
int vul() { char s; // [esp+Ch] [ebp-6Ch] puts("input:"); gets(&s); //棧溢出漏洞,溢出長度不限 return puts("OK,Bye!"); }
思路:
既然程序中沒有system就到libc中找,也就是需要泄露libc基地址。方法是泄露一個位於libc中的函數的真實地址,將真實地址減去函數偏移得到基地址。/bin/sh也能在libc中找到不再贅述。
泄露地址需要一個(程序中出現過的)輸入函數,常見的有puts、write等。泄露完成后需要第二次利用漏洞的機會,所以也就是涉及ROP利用思想。
32位程序:
puts
payload1=[填充]+[puts@plt]+[main]+[某函數@got]
payload2=[填充]+[system@libc_addr]+[4bit填充]+[binsh@libc_addr]
write
payload1=[填充]+[puts@plt]+[main]+[某函數@got]
payload2=[填充]+[system@libc_addr]+[4bit填充]+[binsh@libc_addr]
64位程序:
puts
payload1=[填充]+[pop_rdi]+[某函數@got]+[puts@plt]+[main]
payload2=[填充]+[pop_rdi]+[binsh@libc_addr]+[system@libc_addr]
write
穩妥來說需要控制3個寄存器:write(1,[輸出地址],[輸出長度]) 。如果找不到rdx的gadget和[上面小結](#ROP利用鏈)一樣嘗試看看rdx原值是否符合需求,如果要不滿足就得用ret2csu技巧控制前三個寄存器。
無后門&溢出長度等於ip
溢出只能夠剛好覆蓋完ip就不能再溢出更多字節,顯然是不能寫入完整的利用鏈,這種情況可以考慮棧遷移。棧遷移就是將原本寫在ip后面的利用鏈寫到其他地方,通過控制bp、ip將棧遷移到利用鏈的位置,運行提前布置的利用鏈。
首先這種題目會有一到兩次輸入的機會,輸入內容可能會是局部變量或者全部變量(bss段)。棧遷移需要明確的遷移地址,基於存放位置不同難度也不同:
全局變量
寫到bss段就比較簡單了,遷移地址直接到ida查全局變量地址即可。
局部變量
局部變量在棧上,棧地址是動態的,所以要先泄露棧地址才能進行棧遷移,但是也有用sub esp,xx這種神奇gadget將棧抬高的操作。如果用遷移到棧上一般會有兩次輸入一次,一次是泄露棧地址,一次是寫入利用鏈。
需要注意一點:假如寫入地址和遷移地址為addr,有效利用鏈需要從addr+一個機器周期開始寫入,具體自行調試查看sp變化。
思路
為了便於總結假設一個32位程序有兩次輸入,第一次向全局變量var_1(地址為:addr)寫入,第二次向局部變量s寫入。
// 32位程序 int vul() { char s; // [esp+Ch] [ebp-6Ch] puts("input:"); gets(&var_1); //棧溢出漏洞,溢出長度不限 puts("input:"); read(0,&s,0x74); //棧溢出漏洞,溢出長度有限 return puts("OK,Bye!"); }
提前用ROPgadget找到leave;ret gadget,然后構造payload如下:
payload1='a'*4+p32(puts@plt)+p32(main)+p32(puts@got) payload2='a'*0x6c+p32(addr)+p32(leave;ret)
泄露地址后返回main函數,第二次運行程序。后續getshell可以用onegadget或者system(/bin/sh)。溢出ip為onegadget就能getshell。system(/bin/sh)需要再次棧遷移。
onegadget
payload3=[任意內容] payload4='a'*0x6c+'a'*4+p32(onegadget)
system(/bin/sh)
payload3='a'*4+p32(system@libc_addr)+p32(main)+p32(binsh@libc_addr) payload4='a'*0x6c+p32(addr)+p32(leave;ret)
對於32位程序如果輸出函數只有write影響不大,而64位程序如果輸出函數只有write沒有puts等少參數的函數,可能會比較棘手,原因還是在於第三個參數rdx缺少gadget,需要實際調試查看rdx的原值是否需求,如果不符合需要賦值就要ret2csu,將csu利用鏈寫到遷移地址上。
總結
這里歸納了幾種入門題目常見套路,但是套路遠遠不局限於上文,比如棧溢出修改局部變量,繞過PIE保護等等,后續會分享相關內容,大家敬請關注。