CVE-2026-7971
Overview
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
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.