← Back to Writeups
CUSTOMMediumForensics

Тоже мне спрятал... (As if you hid it...)

XESXOR9/2/20267 min read
#forensics#custom#medium#leak_pixel_recovery#multi_line_stacking#max_projection#count_projection#template_ocr_cross_correlation#vertical_ink_profile#octet_boundary_detection#windows

Тоже мне спрятал... (As if you hid it...)

Platform: bug-makers | Category: Forensics | Type: Challenge | Difficulty: Medium | OS: Windows | Author: XESXOR | Date: 2026-05-29 | Status: Solved Techniques: leak_pixel_recovery, multi_line_stacking, max_projection, count_projection, template_ocr_cross_correlation, vertical_ink_profile, octet_boundary_detection

Summary

Task: a Windows ping screenshot (PNG) with the server IP smudged out by a dark brush; recover the IP. Solution: ignore the red-herring domain DNS, recover redacted text from leak pixels by stacking the 15 identical reply lines (max/count projection) and running template-OCR built from the visible monospace ping numbers; key lesson — verify per-octet digit count by dot positions instead of forcing 3-digit octets.

Тоже мне спрятал... (As if you hid it...) — bug-makers CTF 2026

Description

Товарищ скинул скрин со статистикой пинга его сервера и затер на нем айпишник. Думает будто я его не смогу восстановить. Наивный :)

Translation: "A friend sent a screenshot with ping statistics of his server and smudged out the IP on it. He thinks I can't recover it. Naive :)"

Given: hidden_ping.png — a 647×560 RGBA PNG screenshot of a Windows command prompt running ping наебал.рф. The destination IP is painted over with a dark smudge brush in every place it appears (the bracket line, all 15 Ответ от ... reply lines, and the statistics line). ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

Goal: recover the redacted IPv4 address. Flag format BugCTF{IP}.

The visible (non-redacted) console output:

C:\Users\User>ping наебал.рф
Обмен пакетами с наебал.рф [<smudged IP>] с 32 байтами данных:
Ответ от <smudged IP>: число байт=32 время=148мс TTL=117
Ответ от <smudged IP>: число байт=32 время=136мс TTL=117
... (15 reply lines, times: 148,136,71,51,89,131,84,99,113,58,74,96,125,129,150)
Статистика Ping для <smudged IP>:
    Пакетов: отправлено = 15, получено = 15, потеряно = 0 (0% потерь)
    Минимальное = 51мсек, Максимальное = 150 мсек, Среднее = 103 мсек

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

Analysis

The red herring — DO NOT resolve the domain

The obvious move is to resolve the visible domain наебал.рф (punycode xn--80aacp6ah.xn--p1ai). This is a dead end and was explicitly built as a trap:

  • The domain is a troll/joke domain on Beget shared hosting (the page literally says "Вас средиректило на сайт наебал.рф").
  • Current DNS A record: 45.130.41.62.
  • Historical passive DNS (mnemonic.no, robtex, urlscan, hackertarget): 87.236.16.26, 5.101.153.46 (both 2024).
  • All resolvers (Google/Cloudflare/AdGuard DoH, check-host.net from Russian nodes) returned 45.130.41.62. ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

None of these are the flag. The screenshot is a fabricated ping: the IP shown in it has no relationship to the domain's real DNS history. Therefore the IP must be recovered from the pixels, not from the network.

Metadata/stego checks (exiftool, binwalk, zsteg, PNG chunk inspection) were all negative — a plain flattened PNG. No hidden layers, no LSB payload.

Why the redaction is recoverable

The smudge is an opaque dark brush (pixel values mostly 0–12) painted over the bright console text (value ~204). Two properties of this redaction make recovery possible: ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

  1. Leak pixels. Where the brush stroke was thin or semi-transparent, the alpha-blended result is (1-α)·original + α·brush. Because the brush color ≈ 0, surviving bright pixels are roughly proportional to the original text intensity. A histogram of the IP region showed ~99% of pixels destroyed, but ~150 pixels "leaked" through, some at the full 204 value. These leaks trace the underlying glyphs.

  2. Redundancy across lines. The same IP is rendered identically in 15 reply lines, but each line was smudged with a different random brush pattern. The true glyph ink recurs at the same (x,y) positions on every line; the smear does not. Stacking the lines separates signal from noise. ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

Solution

Step 1 — Stack the 15 identical reply lines

Crop the IP region from each of the 15 Ответ от ... lines (same x-range, aligned by row), then compute two projections across the stack:

  • Max-projection: max over the 15 lines at each pixel — recovers any leak pixel that survived on any line.
  • Count-projection: number of lines that are bright at each pixel — true text pixels recur on many lines (high count), random smear spikes once (low count). This is the most discriminative signal. ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

Sub-pixel alignment + CLAHE contrast enhancement (OpenCV) cleaned up the projections.

Step 2 — Build digit templates from the visible numbers

The console uses a fixed monospace font. Clean glyphs for digits 0–9 were harvested directly from the visible, unsmudged ping time values (148, 136, 117, …) in the very same image. Validated against the clean numbers, these templates gave 100% OCR accuracy — they are pixel-perfect for this font.

Step 3 — Establish the character grid and octet boundaries

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

  • The static text число begins right after <IP>:, anchoring a 9 px monospace pitch. The IP region spans from x=102 to the colon.
  • The three . separators are detected by a vertical ink profile: a dot has ink only on the baseline rows (≈7–8) and is dark elsewhere, unlike digit glyphs which have ink across the full glyph height. Locating the dots fixes the octet structure: XX.XXX.XXX.XX.

Step 4 — Template-match each character cell

Run normalized cross-correlation of each digit template against each character cell on the max/count projections: ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

  • Octets 1–3 recovered strongly and consistently: 67 . 143 . 247.
  • The last octet was the most heavily smudged. The count-projection read its digits as 8 then 9.

Step 5 — The critical 2-digit-octet lesson

The first hypothesis assumed the last octet was 3 digits. The OCR's strong leading "8" then forced 8XX, which is > 255 (invalid IPv4), so the agent discarded it and brute-forced "valid" 3-digit guesses (231, 221, 131, 121, 251, 211) — all rejected.

The mistake: the last octet was only two digits — 89. The dot-position analysis already showed the final cell was narrower (2 digits, not 3). Once the 2-digit reading REDACTED was tried, it was accepted. ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

Lesson: never force an octet to be 3 digits. Verify the digit count per octet from the dot positions/cell widths, and trust the OCR digit reads even when a (wrong) 3-digit interpretation would be invalid.

Conceptual solver outline

python
#!/usr/bin/env python3
# Recover a brush-redacted IP from a Windows ping screenshot.
# Core idea: the same IP is drawn on 15 reply lines, each with a different
# random smudge. Stacking the lines + leak-pixel recovery + template OCR
# (templates harvested from the visible ping-time digits) reassembles it.
‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

import numpy as np
from PIL import Image
import cv2

img = np.array(Image.open("hidden_ping.png").convert("L")).astype(np.float32)

# 1) Locate the 15 "Ответ от <IP>: ..." reply rows and the IP x-range.
#    (row y-centers found by horizontal text-row projection; IP x-range
#    runs from after "Ответ от " to the ':' before "число".)
REPLY_ROWS = [...]        # y-top of each of the 15 lines
IP_X0, IP_X1 = 102, 198   # IP column window
GLYPH_H = 14

# 2) Stack the IP windows across all 15 lines.
stack = np.array([img[y:y+GLYPH_H, IP_X0:IP_X1] for y in REPLY_ROWS])
‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

# Leak pixels: bright text survives where the brush was thin.
BRIGHT = 120
bright = stack > BRIGHT
maxproj   = stack.max(axis=0)          # any-line leak
countproj = bright.sum(axis=0)         # how many lines bright here (true text recurs)

# Enhance contrast for matching.
norm = cv2.createCLAHE(2.0, (4,4)).apply(
        cv2.normalize(countproj.astype(np.float32), None, 0, 255,
                      cv2.NORM_MINMAX).astype(np.uint8))

# 3) Templates 0-9 harvested from visible ping-time numbers (148,136,117,...).
templates = load_clean_digit_templates(img)   # 100% accurate on clean nums
‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

# 4) Detect '.' separators via vertical ink profile (ink only on baseline rows).
def is_dot(cell):
    col = (cell > BRIGHT).sum(axis=1)
    return col[:GLYPH_H-3].sum() == 0 and col[GLYPH_H-3:].sum() > 0

# 5) Walk the 9px monospace grid; classify each non-dot cell by NCC.
PITCH = 9
result, x = [], 0
while x + PITCH <= norm.shape[1]:
    cell = norm[:, x:x+PITCH]
    if is_dot(cell):
        result.append('.')
    else:
        scores = {d: cv2.matchTemplate(cell, t, cv2.TM_CCOEFF_NORMED).max()
                  for d, t in templates.items()}
        result.append(str(max(scores, key=scores.get)))
    x += PITCH
‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

ip = "".join(result)   # -> "REDACTED"
# IMPORTANT: octet count comes from dot positions; the last octet is 2 digits.
print("FLAG = BugCTF{%s}" % ip)

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