Init. Commit

This commit is contained in:
Caplag
2026-09-17 00:50:07 +03:00
commit 880d8698d6
115 changed files with 7611 additions and 0 deletions
+47
View File
@@ -0,0 +1,47 @@
<h1 align="center">Он немного изменился</h1>
<p align="center">
<img src="https://img.shields.io/badge/category-Stego-blueviolet" alt="Stego"/>
</p>
На старте есть изображение `kot4_izmenilsya.png`. В метаданных указаны встроенный слепок и движение внутри клеток. Размер плитки и таблица направлений не указаны. Сначала ищем исходное изображение внутри PNG.
## Решение
В приватном ancillary-чанке `caTs` лежит конверт `CREF`: сигнатура, версия `1`, длина `u32 LE`, SHA-256 и байты чистого PNG. Проверяем CRC чанка, затем длину и SHA-256 конверта. Получаем эталонную картинку для сравнения.
Разность изображений показывает изменения в клетках, выровненных от `(0, 0)`. Пробуем размеры `4 × 4`, `8 × 8`, `16 × 16`. Для каждого сравниваем изменённую плитку с четырьмя циклическими сдвигами эталонной на один пиксель.
```python
import numpy as np
candidates = [
np.roll(tile, -1, axis=0), # вверх
np.roll(tile, 1, axis=1), # вправо
np.roll(tile, 1, axis=0), # вниз
np.roll(tile, -1, axis=1), # влево
]
```
Подходит только сетка **8 × 8**, в которой каждая изменённая плитка точно совпадает с одним из сдвигов. При других размерах движение пересекает предполагаемые границы плиток, и такого совпадения нет.
Однозначное направление можно прочитать только у плиток, которые действительно меняются при сдвиге. Поэтому пригодность проверяем по оригиналу, ещё до чтения битов. Все четыре сдвига должны различаться между собой и с исходной плиткой, а максимальная абсолютная разница по каналам между каждой сдвинутой плиткой и оригиналом не должна превышать `4`. Разность считаем в знаковом типе, чтобы вычитание `uint8` не переполнилось.
Обходим пригодные плитки по строкам. По подсказке первые использованные клетки служат для калибровки. В первых четырёх видим сдвиги вверх, вправо, вниз и влево. Пробуем сопоставить им пары `00`, `01`, `10`, `11` по порядку, а затем проверяем гипотезу по сигнатуре и CRC полученного потока:
| Направление | Пара бит |
|---|---|
| Вверх | `00` |
| Вправо | `01` |
| Вниз | `10` |
| Влево | `11` |
Пропускаем четыре калибровочные плитки и собираем байты по четыре пары, старшая первой. Сначала читаем девятибайтный заголовок с сигнатурой `CTM4`, версией `1` и длиной payload в `u32 LE`. Затем читаем payload указанной длины и ещё четыре байта CRC32 в little-endian. Полная длина конверта равна `9 + payload_length + 4` байтам. Совпадение сигнатуры, версии и CRC подтверждает выбранную карту направлений.
В payload читаем флаг и фразу «Это точно та же самая картинка. Мы почти уверены.» Встроенного эталона и самих различий хватило, чтобы восстановить схему без готовой карты в метаданных.
[Солвер](solve/solve.py).
## Флаг
`caplag{sdv1g_n4_0d1n_p1ks3l}`
+277
View File
@@ -0,0 +1,277 @@
#!/usr/bin/env python3
"""Независимый решатель задачи 4 «Он немного изменился» (CREF + CTM4).
Вход: участнический PNG. Метаданные намеренно НЕ раскрывают параметры
кодирования — решатель восстанавливает всё из данных. Логика:
1. ручной разбор PNG-чанков (struct), проверка CRC каждого чанка;
2. поиск приватного чанка caTs, разбор конверта CREF
(magic, version=1, length, sha256), извлечение чистого оригинала;
3. размер плитки — из выравнивания diff эталона и результата: для
кандидатов 4/8/16 каждая изменённая плитка обязана совпасть ровно с
одним из 4 циклических сдвигов эталонной плитки на 1 пиксель;
размер, прошедший проверку для ВСЕХ изменённых плиток, — рабочий;
4. сетка плиток от (0,0), обход по строкам; пригодность считается
ТОЛЬКО по оригиналу (4 сдвига попарно различны, ни один не равен
исходнику, max |дельта| по каналам <= 4);
5. для каждой пригодной плитки изменённое изображение сравнивается с 4
кандидатами (up/right/down/left): ровно одно совпадение, иначе отказ;
6. карта направлений — из калибровки: первые 4 пригодные плитки дают
up,right,down,left, то есть 00=up 01=right 10=down 11=left;
7. далее пары бит (старшая пара первой) -> конверт CTM4, узнаваемый
по magic в самих байтах (version=1, payload_length u32 LE, payload,
crc32 u32 LE);
8. печать payload (флаг + шутка).
Зависимости: stdlib + Pillow + numpy. Генератор не импортируется,
файлы организатора не читаются.
"""
import binascii
import hashlib
import struct
import sys
import numpy as np
from PIL import Image
TILE_CANDIDATES = (4, 8, 16)
MAXDIFF = 4
MAX_PAYLOAD = 1 << 20 # 1 МиБ
PNG_SIG = b"\x89PNG\r\n\x1a\n"
# up=00, right=01, down=10, left=11
DIRECTIONS = ("up", "right", "down", "left")
class SolveError(Exception):
pass
def parse_chunks(raw):
"""Ручной разбор PNG-чанков с проверкой CRC. Без Pillow."""
if raw[:8] != PNG_SIG:
raise SolveError("неверная сигнатура PNG")
chunks = []
off = 8
while True:
if off + 12 > len(raw):
raise SolveError("усечённый PNG (нет IEND)")
(length,) = struct.unpack(">I", raw[off:off + 4])
if length > len(raw) - off - 12:
raise SolveError("длина чанка выходит за границы файла")
ctype = raw[off + 4:off + 8]
data = raw[off + 8:off + 8 + length]
(crc_stored,) = struct.unpack(">I", raw[off + 8 + length:off + 12 + length])
crc_real = binascii.crc32(ctype + data) & 0xFFFFFFFF
if crc_real != crc_stored:
raise SolveError(f"неверная CRC чанка {ctype!r}")
chunks.append((ctype, data))
off += 12 + length
if ctype == b"IEND":
break
if off != len(raw):
raise SolveError("лишние байты после IEND")
return chunks
def extract_original(chunks):
"""Находит caTs, разбирает CREF, проверяет length и sha256."""
cats = [d for t, d in chunks if t == b"caTs"]
if len(cats) != 1:
raise SolveError(f"ожидался ровно один чанк caTs, найдено {len(cats)}")
env = cats[0]
if len(env) < 4 + 1 + 4 + 32:
raise SolveError("CREF: короткий конверт")
if env[:4] != b"CREF":
raise SolveError("CREF: неверный magic")
if env[4] != 1:
raise SolveError(f"CREF: version {env[4]} != 1")
(length,) = struct.unpack("<I", env[5:9])
if length != len(env) - (4 + 1 + 4 + 32):
raise SolveError("CREF: length не сходится с размером конверта")
original = env[41:]
if hashlib.sha256(original).digest() != env[9:41]:
raise SolveError("CREF: sha256 встроенного оригинала не сошёлся")
return original
def load_rgb(png_bytes, what):
import io
try:
img = Image.open(io.BytesIO(png_bytes)).convert("RGB")
except Exception as e:
raise SolveError(f"не удалось декодировать {what}: {e}")
return np.asarray(img, dtype=np.uint8)
def shift_tile(t, d):
"""Циклический сдвиг квадратной плитки ровно на 1 пиксель."""
if d == 0: # up: строка r получает старую строку r+1
return np.vstack([t[1:], t[:1]])
if d == 1: # right
return np.hstack([t[:, -1:], t[:, :-1]])
if d == 2: # down
return np.vstack([t[-1:], t[:-1]])
return np.hstack([t[:, 1:], t[:, :1]]) # left
def tile_eligible(t):
"""Пригодность ТОЛЬКО по оригиналу: 4 сдвига попарно различны,
ни один не равен исходнику, max |дельта| по каналам <= MAXDIFF."""
cands = [shift_tile(t, d) for d in range(4)]
for i in range(4):
if np.array_equal(cands[i], t):
return False
for j in range(i):
if np.array_equal(cands[i], cands[j]):
return False
ti = t.astype(np.int16)
for c in cands:
if int(np.abs(c.astype(np.int16) - ti).max()) > MAXDIFF:
return False
return True
def eligible_tiles(orig, tile):
h, w = orig.shape[:2]
out = []
for gy in range(h // tile):
for gx in range(w // tile):
t = orig[gy * tile:(gy + 1) * tile, gx * tile:(gx + 1) * tile]
if tile_eligible(t):
out.append((gx, gy))
return out
def changed_tiles(orig, mod, tile):
"""Индексы (gx, gy) плиток, содержащих хотя бы один изменённый пиксель."""
h, w = orig.shape[:2]
th, tw = h // tile, w // tile
diff = np.any(orig[:th * tile, :tw * tile] != mod[:th * tile, :tw * tile],
axis=2)
hit = diff.reshape(th, tile, tw, tile).any(axis=(1, 3))
return [(gx, gy) for gy in range(th) for gx in range(tw) if hit[gy, gx]]
def check_size_candidate(orig, mod, tile):
"""Быстрый отсев кандидата по выравниванию diff: каждая изменённая
плитка обязана быть ровно одним из 4 циклических сдвигов эталонной."""
for gx, gy in changed_tiles(orig, mod, tile):
t = orig[gy * tile:(gy + 1) * tile, gx * tile:(gx + 1) * tile]
m = mod[gy * tile:(gy + 1) * tile, gx * tile:(gx + 1) * tile]
hits = [d for d in range(4) if np.array_equal(shift_tile(t, d), m)]
if len(hits) != 1:
raise SolveError(
f"размер {tile}x{tile} отклонён: плитка (gx={gx} gy={gy}): "
f"совпадений с кандидатами {len(hits)}, ожидалось ровно 1")
def decode_with_tile(orig, mod, tile):
"""Полное декодирование при заданном размере плитки: пригодность,
калибровка, пары бит, конверт CTM4. Возвращает (payload, n_elig, n_used)."""
elig = eligible_tiles(orig, tile)
def read_dir(idx):
gx, gy = elig[idx]
t = orig[gy * tile:(gy + 1) * tile, gx * tile:(gx + 1) * tile]
m = mod[gy * tile:(gy + 1) * tile, gx * tile:(gx + 1) * tile]
hits = [d for d in range(4) if np.array_equal(shift_tile(t, d), m)]
if len(hits) != 1:
raise SolveError(
f"плитка #{idx} (gx={gx} gy={gy}): совпадений с кандидатами "
f"{len(hits)}, ожидалось ровно 1")
return hits[0]
# Калибровка: первые 4 пригодные плитки — up, right, down, left.
for i in range(4):
if len(elig) <= i:
raise SolveError("слишком мало пригодных плиток для калибровки")
d = read_dir(i)
if d != i:
raise SolveError(
f"калибровка не сошлась: плитка #{i} = {DIRECTIONS[d]}, "
f"ожидалось {DIRECTIONS[i]}")
# Пары бит: в байте сначала старшая пара. 4 плитки = 1 байт.
pairs = []
total = None # полная длина конверта в байтах, станет известна после заголовка
idx = 4
header_len = 4 + 1 + 4
while True:
have = len(pairs) // 4
if total is not None and have >= total:
break
if total is None and have >= header_len:
hdr = bytes()
for k in range(header_len):
hdr += bytes([pairs[4 * k] << 6 | pairs[4 * k + 1] << 4
| pairs[4 * k + 2] << 2 | pairs[4 * k + 3]])
if hdr[:4] != b"CTM4":
raise SolveError("CTM4: неверный magic")
if hdr[4] != 1:
raise SolveError(f"CTM4: version {hdr[4]} != 1")
(plen,) = struct.unpack("<I", hdr[5:9])
if plen > MAX_PAYLOAD:
raise SolveError(f"CTM4: payload_length {plen} превышает лимит")
total = header_len + plen + 4
continue
if idx >= len(elig):
raise SolveError("конверт CTM4 не поместился в пригодные плитки")
pairs.append(read_dir(idx))
idx += 1
data = bytearray()
for k in range(total):
data.append(pairs[4 * k] << 6 | pairs[4 * k + 1] << 4
| pairs[4 * k + 2] << 2 | pairs[4 * k + 3])
(plen,) = struct.unpack("<I", bytes(data[5:9]))
payload = bytes(data[header_len:header_len + plen])
(crc_stored,) = struct.unpack("<I", bytes(data[header_len + plen:total]))
if binascii.crc32(payload) & 0xFFFFFFFF != crc_stored:
raise SolveError("CTM4: crc32 payload не сошёлся")
return payload, len(elig), idx
def solve(path, quiet=False):
with open(path, "rb") as f:
raw = f.read()
chunks = parse_chunks(raw)
orig_png = extract_original(chunks)
orig = load_rgb(orig_png, "встроенный оригинал")
mod = load_rgb(raw, "участнический PNG")
if orig.shape != mod.shape:
raise SolveError(f"размеры не совпадают: {mod.shape} vs {orig.shape}")
# Размер плитки восстанавливаем из данных: кандидат проходит быстрый
# отсев по выравниванию diff, затем полное декодирование с калибровкой.
errors = []
for tile in TILE_CANDIDATES:
try:
check_size_candidate(orig, mod, tile)
payload, n_elig, n_used = decode_with_tile(orig, mod, tile)
except SolveError as e:
errors.append(f"[{tile}x{tile}] {e}")
continue
if not quiet:
print(f"размер плитки из данных: {tile}x{tile}", file=sys.stderr)
print(f"пригодных плиток: {n_elig}", file=sys.stderr)
return payload, n_elig, n_used
raise SolveError(
"ни один размер плитки не подошёл: " + " | ".join(errors))
def main(argv):
if len(argv) != 2:
print(f"usage: {argv[0]} <participant.png>", file=sys.stderr)
return 2
try:
payload, n_elig, n_used = solve(argv[1])
except (SolveError, OSError) as e:
print(f"ошибка: {e}", file=sys.stderr)
return 1
text = payload.decode("utf-8", errors="strict")
print(f"пригодных плиток: {n_elig}, использовано: {n_used}", file=sys.stderr)
print(text)
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))