← Back to Writeups
HTBN/ASteganography

Static Image

XESXOR8/23/20266 min read
#steganography#htb#n/a

Static Image

Platform: Broncoctf2026 | Category: Steganography | Type: Challenge | Difficulty: Medium | OS: NA | Author: D3v0o0Nu11 | Date: 2026-07-11 | Status: Solved Techniques: ffmpeg_rawvideo_dump, majority_vote_glyph_extraction, near_duplicate_pair_xor, telecine_cadence_detection

Summary

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 frame[i] and frame[i+2] are bit-identical except inside a fixed banner box, XOR the thresholded near-duplicate pairs to cancel the frozen noise and reveal one flag glyph per ~10-frame block, majority-vote and concatenate 25 glyphs.

Recon

Port scan

nmap -p- -sV -sC <TARGET> --min-rate 1000 -Pn
PortServiceVersionNotes
<PORT><SVC><VER><notes>

Enumeration highlights

  • Event: broncoctf2026 | ID: 20260711_broncoctf2026_static_image
  • Tags: ffmpeg, mp4, video_steganography, telecine, frame_differencing, xor_frame_diff, monochrome_noise, temporal_analysis
  • Indicators: monochrome TV-static MP4 with no audio, 60fps with very high bitrate to preserve noise near-losslessly, title/description pun on 'static' vs 'dynamic, frame[i] bit-identical to frame[i+2] (telecine cadence), near-duplicate frame pairs differ only inside a small fixed rectangular box
  • Source: 20260711_broncoctf2026_static_image.md

Foothold

Vulnerability / Misconfiguration

  1. Ffmpeg_rawvideo_dump
  2. Majority_vote_glyph_extraction
  3. Near_duplicate_pair_xor
  4. Telecine_cadence_detection
<command>

Exploitation

  • See original writeup content for detailed exploitation.

Privilege Escalation

Enumeration

sudo -l
find / -perm -4000 2>/dev/null
getcap -r / 2>/dev/null
cat /etc/crontab
ps aux

Exploitation

  1. N/A for challenge-type writeup; see exploitation above.
  2. Flag obtained via challenge solve.
<command>

Flags

FlagLocationValue
flagREDACTED

Key Takeaways / Lessons

  • ffmpeg_rawvideo_dump
  • majority_vote_glyph_extraction
  • near_duplicate_pair_xor
  • telecine_cadence_detection
  • Tags: ffmpeg, mp4, video_steganography, telecine, frame_differencing, xor_frame_diff, monochrome_noise, temporal_analysis

Original Writeup

<details><summary>Click to expand original content</summary>

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.
#!/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.

</details>

Auto-tracked: saved to WriteUps; run /xesor-revise to fold lessons into XESXor_Methodology.md.

signed by XESXOR