Details
Repro
Clean server at 3f9062e, no config, default user. The ECHO calls only fill the heap block with a known byte so the garbage index is reliably far out of range; the bug is present without them, it just crashes less often.
import socket
s = socket.create_connection(('127.0.0.1', 7496))
def send(*a):
out = b'*%d\r\n' % len(a)
for x in a:
x = x.encode() if isinstance(x, str) else x
out += b'$%d\r\n%s\r\n' % (len(x), x)
s.sendall(out)
n = 257
for _ in range(20):
send('ECHO', 'A' * (n * 8)); s.recv(65536)
send('ECHO', 'A' * (n * 8 - 8)); s.recv(65536)
send('ECHO', 'A' * (n * 8 + 8)); s.recv(65536)
send('MULTI'); send('SET', 'x', '1')
args = []
for i in range(n):
args += ['NX', 'k%d' % i]
send('EXEC', *args)
print(s.recv(65536)) # b'' -- server is gone
Three fresh servers, three crashes (two at n=257, one at n=260 on the next loop iteration):
--- fresh server run 1 (port 7496)
n=257 -> SERVER CLOSED CONNECTION (EOF)
ping: Could not connect to Valkey at 127.0.0.1:7496: Connection refused
--- fresh server run 2 (port 7497)
n=257 EXEC -> ['OK']
n=260 -> SERVER CLOSED CONNECTION (EOF)
ping: Could not connect to Valkey at 127.0.0.1:7497: Connection refused
--- fresh server run 3 (port 7498)
n=257 EXEC -> ['OK']
n=260 -> SERVER CLOSED CONNECTION (EOF)
ping: Could not connect to Valkey at 127.0.0.1:7498: Connection refused
=== VALKEY BUG REPORT START: Cut & paste starting from here ===
15364:M 16 Sep 2026 06:32:57.459 # valkey 255.255.255 crashed by signal: 11, si_code: 1
15364:M 16 Sep 2026 06:32:57.459 # Accessing address: 0x7f17fc968080
15364:M 16 Sep 2026 06:32:57.459 # Crashed running the instruction at: 0x4f7472
------ STACK TRACE ------
15364 valkey-server *
/lib64/libpthread.so.0(+0x118e0)[0x7f17ad45a8e0]
/home/matolson/v92audit/build-plain/src/valkey-server *:7491[0x4f7472]
/home/matolson/v92audit/build-plain/src/valkey-server *:7491(processInputBuffer+0x4ad)[0x50188d]
/home/matolson/v92audit/build-plain/src/valkey-server *:7491(readQueryFromClient+0xaa)[0x5019da]
/home/matolson/v92audit/build-plain/src/valkey-server *:7491[0x5be7af]
/home/matolson/v92audit/build-plain/src/valkey-server *:7491(aeMain+0x10e)[0x48303e]
/home/matolson/v92audit/build-plain/src/valkey-server *:7491(main+0x5d7)[0x460637]
0x4f7472 is inside addKeysToIncrFindBatch (symbol base 0x4f7360), which is the inlined body of addCommandToBatch in src/memory_prefetch.c.
Deterministic view of the same bug
COMMAND GETKEYS shows the uninitialised entries directly. At 256 conditions the whole list lives in getKeysResult.keysbuf and is correct; at 257 the first 256 pos values are garbage, here all zero, so they resolve to argv[0]:
n=255 -> ['k0', 'k1'] ... ['k254'] (len 255)
n=256 -> ['k0', 'k1'] ... ['k255'] (len 256)
n=257 -> ['EXEC', 'EXEC'] ... ['k256'] (len 257)
n=258 -> ['EXEC', 'EXEC'] ... ['k257'] (len 258)
n=300 -> ['EXEC', 'EXEC'] ... ['k299'] (len 300)
The garbage also reaches ACL, so a key-restricted user gets a transaction denied that should be allowed. Every condition key matches ~k*:
n=256 EXEC(lim user) -> RUN
n=257 EXEC(lim user) -> EXECABORT Transaction discarded because of: NOPERM No permissions to access a key
n=300 EXEC(lim user) -> EXECABORT Transaction discarded because of: NOPERM No permissions to access a key
n=1000 EXEC(lim user) -> EXECABORT Transaction discarded because of: NOPERM No permissions to access a key
In cluster mode the same list feeds slot routing, so it can also produce a wrong CROSSSLOT verdict or route on a garbage slot.
Why
src/multi.c:238-252:
while (index < argc) {
int key_index = index + 1;
execCondition condition;
if (parseExecCondition(argv, argc, &index, &condition) != C_OK) {
result->numkeys = 0;
return 0;
}
keys = getKeysPrepareResult(result, numkeys + 1);
keys[numkeys].pos = key_index;
keys[numkeys].flags = CMD_KEY_RO | CMD_KEY_ACCESS;
numkeys++;
}
result->numkeys = numkeys;
initGetKeysResult sets result->size = MAX_KEYS_BUFFER (256, src/server.h:2535) and result->numkeys = 0. On the 257th iteration getKeysPrepareResult takes the "we are using a static buffer" branch and guards the copy on result->numkeys, which is still 0:
result->keys = zmalloc(numkeys * sizeof(keyReference));
if (result->numkeys) memcpy(result->keys, result->keysbuf, result->numkeys * sizeof(keyReference));
src/db.c:2373-2374. Every other incremental caller keeps result->numkeys in step as it appends, for example getKeysUsingKeySpecs at src/db.c:2474.
Fix
Publish numkeys inside the loop:
keys[numkeys].pos = key_index;
keys[numkeys].flags = CMD_KEY_RO | CMD_KEY_ACCESS;
numkeys++;
+ result->numkeys = numkeys;
}
result->numkeys = numkeys;
With only that change applied at 3f9062e, two fresh servers survive the repro at n = 257 through 2000, COMMAND GETKEYS with 257 conditions returns first3=['k0', 'k1', 'k2'] last=['k256'] len=257, and the ~k* user's 257-condition EXEC returns ['OK'].
Alternatively count the conditions in a first pass and size the result once, which also drops the O(n) reallocs.
Introduced by
#4019, 0adf657af9327f906430ab526e5cbe326ba5410a, which added execGetKeys. EXEC had no keys before it.
Testing
tests/unit/multi.tcl and tests/unit/acl-v2.tcl from #4019 only use one or two conditions, so nothing crosses the 256-entry buffer. A case with 257 conditions is what is missing.
This was generated by AI but verified, with love, by a human.
EXECwith more than 256 conditions returns a key list whose first 256 entries are uninitialised heap memory, which crashes the server.execGetKeysinsrc/multi.c:232grows the result one key at a time withgetKeysPrepareResult(result, numkeys + 1)but only publishesresult->numkeysafter the loop ends (src/multi.c:251).getKeysPrepareResultusesresult->numkeysto decide how much of the 256-entry stack buffer to copy when it moves to the heap (src/db.c:2374), so at the 257th condition it allocates a fresh buffer and copies nothing, leavingkeys[0..255].posas whatever was in that heap block. The memory-prefetch path then readsargv[result.keys[i].pos]with that garbage index atsrc/memory_prefetch.c:360and dereferences the result, so any client that can sendEXECwith 257 or more conditions can segfault the server.Details
Repro
Clean server at 3f9062e, no config, default user. The
ECHOcalls only fill the heap block with a known byte so the garbage index is reliably far out of range; the bug is present without them, it just crashes less often.Three fresh servers, three crashes (two at
n=257, one atn=260on the next loop iteration):0x4f7472is insideaddKeysToIncrFindBatch(symbol base0x4f7360), which is the inlined body ofaddCommandToBatchinsrc/memory_prefetch.c.Deterministic view of the same bug
COMMAND GETKEYSshows the uninitialised entries directly. At 256 conditions the whole list lives ingetKeysResult.keysbufand is correct; at 257 the first 256posvalues are garbage, here all zero, so they resolve toargv[0]:The garbage also reaches ACL, so a key-restricted user gets a transaction denied that should be allowed. Every condition key matches
~k*:In cluster mode the same list feeds slot routing, so it can also produce a wrong
CROSSSLOTverdict or route on a garbage slot.Why
src/multi.c:238-252:initGetKeysResultsetsresult->size = MAX_KEYS_BUFFER(256,src/server.h:2535) andresult->numkeys = 0. On the 257th iterationgetKeysPrepareResulttakes the "we are using a static buffer" branch and guards the copy onresult->numkeys, which is still 0:src/db.c:2373-2374. Every other incremental caller keepsresult->numkeysin step as it appends, for examplegetKeysUsingKeySpecsatsrc/db.c:2474.Fix
Publish
numkeysinside the loop:keys[numkeys].pos = key_index; keys[numkeys].flags = CMD_KEY_RO | CMD_KEY_ACCESS; numkeys++; + result->numkeys = numkeys; } result->numkeys = numkeys;With only that change applied at 3f9062e, two fresh servers survive the repro at n = 257 through 2000,
COMMAND GETKEYSwith 257 conditions returnsfirst3=['k0', 'k1', 'k2'] last=['k256'] len=257, and the~k*user's 257-conditionEXECreturns['OK'].Alternatively count the conditions in a first pass and size the result once, which also drops the O(n) reallocs.
Introduced by
#4019,
0adf657af9327f906430ab526e5cbe326ba5410a, which addedexecGetKeys.EXEChad no keys before it.Testing
tests/unit/multi.tclandtests/unit/acl-v2.tclfrom #4019 only use one or two conditions, so nothing crosses the 256-entry buffer. A case with 257 conditions is what is missing.This was generated by AI but verified, with love, by a human.