OpenVPN / OpenVPN/openvpn

Spurious "CRL: cannot read CRL from file" on every CRL reload when the OpenSSL error queue is not empty

Open
#1,103 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
C
Stars
14.6k
Forks
3.4k
PR merge metrics
No merged PRs in 30d

Description

Summary

backend_tls_ctx_reload_crl() (src/openvpn/ssl_openssl.c) decides whether the end of the CRL file was reached by inspecting ERR_peek_error(). ERR_peek_error() returns the oldest error on the thread's OpenSSL error queue, and the function never clears the queue before reading. If any earlier operation in the same handshake left an error queued, a clean EOF after the last CRL is misclassified as a read failure: a M_WARN "CRL: cannot read CRL from file" is logged, the queued (unrelated) OpenSSL errors are printed with it, and the CRLs that were already parsed are then installed anyway ("CRL: loaded 1 CRLs from file"). The warning is a false positive; enforcement is unaffected.

Version / environment

  • OpenVPN 2.7.0 aarch64-unknown-linux-gnu [SSL (OpenSSL)] [LZO] [LZ4] [EPOLL] [PKCS11] [MH/PKTINFO] [AEAD] [DCO] (Ubuntu 26.04 package openvpn 2.7.0-1ubuntu1.2)
  • OpenSSL 3.5.5 (27 Jan 2026)
  • Linux 7.0.0-1012-aws, ovpn-dco in use
  • Server mode, tls-server, tls-crypt-v2 <key> force-cookie, tls-version-min 1.3, remote-cert-tls client, crl-verify crl.pem (file, one PEM CRL), verb 3
  • Code: release/2.7 src/openvpn/ssl_openssl.c, backend_tls_ctx_reload_crl() at line 1395, the EOF test at line 1441; identical in master (line 1325 / 1371) as of 2026-09-10.

Steps to reproduce

  1. Run a server with crl-verify <file> where <file> is a valid PEM CRL signed by the configured CA (one CRL is enough; the same happens with two concatenated).
  2. Let a client connect (any client; the queued error in my case appears during normal handshake processing under OpenSSL 3.5).
  3. Replace the CRL file with a fresh copy so its mtime or size changes (e.g. the same content re-downloaded and atomically renamed into place).
  4. Have a client (re)connect.

Expected

The reload logs only:

CRL: loaded 1 CRLs from file /etc/openvpn/server/crl.pem

Actual

On the first handshake after every file change (3 of 3 attempts; never on handshakes without a file change; never at daemon start, where the queue is empty):

udp4:10.241.0.50:41567 OpenSSL: error:0308010C:digital envelope routines::unsupported:Global default library context, Algorithm (none : 0), Properties (<null>)
udp4:10.241.0.50:41567 OpenSSL: error:0480006C:PEM routines::no start line:
udp4:10.241.0.50:41567 CRL: cannot read CRL from file /etc/openvpn/server/crl.pem
udp4:10.241.0.50:41567 CRL: loaded 1 CRLs from file /etc/openvpn/server/crl.pem

The first OpenSSL: line is a stale error from earlier in the same handshake and has nothing to do with the CRL. The second is the genuine EOF marker from PEM_read_bio_X509_CRL(). The CRL was loaded (count 1) and a revoked client presenting a serial listed in that CRL was correctly rejected immediately afterwards (VERIFY ERROR: depth=0, error=certificate revoked), so the behaviour is cosmetic. It is misleading for operators and for anyone alerting on the "cannot read CRL" string, which is the natural thing to alarm on for a fail-closed crl-verify deployment.

Analysis

    int num_crls_loaded = 0;
    while (true)
    {
        X509_CRL *crl = PEM_read_bio_X509_CRL(in, NULL, NULL, NULL);
        if (crl == NULL)
        {
            /*
             * PEM_R_NO_START_LINE can be considered equivalent to EOF.
             */
            bool eof = ERR_GET_REASON(ERR_peek_error()) == PEM_R_NO_START_LINE;
            /* but warn if no CRLs have been loaded */
            if (num_crls_loaded > 0 && eof)
            {
                /* remove that error from error stack */
                (void)ERR_get_error();
                break;
            }

            crypto_msg(M_WARN, "CRL: cannot read CRL from file %s",
                       print_key_filename(crl_file, crl_inline));
            break;
        }
        ...

ERR_peek_error() peeks the earliest entry in the queue. When the queue already contains an unrelated error, eof is false even though PEM_read_bio_X509_CRL() pushed PEM_R_NO_START_LINE as the newest entry, so the num_crls_loaded > 0 && eof branch is skipped and the warning fires. crypto_msg() then prints and drains the whole queue, which is why the unrelated error appears in the log above the warning. The (void)ERR_get_error() on the EOF path also only pops one entry, so it would remove the stale error and leave the PEM error queued in the reverse situation.

Fix and validation

Patch (PR to follow, and to openvpn-devel per CONTRIBUTING): ERR_clear_error() before the read loop so only errors raised by PEM_read_bio_X509_CRL() are visible, test ERR_peek_last_error() instead of the oldest entry, and clear the queue on the EOF path instead of popping one entry (branch crl-reload-error-queue, one commit on top of master 28ec0f90; clang-format --dry-run -Werror clean).

Validated on the same host with two builds of master 28ec0f90 (aarch64, OpenSSL 3.5.5, DCO), each run as a server with crl-verify <file>; a client connected, the CRL file was atomically replaced with an identical copy (new mtime) three times with a client re-handshake after each, then replaced once with a file containing no CRL:

build reloads warning "cannot read CRL" loaded garbage file
master unpatched 3 of 3, each preceded by the stale 0308010C digital envelope routines::unsupported line and PEM no start line loaded 1 CRLs every time warns, loaded 0 CRLs, VERIFY ERROR: CRL not loaded
master + patch 0 of 3 loaded 1 CRLs every time warns, loaded 0 CRLs, VERIFY ERROR: CRL not loaded; the only OpenSSL line printed is now PEM routines::no start line:Expecting: X509 CRL

So the fix removes the false positive, keeps the real warning for an unreadable file, and stops attributing unrelated queued errors to the CRL.

Suggested fix

Clear the queue before parsing so the peek only ever sees errors produced by this function, or peek the last error instead of the first:

    int num_crls_loaded = 0;
    ERR_clear_error();
    while (true)
    {
        X509_CRL *crl = PEM_read_bio_X509_CRL(in, NULL, NULL, NULL);
        if (crl == NULL)
        {
            bool eof = ERR_GET_REASON(ERR_peek_last_error()) == PEM_R_NO_START_LINE;
            if (num_crls_loaded > 0 && eof)
            {
                ERR_clear_error();
                break;
            }
            crypto_msg(M_WARN, "CRL: cannot read CRL from file %s",
                       print_key_filename(crl_file, crl_inline));
            break;
        }

ERR_clear_error() at the top also keeps stale errors from an earlier operation out of the warning's output, which currently attributes them to the CRL file.

Happy to test a patch on the same setup.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start in src/openvpn/ssl_openssl.c at backend_tls_ctx_reload_crl() and review the OpenSSL error-queue handling around the CRL read loop. Reproduce the reload scenario with a valid CRL and an unrelated queued error, then verify that identical reloads produce no false warning while an unreadable CRL still warns and is not loaded.

Written by the indexing model from the issue text.

Assessment

Tech stack
c
Domain
security
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
70/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.