柚子快報(bào)邀請(qǐng)碼778899分享:Rust逆向?qū)W習(xí) (1)
柚子快報(bào)邀請(qǐng)碼778899分享:Rust逆向?qū)W習(xí) (1)
文章目錄
Hello, Rust Reverse0x01. main函數(shù)定位0x02. main函數(shù)分析line 1line 2line 3line 4~9
0x03. IDA反匯編0x04. 總結(jié)
近年來,Rust語(yǔ)言的熱度越來越高,很多人都對(duì)Rust優(yōu)雅的代碼和優(yōu)秀的安全性贊不絕口。對(duì)于開發(fā)是如此,對(duì)于CTF也是如此,在逆向題和pwn題中都有出現(xiàn)。從本文開始我們將開始進(jìn)行Rust逆向的學(xué)習(xí),筆者將盡可能通過現(xiàn)有的IDA(7.7版本)對(duì)Rust ELF文件中包含的特性進(jìn)行分析與總結(jié),盡可能地減少Rust逆向的難度,盡可能地解決分析過程中產(chǎn)生的每一個(gè)問題,最終爭(zhēng)取達(dá)到能夠通過IDA反匯編結(jié)果還原Rust代碼的程度。
本系列將跟隨《Rust權(quán)威指南》的學(xué)習(xí)路線完成Rust逆向工程的學(xué)習(xí)。
閱讀本文前,建議首先掌握:
? x86-64逆向的基礎(chǔ)知識(shí)? Rust語(yǔ)言的基本使用
Hello, Rust Reverse
首先我們寫一個(gè)流程較猜數(shù)字稍簡(jiǎn)單一些的Rust程序,完成Rust ELF的第一次分析。 以下是Rust源碼:
use std::io;
fn main() {
let mut input: String = String::new();
io::stdin().read_line(&mut input).expect("Read Error!");
let mut num = input.trim().parse().expect("Input not a number!");
println!("{}", match num {
1 => "one",
2 => "two",
x if x < 10 => "Something smaller than 10",
_ => "Something not smaller than 10"
});
}
使用cargo build編譯后將ELF文件放入IDA中進(jìn)行分析。這個(gè)ELF文件沒有去除符號(hào)表,便于分析。
0x01. main函數(shù)定位
反匯編完成后,可以看到,左邊欄的函數(shù)名大多很長(zhǎng),但也有一些規(guī)律可循。定位到main函數(shù)發(fā)現(xiàn),main函數(shù)本身只有很少的幾行代碼,但Rust真正的main函數(shù)也不難找。看到0xA020處有一個(gè)main函數(shù),這個(gè)項(xiàng)目筆者將其命名為revlab,而這個(gè)函數(shù)名中也正好就有revlab,因此可以推測(cè)出,這就是我們要找的Rust main函數(shù)。
但我們可以先不急著查看main函數(shù)的具體內(nèi)容,單是這個(gè)main函數(shù)名就有一番研究的必要。_ZN6revlab4main17h512e681518e409c2E,這是Rust編譯器賦予我們自己的main函數(shù)的函數(shù)名。有沒有覺得這個(gè)函數(shù)名的命名規(guī)則很熟悉呢?沒錯(cuò),這種函數(shù)命名方式被稱為name mangling,與C++編譯器對(duì)函數(shù)的命名規(guī)則類似。這里參考資料。我們就可以將這個(gè)函數(shù)名進(jìn)行簡(jiǎn)單的翻譯:revlab::main,前面的_ZN是固定開頭,6代表下一個(gè)模塊的名字長(zhǎng)度,也就是后面的revlab,4相同,即解析main,17h后面是函數(shù)的哈希值,可以忽略。這里通過左邊欄可以看到,IDA能夠自動(dòng)為我們完成函數(shù)名的解析。
0x02. main函數(shù)分析
別看我們第一次寫的main函數(shù)只有短短的幾行,轉(zhuǎn)換成匯編之后卻有點(diǎn)讓人頭疼??紤]到這是我們第一次進(jìn)行分析,筆者嘗試借助其他的工具輔助分析——傳送門。這個(gè)網(wǎng)站可以幫助我們將源代碼與匯編代碼對(duì)應(yīng)起來,幫助我們進(jìn)行分析。
可以看到,main函數(shù)的匯編邏輯還是比較復(fù)雜的,這也是Rust ELF的一個(gè)特點(diǎn),使得Rust反匯編較C/C++更難。
line 1
第一行定義了一個(gè)字符串變量,使用String::new()方法。但是在匯編中可以發(fā)現(xiàn),call調(diào)用String::new()函數(shù)并沒有對(duì)返回值進(jìn)行操作,而是將rdi進(jìn)行了賦值,這與C語(yǔ)言不同,如果按照C語(yǔ)言的邏輯,則更像是String::new(&input)。隨后,筆者修改了代碼進(jìn)行試驗(yàn),發(fā)現(xiàn)Vec的new方法流程類似??梢姼鱾€(gè)對(duì)象的new方法實(shí)際上是傳了參的。
line 2
第二行就比第一行熱鬧多了,由于io::stdin()返回的是Stdin,代碼中使用的返回值與C語(yǔ)言一樣,保存在rax中。不過這里是首先將函數(shù)地址賦值給rax,通過call rax完成調(diào)用。調(diào)用完stdin()后,Rust不知道為什么用了一個(gè)jmp指令,跨越了幾條指令再繼續(xù)執(zhí)行后面的read_line方法。對(duì)于read_line方法,可以看到前3個(gè)寄存器進(jìn)行了賦值。其中rsi是io::stdin()的返回值,也就是Stdin對(duì)象實(shí)例,rdx是字符串input的地址,這一點(diǎn)可以通過第一行對(duì)[rsp+80]賦值得知,那么rdi是什么呢?這里就需要返回到IDA界面查看。
從上圖可知,IDA將第一個(gè)參數(shù)解析為self,類型為core::result::Result
匯編代碼看到這里,我們能夠發(fā)現(xiàn),即使代碼順序執(zhí)行,Rust編譯器也一定要在一個(gè)函數(shù)調(diào)用結(jié)束后插入一個(gè)jmp指令,這一點(diǎn)可以從調(diào)用read_line方法可以得知,向下不斷滑動(dòng)窗口也能發(fā)現(xiàn),整個(gè)main函數(shù)似乎是被許多jmp指令劃分為許多小部分。
line 3
第三行首先看到,代碼中使用了deref這個(gè)方法,至于為什么使用這個(gè)方法其實(shí)很好理解。deref傳入的是String實(shí)例,返回的是字符串切片&str,而trim方法實(shí)際上是以切片作為self的,因此這里Rust隱式地將String轉(zhuǎn)成切片之后再執(zhí)行trim。
調(diào)用deref方法后需要注意,這里將rdx和rax保存到了棧中。記得在學(xué)習(xí)字符串切片的時(shí)候,書中有提及字符串切片實(shí)際上由兩個(gè)部分組成——指針與長(zhǎng)度。這里我們只通過靜態(tài)分析無法判斷rdx和rax到底是多少,雖然我們心中可能已經(jīng)知道答案,但這里還是通過簡(jiǎn)單的調(diào)試來驗(yàn)證一下。
可以看到,這與我們的預(yù)期是相同的,rdx保存的是長(zhǎng)度,rax保存的是字符串指針。因此我們知道了,String類型的deref方法會(huì)將返回值保存在兩個(gè)寄存器——rdx與rax中。
好繼續(xù)往下看。隨后就是trim方法的調(diào)用,傳入的第1個(gè)參數(shù)是字符串指針,第2個(gè)參數(shù)是長(zhǎng)度。其返回值依然是保存在兩個(gè)寄存器中??梢妼?duì)于返回值為&str的Rust方法,其返回的方式也有一定規(guī)律。
trim之后是parse,返回值是Result類型,和read_line不同的是,read_line返回的Result實(shí)例沒有泛型(Result
隨后,有幾行看似沒有意義的匯編代碼,像是mov qword ptr [rsp + 240], rax,這里的[rsp+240]在main函數(shù)自始至終只有這里被使用過。所以直接忽略。隨后expect的傳參與之前規(guī)則相同。
不過這里的expect是需要將返回值保存在num中的,也就是mov dword ptr [rsp + 28], eax這條語(yǔ)句,可見num是保存在[rsp+0x28]的位置。
line 4~9
下面的幾行是一個(gè)println!一個(gè)match語(yǔ)句的值。在學(xué)Rust的時(shí)候我們了解到,match語(yǔ)句可以實(shí)現(xiàn)類似于lambda函數(shù)的功能,每一個(gè)分支的=>后都可以看成這個(gè)條件下match的返回值。就如這幾行是將match的每一個(gè)分支語(yǔ)句都定義一個(gè)字符串切片作為傳入println! 的格式化參數(shù)。
在上一行語(yǔ)句執(zhí)行結(jié)束后,匯編代碼首先將num的值放到eax中,隨后進(jìn)行分支判斷。判斷順序是:是否等于1、是否等于2、是否小于10,而且match的判斷語(yǔ)句是統(tǒng)一寫在前面,具體的語(yǔ)句內(nèi)容則放在后面。
通過對(duì)分支語(yǔ)句簡(jiǎn)單分析,容易得到match語(yǔ)句的“返回值”是保存在[rsp+208]和[rsp+216],因?yàn)檫@個(gè)是&str,所以要用0x10大小保存。
不過在匯編代碼中,println!的處理流程可能不是都在所有match流程之后,而是在中間插入了一段,隨后又在跳轉(zhuǎn)到后面。使用1.69.0的rustc版本編譯發(fā)現(xiàn)所有的match分支都位于println!之后,而更新版本的1.73.0則是將println!前半部分放在match分支部分中間。
隨后則是println!的宏展開部分,考慮到println!太常見,通過IDA的反匯編輸出的源代碼可以識(shí)別出其特征??梢钥吹皆趨R編中調(diào)用了core::fmt::ArgumentV1::new_display、core::fmt::Arguments::new_v1、std::io::stdio::_print這三個(gè)方法。其中前面兩個(gè)推測(cè)是Rust宏的轉(zhuǎn)換函數(shù),也就是將宏中大括號(hào)部分替換為具體的參數(shù),而最后一個(gè)方法則是輸出內(nèi)容到控制臺(tái)。
對(duì)于第一個(gè)函數(shù),其唯一一個(gè)參數(shù)是match返回的字符串切片的棧地址。而對(duì)于第二個(gè)函數(shù),傳參情況則比較復(fù)雜。根據(jù)下文的_print函數(shù)傳入的參數(shù)判斷,第一個(gè)參數(shù)應(yīng)該是返回值字符串的地址,第二個(gè)參數(shù)指向一個(gè)換行符的地址,但意義不明,第三個(gè)參數(shù)為2,第四個(gè)參數(shù)為第一個(gè)函數(shù)的返回值rax內(nèi)容。第五個(gè)參數(shù)為1。目前只能確定第1個(gè)參數(shù)的含義,因此我們需要請(qǐng)求gdb的幫助。
可以看到,第1個(gè)函數(shù)返回的rax是要輸出的字符串。注意到在ELF中并沒有找到左右大括號(hào){}這個(gè)字符串,判斷可能是Rust使用了其他的方式進(jìn)行解析。但是除了第一個(gè)參數(shù)之外其他參數(shù)的意義還是不明。我們不妨稍稍修改一下println!格式化字符串的值,看看代碼有什么變化。
這里我們將字符串修改為a{}a{},在后面添加一個(gè)1作為第二個(gè)括號(hào)的占位符。隨后我們發(fā)現(xiàn),core::fmt::ArgumentV1::new_display函數(shù)被調(diào)用了兩次。第一次調(diào)用傳入match返回的字符串,而第二次調(diào)用傳入的是這個(gè)東西:
.L__unnamed_27:
.asciz "\001\000\000"
這不正好就是1嗎?也就是說,core::fmt::ArgumentV1::new_display這個(gè)函數(shù)是用來解析println!后面的參數(shù)的,將其轉(zhuǎn)換為字符串切片,有幾個(gè)大括號(hào)就需要調(diào)用幾次。隨后繼續(xù)進(jìn)行分析,發(fā)現(xiàn)匯編代碼將兩個(gè)函數(shù)解析得到的兩個(gè)字符串切片放到了一個(gè)連續(xù)的棧地址空間,并將其作為參數(shù)4(rcx)傳入。
如上圖所示,這里紅框部分就是賦值過程,這個(gè)地方像是一個(gè)數(shù)組的結(jié)構(gòu),按照順序排列每個(gè)大括號(hào)對(duì)應(yīng)的字符串切片。由此便可以判斷出參數(shù)5(r8d)的含義,其實(shí)就是解析的字符串切片的數(shù)量。
接下來我們?cè)倏匆幌聟?shù)2到底是什么東西。參數(shù)2指向了一個(gè)這樣的結(jié)構(gòu):
.L__unnamed_28:
.quad .L__unnamed_36
.asciz "\001\000\000\000\000\000\000"
.quad .L__unnamed_36
.asciz "\001\000\000\000\000\000\000"
.quad .L__unnamed_37
.asciz "\001\000\000\000\000\000\000"
其中有:
.L__unnamed_36:
.byte 97 ; 'a'
.L__unnamed_37:
.byte 10 ; '\n'
這樣看來,這里的含義也就清楚了。編譯器在對(duì)宏進(jìn)行展開時(shí)轉(zhuǎn)義大括號(hào)的內(nèi)容是這樣操作的:
首先將含有大括號(hào)的字符串以大括號(hào)分隔,并形成上面的這個(gè)數(shù)組結(jié)構(gòu)。對(duì)于每一個(gè)大括號(hào),都調(diào)用一次轉(zhuǎn)義函數(shù)進(jìn)行轉(zhuǎn)義,在棧中形成一個(gè)&str的數(shù)組。隨后再調(diào)用另外一個(gè)函數(shù)(core::fmt::Arguments::new_v1)將這些切片拼起來組成最終的字符串。
core::fmt::Arguments::new_v1的5個(gè)參數(shù)含義分別就是:
rdi:輸出字符串指針rsi:預(yù)編譯的數(shù)組結(jié)構(gòu),表示宏不需要轉(zhuǎn)義的字符串部分rdx:預(yù)編譯數(shù)組結(jié)構(gòu)的長(zhǎng)度rcx:運(yùn)行時(shí)解析的已經(jīng)被轉(zhuǎn)義的&str數(shù)組r8:運(yùn)行時(shí)解析的&str數(shù)組長(zhǎng)度
這個(gè)函數(shù)調(diào)用完之后,就可以進(jìn)行宏展開的后續(xù)代碼了。對(duì)于println!而言是輸出,也即調(diào)用std::io::stdio::_print。
輸出之后,后面就沒有多少代碼了:
.LBB60_18:
lea rdi, [rsp + 80]
call qword ptr [rip + core::ptr::drop_in_place
add rsp, 248
ret
mov rax, qword ptr [rip + core::panicking::panic_cannot_unwind@GOTPCREL]
call rax
ud2
.LBB60_20:
mov rdi, qword ptr [rsp + 224]
call _Unwind_Resume@PLT
ud2
這里的core::ptr::drop_in_place應(yīng)該是Rust將這個(gè)String對(duì)象實(shí)例回收了。隨后將棧上抬,main函數(shù)就正常返回了。
0x03. IDA反匯編
上一節(jié)我們對(duì)Rust ELF的分析大多是基于匯編層面進(jìn)行的,當(dāng)代碼量比較多的時(shí)候,基本塊之間的跳轉(zhuǎn)關(guān)系可能會(huì)更加復(fù)雜,不利于我們的分析。不過IDA提供了非常實(shí)用的反匯編功能,在分析時(shí),筆者認(rèn)為如果我們能夠?qū)⒎磪R編的內(nèi)容與純匯編代碼相結(jié)合,效果會(huì)更好。
但I(xiàn)DA的反匯編功能一開始畢竟是為C/C++設(shè)計(jì)的,對(duì)于Rust的反匯編結(jié)果不很直觀也是正常的。
在反匯編的輸出結(jié)果中,出現(xiàn)了比較奇怪的地方。
最為明顯的就是字符串的解析。通過查看ELF中保存字符串的地方可以發(fā)現(xiàn),Rust的字符串與字符串之間有的是以換行符隔開的,有的根本就沒有分割的字符,這與C/C++使用0字符分割每個(gè)字符串不同。因?yàn)镽ust字符串切片的特性,對(duì)一個(gè)字符串切片的操作必然需要使用到這個(gè)切片的長(zhǎng)度。既然已經(jīng)知道了字符串的長(zhǎng)度,字符串與字符串之間的分隔就顯得沒有那么必要了。
不過慶幸的是,反匯編中對(duì)于main函數(shù)的主要邏輯的解析還是比較清楚的,第一行的String::new()表示創(chuàng)建了一個(gè)String實(shí)例,隨后多個(gè)函數(shù)的調(diào)用連在一起就組成了第二行的讀取字符串內(nèi)容,就是expect函數(shù)的解析看上去不是很舒服,畢竟其與C/C++的函數(shù)調(diào)用規(guī)則有些許不同。
再往下,可以看到deref、trim、parse、expect,這些函數(shù)組成了第三行的內(nèi)容。
對(duì)于接下來的match,在反匯編界面中是將其解析成了多個(gè)if-else語(yǔ)句。隨后就是println!的宏展開,輸出字符串。輸出后通過drop_in_place刪除了一開始創(chuàng)建的String實(shí)例,函數(shù)返回。
0x04. 總結(jié)
以上就是我們的第一次Rust逆向嘗試,還是有很多收獲的,下面是本文的總結(jié):
Rust的main函數(shù)與ELF中的main不同,但很好找。Rust編譯器喜歡將代碼用jmp指令分割為一個(gè)個(gè)小部分。對(duì)于返回&str的方法,是將切片的指針和長(zhǎng)度分別保存在rax和rdx之中。對(duì)于struct的new方法,一般可在反匯編界面中直接識(shí)別,在匯編中實(shí)際執(zhí)行的更像是通過xxx.new(&target)的方式進(jìn)行初始化。Rust對(duì)宏展開的處理有一定的規(guī)律,可通過這些規(guī)律在反匯編界面中識(shí)別出宏展開的部分。
不得不說,Rust編譯器在匯編層面的處理還是有點(diǎn)意思的。在后面的文章中,我們將嘗試分析更加復(fù)雜的代碼,嘗試整理出更多Rust語(yǔ)言特性在匯編層面中的實(shí)現(xiàn)方式。
柚子快報(bào)邀請(qǐng)碼778899分享:Rust逆向?qū)W習(xí) (1)
相關(guān)閱讀
本文內(nèi)容根據(jù)網(wǎng)絡(luò)資料整理,出于傳遞更多信息之目的,不代表金鑰匙跨境贊同其觀點(diǎn)和立場(chǎng)。
轉(zhuǎn)載請(qǐng)注明,如有侵權(quán),聯(lián)系刪除。