在前段时间处理了二进制炸弹,自己对IDA的使用已经比较熟练了,在buuctf里面刷了些Reverse逆向工程的题,感觉越来越得心应手。但上次实验以及后续的刷题最大的漏洞就是:太过于依赖汇编指令转C这个功能,导致对汇编指令,数据的存储结构以及函数的调用等基本上没有认识与理解(例如call、ret、pop、push这些只知道在做什么但不清楚具体实现的过程)。

        在二进制炸弹实验根据汇编指令推理而没有太过于依赖汇编转C的小伙伴,相信做缓冲区炸弹应该会非常的快。但对于沉迷使用F5的本人来说,做这个实验确实有点煎熬。最开始不清楚实现的原理,后续借助Kimi又去自行看了下call、ret、pop、push、栈、栈指针、栈顶、栈底、栈帧等的概念或实现过程,把题中栈的关键部分构造了出来。看了很多大佬的博客,学习到了很多有用的东西,但基本上都没有讲题中栈关键部分的构造过程,反而这一部分本人觉得是非常重要的。我自己还是从头开始分析,也许大家更容易接受(而且也帮大家复习巩固或是再次学习下栈的构造)。所以本人这次就站在巨人的肩上,分享下自己解题的过程(包含题中栈的构造以及各个关卡的思路,还望大家指教,抱拳~)。

        在这之前,你必须得掌握栈的结构以及它的构造过程。这方面不太明白的小伙伴,可以参考下这篇文章的附属文件(“栈的结构以及栈的构造过程演示.ppt” ,里面包含了一个栈的构造演示举例,以及“缓冲区炸弹”题目中各个关卡对应的栈的结构图,帮助大家能更好理解具体的过程)。

        那么废话不多说,我们直接进入正题。以下是本人做该实验的过程以及对应的思路与心得:

一、对“缓冲区溢出”的概念理解以及相关的原理过程

        既然已经提到了“缓冲区”这个词,我们要利用缓冲区产生的问题来解决这个炸弹,本人能想到的只有“缓冲区溢出”这个方法了(相信大家也能想到),自己又用Kimi搜了下相关的概念去帮助自己从原理上开始入手这个炸弹,具体如下:

        缓冲区溢出(Buffer Overflow)是一种常见的计算机安全漏洞,通常发生在程序试图将超过缓冲区容量的数据写入缓冲区时。以下是关于缓冲区溢出的详细概念:

(一) 缓冲区的定义

        缓冲区是计算机内存中的一块临时存储区域,用于存放数据。它通常用于在程序的不同部分之间传递数据,或者用于临时存储从输入设备(如键盘、文件或网络)读取的数据。

(二)缓冲区溢出的原理

        缓冲区溢出的核心问题是程序未能正确管理缓冲区的大小,导致写入的数据超出了缓冲区的边界。具体过程如下:

  • 程序分配了一个固定大小的缓冲区(例如,一个数组)来存储数据。

  • 程序从外部(如用户输入、文件或网络)读取数据,并将这些数据写入缓冲区。

  • 如果写入的数据量超过了缓冲区的大小,超出部分的数据会覆盖缓冲区之外的内存区域。

(三)缓冲区溢出的影响

        缓冲区溢出可能导致多种安全问题和程序故障:

  • 程序崩溃:溢出的数据可能会覆盖程序的控制信息(如返回地址、函数指针等),导致程序执行异常,最终崩溃。

  • 数据损坏:溢出的数据可能会覆盖其他重要数据,导致程序运行结果错误。

  • 安全漏洞:攻击者可以利用缓冲区溢出漏洞,通过精心构造的数据覆盖程序的控制流信息(如返回地址),从而执行恶意代码。这种攻击方式被称为“缓冲区溢出攻击”。

(四)缓冲区溢出攻击的原理

        缓冲区溢出攻击通常利用程序的漏洞来实现以下目标:

  • 覆盖返回地址:攻击者通过溢出的数据覆盖函数的返回地址,使其指向攻击者提供的恶意代码(如shellcode)。

  • 执行恶意代码:当程序执行到被覆盖的返回地址时,会跳转到恶意代码执行,从而实现攻击者的意图(如获取系统权限、窃取数据等)。

(五)常见的缓冲区溢出类型

  • 栈溢出(Stack Overflow):这是最常见的缓冲区溢出类型。栈是程序运行时用于存储局部变量、函数调用信息等的内存区域。当程序试图写入超出栈缓冲区的数据时,可能会覆盖函数的返回地址,从而导致栈溢出。

  • 堆溢出(Heap Overflow):堆是程序运行时动态分配内存的区域。如果程序未能正确管理堆内存的大小,可能会导致堆溢出。堆溢出通常比栈溢出更复杂,但同样可能导致程序崩溃或被攻击。

  • 基于字符串的溢出:许多程序使用字符串操作函数(如strcpystrcat等)时,未能正确检查目标缓冲区的大小,从而导致溢出。

(六)如何防止缓冲区溢出

        为了防止缓冲区溢出,可以采取以下措施:

  • 使用安全的编程语言和库:例如,使用C++的std::string代替C语言的字符数组,或者使用Python、Java等高级语言,这些语言通常会自动管理内存。

  • 检查缓冲区大小:在编写代码时,始终检查输入数据的大小是否超出缓冲区的容量。

  • 使用安全的函数:例如,使用strncpysnprintf等安全的字符串操作函数,而不是strcpysprintf等容易导致溢出的函数。

  • 启用编译器保护机制:现代编译器提供了多种保护机制(如栈保护、地址空间随机化ASLR、数据执行保护DEP等),可以有效防止缓冲区溢出攻击。

  • 代码审计和测试:定期对代码进行安全审计和漏洞扫描,及时发现并修复潜在的缓冲区溢出问题。

        缓冲区溢出是一个严重的安全问题,但它也是可以通过合理的编程实践和安全机制来预防的。

        具体到这次炸弹,哪里能体现出会有可能的“缓冲区溢出”的问题呢?自己看了下汇编代码以及相关的转C的代码帮助自己理解,发现只有test中的getbuf中的getxs函数会要求我们进行一个输入并且对输入进行处理。而你把getxs函数汇编转C,喂给Kimi分析,它最后告诉你:这个函数也只是对你输入的字符串(跳过空格)中每个字符转成十六进制数,并且每两个数存储在一个字节中而已,并没有什么值得我们去入手破解炸弹的点。但突然发现getbuf函数中0x00401133地址中存储的"sub esp,0Ch"指令,能大致理解出这是要在栈中留出十个字节的地址空间供存储输入字符串用(可以理解为输入字符串的缓冲区)。这不一下就和主题联系在一起了?那很明显我们需要输入的字符串就肯定不止十个字节了,不然缓冲区又怎么会溢出呢?那么这个时候我们可以很明确我们利用的是其中“栈溢出”的类型了。原理的话,可能“覆盖返回地址”与“执行恶意代码”都会使用吧。

        那说到这,我们也就必须去了解栈的相关结构、构造原理与过程等重要概念了。具体的我放在相关资源“栈的结构以及栈的构造过程演示.ppt”里了,还请大家先弄懂概念与过程再继续看下去(后面的思路分析都基于大家看明白ppt里的内容进行的了,如果还是有疑惑或者对本人内容还有指教或者建议修改补充的,还请在评论区交流或者私聊本人了!拱手~)。

二、对输入字符串前,栈的结构与构造过程的分析(可以结合IDA上汇编指令和ppt内容一起看)

        相信大家对栈的构造原理以及相关函数操作指令的原理都有了一个基本的认识了。下面我们重点来分析输入字符串前,栈的结构以及构造过程。这对后续我们分析该输入什么字符串来破解炸弹很重要!

        由于输入字符串的函数在test函数中,所以我们只需要对test函数进行分析即可。

        首先“push ebp”把main函数的ebp存储起来,执行这段汇编指令后光标移到"ebp"这个字符段上,我们得到存储的ebp==0x0019FF3C;"mov ebp,esp"接着再把ebp赋值为当前esp的值,此时ebp的值为当前执行函数test的ebp值,执行这段指令后用同样方法得到此时ebp==esp==0x0019FDB4;

        接着"sub esp,4"后esp-4==0x0019FDB0,"push esi"使得esi寄存器中的值进栈,esp-4==0x19FDAC;再把0xDEADBEEF的值放在ebp-4==0x0019FDB0中存储起来;"push 17h"使得0x17进栈,esp-4==0x0019FDA8;

        其次GenerateRandomNumber函数执行完后,esp仍然保持0x0019FDA8,ebp也保持0x19FDB4不变;"pop ecx"使得0x19FDA8地址处保存的0x17赋给ecx寄存器,并且esp+4==0x0019FDAC;

        然后chkstk函数执行完后,ebp保持不变,但让我感到惊讶的是:esp的值竟然减少了8,变成了0x19FDA4?!本人在这里也卡了一下,按常理来说,执行完子函数后ebp和esp都应该恢复到执行函数前的大小啊,这里esp是怎么回事呢?进入这个函数看了下汇编:首先没有"push ebp"与"mov ebp,esp"这对经典组合就已经让我很疑惑了。但总体来看也没有对ebp改变什么,函数里面一共push了三次(包括“call _chkstk”指令的隐式push)并且pop了一次("ret"指令的隐式pop),所以最后esp减少了8倒也不是什么很奇怪的事了。

        (ps:这八个字节到底装了什么东西?执行完chkstk后我们入栈观察栈的当前数据存储(执行完chkstk函数后点击“esp”这个字符段):0x0019FDA8存储的是0x00401177,这很明显是"call _chkstk"指令隐式执行的将chkstk返回地址入栈导致的结果,0x0019FDA4存储的是0x00000017,这是我们在执行前面的指令就遇到的数据,具体怎么执行得到的其实也不重要(说实话,这八个字节空间存储的数据其实都不重要,哈哈哈));

        紧接着执行getbuf函数,"call _getbuf"+"push ebp"+"mov ebp,esp"算是老组合了,这时在0x0019FDA0地址处存储着getbuf函数执行完后返回test函数的地址(0x00401181),0x0019FD9C地址存储着test函数的ebp的值(0x0019FDB4),并且ebp的值赋为当前执行函数getbuf的ebp的值(0x0019FD9C),此时esp==ebp==0x0019FD9C;

        最后"sub esp,0Ch"后esp-0Ch==0x19FD90,后面进入getxs函数进行对我们输入字符串的处理与存储(虽然在“call _getxs”指令前还有一个"push eax"指令,但实际上函数对处理后的输入字符串写入内存时仍然是从0x0019FD90这个地址开始的,执行完这个函数后还有个"pop ecx"抵消了"push eax"对esp的影响,所以最后getbuf函数执行完后,esp与ebp是能够分别正常恢复到0x0019FDA4与0x0019FDB4的);

        自此我们已经分析清楚在我们输入字符串前栈的结构以及构造的过程(详图请参考ppt),接下来我们将对每一关该输入怎样的字符串以及栈的结构变化进行分析。

三、对输入字符串后,栈的结构以及接下来的执行结果的分析(可以结合IDA上汇编指令和ppt内容一起看)

        首先我们必须确定一件事:我们每次执行程序只能输入一次字符串触发其中一个木马。这不会像二进制炸弹一样执行一次程序可以输入多次字符串来通关。所以我们必须执行程序四次,并且输入不同的字符串来分别触发四个不同的木马。

(一)第一个木马的触发过程分析

        首先我们先确定Trojan1函数的起始地址:0x004011F0。如果缓冲区没有溢出,那么getbuf函数执行完后会正常回到test函数应该执行的下一条指令,那我们与Trojan1函数将永远接触不到。

        考虑到“缓冲区溢出”中“覆盖返回地址”这一原理,我们可以尝试将"getbuf执行完后返回地址"改成Trojan1的起始地址,这样的话getbuf函数执行完就会跳转到Trojan1函数,只要跳转到Trojan1后续就可以触发“成功运行第一只木马”了。所以根据我们第二步中构造出来的栈的结构:重点是要修改0x0019FDA0这个地址所存储的数据!

        我们的输入是从0x0019FD90这个地址开始存储的,所以尝试输入"00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 F0 11 40 00"(前十六个字节其实输入啥都可以,注意这里的“小端法”存储),这时候我们通过“缓冲区溢出”将getbuf执行完后的返回地址修改为0x004011F0,即Trojan1函数的起始地址。经过执行程序输入检验,此时第一个木马被成功运行,证明我们的想法是对的,下面是运行截图展示:

(二)第二个木马的触发过程分析

        首先还是先确定Trojan2函数的起始地址:0x00401210。

        接着分析Trojan2的逻辑:它会将esp+8所对应地址所存储的数据与我们得到的cookie比较,只有二者相同才可以触发“成功运行第二只木马”。cookie是根据我们学号生成的数据,也就是这个缓冲区炸弹中我们的“通行密码”,比如我的_cookie==0x1CDCAE2B。

        那么我们要考虑的就很简单了:一方面修改返回地址,这个原理和“(一)”中一样;另一方面我们要确定esp+8的值,并借助缓冲区溢出将这个地址的值改为cookie的值。getbuf函数刚执行完后esp==0x0019FDA4(这个原理很简单,就不再过多解释),而Trojan2首先会执行"push ebx"的指令使得esp-4==0x0019FDA0,再把esp+8==0x0019FDA8地址所存储的值与cookie进行比较。所以我们输入的字符串只要能让0x0019FDA8地址的数据变为0xACDCAE2B即可。

        我们的输入是从0x0019FD90这个地址开始存储的,所以尝试输入"00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 10 12 40 00 00 00 00 00 2B AE DC 1C"(前十六个字节其实输入啥都可以,中间有四个字节全为0的部分其实也是输入啥都可以)。经过执行程序输入检验,此时第二个木马被成功运行,证明我们的想法是对的,下面是运行截图展示:

(三)第三个木马的触发过程分析

        老样子还是先确定Trojan3函数的起始地址:0x00401260。

        然后分析Trojan3函数的逻辑:它会将一个确定大小的全局变量global_value与cookie进行比较,只有二者相同才可以触发“成功运行第三只木马”。

        其实第一个闪过脑子的思路,和解决第二问的思路一样:要是能利用缓冲区溢出,直接将原来的global_value的值覆盖掉不就可以了吗?但去看global_value所在的内存地址:0x00409004!好的,那这个思路可以直接pass掉了(笑哭)。

        

        但回忆到缓冲区溢出的第二个攻击原理,“执行恶意代码”:当程序执行到被覆盖的返回地址时,会跳转到恶意代码执行,从而实现攻击者的意图。那就可以考虑用这个方法了。

        如果我们让getbuf函数返回的地址在我们编写的汇编指令所在的地址时,那这个时候不就可以执行我们想要的指令了吗?乍一听,其实跟我们在二进制炸弹中提到的“动态生成指令”方法差不多。那我们可以考虑让函数返回到0x0019FD90这个地址,然后让程序执行我们输入的字符串所对应的汇编指令,用这个思路来对global_value进行修改。

        跳转后该执行怎样的汇编指令呢?大致思路是借助一个寄存器,先保存cookie的值,再把这个寄存器的值赋给global_value所在的内存空间,最后既可以考虑直接利用jmp指令跳转到Trojan3函数的起始地址,又可以考虑利用"push"与"ret"的组合:"push"将Trojan3函数的起始地址入栈,"ret"使得eip直接变为Trojan3函数的起始地址达到跳转的目的。后者因为不用计算跳转的相对地址会更简单一点,本人也用的是后者的思路。但这里把两种思路的结果都进行一次分析。

        首先考虑两种思路共同的修改global_value的部分:这里用如下汇编指令就可以实现:

                                mov eax,0x1CDCAE2B(cookie)

 mov  [0x00409004](global_value所在的地址),eax

Online x86 and x64 Intel Instruction Assembler

        用以上的网站可以实现汇编代码转机器码。我们输入指令,得到两条汇编指令对应的机器码分别为:0xB8 2B AE DC 1C与0xA3 04 90 40 00。接下来考虑跳转到Trojan3函数的两个方法实现:

        1.利用jmp指令实现跳转

        因为前面两条汇编指令的机器码一共有十个字节,所以对应jmp指令的起始地址应该为0x0019FD90+10==0x0019FD9A。我们需要跳转到0x00401260地址,并且jmp指令本身长度为五个字节,所以我们计算得到的跳转相对地址大小为0x00401260-(0x0019FD9A+5)==0x2614C1,所以对应jmp的机器码为E9 C1 14 26 00,但0x19FD90到0x19FDA0一共有十六个字节的地址空间,所以将三个指令的机器码拼接在一起后我们仍需要补充一个单字节大小的数(啥都可以,这里我补充的00),最后得到的字符串为“B8 2B AE DC 1C A3 04 90 40 00 E9 C1 14 26 00 00 90 FD 19 00”,执行程序输入后显示“第三只木马被成功运行”,说明我们思路与输入正确。下面是运行截图展示:

        2.利用push与ret组合指令实现跳转

        我们这里可以直接将Trojan3的起始地址入栈,即执行“push 0x00401260”的指令,然后"ret"指令会让0x00401260这个数出栈并赋给eip,实现跳转功能。两条指令转机器码分别为0x68 60 12 40 00与0xC3。将前面的指令一起拼接后刚好十六个字节,也不需要补充什么。最后得到的字符串为“B8 2B AE DC 1C A3 04 90 40 00 68 60 12 40 00 C3 90 FD 19 00”,执行程序输入后显示“第三只木马被成功运行”,说明我们思路与输入正确。下面是运行截图展示:

(四)第四个木马的触发过程分析

        照旧去获取Trojan4函数的起始地址:0x004012B0。

        再次分析Trojan4的逻辑:我们发现它和Trojan3的原理类似,都是需要将global_value修改为cookie的值。唯一不同之处在于:Trojan3最后执行的是exit的退出程序指令,而Trojan4最后执行的是ret的返回指令。那接下来我们需要分析“ret”到底取的是哪个地址所存储的值跳转。

        执行完getbuf函数后,esp==0x0019FDA4。而Trojan4中又是"push ebx"与最后“pop ebx”+"ret"的常规操作,函数执行到"ret"指令时esp保持0x0019FDA4不变。所以最后ret指令执行时,跳转到的地址应该为0x0019FDA4中存储的数。我们再去寻找exit函数的首地址,保证这一步"ret"可以直接跳转到exit函数去执行程序退出的指令。

        我们最后可以得到exit函数的首地址为0x00401580。所以利用缓冲区溢出,我们要保证0x0019FDA4这个地址存储的值应该为0x00401580即可。

        修改global_value的思路与第三问相同,并且两种方法仍然都可以,所以输入的前二十位除了修改jmp跳转的绝对地址以及push的值的大小,其他是没有变化的(这里jmp的值按上面计算原理,应该为0x004012B0-0x0019FD9A-5==0x00261511;push的值应该为0x004012B0),只不过我们需要再添加“80 15 40 00”来改变Trojan4的返回地址。所以我们尝试输入“B8 2B AE DC 1C A3 04 90 40 00 E9 11 15 26 00 00 90 FD 19 00 80 15 40 00”(jmp思路)或者“B8 2B AE DC 1C A3 04 90 40 00 68 B0 12 40 00 C3 90 FD 19 00 80 15 40 00”(push+ret思路),执行程序输入后显示“第四只木马被成功运行”,说明我们思路与输入正确。下面是运行截图展示:

(五)"鸟还活着"+“缓冲区溢出成功”的触发过程分析

        本人也是在提交作业的时候才发现还有一个“鸟还活着”需要去触发。而这句话的触发就在test函数中。观察它的触发条件:首先把[ebp-4]地址所存储的值与0xDEADBEEF来进行比较。

        正常getbuf函数执行完的话,ebp==0x0019FDB4,而ebp-4==0x0019FDB0所存储的值恰为0xDEADBEEF,所以我们不能改变栈中所存储的test的ebp的值,也就是说0x0019FD9C地址所存储的数我们要保持0x0019FDB4不变;其次我们要保证eax的值与cookie相等,这里我们还是可以利用动态生成指令的原理,考虑生成如下的汇编指令来达到修改eax值,并且还要使得最后eip跳转到"call _getbuf"的下一条指令(存储在0x00401181地址)运行。

                             mov eax,0x1CDCAE2B(cookie)

push 0x00401181("call _getbuf"的下一条指令地址)

                             ret

        将汇编指令转机器码得到他们的机器码分别为0xB8 2B AE DC 1C、0x68 81 11 40 00与0xC3,拼在一起一共十一个字节,最后可以随便补一个字节的数将缓冲区填满(这里我用的是00)。所以我们尝试输入“B8 2B AE DC 1C 68 81 11 40 00 C3 00 B4 FD 19 00 90 FD 19 00”,执行完程序后成功触发“鸟还活着”并且缓冲区也成功溢出。下面是运行截图展示:

        ps:这里也可以同理利用jmp来达到跳转地址的目的。跳转的相对地址计算为0x00401181-0x0019FD95(存储jmp指令的地址)-5==0x002613E7。得到的jmp的机器码为E9 E7 13 26 00。两条指令机器码拼在一起后还需要随便补充两个字节的数来填满缓冲区(这里我用的是00 00)。所以我们尝试输入“B8 2B AE DC 1C E9 E7 13 26 00 00 00 B4 FD 19 00 90 FD 19 00”,执行完程序后也能成功触发“鸟还活着”并且缓冲区也成功溢出。下面是运行截图展示:

四、答案总结

        第一只木马触发字符串:00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 F0 11 40 00

        第二只木马触发字符串:00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 10 12 40 00 00 00 00 00 2B AE DC 1C

        第三只木马触发字符串:B8 2B AE DC 1C A3 04 90 40 00 E9 C1 14 26 00 00 90 FD 19 00(利用jmp)或者B8 2B AE DC 1C A3 04 90 40 00 68 60 12 40 00 C3 90 FD 19 00(利用push+ret) 

        第四只木马触发字符串:B8 2B AE DC 1C A3 04 90 40 00 E9 11 15 26 00 00 90 FD 19 00 80 15 40 00(利用jmp)或者B8 2B AE DC 1C A3 04 90 40 00 68 B0 12 40 00 C3 90 FD 19 00 80 15 40 00(利用push+ret)

        “鸟还活着”+“缓冲区溢出成功”触发:B8 2B AE DC 1C E9 E7 13 26 00 00 00 B4 FD 19 00 90 FD 19 00(利用jmp)或者B8 2B AE DC 1C 68 81 11 40 00 C3 00 B4 FD 19 00 90 FD 19 00(利用push+ret)

 

        希望不仅能够帮到你完成实验,还能助你更好理解汇编指令以及存放局部变量的栈的结构(献花)!

        ps:若对本文章或是附属文件ppt有修改建议的还请指教!

 

Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐