Executive summary
Redis releases in the affected 7.2+ lines prior to the May 2026 fixes contained a heap use-after-free in unblockClientOnKey(). When a blocked client is woken on a key and re-executes its pending command, processCommandAndResetClient() may free that client during client-memory eviction and return C_ERR. Vulnerable versions ignore this return and continue to read c->flags, call queueClientForReprocessing(c), and invoke afterCommand(c) on freed memory.
Public research (Xint Code) demonstrated that this defect can be escalated to remote code execution under authenticated access, jemalloc size-class reuse, and partial RELRO in stock Docker images. This article does not reproduce that weaponized chain. Instead, it provides an evidence-driven reconstruction of the root cause, lab-verified crash reproduction, allocator layout confirmation, and—critically—measured answers to defender questions that the advisory alone does not fully resolve.
Three lab-verified findings extend the common “stream + CONFIG” defender narrative:
- Variant triggers:
BLPOP/LPUSHandBZPOPMIN/ZADDcrash vulnerable Redis under the same measured runtime-limit timing model as stream-family blocking. The bug is not stream-specific; it is shared across all blocking types handled byunblockClientOnKey(). - ACL false comfort: An ACL profile that denies
@stream,XREAD, andXADDdoes not block list or zset variants. We measureddeny_stream_only+BLPOP→ still_crashes on 7.2.13. Stream-only hardening leaves a live attack surface. - Why `-config` works (and what it does not fix): Denying
CONFIGblocks measured CONFIG-timed reproduction paths because runtime client-memory limit tightening during the wake transaction is the reliable timing arm—not because the UAF class disappears. A 31-run no-CONFIG sweep (boot-time limits only, ACL-config) produced zero synchronized UAF crashes, while eviction without runtimeCONFIGstill occurs. Patching remains mandatory.
Bottom line for operators: upgrade to a fixed release. If upgrade is delayed, deny CONFIG and all measured blocking commands (XREAD, XADD, BLPOP, BRPOP, BZPOPMIN, BZPOPMAX), not streams alone. Network isolation and strong authentication remain baseline controls; they reduce exposure but do not remove the memory-safety defect.
Abstract
Blocking commands in Redis park a client until a key event occurs. The unblock-on-key path must safely re-enter the command pipeline. Redis documents that processCommandAndResetClient() returns C_ERR when its client* argument is freed as a side effect. Most call sites honor that contract. unblockClientOnKey() did not—until CVE-2026-23479 was patched in May 2026.
We reproduced the defect in an isolated Docker lab on Redis 7.2.13: AddressSanitizer shows eviction of a blocked client followed by a SEGV in processCommand() reached from processUnblockedClients(). Redis 7.2.14 survives identical behavioral tests.
DWARF verification gives sizeof(client)=776 on both aarch64 and amd64 official binaries. A sibling call-site audit identifies unblockClientOnKey as the only production caller of processCommandAndResetClient() that omitted the C_ERR check in 7.2.13.
Beyond RCA, we measured operational mitigations and variant surfaces. An extended ACL matrix (17 cases) shows that stream denial alone fails against list/zset triggers. A no-CONFIG parameter sweep explains why ACL -config blocks the published chain. Reliability testing reports 12/12 crash rate on the production 7.2.13 binary for the full CONFIG-timed reproduction path—no silent-survive class for that test in our lab.
This work is defensive reconstruction and education. Weaponized RCE is cited from public sources only.
1. Background: blocking clients and why unblock is sensitive
1.1 What blocking commands do
Redis supports commands that suspend a client until data is available:
| Family | Examples | Block type (btype) |
|---|---|---|
| Streams | XREAD BLOCK | BLOCKED_STREAM |
| Lists | BLPOP, BRPOP | BLOCKED_LIST |
| Sorted sets | BZPOPMIN, BZPOPMAX | BLOCKED_ZSET |
While blocked, the client retains its connection state, pending command metadata, and reply buffers. When a peer mutates the watched key (XADD, LPUSH, ZADD, …), Redis walks blocked-client structures and wakes waiters through handleClientsBlockedOnKeys() → unblockClientOnKey().
1.2 Why this code path is security-relevant
Unblock is not a simple “send reply and continue.” For clients that were blocked during command execution, Redis must re-run the original command through the full processCommand pipeline:
// blocked.c — simplified unblock-on-key flow (7.2.13)
unblockClient(c, 0);
if (c->flags & CLIENT_PENDING_COMMAND) {
server.current_client = c;
enterExecutionUnit(1, 0);
processCommandAndResetClient(c); // ← may free c; returns C_ERR
// ... post-call uses of c on vulnerable versions ...
exitExecutionUnit();
afterCommand(c);
}
Any helper that may destroy its client* argument is effectively part of the API contract for every caller. Redis already documents this for processCommandAndResetClient(). The unblock path lagged behind sibling callers in networking.c.
1.3 Client eviction (maxmemory-clients)
Since Redis 7.0, operators can cap aggregate client memory via maxmemory-clients (configuration file, CONFIG SET, or server argv). When usage exceeds the limit, evictClients() frees the largest normal/pubsub clients from memory-usage buckets, logging:
Evicting client: ... cmd=xread ... tot-mem=...
Eviction calls freeClient(c), which sets server.current_client = NULL when the evicted client is the one currently being processed. That is how processCommandAndResetClient() detects it must return C_ERR.
Key insight for defenders: eviction is not an exotic edge case. It is an intentional feature triggered during normal command processing—including during unblock re-execution.
2. Timeline: a composition failure across two PRs
| When | Event |
|---|---|
| 2023-01 | PR #11012 routes blocked commands through processCommandAndResetClient() in unblockClientOnKey. Ignoring C_ERR is *inert*—last use of c. |
| 2023-03 | PR #11568 adds execution-unit wrapping, c->flags checks, queueClientForReprocessing(c), afterCommand(c) after dispatch. |
| 2023-08 | Redis 7.2.0 GA — composed bug ships. |
| 2025-12 | Xint Code demonstrates UAF → RCE at ZeroDay.Cloud. |
| 2026-05-05 | Fix commit 0b51620; CVE-2026-23479 published; patched releases across 6.2/7.2/7.4/8.x lines. |
Review lesson: a latent “ignored return value” became exploitable when a later commit added post-call uses. Static rules of the form “every processCommandAndResetClient( must be followed by == C_ERR guard before any c-> access” would have flagged blocked.c after PR #11568.
3. Root cause
3.1 The missing check
Vulnerable unblockClientOnKey() (7.2.13):
processCommandAndResetClient(c);
if (!(c->flags & CLIENT_BLOCKED)) {
if (c->flags & CLIENT_MODULE) {
moduleCallCommandUnblockedHandler(c);
} else {
queueClientForReprocessing(c);
}
}
exitExecutionUnit();
afterCommand(c);
Documented contract (networking.c):
/* The function returns C_ERR in case the client was freed as a side effect
* of processing the command, otherwise C_OK is returned. */
int processCommandAndResetClient(client *c);
Inside processCommand(), eviction runs before completing the command:
evictClients();
if (server.current_client == NULL) {
return C_ERR; // client freed during eviction
}
If the blocked client is the eviction victim, processCommandAndResetClient propagates C_ERR. The vulnerable unblock path ignores it and queues a dangling pointer.
3.2 Patched behavior (7.2.14+)
if (processCommandAndResetClient(c) == C_ERR) {
exitExecutionUnit();
server.current_client = old_client;
return;
}
No further access to c after free. Valkey adopted the same pattern (commit e4a46f5, CVE-2026-23479).
3.3 Sibling call-site audit
Production callers of processCommandAndResetClient() in 7.2.13:
| Site | Checks C_ERR? |
|---|---|
blocked.c unblockClientOnKey | No ← CVE |
networking.c processPendingCommandAndInputBuffer | Yes |
networking.c processInputBuffer loop | Yes |
Total: 3 call sites. Unsafe: 1. The correct pattern already existed in-tree; unblock lagged.
A static lint rule requiring C_ERR checks after every processCommandAndResetClient call fails on 7.2.13 blocked.c:667 and passes on 7.2.14.
4. Object layout and allocator context
4.1 DWARF-verified client structure
From official Docker redis-server 7.2.13 (aarch64; amd64 matches byte-for-byte):
| Field | Offset | Notes |
|---|---|---|
flags | 8 | CLIENT_PENDING_COMMAND, etc. |
conn | 16 | connection pointer |
argc / argv / cmd | 88 / 96 / 128 | command vector |
last_memory_usage | 664 | used in public exploit writeups |
last_memory_type | 672 | signed index into server stats |
| sizeof(`client`) | 776 | verified via DWARF (aarch64 / amd64) |
DWARF verification was performed on official Docker redis-server 7.2.13 binaries (aarch64; amd64 matches byte-for-byte).
4.2 Why layout matters (without weaponizing)
Public RCE research describes attacker-controlled reclaim of the freed client’s allocator class. In our lab, allocator-class correlation was verified via reclaim instrumentation and ASAN stacks through post-reclaim processCommand. We do not publish a GOT→system chain here.
4.3 Hardening footnote: partial RELRO
Stock Redis 7.2.13 Docker aarch64 binary: PIE yes, partial RELRO (BIND_NOW=false). This raises the cost of GOT-overwrite narratives cited from Xint; it does not fix the UAF.
5. Observed failure mode and lifetime
5.1 Observed behavioral pattern (lab)
We validated vulnerable vs patched behavior using a private one-shot regression harness in an isolated lab. We do not publish command-level sequences in this article.
Behavioral pattern:
- Evictee connection: inflate client memory (large reply backlog), then enter a blocking wait on a key (stream, list, or sorted-set family).
- Trigger connection: arm client-memory eviction pressure, then wake the blocked key in one atomic server turn while the limit is tight.
- Outcome: on Redis 7.2.13, the daemon exits or faults; on 7.2.14, the same behavioral test survives.
The measured tests depend on sudden client-memory limit tightening during the wake transaction. That timing arm is what -config removes—not the underlying unblock code path.
5.2 Figure — measured failure flow (all blocking families)
unblockClientOnKey path.Applies to stream, list, and sorted-set blocking — same unblockClientOnKey path.
5.3 Lifetime (conceptual, text)
Blocking command parks evictee
→ wake command mutates key
→ unblockClientOnKey()
→ processCommandAndResetClient()
→ evictClients() frees evictee
→ returns C_ERR
→ [vuln] ignore C_ERR
→ queueClientForReprocessing(freed c)
→ beforeSleep / processUnblockedClients()
→ processCommand(freed c) → crash / UAF
5.4 Negative controls
| Target | Result |
|---|---|
| 7.2.14, same behavioral test | Survives (PING OK) |
| 7.2.13 ASAN | SEGV / ASan deadly signal |
| 7.2.13 production | Daemon death (12/12 in reliability sweep) |
A private one-shot regression harness was used to validate vulnerable and patched behavior.
6. Observing the UAF (ASAN)
Representative ASAN stack:
Evicting client: ... cmd=xread ...
ERROR: AddressSanitizer: SEGV ... in processCommand (server.c:3857)
# processUnblockedClients (blocked.c)
# processPendingCommandAndInputBuffer
# processCommandAndResetClient
# processCommand ← dereference of reclaimed/freed argv/cmd
Line 3857 is not a benign metadata read—it is post-reclaim consumption of attacker-influenced pointers when reclaim spray is used, or immediate fault on freed memory otherwise.
Q: Does ASAN overstate severity vs production? A: For the full CONFIG-timed reproduction path, our lab saw 100% crash on both ASAN and production 7.2.13 (N=8 ASAN, N=12 prod). We did not observe silent survival on that test. Soft corruption without immediate crash may still exist for other pressure models; we do not claim exhaustive coverage.
7. Reclaim watermark and address correlation
Lab instrumentation confirmed reclaim-sized writes targeting the freed client’s allocator class. Allocator-class correlation was verified in our lab. Instrumented freeClient() logging correlated eviction of blocked clients with concrete freed addresses. ASAN free→use remains definitive for RCA in our lab.
8. Public RCE narrative vs this lab (honest scope)
| Stage | Xint / public | Cipher lab |
|---|---|---|
| Root cause + patch | Yes | Verified |
| ASAN free→use stack | — | Verified |
sizeof(client) / layout | — | Verified (aarch64 + amd64) |
| Sibling audit | — | Original contribution |
| Variant triggers (BLPOP/BZPOPMIN) | Not emphasized in advisory | Measured |
| Extended ACL matrix | — | Measured |
| No-CONFIG negative result | — | Measured |
Lua leak → GOT → system | Demonstrated publicly | Cited only; not reproduced as shell |
We apply the same honesty standard as our other publications: we do not claim screenshots or chains we did not produce under our ethics policy.
9. Variant surface: beyond streams
9.1 Shared code path
unblockClientOnKey() explicitly handles all three block types:
serverAssert(c->bstate.btype == BLOCKED_STREAM ||
c->bstate.btype == BLOCKED_LIST ||
c->bstate.btype == BLOCKED_ZSET);
9.2 Measured variants (CONFIG-timed path)
| Trigger | Wake | 7.2.13 | 7.2.14 |
|---|---|---|---|
XREAD BLOCK | XADD | crash | survived |
BLPOP | LPUSH | crash | survived |
BZPOPMIN | ZADD | crash | survived |
Defender implication: detection signatures and ACL policies focused on XREAD/XADD/@stream miss two equivalent classes.
10. Extended ACL matrix (measured)
17-case matrix on Redis 7.2.13, July 2026.
10.1 Full results
| ACL profile | Trigger | Block allowed? | CONFIG arm? | Result |
|---|---|---|---|---|
baseline_admin | xread / blpop / bzpopmin | yes | yes | still_crashes |
deny_stream_only (-@stream -xread -xadd) | xread | no | yes | blocks_measured_path |
deny_stream_only | blpop | yes | yes | still_crashes |
deny_stream_only | bzpopmin | yes | yes | still_crashes |
deny_config_only (-config) | all three | yes | no | blocks_measured_path |
deny_blocking_list_zset | blpop / bzpopmin | no | yes | blocks_measured_path |
deny_stream_and_config | blpop / bzpopmin | yes | no | blocks_measured_path |
app_role_no_admin (-@admin -config) | blpop | yes | no | blocks_measured_path |
minimal_block_all_measured | all three | no | no | blocks_measured_path |
10.2 Minimum measured ACL to block all three triggers
Temporarily deny CONFIG and unused blocking primitives across stream, list, and sorted-set families (XREAD, XADD, BLPOP, BRPOP, BZPOPMIN, BZPOPMAX as applicable). Exact ACL syntax should be adapted to each deployment and role model. In our lab, this deny-set blocked all three measured trigger families; stream-only denial did not.
10.3 Questions we asked ourselves
Q: If we harden streams, are we done? A: No. -@stream -xread -xadd leaves BLPOP and BZPOPMIN fully available. We crashed 7.2.13 under deny_stream_only with both list and zset variants.
Q: Is denying `CONFIG` enough? A: It blocks measured CONFIG-timed reproduction paths because the attacker cannot perform runtime client-memory limit tightening during the wake transaction. It does not remove the UAF class in code. Boot-time maxmemory-clients still enables eviction without runtime CONFIG (see §11).
Q: Does a typical “app user” ACL without admin block the variant? A: Our `app_role_no_admin` profile (`-@admin -config -@dangerous`) blocked BLPOP variant because -config removes the timing arm—not because BLPOP is forbidden.
Q: What is the smallest command set to deny? A: Measured minimum: deny CONFIG plus every blocking primitive you cannot operationally need: XREAD, XADD (wake), BLPOP, BRPOP, BZPOPMIN, BZPOPMAX. Adjust if your application legitimately needs blocking lists.
Q: Does patch + ACL replace each other? A: No. Patch eliminates the bug class. ACL shrinks reachable recipes during upgrade windows.
11. No-CONFIG eviction: negative result with mechanism
11.1 Threat model
Operators may set maxmemory-clients at boot (redis.conf, argv, managed templates) and deny CONFIG to application users. Question: can an attacker still synchronize eviction-at-unblock without runtime CONFIG SET?
11.2 What we measured
| Experiment | Result |
|---|---|
Boot --maxmemory-clients + ACL -config | CONFIG GET → NOPERM (purity OK) |
| v2 sweep (25 combos, boot 4–12 MB, single evictee) | Evictee often evicted at balloon EXEC (cmd=exec) before XREAD parks |
| High boot limit (50–80 MB), fat balloon | blocked_clients:1, no eviction at wake → survived |
| Total no-CONFIG synchronized UAF crashes | 0 / 31 |
11.3 Interpretation
Two failure modes explain the negative result:
- Tight boot limit: the evictee is the largest client during balloon
EXECand is evicted before blocking—wrong phase, wrong victim. - High boot limit: the evictee blocks successfully but total client memory never crosses the eviction threshold at wake without the sudden CONFIG drop.
Therefore ACL -config is not magic—it removes the specific timing arm the measured CONFIG-timed path depends on. The underlying missing C_ERR check remains until patched.
12. Reliability and detection gap
| Profile | Runs | Crashes | Rate |
|---|---|---|---|
| 7.2.13 production binary | 12 | 12 | 100% |
| 7.2.13 ASAN | 8 | 8 | 100% |
| 7.2.14 patched | 8 | 0 | 0% |
Q: Can defenders rely on crash detection only? A: For the full CONFIG-timed reproduction path on unpatched 7.2.13 in our lab, crashes were deterministic. That does not prove all heap corruption paths crash immediately. Upgrade remains the correct control.
12.1 Detection signals
High-confidence suspicious authenticated activity pattern:
- Repeated client-memory limit changes (especially tightening)
- Long-lived blocking connection on a key (any blocking family)
- Near-simultaneous limit tightening and key mutation from another connection
- Optional large write immediately afterward
- Log line
Evicting client:with a blocking command name, then daemon exit
Do not rely on crash-only detection as a substitute for patching.
13. Remediation
13.1 Primary
Upgrade to a fixed release for your branch:
| Branch | Fixed in |
|---|---|
| 6.2.x | 6.2.22+ |
| 7.2.x | 7.2.14+ |
| 7.4.x | 7.4.9+ |
| 8.2.x | 8.2.6+ |
| 8.4.x | 8.4.3+ |
| 8.6.x | 8.6.3+ |
13.2 Compensating controls (until upgrade)
| Control | Blocks CONFIG-timed path | Blocks list/zset variants | Removes UAF class |
|---|---|---|---|
| Upgrade | Yes | Yes | Yes |
ACL -config | Yes | Yes (no timing arm) | No |
ACL -@stream only | Yes (stream only) | No | No |
| ACL deny blocking cmds (§10.2) | Partial | Yes | No |
| Network deny / strong AUTH | Reduces exposure | Reduces exposure | No |
| Full RELRO | No | No | No (raises exploit cost only) |
13.3 Developer / fork maintainers
- Treat
C_ERRfromprocessCommandAndResetClientas mandatory before anyc->use. - Add CI lint: every
processCommandAndResetClientcall must checkC_ERRbefore further use of the client pointer. - Audit module unblock paths (
moduleUnblockClientOnKey) under the same contract.
14. Lessons learned
- Return values that destroy arguments are API contracts—not decorative error codes.
- Composition risk across PRs can convert latent ignores into exploitable UAFs years later.
- Single-threaded servers make heap UAF reprocessing reliable—for crashes and for exploitation.
- Mitigations must be measured per trigger, not assumed from category names like “streams.”
- Negative results are publishable when they explain *why* a control works (CONFIG as timing arm).
- Patch vs ACL: operational controls shrink recipes; only patches remove defect classes.
15. Ethics and lab safety
All experiments ran against isolated Redis instances in a controlled lab under authorization. Do not test third-party systems without permission.
CVE-2026-23479 is patched upstream. This article supports defensive understanding, detection, and upgrade prioritization—not unauthorized exploitation.
16. References
- Redis security advisory (May 2026): https://redis.io/blog/security-advisory-cve202623479-cve202625243-cve-2026-25588-cve202625589-cve-2026-23631/
- GitHub GHSA-93m2-935m-8rj3: https://github.com/redis/redis/security/advisories/GHSA-93m2-935m-8rj3
- NVD CVE-2026-23479: https://nvd.nist.gov/vuln/detail/CVE-2026-23479
- Fix commit
0b51620: https://github.com/redis/redis/commit/0b51620ef1e0a34fb6239e328bfa15068e12caa4 - Xint Code / ZeroDay.Cloud public deep dive on Redis CVE-2026-23479 (prior RCE research): https://www.zeroday.cloud/blog/redis-cve-2026-23479-deep-dive
- Valkey parallel fix: https://github.com/valkey-io/valkey/commit/e4a46f59dab57c7215d87cc88462f57cf5af7ec6