引言
PHP 是 CTF Web 方向出现频率最高的语言,原因不是它「不安全」,而是它的动态类型系统与历史遗留设计制造了大量语义陷阱:同一个 ==,在 PHP 7 和 PHP 8 下答案可能相反;同一个 md5() 调用,传入字符串和传入数组会走到完全不同的分支。这些陷阱在企业代码里是隐蔽缺陷,在 CTF 里就是一道题的核心考点。
本文讨论的是语言语义层面的绕过,而非框架或组件漏洞。这类题的特点是:不依赖任何第三方库、不需要复杂的环境,往往二十行 PHP 源码就能构成一道中等难度题。也正因为如此,它们最能检验「是否真正读懂了一门语言」,而不是「是否背过 payload 字典」。
需要先明确语境:本文所有示例都以本地靶场最小复现为限,目的是理解语义与建立检测意识,不针对任何真实生产系统。每一节都会给出对应的「检测与防御」,因为同一个知识点在攻防两侧的价值是相通的——理解了绕过原理,才知道该在代码、WAF 还是日志层面拦截它。
阅读顺序上,第 1 到第 5 节围绕「比较与类型转换」这条主线,第 6 到第 8 节进入「变量与对象」,第 9 节讨论绕过检测的手段本身。建议在本地用 Docker 起一个 PHP 8.2 环境边读边验证,PHP 版本差异是这类题最大的变量。
目录
- 弱类型比较:== 与 === 的语义差异
- 魔法哈希与 0e 绕过
- md5/sha1 数组绕过与 null 语义
- intval 截断、科学计数法与进制陷阱
- strcmp、strpos 与 in_array 的绕过面
- 变量覆盖:extract 与 parse_str
- 文件包含与 PHP 伪协议
- 反序列化与魔术方法 POP 链
- 无字母数字 webshell 的构造原理
1. 弱类型比较:== 与 === 的语义差异
PHP 的 == 在做比较前会进行类型转换,规则可以概括为:数字与字符串比较时,字符串尝试转成数字。这条规则制造了经典的绕过场景。在 PHP 8.3 下实测:
<?php
var_dump("0e123" == "0e456"); // bool(true) 两个都是"数字字符串",按数值比较,均为 0
var_dump("1abc" == 1); // bool(false) PHP 8:非数字字符串按字符串比较
var_dump("abc" == 0); // bool(false) PHP 8;PHP 7 下为 true
var_dump("0x1A" == 26); // bool(false) PHP 8;PHP 7 下为 true
var_dump("1" === 1); // bool(false) 严格比较同时比类型
关键在于 PHP 8.0 的「Saner string to number comparisons」RFC:此前 "abc" == 0 为 true(因为 "abc" 转数字得 0),PHP 8 起改为 false。这意味着很多老 writeup 里的 payload 在 PHP 8 环境下会直接失效,做题时必须先确认目标版本——这通常是题目的第一个隐藏考点。
检测与防御:代码层面统一用 === 与 !==,在文件顶部加 declare(strict_types=1); 让函数参数与返回值强制类型;参数入口用类型声明(function check(string $s))。审计时用静态分析工具(PHPStan、Psalm 最高等级)扫描松散比较,重点是涉及口令、token、金额的判断分支。
1.1 字符串何时被当作数字
PHP 判定「数字字符串」的规则是理解所有弱比较绕过的前提。简化后的规则如下:
| 字符串形式 | 是数字字符串 | 弱比较时的行为 |
|---|---|---|
"123" | 是 | 按数值比较,"123" == 123 为真 |
"123abc" | PHP 8 否 / PHP 7 是 | PHP 7 下按 123 比较,PHP 8 下按字符串比较 |
"0e123" | 是(科学计数法) | 按数值 0 比较,这正是 0e 绕过的根源 |
"1e3" | 是 | 按数值 1000 比较 |
" 123"(前导空格) | 是 | PHP 8 起前导空格被允许,仍按数值比较 |
"abc" | 否 | PHP 8 下与数字比较恒为 false |
关键结论:0e 开头的字符串之所以危险,是因为它是合法的科学计数法形式,而不是因为 == 有什么特殊规则。0e 后面无论跟什么数字,数值都是 0。
2. 魔法哈希与 0e 绕过
第 1 节的规则有一个直接推论:如果两个不同字符串的哈希值都是 0e 开头且后续全为数字,那么它们在 == 下会被当作两个数字(都是 0)而判定相等。这类字符串被称为「魔法哈希」。
<?php
// 本地靶场复现:两个不同输入的 md5 在弱比较下相等
$a = "240610708"; // md5 = 0e462097431906509019562988736854
$b = "QNKCDZO"; // md5 = 0e830400451993494058024219903391
var_dump(md5($a) == md5($b)); // bool(true)
var_dump(md5($a) === md5($b)); // bool(false) 严格比较才安全
常用的 md5 魔法字符串还有 aabg7XSs(0e087386482136013740957780965295)、s878926199a(0e545993274517709034328855841020);SHA-1 同样存在,例如 aaroZmOk(0e66507019969427134894567494305185566735)与 aaK1STfY。题目通常要求提交两个不同的参数值,使它们的哈希弱比较相等。
检测与防御:比较哈希一律用 hash_equals() 加 ===;口令存储用 password_hash() / password_verify()(自带随机盐与恒定时间比较),永远不要自己写 md5($pwd) == $stored。WAF 层面可以对参数中出现 0e 加纯数字模式的输入做告警,但这不是根治手段——根治在于代码不写松散比较。
2.1 如何自己找魔法哈希
魔法哈希不是稀缺资源,用一段本地脚本就能批量筛选(仅在自建环境运行,用于理解原理):
# 本地枚举:寻找 md5 以 0e 开头且后接全数字的字符串
import hashlib
for i in range(100000, 1000000):
s = str(i)
h = hashlib.md5(s.encode()).hexdigest()
if h.startswith("0e") and h[2:].isdigit():
print(s, h) # 例如 240610708 -> 0e462097431906509019562988736854
break
这段脚本的工程意义在于说明:魔法哈希的密度并不低(约 1/10^7 量级),所以它不是「某个特定字符串的巧合」,而是一类可批量构造的输入。这也意味着基于「黑名单某个具体字符串」的防御毫无意义。
3. md5/sha1 数组绕过与 null 语义
比 0e 更「暴力」的手法利用了 PHP 的类型约束缺失:md5() 的签名要求 string,传入数组时在 PHP 7 下返回 NULL 并抛出 warning,在 PHP 8 下直接抛 TypeError。
<?php
// PHP 7.x 行为:md5(array) 返回 null,null === null 成立
// 请求 ?a[]=1&b[]=2 使两个参数都变成数组
var_dump(md5($_GET['a'] ?? []) === md5($_GET['b'] ?? [])); // PHP 7: bool(true)
// PHP 8.x:直接 TypeError,绕过失效
注意这个手法在 PHP 8 下已经失效,它同时说明了两件事:一是「绕过」往往依赖具体版本的容错行为;二是「报错信息」本身就是情报,题目里 display_errors=On 时 warning 会直接告诉你调用失败的原因。
检测与防御:入口处用 is_string() 校验并拒绝数组参数(框架的 $request->input() 一般已处理);生产环境关闭 display_errors、开启 log_errors,避免报错泄漏路径与内部结构;对形如 param[]= 的数组语法做入参白名单校验。
3.1 为什么 null 能通过校验
这个手法成立的关键不是 md5() 本身,而是校验逻辑假设了返回值一定是字符串。典型脆弱写法与加固写法对比:
<?php
// 脆弱:只要"两个哈希相等"就放行,未校验类型
if (md5($_GET['a']) === md5($_GET['b'])) { echo "pass"; }
// 加固:先确认是字符串,再比较
$a = $_GET['a'] ?? ''; $b = $_GET['b'] ?? '';
if (!is_string($a) || !is_string($b)) { http_response_code(400); exit; }
if (hash_equals(md5($a), md5($b))) { echo "pass"; }
审计时的通用检查项是:任何函数的返回值在使用前,是否假设了它的类型。返回 null / false 的函数(md5、strcmp、strpos、json_decode、preg_match)都是同类风险的来源。
4. intval 截断、科学计数法与进制陷阱
intval() 的转换规则是 CTF 里另一类高频考点,且版本差异极大:
<?php
var_dump(intval("1e3")); // PHP 8: int(1000);PHP 7: int(1)
var_dump(is_numeric("1e3")); // bool(true) 科学计数法被认作数字
var_dump(is_numeric("0x1A")); // bool(false) PHP 7 起十六进制字符串不再算数字
var_dump(intval("0x1A", 16)); // int(26) 显式指定进制才生效
var_dump(intval("123abc")); // int(123) 截断到第一个非数字字符
在 PHP 7 里 intval("1e3") 返回 1,而 is_numeric("1e3") 返回 true,这个组合意味着「通过了数字校验,但取整后完全变了样」。典型题目场景:校验金额上限用 is_numeric,实际计算用 intval,中间产生了不一致。PHP 8 统一了语义,把这类不一致收窄了。
检测与防御:校验与使用必须用同一个函数,推荐 filter_var($v, FILTER_VALIDATE_INT) 并在失败时直接拒绝;涉及金额一律用整数分或 bcmath,禁用浮点;类型声明 int 参数配合 strict_types=1 可让 "1e3" 直接抛错而不是静默转换。
4.1 典型脆弱场景复现
这类题目在 CTF 里的常见形态是「校验用一套逻辑、使用用另一套逻辑」:
<?php
// 本地靶场:校验认为输入合法,但取整后金额归零
$amount = $_GET['amount'] ?? '0';
if (!is_numeric($amount)) { exit("bad"); } // "1e3" 通过
if ($amount > 1000) { exit("too big"); } // 数值 1000,不触发
$real = intval($amount); // PHP 7: 1;PHP 8: 1000
echo "扣款: " . $real;
防御的正确姿势不是「补一个 intval」,而是只解析一次、只信一个值:用 filter_var 拿到最终的整数,后续所有逻辑都基于这个整数,杜绝二次转换。
5. strcmp、strpos 与 in_array 的绕过面
这三个函数都曾在「比较/查找」场景下被绕过,原理仍是不做类型检查:
<?php
// PHP 7.x:strcmp(array, string) 返回 NULL,NULL == 0 为 true
// 请求 ?pwd[]=x 即可让 strcmp 判定"相等"
var_dump(strcmp(["x"], "secret") == 0); // PHP 7: true / PHP 8: TypeError
// in_array 默认松散比较,"1abc" 在 PHP 7 下等于 1
var_dump(in_array("1abc", [1, 2, 3])); // PHP 7: true / PHP 8: false
strpos 在 PHP 7 下传数组同样返回 NULL,而 strpos($s, $needle) == false 这类写法在 needle 位于首位(返回 0)时也会误判。至于 in_array,正确的写法是第三个参数传 true 启用严格模式。
检测与防御:strcmp 比较口令改用 hash_equals()(恒定时间,天然防时序侧信道);in_array、array_search 一律传第三个参数 true;strpos 的判断必须写 !== false 而非 != false。这三条是代码审计里最容易发现也最容易修的缺陷。
5.1 时序侧信道与恒定时间比较
即便用 === 比较字符串,PHP 的内部实现也是逐字节比较、遇到不同立即返回,理论上可被时序分析推断前缀。对高价值比较(token、签名、重置口令),正确做法是恒定时间比较:
<?php
// 错误:普通比较存在提前返回,泄露前缀匹配长度
if ($token === $expected) { /* ... */ }
// 正确:hash_equals 无论内容如何都耗时恒定
if (hash_equals($expected, (string)$token)) { /* ... */ }
在 CTF 里这类题目偏少(网络抖动会淹没差异),但在真实系统的 API 鉴权、签名校验中是实打实的风险点,属于「知道原理、默认用对」的那一类。
6. 变量覆盖:extract 与 parse_str
extract() 会把数组的键值对注册为当前作用域的变量,如果它的输入来自用户且没有过滤,就能覆盖函数内已有变量——包括本该由代码控制的开关。
<?php
// 本地靶场:extract 覆盖了 $admin 的初始值
$admin = false;
extract($_GET); // 请求 ?admin=1 后 $admin 变成字符串 "1"
var_dump($admin); // string(1) "1",后续 if ($admin) 判断被绕过
parse_str($qs, $out) 在第二个参数省略时也会把结果注入当前作用域(PHP 8 起已强制要求第二个参数,属于历史包袱)。另外 $$var 可变变量、以及早已在 PHP 5.4 移除的 register_globals,都属于同一类「变量作用域被外部控制」的问题。
检测与防御:禁用 extract() 处理用户输入(代码规范层面直接禁止该函数),必须用时传 EXTR_SKIP 并加键名白名单;parse_str 永远传第二个参数接收结果;审计时搜索 extract(、parse_str(、$$、compact( 的调用点。
6.1 可变变量与作用域污染的变体
除了 extract,还有两类同源问题:
<?php
// 变体一:$$ 可变变量,间接控制变量名
$key = $_GET['k'] ?? 'safe';
$$key = $_GET['v'] ?? ''; // ?k=admin&v=1 等价于 $admin = "1"
// 变体二:parse_str 省略第二个参数(PHP 8 已强制要求)
parse_str($_SERVER['QUERY_STRING']); // PHP 7 会注入到当前作用域
两者共同的根因是**「变量名」本身可以被外部输入决定**。防御思路统一:任何来自外部的数据只允许进入显式的数据结构(数组的固定键、对象属性),不允许进入符号表。这也是为什么现代框架一律用 $request->input('name') 而不是「把请求参数导入变量」。
7. 文件包含与 PHP 伪协议
include / require 的参数若可控,就进入了文件包含的领域。CTF 里最经典的用法不是直接包含远程文件,而是用 php://filter 读取源码而非执行它:
# 本地靶场:把目标 PHP 文件按 base64 输出,绕过 PHP 解析器
curl "http://127.0.0.1:8080/?file=php://filter/convert.base64-encode/resource=index.php"
# 解码后即可看到源码,这是审计类题目的标准第一步
echo "PD9waHAgZWNobyAxOz8+" | base64 -d
其他常用伪协议包括 php://input(读取 POST body 作为流)、data://text/plain;base64,...(需 allow_url_include=On 才会被当作 PHP 执行)、以及 phar://(在文件操作函数里触发反序列化)。路径层面还有目录穿越(../../)与历史上 PHP 5.3.4 之前用 %00 截断后缀的手法。
检测与防御:包含的文件名必须走白名单映射(用 ID 查表,而不是拼接路径);关闭 allow_url_include(现代 PHP 默认关闭)与 allow_url_fopen;用 open_basedir 限制脚本可访问的目录;对 php://、data://、phar:// 出现在参数中的情况做 WAF 告警。
7.1 phar:// 为什么危险
phar:// 的特别之处在于:它不需要 include,只要文件操作函数(file_get_contents、file_exists、getimagesize、copy)的参数可控,就可能触发 phar 元数据的反序列化。这意味着很多「看起来只读文件」的功能点,实际上是一条反序列化入口。
触发条件(缺一不可)
1. phar 文件已存在于服务器上(通常通过上传功能投递,需绕过上传校验)
2. 代码中存在 unserialize 可用的 gadget 链
3. 某个文件操作函数的路径参数可控
防御:上传目录禁止脚本执行、校验 phar 文件签名、升级 PHP 并关闭不必要功能
它与第 8 节的反序列化是同一个问题的两种入口,因此防御措施也共用:只要不存在可用的 gadget 链,phar 反序列化就无从利用。
8. 反序列化与魔术方法 POP 链
PHP 反序列化漏洞的本质是:unserialize() 在还原对象时会自动触发一批魔术方法,而这些方法内部可能调用危险函数。常见魔术方法与触发时机:
| 魔术方法 | 触发时机 |
|---|---|
__wakeup() | unserialize() 还原对象时 |
__destruct() | 对象被垃圾回收时(脚本结束必然触发) |
__toString() | 对象被当字符串使用(如 echo、字符串拼接) |
__invoke() | 对象被当函数调用时 |
__get() / __set() | 访问不存在的属性时 |
__call() | 调用不存在的方法时 |
POP 链(Property-Oriented Programming)的构造思路是:从入口魔术方法出发,沿着「能调用另一个对象方法/属性」的路径,一路串到 system、eval、call_user_func 等危险函数。
<?php
// 本地靶场最小链示意(真实题目链更长、gadget 更多)
class A { public $b; function __destruct() { echo $this->b; } } // 入口
class B { public $cmd; function __toString() { return system($this->cmd); } }
// 构造:A->b = new B;B->cmd = "id"
// 反序列化后脚本结束触发 A::__destruct -> echo 对象 -> B::__toString -> system
在真实 CTF 中,POP 链的 gadget 通常来自框架自带的类库(如 Laravel、ThinkPHP),这也是 反序列化漏洞与 RCE 防御 里强调「不要反序列化不可信数据」的原因。
检测与防御:用 json_decode() 替代 unserialize() 处理外部数据;必须序列化时对数据加 HMAC 签名并在反序列化前校验;禁用 system、exec、eval、assert 等函数(disable_functions);对 unserialize 的输入做类型白名单(allowed_classes 参数,PHP 7 起支持)。
8.1 链是怎么被串起来的
POP 链的构造不是「找一个大漏洞」,而是把若干无害的小行为拼成一条可达路径。常见 gadget 来源与用途:
| gadget 来源 | 提供的能力 | 在链中的作用 |
|---|---|---|
框架的 __destruct | 自动触发,无需交互 | 链的入口 |
__toString | 对象转字符串 | 中转,让属性被当字符串处理 |
__call / __invoke | 动态方法调用 | 把控制流导向任意方法名 |
file_put_contents 包装类 | 任意文件写 | 写 webshell 或覆盖配置 |
call_user_func 包装类 | 任意函数调用 | 最终执行 system |
对应的检测思路不是「找出哪条链」,而是在反序列化的入口处做校验:给序列化数据加 HMAC(只有服务端能生成),并在 unserialize 时用 allowed_classes 限制可还原的类。即使链存在,攻击者也无法构造出合法的序列化载荷。
9. 无字母数字 webshell 的构造原理
最后一类技巧不是为了绕过某个具体函数,而是绕过字符层面的检测:当 WAF 或题目过滤掉所有字母和数字时,仍然可以用 PHP 的位运算构造出需要的字符串。
PHP 支持对字符串逐字节做异或(^)与取反(~),两个非字母数字字符异或就能得到字母:
<?php
// 原理演示(PHP 8.3 实测)
var_dump("#" ^ "`"); // string(1) "C" 35 ^ 96 = 67
var_dump("A" ^ " "); // string(1) "a" 65 ^ 32 = 97
// 取反构造:~"system" 的 URL 编码形式
// php -r 'echo urlencode(~"system");' -> %8C%86%8C%8B%9A%92
// 于是 ( ~%8C%86%8C%8B%9A%92 )( ... ) 在服务端被还原为 system( ... )
另外还有自增运算符:$s = "a"; $s++; 得到 "b","z"++ 得到 "aa",理论上可以逐步拼出任意标识符。这些技巧的共同点是让 payload 里不含明显的关键字,从而绕过基于正则的黑名单。
检测与防御:这类绕过针对的是「黑名单」,因此防御必须换成白名单(只允许预期的字符集与长度);检测层面可以统计参数中非字母数字字符的占比、位运算符与连续 URL 编码的出现频率,作为 WAF 告警规则;根本上还是要禁用 eval、assert、create_function 等动态执行函数,并禁止上传可执行脚本到 Web 目录。
9.1 检测这类 payload 的量化思路
既然字符被隐藏了,检测就不该依赖关键字,而要看统计特征。以下阈值仅为示意,实际需要在业务流量上做基线调优:
可疑参数特征(任一命中即告警)
非字母数字字符占比 > 40% # 正常业务参数极少如此
连续 URL 编码(%XX)出现 >= 5 次 # 取反构造的典型形态
参数中出现 ^ 或 ~ 且长度为 8 的倍数 # 逐字节异或/取反的形态
同一 IP 在 60 秒内提交大量 404 且参数随机
响应侧特征
PHP warning/notice 出现在响应体 # 说明触发了错误分支
响应中出现 disable_functions 相关报错
这类统计检测的误报率高于关键字匹配,因此实践中一般作为告警而非拦截,配合人工研判。真正决定成败的仍是第 8 节的那条原则:不给出动态执行能力,payload 再花哨也无处落地。
权衡取舍
同一种「绕过」在不同 PHP 版本、不同防御强度下的可行性差异极大,做题与写代码时都要先定位环境:
| 手法 | PHP 7 可行 | PHP 8 可行 | 根治性防御 |
|---|---|---|---|
"abc" == 0 类弱比较 | 是 | 否 | 用 === 加 strict_types |
0e 魔法哈希 | 是 | 是(只要仍用 ==) | hash_equals |
md5(array) 返回 null | 是 | 否(TypeError) | is_string 校验 |
intval("1e3") 语义不一致 | 是 | 否(语义统一) | FILTER_VALIDATE_INT |
strcmp(array, str) | 是 | 否(TypeError) | hash_equals |
extract($_GET) | 是 | 是 | 禁用 extract,白名单 |
| 伪协议文件包含 | 是 | 是(取决于 ini) | 白名单加 open_basedir |
unserialize POP 链 | 是 | 是 | allowed_classes 加 HMAC |
可以看出一个规律:PHP 8 修复了大量「容错行为」,但修复的是语义一致性,不是设计缺陷本身。extract、unserialize、文件包含这些「设计上就危险」的特性,跨版本依然存在,只能靠代码规范与配置封堵。
常见坑清单
- 照抄老 writeup 的 payload:现象是 payload 明明一样却打不通;原因是 PHP 8 修改了字符串与数字的比较语义;规避方法是先探测目标 PHP 版本再选手法。
==与===混用:现象是口令校验被0e或null绕过;原因是松散比较做类型转换;规避方法是安全相关判断一律用严格比较。- 哈希比较写
==:现象是不同口令通过了校验;原因是魔法哈希;规避方法是hash_equals()加===,口令用password_verify()。 - 参数未校验类型:现象是
md5(array)、strcmp(array)让校验失效;原因是没有is_string检查;规避方法是入口处做类型与白名单双重校验。 is_numeric与intval语义不一致:现象是「校验通过但数值变了」;原因是两函数对科学计数法处理不同;规避方法是校验与使用用同一个函数。in_array忘了第三个参数:现象是"1abc"被判为等于 1;原因是默认松散比较;规避方法是显式传true。extract处理用户输入:现象是内部变量被外部覆盖;原因是作用域污染;规避方法是禁用 extract,必须用时加EXTR_SKIP与白名单。- 包含路径直接拼接:现象是伪协议或目录穿越读到源码;原因是没有白名单映射;规避方法是 ID 查表加
open_basedir。 - 反序列化不可信数据:现象是触发
__destruct链执行命令;原因是魔术方法自动调用;规避方法是allowed_classes白名单加 HMAC 签名,优先改 JSON。 display_errors开着上生产:现象是报错信息直接泄漏路径与调用栈;原因是调试配置带到了线上;规避方法是关display_errors、开log_errors。
小结
PHP 的绕过技巧看似零散,其实只围绕两条主线:一是类型系统在边界处的隐式转换(弱比较、intval、is_numeric、md5 的容错),二是语言特性带来的作用域与执行面(extract、文件包含、反序列化、动态执行)。前者随 PHP 8 的语义统一大幅收窄,后者则跨版本长期存在,只能靠编码规范与配置封堵。理解这条分界线,比记住二十个 payload 有用得多。
从防御视角看,这些题目给出的启示非常直接:不要依赖函数的容错行为做安全判断,不要反序列化不可信数据,不要把用户输入拼进包含路径。把第 9 节的检测思路落到 WAF 与日志上,把「权衡取舍」表里的根治性防御落到代码规范里,这类漏洞在真实项目中的出现概率会显著下降。
下一步建议继续沿着 Web 主线推进:Web 方向 SQL 注入与 WAF 绕过 讲注入类漏洞与绕过技巧,Web 方向 SSTI 与 SSRF 利用链 进入模板注入与服务端请求伪造;如果对本文第 8 节的对象链感兴趣,可以对照本站 反序列化漏洞与 RCE 防御 建立防御侧认知,工具层面的展开见 CTF 工具链与攻击视角下的防御。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。