Pwnable collision 题目详解:指针强转、小端序与payload构造

📅 发布时间:2026/10/8 15:45:28
Pwnable collision 题目详解:指针强转、小端序与payload构造
刷 Pwnable 题库时有一类题目标题看起来特别装但点开源码你只想笑。collision 就是这样一道题名字叫碰撞一听就让人联想到 MD5、SHA-1 那类密码学概念结果整个核心逻辑不到 40 行。真正的主流程就是把一个 20 字节的字符串参数强行当成 5 个int去相加等这个和等于一个固定魔数0x21DD09EC程序就会执行system(/bin/sh)把 shell 直接送到你手里。对准备入坑 CTF 和二进制安全的新手来说这道题是绝佳的热身动作。它不涉及堆溢出、格式化字符串那些高深技巧考的是三件最基础也最容易忽略的事看懂 C 语言里指针和内存的解释方式、搞明白大小端字节序、以及知道怎么把一个二进制 payload 干净地传给一个程序。这篇文章会把完整推导、实操步骤、我踩过的坑都写出来照着走一遍你也能打通这道题而且能收获一堆后续用得上内功。1. 初见 collision一道名字很大、代码很小的题目1.1 拿到题目时的第一印象我在 Pwnable 练习平台翻题目列表时是按名称挑的。collision 这个名字很容易让人以为是密码学方向的题甚至猜测要处理哈希函数、冲突概率、碰撞攻击之类的内容。下载附件之后发现它给了一个可执行文件和一份短得可怜的源码。我当时第一反应是这是不是发错分类了怎么连gets、strcpy这种经典的漏洞函数都没出现这能算 Pwn 题吗实际点开源码之后才明白它被归类在 Pwnable 里是有道理的目标不是破解一个哈希算法而是构造一段输入让程序执行预设的危险命令system(/bin/sh)。虽然代码里没有内存破坏漏洞但存在一个认证逻辑可被绕过的弱点。在真实安全评估中这种验证逻辑设计不当导致被绕过的场景太常见了因此它值得认真对待。下面是我整理并注释过的核心源码#include stdio.h #include string.h unsigned long hashcode 0x21DD09EC; unsigned long check_password(const char *p) { int *ip (int *)p; int i; int res 0; for (i 0; i 5; i) { res ip[i]; } return res; } int main(int argc, char *argv[]) { if (argc 2) { printf(usage : %s [passcode]\n, argv[0]); return 0; } if (strlen(argv[1]) ! 20) { printf(passcode length should be 20 bytes\n); return 0; } if (hashcode check_password(argv[1])) { system(/bin/sh); return 0; } else { printf(wrong passcode\n); } }1.2 逐行读源码真正的检查只有两个这道题没有复杂的结构我把每个关键点都拆开来解释一遍。hashcode是一个全局变量固定为0x21DD09EC。这个名字起得很误导人它并不是程序里算出来的哈希值而是一个预先设定的常量你可以把它理解为期望口令的校验值。check_password是核心函数。它接收一个const char *p字符串指针然后强转成int *ip。这个动作是整道题的关键。在常见的 32 位和 64 位 Linux 环境里int类型都是 4 字节。一个 20 字节的字符串被当成int数组后正好被切成 5 个元素。循环 5 次把ip[0]到ip[4]累加进res最后把累加结果返回。main里的检查只有两个。第一个是参数个数程序必须收到一个命令行参数也就是我们构造的 passcode。第二个是strlen(argv[1]) ! 20要求参数长度恰好 20 字节。这个长度检查并不是随便写的它保证了后续for循环读取 5 个int时不会越界同时也暗示了唯一合法的输入就是 20 字节的原始字节流。当累加结果正好等于hashcode时程序就执行system(/bin/sh)。在 CTF 题目环境中拿到 shell 之后通常就能读取服务器上的 flag 文件。整个流程看起来像是一个口令校验程序但它要防的不是暴力破解而是有人根据已知输出值反推出合法输入。这正好就是碰撞这个名字背后真正的考点。2. 为什么一个求和函数也能叫碰撞2.1 check_password 到底在算什么很多新手在读check_password时会被指针强转搞晕。我用最直白的方式讲一遍。C 语言里char和int是两种不同的内存视图。char *p的意思是从 p 这个地址开始按照一个字节一个字节地去解释数据。而int *ip (int *)p的意思是我现在不按字节看了我从同样的地址开始按照 4 个字节一组去解释数据。举例来说如果内存里连续放着 20 个字节01 01 01 01 01 01 01 01 01 01 01 01 01 01 01 01 e8 05 d9 1d按char*看它就是 20 个独立的字节。按int*看它被切成 5 组每组 4 字节[01 01 01 01] [01 01 01 01] [01 01 01 01] [01 01 01 01] [e8 05 d9 1d]而每一组 4 字节在内存中具体被解释成多大的整数取决于系统的字节序。在一个小端序的 x86/x64 机器上第一组01 01 01 01会被解释成整数0x01010101最后一组e8 05 d9 1d会被解释成0x1dd905e8。这些数值加在一起就构成整个 20 字节输入所对应的校验值。这里还有一个容易被忽略的 C 语言细节res是int类型而函数返回值是unsigned long。在累加过程中如果每个int都是正数且累加结果不超过INT_MAX那就一切正常。可一旦你选择用负数参与累加int在变成unsigned long时会发生符号扩展高 32 位会被填满0xffffffff最终结果会和你预期的值完全不同。这也是为什么解题时几乎所有人都优先选择像0x01010101这样的正数作为拆分块。2.2 输出空间有限、映射又是线性的碰撞自然很容易密码学里的哈希碰撞是指两个不同的输入经过哈希函数后得到同一个输出。这道题里的check_password严格说不是密码学哈希但它也满足多输入到同一输出的碰撞条件因为int数组求和是一个有大量碰撞的映射。从数学角度看check_password的输入空间是 20 字节也就是2^160种可能性输出呢累加结果的本质是一个 32 位整数最多只有2^32种不同的值。输入空间远远大于输出空间根据抽屉原理必然存在海量输入对应同一个输出。换句话说每一组20 字节都能映射到某个 32 位整数结果上但每个结果下面挂着数不清的不同输入。更致命的是这个映射是线性的、可逆的。密码学哈希函数讲究雪崩效应输入变一个比特输出几乎每一位都变而这里的累加函数是线性相加我可以先确定想要的输出值然后反推出一组相加能得到它的输入根本不需要穷举或碰撞搜索。这种攻击在密码学里其实不叫碰撞更准确地说是原像构造——知道目标输出直接构造输入。理解到这一层你就知道为什么源码作者把它命名为 collision 了它把哈希碰撞这个原本高大上的概念简化成了一个朴素的漏洞挑战者需要把一个已知数字拆成 5 个合法的 4 字节整数让它们的和恰好等于0x21DD09EC。接下来就是纯粹的算术和字节操作。3. 完整解题实操从拆数字到拿到 shell3.1 第一步把魔数拆成五个整数我们的目标非常明确找到 5 个 32 位整数它们的和等于0x21DD09EC。最简单优雅的拆法是让前 4 个数都取相同的基准值0x01010101只把剩下不够的那个数放在最后。这样结构清晰而且后面你会看到这个基准值的选择还避免了字节流中出现0x00截断的问题。计算过程如下0x21DD09EC - 4 * 0x01010101 0x21DD09EC - 0x04040404 0x1DD905E8于是 5 个整数就是0x01010101 0x01010101 0x01010101 0x01010101 0x1DD905E8你可以自己在纸上验算一遍。0x01010101的十进制是16843009前四项之和是67372036最后一项0x1DD905E8的十进制是500762088累计正好是568134124也就是0x21DD09EC。整个拆分过程只需要小学算术水平但它背后反映的是一个重要思路遇到线性校验逻辑时先不要急着爆破把公式列出来想想可不可以直接反解。3.2 第二步把整数翻译成字节序列小端序是关键得到 5 个整数之后还不能直接拿去用得把它们变成程序运行时内存里真正存在的 20 个字节。这里是最容易翻车的地方int在内存中的排列顺序是小端序也就是低有效字节存在低地址。对于0x01010101这种四个字节完全一样的数字正着反着都无所谓所以前 16 个字节都是\x01\x01\x01\x01没有任何争议。但最后一个整数0x1DD905E8就不一样了。把它拆成字节高位到低位是1D D9 05 E8在小端序的机器上存进内存的顺序要完全反过来变成E8 05 D9 1D如果你把\x1d\xd9\x05\xe8传给程序程序会把它当成0x1DD905E8吗不会。在小端机器上内存中1D D9 05 E8按int解释的实际值是0xE805D91D根本不是我们想要的数字结果就是wrong passcode。这是我当时实际踩过的坑后面会专门讲。所以最终完整的 20 字节 payload 是\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\xe8\x05\xd9\x1d这也是这道题标准的答案形式。只要按这个顺序把 20 个字节作为参数传给程序累加结果就一定是0x21DD09EC。3.3 第三步把 payload 送进程序里并拿到 shell把 payload 构造出来之后怎么塞给程序也是一门学问。因为 payload 里包含\xe8、\xd9这样的非可见字符直接用键盘是敲不出来的必须借助命令行的转义机制。这里我推荐几种我亲测可用的方式。第一种使用 bash 的$...语法./collision $\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\xe8\x05\xd9\x1dbash会把\x01解释成真正的字节0x01不会把反斜杠 x 当作字面文本。这个方式最直接适合终端里快速实验。第二种用 Python 生成参数并通过命令替换传给程序./collision $(python3 -c import sys; sys.stdout.write(bytes([1]*16 [0xe8,0x05,0xd9,0x1d]).decode(latin1)))这里有几个细节需要特别注意。print会多打印一个换行符\x01也可能被转义处理所以必须用sys.stdout.write。另外bytes类型不能直接作为字符串传给sys.stdout.write需要调用.decode(latin1)手动把每个字节映射成字符。用错了方法参数长度或内容就变了程序会直接报长度错误。命令执行成功后程序会调用system(/bin/sh)进入一个交互 shell。你能看到正常的 shell 提示符可以继续输入ls、cat flag之类的命令读取题目标志。在本地环境验证时可能还会看到一些终端控制相关的提示这是system启动子 shell 的正常现象不影响使用。提示真正的挑战在于远程靶机上执行同样操作。你需要把 payload 作为那个程序启动时的第一个参数传进去然后进入 shell 后立刻执行cat flag一气呵成不要拖泥带水。4. 工程化用脚本自动生成碰撞输入4.1 一个可复用的 Python 生成器手动拆数字、手写\x01串做一次就够了。为了以后遇到类似题目能快速复用我写了一个简单但很实用的 Python 脚本它可以针对任意目标值生成 20 字节的碰撞 payload。#!/usr/bin/env python3 import struct import sys def make_payload(target0x21DD09EC, chunk_count5, filler0x01010101): last target - filler * (chunk_count - 1) if last 0 or last 0xffffffff: raise ValueError(目标值无法在当前参数下拆分) parts [filler] * (chunk_count - 1) [last] # 每个整数按小端序打包成 4 字节 payload b.join(struct.pack(I, part) for part in parts) # 校验数学关系和必须等于目标值 assert sum(parts) target return payload if __name__ __main__: target int(sys.argv[1], 16) if len(sys.argv) 1 else 0x21DD09EC payload make_payload(target) # 默认输出十六进制字节串方便肉眼检查和复制 print(payload.hex())运行时直接指定目标值python3 gen_payload.py 21DD09EC输出结果01010101010101010101010101010101e805d91d脚本里有两个关键点struct.pack(I, part)中的表示小端序I表示 4 字节无符号整数这保证了在 Python 里打包出的字节顺序和 C 程序在 x86 机器上的内存布局完全一致assert sum(parts) target则是给自己加一道保险防止改参数时把数学关系写错。4.2 更硬核的用法直接让 Python 拉起目标程序如果你不想折腾 bash 命令替换还有一个更干净的方式直接让 Python 脚本调用目标程序并把 payload 作为argv[1]传进去。subprocess模块天然支持二进制参数不会像终端命令那样发生转义问题。import subprocess import sys # 复用上面的 make_payload from gen_payload import make_payload payload make_payload(0x21DD09EC) result subprocess.run([./collision, payload], capture_outputTrue) print(result.stdout.decode(errorsreplace))这种方式最大的好处是绕开了所有 shell 转义问题尤其适合包含非可见字符甚至\x00假设某道题允许的 payload。如果题目程序需要从标准输入读取而不是命令行参数也可以改用subprocess.run(..., inputpayload)。也许你会问为什么不把 payload 写进文件再读取因为很多这类题目是从argv拿参数的标准输入根本不会被读取。文件配合命令替换./collision $(cat payload.bin)也能工作但在某些 shell 环境下命令替换对文件内容里的非 UTF-8 字节可能有兼容性问题。直接把argv拼好交给操作系统是最不容易出错的做法。注意这个脚本里的filler默认选0x01010101主要原因是该数值的每个字节都不包含0x00。如果某些目标值很小迫使 filler 或末项出现0x00那么构造出的 payload 一旦经过strlen就会在0x00处被截断长度检查直接失败。这在第 5 章详细说。5. 踩坑记录从 wrong passcode 到 shell 的实战过程5.1 三个让我卡住的实际问题我在这道题上花的实际时间比预想长很多大部分消耗在如何把正确内容传给程序而不是如何解出数字上。这里记录三个典型问题相信能帮你省下不少时间。第一个问题是长度校验失败。一开始我没有立刻理解程序期望的是 20 字节的原始二进制数据而是自作聪明地把它当成了 40 个十六进制字符想着把01010101...这种可见字符串传进去。结果程序直接提示passcode length should be 20 bytes。这就是没理解源码导致的错误strlen统计的是字节数不是十六进制表示后的字符数。第二个问题是字节序写反。我把0x1DD905E8的字节序列按肉眼从左到右的方式写成了\x1d\xd9\x05\xe8自信满满地运行结果得到wrong passcode。后来才意识到小端序机器在解释int时低地址放的是最低有效字节。内存中E8 05 D9 1D才能被解释成0x1DD905E8。从那以后我每写完一个 multibyte payload 都会先停下来问自己这个数字在目标机器的内存里到底长什么样。第三个问题是Python 的 print 输出和命令替换打架。我最初用python3 -c print(\x01*16 \xe8\x05\xd9\x1d)结果程序一直说长度不对。原因有两个print默认在末尾加换行导致实际长度变成 21而在某些 Python 版本中print里的\x01会被当成可打印字符处理输出内容完全不是预期的字节流。后来换成了sys.stdout.write(bytes([...]).decode(latin1))才彻底解决问题。写 shell 命令时永远不要把print当作输出二进制数据的手段。5.2 常见问题速查表现象可能原因解决方法passcode length should be 20 bytes参数被人为变成了十六进制文本或多了换行符payload 含\x00被strlen截断确认传的是 20 个原始字节检查 payload 里有没有0x00不要用print输出wrong passcode拆分数字算错字节序写反用sum(parts)验算把0x1DD905E8对应的内存字节写成\xe8\x05\xd9\x1d命令执行后没有 shell 提示符非交互式 shell没有保持标准输入打开直接在该进程内执行cat flag用 Pythonsubprocess.run捕获输出远程连接后程序行为与本地不同远程架构可能是 64 位int仍是 4 字节但 shell 路径或权限有差异先file查看远程程序架构输出前用ls观察当前目录gdb里看到的值和自己算的对不上没有分析符号断点位置不对内存中还有环境变量数据在check_password返回后查看调试器的寄存器值用x/5wx查看内存内容5.3 用调试器验证你自己的构造如果数学已经算对但程序还是拒绝最快的方式就是让调试器告诉你答案。我习惯于在check_password的返回指令处打断点直接查看累加结果。gdb ./collision (gdb) break *check_password... (gdb) run $\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\xe8\x05\xd9\x1d (gdb) info registers eax在常见的 x86 32 位程序里函数返回值会放在eax寄存器中。如果你断点打在函数即将返回的位置eax的值应该就是0x21DD09EC。如果对不上那就是你的字节序或拆分值有问题。这个验证法比反复试错高效得多。(64 位环境则看rax但题目源码是 32 位风格很多 Pwnable 平台直接提供 32 位二进制。注意用file collision先确认目标文件的架构再用对应位数的调试方法。)6. 题目做完了回头看看碰撞的真正启示6.1 别用线性可预测的函数当认证做完这道题最直观的感想是很多校验逻辑在不经意间就变成了一个可逆的数学表达式。check_password本质上是一个多项式求值函数只是次数很低、完全没有混淆。攻击者一旦知道目标值就可以像解方程一样构造输入。工程上类似的脆弱模式非常常见有人喜欢把几个字段相加得到一个校验码有人用 CRC 做防篡改有人用简单的线性同余生成器做 token。这些方案在面对偶然错误时很有用但面对恶意攻击者时毫无抵抗力。攻击者不需要破解你的算法只需要找到一组满足你等式的输入即可。这也是为什么现如今的密码存储方案都强调使用bcrypt、argon2这类专门设计的慢速密钥派生函数并且要加随机盐。核心思想的转变在于不信任任何可逆变换也不信任任何快速函数让攻击者即使拿到哈希结果也很难反推出原始口令或构造合法输入。6.2 送给新手们的一些个人建议如果你也是刚入坑 Pwnable我想以过来人的身份给你三条建议。第一先把 C 语言的指针和内存模型弄扎实。collision 这道题本质上就是同一个内存地址被不同类型指针解释后产生不同含义的活教材。不理解int *ip (int *)p到底做了什么后面所有步骤都是空中楼阁。第二养成先本地完全打通再连远程的习惯。很多新手一上来就急着打远程结果本地都没验证过浪费大量时间在排查环境差异。我在这道题上最快的突破是先把 payload 放到本地程序里用调试器确认了求和结果再带着完全确定的 payload 去连接远程题目。第三遇到任何涉及二进制传输的命令行操作多想想底层字节流是什么样的。你以为你传的是\xe8\x05\xd9\x1dshell 可能帮你拆成了别的字符你以为函数print很合适它多出来的换行符可能直接毁掉长度校验。这些细节只有亲自踩过一遍才会形成肌肉记忆。顺便分享一个让我养成至今的工作流拿到任何程序的输入先问三个问题——这个输入最终在内存里以什么类型被解释目标的机器是小端还是大端从我的输入到解释之间中间层shell、编程接口会不会转义或添字节这三个问题想清楚很多看似诡异的报错都能在几秒内定位。这道 collision恰好把这三个问题一次性都教给了我。