weight-hangingUMassCTF 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:

  1. Leak a libc address from printf@got.

  2. Compute the libc base.

  3. Overwrite printf@got with system.

  4. 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$s reliably leaks the resolved printf pointer.

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_id and pigment_code, stores a global service_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:

  1. Choose menu option 3.

  2. Enter 0 48879.

  3. Choose menu option 3 again.

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:

  1. No stack canary in user functions (machine_main_demo, cli_recv, etc.)

  2. 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 0

  3. The call exit instruction at binary offset 0xb457 is reachable by overwriting only the lowest byte of the return address (original at 0xb43d)

  4. This creates an oracle: overwrite partial return address, send "exit" to trigger return, then monitor to 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:

  1. read_line_fd(pipe_fd=3, bss_path) - read "/ctf/flag.txt" from parent pipe into BSS

  2. open(bss_path, O_RDONLY) - open flag file (returns fd 4)

  3. read_line_fd(4, bss_buf) - read flag content into BSS

  4. puts(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:

  1. send 0 <padding + ROP chain> - overflow child buffer

  2. send 0 exit - trigger return, ROP chain starts, blocks on pipe read

  3. send 0 /ctf/flag.txt - feed flag path to ROP chain's read_line_fd

  4. Child's puts outputs 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^7 is only 548 bits, so m^7 < n for a 4096-bit RSA modulus.

  • The published seed is actually (m^7)^7 mod n = m^49, and m^49 is still only 3832 bits, so this is also below n.

  • Therefore the huge integer in output.txt is exactly int.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:

  1. The PRNG state s[0..7] is seeded via getrandom() (512 random bits)

  2. The flag (up to 64 bytes) is read into a buffer pre-filled with getrandom() output

  3. The ciphertext ct[i] = buffer[i] XOR state_byte[i] (8 uint64 words) is printed

  4. The 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^k steps)

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 +c

  • If 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:

  1. 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.

  2. /work caches 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. /work also reads studs before verification, so a burst of valid mixed-case encodings for 0|uid can 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:

  1. Read the image as RGB.

  2. Take the least significant bit of every blue pixel.

  3. Walk pixels left-to-right, top-to-bottom.

  4. Pack every 8 bits into a byte, most-significant-bit first.

  5. 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:

  1. The demo header is broken. The playable ticcmd stream starts after 14 junk bytes.

  2. 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), not 4

  • 1705-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 -> E59FADE

  • raw_y = -22218853 -> FEACF79B as 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:

  • J1 exposes 7 data inputs, IN0..IN6

  • J2 is +5V/GND

  • each 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 1 and index 3 are both (11, 41, 71)

  • index 4 and index 6 are 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:84 are 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 8x8 CGC blocks from lush_clean.png at alpha = 0.45 gives 5670 blocks.

  • Those blocks form a 70 x 81 grid.

  • In the raw extracted grid, row 8 is the repeated template row, and row 7 differs from it only at columns 0..6.

  • That means only the first 70*7 + 7 = 497 raw 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 8x8 block with the normal checkerboard AA 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:

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 websitearrow-up-right.

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):

  1. Extract all element IDs from page 138 with their (x, y) positions using a PDF parser (e.g., PyMuPDF).

  2. Filter out invalid element IDs (e.g., 62690 which is a PDF text extraction artifact).

  3. Sort the elements in column-major order: first by column (x-coordinate), then by row (y-coordinate) within each column.

  4. The 0-indexed position of each element in this ordering directly gives the ASCII character code.

For example:

  • Element 6175367 is at position 65 in column-major order โ†’ ASCII 65 = A

  • Element 300426 is at position 83 โ†’ ASCII 83 = S

  • Element 6196548 is at position 85 โ†’ ASCII 85 = U

  • Element 362326 is 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:

  1. Geolocate the image. The building facade/logo in the photo matches Ho King Shopping Centre in Mong Kok, Hong Kong, not the earlier Windsor House / Causeway Bay branch.

  2. Use the clue wording. bring me joy points to Joy Bricks, and special bricks fits a non-LEGO brick seller.

  3. 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:

  1. Entering the hidden side area near the beginning of the level

  2. Hovering across as R2

  3. Pushing a wagon/cart

  4. 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 unlocks Fast Force

  • In LEGO Star Wars: The Complete Saga, Through the Jundland Wastes also maps to Fast 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+ SYSTEM listed as a complete system

  • it was the top-priced complete system among the indexed non-mainstream computers on the storefront

  • the matching A1222+ Motherboard specification 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 KB capacity

  • 108Mhz

  • used 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 KB means 8Mbit

  • 108MHz

  • SPI / 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-S states:

    • 8M-bit

    • 1024K-byte

    • 108MHz 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_KEY is embedded in .data as !_batman-robin-alfred_((67||67))

  • EXPECTED is the 32-byte target hash

  • FLAG is the encrypted flag buffer

  • SBOX is the substitution table

  • Main flow is:

    1. read 32-byte license key

    2. hash it

    3. compare against EXPECTED

    4. decrypt 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:

  1. rotate() is wrong

    • Current code computes (x << 3) | (x >> 6)

    • Intended operation is rol(x, 3), so the immediate 6 should be 5

    • File patch: offset 0x1282, change 0x06 -> 0x05

  2. One SBOX entry is wrong

    • The SBOX is almost a permutation of 0..255, but has duplicate 0x43 and is missing 0x44

    • The bad entry is SBOX[0x18]

    • File patch: offset 0x3098, change 0x43 -> 0x44

  3. decrypt_flag() uses or instead of xor

    • The decryption step should xor each encrypted byte with the verified hash

    • File patch: offset 0x12ec, opcode 0x09 -> 0x31 (or ecx, eax -> xor ecx, eax in 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:

  • FlagEngine

  • FCA

  • SessionValidator

There are fake flags in the challenge.

Solution

I unpacked the APK with jadx and identified the important Java paths:

  • RA is the leaderboard activity.

  • If the top leaderboard entry is the player, RA calls:

    • SessionValidator.validateBrickToken(j, j)

    • SessionValidator.a(j, j), where a() reflectively resolves to syncBrickCache(j, j).

  • SessionValidator natives are registered dynamically in JNI_OnLoad.

Using headless Ghidra on apk_unpacked/lib/x86_64/liblegocore.so, the native registration resolves to:

  • syncBrickCache -> FUN_001210f0

  • refreshTileMap -> FUN_00121280

  • validateBrickToken -> FUN_001213b0

Important observations:

  1. FUN_00121d40(x) checks whether x * (x + 1) is even, which is always true.

  2. FUN_00120350(x) checks whether ~(x*x) & 3 == 0, which is never true for integer squares modulo 4.

  3. syncBrickCache therefore always takes the same non-debug branch in a normal environment.

  4. That branch builds the final flag byte-by-byte in FUN_00121f60.

  5. The anti-debug/anti-frida check is in FUN_00121e80:

    • /proc/self/status for TracerPid:

    • /proc/self/maps for frida, gadget, gum-js, linjector In 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:

  1. Upload HTML as text/plain to get stored XSS when the editorial bot opens /files/<uuid>-name.html.

  2. From that XSS, use the admin session to:

    • set app.active-config=dev

    • call /actuator/refresh

    • change app.outbound.editorial-url to a webhook.site URL so later /submit calls exfiltrate data

  3. 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>

  4. Now GET /api/tags throws IllegalArgumentException: duplicate element: <script>... inside ArticleService.allTags().

  5. The important browser detail: when a real browser navigates to that string error endpoint, Spring negotiates the response as text/html because the request Accept prefers HTML. So the injected <script> executes.

  6. Trigger /admin/report with endpoint /api/tags. The internal report-runner browser:

    • logs in as admin

    • sets the FLAG cookie

    • visits /api/tags

    • executes the injected script

    • replaces the page body with document.cookie

  7. The report result now contains the flag cookie value. Relay that page back out through /submit to 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:

  1. /submit only checks the uploaded part's declared MIME type. An .html file can be uploaded as text/plain.

  2. The saved filename keeps the .html extension, and /files/{filename} later serves it using Files.probeContentType, so the file is returned as text/html.

  3. The editorial bot automatically opens the uploaded file while logged in as admin, so this becomes stored XSS in an admin session.

  4. The XSS uses the admin session to:

    • fetch a CSRF token from /login (important: /admin in production does not render a CSRF field),

    • POST /actuator/env with {"name":"app.active-config","value":"dev"},

    • POST /actuator/refresh,

    • POST /admin/report with endpoint /api/../files/<same uploaded html>.

  5. The report bot logs in as admin, sets a FLAG cookie, and visits the endpoint. When it loads the same uploaded HTML, the payload detects document.cookie containing FLAG= and writes the cookie value into the page body.

  6. /admin/report embeds the report bot output in the response HTML. The editorial-bot XSS regexes UMASS{...} from that response and stores it in the public /api/tags/article/1 endpoint.

  7. 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.css caches 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:

  1. Create a product.

  2. Submit http://cache_proxy:5555/admin/submissions.html%0d%0a<rand>.css.

  3. Fetch the same public path and read the leaked admin CSRF token.

  4. Submit a second URL pointing to a public HTTPS page we control.

  5. That page opens about:blank in a popup, then navigates the popup to a javascript: URL.

  6. Inside the popup, create a text/plain form POST to http://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.html renders post content with |safe, so /user/<name> is stored XSS.

  • product/check-captain leaks the captain container IP.

  • nginx blocks /call-captain, but the block is case-sensitive while Express routing is case-insensitive, so /CALL-CAPTAIN reaches 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 /treasure is 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:

  1. Register an attacker-controlled forum user.

  2. Post a stored-XSS payload into that user's forum posts.

  3. Trigger the captain bot to visit /user/<attacker>.

  4. 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.

  5. 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 session uid, rotates a session seed, and renders one box with |safe.

  • /view/<uid>/<seed> renders the same stored data for any chosen uid and seed.

The vulnerable box is selected with:

That means the server lets us:

  1. Store a payload in any single box we want.

  2. Pick a synthetic seed whose RNG output points at that same box.

  3. 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/goldeagle

  • an exposed /actuator/sessions endpoint available after logging in as Bricktator

  • session IDs of the form xxxxx-xxxxxxxx, where the decimal prefix is the share index and the hex suffix is the share value

  • a degree-2 Shamir-style polynomial used to generate all seeded session IDs

  • an override flow that needs 5 YANKEE_WHITE session approvals

The important source observations were:

  • SessionSuccessHandler pins bricktator, John_Doe, and Jane_Doe to seeded session indices 5001, 1, and 5

  • /actuator/sessions?username=... returns the seeded session ID for a principal

  • john_doe and jane_doe are the seeded principal names, not John_Doe / Jane_Doe

  • CommandWorkFilter runs on /command and does an expensive bcrypt only when the supplied session ID belongs to a stored YANKEE_WHITE session

  • /override/{token} is public and only checks whether the session backing the request has role=YANKEE_WHITE

So the solve is:

  1. Log in as Bricktator.

  2. Query /actuator/sessions?username=bricktator, john_doe, and jane_doe.

  3. Reconstruct the quadratic over mod 2147483647 from those three shares.

  4. Generate valid seeded session IDs for indices 2..5000 and probe /command with each one.

  5. Use the bcrypt timing jump to identify YANKEE_WHITE sessions.

  6. Start an override as Bricktator.

  7. Submit 4 discovered YANKEE_WHITE sessions to /override/<token>.

  8. 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_doe at x=1

  • jane_doe at x=5

  • bricktator at 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 (password goldeagle from source) to get the admin session ID from the dashboard

  • Use Spring Boot Actuator (/actuator/sessions?username=john_doe) to get john_doe and jane_doe session 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:

  1. POST /command/override as bricktator (creates token, counts as approval #1)

  2. 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