fix(network): don't leak duped-layer equipment via EquipUpdate/OPL (#2502)

## Problem

A creature equipped with two items that resolve to the **same layer** can crash the legacy EA 2D client (use-after-free). Equipment `Layer` comes from tiledata (`Layer = (Layer)ItemData.Quality`), so a **two-handed weapon and a shield both resolve to `Layer.TwoHanded`**. `Mobile.FindItemOnLayer` even documents the invariant: *"We only allow 1 item per layer. Its an implicit contract."*

## Root cause

- `SendMobileIncoming` (0x78) **dedupes by layer** (the `layers` span) and sends only the first item per slot — so a static creature is fine.
- But the per-item **`SendEquipUpdate` (0x2E)** and the item **OPL** sends do **not** dedupe. On any equip/property delta (`Item.ProcessDelta`) they fire per item and leak the second same-layer item on its own.
- The client then holds two items on one equipment slot; when that slot is torn down (e.g. a large group of such creatures and the player runs out of range → mass remove) the legacy 2D client frees one and dereferences it → UAF. ClassicUO bounds-checks and is unaffected, but the server is emitting an invalid, self-contradictory stream either way.

## Fix

Make per-item equip/OPL sends honor the same first-item-per-layer rule `SendMobileIncoming` already uses:

- `Item.IsDupedEquipLayer()` — `m_Parent is Mobile m && m.FindItemOnLayer(m_Layer) != this` (reuses the existing helper; true when an earlier item already holds this items layer).
- `Item.ProcessDelta`: early-return before the per-client loop for a duped equipped item (skips the EquipUpdate and OPL to everyone).
- `Mobile` lift-reject re-show: skip the EquipUpdate and OPL for the dupe.

No behavioral change for valid equipment (distinct layers → never duped). For the invalid duped-layer case the second item was already omitted by the 0x78 packet; this just stops it leaking back via the per-item paths.
This commit is contained in:
Kamron Batman 2026-06-22 23:20:57 -07:00 committed by GitHub
parent 70276dcf52
commit 73d1ee3874
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 22 additions and 2 deletions

View file

@ -536,6 +536,14 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
}
}
// SendMobileIncoming (0x78) sends only the first item per layer (it dedupes via the
// `layers` span). Equipment Layer comes from tiledata, so e.g. a two-handed weapon and a
// shield both resolve to Layer.TwoHanded; a later same-layer item sent on its own via
// EquipUpdate (0x2E) or its OPL would leave the client holding two items on one equipment
// slot (a use-after-free on the legacy 2D client when that slot is later torn down).
// Returns true if this equipped item is such a later duplicate and must not be sent alone.
public bool IsDupedEquipLayer() => m_Parent is Mobile m && m.FindItemOnLayer(m_Layer) != this;
public List<Item> Items => LookupItems() ?? EmptyItems;
public int LookupContainerVersion() => (this as Container)?._version ?? LookupCompactInfo()?.Version ?? 0;
@ -1387,6 +1395,14 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
return;
}
// A second item sharing an equipment layer is omitted by SendMobileIncoming (0x78);
// sending it on its own via EquipUpdate/OPL would leave the client with two items on
// one slot. Skip the per-client sends entirely for the dupe.
if (IsDupedEquipLayer())
{
return;
}
foreach (var state in map.GetClientsInRange(worldLoc, GetMaxUpdateRange()))
{
var m = state.Mobile;

View file

@ -5187,14 +5187,18 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
}
else if (item.Parent is Mobile)
{
state.SendEquipUpdate(item);
// Don't re-show a duped-layer item on its own (see Item.IsDupedEquipLayer).
if (!item.IsDupedEquipLayer())
{
state.SendEquipUpdate(item);
}
}
else
{
item.SendInfoTo(state);
}
if (item.Parent != null)
if (item.Parent != null && !item.IsDupedEquipLayer())
{
item.SendOPLPacketTo(state);
}