Назад к блогу

C2PA и подделка времени: как обойти криптографические метки происхождения

C2PA и подделка времени: как обойти криптографические метки происхождения

Стандарт C2PA призван гарантировать подлинность происхождения медиафайлов, однако его защита строится на цепочке подписей, каждая из которых покрывает лишь предыдущую, а не само содержимое. Разбор показывает, где именно в этой цепочке — от claim до метки времени RFC 3161 — остаётся зазор, позволяющий при формально валидных подписях и метках подменить данные незаметно для валидатора.

C2PA выстраивает цепочку доверия от содержимого файла к доверенному времени через несколько подписей: claim, подпись claim и метку времени RFC 3161. Вопрос в том, что именно покрывает каждая подпись и где в этой цепочке остаётся зазор. Разбор нетривиален потому, что метка времени формально валидна, подпись формально валидна, а содержимое файла при этом не защищено ни одним байтом.

Что подписывается на каждом шаге

Цепочка строится последовательно. Сначала формируется claim — для стандартных и обновляющих манифестов подписывается CBOR-представление claim-документа, при этом само содержимое актива в подпись не входит. Результат подписи записывается в C2PA Claim Signature box.

Подпись не может быть частью подписываемого payload, но её метка предопределена, поэтому полный URI-ссылочный идентификатор заранее известен и его можно включить в claim через поле signature. Это обеспечивает явную привязку claim к его подписи.

Затем, если возможно, подпись отправляется в RFC 3161 TSA, и полученная метка времени включается в COSE_Sign1_Tagged как countersignature. Для v2 payload метки времени — это значение поля signature структуры COSE_Sign1_Tagged, созданной при подписании claim. То есть подписывается уже сама подпись claim, а не контент.

Отсюда и берётся разрыв: подпись claim ставится над хешем, а подпись метки времени — над подписью claim. Валидатор извлекает URI из поля signature, разрешает его и требует, чтобы подпись была в том же C2PA Manifest box (self#jumbf); если URI не указывает внутрь того же манифеста, claim отклоняется с claimSignature.missing.

Хранение меток времени в COSE-заголовках

Метки времени лежат в необязательных (unprotected) заголовках COSE. protected-заголовок COSE содержит поля, попадающие под подпись, а unprotected-заголовок COSE — поля, которые становятся известны уже после формирования подписи; метка времени относится именно ко второму виду, поскольку проставляется после подписи. Устаревшие v1 — под текстовой меткой sigTst, актуальные v2 — под sigTst2. В обоих случаях значением заголовка служит контейнер tstContainer, а токены лежат в его массиве tstTokens. Различие в содержимом: у v1 в val каждого токена кладут весь ответ TSA (TimeStampResp), у v2 — только поле timeStampToken из этого ответа.

Если меток времени нет вообще, спецификация требует, чтобы в необязательном заголовке COSE не было ни sigTst, ни sigTst2. При проверке, если один из этих заголовков присутствует, массив tstTokens должен содержать ровно один токен; при большем числе валидатор выдаёт информационный код timestamp.malformed и игнорирует метки времени.

Формирование запроса к TSA

Тело запроса к TSA формируется вызовом default_rfc3161_message(message), заголовки устанавливаются в None. Запрос отправляется с tsa_url, заголовками, телом и message. В message кладётся не сама подпись, а значение, из которого строится ToBeSigned.

Согласно спецификации, MessageImprint структуры TimeStampReq вычисляется созданием значения ToBeSigned по RFC 8152 section 4.4, где context равен CounterSignature, а payload — значение из раздела о выборе payload. ToBeSigned — это ToBeSigned. Контекст CounterSignature — это CounterSignature. ToBeSigned затем хешируется алгоритмом из разрешённого списка, который поддерживает TSA, и этот хеш кладётся в MessageImprint.

Функция cose_countersign_data принимает произвольное сообщение и COSE protected header и возвращает бинарный blob для подписи как часть COSE-подписи. Она создаёт пустой aad (external_aad нулевой длины) и вызывает sig_structure_data с контекстом CounterSignature, protected header, None в качестве payload и этим пустым aad. Спецификация подтверждает: external_aad должен быть bstr нулевой длины, внешние аутентифицированные данные не используются.

Извлечение токена из ответа TSA

Функция timestamptoken_from_timestamprsp декодирует входные байты как TimeStampResp в DER-режиме. Затем извлекает поле time_stamp_token; если оно отсутствует, возвращается ошибка invalid timestamp token. Каждый компонент content_type преобразуется в u32, из полученных чисел создаётся ObjectIdentifier. Далее формируется ContentInfo с этим content_type и содержимым токена, и результат кодируется в DER.

Для V2_sigTst2_CTT вызывается именно эта функция, потому что в sigTst2 используется только TimeStampToken, а не весь TimeStampRsp. Это соответствует спецификации: для v2 значение поля timeStampToken из TimeStampResp сохраняется как val в tstToken, тогда как для v1 сохраняется содержимое всего TimeStampResp.

Порядок подписи и встраивания метки времени

Функция into_writer сначала строит CoseSign1 с protected-заголовком и payload, затем вычисляет подписываемые данные tbs и выполняет подпись. То есть подпись формируется до запроса метки времени. После этого подпись при необходимости конвертируется из DER в P1363 для ES-алгоритмов и сериализуется в sig_data_cbor.

Только после этого вызывается build_unprotected_header, которому передаётся sig_data_cbor, и результат присваивается sign1.unprotected. Внутри вызывается add_sigtst_header, который запрашивает метку времени; при V2_sigTst2_CTT из ответа извлекается только TimeStampToken, оборачивается в TstContainer и кладётся в unprotected header под меткой sigTst2.

v2 требует, чтобы полная структура подписи была завершена до простановки метки времени — так метка служит countersignature на всю структуру подписи, включая сам сертификат.

Разделение protected и unprotected заголовков

В protected header попадает алгоритм подписи (например, PS256), цепочка сертификатов под меткой X5Chain и content type при его наличии. В unprotected header добавляется метка времени, а при наличии OCSP-ответа — rVals с массивом ocspVals.

Метка времени не может быть в protected header, потому что она формируется уже после подписи: запрос к TSA отправляется по уже подписанным данным, и результат кладётся как sigTst или sigTst2. Спецификация прямо говорит, что при отсутствии меток времени ни один из этих заголовков не должен присутствовать в unprotected header.

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

Padding в COSE-подписи

Padding резервирует в claim signature box место под данные, которые станут известны только после подписи: цепочку сертификатов, метку времени RFC 3161 и ответ OCSP. Дописать их после подписи нельзя: подпись claim ставится над сериализованным CBOR claim-документа, и unprotected-заголовок, куда попадают метка времени и OCSP, входит в подписываемую структуру — значит, его размер должен быть известен и зафиксирован ещё до того, как метка времени получена. Padding заполняет это место заранее, чтобы последующая вставка метки времени не изменила размер структуры и не нарушила подпись.

При создании временного COSE_Sign1_Tagged в unprotected-заголовок кладут пару с меткой pad и значением — bstr нужного размера, заполненным нулями; рекомендуется начальный размер 25 килобайт.

Размер резерва складывается из размера сырой подписи, длины цепочки сертификатов, накладных расходов COSE, резерва под метку времени при её наличии и длины ответа OCSP.

Сериализованный CoseSign1 подгоняется точно под целевой размер: если текущий размер уже совпадает с целевым, структура остаётся как есть; если запас слишком мал, чтобы уложиться в целевой размер, возвращается ошибка BoxSizeTooSmall; иначе длина padding подбирается в цикле. В цикле существующее значение pad заменяется на bstr нужной длины, а если padding не найден — он добавляется, и подгонка повторяется. При недостижимости точного размера одной вставкой добавляется второй объект pad2 длиной на 10 байт меньше последнего padding.

Валидация метки времени

В cose_validator.rs отсутствие или невалидность метки времени не прерывает валидацию: если tst_info не передан, вызывается validate_cose_tst_info и результат берётся через .ok(), то есть ошибка превращается в None. Комментарий в коде объясняет: сбои меток времени не фатальны по спецификации C2PA, нужно лишь залогировать состояние и использовать возвращённое значение, если не предоставлена альтернативная метка. Полученное значение передаётся в verify_signature, и клиенту возвращается результат проверки подписи.

Спецификация предписывает: если ни sigTst, ни sigTst2 не присутствуют, либо их токен не удовлетворяет требованиям, манифест валиден при условии, что текущее время валидации попадает в период действия учётных данных подписанта, и тогда возвращается claimSignature.insideValidity. Иначе манифест отклоняется с claimSignature.outsideValidity.

При проблемах с самим time-stamp выдаются информационные коды и метка игнорируется, а не отклоняется claim: timestamp.malformed при более чем одном токене, timestamp.mismatch при несовпадении messageImprint, timestamp.untrusted если сертификат TSA не найден или цепочка доверия не строится, timestamp.outsideValidity если attested time не попадает в период действия сертификата подписи TSA.

Порядок проверок в claim.rs

verify_internal не проверяет подпись claim напрямую: она принимает уже готовый результат в параметре verified и лишь интерпретирует его. Если verified — это Ok(vi) и vi.validated истинно, записываются успешные статусы CLAIM_SIGNATURE_INSIDE_VALIDITY и CLAIM_SIGNATURE_VALIDATED; если vi.validated ложно, фиксируется CLAIM_SIGNATURE_MISMATCH с Error::CoseSignature; если verified — Err(parse_err), фиксируется CLAIM_SIGNATURE_MISMATCH через failure_no_throw.

До проверки метки времени выполняются: проверка иконок claim_generator_info через verify_icons, проверка self-redactions и запрещённых redactions (ACTIONS, HASH_LABELS), подсчёт parent_count по ingredient_assertions и правила update_manifest (разрешённые действия и запрет thumbnail).

Сама проверка метки времени выполняется в cose_validator.rs: при отсутствии переданного tst_info вызывается validate_cose_tst_info с флагом verify_timestamp_trust, и результат передаётся в verify_signature. По спецификации порядок такой: сначала проверяется credential и строится цепочка доверия (signingCredential.trusted), затем валидируется подпись (claimSignature.validated), затем метка времени (timeStamp.trusted и timeStamp.validated), и только после этого проверяется OCSP. Если метка времени валидна, проверяется, что genTime попадает в период действия signing credential, иначе claimSignature.outsideValidity.

Атака «Hacking Time»: обнуление хеша через exclusions

Атака использует то, что C2PA допускает произвольные exclusions — байтовые диапазоны внутри файла, исключаемые из вычисления подписи. В утверждении c2pa.hash.data задаётся единственный диапазон исключения с start=0 и length=3995383, то есть исключается весь файл целиком. Хеш вычисляется по байтам актива за вычетом байтов, попавших в диапазоны исключения, поэтому при таком исключении не остаётся ни одного байта для хеширования, и вычисляется хеш пустой строки:

"exclusions": [ { "start": 0, "length": 3995383 } ],
"hash": "47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU="

3995383 — это длина всего файла, а 47DE...uFU= — хеш пустой строки, что подтверждается командой openssl sha256 -binary /dev/null | base64. Такой хеш оказывается валидным для любого контента, потому что при проверке хеш считается по активу минус исключённые байты, а исключён весь файл.

Подписывается не сам контент, а хеш, вычисленный по данным claim. Поскольку подпись claim покрывает только этот хеш, а не содержимое файла, замена контента не меняет подписанные данные, и старая метка времени остаётся валидной. В коде Claim::data() возвращает либо исходные байты, прочитанные из файла, либо локально сгенерированный CBOR, что и определяет, какие именно байты попадают под подпись.

Как проверка exclusions пропускает такой манифест

Проверка data_hash_exclusions_match_manifest принимает решение по двум условиям. Первое: если ни одна из переданных exclusions не имеет ненулевой длины, проверка сразу проходит — это случай remote/sidecar-манифеста, где весь ассет хешируется и следить не за чем. Второе: если manifest_store_range известен (манифест реально найден в этом ассете), приемлемым считается только такое множество exclusions, которое содержит диапазон, точно совпадающий с реальным расположением манифеста. Если manifest_store_range равен None, проверка проходит только при is_embedded == true; иначе манифест считается detached и отклоняется.

Exclusions, покрывающие весь файл, проходят проверку не из-за самой проверки, а потому что в атаке манифест встроен в ассет и manifest_store_range совпадает с этим единственным exclusion-диапазоном: start=0, length=3995383, где 3995383 — длина всего файла, а хеш — хеш пустой строки, то есть хешируется ноль байт.

Что видит валидатор

Клиент-валидатор присваивает коды статуса из трёх групп: success, informational и failure. Успешная проверка подписи claim даёт claimSignature.validated, совпадение хэша утверждения — assertion.hashedURI.match; эти коды попадают в массив success.

В атаке claim-подпись ставится поверх хэша, который покрывает пустоту, поэтому валидатор видит успешные коды (claimSignature.validated, assertion.hashedURI.match, timeStamp.validated) и не выдаёт failure-кодов. UI показывает «валидную» метку происхождения с поддельным временем, хотя подписанные данные фактически пусты. Как сказано в источнике, «The claim signature is over that hash, and the timestamp signature is over the claim signature. We're effectively signing nothing at all, but as of today all the C2PA verification tools I can find don't flag anything as unusual.»

Зачем exclusions вообще нужны

Спецификация различает hard binding и soft binding. Hard binding отвечает за защиту от подмены: он охватывает сырые байты актива и потому позволяет обнаружить любое изменение файла после подписания. Soft binding такой защиты не даёт — он лишь помогает опознать производные версии и рендишены, поэтому не может служить доказательством неизменности. Hard binding реализуется через data hash assertion (диапазон байтов), general box hash assertion (для не-BMFF боксовых форматов вроде JPEG, PNG, GIF) или BMFF-based hash assertion (для ISO BMFF).

Exclusions нужны потому, что при встраивании манифеста в файл часть байтов должна быть исключена из хешируемого диапазона, иначе хеш не совпадёт. Для JPEG 1 APP11-маркер (FFEB) и длина сегмента Lp всех APP11-сегментов с JUMBF должны быть включены в диапазон исключения. Для PNG Length и 'caBX' чанка с JUMBF должны быть включены в диапазон исключения.

Спецификация прямо указывает, что ранее включавшиеся дополнительные обязательные исключения были признаны небезопасными: «Previous versions of this specification included additional mandatory exclusions, but it was discovered that excluding them is insecure.» Компромисс в том, что исключённые байты не покрываются хешем, поэтому в них теоретически можно вносить изменения, не нарушая hard binding; именно поэтому набор исключений минимизирован.

Почему метка времени необязательна

Метка времени сделана необязательной, потому что сбои её проверки не фатальны. Это означает, что при отсутствии переданного извне tst_info код пытается получить его сам, но результат оборачивается в .ok(), то есть ошибка превращается в None и не прерывает проверку подписи.

Такое поведение смягчает сбои TSA (недоступность сервера, неверный статус, отсутствие цепочки доверия), но открывает возможность подделки времени: злоумышленник может подделать или удалить метку времени, и проверка подписи всё равно пройдёт. В атаке через исключения это позволяет изменить содержимое после получения метки времени, не нарушая подпись и не нарушая доказательство метки времени TSA.

Что спецификация требует от валидаторов

При валидации стандартного манифеста диапазоны исключений извлекаются из утверждения c2pa.hash.data. Если конечное смещение одного диапазона (start + length) больше начального смещения следующего диапазона в массиве, либо значение start или length отрицательно, манифест отклоняется с кодом assertion.dataHash.malformed. Если конец диапазона выходит за конец актива, манифест отклоняется с assertion.dataHash.mismatch. Если поле hash отсутствует или полученный хеш не совпадает, манифест также отклоняется с assertion.dataHash.mismatch.

Для update-манифеста требуется ровно одно утверждение ингредиента с relationship равным parentOf, иначе отклонение с manifest.update.wrongParents; также запрещены любые утверждения, задающие привязку содержимого — c2pa.hash.data, c2pa.hash.boxes, c2pa.hash.collection.data, c2pa.hash.bmff.v2, c2pa.hash.bmff.v3 — или thumbnail-утверждения, иначе отклонение с manifest.update.invalid.

Для time-stamp манифеста требуется ровно одно утверждение ингредиента с relationship равным parentOf, иначе отклонение с manifest.timestamp.wrongParents, и запрещены любые другие утверждения, иначе отклонение с manifest.timestamp.invalid.

Что из этого следует на практике

Метка времени C2PA защищает не содержимое файла, а только факт существования подписи claim в определённый момент. Подпись claim, в свою очередь, покрывает хеш, вычисленный по данным claim с учётом exclusions. Если exclusions покрывают весь файл, хешируется пустая строка, и обе подписи остаются валидными при любом содержимом.

Проверка data_hash_exclusions_match_manifest не ловит такой случай, потому что она сверяет exclusions с реальным расположением манифеста, а не с разумностью их размера. Если манифест встроен в ассет и exclusion совпадает с его диапазоном, проверка проходит.

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

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

Где смотреть в коде

Источники

Похожее