How to stop cross-server markets duplicating items

Reading a memory store listing and then removing it lets two servers sell it twice. How to claim entries safely with UpdateAsync.

criticalLuaudit check: memory-store-races

What it looks like

A cross-server market, auction, shared stock or party list lives in a MemoryStoreHashMap or MemoryStoreSortedMap. The server reads an entry with GetAsync, decides, then writes with SetAsync or removes with RemoveAsync. Another server can do the same thing between the read and the write.

Luau
local listing = market:GetAsync(id)
if listing then
	give(buyer, listing.item)
	market:RemoveAsync(id)
end

How to detect it

  • Find every MemoryStoreService:GetHashMap and GetSortedMap that more than one server writes to. Market listings, auctions, limited stock, shared inventories and global counters are the usual ones.
  • Look for GetAsync followed by SetAsync or RemoveAsync on the same key, with something given, charged or decided in between.
  • Roblox's memory store best practices say to prefer UpdateAsync over SetAsync. Its reference says UpdateAsync saves only if nobody changed the key since it was read, and calls the transform function again with the newer value otherwise. Returning nil cancels the update.
  • Check that the claim happens inside UpdateAsync, and that items or currency are only given after it succeeds and returns the claimed value.
  • The transform function can run more than once, so it must not give anything itself. See Work done inside an UpdateAsync callback.
  • RemoveAsync doesn't say whether the key existed, so removing an entry doesn't prove this server was the one that got it.

What an exploiter could do

Two accounts in two servers buy the same listing at the same moment. Both servers read it, both hand over the item, and the seller's item is duplicated. With an auction, two bids can both be accepted as the highest, or a bid can overwrite a higher one. Coordinated players can repeat it as often as they like.

How to fix it

  • Claim entries with UpdateAsync. Inside the transform function, return nil if the entry is gone or already claimed, otherwise return it marked as claimed by this server.
  • Give items only after UpdateAsync returns the claimed entry. Take the payment before claiming and refund it if the claim fails, so a player can't spend the same coins twice while the claim is in flight.
  • Keep a record of who bought what in a data store, since memory store entries expire.

Vulnerable example

Vulnerable
local MemoryStoreService = game:GetService("MemoryStoreService")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local PlayerData = require(script.Parent.PlayerData)

local market = MemoryStoreService:GetHashMap("Market")

ReplicatedStorage.Remotes.BuyListing.OnServerEvent:Connect(function(player: Player, id: unknown)
	local profile = PlayerData.get(player)
	if typeof(id) ~= "string" or not profile then
		return
	end
	local ok, listing = pcall(market.GetAsync, market, id)
	if not ok or typeof(listing) ~= "table" or profile.Coins < listing.price then
		return
	end
	profile.Coins -= listing.price
	table.insert(profile.Inventory, listing.item)
	pcall(market.RemoveAsync, market, id)
end)

Fixed example

Fixed
local MemoryStoreService = game:GetService("MemoryStoreService")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local PlayerData = require(script.Parent.PlayerData)

local market = MemoryStoreService:GetHashMap("Market")

ReplicatedStorage.Remotes.BuyListing.OnServerEvent:Connect(function(player: Player, id: unknown)
	local profile = PlayerData.get(player)
	if typeof(id) ~= "string" or not profile then
		return
	end
	local found, listing = pcall(market.GetAsync, market, id)
	if not found or typeof(listing) ~= "table" or profile.Coins < listing.price then
		return
	end
	local price = listing.price
	profile.Coins -= price
	local ok, claimed = pcall(market.UpdateAsync, market, id, function(current: any)
		if typeof(current) ~= "table" or current.buyer or current.price ~= price then
			return nil
		end
		current.buyer = player.UserId
		return current
	end, 300)
	if ok and typeof(claimed) == "table" and claimed.buyer == player.UserId then
		table.insert(profile.Inventory, claimed.item)
	else
		profile.Coins += price
	end
end)

Common false positives

  • Memory stores used only by one server, or only for display, such as a "players online" count, aren't worth reporting.
  • Queues are covered by Memory store queue items that can pay out twice.
  • Changes made with UpdateAsync that hand anything out only after it returns, like the fixed example, are the fix.