DEV Community

Yogeshwar Peela
Yogeshwar Peela

Posted on Originally published at exploitnotes.hashnode.dev

BrunnerCTF 2026 : Magic or Not Writeup

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
Enter fullscreen mode Exit fullscreen mode

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 ...
Enter fullscreen mode Exit fullscreen mode

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))
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode
$ 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 ...
Enter fullscreen mode Exit fullscreen mode

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')
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

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.")
Enter fullscreen mode Exit fullscreen mode

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  |
+-----------------------------+
Enter fullscreen mode Exit fullscreen mode

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)