Web 方向 PHP 特性与绕过技巧

系统梳理 CTF Web 方向里 PHP 语言特性导致的绕过手法:弱类型比较与 0e 魔法哈希、md5 数组绕过与 null 语义、intval 截断与科学计数法、strcmp 与 in_array 绕过、extract 变量覆盖、伪协议文件包含、反序列化魔术方法与 POP 链、无字母数字 webshell 构造原理,每个手法都配检测与防御方案。

引言

PHP 是 CTF Web 方向出现频率最高的语言,原因不是它「不安全」,而是它的动态类型系统与历史遗留设计制造了大量语义陷阱:同一个 ==,在 PHP 7 和 PHP 8 下答案可能相反;同一个 md5() 调用,传入字符串和传入数组会走到完全不同的分支。这些陷阱在企业代码里是隐蔽缺陷,在 CTF 里就是一道题的核心考点。

本文讨论的是语言语义层面的绕过,而非框架或组件漏洞。这类题的特点是:不依赖任何第三方库、不需要复杂的环境,往往二十行 PHP 源码就能构成一道中等难度题。也正因为如此,它们最能检验「是否真正读懂了一门语言」,而不是「是否背过 payload 字典」。

需要先明确语境:本文所有示例都以本地靶场最小复现为限,目的是理解语义与建立检测意识,不针对任何真实生产系统。每一节都会给出对应的「检测与防御」,因为同一个知识点在攻防两侧的价值是相通的——理解了绕过原理,才知道该在代码、WAF 还是日志层面拦截它。

阅读顺序上,第 1 到第 5 节围绕「比较与类型转换」这条主线,第 6 到第 8 节进入「变量与对象」,第 9 节讨论绕过检测的手段本身。建议在本地用 Docker 起一个 PHP 8.2 环境边读边验证,PHP 版本差异是这类题最大的变量。

目录

  1. 弱类型比较:== 与 === 的语义差异
  2. 魔法哈希与 0e 绕过
  3. md5/sha1 数组绕过与 null 语义
  4. intval 截断、科学计数法与进制陷阱
  5. strcmp、strpos 与 in_array 的绕过面
  6. 变量覆盖:extract 与 parse_str
  7. 文件包含与 PHP 伪协议
  8. 反序列化与魔术方法 POP 链
  9. 无字母数字 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、文件包含这些「设计上就危险」的特性,跨版本依然存在,只能靠代码规范与配置封堵。

常见坑清单

  1. 照抄老 writeup 的 payload:现象是 payload 明明一样却打不通;原因是 PHP 8 修改了字符串与数字的比较语义;规避方法是先探测目标 PHP 版本再选手法。
  2. == 与 === 混用:现象是口令校验被 0e 或 null 绕过;原因是松散比较做类型转换;规避方法是安全相关判断一律用严格比较。
  3. 哈希比较写 ==:现象是不同口令通过了校验;原因是魔法哈希;规避方法是 hash_equals() 加 ===,口令用 password_verify()。
  4. 参数未校验类型:现象是 md5(array)、strcmp(array) 让校验失效;原因是没有 is_string 检查;规避方法是入口处做类型与白名单双重校验。
  5. is_numeric 与 intval 语义不一致:现象是「校验通过但数值变了」;原因是两函数对科学计数法处理不同;规避方法是校验与使用用同一个函数。
  6. in_array 忘了第三个参数:现象是 "1abc" 被判为等于 1;原因是默认松散比较;规避方法是显式传 true。
  7. extract 处理用户输入:现象是内部变量被外部覆盖;原因是作用域污染;规避方法是禁用 extract,必须用时加 EXTR_SKIP 与白名单。
  8. 包含路径直接拼接:现象是伪协议或目录穿越读到源码;原因是没有白名单映射;规避方法是 ID 查表加 open_basedir。
  9. 反序列化不可信数据:现象是触发 __destruct 链执行命令;原因是魔术方法自动调用;规避方法是 allowed_classes 白名单加 HMAC 签名,优先改 JSON。
  10. 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 工具链与攻击视角下的防御。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「网络安全攻防」更多文章

  1. 流量分析与协议逆向
  2. 椭圆曲线与格攻击
  3. Windows 提权与 AD 内网渗透