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 bugs
Introduction to 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 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
s
Enter 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 ofBundle
class, for example newgetParcelable()
with type argument (Flow will be same for both new and old methods, just new methods ensure thatclazz
argument isn’tnull
while legacy ones set it tonull
) unparcel()
is called, which will check if thisBundle
hasmParcelledData
(meaning it was read fromParcel
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 tounparcel(boolean itemwise)
, which callsinitializeFromParcelLocked(source, /*recycleParcel=*/ true, mParcelledByNative);
,source
is set tomParcelledData
, a copy ofParcel
thatBundle
has made andrecycleParcel
parameter is set totrue
to indicate that passedParcel
is owned byBundle
and it is okay to callParcel.recycle()
on itinitializeFromParcel
callsrecycleParcel &= parcelledData.readArrayMap(map, count, !parcelledByNative, /* lazy */ true, mClassLoader)
in order to read key-value map contents. Keys areString
s and values are read usingreadLazyValue()
, creatingLazyValue
objects for values of types which are written along length prefix.readArrayMap()
returns value indicating if it is okay to recycleParcel
. If there were anyLazyValue
objects presentrecycleParcel
is set tofalse
andParcel
to whichLazyValue
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)- Once
unparcel()
is done,mMap
is set (notnull
) and mapsString
keys to either actual values if they are ready orLazyValue
objects - After that,
getValue()
is called, which maps key (String
) to index (int
) and passes it togetValueAt()
getValueAt()
detectsLazyValue
throughinstanceof BiFunction
and callsapply()
to deserialize itLazyValue.apply()
rewindsParcel
to position ofLazyValue.mPosition
and calls normalParcel.readValue()
about which I’ve already said- Upon successful deserialization
LazyValue
is replaced inmMap
, so that nextBundle.get*()
call for same key will directly return value andLazyValue
deserialization won’t be repeated. WhenBundle
is forwarded that value will be serialized again instead of having original data copied verbatim (however after forwardedBundle
is read, that value will beLazyValue
again and any possiblewriteToParcel
/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 byParcel.writeValue()
and write is delegated toLazyValue.writeToParcel()
LazyValue.writeToParcel()
usesout.appendFrom(source, mPosition, mLength)
to copy wholeLazyValue
data from originalParcel
(again,mPosition
andmLength
includeLazyValue
header, so this also copiestype
andlength
from originalParcel
)
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:
- 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 returnedtrue
it means that object was already written to thisParcel
and 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)maybeWriteSquashed
writes zero as offset to indicate that object isn’t squashed and returnsfalse
to indicate to caller that they should write actual object data - When reading,
Parcel.readSquashed
is called and actual read function is passed to it as lambda.readSquashed
checks 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
Parcel.recycle()
Use-after-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 thatBundle
won’t recycleParcel
as it still belongs to caller, however this does createLazyValue
s which refer to originalParcel
and could outlive originalParcel
s lifetime- Therefore, next thing after that is call to
unparcel(/* itemwise */ true)
, which will usegetValueAt()
on all items to replace allLazyValue
s present inBundle
with actual values
Now, 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 them
Putting 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_server
side that methods constructs and returnsParcelableListBinder
object - 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
1
written and actual item is written throughParcel.writeParcelable()
(which writes name of class being sent and then callsParcelable.writeToParcel
to send data) - If we’ve approached limit of
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 transaction
- Once
ParcelableListBinder
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 toMediaSessionRecord.mQueue
Retrieving “queue” on the other hand, goes bit differently:
MediaController.getQueue()
just callsISessionController.getQueue()
and unwraps receivedParceledListSlice
- On
system_server
side,getQueue()
just wrapsmQueue
intoParceledListSlice
and returns it - Whole split-across-multiple-transactions logic is inside
ParceledListSlice.writeToParcel()
andcreateFromParcel()
methods, in particular,writeToParcel()
upon reaching safe size limit will writeBinder
object that allows retrieving next chunks - When
ParceledListSlice
is read fromParcel
, it reads first part directly fromParcel
and then if not all elements were written inline, it callsBinder
that was written toParcel
in 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 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
Choosing leak target
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 thatParcel
are cleared. This means thatParcel
from which we would like to have data copied from must not berecycle()
d, which approximately means that we can’t take data fromBinder
transaction that has finished - Alternatively, we could take data from some
Parcel
of someBundle
present in system (this includesIntent
extras andsavedInstanceState
ofActivity
). 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 newParcel
objects. Of courseParcel
s to which we’re holding reference won’t be GCed, even if system has no other use for them) Parcel
s used for incomingBinder
transactions use separate pool than otherParcel
s in system. When an outgoingBinder
transaction is being made,Bundle
copies data to secondaryParcel
, or an app usesParcel
for their own purposes, they callParcel.obtain()
, which usesParcel.sOwnedPool
. On the other hand, when there’s an incomingBinder
transaction, system callsParcel.obtain(long obj)
, which usesParcel.sHolderPool
. In both casesParcel.recycle()
is used afterwards and takes care of returningParcel
object into appropriate pool. This means exploit must haveRemoteViews
read fromParcel
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 bothmakeOwnedLeaker
andmakeHolderLeaker
methods in myValueLeakerMaker
class
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
Parcel
Rewinding the 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):
- Item presence flag to start
readParcelable
- Name of
Parcelable
:"android.view.RemoteViews"
- Quite large
ApplicationInfo
object present inRemoteViews
(also it must not benull
and have non-null
packageName
orRemoteViews.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 commonBaseReflectionAction
parameters 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 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:
- Bundle key, read using
Parcel.readString()
, can be pretty much anything, including pointing at invalid length (negative or exceeding totalParcel
size), in that casereadString()
would returnnull
which 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 inRemoteViews
ismode
which we must set toMODE_NORMAL
to reach our code - Not after that, because that’s past point where
IApplicationThread
Binder
is
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.
int
s orString
s 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
IApplicationThread
stays 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
int
s 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 throughRemoteViews
and starts readingBundle
withParcel.hasReadWriteHelper
beingtrue
- That
Bundle
declares to have two key-value pairs. In first value we haveLazyValue
with negative length, that triggersParcel.setDataPosition()
to position where"android.os.Message"
String
is - Reading proceeds to second key-value pair, the key is
"android.os.Message"
andLazyValue
type, length and data are taken fromint
s described in third bullet. I’ve gotLazyValue
withmPosition
andmLength
I wanted. Hooray! - After
LazyValue
s 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 andLazyValue
just stays inBundle
readParcelable()
finishes, but that isn’t end ofMessage
data.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.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 ofLazyValue
data 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 wrappedBinder
object. Reading of thatBinder
fails, becauseBinder
is special object inParcel
and it must be annotated out-of-band to be read. This problem is detected and logged byParcel
on native side, however this isn’t propagated as error andnull
is simply returned
attachApplication()
Stalling 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 accessed- There is
ParceledListSlice
class which during deserialization will make blocking outgoingBinder
call 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
LazyValue
s pointing to different Parcel
s
Creating multiple 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 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:
- I create
LazyValue
- I trigger call to
system_server
,system_server
calls me back- I create
LazyValue
- I trigger call to
system_server
,system_server
calls me back- I create
LazyValue
- I trigger call to
system_server
,system_server
calls me back- …
- I create
- I create
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)
Starting the target app process
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
Putting it all together
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
ContentProvider
we 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_server
to create bunch of objects containingLazyValue
pointing toParcel
that is later recycled. I getParceledListSlice
Binder
reference for each object containingLazyValue
and I can makeBinder
transaction to it to trigger system to write it back. Each ofLazyValue
object creation is done at different depths of mutually-recursive calls betweensystem_server
and my app to make it likely that each of theseLazyValue
s will have dangling reference to differentParcel
object - I lock up
ActivityTaskManagerService.mGlobalLock
by making call toActivityTaskManagerService.moveTaskToFront()
passing in argumentBundle
that upon deserialization performs synchronousBinder
transaction to my process. Next steps are done from that callback and therefore are done with that lock held - I request
ActivityManagerService
for connection withContentProvider
of victim app (note no “Task
” in name,ActivityTaskManagerService
is class focused mostly on handlingActivity
components of apps, whileActivityManagerService
handles other app components (as well as overall process startup), this split happened in Android 10, previously both handling ofActivity
and 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
ParceledListSlice
objects to send their remaining contents (that didn’t fit in initial transaction), that is objects containingLazyValue
pointing to recycledParcel
. Then from hardcoded offset matching position ofIApplicationThread
passed toattachApplication()
I readBinder
object. Right now I’m only saving receivedBinder
s inArrayList
to avoid doing too much with lock held - This is end of code that I do from callback started in step 5.
ActivityTaskManagerService.mGlobalLock
becomes unlocked - I’ve got
IApplicationThread
Binder
. Now I can simply use it to load my code into victim app as described in next section
IApplicationThread
How do I use 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
Additional notes
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()
Additional note: 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 reentrancy
Additional note: 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
Additional note: Other ways to use 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
转载请注明:CVE-2022-20452 的漏洞利用代码。可通过 LazyValue 将已安装的恶意 APP 提权至系统 APP | CTF导航