二进制炸弹bomblab-第一关-保姆级教程

观前提示:本文共一万两千余字,并非速通指南,偏向于如何一步步思考与解题,面向小白。几乎每一个要使用到的指令和知识都会大致介绍一下,所以也许会有些拖沓。

一、 前言

1、炸弹简介

​ BombLab是由CMU(卡内基梅隆大学)推出的。CMU是美国的一所著名私立研究型大学,位于宾夕法尼亚州的匹兹堡市。BombLab是计算机科学领域的一个教育项目,旨在帮助学生学习和理解计算机系统安全和二进制漏洞的概念。该项目通常用于计算机科学相关的课程和实验室,让学生通过解决各种二进制炸弹(Bomb)的任务来加深对计算机系统的理解。

​ 现今已有多个版本。本文版本为 JMU 22级 “软件导论”课程 实验作业 32位版本

原版获取方式

原版地址

在这里插入图片描述

官方介绍

​ “二进制炸弹”是作为目标代码文件提供给学生的程序。运行时,它提示用户键入6个不同的字符串。如果其中任何一个错误,炸弹就会“爆炸”,打印错误消息并在评分服务器上记录事件。学生必须通过拆解和逆向工程程序来“拆除”他们自己独特的炸弹,以确定6个字符串应该是什么。该实验室教学生理解汇编语言,并强制他们学习如何使用调试器。也很有趣。这是一个 Linux/x86-64二进制炸弹,您可以自己试用一下。通知评分服务器的功能已经被禁用,所以你可以随意引爆这个炸弹而不受惩罚。

2、一些碎碎念

​ 过去我看遍全网博客和视频,但仍旧卡在某一个点,不过机缘巧合之下,我遇到了一位很厉害、非常有耐心、非常慷慨的引导人(堪称活菩萨)。是他从零指导我自己亲手解决了卡关之处,本快要放弃的我终究没有放弃,后来我继续迅速破关。不过也耗费了一些san值。现在我终于也走上了写解析帮别人的路,还是挺感慨的。就当是跨越时空,从零指导半年前的我自己,那个曾经期望有人能够讲得稍微细致一点的我自己。

​ 如果这些解析对你有帮助,那么我会非常荣幸和开心!

​ 如果我的解析存在错误,非常抱歉误导了你!请在评论区提出!我也是一个正在不断学习的学生。

​ 谢谢你!

二、了解我们有什么

1、分析我们拥有的文件

​ 打开属于你的那个炸弹文件夹,你会看到这几个文件:

在这里插入图片描述

第一个bomb文件是可执行文件。可执行文件是由编译器将源代码编译成计算机可以直接执行的机器代码而生成的,通常包含机器码和二进制数据。在这里显然是待破解的炸弹程序。运行它,炸弹就开始了。

这是直接强行打开它的样子,看着莫名挺恶心的(大概是颜色以及数字太密集):

在这里插入图片描述

第二个 bomb.c文件是C语言源代码。我们打开可以看到一些注释和代码,那些注释是设下炸弹的所谓的“邪恶博士”的一些自言自语,开篇的大段注释可以知道他隐藏了源代码,这个文件并非所有的代码。余下的注释要么是嘲讽要么是直接的提示要么是二者混杂。

第三个 ** ID文件和 ** 第四个 README文件没有有用信息。

2、分析我们拥有的C语言源代码

(1)头文件

#include <stdio.h>
#include <stdlib.h>
#include "support.h"
#include "phases.h"

这段代码是C语言的预处理指令,用于包含标准头文件和自定义头文件。让我们逐一解释这些指令的意思:

  1. #include <stdio.h>#include <stdlib.h>:这是包含标准头文件的指令。stdio.h是C语言中的标准输入输出头文件,其中包含了一些用于输入输出的函数和常量的声明。通过包含这个头文件,可以在程序中使用标准的输入输出功能(例如printfscanf等)。stdlib.h是C语言中的标准库头文件,其中包含了一些通用函数和常量的声明。通过包含这个头文件,可以在程序中使用诸如内存分配函数(例如mallocfree)等功能。
  2. #include "support.h" #include "phases.h":这是包含自定义头文件的指令。它包含了程序中使用的一些自定义函数、变量或宏的声明。通过包含这个头文件,可以在程序中使用在support.h中声明的函数或变量。

可以发现,正如开头的注释所言,最重要的phases.h文件我们是没有的,也就是每个关卡具体的C语言代码我们不知道。

(2)一些有关输入的分析

FILE *infile;

定义了一个指向FILE类型的指针变量infile。

int main(int argc, char *argv[])  

C语言程序的入口函数。main函数接受两个参数,
argc(argument count)表示命令行参数的数量,
argv(argument vector)是一个字符指针数组,用于存储指向每个命令行参数字符串的指针。
这些有什么用接着往下看就知道了。

char *input;//定义了一个指向char类型的指针变量input,用于表示输入的字符串。
    if (argc == 1) {  
		infile = stdin;/*标准输入流,用于从用户获取输入。默认情况下,输入来自于键盘,但它也可以重定向为来自文件或其他输入源*/
    } 
    else if (argc == 2) {
	    if (!(infile = fopen(argv[1], "r"))) {
	        printf("%s: Error: Couldn't open %s\n", argv[0], argv[1]);
	        exit(8); //exit函数位于stdlib.h头文件中,用于终止程序执行
	    }
    }
    else {
		printf("Usage: %s [<input_file>]\n", argv[0]);
		exit(8);
    }

​ 这段if代码块里有段注释(上面没有,为了结构清晰方便看我删去了),翻译过来是:“当使用一个参数 < file > 运行时,Bomb 从 < file > 读取到 EOF,然后切换到标准输入。因此,当您拆除每个阶段时,您可以将其拆除字符串添加到 < file > ,从而避免重新键入它。” 说人话就是,比如你已经有四关的答案,写到文件里,运行程序时传进去,然后就会从第五关开始才需要手动输入答案,这样就不用每次都手动一关关输入了。

撇开注释,看看代码。分支结构是:

  1. 命令行参数个数为一。然后设置输入方式是用户手动键盘输入。
  2. 命令行参数个数为二。如果不能以只读方式打开文件,就打印提示,然后终止程序。
  3. 其他情况。直接打印提示,然后终止程序。

所以,最多只允许输入2个参数。说了这么多虚的,我们不妨直接试试看

​ 我们想要运行炸弹,可以使用gcc,它是GNU Compiler Collection(GNU[1]解释语句我会按此序号立刻标注在相应段下方编译器集合)的缩写,是一个开源的、功能强大的编译器套件,主要用于编译C、C++、Objective-C和Fortran等语言。在ww老师给的这个镜像里,已经配置好了,所以直接使用。运行可执行文件非常简单,只需在终端或命令提示符中输入可执行文件的名称,然后按下回车即可。

​ [1] GNU(GNU’s Not Unix)是一个自由软件运动的旗舰项目,目标是开发一个完全自由的操作系统,以替代类UNIX系统,并将所有软件的源代码都以自由软件许可证发布,使用户能够自由地使用、修改和重新分发软件。

​ 由于我已经打开了炸弹所在的目录,所以直接在这打开终端,当然,也可以先打开终端然后使用指令来到这个目录下。Linux上运行可执行文件时,需要在文件名前加上./,以告诉终端是在当前目录下找可执行文件。这样是第一个if分支,只有一个命令行参数,argv[0]指向’./bomb‘。于是就开始了炸弹。

​ 下面来看第二个分支。我准备了正确答案放在ans文件里。可以看到不需要我输入东西就自动进入下一个关卡了。(忘了下面这里目录和文件名出现中文是我设置的还是本来就是,没什么关系,个人习惯罢了)

在这里插入图片描述

​ 第二个if分支里,如果是打不开的文件夹,就会打印提示,然后终止程序。如果是可以打开的,就开始进入关卡,自动读文件破解(下面示例读ID文件,它里面不是正确答案,所以第一关就引爆炸弹了)。

在这里插入图片描述

试着输入3个参数来触发第三个分支:

一切就如上面分析的那样。

(3)关卡执行流程部分

    initialize_bomb();//这玩意是在support.h或者phases.h里定义的函数,反正是我们没有的

    printf("Welcome to my fiendish little bomb. You have 6 phases with\n");
    printf("which to blow yourself up. Have a nice day!\n");

    input = read_line();  /*也是自定义函数,凭借之前的注释我们可以知道这个既可以支持文件输入,也可以键盘输入。看它的名字,它应该是一行行读取,所以到时候写答案文件就一关一行地写*/
    phase_1(input); //自定义函数,关卡函数             
    phase_defused();//自定义函数,关卡被破解函数
    printf("Phase 1 defused. How about the next one?\n");
	//余下关卡差不多,略。
    return 0;

​ 以上代码可以知道,似乎一共有六个关卡,每关都需要输入一个字符串,它会被传到关卡函数里面,如果在关卡函数里没触发什么东西,那就会回到main函数,执行关卡被破解这个函数。然后打印通关提示语,进入下一关。重复这样的流程,直到全部破解。

​ 至此,C代码分析完毕。

3、小结:有什么—>做什么

​ 现在我们已经了解清楚我们拥有什么了。一个可执行文件,一个不够完整的c源代码。通过C语言源代码我们只能知道炸弹大概的运行流程。所以要想得到通关字符串,只能从可执行文件下手了,它是由完整的C代码经过预处理[1]、编译[2]、汇编[3]、链接[4]而形成的。虽然那些是写给计算机看的机器码和二进制数据,我们看不懂,但是,我们可以通过gdb[5],反汇编这个可执行程序从而得到汇编代码,然后分析汇编代码,再加上运行这个程序时跟踪它的堆栈内存里的数据变化,就可以破解它了。当然,这需要我们能够看懂一点点汇编语言,以及会使用gdb的一点点基本功能。下面我们就来开始做这些事情。

​ [1] 预处理(Preprocessing):在编译之前,源代码会经过预处理。预处理器会处理以#开头的预处理指令,例如#include#define等,并将它们展开或替换为实际的内容。预处理的结果是展开后的源代码。

​ [2] 编译(Compiling):编译器将预处理后的源代码翻译成汇编代码(assembly code)。汇编代码是更接近机器语言的低级代码,但仍然是面向特定处理器的。

​ [3] 汇编(Assembling):汇编代码进一步由汇编器转换成机器代码(object code)。机器代码是由二进制数字表示的底层指令,它是计算机处理器能够直接执行的形式。

​ [4] 链接(Linking):如果程序中包含多个源代码文件或使用了外部库,那么在编译后的机器代码中,会包含对其他文件或库的引用。链接器负责将这些引用解析并与程序的机器代码进行连接,生成最终的可执行文件。所以才说可执行文件包含所有我们需要的代码。如果程序中没有使用外部库或其他源代码文件,则这个步骤可能会被省略。

​ [5] gdb是GNU Debugger(GNU调试器)的缩写,它是一个功能强大的开源调试器,用于调试C、C++、Fortran等编程语言的程序。是GNU项目的一部分,可以用于许多操作系统和平台。GDB允许程序员在程序运行过程中暂停程序的执行,检查程序状态、变量的值,设置断点,跟踪函数调用栈等。通过GDB,程序员可以更好地理解程序的运行情况,找出程序中的错误和问题,以及进行代码调试和优化。

​ GDB支持许多调试功能,包括但不限于以下功能:

  • 设置断点:在代码的特定位置设置断点,当程序执行到这个位置时,会暂停执行,方便程序员查看变量值和程序状态。
  • 单步执行:逐行执行程序,程序员可以一步一步地跟踪程序的执行过程,检查每一行代码的效果。
  • 查看变量值:可以查看当前运行时程序中各个变量的值,帮助程序员了解程序运行时的状态。
  • 回溯函数调用栈:查看当前函数调用栈的状态,追踪函数的调用和返回过程。
  • 内存调试:查看程序的内存使用情况,检查指针、数组访问等问题。
  • 多线程调试:支持调试多线程程序,查看多线程的状态和运行情况。
  • 反汇编:用于查看程序的汇编代码。这些指令可以帮助程序员深入了解程序的底层执行过程和优化代码。

三、开始正式破解

1、一些题外话

​ 我们需要会用gdb反汇编。曾经有个人和我说过,认识一个工具可以先从官方的指南开始。我无法评判这样好不好,因为我很少在刚刚开始的时候使用这个方法,通常是有点基础了才这样。个人感受是,好像不太适合求速度的纯新手?不过还是挺有意思的。

​ 去官网(好像现在是在一个代码托管平台sourceware上了)逛了一下,(有一说一,sourceware网页的风格真的让我感觉回到过去,很欣赏这种暴力简约的界面,不需要什么背景图,不需要什么色彩,不需要什么彩色图片,又好写看着又简洁明了,哈哈哈哈哈哈来自菜鸟的赞)

在这里插入图片描述

​ 得到了gdb的pdf版手册。我阅读过一部分手册,个人认为如果真靠手册来学习的话,会挑重点是一个重要技能,毕竟一共九百多页,光目录就16页。如果不知道自己要学什么、在学什么,真的就挺浪费时间的。求速度的小白真的没必要去看指南,互联网世界这么大,一定有很多更易懂更快速的方式。我们还没走到高精尖一线位置,我们有好多前人留下的智慧结晶。不过,如果自己闲着那倒可以认真看看,陶冶一下情操。

在这里插入图片描述

2、获取汇编代码

(1)一关一关地获取汇编代码

对于第一关,我们的任务是来到phase_1()这个函数,然后对它反汇编,拿到关卡一具体的汇编代码。

主要指令是:

#终端里可以按tab键系统自动猜测补齐命令,上下键选取以前输入过的命令,这样节省输入时间
gdb bomb  #调用gdb来调试bomb这个可执行文件
b phase_1 #breakpoint 		在phase_1函数处设断点  不设你给完参数就直接炸了
r bomb    #run 	启动炸弹程序,这样它会在phase_1函数断点停住
disas	  #disassemble(反汇编) 然后就得到函数phase_1的汇编语言版本

效果是:

在这里插入图片描述

(2)一次性全部获取

你也可以选择把可执行文件一次性反汇编成一个文件。

方法一 : objdump
objdump -d 可执行文件路径 > 即将生成的文件的名字

objdump 是一个功能强大的 GNU 工具,用于查看和分析可执行文件、目标文件和共享库的内容。它通常用于查看二进制文件的汇编代码、符号表、节(section)信息等。在Linux和其他类UNIX系统中,objdump 是一个非常有用的命令行工具,经常用于调试和逆向工程。

方法一的改进

也许你会觉得要翻一会才能找到Phase_1麻烦,那么可以使用如下命令:

#从phase_1开始反汇编到文件结束
objdump -d ./bomb | awk '/<phase_1>:/,EOF { print }' > disas02.txt

#还可以加一些参数 比如-M intel:使用 Intel 格式的汇编代码,使输出更容易阅读
objdump -d -M intel ./bomb | awk '/<phase_1>:/,EOF { print }' > disas03.txt

awk 是一种文本处理工具,它的名字来源于其创始人 Alfred V. Aho、Peter J. Weinberger 和 Brian W. Kernighan 这三位计算机科学家的姓氏首字母。

在类UNIX系统中,awk 是一个常用的命令行工具,可以直接在终端中使用。它以行为单位对文本进行处理,允许程序员根据特定模式来搜索、匹配和转换文本。awk 使用一种简单而有效的语法,它的功能类似于其他编程语言,但主要用于处理结构化的文本数据。它的用途非常广泛,例如从文本文件中提取特定字段、计算数据的统计信息、格式化输出等。由于它的灵活性和强大的文本处理能力,awk 在编程、数据处理和文本处理领域得到广泛应用。

方法二 : gdb

或者你也可以使用gdb来干这个事情。不过稍显麻烦。

在这里插入图片描述

记得到结尾了最后还要

set logging off   #停止记录日志

​ 在计算机领域中,“日志”(Log)是指对系统、应用程序或事件的活动进行记录的过程。记录的信息被保存到一个文件中,称为日志文件。日志对于软件开发、系统管理、故障排除和安全审计等方面都是非常重要的。日志可以用于不同的目的,包括但不限于:

  1. 故障排除与调试:当应用程序或系统出现问题时,记录日志可以帮助开发人员或管理员了解发生了什么问题,以便更轻松地进行故障排除和调试。
  2. 性能监控:记录系统或应用程序的性能数据,如响应时间、资源利用率等,以便评估系统的运行状况和性能。
  3. 安全审计:记录安全事件和用户活动,以便追踪和审计安全违规行为或潜在的威胁。
  4. 行为分析:通过分析用户行为和应用程序的使用情况,可以了解用户的偏好和需求,从而改进产品和服务。
  5. 版本控制:在软件开发中,记录版本控制系统的日志可以跟踪代码的变更和开发历史。

​ 在 GDB 中,set logging on 命令用于启用日志记录功能。一旦启用,GDB 将记录会话中的所有命令和输出,并将其保存到指定的文件中。这对于在调试过程中跟踪程序状态、查看变量值、执行汇编指令等操作非常有用。set logging off 命令则用于停止记录日志。总之,日志是对活动的记录,它可以帮助我们跟踪和了解系统、应用程序或事件的情况,从而进行监控、调试、安全审计等操作。

这个方法得到的汇编代码格式是这样的:

在这里插入图片描述

3、解读汇编代码

(1)一些背景知识

通过以上三种方式,我们能够看到四种汇编代码,他们所表达的东西都是一样的。往后我以 “使用objdump写入文件默认的AT&T风格” 为例来分析,即寄存器[1]前有%号,立即数[2]前会$号,源操作数[3]在前,目标操作数[3]在后的风格:

;汇编语言用分号来注释	
;汇编地址     机器指令(给计算机看的形式)		   汇编指令(给人看的形式)
;(16进制)   操作码 + 操作数(机器形式)		  操作码(助记符形式)+   操作数
;															源操作数  目标操作数
8048b33:	    83 ec 14           		  sub         		$0x14,    %esp

图源维基百科:

在这里插入图片描述

汇编地址通常指的是内存中的一个位置或存储单元的地址。在计算机中,内存由许多存储单元组成,每个存储单元都有一个唯一的地址,通过这个地址可以访问该存储单元中的数据。

[1] 寄存器是计算机内部用于临时存储数据的一种高速存储器件。它们位于CPU内部,是处理器最快的存储器。下面只列举2个即将见面的寄存器:

  • eax (Extended Accumulator):累加器寄存器,用于算术运算和函数返回值。
  • esp (Extended Stack Pointer):堆栈指针寄存器,用于管理程序堆栈,指向程序的堆栈顶部

操作数代表指令要操作的数据或地址。汇编语言指令操作数的个数范围是 0~3 个,每个操作数可以是寄存器、内存地址、立即数和标签(标签大概类似C语言指针名,用来标记代码中特定位置的符号,汇编器会将标签转换为相应的内存地址)。计算机指令通常由操作码和操作数组成,操作数是操作指令所需数据的一部分。

[2] 立即数是一种操作数,用于提供操作所需的固定数据,是直接嵌入到指令中的常数值。不需要从内存或其他寄存器中获取。它可以是整数、字符、浮点数等常量。

[3] 源操作数是计算机中指令执行过程中参与运算的数据,它们通常是指令中需要读取的数据。在计算机指令中,操作数可以分为源操作数和目的操作数。目的操作数是指计算结果或操作结果需要存储的位置。

汇编指令是怎么执行的?

  1. 取指令阶段:
    • CPU(中央处理器)通过程序计数器(Program Counter,PC)来指示要执行的下一条指令在内存中的地址。
    • 指令地址送到内存控制器,内存控制器从内存中读取指令数据,并将其传送给指令寄存器(Instruction Register,IR)。
    • 指令寄存器将存储的指令传送给下一个阶段。
  2. 译码阶段:
    • 指令寄存器中的指令被解析和译码,确定指令的类型和操作数。
    • 控制器根据指令的操作码识别指令类型,并确定需要使用哪些寄存器或内存地址。
  3. 执行阶段:
    • CPU根据译码阶段的结果执行指令的操作,可能涉及算术运算、逻辑运算、数据传输等。
    • 对于算术和逻辑指令,ALU(算术逻辑单元)执行相应的运算,并将结果保存在临时寄存器中。
  4. 访存阶段:
    • 如果指令需要访问内存中的数据,CPU会将内存地址计算出来,并将其发送到内存控制器。
    • 内存控制器将数据从内存读取出来,或者将数据写入到内存中,完成对内存的访问。
  5. 写回阶段:
    • 对于某些指令,执行阶段的结果可能需要写回到寄存器或内存中。
    • 执行阶段的结果从临时寄存器传送回目标寄存器或内存位置。
  6. 回到第一步:
    • 处理器继续取下一条指令,并重复上述阶段,循环执行直到程序结束或遇到特定终止条件。

(2)关于我们输入的字符串

在进入phase_1这个函数分析前,应该关注一下main函数把我们的字符串存到哪里了。

查看.c源代码文件知道73行在输入字符串,所以先试试看在74行设断点。除了之前那样用函数名设断点,可以也这样按行设:

b bomb.c:74

然后流程和上面一样,设断点,运行,反汇编。

就得到:

mov 是数据传输指令,全称为 “Move”,用于将数据从一个位置复制到另一个位置。

这条指令的含义是将 %eax 寄存器中的值复制到 (%esp) 地址指向的内存位置。

涉及2个寄存器,我们试试看直接查看他们里面是什么。

gdb可以使用

  • p 命令(print的缩写)打印出变量或者寄存器当前的值,用法是p 变量名或者p $寄存器名。
  • x/s 起始地址 命令查看内存中以空字符结尾的字符串。它会从指定地址开始,一直读取内存直到遇到空字符(ASCII码为0)。x是examine的缩写,s代表string。

发现是eax里面存放了储存我们字符串的地址(十进制)。

(3)逐行分析phase_1函数

 8048b33:	83 ec 14             	sub    $0x14,%esp
 ;这条指令存储在8048b33这个地址,对esp(堆栈指针寄存器)执行减法操作,要减去20(十六进制->十进制),目的是分配空间。

为什么esp减去20就是分配空间了?

esp是堆栈指针寄存器,它指向栈的顶部。通常约定存储空间用栈的形式使用,栈是一种后进先出的数据结构。

栈的生长方向是向低地址方向,即栈顶指针向低地址移动表示栈增长,向高地址移动表示栈收缩。因此,sub $0x14, %esp 的效果就是在栈上分配预留了 0x14 字节(20字节)的空间,用于存储临时数据、函数参数或局部变量等。

当一个新的函数被调用时,通常会执行类似于 sub $0x14, %esp 的指令,为该函数的局部变量分配内存空间。在函数返回时,会执行相应的指令将栈指针还原,释放掉分配的栈空间。

 8048b36:	68 84 9f 04 08       	push   $0x8049f84

push指令也类似。把数据存入栈中常用push表示,即压栈。执行这个指令会导致以下操作:

  1. esp寄存器减去4(因为32位机将数据压入栈时一个数据4字节)。
  2. CPU会把0x8049f84当成一个内存地址,访问这个地址的内容。
    • 这个叫做立即寻址。
  3. 将访问到的内存内容读取出来。
  4. 将这个值写入esp指向的内存单元,也就是压入栈顶。

pop 是它的反义词,从栈顶弹出一个数据,并将其加载到目的操作数中。

图源王爽老师的《汇编语言》:

在这里插入图片描述

:8086 CPU是 Intel 公司于1978年推出的一种16位微处理器。它是 Intel x86 架构的第一代微处理器,被广泛应用于早期的个人计算机(PC)和其他计算机系统中。8086 CPU 使用的是16位架构,意味着它可以处理16位的数据和地址。它有16个通用寄存器,每个寄存器都是16位的,其中包括 AX、BX、CX、DX、SI、DI、BP 和 SP 等寄存器。此外,8086 CPU 还有四个段寄存器,分别是 CS(代码段寄存器)、DS(数据段寄存器)、SS(堆栈段寄存器)和 ES(附加段寄存器),用于实现内存分段机制。

所以这里到底压入了什么东西呢?我们不妨来查看一下。

我们得到了一串关卡函数自带的字符串,看起来是一句欢迎语,目前不知道是干什么用的。没关系,那我们接着往下分析。

 8048b3b:	ff 74 24 1c          	pushl  0x1c(%esp)
  1. pushlpush long 指令的缩写,表示将一个32位(4字节)的数据压入栈中。l 表示 “long”,也就是32位。
  2. 0x1c(%esp) 中的0x1c表示一个偏移量,(%esp)表示以ESP栈指针寄存器作为基址。
  3. 所以0x1c(%esp)表示以esp为基地址,加上偏移0x1c字节的内存单元
    • 这个叫基址+偏移寻址。比如从庄重文操场去西苑食堂的路有好多条,有近路有远路,你可以对别人说,先去光前体育馆大门口然后往东再走50米,就到西苑了。
    • 这种小括号写法是AT&T风格的,括号中可以有3个数据,基址寄存器base、索引寄存器index和比例因子scale。在此不做展开解释,如果后期有用到我们再来解释,减少负担。
  4. 这个指令将esp+0x1c地址的内容压入栈顶。

这里又压入了什么东西呢?我们不妨再来查看一下,为了使用x/s指令,我们得先知道执行到这一步时esp的地址,这里需要用到一个指令:

  • ni命令是next instruction(下一条指令)的缩写,表示单步执行一条汇编指令。

在这里插入图片描述

那0x1c(%esp)就是 0xbfffef34 + 0x1c = 0xbfffef50

发现这个恰好是之前我们在主函数查看过的字符串输入时esp指向的地址。

这种现象的原因是:

  • 主函数和子函数共享同一个栈空间。
  • sub 指令减小esp值以分配空间给局部变量。
  • 主函数中的esp值对应了子函数栈底地址。
  • pushl以esp_main为基础的偏移地址,正是主函数中esp指向的位置。
  • 这样通过esp传递参数,实现主子函数交互。

所以当phase_1函数执行pushl 0x1c(%esp)时,压入的值应该是,主函数通过mov %eax, (%esp)压入栈顶的eax寄存器的值。之前在 ”(2)关于我们输入的字符串“ 处分析过,它是我们字符串的地址(十进制)。

我们来验证一下:

在这里插入图片描述

x/xw 0xbfffef50是以十六进制格式显示内存地址0xbfffef50处的一个字[即word,1word=4Byte]的内容)

果然如此。那继续往下分析。

 8048b3f:	e8 42 04 00 00       	call   8048f86 <strings_not_equal>

call是一个汇编指令,用于调用一个函数。8048f86是 strings_not_equal 函数的地址。该地址存放着函数 strings_not_equal 的代码。

call 指令的执行流程:

  1. 将调用函数后的返回地址压入栈中
  2. 跳转到指定的函数入口地址,开始执行被调用函数的代码
  3. 函数执行结束后,返回到原地址继续执行

总之就是调用了一个函数(这个函数留到下面分析,我们先分析完关卡函数),看名字应该是比较字符串相不相等。其实到这里,已经可以猜测是我们输入的字符串要和上面那个欢迎语字符串一样。但是不确定,那接着往下看。

 8048b44:	83 c4 10             	add    $0x10,%esp

有了之前对sub的解释,add就不详细解释了。和sub开辟空间的目的相反,是为了释放栈上的空间。

下面的指令让我们合着看。因为他们是连通的。

介绍一个下面要用的东西:“标志寄存器”,EFLAGS(Extended Flags Register),在这个系统里,它是一个32位的寄存器。EFLAGS寄存器中包含了多个标志位,用于记录指令执行的状态和条件。这些标志位的状态会随着指令的执行而改变,程序可以根据这些标志位的值进行条件分支和判断。

下面将要用到的标志位是:

  • ZF(Zero Flag):零标志,用于记录结果是否为零,为零置为1,反之置0。
  • SF(Sign Flag):符号标志,用于记录结果的正负(准确地说是记录最高位为1or0),规定负数最高位是1,sf置1,正数和零最高位为0,sf置0。
 8048b47:	85 c0                	test   %eax,%eax
 8048b49:	74 05                	je     8048b50 <phase_1+0x1d>
 8048b4b:	e8 2d 05 00 00       	call   804907d <explode_bomb>
 8048b50:	83 c4 0c             	add    $0xc,%esp
 8048b53:	c3                   	ret 

test指令不会修改目标操作数的值,仅更新标志寄存器中的标志位,非常适合用于条件分支和比较操作。它的作用是将目标操作数和源操作数进行按位与(AND)运算,根据运算结果更新 EFLAGS 中的标志位,主要是影响ZF(零标志)和SF(符号标志)。

je指令是Jump if Equal的缩写,它根据零标志位(ZF)的值来执行跳转操作。如果此时ZF标志位的值为1,那么程序会跳转到后面指定的地址继续执行。否则程序会继续顺序执行下一条指令。简单说就是相等就跳转。

ret(return )用于从子程序或中断处理程序返回到调用程序。

所以这个片段连着看就是:

  • 如果test后的zf为0就继续执行下一句call指令,调用“引爆炸弹”函数。
  • 如果test后的zf为1就跳转到地址为8048b50的add指令,这样避开了8048b4b处的“引爆炸弹”函数,然后释放栈上的空间,回到主函数。

显然我们需要test后的zf为1,所以eax结果要为0(0按位与他自己才会是0,1按位与自己为1)。

我们看一下现在的eax,变成1了,说明我输入的字符串“random”是错误的。

这个关卡一函数里没出现过关于eax的改变,所以是在前面strings_not_equal函数内部改的。其实感觉修改的结果和函数名有点微妙暗示了,就像是:

void phase_1(我们输入的字符串){
    if(strings_not_equal(题目给定的字符串,我们输入的字符串)){
		explode_bomb();
	}
}

其实第一关通关字符串已经八九不离十了,就是上面的欢迎语字符串。

但是想百分之一百肯定,也还可以继续下去,分析strings_not_equal函数。

(4)分析strings_not_equal函数

不再赘述它的汇编代码获取方法了,也不再赘述上文出现过的指令了。看到这里了应该会了~

下面再介绍一些即将见面的寄存器:

  • ebx (Extended Base):基址寄存器,可用作内存寻址的基址,也可用于存储数据。

  • edx (Extended Data):数据寄存器,用于存储数据,也常用于 I/O 操作。

  • esi (Extended Source Index):源索引寄存器,通常用于字符串和数组处理,作为源数据的指针寄存器。

  • edi ( Destination Index):目的地索引寄存器,类似于 esi,但通常用于目标数据的指针寄存器。

然后再补充一下即将用到的寄存器知识:

eax是一个 32 位的寄存器。 32 位是由 16 位的高位和 16 位的低位组成,所以 eax可以被看作是两个 16 位寄存器的组合。高位部分是 ah(high),它包含 eax的高 16 位。低位部分是 al (low)。

举个例子,如果 eax 的值是 0x12345678,那么 ah 的值是 0x1234al 的值是 0x5678

分为高位和低位使得在某些场景下,我们可以对数据进行 16 位的操作,这在一些位操作和字节操作中是非常有用的。ebx、ecx 和 edx也是如此。

图源王爽老师的《汇编语言》:

该图以16位寄存器ax为例,eax是Extended ax,即ax的扩大版。

不过先别开始这个函数,我们先想清楚目的。之前我们分析到通关要求走完这个函数之后eax结果是0。首先查看eax的结果在哪被确定,我们倒着看,看到倒数第五行,如果这句指令不会因为跳转语句而被跳过,那么,也要求edx结果是0

 8048fe5:	mov    %edx,%eax

我们查看一下edx的初始值,除了p $寄存器名还可以使用i r 寄存器名指令来查看寄存器的存储信息,不加寄存器名就是一下显示全部的寄存器信息。命令的全拼是 info registers。info是information缩写。

发现edx是1。最开始默认我们会失败。

再大概扫视一下整体(详细指令下面会介绍),可以发现就是一直比较,跳转,赋值:

在这里插入图片描述

现在我们目标明确,知道了什么样是好结局什么样是差结局,知道了流程,再来一步步顺序看代码。

下面的代码删去了机器码,把空间留给注释:

08048f86 <strings_not_equal>:
 8048f86:	push   %edi
 8048f87:	push   %esi
 8048f88:	push   %ebx ;把edi,esi,ebx里存的数据压入栈
 8048f89:	mov    0x10(%esp),%ebx;把esp+16地址处的数据赋给ebx寄存器
 8048f8d:	mov    0x14(%esp),%esi;把esp+20地址处的数据赋给esi寄存器
 8048f91:	push   %ebx;把现在ebx里存的数据再压入栈

这里压入了什么?把什么值赋给了寄存器?老样子,我们不妨来查看一下。

意料之中,esi放的是题目的字符串ebx是我们输入的字符串,果然是这两个字符串比对。

 8048f92:	call   8048f67 <string_length>;调用字符串长度函数
 8048f97:	mov    %eax,%edi;把eax里的数据赋给edi
 8048f99:	mov    %esi,(%esp);把esi里的数据赋给 (%esp) 地址指向的内存位置
 8048f9c:	call   8048f67 <string_length>
 8048fa1:	add    $0x4,%esp;释放四字节空间
 8048fa4:	mov    $0x1,%edx;把edx里存的数据再压入栈

这里还想补充一下:

mov %esi,(%esp)mov %esi,%esp 是有区别的。

  1. mov %esi, (%esp)
    • 这条指令是将 %esi 寄存器中的值移动到 (%esp) 地址指向的内存位置。即将 %esi 的值写入到栈顶所指向的内存位置。是对内存的操作,不会修改 %esp 的值。
  2. mov %esi, %esp
    • 这条指令是将 %esi 寄存器中的值直接复制给 esp 寄存器,也就是将 %esi 的值赋值给栈指针 esp。这个操作会修改栈指针的值,可能会导致栈的状态发生变化。是对寄存器的操作。

走完这些指令,再来看看。试试看能不能摸清楚string_length函数的作用,其实函数名已经明示了,不过还是眼见为实一下。

在这里插入图片描述

就是2个字符串的长度。eax存题目本身的,edi存我们自己的。

再下面的指令就是一些分支跳转语句了。

来,再认识一些要用到的新指令。

cmp 指令(compare):

  1. 执行 目标操作数 减去 源操作数 ,但不保存结果。
  2. 根据减法的结果更新标志寄存器 EFLAGS 中的标志位,包括零标志位(ZF)、符号标志位(SF)、溢出标志位(OF)、进位标志位(CF)等。

jmp 指令(Jump):

  • 无条件地转移到指定的目标地址,从而改变程序的执行流程。jmp 指令通常用于实现循环、条件分支和函数调用等控制流程。
  • 即马上跳。

jne 指令(Jump if Not Equal):

  • 检查标志寄存器 EFLAGS 中的零标志位。如果 ZF 标志位的值为 0,表示前面执行的比较结果不相等,那么程序会跳转到指定地址继续执行。否则,如果为 1,那么程序会继续顺序执行下一条指令。
  • 即不相等就跳。

movzbl指令(Move Zero-Extended Byte Load):

  • Zero Extension (零拓展) 是一种数据扩展操作,常见于计算机体系结构中的数据传送指令。在零拓展中,将一个较小数据类型的值扩展为一个较大数据类型,并在高位补充零。通常,零拓展用于处理无符号整数或字符数据的扩展,确保在进行算术运算或存储数据时,高位都是零。

    • 举个例子:movzbl %al, %eax
    • 假设 al 中的值是 65(ASCII 码对应字符 “A”),即0100 0001
    • 随便设eax里的数值是11111111 11111000 00000000 00000000,那么执行这条指令后
    • eax 的值将变成 00000000 00000000 00000000 01000001,即十六进制值为 0x00000041。高位全用零补充。
  • movzbl主要用于从1Byte(8 bit )大小的源操作数中加载数据,并将其零扩展成一个双字(2 word = 4 Byte= 32bit)大小的目的操作数。或者截取低八位。

    • 如果直接mov %al, %eax会怎么样?
    • 有一定可能会出错。如果eax本来就有数据,高位存在1,就像上面我们举的例子,而 mov 指令是按位传送指令,会将源操作数 al 的低8位复制到目的操作数 eax 的低8位,但是会保持 eax 的其他高位不变
    • eax的值将变成11111111 11111000 00000000 01000001
    • 表示出来就不是al的值,没起到复制的作用。

来分析一下如果继续往下走会怎么样:

 8048fa9:	cmp    %eax,%edi; edi - eax = 6 - 55 != 0   =>   ZF = 1
 8048fab:	jne    8048fe5 <strings_not_equal+0x5f> ; ZF != 0 跳转到8048fe5地址处的指令
 ;         ……………………略去中间代码………………
 8048fe5:	mov    %edx,%eax ;edx赋给eax 此时eax=edx=1 等待着我们的结果就是炸弹爆炸
 8048fe7:	pop    %ebx;
 8048fe8:	pop    %esi
 8048fe9:	pop    %edi;将数据从栈中弹出,释放栈空间。
 8048fea:	ret 

会爆炸。爆炸原因是我们输入的字符串长度和题目自己的不一样。没关系,我们现在是在调试,既然预判会炸,那我们直接修改edi使它和eax相等,使用set指令:

看到它确实被我们修改了。

那接下就应该是:

 8048fa9:	cmp    %eax,%edi; edi - eax = 55 - 55 == 0   =>   ZF = 0
 8048fab:	jne    8048fe5 <strings_not_equal+0x5f> ; ZF == 0 不跳转
 8048fad:	movzbl (%ebx),%eax;来到这一步

确实成功了,再顺便查看一下这些值:

 8048fad:movzbl (%ebx),%eax;把134530016这个十进制地址的低八位赋给eax,此时eax可以代表我们输入的字符串的一部分
 8048fb0:test   %al,%al;eax的低八位按位与自己

在这里插入图片描述

为什么突然开始8位8位地操作?

在汇编语言中,一个字母(字符)通常使用一个字节(8位)的存储空间。一个字节可以存储一个ASCII字符或者其他字符编码的字符。ASCII(American Standard Code for Information Interchange)是一种最常见的字符编码方案,它将每个字符映射到一个唯一的8位二进制数值。因此,每个字母或字符在计算机内部通常使用一个字节来表示。

举例来说,字符 “A” 在ASCII编码中的值是65(十进制),它可以用一个字节(8位二进制)来表示为 01000001。同样地,字符 “a” 在ASCII编码中的值是97(十进制),它也可以用一个字节来表示为 01100001

为什么突然来个test %al,%al?它的目的是什么?

带着这个问题接着往下看:

 8048fb0:	test   %al,%al      ;eax的低八位按位与自己,非零 => ZF=0,
 8048fb2:	je     8048fd2      ;ZF==0  不跳转
 8048fb4:	cmp    (%esi),%al   ;部分我们输入的字符串-题目字符串 !=0  => ZF=0
 8048fb6:	je     8048fbe      ;ZF==0  不跳转
 8048fb8:	jmp    8048fd9 ;直接跳转到8048fd9
 ;         ……………………略去中间代码………………
 8048fd9:	mov    $0x1,%edx    ;edx变成1,是爆炸征兆
 8048fde:	jmp    8048fe5      ;直接跳转到8048fe5
 ;         ……………………略去中间代码………………
 8048fe5:	mov    %edx,%eax    ;edx赋给eax 此时 eax=edx=1 会爆炸
 8048fe7:	pop    %ebx
 8048fe8:	pop    %esi
 8048fe9:	pop    %edi
 8048fea:	ret

又要炸了,这次爆炸的原因是我们输入的字符串的第一个字母和题目的不相等。

其实到这里,停下来想一想,应该能够想到上面问题的答案了。

想不到的话,让我们先假设一下,如果我们输入的字符串和题目一模一样,那这些代码的流程是什么样的?

 8048fb0:	test   %al,%al;
 8048fb2:	je     8048fd2
 8048fb4:	cmp    (%esi),%al;假定一样
 8048fb6:	je     8048fbe ;跳转到下面8048fbe处
 8048fb8:	jmp    8048fd9
 8048fba:	cmp    (%esi),%al
 8048fbc:	jne    8048fe0
 8048fbe:	add    $0x1,%ebx;ebx(我们的字符串)偏移一字节(8位)本来指向字符串开头地址
 8048fc1:	add    $0x1,%esi;esi(题目的的字符串)偏移一字节 本来指向字符串开头地址
 8048fc4:	movzbl (%ebx),%eax;输入的字符串的第2个字母赋给eax
 8048fc7:	test   %al,%al;非零 => ZF=0
 8048fc9:	jne    8048fba;ZF==0  跳转到上面 8048fba处 又开始新的一轮比较和偏移 
 			 ;出口规律是,一旦不一样就跳转到赋1的地方(即爆炸征兆),
 			 ;或者直到8048fc7处的test   %al,%al使ZF=0(即这个过程没触发过不一样跳转)
 			 ;那么就会来到赋0的地方,就能保证炸弹被破解了。
 8048fcb:	mov    $0x0,%edx
 8048fd0:	jmp    8048fe5
 8048fd2:	mov    $0x0,%edx
 8048fd7:	jmp    8048fe5 
 8048fd9:	mov    $0x1,%edx
 8048fde:	jmp    8048fe5
 8048fe0:	mov    $0x1,%edx
 8048fe5:	mov    %edx,%eax
 8048fe7:	pop    %ebx
 8048fe8:	pop    %esi
 8048fe9:	pop    %edi
 8048fea:	ret

在ASCII编码中,字符串结尾的最后一个字符是空字符(Null Character),表示为 '\0'。它的ASCII码值为 0。

test %al,%al的目的就是检验字符串是否比对到结尾了。到这里,一切都清晰了。

这个函数的原理就是逐个字母比较我们输入的字符串和题目给定的字符串。不相等就给eax赋1,相等就赋0。

(5)还原成C代码

不要忘记这是c语言代码反汇编得到的汇编代码。

我们来简单还原一遍:

并非百分百还原,像string_length函数就没还原(那个汇编代码挺短的,一共就十行,原理也差不多,你可以自己去试试看,我已经授之以渔啦),下面就还原一下大致原理。

void phase_1(char *ourStr) {
    char *bombStr = "Hi, username(password), here is a special bomb for you!";
    if (strings_not_equal(bombStr, ourStr)) {
        explode_bomb();
    }
}

int strings_not_equal(char *bombStr, char *ourStr) {
    int flag = 1;
    if (strlen(bombStr) == strlen(ourStr)) {
        while (*ourStr) {
            if (*bombStr == *ourStr) {
                flag = 0;
            } else {
                flag = 1;
                break;
            }
            bombStr++;
            ourStr++;
        }
    } else {
        flag = 1;
    }
    return flag;
}

至此,第一关详细解析完毕。

四、后记

感谢你能够看到这里!

  • 3
    点赞
  • 32
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值