8

深入理解计算机系统(CSAPP)bomblab实验进阶之nuclearlab——详细题解 - 定清先生

 1 year ago
source link: https://www.cnblogs.com/VirusCollector/p/17035762.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

本实验是难度高于bomblab的一个补充实验,该实验部分题目难度已经达到CTF入门水平,且这个实验据说是上一届的某个学长原创,因此互联网上几乎找不到类似的题目。在间断地思考了几周后我最终完成了所有题目,并打算在这篇随笔里详细地给大家分享我的解题过程。

核弹样本(可本地断网运行):https://wwi.lanzoup.com/ifFGo0kmrndg

第0关:准备工作

将文件拖入Detect It Easy进行查壳:64位程序,无壳

 

3073341-20230108220200444-2124085622.png

这个时候按照我们做bomblab的经验,应该会先objdump这个程序,然后用gdb在main函数下断点,但运行程序会发现如下的异常:

3073341-20230108220655384-1721091783.png

如果继续执行会发现程序由于除以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无法识别该函数的后半段代码:

3073341-20230108223121708-2033180201.png

仔细观察该程序逻辑,在call语句后面的三句指令对程序自身进行了修改:

3073341-20230108223547446-567046298.png

于是我们在0x4035EA处下断点并运行到此,观察al的值发现是1:

3073341-20230108224155990-1396858527.png

这个时候继续调试会出现如下提醒(实际上每次调试都会出现),说明该程序会检查程序运行时间,过长(说明有调试)则抛出信号,这里我们选择No即可放弃将信号传递给程序处理,从而继续调试:

3073341-20230108224453587-1593749167.png
3073341-20230108224500385-741509866.png

然后我们就会发现程序跳入0x4035F3处执行,而0x4035F2处的指令永远不会执行,是故意塞入而干扰分析的指令,这种干扰的方法也被称为花指令。

后面的代码分析同上,如下图所示,我们只需将两个check函数nop掉,即可完成对初始化函数的修补:

3073341-20230108225703466-1176631032.png

下面我们再对结果判断的两个函数完成修补:

如下图,将bomb_defused和bomb_explode两函数矩形内的联网代码nop掉,即可使该程序能在本地完成调试,且用户名和密码可以随意输入:

3073341-20230108230535916-24315607.png
3073341-20230108230552987-574662970.png

 建议以文本文件作为输入,因为后续题目可能需要输入非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作为试探输入:

3073341-20230108234840640-748855478.png

这时我们点击vars10,跳转到它对应的栈位置:

3073341-20230108235219710-145974468.png

由cmp内容可知,我们需要把所指位置(实际上是rbp处)当成一个int指针,解该指针得到的值为0x33353138,根据小端顺序,我们可以判断出前四个字符为8153。再点击0x401A30处的vars14,发现跳转至第5个字符'D'处,同理可得第5、6个字符为3A:

3073341-20230108235519007-1958094832.png

而根据前面的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函数,从字面上看它不要我们阅读这段代码,但很显然我的好奇被它激发了,,,,,这段代码汇编如下:

3073341-20230109231926820-946118149.png

我们可以看到与准备阶段相似的花指令,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函数查看:

3073341-20230109233609580-1108155722.png

看了以后直接傻眼,没有字符串比较之类的语句??这个函数既然直接让rip指向rsp的位置,那我们只能动调来看看它到底想干什么,不妨输入12345678:

3073341-20230109233904938-622883403.png

观察栈结构我们可以发现,这个函数直接跳入了我们输入的字符串开头,并将我们的输入当作指令执行!!!因而我们有理由推断,这一关要求我们输入一道字符序列,它从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是不会执行的,可以替换为其它字符:

3073341-20230110112937443-1502949293.png

最终成功返回所需地址。

第6关:tran$f0rm

3073341-20230110114101861-1819399373.png

观察发现同样的花指令混淆,我们把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环境,进入其函数查看:

3073341-20230217230953908-483359417.png

显然核心代码在check_debugger内,进入该函数:

3073341-20230217231203403-153773789.png

又是熟悉的花指令混淆,我们运用之前的方法去除所有的混淆代码,再创建函数:

3073341-20230217231719966-1440118025.png

查阅文档可知,getppid函数取得父进程的标识码整数x,sprintf使v3字符串为"/proc/x/exe",readlink函数会把参数v3的符号链接内容存储到参数v4所指的内存空间,并返回字符串个数,最终使v4成为一个代表调试器路径的字符串,而basename函数则取v4最后一个'/'之后的内容。

例如我们用IDA调试时,在第15行下断点可得到各个参数值:

3073341-20230217232205065-159545990.png

v3=221066,v4="/home/kali/Desktop/linux_server64"(IDA调试文件的路径),s="linux_server64",这样所得的s在第15行运算时不会发生除以0的异常。

3073341-20230217232441907-899053733.png

观察对应的汇编代码,我们容易发现返回值rax就是字符串s,而第15行的运算只用到s,因此下面我们用gdb在0x4015EF处下断点观察rax值:

3073341-20230217232917909-1585548133.png

我们发现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为负数,会出现什么结果呢?我们继续探究:

3073341-20230217232205065-159545990.png

 由第15行可知,正常情况下,被除数是一个负数,除数是一个正数,因此此时该变量是一个负数;第17行使该变量成为-1,第19行使该变量成为1。

如果我们修改ecx为负数,那么第15行该变量是正数,第17行成为0,第19行依然为0,故该变量的值成为0。

再次观察一下C代码,这个dword_404468变量似乎在前面的某处分析出现过??如果不记得了请看下面这张图片:

3073341-20230217234408591-1374664663.png

在准备阶段时,我们动调发现了al的值是1,但是当时并没有解释它的由来。现在根据0x4035E2处指令可知这个值正是dword_404468,接着我们查找一下这个变量的交叉引用:

3073341-20230217235521663-666562642.png

可以发现只有3处位置写了这个变量,它们正是check_debugger函数C代码的15、17、19行处,也就是说如果我们把ecx修改为负数,会导致这个全局变量始终为0,从而使程序无法正确跳过干扰指令,最终产生SIGILL异常而进入bomb_explode函数:

3073341-20230218002235539-595936799.png

1. 当正向分析困难时就多尝试动调得到结果,省时省力。

2. 对于花指令、反调试等混淆技术要学会辨认、清除,这样才能让IDA发挥最好的作用。

最后附上成功拆弹图:

3073341-20230110150247038-696827479.png

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK