Reversing a Native Request-Signature Algorithm: SSL Pinning, OkHttp Interceptors, and MD5

About the author. I’m Colin Xiao — an independent reverse engineer, mostly working solo on contract jobs. I dig into compiled code of every kind, including but not limited to: Android and iOS apps, x86/x64/ARM native binaries, packers and obfuscators, DRM and license checks, network protocols and APIs, crypto and signature algorithms, malware, game binaries, and embedded/IoT firmware. I also handle large-scale data collection and vulnerability research. If you have any project in mind, I’d be glad to take it on — whatever it is, as long as it’s within my wheelhouse. Just reach out. You can reach me on X at @colinxiao_lab or on Telegram at https://t.me/ColinXiao666.

This research is published for educational and defensive purposes. The target sample is anonymized throughout.

This is a walkthrough of recovering a mobile app’s request-signature algorithm end to end: bypassing certificate pinning to capture traffic, tracing the sign parameter to an OkHttp interceptor, dropping into the native layer, and reverse engineering a small obfuscated salt-encoding routine plus an MD5. It’s a good template for how these “signature in the native .so” jobs usually go.


Capturing traffic: the SSL pinning problem

First, nothing shows up in the packet capture. The app is doing certificate pinning — the detection comes through NetworkSecurityTrustManager.checkPins combined with some OkHttp-level checks.

Here’s a Frida script (spawn-mode injection) that takes it down. It covers the usual OkHttp 4.x bypass surface:

Java.perform(function () {
    console.log("[*] Starting SSL Pinning Bypass for OkHttp 4.x");

    // ============================================================
    // OkHttp CertificatePinner bypass
    // ============================================================
    try {
        var CertificatePinner = Java.use("okhttp3.CertificatePinner");

        CertificatePinner.check$okhttp.overload(
            "java.lang.String",
            "java.util.List"
        ).implementation = function (hostname, peerCertificates) {
            console.log("[+] OkHttp CertificatePinner.check bypassed for: " + hostname);
            return;
        };
    } catch (e) {
        console.log("[-] CertificatePinner.check$okhttp not found: " + e);
    }

    try {
        var CertificatePinner2 = Java.use("okhttp3.CertificatePinner");
        CertificatePinner2.check.overload(
            "java.lang.String",
            "java.util.List"
        ).implementation = function (hostname, peerCertificates) {
            console.log("[+] OkHttp CertificatePinner.check bypassed for: " + hostname);
            return;
        };
    } catch (e) {
        console.log("[-] CertificatePinner.check overload not found: " + e);
    }

    // ============================================================
    // TrustManager bypass
    // ============================================================
    try {
        var X509TrustManager = Java.use("javax.net.ssl.X509TrustManager");
        var SSLContext = Java.use("javax.net.ssl.SSLContext");

        var TrustManager = Java.registerClass({
            name: "com.custom.TrustManager",
            implements: [X509TrustManager],
            methods: {
                checkClientTrusted: function (chain, authType) {},
                checkServerTrusted: function (chain, authType) {},
                getAcceptedIssuers: function () {
                    return [];
                }
            }
        });

        var TrustManagers = [TrustManager.$new()];
        var sslContext = SSLContext.getInstance("TLS");
        sslContext.init(null, TrustManagers, null);

        var sslSocketFactory = sslContext.getSocketFactory();
        console.log("[+] Custom TrustManager registered");
    } catch (e) {
        console.log("[-] TrustManager bypass failed: " + e);
    }

    // ============================================================
    // OkHttpClient.Builder SSLSocketFactory bypass
    // ============================================================
    try {
        var Builder = Java.use("okhttp3.OkHttpClient$Builder");

        Builder.sslSocketFactory.overload(
            "javax.net.ssl.SSLSocketFactory",
            "javax.net.ssl.X509TrustManager"
        ).implementation = function (sslSocketFactory, trustManager) {
            console.log("[+] OkHttpClient.Builder.sslSocketFactory hooked");
            var X509TrustManager = Java.use("javax.net.ssl.X509TrustManager");
            var TrustManager = Java.registerClass({
                name: "com.custom.TrustManager2",
                implements: [X509TrustManager],
                methods: {
                    checkClientTrusted: function (chain, authType) {},
                    checkServerTrusted: function (chain, authType) {},
                    getAcceptedIssuers: function () {
                        return [];
                    }
                }
            });
            var SSLContext = Java.use("javax.net.ssl.SSLContext");
            var ctx = SSLContext.getInstance("TLS");
            ctx.init(null, [TrustManager.$new()], null);
            return this.sslSocketFactory(ctx.getSocketFactory(), TrustManager.$new());
        };
    } catch (e) {
        console.log("[-] OkHttpClient Builder hook failed: " + e);
    }

    // ============================================================
    // HostnameVerifier bypass
    // ============================================================
    try {
        var HostnameVerifier = Java.use("javax.net.ssl.HostnameVerifier");
        var OkHostnameVerifier = Java.use("okhttp3.internal.tls.OkHostnameVerifier");

        OkHostnameVerifier.verify.overload(
            "java.lang.String",
            "javax.net.ssl.SSLSession"
        ).implementation = function (hostname, session) {
            console.log("[+] OkHostnameVerifier bypassed for: " + hostname);
            return true;
        };
    } catch (e) {
        console.log("[-] OkHostnameVerifier bypass failed: " + e);
    }

    // ============================================================
    // WebViewClient SSL Error bypass
    // ============================================================
    try {
        var WebViewClient = Java.use("android.webkit.WebViewClient");
        WebViewClient.onReceivedSslError.implementation = function (
            webView,
            sslErrorHandler,
            sslError
        ) {
            console.log("[+] WebViewClient.onReceivedSslError bypassed");
            sslErrorHandler.proceed();
        };
    } catch (e) {
        console.log("[-] WebViewClient hook failed: " + e);
    }

    // ============================================================
    // NetworkSecurityConfig bypass
    // ============================================================
    try {
        var NetworkSecurityTrustManager = Java.use(
            "android.security.net.config.NetworkSecurityTrustManager"
        );
        NetworkSecurityTrustManager.checkPins.implementation = function (chain) {
            console.log("[+] NetworkSecurityTrustManager.checkPins bypassed");
            return;
        };
    } catch (e) {
        console.log("[-] NetworkSecurityTrustManager not found: " + e);
    }

    console.log("[*] SSL Pinning Bypass script loaded successfully");
});

Where is the signature algorithm?

With traffic captured, the next step is finding the sign algorithm. The first instinct is to hook the message-digest algorithms, but that comes up empty. Searching statically for sign, I find a function that builds the request. The obfuscation is annoying — everything is made of the characters o, O, and 0, which are hard to tell apart.

I find an OkHttp interceptor. After smali decompilation, it roughly looks like this. The package com.uxin looks like a third-party SDK but is actually the app’s own code (the package name is anonymized below):

package com.uxin.collect.certpin;

import android.content.Context;
import android.text.TextUtils;
import java.io.IOException;
import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;
import java.util.Set;
import okhttp3.FormBody;
import okhttp3.HttpUrl;
import okhttp3.Interceptor;
import okhttp3.Request;
import okhttp3.RequestBody;
import okhttp3.Response;

public class OkHttpInterceptor implements Interceptor {

    @Override
    public Response intercept(Chain chain) throws IOException {
        // 1. get the original request
        Request originalRequest = chain.request();
        Request.Builder requestBuilder = originalRequest.newBuilder();

        // 2. read the global config class
        OooOOO0 config = OooOOO.OooO0OO();
        String header_c = config.OooOOo0();
        String header_identify = config.OooOOoo();
        String header_appId = config.OooOOO();
        String header_visitorId = config.OooOo0o();
        String authToken = OooOOO.OooO0O0();

        // 3. read device info
        String requestId = com.uxin.base.utils.device.OooO00o.Oooo00O();
        Context context = o00O0o0O.o00OOO00.OooO0o0().OooO0Oo();
        String ua = com.uxin.base.utils.device.OooO00o.OoooOO0(context, requestId);
        String deviceName = com.uxin.base.utils.device.OooO00o.OooOO0();
        String isV8aAbi = com.uxin.base.utils.device.OooO00o.OooooOO();
        String deviceId = com.uxin.base.utils.device.OooO00o.OoooO00();
        boolean isDarkMode = false;
        if (o00O0oOo.o0000.OooO0Oo() != null) {
            isDarkMode = o00O0oOo.o0000.OooO0Oo().OooOO0();
        }

        // 4. add common request headers
        requestBuilder.addHeader("ua", ua);
        requestBuilder.addHeader("_c", header_c);
        requestBuilder.addHeader("Connection", "keep-alive");
        requestBuilder.addHeader("Accept", "*/*");
        requestBuilder.addHeader("device_name", deviceName);
        requestBuilder.addHeader("requestId", requestId);
        requestBuilder.addHeader("isV8aAbi", isV8aAbi);

        // 5. conditionally add headers
        if (!TextUtils.isEmpty(header_identify)) {
            requestBuilder.addHeader("identify", header_identify);
        }
        if (!TextUtils.isEmpty(authToken)) {
            requestBuilder.addHeader("x-auth-token", authToken);
        }
        if (!TextUtils.isEmpty(header_appId)) {
            requestBuilder.addHeader("appId", header_appId);
        }
        if (!TextUtils.isEmpty(header_visitorId)) {
            requestBuilder.addHeader("visitor_id", header_visitorId);
        }

        // 6. expand header (device info + did)
        String expandHeader = "";
        if (!TextUtils.isEmpty(config.OooOOo()) && !"unknown".equals(config.OooOOo())) {
            expandHeader = deviceId + "&did=" + config.OooOOo();
        }
        requestBuilder.addHeader("expand", expandHeader);

        // 7. ie header (environment switch)
        boolean ieFlag = o00O0o0O.o00OOO0O.OooO0oO().OooOOO0();
        requestBuilder.addHeader("ie", ieFlag ? "1" : "0");

        // 8. dark_mode header
        requestBuilder.addHeader("dark_mode", String.valueOf(isDarkMode));

        // 9. GET requests: sign the params, append the sign parameter
        Map<String, String> paramMap = new HashMap<>();
        if ("GET".equals(originalRequest.method())) {
            HttpUrl url = originalRequest.url();
            if (url.querySize() > 0) {
                Set<String> names = url.queryParameterNames();
                Iterator<String> iterator = names.iterator();
                while (iterator.hasNext()) {
                    String key = iterator.next();
                    String value = url.queryParameter(key);
                    paramMap.put(key, value);
                }
                // generate the signature
                String sign = o00OO0.OooO0O0.OooO00o(paramMap);
                // append the sign parameter
                HttpUrl newUrl = url.newBuilder().addQueryParameter("sign", sign).build();
                requestBuilder.url(newUrl);
            }
        }

        // 10. POST Form requests: sign the params, append the sign field
        if ("POST".equals(originalRequest.method())) {
            RequestBody body = originalRequest.body();
            if (body instanceof FormBody) {
                FormBody formBody = (FormBody) body;
                FormBody.Builder newFormBuilder = new FormBody.Builder();

                // iterate the original form params
                for (int i = 0; i < formBody.size(); i++) {
                    String name = formBody.encodedName(i);
                    String value = formBody.encodedValue(i);
                    newFormBuilder.addEncoded(name, value);
                    paramMap.put(name, value);
                }

                // generate the signature and append sign
                String sign = o00OO0.OooO0O0.OooO00o(paramMap);
                newFormBuilder.addEncoded("sign", sign);
                requestBuilder.post(newFormBuilder.build());
            }
        }

        // 11. execute the final request
        Request newRequest = requestBuilder.build();
        return chain.proceed(newRequest);
    }
}

Found where the sign field is added. Following the trail, the signing function itself:

method static constructor <clinit>()V
    .registers 1

    .line 1
    const-string v0, "anonymous"

    .line 3
    invoke-static {v0}, Ljava/lang/System;->loadLibrary(Ljava/lang/String;)V

    .line 6
    return-void
.end method
(omitted)
method private native nativeEncode(JLjava/util/Map;)Ljava/lang/String;
    .annotation system Ldalvik/annotation/Signature;
        value = {
            "(J",
            "Ljava/util/Map<",
            "Ljava/lang/String;",
            "Ljava/lang/String;",
            ">;)",
            "Ljava/lang/String;"
        }
    .end annotation

The constructor shows we’ve reached the native layer. Let me first hook it to grab an example sign.

Java.perform(function () {
    var Tool = Java.use("com.uxin.anonymous.AnonymousTool");
    var HashMap = Java.use("java.util.HashMap");
    var Iterator = Java.use("java.util.Iterator");

    Tool.OooO00o.overload('java.util.Map').implementation = function (map) {
        console.log(" AnonymousTool.OooO00o called");

        try {
            // cast map to HashMap (or just the Map interface)
            var castedMap = Java.cast(map, Java.use("java.util.Map"));
            var entrySet = castedMap.entrySet();
            var iter = entrySet.iterator();
            while (iter.hasNext()) {
                var entry = Java.cast(iter.next(), Java.use("java.util.Map$Entry"));
                console.log("", entry.getKey(), "=", entry.getValue());
            }
        } catch (e) {
            console.log("map dump error:", e);
        }

        var result = this.OooO00o(map);
        console.log(" SIGN =", result);
        return result;
    };
});

This hook sits on the interceptor and directly captures the input map and the output sign. The example I got looks like a statistics endpoint — which also tells us every signed request to the server passes through here.

 clientVersion = 32
 deviceInfo = totalMem=6&cpuCount=4
 SIGN = c7857b30b57c36f25c497e1a8f3584f0

Native-layer analysis

I was going to throw unidbg at it, but the sign looks like an MD5 and the whole signing flow probably isn’t complicated, so — IDA it is.

std::string *__usercall CAnonymous::Encrypt@<X0>(
        std::string *__return_ptr retstr@<X8>,
        CAnonymous *this@<X0>,
        std::map<std::string,std::string> *data@<X1>,
        int version@<W2>)
{
  size_t v4; // x0
  std::string *result; // x0
  unsigned __int8 *buffer; // [xsp+18h] [xbp-198h]
  const std::string *v7; // [xsp+30h] [xbp-180h]
  std::string v12; // [xsp+80h] [xbp-130h] BYREF
  std::string __str; // [xsp+98h] [xbp-118h] BYREF
  std::string v14; // [xsp+B0h] [xbp-100h] BYREF
  std::string v15; // [xsp+C8h] [xbp-E8h] BYREF
  std::string inputStr; // [xsp+E0h] [xbp-D0h] BYREF
  std::string __rhs; // [xsp+F8h] [xbp-B8h] BYREF
  std::string __lhs; // [xsp+110h] [xbp-A0h] BYREF
  std::string v19; // [xsp+128h] [xbp-88h] BYREF
  std::string p_msg; // [xsp+140h] [xbp-70h] BYREF
  std::string v21; // [xsp+158h] [xbp-58h] BYREF
  std::string salt; // [xsp+170h] [xbp-40h] BYREF
  EncodeTool1 encodetool1; // [xsp+188h] [xbp-28h] BYREF
  __int64 v24; // [xsp+1A8h] [xbp-8h]
  v24 = *(_QWORD *)(_ReadStatusReg(TPIDR_EL0) + 40);
  if ( version )
    v7 = &_encodedsalt;
  else
    v7 = &_encodedsalt_old;
  std::string::basic_string();
  EncryptSalt(&salt, v7, &v21); // remember this — it matters later
  std::string::~string(&v21);
  std::to_string(&__rhs, version);
  std::operator+[abi:ne180000]<char,std::char_traits<char>,std::allocator<char>>(&__lhs, "version:", &__rhs);
  std::operator+[abi:ne180000]<char,std::char_traits<char>,std::allocator<char>>(&v19, &__lhs, ",actual salt was:");
  std::operator+[abi:ne180000]<char,std::char_traits<char>,std::allocator<char>>(&p_msg, &v19, &salt);
  CAnonymous::PrintLog(this, &p_msg);
  std::string::~string(&p_msg);
  std::string::~string(&v19);
  std::string::~string(&__lhs);
  std::string::~string(&__rhs);
  mapToQuery(&v15, data);
  std::operator+[abi:ne180000]<char,std::char_traits<char>,std::allocator<char>>(&inputStr, &salt, &v15);
  std::string::~string(&v15);
  std::operator+<char>(&v14, "input string was:", &inputStr);
  CAnonymous::PrintLog(this, &v14);
  std::string::~string(&v14);
  EncodeTool1::EncodeTool1(&encodetool1);
  std::string::basic_string[abi:ne180000](retstr);
  if ( this->_useExtEncrypt )
  {
    std::string::operator=(retstr, &inputStr);
  }
  else
  {
    buffer = (unsigned __int8 *)std::string::data[abi:ne180000](&inputStr);
    v4 = std::string::length[abi:ne180000](&inputStr);
    EncodeTool1::GenerateEncodeTool1(&encodetool1, buffer, v4);
    EncodeTool1::ToString(&__str, &encodetool1);
    std::string::operator=[abi:ne180000](retstr, &__str);
    std::string::~string(&__str);
  }
  std::operator+<char>(&v12, "output string was:", retstr);
  CAnonymous::PrintLog(this, &v12);
  std::string::~string(&v12);
  std::string::~string(&inputStr);
  result = (std::string *)std::string::~string(&salt);
  _ReadStatusReg(TPIDR_EL0);
  return result;
}

The encoding flow: EncodeTool1

The earlier flow calls into EncodeTool1. First question: what algorithm is EncodeTool1? The name suggests an MD5/SHA/HMAC wrapper, and the sign output looks like MD5 — but what if it’s a modified algorithm? Worth checking.

void __fastcall EncodeTool1::encode_process(
        EncodeTool1 *this,
        EncodeTool1::encodetool1_context *ctx,
        unsigned __int8 *data)
{
  // ... (long run of stack-slot variable declarations elided)
  _ReadStatusReg(TPIDR_EL0);
  v133 = *(unsigned int *)data;
  v134 = *((unsigned int *)data + 1);
  v135 = *((unsigned int *)data + 2);
  v136 = *((unsigned int *)data + 3);
  v137 = *((unsigned int *)data + 4);
  v138 = *((unsigned int *)data + 5);
  v139 = *((unsigned int *)data + 6);
  v140 = *((unsigned int *)data + 7);
  v141 = *((unsigned int *)data + 8);
  v142 = *((unsigned int *)data + 9);
  v143 = *((unsigned int *)data + 10);
  v144 = *((unsigned int *)data + 11);
  v145 = *((unsigned int *)data + 12);
  v146 = *((unsigned int *)data + 13);
  v147 = *((unsigned int *)data + 14);
  v148 = *((unsigned int *)data + 15);
  v69 = ctx->state[1];
  v36 = ctx->state[2];
  v3 = ctx->state[3];
  v101 = (v36 & v69 | v3 & ~v69) + v133 + ctx->state[0] + 3614090360u; // MD5 round 1 constant #1
  v102 = (((unsigned int)v101 >> 25) | (v101 << 7)) + v69;
  v4 = (v69 & v102 | v36 & ~v102) + v134 + v3 + 3905402710u; // MD5 round 1 constant #2
  // ...
  ctx->state[1] += (((unsigned int)v100 >> 11) | (unsigned __int64)(v100 << 21)) + v68;
  ctx->state[2] += v68;
  ctx->state[3] += v35;
}

Note:

  • 3614090360 = 0xD76AA478 ✅ the first constant of MD5 round 1
  • 3905402710 = 0xE8C7B756 ✅ the second constant of MD5 round 1

No need to read further — it’s MD5.

The encoding flow: mapToQuery

bool __usercall mapToQuery@<W0>(
        std::string *__return_ptr retstr@<X8>,
        const std::map<std::string,std::string> *cppMap@<X0>)
{
  _BOOL8 result; // x0
  std::__map_const_iterator<...>::pointer v3; // [xsp+20h] [xbp-B0h]
  std::__map_const_iterator<...>::pointer __lhs; // [xsp+28h] [xbp-A8h]
  std::__map_const_iterator<...> v7; // [xsp+70h] [xbp-60h] BYREF
  std::__tree_const_iterator<...>::__iter_pointer ptr; // [xsp+78h] [xbp-58h]
  std::__map_const_iterator<...> __x; // [xsp+80h] [xbp-50h] BYREF
  std::string v10; // [xsp+88h] [xbp-48h] BYREF
  std::string __str; // [xsp+A0h] [xbp-30h] BYREF
  std::__map_const_iterator<...> __y; // [xsp+B8h] [xbp-18h] BYREF
  std::map<std::string,std::string>::const_iterator it; // [xsp+C0h] [xbp-10h] BYREF
  __int64 v14; // [xsp+C8h] [xbp-8h]
  v14 = *(_QWORD *)(_ReadStatusReg(TPIDR_EL0) + 40);
  std::string::basic_string[abi:ne180000](retstr);
  for ( it.__i_.__ptr_ = std::map<std::string,std::string>::begin[abi:ne180000](cppMap).__i_.__ptr_;
        ;
        std::__map_const_iterator<...>::operator++[abi:ne180000](&it) )
  {
    __y.__i_.__ptr_ = std::map<std::string,std::string>::end[abi:ne180000](cppMap).__i_.__ptr_;
    result = std::operator!=[abi:ne180000](&it, &__y);
    if ( !result )
      break;
    __lhs = std::__map_const_iterator<...>::operator->[abi:ne180000](&it);
    std::operator+[abi:ne180000]<char,std::char_traits<char>,std::allocator<char>>(&v10, &__lhs->first, "=");
    v3 = std::__map_const_iterator<...>::operator->[abi:ne180000](&it);
    std::operator+[abi:ne180000]<char,std::char_traits<char>,std::allocator<char>>(&__str, &v10, &v3->second);
    std::string::operator+=[abi:ne180000](retstr, &__str);
    std::string::~string(&__str);
    std::string::~string(&v10);
    ptr = it.__i_.__ptr_;
    __x.__i_.__ptr_ = std::next(it, 1).__i_.__ptr_;
    v7.__i_.__ptr_ = std::map<std::string,std::string>::end[abi:ne180000](cppMap).__i_.__ptr_;
    if ( std::operator!=[abi:ne180000](&__x, &v7) )
      std::string::operator+=[abi:ne180000](retstr, "&");
  }
  _ReadStatusReg(TPIDR_EL0);
  return result;
}

So mapToQuery gives us the map-serialization format:

saltkey1=val1&key2=val2&key3=val3

Now — where’s the salt?

The encoding flow: the salt

This part stumped me for a while.

Clicking through _encodedsalt yields a string, but this time IDA’s magic failed:

bss:0000000000104D08.0 _ZL12_encodedsalt % 1                 ; __r_..__value_..__s..__is_long_
.bss:0000000000104D08.0                                     ; DATA XREF: __cxx_global_var_init.1+10↑o
.bss:0000000000104D08.0                                     ; CAnonymous::Encrypt(std::map<...> &,int)+58↑o
.bss:0000000000104D08.1               % 1                   ; __r_..__value_..__s..__size_
.bss:0000000000104D09                 % 0x17                ; __r_..__value_..__s.__data_
.bss:0000000000104D20 ; const std::string _saltKey

There’s no obvious plaintext here. The reference points to the global-var initializer, so I need to look at that function:

; Attributes: static bp-based frame
; void __cdecl _cxx_global_var_init_1()
__cxx_global_var_init.1
obj= -8
var_s0=  0
; __unwind {
SUB             SP, SP, #0x20
STP             X29, X30, [SP,#0x10+var_s0]
ADD             X29, SP, #0x10
NOP
ADR             X0, _ZL12_encodedsalt ; this
STR             X0, [SP,#0x10+obj]
ADRL            X1, a6zf0r8 ; "[redacted salt string]"
BL              _ZNSt6__ndk112basic_stringIc...EC2B8ne180000ILi0EEEPKc ; std::string::basic_string<0>(char const*)
LDR             X1, [SP,#0x10+obj] ; obj
ADRP            X0, #_ZNSt6__ndk112basic_stringIc...ED1Ev_ptr@PAGE
LDR             X0, [X0,#_ZNSt6__ndk112basic_stringIc...ED1Ev_ptr@PAGEOFF] ; lpfunc
NOP
ADR             X2, __dso_handle ; lpdso_handle
BL              .__cxa_atexit
LDP             X29, X30, [SP,#0x10+var_s0]
ADD             SP, SP, #0x20 ; ' '
RET
; } // starts at 737FC
; End of function __cxx_global_var_init.1

There’s the salt in that ADRL — but when I plugged it straight in, the generated sign was wrong.

The encoding flow: EncryptSalt

  EncryptSalt(&salt, v7, &v21);

The first time through, I didn’t pay attention to this little thing. It wasn’t until the produced sign didn’t work that I realized there was this extra function. IDA, double-click:

std::string *__cdecl EncryptSalt(std::string *__return_ptr retstr, const std::string *text, const std::string *p_key)
{
  std::string *result; // x0
  unsigned __int8 __ch; // [xsp+3Fh] [xbp-41h]
  std::string::const_iterator __end1; // [xsp+68h] [xbp-18h] BYREF
  std::string::const_iterator __begin1; // [xsp+70h] [xbp-10h] BYREF
  __int64 v9; // [xsp+78h] [xbp-8h]
  v9 = *(_QWORD *)(_ReadStatusReg(TPIDR_EL0) + 40);
  std::string::basic_string[abi:ne180000](retstr);
  __begin1.__i_ = std::string::begin[abi:ne180000](text).__i_;
  __end1.__i_ = std::string::end[abi:ne180000](text).__i_;
  while ( 1 )
  {
    result = (std::string *)std::operator!=[abi:ne180000]<char const*>(&__begin1, &__end1);
    if ( ((unsigned __int8)result & 1) == 0 )
      break;
    __ch = *std::__wrap_iter<char const*>::operator*[abi:ne180000](&__begin1);
    if ( isalpha(__ch) )
      isupper(__ch);
    else
      isdigit(__ch);
    std::string::push_back(retstr);
    std::__wrap_iter<char const*>::operator++[abi:ne180000](&__begin1);
  }
  _ReadStatusReg(TPIDR_EL0);
  return result;
}

The logic looks deceptively simple: iterate every character of text:

  • if it’s a letter → check the case
  • if it’s a digit → check that too
  • then push the character (after some processing) into retstr

But where’s the “processing”? The decompiler ate it — this is a case where the optimizer folded the actual encryption out of the pseudocode, leaving only the iteration and the character-type checks. EncryptSalt was probably a character-substitution / XOR / Caesar-style transform whose real math got lost in decompilation.

So I read the assembly:

=============== S U B R O U T I N E =======================================

; Attributes: bp-based frame

; std::string *__cdecl EncryptSalt(std::string *__return_ptr __struct_ptr retstr, const std::string *text, const std::string *p_key)
                EXPORT _Z11EncryptSaltRKNSt6__ndk112basic_stringIcNS_11char_traitsIcEENS_9allocatorIcEEEES5_
_Z11EncryptSaltRKNSt6__ndk112basic_stringIcNS_11char_traitsIcEENS_9allocatorIcEEEES5_
                                        ; CODE XREF: EncryptSalt(...)+C↓j
                                        ; DATA XREF: LOAD:00000000000027E8↑o ...

var_80          = -0x80
var_74          = -0x74
var_70          = -0x70
var_6C          = -0x6C
var_68          = -0x68
var_60          = -0x60
var_54          = -0x54
var_50          = -0x50
__ch            = -0x41
var_31          = -0x31
p_key           = -0x30
text            = -0x28
p_encrypted     = -0x20
__end1          = -0x18
__begin1        = -0x10
var_8           = -8
var_s0          =  0

; __unwind { // __gxx_personality_v0
                SUB             SP, SP, #0x90
                STP             X29, X30, [SP,#0x80+var_s0]
                ADD             X29, SP, #0x80
                STR             X0, [SP,#0x80+var_68]
                MOV             X0, X8  ; this
                LDR             X8, [SP,#0x80+var_68]
                STR             X0, [SP,#0x80+var_60]
                MRS             X9, TPIDR_EL0
                LDR             X9, [X9,#0x28]
                STUR            X9, [X29,#var_8]
                STUR            X0, [X29,#p_encrypted]
                STUR            X8, [X29,#text]
                STUR            X1, [X29,#p_key]
                MOV             W8, WZR
                STURB           W8, [X29,#var_31]
                BL              _ZNSt6__ndk112basic_stringIc...EC2B8ne180000Ev ; std::string::basic_string(void)
                LDUR            X8, [X29,#text]
                STR             X8, [SP,#0x80+__ch+1]
                LDR             X0, [SP,#0x80+__ch+1] ; this
                BL              _ZNKSt6__ndk112basic_stringIc...E5beginB8ne180000Ev ; std::string::begin(void)
                STUR            X0, [X29,#__begin1]
                LDR             X0, [SP,#0x80+__ch+1] ; this
                BL              _ZNKSt6__ndk112basic_stringIc...E3endB8ne180000Ev ; std::string::end(void)
                STUR            X0, [X29,#__end1]
                B               loc_7390C
; ---------------------------------------------------------------------------

loc_7390C                               ; CODE XREF: EncryptSalt(...)+60↑j
                                        ; EncryptSalt(...)+174↓j
                SUB             X0, X29, #-__begin1 ; __x
                SUB             X1, X29, #-__end1 ; __y
                BL              _ZNSt6__ndk1neB8ne180000IPKcEEbRKNS_11__wrap_iterIT_EES7_ ; std::operator!=(...)
                TBZ             W0, #0, loc_73A20
                B               loc_73920
; ---------------------------------------------------------------------------

loc_73920                               ; CODE XREF: EncryptSalt(...)+74↑j
                SUB             X0, X29, #-__begin1 ; this
                BL              _ZNKSt6__ndk111__wrap_iterIPKcEdeB8ne180000Ev ; std::__wrap_iter<char const*>::operator*(void)
                LDRB            W8, [X0]
                STRB            W8, [SP,#0x80+__ch]
                LDRB            W0, [SP,#0x80+__ch] ; __ch
;   try {
                BL              _ZL7isalphai ; isalpha(int)
                STR             W0, [SP,#0x80+var_6C]
                B               loc_73940
; ---------------------------------------------------------------------------

loc_73940                               ; CODE XREF: EncryptSalt(...)+94↑j
                LDR             W8, [SP,#0x80+var_6C]
                CBZ             W8, loc_739C0
                B               loc_7394C
; ---------------------------------------------------------------------------

loc_7394C                               ; CODE XREF: EncryptSalt(...)+A0↑j
                LDRB            W0, [SP,#0x80+__ch] ; __ch
                BL              _ZL7isupperi ; isupper(int)
                STR             W0, [SP,#0x80+var_70]
                B               loc_7395C
; ---------------------------------------------------------------------------

loc_7395C                               ; CODE XREF: EncryptSalt(...)+B0↑j
                LDR             W8, [SP,#0x80+var_70]
                CBZ             W8, loc_739A0
                B               loc_73968
; ---------------------------------------------------------------------------

loc_73968                               ; CODE XREF: EncryptSalt(...)+BC↑j
                LDR             X0, [SP,#0x80+var_60] ; this
                LDRB            W9, [SP,#0x80+__ch]
                MOV             W8, #0x9B
                SUBS            W1, W8, W9
                BL              ._ZNSt6__ndk112basic_stringIc...E9push_backEc ; std::string::push_back(char)
                B               loc_73980
; ---------------------------------------------------------------------------

loc_73980                               ; CODE XREF: EncryptSalt(...)+D4↑j
                B               loc_739BC
; ---------------------------------------------------------------------------
;   cleanup() // owned by 73934
                MOV             X8, X0
                LDR             X0, [SP,#0x80+var_60]
                STR             X8, [SP,#0x80+var_50]
                MOV             W8, W1
                STR             W8, [SP,#0x80+var_54]
                BL              ._ZNSt6__ndk112basic_stringIc...ED1Ev ; std::string::~string()
                B               loc_73A64
; ---------------------------------------------------------------------------

loc_739A0                               ; CODE XREF: EncryptSalt(...)+B8↑j
                LDR             X0, [SP,#0x80+var_60] ; this
                LDRB            W9, [SP,#0x80+__ch]
                MOV             W8, #0xDB
                SUBS            W1, W8, W9
                BL              ._ZNSt6__ndk112basic_stringIc...E9push_backEc ; std::string::push_back(char)
                B               loc_739B8
; ---------------------------------------------------------------------------

loc_739B8                               ; CODE XREF: EncryptSalt(...)+10C↑j
                B               loc_739BC
; ---------------------------------------------------------------------------

loc_739BC                               ; CODE XREF: EncryptSalt(...):loc_73980↑j
                                        ; EncryptSalt(...):loc_739B8↑j
                B               loc_73A10
; ---------------------------------------------------------------------------

loc_739C0                               ; CODE XREF: EncryptSalt(...)+9C↑j
                LDRB            W0, [SP,#0x80+__ch] ; __ch
                BL              _ZL7isdigiti ; isdigit(int)
                STR             W0, [SP,#0x80+var_74]
                B               loc_739D0
; ---------------------------------------------------------------------------

loc_739D0                               ; CODE XREF: EncryptSalt(...)+124↑j
                LDR             W8, [SP,#0x80+var_74]
                CBZ             W8, loc_739F8
                B               loc_739DC
; ---------------------------------------------------------------------------

loc_739DC                               ; CODE XREF: EncryptSalt(...)+130↑j
                LDR             X0, [SP,#0x80+var_60] ; this
                LDRB            W9, [SP,#0x80+__ch]
                MOV             W8, #0x69  ; 'i'
                SUBS            W1, W8, W9
                BL              ._ZNSt6__ndk112basic_stringIc...E9push_backEc ; std::string::push_back(char)
                B               loc_739F4
; ---------------------------------------------------------------------------

loc_739F4                               ; CODE XREF: EncryptSalt(...)+148↑j
                B               loc_73A0C
; ---------------------------------------------------------------------------

loc_739F8                               ; CODE XREF: EncryptSalt(...)+12C↑j
                LDR             X0, [SP,#0x80+var_60] ; this
                LDRB            W1, [SP,#0x80+__ch]
                BL              ._ZNSt6__ndk112basic_stringIc...E9push_backEc ; std::string::push_back(char)
;   } // starts at 73934
                B               loc_73A08
; ---------------------------------------------------------------------------

loc_73A08                               ; CODE XREF: EncryptSalt(...)+15C↑j
                B               loc_73A0C
; ---------------------------------------------------------------------------

loc_73A0C                               ; CODE XREF: EncryptSalt(...):loc_739F4↑j
                                        ; EncryptSalt(...):loc_73A08↑j
                B               loc_73A10
; ---------------------------------------------------------------------------

loc_73A10                               ; CODE XREF: EncryptSalt(...):loc_739BC↑j
                                        ; EncryptSalt(...):loc_73A0C↑j
                B               loc_73A14
; ---------------------------------------------------------------------------

loc_73A14                               ; CODE XREF: EncryptSalt(...):loc_73A10↑j
                SUB             X0, X29, #-__begin1 ; this
                BL              _ZNSt6__ndk111__wrap_iterIPKcEppB8ne180000Ev ; std::__wrap_iter<char const*>::operator++(void)
                B               loc_7390C
; ---------------------------------------------------------------------------

loc_73A20                               ; CODE XREF: EncryptSalt(...)+70↑j
                MOV             W8, #1
                STURB           W8, [X29,#var_31]
                LDURB           W8, [X29,#var_31]
                TBNZ            W8, #0, loc_73A40
                B               loc_73A34
; ---------------------------------------------------------------------------

loc_73A34                               ; CODE XREF: EncryptSalt(...)+188↑j
                LDR             X0, [SP,#0x80+var_60]
                BL              ._ZNSt6__ndk112basic_stringIc...ED1Ev ; std::string::~string()
                B               loc_73A40
; ---------------------------------------------------------------------------

loc_73A40                               ; CODE XREF: EncryptSalt(...)+184↑j
                                        ; EncryptSalt(...)+194↑j
                MRS             X8, TPIDR_EL0
                LDR             X8, [X8,#0x28]
                LDUR            X9, [X29,#var_8]
                SUBS            X8, X8, X9
                B.NE            loc_73A8C
                B               loc_73A58
; ---------------------------------------------------------------------------

loc_73A58                               ; CODE XREF: EncryptSalt(...)+1AC↑j
                LDP             X29, X30, [SP,#0x80+var_s0]
                ADD             SP, SP, #0x90
                RET
; ---------------------------------------------------------------------------

loc_73A64                               ; CODE XREF: EncryptSalt(...)+F4↑j
                LDR             X0, [SP,#0x80+var_50]
                STR             X0, [SP,#0x80+var_80]
                MRS             X8, TPIDR_EL0
                LDR             X8, [X8,#0x28]
                LDUR            X9, [X29,#var_8]
                SUBS            X8, X8, X9
                B.NE            loc_73A8C
                B               loc_73A84
; ---------------------------------------------------------------------------

loc_73A84                               ; CODE XREF: EncryptSalt(...)+1D8↑j
                LDR             X0, [SP,#0x80+var_80]
                BL              _Unwind_Resume
; ---------------------------------------------------------------------------

loc_73A8C                               ; CODE XREF: EncryptSalt(...)+1A8↑j
                                        ; EncryptSalt(...)+1D4↑j
                BL              .__stack_chk_fail
; } // starts at 738A8
; End of function EncryptSalt(...)

The critical lines:

; uppercase letter (isupper = true)
MOV W8, #0x9B
SUBS W1, W8, W9    ; push_back(0x9B - ch)

; lowercase letter (isalpha but not isupper)
MOV W8, #0xDB
SUBS W1, W8, W9    ; push_back(0xDB - ch)

; digit (isdigit)
MOV W8, #0x69      ; 'i' = 105
SUBS W1, W8, W9    ; push_back(0x69 - ch)

; everything else
push_back(ch)      ; kept as-is

So the algorithm is now clear. Testing it against what we hooked:

import hashlib

SALT = "[redacted]"  # the encoded salt string recovered from the .bss global _encodedsalt

def encrypt_salt(raw: str) -> str:
    result = []
    for ch in raw:
        c = ord(ch)
        if ch.isalpha():
            if ch.isupper():
                result.append(chr(0x9B - c))
            else:
                result.append(chr(0xDB - c))
        elif ch.isdigit():
            result.append(chr(0x69 - c))
        else:
            result.append(ch)
    return result

def encrypt_salt_filtered(raw: str) -> str:
    chars = encrypt_salt(raw)
    return "".join(chars)

def map_to_query(params: dict) -> str:
    return "&".join(f"{k}={v}" for k, v in sorted(params.items()))

def sign(params: dict) -> str:
    salt = encrypt_salt_filtered(SALT)  # SALT recovered from the .bss global _encodedsalt
    print("salt:", salt)
    input_str = salt + map_to_query(params)
    print("input:", input_str)
    return hashlib.md5(input_str.encode()).hexdigest()

params = {
    "clientVersion": "32",
    "deviceInfo": "totalMem=6&cpuCount=4",
}
print("sign:", sign(params))
# matches the value captured via Frida

It matches. Done.


What this teaches

The shape of this job is worth internalizing, because “recover the request signature” comes up constantly:

  1. Unblock the traffic first. Certificate pinning hides everything — bypass it before you can even see the request.
  2. Trace the parameter to its source. Here it’s an OkHttp interceptor that appends sign to every request, which conveniently centralizes the signing logic.
  3. Follow the JNI boundary. The Java side just calls nativeEncode; the real work is in the .so.
  4. Recognize the crypto by its constants. Two MD5 round-1 constants (0xD76AA478, 0xE8C7B756) are enough to identify MD5 without reading the whole function.
  5. Don’t trust the decompiler on tight loops. EncryptSalt looked like a trivial iteration in pseudocode; the actual byte transform was only visible in the assembly, where the optimizer had folded the arithmetic into SUBS before push_back.
  6. The salt is rarely a plaintext string. Here it lives in a .bss global, initialized by __cxx_global_var_init, and then transformed again by EncryptSalt before use.

Colin Xiao — I take on contract reverse-engineering work, and writeups like this are how I show what I can do. If you need help with anything in this space — including but not limited to the areas I cover above — I’m available. No project is too big or too small — if it’s reverse engineering and I can do it, I’ll take it. DM me on X (@colinxiao_lab) or Telegram (@ColinXiao666).

Get in touch