CVE-2026-87469
Overview
Background
- CRX3
- Chrome’s extension package format;
components/crx_file/crx_verifier.ccverifies the CRX header and signatures before the payload (a ZIP archive) is trusted and unzipped. - ZIP EOCD tokens
- signature markers used by ZIP parsers to locate the archive directory:
EOCD(PK\x05\x06),EOCD64 locator(PK\x06\x07), and the Zip64EOCD64 record(PK\x06\x06). - `header_bytes`
- the CRX3 protobuf header region that precedes the ZIP payload; if it contains a ZIP end-of-central-directory token, a ZIP parser may key off the wrong location, causing verified content and actually-unzipped content to diverge.
- `kDisallowEocdRecord64TokensInCrx`
- a
BASE_FEATUREadded default-enabled as a killswitch so the new check can be disabled without a code roll if needed.
Root Cause Analysis
crx_verifier.cc scanned the CRX header for ZIP end-of-central-directory markers to prevent a mismatch between the bytes that are cryptographically verified and the bytes a ZIP parser would actually treat as the archive directory, but it only searched for kEocd (PK\x05\x06) and kEocd64 (PK\x06\x07). It did not search for the Zip64 EOCD64 record token PK\x06\x06, so a crafted CRX could embed that token inside the header region and still pass verification. Because a ZIP/Zip64 parser can locate the central directory from an EOCD64 record, this allowed the header to influence how the archive is later interpreted, breaking the invariant that the signed/verified region cannot contain a token that steers unzipping.
The fix adds the constant kEocd64Record ({'P','K',0x06,0x06}) and, gated behind the default-enabled feature kDisallowEocdRecord64TokensInCrx, returns VerifierResult::ERROR_HEADER_INVALID when std::ranges::search(header_bytes, kEocd64Record) finds the token, closing the gap left by the two existing checks. This tightens verification: the header now rejects a third class of ZIP token that was previously accepted.
EOCD and the EOCD64 locator but omitted the Zip64 EOCD64 record (PK\x06\x06) token — and the fix adds the missing token check (behind a killswitch feature) so all three markers are rejected.Attack Path
- Craft a malicious CRX
An attacker builds a CRX3 file and writes the bytes
PK\x06\x06into the header region (the regression test places them at offset 12, the start ofheader_bytes). - Pass pre-patch verification
Because the old check only searched for
kEocdandkEocd64, the embeddedEOCD64 recordtoken was not detected and the header was accepted rather than returningERROR_HEADER_INVALID. - Steer ZIP interpretation
The
EOCD64 recordtoken in the verified header could cause a ZIP/Zip64 parser to locate the central directory such that the unzipped content differs from what was verified (a verify/parse mismatch). - Deliver mismatched payload
Any concrete divergence between verified and extracted extension content, and downstream consequences of loading it, are not demonstrated by the diff
(inferred).
Impact Assessment
EOCD64 record token in the verified header that a ZIP parser may act on, creating a potential mismatch between verified and unzipped content in the extension-verification path. The advisory classifies this as improper input validation in Extensions and does not report exploitation in the wild; the fix is deployed cautiously behind a default-enabled killswitch feature. Any escalation to loading unverified extension content or code execution is (inferred) and not shown by the patch.Changed Functions
| Function | Change | Notes |
|---|---|---|
TEST_Fcomponents/crx_file/crx_verifier_unittest.cc |
modified |
Files Changed
components/crx_file/crx_verifier_unittest.cccomponents/crx_file/crx_verifier.cc
Audit Directions
- Incomplete token/marker denylistsWhen code denies a set of format sentinels (magic bytes, ZIP markers, BOMs), enumerate the full specification and confirm every variant is covered; here
EOCD64 record(PK\x06\x06) was missing besideEOCDand theEOCD64locator. - Verified-region vs parsed-region mismatchAudit other verify-then-parse pipelines for cases where the verified bytes can embed content that changes how a later parser interprets the payload (signature-region confusion).
- Feature-gated security checksTrack killswitch-gated checks like
kDisallowEocdRecord64TokensInCrx; ensure they are not disabled prematurely and that the check is folded permanently in when the flag is retired (noted for October 2026).
Patch
From fac0b6893477da1c69a5fd70b4a18ef4bd8a4a0f Mon Sep 17 00:00:00 2001
From: Devlin Cronin <rdevlin.cronin@chromium.org>
Date: Fri, 31 Jul 2026 15:51:20 -0700
Subject: [PATCH] [Crx] Disallow EOCD64 Record tokens in crx file headers
Just as we disallow EOCD and EOCD64 tokens, we should also disallow
EOCD64 record tokens.
Guard against these, behind a default-enabled feature flag (out of an
abundance of caution), and add a regression test.
Bug: 538715523
Change-Id: Ic5a5ac5e93412e42d3d0c32cb1e07cdcf20f552b
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8176148
Commit-Queue: Devlin Cronin <rdevlin.cronin@chromium.org>
Reviewed-by: Sorin Jianu <sorin@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1672146}
---
diff --git a/components/crx_file/crx_verifier.cc b/components/crx_file/crx_verifier.cc
index 77a54cf..2b91e49 100644
--- a/components/crx_file/crx_verifier.cc
+++ b/components/crx_file/crx_verifier.cc
@@ -5,6 +5,7 @@
#include "components/crx_file/crx_verifier.h"
#include <algorithm>
+#include <array>
#include <climits>
#include <cstring>
#include <iterator>
@@ -14,6 +15,8 @@
#include <utility>
#include "base/base64.h"
+#include "base/feature.h"
+#include "base/feature_list.h"
#include "base/files/file.h"
#include "base/files/file_path.h"
#include "base/functional/bind.h"
@@ -35,6 +38,11 @@
using KeyHash = std::array<uint8_t, crypto::hash::kSha256Size>;
+// A feature to block EOCD64 record tokens in CRX files, only here as a
+// killswitch. This can be removed in October 2026.
+BASE_FEATURE(kDisallowEocdRecord64TokensInCrx,
+ base::FEATURE_ENABLED_BY_DEFAULT);
+
// The SHA256 hash of the DER SPKI "ecdsa_2017_public" Crx3 key.
constexpr KeyHash kPublisherKeyHash = {
0x61, 0xf7, 0xf2, 0xa6, 0xbf, 0xcf, 0x74, 0xcd, 0x0b, 0xc1, 0xfe,
@@ -49,6 +57,7 @@
constexpr auto kEocd = std::to_array<uint8_t>({'P', 'K', 0x05, 0x06});
constexpr auto kEocd64 = std::to_array<uint8_t>({'P', 'K', 0x06, 0x07});
+constexpr auto kEocd64Record = std::to_array<uint8_t>({'P', 'K', 0x06, 0x06});
using VerifierCollection = std::vector<std::unique_ptr<crypto::sign::Verifier>>;
using RepeatedProof = ::google::protobuf::RepeatedPtrField<AsymmetricKeyProof>;
@@ -122,12 +131,19 @@
return VerifierResult::ERROR_HEADER_INVALID;
}
- // If the header contains a ZIP EOCD or EOCD64 token, unzipping may not work
- // correctly.
+ // If the header contains a ZIP EOCD, EOCD64, or EOCD64 record token,
+ // unzipping may not work correctly.
if (std::ranges::search(header_bytes, kEocd) ||
std::ranges::search(header_bytes, kEocd64)) {
return VerifierResult::ERROR_HEADER_INVALID;
}
+ // Out of an abundance of caution, we gate the EOCD64 record token on a
+ // base::Feature. The feature check can be removed (and this can be folded
+ // into the if-statement above) in October 2026.
+ if (base::FeatureList::IsEnabled(kDisallowEocdRecord64TokensInCrx) &&
+ std::ranges::search(header_bytes, kEocd64Record)) {
+ return VerifierResult::ERROR_HEADER_INVALID;
+ }
CrxFileHeader header;
if (!header.ParseFromArray(header_bytes.data(), header_size)) {
diff --git a/components/crx_file/crx_verifier_unittest.cc b/components/crx_file/crx_verifier_unittest.cc
index e47f963..8b5bba2 100644
--- a/components/crx_file/crx_verifier_unittest.cc
+++ b/components/crx_file/crx_verifier_unittest.cc
@@ -3,8 +3,14 @@
// found in the LICENSE file.
#include "components/crx_file/crx_verifier.h"
+
+#include <string>
+#include <vector>
+
#include "base/base_paths.h"
#include "base/files/file_path.h"
+#include "base/files/file_util.h"
+#include "base/files/scoped_temp_dir.h"
#include "base/path_service.h"
#include "base/strings/string_number_conversions.h"
#include "testing/gtest/include/gtest/gtest.h"
@@ -262,4 +268,39 @@
EXPECT_TRUE(compressed_verified_contents.empty());
}
+// Tests that we properly reject a crx file that includes an EOCD64 Record
+// token. Regression test for https://crbug.com/538715523.
+TEST_F(CrxVerifierTest, RejectsEocd64RecordInHeader) {
+ const std::vector<std::vector<uint8_t>> keys;
+ const std::vector<uint8_t> hash;
+ std::string public_key = "UNSET";
+ std::string crx_id = "UNSET";
+
+ std::string crx_contents;
+ ASSERT_TRUE(base::ReadFileToString(TestFile("valid_no_publisher.crx3"),
+ &crx_contents));
+
+ base::ScopedTempDir temp_dir;
+ ASSERT_TRUE(temp_dir.CreateUniqueTempDir());
+
+ // Insert PK\x06\x06 (Zip64 EOCD Record) into header_bytes (starts at offset
+ // 12).
+ std::string crx_with_eocd64_record = crx_contents;
+ ASSERT_GT(crx_with_eocd64_record.size(), 20u);
+ crx_with_eocd64_record[12] = 'P';
+ crx_with_eocd64_record[13] = 'K';
+ crx_with_eocd64_record[14] = 0x06;
+ crx_with_eocd64_record[15] = 0x06;
+
+ base::FilePath test_file =
+ temp_dir.GetPath().AppendASCII("eocd64record.crx3");
+ ASSERT_TRUE(base::WriteFile(test_file, crx_with_eocd64_record));
+
+ EXPECT_EQ(VerifierResult::ERROR_HEADER_INVALID,
+ Verify(test_file, VerifierFormat::CRX3, keys, hash, &public_key,
+ &crx_id, /*compressed_verified_contents=*/nullptr));
+ EXPECT_EQ("UNSET", crx_id);
+ EXPECT_EQ("UNSET", public_key);
+}
+
} // namespace crx_file
Regression Test / PoC
diff --git a/components/crx_file/crx_verifier_unittest.cc b/components/crx_file/crx_verifier_unittest.cc
index e47f963..8b5bba2 100644
--- a/components/crx_file/crx_verifier_unittest.cc
+++ b/components/crx_file/crx_verifier_unittest.cc
@@ -3,8 +3,14 @@
// found in the LICENSE file.
#include "components/crx_file/crx_verifier.h"
+
+#include <string>
+#include <vector>
+
#include "base/base_paths.h"
#include "base/files/file_path.h"
+#include "base/files/file_util.h"
+#include "base/files/scoped_temp_dir.h"
#include "base/path_service.h"
#include "base/strings/string_number_conversions.h"
#include "testing/gtest/include/gtest/gtest.h"
@@ -262,4 +268,39 @@
EXPECT_TRUE(compressed_verified_contents.empty());
}
+// Tests that we properly reject a crx file that includes an EOCD64 Record
+// token. Regression test for https://crbug.com/538715523.
+TEST_F(CrxVerifierTest, RejectsEocd64RecordInHeader) {
+ const std::vector<std::vector<uint8_t>> keys;
+ const std::vector<uint8_t> hash;
+ std::string public_key = "UNSET";
+ std::string crx_id = "UNSET";
+
+ std::string crx_contents;
+ ASSERT_TRUE(base::ReadFileToString(TestFile("valid_no_publisher.crx3"),
+ &crx_contents));
+
+ base::ScopedTempDir temp_dir;
+ ASSERT_TRUE(temp_dir.CreateUniqueTempDir());
+
+ // Insert PK\x06\x06 (Zip64 EOCD Record) into header_bytes (starts at offset
+ // 12).
+ std::string crx_with_eocd64_record = crx_contents;
+ ASSERT_GT(crx_with_eocd64_record.size(), 20u);
+ crx_with_eocd64_record[12] = 'P';
+ crx_with_eocd64_record[13] = 'K';
+ crx_with_eocd64_record[14] = 0x06;
+ crx_with_eocd64_record[15] = 0x06;
+
+ base::FilePath test_file =
+ temp_dir.GetPath().AppendASCII("eocd64record.crx3");
+ ASSERT_TRUE(base::WriteFile(test_file, crx_with_eocd64_record));
+
+ EXPECT_EQ(VerifierResult::ERROR_HEADER_INVALID,
+ Verify(test_file, VerifierFormat::CRX3, keys, hash, &public_key,
+ &crx_id, /*compressed_verified_contents=*/nullptr));
+ EXPECT_EQ("UNSET", crx_id);
+ EXPECT_EQ("UNSET", public_key);
+}
+
} // namespace crx_file