Skip to content

Fix/ready key blocked client uaf 4198#4212

Open
quanyeyang wants to merge 5 commits into
valkey-io:unstablefrom
quanyeyang:fix/ready-key-blocked-client-uaf-4198
Open

Fix/ready key blocked client uaf 4198#4212
quanyeyang wants to merge 5 commits into
valkey-io:unstablefrom
quanyeyang:fix/ready-key-blocked-client-uaf-4198

Conversation

@quanyeyang

@quanyeyang quanyeyang commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Fix heap-use-after-free in handleClientsBlockedOnKey() when serving blocked clients.
  • Root cause: a plain listIter caches the successor listNode*, while the serve path can synchronously freeClient() another client still blocked on the same key (module reply + CLIENT KILL, and also unblockClientOnKeyprocessCommandevictClients() with maxmemory-clients).
  • Fix: snapshot stable client IDs before serving, then re-lookup with lookupClientByID() and skip clients that are no longer blocked on the key.

Reported by @gff-cw in #4198.

Affected versions / backport

The original report mentioned 9.1.0, but the vulnerable pattern appears older than 9.1. Happy to help with backports to active release branches if desired.

Test plan

  • ASan repro with module reply callback that CLIENT KILLs a successor blocked client
  • ./runtest --single unit/moduleapi/blockonkeys

Fixes #4198

Signed-off-by: quanyeyang <quanyemostima@gmail.com>
Signed-off-by: quanyeyang <quanyemostima@gmail.com>
@coderabbitai

coderabbitai Bot commented Jul 19, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 1dba0de5-7fa4-485a-bf82-4af15236319c

📥 Commits

Reviewing files that changed from the base of the PR and between c0a42ad and ad9a89b.

📒 Files selected for processing (1)
  • src/blocked.c
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/blocked.c

📝 Walkthrough

Walkthrough

Blocked-client readiness handling now snapshots queued clients and revalidates them before unblocking. Module commands and a regression test cover killing a successor during reply processing while confirming continued server operation.

Changes

Blocked-client safety

Layer / File(s) Summary
Stable client snapshot and validation
src/blocked.c
handleClientsBlockedOnKey() snapshots clients and IDs, validates live identity and ready-key blocking state, bounds processing, and frees the snapshot.
Reentrant module regression coverage
tests/modules/blockonkeys.c, tests/unit/moduleapi/blockonkeys.tcl
Adds blocking and readiness commands plus a regression test that kills a successor during reply handling and verifies the first reply and server liveness.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Suggested reviewers: dvkashapov

Sequence Diagram(s)

sequenceDiagram
  participant TestClient
  participant BlockOnKeysModule
  participant ValkeyServer
  TestClient->>BlockOnKeysModule: block two clients on the same key
  TestClient->>BlockOnKeysModule: signal the key as ready
  BlockOnKeysModule->>ValkeyServer: reply callback kills successor client
  ValkeyServer->>BlockOnKeysModule: revalidate and process snapshot entry
  TestClient->>ValkeyServer: PING
  ValkeyServer-->>TestClient: PONG
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and directly references the blocked-client UAF fix for ready-key handling.
Description check ✅ Passed The description clearly matches the code change and regression test for the blocked-client UAF fix.
Linked Issues check ✅ Passed The changes snapshot blocked clients, revalidate them before serving, preserve unblocking behavior, and add the requested CLIENT KILL regression coverage.
Out of Scope Changes check ✅ Passed The diff stays focused on the ready-key blocked-client fix and its regression tests, with no clear unrelated changes.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@codecov

codecov Bot commented Jul 19, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 68.75000% with 10 lines in your changes missing coverage. Please review.
✅ Project coverage is 76.80%. Comparing base (6b006c9) to head (c0a42ad).

Files with missing lines Patch % Lines
src/blocked.c 68.75% 10 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##           unstable    #4212      +/-   ##
============================================
- Coverage     76.81%   76.80%   -0.02%     
============================================
  Files           162      162              
  Lines         81455    81483      +28     
============================================
+ Hits          62570    62581      +11     
- Misses        18885    18902      +17     
Files with missing lines Coverage Δ
src/blocked.c 88.26% <68.75%> (-2.04%) ⬇️

... and 17 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@enjoy-binbin
enjoy-binbin requested review from JimB123 and ranshid July 20, 2026 02:51

@ranshid ranshid left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the fix — the snapshot-by-ID + re-lookup approach looks correct. A couple of scope/direction questions below.

Affected versions / backport scope: The linked issue labels this as 9.1.0-only, but the vulnerable pattern — iterating the blocking-keys list with a plain listIter while the serve path can synchronously free another blocked client — looks a lot older than 9.1 (module block-on-keys + synchronous free reentrancy has been around since at least 7.2). Can you clarify the basis for the 9.1-only claim? If it does reach back to 7.2/8.x, we should plan backports to the active release branches accordingly.

Broader direction (non-blocking): Part of the root cause is that many flows free clients synchronously (CLIENT KILL, evictClients(), COB-limit handling, …), and we keep tripping over iterators/cached pointers that outlive those frees. This snapshot fix is a good local solution, but it may be worth a broader discussion on whether these paths should prefer freeClientAsync() so client teardown always happens at a safe point (beforeSleep) rather than mid-iteration. Raising it here since it's a recurring class of bug, not to block this PR.

Comment thread src/blocked.c
while ((ln = listNext(&li)) && count--) {
client *receiver = listNodeValue(ln);
robj *o = lookupKeyReadWithFlags(rl->db, rl->key, LOOKUP_NOEFFECTS);
for (i = 0; i < snapshot_len; i++) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't actually specific to the module reply path. The regular flow — unblockClientOnKey -> processCommandAndResetClient -> processCommand — also re-enters processCommand, which calls evictClients() synchronously, and that does a synchronous freeClient() on other memory-heavy clients. With maxmemory-clients set, that can tear down another client still blocked on this same key (the successor node the old listIter had cached), so a plain BLPOP/BLMOVE could hit the same UAF with no module involved. The good news is the ID-snapshot + re-lookup here covers that case too, since we no longer retain a listNode*/client* across the serve call. Might be worth calling this out so the general nature of the fix is clear.

@quanyeyang

Copy link
Copy Markdown
Contributor Author

Thanks for the fix — the snapshot-by-ID + re-lookup approach looks correct. A couple of scope/direction questions below.

Affected versions / backport scope: The linked issue labels this as 9.1.0-only, but the vulnerable pattern — iterating the blocking-keys list with a plain listIter while the serve path can synchronously free another blocked client — looks a lot older than 9.1 (module block-on-keys + synchronous free reentrancy has been around since at least 7.2). Can you clarify the basis for the 9.1-only claim? If it does reach back to 7.2/8.x, we should plan backports to the active release branches accordingly.

Broader direction (non-blocking): Part of the root cause is that many flows free clients synchronously (CLIENT KILL, evictClients(), COB-limit handling, …), and we keep tripping over iterators/cached pointers that outlive those frees. This snapshot fix is a good local solution, but it may be worth a broader discussion on whether these paths should prefer freeClientAsync() so client teardown always happens at a safe point (beforeSleep) rather than mid-iteration. Raising it here since it's a recurring class of bug, not to block this PR.

@ranshid
Thanks for the review.

On affected versions: the 9.1.0-only wording came from the original report.
I agree the vulnerable pattern (listIter over blocking_keys while a serve
path can synchronously free another blocked client) is older than 9.1 —
module block-on-keys and synchronous freeClient reentrancy have been
present for a long time. Happy to help with backports to active release
branches if maintainers want that.

On the non-module path: good catch. unblockClientOnKey → processCommand →
evictClients() can also synchronously free a successor blocked on the same
key when maxmemory-clients is set, so plain BLPOP/BLMOVE can hit the same
class of UAF. The ID snapshot + re-lookup is intentionally not limited to
the module reply path; I'll update the PR description to make that clear.

On freeClientAsync(): agreed this is a recurring class of bugs.
This PR keeps the local fix; a broader "prefer async client teardown at safe points"
discussion seems worth a separate issue.

Signed-off-by: quanyeyang <quanyemostima@gmail.com>

Snapshot client pointers and re-check live blocking-list membership
instead of lookupClientByID(), which misses module fake clients and
broke async RM_Call BLPOP (fire-and-forget).

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/blocked.c`:
- Around line 653-675: Optimize the validation loop around
clientStillInBlockingKeysList by snapshotting each client’s ID alongside its
pointer. For normal clients, validate liveness and identity with
lookupClientByID() in O(1), while retaining the existing linear
clientStillInBlockingKeysList fallback for module fake clients or clients whose
pointers were freed; preserve the initial-count processing limit and existing
blocking-list membership behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 76e7c3a0-1b63-4200-9f99-645215b01e15

📥 Commits

Reviewing files that changed from the base of the PR and between c5854f7 and a33b5e1.

📒 Files selected for processing (1)
  • src/blocked.c

Comment thread src/blocked.c Outdated
Signed-off-by: quanyeyang <quanyemostima@gmail.com>

Snapshot client pointer and id; re-check via lookupClientByID() for
registered clients, and fall back to a live blocking-list scan for
RM_Call fake clients missing from clients_index.
Comment thread src/blocked.c
* invalidating a listIter's cached successor. Re-check live list
* membership before serving — also covers RM_Call fake clients that
* are absent from clients_index. */
snapshot = zmalloc(sizeof(*snapshot) * count);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dereferencing a pointer that was never assigned an initial value is UB

sizeof(*snapshot) * count should be (sizeof(client*) + sizeof(uint64_t)) * count

@quanyeyang quanyeyang Jul 20, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for taking a look. sizeof(*snapshot) does not evaluate/
dereference snapshot — sizeof on a non-VLA operand is unevaluated
and this is the usual C idiom for malloc(sizeof(*p)).

Using sizeof(client*) + sizeof(uint64_t) would also be incorrect
where the struct has padding (e.g. 32-bit), and could under-allocate.

In C23 N3220: §6.5.4.4 The sizeof and alignof operators,paragraph 2

The sizeof operator yields the size (in bytes) of its operand, which may be an expression or the
parenthesized name of a type. The size is determined from the type of the operand. The result
is an integer. If the type of the operand is a variable length array type, the operand is evaluated;
otherwise, the operand is not evaluated and the result is an integer constant.

And here is an example:
image

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you're right, thanks for the useful info

@AlisinaDevelo AlisinaDevelo left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a maintainer, just read through it carefully since it is a security fix. The snapshot-{client*, id} + re-lookup approach looks right to me, and the regression test reproduces the successor-free case well. Built it and runtest-moduleapi --single unit/moduleapi/blockonkeys passes here.

One thing I could not fully convince myself about, on the fallback branch:

if (lookupClientByID(snapshot[i].id) == receiver) {
    if (!clientStillBlockedOnReadyKey(receiver, rl)) continue;
} else if (!clientStillInBlockingKeysList(rl, receiver)) {
    continue;
}

The fallback (clientStillInBlockingKeysList) matches purely on the client\* pointer and ignores the snapshotted id. It is only reached when lookupClientByID(id) != receiver, i.e. for clients absent from clients_index — which is exactly the fake/module clients (createClient only does linkClient when conn != NULL).

But fake clients are not heap-freed on release — moduleReleaseTempClient recycles the same client\* back into moduleTempClients and a later RM_Call hands the same address out again with a fresh id. So a pooled address can be alive again as a different logical client. If a pooled fake client at address A is snapshotted (id X), released mid-serve, then re-acquired as a different fake client (id Y) that ends up on this key’s blocking list, the pointer scan would match A and serve it through slot X — bypassing the stronger id-confirmed clientStillBlockedOnReadyKey check.

Two honest possibilities, and I do not know the module internals well enough to say which:

  1. A pooled fake client can never sit on a blocking_keys list (RM_Call temp clients do not block on keys), in which case the fallback never positively matches a live client and could be simplified / commented as such; or
  2. It can, in which case the fallback probably wants to confirm the id too, not just the pointer.

Either way it might be worth a comment pinning down when the fallback is expected to match a live client. Apologies if this is already covered by an invariant I have missed.

Signed-off-by: quanyeyang <quanyemostima@gmail.com>
@quanyeyang

Copy link
Copy Markdown
Contributor Author

Not a maintainer, just read through it carefully since it is a security fix. The snapshot-{client*, id} + re-lookup approach looks right to me, and the regression test reproduces the successor-free case well. Built it and runtest-moduleapi --single unit/moduleapi/blockonkeys passes here.

One thing I could not fully convince myself about, on the fallback branch:

if (lookupClientByID(snapshot[i].id) == receiver) {
    if (!clientStillBlockedOnReadyKey(receiver, rl)) continue;
} else if (!clientStillInBlockingKeysList(rl, receiver)) {
    continue;
}

The fallback (clientStillInBlockingKeysList) matches purely on the client\* pointer and ignores the snapshotted id. It is only reached when lookupClientByID(id) != receiver, i.e. for clients absent from clients_index — which is exactly the fake/module clients (createClient only does linkClient when conn != NULL).

But fake clients are not heap-freed on release — moduleReleaseTempClient recycles the same client\* back into moduleTempClients and a later RM_Call hands the same address out again with a fresh id. So a pooled address can be alive again as a different logical client. If a pooled fake client at address A is snapshotted (id X), released mid-serve, then re-acquired as a different fake client (id Y) that ends up on this key’s blocking list, the pointer scan would match A and serve it through slot X — bypassing the stronger id-confirmed clientStillBlockedOnReadyKey check.

Two honest possibilities, and I do not know the module internals well enough to say which:

  1. A pooled fake client can never sit on a blocking_keys list (RM_Call temp clients do not block on keys), in which case the fallback never positively matches a live client and could be simplified / commented as such; or
  2. It can, in which case the fallback probably wants to confirm the id too, not just the pointer.

Either way it might be worth a comment pinning down when the fallback is expected to match a live client. Apologies if this is already covered by an invariant I have missed.

Thanks for the careful read — this was a fair concern.

You’re right that the fallback was pointer-only, and lookupClientByID(id) != receiver is not only “fake client”: it also covers a real client that was freed, in which case jemalloc can reuse the same address for a new client that later blocks on the same key. Matching only client* could then serve the wrong logical client.

One small clarification on module temp clients: moduleAllocTempClient() recycles the pooled object without assigning a new c->id, so the “same address, fresh id Y” pool ABA is less common than the heap-reuse case for real clients. Still, the fallback needed to be stricter.

I tightened it to: live-list membership first (so we don’t deref a freed client), then receiver->id == snapshotted id, then clientStillBlockedOnReadyKey(). That rejects address reuse after freeClient(), and keeps the RM_Call fake-client path working. Added a short comment on when that fallback is expected to match.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[SECURITY] Server-side heap-use-after-free in ready-key blocked-client iteration (BLOCKED_MODULE / CLIENT KILL reentrancy)

4 participants