CTF Pwn入门:通过浮点数溢出漏洞逆向学习二进制安全
1. 项目概述从一道题逆向学习Pwn与浮点数很多刚接触CTF Pwn二进制安全利用的朋友面对一堆汇编指令和内存地址常常感到无从下手。传统的学习路径是先系统学习汇编、C语言、操作系统、编译原理再接触漏洞利用这条路固然扎实但周期长容易在枯燥的理论学习中失去兴趣。今天我想分享一种更直接、更“功利”也更有效的学习方法通过直接刷题来逆向学习。我们不再把Pwn看作一个需要完全掌握前置知识才能触碰的“黑盒”而是把它当作一个待解的谜题。每解开一道题你不仅掌握了利用技巧更逆向推导并理解了其背后的计算机原理。这次我们就以ciscn_2019_n_1这道经典的入门题作为靶子它的核心考点是浮点数溢出与IEEE 754编码。这道题完美诠释了“逆向学习”的精髓——你不需要先成为浮点数专家而是通过解题需求主动去探究“为什么这里的浮点数能影响程序流程”从而深刻理解IEEE 754标准在内存中的表示、精度问题以及如何被用于漏洞利用。对于想快速入门Pwn、感受二进制魅力或者对浮点数底层存储好奇的朋友这篇文章将带你手把手走完从分析到利用的全过程并补全所有必要的原理细节和实战技巧。2. 题目环境搭建与初步分析2.1 获取题目文件与配置环境首先我们需要获取ciscn_2019_n_1的题目文件。你可以在各大CTF平台或题目存档库中找到它通常包含一个可执行文件如ciscn_2019_n_1和一个用于远程连接的libc库文件如libc-2.27.so。为了完全复现比赛环境建议使用Docker或配置好对应libc版本的Linux虚拟机。我这里使用Ubuntu 18.04对应glibc 2.27进行演示。将题目文件下载到本地后第一件事是检查文件属性file ciscn_2019_n_1 checksec --fileciscn_2019_n_1file命令会告诉你这是一个32位还是64位的ELFLinux可执行文件。checksec是pwntools工具集里的一个脚本它能快速显示程序的安全保护机制这是我们制定利用策略的基石。典型的输出可能如下Arch: amd64-64-little RELRO: Partial RELRO Stack: No canary found NX: NX enabled PIE: No PIE (0x400000)这个结果非常“友好”Arch: amd64-64-little: 64位程序小端序。这决定了我们构造payload时字节的排列顺序。Stack: No canary found:栈上未开启金丝雀Canary保护。这是最关键的一点意味着我们可以进行栈溢出攻击而不会触发栈破坏检测大大降低了利用难度。NX: NX enabled: 栈不可执行。这意味着我们不能简单地将shellcode放在栈上并跳转执行需要结合其他技术如ROP。PIE: No PIE: 程序本身的内存地址不是随机化的。我们可以直接使用题目中函数如system、puts的固定地址简化了利用过程。RELRO: Partial: 部分重定位只读通常不影响我们当前的利用。注意在实际比赛中checksec的结果是解题的“地图”。没有Canary和PIE往往提示我们优先考虑栈溢出和直接地址跳转。NX开启则引导我们走向ROP返回导向编程或利用已有函数。2.2 静态分析与动态调试初探接下来我们使用反汇编工具进行静态分析。推荐使用IDA Pro或免费开源的Ghidra、radare2。这里以IDA Pro为例。用IDA打开文件后首先查看main函数和程序中有趣的函数通过Strings窗口或Functions窗口。快速浏览main函数其逻辑通常比较清晰可能有一些puts打印提示信息调用scanf或gets进行输入。我们的目标是找到那个存在漏洞的输入点。同时留意程序中是否存在诸如system、execve或/bin/sh字符串这些是获取shell控制权的关键。在静态分析的同时一定要结合动态调试。使用gdb配合pwndbg或gef插件体验更佳运行程序。在疑似存在溢出的函数如main或某个子函数的返回指令ret处下断点然后单步执行观察栈的变化和寄存器的值。动态调试能让你直观地看到输入如何影响内存这是静态分析无法替代的。对于这道题经过初步分析你可能会发现程序的核心逻辑是要求输入一个浮点数然后与一个预设的浮点常数进行比较。如果相等则跳转到某个函数比如一个能打印flag的函数否则程序退出。漏洞点就在于这个“输入浮点数”的过程。3. 核心漏洞原理浮点数、IEEE 754与溢出3.1 IEEE 754标准浮点数在内存中的“身份证”要理解漏洞必须先理解浮点数在计算机中是如何表示的。计算机使用IEEE 754标准来存储浮点数单精度float双精度double。我们以单精度float32位为例它被分为三个部分符号位Sign1位0表示正数1表示负数。指数位Exponent8位。这里存储的是偏移指数Biased Exponent。实际指数 存储的指数 - 127这个127就是偏移量bias。尾数位Fraction/Mantissa23位。存储的是小数部分隐含了一个开头的1对于规格化数。所以实际尾数 1.尾数。一个float数字的值 (-1)^符号位 * 1.尾数 * 2^(指数-127)。为什么这很重要因为程序在比较float时并不是比较我们人类写的“11.28125”而是比较底层这32位二进制序列。两个数学上相等的浮点数其内存表示一定相同。反之如果我们能控制输入使得其内存表示与目标值相同就能通过检查。3.2 从“浮点数输入”到“栈溢出”那么浮点数输入怎么会导致栈溢出呢关键在于scanf或gets这类函数的行为。看下面这段简化的伪代码void vuln_function() { char buf[40]; // 栈上分配40字节的缓冲区 double var; // 栈上分配一个double变量8字节 gets(buf); // 漏洞点无限制读入可以覆盖掉后面的var if (var 11.28125) { system(/bin/sh); } }gets(buf)是一个危险函数它不检查输入长度会一直读入直到遇到换行符。buf只有40字节但如果我们输入超过40字节多出来的数据就会覆盖栈上紧接着buf的高地址区域的内容。而var这个double变量很可能就紧挨着buf存放在栈上。所以攻击路径就清晰了我们通过gets(buf)输入一个超长的字符串。这个字符串的前40字节填满buf。从第41字节开始数据开始覆盖var在内存中的空间。var在内存中是一个8字节的double。如果我们精心构造这8个字节使其内存表示正好等于11.28125的IEEE 754双精度表示那么if条件判断就会为真从而执行system(“/bin/sh”)。这还没完如果继续溢出还能覆盖掉vuln_function的返回地址保存在栈上位于var更高地址处从而控制程序执行流跳转到我们想要的地址比如system函数。这就是“浮点数溢出”的本质它不是一个单独的漏洞类型而是栈溢出漏洞的一种表现形式。溢出的对象恰好是一个浮点型变量我们需要用特定的字节序列即目标浮点数的IEEE 754编码去覆盖它从而通过条件检查。3.3 目标浮点数的字节序列计算我们需要计算出11.28125的IEEE 754双精度64位内存表示。手动计算比较繁琐但我们可以用Python轻松完成import struct # 将浮点数打包成字节序列使用小端序‘‘ target_bytes struct.pack(‘d‘, 11.28125) print(target_bytes.hex()) # 输出可能是0000000000002640struct.pack(‘d‘, value)函数将value按照双精度浮点数格式以小端序打包成字节串。小端序意味着低位字节在前。所以11.28125的8字节内存表示十六进制就是00 00 00 00 00 00 26 40。在覆盖var时我们需要将这8个字节按顺序考虑到栈的生长方向和变量的布局填入正确的位置。实操心得在实战中你不需要每次都手动计算。动态调试时可以直接在gdb中查看目标变量的内存值。例如在比较语句处下断点用x /gx var命令查看var的地址处存储的8字节值那就是你需要覆盖成的目标。这比计算更直接可靠。4. 完整漏洞利用脚本编写与调试4.1 利用思路与Payload构造基于以上分析我们的利用分为两个层次由易到难层次一仅覆盖浮点变量通过检查这是最直接的目标。我们只需要溢出并精确覆盖var为11.28125。Payload b‘A‘*40 p64(0x4026000000000000)。这里b‘A‘*40是填充buf的垃圾字符p64()是pwntools中用来将整数打包为64位小端序字节串的函数0x4026000000000000是11.28125的另一种表示需要确认字节顺序。更稳妥的做法是使用struct.pack得到的字节串。层次二覆盖返回地址控制程序流如果程序在通过检查后只是打印一条信息然后正常退出我们就需要更进一步控制程序跳转到能获取shell的代码例如system(“/bin/sh”)或一个后门函数。这需要继续溢出覆盖vuln_function的返回地址。确定偏移量我们需要知道从buf起始位置到返回地址存储位置的精确字节数。这可以通过动态调试或模式字符串工具如cyclic来确定。寻找跳转地址在静态分析中我们已经找到了system函数和/bin/sh字符串的地址假设程序本身有。或者如果程序提供了win函数或get_flag函数我们直接跳转到那里。构造ROP链如果需要由于NX保护我们不能执行栈上的代码。如果程序没有直接调用system(“/bin/sh”)的代码片段我们就需要构造一个ROP链来设置参数并调用system。对于这道入门题很可能存在一个现成的后门函数。Payload b‘A‘*offset_to_var p64(target_float) b‘A‘*padding p64(return_address)。4.2 使用Pwntools编写Exploitpwntools是CTF Pwn题的利器。下面是一个可能的利用脚本框架from pwn import * import struct context(os‘linux‘, arch‘amd64‘, log_level‘debug‘) # 本地调试 p process(‘./ciscn_2019_n_1‘) # 远程连接 # p remote(‘node4.buuoj.cn‘, 12345) # 计算目标浮点数的字节表示 target_float 11.28125 float_bytes struct.pack(‘d‘, target_float) # 小端序 # 方法一仅覆盖浮点数变量 offset_to_var 40 # 通过调试确定假设buf到var的偏移是40 payload1 b‘A‘ * offset_to_var float_bytes # 方法二覆盖浮点数后继续覆盖返回地址假设有后门函数 win_addr 0x400596 # 假设通过IDA找到的后门函数地址 offset_to_ret 56 # 假设从buf起始到返回地址的偏移是56 # 覆盖var需要8字节从var结束到返回地址开始可能还有填充比如局部变量对齐 padding offset_to_ret - offset_to_var - 8 payload2 b‘A‘ * offset_to_var float_bytes b‘B‘ * padding p64(win_addr) # 发送payload p.sendline(payload2) p.interactive() # 交互模式拿到shell后可以执行命令4.3 动态调试与偏移量确认编写脚本后一定要在调试模式下运行确保每一步都符合预期。在gdb中运行程序gdb ./ciscn_2019_n_1。在gets或输入函数之后、比较语句之前下断点b *0x400xxx。运行程序并在脚本中启用调试p gdb.debug(‘./ciscn_2019_n_1‘)。当程序在断点处停下时检查栈内存x/40gx $rsp查看栈顶区域。找到buf的起始地址和var的地址计算偏移。确认我们发送的payload是否准确覆盖了var的位置。单步执行观察程序是否成功跳转到目标分支或函数。确定偏移量的技巧使用cyclic工具。在pwntools中cyclic(100)生成一个100字节的、带有唯一4字节模式如aaabacad...的字符串。发送这个字符串让程序崩溃记录下崩溃时RIP指令指针寄存器的值。然后用cyclic -l RIP值就能反推出偏移量。这是确定缓冲区到返回地址距离的最可靠方法。5. 常见问题排查与进阶思考5.1 实战中可能遇到的坑字节序问题x86/x64架构都是小端序Little Endian低位字节在低地址。所以我们在构造float_bytes时使用了‘d‘小端。如果你从内存中直接拷贝出来的十六进制值是40 26 00 00 00 00 00 00那么在payload中应该写成\x00\x00\x00\x00\x00\x00\x26\x40。顺序反了会导致数值完全不对。偏移量计算错误这是最常见的问题。buf可能不是紧挨着var中间可能有编译器插入的填充Padding用于内存对齐。一定要用cyclic模式字符串配合gdb精确计算而不是靠猜。浮点数精度问题我们使用struct.pack得到的是精确的内存表示。但如果你尝试用scanf(“%lf”, var)输入11.28125由于十进制到二进制的转换可能有细微误差可能导致内存表示与直接打包的字节有最低位的差异从而使比较失败。因此在浮点数相关的漏洞利用中直接覆盖其内存字节是最可靠的方式而不是试图通过输入一个“相等”的浮点数。libc版本差异如果题目提供了远程libc本地利用成功但远程失败很可能是libc版本不同导致函数地址偏移不一样。需要根据题目提供的libc计算其与本地libc的基地址偏移并调整system等函数的地址。5.2 从这道题延伸的Pwn学习路径ciscn_2019_n_1是一道优秀的入门题它串联了多个基础知识点栈溢出原理理解缓冲区、返回地址在栈上的布局。基础ROP如果需要调用system就涉及到了简单的ROP链构造虽然本题可能不需要完整的链。数据类型内存表示深刻理解整数、浮点数在内存中的形式这是许多高级漏洞如类型混淆的基础。动态调试技能使用gdb/pwndbg查看内存、寄存器下断点单步执行。解完这道题你的学习路径可以这样展开巩固栈溢出尝试更多只有栈溢出、没有Canary和PIE的题目熟练使用cyclic计算偏移掌握覆盖返回地址跳转到后门函数。引入Canary学习如何泄露或绕过栈金丝雀。引入PIE学习如何利用信息泄露如通过puts打印某个地址来计算出程序基址从而得到函数真实地址。学习ROP当没有现成的system(“/bin/sh”)时学习如何组合程序中的代码片段gadgets来设置参数并调用系统函数。学习堆利用这是Pwn中更复杂但也更精彩的部分。5.3 关于“逆向学习”方法的个人体会最后谈谈我推崇的“通过刷题逆向学习”这种方法。它最大的优势是目标驱动和即时反馈。你不是被动地接受“浮点数有32位1位符号位...”而是带着“我怎么才能让这个if条件成立”的问题主动去搜索、学习IEEE 754。这个过程学到的知识因为有了具体的应用场景和成功实践作为锚点会记得特别牢。每解一道题就像在知识地图上点亮一个区域并且你知道这个区域为什么重要、如何与其他区域连接。当然这并不意味着完全抛弃系统学习。当你在刷题中反复遇到某个概念比如“小端序”感到它成为了一种基础障碍时就是你应该停下来找一本《深入理解计算机系统》相关章节深入阅读的时候。这种“实践-理论-再实践”的循环对于攻克像Pwn这样实践性极强的领域效率远高于单纯的理论学习。ciscn_2019_n_1就是一个完美的起点它用一个小巧的漏洞引导你窥见了二进制安全世界的运行规则与攻防魅力。