Deep
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 messageencoder.pt— PyTorch state_dict forDenseEncoder(data_depth=1, hidden_size=32)steganogan.py— SteganoGAN inference script supporting embed/extract operationstrain.py— full training script showing how encoder+decoder are trained jointlyrequirements.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:
- 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). - The RS-encoded bytes are unpacked into individual bits.
- A 32-zero-bit end-of-message marker is appended.
- The entire
(RS-encoded message + marker)sequence is repeated to fill the full image payload capacity (width × height × data_depthbits). - 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 read-only • copy allowedencoder = 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 read-only • copy alloweddecoder = 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 read-only • copy alloweddecoder.eval() with torch.no_grad(): bits = (decoder(stego).view(-1) > 0).int().cpu().numpy().tolist()
Step 4: Reed-Solomon Decode with Majority Voting
python read-only • copy allowedrs = 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 read-only • copy allowed#!/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()