From 73d1ee3874099ca3f032eacdd5853ba9f8057488 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Mon, 22 Jun 2026 23:20:57 -0700 Subject: [PATCH] fix(network): don't leak duped-layer equipment via EquipUpdate/OPL (#2502) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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. --- Projects/Server/Items/Item.cs | 16 ++++++++++++++++ Projects/Server/Mobiles/Mobile.cs | 8 ++++++-- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/Projects/Server/Items/Item.cs b/Projects/Server/Items/Item.cs index bbf718c65..3a8adafe1 100644 --- a/Projects/Server/Items/Item.cs +++ b/Projects/Server/Items/Item.cs @@ -536,6 +536,14 @@ public partial class Item : IHued, IComparable, 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 Items => LookupItems() ?? EmptyItems; public int LookupContainerVersion() => (this as Container)?._version ?? LookupCompactInfo()?.Version ?? 0; @@ -1387,6 +1395,14 @@ public partial class Item : IHued, IComparable, 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; diff --git a/Projects/Server/Mobiles/Mobile.cs b/Projects/Server/Mobiles/Mobile.cs index f20d972b6..778d5b5a7 100644 --- a/Projects/Server/Mobiles/Mobile.cs +++ b/Projects/Server/Mobiles/Mobile.cs @@ -5187,14 +5187,18 @@ public partial class Mobile : IHued, IComparable, 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); }