Init. commit
This commit is contained in:
233
stego-art-gallery/solve/solver.py
Normal file
233
stego-art-gallery/solve/solver.py
Normal 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}")
|
||||
Reference in New Issue
Block a user