| Описание | I discovered a server-side heap use-after-free vulnerability in Valkey 9.1.0 while auditing the blocked-client and ready-key handling logic. The issue is reproducible against the official src/valkey-server binary compiled from the 9.1.0 source tree with AddressSanitizer enabled. The flaw is rooted in the interaction between the ready-key servicing loop for blocked clients, the adlist iterator model used by the core, and the module blocked-on-keys callback path. In my testing, the flaw can be triggered by loading a minimal test module that uses the public module API to block clients on a key and, from the first blocked client’s reply callback, execute CLIENT KILL ID against a second client blocked on the same key. This causes the server to dereference a freed list node while continuing iteration over the blocked-client list.
The vulnerable logic is centered around the blocked-on-keys subsystem. In src/blocked.c, handleClientsBlockedOnKey() iterates over the blocked clients associated with a ready key by using a normal adlist iterator and the pattern while ((ln = listNext(&li)) && count--). In src/adlist.c, listNext() advances the iterator by assigning current = iter->next and then caching iter->next = current->next. This means that, before the callback for the current blocked client returns, the iterator may already hold a pointer to the successor listNode for the next blocked client in the same blocked list. This design is safe only if no reachable logic destroys that cached successor node. In the affected code path, that assumption is violated.
The problem becomes exploitable because the blocked-client teardown path stores and later frees the same listNode object that the outer iterator may have cached. When a client is blocked on keys, blockForKeys() appends the client to the database’s blocking list for the key by calling listAddNodeTail(). The corresponding list node is then stored as the value in the client’s c->bstate->keys dictionary. The dictionary type used for this structure employs a value destructor that frees the stored pointer via dictVanillaFree. During unblocking, unblockClientWaitingData() processes the entries in c->bstate->keys and later empties the dictionary with dictEmpty(). As a consequence, the listNode that represents the blocked client in db->blocking_keys is not merely unlinked from the list; it is later released through the cleanup path.
My reproduction demonstrates that a module reply callback can legitimately reach that teardown path before the outer ready-key iteration resumes. The first blocked client is served through the module blocked-on-keys reply callback mechanism. Inside the callback, the module invokes ValkeyModule_Call(ctx, "CLIENT", "ccl", "KILL", "ID", second_id) to terminate the second blocked client waiting on the same key. The command path reaches clientKillCommand() and then freeClient(). Because the target client is still blocked, freeClient() proceeds into unblockClient(), which reaches unblockClientWaitingData() for the blocked-on-keys state. That function clears c->bstate->keys and, through the configured dictionary value destructor, frees the stored listNode pointer. The callback then returns to the outer ready-key loop, which continues by calling listNext(&li). At that point, the iterator’s cached successor pointer may already refer to the listNode that was just freed during teardown of the second blocked client.
I validated this sequence with AddressSanitizer and GDB. In the crashing execution, AddressSanitizer reports a heap-use-after-free read in src/adlist.c at listNext(), with the immediate caller being handleClientsBlockedOnKey() in src/blocked.c. The free stack shows the node being released through dictVanillaFree, dictEmpty, and unblockClientWaitingData, reached from freeClient after CLIENT KILL. The allocation stack points back to listAddNodeTail in blockForKeys, confirming that the object being read after free is the blocked-list node allocated by the Valkey core itself, not a module-private allocation.
The GDB evidence further confirms object identity and timing. At the first iteration of handleClientsBlockedOnKey(), the current node ln corresponds to the first blocked client, while li.next already points to the second blocked client’s list node. In my debugging session, li.next matched the node whose value field corresponded to the client ID selected for termination by the first callback. Inside the first reply callback, the callback context belonged to the first blocked client, and the second_id variable matched the ID of the successor blocked client. When execution reached releaseBlockedEntry() during teardown of that second client, the pos variable fetched from c->bstate->keys was equal to the previously saved successor listNode pointer. Finally, when execution returned to listNext() in the outer ready-key loop, the current variable inside listNext() was equal to that same saved successor pointer, proving that the server attempted to continue iteration using a node that had already entered the free path.
This is not a false positive caused by a module freeing internal server memory directly. The module does not manually free list nodes and does not corrupt adlist metadata on its own. Instead, the module triggers a reentrant but valid command path during blocked-client reply handling. The underlying defect is that the Valkey core assumes the successor node cached in listIter.next will remain valid across callback execution, even though the callback can synchronously trigger teardown of a different blocked client that owns that very node. In security terms, the root cause is iterator invalidation in the ready-key servicing state machine, leading to a core-level heap use-after-free on a live server process.
The security impact is at least a reliable denial of service. In my reproduction, the official valkey-server process aborts under AddressSanitizer after dereferencing the freed node in listNext(), and connected clients observe server-side connection termination. The vulnerability affects server stability and can be triggered through the supported module extension surface that interacts with blocked-on-keys callbacks. Based on the verified behavior, the most defensible impact statement is that a module using the public blocked-on-keys API can cause the server to crash by inducing synchronous teardown of another blocked client on the same ready key while the core is iterating that blocked-client list.
The issue affects Valkey 9.1.0. My testing was performed by rebuilding the official source tree with make distclean followed by make -j"$(nproc)" SANITIZER=address OPTIMIZATION=-O0, and by compiling a minimal shared-object harness module against the source tree headers. The server was then started with the official src/valkey-server binary and the module loaded via --loadmodule. Reproduction required three client interactions: the first client executed readyuaf.block race first, the second client executed readyuaf.block race second, and a third client executed readyuaf.signal race. The signal command wrote the key and marked it ready so that blocked-client processing began. The first client’s reply callback then killed the second blocked client, leading to invalidation of the successor node cached by the outer iterator. AddressSanitizer subsequently reported a heap-use-after-free in listNext(), with the free path through dictVanillaFree and unblockClientWaitingData and the allocation path through listAddNodeTail and blockForKeys. This behavior was reproduced consistently.
From a remediation perspective, the core should not continue iterating over a blocked-client list with a normal adlist iterator when callback execution can synchronously remove and free successor nodes. A proper fix should make iteration robust against reentrant deletion of non-current nodes. Acceptable approaches include iterating over a stable snapshot of clients instead of the live list, caching the successor in a form that remains valid even if the underlying list node is torn down, or changing blocked-client bookkeeping so that c->bstate->keys cleanup does not free listNode objects that may still be referenced by an active iterator.
I discovered and analyzed this vulnerability, produced the minimal reproducer, obtained sanitizer evidence, and traced the exact object-lifetime transition with GDB. Based on the collected evidence, this is a real memory-safety flaw in Valkey core rather than a bug confined to a custom module. The module merely exposes the unsafe server behavior through documented extension mechanisms and ordinary command execution. Because the defect resides in the main server’s blocked-client ready-key processing logic and results in a reproducible heap use-after-free and process abort, I believe it is suitable for vulnerability tracking. |
|---|