Skip to main content

RGB: writeup

RGB

CategoryStegano
DifficultyEasy
TechniqueRGB pixel encoding + Base32
Flagflag{qwerty_RGB_flag_example}

Recon

Простенькое задание.

Артефакт: png.image. Имя файла маскирует формат — расширение .image вместо .png.

file png.image
wc -c png.image
xxd -l 16 png.image
png.image: PNG image data, 1577 x 100, 8-bit/color RGB, non-interlaced
157753 png.image
00000000  89 50 4e 47 0d 0a 1a 0a  00 00 00 0d 49 48 44 52  |.PNG........IHDR|

Ключевые наблюдения:

  • Расширение .image — отвлекающий манёвр; внутри стандартный PNG (сигнатура 89 50 4E 47).
  • Размер ≈154 KB подозрительно мал для картинки 1577×100 пикселей.
  • Никаких поверхностных признаков LSB: цветов слишком мало для шума.

Проверяем явно: нет ли данных после чанка IEND (классический appended stego):

python3 - <<'PY'
import struct
from pathlib import Path

data = Path('png.image').read_bytes()
pos = 8
while pos < len(data):
    length = struct.unpack('>I', data[pos:pos+4])[0]
    chunk_type = data[pos+4:pos+8].decode('ascii', 'replace')
    pos += 12 + length
    if chunk_type == 'IEND':
        break
print('trailing bytes after IEND:', len(data) - pos)
PY
trailing bytes after IEND: 0

Результат: 0. Вывод: хвост пуст — искать нужно не после IEND, а в самих пикселях.


RGB Pixel Encoding

Stegano в пикселях — техника, при которой каждый пиксель хранит не цвет картинки, а закодированные данные.

Считаем уникальные цвета в изображении:

python3 - <<'PY'
from PIL import Image

img = Image.open('png.image').convert('RGB')
colors = img.getcolors(maxcolors=10_000_000)
print('size:', img.size)
print('unique colors:', len(colors))
print('top-5:', sorted(colors, reverse=True)[:5])
for ch, name in enumerate('RGB'):
    vals = sorted(set(px[ch] for px in img.getdata()))
    print(name, 'values:', vals)
PY
size: (1577, 100)
unique colors: 16
top-5: [(100, (77, 90, 87)), (100, (71, 67, 90)), ...]
R values: [50, 51, 55, 70, 71, 75, 76, 77, 78, 80, 81, 85, 85, 87, 87, 87]
G values: [...], B values: [...]

16 уникальных цветов на 1577×100 — слишком мало для обычной картинки. Значения каналов находятся в диапазоне 48–90, что совпадает с ASCII-кодами заглавных букв и цифр (0Z).

Образ: картинка — не изображение для глаз, а штрих-код для компьютера. Каждый цветной прямоугольник хранит три символа через R, G, B.

Ключевой вопрос: все ли строки одинаковые? Если да — блочная структура, и данные можно читать только из строки y=0.

python3 - <<'PY'
from PIL import Image

img = Image.open('png.image').convert('RGB')
w, h = img.size
row0 = [img.getpixel((x, 0)) for x in range(w)]
print('all rows identical:', all(
    img.getpixel((x, y)) == row0[x]
    for y in range(h) for x in range(w)
))
PY
all rows identical: True

Структура подтверждена. Декодируем первый блок вручную — цвет первого пикселя (77, 90, 87):

chr(77) = M · chr(90) = Z · chr(87) = W → первый токен MZW.

Алфавит MZW, GCZ, 33O … содержит только заглавные латинские буквы, цифры и = — это Base32.


Decryption

ШагДействиеПример
1Открыть PNG, взять строку y=0img.getpixel((x, 0))
2Выделить горизонтальные блоки по цветовым переходам (run-length)16 блоков
3Каждый цвет (R,G,B)chr(R)+chr(G)+chr(B)(77,90,87)MZW
4Склеить все токены в строкуMZWGCZ33OF3WK4TUPFPVER2CL5TGYYLHL5SXQYLNOBWGK7I=
5Декодировать через base32flag{qwerty_RGB_flag_example}

Попытка base64 даёт мусор: алфавит Base64 включает строчные буквы и /, которых в строке нет. Base32 — только заглавные + цифры + = — совпадает точно.

Root cause:

# Генератор задачи: флаг → base32 → тройки символов → цвета пикселей
encoded = base64.b32encode(FLAG.encode()).decode()
triplets = [encoded[i:i+3] for i in range(0, len(encoded), 3)]
for triplet in triplets:
    color = tuple(ord(ch) for ch in triplet)  # (R, G, B) = ASCII-коды символов
    # блок закрашивается этим цветом

PNG сохраняет значения пикселей без потерь — именно поэтому R, G, B точно соответствуют кодам символов.

Правильный подход (если нужно скрыть данные надёжнее):

  • Использовать настоящий LSB-стеганограф (изменения незаметны глазу, getcolors не выявит аномалию).
  • Добавить шумовые строки с разными цветами — число уникальных цветов перестанет быть признаком.
  • Зашифровать полезную нагрузку перед кодированием в пиксели.

Automation

from __future__ import annotations

import base64
from pathlib import Path

from PIL import Image


def extract_tokens(image_path: str) -> list[str]:
    image = Image.open(image_path).convert("RGB")
    width, _ = image.size
    row = [image.getpixel((x, 0)) for x in range(width)]

    runs: list[tuple[tuple[int, int, int], int]] = []
    current = row[0]
    count = 1
    for pixel in row[1:]:
        if pixel == current:
            count += 1
            continue
        runs.append((current, count))  # конец блока: сохраняем цвет и длину
        current = pixel
        count = 1
    runs.append((current, count))

    # Каждый цвет (R,G,B) → три ASCII-символа
    return ["".join(map(chr, color)) for color, _ in runs]


def main() -> None:
    image_path = Path(__file__).with_name("png.image")
    tokens = extract_tokens(str(image_path))
    encoded = "".join(tokens)           # склеиваем тройки → base32-строка
    decoded = base64.b32decode(encoded).decode()

    print(f"tokens:  {tokens}")
    print(f"encoded: {encoded}")
    print(f"flag:    {decoded}")


if __name__ == "__main__":
    main()
python3 solve.py
# tokens:  ['MZW', 'GCZ', '33O', 'F3W', 'K4T', 'UPF', 'PVE', 'R2C', 'L5T', 'GYY', 'LHL', '5SX', 'QYL', 'NOB', 'WGK', '7I=']
# encoded: MZWGCZ33OF3WK4TUPFPVER2CL5TGYYLHL5SXQYLNOBWGK7I=
# flag:    flag{qwerty_RGB_flag_example}

Key Takeaways

  1. PNG хранит пиксели без потерь. Значения R, G, B сохраняются точно — в отличие от JPEG, который изменяет их при сжатии. Это делает PNG удобным контейнером для кодирования данных в цветах.
  2. Мало уникальных цветов — главный признак пиксельного стегано. Обычная фотография 1577×100 содержит тысячи цветов; 16 уникальных означают искусственную структуру и указывают на кодирование в палитре.
  3. Ловушка задачи: расширение .image уводит в сторону appended data. Первый рефлекс — проверить хвост файла после IEND. Здесь хвост пуст, и участники, зациклившиеся на структуре PNG-чанков, теряют время. Секрет лежал не снаружи, а внутри — в значениях самих пикселей.