Разбираем signature
Разбираем signature

Сегодня на Хабре вышла статья про кражу цепочек рассуждений у закрытых моделей. Схема такая: берёшь зашифрованный блок сильной модели, подкладываешь слабой сестре того же провайдера, просишь пересказать дословно, и читаешь чужие мысли, включая случайно попавшие туда пароли и ключи. Атака красивая и, судя по препринту, работающая. Читал я её с двойным чувством, потому что это самое поле signature, которое Claude прикладывает к каждому блоку thinking, я разбирал по байтам ещё прошлой зимой, когда ковырял историю своего агента. Тогда набралось около полутора тысяч таких блоков из локальных сессий. Вывод был простой: конверт вокруг рассуждения совсем не opaque. Это обычный protobuf, он читается вообще без ключей, и зашифрован внутри ровно один вложенный кусок, сам текст мысли. Всё остальное лежит открыто.

Статья заставила меня сдуть пыль с того исследования и перегнать парсер по свежим сессиям. За полгода протокол подрос на четыре версии схемы, и внутри нашлось кое-что, из-за чего у меня к выводам препринта есть уточнение. Но давайте по порядку. Начнём с одной маленькой, настоящей подписи, которую я вытащил из собственной сессии Claude Code полчаса назад.

Берём одну подпись

Я открыл свою рабочую сессию (~/.claude/projects/…/*.jsonl), попросил модель коротко подумать над пустяковым шагом и выдернул из истории первый попавшийся блок thinking. Вот он целиком, как лежит в JSON:

{
  "type": "thinking",
  "thinking": "",
  "signature": "CAISuAIKhwEIEBgCKkAnGboBaoOmqsLP8A5ICJxFVcsq4Rse…XDbWEYieTo2VzAj4KRSO66JYaCQYAQ=="
}

Первое, что бросается в глаза: поле thinking пустое. Не в том смысле, что модель не думала. Там буквально "" при живой подписи на 428 символов. Запомним это, вернёмся чуть позже. Пока просто возьмём саму signature и начнём её потрошить.

Шаг 1: это base64

Строка кончается на == и состоит из алфавита base64, намёк недвусмысленный. Раскодируем:

import base64
raw = base64.b64decode(sig + "===")   # 428 символов -> 319 байт

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

08 02 12 b8 02 0a 87 01 08 10 18 02 2a 40 27 19

Шаг 2: это protobuf

Байт 08 для всякого, кто хоть раз ловил protobuf руками, узнаётся как отпечаток пальца. Это тег: номер поля в старших битах, тип провода (wire type) в трёх младших. 0x08 значит поле 1, тип 0 (varint). Дальше 02, его значение. Разбираем весь внешний конверт:

08 02          field 1  varint = 2
12 b8 02       field 2  len-delimited, длина 0x138 = 312 байт  { … }
… 18 01        field 3  varint = 1

Внешний конверт тривиален: одно вложенное сообщение на 312 байт в поле 2, плюс два скалярных флажка по краям (field 1 = 2 и field 3 = 1, назначение неизвестно, но значения стабильны). Вся суть в поле 2. Ныряем внутрь тем же способом:

0a 87 01       field 1  len = 135   -> заголовок
12 0c          field 2  len = 12    -> nonce
1a 0c          field 3  len = 12    -> nonce / aad
22 30          field 4  len = 48    -> MAC / подпись
2a 5e          field 5  len = 94    -> шифротекст

И вот тут начинается интересное. Три поля посередине, 12, 12 и 48 байт, имеют константную длину у всех полутора тысяч блоков, что я видел. Никакого блокового выравнивания, никакого паддинга. Это классический расклад nonce(12) + nonce_or_aad(12) + tag/signature(48). Поле 5 это собственно шифротекст, единственное, что действительно зашифровано. А поле 1 это заголовок, и вот он оказался главным сюрпризом.

Шаг 3: заголовок, который никто не прятал

Разбираем те самые 135 байт заголовка. Тем же протобафом, всё так же без единого ключа:

08 10                         field 1  varint = 16          версия схемы
18 02                         field 3  varint = 2           тип блока (thinking)
2a 40  27 19 ba 01 6a 83 …    field 5  len = 64             auth_blob (случайные 64 B)
32 0d  "claude-opus-5"        field 6  len = 13             id модели, открытым текстом
38 01                         field 7  varint = 1           флаг (у opus-5 = 1)
42 08  "thinking"             field 8  len = 8              тип блока, теперь строкой
5a 24  "32e694d7-…-…4753a79"  field 11 len = 36             а вот это уже любопытно

Прочитайте ещё раз, что здесь лежит в открытом виде, без всякой расшифровки, у любого, кто взял base64 и распарсил protobuf:

  • field 6, имя модели. claude-opus-5, буква в букву, прямо внутри подписанного блока, а не только в метаданных запроса.

  • field 8, тип контента. Строка thinking. Именно здесь, если он вообще существует, всплывёт redacted_thinking.

  • field 11, UUID. 36-символьный 32e694d7-…-…4753a79. Я потратил пять минут, чтобы понять, что это, и слегка похолодел: это oauthAccount.organizationUuid из моего же ~/.claude.json. Идентификатор организации, от имени которой работает мой аккаунт. В разных сессиях он один и тот же на протяжении месяца и меняется только при смене аккаунта.

То есть каждый зашифрованный блок рассуждений таскает внутри заголовка, открытым текстом, id модели и id вашей организации. Половину UUID в примере выше я забил точками сознательно. Это не абстрактный «технический идентификатор» из статьи, это реальный ключ, по которому вас можно связать с вашей организацией в Anthropic. И лежит он ровно там, куда никто не смотрел, потому что signature все считали непрозрачным мусором.

Шаг 4: а что же зашифровано

Собственно рассуждение лежит в поле 5, те самые 94 байта шифротекста. И тут работает единственная арифметика, которая нужна для всей истории:

len(шифротекст) = len(открытый текст) + 16

Плюс 16 это стандартный тег аутентичности AES-GCM. Никакого паддинга, никакого сжатия, шифруется ровно текст мысли, байт в байт. Значит 94 − 16 = 78, столько байт занимало настоящее рассуждение на этом шаге. Прочитать его я не могу, ключа у меня нет, но длину мне честно отдаёт сама структура конверта.

Теперь вернитесь к пустому thinking: "" из начала. Показанного текста нет, а шифротекст есть, и в нём 78 байт реального рассуждения. На моделях начиная с opus-4-7 поле thinking в истории приходит пустым практически всегда. Видимого канала нет, а подписанный жив. Так что честный ответ на вопрос «думала ли модель и сколько» даёт len(шифротекст) − 16, а длина показанного текста, которого попросту нет, тут ни при чём.

Как это всё дозревало

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

версия

что появилось

к чему привязывает блок

v12

field 6 = имя модели

к модели

v13, v14

field 8 = тип блока строкой

к типу контента

v15

field 11 = organizationUuid

к организации-владельцу

v16

field 7 = 1 (пока только у opus-5)

?

Структура при этом не меняется: тот же внешний конверт, тот же расклад header / 12 / 12 / 48 / ciphertext, та же формула +16. Парсер, написанный полгода назад, разобрал свежие блоки opus-5 без единой правки. Просто теперь в заголовке на два поля больше.

К чему я это всё

Три вывода, и первый прямо в тему статьи.

1. Дыра чинится левой пяткой. Атака «Opus → Haiku» держится на одном допущении: зашифрованный блок ни к чему не привязан, и сервер расшифрует его кому угодно, кто приложит валидный ключ младшей модели. Но с версии 15 (это июль 2026) в подписанном заголовке лежит organizationUuid, а с версии 12 там же имя модели. У сервера при расшифровке на руках есть всё, чтобы сказать: этот блок подписан для организации X и модели Opus, а запрос пришёл от организации Y к Haiku, до свидания. Никакой криптографии на годы вперёд тут не нужно, достаточно сравнить две строки, которые уже лежат внутри конверта. Оговорюсь честно: наличие поля ещё не доказывает, что сервер его проверяет. Со стороны клиента видно только, что идентификатор внутри и покрыт MAC. Но материал для заплатки провайдер зашил в формат сам, своими руками, ещё до выхода препринта.

2. Подделать блок нельзя. Те самые 48 байт в поле 4 внутреннего конверта это MAC, то есть подпись. Всё содержимое, включая organizationUuid и имя модели, ею покрыто. Подменить org на чужой или переклеить блок с одной модели на другую и при этом не сломать проверку не выйдет: любая правка ломает MAC, а собрать валидный без серверного ключа невозможно. Поэтому вектор «подложить в чужой лог свою инструкцию» из статьи работает только через генерацию нового настоящего блока, отредактировать готовый не получится. Ограничение важное, стоит держать его в голове.

3. Блок хранит чувствительные данные открытым текстом. Это, пожалуй, самое практическое. Даже не умея расшифровать саму мысль, из signature любого вашего блока thinking посторонний достаёт имя модели, тип блока и, начиная с июля, UUID вашей организации. Всё это едет в каждом коммите, где вы случайно оставили .jsonl историю агента, и в каждом датасете агентских логов, выложенном на Hugging Face. Чтобы прочитать саму мысль, нужна отдельная атака с джейлбрейком младшей модели. А заголовок читается тривиально, через base64.b64decode и десяток строк парсера protobuf, которые я привёл выше. Если вы выкладываете логи прогонов в публичный доступ, считайте signature секретом и чистите его так же, как чистили бы .env.

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