Skip to main content

Перестановка: writeup

Перестановка

CategoryCrypto
DifficultyMedium
CipherPermutation + Rolling Vigenère (per-position shift)
Flagflag{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:

$$ e_1[i] = f[p_i] $$

(символ флага на позиции $p_i$ становится символом e1 на позиции $i$)

$$ e_2[i] = e_1[i] \oplus_{\Sigma} k_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 с наименьшим количеством кандидатов — быстрее отсекаем
3BacktrackingДля каждого 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

  1. Structural Bypass. Когда шифр выдаёт несколько связанных выходных строк, иногда достаточно сопоставить пары строк, чтобы полностью обойти неизвестный параметр — даже не пытаясь его восстановить. Здесь key2 нигде не нужен: связь $(e_1[i], e_3)$ раскрывает позиции флага напрямую.

  2. Backtracking с отсечением. Когда прямой жадный алгоритм не работает из-за повторяющихся символов, добавляют перебор с откатом. Сортировка по числу кандидатов (order) сокращает дерево поиска: сначала берём позиции с одним вариантом — они или сразу дают ответ, или быстро показывают противоречие.

  3. Ловушка задачи: key2 — ложный след. Участники тратят время на попытку восстановить случайные сдвиги (перебор $1337^{23} \approx 10^{72}$ вариантов). Настоящий ключ — не числа key2, а структурная связь между тремя строками вывода, которую можно увидеть, только если выписать формулы для всех трёх шагов одновременно.