Skip to content

Segfault в phpcades при VerifyCades зависит от длины пути к PHP-скрипту #11

Description

@anktx

Описание

Расширение 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.php16segfault
/app/tools/aaaaaaaaaaaaaaaaaaaa.php36segfault
/app/tools/aaaaaaaaaaaaaaaaaaaaaaa.php39segfault
/app/tools/aaaaaaaaaaaaaaaaaaaaaaaa.php40OK
/app/tools/aaaaaaaaaaaaaaaaaaaaaaaaa.php41OK
/app/tools/SOLUTION_verify_detached_cades.php45OK

Порог: 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) на результат не влияет — только длина пути.

Окружение

КомпонентВерсия
КриптоПро CSP5.0 (linux-amd64_deb)
PHP8.4.20 (cli, NTS)
phpcadescommit 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Бин mallocPadding
/app/tools/a.php1641487
.../aaaaaaaaaaaaaaaaaaaaaaa.php3964640
.../aaaaaaaaaaaaaaaaaaaaaaaa.php40658015

Когда 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";

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions