UMassCTF 2026
Solutions to most challenges
Disclaimer: AI-generated solutions, let me know if there are any errors.
binary_exploitation
Brick City Office Space
Description
The binary prints a building template, asks for ASCII art, and then prints the supplied input back into the middle of the building. The hint points directly at a format string issue.
Relevant properties:
32-bit ELF
NX enabled
No PIE
No canary
No RELRO
The bug is in vuln(): user input is passed directly to printf, so the program has a format string vulnerability.
Solution
Because the binary is dynamically linked and the challenge ships its own libc.so.6, the cleanest path is:
Leak a libc address from
printf@got.Compute the libc base.
Overwrite
printf@gotwithsystem.On the next prompt, send
cat flag.txt.
The input buffer itself is also on the stack, so appended addresses can be referenced by positional format arguments. Empirically:
fmtstr_payload(..., offset=4)is correct for writes.Appending
p32(printf_got)and reading it with%7$sreliably leaks the resolvedprintfpointer.
After the overwrite, the program still does printf(user_input), but now that call is effectively system(user_input), so sending cat flag.txt prints the flag.
Full solve script:
Recovered flag:
Brick Workshop
Description
The binary exposes a simple menu. Option 3 enters the diagnostics flow:
On the first visit, it asks for
mold_idandpigment_code, stores a globalservice_initialized = 1, and returns.On later visits, it calls
diagnostics_bay(mold_id, pigment_code).
The bug is that mold_id and pigment_code are local variables in workshop_turn() and are only initialized during the first diagnostics call. On the second call they are reused uninitialized, which means the same stack slots still contain the previously entered values.
Relevant logic:
The win condition is:
Choose pigment_code = 0xBEEF = 48879.
Then:
Also, 0xBEEF & 0x43 == 0x43, so the (((mold_id >> 2) & 0x43) | pigment_code) part stays equal to pigment_code regardless of mold_id. That means any mold_id works; 0 is fine.
Solution
Exploit steps:
Choose menu option
3.Enter
0 48879.Choose menu option
3again.
On the second visit, the binary reuses the old stack values and reaches win().
Minimal exploit:
One-shot shell version:
Recovered flag:
Factory Monitor
Description
Binary exploitation challenge (500 pts). A factory monitor CLI binary forks child processes for "machines." The binary is a 64-bit static-PIE ELF with Full RELRO, NX, PIE, and stack canaries in libc functions (but not in user functions).
Solution
Vulnerability: The read_line_fd() function reads bytes one at a time into a buffer with no bounds check, causing a stack buffer overflow in machine_main_demo() (256-byte msg buffer) and potentially in cli_recv() (parent's 256-byte buffer).
Key observations:
No stack canary in user functions (
machine_main_demo,cli_recv, etc.)Child processes are automatically restarted by
machine_monitor()when they crash (signal) or exit with non-zero status, but NOT when they exit with status 0The
call exitinstruction at binary offset0xb457is reachable by overwriting only the lowest byte of the return address (original at0xb43d)This creates an oracle: overwrite partial return address, send "exit" to trigger return, then
monitorto distinguish "exited successfully" (correct address) vs "killed by signal" (wrong address)
Phase 1 - PIE base brute force (byte-by-byte):
Overwrite the return address of machine_main_demo one byte at a time, starting from byte 0 (known: 0x57 from the call exit offset). For each subsequent byte, try all candidates and check the child's exit behavior via monitor:
"exited successfully" (exit code 0) = correct byte, manually
cleanup+start+recv"killed by signal" = wrong byte, machine auto-restarts, just
recv
Byte 1 has 16 candidates (PIE page alignment), bytes 2-5 have 256 candidates each.
Phase 2 - ROP chain:
After recovering the full PIE base, build a ROP chain using gadgets from the static binary:
pop rdi; pop rbp; ret(0xc028)pop rsi; pop rbp; ret(0x15b26)ret(0x901a)
And call binary functions directly:
read_line_fd(pipe_fd=3, bss_path)- read "/ctf/flag.txt" from parent pipe into BSSopen(bss_path, O_RDONLY)- open flag file (returns fd 4)read_line_fd(4, bss_buf)- read flag content into BSSputs(bss_buf)- output flag to stdout (socat socket)
Using read_line_fd instead of read() avoids needing to set rdx (3rd argument), which only had pop rdx; leave; ret available (complicated by the stack pivot). BSS addresses are dynamically chosen to avoid 0x0a (newline) bytes.
Payload flow:
send 0 <padding + ROP chain>- overflow child buffersend 0 exit- trigger return, ROP chain starts, blocks on pipe readsend 0 /ctf/flag.txt- feed flag path to ROP chain'sread_line_fdChild's
putsoutputs flag directly to our socket
Flag: UMASS{AsLR_L3Ak}
cryptography
The Accursed Lego Bin
Description
The challenge encrypts the string I_LOVE_RNG with textbook RSA using e = 7, calls the ciphertext seed, then writes out seed^7 mod n and a flag whose bits were shuffled ten times with Python's random.shuffle.
Solution
The RSA step is broken because the plaintext is tiny:
m = int.from_bytes(b"I_LOVE_RNG", "big")is 79 bits.m^7is only 548 bits, som^7 < nfor a 4096-bit RSA modulus.The published
seedis actually(m^7)^7 mod n = m^49, andm^49is still only 3832 bits, so this is also belown.Therefore the huge integer in
output.txtis exactlyint.from_bytes(b"I_LOVE_RNG", "big")**49.
Once m is known, the original PRNG seed used by the program is just m^7. Recreate the ten shuffles on an index list of the correct bit length, then apply the inverse permutations in reverse order to undo the scrambling.
Recovered flag:
Unfinished Ninjago Game
A binary implements a text adventure game built around xoshiro512, a 512-bit linear PRNG (8 x 64-bit words). On each connection:
The PRNG state
s[0..7]is seeded viagetrandom()(512 random bits)The flag (up to 64 bytes) is read into a buffer pre-filled with
getrandom()outputThe ciphertext
ct[i] = buffer[i] XOR state_byte[i](8 uint64 words) is printedThe player can issue commands including:
m("middle"): observe(sum(s[i] % 101 for i in range(8))) % 101(one byte)Various navigation commands that trigger PRNG jumps (advance by
2^ksteps)
A stack-based stale-pointer vulnerability allows the player to trigger arbitrary jump powers (2^0 through 2^496) on demand, giving full control over which PRNG state is observed.
1. The observation is linear over GF(101)
The explore_middle() function computes (s[0]%101 + s[1]%101 + ... + s[7]%101) % 101. Confirmed by disassembly: each word is reduced mod 101 individually before summing, so there is no uint64 overflow.
Each word s[w] = sum_b 2^b * bit_{w,b} has residue s[w] % 101 = sum_b (2^b mod 101) * bit_{w,b} mod 101. So the observation is a linear function of the 512 state bits over GF(101):
where c_b = 2^b mod 101.
2. Step-0 observations avoid XOR nonlinearity
After next() is called, each state bit becomes the XOR (parity) of multiple original state bits. Computing sum mod 101 of XOR parities creates a "mixed modulus" problem: XOR is GF(2)-linear while the observation is Z/101Z-linear. The composition is nonlinear over both fields.
At step 0, however, each state bit is just itself -- the identity mask. No XOR combining occurs, so the observation is perfectly linear over GF(101).
3. The flag is exactly 64 bytes
This is the critical insight. With flag_len = 64, the flag fills the entire 64-byte buffer. There are no random tail bytes, so state = flag XOR ct with flag shared across all connections.
Each connection's step-0 observation gives one linear equation in the 512 flag bits over GF(101). With enough connections, the system is solvable.
4. Multi-connection linear system
For connection j with ciphertext bits ct_j and observation obs_j:
Since ct_j_bit[i] is known, flag_bit XOR ct_bit is linear in flag_bit:
If
ct_bit = 0: coefficient is+cIf
ct_bit = 1: coefficient is-c, plus constant+c
This gives: A * flag_bits = rhs (mod 101) with 600 equations in 512 unknowns.
Data Collection
Collected 600 step-0 observations from independent connections (linear_pairs.json). Each entry contains the ciphertext (8 uint64 words) and the observation value.
Solving
Built the 600x512 coefficient matrix A and RHS vector rhs over GF(101), then solved via Gaussian elimination:
The system has full rank (512/512). The unique solution is perfectly binary (all values 0 or 1), confirming the flag length is exactly 64. Assembling the bits into bytes gives the flag.
Verification
Cross-verified against all 600 connections: predicted observations match actual observations for every connection. Also verified against independent data (fresh512.json step-0 observation).
Hens and Roosters
Description
The service gives each fresh uid zero studs and lets /work increment the stud count if you submit a valid UOV signature for the current payload "{studs}|{uid}". Reaching 7 studs and then calling /buy returns the flag.
Two issues make this exploitable:
The public key is enough to sign. The 57 public quadratic forms all share the same 57-dimensional right kernel, which exposes the oil space directly. After changing basis so the oil variables are last, the public key has the usual UOV shape and we can solve for oil variables using only the public key.
/workcaches verification by the raw hex string, not by the decoded bytes. Hex is accepted in mixed case, so the same signature bytes can be sent under many different spellings./workalso readsstudsbefore verification, so a burst of valid mixed-case encodings for0|uidcan all increment the same account from the same starting state.
The working live strategy was:
Get a fresh
uid.Forge a valid signature for
0|uid.Send 8 mixed-case encodings of that same signature with unique query strings.
Wait for the backend to process them.
Redeem once with
/buy.
Solution
This returned:
forensics
Click Here For Free Bricks
Description
We are given a packet capture of a malware download and asked for the VirusTotal name of the malware in the format UMASS{[String]_[Sha256 Hash]}.
Solution
First, inspect the description and extract the HTTP objects from the PCAP:
This shows the victim downloading:
Read the installer:
It decrypts ./launcher in place with a NaCl SecretBox key derived from:
Decrypt the payload:
Hash it:
Result:
The original challenge text is slightly misleading. The live challenge page says the answer is the malware name on VirusTotal under the Details tab, and gives an example where the string appears as String_sha256.
To inspect the VT details without an API key, load the public UI JSON or the rendered page:
One of the names in the VT Details tab is:
That directly matches the challengeโs expected String_sha256 pattern, so:
Lost and Found
Description
Help! I was running commands on my ultra minimalistic Linux VM when I installed my favorite package and everything turned into nonsense!
Solution
The challenge gives an .ova containing an Alpine VM. The fastest path was to inspect it offline instead of booting it.
Extract the OVA and decompress the disk:
The root filesystem is partition 3, starting at sector 3430400. Mount it read-only with fuse2fs:
Root shell history immediately gives the important lead:
Relevant commands:
The installed tool is the Rust crate xor, which recursively XOR-encrypts file contents and renames files by XORing the name and hex-encoding it. The crate source is still present on disk:
That README explains the exact rename format. The repeated filename 08555D451D131A075A5D0E is clearly red-herring, so we can recover the beginning of the key. Then the known decoy file content kajdsfojczvioxjoij3\n extends it. Finally, the standard .git/hooks/pre-rebase.sample from git init reveals a full 512-byte repeating XOR key stream.
Key recovery:
Using that keystream, decrypt the mounted /home tree into a local working copy:
The repo is still slightly broken because the find loop also created red-herring files inside .git, but the reflogs are readable directly:
Output:
Flag:
Ninja-Nerds
Description
The attached challenge.png is a PNG with no useful metadata, no appended data, and no extra chunks. The intended path is simple pixel forensics, not reverse-image searching the Ninjago frame.
Solution
This challenge has a very high solve count because the flag is directly embedded in the image bits in a straightforward way.
The winning extraction is:
channel: blue
bit count: 1
traversal: row-major (
xy)byte packing: MSB-first
That means:
Read the image as RGB.
Take the least significant bit of every blue pixel.
Walk pixels left-to-right, top-to-bottom.
Pack every 8 bits into a byte, most-significant-bit first.
Search the resulting byte stream for
UMASS{.
Code:
Output:
Flag: UMASS{perfectly-hidden-ready-to-strike}
Doomed Demo
Description
The provided demo.lmp does not replay directly, but WALKTHROUGH.txt gives the intended route on Freedoom 0.13.0 MAP03: Crude Processing Center. The goal is to recover the player's final Doom fixed-point x and y coordinates, convert both to hexadecimal, and concatenate them as UMASS{...}.
Solution
The file has two layers of damage:
The demo header is broken. The playable ticcmd stream starts after 14 junk bytes.
Several weapon-select button bytes inside the tic stream are corrupted.
Rebuilding a normal vanilla header over demo.lmp[14:] gives a partially working replay. Replaying that against an instrumented Chocolate Doom build and comparing the logged events against WALKTHROUGH.txt shows four bad weapon-change regions:
864-865: should select pistol (12), not41705-1707: should select chaingun (28)2617-2618: should select shotgun (20)3936-3937: should select shotgun (20)
After applying those fixes, the recovered demo follows the walkthrough all the way to the final exit-lift button. The stable end position is:
raw_x = 240777950->E59FADEraw_y = -22218853->FEACF79Bas 32-bit two's complement hex
So the flag is:
Patch script:
Example replay command with the locally instrumented Chocolate Doom build:
That replay ends with:
hardware
Brick by Brick
Description
We are given a CSV capture of a digital signal intercepted from a "custom LEGO controller" and need to recover the hidden message.
Solution
The CSV is not event-based; it is a uniformly sampled logic trace.
The key observation is that the signal has a 15-sample structure. When chunking the bitstream into 15-bit blocks and trying all 15 alignments, every block contains the constant pattern 01111110 at a fixed position for a given alignment. At offset 13, each block becomes:
So the capture can be interpreted as repeated 15-bit symbols made of a fixed 0x7e marker plus 7 payload bits.
The next important step is that those 7 payload bits are transmitted bit-reversed. Reversing each 7-bit payload and interpreting it as ASCII produces a clean Linux boot log. Near the end of the decoded text is:
That value is hex, which decodes to:
Solution script:
Flag:
Smart Brick v2
Description
The attachment is a single KiCad PCB file. There is no firmware or schematic, just a board full of 74LSxx logic, a 7-pin input header, power, and 19 LEDs driven by MOSFETs.
The useful observation is that the board is purely combinational:
J1exposes 7 data inputs,IN0..IN6J2is+5V/GNDeach LED is controlled by a logic net through a
2N7002
So the job is to recover the boolean network from the PCB, simulate all 2^7 = 128 possible inputs, and see which LEDs turn on for which inputs.
Solution
I parsed the PCB file directly, extracted every gate chip's pad-to-net mapping, and simulated the logic network. The resulting truth table is extremely sparse: only a small set of input values ever light LEDs.
That makes the intended behavior clear: each 7-bit input value represents a character, and the lit LEDs mark the positions where that character appears in the secret string.
One subtlety is bit order. The input header is wired so that the natural character value is the reversed bit string IN6..IN0, not IN0..IN6. After reversing the 7-bit inputs, the active characters become:
U, M, A, S, S, {, I, n, _, T, h, 3, _, G, 4, t, 3, s, }
Reading the LEDs from D1 through D19 gives:
UMASS{In_Th3_G4t3s}
Solver code:
Running it prints:
miscellaneous
Deep Down
Description
There's something in the water...
Solution
deep-down.zip contains a single file, CHALL.gif.
Initial checks showed:
no appended payload
no useful metadata/comments
12 GIF frames
a very small file size, which suggested palette/index abuse rather than a large hidden blob
The key detail is the GIF global palette. It contains duplicate-looking entries:
index
1and index3are both(11, 41, 71)index
4and index6are both near-identical yellow values
When the GIF is rendered normally, those duplicate palette entries collapse to the same visible colors, so the hidden information does not show up. The solve is to parse the raw GIF image data, LZW-decode the first frame, and distinguish palette index 1 from palette index 3.
Doing that reveals hidden text embedded in the seabed region. Reading the extracted text gives the flag:
UMASS{1N_A_G1774}
Solve script:
Running the script produces an image where the hidden text is readable, yielding:
UMASS{1N_A_G1774}
Take a Slice
Description
We are given take-a-slice.zip, which contains a single file named cake.
The challenge hint is "It's in the name!", so the first step is to identify what cake actually is.
cake is a binary STL:
The first 80 bytes are the STL header.
Bytes
80:84are the triangle count.The total file size matches
84 + 50 * triangle_count.
That means the attachment is a 3D mesh, and "Take a Slice" strongly suggests slicing the model.
Solution
The STL contains one large connected component for the cake mesh, plus many small connected components hidden inside it.
Those small components are all coplanar, so projecting them into their natural 2D plane reveals text. Reading the projected shapes left-to-right gives:
UMASS{SL1C3_&_D1C3}
Exact solver:
To make the letters readable, I projected each hidden component's edges into that plane:
From the projected glyphs:
U M A S S {S L 1 C 3_&_D 1 C 3}
So the flag is:
knex
Description
lush.png is a deliberately broken PNG. Between normal IDAT chunks it contains many invalid l0l4 chunks. Concatenating those invalid chunk bodies gives a valid JPEG, and removing them gives a valid clean PNG.
The JPEG also contains a steghide payload (teeny.mp3) with the empty passphrase:
The important hint was alpha = 0.45: the clean PNG has a BPCS payload in CGC space at threshold 0.45.
Solution
The normal mobeets-style decode was a trap because the payload was not using the usual trailing conjugation-map blocks. The useful observations were:
Extracting complex
8x8CGC blocks fromlush_clean.pngatalpha = 0.45gives5670blocks.Those blocks form a
70 x 81grid.In the raw extracted grid, row
8is the repeated template row, and row7differs from it only at columns0..6.That means only the first
70*7 + 7 = 497raw blocks are real payload.The bespoke conjugation rule is: the top-left bit of each payload block is the conjugation marker. If it is set, XOR the whole
8x8block with the normal checkerboardAA 55 AA 55 AA 55 AA 55.After that, the recovered bytes are printable Base91. Base91-decoding them yields the flag repeated many times.
Self-contained solver:
Output:
Blink of an Eye - Writeup
Category: Miscellaneous
Points: 500
Flag: UMASS{i_d1d_7h3_l3g0_c0py_p4573_m4nu4lly}
Challenge
This Ohio '67 director once watched over a hero-creating hopecore machine. One of his actors (a wine expert) was the subject of a legal dispute between a Cannon and a giant Dane. My secrets are on page 138.
An attached nums.txt contains 41 integers:
Solution
Step 1: Decode the riddle
Ohio '67 director = David Collins (born 1967 in Ohio), creator/director of Queer Eye.
Hero-creating hopecore machine = Queer Eye (the TV show that transforms people's lives).
One of his actors (a wine expert) = Antoni Porowski, the food & wine expert from Queer Eye.
Legal dispute between a Cannon and a giant Dane = Points to LEGO (a giant Danish company). The legal dispute context connects Antoni Porowski / Queer Eye to LEGO.
My secrets are on page 138 = Page 138 of the LEGO instruction manual.
The connection: LEGO set 10291 is the Queer Eye - The Fab 5 Loft set. Its instruction manual PDF is freely available from LEGO's website.
Step 2: Identify the numbers as LEGO element IDs
The 41 integers in nums.txt are LEGO element IDs. Each element ID uniquely identifies a specific LEGO part in a specific color. All 24 unique element IDs from the list appear on page 138 of the LEGO 10291 instruction manual, which is the set's parts inventory page.
Step 3: The encoding - column-major grid position = ASCII
Page 138 of the manual is a parts inventory page showing 164 valid LEGO elements arranged in a visual grid with 13 columns.
The key insight (hinted by the title "Blink of an Eye" - look carefully at the page layout):
Extract all element IDs from page 138 with their (x, y) positions using a PDF parser (e.g., PyMuPDF).
Filter out invalid element IDs (e.g.,
62690which is a PDF text extraction artifact).Sort the elements in column-major order: first by column (x-coordinate), then by row (y-coordinate) within each column.
The 0-indexed position of each element in this ordering directly gives the ASCII character code.
For example:
Element
6175367is at position 65 in column-major order โ ASCII 65 =AElement
300426is at position 83 โ ASCII 83 =SElement
6196548is at position 85 โ ASCII 85 =UElement
362326is at position 123 โ ASCII 123 ={
Step 4: Decode the flag
Reading the 41 element IDs through the column-major position lookup:
Flag: UMASS{i_d1d_7h3_l3g0_c0py_p4573_m4nu4lly}
In leetspeak: "I did the lego copy paste manually"
osint
Funny Business
Description
We are given a street photo and asked for the contact email address of a store. The clue says the store sells "special bricks", its office is above a well-known shopping centre on the pictured street, and it will "bring me joy".
Solution
The useful path was:
Geolocate the image. The building facade/logo in the photo matches
Ho King Shopping Centrein Mong Kok, Hong Kong, not the earlier Windsor House / Causeway Bay branch.Use the clue wording.
bring me joypoints toJoy Bricks, andspecial bricksfits a non-LEGO brick seller.Verify on the official site. The official site is
https://joooooy.com/. Its homepage title explicitly says it sells alternative brick brands, and the contact page gives the email and office address above Ho King Commercial Centre.
Verification:
Output shows:
Then fetch the contact page:
That confirms:
So the flag is:
Son of a Sith...
Description
We are given a single attachment, son-of-a-sith....zip, containing a PNG screenshot. The flag format is:
UMASS{What_The_Red_Brick_Does}
The screenshot shows a LEGO Star Wars red brick in a sandy canyon/cave area.
Solution
First inspect the provided files:
The ZIP contains one image: Screenshot_20260403_191312.png.
Viewing the screenshot shows:
A LEGO Star Wars red brick
A sandy Tatooine-like canyon
A cave entrance in the rock wall
Two gray rails/tracks leading into the cave
That combination is the key. In LEGO Star Wars, the Through the Jundland Wastes / Jundland Wastes red brick is reached by:
Entering the hidden side area near the beginning of the level
Hovering across as
R2Pushing a wagon/cart
Following the tracks to the cave where the red brick is
This matches the screenshot exactly because the visible gray lines are the cart tracks leading into the cave.
After identifying the level as Through the Jundland Wastes, the red brick reward can be mapped from LEGO Star Wars guides/wiki references:
In
LEGO Star Wars II: The Original Trilogy, that power brick unlocksFast ForceIn
LEGO Star Wars: The Complete Saga,Through the Jundland Wastesalso maps toFast Force
So the flag is:
High Performance
Description
We are given a ZIP containing a single image and asked to identify a nearby computer shop that sells a computer not intended for Windows, macOS, or Linux, then recover the processor used in that shop's flagship PCIe-capable system.
Solution
First, inspect the provided files:
The PNG metadata contains an embedded comment:
That YouTube link is a dead end meme video and does not help with the OSINT path.
Next, inspect the image itself. The scene shows:
European residential architecture
yellow license plates
French-style signage
an industrial-looking background
Those clues point strongly to Luxembourg, especially the industrial southwest around Differdange / Esch-sur-Alzette.
The key location lead is Rue รmile Mark in Differdange, Luxembourg. AAA Technology has a physical shop there. A confirming public article states:
AAA Technology was created in Luxembourg
their physical shop is at
76, Rue รmile Mark โ L-4620 Differdange
Useful lookup:
Public corroboration used during solving:
Amiga Impact article about AAA Technology opening in Luxembourg
indexed shop snippets for
amigakit.fr, which is the AAA Technology storefront
The shop sells Amiga hardware, which satisfies the challenge text about a computer not designed for Windows, macOS, or Linux.
The important part was identifying the correct current flagship PCIe-capable system from the storefront. Search engine indexed snippets for the live catalog showed:
A1222+ SYSTEMlisted as a complete systemit was the top-priced complete system among the indexed non-mainstream computers on the storefront
the matching
A1222+ Motherboardspecification page explicitly says it is based on:
and also explicitly mentions PCIe support.
Representative queries/commands used:
Searches performed:
Final flag:
We Have ๅพๅฏป at Home
Description
The image clue was a streetview-style panorama of an office-park road. The challenge text said the lost chip was:
serial NOR flash
1024 KBcapacity108Mhzused in a children's toy
The flag format was UMASS{name of chip on website}.
Solution
I started by translating the chip requirement into a product filter:
1024 KBmeans8Mbit108MHzSPI / serial NOR flash
That leaves a relatively small set of China-market flash parts. I first checked several Shenzhen-heavy candidates such as HGSEMI, BOYA, XTX, and TD, and also spent time trying to pin the exact office park from the panorama. That geography work produced several plausible Shenzhen corridors, but none of the obvious first-pass flags landed.
The solve came from going back to the product side and looking for an exact official website match rather than forcing the map clue.
Using official ChipSourceTek pages:
The official product page for
XT25F08B-Sstates:8M-bit1024K-byte108MHz for fast read
The official contact page places ShenZhen ChipSourceTek in Shenzhen:
Room302, Building A3, MingXi Creative Park ... Bao'an District, ShenZhen
That made XT25F08B-S a strong fit:
exact capacity match
exact clock match
official website product name available directly on the page
official Shenzhen office/park address matching the challengeโs office-park framing
I verified the exact order-code family from the official datasheet page as well. The product page lists:
Because the flag format wanted the chip name โon websiteโ, I submitted the base product string first:
Final flag:
reverse_engineering
Batcave Bitflips
Description
We are given a non-stripped ELF, batcave_license_checker. The challenge hints say there are 3 bugs, with one involving rotation and one involving the SBOX.
The binary exposes enough symbols to recover the intended structure:
LICENSE_KEYis embedded in.dataas!_batman-robin-alfred_((67||67))EXPECTEDis the 32-byte target hashFLAGis the encrypted flag bufferSBOXis the substitution tableMain flow is:
read 32-byte license key
hash it
compare against
EXPECTEDdecrypt and print the flag
Running the program with the embedded license key does not pass verification, so the shipped binary is corrupted.
Solution
The three bugs are:
rotate()is wrongCurrent code computes
(x << 3) | (x >> 6)Intended operation is
rol(x, 3), so the immediate6should be5File patch: offset
0x1282, change0x06 -> 0x05
One SBOX entry is wrong
The SBOX is almost a permutation of
0..255, but has duplicate0x43and is missing0x44The bad entry is
SBOX[0x18]File patch: offset
0x3098, change0x43 -> 0x44
decrypt_flag()usesorinstead ofxorThe decryption step should xor each encrypted byte with the verified hash
File patch: offset
0x12ec, opcode0x09 -> 0x31(or ecx, eax->xor ecx, eaxin effect)
After applying those three fixes and running the embedded license key, the binary prints the flag:
UMASS{__p4tche5_0n_p4tche$__#}
Solution script:
Verification output:
Lego Clicker
Description
The challenge is an Android APK. The visible app flow is a clicker game with a leaderboard, and the prompt says to "reclaim the top of the leaderboard". The APK contains a native library, liblegocore.so, with JNI for:
FlagEngineFCASessionValidator
There are fake flags in the challenge.
Solution
I unpacked the APK with jadx and identified the important Java paths:
RAis the leaderboard activity.If the top leaderboard entry is the player,
RAcalls:SessionValidator.validateBrickToken(j, j)SessionValidator.a(j, j), wherea()reflectively resolves tosyncBrickCache(j, j).
SessionValidatornatives are registered dynamically inJNI_OnLoad.
Using headless Ghidra on apk_unpacked/lib/x86_64/liblegocore.so, the native registration resolves to:
syncBrickCache->FUN_001210f0refreshTileMap->FUN_00121280validateBrickToken->FUN_001213b0
Important observations:
FUN_00121d40(x)checks whetherx * (x + 1)is even, which is always true.FUN_00120350(x)checks whether~(x*x) & 3 == 0, which is never true for integer squares modulo 4.syncBrickCachetherefore always takes the same non-debug branch in a normal environment.That branch builds the final flag byte-by-byte in
FUN_00121f60.The anti-debug/anti-frida check is in
FUN_00121e80:/proc/self/statusforTracerPid:/proc/self/mapsforfrida,gadget,gum-js,linjectorIn a normal environment this check is false, so the real flag path is used.
The byte transforms are initialized at 0x20370 and FUN_00120fa0, and the character source table comes from FUN_00120310.
This script reconstructs the flag directly from the native library:
Running it prints:
This matches the intended theme and was accepted by the scoreboard.
web_exploitation
BrOWSER BOSS FIGHT
Description
This familiar brick castle is hiding something... can you break in and defeat the Koopa King?
Solution
The landing page had a key input form with inline JavaScript:
That means any normal browser submit rewrites the key parameter before the request is sent. The hint confirmed the intended bypass: do not use the form submit path at all. Send the POST manually.
Submitting any raw key attempt produced a useful response header:
So the real key was under_the_doormat.
Posting that key manually redirected to /bowsers_castle.html:
The castle page was session-gated and set a large number of cookies plus:
The page text said:
That exposed the second trust issue: the application relied on the client-controlled hasAxe cookie. Reusing the authenticated connect.sid from the valid key submission and forcing hasAxe=true returned the victory page with the flag.
Working exploit:
Response:
Flag:
Brick by Brick
Description
BrickWorks Co.'s portal exposed internal documents under /internal-docs/. The onboarding document mentioned that the main intranet lets staff read files via a ?file= parameter and that the admin dashboard credentials are stored in config.php in the web root.
Solution
The bug is a local file inclusion / path traversal on the main page. Absolute paths are blocked, but traversal works:
robots.txt reveals internal docs:
The onboarding document says:
Read config.php through the LFI:
That reveals the hidden admin page:
Read the dashboard source through the same LFI:
The PHP source contains both the default credentials and the flag:
Flag:
The Block City Times V2
Description
The app lets anyone submit a story with an attached text/plain or PDF file. The editorial bot logs in as admin and visits the uploaded file URL. The admin dashboard also has a dev-only /admin/report feature that asks an internal report-runner service to log in as admin, set a FLAG cookie in Chromium, visit an allowed /api/... endpoint, and return the visible page text.
The key bug is that upload validation only trusts the multipart part Content-Type, while the stored filename is served back with Files.probeContentType(...). That means an .html file can be uploaded as text/plain, then served as text/html, giving stored same-origin XSS in the editorial admin bot.
Solution
Exploit chain:
Upload HTML as
text/plainto get stored XSS when the editorial bot opens/files/<uuid>-name.html.From that XSS, use the admin session to:
set
app.active-config=devcall
/actuator/refreshchange
app.outbound.editorial-urlto awebhook.siteURL so later/submitcalls exfiltrate data
Use the admin-only
PUT /api/tags/article/{id}twice to store the same raw tag value on two different articles:<script>document.body.innerText=document.cookie</script>
Now
GET /api/tagsthrowsIllegalArgumentException: duplicate element: <script>...insideArticleService.allTags().The important browser detail: when a real browser navigates to that string error endpoint, Spring negotiates the response as
text/htmlbecause the requestAcceptprefers HTML. So the injected<script>executes.Trigger
/admin/reportwith endpoint/api/tags. The internalreport-runnerbrowser:logs in as admin
sets the
FLAGcookievisits
/api/tagsexecutes the injected script
replaces the page body with
document.cookie
The report result now contains the flag cookie value. Relay that page back out through
/submitto the webhook.
Solution payload used:
Upload it as a file named payload_live_flag.html while forcing the multipart part content type to text/plain, for example:
The returned report leaked:
The Block City Times
Description
The outer service on :5000 is only a gate. It expects a valid UMass CTFd access token, then exposes a managed instance page that starts the real challenge container over socket.io.
The inner instance matches the provided Java source. The bug chain is:
/submitonly checks the uploaded part's declared MIME type. An.htmlfile can be uploaded astext/plain.The saved filename keeps the
.htmlextension, and/files/{filename}later serves it usingFiles.probeContentType, so the file is returned astext/html.The editorial bot automatically opens the uploaded file while logged in as admin, so this becomes stored XSS in an admin session.
The XSS uses the admin session to:
fetch a CSRF token from
/login(important:/adminin production does not render a CSRF field),POST
/actuator/envwith{"name":"app.active-config","value":"dev"},POST
/actuator/refresh,POST
/admin/reportwith endpoint/api/../files/<same uploaded html>.
The report bot logs in as admin, sets a
FLAGcookie, and visits the endpoint. When it loads the same uploaded HTML, the payload detectsdocument.cookiecontainingFLAG=and writes the cookie value into the page body./admin/reportembeds the report bot output in the response HTML. The editorial-bot XSS regexesUMASS{...}from that response and stores it in the public/api/tags/article/1endpoint.Poll the public tags endpoint until the flag appears.
Recovered flag: UMASS{A_mAn_h3s_f@l13N_1N_tH3_r1v3r}
Solution
Standalone solve script:
Building Blocks Market
Description
The bug is a cache-key / forwarded-path mismatch in cache_proxy.
The proxy caches on the raw request path if it ends in a cacheable extension.
But it forwards only the part before
%0d%0a.So requesting
/admin/submissions.html%0d%0aX.csscaches the real admin page under a public, cacheable key.
That leaks the admin submissions page, including the per-admin CSRF token. The remaining problem is turning that leak into an authenticated admin POST on live Chromium.
Solution
The direct cross-site POST ideas fail on live because the public payload page is effectively https://..., so Chromium blocks requests to insecure private hosts like http://cache_proxy:5555 and http://bot:3001.
The working chain is:
Create a product.
Submit
http://cache_proxy:5555/admin/submissions.html%0d%0a<rand>.css.Fetch the same public path and read the leaked admin CSRF token.
Submit a second URL pointing to a public HTTPS page we control.
That page opens
about:blankin a popup, then navigates the popup to ajavascript:URL.Inside the popup, create a
text/plainform POST tohttp://127.0.0.1:3001/visit.
127.0.0.1 is the important detail. Chromium treats loopback as trustworthy, so the secure public page can still reach the bot server on loopback. Targeting http://bot:3001/visit does not work.
The bot expects JSON, but text/plain forms serialize as name=value\r\n. The trick is to place that = inside the JSON string:
input
name:{"url":"<left half of payload up to first =>input
value:<right half after first =>"}
That makes the actual body:
JSON.parse(...) accepts the trailing CRLF, so /visit accepts it.
The JSON url sent to /visit is a data:text/html,... page that auto-submits:
The bot then visits that data: URL directly, and that page successfully POSTs to cache_proxy with the admin session cookie already loaded by the bot.
Public payload page:
Exploit script:
That returned:
Turncoat's Treasure
Description
The challenge ships a product site, a forum, and a captain bot behind an nginx proxy.
Important bugs:
forum/templates/user.htmlrenders post content with|safe, so/user/<name>is stored XSS.product/check-captainleaks the captain container IP.nginx blocks
/call-captain, but the block is case-sensitive while Express routing is case-insensitive, so/CALL-CAPTAINreaches the captain app.The wildcard proxy routes arbitrary subdomains upstream, so
make-<captain_ip>-rr.1u.ms.<host>can be used to send traffic to the captain service.captain /treasureis localhost-only, but it returns CSS:here is your treasure+name+FLAG
The useful trick is that this CSS is still valid if name starts with {--x:. Then:
https://127.0.0.1/treasure?name=%7B--x%3A
produces CSS equivalent to:
If the DOM contains nested custom elements:
then getComputedStyle(t).getPropertyValue('--x') returns the flag.
The clean solve path was:
Register an attacker-controlled forum user.
Post a stored-XSS payload into that user's forum posts.
Trigger the captain bot to visit
/user/<attacker>.The XSS runs on
forum.<host>, loads the localhost treasure CSS, reads the flag from the CSS custom property, logs into the attacker forum account, and posts the flag as a new forum message.Read the posted flag back from the attacker user's page.
Solution
Exploit script used:
Run:
Recovered flag:
ORDER66
Description
The app stores at most one populated box_i per session in Redis, keyed as {uid}:box_i. Rendering is split across two routes:
/keeps the current sessionuid, rotates a sessionseed, and renders one box with|safe./view/<uid>/<seed>renders the same stored data for any chosenuidandseed.
The vulnerable box is selected with:
That means the server lets us:
Store a payload in any single box we want.
Pick a synthetic
seedwhose RNG output points at that same box.Send the admin bot to
/view/<uid>/<seed>.
The bot sets a readable flag cookie and forwards console.log(...) output back in the /admin/visit response, so an XSS payload can print the flag directly.
Solution
I used box_1 and a known Python seed where random.randint(1, 66) returns 1:
Exploit:
Response:
Flag:
Bricktator
Description
The app is a Spring Boot control panel with:
known credentials for
bricktator/goldeaglean exposed
/actuator/sessionsendpoint available after logging in as Bricktatorsession IDs of the form
xxxxx-xxxxxxxx, where the decimal prefix is the share index and the hex suffix is the share valuea degree-2 Shamir-style polynomial used to generate all seeded session IDs
an override flow that needs 5
YANKEE_WHITEsession approvals
The important source observations were:
SessionSuccessHandlerpinsbricktator,John_Doe, andJane_Doeto seeded session indices5001,1, and5/actuator/sessions?username=...returns the seeded session ID for a principaljohn_doeandjane_doeare the seeded principal names, notJohn_Doe/Jane_DoeCommandWorkFilterruns on/commandand does an expensive bcrypt only when the supplied session ID belongs to a storedYANKEE_WHITEsession/override/{token}is public and only checks whether the session backing the request hasrole=YANKEE_WHITE
So the solve is:
Log in as Bricktator.
Query
/actuator/sessions?username=bricktator,john_doe, andjane_doe.Reconstruct the quadratic over
mod 2147483647from those three shares.Generate valid seeded session IDs for indices
2..5000and probe/commandwith each one.Use the bcrypt timing jump to identify
YANKEE_WHITEsessions.Start an override as Bricktator.
Submit 4 discovered
YANKEE_WHITEsessions to/override/<token>.Read the flag from the completion page.
Live solve result:
UMASS{stUx_n3T_a1nt_g0T_n0th1nG_0N_th15}
Solution
Bricktator v2
Description
Web exploitation challenge (500 pts). A Spring Boot "Nuclear Control Center" application uses Shamir's Secret Sharing for session management and requires 5 YANKEE_WHITE clearance approvals to execute "Protocol Sigma" and retrieve the flag.
Solution
The challenge involves a Spring Boot 3.2.4 application with several interacting components:
1. Session ID Structure & Shamir's Secret Sharing
Session IDs follow the format %05d-%08x where the two parts encode a Shamir share (x, y) on a degree-2 polynomial mod PRIME = 2^31 - 1. The app seeds 5000 sessions (x=1..5000) plus an admin session (x=5001). Three known users provide shares:
john_doeat x=1jane_doeat x=5bricktatorat x=5001
With 3 shares and a degree-2 polynomial, we can recover the full polynomial and compute any session ID.
2. Discovering Shares
Login as
bricktator(passwordgoldeaglefrom source) to get the admin session ID from the dashboardUse Spring Boot Actuator (
/actuator/sessions?username=john_doe) to getjohn_doeandjane_doesession IDs (accessible with YANKEE_WHITE or Q_CLEARANCE)
3. Finding YANKEE_WHITE Sessions
The CommandWorkFilter performs BCrypt(strength=13) hashing when a YANKEE_WHITE session accesses /command, causing a consistent ~0.8s delay vs ~0.1s for other sessions. Out of 5000 seeded sessions, 7 are randomly assigned YANKEE_WHITE.
Key insight for reliability: single timing checks produce many false positives from network/server spikes (~1.1s once, then ~0.1s). True BCrypt sessions are consistently slow across multiple checks. The solve uses a full scan followed by triple-checking - requiring at least 3 of 4 checks above threshold eliminates all false positives.
4. Protocol Sigma Override
The override requires 5 distinct YANKEE_WHITE sessions to approve sequentially:
POST /command/overrideas bricktator (creates token, counts as approval #1)POST /override/{token}with 4 more YANKEE_WHITE session cookies
If any approver isn't YANKEE_WHITE, the override is CANCELLED. The v2 OverrideService supports concurrent tokens, so failed attempts can be retried with different candidate sessions.
5. Session Cookie Encoding
Session cookies are Base64URL-encoded session IDs: SESSION = base64url(sessionId).
Flag: UMASS{stUx_n3T_a1nt_g0T_n0th1nG_0N_th15_v2!!!randomNoiseAndStuff}
Last updated