前些天在校园网内测试一些站点,第一个目标便是平时使用的运动场馆预约系统。发现这个系统的前后端通讯有加密(当然,实为 encode 而非 encrypt),且 js 代码存在混淆。当天挖到三个漏洞,提交给网络中心,就转而去测试其他信息系统了;近日苦于难以预测乒乓球场是否存在空位,想到定时爬取预约系统的申请数量,做一个数据分析工作。这就需要写一个爬虫出来。

  显然,最简单的方式是用 Selenium 操纵一个 Firefox/Chrome 浏览器,完成登录、爬取等任务。不过近期笔者想提升 web 渗透测试水平,于是决定重新系统性地分析这个网站的 javascript 混淆机制。于是有了本篇文章。

0x00 获取站点地址

  预约系统是在移动端「哈工大 APP」中使用的,此 APP 是基于华为 welink,并未显示预约系统的 url。想要获取 url,显然可以通过局域网抓包的方式,从 DNS 请求或者 HTTP 请求中拿到域名。

  不过,笔者发现哈工大 APP 里面有「在系统浏览器中打开」按钮,点击即送 url。因此省掉了许多工作,直接拿到域名:venue-book.hit.edu.cn

0x01 探索

  首先,进入每一个页面收集信息。

  观察网络请求:

  对所有 API 的访问都如上图所示。无论是 GET 还是 POST,服务端返回数据一定按照以下格式:

{
    "data": "siVU7wqjTVcR0fGnUUTEPPI+yWUZlQjZJfqjPs1gaOTLCQ1Iu/CA4Cvw0zhDJjbMp3rh/NEnO4m1j4T0L6fpWS7qQMpE7UyK98BRBqSHNpeBYPtq3boTMPhOPOjo08BuBGqknRdhehKwzaeraSJaXnpH11XqZxs19ywDKHG8zzX3wYCny7VfeYd1sELWpChZe3mJU28ernqilEEcmpgiU6oCxPzLYxEffbLBJ1pZQCs=",
    "md5": "JhoUtk4Lsvf3x6vP1Vfz4NASx11EQiRzsi0VXJnnR6EXxXJWISmGXmeAeU4eoVzvnp5rR91UY8V6BMSETT3D74M9/5fwgPMWL34WJ3vBJvNRg/ByJc6/qfDb6MEmFzDFE5vpX5SHbuiL94dhFFVn+eBfnf+z9A+yQP1zUbUjA2M=37666333346434343334633363623338"
}

  如果是 POST 请求,则客户端发送的报文形如:

{
    "data": "IK/CNHtsAmpiFNrebaKTh1R8tNpByd+IHNUwTCOI8i85z6kS4Kw3sZxO81+wvar+klVmynzoj79NE1XMPuMrjwdt0UTia4QmhGHayJ1AaPG8JXELRj10SrOn6IATTCQj",
    "md5": "f982zQQ7JBVGrbde+Y9G0apkwg7tAyRvuIEcujZoMY1LX2szjXwi4RcU16QCoMN2J5y6XrjtnqTUGpyisdYSOQVWaGt3HsQRN+DmfGoq6EGLvFDldbcQsjDIgVGuAzkVXjaczM+PPmmho2br9MCJORQ0waVP0B/m+MK3DyMJZ4w="
}

  观察可知,服务端返回数据中,md5 字段尾部有一串十六进制码。我们给网络请求过程下断点:

  于是追踪到相关逻辑。接下来,进入网络请求的反混淆工作。

0x02 去混淆

  主要的逻辑在下面这个 js:

  这个文件经历过比较强的混淆,相当难看。我们首先看文件的开头:

  这里面有一个 a-zA-Z+/= 的字符串表,推测 a0_0x1934 这个函数是用来编解码的。继续往后看:

  注意到这一部分有很多形如 _0xd11376(0x56f, 'c@mQ') 的调用。动态调试,发现这里的 _0xd11376 正是 a0_0x1934 的别名,且只有第一个数值参数有效,第二个字符串参数与返回值无关。于是可以猜测程序逻辑:

  1. 建立一个 int -> str 的编码表
  2. 之后透过查表的方式获取常量字符串

  这样做是有道理的,可以比较方便地隐藏常量字符串,以逃避关键字检查。而编码表在程序运行的一开始便建立起来,我们可以很方便地在 chrome console 中查表。

  渗透测试过程中,笔者发现这一流程后,采用动态调试 + 肉眼反混淆的方式解析出了网络请求的逻辑。本文采取另一条做法:先想办法自动反混淆。网络上有些工具:

  重复反混淆几次,结果就很漂亮了。结果如下:

0x03 分析网络请求

  快速浏览一遍程序,发现几个网络请求的 url:

  动态调试下断点,找到发出请求前的关键逻辑:

  观察这部分代码。看到一句 _0x27e530.a.enc.Utf8.parse(_0x28d2df) ,猜测这是一个库函数。在网上查询 enc.Utf8.parse 这个 API,发现是 CryptoJS 提供的。类似地,查询 KJUR.crypto.Cipher.encrypt 这个 API,发现对应 jsrsasign 库。

  于是简化如下:

function _0x128461(_0x55f5d0, _0x350abb) {
    _0x350abb = CryptoJS.enc.Hex.parse(_0x350abb)
    var _0x43ce13 = CryptoJS.enc.Utf8.parse(_0x55f5d0),
        _0x36ea5f = CryptoJS.AES.encrypt(_0x43ce13, _0x350abb, {
            mode: CryptoJS.mode.ECB,
            padding: CryptoJS.pad.Pkcs7,
        })
    return _0x36ea5f.toString()
}
function _0x35b81b(_0x4e3276, _0x56dae9) {
    _0x56dae9 = CryptoJS.enc.Hex.parse(_0x56dae9)
    var _0x21fe0c = CryptoJS.AES.decrypt(_0x4e3276, _0x56dae9, {
        mode: CryptoJS.mode.ECB,
        padding: CryptoJS.pad.Pkcs7,
    })
    const _0x50f0cc = CryptoJS.enc.Utf8.stringify(_0x21fe0c).toString()
    return _0x50f0cc
}
function _0x24156d(_0x3d3cf1, _0x22c595) {
    const _0x31e1d3 = jsrsasign.KEYUTIL.getKey(_0x22c595)
    var _0x552c24 = jsrsasign.KJUR.crypto.Cipher.encrypt(_0x3d3cf1, _0x31e1d3)
    return (_0x552c24 = Object(jsrsasign.hextob64)(_0x552c24)), _0x552c24
}
function _0x4ef4f7(plain_msg) {
    if ('pk' === _0x10fe5c.a.state.pk) {
        return plain_msg
    }
    var _0x28d2df = _0x3c4515(16),
        _0x10e785 = CryptoJS.enc.Utf8.parse(_0x28d2df).toString(
            CryptoJS.enc.Hex
        ),
        post_data_msg = JSON.stringify(plain_msg)
    post_data_msg = _0x128461(post_data_msg, _0x10e785)
    var post_data_md5 = CryptoJS.MD5(post_data_msg).toString()
    post_data_md5 = _0x24156d(post_data_md5 + _0x28d2df, _0x10fe5c.a.state.pk)
    var post_data = {
        data: post_data_msg,
        md5: post_data_md5,
    }
    const _0x38c2c2 = post_data
    return _0x38c2c2
}

  继续简化。动态调试发现 _0x3c4515(16) 返回了一个 16 字节长的随机串。它转 hex 之后立刻会参与 _0x128461 过程,而很容易分析出 _0x128461 过程是一个 aes-128-ecb 加密。于是得知这 16 字节随机串是当作了 AES key 使用。

  再进行一些分析,得到结果:

function encrypt_aes_128_ecb(plain, passwd) {
    passwd = CryptoJS.enc.Hex.parse(passwd)
    var _0x43ce13 = CryptoJS.enc.Utf8.parse(plain),
        ciphertext = CryptoJS.AES.encrypt(_0x43ce13, passwd, {
            mode: CryptoJS.mode.ECB,
            padding: CryptoJS.pad.Pkcs7,
        })
    return ciphertext.toString()
}
function decrypt_aes_128_ecb(ciphertext, passwd) {
    passwd = CryptoJS.enc.Hex.parse(passwd)
    var _0x21fe0c = CryptoJS.AES.decrypt(ciphertext, passwd, {
        mode: CryptoJS.mode.ECB,
        padding: CryptoJS.pad.Pkcs7,
    })
    const plaintext = CryptoJS.enc.Utf8.stringify(_0x21fe0c).toString()
    return plaintext
}
function enc_rsa(plain, pubkey) {
    const pubkey_obj = jsrsasign.KEYUTIL.getKey(pubkey)
    var ciphertext = jsrsasign.KJUR.crypto.Cipher.encrypt(plain, pubkey_obj)
    ciphertext = Object(jsrsasign.hextob64)(ciphertext)
    return ciphertext
}
function prepare_send_data(plain_msg) {
    if ('pk' === _0x10fe5c.a.state.pk) {
        return plain_msg
    }
    var aes_key = get_random_bytes(16),
        aes_key_hex = CryptoJS.enc.Utf8.parse(aes_key).toString(
            CryptoJS.enc.Hex
        ),
        post_data_msg = JSON.stringify(plain_msg)
    post_data_msg = encrypt_aes_128_ecb(post_data_msg, aes_key_hex)
    var post_data_md5 = CryptoJS.MD5(post_data_msg).toString()
    post_data_md5 = enc_rsa(post_data_md5 + aes_key, _0x10fe5c.a.state.pk)
    var post_data = {
        data: post_data_msg,
        md5: post_data_md5,
    }
    return post_data
}

  于是我们确定了发包流程:

  1. 随机生成 16 字节的字符串,作为 AES key
  2. 对明文进行 AES 加密,得到 data 字段
  3. md5(data) + aes_key 使用服务端公钥进行 PKCS#1 v1.5 RSA 加密,作为 md5 字段

  简而言之,这个加密体系是 encrypt-then-mac 外面套一层 RSA。那么,服务器在收到一条 (data, md5) 信息后,首先用自己的 RSA 私钥进行解密,获取 aes_key 和密文摘要;然后验证 data 字段的 MD5 值是否确实等同于 RSA 解密所获得的密文摘要,以保证消息不被篡改。

  来考虑这个体系的安全性。只要攻击者不知道服务器端持有的私钥,那么他就无法解密出 aes_key ;而 aes_key 是客户端浏览器随机生成,信道上无法窃听到。因此,这个加密体系是比较安全的。当然,也存在改进的空间,例如 AES ECB 模式是不安全的,应当改为 CBC 等其他模式。

  接下来考察 js 如何解密服务端发回的 HTTP response。这个函数就在发包代码的下面,反混淆之后得到:

function decrypt_response_data(resp) {
    if ('prik' === _0x10fe5c.a.state.priK) {
        return resp
    }
    if ('object' === typeof resp.data) {
        return resp
    }
    var aes_key = resp.md5.slice(-32)
    const result = decrypt_aes_128_ecb(resp.data, aes_key)
    return JSON.parse(result)
}

  它的逻辑是:

  1. 取服务端返回数据中的 md5 字段,将其尾部 32 个十六进制字符转成一个 16 字节的 AES key
  2. 使用该 key 解密 data 字段。

  我们找个请求实例验证一下,信然。

  应当指出,尽管浏览器发送请求的过程是密码学安全的,但服务端返回数据的过程却并非安全。信道上的窃听者可以直接从 md5 字段的尾部获取到 AES key,从而解密 data 获得 response 数据明文。

  因此,我们接下来只需分析客户端如何获取服务端 pubkey,就能模拟这个 js 客户端发出网络请求。

0x04 公钥从何处来?

  搜索 public 字样,发现 1083 行左右比较可疑,推测为密钥协商过程。

  观察这个 await 的写法,它很像是 fetch 或者 ajax 请求。另外,抓包发现有一个 HTTP 请求与之相关:

POST http://venue-book.hit.edu.cn/school/signToken

payload = "pVba/9po2Wp/Yshc08EHhL52Igs6L3wATCIHk+mIzDltllrN6VBks6XoAoNFqmTL"
response = "aQW9W81+Vs96s4wIk/y4U7L3uvWzxDuQWgbucDDW+DWGwF0wbajm2+Er5viYBFIcKUA+JF5eg7Wrr6PS08SUsOXpvnIDGXYG7kIEV1h4ZC3XYD1PyVroRH1OnDbm+jtTKQdH0o7Hw6A3Tij+NOD7iQZF/UnNfnkJyrANbzq4XEyNhq9tZha5FxAepH+bVP91VV81IUyuTmA+3 uHUbZ3XR6eWSGQEoWVpANyev0x3uFZUOkYr1fPz1RuGH8UeXahKlRPpZ97R5iglFkiMTH2rCh8OtWnur+XDVF0yx5GDBQEEa9baARLbZ1bpuYsCWulONyHfuHYx0w7wbezq7CdAkWirudmhxzovJcsmrHgzXKReh5uPXaignXnEg5CsGtoTP1ppmoobjQPVcFnM+O1qGnPpxZzBge/GD4OevXfa8sa31fJAbn/FZSDcAColaO0e1XMP4/9 EZ5u+LVZ0q74O2BMwKl/Zm0nrp9buO1GXBR14IYca2dAsOsAJpm8OXZi1vyLp0mqz/Js+7 G1o+9 on0JaoLMbhKIOYqs21acIeCRDFIekJstKHWZ8qrorzlZMc4ERl0HKqgS0PHkJjFRddwVDyl92hkxsOoNeZIP2X4WXbxcZRDLIw70qsJ8mbioCmla9UurYNkuDEGER3wlzwM3kGCHBbqSH7imwqm1JiMqcx5/suHDM6NXPLxUlVwwfZod1dlvx0Gsybp0HjzQ44dhBNsRu7cqHl4P15hjV2stf3F9UB/4 Y3iqtsPlAccEGA09xjHpJK5wCPQ7T75Ndh7mIioilda5m5JjVuTAGxiYxk2tLKd643FjnJoO8jXn3vjTkiMkGf //B9I86mt9wj2gNVaCQcQjHIxQkKjMjJP0qn4Um3h8DgYCgmiuXjichFLrqp36jxQyJbJAephbZUJSnLTSOzp9Vgwk4z46i6NWH8vx3AkH/VCp8upoTnSN31YWVhLypkgECpJJ+lEvbGjTeonekBpFqon1hpMFclZ+XXZgvuxm9yMEZs1j7GOf77LEID2NHr/sG/C/JRrtdVhXYU429YVeG5RyrDESZ3FDMtV7IzmU6GWGQGz4O3YJJggg1Utefxs7FVRHkPb+KerZfIJaOOWrfi503Zh895usUElDY9PBunukmr7nPP7a/zwxe+JKjPISaInTknovJE24u4uM6HDviI3s/Vycp2QYtjvWTPl14uN38Jf97Wqw5J95g34/HpF4i9nuuruJA5Catj2uHOwS5wS1uO9cW2FILkfcZssE4mo68AkOaJMAxXIUQx85P8pJqzJBnQ5cyHxjbZiUqpp10YfrfFs+ccNbGrMPp9oY0yPNR0eEpZS60yARzHxYTetYLbIJAvbl2avodOBlBLfUteW/tfeeK2PJXEElq3I+7ZfhDr+ZEZEKQu5GFISD7BmHztuSI/1i2A5KsaXI3KzimJzDDDYR0v/oNXdplltAZLbDHvyKH5AEqBm3NUfEDG5nslwMXQRQE0U6E1oG4VU3aNgDyyZllJAsiTIEc0GM/uswPbjhlraaGp1MycSk/uPjRMzugfb810JA=="

  猜测这个地方的逻辑正是 RSA 密钥协商。做一些动态调试,得到:

async (_0x205365, _0x6eea42, _0x227762) => {
      if (
        ((document.title = _0x205365.meta.title),
        -1 !== _0x5e559f.indexOf(_0x205365.path))
      ) {
        return console.log('to.path', _0x205365.path), void _0x227762()
      }
      let local_storage_auth = localStorage.getItem('Authorization')
      const ticket_str = _0x24b86d('ticket')
      if (ticket_str && !local_storage_auth) {
        const nonce_24 = Object(_0x54bf2a.e)(24)   // 生成 24 字节随机数
        let aes_key = nonce_24.substr(4, 16)
        aes_key = to_hex(aes_key)
        var ticket_obj = { ticket: ticket_str }
        let _ticket_obj = ticket_obj
        return (
          console.log('sendData', _ticket_obj),
          (_ticket_obj = JSON.stringify(_ticket_obj)),
          (_ticket_obj = Object(_0x54bf2a.b)(_ticket_obj, aes_key)),
          void Object(_0x5e516e.d)(_ticket_obj, nonce_24).then((resp) => {
            console.log('res1', resp)
            resp = Object(_0x54bf2a.a)(resp, aes_key)   // 解密
            console.log('res2', resp)
            resp = JSON.parse(resp)
            console.log('res3', resp)
            '0' == resp.code
              ? (localStorage.setItem(
                  'Authorization',
                  resp.data.Authorization
                ),
                localStorage.setItem('isSchool', '校内'),
                (window.location.search = window.location.search.replace(
                  /ticket=[\d\w-]*/,
                  ''
                )))
              : (console.log('login2 失败 转统一身份认证', resp),
                _0x1d1f98())
          })
        )
      }
      if (
        (ticket_str &&
          local_storage_auth &&
          ((window.location.search = window.location.search.replace(
            /ticket=[\d\w-]*/,
            ''
          )),
          _0x227762()),
        !local_storage_auth)
      ) {
        return _0x5f4c24(), void _0x227762()
      }
      if (_0x5bd004.a.state.hasKey) {
        return void _0x227762()
      }
      let random_bytes = Object(_0x54bf2a.e)(24),   // 生成 24 字节随机数
        key = random_bytes.substr(4, 16)
      key = to_hex(key)
      var token_obj = { token: local_storage_auth }
      let _token_obj = token_obj
      _token_obj = JSON.stringify(_token_obj)
      _token_obj = Object(_0x54bf2a.b)(_token_obj, key)   // AES 加密
      window.location.href.includes('mock') &&
        ((random_bytes = 'TBPf7wMVX27NXkSSnX7Grk8v'),
        (key = '7wMVX27NXkSSnX7G'),
        (key = to_hex(key)),
        (local_storage_auth = '7755ccc9d58d8bea8a8d580f0147a974'),
        (_token_obj = '{"token":"7755ccc9d58d8bea8a8d580f0147a974"}'))
      await Object(_0x5e516e.g)(_token_obj, random_bytes)
        .then((resp) => {
          if (
            ((resp = Object(_0x54bf2a.a)(resp, key)),
            (resp = JSON.parse(resp)),
            0 !== resp.code)
          ) {
            return '校外人员' === localStorage.getItem('isSchool')
              ? void (window.location.href = '#/external-users')
              : void _0x5f4c24()
          }
          _0x5bd004.a.state.pk =
            '-----BEGIN PUBLIC KEY-----' +
            resp.data.publicKey +
            '-----END PUBLIC KEY-----'
          _0x5bd004.a.state.priK =
            '-----BEGIN PRIVATE KEY-----' +
            resp.data.privateKey +
            '-----END PRIVATE KEY-----'
          _0x5bd004.a.state.hasKey = true
          _0x227762()
        })
        .catch((err) => {
          console.log('catch', err)
        })
    })

  于是,我们终于发现了公钥的生成方式:

  1. 假如 localStorage 里面没有 Authorization 字段,则先通过 ticket 生成一个 auth code 并写入 localStorage
  2. 生成一个 20 字节长度的 random_bytes,取后 16 个字节作为 AES key
  3. 对 auth code 进行 AES 加密,发送给服务端。
    这条 HTTP 请求的 header 中,有一个 randomCode 字段,即 random_bytes
  4. 服务端用同一个 AES key 加密 pubkey,返回给客户端

  这个通讯过程显然不是密码学安全的。攻击者只需从 HTTP header 中拿到 20 字节的 randomCode ,取后 16 位即为 AES key,接下来便能解密出 auth code。

  那么,我们想要获取 pubkey,只需要掏出 auth code,以上述方式向服务器发包,即可获取服务器的 pubkey,进而便能伪装成 js 客户端向服务器发送请求。于是我们面临最后一个问题:如何获取 auth code?

0x05 auth code 从何处来?

  上一段代码里面已经发现,auth code 是通过 ticket 生成的。具体而言,js 向服务器提交 ticket,服务器返回一个 auth code,客户端将其保存进浏览器的 localStorage;以后与服务端通讯的时候,会带一个名为 Authorization 的 HTTP header。

  那么,ticket 又是从何而来的?我们重新从登录过程开始抓一次包,发现 ticket 是哈工大统一身份认证系统(IDS)返回的。具体而言:

  1. 用户向预约系统服务器请求登录,预约系统服务器将用户重定向到 IDS
  2. IDS 接受用户登录,登陆完成之后将用户重定向到预约系统的 /?ticket=ST-xxxx-xxxxx-xx 位置
  3. js 从 url 中看到 ticket,于是请预约系统服务器给出 auth code

  js 请服务器给出 auth code 的通讯过程,与请求 pubkey 的过程是一模一样的。至此我们分析完毕,可以写一个 Python 脚本来伪装成合法客户端,去与服务器通讯了。

0x06 验证

  编写通讯脚本,对 /school/api/place/time/area2 进行 POST,查询 3 月 17 日 18:00 时段的乒乓球场占用情况:

{
  "placeId": 90,
  "sportId": 13,
  "date": "2023-03-17",
  "startTime": "18:00",
  "endTime": "19:30"
}

  服务端返回请求:

{
    "code":0,
    "data":{
        "price":0.00,
        "allowbundling":1,
        "endtime":"19:30",
        "begintime":"18:00",
        "surplusNum":7,
        "state":1,
        "usedNum":11
    },
    "msg":"success."
}

  可见我们确实达成了目的。

0x07 总结

  在渗透测试时,笔者采用了人眼反混淆的方式,浪费了几个小时的时间。当时应该去寻找自动 js 反混淆工具。

  网站开发者有一定的密码学基础,客户端向服务端发送的数据是安全的;但服务端返回的数据并不安全。

  这是笔者第一次做 js 反混淆,感到动态调试的作用相当大。与之类似地,二进制可执行文件的逆向工程也常常可以通过动态调试获取到一些关键线索。