fix: Fixes exploits with bank box deposits. Makes stacking more efficient (#2337)

### Summary

- Fix AccountGold gold duplication exploit: When AccountGold.Enabled was true, double-clicking a BankCheck deposited
the full value to the account but then continued creating physical gold piles for the same amount, duplicating the
value
- Fix Deposit/DepositUpTo partial deposit exploit: Both methods created new max-size gold piles and checks without
first filling existing partial stacks, wasting container slots and causing premature "bank full" failures that could
be leveraged to manipulate gold distribution
- Fix BagOfSending bank stacking: Gold and BankCheck items sent via BagOfSending now use Banker.Deposit for efficient
stacking instead of naive TryDropItem, which could fail on a full bank even when existing piles had room
- Improve gold/check deposit efficiency: Banker.Deposit and Banker.DepositUpTo now top off existing gold piles (up to
60k) and bank checks (up to 1M) before creating new items, maximizing use of available container slots
This commit is contained in:
Kamron Batman 2026-02-13 20:34:35 -08:00 committed by GitHub
parent f7eab79adb
commit 4b392079e9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 232 additions and 102 deletions

View file

@ -137,68 +137,69 @@ public partial class BankCheck : Item
return;
}
var deposited = 0;
var toAdd = _worth;
if (AccountGold.Enabled && from.Account?.DepositGold(toAdd) == true)
if (AccountGold.Enabled)
{
deposited = toAdd;
}
while (toAdd > 0)
{
var amount = Math.Min(toAdd, 60000);
var gold = new Gold(amount);
if (box.TryDropItem(from, gold, false))
if (from.Account?.DepositGold(_worth) != true)
{
toAdd -= amount;
deposited += amount;
return;
}
else
{
gold.Delete();
break;
}
}
if (deposited >= _worth)
{
Delete();
// Gold was deposited in your account:
from.SendLocalizedMessage(1042672, true, $"{_worth:N0}");
}
else
{
Worth -= deposited;
}
// Internalize the check to free its slot for deposit.
// reserveSlots: 1 conditionally keeps a slot free for the check to bounce back
// only if the full amount won't fit — if it fits, no reservation is needed.
RecordBounce();
Internalize();
var deposited = Banker.DepositUpTo(from, _worth, false, 1);
if (deposited >= _worth)
{
Delete();
}
else if (deposited > 0)
{
Worth -= deposited;
Bounce(from);
}
else
{
Bounce(from);
from.SendLocalizedMessage(500390); // Your bank box is full.
return;
}
if (deposited > 0)
{
// Gold was deposited in your account:
from.SendLocalizedMessage(1042672, true, $"{deposited:N0}");
}
if (from is PlayerMobile pm)
if (from is PlayerMobile pm)
{
var qs = pm.Quest;
if (qs is DarkTidesQuest)
{
var qs = pm.Quest;
var obj = qs.FindObjective<CashBankCheckObjective>();
if (qs is DarkTidesQuest)
if (obj?.Completed == false)
{
var obj = qs.FindObjective<CashBankCheckObjective>();
if (obj?.Completed == false)
{
obj.Complete();
}
obj.Complete();
}
}
if (qs is UzeraanTurmoilQuest)
if (qs is UzeraanTurmoilQuest)
{
var obj = qs.FindObjective(typeof(Engines.Quests.Haven.CashBankCheckObjective));
if (obj?.Completed == false)
{
var obj = qs.FindObjective(typeof(Engines.Quests.Haven.CashBankCheckObjective));
if (obj?.Completed == false)
{
obj.Complete();
}
obj.Complete();
}
}
}

View file

@ -5,6 +5,7 @@ using Server.ContextMenus;
using Server.Engines.Quests;
using Server.Regions;
using Server.Spells;
using Server.Mobiles;
using Server.Targeting;
namespace Server.Items;
@ -233,16 +234,35 @@ public partial class BagOfSending : Item, TranslocationItem
{
from.SendLocalizedMessage(1079932); // You don't have enough charges to send that much weight
}
else if (!from.BankBox.TryDropItem(from, item, false))
{
_bag.SendLocalizedMessageTo(from, 1054110, 0x59); // Your bank box is full.
}
else
{
_bag.Charges -= Core.ML ? reqCharges : 1;
bool sent;
// The item was placed in your bank box.
_bag.SendLocalizedMessageTo(from, 1054150, 0x59);
if (item is Gold or BankCheck)
{
var amount = (item as Gold)?.Amount ?? ((BankCheck)item).Worth;
sent = Banker.Deposit(from, amount);
if (sent)
{
item.Delete();
}
}
else
{
sent = from.BankBox.TryDropItem(from, item, false);
}
if (sent)
{
_bag.Charges -= Core.ML ? reqCharges : 1;
// The item was placed in your bank box.
_bag.SendLocalizedMessageTo(from, 1054150, 0x59);
}
else
{
_bag.SendLocalizedMessageTo(from, 1054110, 0x59); // Your bank box is full.
}
}
}
}

View file

@ -206,7 +206,7 @@ public partial class Banker : BaseVendor
return true;
}
public static bool Deposit(Mobile from, int amount)
public static bool Deposit(Mobile from, int amount, bool useChecks = true)
{
// If for whatever reason the TOL checks fail, we should still try old methods for depositing currency.
if (amount <= 0 || AccountGold.Enabled && from.Account?.DepositGold(amount) == true)
@ -221,47 +221,99 @@ public partial class Banker : BaseVendor
return false;
}
using var items = PooledRefQueue<Item>.Create();
using var items = box.ListItemsByType([typeof(Gold), typeof(BankCheck)], false);
while (amount > 0)
// Pre-calculate available capacity without modifying anything
var remaining = amount;
for (var i = 0; i < items.Count && remaining > 0; i++)
{
Item item;
if (amount < 5000)
var item = items[i];
if (item is Gold g)
{
item = new Gold(amount);
amount = 0;
remaining -= Math.Min(60000 - g.Amount, remaining);
}
else if (amount <= 1000000)
else if (useChecks && item is BankCheck c)
{
item = new BankCheck(amount);
amount = 0;
}
else
{
item = new BankCheck(1000000);
amount -= 1000000;
remaining -= Math.Min(1000000 - c.Worth, remaining);
}
}
if (box.TryDropItem(from, item, false))
// Calculate new item slots needed for the remainder
if (remaining > 0)
{
var slotsNeeded = 0;
var r = remaining;
while (r > 0)
{
items.Enqueue(item);
}
else
{
item.Delete();
while (items.Count > 0)
if (!useChecks || r < 5000)
{
items.Dequeue().Delete();
r -= Math.Min(r, 60000);
}
else if (r <= 1000000)
{
r = 0;
}
else
{
r -= 1000000;
}
slotsNeeded++;
}
var maxItems = box.MaxItems;
if (maxItems != 0 && slotsNeeded > maxItems - box.TotalItems)
{
return false;
}
}
// Capacity verified — execute deposit
remaining = amount;
for (var i = 0; i < items.Count && remaining > 0; i++)
{
var item = items[i];
if (item is Gold g && g.Amount < 60000)
{
var add = Math.Min(60000 - g.Amount, remaining);
g.Amount += add;
remaining -= add;
}
else if (useChecks && item is BankCheck c && c.Worth < 1000000)
{
var add = Math.Min(1000000 - c.Worth, remaining);
c.Worth += add;
remaining -= add;
}
}
// Create new items for the remainder
while (remaining > 0)
{
if (!useChecks || remaining < 5000)
{
var pile = Math.Min(remaining, 60000);
box.DropItem(new Gold(pile));
remaining -= pile;
}
else if (remaining <= 1000000)
{
box.DropItem(new BankCheck(remaining));
remaining = 0;
}
else
{
box.DropItem(new BankCheck(1000000));
remaining -= 1000000;
}
}
return true;
}
public static int DepositUpTo(Mobile from, int amount)
public static int DepositUpTo(Mobile from, int amount, bool useChecks = true, int reserveSlots = 0)
{
// If for whatever reason the TOL checks fail, we should still try old methods for depositing currency.
if (AccountGold.Enabled && from.Account?.DepositGold(amount) == true)
@ -276,40 +328,97 @@ public partial class Banker : BaseVendor
return 0;
}
var amountLeft = amount;
while (amountLeft > 0)
var remaining = amount;
// Top off existing gold piles and checks
using var items = box.ListItemsByType([typeof(Gold), typeof(BankCheck)], false);
for (var i = 0; i < items.Count && remaining > 0; i++)
{
Item item;
int amountGiven;
if (amountLeft < 5000)
var item = items[i];
if (item is Gold g && g.Amount < 60000)
{
item = new Gold(amountLeft);
amountGiven = amountLeft;
var add = Math.Min(60000 - g.Amount, remaining);
g.Amount += add;
remaining -= add;
}
else if (amountLeft <= 1000000)
else if (useChecks && item is BankCheck c && c.Worth < 1000000)
{
item = new BankCheck(amountLeft);
amountGiven = amountLeft;
}
else
{
item = new BankCheck(1000000);
amountGiven = 1000000;
}
if (box.TryDropItem(from, item, false))
{
amountLeft -= amountGiven;
}
else
{
item.Delete();
break;
var add = Math.Min(1000000 - c.Worth, remaining);
c.Worth += add;
remaining -= add;
}
}
return amount - amountLeft;
if (remaining == 0)
{
return amount;
}
// Calculate how many new item slots can be filled
int slotsToFill;
var maxItems = box.MaxItems;
if (maxItems == 0 || from.AccessLevel >= AccessLevel.GameMaster)
{
slotsToFill = int.MaxValue;
}
else
{
slotsToFill = maxItems - box.TotalItems;
if (reserveSlots > 0)
{
// Count slots needed to determine if the full amount fits.
// Only reserve if it doesn't — the caller will delete the reserved item on full deposit.
var slots = slotsToFill;
var r = remaining;
while (r > 0)
{
if (!useChecks || r < 5000)
{
r -= Math.Min(r, 60000);
}
else if (r <= 1000000)
{
r = 0;
}
else
{
r -= 1000000;
}
if (--slots < 0)
{
slotsToFill -= reserveSlots;
break;
}
}
}
}
// Create new items for the remainder
while (remaining > 0 && slotsToFill > 0)
{
if (!useChecks || remaining < 5000)
{
var pile = Math.Min(remaining, 60000);
box.DropItem(new Gold(pile));
remaining -= pile;
}
else if (remaining <= 1000000)
{
box.DropItem(new BankCheck(remaining));
remaining = 0;
}
else
{
box.DropItem(new BankCheck(1000000));
remaining -= 1000000;
}
slotsToFill--;
}
return amount - remaining;
}
public static void Deposit(Container cont, int amount)