Project 3: JavaScript Engine Exploitation
Parts A and B due: Friday, February 20th, 11:59 PM
Part C due: Monday, February 23rd, 11:59 PM
Goal
The goal of this project is to gain hands-on experience in JavaScript runtime exploitation.
You will use a type confusion bug to build generic exploitation primitives, then use those primitives to create a rwx mapping from which to run a second stage payload.
Specifically, you will target the QuickJS JavaScript engine, a simple but featureful interpreter written in C, which we have modified to add a convenient type confusion bug for you.
Background
You have already read saelo’s Phrack article on browser
exploitation, which explains the
use of addrof and fakeobj as generic exploitation primitives.
Saelo’s article uses Safari’s JavaScriptCore engine as an example; likewise 0vercl0k’s blog introduction to SpiderMonkey exploitation uses Firefox’s SpiderMonkey engine as an example.
There are several excellent repositories of JavaScript runtime security resources, including:
- Markus Gaasedelen and Amy “itszn” Burnett’s JavaScript engine exploitation reading list;
- Moritz Eckert’s Browser-Pwn;
- awesome-browser-exploit; and
- the JavaScript engine vulnerability database.
The details of exploitation techniques will likely be engine-specific, but many of the ideas generalize.
The Environment
We will use the class VM you installed in project 1.
In proj3.tar.gz we have supplied a patched version of QuickJS that includes the type confusion bug you will use, along with other scaffolding described below.
Inside the quickjs subdirectory, run make to compile QuickJS and
make qjs-debug to build a debug version.
Now, in the main project directory, quickjs/qjs will get you the
release version and quickjs/qjs-debug will get you the debug
version. Both should run fine under gdb, but the release version will
be optimized in a way that will make inspecting some variables harder.
If you give qjs a script argument, it will run that script and exit
instead of launching the REPL. You can ask it to execute the script
and then provide a REPL by adding the -i or --interactive
command-line flag.
You can run qjs under gdb. You will definitely want to use
0vercl0k’s
trick
for setting a breakpoint in a Math function to trap into the
debugger from the REPL. If you set a breakpoint in cos, then
calling Math.cos(v) on some interesting value will trap into gdb.
Go up one stack frame to js_call_c_function and the value will be
available to inspect in argv[0].
You probably won’t need to modify and rebuild QuickJS, but you will
definitely want to spend time referring to quickjs.h,
quickjs-libc.c, qjs.c, and parts of quickjs.c to understand how
the JavaScript runtime is implemented. If you do make changes while
working on the project, be sure to also test your solutions against
binaries built from unmodified source before submitting.
A note about ASLR
Like other modern exploits, yours in this project will need to work in the presence of ASLR.
Do not hardcode the addresses of QuickJS functions, or the offsets between functions or to PLT or GOT entries in the QuickJS binary. The layout of the text segment is different between the debug and release builds, and we expect your exploit to work on both.
Do not hardcode the address of libc functions. To save you a little
work in Part C, you may assume that the mprotect_mus_offset contains
the offset in libc between the malloc_usable_size and mprotect
functions (see more below), but you should not hardcode the offset
between any other libc functions.
Do not hardcode the address of any object on the QuickJS heap. The
location of individual allocations on the heap may change between
runs. The randheap() function in the preamble files will leave the
heap in a different state each time your exploit is run.
You will probably not need to access the stack; but, if you do, do not hardcode any stack addresses. The location of individual frames on the stack may change between runs, for example because in our testing environment variables may be different than in your testing, and the kernel reserves space for these variables at the top of the stack.
Part A
In Part A, you will use a type confusion bug to create addrof,
fakeobj, and fakestr.
We have introduced a bug in the
ArrayBuffer.prototype.transfer
method. This method is meant to allow transferring the backing store
from one ArrayBuffer to another, optionally also changing that
backing store’s size. In our buggy version (triggered when quickjs
is run with the --parta flag), the function that implements
transfer, js_array_buffer_transfer, will fail to clear the
u.array.count and u.array.u.ptr fields of typed arrays that refer
to now-transferred ArrayBuffer. As noted in class, some typed array
operations use these fields rather than checking the ArrayBuffer. For
example, the following test case should cause a use-after-free when
quickjs is run with --parta:
// Create ArrayBuffer and TypedArray
const ab = new ArrayBuffer(64);
const ta = new Uint8Array(ab);
ta.fill(0x41);
console.log("Before detach: ta[0] =", ta[0]);
// Detach the ArrayBuffer (frees the underlying memory)
ab.transfer();
console.log("After transfer");
// Use-After-Free: accessing freed memory
console.log("After detach: ta[0] =", ta[0]);
Write a JavaScript program that takes advantage of the type confusion
enabled by the transfer bug to define three functions, addrof,
fakeobj, and fakestr. The addrof function should take a single
object or string value and return a number corresponding to the
address where the underlying object or string is stored. The
fakeobj function should take a number and return an object value
with the number argument as the object address. Similarly, the
fakestr function should take a number and return a string value with
the number argument as the string address.
For any object o, we should have fakeobj(addrof(o)) === o; for any
string s, we should have fakestr(addrof(s)) === s, and for any
number i we should have addrof(fakeobj(i)) == i and
addrof(fakestr(i)) == i, though running these latter expressions for
an i that doesn’t correspond to an object or string in memory may
confuse QuickJS’s reference-counting garbage collector and lead to
segfaults.
Some hints:
-
The most convenient type confusion in QuickJS is between tagged values and the data buffer for an
ArrayBuffer. -
You’ll need to do some heap grooming (also called heap feng shui) to turn the use-after-free into useful type confusion. See Sotirov’s classic Black Hat Europe 2007 talk for inspiration, but keep in mind that his target used a different allocator.
-
There is information about glibc malloc internals in the glibc wiki, and you could always consult the source code.
-
You can try multiple times to create an allocation collision. How would you know if you succeeded?
Your addrof function should return a JavaScript number, not a string
or a BigInt. (A number can be represented inline as a JSValue,
whereas a string or bignum requires an additional heap allocation. It
is a good idea to avoid allocation in functions implementing low-level
exploit primitives like addrof.)
In a 64-bit CPU, virtual addresses are 64 bits, whereas JavaScript
numbers are implemented as double-precision floating point and so
cannot exactly represent all 64-bit integer values. But our RISC-V
environment uses only 48 bits of the 64-bit address space, and doubles
can exactly represent all 48-bit integer values. Be aware that
numbers larger than $2^{32}$ need to be handled with care:
JavaScript’s bitwise operations first cast their operands to unsigned
32-bit integers, discarding any information in more-significant bits.
(Also now is as good a time as any to mention that JavaScript has both
right arithmetic shift and right logical shift operators, >> and
>>>, and that (i >>> 0).toString(16) is the idiomatic JavaScript
way to print a number i as an unsigned hexadecimal 32-bit value.)
Testing Part A
We will run your Part A solution as follows:
$ quickjs/qjs --parta --partb -I parta-pre.js -I parta-soln.js parta-tests.js
(and the same with quickjs/qjs-debug). The -I arguments tell
QuickJS to read and evaluate JavaScript files in addition to the main
script (here, parta-tests.js). The Part A preamble, parta-pre.js,
defines and calls a randheap function that will leave the heap in a
different state each time, to make your heap spray more interesting.
You will modify only parta-soln.js. Our autograder will include
additional tests in parta-tests.js.
If you would like to test your solution interactively, you could invoke QuickJS as follows:
$ quickjs/qjs-debug --parta --interactive -I parta-pre.js parta-soln.js
The --interactive flag tells QuickJS to launch the REPL after it
executes the named scripts. While qjs-debug is waiting for REPL
input, you can find its process ID (ps -u user should list it) and
attach to it with GDB (using gdb -p [pid]).
Part B
In Part B, you will use the generic primitives addrof, fakeobj,
and fakestr to build the generic primitives readmem and
writemem.
Write a JavaScript program that uses addrof, fakeobj, and
fakestr as described above (but no additional type confusion) and
defines two functions, readmem and writemem. The readmem
function should take a single number and return the value of the
four-byte word in memory at the address in the argument, as an
unsigned number between 0 and $2^{32}-1$. The writemem function
should take two arguments, an number representing an address and a
number representing a (32-bit, unsigned) value, and write the value in
the second argument to memory at the four-byte word at the address
in the first argument.
The natural way to implement readmem(addr) and writemem(addr, val)
is to have a Uint32Array object ta whose u.array.u.uint32_ptr
field contains addr; then readmem is reading ta[0] and
writemem is setting ta[0] = val.
You could use fakeobj to create a new typed array of this sort for
each readmem and writemem call, but you’ll be calling those a lot
in Part C, so making a fake array each time will get tedious and risks
triggering the garbage collector and causing a crash.
A better strategy is to set up two typed arrays as part of your
script. One typed array, the “data plane,” will be used to read and
write memory, with its u.array.u.uint32_ptr set to the right value
before each access. The other typed array, the “control plane,” will
have its u.array.u.uint32_ptr field point to the data plane object,
so that writes to the right offset in the control plane change the
data plane’s uint32_ptr field. The layout is something like this:
.---------------. .---->.---------------.
| control plane | | | data plane |
| typed array | | | typed array | .---> memory
| | | | | |
| | | | | |
| uint32_ptr o-----' | uint32_ptr o-----'
| | | |
'---------------' '---------------'
The data plane array could start out as a regular Uint32Array; only
the control plane array needs to be faked.
We recommend that your script creates both of these typed arrays once,
at startup, and then your readmem and writemem implementations
reuse the arrays.
There is also the question of how to convincingly fake an object such
as a typed array. In QuickJS, such objects have too many pointers to
other data structures (for example, to the shape object we explored in
class) to be conjured out of nothing. You are much better off
examining a real typed array as created by the QuickJS runtime and
creating a clone of it with fields changed as necessary. But how will
you examine a typed array if you need to fake a typed array to create
readmem?
One answer is to use strings. QuickJS’s JSString data structure is
much simpler than its JSObject data structure. You should be able
to fake a JSString.
What’s more, QuickJS stores string contents inline with the JSString
header, using C’s flexible array member feature (see pages 105-107 of
the draft C23
standard).
That means that:
-
If you know the address of
JSString(with the help ofaddrof, for example), you also know the address of the bytes that make up the string itself, without having to read and follow a pointer; and -
If you fake a
JSString(with the help offakestr, for example) that claims to represent a long string, you can use it to read memory from the heap in locations contiguous with the string. If you get lucky and a typed array gets placed there (how would you know?), you can read that typed array’sJSObjectheader.
Some hints:
-
Read the above text carefully. It represents our suggestion for the easiest approach to a Part B solution. You don’t have to follow our approach but be sure to deviate from it for a concrete reason!
-
Keep in mind the
String.prototype.charCodeAtandString.fromCharCodefunctions, along with other convenient string routines likeString.prototype.substringand string operators like+, concatenation. -
QuickJS strings can be encoded using one-byte characters or two-byte wide characters (UTF-16). You want to avoid UTF-16. To escape unprintable single-byte characters, use
\x[dig][dig], where each[dig]is a hexadecimal digit. Take a look atsecondstage.jsfor an example. -
Don’t forget that in setting string contents you can combine string constants (like
"hello") with strings constructed dynamically by your depending on values observed during execution.
To be extra clear, the strategy recommended above calls for you to
create two “host” strings, one each at two different parts of your
exploit script. The first host string has a fake string
header as its contents; with the help of fakestr, you use this fake
string header to read memory that comes after the string, to look for
a typed array header to copy. The second host string has a fake typed
array header as its contents; with the help of fakeobj, you turn
this fake typed array header into the control plane typed array.
Testing Part B
We will run your Part B solution as follows:
$ quickjs/qjs --partb --partc -I partb-pre.js -I partb-soln.js partb-tests.js
(and the same with quickjs/qjs-debug). The Part B preamble,
partb-pre.js, like the Part A preamble, defines and calls
randheap. It also exports built-in versions of addrof, fakeobj,
and fakestr, so you can work on Part B regardless of whether your
Part A solution is done. You will modify only partb-soln.js. Our
autograder will include additional tests in partb-tests.js.
If you would like to test your solution interactively, you could invoke QuickJS as follows:
$ quickjs/qjs-debug --partb --interactive -I partb-pre.js partb-soln.js
Part C
In Part C, you will use the generic primitives readmem, and
writemem (along with addrof, for convenience) to create an rwx
mapping, populate that mapping with a second-stage payload (found in
the second_stage variable), and then execute that payload.
Linux will let programs change memory protections after the fact using
the
mprotect
system call. It is probably easiest to use this system call to mark a
page on the heap rwx using flags PROT_READ | PROT_WRITE | PROT_EXEC.
Either mark the page that holds the second_stage string rwx or mark
some other page rwx and then copy the string’s contents into the rwx
page. Then jump to the rwx copy of the second-stage payload to
execute it.
How you structure your Part C solution is up to you.
You could choose to implement it in full return-oriented style, in which case you will want to consult the two papers on RISC-V ROP.
But you will almost certainly find it easier to keep more of the logic
in JavaScript and arrange to misuse QuickJS mechanisms for calling C
functions from JavaScript to call the libc mprotect wrapper. This
is the approach we recommend.
Some hints:
-
If you disassemble
js_call_c_function, you will see that, in most cases, it invokes the C function witha0,a1, anda2set torealm, the value field ofthis, and the tag field ofthis, respectively. (But not in theJS_CFUNC_f_fandJS_CFUNC_f_f_fcases used for the math functions – those convert their arguments to doubles and pass them in the floating point registers.) -
You can either make a new, fake function object or you can hijack an existing but rarely-called function;
console.log, which points tojs_print, is a good choice. -
Don’t forget about
Function.prototype.apply. -
To find addresses in the qjs text segment, look for a function pointer accessible from the JavaScript heap to a qjs function. There are lots of them; one example is
console.log‘scfunc, which points tojs_print. But keep in mind that the text segment is laid out differently in qjs and qjs-debug, so you should not assume that two functions are at some specific distance from each other. -
To find addresses in libc, look for a function pointer accessible from the JavaScript heap to a libc function. One example is
malloc_usable_size. There is no direct pointer to the libc functionmalloc_usable_sizein the QuickJS heap, but there is a pointer to the QuickJS functionjs_def_malloc_usable_size, reachable fromctx->rt->mf.js_malloc_usable_size. (Where can you get a pointer to the context?) This function is just a wrapper aroundmalloc_usable_size, so if you disassemble it you will quickly find ajorjalinstruction (depending on whether you are targetingqjsorqjs-debug) that transfers control to themalloc_usable_sizeentry in QuickJS’s PLT. Parse the PLT stub to find the corresponding GOT entry, and that entry will contain a pointer to libc itself, at least assuming the function has already been called so that the dynamic linker has had a chance to resolve it. (The best reference by far on linkers and loaders, if you’re curious, is John Levine’s book; also see Frederic Cambus’ toolchain resource page.) -
To find addresses on the stack, you could look for the
environsymbol, stored immediately after the GOT. But for the recommended approach you do not need to find addresses on the stack.
To allow you to work on Part C regardless of whether your Parts A and
B solutions are done, we have provided builtin versions of readmem,
writemem, and addrof, as cse291y.readmem, cse291y.writemem,
and cse291y.addrof. To enable these builtins instead of nine(),
invoke qjs with the --partc command-line flag. When we test your
Part C solution, we will include the following preamble to your
partc.js solution script, as part of partc-pre.js:
let readmem = cse291y.readmem;
let writemem = cse291y.writemem;
let addrof = cse291y.addrof;
You can do the same in your testing, but make sure that these lines
are not part of the partc.js solution you submit, because that
will make it harder for us to test your end-to-end solution.
Our preamble will also define a string second_stage containing the
(RISC-V instruction binary) payload to execute in the second exploit
stage. You should not assume anything about this string except that
it is less than 4096 bytes long, so a single rwx page is enough (or
two, if the shellcode spans a page boundary).
Finally, our preamble will define a variable mprotect_mus_offset
that we will set to the offset in our testing environment from the
malloc_usable_size function in libc to the mprotect function in
libc. If you use the strategy recommended above and this variable to
find mprotect, your exploit should work in our autograder even if
our libc ended up with a slightly different layout than the one you
used in developing your exploit. You can find the right value for
mprotect_mus_offset in your environment with the gdb command
p/x (uint64_t)mprotect - (uint64_t)malloc_usable_size
when you are debugging qjs-debug.
Testing Part C
We will run your Part C solution as follows:
$ quickjs/qjs --partc -I partc-pre.js partc-soln.js
(and the same with quickjs/qjs-debug). The Part C preamble,
partc-pre.js defines and calls randheap. It also exports built-in
versions of addrof, readmem, and writemem, so you can work on
Part C regardless of whether your Part A and Part B solutions are
done. As explained above, it also exports an mprotect_mus_offset
variable suitable for our testing environment, and a second_stage
variable holding shellcode. You will modify only partc-soln.js. We
expect your Part C solution to execute the second-stage shellcode;
there are no other functionality tests and no partc-tests.js file.
The end-to-end solution
We will also test that your solutions to Part A, Part B, and Part C can be combined to give an end-to-end exploit, by running
$ quickjs/qjs --parta -I combined-pre.js -I parta-soln.js \
-I partb-soln.js partc-soln.js
(and the same with quickjs/qjs-debug). The combined preamble, like
the Part C preamble, defines mprotect_mus_offset and second_stage.
Logistics
You will submit using Gradescope. You should submit a zip file of your solution, without directory structure. Your solution should include at least the following files:
-
parta-soln.js: This is your solution to Part A. It should define functionsaddrof,fakeobj, andfakestrat global scope. -
partb-soln.js: This is your solution to Part B. It should define functionsreadmemandwritememat global scope. -
partc-soln.js: This is your solution to Part C. It should assume that a string calledsecond_stageis defined at global scope and cause the contents of that string to execute in an rwx memory region.
Grading
The project will be graded out of 10 points.
We will grade each part in isolation, using the builtin functions
implementing the underlying primitives. So, for example, we will
grade your solution to Part B using the builtin implementations
(cse291y.addrof, etc.) and the script preamble partb-pre.js to
expose them as addrof, etc. Each part graded in isolation is worth
3 points.
We will assign the final, tenth point based on whether your solutions
to all three parts work together. That is, whether you have caused
the browser to execute the second-stage payload given just the
nine() type confusion bug from Part A, without the builtins
supplied in Parts B and C.
We will test Part C and the end-to-end solution, at least, on both the
qjs and qjs-debug binaries. Pay attention to the discussion of
ASLR above.
Scoring is based on functionality in our testing, though solutions that violate the spirit of the assignment (e.g., use an unrelated bug) may be docked points.
Thanks
Greetz to qwertyoruiop, Fire30, and the other participants in the crackbykim IRC challenge for QuickJS; and to 0vercl0k, __x86, bkth, itszn, natashenka, niklasb, saelo, tsuro, and others in the JavaScript JIT security community.