【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,已经将这个问题修复了。新版本保证了计算索引使用索引 在同一个字符串上进行,彻底消除了索引错位的可能。

相关推荐
@hdd10 小时前
生产环境最佳实践:资源管理、高可用与安全加固
安全·云原生·kubernetes
AC赳赳老秦14 小时前
文旅AI趋势:DeepSeek赋能客流数据,驱动2026智慧文旅规模化跃迁
人工智能·python·mysql·安全·架构·prometheus·deepseek
Bruce_Liuxiaowei17 小时前
深入剖析 Windows 网络服务:用 witr 一键溯源所有监听端口
windows·安全·系统安全
сокол18 小时前
【网安-Web渗透测试-漏洞系列】RCE漏洞
web安全·php
xiejava101818 小时前
网络安全资产画像实战
安全·web安全·网络安全
Jerry_Gao92120 小时前
【CTF】【ez-rce】无字母数字绕过正则表达式
正则表达式·php·ctf
菩提小狗1 天前
第16天:信息打点-CDN绕过&业务部署&漏洞回链&接口探针&全网扫描&反向邮件_笔记|小迪安全2023-2024|web安全|渗透测试|
笔记·安全·web安全
~央千澈~1 天前
优雅草正版授权系统 - 优雅草科技开源2月20日正式发布
python·vue·php·授权验证系统
DeeplyMind1 天前
第25章 Docker安全
安全·docker·容器