Дифференциальное тестирование: единственный барьер против галлюцинаций модели в реверс-инжиниринге

Последнее обновление: 2026-07-30 10:58:49

Модель дочитала вашу листовую функцию и уверенно выдала вывод: «Это ChaCha20, только число раундов заменили на переменную, зависящую от ключа». Звучит правдоподобно — настолько, что уже хочется сразу писать замену.

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

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

Почему не стоит спрашивать модель: «Это точно работает правильно?»

Новички чаще всего задают модели один вопрос: «Можешь подтвердить, что эта реализация корректна?»

Это тупиковый вопрос сразу по трём причинам.

Во-первых, модель настроена соглашаться с пользователем. В самой формулировке «эта реализация правильная?» уже есть ожидание положительного ответа — и модель обычно улавливает этот настрой.

Во-вторых, ей не на что опереться. Единственный критерий корректности — совпадение результатов чёрного ящика на одном наборе входов. Этого эталона в контексте модели нет. Она может лишь оценить, насколько код выглядит разумно, а обфусцированный код как раз лучше всего умеет выглядеть разумно.

В-третьих, вы обращаетесь не к тому арбитру. Корректность — не предмет обсуждения, а факт выполнения равенства. Не спрашивайте модель, отдайте вопрос в assert: модель считает код верным, но тест падает — прав тест; модель считает его ошибочным, но все тесты проходят — всё равно прав тест. Как только вердикт начинает зависеть от модели, вы строите работу на галлюцинации.

Какие входы нужны для проверки границ

Схема дифференциального теста элементарна: оригинал остаётся чёрным ящиком, а ваша замена сравнивается с ним на одинаковом наборе входов, случай за случаем. Ценность здесь не в цикле, а в том, какие именно данные вы в него подадите.

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

# Boundary cases: derived from block size B
def boundary_cases(B: int) -> list[bytes]:
    return [
        b"",                 # empty: exposes initial state and padding logic
        b"\x00",             # single zero byte
        b"\xff",             # single high byte: checks sign-bit / unsigned handling
        b"A" * (B - 1),      # one below the block boundary
        b"A" * B,            # exactly one block
        b"A" * (B + 1),      # one above: checks carry and padding
        bytes(range(256)),   # full byte coverage: checks the alphabet map covers the whole domain
    ]

Каждый из них проверяет конкретную ветку. Старший байт \xff вытаскивает наружу обработку знакового бита: разница между >>> и >> в JS, наличие или отсутствие & 0xff в Python — всё проявится на этом единственном случае. Тройка B-1 / B / B+1 особенно важна: только на ней раскрывается логика блочного дополнения. Полный перебор байтов нужен для нестандартных алфавитов: лишний или недостающий символ в таблице соответствий гарантированно сломает этот тест.

Сам каркас дифференциальной проверки занимает меньше десяти строк:

# Original as black box, rewrite compared case by case
def diff_test(blackbox, rewritten, B: int) -> None:
    for case in boundary_cases(B):
        got, want = rewritten(case), blackbox(case)
        assert got == want, f"len={len(case)} hex={case.hex()}"

Если гипотеза о точке отклонения затрагивает стандартный алгоритм, оригинальный чёрный ящик нужен не всегда: для публичных стандартов часто есть авторитетные тестовые векторы. Например, RFC 8439 §2.1.1 содержит фиксированную пару входа и выхода для четвертьраунда ChaCha20: при a=0x11111111, b=0x01020304, c=0x9b8d6f43, d=0x01234567 получается a=0xea2a92f4, …. Сначала добейтесь, чтобы ваша реализация проходила стандартный вектор, а затем сравнивайте её с целевым чёрным ящиком. Так вы чётко разделите две проблемы: «я неверно реализовал ChaCha» и «в целевой реализации изменили ChaCha».

Обратите внимание: сообщение в assert содержит len(case). Это самая полезная диагностическая строка во всём наборе тестов. Если из семи случаев не проходит только B+1, проблема почти наверняка в дополнении или переносе; если падает лишь полный перебор байтов — дело в таблице алфавита. Длина неудачного входа сразу указывает на ошибочный слой, без гаданий.

Всегда запускайте один вход дважды

Самая частая причина проблем при переписывании логики подписи — не неверно распознанный алгоритм, а источник энтропии, который ещё не вынесен наружу.

Выявить его можно одной строкой: выполнить функцию два раза на одном входе.

# Same input twice: differing results mean a hidden RNG / timestamp
def assert_deterministic(fn, case: bytes) -> None:
    assert fn(case) == fn(case), "unfixed entropy source or timestamp present"

Если результаты различаются, реализация подмешивает time.time(), nonce, автоматически увеличиваемый счётчик или что-то ещё, меняющееся при каждом вызове. Пока это не устранено, дифференциальное сравнение вообще невозможно: чёрный ящик каждый раз отвечает по-разному — с чем тогда сопоставлять результат?

Решение не в том, чтобы удалить источник энтропии: без него подпись станет неправильной. Его нужно вынести из кода в инъецируемый параметр и зафиксировать на постоянном значении во время дифференциального тестирования:

# Lift the entropy source into an injectable parameter, pin it with a stub for diffing
class Rewritten:
    def __init__(self, clock=time.time, rng=os.urandom):
        self._clock = clock          # formerly an inline time.time(), now injected
        self._rng = rng

    def __call__(self, data: bytes) -> bytes:
        ts = int(self._clock())      # for diffing, clock=lambda: 0
        nonce = self._rng(16)        # for diffing, rng=lambda n: b"\x00" * n
        ...

То же нужно сделать с целевым чёрным ящиком: найти место, где подставляются время или случайные данные, и зафиксировать их — например, перехватить Date.now на странице или передать Node фиксированный seed. Когда энтропия закреплена с обеих сторон, результат снова становится детерминированным и сравнение приобретает смысл. После успешной сквозной проверки верните clock и rng к настоящим реализациям.

И здесь модель тоже не поможет: место, где скрыта энтропия, и способ её внедрения определяются поведением при выполнении. Это выясняется двойным запуском, а не чтением кода.

Сверяйте слои по очереди, а не только итог

Допустим, общий результат вашей реализации не совпадает с чёрным ящиком. Не зацикливайтесь на финальной последовательности байтов: она собрана из нескольких вложенных слоёв, и пока неизвестно, какой из них ошибочен.

Логика подписи обычно устроена послойно: внутри хеш или блочный шифр, поверх него кодирование — семейство Base64, hex или собственная таблица, а снаружи сборка результата: префикс, поля, заголовок длины. Послойная проверка означает двигаться изнутри наружу и переходить дальше, только когда текущий слой уже совпадает:

# Peel layer by layer: match the innermost first, then move outward
LAYERS = ["digest", "encode", "assemble"]   # inner → outer

def first_divergent_layer(bb_dump, rw_dump, case: bytes) -> str | None:
    for layer in LAYERS:
        if bb_dump(case)[layer] != rw_dump(case)[layer]:
            return layer                     # the first layer to diverge is the faulty one
    return None

Для этого ваша реализация должна уметь выгружать промежуточное состояние каждого слоя, а соответствующие значения нужно извлечь и из чёрного ящика — обычно через инструментирование во время выполнения. Дополнительные усилия окупаются: первый расходящийся слой и есть место ошибки, так что область поиска моментально сужается.

Это напрямую связано с четырьмя категориями точек отклонения: расхождение на слое digest обычно означает изменённую константу или число раундов; на encode — переставленный алфавит; на assemble — внедрённые в результат данные. Расходящийся слой подсказывает, в какой категории из материала о fingerprinting искать причину.

Фикстуры, которые не теряют ценность со временем

Когда дифференциальный тест наконец становится зелёным, это приятно. Но результат разовый: завтра upstream выпустит новую версию, и реализация, которую вы проверили сегодня, может полностью перестать соответствовать цели.

По-настоящему ценным со временем становится другой артефакт — фикстура: таблица известных пар вход → выход, закоммиченная в репозиторий.

# Fixture: known input → known output, committed to the repo
# Re-run on every upstream change; green yesterday, red today = upstream changed, not you
VECTORS = load_json("vectors.json")   # [{"in": "<hex>", "out": "<hex>"}, ...]

for v in VECTORS:
    got = rewritten(bytes.fromhex(v["in"])).hex()
    assert got == v["out"], v["in"]

Её ценность проявляется сразу после обновления upstream. Однажды CI покраснеет: код, которого вы вчера не касались, не пройдёт фикстуру. Это бесценный сигнал — он исключает вариант «я что-то сломал» и однозначно указывает на «upstream изменился». Без фикстуры можно потратить полдня на отладку абсолютно правильного кода, просто не понимая, кто сдвинул границу.

Лучшими входами для фикстуры будут те же граничные случаи, что использовались выше: это уже ваш набор с максимальным покрытием.

На этом этапе модель делает ровно две вещи

Разделение ролей в дифференциальном тестировании предельно простое: у модели есть две задачи, но права выносить вердикт нет.

Первая — генерировать случаи. Массово составлять граничные входы для множества примитивов, собирать наборы, отличающиеся одним битом, чтобы проверить подозрительную константу, — это перебор, а не рассуждение. Здесь выгоднее всего дешёвый уровень с высокой параллельностью.

Вторая — объяснять расхождение. Когда случай не проходит, покажите модели дампы обоих слоёв и попросите по конкретным байтам объяснить, где возникло первое различие и к какой из четырёх категорий отклонений оно, вероятнее всего, относится. Тут нужны средний уровень рассуждений и способность объяснять результат по байтам — именно здесь модель из этой статьи действительно выполняет полезную работу.

Вердикт остаётся за assert, и это не меняется. Объяснение модели — лишь зацепка, а не заключение; неверные зацепки нормальны, и именно assert их отсеивает.

Эти две задачи требуют от модели разных качеств. Использовать один уровень для всего — значит либо тратить лишние деньги, либо терять точность:

Подзадача дифференциального тестирования

Что требуется от модели

Выбор

model id

Массово генерировать граничные и контрольные случаи для множества примитивов

Низкая цена и высокая параллельность; перебор не требует рассуждений

Claude Sonnet 5

claude-sonnet-5

Разобрать одно неуспешное сравнение и объяснить его по байтам

Средний уровень рассуждений; локализация на конкретном слое

GPT-5.6 Sol

gpt-5.6-sol

Разобрать глубокую первопричину, когда локализация не сходится: например, изменённую константу

Сильные рассуждения; выводы по промежуточному состоянию нескольких раундов

Claude Opus 5

claude-opus-5

Обработать множество дампов слоёв или целый пакет фикстур для поиска расхождения

Длинный контекст

Kimi K3

kimi-k3

Основной рабочий уровень — второй. Стоит ли выбирать модель именно по качеству локализации различий, вы поймёте после одного собственного тестового раунда. Протокол короткий:

  1. Возьмите один действительно падающий случай из дифференциального набора вместе с двумя дампами слоёв: чёрного ящика и вашей реализации.

  2. Отдельно отправьте одно и то же расхождение в gpt-5.6-sol и claude-opus-5, задав только два вопроса: на каком слое начинается первое различие и к какой из четырёх категорий отклонений оно вероятнее всего относится.

  3. Оценивайте лишь одно: указывает ли ответ конкретный байт и конкретный слой или ограничивается расплывчатым «возможно, проблема в дополнении».

  4. Точность локализации — ваш критерий выбора: от неё напрямую зависит, сколько циклов правок потребуется, чтобы этот случай стал зелёным.

Одного раунда достаточно, чтобы увидеть разницу — намного нагляднее любого рейтинга бенчмарков.

Главная проблема — цена переключения

Эти четыре уровня приходят от трёх поставщиков: три SDK, три схемы авторизации, три формата ошибок. Трижды подключать клиент только ради переключения уровней между подзадачами невыгодно. Поэтому большинство в итоге запускает одну модель на всё, получает для локализации различий только расплывчатые ответы и тратит несколько циклов правок, не понимая причины.

AIReiter убирает этот слой: один ключ, один OpenAI-совместимый интерфейс, все четыре уровня за ним, а для переключения достаточно изменить поле model в теле запроса.

# Difference attribution: the mid-reasoning tier
curl https://aireiter.com/api/v1/chat/completions \
  -H "Authorization: Bearer $AIREITER_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "gpt-5.6-sol",
    "messages": [{"role": "user", "content": "<diff of the two layer dumps + locate the first divergent layer and deviation category>"}]
  }'

# Attribution won't converge, escalate to dig the root cause: change one field
#   "model": "claude-opus-5"
# Bulk-generate boundary cases:
#   "model": "claude-sonnet-5"

Уже работаете через OpenAI SDK? Укажите для base_url адрес https://aireiter.com/api/v1 — больше ничего менять не нужно. Используете Anthropic SDK? Отправляйте запросы на POST /api/v1/messages с тем же ключом.

По цене модели Claude идут со скидкой 30% от прайса, а GPT — вдвое дешевле. В этом процессе скидка приходится на самый интенсивный этап: локализация различий — самая часто вызываемая часть дифференциального тестирования. На каждый падающий случай нужен один раунд, а каждое обновление upstream означает пересборку фикстуры и новую пачку расхождений для разбора. Основная модель gpt-5.6-sol относится к GPT и стоит вдвое дешевле, поэтому затраты на плотную часть работы сразу сокращаются наполовину. Редкая эскалация к claude-opus-5 для глубокого поиска причины требует мало вызовов, но и там действует скидка 30% на Claude.

Итог

У переписывания в реверс-инжиниринге есть всего две несущие опоры: модель предлагает гипотезу, а assert выносит вердикт.

Модель — генератор подозрений, видевший все публичные реализации. За секунды она способна сказать: «возможно, изменение здесь», но не знает, права ли именно в этом случае. Дифференциальный тест превращает «возможно» в «да» или «нет»: граничные входы вытаскивают скрытые ветки, двойной запуск одного входа обнаруживает источник энтропии, послойная проверка локализует ошибочный слой, а фикстура отличает «я написал неправильно» от «они изменили реализацию».

Любой вывод, который дают четырёхэтапный процесс и материал о поиске отклонений, в конце концов должен пройти через эти ворота. Не имеет значения, что говорит модель; значение имеет только то, что говорит assert.