Vulnerability Research
OpenVPN CVE-2026-13122: How a Short Token Entered Session Memory and Stopped the VPN Server
CVE-2026-13122 affects OpenVPN 2.6.0–2.6.20 and 2.7 alpha1–2.7.4 servers using external-auth : a short credential beginning with SESS_ID_AT_ can fail cryptographic verification, still be retained as the initial session token, and then drive fixed-offset reads and writes beyond its allocation before a fatal assertion terminates the daemon.

In this article
1 A ticket stub entered the session archive as if it were the whole ticket
The password arriving at the server can be only eleven visible characters long: SESS_ID_AT_. It carries no session identifier, no creation or renewal time, and no SHA-256 HMAC made with the server secret. OpenVPN's token verifier sees an empty payload and rejects it. In external-auth mode, the authentication flow quite properly continues toward a script, plugin, or management client because an external identity system may still have a valid reason to accept the account. The decisive operation comes immediately after verification: the failed input is copied with strdup() into tls_multi.auth_token_initial. A candidate credential supplied by a client has become the server's durable record of where the session began.
Later functions never ask how many characters the copied object actually owns. Environment export needs a stable session ID, so it copies sixteen characters starting after the prefix. Token renewal needs the original ID and creation time, so it advances another sixteen characters, writes a temporary terminator twelve characters farther on, and attempts to decode those windows. The prefix-only copy occupies twelve bytes including its NUL. The environment read begins at its final legal byte and continues fifteen bytes beyond it. The renewal code forms a pointer at offset 27 and writes a NUL at offset 39. A Base64 length assertion eventually fails and assert_failed() ends the daemon with _exit(1).
The public record correctly describes a reachable assertion and a remote denial of service. Reading the source in execution order reveals a detail that matters for reproduction and forensics: the assertion is not the first invalid memory operation. A fixed-width out-of-bounds read and a fixed-value out-of-bounds write occur earlier on the same poisoned object. An AddressSanitizer build may therefore stop in add_session_token_env() or at the timestamp terminator; an ordinary release log may show only the later assertion. Those accesses are source-proven stages of the published flaw. Public evidence does not establish controlled memory corruption or code execution, so the confirmed impact remains daemon availability.
The path took years to assemble. In 2019 OpenVPN redesigned authentication tokens as stateless HMAC records and added a permanent session ID for external-auth. That design let an identity service recognize one login through renegotiations and reconnects. In 2021 a cleanup moved initial-token retention earlier, saving the first token observed from a client so later code could use one common source. The classifier at that point still meant only “starts with the token prefix,” while the new assignment read it as “is a verified token.” The ordinary authentication path erased failed tokens quickly. The external-auth continuation preserved the contradiction long enough for fixed-offset consumers to encounter it.
Three separate questions run through the code. Shape asks whether a string has the complete external form required for safe parsing. Authenticity asks whether its HMAC, timestamps, username binding, and session continuity are valid. Policy asks whether an account or device should be allowed now. OpenVPN intentionally keeps policy independent so an expired token can be followed by MFA, a directory check, or another acceptable factor. CVE-2026-13122 allowed the weakest answer—an eleven-byte prefix match—to authorize a write whose consumers depended on the strongest answer. The fix preserves all three decisions while ensuring that only a full, server-authenticated record can become session history.
The smallest accurate root-cause sentence is consequently very specific: after HMAC verification failed, a client-controlled string was promoted into the trusted initial-token field, and readers that require a complete record lost both their shape and provenance preconditions. “A Base64 assertion crashes OpenVPN” describes the final symptom but omits the promotion that made it possible. Keeping that sentence precise also keeps the repair precise: restore the record's exact shape at classification, then require cryptographic provenance at the durable write.
1.1 key_method_2_read() lifts the username and password from the TLS control channel
Remote data first reaches the relevant application layer in src/openvpn/ssl.c. After a TLS control session is established, key_method_2_read() consumes the key-method message. It skips the four-byte header, processes protocol flags, peer key material and option-consistency data, allocates a temporary struct user_pass, and calls read_string() for the username and password in wire order. Peer information follows. Length failures produce a negative result and move authentication toward AUTH_FAILED. The triggering credential is therefore an ordinary, NUL-terminated protocol string within USER_PASS_LEN, not a raw packet that overruns the initial wire parser.
At the start of this decision, skip_auth is false and the key state has not inherited a successful token result. The reserved-token route is selected only when server token generation is enabled and the password satisfies is_auth_token(). That baseline matters: the trigger needs no overflow in read_string(), no corrupted username, and no pre-set authentication flag. It enters through an ordinary credential field, then changes meaning as later code interprets the classifier's narrow answer.
When username/password authentication is enabled and the fields satisfy the basic presence checks, the function calls verify_user_pass(up, multi, session). On return, secure_memzero() wipes the temporary object. That return gives the original network credential a short and understandable lifetime. The bug carries it past that lifetime deliberately: verify_user_pass() duplicates the password into the connection-wide tls_multi object. The copy has valid C ownership and is later freed correctly; its type is wrong. A short, rejected candidate now occupies a field whose name, comments, and readers all assume a full initial authentication token.
Before choosing the token route, verify_user_pass() normalizes the username with string_mod_remap_name() and constrains the password to printable characters with string_mod(). Every character in SESS_ID_AT_ survives unchanged. The token branch requires both auth_token_generate in server options and a true result from is_auth_token(up->password). In the vulnerable tree, that helper compares only the constant prefix. The protocol parser, character filtering, and prefix comparison all behave locally as implemented. The failure appears when their narrow guarantees are promoted into a durable fixed-layout object.
Reachability begins after the control-channel protections required by a particular deployment. A server that requires client certificates generally needs a trusted certificate; tls-auth, tls-crypt, or tls-crypt-v2 adds the corresponding control-channel material; deployments with optional client certificates and external account decisions present another set of prerequisites. “Network reachable” in the CVE record identifies the application entry point. It does not mean that an unauthenticated UDP datagram containing the prefix is sufficient. A faithful reproduction must complete the same TLS and key-method path as a real client, then submit the credential in the protected password field.
Once external processing begins, immediate success and supported deferred results both flow toward the common accepted-or-pending block. A management decision can set KS_AUTH_DEFERRED, yet token preparation still occurs before the final reply arrives. The password has therefore crossed three distinct moments—internal rejection, provisional policy continuation, and generator use—that a log line labelled simply “authentication pending” cannot reconstruct. Capturing each moment is essential when the daemon exits before the backend records its final answer.
1.2 auth-gen-token lets one login survive later renegotiations
--auth-gen-token addresses a practical conflict between long VPN sessions and one-time credentials. After a successful password, PAM, RADIUS, plugin, or other external login, the server issues a temporary token to the client. On a later TLS renegotiation, the client places the token in the password field. OpenVPN verifies the HMAC and time windows locally, avoiding another submission of the original password or OTP. Active clients receive renewed tokens; a client that stays away too long or exceeds the configured lifetime must authenticate again. The mechanism reduces identity-provider traffic without turning a temporary login into an unlimited credential.
Default token mode treats internal verification as the final answer. A complete, unexpired token may skip the external provider; an invalid token is wiped and rejected. The optional grammar auth-gen-token [lifetime] [renewal-time] [external-auth] moves the policy decision. Option parsing sets options->auth_token_call_auth, and initialization copies it into the TLS options. The normal authentication methods then run even after token verification. Organizations use this mode when account disablement, device posture, tenant membership, risk scoring, or a fresh MFA decision can change while the token is still cryptographically sound.
That option is not consulted only by a command-line parser and then forgotten. Its auth_token_call_auth value is carried from the global options object into the per-TLS configuration that verify_user_pass() actually reads. Included configuration, a service wrapper, or an appliance-generated argument can therefore change the live state machine without altering the visible authentication plugin. Exposure review must follow the value into the running TLS options, because the same backend behaves differently when this one bit changes.
OpenVPN exports context so the external handler can distinguish those cases. session_state can represent Initial, Authenticated, Expired, Invalid, and compatibility variants for clients that omit the username. session_id gives the backend a stable login identifier. The manual expects an external handler to interpret Invalid and Expired carefully: a client may continue only when some other accepted authentication path succeeds. The handler does not need to decode OpenVPN's private binary format. OpenVPN remains responsible for producing a structurally safe session ID and for deciding which token may seed the connection.
This division is also an interface-compatibility promise. PAM or RADIUS adapters, plugins, scripts, and management clients consume session_state and session_id; they are not expected to parse the private Base64 record or defend its internal offsets. Requiring exact shape and HMAC provenance inside OpenVPN leaves those interfaces unchanged. A correct backport should therefore keep existing policy outcomes while making the exported identifier come from a complete local or verified token.
The permanent session ID is what makes the arrangement useful. A renewed token keeps the original twelve-byte ID and initial timestamp while changing the renewal timestamp and HMAC. The external service can correlate a first login, several key renegotiations, a brief reconnect, and even migration across servers that share auth-gen-token-secret. auth_token_initial is the archive from which those stable fields are restored. Its long lifetime and broad readership make its admission rule more important than the admission rule for a temporary password. Once poisoned, the object can cross several TLS sessions and asynchronous authentication stages.
The field carrying the password can therefore hold three different kinds of material during normal operation: an initial account password, a valid server token, or a real but expired token that needs a policy decision. Shape, authenticity, and policy must remain separate. An external script returning success can authorize an account; it cannot manufacture the missing session ID, timestamps, and HMAC inside an eleven-character string. The vulnerability allowed policy continuation to carry a structurally incomplete internal record into renewal. The repair leaves external policy power intact and restores ownership of token structure to OpenVPN.
1.3 Sixty binary bytes become an exact ninety-one-character credential
The token layout is defined next to its implementation in the fixed tree. AUTH_TOKEN_SESSION_ID_LEN is twelve bytes. Two int64_t values store the initial and renewal timestamps. AUTH_TOKEN_HMAC_LEN is the thirty-two-byte SHA-256 digest length. Together these fields form the sixty-byte TOKEN_DATA_LEN. A static assertion requires the payload length to divide evenly by three, so Base64 produces exactly eighty characters without padding. Adding the eleven-character SESSION_ID_PREFIX yields TOTAL_SESSION_TOKEN_LEN == 91.
The session ID has its own useful landmark. Twelve binary bytes encode to sixteen Base64 characters, which allows add_session_token_env() to copy the first sixteen characters after the prefix without decoding the whole record. The timestamp's position is less visually obvious because it crosses Base64 groups. generate_auth_token() takes a twelve-character window beginning after the session-ID text, decodes nine bytes, and uses the first eight as the initial timestamp. These offsets are character coordinates in the external string; twelve, eight, eight, and thirty-two are binary field widths. Mixing the two coordinate systems produces misleading memory calculations.
The HMAC covers the username, session ID, initial timestamp, and renewal timestamp. Verification first uses the supplied username. For compatibility with historical OpenVPN 3 behavior, it can try an empty username and set AUTH_TOKEN_VALID_EMPTYUSER if that form succeeds. Only a successful constant-time HMAC comparison grants AUTH_TOKEN_HMAC_OK. Time checks follow: the renewal window must be acceptable, the initial time cannot follow the renewal time, and an optional total lifetime must not be exceeded. A genuine but expired record keeps HMAC OK and gains AUTH_TOKEN_EXPIRED, preserving the distinction between provenance and current policy.
The compatibility retry has an observable consequence beyond a second comparison. If the supplied username fails but the empty-string HMAC succeeds, the verifier sets both HMAC OK and AUTH_TOKEN_VALID_EMPTYUSER, then clears the current username so later policy sees the identity that was actually authenticated. Only failure of both HMAC forms returns zero. That ordering is why storage must test the capability bit: compatibility and expiry bits may accompany valid provenance without weakening it.
Time validation is similarly layered. The current time must fall from the renewal timestamp up to, but not including, twice the configured renewal interval beyond it; the renewal timestamp cannot precede the initial timestamp; and an optional total lifetime is measured from that initial value. A token can therefore be cryptographically genuine and still be marked expired. Keeping HMAC OK alongside EXPIRED lets external policy request fresh proof while preserving the login's original session identity.
The prefix-only sample fails before any binary field is consumed. verify_auth_token() Base64-decodes the substring after the prefix and requires a decoded length of sixty. An empty region produces the wrong size, emits the relevant warning, and returns zero flags. It does not accept the credential, forge a timestamp, or accidentally set HMAC OK. The vulnerability sits in its caller, which received an unambiguous rejection and nevertheless saved the string. This is why the final fix did not need a new cryptographic construction. It needed the existing result to control a state transition.
The ninety-one-character contract can be used as a map for every producer and consumer. A reader at external offset 11, 27, or 39 needs proof that the object owns the complete layout. A writer to auth_token_initial also needs proof that the bytes were generated by the server or authenticated with its secret. Exact length provides safe shape; HMAC OK provides provenance. Neither replaces the other. A 91-character attacker string is safe to parse yet untrusted, while an eleven-character prefix is neither complete nor authentic.
The compile-time arithmetic is deliberate future-proofing. Sixty payload bytes divide by three, so the encoded form has no = padding; changing an identifier, timestamp, or digest width can change both the encoded length and every character window. The static assertions force that format change to confront its consumers during compilation. Ninety-one is thus a derived protocol contract, not a magic number to scatter through vendor patches, and any future format extension must re-audit every stored offset.
2 Three decisions returned three different answers
OpenVPN does not ask one function to settle every authentication question. is_auth_token() routes strings that have token syntax. verify_auth_token() evaluates cryptographic and temporal state. A script, plugin, or management client evaluates account policy. This separation creates precise behavior for valid, expired, and invalid tokens and lets a real-time directory coexist with a stateless credential. The flaw arose because the caller used the weakest routing answer as permission to populate the strongest long-lived state.
2.1 The old is_auth_token() compared only eleven prefix bytes
In the affected tree the classifier is an inline helper in auth_token.h. It calls a constant-time comparison for strlen(SESSION_ID_PREFIX) bytes and returns true when the password begins with SESS_ID_AT_. It does not inspect total length, Base64 alphabet, decoded size, timestamps, session continuity, or HMAC. A prefix by itself, a prefix plus one printable character, and a prefix followed by arbitrary short text all receive the same true answer as a server-issued token. The comment accurately describes a prefix test; the broad function name invites a stronger reading.
Constant-time comparison protects the timing characteristics of those eleven bytes. It cannot create structural validation. When the result is used only to select verify_auth_token(), the weak contract is tolerable because the verifier performs complete decoding and authentication. The 2021 assignment introduced a second use: a true result eventually made the original password eligible for auth_token_initial. That reader required far more than the helper promised. A locally safe comparison thus became the first gate in a cross-function memory-safety failure.
The shortest accepted input makes the distinction clear. A password array containing eleven prefix bytes and a terminating NUL lets memcmp_constant_time() read exactly its legal eleven-byte window. Static analysis focused on this helper sees no out-of-bounds access. The true return then allows strdup() to create a perfectly valid twelve-byte object. Only later, when readers rely on the field's semantic type, do accesses cross the allocation. The taint travels through a boolean classification and a durable state assignment, not through an immediately unsafe copy length.
A useful review technique is to write the helper's postcondition beside each caller's precondition. The old postcondition was “the first eleven bytes match a constant.” The verifier needed only that much because it checked decoded length itself. Fixed-offset consumers needed a complete ninety-one-character record, and long-lived storage needed authenticated provenance as well. The gap becomes obvious on paper even though each function appears reasonable alone. This technique generalizes to magic-byte classifiers, file signatures, protocol version tags, and object type markers throughout security code.
The fixed helper moves into auth_token.c, first requires strlen(password) == TOTAL_SESSION_TOKEN_LEN, and then performs the prefix comparison. A correctly sized value with a false HMAC still classifies as token-shaped; the verifier owns authenticity. Strict equality matters. The protocol emits a fixed record, trailing data has no defined purpose, and “at least 91” would let different Base64 consumers disagree. Upstream tests reject both ninety and ninety-two characters, locking the format's lower and upper limits.
2.2 verify_auth_token() had already delivered a clear rejection
The verifier starts immediately after the prefix and decodes into a buffer sized for the credential limits. Base64 cannot expand beyond its input, so the destination is adequate. The next condition requires decoded_len == TOKEN_DATA_LEN. A prefix-only password supplies an empty encoded region, produces the wrong size, logs ERROR: --auth-token wrong size, and returns zero. No timestamp is trusted, no HMAC is compared, and no session-ID continuity check is performed. The function fails early and explicitly.
The decoder's destination is the full USER_PASS_LEN workspace, while Base64 decoding produces fewer bytes than the text it consumes. The overflow is consequently not a decoder writing past that local array. The safety stop is the exact decoded-length comparison before timestamp or HMAC offsets are used. Keeping the destination and source violations separate prevents the first legal buffer in the stack from being blamed for the later poisoned heap object.
Its return value is a bit set, not a generic success boolean. AUTH_TOKEN_HMAC_OK proves that the record came from a holder of the server secret and was not modified. AUTH_TOKEN_EXPIRED marks a genuine record outside the acceptable time window. AUTH_TOKEN_VALID_EMPTYUSER records the compatibility verification with an empty username. A caller can require a currently usable token or ask only whether provenance is established. The vulnerable initial-state assignment read none of these bits, discarding the verifier's most important output.
For a full sixty-byte payload, the function copies timestamps into aligned local integers and converts network byte order. It calculates the HMAC with the submitted username, optionally retries with an empty username, and performs constant-time digest comparison. After authenticity is established it evaluates renewal and total-lifetime windows. If an initial token is already stored, the verifier compares the prefix plus sixteen-character session-ID region so a live tls_multi cannot quietly switch to a different login. Those operations are carefully staged; the short sample exits before them.
That continuity comparison has its own structural assumption: an existing initial value must contain the eleven-character prefix followed by the full sixteen-character session-ID text. The prefix-only trigger arrives while initial is NULL, so the verifier never reads that window; the caller stores the rejected value afterward. On a later pass the same field could make continuity code unsafe as well. The missing producer check, not an insufficient comparison length, is what places every such consumer at risk.
The state bits form an executable truth table for the storage fix. Zero may never authorize client data to become initial history. HMAC OK alone is a valid current token. HMAC OK plus EXPIRED is genuine history that may be retained while external policy asks for fresh proof. HMAC OK plus EMPTYUSER is a genuine compatibility case. The patch uses a bit test instead of flags == AUTH_TOKEN_HMAC_OK so these legitimate combinations survive. A vendor backport that requires exact equality may stop the crash while breaking token renewal semantics.
2.3 external-auth continued account policy without authenticating the token
Without auth_token_call_auth, a failed token causes wipe_auth_token(), marks the key state false, and returns before the later renewal block. The malformed copy may exist briefly, but cleanup removes it before the published crash sequence completes. External-auth mode deliberately does not return there. The external service must be able to see Invalid or Expired and decide whether a certificate, new password, MFA result, or another local factor permits the client to continue. This continuation is an intended feature, not an implicit declaration that the token itself passed.
The source comment makes the delegation explicit. The branch does not add HMAC OK, set skip_auth, or transform Invalid into Authenticated. It simply proceeds to environment preparation and configured authentication methods. The unconditional auth_token_initial = strdup(up->password) occurred around this decision without requiring HMAC OK. Internal token validity and external account acceptance therefore crossed: a rejected structure became durable before policy had answered.
set_verify_user_pass_env() prepares username, common name, client address, and token context before invoking the external modules. Its call to add_session_token_env() sets session_state=Invalid for zero flags and must still provide a session ID. In normal invalid or initial cases, an empty initial field triggers generate_auth_token() to build a complete local record, after which the function extracts its sixteen-character ID. The vulnerable assignment made the field non-NULL, bypassing that safety path and selecting the short client allocation as the ID source.
An external SUCCESS on this path is not an OpenVPN token-authentication bypass. The integration is explicitly entrusted to accept a certificate, password, MFA result, or other account evidence after the token has been labelled Invalid. The confirmed defect is that internal session history was populated from the rejected token while that independent policy work continued. Reports should explain why the backend proceeded and why the token failed as two separate facts.
Plugins can return SUCCESS, ERROR, or DEFERRED. The management interface can leave authentication pending, and script/plugin plumbing preserves deferred states where supported. A success or deferred result that is not defeated by another configured method enters the common accepted-or-pending block. Token generation occurs there before a final asynchronous decision necessarily arrives. A management operator can ultimately reject the request after the process has already parsed the poisoned initial record. Reproduction notes must capture the immediate return that controls the current stack frame as well as the later final decision.
A synchronous backend that rejects Invalid immediately may stop the later generator, but environment preparation and its fixed-width read have already occurred. A deferred plugin or management flow can reach both the read and renewal parser. A backend that accepts another valid factor can reasonably return success, yet it cannot make the short string acquire missing fields. Rules in the identity provider are therefore partial mitigations at best. The durable-state guard belongs at token classification and state admission, before every backend-specific continuation.
Timing determines what each temporary control can block. An immediate synchronous rejection may prevent the generator but cannot undo the earlier environment read. Deferred policy has no later opportunity to protect the current stack frame. A network filter normally cannot inspect the TLS-protected password, certificate admission still permits legitimate holders to reach the function, and a supervisor only repairs availability after exit. The two source guards are the only fix that covers every backend form and both memory operations.
3 The rejected input was saved after verification
The decisive vulnerable statement is simple. When multi->auth_token_initial is NULL, verify_user_pass() assigns strdup(up->password). The code sits after verify_auth_token() has produced its state flags, but the condition does not consult them. The new allocation has exactly the client string's length plus its terminator. From that moment, later functions interpret a client-sized object through a server-token type. Understanding why the assignment existed, and which readers trust it, explains both the delayed discovery and the two-part repair.
3.1 auth_token_initial preserves the beginning of a login
tls_multi lives across the multiple TLS sessions that can make up one OpenVPN connection. Active, initial, and lame-duck key sessions may rotate while the connection-wide object remains. Two token pointers serve different purposes. auth_token represents the current value prepared for delivery or renewal. auth_token_initial preserves the first session ID and creation time so every later renewal remains part of the same login. It is less like a request buffer and more like an identity ledger carried through the connection.
When no initial token exists, generate_auth_token() creates a random twelve-byte session ID and uses the current time for both initial and renewal timestamps. It computes the HMAC, Base64-encodes the sixty-byte record, prefixes it, and stores the result as the current token. If initial is still empty, the generated value is copied there too. This server-side producer satisfies every invariant by construction: exact external length, complete fields, and an HMAC made with the configured or ephemeral server secret.
A second producer is necessary when a client reconnects with a token made earlier. If the server can validate that token and the new tls_multi has no initial record, preserving the supplied value restores the original session ID and initial time. The 2021 refactor placed that operation in verify_user_pass(), making the state available to environment export and renewal immediately. The business purpose is sound. The missing condition was that the client string had actually earned AUTH_TOKEN_HMAC_OK.
wipe_auth_token() owns cleanup. It securely clears the actual strlen() of current and initial strings, frees each allocation, and sets both pointers to NULL. The malformed short object is not a dangling pointer, double free, or oversized erase. Its allocation remains live until the expected cleanup point. The defect is a semantic lifetime error: a long-lived field receives an object of the wrong validated type, so correct ownership management exposes it to readers that make stronger layout assumptions.
The fixed condition expresses that type transition in ordinary C control flow. Initial must be empty, and auth_token_state_flags & AUTH_TOKEN_HMAC_OK must be nonzero. An expired token still carries HMAC OK, allowing successful external reauthentication to keep the same login origin. A false-HMAC or malformed token cannot become the archive. This is a capability check: the verifier grants a bit that authorizes promotion from untrusted credential text to durable session state.
3.2 A 2019 fixed layout met a 2021 state simplification
Commit 1b9a88a2… rewrote authentication tokens in 2019 as HMAC-protected stateless records. The server no longer needed an in-memory database entry for every token; a shared secret and the fields carried by the client were enough to validate a reconnect. Commit c8723aa7… then added permanent session IDs and the external-auth state exported to identity integrations. Fixed character windows became practical because the server's own generator always emitted a known layout.
The session-ID window quickly became an interface. add_session_token_env() copied sixteen Base64 characters after the prefix so a script or plugin could correlate the login. A 2020 correction, 42fe3e81…, fixed an offset that had omitted the first character, showing that the position had operational consequences outside the token module. The producer assumption remained implicit: the initial field came from the generator, so its prefix, width, timestamps, and HMAC were already established.
Commit d75e0736… in 2021 simplified initial-token handling. Its intent was to make the initial value available consistently and to enforce that a session ID could not change within one login. The code began copying up->password when the initial pointer was empty. The surrounding prose described a verified token, while the concrete guard was only the outer is_auth_token() branch. The cryptographic flags returned a few lines earlier were not part of the assignment.
Default token behavior hid the mismatch. An invalid token was wiped and the routine returned, so common tests ended at rejection and never exercised the later fixed-offset reader. External-auth existed specifically to continue after Invalid or Expired, preserving the poisoned field. The 2019 consumer assumption, the 2021 producer expansion, and a particular deployment option had to align. That combination explains why independently sensible functions passed ordinary tests while their composition remained vulnerable for years.
The official affected range begins at 2.6.0. Although v2.5.11 shares the prefix-only classifier and supports external-auth, its verify_user_pass() does not contain the same unconditional client-password assignment to auth_token_initial. A weak helper alone is insufficient to complete this CVE. The source difference agrees with the CNA range and prevents a visual similarity from being turned into an unsupported 2.5.x claim.
3.3 Environment export reads sixteen bytes beyond a short source
External handlers need their context before OpenVPN calls them, so set_verify_user_pass_env() invokes add_session_token_env() first. The helper translates flags into a state string and chooses a source for the session ID. With zero flags it produces Invalid. If no initial token exists, it normally generates a complete local token to supply a safe ID. The vulnerable assignment has just made initial non-NULL, so that generator fallback is skipped and the client-sized copy is selected.
The local destination session_id is twenty-four bytes and initialized to zero. Its capacity is not the problem. session_id_source points to multi->auth_token_initial, and memcpy() copies sixteen bytes from source plus eleven. For the prefix-only example, strdup() allocated twelve bytes: offsets zero through ten are the marker and offset eleven is NUL. The copy starts on that final byte and reads fifteen bytes outside the exact heap object.
The first copied byte is NUL. When setenv_str() later treats the destination as a C string, it normally exports an empty value; the fifteen following bytes stay beyond that C-string terminator. That fact prevents a direct information-disclosure claim from this path. It does not make the fixed-width read legal. ASan can report a heap-buffer-overflow before the plugin, script, or management client is invoked; an ordinary allocator may place the adjacent bytes in an accessible size class and allow execution to continue.
Changing the sample length changes which consumer first outlives the allocation. A prefix followed by sixteen characters gives this environment copy a legal source window, yet the object is still far shorter than the ninety-one characters required by timestamp and HMAC readers. At lengths 11, 27, 39, 90, 91, and 92, different assumptions become visible. A useful fuzz corpus includes all of them, plus correct-length invalid Base64 and false-HMAC records, so tests describe the complete object contract instead of memorizing one crash string.
Intermediate lengths change the bytes observed, not the root cause. A string containing the prefix and a few characters lets the sixteen-byte copy include attacker text until the source NUL, then read beyond the object; the exported C string still stops at that first NUL. At total length twenty-seven the session-ID window is finally present, while the timestamp pointer lands on the terminator and later fields remain absent. This progression explains different sanitizer offsets without turning them into separate vulnerabilities.
This earlier read explains differing incident stacks. An instrumented build can terminate in add_session_token_env(); a release build may export Invalid, notify the management interface, and only then die in token generation. Both outcomes originate from the same poisoned initial pointer. Source line numbers also vary across vendor backports. Investigators should preserve the first memory error, build identity, and full call sequence; a single advisory screenshot cannot define every valid top frame.
Both fixed gates restore safety without burdening the consumer. Prefix-only no longer classifies as a token, leaving initial empty; environment export generates a complete local record before reading its ID. A ninety-one-character false-HMAC value remains token-shaped and Invalid, but the HMAC gate prevents storage, so the same local generation occurs. Once every producer supplies a complete authenticated record, fixed-offset readers can rely on the field again.
4 The pointer left the allocation before the assertion fired
The advisory's assertion description captures the reliable release-build outcome. A line-by-line walk of the prefix-only object gives a fuller memory ledger. Environment export reads past the allocation. Renewal creates a second exact-length copy, forms a timestamp pointer well beyond it, and writes a terminator before decoding. The assertions make process termination predictable under the original internal invariant, but they are downstream of memory operations that have already left the C string object.
4.1 Immediate success and deferred authentication enter the same renewal block
After preparing the environment, verify_user_pass() invokes the configured management, plugin, and script mechanisms. Plugin SUCCESS and DEFERRED both produce an acceptable immediate result; equivalent deferred plumbing keeps asynchronous requests pending; management authentication can mark the key state for a later control response. Username locking must also succeed so the connection cannot switch identities while a decision is in flight. The combined condition distinguishes a hard error from a flow that may proceed or wait.
Inside the common block, the key state is first set to KS_AUTH_TRUE and adjusted to KS_AUTH_DEFERRED when any configured mechanism remains pending. This “success block” therefore means accepted for continued processing, not necessarily finally approved. The code handles username-as-common-name behavior and generated tokens while the stack frame is still active. Preparing renewal state early is reasonable when every initial-token producer enforces the format contract.
If the submitted token has a valid, current HMAC and no current token is stored, the function copies it to multi->auth_token. Prefix-only has zero flags and does not take that branch. The independent auth_token_generate option then causes an unconditional call to generate_auth_token(up, multi). The malformed initial field is the only historical source available to that generator, so fixed-offset reuse begins even though the internal verifier rejected the credential.
Success can also be legitimate under the deployment's own policy. An external handler may accept a trusted certificate, a fresh second factor, or an account rule even while the supplied token is Invalid. That decision grants connection policy; it cannot fill missing internal bytes. Relying on every integration to reject one malformed spelling would break the design and still leave the pre-callback read. A token module must defend its own durable state regardless of what an external identity service decides.
4.2 string_alloc() copies the real length while the parser advances through a full record
generate_auth_token() creates a garbage-collection arena, prepares current and initial timestamps, and obtains its HMAC context. When multi->auth_token_initial is non-NULL, it calls string_alloc(initial, &gc) to obtain a writable copy. The implementation in buffer.c computes n = strlen(str) + 1, allocates exactly n bytes through the arena, and copies exactly n. It does not reserve ninety-one characters merely because the destination will be interpreted as a token.
For prefix-only, the second object is again twelve bytes. old_sessid = initial_token_copy + strlen(SESSION_ID_PREFIX) points at base plus eleven, the legal terminator. old_tsamp_initial = old_sessid + AUTH_TOKEN_SESSION_ID_BASE64_LEN then attempts to form base plus twenty-seven. Array-pointer arithmetic for this twelve-byte object may produce only addresses from base through the one-past value at base plus twelve; base plus twenty-seven is beyond one-past, so forming that pointer is already undefined behavior. The later read, write, and decoder call continue using the invalid result; the C object model has already been violated by its formation.
The code wants a temporary twelve-character Base64 window for the initial timestamp, so it executes old_tsamp_initial[12] = '\0'. The target is base plus thirty-nine, twenty-eight bytes beyond the last allocated offset. This is a deterministic one-byte out-of-bounds write with a fixed zero value, and it occurs before the Base64 return length is asserted. The source supports that ordering directly; exploitability beyond denial of service would require additional evidence about allocation layout, control, and continued execution.
Next, openvpn_base64_decode() receives the pointer at base plus twenty-seven and a nine-byte local destination. No valid string begins there. The decoder may encounter surrounding heap data and the newly written NUL, return a length other than nine, or be stopped by instrumentation. The following assertion expects nine. If it somehow passes, the function later terminates another substring, decodes the session ID from base plus eleven, and asserts a twelve-byte result. Prefix-only begins with a real NUL at that location, leaving another reliable failure.
Assertions were originally internal consistency checks. When the only producers were the server generator and verified client records, a malformed timestamp window indicated impossible corruption, and a fatal stop was defensible. The producer set changed. Untrusted text reached the assertion only after pointer arithmetic and mutation. Remote-input validation must therefore happen when the value is admitted to the field, not when a deep consumer notices that its old invariant has already failed.
Dynamic tools can disagree about the first visible line while agreeing on the object. ASan commonly catches the earlier source over-read; another build may catch the NUL write; Valgrind may flag the fixed memcpy(); a normal optimized binary may reach only the fatal assertion. Allocator size classes can place the twelve-byte request in a larger physical chunk, but the language-level object remains twelve bytes and sanitizers track the requested length. Treat these reports as alternate observations of one state-poisoning path.
4.3 assert_failed() ends the daemon with _exit(1)
OpenVPN's ASSERT(x) macro calls assert_failed(__FILE__, __LINE__, #x) when its expression is false. The implementation logs the file, line, and condition at M_FATAL, then invokes _exit(1). It does not raise a recoverable exception around the current connection or close only the offending TLS session. Every client whose tunnel is served by that process loses the daemon together.
A service manager commonly restarts OpenVPN, and an HA group may steer reconnects elsewhere. That changes outage duration, not vulnerability status. If the same prerequisite-bearing client can reconnect after the listener returns, repeated attempts can turn one short interruption into sustained churn. Certificate validation, external authentication, route installation, and client renegotiation all surge during recovery. A five-second restart can create a much longer service episode when thousands of clients begin again at once.
Measure that episode in three intervals: process exit to a listening socket, listener recovery to the first successful authentication, and first authentication to restored client routes and data flow. The middle interval reveals certificate and identity-provider pressure; the last reveals routing, DCO, and client-backoff costs. A health probe that ends at “port open” can therefore understate the outage that users and downstream systems actually experience.
Core behavior varies. _exit(1) does not itself guarantee the SIGABRT core one might expect from libc abort(). Core limits, systemd configuration, container policy, and watchdog behavior matter. ASan may terminate earlier with its own report. Evidence collection should include journal exit status, the previous container instance, sanitizer output, core-pattern settings, orchestrator restart counters, HA failover events, and identity-provider load. Waiting only for a conventional core can leave the most useful trace undiscovered.
A plausible log sequence is wrong token size, Invalid session state, an external authentication notification, and then a fatal assertion in auth_token.c. An ASan build may end at the session-ID memcpy() before the notification appears. Vendor backports move line numbers, so the running binary hash and package build must accompany any source mapping. The durable signatures are the functions, state transitions, object length, and process exit, not one hard-coded line.
The fixed zero write and surrounding read justify preserving the event as a memory-safety incident. They do not on their own demonstrate arbitrary read, arbitrary write, token disclosure, authentication bypass, or control-flow hijack. The byte value is fixed, heap layout varies, and the process reaches fatal handling quickly. A stronger claim would need a reproducible supported build and coordinated disclosure. Until then, the evidence-backed outcome is remotely initiated loss of service on the affected configuration.
[+11,+27): the dark NUL at +11 is its one legal byte, while the cyan cells at +12 through +26 are 15 out-of-bounds bytes. The +27 marker is the read's exclusive end and the subsequent invalid timestamp pointer—not another byte read by memcpy()—and the ochre 12-character window leads to the zero write at +39. These operations precede the later assertion.5 Version, configuration, and backend behavior determine reachability
A scanner can find an OpenVPN version quickly, but it cannot answer whether one daemon enters this branch. A defensible exposure decision combines the affected source range, an effective auth-gen-token ... external-auth setting, the TLS materials required to submit a key-method password, and the external backend's immediate response. Recording those conditions as asset fields turns a product alert into a testable statement about a particular gateway.
5.1 The published range is 2.6.0–2.6.20 and 2.7 alpha1–2.7.4
The OpenVPN CNA lists Community OpenVPN 2.6.0 through 2.6.20 and 2.7 alpha1 through 2.7.4 as affected. Version 2.6.21 and 2.7.5 are the fixed floors on those lines. Inventory must describe the daemon that is actually mapped into the running process: capture openvpn --version, executable path, package build, image digest, process start time, and the configuration source. A newly installed package beside a weeks-old PID does not close the issue.
Distribution packages often retain an older upstream version string while backporting security commits. Conversely, an application directory can contain a fixed binary while a unit or container still launches the old one. Acceptable evidence includes a vendor advisory mapping the package build to CVE-2026-13122, a changelog, the source patch, symbols or disassembly showing both guards, and a post-restart process-image identity. Version comparison is a triage tool; patch and runtime evidence provide closure.
A defensible backport proof has four linked records: the vendor bulletin points to a source or patch containing both guards; the delivered package or image contains that build; the deployment revision selects it; and the running PID maps the new inode or image layer. Each missing link creates a familiar false closure—a bulletin for another edition, a package installed beside an old process, or a fixed image never selected by the workload. Store the Community baseline and vendor release in the SBOM so the chain can be repeated.
The maintenance branches use different commit identities. The release/2.6 repair is ee119b24b3178b8402b087a1f3f0930be25c5618. The release/2.7 repair is 010b6c833b7fdbe889c0c7f8fc33f588a658b81f. Master uses 423afa8e265a21527838540529a41fef834a4033. Their substantive changes are equivalent, but their ancestry is not. An SBOM or change record should name the hash appropriate to the delivered branch instead of treating the presence of the master hash as proof for every binary.
Version 2.5 deserves a precise scope statement. v2.5.11 still contains the prefix-only helper, which initially looks suspicious, but its verify_user_pass() lacks the affected client-input assignment to auth_token_initial. The full producer-to-consumer chain does not exist there in the same form. Unsupported 2.5 deployments may need a lifecycle upgrade for many reasons, but that similarity does not add them to the CNA's CVE-2026-13122 affected set.
OpenVPN Access Server, embedded appliances, and third-party forks may share Community code, carry an independent token implementation, or backport selected changes. A product label in a web interface cannot settle the mapping. Ask the vendor for Community baseline, CVE status, fixed firmware, and a description of any backport. Where the answer remains incomplete, use an isolated representative image to verify the exact classifier and HMAC-gated state assignment, then run the non-destructive length matrix.
5.2 Effective configuration must resolve to external-auth
Searching the primary configuration file is only the first step. OpenVPN accepts command-line options, includes, inline profiles, service-manager units, container arguments, and control-plane-generated files. Appliances can synthesize the final command just before launch. Preserve the complete auth-gen-token arguments, including lifetime, renewal time, and the final external-auth keyword, from the effective process configuration. A commented template or an unused file is not exposure evidence; a generated argument omitted from the main file can be decisive.
If auth-gen-token is disabled, verify_user_pass() never uses the token classifier. If generated tokens are enabled without external-auth, an invalid token is wiped and rejected before the continuation that reaches renewal. Both are meaningful configuration-level non-exposure findings for the current instance. They should remain in the component inventory because failover profiles, future changes, or a control-plane rollout can alter the option without changing the package version.
Client admission controls define what a remote actor needs before the password reaches the vulnerable function. Record verify-client-cert, CA and revocation handling, OCSP, tls-auth, tls-crypt, tls-crypt-v2, optional username/password modes, and certificate distribution. A public listener with self-service client material differs materially from a private gateway whose certificates and control key are held by a small administration team. These facts map the CNA's low privileges and present attack requirement to the organization's real population.
The backend needs equal care. A synchronous verification script often rejects a malformed password immediately. A plugin may return DEFERRED and inspect a control file later. A management client can receive a pending authentication request and answer asynchronously. The common OpenVPN block generates a token while deferred, making those latter forms important to the full assertion path. Labeling the system merely “LDAP” or “RADIUS” loses the adapter's immediate status and timing, which are the source-level conditions that matter.
Several mechanisms can be enabled at once. OpenVPN combines their results; one hard error can prevent the common block even if another method is pending. Build a table with one column per plugin, script, or management decision, its immediate return, any deferred handle, final callback, and resulting key state. For each malformed length, identify the exact function where processing stops. This table avoids both overclaiming every external-auth server is trivially triggerable and overlooking a deferred adapter hidden behind a familiar identity product.
auth-gen-token-secret affects cluster continuity but not the short-object root cause. With a shared secret, another node can validate and renew a token. Without it, a restart changes the ephemeral secret and existing tokens become Invalid. Both layouts can save the short value on vulnerable code. During an upgrade, protect the secret's permissions and distribution; rotating or dropping it accidentally can force a fleet-wide reauthentication storm that obscures whether the security change itself is healthy.
5.3 Confirmed impact is repeatable loss of the gateway daemon
The CNA classifies the flaw as CWE-617, Reachable Assertion. Its CVSS 4.0 base score is 5.9 Medium, with no confidentiality or integrity impact and high availability impact on the vulnerable system. NVD's current CVSS 3.1 assessment is 5.3 Medium. The scoring frameworks express prerequisites differently, but both place the confirmed result at a configuration-dependent network denial of service.
Source establishes the earlier over-read and one-byte zero write. Those facts refine execution order and explain sanitizer reports. They do not establish arbitrary memory access, secret disclosure, a tunnel authorization bypass, or control-flow takeover. Allocator behavior, instrumentation, hardening, and supervisor policy change the visible failure. Any stronger impact statement needs an independent reproduction on a supported build and a disclosure path that lets maintainers assess it before publication.
One process exit affects every client attached to that daemon. Sockets close, routes or DCO peers are cleaned according to platform behavior, and clients begin reconnecting to the same or another node. An external identity service can receive a sudden burst of certificate, password, MFA, or directory work. If HA peers are already near capacity, the failover can turn a single-node crash into a group-wide performance event even when the listener returns quickly.
Repeated triggering requires the actor to satisfy TLS and authentication sequencing again. The CVE record's Automatable: No value comes from the CISA ADP's SSVC enrichment, not from the OpenVPN CNA's CVSS 4.0 assessment; the CNA record leaves the CVSS 4.0 Automatable supplemental metric undefined. A holder of durable client material can nevertheless retry after an automatic restart when the vulnerable configuration remains. Connection limits, certificate requirements, and rate controls raise cost. They cannot replace the source fix because legitimate clients are already permitted to reach the same password-handling function.
Malformed tokens can also originate without hostile intent. A corrupted client credential cache, a truncating wrapper, an incompatible integration, or a manual test can produce the reserved prefix with too little payload. Intent comes from repetition, source history, account and certificate context, timing, and the relationship to restarts. Remediation does not need to wait for attribution: a gateway process that remote input can reliably terminate already warrants correction.
6 The repair enforces complete shape and trusted provenance at different gates
The three branch patches are small enough to review in one sitting and broad enough to restore the design. The first change allows only exact ninety-one-character records through the token classifier. The second allows only HMAC-authenticated client tokens into auth_token_initial. Invalid and expired states can still reach external policy, so the useful business workflow survives. Structure and origin now have separate, visible enforcement points.
6.1 Exact length restores the promise behind every fixed offset
The fixed implementation defines AUTH_TOKEN_HMAC_LEN, TOKEN_DATA_BASE64_LEN, and TOTAL_SESSION_TOKEN_LEN beside the token code. It derives external width from the binary payload through the project's Base64 length macro. Static assertions preserve divisibility assumptions for both the session ID and complete payload. If a future algorithm changes the digest, timestamp, or identifier width, compilation and tests expose a layout drift instead of silently leaving stale character offsets.
is_auth_token() now compares strlen(password) with the total and returns false immediately on any mismatch. Only an exact-length value reaches the constant-time prefix comparison. Prefix-only therefore follows the ordinary initial-credential path. When environment export needs a session ID and initial is empty, OpenVPN generates a full local token first. The attacker-controlled short object never reaches token decoding or the initial field.
An exact-length value with invalid Base64 or a false HMAC still enters verify_auth_token(). That is intentional. It has enough storage for safe structural parsing and receives session_state=Invalid for external policy. The second gate prevents it from becoming initial history, so environment export again obtains its ID from a server-generated record. The classifier answers shape and the verifier answers provenance; neither function impersonates the other.
A hard-coded 91 check can repair today's layout, but the upstream derived constants are better backport material. A minimum-length test is not equivalent: the protocol defines no trailing bytes and alternate decoders may handle them differently. A prefix-only special case is also incomplete because every length from twelve through ninety violates some downstream field window. Review strict equality and both adjacent-length tests, not simply the advisory sample.
Moving the helper from an inline header implementation to one C definition reduces semantic copies. All callers link the same logic, and external modules do not need to know the private total-length calculation. A clean rebuild matters because an old inline body can remain in previously compiled object files. Release evidence should include a clean package build or equivalent reproducibility, then confirm no copied prefix-only classifier survives elsewhere in the fork.
Both upstream changes are required for that claim. The length gate prevents short client text from entering the token route, while the HMAC gate prevents an exact-shape false token from becoming trusted history and protects future consumers that may rely on origin. Shipping only the first can stop the published eleven-character input yet leave the durable field writable by unverified content. Shipping only the second leaves a misleading classifier available to other fixed-offset callers.
6.2 AUTH_TOKEN_HMAC_OK becomes permission to write durable state
The vulnerable condition checked only whether initial was empty. The repaired form also requires auth_token_state_flags & AUTH_TOKEN_HMAC_OK. This bit test deliberately permits combinations. A genuine expired token still proves server origin and can preserve its session ID while the external system requests fresh authentication. A genuine token validated with the empty-username compatibility path remains eligible. Invalid client content has no HMAC capability and cannot cross the assignment.
Consider a ninety-one-character false-HMAC record. The new classifier returns true, the verifier returns zero, and external-auth sees Invalid. Initial remains empty. add_session_token_env() creates a valid local token to provide a stable ID; if policy returns success or deferred, the later generator reads that complete local record. External policy is free to proceed, yet every fixed-offset consumer has a safe source. This counterexample shows why the HMAC guard is an independent part of the repair.
The guard enforces provenance. Length proves that a record can be parsed without crossing its allocation. HMAC proves that it originated from a server-side generation path holding the configured secret and that its fields were not altered. External authentication success proves that an account may proceed at this moment. Only provenance authorizes client text to become the login archive. Keeping those proofs named prevents a future refactor from treating policy success as a structural guarantee.
The patch reuses flags already calculated by verify_auth_token(); no second HMAC calculation enters the hot path. The caller performs one bit test at the state transition. This placement is stronger and easier to audit than asking every reader to revalidate the same object. In the fixed tree, production writes to initial are limited to a locally generated complete token and a complete client token that passed HMAC verification. Cleanup is the only destructive writer.
Expired-token behavior deserves a positive regression. After external MFA succeeds, the generator should preserve the original session ID and initial timestamp while advancing renewal. If a backport rejects all flags except exactly HMAC OK, it can turn a genuine expired login into a new lifetime or a failed renewal. Likewise, empty-username compatibility must retain its documented semantics. Security acceptance includes these business cases, not only the malformed sample.
6.3 Branch commits and tests define the repair
Commit metadata records three branch landing dates: release/2.6 fix ee119b24… on June 30, 2026; release/2.7 fix 010b6c83… on July 1; and master fix 423afa8e… on July 1. The maintenance commits entered 2.6.21 and 2.7.5 respectively. Their diffs touch auth_token.c, auth_token.h, ssl_verify.c, documentation, and unit tests. Recording branch-specific identities avoids a common supply-chain error: citing a correct master commit for a maintenance package whose history cannot contain that exact object.
The new auth_token_test_is_auth_token() confirms known-good tokens, then rejects empty input, an ordinary password, the prefix alone, and another short prefix-bearing value. It creates a total-length buffer with the wrong prefix, expects false, repairs the prefix, and expects true. Finally it shortens a known token by one character and extends it by one, requiring both to fail. These cases define exact syntax limits independent of HMAC content.
Environment tests now capture session_id. After local generation, the test finds the ID in auth_token_initial and verifies its offset is eleven. It also loads a known valid token and checks the exported sixteen-character value. Those assertions bind the consumer's character window to the generator's layout, the interface that mattered when the malformed object reached add_session_token_env().
Upstream unit coverage does not replace an end-to-end external-auth regression. A downstream test should pass prefix-only through verify_user_pass(), arrange immediate success and deferred results, assert the daemon remains alive, and verify that client text never occupies initial. A second case should provide a correct-length false-HMAC token: it may be reported as Invalid, but initial must come from local generation. These cases exercise the invariant across classifier, verifier, environment export, and generator.
Positive flows belong in the same suite. First password login should receive a token. Renegotiation should reuse the session ID and update renewal time. A valid token should be Authenticated, an expired genuine token should be Expired and eligible for the configured reauthentication, and the empty-username compatibility state should match supported clients. Closing the CVE by disabling external-auth behavior or breaking renewal is not a successful repair.
An integration assertion set can be concise: the daemon PID remains stable; short and long malformed strings do not classify as tokens; a full-length invalid record reports Invalid; none of those client values becomes initial; the backend's session ID comes from a complete local token; deferred final callbacks still update the key state. Run debug or sanitizer instrumentation in isolation, record only lengths and flags, and keep passwords and complete tokens out of logs.
7 Close the production path and keep session state under review
Closing this CVE means proving that every running daemon has both guards and that external-auth still works through renegotiation and deferred decisions. Installing a package without restarting, updating only standby nodes, or testing only first login leaves an evidence gap. The same source model can be translated directly into a change ticket, regression table, and incident timeline.
7.1 Inventory the binary, effective options, and immediate backend result
Begin with every Community daemon, embedded appliance instance, container sidecar, disaster-recovery node, and autoscaling template. For each, record listener, role, PID, executable, openvpn --version, package build, process start, image digest, upstream branch or firmware, and current sessions. For an HA group, calculate how many members can be restarted while the rest carry both ordinary traffic and a possible reconnect surge.
Resolve the effective configuration. Expand includes, systemd ExecStart and drop-ins, container command and arguments, environment rendering, and control-plane output. Preserve the full auth-gen-token line. An absent keyword in the primary file is inconclusive when a launcher appends it. Store a checksum or signed export so the tested configuration can be tied to the production process.
Label the external mechanism precisely: auth-user-pass-verify script, authentication plugin, management-client-auth, deferred control file, or a combination. Record the immediate response to an ordinary bad password, Invalid token, and pending request. Plugin DEFERRED and the eventual reject are separate rows. This is the operational field that determines whether the current stack reaches the common token-generation block.
Collect TLS admission facts alongside it: mandatory client certificate, CA and revocation controls, static control-channel key, optional username/password behavior, and the distribution of client material. Record whether the token HMAC secret is ephemeral per node or shared across the cluster. These fields describe the actor's prerequisites and predict whether existing client tokens will survive a rolling restart.
Require both fix and runtime proof. A 2.6 deployment should be at least 2.6.21 and a 2.7 deployment at least 2.7.5, unless a vendor backport demonstrates exact length and HMAC-gated storage. After rollout, confirm the PID and start time changed, the old executable is no longer mapped, health checks pass, and a client completes renegotiation. Keep the vendor bulletin, package identity, deployment revision, and test result together.
If an appliance vendor has not mapped the CVE, open a ticket asking for Community baseline, affected status, fixed firmware, and release date. While waiting, tighten client certificate admission, restrict reachable populations where feasible, ensure restart and HA capacity, and reduce the number of sessions concentrated in one process. These measures reduce probability or outage duration; they do not provide the field invariant delivered by the patch.
The change ticket should define completion. All live, standby, and template nodes are in scope; every rolling step checks remaining capacity; version, PID, start time, and health are captured; first login, valid renewal, expired reauthentication, and deferred behavior pass; malformed length cases run only in isolation; and monitoring shows no assertion or authentication surge. Any unpatched exception needs an owner, compensating control, vendor case, and expiration date.
Start on one low-traffic member and keep it in service long enough for a valid token to survive at least one renegotiation before widening the rollout. If the shared token secret is unchanged, that test should preserve ordinary sessions across members; if the change also rotates the secret, treat the resulting full reauthentication as a separate operational event. Any rollback image must already contain both guards—restoring the known vulnerable binary is not an acceptable way to cure an unrelated deployment fault.
7.2 Regression and forensics should follow length, state, and process lifetime
Build a dedicated test instance with the production external-auth adapter and relevant TLS options, using a test CA and accounts. The structural corpus begins with prefix-only, total length ninety, total length ninety-one with invalid HMAC, and total length ninety-two. Add lengths twenty-seven and thirty-nine to expose the fixed session-ID and timestamp windows. None requires a destructive payload; they test which invariants the code establishes before reading.
For each sample, make a synchronous backend return error and success, a plugin return deferred, and a management client hold a pending request. A fixed daemon stays alive. Short and long strings do not classify as tokens. The exact-length invalid record may export Invalid, but auth_token_initial must remain empty or be populated by the local generator, never copied from the client. Complete the asynchronous callback and verify the expected final key state.
Then run the positive matrix: initial password login, valid token renegotiation, renewal-time advancement, stable session ID, expired token reauthentication, shared-secret failover if used, and empty-username compatibility where required. Observe client routes and data flow, not only server logs. This catches a backport that blocks the crash by discarding the external-auth feature or by refusing legitimate expired records.
Historical investigation starts with exits. Search for assertions in auth_token.c, wrong-size warnings, Invalid state near external success, OpenVPN unit restarts, container restart counters, and HA failover. On instrumented builds, include add_session_token_env() source over-reads and the timestamp terminator write in generate_auth_token(). Group events by connection ID, certificate fingerprint, redacted username hash, client address, and time.
Do not centralize complete passwords or tokens. A wrapper can record credential_length, prefix match, decoded length, flags, session state, backend immediate/final results, and connection ID. Tokens remain replay-sensitive even during debugging. Cores may contain credentials and HMAC material, so access, retention, transport, and deletion should follow secret-handling policy. Share minimized traces with vendors through an approved channel.
Put network, application, identity, and supervisor clocks on one timeline and normalize time zones. The firewall supplies the connection, OpenVPN supplies certificate and token state, the backend supplies immediate and final policy, and the service manager supplies exit and restart. If sanitizer stops before the assertion, treat it as the same path. If only a restart remains, check journald rate limits, previous-container logs, watchdog records, and core settings before concluding the input was absent.
Repeated attempts from several sources, another exit immediately after automatic recovery, or a persistent certificate/account pattern should become an active exploitation investigation. A single event next to an authorized test may be a compatibility incident. Patch status does not depend on that attribution. Keep a post-change observation window long enough to cover normal token renewal and confirm that abnormal lengths and unexplained restarts cease.
7.3 Adjacent review closes every producer and fixed-offset consumer in the repaired tree
Only three paths populate or clear auth_token_initial in v2.6.21. generate_auth_token() writes a locally constructed, complete, HMAC-protected record. verify_user_pass() copies a client record only when HMAC OK is present. wipe_auth_token() clears, frees, and nulls the field. No third unvalidated network producer appears in the fixed supported tree. That producer inventory is the first half of the field proof.
The fixed-offset consumers form the other half. add_session_token_env() reads the sixteen characters after the prefix. generate_auth_token() restores the session ID and initial timestamp. verify_auth_token(), when initial already exists, compares the prefix and session-ID region for continuity. Each reader traces back to one of the two trusted producers. The repaired field has a closed provenance and lifetime graph.
The exact-length false-HMAC case is the most useful counterexample. The new classifier returns true, verification flags remain zero, and the HMAC guard blocks initial storage. Environment export sees an empty initial and creates a complete local token before reading the ID. External policy can still return success or deferred, and the later generator reads the local record. This single walk proves that the two guards cooperate instead of redundantly checking the same property.
Cross-checking v2.5.11, deferred-auth branches, the session-ID mismatch comparison, and token wiping did not confirm a second independent malformed-layout write, authentication bypass, or reachable assertion in the fixed supported versions. Deferred authentication remains a productive fuzz target because state advances before a final policy answer. An interesting state transition is a research lead, not a vulnerability record, until a supported fixed release reproduces an independent root cause and impact.
The engineering lesson extends beyond OpenVPN. Parsers often expose shape, authenticity, and policy as separate outcomes. Every durable write must state which outcome authorizes it, and every fixed-layout reader must be traceable to producers that establish its full precondition. Convenience code that saves “the first value seen” deserves particular scrutiny when the destination outlives the request. Keeping this rule in review and integration tests is more durable than memorizing one CVE.
Production closure can be expressed in one acceptance sentence: every relevant running server is 2.6.21, 2.7.5, or an equivalent two-guard backport and has restarted; first login, valid renewal, expired reauthentication, and deferred external-auth all work; the malformed-length corpus cannot pollute initial state or terminate the process. When all three clauses are evidenced, CVE-2026-13122 has moved from an advisory into an operationally closed state.
Research record
8Evidence, objects, and sources
The material below preserves the identifiers and references used in this report.
8.1Research objects
Products, actors, techniques, affected objects, and control points discussed in the report.
OpenVPN external-auth malformed token state pollution and daemon exit
Community release ranges published by the OpenVPN CNA
Upstream floors containing both structural and HMAC state guards
Mode that continues internal token state to an external identity decision
Eleven-character protocol marker; a complete credential is ninety-one characters
Fix on the release/2.6 branch
Fix on the release/2.7 branch
Master-branch repair and unit regression
8.2Event chronology
- HMAC tokens and permanent session IDs entered the tree
OpenVPN established the sixty-byte record, external-auth session state, and fixed session-ID extraction.
- Initial-token handling was simplified
The first client-supplied token began to be stored directly in verify_user_pass.
- Upstream authored the repair
The classifier gained an exact-length requirement and state admission gained the AUTH_TOKEN_HMAC_OK guard.
- OpenVPN 2.6.21 and 2.7.5 were released
The official Downloads page records both fixed releases on July 1, 2026.
- The CVE record became public
The OpenVPN CNA published affected releases, CWE-617, and its CVSS 4.0 assessment.
- SOSEC completed the source-level review
Four releases, three branch fixes, the pre-assert memory operations, commit history, and adjacent producers and consumers were reviewed locally.
8.3Sources and material
- OpenVPN security announcement for CVE-2026-13122https://community.openvpn.net/Security%20Announcements/CVE-2026-13122
- OpenVPN Community downloads and release informationhttps://community.openvpn.net/Downloads
- OpenVPN CNA record for CVE-2026-13122https://www.cve.org/CVERecord?id=CVE-2026-13122
- release/2.6 fix ee119b24https://github.com/OpenVPN/openvpn/commit/ee119b24b3178b8402b087a1f3f0930be25c5618
- release/2.7 fix 010b6c83https://github.com/OpenVPN/openvpn/commit/010b6c833b7fdbe889c0c7f8fc33f588a658b81f
- master fix 423afa8ehttps://github.com/OpenVPN/openvpn/commit/423afa8e265a21527838540529a41fef834a4033
- OpenVPN 2.6.20 auth_token.chttps://github.com/OpenVPN/openvpn/blob/v2.6.20/src/openvpn/auth_token.c
- OpenVPN 2.6.21 auth_token.chttps://github.com/OpenVPN/openvpn/blob/v2.6.21/src/openvpn/auth_token.c
- OpenVPN 2.6.20 auth_token.hhttps://github.com/OpenVPN/openvpn/blob/v2.6.20/src/openvpn/auth_token.h
- OpenVPN 2.6.21 auth_token.hhttps://github.com/OpenVPN/openvpn/blob/v2.6.21/src/openvpn/auth_token.h
- OpenVPN 2.6.20 verify_user_pass implementationhttps://github.com/OpenVPN/openvpn/blob/v2.6.20/src/openvpn/ssl_verify.c
- OpenVPN 2.6.21 fixed verify_user_pass implementationhttps://github.com/OpenVPN/openvpn/blob/v2.6.21/src/openvpn/ssl_verify.c
- OpenVPN 2.6.21 key_method_2_read entryhttps://github.com/OpenVPN/openvpn/blob/v2.6.21/src/openvpn/ssl.c
- OpenVPN 2.6.21 auth-gen-token option parsinghttps://github.com/OpenVPN/openvpn/blob/v2.6.21/src/openvpn/options.c
- OpenVPN 2.6.21 external-auth manualhttps://github.com/OpenVPN/openvpn/blob/v2.6.21/doc/man-sections/server-options.rst
- OpenVPN auth-token unit regressionshttps://github.com/OpenVPN/openvpn/blob/v2.6.21/tests/unit_tests/openvpn/test_auth_token.c
- OpenVPN string_alloc exact-size implementationhttps://github.com/OpenVPN/openvpn/blob/v2.6.21/src/openvpn/buffer.c
- OpenVPN assert_failed process-exit implementationhttps://github.com/OpenVPN/openvpn/blob/v2.6.21/src/openvpn/error.c
- OpenVPN tls_multi, token flags, and authentication stateshttps://github.com/OpenVPN/openvpn/blob/v2.6.21/src/openvpn/ssl_common.h
- OpenVPN 2.5.11 comparison without the affected initial assignmenthttps://github.com/OpenVPN/openvpn/blob/v2.5.11/src/openvpn/ssl_verify.c
- 2019 HMAC authentication-token rewritehttps://github.com/OpenVPN/openvpn/commit/1b9a88a2c38a6a29f8ccbec4fd529d7b363bfc06
- 2019 permanent session ID and external-auth changehttps://github.com/OpenVPN/openvpn/commit/c8723aa7bebb0ddb088819590d5a9bea6ea0d669
- 2020 environment session-ID offset correctionhttps://github.com/OpenVPN/openvpn/commit/42fe3e8175822a4cf2c85cc4ce3fdffd41d74455
- 2021 initial auth-token handling refactorhttps://github.com/OpenVPN/openvpn/commit/d75e0736b4a0501a2c038ecb55730bf4f482b990
- OpenVPN 2.6.21 Changes.rsthttps://github.com/OpenVPN/openvpn/blob/v2.6.21/Changes.rst
- OpenVPN 2.7.5 Changes.rsthttps://github.com/OpenVPN/openvpn/blob/v2.7.5/Changes.rst
- OpenVPN external-auth token renewal discussionhttps://github.com/OpenVPN/openvpn/issues/506
- Historical OpenVPN deferred-authentication security fixhttps://community.openvpn.net/Security%20Announcements/CVE-2020-15078