# Root Cause Analysis — CVE-2026-17510

## Summary
Crypt::OpenSSL::PKCS12 before 1.98 contains a NULL pointer dereference in the
`V_ASN1_BMPSTRING` branch of `print_attribute()` (PKCS12.xs). When
`info_as_hash()` parses a crafted PKCS#12 file containing a zero-length
BMPSTRING bag attribute, `Renew(*attribute, 0, char)` (Perl's
`safesysrealloc`) frees the destination buffer and returns `NULL`; the `NULL`
is stored back into `*attribute`, and the downstream caller runs
`newSVpvn(attribute_value, strlen(attribute_value))`, dereferencing `NULL`
inside `strlen()` and causing a deterministic SIGSEGV (exit status 139).

## Impact
- Package: `Crypt-OpenSSL-PKCS12` (CPAN), XS binding to OpenSSL's PKCS12 API.
- Affected versions: all versions before 1.98 (vulnerable code confirmed at
  commit `5934ce7fe7c4683c8d9a08edcbd0c6871a52945f`, the parent of the fix).
- Risk: medium — remote/unauthenticated denial of service of any Perl process
  that calls `info_as_hash()` on attacker-supplied PKCS#12 data. The `info()`
  path is unaffected (it uses the `BIO_printf` branch and never calls `Renew`).

## Impact Parity
- Disclosed/claimed maximum impact: denial of service (process crash via NULL
  dereference). The advisory explicitly scopes impact to DoS; no memory
  disclosure or code execution is claimed.
- Reproduced impact from this run: deterministic SIGSEGV (exit 139, core
  dumped) in the real library function `info_as_hash()` on the vulnerable
  build, twice in a row; fixed build returns the attribute as the empty
  string and completes normally, twice in a row.
- Parity: **full** — the claimed DoS impact was demonstrated exactly.
- Not demonstrated: nothing beyond the claim; no code execution was claimed
  or attempted.

## Root Cause
In `print_attribute()` (pre-fix PKCS12.xs, line ~666):

```c
value = OPENSSL_uni2asc(av->value.bmpstring->data, length);
if (*attribute != NULL) {
  Renew(*attribute, length, char);        /* length == ASN.1 byte length */
  strncpy(*attribute, value, length);
}
```

For a normal BMPSTRING this is benign because `OPENSSL_uni2asc()` returns a
NUL-terminated ASCII string and `strncpy` zero-pads the oversized buffer. For
an **empty** BMPSTRING (`length == 0`) it degenerates: Perl's
`safesysrealloc` treats a zero size as free-and-return-NULL, so
`Renew(*attribute, 0, char)` frees the buffer, `*attribute` becomes `NULL`,
`strncpy(NULL, value, 0)` writes nothing, and the downstream
`dump_certs_pkeys_bag` / `print_attribs` code calls
`newSVpvn(attribute_value, strlen(attribute_value))` on the `NULL` pointer —
a deterministic NULL dereference in `strlen()`. Only `info_as_hash()` reaches
this branch because it passes a non-NULL hash, making `*attribute` non-NULL.

Fix commit: https://github.com/dsully/perl-crypt-openssl-pkcs12/commit/6cb282d8d8e8ded4859551cd2d3cfa7c6028ce48
The fix sizes the buffer with `strlen(value) + 1` (never zero), copies with
`memcpy`, writes an explicit terminator, and adds a NULL check on the
`OPENSSL_uni2asc()` return value.

## Reproduction Steps
1. `bundle/repro/reproduction_steps.sh` (self-contained; run from anywhere,
   honors `PRUVA_ROOT`).
2. The script:
   - Clones `dsully/perl-crypt-openssl-pkcs12` (uses the prepared project
     cache mirror when available, GitHub otherwise).
   - Resolves `VULN_COMMIT = 6cb282d...^` = `5934ce7fe7c4...` and
     `FIXED_COMMIT = 6cb282d8d8e8ded4859551cd2d3cfa7c6028ce48`, and verifies
     the vulnerable tree contains the pre-fix `Renew(*attribute, length,
     char)` hunk while the fixed tree contains the `strlen(value) + 1` fix.
   - Installs the pure-Perl configure dependency `Crypt::OpenSSL::Guess` into
     a bundle-local `INSTALL_BASE` if missing.
   - Builds the XS module from both commits with `perl Makefile.PL && make`.
   - Extracts the crafted fixture `certs/bmpstring-empty.p12` from the fixed
     commit (it ships there as the regression-test fixture: a certBag whose
     bag attribute at OID `1.2.3.4.6` is a zero-length ASN.1 BMPSTRING,
     password `Password1`, SHA-256 MAC) and validates it with
     `openssl pkcs12 -info`.
   - Runs `Crypt::OpenSSL::PKCS12->new_from_file(...)->info_as_hash('Password1')`
     twice against the vulnerable build and twice against the fixed build.
3. Expected evidence: both vulnerable attempts die with SIGSEGV (exit 139,
   core dumped, `INFO_AS_HASH_RETURNED` never printed); both fixed attempts
   print `attribute 1.2.3.4.6 value=<>` and `INFO_AS_HASH_RETURNED` with
   exit 0.

## Evidence
- `bundle/logs/reproduction_steps.log` — full script transcript, including:
  - `[*] vuln-attempt-1 exit=139` / `Segmentation fault (core dumped)` and
    `timeout: the monitored command dumped core`
  - `[*] vuln-attempt-2 exit=139`
  - `[*] fixed-attempt-1 exit=0` → `attribute 1.2.3.4.6 value=<>`,
    `INFO_AS_HASH_RETURNED`
  - `[*] fixed-attempt-2 exit=0` → same
- `bundle/logs/vuln-attempt-{1,2}.log`, `bundle/logs/fixed-attempt-{1,2}.log`
  — per-attempt output.
- `bundle/logs/build-vuln.log`, `bundle/logs/build-fixed.log` — build logs.
- `bundle/repro/runtime_manifest.json` — machine-readable runtime evidence
  (`entrypoint_kind=function_call`, `target_path_reached=true`, commit and
  digest identity).
- Environment: Perl 5.38.2 (x86_64-linux-gnu-thread-multi), OpenSSL 3.0.13
  (module linked against system libssl/libcrypto), gcc, Ubuntu noble.
- A `Data::Dumper` dump of `info_as_hash()` on the fixed build confirms
  `'bag_attributes' => { '1.2.3.4.6' => '' }` — the empty-string return the
  advisory predicts for the patched version.

## Recommendations / Next Steps
- Upgrade to Crypt-OpenSSL-PKCS12 1.98 or later.
- The upstream fix (size on `strlen(value) + 1`, explicit terminator, NULL
  check on `OPENSSL_uni2asc()`) is correct and verified by this run.
- Services accepting untrusted PKCS#12 uploads should not call
  `info_as_hash()` on unpatched versions; sandboxing the parse in a
  disposable process limits DoS blast radius.

## Additional Notes
- Idempotency: the script was executed twice consecutively; both runs exited
  0 with identical verdicts. Re-runs reuse the local mirror and rebuild both
  worktrees from scratch (`rm -rf` + fresh `git worktree add`).
- No sanitizer was used; the crash is a product-visible native SIGSEGV from
  the real XS library (`sanitizer_used=false`).
- The crafted fixture is not synthesized by this run: it is the exact
  regression fixture `certs/bmpstring-empty.p12` shipped in the upstream fix
  commit, so the attacker input is byte-identical to what upstream used to
  prove the bug.
- The claim surface is `library_api` / `function_call`; the proof invokes the
  real published library entry points (`new_from_file`, `info_as_hash`)
  through the module's own compiled XS code, matching the claim contract.
