Calling the TikTok Signature SO Directly with unidbg

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.

Calling the TikTok Signature SO Directly with unidbg

TikTok version: 33.2.5 (Jan 25, 2024).

1. Where the signature comes from

Capturing TikTok traffic shows that every HTTP request has signature fields added to its headers — X-Argus, X-Gorgon, X-Khronos, and X-Ladon:

The four signature headers

2. Locating the key function

Decompiling the APK with jadx and searching for keywords like x-argus turns up nothing:

No signature keywords in the Java layer

Hooking the Java-layer Header object constructor with Frida shows HTTPS header assignment happening, but the signature field never appears:

var Header = Java.use("X.2jn");
Header.$init.implementation = function(key, val) {
    console.log(key + " : " + val);
    this.$init(key, val);
}

Next I tried hooking the interceptor to print the request headers — and still no signature field in the output:

    // add intercept
    var Request = Java.use("X.LGn");
    Request.LIZJ.implementation = function(intercept){
        var newObj = Java.cast(intercept, Java.use("java.lang.Object"));
        //console.log(newObj.getClass());
        var new_cls = newObj.getClass();
        var class_name = new_cls.getName();
 
        if(!mmp[class_name]) {
            mmp[class_name] = 1;
            var hook_class = Java.use(class_name);
            hook_class.intercept.implementation = function(tt){
                var res = this.intercept(tt);
/*              var rq = tt.request();
                console.log(rq.getUrl()); */
 
                var request = getFieldVal(res, "LIZ");
                //console.log(getFieldVal(request, "LIZ"));
                //console.log(getFieldVal(request, "LIZJ"));
                //console.log(getFieldVal(request, "LJI"));
                 
                var headers = getFieldVal(request, "LIZLLL");
                //console.log(headers);
                //console.log("----------------------------------------------------");
                return res;
            }
        }
 
        this.LIZJ(intercept);
    }

After searching the entire Java layer without finding the signature string, it’s very likely set in the native layer, right when the HTTPS request goes out. TikTok uses the Cronet networking library. Dropping libsscronet into IDA and searching for the x-gorgon keyword does find it:

x-gorgon in libsscronet

Cross-referencing the Cronet source gives us the native function that sets request headers:

void HttpRequestHeaders::SetHeader(const base::StringPiece& key,
                                   const base::StringPiece& value) {
  DCHECK(HttpUtil::IsValidHeaderName(key.as_string()));
  // TODO(ricea): Revert this. See crbug.com/627398.
  CHECK(HttpUtil::IsValidHeaderValue(value.as_string()));
  HeaderVector::iterator it = FindHeader(key);
  if (it != headers_.end())
    it->value.assign(value.data(), value.size());
  else
    headers_.push_back(HeaderKeyValuePair(key, value));
}

The offset in libsscronet is 0x2C44BC. Hook that address and print the arguments:

var SetHeaders = lib.base.add(0x2C44BC);
Interceptor.attach(SetHeaders, {
    onEnter: function (args) {
        var key = args[1].readPointer().readUtf8String();  // first field is the string address, second is its length
        var val = args[2].readPointer().readUtf8String();
        console.log(key + " : " + val);
    },
    onLeave: function (retval) {
         
    }
});

This time every header shows up. Perfect:

All headers visible

Then print the call stack when the signature fields are set:

var SetHeaders = lib.base.add(0x2C44BC);
Interceptor.attach(SetHeaders, {
    onEnter: function (args) {
 
        var key = args[1].readPointer().readUtf8String();  // first field is the string address, second is its length
        var val = args[2].readPointer().readUtf8String();
        if(key.indexOf("X-Gorgon") !== -1) {
            console.log(key + " : " + val);
            console.log('SetHeaders called from:\n' + Thread.backtrace(this.context, Backtracer.ACCURATE).map(DebugSymbol.fromAddress).join('\n') + '\n');
            console.log("---------------------------end----------------------------------");
        }
    },
    onLeave: function (retval) {
         
    }
});

There are two call sites: 0x3a662c and 0x2c4b78:

Two call sites

Decompiling both, 0x3a662c looks more like TikTok’s custom header-assembly point. The key decompiled code:

Decompiled custom header assembly

v128 is the result returned from some function call, and splitting that result yields the signature fields. So hook there and print the two arguments passed in:

The two arguments

Hook 0x3A658C directly and print the x0, x1, and X23 register values:

var test = lib.base.add(0x3A658C);
Interceptor.attach(test, {
    onEnter: function (args) {
        console.log(args[0].readUtf8String());
        console.log(args[1].readUtf8String());
        console.log("X23:" + this.context.x23);
    },
    onLeave: function (retval) {
         
    }
});

Output (values redacted):

https://pitaya-ttp2.us.tiktokv.com/api/v2/pitaya/distribute
cookie
[redacted]
x-vc-bdturing-sdk-version
2.3.5.i18n
x-bd-kmsv
0
x-tt-dm-status
login=1;ct=1;rt=1
x-ss-req-ticket
[redacted]
x-bd-client-key
[redacted]
pns_event_id
75
content-type
application/json; charset=utf-8
content-length
919
x-tt-store-region
us
x-tt-store-region-src
uid
x-ss-dp
1233
x-tt-trace-id
[redacted]
user-agent
com.zhiliaoapp.musically/2023302050 (Linux; U; Android 11; en; Mi 9T Pro; Build/RKQ1.200826.002; Cronet/TTNetVersion:996128d2 2024-01-12 QuicVersion:ce58f68a 2024-01-12)
accept-encoding
gzip, deflate, br
X23:0x790584aee0

Argument 1 is the HTTPS request URL, argument 2 is the HTTPS request header, and X23 sits at offset 0x88ee0 inside libmetasec_ov.so. So 0x88ee0 is basically confirmed as the key signature function.

Hook that function and print its arguments and return value:

function waitForModule(moduleName) {
    return new Promise(resolve => {
        const interval = setInterval(() => {
            const module = Process.findModuleByName(moduleName);
            if (module != null) {
                clearInterval(interval);
                resolve(module);
            }
        }, 0);
    });
}
 
 
waitForModule("libmetasec_ov.so").then((lib) => {
    logger(`[*][+] Found libsscronet at: ${lib.base}`)
    hook_tt_get_sig(lib);
});
 
 
function hook_tt_get_sig(lib) {
    var get_sig = lib.base.add(0x88ee0);
    Interceptor.attach(get_sig, {
        onEnter: function (args) {
            console.log("arg0:" + args[0].readUtf8String());
            console.log("arg1:" + args[1].readUtf8String());
        },
        onLeave: function (retval) {
            console.log("retval: " + retval.readUtf8String());
        }
    });
}

Output (values redacted):

arg0:https://log16-normal-useast8.us.tiktokv.com/service/2/app_alert_check/?device_id=[redacted]&aid=1233&tt_info=[redacted]&iid=[redacted]&ac=wifi&channel=googleplay&app_name=musical_ly&version_code=330205&version_name=33.2.5&device_platform=android&os=android&ab_version=33.2.5&ssmix=a&device_type=Mi+9T+Pro&device_brand=Xiaomi&language=en&os_api=30&os_version=11&openudid=[redacted]&manifest_version_code=2023302050&resolution=1080*1920&dpi=480&update_version_code=2023302050&_rticket=[redacted]&is_pad=0&current_region=NZ&app_type=normal&sys_region=US&mcc_mnc=53028&timezone_name=Pacific%2FAuckland&residence=NZ&app_language=en&carrier_region=NZ&ac2=wifi5g&uoo=0&op_region=NZ&timezone_offset=43200&build_number=33.2.5&host_abi=arm64-v8a&locale=en&region=US&ts=1707127561&cdid=[redacted]&cronet_version=996128d2_2024-01-12&ttnet_version=4.2.137.48-tiktok&use_store_region_cookie=1
arg1:cookie
[redacted]
x-tt-multi-sids
[redacted]
sdk-version
2
x-bd-kmsv
0
x-tt-dm-status
login=1;ct=1;rt=1
x-tt-token
[redacted]
x-ss-req-ticket
[redacted]
x-bd-client-key
[redacted]
multi_login
1
passport-sdk-version
19
pns_event_id
22
x-vc-bdturing-sdk-version
2.3.5.i18n
x-ss-dp
1233
x-tt-trace-id
[redacted]
user-agent
com.zhiliaoapp.musically/2023302050 (Linux; U; Android 11; en; Mi 9T Pro; Build/RKQ1.200826.002; Cronet/TTNetVersion:996128d2 2024-01-12 QuicVersion:ce58f68a 2024-01-12)
accept-encoding
gzip, deflate, br
retval: X-Argus
[redacted]
X-Gorgon
[redacted]
X-Khronos
[redacted]
X-Ladon
[redacted]

Confirmed: the four signature fields and their values are returned together, line-split, then set individually in the request headers.

3. Calling it directly

Enough talk — here’s the code:

package com.zhiliaoapp.musically;
 
import capstone.api.Instruction;
import com.github.unidbg.AndroidEmulator;
import com.github.unidbg.Emulator;
import com.github.unidbg.Module;
import com.github.unidbg.arm.backend.Unicorn2Factory;
import com.github.unidbg.file.FileResult;
import com.github.unidbg.file.IOResolver;
import com.github.unidbg.file.linux.AndroidFileIO;
import com.github.unidbg.hook.HookListener;
import com.github.unidbg.linux.android.AndroidEmulatorBuilder;
import com.github.unidbg.linux.android.AndroidResolver;
import com.github.unidbg.linux.android.dvm.*;
import com.github.unidbg.linux.android.dvm.jni.ProxyClassFactory;
import com.github.unidbg.linux.file.SimpleFileIO;
import com.github.unidbg.listener.TraceCodeListener;
import com.github.unidbg.memory.Memory;
import com.github.unidbg.memory.MemoryBlock;
import com.github.unidbg.memory.SvcMemory;
import com.github.unidbg.pointer.UnidbgPointer;
import sun.rmi.runtime.Log;
 
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
 
public class MetaSec extends AbstractJni implements IOResolver<AndroidFileIO> {
 
    private final AndroidEmulator emulator;
    private final VM vm;
    private final Module module;
 
    public MetaSec() {
 
        emulator = AndroidEmulatorBuilder.for64Bit()
                .setProcessName("com.zhiliaoapp.musically")
                .addBackendFactory(new Unicorn2Factory(true))
                .build(); // create the emulator instance; choose 32-bit or 64-bit here
        final Memory memory = emulator.getMemory(); // the emulator's memory API
        //SvcMemory svcMemory = emulator.getSvcMemory();
        memory.setLibraryResolver(new AndroidResolver(23)); // set the system library resolver
 
        vm = emulator.createDalvikVM(); // create the Android VM
        vm.setVerbose(false); // whether to print JNI call details
        vm.setJni(this);
 
        // load dependent SO libraries
        vm.loadLibrary(new File("unidbg-android/src/test/resources/example_binaries/libPitayaObject.so"), false);
        vm.loadLibrary(new File("unidbg-android/src/test/resources/example_binaries/libAndroidPitayaProxy.so"), false);
        vm.loadLibrary(new File("unidbg-android/src/test/resources/example_binaries/libc++_shared.so"), false);
 
        // the module's bound Java-layer classes
        DvmClass k = vm.resolveClass("ms/bd/o/k");
        DvmClass a0 = vm.resolveClass("ms/bd/o/a0",k);
        vm.resolveClass("com/bytedance/mobsec/metasec/ov/MS", a0);
 
        // load the target module
        DalvikModule dm = vm.loadLibrary(new File("unidbg-android/src/test/resources/example_binaries/libmetasec_ov.so"), false);
        module = dm.getModule();
 
        //emulator.traceCode(module.base, module.base + module.size);
        dm.callJNI_OnLoad(emulator); // manually call JNI_OnLoad
 
        // read the test parameters
        String url = readFile("unidbg-android/src/test/resources/test_url.txt");
        MemoryBlock block = memory.malloc(url.length(),true);
        UnidbgPointer url_ptr = block.getPointer();
        url_ptr.write(url.getBytes());
 
        String header = readFile("unidbg-android/src/test/resources/test_header.txt");
        System.out.println(header);
        MemoryBlock block_header = memory.malloc(header.length(),true);
        UnidbgPointer header_ptr = block_header.getPointer();
        header_ptr.write(header.getBytes());
 
        //emulator.traceCode(module.base, module.base + module.size);
        // call the target signature function
        Number number = module.callFunction(emulator,
                0x88ee0,
                url_ptr, header_ptr);
 
        // print the return value
        UnidbgPointer result = memory.pointer(number.longValue());
        System.out.println(result.getString(0));
    }
 
 
    public static void main(String[] args) throws Exception {
        new MetaSec();
    }
 
    @Override
    public FileResult<AndroidFileIO> resolve(Emulator<AndroidFileIO> emulator, String pathname, int oflags) {
        System.out.println("PATHNAME:" + pathname);
//        if (pathname.equals("/system/lib/libc.so")){
//            System.out.println("LIBSO");
//            File reg = new File("unidbg-android\\src\\main\\resources\\android\\sdk23\\lib\\libc.so");
//            return FileResult.<AndroidFileIO>success(new SimpleFileIO(oflags,reg,pathname));
//        }
//        if (pathname.equals("/proc/self/exe")){
//            System.out.println("EXE");
//            return FileResult.<AndroidFileIO>success(new SimpleFileIO(oflags, new File("E:\\Douyin\\unidbg-0.9.7\\unidbg-android\\src\\test\\resources\\dylib\\base.apk"), pathname));
//        }
//        if (pathname.equals("/sys/devices/system/cpu/online")){
//            System.out.println("ONLINE");
//            return FileResult.<AndroidFileIO>success(new SimpleFileIO(oflags, new File("E:\\Douyin\\unidbg-0.9.7\\unidbg-android\\src\\test\\resources\\dylib\\online"), pathname));
//        }
 
        return null;
    }
 
    @Override
    public int getStaticIntField(BaseVM vm, DvmClass dvmClass, String signature) {
        System.out.println("getStaticIntField: " + signature);
        if ("com/bytedance/mobsec/metasec/ov/MS->a()V".equals(signature)) {
            return 0x40;
        }
        throw new UnsupportedOperationException(signature);
    }
    @Override
    public void callVoidMethod(BaseVM vm, DvmObject<?> dvmObject, String signature, VarArg varArg) {
        System.out.println("callVoidMethod: " + signature);
        switch (signature) {
            case "com/bytedance/mobsec/metasec/ov/MS->a()V":
                System.out.println("> Patched: com/bytedance/mobsec/metasec/ov/MS->a()V");
                return;
        }
        super.callVoidMethod(vm, dvmObject, signature, varArg);
    }
 
    @Override
    public DvmObject<?> callStaticObjectMethodV(BaseVM vm, DvmClass dvmClass, String signature, VaList vaList) {
        System.out.println(signature);
        switch (signature) {
            case "com/bytedance/mobsec/metasec/ov/MS->b(IIJLjava/lang/String;Ljava/lang/Object;)Ljava/lang/Object;":
                return null;
        }
        throw new UnsupportedOperationException(signature);
    }
 
    private String readFile(String fileName) {
        String res="";
        try{
            FileInputStream fin = new FileInputStream(new File(fileName));
            int length = fin.available();
            byte [] buffer = new byte[length];
            fin.read(buffer);
            res = new String(buffer);
            fin.close();
        }
        catch(Exception e){
            e.printStackTrace();
        }
        return res;
    }
}

Test passed:

Test passed


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