Skip to content

xx消消乐的逆向分析与利用 #32

@ohroy

Description

@ohroy

前言

今天我们来研究一下如何收获妹子崇拜的眼神,从而获得妹子的芳心...啊不,是如何在妹子面前装一个圆润的漂亮的逼。即帮他减轻喜欢的游戏的压力,让她知道游戏是如此的...无聊。
这款游戏的名字叫《xx消消乐》,怎么样,一看到这个游戏就知道妹子显然是一个清纯的人。
这里,我们的目的是使用多种方式来给这款游戏作弊。
本次探索中可能重要的技术或工具为

  • ida
  • frida
  • lua
  • c/c++

但本文只为技术交流,无任何赢利目的。但恶意修改游戏是违法行为,各位读者需自负责任,不要走上违法犯罪的道路。本文作者不负相关责任。
且本文所分析样本为2019年1月份的某个版本,现行版本不一定适用,因此所有代码仅供参考。同时,我也希望能对游戏行业的开发人员有点警示作用,使之明白安全防护的重要性,以及现在裸奔是多么的危险。

分析

引擎分析

正如之前提到的,分析一个游戏的第一步,显然是先分析到游戏所用的引擎。常见的框架有cocos2dUnity3dunrealengine等。本次探索的目标是使用的lua引擎,这类游戏和U3d一样,都非常的简单,只要反编译到游戏的源代码,则基本如履平地,驰骋疆场。
你问我如何知道它是lua?最简单的方法是拖到ida里面一顿梭,只要含有lua相关关键字的,一般八九不离十就是了,另外,对于安卓来说,包解压后直接看lib目录里是否有libcocos2dlualiblua,libhellolua等即可快速判断出。由此,我们可以得出结论。除了这款游戏之外,《梦幻西游》《奇迹暖暖》等也是这个引擎,也可按本文下述套路一顿梭。

lua引擎的弱点

  1. 基于lua是一种脚本语言的说法,且为了开发和更新方便,一般安全意识较弱的公司,对lua脚本的存放都在资源文件夹里,有的甚至文件根本没有加密。
  2. 稍微安全意识较强的,可能会把lua给打包存放,甚至加密存放。但这些属于徒劳,顶多算是自欺欺人,因为最后在lua虚拟机装载的时候,总要进行解密,我们可以在这个时候勾住装载函数,以获取所有的脚本。本文示例的游戏即是此类型。
  3. 安全意识更强的,则考虑把lua编译后再放入客户端,此时攻击者无法直接获取到lua的源码,取而代之的是获取到编译后的结果。但显然这也是自欺欺人的表现,因为lua解释器开源的,制作一个lua的反编译工具是非常简单的,且现在已经有很多的实现。反编译后依然能得到源码。
  4. 安全意识极强的,可能由上述方案更进一步,既然lua解释器开源的,那就对lua解释器进行魔改,使之成为非标准的,则市面上成型的工具不是全都失效了吗?显然是。这种方案是安全程度相对较高的。但也不是绝对安全。因为解释器还是放在客户端,我们只需要分析出解释器修改了那些地方,再同样的修改反编译工具即可。之前发布的触动精灵加密脚本还原即是对应的逆向实现。

实战

获取脚本

根据上述的理论知识,我们直接依次尝试,结果发现此游戏属于运行时解密的类型,(即上述第2种),我们直接使用frida来做勾取

nptr = Module.findExportByName(null, "luaL_loadbuffer");
var keep = null;
var keep2 = [];
var keep1 = null;
if (!nptr) {
    console.log("luaL_loadbuffer can be found!");
} else {
    console.log("find %d", nptr);
    Interceptor.attach(nptr, {
        onEnter: function(args) {
            var len = args[2].toInt32();
            var code = args[1].readCString(len);
            send({ path: args[3].readCString(), dump: code });
        }
    }
}

上述脚本,hook了

int luaL_loadbuffer (lua_State *L, const char *buff, size_t sz, const char *name);

这个函数,第一个参数为lua虚拟机指针,第二个为代码的字符串buff,第三个为代码的长度,最后一个则为这个脚本的名字,由此,天时地利人和,参数齐备,我们获取之后发回到frida即可保存在主机上,当然frida这边需要保存。

def savefile(path, data):
    create_dir(os.path.dirname(path))
    with codecs.open(path, 'w', 'utf-8') as f:
        f.write(data)
def on_message(message, data):
    if 'payload' in message and message['type'] == 'send':
        payload = message['payload']
        if 'dump' in payload:
            origin_path = payload['path']
            data = payload['dump']
            savefile(origin_path,data.encode("utf-8"))



            return
        if message['type'] == 'send':
            print("[*] {0}".format(message['payload'].encode('utf-8')))
        else:
            print(message)

此时,我们即可成功dump《xx消消乐》的所有代码,如下
kaixin

在图中,我们甚至可以看到注释。。。显然,这家公司心太大了。

无限步数

既然有源码,甚至有注释,这一步的工作可以说是非常简单了。我们一番搜索之后发现上图中所示位置

	mainLogic.theCurMoves = mainLogic.theCurMoves - 1;
  if mainLogic.PlayUIDelegate then --------调用UI界面函数显示移动步数

mainLogic.theCurMoves即代表剩余步数,我们可以选择每走一步+1,或者一直不减....等等逻辑。

无限精力

经过上面这么一整,显然是舒服了,再也不担心会输了,一口气过了好多关。但接下来问题出现了,精力不够了,怎么办?
只能盘他了。
我们一顿直接搜索jingli然而什么都没找到,看来这家公司技术不怎么行但英语还是不错的。我给我初中同学打了个电话,问了问她精力的英文怎么拼,她骂了我一顿说我神经病然后让我百度翻译....然后一顿翻译以后得知他的英文果然不错。正则搜索energy[\s]+=果然命中。

function UserRef:setEnergy(v)
	local key = "UserRef.energy"..tostring(self)
	self.energy = v --onlu used for encode
	encrypt_integer_f(key, v)
end

更牛逼的是这里面的注释竟然写着他有编码,嗯,显然是为了防止类似ce八门神器等的业余玩家,我百度了一下,发现这个公司的技术负责人竟然在教别人游戏怎么防攻击。。。好吧,我实在不知道该说什么好了。

无限金币/风车币

这个就简单了,就在上面精力的下面

function UserRef:getCoin()
	local key = "UserRef.coin"..tostring(self)
	return decrypt_integer(key)
end
function UserRef:setCoin(v)
	local key = "UserRef.coin"..tostring(self)
	self.coin = v --onlu used for encode
	encrypt_integer(key, v)
end

function UserRef:getCash()
	local key = "UserRef.cash"..tostring(self)
	return decrypt_integer(key)
end
function UserRef:setCash(v)
	local key = "UserRef.cash"..tostring(self)
	self.cash = v --onlu used for encode
	encrypt_integer(key, v)
end

function UserRef:getRealTopLevelId()--最高通过关卡而不是最高停留关卡
	local topLevel = self:getTopLevelId()	
	local levelScore = UserManager.getInstance():getUserScore(topLevel)
	if levelScore and levelScore.star > 0 then
		return topLevel 
	else
		return topLevel - 1
	end
end

function UserRef:getTopLevelId()
	local key = "UserRef.topLevelId"..tostring(self)
	local level = decrypt_integer_f(key)
	if level > kMaxLevels then level = kMaxLevels end
	return level
end
function UserRef:setTopLevelId(v)
	local key = "UserRef.topLevelId"..tostring(self)
	self.topLevelId = v --onlu used for encode
	encrypt_integer_f(key, v)
end

function UserRef:getStar()
	local key = "UserRef.star"..tostring(self)
	return decrypt_integer(key)
end
function UserRef:setStar(v)
	local key = "UserRef.star"..tostring(self)
	self.star = v --onlu used for encode
	encrypt_integer(key, v)
end

function UserRef:getHideStar()
	local key = "UserRef.hideStar"..tostring(self)
	return decrypt_integer(key)
end
function UserRef:setHideStar(v)
	local key = "UserRef.hideStar"..tostring(self)
	self.hideStar = v --onlu used for encode
	encrypt_integer(key, v)
end

function UserRef:getEnergy()
	local key = "UserRef.energy"..tostring(self)
	return decrypt_integer_f(key)
end
function UserRef:setEnergy(v)
	local key = "UserRef.energy"..tostring(self)
	self.energy = v --onlu used for encode
	encrypt_integer_f(key, v)
end

function UserRef:getUpdateTime()
	local key = "UserRef.updateTime"..tostring(self)
	return decrypt_number(key)
end
function UserRef:setUpdateTime(v)
	local key = "UserRef.updateTime"..tostring(self)
	v = tonumber(v) or Localhost:time() --onlu used for encode
	self.updateTime = v
	encrypt_number(key, v)
end

function UserRef:encode()
	local dst = {}
	self.updateTime = self:getUpdateTime()
	self.energy = self:getEnergy()
	self.hideStar = self:getHideStar()
	self.star = self:getStar()
	self.topLevelId = self:getTopLevelId()
	self.cash = self:getCash()
	self.coin = self:getCoin()

	for k,v in pairs(self) do
		if k ~="class" and v ~= nil and type(v) ~= "function" then dst[k] = v end
	end
	return dst
end

这里面什么都有了,对着修改即可。。

开发者模式

对代码一番研究,竟然发现有开发者模式。
而且就是前几行

_G.kUseSmallResource = true
_G.kScreenWidthDefault = 720
_G.kScreenHeightDefault = 1280
_G.kDefaultSocialPlatform = "ios_all"
_G.kUserLogin = false
_G.isLocalDevelopMode = StartupConfig:getInstance():isLocalDevelopMode()

也是牛逼顶天了。开发者模式开启后,界面会多出来一些了不得的功能。

GM模式

这个就不讲实现了吧。。。。

完整frida脚本

function patchLua(src, dst, args) {
    var origLength = args[2].toInt32();
    var test = args[1].readCString(origLength);
    if (test.indexOf(src) > -1) {
        var test2 = test.replace(src, dst); //"mainLogic.theCurMoves<100?mainLogic.theCurMove+ 1:mainLogic.theCurMove - 1;");
        //test3.writeByteArray(strToBinary(test));
        var tmpP = Memory.allocUtf8String(test2);
        keep2.push(tmpP);
        args[1] = tmpP;
        var length = getStringLen(args[1]);
        args[2] = ptr(length);
        console.log(src + "patch ok");
    }
}

nptr = Module.findExportByName(null, "luaL_loadbuffer");
var keep = null;
var keep2 = [];
var keep1 = null;
if (!nptr) {
    console.log("open can be found!");
} else {
    console.log("find %d", nptr);
    Interceptor.attach(nptr, {
        onEnter: function(args) {

            var origLength = args[2].toInt32();
            var test = args[1].readCString(origLength);
            //send({ path: args[3].readCString(), dump: test });
            if (test.indexOf("mainLogic.theCurMoves - 1") > -1 &&
                args[3].readCString().indexOf("BonusStep") < 0) {
                var test2 = test.replace("mainLogic.theCurMoves - 1", "mainLogic.theCurMoves + 1"); //"mainLogic.theCurMoves<100?mainLogic.theCurMove+ 1:mainLogic.theCurMove - 1;");
                //test3.writeByteArray(strToBinary(test));
                var tmpP = Memory.allocUtf8String(test2);
                keep = tmpP;
                args[1] = tmpP;
                var length = 0;
                var p = args[1];
                var length = getStringLen(args[1]);
                args[2] = ptr(length);
            }
            //修改精力
            patchLua("self.energy = v", "v=30\nself.energy = v", args);
            //修改风车币
            patchLua("self.cash = v", "v=79878\nself.cash = v", args);
            //开发者模式
            //patchLua("StartupConfig:getInstance():isLocalDevelopMode()", "true",args);
        },
        onLeave: function(retval) {}
    });
}

防检测

逆向这么强的吗?那游戏公司不是混不下去了???显然不是,虽然客户端是攻击者的主场,但游戏公司也有自己的主场,那就是各种层出不穷的检测。。

但一般来讲,我们不应该去修改lua源码本身,因为可能大多数的公司都会有lua源码进行类似crc,md5adlerhash等校验,我们一旦对源码进行修改,则意味者被认定为作弊玩家。因此,对于此种lua游戏,可以选择再次加载自身的lua脚本,因为该脚本也和游戏脚本在同一个虚拟机的命名空间之内,由此可以实现变量的覆盖,函数的调用等。

但即便如此,也不可能全然绕过游戏的检测和防御。而关于如何绕过?这个是最有技术含量的,一般来说,功能的实现都是比较简单的,但往后检测的对抗才是智力体力的终极对抗。

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions