
Writeup и эксплойт для CVE-2023-45777, обход проверки Intent внутри AccountManagerService на Android 13, несмотря на смягчение "Lazy Bundle"
Начнём на этот раз с патча, который появился как исправление для CVE-2023-45777 в Android Security Bulletin:```diff diff --git a/services/core/java/com/android/server/accounts/AccountManagerService.java b/services/core/java/com/android/server/accounts/AccountManagerService.java index 7a19d034c2c8..5238595fe2a2 100644 --- a/services/core/java/com/android/server/accounts/AccountManagerService.java +++ b/services/core/java/com/android/server/accounts/AccountManagerService.java @@ -4923,7 +4923,7 @@ public class AccountManagerService p.setDataPosition(0); Bundle simulateBundle = p.readBundle(); p.recycle();
Intent intent = bundle.getParcelable(AccountManager.KEY_INTENT);
Intent intent = bundle.getParcelable(AccountManager.KEY_INTENT, Intent.class);
if (intent != null && intent.getClass() != Intent.class) {
return false;
}
Few people were puzzled by it enough to ask me, previously I've replied to them with some hints and now I'm publishing full writeup for this issue
But first lets provide some context about what is going on in this patch
This is change in [`checkKeyIntent()` method](https://cs.android.com/android/platform/superproject/main/+/main:frameworks/base/services/core/java/com/android/server/accounts/AccountManagerService.java;l=4938-4954;drc=47de64a38aa1799cb41f41b2ea0c539ee61de64d). This method performs multiple checks to ensure that `Intent` provided by application is safe for system to launch (using privileges of system)
First, this method uses `checkKeyIntentParceledCorrectly()` which serializes and deserializes again `Bundle` which we're checking and checks if `Intent` taken from `Bundle` before that matches `Intent` from `Bundle` after such cycle. Since launch of `Intent` happens in other system app processes than one which performs validation, it was previously [possible to construct `Bundle`-s which appeared safe during validation inside `AccountManagerService`, but contained different `Intent` after being sent to next process](https://github.com/michalbednarski/IntentsLab/issues/2#issuecomment-344365482). This simulates sending `Bundle` to next process in order to detect such situations.
After `checkKeyIntentParceledCorrectly()` we have `bundle.getParcelable()` call, which this patch switches from deprecated version that could construct any object to one that validates that object that is about to be deserialized is of type which was specified in second parameter
That version with type parameter was introduced in Android 13, as part of larger `Parcel`/`Bundle` hardening. In particular, before Android 13 when `Bundle` was sent between processes, it kept raw copy of whole serialized data until any item was accessed, at which point every value was deserialized. Now when any value is accessed for first time after `Bundle` has been received, only `String` keys and the values of primitive types are deserialized, while non-primitive values are left as `LazyValue`-s, which have their length stored as part of serialized data in order to ensure that even when serialization/deserialization logic is mismatched, such mismatches won't affect other entries
Before we dive in, lets have a look at `LazyValue`: In it's source code [we've got nice comment explaining it's data structure](https://cs.android.com/android/platform/superproject/+/master:frameworks/base/core/java/android/os/Parcel.java;l=4392-4399;drc=03c34f57c05feecfb090de3917787f049cb5f804)```
| 4B | 4B |
mSource = Parcel{... | type | length | object | ...}
a b c d
length = d - c
mPosition = a
mLength = d - a
mPosition и mLength описывают расположение всех данных LazyValue в исходном Parcel, включая type и length. "length" (без "m" в начале) относится к значению длины, записанному в Parcel, и исключает заголовок (type и length)
Если Bundle, содержащий LazyValue, пересылается в другой процесс, весь LazyValue, включая поля type и length, копируется дословно из Bundle.mParcelledData в целевой Parcel
Когда элемент Bundle, представленный LazyValue, запрашивается, Parcel перематывается к mPosition и вызывается readValue(). Если в bundle.getParcelable() передаётся аргумент типа, он передаётся в readValue(), который как гарантирует, что тип, подлежащий распаковке, является ожидаемым, так и проверяет после распаковки, что тип распакованного значения является ожидаемым. После распаковки LazyValue заменяется, поэтому при следующей записи Bundle в Parcel значение будет сериализовано через writeValue() снова
Использование типизированного параметра Bundle.get*()/Parcel.read*() в основном актуально для таких методов, как Parcel.readParcelableList(), который возвращает ArrayList, и из-за стирания типов в Java, даже если вы сделали что-то вроде List<SomeParcelableType> field = parcel.readParcelableList();, часть <SomeParcelableType> не проверялась во время выполнения, и такой List мог содержать любые классы Parcelable, доступные в системе, и поэтому все createFromParcel/writeToParcel, доступные в системе, могли использоваться как часть сериализации/десериализации типа, содержащего такой List
Вам также может быть интересна презентация команды Android Security and Privacy о внедрении этих механизмов (слайды, видео)
Однако здесь использование типизированной версии выглядит избыточным, поскольку мы также явно проверяем тип возвращаемого объекта. Так что же происходит и какая уязвимость здесь исправляется?
Взгляните ещё раз на патч с самого начала
"intent", является Intent
Intent, у нас была бы гораздо более серьёзная проблемаIntent
Intent внутри Bundle после его отправки в другой процесс, но тип Parcelable сохраняется на более раннем смещении, чем любое возможное несоответствие, а префикс длины LazyValue не позволяет нам изменять следующие пары ключ-значение в случае несоответствия writeToParcel/createFromParcelИтак, что опасного мог бы здесь сделать вызов bundle.getParcelable(AccountManager.KEY_INTENT) без аргумента типа?
[Ответ в следующем абзаце, попробуйте угадать, прежде чем читать дальше. Если бы у меня была фурсона, здесь было бы место для какого-нибудь арта]
Ответ заключается в вызове несвязанного createFromParcel(), который фактически изменяет необработанные данные LazyValue, хранящегося под другим ключом и передаваемого дословно в следующий процесс