共享浏览器Session克隆:基于CDP的登录态迁移与多租户隔离

发布时间:2026/10/29 22:40:59
共享浏览器Session克隆:基于CDP的登录态迁移与多租户隔离

简介:围绕“共享浏览器与会话克隆”展开的实战资料包,面向具备一定 Web 开发与网络基础、希望理解跨设备会话延续机制的开发者与安全学习者。内容以 HTTP 会话管理为主线,涉及 Session ID 的捕获与解析、Cookie 在请求中的携带方式、跨设备安全传输、代理或浏览器扩展注入请求头、会话状态实时同步,以及 HTTPS 加密、隐私与合规等要点,可用于团队协作、远程办公等场景的原型研究。压缩包共 129 个文件,约 77.46MB,以 pak、dll、pdb、xml、exe、ini 等为主,包含浏览器运行依赖、配置与调试信息,便于搭建和排查基于 Cef 内核的浏览器工程。已有 362 人学习,适合用来对照理解会话克隆链路、梳理模块职责与验证思路,并在此基础上做安全边界和合规性评估。

1. 一台登录、另一台接着操作:共享浏览器要解决的实际问题

「在家打开后台,提示异地登录,短信验证码却发不到手边那台手机上」——这个场景很多做运营、做自动化的开发者都撞过。共享浏览器加上 Session 克隆要做的事很明确:把 A 机器上已经登录好的浏览器身份,连同 Cookie、localStorage、IndexedDB 一并搬到 B 机器,B 机器打开就是已登录状态,不用重走一遍验证流程。它不能替代远程桌面,更像一套「浏览器状态可迁移」的工程实现:源端负责导出加密凭证,传输层负责打包并写入文件,目标端用一个隔离的浏览器上下文把状态写回去,再把画面推给前端。适合的人群也比较集中——做多账号工作台的、做自动化测试需要复用登录态的、以及想在一台常开的机器上挂着浏览器、从任何设备接进去继续点的人。真正麻烦的地方不在「搬」,而在搬完之后身份还能不能被目标站点认可,后面的内容基本都围绕这件事展开。

2. 架构拆解:从 Local State 到 CDP 会话的三层链路

2.1 存储层、协议层、呈现层各自负责什么

整个系统可以按三层理解,分层的好处是每一层都能单独替换。

存储层就是浏览器的 User Data 目录。这个目录里文件不少,但不是所有都要迁移,先把关键几个列清楚:

相对路径内容迁移必要性
Local Stateos_crypt.encrypted_key,解 Cookie 用的主密钥密文不直接搬,改为解出明文后在目标端重新处理
Default/Network/CookiesSQLite 库,存着所有域的 Cookie必须
Default/Local Storage/leveldb按 origin 分库的 localStorage必须
Default/IndexedDB/<origin>结构化数据,登录 token 常藏在这里看业务
Default/Login Data密码库,加密方式和 Cookie 一致一般不动
Default/Preferences语言、时区、部分站点开关建议带上

协议层是 CDP(Chrome DevTools Protocol)。给浏览器带上--remote-debugging-port=9222参数启动,所有操作都通过一条 WebSocket 上的 JSON 消息完成。这里有一个比「把整个 profile 目录复制过去」更省事的思路:直接复制目录会同时遇到单例锁、版本不兼容、密钥与本机绑定这三类问题,而只迁移状态就能绕开大半。

呈现层是前端一块 canvas。服务端用Page.startScreencast推 JPEG 帧,前端画到 canvas 上,鼠标键盘事件反向转成Input.dispatchMouseEvent和Input.dispatchKeyEvent发回去。三层各司其职,哪一层出问题都好定位。

2.2 会话隔离:一个浏览器进程里放多个身份

多租户场景下最常见的错误做法是「一个租户起一个浏览器进程」。内存扛不住是一方面,更麻烦的是 profile 目录的单例锁会让第二个实例直接起不来。正确的做法是用 CDP 的 browser context,它是浏览器内部的隔离单元,每个 context 有独立的 cookie jar 和 storage,开销接近于开一个无痕窗口。

调用链只有三步:Target.createBrowserContext建隔离空间,Target.createTarget在里面开一个标签页,Target.attachToTarget拿到这条标签页的会话句柄。

// server/cdp/session.ts export async function createTenantSession(cdp, tenantId) { // 建一个隔离的存储空间;disposeOnDetach 设 false, // 前端 WebSocket 断开后 context 还在,刷新还能接回来 const { browserContextId } = await cdp.send('Target.createBrowserContext', { disposeOnDetach: false, }); // 在这个空间里开一个空白页 const { targetId } = await cdp.send('Target.createTarget', { url: 'about:blank', browserContextId, }); // flatten: true 是关键,所有会话消息复用同一条 WebSocket, // 靠 sessionId 字段区分是哪条标签页 const { sessionId } = await cdp.send('Target.attachToTarget', { targetId, flatten: true, }); return { tenantId, browserContextId, targetId, sessionId }; }

三个参数值得单独说一下。disposeOnDetach如果不关掉,前端一刷新页面,之前注入的登录态就随 context 一起销毁了,用户会以为「迁移失败」,但其实只是被回收了。flatten如果不打开,CDP 会为每个 target 单独建立一条 WebSocket,几十个租户就是几十条长连接,服务端的 fd 很快就不够用。browserContextId要持久化到自己的数据库里,回收资源时按它去调Target.disposeBrowserContext,否则 context 会一直挂在内存里。

2.3 目录结构:代码怎么分层

实际落地时我一般按职责切成三个包,服务端只依赖 CDP,克隆器只依赖 SQLite 和系统密钥链,两边通过一份 JSON 快照解耦:

shared-browser/ ├── server/ │ ├── index.ts # HTTP + WS 入口,鉴权与租户路由 │ ├── cdp/connection.ts # 单条 WS 上的 id / sessionId 复用 │ ├── cdp/session.ts # browserContext 分配与回收 │ ├── stream/screencast.ts # 帧推送与 ack │ └── input/dispatch.ts # 坐标换算与事件回注 ├── cloner/ │ ├── extract.ts # 源端:从 profile 读出明文状态 │ ├── crypto/win.ts # DPAPI + AES-256-GCM │ ├── crypto/mac.ts # Keychain + PBKDF2-SHA1 │ ├── crypto/linux.ts # libsecret + PBKDF2 │ └── inject.ts # 目标端:CDP 注入与回读校验 ├── web/ │ ├── canvas.ts # 帧渲染与缩放 │ └── input.ts # 事件采集与节流 ├── docker/ │ ├── Dockerfile │ └── entry.sh └── package.json

这样切的好处是克隆器可以单独当 CLI 用——很多时候你只想导出一份快照存着,不想启动整个服务,node cloner/extract.ts --profile ./UserData --out snap.json就能跑。

3. Session 克隆核心:三平台密钥提取与 Cookie 解密

3.1 Windows:DPAPI 外面再套一层 AES-256-GCM

Windows 上的链路是两层。最里面是 DPAPI,用当前用户登录态加密;外面套一层 AES-256-GCM,密钥就是 DPAPI 解出来的那 32 字节。所以要拿明文,必须先解Local State里的os_crypt.encrypted_key。

那个字段是 base64 字符串,解码后前 5 个字节固定是DPAPI这五个 ASCII 字符,属于标识头,要切掉再送进 DPAPI。

// cloner/crypto/win.ts import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; export function getMasterKey(userDataDir) { const localState = JSON.parse( fs.readFileSync(`${userDataDir}/Local State`, 'utf8') ); if (!localState.os_crypt?.encrypted_key) { throw new Error('Local State 里没有 encrypted_key,profile 可能不完整'); } const blob = Buffer.from(localState.os_crypt.encrypted_key, 'base64'); const dpapiBlob = blob.subarray(5); // 切掉 "DPAPI" 标识头 // 用 PowerShell 调 ProtectedData.Unprotect,省得编译原生模块 const ps = ` Add-Type -AssemblyName System.Security; $raw = [Convert]::FromBase64String('${dpapiBlob.toString('base64')}'); $dec = [Security.Cryptography.ProtectedData]::Unprotect( $raw, $null, 'CurrentUser' ); [Convert]::ToBase64String($dec); `; const out = execFileSync( 'powershell', ['-NoProfile', '-NonInteractive', '-Command', ps], { encoding: 'utf8' } ); return Buffer.from(out.trim(), 'base64'); // 32 字节,AES-256 的 key }

这段代码里CurrentUser作用域是硬约束:调 PowerShell 的进程必须和当初登录浏览器的那个系统用户是同一个,否则 DPAPI 会直接拒绝。所以克隆器不能做成「在服务器上跑一个任务去解别人机器的 Local State」,它必须运行在源端那台机器上,这点在架构设计阶段就要定下来。另外 PowerShell 启动一次三百到五百毫秒,批量导出时应该只调一次并把 key 缓存住,不要每读一条 cookie 就起一个子进程。

拿到 key 之后解 cookie 就简单了:

export function decryptCookieValue(encrypted, key) { if (!encrypted || encrypted.length === 0) return ''; const prefix = encrypted.subarray(0, 3).toString(); // v20 是 App-Bound Encryption,密钥不在 Local State 里,离线解不开 if (prefix === 'v20') { throw new Error('v20 cookie 需要走在线读取,不要尝试离线解密'); } // 更老的版本可能根本没加密 if (prefix !== 'v10' && prefix !== 'v11') { return encrypted.toString('utf8'); } const nonce = encrypted.subarray(3, 15); // 12 字节 IV const tag = encrypted.subarray(-16); // 末尾 16 字节 GCM tag const ciphertext = encrypted.subarray(15, -16); // 中间是密文 const decipher = crypto.createDecipheriv('aes-256-gcm', key, nonce); decipher.setAuthTag(tag); return Buffer.concat([ decipher.update(ciphertext), decipher.final(), ]).toString('utf8'); }

v10前缀后面的布局是固定的:3 字节前缀 + 12 字节 nonce + 密文 + 16 字节 tag。切分位置错一位,decipher.final()就会抛unable to authenticate data,这个报错信息很没有指向性,实际上九成是切分位置算错了。

3.2 Cookie 表:从 SQLite 里读,注意时间戳纪元

Cookie 存在 SQLite 里,从较新的版本开始路径搬到了Default/Network/Cookies,老版本在Default/Cookies,代码里最好两个都试一下。

// cloner/extract.ts import Database from 'better-sqlite3'; // 1601-01-01 到 1970-01-01 的毫秒数,Chrome 的时间戳以 1601 为纪元 const CHROME_EPOCH_OFFSET = 11644473600000n; export function readCookies(profileDir, key) { const candidate = [ `${profileDir}/Default/Network/Cookies`, `${profileDir}/Default/Cookies`, ]; const dbPath = candidate.find(p => fs.existsSync(p)); if (!dbPath) throw new Error('找不到 Cookies 库'); // readonly + fileMustExist,避免意外建空库; // 浏览器正在运行时先复制一份到临时目录再读,否则可能读到锁 const db = new Database(dbPath, { readonly: true, fileMustExist: true }); const rows = db.prepare(` SELECT host_key, name, encrypted_value, path, expires_utc, is_secure, is_httponly, samesite FROM cookies `).all(); db.close(); return rows.map(r => ({ domain: r.host_key, name: r.name, value: decryptCookieValue(r.encrypted_value, key), path: r.path, secure: !!r.is_secure, httpOnly: !!r.is_httponly, // 微秒除以 1000 得毫秒,再减纪元偏移; // 0 或负数表示会话 Cookie,用 -1 标记 expires: r.expires_utc > 0 ? Number(r.expires_utc / 1000n - CHROME_EPOCH_OFFSET) / 1000 : -1, // samesite 存的是整数:-1 未设置、0 无限制、1 Lax、2 Strict sameSite: ({ 0: 'None', 1: 'Lax', 2: 'Strict' })[r.samesite] ?? 'Lax', })); }

samesite这个字段很容易被忽略。它是个整数枚举,不是字符串,直接透传给目标端会因为类型不匹配被静默丢弃,表现出来就是「cookie 明明注入成功了,但跨站请求带不上」。

3.3 macOS 与 Linux:密钥从系统密钥链取

macOS 上主密钥不在文件里,而是存在系统钥匙串中,服务名Chrome Safe Storage。取出来的是个字符串口令,还要再经过一轮 PBKDF2 才变成真正可用的对称密钥。

# 从钥匙串取出那个口令字符串 security find-generic-password -w -s "Chrome Safe Storage" -a "Chrome"
// cloner/crypto/mac.ts const key = crypto.pbkdf2Sync(password, 'saltysalt', 1003, 16, 'sha1'); const iv = Buffer.alloc(16, 0x20); // 16 个空格,固定 IV

1003 轮、salt 固定为saltysalt、迭代哈希用 SHA-1,这三项是写死在浏览器里的常量,不是可配置项,照抄就行。IV 固定 16 个空格这一点看着很反直觉,但确实是这么实现的。

Linux 稍微麻烦,先看有没有可用的 keyring:

secret-tool lookup application chrome

取到口令后和 macOS 一样跑 PBKDF2,只是迭代次数是 1。如果系统里没有 keyring,浏览器会退化到硬编码口令peanuts,同样 1 轮迭代。判断走哪条路最简单的办法是两条都试,能解出第一条 cookie 的就是对的。

3.4 localStorage 与 IndexedDB:不要自己写 LevelDB 解析器

localStorage 在磁盘上是 LevelDB 格式,.ldb加.log两套文件,还要重放 WAL 日志才能拿到最新值。自己写解析器的结果通常是:能跑,但换个浏览器版本就崩,而且崩得毫无征兆。血泪经验是别碰它。

更省事的路子是让源端也开一个调试端口,用DOMStorage域直接读。反正目标端本来就要用 CDP 注入,源端复用同一套连接逻辑,成本几乎为零。

// 按 origin 批量取 localStorage async function dumpLocalStorage(cdp, sessionId, origin) { await cdp.send('DOMStorage.enable', {}, sessionId); // storageId 要自己构造,不依赖当前页面是否正好在这个 origin 上 const storageId = { securityOrigin: origin, isLocalStorage: true }; const { entries } = await cdp.send( 'DOMStorage.getDOMStorageItems', { storageId }, sessionId ); // entries 是 [[key, value], [key, value], ...] return Object.fromEntries(entries); }

IndexedDB 的结构更复杂,要按databaseName→objectStoreName一层层遍历,用IndexedDB.requestData逐条取。实践中的取舍是:绝大多数站点的登录态只依赖 Cookie 加 localStorage 里的一条 token,先把这两样抓全能覆盖八成场景;剩下的两成再按域名单独做适配,不值得一开始就做通用方案。

3.5 注入端:setCookie 的正确姿势与回读校验

注入比导出更容易出问题,因为它没有报错——失败的时候只是一条success: false。

// cloner/inject.ts export async function injectSession(cdp, sessionId, snapshot) { await cdp.send('Network.enable', {}, sessionId); // 1. Cookie 只能一条条塞,没有批量接口 let failed = 0; for (const c of snapshot.cookies) { const { success } = await cdp.send('Network.setCookie', { name: c.name, value: c.value, domain: c.domain, // 原样透传,保留 host_key 的前导点 path: c.path, secure: c.secure, httpOnly: c.httpOnly, sameSite: c.sameSite, // 必须是 'None' | 'Lax' | 'Strict' expires: c.expires, // 秒级时间戳,-1 表示会话 Cookie }, sessionId); if (!success) failed++; } // 2. localStorage 必须先落到对应 origin 才能写 for (const [origin, items] of Object.entries(snapshot.localStorage)) { await cdp.send('Page.navigate', { url: origin }, sessionId); await waitForLoad(cdp, sessionId); await cdp.send('Runtime.evaluate', { expression: `(() => { const data = ${JSON.stringify(items)}; for (const k in data) localStorage.setItem(k, data[k]); return localStorage.length; })()`, returnByValue: true, }, sessionId); } return { injected: snapshot.cookies.length - failed, failed }; }

Network.setCookie的domain和url是两个互斥参数,二选一。用domain的时候不能带协议前缀,写了https://example.com会被判非法。用url则会让浏览器自己推导 domain,推导过程中可能把前导点吃掉,导致父域下的子域拿不到这条 cookie。

写完不算完,一定要回读:

const { cookies } = await cdp.send('Network.getAllCookies', {}, sessionId); console.log('目标端 cookie 数:', cookies.length, '预期:', snapshot.cookies.length);

数量对不上就逐个域去比,比事后被用户反馈「登录不上」要省事得多。

4. 共享浏览器服务端:多路复用与画面推流

4.1 一条 WebSocket 承载所有租户的会话

CDP 的 flatten 模式让所有 target 的会话共享一条 WebSocket,消息靠顶层的sessionId字段区分。自己要维护两张表:一张按递增id找回调,一张按sessionId分发事件。

// server/cdp/connection.ts export class CdpConnection { #id = 0; #pending = new Map(); // id -> { resolve, reject } #listeners = new Map(); // sessionId -> Map<method, handler[]> send(method, params = {}, sessionId) { const id = ++this.#id; const payload = { id, method, params }; if (sessionId) payload.sessionId = sessionId; return new Promise((resolve, reject) => { this.#pending.set(id, { resolve, reject }); this.ws.send(JSON.stringify(payload)); // 超时兜底:CDP 在某些异常状态下会既不回结果也不报错 setTimeout(() => { if (this.#pending.delete(id)) { reject(new Error(`${method} 超时,sessionId=${sessionId}`)); } }, 10_000); }); } onEvent(sessionId, method, handler) { if (!this.#listeners.has(sessionId)) this.#listeners.set(sessionId, new Map()); const m = this.#listeners.get(sessionId); if (!m.has(method)) m.set(method, []); m.get(method).push(handler); } #dispatch(msg) { if (msg.id && this.#pending.has(msg.id)) { const { resolve, reject } = this.#pending.get(msg.id); this.#pending.delete(msg.id); msg.error ? reject(new Error(msg.error.message)) : resolve(msg.result); return; } const m = this.#listeners.get(msg.sessionId)?.get(msg.method) || []; m.forEach(h => h(msg.params)); } }

那个 10 秒超时看着多余,实际上是排障时最有用的东西。没有它的话,一个卡住的Page.navigate会让整个租户的连接永远没有响应,前端只会显示「加载中」,排查起来毫无头绪。

4.2 推帧与输入回注:screencastFrameAck 不能忘

推流这块只有一个硬性规则:每收到一帧必须回一次 ack。不回的话浏览器推完第一帧就彻底停住,而且不报任何错。

// server/stream/screencast.ts export async function startStream(cdp, sessionId, ws) { await cdp.send('Page.enable', {}, sessionId); await cdp.send('Page.startScreencast', { format: 'jpeg', quality: 55, // 从 80 往下调,肉眼差别不大,带宽差将近一倍 maxWidth: 1280, maxHeight: 800, everyNthFrame: 1, // 真正的节流放在前端 requestAnimationFrame 里 }, sessionId); cdp.onEvent(sessionId, 'Page.screencastFrame', async (params) => { const { data, sessionId: frameId, metadata } = params; if (ws.readyState === 1) { ws.send(JSON.stringify({ type: 'frame', data, w: metadata.deviceWidth, h: metadata.deviceHeight, })); } // 必须回,否则浏览器只推第一帧 await cdp.send('Page.screencastFrameAck', { sessionId: frameId }, sessionId); }); }

前端把帧画到 canvas 之后,鼠标坐标是 canvas 像素,要先换算回页面 CSS 像素再发事件:

function toViewport(x, y, canvas, viewport) { return { x: Math.round(x * viewport.width / canvas.width), y: Math.round(y * viewport.height / canvas.height), }; } await cdp.send('Input.dispatchMouseEvent', { type: 'mousePressed', x: p.x, y: p.y, button: 'left', buttons: 1, // 按下时置 1,抬起时要置 0,否则页面认为按键卡住 clickCount: 1, }, sessionId);

键盘输入有两种做法。Input.insertText一次把整串写进去,快,但它不产生keydown事件,某些用 JS 监听按键的富文本框会收不到;Input.dispatchKeyEvent要自己维护windowsVirtualKeyCode映射,麻烦,但兼容性好。我一般默认用前者,遇到特定站点输入不进去再切后者。

4.3 容器化部署:启动参数决定一半的可用性

镜像本身没什么技术要求,重点全在启动参数上。

FROM debian:bookworm-slim RUN apt-get update && apt-get install -y --no-install-recommends \ chromium fonts-noto-cjk ca-certificates nodejs npm \ && rm -rf /var/lib/apt/lists/* COPY server /app WORKDIR /app CMD ["sh", "/app/docker/entry.sh"]
#!/bin/sh # docker/entry.sh exec chromium \ --headless=new \ --remote-debugging-port=9222 \ --remote-debugging-address=0.0.0.0 \ --remote-allow-origins='*' \ --user-data-dir=/data/profile \ --no-sandbox \ --disable-dev-shm-usage \ --disable-gpu \ --lang=zh-CN \ --window-size=1280,800 \ --disable-features=Translate,BackForwardCache

逐条解释几句。--remote-allow-origins从 111 版本开始是必须的,不加的话 WebSocket 握手会直接返回 403,而且报错信息只说握手失败,不提 Origin 校验,很容易查很久。--disable-dev-shm-usage是因为容器里/dev/shm默认只有 64MB,页面一复杂渲染进程就整个崩掉,日志里只看到无头浏览器莫名退出。--window-size要和前端的Emulation.setDeviceMetricsOverride保持一致,否则截出来的帧尺寸和实际视口对不上,鼠标点击会整体偏移。中文字体包fonts-noto-cjk也得装,不然所有中文都是方块。

5. 避坑与排查:五条都是在实际调试中踩出来的问题

5.1 注入后页面是登录态,一刷新就掉

现象:目标端打开首页显示已登录,点进任意二级页面或者按一下 F5,立刻跳回登录页。

原因:Network.setCookie的 domain 被「规范化」过。host_key里带前导点的记录(比如.example.com)表示这条 cookie 对所有子域生效,一旦被某个 URL 解析器处理成不带点的形式,父域就收不到了。另一个高频原因是sameSite传了字符串以外的东西(比如传了整数或 undefined),字段被静默丢弃,跨站请求自然带不上 cookie。

解决:domain 原样透传,中间不做任何 trim 或小写化以外的处理;统一用domain字段而不是url字段;注入完成后马上调Network.getAllCookies回读,按域名逐个比对数量,不要只比总数。这条我后来做成了硬性检查,失败就拒绝交付。

5.2 本地连得好好的,容器里一连接就 403

现象:ws://127.0.0.1:9222/devtools/browser/xxx在本机跑得好好的,换成容器地址或者带上 Origin 请求头就连不上,日志显示Unexpected response code: 403。

原因:Chromium 从 111 版本开始对 CDP 的 WebSocket 握手加了 Origin 校验,来源不在白名单就直接拒绝。而很多服务端框架自己带了一层中转,会顺手把 Origin 改掉,问题就出现在这里。

解决:启动参数加--remote-allow-origins='*';如果是 node 的 ws 客户端,也可以在 connect 时显式指定origin: 'http://127.0.0.1:9222'。生产环境别图省事用*,把前端实际域名列进去更合适。

5.3 解密抛「unable to authenticate data」

现象:GCM 校验失败,decipher.final()直接抛异常,堆栈里看不出任何有用信息。

原因:三种可能。一,密钥取错了,比如 DPAPI 用了另一个系统用户的作用域;二,切分位置算错,nonce 或 tag 长度不对;三,最麻烦的一种——浏览器在 Windows 上启用了 App-Bound Encryption,cookie 前缀变成v20,密钥不再存在Local State里,而是通过一个独立服务跟可执行文件的路径绑定,离线硬解基本走不通。

解决:先判断前缀是不是v20,是的话就别浪费时间了

本文还有配套的精品资源,点击获取