玩家数据保存在服务器的数据库里,这使得我们没办法直接修改它。那间接的呢?
在以前,iOS上有一些内购插件,据说可以破解App内购。其实现的原理,在于iOS内购的不完善,或者说bug:用户在手机上点击购买后,会向AppStore发送购买消息;AppStore收到消息,执行扣费,然后返回给手机;手机收到AppStore返回的消息,再向游戏服务器发送购买消息。这里面就有一个问题,我购买1元钱的A物品,但通过破解,向服务发送购买10元钱的B物品。服务器无从辨别我到底买的是A还是B,所以只能当真了。然后,我还能再进一步,直接向服务器发送购买消息,而不再向AppStroe真实地购买物品。这就是内购破解。现在,据说苹果做了修正,已经可以直接通知游戏服务器。如此,内购相关的用户数据,我们已经无法破解。
修改网络游戏玩家数据的最大障碍,在于数据是在服务器端计算的。比如钻石+10,它并不是在客户端(也就是手机上)计算出结果,再将计算结果发给服务器,服务器收到结果后进行持久化。比如玩家当前有100个钻石,打怪掉落10个钻石,客户端就计算出结果110个钻石,然后将110个钻石发给服务器,服务器收到,保存110个钻石。完全不是这样,而是相反的:打怪掉落10个钻石,杂玩家捡取时,向服务器发送捡取10个钻石的消息,服务器计算并保存结果,然后返回结果给手机(有些游戏确实是这样做的,可以确保数据同步。但大多数游戏在保存结果后,返回的不是计算结果,而仅仅是一个是否捡取成功的标志。客户端根据这个标志做出相应的处理。如果收到某些因素的影响,导致标志被更改,这只会让客户端显示出错误的数据。在玩家下次登录游戏时,会接收服务器端的正确数据)。如果向服务器发送的是“捡取10个钻石” 的消息,那我们就又可以动点脑筋了,比如,改成“捡取100000个钻石”怎么样?好吧,这招也行不通,因为真正发送给服务器的是“玩家xx捡取了掉落yy”。服务器收到消息,会检测“掉落yy”所对应的怪是不是“玩家xx”打死的。如果是,再从服务器的数据库中读取“掉落yy”的内容,将之奖励给“玩家xx”,再反馈给客户端。
现在要弄明白的问题是,《不思议迷宫》中有没有漏洞:在客户端进行的计算、类似“捡取10个钻石”的消息。就是这个思路,但我个人精力有限,不可能一一验证。
在我将主线玩到第二关(或是第三关?人老了记忆都不好了,忘记了……)的时候,地牢中出现“奇怪的地板”,用铁锹挖开后会奖励一些东西。关键是这些东西是随机的,可能是金币,也可能是钻石,还可能是物品。我就在想,能改成每次都随机到钻石吗?
计算奖励的代码文件为src/game/formula/CALC_ODD_FLOOR_BONUS.luac,内容如下:
returnfunction(user)
local dungeonId = DungeonM.getDungeonId();
local layer = DungeonM.currentLayer();
local bonus = {};
if DungeonAreaM.isEndlessArea(dungeonId)then
local rand = DungeonM.getRandSeed("ODD_FLOOR");
local rand1 =DungeonM.getRandSeed("ODD_FLOOR");
local classId = FormulaM.invoke("FETCH_BY_RAND",{ 1102, 1103 }, rand1);
local arr;
local arr1;
ifBuildingBonusM.getFieldStat("odd_floor_gem") < 10 then
arr = { {["bonus"] = { 2, "money", 300, }, ["ratio"] = 40,},
{["bonus"] = { 1, classId, 3, }, ["ratio"] = 40, },
{["bonus"] = { 2, "gem", 1, }, ["ratio"] = 20,},};
else
arr = { {["bonus"] = { 2, "money", 300, }, ["ratio"] = 40,},
{["bonus"] = { 1, classId, 3, }, ["ratio"] = 40, },};
end
arr1 = fetchElemBySeed(arr, rand);
arr = arr1["bonus"];
-- 当前累计的地板奖励钻石
-- 添加统计
if arr[2] == "gem" then
BuildingBonusM.addFieldStat("odd_floor_gem", arr[3]);
end
bonus = { arr, };
else
-- 小关卡
if layer == 3 or layer == 12 then
bonus = { 2, "money", 500};
else
-- 第18F层奖励:5钻石(再次获得,则为【金币】×500)
ifNewbieDungeonM.isFirstNewbieBonus("odd_floor") then
bonus = { 2, "gem", 5};
NewbieDungeonM.statNewbieFirstBonus("odd_floor");
else
bonus = { 2, "money",500 };
end
end
end
return { ["bonus"] = bonus, };
end
从上面的代码中完全看不出是如何与服务器通信的。这个暂时先放放,我们先把奖励的数值改了,看看效果。因为我已进入无尽模式,因此就改了无尽模式中的相应奖励,即上面中的红字部分。嗯,就在奖励数值后头加两个0吧:
ifBuildingBonusM.getFieldStat("odd_floor_gem") < 10 then
arr = { { ["bonus"] = {2, "money", 30000, }, ["ratio"] = 40, },
{ ["bonus"] = {1, classId, 300, }, ["ratio"] = 40, },
{["bonus"] = { 2, "gem", 100, }, ["ratio"] = 20, },};
else
arr = { { ["bonus"] = {2, "money", 30000, }, ["ratio"] = 40, },
{ ["bonus"] = { 1,classId, 300, }, ["ratio"] = 40, },};
end
保存后安装到手机,进入游戏后,发现客户端所用数值正是刚刚修改的:挖一次奇怪的地板,如果奖励是金币,就+3万;如果是物品,就+300;如果是钻石,就+100。这让我很高兴,因为它证明了这个文件确实被客户端执行了,而它里面包含了好几个分支逻辑。这些分支逻辑是如何和服务器同步的呢?难道每一个有关玩家信息的函数调用都是同步调用?如果不是,那就应当是使用了某种缓存,我们是不是有什么可以下手的地方?
先不管那么多,改成每次都随机到钻石,这只要在“arr1 = fetchElemBySeed(arr, rand);”这条语句之前添加一个循环即可:
while true do
if fetchElemBySeed(arr,rand)["bonus"][2] == "gem" then
break
end
rand =DungeonM.getRandSeed("ODD_FLOOR");
end
进游戏,开挖,然后,游戏失去响应玩完了。回头再仔细看看代码,发现原来是:
if BuildingBonusM.getFieldStat("odd_floor_gem") < 10then
这个判断的问题。奇怪的地板只能挖取10个钻石,再多就没有了。所以while进入了死循环。既然如此,那我们就改掉吧,来个最简单的:
returnfunction(user)
return { ["bonus"] = { 2,"gem", 100, }, ["ratio"] = 20, } };
end
这个有啥效果我没有进行验证。——由于手机未越狱,而《不思议迷宫》全是零碎的小文件,安装一次太耗时了;我又是个正处失业状态的穷吊死,舍不得手机被翻来覆去的折腾;最关键的是,我不认为它有啥用。
没验证就没有发言权,所以这个问题暂时保留。——正常情况下应当是在进行同步时报“数据异常”的错误。
如果没有生效,那我就会想,是不是改得该简单了,也许原函数中的某个函数调用中包含了至关重要的和服务器端的“同步调用”。当然,具体的同步机制我们在下面会进行研究。但是在现在,我只想做点小更改看看效果——万一行了呢,不就省了一大堆的代码阅读理解了吗?
这次我们作最小更改:
returnfunction(user)
……
if DungeonAreaM.isEndlessArea(dungeonId)then
……
arr1 = fetchElemBySeed(arr, rand);
arr1 =arr[1];
arr = arr1["bonus"];
……
else
……
end
只是添加了一条语句,将奖励改为金币而不会出现物品。至于效果嘛,大家自己验证吧,反正我估计是不会成功的。
有人可能就说了,这个你不验证,那个你也不验证,那还写啥啊,洗洗睡吧。我呢,当然有需要验证的东西。个人觉得成功率不大或者没啥帮助的东西,就会忽略过去。我之所以觉得上面的最小修改会没有效果,是因为我跟踪过DungeonM.getRandSeed函数。
-- 获取随机种子,循环使用。这个接口不能随便乱用,一定要确保和服务器严格同步
function getRandSeed(desc)
if not descthen
error("desc不能为空")
end
if notdungeonContainer then
return0;
end
-- 根据游标
local index= RandomFactoryM.fetchRandCursor(desc);
local seeds= dungeonContainer.rand_seed;
local num =#seeds;
local seed =seeds[index % num + 1];
-- 游标往下移动
RandomFactoryM.useRandCursor(desc);
localRandomType = RandomFactoryM.getRandomType(desc);
local msg =string.format("[%s]获取随机数: %d, cursor: %d",RandomType, seed, index);
DungeonLogM.addLog(msg);
DungeonDebugM.addCursor(desc, RandomType, index);
return seed;
end
先不用看代码,就看注释吧,“这个接口不能随便乱用,一定要确保和服务器严格同步”。如果注释没有欺骗我们的话,那么上面的最小修改就是和服务器不同步的。当然,不同步的不是DungeonM.getRandSeed及其相关调用,而是arr及最终的奖励。服务器的奖励是按照
arr1 = fetchElemBySeed(arr, rand);
arr = arr1["bonus"];
进行计算的。而本地却横插一杠,得出了和服务端不同的结果:
arr1 = fetchElemBySeed(arr, rand);
arr1 =arr[1];
arr = arr1["bonus"];
这就“数据异常”了。
我们要找出一个和服务器端同步的解决办法。回到开头的哪个while:
while true do
if fetchElemBySeed(arr,rand)["bonus"][2] == "gem" then
break
end
rand =DungeonM.getRandSeed("ODD_FLOOR");
end
这个就很好。如果DungeonM.getRandSeed中包含了和服务器的同步调用,那我们的最终计算也会是和服务器的同步的。当然了,这次我们要修改的不是钻石,不是金币:
while true do
if fetchElemBySeed(arr,rand)["bonus"][2] == "money" then
break
end
rand =DungeonM.getRandSeed("ODD_FLOOR");
end
那么,如何验证我们的修改到底生效与否?首先,我们要排除干扰的地方,比如炼金工坊中的冈布奥,不要让它采集金币;先前已验证过不成功的+30000金币的修改,恢复为正常的+300。然后进主线第二关“英雄之村”,玩吧。在碰到“奇怪的地板”,在开挖之前,先暂离,记录一下当前的金币数量。然后再回到副本,开挖。挖取后再暂离,看看金币是否加了300。但这还不够准确,最好的办法是挖取后暂离,然后重新登录,再看看金币是否增加。如果确实增加了,也不要高兴的太早,因为这很可能是巧合。我们需要多试几次。
我在试到第二次的时候失败了。我一时找不到其他办法,只能老老实实坐下来研究下代码,就从DungeonM.getRandSeed开始。
函数不大,瞄几眼就能找到重点:
-- 根据游标
local index= RandomFactoryM.fetchRandCursor(desc);
local seeds= dungeonContainer.rand_seed;
local num =#seeds;
local seed = seeds[index % num + 1];
-- 游标往下移动
RandomFactoryM.useRandCursor(desc);
……
return seed;
很明显,返回的随机数受到两个因素的影响,index和num,也就是RandomFactoryM.fetchRandCursor(desc)和dungeonContainer.rand_seed。后者看起来是一个固定的数组,至少它的值在函数DungeonM.getRandSeed中没有被改变,在计算奖励的匿名函数中也没有被改变。——或许有改变,在下面的RandomFactoryM.useRandCursor(desc)中?前者和RandomFactoryM.useRandCursor(desc)都很可疑,那就一起研究研究吧。
-- 获取随机数游标(暂时只有迷宫中用到,以后再说)
function fetchRandCursor(desc)
local id =0;
iftype(cursorTable[desc]) == "table" then
id =cursorTable[desc].id;
end
local cursor= ME.user.dbase:query("randomCursor", {});
return cursor[id]or 0;
end
冒出了一个新的东西:ME.user.dbase:query,看名称是从数据库中查询。函数简短,也没什么难理解的地方,就是获得desc对应的id,然后根据这个id返回从数据库中查询到的、与其对应的一个值。没什么特别的,那先放放,看看RandomFactoryM.useRandCursor(desc):
-- 使用游标
function useRandCursor(desc, offset)
local id =0;
iftype(cursorTable[desc]) == "table" then
id =cursorTable[desc].id;
end
local cursor= ME.user.dbase:query("randomCursor", {});
ME.user.dbase:set("randomCursor", cursor);
local value= (cursor[id] or 0) + (offset or 1);
cursor[id] =bit.band(value, 0xffff);
end
这个函数的前面几句和fetchRandCursor相同,需要注意的是后面三句。
第一句:ME.user.dbase:set("randomCursor",cursor);这个只是个简单的set,它的唯一用处是当randomCursor不存在时,初始化为{}(这个还是在上一句ME.user.dbase:query("randomCursor", {})中作为默认值返回的)。
下面两句是重点,因为它更改了一点东西。
在DungeonM.getRandSeed中,仅向useRandCursor传递了一个参数。因此,在此处,第二个参数offset是undefined(我也不知道lua中用啥表示,大家凑合着看吧)。local value = (cursor[id] or 0) + (offset or 1);,也就变成了local value = (cursor[id] or 0) + 1;。看到+1我就放心了:它和cursor这个东西对上了,大概是让cursor移动到下一个位置;而cursor[id]中记录着cursor的当前位置。可是第三句又让我迷糊了,为毛不是cursor[id] = value;?为什么要bit.band(value, 0xffff)?经过查找,得知bit.band是lua提供的内置函数,用来执行整数的位and运算。这下明白了,这是将cursor[id]的值域限制在了[0, 0xffff-1]。
到此,就不由得叹气了,没发现和服务器有啥关系啊,看来得回头看看刚刚放过的ME.user.dbase:query。