CVE-2026-7971
Overview
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\xBFthat may optionally prefix a UTF-8 document and carries no textual content. - Parser breaker
- a prefix such as
for (;;);orwhile (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.
AdvancePastUtf8Bom before each match realigns the comparison with the document’s true first meaningful byte.Attack Path
- 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. - 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).
- Sniffers miss the signature
The leading BOM shifts the real
<html/<?xml/JSON/parser-breaker signature past offset zero, so the sniffer returns non-kYesand ORB does not classify the body as sensitive. - 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
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifservices/network/orb/orb_sniffers.cc |
modified | |
TESTservices/network/orb/orb_sniffers_unittest.cc |
modified |
Files Changed
services/network/orb/orb_sniffers.ccservices/network/orb/orb_sniffers_unittest.cc
Audit Directions
- Byte-zero anchoringAudit 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 confusionReview 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 heuristicsWhere 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.
Patch
From 74c46d0bc1dcfcd14f5725f93a7aa61b0eb79ee1 Mon Sep 17 00:00:00 2001 From: Lukasz Anforowicz <[email protected]> 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 <[email protected]> Commit-Queue: Łukasz Anforowicz <[email protected]> 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
Regression Test / PoC
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
Original Bug Report
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.ccservices/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
- Whitespace Skipping: The function
AdvancePastWhitespace()is used bySniffForHTMLandSniffForXMLto skip leading characters, but it only checks for ASCII whitespace (' ','\t','\r','\n'). It does not strip the UTF-8 BOM. - Sniffer Failures:
SniffForHTMLcompares the data against signatures starting with<. Since the data starts with0xEF, it returnskNo.SniffForXMLcompares against<?xmland also fails.SniffForJSONskips whitespace byte-by-byte but expects the first non-whitespace character to be{. Upon encountering0xEF, it returnskNo.
- Fail-Open Behavior: When all sniffers fail to recognize the content,
OpaqueResponseBlockingAnalyzer::Sniff()eventually falls back toHandleEndOfSniffableResponseBody(). Due to a transitional fail-open policy for unrecognized non-JS MIME types, this function returnsDecision::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.
- An attacker creates a malicious webpage (e.g.,
https://evil.com). - The attacker’s page executes a
no-corscross-origin request (e.g., viafetchor an<img src="...">tag) targeting a sensitive endpoint onhttps://victim.com/secret. - The victim server responds with sensitive HTML, XML, or JSON data. The response lacks the
X-Content-Type-Options: nosniffheader and the body is prefixed with a UTF-8 BOM (0xEF 0xBB 0xBF). - The Network Service’s ORB implementation intercepts the response and attempts to sniff the content.
- Because
AdvancePastWhitespace()and the sniffers do not skip the BOM, they fail to identify the response as HTML, XML, or JSON. - ORB falls back to
HandleEndOfSniffableResponseBody(), which returnsDecision::kAllow, allowing the sensitive data into the attacker’s renderer process. - 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.