漏洞

OpenVPN CVE-2026-13122:一段短令牌如何进入会话记忆并终止 VPN 服务

CVE-2026-13122 发生在 OpenVPN 2.6.0—2.6.20 与 2.7 alpha1—2.7.4 的 external-auth 模式:只带 SESS_ID_AT_ 前缀的短凭据会在密码学校验失败后仍被保存为初始会话令牌,固定偏移读取与写入随即走出这段字符串的真实长度,最终触发致命断言并结束服务端进程。

OpenVPN CVE-2026-13122 源码技术封面:短凭据通过前缀分类、令牌校验失败、external-auth 继续处理,畸形值进入 auth_token_initial 后抵达固定偏移读取、写入和断言;修复增加 91 字符与 HMAC 两道门槛。
文章导航

研究依据对 OpenVPN 2.6.20/2.6.21 与 2.7.4/2.7.5 源码、三个分支修复、OpenVPN 与 CVE 记录、历史提交及认证令牌回归测试的防御性复核

来源OpenVPN 2.6.20/2.6.21 与 2.7.4/2.7.5 源码 / 三个发布分支修复提交 / OpenVPN 与 CVE 发布材料 / SOSEC 源码复核

1 一张只有票头的票,走进了会话档案

服务端收到的 password 只有十一枚可见字符:SESS_ID_AT_。它没有会话编号,没有两个时间戳,也没有由服务器密钥生成的 SHA-256 HMAC。OpenVPN 的令牌校验器看见空载荷后给出失败结果,外部认证模式仍按设计继续询问脚本、插件或 management interface;真正改变结局的是紧随校验之后的一次 strdup()。失败的输入被写进 tls_multi.auth_token_initial,从“客户端提交的候选凭据”变成“服务端稍后可以按固定布局读取的初始会话令牌”。

后面的函数没有再问这段字符串究竟多长。环境变量导出需要 session ID,于是从前缀后复制十六字节;外部策略返回成功或 deferred 后,令牌续签需要复用最初的会话编号和创建时间,于是把指针移动到 +27,又在 +39 写入一个字符串终止符。对只有十二字节实际分配空间的副本而言,这些地址早已越界。Base64 解码随后返回不符合预期的长度,ASSERT() 记录致命错误并调用 _exit(1),VPN daemon 结束。

CVE 公告把结果准确归入 reachable assertion 和远程拒绝服务。源码还给出了更细的先后关系:断言退出前已经存在越界读取与越界写入。这层次序决定了 AddressSanitizer、core 和普通日志会呈现什么,也解释了修复为何同时修改“怎样认出一枚令牌”和“什么值有资格成为会话历史”。公开证据没有建立可操作的代码执行链,确认影响仍是 daemon 可用性。

这段故事跨越七年。2019 年,OpenVPN 把认证令牌重写为无状态 HMAC 记录,并加入永久 session ID 与 external-auth,让外部身份系统能够区分首次登录、续期和重连。2021 年,一次旨在简化初始令牌处理的重构,把“第一次看到的 token”直接保存进 auth_token_initial。当时的分类函数只检查前缀,密码学结果又没有参加这次写入判定,两条原本独立的便利路径由此合成了一个延迟五年显现的状态污染缺陷。

理解本问题需要把三个问题分开。第一,输入的外形是否足够像令牌,属于结构分类;第二,HMAC、时间戳和 session ID 是否可信,属于密码学验证;第三,账号此刻是否允许继续,属于外部策略。external-auth 的价值正来自三者可以独立表达。漏洞让第一项的弱答案授权了一个本应由第二项决定的持久状态写入。修复后的代码仍允许外部系统看到 Invalid,却不再让这个结果污染服务端令牌种子。

这条状态机也给出最短的根因表述:客户端控制的字符串在 HMAC 失败后被提升为服务端可信初始令牌,后续固定布局消费者因此失去长度和来源前置条件。这个表述同时解释输入、错误状态转换、内存访问与进程结果,适合用于漏洞台账、变更单和复盘标题,避免只写“Base64 断言崩溃”而丢掉真正需要长期防止的状态提升。

1.1 key_method_2_read() 从 TLS 控制信道取出用户名与密码

远端输入首先抵达 src/openvpn/ssl.ckey_method_2_read()。这里处理的内容已经经过 TLS 控制信道保护,函数先跳过四字节头部,读取 key-method 标志、双方数据通道密钥材料和 OCC 选项,再为 struct user_pass 分配临时对象。read_string() 按协议顺序提取 username 和 password,随后读取 peer-info。用户名或密码超过 USER_PASS_LEN 会得到负长度,代码把认证状态设为失败并准备向客户端返回 AUTH_FAILED,因此本 CVE 使用的是长度上限内、正确终止的普通协议字符串。

当服务端启用用户名密码认证,且两个字段满足基本存在性要求时,key_method_2_read() 调用 verify_user_pass(up, multi, session)。调用结束后,整个 user_pass 会由 secure_memzero() 清除。远端原始字符串的短生命周期本来很清晰;缺陷发生在验证函数把其中一个值复制进连接级 tls_multi 对象,越过了临时凭据的清除点。

verify_user_pass() 进入后先用 string_mod_remap_name() 规范用户名,再用 string_mod() 把 password 限制在可打印字符集合。SESS_ID_AT_ 全部属于允许字符,规范化不会改变它。随后代码初始化 skip_auth=false,准备在内部令牌校验与外部认证模块之间选择路径。令牌分支的入口条件由两个布尔值组成:服务端配置启用了 auth_token_generate,并且 is_auth_token(up->password) 认为密码像一枚认证令牌。

控制信道包装决定谁能把字符串送到这里。强制客户端证书的实例通常要求一份受信任证书;使用 tls-authtls-crypttls-crypt-v2 的实例还要求相应控制信道材料;把客户端证书设为可选、并把账户判断交给外部系统的部署会形成另一组前置条件。CVE 的“网络可达”描述精确指向完成相应 TLS 建连后的应用层入口,普通未建连 UDP 数据报停留在更早的协议阶段。

同一函数还解释了为何 server 进程会在认证结果尚未完全落定时生成令牌。脚本与插件返回 success 或 deferred 都被视为可以进入公共成功块;management deferred authentication 也会把 key state 标成 KS_AUTH_DEFERRED。公共块在最终异步决定之前调用 generate_auth_token()。这项设计让 OpenVPN 能提前准备续签状态,正常情况下输入字段已经满足完整令牌契约;CVE 把畸形初始值带入了这里。

从网络到崩溃的第一段链路因此可以固定为:TLS 控制报文 → key_method_2_read()read_string(password)verify_user_pass()。后续所有状态都能回到这枚 user_pass.password,没有借助配置文件注入、管理端写内存或本地文件破坏。它是一条远端协议数据进入服务端连接状态的直接路径。

1.2 auth-gen-token 原本为重新协商保存一次登录

--auth-gen-token 解决的是长连接与一次性凭据之间的摩擦。用户首次通过脚本、插件、PAM、RADIUS 或其他后端验证后,服务端生成一枚临时 token 并推送给客户端。后续 TLS 重新协商时,客户端把 token 放进 password 字段,OpenVPN 在本地验证 HMAC 与时间窗口,无需再次向外部系统提交原始密码或 OTP。活跃客户端可以持续获得更新后的 token,离线时间过长或超过总 lifetime 的 token 则失效。

默认模式把内部令牌验证当作最终答案:完整且未过期的 token 可以跳过外部认证;无效 token 会直接失败。external-auth 改变的是决策位置。选项解析器接受 auth-gen-token [lifetime] [renewal-time] [external-auth],最后一个关键字把 options->auth_token_call_auth 设为 true,并在初始化阶段复制到 tls_options。此后即使 token 内部验证成功,普通认证方法仍会被调用。

这种模式适合账户状态可能在 token 生命周期内变化的组织。服务端的 HMAC 只能证明 token 由共享密钥生成、内容未被改写且仍处于时间窗口;它无法知道员工是否离职、设备是否被隔离、MFA 风险是否升高、租户权限是否撤销。外部系统通过每次重新协商得到机会,结合账号目录、设备姿态或人工审批作出新的允许/拒绝决定。

为了让外部系统理解上下文,OpenVPN 导出 session_statesession_id。状态可以是 InitialAuthenticatedExpiredInvalid,以及兼容空用户名客户端的两个变体。手册明确要求 external-auth 处理器对 Invalid/Expired 做出恰当判断;只有客户端可以用另一种可接受方式完成认证时,才应继续。这个接口表达的是“内部令牌结果 + 外部账户结果”的组合,不承诺无效 token 自动获得隧道访问。

长期 session ID 让外部系统可以把续签与最初登录关联起来。服务器更新 renewal timestamp 时保留最初 session ID 和 initial timestamp,使同一次登录在多次 TLS renegotiation、短暂重连与集群节点迁移后仍有稳定标识。若配置 auth-gen-token-secret,多个服务端可以共享 HMAC 密钥,客户端也能把 token 带到另一个节点继续会话。正因为这些字段需要长期稳定,auth_token_initial 才成为固定偏移消费者共同依赖的档案。

代码规则由此非常具体:external-auth 可以推翻“账号是否允许”的结论,却不应重写“这段内存是否拥有 token 布局”的事实。一个账户脚本返回 success,只能授权连接策略;它无法为十一字符字符串补出 session ID、时间戳和 HMAC。漏洞在代码中混合了这两类授权,让外部策略的续行把不完整结构送进只接受完整记录的内部生成器。

从身份系统视角观察,同一 password 字段在一次连接里可能承载三种材料:首次登录的真实密码、服务端签发且仍有效的 token、内容真实但已过期或无效的 token。external-auth 通过 session_state 获知它面对哪一种,而 OpenVPN 仍负责确保导出的 session_id 来自结构完整的记录。这个职责划分使后端无需解析私有 Base64 布局,也不应要求后端用字符串长度替 OpenVPN 兜底。正确补丁保留了接口分工,升级后原有 PAM、RADIUS、插件和 management 策略无须理解新字段即可继续工作。

1.3 完整令牌由 60 字节载荷编码成 91 个字符

v2.6.21 的 auth_token.c 把格式常量集中在文件开头。AUTH_TOKEN_SESSION_ID_LEN 是十二字节;两个 int64_t 分别保存最初创建时间与最近更新时间;AUTH_TOKEN_HMAC_LEN 取 SHA-256 摘要长度三十二字节。四段相加得到六十字节 TOKEN_DATA_LEN。静态断言要求长度能被三整除,因此 Base64 输出没有 padding,长度稳定为八十字符。

外部 credential 再加十一字符 SESSION_ID_PREFIXTOTAL_SESSION_TOKEN_LEN 最终等于九十一。前缀是协议标记,后八十字符才是完整载荷。十二字节 session ID 恰好编码为十六字符,所以代码可以从前缀后复制固定十六字符作为外部 session_id。时间戳位置跨越 Base64 三字节分组,generate_auth_token() 从初始时间位置取十二字符解出九字节,再使用其中八字节恢复 initial_timestamp

HMAC 覆盖 username、session ID、initial timestamp 与 renewal timestamp。验证器先对客户端 username 计算一次;若失败,又以空 username 计算一次,以兼容历史 OpenVPN 3 客户端的用户名提示行为。后一种成功会设置 AUTH_TOKEN_VALID_EMPTYUSER 并清空本次 username。只有两个 HMAC 都失败时,函数返回零,说明这段结构无法证明来自持有服务端秘密的生成器。

时间判定发生在 HMAC 成功之后。当前时间必须落在 renewal timestamp 起始、两倍 renewal 间隔结束之前;initial timestamp 不能晚于 renewal timestamp;若配置总 lifetime,当前时间也不能超过最初创建时间加 lifetime。超出窗口会保留 AUTH_TOKEN_HMAC_OK 并叠加 AUTH_TOKEN_EXPIRED,让 external-auth 区分“内容真实但过期”和“内容不真实”。这套位标志设计本来已经为状态写入提供了足够细的依据。

短输入在第一步 Base64 长度检查就失败。验证器从 password 的第十一字符开始解码,得到的 decoded_len 必须等于六十;否则写一条 wrong size 警告并返回零。它没有读取二进制载荷中的时间戳或 HMAC,也没有把失败伪装成成功。CVE 的核心因此不在 verify_auth_token(),而在调用者收到零之后仍执行了持久化赋值。

这份格式契约可以用于审计所有消费者。任何从 auth_token_initial 读取 +11+27 或更后位置的代码,都需要先建立“字段来自完整九十一字符记录”的证明;任何写入该字段的代码,都应当证明 token 的服务器来源与完整性。修复后的 length gate 负责前一个结构条件,HMAC gate 负责后一个来源条件,二者恰好覆盖了令牌图上的两种承诺。

字符偏移和二进制偏移需要分开记账。+11+27+39 都位于外部 ASCII 字符串;十二、八、八、三十二则是 Base64 解码后的二进制字段长度。+27 的初始时间位置来自十二字节 session ID 编码后稳定占用的十六字符。审计人员若把两套坐标混在一起,很容易误判写入幅度或认为时间戳从二进制偏移二十七开始。图 1 特意同时保留两把刻度,后续每一次指针移动都能回到具体常量和静态断言。

六十字节能整除三也解释了为何合法 token 没有 Base64 padding。若未来增加一个字节,编码长度、尾部等号和所有字符窗口都可能改变;static assertion 会在编译期迫使维护者重新审视这些消费者。这里的常量并非为了省一次解析而随意硬编码,它们把协议布局变成编译契约。安全评审需要同时检查推导公式和消费者,不能只把九十一当成永远不变的魔数。

OpenVPN auth-token 精确布局图:十二字节 session ID、两个八字节时间戳与三十二字节 SHA-256 HMAC 组成六十字节载荷,编码为八十字符并与十一字符 SESS_ID_AT_ 前缀合成九十一字符凭据。
移动端可横向滑动查看字节与字符偏移
图 1:九十一字符构成所有固定偏移读取共同依赖的内存契约。旧分类器只证明前十一字符存在,后续消费者却按完整六十字节载荷生成的八十字符区域取值。

2 三道判定给出了三个答案

OpenVPN 没有在一个函数里决定所有认证语义。is_auth_token() 判断输入是否属于 token 语法;verify_auth_token() 判断其密码学状态;脚本、插件或 management interface 判断账户策略。这样的拆分让日志与策略更精确,也让无状态 token 可以和实时目录共同工作。缺陷来自调用者把最弱的第一个答案当作写入最强状态的许可证。

2.1is_auth_token() 只比较了十一字节前缀

受影响树的分类器位于 auth_token.h,实现只有一次常量时间比较:以 strlen(SESSION_ID_PREFIX) 为长度,对比 password 与 SESS_ID_AT_。函数注释写的也是“是否以 session token prefix 开头”。它没有检查 password 总长,也没有验证 Base64 字符、解码长度、时间戳或 HMAC。对 prefix-only、prefix 加一个字符、甚至正确前缀后跟任意短文本,答案都为 true。

常量时间比较在这里保护的是前缀匹配的时序特征,不能补足结构校验。名称 is_auth_token() 容易让调用者读成“这是一枚 token”,实现实际承诺的是“开头像 token”。这种语义差距在仅用于路由到验证器时可以接受:验证器会拒绝畸形载荷。2021 年的状态写入把这个弱布尔值带到了另一个用途,分类器的承诺便不再足够。

输入最短可以正好等于前缀。memcmp_constant_time() 读取十一字节,password 数组包含这些字符及终止符,比较本身没有越界。旧分类器的局部内存安全性解释了为什么常规静态扫描可能不报警:危险不在这行读取,而在 true 结果允许调用者保存一个布局不完整的对象,再由后续函数做固定偏移访问。

修复把函数从 header inline 实现移到 auth_token.c,先计算 strlen(password),要求它严格等于 TOTAL_SESSION_TOKEN_LEN,再比较前缀。这样一来,函数名与消费者需要的结构承诺一致。等长、正确前缀、无效 HMAC 的值仍返回 true,因为它确实拥有可安全解析的 token 外形;真实性继续由专门验证器决定。

精确等号比“至少九十一字符”更合适。协议 token 由服务端生成,长度固定;额外尾随字符没有业务意义,还可能被不同 Base64 实现以不同方式处理。单元测试同时覆盖一字符短与一字符长,锁定完整记录的上下限。运营回移补丁时可以用这两个相邻值迅速判断是否只是增加了最低长度,还是保留上游的精确契约。

对这类 helper,最可靠的审查方法是写出它的后置条件,再逐个核对调用者所需的前置条件。旧实现的后置条件只有“前十一字节等于常量”;verify_auth_token() 只需要这个条件,因为它会自行解码并核验长度;auth_token_initial 的固定偏移消费者却需要“至少九十一字符、布局完整且来源可信”。同一个 true 值服务于两种不同强度的需求,缺口便出现在调用链上。修复把结构强度提升到所有调用者的共同下限,来源可信仍由 flags 单独表达,接口含义因此可以被机械地复核。

2.2 verify_auth_token() 已经明确返回失败

进入验证器后,OpenVPN 从前缀末尾开始 Base64 解码,目标数组大小是 USER_PASS_LEN。Base64 输出不会比输入更长,因此这块栈缓冲区足够。紧接着的长度检查要求 decoded_len == TOKEN_DATA_LEN;不满足就记录 ERROR: --auth-token wrong size 并返回零。prefix-only 的解码区域为空,路径在任何时间戳、HMAC 或 session ID 比较之前结束。

返回值是位集合。AUTH_TOKEN_HMAC_OK 表示完整性校验成功;AUTH_TOKEN_EXPIRED 表示内容真实但不处于接受窗口;AUTH_TOKEN_VALID_EMPTYUSER 表示只有空用户名参与 HMAC 时成立。调用者可以用等号要求“有效且未过期且用户名正常”,也可以用按位与判断“至少能证明服务端来源”。受影响代码保存初始 token 时没有读取任何一位。

当 decoded length 正确,函数从六十字节数组中切出 session ID、initial timestamp 与 renewal timestamp。两个时间戳用 memcpy() 搬入对齐的 uint64_t 局部变量,再做网络序转换,避免未对齐访问。HMAC 计算先使用客户端 username,必要时再尝试空 username。只有通过这一步的值才带有 AUTH_TOKEN_HMAC_OK

验证器还将当前 token 与已保存 initial token 的“前缀 + 十六字符 session ID”区域做常量时间比较,防止同一 tls_multi 在中途换到另一枚 session token。修复前,这项比较也依赖 initial field 至少包含二十七字符;但最短输入是在 initial field 为空时第一次写入,当前验证器因 decoded length 失败直接返回,尚未执行该比较。危险由调用者在返回后制造。

从数据流角度看,verify_auth_token() 已提供一条清晰的 taint 分界:零表示输入不能作为服务端 token;带 HMAC 位表示可以信任其来源和布局;带 expired 位表示外部策略仍可考虑其他认证方式。把这三个状态压扁成“看起来像 token”会丢失函数最有价值的输出。2026 年补丁没有重写验证算法,只把现成的 HMAC 位接回持久状态写入条件。

flags 的组合还提供了一张可执行的真值表:零值禁止进入 initial;只有 AUTH_TOKEN_EXPIRED 而没有 HMAC 位在当前实现中不会出现;HMAC OK 单独出现表示真实且处于窗口;HMAC OK 加 EXPIRED 表示真实但需要外部重新判断;HMAC OK 加 EMPTYUSER 表示兼容路径验证成功。修复使用按位与而没有比较完整枚举值,正是为了允许后三种可信来源继续携带最初身份。下游回移若写成 flags == AUTH_TOKEN_HMAC_OK,虽然挡住攻击样本,却会破坏过期续认和空用户名兼容,需要在补丁评审时拒绝。

2.3 external-auth 让账户策略继续,却没有认可令牌内容

auth_token_call_auth 为 false,内部 token 失败会调用 wipe_auth_token()、把 key state 认证状态设为 false,并立即返回。畸形值即使短暂被复制,也会在进入环境导出和令牌续签之前被清理,公告描述的崩溃链无法完成。external-auth 分支故意不在这里返回,因为外部系统需要看到 Invalid/Expired 并决定客户端是否还能用证书、重新认证或其他因素继续。

代码注释写得很直白:在 external-auth 模式下,内部结果由外部认证负责解释。这个空分支没有把 auth_token_state_flags 改成成功,也没有设置 skip_auth;它只是继续执行公共环境准备和认证模块调用。令牌密码学仍为失败,账户策略尚未回答。畸形值被保存的那次赋值位于这项分岔之前,因而两条语义被错误交叉。

set_verify_user_pass_env() 在调用外部模块前设置 username、common_name、真实客户端地址,并调用 add_session_token_env()。后者根据 token flags 生成 session_state=Invalid,还必须提供 session ID。正常情况下,无效或首次密码没有可复用 ID,函数会调用 generate_auth_token() 创建一枚完整本地 token,再从中提取十六字符。受影响路径已经把短值塞入 auth_token_initial,使“如果为空则生成”的保护被绕过。

插件可以返回 success、error 或 deferred。success 与 deferred 都使 plugin_ok 为真;脚本 success 同样为真;management deferred authentication 会通知管理客户端并把 key state 置为 deferred。只要组合条件没有出现明确 error,代码进入公共成功块。对 deferred 分支,最终身份决定还没回来,令牌生成已经发生。崩溃因此可能早于操作员或异步身份系统的后续批准。

手册对 external-auth 处理器提出的警告依然成立:Invalid/Expired 通常应拒绝,除非客户端能以另一种可接受方式认证。一个同步脚本若立即对 prefix-only 密码返回 error,路径会停在环境导出后的失败块,通常不会抵达 generate_auth_token() 中的断言;它仍会经过前置固定长度读取。返回 deferred 的插件或 management 模式则能在最终判定之前进入生成器。具体可达性取决于后端类型与配置,不能用一个脚本实验概括全部部署。

外部 success 也不等同于 OpenVPN 认证绕过。外部系统本来就拥有允许连接的权限;如果它根据有效证书、二次认证或本地策略接受用户,这是预期路径。已公开并可证明的安全后果是,攻击者构造的 token-shaped 输入污染内部 state,随后使 daemon 退出。评估时应分别记录“外部模块为何继续”和“内部 token 为何无效”,不要把身份策略与内存契约混成一个问题。

调用顺序也决定了缓解措施的有效范围。让所有同步脚本立即拒绝 Invalid 可以阻断后面的公共生成块,却阻止不了脚本调用前已经发生的 session ID 越界读取;插件或 management 先返回 deferred 时,连后一道阻断也不存在。WAF 规则无法看到 TLS 内 password,后端规则又位于第一次非法访问之后。证书准入、连接速率限制和进程监督能够降低攻击频率或恢复时间,只有在分类与持久化写入点修正代码,才能让所有后端形态同时回到安全状态。

3 失败的输入在校验之后被保存

漏洞的决定性语句没有复杂控制流:当 multi->auth_token_initial 为空时,受影响版本执行 multi->auth_token_initial = strdup(up->password)。它位于 verify_auth_token() 返回之后,却没有使用返回值。连接级字段从此持有一份长度与客户端输入完全相同的堆对象,后续消费者按照服务端生成格式理解它。

3.1 auth_token_initial 保存的是登录身份的起点

tls_multi 贯穿一个多会话连接,内部的 active、initial 与 lame-duck TLS session 可以轮换,auth_token_initial 仍作为连接级身份档案保留。它与 auth_token 分工不同:后者是当前准备推送或续签的 token,前者固定保存第一次 token 的 session ID 与初始时间,使后续生成器只更新 renewal timestamp。

generate_auth_token() 在没有 initial token 时随机生成十二字节 session ID,并把当前时间同时作为 initial 与 renewal timestamp。生成完完整 token 后,它复制到 multi->auth_token;若 initial 仍为空,再复制到 multi->auth_token_initial。这条服务器内部写入天然满足格式和 HMAC 条件。

客户端带着已有 token 建立新 TLS session 时,服务端需要继续同一身份。验证成功且 initial 为空,verify_user_pass() 可以把客户端提交的完整 token 保存下来;随后生成的新 token 从该字段恢复原 session ID 与 initial timestamp。这个用例解释了 2021 年重构为何把赋值移到验证函数:状态在第一次看到有效客户端 token 时就可用,环境导出与续签逻辑都更简单。

字段清理由 wipe_auth_token() 负责。函数对当前 token 和 initial token 分别执行 secure_memzero(strlen)free(),再把两个指针设为 NULL。释放逻辑按真实字符串长度擦除,不会访问九十一字符以外;畸形短值在清理路径本身没有固定偏移风险。危险来自字段非空期间的读取者。

所有权很直接:strdup() 返回的堆对象由 tls_multi 独占,直到 wipe 或连接析构。语义所有权却更强:字段名称、生成器和注释共同声明它是一枚经过认证的 initial auth token。C 结构体没有类型系统区分“任意 password string”和“完整 verified token”,这项区别只能由写入条件维护。

修复后的条件把语义写进控制流:initial 为空并且 auth_token_state_flags & AUTH_TOKEN_HMAC_OK 时才复制。Expired token 仍带有 HMAC OK,因此可保留原 session ID,让 external-auth 在重新认证后延续同一登录上下文;伪造或畸形 token 没有 HMAC 位,不能成为档案。这正符合手册允许外部系统处理过期状态的设计。

3.2 2019 的固定布局与 2021 的简化在这里相遇

提交 1b9a88a2… 在 2019 年把 auth-token 改成 HMAC 签名的无状态记录。此前服务端重连后会忘记 token,新实现把身份所需字段全部放入客户端携带的 credential,使服务端凭共享密钥即可验证。紧随其后的 c8723aa7… 增加永久 session ID 和 external-auth 环境变量,让外部系统能区分重连与多连接。

固定偏移读取从这一阶段进入代码。十二字节 session ID 的 Base64 长度稳定为十六字符,add_session_token_env() 可以从前缀末尾复制这段文本;生成器也可以在续签时从 initial token 恢复 ID 和时间。2020 年的 42fe3e81… 修正了环境变量曾漏掉第一个字符的偏移错误,说明该位置对外部集成确实是长期接口。

2021 年提交 d75e0736… 清理 initial token 的处理。提交说明明确希望 initial field 始终可用,移除推送阶段的延迟赋值,并在客户端 token 验证时直接把 up->password 设为 initial。重构还增加 session ID 不能在同一 session 中变化的检查,整体目标是减少特殊分支并强化会话连续性。

缺失条件藏在“once we verified”与实际代码之间。提交说明把输入描述为已经验证的有效 token,具体赋值却只受外层 is_auth_token() 分支保护。默认模式中,无效 token 很快被 wipe 并 return,这个误差难以显现;external-auth 让失败结果继续,才把短值带到固定布局消费者。

官方受影响范围从 2.6.0 开始。v2.5.11 同样有前缀型 is_auth_token() 和 external-auth,但 verify_user_pass() 没有受影响的 client-input initial 赋值,2021 master 重构也没有以同一提交进入 2.5 分支。源码对比与 CNA 范围一致,不能因为“共享旧分类器”便把 2.5 推入受影响范围。

这段历史说明代码审查要追踪不变量的迁移。2019 年消费者假设 initial token 由服务器生成;2021 年生产者新增“由客户端恢复”的来源,原假设需要一同迁移到写入门槛。函数单独测试都可能正常:分类器正确路由前缀,验证器正确拒绝短值,生成器正确解析完整 initial;组合后的生产者集合发生变化,旧消费者的前置条件却没有重新证明。

3.3 环境导出先从短对象后面读取十六字节

set_verify_user_pass_env() 在任何插件、脚本或 management 通知之前调用 add_session_token_env()。函数先把状态标为 Invalid,然后选择 session ID 来源。flags 没有 HMAC OK 时,它进入 else 分支;若 initial 为空,本应调用 generate_auth_token() 创建一枚合法本地 token。受影响代码刚刚保存了短值,initial 非空,生成动作被跳过。

接下来执行 session_id_source = multi->auth_token_initial。局部数组 session_id 有二十四字节并已清零,memcpy() 从 source 加十一开始复制十六字节。目的缓冲区容量足够,源对象却只有输入长度加一。prefix-only 示例的 strdup() 分配十二字节:偏移零到十是前缀,偏移十一是 NUL,随后十五字节位于对象之外。

复制出的第一字节是终止符,setenv_str() 通常把 session ID 视为空字符串,不会把终止符后的堆内容作为 C 字符串输出。这个事实限制了直接信息泄露推断,却不改变 memcpy() 已完成越界读取。AddressSanitizer 可以在外部认证模块被调用前报告 heap-buffer-overflow;普通 allocator 可能让读取落在同一 size class 的可访问页中,进程继续走到后面的路径。

若输入是前缀加少量字符,复制区域会先包含攻击者字符,再遇到字符串终止符,之后继续越过对象。环境变量仍在第一个 NUL 结束。读取宽度固定十六,越界量随输入长度变化;长度达到前缀加十六时,这一处 session ID 复制不再越界,但 token 仍可能远短于九十一,生成器的时间戳读取继续需要更后区域。

这项前置访问解释了为什么只搜索崩溃日志中的 generate_auth_token() 可能漏掉 sanitizer 现场。安全构建可能在 add_session_token_env() 就终止;生产构建可能记录 session_state=Invalid、向管理接口发出认证请求,然后在生成器断言退出。调查应保存首个内存错误位置;core 的栈顶会随插桩和构建方式变化。

固定版本依靠两层保护让这段 memcpy 安全。prefix-only 不再被 is_auth_token() 分类为 token,状态成为 Initial;initial 为空时,函数调用生成器建立完整本地 token,再从其第十一字符读取十六字节。等长但 HMAC 失败的值仍是 Invalid,HMAC gate 阻止它写入 initial,函数同样生成完整本地对象作为 ID 来源。消费者无需自己重复长度校验,因为生产者集合已经被修复。

在动态分析里,这一读取应当单独设断点或 sanitizer 预期。prefix-only 的源地址等于分配对象最后一个合法字节,十六字节复制跨出十五字节;长度为二十七的“前缀 + 十六字符”样本会使本次复制落在对象内,却仍不足以支撑后续时间戳窗口;九十字符样本只差一个字符,旧版本依然会把它当作 initial。用 11、27、39、90、91、92 这些关键长度组成语料,比只保留一个崩溃 PoC 更能说明各消费者需要的最小契约,也更容易在未来字段调整时发现新断层。

OpenVPN external-auth 函数状态图:key_method_2_read 读取密码,旧分类器接受前缀,verify_auth_token 返回零,畸形值写入 auth_token_initial,环境导出固定读取,外部认证成功或 deferred 后生成器解析固定偏移并由 assert_failed 结束进程。
移动端可横向滑动查看函数与状态顺序
图 2:shape、HMAC 与 policy 是三次独立判定。缺陷发生在第三项尚未完成时,第一项的弱分类已经写入持久字段;修复把结构长度放回分类器,把 HMAC 来源证明放回状态写入点。

4 断言之前,指针已经走出对象

上游把漏洞描述为 malformed auth-token 触发可达断言,最终可用性结论成立。逐行执行 prefix-only 示例会看到更完整的内存账本:环境导出在真实对象末尾继续读,生成器创建精确副本后把时间戳指针放到对象外,又在调用 Base64 解码前写入 NUL。断言负责稳定结束进程,却不是第一条非法访问。

4.1 success 与 deferred 都会进入公共令牌生成块

完成环境准备后,verify_user_pass() 根据配置调用 management、插件和脚本。插件状态为 SUCCESS 或 DEFERRED 时 plugin_ok 为真;脚本同理接受 SUCCESS 或 DEFERRED;management 只要没有返回 ERROR,就能通过公共条件。用户名锁定 tls_lock_username() 也必须成功,防止同一连接在认证过程中更换用户。

条件成立后,key state 先被设为 KS_AUTH_TRUE。任一插件/脚本 deferred 会改为 KS_AUTH_DEFERRED;management 模式也会根据 man_def_auth 改成 deferred。代码仍处在同一个成功分支,继续处理 username-as-common-name 和 auth-token。这里的“成功分支”表示认证流程被接受或挂起,并不保证异步外部结论已经到达。

若客户端 token HMAC 有效、未过期且当前 multi->auth_token 为空,代码先把本次 password 复制到 current token。prefix-only flags 为零,不会经过这项赋值。随后只要 auth_token_generate 启用,函数无条件调用 generate_auth_token(up, multi)。畸形 initial field 因而成为生成器唯一读取的历史来源。

同步脚本对短 password 返回失败时,公共条件不成立,key state 变为 false,当前调用不会进入生成器。插件若先返回 deferred、打算稍后检查控制文件,代码已经调用生成器;management deferred authentication 通知控制端后也会先生成 token。触发模型必须记录后端的即时返回值,而不能只看最终身份结果。

外部策略可接受另一种身份材料时,返回 success 同样合理。例如部署允许客户端证书作为替代认证因素,脚本根据证书上下文接受 Invalid token。该业务决定不会让短字符串变长,生成器仍会崩溃。安全修复因此必须落在内部 state 写入处,无法依赖每一个外部集成都拒绝某种输入。

4.2 string_alloc() 只复制真实长度,解析器却按九十一字符前进

generate_auth_token() 先建立垃圾收集 arena,准备当前与初始时间戳,并取出 HMAC context。发现 multi->auth_token_initial 非空后,它调用 string_alloc(initial, &gc),避免解析时直接修改持久字段。buffer.c 的实现计算 n = strlen(str) + 1,通过 gc_malloc(n) 分配,最后 memcpy(ret, str, n)。没有预留九十一字符容量。

prefix-only 的副本因而精确为十二字节。old_sessid = initial_token_copy + strlen(SESSION_ID_PREFIX) 得到 base + 11,指向真实终止符。old_tsamp_initial = old_sessid + AUTH_TOKEN_SESSION_ID_BASE64_LEN 随后试图形成 base + 27。对这个十二字节对象,数组指针算术只能产生 base 至 one-past 的 base + 12;base + 27 已越过 one-past,因此形成该指针本身就是未定义行为。后续读取、写入与 decoder 调用只是继续使用这个无效结果,并非 C 对象模型第一次失效的位置。

生成器希望把 initial timestamp 的十二字符 Base64 窗口临时截断,因此执行 old_tsamp_initial[12] = '\0'。目标地址是 base + 39,比十二字节对象末尾远二十八字节。这是一个确定的单字节越界写,写入值固定为零,发生在任何解码长度断言之前。

下一行把 base + 27 交给 openvpn_base64_decode(),目标局部数组九字节,并断言返回值等于九。prefix-only 对象在 base + 27 没有合法字符串,decoder 读取周围堆内容直到遇到此前写入的 NUL 或其他终止条件。普通情况下返回长度与九不等,断言触发;具体 sanitizer 也可能在这次读取或前一行写入直接中止。

假设第一次断言意外通过,代码还会把解码数组前八字节解释为 initial timestamp,把 old_tsamp_initial[0] 写成 NUL,再从 base + 11 解码十二字节 session ID,并要求返回十二。prefix-only 在 base + 11 立即遇到真实终止符,这一断言仍会失败。多重断言提高了普通构建终止的确定性,却没有把越界访问变成安全检查。

这条路径展示了断言的正确用法。生成器内部原本只接收自己产生或已经 HMAC 验证的 token,在该不变量下固定偏移和 ASSERT 能捕获不可能的内部损坏。生产者集合改变后,不可信输入抵达断言之前已经参与指针运算与写入。字段写入处必须完成远端输入验证,深层断言继续承担内部一致性检查。

不同内存工具可能给出三种首报位置。ASan 通常在环境导出的 memcpy() 发现源堆越界;若该路径未被插桩或对象落在可访问区域,它可能在 old_tsamp_initial[12] 报告写越界;若两次访问都未被运行时捕获,Base64 decoder 读取垃圾区域并由长度断言结束。Valgrind 会把首次非法读定位到复制指令,普通 release 构建则更可能只留下 fatal assertion。三个现场属于同一数据对象和同一根因,不能因栈顶不同就拆成互不相关的故障。

4.3 assert_failed()_exit(1) 结束整个 daemon

OpenVPN 的 ASSERT(x) 宏在条件为 false 时调用 assert_failed(__FILE__, __LINE__, #x)。函数以 M_FATAL 记录文件、行号和条件,然后直接 _exit(1)。它不抛出可由当前连接捕获的异常,也不只关闭一个 TLS session;单进程服务端的所有客户端会共同失去该 daemon 提供的隧道。

进程管理器通常会重启 OpenVPN,HA 集群也可能把流量切到其他节点,因此单次崩溃不必然造成长时间全面中断。远端可以在服务恢复后重复满足条件时,可用性影响会由一次重连放大为反复抖动。重新认证、会话迁移和路由收敛还会给外部身份系统与控制面增加负载。

core 是否产生取决于服务管理器、rlimit、容器策略和构建选项。_exit(1) 本身不会像 abort() 那样必然产生 SIGABRT core;AddressSanitizer 或前置非法访问可能以自己的信号终止。响应团队应同时收集 journal/service exit code、sanitizer 日志、kernel core pattern、容器 restart count 和外部认证事件,避免只等待一份标准 core。

日志顺序可以帮助区分路径。验证器可能先记录 wrong size;环境导出把 session_state 设为 Invalid;management 或插件可能收到认证请求;生成器随后在 auth_token.c 的 timestamp decode assertion 结束。若 ASan 在 add_session_token_env() 的 memcpy 报告,后续日志不会出现。构建哈希与源码行号必须一同保存,因为下游回移会改变行号。

固定零写入与堆外读取扩大了根因描述,却没有自动证明远程代码执行。对象大小会被 allocator 对齐,周围块布局由构建和运行状态决定,写入字节不可自由选择,进程很快进入致命路径,公开材料也没有给出控制流劫持。确认影响保持在服务端 DoS,sanitizer 报告仍应按内存安全事件保留,以便继续评估。

暖纸手绘的精确十二字节 token 偏移图:完整十六字节源读取是半开区间 [+11,+27),由 +11 处深色合法 NUL 与其后十五枚青色越界字节组成;+27 是读取的 exclusive end,也是随后形成的无效时间戳指针,+39 标出后续 NUL 写入。
移动端可横向滑动查看分配长度和偏移
图 3:四组三格让精确的 12 字节分配可以直接核数。完整 16 字节源读取是 [+11,+27):+11 处的深色 NUL 是其中唯一合法的 1 字节,+12 至 +26 的青色格是 15 字节越界读取。+27 标记读取的 exclusive end 与随后形成的无效时间戳指针,并不是 memcpy() 额外读取的一字节;赭色 12 字符窗口最终指向 +39 的零写入。这些操作均发生在后续断言之前。

5 可达性由版本、配置和认证后端共同决定

漏洞扫描器可以快速找到版本,无法单独回答实例是否进入该分支。准确风险模型需要同时确认受影响代码、auth-gen-token ... external-auth、TLS 客户端准入方式、外部后端类型及其即时返回。把这些条件写成资产字段,才能把修复优先级从产品名落到真实网关。

5.1 官方范围是 2.6.0—2.6.20 与 2.7 alpha1—2.7.4

OpenVPN CNA 记录把 Community OpenVPN 2.6.0 至 2.6.20,以及 2.7 alpha1 至 2.7.4 列为受影响。2.6.21 与 2.7.5 是各自发布线的修复下限。版本判断应以实际运行的 daemon 为准:采集 openvpn --version、进程可执行文件路径、包 build ID、进程启动时间与配置来源。

发行版经常保留上游版本字符串并回移安全补丁。看到 2.6.20 不足以断言仍然脆弱,看到安装目录中有 2.6.21 也不足以证明旧进程已经重启。可接受证据包括厂商安全公告、包 changelog、源码 patch、符号/反汇编显示精确长度检查与 HMAC 状态条件,以及重启后的进程映像哈希。

release/2.6 的修复哈希是 ee119b24b3178b8402b087a1f3f0930be25c5618;release/2.7 是 010b6c833b7fdbe889c0c7f8fc33f588a658b81f;master 是 423afa8e265a21527838540529a41fef834a4033。三个提交内容等价,祖先关系不同。变更审核与 SBOM 备注应记录目标分支对应哈希,避免用 master 提交存在性去证明 maintenance binary 已包含修复。

2.5.x 需要尊重厂商范围。v2.5.11 仍使用 prefix-only 分类器,代码外观相似,却没有 2.6 路线中那条无条件保存客户端 password 的赋值。共享一个弱 helper 不等于共享完整 CVE 链。已经停止支持的产品仍应按生命周期策略升级,但不能据此把它加入 CVE-2026-13122 的官方 affected set。

OpenVPN Access Server、网络设备固件和第三方 fork 可能复用 Community 代码,也可能有不同令牌实现或独立补丁。只凭产品 UI 显示“OpenVPN”无法套用版本表。设备供应商需要给出组件来源、修复公告或可验证回移;无法证明时,可以在隔离测试实例运行结构长度回归,观察 daemon 与状态字段。

5.2 配置必须解析到最终生效的 external-auth

主配置文件里搜索关键字只是起点。OpenVPN 支持命令行、include、内联配置、systemd unit、容器参数和控制面生成文件;设备平台还可能在启动前动态拼接选项。应读取进程命令行与最终配置来源,展开 include,确认 auth-gen-token 的 lifetime、renewal-time 和 external-auth 是否实际传入。

没有启用 auth-gen-token 时,verify_user_pass() 不调用 token classifier。启用 token 但没有 external-auth 时,无效 token 在内部验证后立即 wipe 并失败,公告链被截断。两种情况都应记录为配置级不暴露证据并保留在资产清单中,因为配置可能在下一次变更或灾备切换中变化。

客户端准入决定远端所需材料。检查 verify-client-cert、CA、CRL/OCSP、tls-authtls-crypttls-crypt-v2auth-user-pass-optional 与 username/password 插件。记录证书分发范围和是否存在可自助获得的客户端材料,能把 CNA 的低权限与高复杂度字段映射到组织环境。

external backend 也要分类。同步脚本遇到畸形 password 常会立即失败;插件可能先返回 DEFERRED;management interface 的 deferred auth 会在控制端稍后回答。公共代码在 deferred 状态生成 token,这使后两类更容易抵达断言。评估时需要实际返回码与时序,不能仅写“LDAP”或“RADIUS”。

多种认证方式可以同时启用,OpenVPN 要求脚本和插件结果共同可接受。一个 backend 失败会阻止公共成功块,另一个 deferred 不会覆盖失败。测试矩阵应列出每个模块的同步结果、控制文件/management 回调和最终 key-state 状态,并确认 prefix-only 值在哪一行终止。

秘密共享配置影响 token 跨节点验证,却不直接改变短输入 bug。auth-gen-token-secret 让多个节点使用同一 HMAC key,负载均衡后的 token 可以继续;未配置时重启会换临时 key,旧 token 变 Invalid。两种情况下受影响代码都可能保存短值。升级时应保护秘密文件权限,避免为了修 CVE 意外让集群 token 全部失效。

暖纸手绘的四道可达性闸门:一张空白凭据依次经过版本分叉、外部认证配置、客户端准入和后端即时结果,抵达右侧网关;下方青色旁路表示任一道前置不成立时,公开链路在进入脆弱路径前结束。
移动端可横向滑动查看可达条件
图 4:版本、最终生效配置、客户端准入和后端时序是四道独立闸门。资产结论必须逐道给证据;只看产品版本或主配置文件,无法说明请求是否真的到达共同令牌生成块。

5.3 已确认影响是网关 daemon 的可重复退出

公开 CNA 将漏洞归为 CWE-617 Reachable Assertion,CVSS 4.0 基础分 5.9 Medium。机密性与完整性影响均为 None,脆弱系统 availability 为 High;NVD 的 CVSS 3.1 评估为 5.3 Medium。两个评分体系的字段选择略有不同,共同落点是需要特定条件的网络触发服务不可用。

源码证实前置越界读取和单字节零写入,两者属于同一根因的连续执行阶段。没有可公开验证的任意读、任意写、身份绕过、隧道数据泄露或控制流劫持。实际构建的 allocator、hardening、sanitizer 与 watchdog 会改变表现。任何更高影响结论都需要独立复现。

单节点退出会同时影响该进程承载的所有客户端。TCP/UDP socket 关闭、隧道路由撤销或 DCO peer 清理取决于平台;客户端重连会撞向同一节点或其他 HA 节点。集中身份后端可能在短时间收到大量重新认证,请求风暴又会延长恢复时间。业务影响应按并发连接数、路由角色、恢复时间目标和备用容量计算。

重复触发需要重新满足 TLS 与认证时序。CVE 记录中的 Automatable: No 来自 CISA ADP 的 SSVC enrichment,不是 OpenVPN CNA 的 CVSS 4.0 结论;CNA 记录把 CVSS 4.0 的 Automatable 补充指标留为 undefined。攻击者若持有长期可用客户端材料、目标节点自动重启并保持相同配置,仍可能进行多次尝试。限速、连接配额与前置证书能提高成本,无法替代代码修复,因为合法客户端流量仍能进入脆弱分支。

异常也可能来自兼容性故障。损坏的客户端凭据缓存、错误剪裁 token、代理改写或人工测试都能产生前缀型短值。判断行为意图需要来源、频率、认证账号、证书、session_state、进程退出时序与重复模式。处置漏洞不要求先证明恶意利用;网关进程被远端输入稳定结束已经足以升级。

6 修复把“外形完整”与“来源可信”分别落到代码

三个分支的补丁规模不大,设计却很完整。第一道修改让分类器只接受精确九十一字符;第二道修改让 auth_token_initial 只接收带 HMAC OK 的客户端 token。结构与来源分别得到守卫,external-auth 仍可以处理 Invalid/Expired,原有业务能力得以保留。

6.1 精确长度让固定偏移重新拥有结构保证

新代码定义 AUTH_TOKEN_HMAC_LENTOKEN_DATA_BASE64_LENTOTAL_SESSION_TOKEN_LEN,用 OPENVPN_BASE64_LENGTH() 从二进制长度推导字符长度。两个 static assertion 保证 session ID 与完整 payload 的三字节整除关系,避免未来字段长度改变后静默出现 padding 或偏移漂移。

is_auth_token() 先比较 strlen(password) 与 total length,不等立即 false;相等后执行常量时间前缀比较。prefix-only 在最早分类点回到普通初始密码路径,add_session_token_env() 会生成一枚合法本地 token 作为 session ID 来源,畸形值不会进入 token decoder。

等长但字符内容无效的输入仍会进入 verify_auth_token()。Base64 decoder 或 HMAC 检查给出 flags 0,external-auth 获得 session_state=Invalid,可以按手册执行策略。结构长度保证让解析与环境导出拥有完整前提;密码学结果仍保持独立。

回移时硬编码 91 能修当前布局,保留推导常量更能抵御未来变化。若 HMAC 算法、timestamp 或 session ID 长度调整,编译期与单元测试会共同暴露不一致。厂商 patch 只增加 strlen >= 11 或最低长度无法等价证明修复。

分类器从 header 移到 C 文件也减少复制语义。所有调用者链接同一个实现,长度宏留在令牌模块内部,不要求其他组件知道完整布局。函数声明仍在 header,文档应更新为“完整长度且前缀匹配”,避免调用者再次把它理解成真实性验证。

这项门槛单独就能挡住公告中的短输入 assertion。上游同时修正 initial state 写入资格,把 invalid token 排除在持久字段之外。生产回移验收必须核对两处,否则未来等长无效 token 或新消费者仍可能遇到语义污染。

6.2 AUTH_TOKEN_HMAC_OK 成为持久状态写入许可证

受影响条件是 if (!multi->auth_token_initial)。修复把它扩展为 initial 为空并且 flags 包含 HMAC OK。这个按位检查有意允许 AUTH_TOKEN_EXPIREDAUTH_TOKEN_VALID_EMPTYUSER 组合:token 来源仍可信,外部策略可以在重新认证成功后继续原 session ID。

形状正确但 HMAC 错误的九十一字符输入不再写入 initial。进入环境导出时 initial 仍空,函数生成完整、由服务端密钥签名的临时 token 来提供 session ID。外部 backend 看到 Invalid,可以返回失败、success 或 deferred;无论策略如何,生成器后来读取的 initial 都满足自己的布局和来源不变量。

这是一条 provenance gate。length 证明对象可以安全解析,HMAC 证明对象来自持有 server secret 的生成路径且内容未改写。外部账户成功证明“用户可继续”,不能替代 provenance。把条件写在 state assignment 处,比让每个消费者单独重验更容易审计。

补丁复用现有 flags,没有增加重复 HMAC 计算。verify_auth_token() 已完成常量时间对比并保留结果,调用者只做一次位测试。热路径成本可忽略,状态语义却明显收紧。所有生产写入现在只有两类:服务器生成的完整 token,以及通过 HMAC 的客户端完整 token。

过期 token 的保留值得单独验证。external-auth 可能要求用户重新完成 MFA,成功后生成器需要延续原 session ID 与 initial time,避免把过期会话悄悄变成一段全新 lifetime。HMAC OK + expired 正是“内容真实、政策需重判”的状态。简单要求 flags 完全等于 HMAC OK 会破坏这项业务语义。

三排暖纸手绘分类路径:上排短纸条在定宽框处转入弃置盘;中排完整纸条通过长度框,却因没有青色封印而在来源检查后转入外部策略天平;下排完整且带青色封印的纸条穿过两道检查,最终进入上锁的持久档案箱。
移动端可横向滑动查看两道守卫
图 5:精确长度只证明对象可安全解析,HMAC 状态才允许客户端内容成为长期初始状态。短值退出令牌路径,等长但来源不可信的值仍可交给外部策略,却不能写入持久档案。

6.3 分支提交与单元测试共同证明修复

提交元数据记录了三个分支落点:release/2.6 的 ee119b24… 于 2026 年 6 月 30 日合入;release/2.7 的 010b6c83… 与 master 的 423afa8e… 均于 7 月 1 日合入。两个维护分支提交分别进入 2.6.21 与 2.7.5。三份 diff 都修改 auth_token.cauth_token.hssl_verify.c、手册和 unit test。

新增 auth_token_test_is_auth_token() 先确认两份 known-good token,再拒绝空字符串、普通 password、单独前缀和短前缀值。测试构造一个长度正确但前缀错误的数组,修正前缀后应识别为 token-shaped;最后把已知 token 缩短一字符和延长一字符,均要求 false。

环境变量测试开始捕获 session_id。新 token 生成后,测试在 auth_token_initial 中查找该 ID 并断言偏移等于十一;随后加载一份 known token,确认导出 ID 为预期十六字符。这些断言锁住环境消费者的字符窗口。

上游测试没有直接把 prefix-only 输入送入完整 verify_user_pass() external-auth 流,并断言 initial field 保持未污染。length test 会阻断公开样本,源码条件也清晰修复写入,但下游集成测试仍应补上这条跨函数 invariant,尤其覆盖 plugin deferred 与 management deferred 两类立即返回。

安全回归不需要在生产崩溃。隔离实例可以提交 prefix-only、一字符短、等长无效和一字符长的 credential,让受控 backend 返回 success、error、deferred,观察 daemon 存活、state flags、initial field 来源与 token 推送。测试日志只记录长度和分类,不保存 credential。

验证二进制时,优先使用版本与厂商证明;必要时可在 debug/sanitizer 构建设置断点。固定版本应表现为 prefix-only 不进入 token verifier,等长无效值进入 verifier但 initial 仍由本地生成。若后端返回 deferred,进程保持运行,异步结果随后正常完成或拒绝。

一条完整集成用例应同时断言六件事:daemon PID 不变;短值被分类为普通初始凭据;等长无效值得到 Invalid;两类值都没有成为 auth_token_initial;外部后端收到的 session ID 来自本地完整 token;deferred 的最终回调仍能正确改变 key state。再补一条有效、过期、空用户名兼容 token 的正向用例,确认 HMAC gate 没有切断业务路径。把这些断言放进 CI,才算覆盖了修复跨越的分类器、验证器、环境导出和生成器四个函数。

7 把修复落到网关,也把会话状态留给下一次审计

关闭 CVE 需要让实际运行进程拥有两道守卫,并证明 external-auth 集成在重新协商与 deferred 状态下仍工作。单纯安装包、重启一台备用机或只测试首次登录都不够。下面的步骤可以直接转成变更单、回归用例和事件调查字段。

7.1 资产盘点要落到运行二进制、有效配置和后端返回

先列出所有 Community OpenVPN daemon、设备内嵌实例、容器 sidecar 与灾备节点。每个条目记录监听地址、节点角色、进程 PID、可执行文件、openvpn --version、包 build、启动时间、镜像 digest、上游分支或厂商固件版本。对 HA 组记录连接数和可承受的逐台重启窗口。

解析有效配置,搜索 auth-gen-token 并保留完整参数。展开 include、systemd ExecStart 与 drop-in、容器 command/args、环境渲染和控制面生成文件。主文件未出现 external-auth 不能作为结束证据,进程实际参数与生成结果优先。

为每个启用实例标注 external mechanism:auth-user-pass-verify 脚本、认证 plugin、management-client-auth、deferred control file,及其组合。记录错误 password、Invalid token 和 pending 请求的即时返回码。plugin 的 DEFERRED 与最终拒绝是两个不同时间点,二者都要进测试表。

收集 TLS 准入事实:客户端证书是否强制、CA/CRL/OCSP、静态控制信道 key、token secret 是否集群共享、username/password 是否可选。凭据材料的分发范围帮助判断攻击者需要的已有权限,也决定更新过程中 token 是否能跨节点继续。

版本闭环要求修复证明与运行证明同时成立。2.6 线上至少是 2.6.21,2.7 至少是 2.7.5;回移包要能指出 length gate 与 HMAC state gate。更新后确认 PID/启动时间变化、旧 binary 不再映射、health check 与客户端 renegotiation 正常。

设备厂商未提供明确映射时,创建问题单要求其回答 Community 基线、CVE 状态与固件修复版本。等待答复期间可限制管理/认证入口、强化客户端证书、降低单进程承载量并准备快速重启;这些是风险缓释,不能替代最终补丁。

变更单可以直接规定退出条件:所有生产、灾备和自动扩容模板都列入范围;每个节点在滚动前确认剩余组容量,滚动后记录版本、PID、启动时间和 health;至少一名真实客户端完成首次登录、令牌续签与重新协商;四个畸形结构样本只在隔离实例执行;身份后端的成功、失败和 deferred 均有结果;监控窗口内没有新的 assertion、异常重启或认证洪峰。任何节点因固件限制无法更新,都要有 owner、临时准入限制、厂商工单与明确到期日。

上线顺序以一台低流量成员开始,先确认正常 token 能跨越至少一次 renegotiation,再逐步扩大批次。若共享 secret 不变,既有客户端应平稳迁移;若必须轮换 secret,应把全量重认证作为独立变更评估。回滚也要回到含两道守卫的上一可用包,不能因业务异常重新启用已确认脆弱构建。

7.2 回归与取证围绕长度、状态和进程生命周期展开

建立专用测试实例,复制生产的 external-auth 类型与关键 TLS 选项,使用测试 CA 和账号。四个结构样本是前缀 alone、总长九十、总长九十一但 HMAC 无效、总长九十二。它们覆盖旧分类、长度门槛两侧和 HMAC state gate,不需要任何破坏性 payload。

对每个样本分别让同步 backend 返回 error/success,让插件返回 deferred,并在 management 模式产生 pending 请求。固定构建应保持 daemon 存活;短/长值不得被识别为 token;等长无效值可以导出 Invalid,但 auth_token_initial 必须来自本地生成器或保持为空,不能复制客户端值。

正常流也要覆盖。首次密码登录应获得 token;重新协商应复用 session ID、更新 renewal timestamp;有效 token 应显示 Authenticated;过期 token 应显示 Expired,并按组织策略重新认证;空用户名兼容状态应符合客户端实际需求。修复不能以关闭 external-auth 业务语义换取测试通过。

历史调查从服务退出开始。搜索 Assertion failed at ... auth_token.c、wrong size、Invalid session_state、OpenVPN unit restart、容器 restart count 与 HA failover。若有 ASan,重点查 add_session_token_env 的 memcpy 和 generate_auth_token 的 timestamp terminator write。把事件按客户端地址、证书指纹、username hash 与时间关联。

不要记录完整 password 或 token。包装器可以记录 credential_lengthprefix_matchdecoded_length、flags、session_state、backend immediate/final result 和 connection ID。令牌是可重放敏感凭据,调试期间也应脱敏。core 可能包含 credential,访问和保留策略按秘密材料处理。

进程恢复时间与连接波动共同决定事件等级。一次退出若紧邻手工测试,来源明确且未重复,可以作为兼容性事件处理;多个来源反复命中、服务自动重启后再次退出、同一证书或账户持续尝试,应升级为主动利用调查。完成更新后保留一段观察窗口,确认退出与异常 length 信号停止。

取证时间线应把六类时钟统一到同一时区:入口防火墙的五元组、OpenVPN connection ID、证书验证、token wrong-size/flags、外部认证即时与最终结果、服务管理器退出和重启。客户端 IP 可能经过 NAT,username 可能包含敏感信息,关联时优先使用证书指纹、脱敏账号哈希和服务端连接标识。若最早记录是 sanitizer 越界而没有 fatal assertion,仍按同一路径调查;若只有重启而缺少应用日志,则检查 journald rate limit、容器上一实例日志和 watchdog 事件,避免把证据缺口当成未触发。

暖纸手绘的生产回归台:左侧四种空白结构样本分别通过量规,稳定的进程时钟与心跳守住中央,一枚带青色封印的令牌绕过完整重协商循环,停止、继续和延迟三条后端路径在右侧重新汇合,最终由健康双节点与证据箱收尾。
移动端可横向滑动查看回归闭环
图 6:验收同时观察四类结构样本、进程存活、令牌续签、同步成功或失败、延迟完成与双节点切换。只有这些路径都回到健康网关,并留下版本、进程与测试证据,修复才真正进入生产。

7.3 相邻审计确认固定树的生产者与消费者重新闭合

v2.6.21 中会填充或清理 auth_token_initial 的路径只有三类。generate_auth_token() 写入本地构造、六十字节载荷、HMAC 完整的 token;verify_user_pass() 只在客户端 token 通过 HMAC 后复制;wipe_auth_token() 负责清零、释放和置空。固定树中没有第三个未验证输入来源。

固定偏移消费者同样只有三处。add_session_token_env() 读取前缀后的十六字符 session ID;generate_auth_token() 恢复 session ID 与 initial timestamp;verify_auth_token() 在已有 initial 时比较前缀与 session ID 区域。每个消费者都能追溯到前述两个可信生产者,字段生命周期闭环。

等长无效 token 是重要反例。新 classifier 会返回 true,验证 flags 为零,HMAC gate 阻止写入 initial;环境导出发现 initial 为空,先调用生成器创建完整本地 token,再读 session ID。external-auth 可以继续做策略判断,后续生成器读取的是本地完整记录。这个路径证明两个补丁条件协同工作。

对 v2.5.11、deferred auth 分支、session-ID mismatch 比较和 token wipe 的交叉检查,没有在固定支持版本中确认第二条独立的畸形布局写入、认证绕过或可达断言。deferred 设计本身值得持续 fuzz,因为状态会在最终策略前推进;当前证据不足以把审计兴趣写成新漏洞。

这次缺陷留下的通用工程结论很朴素:解析器可以分别给出 shape、authenticity 和 policy 三类结果,持久状态写入必须明确采用哪一类授权。任何“第一次看到就保存”的便利缓存,都要重新证明后续消费者的布局和来源前置条件。把这条规则留在代码审查与回归测试里,价值会长于一个版本号。

最终变更目标可以用一句验收语言表达:所有运行中的相关服务端已经升级到 2.6.21、2.7.5 或具备两道等效守卫的回移版本,并完成重启;external-auth 的首次登录、有效续签、过期重认证和 deferred 流程均通过;四个结构长度样本不会污染 initial state 或终止进程。满足这三项,CVE 才算从公告进入了生产闭环。

研究记录

8证据、对象与来源

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

8.1研究对象

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

CVECVE-2026-13122

OpenVPN external-auth 畸形令牌状态污染与服务端退出

受影响版本OpenVPN 2.6.0-2.6.20 / 2.7_alpha1-2.7.4

OpenVPN CNA 公布的 Community 版本范围

修复版本OpenVPN 2.6.21 / 2.7.5

包含结构长度与 HMAC 状态写入守卫的上游版本

受影响配置--auth-gen-token ... external-auth

内部令牌状态继续交由外部身份系统判断的模式

令牌前缀SESS_ID_AT_

十一字符协议标记;完整 credential 总长应为九十一字符

2.6 修复提交ee119b24b3178b8402b087a1f3f0930be25c5618

release/2.6 分支修复

2.7 修复提交010b6c833b7fdbe889c0c7f8fc33f588a658b81f

release/2.7 分支修复

主线修复提交423afa8e265a21527838540529a41fef834a4033

master 分支修复与单元测试

8.2事件时间

  1. HMAC token 与永久 session ID 进入主线

    OpenVPN 建立六十字节载荷、external-auth 状态与固定 session ID 读取。

  2. 初始令牌处理被简化

    客户端第一次提交的 token 开始在 verify_user_pass 中直接写入 auth_token_initial。

  3. 上游完成修复编写

    分类器加入精确长度,状态写入加入 AUTH_TOKEN_HMAC_OK 门槛。

  4. OpenVPN 2.6.21 与 2.7.5 发布

    官方 Downloads 页面记录两个修复版本均于 2026 年 7 月 1 日发布。

  5. CVE 记录公开

    OpenVPN CNA 发布受影响版本、CWE-617 与 CVSS 4.0 评估。

  6. SOSEC 完成源码级复核

    固定四个版本,核对三分支提交、前置内存访问、历史形成与相邻生产者/消费者。

8.3来源与材料

  1. OpenVPN 安全公告:CVE-2026-13122https://community.openvpn.net/Security%20Announcements/CVE-2026-13122
  2. OpenVPN Community 下载页:2.6.21 与 2.7.5 发布记录https://community.openvpn.net/Downloads
  3. OpenVPN CNA 的 CVE-2026-13122 记录https://www.cve.org/CVERecord?id=CVE-2026-13122
  4. release/2.6 修复提交 ee119b24https://github.com/OpenVPN/openvpn/commit/ee119b24b3178b8402b087a1f3f0930be25c5618
  5. release/2.7 修复提交 010b6c83https://github.com/OpenVPN/openvpn/commit/010b6c833b7fdbe889c0c7f8fc33f588a658b81f
  6. master 修复提交 423afa8ehttps://github.com/OpenVPN/openvpn/commit/423afa8e265a21527838540529a41fef834a4033
  7. OpenVPN 2.6.20 auth_token.chttps://github.com/OpenVPN/openvpn/blob/v2.6.20/src/openvpn/auth_token.c
  8. OpenVPN 2.6.21 auth_token.chttps://github.com/OpenVPN/openvpn/blob/v2.6.21/src/openvpn/auth_token.c
  9. OpenVPN 2.6.20 auth_token.hhttps://github.com/OpenVPN/openvpn/blob/v2.6.20/src/openvpn/auth_token.h
  10. OpenVPN 2.6.21 auth_token.hhttps://github.com/OpenVPN/openvpn/blob/v2.6.21/src/openvpn/auth_token.h
  11. OpenVPN 2.6.20 verify_user_pass 实现https://github.com/OpenVPN/openvpn/blob/v2.6.20/src/openvpn/ssl_verify.c
  12. OpenVPN 2.6.21 verify_user_pass 修复实现https://github.com/OpenVPN/openvpn/blob/v2.6.21/src/openvpn/ssl_verify.c
  13. OpenVPN 2.6.21 key_method_2_read 入口https://github.com/OpenVPN/openvpn/blob/v2.6.21/src/openvpn/ssl.c
  14. OpenVPN 2.6.21 auth-gen-token 选项解析https://github.com/OpenVPN/openvpn/blob/v2.6.21/src/openvpn/options.c
  15. OpenVPN 2.6.21 external-auth 手册https://github.com/OpenVPN/openvpn/blob/v2.6.21/doc/man-sections/server-options.rst
  16. OpenVPN auth-token 单元回归https://github.com/OpenVPN/openvpn/blob/v2.6.21/tests/unit_tests/openvpn/test_auth_token.c
  17. OpenVPN string_alloc 精确长度分配实现https://github.com/OpenVPN/openvpn/blob/v2.6.21/src/openvpn/buffer.c
  18. OpenVPN assert_failed 进程退出实现https://github.com/OpenVPN/openvpn/blob/v2.6.21/src/openvpn/error.c
  19. OpenVPN tls_multi、token flags 与认证状态定义https://github.com/OpenVPN/openvpn/blob/v2.6.21/src/openvpn/ssl_common.h
  20. OpenVPN 2.5.11 对照:未含同一 initial 写入https://github.com/OpenVPN/openvpn/blob/v2.5.11/src/openvpn/ssl_verify.c
  21. 2019 HMAC auth-token 重写https://github.com/OpenVPN/openvpn/commit/1b9a88a2c38a6a29f8ccbec4fd529d7b363bfc06
  22. 2019 永久 session ID 与 external-authhttps://github.com/OpenVPN/openvpn/commit/c8723aa7bebb0ddb088819590d5a9bea6ea0d669
  23. 2020 session ID 环境偏移修复https://github.com/OpenVPN/openvpn/commit/42fe3e8175822a4cf2c85cc4ce3fdffd41d74455
  24. 2021 初始 auth-token 处理重构https://github.com/OpenVPN/openvpn/commit/d75e0736b4a0501a2c038ecb55730bf4f482b990
  25. OpenVPN 2.6.21 Changes.rsthttps://github.com/OpenVPN/openvpn/blob/v2.6.21/Changes.rst
  26. OpenVPN 2.7.5 Changes.rsthttps://github.com/OpenVPN/openvpn/blob/v2.7.5/Changes.rst
  27. OpenVPN external-auth token 续签行为讨论https://github.com/OpenVPN/openvpn/issues/506
  28. OpenVPN deferred authentication 历史安全修复https://community.openvpn.net/Security%20Announcements/CVE-2020-15078