Новогодняя открытка (New Year Card)
Новогодняя открытка (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 atEXtchunkComment\0Created with GIMP. NozTXt/iTXt, nothing hidden in metadata.binwalkreported YAFFS filesystem signatures, but these were false positives (no real embedded data).- No data appended after the PNG
IENDchunk.
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 bitsb'\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:
- The snowflakes are all exactly one grey color,
RGB(103,103,103). Connected-components labeling isolates 47 blobs. - There are two distinct snowflake glyphs, distinguishable by pixel area:
- SMALL ~453–459 px → Morse dot
. - LARGE ~551–562 px → Morse dash
-
- SMALL ~453–459 px → Morse dot
- 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
- Build a mask of the snowflake color
(103,103,103). scipy.ndimage.label→ 47 connected components; compute each blob's centroid and pixel area.- Group blobs by their row (centroid y), sort within each row by x.
- Map area to dot/dash (threshold ~500 px), concatenate per row, decode Morse.
python read-only • copy allowedfrom 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):
| Row | Morse | Letter |
|---|---|---|
| 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.