Contents

99客服系统逆向实录

Contents

倒不是找不着准考证,不过成绩公布在假期里面就有点蛋疼了。[白眼]原来找不着准考证可以去一个神奇的网站,不过这两天想去查的时候,那个网站已经关停露出云服务商的404了。看那个项目的 issue 里面,似乎也是6月成绩放出来时候99ss顺便更新了,于是这个网站也不好使了。没办法自己下了99客服客户端,体验了一番,顺便搞掉这个充满铜臭味的软件做成网页版,方便普罗大众。

其实本来宝宝只想做一个诚实正直的好孩子,没想搞这个软件来着,不过因为虚拟机里面有个关不掉的 ProxyCap ,在99ss的黑名单里。 我想查个成绩竟然要被扫描电脑里安装的程序?你牛逼啊! 本意只是想抓个包看看请求就完事了,这简直逼人玩硬的么不是。

这个软件没有任何加密或者加壳处理,可以直接加载到 OD 里面。扫描字符串 “不兼容软件” 找到关键点,把几个 exit(1) nop 掉。

/zh/2016/99ss-pwn/00.png

小彩蛋。我在想它为啥不让我用 ProxyCap 时候,顺手在字符串里面搜索了 proxy ,然后跟进 xref 看列表,反汇编结果如下。 得罪了一票友商啊。

// ...
  v78 = "AdClean LSP Filter";
  v50 = "AdClean LSP Filter";
  v2 = 0;
  v88 = a2;
  // 杀毒软件
  lpMultiByteStr = "Dr.COM";
  // 水果同步
  v75 = "Itunes";
  // 虚拟机
  v76 = "vmware";
  // 杀毒软件
  v77 = "AVSDA";
  // 迅雷加速器
  v28 = "XLacc";
  // 迅雷加速器
  v29 = "XLNetAcc";
  // 迅游加速器
  v30 = "xunyou";
  // 网易UU (广告:个人觉得超好用)
  v31 = "Netease UU";
  // 国外加速器(?)
  v32 = "GameCap";
  v33 = "NGC-TcpFilter";
  v34 = "NGC-UdpFilter";
  // 唔,又是友商
  v35 = "海豚加速器";
  v36 = "xrush_socks";
  // 应该是TX游戏助手
  v37 = "QQVIPLSP";
  v38 = "IERD_TGP_LSP";
  v39 = "Easy2Game";
  // 这也不行??
  v40 = "XunLei Net Monitor";
  v41 = "iKu Smart Network";
  v42 = "ttslsp";
  v43 = "SangforLSP";
  v44 = "TGPLSP";
  v45 = "VMCI sockets";
  v46 = "MSAFD RfComm [Bluetooth]";
  v47 = "FunAccelerator";
  v48 = "MSAFD RSVP";
  v49 = "MSAFD IPLAYER GAMELSP";
  v51 = "HomeSafe LSPRAW";
  v52 = "HomeSafe LSPTCP";
  v53 = "HomeSafe LSPUDP";
  v54 = "GameLSP";
  v55 = "EvilLSP";
  v56 = "RainSoft IP";
  v57 = "rtwspi";
  v58 = "Hyper-V RAW";
  v59 = "GsLSP";
  v60 = "LSP Pro";
  v61 = "iKu Smart Network LSP";
  // proxy
  v62 = "PROXIFIER LSP";
  v63 = "GameLSP IPLayer";
  // proxy
  v64 = "ASProxy";
  v65 = "LavasoftLSP";
  v66 = "TESTLSP";
  v67 = "TenLSP";
  // 也是迅雷
  v68 = "XLNetAccLsp";
  v69 = "SinforLSP";
  v70 = "Network Tunnel Layered IP";
  v71 = "XLNet";
  // 唔
  v72 = "Letv Network";
  // 磨灭了一个输入法的野心
  v73 = "SogouIpFilter";
// ...

呵呵。一个钦定的查四六级成绩的公司,非要把一个能在网页做的功能强行做成客户端,还捆绑一个垃圾加速器(减速器)模块,还不让人用这些别的软件。 隔着屏幕都能闻着铜臭的味道。 钦定的感觉就是不一样。

然后进到了主界面,随便查一个人,proxycap + mitmproxy 把请求拦下,看着看着发现请求超时了…… 随手把超时改成了 0xFF ,然后把查询间隔的判断直接跳过了,都上阿里云了还计较这点访问压力,抠门不抠门?

逆向的时候最大难点就是如何判断哪个函数是做什么用的。这里要感谢一下 99ss 码农的姿势水平,程序简洁明了得就差给你源代码了。这里是关键部分的反汇编。

// sub_40D2C0
            default:
              AfxMessageBox("无法找到对应的准考证号,请联系客服", 0, 0);
              stopconn(&sock);
              break;
          }
        }
        else
        {
          AfxMessageBox("服务器响应错误,请稍后尝试", 0, 0);
          stopconn(&sock);
        }
      }
      else
      {
        AfxMessageBox("连接服务器失败,请稍后尝试", 0, 0);
        stopconn(&sock);
      }
    }
    else
    {
      AfxMessageBox("分配内存失败,请重启尝试", 0, 0);
    }
  }

stopconn是通过里面调用了socket连接的winapi瞎猜的,那 else 是错的那 if 里的函数就是正确的处理咯!

[你知道吗] 99宿舍客服系统的码农,啊不程序员,个个都是精通 HTTP 网络协议的高手,甚至能用 nc 与服务器谈笑风生。他们需要联网的时候,手写一个个 HTTP 请求都是信手拈来,链接 raw socket ,sprintf 行云流水,去看他们的代码,你将受益匪浅!

   //add computer macaddr to post parameters
   strlen = (int)&strs[sprintf(&strs[(_DWORD)&params], "&m=%s", &macaddr)];
    calcmagic((char *)&tmp2);
    if ( encryptmessage((char *)&tmp2, strlen, &params) )
    {
      *(_DWORD *)ArgList = 17;
      startconn((int)&sock);
      LOBYTE(v84) = 6;
      if ( postmessage((int)"http://find.cet.99sushe.com/search", &params, strlen, retstatus, (int *)ArgList) == 1 )
      {
        if ( *(_DWORD *)ArgList == 1 || *(_DWORD *)ArgList == 17 )
        {
          retstatus[*(_DWORD *)ArgList] = 0;
          switch ( atoi(retstatus) )
          {
            case 2:
              AfxMessageBox("参数错误,请联系客服", 0, 0);

跟进去以后果然, if 里面的函数 encryptmessage 带的参数是 calcmagic 函数生成的密码,这个函数的原理有点像密码学里面的看过的自动机,其实可以生成无限长的随机密码的。我写成了 py 版本,链接待补, 99cs 的码农复制这个函数,改吧改吧里面的参数就生成出一个新的8位 ascii 密码,然后美其名曰版本更新。呵呵,那以后我也改个参数咯。

到这里卡了好久,加密函数进去以后,把两个参数分别传给了三个函数,三个函数又调用了五六七八个子函数,里面的一串一串的位运算让我冷静下来开始思考人生,我就是想查一个考号啊亲……我……

然后我打开QQ问了我后桌的考号,然后随便找了个地方查到了成绩。

全剧终。

才怪。直到我翻代码时候不小心翻到了这个。

/zh/2016/99ss-pwn/01.png

红红火火恍恍惚惚我似乎突然领悟了逆向的真谛。

搜索字符串啊亲!

到网上找来 openssl 1.0.0a 版本,2010年版本的,啧。看样子应该是静态链接进去了。 OD 识别是没指望了, IDA 有个 FLAIR(Fast Library Acquisition for Identification and Recognition) 可以自动生成一个库的特征文件,大概流程如下(粘贴自 README )

Typical scenario of a signature creation is: - run a parser and create pattern (PAT) files - run sigmake and get EXC file with collisions - edit EXC file and resolve collisions - run sigmake again and get SIG file - repeat the above 2 steps till collisions exist - run zipsig and get compressed SIG file

其实就是三个命令。

  • link 把lib里面的一个个obj模块全解压出来
  • plb 把模块的特征提取出来,取出一个个函数的名字
  • sigmake 从所有模块中去掉重复的函数/模块的特征,并打包成整个lib的signature

linux 下的 ar 命令似乎对 win 的 lib 不那么友好,不知道为啥,解压不出来。破 IDA 只能读取,能解压出来但不把文件给你,磨人的小妖精。没办法跑去下了个平时最讨厌的 VC++6.0 简体中文精简版 ,用它带的 link 命令一个模块一个模块解压。最后受不了了还是跑去找了个批量脚本。折腾一个sig加载了。世界瞬间明亮了。

加密函数部分:

char *__usercall encryptmessage@<eax>(char *key@<ecx>, size_t len@<edi>, char *text)
{
  char *pkey; // ebx@1
  char *rststr; // eax@1
  char *pstrstr; // esi@1
  char *v6; // ecx@2
  int v7; // edx@2
  void *v8; // ebx@2
  unsigned int v9; // [sp+0h] [bp-A8h]@0
  int v10; // [sp+Ch] [bp-9Ch]@2
  void *ptxt; // [sp+10h] [bp-98h]@1
  char v12; // [sp+14h] [bp-94h]@2
  char *pkey_1; // [sp+98h] [bp-10h]@2
  int v14; // [sp+9Ch] [bp-Ch]@2

  pkey = key;
  ptxt = text;
  rststr = (char *)operator new[](v9);
  pstrstr = rststr;
  if ( rststr )
  {
    v6 = *(char **)pkey;
    v7 = *((_DWORD *)pkey + 1);
    v10 = 0;
    pkey_1 = v6;
    v14 = v7;
    DES_set_odd_parity((int)&pkey_1);
    DES_set_key_checked((int)&pkey_1, (int)&v12);
    v8 = ptxt;
    DES_cfb64_encrypt((char *)ptxt, pstrstr, len, (int)&v12, (int)&pkey_1, &v10, 1);
    memcpy_0(v8, pstrstr, len);
    operator delete(pstrstr);
    rststr = (char *)1;
  }
  return rststr;
}

噢。好厉害。

那就一个 DES 加密呗。放狗搜了一下,CFB64 加密还是 libopenssl 的隐藏剧情!再一次印证了我的判断——99宿舍的程序员个个都是大神!

噢!好厉害!

大概分析完了,我又找到原来那个神奇网址的 GH 代码库看看能用多少。看了一遍发现,全能用。改个密码就行。

哦耶!

回头仔细看了看代码,想瞻仰一下无名大神是怎么写的加解密部分。结果发现,

if not libcrypto_path:
    from ctypes.util import find_library
    libcrypto_path = find_library('crypto')
    if not libcrypto_path:
        raise Exception('libcrypto(OpenSSL) not found')

self.libcrypto = CDLL(libcrypto_path)

噢。好厉害。

python,好厉害噢。

部署了 venv ,赶紧打开查一下自己的准考证……没有。好吧那我查我后桌的。查到了。可我后桌准考证不是5开头的啊……又点了一遍。唔,也不是8开头的啊……看来哪里不对。

用 mitmproxy 看了一下请求,是这样的。

POST /search HTTP/1.1
Host: find.cet.99sushe.com
Content-Length: 77
User-Agent: python-requests/2.7.0 CPython/2.7.10 Darwin/15.6.0
Connection: keep-alive
Accept: */*
Accept-Encoding: gzip, deflate

...

程序发的请求,是这样的。

POST /search HTTP/1.0
host: find.cet.99sushe.com
Accept: */*
Connection: keep-alive
Content-Length: 71

...

直觉判断是 User-Agent 的锅。尝试改成空 UA ,返回结果依然是随机数字。再用 mitmproxy 把 UA 字段整个去掉,返回结果正确。

于是又发现了一个奇妙的事实,python 的 Requests 库不能做到不包含 UA 的请求头。起因是, requests.utils.default_headers 里面定义了默认的头,会包含 requests 和版本号作为默认 UA 。这就麻烦了。要是不用 requests 呢,就要用 又臭又长 的 urllib2 重写请求部分,原来一句话的事要扩写成一个函数那么多?天啊。

唉,反正在 virtualenv 里面。把 default_user_agent 那里注释掉得了。懒[捂脸]

其实代码自己写出来也不是很费劲了啦,不过一个是放假,一个是学习的重点也不在代码上,(调用 libcrypto 这么奇葩的方法换我我也想不到)。才不是要玩 MC 呢。