1. 从一道题看透 parse_url 的脾气
做 Web 方向题目的朋友大概都有过这种体验:明明代码逻辑看着人畜无害,过滤函数也写得挺像回事,可偏偏就是能绕过去。parse_url相关的题目就是典型代表,它不像 SQL 注入那样有明确的 payload 套路,也不像文件包含那样一眼能看出路径拼接的痕迹,它坑在“解析结果和你肉眼看到的不一样”。我第一次碰到这类题的时候,盯着源码看了半小时,觉得parse_url返回的 host 明明就是白名单里的域名,怎么还能打到别的地方去,后来才明白问题出在解析器对特殊字符的处理顺序上。
这篇内容围绕parse_url这一类题目的解题思路展开,核心讲清楚三件事:这个函数到底怎么切分 URL、哪些字符会让它的判断和后续代码的判断产生分歧、以及拿到一道题之后应该按什么顺序去试。适合已经会基础 Web 操作、想补齐“解析差异”这块短板的朋友,也适合刚接触这类题目、被各种绕过姿势绕晕的新手。文中所有示例域名和参数都用虚构代称,思路可以直接迁移到你手头的练习环境里。
需要先说明一点:parse_url本身不是漏洞,它只是一个解析工具。真正的问题在于“解析一次”和“使用多次”之间的不一致——代码用parse_url取出一部分做校验,后面又用原始字符串或者别的函数去拼接、去请求,两边的理解不一样,缝隙就出来了。理解这个本质,比背十个 payload 有用得多。
2. parse_url 到底怎么切一刀
2.1 返回结构先摸清楚
parse_url的行为其实很朴素:给它一个字符串,它按固定顺序找几个标志性符号,切出 scheme、user、pass、host、port、path、query、fragment 这几块。关键在于它是“从左往右、遇到第一个匹配就停”的贪婪式切分,而不是先做整体语法校验再解析。这就意味着,一个看起来畸形的字符串,它照样能吐出一个数组来。
举个最直观的例子,http://user:pass@host:8080/path?q=1#frag解析出来是 scheme=http、user=user、pass=pass、host=host、port=8080、path=/path、query=q=1、fragment=frag。这个结构大家都会背,但真正要命的是那些“不标准”的输入。比如http://host/path?url=http://evil这种,query 里又套了一个完整 URL,parse_url只认最外层,里层那个它根本不管。很多题目就是利用这一点:外层校验通过,里层被拿去二次解析。
我习惯在本地先跑一遍确认结构,命令很简单:
php -r "var_dump(parse_url('http://a:b@c:80/d?e=f#g'));"输出里每个键对应的值一目了然。做题前先把手头 payload 丢进去看一眼,比在脑子里推演靠谱得多。尤其是带@、带多个://、带编码字符的串,肉眼判断经常出错。
2.2 那些让解析“跑偏”的字符
真正制造分歧的,是几类特殊字符。第一类是@,它把前面当 userinfo、后面当 host,但如果出现多个@,不同处理方式结果不同。第二类是#,它后面的全部归 fragment,而 fragment 在很多后端逻辑里会被忽略或者不参与校验。第三类是?,query 的起点,query 里再出现://不会影响外层 host 的判断。第四类是反斜杠和编码后的斜杠,某些场景下会被规范化成/,从而改变路径含义。
这里有个容易被忽略的点:parse_url对不合法的输入可能返回false,而很多代码写的是if (parse_url($url)['host'] === 'xxx'),一旦返回 false,取下标会报错或者得到 null,校验逻辑就直接失效了。这种“解析失败反而放行”的情况,在题目里出现频率不低。所以看到源码里没有对返回值做类型判断,就要多留个心眼。
2.3 校验与使用分离才是根因
把话说透:单看parse_url,它没有安全问题。问题出在“校验用一套逻辑,使用用另一套逻辑”。常见组合有这么几种:用parse_url取 host 做白名单,然后用原始$url去发起请求;用parse_url取 path 做后缀判断,然后用字符串拼接去读文件;用parse_url取 query 做参数提取,然后对提取结果再做一次解析。每一种组合,只要两边的“理解”有偏差,就有操作空间。
我在整理这类题的时候,习惯画一张对照表,把“校验看到的”和“实际使用的”并排列出来,缝隙往往一眼就能看见。下面这张表是常见分歧点的归纳,实际做题时可以照着对:
| 分歧点 | 校验侧常见写法 | 使用侧常见写法 | 可能的结果 |
|---|---|---|---|
| host 判断 | 取 parse_url 的 host 比对白名单 | 用原始字符串请求 | 原始串里的真实目标与 host 不一致 |
| 后缀判断 | 取 path 判断结尾 | 拼接后读文件 | 路径中段被注入 |
| 参数提取 | 取 query 再解析 | 二次解析或直接拼接 | 嵌套结构被利用 |
| 编码处理 | 未解码直接比对 | 解码后使用 | 编码字符绕过比对 |
这张表不是让你背,而是提醒你:拿到题先找“两次理解”在哪里,比盲目试 payload 高效得多。
3. 拿到题之后的实操推进顺序
3.1 第一步永远是读源码找“两次理解”
我不建议一上来就丢 payload。先通读源码,重点找三样东西:parse_url出现在哪、它的返回值被拿去做了什么判断、原始输入在后面还有没有被使用。把这三样串起来,基本就能定位缝隙。比如看到$u = parse_url($url); if($u['host']==='safe.example'){ ... file_get_contents($url); },那缝隙就在“host 校验的是解析结果,读取用的是原始串”。
读源码的时候有个小技巧:把每个变量在脑子里的“值形态”标出来——它是原始字符串、是解析后的数组、还是某一段子串。形态一变,理解就可能变。很多绕过就发生在“字符串变数组再变回字符串”的转换环节。
3.2 构造 payload 的通用手法
定位到缝隙之后,构造思路就清晰了:让parse_url解析出来的关键字段落在白名单里,同时让原始字符串在真正被使用的时候指向别处。常见手法有这么几类,我按使用频率排一下。
第一类是 userinfo 混淆。形如http://safe.example@target.example/,parse_url取 host 得到的是target.example,但如果代码取的是别的字段或者做了字符串包含判断,结果就不同。反过来,如果代码判断 host 必须是 safe.example,那这个串过不了;但如果代码判断的是“字符串里包含 safe.example”,那它就能过,而实际请求会打到 target.example。判断方式决定了 payload 方向,这也是为什么必须先读源码。
第二类是 fragment 截断。http://safe.example/path#@target.example,#后面归 fragment,host 仍是 safe.example,校验能过;但如果后续有逻辑把 fragment 拼回去或者做了别的处理,就可能出问题。这类手法在“校验 host、使用整串”的场景里要结合具体实现看。
第三类是 query 嵌套。http://safe.example/?url=http://target.example/,外层 host 合法,内层 URL 藏在 query 里。如果后端有“从参数里取 URL 再请求”的逻辑,内层就会被二次使用。这类题的关键是找到那个“二次解析”的入口。
第四类是编码与规范化差异。把某些字符做 URL 编码,校验侧不解码直接比对,使用侧解码后使用,两边看到的东西就不一样。这里要注意,不同实现对编码字符的处理并不统一,必须实测,不能想当然。
3.3 一个完整的推演示例
假设某练习环境里有这么一段逻辑(虚构代称):接收参数u,用parse_url取 host,要求 host 等于safe.example,通过后用file_get_contents读取u指向的资源。目标是读到本地的敏感文件。
推演过程是这样的:file_get_contents支持file://这类封装,但 host 校验要求是safe.example,直接写file:///etc/passwd过不了 host 判断。这时候就要想,能不能让parse_url认为 host 是 safe.example,而file_get_contents实际读的是本地文件。一种思路是利用 userinfo 和路径的组合,让解析结果和实际读取目标分离;另一种思路是看file_get_contents对某些畸形串的容错行为。具体哪种可行,取决于运行环境的实现细节,必须本地实测。
我本地验证的习惯是先写个小脚本模拟校验逻辑,把候选 payload 一个个丢进去看parse_url的输出,再单独测file_get_contents的行为,两边都符合预期了再上题目环境。这样比在题目里反复试错快得多,也不会因为试太多次触发限制。
<?php $url = 'http://safe.example@target.example/path'; var_dump(parse_url($url)); // 观察 host 到底是哪个,再决定下一步实测下来,这类题最耗时间的不是构造,而是“确认当前环境的解析行为”。同一个 payload 在不同版本、不同配置下结果可能不同,所以本地先跑一遍是省时间的做法,不是多余步骤。
4. 踩过的坑和排查清单
4.1 常见问题速查
做这类题踩的坑,我整理成了一张表,遇到卡壳的时候逐条对一遍,往往能发现问题所在。
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| payload 本地能过、题目不过 | 环境解析行为不同 | 在题目环境回显解析结果 |
| 校验通过但请求没打到目标 | 使用侧做了额外处理 | 检查是否有二次解析或拼接 |
| 返回 false 导致报错 | 输入不合法 | 调整串结构使其可解析 |
| 编码后反而失效 | 使用侧未解码 | 确认解码发生在哪一步 |
| 多次尝试后被限制 | 触发频率控制 | 减少无效请求,先本地验证 |
这张表里第一条是最常见的。很多人本地用某个版本跑通了,直接拿去用,结果题目环境是另一个版本,解析结果差一个字段,整个思路就断了。所以“在题目环境确认解析结果”这一步不能省,哪怕只是通过报错信息或者回显间接确认。
4.2 几条实打实的心得
第一条心得:不要迷信“标准 payload”。网上流传的很多串是针对特定题目的特定逻辑,换个环境就失效。真正要带走的是“找两次理解差异”的方法,而不是那串字符本身。
第二条心得:善用报错和回显。有些环境会把解析结果或者错误信息带出来,这就是最好的调试窗口。哪怕只回显一个字段,也能帮你确认当前环境的解析行为,比盲试强太多。
第三条心得:控制尝试次数。有些练习环境对请求频率或者失败次数有限制,无效 payload 丢太多会把自己锁在外面。我的做法是本地把候选串全部验证一遍,筛出两三个最可能的,再上题目环境,一次到位。
第四条心得:注意返回值类型。parse_url失败返回 false,取下标在部分配置下会告警甚至中断流程,这种“意外放行”或者“意外中断”本身就是可利用点,读源码时看到没有类型判断就要标记出来。
提示:所有 payload 和域名示例均为虚构代称,仅用于说明解析差异的原理,实际练习请在自己搭建的环境中进行。
4.3 这类思路还能往哪延伸
parse_url的解析差异只是“解析器不一致”这个大主题下的一个分支。同样的思路可以迁移到其他场景:比如某个函数对路径的规范化处理和另一个函数不一致,导致路径穿越;比如某个校验对大小写敏感而使用侧不敏感;比如编码解码顺序不同导致比对失效。核心永远是那一句话——找到“校验看到的”和“实际使用的”之间的差异,然后让这个差异为你所用。
我在实际做题中的体会是,这类题目真正考的不是记忆,而是“读代码时能不能把每个变量的形态变化跟踪清楚”。把源码当成一个数据流来读,标出每个环节数据长什么样,缝隙自然就浮出来了。payload 只是最后一步的表达,前面的分析才是决定成败的地方。