Cysharp / Cysharp/ObservableCollections
Avoid assuming List<T>'s private field layout via Unsafe.As (ListView/LegacyListView)
@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
- No contract. The field layout of
List<T>is an implementation detail. Nothing prevents the BCL from reordering, renaming, or adding fields. - Field-count detection is not layout validation.
typeof(List<>).GetFields(...).Lengthdistinguishes "3 fields" from "4 fields (_syncRoot)", but it does not verify that_itemsis the first field, nor its offset. - Writing makes the failure mode worse. The
AddRangepath assigns_items,_sizeand_versiondirectly. 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. - It fails silently.
Unsafe.Asperforms no type check, so a layout mismatch produces heap corruption or a GC crash rather than a clean exception. - 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.SetCountandList<T>.AddRange(ReadOnlySpan<T>)already cover both cases, so this is only about thenetstandard2.0/netstandard2.1/net6.0builds. - For the polyfill builds, the
AddRangecase can be implemented without touching internals:list.EnsureCapacityis not available on netstandard, but appending element-by-element, orlist.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
_itemsviaGetField("_items", NonPublic | Instance), confirmFieldType == 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 mutatingAddRangepath deserves that guard even if the read-onlyAsSpanpath 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
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Assessment
This issue has not been assessed yet.