项目地址:https://github.com/tbruceyu/AppCaller
前段时间闲的没事,经常刷某视频App。里面有很多有才的人,突然想把他们的视频都给下载下来在电脑上面存起来慢慢看。正好这段时间比较空闲,就尝试去破解一下它的Http加密协议。
作为一个主要工作在客户端上的码农,第一时间想到了去抓包看一下他们的App协议。
可以看到,通过调用http://103.107.217.65/rest/n/feed/profile2这个接口就可以去获取到这个主播的视频了。然后通过分页加载即可下载所有的视频。但是通常的App都会对请求做保护,防止被爬虫抓取。看了下我们的请求里面有一个sig xxxxxx的字段。看来我们需要去破解这个签名算法才行。
通常的破解都是用Java的反编译工具去找到App的上层加密调用函数,找到Java层的核心加密函数的位置,然后再用静态分析和动态调试汇编代码,最后自己手动实现这个加密算法。这个方法是最完美的破解方案,但是难度巨大,而且如果App升级后,又需要重新去完整分析一遍。对技术的要求还是很高的。而且现在越来越多的APP会把so里面的符号表去掉,还会混淆核心函数的实现,加大分析难度。以某音的加密libcms.so为例,里面的汇编代码都被混淆了,用IDA的F5插件转化后的代码也根本读不懂。里面连RegisterNative函数调用都找不到。
如果要用这种方式去破解移动App的协议,无疑要花费大量的时间和精力,而且我自己本身也没有太多逆向分析的经验,那我们能不能用其他的方式去做呢?
在Android开发生态下,有很多的黑科技,其中一种就是应用双开技术,比如BLE平行空间、VirtualApp等。好在16年的时候VirtuapApp开源后我有去研究过它的代码,核心原理就是利用Java动态代理和反射技术,自己模拟一个Android的Framework,架设在App和系统之间,达到欺骗App和系统的目的。知道了沙盒技术的原理。思路来了:
利用沙盒把App放到自己能够控制的环境里面去执行,然后在里面启动一个Web服务器,通过远程Http调用访问沙盒里的App,去调用核心加密逻辑,计算出密钥,然后返回给客户端。这样就能够达到破解App的加密算法的目的。
废话不说,开干!
apktool 工具先把这个App的包解出来:
xxx@bogon:~/temp/kuaishou$ apktool d kuaishou.apk
I: Using Apktool 2.4.0 on kuaishou.apk
I: Loading resource table...
I: Decoding AndroidManifest.xml with resources...
I: Loading resource table from file: /Users/xxx/Library/apktool/framework/1.apk
I: Regular manifest package...
I: Decoding file-resources...
I: Decoding values */* XMLs...
I: Baksmaling classes.dex...
I: Baksmaling classes10.dex...
I: Baksmaling classes11.dex...
I: Baksmaling classes12.dex...
I: Baksmaling classes13.dex...
I: Baksmaling classes14.dex...
I: Baksmaling classes2.dex...
I: Baksmaling classes3.dex...
I: Baksmaling classes4.dex...
I: Baksmaling classes5.dex...
I: Baksmaling classes6.dex...
I: Baksmaling classes7.dex...
I: Baksmaling classes8.dex...
I: Baksmaling classes9.dex...
I: Copying assets and libs...
I: Copying unknown files...
I: Copying original files...
xxx@bogon:~/temp/kuaishou$
然后我们去里面搜索sig字符串,看在哪里加入到Http请求里的:
xxx@bogon:~/temp/kuaishou$ cd kuaishou/
xxx@bogon:~/temp/kuaishou/kuaishou$ grep sig . -rw
./smali_classes10/com/xiaomi/push/service/p.smali: const-string/jumbo v0, "sig"
./smali_classes10/com/xiaomi/push/service/XMPushService$c.smali: const-string/jumbo v3, "invalid-sig"
./smali_classes10/com/xiaomi/push/service/XMPushService$c.smali: const-string/jumbo v4, "SMACK: bind error invalid-sig token = "
Binary file ./lib/armeabi-v7a/libgodzilla.so matches
Binary file ./lib/armeabi-v7a/libBugly.so matches
Binary file ./lib/armeabi-v7a/libksimage.so matches
Binary file ./lib/armeabi-v7a/libCGE.so matches
Binary file ./lib/armeabi-v7a/libawesomecache.so matches
Binary file ./lib/armeabi-v7a/libkwsgmain.so matches
Binary file ./lib/armeabi-v7a/libxylivesdk.so matches
Binary file ./lib/armeabi-v7a/libquic.so matches
Binary file ./lib/armeabi-v7a/libYTFaceReflect.so matches
./smali_classes2/com/yxcorp/gifshow/retrofit/k.smali: const-string/jumbo v2, "sig"
xxx@bogon:~/temp/kuaishou/kuaishou$
前面几个smali文件明显是小米的渠道Push相关的代码,可以忽略,肯定是./smali_classes2/com/yxcorp/gifshow/retrofit/k.smali这一个retrofit里面加入的了。我们打开这个文件,果然发现这个sig就是里面的一个叫computeSignature的方法返回的Pair的first,并且参数就是okhttp的Request,很明显是用来计算密钥的。
# virtual methods
.method public final computeSignature(Lokhttp3/Request;Ljava/util/Map;Ljava/util/Map;)Landroid/util/Pair;
.locals 5
.annotation system Ldalvik/annotation/Signature;
value = {
"(",
"Lokhttp3/Request;",
"Ljava/util/Map",
"<",
"Ljava/lang/String;",
"Ljava/lang/String;",
">;",
"Ljava/util/Map",
"<",
"Ljava/lang/String;",
"Ljava/lang/String;",
">;)",
"Landroid/util/Pair",
"<",
"Ljava/lang/String;",
"Ljava/lang/String;",
">;"
}
.end annotation
.prologue
.line 23
const-string/jumbo v0, ""
invoke-static {p2, p3}, Lcom/yxcorp/retrofit/f/a;->b(Ljava/util/Map;Ljava/util/Map;)Ljava/util/List;
move-result-object v1
invoke-static {v0, v1}, Landroid/text/TextUtils;->join(Ljava/lang/CharSequence;Ljava/lang/Iterable;)Ljava/lang/String;
move-result-object v0
.line 24
new-instance v1, Landroid/util/Pair;
const-string/jumbo v2, "sig"
.line 25
invoke-static {}, Lcom/yxcorp/gifshow/b;->a()Lcom/yxcorp/gifshow/e;
move-result-object v3
invoke-interface {v3}, Lcom/yxcorp/gifshow/e;->b()Landroid/app/Application;
move-result-object v3
sget-object v4, Lorg/apache/internal/commons/io/a;->f:Ljava/nio/charset/Charset;
invoke-virtual {v0, v4}, Ljava/lang/String;->getBytes(Ljava/nio/charset/Charset;)[B
move-result-object v0
sget v4, Landroid/os/Build$VERSION;->SDK_INT:I
invoke-static {v3, v0, v4}, Lcom/yxcorp/gifshow/util/CPU;->a(Landroid/content/Context;[BI)Ljava/lang/String;
move-result-object v0
invoke-direct {v1, v2, v0}, Landroid/util/Pair;->(Ljava/lang/Object;Ljava/lang/Object;)V
.line 24
return-object v1
.end method
这段Smili转化成Java大致如下:
public final Pair computeSignature(Request request, Map map, Map map2) {
return new Pair<>("sig", CPU.a(com.yxcorp.gifshow.b.a().b(), TextUtils.join("", a.b(map, map2)).getBytes(org.apache.internal.commons.io.a.f), VERSION.SDK_INT));
}
这里可以看到,计算签名和request参数根本没有关系,那么map和map2分别是什么呢?此时我们可以祭出Hook大法。我们就可以去写一个插件去Hook看一下方法参数里面的两个Map分别是什么。站在巨人的肩膀上(用现成的),我这里用的VirtualHook这个库来Hook打印的。当然我们也可以用Xposed或者VirtualPosed去Hook。
package lab.galaxy.demeHookPlugin;
import android.util.Log;
import android.util.Pair;
import java.util.Map;
public class Hook_computeSignature {
public static String className = "com.yxcorp.gifshow.retrofit.k";
public static String methodName = "computeSignature";
public static String methodSig = "(Lokhttp3/Request;Ljava/util/Map;Ljava/util/Map;)Landroid/util/Pair;";
public static Pair hook(Object thiz, Object request, Map map, Map map2) {
Log.d("YAHFA", "map:" + map.toString());
Log.d("YAHFA", "map2:" + map2.toString());
return backup(thiz, request, map, map2);
}
public static Pair backup(Object thiz, Object request, Map map, Map map2) {
Log.e("YAHFA", "should not be here");
return null;
}
}
配合抓包工具和第二,三个map的打印结果可以发现其实map就是所有的URL Query,map2就是所有的POST的内容
10-14 15:50:01.003 30075-30253/com.smile.gifmaker D/YAHFA: map:{isp=, mod=vivo(vivo X7), pm_tag=, lon=333.355416, country_code=CN, kpf=ANDROID_PHONE, extId=85db4aca4443a3c46a5bac6ed1c78836, did=ANDROID_0b171a80c3ff2d40, kpn=KUAISHOU, net=WIFI, app=0, oc=BAIDU, ud=0, hotfix_ver=, c=BAIDU, sys=ANDROID_5.1.1, appver=6.4.0.9003, ftt=, language=zh-cn, iuid=, lat=33.33333, did_gt=1571111544356, ver=6.4, max_memory=256}
10-14 15:50:01.003 30075-30253/com.smile.gifmaker D/YAHFA: map2:{source=1, volume=0.19, browseType=1, seid=52203040-6dce-1111-b08d-d9ae060c2718, pv=false, needInterestTag=false, client_key=3c2cd3f3, coldStart=false, count=20, pcursor=, os=android, refreshTimes=1, id=10, type=7, page=1}
field。有了这些分析,我们接下来就开始去开始实现我们的想法了。
VirtualApp实现了一套很方便的反射架构,我们可以直接拿来用。不过需要注意一下,由于这里需要反射的是应用的类,而VirtualApp的反射框架是直接反射的系统的类,加载时机是virtualapp自身被fork出来的时候,所以是可以直接加载使用的,而我们要反射的是应用内的类,这些类是在应用的Application创建的时候新建的ClassLoader加载的(LoadedApk.makeApplication)。而我们在VirtualApp里的类环境是相对于这个app的父环境。所以我们需要用新创建的这个ClassLoader才能够加载App内我们需要调用到的类。关于这块有疑惑的同学可以去了解一下Java类的双亲委派的加载机制。代码如下:
package plugins.kuaishou.com.yxcorp.gifshow.retrofit;
import android.util.Pair;
import mirror.RefClass;
import mirror.RefMethod;
public class k {
public static void init(ClassLoader classLoader) {
try {
TYPE = RefClass.load(k.class, classLoader.loadClass("com.yxcorp.gifshow.retrofit.k"));
} catch (ClassNotFoundException e) {
e.printStackTrace();
}
}
public static Class> TYPE;
public static RefMethod> computeSignature;
}
接下来然后我们可以写一个简单的测试函数把抓包工具抓取到的GET Query和POST Fields去除POST里的sig组合成两个map,然后通过
Pair res = k.computeSignature.call(instance, null, queryParams, fieldParams);
Log.d("test", "sig:" + res.second);
去调用看一下结果是否和抓包工具里面的相同。我这里已经验证了这个方法就是计算签名的,就不做过多的赘述了。
在Java世界里的现成的Http服务库太多了,我们这里选择了使用Netty库来架设Http服务。Netty库是业界广泛使用的一个NIO实现的异步事件驱动网络框架,并且Netty自身就带了Http的Handler,不用花什么功夫就能做出来。大致就下面这么几行代码
考虑到大部分的应用的加密模块都会做签名校验,一般会在Application创建的时候去初始化,所以我们的VirtualApp里面多开启动的App都是在一个新的子进程里。在VirtualApp环境里里子App的Application的onCreate调用就在VClientImpl的bindApplicationNoCheck里。所以我们的服务需要是在这之后启动。我们这里简单的抽象包装了一下,万一以后要增加其他的App呢。
package plugins;
import android.content.Context;
import com.tby.http.SmHttpRequest;
import java.util.List;
import java.util.Map;
public interface IPlatformPlugin {
void init(Context context);
SmHttpRequest process(String url, String commonParams, Map fields, Map> headers);
}
我们把这个功能抽象成了init和process两步初始化的时候会把App的Application传入进去,而处理就是具体要做的工作。要新增加一个App直接去实现这两个接口即可。
我们简单的设计了客户端一个请求协议:
里面包含请求的URL,commonParams用于区分App的一些公共参数,field是所有的POST Fields,header就是所有的HTTP请求头。服务器返回就直接在这个格式里面移除commonParams,插入签名字段就可以了。
接下来就是体力活编写启动Http服务的工作了:
MainServerThread thread = new MainServerThread(port);
thread.addPathHandler("/api", new IPathHandler() {
@Override
public void handleRequest(ChannelHandlerContext ctx, FullHttpRequest httpRequest) throws Exception {
String body = HttpServerUtils.getBody(httpRequest);
JSONObject jsonObject = new JSONObject(body);
String url = jsonObject.getString("url");
String commonParams = jsonObject.getString("commonParams");
Map> headers = JsonUtil.parseHeaders(jsonObject.getJSONObject("header"));
Map fields = JsonUtil.parseFields(jsonObject.optJSONObject("field"));
SmHttpRequest request = plugin.process(url, commonParams, fields, headers);
HttpServerUtils.send(ctx, packageResult(request).toString(), HttpResponseStatus.OK);
}
});
thread.start();
下面就是去PC环境上运行这个架设在手机端上的远程服务了。我这手机肯定很高兴:平时都是我去调用服务,这次终于轮到你们来调用我了: )
现在App上面把我们的App运行起来,如下图所示:
接下来在终端下去测试一下。先去查一下手机的IP地址,然后用curl请求:
xxx@bogon:~$ curl http://xxx.xxx.xxx.xxx:8889/api -X POST -d ‘{ “url”: “http://api.ksapisrv.com/rest/n/feed/profile2?app=0&kpf=ANDROID_PHONE&ver=6.4&c=BAIDU”, “commonParams”: “mod=Xiaomi%28MI%208%29&appver=6.4.0.9003&ftt=K-F-T&isp=CTCC&kpn=KUAISHOU&lon=333.337841&language=zh-cn&sys=ANDROID_9&max_memory=512&ud=0&country_code=cn&pm_tag=11694575470&oc=BAIDU&hotfix_ver=&did_gt=153xxxxx91012&iuid=&net=WIFI&did=ANDROID_a1dfcb2b57035073&lat=333.979012”, “field”: { “token”:"", “user_id”:“3848”, “lang”:“zh”, “count”:“30”, “privacy”:“public”, “referer”:“ks%3A%2F%2Fprofile%2F3848%2F5246693579318535881%2F1_i%2F1633500022418448386_h61%2F8”, “browseType”:“1”, “client_key”: “xxx”, “os”: “android” }, “header”: { “User-Agent”: [“kwai-android”], “Accept-Language”: [“zh-cn”], “X-REQUESTID”: [“193601906”], “Host”: [“api.ksapisrv.com”] } }’
{“url”:“http://api.ksapisrv.com/rest/n/feed/profile2?app=0&kpf=ANDROID_PHONE&ver=6.4&c=BAIDU&mod=Xiaomi%28MI%208%29&appver=6.4.0.9003&ftt=K-F-T&isp=CTCC&kpn=KUAISHOU&lon=333.337841&language=zh-cn&sys=ANDROID_9&max_memory=512&ud=0&country_code=cn&pm_tag=11694575470&oc=BAIDU&hotfix_ver=&did_gt=1539073091012&iuid=&net=WIFI&did=ANDROID_a1dfcb2b57035073&lat=333.979012”,“header”:{“User-Agent”:[“kwai-android”],“Accept-Language”:[“zh-cn”],“X-REQUESTID”:[“193601906”],“Host”:[“api.ksapisrv.com”]},“method”:0,“field”:{“token”:"",“user_id”:“3848”,“lang”:“zh”,“count”:“30”,“privacy”:“public”,“referer”:“ks%3A%2F%2Fprofile%2F3848%2F5246693579318535881%2F1_i%2F1633500022418448386_h61%2F8”,“browseType”:“1”,“client_key”:“xxx”,“os”:“android”,“sig”:"8a2fb705bed7471328f99d7f3d91f928"}}
xxx@bogon:~$
我们这个方案是基于VirtualApp的沙盒环境做的,直接用现有的双开检测方案即可。通过检测应用的工作目录路径是否是正常的/data/data/目录是否正常即可。
在实现这个项目的过程中愈发觉得在Android平台下的App的安全真的很难做。这次破解的这个App本身是很容易破解的,后来我又尝试去破解了另外一个很火的短视频App,过程比较曲折一些,但是一样很快就分析破解了。就算我们能够通过双开检测来从上层去防范应用被注入,但是Android平台本身是开源的,我们完全可以在系统上面直接注入类似这样的Hack代码去直接调用某一个应用的一些敏感函数,如何防范这种方式的注入?看起来只能混淆得更深才行了。不过始终没有绝对的安全,最重要的还是服务端的防爬取做好,端控、频控做好。而在客户端上能够提高破解门槛,杜绝大部分的心怀不轨的用户就行了。
另外关于Android的沙盒程序真是一个神器,用来分析和其他应用都十分有用。试想一下你编译了一个Debug版本的VirtualApp,就能够去直接调试其他的应用。用来做一些竞品性能分析,动态调试,关键技术分析等都十分有用。
本项目仅用于学习使用,严禁用于任何商业用途