Introduction
False Colour is a medium-difficulty, 200-point forensics challenge by Stella.
The eye sees RGB colours. A very pretty scene. The file sees more. Play with colours and try to paint with it.
Show your creative side. Find the hidden flag.
The challenge attachments were distributed through a Google Drive folder.
The archive contained images, misleading file extensions, and flag-shaped decoys. The actual solution required extracting a second PNG from the main image, investigating its alpha and colour channels, and recovering a compressed packet from its blue-channel least-significant bits.
Initial Reconnaissance
The main carrier,
images/image.png, was a 6000 × 4000 RGBA PNG.That distinction matters: RGBA includes an alpha channel in addition to red, green, and blue. The challenge description’s observation that “the file sees more” suggested inspecting data beyond the visible RGB scene.
Metadata inspection revealed the first actionable clue:
text
The image is only the carrier. GABR / 7919 / 104729
The three components suggested:
| Clue | Interpretation |
|---|---|
GABR | Repeating channel order: green, alpha, blue, red |
7919 | Starting pixel index |
104729 | Pixel traversal stride |
The image contains:
text
6000 × 4000 = 24000000 pixels
Flattening those pixels in row-major order allowed the extraction to operate on a single pixel-index space.
Look Beyond the Visible Image
The alpha channel and low-order colour bits can carry data without producing an obvious change in the displayed scene. File structure, metadata, and individual channels therefore provided stronger leads than visual inspection alone.
Vulnerability Analysis
Layer 1: Channel-Cycling LSB Steganography
This challenge used a deliberately constructed steganographic carrier rather than a software vulnerability.
For each output bit, the extraction selected a pixel using modular arithmetic:
text
pixel_index(i) = (7919 + i × 104729) mod 24000000
The channel advanced through the repeating sequence:
text
Green → Alpha → Blue → Red
For an RGBA array, that corresponds to channel indices:
python
channel_order = [1, 3, 2, 0]
The extracted bit was:
python
bit = pixels[pixel_index, channel_order[i % 4]] & 1
Reading the least-significant bit changes the interpretation of the image from a visible scene into a bitstream. Packing that stream into bytes exposed a framed payload containing another PNG.
The frame included a length and SHA-256 digest. The extracted 551 × 310 PNG matched the stored digest, confirming that the first extraction was exact.
Layer 2: Alpha-Channel Clues
Expanding the recovered image’s alpha-channel contrast revealed:
text
BLUE IS NOT WHERE YOU THINK
4241
7919
Its alpha-channel LSBs also contained a checksum-valid record with magic
AC3C and the hint:text
B2:4241|S7:7919
These clues redirected the investigation toward the recovered image’s blue channel.
Using start 4241 and stride 7919 recovered two
FC3C packets:text
DECOY!!!
B2:4241|S7:7919
The recovered image’s red and green LSBs also contained misleading flag strings.
A Valid Packet Can Still Be a Decoy
A matching SHA-256 digest establishes that a payload was extracted correctly. It does not establish that the payload is the intended flag. The challenge deliberately included correctly framed clue and decoy records.
Layer 3: Searching for Packet Structure
Searching only for the known
FC3C header constrained the investigation too heavily.The next step was to search for the broader record structure: a magic field, a version byte, a short payload length, and a trailing SHA-256 digest.
Of 39 candidate records, three passed verification:
- The known decoy packet.
- The known hint packet.
- A previously unidentified packet with magic
FC4F.
The final packet used these extraction parameters:
| Parameter | Value |
|---|---|
| Carrier | Recovered 551 × 310 PNG |
| Channel | Blue |
| Bit plane | Least-significant bit |
| Starting pixel | 27040 |
| Stride | 17307 |
| Magic | FC4F |
| Payload length | 86 bytes |
The final start and stride were discovered through the search. They were not directly derived from
B2:4241|S7:7919.The Decisive Change
Allowing the packet magic to vary exposed the final record. Reusing a previously observed header as a mandatory search condition had excluded the valid `FC4F` packet.
Exploitation / Solver
Extraction Pipeline
The complete recovery followed this sequence:
- Read the outer PNG as RGBA pixels.
- Extract LSBs using the
GABRchannel cycle and modular traversal. - Recover the embedded PNG and verify its SHA-256 digest.
- Inspect its alpha-channel clues and framed records.
- Extract the final blue-channel stream using start
27040and stride17307. - Recover the 86-byte payload and verify its packet digest.
- Decompress its zlib data and verify the resulting flag digest.
The
FC4F payload contained:- A four-byte original length.
- A SHA-256 digest of the uncompressed content.
- The zlib-compressed content.
Decompression produced 44 bytes.
Python Recovery Script
The following decoder reconstructs the extraction from the recovered parameters. It implements the recovery path after the final traversal parameters are known; it does not reproduce the original parameter search.
To avoid depending on unspecified frame-field offsets, it locates the embedded PNG through its standard signature and chunk structure. It locates the final payload by its known length and matching digest.
python
#!/usr/bin/env python3
"""
False Colour — recovery decoder
Author: Suraj Vishwanath
Dependencies:
python3 -m pip install pillow numpy
Usage:
python3 solve.py images/image.png
"""
import argparse
import hashlib
import io
import struct
import zlib
from pathlib import Path
import numpy as np
from PIL import Image
PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
def digest_present(data, digest):
"""Accept binary or ASCII hexadecimal SHA-256 storage."""
return (
digest in data
or digest.hex().encode() in data
or digest.hex().upper().encode() in data
)
def extract_bits(image, start, stride, channels, bit_count):
"""
Flatten pixels in row-major order and read one LSB per step.
channels repeats independently of the selected pixel index.
Bits are packed most-significant-bit first.
"""
pixels = np.asarray(image.convert("RGBA")).reshape(-1, 4)
channel_cycle = np.asarray(channels, dtype=np.int64)
output = bytearray()
chunk_size = 8 * 65536
for offset in range(0, bit_count, chunk_size):
count = min(chunk_size, bit_count - offset)
steps = np.arange(offset, offset + count, dtype=np.int64)
indices = (start + steps * stride) % len(pixels)
selected_channels = channel_cycle[steps % len(channel_cycle)]
bits = pixels[indices, selected_channels] & 1
output.extend(np.packbits(bits, bitorder="big").tobytes())
return bytes(output)
def recover_png(stream):
"""Locate a PNG and validate its chunk CRCs through IEND."""
search_offset = 0
while True:
start = stream.find(PNG_SIGNATURE, search_offset)
if start < 0:
raise ValueError("No valid embedded PNG found")
cursor = start + len(PNG_SIGNATURE)
try:
while cursor + 12 <= len(stream):
length = struct.unpack_from(">I", stream, cursor)[0]
end = cursor + 12 + length
if end > len(stream):
break
chunk_type = stream[cursor + 4:cursor + 8]
chunk_data = stream[cursor + 8:cursor + 8 + length]
stored_crc = struct.unpack_from(
">I", stream, cursor + 8 + length
)[0]
calculated_crc = zlib.crc32(
chunk_type + chunk_data
) & 0xFFFFFFFF
if calculated_crc != stored_crc:
break
cursor = end
if chunk_type == b"IEND":
png = stream[start:end]
# Confirm that Pillow can fully decode the image.
with Image.open(io.BytesIO(png)) as image:
image.load()
return png
except (OSError, ValueError, struct.error):
pass
search_offset = start + 1
def recover_flag(stream):
"""
Find a digest-verified 86-byte payload, then validate its
decompressed content against the digest stored inside it.
"""
if b"FC4F" not in stream:
raise ValueError("Expected FC4F marker was not recovered")
payload_length = 86
for start in range(len(stream) - payload_length + 1):
payload = stream[start:start + payload_length]
packet_digest = hashlib.sha256(payload).digest()
if not digest_present(stream, packet_digest):
continue
# Probe possible zlib offsets without assuming field order.
for compressed_offset in range(len(payload)):
try:
decoder = zlib.decompressobj()
plaintext = decoder.decompress(
payload[compressed_offset:]
)
plaintext += decoder.flush()
except zlib.error:
continue
if not decoder.eof:
continue
if not (
plaintext.startswith(b"BREACH{")
and plaintext.endswith(b"}")
):
continue
plaintext_digest = hashlib.sha256(plaintext).digest()
if not digest_present(payload, plaintext_digest):
continue
prefix = payload[:compressed_offset]
length = len(plaintext)
if not any(
struct.pack(order + "I", length) in prefix
for order in ("<", ">")
):
continue
return plaintext, payload
raise ValueError("No fully verified final payload found")
def main():
parser = argparse.ArgumentParser()
parser.add_argument("carrier", type=Path)
parser.add_argument("--output", type=Path, default=Path("recovered"))
args = parser.parse_args()
args.output.mkdir(parents=True, exist_ok=True)
with Image.open(args.carrier) as outer:
outer.load()
stream = extract_bits(
outer,
start=7919,
stride=104729,
channels=[1, 3, 2, 0], # G, A, B, R
bit_count=outer.width * outer.height,
)
embedded_png = recover_png(stream)
embedded_digest = hashlib.sha256(embedded_png).digest()
if not digest_present(stream, embedded_digest):
raise ValueError("Embedded PNG digest was not found")
embedded_path = args.output / "embedded.png"
embedded_path.write_bytes(embedded_png)
with Image.open(io.BytesIO(embedded_png)) as inner:
inner.load()
# Capture a generous prefix containing the final record.
final_stream = extract_bits(
inner,
start=27040,
stride=17307,
channels=[2], # Blue
bit_count=2048 * 8,
)
flag, payload = recover_flag(final_stream)
(args.output / "fc4f_payload.bin").write_bytes(payload)
(args.output / "flag.txt").write_bytes(flag + b"\n")
print("[+] Embedded PNG SHA-256 verified")
print("[+] Final payload SHA-256 verified")
print("[+] Decompressed content SHA-256 verified")
print(f"[+] Decompressed length: {len(flag)} bytes")
print(f"[+] Flag: {flag.decode('ascii')}")
if __name__ == "__main__":
main()
Run the Recovery Decoder
$python3 -m pip install pillow numpy
$python3 solve.py images/image.png
Solution & Flag Capture
The final
FC4F packet contained an 86-byte payload whose SHA-256 matched its stored packet digest. Its zlib stream decompressed to a 44-byte flag, and the decompressed content also matched its embedded SHA-256 digest.Those checks established that the recovered bytes matched the challenge’s final payload. The visible image, intermediate clues, and flag-shaped decoys were only parts of the path to that record.
Verified Recovery
The embedded PNG, final packet payload, and decompressed flag were verified against their stored SHA-256 digests.