← Back to Writeups
CUSTOMMediumSteganography

Новогодняя открытка (New Year Card)

XESXOR9/2/20264 min read
#steganography#custom#medium#connected_components_labeling#morse_code_decoding#per_row_symbol_grouping#glyph_size_binary_encoding#visual_element_stego

Новогодняя открытка (New Year Card)

Platform: hackerlab | Category: Steganography | Type: Challenge | Difficulty: Medium | OS: NA | Author: XESXOR | Date: 2025-12-21 | Status: Solved Techniques: connected_components_labeling, morse_code_decoding, per_row_symbol_grouping, glyph_size_binary_encoding, visual_element_stego

Summary

Task: a New Year greeting PNG with scattered grey snowflakes; LSB/alpha/metadata stego all dead-end. Solution: snowflakes come in two pixel-area sizes (dot/dash); group them per horizontal row and decode each row as one Morse letter to spell REDACTED.

Новогодняя открытка (New Year Card) — hackerlab

Challenge

А у Вас уже снег пошёл?

("Has snow started falling for you yet?")

We are given a ZIP containing a single PNG Новогодняя открытка.png (1920×1200, 8-bit RGBA, ~9.2 MB). The image is a festive greeting card: white background, blue/silver Christmas baubles, silver glitter, blue text "С наступающим Новым Годом!", and many grey snowflake glyphs scattered across the white area.

Flag format: CODEBY{...} (lowercase inside the braces).

Recon

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

Standard stego triage was performed first:

  • exiftool / PNG chunk inspection — only a tEXt chunk Comment\0Created with GIMP. No zTXt/iTXt, nothing hidden in metadata.
  • binwalk reported YAFFS filesystem signatures, but these were false positives (no real embedded data).
  • No data appended after the PNG IEND chunk.

Dead ends (what did NOT work)

These were all eliminated — important to record so the same ground isn't covered twice:

  • Alpha-channel stego — the alpha channel is uniform 255, so nothing is hidden in transparency.
  • RGB LSB extraction (both interleaved and per-channel bit planes 0, 1, 2) — pure noise, no printable text.
  • Appended-data / metadata carving — nothing after IEND; only the benign GIMP comment tEXt chunk.
  • Flat bitstream over snowflakes — reading all 47 snowflakes sorted by (y, x) with large=1 / small=0 produced 47 bits b'\x05\x9a\xe3\xb2\xb1', which is not byte-aligned and looks like noise. This failure is itself a clue: the data is grouped per row (as Morse letters), not a continuous bitstream. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Key insight

The hint "снег пошёл" (the snow has started falling) points to the visible decoration as the carrier, and to the reading order: each falling line of snow = one row = one letter.

Two observations unlock it:

  1. The snowflakes are all exactly one grey color, RGB(103,103,103). Connected-components labeling isolates 47 blobs.
  2. There are two distinct snowflake glyphs, distinguishable by pixel area:
    • SMALL ~453–459 px → Morse dot .
    • LARGE ~551–562 px → Morse dash -
  3. The snowflakes lie on 16 horizontal rows (y ≈ 37, 110, 183, …, 1132, step ~73). X positions within a row are irregular; per-row count varies from 1 to 4. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Treat each row as one Morse letter: read its snowflakes left→right, map small→dot / large→dash, and look up the Morse character.

Solution

  1. Build a mask of the snowflake color (103,103,103).
  2. scipy.ndimage.label → 47 connected components; compute each blob's centroid and pixel area.
  3. Group blobs by their row (centroid y), sort within each row by x.
  4. Map area to dot/dash (threshold ~500 px), concatenate per row, decode Morse.
python
from PIL import Image
import numpy as np
from scipy import ndimage
from collections import defaultdict
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

a = np.array(Image.open('card.png'))[:, :, :3]
mask = (a[:,:,0]==103) & (a[:,:,1]==103) & (a[:,:,2]==103)
lbl, n = ndimage.label(mask)
sizes = ndimage.sum(mask, lbl, range(1, n+1))
coms  = ndimage.center_of_mass(mask, lbl, range(1, n+1))
flakes = [(round(coms[i][0]), round(coms[i][1]), sizes[i]) for i in range(n) if sizes[i] > 50]

rows = defaultdict(list)
for y, x, s in flakes:
    rows[y].append((x, s))

MORSE = {'.-':'A','-...':'B','-.-.':'C','-..':'D','.':'E','..-.':'F','--.':'G',
         '....':'H','..':'I','.---':'J','-.-':'K','.-..':'L','--':'M','-.':'N',
         '---':'O','.--.':'P','--.-':'Q','.-.':'R','...':'S','-':'T','..-':'U',
         '...-':'V','.--':'W','-..-':'X','-.--':'Y','--..':'Z'}
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

msg = ''
for y in sorted(rows):
    seq = ''.join('-' if s > 500 else '.' for x, s in sorted(rows[y]))
    msg += MORSE.get(seq, '?')
print(msg)  # REDACTED

Row-by-row decode (every one of the 16 rows is a valid Morse character — strong confirmation):

RowMorseLetter
1....H
2.-A
3.--.P
4.--.P
5-.--Y
6-.N
7.E
8.--W
9-.--Y
10.E
11.-A
12.-.R
13--.G
14..-U
15-.--Y
16...S
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Message: REDACTED. Wrap in the flag format with lowercase contents. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍