Cysharp / Cysharp/ObservableCollections

Avoid assuming List<T>'s private field layout via Unsafe.As (ListView/LegacyListView)

Open
#136 0 comments 0 reactions 1 assignee View on GitHub

@aetos382 is already working on this.

Since Sep 17, 2026.

Dominant language
C#
Stars
1k
Forks
73
Avg merge
5d 4h
Merged PRs (30d)
2

Description

Summary

src/ObservableCollections/Shims/ reads and writes private fields of List<T> by declaring view classes with an assumed field layout and reinterpreting the instance with Unsafe.As. I would like to propose moving away from this technique.

Affected code

1. Shims/CollectionsMarshalEx.cs (#if !NET7_0_OR_GREATER) — https://github.com/Cysharp/ObservableCollections/blob/master/src/ObservableCollections/Shims/CollectionsMarshalEx.cs

if (IsLegacyList)
{
    ref var view = ref Unsafe.As<List<T>, LegacyListView<T>>(ref list!);
    return view._items.AsSpan(0, list.Count);
}
else
{
    ref var view = ref Unsafe.As<List<T>, ListView<T>>(ref list!);
    return view._items.AsSpan(0, list.Count);
}

2. Shims/Collections.cs (#if !NET8_0_OR_GREATER) — the AddRange polyfill, which mutates the private state:

ref var view = ref Unsafe.As<List<T>, CollectionsMarshal.ListView<T>>(ref list!);

if (view._items.Length - view._size < source.Length)
{
    Grow(ref view._items, view._size, checked(view._size + source.Length));
}

source.CopyTo(view._items.AsSpan(view._size));
view._size += source.Length;
view._version++;

To be clear, this code already goes further than most: it detects the .NET Framework 4-field layout at runtime (IsLegacyList). That mitigation is what makes the underlying fragility visible.

Why this is risky

  1. No contract. The field layout of List<T> is an implementation detail. Nothing prevents the BCL from reordering, renaming, or adding fields.
  2. Field-count detection is not layout validation. typeof(List<>).GetFields(...).Length distinguishes "3 fields" from "4 fields (_syncRoot)", but it does not verify that _items is the first field, nor its offset.
  3. Writing makes the failure mode worse. The AddRange path assigns _items, _size and _version directly. If the offsets are wrong, this does not read garbage — it writes garbage into a live BCL object, and the corruption surfaces somewhere else entirely.
  4. It fails silently. Unsafe.As performs no type check, so a layout mismatch produces heap corruption or a GC crash rather than a clean exception.
  5. IL2CPP is not bound by CoreCLR's layout rules. ZLinq's equivalent polyfill carries the comment // Unsafe.As<>._size is failed in Unity so don't use it., which suggests this technique has already broken in practice on Unity.

Suggested direction

  • On .NET 8+ the real CollectionsMarshal.AsSpan / CollectionsMarshal.SetCount and List<T>.AddRange(ReadOnlySpan<T>) already cover both cases, so this is only about the netstandard2.0 / netstandard2.1 / net6.0 builds.
  • For the polyfill builds, the AddRange case can be implemented without touching internals: list.EnsureCapacity is not available on netstandard, but appending element-by-element, or list.AddRange(someEnumerable), is correct if slower. If the throughput matters, owning the storage (an internal list type that exposes its backing array) removes the assumption entirely and is faster than either.
  • If keeping the current approach is preferred for now, a smaller step would be to turn the count probe into a real validation — resolve _items via GetField("_items", NonPublic | Instance), confirm FieldType == typeof(T[]), and fall back to a safe path when it does not match — so a layout change degrades into a slow path rather than memory corruption. At minimum, the mutating AddRange path deserves that guard even if the read-only AsSpan path stays as-is.

Note

I found this while auditing R3, and the same or similar pattern exists in several other Cysharp repositories (R3, ZLogger, ZLinq, MemoryPack, ZString). I am filing one issue per repository rather than a single cross-repo issue, since the right fix differs per project.

Contributor guide

No contributing guide indexed for this repository

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.