问题现象
玩家 A 把物品 A 放入交易盒后,如果客户端继续重放背包拖拽交换封包,仍然可以把背包里的 A 和 B 强行换位,或者把 A 拖到空格。
这样会出现一个很危险的结果:
- 目标玩家交易界面里看到的还是 A。
- 但服务端最终结算时,真实背包里的位置已经被改成了 B。
- 最后就变成“界面展示 A,实际收到 B”。
这本质上是:交易展示使用的是“放入交易盒那一刻的对象引用”,而背包真实状态又被后续重放包偷偷改掉了。
最终修复思路
这次修复的正确做法不是“拦截所有锁定物品拖拽”,而是:
- 只拦截 正在交易中的物品。
- 判断依据使用
item:is_in_exchange()。
- 不使用
item:is_lock() 作为这条漏洞的最终拦截条件。
原因很重要:
is_lock() 在这个项目里既可能表示玩家手动锁,也可能表示交易临时锁。
- 如果直接用
is_lock() 拦截,会把普通锁定物品和交易中的物品混为一谈。
- 这会导致交易结束后,客户端还可能认为物品不能拖动,或者误伤正常逻辑。
所以最终版修复的核心是:
- 用
is_in_exchange() 专门识别“交易态”。
- 只封交易态物品的拖拽交换。
- 交易结束后,再主动同步背包格子给客户端,确保客户端立刻看到“已解锁”的状态。
涉及文件
本次最终修复涉及 3 个位置:
lualib/item.lua
services/scene/scenecore.lua
lualib/exchange_box.lua
其中:
item.lua 负责保存物品是否正在交易。
scenecore.lua 负责拦截背包拖拽交换请求。
exchange_box.lua 负责交易结束时把客户端背包状态刷新回来。
第一步:给物品增加真正的交易状态
文件:lualib/item.lua
之前交易流程里虽然已经在调用:
item:set_in_exchange(true)
item:set_in_exchange(false)
但最早这个函数本身是空实现,等于没有真正保存状态。
后来补成了真正的字段:
function item:ctor()
self.guid = guid_cls.new()
self.item_index = -1
self.rule = 0
self.status = 0
self.in_exchange = false
...
end
function item:is_in_exchange()
return self.in_exchange == true
end
function item:set_in_exchange(in_exchange)
self.in_exchange = in_exchange and true or false
end
这一步是整个修复能成立的基础。
因为只有这样,服务端才能分清楚:
- 这件物品只是锁了。
- 还是这件物品正处于交易流程中。
第二步:定位背包拖拽交换入口
客户端消息入口在:
services/msgagent.lua
function request:CGPackage_SwapItem()
skynet.call(my_scene, "lua", "package_swap_item", my_obj_id, self)
end
真正处理逻辑在:
services/scene/scenecore.lua
function scenecore:package_swap_item(whos, swap)
local obj = self.objs[whos]
...
end
也就是说,所有背包拖拽换位、拖到空格、同类叠加,都会经过这个函数。
所以这里是最佳拦截点。
第三步:原始漏洞为什么会出现
原始代码里有这样一段:
if item_to then
if not item_from:can_lay()
or item_from:is_lock()
or item_to:is_lock() then
is_change = true
else
-- 尝试叠加
end
else
is_change = true
end
后面只要 is_change == true,就直接交换:
bag:set_item(from,item_to)
bag:set_item(to,item_from)
问题就在这里:
- 交易中的物品放入交易盒后,会被
lock_item()。
- 但
package_swap_item() 并没有把“锁定”当成非法操作直接拒绝。
- 它反而把“锁定”当成“不能叠加,那就改成交换”。
于是就变成了:
- 交易物品因为锁定,反而更容易落入交换分支。
- 重放拖拽包后,交易中的 A 还能被换成 B。
第四步:为什么不能直接用 is_lock() 修
这个问题是本次修复里最容易走偏的地方。
一开始很容易想到:
if item_from:is_lock() then
reject_swap(...)
return
end
但这样会出问题,因为这个项目里的 is_lock() 语义不纯。
4.1 is_lock() 实际承载了两种状态
它既可能表示:
- 玩家手动给物品加的普通锁。
也可能表示:
- 交易、摆摊这类流程中的临时锁。
所以直接用 is_lock() 拦截,就把两类完全不同的场景混到一起了。
4.2 混用 is_lock() 的副作用
如果这条漏洞修复直接按 is_lock() 拦截,会有这些风险:
- 普通锁定物品也被当成交易物品处理。
- 交易结束后,客户端仍可能残留“不可拖动”的显示。
- 以后如果别的系统也复用这个锁位,也会一起被误伤。
所以最终版修复做了语义拆分:
is_lock() 继续保留给系统原有用途。
- 交易漏洞拦截只看
is_in_exchange()。
这才是正确的收口方式。
第五步:最终版 package_swap_item() 修法
文件:services/scene/scenecore.lua
最终版关键逻辑如下:
***付费内容*** ">[/kkpay]
第六步:关键修改点解释
6.1 先刷新锁状态
obj:refresh_locked_item_unlock_state(false)
虽然最终不是用 is_lock() 做拦截,但这一步仍然保留。
作用是:
- 让背包里的锁信息先更新到服务端最新状态。
- 避免客户端残留旧锁定显示时,服务端判断还在旧状态。
这一步是状态整洁,不是交易拦截核心。
6.2 只提取交易态
local item_from_in_exchange = item_from.is_in_exchange and item_from:is_in_exchange()
local item_to_in_exchange = item_to and item_to.is_in_exchange and item_to:is_in_exchange()
这一步就是把“交易中的锁”和“普通锁”从语义上拆开。
最终判断只看:
- 源格物品是不是交易物品。
- 目标格物品是不是交易物品。
6.3 交易态直接拒绝
if item_from_in_exchange then
reject_swap("交易中的道具无法移动")
return
end
if item_to_in_exchange then
reject_swap("交易中的道具无法交换")
return
end
这样就能保证:
- 已放入交易盒的 A 无法再被拖走。
- 也不能把别的普通物品拖到 A 所在格形成换位。
6.4 为什么失败时要同步格子
sync_swap_slots()
因为客户端重放拖拽包后,可能本地先把画面改了。
如果服务端只是静默 return:
- 服务端状态没变。
- 客户端画面却可能临时显示成已经换位。
所以必须重新回发两个背包格子的 GCItemInfo,把客户端状态强制拉回正确值。
第七步:为什么交易结束后还要刷新背包格子
这部分对应的是:
文件:lualib/exchange_box.lua
***付费内容***
这行:
self.my_self:send_prop_bag_item_info(bag_index)
是有必要保留的。
7.1 它不是为了服务端正确性
前两步已经完成了服务端状态修改:
item_operator:unlock_item(prop_bag, bag_index)
item:set_in_exchange(false)
从服务端角度看,到这里逻辑已经正确了。
7.2 它是为了客户端立即同步
问题在于客户端不知道你已经解锁了。
如果 reset() 之后不主动发这个格子的 GCItemInfo:
- 服务端认为物品已不在交易中。
- 客户端却可能还显示成交易锁状态。
- 于是玩家就会看到“交易结束后还是拖不动”。
所以这里的发包是必须的客户端同步动作。
7.3 为什么只在 reset() 里发还不够
因为交易成功时,物品可能已经被搬到别的格子,甚至给了对方。
所以我在:
文件:services/scene/scenecore.lua
的 char_exchange_ok_III() 里又补了一层刷新:
- 记录交易成功后双方受影响的背包格子。
reset() 完成后,再对这些格子主动调用 send_prop_bag_item_info()。
这样就覆盖了两类场景:
- 交易取消 / 失败 / 回滚:靠
exchange_box:reset() 内部刷新。
- 交易成功:靠
char_exchange_ok_III() 末尾补发受影响格子。
第八步:交易成功后的补充刷新
文件:services/scene/scenecore.lua
在 char_exchange_ok_III() 里增加了两个刷新表:
local my_refresh_prop_indices = {}
local tar_refresh_prop_indices = {}
在实际搬移物品时记录受影响格子:
local function mark_refresh(refresh_map, bag_index)
if bag_index ~= nil and bag_index ~= define.INVAILD_ID then
refresh_map[bag_index] = true
end
end
例如 A 给了对方时:
mark_refresh(my_refresh_prop_indices, bag_index)
mark_refresh(tar_refresh_prop_indices, empty_index)
最后在交易成功包发完、双方 reset() 之后,再主动刷新:
for bag_index in pairs(my_refresh_prop_indices) do
obj_me:send_prop_bag_item_info(bag_index)
end
for bag_index in pairs(tar_refresh_prop_indices) do
obj_tar:send_prop_bag_item_info(bag_index)
end
这样客户端不会残留旧的锁显示。
第九步:这次修复和“普通锁定物品”是什么关系
这次修复 不是 为了统一处理普通锁定物品。
它解决的是:
- 交易中的物品被重放拖拽交换。
它不试图解决:
- 普通玩家手动锁物品的所有拖拽行为。
- 其他系统里复用
is_lock() 的场景。
所以文档里必须明确:
- 本次漏洞的拦截条件是
is_in_exchange()。
- 不是
is_lock()。
- 否则就会再次把两种语义混掉。
第十步:修复后的行为
修复前
- A 放入交易盒。
- A 被打上锁,但背包里还在。
- 客户端重放拖拽交换包。
- 服务端把锁定物品仍然当作可交换对象处理。
- 背包中 A/B 实际换位。
- 交易界面仍显示 A。
- 最终收到的可能是 B。
修复后
- A 放入交易盒时,
item:set_in_exchange(true)。
- 客户端重放拖拽交换包。
package_swap_item() 先判断 item_from:is_in_exchange()。
- 发现源格或目标格在交易中,立即拒绝。
- 回发失败包和两个背包格子的真实状态。
- 客户端显示被服务端强制纠正。
- 交易界面里的 A 和最终收到的物品保持一致。
第十一步:建议测试项
11.1 交易中 A/B 强制换位
步骤:
- 把 A 放入交易盒。
- 重放背包拖拽封包,把 A 和 B 强制换位。
预期:
- 服务端拒绝本次拖拽。
- 客户端背包格子被重新同步。
- 最终交易仍然只会给出 A。
11.2 交易中 A 拖到空格
步骤:
- 把 A 放入交易盒。
- 重放拖拽包,把 A 拖到空格。
预期:
- 服务端拒绝。
- A 原位置不变。
- 客户端显示被纠正。
11.3 交易取消后立即拖动物品
步骤:
- 把 A 放入交易盒。
- 取消交易。
- 立刻尝试拖动 A。
预期:
- 客户端能立即拖动。
- 不会残留“看起来还锁着”的状态。
11.4 交易成功后检查双方背包
步骤:
- 正常完成一笔有物品交换的交易。
- 交易结束后检查双方受影响的背包格子。
预期:
- 客户端显示与服务端一致。
- 不残留交易锁定状态。
11.5 普通未交易物品拖拽
步骤:
- 使用普通未处于交易中的物品做交换、叠加、拖到空格。
预期:
- 原有功能保持不变。
第十二步:总结
这次修复最关键的结论是:
不要用 is_lock() 去直接定义“交易中的物品”。
正确做法是:
is_in_exchange() 只负责表达“当前是否处于交易流程中”。
package_swap_item() 只拦截交易态。
exchange_box:reset() 和 char_exchange_ok_III() 负责在交易结束后把客户端背包格子状态刷新回来。
这样既能修住“交易中背包拖拽换物”的漏洞,又不会再把普通锁定和交易锁状态混淆。
剩余 16% 内容需要支付 66.00
金币 后可完整阅读
支持付费阅读,激励作者创作更好的作品。