Описание
Расширение phpcades (PHP-обёртка над КриптоПро CSP) крашит процесс (segfault, exit code 139) при вызове CPSignedData::VerifyCades(). Краш воспроизводится стабильно и детерминированно в зависимости от длины полного пути к PHP-скрипту.
Минимальное воспроизведение
<?php// verify-test.php$payloadBytes = file_get_contents('/path/to/payload.bin');
$signBytes = file_get_contents('/path/to/signature.p7s');
$payloadBase64 = base64_encode($payloadBytes);
$signBase64 = base64_encode($signBytes);
$sd = newCPSignedData();
$sd->set_ContentEncoding(BASE64_TO_BINARY);
$sd->set_Content($payloadBase64);
$sd->VerifyCades($signBase64, CADES_BES, 1); // segfault# Копируем один и тот же файл под короткое и длинное имя:
cp verify-test.php /app/tools/a.php
cp verify-test.php /app/tools/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.php
# Короткий путь → segfault:
php -d opcache.enable=0 /app/tools/a.php
# Длинный путь → OK:
php -d opcache.enable=0 /app/tools/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.php
Условие
Segfault возникает, если полный путь к скрипту короче 40 символов.
Проводился бинарный поиск — один и тот же файл копировался под разными именами:
| Полный путь | Длина | Результат |
|---|
/app/tools/a.php | 16 | segfault |
/app/tools/aaaaaaaaaaaaaaaaaaaa.php | 36 | segfault |
/app/tools/aaaaaaaaaaaaaaaaaaaaaaa.php | 39 | segfault |
/app/tools/aaaaaaaaaaaaaaaaaaaaaaaa.php | 40 | OK |
/app/tools/aaaaaaaaaaaaaaaaaaaaaaaaa.php | 41 | OK |
/app/tools/SOLUTION_verify_detached_cades.php | 45 | OK |
Порог: 40 символов полного пути.
Идентичные файлы — разный результат
Побайтовое сравнение файлов подтвердило идентичность (md5 совпадает). При этом:
SOLUTION_verify_detached_cades.php (45 символов) — работает стабильно 3/3 запусковtest_step1.php (30 символов) — падает стабильно 3/3 запусков
Переименование подтверждает, что результат определяется путём, а не содержимым:
# Файл A (длинное имя) работает, файл B (короткое имя) падает
cp verify-test.php /app/tools/SOLUTION_verify_detached_cades.php # OK
cp verify-test.php /app/tools/test_step1.php # segfault# Меняем имена местами — результат меняется вслед за именем
mv /app/tools/SOLUTION_verify_detached_cades.php /app/tools/backup.php
mv /app/tools/test_step1.php /app/tools/SOLUTION_verify_detached_cades.php
php /app/tools/SOLUTION_verify_detached_cades.php # OK (бывший test_step1)
php /app/tools/backup.php # segfault (бывший SOLUTION)
Способ вызова (напрямую / через sh -c) на результат не влияет — только длина пути.
Окружение
| Компонент | Версия |
|---|
| КриптоПро CSP | 5.0 (linux-amd64_deb) |
| PHP | 8.4.20 (cli, NTS) |
| phpcades | commit 9ec6829 (master) |
| ОС контейнера | Debian bookworm (php:8.4-cli-bookworm) |
| OPcache | отключён (opcache.enable=0) |
Установленная причина: сырой указатель на zend_string в set_Content
Анализ исходного кода phpcades
Анализ репозитория https://github.com/CryptoPro/phpcades выявил корневую причину.
Файл: src/PHPCadesCPSignedData.cpp, строки ~196-210:
PHP_METHOD(CPSignedData, set_Content) {
char *sVal;
size_t lVal;
if (zend_parse_parameters(ZEND_NUM_ARGS(), "s", &sVal, &lVal) == FAILURE)
RETURN_WITH_EXCEPTION(E_INVALIDARG);
HR_ERRORCHECK_RETURN(obj->m_pCppCadesImpl->put_Content(sVal, lVal));
}sVal — это прямой указатель во внутренний буфер zend_string (поле val[1]). Он передаётся без копирования в закрытую библиотеку libcppcades.so через put_Content(). Если библиотека хранит этот указатель или читает за границей lVal байт при последующем вызове VerifyCades(), происходит чтение из кучи, layout которой зависит от пути к скрипту.
phpcades — C++ расширение, работающее с PHP-строками через структуру zend_string:
struct_zend_string {
zend_refcountedgc; // 8 байтzend_ulongh; // 8 байтsize_tlen; // 8 байтcharval[1]; // тело строки
};Полная аллокация: 24 + длина_строки + 1 байт.
Аллокатор glibc malloc выделяет память бинами фиксированного размера: 32, 48, 64, 80, 96...
| Полный путь | Длина пути | Аллокация zend_string | Бин malloc | Padding |
|---|
/app/tools/a.php | 16 | 41 | 48 | 7 |
.../aaaaaaaaaaaaaaaaaaaaaaa.php | 39 | 64 | 64 | 0 |
.../aaaaaaaaaaaaaaaaaaaaaaaa.php | 40 | 65 | 80 | 15 |
Когда zend_string занимает бин полностью (padding = 0), чтение за границей попадает на метаданные следующего блока кучи → segfault. Когда padding > 0 — читает нулевые байты заполнения → не падает.
Порог 40 символов — это граница между бинами 64 и 80 аллокатора glibc.
Рекомендуемый фикс
// В set_Content — делать копию с null-терминатором:
std::vector<char> contentCopy(sVal, sVal + lVal + 1);
contentCopy[lVal] = '\0';
HR_ERRORCHECK_RETURN(obj->m_pCppCadesImpl->put_Content(contentCopy.data(), lVal));
Workaround
Создание трёх объектов CPSignedData с предварительными «холостыми» вызовами VerifyCades (trio-паттерн) устраняет segfault, так как меняет layout кучи. Однако это workaround, а не исправление корневой причины.
<?php// Workaround: trio-паттерн$payloadBase64 = base64_encode(file_get_contents('/path/to/payload.bin'));
$signBase64 = base64_encode(file_get_contents('/path/to/signature.p7s'));
// Dummy #1 — безопасно упадёт с 0x80070057$sd1 = newCPSignedData();
$sd1->set_ContentEncoding(BASE64_TO_BINARY);
$sd1->set_Content($payloadBase64);
try { $sd1->VerifyCades($signBase64, CADES_BES, true); } catch (Throwable$e) {}
// Dummy #2 — безопасно упадёт с 0x80070057$sd2 = newCPSignedData();
$sd2->set_ContentEncoding(ENCODE_BINARY);
$sd2->set_Content(base64_decode($payloadBase64));
try { $sd2->VerifyCades($signBase64, CADES_BES, true); } catch (Throwable$e) {}
// Рабочий вызов$sd = newCPSignedData();
$sd->set_ContentEncoding(BASE64_TO_BINARY);
$sd->set_Content($payloadBase64);
try {
$sd->VerifyCades($signBase64, CADES_BES, 1);
} catch (Throwable$e) {
// 0x800B010E — подпись верна, проверка отзыва недоступна
}
$signer = $sd->get_Signers()->get_Item(1);
$cert = $signer->get_Certificate();
echo$cert->GetInfo(CERT_INFO_SUBJECT_SIMPLE_NAME) . "\n";
Описание
Расширение
phpcades(PHP-обёртка над КриптоПро CSP) крашит процесс (segfault, exit code 139) при вызовеCPSignedData::VerifyCades(). Краш воспроизводится стабильно и детерминированно в зависимости от длины полного пути к PHP-скрипту.Минимальное воспроизведение
Условие
Segfault возникает, если полный путь к скрипту короче 40 символов.
Проводился бинарный поиск — один и тот же файл копировался под разными именами:
/app/tools/a.php/app/tools/aaaaaaaaaaaaaaaaaaaa.php/app/tools/aaaaaaaaaaaaaaaaaaaaaaa.php/app/tools/aaaaaaaaaaaaaaaaaaaaaaaa.php/app/tools/aaaaaaaaaaaaaaaaaaaaaaaaa.php/app/tools/SOLUTION_verify_detached_cades.phpПорог: 40 символов полного пути.
Идентичные файлы — разный результат
Побайтовое сравнение файлов подтвердило идентичность (md5 совпадает). При этом:
SOLUTION_verify_detached_cades.php(45 символов) — работает стабильно 3/3 запусковtest_step1.php(30 символов) — падает стабильно 3/3 запусковПереименование подтверждает, что результат определяется путём, а не содержимым:
Способ вызова (напрямую / через
sh -c) на результат не влияет — только длина пути.Окружение
opcache.enable=0)Установленная причина: сырой указатель на
zend_stringвset_ContentАнализ исходного кода phpcades
Анализ репозитория https://github.com/CryptoPro/phpcades выявил корневую причину.
Файл:
src/PHPCadesCPSignedData.cpp, строки ~196-210:sVal— это прямой указатель во внутренний буферzend_string(полеval[1]). Он передаётся без копирования в закрытую библиотекуlibcppcades.soчерезput_Content(). Если библиотека хранит этот указатель или читает за границейlValбайт при последующем вызовеVerifyCades(), происходит чтение из кучи, layout которой зависит от пути к скрипту.phpcades — C++ расширение, работающее с PHP-строками через структуру
zend_string:Полная аллокация:
24 + длина_строки + 1байт.Аллокатор glibc
mallocвыделяет память бинами фиксированного размера: 32, 48, 64, 80, 96.../app/tools/a.php.../aaaaaaaaaaaaaaaaaaaaaaa.php.../aaaaaaaaaaaaaaaaaaaaaaaa.phpКогда
zend_stringзанимает бин полностью (padding = 0), чтение за границей попадает на метаданные следующего блока кучи → segfault. Когда padding > 0 — читает нулевые байты заполнения → не падает.Порог 40 символов — это граница между бинами 64 и 80 аллокатора glibc.
Рекомендуемый фикс
Workaround
Создание трёх объектов
CPSignedDataс предварительными «холостыми» вызовамиVerifyCades(trio-паттерн) устраняет segfault, так как меняет layout кучи. Однако это workaround, а не исправление корневой причины.