在前面的章節中相信讀者已經學會了使用Metasploit
工具生成自己的ShellCode
程式碼片段了,本章將繼續深入探索關於ShellCode
的相關知識體系,ShellCode 通常是指一個原始的可執行程式碼的有效載荷,攻擊者通常會使用這段程式碼來獲得被攻陷系統上的互動Shell的存取許可權,而現在用於描述一段自包含的獨立的可執行程式碼片段。ShellCode程式碼的編寫有多種方式,通常會優先使用組合語言實現,這得益於組合語言的可控性。
ShellCode 通常會與漏洞利用並肩使用,或是被惡意程式碼用於執行程序程式碼的注入,通常情況下ShellCode
程式碼無法獨立執行,必須依賴於父程序或是Windows
檔案載入器的載入才能夠被執行,本章將通過一個簡單的彈窗(MessageBox)來實現一個簡易版的彈窗功能,並以此來加深讀者對組合語言的理解。
在編寫ShellCode
之前,我們需要查詢一個函數地址,由於我們需要呼叫MessageBoxA()
這個函數,所以需要獲取該函數的記憶體動態地址,根據微軟的官方定義可知,該函數預設放在了User32.dll
庫中,為了能夠了解壓棧時需要傳入引數的型別,我們還需要查詢一下函數的原型;
在微軟定義中MessageBoxA
函數的原型如下:
int MessageBoxA(
HWND hWnd,
LPCSTR lpText,
LPCSTR lpCaption,
UINT uType
);
引數說明:
需要注意的是,由於我們呼叫的是MessageBoxA
,而此函數為ASCII模式,需要讀者自行修改解決方案,在設定屬性的常規索引標籤,修改字元集(使用多位元組字元集)即可,如下圖所示;
讀者可以通過編寫一段簡單的程式碼來獲取所需資料,首先通過LoadLibrary
函數載入名為user32.dll
的動態連結庫,並將其基地址儲存在HINSTANCE
型別的變數LibAddr
中。然後,使用GetProcAddress
函數獲取 MessageBoxA
函數的地址,並將其儲存在MYPROC
型別的變數ProcAddr
中。最後輸出所需結果;
#include <windows.h>
#include <iostream>
typedef void(*MYPROC)(LPTSTR);
int main(int argc, char *argv[])
{
HINSTANCE LibAddr,KernelAddr;
MYPROC ProcAddr;
// 獲取User32.dll基地址
LibAddr = LoadLibrary("user32.dll");
printf("user32.dll 動態庫基地址 = 0x%x \n", LibAddr);
// 獲取kernel32.dll基地址
KernelAddr = LoadLibrary("kernel32.dll");
printf("kernel32.dll 動態庫基地址 = 0x%x \n", KernelAddr);
// 獲取MessageBox基地址
ProcAddr = (MYPROC)GetProcAddress(LibAddr, "MessageBoxA");
printf("MessageBoxA 函數相對地址 = 0x%x \n", ProcAddr);
// 獲取ExitProcess基地址
ProcAddr = (MYPROC)GetProcAddress(KernelAddr, "ExitProcess");
printf("ExitProcess 函數相對地址 = 0x%x \n", ProcAddr);
system("pause");
return 0;
}
上方的程式碼經過編譯執行後會得到兩個返回結果,如下圖所示,其中User32.dll
的基地址是0x75a40000
而該模組內的MessageBoxA
函數在當前系統中的地址為0x75ac0ba0
,當然這兩個模組地址在每次系統啟動時都會發生幻化,讀者電腦中的地址肯定與筆者不相同,這都是正常現象,之所以會出現這種情況是因為,系統中存在一種ASLR機制。
擴充套件知識:ASLR(Address Space Layout Randomization)機制的核心是用於隨機化系統中程式和資料的記憶體地址分佈,從而增加攻擊者攻擊系統的難度,在啟用了ASLR機制的系統下,每次執行程式時,程式和系統元件(例如DLL、驅動程式等)都會被分配不同的記憶體地址,而不是固定的記憶體地址。這樣可以使得攻擊者難以利用已知的記憶體地址漏洞進行攻擊,因為攻擊者需要先找到正確的記憶體地址才能利用漏洞。ASLR的隨機化是根據作業系統的一些隨機因素進行計算的,例如啟動時間、程序 ID 等等。
由於如上機制的存在,導致user32.dll
模組地址不確定,也就會導致其地址內部的API函數地址也會發生一定的變化,下圖僅作為參考圖;
在獲取到MessageBoxA
函數的記憶體地址以後,我們接著需要獲取一個ExitProecess
函數的地址,這個API函數的作用是讓程式正常退出,這是因為我們注入程式碼以後,原始的堆疊地址會被破壞,堆疊失衡後會導致程式崩潰,所以為了穩妥起見我們還是新增一行正常退出為好。函數ExitProcess
的原型如下:
VOID WINAPI ExitProcess(
UINT uExitCode
);
其中引數uExitCode
指定了程序的退出程式碼,表示程序成功退出或者發生了錯誤。如果uExitCode
為0,表示程序成功退出,其他的非0值則表示程序發生了錯誤,不同的非0值可以用於表示不同的錯誤型別。
既然獲取到了相應的記憶體地址,那麼接下來就需要通過組合來編寫可執行程式碼片段了,在編寫這段程式碼之前,先來了解一下組合語言的呼叫約定,在組合語言中,要想呼叫某個函數,需要使用CALL語句,而在CALL語句的後面,要跟上該函數在系統中的地址,前面我們已經獲取到了相應的記憶體地址了,所以在這裡就可以通過CALL相應的地址來呼叫相應的函數。
我們以32位元應用程式為例,在32位元應用程式內通常使用STDCALL
呼叫約定,它定義了函數在被呼叫時,引數傳遞、返回值傳遞以及棧的使用等方面的規則,該呼叫約定的規則如下所示:
總之,stdcall呼叫約定將引數按照從右到左的順序壓入棧中,由被呼叫者清理棧,返回值儲存在EAX暫存器中,函數呼叫者和被呼叫者都需要遵循一定的棧使用規則。這種約定的好處是引數傳遞簡單,可讀性高,並且在函數返回時棧已經被清理,不需要額外的清理工作。
在實際的程式設計中,一般還是先將地址賦值給eax
暫存器,然後再CALL
呼叫相應的暫存器實現呼叫,比如現在筆者有一個lyshark(a,b,c,d)
函數,如果我們想要呼叫它,那麼它的組合程式碼就應該編寫為:
push d
push c
push b
push a
mov eax,AddressOflyshark // 獲取偏移地址
call eax // 間接呼叫
根據上方的呼叫方式,我們可以寫出ExitProcess()
函數的組合版呼叫結構,如下;
xor ebx, ebx
push ebx
mov eax, 0x76c84100
call eax
接著編寫MessageBox()
這個函數呼叫。與ExitProcess()
函數不同的是,這個API函數包含有四個引數,當然第一和第四個引數,我們可以賦給0值,但是中間兩個引數都包含有較長的字串,這個該如何解決呢?我們不妨先把所需要用到的字串轉換為ASCII碼值,轉換的方式有許多,如下程式碼則是通過Python實現的轉換模式;
import os,sys
from LyScript32 import MyDebug
# 字串轉ascii
def StringToAscii(string):
ref = []
for index in range(0,len(string)):
hex_str = str(hex(ord(string[index])))
ref.append(hex_str.replace("0x","\\x"))
return ref
if __name__ == "__main__":
# 輸出MsgBox標題
title = StringToAscii("alert")
for index in range(0,len(title)):
print(title[index],end="")
print()
# 輸出MsgBox內容
box = StringToAscii("hello lyshark")
for index in range(0,len(box)):
print(box[index],end="")
當Python
程式被執行,則使用者即可得到兩串通過編碼後的字串資料。
MsgBox標題:alert \x61\x6c\x65\x72\x74\x21
MsgBox內容:hello lyshark \x68\x65\x6c\x6c\x6f\x20\x6c\x79\x73\x68\x61\x72\x6b
由於我們使用的是32位元組合,所以上方的字串需要做一定的處理,我們分別將每四個字元為一組,進行分組,將不滿四個字元的,以空格0x20
進行填充,這是因為我們採用的儲存字串模式為棧傳遞,而一個暫存器為32位元,所以就需要填充滿4位元組才可以平衡;
-------------------------------------------------------------
填充 alert
-------------------------------------------------------------
\x61\x6c\x65\x72
\x74\x21\x20\x20
-------------------------------------------------------------
填充 hello lyshark
-------------------------------------------------------------
\x68\x65\x6c\x6c
\x6f\x20\x6c\x79
\x73\x68\x61\x72
\x6b\x20\x20\x20
上方的空位置之所以需要以0x20
進行填充,而不是0x00
進行填充,是因為strcpy
這個字串拷貝函數,預設只要一遇到0x00
就會認為我們的字串結束了,就不會再拷貝0x00
後的內容了,所以這裡就不能使用0x00
進行填充了,這裡要特別留意一下。
接著我們需要將這兩段字串分別壓入堆疊儲存,這裡需要注意,由於我們的計算機是小端序
排列的,因此字元的入棧順序是從後往前不斷進棧的,上面的字串壓棧引數應該寫為:
小提示:小端序(Little Endian)是一種資料儲存方式,在組合語言中,小端序的表示方式與高位位元組優先(Big Endian)相反。例如,對於一個16位元的整數0x1234,它在小端序的儲存方式下,將會被儲存為0x340x12(低位位元組先儲存);而在高位位元組優先的儲存方式下,將會被儲存為0x120x34(高位位元組先儲存)。
-------------------------------------------------------------
壓入字串 alert
-------------------------------------------------------------
push 0x20202174
push 0x72656c61
-------------------------------------------------------------
壓入字串 hello lyshark
-------------------------------------------------------------
push 0x2020206b
push 0x72616873
push 0x796c206f
push 0x6c6c6568
既然字串壓入堆疊的功能有了,那麼下面問題來了,我們如何獲取這兩個字串的地址,從而讓其成為MessageBox()
的引數呢?
其實這個問題也不難,我們可以利用esp
指標,因為它始終指向的是棧頂的位置,我們將字元壓入堆疊後,棧頂位置就是我們所壓入的字元的位置,於是在每次字元壓棧後,可以加入如下指令,依次將第一個字串基地址儲存至eax
暫存器中,將第二個基地址儲存至ecx
暫存器中。
xor ebx,ebx // 清空暫存器
push 0x20202174 // 字串 alert
push 0x72656c61
mov eax,esp // 獲取第一個字串的地址
push ebx // 壓入00為了將兩個字串分開
push 0x2020206b // 字串 hello lyshark
push 0x72616873
push 0x796c206f
push 0x6c6c6568
mov ecx,esp // 獲取第二個字串的地址
上方組合指令完成壓棧以後,接下來我們就可以呼叫MessageBoxA
函數了,其呼叫程式碼如下。
push ebx // push 0
push eax // push "alert"
push ecx // push "hello lyshark !"
push ebx // push 0
mov eax,0x75ac0ba0 // 將MessageBox地址賦值給EAX
call eax // 呼叫 MessageBox
通過上方的實現流程,我們的ShellCode
就算開發完成了,接下來讀者只需要將上方ShellCode
整理成一個可執行檔案並編譯即可。
#include <iostream>
int main(int argc, char *argv[])
{
_asm
{
sub esp, 0x50 // 擡高棧頂,防止衝突
xor ebx, ebx // 清空ebx
push ebx
push 0x20202174
push 0x72656c61 // 字串 "alert"
mov eax, esp // 獲取棧頂
push ebx // 填充00 截斷字串
push 0x2020206b
push 0x72616873
push 0x796c206f
push 0x6c6c6568 // 字串 hello lyshark
mov ecx, esp // 獲取第二個字串的地址
push ebx
push eax
push ecx
push ebx
mov eax, 0x75ac0ba0 // 獲取MessageBox地址
call eax // call MessageBox
push ebx
mov eax, 0x76c84100 // 獲取ExitProcess地址
call eax // call ExitProcess
}
return 0;
}
接下來就是需要手動提取此處組合指令的特徵碼,本案例中我們可以通過x64dbg
中的LyScript
外掛實現提取,首先載入被偵錯程序,然後尋找到如下所示的特徵位置,當遇到Call
時,則通過F7進入到內部,如下圖所示;
如下圖中所示,就是我們所需要的組合指令集,也就是我們自己的ShellCode
程式碼片段,記憶體地址為0x002D12A0
轉換為十進位制為2953888
通過LyScript外掛並編寫如下指令碼,並將EIP位置設定為eip = 2953888
執行這段程式碼;
from LyScript32 import MyDebug
if __name__ == "__main__":
dbg = MyDebug()
dbg.connect()
ShellCode = []
eip = 2953888
for index in range(0, 100 - 1):
read_code = dbg.read_memory_byte(eip + index)
ShellCode.append(str(hex(read_code)))
for index in ShellCode:
print(index.replace("0x","\\x"),end="")
dbg.close()
則可輸出如下圖所示的完整特徵碼,讀者可自行將此處特徵碼格式化;
當然讀者通過在_asm
指令位置設定F9
斷點,並通過F5
啟動偵錯,如下圖所示;
當偵錯程式被斷下時,通過按下Ctrl+Alt+D
跳轉至反組合程式碼位置,並點選顯示程式碼位元組,同樣可以實現提取,如下圖所示;
我們直接將上方的這些機器碼提取出來,從而編寫出完整的ShellCode,最終測試程式碼如下。
#include <windows.h>
#include <stdio.h>
#include <string.h>
#pragma comment(linker,"/section:.data,RWE")
unsigned char shellcode[] = "\x83\xec\x50"
"\x33\xdb"
"\x53"
"\x68\x74\x21\x20\x20"
"\x68\x61\x6c\x65\x72"
"\x8b\xc4"
"\x53"
"\x68\x6b\x20\x20\x20"
"\x68\x73\x68\x61\x72"
"\x68\x6f\x20\x6c\x79"
"\x68\x68\x65\x6c\x6c"
"\x8b\xcc"
"\x53"
"\x50"
"\x51"
"\x53"
"\xb8\xa0\x0b\xac\x75"
"\xff\xd0"
"\x53"
"\xb8\x00\x41\xc8\x76"
"\xff\xd0";
int main(int argc, char **argv)
{
LoadLibrary("user32.dll");
__asm
{
lea eax, shellcode
call eax
}
return 0;
}
上方程式碼經過編譯以後,執行會彈出一個我們自己DIY
的MessageBox
提示框,輸出效果圖如下所示;