← Back to Writeups
CUSTOMMediumSteganography

Deep

XESXOR9/2/20265 min read
#steganography#custom#medium#reed_solomon_error_correction#repeated_message_majority_voti#steganogan_architecture_analys#surrogate_decoder_training#synthetic_data_generation_from

Deep

Platform: volgactf | Category: Steganography | Type: Challenge | Difficulty: Medium | OS: NA | Author: XESXOR | Date: 2026-03-28 | Status: Solved Techniques: reed_solomon_error_correction, repeated_message_majority_voting, steganogan_architecture_analysis, surrogate_decoder_training, synthetic_data_generation_from_frozen_encoder

Summary

Task: PNG image with SteganoGAN-embedded message, only encoder model provided (no decoder). Solution: train a surrogate decoder from scratch using the frozen encoder as a synthetic data generator, then decode the stego image and Reed-Solomon recover the repeated flag.

Deep — VolgaCTF 2026

Description

I learnt to hide my secrets deep.

Given a deep.zip archive containing:

  • stego.png — a 2040×1356 RGB PNG image with a hidden message
  • encoder.pt — PyTorch state_dict for DenseEncoder(data_depth=1, hidden_size=32)
  • steganogan.py — SteganoGAN inference script supporting embed/extract operations
  • train.py — full training script showing how encoder+decoder are trained jointly
  • requirements.txt — torch>=2.6, torchvision, tqdm, Pillow, reedsolo

The goal is to extract the hidden message from stego.png, but only the encoder model is provided — the decoder is missing. ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

Analysis

Initial Reconnaissance

Standard stego checks (file, exiftool, strings, xxd, binwalk) reveal nothing unusual about stego.png — it's a clean 2040×1356 RGB PNG with no metadata anomalies and no trailing data after the IEND chunk.

The encoder.pt file is a PyTorch state_dict containing weights for DenseEncoder(data_depth=1, hidden_size=32) — a 4-layer dense convolutional network that takes a cover image and a binary payload, producing a stego image via a residual connection (image + x). ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

Understanding the Embedding Pipeline

From steganogan.py, the embedding process works as follows:

  1. The plaintext message is UTF-8 encoded and then Reed-Solomon encoded with RSCodec(nsym=250, nsize=255) — very heavy error correction (250 ECC symbols per 5 data symbols in each 255-byte block).
  2. The RS-encoded bytes are unpacked into individual bits.
  3. A 32-zero-bit end-of-message marker is appended.
  4. The entire (RS-encoded message + marker) sequence is repeated to fill the full image payload capacity (width × height × data_depth bits).
  5. The payload tensor is fed to the encoder along with the cover image to produce the stego image. ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

The Missing Decoder Problem

The extraction script expects a decoder.pt file containing weights for DenseDecoder(data_depth=1, hidden_size=32). This file is not provided — only the encoder is given.

Direct analytical inversion of the encoder is infeasible because it involves nonlinear operations (Conv2d → LeakyReLU → BatchNorm2d) across 4 dense layers, and the encoder depends on both the unknown cover image and the payload.

Key Insight: Surrogate Decoder Training

From train.py, the encoder and decoder are trained jointly on random binary payload bits using BCE loss. The decoder learns to recover arbitrary random bits from encoder output — it doesn't need to see the actual message during training. ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

This means we can train a surrogate decoder from scratch:

  • The encoder's behavior is deterministic given its frozen weights.
  • We can generate unlimited synthetic (stego, payload) training pairs by running the encoder on arbitrary cover images with random payloads.
  • A fresh decoder trained on these synthetic pairs will learn to invert the encoder's embedding function.

The cover images used for synthetic training don't need to match the original cover — random crops from stego.png itself work well enough as approximate covers. ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

Solution

Step 1: Load the Encoder and Stego Image

python
encoder = DenseEncoder(1, 32).to(device)
encoder.load_state_dict(torch.load("encoder.pt", map_location="cpu"))
encoder.eval()

stego = load_image("stego.png", device)  # normalize to [-1, 1]

Step 2: Train a Surrogate Decoder

Initialize a fresh DenseDecoder(1, 32) and train it using synthetic data generated by the frozen encoder:

python
decoder = DenseDecoder(1, 32).to(device)
optimizer = torch.optim.Adam(decoder.parameters(), lr=3e-3)
loss_fn = torch.nn.BCEWithLogitsLoss()
‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

for step in range(400):
    # Random 64x64 crops from stego.png as approximate covers
    crops = [stego[:, :, y:y+64, x:x+64] for ...]
    cover = torch.cat(crops, dim=0)  # batch of 8
    payload = torch.randint(0, 2, (8, 1, 64, 64), device=device).float()

    # Generate synthetic stego images using frozen encoder
    with torch.no_grad():
        generated = encoder(cover, payload).clamp(-1.0, 1.0)

    # Train decoder to recover payload from synthetic stego
    logits = decoder(generated)
    loss = loss_fn(logits, payload)
    optimizer.zero_grad()
    loss.backward()
    optimizer.step()

‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

After ~400 steps, the decoder reaches ~96% bit accuracy on synthetic data.

Step 3: Decode the Real Stego Image

python
decoder.eval()
with torch.no_grad():
    bits = (decoder(stego).view(-1) > 0).int().cpu().numpy().tolist()

Step 4: Reed-Solomon Decode with Majority Voting

python
rs = RSCodec(nsym=250, nsize=255)
marker = b"\x00\x00\x00\x00"
ints = bytearray(np.packbits(bits, bitorder="big"))

candidates = Counter()
for candidate in ints.split(marker):
    try:
        text = rs.decode(candidate)[0].decode("utf-8")
        if text:
            candidates[text] += 1
    except BaseException:
        pass

‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

The heavy RS error correction (250 ECC symbols per block) compensates for the ~4% bit error rate of the surrogate decoder. The message is repeated ~54 times across the image, and majority voting across all successfully decoded copies confirms the flag.

Full Solver Script

python
#!/usr/bin/env python3
import random
from collections import Counter

import numpy as np
import torch
from PIL import Image
from reedsolo import RSCodec

from steganogan import DenseDecoder, DenseEncoder


def load_image(path, device):
    arr = np.array(Image.open(path), dtype=np.float32)
    arr = arr / 127.5 - 1.0
    return torch.FloatTensor(arr).permute(2, 0, 1).unsqueeze(0).to(device)
‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍


def decode_candidates(bits, rs):
    marker = b"\x00\x00\x00\x00"
    ints = bytearray(np.packbits(bits, bitorder="big"))
    candidates = Counter()
    for candidate in ints.split(marker):
        try:
            text = rs.decode(candidate)[0].decode("utf-8")
            if text:
                candidates[text] += 1
        except BaseException:
            pass
    return candidates


def main():
    device = (
        "mps"
        if getattr(torch.backends, "mps", None) and torch.backends.mps.is_available()
        else "cpu"
    )
‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

    encoder = DenseEncoder(1, 32).to(device)
    encoder.load_state_dict(torch.load("encoder.pt", map_location="cpu"))
    encoder.eval()

    stego = load_image("stego.png", device)
    _, _, height, width = stego.shape

    decoder = DenseDecoder(1, 32).to(device)
    optimizer = torch.optim.Adam(decoder.parameters(), lr=3e-3)
    loss_fn = torch.nn.BCEWithLogitsLoss()

    for step in range(400):
        crops = []
        for _ in range(8):
            y = random.randint(0, height - 64)
            x = random.randint(0, width - 64)
            crops.append(stego[:, :, y : y + 64, x : x + 64])
        cover = torch.cat(crops, dim=0)
        payload = torch.randint(0, 2, (8, 1, 64, 64), device=device).float()
‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

        with torch.no_grad():
            generated = encoder(cover, payload).clamp(-1.0, 1.0)

        logits = decoder(generated)
        loss = loss_fn(logits, payload)
        optimizer.zero_grad()
        loss.backward()
        optimizer.step()

    decoder.eval()
    with torch.no_grad():
        bits = (decoder(stego).view(-1) > 0).int().cpu().numpy().tolist()

    rs = RSCodec(nsym=250, nsize=255)
    candidates = decode_candidates(bits, rs)
    for text, count in candidates.most_common(10):
        print(f"{count:4d}x {text}")
‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍


if __name__ == "__main__":
    main()

‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍