APP协议分析心得
一 前言
抓包工具:Charles
反汇编工具:JEB、JADX
inject:frida
查壳:360加固
二 抓包
2.1 Headers
POST: /api/user/login HTTP/1.1
Content-Type: application/json; charset=utf-8
User-Agent: Dalvik/2.1.0 (Linux; U; Android 8.1.0; Pixel 2 XL Build/OPM4.171019.021.R1)
Host: api.dodovip.com
Accept-Encoding: gzip
Content-Length: 262
Connection: keep-alive
2.2 Text
{"Encrypt":"NIszaqFPos1vd0pFqKlB42Np5itPxaNH//FDsRnlBfgL4lcVxjXii+GOZz1l+A5V9FPOSMf47jbE010Kk+PbNyEDRjj1zY76jXa7VyHLkjxpqsrJYht6LX1PcVabK8oBp/fiOE4l2lC5JVjqx/JI7CJmeUXVXkgJ6rgPne3WCJUYU+ztDNEi+mvECeOktUk0KxqBbPzuJj3LKsW5Ux080rWm4NZWHxPFbZYlIs2IRcs="}
2.3 Response
2v+DC2gq7RuAC8PE5GZz5wH3/y9ZVcWhFwhDY9L19g9iEd075+Q7xwewvfIN0g0ec/NaaF43/S0=
多次抓包仅 Encrypt 参数变化,需要分析的就是它了。
三 脱壳
对脱壳流程有不明白的可参考我之前写的文章:[原创]ART环境下dex加载流程分析及frida dump dex方案。
上脚本,手机端启动fs后执行即可,脱壳的dex会在/data/data/com.dodonew.online目录下:
function find_hook_fun() { var fun_Name = ""; var libart = Module.findBaseAddress('libart.so'); //查找基地址 var exports = Module.enumerateExportsSync("libart.so"); for(var i=0; i if(exports[i].name.indexOf("OpenMemory") !== -1){ fun_Name = exports[i].name; console.log("导出模块名: " + exports[i].name + "\t\t偏移地址: "+ (exports[i].address - libart - 1)); break; }else if(exports[i].name.indexOf("OpenCommon") !== -1){ fun_Name = exports[i].name; console.log("导出模块名: " + exports[i].name + "\t\t偏移地址: "+ (exports[i].address - libart - 1)); break; } } return fun_Name; } function DexFileVerifier(Verify){ var magic_03x = true; var magic_Hex = [0x64, 0x65, 0x78, 0x0a, 0x30, 0x33, 0x35, 0x00]; for(var i = 0; i < 8; i++){ if(Memory.readU8(ptr(Verify).add(i)) !== magic_Hex[i]){ if(Memory.readU8(ptr(Verify).add(i)) === 0x37 || 0x38){ console.log('new dex'); }else{ magic_03x = false; break; } } } return magic_03x; } function dump_Dex(fun_Name, apk_Name){ if (fun_Name !== ''){ var hook_fun = Module.findExportByName("libart.so", fun_Name); Interceptor.attach(hook_fun, { onEnter: function (args) { var begin = 0; var dex_flag = false; dex_flag = DexFileVerifier(args[0]); if(dex_flag === true){ begin = args[0]; } if(begin === 0){ dex_flag = DexFileVerifier(args[1]); if(dex_flag === true){ begin = args[1]; } } if(dex_flag === true){ console.log("magic : " + Memory.readUtf8String(begin)); var address = parseInt(begin,16) + 0x20; var dex_size = Memory.readInt(ptr(address)); console.log("dex_size :" + dex_size); var dex_path = "/data/data/" + apk_Name + "/" + dex_size + ".dex"; var dex_file = new File(dex_path, "wb"); dex_file.write(Memory.readByteArray(begin, dex_size)); dex_file.flush(); dex_file.close(); } }, onLeave: function (retval) { } }); }else{ console.log("Error: no hook function."); } } var fun_Name = find_hook_fun(); var apk_Name = 'com.dodonew.online' dump_Dex(fun_Name, apk_Name); // frida -U -f com.dodonew.online -l dumpdex.js --no-pause
四 dex解析
将脱壳后的dex推出:
其中第一个为加壳程序;
第二个为IjkMediaPlayer和rx库,IjkMediaPlayer是基于FFmpeg的Android多媒体播放器库,大佬们可自行百度了解;
第三个为应用程序界面信息dex;
第四个为应用程序逻辑代码。
既然是分析登陆逻辑,那肯定是在第四个dex中分析啦!
五 协议分析
jadx每次生成的参数名称会有所出入,各位在对照这这份教程进行分析的时候只需把握整体步骤即可。
5.1 入手点定位
将第四个文件拖入jadx等待加载完成,搜 "Encrypt" 结果还挺多:
挺好定位 com.dodonew.online.http.JsonRequest 类中存在
addRequestMap(Map, int) void 方法和 paraMap(Map) void 方法, 两方法中都有进行参数存放操作。
第一个方法 addRequestMap 翻译以下:添加请求的 Map,可疑,跟进去看看:
public void addRequestMap(Map map, int i) { String str = System.currentTimeMillis() + ""; if (map == null) { map = new HashMap<>(); } map.put("timeStamp", str); String encodeDesMap = RequestUtil.encodeDesMap(RequestUtil.paraMap(map, Config.BASE_APPEND, "sign"), this.desKey, this.desIV); JSONObject jSONObject = new JSONObject(); try { jSONObject.put("Encrypt", encodeDesMap); this.mRequestBody = jSONObject + ""; } catch (JSONException e) { e.printStackTrace(); } }
看这两句代码:
String encodeDesMap = RequestUtil.encodeDesMap(RequestUtil.paraMap(map, Config.BASE_APPEND, "sign"), this.desKey, this.desIV); jSONObject.put("Encrypt", encodeDesMap);
第一句中生成的encodeDesMap就是Encrypt,入口点定位无误。
5.2 md5 算法分析
继续分析addRequestMap函数代码,看代码:
String str = System.currentTimeMillis() + ""; map.put("timeStamp", str);
获取时间戳,然后将时间戳添加进 Map 中,再调用:
RequestUtil.paraMap(map, Config.BASE_APPEND, "sign");
跟进RequestUtil.paraMap函数看看:
public static String paraMap(Map map, String str, String str2) { try { Set keySet = map.keySet(); StringBuilder sb = new StringBuilder(); ArrayList arrayList = new ArrayList(); for (String str3 : keySet) { arrayList.add(str3 + "=" + map.get(str3)); } Collections.sort(arrayList); for (int i = 0; i < arrayList.size(); i++) { sb.append((String) arrayList.get(i)); sb.append("&"); } sb.append("key=" + str); map.put(str2, Utils.md5(sb.toString()).toUpperCase()); String json = new GsonBuilder().serializeNulls().create().toJson(sortMapByKey(map)); Log.w(AppConfig.DEBUG_TAG, json + " result"); return json; } catch (Exception e) { e.printStackTrace(); return ""; } }
首先将 Map 中的键提取出来存入 Set 中,再定义一个 List 集合用来存放键值信息,and 进行 sort 排序,
其中有处:sb.append("key=" + str); str是入参参数二,向上跟一下是个固定值:
public static final String BASE_APPEND = "sdlkjsdljf0j2fsjk";
经过一系列操作完后对值进行 md5,md5 得到的值就是 sign 的值,hook 看看那些值需进行 md5:
function main() { Java.perform(function () { var Utils = Java.use("com.dodonew.online.util.Utils"); Utils["md5"].implementation = function (string) { console.log('md5 is called' + ', ' + 'string: ' + string); var ret = this.md5(string); console.log('md5 ret value is ' + ret); return ret; }; }); } setImmediate(main)
hook 结果:
md5 is called, string: equtype=ANDROID&loginImei=Androidc0b30f35fc9535b5&timeStamp=1687772161410&userPwd=12334&username=123456789&k
ey=sdlkjsdljf0j2fsjk
md5 ret value is e888bef28d91b42fc10cf91540ec057b
试着 python 还原下看看是不是标准 md5 算法:
from hashlib import md5 def get_encode_mes(mes): new_md5 = md5() new_md5.update(mes.encode(encoding='utf-8')) return new_md5.hexdigest() if __name__ == '__main__': print(get_encode_mes('equtype=ANDROID&loginImei=Androidc0b30f35fc9535b5&timeStamp=1687772161410&userPwd=12334&username=123456789&k ey=sdlkjsdljf0j2fsjk'))
结果:e888bef28d91b42fc10cf91540ec057b,对照一致,标准md5算法。
5.3 des 加密算法分析
继续分析addRequestMap函数代码,看代码:
String encodeDesMap = RequestUtil.encodeDesMap(RequestUtil.paraMap(map, Config.BASE_APPEND, "sign"), this.desKey, this.desIV);
其中this.desKey, this.desIV,猜测为des算法,先hook看看数据,hook代码:
function main() { Java.perform(function () { var RequestUtil = Java.use("com.dodonew.online.http.RequestUtil"); RequestUtil["encodeDesMap"].overload('java.lang.String', 'java.lang.String', 'java.lang.String').implementation = function (data, desKey, desIV) { console.log('encodeDesMap is called' + ', ' + 'data: ' + data + ', ' + 'desKey: ' + desKey + ', ' + 'desIV: ' + desIV); var ret = this.encodeDesMap(data, desKey, desIV); console.log('encodeDesMap ret value is ' + ret); return ret; }; }); } setImmediate(main)
hook 结果:
encodeDesMap is called, data: {"equtype":"ANDROID","loginImei":"Androidc0b30f35fc9535b5","sign":"0FAFB81829C15EF86EBD30E214675BBC",
"timeStamp":"1687772424834","userPwd":"12334","username":"123456789"}, desKey: 65102933, desIV: 32028092
encodeDesMap ret value is NIszaqFPos1vd0pFqKlB42Np5itPxaNH//FDsRnlBfgL4lcVxjXii+GOZz1l+A5V9FPOSMf47jbE
010Kk+PbN/jjSVvUEnMkBeVQY2tdy+to9cUXg0XyzdSi3Wehubi6R5t5NLiRanFipatR61mx4ISH
B/wjHUkmAFDl2b3zZIYs2UMZhz4YfC4HgFeRqA/9X1+m1LNZQYUkOLl/HqD5GFDgdRel9stq/g+8
ZB8fY84=
在此吃了个亏,直接用 hook 出来的 desKey、desIV 进行加密,怎么搞都不对,后面发现它还进行了操作,还是太年轻了。跟进 encodeDesMap 方法查看:
public static String encodeDesMap(String data, String desKey, String desIV) { try { DesSecurity ds = new DesSecurity(desKey, desIV); return ds.encrypt64(data.getBytes("UTF-8")); } catch (Exception e) { e.printStackTrace(); return ""; } }
先调用 DesSecurity(desKey, desIV); 对 desKey、desIV 进行操作,跟进看看:
public DesSecurity(String key, String iv) throws Exception { if (key == null) { throw new NullPointerException("Parameter is null!"); } InitCipher(key.getBytes(), iv.getBytes()); } private void InitCipher(byte[] secKey, byte[] secIv) throws Exception { MessageDigest md = MessageDigest.getInstance("MD5"); md.update(secKey); DESKeySpec dsk = new DESKeySpec(md.digest()); SecretKeyFactory keyFactory = SecretKeyFactory.getInstance("DES"); SecretKey key = keyFactory.generateSecret(dsk); IvParameterSpec iv = new IvParameterSpec(secIv); this.enCipher = Cipher.getInstance("DES/CBC/PKCS5Padding"); this.deCipher = Cipher.getInstance("DES/CBC/PKCS5Padding"); this.enCipher.init(1, key, iv); this.deCipher.init(2, key, iv); }
查看其构造方法,调用 InitCipher 方法对 desKey、desIV 进行操作:
MessageDigest md = MessageDigest.getInstance("MD5"); md.update(secKey);
对 desKey 进行了 MD5 加密,然后才传进去进行 DES 加密,加密模式 CBC 填充方式 PKCS5Padding。再看:
public String encrypt64(byte[] data) throws Exception { return Base64.encodeToString(this.enCipher.doFinal(data), 0); }
对加密后的数据又进行了一次 Base64 编码,这回清楚了,再进行还原:
from pyDes import CBC, PAD_PKCS5, des from hashlib import md5 import base64 def get_md5_mes(mes): new_md5 = md5() new_md5.update(mes.encode(encoding='utf-8')) return new_md5.hexdigest() def des_encrypt(data, desKey, desIV): """DES 加密 :param data: 原始字符串 :param desKey: 取加密密钥 8 位 :return: 加密后字符串, base64""" key = desKey[:8] # 只需前八字节 ds = des(key, CBC, desIV, pad=None) en = ds.encrypt(data.encode(), padmode = PAD_PKCS5) return base64.b64encode(en).decode() if __name__ == '__main__': desIV = '32028092' # 需转换成 byte 的 hex 值 用 hexstr 来创建 bytes 对象 desKey = bytes.fromhex(get_md5_mes('65102933')) data = '{"equtype":"ANDROID","loginImei":"Androidc0b30f35fc9535b5","sign":"0FAFB81829C15EF86EBD30E214675BBC","timeStamp":"1687772424834","userPwd":"12334","username":"123456789"}' print(des_encrypt(data, desKey, desIV))
执行结果:
NIszaqFPos1vd0pFqKlB42Np5itPxaNH//FDsRnlBfgL4lcVxjXii+GOZz1l+A5V9FPOSMf47jbE010Kk+PbN/jjSVvUEnMkBeVQY2tdy+to9cUXg0XyzdSi3Wehubi6R5t5NLiRanFipatR61mx4ISHB/wjHUkmAFDl2b3zZIYs2UMZhz4YfC4HgFeRqA/9X1+m1LNZQYUkOLl/HqD5GFDgdRel9stq/g+8ZB8fY84=
对照其hook结果一直,还原成功,至此整个协议就分析完成了,Encrypt数据也成功拿到,接下来就是模拟请求了。
六 模拟请求
前面该分析的也都分析好了,写代码这种事情相信各位佬随手拈来,我就不在讲解了,直接上代码,是在不明白,代码中的注释也很全:
from pyDes import CBC, PAD_PKCS5, des from hashlib import md5 import requests import base64 import time def get_md5_mes(mes): """获取字符串的MD5摘要""" new_md5 = md5() new_md5.update(mes.encode(encoding='utf-8')) return new_md5.hexdigest() def des_encrypt(data, desKey, desIV): """DES加密 :param data: 原始字符串 :param desKey: 加密密钥,取前8字节 :return: 加密后的字符串,base64编码 """ key = desKey[:8] # 只需前八字节 ds = des(key, CBC, desIV, pad=None) en = ds.encrypt(data.encode(), padmode=PAD_PKCS5) return base64.b64encode(en).decode() def get_timeStamp(): """获取时间戳(毫秒级)""" return str(int(time.time() * 1000)) def get_sign(): """获取请求签名""" s = 'equtype=ANDROID&loginImei=Androidnull&timeStamp=' + timeStamp + '&userPwd=12334&username=123456789&key=sdlkjsdljf0j2fsjk' return get_md5_mes(s).upper() def get_Encrypt(): """获取加密后的请求参数""" s = '{"equtype":"ANDROID","loginImei":"Androidnull","sign":"' + get_sign() + '","timeStamp":"' + timeStamp + '","userPwd":"12334","username":"123456789"}' return des_encrypt(s, desKey, desIV) def login(): """登录函数""" url = "http://api.dodovip.com/api/user/login" header = { "Host": "api.dodovip.com", "Cache-Control": "public, max-age=0", 'Content-Type': 'application/json; charset=utf-8', 'User-Agent': "Dalvik/2.1.0 (Linux; U; Android 11; M2012K11AC Build/RQ3A.211001.001)", } data = { 'Encrypt': get_Encrypt() } res = requests.post(url, headers=header, json=data) print(res.text) if __name__ == '__main__': desIV = '32028092' # 需转换成 byte 的 hex 值 用 hexstr 来创建 bytes 对象 desKey = bytes.fromhex(get_md5_mes('65102933')) timeStamp = get_timeStamp() login()
结果,与抓包结果一致,返回数据还是加密的:
2v+DC2gq7RuAC8PE5GZz5wH3/y9ZVcWhFwhDY9L19g9iEd075+Q7xwewvfIN0g0ec/NaaF43/S0=
七 des 解密算法分析
对于返回结果是密文也是预料之中的,des 为比较早期的对称加密算法,加密与解密就是一个对称的过程。
请求是 addRequestMap 有 request 那么就会有 response,而且这个方法就在我们找到的 addRequestMap 上方:
public Response> parseNetworkResponse(NetworkResponse response) { String parsed; try { parsed = new String(response.data, HttpHeaderParser.parseCharset(response.headers)); } catch (UnsupportedEncodingException e) { parsed = new String(response.data); } if (this.useDes) { parsed = RequestUtil.decodeDesJson(parsed, this.desKey, this.desIV); } Log.w(AppConfig.DEBUG_TAG, parsed); RequestResult res = (RequestResult) this.mGson.fromJson(parsed, this.typeOfT); res.response = parsed; if (this.useDes) { try { JSONObject object = new JSONObject(parsed); if (object.has("code")) { String code = object.getString("code"); if (code.equals(a.e)) { if (object.has(MapTilsCacheAndResManager.AUTONAVI_DATA_PATH)) { res.response = object.getString(MapTilsCacheAndResManager.AUTONAVI_DATA_PATH); } } else if (code.equals("-10")) { this.mHandler.sendEmptyMessage(0); } } } catch (Exception e2) { e2.printStackTrace(); } } return Response.success(res, HttpHeaderParser.parseCacheHeaders(response)); }
留意:
parsed = RequestUtil.decodeDesJson(parsed, this.desKey, this.desIV);
hook 它看看:
function main() { Java.perform(function () { var RequestUtil = Java.use("com.dodonew.online.http.RequestUtil"); RequestUtil["decodeDesJson"].implementation = function (json, desKey, desIV) { console.log('decodeDesJson is called' + ', ' + 'json: ' + json + ', ' + 'desKey: ' + desKey + ', ' + 'desIV: ' + desIV); var ret = this.decodeDesJson(json, desKey, desIV); console.log('decodeDesJson ret value is ' + ret); return ret; }; }); } setImmediate(main)
结果:
decodeDesJson is called, json: 2v+DC2gq7RuAC8PE5GZz5wH3/y9ZVcWhFwhDY9L19g9iEd075+Q7xwewvfIN0g0ec/NaaF43/S0=, desKey: 65102933, desIV: 32028092
decodeDesJson ret value is {"code":-1,"message":"账号或密码错误","data":{}}
因为我在这给的账号和密码本就是错误的,所以提示账号或密码错误一点问题没有。
