Skip to content

Exploit for CVE-2022-20452, privilege escalation on Android from installed app to system app (or another app) via LazyValue using Parcel after recycle()

Notifications You must be signed in to change notification settings

michalbednarski/LeakValue

Repository files navigation

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:

Screenshot of application displaying text. Title: LeakValue. Main text: Created 6 ValueLeaker-s. Locking ActivityTaskManagerService. Locked ActivityTaskManagerService. Unlocking ActivityTaskManagerService. Unlocked ActivityTaskManagerService. leakedBinders=[android.os.BinderProxy@f06702e]. Leaked interface: android.app.IApplicationThread. Requesting code execution. Shellcode has been executed in uid=1000 pid=6904 packageName=com.android.settings uid=1000(system) gid=1000(system) groups=1000(system),1007(log),1065(reserved_disk),1077(external_storage),3001(net_bt_admin),3002(net_bt),3003(inet),3007(net_bw_acct),9997(everybody) context=u:r:system_app:s0. At bottom of screen there are two buttons: START and MANUAL TESTING

(Also logcat from app execution, exploitation is noisy in logs)

Introduction to Parcel and Parcelable mismatch bugs

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

Enter LazyValue, the end of self-changing Bundles

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:

  1. Caller uses one of many 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)
  2. 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.)
  3. 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() on it
  4. initializeFromParcel calls recycleParcel &= parcelledData.readArrayMap(map, count, !parcelledByNative, /* lazy */ true, mClassLoader) in order to read key-value map contents. Keys are Strings 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 LazyValues 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)
  5. Once unparcel() is done, mMap is set (not null) and maps String keys to either actual values if they are ready or LazyValue objects
  6. After that, getValue() is called, which maps key (String) to index (int) and passes it to getValueAt()
  7. getValueAt() detects LazyValue through instanceof BiFunction and calls apply() to deserialize it
  8. LazyValue.apply() rewinds Parcel to position of LazyValue.mPosition and calls normal Parcel.readValue() about which I've already said
  9. Upon successful deserialization LazyValue 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)):

  1. LazyValue is detected by Parcel.writeValue() and write is delegated to LazyValue.writeToParcel()
  2. 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 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:

  1. First, squashing has to be enabled with Parcel.allowSquashing()
  2. Then, when class supporting squashing is being written, it first calls 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 data
  3. When reading, Parcel.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 now

Use-after-Parcel.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:

  1. 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 LazyValues which refer to original Parcel and could outlive original Parcels lifetime
  2. Therefore, next thing after that is call to unparcel(/* itemwise */ true), which will use getValueAt() on all items to replace all LazyValues present in Bundle with 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

Putting Parcelables in system_server and retrieving them

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:

  1. MediaSession.setQueue() first calls ISession.getBinderForSetQueue()
  2. On system_server side that methods constructs and returns ParcelableListBinder object
  3. After that, MediaSession.setQueue() calls ParcelableListBinder.send() which will send list contents into provided Binder, possibly over multiple transactions:
  4. 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 to MediaSessionRecord.mQueue

Retrieving "queue" on the other hand, goes bit differently:

  1. MediaController.getQueue() just calls ISessionController.getQueue() and unwraps received ParceledListSlice
  2. On system_server side, getQueue() just wraps mQueue into ParceledListSlice and returns it
  3. Whole split-across-multiple-transactions logic is inside ParceledListSlice.writeToParcel() and createFromParcel() methods, in particular, writeToParcel() upon reaching safe size limit will write Binder object that allows retrieving next chunks
  4. When ParceledListSlice 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 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

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 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 finished
  • Alternatively, we could take data from some Parcel 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 Parcels to which we're holding reference won't be GCed, even if system has no other use for them)
  • Parcels used for incoming Binder transactions use separate pool than other Parcels 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 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

Rewinding the 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 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):

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:

  1. Bundle key, read using 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
  2. Value type, this must be one of types for which isLengthPrefixed() returns true
  3. Value length, this also shall be value controlled by us, 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:

  • 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 in RemoteViews is mode which we must set to MODE_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:

  1. Has at or near beginning place where we can put arbitrary data (e.g. ints or Strings that are just data and don't affect serialization process)
  2. Can contain RemoteViews (either directly or via arbitrary readParcelable)
  3. Has not too long fully qualified class name, because we're still size limited by position at which 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:

  • 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 through RemoteViews and starts reading Bundle with Parcel.hasReadWriteHelper being true
  • That 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
  • Reading proceeds to second key-value pair, the key is "android.os.Message" and LazyValue type, length and data are taken from ints described in third bullet. I've got LazyValue with mPosition and mLength I wanted. Hooray!
  • After LazyValues 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 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 of LazyValue data was set to value I've needed to grab IApplicationThread. So I just got lucky here
  • Next possible problem could be Messenger.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 returned

Stalling attachApplication()

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 outgoing Binder 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

Creating multiple LazyValues pointing to different Parcels

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_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
        • ...

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)

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):

  1. 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)
  2. (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 otherwise ActivityManager.killBackgroundProcesses() won't consider target process to be "background" and won't kill it
  3. Kill victim application process using ActivityManager.killBackgroundProcesses(), as attachApplication() is called only at process startup
  4. Request system_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 LazyValues will have dangling reference to different Parcel object
  5. I lock up ActivityTaskManagerService.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 held
  6. I request ActivityManagerService 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)
  7. I sleep() a little bit to give newly launched process time to start calling attachApplication()
  8. 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 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 Binders in ArrayList to avoid doing too much with lock held
  9. This is end of code that I do from callback started in step 5. ActivityTaskManagerService.mGlobalLock becomes unlocked
  10. I've got IApplicationThread Binder. Now I can simply use it to load my code into victim app as described in next section

How do I use IApplicationThread

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

Additional note: 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 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

Additional note: Binder calls and mutexes reentrancy

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

Additional note: Other ways to use 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

About

Exploit for CVE-2022-20452, privilege escalation on Android from installed app to system app (or another app) via LazyValue using Parcel after recycle()

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages