Bypassing the "run-as" debuggability check on Android via newline injection
An attacker with ADB access to an Android device can trick the “run-as” tool into believing any app is debuggable. By doing so, they can read and write private data and invoke system APIs as if they were most apps on the system—including many privileged apps, but not ones that run as the system
user. Furthermore, they can achieve persistent code execution as Google Mobile Services (GMS) or as apps that use its SDKs by altering executable code that GMS caches in its data directory.
Google assigned the issue CVE-2024-0044 and fixed it in the March 2024 Android Security Bulletin, which becomes public today. Most device manufacturers received an advance copy of the Bulletin a month ago and have already prepared updates that include its fixes.
Vulnerability details
On Android 12 and 131, a newly-installed app’s “installer package name” is not sanitized when set via pm install
’s -i
flag. Neither pm
nor the underlying PackageInstallerService check that it doesn’t contain special characters, let alone that it references an installed package.
Although special characters in the installer package name are harmlessly escaped when written to /data/system/packages.xml
, they are not escaped when written to /data/system/packages.list
, which replicates certain package metadata in a simple newline- and space-delimited format. By providing a name with newlines and spaces, an attacker with ADB shell access can inject an arbitrary number of fake fields and entries2 into packages.list
.
One user3 of packages.list
is run-as, which lets the ADB shell run code in the context of a given app. run-as is designed to reject non-debuggable apps, but it queries the app’s debuggability—along with its UID, SELinux context, and data directory—from packages.list
. By injecting a fake entry that preserves the latter but alters the former, the attacker can bypass the debuggability check and become nearly any app on the system.
We say “nearly” because run-as does have some extra defense-in-depth checks, the most notable of which is that it won’t assume non-app UIDs (including the system
user, reserved for the most highly-privileged apps) even if packages.list
says it should. It also doesn’t assume the same SELinux context as the real app, since it only considers seapp_contexts
with fromRunAs=true
: this makes no difference for unprivileged apps, since runas_app is strictly more privileged than untrusted_app, but it does prevent the attacker from taking actions gated to priv_app or platform_app—even as an app that normally could.
The issue is compounded by a separate logic bug in run-as that lets it target privapps. Typically, that would be forbidden by the checks in check_data_path()
, which try to ensure that
- every parent of the app’s data directory is owned by
system
, and - the data directory itself is owned by the app’s UID.
Since run-as isn’t allowed to stat()
privapp_data_file, check #2 should fail for a privapp either with a UID mismatch (if the fake app has the wrong data directory) or with a permission denial (if it has the right one). However, the check_directory()
helper that performs each check includes a special case for the path /data/user/0
. Although intended only to allow that path to be a symlink, the special case inadvertently also skips UID validation. So by setting the fake app’s data path to /data/user/0
, the attacker can satisfy run-as’s internal security checks. And once in runas_app, they can read and write privapp_data_file because Android somewhat perplexingly allows any app to do that.
On Android devices with Google Mobile Services (GMS), the attacker can gain persistence within GMS (escalating to gmscore_app in the process) by rewriting the cached ODEX/VDEX files in /data/user_de/0/com.google.android.gms/app_chimera/m/*/oat/
, which contain unsigned executable code that GMS loads. Some of that code is also loaded into apps that use Google APIs, allowing persistence there too. This isn’t a bug per se, but it does highlight the importance of enforcing W|X in privapp data directories.
Exploitation
A basic exploit takes just 4 lines:
# Pretty ugly way to get the package's UID, but I couldn't find a simpler one.
UID=$(pm list packages -U | sed -n "s/^package:$1 uid://p")
# This is the line we inject...
PAYLOAD="@null
victim $UID 1 /data/user/0 default:targetSdkVersion=28 none 0 0 1 @null"
# ...and this is how we inject it.
pm install -i "$PAYLOAD" any-app.apk
Since “installer package name” is the last field in a packages.list entry, all we have to do is provide a legitimate-looking value followed by a newline and any forged entry we want. For this PoC, we gave the forged entry a package name of “victim”, meaning run-as victim
will switch to the UID and SELinux context described by that line. We set the UID dynamically based on the real package we intend to exploit, and all the other fields are set to fixed or dummy values:
- The third field,
1
indicates the package is debuggable. - The fourth,
/data/user/0
, is the data path needed to become privapps as described in the writeup. - The fifth field is used to derive the SELinux domain and is set to a generic value that will work for any app targeting API >= 28.
- The other fields don’t matter to run-as.
Attack scenarios
A local attacker with ADB shell access to an Android 12 or 13 device with Developer Mode enabled can exploit the vulnerability to run code in the context of any non-system
-UID app. From there, the attacker can do anything the app can, like access its private data files or read the credentials it’s stored in AccountManager. This violates the security guarantees of the Application Sandbox, which is supposed to safeguard an app’s data from even the owner of the device.
Non-system
privapps are vulnerable, but for those the attacker does not gain any SELinux permissions beyond what run-as grants for a normal unprivileged app. That means no access to Binder APIs marked only as system_api_service
, for example.
Response
We reported this vulnerability privately to Google on October 24, 2023. Google acknowledged our report immediately, and the Android Security Team rated it as High severity the following week. On December 19th, Google informed us they’d developed a fix and planned to release it with the March Android Security Bulletin, which they acknowledged was past Meta’s default 90-day disclosure period. We offered to move our disclosure to match theirs, as is our policy when a vendor demonstrates a good-faith effort to promptly address an issue.
As planned, this post, our accompanying disclosure, and the March ASB were all released today.
Issue list
For ease of reference, here’s a numbered list of the technical flaws we identified in this report:
- [Bug] It’s possible to inject newlines and spaces into
packages.list
on Android 12 and 13. - [Bug] run-as accepts
/data/user/0
as a data directory for any app. - [Weakness] run-as trusts the data path from
packages.list
whenuserId == 0
, even though it has enough information to construct that path itself (as demonstrated by the userId != 0 case). - [Weakness] untrusted_app is granted broad SELinux permissions on privapp_data_file, even though (as far as we’re aware) there’s no legitimate need for write access.
- [Weakness] Android stores AOT-compiled ODEX/VDEX files alongside the APK they’re for, even when that APK is in an app-writable data directory. It does not apply an alternate SELinux label, such as the already-extant app_exec_data_file, to prevent apps from altering them.
Appendix: disclosure timeline
- July 23rd, 2022: We notice the
packages.list
injection vulnerability as part of unrelated Android research and build a basic run-as PoC, but other planned work prevents us from investigating further. - May 5th, 2023: We return to the issue and discover the exploit can be tweaked to work for privapps too. We begin looking for interesting data files among privileged apps.
- June 5th, 2023: We demonstrate persistent code execution in GMS and in apps that use Google SDKs by modifying cached code GMS’s data directory.
- October 24, 2023: We report our findings to Google, who passes them to the Android Security Team.
- November 3rd, 2023: Google notifies us that they’ve rated the issue High Severity.
- December 12th, 2023: We ask Google why they settled on High severity, as that contravenes their published rubric which says that exploits requiring Developer Mode are Low severity at most. Google responds that attacks “against the device or an app on the device”, as opposed to “against the device user themselves”, are not subject to that restriction.
- December 19th, 2023: Google says they’ve developed a fix for the injection vulnerability but won’t be able to release it until the March 4th, 2024 Android Security bulletin. They ask for an extension of our tentative 90-day disclosure date, which we agree to.
- December 22nd, 2023: We meet briefly with members of the Google VRP and Android Security teams to discuss details of the disclosure plan. Google tells us that the report qualifies for a $7,000 bounty.
- January 16th, 2024: Google officially offers us the bounty, which we ask them on January 26th to donate to charity. (Google, like Meta, doubles bounties paid to charity.)
- February 6th, 2024: We ask Google to confirm the CVE ID of CVE-2024-0044, which we learned from the March ASB partner preview, as they had not yet told us. They confirm it.
- March 4th, 2024: This post, our disclosure, and the March ASB all go live.
Footnotes
-
In Android 14,
PackageInstallerService
ensures the installer package name references an installed package, so the issue is no longer exploitable. However, the check is still fairly high in the call stack and the change that added it seems to have fixed this issue inadvertently rather than intentionally, so we still recommend additional defense in depth. ↩ -
Entries in
packages.list
are deterministically ordered by Java’sString.hashCode()
, so it’s possible to craft a package that appears at the very top whose injected entries a parser will always see before real entries with the same package name. The namecom.hashed.first.WHGCXIP
is one of many that hashes to the lowest possible value andcom.hashed.last.JJEJTOC
is likewise for the highest. Sincerun-as
doesn’t care about package name, we didn’t have to use this trick in our PoC. ↩ -
packages.list
has other clients, like simpleperf_app_runner, butrun-as
is the one for which this issue causes by far the greatest security threat. ↩