记录关于CSAPP 二进制炸弹实验过程
(CSAPP配套教学网站Bomb Lab自学版本,实验地址:http://csapp.cs.cmu.edu/2e/labs.html)
(个人体验:对x86汇编寻址模式要有清晰的了解,如mov指令涉及的是计算出的地址所指向的存储单元的值,而lea指令保留的是计算出来的地址,数字是否加$表示常数的问题等;
实验中涉及的跳表的存储方式、链表的处理等是C语言的汇编语言实现方式,处理起来较为复杂,但可对这些方式的对象底层实现方式有一个较为清晰的了解;
涉及指针操作时,尤其需要注意哪些指令是用于计算地址,哪些指令是使用某个地址所对应的空间的值;
虽然自学版本不涉及打分与扣分,但仍需要较大的耐心和精力去完成,have fun~)
1.实验准备
阅读随实验准备的Writeup和README文件,对实验的内容有大致的了解。
README:二进制炸弹是一个包含有6个“炸弹”的linux环境中可运行的C程序,实验过程中需要借助调试工具和技巧获得目标字符串,从而拆除炸弹。该文件也介绍了如何将实验动态部署从而获得打分等功能的过程,这里不做讨论;
Writeup:提示了一些可能应用的到的工具。也提供了关于实验的一些有效信息。
(1)实验使用两种方式读取输入,直接在运行过程中读取输入或是从指定文件中读取输入(每个字符串占据一行),后者格式为./bomb file.txt(./ 表示在当前目录下,bomb为可执行文件, file.txt为记录目标字符串的文件);
(2)可以帮助实验的工具如gdb,linux中的指令objdump -t(输出程序符号表)/-d(输出程序的反汇编),strings(输出程序中的可输出字符串)。在使用上述工具的一个重要发现在于除了明确提出的6个字符串处理函数(phase1-6)外,还存在另外的两个名为fun7和secert_phase的函数,猜想可能有额外的字符串需要进行破解;
另外,简单的使用objdump -d bomb命令对程序查看,可以知道主干部分(main)主要是进行初始化操作、读取方式选择、对目标字符的处理和判断这几个部分,与字符串读取相关的函数有read_line等, 字符串读取后调用单独的函数进行验证,故实验过程可以直接汇聚精力在字符串处理的函数部分,其它逻辑可依自己的兴趣查看。
2.实验过程
程序对字符串的处理主要流程如下图所示
调用read_line函数读取字符串,将%eax中值入栈(注意这里的%eax是在函数调用之后的值,所以存储的是read_line的返回值,具体过程参见read_line函数定义),这里的入栈是函数调用传入参数的过程,之后通过特定的函数对字符串进行验证。
- phase_1
将函数参数存放在%eax中,并将%eax和0x80497c0入栈,调用strings_not_equal函数,猜想0x80497c0为目标字符串存放的地址,使用gdb进行断点调试,得到的结果如下图
从而得到了第一个字符串。
- phase_2
分析主要集中在phase_2调用的函数上面。
read_six_number:将六个连续栈上空间地址入栈,从低地址到高地址为%ebp-24至%ebp-4,这里的%ebp是对应phase_2函数的%ebp,而不是read_six_number的%ebp,后者在函数被调用时更新。之后将0x8049b1b和输入字符串的地址依次入栈,调用sscanf函数;
sscanf:用于格式化字符串的读取,典型的参数形式为——待读取字符串的地址,读取字符串的格式,存储读取内容的地址。按照函数入栈时从右自左的顺序,猜想程序将输入字符串的地址作为读取的源地址,0x8049b1b存储的应为读取字符串的格式,其余的六个地址应为存储读取出来的数据的地址。输出0x8049b1b处的字符串,结果如下,验证了猜想。
read_six_number是将输入的字符串(6个数字)读取到栈的相应位置,准确的说是按输入顺序存放在%ebp-24至%ebp-4之间连续的6个存储单元中(注意函数参数的入栈顺序,且%ebp相对于phase_2函数而言),再进行后续的验证工作。
phase_2验证部分:
检验第一个数的值是否为1
前三条语句使%ebx值为1,%esi值为%ebp-24,%eax值为2,imul指令使得%eax为2,其中一个乘法因子地址为%ebp-24+1*4-4即为%ebp-24,指向的值为前面验证过的1
之后为一个循环语句,%ebx值自增1,当%ebx不大于5时,重复上述过程,即
%ebx=%ebx+1;
%eax=%ebx+1,
%eax=%eax*前一个验证过的数字的值,将%eax与当前待验证的值相比较
故第一个值为1,第二个值应为(1+1)*1=2,第三个值为(2+1)*2=6,第四个值为(3+1)*6=24,第五个值为(4+1)*24=120,第六个值为(5+1)*120=720.
- phase_3
phase_3也调用了sscanf函数,其参数按高地址到低地址为%ebp-4,%ebp-5,%ebp-12(相对于phase_3函数的%ebp),0x80497de,%edx(存放从输入读取的字符串)
类似于phase_2中sscanf的用法,此时读取格式如图所示,在读取数字和字符的基础上,对相应字符串进行验证。
需要注意的是函数的参数是按从左至右的顺序入栈的,所以对于输入的字符串,%ebp-4存储的是输入的第三个数字,%ebp-5存储字符,%ebp-12存储的是输入的第一个数字,即需要注意顺序。
对后续验证过程进行分析,首先对第一个数字(%ebp-12处)进行检验,验证其值是否大于7,并根据其值进行跳转,在这一步暂时无法发现具体的值是多少,继续向后看。
后续存在多处类似图中所示的处理,即对%bl进行操作,再将第三个数字(%ebp-4处)检验,最后跳转至0x8048c8f处,并在该处对中间的字符(%ebp-5)进行验证
猜想应该有多种可能的值相对应可以选择,使得对应的一组字符串相匹配。(注,后来发现,形如jmp *xx(,%eax,4)的写法是switch语句中跳表所用的结构,为间接跳转)
左图为0x80497e8处的跳转地址表的具体值.
这里求解使用的是case 0的情况,即%eax=0,从而有%bl=0x71,%ebp-4=0x309,%ebp-5=0x71,得到指定字符串为0 q 777.
- phase_4
同样调用sscanf函数,传入参数为%ebp-4,0x8049808,%edx,即读取一个整数。
首先将读取的值与0比较,若小于0则炸弹爆炸,再将读取的这个值作为参数,调用函数func4.
且只有函数返回值为0x37时,才能解开phase_3.
func4的处理逻辑描述:
func4应为递归函数的形式。产生的返回值随k的增加为斐波那契数列。
int func4(int k)
{
if(k<=1)
return 1;
return func4(k-1)+func4(k-2);
}
由上述分析可知,func4返回值为0x37(即55)的输入为9.
- phase_5
phase_5首先调用string_length函数得到输入字符串的值,并与6比较(再次提醒,在函数调用之后出现的%eax很可能存储函数的返回值,假如返回值存在的话),相等时继续进行判定。
下图进行一些赋值操作,令%edx=0(异或),%ecx=%ebp-8,%esi=0x804b220。
接下来是一个循环语句,进行字符串的验证操作。寻址模式(%edx,%ebx,1)表示计算%edx+%ebx*1,并将结果作为地址访问对应的存储单元的值。(%ebx存储的是指向输入字符串第一个字符的地址)
处理的逻辑是:
(1)将第n个字符存放在%al中(n=0,1,2,3,4,5),并截取低4位(and操作)符号拓展存放到%eax中
(2)将%eax作为偏移量,将0x804b220(即%esi)+%eax所指向的存储单元的值存放在%al中
(3)将上述处理后的值存放到%ebp-8+n处
之后将0x804980b和%ebp-8入栈,并比较两者是否相等,则0x804980b处存放的应该为处理后的字符串。
具体的情况如下
经处理后的字符应为giants,对应的字符分别为0x67,0x69,0x61,0x6e,0x74,0x73
图示为0x804b220起始的连续16个字符序列
则前文中对应的偏移量为0xf,0x0,0x5,0xb,0xd,0x1.则输入字符串保证低四位与对应的偏移量相同即可。
这里选用的是0x6f,0x70,0x65,0x6b,0x6d,0x61,即为opekma(答案不唯一)。
- phase_6(接触时感觉处理有点复杂,涉及多重循环,后来经人提醒,处理过程还涉及链表操作)
首先进行赋值操作,%edx=%ebp+8(即输入字符串起始地址,也是phase_6传入的参数)处存储的值,%eax=%ebp-24,并将%eax与%edx入栈,调用read_six_numbers函数,其功能前面已有介绍。
再对读取出来的数字进行相应的处理,后续为一个较大的循环过程,这里直接描述其逻辑处理过程,不再截图。
(1)%eax=%ebp-24,%eax=%eax+%edi*4处存储的值(%edi初始值为0)
(2)%eax--,并将其值与5比较,当%eax-1的值不大于5时,继续进行验证
(3)%ebx=%edi+1,若%ebx>5,则跳至(7)
(4)%eax=%edi*4,并将%eax种的值存放在%ebp-0x38处,令%esi=%ebp-24
(5)%edx=%ebp-0x38处存储的值(这里注意有连续的两条指令一个是lea,另一个是mov),令%eax=%edx+%esi*1处存储的值,并与%esi+%ebx*4处存储的值相比较
(6)两者不相等时,将%ebx++,当%ebx不大于5时,跳至(5),否则顺序执行
(7)%edi++,若%edi不大于5,则跳至(1)
上述循环完成的功能是:将每个数读取出来,并验证其不大于6,同时,保证任意两个数字不相等。
之后是一个类似于上述描述的循环:
(1)%ecx=%ebp-24,%eax=%ebp-48,并将%eax的值存放在%ebp-60处
(2)%esi=%ebp-52处存储的值,%ebx=1,%eax=4*%edi(%edi初值为0),%edx=%eax
(3)比较%ebx和%eax+%ecx处存储的值,若前者大于等于后者,则跳转至(6),否者顺序执行
(4)将%edx+%ecx处存储的值赋值给%eax,%esi=%ebp-44,
(5)%ebx++,若%ebx<%eax,则跳转至(4)
(6)将%ebp-52处存储的值赋值给%edx,%esi的值赋值给%edx+4*%edi处的存储单元,%edi++,若%edi小于等于5,则跳转至(2)执行,否则顺序执行
(在这段程序中,出现了%eiz寄存器,最终在Stack Overflow中找到了对应的解释,原答案。自己对最高票回答的理解,即在指令的执行过程中,为了保证指令的正常执行如流水线操作等,可能需要加入必要的延时来避免竞争,一般是可以在两条指令中间插入合适数量的nop保证正常运行,但是处理器处理一条长指令比对应的多条短指令如nop要更有效率,所以有时会在程序中插入这种奇怪的lea指令,占据7个字节,作为替代,比执行7条nop指令要更为快,同时也保证程序正常执行。这里%eiz为一个值为0的伪寄存器,通过lea 0x0(%esi,%eiz,1),%esi这种指令达到类似于nop指令的效果。)
乍一看感觉一点头绪都没有...
经他人提示,程序处理中涉及链表。猜想下列程序过程涉及的是链表操作(因为%esi中存储的总是地址,且可以通过%esi+8访问新的地址),%esi初始值(即%ebp-52处的值)为链表的头节点地址,%esi+8为该节点的指针域,存放的是下一个节点的地址,理论上是可以成立的。
按照上述猜想,上述循环的功能为根据输入的每个数字的大小,将每个数字对应的节点(1或小于1对应头节点,6对应第6个节点,前面已经证明输入数字不大于6,且两两不相等)的地址按顺序存放在%ebp-48至%ebp-28的存储空间。
相应的,以下处理将每个节点的指针域按照上述排列的顺序所存储的地址做出了修改。每次取存放在%ebp-48+(i-1)*4处的地址所对应的节点,将其指针域修改为%ebp-48+i*4处的地址。
紧接着上述逻辑之后的代码,功能似乎是将尾节点的指针域赋值为NULL,使得上述猜想更有说服力。
按照上述猜想,下列代码的功能为:
(1)将%esi赋值为头节点地址,%edi=0;
(2)%edx=节点指针域的值,则(%esi)表示节点数据域的值,(%edx)为后继节点数据域的值(数据域应为双字四字节即一个寄存器的大小)
(3)将当前节点数据域与后继节点数据域比较,当前者大于等于后者时继续进行后续节点的比较,否则炸弹爆炸
上述代码的功能为:检验经过重新排序的链表应为单调递减链表。
此时对原始链表的情况进行分析,原始链表的起始地址存放在%ebp-52处(未经过排序操作前)。
gdb可以直接输出链表节点中存储的值,再由上述分析链表应重新排列为递减链表。
所以输入的序列值可以为4 2 6 3 1 5
以上即为所有6个字符串炸弹的解答过程,主线任务已经完成...
然而,从实验准备阶段我们已经知道存在secert_phase的支线任务...
- secert_phase
从反汇编的结果我们可以看出存在一个函数secert_phase,而在前面的所有过程中包括phase1-6和main函数中都没有包含对其的调用。在main函数所调用的各种函数中,只有initialize_bomb、read_line、phase_defused函数是自定的函数,查看这几个函数的定义,发现secert_phase函数在phase_defused中被调用。
对phase_defused的处理方式如下图所示,main函数调用phase_i进行验证(如不满足条件,调用bomb函数),之后马上调用phase_defused,之前以为其功能为拆除炸弹后进行相应的交互提醒,后来发现这一功能由后面的printf实现,如图示0x80496e0处即存储一个提示字符串。
对phase_defused进行分析:
将0x804b480处的值与6比较,从反汇编中可以看出该处存放的应该为输入的字符串的个数,若输入字符串不为6,则直接跳至phase_defused的末尾,即secert_phase需要在前六个字符串解除后完成。。
之后调用了sscanf函数,其中各个参数的值在gdb中可以显示出来
第一个参数指明了读取的格式,而第二个参数,笔者的第一反应是前面phase4的输入9.
即在phase_defused函数除了读取输入9外,还可以读入额外加入的字符串,并对字符串进行处理。没有字符串时,sscanf返回值不为2,会直接跳至phase_defused函数末尾,不影响主干部分执行。
之后,phase_defused函数对读取的字符串进行了比较,用来比较的是0x8049d09处存储的字符,如下图所示。当比较相匹配时,就会调用secert_phase函数。
对secert_phase函数解析:
首先调用__strtol_internal函数,原型为long int __strtol_internal(const char *__nptr, char **__endptr, int __base, int __group);最后一个参数为0时,功能与strtol相同。
其中,第一个参数为目标字符串,中间参数可用作错误返回值(题中卫NULL),第三个参数为采用的进制base(指第一参数采用的进制)。返回值为目标字符串对应的整数值。题中的%eax为read_line函数的返回值,即为输入的字符串的起始地址。设函数的返回值为n。这里输入数字(以字符串表示)采用的是0xa即十进制。
令%ebx=n,%eax=n-1,首先应满足无符号比较中%eax≤0x3e8。
之后调用fun7函数,只有fun7返回值为7时,才能避免炸弹爆炸。这里传入的参数为n和常数0x804b320.
fun7为一个递归函数,处理过程如下:
int fun7(int *p,int n)
{
if(!p)
return 0;
if(n<*p)
return 2*fun7(*(p+4),n);
else
{
if(n==*p)
return 0;
else
return 2*fun7(*(p+8),n)+1;
}
}
在0x804b320处存储的值的情况如下图:
对fun7返回值为7进行猜想,可能为7=2*3+1,3=2*1+1,1=2*0+1的情况。
故第一层:p=0x804b320,n>*p(即36),*(p+8)=0x804b308,函数返回值为2*3+1=7,
第二层:p=0x804b308,n>*p(即50),*(p+8)=0x804b2d8,函数返回值为2*1+1=3,
第三层:p=0x804b2d8,n>*p(即107),*(p+8)=0x804b278,函数返回值为2*0+1=1
第四层:p=0x804b278,n=*p(即为0x3e9,前面已限定n≤0x3e9)时,函数返回值为0。
从而使得递归最终的返回值为7,此时,n=0x3e9的十进制表示,而输入字符串为1001时,会由__strtol_internal转化为10进制数。
所以输入为1001.
3.实验结果
得到最终的结果: