← Back to Writeups
HTBMediumCryptography

stained-glass

D3v0o0Nu119/2/20265 min read
#cryptography#htb#medium#autocorrelation_key_length#known_plaintext_attack#multi_layer_xor_decryption#per_channel_xor_key_recovery

stained-glass

Platform: Tjctf | Category: Cryptography | Type: Challenge | Difficulty: Medium | OS: NA | Author: D3v0o0Nu11 | Date: 2026-05-15 | Status: Solved Techniques: autocorrelation_key_length, known_plaintext_attack, multi_layer_xor_decryption, per_channel_xor_key_recovery

Summary

Task: 7488-byte binary file encrypted with three per-channel XOR keys plus a residual whole-file XOR layer. Solution: autocorrelation to find key lengths (10/6/14), exploit zero-plaintext region for key recovery, PNG signature known-plaintext for residual key, full decryption reveals flag image.

Recon

Port scan

bash
nmap -p- -sV -sC <TARGET> --min-rate 1000 -Pn
PortServiceVersionNotes
<PORT><SVC><VER><notes>

Enumeration highlights

  • Event: tjctf | ID: 20260515_tjctf_stained_glass
  • Tags: png, xor, repeating_key, multi_layer_encryption, per_channel_xor
  • Indicators: file size divisible by 3 suggesting per-channel structure, zero-plaintext region reveals XOR key directly, multiple distinct key lengths per channel (autocorrelation), residual XOR discovered via PNG signature known-plaintext, challenge mentions three brushes hinting at three XOR layers
  • Source: 20260515_tjctf_stained_glass.md

Foothold

Vulnerability / Misconfiguration

  1. autocorrelation_key_length
  2. known_plaintext_attack
  3. multi_layer_xor_decryption
  4. per_channel_xor_key_recovery
bash
<command>

Exploitation

  • See original writeup content for detailed exploitation.

Privilege Escalation

Enumeration

bash
sudo -l
find / -perm -4000 2>/dev/null
getcap -r / 2>/dev/null
cat /etc/crontab
ps aux

Exploitation

  1. N/A for challenge-type writeup; see exploitation above.
  2. Flag obtained via challenge solve.
bash
<command>

Flags

FlagLocationValue
flagREDACTED

Key Takeaways / Lessons

  • autocorrelation_key_length
  • known_plaintext_attack
  • multi_layer_xor_decryption
  • per_channel_xor_key_recovery
  • Tags: png, xor, repeating_key, multi_layer_encryption, per_channel_xor

Original Writeup

<details><summary>Click to expand original content</summary>

Description

three brushes painted over the window

Given a single file window.bin (7488 bytes). The goal is to decrypt it and recover the flag. The hint "three brushes" suggests three layers of encryption applied to a "window" (PNG image).

Analysis

File structure

The 7488-byte file can be viewed as three interleaved channels (byte positions mod 3), each encrypted with a different repeating XOR key. This matches the "three brushes" hint — each brush paints one channel.

Key length determination via autocorrelation

Autocorrelation analysis on each channel revealed distinct repeating key periods:

  • Channel 0 (file positions 0, 3, 6, ...): key period 10
  • Channel 1 (file positions 1, 4, 7, ...): key period 6
  • Channel 2 (file positions 2, 5, 8, ...): key period 14

Zero-plaintext region

A 304-byte region at file positions 160–463 showed extremely low byte diversity per-channel per-key-position — exactly 1 unique value at each (channel, key_position) slot. This indicated the plaintext was zero (null bytes) in that region, meaning the ciphertext bytes there directly reveal the per-channel XOR keys.

Residual XOR layer

After per-channel decryption, the first 8 bytes were 89 FB 6F 57 0F 0A 1A A1 — close to the PNG signature 89 50 4E 47 0D 0A 1A 0A but not exact. XORing the two revealed a period-6 residual key: [0x00, 0xAB, 0x21, 0x10, 0x02, 0x00]. This was confirmed against the IEND chunk at the file's end.

Solution

Step 1: Per-channel key recovery from zero region

The zero region starts at channel index 53 (file position 160 / 3 ≈ 53). Key position offsets must account for 53 % key_length:

python
#!/usr/bin/env python3
import struct

with open("window.bin", "rb") as f:
    data = bytearray(f.read())

n = len(data)  # 7488

# Split into 3 channels
channels = [data[i::3] for i in range(3)]

# Zero region: file positions 160-463 → channel indices 53-154 (approx)
# Each channel has ~101 bytes in this region

key_lengths = [10, 6, 14]
keys = [None, None, None]

# Channel 0: key length 10
# Zero region starts at channel index 53, so key offset = 53 % 10 = 3
ch0 = channels[0]
key0 = [0] * 10
for i in range(53, 155):
    pos = i % 10
    key0[pos] = ch0[i]
keys[0] = key0  # [190, 46, 30, 232, 144, 174, 62, 14, 248, 128]

# Channel 1: key length 6
# Zero region starts at channel index 53, so key offset = 53 % 6 = 5
ch1 = channels[1]
key1 = [0] * 6
for i in range(53, 155):
    pos = i % 6
    key1[pos] = ch1[i]
keys[1] = key1  # [94, 110, 103, 241, 137, 207]

# Channel 2: key length 14
# Zero region starts at channel index 53, so key offset = 53 % 14 = 11
ch2 = channels[2]
key2 = [0] * 14
for i in range(53, 155):
    pos = i % 14
    key2[pos] = ch2[i]
keys[2] = key2  # [235, 8, 35, 159, 152, 15, 23, 202, 41, 2, 190, 185, 46, 54]

Step 2: Per-channel decryption

python
# Decrypt each channel
for ch_idx in range(3):
    ch = channels[ch_idx]
    klen = key_lengths[ch_idx]
    key = keys[ch_idx]
    for i in range(len(ch)):
        ch[i] ^= key[i % klen]

# Reassemble
decrypted = bytearray(n)
for i in range(3):
    decrypted[i::3] = channels[i]

Step 3: Residual XOR removal

python
# PNG signature: 89 50 4E 47 0D 0A 1A 0A
png_sig = bytes([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A])
residual_key = [decrypted[i] ^ png_sig[i] for i in range(6)]
# Result: [0x00, 0xAB, 0x21, 0x10, 0x02, 0x00]

# Apply residual key (period 6)
for i in range(n):
    decrypted[i] ^= residual_key[i % 6]

with open("window_decrypted.png", "wb") as f:
    f.write(decrypted)

Step 4: Read the flag

The decrypted PNG (600×140 pixels, RGB) shows the flag text rendered upside-down (rotated 180°). After rotation:

</details>

signed by XESXOR