前文:https://bbs.tampermonkey.net.cn/thread-3560-1-1.html
https://bbs.tampermonkey.net.cn/thread-3572-1-1.html
书接上文,我们前面重写请求已经把UID暴露出来了,直接在Charles中搜索这个UID,发现大都在client.app.coc.10086.cn这个域名下的cookie里,那就像前面一样,搜索Set-Cookie请求,可以确定是这个请求设置了UID:
https://client.app.coc.10086.cn/biz-orange/LN/uamrandcodelogin/autoLogin
让我们复现一下,将请求头和data照抄一遍,成功得到了新的UID。这里注意到Set-Cookie是这种格式:
JSESSIONID=XXX; UID=XXX; Comment=SessionServer-unity; Path=/;HTTPOnly; ticketID=XXX; Secure
如果你对cookie有一定了解,可以知道这种写法其实是错误的。在Set-Cookie中,由于包含Path、Secure、HttpOnly等字段,这些字段之间使用分号分隔;而响应头中允许发送多条Set-Cookie,也允许将这些Set-Cookie合并为一条,其使用逗号分隔。在我们的情况中,Set-Cookie全部使用分号作为分隔符,这会导致浏览器的解析出现问题,用开发者工具或GM_cookie查看client.app.coc.10086.cn域下的cookie,可以发现只有一条JSESSIONID,其他的都被丢弃了。
为什么客户端就能正常工作?因为客户端并不是真正的浏览器,他对cookie的处理就是当成一个字符串存储,然后在发送请求时手动携带上去,可以去看一下后续请求的cookie,跟这个Set-Cookie一模一样。当需要解析cookie时,是通过解析字符串手动实现的,可以看看这个函数:
com.cmccit.basemodule.helper.ParamUtil.getUid
这种操作属于代码跑起来了就算成功,完全不考虑规范问题,应当认为是一个bug。不过问题出现了我们总得解决,尽管XMLHttpRequest和fetch不允许直接读取Set-Cookie,但GM_xhr是允许的,因此我们可以从响应头中获取Set-Cookie字段,再手动解析出UID。这里带出了另一个问题:脚本猫在解析这个古怪的Set-Cookie时出现bug,导致Set-Cookie丢失了。这个问题在我与一之大佬反馈后,目前于脚本猫v0.10.0-beta.1得到解决,不过由于版本还不太稳定,最好等待0.10.0正式版再做测试。
继续我们的分析。显然这个autoLogin的data被加密了,请求头中也包含很多x-开头的加密字段,直接从Java源码里找。搜索x-nonce,可以找到处理加密的地方是(这里要用jd-gui 1.4.0查看):
com.cmccit.netmodule.CommonHttp
先不管请求头的处理,注意到里面有一个encrypt函数:
EncryptionAesUtils.encrypt这句已经明明白白告诉你了,这是AES加密,第一个参数是明文,后两个参数是什么呢?了解AES的话应该可以直接猜出答案:是key和iv。我们姑且这么猜测,key和iv有2套,分别是EncryptionContent.requestKeyByte_online/9791027341711819和EncryptionContent.requestKeyByte_gray/1234567890123456。点一下requestKeyByte_online跟进:
好家伙,这不就是密钥吗?在脚本里测试一下,需要用到CryptoJS这个库,我这里给出加解密的函数:
// aes加密
function aesEncrypt(str, key, iv) {
const encrypt = CryptoJS.AES.encrypt(str, key, {
iv: iv,
mode: CryptoJS.mode.CBC,
padding: CryptoJS.pad.Pkcs7
});
return encrypt.toString();
}
// aes解密
function aesDecrypt(str, key, iv) {
const decrypt = CryptoJS.AES.decrypt(str, key, {
iv: iv,
mode: CryptoJS.mode.CBC,
padding: CryptoJS.pad.Pkcs7
});
return CryptoJS.enc.Utf8.stringify(decrypt);
}
key和iv不能直接传,要用CryptoJS指定的格式,key是字节数组,先用这个函数转成十六进制字符串:
function bytes2hex(arr) {
return arr.map(key => ('0' + key.toString(16)).match(/..$/)[0]).join('').toUpperCase();
}
得到62414967767741754134746244723964
,所以
key = CryptoJS.enc.Hex.parse('62414967767741754134746244723964');
iv是字符串比较简单:
iv = CryptoJS.enc.Utf8.parse('9791027341711819');
由于AES是对称加密,加解密用的相同密钥,我们可以试着用这套密钥来解密一下,把data放进来试试,哈,成功解密出一段json。响应数据看着也是乱码,试一下结果解密失败,换成gray的那套密钥,还是失败。且慢,我们得到的密钥明明白白写着requestKey,这下面不是还有个responseKey吗?故技重施一下,得到responseKeyByt_online的hex是47533756656C6B4A6C35495431757751
,再试一次,解密成功了!继续尝试,你会发现所有client.app.coc.10086.cn这个域下的请求,都可以用这套密钥解密成功。这种简单根据函数名大胆假设的做法,可以省去很多分析源码的功夫,在逆向中是一种很有用的技巧。
对于请求头的处理全都写在com.cmccit.netmodule.CommonHttp.requestData
这个函数里了,这种就是纯Java分析,没什么可说的,之后我会发一个成品的脚本,可以参考里面代码。
现在来研究下data,我们已解密出明文,data是一个json对象,对比其他client.app.coc.10086.cn的请求,可以发现属性基本是一模一样的,只有reqBody不同。这些相同的属性含了你的设备信息、应用签名信息、个人信息等内容,如果只是自用脚本,照抄就可以了,发布的话需要做匿名化处理。测试一下发现有些可以去掉,去不掉可以尝试用0填充,最后发现只有ak和xk这两个比较特殊。ak的设置在requestData函数里,跟进去看到是应用签名信息,照抄即可。xk比较复杂,跟到com.mc10086.lib.base.AppDataManager.getXk
可以发现,xk写在数据库里了,经过多次搜索,我在这里找到了线索:
com.mc10086.cmcc.view.tabs.AppTabFragment.getXkData
从这里跟进去,最终可以定位到:
com.mc10086.cmcc.helper.XkHelper.queryData
该函数发出一个请求,猜测是从响应数据中获得xk,那这个请求在哪里呢?多次启动APP抓包发现,xk是不变的,猜测是第一次启动时的请求。手机设置->应用管理->中国移动->清除数据,这个操作会把应用初始化,重新启动抓包,结果抓到了这个:
https://client.app.coc.10086.cn/biz-orange/DN/init/startInit
该请求的xk是null,响应数据中包含了一个ssv参数,而后续请求携带的xk,都与这个ssv相同,由此可以断定,这就是获取xk的请求。
脚本测试一下,发现reqBody中除了ssk参数,其他都是非必须的,ssk的处理写在queryData函数里,跟一下看看:
很明显是对手机设备信息进行拼接然后做DES加密,跟前面AES的分析是一样的,得到key为40786927616E256C766469616E237869746F6E6762757E26
,iv为01234567
。DES的加解密与AES基本一样,只需将前面函数中的CryptoJS.AES替换为CryptoJS.DES即可,不过这里要注意,key是24字节,CryptoJS.DES只支持8字节,应替换为CryptoJS.TripleDES。
解密ssk,看到确实是设备信息的拼接,既然要做匿名化处理,干脆全部替换成等长的0:
00000000000000000|#$0000000000000|#$0000000000000000|#$00:00:00:00:00:00|#$null
加密得到新的ssk:
+SqABQLTnglfIwDzp4GnO6PPsXqrQ3K9ZGDeJum/OObGx2Yyn4dwWwkBWekX8KIGg7nhMj6fGDPWARjKIOJ2sGApIwvaXIcjdLNp8UaNyvM=
用这个ssk构造startInit请求,就可以得到我们自己的xk了,后面脚本其实不再需要包含DES算法,直接用这个现成的ssk去申请xk即可。测试一下后续请求,发现autoLogin不好使了,响应数据提示:尊敬的用户,您好,该号码已在其他设备登录,请重新登录。显然服务端是根据xk来判断登录设备的,我们APP注销一下重新登录,抓到登录请求是这个:
https://client.app.coc.10086.cn/biz-orange/LN/uamthreenetworklogin/login
发送手机验证码的请求是这个:
https://client.app.coc.10086.cn/biz-orange/LN/uamrandcode/sendMsgLogin
脚本里测试一下,配合手机验证码成功用这个“虚空设备”完成了登录,之后又可以autoLogin了。既然这么麻烦,为什么不直接用抓包得到的ssk呢?因为ssk包含你的个人设备信息,脚本给别人用的话相当于所有人都在用你的手机登录。另外使用这个假设备登录以后,会发现手机上的登录失效了,两者不能共存,即脚本与手机会互相挤掉线。出于方便使用的角度考虑,可以在脚本中提供ssk的设置项,会抓包分析的用户填入自己的ssk,不会的就用假设备。
最后一个问题,autoLogin和login的reqBody都有一个cellNum属性,应该是被加密后的手机号,在jd-gui中搜索autoLogin,找到:
com.cmccit.loginmodule.helper.manager.autoLoginOnekey
跟进sendOneKeyAutoLoginReq,再跟进EncryptionLogin.getEncryptStr,后面的是不是很眼熟?没错,这就是我们一开始分析UID加密的地方,只是这次换成了Encrypt函数(上次是EncryptTEL),动态链接库还是同一个,分析方式完全一样,最终读出加密公钥rsa_s:
N: 968E9ACF9E59B4E2E1BB24266EE39043788A45F788ECDC5C971F4964C3267CF20AFD3B7F029B68A9A9B65D77D0306B8319DF0C174F3DD82EF3894A6C38E23634F6095A81901AD7E6650911C0910F12C7DE50A6FCEE3AE3563CC5985C46A965DB2AF49E94F69B62F67FF3D5C0F79782572375E5F8B44AA43C0CA6D48E8A969BEB
E: 10001
至此,我们完成了全部破解工作,可以动手写成脚本了,后面我会发布完整脚本以供参考。如此大费周章只为实现一个签到功能,这些看似简单的请求背后需要复杂的分析工作,脚本的编写也绝非易事啊。其实APP里还有很多其他功能可以自动化,这里就不继续深入了,方法已经给出,读者如有兴趣,不妨作为练习。