July 18, 2022·6 min read·engineeringvalkeyredissecuritystreams

Redis XAUTOCLAIM must count deleted entries against its response limit

Redis 7.0.4 fixes a heap overflow in XAUTOCLAIM's deleted-entry handling. A missing decrement allowed cleanup results to exceed the space allocated from COUNT.

Redis 7.0's XAUTOCLAIM can return information about messages it cannot deliver. When a pending entry refers to a message that has been deleted, the command removes the obsolete pending record and reports the deleted ID separately. That cleanup path needs to consume the same response budget as a successfully claimed message. Before Redis 7.0.4, it did not.

The July 18 release notes identify CVE-2022-31144 as a heap overflow involving a crafted XAUTOCLAIM request and particular stream state. The maintainer advisory, published the same day, identifies 7.0.4 as the fix. Potential remote code execution is the stated impact, so this is a security update rather than an adjustment to an incidental counter.

I like the explicit ownership model of Redis consumer groups. It makes the difference between new delivery and reassignment visible to an application. The implementation still has to keep every piece of bookkeeping consistent with its memory bounds, including records for messages that no longer exist.

A pending record can outlive its stream entry

The 7.0.4 stream implementation describes XAUTOCLAIM as changing ownership of messages in a group's pending-entry list. A caller specifies the group, receiving consumer, minimum idle time, and a starting ID. The command traverses pending entries and transfers suitable messages to the selected consumer.

Pending state and stream contents have separate lifecycles. The command explicitly checks whether the stream entry still exists before transferring ownership. If the entry is gone, there is no body to return to the receiving consumer. Redis instead removes the pending record from the group and the former consumer, frees its bookkeeping object, and records the deleted ID for the reply.

The command specification in the release tag documents the deleted-entry reply element as a Redis 7.0 addition. This explains why a pre-existing command acquired a new failure mode in that release line. The response now accounts for a category of result beyond successfully claimed live messages.

The cleanup is useful. Leaving a pending record for nonexistent data would retain an ownership obligation that no consumer can complete through the original message body. Reporting its removal gives the caller information about why that pending work disappeared. But the extra result also needs storage and limits of its own.

COUNT supplies a shared response budget

The implementation initializes count to 100 and permits an explicit COUNT argument. It allocates a deleted_ids array using the count and the size of a stream ID. The traversal loop is also bounded by a remaining count and a separate attempt budget.

For a successfully claimed live entry, the code decrements the remaining count after adding the reply. That keeps the number of reported live results within the requested budget. The deleted-entry branch records an ID in the allocated array and continues the loop through a different path.

The difference between 7.0.3 and 7.0.4 adds the missing decrement to that branch. Its comment states that count limits the command response size. That is the key invariant. A deleted ID is a response result even though it is not a newly assigned message with a body.

Before the change, deleted records could consume array slots without reducing the remaining count. A bound on traversal attempts did not make the smaller allocated array large enough for every deleted ID the loop could encounter. The fix brings the branch that appends those IDs under the same budget as the branch returning claimed messages.

This distinction is easy to miss when interpreting COUNT as only the number of successful transfers. The implementation has to count every result that consumes the bounded storage. A cleanup outcome can be operationally different from a delivery outcome while still consuming that shared resource.

The cursor can advance without a live message

The regression test added with the fix creates a stream and a consumer group, delivers entries to one consumer, and deletes some stream entries before attempting reassignment. It then uses a small count to verify the cleanup behavior. Each deleted ID consumes the budget, so a call can return no live messages and still advance the cursor.

The test checks the deleted-ID element separately from the live-message element. That matters to client logic. An empty live-message list does not prove that traversal has finished or that no state changed. The cursor and deleted-ID list provide the information needed to interpret that result.

The test eventually reaches an existing entry and returns it for the receiving consumer. It also verifies that the former consumer's pending list is empty. This covers both kinds of progress. Obsolete records disappear through cleanup, while a surviving entry changes ownership.

An application implementing reassignment needs to retain those distinctions. Its next request should use the returned cursor according to the command's traversal model. It should not infer completion solely from the absence of message bodies in one reply. The fixed behavior makes the response budget accurate without removing cleanup from the command.

Idle time applies after the existence check

The implementation checks for deleted stream entries before applying the minimum-idle condition to live entries. That order reflects their different purposes. A missing message cannot be transferred, regardless of how long its pending record has been idle. A live message needs to satisfy the idle threshold before ownership moves.

For eligible live messages, the receiving consumer is created if needed. Redis updates ownership and resets delivery time. Unless JUSTID is selected, it increments the delivery count and returns the entry body. Those operations use the same remaining count that the corrected deletion path now decrements.

This makes the command more than a read-only scan. It can alter consumer bookkeeping and clean up records, as well as return data. The command specification marks it as a write command and assigns it to the stream ACL category. Access to that operation is therefore a concrete permission decision in a shared Redis deployment.

The advisory's affected scope and the command's authorization are different facts. The release notes identify the vulnerable implementation line. Permissions determine which callers can invoke the operation against the relevant key. Restricting access can reduce exposure, but it does not change the implementation's memory accounting or make an affected binary fixed.

The deletion path also propagates its bookkeeping change. Before removing the pending record, it emits the corresponding claim propagation information and increments the dirty counter. Cleanup is therefore part of the database state transition, even when the caller receives no live message. Returning an empty message list must not be interpreted as a read that left all state untouched.

The reply has three components in this version, with the cursor first and live entries separate from deleted IDs. A client written around an older two-part expectation needs to preserve the new component when decoding the response. This is relevant independently of the memory defect because the deleted IDs explain a real change to pending ownership records.

The repair is in result accounting

The patch does not remove deleted-ID reporting or stop pending-entry cleanup. It retains those semantics and charges the result against the count. That is a useful property of the repair because the client still receives information needed to reconcile its view of outstanding work.

The attempt budget also remains separate. Traversal can encounter entries that are not eligible for transfer, so scanning and returning results have different bounds. Correctness requires both bounds to match their own purpose. A loop limit cannot substitute for an allocation limit when the two count different outcomes.

I prefer reading this fix through that invariant rather than treating it as an unexplained reason to avoid Streams. Consumer-group recovery is a legitimate operation, including recovery after deletion. The security defect is the disagreement between the storage allocated for cleanup results and the number of cleanup results the loop could record.

Redis 7.0.4 corrects that disagreement in the deleted-entry branch and adds a regression test that exercises the interaction. The release notes classify the update as security-related and identify the patched version. For an affected Redis 7.0 deployment, updating supplies the repaired accounting while preserving the command's cursor, cleanup, and ownership-transfer behavior.