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)
Parcel
and Parcelable
mismatch bugsAndroid'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()
](https://developer.android.com/reference/android/os/Parcel#dataPosition())/[`setDataPosition()`](https://developer.android.com/reference/android/os/Parcel#setDataPosition(int)). 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 Parcelable
s 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 Binder
s. Binder
s 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
LazyValue
, the end of self-changing Bundle
sSince 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
:
get*()
methods of Bundle
class, for example new getParcelable()
with type argument (Flow will be same for both new and old methods, just new methods ensure that clazz
argument isn't null
while legacy ones set it to null
)unparcel()
is called, which will check if this Bundle
has mParcelledData
(meaning it was read from Parcel
but no value was accessed yet and key names were not unpacked yet, if that isn't the case skip to step 5.)unparcel()
delegates to unparcel(boolean itemwise)
, which calls initializeFromParcelLocked(source, /*recycleParcel=*/ true, mParcelledByNative);
, source
is set to mParcelledData
, a copy of Parcel
that Bundle
has made and recycleParcel
parameter is set to true
to indicate that passed Parcel
is owned by Bundle
and it is okay to call [Parcel.recycle()
](https://developer.android.com/reference/android/os/Parcel#recycle()) on itinitializeFromParcel
calls recycleParcel &= parcelledData.readArrayMap(map, count, !parcelledByNative, /* lazy */ true, mClassLoader)
in order to read key-value map contents. Keys are String
s and values are read using readLazyValue()
, creating LazyValue
objects for values of types which are written along length prefix. readArrayMap()
returns value indicating if it is okay to recycle Parcel
. If there were any LazyValue
objects present recycleParcel
is set to false
and Parcel
to which LazyValue
s 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)unparcel()
is done, mMap
is set (not null
) and maps String
keys to either actual values if they are ready or LazyValue
objectsgetValue()
is called, which maps key (String
) to index (int
) and passes it to getValueAt()
getValueAt()
detects LazyValue
through instanceof BiFunction
and calls apply()
to deserialize itLazyValue.apply()
rewinds Parcel
to position of LazyValue.mPosition
and calls normal Parcel.readValue()
about which I've already saidLazyValue
is replaced in mMap
, so that next Bundle.get*()
call for same key will directly return value and LazyValue
deserialization won't be repeated. When Bundle
is forwarded that value will be serialized again instead of having original data copied verbatim (however after forwarded Bundle
is read, that value will be LazyValue
again and any possible writeToParcel
/createFromParcel
mismatches 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)):
LazyValue
is detected by Parcel.writeValue()
and write is delegated to LazyValue.writeToParcel()
LazyValue.writeToParcel()
uses out.appendFrom(source, mPosition, mLength)
to copy whole LazyValue
data from original Parcel
(again, mPosition
and mLength
include LazyValue
header, so this also copies type
and length
from original Parcel
)Parcel.ReadWriteHelper
and Parcel.readSquashed
(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 String
s in pool; that however is never done with app-controlled Parcel
s
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:
Parcel.allowSquashing()
Parcel.maybeWriteSquashed(this)
. If that method returned true
it means that object was already written to this Parcel
and now only offset to previous object data was written to Parcel
. Otherwise (either squashing is not enabled or this is first time this object is written) maybeWriteSquashed
writes zero as offset to indicate that object isn't squashed and returns false
to indicate to caller that they should write actual object dataParcel.readSquashed
is called and actual read function is passed to it as lambda. readSquashed
checks if offset written by maybeWriteSquashed()
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 nowParcel.recycle()
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 that Bundle
won't recycle Parcel
as it still belongs to caller, however this does create LazyValue
s which refer to original Parcel
and could outlive original Parcel
s lifetimeunparcel(/* itemwise */ true)
, which will use getValueAt()
on all items to replace all LazyValue
s present in Bundle
with actual valuesNow, can we make these LazyValue
s 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 Bundle
s 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
Parcelable
s in system_server
and retrieving themSo 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](https://developer.android.com/reference/android/media/session/MediaController#getQueue()) of MediaController
(which can be retrieved through [MediaSession.getController()
](https://developer.android.com/reference/android/media/session/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 calls ISession.getBinderForSetQueue()
system_server
side that methods constructs and returns ParcelableListBinder
objectMediaSession.setQueue()
calls ParcelableListBinder.send()
which will send list contents into provided Binder
, possibly over multiple transactions:
1
written and actual item is written through Parcel.writeParcelable()
(which writes name of class being sent and then calls Parcelable.writeToParcel
to send data)Binder
transaction size, 0
is written to indicate that there are no more items in this transaction and next items will be sent in another transactionParcelableListBinder
has received number of elements that was specified in first transaction, it invokes lambda passed to its constructor, which in this case assigns retrieved list to MediaSessionRecord.mQueue
Retrieving "queue" on the other hand, goes bit differently:
MediaController.getQueue()
just calls ISessionController.getQueue()
and unwraps received ParceledListSlice
system_server
side, getQueue()
just wraps mQueue
into ParceledListSlice
and returns itParceledListSlice.writeToParcel()
and createFromParcel()
methods, in particular, writeToParcel()
upon reaching safe size limit will write Binder
object that allows retrieving next chunksParceledListSlice
is read from Parcel
, it reads first part directly from Parcel
and then if not all elements were written inline, it calls Binder
that was written to Parcel
in order to retrieve these itemsAs 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 ParceledListSlice
s. 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:
Parcel.recycle()
is called, contents of that Parcel
are cleared. This means that Parcel
from which we would like to have data copied from must not be recycle()
d, which approximately means that we can't take data from Binder
transaction that has finishedParcel
of some Bundle
present in system (this includes Intent
extras and savedInstanceState
of Activity
). These are usually not recycle()
d at all (they are cleaned by Garbage Collector and don't return to pool, when pool gets depleted Parcel.obtain()
creates new Parcel
objects. Of course Parcel
s to which we're holding reference won't be GCed, even if system has no other use for them)Parcel
s used for incoming Binder
transactions use separate pool than other Parcel
s in system. When an outgoing Binder
transaction is being made, Bundle
copies data to secondary Parcel
, or an app uses Parcel
for their own purposes, they call Parcel.obtain()
, which uses Parcel.sOwnedPool
. On the other hand, when there's an incoming Binder
transaction, system calls Parcel.obtain(long obj)
, which uses Parcel.sHolderPool
. In both cases Parcel.recycle()
is used afterwards and takes care of returning Parcel
object into appropriate pool. This means exploit must have RemoteViews
read from Parcel
belonging 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 both makeOwnedLeaker
and makeHolderLeaker
methods in my ValueLeakerMaker
classIn 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
Parcel
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 int
s 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):
readParcelable
Parcelable
: "android.view.RemoteViews"
ApplicationInfo
object present in RemoteViews
(also it must not be null
and have non-null
packageName
or RemoteViews.writeToParcel()
will fail when we'll try to get this object to be sent back)RemoteViews.readActionsFromParcel()
, which calls getActionFromParcel()
, which constructs ReflectionAction
, which after reading common BaseReflectionAction
parameters finally constructs Bundle
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 LazyValue
s 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:
Parcel.readString()
, can be pretty much anything, including pointing at invalid length (negative or exceeding total Parcel
size), in that case readString()
would return null
which is valid key in Bundle
isLengthPrefixed()
returns true
Parcel.appendFrom()
will fail if length is not aligned or exceeds total size of source Parcel
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:
Parcelable
("android.view.RemoteViews"
), because there's not enough spaceParcelable
, because we're unable to set type and lengthParcelable
, because first thing in RemoteViews
is mode
which we must set to MODE_NORMAL
to reach our codeIApplicationThread
Binder
isHmm, there isn't any good place when RemoteViews
is outermost object in parcelled data
We need to find some other Parcelable
that:
int
s or String
s that are just data and don't affect serialization process)RemoteViews
(either directly or via arbitrary readParcelable
)IApplicationThread
stays in target Parcel
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:
readParcelable
Parcelable
: "android.os.Message"
int
s that we can set to whatever values we want are read into fieldsreadParcelable()
call, which goes all the way we described above through RemoteViews
and starts reading Bundle
with Parcel.hasReadWriteHelper
being true
Bundle
declares to have two key-value pairs. In first value we have LazyValue
with negative length, that triggers Parcel.setDataPosition()
to position where "android.os.Message"
String
is"android.os.Message"
and LazyValue
type, length and data are taken from int
s described in third bullet. I've got LazyValue
with mPosition
and mLength
I wanted. Hooray!LazyValue
s are read they are unparcelled. The one with negative size gets successfully unparcelled and replaced with empty Map
, while the other fails deserialization, but that exception is caught and LazyValue
just stays in Bundle
readParcelable()
finishes, but that isn't end of Message
data. Message.readFromParcel()
is now continuing reading data after rewind and sees data which were initially written as part of RemoteViews
. If anything throws exception at this point whole plan gets foiledreadBundle()
call. Bundle
has 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 of LazyValue
data was set to value I've needed to grab IApplicationThread
. So I just got lucky hereMessenger.readMessengerOrNullFromParcel()
call. This is actually wrapped Binder
object. Reading of that Binder
fails, because Binder
is special object in Parcel
and it must be annotated out-of-band to be read. This problem is detected and logged by Parcel
on native side, however this isn't propagated as error and null
is simply returnedattachApplication()
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:
Bundle
performs deserialization of values in it when these values are accessedParceledListSlice
class which during deserialization will make blocking outgoing Binder
call to object specified inside serialized dataAdding 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
LazyValue
s pointing to different Parcel
sParcel.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 LazyValue
s pointing to various Parcel
s
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:
LazyValue
system_server
, system_server
calls me back
LazyValue
system_server
, system_server
calls me back
LazyValue
system_server
, system_server
calls me back
Then once I've got enough LazyValue
s I finish doing that, return from all of these calls and all Parcel
s which were reserved by these calls get recycle()
d
Each of LazyValue
s 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 MediaSession
s)
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):
Property.of()
, which I haven't seen elsewhere)ContentProvider
we established to in step 6 during previous execution. We have to do it as otherwise ActivityManager.killBackgroundProcesses()
won't consider target process to be "background" and won't kill itActivityManager.killBackgroundProcesses()
, as attachApplication()
is called only at process startupsystem_server
to create bunch of objects containing LazyValue
pointing to Parcel
that is later recycled. I get ParceledListSlice
Binder
reference for each object containing LazyValue
and I can make Binder
transaction to it to trigger system to write it back. Each of LazyValue
object creation is done at different depths of mutually-recursive calls between system_server
and my app to make it likely that each of these LazyValue
s will have dangling reference to different Parcel
objectActivityTaskManagerService.mGlobalLock
by making call to ActivityTaskManagerService.moveTaskToFront()
passing in argument Bundle
that upon deserialization performs synchronous Binder
transaction to my process. Next steps are done from that callback and therefore are done with that lock heldActivityManagerService
for connection with ContentProvider
of victim app (note no "Task
" in name, ActivityTaskManagerService
is class focused mostly on handling Activity
components of apps, while ActivityManagerService
handles other app components (as well as overall process startup), this split happened in Android 10, previously both handling of Activity
and other app components was in ActivityManagerService
)sleep()
a little bit to give newly launched process time to start calling attachApplication()
ParceledListSlice
objects to send their remaining contents (that didn't fit in initial transaction), that is objects containing LazyValue
pointing to recycled Parcel
. Then from hardcoded offset matching position of IApplicationThread
passed to attachApplication()
I read Binder
object. Right now I'm only saving received Binder
s in ArrayList
to avoid doing too much with lock heldActivityTaskManagerService.mGlobalLock
becomes unlockedIApplicationThread
Binder
. Now I can simply use it to load my code into victim app as described in next sectionIApplicationThread
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
Bundle.clear()
For simplicity, I've here described updated Bundle
without one commit that was later introduced, that allows Parcel
used in Bundle
for backing LazyValue
s 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
Binder
calls and mutexes reentrancyA 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
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