资讯动态

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

发布时间:2026/10/29 22:41:03 来源:尧图企业网站定制
简介围绕“共享浏览器与会话克隆”展开的实战资料包面向具备一定 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语言、时区、部分站点开关建议带上协议层是 CDPChrome DevTools Protocol。给浏览器带上--remote-debugging-port9222参数启动所有操作都通过一条 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 WindowsDPAPI 外面再套一层 AES-256-GCMWindows 上的链路是两层。最里面是 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_keyprofile 可能不完整); } 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 个空格固定 IV1003 轮、salt 固定为saltysalt、迭代哈希用 SHA-1这三项是写死在浏览器里的常量不是可配置项照抄就行。IV 固定 16 个空格这一点看着很反直觉但确实是这么实现的。Linux 稍微麻烦先看有没有可用的 keyringsecret-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 - Mapmethod, 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 \ --headlessnew \ --remote-debugging-port9222 \ --remote-debugging-address0.0.0.0 \ --remote-allow-origins* \ --user-data-dir/data/profile \ --no-sandbox \ --disable-dev-shm-usage \ --disable-gpu \ --langzh-CN \ --window-size1280,800 \ --disable-featuresTranslate,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 Encryptioncookie 前缀变成v20密钥不再存在Local State里而是通过一个独立服务跟可执行文件的路径绑定离线硬解基本走不通。解决先判断前缀是不是v20是的话就别浪费时间了本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑