Перестановка: writeup
Перестановка
| Category | Crypto |
| Difficulty | Medium |
| Cipher | Permutation + Rolling Vigenère (per-position shift) |
| Flag | flag{manually_analysis} |
Recon
Дано два файла: task.py — код, который строит три строки из флага, и output.txt — результат его выполнения. Нужно восстановить исходный флаг.
Артефакты: task.py, output.txt.
cat output.txt
naniy_aslgflmuaa{}salyl
zsvz}i{si}_n}rkpsruckhv
_n{}s}kzrcvkhipvsi}szur
Три строки — назовём их e1, e2, e3. Быстро смотрим генератор:
# task.py — три шага
enc, key1 = permute(flag) # шаг 1: случайная перестановка символов флага → e1
key2 = [randint(1,1337) for ...]
enc = rollingrollingrolling(enc, key2) # шаг 2: каждый символ сдвигается на key2[i] → e2
enc = arrange(enc, key1) # шаг 3: e2 раскладывается обратно по key1 → e3
Ключевые наблюдения:
- Алфавит $\Sigma$ =
abcdefghijklmnopqrstuvwxyz{}_, $|\Sigma| = 29$. key1(перестановка) в файл не записывается.key2(сдвиги) генерируются случайно и нигде не сохраняются.- Формат флага
flag{...}даёт 6 известных символов на конкретных позициях.
key2 восстановить нельзя — перебор $1337^{23}$ вариантов исключён. Значит, нужен другой путь.
Structural Bypass
Перестановка — операция, переставляющая символы строки в случайном порядке; ключ p задаёт, откуда берётся каждый символ.
Запишем три шага формально. Пусть p — вектор перестановки key1:
(символ флага на позиции $p_i$ становится символом e1 на позиции $i$)
(каждый символ сдвигается на неизвестное $k_i$)
$$ e_3[p_i] = e_2[i] $$(arrange кладёт $e_2[i]$ обратно на позицию $p_i$)
Из первого и третьего уравнения одновременно:
$$ e_1[i] = f\!\left[p_i\right], \qquad e_3\!\left[p_i\right] = e_2[i] $$Вывод: пара $(e_1[i],\; e_2[i])$ указывает на одну и ту же позицию $p_i$ и в флаге, и в $e_3$. Если мы нашли $j$ такой, что $e_3[j] = e_2[i]$ — то $f[j] = e_1[i]$.
Сдвиги $k_i$ в это рассуждение вообще не входят: они нужны только для связи $e_1 \to e_2$, но позиционная информация $i \leftrightarrow p_i$ сохраняется через пару $(e_1, e_3)$.
Ручной расчёт первого шага ($i = 0$):
e1[0] = 'n',e2[0] = 'z'- ищем
'z'вe3 = _n{}s}kzrcvkhipvsi}szur→ позиции 7 и 20 - кандидаты:
flag[7] = 'n'илиflag[20] = 'n' - оба пока допустимы: переходим к перебору с откатом
Decryption
Проблема неоднозначности: одинаковые символы в e3 порождают несколько сопоставлений $i \to j$. Решение — backtracking (перебор с откатом): пробуем вариант, фиксируем, рекурсируем, при конфликте откатываемся.
| Шаг | Действие | Детали |
|---|---|---|
| 1 | Построить карту позиций | positions[c] = список индексов j, где e3[j] == c |
| 2 | Упорядочить позиции | Сначала обрабатываем i с наименьшим количеством кандидатов — быстрее отсекаем |
| 3 | Backtracking | Для каждого i: пробуем каждый j ∈ positions[e2[i]], назначаем flag[j] = e1[i] |
| 4 | Отсечение | Конфликт с уже назначенным символом или нарушение flag{...} → откат |
| 5 | Верификация | Готовый кандидат прогоняется через логику task.py — проверяем все три строки |
Root cause — arrange раскрывает позиционную информацию:
def arrange(enc, key):
dec = [0]*len(enc)
for i in range(len(enc)):
dec[key[i]] = enc[i] # e3[key[i]] = e2[i]: позиция key[i] стала известна через e3
return ''.join(dec)
Пока key1 не опубликован, но e3 создаёт тот же эффект: наблюдатель видит, на какую позицию попал каждый символ e2.
Правильный подход:
- Выводить только одну строку (только
e1или толькоe3), никогда обе - Сделать
key2зависимым отkey1криптографически (HMAC), чтобы структура не раскрывалась - Использовать аутентифицированное шифрование (AES-GCM): тогда нет ни
e1, ниe3в открытом виде - Если задача педагогическая — добавить перемешивание индексов итогового вывода отдельным секретным ключом
Automation
from collections import defaultdict
from pathlib import Path
ALPHABET = 'abcdefghijklmnopqrstuvwxyz{}_'
# Словарь для ранжирования кандидатов по читаемости; применяется после верификации
COMMON_PARTS = {
'manually': 10, 'analysis': 10,
'manual': 8, 'analys': 7,
'ally': 4, 'lysis': 4,
'_': 2,
}
def load_output(path: str = 'output.txt') -> tuple[str, str, str]:
lines = [l.strip() for l in Path(path).read_text().splitlines() if l.strip()]
return tuple(lines) # возвращает (e1, e2, e3)
def recover_candidates(e1, e2, e3):
n = len(e1)
# positions_by_char[c] — все j, где e3[j] == c
positions_by_char = defaultdict(list)
for j, c in enumerate(e3):
positions_by_char[c].append(j)
# фиксированные позиции из формата flag{...}
fixed = {0: 'f', 1: 'l', 2: 'a', 3: 'g', 4: '{', n - 1: '}'}
# обрабатываем в порядке возрастания числа кандидатов → быстрее отсекаем
order = sorted(range(n), key=lambda i: len(positions_by_char[e2[i]]))
used = [False] * n
candidate = ['?'] * n
found = []
def bt(step):
if step == n:
w = ''.join(candidate)
if w.startswith('flag{') and w.endswith('}'):
found.append(w)
return
i = order[step]
for j in positions_by_char[e2[i]]:
if used[j]:
continue
c = e1[i]
if j in fixed and fixed[j] != c: # нарушает формат
continue
if candidate[j] not in ('?', c): # конфликт с ранее назначенным
continue
prev, used[j], candidate[j] = candidate[j], True, c
bt(step + 1)
candidate[j], used[j] = prev, False # откат
bt(0)
return sorted(set(found))
def verify(flag, e1, e2, e3):
# восстанавливаем key1 из кандидата и проверяем все три строки
pos = defaultdict(list)
for j, c in enumerate(e3):
pos[c].append(j)
used = [False] * len(flag)
key1 = [None] * len(flag)
for i, (l, r) in enumerate(zip(e1, e2)):
for j in pos[r]:
if not used[j] and flag[j] == l:
used[j] = True; key1[i] = j; break
if key1[i] is None:
return False
if ''.join(flag[j] for j in key1) != e1:
return False
shifts = [(ALPHABET.index(e2[i]) - ALPHABET.index(e1[i])) % 29 for i in range(len(e1))]
rolled = ''.join(ALPHABET[(ALPHABET.index(e1[i]) + shifts[i]) % 29] for i in range(len(e1)))
if rolled != e2:
return False
arr = ['?'] * len(flag)
for i, j in enumerate(key1):
arr[j] = e2[i]
return ''.join(arr) == e3
def score(flag):
inner = flag[5:-1]
return sum(w for p, w in COMMON_PARTS.items() if p in inner)
e1, e2, e3 = load_output()
candidates = [c for c in recover_candidates(e1, e2, e3) if verify(c, e1, e2, e3)]
candidates.sort(key=lambda c: (-score(c), c))
print(f'Flag: {candidates[0]}')
python3 solve.py
# Flag: flag{manually_analysis}
Key Takeaways
Structural Bypass. Когда шифр выдаёт несколько связанных выходных строк, иногда достаточно сопоставить пары строк, чтобы полностью обойти неизвестный параметр — даже не пытаясь его восстановить. Здесь
key2нигде не нужен: связь $(e_1[i], e_3)$ раскрывает позиции флага напрямую.Backtracking с отсечением. Когда прямой жадный алгоритм не работает из-за повторяющихся символов, добавляют перебор с откатом. Сортировка по числу кандидатов (
order) сокращает дерево поиска: сначала берём позиции с одним вариантом — они или сразу дают ответ, или быстро показывают противоречие.Ловушка задачи:
key2— ложный след. Участники тратят время на попытку восстановить случайные сдвиги (перебор $1337^{23} \approx 10^{72}$ вариантов). Настоящий ключ — не числаkey2, а структурная связь между тремя строками вывода, которую можно увидеть, только если выписать формулы для всех трёх шагов одновременно.
