VYPR
Medium severity6.2NVD Advisory· Published Apr 8, 2026· Updated Apr 21, 2026

CVE-2026-33753

CVE-2026-33753

Description

rfc3161-client is a Python library implementing the Time-Stamp Protocol (TSP) described in RFC 3161. Prior to 1.0.6, an Authorization Bypass vulnerability in rfc3161-client's signature verification allows any attacker to impersonate a trusted TimeStamping Authority (TSA). By exploiting a logic flaw in how the library extracts the leaf certificate from an unordered PKCS#7 bag of certificates, an attacker can append a spoofed certificate matching the target common_name and Extended Key Usage (EKU) requirements. This tricks the library into verifying these authorization rules against the forged certificate while validating the cryptographic signature against an actual trusted TSA (such as FreeTSA), thereby bypassing the intended TSA authorization pinning entirely. This vulnerability is fixed in 1.0.6.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
rfc3161-clientPyPI
< 1.0.61.0.6

Affected products

1

Patches

1
4f7d372297b4

Merge commit from fork

6 files changed · +133 12
  • CHANGELOG.md+9 0 modified
    @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
     
     ## [Unreleased]
     
    +## [1.0.6] - 2026-XX-XX
    +
    +### Fixed
    +
    +- Fixed a bug where the verification incorrectly picked the leaf certificate. This
    +  allowed an attacker who could modify a timestamp response to make a
    +  legitimately-signed timestamp from TSA-A pass verification as if it came from
    +  TSA-B.
    +
     ## [1.0.5] - 2025-09-23
     
     ### Changed
    
  • rust/src/lib.rs+21 0 modified
    @@ -427,6 +427,27 @@ impl SignerInfo {
         fn version(&self) -> pyo3::PyResult<u8> {
             Ok(self.raw.borrow_dependent().version)
         }
    +
    +    #[getter]
    +    fn issuer<'p>(&self, py: pyo3::Python<'p>) -> pyo3::PyResult<pyo3::Bound<'p, pyo3::PyAny>> {
    +        let issuer = &self.raw.borrow_dependent().issuer_and_serial_number.issuer;
    +        let py_name = crate::name::parse_name(py, issuer.unwrap_read())?;
    +        Ok(py_name)
    +    }
    +
    +    #[getter]
    +    fn serial_number<'p>(
    +        &self,
    +        py: pyo3::Python<'p>,
    +    ) -> pyo3::PyResult<pyo3::Bound<'p, pyo3::PyAny>> {
    +        let serial = self
    +            .raw
    +            .borrow_dependent()
    +            .issuer_and_serial_number
    +            .serial_number
    +            .as_bytes();
    +        crate::util::big_byte_slice_to_py_int(py, serial)
    +    }
     }
     
     #[pyo3::pyclass(frozen, module = "rfc3161_client._rust")]
    
  • src/rfc3161_client/tsp.py+10 0 modified
    @@ -224,5 +224,15 @@ class SignerInfo(metaclass=abc.ABCMeta):
         def version(self) -> int:
             """Returns the version."""
     
    +    @property
    +    @abc.abstractmethod
    +    def issuer(self) -> cryptography.x509.Name:
    +        """Returns the issuer from the SignerIdentifier (issuerAndSerialNumber)."""
    +
    +    @property
    +    @abc.abstractmethod
    +    def serial_number(self) -> int:
    +        """Returns the serial number from the SignerIdentifier (issuerAndSerialNumber)."""
    +
     
     SignerInfo.register(_rust.SignerInfo)
    
  • src/rfc3161_client/verify.py+16 9 modified
    @@ -241,25 +241,32 @@ def _verify_leaf_certs(self, tsp_response: TimeStampResponse) -> bool:
                 msg = "Certificates neither found in the answer or in the Verification Options."
                 raise VerificationError(msg)
     
    -        leaf_certificate: cryptography.x509.Certificate
    +        leaf_certificate: cryptography.x509.Certificate | None = None
     
             if len(tsp_response.signed_data.certificates) > 0:
                 certs = [
                     cryptography.x509.load_der_x509_certificate(cert)
                     for cert in tsp_response.signed_data.certificates
                 ]
     
    -            leaf_certificate_found = None
    +            # Identify the leaf certificate using the SignerInfo's
    +            # issuerAndSerialNumber (sid), which is covered by the PKCS#7
    +            # signature and cannot be tampered with.
    +            signer_infos = tsp_response.signed_data.signer_infos
    +            if len(signer_infos) != 1:
    +                msg = f"Expected exactly one SignerInfo, got {len(signer_infos)}."
    +                raise VerificationError(msg)
    +
    +            si = signer_infos.pop()
    +
                 for cert in certs:
    -                if not [c for c in certs if c.issuer == cert.subject]:
    -                    leaf_certificate_found = cert
    +                if cert.issuer == si.issuer and cert.serial_number == si.serial_number:
    +                    leaf_certificate = cert
                         break
    -            else:
    -                msg = "No leaf certificate found in the chain."
    -                raise VerificationError(msg)
     
    -            # Now leaf_certificate_found is guaranteed to be not None
    -            leaf_certificate = leaf_certificate_found
    +            if leaf_certificate is None:
    +                msg = "No leaf certificate found matching the SignerInfo."
    +                raise VerificationError(msg)
     
                 # Note: The order of comparison is important here since we mock
                 # _tsa_certificate's __ne__ method in tests, rather than leaf_certificate's
    
  • test/fixtures/test_tsa/response-injected-certs.tsr+0 0 added
  • test/test_verify.py+77 3 modified
    @@ -324,7 +324,7 @@ def test_verify_leaf_cert_no_leaf_cert(
             def mock_load_der_x509_certificate(_cert: bytes) -> cryptography.x509.Certificate:
                 return cast(
                     "cryptography.x509.Certificate",
    -                pretend.stub(issuer="fake-name", subject="fake-name"),
    +                pretend.stub(issuer="fake-name", subject="fake-name", serial_number=0),
                 )
     
             monkeypatch.setattr(
    @@ -333,11 +333,43 @@ def mock_load_der_x509_certificate(_cert: bytes) -> cryptography.x509.Certificat
                 mock_load_der_x509_certificate,
             )
     
    +        signer_info_stub = pretend.stub(
    +            issuer="non-matching-issuer",
    +            serial_number=999,
    +        )
    +        response = pretend.stub(
    +            signed_data=pretend.stub(
    +                certificates=[b"fake-cert", b"fake-cert-2"],
    +                signer_infos=[signer_info_stub],
    +            )
    +        )
    +
    +        with pytest.raises(
    +            VerificationError, match="No leaf certificate found matching the SignerInfo"
    +        ):
    +            verifier._verify_leaf_certs(tsp_response=response)  # ty: ignore[invalid-argument-type]
    +
    +    def test_verify_leaf_certs_multiple_signer_infos(
    +        self, verifier: Verifier, monkeypatch: MonkeyPatch
    +    ) -> None:
    +        verifier = cast("_Verifier", verifier)
    +
    +        monkeypatch.setattr(
    +            cryptography.x509,
    +            "load_der_x509_certificate",
    +            lambda _cert: pretend.stub(),
    +        )
    +
    +        si_a = pretend.stub(issuer="a", serial_number=1)
    +        si_b = pretend.stub(issuer="b", serial_number=2)
             response = pretend.stub(
    -            signed_data=pretend.stub(certificates=[b"fake-cert", b"fake-cert-2"])
    +            signed_data=pretend.stub(
    +                certificates=[b"fake-cert"],
    +                signer_infos=[si_a, si_b],
    +            )
             )
     
    -        with pytest.raises(VerificationError, match="No leaf certificate found in the chain."):
    +        with pytest.raises(VerificationError, match="Expected exactly one SignerInfo, got 2"):
                 verifier._verify_leaf_certs(tsp_response=response)  # ty: ignore[invalid-argument-type]
     
         def test_verify_leaf_name_mismatch(
    @@ -502,6 +534,48 @@ def test_verify_fails_invalid_tsr_signature() -> None:
             verifier.verify_message(ts_response, b"hello")
     
     
    +def test_verify_rejects_injected_cert_bag_spoofing_common_name() -> None:
    +    """Regression test for GHSA-3xxc-pwj6-jgrj: an attacker injects a spoofed
    +    certificate (CN=Spoofed TSA) and a dummy certificate into the PKCS#7
    +    certificate bag of a legitimate TSR.
    +
    +    Before the fix, the naive leaf-finding heuristic would select the spoofed
    +    certificate as the "leaf", allowing common_name pinning to be bypassed
    +    while the real signature remained valid against the authentic signer.
    +
    +    The fix identifies the leaf via SignerInfo.issuerAndSerialNumber instead.
    +    """
    +    cert_path = _FIXTURE / "test_tsa" / "ts_chain.pem"
    +    tsr_path = _FIXTURE / "test_tsa" / "response-injected-certs.tsr"
    +
    +    chain = cryptography.x509.load_pem_x509_certificates(cert_path.read_bytes())
    +    root_cert = chain[-1]
    +    intermediate_cert = chain[1]
    +
    +    ts_response = decode_timestamp_response(tsr_path.read_bytes())
    +
    +    # Verify that the spoofed cert IS in the bag (precondition)
    +    subjects = [
    +        cryptography.x509.load_der_x509_certificate(c).subject.rfc4514_string()
    +        for c in ts_response.signed_data.certificates
    +    ]
    +    assert "CN=Spoofed TSA" in subjects
    +
    +    # Attempt to verify with the spoofed common_name — must be rejected
    +    verifier = VerifierBuilder(
    +        common_name="CN=Spoofed TSA",
    +        roots=[root_cert],
    +        intermediates=[intermediate_cert],
    +    ).build()
    +
    +    digest = hashes.Hash(hashes.SHA512())
    +    digest.update(b"hello")
    +    message = digest.finalize()
    +
    +    with pytest.raises(VerificationError, match="name provided in the opts does not match"):
    +        verifier.verify(ts_response, message)
    +
    +
     def test_verify_succeeds_even_if_cert_is_currently_expired() -> None:
         """Ensure that a timestamp is considered valid even if it is expired
         at verification time (as long as the full certificate
    

Vulnerability mechanics

Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

5

News mentions

0

No linked articles in our index yet.