1.4 編寫簡易ShellCode彈窗

2023-07-02 12:00:27

在前面的章節中相信讀者已經學會了使用Metasploit工具生成自己的ShellCode程式碼片段了,本章將繼續深入探索關於ShellCode的相關知識體系,ShellCode 通常是指一個原始的可執行程式碼的有效載荷,攻擊者通常會使用這段程式碼來獲得被攻陷系統上的互動Shell的存取許可權,而現在用於描述一段自包含的獨立的可執行程式碼片段。ShellCode程式碼的編寫有多種方式,通常會優先使用組合語言實現,這得益於組合語言的可控性。

ShellCode 通常會與漏洞利用並肩使用,或是被惡意程式碼用於執行程序程式碼的注入,通常情況下ShellCode程式碼無法獨立執行,必須依賴於父程序或是Windows檔案載入器的載入才能夠被執行,本章將通過一個簡單的彈窗(MessageBox)來實現一個簡易版的彈窗功能,並以此來加深讀者對組合語言的理解。

1.4.1 尋找DLL庫函數地址

在編寫ShellCode之前,我們需要查詢一個函數地址,由於我們需要呼叫MessageBoxA()這個函數,所以需要獲取該函數的記憶體動態地址,根據微軟的官方定義可知,該函數預設放在了User32.dll庫中,為了能夠了解壓棧時需要傳入引數的型別,我們還需要查詢一下函數的原型;

在微軟定義中MessageBoxA函數的原型如下:

int MessageBoxA(
  HWND hWnd,
  LPCSTR lpText,
  LPCSTR lpCaption,
  UINT uType
);

引數說明:

  • hWnd:訊息方塊的父視窗控制程式碼。
  • lpText:訊息方塊中顯示的文字。
  • lpCaption:訊息方塊的標題列文字。
  • 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值可以用於表示不同的錯誤型別。

1.4.2 探討STDCALL呼叫約定

既然獲取到了相應的記憶體地址,那麼接下來就需要通過組合來編寫可執行程式碼片段了,在編寫這段程式碼之前,先來了解一下組合語言的呼叫約定,在組合語言中,要想呼叫某個函數,需要使用CALL語句,而在CALL語句的後面,要跟上該函數在系統中的地址,前面我們已經獲取到了相應的記憶體地址了,所以在這裡就可以通過CALL相應的地址來呼叫相應的函數。

我們以32位元應用程式為例,在32位元應用程式內通常使用STDCALL呼叫約定,它定義了函數在被呼叫時,引數傳遞、返回值傳遞以及棧的使用等方面的規則,該呼叫約定的規則如下所示:

  • 引數傳遞:引數從右向左依次壓入棧中,由被呼叫者在返回前清理棧。
  • 返回值傳遞:函數返回時將返回值儲存在EAX暫存器中。
  • 棧的使用:函數被呼叫前,呼叫者將引數壓入棧中;被呼叫者在返回前清理棧,以確保棧的平衡。
  • 函數呼叫:在呼叫函數之前,呼叫者將返回地址(Return Address)和EBP暫存器的值儲存在棧中,並將ESP暫存器指向參數列的最後一個元素;在函數返回之後,呼叫者通過將之前儲存的EBP和返回地址彈出棧中,並將ESP暫存器恢復到最初的位置來恢復棧的狀態。

總之,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

1.4.3 ShellCode提取與應用

通過上方的實現流程,我們的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;
}

上方程式碼經過編譯以後,執行會彈出一個我們自己DIYMessageBox提示框,輸出效果圖如下所示;