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 <[email protected]> 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 <[email protected]> Commit-Queue: Tiffany Song <[email protected]> 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},