记一次逆向 apk 寻找 sig 参数生成算法过程

闲来无事,决定了结一件之前曾简单研究过但因遇到困难未能深入的事情。当时,我在研究币安 app 登录接口协议头中的 sig 参数生成算法,通过 jadx 分析,发现参数生成在 so 层,顿感复杂耗时,于是便不了了之了。

准备

  • Frida
  • Fiddler
  • IDA64
  • Jadx
  • Android手机(已root)
  • 币安 2.79.5 apk

关于工具配置以及环境搭建等问题请自行 Google 解决。另外,如遇 Fiddler 无法抓包,请将 Fiddler 证书设置为系统级别证书,一般可解决。

抓包

开启抓包软件,打开币安,输入账号点击登录,可以发现有好几个包,只需要看关于登录操作的第一个包,请求数据如下:

POST https://www.yingwangtech.club/bapi/accounts/v1/public/account/security/precheck HTTP/1.1
Cache-Control: no-cache, no-store
clienttype: android
x-trace-id: android_00b26de5-64eb-4823-9ed5-dc8261d7b873
fvideo-id: 23a105016402afefb4b6c7bf1082ca74bff85b28
fvideo-token: 2njCtuScAjgWnd9XqENtQaUK/FwUiWr7t/IObMhu7RbqT/szBC5yzeiMuEnAnuqApL/doIomtjl8BldkyJRhtcBf14emblPXjKv2eqQ9tQSDC+XYK4IS1jEH6FrYVgykqile+CylqQBt0+nb09JF4KHP7cYyIbHc+dmTDMLiX5Fm57lqf9ETrJxaJaVSG1bG4=6e
lang: zh-CN
versioncode: 27905
versionname: 2.79.5
isNight: true
BNC-App-Mode: lite
BNC-UUID: 4f609e63-938f-4d6c-8692-b05209204c19
BNC-Time-Zone: Asia/Shanghai
BNC-App-Channel: binance
device-info: eyJkZXZpY2VfaWQiOiIiLCJhX2Jvb3Rsb2FkZXIiOiJ1bmtub3duIiwiYV9icmFuZCI6IlJlZG1pIiwiYV9sb2NhdGlvbl9jaXR5IjoidW5rbm93biIsImFfY3B1X2FiaSI6Ilthcm02NC12OGEsIGFybWVhYmktdjdhLCBhcm1lYWJpXSIsImFfZGV2aWNlX2xvZ2luX25hbWUiOiJwZWFybCIsImRldmljZV9uYW1lIjoiMjMwNTRSQTE5QyIsImFfZGlzcGxheSI6IlRQMUEuMjIwNjI0LjAxNCIsImFfZmluZ2VycHJpbnQiOiJSZWRtaS9wZWFybC9wZWFybDoxMy9UUDFBLjIyMDYyNC4wMTQvVjE0LjAuNi4wLlRMSENOWE06dXNlci9yZWxlYXNlLWtleXMiLCJhX2hvc3QiOiJwYW5ndS1idWlsZC1jb21wb25lbnQtc3lzdGVtLTIxMDk4OC1xNGpwcC1yNDl2Ny14c3pxcCIsImFfZGV2aWNlX3ZlcnNpb25faWQiOiJUUDFBLjIyMDYyNC4wMTQiLCJhX2ltZWkiOiJ1bmtub3duIiwiYV9yb21fc2l6ZSI6IjIzMSwwNjFNQiIsImFfbWFjX2FkZHJlc3MiOiJ1bmtub3duIiwiYV9nZXRfbGluZV9udW1iZXIiOiJ1bmtub3duIiwiYV9wcm9kdWN0IjoicGVhcmwiLCJhX3JhbV9zaXplICI6IjExLDQ5OU1CIiwiYV9zY3JlZW5IZWlnaHQiOiIyMjMwIiwiYV9zY3JlZW5XaWR0aCI6IjEwODAiLCJhX3NkayI6IjMzIiwiYV9zZXJpYWxfaW5mbyI6InVua25vd24iLCJhX3NpbV9zZXJpYWxfbnVtYmVyIjoidW5rbm93biIsImFfYnVpbGRfdGltZSI6IjE2ODg5ODYxMjUwMDAiLCJhX3VzZXIiOiJidWlsZGVyIiwiYnJhbmRfbW9kZWwiOiJSZWRtaTIzMDU0UkExOUMiLCJhX2FwcF9pbnN0YWxsX2RhdGUiOiIxNzEwMzQwOTMwNDI2IiwianVkZ2Vfcm9vdCI6MCwic2NyZWVuX3Jlc29sdXRpb24iOiIxMDgwKjIyMzAiLCJzeXN0ZW1fbGFuZyI6InpoLUNOIiwic3lzdGVtX3ZlcnNpb24iOiIzMyIsInN5c3RlbV92ZXJzaW9uX25hbWUiOiIxMyIsInRpbWV6b25lIjoiR01UKzA4MDAifQ==
mclient-x-tag: pch5D9lsORjgObhyjdSK
BNC-Currency: CNY
referer: https://www.binance.com/
x-seccheck-sig: a1.5.4#NwEAAJQAAABXAAAAKAEAAAMpCJCBG6gss1NtWENQ5NJfZdK6u5ZrsWOdKivvYe15xwIVq39PCr6uCkzvwcTryB-JBXlhRP415kpIYQByS3LqVbXn7gclnT737wbyWF775TGq0-fbj1cSPuhwjTOi-BGTUbQwcb2veXzc4ppGFGmDimK_2OxC84ZQF8ifrC_I1h8muzQjSNyZuDoCysHz-kqzlWWUscj3PR9UcjF0AWT1zU4sjCo3WgL03mg0CjnOZg5HnTsE3LqxeD-U3gWiK_x700QspKMMM12jVZBSEnmlzBsR4TsrQsc_OqkK7eTm-vVimuAQBzrA_FLkysd2V_ikNnYxAHOk07Tw5xlbU58cO6TaO6h2Pi4zanG1PXYKxc5vrpImtK28lsbsvuKBDeia9jk6pxFfKKx1DEga1XKP58hQJNoLN1gI9UuBP-bMJLtaLnS5PcADzveUVGQIu8K2qSEW_8y53xVdjwTbwsmZhN09VkeCMido09fNgbJkBJBWCQ
x-seccheck-token: a1.5.4#9wAAABQCAABXAQAAyAEAADXrtoQZCgS6KOzH_RNBIU_sDNHfkyBw5DzH_t2Uyq9o-FJ4Gk0FFULPC8LoZGDVD55D4i1AMLNSDo6p3afkcEEVIJCA_BHeJS59c18wNDZzcY3BuOZphJ9L1-0pvzGFUMvvjIMTOlS29uakXrRvmEdyJvcpGPoebXf5HA6iBOvmGuXYCykRaO7GJ759YMYiqIgjbESKF1rLT-QLEukhi7031E2XACc6oR5IlxkV1ju-67fL8o5vqPZSbSVX2Tsf1Qr7GDh4460zLyX_bOZBG_EsWxPJm-XBe32KJaIHScDQMBGdypVkXIgYUYYzVeWvpe3kHg69Smj3fWMt3ZD9GzTmvtRD7pzV7GK7whwMpvsl3NvhIys8nTyJnIFrArc5imY9H4mlYJSTQIcN0sRX8itaMFLK0-sZhFf-8hRZaEBPjd-SmHaNxtlV6CHjSMuJQdtHOwWC8d1XtTykiDQGmncMiCeQeLfPdwQwlCd08FknWawu2Ujc3sYWtE5Mozmgq0RaiLujudB0QStoSZPUcKdFfOP_tUTvFyt2fjUljCXOV72Y3nChNczsRCRHL9w35oyDq_APjR_rVhXaTc0dgXKgCAiItLTFJ8hPj3jPia78oB0VStR915i31Wp0L28TjTr7Op8wkwW0yWIBszB1_c_D3wPF8O_3AMQdWM_fLlhOCvMZmgLYbvFu73g_LcJPfFUlDU8UZD4w-TIWQIcn2hmTh-W2OQ6EGs3Yd5fFa7Sbo9aLVVPT8A0VBHwQm9Q6F80_QDmhJzUAUqQfICJlov2-shE8Y7pDux4Az4Ri4l9GQXq3RREEVJYfqbDoL8UBEEUk-71Ry5_nOfP0yxBEJQqDPvdlqs3s2dVtdsdoo_kh98ksDID2oHoIQ7w3kulAzLfIpsZtpwhqyEd6eUrAtwvkX_OghBWpn-LUqfuUdPPy3Wrd0vyBkOP_qvYvEOrluIByTG6pjOLXTHCgrazvtlEV1wMscBelAt0q-HKc-XOLEPt7qiwV9V61BLsyOckCbKeaaI_JcWDV1sSJ3bwUd6FEHdl6s4BrPQA3SnV3B7dHt4Wr4w#4E37E891
Content-Type: application/json
Content-Length: 117
Host: www.yingwangtech.club
Connection: Keep-Alive
Accept-Encoding: gzip
User-Agent: okhttp/4.11.0

{"email":"[email protected]","bizType":"login","type":"sessionId","bizId":"login","playIntegritySupport":true,"cs":0}

经过多次测试,可以确定这个 precheck 的功能是在登录前检查账号是否需要验证码。可以看到协议头中有个 x-seccheck-sig 字段,我们的目标就是要找到它是怎么生成的。

JAVA 层分析

将币安 apk 文件拖入 Jadx ,搜索有关于字符串 “x-seccheck-sig” 的代码,会得到三个结果,且都在一个代码文件,双击其中一个进入,代码片段:

  List<String> mo34942a = this.f70909d.mo34942a();
                Pair<String, String> m35712c = m35712c(request);
                String component1 = m35712c.component1();
                String component2 = m35712c.component2();
                C48071xe6a6226 c48071xe6a6226 = c41166x51ee57462.f87968b;
                C48071xe6a6226.C480724 c480724 = Headers.f1275744;
                C48071xe6a6226.C480724.m667d(c480724, "x-seccheck-sig");
                C48071xe6a6226.C480724.m671a(c480724, component1, "x-seccheck-sig");
                c48071xe6a6226.f127576a.add("x-seccheck-sig");
                c48071xe6a6226.f127576a.add(StringsKt.trim((CharSequence) component1).toString());
                C48071xe6a6226 c48071xe6a62262 = c41166x51ee57462.f87968b;
                C48071xe6a6226.C480724 c4807242 = Headers.f1275744;
                C48071xe6a6226.C480724.m667d(c4807242, "x-seccheck-token");
                C48071xe6a6226.C480724.m671a(c4807242, component2, "x-seccheck-token");
                c48071xe6a62262.f127576a.add("x-seccheck-token");
                c48071xe6a62262.f127576a.add(StringsKt.trim((CharSequence) component2).toString());
                boolean z2 = true;
                if (!mo34942a.isEmpty()) {
                    List<String> list = mo34942a;
                    if (!(list instanceof Collection) || !list.isEmpty()) {
                        for (String str : list) {
                            if (StringsKt.endsWith$default(obj, str, false, 2, (Object) null)) {
                                z = true;
                                break;
                            }
                        }
                    }
                    z = false;
                }

Pair<String, String> m35712c = m35712c(request);

可以看出,x-seccheck-token 的来源是函数 m35712c() 返回的 Pair,第一个是 sig,第二个是token,我们只关心第一个,双击函数继续往上追溯,代码片段:

        /* renamed from: c */
        private Pair<String, String> m35712c(detachViewInternal detachviewinternal) {
            try {
                String signature = SecCheck.createApiSig().getSignature(detachviewinternal);
                SecCheck secCheck = SecCheck.getInstance();
                String token = secCheck != null ? secCheck.getToken(this.f70909d.mo34937e()) : null;
                if (token == null) {
                    token = "";
                }
                return TuplesKt.m933to(signature, token);
            } catch (Throwable th) {
                String obj = detachviewinternal.f87966i.toString();
                StringBuilder sb = new StringBuilder();
                sb.append("Get secCheck token,Exception: ");
                sb.append(th.getMessage());
                try {
                    this.f70909d.mo34940b(obj, sb.toString());
                } catch (Throwable unused) {
                }
                return TuplesKt.m933to("", "");
          
        }

return TuplesKt.m933to(signature, token); 这段代码显然是在构造 Pair,第一个参数是 signature ,往上追溯 getSignature 函数,代码片段:

 public String getSignature(detachViewInternal detachviewinternal) {
        return calcSig(detachviewinternal);
    }

    public SecApiSig(SecCheckConfig secCheckConfig) {
        this.f2279b = secCheckConfig;
        initSecApiSig();
    }

    private String calcSig(detachViewInternal detachviewinternal) {
        String requestBodyText;
        String obj = detachviewinternal.f87966i.toString();
        ArrayList arrayList = new ArrayList();
        ArrayList arrayList2 = new ArrayList();
        ArrayList arrayList3 = new ArrayList();
        String headerValues = getHeaderValues(detachviewinternal);
        String requestQueryParams = getRequestQueryParams(detachviewinternal);
        C3253d c3253d = new C3253d();
        if (isFormRequest(detachviewinternal)) {
            requestBodyText = getRequestFormParams(detachviewinternal, c3253d);
        } else {
            requestBodyText = getRequestBodyText(detachviewinternal, c3253d);
        }
        if (!headerValues.isEmpty()) {
            arrayList.add(headerValues);
        } else {
            c3253d.f2293a = true;
        }
        if (!requestQueryParams.isEmpty()) {
            arrayList.add(requestQueryParams);
        } else {
            c3253d.f2300h = true;
        }
        arrayList2.addAll(arrayList);
        arrayList3.addAll(arrayList);
        String str = "";
        if (!requestBodyText.isEmpty()) {
            arrayList.add(requestBodyText);
            String gsrq = SecCheckNative.gsrq(requestBodyText);
            if (gsrq != null) {
                arrayList2.add(gsrq);
            } else {
                c3253d.f2302j = true;
            }
            C3248a m93441a = C3249b.m93441a(requestBodyText, c3253d);
            if (m93441a != null) {
                StringBuilder sb = new StringBuilder();
                sb.append(m93441a.f2280a);
                sb.append(TopicsStore.DIVIDER_QUEUE_OPERATIONS);
                sb.append(m93441a.f2281b);
                str = sb.toString();
                gsrq = SecCheckNative.gsrq(m93441a.f2281b);
            }
            if (gsrq != null) {
                arrayList3.add(gsrq);
            } else {
                c3253d.f2303k = true;
            }
        } else {
            c3253d.f2301i = true;
        }
        return SecCheckNative.m93447as(C3254e.m93433a(IOUtils.LINE_SEPARATOR_UNIX, arrayList), C3254e.m93433a(IOUtils.LINE_SEPARATOR_UNIX, arrayList2), C3254e.m93433a(IOUtils.LINE_SEPARATOR_UNIX, arrayList3), headerValues, requestBodyText, str, obj, c3253d.m93436a());
    }

继续追溯 calcSig 函数,发现是调用了 SecCheckNative.m93447as 这个函数,经过一番数据处理,并传入足足八个参数,可怕。继续向上追溯,代码片段:

/* loaded from: classes6.dex */
public class SecCheckNative {
    static {
        setPaddingEnd.m8282a("com/bina/security/secsdk/SecCheckNative.<clinit>(l1)->java/lang/System.loadLibrary");
        System.loadLibrary(C3247a.f2272b);
        setPaddingEnd.m8278e("com/bina/security/secsdk/SecCheckNative.<clinit>(l1)->java/lang/System.loadLibrary");
    }

    /* renamed from: as */
    public static native String m93447as(String str, String str2, String str3, String str4, String str5, String str6, String str7, String str8);

    /* renamed from: di */
    public static native void m93446di();

    /* renamed from: ed */
    public static native String m93445ed(String str);

    public static native String gcd();

    public static native String gdd();

    /* renamed from: gs */
    public static native String m93444gs(String str, int i);

    public static native String gsrq(String str);

    public static native String gsrqb(byte[] bArr);

    /* renamed from: gt */
    public static native String m93443gt(String str);

    /* renamed from: ii */
    public static native long m93442ii(String str);

    public static native void sgd(String str);
}

“System.loadLibrary”,看见它,基本可以确定这里是调用的 so 库了,无法再继续追溯,只能跟到这

/* renamed from: gs */ public static native String m93444gs(String str, int i) ,

注意这里的 gs 是在 so 中的函数名。在分析 so 前需先拿到 so 文件名以及对应的 so 函数名。

System.loadLibrary(C3247a.f2272b) 这一句是加载 so 文件,C3247a.f2272b 里边储存着文件名。

so文件:com.bina.security.secsdk.so

函数名:gs

现在已经知道是哪个 so 文件了,用 7-zip 打开 apk,进入 lib 目录,找到包含字段 ”com.bina.security.secsdk“ 的 so 文件,将它解压到桌面,以供后续分析。

SO 层分析

通过前面在 java 层的分析,我们可以确定 x-seccheck-token 的生成是调用了 so 中名为 gas(s1,s2,s3,s4,s5,s6,s7,s8) 的函数,并给它传入了八个参数,下面我们深入 so 中分析下这个函数。

用 IDA64 打开前面准备好的 libcom.bina.security.secsdk.so 文件,在导出函数列表按下快捷键 ctrl+f ,搜索 “gas”

如上图,可以看到有两个结果,直接双击第二个函数,因为第一个函数 j_gas 就是调用的 gas。双击后你会发现右边的汇编视图已经转到这个 gas 函数的汇编代码处,将鼠标焦点移至汇编窗口并按下快捷键 f5 ,此时 IDA64 便会将汇编代码解析为 C 语言伪代码,如下图

现在能够清晰地看见整个 gas 函数,而且输入参数的数量也恰好是八个,应该是找对地方了,我们分析一下这个 gas 函数的代码片段

char *__fastcall gas(const char *a1, const char *a2, const char *a3, const char *a4, const char *a5, __int64 a6, __int64 a7, const char *a8)
{
  char *v15; // x28
  __int64 v16; // x0
  char *v17; // x28
  __int64 v18; // x0
  char *v19; // x28
  __int64 v20; // x0
  char *v21; // x28
  __int64 v22; // x0
  char *v23; // x28
  __int64 v24; // x0
  const char *v25; // x24
  char *v26; // x28
  const char *v27; // x26
  size_t v28; // w19
  size_t v29; // w19
  char *v30; // x20
  const char *v31; // x19
  char *v32; // x21
  size_t v34; // [xsp+28h] [xbp-88h]
  const char *v35; // [xsp+30h] [xbp-80h]
  const char *v36; // [xsp+38h] [xbp-78h]
  const char *v37; // [xsp+40h] [xbp-70h]
  const char *v38; // [xsp+48h] [xbp-68h]
  __int64 v39; // [xsp+50h] [xbp-60h]

  v15 = (char *)calloc(0x20u, 1u);
  v16 = strlen(a1);
  j_hmac_sha256_get(v15, a1, v16, "y*TFyoQQkqwUzZD9MCofUYPwqoAwucukGooP--KcZCyoDFXZ.TV2ogZiE!Y79dXC", 64LL);
  v38 = (const char *)calloc(9u, 1u);
  j_toHex(v15 + 28, 4LL, v38);
  free(v15);
  v17 = (char *)calloc(0x20u, 1u);
  v18 = strlen(a2);
  j_hmac_sha256_get(v17, a2, v18, "y*TFyoQQkqwUzZD9MCofUYPwqoAwucukGooP--KcZCyoDFXZ.TV2ogZiE!Y79dXC", 64LL);
  v37 = (const char *)calloc(9u, 1u);
  j_toHex(v17 + 28, 4LL, v37);
  free(v17);
  v19 = (char *)calloc(0x20u, 1u);
  v20 = strlen(a3);
  j_hmac_sha256_get(v19, a3, v20, "y*TFyoQQkqwUzZD9MCofUYPwqoAwucukGooP--KcZCyoDFXZ.TV2ogZiE!Y79dXC", 64LL);
  v36 = (const char *)calloc(9u, 1u);
  j_toHex(v19 + 28, 4LL, v36);
  free(v19);
  v21 = (char *)calloc(0x20u, 1u);
  v22 = strlen(a4);
  j_hmac_sha256_get(v21, a4, v22, "y*TFyoQQkqwUzZD9MCofUYPwqoAwucukGooP--KcZCyoDFXZ.TV2ogZiE!Y79dXC", 64LL);
  v35 = (const char *)calloc(9u, 1u);
  j_toHex(v21 + 28, 4LL, v35);
  free(v21);
  v23 = (char *)calloc(0x20u, 1u);
  v24 = strlen(a5);
  j_hmac_sha256_get(v23, a5, v24, "y*TFyoQQkqwUzZD9MCofUYPwqoAwucukGooP--KcZCyoDFXZ.TV2ogZiE!Y79dXC", 64LL);
  v25 = (const char *)calloc(9u, 1u);
  j_toHex(v23 + 28, 4LL, v25);
  free(v23);
  v26 = (char *)j_getRequestContent(a7, a6);
  v39 = j_getTimestamp();
  v27 = (const char *)calloc(0x25u, 1u);
  j_generateUuid();
  v34 = strlen(v27);
  v28 = strlen(v38);
  LODWORD(a1) = strlen(v37);
  LODWORD(a2) = strlen(v36);
  LODWORD(a7) = strlen(v35);
  LODWORD(a3) = strlen(v25);
  LODWORD(a4) = strlen(v26);
  v29 = v34 + v28 + (_DWORD)a1 + (_DWORD)a2 + a7 + (_DWORD)a3 + (_DWORD)a4 + strlen(a8) + 256;
  v30 = (char *)calloc(v29, 1u);
  snprintf(
    v30,
    v29,
    "{\"tm\": \"%lld\", \"nc\": \"%s\", \"sig\": \"%s\", \"sig1\": \"%s\", \"sig2\": \"%s\", \"ht\": \"%s\", \"bdy\": \"%"
    "s\", \"fl\": \"%s\", \"cnt\": \"%s\"}",
    v39,
    v27,
    v38,
    v37,
    v36,
    v35,
    v25,
    a8,
    v26);
  v31 = (const char *)j_ed(v30);
  free(v30);
  free(v26);
  LODWORD(v30) = strlen(v31) + 8;
  v32 = (char *)calloc((size_t)v30, 1u);
  snprintf(v32, (size_t)v30, "%s%s%s", "a1.5.4", "#", v31);
  return v32;
}

为了验证是否找对函数,先使用 Frida hook 看看这个函数的传入参数跟返回值,写一段 Hook 这个 gas 函数的 Frida 代码,但在 Hook 之前我们得先拿到这个 gas 函数在内存中的地址。

function find_func(functionName) {
    Java.perform(function () {
        var modules = Process.enumerateModules();
        var found = false;
        for (var i = 0; i < modules.length; i++) {
            var module = modules[i];
            if (module.path.includes("lib")) {
                console.log("Module Name:", module.name);
                var libName = module.name;
                const hooks = Module.load(libName);
                var exports = hooks.enumerateExports();
                for (var j = 0; j < exports.length; j++) {
                    var expt = exports[j];
                    if (expt.name == functionName) {
                        console.log("Type:", expt.type);
                        console.log("Name:", expt.name);
                        console.log("Module:", expt.module);
                        console.log("Address:", expt.address);
                        found = true;
                        break;
                    }
                }
            }
            if (found) break;
        }
    });
}

执行 find_func(”gas”) ,这段代码是遍历这个 apk 运行时所有加载的 so 文件中的函数,一旦匹配则跳出循环,输出函数信息。

Module Name: [email protected]
Module Name: libcom.bina.security.secsdk.so
Type: function
Name: gas
Module: undefined
Address: 0x72daf8cb64
[23054RA19C::com.binance.dev ]->

OK,拿到函数地址为:0x72daf8cb64,开始编写 Hook 代码。注意,这个地址不是固定的,每次打开 apk 都会变化,所以需要每次 hook 前遍历函数地址。

function hook_gas() {
    var targetFunctionAddress = new NativePointer('0x72daf8cb64');
    var targetFunctionName = "gas"

    // 拦截目标函数调用
    Interceptor.attach(targetFunctionAddress, {
        onEnter: function (args) {
            //char *__fastcall gas(const char *a1, const char *a2, const char *a3, const char *a4, const char *a5, __int64 a6, __int64 a7, const char *a8)
            console.log("[*]HOOK ", targetFunctionName)
            console.log('[*]arg1 输入字符串: ' + args[0].readCString());
            console.log('[*]arg2 输入字符串: ' + args[1].readCString());
            console.log('[*]arg3 输入字符串: ' + args[2].readCString());
            console.log('[*]arg4 输入字符串: ' + args[3].readCString());
            console.log('[*]arg5 输入字符串: ' + args[4].readCString());
            console.log('[*]arg6 输入字符串: ' + args[5].readCString());
            console.log('[*]arg7 输入字符串: ' + args[6].readCString());
            console.log('[*]arg8 输入字符串: ' + args[7].readCString());


        }, onLeave: function (retval) {
            console.log("[*]", targetFunctionName, " 结果", retval.readCString());
        }
    });
}
[*]HOOK  gas
[*]arg1 输入字符串: clienttype:android&versioncode:27905&versionname:2.79.5
{"email":"[email protected]","bizType":"login","type":"sessionId","bizId":"login","playIntegritySupport":true,"cs":0}
[*]arg2 输入字符串: clienttype:android&versioncode:27905&versionname:2.79.5
13A9E4F27ECA3C0EE6C8F9A4AF5967D5E5F1B2F7949179233D8E829BBB3095A2
[*]arg3 输入字符串: clienttype:android&versioncode:27905&versionname:2.79.5
86F9C6AE16A043F00F8D5A5A4C51D16B480DE8C9793534A0ABBB52092745665C
[*]arg4 输入字符串: clienttype:android&versioncode:27905&versionname:2.79.5
[*]arg5 输入字符串: {"email":"[email protected]","bizType":"login","type":"sessionId","bizId":"login","playIntegritySupport":true,"cs":0}
[*]arg6 输入字符串: email:[email protected]|bizType:login|type:sessionId|bizId:login|playIntegritySupport:true|cs:0,9589fefa47a4a6b763cce7014f9b2e9f
[*]arg7 输入字符串: https://www.yingwangtech.club/bapi/accounts/v1/public/account/security/precheck
[*]arg8 输入字符串: 00000001000000
[*] gas  结果 a1.5.4#..............省略亿点字

执行 hook_gas() ,然后回到手机,点击登录,这时会发起第一个请求包,由于要调用 gas 生成 x-seccheck-sig ,不出意外 Hook 会被触发,可以看到,已被触发。再来看看 gas 执行结果能不能对上请求包中的 x-seccheck-sig,啊!它对上了!!!那我们确实找对地方了,接下来的任务就是剖析 gas 函数的伪代码,并将其翻译成其它语言。

分析 gas 的伪代码

函数原型 char *__fastcall gas(const char *a1, const char *a2, const char *a3, const char *a4, const char *a5, __int64 a6, __int64 a7, const char *a8)

输入参数 a1,a2,a3,a4,a5,a6,a7,a8,字符串类型

a1 = clienttype:android&versioncode:27905&versionname:2.79.5 + 换行 + {"email":"[email protected]","bizType":"login","type":"sessionId","bizId":"login","playIntegritySupport":true,"cs":0}
a2 = clienttype:android&versioncode:27905&versionname:2.79.5 + 换行 + 13A9E4F27ECA3C0EE6C8F9A4AF5967D5E5F1B2F7949179233D8E829BBB3095A2
a3 = clienttype:android&versioncode:27905&versionname:2.79.5 + 换行 + 86F9C6AE16A043F00F8D5A5A4C51D16B480DE8C9793534A0ABBB52092745665C
a4 = clienttype:android&versioncode:27905&versionname:2.79.5
a5 = {"email":"[email protected]","bizType":"login","type":"sessionId","bizId":"login","playIntegritySupport":true,"cs":0}
a6 = email:[email protected]|bizType:login|type:sessionId|bizId:login|playIntegritySupport:true|cs:0,9589fefa47a4a6b763cce7014f9b2e9f
a7 = https://www.yingwangtech.club/bapi/accounts/v1/public/account/security/precheck
a8 = 00000001000000

分析 a1 – a5

  v15 = (char *)calloc(0x20u, 1u);
  v16 = strlen(a1);
  j_hmac_sha256_get(v15, a1, v16, "y*TFyoQQkqwUzZD9MCofUYPwqoAwucukGooP--KcZCyoDFXZ.TV2ogZiE!Y79dXC", 64LL);
  v38 = (const char *)calloc(9u, 1u);
  j_toHex(v15 + 28, 4LL, v38);
  free(v15);

首先是申请了 0x20u(十进制32)个字节大小的内存并将这块内存的指针放入 v15,然后将 a1 字符串的长度放入 v16,再调用 HMAC-SHA256 对 a1 进行加密,密码是 “y*TFyoQQkqwUzZD9MCofUYPwqoAwucukGooP–KcZCyoDFXZ.TV2ogZiE!Y79dXC”, 将加密结果放入 v15,接着申请 9 个字节的内存并将指针存入 v38,调用 tohex 函数将 v15 字节数组转为 HEX 字符串(大写字母),HEX 结果被放到了 v38。

需要注意,v15 + 28,这里向后偏移了 28 个字节,也就是前 28 个字节不要,只要后 4 个字节的 HEX 字符串,HMAC-SHA256 的密文长度是 32 字节。

分析 a6,a7

v26 = (char *)j_getRequestContent(a7, a6);

这里是调用了 j_getRequestContent 函数,并将结果放入了 v26。同样的,在 IDA 导出函数窗口搜索这个函数,向上追溯,最终来到

char *__fastcall getRequestContent(const char *a1, const char *a2)
{
  char *v4; // x19
  unsigned __int64 v5; // x8
  size_t v6; // w2
  char *v7; // x0
  const char *v8; // x1
  __int64 v9; // x20
  char *v10; // x21
  __int64 v12[2]; // [xsp+0h] [xbp-30h] BYREF

  v12[1] = *(_QWORD *)(_ReadStatusReg(ARM64_SYSREG(3, 3, 13, 0, 2)) + 40);
  v4 = (char *)calloc(0xFBu, 1u);
  if ( strstr(a1, "public/account/security/precheck")
    || strstr(a1, "gift-box/pick/campaign/pool")
    || strstr(a1, "gift-box/code/grab/campaign")
    || strstr(a1, "gift-box/code/grabV2") )
  {
    v5 = strlen(a2);
    if ( v5 < 0xFB )
    {
      v7 = v4;
      v8 = a2;
      v6 = v5;
    }
    else
    {
      v6 = 250;
      v7 = v4;
      v8 = a2;
    }
    strncpy(v7, v8, v6);
    v12[0] = 9LL;
    v9 = strlen(v4);
    v10 = (char *)calloc(2 * (int)v9, 1u);
    j_b64url_encode(v4, v9, v10, v12);
    free(v4);
    v4 = v10;
  }
  return v4;
}

可以看出,大概意思就是判断 a1 是否包含指定的字符串,如果包含则对 a2 进行 base64url 编码然后返回,这里的 a1,a2 分别对应前面传入的 a7,a6。

继续看 gas 代码,开始分析后半段代码

v39 = j_getTimestamp();
  v27 = (const char *)calloc(0x25u, 1u);
  j_generateUuid();
  v34 = strlen(v27);
  v28 = strlen(v38);
  LODWORD(a1) = strlen(v37);
  LODWORD(a2) = strlen(v36);
  LODWORD(a7) = strlen(v35);
  LODWORD(a3) = strlen(v25);
  LODWORD(a4) = strlen(v26);
  v29 = v34 + v28 + (_DWORD)a1 + (_DWORD)a2 + a7 + (_DWORD)a3 + (_DWORD)a4 + strlen(a8) + 256;
  v30 = (char *)calloc(v29, 1u);
  snprintf(
    v30,
    v29,
    "{\"tm\": \"%lld\", \"nc\": \"%s\", \"sig\": \"%s\", \"sig1\": \"%s\", \"sig2\": \"%s\", \"ht\": \"%s\", \"bdy\": \"%"
    "s\", \"fl\": \"%s\", \"cnt\": \"%s\"}",
    v39,
    v27,
    v38,
    v37,
    v36,
    v35,
    v25,
    a8,
    v26);
  v31 = (const char *)j_ed(v30);
  free(v30);
  free(v26);
  LODWORD(v30) = strlen(v31) + 8;
  v32 = (char *)calloc((size_t)v30, 1u);
  snprintf(v32, (size_t)v30, "%s%s%s", "a1.5.4", "#", v31);
  return v32;
}

v39 = j_getTimestamp(),这里将13位的毫秒时间戳存入 v39,为什么是13位毫秒而不是10位秒?因为我已经 Hook 验证过了,你也可以尝试 Hook 一下。

j_generateUuid(),这一句就很奇怪,没传参数,也没变量接收返回,可能是 IDA 解析错误了,看着前面申请了内存,应该就是将 UUID 放入了 v27(经过 Hook 验证,确实是放入了 v27,还做了一些处理,转大写并去除了 “-” 符号)。

再来看看这个 snprintf ,它把前面所生成的数据格式化到了 v30

snprintf(
    v30,
    v29,
    "{\"tm\": \"%lld\", \"nc\": \"%s\", \"sig\": \"%s\", \"sig1\": \"%s\", \"sig2\": \"%s\", \"ht\": \"%s\", \"bdy\": \"%"
    "s\", \"fl\": \"%s\", \"cnt\": \"%s\"}",
    13位时间戳,
    特殊UUID,
    hmac-hash256(a1)结果后4字节,
    hmac-hash256(a2)结果后4字节,
    hmac-hash256(a3)结果后4字节,
    hmac-hash256(a4)结果后4字节,
    hmac-hash256(a5)结果后4字节,
    a8,
    j_getRequestContent(a7, a6),
    );
  v31 = (const char *)j_ed(v30);

然后又将 v30 传给了 j_ed() 这个函数,先追溯到这个函数看看

void *__fastcall ed(const char *a1)
{
  __int64 v2; // x21
  void *v3; // x22
  __int64 v4; // x8
  unsigned __int64 v5; // x20
  __int64 v6; // x23
  void *v7; // x0
  void *v8; // x19
  _OWORD *v9; // x21
  __int64 v10; // x0
  __int64 v11; // x20
  void *v12; // x21
  void *v13; // x22
  size_t v14; // w23
  void *v15; // x20
  __int64 v17; // [xsp+8h] [xbp-118h] BYREF
  __int64 v18; // [xsp+10h] [xbp-110h] BYREF
  char v19[192]; // [xsp+18h] [xbp-108h] BYREF
  __int64 v20; // [xsp+D8h] [xbp-48h]

  v20 = *(_QWORD *)(_ReadStatusReg(ARM64_SYSREG(3, 3, 13, 0, 2)) + 40);
  v2 = strlen(a1);
  v3 = calloc((int)v2 + 1, 1u);
  memcpy(v3, a1, v2);
  j_swapGroup(v3, v2);
  j_swapBit(v3, v2);
  v4 = v2 + 16;
  if ( v2 + 16 < 0 )
    v4 = v2 + 31;
  v5 = v4 & 0xFFFFFFFFFFFFFFF0LL;
  v6 = (v4 & 0xFFFFFFFFFFFFFFF0LL) - v2;
  v7 = calloc((unsigned int)v4 & 0xFFFFFFF0, 1u);
  v8 = v7;
  if ( v6 )
    memset(v7, v6, v5);
  memcpy(v8, v3, v2);
  v9 = calloc(0x10u, 1u);
  *v9 = *(_OWORD *)"kYp3s6v9y$B&E)H@";
  j_swapGroup(v9, 16LL);
  j_swapBit(v9, 16LL);
  j_AES_init_ctx_iv(v19, v9, "9z$C&F)J@NcRfUjX");
  j_AES_CBC_encrypt_buffer(v19, v8, v5);
  free(v9);
  j_swapGroup(v8, v5);
  j_swapBit(v8, v5);
  v18 = 0LL;
  v10 = j_salting(v8, v5, &v18);
  v11 = v18;
  v12 = (void *)v10;
  v13 = calloc(2 * (int)v18, 1u);
  v17 = 0LL;
  j_b64url_encode(v12, v11, v13, &v17);
  v14 = v17;
  v15 = calloc((int)v17 + 1, 1u);
  memcpy(v15, v13, v14);
  free(v8);
  free(v12);
  return v15;
}

🥲🥲 啊!我死了!一眼看去,这是一个 AES 加密,前前后后还有点复杂呢… 没办法,只能硬着头皮上啦。

经过一番 hook 之后(内容过长,过程省略),我来说说这几个函数是干什么的吧

j_swapGroup 交换数组中相邻两个位置的数据

j_swapBit 交换数组中相邻两个数据的字节低四位

j_salting 对数据进行加盐混淆

好了,以上就这些是重点,继续分析,可以看见,它先是对数据依次进行 swapGroup,swapBit,结果丢给了 AES CBC 加密,然后又将加密结果依次进行 swapGroup,swapBit,salting,最终丢给 base64url 编码(末尾不带==),然后返回。

好了,差不多搞清楚了,回到 gas 代码片段

v31 = (const char *)j_ed(v30);
  free(v30);
  free(v26);
  LODWORD(v30) = strlen(v31) + 8;
  v32 = (char *)calloc((size_t)v30, 1u);
  snprintf(v32, (size_t)v30, "%s%s%s", "a1.5.4", "#", v31);
  return v32;

经过 j_ed() 加密后的数据被放到了 v31 ,然后将 v31 格式化成 x-seccheck-sig 的模样,最终将格式化结果返回,结果大概长这样

x-seccheck-sig: a1.5.4#NwEAAJQAAABXAAAAKAEAAAMpCJCBG6gss1NtWENQ5NJfZdK6u5ZrsWOdKivvYe15xwIVq39PCr6uCkzvwcTryB……省略n字

至此,整个分析过程就已完成。另外我再附上 swapGroup,swapBit,salting 的 go 语言实现,都是对照着伪代码一句句翻译过来的,经过 Hook 验证,翻译正确。

salting


func salting(a1 []byte, a2 int) []byte {
	v5 := make([]byte, 16)
	v6 := make([]byte, 4)

	var v8, v10, v12, v14 int
	rand.New(rand.NewSource(time.Now().Unix()))
	v8 = rand.Int() //rand
	v9 := v8 - v8/a2*a2
	v5[0] = byte(v9)
	v10 = rand.Int() //rand
	v11 := v10 - v10/a2*a2
	v5[4] = byte(v11)
	v12 = rand.Int() //rand
	v13 := v12 - v12/a2*a2
	v5[8] = byte(v13)
	v14 = rand.Int() //rand
	v15 := v14 - v14/a2*a2
	v5[12] = byte(v15)

	//存放结果
	v16 := make([]byte, a2+20)
	copy(v16, v5)

	//前面已经填充了16个字节 跳过4个字节后再填充
	copy(v16[16:], a1)

	var v19, v20, v21, v22 int
	rand.New(rand.NewSource(time.Now().Unix()))
	v6[0] = a1[v9]
	v19 = rand.Int() //rand
	v16[16+v9] = byte(v19 - v19/a2*a2)
	v6[1] = a1[v11]
	v20 = rand.Int() //rand
	v16[16+v11] = byte(v20 - v20/a2*a2)
	v6[2] = a1[v13]
	v21 = rand.Int() //rand
	v16[16+v13] = byte(v21 - v21/a2*a2)
	v6[3] = a1[v15]
	v22 = rand.Int() //rand
	v16[16+v15] = byte(v22 - v22/a2*a2)

	//*(unsigned int *)((char *)v17 + a2) = v23;
	for i := 0; i < 4; i++ {
		v16[16+a2+i] = v6[i]
	}
	return v16
}

swapBit

func swapBit(data []byte, groupSize int) {
	// 确保 groupSize 在有效范围内
	if groupSize > len(data) {
		groupSize = len(data)
	}
	for i := 0; i < groupSize-1; i += 2 {
		data[i], data[i+1] = data[i]&0xf0|data[i+1]&0x0f, data[i+1]&0xf0|data[i]&0x0f
	}
}

swapGroup

func swapGroup(data []byte, groupSize int) {
	// 确保 groupSize 在有效范围内
	if groupSize > len(data) {
		groupSize = len(data)
	}
	// 从最后一个分组开始向前遍历,每个分组的相邻元素互换
	for i := 0; i < groupSize-1; i += 2 {
		data[i], data[i+1] = data[i+1], data[i]
	}
}

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注