← Back to Writeups
CUSTOMMediumSteganography

Static Image

XESXOR9/10/20265 min read
#steganography#custom#medium#ffmpeg_rawvideo_dump#majority_vote_glyph_extraction#near_duplicate_pair_xor#telecine_cadence_detection

Static Image

Platform: CUSTOM | Category: Steganography | Type: Challenge | Difficulty: Medium | OS: N/A | **Author: XESXOR Techniques: ffmpeg_rawvideo_dump, majority_vote_glyph_extraction, near_duplicate_pair_xor, telecine_cadence_detection

Summary

Walkthrough of the CUSTOM Steganography Static Image. Task: a 300x300 60fps monochrome TV-static MP4 with no audio; the pun 'now that's dynamic' points to inter-frame motion, not per-pixel temporal aggregates. Solution: detect the telecine cadence where

Description

"It's not often that you do static analysis outside of Reversing, is it?"

Artifact: static.mp4

English summary: We are given static.mp4, a 300x300, 60fps, 1500-frame, yuv420p, monochrome "TV static" white-noise video with no audio and a single video stream (~58 MB, ~18 Mbps — a bitrate high enough to preserve the noise nearly losslessly). The flag is hidden somewhere in the noise; the goal is to recover it.

Analysis

Semantic clue — the whole challenge is a pun

  • "static" = TV static (visual noise) AND static analysis.
  • The intended read of the description is a play on dynamic: "now that's dynamic". The flag itself is bronco{REDACTED}.
  • Takeaway: the signal is not in any static/averaged image of the noise — it is in the motion between frames (the dynamic part).

Why per-pixel temporal aggregation fails

The obvious first attack on a stack of noise frames is to reduce them per pixel over time (mean, median, min/max, mode, std, white-probability, bit-plane counts, temporal FFT). Every single one of these produced pure noise:

  • The per-pixel white-probability across 1500 frames ranges 0.43–0.57, which is exactly the spread of 1500 fair Bernoulli samples. There is no embedded per-pixel bias image.
  • Temporal FFT, 2D spatial FFT magnitude/phase, phase-coherent complex average
    • inverse, global XOR of all frames, transition counts, first-vs-last diff — all noise, no watermark.

Conclusion: no information is carried by the marginal distribution of any single pixel. The data must live in the temporal correlation structure between frames.

The telecine cadence

Comparing thresholded frames revealed that most frames are bit-identical to the frame two positions later: threshold(frame[i]) == threshold(frame[i+2]). The video is a telecine-style cadence — a frozen noise background is repeated on a fixed period. Treated as a bitstream (i == i+2 or not) this cadence is a perfectly periodic square wave (period 60, "111111100000..."); it is pure structure and carries no data by itself. This is a decoy: it tells you the video is built from repeated frozen frames, nothing more.

The real signal — near-duplicate-but-different pairs

In 25 evenly spaced blocks of ~10 consecutive frames each (block start frames 33, 93, 153, ..., 1473; period 60, hence 25 blocks), the pair (i, i+2) is not identical — but the difference is confined to a small fixed rectangular box (e.g. around x=88–215, y=232–263 for one block). Outside that box the two frames are the same frozen noise.

Because the background noise is identical in both frames of a near-duplicate pair, threshold(frame[i]) ^ threshold(frame[i+2]) cancels the frozen background and leaves only the box — which contains a single flag glyph (one character). 25 blocks → 25 characters → the full flag.

Averaging the XOR mask over all ~10 pairs in a block (majority vote) removes the residual per-pixel noise flicker inside the box and yields a clean glyph.

Solution

  1. Dump all frames to raw grayscale with ffmpeg.
  2. Threshold each frame at 127 to a boolean image.
  3. Find every (i, i+2) pair whose Hamming distance is nonzero but small (0 < d < 20000) — these are the near-duplicate-but-different pairs.
  4. Group consecutive such pairs (step ≤ 3) into per-character blocks.
  5. Per block, accumulate threshold(frame[i]) ^ threshold(frame[i+2]) and majority-vote to a clean glyph.
  6. Crop each glyph and concatenate all 25 in order → the flag.
python
#!/usr/bin/env python3
import subprocess, numpy as np, cv2

# 1. dump all 1500 frames as raw 8-bit grayscale
raw = subprocess.check_output(
    ['ffmpeg', '-v', 'error', '-i', 'static.mp4',
     '-f', 'rawvideo', '-pix_fmt', 'gray', '-'])
a = np.frombuffer(raw, np.uint8).reshape(-1, 300, 300)
th = a > 127                                   # 2. threshold -> boolean

# 3. near-duplicate telecine pairs (i, i+2): differ, but only slightly
pairs = []
for i in range(len(a) - 2):
    d = np.count_nonzero(th[i] ^ th[i + 2])
    if 0 < d < 20000:
        pairs.append((i, i + 2, d))

# 4. group consecutive pairs (step 3) into per-character blocks
blocks, cur = [], [pairs[0]]
for p in pairs[1:]:
    if p[0] - cur[-1][0] <= 3:
        cur.append(p)
    else:
        blocks.append(cur); cur = [p]
blocks.append(cur)

# 5. per block: majority-vote XOR mask -> one glyph
crops = []
for blk in blocks:
    acc = np.zeros((300, 300), int)
    for (i, j, d) in blk:
        acc += (th[i] ^ th[j])
    g = (acc > len(blk) // 2).astype('uint8') * 255
    ys, xs = np.where(g > 0)
    crops.append(g[ys.min():ys.max() + 1, xs.min():xs.max() + 1])

# 6. crop + concatenate all 25 glyphs in order
H = max(c.shape[0] for c in crops)
row = [np.pad(c, ((0, H - c.shape[0]), (3, 3))) for c in crops]
cv2.imwrite('flag_strip.png', np.hstack(row))   # reads: bronco{REDACTED}

The output image flag_strip.png spells the flag left-to-right across its 25 glyphs.

Dead ends (conclusively ruled out — do not repeat)

All of the following produced only noise:

  • Per-pixel temporal aggregates: mean, median, min, max, mode, std, range, white-probability, counts of ==0/==255/>=240. (White-prob range 0.43–0.57 = expected spread of 1500 Bernoulli samples; no embedded bias image.)
  • Frequency domain: temporal FFT per pixel; averaged 2D spatial FFT magnitude/phase; phase-coherent complex average + inverse.
  • Whole-stack ops: global XOR of all frames, transition counts, first-vs-last diff.
  • Exact-duplicate cadence alone (i == i+2): a perfectly periodic square-wave (period 60) — telecine structure, carries no data by itself.
  • Per-frame metrics: brightness, entropy, neighbor-similarity — no single frame is a clean image.
  • Chroma / channels: U/V constant ~127 (monochrome), R=G=B.
  • Byte/container level: spatial LSB within frames; raw MP4 byte bit-planes; packet sizes as an image; container atoms clean (ftyp/free/mdat/moov, no trailing/appended data, no extra tracks/metadata); MPEG-4 user_data only holds the encoder string; codec QP uniform.

The signal is only in the near-duplicate-but-different (i, i+2) pairs.

Key Takeaways / Lessons

  • Mastering ffmpeg_rawvideo_dump, majority_vote_glyph_extraction, near_duplicate_pair_xor, telecine_cadence_detection was key to this challenge.
  • Always check for common misconfigurations and verify assumptions.

Crafted by XESXOR Research • 2026-07-11 • CUSTOM • Static Image • Difficulty: Medium