Low watch CVSS 6.5 chrome Logic Error 🔧 Commit mapped

Overview

Low
Severity
6.5
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInterpretation conflict in Safebrowsing
DescriptionInterpretation conflict in Safebrowsing
ComponentSafebrowsing
Sub-componentSafe_browsing
Bug ClassLogic Error
Action tierwatch — logic/policy bug — security-relevant, no memory-unsafety
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker513473551
Fix commit356828e2157d (chromium/src) +354/-47
CWECWE-436
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:H/A:N
CISA KEVNot listed
CreditedGoogle
Reported2026-05-15 (per vendor advisory)
Fix landed2026-08-05 (public git)
Disclosed2026-09-08
Patch gap34 days — fix public in source before the advisory

Background

HFS+ catalog B-tree
The on-disk index inside an HFS+/DMG volume whose leaf nodes hold variable-length keyed records (folders, files, and thread records) that Chrome’s Safe Browsing analyzer walks to enumerate a downloaded disk image’s contents.
B-tree node offset table (TN1150)
A trailing array of numRecords + 1 big-endian uint16 offsets stored in reverse order at the end of each node; entry i at node[nodeSize - 2*(i+1)] gives record i’s byte offset, and the last entry marks the start of free space.
`keyLength`
The leading uint16 of every keyed record; per spec a record’s data begins at recordOffset + sizeof(keyLength) + keyLength, so keyLength may deliberately pad past the actual variable-length key/name.
Interpretation conflict (parser differential)
When Chrome’s HFS+ reader and the real macOS filesystem disagree on which bytes constitute a file, the analyzer scans different content than the OS ultimately mounts and executes, letting malware evade Safe Browsing.

Root Cause Analysis

HFSBTreeIterator::Next() located catalog records by walking the leaf node sequentially from the BTNodeDescriptor: it skipped only the keyLength field with AdvanceLeafPast<uint16_t>() and then assumed the record’s catalog data began immediately after the fixed key/name bytes, ignoring both the trailing offset table and the record’s declared keyLength. This violated the TN1150 invariant that record i lives at the offset-table entry and its data starts at recordOffset + sizeof(keyLength) + keyLength, so a crafted image using either degree of freedom — a padded keyLength or a non-contiguous record placement — was parsed by Chrome differently than by every other HFS+ implementation, including the macOS mounter.

The fix has ReadCurrentLeaf() read and validate the numRecords + 1 offset table into record_offsets_ (checking each entry is within the node, past the descriptor, before the table, and monotonically non-decreasing), and Next() now seeks to record_offsets_[i], reads keyLength, and computes the spec-correct data_offset (bounded by maxKeyLength and record_end) before reading the record. Because records are now positioned exactly as macOS positions them, the scanner and the OS agree on the image’s contents, closing the differential.

Key insight
The reader discarded the two fields the HFS+ format uses to locate a record — the node offset table and each record’s keyLength — and instead guessed record positions by assuming contiguous layout, so an attacker could lay out a catalog that Chrome parsed differently from macOS. The fix restores spec-conformant positioning (offset table plus keyLength) with bounds validation, eliminating the interpretation gap.

Attack Path

  1. Craft a divergent HFS+ image An attacker builds a DMG whose catalog leaf uses a padded keyLength (data offset beyond the name) or places a record at a non-contiguous offset reachable only through the trailing offset table, exactly the layout exercised by the added LeafRecordsLocatedByOffsetTableAndKeyLength test.
  2. Deliver the download The user downloads the DMG; Chrome’s Safe Browsing analyzer opens it and drives HFSIterator/HFSBTreeIterator::Next() to enumerate its files for malware inspection.
  3. Parser diverges from the OS The pre-patch iterator locates record data by contiguous assumption rather than by offset table and keyLength, so it reads the attacker’s benign-looking bytes while the file the macOS mounter later exposes is different.
  4. Evasion The analyzer clears or misclassifies the archive because it never inspects the bytes macOS will actually surface, so the malicious payload bypasses Safe Browsing (inferred downstream consequence of the parser differential the patch demonstrates).

Impact Assessment

An attacker gains a Safe Browsing detection bypass: a downloaded HFS+/DMG image whose catalog exploits the offset-table or keyLength layout freedom is scanned as different content than macOS will mount, so malicious files inside it evade the analyzer that runs in Chrome’s sandboxed utility process. The precondition is that the victim downloads and opens the crafted image with Safe Browsing analysis active on macOS. The advisory classifies this as a LogicError / interpretation conflict; any memory-safety or code-execution escalation beyond the demonstrated parsing differential is (inferred) and not shown by the patch.

Changed Functions

FunctionChangeNotes
if
chrome/utility/safe_browsing/mac/hfs.cc
modified
switch
chrome/utility/safe_browsing/mac/hfs.cc
modified
for
chrome/utility/safe_browsing/mac/hfs.cc
modified
TEST
chrome/utility/safe_browsing/mac/hfs_unittest.cc
modified

Files Changed

  • chrome/utility/safe_browsing/mac/hfs.cc
  • chrome/utility/safe_browsing/mac/hfs_unittest.cc
  • chrome/utility/safe_browsing/mac/hfs.h

Audit Directions

  • Format-relative positioning
    Audit every binary/container parser (other archive analyzers, DMG/ISO/filesystem readers) that infers record or field positions from assumed contiguity rather than from the format’s own offset/length metadata, since such shortcuts create parser differentials against the authoritative implementation.
  • Length fields discarded as padding
    Review parsers that skip a declared length (like keyLength) instead of honoring it to locate the following payload; ignoring caller-controlled length fields is both an interpretation-conflict and an out-of-bounds risk.
  • Tag/union pointer consistency
    Look for code that selects a union member or downcasts based on a type tag without confirming the corresponding pointer/field was actually populated, as IsDirectory/IsFile did before this fix.
From 356828e2157d030483c91b2774a59bd8e583556e Mon Sep 17 00:00:00 2001
From: Tiffany Song <[email protected]>
Date: Wed, 05 Aug 2026 08:43:54 -0700
Subject: [PATCH] safe_browsing: Use offset table and keyLength in HFS+ B-tree

HFSBTreeIterator walked catalog leaf records sequentially from the
BTNodeDescriptor and discarded each record's keyLength, assuming the
catalog data immediately follows the node name. Per TN1150, record i is
located via the trailing offset table at node[nodeSize - 2*(i+1)] and
its data begins at recordOffset + sizeof(keyLength) + keyLength. Images
that use either of those degrees of freedom were parsed differently from
other HFS+ implementations.

ReadCurrentLeaf() now reads and validates the numRecords + 1 entry
offset table into record_offsets_. Next() seeks to the current record's
offset, reads keyLength, and seeks to the resulting data offset before
reading the catalog record. Thread records no longer need their
variable-length body skipped, so the AdvanceLeafPast<T>() helper is
removed.

Also introduces HFSIterator::IsFile() and updates IsDirectory() to
safely encapsulate record type and pointer validation across all entry
inspection helpers.

Adds a unit test that builds a minimal HFS+ image in memory whose single
catalog leaf uses a padded keyLength on record 0 and places record 1 at
a non-contiguous offset reachable only via the offset table, and
verifies the iterator surfaces both entries and the file's data fork.

Internal CL: https://chrome-internal-review.git.corp.google.com/c/chrome/experimental/chromium/src/+/9625330

Fixed: 513473551
Change-Id: Icb5d5c1f2ed12ef6c0e81b80506333602c64a5f6
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8196986
Commit-Queue: Tiffany Song <[email protected]>
Reviewed-by: Javier Castro <[email protected]>
Reviewed-by: Yaw Frempong <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1674190}
---
diff --git a/chrome/utility/safe_browsing/mac/hfs.cc b/chrome/utility/safe_browsing/mac/hfs.cc
index bf214ab0..996130d7 100644
--- a/chrome/utility/safe_browsing/mac/hfs.cc
+++ b/chrome/utility/safe_browsing/mac/hfs.cc
@@ -227,12 +227,6 @@
   template <typename T>
   const T* GetLeafObjectHostEndian();
 
-  // Advances the position of `leaf_iterator_` past the next sizeof(T) bytes, if
-  // possible, and returns true. Returns false if the new position would exceed
-  // the total size of the `leaf_data_`.
-  template <typename T>
-  bool AdvanceLeafPast();
-
   // Checks if the HFS+ catalog key is a Mac OS X reserved key that should not
   // have it or its contents iterated over.
   bool IsKeyUnexported(const std::u16string& path);
@@ -267,6 +261,11 @@
   // Keeps track of our current position within the current `leaf_data_`.
   std::unique_ptr<base::BufferIterator<uint8_t>> leaf_iterator_;
 
+  // The per-record byte offsets into `leaf_data_` for the current leaf, taken
+  // from the offset table at the end of the node. There are `numRecords + 1`
+  // entries; the last entry marks the start of the node's free space.
+  std::vector<uint16_t> record_offsets_;
+
   // Points to the BTNodeDescriptor at the start of `leaf_data_`.
   raw_ptr<const BTNodeDescriptor> current_leaf_ = nullptr;
   // The record read at the current position of the `leaf_iterator_`.
@@ -326,8 +325,7 @@
     keep_going = catalog_->Next();
     if (keep_going) {
       if (!catalog_->current_record()->unexported &&
-          (catalog_->current_record()->record_type == kHFSPlusFolderRecord ||
-           catalog_->current_record()->record_type == kHFSPlusFileRecord)) {
+          (IsDirectory() || IsFile())) {
         return true;
       }
       keep_going = catalog_->HasNext();
@@ -338,27 +336,38 @@
 }
 
 bool HFSIterator::IsDirectory() {
-  return catalog_->current_record()->record_type == kHFSPlusFolderRecord;
+  return catalog_->current_record()->record_type == kHFSPlusFolderRecord &&
+         catalog_->current_record()->folder;
+}
+
+bool HFSIterator::IsFile() {
+  return catalog_->current_record()->record_type == kHFSPlusFileRecord &&
+         catalog_->current_record()->file;
 }
 
 bool HFSIterator::IsSymbolicLink() {
-  if (IsDirectory())
+  if (IsDirectory()) {
     return S_ISLNK(catalog_->current_record()->folder->bsdInfo.fileMode);
-  else
+  }
+  if (IsFile()) {
     return S_ISLNK(catalog_->current_record()->file->bsdInfo.fileMode);
+  }
+  return false;
 }
 
 bool HFSIterator::IsHardLink() {
-  if (IsDirectory())
+  if (IsDirectory()) {
     return false;
+  }
   const HFSPlusCatalogFile* file = catalog_->current_record()->file;
   return file->userInfo.fdType == kHardLinkFileType &&
          file->userInfo.fdCreator == kHFSPlusCreator;
 }
 
 bool HFSIterator::IsDecmpfsCompressed() {
-  if (IsDirectory())
+  if (IsDirectory()) {
     return false;
+  }
   const HFSPlusCatalogFile* file = catalog_->current_record()->file;
   return file->bsdInfo.ownerFlags & UF_COMPRESSED;
 }
@@ -368,8 +377,9 @@
 }
 
 std::unique_ptr<ReadStream> HFSIterator::GetReadStream() {
-  if (IsDirectory() || IsHardLink())
+  if (IsDirectory() || IsHardLink()) {
     return nullptr;
+  }
 
   DCHECK_EQ(kHFSPlusFileRecord, catalog_->current_record()->record_type);
   return std::make_unique<HFSForkReadStream>(
@@ -569,8 +579,28 @@
 
   CHECK(leaf_iterator_);
 
-  // Skip keyLength.
-  if (!AdvanceLeafPast<uint16_t>()) {
+  // Position at the start of the current record using the node's offset table.
+  if (static_cast<size_t>(current_leaf_records_read_) + 1u >=
+      record_offsets_.size()) {
+    return false;
+  }
+  const uint16_t record_offset = record_offsets_[current_leaf_records_read_];
+  const uint16_t record_end = record_offsets_[current_leaf_records_read_ + 1];
+  leaf_iterator_->Seek(record_offset);
+
+  auto key_length = CopyLeafDataHostEndian<uint16_t>();
+  if (!key_length.has_value()) {
+    return false;
+  }
+
+  // The data portion of a keyed record begins immediately after the key,
+  // located at `record_offset + sizeof(keyLength) + keyLength`.
+  base::CheckedNumeric<size_t> data_offset = record_offset;
+  data_offset += sizeof(uint16_t);
+  data_offset += *key_length;
+  if (*key_length > header_.maxKeyLength || !data_offset.IsValid() ||
+      data_offset.ValueOrDie() > record_end) {
+    DLOG(ERROR) << "Catalog record key extends past record";
     return false;
   }
 
@@ -583,6 +613,12 @@
   if (!key_string_length.has_value()) {
     return false;
   }
+  if (leaf_iterator_->position() +
+          static_cast<size_t>(*key_string_length) * sizeof(uint16_t) >
+      data_offset.ValueOrDie()) {
+    DLOG(ERROR) << "Catalog key node name extends past key";
+    return false;
+  }
 
   // Read and byte-swap the variable-length key string.
   std::u16string key(*key_string_length, '\0');
@@ -597,6 +633,7 @@
 
   // Read the record type and then rewind as the field is part of the catalog
   // structure that is read next.
+  leaf_iterator_->Seek(data_offset.ValueOrDie());
   size_t rewind_to = leaf_iterator_->position();
   auto record_type = CopyLeafDataHostEndian<int16_t>();
   if (!record_type.has_value()) {
@@ -609,6 +646,11 @@
 
   switch (current_record_.record_type) {
     case kHFSPlusFolderRecord: {
+      if ((data_offset + sizeof(HFSPlusCatalogFolder))
+              .ValueOrDefault(SIZE_MAX) > record_end) {
+        DLOG(ERROR) << "Folder record data extends past record";
+        return false;
+      }
       const HFSPlusCatalogFolder* folder =
           GetLeafObjectHostEndian<HFSPlusCatalogFolder>();
       if (!folder) {
@@ -642,6 +684,11 @@
       break;
     }
     case kHFSPlusFileRecord: {
+      if ((data_offset + sizeof(HFSPlusCatalogFile)).ValueOrDefault(SIZE_MAX) >
+          record_end) {
+        DLOG(ERROR) << "File record data extends past record";
+        return false;
+      }
       const HFSPlusCatalogFile* file =
           GetLeafObjectHostEndian<HFSPlusCatalogFile>();
       if (!file) {
@@ -661,22 +708,8 @@
     case kHFSPlusFolderThreadRecord:
     case kHFSPlusFileThreadRecord: {
       // Thread records are used to quickly locate a file or folder just by
-      // CNID. As these are not necessary for the iterator, skip past the data.
-      if (!AdvanceLeafPast<uint16_t>() ||  // recordType
-          !AdvanceLeafPast<uint16_t>() ||  // reserved
-          !AdvanceLeafPast<uint32_t>()) {  // parentID
-        return false;
-      }
-      // Skip past the nodeName string.
-      auto string_length = CopyLeafDataHostEndian<uint16_t>();
-      if (!string_length.has_value()) {
-        return false;
-      }
-      for (uint16_t i = 0u; i < *string_length; ++i) {
-        if (!AdvanceLeafPast<uint16_t>()) {
-          return false;
-        }
-      }
+      // CNID. These are not necessary for the iterator; the next record is
+      // located via the node's offset table.
       ++leaf_records_read_;
       ++current_leaf_records_read_;
       break;
@@ -693,6 +726,7 @@
     current_leaf_number_ = current_leaf_->fLink;
     read_current_leaf_ = false;
     leaf_iterator_.reset();
+    record_offsets_.clear();
   }
 
   return true;
@@ -721,12 +755,14 @@
 
   if (!SeekToNode(current_leaf_number_)) {
     DLOG(ERROR) << "Failed to seek to node " << current_leaf_number_;
+    record_offsets_.clear();
     return false;
   }
 
   CHECK_EQ(leaf_data_.size(), header_.nodeSize);
   if (!stream_->ReadExact(leaf_data_)) {
     DLOG(ERROR) << "Failed to read node " << current_leaf_number_;
+    record_offsets_.clear();
     return false;
   }
 
@@ -736,14 +772,50 @@
   if (!current_leaf_) {
     DLOG(ERROR) << "Failed to read node " << current_leaf_number_;
     leaf_iterator_.reset();
+    record_offsets_.clear();
     return false;
   }
   if (current_leaf_->kind != kBTLeafNode) {
     DLOG(ERROR) << "Node " << current_leaf_number_ << " is not a leaf";
     current_leaf_ = nullptr;
     leaf_iterator_.reset();
+    record_offsets_.clear();
     return false;
   }
+
+  // Each B-tree node ends with a table of `numRecords + 1` big-endian uint16
+  // offsets, stored in reverse order at the end of the node. Entry `i` gives
+  // the byte offset of record `i` within the node and the final entry marks
+  // the start of free space. Records must lie between the node descriptor and
+  // the offset table itself.
+  const size_t num_offsets =
+      static_cast<size_t>(current_leaf_->numRecords) + 1u;
+  const size_t table_size = num_offsets * sizeof(uint16_t);
+  if (table_size > leaf_data_.size() - sizeof(BTNodeDescriptor)) {
+    DLOG(ERROR) << "Node " << current_leaf_number_
+                << " offset table overflows node";
+    current_leaf_ = nullptr;
+    leaf_iterator_.reset();
+    record_offsets_.clear();
+    return false;
+  }
+  const size_t records_end = leaf_data_.size() - table_size;
+  record_offsets_.resize(num_offsets);
+  for (size_t i = 0; i < num_offsets; ++i) {
+    leaf_iterator_->Seek(leaf_data_.size() - (i + 1) * sizeof(uint16_t));
+    auto offset = CopyLeafDataHostEndian<uint16_t>();
+    if (!offset.has_value() || *offset < sizeof(BTNodeDescriptor) ||
+        *offset > records_end || (i > 0 && *offset < record_offsets_[i - 1])) {
+      DLOG(ERROR) << "Node " << current_leaf_number_
+                  << " has inconsistent record offset table";
+      current_leaf_ = nullptr;
+      leaf_iterator_.reset();
+      record_offsets_.clear();
+      return false;
+    }
+    record_offsets_[i] = *offset;
+  }
+
   current_leaf_records_read_ = 0u;
   read_current_leaf_ = true;
   return true;
@@ -769,19 +841,6 @@
   return object;
 }
 
-template <typename T>
-bool HFSBTreeIterator::AdvanceLeafPast() {
-  CHECK(leaf_iterator_);
-  base::CheckedNumeric<size_t> size = sizeof(T);
-  auto new_position = size + leaf_iterator_->position();
-  if (!new_position.IsValid() ||
-      new_position.ValueOrDie() > leaf_iterator_->total_size()) {
-    return false;
-  }
-  leaf_iterator_->Seek(new_position.ValueOrDie());
-  return true;
-}
-
 bool HFSBTreeIterator::IsKeyUnexported(const std::u16string& key) {
   return key == kHFSDirMetadataFolder ||
          key == kHFSMetadataFolder;
diff --git a/chrome/utility/safe_browsing/mac/hfs.h b/chrome/utility/safe_browsing/mac/hfs.h
index 10430c44..beeb3d6 100644
--- a/chrome/utility/safe_browsing/mac/hfs.h
+++ b/chrome/utility/safe_browsing/mac/hfs.h
@@ -55,10 +55,12 @@
   // is not legal to call any other methods.
   bool Next();
 
-  // Returns true if the current iterator item is a directory and false if it
-  // is a file.
+  // Returns true if the current iterator item is a directory.
   bool IsDirectory();
 
+  // Returns true if the current iterator item is a file.
+  bool IsFile();
+
   // Returns true if the current iterator item is a symbolic link.
   bool IsSymbolicLink();
 
diff --git a/chrome/utility/safe_browsing/mac/hfs_unittest.cc b/chrome/utility/safe_browsing/mac/hfs_unittest.cc
index ba975781..fbbced6 100644
--- a/chrome/utility/safe_browsing/mac/hfs_unittest.cc
+++ b/chrome/utility/safe_browsing/mac/hfs_unittest.cc
@@ -4,12 +4,14 @@
 
 #include "chrome/utility/safe_browsing/mac/hfs.h"
 
+#include <libkern/OSByteOrder.h>
 #include <stddef.h>
 #include <stdint.h>
 
 #include <array>
 #include <memory>
 #include <string_view>
+#include <vector>
 
 #include "base/compiler_specific.h"
 #include "base/containers/span.h"
@@ -222,6 +224,250 @@
   EXPECT_EQ(0u, maybe_data->size());
 }
 
+// Builds a minimal HFS+ image in memory whose catalog leaf node lays out
+// records using the trailing record-offset table and a `keyLength` larger than
+// the catalog key's name would imply. The iterator must locate records via the
+// offset table and locate each record's data via `keyLength`, as described in
+// TN1150 "Node Structure".
+TEST(HFSBTreeIteratorTest, LeafRecordsLocatedByOffsetTableAndKeyLength) {
+  constexpr uint32_t kBlockSize = 4096;
+  constexpr uint16_t kNodeSize = 4096;
+  constexpr uint32_t kCatalogStartBlock = 1;
+  constexpr uint32_t kCatalogBlockCount = 2;
+  constexpr uint32_t kFileDataBlock = 3;
+  constexpr size_t kCatalogOffset = kCatalogStartBlock * kBlockSize;
+  constexpr size_t kLeafOffset = kCatalogOffset + kNodeSize;
+  constexpr std::string_view kFileData = "hello";
+
+  std::vector<uint8_t> image(4 * kBlockSize, 0);
+
+  auto put16 = [&](size_t at, uint16_t v) {
+    v = OSSwapHostToBigInt16(v);
+    base::span(image).subspan(at).copy_prefix_from(base::byte_span_from_ref(v));
+  };
+  auto put32 = [&](size_t at, uint32_t v) {
+    v = OSSwapHostToBigInt32(v);
+    base::span(image).subspan(at).copy_prefix_from(base::byte_span_from_ref(v));
+  };
+
+  // HFSPlusVolumeHeader at byte 1024.
+  constexpr size_t kVH = 1024;
+  HFSPlusVolumeHeader header = {};
+  header.signature = OSSwapHostToBigInt16(kHFSPlusSigWord);  // signature
... (file diff truncated)
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/chrome/utility/safe_browsing/mac/hfs_unittest.cc b/chrome/utility/safe_browsing/mac/hfs_unittest.cc
index ba975781..fbbced6 100644
--- a/chrome/utility/safe_browsing/mac/hfs_unittest.cc
+++ b/chrome/utility/safe_browsing/mac/hfs_unittest.cc
@@ -4,12 +4,14 @@
 
 #include "chrome/utility/safe_browsing/mac/hfs.h"
 
+#include <libkern/OSByteOrder.h>
 #include <stddef.h>
 #include <stdint.h>
 
 #include <array>
 #include <memory>
 #include <string_view>
+#include <vector>
 
 #include "base/compiler_specific.h"
 #include "base/containers/span.h"
@@ -222,6 +224,250 @@
   EXPECT_EQ(0u, maybe_data->size());
 }
 
+// Builds a minimal HFS+ image in memory whose catalog leaf node lays out
+// records using the trailing record-offset table and a `keyLength` larger than
+// the catalog key's name would imply. The iterator must locate records via the
+// offset table and locate each record's data via `keyLength`, as described in
+// TN1150 "Node Structure".
+TEST(HFSBTreeIteratorTest, LeafRecordsLocatedByOffsetTableAndKeyLength) {
+  constexpr uint32_t kBlockSize = 4096;
+  constexpr uint16_t kNodeSize = 4096;
+  constexpr uint32_t kCatalogStartBlock = 1;
+  constexpr uint32_t kCatalogBlockCount = 2;
+  constexpr uint32_t kFileDataBlock = 3;
+  constexpr size_t kCatalogOffset = kCatalogStartBlock * kBlockSize;
+  constexpr size_t kLeafOffset = kCatalogOffset + kNodeSize;
+  constexpr std::string_view kFileData = "hello";
+
+  std::vector<uint8_t> image(4 * kBlockSize, 0);
+
+  auto put16 = [&](size_t at, uint16_t v) {
+    v = OSSwapHostToBigInt16(v);
+    base::span(image).subspan(at).copy_prefix_from(base::byte_span_from_ref(v));
+  };
+  auto put32 = [&](size_t at, uint32_t v) {
+    v = OSSwapHostToBigInt32(v);
+    base::span(image).subspan(at).copy_prefix_from(base::byte_span_from_ref(v));
+  };
+
+  // HFSPlusVolumeHeader at byte 1024.
+  constexpr size_t kVH = 1024;
+  HFSPlusVolumeHeader header = {};
+  header.signature = OSSwapHostToBigInt16(kHFSPlusSigWord);  // signature
+  header.version = OSSwapHostToBigInt16(kHFSPlusVersion);    // version
+  header.blockSize = OSSwapHostToBigInt32(kBlockSize);       // blockSize
+  header.totalBlocks =
+      OSSwapHostToBigInt32(image.size() / kBlockSize);  // totalBlocks
+
+  header.catalogFile.logicalSize =
+      OSSwapHostToBigInt64(kCatalogBlockCount * kBlockSize);
+  header.catalogFile.totalBlocks = OSSwapHostToBigInt32(kCatalogBlockCount);
+  header.catalogFile.extents[0].startBlock =
+      OSSwapHostToBigInt32(kCatalogStartBlock);
+  header.catalogFile.extents[0].blockCount =
+      OSSwapHostToBigInt32(kCatalogBlockCount);
+  base::span(image)
+      .subspan(kVH, sizeof(header))
+      .copy_from(base::byte_span_from_ref(header));
+
+  // Catalog header node (node 0): BTNodeDescriptor + BTHeaderRec.
+  BTNodeDescriptor header_descriptor = {};
+  header_descriptor.kind = kBTHeaderNode;
+  header_descriptor.numRecords = OSSwapHostToBigInt16(3);
+  base::span(image)
+      .subspan(kCatalogOffset, sizeof(header_descriptor))
+      .copy_from(base::byte_span_from_ref(header_descriptor));
+
+  BTHeaderRec header_rec = {};
+  header_rec.treeDepth = OSSwapHostToBigInt16(1);
+  header_rec.rootNode = OSSwapHostToBigInt32(1);
+  header_rec.leafRecords = OSSwapHostToBigInt32(2);
+  header_rec.firstLeafNode = OSSwapHostToBigInt32(1);
+  header_rec.lastLeafNode = OSSwapHostToBigInt32(1);
+  header_rec.nodeSize = OSSwapHostToBigInt16(kNodeSize);
+  header_rec.maxKeyLength =
+      OSSwapHostToBigInt16(kHFSPlusCatalogKeyMaximumLength);
+  header_rec.totalNodes = OSSwapHostToBigInt32(2);
+  base::span(image)
+      .subspan(kCatalogOffset + sizeof(header_descriptor), sizeof(header_rec))
+      .copy_from(base::byte_span_from_ref(header_rec));
+
+  // Catalog leaf node (node 1).
+  BTNodeDescriptor leaf_descriptor = {};
+  leaf_descriptor.kind = static_cast<uint8_t>(kBTLeafNode);
+  leaf_descriptor.height = 1;
+  leaf_descriptor.numRecords = OSSwapHostToBigInt16(2);
+  base::span(image)
+      .subspan(kLeafOffset, sizeof(leaf_descriptor))
+      .copy_from(base::byte_span_from_ref(leaf_descriptor));
+
+  // Record 0: root folder. The key declares a length larger than the bytes
+  // occupied by parentID + nodeName, leaving padding before the data.
+  constexpr uint16_t kRecord0 = sizeof(leaf_descriptor);
+  constexpr uint16_t kRecord0KeyLength = 32;
+  put16(kLeafOffset + kRecord0, kRecord0KeyLength);     // keyLength
+  put32(kLeafOffset + kRecord0 + 2, kHFSRootParentID);  // parentID
+  put16(kLeafOffset + kRecord0 + 6, 1);                 // nodeName.length
+  put16(kLeafOffset + kRecord0 + 8, 'V');               // nodeName.unicode[0]
+
+  constexpr size_t kFolderData =
+      kLeafOffset + kRecord0 + sizeof(uint16_t) + kRecord0KeyLength;
+  HFSPlusCatalogFolder folder = {};
+  folder.recordType = OSSwapHostToBigInt16(kHFSPlusFolderRecord);
+  folder.folderID = OSSwapHostToBigInt32(kHFSRootFolderID);
+  base::span(image)
+      .subspan(kFolderData, sizeof(folder))
+      .copy_from(base::byte_span_from_ref(folder));
+  static_assert(sizeof(HFSPlusCatalogFolder) == 88);
+  constexpr uint16_t kRecord0End = kRecord0 + sizeof(uint16_t) +
+                                   kRecord0KeyLength +
+                                   sizeof(HFSPlusCatalogFolder);
+
+  // Record 1: file. Placed at a non-contiguous offset relative to record 0.
+  constexpr uint16_t kRecord1 = 600;
+  static_assert(kRecord1 > kRecord0End);
+  constexpr uint16_t kRecord1KeyLength = 8;
+  put16(kLeafOffset + kRecord1, kRecord1KeyLength);     // keyLength
+  put32(kLeafOffset + kRecord1 + 2, kHFSRootFolderID);  // parentID
+  put16(kLeafOffset + kRecord1 + 6, 1);                 // nodeName.length
+  put16(kLeafOffset + kRecord1 + 8, 'f');               // nodeName.unicode[0]
+
+  constexpr size_t kFileRec =
+      kLeafOffset + kRecord1 + sizeof(uint16_t) + kRecord1KeyLength;
+  HFSPlusCatalogFile file = {};
+  file.recordType = OSSwapHostToBigInt16(kHFSPlusFileRecord);
+  file.fileID = OSSwapHostToBigInt32(kHFSFirstUserCatalogNodeID);
+  file.dataFork.logicalSize = OSSwapHostToBigInt64(kFileData.size());
+  file.dataFork.totalBlocks = OSSwapHostToBigInt32(1);
+  file.dataFork.extents[0].startBlock = OSSwapHostToBigInt32(kFileDataBlock);
+  file.dataFork.extents[0].blockCount = OSSwapHostToBigInt32(1);
+  base::span(image)
+      .subspan(kFileRec, sizeof(file))
+      .copy_from(base::byte_span_from_ref(file));
+
+  static_assert(sizeof(file) == 248);
+  constexpr uint16_t kRecord1End =
+      kRecord1 + sizeof(uint16_t) + kRecord1KeyLength + sizeof(file);
+
+  // Record offset table at the end of the leaf, in reverse order.
+  put16(kLeafOffset + kNodeSize - 2, kRecord0);
+  put16(kLeafOffset + kNodeSize - 4, kRecord1);
+  put16(kLeafOffset + kNodeSize - 6, kRecord1End);
+
+  // File data fork contents.
+  base::span(image)
+      .subspan(kFileDataBlock * kBlockSize)
+      .copy_prefix_from(base::as_byte_span(kFileData));
+
+  MemoryReadStream stream(image);
+  HFSIterator hfs_reader(&stream);
+  EXPECT_TRUE(hfs_reader.Open());
+
+  bool next1 = hfs_reader.Next();
+  EXPECT_TRUE(next1);
+  if (next1) {
+    EXPECT_TRUE(hfs_reader.IsDirectory());
+    EXPECT_FALSE(hfs_reader.IsFile());
+    EXPECT_EQ(u"V", hfs_reader.GetPath());
+  }
+
+  bool next2 = hfs_reader.Next();
+  EXPECT_TRUE(next2);
+  if (next2) {
+    EXPECT_FALSE(hfs_reader.IsDirectory());
+    EXPECT_TRUE(hfs_reader.IsFile());
+    EXPECT_FALSE(hfs_reader.IsHardLink());
+    EXPECT_EQ(u"V/f", hfs_reader.GetPath());
+
+    std::unique_ptr<ReadStream> file_stream = hfs_reader.GetReadStream();
+    EXPECT_TRUE(file_stream);
+    if (file_stream) {
+      auto data = ReadEntireStream(*file_stream);
+      EXPECT_TRUE(data.has_value());
+      if (data.has_value()) {
+        EXPECT_EQ(kFileData, base::as_string_view(*data));
+      }
+    }
+  }
+
+  EXPECT_FALSE(hfs_reader.Next());
+}
+
+TEST(HFSBTreeIteratorTest, InconsistentOffsetTableRejected) {
+  constexpr uint32_t kBlockSize = 4096;
+  constexpr uint16_t kNodeSize = 4096;
+  constexpr uint32_t kCatalogStartBlock = 1;
+  constexpr uint32_t kCatalogBlockCount = 2;
+  constexpr size_t kCatalogOffset = kCatalogStartBlock * kBlockSize;
+  constexpr size_t kLeafOffset = kCatalogOffset + kNodeSize;
+
+  std::vector<uint8_t> image(3 * kBlockSize, 0);
+
+  auto put16 = [&](size_t at, uint16_t v) {
+    v = OSSwapHostToBigInt16(v);
+    base::span(image).subspan(at).copy_prefix_from(base::byte_span_from_ref(v));
+  };
+
+  // HFSPlusVolumeHeader at byte 1024.
+  constexpr size_t kVH = 1024;
+  HFSPlusVolumeHeader header = {};
+  header.signature = OSSwapHostToBigInt16(kHFSPlusSigWord);
+  header.version = OSSwapHostToBigInt16(kHFSPlusVersion);
+  header.blockSize = OSSwapHostToBigInt32(kBlockSize);
+  header.totalBlocks = OSSwapHostToBigInt32(image.size() / kBlockSize);
+
+  header.catalogFile.logicalSize =
+      OSSwapHostToBigInt64(kCatalogBlockCount * kBlockSize);
+  header.catalogFile.totalBlocks = OSSwapHostToBigInt32(kCatalogBlockCount);
+  header.catalogFile.extents[0].startBlock =
+      OSSwapHostToBigInt32(kCatalogStartBlock);
+  header.catalogFile.extents[0].blockCount =
+      OSSwapHostToBigInt32(kCatalogBlockCount);
+  base::span(image)
+      .subspan(kVH, sizeof(header))
+      .copy_from(base::byte_span_from_ref(header));
+
+  // Catalog header node (node 0): BTNodeDescriptor + BTHeaderRec.
+  BTNodeDescriptor header_descriptor = {};
+  header_descriptor.kind = kBTHeaderNode;
+  header_descriptor.numRecords = OSSwapHostToBigInt16(3);
+  base::span(image)
+      .subspan(kCatalogOffset, sizeof(header_descriptor))
+      .copy_from(base::byte_span_from_ref(header_descriptor));
+
+  BTHeaderRec header_rec = {};
+  header_rec.treeDepth = OSSwapHostToBigInt16(1);
+  header_rec.rootNode = OSSwapHostToBigInt32(1);
+  header_rec.leafRecords = OSSwapHostToBigInt32(1);
+  header_rec.firstLeafNode = OSSwapHostToBigInt32(1);
+  header_rec.lastLeafNode = OSSwapHostToBigInt32(1);
+  header_rec.nodeSize = OSSwapHostToBigInt16(kNodeSize);
+  header_rec.maxKeyLength =
+      OSSwapHostToBigInt16(kHFSPlusCatalogKeyMaximumLength);
+  header_rec.totalNodes = OSSwapHostToBigInt32(2);
+  base::span(image)
+      .subspan(kCatalogOffset + sizeof(header_descriptor), sizeof(header_rec))
+      .copy_from(base::byte_span_from_ref(header_rec));
+
+  // Catalog leaf node (node 1).
+  BTNodeDescriptor leaf_descriptor = {};
+  leaf_descriptor.kind = static_cast<uint8_t>(kBTLeafNode);
+  leaf_descriptor.height = 1;
+  leaf_descriptor.numRecords = OSSwapHostToBigInt16(1);
+  base::span(image)
+      .subspan(kLeafOffset, sizeof(leaf_descriptor))
+      .copy_from(base::byte_span_from_ref(leaf_descriptor));
+
+  // Record offset table: set offset[0] to an invalid small value (< 14).
+  put16(kLeafOffset + kNodeSize - 2, 10);
+  put16(kLeafOffset + kNodeSize - 4, 100);
+
+  MemoryReadStream stream(image);
+  HFSIterator hfs_reader(&stream);
+  EXPECT_TRUE(hfs_reader.Open());
+  EXPECT_FALSE(hfs_reader.Next());
+}
+
 INSTANTIATE_TEST_SUITE_P(HFSIteratorTest,
                          HFSFileReadTest,
                          testing::Values("hfs_plus.img",
Loading diff…

Original Bug Report

The reporter's bug is still restricted on the tracker. Chrome de-restricts security bugs ~30–90 days after the fix ships; a later run will backfill it here.