CVE-2023-38600: Story of an innocent Apple Safari copyWithin gone (way) outside

In May 2023, we received a vulnerability report from an anonymous researcher regarding a vulnerability in Apple Safari. It turned out to be an interesting classic integer underflow vulnerability. Apple assigned CVE-2023-38600 to this issue and fixed it in the following security advisories:

— iOS 16.6 and iPadOS 16.6
— macOS Ventura 13.5
— tvOS 16.6
— Safari 16.6
— watchOS 9.6

 Now that this vulnerability has been addressed by the vendor, we are ready to share additional details with the community.

Proof of Concept

Here is the minimal proof-of-concept code for this bug:

Let’s break it down quickly:

     1 – A new ArrayBuffer object of size 0x1000 is created. Note that it is resizable as it specifies maxByteLength.
     2 – The ArrayBuffer object created at step 1 is used to create a Uint8Array typed array.
     3 – A function is defined which resizes the ArrayBuffer object to zero. It also returns a value of zero.
     4 – The copyWithin method is called, which copies part of typed array to another location in the same typed array without modifying its length. It takes 3 arguments:
          a – insert (“target”) position: The value provided here is 0x20.
          b – start position: The value provided is an object having a valueOf property, specifying the function defined above as the callback.
          c – end position: not specified, and thus it is undefined.

An experienced eye will quickly notice presence of the object with the valueOf callable property defined. This is often used to receive control in the middle of a function execution to change (cached) values and trigger incorrect behavior. There have been many vulnerabilities triggered using callbacks in this fashion.

Here the results of running the PoC against a vulnerable version of JSC under LLDB (a debug build of JSC commit cb91b749f30d1cc1bb01bfce9adbe18ad3cea698 is used in this blog):

Now we have enough information to find the root cause of this vulnerability.

Root Cause Analysis

There are some hints in the output of LLDB session. The crash happens inside a loop during a memmove. The loop counter (rdx) is huge (0xffffffffffffefc0) and thus a crash is inevitable. Looking at the backtrace, the faulty memmove call is performed by the JSC::genericTypedArrayViewProtoFuncCopyWithin function in JSGenericTypedArrayViewPrototypeFunctions.h. Let’s go step by step inside this function and see what is going on.

Here is the code of the unpatched version of the function:

The function first gets the length of ArrayBuffer at (1) and saves it into the length variable. This is 0x1000 based on the PoC. Then, at (2), (3) and (4), it parses the arguments we passed in our call to copyWithin method by calling argumentClampedIndexFromStartOrEnd function for each argument, assigning the results to the to, from and final variables respectively. The code of argumentClampedIndexFromStartOrEnd is as follows:

The main goal of this function is to perform a sanity check (e.g., to check that the value is not more than the length). Let’s see what happens when each argument is parsed:

• First argument: The provided value is the integer 0x20. Thus the argumentClampedIndexFromStartOrEnd function will reach (2) and return 0x20 after checking that it does not exceed length (0x1000). So the to variable will be 0x20.

• Second argument: The provided value is an object having a valueOf callback. Thus execution reaches (3), where a call to toIntegerOrInfinity method is made. This will invoke the valueOf callback function. Inside the callback, the PoC changes the ArrayBuffer length to zero and then returns a value of 0. Thus, the from variable will be 0. Note that the length variable remains set to 0x1000, which is now out-of-date and incorrect.

• Third argument: The PoC did not specify a third argument, so it is undefined. Therefore the condition at (1) is true, and argumentClampedIndexFromStartOrEnd returns the length value of 0x1000. This becomes the value of final.

Now that it is done with parsing arguments, genericTypedArrayViewProtoFuncCopyWithin function reaches (5) to compute count (the number of array elements that need to be copied):

size_t count = std::min(length [0x1000] – std::max(to [0x20], from [0]), final [0x1000] – from [0]);

Therefore, the count variable will be 0xfe0.

We now reach the final part of the function, where it performs the actual copy. Here, things get interesting. The developers were aware that it is possible that the ArrayBuffer object has been resized, and mentioned that in the comment:

At (6) the code fetches the updated length, which is zero. As it is not equal to the previously extracted length (0x1000), a further check is done to make sure the count is within bounds:

At (7), the length variable will be adjusted to the updated length, which is 0. The code also attempts to adjust count accordingly. However, this doesn’t go properly. to is already 0x20, so an integer underflow occurs, causing count to get the value 0xffffffffffffffe0. This huge value is then used as the count argument in a call to the memmove function where the crash occurs.

The Patch

The issue was patched by aborting the copy if either of the two variables to or from is larger than the updated length.

Final Notes

This blog demonstrates how even an old trick can be used to trigger a new vulnerability. The values we used during the exploit were sane as they went through a sanitizer function. However, in the final stage, the values were updated without checking if there are inside the buffer length bounds. A classic technique that never seems to go out of style. Thanks again to the anonymous researcher who submitted the bug. If you find something similar, we’re always interested in acquiring great bugs.

Until then, you can find me on Twitter at @hosselot and follow the team on Twitter, Mastodon, LinkedIn, or Instagram for the latest in exploit techniques and security patches.