吾爱破解 - 52pojie.cn

 找回密码
 注册[Register]

QQ登录

只需一步,快速开始

查看: 5228|回复: 71
上一主题 下一主题
收起左侧

[原创] 赛尔号逆向:封包捕获与分析

  [复制链接]
跳转到指定楼层
楼主
niupaiman 发表于 2025-8-13 22:39 回帖奖励

文章内容完善中......

前言

作者是一名赛尔号PVP玩家,最近胜率一直不高,因此决定制作一款巅峰辅助工具。

本人此前从未接触过网络通信这一块,基本是现学现用,因此可能会存在部分问题,还望大佬们指出!

本文所用工具如下:

工具 描述
Fiddler Classic 抓包工具
JPEXS Free Flash Decompiler Flash文件反编译工具
x64dbg 程序调试器

项目开源地址:https://github.com/dauphinYan/SeerAssistant

获取Flash文件并反编译

本阶段用到的工具:Fiddler Classic、JPEXS Free Flash Decompiler。

这部分暂时跳过...(可以参考文末链接)

反编译成功后,我们会得到下述四个文件夹:

Client
RobotAppDLL
RobotCoreDLL
TaomeeLibrary

尝试读源码!

通信数据解密

我们可以在前面反编译得到的TaomeeLibrary文件夹中搜索socket,毕竟网络通信都需要使用到socket,所以可以使用这种方法快速接近目标。然后,我们发现了十分显眼的MDecrypt.as,赶快进去瞧瞧!

可以看到MDecrypt.as的主要函数为:

public function MDecrypt(param1:ByteArray, param2:int, param3:ByteArray) : void

因此我们需要弄清传入的三个参数对应的含义。

同样,在TameeLibrary文件夹中搜索MDecrypt(,这里有一个技巧,在末尾添加(就可以快速找到该函数在哪里实现以及调用。

现在可以清楚的看到在MessageEncrypt.as中有调用,我们进去看看。

private static var NO_ENCRYPT_LEN:int = 4;  

public static function decrypt(inData:ByteArray) : ByteArray
  {
     var inLen:int = inData.readUnsignedInt() - NO_ENCRYPT_LEN;
     var outData:ByteArray = new ByteArray();
     outData.writeUnsignedInt(0);
     MDecrypt(inData,inLen,outData);
     outData.position = 0;
     outData.writeUnsignedInt(outData.length);
     outData.position = 0;
     return outData;
  }

通过这个函数实现,可以大胆猜测:

  • InData:传入的数据包。
  • inLen:真正需要解密的部分。
  • outData:解密后的数据包。
  • NO_ENCRYPT_LEN:记录数据包的长度信息。(这里大胆猜测,前四字节为包体长度)

为什么这么猜测呢?因为数据在传递的过程中,数据不是逐字节的发送的,而是以数据包的形式成批发送,那么发送方为了让接收方区分接收到的数据,通常会在数据包头部保留明文片段用于标识数据包的长度。

MDecrypt.as源码可读性比较差,有兴趣可以自行阅读分析,使用C++重写的源码可见下方。

C++重写后的解密源码:

std::vector<uint8_t> Cryptor::Decrypt(const std::vector<uint8_t> &Cipher)
{
    size_t len = Cipher.size();
    if (len == 0 || Key.empty())
        return {};

    // 计算旋转量
    int result = Key[(len - 1) % Key.size()] * 13 % len;

    // 环形右移 result 个字节
    std::vector<uint8_t> rotated = Merge(
        Slice(Cipher, len - result, len),
        Slice(Cipher, 0, len - result));

    // 右移/左移组合恢复原始字节序列(去掉最后一个字节)
    std::vector<uint8_t> plain(len - 1);
    for (size_t i = 0; i < len - 1; ++i)
    {
        plain[i] = static_cast<uint8_t>((rotated[i] >> 5) | (rotated[i + 1] << 3));
    }

    // 异或解密
    size_t j = 0;
    bool NeedBecomeZero = false;
    for (size_t i = 0; i < plain.size(); ++i)
    {
        if (j == 1 && NeedBecomeZero)
        {
            j = 0;
            NeedBecomeZero = false;
        }
        if (j == Key.size())
        {
            j = 0;
            NeedBecomeZero = true;
        }
        plain[i] = static_cast<uint8_t>(plain[i] ^ Key[j]);
        ++j;
    }

    return plain;
}

不过有一点需要注意,在官方的源码中

_loc11_ = CModule.mallocString(getDefinitionByName("com.robot.core.net.SocketConnection").key);

_loc11_在很多处都有参与运算,因此我们需要弄清楚这个值是怎么来的。

根据路径找到SocketConnection.as,发现关键函数key()

private static var _encryptKeyStringArr:Array;

public static function get key() : String
{
 var _loc2_:int = 0;
 var _loc3_:String = null;
 var _loc1_:String = "";
 if(_encryptKeyStringArr == null)
 {
    _loc1_ = "!crAckmE4nOthIng:-)";
 }
 else
 {
    _loc2_ = 0;
    while(_loc2_ < _encryptKeyStringArr.length)
    {
       _loc3_ = StringUtil.replace(_encryptKeyStringArr[_loc2_],"*","");
       _loc1_ += _loc3_;
       _loc2_++;
    }
 }
 return _loc1_;
}

不难知道,默认情况下key的值为!crAckmE4nOthIng:-),这个密钥看着很像某些单词的缩写,简单解读一下。

  • crAckmE:Crack me.
  • 4:for.
  • nOthIng:Nothing.

连起来也就是:

Crack me for nothing.   试着破解我吧,反正也没啥有用的东西😊

这里感谢”圆圆圆“提供的解释。

显然,这个key在后面肯定会被修改,我们在这个文件中又找到的修改函数:

public static function setEncryptKeyStringArr(param1:Array) : void
{
 _encryptKeyStringArr = param1;
}

因此在文件夹中搜索setEncryptKeyStringArr(,得到结果如下图所示:

只找到一处该方法的调用,和我们的预期有所差距:

  private static function onSocketClose(param1:Event) : void
  {
     var event:Event = param1;
     DebugTrace.show("////////////////////////////////////////////////////////////////////////////////\r//\r//\t\t\t\t" + "socket was closed\r//\r////////////////////////////////////////////////////////////////////////////////");
     try
     {
        SocketConnection.mainSocket.removeEventListener(Event.CLOSE,onSocketClose);
        SocketConnection.setEncryptKeyStringArr(null);
        SocketConnectionHelper.clear();
        ModuleManager.hideAllModule();
        if(login10004)
        {
           return;
        }
        Alarm.show("此次连接已经断开,请重新登录",function():void
        {
           if(ExternalInterface.available)
           {
              navigateToURL(new URLRequest("javascript:window.location.reload();"),"_self");
           }
           else
           {
              navigateToURL(new URLRequest("https://seer.61.com"),"_self");
           }
        },false,true,false,LevelManager.stage);
     }
     catch(e:Error)
     {
     }
  }

可以看到这部分发生在与游戏服务器断开的阶段,并没有达到修改key的目的,现在就比较麻烦了。

既然如此,那我们先去开一把休息一下,羁绊后的伽马强度还是太爆炸了......

数据包格式解析

在找key之前呢,可以先看看官方是如何解析数据包的,前面我们知道MessageEncrypt.as有一个decrypt(函数,很明显,这是一个套壳函数,没有解析数据的具体实现。因此文件夹中搜索decrypt(,可以定位到SocketEncryptImpl.as,其中包含一个函数:

  private function onData(e:Event) : void
  {
     var msgLen:int = 0;
     var ba:ByteArray = null;
     DebugTrace.show("socket onData handler....................");
     this._chunkBuffer.clear();
     if(this._tempBuffer.length > 0)
     {
        this._tempBuffer.position = 0;
        this._tempBuffer.readBytes(this._chunkBuffer,0,this._tempBuffer.length);
        this._tempBuffer.clear();
     }
     readBytes(this._chunkBuffer,this._chunkBuffer.length,bytesAvailable);
     this._chunkBuffer.position = 0;
     while(this._chunkBuffer.bytesAvailable > 0)
     {
        if(this._chunkBuffer.bytesAvailable > MSG_FIRST_TOKEN_LEN)
        {
           msgLen = this._chunkBuffer.readUnsignedInt() - MSG_FIRST_TOKEN_LEN;
           if(this._chunkBuffer.bytesAvailable >= msgLen)
           {
              this._chunkBuffer.position -= MSG_FIRST_TOKEN_LEN;
              ba = MessageEncrypt.decrypt(this._chunkBuffer);           // 在这里!
              this.parseData(ba);
           }
           else
           {
              this._chunkBuffer.position -= MSG_FIRST_TOKEN_LEN;
              this._chunkBuffer.readBytes(this._tempBuffer,0,this._chunkBuffer.bytesAvailable);
           }
        }
        else
        {
           this._chunkBuffer.readBytes(this._tempBuffer,0,this._chunkBuffer.bytesAvailable);
        }
     }
  }

在解密函数的下方,有一个ParseData函数,翻译过来就是”解析数据“。那么我们定位到那里去:

private static const HEAD_LENGTH:uint = 17;

private function parseData(data:ByteArray) : void
  {
     var info:ByteArray = null;
     var tmfClass:Class = null;
     this._packageLen = data.readUnsignedInt();
     if(this._packageLen < HEAD_LENGTH || this._packageLen > PACKAGE_MAX)
     {
        this.readDataError(0);
        dispatchEvent(new SocketErrorEvent(SocketErrorEvent.ERROR,null));
        data.readBytes(new ByteArray());
        return;
     }
     this._headInfo = new HeadInfo(data);
     if(this._headInfo.cmdID == 1001)
     {
        this._result = this._headInfo.result;
     }
     DebugTrace.show("<<Socket[" + this.ip + ":" + this.port.toString() + "][cmdID:" + this._headInfo.cmdID + "]",getCmdLabel(this._headInfo.cmdID));
     if(this._headInfo.result > 1000)
     {
        this.readDataError(this._headInfo.cmdID);
        this.dispatchError(this._headInfo.cmdID,this._headInfo);
        dispatchEvent(new SocketErrorEvent(SocketErrorEvent.ERROR,this._headInfo));
        return;
     }
     this._dataLen = this._packageLen - HEAD_LENGTH;
     if(this._dataLen == 0)
     {
        this.readDataError(this._headInfo.cmdID);
        this.dispatchCmd(this._headInfo.cmdID,this._headInfo,null);
     }
     else
     {
        info = new ByteArray();
        data.readBytes(info,0,this._dataLen);
        tmfClass = TMF.getClass(this._headInfo.cmdID);
        this.readDataError(this._headInfo.cmdID);
        this.dispatchCmd(this._headInfo.cmdID,this._headInfo,new tmfClass(info));
     }
  }

可以看到,Debug输出与_headInfo有关,那么同样的方式,我们定位到HeadInfo

  public function HeadInfo(headData:IDataInput)
  {
     super();
     this._version = headData.readUTFBytes(1);      // 版本号,1字节
     this._cmdID = headData.readUnsignedInt();      // 命令号,4字节
     this._userID = headData.readUnsignedInt();     // 用户号,4字节
     this._result = headData.readInt();             // 序列号,4字节
  }

再次阅读代码,现在封包的结构就十分明确了:

// 封包长度, 4字节|版本号,1字节|命令号,4字节|用户号,4字节|序列号,4字节|包体数据

this._packageLen = data.readUnsignedInt();          // 封包长度, 4字节

this._headInfo = new HeadInfo(data);                // 头部信息,13字节

this._dataLen = this._packageLen - HEAD_LENGTH;     // 包体长度

这里补充一下,我们会发现在parseData()函数中,有一处命令ID的判断:

 if(this._headInfo.cmdID == 1001)
 {
    this._result = this._headInfo.result;
 }

可以尝试在文件夹中搜索cmdID,接着我们会发现:

这里有一个cmdID的值为41129,接着我们继续搜索41129,然后我们会找到CommandID.as文件夹,这里面就包含了所有命令的ID,下面截取部分内容:

package com.robot.core
{
   public class CommandID
   {

      public static const LOADING_TIME_STAT:uint = 9303;

      public static const GET_PET_TOWER_ACHIEVE:uint = 3453;

      public static const RECEIVE_PRANKSTER_REWARD:uint = 1014;

      public static const TOPFIGHT_RANKING_LIST:uint = 2459;

      public static const TOPFIGHT_GET_AWARD:uint = 9374;

      public static const TOPFIGHT_WEEK_WIN:uint = 2532;

      public static const TOPFIGHT_BEYOND:uint = 2567;
   }
}

找到这个文件的方法有很多,这里只列举其中一种,最开始找到这个文件的方法我忘记了,上述方法是临时找的。

找出修改Key的位置

回到这里,既然找不到修改key的位置,我们不妨先使用当前已经得到的解密算法对接收到的数据进行解密,这里选用Hook注入的方法(现在不清楚不用着急,后面会介绍的),解密得到的信息如下:

[2025-07-03 15:22:54.849] [ThreadID 0xfb8] [Temp] [Hooked send] Parsed Data:[Length=17 Version=49 CmdID=111 Cmd=———— UserID=123456789 SN=0 BodySize=0] 
[2025-07-03 15:22:54.874] [ThreadID 0xfb8] [Temp] [Hooked recv] Parsed Data:[Length=21 Version=0 CmdID=111 Cmd=———— UserID=123456789 SN=0 BodySize=4] Body=[00 00 00 00 ]
[2025-07-03 15:22:54.878] [ThreadID 0xfb8] [Temp] [Hooked send] Parsed Data:[Length=37 Version=49 CmdID=109 Cmd=SYS_ROLE UserID=123456789 SN=0 BodySize=20] Body=[a4 0e a7 cc 84 21 87 f2 f6 91 51 9a 7a 0e b7 6e ...]
[2025-07-03 15:22:54.903] [ThreadID 0xfb8] [Temp] [Hooked recv] Parsed Data:[Length=21 Version=0 CmdID=109 Cmd=SYS_ROLE UserID=123456789 SN=0 BodySize=4] Body=[00 00 00 00 ]
[2025-07-03 15:22:54.905] [ThreadID 0xfb8] [Temp] [Hooked send] Parsed Data:[Length=37 Version=49 CmdID=105 Cmd=COMMEND_ONLINE UserID=123456789 SN=0 BodySize=20] Body=[a4 0e a7 cc 84 21 87 f2 f6 91 51 9a 7a 0e b7 6e ...]
[2025-07-03 15:22:54.934] [ThreadID 0xfb8] [Temp] [Hooked recv] Parsed Data:[Length=561 Version=0 CmdID=105 Cmd=COMMEND_ONLINE UserID=123456789 SN=0 BodySize=544] Body=[00 00 0b 99 00 00 00 03 00 79 ec 33 00 00 00 02 ...]
[2025-07-03 15:23:06.439] [ThreadID 0xfb8] [Temp] [Hooked send] Parsed Data:[Length=141 Version=49 CmdID=1001 Cmd=LOGIN_IN UserID=123456789 SN=202 BodySize=124] Body=[a4 0e a7 cc 84 21 87 f2 f6 91 51 9a 7a 0e b7 6e ...]
[2025-07-03 15:23:06.989] [ThreadID 0xfb8] [Temp] [Hooked recv] Parsed Data:[Length=171 Version=62 CmdID=8002 Cmd=SYSTEM_MESSAGE UserID=123456789 SN=0 BodySize=154] Body=[00 01 88 43 68 66 2f da 00 00 00 8e 20 e7 b2 be ...]
[2025-07-03 15:23:06.995] [ThreadID 0xfb8] [Temp] [Hooked recv] Parsed Data:[Length=171 Version=62 CmdID=8002 Cmd=SYSTEM_MESSAGE UserID=123456789 SN=0 BodySize=154] Body=[00 01 88 43 68 66 2f da 00 00 00 8e 20 e7 b2 be ...]
[2025-07-03 15:23:06.996] [ThreadID 0xfb8] [Temp] [Hooked recv] Parsed Data:[Length=113 Version=62 CmdID=8002 Cmd=SYSTEM_MESSAGE UserID=123456789 SN=0 BodySize=96] Body=[00 01 88 43 68 66 2f da 00 00 00 54 e5 a6 82 e6 ...]
[2025-07-03 15:23:06.996] [ThreadID 0xfb8] [Temp] [Hooked recv] Parsed Data:[Length=129 Version=62 CmdID=8002 Cmd=SYSTEM_MESSAGE UserID=123456789 SN=0 BodySize=112] Body=[00 00 00 03 68 66 2f da 00 00 00 64 20 e4 bd a0 ...]
[2025-07-03 15:23:06.997] [ThreadID 0xfb8] [Temp] [Hooked recv] Parsed Data:[Length=25 Version=62 CmdID=3452 Cmd=LASTVALUE UserID=123456789 SN=0 BodySize=8] Body=[00 00 00 7f 00 00 00 32 ]
[2025-07-03 15:23:06.997] [ThreadID 0xfb8] [Temp] [Hooked recv] Parsed Data:[Length=25 Version=62 CmdID=3452 Cmd=LASTVALUE UserID=123456789 SN=0 BodySize=8] Body=[00 00 00 63 00 00 00 02 ]
[2025-07-03 15:23:06.997] [ThreadID 0xfb8] [Temp] [Hooked recv] Parsed Data:[Length=25 Version=62 CmdID=2394 Cmd=PET_BOOK_UPDATE UserID=123456789 SN=0 BodySize=8] Body=[00 00 05 1c 00 00 00 01 ]
[2025-07-03 15:23:06.998] [ThreadID 0xfb8] [Temp] [Hooked recv] Parsed Data:[Length=25 Version=62 CmdID=3452 Cmd=LASTVALUE UserID=123456789 SN=0 BodySize=8] Body=[00 00 00 2b 00 00 00 3c ]
[2025-07-03 15:23:06.998] [ThreadID 0xfb8] [Temp] [Hooked recv] Parsed Data:[Length=25 Version=62 CmdID=3452 Cmd=LASTVALUE UserID=123456789 SN=0 BodySize=8] Body=[00 00 00 2b 00 00 00 00 ]
[2025-07-03 15:23:06.998] [ThreadID 0xfb8] [Temp] [Hooked recv] Parsed Data:[Length=25 Version=62 CmdID=3452 Cmd=LASTVALUE UserID=123456789 SN=0 BodySize=8] Body=[00 00 00 2b 00 00 01 54 ]
[2025-07-03 15:23:06.998] [ThreadID 0xfb8] [Temp] [Hooked recv] Parsed Data:[Length=25 Version=62 CmdID=3452 Cmd=LASTVALUE UserID=123456789 SN=0 BodySize=8] Body=[00 00 00 2b 00 00 08 8b ]
[2025-07-03 15:23:06.999] [ThreadID 0xfb8] [Temp] [Hooked recv] Parsed Data:[Length=25 Version=62 CmdID=3452 Cmd=LASTVALUE UserID=123456789 SN=0 BodySize=8] Body=[00 00 00 2b 00 00 0a 31 ]
[2025-07-03 15:23:07.031] [ThreadID 0xfb8] [Temp] [Hooked recv] Parsed Data:[Length=3413 Version=62 CmdID=1001 Cmd=LOGIN_IN UserID=123456789 SN=46 BodySize=3396] Body=[0d b2 32 92 4d 71 dc 79 e7 89 9b e6 8e 92 00 00 ...]

这里只截取正常的部分(UserID我就用"123456"代替了),从接收到LOGIN_IN指令以后,数据变得非常奇怪,也就是说,key值的改变发生在接收到LOGIN_IN指令后!

在文件夹中搜索LOGIN_IN,结果不多,挨个查看下,最后锁定在MainEntry.as

  private static function onLogin(param1:SocketEvent) : void
  {
     if(SaveUserInfo.loginIDInfo.loginType == 3)
     {
        StatManager.sendStat2014("手机验证码注册/登录","【手机验证码登录玩家】登录ONLINE服","手机验证码");
     }
     if(MainManager.isNewUser)
     {
        StatManager.sendStat2014("_newtrans_","fOnlineSucc","");
     }
     if(!isReconnect)
     {
        SocketConnection.removeCmdListener(CommandID.LOGIN_IN,onLogin);
        EventManager.addEventListener(RobotEvent.CREATED_ACTOR,onCreatedActor);
     }
     else
     {
        SocketConnection.removeCmdListener(CommandID.RELOGIN_IN,onLogin);
     }
     var _loc2_:int = 0;
     if(isReconnect)
     {
        _loc2_ = int(MainManager.actorInfo.mapID);
     }
     MainManager.setup(param1.data,isReconnect,_loc2_);
     var _loc3_:ByteArray = param1.data as ByteArray;
     var _loc4_:int = int(_loc3_.readUnsignedInt());
     initKey(_loc4_);
     MainEntry.login10004 = false;
     LevelManager.openMouseEvent();
     if(isReconnect)
     {
        MainManager.reconnectRequest();
        EventManager.dispatchEvent(new Event(ReconnectCompleteController.RECONNECTSUC));
     }
     if(!isReconnect)
     {
        MapConfig.setup();
        MapSeatPointConfig.setup();
     }
     sendSystemInfo();
     SocketConnection.send(1022,86066824);
  }

然后我们会发现一个特别显眼的函数:initKey(_loc4_)

  private static function initKey(param1:int) : void
  {
     var _loc2_:String = "c&o&m.--rob-ot.c--o-r-e.&n-et.S-oc-ke-t&C-on-n-e-c-t-i-on";
     var _loc3_:* = "s*e*tE&&&n*c";
     _loc3_ += "r*yp*t&&&&Ke*yS*tr*i&n&&g*Arr";
     _loc2_ = StringUtil.replace(_loc2_,"-","");
     _loc2_ = StringUtil.replace(_loc2_,"&","");
     _loc3_ = StringUtil.replace(_loc3_,"*","");
     _loc3_ = StringUtil.replace(_loc3_,"&","");
     param1 ^= MainManager.actorInfo.userID;
     var _loc4_:String = MD5.hash(param1 + "");
     var _loc5_:* = MainManager.actorInfo.userID + "";
     var _loc6_:Array = [];
     var _loc7_:int = 0;
     while(_loc7_ < 10)
     {
        _loc6_[_loc7_] = "*" + _loc4_.charAt(_loc7_) + "*";
        _loc7_++;
     }
     getDefinitionByName(_loc2_)[_loc3_](_loc6_);
  }

处理一下,就可以的得到:

_loc2_ = "com.robot.core.net.SocketConnection";
_loc3_ = "setEncryptKeyStringArr";

这种避免直接通过字符串搜索的方法学到了!

发现没,_loc3_就是我们前面只能找到一处调用的方法setEncryptKeyStringArr

制作我们自己的Hook

本文中有什么作用?

在本文中,我们的最终目的是对网络通信中的数据包进行解密,因此如何捕获数据非常重要。为了实现这一目的,就需要请出本节主角:Hook!

什么是Hook?

Hook,又称钩子,是一种能够拦截和修改函数或方法的技术。它允许开发者在不修改原有的代码前提下,修改程序的行为。

例如在网络通信中,我们通过我们通过Hook拦截网络API的调用,例如sendrecv等,这样就可以捕获网络通信中的一段十六进制数据:

3c 3f 78 6d 6c 20 76 65 72 73 69 6f 6e 3d 22 31 2e 30 22 3f 3e 3c 21 44 4f 43 54 59 50 45 20 63 72 6f 73 73 2d 64 6f 6d 61 69 6e 2d 70 6f 6c 69 63 79 3e 3c 63 72 6f 73 73 2d 64 6f 6d 61 69 6e 2d 70 6f 6c 69 63 79 3e 3c 61 6c 6c 6f 77 2d 61 63 63 65 73 73 2d 66 72 6f 6d 20 64 6f 6d 61 69 6e 3d 22 2a 22 20 74 6f 2d 70 6f 72 74 73 3d 22 2a 22 20 2f 3e 3c 2f 63 72 6f 73 73 2d 64 6f 6d 61 69 6e 2d 70 6f 6c 69 63 79 3e 00

将其转换为ASCII字符就可以得到:

<?xml version="1.0"?>
<!DOCTYPE cross-domain-policy>
<cross-domain-policy>
    <allow-access-from domain="*" to-ports="*" />
</cross-domain-policy>

当然了,这个肯定是我们不需要的数据。

总的来说,Hook在本文的作用就是捕获我们需要分析的数据包。

Hook注入

一般情况下,Hook都是以DLL的形式注入到进程中,然后对进程进行修改,所以我们也需要制作DLL文件。

考虑到使用Windows原生API实现Hook难度不小,因此本文采用第三方开源库MinHook

链接:https://github.com/TsudaKageyu/minhook

MinHook的实现就非常方便了,大致流程如下:

// 初始化
MH_STATUS WINAPI MH_Initialize(VOID);

// 创建钩子 pTarget:需要替换的目标函数地址。 pDetour:我们自己创建的函数地址。 ppOriginal:用于保存原始函数地址(方便绕过Hook)
MH_STATUS WINAPI MH_CreateHook(LPVOID pTarget, LPVOID pDetour, LPVOID *ppOriginal);

// 启动钩子
MH_STATUS WINAPI MH_EnableHook(LPVOID pTarget)

// 关闭钩子
MH_STATUS WINAPI MH_DisableHook(LPVOID pTarget)

// 收工后的一些处理
MH_STATUS WINAPI MH_Uninitialize(VOID);

关于MH_CreateHook传入参数如何得到,请自行查看SocketHook.cpp

那么我们该如何捕获数据呢?这里就以捕获接收包为例子:

/* 
S:当前接收数据的端点。
BufferPtr:指向缓冲区的指针,存放S接收的数据。
Length:缓冲区的大小。
Flag:接收标志位,控制接收方式。(本文没有用到)
*/
int WINAPI RecvEvent(SOCKET S, char *BufferPtr, int Length, int Flag)
{
    int Result = OriginalRecv(S, BufferPtr, Length, Flag); // 实际接收到的长度。

    if (g_hookEnabled && Result > 0)
    {
        std::lock_guard<std::mutex> lock(g_DataMutex);

        std::vector<char> Temp(BufferPtr, BufferPtr + Result);

        // 处理本次接收到的数据。
        PacketProcessor::ProcessRecvPacket(S, Temp, Result);
    }

    return Result;
}
开始处理数据包!

现在接收到数据后,就需要想办法对数据进行处理了。

首先我创建了PacketProcessor类,用于处理这些数据。

struct PacketData
{
    int32_t Length;  // 包长 4字节
    uint8_t Version; // 版本 1字节
    int32_t CmdID;   // 命令号 4字节
    int32_t UserID;  // 米米号 4字节
    int32_t SN;      // 序列号 4字节
    std::vector<uint8_t> Body;

    void LogCout(bool bIsSend) const;
};

class PacketProcessor
{
public:
    static void ProcessRecvPacket(SOCKET Socket, const vector<char> &Data, int Length);

    static PacketData ParsePacket(const vector<uint8_t> &Packet);

    static bool ShouldDecrypt(const vector<uint8_t> &Cipher);
    static vector<uint8_t> DecryptPacket(const vector<uint8_t> &Cipher);

    static void Logining(PacketData &InPacketData);

private:
    static vector<uint8_t> s_RecvBuf;
    static size_t s_RecvBufIndex;
    static size_t s_RecvBufLen;
    static size_t s_RecvNum;
    static SOCKET s_CurrentSocket;
    static bool s_HaveLogin;
    static size_t s_SN;
    static int32_t s_UserID;
};

在介绍最关键的ProcessRecvPacket前呢,需要先了解一些基础知识:粘包拆包大端小端

粘包和拆包

在使用TCP协议进行数据传输的过程中,TCP会将发送方的数据存储在缓冲区中,并根据网络状况优化数据包的大小进行传输,也就是说,TCP一次传递报文段可能由多个小的数据包组成,接收方接收时就可能出现多个数据包“粘”在一起的现象,这种情况就是粘包;同样的,如果单个数据包过大,超过了TCP 报文段的最大传输单元,那么TCP会将这个大的数据包拆分,并分为多个报文段进行传输,这样就造成了拆包。

大端和小端

大小端是多字节数据的存储方式,它决定一个多字节类型在内存中按照什么顺序存放各个字节。

简单来说,对于作为正常人的我们来说,对于123这个数字,我们都会将其读作“一百二十三”,也就是大端,即高位在前,低位在后;但是,处理器不这么认为,它是怎么读的呢?

“三百二十一”

也就是说,它与正常人读取的方式完全相反,高位在后,低位在前。下方是比较专业的例子:

整数 0x12345678 的字节排列:

地址:   0x00  0x01  0x02  0x03
大端:   0x12  0x34  0x56  0x78
小端:   0x78  0x56  0x34  0x12
ProcessRecvPacket

先贴代码

void PacketProcessor::ProcessRecvPacket(SOCKET Socket, const vector<char> &Data, int Length)
{
    PacketData RecvPacketData = PacketData();

    s_RecvBuf.insert(s_RecvBuf.end(), Data.begin(), Data.begin() + Length);

    // 是否是同一连接。
    if (s_CurrentSocket != Socket)
    {
        s_RecvBufIndex += Length;

        // 此时索引等于缓冲区长度,则说明刚好取完此包。
        if (s_RecvBufIndex == s_RecvBuf.size())
        {
            s_RecvBuf.clear();
            s_RecvBufIndex = 0;
        }

        return;
    }

    while (true)
    {
        size_t Remain = s_RecvBuf.size() - s_RecvBufIndex;

        // 不足包头长度,需等待。
        if (Remain < sizeof(uint32_t))
            break;

        // 读包。
        uint32_t PacketLength = 0;
        memcpy(&PacketLength, &s_RecvBuf[s_RecvBufIndex], sizeof(PacketLength));
        PacketLength = ntohl(PacketLength);

        // 包未齐,需等待。
        if (Remain < PacketLength)
            break;

        vector<uint8_t> Cipher(s_RecvBuf.begin() + s_RecvBufIndex, s_RecvBuf.begin() + s_RecvBufIndex + PacketLength);

        vector<uint8_t> Plain = ShouldDecrypt(Cipher) ? DecryptPacket(Cipher) : Cipher;

        RecvPacketData = ParsePacket(Plain);
        ++s_RecvNum;
        RecvPacketData.LogCout(false);

        // 如果是登录包
        if (RecvPacketData.CmdID == 1001)
        {
            Logining(RecvPacketData);
            s_CurrentSocket = Socket;
            s_SN = RecvPacketData.SN;
            s_UserID = RecvPacketData.UserID;
            s_HaveLogin = true;
        }

        s_RecvBufIndex += PacketLength;

        if (s_RecvBufIndex == s_RecvBuf.size())
        {
            s_RecvBuf.clear();
            s_RecvBufIndex = 0;
            break;
        }

        if (s_RecvBufIndex > 0)
        {
            s_RecvBuf.erase(s_RecvBuf.begin(), s_RecvBuf.begin() + s_RecvBufIndex);
            s_RecvBufIndex = 0;
            // s_CurrentSocket = Socket;
        }
    }
}

私以为其中的重点为粘包拆包以及登录包的处理的解决方法。

先说粘包拆包

while (true)
{
    size_t Remain = s_RecvBuf.size() - s_RecvBufIndex;

    // 不足包头长度,需等待。
    if (Remain < sizeof(uint32_t))
        break;

    // 读包。
    uint32_t PacketLength = 0;
    memcpy(&PacketLength, &s_RecvBuf[s_RecvBufIndex], sizeof(PacketLength));
    PacketLength = ntohl(PacketLength);

    // 包未齐,需等待。
    if (Remain < PacketLength)
        break;

    vector<uint8_t> Cipher(s_RecvBuf.begin() + s_RecvBufIndex, s_RecvBuf.begin() + s_RecvBufIndex + PacketLength);

    vector<uint8_t> Plain = ShouldDecrypt(Cipher) ? DecryptPacket(Cipher) : Cipher;

    RecvPacketData = ParsePacket(Plain);
    ++s_RecvNum;
    RecvPacketData.LogCout(false);

    // 如果是登录包
    if (RecvPacketData.CmdID == 1001)
    {
        Logining(RecvPacketData);
        s_CurrentSocket = Socket;
        s_SN = RecvPacketData.SN;
        s_UserID = RecvPacketData.UserID;
        s_HaveLogin = true;
    }

    s_RecvBufIndex += PacketLength;

    if (s_RecvBufIndex == s_RecvBuf.size())
    {
        s_RecvBuf.clear();
        s_RecvBufIndex = 0;
        break;
    }

    if (s_RecvBufIndex > 0)
    {
        s_RecvBuf.erase(s_RecvBuf.begin(), s_RecvBuf.begin() + s_RecvBufIndex);
        s_RecvBufIndex = 0;
        // s_CurrentSocket = Socket;
    }

我创建了多个全局变量用于处理上述问题:

  • s_RecvBuf接收缓冲区,用于缓存接收来的数据流。
  • s_RecvBufIndex当前处理到的接收缓冲区索引

大致流程如下:

  1. 判头长,先判断包头长度是否足够,不够则继续等待新的包。
  2. 读包头,将网络字节序(大端)转换为机器读法(小端)得到包长。
  3. 判包长,判断当前缓冲区的包是否完整,不完整继续等。
  4. 分析包,提取完整的数据包,进行解密分析。
  5. 清库存,更新缓冲区的状态,确保不会影响下一轮的分析。

再说登陆包的处理:

if (RecvPacketData.CmdID == 1001)
{
    Logining(RecvPacketData);
    s_CurrentSocket = Socket;
    s_SN = RecvPacketData.SN;
    s_UserID = RecvPacketData.UserID;
    s_HaveLogin = true;
}

非常的清晰,如果命令号为1001,就代表此时为登录包,然后依照官方处理方法,对key进行处理。

void PacketProcessor::Logining(PacketData &InPacketData)
{
    if (InPacketData.Body.size() < 4)
    {
        return;
    }

    // 1. 取尾 4 字节并按“大端”组装
    size_t n = InPacketData.Body.size();
    uint32_t tail4 = (static_cast<uint32_t>(InPacketData.Body[n - 1])) | (static_cast<uint32_t>(InPacketData.Body[n - 2]) << 8) | (static_cast<uint32_t>(InPacketData.Body[n - 3]) << 16) | (static_cast<uint32_t>(InPacketData.Body[n - 4]) << 24);

    // 2. 异或 userId
    uint32_t xorRes = tail4 ^ static_cast<uint32_t>(InPacketData.UserID);

    // 3. 转为字符串
    std::string plain = std::to_string(xorRes);

    // 4. 计算 MD5
    MD5 md5;
    md5.update(reinterpret_cast<const uint8_t *>(plain.data()), plain.size());
    md5.finalize();
    std::string md5hex = md5.hexdigest();

    // 5. 取前 10 字符作密钥
    std::string key = md5hex.substr(0, 10);

    // 初始化加密算法
    Cryptor::InitKey(key);
}

然后,我们就得到的关键道具:key。在这之后呢,所有的数据包的解密都会用到key,现在我们可以随意地捕获并解密封包了。

测试

登录阶段,可以看到LOGIN_IN指令分别进行了一次发送与一次接收。

尝试移动一下,可以看到PEOPLE_WALK分别被发送和接收了一次,至于日志中为什么是两次,因为第一次没截好图。

那么到这里,封包解密基本结束了,下面进入第二阶段:

“对战信息捕获与UI展示”

暂无...

最后

本文仅用于技术研究与学习交流,请勿用于任何商业用途。

所有相关资源版权归上海淘米网络科技有限公司所有。

如因滥用本文造成法律纠纷,责任由使用者自行承担

本文参考自赛尔号:通信协议逆向与模拟&中间人攻击窃取登录凭证


那么最后,赛尔号见!!!


免费评分

参与人数 10吾爱币 +17 热心值 +9 收起 理由
RIKKIA + 1 + 1 我很赞同!
xinruyu + 1 谢谢@Thanks!
Issacclark1 + 1 谢谢@Thanks!
KesinFox + 1 + 1 看不懂,但是热心回复!
忆魂丶天雷 + 2 + 1 用心讨论,共获提升!
IcePlume + 1 + 1 我很赞同!
chaifengbox + 1 + 1 用心讨论,共获提升!
TinPandora + 1 + 1 谢谢@Thanks!
若雪 + 2 + 1 感谢发布原创作品,吾爱破解论坛因你更精彩!
正己 + 7 + 1 欢迎分析讨论交流,吾爱破解论坛有你更精彩!

查看全部评分

发帖前要善用论坛搜索功能,那里可能会有你要找的答案或者已经有人发布过相同内容了,请勿重复发帖。

推荐
唐先森 发表于 2026-1-10 14:43
niupaiman 发表于 2026-1-10 11:23
客户端用的C#中一个webBrowser组件,网络调用用的是ws2_32.dll的api。

谢谢大佬回复我已经搞定了,我是用的 cefsharp 谷歌内核的,他会开几个进程,然后我去选那个 pepflash 的进程就可以了 。
推荐
1Ovo 发表于 2025-8-15 11:18
推荐
IcePlume 发表于 2025-8-15 12:51
推荐
正己 发表于 2025-8-14 15:14
赛尔号,好古早的游戏,那是我逝去的青春
推荐
pA55eR 发表于 2025-8-15 14:29
好贴,学习了很多啊
头像被屏蔽
推荐
YuYu798 发表于 2025-8-14 14:52
提示: 该帖被管理员或版主屏蔽
4#
bester 发表于 2025-8-14 10:36
vscode怎么配置可以调用windows的api?我之前配置的mingw好像不可以使用window的api?
然后你这个客户端应该是用C#写的吧,C#没有对socket进行封装?可以直接注入dll挂钩?
5#
oneday11111 发表于 2025-8-14 10:57
赛尔号现在是只有pc端没有手游吗
6#
xixicoco 发表于 2025-8-14 13:01
现在还有flash的游戏啊?厉害
7#
enlight 发表于 2025-8-14 14:55
oneday11111 发表于 2025-8-14 10:57
赛尔号现在是只有pc端没有手游吗

有的,和网页版互通的
8#
free1031 发表于 2025-8-14 20:28
好怀念啊,感谢楼主分享
9#
glcslz 发表于 2025-8-14 21:01
感觉flash的抓包分析还是很深奥啊
10#
 楼主| niupaiman 发表于 2025-8-14 21:24 |楼主
glcslz 发表于 2025-8-14 21:01
感觉flash的抓包分析还是很深奥啊

这个其实反编译到源码还是简单。
您需要登录后才可以回帖 登录 | 注册[Register]

本版积分规则

返回列表

RSS订阅|小黑屋|处罚记录|联系我们|吾爱破解 - 52pojie.cn ( 京ICP备16042023号 | 京公网安备 11010502030087号 )

GMT+8, 2026-2-14 14:01

Powered by Discuz!

Copyright © 2001-2020, Tencent Cloud.

快速回复 返回顶部 返回列表