Digital signatures were introduced to Portable Document Format (PDF) a quarter of a century ago. There are several ways to sign a PDF. Usually, one hashes the document content using a cryptographic hash function and then computes a public-key signature over the hash. The resulting value and a certificate carrying the signer's public key are then embedded in the PDF for the recipients to verify.

For a signature to be considered valid, it is necessary that:

  1. the hash value within the signature matches the contents of the PDF file
  2. the public-key signature verifies under the appropriate public key
  3. the certificate is trusted

If any of the verification steps is omitted, forgeries become possible. I discovered that LibreOffice (CVE-2025-2866) and Poppler (CVE-2025-43903) could be made to skip the second step, permitting trivial signature forgeries.

To detach or not to detach

The usual language of cryptography can get rather confusing. Detached signatures make matters worse. A signed PDF file carries the signature within itself, the signature must obviously be attached to the PDF.

A signature is called detached if it only carries a hash of a message instead of the actual content. One can either embed the file in the signature or the signature in the file. Because signed documents should be valid PDF files, any signature attached to a PDF is a detached signature.

Two recent standard formats of signatures attached to PDFs are called adbe.pkcs7.detached and ETSI.CAdES.detached. There is an older format called adbe.pkcs7.sha1 that is no less attached (or detached) than the other two. Signatures conforming to the legacy signature format are sometimes incorrectly referred to as non-detached.

A tale of two formats

The three formats mentioned so far follow Cryptographic Message Syntax (CMS). The standard defines the precise encoding of the data structure that includes hashes, certificates and public-key signatures. The format adbe.pkcs7.sha1 differs from adbe.pkcs7.detached and ETSI.CAdES.detached in the specific position of the hash value within the data structure. This must be reflected in the code performing verification.

The two *.detached formats are the most common and both LibreOffice and Poppler do a reasonable job verifying such signatures.

Spot the bug

This is how Poppler used to verify "non-detached" adbe.pkcs7.sha1 signatures using NSS:

SECItem *content_info_data = CMSSignedData->contentInfo.content.data;
if (content_info_data != nullptr && content_info_data->data != nullptr) {
    /*
      This means it's not a detached type signature
      so the digest is contained in SignedData->contentInfo
    */
    if (digest.len == content_info_data->len && memcmp(digest.data, content_info_data->data, digest.len) == 0) {
        return SIGNATURE_VALID;
    } else {
        return SIGNATURE_DIGEST_MISMATCH;
    }

} else if (NSS_CMSSignerInfo_Verify(CMSSignerInfo, &digest, nullptr) != SECSuccess) {
    return NSS_SigTranslate(CMSSignerInfo->verificationStatus);
} else {
    return SIGNATURE_VALID;
}

The following is the analogue in LibreOffice on Linux & macOS:

SECItem* pContentInfoContentData = pCMSSignedData->contentInfo.content.data;
if (bNonDetached && pContentInfoContentData && pContentInfoContentData->data)
{
    // Not a detached signature.
    if (!std::memcmp(pActualResultBuffer, pContentInfoContentData->data, nMaxResultLen) && nActualResultLen == pContentInfoContentData->len)
        rInformation.nStatus = xml::crypto::SecurityOperationStatus_OPERATION_SUCCEEDED;
}
else
{
    // Detached, the usual case.
    SECItem aActualResultItem;
    aActualResultItem.data = pActualResultBuffer;
    aActualResultItem.len = nActualResultLen;
    if (NSS_CMSSignerInfo_Verify(pCMSSignerInfo, &aActualResultItem, nullptr) == SECSuccess)
        rInformation.nStatus = xml::crypto::SecurityOperationStatus_OPERATION_SUCCEEDED;
}

and on Windows:

if (bNonDetached)
{
    // Not a detached signature.
    DWORD nContentParam = 0;
    if (!CryptMsgGetParam(hMsg, CMSG_CONTENT_PARAM, 0, nullptr, &nContentParam))
    {
        SAL_WARN("svl.crypto", "ValidateSignature: CryptMsgGetParam() failed");
        return false;
    }

    std::vector<BYTE> aContentParam(nContentParam);
    if (!CryptMsgGetParam(hMsg, CMSG_CONTENT_PARAM, 0, aContentParam.data(), &nContentParam))
    {
        SAL_WARN("svl.crypto", "ValidateSignature: CryptMsgGetParam() failed");
        return false;
    }

    if (VerifyNonDetachedSignature(aData, aContentParam))
        rInformation.nStatus = xml::crypto::SecurityOperationStatus_OPERATION_SUCCEEDED;
}
else
{
    // Detached, the usual case.
    // Use the CERT_INFO from the signer certificate to verify the signature.
    if (CryptMsgControl(hMsg, 0, CMSG_CTRL_VERIFY_SIGNATURE, pSignerCertContext->pCertInfo))
        rInformation.nStatus = xml::crypto::SecurityOperationStatus_OPERATION_SUCCEEDED;
}
// ...

/// Verifies a non-detached signature using CryptoAPI.
bool VerifyNonDetachedSignature(const std::vector<unsigned char>& aData, const std::vector<BYTE>& rExpectedHash)
{
    HCRYPTPROV hProv = 0;
    if (!CryptAcquireContextW(&hProv, nullptr, nullptr, PROV_RSA_AES, CRYPT_VERIFYCONTEXT))
    {
        SAL_WARN("svl.crypto", "CryptAcquireContext() failed");
        return false;
    }

    HCRYPTHASH hHash = 0;
    if (!CryptCreateHash(hProv, CALG_SHA1, 0, 0, &hHash))
    {
        SAL_WARN("svl.crypto", "CryptCreateHash() failed");
        return false;
    }

    if (!CryptHashData(hHash, aData.data(), aData.size(), 0))
    {
        SAL_WARN("svl.crypto", "CryptHashData() failed");
        return false;
    }

    DWORD nActualHash = 0;
    if (!CryptGetHashParam(hHash, HP_HASHVAL, nullptr, &nActualHash, 0))
    {
        SAL_WARN("svl.crypto", "CryptGetHashParam() failed to provide the hash length");
        return false;
    }

    std::vector<unsigned char> aActualHash(nActualHash);
    if (!CryptGetHashParam(hHash, HP_HASHVAL, aActualHash.data(), &nActualHash, 0))
    {
        SAL_WARN("svl.crypto", "CryptGetHashParam() failed to provide the hash");
        return false;
    }

    CryptDestroyHash(hHash);
    CryptReleaseContext(hProv, 0);

    return aActualHash.size() == rExpectedHash.size() &&
           !std::memcmp(aActualHash.data(), rExpectedHash.data(), aActualHash.size());
}

No verification was performed beyond memcmp() on message digests in the "non-detached" case. Because the hashes are independent of the signer's key (pair), signatures were trivial to forge. Pick any document & certificate, assemble a CMS signature with the appropriate message digest. No cryptography beyond SHA-1 needed.

The impact

Here is an example file that appeared to LibreOffice as signed by the Swiss Post:

Screenshot of LibreOffice for Windows opening forgery_legacy.pdf

The pdfsig utility shipped with Poppler was also satisfied:

Digital Signature Info of: forgery_legacy.pdf
Signature #1:
  - Signature Field Name: 7CD61DE662728B919752DCFFAAC67ABB
  - Signer Certificate Common Name: Post CH AG
  - Signer full Distinguished Name: CN=Post CH AG,O=Post CH AG,OID.2.5.4.97=CHE-435.551.225,L=Bern,ST=Bern,C=CH
  - Signing Time: Mar 10 2025 20:56:49
  - Signing Hash Algorithm: SHA1
  - Signature Type: adbe.pkcs7.sha1
  - Signed Ranges: [0 - 5705], [13387 - 13738]
  - Total document signed
  - Signature Validation: Signature is Valid.
  - Certificate Validation: Certificate is Trusted.

Okular, a document viewer using the Poppler library displayed the following:

Screenshot of Okular opening forgery_legacy.pdf

Needless to say, I do not control the private RSA key associated with the certificate used in my examples. In fact, I omitted the RSA signatures completely. The progams skip the verification step anyway.

Poppler amnesia

The attack only works if the signature follows the adbe.pkcs7.sha1 format. It is not possible to forge a signature that LibreOffice would "see" as ETSI.CAdES.detached.

Upon verification, Poppler checks that the signature claims to conform to one of the three supported formats. By the time it reaches the code snippet I pasted above, it "forgets" which particular format is being dealt with. The library can therefore be lied to. Embed a forged adbe.pkcs7.sha1 signature and label it as ETSI.CAdES.detached (say).

Because Poppler does not enforce the sha1 part of adbe.pkcs7.sha1, one can even "upgrade" the forgery to use SHA-256. Since PDF 2.0, the adbe.pkcs7.sha1 format and indeed any use of SHA-1 have been deprecated. Here is an example PDF with a forged signature that appears rather modern:

Digital Signature Info of: forgery_etsi_sha256.pdf
Signature #1:
  - Signature Field Name: C57B1C8F555B3C8D7B9C263DC6BFF4F1
  - Signer Certificate Common Name: Post CH AG
  - Signer full Distinguished Name: CN=Post CH AG,O=Post CH AG,OID.2.5.4.97=CHE-435.551.225,L=Bern,ST=Bern,C=CH
  - Signing Time: Mar 10 2025 21:02:04
  - Signing Hash Algorithm: SHA-256
  - Signature Type: ETSI.CAdES.detached
  - Signed Ranges: [0 - 5709], [13391 - 13743]
  - Total document signed
  - Signature Validation: Signature is Valid.
  - Certificate Validation: Certificate is Trusted.

What went wrong?

The two independent projects are known to share ideas and some code responsible for digital signatures. Such sharing is a great feature of free software. I wondered whether the bug had found its way to LibreOffice from Poppler. The precise origin does not really matter, the bugs have been around for about a decade. I spotted the problem in February 2025 and cannot recall either, where I saw it first.

Software bugs and vulnerabilities are a fact of life. Both Poppler and LibreOffice are mature projects, there are code reviews and (some) tests in place. A vague suggestion of "more tests" sounds easy but involves some work, especially in the case of signature verification.

Accepting valid signatures is equivalent to rejecting invalid signatures. Yet the latter goal is a lot more important. A rejected valid signature is an inconvenience, an accepted invalid signature a vulnerability. Any validation routine should be thoroughly tested on all sorts of invalid signatures. The real world tends to deliver test cases of the "wrong" kind. As a programmer, one normally fixes an inconvenience. Testing for the associated vulnerability requires PDF files with invalid signatures one often has to craft "by hand".

LibreOffice and Poppler are free software. Anybody can submit patches or review code contributed by others. I did. There is hopefully one fewer bug in the recent releases. The system works. Consider joining us.