美图秀秀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



配置抓包



连接vpn



抓包分析



0x05、分析数据字段来源:



    0x001. 老规矩jadx-gui 搜索关键字,由于我已经分析完毕了,代码也写完了,所以我知道是哪个类,如果不知道的话就挨个看一下,注意区分类名.

搜索关键字



签名信息类



加密函数



    0x002. 使用Frida hook 该函数可以得传入的参数各个值.

frida 验证签名



//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 应该是个加密的密钥之类的东西


ida分析so文件



ida静态分析



    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

hook so



友情提示:本文只为技术分享交流,请勿非法用途.产生一切法律问题与本人无关



在浏览的同时希望给予作者打赏,来支持作者的服务器维护费用.一分也是爱~