Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInappropriate implementation in XML
DescriptionInappropriate implementation in XML
ComponentXML
Bug ClassLogic Error
Tracker502285273
Fix commitf7eda6f7c17e (chromium/src) +86/-17
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-02

Files Changed

  • third_party/rust/chromium_crates_io/patches/xml-v1/0004-Avoid-character-duplication-in-streaming-mode.patch
  • third_party/rust/chromium_crates_io/vendor/xml-v1/src/reader/lexer.rs
From f7eda6f7c17ef975dea890d2f4b7a1d747d16f50 Mon Sep 17 00:00:00 2001
From: Dominik Röttsches <drott@chromium.org>
Date: Tue, 28 Apr 2026 15:00:50 -0700
Subject: [PATCH] [xml] Avoid character duplication in streaming mode

When in streaming mode and an EOF occurs in specific states, such as
during parsing of an attribute that contains a slash, or during comments
and CDATA, duplication of the characters can occur.

Address this by locally patching-in upstream change from [1]. Change has
landed but has not been released yet.

Locally verified against server.py in bug report, upstream
contains tests/streaming.rs to cover chunking at / or comment/CDATA
boundaries.

[1] https://github.com/kornelski/xml-rs/pull/76

Fixed: 502285273
Change-Id: I96ec713b2013a2808d3887f3832a536c490dee84
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7799336
Reviewed-by: Łukasz Anforowicz <lukasza@chromium.org>
Commit-Queue: Łukasz Anforowicz <lukasza@chromium.org>
Auto-Submit: Dominik Röttsches <drott@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1622057}
---

diff --git a/third_party/rust/chromium_crates_io/patches/xml-v1/0004-Avoid-character-duplication-in-streaming-mode.patch b/third_party/rust/chromium_crates_io/patches/xml-v1/0004-Avoid-character-duplication-in-streaming-mode.patch
new file mode 100644
index 0000000..9716be11
--- /dev/null
+++ b/third_party/rust/chromium_crates_io/patches/xml-v1/0004-Avoid-character-duplication-in-streaming-mode.patch
@@ -0,0 +1,79 @@
+From aeda0424535a4300b53ad60e501fd5423e453ebb Mon Sep 17 00:00:00 2001
+From: =?UTF-8?q?Dominik=20R=C3=B6ttsches?= <drott@chromium.org>
+Date: Tue, 28 Apr 2026 14:32:22 +0300
+Subject: [PATCH] Avoid character duplication in streaming mode
+
+---
+ .../vendor/xml-v1/src/reader/lexer.rs         | 24 ++++++-------------
+ 1 file changed, 7 insertions(+), 17 deletions(-)
+
+diff --git a/third_party/rust/chromium_crates_io/vendor/xml-v1/src/reader/lexer.rs b/third_party/rust/chromium_crates_io/vendor/xml-v1/src/reader/lexer.rs
+index 06c49b74c1d30..523e12def4486 100644
+--- a/third_party/rust/chromium_crates_io/vendor/xml-v1/src/reader/lexer.rs
++++ b/third_party/rust/chromium_crates_io/vendor/xml-v1/src/reader/lexer.rs
+@@ -334,21 +334,12 @@ impl Lexer {
+             State::InsideCdata | State::CDataClosing(_) => Err(self.error(SyntaxError::UnclosedCdata)),
+             State::TagStarted | State::CommentOrCDataOrDoctypeStarted |
+             State::CommentStarted | State::CDataStarted(_)| State::DoctypeStarted(_) |
+-            State::CommentClosing(ClosingSubstate::Second) |
++            State::CommentClosing(_) |
+             State::InsideComment | State::InsideMarkupDeclaration |
+             State::InsideProcessingInstruction | State::ProcessingInstructionClosing |
+-            State::InsideDoctype | State::InsideMarkupDeclarationQuotedString(_) =>
++            State::InsideDoctype | State::InsideMarkupDeclarationQuotedString(_) |
++            State::EmptyTagClosing | State::InvalidCDataClosing(_) =>
+                 Err(self.error(SyntaxError::UnexpectedEof)),
+-            State::EmptyTagClosing =>
+-                Ok(Token::Character('/')),
+-            State::CommentClosing(ClosingSubstate::First) =>
+-                Ok(Token::Character('-')),
+-            State::InvalidCDataClosing(ClosingSubstate::First) =>
+-                Ok(Token::Character(']')),
+-            State::InvalidCDataClosing(ClosingSubstate::Second) => {
+-                self.eof_handled = false;
+-                Ok(self.move_to_with_unread(State::Normal, &[']'], Token::Character(']')))
+-            },
+             State::Normal => Ok(Token::Eof),
+         }
+     }
+@@ -783,7 +774,7 @@ mod tests {
+     #[test]
+     fn special_chars_test() {
+         let (mut lex, mut buf) = make_lex_and_buf(
+-            r"?x!+ // -| ]z]]"
++            r"?x!+ // -| ]z]] "
+         );
+ 
+         assert_oks!(for lex and buf ;
+@@ -802,6 +793,7 @@ mod tests {
+             Token::Character('z')
+             Token::Character(']')
+             Token::Character(']')
++            Token::Character(' ')
+         );
+         assert_none!(for lex and buf);
+     }
+@@ -1009,11 +1001,7 @@ mod tests {
+             })
+         );
+         eof_check!("?"  ; Token::Character('?'));
+-        eof_check!("/"  ; Token::Character('/'));
+         eof_check!("-"  ; Token::Character('-'));
+-        eof_check!("]"  ; Token::Character(']'));
+-        eof_check!("]"  ; Token::Character(']'));
+-        eof_check!("]"  ; Token::Character(']'));
+     }
+ 
+     #[test]
+@@ -1027,6 +1015,8 @@ mod tests {
+         );
+         eof_check!("<"        ; 0, 1);
+         eof_check!("<!"       ; 0, 2);
++        eof_check!("/"        ; 0, 1);
++        eof_check!("]"        ; 0, 1);
+         eof_check!("<!-"      ; 0, 3);
+         eof_check!("<!["      ; 0, 3);
+         eof_check!("<![C"     ; 0, 4);
+-- 
+2.54.0.545.g6539524ca2-goog
+
diff --git a/third_party/rust/chromium_crates_io/vendor/xml-v1/src/reader/lexer.rs b/third_party/rust/chromium_crates_io/vendor/xml-v1/src/reader/lexer.rs
index 06c49b7..523e12de 100644
--- a/third_party/rust/chromium_crates_io/vendor/xml-v1/src/reader/lexer.rs
+++ b/third_party/rust/chromium_crates_io/vendor/xml-v1/src/reader/lexer.rs
@@ -334,21 +334,12 @@
             State::InsideCdata | State::CDataClosing(_) => Err(self.error(SyntaxError::UnclosedCdata)),
             State::TagStarted | State::CommentOrCDataOrDoctypeStarted |
             State::CommentStarted | State::CDataStarted(_)| State::DoctypeStarted(_) |
-            State::CommentClosing(ClosingSubstate::Second) |
+            State::CommentClosing(_) |
             State::InsideComment | State::InsideMarkupDeclaration |
             State::InsideProcessingInstruction | State::ProcessingInstructionClosing |
-            State::InsideDoctype | State::InsideMarkupDeclarationQuotedString(_) =>
+            State::InsideDoctype | State::InsideMarkupDeclarationQuotedString(_) |
+            State::EmptyTagClosing | State::InvalidCDataClosing(_) =>
                 Err(self.error(SyntaxError::UnexpectedEof)),
-            State::EmptyTagClosing =>
-                Ok(Token::Character('/')),
-            State::CommentClosing(ClosingSubstate::First) =>
-                Ok(Token::Character('-')),
-            State::InvalidCDataClosing(ClosingSubstate::First) =>
-                Ok(Token::Character(']')),
-            State::InvalidCDataClosing(ClosingSubstate::Second) => {
-                self.eof_handled = false;
-                Ok(self.move_to_with_unread(State::Normal, &[']'], Token::Character(']')))
-            },
             State::Normal => Ok(Token::Eof),
         }
     }
@@ -783,7 +774,7 @@
     #[test]
     fn special_chars_test() {
         let (mut lex, mut buf) = make_lex_and_buf(
-            r"?x!+ // -| ]z]]"
+            r"?x!+ // -| ]z]] "
         );
 
         assert_oks!(for lex and buf ;
@@ -802,6 +793,7 @@
             Token::Character('z')
             Token::Character(']')
             Token::Character(']')
+            Token::Character(' ')
         );
         assert_none!(for lex and buf);
     }
@@ -1009,11 +1001,7 @@
             })
         );
         eof_check!("?"  ; Token::Character('?'));
-        eof_check!("/"  ; Token::Character('/'));
         eof_check!("-"  ; Token::Character('-'));
-        eof_check!("]"  ; Token::Character(']'));
-        eof_check!("]"  ; Token::Character(']'));
-        eof_check!("]"  ; Token::Character(']'));
     }
 
     #[test]
@@ -1027,6 +1015,8 @@
         );
         eof_check!("<"        ; 0, 1);
         eof_check!("<!"       ; 0, 2);
+        eof_check!("/"        ; 0, 1);
+        eof_check!("]"        ; 0, 1);
         eof_check!("<!-"      ; 0, 3);
         eof_check!("<!["      ; 0, 3);
         eof_check!("<![C"     ; 0, 4);
Loading diff…

Original Bug Report

reported by vm...@google.com

Parser differential in experimental Rust XML lexer via chunk boundaries

Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports without the Chrome Security team.

Overview: The experimental Rust XML lexer (xml-v1 crate) fails to reset its internal state when reaching the end of a stream immediately after certain characters, such as /. When parsing resumes with the next network chunk, this stale state causes the lexer to emit a duplicate character token. An attacker can exploit this parsing differential by aligning network chunk boundaries to bypass server-side sanitizers.

Affected files:

  • third_party/rust/chromium_crates_io/vendor/xml-v1/src/reader/lexer.rs
  • third_party/rust/chromium_crates_io/vendor/xml-v1/src/reader/parser.rs
  • third_party/blink/renderer/core/xml/parser/xml_ffi.rs

Estimated timestamp from git blame: 2025-10-07

Description

A state machine logic flaw exists in the xml-v1 Rust crate, which is used by Chrome’s experimental Rust XML parser (enabled via the XMLRustForNonXslt feature). The lexer improperly handles end-of-stream (EOF) scenarios when the stream abruptly ends in specific intermediate states, such as immediately after a / character.

In third_party/rust/chromium_crates_io/vendor/xml-v1/src/reader/lexer.rs, when the lexer reads a /, it transitions to State::EmptyTagClosing. If the stream ends at this exact point (e.g., at a network chunk boundary), the end_of_stream() method is invoked:

    fn end_of_stream(&mut self) -> Result<Token> {
        // ...
        match self.st {
            // ...
            State::EmptyTagClosing =>
                Ok(Token::Character('/')),
            // ...

The lexer correctly returns Token::Character('/'), but it fails to reset its internal state self.st to State::Normal.

Chrome configures the parser with ignore_end_of_stream(true) to support streaming data (in third_party/blink/renderer/core/xml/parser/xml_ffi.rs). This means EOF is treated as a temporary pause, allowing parsing to resume when more data arrives.

When the next chunk of data arrives, the lexer resumes, but it is still trapped in State::EmptyTagClosing. It reads the next character (e.g., y). Because it is not >, the empty_element_closing() method pushes the y back onto the unread queue and returns a second Token::Character('/'). The parser then reads the unread y, resulting in the characters //y instead of /y.

Potential Security Impact

This flaw creates a parsing differential between Chrome’s streaming XML parser and atomic server-side parsers or sanitizers. An attacker can craft a malicious XML/SVG payload to bypass security filters.

For example, if an application validates that an href attribute is a safe relative URL (e.g., x/y.com), an attacker can split the payload using HTTP chunked transfer encoding right after the /. The sanitizer, seeing the full stream, validates x/y.com. Chrome, processing in chunks, duplicates the slash and parses the attribute as x//y.com—a protocol-relative absolute URL. This can lead to Cross-Site Scripting (XSS) or Server-Side Request Forgery (SSRF) bypasses.

Potential Steps to Trigger

(Note: These are suggested steps to trigger the vulnerability based on code analysis; our tooling agent does not run live exploit code.)

  1. Ensure the XMLRustForNonXslt Blink feature is enabled in Chrome.
  2. An attacker sets up an HTTP server that serves an image/svg+xml document using chunked transfer encoding.
  3. The attacker sends the first chunk, splitting the payload precisely after a forward slash:
    • Chunk 1: <svg xmlns="http://www.w3.org/2000/svg"><a href="x/
  4. Chrome receives the chunk, the lexer reads /, moves to EmptyTagClosing, hits EOF, emits the first /, and pauses.
  5. The attacker sends the second chunk:
    • Chunk 2: evil.com"></a></svg>
  6. Chrome resumes parsing. Being stuck in EmptyTagClosing, the lexer reads e, emits a second /, unreads e, and then continues normally.
  7. The resulting DOM contains an anchor tag with href="x//evil.com".

Suggested Fix

In third_party/rust/chromium_crates_io/vendor/xml-v1/src/reader/lexer.rs, update the end_of_stream() method to ensure the lexer state is properly reset to State::Normal when flushing intermediate character tokens.

    fn end_of_stream(&mut self) -> Result<Token> {
        // ...
        match self.st {
            // ...
            State::EmptyTagClosing => {
                self.st = State::Normal;
                Ok(Token::Character('/'))
            },
            State::CommentClosing(ClosingSubstate::First) => {
                self.st = State::Normal;
                Ok(Token::Character('-'))
            },
            State::InvalidCDataClosing(ClosingSubstate::First) => {
                self.st = State::Normal;
                Ok(Token::Character(']'))
            },
            // ...

Evaluated with Chrome root at commit: 096fc8fdbfacf2546485756d03f160a3d04fcc9b


Results 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
Links in the report