Rabbit на практике: как работает потоковый шифр в реальном коде

Rabbit на практике: как работает потоковый шифр в реальном коде

Подробно разбираем алгоритм Rabbit, структуру инициализации и реализацию на Python.

image

Эта статья — практический воркшоп по шифру Rabbit. Сначала мы научимся "узнавать" алгоритм в чужом коде по его уникальным сигнатурам. Затем мы перейдем к делу: пошагово реализуем на Python весь конвейер, включая инициализацию ключа (key setup) и вектора (IV), а также потоковую обработку данных «чтение-обработка-запись». Результатом станет готовый модуль для шифрования и дешифровки файлов, который мы немедленно проверим на корректность с помощью официальных тестовых векторов.

Коротко о Rabbit и зачем он нужен

Rabbit относится к синхронным потоковым шифрам. Он генерирует псевдослучайный поток байтов, который складывается по XOR с открытым текстом. Ключ 128 бит и по желанию вектор инициализации 64 бита. На каждый такт получается 128 бит выходных данных. Алгоритм рассчитан на высокую скорость в программных реализациях и давно опубликован для свободного использования. Для вхождения в тему достаточно помнить два правила. Первое. Каждый ключ с каждым IV образуют независимый поток. Второе. Один и тот же IV нельзя повторять с тем же ключом, иначе теряется конфиденциальность.

Для углубления полезно держать под рукой описание алгоритма и тестовые векторы. Мы дадим ссылки в конце, а по ходу текста напомним, где искать нужные фрагменты.

Как распознать Rabbit по исходникам

Если на руках есть проект с заглушками вида cipher_update, но без явных названий алгоритмов, Rabbit можно вычислить по нескольким признакам. Ниже перечислены самые заметные детали. Они встречаются вместе и хорошо ловятся поиском по коду.

  • Внутреннее состояние в виде двух массивов по восемь 32-битных слов. Обычно это x[8] и c[8], плюс одиночный перенос b.
  • Три «шагающие» константы для счетчиков. В исходниках часто видны значения 0x4D34D34D, 0xD34D34D3, 0x34D34D34. Они прибавляются по кругу при обновлении c[i].
  • Нелинейная функция g(). Вызывается для каждой пары (x[i], c[i]) и вычисляется как квадрат суммы с последующим смешиванием старших и младших частей. В коде это похоже на «возвести 32-битную сумму в квадрат как 64-битное число, затем сложить XOR ее верхнюю и нижнюю половины и снова усечь до 32 бит».
  • Два характерных циклических сдвига при смешивании результатов. Обычно это вращение на 8 и 16 бит внутри 32-битного слова.
  • Схема извлечения 128 бит из восьми слов состояния. Там встречается XOR «младшей половины одного слова» со «старшей половиной другого» в фиксированной перестановке индексов.
  • Инициализация от 128-битного ключа в виде восьми 16-битных под-ключей с чередованием порядка при записи в x и c, потом четыре прогонки, затем c[i] ^= x[i+4]. При наличии IV делается специфическое раскладывание 64 бит IV на четыре 32-битных слова, которые по кругу XORятся в c, после чего снова четыре прогонки.

Эти приметы в совокупности дают уверенную идентификацию. Если в проекте совпали константы счетчиков, квадратная g() и двойные сдвиги, можно считать, что перед нами Rabbit.

Поток данных чтение → обработка → запись

Перед тем как писать модуль, зафиксируем простой, но надежный конвейер. Для файлов шифрование строится на трех шагах. Читаем очередной блок байт, генерируем такое же число байт ключевого потока, выполняем XOR и пишем результат. Размер блока выбираем исходя из баланса между скоростью и потреблением памяти. На практике 1–4 МБ на чтение подходят для большинства конфигураций.

Стримовая природа полезна тем, что не требуется загружать файл целиком. Можно обрабатывать гигабайты последовательно. Важно только синхронно породить ровно столько же байт ключевого потока, сколько пришло из источника. Ошибки со сдвигом по потоку приводят к порче результата на всем хвосте файла, а не только в месте сбоя.

Инициализация ключа и вектора

Инициализация в Rabbit состоит из двух частей. Сначала создается «мастер-состояние» из 128-битного ключа. Ключ разбивается на восемь 16-битных слов в порядке от младших к старшим байтам. Из них формируются начальные x[i] и c[i] по чередующейся схеме. Затем выполняются четыре итерации обновления, после чего счетчики c дополнительно смешиваются с состоянием x сдвигом на четыре позиции по модулю восемь.

Если нужен IV, его 64 бита раскладываются на четыре 32-битных значения в заданном порядке и по кругу XORятся в массив c. После этого снова выполняются четыре итерации. Такой порядок обеспечивает независимое начальное состояние для каждой пары ключ и IV. В следующем разделе покажем это на коде Python.

Пишем модуль Rabbit на Python

Ниже самодостаточная реализация Rabbit. Она не зависит от внешних библиотек и годится для встраивания в служебные утилиты. Все операции выполняются в рамках 32-битных слов с усечением по модулю 232, а извлечение блока отдает 16 байт за такт. Комментарии оставлены максимально простыми.

# rabbit_cipher.py
# Реализация потокового шифра Rabbit на чистом Python.
# Комментарии на русском чтобы упрощать разбор.

from __future__ import annotations
from typing import Iterable, Generator
import struct
import os

_WORD = 0x100000000  # 2**32 для усечения

class Rabbit:
    """
    Потоковый шифр Rabbit. Ключ 16 байт. IV опционально 8 байт.
    """

    _A = (0x4D34D34D, 0xD34D34D3, 0x34D34D34)  # константы инкремента счетчиков

    def __init__(self, key: bytes, iv: bytes | None = None) -> None:
        if len(key) != 16:
            raise ValueError("Ключ должен быть ровно 16 байт")
        if iv is not None and len(iv) not in (0, 8):
            raise ValueError("IV должен быть 8 байт или не задан")

        # Разбиваем ключ на 8 слов по 16 бит. Младшие байты вперед.
        k = struct.unpack("<8H", key)

        # Начальные массивы состояния и счетчиков.
        self.x: list[int] = []
        self.c: list[int] = []
        self.b: int = 0  # перенос для системы счетчиков

        for j in range(8):
            v1, v2 = k[(j + 0) & 7], k[(j + 1) & 7]
            w1, w2 = k[(j + 4) & 7], k[(j + 5) & 7]
            if j % 2 == 0:
                # Xj = K_{j+1} || K_j
                # Cj = K_{j+4} || K_{j+5}
                self.x.append(((v2 << 16) | v1) & 0xFFFFFFFF)
                self.c.append(((w1 << 16) | w2) & 0xFFFFFFFF)
            else:
                # Xj = K_{j+5} || K_{j+4}
                # Cj = K_j || K_{j+1}
                self.x.append(((w2 << 16) | w1) & 0xFFFFFFFF)
                self.c.append(((v1 << 16) | v2) & 0xFFFFFFFF)

        # Четыре прогонки для вымешивания
        self._iterate4()

        # Дополнительное смешивание счетчиков через XOR с X[j+4]
        self.c = [(cj ^ self.x[(j + 4) & 7]) & 0xFFFFFFFF for j, cj in enumerate(self.c)]

        # Если задан IV, применяем процедуру IV setup
        if iv:
            # Разложение 64 бит IV на i0..i3 в порядке little-endian
            i0, i2 = struct.unpack("<LL", iv)
            i1 = ((i0 >> 16) | (i2 & 0xFFFF0000)) & 0xFFFFFFFF
            i3 = ((i2 << 16) | (i0 & 0x0000FFFF)) & 0xFFFFFFFF
            mix = (i0, i1, i2, i3)
            self.c = [(cj ^ mix[j % 4]) & 0xFFFFFFFF for j, cj in enumerate(self.c)]
            self._iterate4()

    # --- Внутренние примитивы ---

    @staticmethod
    def _rotl32(v: int, r: int) -> int:
        r &= 31
        return ((v << r) | (v >> (32 - r))) & 0xFFFFFFFF

    @staticmethod
    def _g(x: int, c: int) -> int:
        # u = (x + c) mod 2**32
        u = (x + c) & 0xFFFFFFFF
        # t = u*u как 64-битное, затем XOR верхней и нижней половин
        t = (u * u) & 0xFFFFFFFFFFFFFFFF
        return ((t ^ (t >> 32)) & 0xFFFFFFFF)

    def _counter_update(self) -> None:
        carry = self.b
        for i in range(8):
            carry = (self.c[i] + self._A[i % 3] + carry)
            self.c[i] = carry & 0xFFFFFFFF
            carry >>= 32
        self.b = carry & 1

    def _next_state(self) -> None:
        # Обновляем счетчики
        self._counter_update()

        # Вычисляем g[i] для каждой пары
        g = [self._g(self.x[i], self.c[i]) for i in range(8)]

        # Обновляем X с перекрестным смешиванием через сдвиги на 8 и 16
        self.x[0] = (g[0] + self._rotl32(g[7], 16) + self._rotl32(g[6], 16)) & 0xFFFFFFFF
        self.x[1] = (g[1] + self._rotl32(g[0], 8)  + g[7]) & 0xFFFFFFFF
        self.x[2] = (g[2] + self._rotl32(g[1], 16) + self._rotl32(g[0], 16)) & 0xFFFFFFFF
        self.x[3] = (g[3] + self._rotl32(g[2], 8)  + g[1]) & 0xFFFFFFFF
        self.x[4] = (g[4] + self._rotl32(g[3], 16) + self._rotl32(g[2], 16)) & 0xFFFFFFFF
        self.x[5] = (g[5] + self._rotl32(g[4], 8)  + g[3]) & 0xFFFFFFFF
        self.x[6] = (g[6] + self._rotl32(g[5], 16) + self._rotl32(g[4], 16)) & 0xFFFFFFFF
        self.x[7] = (g[7] + self._rotl32(g[6], 8)  + g[5]) & 0xFFFFFFFF

    def _iterate4(self) -> None:
        self._next_state()
        self._next_state()
        self._next_state()
        self._next_state()

    def _extract_block(self) -> bytes:
        # Собираем 128 бит из восьми половинок по схеме Rabbit
        s = []
        pairs = ((0, 5), (3, 0), (2, 7), (5, 2), (4, 1), (7, 4), (6, 3), (1, 6))
        for a, b in pairs:
            lo = self.x[a] & 0xFFFF
            hi = (self.x[b] >> 16) & 0xFFFF
            s.append(lo ^ hi)
        return struct.pack("<8H", *s)

    def keystream(self) -> Generator[int, None, None]:
        # Генератор байт ключевого потока без ограничения длины
        while True:
            self._next_state()
            block = self._extract_block()
            for byte in block:
                yield byte

    def xor_bytes(self, data: bytes) -> bytes:
        # Выделяем столько же байт из потока и складываем по XOR
        out = bytearray(len(data))
        ks = self.keystream()
        for i, b in enumerate(data):
            out[i] = b ^ next(ks)
        return bytes(out)


def encrypt_stream(fin, fout, key: bytes, iv: bytes | None, chunk: int = 1 << 20) -> None:
    """
    Потоковое шифрование из файла в файл. По XOR с ключевым потоком.
    """
    cipher = Rabbit(key, iv)
    ks = cipher.keystream()
    # Буферизованная обработка
    while True:
        buf = fin.read(chunk)
        if not buf:
            break
        out = bytearray(len(buf))
        for i, b in enumerate(buf):
            out[i] = b ^ next(ks)
        fout.write(out)


def decrypt_stream(fin, fout, key: bytes, iv: bytes | None, chunk: int = 1 << 20) -> None:
    """
    Для потокового шифра операция та же самая. Функция оставлена для симметрии интерфейса.
    """
    encrypt_stream(fin, fout, key, iv, chunk)


def hex_to_bytes(x: str, expected_len: int | None = None) -> bytes:
    x = x.replace(" ", "").replace("-", "")
    if len(x) % 2 != 0:
        raise ValueError("Нечетная длина hex-строки")
    b = bytes.fromhex(x)
    if expected_len is not None and len(b) != expected_len:
        raise ValueError(f"Ожидалось {expected_len} байт, получено {len(b)}")
    return b

Мини-CLI для работы с файлами

Добавим тонкую обертку, чтобы удобно зашифровать и расшифровать файл из командной строки. Ключ и IV передаются в шестнадцатеричном виде. При необходимости IV можно сгенерировать заранее и сохранить рядом с шифртекстом.

# rabbit_file.py
import argparse, sys, os
from rabbit_cipher import encrypt_stream, decrypt_stream, hex_to_bytes

def main():
    p = argparse.ArgumentParser(prog="rabbit_file", description="Шифрование и дешифровка файлов потоком Rabbit")
    p.add_argument("mode", choices=["enc", "dec"], help="Режим работы enc или dec")
    p.add_argument("-k", "--key", required=True, help="Ключ в hex длиной 16 байт")
    p.add_argument("-i", "--iv",  default="", help="IV в hex длиной 8 байт. Можно опустить")
    p.add_argument("inp", help="Входной файл")
    p.add_argument("out", help="Выходной файл")
    p.add_argument("--chunk", type=int, default=1<<20, help="Размер блока чтения в байтах")
    args = p.parse_args()

    key = hex_to_bytes(args.key, 16)
    iv  = hex_to_bytes(args.iv, 8) if args.iv else None

    with open(args.inp, "rb") as fin, open(args.out, "wb") as fout:
        if args.mode == "enc":
            encrypt_stream(fin, fout, key, iv, args.chunk)
        else:
            decrypt_stream(fin, fout, key, iv, args.chunk)

if __name__ == "__main__":
    main()

Проверяем себя тестовыми векторами

Надежный способ убедиться в корректности реализации. Сначала берем случай из набора «без IV», где на нулевом ключе первые 16 байт выходного потока равны последовательности B1 57 54 F0 36 A5 D6 EC F5 6B 45 26 1C 4A F7 02. Затем проверяем один пример с IV. Ниже простая проверка в коде.

# tests_rabbit.py
from rabbit_cipher import Rabbit, hex_to_bytes

def take(n, g):
    out = bytearray()
    for _ in range(n):
        out.append(next(g))
    return bytes(out)

def test_no_iv():
    key = bytes(16)  # 00..00
    r = Rabbit(key)
    ks = take(16, r.keystream())
    want = bytes.fromhex("B1 57 54 F0 36 A5 D6 EC F5 6B 45 26 1C 4A F7 02")
    assert ks == want

def test_with_iv():
    key = bytes(16)
    iv  = bytes(8)
    r = Rabbit(key, iv)
    ks = take(16, r.keystream())
    want = bytes.fromhex("C6 A7 27 5E F8 54 95 D8 7C CD 5D 37 67 05 B7 ED")
    assert ks == want

if __name__ == "__main__":
    test_no_iv(); test_with_iv()
    print("Обе проверки пройдены")

Если обе проверки проходят, базовая часть реализована верно. Для полной уверенности можно свериться с расширенными отладочными значениями внутреннего состояния и расхождений не будет.

Практика. Шифруем и расшифровываем файлы

Пусть есть файл photo.raw. Создадим ключ и IV и пропустим его через утилиту. В демонстрации IV задан явно, но ничто не мешает генерировать его и сохранять отдельно рядом с шифртекстом.

# ключ и IV в шестнадцатеричном виде
KEY=00112233445566778899aabbccddeeff
IV=0f1e2d3c4b5a6978

# шифрование
python rabbit_file.py enc -k $KEY -i $IV photo.raw photo.raw.rab

# расшифровка
python rabbit_file.py dec -k $KEY -i $IV photo.raw.rab photo.restored.raw

Пара файлов photo.raw и photo.restored.raw должна совпасть побайтно. Это легко проверяется сравнив хэши. Важно помнить, что для каждого нового сообщения с одним и тем же ключом выбирается новый IV. Повтор со старым IV делает поток одинаковым. Это упрощает анализ и раскрывает одинаковые префиксы входных данных.

Безопасность и типичные ошибки

Потоковое шифрование по XOR не защищает от изменений шифртекста. Любой байт можно подменить и это предсказуемо исказит соответствующий байт расшифровки. Значит в реальных приложениях нужен контроль целостности. Самый прямой путь применить HMAC поверх шифртекста или воспользоваться контейнером, где вместе с данными хранится тег целостности. Отдельно стоит повторить главный запрет. Нельзя повторять IV для одного и того же ключа.

Есть еще один слой практической гигиены. Если пользователю удобнее работать с паролем, а не с шестнадцатеричным ключом, нужен стойкий способ получения ключа из пароля. Для этого подойдет KDF с солью вроде PBKDF2 или scrypt. В эту статью такой код не включен, чтобы держать фокус на самом Rabbit, однако логика проста. Производим ключ длиной 16 байт, генерируем соль, сохраняем ее рядом с шифртекстом, а IV выбираем случайно и тоже сохраняем.

Разбор исходников. Как найти место инициализации

На практике поиск инициализации в чужом проекте строится как небольшая археология. Помогают регулярные выражения и чтение функций, где встречаются упоминания о ключе или IV. Ниже несколько подсказок, которые часто срабатывают.

  • Поиск по константам. Выражение 4D34D34D|D34D34D3|34D34D34 выводит на систему счетчиков.
  • Поиск по квадрату суммы. В C встречается формула вида uint64_t t = (u * u); g[i] = (uint32_t)((t ^ (t >> 32)) & 0xFFFFFFFF);.
  • Ротации на 8 и 16 бит. В исходниках они бросаются в глаза как ROTL32(y, 8) и ROTL32(y, 16).
  • Схема извлечения 128 бит. Набор индексов (0,5), (3,0), (2,7), (5,2), (4,1), (7,4), (6,3), (1,6) практически уникален.

Нашли это место значит вы у цели. Далее остается связать его с функциями, которые читают входной поток и пишут выходной. Там виден чистый цикл чтение → обработка → запись. Иногда авторы кэшируют несколько блоков ключевого потока вперед ради ускорения. Для корректности это не играет роли, лишь бы количество байт совпадало с длиной входа.

Чек-лист внедрения

  • Храните ключ отдельно от файла. Для пароля используйте KDF с солью.
  • Генерируйте новый IV для каждого сообщения. Длина 8 байт.
  • Записывайте IV рядом с шифртекстом. Удобный вариант отдельный файл с тем же именем и расширением .iv.
  • Добавляйте контроль целостности. HMAC по всему шифртексту решает задачу.
  • Проверяйте реализацию по тестовым векторам. Минимум один случай без IV и один с IV.
  • Обрабатывайте файлы потоком и не держите все целиком в памяти.

Полезные материалы и ссылки

Итоги

Мы научились узнавать Rabbit в исходниках по характерным признакам и разобрали конвейер чтение → обработка → запись. Настроили инициализацию по ключу и IV, написали модуль на Python и подтвердили корректность тестами. Такой фундамент уже подходит для реальной задачи. Дальше остается добавить аутентификацию, удобную упаковку метаданных и интеграцию с остальным кодом проекта.


Хакер уже внутри, но ваша SIEM его не замечает.

Узнайте, как Security Vision UEBA видит невидимое. Регистрируйтесь на бесплатный вебинар, который состоится 13 ноября в 11:00!

Реклама. 18+, ООО «Интеллектуальная безопасность», ИНН 7719435412