Challenge Name: misc_magic-or-not
Category: Misc
Difficulty: Easy
Author: H4N5
Challenge Description
As an intern at Brunner Corporation, I developed a cutting-edge image obfuscation algorithm, designed to hide sensitive image data.
I’m confident it’s secure, but the security team isn’t convinced. They believe custom cryptography always hides a flaw.
Can you prove them wrong? Analyze the implementation, recover the original image, and find the flag.
We get four files with image extensions — Brunner1.jpg, Brunner2.gif, Brunner3.png, Brunner4.bmp — but file reports all four as raw data: their magic bytes are wrong. The name “magic-or-not” is the whole hint.
Approach
Step 1 — The headers are corrupted, and there’s a tell
$ for f in Brunner*; do file "$f"; done
Brunner1.jpg: data
Brunner2.gif: data
Brunner3.png: data
Brunner4.bmp: data
Hexdumping each file shows enormous runs of a single repeated byte — a different one per file:
Brunner1.jpg ... 58 58 58 58 ... (0x58 'X')
Brunner2.gif ... 57 57 57 57 ... (0x57 'W')
Brunner3.png ... 59 59 59 59 ... (0x59 'Y')
Brunner4.bmp ... 5a 5a 5a 5a ... (0x5a 'Z')
Real images have large flat regions (a solid background), which are runs of 0x00. A run of a constant non-zero byte where zeros should be is the classic fingerprint of a single-byte XOR: 0x00 ^ key == key. So each file’s repeated byte is its XOR key.
Step 2 — Confirm against known magic bytes
Each format has a fixed file signature; XORing the file’s first bytes against it must yield a constant. For Brunner3.png (should start 89 50 4E 47 0D 0A 1A 0A):
d0^89 = 59 09^50 = 59 17^4e = 59 1e^47 = 59
54^0d = 59 53^0a = 59 43^1a = 59 53^0a = 59
Every byte gives 0x59 — matching the filler. The same holds for the others:
| File | Expected magic | Key | (filler char) |
|---|---|---|---|
| Brunner1.jpg | FF D8 FF | 0x58 | X |
| Brunner2.gif | 47 49 46 38 (GIF8) | 0x57 | W |
| Brunner3.png | 89 50 4E 47 … | 0x59 | Y |
| Brunner4.bmp | 42 4D (BM) | 0x5a | Z |
The keys are W X Y Z — a cute touch, and confirmation the “cutting-edge algorithm” is just XOR with one byte.
Step 3 — Decrypt and validate
keys = {"Brunner1.jpg":0x58, "Brunner2.gif":0x57, "Brunner3.png":0x59, "Brunner4.bmp":0x5a}
for fn, k in keys.items():
data = bytes(b ^ k for b in open(fn,"rb").read())
open("out/dec_"+fn, "wb").write(data)
$ file out/dec_*
dec_Brunner1.jpg: JPEG image data, ... 642x896
dec_Brunner2.gif: GIF image data, version 89a, 190 x 896
dec_Brunner3.png: PNG image data, 68 x 896, 8-bit RGBA
dec_Brunner4.bmp: PC bitmap, ... 321 x 896 x 32
All four decode to valid images — and all are 896 px tall. They are vertical strips of one picture (widths 642 + 190 + 68 + 321 = 1221).
Step 4 — Reassemble
Stitching the strips left-to-right in filename order (1, 2, 3, 4) with ImageMagick:
convert dec_Brunner1.jpg dec_Brunner2.gif dec_Brunner3.png dec_Brunner4.bmp +append stitched.png
produces a clean 1221×896 photo of a pie-textured flag on a flagpole, with the flag text spelling out the answer. The seams line up perfectly and the text is continuous, confirming the order is correct.
Flag
brunner{ctf2026}
Reflections and Learnings
- Constant non-zero runs mean single-byte XOR: real images have large flat regions of
0x00; a long run of a constant non-zero byte is0x00 ^ key, so the filler byte literally is the key. That one fingerprint deflated the “cutting-edge algorithm” instantly. - Confirm the key against known structure: XORing each file’s head against its true magic signature yielded a single constant per file — independent confirmation before bothering to decrypt the whole thing.
- The title was the hint: “magic-or-not” pointed straight at the file signatures. Challenge names are often the first and cheapest clue.
- Let the data drive reassembly: all four strips decoded to the same 896-px height, and the continuous flag text across the seams confirmed left-to-right filename order — validation baked into the result rather than assumed.