Medium watch CVSS 6.3 chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
6.3
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInappropriate implementation in ORB
DescriptionInappropriate implementation in ORB
ComponentORB
Bug ClassLogic Error
Action tierwatch — information disclosure — security-relevant, below the RCE-class bar
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.
Tracker497529290
Fix commit74c46d0bc1dc (chromium/src) +27/-0
CWECWE-269
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:L/I:L/A:L
CISA KEVNot listed
CreditedGoogle
Reported2026-03-29 (per vendor advisory)
Fix landed2026-04-01 (public git)
Disclosed2026-05-05
Patch gap34 days — fix public in source before the advisory

Background

ORB (Opaque Response Blocking)
a network-service mechanism that inspects cross-origin response bodies and blocks delivery of ones that sniff as sensitive data formats (HTML/XML/JSON) to contexts not entitled to read them.
Content sniffing
heuristically classifying a byte stream by matching leading bytes against known signatures (e.g. <html, <?xml, JSON structure, parser-breaker prefixes).
UTF-8 BOM
the three-byte sequence \xEF\xBB\xBF that may optionally prefix a UTF-8 document and carries no textual content.
Parser breaker
a prefix such as for (;;); or while (1); prepended to JSON to defeat <script>-tag inclusion, which ORB treats as a signal the body is sensitive.

Root Cause Analysis

The ORB sniffers (SniffForHTML, SniffForXML, SniffForJSON, and the parser-breaker sniffer) matched signatures against the raw body starting at byte zero, and only AdvancePastWhitespace was applied before matching in some paths. A response that began with a legitimate UTF-8 BOM therefore pushed the real signature three bytes downstream, so the leading-byte comparison failed and the sniffer returned a non-kYes result for content that was, in fact, HTML/XML/JSON. Because ORB relies on these sniffers to decide to block a cross-origin response, a BOM-prefixed sensitive body could slip past classification and be delivered where it should have been blocked.

The fix adds AdvancePastUtf8Bom, which skips the kUtf8Bom prefix via remove_prefix before each sniff, restoring correct classification. The direction of the change is a tightening of detection: more bodies now correctly sniff as sensitive and get blocked.

Key insight
The single mistake was that the sniffers anchored their signature match at absolute byte zero without first skipping a semantically-empty UTF-8 BOM; adding AdvancePastUtf8Bom before each match realigns the comparison with the document’s true first meaningful byte.

Attack Path

  1. Host sensitive data with a BOM An attacker-controlled or victim server returns a cross-origin HTML/XML/JSON document whose body begins with the UTF-8 BOM \xEF\xBB\xBF.
  2. Request it in a disallowed context A malicious page fetches that resource cross-origin in a way ORB is meant to screen (e.g. as a subresource lacking CORS entitlement).
  3. Sniffers miss the signature The leading BOM shifts the real <html/<?xml/JSON/parser-breaker signature past offset zero, so the sniffer returns non-kYes and ORB does not classify the body as sensitive.
  4. Response is delivered ORB fails to block the response, allowing the cross-origin body to reach the requesting context (inferred: enabling cross-origin information disclosure; the patch demonstrates the classification miss, not a specific read primitive).

Impact Assessment

An attacker gains a means to bypass ORB’s cross-origin protection for responses that carry a UTF-8 BOM, potentially allowing a malicious page to obtain cross-origin sensitive content that ORB would otherwise block, within the renderer/network boundary. The advisory rates this medium severity (inappropriate implementation in ORB) and does not report in-the-wild exploitation. The precondition is that the targeted sensitive resource is served with a leading BOM.

Changed Functions

FunctionChangeNotes
if
services/network/orb/orb_sniffers.cc
modified
TEST
services/network/orb/orb_sniffers_unittest.cc
modified

Files Changed

  • services/network/orb/orb_sniffers.cc
  • services/network/orb/orb_sniffers_unittest.cc

Audit Directions

  • Byte-zero anchoring
    Audit any sniffer or signature matcher that compares against the start of a buffer for failure to skip optional prefixes (BOMs, whitespace, leading newlines) before matching.
  • Encoding-prefix confusion
    Review classification code that treats encoding artifacts (BOMs, byte-order marks for UTF-16/UTF-32) as content, where their presence can flip a security decision such as block-vs-allow.
  • Security decisions on best-effort heuristics
    Where a block/allow gate depends on a “best effort” sniff, check that every sniff entry point applies the same normalization so one path cannot be evaded while others are covered.
From 74c46d0bc1dcfcd14f5725f93a7aa61b0eb79ee1 Mon Sep 17 00:00:00 2001
From: Lukasz Anforowicz <lukasza@chromium.org>
Date: Wed, 01 Apr 2026 09:22:23 -0700
Subject: [PATCH] Teach ORB about UTF-8 BOM (byte order mark).

On one hand, ORB sniffing is a best effort heuristic.  OTOH, recognizing
and skipping the UTF-8 BOM is a fairly low hanging fruit, so it seems
worth implementing and landing this change.

Fixed: 497529290
Change-Id: I5c11865628a45cecf19afe2ff6773cb0a2d58cf9
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7717765
Reviewed-by: Charlie Reis <creis@chromium.org>
Commit-Queue: Łukasz Anforowicz <lukasza@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1608590}
---

diff --git a/services/network/orb/orb_sniffers.cc b/services/network/orb/orb_sniffers.cc
index 11dd17562..d3e27a0 100644
--- a/services/network/orb/orb_sniffers.cc
+++ b/services/network/orb/orb_sniffers.cc
@@ -25,6 +25,15 @@
 
 namespace {
 
+void AdvancePastUtf8Bom(std::string_view* data) {
+  // https://en.wikipedia.org/wiki/Byte_order_mark#UTF-8
+  const std::string_view kUtf8Bom("\xEF\xBB\xBF");
+
+  if (data->starts_with(kUtf8Bom)) {
+    data->remove_prefix(kUtf8Bom.size());
+  }
+}
+
 void AdvancePastWhitespace(std::string_view* data) {
   size_t offset = data->find_first_not_of(" \t\r\n");
   if (offset == std::string_view::npos) {
@@ -169,6 +178,7 @@
       std::string_view("<p")   // Mozilla
   };
 
+  AdvancePastUtf8Bom(&data);
   while (data.length() > 0) {
     AdvancePastWhitespace(&data);
 
@@ -192,6 +202,7 @@
   // TODO(dsjang): Once CrossOriginReadBlocking is moved into the browser
   // process, we should do single-thread checking here for the static
   // initializer.
+  AdvancePastUtf8Bom(&data);
   AdvancePastWhitespace(&data);
   static constexpr std::string_view kXmlSignatures[] = {
       std::string_view("<?xml")};
@@ -216,6 +227,7 @@
     kRightQuoteState,
   } state = kStartState;
 
+  AdvancePastUtf8Bom(&data);
   for (size_t i = 0; i < data.length(); ++i) {
     const char c = data[i];
     if (state != kLeftQuoteState && state != kEscapeState) {
@@ -297,6 +309,7 @@
       std::string_view("for (;;);"),
       std::string_view("while (1);"),
   };
+  AdvancePastUtf8Bom(&data);
   SniffingResult has_parser_breaker = MatchesSignature(
       &data, kScriptBreakingPrefixes, base::CompareCase::SENSITIVE);
   if (has_parser_breaker != kNo) {
diff --git a/services/network/orb/orb_sniffers_unittest.cc b/services/network/orb/orb_sniffers_unittest.cc
index e88ec13..507142d 100644
--- a/services/network/orb/orb_sniffers_unittest.cc
+++ b/services/network/orb/orb_sniffers_unittest.cc
@@ -108,6 +108,10 @@
   EXPECT_EQ(SniffingResult::kMaybe, SniffForHTML("<!-- unterminated..."));
   EXPECT_EQ(SniffingResult::kMaybe,
             SniffForHTML("<!-- blah --> <html> no newline yet"));
+
+  // UTF-8 BOM (https://en.wikipedia.org/wiki/Byte_order_mark#UTF-8) followed by
+  // valid HTML tags.
+  EXPECT_EQ(SniffingResult::kYes, SniffForHTML("\xEF\xBB\xBF<html><body>"));
 }
 
 TEST(OrbSnifferTest, SniffForXML) {
@@ -121,6 +125,11 @@
 
   // Empty string should be indeterminate.
   EXPECT_EQ(SniffingResult::kMaybe, SniffForXML(empty_data));
+
+  // UTF-8 BOM (https://en.wikipedia.org/wiki/Byte_order_mark#UTF-8) followed by
+  // valid XML tags.
+  EXPECT_EQ(SniffingResult::kYes,
+            SniffForXML("\xEF\xBB\xBF<?xml version=\"1.0\"?>\n <catalog"));
 }
 
 TEST(OrbSnifferTest, SniffForJSON) {
@@ -169,6 +178,11 @@
       << "Lists dictionary are not recognized (since they're valid JS too)";
   EXPECT_EQ(SniffingResult::kNo, SniffForJSON(R"({":"})"))
       << "A colon character inside a string does not trigger a match";
+
+  // UTF-8 BOM (https://en.wikipedia.org/wiki/Byte_order_mark#UTF-8) followed by
+  // valid JSON.
+  EXPECT_EQ(SniffingResult::kYes,
+            SniffForJSON("\xEF\xBB\xBF   { \"name\" : \"chrome\", "));
 }
 
 }  // namespace network::orb
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/services/network/orb/orb_sniffers_unittest.cc b/services/network/orb/orb_sniffers_unittest.cc
index e88ec13..507142d 100644
--- a/services/network/orb/orb_sniffers_unittest.cc
+++ b/services/network/orb/orb_sniffers_unittest.cc
@@ -108,6 +108,10 @@
   EXPECT_EQ(SniffingResult::kMaybe, SniffForHTML("<!-- unterminated..."));
   EXPECT_EQ(SniffingResult::kMaybe,
             SniffForHTML("<!-- blah --> <html> no newline yet"));
+
+  // UTF-8 BOM (https://en.wikipedia.org/wiki/Byte_order_mark#UTF-8) followed by
+  // valid HTML tags.
+  EXPECT_EQ(SniffingResult::kYes, SniffForHTML("\xEF\xBB\xBF<html><body>"));
 }
 
 TEST(OrbSnifferTest, SniffForXML) {
@@ -121,6 +125,11 @@
 
   // Empty string should be indeterminate.
   EXPECT_EQ(SniffingResult::kMaybe, SniffForXML(empty_data));
+
+  // UTF-8 BOM (https://en.wikipedia.org/wiki/Byte_order_mark#UTF-8) followed by
+  // valid XML tags.
+  EXPECT_EQ(SniffingResult::kYes,
+            SniffForXML("\xEF\xBB\xBF<?xml version=\"1.0\"?>\n <catalog"));
 }
 
 TEST(OrbSnifferTest, SniffForJSON) {
@@ -169,6 +178,11 @@
       << "Lists dictionary are not recognized (since they're valid JS too)";
   EXPECT_EQ(SniffingResult::kNo, SniffForJSON(R"({":"})"))
       << "A colon character inside a string does not trigger a match";
+
+  // UTF-8 BOM (https://en.wikipedia.org/wiki/Byte_order_mark#UTF-8) followed by
+  // valid JSON.
+  EXPECT_EQ(SniffingResult::kYes,
+            SniffForJSON("\xEF\xBB\xBF   { \"name\" : \"chrome\", "));
 }
 
 }  // namespace network::orb
Loading diff…

Original Bug Report

reported by vm...@google.com

ORB bypass via UTF-8 BOM allowing cross-site data leak

Project Fortify, an experimental security project, has identified the following potential security issue.

Overview: ORB’s content sniffers do not skip the UTF-8 Byte Order Mark (BOM) when analyzing responses. This causes ORB to fail to identify BOM-prefixed HTML, XML, or JSON, leading to a fail-open condition where the sensitive response is allowed into the renderer process. An attacker could exploit this to leak cross-origin data using Spectre-style attacks.

Affected files:

  • services/network/orb/orb_sniffers.cc
  • services/network/orb/orb_impl.cc

Estimated timestamp from git blame: 2024-02-20

Summary

Opaque Response Blocking (ORB) aims to prevent sensitive cross-origin data (like HTML, XML, or JSON) from entering a renderer process unless permitted by CORS. When a response lacks the X-Content-Type-Options: nosniff header, ORB relies on content sniffing to determine if the response should be blocked. However, the ORB sniffers (SniffForHTML, SniffForXML, SniffForJSON in services/network/orb/orb_sniffers.cc) fail to account for the UTF-8 Byte Order Mark (BOM, 0xEF 0xBB 0xBF) at the beginning of a response.

Technical Details

  1. Whitespace Skipping: The function AdvancePastWhitespace() is used by SniffForHTML and SniffForXML to skip leading characters, but it only checks for ASCII whitespace (' ', '\t', '\r', '\n'). It does not strip the UTF-8 BOM.
  2. Sniffer Failures:
    • SniffForHTML compares the data against signatures starting with <. Since the data starts with 0xEF, it returns kNo.
    • SniffForXML compares against <?xml and also fails.
    • SniffForJSON skips whitespace byte-by-byte but expects the first non-whitespace character to be {. Upon encountering 0xEF, it returns kNo.
  3. Fail-Open Behavior: When all sniffers fail to recognize the content, OpaqueResponseBlockingAnalyzer::Sniff() eventually falls back to HandleEndOfSniffableResponseBody(). Due to a transitional fail-open policy for unrecognized non-JS MIME types, this function returns Decision::kAllow, permitting the response to cross the process boundary.

Potential Exploitation Steps

Note: These are suggested steps based on code analysis; a working Proof of Concept has not yet been executed.

  1. An attacker creates a malicious webpage (e.g., https://evil.com).
  2. The attacker’s page executes a no-cors cross-origin request (e.g., via fetch or an <img src="..."> tag) targeting a sensitive endpoint on https://victim.com/secret.
  3. The victim server responds with sensitive HTML, XML, or JSON data. The response lacks the X-Content-Type-Options: nosniff header and the body is prefixed with a UTF-8 BOM (0xEF 0xBB 0xBF).
  4. The Network Service’s ORB implementation intercepts the response and attempts to sniff the content.
  5. Because AdvancePastWhitespace() and the sniffers do not skip the BOM, they fail to identify the response as HTML, XML, or JSON.
  6. ORB falls back to HandleEndOfSniffableResponseBody(), which returns Decision::kAllow, allowing the sensitive data into the attacker’s renderer process.
  7. The attacker utilizes a Spectre-style side-channel gadget in their JavaScript to speculatively read the sensitive cross-origin bytes from the renderer’s memory and exfiltrate them.

Suggested Fix

Modify the sniffing logic in services/network/orb/orb_sniffers.cc to explicitly detect and skip the UTF-8 BOM (0xEF 0xBB 0xBF) if it is present at the very beginning of the response body.

  • Update AdvancePastWhitespace() (or create a new helper) to strip the BOM.
  • Ensure SniffForJSON() also skips the BOM before its state machine begins evaluating characters.

Evaluated with Chrome root at commit: a9cbf6e8b275fe4147435aa905f3b7f5a656f5f0


Results from so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.

View on issue tracker