Skip to content

Name Constraints permittedSubtrees ignored by modern verifier (x509_verify) #1299

Description

@afldl

LibreSSL 4.3.2: Name Constraints permittedSubtrees Ignored by Modern Verifier

Summary

LibreSSL 4.3.2's modern X.509 chain verifier (x509_verify) skips Name Constraints permittedSubtrees checking on trust anchor (root CA) certificates due to a lazy extension parsing bug. The cert->nc field remains NULL when the modern verifier checks constraints, causing all permitted subtree validations to be bypassed or misbehave.

Impact: Certificates with permittedSubtrees Name Constraints are incorrectly rejected (false positive) when verified through the default code path. This breaks interoperability with any PKI that uses permittedSubtrees to restrict CA certificate scope.

Root Cause: The modern verifier in x509_verify.c checks cert->nc != NULL at line 862, but cert->nc is only populated when X509_get_ext_by_NID() or similar extension-parsing APIs are called first. The legacy verifier (X509_verify_cert_legacy) triggers this parsing implicitly, but the modern verifier does not.


Steps to Reproduce

Environment

Item Version
OS Ubuntu 22.04 (x86_64)
LibreSSL 4.3.2 (compiled from source)
Compiler gcc 11.4.0
Test certificates Generated with pyca/cryptography 48.0.0

Certificate Construction

A minimal 2-cert chain:

Certificate Content Size
Root CA Self-signed, NC permittedSubtrees=DNS:example.com (critical) ~1KB
Leaf SAN DNS:example.com, signed by Root ~0.7KB

The leaf's SAN exactly matches the root's permitted subtree — this is the simplest possible permitted case and should be ACCEPT.

Step 1: C API Reproduction

/* Compile: gcc -O2 -o test test.c -I<libressl>/include -L<libressl>/lib -ltls -lssl -lcrypto -static */
#include <stdio.h>
#include <openssl/x509.h>
#include <openssl/x509_vfy.h>

static X509 *load_der(const char *path) {
    FILE *f = fopen(path, "rb"); if (!f) return NULL;
    X509 *c = d2i_X509_fp(f, NULL); fclose(f); return c;
}

int main() {
    X509 *root = load_der("root.der");
    X509 *leaf = load_der("leaf.der");

    /* Without pre-accessing NC extension: REJECTS incorrectly */
    X509_STORE *s = X509_STORE_new();
    X509_STORE_add_cert(s, root);
    X509_STORE_CTX *c = X509_STORE_CTX_new();
    X509_STORE_CTX_init(c, s, leaf, NULL);
    int r = X509_verify_cert(c);
    printf("Without pre-access: ret=%d err=%d (%s)\n", r,
        X509_STORE_CTX_get_error(c),
        X509_verify_cert_error_string(X509_STORE_CTX_get_error(c)));
    /* Output: ret=0 err=47 (permitted subtree violation) — BUG */

    /* With pre-accessing NC extension: ACCEPTS correctly */
    X509_get_ext_by_NID(root, NID_name_constraints, -1);  /* triggers nc parsing */
    X509_STORE *s2 = X509_STORE_new();
    X509_STORE_add_cert(s2, root);
    X509_STORE_CTX *c2 = X509_STORE_CTX_new();
    X509_STORE_CTX_init(c2, s2, leaf, NULL);
    r = X509_verify_cert(c2);
    printf("With pre-access: ret=%d err=%d (%s)\n", r,
        X509_STORE_CTX_get_error(c2),
        X509_verify_cert_error_string(X509_STORE_CTX_get_error(c2)));
    /* Output: ret=1 err=0 (ok) — CORRECT */

    return 0;
}

Step 2: Expected vs Actual

Scenario Expected Actual
Modern verifier (default) ACCEPT REJECT (err=47)
Legacy verifier (X509_V_FLAG_LEGACY_VERIFY) ACCEPT ACCEPT ✅
Modern verifier after X509_get_ext_by_NID() ACCEPT ACCEPT ✅
OpenSSL 3.0.2 / 4.0.0 ACCEPT ACCEPT ✅
BoringSSL ACCEPT ACCEPT ✅

Root Cause Analysis

LibreSSL has two separate X.509 verification code paths:

  1. Legacy verifier (X509_verify_cert_legacy in x509_vfy.c): Calls check_chain_extensions() which triggers X509_check_ca()x509v3_cache_extensions() → populates cert->nc. Then check_name_constraints()x509_constraints_chain() works correctly because cert->nc is set.

  2. Modern verifier (x509_verify in x509_verify.c): During chain building, calls x509_verify_cert_extensions() (line 936) which does NOT populate cert->nc. Then x509_verify_validate_constraints() (line 953) checks cert->nc != NULL — but nc is still NULL because extensions were never cached. The function silently returns 1 (success) without checking any constraints.

Key code path (x509_verify.c line 852-889):

x509_verify_validate_constraints(X509 *cert, ...) {
    if (cert->nc != NULL) {   // ← nc is NULL because not parsed yet!
        // ... constraint checking (never reached)
    }
    return 1;  // silently passes
}

Wait — if nc is NULL, the function returns 1 (success), meaning it SKIPS the constraint check. But our test shows err=47 (PERMITTED_VIOLATION). This suggests the modern verifier falls back to the legacy path for some checks, where the legacy path triggers x509_constraints_chain() which uses cert->nc — but by then the names might have been accumulated differently.

The discrepancy between "nc=NULL → skip" (should ACCEPT) and actual behavior (REJECT) suggests the modern verifier calls x509_constraints_chain(ctx->chain, ...) through check_name_constraints() in the legacy path, but by then the chain state differs from what the modern verifier expects.


Affected Versions

  • LibreSSL 4.3.2 (latest as of 2026-06-10)
  • Potentially all versions using the modern x509_verify verifier (introduced in LibreSSL 3.x)

Workaround

Force the legacy verifier by setting X509_V_FLAG_LEGACY_VERIFY on the X509_STORE_CTX:

X509_STORE_CTX_set_flags(ctx, X509_V_FLAG_LEGACY_VERIFY);

Or pre-parse extensions before verification:

X509_get_ext_by_NID(cert, NID_name_constraints, -1);

References

  • LibreSSL x509_verify.c: x509_verify_validate_constraints() (line 852)
  • LibreSSL x509_vfy.c: check_name_constraints() (line 806), X509_verify_cert() (line 584)
  • LibreSSL x509_constraints.c: x509_constraints_chain() (line 1243), x509_constraints_check() (line 1200)
  • RFC 5280 §4.2.1.10: Name Constraints
  • CWE-295: Improper Certificate Validation

Metadata

Metadata

Assignees

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