The security implications of Just-in-Time (JIT) Compilers in browsers have been getting attention for the past decade and the references to more recent resources is too great to enumerate. While it’s not the only class of flaw in a browser, it is a common one; and diving deeply into it has a higher barrier to entry than, say, UXSS injection in the UI. This post is about lowering that barrier to entry.
If you want to understand what is happening under the hood in the JIT engine, you can read the source. But that’s kind of a tall order given that the folder js/ contains 500,000+ lines of code. Sometimes it’s easier to treat a target as a black box until you find something you want to dig into deeper. To aid in that endeavor, we’ve landed a feature in the js shell that allows you to get the assembly output of a Javascript function the JIT has processed. Disassembly is supported with the zydis disassembly library (our in-tree version).
To use the new feature; you’ll need to run the js interpreter. You can download the jsshell for any Nightly version of Firefox from our FTP server – for example here’s the latest Linux x64 jsshell. Helpfully, these links always point to the latest version available, historical versions can also be downloaded.
You can also build the js shell from source (which can be done separately from building Firefox, but doing the full browser build can also create the shell.) If building from source, in your .mozconfig, you’ll want to following to get the tools and output you want but also emulate the shell as the Javascript engine is released to users:
ac_add_options --enable-application=js
ac_add_options --enable-js-shell
ac_add_options --enable-jitspew
ac_add_options --disable-debug
ac_add_options --enable-optimize
# If you want to experiment with the debug and optimize flags,
# you can build Firefox to different object directories
# (and avoid an entire recompilation)
mk_add_options MOZ_OBJDIR=@TOPSRCDIR@/obj-nodebug-opt
# mk_add_options MOZ_OBJDIR=@TOPSRCDIR@/obj-debug-noopt
After building the shell or Firefox, fire up `obj-dir/dist/bin/js[.exe]` and try the following script:
function add(x, y) { x = 0+x; y = 0+y; return x+y; } for(i=0; i<500; i++) { add(2, i); } print(disnative(add))
You’ll be greeted by an initial line indicating which backend is being used. The possible values and their meanings are:
- Wasm – A WebAssembly function0
- Asmjs – An asm.js module or exported function
- Baseline – indicates the Baseline JIT, a first-pass JIT Engine that collects type information (that can be used by Ion during a subsequent compilation).
- Ion – indicates IonMonkey, a powerful optimizing JIT that performs aggressive compiler optimizations at the cost of additional compile time.
0 The WASM function itself might be Baseline WASM or compiled with an optimizing compiler Cranelift on Nightly; Ion otherwise – it’s not easily enumerated which the assembly function is, but identifying baseline or not becomes easier once you’ve looked at the assembly output a few times.
After running a function 100 times, we will trigger the Baseline compiler; after 1000 times we will trigger Ion, and after 100,000 times the full, more expensive, Ion compilation.
For more information about the differences and internals of the JIT Engines, we can point to the following articles:
- The Baseline Compiler Has Landed (2013)
- IonMonkey: Optimizing Away (2014)
- IonMonkey: Evil on your behalf (2016)
- The Baseline Interpreter (2019)
- The Compiler Compiler Youtube Series (2020)
- The SpiderMonkey Blog (2019-2020)
- A cartoon intro to WebAssembly (2017)
- Making WebAssembly even faster (2018)
- Calls between JavaScript and WebAssembly are finally fast (2018)
Let’s dive into the output we just generated. Here’s the output of the above script:
; backend=baseline 00000000 jmp 0x0000000000000028 00000005 mov $0x7F8A23923000, %rcx 0000000F movq 0x170(%rcx), %rcx 00000016 movq %rsp, 0xD0(%rcx) 0000001D movq $0x00, 0xD8(%rcx) 00000028 push %rbp 00000029 mov %rsp, %rbp | 0000002C sub $0x48, %rsp | Allocating & initializing 00000030 movl $0x00, -0x10(%rbp) | BaselineFrame structure on 00000037 movq 0x18(%rbp), %rcx | stack. 0000003B and $-0x04, %rcx | (BaselineCompilerCodeGen:: 0000003F movq 0x28(%rcx), %rcx | emitInitFrameFields) 00000043 movq %rcx, -0x30(%rbp) | 00000047 mov $0x7F8A239237E0, %r11 | 00000051 cmpq %rsp, (%r11) | 00000054 jbe 0x000000000000006C | 0000005A mov %rbp, %rbx | Stackoverflow check 0000005D sub $0x48, %rbx | (BaselineCodeGen:: 00000061 push %rbx | emitStackCheck) 00000062 push $0x5821 | 00000067 call 0xFFFFFFFFFFFE1680 | 0000006C mov $0x7F8A226CE0D8, %r11 00000076 addq $0x01, (%r11) 0000007A mov $0x7F8A227F6E00, %rax | 00000084 movl 0xC0(%rax), %ecx | 0000008A add $0x01, %ecx | 0000008D movl %ecx, 0xC0(%rax) | 00000093 cmp $0x3E8, %ecx | 00000099 jl 0x00000000000000CC | Check if we should tier up to 0000009F movq 0x88(%rax), %rax | Ion code. 0x38 (1000) is the 000000A6 cmp $0x02, %rax | threshold. After that check, 000000AA jz 0x00000000000000CC | it checks 'are we already 000000B0 cmp $0x01, %rax | compiling' and 'is Ion 000000B4 jz 0x00000000000000CC | compilation impossible' 000000BA mov %rbp, %rcx | 000000BD sub $0x48, %rcx | 000000C1 push %rcx | 000000C2 push $0x5821 | 000000C7 call 0xFFFFFFFFFFFE34B0 | 000000CC movq 0x28(%rbp), %rcx | 000000D0 mov $0x7F8A227F6ED0, %r11 | 000000DA movq (%r11), %rdi | 000000DD callq (%rdi) | 000000DF movq 0x30(%rbp), %rcx | Type Inference Type Monitors 000000E3 mov $0x7F8A227F6EE0, %r11 | for |this| and each arg. 000000ED movq (%r11), %rdi | (This overhead is one of the 000000F0 callq (%rdi) | reasons we're doing 000000F2 movq 0x38(%rbp), %rcx | WARP - see below.) 000000F6 mov $0x7F8A227F6EF0, %r11 | 00000100 movq (%r11), %rdi | 00000103 callq (%rdi) | 00000105 movq 0x30(%rbp), %rbx | 00000109 mov $0xFFF8800000000000, %rcx | 00000113 mov $0x7F8A227F6F00, %r11 | Load Int32Value(0) + arg1 and 0000011D movq (%r11), %rdi | calling an Inline Cache stub 00000120 callq (%rdi) | 00000122 movq %rcx, 0x30(%rbp) | 00000126 movq 0x38(%rbp), %rbx | 0000012A mov $0xFFF8800000000000, %rcx | Load Int32Value(0) + arg2 and 00000134 mov $0x7F8A227F6F10, %r11 | calling an Inline Cache stub 0000013E movq (%r11), %rdi | 00000141 callq (%rdi) | 00000143 movq %rcx, 0x38(%rbp) | 00000147 movq 0x38(%rbp), %rbx | 0000014B movq 0x30(%rbp), %rcx | 0000014F mov $0x7F8A227F6F20, %r11 | 00000159 movq (%r11), %rdi | Final Add Inline Cache call 0000015C callq (%rdi) | followed by epilogue code and 0000015E jmp 0x0000000000000163 | return 00000163 mov %rbp, %rsp | 00000166 pop %rbp | 00000167 jmp 0x0000000000000171 | 0000016C jmp 0xFFFFFFFFFFFE69E0 00000171 ret 00000172 ud2
So that’s the Baseline code. It’s the more simplistic JIT in Firefox. What about IonMonkey – its faster, more aggressive big brother?
If we preface our script with setJitCompilerOption("ion.warmup.trigger", 4); then we will induce the Ion compiler to trigger earlier instead of the aforementioned 1000 invocations. You can also set setJitCompilerOption("ion.full.warmup.trigger", 4); to trigger the more aggressive tier for Ion compilation that otherwise kicks in after 100,000 invocations. After triggering the ‘full’ layer, the output will look like:
; backend=ion 00000000 movq 0x20(%rsp), %rax | 00000005 shr $0x2F, %rax | 00000009 cmp $0x1FFF3, %eax | 0000000E jnz 0x0000000000000078 | 00000014 movq 0x28(%rsp), %rax | 00000019 shr $0x2F, %rax | Type Guards 0000001D cmp $0x1FFF1, %eax | for this variable, 00000022 jnz 0x0000000000000078 | arg1, & arg2 00000028 movq 0x30(%rsp), %rax | 0000002D shr $0x2F, %rax | 00000031 cmp $0x1FFF1, %eax | 00000036 jnz 0x0000000000000078 | 0000003C jmp 0x0000000000000041 | 00000041 movl 0x28(%rsp), %eax | 00000045 movl 0x30(%rsp), %ecx | Addition 00000049 add %ecx, %eax | 0000004B jo 0x000000000000007F | Overflow Check 00000051 mov $0xFFF8800000000000, %rcx | Box int32 into 0000005B or %rax, %rcx | Int32Value 0000005E ret 0000005F nop 00000060 nop 00000061 nop 00000062 nop 00000063 nop 00000064 nop 00000065 nop 00000066 nop 00000067 mov $0x7F8A23903FC0, %r11 | 00000071 push %r11 | 00000073 jmp 0xFFFFFFFFFFFDED40 | 00000078 push $0x00 | 0000007A jmp 0x000000000000008D | 0000007F sub %ecx, %eax | Out-of-line 00000081 jmp 0x0000000000000086 | error handling 00000086 push $0x0D | code 00000088 jmp 0x000000000000008D | 0000008D push $0x00 | 0000008F jmp 0xFFFFFFFFFFFDEC60 | 00000094 ud2 |
There are some other things worth noting.
You can control the behavior of the JITs using environment variables, such as JIT_OPTION_fullDebugChecks=false (this will avoid running all the debug checks even in the debug build.) The full list of JIT Options with documentation is available in JitOptions.cpp.
There are also a variety of command-line flags that can be used in place of environment variables or setJitCompilerOption. For instance --baseline-eager and --ion-eager will trigger JIT compilation immediately instead of requiring multiple compilations. (ion-eager triggers ‘full’ compilation, so avoid it if you want the non-full behavior.) --no-threads or --ion-offthread-compile=off will disable off-thread compilation that can make it harder to write reliable tests because it adds non-determinism. no-threads turns off all the background threads and implies ion-offthread-compile=off.
Finally, we have a new in-development frontend for Ion: WarpBuilder. You can learn more about WarpBuilder over in the spidermonkey newsletter or the Bugzilla bug. Enabling warp (by passing --warp to the js shell executable) significantly reduces the assembly generated, partly because we’re simplifying how type information is collected and updated.
If you’ve got other tricks or techniques you use to help you navigate our JIT(s), be sure to reply to our tweet so others can find them!