什么是“倒著跑”的程序?
正常的程序都是正著跑的:CPU 里有一個程序計數器(PC),用來記錄當前正在執行的指令的地址。執行完 PC 處的指令后,CPU 會接著執行 PC+1 處的指令。
而倒著跑的程序指的是:執行完 PC 處的指令后,程序回過頭去執行 PC-1 處的指令。如果你反匯編這個程序,你會看到程序中機器指令的序列也是完全倒過來的,比如程序看起來會先調用函數,后傳參。
等等,這樣的程序是真實存在的嗎?
是的,而且我真的寫了一個這樣的 x86-64 程序,還把它出成了一道 CTF 逆向題!
你可以下載這道題的附件:
然后反匯編看一下:
IDA 反匯編的結果
正著看確實狗屁不通,反過來理解卻合情合理。最主要的是,這個 Linux 程序真的能在 x86-64 CPU 上跑起來!你可以自己找臺 20.04 的服務器嘗試一下。
這個程序真的能跑
那么,這個程序是怎么實現的呢?
緣起
某天刷知乎的時候看到了這個回答:
具體的各種爭奪手法差不多可以寫本書了,這里就舉一個有趣的加密手法:
利用int3讓內存里的程序倒序執行。或者說倒序才是正確的打開方式。
本來第n條指令執行完畢之后應該執行第n+1條指令,加密程序利用int3在執行完第n條指令之后,把命令指針改成n-1,從而實現倒序執行。
靜態分析,內存里的代碼簡直狗屁不通。
動態跟蹤你就得去截int3。可你一篡改int3,程序就沒法倒序執行了,結果就更是狗屁不通了,從而達成防止破解的目的。
我覺得很有意思,剛好當時在籌備下一屆 ,于是決定把這個思路出成一道逆向,雖然我自己完全不會逆向(遺憾)。
但讓一個合法的 x86-64 程序倒序執行,并不是一個簡單的事情,我們需要考慮很多問題,包括:
好在思考了一段時間,外加一通 STFW 之后,我還是找到了一種可行的實現方法。
實現思路倒序執行
如何倒序執行程序呢?
首先,負責執行程序的是 CPU。而據我所知,世界上的絕大多數 CPU,都只會按照 PC 遞增方向執行代碼(控制轉移指令除外),同時也不提供更改代碼執行方向的配置選項。
不過并不是說這個事是解決不了的,或者說,既然 CPU 本身沒辦法解決這個事,我們也許可以換種思路:
讓 CPU 單步執行代碼。使用軟件接管單步執行的流程。在一次單步執行結束后,修改 PC,使其指向上一條指令而非下一條。繼續單步執行。
好,之前的問題解決了,但我們又多出三個問題:
如何讓 CPU 單步執行代碼?如何使用軟件接管單步執行的流程?如何修改 PC 使其指向上一條指令?
我們的目標是寫出一個倒著跑的 x86-64 程序,而在 x86 上,CPU 恰好為我們提供了一種單步執行的機制:trap flag,確切的說是 flags// 寄存器的第 8 位。
8086 的 flags 寄存器,第 8 位為 TF
在設置 trap flag 之后,CPU 會進入單步模式,每執行一條指令觸發一次異常,之后進入異常處理程序。而運行于 CPU 之上的操作系統,會根據異常原因識別出這一行為,并且通知當前執行的進程發生了 trap。
在 Linux(或者說 POSIX)中,這種通知機制由 機制實現。發生 trap 時,程序會收到 信號。如需自行處理該信號,程序可注冊對應的 。
不過,一般情況下, 會由調試器觸發,比如你正在單步調試。此時程序內是沒有任何 處理這個信號的,調試器會使用 來處理被調試進程的 。
所以,為了實現(至少是部分代碼的)倒序執行,我們可以在程序啟動后設置 的 ,然后設置 trap flag,之后在 中處理更新 PC 的邏輯。
那么,如何在 中,將 PC 指向上一條指令呢?
仔細閱讀 Linux 手冊中和 相關的部分,例如 ,其中提到 具備三個參數,其中第三個參數 的描述如下:
This is a to a , cast to void *.
The to by this field that was saved on the user-space stack by the ; for , see (2). about the can be found in (3) and (7).
, the doesn't make any use of the third .
也就是說,我們可以借助 ,修改由內核保存的,和該進程相關的上下文(),其中自然包括了所有寄存器的值。在 x86-64 中,我們只需要修改 rip( )寄存器,即可控制程序的 PC,很簡單吧!
然而,上帝這個 byd 在為你打開一扇窗之后,就必然會關閉一扇門。在 x86-64 下,我們還需要考慮另一個問題:作為古老的 CISC 架構,x86-64 的指令是變長的,我們沒辦法簡單地通過“將 rip 的值減去固定數值”的方式,讓程序倒序執行。更蛋疼的是,x86-64 的指令編碼是一種前綴碼,你只能通過從前往后掃描來確定指令的邊界,從后往前掃是不行的。
x86 的變長指令編碼
也許這個問題有很多種解決方式,比如往程序里嗯塞一個 x86-64 (如 iced),或者利用 CPU 來幫我們完成一部分譯碼的任務(其實我沒想好這樣要怎么搞)。但在權衡實現的復雜性之后,我還是決定用一種非常簡單粗暴的方式來解決這個問題:打表。
當然,為了進一步簡化實現,縮小程序的體積,我們可以把這個表壓縮成位圖():也就是說,指定一串數據,其中的每一位對應程序代碼段的每一個字節。如果某個字節是某條指令的開頭,就將位圖里對應位設為 1,否則設為 0。這個位圖可以提前用其他方式生成,然后嵌入到程序中。
在需要更新 rip 的時候,我們可以根據 rip 目前的值,找到位圖中的對應位。需要注意的是,在設置 trap flag 的情況下,CPU 執行完一條指令之后,會將 rip 指向下一條指令,然后再觸發異常,進入 。所以我們需要在位圖中找到 rip 對應指令之前第二條指令的開頭,并將 rip 設為對應的地址。
處理控制流
經過一通折騰,我們終于找到了一種可行的方法,將整個順序執行的指令流變成倒序執行。不過,現實世界的程序中,幾乎不可能只有順序執行這一種情況,還會出現分支、跳轉、函數調用/返回等各類控制流轉移的情況。怎么處理這類情況呢?
其實我們剛剛提出的處理方式已經可以完全兼容控制流了,只需要做出一些小小的改動。考慮以下情況:
inst1
inst2
jump label1
inst3

inst4
label1:
inst5
inst6
如果我們把它簡單地連 label 一起倒過來:
inst6
inst5
label1:
inst4
inst3
jump label1
inst2
inst1
然后按照我們剛剛的思路,從 inst1 開始倒序執行,你會發現:執行完 jump 之后,rip 會指向 inst4。接著進入 , 會將 rip 前移兩條指令,也就是指向 inst6,然后開始執行。
但程序原本的執行流程應該是,遇到 jump 之后,接著從 inst5 開始執行。為了實現這樣的效果,我們只需要把 label 往后挪一個指令:
inst6
inst5
inst4
label1:

inst3
jump label1
inst2
inst1
這樣,倒序執行時的執行流就和順序時完全一致了。這種處理方式對于分支,函數調用等等其他控制流指令也同理。不過需要注意以下的情況:
func:
inst1
inst2
inst3
call func
倒過來之后,我們需要往開頭(或者說結尾)加兩條別的指令,然后才能把 func label 挪下去:
call func
inst3
inst2
inst1
nop
func:
nop
當然如果沒出錯直接用二進制寫程序,這兩條指令永遠都不會被執行到,所以其實把它們換成任何指令都是可以的。
自動化
首先,很顯然,人肉寫匯編的話,按照上面我們討論的思路手搓一個倒序執行的程序,是完全可行的。
然而,作為一個能寫腳本就決不躬親的極致懶狗,讓我手搓還不如把我揚了——而且這樣一點也不酷。跑個自動化腳本,啪的一下,很快啊,你寫的源代碼直接就變成一個倒著執行的二進制了,這才符合我對計算機的想象,科技并帶著趣味。
所以我是怎么實現自動化的呢?首先我決定用 C 來編寫這樣的程序——其實用什么語言無所謂了,不過 C 會省心很多。我將前文提到的若干內容封裝成了頭文件,包括:
任何需要倒序執行的程序,只需 上述頭文件即可,非常省事。
接下來是負責把程序生成的機器指令倒過來的部分,這部分我選擇用以下方法實現:
首先 gcc -S 輸出 C 程序對應的匯編。然后寫個腳本,按照前文提到的邏輯把匯編倒過來,同時修改所有 label 的位置。指定使用修改過的鏈接器腳本鏈接這個匯編程序,以便 C 里判斷代碼段范圍的邏輯生效。
最后,我們需要生成 ,然后將其嵌入程序。這里我偷個懶,用 掃一遍剛剛得到的二進制,然后用腳本簡單解析一下輸出,生成一個 。最后用 定位二進制內 符號的偏移量,把 寫到二進制文件即可。
相關工具及用法
好消息:我開源了剛剛提到的自動化腳本。
如果你也像我一樣閑,希望搞一個能倒著跑的 x86-64 Linux 程序的話,可以直接參考這套實現:
具體來說,你需要:
下載 rev.h。寫一個普通的 C 語言程序。注意這個實現暫不支持多文件(當然你感興趣的話可以改改),所以你必須把代碼寫在一個文件里。在程序的開頭 rev.h。下載 .py,執行 ./.py 你寫的C文件 編譯程序。程序會保存在當前目錄,運行一下看看吧!
說實話這個腳本寫的有點直接用二進制寫程序,里面依賴了很多 gcc、ld、 和 的特性。而且,我只測試了給 出題用的那個程序(以及其他簡單的例子),對此這個腳本是可以正常工作的,但我不保證對于其他的 C 程序,這個腳本還能生成一個能跑的 ELF。
為了避免這個腳本在你的環境上崩潰,我還提供了一個 。你可以執行(請確保你的 CPU 是 x86-64 架構):
docker build -f Dockerfile路徑 -t rev
然后在生成的鏡像里跑這個腳本,或者測試腳本生成的程序。
你可以用它做什么?
玩。