Любая интеграция с PKCS#11 начинается одинаково: загрузить модуль, вызвать C_Initialize, передать CKF_OS_LOCKING_OK, раз приложение многопоточное. Флаг читается как заявление, что библиотеку можно звать из нескольких потоков. На деле это запрос, и стандарт прямо говорит, что должна сделать библиотека, которая его не тянет.
Формулировки ниже даны в нашем переводе, ссылки на оригиналы в конце.
Чего требует стандарт
PKCS#11 версии 2.40 задаёт для C_Initialize четыре случая — по флагу и по тому, передало ли приложение свои функции для мьютексов. Почти все используют второй:
Если флаг установлен, а указатели на функции не переданы (то есть все имеют значение NULL_PTR), это означает, что приложение будет обращаться к Cryptoki из нескольких потоков, и библиотеке нужно использовать примитивы операционной системы для обеспечения безопасного многопоточного доступа. Если библиотека не в состоянии это сделать, C_Initialize должна вернуть значение CKR_CANT_LOCK.
У самого кода возврата тоже есть определение:
CKR_CANT_LOCK: это значение может быть возвращено только функцией C_Initialize. Оно означает, что запрошенный приложением тип блокировок для потокобезопасности в данной библиотеке недоступен, и потому приложение не может использовать эту библиотеку указанным образом.
Договор ясен: просим блокировки средствами системы, а библиотека, которая так не умеет, об этом сообщает. Чего она делать не должна — принять запрос и упасть.
Что делает актуальный провайдер
Мы замерили коммерческий провайдер, который сам через C_GetInfo называет себя Aktiv Co., Rutoken ECP PKCS #11 library, версия библиотеки 2.21, Cryptoki 2.40 — это текущий пакет вендора для macOS, выпущенный за две недели до этой статьи. Токен — Rutoken ECP, хост — macOS.
Репродьюсер не зависит ни от чего, кроме крейта cryptoki. Две нити встречаются на барьере и вызывают C_Initialize в один момент, обе передают CKF_OS_LOCKING_OK без функций для мьютексов — это случай 2 из стандарта.
use cryptoki::context::{CInitializeArgs, CInitializeFlags, Pkcs11};
use std::sync::{Arc, Barrier};
use std::thread;
fn main() {
let path = std::env::var("PKCS11_MODULE_PATH").expect("set PKCS11_MODULE_PATH");
let barrier = Arc::new(Barrier::new(2));
let threads: Vec<_> = (0..2)
.map(|_| {
let path = path.clone();
let barrier = Arc::clone(&barrier);
thread::spawn(move || {
let ctx = Pkcs11::new(&path).expect("load module");
barrier.wait();
ctx.initialize(CInitializeArgs::new(CInitializeFlags::OS_LOCKING_OK))
})
})
.collect();
for t in threads {
match t.join().expect("thread panicked") {
Ok(()) => println!("initialized"),
Err(e) => println!("refused: {e}"),
}
}
}
Укажите PKCS11_MODULE_PATH на свой провайдер и прогоняйте в цикле: каждая итерация — отдельный процесс, потому что упавший ничего не сообщит.
| Что выполняется | Результат на 10–20 прогонах |
|---|---|
Два вызова C_Initialize подряд | Корректно каждый раз: первый возвращает успех, второй — CKR_CRYPTOKI_ALREADY_INITIALIZED |
| Две нити под мьютексом в нашем коде | Корректно каждый раз, та же пара результатов |
| Две нити без сериализации, флаг передан | Смерть процесса, 20 из 20 |
Ни одного чистого прогона. Умирает по-разному: девять раз SIGABRT с libc++abi: terminating в поток ошибок, пять раз SIGSEGV, шесть раз SIGTRAP. Такой разброс — признак разрушенного состояния, а не одного неверного указателя.
CKR_CANT_LOCK библиотека не вернула ни разу. Она приняла запрос, который выполнить не может.
Год назад мы наткнулись на это же на версии 2.14.1, на двух моделях токенов: там оно проявлялось необработанным исключением C++ на одной и ошибкой сегментации на другой. Семь минорных релизов спустя репродьюсер работает по-прежнему.
Дело не в одном вендоре
У этого дефекта есть история в экосистеме, и случаи в открытом коде публичны. OpenSC жил с ним до 2021 года; запрос на слияние, который его закрыл, описывает механику точно:
C_Initialize может вызываться несколькими потоками. Но при попытке создать контекст OpenSC, настроить глобальные блокировки и обнаружить карты несколько потоков могут делать это одновременно, поскольку единственная проверка — «if (context == NULL)», а она может не сработать вовремя, и данные вроде существующего контекста затираются, вызывая дополнительные проблемы с pcsc.
Починкой стал мьютекс вокруг инициализации, влитый в январе 2021 года. У tpm2-pkcs11 был смежный отказ: C_OpenSession уходил в дедлок именно тогда, когда C_Initialize вызывали с CKF_OS_LOCKING_OK, — сообщение 2018 года.
Проверка вида if (context == NULL) и есть повторяющаяся причина: выглядит как «инициализировать однажды», а на деле гонка. Флаг усугубляет — приложение, передавшее его, считает, что о многопоточности позаботились, и зовёт из нескольких потоков, что и создаёт предпосылку для дефекта.
Почему в модуле аутентификации это тяжелее
В браузере или на сервере падение в PKCS#11 — это отказ обслуживания. В PAM-модуле хуже. pam_tessera — разделяемая библиотека, загружаемая в чужой процесс: sshd, login, дисплейный менеджер. Смерть процесса там не операция, вернувшая код ошибки, а исчезновение самого процесса аутентификации. На устройстве без присмотра, где этот же механизм и есть способ войти, это машина, в которую никто не войдёт, пока к ней не придут физически.
Из-за этой асимметрии мы считаем многопоточность провайдера враждебной по умолчанию.
Что мы с этим делаем
Три правила.
Сериализовать C_Initialize на весь процесс. Не на дескриптор и не на экземпляр — дефект провайдера глобален для процесса, значит и защита должна быть такой. Наш реестр контекстов держит один инициализированный контекст на путь к модулю и раздаёт ссылки: второй вызывающий присоединяется к существующему, а не спорит за создание нового.
Всё равно просить блокировки у системы. Флаг по-прежнему передаётся при каждом C_Initialize, потому что библиотеке, соблюдающей стандарт, нужно услышать запрос. Спросить ничего не стоит; дорого обходится вера в ответ.
По умолчанию сериализовать каждый вызов, а расхождения решать в сторону строгого. Если два компонента в одном процессе хотят разных режимов блокировки, побеждает строгий: сериализация, от которой половина вызывающих отказалась, не сериализует ничего. Цена — один незанятый мьютекс на вызов, десятки наносекунд против миллисекунд аппаратной подписи.
Тест, который прятал это три года
Неприятнее всего не провайдер. Наша собственная сюита всё это время докладывала, что здесь здорово: всё гонялось на SoftHSM2, который гонку переживает, а тест, названный в честь конкурентного случая, не делал ни одного вызова в PKCS#11 — внутри были две нити и thread::sleep. Он мерил наш собственный мьютекс и зеленел год за годом, подпирая комментарий в исходниках о том, что современные провайдеры с многопоточностью справляются.
Зелёный тест, не доходящий до зависимости, в честь которой назван, хуже отсутствующего: он превращает непроверенное допущение в видимость доказательства. Когда сюиту наконец прогнали на живом железе, допущение умерло на первой минуте.
Если вы интегрируете PKCS#11 сами: сериализуйте инициализацию на весь процесс независимо от того, что заявляет провайдер, и добейтесь, чтобы хотя бы один тест в вашей сюите вызывал вендорскую библиотеку на том железе, с которым вы поставляетесь, а не программный токен, который снисходительнее того, что окажется в руках у пользователя.
Честные границы
Один провайдер, одна операционная система, одно семейство токенов, одна сборка. Мы не перепроверяли сборку 2.21 под Linux, актуальные релизы других вендоров и вызовы помимо C_Initialize. Репродьюсер достаточно короток, чтобы каждый проверил своё сочетание, а результат двоичный: процесс либо переживает двадцать конкурентных инициализаций, либо нет.
Снаружи здесь эксплуатировать нечего — чтобы добраться до этого кода, надо уже выполняться внутри процесса. Это дефект устойчивости, из тех, что решают, останется ли парк устройств без присмотра доступным.
Источники
- PKCS #11 Cryptographic Token Interface Base Specification Version 2.40, OASIS —
C_Initialize, четыре случая блокировок иCKR_CANT_LOCK - OpenSC, запрос на слияние #2067 «PKCS11 C_Initialize locking» — влит в январе 2021
- tpm2-pkcs11, issue #38 — дедлок при
CKF_OS_LOCKING_OK, 2018