【CTF】【ez-upload】FrankenPHP(v1.11.1)Unicode路径解析漏洞

源码分析

访问实例,可以看到源码

php 复制代码
<?php
$action = $_GET['action'] ?? '';
if ($action === 'create') {
  $filename = basename($_GET['filename'] ?? 'phpinfo.php');
  file_put_contents(realpath('.') . DIRECTORY_SEPARATOR . $filename, '<?php phpinfo(); ?>');
  echo "File created.";
} elseif ($action === 'upload') {
  if (isset($_FILES['file']) && $_FILES['file']['error'] === UPLOAD_ERR_OK) {
    $uploadFile = realpath('.') . DIRECTORY_SEPARATOR . basename($_FILES['file']['name']);
    $extension = pathinfo($uploadFile, PATHINFO_EXTENSION);
    if ($extension === 'txt') {
      if (move_uploaded_file($_FILES['file']['tmp_name'], $uploadFile)) {
        echo "File uploaded successfully.";
      }
    }
  }
} else {
  highlight_file(__FILE__);
}

主要有两个功能:生成特定文件 以及上传文本文件

1. create :生成 phpinfo 文件

通过 $_GET['filename'] 获取文件名(默认为 phpinfo.php),但是文件内容固定为 <?php phpinfo(); ?>。并且使用 basename() 函数提取文件名,防止通过 ../ 进行目录穿越攻击。

2. upload :上传 .txt 文件

允许上传任意内容的文件,但是后缀只能是txt


既然给了我们phpinfo,那我们就查看一下

发现了FrankenPHP

什么是FrankenPHP

FrankenPHP 是一款基于 Go 语言编写、构建在 Caddy Web 服务器之上的现代 PHP 应用服务器,它通过将 PHP 解释器直接嵌入 Caddy,实现了比传统 PHP-FPM 架构更卓越的性能和更简化的部署流程。

与传统ServerAPI的区别:
维度 传统 PHP-FPM / FastCGI FrankenPHP (Worker Mode)
进程模型 每一请求都会重新初始化环境(无状态)。 常驻内存:应用启动一次,循环处理请求。
启动开销 每次请求都要重新加载 vendor、配置和类。 零重复加载:代码只加载一次,速度极快。
依赖关系 需要 Nginx + PHP-FPM 两个组件协作。 单进程:Web Server (Caddy) 与 PHP 深度集成。
连接协议 内部通过 Unix Socket 或 TCP 交换数据。 进程内通信:直接在 Go 与 C++ 层面交互。
部署形态 复杂的环境配置,需安装多个包。 单文件可执行:应用代码可直接打成二进制包。
为什么 FrankenPHP 更先进?

1. 消除"启动性能损失"

在 Laravel 或 Symfony 等重型框架中,加载框架代码往往占据了请求处理时间的 50% 以上。FrankenPHP 通过 Worker Mode 让框架常驻内存,请求进来时直接进入路由处理阶段,响应延迟(Latency)大幅降低。

2. "真正的"协程与现代功能

由于它构建在 Go 语言的 Caddy 之上,FrankenPHP 继承了 Go 的并发优势:

  • 103 Early Hints:在服务器准备响应时提前告知浏览器加载资源,让网页加载更快。
  • 实时推送:原生内置了 Mercure 协议,处理 WebSocket 级别的实时交互变得极其简单。

3. 部署的"容器化"思维

以前部署 PHP 需要配置 nginx.conf、调整 php.inipool.conf。FrankenPHP 可以让你把代码和整个运行环境打包成一个二进制文件,直接扔到服务器上运行,不需要安装任何依赖。

注意: 由于 Worker Mode 下代码常驻内存,开发者需要更小心地处理内存泄漏全局变量污染(就像在 Node.js 或 Go 中开发一样)。

漏洞点

翻阅FrankenPHP的官方仓库(Version1.11.1),查看解析URL路径的源码./cgi.go

https://github.com/php/frankenphp/blob/v1.11.1/cgi.go

来到splitPossplitCgiPath这两个Function

go 复制代码
func splitPos(path string, splitPath []string) int {
    if len(splitPath) == 0 {
        return 0
    }

    lowerPath := strings.ToLower(path)  // 👈 问题出在这里!
    for _, split := range splitPath {
        if idx := strings.Index(lowerPath, strings.ToLower(split)); idx > -1 {
            return idx + len(split)
        }
    }
    return -1
}
go 复制代码
func splitCgiPath(fc *frankenPHPContext) {
    path := fc.request.URL.Path
    // ...
    if splitPos := splitPos(path, splitPath); splitPos > -1 {
        fc.docURI = path[:splitPos]      // 👈 用原始 path 切片!
        fc.pathInfo = path[splitPos:]    // 👈 用原始 path 切片!
        fc.scriptName = strings.TrimSuffix(path, fc.pathInfo)
        // ...
    }
    fc.scriptFilename = sanitizedPathJoin(fc.documentRoot, fc.scriptName)
    // ...
}

可以看到关键问题就出在索引计算字符串切片 使用的是不同的字符串

splitPos 函数:

  1. 把 path 转成小写 → lowerPath
  2. 在 lowerPath 上查找 ".php" 的位置 → idx
  3. 返回这个 idx 作为分割位置

splitCgiPath 函数:

4.再用 idx 在【原始 path】上切片

这看起来没问题,对吧? 因为 ASCII 字符转小写不会改变长度,但是Unicode 世界不一样,有些 Unicode 字符在转小写时长度会改变!

最经典的例子是字符 Ⱥ (U+023A,拉丁大写字母 A 带一横):

字符 Unicode UTF-8 编码长度
Ⱥ (大写) U+023A 2 字节
ⱥ (小写) U+2C65 3 字节

Ⱥ 是 2 字节,但转小写后 ⱥ 变成 3 字节,也就是说:
每出现一个 Ⱥ,小写后的字符串就会多出 1 个字节


什么意思呢?比如我们现在上传一个ȺȺȺȺshell.php.txt,然后get请求ȺȺȺȺshell.php.txt.php,FrankenPHP会这样解析:

Step 1:splitPos() 小写化路径:

go 复制代码
lowerPath := strings.ToLower(path)

原始 path 的字节布局:

txt 复制代码
/   Ⱥ     Ⱥ     Ⱥ     Ⱥ     s   h   e   l   l   .   p   h   p   .   t   x   t   .   p   h   p
2F  C8BA  C8BA  C8BA  C8BA  73  68  65  6C  6C  2E  70  68  70  2E  74  78  74  2E  70  68  70
0   1-2   3-4   5-6   7-8   9   10  11  12  13  14  15  16  17  18  19  20  21  22  23  24  25
    ↑     ↑     ↑     ↑
   每个 Ⱥ 是 2 字节

小写后 lowerPath 的字节布局:

txt 复制代码
/   ⱥ       ⱥ       ⱥ       ⱥ       s   h   e   l   l   .   p   h   p   .   t   x   t   .   p   h   p
2F  E2B1A5  E2B1A5  E2B1A5  E2B1A5  73  68  65  6C  6C  2E  70  68  70  2E  74  78  74  2E  70  68  70
0   1-3     4-6     7-9     10-12   13  14  15  16  17  18  19  20  21  22  23  24  25  26  27  28  29
    ↑       ↑       ↑       ↑
   每个 ⱥ 是 3 字节(膨胀了)

Step 2:在 lowerPath 上查找 .php

go 复制代码
if idx := strings.Index(lowerPath, ".php"); idx > -1 {
    return idx + len(".php")
}

在 lowerPath 上,第一个 .php 的位置是:

txt 复制代码
lowerPath: /   ⱥ       ⱥ       ⱥ       ⱥ       s   h   e   l   l   .   p   h   p   ...
位置:      0   1-3     4-6     7-9     10-12   13  14  15  16  17  18  19  20  21  ...
                                                                   ↑
                                                          第一个 .php 在位置 18

返回值: splitPos = 18 + 4 = 22

Step 3:用错误索引分割原始路径

go 复制代码
fc.docURI = path[:splitPos]    // path[:22]
fc.pathInfo = path[splitPos:]  // path[22:]

现在在【原始 path】上用索引 22 切片:

plain 复制代码
原始 path:
/   Ą     Ą     Ą     Ą     s   h   e   l   l   .   p   h   p   .   t   x   t   .   p   h   p
2F  C8BA  C8BA  C8BA  C8BA  73  68  65  6C  6C  2E  70  68  70  2E  74  78  74  2E  70  68  70
0   1-2   3-4   5-6   7-8   9   10  11  12  13  14  15  16  17  18  19  20  21  22  23  24  25
                                                                                 ↑
                                                                           位置 22 在这里!

切片结果:

  • SCRIPT_NAME:ȺȺȺȺshell.php.txt
  • PATH_INFO:.php

服务器把shell.php.txt文件当成php直接执行了

补充:
SCRIPT_NAME PATH_INFO 是两个非常关键的环境变量。它们共同决定了 Web 服务器如何将一个 URL 映射到你的程序代码上。

简单来说,它们把一个完整的 URL 路径像"切蛋糕"一样分成了两部分:"你是谁"和"你要去哪"。
SCRIPT_NAME(脚本路径):

指的是 脚本/应用程序本身 在 URL 空间中的位置,它告诉 Web 服务器:应该运行哪一个程序
PATH_INFO(额外路径信息):

指的是 URL 中 脚本名称之后、查询参数(?)之前 的那部分路径,它告诉应用程序:在这个程序内部,用户想要访问哪个资源或路由,通常被现代 Web 框架(如 Flask, Django, Gin)用来做路由匹配
现代框架通过读取 PATH_INFO 来决定调用哪个函数。例如:

如果PATH_INFO/login , 执行 show_login_form(),如果是/user/profile,执行get_profile()


Cat Flag

1.先上传txt文件

disable_function没有禁用system

2.创建触发文件

访问 URL 创建文件:?action=create&filename=ȺȺȺȺshell.php.txt.php
3.触发RCE

  • 访问 URL:/ȺȺȺȺshell.php.txt.php?cmd=cat /flag

总结

本题利用了 FrankenPHP 在处理 Unicode 字符时的 case-folding 漏洞。通过构造包含特殊 Unicode 字符(Ⱥ)的文件名,利用大小写转换后字符串长度不一致的特性,绕过了文件扩展名限制,成功将 .txt 文件作为 PHP 执行,最终实现 RCE 并获取 flag。

参考文章:

https://internethandout.com/post/ezupload

https://www.olsp.top/changanCTF/0CTF-2025-ezupload/

后记

可以看到截止目前的FrankenPHP最新版本V1.11.2,已经将这个问题修复了。新版本保证了计算索引使用索引 在同一个字符串上进行,彻底消除了索引错位的可能。

相关推荐
门思科技1 小时前
LoRaWAN 设备类别:Class A、B、C 对比与选型指南
c语言·开发语言·php
BullSmall4 小时前
第三方软件安全-Dependency‑Track Docker 部署
安全·docker·容器
AImatters5 小时前
国产算力底座,支撑油气储运数字化迈过关键分水岭
安全·cpu·算力·海光·油气储运
叠叠乐8 小时前
中国移动家庭云电脑window关闭所有安全脚本
安全
Jmyd01238 小时前
实训室的 3D 模型涉及肖像文物,数据安全与合规怎么做?
安全·3d·数据安全·虚拟实训
芯盾时代8 小时前
《金融业网络安全管理办法(征求意见稿)》全条款深度拆解(三)
网络·安全·网络安全
敢敢是只喵i9 小时前
一个本地 AI Agent 要操作多个门店或 SaaS 账号,应该怎样安全切换身份?
人工智能·安全·ai·系统架构·业界资讯
Acrel12349 小时前
DC800V 大规模落地,算力机房安全该如何保障
安全
其实防守也摸鱼10 小时前
权限提升与横向移动:从内网渗透到域控的完整技术图谱
运维·服务器·数据库·安全·github·copilot·渗透
深圳海导科技10 小时前
资产防盗追踪系统:物联网高价值资产安全管理|海导科技navynav
科技·物联网·安全