Mobile wallpaper 1Mobile wallpaper 2Mobile wallpaper 3Mobile wallpaper 4
1772 字
9 分钟
Pasteboard引入的selenium安全杂谈
2026-01-18
统计加载中...

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 webdriver
from 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 SubprocessStdAlias
from 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,
@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)

再是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");

以上。

Pasteboard引入的selenium安全杂谈
https://steins-gate.cn/posts/selenium安全杂谈/
作者
萦梦sora~Nya
发布于
2026-01-18
许可协议
me

部分信息可能已经过时