Overview

High
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in Storage
DescriptionUse after free in Storage
ComponentStorage
Sub-componentBlink>Storage
Bug ClassUAF
Tracker559793873
Fix commita73219eb4cb1 (chromium/src) +246/-250
CISA KEVNot listed
CreditedXinyang Ge
Disclosed2026-10-06

Changed Functions

FunctionChangeNotes
if
storage/browser/blob/blob_registry_impl.cc
modified
is_single_unknown_size_file_
third_party/blink/renderer/platform/blob/blob_data.cc
modified
if
third_party/blink/renderer/platform/blob/blob_data.cc
modified
TEST_F
storage/browser/blob/blob_registry_impl_unittest.cc
modified

Files Changed

  • third_party/blink/renderer/DEPS
  • third_party/blink/renderer/modules/webshare/navigator_share_test.cc
  • third_party/blink/renderer/core/fileapi/file_test.cc
  • third_party/blink/renderer/core/fetch/fetch_data_loader_test.cc
  • third_party/blink/renderer/core/fetch/form_data_bytes_consumer_test.cc
  • third_party/blink/renderer/modules/indexeddb/idb_request_test.cc
  • third_party/blink/renderer/platform/blob/testing/fake_blob_registry.h
  • third_party/blink/renderer/platform/blob/testing/fake_blob_registry.cc
  • third_party/blink/renderer/platform/blob/blob_data.h
  • third_party/blink/renderer/bindings/core/v8/serialization/serialized_script_value_test.cc
  • third_party/blink/renderer/bindings/core/v8/serialization/v8_script_value_serializer_test.cc
  • storage/browser/blob/blob_registry_impl.cc
  • third_party/blink/public/mojom/blob/blob_registry.mojom
  • storage/browser/blob/blob_registry_impl_unittest.cc
  • third_party/blink/public/mojom/blob/file_backed_blob_factory.mojom
  • storage/browser/blob/blob_registry_impl.h
  • third_party/blink/renderer/core/frame/web_frame_test.cc
  • third_party/blink/renderer/modules/cache_storage/cache_test.cc
  • third_party/blink/renderer/platform/blob/blob_data.cc
From a73219eb4cb139df9b7e79ef8507d3078a97b27e Mon Sep 17 00:00:00 2001
From: Evan Stade <evanstade@microsoft.com>
Date: Fri, 18 Sep 2026 15:16:54 -0700
Subject: [PATCH] Generate Blob UUID in browser process

This should generally be less exploitable than trusting the renderer to
generate UUIDs, even if we were to spot fix the browser UAF.

This is intended as a relatively simple, non-disruptive fix (aside from
updating a fair number of tests). A longer term fix involves removing
UUID altogether, or hiding it from the renderer, but there are several
uses of blob UUID that are not easily removed (crbug.com/40529364),
particularly in extensions APIs.

Bug: 560470099,559793873
Change-Id: Idf5d92e6d6201d4b8b8af797bee6103eebb81bef
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8406427
Commit-Queue: Evan Stade <evanstade@microsoft.com>
Reviewed-by: Jeremy Roman <jbroman@chromium.org>
Reviewed-by: Alex Gough <ajgo@chromium.org>
Reviewed-by: Fergal Daly <fergal@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1701129}
---
diff --git a/storage/browser/blob/blob_registry_impl.cc b/storage/browser/blob/blob_registry_impl.cc
index a7461620..40f62fb 100644
--- a/storage/browser/blob/blob_registry_impl.cc
+++ b/storage/browser/blob/blob_registry_impl.cc
@@ -12,6 +12,7 @@
 #include "base/functional/callback_helpers.h"
 #include "base/memory/raw_ptr.h"
 #include "base/task/sequenced_task_runner.h"
+#include "base/uuid.h"
 #include "storage/browser/blob/blob_builder_from_stream.h"
 #include "storage/browser/blob/blob_data_builder.h"
 #include "storage/browser/blob/blob_impl.h"
@@ -511,20 +512,14 @@
 
 void BlobRegistryImpl::Register(
     mojo::PendingReceiver<blink::mojom::Blob> blob,
-    const std::string& uuid,
     const std::string& content_type,
     const std::string& content_disposition,
     std::vector<blink::mojom::DataElementPtr> elements,
     RegisterCallback callback) {
-  if (!context_) {
-    std::move(callback).Run();
-    return;
-  }
+  const std::string uuid = base::Uuid::GenerateRandomV4().AsLowercaseString();
 
-  if (uuid.empty() || context_->registry().HasEntry(uuid) ||
-      blobs_under_construction_.contains(uuid)) {
-    receivers_.ReportBadMessage(
-        "Invalid UUID passed to BlobRegistry::Register");
+  if (!context_) {
+    std::move(callback).Run(uuid);
     return;
   }
 
@@ -541,7 +536,7 @@
             uuid, content_type, content_disposition,
             BlobStatus::ERR_REFERENCED_FILE_UNAVAILABLE);
         BlobImpl::Create(std::move(handle), std::move(blob));
-        std::move(callback).Run();
+        std::move(callback).Run(uuid);
         return;
       }
       if (file->length == std::numeric_limits<uint64_t>::max()) {
@@ -569,7 +564,7 @@
 
   blobs_under_construction_[uuid]->StartTransportation(std::move(blob_impl));
 
-  std::move(callback).Run();
+  std::move(callback).Run(uuid);
 }
 
 void BlobRegistryImpl::RegisterFromStream(
diff --git a/storage/browser/blob/blob_registry_impl.h b/storage/browser/blob/blob_registry_impl.h
index 79d3a20..7e1f9fe 100644
--- a/storage/browser/blob/blob_registry_impl.h
+++ b/storage/browser/blob/blob_registry_impl.h
@@ -51,7 +51,6 @@
             std::unique_ptr<Delegate> delegate);
 
   void Register(mojo::PendingReceiver<blink::mojom::Blob> blob,
-                const std::string& uuid,
                 const std::string& content_type,
                 const std::string& content_disposition,
                 std::vector<blink::mojom::DataElementPtr> elements,
diff --git a/third_party/blink/public/mojom/blob/blob_registry.mojom b/third_party/blink/public/mojom/blob/blob_registry.mojom
index 5a97e1b..88bb66e 100644
--- a/third_party/blink/public/mojom/blob/blob_registry.mojom
+++ b/third_party/blink/public/mojom/blob/blob_registry.mojom
@@ -26,11 +26,11 @@
 interface BlobRegistry {
   // Registers a new blob with the blob registry.
   // TODO(crbug.com/974616): Make this method non-sync and get rid of the UUID
-  // parameter once enough of the rest of the system doesn't rely on the UUID
+  // return value once enough of the rest of the system doesn't rely on the UUID
   // anymore.
-  [Sync] Register(pending_receiver<blink.mojom.Blob> blob, string uuid,
+  [Sync] Register(pending_receiver<blink.mojom.Blob> blob,
                   string content_type, string content_disposition,
-                  array<DataElement> elements) => ();
+                  array<DataElement> elements) => (string uuid);
 
   // Creates a new blob out of a data pipe.
   // |length_hint| is only used as a hint, to decide if the blob should be
diff --git a/third_party/blink/public/mojom/blob/file_backed_blob_factory.mojom b/third_party/blink/public/mojom/blob/file_backed_blob_factory.mojom
index fdf0e95a..e6a35ea8 100644
--- a/third_party/blink/public/mojom/blob/file_backed_blob_factory.mojom
+++ b/third_party/blink/public/mojom/blob/file_backed_blob_factory.mojom
@@ -12,6 +12,7 @@
 // This interface is channel-associated (for frames).
 interface FileBackedBlobFactory {
     // Registers a file backed blob.
+    // TODO(crbug.com/40529364): get rid of UUID parameter.
     RegisterBlob(pending_receiver<blink.mojom.Blob> blob, string uuid,
         string content_type,
         blink.mojom.DataElementFile file);
diff --git a/third_party/blink/renderer/platform/blob/blob_data.cc b/third_party/blink/renderer/platform/blob/blob_data.cc
index b3f33c8f..39630ac 100644
--- a/third_party/blink/renderer/platform/blob/blob_data.cc
+++ b/third_party/blink/renderer/platform/blob/blob_data.cc
@@ -287,23 +287,20 @@
 }
 
 BlobDataHandle::BlobDataHandle()
-    : uuid_(CreateCanonicalUuidString()),
-      size_(0),
-      is_single_unknown_size_file_(false) {
+    : size_(0), is_single_unknown_size_file_(false) {
   GetThreadSpecificRegistry()->Register(
-      blob_remote_.InitWithNewPipeAndPassReceiver(), uuid_, "", "", {});
+      blob_remote_.InitWithNewPipeAndPassReceiver(), "", "", {}, &uuid_);
 }
 
 BlobDataHandle::BlobDataHandle(std::unique_ptr<BlobData> data, uint64_t size)
-    : uuid_(CreateCanonicalUuidString()),
-      type_(data->ContentType()),
+    : type_(data->ContentType()),
       size_(size),
       is_single_unknown_size_file_(data->IsSingleUnknownSizeFile()) {
   auto elements = data->ReleaseElements();
   TRACE_EVENT0("Blob", "Registry::RegisterBlob");
   GetThreadSpecificRegistry()->Register(
-      blob_remote_.InitWithNewPipeAndPassReceiver(), uuid_,
-      type_.IsNull() ? "" : type_, "", std::move(elements));
+      blob_remote_.InitWithNewPipeAndPassReceiver(),
+      type_.IsNull() ? "" : type_, "", std::move(elements), &uuid_);
 }
 
 BlobDataHandle::BlobDataHandle(
@@ -312,12 +309,12 @@
     const String& content_type,
     uint64_t size,
     bool synchronous_register)
-    : uuid_(CreateCanonicalUuidString()),
-      type_(content_type),
+    : type_(content_type),
       size_(size),
       is_single_unknown_size_file_(size ==
                                    std::numeric_limits<uint64_t>::max()) {
   if (file_backed_blob_factory) {
+    uuid_ = CreateCanonicalUuidString();
     if (synchronous_register) {
       file_backed_blob_factory->RegisterBlobSync(
           blob_remote_.InitWithNewPipeAndPassReceiver(), uuid_,
@@ -335,8 +332,8 @@
     elements.push_back(DataElement::NewFile(std::move(file_element)));
     TRACE_EVENT0("Blob", "Registry::RegisterBlob");
     GetThreadSpecificRegistry()->Register(
-        blob_remote_.InitWithNewPipeAndPassReceiver(), uuid_,
-        type_.IsNull() ? "" : type_, "", std::move(elements));
+        blob_remote_.InitWithNewPipeAndPassReceiver(),
+        type_.IsNull() ? "" : type_, "", std::move(elements), &uuid_);
   }
 }
 
diff --git a/third_party/blink/renderer/platform/blob/blob_data.h b/third_party/blink/renderer/platform/blob/blob_data.h
index fa9cab9b..c80edb9a 100644
--- a/third_party/blink/renderer/platform/blob/blob_data.h
+++ b/third_party/blink/renderer/platform/blob/blob_data.h
@@ -196,6 +196,7 @@
     return base::AdoptRef(new BlobDataHandle(uuid, type, size));
   }
 
+  // For reinflation from IPC.
   static scoped_refptr<BlobDataHandle> Create(
       const String& uuid,
       const String& type,
@@ -245,7 +246,7 @@
 
   // This UUID is deprecated and should not be used to reference the blob in the
   // backend (BlobRegistry). TODO(crbug.com/40529364): remove.
-  const String uuid_;
+  String uuid_;
   const String type_;
   const uint64_t size_;
   const bool is_single_unknown_size_file_;
diff --git a/storage/browser/blob/blob_registry_impl_unittest.cc b/storage/browser/blob/blob_registry_impl_unittest.cc
index 208fd1e..b4b4666 100644
--- a/storage/browser/blob/blob_registry_impl_unittest.cc
+++ b/storage/browser/blob/blob_registry_impl_unittest.cc
@@ -23,6 +23,7 @@
 #include "base/task/thread_pool.h"
 #include "base/test/bind.h"
 #include "base/test/task_environment.h"
+#include "base/test/test_future.h"
 #include "base/threading/thread_restrictions.h"
 #include "mojo/public/cpp/bindings/associated_receiver.h"
 #include "mojo/public/cpp/bindings/associated_remote.h"
@@ -118,17 +119,9 @@
   }
 
   std::string UUIDFromBlob(blink::mojom::Blob* blob) {
-    base::RunLoop loop;
-    std::string received_uuid;
-    blob->GetInternalUUID(base::BindOnce(
-        [](base::OnceClosure quit_closure, std::string* uuid_out,
-           const std::string& uuid) {
-          *uuid_out = uuid;
-          std::move(quit_closure).Run();
-        },
-        loop.QuitClosure(), &received_uuid));
-    loop.Run();
-    return received_uuid;
+    base::test::TestFuture<std::string> future;
+    blob->GetInternalUUID(future.GetCallback<const std::string&>());
+    return future.Get();
   }
 
   void OnBadMessage(const std::string& error) {
@@ -138,9 +131,7 @@
   void WaitForBlobCompletion(BlobDataHandle* blob_handle) {
     base::RunLoop loop;
     blob_handle->RunOnConstructionComplete(
-        base::BindOnce([](base::OnceClosure closure,
-                          BlobStatus status) { std::move(closure).Run(); },
-                       loop.QuitClosure()));
+        base::IgnoreArgs<BlobStatus>(loop.QuitClosure()));
     loop.Run();
   }
 
@@ -183,6 +174,15 @@
     return registry_impl_->BlobsBeingStreamedForTesting();
   }
 
+  bool Register(mojo::PendingReceiver<blink::mojom::Blob> blob,
+                std::string& uuid,
+                const std::string& content_type,
+                const std::string& content_disposition,
+                std::vector<blink::mojom::DataElementPtr> elements) {
+    return registry_->Register(std::move(blob), content_type,
+                               content_disposition, std::move(elements), &uuid);
+  }
+
  protected:
   base::ScopedTempDir data_dir_;
   base::test::TaskEnvironment task_environment_;
@@ -200,57 +200,21 @@
   std::vector<std::string> bad_messages_;
 };
 
-TEST_F(BlobRegistryImplTest, Register_EmptyUUID) {
-  mojo::Remote<blink::mojom::Blob> blob;
-  EXPECT_FALSE(
-      registry_->Register(blob.BindNewPipeAndPassReceiver(), "", "", "",
-                          std::vector<blink::mojom::DataElementPtr>()));
-
-  EXPECT_EQ(1u, bad_messages_.size());
-
-  registry_.FlushForTesting();
-  EXPECT_FALSE(registry_.is_connected());
-
-  blob.FlushForTesting();
-  EXPECT_FALSE(blob.is_connected());
-  EXPECT_EQ(0u, BlobsUnderConstruction());
-}
-
-TEST_F(BlobRegistryImplTest, Register_ExistingUUID) {
-  const std::string kId = "id";
-  std::unique_ptr<BlobDataHandle> handle =
-      CreateBlobFromString(kId, "hello world");
-
-  mojo::Remote<blink::mojom::Blob> blob;
-  EXPECT_FALSE(
-      registry_->Register(blob.BindNewPipeAndPassReceiver(), kId, "", "",
-                          std::vector<blink::mojom::DataElementPtr>()));
-
-  EXPECT_EQ(1u, bad_messages_.size());
-
-  registry_.FlushForTesting();
-  EXPECT_FALSE(registry_.is_connected());
-
-  blob.FlushForTesting();
-  EXPECT_FALSE(blob.is_connected());
-  EXPECT_EQ(0u, BlobsUnderConstruction());
-}
-
 TEST_F(BlobRegistryImplTest, Register_EmptyBlob) {
-  const std::string kId = "id";
+  std::string id;
   const std::string kContentType = "content/type";
   const std::string kContentDisposition = "disposition";
 
   mojo::Remote<blink::mojom::Blob> blob;
-  EXPECT_TRUE(registry_->Register(blob.BindNewPipeAndPassReceiver(), kId,
-                                  kContentType, kContentDisposition,
-                                  std::vector<blink::mojom::DataElementPtr>()));
+  EXPECT_TRUE(Register(blob.BindNewPipeAndPassReceiver(), id, kContentType,
+                       kContentDisposition,
+                       std::vector<blink::mojom::DataElementPtr>()));
 
   EXPECT_TRUE(bad_messages_.empty());
 
-  EXPECT_EQ(kId, UUIDFromBlob(blob.get()));
-  EXPECT_TRUE(context_->registry().HasEntry(kId));
-  std::unique_ptr<BlobDataHandle> handle = context_->GetBlobDataFromUUID(kId);
+  EXPECT_EQ(id, UUIDFromBlob(blob.get()));
+  EXPECT_TRUE(context_->registry().HasEntry(id));
+  std::unique_ptr<BlobDataHandle> handle = context_->GetBlobDataFromUUID(id);
   EXPECT_EQ(kContentType, handle->content_type());
   EXPECT_EQ(kContentDisposition, handle->content_disposition());
   EXPECT_EQ(0u, handle->size());
@@ -263,7 +227,7 @@
 }
 
 TEST_F(BlobRegistryImplTest, Register_EmptyBytesBlob) {
-  const std::string kId = "id";
+  std::string id;
   const std::string kContentType = "content/type";
   const std::string kContentDisposition = "disposition";
 
@@ -273,15 +237,14 @@
           0, std::nullopt, CreateBytesProvider(""))));
 
   mojo::Remote<blink::mojom::Blob> blob;
-  EXPECT_TRUE(registry_->Register(blob.BindNewPipeAndPassReceiver(), kId,
-                                  kContentType, kContentDisposition,
-                                  std::move(elements)));
+  EXPECT_TRUE(Register(blob.BindNewPipeAndPassReceiver(), id, kContentType,
+                       kContentDisposition, std::move(elements)));
 
   EXPECT_TRUE(bad_messages_.empty());
 
-  EXPECT_EQ(kId, UUIDFromBlob(blob.get()));
-  EXPECT_TRUE(context_->registry().HasEntry(kId));
-  std::unique_ptr<BlobDataHandle> handle = context_->GetBlobDataFromUUID(kId);
+  EXPECT_EQ(id, UUIDFromBlob(blob.get()));
+  EXPECT_TRUE(context_->registry().HasEntry(id));
+  std::unique_ptr<BlobDataHandle> handle = context_->GetBlobDataFromUUID(id);
   EXPECT_EQ(kContentType, handle->content_type());
   EXPECT_EQ(kContentDisposition, handle->content_disposition());
   EXPECT_EQ(0u, handle->size());
@@ -294,7 +257,7 @@
 }
 
 TEST_F(BlobRegistryImplTest, Register_ReferencedBlobClosedPipe) {
-  const std::string kId = "id";
+  std::string id;
 
   std::vector<blink::mojom::DataElementPtr> elements;
   mojo::PendingRemote<blink::mojom::Blob> referenced_blob_remote;
@@ -304,11 +267,11 @@
           std::move(referenced_blob_remote), 0, 16)));
 
   mojo::PendingRemote<blink::mojom::Blob> blob;
-  EXPECT_TRUE(registry_->Register(blob.InitWithNewPipeAndPassReceiver(), kId,
-                                  "", "", std::move(elements)));
+  EXPECT_TRUE(Register(blob.InitWithNewPipeAndPassReceiver(), id, "", "",
+                       std::move(elements)));
   EXPECT_TRUE(bad_messages_.empty());
 
-  std::unique_ptr<BlobDataHandle> handle = context_->GetBlobDataFromUUID(kId);
+  std::unique_ptr<BlobDataHandle> handle = context_->GetBlobDataFromUUID(id);
   WaitForBlobCompletion(handle.get());
 
   EXPECT_TRUE(handle->IsBroken());
@@ -317,7 +280,7 @@
 }
 
 TEST_F(BlobRegistryImplTest, Register_SelfReference) {
-  const std::string kId = "id";
+  std::string id;
 
   mojo::PendingRemote<blink::mojom::Blob> blob_remote;
   mojo::PendingReceiver<blink::mojom::Blob> receiver =
@@ -327,11 +290,10 @@
   elements.push_back(blink::mojom::DataElement::NewBlob(
       blink::mojom::DataElementBlob::New(std::move(blob_remote), 0, 16)));
 
-  EXPECT_TRUE(registry_->Register(std::move(receiver), kId, "", "",
-                                  std::move(elements)));
+  EXPECT_TRUE(Register(std::move(receiver), id, "", "", std::move(elements)));
   EXPECT_TRUE(bad_messages_.empty());
 
-  std::unique_ptr<BlobDataHandle> handle = context_->GetBlobDataFromUUID(kId);
+  std::unique_ptr<BlobDataHandle> handle = context_->GetBlobDataFromUUID(id);
   WaitForBlobCompletion(handle.get());
... (file diff truncated)
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/storage/browser/blob/blob_registry_impl_unittest.cc b/storage/browser/blob/blob_registry_impl_unittest.cc
index 208fd1e..b4b4666 100644
--- a/storage/browser/blob/blob_registry_impl_unittest.cc
+++ b/storage/browser/blob/blob_registry_impl_unittest.cc
@@ -23,6 +23,7 @@
 #include "base/task/thread_pool.h"
 #include "base/test/bind.h"
 #include "base/test/task_environment.h"
+#include "base/test/test_future.h"
 #include "base/threading/thread_restrictions.h"
 #include "mojo/public/cpp/bindings/associated_receiver.h"
 #include "mojo/public/cpp/bindings/associated_remote.h"
@@ -118,17 +119,9 @@
   }
 
   std::string UUIDFromBlob(blink::mojom::Blob* blob) {
-    base::RunLoop loop;
-    std::string received_uuid;
-    blob->GetInternalUUID(base::BindOnce(
-        [](base::OnceClosure quit_closure, std::string* uuid_out,
-           const std::string& uuid) {
-          *uuid_out = uuid;
-          std::move(quit_closure).Run();
-        },
-        loop.QuitClosure(), &received_uuid));
-    loop.Run();
-    return received_uuid;
+    base::test::TestFuture<std::string> future;
+    blob->GetInternalUUID(future.GetCallback<const std::string&>());
+    return future.Get();
   }
 
   void OnBadMessage(const std::string& error) {
@@ -138,9 +131,7 @@
   void WaitForBlobCompletion(BlobDataHandle* blob_handle) {
     base::RunLoop loop;
     blob_handle->RunOnConstructionComplete(
-        base::BindOnce([](base::OnceClosure closure,
-                          BlobStatus status) { std::move(closure).Run(); },
-                       loop.QuitClosure()));
+        base::IgnoreArgs<BlobStatus>(loop.QuitClosure()));
     loop.Run();
   }
 
@@ -183,6 +174,15 @@
     return registry_impl_->BlobsBeingStreamedForTesting();
   }
 
+  bool Register(mojo::PendingReceiver<blink::mojom::Blob> blob,
+                std::string& uuid,
+                const std::string& content_type,
+                const std::string& content_disposition,
+                std::vector<blink::mojom::DataElementPtr> elements) {
+    return registry_->Register(std::move(blob), content_type,
+                               content_disposition, std::move(elements), &uuid);
+  }
+
  protected:
   base::ScopedTempDir data_dir_;
   base::test::TaskEnvironment task_environment_;
@@ -200,57 +200,21 @@
   std::vector<std::string> bad_messages_;
 };
 
-TEST_F(BlobRegistryImplTest, Register_EmptyUUID) {
-  mojo::Remote<blink::mojom::Blob> blob;
-  EXPECT_FALSE(
-      registry_->Register(blob.BindNewPipeAndPassReceiver(), "", "", "",
-                          std::vector<blink::mojom::DataElementPtr>()));
-
-  EXPECT_EQ(1u, bad_messages_.size());
-
-  registry_.FlushForTesting();
-  EXPECT_FALSE(registry_.is_connected());
-
-  blob.FlushForTesting();
-  EXPECT_FALSE(blob.is_connected());
-  EXPECT_EQ(0u, BlobsUnderConstruction());
-}
-
-TEST_F(BlobRegistryImplTest, Register_ExistingUUID) {
-  const std::string kId = "id";
-  std::unique_ptr<BlobDataHandle> handle =
-      CreateBlobFromString(kId, "hello world");
-
-  mojo::Remote<blink::mojom::Blob> blob;
-  EXPECT_FALSE(
-      registry_->Register(blob.BindNewPipeAndPassReceiver(), kId, "", "",
-                          std::vector<blink::mojom::DataElementPtr>()));
-
-  EXPECT_EQ(1u, bad_messages_.size());
-
-  registry_.FlushForTesting();
-  EXPECT_FALSE(registry_.is_connected());
-
-  blob.FlushForTesting();
-  EXPECT_FALSE(blob.is_connected());
-  EXPECT_EQ(0u, BlobsUnderConstruction());
-}
-
 TEST_F(BlobRegistryImplTest, Register_EmptyBlob) {
-  const std::string kId = "id";
+  std::string id;
   const std::string kContentType = "content/type";
   const std::string kContentDisposition = "disposition";
 
   mojo::Remote<blink::mojom::Blob> blob;
-  EXPECT_TRUE(registry_->Register(blob.BindNewPipeAndPassReceiver(), kId,
-                                  kContentType, kContentDisposition,
-                                  std::vector<blink::mojom::DataElementPtr>()));
+  EXPECT_TRUE(Register(blob.BindNewPipeAndPassReceiver(), id, kContentType,
+                       kContentDisposition,
+                       std::vector<blink::mojom::DataElementPtr>()));
 
   EXPECT_TRUE(bad_messages_.empty());
 
-  EXPECT_EQ(kId, UUIDFromBlob(blob.get()));
-  EXPECT_TRUE(context_->registry().HasEntry(kId));
-  std::unique_ptr<BlobDataHandle> handle = context_->GetBlobDataFromUUID(kId);
+  EXPECT_EQ(id, UUIDFromBlob(blob.get()));
+  EXPECT_TRUE(context_->registry().HasEntry(id));
+  std::unique_ptr<BlobDataHandle> handle = context_->GetBlobDataFromUUID(id);
   EXPECT_EQ(kContentType, handle->content_type());
   EXPECT_EQ(kContentDisposition, handle->content_disposition());
   EXPECT_EQ(0u, handle->size());
@@ -263,7 +227,7 @@
 }
 
 TEST_F(BlobRegistryImplTest, Register_EmptyBytesBlob) {
-  const std::string kId = "id";
+  std::string id;
   const std::string kContentType = "content/type";
   const std::string kContentDisposition = "disposition";
 
@@ -273,15 +237,14 @@
           0, std::nullopt, CreateBytesProvider(""))));
 
   mojo::Remote<blink::mojom::Blob> blob;
-  EXPECT_TRUE(registry_->Register(blob.BindNewPipeAndPassReceiver(), kId,
-                                  kContentType, kContentDisposition,
-                                  std::move(elements)));
+  EXPECT_TRUE(Register(blob.BindNewPipeAndPassReceiver(), id, kContentType,
+                       kContentDisposition, std::move(elements)));
 
   EXPECT_TRUE(bad_messages_.empty());
 
-  EXPECT_EQ(kId, UUIDFromBlob(blob.get()));
-  EXPECT_TRUE(context_->registry().HasEntry(kId));
-  std::unique_ptr<BlobDataHandle> handle = context_->GetBlobDataFromUUID(kId);
+  EXPECT_EQ(id, UUIDFromBlob(blob.get()));
+  EXPECT_TRUE(context_->registry().HasEntry(id));
+  std::unique_ptr<BlobDataHandle> handle = context_->GetBlobDataFromUUID(id);
   EXPECT_EQ(kContentType, handle->content_type());
   EXPECT_EQ(kContentDisposition, handle->content_disposition());
   EXPECT_EQ(0u, handle->size());
@@ -294,7 +257,7 @@
 }
 
 TEST_F(BlobRegistryImplTest, Register_ReferencedBlobClosedPipe) {
-  const std::string kId = "id";
+  std::string id;
 
   std::vector<blink::mojom::DataElementPtr> elements;
   mojo::PendingRemote<blink::mojom::Blob> referenced_blob_remote;
@@ -304,11 +267,11 @@
           std::move(referenced_blob_remote), 0, 16)));
 
   mojo::PendingRemote<blink::mojom::Blob> blob;
-  EXPECT_TRUE(registry_->Register(blob.InitWithNewPipeAndPassReceiver(), kId,
-                                  "", "", std::move(elements)));
+  EXPECT_TRUE(Register(blob.InitWithNewPipeAndPassReceiver(), id, "", "",
+                       std::move(elements)));
   EXPECT_TRUE(bad_messages_.empty());
 
-  std::unique_ptr<BlobDataHandle> handle = context_->GetBlobDataFromUUID(kId);
+  std::unique_ptr<BlobDataHandle> handle = context_->GetBlobDataFromUUID(id);
   WaitForBlobCompletion(handle.get());
 
   EXPECT_TRUE(handle->IsBroken());
@@ -317,7 +280,7 @@
 }
 
 TEST_F(BlobRegistryImplTest, Register_SelfReference) {
-  const std::string kId = "id";
+  std::string id;
 
   mojo::PendingRemote<blink::mojom::Blob> blob_remote;
   mojo::PendingReceiver<blink::mojom::Blob> receiver =
@@ -327,11 +290,10 @@
   elements.push_back(blink::mojom::DataElement::NewBlob(
       blink::mojom::DataElementBlob::New(std::move(blob_remote), 0, 16)));
 
-  EXPECT_TRUE(registry_->Register(std::move(receiver), kId, "", "",
-                                  std::move(elements)));
+  EXPECT_TRUE(Register(std::move(receiver), id, "", "", std::move(elements)));
   EXPECT_TRUE(bad_messages_.empty());
 
-  std::unique_ptr<BlobDataHandle> handle = context_->GetBlobDataFromUUID(kId);
+  std::unique_ptr<BlobDataHandle> handle = context_->GetBlobDataFromUUID(id);
   WaitForBlobCompletion(handle.get());
 
   EXPECT_TRUE(handle->IsBroken());
@@ -346,9 +308,9 @@
 }
 
 TEST_F(BlobRegistryImplTest, Register_CircularReference) {
-  const std::string kId1 = "id1";
-  const std::string kId2 = "id2";
-  const std::string kId3 = "id3";
+  std::string kId1;
+  std::string kId2;
+  std::string kId3;
 
   mojo::PendingRemote<blink::mojom::Blob> blob1_remote, blob2_remote,
       blob3_remote;
@@ -371,12 +333,12 @@
   elements3.push_back(blink::mojom::DataElement::NewBlob(
       blink::mojom::DataElementBlob::New(std::move(blob3_remote), 0, 16)));
 
-  EXPECT_TRUE(registry_->Register(std::move(blob_receiver1), kId1, "", "",
-                                  std::move(elements2)));
-  EXPECT_TRUE(registry_->Register(std::move(blob_receiver2), kId2, "", "",
-                                  std::move(elements3)));
-  EXPECT_TRUE(registry_->Register(std::move(blob_receiver3), kId3, "", "",
-                                  std::move(elements1)));
+  EXPECT_TRUE(
+      Register(std::move(blob_receiver1), kId1, "", "", std::move(elements2)));
+  EXPECT_TRUE(
+      Register(std::move(blob_receiver2), kId2, "", "", std::move(elements3)));
+  EXPECT_TRUE(
+      Register(std::move(blob_receiver3), kId3, "", "", std::move(elements1)));
   EXPECT_TRUE(bad_messages_.empty());
 
 #if DCHECK_IS_ON()
@@ -415,7 +377,7 @@
 }
 
 TEST_F(BlobRegistryImplTest, Register_NonExistentBlob) {
-  const std::string kId = "id";
+  std::string id;
 
   std::vector<blink::mojom::DataElementPtr> elements;
   mojo::PendingRemote<blink::mojom::Blob> referenced_blob_remote;
@@ -427,11 +389,11 @@
           std::move(referenced_blob_remote), 0, 16)));
 
   mojo::PendingRemote<blink::mojom::Blob> blob;
-  EXPECT_TRUE(registry_->Register(blob.InitWithNewPipeAndPassReceiver(), kId,
-                                  "", "", std::move(elements)));
+  EXPECT_TRUE(Register(blob.InitWithNewPipeAndPassReceiver(), id, "", "",
+                       std::move(elements)));
   EXPECT_TRUE(bad_messages_.empty());
 
-  std::unique_ptr<BlobDataHandle> handle = context_->GetBlobDataFromUUID(kId);
+  std::unique_ptr<BlobDataHandle> handle = context_->GetBlobDataFromUUID(id);
   WaitForBlobCompletion(handle.get());
 
   EXPECT_TRUE(handle->IsBroken());
@@ -453,7 +415,7 @@
   mojo::MakeSelfOwnedReceiver(std::make_unique<FakeBlob>(kId1),
                               blob1_remote.InitWithNewPipeAndPassReceiver());
 
-  const std::string kId2 = "id2";
+  std::string kId2;
   mojo::PendingRemote<blink::mojom::Blob> blob2_remote;
   mojo::PendingReceiver<blink::mojom::Blob> blob_receiver2 =
       blob2_remote.InitWithNewPipeAndPassReceiver();
@@ -467,11 +429,11 @@
       blink::mojom::DataElementBlob::New(std::move(blob2_remote), 0, 8)));
 
   mojo::PendingRemote<blink::mojom::Blob> final_blob;
-  const std::string kId3 = "id3";
-  EXPECT_TRUE(registry_->Register(final_blob.InitWithNewPipeAndPassReceiver(),
-                                  kId3, "", "", std::move(elements2)));
-  EXPECT_TRUE(registry_->Register(std::move(blob_receiver2), kId2, "", "",
-                                  std::move(elements1)));
+  std::string kId3;
+  EXPECT_TRUE(Register(final_blob.InitWithNewPipeAndPassReceiver(), kId3, "",
+                       "", std::move(elements2)));
+  EXPECT_TRUE(
+      Register(std::move(blob_receiver2), kId2, "", "", std::move(elements1)));
 
   // kId3 references kId2, kId2 reference kId1, kId1 is a simple string.
   std::unique_ptr<BlobDataHandle> handle2 = context_->GetBlobDataFromUUID(kId2);
@@ -515,9 +477,9 @@
           std::move(blob1_remote), 0, kBlob1Data.length())));
 
   mojo::PendingRemote<blink::mojom::Blob> final_blob;
-  const std::string kId2 = "id2";
-  EXPECT_TRUE(registry_->Register(final_blob.InitWithNewPipeAndPassReceiver(),
-                                  kId2, "", "", std::move(elements)));
+  std::string kId2;
+  EXPECT_TRUE(Register(final_blob.InitWithNewPipeAndPassReceiver(), kId2, "",
+                       "", std::move(elements)));
 
   // Run the runloop to make sure registration of blob kId2 gets far enough
   // before blob kId1 is populated.
@@ -545,7 +507,7 @@
 TEST_F(BlobRegistryImplTest, Register_UnreadableFile) {
   delegate_ptr_->can_read_file_result = false;
... (truncated)
Loading diff…

Original Bug Report

reported by xi...@anthropic.com

Heap-use-after-free in BlobStorageContext::FinishBuilding (browser process) reachable from renderer

Security Bug

VULNERABILITY DETAILS

Use-after-free in the BROWSER process (IO thread), storage/browser/blob, reachable from any renderer over blink.mojom.BlobRegistry / Blob / BytesProvider (+ discardable_memory.mojom.DiscardableSharedMemoryManager only to make a DataPipe allocation fail). In BlobStorageContext::FinishBuilding(entry) the release of a just-completed copy’s MemoryAllocation (blob_storage_context.cc, the set_memory_allocation(nullptr) / building_state_.reset() sites) runs ~MemoryAllocation -> BlobMemoryController::RevokeMemoryAllocation -> MaybeGrantPendingMemoryRequests() synchronously, which grants quota to an attacker’s pending blob X and starts its transport on the same stack; DataPipeTransportStrategy calls mojo::CreateDataPipe, and when that fails it reports ERR_OUT_OF_MEMORY synchronously -> CancelBuildingBlob(X) -> teardown of X and of blobs depending on it, which frees ShareableBlobDataItem records (48-byte slots), a BlobEntry (144), its BuildingState (192) and item vectors that the outer FinishBuilding frame still holds as a raw BlobEntry* parameter, range-for references and cached iterators; the freed quota is immediately re-granted to another attacker blob whose 40/128-byte element copies re-occupy the freed slots (same thread, PartitionAlloc thread-cache LIFO). FinishBuilding then continues on freed/re-occupied memory (first stale use at :531 / :557 NotifyMemoryItemsUsed, then ShareableBlobDataItem::set_item). Making CreateDataPipe fail needs no bug: a renderer can drive the browser to base::SharedMemorySecurityPolicy’s 32 GiB per-process mapping cap with DiscardableSharedMemory (any OS) or to RLIMIT_NOFILE (Linux). ASan: heap-use-after-free READ of size 8 on Chrome_IOThread in BlobMemoryController::NotifyMemoryItemsUsed <- BlobStorageContext::FinishBuilding, 4/4. On stock Chrome 153.0.8010.37 macOS this was exploited (8 triggers per run) to leak browser heap pointers, obtain a verified controlled re-occupation, read arbitrary browser memory (image base) and execute posix_spawnp("/bin/sh","-c",CMD) from the browser IO thread with the browser surviving — 3/3 runs (umbrella report). Present since at least M64 by source history. Full analysis and evidence follow below the template.

VERSION

Chrome Version: 153.0.8010.37 (Official Build) stable, arm64 (exploited); Chrome for Testing 152.0.7977.64/.82/.83 linux64 (PoC + exploit); Chromium 155.0.8038.0 ASan (d81900a3939b6, 2026-09-01) for the ASan report.
Operating System: macOS 26.6.2 (25G83), Apple M4 Max; Linux x86-64.

REPRODUCTION CASE

Threat model: compromised renderer. Stand-in per Chrome’s guidance: start Chrome with --enable-blink-features=MojoJS,MojoJSTest, Chrome sandbox ON, and load the attached page from 127.0.0.1 (server.py serves the page and the bindings bundle it imports). Attached as individual files:

  • poc.html — the trigger. Query parameters (edge=shm|fd, refill=0|1, fakeptr=0x…, tuning knobs) are documented in its header comment. One trigger ⇒ one use-after-free.
  • mojo_bindings.bundle.js — the generated blink.mojom (BlobRegistry, Blob, BytesProvider, BlobReaderClient) and discardable_memory.mojom JS bindings the page imports, bundled into one file from out/<build>/gen of the matching build.
  • server.py, run.sh — tiny static server and a launcher that prints the exact Chrome switch list into the log.
  • README.md — how to run against an ASan build and against stock Chrome for Testing.
  • EVIDENCE.txt — exact command lines, ASan reports (alloc/free/use stacks), release-build crash states, hit-rate table.

Canonical runs:

# ASan Chromium build
CHROME=out/asan/chrome ASAN_SYMBOLIZER=third_party/llvm-build/Release+Asserts/bin/llvm-symbolizer ./run.sh asan_shm_1
# stock Chrome for Testing 152.0.7977.83 (release): re-occupy the freed BlobEntry so the browser faults at the planted pointer
CHROME=<cft-dir>/chrome ./run.sh cft83_refill_1 "refill=1"

FOR CRASHES, PLEASE INCLUDE THE FOLLOWING ADDITIONAL INFORMATION

Type of crash: BROWSER process (Chrome_IOThread).
Crash State: ASan (full stacks in EVIDENCE.txt):

ERROR: AddressSanitizer: heap-use-after-free READ of size 8 ... thread T10 (Chrome_IOThread)
  #0 storage::BlobMemoryController::NotifyMemoryItemsUsed(...)
  #1 storage::BlobStorageContext::FinishBuilding(storage::BlobEntry*) storage/browser/blob/blob_storage_context.cc:557
  freed by thread T10 (Chrome_IOThread): ... BlobStorageRegistry::DeleteEntry <- ~BuildingState <- CancelBuildingBlob teardown
  previously allocated by thread T10: ... BlobRegistryImpl::Register

Release CfT/Chrome: SIGSEGV in BlobStorageContext::FinishBuilding / BlobMemoryController::NotifyMemoryItemsUsed with the faulting address = renderer-planted pointer (registers in EVIDENCE.txt; macOS .ips available).
Client ID (if relevant): n/a (local builds / crash reporting not uploaded).

CREDIT INFORMATION

Reporter credit: Xinyang Ge
Assisted-by: Claude

This is the third of three related reports (stage 3 = browser sandbox escape of a full chain on stable 153.0.8010.37; umbrella report filed separately). Issue numbers will be cross-referenced once assigned.


ADDITIONAL DETAILS

Summary

blink.mojom.BlobRegistry is bound for every renderer process (content/browser/renderer_host/render_process_host_impl_receiver_bindings.cc:283-285), so any frame or worker can register blobs whose elements reference other, still-building blobs; the browser keeps a BlobDataHandle to every referenced blob in the referencing blob’s BuildingState::dependent_blobs (blob_storage_context.cc:257-279). When such a blob A finishes, BlobStorageContext::FinishBuilding(A) (blob_storage_context.cc:505-569, browser process, Chrome_IOThread) contains two statements that destroy a BlobMemoryController::MemoryAllocation: :529 copy.dest_item->set_memory_allocation(nullptr) inside the copies loop (taken when a copy source was paged to disk), and :554 entry->building_state_.reset() (destroys A’s dependent_blobs handles; if one was the last handle of a populated blob, that blob’s items and their allocations die). ~MemoryAllocation → RevokeMemoryAllocation → MaybeGrantPendingMemoryRequests() (blob_memory_controller.cc:331-334, :1117-1129, :853-864 @153.0.8010.25) then synchronously grants every queued memory-quota request that now fits and runs its continuation — continuations that belong to other blobs. If the granted blob X transports its bytes over a data pipe, the continuation is OnEnoughSpaceForTransport(X) → RequestTransport → BlobUnderConstruction::OnReadyForTransport → DataPipeTransportStrategy::BeginTransport → RequestDataPipe, which calls mojo::CreateDataPipe() on the same IO-thread stack; if pipe creation fails, the strategy runs its result callback synchronously with BlobStatus::ERR_OUT_OF_MEMORY (blob_transport_strategy.cc:166-172 @153) → BlobUnderConstruction::TransportComplete → CancelBuildingBlob(X) → CancelBuildingBlobInternal(X) → FinishBuilding(X) → ~BuildingState(X) → ~BlobDataHandle(A) → DecrementBlobRefCount(A) → refcount 0 → registry_.DeleteEntry(A) → ~BlobEntry(A) — while FinishBuilding(A) is still on the stack two FinishBuilding frames up, holding BlobEntry* entry (plain pointer parameter) and const ItemCopyEntry& copy (range-for reference into entry->building_state_->copies). Freed under it: the BlobEntry (sizeof 128 ⇒ PartitionAlloc 144-byte slot with BRP’s in-slot metadata), and in the :529 variant also A’s BuildingState (184 ⇒ 192), the std::vector<ItemCopyEntry> backing that copy refers into (24·capacity bytes) and A’s copy-destination ShareableBlobDataItems (40 ⇒ 48-byte slot). When the inner frames unwind, FinishBuilding(A) continues on the freed objects: :531 reads copy.source_item from the freed vector backing (first stale use in the :529 variant), :537 copy.dest_item->set_item(new_item) writes a fresh BlobDataItem* into — and Release()s the old pointer read from — the freed destination ShareableBlobDataItem, :545/:548 write into the freed item/entry, :557 NotifyMemoryItemsUsed(entry->items()) iterates items_ read out of the freed BlobEntry (first stale use in the :554 variant) and stores the pointers it finds into BlobMemoryController::populated_memory_items_, :562 reads entry->status(). None of the holders is raw_ptr/raw_ref, so BackupRefPtr/MiraclePtr does not intervene. The same synchronous grant loop that frees A also lets the renderer re-occupy the freed slots inside the free→use window: the next quota request it grants can be a copy-only blob Y of the renderer’s, whose FinishBuilding(Y) → BlobDataItem::PopulateBytes(n) is operator new(n) + memcpy of n renderer-chosen bytes on the same thread (PartitionAlloc per-thread cache, LIFO). A renderer that can send arbitrary but individually valid Mojo messages (MojoJS after a renderer compromise) therefore controls both the free and the contents of the freed memory at the moment of use, turning this into (i) a self-leak of a browser heap address, (ii) a scoped_refptr Release() on an attacker-chosen address, (iii) an arbitrary browser-memory read via blob paging-to-disk of fake items, and (iv) a destructor/callback dispatch on a fake BlobDataItem ⇒ posix_spawnp("/bin/sh","-c",CMD) on the browser IO thread, with the browser surviving. Demonstrated on stock Google Chrome 153.0.8010.37 macOS arm64 (everyday profile, other tabs open): 43/43 consecutive triggers without an unintended browser crash once the re-occupation depths were tuned (24/24 in the three end-to-end runs of the final kit); full chain to Calculator 3/3 (see umbrella report).

Making the browser’s CreateDataPipe() fail deterministically needs no bug: base::SharedMemorySecurityPolicy caps the total size of shared-memory mappings per process at 32 GiB on 64-bit (base/memory/shared_memory_security_policy.cc:27, refusal at :76 in AcquireReservationForMapping, reached from PlatformSharedMemoryRegion::MapAt, platform_shared_memory_region.cc:71), and a renderer can make the browser map ~32 GiB of DiscardableSharedMemory segments (address space only; one header page committed per segment) through discardable_memory.mojom.DiscardableSharedMemoryManager.AllocateLockedDiscardableSharedMemory (also bound per renderer process, render_process_host_impl_receiver_bindings.cc:414-419; components/discardable_memory/service/discardable_shared_memory_manager.cc :472-509), shaped to leave ~8 MiB of budget, so ipcz DataPipe::CreatePair — which creates and maps the pipe’s ring buffer in the creating process (mojo/core/ipcz_driver/data_pipe.cc:145-175) — fails for X’s 10 MiB pipe (MOJO_RESULT_RESOURCE_EXHAUSTED) while every mapping ≤ 8 MiB still works. The cap is held only around each trigger (0.6 s measured per trigger in the exploit; fill ≈45 IPCs, release immediately after the cancelled transport is observed). (On Linux the same failure is independently reachable by driving the browser to its RLIMIT_NOFILE — 8192 after the browser’s own IncreaseFdLimitTo(8192) at startup (content/browser/browser_main_loop.cc:590 @153 [152 :583]; it only raises a smaller inherited soft limit, so a browser started with a larger one keeps it and this lever is then out of reach) — with ~8 k browser-created blob transport pipes; executed, see Reproduction.) This is NOT one of the issues we reported on 2026-09-04.

Affected

  • Component: Blink>Storage>BlobStorage (storage/browser/blob), process: BROWSER (Chrome_IOThread). Also relevant: Internals>Mojo (DataPipe maps its buffer in the creating process), Internals>Core (SharedMemorySecurityPolicy budget exhaustible by one renderer = an attacker-reachable failure injector for every browser-side shared-memory mapping).
  • Confirmed by execution: Google Chrome 153.0.8010.37 (Official Build) (arm64), macOS 26.6.2, everyday profile with other tabs open (exploit kit; 43/43 triggers, 3/3 end-to-end).
  • Confirmed by execution with the attached minimal PoC (2026-09-10, Linux x86-64, headless, Chrome sandbox on): Google Chrome for Testing 152.0.7977.83 — freed BlobEntry re-occupied with renderer bytes ⇒ browser SEGV_MAPERR at the page-chosen pointer 8/8 (mapping-budget trigger 5/5 + a different planted pointer 1/1 + fd-limit trigger 2/2, one of them at the stock 8192 limit), :529 variant without re-occupation ⇒ browser GPF on PartitionAlloc’s free-poison 2/2; CfT 152.0.7977.82 1/1, 152.0.7977.64 1/1. Chromium 155.0.8038.0 (trunk of 2026-09-01) ASan builds, with and without dcheck_always_on: heap-use-after-free in the browser process on Chrome_IOThread 11/11 (:554 variant 9/9, :529 variant 2/2); reports attached. Earlier exploit-chain batteries on CfT 152.0.7977.83 Linux: 33/37 and 23/23 end-to-end execvp in the browser (see umbrella).
  • Source: storage/browser/blob/blob_storage_context.cc is byte-identical at 152.0.7977.64/.79, 153.0.8010.25 and 155.0.8038.0 (git blob 892320c8e2), as are blob_registry_impl.cc, blob_entry.h/.cc, blob_data_handle.cc, shareable_blob_data_item.h/.cc, blob_data_item.h/.cc and blob_storage_registry.h; blob_memory_controller.cc at 153 differs from 152 only in CreateEmptyFiles’ open flags (−4 lines; functions cited here are at 152 line +4) and from trunk only cosmetically; blob_transport_strategy.cc’s DataPipeTransportStrategy is unchanged 152→153→trunk (153 rewrote only FileTransportStrategy, +4 line offset). Line numbers below are for 153.0.8010.25 with 152.0.7977.64 / trunk in brackets where they differ.
  • First affected (from git log/git blame, not bisected by building): ~MemoryAllocation → RevokeMemoryAllocation → MaybeGrantPendingMemoryRequests() running queued continuations synchronously dates from the controller’s introduction, 0b0e36d9a68b3 “[BlobStorage] BlobMemoryController & tests” (2016-10-21, 56.0.2898.0); FinishBuilding()’s use of entry after building_state_.reset() is cf848a6957fb4 (2016-11-17, 56.0.2924.0) and the kFile copy branch with set_memory_allocation(nullptr) inside the loop is 07725e0f2bd75 (2016-12-05, 57.0.2943.0); the renderer-reachable synchronous failure continuation (DataPipeTransportStrategy::RequestDataPipe → ERR_OUT_OF_MEMORY → TransportComplete → CancelBuildingBlob) is 2d263e6d8f564 “Implement byte transportation for mojo blob service” (2017-07-17, 61.0.3160.0), on by default since df51989616e17 “Turn mojo blobs on by default” (2017-10-26, 64.0.3251.0). ⇒ The re-entrancy exists since M56; the renderer-triggerable free-under-caller path since M64; every release since is believed affected; we executed 152, 153 and 155. No commit in the last 12 months of storage/browser/blob/ history in our checkout touches FinishBuilding, MaybeGrantPendingMemoryRequests or DecrementBlobRefCount ownership. Status at trunk 155.0.8038.0: present.
  • Not reachable: profiles without blob disk paging (incognito / in-memory BlobStorageContext): file_paging_enabled_ is false, so CanReserveQuota() (blob_memory_controller.cc:634-639) refuses an over-budget request instead of queueing it and the pending quota queue the bug needs never forms. ChromeOS/Android: same code, smaller memory budgets (blob_memory_controller.cc:97-104) — not executed. Windows: same code by source (64-bit 2 GiB budget branch; both interfaces bound the same way) — not executed. The 153 branch additionally has a second synchronous CreateDataPipe-failure site, in FileTransportStrategy::NextElementOrDone() (blob_transport_strategy.cc:473-478 @153.0.8010.25, reached synchronously from FileTransportStrategy::BeginTransport() :329; added by 966e352a7bb47 at 153.0.7993.0, removed by 0c52ba991c520 at 154.0.8026.0, i.e. before the 154 release branch — 152, 154.0.8037.x and trunk have only the DataPipeTransportStrategy site) — relevant to the fix, not used as a trigger.

Root cause

One sentence. BlobMemoryController returns memory quota from a destructor (~MemoryAllocation → RevokeMemoryAllocation) and, from inside that destructor, synchronously grants every queued quota request that now fits and runs its continuation (MaybeGrantPendingMemoryRequests → RunDoneCallback(true)); those continuations belong to other blobs and re-enter BlobStorageContext; one of them — a data-pipe transport whose CreateDataPipe() fails — cancels its blob synchronously, which destroys that blob’s BlobDataHandles to the blobs it references; when one of those is the last handle of the blob whose FinishBuilding() is executing further up the stack, FinishBuilding()’s BlobEntry* entry and the ItemCopyEntry& it is iterating dangle and are used.

Code (153.0.8010.25 = 152 = trunk for this file), storage/browser/blob/blob_storage_context.cc:

505 void BlobStorageContext::FinishBuilding(BlobEntry* entry) {          // entry: plain pointer into BlobStorageRegistry::blob_map_
...                                                                       //        (unordered_map<string, unique_ptr<BlobEntry>>, blob_storage_registry.h:57)
510   if (BlobStatusIsPending(entry->status_)) {
511     for (const ItemCopyEntry& copy : entry->building_state_->copies) {  // copy: reference into a std::vector member of *entry->building_state_
...       (kBytes case :517-524: copy.dest_item->item()->PopulateBytes(src) = operator new(n) + memcpy — this is also the refill gadget)
525         case BlobDataItem::Type::kFile: {
528           if (dest_type == BlobDataItem::Type::kBytesDescription)
529             copy.dest_item->set_memory_allocation(nullptr);           // (a) ~MemoryAllocation ⇒ other blobs' continuations run HERE;
                                                                          //     may delete *entry, its BuildingState, the copies vector, dest items
531           const auto& source_item = copy.source_item->item();         // (a) READ of copy.source_item out of the freed vector backing
532           scoped_refptr<BlobDataItem> new_item = BlobDataItem::CreateFile(
533               source_item->path(),
534               source_item->offset() + copy.source_item_offset, dest_size,
535               source_item->expected_modification_time(), source_item->file_ref_,
536               source_item->file_access_);                              //     (allocates a fresh BlobDataItem N, sizeof 0x330 ⇒ 832 slot)
537           copy.dest_item->set_item(std::move(new_item));              // (a) 8-byte WRITE of N at dest+0x18 and Release() of the pointer
538           break;                                                      //     previously stored there — dest read from freed backing, *dest freed
...
545       copy.dest_item->set_state(ShareableBlobDataItem::POPULATED_WITH_QUOTA);  // (a) 4-byte WRITE at dest+0x10
546     }
548     entry->set_status(BlobStatus::DONE);                              // (a) 4-byte WRITE (0xC8) at entry+0
549   }
551   std::vector<BlobStatusCallback> callbacks;
552   if (entry->building_state_.get()) {                                 // (a) READ entry+0x78
553     std::swap(callbacks, entry->building_state_->build_completion_callbacks);
554     entry->building_state_.reset();                                   // (b) ~BuildingState ⇒ ~BlobDataHandle(P) ⇒ … ~MemoryAllocation ⇒
555   }                                                                   //     other blobs' continuations run HERE; may delete *entry
557   memory_controller_.NotifyMemoryItemsUsed(entry->items());           // (b) READ of items_ {begin,end} at entry+0x40 from the freed BlobEntry;
                                                                          //     each pointer found is dereferenced and stored RAW in populated_memory_items_
559   auto runner = base::SingleThreadTaskRunner::GetCurrentDefault();   //     (blob_memory_controller.cc:740-759, Put() at :755 [152 :759, trunk :761])
560   for (auto& callback : callbacks)
561     runner->PostTask(FROM_HERE,
562                      base::BindOnce(std::move(callback), entry->status())); // (b) READ entry+0

storage/browser/blob/blob_memory_controller.cc @153.0.8010.25 [152.0.7977.64 / trunk]:

331 MemoryAllocation::~MemoryAllocation() {                              // [152 :335, trunk :335]
332   if (controller_)
333     controller_->RevokeMemoryAllocation(item_id_, length_);
334 }
357   void RunDoneCallback(bool success) {                               // MemoryQuotaAllocationTask [152/trunk :361]
360     if (success)
361       controller_->GrantMemoryAllocations(&pending_items_, allocation_size_);
362     std::move(done_callback_).Run(success);                           // = BlobStorageContext::OnEnoughSpaceForTransport / …ForCopies — synchronously
363   }
853 void BlobMemoryController::MaybeGrantPendingMemoryRequests() {      // [152 :857, trunk :860]
854   while (!pending_memory_quota_tasks_.empty() &&
855          limits_.max_blob_in_memory_space - blob_memory_used_ >=
856              pending_memory_quota_tasks_.front()->allocation_size()) {
857     std::unique_ptr<MemoryQuotaAllocationTask> memory_task = std::move(pending_memory_quota_tasks_.front());
859     pending_memory_quota_tasks_.pop_front();
861     memory_task->RunDoneCallback(true);                               // ANOTHER blob's continuation  [152 :865, trunk :868]
862   }
1117 void BlobMemoryController::RevokeMemoryAllocation(uint64_t item_id, size_t length) {   // [152 :1121, trunk :1126]
1120   blob_memory_used_ -= length;
 ...   (populated_memory_items_ bookkeeping)
1128   MaybeGrantPendingMemoryRequests();                                // [152 :1132, trunk :1137]
1129 }

storage/browser/blob/blob_transport_strategy.cc, DataPipeTransportStrategy::RequestDataPipe @153 :150-182 [152/trunk :146-178]:

166     MojoResult result =
167         CreateDataPipe(&options, producer_handle, consumer_handle_);    // capacity = min(element size, max_shared_memory_size = 10 MiB)
168     if (result != MOJO_RESULT_OK) {
169       DVLOG(1) << "Unable to create data pipe for blob transfer.";
170       std::move(result_callback_).Run(BlobStatus::ERR_OUT_OF_MEMORY); // synchronous; = BlobUnderConstruction::TransportComplete  [152/trunk :166]
171       return;                                                         //   ⇒ blob_registry_impl.cc:461 context()->CancelBuildingBlob(uuid(), result)
172     }

Actors (all registered by one renderer through BlobRegistry::Register; “slice” = a DataElementBlob naming a renderer-implemented blink.mojom.Blob that answers GetInternalUUID with the target’s UUID; sizes are set against the measured available blob memory so that exactly one request pends):

  • P: small bytes blob (4 KiB in the exploit; 250 KiB = max_ipc_memory_size in the attached PoC, where the return of its own quota is the variant-(b) trigger) transported by BytesProvider.RequestAsReply (≤ 250 KiB ⇒ reply strategy, blob_memory_controller.cc:623-624), which the renderer withholds (answering it is the trigger).
  • D6: 6 MiB populated blob, paged to disk by the browser after A was registered (the renderer appends one request just above the available quota ⇒ MaybeScheduleEvictionUntilSystemHealthy pages the LRU-oldest populated items); S2, Sr: small populated renderer blobs (copy sources; Sr holds the refill images).
  • A: [P slice(1,40)] + (NPRE−1)×[S2 slice(40)] + [D6 slice(200 KiB)] — waits on P; each element is an ItemCopyEntry with a 40-byte or 200 KiB copy-destination ShareableBlobDataItem whose memory quota is granted at registration; the D6 copy is last in copies.
  • Z: 32 MiB bytes blob, quota granted, never fed (holds 32 MiB of accounting, no RAM).
  • X: [10 MiB bytes (data-pipe transport), Z, A] (whole-blob references) — registered when available quota ∈ [10 MiB−200 KiB+60 KiB, +64 KiB), so its transport-quota request pends at the front of the queue; BlobDataBuilder::dependent_blobs() is a std::set<std::string>, and Z’s UUID is spelled 0Z… < A…, so X’s dependent_blobs vector is [Z, A] and is destroyed A first, then Z. The renderer then closes its own Blob handles to A and Z ⇒ X holds their last handles.
  • Y (and Y2): copy-only blobs [S2 slice(≈1.25 MiB), 36×Sr slice(40), 3×Sr slice(128)] (+ 12×Sr slice(128)) queued behind X — one quota task each, sized to be grantable only by Z’s 32 MiB; B: a 100 MiB request queued last as a guard (never fits, so the grant loop stops there).
  • (capture rounds only) A′ = [W slice(0,824), P slice] registered right after A, so FinishBuilding(P) posts OnDependentBlobFinished for A and then A′ as two consecutive IO-thread tasks.
  • Immediately before the trigger the renderer raises the mapping-budget edge (previous section).

Sequence on Chrome_IOThread when the renderer answers P (variant (a), as exploited; every frame below appears in the attached ASan free stack): 0. FinishBuilding(P) completes P and posts A’s OnDependentBlobFinished (:559-562); the UAF happens in that next task, top frame OnDependentBlobFinished(A) (:620-639) → FinishBuilding(entry = A) (:505).

  1. Copies loop :511: the P and S2 slices are kBytes sources ⇒ PopulateBytes(40) each. The D6 slice’s source is now kFile ⇒ :529 copy.dest_item->set_memory_allocation(nullptr) destroys A’s own 200 KiB copy-destination allocation ⇒ ~MemoryAllocation (:331) → RevokeMemoryAllocation (:1117) → MaybeGrantPendingMemoryRequests() (:853): available quota rises from <10 MiB to ≥10 MiB ⇒ X’s request (queue front) fits ⇒ RunDoneCallback(true) (:357) → GrantMemoryAllocations → done_callback_ = BlobStorageContext::OnEnoughSpaceForTransport(X) (:584-604) → RequestTransport(X) (:571-582) → transport_allowed_callback = BlobRegistryImpl::BlobUnderConstruction::OnReadyForTransport (blob_registry_impl.cc:428-437) → DataPipeTransportStrategy::BeginTransport (blob_transport_strategy.cc:134) → NextRequestOrDone (:140) → RequestDataPipe (:150) → CreateDataPipe() fails (:166-168; the browser cannot map the 10 MiB ring buffer) → :170 result_callback_.Run(ERR_OUT_OF_MEMORY) → BlobUnderConstruction::TransportComplete (blob_registry_impl.cc:439-472) → :461 context()->CancelBuildingBlob(X) (:377-380) → CancelBuildingBlobInternal(X) (:480-503): ClearAndFreeMemory(X), set_status, :502 FinishBuilding(X) → :554 X->building_state_.reset() → ~BuildingState(X) (blob_entry.cc:37) → ~dependent_blobs (std::vector<std::unique_ptr<BlobDataHandle>>, blob_entry.h:83; back-to-front) → ~BlobDataHandle(A) (blob_data_handle.cc:70-74; on the IO sequence shared_ dies inline) → ~BlobDataHandleShared (:47-50) → DecrementBlobRefCount(A) (:400-411): refcount 0 → ClearAndFreeMemory(A) (:641-647: A->ClearItems()) → :409 registry_.DeleteEntry(A) → ~BlobEntry(A) → ~BuildingState(A) (still attached in this variant) → ~copies: the last entry’s dest_item (the D6-slice destination D_last, ShareableBlobDataItem, 40 B) dies first, then for each of the NPRE benign, already populated copies (the P slice and the NPRE−1 S2 slices; BuildingState members die in reverse declaration order, so copies goes before dependent_blobs, blob_entry.h:72-101) three 48-slot objects (its MemoryAllocation, the 40-byte bytes_ backing, the ShareableBlobDataItem; each of these 40-byte revokes runs the grant loop too, but with X’s 10 MiB just granted nothing queued fits) plus its 832-slot BlobDataItem, then the vector<ItemCopyEntry> backing itself; then (after A’s handles to P/S2/D6, which the renderer still holds, and the BuildingState) the BlobEntry (128 B). This is the free. Still inside ~dependent_blobs(X): ~BlobDataHandle(Z) → DecrementBlobRefCount(Z) → 0 → ClearAndFreeMemory(Z) → Z->ClearItems() → Z’s 32 MiB granted-never-fed item dies → ~MemoryAllocation → RevokeMemoryAllocation → MaybeGrantPendingMemoryRequests() again — the first grant loop that can grant anything after all of A’s slots are on the IO thread’s free lists ⇒ Y fits ⇒ RunDoneCallback(true): GrantMemoryAllocations (one 32-byte MemoryAllocation per Y element, 48 slot) then OnEnoughSpaceForCopies(Y) (:606-618) → FinishBuilding(Y) → copies loop → PopulateBytes(40) ×36 and PopulateBytes(128) ×3 (bytes_.assign(), blob_data_item.cc:189-194 = operator new(n) + memcpy of renderer bytes): PartitionAlloc’s per-thread cache is LIFO per bucket, so one of the 40-byte copies (observed: the 14th-16th) re-occupies D_last’s 48-byte slot and one of the 128-byte copies of Y/Y2 re-occupies A’s BlobEntry 144-byte slot, both with renderer-authored bytes; Y2 is granted in the same loop; B does not fit and the loop stops; DeleteEntry(Z). Everything unwinds (FinishBuilding(X) :557 on X’s cleared items, TransportComplete returns, ~MemoryQuotaAllocationTask(X) returns X’s 10 MiB — another nested grant loop, nothing fits) back to FinishBuilding(A) after :529.
  2. FinishBuilding(A) continues with stale state. :531 copy.source_item->item() — copy refers into the freed vector<ItemCopyEntry> backing (READ; the trigger copy is the last of NPRE+1 entries, i.e. beyond the ≤64 bytes PartitionAlloc’s x86-64 free-poisoning overwrites, and arm64 has no free-poisoning, so the stale scoped_refptr words are intact and point at D6’s genuine, live item); :532-536 CreateFile(...) allocates a fresh BlobDataItem N (sizeof 0x330 ⇒ 832 slot) and takes two references through the genuine source item; :537 copy.dest_item->set_item(std::move(new_item)) — dest_item (read from the freed backing) points at D_last’s slot, which now holds Y’s 40 renderer bytes: scoped_refptr move-assignment swaps N into offset +0x18 of that block (renderer-readable ⇒ the leak) and Release()s the pointer the renderer had placed there (“blockPtr”: 0 ⇒ nothing happens; a real object ⇒ --ref_count_ at blockPtr+8 and, at zero, ~BlobDataItem + operator delete); :545 writes 3 at +0x10 of the block; the range-for ends (cached end iterator); :548 writes 0xC8 at offset 0 of A’s old slot (now Y’s 128-byte block — harmless; observed as status=200 in the read-back); :552 reads +0x78 of that block (renderer put 0 ⇒ no callbacks); :557 NotifyMemoryItemsUsed(entry->items()) iterates the fake items_ {begin,end} at +0x40/+0x48 of the block — zeros ⇒ nothing, or pointers into a renderer-controlled buffer ⇒ fake ShareableBlobDataItem*s dereferenced (->item_->type_, ->state_, ->item_id_) and Put() raw into populated_memory_items_ (persistent); FinishBuilding(A) returns normally and the browser lives. Variant (b) (the attached PoC’s default, no paging needed): the renderer also closes its handle to P, A = [P slice] only and the quota window is 10 MiB − 250 KiB; then the copies loop is benign and the ~MemoryAllocation that grants X is P’s populated 250 KiB item dying under :554 entry->building_state_.reset() → ~BuildingState(A) → ~BlobDataHandle(P) → DecrementBlobRefCount(P) → ClearAndFreeMemory(P) → P->ClearItems() (:644); the same X cascade deletes A’s BlobEntry (its BuildingState is already detached and mid-destruction), Z’s revoke grants Y whose 128-byte copies re-occupy the entry, and the first stale use is :557 (then :562). Variant (c), same shape, not separately executed: ClearAndFreeMemory() :644 entry->ClearItems() → grant loop → :645-646 write through entry; reached from DecrementBlobRefCount :408 and CancelBuildingBlobInternal :496.

Freed objects, holders, mitigations. Freed: storage::BlobEntry (owner std::unique_ptr<BlobEntry> in BlobStorageRegistry::blob_map_; sizeof 128: status_+0, refcount_+8, content_type_+0x10, content_disposition_+0x28, items_ +0x40, size_+0x58, offsets_+0x60, building_state_+0x78 ⇒ 144-byte PartitionAlloc slot once BRP’s in-slot metadata is added: +4 B on Linux/Windows, +8 B on Apple, partition_alloc/in_slot_metadata.h:44-50,585-587); BlobEntry::BuildingState (owner BlobEntry::building_state_; 184 ⇒ 192 slot); the std::vector<ItemCopyEntry> heap backing (ItemCopyEntry = {scoped_refptr source, size_t offset, scoped_refptr dest} = 24 B; backing 24·capacity); copy-destination ShareableBlobDataItems (base::RefCounted; sizeof 40: ref_count_+0, item_id_+8, state_+0x10, item_+0x18, memory_allocation_+0x20 ⇒ 48-byte slot — the same slot class as BlobMemoryController::MemoryAllocation (32 B) and as a 40-byte PopulateBytes buffer). Stale holders: BlobEntry* entry (plain pointer, function parameter), const ItemCopyEntry& copy plus the range-for’s cached begin/end (reference/iterators into a std::vector member of the freed BuildingState), copy.source_item / copy.dest_item (scoped_refptr values read out of freed storage; the dest_item pointee is itself freed), and entry->items_ (vector header read out of the freed BlobEntry, whose element pointers are then dereferenced and retained in populated_memory_items_, a base::LRUCache<uint64_t, ShareableBlobDataItem*> of plain pointers, blob_memory_controller.h:315). None is raw_ptr/raw_ref ⇒ BackupRefPtr does not poison, quarantine or detect anything here; no class in storage/browser/blob has ADVANCED_MEMORY_SAFETY_CHECKS(); PartitionAlloc’s scheduler-loop quarantine default configuration covers only browser/main (+ AMSC classes) at 152/153/trunk (base/allocator/partition_alloc_features.cc:127-166 @153), so Chrome_IOThread frees go straight to the thread cache and are handed out again LIFO; x86-64 thread-cache free-poisoning covers ≤64 bytes of a slot and is irrelevant once the slot is re-filled; arm64/macOS has none. The implicit “check” that entry stays valid is made once, at OnDependentBlobFinished’s registry_.GetEntry() (:623); the statements at :529-:562 act on memory whose lifetime was decided by other blobs’ continuations in between. Missing invariant: no code reached from ~MemoryAllocation may run another blob’s continuation (equivalently: BlobStorageContext must not hold a raw BlobEntry* / element reference across a statement that can drop the last BlobDataHandle of that entry).

ASan (Chromium 155.0.8038.0, is_asan, browser pid, attached in full). Variant (a), v=a:

ERROR: AddressSanitizer: heap-use-after-free ... READ of size 8 ... thread T10 (Chrome_IOThread)
  #0 storage::BlobStorageContext::FinishBuilding(storage::BlobEntry*) base/memory/scoped_refptr.h:293   (= blob_storage_context.cc:531 copy.source_item->item())
... is located 24 bytes inside of 48-byte region                                                          (= vector<ItemCopyEntry> backing, 2 entries in the PoC)
freed by thread T10 (Chrome_IOThread):
  #1 storage::BlobEntry::BuildingState::~BuildingState()  #2 storage::BlobEntry::~BlobEntry()  #4 storage::BlobStorageRegistry::DeleteEntry
  #5 ~BlobDataHandleShared  #9 ~BuildingState()  #10 FinishBuilding  #11 CancelBuildingBlobInternal blob_storage_context.cc:502
  #12 BlobUnderConstruction::TransportComplete blob_registry_impl.cc:461  #14 DataPipeTransportStrategy::RequestDataPipe
  #17 BlobUnderConstruction::OnReadyForTransport  #21 BlobStorageContext::OnEnoughSpaceForTransport  #24 BlobMemoryController::MaybeGrantPendingMemoryRequests
  #25 BlobMemoryController::MemoryAllocation::~MemoryAllocation blob_memory_controller.cc:1137  #26 BlobStorageContext::FinishBuilding   (= :529)
previously allocated by: vector<ItemCopyEntry>::emplace_back <- BlobDataBuilder::SliceBlob <- BlobDataBuilder::AppendBlob blob_data_builder.cc:244

Variant (b), default: READ of size 8 … Chrome_IOThread #0 BlobMemoryController::NotifyMemoryItemsUsed #1 BlobStorageContext::FinishBuilding blob_storage_context.cc:557:22, 64 bytes inside of 128-byte region (= BlobEntry::items_), freed by DeleteEntry ← ~BlobDataHandleShared ← ~BuildingState ← FinishBuilding ← CancelBuildingBlobInternal :502 ← TransportComplete :461 ← RequestDataPipe ← … ← OnEnoughSpaceForTransport ← MaybeGrantPendingMemoryRequests ← ~MemoryAllocation :1137 ← ~ShareableBlobDataItem ← … ← DecrementBlobRefCount :644 ← ~BlobDataHandleShared ← ~BuildingState ← FinishBuilding (= :554), allocated by BlobStorageRegistry::CreateEntry ← AddFutureBlob ← BlobRegistryImpl::Register — the free stack contains the use frame. On release builds without ASan: variant (a) alone crashes the browser (SI_KERNEL, rax = 0xbadbad00badbad00 = PartitionAlloc free-poison read as copy.source_item, at the :531/:532 load); variant (b) is silent unless the BlobEntry slot is re-occupied (then SEGV_MAPERR at the planted items_.begin, in NotifyMemoryItemsUsed’s item->item()->type() load, caller FinishBuilding).

Exploitation outline (what each of the 8 identical triggers is used for; details in umbrella report)

  • R1 leak: the 40-byte block of Y that re-occupied D_last carries blockPtr = 0 at +0x18; the stale set_item() stores the freshly allocated BlobDataItem* N1 there and skips Release(nullptr); the browser survives and the renderer reads Y back (Blob.ReadRange) ⇒ browser heap address N1 (a live 832-slot kFile item on the IO thread, refcount 1, referenced only by our block ⇒ stable). R2: same ⇒ N2, a second live BlobDataItem we never release (read target: its first qword is BlobDataItem’s vtable pointer).
  • R3 capture: plant blockPtr = N1 ⇒ the stale set_item Release()s the real item at N1 (refcount 1→0 ⇒ genuine ~BlobDataItem, slot freed as the cascade task’s last 832-bucket operation) and, in the very next IO-thread task (FinishBuilding(A′), posted by FinishBuilding(P) right after A’s), copy[0]’s BlobDataItem::PopulateBytes → bytes_.assign(824) re-takes N1’s slot with 824 renderer bytes = K1 (a buffer at a known browser address; A′ is DONE afterwards, so Blob.ReadRange(A′) reads K1’s current bytes back — the oracle channel). Note for macOS: PartitionAlloc’s BackupRefPtr in-slot metadata is 8 B on Apple vs 4 B elsewhere (partition_alloc/in_slot_metadata.h AlignUpInSlotMetadataSizeForApple), so BlobDataItem (816 B) sits in the 832 slot on both but the capture length must be 824 (824+8 = 832), not 828 (⇒ 896 slot on Apple), to land in it.
  • R4 oracle: plant blockPtr = K1+0x10; Release() decrements the uint32_t at K1+0x18; we pre-set 0x40000000 there and read 0x3fffffff back through A′ ⇒ K1’s address is proven before anything dereferences it (otherwise a foreign BlobDataItem::length_ lost 1 and the kit aborts cleanly).
  • R5 read: the 128-byte BlobEntry refill of this trigger carries a fake items_ {begin,end} pointing at an array inside K1 of pointers to fake ShareableBlobDataItems inside K1 {state POPULATED_WITH_QUOTA, item_ → fake BlobDataItem inside K1 {type kBytes, length_ 0, bytes_ = (chosen browser address, length)}} ⇒ the stale :557 NotifyMemoryItemsUsed registers them in populated_memory_items_; the renderer then populates a real 5 MiB blob R (newest in the LRU) and appends one request just above the available quota ⇒ MaybeScheduleEvictionUntilSystemHealthy → CollectItemsForEviction pops LRU-oldest items including the fakes and R ⇒ the file runner write()s span(fake ptr, fake len) of browser memory into the page file ahead of R’s data ⇒ Blob.ReadRange(R, 0, n) returns those bytes to the renderer ⇒ [N2] = BlobDataItem vtable ⇒ Chrome Framework image base cb (cross-checked against the same qword seen through N2’s whole page).
  • R6/R7: capture and oracle a second slot Kx the same way, with an image built now that cb is known: a complete fake BlobDataItem D_E at Kx {vptr (cosmetic), ref_count_=1, type_ 0, length_ = oracle dword, every destructible member zero, file_access_ (+0x328, a base::RepeatingCallback = scoped_refptr<BindStateBase>) → fake BindStateBase BS inside Kx {ref_count_ 1, destructor_ (+0x10) = cb+base::internal::Invoker<…>::RunOnce (an existing 6-argument invoker that loads x0..x5 and the target from the BindState), bound fn = cb+posix_spawnp import stub, args = &pid, “/bin/sh”, 0, 0, argv{sh,-c,CMD}, envp}}.
  • R8 exec: plant blockPtr = D_E ⇒ the stale set_item’s Release(D_E) ⇒ refcount 1→0 ⇒ the real ~BlobDataItem runs on our object (ThinLTO devirtualised the delete in RefCounted<BlobDataItem>::Release on 153 mac-arm64, so no vptr is read on this path) ⇒ its first member release is file_access_ ⇒ if (--BS->ref_count_ == 0) BS->destructor_(BS) ⇒ the Invoker loads x0..x5 from BS and blrs posix_spawnp ⇒ child /bin/sh -c CMD; the Invoker returns, the destructor finishes over zeroed members, operator delete(Kx, 0x330) frees a genuine slot start, control returns into FinishBuilding(A) and the browser continues (the next task’s PopulateBytes(824) re-takes Kx). (macOS: no CFI and no PAC on this indirect call — ldr x8,[x0,#0x10]; blr x8; Linux official builds CFI-icall-check exactly this call and trap with ud1, confirming control of the call target; the Linux chain therefore used a data-only delete D_E → real BindState<…,ScopedClosureRunner>::Destroy → pointer-to-member Invoker → execvp route instead.)

Reproduction

Standalone trigger (for triage; attached poc/: poc.html ≈300 lines + gen/ = stock MojoJS bindings from a Chromium build’s out/*/gen (blob mojoms are not ordinal-scrambled; the DiscardableSharedMemoryManager binding is patched only to take its two per-build ordinals from a global, because official builds salt them with chrome/VERSION) + server.py (loopback static server) + run.sh (headless runner: fresh profile, Chrome sandbox ON, verdict line)). Compromised-renderer stand-in: --enable-blink-features=MojoJS,MojoJSTest — nothing else (no --no-sandbox, no --disable-features, no CDP); every message the page sends is individually valid (no bad-message path). A regular on-disk profile is required (see Affected).

# ASan build (any recent Chromium, is_asan=true): heap-use-after-free in the BROWSER process, thread Chrome_IOThread (~13 s)
CHROME=out/asan/chrome ASAN_SYMBOLIZER=.../llvm-symbolizer ./run.sh a1            # variant (b): use at blob_storage_context.cc:557
CHROME=out/asan/chrome ./run.sh a2 "v=a"                                          # variant (a): use at :531, freed under :529
# Official build, e.g. Chrome for Testing 152.0.7977.83: freed BlobEntry re-occupied ⇒ browser SEGV_MAPERR at the planted pointer (~15-25 s)
CHROME=chrome-linux64/chrome ./run.sh r1 "refill=1"                               # SEGV_MAPERR 414141410000, r13 = 0x414141410000
CHROME=chrome-linux64/chrome ./run.sh r2 "refill=1&fakeptr=0x7e57ab1e0f08"        # fault address follows the page
CHROME=chrome-linux64/chrome ./run.sh r3 "v=a"                                    # variant (a), no refill: SI_KERNEL, rax = 0xbadbad00badbad00
# Linux fd-limit trigger instead of the mapping budget (stock browser limit 8192; ~4-5 min; run.sh hands the process tree a soft
# limit of 8192 so the MojoJS stand-in renderer can hold one producer handle per parked pipe — the browser's own limit is 8192 either way):
EDGE=fd TMO=900 CHROME=... ./run.sh f1 "refill=1&nfd=8100"

What the page does (variant b): bind blink.mojom.BlobRegistry; wait until blob storage limits are calculated (a 600 MiB reservation is accepted); register P (250 KiB, reply withheld) and A = [P slice(1,64)]; converge the free blob-memory budget to [10 MiB − 250 KiB + 64 KiB, +64 KiB) with granted-but-unfed stall blobs (pure accounting, no RAM; grantedness is visible to the renderer because RequestAsStream/ RequestAsReply arrives before Register replies); register X = [10 MiB unfed bytes, A] (verified pending) and B = 100 MiB (queued behind); close the page’s handles to A and P; edge up (DSM segments 2 GiB−64 KiB, 1 GiB, 512 MiB … until each is refused ⇒ 32 GiB cap, then free one 8 MiB segment; ≈45 IPCs, tens of ms, nothing committed); answer P; observe X’s BytesProvider being closed (= X was granted and cancelled re-entrantly); edge down. v=a additionally registers D6 (6 MiB, fed), gives A a second element D6 slice(1, 200 KiB), and after converging appends one request that cannot fit so the browser pages D6 to disk. refill=1 additionally registers Sr (4 KiB of 128-byte fake BlobEntry images whose items_ begin/end = fakeptr), S2, Z (32 MiB granted-unfed, listed in X before A) and 8 copy-only blobs Y_i = [S2 slice(1.5 MiB−2), Sr slice(128)] queued behind X. Expected: ASan report as quoted under Root cause (attached: 7/7 mapping-budget edge, 1/1 with dcheck_always_on — no DCHECK on the path, 1/1 fd edge, 2/2 v=a); CfT 152.0.7977.83 refill=1: Received signal 11 SEGV_MAPERR 414141410000, r13 = 0x414141410000 (fake items_.begin), r11 = 0x414141410008 (fake end), ip = image+0x4ef8235 = mov (%r13),%rax; mov 0x18(%rax),%rdi; cmpl $0,0xc(%rdi) (NotifyMemoryItemsUsed’s item->item()->type()), caller frame image+0x571ef08 inside FinishBuilding — 8/8; control without refill: X fate=closed, browser alive (the stale walk sees the freed entry’s cleared vector).

As exploited (stock Chrome 153.0.8010.37 on macOS, MojoJS obtained via reports A+B): see umbrella; per-trigger harness log (page-side telemetry relayed through the local server) of round 1 of a successful end-to-end run:

C8 R1: S2/D6/P/Sr/Z/A ready; avail before D6 2040.00M, at PG [2014.54M,2014.60M), request 2014.60M => timeout, after 2014.54M => D6 paged: yes (accounting: 6 MiB left memory)
C8 R1: X pending, Y/Y2/B queued, BlobImpl(A/Z) closed, edge up; answering P => FIRE
C8 R1: EDGE UP: browser mapped 26 segments = 31.934 GiB in 6 ms [2048Mx15 1024Mx1 512Mx1 256Mx1 128Mx1 32Mx1 16Mx1 8Mx1 4Mx1 1Mx1 128Kx1 32Kx1] until base::SharedMemorySecurityPolicy refused (32 GiB/process cap => ~68 MiB were already mapped by the browser itself); shaped: freed 8M => free budget now ~8.00M < X pipe 10.00M => CreateDataPipe for X must fail; holding 25 segments
C8 R1: EDGE DOWN: released 25 segments (0 ms) => browser mapping budget restored
C8 R1: fired; X fate=closed (closed = granted -> CreateDataPipe refused -> cancelled = TRIGGERED; stream = pipe created = NOT triggered; timeout = X never granted); browser alive=true; B released; edge was held 610 ms
C8 R1: read back 1824 B: 40-block hits [#16:state=3,item=0x10c18146cc0] BlobEntry-A refill YES @Y2#0 (of Y#0..2 Y2#0..11) => N1=0x10c18146cc0
C8 R1 LEAK N1=0x10c18146cc0

Failure signatures when a re-occupation misses (the only failure class the trigger ever showed; useful for triage): 48-slot (destination item) miss ⇒ browser SIGSEGV on Chrome_IOThread in ShareableBlobDataItem::set_item → RefCounted::Release on whatever the un-refilled slot holds, called from FinishBuilding :537; a miss on the other freed objects (ItemCopyEntry backing / BlobEntry) ⇒ SIGSEGV inside FinishBuilding itself (the :531-532 source-item load on a clobbered backing, or the post-:548 building_state_ / completion-callback walk on an un-refilled entry). Two macOS crash reports from kit development additionally show the stale :557 walk concretely: SIGSEGV in BlobMemoryController::NotifyMemoryItemsUsed (Google Chrome Framework+0x1d932f0, lr +0x3063a28 = BlobStorageContext::FinishBuilding :557, 153.0.8010.37 mac-arm64) iterating our fake items_ with x21 = the planted begin pointer — there the planted pointer targeted a slot that still held a live BlobDataItem (an early capture method that cannot work on Apple’s 8-byte slot metadata), not a refill miss; .ips available.

Impact

Use-after-free (read and write, plus retention of attacker-supplied pointers) in the browser process, reachable by any renderer that can issue BlobRegistry/Blob/BytesProvider and DiscardableSharedMemoryManager messages in an attacker-chosen order — i.e. a compromised renderer, the standard sandbox-escape threat model; plain web JavaScript is not known to reach it (Blink always answers BytesProvider requests promptly, so the quota queue ordering cannot be produced through the Web Blob API as far as we determined). No user gesture, no navigation, no timing race (the renderer schedules every allocation in the window itself), any frame or worker, regular profile. Demonstrated consequences on a shipping stable build: browser ASLR defeat (heap address and image base), arbitrary browser-memory read, and arbitrary command execution as the browser (user) with the browser left running. Suggested severity: High (renderer→browser memory corruption with a demonstrated sandbox escape); Critical only as part of the full chain (umbrella). The shared-memory mapping budget being exhaustible by one renderer is a contributing design weakness worth its own hardening bug (it also lets a renderer make every browser-side data-pipe / shared-buffer mapping larger than the remaining headroom fail for as long as it holds the segments).

Attachments to include

  • poc/ (poc.html + run.sh + server.py + gen/ bindings; README), the ASan reports and CfT crash dumps quoted above (scrubbed evidence file), the fix patch; for the exploit: see umbrella (harness logs of the three successful macOS runs, the two macOS NotifyMemoryItemsUsed .ips from kit development that show the stale :557 walk over attacker-supplied items_).
View on issue tracker