逆向某直播app全程

  • 抓包

逆向原因不表。第一步当然是从抓包开始,电脑打开fiddler,设置手机wifi代理为电脑局域网ip,端口8888。打开app,抓到几个请求,其中获取直播列表请求如下:

  1. POST http://xxx.xxxx.cn/mapi/index.php HTTP/1.1
  2. Host: xxx.xxxx.cn
  3. Connection: keep-alive
  4. Accept-Encoding: gzip, deflate
  5. Accept: */*
  6. User-Agent: Dalvik/2.1.0 (Linux; U; Android 7.1.1; E6683 Build/32.4.A.1.54)
  7. X-JSL-API-AUTH: sha1|1515724202|pRQCJVbTdAI6|e91cd7a62631b4426175b2ca4f77948f0f350fd9
  8. Cookie: 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.cn
  9. Content-Length: 342
  10. Content-Type: application/x-www-form-urlencoded
  11. itype=&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字段太长,已省略。):

  1. HTTP/1.1 200 OK
  2. Date: Fri, 12 Jan 2018 02:25:12 GMT
  3. Content-Type: text/html; charset=utf-8
  4. Connection: keep-alive
  5. Vary: Accept-Encoding
  6. Vary: Accept-Encoding
  7. Cache-Control: no-cache, no-store, max-age=0, must-revalidate
  8. X-Cache: bypass
  9. Content-Length: 128941
  10. {"output":"5x2AZX2LDCzk2qFiaD1E4UmfLy1LzPnHKMuEHGkthendVDWGGuJZP16YLxvyhhIsKu7T\/4IRa0S1cc3pJRf0UZsiBO1aNHjw4P3eCYxH2AX0GQRfW39pmQhCdOj2klek..."}

很显然,请求和响应加密了,只能反编译app寻找加密方法。

  • 反编译

使用apktool解包apk,得到资源文件和classes.dex文件,使用dex2jar工具反编译得到jar包,使用jd-gui工具查看并导出jar包源码,使用编辑器或ide浏览源码。apk没有加固,顺利拿到源码。

jd-gui需要java8环境,java9会出错,真替java捉急。

尝试全局搜索请求参数关键字"requestData"(不加引号结果太多,加引号就只搜索到出现该字符串的代码了),找到AppHttpUtil.java关键部分代码如下,参数跟抓到的相符,说明发起请求的逻辑正是此处。

  1. for (localObject1 = localObject2;; localObject1 = AESUtil.encrypt(SDJsonUtil.object2Json(localObject1), ApkConstant.getAeskey()))
  2. {
  3. if (i != 0)
  4. {
  5. localRequestParams.addBodyParameter("requestData", (String)localObject1);
  6. localRequestParams.addBodyParameter("i_type", String.valueOf(i));
  7. localRequestParams.addBodyParameter("ctl", (String)localObject3);
  8. localRequestParams.addBodyParameter("act", str);
  9. localRequestParams.addBodyParameter("itype", AppRequestParams.getItypeParams());
  10. localRequestParams.addBodyParameter("sdk_version_name", SDPackageUtil.getVersionName());
  11. }
  12. // 省略
  13. }

AESUtil.encrypt显示这是一个AES加密,找到AESUtil.encrypt方法的代码:

查看AppHttpUtil.java文件顶部的import xxx.AESUtil行,按照import路径即可找到AESUtil.java文件。下面跳转到代码都用这个方法,如果没有import 这行,说明这个类跟当前类在同一个目录下。

  1. public static String encrypt(String paramString1, String paramString2)
  2. {
  3. Object localObject2 = null;
  4. Object localObject1 = null;
  5. try
  6. {
  7. paramString1 = paramString1.getBytes("UTF-8");
  8. paramString2 = new SecretKeySpec(paramString2.getBytes("UTF-8"), "AES");
  9. Cipher localCipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
  10. localCipher.init(1, paramString2);
  11. paramString1 = localCipher.doFinal(paramString1);
  12. paramString2 = (String)localObject1;
  13. if (paramString1 != null) {
  14. paramString2 = Base64.encodeToString(paramString1, 0);
  15. }
  16. return paramString2;
  17. }
  18. // 省略
  19. }

AES加密方式为AES/ECB/PKCS5Padding,再看上面的调用方式,加密key为ApkConstant.getAeskey(),找到该类:

  1. public static final String AES_KEY = SDResourcesUtil.getString(2131165252) + "000000";
  2. private static String AES_KEY_DYNAMIC = "";
  3. public static String getAeskey()
  4. {
  5. if (!TextUtils.isEmpty(AES_KEY_DYNAMIC)) {
  6. return AES_KEY_DYNAMIC;
  7. }
  8. return AES_KEY;
  9. }

这里使用了AES_KEY_DYNAMIC字段,如果没有则使用默认的AES_KEY字段,说明服务端可能会动态更新AES_KEY_DYNAMIC。我们先用默认的AES_KEY试试可不可行。

AES_KEY获取方式为SDResourcesUtil.getString(2131165252),根据名字猜测是获取资源文件值,2131165252是资源id,全局搜索一下这个id,在R.java中找到下面一行定义:

由于编译优化的原因,常量名会直接替换为本来的数值。方法的局部变量命名很奇怪也是如此,编译后不存在局部变量的名字,名字都是反编译工具生成的。

  1. public static final int app_id_tencent_live = 2131165252;

百度搜索一下R.java,出来的是安卓资源文件相关的结果,说明这个值在资源文件中定义。打开解包apk后的res文件夹,全局搜索app_id_tencent_live,在strings.xml文件中找到了该值:

  1. <string name="app_id_tencent_live">1400057185</string>

那么AES_KEY="1400057185" + "000000",也即AES加密KEY为1400057185000000

  • AES加密

找一个在线AES加密网站验证一下结果是否正确,设置参数如下:

  1. 加密模式:ECB
  2. 填充:pkcs5padding
  3. 密码:1400057185000000
  4. 字符集: utf8

粘贴上面抓包请求中的requestData字段值,注意上面是urlencode后的,可以直接从fiddler的WebFroms视图里复制原始值。点击解密,得到结果如下:

  1. {"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抓包如下:

  1. Method:
  2. POST http://tool.chacuo.net/cryptaes
  3. WebForms:
  4. type: aes
  5. data: test
  6. arg: m=ecb_pad=pkcs5_block=128_p=1400057185000000_o=0_s=utf-8_t=0
  7. Returns:
  8. {"status":1,"info":"ok","data":["3KLa9xdRIRq4WVy7Sr3\/Ew=="]}

解密协议相同,只是arg参数变为以t=1结尾。python调用代码如下:

  1. def aes_encrypt(text):
  2. params = {
  3. 'data': text,
  4. 'type': 'aes',
  5. 'arg': 'm=ecb_pad=pkcs5_block=128_p=%s_i=_o=0_s=utf-8_t=0' % conf['aeskey']
  6. }
  7. r = requests.post('http://tool.chacuo.net/cryptaes', data=params)
  8. return r.json()['data'][0]
  9. def aes_decrypt(text):
  10. params = {
  11. 'data': text,
  12. 'type': 'aes',
  13. 'arg': 'm=ecb_pad=pkcs5_block=128_p=%s_i=_o=0_s=utf-8_t=1' % conf['aeskey']
  14. }
  15. r = requests.post('http://tool.chacuo.net/cryptaes', data=params)
  16. return r.json()['data'][0]
  • sign签名

接下来寻找获取房间信息的请求,先在手机上请求,fiddler抓包,报文跟直播列表请求结构相似,解密requestData得到原文:

  1. {"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代码如下:

  1. public static SDRequestHandler requestRoomInfo(int paramInt1, int paramInt2, String paramString, AppRequestCallback<App_get_videoActModel> paramAppRequestCallback)
  2. {
  3. AppRequestParams localAppRequestParams = new AppRequestParams();
  4. localAppRequestParams.putCtl("video");
  5. localAppRequestParams.putAct("get_video2");
  6. localAppRequestParams.put("room_id", Integer.valueOf(paramInt1));
  7. localAppRequestParams.put("is_vod", Integer.valueOf(paramInt2));
  8. localAppRequestParams.put("private_key", paramString);
  9. paramString = AppRuntimeWorker.getSdkappid();
  10. String str = AppRuntimeWorker.getLoginUserID();
  11. localAppRequestParams.put("sign", MD5Util.MD5(paramString + str + paramInt1));
  12. return AppHttpUtil.getInstance().post(localAppRequestParams, paramAppRequestCallback);
  13. }

可以看到sign参数为MD5Util.MD5(paramString + str + paramInt1)
strAppRuntimeWorker.getLoginUserID(),根据名字知道这是用户id,用户id当然在cookie中,查看最开始抓到的请求中,cookie头有user_id字段,就是该值。
结合localAppRequestParams.put("room_id", Integer.valueOf(paramInt1))这行,知道paramInt1为room_id,即房间id,在直播列表请求返回的结果中,有room_id字段。
paramStringAppRuntimeWorker.getSdkappid()

转到AppRuntimeWorker.getSdkappid方法代码:

  1. public static String getSdkappid()
  2. {
  3. InitActModel localInitActModel = InitActModelDao.query();
  4. if (localInitActModel == null) {
  5. return null;
  6. }
  7. return localInitActModel.getSdkappid();
  8. }

sdkappid是从InitActModel中读取的,找到InitActModel类,发现是一个纯数据类,sdkappid是外部设置的。尝试全局搜索setSdkappid方法,找到设置该值的地方,结果略多,没有找到有用的代码。

变换思路,查看InitActModelDao代码:

  1. public class InitActModelDao
  2. {
  3. public static void delete()
  4. {
  5. JsonDbModelDao.getInstance().delete(InitActModel.class);
  6. }
  7. public static boolean insertOrUpdate(InitActModel paramInitActModel)
  8. {
  9. boolean bool = JsonDbModelDao.getInstance().insertOrUpdate(paramInitActModel);
  10. HostManager.getInstance().saveActHost();
  11. return bool;
  12. }
  13. public static InitActModel query()
  14. {
  15. return (InitActModel)JsonDbModelDao.getInstance().query(InitActModel.class);
  16. }
  17. }

看上去是orm代码,继续查看JsonDbModelDao代码,从代码风格可以确定是orm了。代码不多,看到该行:

  1. DbManagerX.getDb().selector(JsonDbModel.class)

说明数据库初始化部分在DbManagerX类里,查看DbManagerX代码,发现下面这行:

  1. 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头

抓包请求中有如下一个http头,使用fiddler的Reissue and Edit功能重发请求,并编辑,去掉该头,发现返回了一个html页面,提示请求被拦截,说明该头是用来验证请求的有效性。

  1. X-JSL-API-AUTH: sha1|1515583942|EgloVUHapki7|e4abc467fdfb98d72c0c881329b1450c301b2d26

发现每次请求这个头的值都不一样,并且第二个参数是一串数字,看起来是时间戳,猜测这个头每次请求会生成,并且具有有效期。特意等待几分钟后,使用fiddler重新发起该请求,返回结果提示拦截,果然如此。

在源码中搜索X-JSL-API-AUTH关键字,找到关键代码:

  1. Object localObject1 = new StringBuilder(paramSDRequestParams.getUrl());
  2. localRequestParams.addHeader("X-JSL-API-AUTH", getToken(((StringBuilder)localObject1).toString()));
  3. //省略
  4. public static String getToken(String paramString)
  5. {
  6. String str = getStringRandom();
  7. long l = getSysTime();
  8. paramString = getStrToSHA("sha1|09969cd379a84835b6fece3c8d327bf1|" + l + "|" + str + "|" + paramString);
  9. return "sha1|" + l + "|" + str + "|" + paramString;
  10. }

实现逻辑比较简单,拼接字符串sha1|09969cd379a84835b6fece3c8d327bf1|、时间戳、一个随机字符串、要请求的url得到加密用原文,求sha1值,作为签名,然后拼接sha1、时间戳、随机字符串、以及签名即可得到该头部值。

python代码实现如下:

  1. def token():
  2. l = str(int(time.time()) + 300)
  3. string = ''.join(random.sample('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', 12))
  4. sign = sha1("sha1|09969cd379a84835b6fece3c8d327bf1|" + l + "|" + string + "|" + conf['url']).hexdigest()
  5. return "sha1|" + l + "|" + string + "|" + sign

事后发现,这里有个坑,脚本在我电脑上运行正常,在服务器上运行一直返回请求被拦截,开始以为是ip或cookie的问题,调试了好久发现,上面签名用的时间戳不是当前时间,而是失效时间。我自己电脑的时间比较快,所以坑了…解决方法也很简单,取当前时间加上5分钟就好。

猜测很可能会验证User-Agent头,试了下果然如此, 请求需要伪造User-Agent头。使用fiddler抓到的手机发出的请求的User-Agent即可。

  1. User-Agent: Dalvik/2.1.0 (Linux; U; Android 7.1.1; E6683 Build/32.4.A.1.54)
  • 模拟

至此,请求已分析完毕,可以编码模拟了。待填坑。。

发表评论

电子邮件地址不会被公开。 必填项已用*标注