Skip to content

fix(net): pin the array behind PacketReader(byte[]) — dangling pointer on every compressed gump - #251

Open
JD-Ultima wants to merge 1 commit into
markdwags:masterfrom
JD-Ultima:fix/packetreader-pin-upstream
Open

JD-Ultima wants to merge 1 commit into
markdwags:masterfrom
JD-Ultima:fix/packetreader-pin-upstream

Conversation

@JD-Ultima

Copy link
Copy Markdown

The bug

PacketReader(byte[] buff, bool dyn) in Razor/Network/Packet.cs takes its pointer inside a fixed block and keeps using it after the block ends, while holding no reference to the array:

public PacketReader(byte[] buff, bool dyn)
{
    fixed (byte* p = buff)
        m_Data = p;          // escapes the fixed block
    m_Length = buff.Length;
    ...
}

fixed pins only for the statement it wraps. Its only caller is Packet.GetCompressedReader(), where the inflated buffer is a local — so from the moment the reader is returned, nothing keeps the array alive or in place and the GC is free to move or collect it.

Handlers.CompressedGump (the 0xDD handler) then reads through that pointer while allocating heavily in between — Regex.Split over the layout, an int.Parse per match, and a string per string-table entry. A collection landing inside that window is not a remote possibility.

The result is either silently wrong gump data, or an access violation in JIT code. An AV is a corrupted-state exception, so the handler's catch {} never sees it and the host process dies. Under ClassicUO that surfaces as the client exiting with 0xC0000005 and no managed stack trace — intermittent, and more frequent on shards that redraw gumps often.

Reproduction

A harness that loads a released Razor.exe by reflection, builds a PacketReader the way GetCompressedReader does, forces a compacting GC, then reads the buffer back:

Build Trials returning wrong bytes
1.9.77.0 (released binary) 200 / 200
with this patch 0 / 200

I first found this from a minidump of a ClassicUO crash: AV reading a misaligned address, ExceptionAddress in no loaded module (JIT heap), mscorlib.ni.dll + clr.dll on the faulting stack, and the managed heap holding a constructed "Attempted to read or write protected memory" exception. The client log ended mid-0xDD.

The fix

Pin the array with a GCHandle for the reader's lifetime and free it in a finalizer.

The byte* constructor is the per-packet hot path and its buffer is owned and already pinned by the caller (ClassicUO.OnRecv/OnSend and OSI build their readers inside their own fixed blocks and never let them escape), so it allocates no handle and calls GC.SuppressFinalize to stay off the finalizer queue. Nothing changes for that path.

PacketReader(byte[]) is the only place in the codebase where such a pointer escapes — I checked every fixed block in Razor/.

Builds clean against master (net472, x64).


🤖 Generated with Claude Code

…lifetime

`PacketReader(byte[] buff, bool dyn)` takes its pointer inside a `fixed` block
and keeps using it after the block ends, while holding no reference to the
array:

    fixed (byte* p = buff)
        m_Data = p;

`fixed` pins only for the statement it wraps. Its only caller is
`Packet.GetCompressedReader()`, where the inflated buffer is a local, so from
the moment the reader is returned the GC is free to move or collect it. The
0xDD handler (`Handlers.CompressedGump`) then walks the layout and the string
table through that pointer while allocating heavily in between - `Regex.Split`,
`int.Parse`, and a string per table entry - so a collection landing inside that
window is not unlikely.

The result is either silently wrong gump data or an access violation in JIT
code. An AV is a corrupted-state exception, so the handler's `catch {}` does not
see it and the host process dies. Under ClassicUO that surfaces as the client
exiting with 0xC0000005 and no managed stack trace.

Reproduced against a released 1.9.77.0 binary with a harness that builds a
reader the way GetCompressedReader does, forces a compacting GC, then reads
back: 200/200 trials return the wrong bytes. With this change, 0/200.

Fix: pin with a GCHandle for the reader's lifetime and free it in a finalizer.
The `byte*` constructor - the per-packet hot path, whose buffer is owned and
already pinned by the caller - suppresses finalization, so nothing changes
there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

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

Copilot review overview

🟡 Changes recommended

The finalizer design adds per-packet overhead and retains pinned buffers nondeterministically.

Get a fresh assessment by requesting another Copilot review.

Review effort: Balanced
Findings: 1 Medium severity

Open (1)
What changed in this PR

Fixes dangling pointers in array-backed PacketReader instances used for compressed packet data.

Changes:

  • Pins array-backed buffers with GCHandle.
  • Adds finalizer-based handle cleanup.
  • Suppresses finalization for caller-owned pointer buffers.
File Description
Razor/​Network/​Packet.cs Adds managed-buffer pinning and lifetime cleanup.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread Razor/Network/Packet.cs
Comment on lines +821 to +824
~PacketReader()
{
if (m_Pin.IsAllocated)
m_Pin.Free();
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.

3 participants