Overview

Low
Severity
4.3
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactIncorrect type conversion or cast in Safebrowsing
DescriptionIncorrect type conversion or cast in Safebrowsing
ComponentSafebrowsing
Sub-componentMac
Bug ClassType Confusion
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.
Tracker517926950
Fix commit8b011e55ac43 (chromium/src) +83/-13
CWECWE-704 (Incorrect type conversion)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:L/A:N
CISA KEVNot listed
CreditedGoogle
Reported2026-05-29 (per vendor advisory)
Fix landed2026-07-30 (public git)
Disclosed2026-09-08
Patch gap40 days — fix public in source before the advisory

Background

UDIF / DMG parsing
Chrome’s Safe Browsing analyzes downloaded macOS disk images (.dmg) by parsing their UDIF block table in chrome/utility/safe_browsing/mac/udif.cc so 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 into decompress_buffer_.
`avail_in` / `avail_out`
the 32-bit (uInt / unsigned int) input- and output-remaining fields of zlib’s z_stream and bzip2’s bz_stream, which are narrower than a 64-bit size_t buffer length.
Narrowing conversion
assigning a 64-bit size_t into a 32-bit field silently truncates the high bits when the value exceeds UINT_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.

Key insight
The single core mistake is an incorrect narrowing type conversion — a 64-bit 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

  1. Craft an oversized compressed chunk Build a .dmg whose UDIF block table declares a zlib (UDZO) or bzip2 (UDBZ) chunk whose decompressed size, chunk->sector_count * block_size (* 512), exceeds UINT_MAX.
  2. Deliver for Safe Browsing analysis Have the target download the crafted DMG so Chrome’s utility-process DMG analyzer parses it and constructs a UDIFBlockChunkReadStream for that chunk, resizing decompress_buffer_ to the full length_in_bytes_.
  3. Trigger the truncated decompress In the pre-patch HandleZLib/HandleBZ2, avail_out = decompress_buffer_.size() truncates to a smaller 32-bit value, so the single inflate(Z_FINISH) / BZ2_bzDecompress() returns non-Z_STREAM_END/BZ_STREAM_END and the chunk is treated as a decompression failure.
  4. 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

Per the commit message, an attacker who crafts a DMG with a chunk decompressing to more than 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

FunctionChangeNotes
did_decompress_
chrome/utility/safe_browsing/mac/udif.cc
modified
if
chrome/utility/safe_browsing/mac/udif.cc
modified
TEST_P
chrome/utility/safe_browsing/mac/udif_unittest.cc
modified

Files Changed

  • chrome/utility/safe_browsing/mac/udif.h
  • chrome/utility/safe_browsing/mac/udif.cc
  • chrome/utility/safe_browsing/mac/udif_unittest.cc

Audit Directions

  • Pattern: `size_t` → 32-bit compression-field assignment
    Audit every other assignment of a size_t/64-bit length into z_stream/bz_stream avail_in/avail_out (or analogous 32-bit API fields) across chrome/utility/safe_browsing/ and other decompression call sites for silent narrowing and single-shot (non-looping) decompression.
  • Pattern: one-shot decompress on attacker-sized buffers
    Look 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.
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},
Loading diff…

Regression Test / PoC

shipped with the fix
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},
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.