← Back to Writeups
CUSTOMMediumSteganography

Незваный гоуст

XESXOR9/10/20264 min read
#steganography#custom#medium#cell_centroid_extraction#gaussian_smoothing#local_background_subtraction

Незваный гоуст

Platform: avitoctf | Category: Steganography | Type: Challenge | Difficulty: Medium | OS: NA | Author: XESXOR | Date: 2026-07-22 | Status: Solved Techniques: cell_centroid_extraction, gaussian_smoothing, local_background_subtraction

Summary

Task: A static Ghost Font PNG hides text in a regular field of jittered dots rather than conventional pixel bit planes. Solution: Measure each 8x8 cell's vertical ink centroid, smooth the feature grid, and subtract the local background.

Uninvited Ghost — avitoctf

Description

Пчёлы с Полянки 38 поставили на всех каналах сотовой связи СОРМ: ИИ читает переписку и смотрит картинки, докладывая о каждом упоминании мёда. Медоед-разведчик нашёл свежий сервис Ghost Font https://www.mixfont.com/ghost-font, который обещает делать текст нечитаемым для ИИ, и набрал им важное послание.

Правда, разбираться в инструкции он не стал: вместо видео прислал в штаб один скриншот и сразу отключил связь. До вылазки осталось полчаса, а на картинке только шум.

Разберите послание. Текст на картинке — флаг.

https://avitoctf.ru/files/ghost_screenshot.png Флаг в этом задании не в формате avito{...}, а фраза «WRITTEN IN GHOST FONT» добавляется сервисом на каждую картинку, это не секретное послание.

The supplied artifact is a single screenshot of a Ghost Font animation. The goal is to recover the hidden text from the apparent dot noise; the visible service attribution is an explicit decoy.

Analysis

ghost_screenshot.png is a 1918×997 RGBA PNG. Container and channel checks found no useful metadata, appended payload, varying alpha, or conventional sequential LSB stream. The image therefore had to be analyzed as the rendered output of Ghost Font rather than as an ordinary PNG bit-plane challenge.

Ghost Font forms glyphs through coherent dot movement while background dots move independently. Although motion is unavailable in one screenshot, each dot's instantaneous displacement remains measurable. The white canvas begins near (97, 40) and is 1712 pixels wide, exactly 214 × 8 pixels. Thresholding dark pixels and dividing the canvas into 8×8 cells produces a 119×214 grid with approximately one dot per cell.

For every cell, compute the centroid of its dark pixels. Dot area, covariance, shape, and horizontal centroid are dominated by background jitter, but the vertical centroid forms a weak spatially coherent signal. A small Gaussian blur suppresses independent noise and reveals two text lines. Subtracting a much broader blur removes gradual page-wide drift and improves contrast:

text
signal = GaussianBlur(vertical_centroid, 1.2)
       - GaussianBlur(vertical_centroid, 7.0)

The result contains leetspeak-like glyphs that can be mistaken for letters. Since the task requires the text exactly as rendered, preserve the confirmed transcription rather than normalizing ambiguous characters.

Solution

  1. Crop the visible Ghost Font canvas at (97, 40, 1809, 997).
  2. Convert it to grayscale and classify pixels below intensity 100 as ink.
  3. Trim the crop to complete 8×8 cells and reshape it into a dot-cell grid.
  4. Compute each cell's vertical dark-pixel centroid.
  5. Smooth the centroid map with sigma ≈ 1.2–1.4.
  6. Optionally subtract a sigma = 7.0 background estimate.
  7. Crop the two glyph rows, enlarge with nearest-neighbor interpolation, and read the text without character normalization.

The following concise solver reproduces the high-pass proof image:

python
#!/usr/bin/env python3
from pathlib import Path

import cv2
import numpy as np
from PIL import Image

root = Path(__file__).resolve().parent
rgba = np.asarray(Image.open(root / "ghost_screenshot.png").convert("RGBA"))

# Ghost Font canvas: 214 complete columns of 8x8 dot cells.
x0, y0, x1, y1 = 97, 40, 1809, 997
gray = cv2.cvtColor(rgba[y0:y1, x0:x1, :3], cv2.COLOR_RGB2GRAY)
ink = (gray < 100).astype(np.uint8)

h = ink.shape[0] // 8 * 8
w = ink.shape[1] // 8 * 8
cells = ink[:h, :w].reshape(h // 8, 8, w // 8, 8).transpose(0, 2, 1, 3)

yy = np.mgrid[:8, :8][0]
area = cells.sum(axis=(2, 3)).astype(np.float32)
cy = (cells * yy).sum(axis=(2, 3)) / np.maximum(area, 1)

fine = cv2.GaussianBlur(cy.astype(np.float32), (0, 0), 1.2)
coarse = cv2.GaussianBlur(cy.astype(np.float32), (0, 0), 7.0)
decoded = (fine - coarse)[24:89, 12:207]

lo, hi = np.percentile(decoded, (3, 99))
decoded = np.clip((decoded - lo) * 255 / (hi - lo), 0, 255).astype(np.uint8)
decoded = cv2.resize(decoded, None, fx=8, fy=8, interpolation=cv2.INTER_NEAREST)
Image.fromarray(decoded).save(root / "decoded_phrase_highpass.png")

Evidence and Artifacts

  • ghost_screenshot.png — original 1918×997 RGBA challenge artifact.
  • analyze_ghost.py — complete feature extraction and proof-image generator.
  • decoded_phrase.png — vertically smoothed centroid map.
  • decoded_phrase_highpass.png — local-background-subtracted map; some glyphs remain visually ambiguous, so exact transcription must not be inferred through normalization.
  • notes.md — reconnaissance, failed families, and extraction findings.