Invisible WORDs picoCTF 2023 Solution

Published: April 26, 2023

Description

A BMP image with suspicious noise hides a ZIP archive in its blue and green color channels. Extract the pixel data, skip the red and alpha bytes, and unzip the result to find the flag.

Download the BMP image.

Open it in CyberChef or a hex editor to inspect the file structure.

bash
wget 'https://artifacts.picoctf.net/c/371/Invisible WORDs.bmp'
bash
pip3 install pillow

Solution

Want to try it yourself first?

The guided walkthrough reveals hints one step at a time.

Walk me through it
  1. Step 1
    Inspect the image and identify the anomaly
    Observation
    I noticed the challenge description calls out a BMP file with 'suspicious noise,' which suggested that data was embedded at the channel level rather than the pixel LSBs, so inspecting each color channel individually was the right first move to locate which channels carried the anomaly.
    Open the image in CyberChef and split into color channels. The blue and green channels show obvious noise patterns at the bottom of the image, while the red channel looks normal. This indicates data hidden in the blue and green channels. The file header reveals a 32-bit bit depth, meaning pixels are stored as BGRA (blue, green, red, alpha).
    python
    python3 -c "from PIL import Image; img = Image.open(\"Invisible WORDs.bmp\"); print(img.mode, img.size)"
    What didn't work first

    Tried: Open the BMP in steghide and attempt to extract with an empty passphrase

    steghide operates on JPEG and BMP files using a DCT/LSB approach and expects a passphrase-protected container. It will report 'steghide: could not extract any data' because the data here is not embedded via steghide - it occupies entire color channels rather than LSBs. The correct approach is to inspect channel-level pixel values, not steghide's hidden-payload container format.

    Tried: Convert the BMP to PNG with ImageMagick and run zsteg on the result

    zsteg targets PNG and BMP files but looks for LSB-plane patterns and known stego signatures. This BMP encodes two full bytes per pixel across the blue and green channels, not individual LSBs, so zsteg's plane scans will not surface the ZIP magic bytes. The image also needs to stay in BMP format so the raw BGRA pixel layout is preserved during extraction.

    Learn more

    BMP files with 32-bit color store pixels as four bytes per pixel: Blue, Green, Red, Alpha (BGRA). The BMP file header at offset 0x0A stores the pixel array start offset. Examining this value reveals where the raw pixel data begins, which helps when working at the byte level without a library.

    In CyberChef, the Split Colour Channels operation separates an image into its component channels. Channels carrying hidden data typically appear as random noise or structured patterns that differ from the rest of the image. Clean channels look smooth and gradual.

  2. Step 2
    Extract blue and green bytes with Python
    Observation
    I noticed that the blue and green channels both showed structured noise while red and alpha were clean, which suggested the hidden payload was interleaved across exactly those two channels at two bytes per pixel in the BGRA layout, making a Python script the right tool to walk the pixel array and collect them.
    The pixel array starts at byte 0x8A in this BMP. Each pixel is 4 bytes: BGRA. To extract the hidden data, read two bytes (blue and green) and skip two bytes (red and alpha) for every pixel until the end of the file. Write the extracted bytes to a new file.
    python
    python3 - <<'PY'
    with open("Invisible WORDs.bmp", "rb") as f:
        data = f.read()
    
    # BMP pixel data starts at offset stored at bytes 0x0A..0x0D (little-endian)
    import struct
    pixel_start = struct.unpack_from("<I", data, 0x0A)[0]  # typically 0x8A = 138
    
    out = bytearray()
    i = pixel_start
    while i + 3 < len(data):
        out.append(data[i])      # blue
        out.append(data[i + 1])  # green
        i += 4                   # skip red and alpha
    
    with open("output.bin", "wb") as f:
        f.write(out)
    
    print(f"Extracted {len(out)} bytes to output.bin")
    PY
    What didn't work first

    Tried: Extract only the blue channel (every 4th byte starting from pixel_start) instead of both blue and green

    Reading only byte 0 of each pixel yields half the hidden payload, producing a truncated file that binwalk cannot recognize as a valid ZIP because the central directory and file headers are split across both channels. The ZIP is reconstructed by interleaving two bytes per pixel (blue at i, green at i+1), so both must be written consecutively into the output buffer.

    Tried: Start reading from byte 0 of the file instead of using the header offset at 0x0A

    The first 138 bytes (0x00 to 0x89) are BMP file and info headers, not pixel data. Treating them as BGRA pixels injects garbage at the start of the output and corrupts the ZIP magic bytes (PK\x03\x04), causing any subsequent unzip or binwalk attempt to fail. Reading struct.unpack_from('<I', data, 0x0A)[0] gives the correct pixel array start for this specific BMP.

    Learn more

    The BMP file format stores the pixel array offset at bytes 0x0A through 0x0D as a little-endian 32-bit integer. For this particular file the value is 0x8A (138 decimal), meaning pixel data starts 138 bytes into the file.

    BGRA pixel layout means each group of 4 bytes is one pixel: byte 0 is blue, byte 1 is green, byte 2 is red, byte 3 is alpha. Skipping two bytes and reading two is the pattern: read at i and i+1, then advance i by 4.

  3. Step 3
    Identify and unzip the embedded archive
    Observation
    I noticed that the extracted output.bin could start with leading garbage before the actual file signature, which suggested using binwalk to scan for the PK magic bytes and carve out the valid ZIP section rather than calling unzip directly on the raw output.
    The extracted binary file is a ZIP archive, but it may be corrupted. Run binwalk to find valid ZIP data inside it, then extract to get a text file. Search the text for the flag.
    bash
    binwalk output.bin
    bash
    binwalk -e output.bin
    bash
    grep -r 'picoCTF' _output.bin.extracted/

    Expected output

    picoCTF{w0rd_d4wg_y0u_f0und_5h3113ys_m4573rp13c3_...}
    What didn't work first

    Tried: Run unzip output.bin directly without using binwalk first

    The extracted byte stream may have leading garbage before the first valid PK\x03\x04 signature, causing unzip to report 'End-of-central-directory signature not found' or extract nothing. binwalk -e carves out the valid ZIP section by seeking to the correct offset, so the extracted archive is clean and unzip-able. Always binwalk first to confirm the offset before trying unzip directly.

    Tried: Search for the flag inside the extracted text file with strings output.bin instead of grep on the extracted directory

    Running strings on output.bin searches the raw binary before extraction, and the flag text is compressed inside the ZIP, so it will not appear as a readable string in the compressed data. After binwalk -e creates _output.bin.extracted/, the ZIP is decompressed into a plain text file where grep -r 'picoCTF' can match the flag directly.

    Learn more

    binwalk scans a binary file for known magic numbers and reports embedded file formats. ZIP archives start with the bytes PK\x03\x04. Even if the outer file appears broken, binwalk -e can carve out valid ZIP sections. The extracted archive in this challenge contains a text file, which turns out to be the text of Frankenstein, with the flag hidden inside.

    After extraction, use grep -r 'picoCTF' on the extracted directory to find the flag without reading the entire novel.

Interactive tools
  • StegallDrop any file and Stegall runs every applicable steg technique in parallel: LSB sweeps, bit planes, spectrograms, polyglot carving, metadata, whitespace decode, and a 6-layer base/ROT/XOR/zlib cascade. Recursively unpacks results and surfaces flag matches.
  • Hex ViewerView text or raw hex bytes as a xxd-style hex dump with byte offset, hex columns, and ASCII sidebar. Highlights printable characters and null bytes.
  • Strings ExtractorPull printable text from any binary, library, or image. ASCII and UTF-16 detection, configurable minimum length, flag-like highlight, no command line needed.

Flag

Reveal flag

picoCTF{w0rd_d4wg_y0u_f0und_5h3113ys_m4573rp13c3_...}

The flag is embedded in a text file (the novel Frankenstein by Mary Shelley) inside the ZIP archive hidden in the BMP image. The trailing 8-character hex suffix is generated per instance and will differ from the value shown here.

Key takeaway

Steganography can exploit the structural layout of a file format rather than the least significant bits of individual pixels. By assigning an entire color channel to carry a payload, the hidden data is invisible to casual inspection because the visual difference between channels is not apparent at a glance. Understanding a format's byte layout (pixel ordering, channel interleaving, header offsets) is the key skill, and it transfers to any binary format where some fields are less perceptually meaningful than others, including audio samples, video frame padding, and PDF embedded streams.

Related reading

Want more picoCTF 2023 writeups?

Useful tools for Forensics

What to try next