Chrome · Safebrowsing
CVE-2026-87627
Logic Error in Safebrowsing
Overview
Low
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifchrome/utility/safe_browsing/mac/hfs.cc |
modified | |
switchchrome/utility/safe_browsing/mac/hfs.cc |
modified |
Files Changed
chrome/utility/safe_browsing/mac/hfs.cc
Patch
From 356828e2157d030483c91b2774a59bd8e583556e Mon Sep 17 00:00:00 2001
From: Tiffany Song <tiffanysong@google.com>
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 <tiffanysong@google.com>
Reviewed-by: Javier Castro <jacastro@chromium.org>
Reviewed-by: Yaw Frempong <yawfrempong@google.com>
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;
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.
References
On This Page