美图秀秀APP协议分析--签名分析
0x01、 目标需求:
.a) 美图秀秀APP的接口参数以及相关的签名获取的方式
.b) 通过关键字来搜索我们想要的数据
0x02、分析背景:
.a) 美图秀秀APP
.b) APP版本9.0.8.0(目前最新)
.c) 软件无壳
.d) 软件通讯过程中采取了签名验证的方式,返回数据无加密
0x03、分析流程:
.a) 通过数据包抓取或敏感函数hook方式获得接口功能
.b) 通过函数内部参数的组装继续分析参数的来源以及加密的流程
0x04、抓取数据包:
.a) 截图如下:
0x001. 抓包分析,首次打开软件会有一个设备注册的接口,上传的设备信息不多,可以构造,主要是sig,sigVersion,sigTime 这三个签名会校验数据的正确性,由于签入了时间戳信息,所以数据即便相同,返回的结果每次都不同,先说一下抓包配置,因为有一些小伙伴可能抓不到一些APP的包.
#通过命令行把Charles 的tcp代理端口转发到手机上
#然后通过手机APP Clash的安卓端 来连接127.0.0.1 的8889端口上
#Clash配置文件有需要的加我QQ
adb reverse tcp:8889 tcp:8889
0x05、分析数据字段来源:
0x001. 老规矩jadx-gui 搜索关键字,由于我已经分析完毕了,代码也写完了,所以我知道是哪个类,如果不知道的话就挨个看一下,注意区分类名.
0x002. 使用Frida hook 该函数可以得传入的参数各个值.
//frida 代码如下
Java.perform(function(){
var s = Java.use("com.meitu.secret.SigEntity");
s.generatorSig.overload('java.lang.String', '[Ljava.lang.String;', 'java.lang.String', 'java.lang.Object').implementation = function(v1,v2,v3,v4){
console.log("v1: \t" + v1);
console.log("v2: \t" + v2);
console.log("v3: \t" + v3);
console.log("v4: \t" + v4);
var res = s.generatorSig(v1,v2,v3,v4);
console.log("4个参数返回值为:" + res.sig.value);
var res2 = s.generatorSig(v1,v2,v3);
console.log("三个参数返回值为:" + res2.sig.value);
return res2;
}
})
0x003. 由上图可以得知,两个函数调用哪个都是没问题的,都是有效的,都可以用,静态分析so release_sig
参数1: 请求地址的uri 域名后的路径不携带问号后的参数
参数2: 数String类型的数组,可以得知,该参数为url的问号后的参数的value值,经过测试,该参数我本以为是要字典排序,但是发现顺序并不是字典排序,我手动调整了下参数顺序,并不影响请求结果,所以这里顺序不要求,应该是在so内做的排序,服务器端也是收到后获取所有的参数 在排序加密,校验。
参数3: 固定值 6184556633574670337 应该是个加密的密钥之类的东西
0x004. 提取so文件以及安卓代码,代码如下(合适的地方直接调用SigEntity.generatorSig(str1,str2,str3);即可,返回值为SigEntity,直接获取sig,sigVersion,sigTime 三个字段值即可):
package com.meitu.secret;
import android.content.Context;
import android.util.Log;
/**
* 美图秀秀Sig签名处理
*/
public class SigEntity {
private static final String SO_NAME = "release_sig";
public String finalString;
public String sig;
public String sigTime;
public String sigVersion;
static {
System.loadLibrary(SO_NAME);
}
public SigEntity(String str, String str2, String str3) {
this.sigTime = str;
this.sigVersion = str2;
this.sig = str3;
}
public SigEntity(String str, String str2, String str3, String str4) {
this.sigTime = str;
this.sigVersion = str2;
this.sig = str3;
this.finalString = str4;
}
public static native SigEntity nativeGeneratorSig(String str, byte[][] bArr, String str2, Object obj);
public static native SigEntity nativeGeneratorSigFinal(String str, byte[][] bArr, String str2, Object obj);
public static native SigEntity nativeGeneratorSigOld(String str, byte[][] bArr, String str2);
public static SigEntity generatorSig(String str, String[] strArr, String str2, Object obj) throws Exception {
if (str == null || strArr == null || str2 == null || obj == null) {
throw new Exception("path or params[] or appId or mContext must not be null.");
} else if (obj instanceof Context) {
byte[][] bArr = new byte[strArr.length][];
for (int i = 0; i < strArr.length; i++) {
if (strArr[i] == null) {
Log.e("SigEntity", str + " params[" + i + "] is null, encryption result by server maybe failed.");
strArr[i] = "";
}
bArr[i] = strArr[i].getBytes();
}
try {
return nativeGeneratorSig(str, bArr, str2, obj);
} catch (UnsatisfiedLinkError unused) {
return nativeGeneratorSig(str, bArr, str2, obj);
}
} else {
throw new Exception("mContext must be Context!");
}
}
public static SigEntity generatorSig(String str, String[] strArr, String str2) throws Exception {
if (str == null || strArr == null || str2 == null) {
throw new Exception("path or params[] or appId must not be null.");
}
byte[][] bArr = new byte[strArr.length][];
for (int i = 0; i < strArr.length; i++) {
if (strArr[i] == null) {
Log.e("SigEntity", str + " params[" + i + "] is null, encryption result by server maybe failed.");
strArr[i] = "";
}
bArr[i] = strArr[i].getBytes();
}
return nativeGeneratorSigOld(str, bArr, str2);
}
public static SigEntity generatorSigWithFinal(String str, String[] strArr, String str2, Object obj) throws Exception {
if (str == null || strArr == null || str2 == null) {
throw new Exception("path or params[] or appId must not be null.");
}
byte[][] bArr = new byte[strArr.length][];
for (int i = 0; i < strArr.length; i++) {
if (strArr[i] == null) {
Log.e("SigEntity", str + " params[" + i + "] is null, encryption result by server maybe failed.");
strArr[i] = "";
}
bArr[i] = strArr[i].getBytes();
}
return nativeGeneratorSigFinal(str, bArr, str2, obj);
}
}
0x005. 服务器返回数据结构分析:
0x006. java代码如下:
package com.crawl.main;
import com.alibaba.fastjson.JSONArray;
import com.alibaba.fastjson.JSONObject;
import com.crawl.utils.HttpTools;
import com.crawl.utils.HttpsTools;
import okhttp3.Call;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
import org.apache.commons.lang3.StringUtils;
import org.openqa.selenium.remote.http.HttpClient;
import java.io.IOException;
import java.net.URLEncoder;
import java.nio.file.attribute.AclEntry;
import java.util.*;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
/**
* @project: zhihuDemo
* @package: com.crawl.main
* @Description: 美图秀秀最新版9.0.8.0搜索接口爬虫
* @Since: 1.8.0_111
* @Auther MyPC, QuJianJun
* @Email: 8577352@qq.com
* @Date: 2021-01-13 00:10:33
*/
public class MeiTuSearchMain {
static final String MEITUXIUXIU_SEARCH_API_URL = "http://api.xiuxiu.meitu.com/v1/search/feeds.json";
static String ENCRYPT_API_URL = "http://49.232.3.169:7788/?type=meitu";
public static void main(String[] args) {
// System.setProperty("proxyType", "4");
// System.setProperty("proxySet", "true");
// System.setProperty("proxyHost", "127.0.0.1");
// System.setProperty("proxyPort", "8888");
//,"除暴","沐浴之王"
String[] keyWords = {"丁真"};
//线程池数量,根据关键词数量开启多线程
ExecutorService executor = Executors.newFixedThreadPool(keyWords.length);
for (String keyWord: keyWords){
executor.execute(new Runnable() {
@Override
public void run() {
//调用请求函数,获取搜索数据
startSearch(keyWord,"");
}
});
}
}
/**
* 开始搜索关键词,分页没写,页码参数为cursor从返回数据内获取,传入递归调用即可.
* @param keyWord
*/
public static void startSearch(String keyWord, String cursor){
//构造请求参数,开始组装组装参数
Map<String,String> map = new HashMap<>();
if (StringUtils.isNotBlank(cursor)){
map.put("cursor",cursor);
}
map.put("client_timestamp",String.valueOf(System.currentTimeMillis()));
map.put("client_operator","中国联通");
map.put("client_timezone","GMT+8");
map.put("is_gdpr","0");
map.put("gid","2430718148");
map.put("client_channel_id","taobao");
map.put("client_model",genSixToSixteenPsw(5).toUpperCase());
map.put("client_brand","Redmi");
map.put("resolution","1080*2196");
map.put("client_language","zh_CN");
map.put("client_id","1089867602");
map.put("home_style","10");
map.put("runtimeMaxMemory","512");
map.put("ad_sdk_version","4.23.20");
map.put("client_os","10");
map.put("feed_sort","normal");
map.put("is_test","0");
map.put("keyword", keyWord);
map.put("is_privacy","0");
map.put("client_network","4G");
map.put("user_agent","mtxx-9080-Xiaomi-M2003J15SC-android-10-"+genSixToSixteenPsw(7));
map.put("oaid",genSixToSixteenPsw(16));
map.put("ram","3754");
map.put("count","12");
map.put("version","9.0.8.0");
map.put("personality_not_recommend","0");
map.put("community_version","2.0.0");
map.put("country_code","CN");
map.put("app_hot_start_times","0");
//is_video == 1 只搜索视频,0搜索全部
map.put("is_video","1");
map.put("attachFlag","153");
map.put("android_sdk_int","29");
map.put("is64Bit","0");
map.put("is_device_support_64","1");
map.put("android_id",genSixToSixteenPsw(16));
//机器是否root,这里给0 未root
map.put("client_is_root","0");
List<String> list = new ArrayList<>();
for (Map.Entry m : map.entrySet()){
list.add(map.get(m.getKey()));
}
String encryptArr = JSONObject.toJSONString(list);
// System.out.println(encryptArr);
//请求加密服务,获取加密结果
String res = HttpTools.sendPost(ENCRYPT_API_URL,encryptArr,null);
//解析加密结果
int code = JSONObject.parseObject(res).getInteger("code");
if (code == 200){
res = JSONObject.parseObject(res).getString("data");
String sig = JSONObject.parseObject(res).getString("sig");
String sigVersion = JSONObject.parseObject(res).getString("sigVersion");
String sigTime = JSONObject.parseObject(res).getString("sigTime");
System.out.println("sig:"+sig+"\tsigVersion:"+sigVersion+"\tsigTime:"+sigTime);
map.put("sig",sig);
map.put("sigVersion",sigVersion);
map.put("sigTime",sigTime);
}else{
System.out.println("签名服务返回状态码错误...");
return;
}
//准备请求 美图秀秀服务器获取数据
okhttp3.OkHttpClient client = new OkHttpClient();
Request request = new Request.Builder()
.url(MEITUXIUXIU_SEARCH_API_URL+"?"+mapToParam(map))
.addHeader("User-Agent",map.get("user_agent"))
.addHeader("Connection","keep-alive")
.build();
Call call = client.newCall(request);
try {
Response response = call.execute();
res = response.body().string();
} catch (IOException e) {
e.printStackTrace();
}
int errorCode = JSONObject.parseObject(res).getInteger("error_code");
if (errorCode == 0){
//数据返回成功,开始解析,获取data字段数据
String data = JSONObject.parseObject(res).getString("data");
//遍历列表数据
JSONArray jsonArray = JSONObject.parseObject(data).getJSONArray("items");
for (Object item : jsonArray){
//源 ID
String feedId = JSONObject.parseObject(item.toString()).getString("feed_id");
// 标题
String title = JSONObject.parseObject(item.toString()).getString("title");
//经度
String lng = JSONObject.parseObject(item.toString()).getString("lng");
//纬度
String lat = JSONObject.parseObject(item.toString()).getString("lat");
//喜欢次数 点赞次数
String likeCount = JSONObject.parseObject(item.toString()).getString("like_count");
//展示次数
String viewCount = JSONObject.parseObject(item.toString()).getString("view_count");
//评论次数
String commentCount = JSONObject.parseObject(item.toString()).getString("comment_count");
//创建时间
String createTime = JSONObject.parseObject(item.toString()).getString("create_time");
//用户id
int userId = JSONObject.parseObject(item.toString()).getJSONObject("user").getInteger("uid");
//用户昵称
String nickName = JSONObject.parseObject(item.toString()).getJSONObject("user").getString("screen_name");
//用户头像url
String headImage = JSONObject.parseObject(item.toString()).getJSONObject("user").getString("avatar_url");
//用户描述信息
String description = JSONObject.parseObject(item.toString()).getJSONObject("user").getString("desc");
//媒体信息数组
String url = "无数据...";
if(JSONObject.parseObject(item.toString()).getJSONArray("medias").size()>0) {
JSONObject medias = JSONObject.parseObject(item.toString()).getJSONArray("medias").getJSONObject(0);
String mediaId = medias.getString("media_id");
String type = medias.getString("type");
//这个url可能会过期
url = medias.getString("url");
String text = medias.getString("text");
String width = medias.getString("width");
String height = medias.getString("height");
String duration = medias.getString("duration");
String file_size = medias.getString("file_size");
//这个请求后还需再次解析,这个应该是不会过期的url
String dispatch_video = medias.getString("dispatch_video");
String coverUrl = medias.getString("cover_url");
}
System.out.println(String.format("作者昵称:[%s], 标题:[%s], 播放次数:[%s], 点赞次数:[%s], 评论次数:[%s], 视频地址:[%s]",nickName,title,viewCount,likeCount,commentCount,url));
}
cursor = JSONObject.parseObject(data).getString("next_cursor");
if (StringUtils.isBlank(cursor)){
System.out.println("没有更多数据了,退出.");
return;
}else{
System.out.println("开始翻页操作...");
//递归调用翻页操作
startSearch(keyWord,cursor);
}
}else{
System.out.println(String.format("美图服务器返回错误,{%s}", res));
}
}
/**
* map转url参数
* @param map
* @return
*/
public static String mapToParam(Map<String,String> map){
StringBuilder sb = new StringBuilder();
for(Map.Entry entry: map.entrySet()){
String key = entry.getKey().toString();
//特殊原因 Url编码后 空格会变成加号服务器接收到后就丢失了所以这里编码后替换成%20,
sb.append(key + "=" + URLEncoder.encode(map.get(key)) + "&");
}
return sb.substring(0,sb.toString().length()-1);
}
/**
* 获取指定长度的随机数 到小写
* @param length
* @return
*/
public static String genSixToSixteenPsw(int length) {
String val = "";
Random random = new Random();
for (int i = 0; i < length; i++) {
// 输出字母还是数字
String charOrNum = random.nextInt(2) % 2 == 0 ? "char" : "num";
// 字符串
if ("char".equalsIgnoreCase(charOrNum)) {
//取得大写字母还是小写字母
int choice = random.nextInt(2) % 2 == 0 ? 65 : 97;
val += (char) (choice + random.nextInt(26));
} else if ("num".equalsIgnoreCase(charOrNum)) {
// 数字
val += String.valueOf(random.nextInt(10));
}
}
return val.toLowerCase();
}
}
0x06、测试代码:
0x001. 测试运行结果:
0x07、soHook分析算法:
0x001. hook关键so(经过分析,得出的结果需要进行单双位互换即可),算法为MD5
0x002. Frida Hook 代码所在位置 Gitee
友情提示:本文只为技术分享交流,请勿非法用途.产生一切法律问题与本人无关
在浏览的同时希望给予作者打赏,来支持作者的服务器维护费用.一分也是爱~