Reversing the sign Algorithm of a Major E-commerce App

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.


Reversing the sign Algorithm of a Major E-commerce App

1. Find the sign parameter

With Charles I captured the traffic and noticed every request URL carries a sign parameter, and it changes each time. No other unusual parameters. So the crux is figuring out how sign is generated.

2. Locate the sign generation with jadx

Searching for sign directly matches too many hits to tell which is real. So I searched with the getSign prefix instead:

Lucky break: I found a native method whose name also starts with getSign. Opened that class, then chased its references to confirm it’s the sign-computing class.

The reference is in BaseApplication, which confirms this is the class that computes sign.

3. Analyze the sign method’s arguments

Hook it with Frida:

functionmain(){
     Java.perform(function(){
       varBitmapkitUtils =  Java.use("com.jingdong.common.utils.BitmapkitUtils")
       BitmapkitUtils.getSignFromJni.overload('android.content.Context', 'java.lang.String', 'java.lang.String', 'java.lang.String', 'java.lang.String', 'java.lang.String').implementation = function( context,  str,  str2,  str3,  str4,  str5){
             console.log("sign inputs: ","str         =>",str,"str2=>",str2,"str3=>",str3,"str4=>",str4,"str5=>",str5)
            varresult = this.getSignFromJni(context,str,str2,str3,str4,str5)
             console.log('sign result: ',result)
            returnresult
      }
     })
 }

From the hook output the arguments shake out as:

  • context — the context object.
  • str — the URL path.
  • str2 — the request body.
  • str3 — 55a9c688729bb118, a 16-char key, fixed across requests.
  • str4 — android, the platform.
  • str5 — the version number.

Reading the source, str3 is the installationId — a random 16-char UUID created on first install and cached.

4. Black-box call with unidbg

First step is, of course, stubbing the environment:

I need to return an application object. Lazy shortcut: copy it from AbstractJni.

Running again throws a missing-methodId error. So I swapped Application for Activity — an Activity can also produce an Application object.

  @Override
     publicDvmObject<?> getStaticObjectField(BaseVM vm, DvmClass dvmClass, String signature) {
         if("com/jingdong/common/utils/BitmapkitUtils->a:Landroid/app/Application;".equals(signature)) {
             // return the application
             returnvm.resolveClass("android/app/Activity",
                    vm.resolveClass("android/content/ContextWrapper",
                             vm.resolveClass("android/content/Context"))).newObject(null);
        }
         returnsuper.getStaticObjectField(vm, dvmClass, signature);
     }

That fixes it. Next error: return the APK path. Easy.

 @Override
 publicDvmObject<?> getObjectField(BaseVM vm, DvmObject<?> dvmObject, String signature) {
     // sourceDir is the current APK directory
     if("android/content/pm/ApplicationInfo->sourceDir:Ljava/lang/String;".equals(signature)){
         StringObject stringObject = newStringObject(vm, APK_PATH);
         returnstringObject;
     }
     returnsuper.getObjectField(vm, dvmObject, signature);
 }

For the return shown above I didn’t initially know what to pass, so I looked at the source — but the concrete implementation hadn’t decompiled. So I hooked the method with Frida and got the return below.

  • First argument: the app install directory. Second: the META-INF directory. Third: RSA.

Given that, just return the RSA public key directly:

@Override
     publicDvmObject<?> callStaticObjectMethod(BaseVM vm, DvmClass dvmClass, String signature, VarArg varArg) {
         if("com/jingdong/common/utils/BitmapkitZip->unZip(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)[B".equals(signature)) {
             byte[] unzip = vm.unzip("META-INF/xxxx.RSA");
             System.out.println("unzip "+ newString(unzip));
             returnnewByteArray(vm, unzip);
         }
         returnsuper.callStaticObjectMethod(vm, dvmClass, signature, varArg);
     }

Keep stubbing:

 @Override
 publicDvmObject<?> newObject(BaseVM vm, DvmClass dvmClass, String signature, VarArg varArg) {
     if("sun/security/pkcs/PKCS7-><init>([B)V".equals(signature)) {
         DvmObject<?> objectArg = varArg.getObjectArg(0);
         try{
             PKCS7 pkcs7 = newPKCS7((byte[]) objectArg.getValue());
             returnvm.resolveClass("sun/security/pkcs/PKCS7").newObject(pkcs7);
         } catch(ParsingException e) {
             e.printStackTrace();
         }

     }
     returnsuper.newObject(vm, dvmClass, signature, varArg);
 }

More stubbing, as follows:

 @Override
     publicDvmObject<?> callObjectMethod(BaseVM vm, DvmObject<?> dvmObject, String signature, VarArg varArg) {
         if("sun/security/pkcs/PKCS7->getCertificates()[Ljava/security/cert/X509Certificate;".equals(signature)) {
             PKCS7 pkcs7 = (PKCS7) dvmObject.getValue();
             X509Certificate[] certificates = pkcs7.getCertificates();
             DvmObject<?> object = ProxyDvmObject.createObject(vm, certificates);
             returnobject;


         }
         returnsuper.callObjectMethod(vm, dvmObject, signature, varArg);
     }

Still stubbing — copy an objectToBytes method from the decompiled source and return the ByteArray:

 if("com/jingdong/common/utils/BitmapkitZip->objectToBytes(Ljava/lang/Object;)[B".equals(signature)){
     DvmObject<?> objectArg = varArg.getObjectArg(0);
     byte[] bytes = objectToBytes(objectArg.getValue());
     returnnewByteArray(vm,bytes);
 }

5. Black-box call the sign method

 List<Object> params = newArrayList<>();
        params.add(dalvikVM.getJNIEnv());
        params.add(0);
        DvmClass context = dalvikVM.resolveClass("android/content/Context");
        params.add(dalvikVM.addLocalObject(context.newObject(null)));
        params.add(dalvikVM.addLocalObject(newStringObject(dalvikVM, "personinfoBusiness")));
        params.add(dalvikVM.addLocalObject(newStringObject(dalvikVM, "{\"callCJH\":\"1\",\"callNPS\":\"1\",\"closeJX\":\"0\",\"headTaskRefresh\":\"1\",\"locationArea\":\"0_0_0_0\",\"menuStaticSource\":\"0\",\"menuTimeStamp\":\"1631586010000\"}")));
        params.add(dalvikVM.addLocalObject(newStringObject(dalvikVM, INSTALL_ID)));
        params.add(dalvikVM.addLocalObject(newStringObject(dalvikVM, PLAT_FROM)));
        params.add(dalvikVM.addLocalObject(newStringObject(dalvikVM, VERSION)));
        Number numbers = moduleModule.callFunction(androidEmulator, 0x028B4+1, params.toArray())[0];

Running throws — more environment missing:

 @Override
 publicDvmObject<?> newObjectV(BaseVM vm, DvmClass dvmClass, String signature, VaList vaList) {
     if("java/lang/StringBuffer-><init>()V".equals(signature)){
         StringBuffer stringBuffer = newStringBuffer();
         returnvm.resolveClass("java/lang/StringBuffer").newObject(stringBuffer);
     }
     returnsuper.newObjectV(vm, dvmClass, signature, vaList);
 }

Keep stubbing — this part is simple — and go straight to the final run:

The black-box call works. Now to figure out how sign is actually produced. Full harness:

 package demo;

 importcom.github.unidbg.AndroidEmulator;
 importcom.github.unidbg.Module;
 importcom.github.unidbg.linux.android.AndroidEmulatorBuilder;
 importcom.github.unidbg.linux.android.AndroidResolver;
 importcom.github.unidbg.linux.android.dvm.*;
 importcom.github.unidbg.linux.android.dvm.apk.Apk;
 importcom.github.unidbg.linux.android.dvm.array.ArrayObject;
 importcom.github.unidbg.linux.android.dvm.array.ByteArray;
 importcom.github.unidbg.linux.android.dvm.jni.ProxyClassFactory;
 importcom.github.unidbg.linux.android.dvm.jni.ProxyDvmObject;
 importcom.github.unidbg.memory.Memory;
 importorg.apache.log4j.Level;
 importorg.apache.log4j.Logger;
 importsun.security.pkcs.PKCS7;
 importsun.security.pkcs.ParsingException;
 importjava.io.*;
 importjava.security.cert.X509Certificate;
 importjava.util.ArrayList;
 importjava.util.List;

 publicclassSignReplayer extendsAbstractJni {


     privatestaticfinalString SO_PATH = "";
     privatestaticfinalString APK_PATH = "";

     privatestaticfinalString INSTALL_ID = "55a9c688729bb118";
     privatestaticfinalString PLAT_FROM = "android";
     privatestaticfinalString VERSION = "10.4.6";
     privateAndroidEmulator androidEmulator;

     publicstaticvoidmain(String[] args) {
         Logger.getLogger("com.github.unidbg.AbstractEmulator").setLevel(Level.DEBUG);
         Logger.getLogger("com.github.unidbg.linux.android.dvm.DalvikVM").setLevel(Level.DEBUG);
         Logger.getLogger("com.github.unidbg.linux.android.dvm.BaseVM").setLevel(Level.DEBUG);
         Logger.getLogger("com.github.unidbg.linux.android.dvm").setLevel(Level.DEBUG);
         SignReplayer replayer = newSignReplayer();
         replayer.start();
     }


     publicvoidstart() {
         androidEmulator = AndroidEmulatorBuilder.for32Bit().setProcessName("com.jingdong.android")
                 .build();
          Memory androidEmulatorMemory = androidEmulator.getMemory();
          androidEmulatorMemory.setLibraryResolver(newAndroidResolver(23));
          VM dalvikVM = androidEmulator.createDalvikVM(newFile(APK_PATH));
         DalvikModule module = dalvikVM.loadLibrary(newFile(SO_PATH), false);
         dalvikVM.setJni(this);
         Module moduleModule = module.getModule();
         dalvikVM.callJNI_OnLoad(androidEmulator, moduleModule);
         List<Object> params = newArrayList<>();
         params.add(dalvikVM.getJNIEnv());
         params.add(0);
         DvmClass context = dalvikVM.resolveClass("android/content/Context");
         params.add(dalvikVM.addLocalObject(context.newObject(null)));
         params.add(dalvikVM.addLocalObject(newStringObject(dalvikVM, "personinfoBusiness")));
         params.add(dalvikVM.addLocalObject(newStringObject(dalvikVM, "{\"callCJH\":\"1\",\"callNPS\":\"1\",\"closeJX\":\"0\",\"headTaskRefresh\":\"1\",\"locationArea\":\"0_0_0_0\",\"menuStaticSource\":\"0\",\"menuTimeStamp\":\"1631586010000\"}")));
         params.add(dalvikVM.addLocalObject(newStringObject(dalvikVM, INSTALL_ID)));
         params.add(dalvikVM.addLocalObject(newStringObject(dalvikVM, PLAT_FROM)));
         params.add(dalvikVM.addLocalObject(newStringObject(dalvikVM, VERSION)));
         Number numbers = moduleModule.callFunction(androidEmulator, 0x028B4+ 1, params.toArray())[0];
         DvmObject<?> object = dalvikVM.getObject(numbers.intValue());
         System.out.println("encryption result: "+ object.getValue());
     }

     @Override
     publicDvmObject<?> getStaticObjectField(BaseVM vm, DvmClass dvmClass, String signature) {
         if("com/jingdong/common/utils/BitmapkitUtils->a:Landroid/app/Application;".equals(signature)) {
             // return the application
             returnvm.resolveClass("android/app/Activity",
                     vm.resolveClass("android/content/ContextWrapper",
                             vm.resolveClass("android/content/Context"))).newObject(null);
         }
         returnsuper.getStaticObjectField(vm, dvmClass, signature);
     }

     @Override
     publicDvmObject<?> callStaticObjectMethod(BaseVM vm, DvmClass dvmClass, String signature, VarArg varArg) {
         if("com/jingdong/common/utils/BitmapkitZip->unZip(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)[B".equals(signature)) {
             byte[] unzip = vm.unzip("META-INF/xx.RSA");
             System.out.println("unzip "+ newString(unzip));
             returnnewByteArray(vm, unzip);
         } elseif("com/jingdong/common/utils/BitmapkitZip->objectToBytes(Ljava/lang/Object;)[B".equals(signature)) {
             DvmObject<?> objectArg = varArg.getObjectArg(0);
             byte[] bytes = objectToBytes(objectArg.getValue());
             returnnewByteArray(vm, bytes);
         }
         returnsuper.callStaticObjectMethod(vm, dvmClass, signature, varArg);
     }

     publicstaticbyte[] objectToBytes(Object obj) {
         try{
             ByteArrayOutputStream byteArrayOutputStream = newByteArrayOutputStream();
             ObjectOutputStream objectOutputStream = newObjectOutputStream(byteArrayOutputStream);
             objectOutputStream.writeObject(obj);
             objectOutputStream.flush();
             byte[] byteArray = byteArrayOutputStream.toByteArray();
             objectOutputStream.close();
             byteArrayOutputStream.close();
             returnbyteArray;
         } catch(IOException e) {
             returnnull;
         }
     }

     @Override
     publicDvmObject<?> newObject(BaseVM vm, DvmClass dvmClass, String signature, VarArg varArg) {
         if("sun/security/pkcs/PKCS7-><init>([B)V".equals(signature)) {
             ByteArray byteArray = varArg.getObjectArg(0);
             try{
                 PKCS7 pkcs7 = newPKCS7(byteArray.getValue());
                 returnvm.resolveClass("sun/security/pkcs/PKCS7").newObject(pkcs7);
             } catch(ParsingException e) {
                 e.printStackTrace();
             }

         }
         returnsuper.newObject(vm, dvmClass, signature, varArg);
     }

     @Override
     publicDvmObject<?> callObjectMethod(BaseVM vm, DvmObject<?> dvmObject, String signature, VarArg varArg) {
         if("sun/security/pkcs/PKCS7->getCertificates()[Ljava/security/cert/X509Certificate;".equals(signature)) {
             PKCS7 pkcs7 = (PKCS7) dvmObject.getValue();
             X509Certificate[] certificates = pkcs7.getCertificates();
             DvmObject<?> object = ProxyDvmObject.createObject(vm, certificates);
             returnobject;


         }
         returnsuper.callObjectMethod(vm, dvmObject, signature, varArg);
     }

     @Override
     publicDvmObject<?> getObjectField(BaseVM vm, DvmObject<?> dvmObject, String signature) {
         // sourceDir is the current APK directory
         if("android/content/pm/ApplicationInfo->sourceDir:Ljava/lang/String;".equals(signature)) {
             StringObject stringObject = newStringObject(vm, APK_PATH);
             returnstringObject;
         }
         returnsuper.getObjectField(vm, dvmObject, signature);
     }


     @Override
     publicDvmObject<?> callObjectMethodV(BaseVM vm, DvmObject<?> dvmObject, String signature, VaList vaList) {
         if("java/lang/StringBuffer->append(Ljava/lang/String;)Ljava/lang/StringBuffer;".equals(signature)) {
             StringBuffer stringBuffer = (StringBuffer) dvmObject.getValue();
             DvmObject<?> objectArg = vaList.getObjectArg(0);
             stringBuffer.append(objectArg.getValue().toString());
             returnvm.resolveClass("java/lang/StringBuffer").newObject(stringBuffer);
         } elseif("java/lang/Integer->toString()Ljava/lang/String;".equals(signature)) {
             Integer integer = (Integer) dvmObject.getValue();
             returnnewStringObject(vm, integer.toString());
         }elseif("java/lang/StringBuffer->toString()Ljava/lang/String;".equals(signature)){
             StringBuffer stringBuffer = (StringBuffer) dvmObject.getValue();
             returnnewStringObject(vm, stringBuffer.toString());
         }
         returnsuper.callObjectMethodV(vm, dvmObject, signature, vaList);
     }

     @Override
     publicDvmObject<?> newObjectV(BaseVM vm, DvmClass dvmClass, String signature, VaList vaList) {
         if("java/lang/StringBuffer-><init>()V".equals(signature)) {
             StringBuffer stringBuffer = newStringBuffer();
             returnvm.resolveClass("java/lang/StringBuffer").newObject(stringBuffer);
         } elseif("java/lang/Integer-><init>(I)V".equals(signature)) {
             intintArg = vaList.getIntArg(0);
             Integer integer = Integer.valueOf(intArg);
             returnvm.resolveClass("java/lang/Integer").newObject(integer);
         }
         returnsuper.newObjectV(vm, dvmClass, signature, vaList);
     }
 }

6. How sign is generated — IDA + unidbg

Load the .so in IDA, find the sign method in the exports:

F5 to pseudocode:

I renamed the functions by their logic for readability. First glance: it joins the inputs as key=value pairs.

The unidbg execution log confirms it:

Now work backwards from where the function returns. A standalone call returns:

st=1648374072802&sign=8102aefd41782d405174725cd433c5a0&sv=111

So I need to analyze three values: st, sign, and sv.

  1. st — a 13-digit timestamp used to compute sign.
  2. sign — generated by sub_126AC; everything above it is just string concatenation of the inputs.

Hook that method in unidbg:

Then endless S single-steps. At the switch, a bl jumps to sub_10de4. Debugging this spot repeatedly: sv = 111 takes case 2, and I’ve also seen sv = 110 take case 1. I’ll only analyze the 2 case (sub_10DE4), because the encryption path after it looks simpler — like MD5.

Check sub_10de4’s arguments by hooking it:

mr1 is a fixed string.

mr2 is a fixed 0x1.

mr3 is the concatenated parameter string, length 0x106.

Enter sub_10de4, then click into sub_12ECC (I skipped sub_12FF0 — nothing special there).

Hook sub_12ECC and inspect its arguments:

Argument 1: printed out, not obviously meaningful, but it’s 64-bit. From the pseudocode below, this value is related to the MD5 magic constants.

Argument 2: the fixed string 80306f4370b39fd5630ad0529f77adb6.

Argument 3: 0x1.

Argument 4: the plaintext concatenated parameter string:

functionId=personinfoBusiness&body={"callCJH":"1","callNPS":"1","closeJX":"0","headTaskRefresh":"1","locationArea":"0_0_0_0","menuStaticSource":"0","menuTimeStamp":"1631586010000"}&uuid=55a9c688729bb118&client=android&clientVersion=10.4.6&st=1648455172760&sv=111

Argument 5: the length, 0x106.

Analyzing the code

From the above, v27 should be the MD5 magic constant array, roughly:

v27[1] = 0x68449237;
v27[2] = 0x7FCC3DA5;
v27[3] = 0x88D90FBB;
v27[4] = 0x5AE99AEE;

Keep going:

This region is the actual implementation. flag is the earlier 0x1, so it takes the if branch. The do-while roughly reads magic constants from v27; v18 = 0x37 is likely the first read. When reimplementing, v27 is this array:

 {0x37, 0x92, 0x44, 0x68, 0xA5, 0x3D, 0xCC, 0x7F, 0xBB, 0x0F, 0xD9, 0x88, 0xEE, 0x9A,
0xE9, 0x5A};

Step on: 0x38 is the first character of the fixed string 80306f4370b39fd5630ad0529f77adb6.

r0 = 0x66 is the first char taken from the concatenated string.

r0 = 0x66  r1 = 0x0  r2 = 0x37  r3 = 0x1  r4 = 0x38
R0 = R0 ^ R2  = 0x51
R0 = R0 ^ R4  = 0x69

From the assembly, I guessed the rest: take the XOR result, add R2 (0x37), then R2 = R2 ^ R0:

*(v5 - 1) = md5_init_key_index ^ *(_BYTE *)(key + v17);// EOR.W R2, R2, R1    STRB.W R2, [R10,#-1]

Cross-referencing IDA’s pseudocode with the assembly: each do-while pass rewrites the value at v5’s index, looping until it hits v5’s length. Reimplementing the sign algorithm in C:

 voidencryption(char*data) {
     uint32_t v1, v2, v3 = 0;
     unsigned charinit_key[60] = {0x37, 0x92, 0x44, 0x68, 0xA5, 0x3D, 0xCC, 0x7F, 0xBB,    0x0F, 0xD9, 0x88, 0xEE, 0x9A,
                                   0xE9, 0x5A};
     char*sign = "80306f4370b39fd5630ad0529f77adb6";
     intdata_length = strlen(data);
     unsigned charmd5_init_key = 0;
     unsigned charsign_key = 0;
     do{
         v1 = v3 & 0xF;
         v2 = v3 & 7;
         v3 = v3 + 1;
         sign_key = sign[v2];
         md5_init_key = init_key[v1];
         md5_init_key = md5_init_key ^ ((md5_init_key ^ *data ^sign_key) + md5_init_key);
         *data = md5_init_key ^ sign_key;
         data++;
     } while(v3 != data_length);
 }

Compare the C reimplementation (called from Python) against the unidbg black-box result:

Closing

This app’s sign has three algorithms; I only reconstructed the simpler sv = 111 one. It still took a while — but pairing unidbg with IDA makes algorithm recovery genuinely painless.


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