前些天在逛 52 时发现了两篇文章,分别是对赛尔号和摩尔庄园两个页游加密算法的逆向和复现,深有感触。于是我决定分享一下我是如何破解 seer2(现已改名为约瑟传说),分析出它的加密过程的。
所用工具
- flash 反编译软件 ffdec
- idea 开发工具
- NotePad++ (含HEX-EDIT插件,用于查看二进制内容)
ffdec 用于反编译 swf 文件,这个很重要。
idea 是 jb 家的开发工具,我主要使用 java 语言,而且 idea 也可以很方便的追踪和查看 actionscript 代码。
为了更方便的使用 ffdec 和 idea,可以下载 flash 的开发包 flex_sdk,其作用大概相当于 jar 于 java,不过也没必要深究。
actionscript3 是 flash 专用语言,大部分和 javascript 类似,按照 js 理解就行,同样没必要深究。flash 都要凉,这语言看看就行,有问题翻一翻文档,不用特别在意。
代码获取
通过浏览器控制台捕获事件可以知道,游戏的入口是 Client.swf 文件。Client.swf 经过 ffdec 反编译可以提取出 Client.as 等文件,建议使用 idea 查看。Client.swf 会在登录成功后加载 ClientAppDLL.swf、ClientCoreDLL.swf 和 TaomeeCoreDLL.swf 三个文件,这三个文件都是加密过的,ffdec 无法直接打开。
有加密过程,就有解密过程。Client.as 作为入口,一定含有解密的过程,于是可以追踪到这样一段代码。
// net DLLLoader.as
public function DLLLoader()
{
this._stream = new URLStream();//使用url加载资源
this._stream.addEventListener(Event.OPEN, this.onOpen);
this._stream.addEventListener(Event.COMPLETE, this.onComplete);//加载完成后调用onComplete函数
this._stream.addEventListener(ProgressEvent.PROGRESS, this.onProgressHandler);
this._loader = new Loader();
this._loader.contentLoaderInfo.addEventListener(Event.COMPLETE, this.onLoaderOver);
return;
}// end function
private function onComplete(event:Event) : void
{
var _loc_2:* = this._dllList[0];
var _loc_3:* = new ByteArray();
if (this._isDebug == false)
{
this._stream.readBytes(new ByteArray(), 0, 7);// 舍去xxDLL.swf的前7个字节
}
this._stream.readBytes(_loc_3);
if (this._isDebug == false)
{
_loc_3.uncompress();//默认调用zlib算法解压缩
}
this._stream.close();
var _loc_4:* = new LoaderContext(false, ApplicationDomain.currentDomain);
this._loader.loadBytes(_loc_3, _loc_4);
return;
}// end function
解密方法就是舍弃前7个字节,对剩下的字节调用 zlib 解压缩。由此,就可以获得解密后的 DLL 文件了,这时 ffdec 就可以打开该文件了。
ffdec 阅读代码不方便,同样,我们导出代码,用 idea 查看。这样,我们就获得了游戏用的绝大多数代码,可以进行下一步了。
协议分析
之前已经分析过通信用的协议了,其使用小端编制,结构如下:
4 字节 | 2 字节 | 4 字节 | 4 字节 | 4 字节 | (x-18) 字节 |
---|---|---|---|---|---|
数据长度x | 协议号 | 用户账号 | 消息序列号 | 校验码 | 数据 |
加密后的消息结构
4 字节 | 2 字节 | (y-6)字节 |
---|---|---|
加密后的长度y | 协议号 | 加密数据 |
加密算法如下:
// com.taomee.seer2.core.net.message RequestPacker.as
public static function pack(param1:uint, param2:uint, param3:Array, param4:String = null) : ByteArray
{
if(param4 == null)
{
param4 = Endian.LITTLE_ENDIAN;//小端编址
}
var _loc5_:ByteArray = new ByteArray();
_loc5_.endian = param4;
serializeBinary(_loc5_,param3);
var _loc6_:ByteArray = packHead(param1,param2,_loc5_,_loc5_.length,param4);
var _loc7_:ByteArray = new ByteArray();
_loc7_.endian = param4;
_loc7_.writeBytes(_loc6_);//写入数据头信息
_loc7_.writeBytes(_loc5_);//写入数据体
_loc7_.position = 0;
return MessageEncrypt.encrypt(_loc7_);//加密
}
//cmid,m_len,_sequenceNum
private static function generateNewSequenceNum(param1:uint, param2:int, param3:int) : int
{
var _loc4_:CLibInit = null;
if(_descObj == null)
{
_loc4_ = new CLibInit();
_descObj = _loc4_.init();
LogArea.getInstance().addLog("desc:"+ _descObj.Desc);
}
_sequenceNum = _descObj.Desc(param1,param2,param3);
LogArea.getInstance().addLog("seq:"+param1+","+param2+","+param3+"="+_sequenceNum);
return _sequenceNum;
}
加密部分:
// com.fcc MEncrypt.as
public function MEncrypt(param1:ByteArray, param2:int, param3:ByteArray) : void
{
var _loc5_:* = undefined;
var _loc8_:* = 0;
var _loc19_:* = 0;//len
var _loc18_:int = 0;
var _loc4_:* = 0;
var _loc16_:int = 0;
var _loc17_:int = 0;
var _loc15_:* = 0;
var _loc14_:* = 0;
var _loc13_:* = 0;
var _loc12_:* = 0;
var _loc10_:int = 0;//loc6-16 len
var _loc9_:int = 0;
var _loc6_:* = int(ESP);
_loc8_ = _loc6_;
ESP = _loc6_ & -16;// 11...11,0000
var _loc7_:* = int(getDefinitionByName("net.DLLLoader").size);
if(_loc7_ == 1011)
{
ESP = _loc6_ & -16;
_loc19_ = param2;
_loc6_ = int(_loc6_ - 16);
si32(_loc19_,_loc6_);
ESP = _loc6_;
F_malloc();
_loc6_ = int(_loc6_ + 16);
_loc18_ = eax;
ESP = _loc6_ & -16;
CModule.writeBytes(_loc18_,_loc19_,param1);
_loc6_ = int(_loc6_ - 16);
_loc17_ = _loc19_ + 1;
si32(_loc17_,_loc6_);
ESP = _loc6_;
F_malloc();
_loc6_ = int(_loc6_ + 16);
_loc16_ = eax;
_loc15_ = _loc18_;
_loc14_ = _loc16_;
_loc13_ = _loc19_;
_loc12_ = 0;
if(_loc19_ >= 1)
{
do
{
_loc4_ = li8(_loc15_);//待加密的字节
_loc9_ = L__2E_str5;
_loc10_ = 0;
if(_loc12_ != 17)
{
_loc9_ = L__2E_str5 + _loc12_;//注意这里的 L__2E_str5
_loc10_ = _loc12_ + 1;
}
_loc7_ = li8(_loc9_);//加密的key
_loc7_ = _loc7_ ^ _loc4_;
si8(_loc7_,_loc14_);//加密后存放字节
_loc15_ = int(_loc15_ + 1);
_loc14_ = int(_loc14_ + 1);
_loc13_ = int(_loc13_ + -1);
_loc12_ = _loc10_;
}
while(_loc13_ != 0);// _loc_13 剩余长度
}
_loc4_ = int(_loc16_ + _loc19_);
si8(0,_loc4_);//加密后补个0
_loc7_ = int(_loc19_ + -1);
if(_loc7_ >= 0)
{
_loc13_ = int(0 - _loc19_);
if(_loc13_ <= -1)
{
_loc13_ = -1;
}
_loc7_ = int(_loc19_ + _loc13_);
_loc19_ = int(_loc7_ + 1);
do
{
_loc7_ = li8(_loc4_);
var _loc11_:* = li8(_loc4_ - 1);
_loc11_ = int(_loc11_ >>> 3);
_loc7_ = _loc11_ | _loc7_;
si8(_loc7_,_loc4_);
_loc7_ = li8(_loc4_ - 1);
_loc7_ = _loc7_ << 5;
si8(_loc7_,_loc4_ - 1);
_loc4_ = int(_loc4_ + -1);//倒着进行
_loc19_ = int(_loc19_ + -1);
}
while(_loc19_ != 0);
}
_loc7_ = li8(_loc16_);
_loc7_ = _loc7_ | 3;
si8(_loc7_,_loc16_);
if(_loc18_ != 0)
{
_loc6_ = int(_loc6_ - 16);
si32(_loc18_,_loc6_);
ESP = _loc6_;
F_idalloc();
_loc6_ = int(_loc6_ + 16);
}
ESP = _loc6_ & -16;
CModule.readBytes(_loc16_,_loc17_,param3);
if(_loc16_ != 0)
{
_loc6_ = int(_loc6_ - 16);
si32(_loc16_,_loc6_);
ESP = _loc6_;
F_idalloc();
_loc6_ = int(_loc6_ + 16);
}
}
_loc6_ = _loc8_;
ESP = _loc6_;
return _loc5_;
}
加密过程看起来很复杂,还涉及不少函数调用。但是看到 ESP、F_malloc、li8、si8 就可以大胆地猜测,这些是在模拟c语言,li8 应该就是 load 8 bite,si8 就是 store 8 bite。事实上,就是在模拟 c 语言调用,不过代码不适合阅读就是了。
这样看,上面的加密过程也就不复杂了,先是与密钥进行异或,之后进行错位调整,仅此而已。而上面的 L__2E_str5 就是加密用的密钥。
查找发现L__2E_str5
是 S__2E_rodata
的第 208 个地址偏移。
public const L__2E_str5:int = S__2E_rodata + 208;
继续查找S__2E_rodata
可以找到
追踪到这里就卡壳了,allocDataSect 部分更加繁琐,不易阅读。但是从它的含义来看,可以猜测,它是分配了一个大小为 21184 字节的空间,那么极有可能,这个 21184 是一个文件的大小,那么从哪里能找到这个文件呢。
swf 中确实有个地方可以存放二进制文件,那就是 binaryData,导出 binaryData 中的这 23 个文件。
导出后,可以发现,确实有一个文件大小刚好是 21184 字节。
那么这个文件的第208(0xd0)个偏移是什么呢?
没错,就是taomee_seer2_k_~#
,这个就应该是是 s2 加解密用的密钥了。
除了猜测,还要找到证据。
同一个包下,有一个很可疑的类,类名与众不同,特别长。
继续追踪 DS2类可以发现,它实际上就是在读取前面找到的 21184 文件。
那么到这里就可以确定,密钥就是上面的taomee_seer2_k_~#
算法验证
这样,我们就获得了上面的加密方法,同理,也可以找到解密方法。
我这里给出了 java 实现,附在文末了。
通过对 Client.as 分析可知,网通登录时,客户端会先向http://cncsr2login.61.com/ip.txt
提供的服务器地址发送登录信息,获取到一个 session 和 若干个游戏用的服务器地址。此阶段数据均未加密,可以自己搭建代理服务器捕获这些数据。
之后,客户端将会和上面的的服务器之一建立连接,直到用户关闭网页。此间,通信均为加密过的,我们就选择捕获这一部分的数据。
下面是捕获的一次数据。
[103, 0, 0, 0, -23, 3, 35, -12, -63, 24, 11, -72, -20, 107, 78, 1, 77, 78, -90, 106, -19, -53, -113, -66, -122, -114, -78, 87, -64, 124, -24, 33, 106, 113, 117, 64, 80, 55, -13, -51, 111, -124, -114, 46, -20, -83, -83, -84, -20, 107, -82, -84, 76, 78, -26, 107, -19, -53, 111, -124, -114, 46, -20, -83, -83, -84, -20, 107, -82, -84, 76, 78, -26, 107, -19, -53, 111, -124, -114, 46, -20, -83, -83, -84, -20, 107, -82, -84, 76, 78, -26, 107, -19, -53, 111, -124, -114, 46, -20, -83, -83, -84, 12]
调用解密函数后
[66, 00, 00, 00, e9, 03, d5, 6e, a9, 35, a5, 00, 00, 00, 6f, 0d, 00, 00, 0a, 00, 00, 00, d7, 41, 00, f5, d2, 6f, 83, 26, 50, 22, ee, ce, 71, b0, e5, f2, 30, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00]
貌似看不出看什么。但如果按照小端编制的逻辑进行解析,可以得知:
cmdId:1001, length:102, uid:900296405, sequenceIndex:165, statusCode:3439
与当前登录的账号匹配,说明解密没有问题。
当然,还可以更进一步的验证,用自己编写的加密函数加密,再利用解密函数解密,如果数据一致,就说明没有问题了。
我之前用黑盒法也解出了等价的算法,有兴趣的朋友可以拿去比对比对。
附上代码
解压 DLL 的 Java 代码如下:
public static final String fileName = "ClientCoreDLL.swf";
public static void swfDecompress(String fileName) {
FileInputStream fin;
FileOutputStream fout;
try {
fin = new FileInputStream(new File(fileName));
fout = new FileOutputStream(new File("unc_"+fileName));
fin.read(new byte[7], 0, 7);
fout.write(decompress(fin));
System.out.println("decompress succeed");
fout.close();
fin.close();
} catch (Exception e) {
e.printStackTrace();
}
System.out.println("succeed");
}
public static byte[] decompress(InputStream is) {
InflaterInputStream iis = new InflaterInputStream(is);
ByteArrayOutputStream o = new ByteArrayOutputStream(1024);
try {
int i = 1024;
byte[] buf = new byte[i];
while ((i = iis.read(buf, 0, i)) > 0) {
o.write(buf, 0, i);
}
} catch (IOException e) {
e.printStackTrace();
}
return o.toByteArray();
}
加密解密代码如下:
public static byte[] encrypt1(byte[] bytes) {
int offset = 4+2;
byte[] outBytes = new byte[bytes.length + 1];
int i = 0;
for (int j = offset;j<bytes.length;j++) {
if(i==KEY.length)i=0;
outBytes[j] = (byte) (KEY[i++] ^ bytes[j]);
}
for (int j = outBytes.length-1; j >offset; j--) {
outBytes[j] = (byte)((outBytes[j])|((outBytes[j-1]&0xff)>>3));
outBytes[j-1]<<=5;
}
outBytes[offset]|=3;
writeLen(outBytes,bytes);
return outBytes;
}
public static byte[] decrypt1(byte[] bytes) {
int offset = 4+2;
byte[] outBytes = new byte[bytes.length - 1];
int i = 0;
for (int j = offset;j<outBytes.length;j++) {
if(i==KEY.length)i=0;
outBytes[j] = (byte)(( (bytes[j]&0xff) >> 5) | (bytes[j+1] <<3));
outBytes[j] ^=KEY[i++];
}
writeLen(outBytes,bytes);
return outBytes;
}
private static void writeLen(byte[] outBytes,byte[] bytes){
outBytes[0]= (byte) (outBytes.length&0xff);
outBytes[1]= (byte) ((outBytes.length>>8)&0xff);
outBytes[2]= (byte) ((outBytes.length>>16)&0xff);
outBytes[3]= (byte) ((outBytes.length>>24)&0xff);
outBytes[4]=bytes[4];
outBytes[5]=bytes[5];
}
参考连接:
本文由 ukuq 创作,采用 知识共享署名4.0 国际许可协议进行许可
本站文章除注明转载/出处外,均为本站原创或翻译,转载前请务必署名
最后编辑时间为: Jun 4, 2020 at 11:26 pm
有幸看到这篇文章,博主太厉害了!!看到楼下的评论,盲看这段反编译代码的内容,貌似generateNewSequenceNum这个函数主要作用是将某3个参数形成的参数序列添加作为日志,方便日后淘米工作人员做日志分析?具体也不是太懂。。。
generateNewSequenceNum这个好像没有分析耶
这个确实没分析出来/捂脸
这可是关键!冲!
这些都是很早之前搞的了,现在想想,可能相关的逻辑就在binaryData里面,至于具体是啥?这就交给大佬们去探究了,不过我觉得这个没啥探究的必要了,不是什么核心内容
真不错