RGB: writeup
RGB
| Category | Stegano |
| Difficulty | Easy |
| Technique | RGB pixel encoding + Base32 |
| Flag | flag{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-кодами заглавных букв и цифр (0–Z).
Образ: картинка — не изображение для глаз, а штрих-код для компьютера. Каждый цветной прямоугольник хранит три символа через 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=0 | img.getpixel((x, 0)) |
| 2 | Выделить горизонтальные блоки по цветовым переходам (run-length) | 16 блоков |
| 3 | Каждый цвет (R,G,B) → chr(R)+chr(G)+chr(B) | (77,90,87) → MZW |
| 4 | Склеить все токены в строку | MZWGCZ33OF3WK4TUPFPVER2CL5TGYYLHL5SXQYLNOBWGK7I= |
| 5 | Декодировать через base32 | flag{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
- PNG хранит пиксели без потерь. Значения
R,G,Bсохраняются точно — в отличие от JPEG, который изменяет их при сжатии. Это делает PNG удобным контейнером для кодирования данных в цветах. - Мало уникальных цветов — главный признак пиксельного стегано. Обычная фотография
1577×100содержит тысячи цветов; 16 уникальных означают искусственную структуру и указывают на кодирование в палитре. - Ловушка задачи: расширение
.imageуводит в сторону appended data. Первый рефлекс — проверить хвост файла послеIEND. Здесь хвост пуст, и участники, зациклившиеся на структуре PNG-чанков, теряют время. Секрет лежал не снаружи, а внутри — в значениях самих пикселей.
