以闪亮之名逆向

大类
iOS
Crack
Game
逆向
技术标签
逆向-iOS
开发-协议脚本
逆向-手游-UnReal
优先级
Low
开始日期
Mar 24, 2023
状态
Maintaining
Public
Public
最后更新
Mar 29, 2023

第一层解包:祖龙工作室私有格式

这个格式从完美世界一直沿用到龙族以及以闪亮之名
标志性特征有这些:
  • 超大的PNG文件,在StreamingAssets文件夹下面
  • PNG的文件头是`EF 23 CA 4D` (EF23CA4D) ,小端序是 4DCA23EF
  • 在二进制中会存在AFilePackMan::OpenFilePackage字符串
  • 会有Azure File Package, Loong Co. Ltd. All Rights Reserved.字符串
 
通过巧妙搜索,找到了国外大佬前辈的解包工具,测了一下直接就能用
 

第二层解包1:LuaC反编译

Lua文件夹内的文件头是LuaQ,所以是Lua5.1的文件。
直接用unluac批量跑一遍就完事了,虽然用了LuaC,但是调试符号都在,所以没有太大影响,只有一两个文件反编译失败
 

编写更新监测

逆向游戏初始化协议

  • 在StreamingAssets\config下可以很轻松找到这个入口服务器的地址
    • notion image
  • 所以我们知道入口服务器在祖龙那边叫“dir server”,那我们在lua里面搜一下
    • 能找到update.lua,里面有大量相关逻辑
    • 梳理控制流后可以看到里面主要就是调用request_dir函数,然后进一步调用GameUtil.RequestDirInfo,这是个实现在Native层的函数
  • 搜索字符串可以很轻易在native层找到对应函数,继续在native层逆向
    • 所有逻辑都用的CPP编写,符号比较少,字符串一般,不多也不少
    • 没有什么投机取巧的办法,就硬着头皮逆
      • 一个个重建Cpp类:标准库、自定义Buffer、网络manager、stream抽象,等等
      • 一点一点梳理逻辑:连接、发送buffer、接收buffer、解析buffer
  • 我使用了GPT来辅助逆向
      1. 通信协议用了变长编码,看了许久也看不出来,GPT一下就说出来变长的思路:基于前三个bit来判断是否整数的长度
      1. 代码里混杂了很多UTF16和UTF8导致需要很多互转。UTF8的解析我确实不懂,但GPT一下就看出来了
  • 中间出了一些问题,怎么逆都逆不出来
    • 我误以为有通信的加密密钥(isec/iseckey/osec/oseckey),然后找了许久也没有找到设置的地方
      • 这些参数在当年完美世界私服的时候是有用的,但是在这里所有的都是空
    • 我使用了Debugger打断点看了逻辑才知道并没有加密,而只是压缩了一下
    • 原因是我一直在连接处理的地方转悠,而游戏在最后输出给Lua的时候才解压,解压函数在这个函数的第三层(
      • notion image
    • 失误的另一个原因则是我不熟悉zlib的header,导致没有把压缩编码认出来。在这次中我碰到的header是 0x78 0x9c,似乎0x9c可以变,但0x78往往就是zlib编码(长记性了
  • 知道了这些之后,就可以编写协议脚本了

自动更新游戏版本

  • 使用GitHub Actions来自动每隔一段时间来跑一次,这样就不用服务器了
 

第二层解包2:解析BCFG

  • 可能是为了降低Lua object的内存占用,他用了一个奇怪的编码方式编码了大lua table
  • 祖龙工作室管这个叫“convex”,网络上查了一下,并没有太多资料
    • 只能自己逆了!
  • convex的代码里面带着很多调试字符串,所以逻辑还算清晰
    • 可以很快逆向出来Header、Definition、Row等结构
    • 然而,重组这些结构则很麻烦,因为table涉及到数组、字典等等不同类型,所以解析做的很复杂
  • 为了方便调试,我编写了010editor的模板方便我看看我解析的思路是否正确
  • 首先我们需要搞清楚bcfg整体的文件结构
    • | BCFGHeader | RawData | BCFGDefinition | BCFGRows |
    • header里面存了这个文件有多少definition和row,以及原始数据的大小和这个table的id
    • RawData是结构中的实际数据
    • BCFGDefinition里面存储了table的类型以及字段名等等信息
    • Rows存放了指向oriData的偏移(但是没有每一个row的大小)
    • (我一开始没搞清楚RawData在那里,白浪费很多时间)
  • 然后,我们就得搞定RawData的格式
    • definition里面有好几个格式,id分别为0、0x1A0、0x2A0,由于1A0 2A0的结构都很小,所以我们从比较完备的0号结构入手
    • Cpp代码那边用了很多UData(lua userdata)还有std::map来存储数据,这导致很多数据流不好分析
      • map用的是std::tree,所以就很不好逆
    • 一开始想着急于求成,希望能避开udata还有std::map的逻辑,但是后来发现这样是不行的,因为几乎所有逻辑都和他俩有关
      • 要静下心来好好恢复结构体,这样数据流就清晰了
      • 仔细推敲二进制中出现的字符串:
        • loadBcfg之后:get ctable[%d]
        • 然后调用了row %d(%p) set metatable ok
        • 在这个函数里初始化了__index的binding函数:
          • rowUData index arg1 isn't userdata
          • get %s from type(0x%x) will get a 0x%0x
          • create lua table for list failed
          • 可以看出这个函数就是核心的逻辑处理函数
    • 恢复了数据流之后,RawData的结构就呼之欲出了
  • 处理List与Table
    • 由于源代码过于杂乱,所以我选择投机取巧一下,猜一下List的存储格式
      • 根据代码可以看到List就是0xXAF,而Bean(Table)也就是0xXA0~0xXAE
      • 根据010editor猜一下格式,一开始我猜测是直接一个挨着一个存,能parse好几组数据,但是后面就出错了
    • 试了好几种格式都不对,最后只好回到源代码里面好好看,发现他其实是存了list的大小,然后list的元素如果是bean的话,则每个元素也存放了bean的大小
    • 这样就对了
  • int16的解析
    • 解析完了之后发现很多数值都被x2了
    • 仔细看了代码,发现他是把最后一位用于当做符号位了,所以我们修了一下,又好了
 

计算得分逻辑

原始数据分析笔记
  • 通过搜索衣服的名字可以很轻松找到fashion_essence.bcfg,里面的结构也很简单
    • notion image
  • 然后我们就需要确定各个attribute以及tag的对应关系,tag的对应关系很好找,但是attribute代码里似乎没有这种对应关系(猜测是通过UI直接赋值的)
    • 一开始以为是这一组对应关系,但是发现数值对不上(最大的属性不对)
    • notion image
    • 然后我意识到还有另一组enum
      • notion image
      • 所以说上一组其实是错的,并不是用来对应这些attribute的
    • 手动通过比对分值确定了对应关系
  • 但是我发现不管怎么计算,游戏里面的分数和衣服的分数都对不上
    • 游戏的衣服分数完全由服务器计算得出,客户端发送gp_stage_combat_start,服务端则返回gp_stage_combat_start_re
      • gp_stage_combat_start中包含了关卡id、选择的衣服、选择的羁绊
      • 我尝试直接发送api来排除羁绊的影响,但是只要少发东西,服务器就报错不返回
  • 所以,首先我想的是排除羁绊卡牌的加成
    • 所以我选了一张最菜的R卡,然后看了下他的数据
      • notion image
    • 数据显示所有的同系卡牌(R/SR/SSR)他的数值都是一样的
    • 卡牌的加成在ECCard:GetAttriBonus方法中
    •  
  • 我第一个找到的算分代码在ECFashionCombatMan.CalcTotalCombatScore里面,这套算分的代码是用于处理bClientCombat情况的(但绝大部分关卡都是联网对战)
    • 卡牌的计算在这里,可以看到是乘以对应的卡组属性加成
    • notion image
    • 卡组属性加成则在上面的ConvertCurCardsToSeasonCardData计算
      • notion image
    • card:GetAttriBonus里面则是把卡组的原始数据乘以scale factor(固定为1.0E-4,也就是除以1000)
      • notion image
    • 那么计算卡组在性感(attr[2][1])这个属性上的加成是(1 + 15 / 1000) = 1.0015
  • 随后我注意到游戏里面在使用Fashion的时候有两套分数:
    • FashionDetail和FashionInfo究竟有什么区别
    • notion image
    • 经过lua测试,发现FashionInfo就是原始数据,返回7420
    • 而FashionDetail返回则是8162
    • 后面,我注意到7420 * 1.10 = 8162,然后我注意到斑斓度的加成
      • 所以,FashionDetail其实就是斑斓度的加成后数值
  • 然后,我发现了第二套算分代码,是用来在预选卡牌提示的时候算分的:ECFashionCombatMan.CalcFashionsCombatInfo
    • 整体的计算逻辑类似,还是weight * score,重点的区别是这里多了一个factor(由CalcPlayerFactor算出)
    • notion image
    • 在CalcPlayerFactor中算的是TotalBonus还有LevelBonus
    • notion image
    • 第一个函数GetTotalBonusByAttriIncludeFaction里面是算了两部分Factor,练习的factor和从m_AttriData直接读出来的factor
      • notion image
      • 后续看了下m_AttriData是直接从服务器同步过来的,初步看了下和卡牌、套装之类的收集度有关
      • 我直接用lua把这个函数的值读出来
    • Level bonus后续看了没有似乎没有用上,可能是需要组合套装之后才可以
  • 这样计算就对了
    • 最终公式:基础分数 * 斑斓度加成 * 玩家属性加成(服务器算,可能跟玩家等级有关) * 协会风格指数加成 * 羁绊分数加成(1+卡牌分数/10000) + 卡牌技能加成
    • 样例:
      • 璞月之章,清纯分数7420
      • 斑斓度16,加成10% = 8162
      • 玩家属性加成1.00475 (GetTotalBonusByAttriIncludeFaction(2*2+1-1) = 0.475 / 100 +1)
      • 协会风格指数加成(我没有): 0
      • 羁绊卡组加成:1.0015 (15 / 10000 + 1)
      • 关卡属性(1-2),attribute_value2=6000,倍率6000/1000 = 6
      • 7420 * 1.1 * 1.00475 * 1.0015 * 6 = 49278.4 (下取整)
      • 与服务器分数一致
  • 后续逆向发现玩家的属性加成其实应该就是这些
    • notion image
    • 因为代码里是这样写的
    • notion image
      15680 * 1.00465 * 3 = 47258.736 (origin score) 15680 * 1.0 * 1.00465 * 1.0031 * 3 = 52145 * 1.9 = 89790 banlan wanjia kazu beilv s print(_G.g_CardMan.m_AllCards[107]:GetAttriBonus(1, 1)) s print(g_AttriBonusData:GetLevelBonusByAttri(1, 2)) s print(g_AttriBonusData:GetTotalBonusByAttriIncludeFaction(2))