
Сегодня на Хабре вышла статья про кражу цепочек рассуждений у закрытых моделей. Схема такая: берёшь зашифрованный блок сильной модели, подкладываешь слабой сестре того же провайдера, просишь пересказать дословно, и читаешь чужие мысли, включая случайно попавшие туда пароли и ключи. Атака красивая и, судя по препринту, работающая. Читал я её с двойным чувством, потому что это самое поле 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 |
| к модели |
v13, v14 |
| к типу контента |
v15 |
| к организации-владельцу |
v16 |
| ? |
Структура при этом не меняется: тот же внешний конверт, тот же расклад 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. Только основную опасность несёт не сама зашифрованная мысль, а служебные поля рядом с ней, лежащие вообще без замка. В этом конверте обратный адрес прочитать проще, чем письмо.

