Android 13 introduces many enhancements in order to harden Parcel serialization mechanism
Here's presentation from Android Security and Privacy team on enhancements made
That is great, definitely eliminates or makes unexploitable many vulnerabilities. Also they describe breaking my previous exploit, allowing apps to load their code into other apps (including system ones)
But now I am back with new exploit that achieves the same, although in different way. It relies on following vulnerabilities that were introduced during aforementioned Parcel hardening:
(Also logcat from app execution, exploitation is noisy in logs)
Android's Parcel class is base of communication between processes
Objects can implement Parcelable interface in order to allow writing them to Parcel, for example (copied from AOSP):
public class UsbAccessory implements Parcelable {
public static final Parcelable.Creator<UsbAccessory> CREATOR =
new Parcelable.Creator<UsbAccessory>() {
public UsbAccessory createFromParcel(Parcel in) {
String manufacturer = in.readString();
String model = in.readString();
String description = in.readString();
String version = in.readString();
String uri = in.readString();
IUsbSerialReader serialNumberReader = IUsbSerialReader.Stub.asInterface(
in.readStrongBinder());
return new UsbAccessory(manufacturer, model, description, version, uri,
serialNumberReader);
}
};
public void writeToParcel(Parcel parcel, int flags) {
parcel.writeString(mManufacturer);
parcel.writeString(mModel);
parcel.writeString(mDescription);
parcel.writeString(mVersion);
parcel.writeString(mUri);
parcel.writeStrongBinder(mSerialNumberReader.asBinder());
}
}Note that Parcel internally stores position at which write or read is performed, readString() parses data into String as well as advances position. That position can be manually get/set through dataPosition()/setDataPosition(). Implementations of Parcelable interface must ensure that their writeToParcel and createFromParcel write/read same amount of data, otherwise all subsequent reads will get data from wrong offsets
Bundle (key-value map that can be sent across processes) can contain variety of objects that can be written to Parcel through writeValue(). When contents of Bundle are read from Parcel, any Parcelable class available in system there can be read
Bundle defers actual parsing of contents by having length of whole parcelled data written into Parcel and then just copying relevant part of original Parcel to secondary Parcel stored in mParcelledData (this allows for example Activity.onSaveInstanceState() to provide Parcelables which are not available in system_server, whole Bundle is then passed to system_server and back verbatim without parsing contents)
Once however any value in Bundle was accessed, all values inside Bundle were unparcelled and every present key-value pair was parsed. If such map contained Parcelable which had unbalanced writeToParcel and createFromParcel methods and later such Bundle was forwarded to another process, that another process could see different contents of Bundle. This made all such mismatches in classes available in system vulnerabilities as there are places in system where Bundle is inspected to be safe and then forwarded to another process
In this writeup I'm calling such Bundle which presents one contents and then other after being forwarded a self-changing Bundle
Another important thing here is that besides just bytes (Strings, numbers, objects made of above), Parcel can also contain File Descriptors and Binders. Binders are objects on which one can make RPC call, that is one process creates Binder object and overrides onTransact() method. Then Binder is passed to another process, in example code above you can see read/writeStrongBinder() calls used to read and write it to Parcel. In another process, when readStrongBinder() is used a BinderProxy object is created (hidden behind IBinder interface). Then that another process can call transact() on that object and in original object onTransact() will be executed. Usually through, one doesn't manually write transact()/onTransact() but uses AIDL instead
Since in the past there were many cases of classes with writeToParcel/createFromParcel mismatch, Android 13 solves problem of any such class being present anywhere in system allowing construction of self-changing Bundle by introducing LazyValue
Now, when writeValue is used, if value being written is not primitive, length of value is written to Parcel as well
When normal app directly uses Parcel.readValue(), everything happens as before except a warning is printed if length read from Parcel doesn't match size of actually read data (Note though that Slog.wtfStack never throws)
Bundle however, now uses Parcel.readLazyValue() instead
Lets take closer look at how it works: in LazyValue class we have nice comment explaining structure of LazyValue data inside Parcel:
| 4B | 4B |
mSource = Parcel{... | type | length | object | ...}
a b c d
length = d - c
mPosition = a
mLength = d - a
mSource is reference to original Parcel on which readLazyValue() was called
mPosition and mLength describe location of whole LazyValue data in original Parcel, including type and length
"length" (without "m" at beginning) refers to length value as written to Parcel and excludes header (type and length)
So here is what happens when someone (either system or app) takes value from Bundle that was read from Parcel:
- Caller uses one of many
get*()methods ofBundleclass, for example newgetParcelable()with type argument (Flow will be same for both new and old methods, just new methods ensure thatclazzargument isn'tnullwhile legacy ones set it tonull) unparcel()is called, which will check if thisBundlehasmParcelledData(meaning it was read fromParcelbut no value was accessed yet and key names were not unpacked yet, if that isn't the case skip to step 5.)unparcel()delegates tounparcel(boolean itemwise), which callsinitializeFromParcelLocked(source, /*recycleParcel=*/ true, mParcelledByNative);,sourceis set tomParcelledData, a copy ofParcelthatBundlehas made andrecycleParcelparameter is set totrueto indicate that passedParcelis owned byBundleand it is okay to callParcel.recycle()on itinitializeFromParcelcallsrecycleParcel &= parcelledData.readArrayMap(map, count, !parcelledByNative, /* lazy */ true, mClassLoader)in order to read key-value map contents. Keys areStrings and values are read usingreadLazyValue(), creatingLazyValueobjects for values of types which are written along length prefix.readArrayMap()returns value indicating if it is okay to recycleParcel. If there were anyLazyValueobjects presentrecycleParcelis set tofalseandParcelto whichLazyValues refer won't be recycled (there is an exception to that, but it is not relevant here, I'll describe it in "Additional note:Bundle.clear()" section)- Once
unparcel()is done,mMapis set (notnull) and mapsStringkeys to either actual values if they are ready orLazyValueobjects - After that,
getValue()is called, which maps key (String) to index (int) and passes it togetValueAt() getValueAt()detectsLazyValuethroughinstanceof BiFunctionand callsapply()to deserialize itLazyValue.apply()rewindsParcelto position ofLazyValue.mPositionand calls normalParcel.readValue()about which I've already said- Upon successful deserialization
LazyValueis replaced inmMap, so that nextBundle.get*()call for same key will directly return value andLazyValuedeserialization won't be repeated. WhenBundleis forwarded that value will be serialized again instead of having original data copied verbatim (however after forwardedBundleis read, that value will beLazyValueagain and any possiblewriteToParcel/createFromParcelmismatches won't be able affect other values)
If Bundle is being forwarded while it still contains LazyValue (meaning that this particular value was not accessed, but some other value from that Bundle was (that is unparcel() was called, but LazyValue.apply() for that item wasn't)):
LazyValueis detected byParcel.writeValue()and write is delegated toLazyValue.writeToParcel()LazyValue.writeToParcel()usesout.appendFrom(source, mPosition, mLength)to copy wholeLazyValuedata from originalParcel(again,mPositionandmLengthincludeLazyValueheader, so this also copiestypeandlengthfrom originalParcel)
(Details of these are not important for this exploit, only relevant thing here is that these mechanisms exist)
Another interesting feature of Parcel is optional ability to deduplicate written Strings and objects
The deduplication of Strings is done by overriding Parcel.ReadWriteHelper class: Parcel.readString() actually delegates to ReadWriteHelper and default helper directly reads String from Parcel
Alternate implementation of Parcel.ReadWriteHelper can replace readString calls with reading pool of Strings beforehand and using readInt to get indexes of Strings in pool; that however is never done with app-controlled Parcels
Parcel does offer hasReadWriteHelper() method, which allows callers detect presence of such deduplication mechanism being active and disable features incompatible with it
Other deduplication mechanism available in Parcel is squashing:
- First, squashing has to be enabled with
Parcel.allowSquashing() - Then, when class supporting squashing is being written, it first calls
Parcel.maybeWriteSquashed(this). If that method returnedtrueit means that object was already written to thisParceland now only offset to previous object data was written toParcel. Otherwise (either squashing is not enabled or this is first time this object is written)maybeWriteSquashedwrites zero as offset to indicate that object isn't squashed and returnsfalseto indicate to caller that they should write actual object data - When reading,
Parcel.readSquashedis called and actual read function is passed to it as lambda.readSquashedchecks if offset written bymaybeWriteSquashed()indicates that another occurrence of object was read earlier: if yes then previously read object is returned, otherwise provided lambda is called to read it now
On Java side Parcel objects can be recycled into pool, that is once you're done with Parcel you call recycle() on it and next time someone calls Parcel.obtain() they'll get previously recycled Parcel. This allows reducing amount of object allocations and subsequent Garbage Collection
On the other hand, such manual memory management brings possibility of Use-After-Free-like bugs into Java (although with type safety, unlike usual Use-After-Free in C)
As noted above, Bundle creates copy of Parcel and won't call Parcel.recycle() if LazyValue is present, that however is not the case if Parcel.hasReadWriteHelper() is true, in that case:
initializeFromParcelLocked(parcel, /*recycleParcel=*/ false, isNativeBundle);is called, this means thatBundlewon't recycleParcelas it still belongs to caller, however this does createLazyValues which refer to originalParceland could outlive originalParcels lifetime- Therefore, next thing after that is call to
unparcel(/* itemwise */ true), which will usegetValueAt()on all items to replace allLazyValues present inBundlewith actual values
Now, can we make these LazyValues survive step 2. and turn that behavior into Use-After-Recycle?
If deserialization fails (for example class with name specified inside Parcel could not be found), a BadParcelableException is thrown and then caught by getValueAt(). If BaseBundle.sShouldDefuse static field is true, an exception isn't raised and execution proceeds leaving Bundle containing LazyValue referring to original Parcel. sShouldDefuse indicates that unavailable values from Bundle shouldn't cause exceptions in particular process and is set to true in system_server
If original Parcel gets recycled and after that the Bundle read from it will be written to another Parcel, contents of original Parcel will be copied to destination Parcel, but at that point original Parcel could be reused for something else and data from unrelated IPC operation could be copied
Okay, but how do we get Parcel.hasReadWriteHelper() to be true while Bundle provided by us is being deserialized?
Turns out that RemoteViews class (normally used for example for passing widgets to home screen) explicitly sets ReadWriteHelper when reading Bundles nested in it. This ReadWriteHelper doesn't do String deduplication and is present only to cause Bundle to skip copying data to secondary Parcel. The reason for that being done is that RemoteViews enables squashing in order to deduplicate ApplicationInfo objects nested in it, but this could also cause ApplicationInfo objects present inside Bundle to be squashed, so reading of that Bundle cannot be deferred because then those squashed objects would fail to unsquash
So now we want system_server to read our RemoteViews containing Bundle containing LazyValue that fails to deserialize and later (in another Binder IPC transaction) send that object back to us
It probably could be done through some legitimate means, such as registering ourselves as app widget host (but that would require user interaction to grant us permission) or posting a Notification with contentView set (but that would cause interaction with other processes and/or be visible to user and I preferred to avoid both of these)
I've decided instead to create MediaSession and call setQueue(List<MediaSession.QueueItem> queue) on it to send object to system_server and later get it back through List<MediaSession.QueueItem> getQueue() method of MediaController (which can be retrieved through MediaSession.getController()). While these methods don't look like they could accept RemoteViews, they actually do thanks to Java Type Erasure and fact that under the hood they are implemented using generic serialization operations on List
However, I'm not using these SDK methods, I'm manually writing data for underlying Binder transactions (because I need to write and later read malformed serialized data), so lets take a look at how these methods work
Both of these methods had to take care of fact that total size of queue might exceed maximum size of Binder transaction so transfer can be split into multiple transactions
Sending "queue" to system_server normally goes as follows:
MediaSession.setQueue()first callsISession.getBinderForSetQueue()- On
system_serverside that methods constructs and returnsParcelableListBinderobject - After that,
MediaSession.setQueue()callsParcelableListBinder.send()which will send list contents into providedBinder, possibly over multiple transactions:- First transaction contains at beginning total number items that will be in list before contents of first part
- Then, for every item transferred there's
1written and actual item is written throughParcel.writeParcelable()(which writes name of class being sent and then callsParcelable.writeToParcelto send data) - If we've approached limit of
Bindertransaction size,0is written to indicate that there are no more items in this transaction and next items will be sent in another transaction
- Once
ParcelableListBinderhas received number of elements that was specified in first transaction, it invokes lambda passed to its constructor, which in this case assigns retrieved list toMediaSessionRecord.mQueue
Retrieving "queue" on the other hand, goes bit differently:
MediaController.getQueue()just callsISessionController.getQueue()and unwraps receivedParceledListSlice- On
system_serverside,getQueue()just wrapsmQueueintoParceledListSliceand returns it - Whole split-across-multiple-transactions logic is inside
ParceledListSlice.writeToParcel()andcreateFromParcel()methods, in particular,writeToParcel()upon reaching safe size limit will writeBinderobject that allows retrieving next chunks - When
ParceledListSliceis read fromParcel, it reads first part directly fromParceland then if not all elements were written inline, it callsBinderthat was written toParcelin order to retrieve these items
As to why these are different: there is an ongoing effort to make sure that system_server doesn't make outgoing synchronous Binder calls to other apps, because if these calls would hang that could hang whole system_server. This means system_server shouldn't be receiving ParceledListSlices. While there is code that warns about outgoing synchronous transactions from system_server, it couldn't yet be made enforcing because there are still cases where system_server does make such calls, for example by actually receiving ParceledListSlice
So now we have primitives needed to make system_server do parcel_that_will_be_sent_to_us.appendFrom(some_recycled_parcel, somewhat_controlled_position, controlled_size)
We could either randomly attempt pulling Parcel data from system or arrange stuff to take something specific
There are following considerations:
- When
Parcel.recycle()is called, contents of thatParcelare cleared. This means thatParcelfrom which we would like to have data copied from must not berecycle()d, which approximately means that we can't take data fromBindertransaction that has finished - Alternatively, we could take data from some
Parcelof someBundlepresent in system (this includesIntentextras andsavedInstanceStateofActivity). These are usually notrecycle()d at all (they are cleaned by Garbage Collector and don't return to pool, when pool gets depletedParcel.obtain()creates newParcelobjects. Of courseParcels to which we're holding reference won't be GCed, even if system has no other use for them) Parcels used for incomingBindertransactions use separate pool than otherParcels in system. When an outgoingBindertransaction is being made,Bundlecopies data to secondaryParcel, or an app usesParcelfor their own purposes, they callParcel.obtain(), which usesParcel.sOwnedPool. On the other hand, when there's an incomingBindertransaction, system callsParcel.obtain(long obj), which usesParcel.sHolderPool. In both casesParcel.recycle()is used afterwards and takes care of returningParcelobject into appropriate pool. This means exploit must haveRemoteViewsread fromParcelbelonging to same pool as we'd like to leak data from. Before I've decided on particular variant I've written both, so you can find bothmakeOwnedLeakerandmakeHolderLeakermethods in myValueLeakerMakerclass
In the end I've decided to attempt grabbing IApplicationThread Binder, which is sent by app to system_server when app process starts and system_server uses it to tell application which components it should load
When application process initially starts, one of first things it does is sending IApplicationThread to system_server through call to attachApplication() and this is transaction from which I'll be grabbing that Binder from. There are other places where IApplicationThread is being put in Parcel, such as being passed for caller identification by system when starting activity (but I didn't have much control over when target application does that) or being sent by system to application as part of Activity lifecycle management (but this is done in oneway transaction outgoing from system_server and chances of winning race against Parcel.recycle() would be slim)
That being said, grabbing Binder that is being received by system_server during attachApplication() transaction is also nontrivial and there were few problems to overcome
First problem with grabbing IApplicationThread Binder from Parcel from which data for attachApplication() are received is that this Binder is at quite early/low dataPosition(), much lower than our LazyValue in Bundle in RemoteViews could be
Data for attachApplication() transaction consist just of RPC header followed by IApplicationThread Binder. RPC header (written through Parcel.writeInterfaceToken()) consist of few ints and name of interface, in this case "android.app.IActivityManager"
Meanwhile to read Bundle embedded in RemoteViews we'd need to get past at least (few minor items are skipped):
- Item presence flag to start
readParcelable - Name of
Parcelable:"android.view.RemoteViews" - Quite large
ApplicationInfoobject present inRemoteViews(also it must not benulland have non-nullpackageNameorRemoteViews.writeToParcel()will fail when we'll try to get this object to be sent back) - Finally we reach
RemoteViews.readActionsFromParcel(), which callsgetActionFromParcel(), which constructsReflectionAction, which after reading commonBaseReflectionActionparameters finally constructsBundle
Now in Bundle, we just need to put String key and reading of LazyValue starts, position in Parcel is remembered, but at this point its way past position IApplicationThread Binder would be
Can we perhaps upon reaching this point rewind position in Parcel? In other words could we have Parcel.setDataPosition() called with value pointing to earlier position than current one?
Turn out, we can, thanks to another bug in LazyValue. This is code used for reading it:
public Object readLazyValue(@Nullable ClassLoader loader) {
int start = dataPosition();
int type = readInt();
if (isLengthPrefixed(type)) {
int objectLength = readInt();
int end = MathUtils.addOrThrow(dataPosition(), objectLength);
int valueLength = end - start;
setDataPosition(end);
return new LazyValue(this, start, valueLength, type, loader);
} else {
return readValue(type, loader, /* clazz */ null);
}
}(Original in AOSP, LazyValue constructor just assigns parameters to fields)
The thing is MathUtils.addOrThrow() checks for overflow, but is perfectly fine with negative values
If we'd try doing Parcel.writeValue() on LazyValue with negative mLength (filled from valueLength parameter) then that would throw on appendFrom(), however since we're during read of Bundle with Parcel.hasReadWriteHelper() being true all LazyValues are unparcelled after reading and we had to intentionally put faulty Parcelable inside it make it stay as LazyValue. If we put valid parcelled data at position where LazyValue is, it'll be unparcelled and as noted earlier mismatched length will only trigger message in logcat. This particular exploit sets type to VAL_MAP and number of key-value pairs to zero. In logcat upon reading that value we can see following message: "E Parcel : android.util.Log$TerribleFailure: Unparcelling of {} of type VAL_MAP consumed 4 bytes, but -540 expected."
(Also LazyValue with negative length specified can be used (without using other bugs described in this writeup) to create self-changing Bundle, the thing LazyValue was created to eliminate. But that is another story (and separately reported to Google), in this exploit I'm aiming for more)
So how much do we want to rewind?
After setDataPosition() call happens, reading will proceed to next key-value pair in Bundle, so we need to pick position where we'll have:
- Bundle key, read using
Parcel.readString(), can be pretty much anything, including pointing at invalid length (negative or exceeding totalParcelsize), in that casereadString()would returnnullwhich is valid key inBundle - Value type, this must be one of types for which
isLengthPrefixed()returnstrue - Value length, this also shall be value controlled by us,
Parcel.appendFrom()will fail if length is not aligned or exceeds total size of sourceParcel
So what position in Parcel that could be it could be considering the same data were already read and are necessary to reach this point:
- Not before name of
Parcelable("android.view.RemoteViews"), because there's not enough space - Not inside name of
Parcelable, because we're unable to set type and length - Not directly after name of
Parcelable, because first thing inRemoteViewsismodewhich we must set toMODE_NORMALto reach our code - Not after that, because that's past point where
IApplicationThreadBinderis
Hmm, there isn't any good place when RemoteViews is outermost object in parcelled data
We need to find some other Parcelable that:
- Has at or near beginning place where we can put arbitrary data (e.g.
ints orStrings that are just data and don't affect serialization process) - Can contain
RemoteViews(either directly or via arbitraryreadParcelable) - Has not too long fully qualified class name, because we're still size limited by position at which
IApplicationThreadstays in targetParcel
So I've taken list of Parcelable classes in system, sorted it by ascending length of fully qualified class name and began checking items on that list to see if they fulfill condition 2
That way I've reached to "android.os.Message", which is what this exploit uses. Now process of reading item our prepared object from Parcel goes as follows:
- Item presence flag to start
readParcelable - Name of
Parcelable:"android.os.Message" - Few
ints that we can set to whatever values we want are read into fields - We reach
readParcelable()call, which goes all the way we described above throughRemoteViewsand starts readingBundlewithParcel.hasReadWriteHelperbeingtrue - That
Bundledeclares to have two key-value pairs. In first value we haveLazyValuewith negative length, that triggersParcel.setDataPosition()to position where"android.os.Message"Stringis - Reading proceeds to second key-value pair, the key is
"android.os.Message"andLazyValuetype, length and data are taken fromints described in third bullet. I've gotLazyValuewithmPositionandmLengthI wanted. Hooray! - After
LazyValues are read they are unparcelled. The one with negative size gets successfully unparcelled and replaced with emptyMap, while the other fails deserialization, but that exception is caught andLazyValuejust stays inBundle readParcelable()finishes, but that isn't end ofMessagedata.Message.readFromParcel()is now continuing reading data after rewind and sees data which were initially written as part ofRemoteViews. If anything throws exception at this point whole plan gets foiled- First possible exception, there's
readBundle()call.Bundlehas magic value and if it is wrong an exception will be thrown. That magic value however isn't present if length is zero or negative and that happened to be the case when length ofLazyValuedata was set to value I've needed to grabIApplicationThread. So I just got lucky here - Next possible problem could be
Messenger.readMessengerOrNullFromParcel()call. This is actually wrappedBinderobject. Reading of thatBinderfails, becauseBinderis special object inParceland it must be annotated out-of-band to be read. This problem is detected and logged byParcelon native side, however this isn't propagated as error andnullis simply returned
Okay, so in previous step we've successfully created object that will allow us grabbing IApplicationThread object while attachApplication() method is running
The thing is, that method completes quickly and our chances in fair race against its completion would be rather slim
That method however, does acquire few mutexes (Through use of Java's synchronized () {} blocks), if we get to acquire one of such mutexes and stall there, this method would stall as well
Now lets return to few things that were already said in this writeup and will become useful for this purpose:
Bundleperforms deserialization of values in it when these values are accessed- There is
ParceledListSliceclass which during deserialization will make blocking outgoingBindercall to object specified inside serialized data
Adding all those things together: If we find in system_server place where contents of Bundle provided by app are accessed under mutex which is also used by attachApplication(), we'll be able to stall attachApplication() until Binder transaction made to our process finishes
ActivityOptions is class describing various parameters related to Activity start (for example animation). Unlike other classes describing parameters passed to system_server, this one doesn't implement Parcelable but instead provides method for converting it to Bundle
On system_server side, that Bundle is converted back to ActivityOptions, triggering deserialization. I've found place where that operation is being done while ActivityTaskManagerService.mGlobalLock mutex is held in ActivityTaskManagerService.moveTaskToFront()
So I call ActivityManager.moveTaskToFront(), passing Bundle that contains ParceledListSlice instead of value with expected type. That ParceledListSlice makes Binder call to my process and until I return from that call the ActivityTaskManagerService.mGlobalLock mutex will remain locked
Parcel.recycle() and Parcel.obtain() work in Last-In-First-Out manner
This means, that if I create rigged LazyValue when no other Binder transaction to system_server is running, I'll get LazyValue that will point to Parcel that is always used when there is only one transaction incoming to system_server (until it happens that two concurrent transactions incoming to system_server start and finish in non-stack order)
As I don't have control over what other transactions are incoming to system_server, in order to improve exploit reliability I've created multiple LazyValues pointing to various Parcels
Since I have ability to trigger synchronous Binder transaction to my process from system_server, I've used that ability to create LazyValue at various levels of recursion between my process and system_server (although this time I did so without holding a global mutex)
So:
- I create
LazyValue - I trigger call to
system_server,system_servercalls me back- I create
LazyValue - I trigger call to
system_server,system_servercalls me back- I create
LazyValue - I trigger call to
system_server,system_servercalls me back- ...
- I create
- I create
Then once I've got enough LazyValues I finish doing that, return from all of these calls and all Parcels which were reserved by these calls get recycle()d
Each of LazyValues I've made is wrapped in separate ParceledListSlice created by getQueue() and I can call ParceledListSlice Binder to make system_server serialize it and send it to my process
(Alternative way of doing that would be creating multiple MediaSessions)
Now we have everything needed to capture IApplicationThread from attachApplication() when it happens, but we still need to make attachApplication() happen
In general there is a few of types of app components other app can interact with, each of which requires app process to be started
I wanted to start system Settings app (which runs under system uid and therefore has access to everything behind Android permissions)
Initially I've attempted to launch it through startActivity(), however when I've tried that process wasn't started until I've released ActivityTaskManagerService lock. Details on why that was case are in "Additional note: Binder calls and mutexes reentrancy" section, but as a solution I've decided request system for ContentProvider from that app instead of Activity. This had additional advantage of avoiding interference with my UI
I haven't used official ContentResolver API exposed by SDK, but instead I've used system internal one, as I needed asynchronous API because binding to ContentProvider wouldn't finish until attachApplication() which I'm hanging, although starting another thread could be alternative
(It doesn't matter what this particular ContentProvider offer, only relevant thing is that I can establish connection to it)
So that is how I start process of Settings app. I'm ensuring it isn't already running in first place by using officially available ActivityManager.killBackgroundProcesses() method
Primitives are now described, so now here's how it all works together (this is pretty much transcription of MainActivity.doAllStuff() method from this exploit):
- Enable hidden API access (hidden APIs are not security boundary and there are publicly available workarounds already, although here I've used method based on
Property.of(), which I haven't seen elsewhere) - (Only if we're re-running exploit after first attempt) Release connection to
ContentProviderwe established to in step 6 during previous execution. We have to do it as otherwiseActivityManager.killBackgroundProcesses()won't consider target process to be "background" and won't kill it - Kill victim application process using
ActivityManager.killBackgroundProcesses(), asattachApplication()is called only at process startup - Request
system_serverto create bunch of objects containingLazyValuepointing toParcelthat is later recycled. I getParceledListSliceBinderreference for each object containingLazyValueand I can makeBindertransaction to it to trigger system to write it back. Each ofLazyValueobject creation is done at different depths of mutually-recursive calls betweensystem_serverand my app to make it likely that each of theseLazyValues will have dangling reference to differentParcelobject - I lock up
ActivityTaskManagerService.mGlobalLockby making call toActivityTaskManagerService.moveTaskToFront()passing in argumentBundlethat upon deserialization performs synchronousBindertransaction to my process. Next steps are done from that callback and therefore are done with that lock held - I request
ActivityManagerServicefor connection withContentProviderof victim app (note no "Task" in name,ActivityTaskManagerServiceis class focused mostly on handlingActivitycomponents of apps, whileActivityManagerServicehandles other app components (as well as overall process startup), this split happened in Android 10, previously both handling ofActivityand other app components was inActivityManagerService) - I
sleep()a little bit to give newly launched process time to start callingattachApplication() - While lock is still held, I request all previously created
ParceledListSliceobjects to send their remaining contents (that didn't fit in initial transaction), that is objects containingLazyValuepointing to recycledParcel. Then from hardcoded offset matching position ofIApplicationThreadpassed toattachApplication()I readBinderobject. Right now I'm only saving receivedBinders inArrayListto avoid doing too much with lock held - This is end of code that I do from callback started in step 5.
ActivityTaskManagerService.mGlobalLockbecomes unlocked - I've got
IApplicationThreadBinder. Now I can simply use it to load my code into victim app as described in next section
As noted earlier IApplicationThread Binder, is sent by app to system_server when app process starts and then system_server uses it to tell application which components it should load
It is assumed that this object is only passed to system_server and therefore there are no Binder.getCallingUid()-based checks there, so we can just directly call methods offered by that interface
I've described in my previous writeup on how I get code execution by manipulating scheduleReceiver() arguments. Now situation is same except this time I'm calling scheduleReceiver() myself while then I was tampering with interpretation of arguments of call made by system_server
In these section I'm describing few things that in the end didn't turn out to be useful in this case, although they may be features worth being aware of or are potential bugs
For simplicity, I've here described updated Bundle without one commit that was later introduced, that allows Parcel used in Bundle for backing LazyValues to be recycled by calling Bundle.clear()
As noted in commit message, it is tracked if Bundle is copied and in that case clear() won't recycle the Parcel
However that commit also changes semantics of recycleParcel parameter/variable of BaseBundle.initializeFromParcelLocked()
Previously recycleParcel being false indicated that Parcel shouldn't be recycled, either because caller set recycleParcel to false to indicate that Parcel isn't owned by Bundle or it was set to false based on result of parcelledData.readArrayMap()
Now reasons recycleParcel could be false are same, however interpretation of that changed, now that doesn't mean "don't recycle this Parcel", it means "defer recycling of Parcel until Bundle.clear() call"
This means that if clear() would be called on Bundle created with Parcel.hasReadWriteHelper() being true this would led to Parcel being recycled, while code invoking creation of that Bundle would also recycle that Parcel, leading to double-recycle(), which leads to similar behavior as double-free: next calls to Parcel.obtain() would return same object twice
However, I haven't found way to have clear() called on such Bundle
Since I've originally written this, behavior of recycle() was changed and now additional recycle is no-op with possible crash through Log.wtf() (depending on configuration, but never crashing system_server). I'd say that new behavior still might be dangerous, especially when we have ability to programmatically stall deserialization happening in other process, but there really isn't good way to handle double-recycle
A not very well known feature of Binder is that it supports dispatching recursive calls to original thread
That is if process A makes synchronous Binder call to process B and then process B while handling it on same thread makes synchronous Binder call to process A, that call in process A will be dispatched in same thread that is waiting for original call to process B to complete
Other thing is that synchronized () {} sections in Java are reentrant mutexes, which means that if you enter it twice from same thread, it will let you in and won't deadlock
This means that in theory while we're keeping ActivityTaskManagerService.mGlobalLock locked, we still could start Settings app using startActivity(new Intent(Settings.ACTION_SETTINGS)) and we'd successfully enter synchonized block that we're stalling, however starting that Activity also involves Task creation, which involves calling notifyTaskCreated(), which posts message to DisplayThread, and handling of that attempts acquiring lock we're stalling from another thread. So until release ActivityTaskManagerService.mGlobalLock, DisplayThread thread will remain blocked. Later, procedure of starting Activity involves posting message to same thread in order to start app process. All of that means that in this case app process won't be started until we release lock and the reason we were holding that lock in first place was to keep attachApplication() transaction from finishing so we could grab handles from it, but in this case that transaction wouldn't actually start
Even if we launch Activity that will be part of same Task as current one (that is, we'd launch different Activity from Settings app, one that doesn't specify android:launchMode="singleTask"), that procedure will still involve notifyTaskDescriptionChanged(), which has same impact here as notifyTaskCreated()
So while my thread could call into methods which are using synchronized (ActivityTaskManagerService.mGlobalLock) {}, starting new app process after startActivity() involved use of that lock from different thread and that wasn't useful in this case, so I've opted to trigger start of app process through ContentProvider instead
IApplicationThread is very privileged handle, so I consider making use of it after obtaining it a post-exploitation
In this exploit I've used it directly to request code execution in target process, taking advantage of fact that access to that operation is gated by capability (possession of Binder object, which we here leaked) and not by Binder.getCallingUid()
Adding Binder.getCallingUid() check in ApplicationThread.scheduleReceiver() (which we used here to request code execution) and other methods of ApplicationThread (as scheduleReceiver() isn't only method in IApplicationThread allowing code loading) still wouldn't prevent using IApplicationThread to load code into process of other app, as attacker could pass leaked IApplicationThread in place of own one to attachApplication()
Besides loading code into process, having IApplicationThread allows performing grantUriPermission() using privileges of process to which that handle belongs to