Skip to content

Fix dangling pointer crash in PacketReader(byte[], bool)#249

Open
kamronbatman wants to merge 1 commit intomarkdwags:masterfrom
kamronbatman:fix/packet-reader-dangling-pointer
Open

Fix dangling pointer crash in PacketReader(byte[], bool)#249
kamronbatman wants to merge 1 commit intomarkdwags:masterfrom
kamronbatman:fix/packet-reader-dangling-pointer

Conversation

@kamronbatman
Copy link
Copy Markdown
Contributor

@kamronbatman kamronbatman commented Apr 28, 2026

The bug

PacketReader(byte[] buff, bool dyn) in Razor/Network/Packet.cs stored a byte* past the lifetime of its fixed block:

public PacketReader(byte[] buff, bool dyn)
{
    fixed (byte* p = buff)
        m_Data = p;       // pinned ONLY for this statement
    m_Length = buff.Length;
    ...
}

The instant the fixed block exits, buff is unpinned and may be relocated by the GC. m_Data becomes a dangling pointer; every subsequent ReadByte dereferences whatever now lives at the old address — usually still-valid heap memory (silent), sometimes garbage from another allocation, sometimes an unmapped page.

The reliable production trigger is GetCompressedReaderCompressedGump. The handler allocates new string[numStrings] between constructing the inner reader and the first pComp.ReadInt16(), opening a Gen0 collection window before any read happens.

In .NET Framework 4+, AccessViolationException is a Corrupted State Exception. Razor's app.config doesn't enable <legacyCorruptedStateExceptionsPolicy> and no method carries [HandleProcessCorruptedStateExceptions], so the AVE escapes both catch blocks (CompressedGump's and ProcessViewers's) and the process is terminated. This matches the reported production stack trace verbatim:

at Assistant.PacketReader.ReadByte()
at Assistant.PacketReader.ReadInt16()
at Assistant.PacketHandlers.CompressedGump(...)
at Assistant.PacketHandler.ProcessViewers(...)
at Assistant.PacketHandler.OnServerPacket(...)
at Assistant.ClassicUOClient.OnRecv(...)

Empirical proof

A standalone repro built two readers — the current buggy ctor and a GCHandle.Pinned variant — fragmented the LOH with bracketing 100KB allocations, dropped the holes, forced a compacting Gen2 GC, then read every byte. 30 runs each on x64 Server GC, .NET 4.7.2:

Scenario Buff relocated Lucky-correct reads Garbage reads AccessViolationException
Buggy (current master) 30/30 28 1 1
Fixed (this PR) 0/30 (pinned) 30 0 0

The buggy run produced the exact AccessViolationException at ReadByte shown in the production crash. The fixed reader couldn't be relocated at all (GCHandle.Pinned is what it says on the tin) and every byte read came back correct. Note the bug is observable in 2/30 runs as garbage or AVE; the other 28 are "lucky" — the freed memory at the stale address still happened to contain the original bytes. This explains why crashes feel rare in production despite the bug firing on every CompressedGump.

The fix

Replace the leaked fixed pointer with a GCHandle.Alloc(buff, GCHandleType.Pinned) taken in the ctor and freed in a finalizer. The byte* overload calls GC.SuppressFinalize(this) since the caller owns the pin in that path.

public PacketReader(byte[] buff, bool dyn)
{
    m_Handle = GCHandle.Alloc(buff, GCHandleType.Pinned);
    m_Data = (byte*)m_Handle.AddrOfPinnedObject();
    m_Length = buff.Length;
    m_Pos = 0;
    m_Dyn = dyn;
}

~PacketReader()
{
    if (m_Handle.IsAllocated)
        m_Handle.Free();
}

The pin lasts at most one GC cycle past the reader's last reachable use (until the finalizer runs), which is well under a millisecond in practice. No callers need to change.

The byte[] constructor pinned the array only for the duration of the
fixed block and stored the pointer past the unpin, leaving m_Data
referring to a managed array the GC was free to relocate. Reads after
a Gen0 compaction would dereference invalid memory and raise
AccessViolationException.

The most reliable trigger is GetCompressedReader -> CompressedGump,
which allocates a string[] between constructing the inner reader and
the first ReadInt16 -- opening a Gen0 collection window before any
read. Pin the array for the lifetime of the reader via GCHandle and
release it from the finalizer; suppress finalize on the byte* overload
since the caller owns the pin there.
@kamronbatman kamronbatman force-pushed the fix/packet-reader-dangling-pointer branch from f5cca2a to 75e62cf Compare April 28, 2026 00:53
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.

1 participant