-
抓包
逆向原因不表。第一步当然是从抓包开始,电脑打开fiddler,设置手机wifi代理为电脑局域网ip,端口8888。打开app,抓到几个请求,其中获取直播列表请求如下:
POST http://xxx.xxxx.cn/mapi/index.php HTTP/1.1Host: xxx.xxxx.cnConnection: keep-aliveAccept-Encoding: gzip, deflateAccept: */*User-Agent: Dalvik/2.1.0 (Linux; U; Android 7.1.1; E6683 Build/32.4.A.1.54)X-JSL-API-AUTH: sha1|1515724202|pRQCJVbTdAI6|e91cd7a62631b4426175b2ca4f77948f0f350fd9Cookie: client_ip=143.97.168.92; user_id=464890; __jsluid=c13ebd1234964c11ds9802a5b24sd6afc; nick_name=%e7%bb%ad%e5%91%bd; $Path=/; user_pwd=0091ea2a881c56fae1sece697510ec80; PHPSESSID2=9eirdal3p5q05ppdkj7uj767h5; PHPSESSID=5rdo1e11mk3bjctoaaeksmun63; $Domain=69.9vtao.cnContent-Length: 342Content-Type: application/x-www-form-urlencodeditype=&i_type=1&sdk_version_name=2.5.12&requestData=apwlYyV6t85HEc7wCgrb41qkcD0E%2F%2BMKEQcyaZzX0Xsob1xZKtUfh5P6PIGRHOPzocvGjqyDRcsW11TC7ZCJwRgLMmmew5%2FKTmYY9M3Pl8u2ohlBQRCkuR4YmL9%2FzTIQt2ieybEsLPh3Fa2ZC2EBmJwNqq0NtO3L5%2Fq2nxi%2FzX4L2xzFsL5dnJHMLD4jQxwFwWJs98OWS91ImakLf7MXqIhOoZEhslBzqHkVtFRq4TMHMhdgmQPyGaoRTXEXd%2BRR&act=index&ctl=index
出于隐私考虑,Host及Cookie值并非实际值,有修改。
响应(output字段太长,已省略。):
HTTP/1.1 200 OKDate: Fri, 12 Jan 2018 02:25:12 GMTContent-Type: text/html; charset=utf-8Connection: keep-aliveVary: Accept-EncodingVary: Accept-EncodingCache-Control: no-cache, no-store, max-age=0, must-revalidateX-Cache: bypassContent-Length: 128941{"output":"5x2AZX2LDCzk2qFiaD1E4UmfLy1LzPnHKMuEHGkthendVDWGGuJZP16YLxvyhhIsKu7T\/4IRa0S1cc3pJRf0UZsiBO1aNHjw4P3eCYxH2AX0GQRfW39pmQhCdOj2klek..."}
很显然,请求和响应加密了,只能反编译app寻找加密方法。
使用apktool解包apk,得到资源文件和classes.dex文件,使用dex2jar工具反编译得到jar包,使用jd-gui工具查看并导出jar包源码,使用编辑器或ide浏览源码。apk没有加固,顺利拿到源码。
jd-gui需要java8环境,java9会出错,真替java捉急。
尝试全局搜索请求参数关键字"requestData"(不加引号结果太多,加引号就只搜索到出现该字符串的代码了),找到AppHttpUtil.java关键部分代码如下,参数跟抓到的相符,说明发起请求的逻辑正是此处。
for (localObject1 = localObject2;; localObject1 = AESUtil.encrypt(SDJsonUtil.object2Json(localObject1), ApkConstant.getAeskey())){if (i != 0){localRequestParams.addBodyParameter("requestData", (String)localObject1);localRequestParams.addBodyParameter("i_type", String.valueOf(i));localRequestParams.addBodyParameter("ctl", (String)localObject3);localRequestParams.addBodyParameter("act", str);localRequestParams.addBodyParameter("itype", AppRequestParams.getItypeParams());localRequestParams.addBodyParameter("sdk_version_name", SDPackageUtil.getVersionName());}// 省略}
AESUtil.encrypt显示这是一个AES加密,找到AESUtil.encrypt方法的代码:
查看
AppHttpUtil.java文件顶部的import xxx.AESUtil行,按照import路径即可找到AESUtil.java文件。下面跳转到代码都用这个方法,如果没有import 这行,说明这个类跟当前类在同一个目录下。
public static String encrypt(String paramString1, String paramString2){Object localObject2 = null;Object localObject1 = null;try{paramString1 = paramString1.getBytes("UTF-8");paramString2 = new SecretKeySpec(paramString2.getBytes("UTF-8"), "AES");Cipher localCipher = Cipher.getInstance("AES/ECB/PKCS5Padding");localCipher.init(1, paramString2);paramString1 = localCipher.doFinal(paramString1);paramString2 = (String)localObject1;if (paramString1 != null) {paramString2 = Base64.encodeToString(paramString1, 0);}return paramString2;}// 省略}
AES加密方式为AES/ECB/PKCS5Padding,再看上面的调用方式,加密key为ApkConstant.getAeskey(),找到该类:
public static final String AES_KEY = SDResourcesUtil.getString(2131165252) + "000000";private static String AES_KEY_DYNAMIC = "";public static String getAeskey(){if (!TextUtils.isEmpty(AES_KEY_DYNAMIC)) {return AES_KEY_DYNAMIC;}return AES_KEY;}
这里使用了AES_KEY_DYNAMIC字段,如果没有则使用默认的AES_KEY字段,说明服务端可能会动态更新AES_KEY_DYNAMIC。我们先用默认的AES_KEY试试可不可行。
AES_KEY获取方式为SDResourcesUtil.getString(2131165252),根据名字猜测是获取资源文件值,2131165252是资源id,全局搜索一下这个id,在R.java中找到下面一行定义:
由于编译优化的原因,常量名会直接替换为本来的数值。方法的局部变量命名很奇怪也是如此,编译后不存在局部变量的名字,名字都是反编译工具生成的。
public static final int app_id_tencent_live = 2131165252;
百度搜索一下R.java,出来的是安卓资源文件相关的结果,说明这个值在资源文件中定义。打开解包apk后的res文件夹,全局搜索app_id_tencent_live,在strings.xml文件中找到了该值:
<string name="app_id_tencent_live">1400057185</string>
那么AES_KEY="1400057185" + "000000",也即AES加密KEY为1400057185000000。
找一个在线AES加密网站验证一下结果是否正确,设置参数如下:
加密模式:ECB填充:pkcs5padding密码:1400057185000000字符集: utf8
粘贴上面抓包请求中的requestData字段值,注意上面是urlencode后的,可以直接从fiddler的WebFroms视图里复制原始值。点击解密,得到结果如下:
{"screen_width": 1080, "sdk_type": "android", "sdk_version_name": "2.5.12", "sex": 0, "p": 1, "sdk_version": 2017122401, "cate_id": 0, "act": "index", "ctl": "index", "screen_height": 1776}
说明我们的猜测是对的,通信协议加密搞定。粘贴响应结果的output字段,点击解密,同样得到了原文(结果这里就不贴了,是一个json结构,返回了直播列表,包含房间id等信息)。
观察该请求的参数,并没有什么变量(screen_width跟设备相关,sdk_version跟app版本相关,app升级后可能会变),直接照抄参数即可模拟该请求。
python可使用pycrypto库实现AES加/解密,由于该库安装需要编译,而且不自动处理padding,比较麻烦,这里直接使用上面AES网站的API来做AES加/解密操作。fiddler抓包如下:
Method:POST http://tool.chacuo.net/cryptaesWebForms:type: aesdata: testarg: m=ecb_pad=pkcs5_block=128_p=1400057185000000_o=0_s=utf-8_t=0Returns:{"status":1,"info":"ok","data":["3KLa9xdRIRq4WVy7Sr3\/Ew=="]}
解密协议相同,只是arg参数变为以t=1结尾。python调用代码如下:
def aes_encrypt(text):params = {'data': text,'type': 'aes','arg': 'm=ecb_pad=pkcs5_block=128_p=%s_i=_o=0_s=utf-8_t=0' % conf['aeskey']}r = requests.post('http://tool.chacuo.net/cryptaes', data=params)return r.json()['data'][0]def aes_decrypt(text):params = {'data': text,'type': 'aes','arg': 'm=ecb_pad=pkcs5_block=128_p=%s_i=_o=0_s=utf-8_t=1' % conf['aeskey']}r = requests.post('http://tool.chacuo.net/cryptaes', data=params)return r.json()['data'][0]
接下来寻找获取房间信息的请求,先在手机上请求,fiddler抓包,报文跟直播列表请求结构相似,解密requestData得到原文:
{"screen_width": 1080, "sdk_type": "android", "sdk_version_name": "2.5.12", "sign": "65c65aefb02040eebca7127576c47a2c", "is_vod": 0, "sdk_version": 2017122401, "room_id": 172084, "act": "get_video2", "ctl": "video", "screen_height": 1776}
解密响应包,内容是房间的具体信息,包含了一个flv链接的直播流,使用支持流媒体的播放器打开这个链接可直接观看。
注意多了一个sign字段,有签名验证,没有想象中顺利。尝试搜索请求参数get_video2(其他重复率可能比较低的参数也行,多尝试),找到CommonInterface.java代码如下:
public static SDRequestHandler requestRoomInfo(int paramInt1, int paramInt2, String paramString, AppRequestCallback<App_get_videoActModel> paramAppRequestCallback){AppRequestParams localAppRequestParams = new AppRequestParams();localAppRequestParams.putCtl("video");localAppRequestParams.putAct("get_video2");localAppRequestParams.put("room_id", Integer.valueOf(paramInt1));localAppRequestParams.put("is_vod", Integer.valueOf(paramInt2));localAppRequestParams.put("private_key", paramString);paramString = AppRuntimeWorker.getSdkappid();String str = AppRuntimeWorker.getLoginUserID();localAppRequestParams.put("sign", MD5Util.MD5(paramString + str + paramInt1));return AppHttpUtil.getInstance().post(localAppRequestParams, paramAppRequestCallback);}
可以看到sign参数为MD5Util.MD5(paramString + str + paramInt1)。
str为AppRuntimeWorker.getLoginUserID(),根据名字知道这是用户id,用户id当然在cookie中,查看最开始抓到的请求中,cookie头有user_id字段,就是该值。
结合localAppRequestParams.put("room_id", Integer.valueOf(paramInt1))这行,知道paramInt1为room_id,即房间id,在直播列表请求返回的结果中,有room_id字段。
paramString为AppRuntimeWorker.getSdkappid()。
转到AppRuntimeWorker.getSdkappid方法代码:
public static String getSdkappid(){InitActModel localInitActModel = InitActModelDao.query();if (localInitActModel == null) {return null;}return localInitActModel.getSdkappid();}
sdkappid是从InitActModel中读取的,找到InitActModel类,发现是一个纯数据类,sdkappid是外部设置的。尝试全局搜索setSdkappid方法,找到设置该值的地方,结果略多,没有找到有用的代码。
变换思路,查看InitActModelDao代码:
public class InitActModelDao{public static void delete(){JsonDbModelDao.getInstance().delete(InitActModel.class);}public static boolean insertOrUpdate(InitActModel paramInitActModel){boolean bool = JsonDbModelDao.getInstance().insertOrUpdate(paramInitActModel);HostManager.getInstance().saveActHost();return bool;}public static InitActModel query(){return (InitActModel)JsonDbModelDao.getInstance().query(InitActModel.class);}}
看上去是orm代码,继续查看JsonDbModelDao代码,从代码风格可以确定是orm了。代码不多,看到该行:
DbManagerX.getDb().selector(JsonDbModel.class)
说明数据库初始化部分在DbManagerX类里,查看DbManagerX代码,发现下面这行:
private static final DbManager.DaoConfig configDefault = new DbManager.DaoConfig().setDbName("fanwe.db")
数据库文件为fanwe.db,应该是一个sqlite数据库。可能是内置于apk里的,去apk解包后的文件夹里查找该文件,并没有找到。那就说明数据库文件是apk运行后创建的,那么sdkappid也可能是动态设置的,由服务端返回。
回到fiddler,查看之前抓到的请求,对每个请求结果都解密,查找sdkappid关键字。在一个包含ctl=app_init参数的请求中找到了该值"sdkappid": "1400057185",结果跟上面的aes_key是同一个值,what the fuck…
这里也可以直接去手机里找
fanwe.db文件, 但是我用的手机并未root,无法查看app私有数据目录。实在找不到的情况下,可以用安卓虚拟机去运行app再找到数据库文件。
现在用于签名的三个参数都已经找到,我们使用上面的room_id 172084,结合userid和sdkappid,找个在线的md5网站,计算md5值,得到结果65c65aefb02040eebca7127576c47a2c,跟sign字段一致,签名验证搞定。本来还以为它的MD5Util.md5方法还要在加个什么key之类的…
现在房间信息请求也搞定了,然而我看到了请求头有一个X-JSL-API-AUTH的特殊的头,就知道事情并没有那么简单。
抓包请求中有如下一个http头,使用fiddler的Reissue and Edit功能重发请求,并编辑,去掉该头,发现返回了一个html页面,提示请求被拦截,说明该头是用来验证请求的有效性。
X-JSL-API-AUTH: sha1|1515583942|EgloVUHapki7|e4abc467fdfb98d72c0c881329b1450c301b2d26
发现每次请求这个头的值都不一样,并且第二个参数是一串数字,看起来是时间戳,猜测这个头每次请求会生成,并且具有有效期。特意等待几分钟后,使用fiddler重新发起该请求,返回结果提示拦截,果然如此。
在源码中搜索X-JSL-API-AUTH关键字,找到关键代码:
Object localObject1 = new StringBuilder(paramSDRequestParams.getUrl());localRequestParams.addHeader("X-JSL-API-AUTH", getToken(((StringBuilder)localObject1).toString()));//省略public static String getToken(String paramString){String str = getStringRandom();long l = getSysTime();paramString = getStrToSHA("sha1|09969cd379a84835b6fece3c8d327bf1|" + l + "|" + str + "|" + paramString);return "sha1|" + l + "|" + str + "|" + paramString;}
实现逻辑比较简单,拼接字符串sha1|09969cd379a84835b6fece3c8d327bf1|、时间戳、一个随机字符串、要请求的url得到加密用原文,求sha1值,作为签名,然后拼接sha1、时间戳、随机字符串、以及签名即可得到该头部值。
python代码实现如下:
def token():l = str(int(time.time()) + 300)string = ''.join(random.sample('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', 12))sign = sha1("sha1|09969cd379a84835b6fece3c8d327bf1|" + l + "|" + string + "|" + conf['url']).hexdigest()return "sha1|" + l + "|" + string + "|" + sign
事后发现,这里有个坑,脚本在我电脑上运行正常,在服务器上运行一直返回请求被拦截,开始以为是ip或cookie的问题,调试了好久发现,上面签名用的时间戳不是当前时间,而是失效时间。我自己电脑的时间比较快,所以坑了…解决方法也很简单,取当前时间加上5分钟就好。
猜测很可能会验证User-Agent头,试了下果然如此, 请求需要伪造User-Agent头。使用fiddler抓到的手机发出的请求的User-Agent即可。
User-Agent: Dalvik/2.1.0 (Linux; U; Android 7.1.1; E6683 Build/32.4.A.1.54)
至此,请求已分析完毕,可以编码模拟了。待填坑。。