PHP代码分析溯源实战:从十六进制字符串还原隐藏IP
墨者学院的《PHP代码分析溯源》系列刷到第四题的时候我一度以为自己拿错了题目包。前三题都是直接给一段有漏洞的PHP代码让你找注入点、找命令执行这一题却更像一份“案发现场遗留物”源码里没有任何会造成漏洞的高危函数倒是有个叫Resolver的类函数不多看来看去都像是在做字符串转换。但恰恰是这段不起眼的转换代码把攻击者的真实IP藏了进去。这篇复盘我会完整还原当时的解题过程——从解压源码包时的文件勘察到最终用一条命令行还原出IP并顺利通过验证。如果你也在刷墨者学院或者正在练代码分析溯源方向这篇应该能帮你理清思路。1. 拿到题目后的第一步先看代码里的“地图”1.1 解压后的文件清单决定了题目的叙述顺序做题和做项目不一样不能急着抓一份代码就开始读。第四题的压缩包解压后第一眼看到的东西非常重要。我记得当时压缩包里有一个source.zip解压出来的文件不算多但结构很讲究。我一般在终端里先做三件套ls -la find . -type f find . -name *.php -exec wc -l {} \;第一行看隐藏文件和目录属性第二行看完整文件列表第三行看PHP文件规模。这一步不是走过场。文件大小和时间戳本身就是线索一个几百字节的PHP文件往往浓缩了整道题的考点而某个文件比同级其他文件晚了几分钟很可能就是攻击后留下的痕迹。第四题的文件大小非常规整没有一个文件超过2KB说明考点一定藏在逻辑转换里而不是藏在某个超长加密数据块里。我当时看到的文件结构长这样已经脱敏简化. ├── index.php ├── config.php ├── class │ └── Resolver.php └── logs └── access.logindex.php是入口config.php里只有几个常量class/Resolver.php就是核心logs/access.log给了几十行看起来无害的访问记录。正常情况下溯源题一定会把关键线索放在这三个位置之一入口文件的调用逻辑、类的实现细节、日志里的异常项。我在第一轮浏览时并没有急着打开Resolver.php而是先扫了一眼index.php。入口文件往往决定了整道题的阅读顺序。果然index.php只有十几行最基本的调用逻辑就是require class/Resolver.php;然后根据路由参数决定是否调用Resolver::resolve()。这说明重点已经明确指向Resolver类。1.2 全局搜索关键词而不是一上来搜危险函数很多新手做代码审计习惯先搜eval、system、exec。但第四题明显不是考漏洞利用如果用这个思路第一轮会一无所获——因为题目里根本没有这些函数。我换了一组关键词ip、address、long2ip、inet_ntop、hex2bin、pack、unpack、base64。在class/Resolver.php里立刻发现两处比较可疑的地方private static $map [ seed c6336407, salt php2024, delta 3 ];这个seed值眼熟得像十六进制但光有一个值还不够。紧接着我在下面看到public static function resolve($name) { $raw self::$map[$name] ?? 0.0.0.0; $raw hex2bin($raw); $ip inet_ntop($raw); return $ip; }到这里整个“地图”就已经清晰了resolve方法先取出seed再做一次十六进制到二进制转换最后用inet_ntop把二进制地址转成点分十进制。这就是一个典型的“把IP藏进十六进制字符串”的小机关。溯源的目标马上从“找漏洞”转成了“读懂转换”。这里我想插一句为什么全局搜索时要避开危险函数因为溯源题的设计思路完全不同于漏洞题。出题人希望考察的是分析者能不能从编码变换中还原线索而不是能不能找到系统被利用的入口。一旦你发现题目里有大量base64_decode或者hex2bin就该意识到这不是“找漏洞”的题而是“找逆向还原点”的题。搜索词选对了方向就对了。2. 核心代码片段IP是如何被“藏”进一个整数的2.1 从十六进制常量到点分十进制先把题目里的关键代码简化出来方便后面解释$hex c6336407; $ip inet_ntop(hex2bin($hex)); echo $ip;跑这段代码屏幕上会准确打印出198.51.100.7。为什么因为inet_ntopnetwork to presentation就是把一个二进制的网络字节序地址转换成我们习惯的点分格式而hex2bin正好把十六进制字符串转成了它需要的4字节二进制。用位运算也能实现同样的效果很多CTF题里会用unpack$bytes array_map(hexdec, str_split(c6336407, 2)); $ip implode(., $bytes);手动展开这个计算就是下面这张表十六进制十进制IP段c61981983351.5164100.100077.7所以答案就是198.51.100.7。本质上一个IPv4地址在计算机里就是4个无符号字节组成的32位数据只不过我们平时看到的是便于记忆的点分写法而代码里为了存储或传输会把它压缩成十六进制、长整型甚至base64。这种十六进制字符串是我们在溯源题里最常见的“中段表示”。攻击者从内存里拿到一个IP可能直接以整数形式写入日志日志采集系统为了美观又可能转成十六进制最后呈现到我们面前的真实文件可能是c6336407这种完全不像IP的东西。如果不熟悉这个转换关系就会卡在这一步很久。2.2 为什么溯源题喜欢用这种“不直观”的表示方法这就要聊到攻击者视角了。做过流量分析或者日志回溯的人都知道真正落到磁盘上的东西很少是干净的140.112.33.7这种格式。恶意样本里存的C2地址可能是大整数可能是十六进制字符串也可能是base64再倒序的结果。为了降低被人工巡检发现的概率攻击者普遍会把IP地址做一次编码转换。对解题者来说这反而给了我们一条很清晰的溯源路径拿到一段混杂的代码先找字符串转换函数再看它作用在哪个变量上最后根据编码方式逆回去。第四题的考点就是这条路径中的一小段——它把十六进制字符串转换成了IP没有再多搞几层算是给后面几题做铺垫。也正是因为只用了最朴素的转换这道题反而很适合用来建立“IP编码转换”的肌肉记忆。我在本地试过一次完全类似的代码片段在一台测试服务器上抓包后看到协议栈记录的“源IP”往往就是32位无符号整型如果不转成点分格式人眼根本无法阅读。所以这类转换不是习题里才有的花架子实战里每天都在用。理解了这一点再回头去看题目里的Resolver就会有“哦原来是这么回事”的感觉。3. 从代码中剥离干扰项还原出真正的IP3.1 排查“烟雾弹”的三板斧第四题虽然核心逻辑很干净源码里也放了不少干扰代码。比如Resolver类中有一个$map里面有seed、salt、delta三个键关键只有seed。salt看起来像个字符串delta看起来像数字但它们都没有被resolve用到。这就是典型的烟雾弹。面对这种情况我的做法是“只看被执行的路径”。用 IDE 的“查找引用”功能看resolve方法到底被谁调用、以什么参数调用。如果入口文件里只有一行Resolver::resolve(seed)那seed就是唯一入口其他键值直接忽略。这一步能省下大量时间。很多人一进源码就试图读懂所有类结果在无关方法里浪费了半个多小时。溯源题里的干扰项往往不止一个但它们有个共同点绝对不出现在关键调用链上。还有一个技巧是注意注释和变量名。第四题的Resolver.php头部有一句被注释掉的调试代码// echo print_r(Resolver::$map, true);看起来像是开发时留的调试信息其实也是在提示你关注$map。遇到这种注释不要跳过往往藏着作者想让你注意的东西。我在这个注释下面又仔细找了一遍发现$map[seed]的赋值形式特别规整像是专门用来给人读的。这种“过度规整”通常意味着它就是答案载体。3.2 用一行PHP命令完成还原验证知道seed的值和转换函数后验证答案不需要写脚本。直接在终端里执行php -r echo inet_ntop(hex2bin(c6336407));输出结果198.51.100.7拿到这个IP后还要配合题目的要求提交。一般这类平台会要求把IP包在一个flag格式里比如flag{198.51.100.7}。我当时第一时间就试着提交一次通过。如果你更习惯用 Python也可以这样验证import socket, binascii print(socket.inet_ntoa(binascii.unhexlify(c6336407)))两种方式结果一致。顺带说一句在解题时学会用“一行命令验证”比每次新建脚本文件效率高得多。尤其是这种只需要拆解一次编码的情况。后来我甚至把最常用的几类转换都做成了一行命令备忘录php -r echo long2ip(3232236033) . \n; php -r echo inet_ntop(hex2bin(c6336407)) . \n; php -r echo base64_encode(inet_ntop(hex2bin(c6336407))) . \n;看到没只要转换函数熟悉还原思路几乎是一瞬间的事。4. 动态验证与踩坑记录本地环境实测反馈4.1 用PHP内置服务器快速跑题静态分析得到结果后我一般不会直接提交而是先在本地把代码跑一遍。原因很简单静态分析可能忽略运行环境的细节比如PHP版本差异、平台字节序动态跑一遍能确认整个链路是否真的通。我的做法是在源码目录起一个 PHP 内置服务器php -S 127.0.0.1:8080然后访问触发resolve的路由通常是index.php里的某段逻辑页面直接输出IP。这一步如果输出和静态分析一致答案基本板上钉钉。不过这里有个小细节本地起服务时如果题目代码里用了$_SERVER[REMOTE_ADDR]一类的变量那你访问时这个值就是127.0.0.1和你通过静态分析算出来的结果可能对不上。第四题没有用到这个变量所以很干净。但如果换一题出现这类情况就要会用curl手动指定请求头或者Host字段不要让环境变量干扰了你的判断。4.2 最容易翻车的三个细节这一题虽然不难但我见过不少人卡住问题普遍出在下面三处数据类型符号。inet_ntop要求传入二进制字符串如果你拿到的是长整型要先确认要不要转成unpack后的字节。直接用ip2long的反向逻辑时遇到大于127的首段在32位系统上容易得到负数再用long2ip强行还原会出错。第四题给的是c6336407首字节c6对应198如果把它当成有符号整数一不小心就会掉进符号扩展的坑。大小端。c6336407是网络字节序大端。如果题目代码里用的是小端处理比如strrev还原出来的IP会完全不对。做题时要看清有没有strrev、endian之类的字样。第四题里没有出现但我在练习时曾遇到一个变体题它把c6336407先strrev变成076433c6再hex2bin inet_ntop结果整个IP就变成了7.100.51.198。当时我没注意白白浪费了十分钟。时区与编码。虽然第四题不涉及但整个系列后面的题会混入时间戳和跨语言编码比如用base64_encode再rawurlencode。这种时候一定要在本地用php -r先跑一遍别手动心算。我在刷另一个平台时遇过一道题IP被拆成了几段分别用str_rot13和base64_decode处理最后还拼上一个date(Y)。这种题如果不动态验证自己心算几乎不可能不出错。我当时就在第一类问题上吃过亏有一次题目给的是整数3232236033我在本地用long2ip得到的是一个奇怪的结果后来发现系统 PHP 是 32 位版本long2ip把最高位解释成了负数。换成unpack(N, pack(N, $ip_long))的写法才正常。所以现在只要涉及 IP 整数转换我都会先在命令行里确认一下 PHP 的 integer 位数php -r var_dump(PHP_INT_SIZE); # 4还是8这个输出能直接影响你对整数溢出问题的判断。5. 这类“溯源题”的通用解题流程个人经验5.1 从输入到输出画一条数据流图是最笨也最稳的方法做完第四题我对同类题的通用解法有了比较清晰的框架。拿到任何一份PHP源码先别管代码多花从入口文件开始把每一个外部输入GET/POST/COOKIE和内部可疑常量标注出来再沿着调用链往输出方向走。这个过程可以简单画在草稿纸上不一定用工具。第四题其实就三步seed常量 -hex2bin-inet_ntop- 输出IP。很多题看起来复杂核心链路往往也只有这么短。不要被类和函数名带偏。比如一个方法叫generateToken结果内部只是return ip2long($this-ip);这种命名和实现完全不符的情况在溯源题里很常见。你得靠数据流来判断而不能靠函数名推测。5.2 溯源题的答案通常藏在“转换”里不在“危险函数”里和常规代码审计不同溯源题的重点不是找漏洞触发点而是找编码转换点。攻击者想要隐藏线索就一定会把IP、时间、User-Agent这些信息做一次变换。变换越复杂留下的代码痕迹越多。所以要重点关注这些函数base64_encode/base64_decodehex2bin/bin2hexstr_rot13pack/unpackinet_ntop/inet_ptonip2long/long2ipimplode/explode配合分隔符拼接substr/str_split/strrev这类字符串裁剪重组函数一旦看到上面这些函数作用在一个可疑常量或输入上基本上就离正确答案不远了。我在复盘时还给自己定了一条规矩遇到pack或unpack无论它出现在哪都值得停顿一下看看参数格式是不是N、C4或H*。不同的格式直接决定字节序和二进制数据是怎么被解析的。5.3 给新手的练习建议如果你刚开始练这个方向建议先做两件事。第一把 PHP 手册里和 IP 处理相关的函数全部过一遍自己写几个小例子验证比如ip2long和long2ip在64位和32位上的区别inet_ntop与pack/unpack的关系。这个基础打牢之后遇到任何编码组合都不会慌。我当时为了练手写了十几个一行命令把127.0.0.1、192.168.1.1、198.51.100.7这些地址反复在十六进制、整数、点分之间切换直到闭着眼睛都能默写转换关系。第二找几道类似题目做专项训练。墨者学院这个系列就是从直球到加混淆层层递进的。第四题做完了后面几题可能就会把hex2bin换成base64_decode再拼接str_rot13。但只要你养成了“找转换函数、沿数据流还原”的习惯万变不离其宗。我还记得自己在做同一系列的下一题时看到$enc base64_encode(strrev($ip));这一行瞬间就反应过来该先strrev再base64_decode。这种敏感度完全靠练。最后分享一个自己总结的小经验这类题提交答案之前把所有可能的大小写、分隔符、flag格式都试一遍。有一次我只是把IP中的字母大写平台判错小写才过。编码题里大小写是敏感项别在这一步丢分。还有如果平台要求答案之间用空格分号之类隔开一定要严格遵守题面上的示例不要自己加多余符号。做完第四题再回看其实它的难度不在算法而在你是否熟悉“IP编码转换”这套基本功。把基本功练扎实溯源题就变成了一道道送分题。