ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Pwnable collision入门:从字符串到内存数字的小端字节序与校验和碰撞

Pwnable collision入门:从字符串到内存数字的小端字节序与校验和碰撞 Pwnable 入门题里的 collision是我见过最适合解释“字符串和内存数字关系”的一道题。题目名字听着像密码学里的 MD5/SHA 碰撞但打开源码会发现它考的其实是一个极其简陋的累加校验把用户输入的 20 个字节分成 5 个 int加完后等于常量 0x21DD09EC 就放行。核心目标就是构造一个“伪碰撞”让固定校验函数对一段完全不同的输入算出一模一样的值。这篇文章就把题目逻辑、小端字节序、argv 不能带 \0 这些点一次性说透适合刚接触 Pwnable/CTF pwn 的读者也适合当成一道“拿到校验函数怎么拆”的速查题。1. 原题梳理这里的 collision 到底在考什么1.1 先看清楚 check_password 在干嘛进入到 pwnable.kr 的题目环境后col.c的核心逻辑大约长这样#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(passcode length should be 20 bytes\n); return 0; } if(strlen(argv[1]) ! 20){ printf(passcode length should be 20 bytes\n); return 0; } if(hashcode check_password(argv[1])){ printf(hack!\n); return 0; } else { printf(wrong!\n); return 0; } }第一眼容易懵在int* ip (int*)p这一句。它把const char*强行转成了int*意思是我不再把这段输入当成一堆字符而是把它当成一堆 32 位整数来看。配合后面循环的i 5说明程序期待一个长度为 20 字节的参数因为 20 字节刚好装得下 5 个 int。题目限制只说strlen(argv[1]) ! 20就报长度错误。也就是说你给的参数必须在 C 字符串层面正好 20 字节。这是个关键约束后面构造 payload 的时候会反复用到。1.2 碰撞目标为什么是 0x21DD09EC代码最后把check_password(argv[1])的结果和一个固定的全局变量hashcode 0x21DD09EC比较。所以我们要做的不是“绕过比较”而是老老实实让函数算出来这个数。check_password做的事情可以写成一条数学等式ip[0] ip[1] ip[2] ip[3] ip[4] 0x21DD09EC其中每个ip[i]来自输入中连续的 4 个字节。所以题目本质是给你 5 个 32 位整数槽位填上 20 个字节使它们的和等于一个固定值。这就是标题里“collision”的含义不是要找两个内容完全一样的字符串而是要找一段输入在经过这道弱校验后得到和别人一样的“哈希结果”。这道题的难度其实不高但它把三个非常基础的知识点全部揉在了一起内存指针的类型转换、小端字节序、argv 字符串不能包含\0。任何一个点没想明白payload 都可能莫名其妙失败。2. 四个关键细节字节序、指针转换、NUL 限制和整数求和2.1 从 char* 到 int*20 字节变成了 5 个数很多人第一次接触(int*)p时会觉得不就是一个强制类型转换嘛。但它背后隐藏着“内存就是一段连续字节”的概念。假设输入是01 02 03 04 05 06 07 08 09 0a 0b 0c 0d 0e 0f 10 11 12 13 14用char*看这是一个有 20 个元素的字符数组。用int*看它就被重新解释成一个长度为 5 的整数数组[01 02 03 04] [05 06 07 08] [09 0a 0b 0c] [0d 0e 0f 10] [11 12 13 14]每一组 4 个字节在内存中被当作一个 int 后具体数值是多少取决于 CPU 的字节序。x86 和绝大多数常见环境是小端所以内存里排在前面的低字节会变成整数的低位。这个顺序如果搞反后面怎么算都算不对。提示(char*)p转成(int*)p在 C 标准里其实属于未定义行为尤其涉及内存对齐问题时。但 pwnable.kr 这道题的运行环境是 x86未对齐访问也能正常跑所以题目才敢这么写。你只需要关注题目的实际行为不用纠结标准怎么说。2.2 小端环境下内存里的字节顺序是反的小端的核心规律是一个 32 位整数0x11223344存在内存里时从低地址到高地址依次是44 33 22 11也就是说我们日常写数字时习惯把最高位写在最左边但内存里最低位先出现。比如0x01010101比较特殊四个字节都是01所以它在内存里是01 01 01 01不管怎么排都一样。但一旦末位数不是对称的就必须特别小心。比如要放整数0x1DD905E8它在内存里的 4 个字节应该是e8 05 d9 1d而不是直觉上的1d d9 05 e8很多新手第一次做这道题手算出了正确的0x1DD905E8却把字节顺序写反。结果程序把1d d9 05 e8这四个字节当作一个整数以后得到的根本不是0x1DD905E8而是另一个完全不相关的数累加结果自然对不上。2.3 argv 和 strlen为什么 \x00 想用却不能用如果不考虑字节序最朴素的想法是前 4 个 int 都填 0最后一个 int 填0x21DD09EC不就行了数学上确实成立但实践上完全行不通因为0x21DD09EC在小端内存里是ec 09 dd 21这 4 个字节没有\x00但前 4 个 int 如果都是 0写成字节就是00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00先不说别的strlen(argv[1])遇到第一个\x00就会当作字符串结束长度直接变成 0根本过不了 20 字节检查。而 argv 本身是 C 字符串任何通过 shell 传进来的参数都不可能包含真正的\x00字节。就算用 subprocess 直接以字节数组方式传参数C 程序内部的argv也只能以\0作为边界传进去的\x00会被截断。所以这道题真正的约束浮出水面20 个字节里不能出现 NUL 字节否则长度检查先挂掉同时每个 4 字节组还必须是可计算的整数。这就逼着我们找一组“全非零”的整数组合来凑出目标值。2.4 累加校验和为什么人人可撞check_password把 5 个 int 直接累加本质上和 CRC、奇偶校验这类无密钥校验一样都是“低碰撞成本”的算法。强密码学哈希要求两个不同输入几乎不可能算出同一个摘要但这里只是一个求和攻击者完全可以反解。这道题碰撞空间非常大。我可以任意指定前 4 个整数然后让最后一个整数等于0x21DD09EC减去前 4 个之和。照这个思路只要不引入 NUL 字节随便怎么填都行。所以它真正难的地方不是“怎么碰撞”而是“怎么在 C 字符串的限制下碰撞”。3. 构造 payload 并跑通题目的完整过程3.1 手工拆数前 4 个 int 取 0x01010101为了满足“不含 \x00”和“长度 20”两个条件我选择前 4 个 int 全部填0x01010101。写成字节就是01 01 01 01 01 01 01 01 01 01 01 01 01 01 01 01也就是\x01连续出现 16 次。这样所有字节都不是 0而且数值好算。前 4 个数的总和是0x01010101 * 4 0x04040404在十进制里大概是16,843,009 * 4 67,372,036这个数也不需要背只要知道它是个正数、且比目标小就够了。在 Python 里随时可以验证 target 0x21DD09EC part 0x01010101 hex(4 * part) 0x40404043.2 最后一个 int 的换算与字节序列现在反推最后一个 int0x21DD09EC - 0x04040404 0x1DD905E8这个数就是第 5 个 int 需要等于的值。接下来是小端字节序关键一步 (0x1DD905E8).to_bytes(4, little) b\xe8\x05\xd9\x1d所以完整的 20 字节 payload 是\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\xe8\x05\xd9\x1d为了更直观我把它拆成 5 组[01 01 01 01] [01 01 01 01] [01 01 01 01] [01 01 01 01] [e8 05 d9 1d]对应的小端整数值分别是0x01010101 0x01010101 0x01010101 0x01010101 0x1DD905E8累加结果正好等于0x21DD09EC。这个 payload 里没有\x00所以能安全地作为命令行参数传给程序。3.3 终端里的三条验证命令先构造 payload然后用双引号包住命令行参数最后执行二进制。以 Python 3 为例payload$(python3 -c import sys; sys.stdout.buffer.write(b\x01 * 16 b\xe8\x05\xd9\x1d)) ./col $payload如果环境里有 Python 2也可以直接用./col $(python -c print \x01 * 16 \xe8\x05\xd9\x1d)要注意的是Python 2 的print默认会在末尾加一个换行但$(...)命令替换会把尾部换行吃掉所以不会多算长度。Python 3 的sys.stdout.buffer.write不会自己加换行更干净。执行前最好确认一下 payload 长度真的是 20payload$(python3 -c import sys; sys.stdout.buffer.write(b\x01 * 16 b\xe8\x05\xd9\x1d)) printf %s $payload | wc -c正常会输出20。如果长度不是 20多半是命令替换时出了问题或者某条命令默认追加了额外字节。3.4 更稳的做法直接用 Python 脚本调二进制终端里的做法虽然能跑但每次都要处理 shell 转义和引号稍不留神就会出错。更稳的方式是写一个小脚本用subprocess直接传字节数组import subprocess payload b\x01 * 16 b\xe8\x05\xd9\x1d print(payload len , len(payload)) print(payload hex , payload.hex()) proc subprocess.run([./col, payload], capture_outputTrue) print(proc.stdout.decode(errorsignore))这样既能避免 shell 拆词又能把 payload 的十六进制打出来供检查。远程题目环境如果允许上传脚本或者你有 SSH 交互这段脚本可以直接在目标机上跑。脚本输出里如果能看到hack!或题目给的 flag就说明整条链路已经通了。4. 踩坑实录从长度报错到 wrong 的排查清单4.1 长度明明是 20程序却说长度不对有一种非常典型的情况你在 shell 里用单引号或者双引号传 payload以为传了 20 字节但实际已经被终端解析器额外处理过。尤其是当你尝试在前 4 个 int 里用\x00时argv[1]会在第一个空字节处截断长度立刻变短。还有一种情况是命令替换后的变量没加双引号。比如./col $payload$payload一不带引号shell 就会对它做分词和路径展开。虽然我们的 payload 里没有空格这个例子不一定会立刻爆炸但这是一条迟早会踩的规则凡是包含不可见控制字符的参数一律用双引号包起来。4.2 最后四个字节写反或写错如果程序没报长度错误但输出wrong!最常见的原因就是小端顺序写反。0x1DD905E8的正确内存表示是e8 05 d9 1d有朋友会写成1d d9 05 e8这四个字节在 x86 上解释成整数后实际值是0xE805D91D和期望的0x1DD905E8差了很远。累加结果自然不是目标值。我自己的排查习惯是如果wrong!先用 Python 把 payload 的每一组转成整数看累加结果到底是什么。手工拆一遍就能立刻知道是字节序问题、还是算数问题payload b\x01 * 16 b\xe8\x05\xd9\x1d ints [int.from_bytes(payload[i:i4], little) for i in range(0, 20, 4)] print([hex(x) for x in ints]) print(hex(sum(ints)))这一打印任何手算错误都会暴露。4.3 shell 变量不引号导致参数被拆分虽然是老生常谈但在 pwn 题里真的很容易遇到。因为 payload 可能是不可见字符一旦忘记加双引号shell 会按IFS切分程序拿到的argv[1]就不是完整的 20 字节。结果表现也是“长度不对”或者“wrong”。另外echo $payload和printf %s $payload的结果可能不同。有些版本的echo会解释\x转义等于把 payload 改了。检查长度和调试时统一用printf或者直接让 Python 打印十六进制。4.4 换架构或换编译器带来的差异如果这道题不是跑在 x86 小端环境而是跑在某种大端架构上同样的字节序列会被解释成完全不同的整数。最关键的一步就变成先确认目标环境的字节序再决定字节怎么排。另外int 不一定是 4 字节。虽然绝大多数 CTF 环境都是 4 字节 int但如果是 16 位 int 的老平台整个分组方式都会变。做任何 pwn 题都应该以目标文件实际跑在什么环境为准不要只看源码里的类型就默认。为了把这一类问题讲清楚我整理了一个排查速查表现象大概率原因快速验证手段passcode length should be 20 bytespayload 里有\x00被截断或 shell 参数没传完整Python 打印 len(payload)wrong!且前 4 字节正常最后 4 字节大小端写反按 4 字节分组逐组打印 hexwrong!且累加值不对手算减法出错或 payload 多/少字节Python 执行sum(ints)命令执行报错或找不到文件路径不对或脚本在当前目录外ls -l ./col确认文件位置注意pwnable 系列题目的二进制通常已经编译好并且存在固定的运行环境。如果你在本地重编译千万不要改函数返回类型或者循环边界否则题目语义会变。5. 这类碰撞的通用攻击面与我的经验5.1 弱校验和碰撞远比想象中容易做完这道题我最大的感受是很多人看到“hash”就联想到 MD5、SHA-256但实际开发里大量校验只是简单的累加、异或、CRC 表查值。这些算法的碰撞成本低得惊人。攻击者如果知道你用的是哪一种弱校验完全可以反解出可控输入让校验值等于任意目标。例如如果某个登录逻辑用“用户名和密码的字节累加值等于某常量”来判断那就和这道题没有任何区别。攻击者不需要知道密码原文只需要知道目标校验值然后用几个精心构造的字节拼出同样的和。真正的安全校验必须使用密钥参与的、不可逆的算法比如 HMAC 或带随机盐的密码哈希。5.2 类型强转与边界控制是 pwn 的常客int* ip (int*)p这种写法在真实代码里也不罕见常见于网络协议解析、文件头解析、图像数据处理。问题在于很多解析器没有严格校验长度就能直接强转导致越界读取、对齐异常、字节序误判。从攻击者视角看看到“输入长度固定”和“强制类型转换”这两个信号第一反应就应该是我能不能通过构造字节让解析器误以为这段数据是某种数值这道题只是让你 5 个数加起来等于某个常量属于最温和的利用。如果后面再跟上数组索引、偏移计算同样的思路就可能变成任意读写。5.3 面对这类题我的固定思考路径现在我做类似题目基本会按这几步走第一步读源码找到校验函数明确输入经过几次转换和几次判断。第二步把校验函数写成数学等式不急着写 exploit。第三步考虑目标环境的字节序、int 宽度、字符串是否允许\0。第四步构造 payload先用 Python 在本地验证累加结果再交给真实二进制跑。这道 collision 最让人舒服的地方是它不需要任何花哨的堆利用和 ROP纯粹就是把“数字在内存的排列方式”想明白。我个人建议新手不要直接搜 payload 从网上抄而是自己拿一张纸手动算一遍0x21DD09EC怎么拆然后亲手在 Python 里转一次字节序。这一步想透后面很多 pwn 题里的整数溢出、数组越界都会变得顺理成章。最后再分享一个小技巧遇到类似“固定常量 累加校验 定长输入”的题目时永远先尝试“拆成 N 份等值 一个修正项”的构造法。这个思路不仅适用于这道题也适用于很多协议校验或者游戏里的资源校验算是最通用的解题模板之一。
返回列表