flash页游seer2折腾日记5-协议的加解密

in 折腾一下 with 6 comments

前些天在逛 52 时发现了两篇文章,分别是对赛尔号和摩尔庄园两个页游加密算法的逆向和复现,深有感触。于是我决定分享一下我是如何破解 seer2(现已改名为约瑟传说),分析出它的加密过程的。

所用工具

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 就可以打开该文件了。

image-20200603222056676.png

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_str5S__2E_rodata的第 208 个地址偏移。

public const L__2E_str5:int = S__2E_rodata + 208;

继续查找S__2E_rodata可以找到

image-20200603224731264.png

追踪到这里就卡壳了,allocDataSect 部分更加繁琐,不易阅读。但是从它的含义来看,可以猜测,它是分配了一个大小为 21184 字节的空间,那么极有可能,这个 21184 是一个文件的大小,那么从哪里能找到这个文件呢。

swf 中确实有个地方可以存放二进制文件,那就是 binaryData,导出 binaryData 中的这 23 个文件。

image-20200603225346841.png

导出后,可以发现,确实有一个文件大小刚好是 21184 字节。

image-20200603225618966.png

那么这个文件的第208(0xd0)个偏移是什么呢?

image-20200603230052666.png

没错,就是taomee_seer2_k_~#,这个就应该是是 s2 加解密用的密钥了。

除了猜测,还要找到证据。

同一个包下,有一个很可疑的类,类名与众不同,特别长。

image-20200603230856829.png

继续追踪 DS2类可以发现,它实际上就是在读取前面找到的 21184 文件。

那么到这里就可以确定,密钥就是上面的taomee_seer2_k_~#

算法验证

这样,我们就获得了上面的加密方法,同理,也可以找到解密方法。

我这里给出了 java 实现,附在文末了。

通过对 Client.as 分析可知,网通登录时,客户端会先向http://cncsr2login.61.com/ip.txt提供的服务器地址发送登录信息,获取到一个 session 和 若干个游戏用的服务器地址。此阶段数据均未加密,可以自己搭建代理服务器捕获这些数据。

image-20200604230422671.png

之后,客户端将会和上面的的服务器之一建立连接,直到用户关闭网页。此间,通信均为加密过的,我们就选择捕获这一部分的数据。

下面是捕获的一次数据。

[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];
}

参考连接:

摩尔庄园页游数据包加密算法逆向及复现

flash页游seer2折腾日记4-通信协议

上一篇: onedrive 无管理员账户如何调用 api
下一篇: 如何为wegame版三国杀绑定手机号
Responses
  1. fqyd

    有幸看到这篇文章,博主太厉害了!!看到楼下的评论,盲看这段反编译代码的内容,貌似generateNewSequenceNum这个函数主要作用是将某3个参数形成的参数序列添加作为日志,方便日后淘米工作人员做日志分析?具体也不是太懂。。。

    Reply
  2. mole

    generateNewSequenceNum这个好像没有分析耶

    Reply
    1. @mole

      这个确实没分析出来/捂脸

      Reply
      1. mole
        @ukuq

        这可是关键!冲!

        Reply
        1. @mole

          这些都是很早之前搞的了,现在想想,可能相关的逻辑就在binaryData里面,至于具体是啥?这就交给大佬们去探究了,不过我觉得这个没啥探究的必要了,不是什么核心内容

          Reply
  3. Am

    真不错

    Reply