Summary
The challenge provides four files (Brunner1.jpg, Brunner2.gif,
Brunner3.png, Brunner4.bmp) that all report as generic data under
file - none of them start with the magic bytes their extension implies.
Each file turns out to be a valid image of its stated format XOR'd with a
single repeating byte. Recovering the four keys and XOR-decoding each file
restores four vertical slices of one picture, which stitch together into a
single wide banner image containing the flag as plain text.
1. Recon - the files don't match their extensions
$ file Brunner1.jpg Brunner2.gif Brunner3.png Brunner4.bmp
Brunner1.jpg: data
Brunner2.gif: data
Brunner3.png: data
Brunner4.bmp: data
None of them are recognized. Dumping the first bytes of each is the
giveaway - "cutting-edge obfuscation" is a red flag for custom crypto, and
the security team's hunch pans out immediately:
Brunner1.jpg first bytes: a7 80 a7 b3 7b 42 12 08 41 58 58 58 58 59 58 58 ...
Brunner2.gif first bytes: 10 1e 11 6f 36 e9 57 d7 54 a0 57 57 57 57 57 57 ...
Brunner3.png first bytes: d0 09 17 1e 54 53 43 53 59 59 59 54 10 11 1d 0b ...
Brunner4.bmp first bytes: 18 17 d0 d4 4b 5a 5a 5a 5a 5a d0 5a 5a 5a 26 5a ...
Each file has long runs of a single repeated byte (58, 57, 59, 5a -
i.e. ASCII X, W, Y, Z). Long constant runs after XOR strongly
suggest a single-byte XOR cipher applied over image data that itself
contains long runs of a constant byte (e.g. padding, or solid-color pixel
regions) - the "obfuscation algorithm" is a one-byte XOR "cipher."
2. Recovering the key per file
Each filename tells us the plaintext format to expect, so the known-magic
attack applies directly: XOR each file's header against the expected
signature for that format and see which single byte reproduces it.
sigs = {
'Brunner1.jpg': bytes.fromhex('ffd8ff'), # JPEG SOI + marker
'Brunner2.gif': b'GIF89a',
'Brunner3.png': bytes.fromhex('89504e470d0a1a0a'),
'Brunner4.bmp': b'BM',
}
for fname, sig in sigs.items():
data = open(fname, 'rb').read()
for key in range(256):
if bytes(b ^ key for b in data[:len(sig)]) == sig:
print(fname, hex(key))
Results:
| File | Recovered key |
|---|---|
| Brunner1.jpg | 0x58 |
| Brunner2.gif | 0x57 |
| Brunner3.png | 0x59 |
| Brunner4.bmp | 0x5a |
(Note these are exactly the repeated bytes spotted in step 1 - the "custom
crypto" leaks its own key in plaintext wherever the underlying image has a
run of constant-value pixels.)
3. Decoding
XOR each full file against its own single-byte key:
keys = {
'Brunner1.jpg': 0x58,
'Brunner2.gif': 0x57,
'Brunner3.png': 0x59,
'Brunner4.bmp': 0x5a,
}
for fname, key in keys.items():
data = open(fname, 'rb').read()
dec = bytes(b ^ key for b in data)
open('decoded_' + fname, 'wb').write(dec)
$ file decoded_*
decoded_Brunner1.jpg: JPEG image data, ... 642x896, components 3
decoded_Brunner2.gif: GIF image data, version 89a, 190 x 896
decoded_Brunner3.png: PNG image data, 68 x 896, 8-bit/color RGBA
decoded_Brunner4.bmp: PC bitmap, ... 321 x 896 x 32 ...
All four decode to valid images of their claimed format. Every image has
the same height (896 px) but a different width - a strong hint they are
vertical slices of one wider picture, meant to be reassembled side by side.
4. Reassembling the image
from PIL import Image
imgs = ['decoded_Brunner1.jpg', 'decoded_Brunner2.gif',
'decoded_Brunner3.png', 'decoded_Brunner4.bmp']
ims = [Image.open(i).convert('RGB') for i in imgs]
canvas = Image.new('RGB', (sum(im.width for im in ims), ims[0].height))
x = 0
for im in ims:
canvas.paste(im, (x, 0))
x += im.width
canvas.save('stitched.png')
Pasting the slices in filename order (1, 2, 3, 4) produces a single
1221x896 image straight away - a flag banner with the text baked directly
into the picture:
brunner{ctf2026}
Full solve script
from PIL import Image
# 1. Recover per-file single-byte XOR key via known-plaintext (file magic bytes)
sigs = {
'Brunner1.jpg': bytes.fromhex('ffd8ff'), # JPEG SOI + marker
'Brunner2.gif': b'GIF89a',
'Brunner3.png': bytes.fromhex('89504e470d0a1a0a'),
'Brunner4.bmp': b'BM',
}
keys = {}
for fname, sig in sigs.items():
data = open(fname, 'rb').read()
for key in range(256):
if bytes(b ^ key for b in data[:len(sig)]) == sig:
keys[fname] = key
break
print("Recovered keys:", {k: hex(v) for k, v in keys.items()})
# 2. XOR-decode each full file with its recovered key
decoded_names = []
for fname, key in keys.items():
data = open(fname, 'rb').read()
dec = bytes(b ^ key for b in data)
out = 'decoded_' + fname
open(out, 'wb').write(dec)
decoded_names.append(out)
# 3. Stitch the decoded slices together horizontally, in filename order
imgs = ['decoded_Brunner1.jpg', 'decoded_Brunner2.gif',
'decoded_Brunner3.png', 'decoded_Brunner4.bmp']
ims = [Image.open(i).convert('RGB') for i in imgs]
canvas = Image.new('RGB', (sum(im.width for im in ims), ims[0].height))
x = 0
for im in ims:
canvas.paste(im, (x, 0))
x += im.width
canvas.save('stitched.png')
print("Saved stitched.png -- flag is visible as text in the image.")
Key Vulnerabilities
| # | Weakness | Impact |
|---|---|---|
| 1 | Single-byte XOR is the entire "obfuscation algorithm" | Trivially breakable via known-plaintext (file magic bytes) attack |
| 2 | Same key reused across the whole file | Any run of repeated plaintext bytes (padding, flat color) leaks the key directly in the ciphertext |
| 3 | Image split into slices without further protection | Once each slice decodes, correct reassembly order is discoverable from filenames/dimensions alone |
Attack Chain
+-----------------------------+
| 4 files, wrong-looking |
| magic bytes for their |
| extensions |
+---------------+---------------+
|
v
+-----------------------------+
| Spot repeated single bytes |
| in each file -> single-byte |
| XOR "cipher" |
+---------------+---------------+
|
v
+-----------------------------+
| Known-plaintext attack: |
| XOR header vs expected magic |
| bytes for each format |
| -> recover key per file |
+---------------+---------------+
|
v
+-----------------------------+
| XOR-decode full files -> |
| 4 valid images, same height, |
| different widths |
+---------------+---------------+
|
v
+-----------------------------+
| Stitch slices horizontally |
| in filename order |
+---------------+---------------+
|
v
+-----------------------------+
| Flag banner image recovered |
+-----------------------------+
Mitigations
- Never invent custom cryptographic primitives; use vetted, standard algorithms (AES-GCM, ChaCha20-Poly1305, etc.) for confidentiality.
- Single-byte (or any short repeating-key) XOR provides no real security - it is broken by trivial known-plaintext or frequency analysis.
- If data must be split, don't leave filenames or metadata (dimensions, ordering) that reveal how to reassemble it.
- Any "obfuscation" that reuses a fixed key across an entire file is vulnerable the moment any portion of the plaintext is guessable - image file headers and padding are always guessable.
Files
-
decoded_Brunner1.jpg,decoded_Brunner2.gif,decoded_Brunner3.png,decoded_Brunner4.bmp- individually XOR-decoded slices -
stitched.png- final reassembled flag banner
Top comments (0)