漏洞

OpenVPN 会话晋升留下了一个已释放的 ACK 指针(CVE-2026-12996

CVE-2026-12996 发生在 OpenVPN 服务端一次 TLS 多会话调度中:活动会话先把独立 ACK 的浅指针交给外层发送槽,候选会话随后达到 TLS 晋升门槛并释放旧会话,事件循环继续读取已经失效的 ack_write_buf ,形成可由远端协议时序触发的堆释放后使用。

暖纸手绘的 OpenVPN 会话晋升前后对照:上图中两条隧道让 ACK 信封系在坍塌的借用托盘上;下图先清空借用路径,再让退役隧道消失。
文章导航

研究依据SOSEC 安全研究 · 基于本地固定的 OpenVPN v2.6.21 源码树、三个发布分支修复提交与 2026 年 7 月上游发布材料复核 · 最后修订 2026 年 7 月 15 日

来源OpenVPN 2.6.21 源码 / 2.6、2.7 与 master 修复提交 / OpenVPN 2026 年 7 月发布材料 / SOSEC 源码复核

1 一枚还没有离开进程的 ACK

故事从一枚很小的确认包开始。它已经完成格式化,远端地址也已经确定,OpenVPN 的上层状态机把它视为下一次 socket 可写时就能发出的控制报文;可在真正调用 send()sendto() 之前,保存这枚报文字节的会话被另一条正常的认证路径淘汰了。外层仍握着一个看起来完整的 struct buffer:容量、偏移、长度都在,只有最关键的 data 已经指向释放后的堆内存。CVE-2026-12996 的危险没有发生在复杂密码学里,而发生在“报文已准备好”和“报文已发送”之间那段通常只有一次事件循环宽度的缝隙中。

这个缝隙之所以值得逐行拆开,是因为参与其中的每个局部动作单看都合理。控制信道收到报文就应记下一份 ACK;没有其他控制报文可以顺带携带 ACK 时,单独组包可以降低重传等待;新 TLS 会话完成 key-method 交换并通过所配置的证书验证后应接替旧会话;会话接替时应释放旧密钥、BIO 与工作缓冲区;网络层稍后再消费 c2.to_link 也是 OpenVPN 事件驱动架构的一部分。错误发生在这些动作共享了一份未表达所有权的指针;任何单个函数表面上都完成了自己的职责。

上游修复提交把触发条件概括为:活动 TLS 会话存在待发送的独立 ACK,并且在同一次 tls_multi_process() 执行中,初始化会话抵达 TLS 认证门槛。这个表述已经勾勒出主线,问题只剩两个:独立 ACK 的字节究竟由谁拥有;初始化会话的晋升为什么能在外层发送之前结束这个所有者。等两条线在 to_link.data 上汇合,补丁为何只需要六行、为何选择丢弃这一枚 ACK、为何没有复制或引入引用计数,也会自然得到答案。

1.1 数据隧道之外,控制信道还维护着自己的可靠顺序

OpenVPN 的数据通道负责承载用户流量,TLS 控制信道则负责握手、认证、参数推送、密钥更新和会话维护。两者共享一条底层链路,却有不同的状态与可靠性要求。即使底层是 UDP,控制信道也不能把证书片段或密钥协商消息当成可以任意丢失的数据报;OpenVPN 因此为控制报文分配包序号,把未确认的外发报文留在 send_reliable,把已收到但尚待按序交付的报文放进 rec_reliable,并在 rec_ack 中记录需要回送的包序号。

这套设计解释了 ACK 为何既常见又短命。新的控制报文到达后,tls_pre_decrypt() 验证控制包、读取包序号、处理重放和接收顺序,随后调用 reliable_ack_acknowledge_packet_id(ks->rec_ack, id)。此时系统只是把一个序号加入“欠对端的确认”集合,并没有立刻进行网络写入。下一次 TLS 调度中,如果可靠发送队列本来就有控制报文,ACK 可以附着在那份报文上;如果没有,tls_process() 才构造 P_ACK_V1 独立报文。漏洞只经过后一条路径,因为附着路径的底层存储属于可靠发送数组,原有会话销毁保护已经认识这类缓冲区。

这里还有一个容易忽略的可靠性细节。write_control_auth() 调用 reliable_ack_write() 时,新的 ACK 序号会先被复制进 lru_acks,再写入输出缓冲区,随后从当前 rec_ack 中移除。也就是说,补丁在会话切换前清空已经组装好的 to_link,并不等同于把协议状态粗暴遗忘。最近的 ACK 仍进入 MRU 记录,控制信道还有重传、后续捎带和新会话状态推进机制。安全修复让一个已经失去合法后端存储的视图退出发送流程;继续保留过期指针,只会以内存安全换取表面上的“绝不丢包”。

1.2 struct buffer 看起来像报文,实际上只是一组坐标

OpenVPN 的 buffer.hstruct buffer 定义得很直接:capacity 表示分配容量,offset 指向有效内容起点,len 表示有效长度,uint8_t *data 指向堆内存。结构体没有析构函数、引用计数、所有者字段或共享指针语义。buf_bptr() 所看到的内容起点只是 data + offset。因此,C 语言赋值 struct buffer buf = ks->ack_write_buf 会复制四个字段,不会复制 data 背后的字节。

key_state_init() 在初始化每个 key state 时用 alloc_buf(BUF_SIZE(&session->opt->frame)) 创建 ack_write_buf。同一函数还创建明文读写缓冲区、可靠发送与接收对象、待确认集合等资源。对称的 key_state_free() 依次释放 TLS/BIO、双向密钥上下文、三个工作缓冲区、可靠对象和 ACK 集合,其中 free_buf(&ks->ack_write_buf) 是这块字节内存清晰且唯一的生命周期终点。所有权从来没有转移给 to_link

继续进入 buffer.c,分配与复制的差异会变得不可混淆。alloc_buf() 先验证尺寸,把 capacity 设为请求值、把 offsetlen 归零,再用 calloc(1, size) 建立一块新的后端存储。项目若需要真正的深复制,已经提供 clone_buf():它重新 malloc(),保留描述符坐标,并用 memcpy() 复制有效区。独立 ACK 分支没有调用 clone_buf()buf_assign(),源码直接做结构体赋值;因此“这是浅借用”不是依据命名作出的猜测,而是分配调用与赋值语义共同给出的结论。

free_buf() 的两行实现同样重要:先 free(buf->data),随后 CLEAR(*buf)。清零的是传入的所有者描述符,不是整个进程中保存相同地址的每一份副本。ks->ack_write_buf.data 会变成空指针,早先复制到局部 buf、再复制到 c2.to_link 的地址不会收到通知。C 的普通指针没有反向引用表,分配器也不会遍历业务对象撤销别名。正因为析构函数在自己的视角里做得完整,这类缺陷才必须在状态转换边界显式维护借用关系。

偏移也不会削弱地址比较的有效性。OpenVPN 用 offset 表达有效报文在分配块中的起点,BPTR(buf) 才计算 data + offset;结构体中的 data 仍保留分配基址。write_control_auth() 可能在预留 headroom 中前置 opcode、会话 ID、ACK 列表和控制信道认证材料,却不会把 data 改成内部指针。修复因此比较 ack_write_buf.datato_link.data,而不必推测某个动态偏移。若比较的是 BPTR(),不同包装方式反而会让同一所有者产生多个表面地址。

于是,这枚 ACK 在代码中的真实身份可以写成一句非常具体的话:to_link 是会跨过 tls_multi_process() 返回边界的借用视图,ack_write_buf 是隶属于某个 key_state 的所有者。借用本身并不错误;OpenVPN 的热路径大量依赖缓冲区视图来避免复制。需要维护的约束是,任何可能销毁 key state 的状态转换,都必须先证明外层没有继续借用它的 data。CVE-2026-12996 正是这条约束在一个成员上失效,而多个会话共用一次调度,恰好给这种交叉提供了时间窗口。

2 三个会话共用一次调度

长期运行的 VPN 不能为了更新密钥而先切断数据,再从零开始建立连接。OpenVPN 的解决办法是让旧会话、候选会话和退役密钥短暂并存。在稳定状态下,运营人员看到的仍是一条持续的隧道;在内存里,tls_multi 却像一座有三个泊位的船坞,事件循环每次进入都可能推进多个泊位的状态。这个为连续性而设计的重叠,也是漏洞能够把“旧会话的输出”与“新会话越过 TLS 交换门槛”放进同一次函数调用的前提。

2.1 TM_ACTIVETM_INITIALTM_LAME_DUCK 各自解决一个连续性问题

ssl_common.htls_multi.session 定义三个固定索引。TM_ACTIVE 是当前被调度服务的会话槽位;这个名称不表示其中对象已经完整受信任,也不表示它必然可以承载隧道数据。若所配置的证书校验已经通过(启用时)、但用户名密码校验失败,候选会话仍可作为 semi-trusted session 被移入该槽;它只能在 TLS 控制信道上传递数据,不能在隧道信道上传递数据。TM_INITIAL 保存正在协商的候选会话;TM_LAME_DUCK 暂存仍处在过渡窗口内的旧密钥,使数据通道在重协商失败或密钥交接期间不必立即中断。每个 tls_session 又包含自己的 key state 数组,每个 key state 持有独立的 TLS 对象、工作缓冲区、可靠层状态和认证状态。

数组常量把嵌套关系固定得很具体:TM_SIZE 是 3,索引依次为 0、1、2;每个会话里的 KS_SIZE 是 2,KS_PRIMARY 为 0,KS_LAME_DUCK 为 1。于是一个 tls_multi 最多同时容纳六个 key-state 槽位,但数据通道扫描函数 get_key_scan() 只按安全顺序返回三个可能解密数据的 key:活动会话主 key、活动会话退役 key,以及旧会话的退役 key。控制通道调度则按三个 session 索引遍历。理解这两套扫描顺序,可以避免把“session 的 lame-duck 槽”和“session 内部的 lame-duck key”混成同一个对象。

双层退役机制服务于不同故障时刻。正常软重协商发生在一个 session 内:key_state_soft_reset() 先给主 key 设置 must_die,释放原来的退役 key,把主 key 结构体移动到 KS_LAME_DUCK,再为 KS_PRIMARY 建立新状态。若整个活动 session 在新 key 交接期间出错,而内部退役 key 仍可用,tls_multi_process() 才把整个 session 移到 TM_LAME_DUCK。这使数据通道可以继续接受过渡期报文,也意味着释放审查必须辨明究竟是哪一层对象即将退场。

这里同时存在 session 与 key state 两层对象。会话负责控制包包装、会话 ID、远端地址、证书信息和 key state 数组;key state 负责具体密钥 ID、SSL/BIO、收发可靠队列、rec_acklru_acksack_write_buf。当 TM_INITIAL 达到 TLS_AUTHENTICATED 所表达的 TLS 交换门槛后,OpenVPN 调用 move_session(multi, TM_ACTIVE, TM_INITIAL, true):先释放目的槽位中的活动会话,再用结构体赋值把候选会话放入活动槽位,最后重新初始化来源槽位。任何借自旧目的槽位的指针,都必须在第一步之前解除。

2.2 tls_multi_process() 先遍历三个槽位,最后才决定谁接管活动位置

forward.c::check_tls() 把全局上下文中的 &c->c2.to_link、远端地址输出指针和唤醒时间传给 tls_multi_process()。后者从索引零开始遍历 TM_SIZE 个会话。对每个状态至少为 S_INITIAL 且远端地址有效的会话,它调用 tls_process()。由于 TM_ACTIVE 的索引是零、TM_INITIAL 的索引是一,活动会话总能先有机会把报文写入同一个 to_link,候选会话随后继续推进握手和认证。

遍历结束不代表函数结束。代码更新认证总体状态,处理数据通道密钥生成、延迟认证状态、lame-duck 到期,然后才检查 TLS_AUTHENTICATED(multi, &multi->session[TM_INITIAL].key[KS_PRIMARY])。一旦成立,函数在第 3461 行附近调用会话缓冲区守卫,并在下一行执行 move_session()。所以触发窗口不是线程在任意时刻抢占另一个线程,也不依赖一个神秘的异步回调;它是同一调用栈里确定的先后顺序:活动槽先生成输出,循环继续,候选槽完成 key-method 交换并达到门槛,循环尾部释放活动槽。

这种顺序也解释了“精确时序”真正意味着什么。攻击侧需要让活动会话在这一轮欠有独立 ACK,同时让候选会话在这一轮满足 TLS_AUTHENTICATED。如果候选会话上一轮就晋升,ACK 属于新所有者;如果下一轮才晋升,旧 ACK 往往已经在 socket 可写事件中被消费;如果活动会话有其他可靠控制报文可捎带 ACK,输出后端会落在 send_reliable 而非专用工作区。漏洞需要几个合法状态相交,却不要求破坏 TLS 加密或伪造任意内存地址。

源码中的短路也帮助界定反例。可靠发送分支在执行 *to_link = b 后立刻返回 false,注释直接说明这是为了避免继续运行后释放刚刚外借的 key-state buffer;独立 ACK 分支位于主状态循环结束后,没有同样的短路,因为通常它之后只计算唤醒时间。跨 session 的晋升逻辑位于更外层,局部函数无法看见。这个不对称揭示了缺陷的形成方式:开发者已经防住同一 key-state 函数内的释放,却没有把独立 ACK 新形成的借用完整传达给多会话销毁守卫。

三排暖纸手绘时序:同一轮调度先由活动会话把一枚独立 ACK 借给外层发送车,候选会话随后越过晋升门,守卫在旧会话泊位落入销毁井前剪断借用并清空发送车。
移动端可横向滑动查看源码细节
图 1:三排展示同一轮调度的先后:旧泊位先把 ACK 交给外层发送车,候选门随后开启;固定守卫在旧泊位退场前剪断别名,车中不再留下失去所有者的报文。

2.3 TLS_AUTHENTICATED 描述会话晋升所需的 TLS 门槛

上游发布说明使用“control channel + authentication packets”概括触发流量,源码中的决定性条件却比“有效 VPN 账户”更精确。ssl_verify.hTLS_AUTHENTICATED(multi, ks) 定义为 ks->state >= (S_GOT_KEY - multi->opt.server)。注释说明它判断 key-method 2 的读写交换以及所配置的证书验证是否完成;连接级或延迟认证仍可能在等待,数据通道密钥也可能尚未生成。服务端模式下,减去 server 标志让门槛落在与服务端状态顺序相配的 S_SENT_KEY。这个宏观察的是 TLS 状态机的进度,不读取用户名密码认证的最终枚举值。

用户认证另有一条独立状态轴。ssl_common.henum ks_auth_state 包含 KS_AUTH_FALSEKS_AUTH_DEFERREDKS_AUTH_TRUE,注释明确写着只有最后一种才是完整认证。key_method_2_read() 解析 key source、选项、用户名密码和 peer-info,调用脚本、插件或管理接口,并把结果写入 ks->authenticated;解析本身成功后,TLS 状态仍可以前进到 S_GOT_KEY,即使用户名密码的结果是失败或仍在等待。也就是说,TLS_AUTHENTICATEDks->authenticated == KS_AUTH_TRUE 不是两种写法,而是两个不同问题。

tls_multi_process() 自己把这一区别写进了控制流。函数先调用 tls_authentication_status(),后者扫描可用 key、通过 update_key_auth_status() 读取延迟认证控制文件,再根据 ks->authenticated 返回成功、失败或等待。可是决定 TM_INITIAL 是否取代 TM_ACTIVE 的分支并不检查这个返回值,它检查 TLS_AUTHENTICATED。紧邻代码注释把被晋升对象称为 semi-trusted session:证书验证已经完成(若部署启用),用户名密码可能失败;它可以继续控制信道交换,却不能承载隧道数据。晋升之后代码再次计算总体认证状态,并把日志写成 trusted 或 semi-trusted。

攻击前提由部署配置决定,不存在统一适用的“已认证远端”标签。强制客户端证书且证书链严格校验的实例,远端通常需要一份服务器接受的客户端证书才能把候选会话推进到该门槛;允许仅用户名密码、把客户端证书设为可选或关闭证书校验的实例,达到 TLS 门槛所需材料会不同,失败或延迟的用户认证也不必然阻止会话先被晋升。tls-authtls-crypttls-crypt-v2 还可能在更外层要求静态控制信道材料。资产评估必须读取实际配置,单一实验环境的凭据组合无法代表所有部署。

3 沿着一枚控制包走到 to_link

要判断一条数据路径是否真正贯通,不能只把几个函数名并排写在图上,还要确认每一步改变了哪个对象、条件失败时会在哪里退出,以及协议状态在缓冲区被清空后是否仍然存在。从进入网卡的控制包开始,路径依次经过 tls_pre_decrypt()rec_acktls_process()write_control_auth()reliable_ack_write(),最终抵达 *to_link = buf;“产生一枚待发 ACK”由此还原成一条可逐行复查的链。

3.1 入站控制包先经过认证、序号与重放检查,才在 rec_ack 留下债务

tls_pre_decrypt() 是控制包进入 TLS 多会话层的重要入口。它先读取 opcode 和 key ID,定位会话与 key state,验证控制包包装,再读取报文中对本端可靠发送队列的确认记录。reliable_ack_read() 解析对端已经收到的包号,reliable_send_purge() 据此释放本端不再需要重传的可靠报文。只有在报文不是纯 P_ACK_V1、接收队列还有空间,并且包序号不会破坏顺序时,负载才进入可靠接收缓冲区。

随后出现与漏洞上游直接相连的一行:无论该控制报文是不是重放,只要序号处于可接受顺序,代码都会调用 reliable_ack_acknowledge_packet_id(ks->rec_ack, id)。注释给出的理由是即使重放也要确认,从而避免对端持续重传。rec_ack 因而成为协议层的待办清单;它保存包 ID,不保存最终报文字节。到这里,远端可以通过发送合法控制流量让活动 key state 欠下一份 ACK,但尚未决定这份 ACK 会附着在哪个输出缓冲区。

struct reliable_ack 本身只有一个长度和最多八个 packet_id_typereliable_ack_acknowledge_packet_id() 先线性检查去重,容量尚未达到 RELIABLE_ACK_SIZE 时才把 ID 追加到数组。它既不分配网络帧,也不持有发送地址。这个固定上限阻止待确认集合无限增长,也说明漏洞不是由超长 ACK 列表越界造成:出问题的长度是之后合法构造的报文长度,失效的是承载它的堆块生命周期。

入站报文同时携带的“对本端报文的确认”走另一条方向。reliable_ack_read() 解析计数、逐个读取网络字节序 ID,并在计数非零时核对远端会话 ID;reliable_send_purge() 据此停用已获确认的可靠发送条目。随后,当前入站控制包自身的 ID 才被加入 rec_ack。把这两个方向分开很重要:远端 ACK 会缩短 send_reliable 生命周期,本端欠下的 ACK 会进入 ack_write_buf;CVE 经过的是后一条链。

包序号比较还考虑 32 位回绕。reliable_pid_min() 使用半空间规则比较先后,接收逻辑再结合窗口和可靠队列判断报文是否可接受。复现实验因此不应任意改写序号字段后期待进入 rec_ack;错误 session ID、截断 ACK 记录、窗口外 ID 或控制包装校验失败都会在更早位置退出。真正能触发后半链的报文首先是一份被可靠层接受的控制报文,这也是为何简单的无状态端口扫描无法代表实际可达性。

3.2 独立 ACK 分支只有在 to_link 空闲时借用专用缓冲区

tls_process() 先处理 key state 的握手、可靠重传、TLS 明密文流转和状态变化。等这些工作结束,它检查 !to_link->len && !reliable_ack_empty(ks->rec_ack)。第一个条件保证当前调用还没有准备其他外发报文,第二个条件说明确有待确认序号。若 control_packet_needs_wkc(ks) 成立,代码创建一个可以可靠重传的空控制报文,让 ACK 与 wrapped client key 一起发送;常规情况则进入专用 ACK 路径。

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;

第一行就是借用发生的瞬间。局部 buf 与成员 ack_write_bufdata 相同,buf_init() 只在这份局部描述符中重设 offset 与 len。write_control_auth() 把 ACK 记录与会话 ID 写入同一块字节区,再根据 tls-authtls-crypt 或无包装模式完成控制包包装,并设置远端地址。最后一行把描述符再浅拷贝给调用者。此后局部变量消失不构成问题;真正重要的是其 data 仍属于 ks

buf_init() 的 headroom 参数来自 frame 配置,为之后向报文头部前置控制字段留出空间。局部副本使写入过程能够调整 offsetlen 而不改变成员描述符的坐标;下一次使用同一 scratch 区时又可从干净视图开始。这个技巧节省重置成本,却产生一种容易被类型系统掩盖的状态:成员看起来长度仍为零,外层副本却指向同一存储并拥有非零长度。只看 ack_write_buf.len 无法判断其内存是否正在外借,必须比较 data

write_control_auth() 的责任不仅是写一个 opcode。它先让 reliable_ack_write() 形成 ACK 区域和远端 session ID,再按当前 tls_wrap 模式选择认证或加密包装,最终把 to_link_addr 指向 key state 保存的远端地址。于是外层拿到的是一份足以直接进入链路层的完整视图:字节、有效范围和目的地址都已确定。会话晋升释放的是字节所有者,目的地址会复制到 multi->to_link_addr;这解释了为什么发送阶段仍有充分条件继续走下去,而不会因地址缺失自然中止。

控制帧尺寸计算为这块 scratch buffer 预留了完整包装空间。初始化 frame 时,OpenVPN 把 SOCKS、tls-auth/tls-crypt、TCP 长度、opcode、最多八条 ACK 与远端 session ID 的最坏开销加入 headroom 和 tailroom;payload 默认以 1500 加余量为基础。key_state_init() 再按整个 BUF_SIZE(frame) 分配 ack_write_buf。所以独立 ACK 分支中的断言通常能在一块容量充分的合法缓冲区上成立,问题与容量不足没有因果关系。

对普通可靠控制报文,calc_control_channel_frame_overhead() 会把当前 rec_ack 的待办数与 lru_acks 的历史数相加,再限制到允许捎带的最大值。独立 ACK 则直接以 RELIABLE_ACK_SIZE 调用 write_control_auth(),尽可能偿还当前确认债务。两条路径共享 ACK 编码和控制包装,差别主要在报文字节最终驻留于可靠数组还是专用工作区。也正因为协议内容几乎相同,单靠抓包很难判断底层所有者是哪一类。

write_control_auth() 还包含一个兼容性分支:在无 TLS wrapping 的客户端模式、未使用 key material exporter 时,把最多 ACK 数限制为四,以兼容会丢弃更多 ACK 的 SoftEther 实现。该分支只改变写入数量,不改变 data 所有权。它提醒测试人员,客户端/服务端角色与包装模式会改变报文外观;生命周期断言应围绕地址和 session,固定八个 ID 或某个精确包长都不适合作为前提。

独立 ACK 生成后,函数仍会计算可靠发送超时、握手截止和重协商唤醒时间。它没有立即调用链路写入,也没有把工作区交给引用计数对象。返回值只告诉外层 TLS 仍处于活动状态,真正的报文存在性由 to_link->len 表达。这段尾部调度让候选 session 的晋升有机会在函数返回前发生,并把一个本来正常的短期借用推过所有者终点。

专用工作区避免了每次 ACK 都新分配内存,也避免把纯 ACK 放进需要可靠确认的发送队列。这个性能选择与协议选择都有合理性。错误不在于使用 scratch buffer,而在于跨函数返回借用它之后,外层状态机还可能在发送前销毁 scratch buffer 的所有者。如果设计约定是“借用只在本函数内有效”,那么 *to_link = buf 已经违反约定;OpenVPN 的实际约定更宽,它允许借用延续到事件循环发送,因此必须在所有会话销毁点做别名守卫。

3.3 reliable_ack_write() 同时改变字节与协议状态

write_control_auth() 在写控制包装前调用 reliable_ack_write(ks->rec_ack, ks->lru_acks, buf, &ks->session_id_remote, max_ack, prepend_ack)。实现先把最多 max 个新 ACK 合并到 MRU 队列,再按可容纳数量从 lru_acks 写入输出子缓冲区。如果至少写入一个 ACK,还要附上远端会话 ID。最后,被本轮选中的新 ACK 从 rec_ack 前部移除。

因此,危险窗口中的对象状态并不是“ACK 还完全留在原队列里,之后随便重做”这么简单。字节已经写好,最近确认集合已经更新,新 ACK 已从当前待办列表移出,而外层输出尚未发送。补丁在晋升前发现借用冲突后把 to_link->lento_link->data 清零,保留的是安全且可继续推进的协议状态。后续控制交换仍可以利用 MRU 记录,远端若未得到确认也会按可靠层策略重传原控制包。这个恢复模型使“丢弃视图”成为比“复制所有 ACK”更小、更符合现有架构的修复。

沿数据路径看,到此已经得到前半段:入站有效控制包的 id 进入 key_state.rec_acktls_process() 发现待确认集合且没有其他输出;write_control_auth() 通过 reliable_ack_write() 把序号写入 key_state.ack_write_buf.data;结构体赋值让 c2.to_link.data 与它相等。后半段只需证明同一调用随后释放对应 key state,并且 process_outgoing_link() 仍会消费这个外层字段。第四章正好在这条边界上展开。

4 从合法借用到悬空指针,只隔着一次会话晋升

内存安全问题常被一张崩溃栈压扁成“某行读了坏地址”。CVE-2026-12996 更有解释力的单位是字段生命周期账本:谁分配、谁借用、谁释放、谁最后消费。把五个动作写在同一条线上,会发现释放点本身完全正确,最后消费者也按自己的契约工作,缺失的是两者之间对借用关系的完整认识。

4.1 字段账本把所有者、别名与销毁点放在同一页

阶段函数与字段对象变化安全含义
分配key_state_init()
ks->ack_write_buf
为 frame 大小分配堆内存key_state 是唯一所有者
积累tls_pre_decrypt()
ks->rec_ack
记录需要确认的控制包 ID尚未创建外发字节
借用tls_process()
*to_link = buf
复制描述符与原始 data 指针外层获得非拥有视图
销毁move_session()tls_session_free()key_state_free()释放活动会话的 ack_write_buf所有别名必须在此前失效
消费process_outgoing_link()
c2.to_link
读取长度和数据并写向 socket未清理的别名成为 UAF

表中的关键不是函数距离,而是地址相等关系。check_session_buf_not_used() 不需要理解 ACK 语义,只需要取出 dataptr = to_link->data,判断它是否等于即将释放会话中任一后端存储的 data。这是一种运行时所有权成员测试:外层视图没有显式 owner 指针,守卫便用地址相等反推所有者。如果所有可能导出到 to_link 的存储家族都在集合里,转换安全;遗漏一个家族,那个家族对应的所有借用就能穿过销毁点。

账本还区分了“描述符还活着”和“字节仍有效”。c2.to_link 属于连接上下文,生命周期长于任一 TLS session;它的四个字段不会因为 session 被释放而离开作用域。ack_write_buf.data 指向的分配块却属于 key state。UAF 恰恰发生在长寿命描述符承载短寿命存储地址时。若只用作用域或结构体嵌套判断生命周期,会误以为 to_link 一直有效;若只看堆块,会漏掉谁仍保存地址。

修复的成员测试是一种有意选择的动态契约。它没有把 owner ID 塞进每个 struct buffer,也没有改变数百个缓冲区调用点;相反,销毁边界枚举能够合法外借给 to_link 的后端。这个方案依赖名单完整,因此未来新增第三种 session-owned 输出缓冲区时,代码评审必须同时更新守卫。把名单明确下来,才能让六行补丁背后的维护义务延续到后续版本,而不是只留下一个 CVE 号。

字段与函数生命周期图:控制包序号进入 rec_ack,reliable_ack_write 把 ACK 写入 ack_write_buf,tls_process 把浅指针交给 to_link,会话晋升释放所有者,随后网络发送路径读取悬空指针。
移动端可横向滑动查看源码细节
图 2:数据路径已经贯通到最后消费者。绿色箭头是有效数据或别名传递,红色箭头是所有者销毁后的危险路径;修复切入点位于借用完成与 move_session() 之间。

4.2 move_session() 先释放目的槽,结构体赋值随后才接管候选会话

候选会话达到 TLS_AUTHENTICATED 门槛后,tls_multi_process() 先调用 check_session_buf_not_used(to_link, &multi->session[TM_ACTIVE]),再调用 move_session(multi, TM_ACTIVE, TM_INITIAL, true)move_session() 的第一项实质工作是 tls_session_free(&multi->session[dest], false)tls_session_free() 遍历会话内全部 KS_SIZE 个 key state,并调用 key_state_free()。后者在释放可靠对象之前就执行 free_buf(&ks->ack_write_buf)。只有目的会话完整清理后,代码才做 multi->session[dest] = multi->session[src]

这条析构链也排除了一个常见误读:并非新会话覆盖旧结构体后导致旧指针找不到、进而产生泄漏;旧对象在覆盖前被主动且完整地释放。真正的问题是 to_link 位于 tls_multi 会话树之外,析构函数不知道有人还借着其中一块内存。原有守卫正是为弥合这一抽象边界而存在。它检查 tls_wrap.worktls_wrap_reneg.work 和每个有效 key state 的 send_reliable->array[j].buf.data。修复前,名单里没有与这些字段并列定义的 ack_write_buf.data

当遗漏发生,free_buf() 把堆块交回分配器,局部和外层描述符中的地址值并不会自动消失。随后来源会话被浅赋值进活动槽,TM_INITIAL 重新初始化,原活动对象的逻辑身份也彻底结束。此时 to_link.len 仍是已经包装好的 ACK 长度,to_link.data 仍是旧地址。它足以让事件选择逻辑认为 socket 有数据待写,却不再足以证明地址中的字节仍属于这枚 ACK。

move_session() 的结构体赋值不能被理解为复制出第二套资源。目的槽已经由 tls_session_free() 清空后,multi->session[dest] = multi->session[src] 把来源中的指针整体转交给目的槽;随后 tls_session_init() 为来源建立全新资源。对候选会话而言,这是一次所有权搬迁;对被替换的活动会话而言,所有权已经终止。悬空的 to_link 不属于这次搬迁的任一边:它引用已释放目的槽的 ACK 块,候选来源中的块与它无关。

4.3 函数返回以后,process_outgoing_link() 成为第一个不知情的消费者

check_tls() 调用 tls_multi_process() 后不会立即在同一函数里复制输出字节。它只根据返回状态调整超时和重连状态,c2.to_link 留在连接上下文中。事件循环根据缓冲区是否有内容以及 socket 可写状态注册或响应 SOCKET_WRITE。当 process_io() 收到这个事件位,它调用 process_outgoing_link(c);后者检查 c->c2.to_link.len 是否处于 frame 容量范围,准备目标地址、流量整形与 ping 状态,再把缓冲区交给底层链路写入。

check_tls()forward.c 第 183 行附近把 &c->c2.to_link 原址传入;调用参数就是外层持久输出对象。返回后它处理 TLSMP_RECONNECTTLSMP_ACTIVETLSMP_KILL,安排下一次控制信道唤醒,并检查 DCO key 状态;没有深复制或统一清空输出。只要 len 保持非零,后续 I/O 规划就会请求链路 socket 的写事件。这个调用边界正是“准备报文”与“真正发送”被拆开的地方。

process_outgoing_link() 的第一个有效性判断只验证 len > 0 且不超过 frame payload;它无从知道 data 的分配历史。调试日志中的 PROTO_DUMP() 可能先读取报文,SOCKS5 UDP 路径可能前置代理头,最终 link_socket_write() 把缓冲区交给 socket。因而 ASan 的“最后访问”既可能出现在正式系统调用前的日志或代理处理,也可能落在链路写函数。修复把 lendata 一起清零,既阻止事件调度,也防止后续辅助代码拿到旧地址。

这段延迟消费让分配器有机会改变释放块内容。最温和的情况是地址仍映射、内容暂时未变,错误可能没有立即显现;调试分配器或 AddressSanitizer 会把第一次读取报告为 heap-use-after-free;常规分配器可能把同尺寸对象重新放进该槽位,使网络写路径读取新的对象数据、元数据或被覆盖的 ACK;若指针或长度在后续处理里参与更多操作,则可能出现崩溃或更广泛的内存破坏。公开材料确认的是 UAF 路径,不需要假定每个平台都给出相同症状。

也正因为实际故障可能晚于释放点,崩溃栈不一定把 move_session() 放在顶部。栈顶可能位于 socket 写、SOCKS 预处理、日志格式化或其他读取 to_link 的位置。调查时若只按最后一帧归类为网络错误,会错过真正的所有权断点。应把同一连接最近一次 TLS 重协商、key-method 交换、认证状态变化、会话晋升和控制 ACK 活动与 core 时间对齐,并检查释放块的分配栈。完整因果链到这里闭合:远端控制序号变成 ACK 字节,ACK 字节的浅指针被外借,TLS 状态门槛让所有者退场,事件循环读取退场后留下的地址。

5 五个可调度状态共同构成触发窗口

“需要精确时序”有时会被误解成几乎不可能复现的纳秒级竞争。这里没有两个 CPU 核争夺一个无锁指针;触发条件都在协议状态机里,远端可以通过控制包发送、重协商推进和认证响应时机影响它们。真正困难的是让 ACK 走专用分支,并让候选会话恰好在同一次多会话调用末尾晋升。对研究验证而言,这是一组可布置的前置状态;对生产风险而言,它意味着重试可以提高命中概率。

5.1 五个必要条件共同定义可复现实验

  1. 服务端存在活动 TLS 会话。其主 key state 至少处于可处理控制流量的状态,远端地址有效。
  2. 活动 key state 的 rec_ack 非空。一个经过控制包认证与序号检查的入站报文可以建立该状态。
  3. 本轮没有更早的外发控制报文占用 to_link否则 ACK 会等待或附着于其他可靠报文,底层存储家族不同。
  4. ACK 进入常规独立分支。control_packet_needs_wkc() 特例不得把它改造成可靠队列中的空控制包。
  5. TM_INITIAL 在同一次 tls_multi_process() 中满足 TLS_AUTHENTICATED函数尾部因此守卫并释放 TM_ACTIVE

这五项不是外部报文格式的简单并列。第一和第二项位于旧会话,第四项决定输出后端,第五项位于候选会话;第三项把两条路径限定在同一个外层输出槽上。高质量回归测试应直接构造或观测这些内部不变量。只发送一个固定 PCAP 后等待偶然崩溃,无法区分该 CVE 与同一安全版本中修复的其他控制信道问题。

推荐的最小观测点有四组。第一组在独立 ACK 分支记录 ksrec_ack->lenack_write_buf.data、局部 buf.data 与赋值后的 to_link.data;第二组在候选晋升分支同时记录 stateauthenticatedto_link.len 和活动会话地址;第三组在 key_state_free() 记录被释放 ACK 基址;第四组在 process_outgoing_link() 记录最终视图。四组地址相等且调用顺序连续,才能证明同一对象完成了“分配—借用—释放—消费”。

实验的判定条件也应分层。脆弱构建的一级成功是观察到 to_link.data 在所有者释放后仍保持原地址;二级成功是 ASan 在后续读取报告 heap-use-after-free;崩溃或网络字节异常只是可能表现。固定构建的成功是守卫在释放前命中 ack_write_buf、清零外层视图,并且进程与隧道继续运行。这样的判定不要求把缺陷发展成利用链,也能完整验证补丁修复了正确的生命周期断点。

5.2 传输、认证后端与分配器改变表现,不改变根因

UDP 最直观地体现 OpenVPN 自有可靠层,但 UAF 的核心位于 TLS 控制状态和外层输出缓冲区,TCP 部署也应进入验证矩阵。TCP 的写缓冲与事件就绪行为可能改变释放后第一次读取的时间,SOCKS 代理路径还会在发送前处理 to_link,这些差异影响栈形和复现稳定度。tls-authtls-crypttls-crypt-v2 影响控制包包装以及 WKC 特例是否出现,也应分别验证。

认证配置决定候选会话抵达 TLS 门槛的材料和节奏,却不能用“后端返回成功”概括。同步用户名密码校验可能在 key_method_2_read() 内把 authenticated 设为 true 或 false;脚本、插件和管理接口可以留下 KS_AUTH_DEFERRED,由后续 update_key_auth_status() 收敛。无论是哪一种结果,晋升分支观察的仍是 state。测试应把证书策略与用户认证组合成矩阵,分别记录 TLS_AUTHENTICATED 首次为真、authenticated 取值和 tls_authentication_status() 返回值所在的调度轮次。

建议至少建立四种服务端配置:强制客户端证书且无用户密码、强制证书并叠加同步用户认证、可选或关闭客户端证书并启用用户认证、以及带延迟插件/管理认证的生产等效配置。每种配置都分别使用成功、失败和延迟的身份结果。预期不是每格都能到达漏洞窗口;预期是明确记录哪一层先拒绝、哪一格会产生 semi-trusted 晋升。这样得到的前置条件可以直接映射资产,而不会把某一套实验凭据误写成产品固有要求。

内存分配器决定可见症状。ASan 会隔离已释放区域并在访问时提供分配与释放栈,是确认根因的首选;glibc、musl、Windows 堆或设备厂商定制分配器可能立即复用、延迟复用或填充不同字节。不同优化级别也会改变首次实际解引用位置。风险评估不应把“某次普通构建没有崩溃”解释为不可触发,而应检查修复前后指针成员关系是否满足不变量。

网络条件负责让状态相遇。实验可以在客户端与服务端之间加入可控延迟和丢包,只对控制信道事件施加小范围调度,并用服务端内部观测确认何时积累 rec_ack。不应靠无限高速重放制造负载,因为那会混入连接表、HMAC 防护和内存压力等其他失败模式。更有价值的方法是先把候选会话推进到门槛前一拍,再让活动会话收到一份需要独立确认的合法控制报文,最后释放候选会话剩余的状态推进。

TCP 与 UDP 的对照能验证根因是否位于传输层之上。两者若都能观察到相同地址关系,而首次读取位置与等待时间不同,就与源码模型一致;只有某一种传输到达窗口时,则需要检查报文合并、socket 可写和控制层调度差异。tls-crypt-v2 还要分离 wrapped client key 特例:control_packet_needs_wkc() 为真时,代码创建进入可靠数组的空控制报文,已有守卫能够识别该存储,不应把这条安全反例误报为专用 ACK UAF。

5.3 已确认影响是服务端堆释放后使用,处置优先级由边界位置放大

OpenVPN 2.6.21 与 2.7.5 的发布材料把 CVE-2026-12996 列为 ack_write_buf 的 use-after-free,并明确指出经过时序安排的控制信道与认证包可以触发。修复提交进一步锁定活动会话、待发独立 ACK、同一次多会话执行和初始化会话认证四个要素。结合源码,最直接的安全后果是 OpenVPN 服务端进程读取已释放堆内存,造成拒绝服务,并具备内存内容混淆或更严重内存安全后果的条件。

VPN daemon 往往位于互联网或合作方网络边界,长期运行并持有多租户会话。远端主体是否需要客户端证书、静态控制密钥或有效账户取决于配置;源码已经表明完整用户认证并非晋升宏的统一前提。一个网关进程退出会同时中断该进程承载的其他连接;在主动—备用架构中,重复故障还会消耗健康检查与切换窗口。容器或系统服务自动拉起进程只能缩短单次中断,无法关闭重复触发路径。

风险记录应把“已证实”和“尚未证实”拆成独立字段,叙述保持单向清晰。已证实项包括服务端堆块释放、外层地址继续存活、网络写路径后续读取、远端协议时序可达以及固定版本;当前公开证据未建立通用代码执行链,也没有可单独归因的线端 IOC。处置结论仍然明确:边界服务存在远端可影响的堆 UAF,升级到包含守卫的构建,并对历史异常保留版本、core、日志和会话时序。

资产优先级可以按五个事实排序:运行版本是否包含修复;服务端控制端口是否可由互联网或低信任网络到达;控制信道是否要求外层静态材料;客户端证书验证策略;单进程承载连接数与故障切换方式。第一项决定漏洞是否存在,接下来的三项决定达到 TLS 门槛的难度,最后一项决定一次进程故障的业务半径。这个排序能让修复队列精确落到具体网关,所有“OpenVPN 资产”共用一个笼统标签只会掩盖差异。

6 六行补丁修复的,是一张不完整的所有者名单

补丁很短,却不是“在 free 前判空”这种偶然止血。OpenVPN 早在 2023 年就引入 check_session_buf_not_used(),专门处理会话释放时外层仍计划发送内部缓冲区的情况。2026 年修复做的是补齐守卫认识的后端存储集合。这一历史脉络说明项目已经知道跨层借用需要转换保护,缺陷来自新增或既有路径没有被完整纳入同一不变量。

6.1 2023 年守卫覆盖包装区和可靠数组,却没有覆盖专用 ACK 工作区

提交 cd4d819c99266fa727c294225cafdb4ae331d02e 增加了会话缓冲区二次检查,初衷是捕获“已经计划发送报文,但同一会话随后出错并被重置”的释放后使用。守卫先比较 session->tls_wrap.work.data,再遍历每个有效 key state 的 send_reliable 数组;命中后记录警告并跳到统一 used 标签,把外层长度和数据指针清零。后续动态 tls-crypt 修复又把 tls_wrap_reneg.work.data 加入集合。

2023 年守卫的调用位置比函数本身更能说明契约。tls_process() 若把会话推入 S_ERROR,外层会在 reset_session()move_session(..., TM_LAME_DUCK, TM_ACTIVE, ...) 前检查;候选会话晋升前也检查即将被替换的 TM_ACTIVE。这些调用都发生在析构之前,且拿到同一份外层 to_link。守卫不是事后检测器,而是状态转换的前置条件:准备销毁一棵 session 资源树时,先撤销仍指向树内的待发视图。

同一版本的另一个修复 CVE-2026-13117 把 tls_wrap_reneg.work 纳入该守卫,触发面是动态 tls-crypt 控制报文。它与 ACK 缺陷的协议入口不同,根因形状却一致:一个 session-owned 工作区被导出到更长寿命的发送槽,随后状态转换释放 session,守卫的所有者名单没有覆盖新后端。两项修复同时出现,说明审计单位应从单一函数扩大到“所有可能支撑 to_link.data 的存储家族”。

专用 ACK 缓冲区在结构布局上紧邻明文工作区,语义上却既不属于 TLS wrapping,也不属于 reliable send array。它只在没有其他输出时作为 scratch storage,被 tls_process() 的结构体赋值短暂导出。若审查从析构函数向下列出“会话释放了哪些字段”,ack_write_buf 很醒目;若审查从常见外发队列向上列出“报文通常存在哪里”,它很容易被当作函数内临时区。跨文件的外层消费使两种局部认识之间留下了空隙。

历史 blame 还需要谨慎解释。当前分支中独立 ACK 分支的多行归属于 2022 年 e7d8c4a72002...,但该提交只是为 P_CONTROL_WKC_V1 加入特例并把原有 ACK 代码包进 else;差异中清楚显示 struct buffer buf = ks->ack_write_buf 在父版本已经存在。进一步 blame 至少追到 2016 年大规模格式化提交,部分更早 blob 不在本地部分克隆中。可以确认的是危险借用长期存在,不能把 2022 年包装提交误写为漏洞首次引入年份。

6.2 修复在每个有效 key state 上增加一次地址比较,并复用既有清理出口

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 提交 97f39e1e993ba2217623a5b4ea16f879b2fb6a1e 在现有 key state 循环里增加上述判断;2.6 分支的等效提交是 ea01c6544d00e5124ef26d6bc85d7663dfbb4a03,2.7 分支是 5ee1f9b90fe03ecf7cef5431147ecaabbe96db9e。三个提交的意图一致:只要待发指针由即将释放会话任一有效 key state 的 ACK 工作区支撑,就记录具体 key 索引并走统一清空分支。

判断使用分配基址的严格相等,不依赖长度、opcode 或当前认证结果。长度可能因控制包装而变化,opcode 只存在于报文字节,认证结果属于候选会话;这些值都无法回答待发内存是否属于即将释放的活动会话。地址成员关系恰好命中生命周期不变量,并把热路径成本限制为每次销毁前对两个 key 槽的少量比较。平时没有 session 转换时,这段检查不在每个报文的常规发送路径上。

补丁没有把 ack_write_buf 移出 key state,没有为 struct buffer 增加引用计数,也没有在每次独立 ACK 上分配新堆块。原因与可靠性模型一致:会话马上被候选会话取代,这枚属于旧会话的 ACK 视图已经失去合适的发送上下文;复制它只会延长过期会话状态,引用计数则会让整个 session 析构等待一个外层异步消费者。清空 to_link 让事件循环不再注册这次写入,远端的可靠层和后续控制交换负责恢复,修改面最小。

v2.6.21 的相邻所有权关系也能沿修复思路闭合。ssl.c 中写入 *to_link 的直接赋值只有两类:tls_process_state()send_reliable 条目导出可靠控制报文,以及 tls_process()ack_write_buf 导出独立 ACK。前者由守卫遍历可靠数组覆盖,后者由本补丁覆盖;控制包装过程中可能使用的 tls_wrap.worktls_wrap_reneg.work 也已在名单中。plaintext_read_bufplaintext_write_buf 只在 TLS/BIO 与可靠队列之间搬运数据,没有直接逃逸为外层待发视图。

随后从所有 tls_session_free()reset_session()move_session()key_state_free() 调用点反向核对。错误重置、活动会话迁移和候选晋升都在销毁前调用守卫。lame-duck 到期路径表面上直接释放,继续沿控制流检查后可以看到控制输出由 session 的主 key 生成,而到期判断释放的是退役 key;外层旧会话槽的到期清理也没有一条在同轮从待释放退役 key 导出控制报文的路径。当前固定源码树中没有由此确认新的 to_link UAF。

这项结论只覆盖已经枚举的别名与销毁链,不等于对整个 OpenVPN 内存安全作出永久保证。后续值得持续自动化的规则有三条:任何把 session/key-state 成员浅拷贝到外层缓冲区的新增赋值必须进入所有者清单;任何在 tls_multi_process() 内新增的 session/key 释放点必须证明已撤销待发别名;任何把 data + offset 作为新基址导出的代码都要重新审视严格基址比较。新命中先作为审计候选复现,只有完成可达性和安全影响验证后才应作为漏洞披露。

会话转换守卫修复前后对比:旧守卫识别 tls_wrap、tls_wrap_reneg 和 send_reliable 三类存储,补丁增加对每个 key state 的 ack_write_buf.data 地址比较,并复用清空 to_link 的出口。
移动端可横向滑动查看源码细节
图 3:六行改动补全了“哪些内存属于即将释放会话”的集合。集合完整后,地址相等测试才能可靠承担运行时所有权守卫。

6.3 2.6.21 与 2.7.5 是可核验修复下限,回移包需要提交级证据

源码基准固定在 OpenVPN 2.6.21 的发布提交 4dee614e4e799d6d4a20bffabd902291c86406f2。这是带注释 tag v2.6.21 解引用后的 commit,提交主题为“OpenVPN Release 2.6.21”;版本分析没有用随时间变化的 master 文件替代发布源码。函数行号、结构体布局和补丁前后比较都以这一棵树为基准,分支差异再分别回到 2.6、2.7 与 master 的修复提交核对。

OpenVPN Community 下载页记录 2.6.21 和 2.7.5 均于 2026 年 7 月 1 日发布,并在安全修复列表中写明 CVE-2026-12996。Git 提交包含关系给出了清晰边界:2.6.20 不包含 ea01c6544...,2.6.21 包含;2.7.4 不包含 5ee1f9b90...,2.7.5 包含。master 的提交 ID与维护分支不同,不能仅用 97f39e1e 是否出现判断所有发行线。

Linux 发行版、网络设备和商业产品可能把六行补丁回移到保留旧上游版本字符串的软件包。接受回移时,应取得能落到源码的证据:厂商公告明确列出 CVE,源码包补丁包含 ack_write_buf.data == dataptr,或构建对应提交可在分支历史中验证。单纯看到“2026 年 7 月以后构建”或“厂商称已加固”不足以建立修复下限。反过来,版本号低于 2.6.21 也不能自动判定仍脆弱,前提是回移证据完整且运行进程确实加载了该构建。

设备固件常把 OpenVPN 静态链接进守护进程,或在包版本后附加厂商 release 编号。此时最强证据是可审计的源码包和补丁清单;次强证据是厂商安全公告、SBOM 组件版本与带符号二进制中的修复分支;仅靠 Web 管理界面显示的产品版本最弱。若无法取得源码,可在授权实验设备上观察固定版本守卫日志,或对合法测试流量验证外层别名在销毁前被清除。任何二进制差分都应绑定固件哈希,避免把另一个地区或硬件型号的结果套用过来。

2026 年 7 月 15 日复核时,OpenVPN Supported versions 页面把 2.7 列为当前稳定线、把 2.6 列为上一稳定线,并将两者都标为 Full stable support。升级路线应优先遵循操作系统或设备厂商支持矩阵;这一 CVE 本身不要求已修复的 2.6 部署跨到 2.7。关键验收点是运行中的 daemon 包含等效守卫。磁盘上安装新二进制后,长寿命 VPN 进程仍可能继续执行旧映像;变更完成必须核对进程启动时间、映射的可执行文件、实际 --version 输出与服务重启记录。

版本结论也有明确时效。发行版与设备厂商会继续回移补丁,支持分支也可能调整;运营时应以厂商当前公告与实际源码包更新资产判定。不会变化的是等效修复的语义:在任何释放 session 的相关转换前,只要 to_link.data 等于该 session 任一有效 key 的 ack_write_buf.data,就撤销待发视图。版本字符串只是一条找到这项语义的路径。

暖纸手绘的双发布线证据台:两条旧版本轨道分别经过分支修复压机抵达亮起青色状态灯的新版本,下面的源码树、补丁封印、运行机箱和厂商回移包由同一把放大镜串成核验链。
移动端可横向滑动查看版本证据
图 4:2.6.21 与 2.7.5 各有自己的修复轨道;厂商回移也必须把源码、等效守卫、实际构建和运行映像接成一条可复核证据链,不能只凭版本外观放行。

7 把修复落到网关,也把这条所有权规律留下来

故事的结尾不在补丁合入,而在生产网关真正退出旧代码、历史异常得到合适保存、回归测试能够再次走过那次事件循环。CVE-2026-12996 的处置不需要关闭 TLS 认证、禁止重协商或牺牲控制信道可靠性;固定版本已经在会话转换边界恢复不变量。运营工作的难点是找到所有以不同产品名称出现的 OpenVPN daemon,并证明它们运行的是包含补丁的构建。

7.1 资产盘点必须从产品名深入到运行二进制与上游分支

盘点范围至少包括互联网远程接入网关、站点到站点节点、云市场镜像、Kubernetes 或 Docker 容器、Linux NetworkManager 后端、路由器与防火墙固件、托管 VPN 服务节点,以及把 OpenVPN Community 作为子组件嵌入的第三方产品。对每项资产记录产品型号、OpenVPN 上游分支、软件包完整版本、构建来源、运行参数、认证后端、传输协议、高可用角色和进程启动时间。不能只统计桌面客户端,因为本漏洞的确认链路位于服务端多会话处理。

每条资产记录至少需要形成一组可验收字段:唯一资产 ID、业务与技术负责人、监听地址和端口、可达网络区域、UDP/TCP 模式、控制信道包装、客户端证书策略、用户认证方式、上游/厂商版本、包管理器 release、二进制 SHA-256、进程启动时间、集群角色、计划变更窗口和证据链接。字段不能为空字符串;无法取得的项目应标记原因和补齐日期。这样一来,漏洞前置条件可以直接映射到资产,会议中的“应该有证书保护”必须由配置证据支撑。

配置复核要特别区分三个安全门槛。第一层是端口可达性和连接限速;第二层是 tls-authtls-crypttls-crypt-v2 所需控制材料;第三层才是 TLS 客户端证书及用户/插件认证。记录 verify-client-cert、CA 与证书用途约束、auth-user-pass-verify、插件、管理认证和 auth-gen-token 等实际选项。由于 CVE 的晋升条件与完整用户认证分离,这些层级必须各自记录,不能压成一个“开启 MFA”布尔字段。

可执行的修复下限是:原生 2.6 分支不得低于 2.6.21,原生 2.7 分支不得低于 2.7.5;厂商回移必须能证明包含 ea01c65445ee1f9b90 或等效的 ack_write_buf 地址检查。资产系统应把“已安装版本”和“运行版本”分开。容器要检查镜像 digest 与 Pod 重建时间,传统服务要检查进程映射和 systemd 启动时间,主备设备要分别验证两个节点,避免备用机在故障切换时重新暴露旧代码。

版本判定输出应只有三种:已修复、受影响、证据不足。原生版本达到下限且运行映像一致可以判为已修复;低于下限且无回移证据判为受影响;厂商版本字符串不透明、磁盘包已升级但进程未重启、或只有销售口径时判为证据不足。证据不足不能自动转成低风险,它应进入厂商问询、实验验证或临时隔离队列。每次判定保留原始命令输出或支持包片段,便于变更后复核。

二进制层的核对要防止“新文件、旧进程”。Linux 可以把 /proc/<pid>/exe 指向、映射 inode、包管理器记录和 openvpn --version 对齐;容器必须使用运行 Pod 的 image ID,registry 中后来覆盖的 tag 不能证明在途工作负载已经更新;Windows 服务要核对服务路径、文件版本、签名和进程启动时间;设备集群则逐节点记录固件 build。验收必须在重启后重新采样,变更前的资产快照不具备放行效力。

升级顺序应围绕业务连续性设计。先确认备用容量和会话迁移能力,在一个节点排空连接或从负载均衡摘除,安装固定构建并彻底重启 daemon,建立测试隧道、传输双向数据、触发一次重协商并验证认证插件,再恢复流量并处理下一节点。回滚包也必须经过版本检查,避免因业务故障回退到包含 CVE 的二进制。整个过程不应以临时关闭重协商作为长期替代,因为密钥轮换和会话维护本身是安全机制的一部分。

变更单应把通过条件写成可观察结果:节点摘除后现有会话按设计排空;固定二进制启动且哈希匹配;测试客户端完成连接、路由与 DNS 下发;上下行流量持续通过;主动触发 renegotiation 后会话不掉线;同步或延迟认证后端均返回预期;服务日志没有新的 fatal 或高频守卫噪声;监控恢复健康;最后才允许节点重新承载生产流量。任一项失败都保留日志和时间点,再按预先批准的安全回滚包处理。

回滚决策要区分安全故障与业务故障。若新版本无法启动,可回到包含同等回移补丁的上一构建;若只有脆弱旧包可用,应优先保持节点摘除、扩容其他已修复节点或限制低信任可达性。无条件恢复暴露会重新打开已经关闭的风险窗口。临时控制包括缩小源网络、强化外层控制密钥、降低单进程承载量和提高崩溃取证能力,它们不能替代补丁,却能在厂商固件尚未发布时降低窗口。

暖纸手绘的网关滚动上线:左侧一台节点在同伴继续承载青色流量时被闸门排空,中间打开机箱并把旧棕色缓冲卷换成青色卷、绕环验证,右侧两台健康节点重新并肩承载流量,证据托盘完成收尾。
移动端可横向滑动查看上线闭环
图 5:每次只排空一个节点,证明固定构建已经运行并让测试隧道、双向流量与重协商通过,再把节点放回集群;版本、进程与回归证据随同变更一起封存。

7.2 历史调查以 core、守卫日志和会话时序为三根支柱

脆弱版本若在 TLS 重协商、key-method 交换或认证状态变化附近异常退出,应优先保留 core dump、OpenVPN 详细日志、服务管理器重启记录、进程构建信息与连接时间线。栈中检索 tls_multi_processmove_sessiontls_session_freekey_state_freefree_bufprocess_outgoing_link;若启用 ASan,核对报告中的分配栈是否落在 key_state_init() 的 ACK 分配、释放栈是否落在会话晋升。最后访问栈可能位于网络写路径,不应据此排除。

现场保存顺序应尽量减少自动恢复覆盖证据。先记录 UTC 与本地时间、节点 ID、PID、启动时间、服务状态和当前二进制哈希,再复制 core、journal 或事件日志、OpenVPN 日志、配置快照与容器/固件标识;随后才进行反复重启测试。若高可用平台已把流量切到备用节点,保留故障节点隔离状态更有利于分析。所有文件计算哈希并写入案件清单,配置中的私钥、令牌和用户信息按取证权限单独保护。

core 分析的核心不是寻找一条固定栈,而是恢复三段时间关系。分配段确认 ack_write_buf.data 来自哪个 key state;释放段确认该 key state 属于哪个 session,调用者是候选晋升还是错误恢复;访问段确认 c2.to_link 的地址、长度与释放块一致。若分配器元数据仍在,检查块大小是否对应 frame 缓冲区、是否已被同尺寸对象复用。寄存器或日志中出现 P_ACK_V1 只作辅助,地址身份才是主证据。

固定版本命中保护时会记录“buffer of freed TLS session is still in use”,并指出 session->key[i].ack_write_buf。这条日志非常有价值,因为它证明外层输出确实借用了即将释放会话的专用 ACK 缓冲区,且修复成功阻止了访问。它仍需要上下文:正常网络丢包、认证延迟和重协商也可能形成相同状态交汇。应按连接 ID、远端地址、账户、证书、时间密度和重复模式判断是否属于主动探测或稳定触发。

守卫日志适合做高信噪比主机遥测,却不适合直接当作攻击签名。单次命中证明危险状态曾自然形成,正好说明补丁有必要;短时间内由同一来源、同一证书或相邻会话反复命中,才更接近主动调度。告警规则可以聚合节点、远端、连接建立时间、认证结果和重协商次数,设置观察与升级两级阈值。无论归因如何,固定构建已经在命中点安全丢弃输出,响应重点是确认没有旧节点与异常高频模式。

调查结论可以分为四级。确认:地址链或 ASan 栈贯通分配、会话释放与后续读取;高度一致:脆弱版本、重协商/晋升时序和网络写栈吻合,但缺少完整分配栈;待定:只有异常退出与相关时间窗;不相关:崩溃由另一条明确根因解释。每一级都记录支持与缺失证据,避免把“没有 exploit 痕迹”当作排除,也避免把每次 TLS 错误都归入 CVE。

完成历史调查后,原始证据应按保留期封存,分析副本用于符号化和关联。对外共享的 IP、账户、证书主题和 core 内容需要按组织隐私规则脱敏,内部案件仍保留可追溯映射。最终时间线至少包含进程启动、相关连接建立、候选 TLS 交换、认证状态、控制包活动、进程退出、自动恢复和修复上线。这样即使无法达到“确认”级别,后续同类事件也能沿同一数据路径进行一致比较。

7.3 回归验收要证明“借用在所有者退场前被撤销”

源码级验收可在 ASan 构建中为独立 ACK 赋值、晋升守卫、move_session()process_outgoing_link() 设置断点或结构化日志。准备一个已建立的活动会话和一个即将达到 TLS 门槛的候选会话,让活动 key state 的 rec_ack 非空,同时保证 to_link 没有其他控制报文。单步确认 tls_process() 执行 *to_link = buf 后,to_link.data == active.key[i].ack_write_buf.data;候选会话满足 TLS_AUTHENTICATED 后,守卫必须在 tls_session_free() 之前把 to_link.lento_link.data 清零。

测试构建应固定到明确 tag 或厂商源码包,并开启足够的调试符号。ASan 与未定义行为检测器可以分别捕获释放后访问和相邻算术问题,优化级别保留一套接近生产的配置。证书、控制密钥、用户后端和网络命名空间全部使用隔离实验材料,禁止把生产凭据复制进复现环境。测试脚本只推进协议状态与断言,不需要尝试控制释放块内容;生命周期证据已经足以判断补丁。

脆弱基线与固定候选必须成对运行。基线用于证明测试确实到达独立 ACK 外借和会话释放的交叉点;候选用于证明同一输入下守卫清理外层视图。若两边状态路径不同,例如候选因配置或证书差异提前退出,测试没有比较价值。每次运行导出四个关键时间戳、地址相等断言、候选 state/authenticated 二元状态、守卫日志和 sanitizer 结果,形成机器可读记录。

固定构建至少需要三个正向断言:守卫命中时日志字段准确指向 ack_write_buf 及 key 索引;move_session() 仍然完成候选会话搬迁;外层 I/O 不读取旧地址。随后观察对端重传或后续控制交换是否自然恢复,活动隧道是否保持业务可用。只证明“不再崩溃”不够,因为粗暴禁用会话晋升或吞掉全部控制包也能得到表面稳定,却会破坏密钥轮换。

验收矩阵至少覆盖 UDP 与 TCP、常规控制包装与生产所用 tls-auth/tls-crypt、同步和延迟认证、主 key 与退役 key、正常重协商、丢包与重传。负向用例同样重要:没有会话晋升时,独立 ACK 必须正常生成并发出;to_link 指向 send_reliable 或包装工作区时,既有守卫仍应正确识别;指针不属于即将释放会话时,不得误清合法输出。功能侧要验证隧道连续、路由与 DNS 推送正常、认证插件没有被绕过、日志没有形成高频噪声。

认证矩阵要按前文的双状态轴验收。候选分别处于 KS_AUTH_TRUEKS_AUTH_DEFERREDKS_AUTH_FALSE 时,记录是否达到 TLS_AUTHENTICATED、是否晋升为 trusted 或 semi-trusted、数据通道是否被正确禁止或允许。修复只能改变外层缓冲区清理,不能把失败认证变成成功、不能提前生成数据通道密钥,也不能绕过延迟认证到期。这个矩阵同时防止安全补丁造成认证回归。

上线后的观察窗至少跨过一个正常密钥重协商周期和一个业务高峰。监控固定版本守卫字符串、进程退出、连接重建、认证后端延迟、隧道丢包、主备切换与资源使用;抽样确认运行二进制未被自动回滚。若网关规模较大,先选择可快速摘除的 canary 节点,确认上述指标稳定,再分批扩大。每批次保留开始、完成和验证时间,出现异常可定位到具体构建与节点集合。

关闭任务前,资产清单中每台服务端都必须拥有“运行修复构建”的直接证据,证据不足项必须有隔离或厂商处置计划;历史调查给出分级结论;回归记录证明危险别名被撤销且认证、重协商、可靠层没有退化;监控规则已覆盖守卫命中与异常退出。完成这些条件后,这枚 ACK 的故事才真正结束:它仍可以被高效借用,候选会话仍能无缝接管,控制信道仍能依靠可靠层恢复丢失确认;旧会话退场时,事件循环不再把它的内存当成一枚准备出门的报文。

研究记录

8证据、对象与来源

下面保留本文实际使用的标识、时间和原始材料,便于继续调查。

8.1研究对象

报告涉及的产品、行为者、技术、受影响对象和控制点。

CVECVE-2026-12996

OpenVPN 服务端 TLS 控制信道独立 ACK 缓冲区释放后使用

修复版本OpenVPN 2.6.21 / 2.7.5

上游发布中可核验的两个修复下限

2.6 修复提交ea01c6544d00e5124ef26d6bc85d7663dfbb4a03

release/2.6 分支将 ack_write_buf 加入会话转换守卫

2.7 修复提交5ee1f9b90fe03ecf7cef5431147ecaabbe96db9e

release/2.7 分支的等效修复

主线修复提交97f39e1e993ba2217623a5b4ea16f879b2fb6a1e

master 分支六行所有者成员检查

关键数据路径rec_ack → ack_write_buf.data → to_link.data

从待确认序号到外层借用指针的字段传递

析构链move_session() → tls_session_free() → key_state_free() → free_buf()

活动会话晋升时结束 ACK 所有者生命周期

主机日志session->key[i].ack_write_buf

固定版本阻止旧会话 ACK 别名时出现的字段名称

8.2事件时间

  1. 会话缓冲区守卫进入主线

    提交 cd4d819c9 为会话释放路径增加外层输出别名检查。

  2. ACK 成员修复完成

    Max Fillinger 编写把 ack_write_buf 纳入守卫的修复,提交记录列出多个独立报告来源。

  3. 2.6 维护分支接收修复

    release/2.6 以 ea01c6544 接收等效补丁。

  4. OpenVPN 2.6.21 与 2.7.5 发布

    两个安全版本同日发布并在变更说明中列出 CVE-2026-12996。

  5. SOSEC 完成源码级重建

    本地固定 v2.6.21,贯通入站 ACK、缓冲区借用、会话晋升、析构与外层发送链,并核验三个分支提交及版本包含关系。

8.3来源与材料

  1. OpenVPN Community 下载页:2.6.21 与 2.7.5 发布记录https://community.openvpn.net/Downloads
  2. OpenVPN Community 安全公告索引https://community.openvpn.net/Security%20Announcements
  3. OpenVPN 2.6.21 Changes.rst 安全修复说明https://github.com/OpenVPN/openvpn/blob/v2.6.21/Changes.rst
  4. master 修复提交 97f39e1e993bhttps://github.com/OpenVPN/openvpn/commit/97f39e1e993ba2217623a5b4ea16f879b2fb6a1e
  5. release/2.6 修复提交 ea01c6544https://github.com/OpenVPN/openvpn/commit/ea01c6544d00e5124ef26d6bc85d7663dfbb4a03
  6. release/2.7 修复提交 5ee1f9b90https://github.com/OpenVPN/openvpn/commit/5ee1f9b90fe03ecf7cef5431147ecaabbe96db9e
  7. v2.6.21 buffer.h:struct buffer 字段与内容坐标https://github.com/OpenVPN/openvpn/blob/v2.6.21/src/openvpn/buffer.h#L50-L74
  8. v2.6.21 buffer.c:alloc_buf、clone_buf 与深浅复制差异https://github.com/OpenVPN/openvpn/blob/v2.6.21/src/openvpn/buffer.c#L58-L129
  9. v2.6.21 buffer.c:buf_assign 与 free_buf 的所有者清理语义https://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 与 TRUE 的独立认证状态https://github.com/OpenVPN/openvpn/blob/v2.6.21/src/openvpn/ssl_common.h#L139-L155
  11. v2.6.21 ssl_common.h:key_state 与 ack_write_buf 所有权布局https://github.com/OpenVPN/openvpn/blob/v2.6.21/src/openvpn/ssl_common.h#L198-L239
  12. v2.6.21 ssl_common.h:key-state 与多会话槽位索引https://github.com/OpenVPN/openvpn/blob/v2.6.21/src/openvpn/ssl_common.h#L440-L535
  13. v2.6.21 ssl_verify.h:完整认证结果与 TLS_AUTHENTICATED 宏的边界https://github.com/OpenVPN/openvpn/blob/v2.6.21/src/openvpn/ssl_verify.h#L68-L109
  14. v2.6.21 ssl_verify.c:延迟认证收敛与 tls_authentication_statushttps://github.com/OpenVPN/openvpn/blob/v2.6.21/src/openvpn/ssl_verify.c#L1086-L1227
  15. v2.6.21 ssl.c:key state、ACK 缓冲区的分配与销毁https://github.com/OpenVPN/openvpn/blob/v2.6.21/src/openvpn/ssl.c#L974-L1094
  16. v2.6.21 ssl.c:会话销毁、移动与重置https://github.com/OpenVPN/openvpn/blob/v2.6.21/src/openvpn/ssl.c#L1215-L1274
  17. v2.6.21 ssl.c:key_method_2_read 与独立用户认证状态https://github.com/OpenVPN/openvpn/blob/v2.6.21/src/openvpn/ssl.c#L2371-L2557
  18. v2.6.21 ssl.c:可靠输出与 TLS 状态推进https://github.com/OpenVPN/openvpn/blob/v2.6.21/src/openvpn/ssl.c#L2898-L3038
  19. v2.6.21 ssl.c:独立 ACK 导出与会话缓冲区守卫https://github.com/OpenVPN/openvpn/blob/v2.6.21/src/openvpn/ssl.c#L3083-L3292
  20. v2.6.21 ssl.c:多会话遍历与候选会话晋升https://github.com/OpenVPN/openvpn/blob/v2.6.21/src/openvpn/ssl.c#L3302-L3472
  21. v2.6.21 ssl.c:入站控制包、可靠序号与 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_auth 与 reliable_ack_writehttps://github.com/OpenVPN/openvpn/blob/v2.6.21/src/openvpn/ssl_pkt.c#L167-L197
  23. v2.6.21 reliable.c:ACK 汇集、MRU 历史与序列化https://github.com/OpenVPN/openvpn/blob/v2.6.21/src/openvpn/reliable.c#L130-L310
  24. v2.6.21 forward.c:check_tls 把 c2.to_link 借给 TLS 调度器https://github.com/OpenVPN/openvpn/blob/v2.6.21/src/openvpn/forward.c#L166-L231
  25. v2.6.21 forward.c:process_outgoing_link 最终消费借用视图https://github.com/OpenVPN/openvpn/blob/v2.6.21/src/openvpn/forward.c#L1767-L1844
  26. 2023 年会话缓冲区守卫原始提交 cd4d819c9https://github.com/OpenVPN/openvpn/commit/cd4d819c99266fa727c294225cafdb4ae331d02e
  27. 2022 年 WKC 特例提交及其对既有 ACK 分支的包装https://github.com/OpenVPN/openvpn/commit/e7d8c4a72002cbaa7542ea0cff8acca1b971b1f5
  28. OpenVPN Community 当前支持分支https://community.openvpn.net/Pages/Supported%20versions
  29. CVE-2026-12996 CVE 记录https://www.cve.org/CVERecord?id=CVE-2026-12996