前言

2026 年 8 月 27 日,CVE-2026-81934 的公开记录落地。它描述的是 Redis 里一个释放后使用(Use After Free, UAF)漏洞,位置在 src/tls.c 的 tlsProcessPendingData() 函数,影响所有编译并启用了 TLS 支持的 Redis 实例。

比 CVE 记录早两天,一份可用的漏洞验证代码(Proof of Concept, PoC)已经躺在 GitHub 上。它把这个悬垂指针一路串到了任意命令执行,最终以 redis-server 进程身份跑出 id

这件事值得写一篇长文,原因不止于漏洞本身。

围绕它的公开信息存在三处明显的错位:评分从 9.8 一路改到 7.5;CVE 描述文本说无需认证,修订后的评分向量却写着需要低权限;Redis 官方给出的缓解建议,与公开 PoC 实际依赖的命令集合并不吻合。国内不少转载直接照抄了最早的口径,把 9.2 和"无需认证"当成既定事实往外发。

本文先把机理讲清楚,再逐项核实影响范围。第二部分是重点,因为一个 Redis 远程代码执行(Remote Code Execution, RCE)的标题落在运维邮箱里,和它真实落在这台机器上的概率,中间隔着一整个配置审计的距离。

在展开之前,先给结论。

第一,这个漏洞只存在于启用了 TLS 的 Redis 上,纯 TCP 实例不在影响范围内。第二,公开 PoC 强绑定 Redis 8.8.0 的 amd64 官方镜像,换构建就要重算偏移,失败率不低。第三,截至 2026 年 8 月 27 日,Redis 声明未发现客户环境中的主动利用。第四,真实风险档位取决于一个具体问题:能连上那个 TLS 端口的账号,有没有脚本和 Pub/Sub 的执行权限。

一、漏洞事实:九行补丁与一条错位的时序

1.1 时间线

这个漏洞的时间顺序有点反常,值得先摆出来。

日期

事件

2026-08-17

修复提交 6d088c3 合入,修复 issue #1391,同日为 CVE 的 datePublic

2026-08-25

v12-security 将 PoC 加入公开仓库(提交 a83d484

2026-08-27

CVE-2026-81934 记录公开(19:40 UTC),CISA 同日记录 SSVC 决策(19:12 UTC)

2026-08-28

Redis 发布官方安全通告

2026-08-31

CVE 记录更新

2026-09-01

Redis 通告追加更新,确认公开记录已被修订为 CVSS 7.5(High)

补丁先落地,PoC 后公开,这是协调披露的正常节奏。真正值得注意的是表格末行:公开记录原本给的是 9.8(Critical),Redis 提出异议并请求复核,复核结果是记录被改成 7.5(High)。

一个 CVSS 从 Critical 被改回 High,中间差的不只是分数,是对攻击前提的定性分歧。这个分歧放到第四节细讲。

1.2 受影响与已修复的版本

Redis 开源版(仅在编译并配置启用 TLS 时受影响):

分支

受影响

已修复

8.10.x

< 8.10.1

8.10.1

8.8.x

< 8.8.2

8.8.2

8.6.x

< 8.6.6

8.6.6

8.4.x

< 8.4.6

8.4.6

8.2.x

< 8.2.9

8.2.9

7.4.x

< 7.4.11

7.4.11

7.2.x

< 7.2.16

7.2.16

6.2.x

< 6.2.24

6.2.24

CVE 记录里的受影响区间写作 from 0 before <版本>,也就是说该分支的全系列都算受影响,上限是修复版本号。这张表的信息量在于分支覆盖面:从 6.2 到 8.10,八个长期维护分支全部在列,包括已经很老的 6.2 与 7.2。

Redis Software(企业版) 的四个受影响构建与对应修复版本:

分支

受影响

已修复

8.2.0

< 8.2.0-46

8.2.0-46

8.0.20

< 8.0.20-96

8.0.20-96

7.22.2

< 7.22.2-179

7.22.2-179

7.8.6

< 7.8.6-303

7.8.6-303

Redis Cloud:Essentials 订阅已完成修补,Pro 订阅的修复正在推进,需要加快维护排期的客户走技术客户经理(Technical Account Manager, TAM)通道。

不受影响的部分:编译时未带 TLS 支持的实例;编译带了但 tls-port 没有配置、流量全部走纯 TCP 的实例。

1.3 触发前提:TLS 是一条额外的代码路径

这一点必须说透,因为它把影响面直接收窄了一大截。

Redis 的网络层是抽象的 connection 接口,底下挂两套实现:纯 TCP 的 connSocket,以及 TLS 的 connTLS。TLS 连接有个 TCP 连接没有的麻烦,OpenSSL 的 SSL_read() 一次调用可能只吐出一部分解密后的明文,而剩下的密文还留在 OpenSSL 自己的用户态缓冲区里。

Redis 的解法是维护一条待处理数据链表(pending-data list),挂在事件循环的 el->privdata[1] 上。凡是"还有解密数据没被消费完"的连接,就把自己链接进这条表,等事件循环下一轮再来捞。tlsProcessPendingData() 就是干这个捞取动作的函数。

换句话说,这条链表只在 TLS 路径上存在。你的 Redis 如果压根没开 tls-port,这段代码一次都不会执行。

影响范围的第一个收敛条件是编译配置,不是版本号。查这个漏洞时先看 CONFIG GET tls-port,比先看版本号更快出结论。

二、根因拆解:迭代器缓存的那个 next 指针

2.1 补丁前的遍历逻辑

修复前的 tlsProcessPendingData() 长这样,这段代码来自公开 PoC 的文档与补丁 diff,是本文全部机理分析的起点:

static int tlsProcessPendingData(struct aeEventLoop *el) {
    listIter li;
    listNode *ln;
 
    list *pending_list = el->privdata[1];
    if (!pending_list) return 0;
 
    int processed = listLength(pending_list);
    listRewind(pending_list, &li);
    while ((ln = listNext(&li))) {
        tls_connection *conn = listNodeValue(ln);
        tlsHandleEvent(conn, AE_READABLE);
    }
    return processed;
}

看起来是一段再标准不过的链表遍历。listRewind() 初始化迭代器,listNext() 逐个取节点,取到空就停。

问题藏在这一行里:tlsHandleEvent(conn, AE_READABLE)

2.2 adlist 迭代器的契约

Redis 自己实现的双向链表叫 adlist,它的迭代器结构里存了 next 指针和方向。关键在于 listNext() 的实现逻辑:它在返回当前节点之前,会把 current->next 预先存进迭代器。

listNode *listNext(listIter *iter) {
    listNode *current = iter->next;
    if (current != NULL) {
        if (iter->direction == AL_START_HEAD)
            iter->next = current->next;
        else
            iter->next = current->prev;
    }
    return current;
}

这个预缓存的动作,构成了 adlist 迭代器的隐含契约:迭代期间只允许删除当前节点,不允许删除任意其他节点

理由很直接。如果你删掉了迭代器已经缓存的那个后继节点,缓存下来的地址就变成了指向已释放内存的悬垂指针(dangling pointer)。下一次 listNext() 不会去检查这个地址还有效,它会直接返回,调用方随后解引用。

删当前节点是安全的,因为迭代器已经把 next 存好了,删除当前节点不会动到那个 next。删别的节点,尤其是删掉 next 本身,就越界了。

2.3 谁摘走了那个节点

那么 tlsHandleEvent() 里发生了什么,会把别的节点摘掉。

tlsHandleEvent() 调的是这条连接的读回调,也就是 readQueryFromClient()。它会解析 RESP 协议,然后执行一条 Redis 命令

命令执行是这里的关键。有些命令会关闭连接,而关闭的不一定是当前这条。

关闭路径是这样的:

freeClient()
  → connClose()
    → connTLSClose()
      → listDelNode(pending_list, node)

freeClient() 释放客户端,connClose() 走连接抽象层,TLS 实现下的 connTLSClose() 会把这条连接从待处理链表上摘下来,也就是 listDelNode(),节点内存随之释放。

于是完整的触发链条成立:

  1. 外层循环遍历待处理链表,迭代器在内部缓存了下一个节点 B 的 listNode *

  2. 轮到连接 A,tlsHandleEvent() 执行了 A 的读回调

  3. 读回调执行 A 的命令,而这条命令导致连接 B 被关闭

  4. B 的节点走 freeClient() → connClose() → connTLSClose() → listDelNode() 被释放

  5. 外层循环调 listNext(),返回的是已经释放的 B 节点地址

  6. listNodeValue(ln)

     读 freed memory,UAF 成立

Redis 在补丁说明里给的例子是被执行的命令是 CLIENT KILL。这个例子好懂,因为 CLIENT KILL 的语义就是关掉别人。

这不是越界读写,也不是类型混淆,是纯粹的生命周期错误。迭代器跨过了一次可能改变容器结构的调用,而它缓存的地址在调用返回时已经失效。这类 bug 的共性在于,单看遍历代码和单看命令代码都挑不出毛病,毛病出在两者的组合上。

2.4 修复方式:从头摘除加有界排空

补丁只有九行,一个文件,4 行新增、5 行删除:

static int tlsProcessPendingData(struct aeEventLoop *el) {
    list *pending_list = el->privdata[1];
    if (!pending_list) return 0;
 
    int processed = listLength(pending_list);
    for (int i = 0; i < processed; i++) {
        listNode *ln = listFirst(pending_list);
        if (!ln) break;
        tls_connection *conn = listNodeValue(ln);
        tlsPendingRemove(conn);
        tlsHandleEvent(conn, AE_READABLE);
    }
    return processed;
}

改动有三处,每一处都对着上面那个链条:

第一,不再持有迭代器状态。 listIter 和预缓存的 next 全部去掉,改成每次循环重新读 listFirst(pending_list)。这样任何时刻拿到的都是链表当前的真实头节点,不存在"缓存了一个可能已被释放的地址"这回事。

第二,回调之前先摘头。 tlsPendingRemove(conn) 被挪到了 tlsHandleEvent() 之前。这意味着进入命令执行时,这条连接已经不在链上了。就算命令执行导致别的连接被关闭,listDelNode() 摘掉的是链表里现存的节点,不会和任何跨调用持有的指针产生冲突。

第三,循环上界固定为 processed 用进入函数时记录的长度做上界,配合每轮摘头,保证循环一定向前推进,不会因为节点提前被摘空而漏处理,也不会因为反复有新节点入链而变成活锁。补丁说明里明确写了语义被保留:常见情况下每条连接每轮恰好被处理一次,且保持原有顺序。

2.5 为什么事件循环里容易出这类问题

Redis 是单线程事件循环(除了后来的 I/O 多线程,命令执行仍在主线程)。单线程带来一个很强的直觉:既然只有一个线程,容器就不会被并发修改,遍历的时候就不用加锁,也就不用想太多。

这个直觉在纯计算路径上成立,在回调路径上不成立。

单线程保证的是没有另一个线程来改你的链表,它没有保证回调本身不改。tlsHandleEvent() 不是纯函数,它执行用户可控的命令,命令可以关闭连接,关闭连接会改链表。事件循环的"回调之间互不干扰"是一个隐含假设,而命令执行把这个假设撕开了。

同类问题在其他项目里反复出现过,形态都差不多:遍历文件描述符表时回调关掉了别的 fd,遍历定时器队列时回调删掉了别的定时器,遍历客户端链表时回调踢掉了别的客户端。修法也高度一致,要么先摘除再处理,要么用引用计数,要么把待删除项放进延迟回收队列,等遍历结束再统一释放。

三、PoC 做了什么:从悬垂指针到 CRASH-AND-RECOVER

这一节只讲机制链路,不提供完整利用代码,不给偏移、布局与喷射细节。所有信息均来自公开 PoC 仓库的文档。

3.1 攻击者的实际起点

公开 PoC 的启动脚本给默认用户授予的命令集合是这样的:

+ping +echo +eval +eval_ro +publish +hello +subscribe +lpos +rpush +del +hset

这张清单就是本次利用的真实门槛。它不涉及模块加载,不需要向目标文件系统写文件,不需要调试器,不需要事先拿到机器上的任何访问权限。换成一个配置了强认证和最小权限 ACL 的实例,这张清单里的大部分命令拿不到,链路在第一步就断了。

PoC 的靶标是官方 amd64 Redis 8.8.0 镜像,按 digest 锁定。它依赖该构建的 PIE 偏移、jemalloc 的分配行为、内嵌 Lua 5.1 的实现细节,以及 TLS 支持。这四项里换任何一项,都需要重做适配。

3.2 七个阶段

PoC 把一个悬垂指针解引用拆成了七步,这里按阶段概括:

阶段一,泄漏 Lua 锚点并准备伪造对象。 利用 Lua 对象的字符串化行为,拿到 _G 全局表、redis API 表、pairs CClosure 的地址,以及若干候选伪造 TLS 字符串的地址。分配一个带 60 个上值(upvalue)的临时闭包,让它落在与伪造 TLS TString 相同的 size class 里,用泄漏出的地址预测载荷基址。

阶段二,排布不安全的迭代器顺序。 用了三种连接角色:发送慢速 EVAL 的客户端 A,堆积了足够输出因而成为关闭候选的 RESP3 Pub/Sub 客户端 B,以及用 LPOS 打大列表来塑造事件循环时序的阻塞客户端。需要的条件相当具体:B 必须排在外层待处理链表中 A 的后面,且外层迭代器必须在嵌套处理关闭 B 之前就缓存好 B 的节点指针。仅仅是关闭 B 是不够的。

阶段三,释放并回收 B 的缓存节点。 A 的 Lua 脚本执行一次大 PUBLISH,然后烧 CPU 跨过 Redis 的 Lua 超时路径,接着覆写预先整形过的 HSET 值。PoC 文档给出的关键片段如下(非完整利用代码,仅用于说明命令组合):

redis.call('PUBLISH', KEYS[1], string.rep('A', 35651584))
local a = 0 for i = 1, 1500000000 do a = a + 1 end
redis.call('HSET', KEYS[2], unpack(h))

Lua 超时处理会在脚本仍然活跃的情况下泵动事件循环,Pub/Sub 的输出压力就在这个嵌套事件循环里把 B 关闭。tlsPendingRemove() 摘掉并释放 B 那个 24 字节的 listNode,而外层迭代器仍然存着它的地址。随后的 HSET 用 22 字节的 SDS 值替换预先整形的 80 字节值,这些分配恰好落进被释放节点的分配器 size class,把外层循环恢复后要读的字段整形为:

stale listNode + 0x08 -> 受控的 next 指针
stale listNode + 0x10 -> 伪造的 tls_connection 指针

阶段四,把悬垂指针转成一次单比特写。 恢复后的外层遍历把被回收节点的 value 当作 tls_connection * 读出。伪造的连接把 state 设为已连接、fd 设为 0、不提供读写回调、el 指向嵌在同一个 Lua 字符串里的伪造 aeEventLoop

伪造的事件循环把 events[0].mask 别名为 _G + 8,也就是 Lua 表的类型标签字节。它的 epoll 状态用 epfd = -1,后端忽略掉那次失败的 epoll_ctl() 删除,之后 aeDeleteFileEvent() 仍然会执行掩码更新:

aeApiDelEvent(eventLoop, fd, mask);
fe->mask = fe->mask & (~mask);

伪造连接上没有回调,updateSSLEvent() 于是移除 AE_READABLE。这一次掩码更新清掉了 _G + 8 的第零位,把对象类型从 LUA_TTABLE(5,二进制 0101)改成了 LUA_TSTRING(4,二进制 0100)。

阶段五,回收 redis 表并构造读写原语。 _G 不再是表类型之后,Lua 的垃圾回收器不再正常遍历它的表项。一次完整回收可以释放 redis API 表,而正在运行的脚本仍持有指向那块内存的悬垂引用。这个构建里 Lua Table 是 72 字节,一个 24 字节头加 47 个受控字节再加结尾 NUL 的 TString 也是 72 字节,于是喷射的 47 字节字符串可以占据被释放的表槽位。用 Z3 解出无约束字节,使每个字符串的 Lua 哈希为零,同时在固定位置编码伪造表的数组与哈希字段。整数索引 r[1] 访问伪造数组槽位,整数键 r[0] 触达与数组指针重叠的伪造哈希节点。把该数组指向一个真实的辅助表,对 r[1] 的赋值就重定向到了 p[1],此后经 p[1] 的读写可以命中攻击者选定的一个 8 字节。

阶段六,推导 PIE 基址并改写重启状态。 从泄漏的 pairs CClosure 读回 luaB_pairs 的原始函数指针,减去已知镜像偏移得到 Redis 的 PIE 基址。然后把命令字符串与 argv 写进第一个伪造 TLS 缓冲区,并改写四个位置:

  • server.executable

     指向 /bin/sh

  • server.exec_argv

     指向 [NULL, "-c", command, NULL]

  • server.enable_debug_cmd

     开启受保护的 DEBUG 动作

  • redisCommandTable[DEBUG].flags

     加上 CMD_NO_AUTH 标志

Redis 在 execve() 之前会把 exec_argv[0] 替换成 server.executable,于是最终得到 ["/bin/sh", "-c", command, NULL]

阶段七,进入重启路径。 受保护动作开关和命令标志都改好之后,发送 DEBUG CRASH-AND-RECOVER。Redis 在重启过程中关闭连接,并以 redis-server 用户身份执行 /bin/sh -c <命令>

PoC 默认跑 id,成功时靶机终端会打印:

uid=999(redis) gid=999(redis) groups=999(redis)

3.3 关键的一步是那个单比特写

整条链路里最精巧的,是阶段四那一次类型标签翻转。

它只改了一个比特,代价极小,收益极大。_G 从表变成字符串,Lua 的 GC 就不再遍历它,于是 redis 表可以被提前回收,而脚本手里还攥着指向它的引用。读写原语是从这里长出来的。

3.4 复现门槛决定了危害档位

PoC 文档自己写明了三个限制:

  • 利用对时序和堆布局敏感,最多尝试八次

  • 失败之后必须停掉靶机再重启才能重试

  • 不同 Redis 构建需要新的偏移,可能需要不同的堆整形

这三条决定了它不是一把通用钥匙。它证明的是"这条路径可达成代码执行",不是"随便哪个 Redis 都能打"。

同时也要看到另一面。这个门槛降低的速度通常很快,偏移提取可以脚本化,堆整形可以适配多个主流镜像。PoC 已经公开,从"针对 8.8.0 可用"到"覆盖几个常见构建"之间,隔着的是工作量,不是原理障碍。

四、影响范围核实:评分之争与真相

4.1 三个评分并排看

这个漏洞在公开渠道上出现过三个分数,来源各不相同:

评分

来源

向量要点

9.8

初始 CVE 记录,CVSS 3.1

AV:N / AC:L / PR:N / UI:N

9.2

GHSA-32xw-c2hw-m34g,CVSS 4.0

AV:N / AC:L / AT:P / PR:N

7.5

Redis 主张,CVSS 4.0

AV:A / AC:H / AT:P / PR:L

分歧集中在两个向量分量上。

AV(攻击向量):是 Network 还是 Adjacent。Redis 主张 Adjacent,隐含假设是 TLS 端口只对内部网络开放。GHSA 主张 Network,隐含假设是 TLS 端口可以从不可信网络直达。

PR(所需权限):是 None 还是 Low。Redis 主张 Low,即攻击者需要有效凭据。GHSA 主张 None,即不需要。

结局是 Redis 赢了至少一个回合。2026 年 9 月 1 日,Redis 在通告顶部追加了一条更新:经进一步复核,CVE-2026-81934 的公开记录已被更新为修订后的 CVSS 7.5(High)。CVE.org 记录里现在并列着 7.1(CVSS 3.1,AV:A/AC:H/PR:L)与 7.5(CVSS 4.0,AV:A/AC:H/AT:P/PR:L)。

4.2 描述文本与评分向量自相矛盾

这里有一个必须点出来的细节。

修订后的 CVE 记录,描述文本仍然写着 "A remote, unauthenticated attacker may be able to execute arbitrary commands",即远程未认证攻击者。但同一条记录里的评分向量是 PR:L(需要低权限)和 AV:A(相邻网络)。

描述文本和评分向量打架了。分数改了,描述没改。

这个自相矛盾直接导致下游转载的口径混乱。有的媒体取描述文本,写"无需认证";有的取分数,写"高危需认证"。两边都能引到出处。

真相取决于部署,不取决于哪个文档。

4.3 官方缓解建议与 PoC 实际用命令的差异

Redis 在通告里建议:移除对 CLIENT KILL、Lua 脚本、Pub/Sub 以及相关键或通道的不必要访问

三项里有一项对公开 PoC 无效。

CLIENT KILL 是补丁说明里举的例子,用来解释"命令可以关掉别的连接"这个机制。但公开 PoC 并没有用 CLIENT KILL,它是靠 Pub/Sub 的输出压力在 Lua 超时路径里把受害连接关掉的。

真正卡住这条链路的是另外两项。PoC 文档原文写得很清楚:禁用脚本,或者拒绝该触发链用到的任意一条命令,就会按当前写法打断这个利用链。它依赖的十一个命令是:

ping echo  eval  eval_ro publish hello subscribe lpos rpush del hset

其中 eval 与 eval_ro 归入脚本类,publish 与 subscribe 归入 Pub/Sub 类,其余七个是读写与连接类的基础命令。

这意味着,如果你的缓解动作只做了"禁用 CLIENT KILL",那么公开 PoC 该怎么跑还是怎么跑。而如果只做了"禁用脚本",按 PoC 文档的说明,这条链就断了。

4.4 四象限威胁模型

把两个条件交叉,可以得到四档实际风险:


可达账号有脚本与 Pub/Sub 权限

可达账号无脚本或 Pub/Sub 权限

TLS 端口可从不可信网络直达

最高档。未认证即可 RCE,接近 9.8 的情形

中档。公开 PoC 链路断裂,但漏洞本身仍在,存在崩溃与未知链风险

TLS 端口仅内部可达

中高档。需要进入内网或持有效凭据,接近 7.5 的情形

低档。叠加网络隔离与命令限制,实际风险有限

落在左上角的实例,是这批漏洞里最该立刻处理的一批。特征很明确:开了 TLS,端口暴露在不可信网络,认证为默认或空,账号未被 ACL 收敛。

右下角的实例,可以做计划内维护,不需要半夜拉人。

4.5 在野利用情况

截至 2026 年 8 月 27 日,Redis 声明未发现客户环境中的主动利用。CISA 的 SSVC 决策记录给出的是:Exploitation 状态为 poc(已有公开 PoC)、Automatable 为 no(不可自动化)、Technical Impact 为 total(完全技术影响)。EPSS 处在低位,不同数据源给出 0.4%(30 天)与 0.6% 两个数值。

三个信号合起来的读法是:攻击代码公开了,但没人观察到实际攻击,且自动化门槛不低。

需要说明的是,PoC 公开于 8 月 25 日,本节结论的观察窗口只有几天。这个结论会过期。POC地址:https://github.com/v12-security/pocs/tree/main/redis/server_ssl