Pasteboard引入的selenium安全杂谈
前言:
在存在 DOM 污染的前提下,Selenium的默认启动参数 + strict-dynamic以及CSP + data,可能使Attacker 间接影响 Chromedriver 的runtime行为。这并不是配置层面的,而是默认的配置与浏览器常见的xss进行联动而产生的安全隐患,来源于同源策略的不可信,我想,这是需要进一步校验考量的。
思来想去,我还是想把这篇报告写得更为纯粹一些,所以我会稍微放弃一些在此题前置的DOM污染的讲解。
Pasteboard
先看看dom污染部分的源码操作吧
(function () { const n = document.getElementById("rawMsg"); const raw = n ? n.textContent : ""; const card = document.getElementById("card");
try { const cfg = window.renderConfig || { mode: (card && card.dataset.mode) || "safe" }; const mode = cfg.mode.toLowerCase(); const clean = DOMPurify.sanitize(raw, { ALLOW_DATA_ATTR: false }); if (card) { card.innerHTML = clean; } if (mode !== "safe") { console.log("Render mode:", mode); } } catch (err) { window.lastRenderError = err ? String(err) : "unknown"; handleError(); }
function handleError() { const el = document.getElementById("errorReporterScript"); if (el && el.src) { return; }
const c = window.errorReporter || { path: "/telemetry/error-reporter.js" }; const p = c.path && c.path.value ? c.path.value : String(c.path || "/telemetry/error-reporter.js"); const s = document.createElement("script"); s.id = "errorReporterScript"; let src = p; try { src = new URL(p).href; } catch (err) { src = p.startsWith("/") ? p : "/telemetry/" + p; } s.src = src;
if (el) { el.replaceWith(s); } else { document.head.appendChild(s); } }})();在try代码块的后面,如果报error的时候,便会调用handleerror函数,我们看看这函数的构造,在window对象上有renderconfig和errorreporter,污染点在别的地方,这里仅放部分源码
<div id="injected">{{ msg|safe }}</div>def _csp_header(nonce): return ( "default-src 'self'; " "base-uri 'none'; " "object-src 'none'; " "img-src 'self' data:; " "style-src 'self'; " "connect-src *; " f"script-src 'nonce-{nonce}' 'strict-dynamic'" )可以看到这里的f”script-src ‘nonce-{nonce}’ ‘strict-dynamic’“,有严格的安全限制,不允许我们在html里随便插入自创脚本,但是strict-dynamic允许我们动态加载脚本,只有其父脚本携带合法 nonce,以及未被 Trusted Types阻止,那么我们就可以劫持renderconfig和errorreporter两个变量去伪造js路径,并且利用data协议去进行现时加载。这是dom部分。
关于selenium
我们看看核心bot.py
import time
from selenium import webdriverfrom selenium.webdriver.chrome.options import Options
BASE_URL = "http://127.0.0.1:5000"FLAG = "uoftctf{fake_flag}"
def visit_url(target_url): options = Options() options.add_argument("--headless=true") options.add_argument("--disable-gpu") options.add_argument("--no-sandbox") driver = webdriver.Chrome(options=options) try: driver.get(target_url) time.sleep(30) finally: driver.quit()初看仅仅是一个bot去访问target的脚本,虽然限制并不是很严格,设置了no sandbox,但是flag是在bot.py的变量里,而不是在cookie那些地方,我们只能控制前端js,那么如何访问后端呢。
于是我审计了selenium的源码,可以从头开始谈论一下selenium的行动
class Options(ChromiumOptions): @property def default_capabilities(self) -> dict: return DesiredCapabilities.CHROME.copy()
def enable_mobile( self, android_package: str | None = "com.android.chrome", android_activity: str | None = None, device_serial: str | None = None, ) -> None: super().enable_mobile(android_package, android_activity, device_serial)可以看到这个类是继承于ChromiumOptions,继续溯源。
# Licensed to the Software Freedom Conservancy (SFC) under one# or more contributor license agreements. See the NOTICE file# distributed with this work for additional information# regarding copyright ownership. The SFC licenses this file# to you under the Apache License, Version 2.0 (the# "License"); you may not use this file except in compliance# with the License. You may obtain a copy of the License at## http://www.apache.org/licenses/LICENSE-2.0## Unless required by applicable law or agreed to in writing,# software distributed under the License is distributed on an# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY# KIND, either express or implied. See the License for the# specific language governing permissions and limitations# under the License.
from collections.abc import Mapping, Sequence
from selenium.types import SubprocessStdAliasfrom selenium.webdriver.chromium import service
class Service(service.ChromiumService): """Service class responsible for starting and stopping the chromedriver executable.
Args: executable_path: Install path of the chromedriver executable, defaults to `chromedriver`. port: Port for the service to run on, defaults to 0 where the operating system will decide. service_args: (Optional) Sequence of args to be passed to the subprocess when launching the executable. log_output: (Optional) int representation of STDOUT/DEVNULL, any IO instance or String path to file. env: (Optional) Mapping of environment variables for the new process, defaults to `os.environ`. """
def __init__( self, executable_path: str | None = None, port: int = 0, service_args: Sequence[str] | None = None, log_output: SubprocessStdAlias | None = None, env: Mapping[str, str] | None = None, **kwargs, ) -> None: self._service_args = service_args or []
super().__init__( executable_path=executable_path, port=port, service_args=service_args, log_output=log_output, env=env, **kwargs, )
def command_line_args(self) -> list[str]: return ["--enable-chrome-logs", f"--port={self.port}"] + self._service_args
@property def service_args(self) -> Sequence[str]: """Returns the sequence of service arguments.""" return self._service_args
@service_args.setter def service_args(self, value: Sequence[str]): if isinstance(value, str) or not isinstance(value, Sequence): raise TypeError("service_args must be a sequence") self._service_args = list(value)以及webdriver的父类这一段
def __init__( self, browser_name: str | None = None, vendor_prefix: str | None = None, options: ChromiumOptions | None = None, service: ChromiumService | None = None, keep_alive: bool = True,) -> None: """Create a new WebDriver instance, start the service, and create new ChromiumDriver instance.
Args: browser_name: Browser name used when matching capabilities. vendor_prefix: Company prefix to apply to vendor-specific WebDriver extension commands. options: This takes an instance of ChromiumOptions. service: Service object for handling the browser driver if you need to pass extra details. keep_alive: Whether to configure ChromiumRemoteConnection to use HTTP keep-alive. """ self.service = service if service else ChromiumService() options = options if options else ChromiumOptions()这里两个init方法初始化,也就是入口,在这里我们要关注的是options,如果没传options就是默认逻辑,继续观察处理逻辑,观察发现options通过取caps能力字典去规范化启动参数,因为这里取自chrome/server.py,而它继承于父类service.ChromiumService,与此同时,返回的还有默认端口号,路径等等。当然这并不重要,因为这都是启动参数,我们要关注的是启动后我们能改变的因素,那就是Chromedriver命令行。
def command_line_args(self) -> list[str]: return ["--enable-chrome-logs", f"--port={self.port}"] + self._service_args这里返回的参数是可以拼接的,这让我找到了新的可能性,继续溯源,发现是双重继承,分别是ChromiumService类和Service类。
def __init__( self, executable_path: str | None = None, port: int = 0, service_args: Sequence[str] | None = None, log_output: SubprocessStdAlias | None = None, env: Mapping[str, str] | None = None, **kwargs,@propertydef service_args(self) -> Sequence[str]: """Returns the sequence of service arguments.""" return self._service_args
@service_args.setterdef service_args(self, value: Sequence[str]): if isinstance(value, str) or not isinstance(value, Sequence): raise TypeError("service_args must be a sequence") self._service_args = list(value)再是class Service
def _start_process(self, path: str) -> None: """Creates a subprocess by executing the command provided.
Args: path: full command to execute """ cmd = [path] cmd.extend(self.command_line_args()) close_file_descriptors = self.popen_kw.pop("close_fds", sys.platform != "win32") try: start_info = None if sys.platform == "win32": start_info = subprocess.STARTUPINFO() start_info.dwFlags = subprocess.CREATE_NEW_CONSOLE | subprocess.STARTF_USESHOWWINDOW start_info.wShowWindow = subprocess.SW_HIDE
self.process = subprocess.Popen( cmd, env=self.env, close_fds=close_file_descriptors, stdout=cast(Optional[Union[int, IO[Any]]], self.log_output), stderr=cast(Optional[Union[int, IO[Any]]], self.log_output), stdin=PIPE, creationflags=self.creation_flags, startupinfo=start_info, **self.popen_kw, )可以看到service_arg自始至终都是直接向上递归并且没有做任何过滤,然后就原封不动得传到了service里cmd.extend(self.command_line_args())作为启动参数了,也就是说,service是默认开发策略才能让我们进调试,确实,因为只有回环地址才能访问。可以说,这种行为是一种信任转移。
当然,出错最多的也是这一方面,那就是一种联动策略,按理来说是无法直接访问回环的,但是浏览器可以,而我们就可以利用浏览器的dom污染加载恶意js去完成后端层面的参数污染。所以在开发层面来说,这种原本是便利前端调试的功能却出了漏洞。多数情况下都会注重后端安全去多多少少去忽视前端的安全。因为js,浏览器可以类比为一个同源客户端,所以在前后端衔接的时候,我想是可以多多少少考虑这一方面的安全的。
这里附上一段简易的js的POC,以验证本地回环的可行性
window.renderconfig = { script: "data:text/javascript,console.log('control')"};
throw new Error("trigger");
fetch("http://127.0.0.1:xxxx");以上。
部分信息可能已经过时





