Init. commit

This commit is contained in:
Caplag
2026-04-22 10:42:16 +03:00
commit 98e51ca58b
35 changed files with 2371 additions and 0 deletions

View File

@@ -0,0 +1,54 @@
<h1 align="center">Художественная галерея</h1>
<p align="center">
<img src="https://img.shields.io/badge/category-Stego-blueviolet" alt="Stego"/>
<img src="https://img.shields.io/badge/points-979-critical" alt="979 pts"/>
</p>
Изучаем выданный `gallery.psd` — PSD-файл с пятью слоями:
| # | Имя | Состояние |
|---|---|---|
| 0 | `Background` | видимый |
| 1 | `Title` | видимый |
| 2 | `Pattern A` | скрытый |
| 3 | `Pattern B` | скрытый |
| 4 | `Encrypted` | скрытый |
Два из трёх скрытых слоёв — ложный след, а настоящий QR-код лежит в третьем и дополнительно зашифрован AES'ом.
## Решение
PSD разбираем руками по [официальной спецификации Adobe](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/). Структура файла такая:
```mermaid
block-beta
columns 1
H["8BPS header"]
CM["Color Mode Data"]
IR["Image Resources<br/>XMP ID 0x0424 ← ключ к расшифровке"]
LMI["Layer and Mask Info<br/>слои, флаги, каналы"]
ID["Image Data"]
classDef base fill:#f3f4f6,stroke:#6b7280,color:#111827
classDef hit fill:#fef3c7,stroke:#f59e0b,color:#78350f
class H,CM,LMI,ID base
class IR hit
```
В секции `Image Resources` ищем XMP-метаданные (блок с ID `0x0424`) — там и запрятан ключ ко всей задаче. Внутри XMP смотрим на тег `<xmp:CreateDate>`: ISO 8601 timestamp создания файла. В секции `Layer and Mask Information` для каждого слоя лежит имя, флаги видимости (бит `0x02` в flags = «скрытый»), `opacity` и сырые данные каналов. Пиксельные данные хранятся несжатыми — можно напрямую залить в `numpy`-массив `h × w`.
Первое, что теперь приходит в голову при виде двух скрытых «паттернов» — это XOR'нуть их между собой. Берём `Pattern A` и `Pattern B`, XOR — получается картинка с вполне читаемым QR-кодом. Сканируем, внутри флаг, таск решён… *кроме того, что флаг внутри фейковый*. Настоящий QR живёт в третьем скрытом слое — `Encrypted`, и это шифротекст исходного QR в режиме AES-ECB.
Ключ для AES собирается из той самой timestamp-метки:
```python
aes_key = sha256(timestamp.encode()).digest()[:16]
```
Расшифровываем R-канал в `AES.MODE_ECB`, снимаем PKCS#7-паддинг по последнему байту, интерпретируем результат как `h × w` grayscale-картинку. Внутри — настоящий QR, внутри QR — флаг. Если `pyzbar` с первого раза не узнал код, помогает `NEAREST`-апскейл в 23 раза.
Готовый солвер — [`solve/solver.py`](solve/solver.py).
## Флаг
`caplag{l4y3rs_0f_d3c3pt10n_unv31l3d}`

View File

@@ -0,0 +1,233 @@
#!/usr/bin/env python3
"""
Решение для задания 6: Художественная галерея (усложнённая версия)
PSD-файл содержит 5 слоёв:
0: Background (видимый)
1: Title (видимый)
2: Pattern A (скрытый) — случайный шум
3: Pattern B (скрытый) — шум,содержащий ложный QR
4: Encrypted (скрытый) — верный QR, зашифрованный AES-ECB
Цепочка решения:
1. Разобрать PSD, найти скрытые слои
2. XOR слоёв 2 и 3 -> ЛОЖНЫЙ QR -> фейковый флаг (ловушка!)
3. Обнаружить слой 4 ("Encrypted") — третий скрытый слой
4. Извлечь временну́ю метку создания из XMP-метаданных PSD
5. Получить AES-ключ: SHA256(временная_метка)[:16]
6. Расшифровать слой 4 через AES-ECB -> настоящий QR -> настоящий флаг
"""
import sys, os, struct, io, hashlib, re
import numpy as np
from PIL import Image
from pyzbar.pyzbar import decode as decode_qr
from Crypto.Cipher import AES
def parse_psd_layers(filepath):
"""Разбирает PSD-файл и извлекает изображения всех слоёв и XMP-метаданные."""
with open(filepath, 'rb') as f:
data = f.read()
buf = io.BytesIO(data)
# ── Заголовок файла ──────────────────────────────────────────────────────
sig = buf.read(4)
assert sig == b'8BPS' # Сигнатура формата PSD
version = struct.unpack('>H', buf.read(2))[0]
buf.read(6) # Зарезервированные байты
num_channels = struct.unpack('>H', buf.read(2))[0]
height = struct.unpack('>I', buf.read(4))[0]
width = struct.unpack('>I', buf.read(4))[0]
depth = struct.unpack('>H', buf.read(2))[0] # Бит на канал
color_mode = struct.unpack('>H', buf.read(2))[0] # 3 = RGB
print(f"[*] PSD: {width}x{height}")
# ── Секция Color Mode Data (для RGB — пустая) ────────────────────────────
cm_len = struct.unpack('>I', buf.read(4))[0]
buf.read(cm_len)
# ── Секция Image Resources — здесь хранятся XMP-метаданные ──────────────
ir_len = struct.unpack('>I', buf.read(4))[0]
ir_start = buf.tell()
ir_data = buf.read(ir_len)
# Ищем блок XMP среди ресурсов изображения
xmp_data = None
ir_buf = io.BytesIO(ir_data)
while ir_buf.tell() < len(ir_data) - 12:
try:
sig = ir_buf.read(4)
if sig != b'8BIM': # Сигнатура каждого ресурса
break
res_id = struct.unpack('>H', ir_buf.read(2))[0]
name_len = struct.unpack('>B', ir_buf.read(1))[0]
ir_buf.read(name_len)
if (1 + name_len) % 2 != 0: # Выравнивание до чётного байта
ir_buf.read(1)
data_len = struct.unpack('>I', ir_buf.read(4))[0]
res_data = ir_buf.read(data_len)
if data_len % 2 != 0: # Выравнивание данных ресурса
ir_buf.read(1)
if res_id == 0x0424: # ID 0x0424 = XMP-метаданные
xmp_data = res_data
print(f"[+] Найден XMP-ресурс ({len(xmp_data)} байт)")
except:
break
# Извлекаем временну́ю метку создания из XMP
timestamp = None
if xmp_data:
xmp_str = xmp_data.decode('utf-8', errors='ignore')
# Тег <xmp:CreateDate> содержит дату/время в формате ISO 8601
m = re.search(r'<xmp:CreateDate>([^<]+)</xmp:CreateDate>', xmp_str)
if m:
timestamp = m.group(1)
print(f"[+] Временна́я метка создания: {timestamp}")
# ── Секция Layer and Mask Information ────────────────────────────────────
lm_len = struct.unpack('>I', buf.read(4))[0]
li_len = struct.unpack('>I', buf.read(4))[0]
# Отрицательное значение означает, что первый альфа-канал — маска прозрачности
layer_count = abs(struct.unpack('>h', buf.read(2))[0])
print(f"[*] Количество слоёв: {layer_count}")
layers = []
for i in range(layer_count):
# Координаты прямоугольника слоя
top = struct.unpack('>i', buf.read(4))[0]
left = struct.unpack('>i', buf.read(4))[0]
bottom = struct.unpack('>i', buf.read(4))[0]
right = struct.unpack('>i', buf.read(4))[0]
lw = right - left
lh = bottom - top
# Список каналов слоя (id канала + длина данных)
n_ch = struct.unpack('>H', buf.read(2))[0]
channels = []
for _ in range(n_ch):
ch_id = struct.unpack('>h', buf.read(2))[0] # -1=alpha, 0=R, 1=G, 2=B
ch_len = struct.unpack('>I', buf.read(4))[0]
channels.append((ch_id, ch_len))
buf.read(4) # Сигнатура режима наложения
blend_mode = buf.read(4) # Код режима наложения (norm, mul и т.д.)
opacity = struct.unpack('>B', buf.read(1))[0] # 0 = полностью прозрачный
buf.read(1) # Clipping
flags = struct.unpack('>B', buf.read(1))[0]
buf.read(1) # Зарезервированный байт
# Бит 0x02 флагов означает, что слой скрыт (невидим)
visible = not (flags & 0x02)
# Дополнительные данные слоя: маска, диапазоны смешения, имя
extra_len = struct.unpack('>I', buf.read(4))[0]
extra_start = buf.tell()
mask_len = struct.unpack('>I', buf.read(4))[0]
buf.read(mask_len)
blend_range_len = struct.unpack('>I', buf.read(4))[0]
buf.read(blend_range_len)
name_len = struct.unpack('>B', buf.read(1))[0]
name = buf.read(name_len).decode('ascii', errors='ignore')
buf.seek(extra_start + extra_len) # Перепрыгиваем остаток extra-данных
layers.append({
'name': name, 'width': lw, 'height': lh,
'opacity': opacity, 'visible': visible,
'channels': channels,
})
print(f" Слой {i}: '{name}' {lw}x{lh} opacity={opacity} visible={visible}")
# ── Чтение пиксельных данных каналов ────────────────────────────────────
for layer in layers:
lw, lh = layer['width'], layer['height']
channel_data = {}
for ch_id, ch_len in layer['channels']:
compression = struct.unpack('>H', buf.read(2))[0] # 0=Raw, 1=PackBits RLE
pixel_data = buf.read(ch_len - 2)
if compression == 0 and lw * lh > 0:
# Несжатые данные: напрямую читаем в массив нужного размера
arr = np.frombuffer(pixel_data[:lw * lh], dtype=np.uint8).reshape((lh, lw))
else:
# Сжатые/пустые данные — заполняем нулями (достаточно для нашей задачи)
arr = np.zeros((lh, lw), dtype=np.uint8)
channel_data[ch_id] = arr
layer['channel_data'] = channel_data
return layers, timestamp
def solve(filepath: str) -> str:
layers, timestamp = parse_psd_layers(filepath)
# Скрытые слои: невидимые или с нулевой непрозрачностью
hidden = [l for l in layers if not l['visible'] or l['opacity'] == 0]
print(f"\n[*] Найдено скрытых слоёв: {len(hidden)}")
if len(hidden) < 3:
return "ОШИБКА: ожидалось 3 скрытых слоя"
# Ищем зашифрованный слой по имени; если не нашли — берём третий скрытый
enc_layer = None
for l in hidden:
if 'ncrypt' in l['name'].lower(): # "Encrypted", "encrypted" и т.п.
enc_layer = l
break
if not enc_layer:
enc_layer = hidden[2]
print(f"[*] Зашифрованный слой: '{enc_layer['name']}'")
# Извлекаем зашифрованные байты из R-канала (id=0) или альфа-канала (id=-1)
enc_data = enc_layer['channel_data'].get(0, enc_layer['channel_data'].get(-1))
enc_bytes = enc_data.tobytes()
# ── Деривация AES-ключа из временно́й метки ──────────────────────────────
if not timestamp:
return "ОШИБКА: не удалось найти временну́ю метку создания"
# Ключ = первые 16 байт SHA-256 от строки временно́й метки (128-битный AES)
aes_key = hashlib.sha256(timestamp.encode()).digest()[:16]
print(f"[*] AES-ключ: {aes_key.hex()}")
# ── Расшифровка AES-ECB ──────────────────────────────────────────────────
cipher = AES.new(aes_key, AES.MODE_ECB)
decrypted = cipher.decrypt(enc_bytes)
# Удаляем PKCS#7-паддинг: последний байт указывает количество байт паддинга
pad_len = decrypted[-1]
if 1 <= pad_len <= 16:
decrypted = decrypted[:-pad_len]
# Восстанавливаем изображение QR-кода из расшифрованных байт
h, w = enc_layer['height'], enc_layer['width']
dec_array = np.frombuffer(decrypted[:h * w], dtype=np.uint8).reshape((h, w))
# ── Декодирование QR-кода ────────────────────────────────────────────────
img = Image.fromarray(dec_array, 'L') # 'L' = grayscale
results = decode_qr(img)
if results:
return results[0].data.decode('utf-8')
# Если QR не распознался — пробуем увеличить изображение (pyzbar любит крупные QR)
for scale in [2, 3]:
scaled = img.resize((w * scale, h * scale), Image.NEAREST)
results = decode_qr(scaled)
if results:
return results[0].data.decode('utf-8')
return "ОШИБКА: не удалось декодировать QR после расшифровки"
if __name__ == '__main__':
psd_path = os.path.join(os.path.dirname(os.path.abspath(__file__)),
'..', 'public', 'gallery.psd')
# Путь к файлу можно передать первым аргументом командной строки
if len(sys.argv) > 1:
psd_path = sys.argv[1]
flag = solve(psd_path)
print(f"[+] Флаг: {flag}")