Skip to content

[Storage] Fix use-after-free in ProcessResp*Output; use ScratchBufferAllocator for API-returned PinnedSpanByte#1672

Merged
badrishc merged 21 commits into
devfrom
badrishc/fix-process-resp-output-use-after-free
Apr 7, 2026
Merged

[Storage] Fix use-after-free in ProcessResp*Output; use ScratchBufferAllocator for API-returned PinnedSpanByte#1672
badrishc merged 21 commits into
devfrom
badrishc/fix-process-resp-output-use-after-free

Conversation

@badrishc

@badrishc badrishc commented Apr 6, 2026

Copy link
Copy Markdown
Collaborator

Root Cause

ProcessResp2ArrayOutput, ProcessResp3ArrayOutput, ProcessRespArrayOutputAsPairs, and ProcessRespSingleTokenOutput in StorageSession return PinnedSpanByte values pointing into IMemoryOwner<byte> buffers from MemoryPool<byte>.Shared. These buffers are not pinnedRespMemoryWriter pins them temporarily via MemoryHandle during write, but unpins on dispose. The ProcessResp* methods then dispose the IMemoryOwner in their finally blocks, returning dangling pointers into freed, unpinned memory.

This only affects the new ObjectOutput() code path (SpanByte invalidated, forcing IMemory allocation). The RESP handler path uses GetObjectOutput() backed by the pinned session network buffer, so it is unaffected.

Same bug exists on main with ArgSlice/GarnetObjectStoreOutput types.

Affected Commands

ZMPOP, LMPOP, BLMPOP, BZMPOP, and any custom procedures or Lua scripts calling HashGet, HashGetAll, HashGetMultiple, HashRandomField, SetMembers, SetPop, SortedSetPop, SortedSetRange, SortedSetIncrement, GET, or ObjectScan through IGarnetApi.

Description of Change

Use existing ScratchBufferAllocator (SBA) alongside existing ScratchBufferBuilder (SBB) in StorageSession. SBA uses pinned arrays (GC.AllocateArray(_, true)) and keeps old buffers rooted, so returned PinnedSpanByte values remain valid across multiple API calls within a batch.

Key changes:

  • Core fix (Common.cs): Copy parsed output to SBA inside fixed block before IMemoryOwner disposal
  • GET fix (MainStoreOps.cs): Use SBA ViewRemainingArgSlice for zero-copy common case; copy on IMemory overflow
  • Custom command fix (CustomRespCommands.cs): All returned PinnedSpanByte output uses SBA + proper Memory disposal
  • SBA wiring: Added to StorageSession, DatabaseManagerBase, RespServerSession, LocalServerSession
  • API cleanup (IGarnetApi): Removed GetScratchBufferOffset() and ResetScratchBuffer(int), added ResetScratchBuffer() (full SBA reset)
  • SBB debug safety (ScratchBufferBuilder.cs): Debug-only outstandingSlices counter asserts no more than one live PinnedSpanByte at a time
  • SBB safety fixes: Added missing rewinds in SortedSetIncrement, KeyAdminCommands, CollectionItemBroker; single contiguous allocation for SortedSetRange LIMIT; create+use+rewind for Lua AddKey
  • New API: CreateArgSliceAsOffset returns (Offset, Length) instead of PinnedSpanByte — safe for multi-alloc (used by ArgSliceVector)
  • Visibility: scratchBufferBuilder and scratchBufferAllocator marked internal on StorageSession
  • Cleanup: Removed unused FormatScratchAsResp, GetSliceFromTail, ResetScratchBuffer(int) from SBB

Key Technical Details

SBB vs SBA roles:

  • ScratchBufferBuilder — contiguous temporary workspace (Lua serialization, command input building with rewind). Single buffer, reallocates in place.
  • ScratchBufferAllocator — persistent output storage for API-returned PinnedSpanByte. Keeps old buffers rooted in a stack. Reset between batches.

Affected types:

  • StorageSession — new scratchBufferAllocator field
  • IGarnetApi / GarnetApi / GarnetWatchApi — simplified scratch buffer API
  • ScratchBufferBuilder — debug counter, CreateArgSliceAsOffset, doc comments on view APIs, removed unused APIs

What NOT to Do

  • Do not return PinnedSpanByte from ScratchBufferBuilder for values that must survive across multiple API calls — use ScratchBufferAllocator
  • Do not call CreateArgSlice on SBB without rewinding before the next CreateArgSlice — debug asserts enforce this
  • Do not skip Memory.Dispose() after copying from IMemoryOwner to SBA — causes pool buffer leaks
  • Do not store ViewFullArgSlice / ViewRemainingArgSlice results — they are immediate-use views invalidated by subsequent operations

@badrishc badrishc force-pushed the badrishc/fix-process-resp-output-use-after-free branch 2 times, most recently from 28f2280 to 227fef3 Compare April 6, 2026 18:45
ProcessResp2ArrayOutput, ProcessResp3ArrayOutput, ProcessRespArrayOutputAsPairs,
and ProcessRespSingleTokenOutput return PinnedSpanByte values pointing into
IMemoryOwner<byte> buffers that are disposed in their finally blocks. Since the
IMemoryOwner buffers from MemoryPool<byte>.Shared are not pinned (RespMemoryWriter
unpins them on dispose), the returned PinnedSpanByte pointers are both dangling
(due to dispose) and pointing to unpinned memory.

This affects all callers that use new ObjectOutput() (which forces IMemory
allocation): ZMPOP, LMPOP, BLMPOP, BZMPOP, and any custom procedures or Lua
scripts calling HashGet, HashGetAll, HashGetMultiple, HashRandomField,
SetMembers, SetPop, SortedSetPop, SortedSetRange, SortedSetIncrement, or
ObjectScan through IGarnetApi.

Fix: copy parsed data to scratchBufferBuilder (which uses GC.AllocateArray
with pinned=true) inside the fixed block before the finally disposes the
Memory. Uses single-allocation helpers to avoid scratch buffer reallocation.

Co-authored-by: Copilot <[email protected]>
@badrishc badrishc force-pushed the badrishc/fix-process-resp-output-use-after-free branch from 227fef3 to cbb558c Compare April 6, 2026 18:50
badrishc and others added 8 commits April 6, 2026 12:10
…or output

- Add ScratchBufferAllocator parameter to StorageSession constructor, symmetric
  with ScratchBufferBuilder. All callers updated to pass an instance.
- Fix GET(key, out PinnedSpanByte value) to copy output to SBA instead of SBB,
  so returned values remain valid across multiple API calls.
- Update OutOfOrderFreeBuffer test to reflect that GET output no longer
  consumes SBB space.

Co-authored-by: Copilot <[email protected]>
- Add GetScratchBufferAllocatorOffset() and ResetScratchBufferAllocator()
  to IGarnetApi, GarnetApi, and GarnetWatchApi (symmetric with existing
  SBB APIs but without offset param since SBA only supports full reset).
- Make ScratchBufferAllocator.ScratchBufferOffset public.
- Update LargeGet/LargeGetTxn to use SBA reset to prevent unbounded growth.
- Update OutOfOrderFreeBuffer to test SBA offset advancement and reset.

Co-authored-by: Copilot <[email protected]>
- Add ViewRemainingArgSlice to ScratchBufferAllocator for zero-copy GET
- Fix GET to use SBA: zero-copy in common case (SpanByte), copy only on
  IMemory overflow
- Fix InvokeCustomRawStringCommand and InvokeCustomObjectCommand to use SBA
  instead of SBB for returned output
- Simplify IGarnetApi: remove GetScratchBufferOffset and
  ResetScratchBuffer(int offset), rename ResetScratchBufferAllocator to
  ResetScratchBuffer() (parameterless full reset)
- Update tests for new API

Co-authored-by: Copilot <[email protected]>
…torageSession

Both are implementation details used only within libs/server/.
SBB is used for temporary workspace (Lua, command input building).
SBA is used for API-returned PinnedSpanByte values.

Co-authored-by: Copilot <[email protected]>
Add outstandingSlices counter (DEBUG only) that asserts no more than one
PinnedSpanByte slice is live at a time. Fires on CreateArgSlice and
ExpandScratchBuffer when an unrewound slice exists.

Add CreateArgSliceAsOffset API returning (offset, length) instead of
PinnedSpanByte — safe for multiple calls since offsets survive reallocation.

Fix missing rewinds caught by the new assert:
- SortedSetIncrement: rewind incrSlice after RMW
- KeyAdminCommands (RESTORE): rewind valArgSlice after SET_Conditional
- CollectionItemBroker: rewind asKey in finally block
- SortedSetRange LIMIT: single contiguous allocation instead of 3 separate
- Lua AddKey: create+use+rewind per key iteration
- ArgSliceVector: use CreateArgSliceAsOffset instead of CreateArgSlice

Co-authored-by: Copilot <[email protected]>
@badrishc badrishc marked this pull request as ready for review April 6, 2026 21:30
Copilot AI review requested due to automatic review settings April 6, 2026 21:31

Copilot AI 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.

Pull request overview

Fixes a use-after-free / unpinned-pointer bug in RESP output parsing by ensuring PinnedSpanByte results no longer reference IMemoryOwner<byte> buffers that are disposed/unpinned after parsing. This primarily affects object-API callers that force heap IMemory allocation (e.g., via new ObjectOutput()), including custom procedures and Lua.

Changes:

  • Copy parsed RESP outputs into pinned ScratchBufferAllocator storage when output came from heap IMemoryOwner<byte> so returned PinnedSpanByte remains valid.
  • Route string/object command outputs (e.g., GET, custom command invocations) through ScratchBufferAllocator rather than ScratchBufferBuilder when results must survive disposal of heap-backed memory.
  • Simplify scratch-buffer API by removing offset-based reset from IGarnetApi and adding safer builder patterns (CreateArgSliceAsOffset) plus DEBUG assertions to catch unsafe multi-slice usage.

Reviewed changes

Copilot reviewed 18 out of 18 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
test/Garnet.test/RespCustomCommandTests.cs Updates custom-procedure tests to use the new scratch-buffer reset API.
libs/server/Storage/Session/StorageSession.cs Threads ScratchBufferAllocator through StorageSession so parsing/copy helpers can allocate pinned storage.
libs/server/Storage/Session/ObjectStore/SortedSetOps.cs Adjusts scratch usage (rewind / single-allocation LIMIT) to avoid pointer invalidation risks.
libs/server/Storage/Session/ObjectStore/Common.cs Copies RESP-parsed PinnedSpanByte arrays/pairs/single tokens into pinned scratch allocation before disposing heap memory.
libs/server/Storage/Session/MainStore/MainStoreOps.cs Makes GET output allocation use ScratchBufferAllocator (including “view remaining” + claim/copy behavior).
libs/server/Resp/RespServerSession.cs Passes allocator into StorageSession construction.
libs/server/Resp/LocalServerSession.cs Instantiates and passes a ScratchBufferAllocator for local sessions.
libs/server/Resp/KeyAdminCommands.cs Rewinds scratch buffer slice used to build RESTORE value argument.
libs/server/Objects/ItemBroker/CollectionItemBroker.cs Rewinds key slice allocated from scratchBufferBuilder after use.
libs/server/Lua/LuaRunner.cs Rewinds temporary key slices after computing/storing key hashes for transactions.
libs/server/Databases/DatabaseManagerBase.cs Creates allocator alongside builder for background/maintenance StorageSessions.
libs/server/Custom/CustomRespCommands.cs Switches response formatting/copying to allocator-backed slices to avoid disposed heap memory.
libs/server/ArgSlice/ScratchBufferBuilder.cs Adds DEBUG tracking/asserts for outstanding slices; replaces offset-reset API with offset-based slice creation helper.
libs/server/ArgSlice/ScratchBufferAllocator.cs Adds ViewRemainingArgSlice to support “write into remaining space then claim” patterns.
libs/server/ArgSlice/ArgSliceVector.cs Uses offset-based builder API to safely store many items without returning unstable pointers.
libs/server/API/IGarnetApi.cs Replaces offset-based scratch buffer APIs with a single ResetScratchBuffer() method.
libs/server/API/GarnetWatchApi.cs Updates wrapper to match new IGarnetApi.ResetScratchBuffer() API.
libs/server/API/GarnetApi.cs Implements ResetScratchBuffer() by resetting the session’s ScratchBufferAllocator.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread libs/server/API/IGarnetApi.cs
Comment thread test/Garnet.test/RespCustomCommandTests.cs
Comment thread test/Garnet.test/RespCustomCommandTests.cs
Comment thread libs/server/ArgSlice/ScratchBufferAllocator.cs
badrishc and others added 2 commits April 6, 2026 17:38
The object store output path was copying to SBA but not disposing the
IMemoryOwner, leaking pooled buffers. The raw string path already had
the dispose. Found by GPT 5.4 review.

Co-authored-by: Copilot <[email protected]>
@badrishc badrishc changed the title Fix use-after-free in ProcessResp*Output methods [Storage] Fix use-after-free in ProcessResp*Output; introduce ScratchBufferAllocator for API-returned PinnedSpanByte Apr 6, 2026
badrishc and others added 4 commits April 6, 2026 15:22
Remove FormatScratchAsResp (both overloads), GetSliceFromTail, and
GetRespFormattedStringLength from ScratchBufferBuilder — no callers.

Rename scratchBufferManager parameter to scratchBufferBuilder in
LuaRunner.Functions.cs for consistency.

Co-authored-by: Copilot <[email protected]>
- Fix ScratchBufferAllocator.ViewRemainingArgSlice doc: 'ArgSlice' -> 'PinnedSpanByte'
- LargeGet: validate HashGet result content, not just length
- OutOfOrderFreeBuffer: validate GET results are non-empty and identical
  (verifies data integrity, not just pointer liveness)

Co-authored-by: Copilot <[email protected]>
Document SBB vs SBA roles, rules for returning PinnedSpanByte,
rewind discipline, IMemoryOwner disposal, and view API safety.

Co-authored-by: Copilot <[email protected]>
Set test values to a verifiable pattern instead of all-zeros. Validate
actual content in LargeGet (HashGet) and OutOfOrderFreeBuffer (GET) to
catch data corruption from dangling pointers, not just empty results.

Co-authored-by: Copilot <[email protected]>
@badrishc badrishc changed the title [Storage] Fix use-after-free in ProcessResp*Output; introduce ScratchBufferAllocator for API-returned PinnedSpanByte [Storage] Fix use-after-free in ProcessResp*Output; use ScratchBufferAllocator for API-returned PinnedSpanByte Apr 6, 2026
badrishc and others added 2 commits April 6, 2026 16:06
Match SBB's 64-byte minimum to avoid excessive small pinned array
allocations on first use. Applies to RespServerSession, LocalServerSession,
and DatabaseManagerBase SBA instances.

Co-authored-by: Copilot <[email protected]>
Set the default at the class level instead of at each call site.

Co-authored-by: Copilot <[email protected]>
Comment thread .github/copilot-instructions.md Outdated
Comment thread .github/copilot-instructions.md Outdated
Clarify that SBB lays out data sequentially in one buffer and expansion
invalidates pointers. Clarify that SBA maintains fragmented buffers with
old ones kept rooted so pointers remain valid.

Co-authored-by: Copilot <[email protected]>
Comment thread libs/server/ArgSlice/ScratchBufferAllocator.cs
badrishc and others added 2 commits April 6, 2026 16:37
Test explicitly passes minSizeBuffer: 2 since it tests internal
sizing logic that depends on the minimum buffer size.

Co-authored-by: Copilot <[email protected]>
Previously minSizeBuffer was applied before RoundUpToPowerOf2, causing
minSizeBuffer=64 with a 5-byte request to allocate 128 bytes (64+1
rounded up). Now the power-of-2 rounding is done on the actual request
size first, then clamped to minSizeBuffer. A 5-byte request with
minSizeBuffer=64 now allocates exactly 64 bytes.

Co-authored-by: Copilot <[email protected]>
Comment thread libs/server/Objects/ItemBroker/CollectionItemBroker.cs
@badrishc badrishc merged commit 1cb0a98 into dev Apr 7, 2026
17 of 22 checks passed
@badrishc badrishc deleted the badrishc/fix-process-resp-output-use-after-free branch April 7, 2026 00:31
@github-actions github-actions Bot locked and limited conversation to collaborators Jun 6, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants