深入理解計算機系統(CSAPP)bomblab實驗進階之nuclearlab——詳細題解

定清先生發表於2023-03-01

前言

本實驗是難度高於bomblab的一個補充實驗,該實驗部分題目難度已經達到CTF入門水平,且這個實驗據說是上一屆的某個學長原創,因此網際網路上幾乎找不到類似的題目。在間斷地思考了幾周後我最終完成了所有題目,並打算在這篇隨筆裡詳細地給大家分享我的解題過程。

核彈樣本(可本地斷網執行):https://wwi.lanzoup.com/ifFGo0kmrndg

 

第0關:準備工作

將檔案拖入Detect It Easy進行查殼:64位程式,無殼

 

這個時候按照我們做bomblab的經驗,應該會先objdump這個程式,然後用gdb在main函式下斷點,但執行程式會發現如下的異常:

如果繼續執行會發現程式由於除以0錯誤引發異常,從而進入explode函式,結合check_debugger名稱我們可以推斷出這個程式具有反gdb除錯功能,因此我們可以考慮更換其他工具繼續實驗,這裡我選擇了IDA pro。

(IDA pro介紹:https://blog.csdn.net/yi_rui_jie/article/details/127865485)

理論上而言我們應該閱讀彙編程式碼再進行逆向分析,但IDA pro的強大之處在於它可以自動幫助我們把組合語言翻譯成高階語言,按F5快捷鍵即可。

特別說明:

1. IDA的彙編程式碼是Intel風格,且實際中掌握Intel風格的組合語言是非常必要的。

2. 高階語言分析不代表不再需要看懂彙編程式碼,相反地,許多關鍵之處IDA的分析往往不能令人滿意,需要進行修補,對彙編能力有較高的要求。下文中你就可以更好地明白這一點的意義。

3. 這篇題解不會具體介紹IDA的用法(如檢視彙編程式碼和高階程式碼、建立函式、修改位元組、儲存檔案、遠端除錯等),請自行查閱資料。

我們首先檢視main函式的c語言程式碼:

 1 int __cdecl main(int argc, const char **argv, const char **envp)
 2 {
 3   if ( argc != 3 )
 4   {
 5     __fprintf_chk(stderr, 1LL, "Usage: %s studentId password\n", *argv);
 6     exit(8);
 7   }
 8   strncpy(userid, argv[1], 0x80uLL);
 9   byte_4045FF = 0;
10   strncpy(userpwd, argv[2], 0x80uLL);
11   byte_40467F = 0;
12   ((void (*)(void))bomb_initialize)();
13   __printf_chk(1LL, "%s: ", "pupil");
14   readNextLine(128);
15   pupil();
16   bomb_defused();
17   __printf_chk(1LL, "%s: ", "tr1vial");
18   readNextLine(128);
19   tr1vial();
20   bomb_defused();
21   __printf_chk(1LL, "%s: ", "rainb0w");
22   readNextLine(128);
23   rainb0w();
24   bomb_defused();
25   __printf_chk(1LL, "%s: ", "q_math");
26   readNextLine(128);
27   q_math();
28   bomb_defused();
29   __printf_chk(1LL, "%s: ", "hothothot");
30   readNextLine(128);
31   hothothot();
32   bomb_defused();
33   __printf_chk(1LL, "%s: ", "tran$f0rm");
34   readNextLine(128);
35   ((void (*)(void))tran_f0rm)();
36   bomb_defused();
37   return 0;
38 }

不難看出這個程式的大致流程是:首先讀取兩個命令列引數並儲存,接著第12行進入bomb初始化函式,然後和bomblab一樣需要完成6個小實驗。由於該實驗需要聯網,我們需要先對bomb_initialize函式(初始化函式)進行修改。

找到初始化函式的位置,發現IDA無法識別該函式的後半段程式碼:

仔細觀察該程式邏輯,在call語句後面的三句指令對程式自身進行了修改:

於是我們在0x4035EA處下斷點並執行到此,觀察al的值發現是1:

這個時候繼續除錯會出現如下提醒(實際上每次除錯都會出現),說明該程式會檢查程式執行時間,過長(說明有除錯)則丟擲訊號,這裡我們選擇No即可放棄將訊號傳遞給程式處理,從而繼續除錯:

然後我們就會發現程式跳入0x4035F3處執行,而0x4035F2處的指令永遠不會執行,是故意塞入而干擾分析的指令,這種干擾的方法也被稱為花指令

後面的程式碼分析同上,如下圖所示,我們只需將兩個check函式nop掉,即可完成對初始化函式的修補:

下面我們再對結果判斷的兩個函式完成修補:

如下圖,將bomb_defused和bomb_explode兩函式矩形內的聯網程式碼nop掉,即可使該程式能在本地完成除錯,且使用者名稱和密碼可以隨意輸入:

 建議以文字檔案作為輸入,因為後續題目可能需要輸入非ASCII字元。如./nuclearlab user pwd < answer.txt

 

第1關:pupil

 1 unsigned __int64 pupil()
 2 {
 3   int v0; // edi
 4   unsigned int v2; // [rsp+4h] [rbp-14h] BYREF
 5   unsigned __int64 v3; // [rsp+8h] [rbp-10h]
 6 
 7   v0 = (int)now_input;
 8   v3 = __readfsqword(0x28u);
 9   if ( (unsigned int)__isoc99_sscanf(now_input, "%d", &v2) != 1 )
10     bomb_explode(v0);
11   if ( (unsigned int)pupil_pow_mod(233333LL, v2) != 1 )
12     bomb_explode(233333);
13   return __readfsqword(0x28u) ^ v3;
14 }

 由程式碼可知,我們需要pupil_pow_mod(233333, v2)值為1,而v2是我們輸入的值,下面檢視該函式程式碼:

 1 __int64 __fastcall pupil_pow_mod(__int64 a1, int a2)
 2 {
 3   __int64 result; // rax
 4   int v3; // eax
 5   __int64 v4; // rcx
 6 
 7   result = 1LL;
 8   if ( a2 )
 9   {
10     v3 = pupil_pow_mod(a1, (unsigned int)(a2 >> 1));
11     v4 = v3 * (__int64)v3 % 998244353;
12     result = v4;
13     if ( (a2 & 1) != 0 )
14       return (unsigned int)(v4 * (int)a1 % 998244353);
15   }
16   return result;
17 }

可以看到result本身值為1,因此只要輸入的值為0,函式便會直接返回1,從而透過該關,確實挺符合它的名字,,,,,,

 

第2關:tr1vial

 1 unsigned __int64 tr1vial()
 2 {
 3   char *v0; // r12
 4   size_t v1; // rcx
 5   unsigned __int64 v2; // rdx
 6   __int64 *v3; // rdi
 7   __int16 v4; // dx
 8   signed __int64 v5; // rdx
 9   void *v6; // rsp
10   bool v7; // cf
11   bool v8; // zf
12   const char *v9; // rsi
13   __int64 v10; // rcx
14   const char *v11; // rdi
15   _BYTE v14[4088]; // [rsp+8h] [rbp-1020h] BYREF
16   __int64 v15; // [rsp+1008h] [rbp-20h] BYREF
17   unsigned __int64 v16; // [rsp+1010h] [rbp-18h]
18 
19   v0 = now_input;
20   v16 = __readfsqword(0x28u);
21   v1 = strlen(now_input);
22   v2 = 4 * ((v1 + 2) / 3) + 16;
23   v3 = (__int64 *)((char *)&v15 - (v2 & 0xFFFFFFFFFFFFF000LL));
24   v4 = v2 & 0xFFF0;
25   if ( &v15 != v3 )
26   {
27     while ( v14 != (_BYTE *)v3 )
28       ;
29   }
30   v5 = v4 & 0xFFF;
31   v6 = alloca(v5);
32   if ( v5 )
33     *(_QWORD *)&v14[v5 - 8] = *(_QWORD *)&v14[v5 - 8];
34   EVP_EncodeBlock(v14, v0, (unsigned int)v1);
35   v9 = v14;
36   v10 = 17LL;
37   v11 = "fkdM8J+Sr0hGfg==";
38   do
39   {
40     if ( !v10 )
41       break;
42     v7 = *v9 < (unsigned int)*v11;
43     v8 = *v9++ == *v11++;
44     --v10;
45   }
46   while ( v8 );
47   if ( (!v7 && !v8) != v7 )
48     bomb_explode((int)v11);
49   return __readfsqword(0x28u) ^ v16;
50 }

首先關注第47行,分析可知只有v7=0,v8!=0時不會進explode函式,而38-45行的迴圈分析可知,當v9和v11所指向的字串完全相等時才能滿足這個要求。v9=v14=

 EVP_EncodeBlock(v14, v0, (unsigned int)v1),其中v0是輸入的字串。查詢資料可知這個函式對v0進行base64加密,因而我們對v11解密即可。

base64介紹:https://blog.csdn.net/qq_19782019/article/details/88117150

python解密指令碼:

1 import base64
2 import math
3 
4 ss="fkdM8J+Sr0hGfg0="
5 print(base64.b64decode(ss))

 可得答案為~GL\xf0\x9f\x92\xafHF~,由於字串中含有非ASCII字元,因此需要先寫入文字,再讀入文字作為輸入。如果直接到網站上解密可能無法顯示不可見字元(被坑慘了炸了好幾次),事實上中間的字元是?,這個字串"~GL?HF~"意思是:Good Luck and Have Fun,祝你取得滿分。

 

 第3關:rainb0w

 1 unsigned __int64 rainb0w()
 2 {
 3   char *v0; // r12
 4   int *v1; // rbx
 5   size_t v2; // rax
 6   int *v3; // rdi
 7   char vars0; // [rsp+0h] [rbp+0h] BYREF
 8   int vars10; // [rsp+10h] [rbp+10h] BYREF
 9   __int16 vars14; // [rsp+14h] [rbp+14h]
10   char vars30; // [rsp+30h] [rbp+30h] BYREF
11   unsigned __int64 vars38; // [rsp+38h] [rbp+38h]
12 
13   v0 = now_input;
14   vars38 = __readfsqword(0x28u);
15   v1 = &vars10;
16   v2 = strlen(now_input);
17   MD5(v0, v2, &vars0);
18   do
19   {
20     v3 = v1;
21     v1 = (int *)((char *)v1 + 2);
22     __sprintf_chk(v3, 1LL, -1LL, "%2.2hhX", (unsigned int)vars0);
23   }
24   while ( v1 != (int *)&vars30 );
25   if ( vars10 != 859124024 || vars14 != 16691 )
26     bomb_explode((int)v3);
27   return __readfsqword(0x28u) ^ vars38;
28 }

首先還是關注explode的條件。由於vars10和vars14與其它變數似乎沒有直接關聯,我們轉到其彙編程式碼0x4019FB處下斷點並執行到此處,以123456作為試探輸入:

這時我們點選vars10,跳轉到它對應的棧位置:

由cmp內容可知,我們需要把所指位置(實際上是rbp處)當成一個int指標,解該指標得到的值為0x33353138,根據小端順序,我們可以判斷出前四個字元為8153。再點選0x401A30處的vars14,發現跳轉至第5個字元'D'處,同理可得第5、6個字元為3A:

而根據前面的MD5函式,我們有理由猜測這個字串"E10ADC3949BA59ABBE56E057F20F883E"就是123456的MD5加密32位值,在MD5加密網站上輸入123456可得到相同結果,證明猜想正確。

於是題目轉化為,輸入一個字串,使得它的MD5加密值前六位是字串“81533A”,這樣的字串有很多,但由於MD5的不可逆性,即我們不能透過演算法逆向解密某個加密後的值,因此我們只能從正向突破,其中一種方法是嘗試從數字開始列舉,得到一個答案為1914718:

1 import hashlib
2 
3 for i in range(10000000):
4     s=str(i)
5     if(hashlib.md5(s.encode()).hexdigest()[:6]=='81533a'):
6         print(i)
7         break

 

第4關:qmath

 1 unsigned __int64 q_math()
 2 {
 3   int v0; // edi
 4   unsigned int v1; // edx
 5   int v2; // ebp
 6   unsigned int v3; // ebx
 7   int v5; // [rsp+4h] [rbp-24h] BYREF
 8   unsigned __int64 v6; // [rsp+8h] [rbp-20h]
 9 
10   v6 = __readfsqword(0x28u);
11   __isoc99_sscanf(now_input, "%u", &v5);
12   magic = (1431655766 * (unsigned __int64)(unsigned int)magic) >> 32;
13   v0 = v5;
14   v2 = t00rerauqs((unsigned int)v5) + 1;
15   if ( v2 <= 29999 )
16     bomb_explode(v0);
17   if ( v2 > 40000 )
18     bomb_explode(v0);
19   v3 = 2;
20   while ( 1 )
21   {
22     if ( !(v1 % v3) )
23       bomb_explode(v0);
24     if ( ++v3 > v2 )
25       break;
26     v1 = v5;
27   }
28   return __readfsqword(0x28u) ^ v6;
29 }

從第14行我們可以看出v2是把我們輸入的值透過一個函式進行處理而得到的,由於具體找出公式較複雜,我們可以在15行處下斷點,然後動態地輸入一組資料來觀察v2的變化規律,這裡我發現v2隨輸入值的增大而增大。那麼我們不斷地以10的指數去嘗試,使得v2最終落在30000-40000之間,這時我們可以確定一個相對的範圍。然後我們再觀察while迴圈,不難看出它要滿足[2,v2]之間的所有整數都不是輸入值的因子,那麼我們在區間內寫一個迴圈判斷即可得到答案,其中一種為1000166183。

 

第5關:hothothot

 1 unsigned __int64 hothothot()
 2 {
 3   __int64 v1; // [rsp+0h] [rbp-98h] BYREF
 4   unsigned __int64 v2; // [rsp+88h] [rbp-10h]
 5 
 6   v2 = __readfsqword(0x28u);
 7   ((void (*)(void))hothothot_you_shouldnt_read_this_part_0)();
 8   convert_text_enc0ding(now_input, &v1, 128LL);
 9   if ( !__sigsetjmp(&buf, 1) )
10     return hothothot_cold();
11   ((void (__fastcall *)(_QWORD))hothothot_you_shouldnt_read_this)(0LL);
12   return __readfsqword(0x28u) ^ v2;
13 }

這道題應該是6個關卡中難度最高的一個,本質上來說它已經屬於pwn的範疇了,,,,,,

首先我們會進入一個hothothot_you_shouldnt_read_this_part_0函式,從字面上看它不要我們閱讀這段程式碼,但很顯然我的好奇被它激發了,,,,,這段程式碼彙編如下:

 

我們可以看到與準備階段相似的花指令,dword_404468被設定為1來使10變成01,從而使jmp語句跳入0x401705處,後面是一些訊號處理函式,對解題沒有什麼影響,看來的確沒啥讀的必要。接著我們重點關注convert函式:

 

 1 unsigned __int64 __fastcall convert_text_enc0ding(char *a1, char *a2, size_t a3)
 2 {
 3   iconv_t v3; // rax
 4   size_t outbytesleft; // [rsp+8h] [rbp-30h] BYREF
 5   char *outbuf; // [rsp+10h] [rbp-28h] BYREF
 6   char *inbuf; // [rsp+18h] [rbp-20h] BYREF
 7   size_t inbytesleft; // [rsp+20h] [rbp-18h] BYREF
 8   unsigned __int64 v9; // [rsp+28h] [rbp-10h]
 9 
10   outbytesleft = a3;
11   inbuf = a1;
12   outbuf = a2;
13   v9 = __readfsqword(0x28u);
14   inbytesleft = strlen(a1);
15   v3 = iconv_open("GBK", "UTF-8");
16   iconv(v3, &inbuf, &inbytesleft, &outbuf, &outbytesleft);
17   return __readfsqword(0x28u) ^ v9;
18 }

 

查詢iconv函式可知,這個函式會把我們的輸入以UTF-8編碼讀入,然後轉換為GBK編碼。當輸入的為ASCII字元時,轉換後沒有任何變化;而當輸入其它字元時(如\xe8\x8a\x92在UTF-8下表示“芒”),會把它轉換為GBK編碼(\xc3\x92在GBK下表示“芒”),因而我們輸入\xe8\x8a\x92就能得到\xc3\x92。

附上GBK編碼表:https://blog.csdn.net/itnerd/article/details/118615080

接下來回到hothothot函式,查詢資料可知sigsetjmp函式在正常情況下會返回0,因而我們進入hothothot_cold函式檢視:

看了以後直接傻眼,沒有字串比較之類的語句??這個函式既然直接讓rip指向rsp的位置,那我們只能動調來看看它到底想幹什麼,不妨輸入12345678:

觀察棧結構我們可以發現,這個函式直接跳入了我們輸入的字串開頭,並將我們的輸入當作指令執行!!!因而我們有理由推斷,這一關要求我們輸入一道字元序列,它從UTF-8轉換為GBK後的指令能夠正常返回函式的0x401CEB處即可成功。

特別注意以下幾點:

1. GBK總體編碼範圍為 8140-FEFE,首位元組在 81-FE 之間,尾位元組在 40-FE 之間,剔除 xx7F 一條線。因此最後構造的指令中如果有非ASCII字元,則必須兩兩成對且落在指定區間內,再找到GBK對應的漢字,以這個漢字的UTF-8編碼輸入。

2. 構造指令中不能含有空字元(\x00),它會被轉換為\x20等其它非空字元。

基於上述兩點,一些簡單的構造都失效了:

jmp 暫存器  出現FF字元,找不到對應GBK編碼

push 0x401CEB  出現00字元,空字元失效

mov 暫存器,0x401CEB  出現00字元,空字元失效

mov 暫存器,[記憶體地址+偏移]  偏移為0時出現00字元,空字元失效

我們可以利用棧中已有的地址和空閒暫存器(如r8)的地址,把它們不斷累加得到0x401CEB(注意讓偏移不為0),然後再push+ret即可,下面是我的構造方法:

48 e5 86 ad 09 4c 03 40 01 49 e5 85 9d 0c 41 50 e8 8a 92  UTF-8

48 83 e8      09 4c 03 40 01 49 83 c0      0c 41 50 c3 a2     GBK

GBK的字串為機器碼,執行指令如圖,實際上retn只需要c3即可,後面的a2是不會執行的,可以替換為其它字元:

最終成功返回所需地址。

 

第6關:tran$f0rm

觀察發現同樣的花指令混淆,我們把10改為01,再nop掉0x401D53處的48,之後我們即可建立transform函式,F5得到如下程式碼:

 1 unsigned __int64 tran_f0rm()
 2 {
 3   int v0; // edi
 4   int v1; // edx
 5   int v3; // [rsp+Ch] [rbp-1Ch] BYREF
 6   int v4; // [rsp+10h] [rbp-18h] BYREF
 7   int v5; // [rsp+14h] [rbp-14h]
 8   unsigned __int64 v6; // [rsp+18h] [rbp-10h]
 9 
10   v0 = (int)now_input;
11   v6 = __readfsqword(0x28u);
12   __isoc99_sscanf(now_input, "%d%d", &v3, &v4);
13   *(&loc_401D51 + 1) = 16;
14   ma1f0rm();
15   v5 = v1;
16   if ( v3 <= 100 )
17     bomb_explode(v0);
18   if ( v4 != v5 )
19     bomb_explode(v0);
20   return __readfsqword(0x28u) ^ v6;
21 }

這道題甚至比qmath還簡單,我們先輸入一個大於100的整數,再動調得到v5的值,即可得到一組答案。我的答案是:120 12869

 

反gdb除錯分析

由之前的分析可知,該檔案在debugger_check_caller函式內檢測gdb環境,進入其函式檢視:

顯然核心程式碼在check_debugger內,進入該函式:

又是熟悉的花指令混淆,我們運用之前的方法去除所有的混淆程式碼,再建立函式:

查閱文件可知,getppid函式取得父程式的標識碼整數x,sprintf使v3字串為"/proc/x/exe",readlink函式會把引數v3的符號連結內容儲存到引數v4所指的記憶體空間,並返回字串個數,最終使v4成為一個代表偵錯程式路徑的字串,而basename函式則取v4最後一個'/'之後的內容。

例如我們用IDA除錯時,在第15行下斷點可得到各個引數值:

v3=221066,v4="/home/kali/Desktop/linux_server64"(IDA除錯檔案的路徑),s="linux_server64",這樣所得的s在第15行運算時不會發生除以0的異常。

觀察對應的彙編程式碼,我們容易發現返回值rax就是字串s,而第15行的運算只用到s,因此下面我們用gdb在0x4015EF處下斷點觀察rax值:

我們發現rax = s = "gdb",因此*(_WORD *)&s[strlen(s) - 2] = *(short*)&s[1] = 0x6264 = 25188,代入(*(_WORD *)&s[strlen(s) - 2] | 0x2020) - 25188) & 0x7FFFFFFF可得該式值為0,從而引發了除以0異常。不過相應的處理方法也很簡單,在0x401622指令處下斷點,將ecx的值改成一個正數即可。

那可能有人要問了,你為什麼一定要把ecx改成正數,負數不行嗎?答案是:不行!因為根據&0x7fffffff可知,正常邏輯下除數一定是一個非負數,既然除數不能為0,那麼只能修改為正數。如果修改ecx為負數,會出現什麼結果呢?我們繼續探究:

 由第15行可知,正常情況下,被除數是一個負數,除數是一個正數,因此此時該變數是一個負數;第17行使該變數成為-1,第19行使該變數成為1。

如果我們修改ecx為負數,那麼第15行該變數是正數,第17行成為0,第19行依然為0,故該變數的值成為0。

再次觀察一下C程式碼,這個dword_404468變數似乎在前面的某處分析出現過??如果不記得了請看下面這張圖片:

在準備階段時,我們動調發現了al的值是1,但是當時並沒有解釋它的由來。現在根據0x4035E2處指令可知這個值正是dword_404468,接著我們查詢一下這個變數的交叉引用:

可以發現只有3處位置寫了這個變數,它們正是check_debugger函式C程式碼的15、17、19行處,也就是說如果我們把ecx修改為負數,會導致這個全域性變數始終為0,從而使程式無法正確跳過干擾指令,最終產生SIGILL異常而進入bomb_explode函式:

 

總結:

1. 當正向分析困難時就多嘗試動調得到結果,省時省力。

2. 對於花指令、反除錯等混淆技術要學會辨認、清除,這樣才能讓IDA發揮最好的作用。

最後附上成功拆彈圖:

 

 

 

 

 

 

 

 

 

 

相關文章