Описание
Расширение 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 = new CPSignedData();
$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_refcounted gc; // 8 байт
zend_ulong h; // 8 байт
size_t len; // 8 байт
char val[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 = new CPSignedData();
$sd1->set_ContentEncoding(BASE64_TO_BINARY);
$sd1->set_Content($payloadBase64);
try { $sd1->VerifyCades($signBase64, CADES_BES, true); } catch (Throwable $e) {}
// Dummy #2 — безопасно упадёт с 0x80070057
$sd2 = new CPSignedData();
$sd2->set_ContentEncoding(ENCODE_BINARY);
$sd2->set_Content(base64_decode($payloadBase64));
try { $sd2->VerifyCades($signBase64, CADES_BES, true); } catch (Throwable $e) {}
// Рабочий вызов
$sd = new CPSignedData();
$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, а не исправление корневой причины.