CVE-2026-87546
Overview
Background
- UDIF / DMG parsing
- Chrome’s Safe Browsing analyzes downloaded macOS disk images (
.dmg) by parsing their UDIF block table inchrome/utility/safe_browsing/mac/udif.ccso partition contents can be scanned. - `UDIFBlockChunkReadStream`
- the class that reads one UDIF block chunk, sizing its output via
length_in_bytes_(chunk->sector_count * block_size) and decompressing zlib/bzip2 chunks intodecompress_buffer_. - `avail_in` / `avail_out`
- the 32-bit (
uInt/unsigned int) input- and output-remaining fields of zlib’sz_streamand bzip2’sbz_stream, which are narrower than a 64-bitsize_tbuffer length. - Narrowing conversion
- assigning a 64-bit
size_tinto a 32-bit field silently truncates the high bits when the value exceedsUINT_MAX.
Root Cause Analysis
The commit message states that the zlib and bzip2 handlers assigned size_t buffer lengths directly into the 32-bit avail_in/avail_out fields and then called the decompressor exactly once (inflate(&zlib, Z_FINISH) and BZ2_bzDecompress(&bz)). The removed lines confirm this: zlib.avail_out = decompress_buffer_.size(); and bz.avail_out = decompress_buffer_.size();, where decompress_buffer_ is resized to length_in_bytes_ = chunk->sector_count * block_size. When a chunk’s decompressed size exceeded UINT_MAX, this incorrect narrowing conversion truncated the length, so the single decompression pass could not produce the full output and returned something other than Z_STREAM_END / BZ_STREAM_END, causing the chunk (and its partition) to be rejected/skipped during DMG analysis.
The fix replaces the single truncating call with the standard streaming idiom: it iterates inflate() / BZ2_bzDecompress() in a loop, refilling avail_in/avail_out from remaining 64-bit base::span byte counts via base::checked_cast<uInt>(std::min(remaining.size(), GetMaxDecompressChunkSize())), advancing the spans by bytes_consumed/bytes_produced each iteration. The constructor was also hardened: the weak post-multiply check CHECK(length_in_bytes_ == 0 || length_in_bytes_ >= block_size) was replaced with an explicit base::CheckedNumeric<size_t> multiplication validated by CHECK(length.AssignIfValid(&length_in_bytes_)), so an overflowing sector_count * block_size is detected outright rather than inferred after the fact.
size_t length assigned into zlib/bzip2’s 32-bit avail_in/avail_out — combined with a one-shot decompress call, so chunks larger than UINT_MAX were silently truncated and then rejected; the fix drives the decompressor in a loop and feeds it base::checked_cast-bounded slices of the full 64-bit lengths.Attack Path
- Craft an oversized compressed chunk
Build a
.dmgwhose UDIF block table declares a zlib (UDZO) or bzip2 (UDBZ) chunk whose decompressed size,chunk->sector_count * block_size(* 512), exceedsUINT_MAX. - Deliver for Safe Browsing analysis
Have the target download the crafted DMG so Chrome’s utility-process DMG analyzer parses it and constructs a
UDIFBlockChunkReadStreamfor that chunk, resizingdecompress_buffer_to the fulllength_in_bytes_. - Trigger the truncated decompress
In the pre-patch
HandleZLib/HandleBZ2,avail_out = decompress_buffer_.size()truncates to a smaller 32-bit value, so the singleinflate(Z_FINISH)/BZ2_bzDecompress()returns non-Z_STREAM_END/BZ_STREAM_ENDand the chunk is treated as a decompression failure. - Evade content scanning The rejected chunk causes its partition to be skipped during DMG analysis, so any malicious contents in that partition are not examined by Safe Browsing (inferred: this evasion lets malicious payloads reach the user unscanned).
Impact Assessment
UINT_MAX bytes causes that partition to be skipped during Safe Browsing DMG analysis, i.e., the affected content is not scanned; this occurs in Chrome’s sandboxed Safe Browsing utility process on macOS when a crafted .dmg is processed. The precondition is that the declared decompressed chunk size exceeds UINT_MAX (sector_count * 512 > UINT_MAX), which also drives a large decompress_buffer_ allocation. Downstream consequences beyond the skipped-partition behavior the patch demonstrates — malware successfully bypassing detection and compromising the user — are (inferred); the advisory’s stated class is “Incorrect type conversion or cast in Safebrowsing,” and no exploited-in-the-wild status or memory-corruption primitive is established by the diff.Changed Functions
| Function | Change | Notes |
|---|---|---|
did_decompress_chrome/utility/safe_browsing/mac/udif.cc |
modified | |
ifchrome/utility/safe_browsing/mac/udif.cc |
modified | |
TEST_Pchrome/utility/safe_browsing/mac/udif_unittest.cc |
modified |
Files Changed
chrome/utility/safe_browsing/mac/udif.hchrome/utility/safe_browsing/mac/udif.ccchrome/utility/safe_browsing/mac/udif_unittest.cc
Audit Directions
- Pattern: `size_t` → 32-bit compression-field assignmentAudit every other assignment of a
size_t/64-bit length intoz_stream/bz_streamavail_in/avail_out(or analogous 32-bit API fields) acrosschrome/utility/safe_browsing/and other decompression call sites for silent narrowing and single-shot (non-looping) decompression. - Pattern: one-shot decompress on attacker-sized buffersLook for
inflate/BZ2_bzDecompress(and similar) invoked once without a refill loop, where the output buffer size derives from attacker-controlled counts, since large inputs can be silently truncated or rejected.
Patch
From 8b011e55ac4324a0250b338e95881025cec4a17f Mon Sep 17 00:00:00 2001
From: Tiffany Song <tiffanysong@google.com>
Date: Thu, 30 Jul 2026 10:14:52 -0700
Subject: [PATCH] [mac] UDIF: handle zlib/bzip2 chunks larger than uInt
UDIFBlockChunkReadStream::HandleZLib() and HandleBZ2() assigned size_t
buffer lengths directly to z_stream/bz_stream's 32-bit
avail_in/avail_out fields and called the decompressor once. When a
chunk's decompressed size (sector_count * 512) exceeded UINT_MAX the
assignment truncated and the chunk was rejected, causing the partition
to be skipped during DMG analysis.
Switch both functions to the standard streaming idiom: call
inflate()/BZ2_bzDecompress() in a loop, refilling avail_in / avail_out
via base::saturated_cast<uInt>() from the remaining 64-bit byte counts
whenever they reach zero. For chunks that fit in 32 bits the loop runs
once and behavior is unchanged.
Fixed: 517926950
Change-Id: Id7f4137786dea7635f7ae4525109daee41fa4a3e
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8167125
Reviewed-by: Yaw Frempong <yawfrempong@google.com>
Commit-Queue: Tiffany Song <tiffanysong@google.com>
Cr-Commit-Position: refs/heads/main@{#1671186}
---
diff --git a/chrome/utility/safe_browsing/mac/udif.cc b/chrome/utility/safe_browsing/mac/udif.cc
index 7babcae2..48245fa7 100644
--- a/chrome/utility/safe_browsing/mac/udif.cc
+++ b/chrome/utility/safe_browsing/mac/udif.cc
@@ -11,6 +11,7 @@
#include <algorithm>
#include <array>
+#include <limits>
#include <memory>
#include <optional>
#include <utility>
@@ -26,6 +27,7 @@
#include "base/notimplemented.h"
#include "base/notreached.h"
#include "base/numerics/ostream_operators.h"
+#include "base/numerics/safe_conversions.h"
#include "base/numerics/safe_math.h"
#include "base/strings/sys_string_conversions.h"
#include "chrome/utility/safe_browsing/mac/convert_big_endian.h"
@@ -363,6 +365,11 @@
} // namespace
+size_t& GetMaxDecompressChunkSize() {
+ static size_t max_chunk_size = std::numeric_limits<uint32_t>::max();
+ return max_chunk_size;
+}
+
UDIFParser::UDIFParser(ReadStream* stream)
: stream_(stream),
partition_names_(),
@@ -744,12 +751,12 @@
const UDIFBlockChunk* chunk)
: stream_(stream),
chunk_(chunk),
- length_in_bytes_(chunk->sector_count * block_size),
offset_(0),
decompress_buffer_(),
did_decompress_(false) {
- // Make sure the multiplication above did not overflow.
- CHECK(length_in_bytes_ == 0 || length_in_bytes_ >= block_size);
+ base::CheckedNumeric<size_t> length =
+ base::CheckedNumeric<size_t>(chunk->sector_count) * block_size;
+ CHECK(length.AssignIfValid(&length_in_bytes_));
}
UDIFBlockChunkReadStream::~UDIFBlockChunkReadStream() = default;
@@ -849,12 +856,38 @@
}
decompress_buffer_.resize(length_in_bytes_);
- zlib.next_in = compressed_data.data();
- zlib.avail_in = compressed_data.size();
- zlib.next_out = decompress_buffer_.data();
- zlib.avail_out = decompress_buffer_.size();
+ base::span<const uint8_t> remaining_in = compressed_data;
+ base::span<uint8_t> remaining_out = decompress_buffer_;
- int rv = inflate(&zlib, Z_FINISH);
+ int rv = Z_OK;
+ while (rv == Z_OK && (!remaining_in.empty() || !remaining_out.empty())) {
+ zlib.next_in = const_cast<uint8_t*>(remaining_in.data());
+ zlib.avail_in = base::checked_cast<uInt>(
+ std::min(remaining_in.size(), GetMaxDecompressChunkSize()));
+ zlib.next_out = remaining_out.data();
+ zlib.avail_out = base::checked_cast<uInt>(
+ std::min(remaining_out.size(), GetMaxDecompressChunkSize()));
+
+ uInt avail_in_before = zlib.avail_in;
+ uInt avail_out_before = zlib.avail_out;
+
+ int flush = (remaining_in.size() <= GetMaxDecompressChunkSize() &&
+ remaining_out.size() <= GetMaxDecompressChunkSize())
+ ? Z_FINISH
+ : Z_NO_FLUSH;
+ rv = inflate(&zlib, flush);
+
+ size_t bytes_consumed = avail_in_before - zlib.avail_in;
+ size_t bytes_produced = avail_out_before - zlib.avail_out;
+
+ if (bytes_consumed == 0 && bytes_produced == 0 && rv == Z_OK) {
+ break;
+ }
+
+ remaining_in = remaining_in.subspan(bytes_consumed);
+ remaining_out = remaining_out.subspan(bytes_produced);
+ }
+
inflateEnd(&zlib);
if (rv != Z_STREAM_END) {
@@ -884,12 +917,35 @@
}
decompress_buffer_.resize(length_in_bytes_);
- bz.next_in = reinterpret_cast<char*>(compressed_data.data());
- bz.avail_in = compressed_data.size();
- bz.next_out = reinterpret_cast<char*>(decompress_buffer_.data());
- bz.avail_out = decompress_buffer_.size();
+ base::span<const uint8_t> remaining_in = compressed_data;
+ base::span<uint8_t> remaining_out = decompress_buffer_;
- int rv = BZ2_bzDecompress(&bz);
+ int rv = BZ_OK;
+ while (rv == BZ_OK && (!remaining_in.empty() || !remaining_out.empty())) {
+ bz.next_in =
+ const_cast<char*>(reinterpret_cast<const char*>(remaining_in.data()));
+ bz.avail_in = base::checked_cast<unsigned int>(
+ std::min(remaining_in.size(), GetMaxDecompressChunkSize()));
+ bz.next_out = reinterpret_cast<char*>(remaining_out.data());
+ bz.avail_out = base::checked_cast<unsigned int>(
+ std::min(remaining_out.size(), GetMaxDecompressChunkSize()));
+
+ unsigned int avail_in_before = bz.avail_in;
+ unsigned int avail_out_before = bz.avail_out;
+
+ rv = BZ2_bzDecompress(&bz);
+
+ size_t bytes_consumed = avail_in_before - bz.avail_in;
+ size_t bytes_produced = avail_out_before - bz.avail_out;
+
+ if (bytes_consumed == 0 && bytes_produced == 0 && rv == BZ_OK) {
+ break;
+ }
+
+ remaining_in = remaining_in.subspan(bytes_consumed);
+ remaining_out = remaining_out.subspan(bytes_produced);
+ }
+
BZ2_bzDecompressEnd(&bz);
if (rv != BZ_STREAM_END) {
diff --git a/chrome/utility/safe_browsing/mac/udif.h b/chrome/utility/safe_browsing/mac/udif.h
index ee4d7de..932151d3 100644
--- a/chrome/utility/safe_browsing/mac/udif.h
+++ b/chrome/utility/safe_browsing/mac/udif.h
@@ -90,6 +90,9 @@
std::vector<uint8_t> signature_blob_; // DMG signature.
};
+// Returns a reference to the maximum chunk size used for decompression.
+size_t& GetMaxDecompressChunkSize();
+
} // namespace dmg
} // namespace safe_browsing
diff --git a/chrome/utility/safe_browsing/mac/udif_unittest.cc b/chrome/utility/safe_browsing/mac/udif_unittest.cc
index 5927f5f..3950d91 100644
--- a/chrome/utility/safe_browsing/mac/udif_unittest.cc
+++ b/chrome/utility/safe_browsing/mac/udif_unittest.cc
@@ -11,6 +11,7 @@
#include <array>
+#include "base/auto_reset.h"
#include "base/compiler_specific.h"
#include "base/containers/span.h"
#include "base/files/file.h"
@@ -197,6 +198,16 @@
RunReadAllTest(100000);
}
+TEST_P(UDIFParserTest, ReadAll_SmallDecompressChunk_64) {
+ base::AutoReset<size_t> auto_reset(&GetMaxDecompressChunkSize(), 64);
+ RunReadAllTest(512);
+}
+
+TEST_P(UDIFParserTest, ReadAll_SmallDecompressChunk_1024) {
+ base::AutoReset<size_t> auto_reset(&GetMaxDecompressChunkSize(), 1024);
+ RunReadAllTest(512);
+}
+
constexpr UDIFTestCase cases[] = {
{"dmg_UDBZ_GPTSPUD.dmg", kGPTExpectedPartitions, UDIFTestCase::ALL_PASS},
{"dmg_UDBZ_NONE.dmg", kNoPartitionMap, UDIFTestCase::ALL_PASS},
Regression Test / PoC
diff --git a/chrome/utility/safe_browsing/mac/udif_unittest.cc b/chrome/utility/safe_browsing/mac/udif_unittest.cc
index 5927f5f..3950d91 100644
--- a/chrome/utility/safe_browsing/mac/udif_unittest.cc
+++ b/chrome/utility/safe_browsing/mac/udif_unittest.cc
@@ -11,6 +11,7 @@
#include <array>
+#include "base/auto_reset.h"
#include "base/compiler_specific.h"
#include "base/containers/span.h"
#include "base/files/file.h"
@@ -197,6 +198,16 @@
RunReadAllTest(100000);
}
+TEST_P(UDIFParserTest, ReadAll_SmallDecompressChunk_64) {
+ base::AutoReset<size_t> auto_reset(&GetMaxDecompressChunkSize(), 64);
+ RunReadAllTest(512);
+}
+
+TEST_P(UDIFParserTest, ReadAll_SmallDecompressChunk_1024) {
+ base::AutoReset<size_t> auto_reset(&GetMaxDecompressChunkSize(), 1024);
+ RunReadAllTest(512);
+}
+
constexpr UDIFTestCase cases[] = {
{"dmg_UDBZ_GPTSPUD.dmg", kGPTExpectedPartitions, UDIFTestCase::ALL_PASS},
{"dmg_UDBZ_NONE.dmg", kNoPartitionMap, UDIFTestCase::ALL_PASS},