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 13905402710=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:
- Unblock the traffic first. Certificate pinning hides everything — bypass it before you can even see the request.
- Trace the parameter to its source. Here it’s an OkHttp interceptor that appends
signto every request, which conveniently centralizes the signing logic. - Follow the JNI boundary. The Java side just calls
nativeEncode; the real work is in the.so. - Recognize the crypto by its constants. Two MD5 round-1 constants (
0xD76AA478,0xE8C7B756) are enough to identify MD5 without reading the whole function. - Don’t trust the decompiler on tight loops.
EncryptSaltlooked like a trivial iteration in pseudocode; the actual byte transform was only visible in the assembly, where the optimizer had folded the arithmetic intoSUBSbeforepush_back. - The salt is rarely a plaintext string. Here it lives in a
.bssglobal, initialized by__cxx_global_var_init, and then transformed again byEncryptSaltbefore 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