前言
本實驗是難度高於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發揮最好的作用。
最後附上成功拆彈圖: