Vulnerability Research

OpenVPN Session Promotion Left a Freed ACK Pointer Behind (CVE-2026-12996)

CVE-2026-12996 occurs during one OpenVPN server TLS multi-session pass: the active session lends a shallow view of a dedicated ACK to the outer send slot, a candidate session reaches the TLS promotion threshold and frees the old owner, and the event loop later reads the stale ack_write_buf , producing a remotely influenceable heap use-after-free.

A warm hand-drawn before-and-after view of OpenVPN session promotion: above, two tunnels leave an ACK envelope tied to a collapsing borrowed holder; below, the repaired transition clears the borrowed path before the retired tunnel disappears.
In this article

Research basisSOSEC Security Research · Reconstructed from a locally pinned OpenVPN v2.6.21 tree, three release-line fixes, and the July 2026 upstream publication · Last revised Jul 15, 2026

SourceOpenVPN 2.6.21 source / 2.6, 2.7 and master fixes / OpenVPN July 2026 releases / SOSEC source review

1 An ACK that had not yet left the process

The story begins with a very small control-channel acknowledgment. OpenVPN has already formatted it. The destination is known. The outer forwarding state treats it as the next packet to write when the link socket is ready. Yet before the program reaches send() or sendto(), another perfectly ordinary state transition retires the TLS session that owns the packet bytes. The descriptor in the connection context still looks complete: capacity, offset, and length retain sensible values. Its data member is now an address into a heap block that has been returned to the allocator.

That interval—after a packet has been prepared but before the network consumes it—is the whole terrain of CVE-2026-12996. The defect does not depend on breaking a cipher or parsing a wildly oversized record. Each local operation has a good reason to exist. A received control packet should be acknowledged. A standalone ACK avoids waiting for unrelated control data. A candidate TLS session that completes the key-method exchange should replace the old session. Replacement should release the old SSL objects, reliable queues, and work buffers. An event-driven program should be able to prepare output in one routine and write it later. The failure appears only when these operations share a pointer without carrying the pointer's owner across the abstraction boundary.

OpenVPN's release description says a well-timed sequence of control-channel and authentication packets can trigger the bug. The source reduces that sentence to two questions. Which object owns the bytes of a dedicated ACK after tls_process() hands a buffer to its caller? How can tls_multi_process() end that owner during the same call, before the forwarding loop has consumed the bytes? Once both paths meet at to_link.data, the six-line patch, the choice to discard one pending ACK, and the exact version boundary become consequences of the same lifecycle.

1.1 The control channel maintains reliable order above its transport

OpenVPN separates the data channel that carries tunnel traffic from the TLS control channel that negotiates keys, authenticates peers, pushes configuration, and maintains a long-lived session. Both travel over the configured link, but their reliability contracts differ. A dropped data packet can be handled by the tunneled protocol. A missing certificate fragment or key-method message can stall the tunnel itself. OpenVPN therefore implements packet IDs, acknowledgments, ordering, and retransmission for control traffic even when the underlying transport is UDP.

The relevant structures live inside each key_state. send_reliable retains outgoing control packets until the peer acknowledges them. rec_reliable orders received ciphertext before it is fed into TLS. rec_ack is a short list of packet IDs this side owes to the peer. lru_acks remembers recently transmitted acknowledgments so they can be repeated. These structures describe protocol state; none of them, by themselves, means that a packet has already reached the operating system's socket layer.

When tls_pre_decrypt() accepts a control packet, it identifies the opcode and key ID, selects the corresponding session and key state, validates the configured control-channel wrapper, parses acknowledgment records sent by the peer, and applies the reliable receive rules. A line near the end of that path calls reliable_ack_acknowledge_packet_id(ks->rec_ack, id). The function deduplicates the ID and appends it if the fixed eight-entry array has room. At that point OpenVPN owes an acknowledgment, but no outgoing byte buffer has been chosen.

The separation matters because ACKs have two possible rides. If send_reliable already has a control packet eligible for transmission, write_control_auth() can prepend or append acknowledgment records to that retained packet. Its backing storage is one of the reliable array entries. If no such packet exists and to_link is empty, tls_process() creates a dedicated P_ACK_V1 packet in ack_write_buf. CVE-2026-12996 follows only the second route. The pre-existing destruction guard already enumerated reliable array storage; it did not enumerate the dedicated scratch buffer.

reliable_ack_write() also explains why dropping the unsafe outgoing view does not erase protocol truth. The routine moves newly selected IDs into the most-recently-used acknowledgment history, writes as many recent IDs as the packet can hold, appends the remote session ID when the count is nonzero, and removes the selected new IDs from rec_ack. If the resulting packet is discarded during session promotion, the peer will retain and retransmit any control message it still considers unacknowledged. OpenVPN deliberately acknowledges acceptable replayed control packets for exactly that reason: a repeated packet can recreate the acknowledgment and allow the reliable exchange to converge.

1.2 struct buffer is a coordinate system, not a self-owning packet

OpenVPN's buffer.h makes the representation plain. A struct buffer contains capacity, offset, len, and a raw uint8_t *data. The active content begins at data + offset. There is no destructor embedded in the type, no reference count, no owner field, and no automatic distinction between an owning descriptor and a borrowed view. Those semantics come entirely from the surrounding call path.

key_state_init() establishes ownership by assigning alloc_buf(BUF_SIZE(&session->opt->frame)) to ks->ack_write_buf. In buffer.c, alloc_buf() validates the requested size, initializes capacity, offset, and length, then uses calloc(1, size) for a new allocation. The matching end of life is equally direct. key_state_free() calls free_buf(&ks->ack_write_buf); free_buf() calls free(buf->data) and clears the descriptor passed to it. This owner is a key state, not the outer forwarding context.

The project already has operations for real copying. clone_buf() allocates a new block, preserves the source coordinates, and copies the active bytes with memcpy(). buf_assign() writes source bytes into storage supplied by the destination. The vulnerable branch uses neither. Its first line is struct buffer buf = ks->ack_write_buf, an ordinary C aggregate copy. Capacity, offset, length, and the numerical pointer value are duplicated. The heap allocation is not.

The local copy is useful. buf_init(&buf, headroom) can reset the local offset and length without disturbing the reusable member descriptor. Control headers can be prepended into reserved headroom; the dedicated work area is ready for the next ACK once the current output has been consumed. The subtle result is that ks->ack_write_buf.len can remain zero while the outer copy has a nonzero packet length. Looking only at the member's length cannot reveal that its storage is currently on loan. Comparing the base data address can.

The second aggregate assignment, *to_link = buf, extends the loan beyond the lifetime of the local variable. When tls_process() returns, the stack descriptor disappears, but c2.to_link still contains the same allocation base, the offset of the formatted packet, and its length. That is legal only if every state transition that can destroy the key state first revokes the outer view. The connection context outlives individual TLS sessions, so scope alone cannot supply that guarantee.

free_buf() cannot revoke copies it does not know about. Clearing ks->ack_write_buf after free() prevents the owner from reusing its stale address, but it does not search the process for aggregate copies. The local buf and outer to_link are separate descriptors. Their pointer values remain unchanged. This is why the destructor can be locally correct while the program still has a use-after-free: the missing operation belongs at the transition where ownership ends, not inside a generic free routine that has no alias graph.

Offset handling reinforces the fix's design. OpenVPN keeps data as the allocation base and computes the content pointer with BPTR(). Wrapping an ACK may move offset as fields are prepended, but it does not turn data into an interior pointer. The repair can therefore compare to_link.data directly with each session-owned allocation base. A comparison of content pointers would have varied with headroom and wrapper mode; base equality captures the owner regardless of packet layout.

2 Three sessions share one scheduler pass

A VPN designed to run for days cannot tear down the data path every time it rotates keys. OpenVPN preserves continuity by allowing old, candidate, and retiring security state to overlap. The operator sees one tunnel. In memory, tls_multi resembles a small dock in which several session objects can advance during the same event-loop invocation. That overlap is the architectural reason output from an old owner can coexist with the transition of a successor.

2.1 TM_ACTIVE, TM_INITIAL, and TM_LAME_DUCK preserve continuity at different moments

ssl_common.h assigns three fixed indexes to tls_multi.session. TM_ACTIVE is index zero: it is the session slot currently being serviced, not a declaration that its occupant is fully trusted or permitted to carry tunnel data. A candidate whose configured certificate check has succeeded, when enabled, but whose username/password verification has failed can still be moved into this slot as semi-trusted; it may forward TLS control-channel data but not tunnel-channel data. TM_INITIAL is index one and holds a candidate being negotiated. TM_LAME_DUCK is index two and can retain an older session while useful key material completes its transition window. TM_SIZE is three, so the iteration order is not an implementation accident: active always precedes initial.

Each tls_session contains another fixed array. KS_PRIMARY is key-state index zero; KS_LAME_DUCK is index one; KS_SIZE is two. A session can therefore carry a primary key and one retiring key, while tls_multi can carry three sessions. The data-channel scan is deliberately narrower than all six theoretical slots. get_key_scan() returns the active session's primary key, its retiring key, and the old session's retiring key in the order in which data-channel code may use them.

Those two lame-duck concepts solve different handoff problems. During a normal soft reset, key_state_soft_reset() gives the current primary key an expiration, frees the previous retiring key, copies the primary descriptor into KS_LAME_DUCK, and initializes a fresh primary key. If the entire active session fails while its retiring key still has time left, the outer state machine can move the session into TM_LAME_DUCK. The first mechanism overlaps keys within a session. The second preserves an older session object after a broader failure.

Resource ownership follows those boundaries. A session owns control-channel wrapping contexts, IDs, certificate information, addresses, and its key-state array. A key state owns SSL/BIO objects, cipher state, reliable send and receive queues, pending and recent acknowledgments, plaintext work buffers, and ack_write_buf. Destroying a session walks both key-state slots and frees each resource. Any outer pointer borrowed from either key state has to be revoked before that walk begins.

Promotion does not merge selected fields into an existing active object. move_session(multi, TM_ACTIVE, TM_INITIAL, true) first calls tls_session_free() on the destination, then assigns the entire source session into that slot, then initializes a new source session. The candidate's pointers move as one ownership bundle. The previous active session is completely gone before the assignment. A view into the previous destination cannot become valid merely because a new session now occupies the same array index.

2.2 tls_multi_process() visits the slots first and chooses the active owner afterward

forward.c::check_tls() passes four important pieces of outer state to tls_multi_process(): the multi-session object, the address of c2.to_link, an output location for the remote address, and a wake-up deadline. The multi-session routine enters a for loop from zero to TM_SIZE - 1. For each session whose primary key is at least S_INITIAL and has a defined remote address, it calls tls_process() with the same outer output buffer.

Index zero gives the active session the first opportunity to write. If it owes an ACK, has no earlier control packet ready, and does not need the wrapped-client-key special path, the dedicated branch formats a P_ACK_V1 packet and assigns its descriptor into to_link. Index one then processes the candidate. Most output producers test to_link->len before writing, so a packet already prepared by the active session is not casually overwritten. The candidate can still consume input and advance its TLS state.

The routine does substantial work after the loop. It updates the aggregate authentication result, may generate data-channel keys for a fully authenticated active session, advances connect state, expires retiring material, and finally examines the candidate primary key. The condition near line 3459 in v2.6.21 is TLS_AUTHENTICATED(multi, &multi->session[TM_INITIAL].key[KS_PRIMARY]). When true, code calls the alias guard on TM_ACTIVE and immediately invokes move_session().

The reliable-send branch shows that developers had already recognized a local form of this hazard. When tls_process_state() assigns a retained reliable buffer to *to_link, it immediately returns false. Its comment says further processing could invalidate the key-state buffer and leave to_link referencing freed storage. The dedicated ACK is created later, after the main state loop, and lacks that local short circuit. Its owner can still disappear in the outer multi-session transition, a boundary the local function cannot enforce by itself.

Timing also has clear negative cases. If the candidate was promoted on the previous pass, the ACK belongs to the new active owner. If promotion waits until a later pass, the socket layer will normally consume and clear the old output first. If a reliable control packet can carry the ACK, its bytes live in send_reliable and the old guard recognizes that array. If control_packet_needs_wkc() is true, OpenVPN creates an empty reliable control packet for the ACK and wrapped client key; the dedicated scratch area remains unused. A useful regression suite must prove these safe branches stay safe.

A three-row warm-paper sequence: one scheduler pass first lends a dedicated ACK from the active session to the outer send cart, then advances the candidate through its promotion gate, and finally cuts the loan and empties the cart before the old session dock drops into destruction.
Swipe horizontally on narrow screens to inspect source details
Figure 1. The rows preserve the order inside one scheduler pass: the old dock lends its ACK to the outer cart, the candidate gate opens, and the fixed guard cuts the alias before the old dock leaves, so the cart cannot retain an ownerless packet.

2.3 TLS_AUTHENTICATED defines the TLS threshold for session promotion

This distinction changes the attack prerequisite. ssl_verify.h defines TLS_AUTHENTICATED(multi, ks) as ks->state >= (S_GOT_KEY - multi->opt.server). The accompanying comment says key-method 2 read/write and configured certificate verification have completed. Connect or deferred authentication may still be pending, and data-channel keys may not yet exist. In server mode, subtracting the server flag aligns the threshold with the server's reversed order of S_GOT_KEY and S_SENT_KEY.

User and plugin authentication live on a separate axis. enum ks_auth_state contains KS_AUTH_FALSE, KS_AUTH_DEFERRED, and KS_AUTH_TRUE; the source comment explicitly reserves “fully authenticated” for the last value. key_method_2_read() parses key material, options, username and password, and peer information. It invokes the configured scripts, plugins, or management interface and stores their result in ks->authenticated. A syntactically successful key-method exchange can advance the TLS state even when user authentication fails or remains deferred.

tls_authentication_status() proves the two concepts are intentionally separate. It scans keys that satisfy TLS_AUTHENTICATED, calls update_key_auth_status() to collect deferred results, and returns success only if it finds a key with KS_AUTH_TRUE. It can instead return failed or deferred. The promotion branch does not test that aggregate result. It tests the TLS state macro, moves the candidate into TM_ACTIVE, calculates authentication status again, and logs whether the promoted session is trusted or semi-trusted.

The comment above promotion is unusually explicit. A semi-trusted session may have passed certificate verification, when certificate verification is enabled, while username/password verification failed. It may forward data on the TLS control channel but not on the tunnel channel. This is a designed containment state. It also means a report that says “valid VPN user credentials are always required” narrows the source-defined reachability without evidence.

Actual prerequisites depend on configuration. A server that requires and rigorously validates client certificates will generally require a certificate it accepts before a candidate reaches the macro threshold. A deployment that makes client certificates optional or disables that verification and relies on username/password has a different boundary. tls-auth, tls-crypt, and tls-crypt-v2 can require additional outer control-channel material before the TLS exchange gets this far. Deferred plugins affect timing. None of these configuration choices changes the ownership failure after promotion; they change which remote parties can arrange the candidate state.

3 Following one control packet all the way to to_link

The data path becomes useful when every transition says which object changed and why the next function can see it. This chapter starts with a control packet entering the TLS pre-decrypt path and ends with an outer connection field containing a shallow pointer. Along the way, the packet's own ID becomes a pending acknowledgment, the acknowledgment becomes bytes in a key-state work area, and a pair of descriptor copies lets those bytes escape their owner's scope.

3.1 The inbound packet passes wrapper, session, packet-ID, and reliability checks before it leaves an ACK debt

tls_pre_decrypt() is one of the central entry points for received control traffic. It reads the header's opcode and key ID, identifies the session, selects the corresponding key state, and invokes the appropriate control-channel authentication or decryption path. Depending on configuration, read_control_auth() may verify an HMAC, decrypt a tls-crypt envelope, process packet-ID replay protection, or handle the special initial tls-crypt-v2 material. Failure at this layer stops the packet before it can influence the reliable state discussed here.

The control packet can itself contain acknowledgments for packets OpenVPN previously sent. reliable_ack_read() parses a one-byte count, reads each network-order packet ID, and, when the count is nonzero, verifies the embedded remote session ID against the expected session. reliable_send_purge() then searches the outgoing reliable array and deactivates entries whose IDs the peer acknowledged. This is the peer paying its debt to the server.

The opposite debt is recorded later. For a non-ACK control packet that fits the receive rules, OpenVPN evaluates ordering and replay, places payload into the reliable receive path as appropriate, and calls reliable_ack_acknowledge_packet_id(ks->rec_ack, id). The helper first searches the current list to avoid duplicates. If the ID is new and fewer than eight IDs are waiting, it appends the value and increments ack->len. No frame allocation occurs there.

OpenVPN deliberately acknowledges an acceptable replayed control packet as well. The peer may be replaying because the previous acknowledgment was lost; refusing to acknowledge the replay would keep both ends in a retransmission loop. This behavior is part of the recovery after the patched guard discards an unsafe ACK view. The peer still retains its reliable control packet and can retransmit it. The replay will cause another acknowledgment under the surviving session state.

Reliable packet-ID comparisons account for 32-bit wrap using a half-range ordering rule. Receive-window state, expected sequence, available reliable slots, session identity, and wrapper validation all constrain what becomes an ACK debt. A reproduction that mutates a packet ID at random may be rejected long before rec_ack. Source-level validation should either use a cooperating client that drives legal control traffic or instrument the state directly, then confirm that the exact active key_state has a nonzero rec_ack->len.

3.2 The dedicated ACK path borrows its work area only when the outer send slot is empty

tls_process() advances handshakes, reliable retransmission, TLS ciphertext and plaintext flow, key-method state, and timers. After its main state loop, it asks whether to_link is empty and whether rec_ack contains anything. In v2.6.21 the condition is !to_link->len && !reliable_ack_empty(ks->rec_ack). The first half preserves an earlier packet prepared during the same multi-session pass. The second confirms that this key state owes at least one acknowledgment.

The wrapped-client-key special case takes a safe storage route. If control_packet_needs_wkc(ks) is true, OpenVPN obtains a free output entry from send_reliable, marks it as a P_CONTROL_WKC_V1 packet, and lets the normal reliable path piggyback the ACK and wrapped key. The allocation then belongs to the reliable array, which the original session guard already scans. If no reliable entry is available, the routine returns and waits.

The ordinary dedicated branch is only five operational lines:

struct buffer buf = ks->ack_write_buf;
ASSERT(buf_init(&buf, multi->opt.frame.buf.headroom));
write_control_auth(session, ks, &buf, to_link_addr,
                   P_ACK_V1, RELIABLE_ACK_SIZE, false);
*to_link = buf;

The first line creates a borrowed descriptor. The second resets the local coordinates with enough headroom for wrapper fields. write_control_auth() serializes ACK records, constructs the control header, applies the configured wrapper, and sets an output pointer to ks->remote_addr. The final line publishes the descriptor. At that moment to_link.data == ks->ack_write_buf.data, while to_link.len describes a complete control packet.

Frame sizing makes this a normal, valid write. During TLS frame initialization, OpenVPN reserves pessimistic headroom and tailroom for SOCKS, control-channel HMAC or encryption, TCP length, opcode, up to eight ACK IDs, and the associated remote session ID. key_state_init() allocates ack_write_buf with the full frame buffer size. The assertion is expected to succeed. The use-after-free is independent of a capacity failure; the packet fits, and the address later loses its owner.

write_control_auth() starts with a header byte combining key ID and opcode. It includes a compatibility rule for unwrapped client mode that caps ACKs at four for SoftEther implementations known to reject more. It calls reliable_ack_write(), then tls_wrap_control(), and finally exposes the key state's saved remote address. These details can change packet length and appearance without changing the allocation base. The lifetime guard therefore compares data; opcode, length, and content cannot identify the owner.

The distinction between the member descriptor and local view can confuse debugging. buf_init() changes the local copy's offset and length; the member may still display a zero length. The bytes are nevertheless being written into the member's allocation. A breakpoint that watches only ks->ack_write_buf.len can miss the loan. A correct watch records the allocation base at key_state_init(), the local base before writing, and the outer base after assignment.

After publishing the ACK, tls_process() calculates the earliest wake-up for retransmission, negotiation deadline, and renegotiation. It does not call the link writer or transfer ownership into a ref-counted object. A nonzero to_link.len is the outer signal that output exists. The routine returns to tls_multi_process(), where the candidate-session promotion check still lies ahead.

3.3 reliable_ack_write() changes protocol state before the network has consumed the bytes

write_control_auth() calls reliable_ack_write(ks->rec_ack, ks->lru_acks, buf, &ks->session_id_remote, max_ack, prepend_ack). The implementation selects at most max_ack new IDs, copies them into the MRU history in an order-preserving manner, and calculates how many recent IDs fit. It writes the count and network-order IDs to a sub-buffer. If at least one ID is present, it also writes the remote session ID.

The operation then removes the newly selected entries from the front of rec_ack. The program has progressed from “we owe these IDs” to “we have prepared an acknowledgment containing recent IDs.” That state transition occurs before the socket write. Consequently, clearing to_link at promotion does not rewind every field to its pre-packet state. The MRU history remains, and the peer's retransmission behavior supplies another chance to acknowledge.

The safe recovery is therefore to discard the view. Copying the bytes would require deciding whether an ACK authenticated and addressed in the old session remains semantically valid after a successor takes over. Keeping the old session alive with a reference count would extend the lifetime of SSL state, queues, credentials, and timers for one asynchronous consumer. OpenVPN already has a reliability mechanism designed for a lost control packet. Clearing the output and allowing retransmission is the smallest change that restores memory safety without inventing a new ownership model.

The complete forward data path can now be stated without gaps. An accepted inbound control packet has ID id. tls_pre_decrypt() adds id to ks->rec_ack. tls_process() sees that list while to_link is empty. reliable_ack_write() emits the acknowledgment into ks->ack_write_buf.data. The local aggregate copy and *to_link = buf make c2.to_link.data equal that base. What remains is to prove that promotion frees that exact key state and that the forwarding loop later dereferences the same outer field.

4 One promotion turns a valid loan into a dangling pointer

A crash stack tends to compress a lifetime bug into the last line that touched bad memory. The more explanatory unit here is a ledger: allocator, owner, borrower, destructor, and final consumer. Each participant follows its local contract. The missing contract is the revocation of a borrowed descriptor when the owner leaves the multi-session tree.

4.1 A lifetime ledger puts the owner, alias, free, and consumer on one page

StageFunction and fieldObject changeSecurity meaning
Allocatekey_state_init()
ks->ack_write_buf
Allocate a frame-sized heap blockThe key state is the owner
Accumulatetls_pre_decrypt()
ks->rec_ack
Record control packet IDs owed to the peerNo outgoing bytes yet
Borrowtls_process()
*to_link = buf
Copy the descriptor and raw data pointerThe outer context has a non-owning view
Destroymove_session()tls_session_free()key_state_free()Free the active session's ACK blockEvery outer alias must be revoked first
Consumeprocess_outgoing_link()
c2.to_link
Inspect and write the pending packetAn uncleared alias is a UAF

The repair operates on address membership. check_session_buf_not_used() saves to_link->data as dataptr and asks whether that base belongs to any storage family in the session about to be destroyed. It does not need to parse the ACK or understand authentication. If the list of possible backing allocations is complete, exact equality identifies the owner. If one family is omitted, every borrowed view from that family can cross the transition.

This is a dynamic ownership contract chosen to minimize disruption. OpenVPN did not add owner metadata to the generic buffer type or convert every control packet to shared ownership. The destruction boundary maintains a set of session-owned allocations that may escape as to_link. That design is efficient and appropriate for an established C codebase, but it gives future reviews a clear obligation: every new session-backed output producer must update the set.

The address comparison uses the allocation base, not the active content pointer. Wrapper mode and headroom can change offset; BPTR(to_link) may therefore vary for the same allocation. data remains stable. Matching bases avoids a false negative when the ACK view begins after reserved headroom. The patch's simplicity depends on the buffer representation we traced in Chapter 1.

OpenVPN field and function lifetime ledger: a control packet ID enters rec_ack, reliable_ack_write serializes it into ack_write_buf, tls_process lends the pointer to to_link, session promotion frees the owner, and the network path later consumes the stale view.
Swipe horizontally on narrow screens to inspect source details
Figure 2. Green links carry valid state or a valid loan. The red segment begins when promotion destroys the owner while the outer descriptor still holds the same base address. The fixed guard cuts the chain before move_session().

4.2 move_session() destroys the destination before it adopts the candidate

At the promotion point, tls_multi_process() calls check_session_buf_not_used(to_link, &multi->session[TM_ACTIVE]), then move_session(multi, TM_ACTIVE, TM_INITIAL, true). The first substantive operation in move_session() is tls_session_free(&multi->session[dest], false). Only after the destination is fully torn down does the function assign multi->session[dest] = multi->session[src].

tls_session_free() releases both wrapping contexts and iterates across the two key-state slots. key_state_free() sets a key's state to S_UNDEF, releases its SSL/BIO and cryptographic contexts, then frees plaintext_read_buf, plaintext_write_buf, and ack_write_buf. It continues through queued payload, reliable objects, ACK structures, key source material, packet-ID state, and deferred-authentication files. The ACK allocation has an unambiguous free stack within ordinary cleanup.

free_buf() returns the heap block and clears the owner's descriptor. The outer aggregate copy is outside the session tree, so it remains unchanged. The candidate session is then shallow-assigned into the active slot as an ownership transfer, and a fresh source session is initialized. The stale view points neither to the new active session's ACK block nor to the reinitialized candidate slot. It points to the old destination's freed allocation.

This ordering rules out a superficially similar interpretation. The problem is not that overwriting the active struct loses a pointer and leaks memory. OpenVPN correctly frees the destination before overwriting it. The problem is an external borrower that the destructor does not know about. The original guard existed to bridge exactly that abstraction gap. Before the July fix, it checked tls_wrap.work, tls_wrap_reneg.work, and every send_reliable->array[j].buf.data, but not ack_write_buf.data.

4.3 The forwarding loop becomes the first consumer that cannot see the vanished owner

check_tls() calls tls_multi_process() with the address of the persistent c2.to_link field. On return, it handles reconnect, active, or kill status, schedules the next control-channel timeout, and updates DCO key state. It does not clone the packet bytes. The event planner sees a nonzero outgoing buffer and asks the link socket for write readiness, possibly after traffic-shaping delay or ordinary backpressure.

process_outgoing_link() first checks that to_link.len is positive and no larger than the frame payload. Both conditions can remain true after the free. It asserts a destination address, updates shaping and ping timers, may render the packet in debug logging, lets the SOCKS5 UDP path prepend a proxy header, and calls link_socket_write(). None of those layers has access to the former TLS session owner. They reasonably trust the connection-level buffer contract.

The first stale read can therefore occur before the operating-system write. A protocol dump in verbose logging may inspect bytes. A SOCKS helper may manipulate the buffer. The link writer may pass it to a system call. Optimization level and build flags change where an AddressSanitizer report points. A core whose top frame is a formatter or socket routine can still originate in the earlier move_session() free; investigators have to align allocation, destruction, and access because the final frame alone cannot classify the root cause.

The chain is now closed in both code and time: accepted control traffic creates an ACK debt; the dedicated path serializes it into key-state-owned storage; a shallow descriptor escapes; the candidate's TLS state triggers destruction of the active owner; and a later connection-level consumer reads the escaped address. The security fix has one precise place to intervene—after the loan exists and before move_session() begins.

5 Five schedulable states form the trigger window

“Well timed” can sound like a nearly impossible instruction-level race. The source shows a more concrete protocol schedule. All decisive operations are ordered inside the same multi-session call. A remote peer can influence when control packets arrive, when a candidate handshake advances, and when authentication-related messages are processed. The difficult part is aligning the dedicated ACK branch with the promotion threshold before the outer send slot is consumed.

5.1 Five necessary conditions define a reproducible experiment

  1. An active TLS session exists. Its primary key state can process control traffic and has a defined remote address.
  2. The active key state's rec_ack is nonempty. A control packet accepted by wrapper, session, packet-ID, and reliability checks can establish this state.
  3. No earlier control output occupies to_link in this pass. Otherwise the dedicated branch waits or the ACK is carried by another retained packet.
  4. The ACK takes the ordinary dedicated path. The wrapped-client-key special case must not redirect it into send_reliable.
  5. TM_INITIAL satisfies TLS_AUTHENTICATED during the same tls_multi_process() call. The tail promotion then guards and destroys TM_ACTIVE.

These conditions inhabit different objects. The ACK debt and dedicated output belong to the old active key. The promotion threshold belongs to the candidate primary key. to_link is the connection-level slot that joins them. A packet capture alone cannot prove all five. A high-quality test observes internal fields or uses conditional breakpoints and preserves enough protocol logs to map each field back to the same scheduler invocation.

Four instrumentation groups are sufficient. At the dedicated branch, record the active session and key addresses, rec_ack->len, ack_write_buf.data, local buf.data, and the resulting to_link. At promotion, record candidate state and authenticated, active-session identity, and output length. At key_state_free(), record the ACK base being freed. At process_outgoing_link(), record the final base and length. Equality across those points proves that one object completed the lifetime chain.

Success should be layered. The first vulnerable-baseline result is an invariant violation: after the active ACK allocation is freed, to_link.data still equals that allocation's former base. A sanitizer report during a later read is stronger runtime confirmation. A crash is only one possible symptom and is not required. For the fixed build, success means the guard identifies ack_write_buf, clears both len and data before destruction, and allows session processing to continue.

5.2 Transport, authentication configuration, wrappers, and allocators change the manifestation

UDP makes OpenVPN's own reliable layer most visible, but the UAF sits above the transport in TLS session scheduling and the outer output descriptor. TCP belongs in the validation matrix. It can change write readiness, buffering, and the time between preparation and consumption. SOCKS5 UDP can introduce a pre-send manipulation point. Observing the same address invariant in both transports, with different final access stacks, supports the source model.

Control wrappers change reachability and storage selection. Test the deployment's actual tls-auth, tls-crypt, or tls-crypt-v2 mode. In tls-crypt-v2, separately exercise the period when control_packet_needs_wkc() is true. That branch deliberately creates a reliable control packet and should be protected by the reliable-array checks, not the new ACK-member comparison. Treat it as a negative control, not as another route to the dedicated UAF.

Authentication needs a two-dimensional matrix. Candidate state tracks TLS/key-method progress; authenticated tracks user, plugin, management, and final policy outcome. At minimum, test a certificate-only server, certificate plus synchronous user authentication, optional or disabled client certificate with user authentication, and a production-equivalent deferred plugin or management flow. For each, use successful, failed, and deferred results where the configuration permits them.

The expected question in each cell is not “did login succeed?” It is “did the candidate reach TLS_AUTHENTICATED, what was ks->authenticated, and was it promoted as trusted or semi-trusted?” An outer wrapper or certificate rejection may stop one configuration before the threshold. Failed user credentials can still coexist with a source-defined semi-trusted promotion in another. Recording the exact rejection or promotion point gives asset owners an honest prerequisite for their configuration.

Network scheduling should be deliberate and bounded. A useful laboratory sequence advances the candidate to one state transition before the threshold, creates a legitimate ACK debt in the active session, then releases the candidate's remaining progress. Small controlled latency and loss can help place events. High-rate blind replay introduces connection-table pressure, HMAC state-exhaustion defenses, allocator pressure, and other failure modes that obscure the lifetime assertion.

The allocator governs visible consequences. AddressSanitizer quarantines freed memory and reports allocation, free, and access stacks. glibc, musl, Windows heaps, and appliance allocators have different size classes, reuse policies, and debug fills. Optimization can move the first actual read from a protocol dump to the socket writer. A normal build that survives one run has not disproved reachability if the address invariant was violated. Conversely, a crash without the invariant can belong to another July 2026 OpenVPN control-channel fix.

Boundary cases refine the test further. Vary rec_ack from one to eight IDs, include duplicates, approach packet-ID wrap, toggle the presence of a reliable packet that can piggyback ACKs, and vary frame headroom through supported wrapper modes. The only case expected to hit the new member check is a pending outer view whose base belongs to an ack_write_buf in the session about to be freed. Unrelated output must remain untouched.

5.3 The confirmed impact is a server heap UAF at a boundary service

OpenVPN's 2.6.21 and 2.7.5 release material identifies CVE-2026-12996 as a use-after-free in ack_write_buf triggered by a suitably timed sequence of control-channel and authentication packets. The three fix commits identify the same active-session ACK, same-call candidate promotion, and incomplete alias guard. The pinned source confirms that the stale pointer reaches a later network consumer in the server process.

The immediate consequence is a read from a freed heap allocation. Depending on build and allocator, that can produce a sanitizer finding, abnormal process termination, a control packet containing changed bytes, or a broader memory-safety condition. Public upstream evidence does not establish a portable code-execution chain, a stable heap-shaping method, or a network signature unique to exploitation. Incident records should preserve confirmed and hypothetical outcomes in separate structured evidence fields.

The operational priority remains high because OpenVPN daemons commonly sit on an internet or partner-network edge and carry many tunnels in one process. A remote party's exact prerequisites vary with wrapper and certificate policy; complete user authentication is not a universal requirement of the promotion macro. If the daemon exits, connections belonging to other users on the same instance can be interrupted. Active/standby health checks and automatic restarts shorten one outage but do not remove a repeatable trigger.

Prioritize assets using facts that can be measured. First, determine whether the running binary contains the fix. Then record control-port reachability, static control-key requirements, client-certificate policy, user/deferred authentication configuration, process privilege, connections per daemon, and failover behavior. The version decides whether the lifetime defect exists. The next controls determine who can reach the TLS threshold. The final values determine the service radius of a process failure.

6 The six-line repair completes an ownership set

The fix is short because OpenVPN already had the right transition boundary. In 2023 the project introduced check_session_buf_not_used() to prevent a prepared outer packet from outliving the TLS session that backed it. The July 2026 change does not invent a new packet validator or memory manager. It adds the omitted ACK work area to the set of allocations the guard recognizes.

6.1 The 2023 guard knew wrapper and reliable storage, but not the dedicated ACK area

Commit cd4d819c99266fa727c294225cafdb4ae331d02e introduced the session-buffer safeguard after a related scenario in which a packet had been scheduled for sending and the same session then hit an error. The guard takes the pending output and the session about to be destroyed. It returns immediately if to_link.data is null. Otherwise it compares the address with session-owned storage and jumps to one shared cleanup label on a match.

The original storage set included session->tls_wrap.work.data and every buffer in each defined key state's send_reliable array. A later dynamic tls-crypt repair added tls_wrap_reneg.work.data. The guard is called before resetting a session in error, before moving an active session into the old-session slot, and before a candidate usurps TM_ACTIVE. The call sites make the function a precondition of destruction, not a post-crash diagnostic.

ack_write_buf sits beside the plaintext work buffers in key_state, outside both TLS wrapper contexts and the reliable packet array. Its name suggests scratch storage local to ACK construction, while *to_link = buf gives it a longer reach. A review that begins with common outgoing queues can overlook it. A review that begins with every resource released by key_state_free() notices the member but must still discover which ones escape. The vulnerability occupies the gap between those two local views.

The July release also fixed CVE-2026-13117, involving the renegotiation wrapper work buffer and the same incomplete guard pattern. Its protocol path differs, but the ownership shape is identical: session-owned work storage backs an outer output, and a state transition can release the session before the consumer runs. Seeing both repairs together makes the correct audit unit clear: enumerate every allocation that can become to_link.data, then pair it with every session destructor reachable in the same scheduler pass.

History attribution needs care. Current blame for several lines of the dedicated ACK branch points to 2022 commit e7d8c4a72002.... Inspecting that diff shows the commit added the wrapped-client-key special case and placed an already-existing ACK branch inside else. The shallow assignment existed in its parent. Further blame in the local partial clone reaches a broad 2016 formatting change, but older objects are incomplete. The defensible history is that the loan predates 2022, the 2023 guard did not include it, and the 2026 patch completed the set. The exact original introduction requires a full earlier history.

6.2 One address comparison per valid key reuses the established cleanup exit

if (ks->ack_write_buf.data == dataptr)
{
    msg(M_INFO,
        "Warning buffer of freed TLS session is still in use "
        "(session->key[%d].ack_write_buf)", i);
    goto used;
}

Master commit 97f39e1e993ba2217623a5b4ea16f879b2fb6a1e inserts this check inside the existing loop over KS_SIZE. The release/2.6 equivalent is ea01c6544d00e5124ef26d6bc85d7663dfbb4a03; release/2.7 uses 5ee1f9b90fe03ecf7cef5431147ecaabbe96db9e. On a match, the message records the key index and control reaches the same used label that sets to_link->len = 0 and to_link->data = 0.

The condition uses strict base-address equality. Opcode, packet length, candidate authentication result, and current offset do not answer the ownership question. Length varies with wrappers. The candidate's authentication state belongs to a different session. Offset varies with headroom. The allocation base identifies whether the pending view is backed by the session that is about to disappear. With two key-state slots, the added cost is a handful of comparisons at a destruction boundary, not a reference-count operation on every packet.

Clearing both fields has two effects. A null data prevents helpers from reaching the old address. A zero len prevents event planning from treating the packet as pending output. The remote address can remain copied in the multi-session object without causing a write because the packet is empty. OpenVPN's control reliability will recover any lost acknowledgment. Promotion still occurs, so the fix does not disable rekeying or postpone ownership transfer.

The adjacent ownership graph also closes on the pinned v2.6.21 tree. Direct assignments to *to_link in ssl.c produce two backing families: retained reliable entries in tls_process_state() and ack_write_buf in the dedicated branch. The guard now covers both. Control wrapping can back output with tls_wrap.work or tls_wrap_reneg.work, also covered. The plaintext read and write buffers feed TLS and the reliable queues; they are not directly exported as the outer pending link buffer.

Calls to tls_session_free(), reset_session(), move_session(), and key_state_free() complete the other side of that graph. Error reset, active-session migration, and candidate promotion invoke the guard before destroying the relevant session. Lame-duck expiration looks different because it frees retiring state directly. Following the control output path shows that control packets are generated from the session's primary key; the expiring retiring key is not exported into to_link in that step. No other to_link UAF was confirmed in the fixed tree.

That result is bounded, not a permanent certificate of memory safety. Three rules deserve automation in future reviews. A new aggregate copy from session or key-state storage to an outer buffer must join the ownership set. A new session or key destruction point inside tls_multi_process() must prove pending aliases are revoked. A future path that exports an interior pointer as a new data base must revisit exact base comparison. Any match is first an audit hypothesis; it becomes a vulnerability only after reachability, lifetime, and security impact are demonstrated.

Before-and-after view of the OpenVPN session transition guard: wrapper work buffers and reliable array entries were already recognized; the fix adds each key state's ack_write_buf base and reuses the common to_link clearing path.
Swipe horizontally on narrow screens to inspect source details
Figure 3. The patch completes the set of session-owned allocations that can back a pending control packet. The cleanup behavior already existed; the missing member prevented it from running for dedicated ACKs.

6.3 Versions 2.6.21 and 2.7.5 are verifiable fixed floors

The source tree is pinned to the release commit behind annotated tag v2.6.21: 4dee614e4e799d6d4a20bffabd902291c86406f2, whose subject is “OpenVPN Release 2.6.21.” Function lines and structure layout refer to that tree. Branch-specific comparisons use the release/2.6, release/2.7, and master fix commits; a moving master file cannot substitute for a versioned source.

OpenVPN released 2.6.21 and 2.7.5 on July 1, 2026. Commit ancestry gives a sharp boundary. ea01c6544... is an ancestor of dereferenced tag v2.6.21 and is not an ancestor of v2.6.20. 5ee1f9b90... is an ancestor of v2.7.5 and is not an ancestor of v2.7.4. The adjacent release commits are 4dee614e..., 8e87f2c3..., b25bb2a8..., and 8e9e91f4..., making the boundary reproducible from a complete repository.

The master and release-line hashes differ because fixes were applied across branches. Checking only for 97f39e1e would misclassify correct maintenance releases. Conversely, a package built after July 1 is not automatically fixed. The relevant fact is whether its source includes an equivalent ack_write_buf.data == dataptr member test at every applicable session transition.

Linux distributions, appliances, and commercial products can backport the six lines while retaining an older upstream version string. Accept a backport with source-package patches, a vendor advisory that names the CVE, or equivalent build evidence. A Web console product version or a statement that a device was “hardened” is weak evidence. When source is unavailable, a fixed-build guard warning observed in an authorized laboratory and tied to a firmware hash can supplement the vendor record.

On July 15, 2026, OpenVPN's Supported versions page identified 2.7 as the current stable line and 2.6 as the previous stable line, with both under Full stable support at that time. Follow the operating-system or appliance vendor's support matrix; this CVE does not by itself require moving a repaired 2.6 deployment to 2.7. The acceptance criterion is a running build with the equivalent guard, and the upstream support page should be checked again when deployment occurs because branch status can change.

Installed and running versions must be separate asset fields. A long-lived daemon can continue executing an old mapped image after package replacement. A container tag can be updated in a registry while existing Pods retain the former image ID. A high-availability standby can preserve vulnerable firmware until failover. Closure requires the running process, image digest, or firmware build to match the fixed evidence after a controlled restart.

A warm-paper evidence bench with two release rails: each old build passes through its own branch repair press and reaches a new build with a teal status lamp, while a source tree, patch seal, running chassis, and vendor backport crate form one magnified verification chain below.
Swipe horizontally on narrow screens for version evidence
Figure 4. Versions 2.6.21 and 2.7.5 have separate repair rails. A vendor backport must likewise connect source, the equivalent guard, the shipped build, and the running image into reproducible evidence instead of relying on the visible version alone.

7 Retiring the vulnerable code without weakening the tunnel

The end of the story is not the merge commit. It is the moment every production daemon has stopped executing the old path, historical failures have been preserved at an appropriate evidentiary level, and regression testing has replayed the ownership transition without harming authentication or reliable control traffic. OpenVPN does not need to disable renegotiation, weaken TLS, or abandon its scratch-buffer design. Because the software appears as a directly installed server, firewall feature, cloud image, container, site-to-site component, or embedded library, closure begins with the running process and ends with evidence tied to that process.

7.1 Inventory must reach the daemon, source branch, and actual control boundary

Start with internet remote-access gateways, partner and site-to-site concentrators, firewall and router VPN services, cloud marketplace images, managed VPN nodes, Docker and Kubernetes workloads, and third-party products that embed the Community daemon. Desktop clients and NetworkManager plugins can be recorded separately; the confirmed chain is the server multi-session path. A machine with a client package installed is not equivalent to a daemon listening for control traffic.

Discover from execution. On Linux, map service units, containers, command lines, --config arguments, listening sockets, and /proc/<pid>/exe. In Kubernetes, connect Deployments or DaemonSets to running Pod image IDs, mounted configurations, Secrets references, and restart times. On appliances, combine the administrative VPN configuration with firmware component lists and support-bundle process data. On Windows, record service path, signed file version, process start time, and listener.

Each asset record needs fields that can be accepted or rejected: unique asset ID; business and technical owner; listener and reachable network zones; UDP or TCP transport; control wrapper; client-certificate policy; user, plugin, or management authentication; upstream branch or vendor line; full package or firmware build; binary SHA-256 or image digest; running start time; high-availability role; connection count; planned change window; and links to source or vendor evidence. Unknown values receive an owner and due date; blank cells are rejected.

Keep three trust layers separate. The first is network reachability and rate control. The second is the material required by tls-auth, tls-crypt, or tls-crypt-v2. The third is TLS client-certificate and user/deferred authentication policy. Record actual values such as verify-client-cert, certificate constraints, auth-user-pass-verify, plugins, management authentication, and token settings. An “MFA enabled” checkbox cannot describe whether an unauthenticated or semi-trusted candidate can reach the promotion macro.

Classify version evidence as fixed, affected, or insufficient. Native 2.6 builds at or above 2.6.21 and native 2.7 builds at or above 2.7.5 are fixed when the running image matches. A lower version without backport evidence is affected. An opaque vendor build, a staged package without restart evidence, or an unsupported sales statement is insufficient. Insufficient evidence remains in a vendor-question, laboratory-validation, or temporary-isolation queue; it does not silently become low risk.

Backports require a source-level anchor. Prefer a vendor patch showing the new member comparison, a source-package changelog that names CVE-2026-12996, or a commit relationship in the vendor tree. For statically linked appliance daemons, bind the result to the exact firmware hash and hardware model. Regional or hardware variants can ship different embedded code under the same product version. An authorized behavioral check can support provenance, but it should not replace an available vendor source package.

Roll out around service continuity. Confirm spare capacity and failover health. Drain one node or remove it from the load balancer. Install the fixed build, fully restart the daemon, verify the mapped executable or image, establish a test tunnel, pass traffic in both directions, force one supported renegotiation, exercise the production authentication backend, inspect logs, and return the node only after monitoring is healthy. Upgrade the peer in a separate change window.

The change ticket needs observable acceptance criteria. The node drains as designed. The new process hash and version match approved evidence. Route and DNS pushes arrive. Bidirectional traffic remains stable. Renegotiation completes. Synchronous or deferred authentication returns the expected result. There are no new fatal messages or sustained guard warnings. Health checks recover. Rollback artifacts must themselves contain the equivalent fix; a business rollback to a known vulnerable binary should trigger isolation or capacity alternatives instead of automatic re-exposure.

Temporary controls can reduce exposure while an appliance vendor prepares firmware: restrict the control port to known networks, strengthen outer control-key requirements where operationally possible, lower the number of tunnels sharing one process, improve crash capture, and ensure fixed peers have failover capacity. They are compensating measures. The lifetime error remains in the binary until the session guard recognizes ack_write_buf.

A warm-paper gateway rollout: one node drains behind a gate while its peer carries the teal traffic ribbon, the opened chassis receives a teal buffer spool and a looped validation run, and two healthy nodes rejoin the ribbon beside a sealed evidence tray.
Swipe horizontally on narrow screens for the rollout closure
Figure 5. Drain one node at a time, prove that the fixed build is running and that a test tunnel, bidirectional traffic, and renegotiation pass, then return it to the cluster with version, process, and regression evidence attached to the change.

7.2 Historical investigation rests on cores, guard logs, and session timing

If a vulnerable daemon exits near renegotiation, key-method exchange, or authentication state changes, preserve the core dump, OpenVPN logs, service-manager restart record, binary provenance, configuration snapshot, and connection timeline. Search stacks for tls_multi_process, move_session, tls_session_free, key_state_free, free_buf, and process_outgoing_link. The final access can be in a logger, SOCKS helper, or socket writer, so absence of move_session() at the top does not exclude the chain.

Preservation order matters on automatically recovering infrastructure. Record UTC and local time, node ID, PID, process start, service status, mapped binary hash, and failover state. Copy the core, journal or event log, container termination record, daemon log, firmware or image identity, and relevant configuration before repeated restart experiments. Hash each artifact. Protect private keys, tokens, usernames, and tunnel metadata under the incident's evidence and privacy controls.

A strong core analysis restores three segments. The allocation segment ties the block to key_state_init() and a specific ack_write_buf. The destruction segment ties that key state to the active session and candidate promotion or another guarded transition. The access segment ties c2.to_link.data and length to the same freed base. If allocator metadata survives, check block size against the frame allocation and whether another object reused the slot.

Fixed builds can emit “buffer of freed TLS session is still in use” and name session->key[i].ack_write_buf. That message is unusually valuable: it proves the outer output was backed by a session about to be freed and that the guard prevented the stale access. A single hit can occur through ordinary loss, replay, and renegotiation timing. Aggregate by node, remote endpoint, certificate, account where available, connection age, authentication result, and repetition before deciding whether the sequence was intentional.

The absence of that string on a vulnerable build has no exculpatory value; the old code did not have the member check. After upgrading, a small number of guard hits can help search backward for similar windows in earlier crashes. Encrypted control traffic limits packet inspection. Use captures to establish tuple, direction, length, and timing, then let endpoint state and process evidence carry the attribution.

Use four conclusion levels. Confirmed means allocation, free, and later access have been tied to one address or an equivalent sanitizer trace. Highly consistent means vulnerable build, session timing, and access stack agree while one allocation segment is missing. Indeterminate means only a temporally related exit is available. Unrelated means another root cause explains the failure. Store supporting and missing evidence for each level; do not turn every TLS error into this CVE or treat the absence of a public exploit as exclusion.

Preserve original artifacts under the case retention policy and work from analysis copies. A final timeline should include process start, connection establishment, candidate key-method progress, authentication state, ACK-producing control activity, promotion or suspected promotion, process exit, automatic recovery, and fixed-build deployment. This makes later incidents comparable even when the first case cannot reach confirmed status.

7.3 Regression must prove the loan is revoked before the owner leaves

Build validation from a fixed tag or vendor source package with debug symbols. Use an AddressSanitizer configuration for allocation/free/access stacks and an optimized configuration close to production for protocol reachability. Keep all certificates, static control keys, users, and plugins inside an isolated laboratory. The test advances protocol state and checks ownership; it does not need to control recycled heap contents or produce an exploit.

Run a vulnerable baseline and fixed candidate as a pair. Prepare an active session with a nonempty rec_ack and no earlier output. Advance a candidate to one transition before TLS_AUTHENTICATED. Let the active session execute the dedicated branch and assert to_link.data == active.key[i].ack_write_buf.data. Advance the candidate. In the vulnerable build, record that destruction leaves the outer base unchanged. In the fixed build, require the guard to zero length and data before tls_session_free().

The fixed test has three positive requirements. It logs the correct ack_write_buf owner and key index. It still completes move_session(), so rekeying remains functional. The forwarding layer never reads the old base. Then observe that reliable control processing recovers through later ACK or retransmission and that the tunnel remains usable. “No crash” alone is insufficient; disabling promotion or swallowing all control traffic would also avoid a crash while breaking the VPN.

Cover UDP and TCP, the production wrapper mode, successful and failed synchronous user authentication, deferred plugin or management authentication, primary and retiring key states, normal renegotiation, controlled loss, and retransmission. Record both candidate state and authenticated. The patch must not turn a failed identity into success, generate data-channel keys early, or bypass a deferred decision. It changes only whether a doomed session may remain the backing owner of pending output.

Negative cases are equally important. Without promotion, a dedicated ACK must still be formatted and sent. A send_reliable packet must continue to match the reliable-array guard. Wrapper work buffers must match their existing checks. A to_link backed by another live session or connection buffer must not be cleared. The WKC special path must use reliable storage. Duplicate ACK IDs, one through eight pending IDs, packet-ID wrap boundaries, and supported headroom variations should not alter the ownership rule.

Measure performance at transitions and in steady state, with transition metrics emphasized. The new work is an address comparison for each of two key slots when a session is being destroyed; normal packet sends do not acquire a new reference count. Under high renegotiation rates and many connections, monitor CPU, event-loop latency, guard-message volume, and connection continuity. If legitimate hits create excessive logs, retain the safety clearing and consider aggregation or rate limiting at the logging layer.

Production acceptance should include a canary that can be quickly drained. Observe at least one normal renegotiation interval and one business peak. Monitor process exits, guard warnings, reconnect rate, authentication latency, tunnel loss, HA transitions, and resource use. Recheck the running binary or image after automation has had time to reconcile the deployment; an unnoticed rollback can reintroduce the vulnerable path.

Closure requires direct fixed-build evidence for every server, an isolation or vendor plan for every insufficient-evidence asset, a graded conclusion for historical failures, regression traces proving alias revocation without authentication or reliability regression, and monitoring for both guard hits and abnormal exits. At that point the small ACK can keep its efficient borrowed path, the candidate can still take over seamlessly, and the reliable control layer can recover a dropped acknowledgment. What can no longer happen is an old session leaving the stage while the event loop still treats its memory as a packet waiting at the door.

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.

CVECVE-2026-12996

OpenVPN server TLS control-channel dedicated ACK buffer use-after-free

Fixed releasesOpenVPN 2.6.21 / 2.7.5

Verifiable upstream fixed floors for both maintained lines

2.6 fix commitea01c6544d00e5124ef26d6bc85d7663dfbb4a03

release/2.6 adds ack_write_buf to the transition guard

2.7 fix commit5ee1f9b90fe03ecf7cef5431147ecaabbe96db9e

Equivalent release/2.7 repair

Master fix commit97f39e1e993ba2217623a5b4ea16f879b2fb6a1e

Master-line ownership member check

Data pathrec_ack → ack_write_buf.data → to_link.data

Pending ID to dedicated storage to outer borrowed view

Destruction chainmove_session() → tls_session_free() → key_state_free() → free_buf()

Promotion ends the active ACK owner's lifetime

Host logsession->key[i].ack_write_buf

Fixed-build warning naming the discarded alias owner

8.2Event chronology

  1. Session buffer guard enters master

    Commit cd4d819c9 adds an outer-output alias check before TLS session destruction.

  2. ACK member repair authored

    The dedicated ACK allocation is added to the existing owner set after independent reports.

  3. The 2.6 maintenance line receives the fix

    release/2.6 takes the equivalent patch as ea01c6544.

  4. OpenVPN 2.6.21 and 2.7.5 released

    Both fixed versions ship with CVE-2026-12996 in the security changes.

  5. SOSEC source reconstruction completed

    The inbound ACK, shallow alias, promotion, destructor, authentication boundary, and final network consumer are connected in a pinned tree.

8.3Sources and material

  1. OpenVPN Community Downloads: 2.6.21 and 2.7.5 release recordshttps://community.openvpn.net/Downloads
  2. OpenVPN Community security announcement indexhttps://community.openvpn.net/Security%20Announcements
  3. OpenVPN 2.6.21 Changes.rst security noteshttps://github.com/OpenVPN/openvpn/blob/v2.6.21/Changes.rst
  4. Master repair 97f39e1e993bhttps://github.com/OpenVPN/openvpn/commit/97f39e1e993ba2217623a5b4ea16f879b2fb6a1e
  5. release/2.6 repair ea01c6544https://github.com/OpenVPN/openvpn/commit/ea01c6544d00e5124ef26d6bc85d7663dfbb4a03
  6. release/2.7 repair 5ee1f9b90https://github.com/OpenVPN/openvpn/commit/5ee1f9b90fe03ecf7cef5431147ecaabbe96db9e
  7. v2.6.21 buffer.h: struct buffer fields and content coordinateshttps://github.com/OpenVPN/openvpn/blob/v2.6.21/src/openvpn/buffer.h#L50-L74
  8. v2.6.21 buffer.c: alloc_buf and clone_buf copy semanticshttps://github.com/OpenVPN/openvpn/blob/v2.6.21/src/openvpn/buffer.c#L58-L129
  9. v2.6.21 buffer.c: buf_assign and free_bufhttps://github.com/OpenVPN/openvpn/blob/v2.6.21/src/openvpn/buffer.c#L172-L187
  10. v2.6.21 ssl_common.h: KS_AUTH_FALSE, DEFERRED, and TRUEhttps://github.com/OpenVPN/openvpn/blob/v2.6.21/src/openvpn/ssl_common.h#L139-L155
  11. v2.6.21 ssl_common.h: key_state and ack_write_buf ownershiphttps://github.com/OpenVPN/openvpn/blob/v2.6.21/src/openvpn/ssl_common.h#L198-L239
  12. v2.6.21 ssl_common.h: key-state and multi-session slot indexeshttps://github.com/OpenVPN/openvpn/blob/v2.6.21/src/openvpn/ssl_common.h#L440-L535
  13. v2.6.21 ssl_verify.h: full authentication status and TLS_AUTHENTICATEDhttps://github.com/OpenVPN/openvpn/blob/v2.6.21/src/openvpn/ssl_verify.h#L68-L109
  14. v2.6.21 ssl_verify.c: deferred-auth updates and aggregate statushttps://github.com/OpenVPN/openvpn/blob/v2.6.21/src/openvpn/ssl_verify.c#L1086-L1227
  15. v2.6.21 ssl.c: key-state allocation and destructionhttps://github.com/OpenVPN/openvpn/blob/v2.6.21/src/openvpn/ssl.c#L974-L1094
  16. v2.6.21 ssl.c: session destruction, move, and resethttps://github.com/OpenVPN/openvpn/blob/v2.6.21/src/openvpn/ssl.c#L1215-L1274
  17. v2.6.21 ssl.c: key_method_2_read and independent user-authentication statehttps://github.com/OpenVPN/openvpn/blob/v2.6.21/src/openvpn/ssl.c#L2371-L2557
  18. v2.6.21 ssl.c: reliable output and TLS state progressionhttps://github.com/OpenVPN/openvpn/blob/v2.6.21/src/openvpn/ssl.c#L2898-L3038
  19. v2.6.21 ssl.c: dedicated ACK export and session buffer guardhttps://github.com/OpenVPN/openvpn/blob/v2.6.21/src/openvpn/ssl.c#L3083-L3292
  20. v2.6.21 ssl.c: multi-session iteration and candidate promotionhttps://github.com/OpenVPN/openvpn/blob/v2.6.21/src/openvpn/ssl.c#L3302-L3472
  21. v2.6.21 ssl.c: inbound control packets and rec_ackhttps://github.com/OpenVPN/openvpn/blob/v2.6.21/src/openvpn/ssl.c#L3676-L4051
  22. v2.6.21 ssl_pkt.c: write_control_authhttps://github.com/OpenVPN/openvpn/blob/v2.6.21/src/openvpn/ssl_pkt.c#L167-L197
  23. v2.6.21 reliable.c: ACK accumulation, MRU history, and serializationhttps://github.com/OpenVPN/openvpn/blob/v2.6.21/src/openvpn/reliable.c#L130-L310
  24. v2.6.21 forward.c: check_tls lends c2.to_link to the TLS schedulerhttps://github.com/OpenVPN/openvpn/blob/v2.6.21/src/openvpn/forward.c#L166-L231
  25. v2.6.21 forward.c: process_outgoing_link consumes the viewhttps://github.com/OpenVPN/openvpn/blob/v2.6.21/src/openvpn/forward.c#L1767-L1844
  26. 2023 session-buffer guard commit cd4d819c9https://github.com/OpenVPN/openvpn/commit/cd4d819c99266fa727c294225cafdb4ae331d02e
  27. 2022 WKC special-case commit showing the ACK branch already existedhttps://github.com/OpenVPN/openvpn/commit/e7d8c4a72002cbaa7542ea0cff8acca1b971b1f5
  28. OpenVPN Community supported versionshttps://community.openvpn.net/Pages/Supported%20versions
  29. CVE-2026-12996 recordhttps://www.cve.org/CVERecord?id=CVE-2026-12996