-
抓包
逆向原因不表。第一步当然是从抓包开始,电脑打开fiddler,设置手机wifi代理为电脑局域网ip,端口8888。打开app,抓到几个请求,其中获取直播列表请求如下:
POST http://xxx.xxxx.cn/mapi/index.php HTTP/1.1
Host: xxx.xxxx.cn
Connection: keep-alive
Accept-Encoding: gzip, deflate
Accept: */*
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|e91cd7a62631b4426175b2ca4f77948f0f350fd9
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
Content-Length: 342
Content-Type: application/x-www-form-urlencoded
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
字段太长,已省略。):
HTTP/1.1 200 OK
Date: Fri, 12 Jan 2018 02:25:12 GMT
Content-Type: text/html; charset=utf-8
Connection: keep-alive
Vary: Accept-Encoding
Vary: Accept-Encoding
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
X-Cache: bypass
Content-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/cryptaes
WebForms:
type: aes
data: test
arg: m=ecb_pad=pkcs5_block=128_p=1400057185000000_o=0_s=utf-8_t=0
Returns:
{"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)
至此,请求已分析完毕,可以编码模拟了。待填坑。。