Files
HackBrowserData/filemanager/copy_windows.go
T
Roger 2c4e871e59 fix: strip host_key prefix from Chrome 130+ cookie values (#526)
* fix: strip SHA256(host_key) prefix from Chrome 130+ cookie values

Chrome 130 (Cookie DB schema v24) prepends SHA256(domain) to cookie
values before encryption to prevent cross-domain replay attacks.
After decryption, this 32-byte hash must be verified and stripped.

Changes:
- Add stripCookieHash() that verifies SHA256(host_key) and strips
  the prefix only when it matches (auto-compatible with older Chrome)
- Fix edge case: cookies with empty values (exactly 32 bytes = hash only)
- Add decrypt_test.go with v10 round-trip encryption/decryption test
- Add stripCookieHash test cases for v24+, older Chrome, empty values,
  short values, and host mismatch scenarios

Closes #524

* fix: strip SHA256(host_key) prefix from Chrome 130+ cookie values

Chrome 130 (Cookie DB schema v24) prepends SHA256(domain) to cookie
values before encryption to prevent cross-domain replay attacks.
After decryption, this 32-byte hash must be verified and stripped.

Changes:
- Add stripCookieHash() that verifies SHA256(host_key) and strips
  the prefix only when it matches (auto-compatible with older Chrome)
- Fix edge case: cookies with empty values (exactly 32 bytes = hash only)
- Add table-driven decrypt tests for v10/v20/DPAPI per platform
- Add Windows-specific DPAPI round-trip test using CryptProtectData
- Add shared testAESKey constant in testutil_test.go
- Add stripCookieHash tests for v24+, older Chrome, empty values,
  short values, and host mismatch scenarios
- Extend lint CI to run on ubuntu, windows, and macos

Closes #524

* fix: remove DPAPI test from darwin/linux (returns nil on Linux)

DecryptWithDPAPI returns nil error on Linux (silent no-op) but error
on macOS, causing the test to fail on Ubuntu CI. DPAPI round-trip
testing is properly covered in decrypt_windows_test.go.

* fix: resolve Windows CI lint errors exposed by multi-platform lint

- Add _ = before windows.CloseHandle calls to satisfy errcheck
- Add build tag to params.go (only used on macOS/Linux, not Windows)

* fix: add .gitattributes to force LF and refactor cookie tests

- Add .gitattributes with `* text=auto eol=lf` to prevent CRLF
  conversion on Windows CI causing gofumpt false positives
- Add .gitattributes to .gitignore whitelist
- Refactor stripCookieHash tests into table-driven style

* fix: address Copilot review on decrypt tests

- Assert error on wrong key instead of ignoring it (AES-CBC returns
  padding error, not silent empty result)
- Guard empty plaintext in encryptWithDPAPI to prevent nil pointer panic
- Convert uint32 to int for make/copy slice bounds in Windows test

* fix: assert specific error message in wrong key decrypt test
2026-04-04 01:41:01 +08:00

333 lines
10 KiB
Go

//go:build windows
package filemanager
import (
"fmt"
"os"
"strings"
"unsafe"
"golang.org/x/sys/windows"
)
const (
// systemExtendedHandleInformation is the information class for
// NtQuerySystemInformation that returns SYSTEM_HANDLE_INFORMATION_EX.
// This is the 64-bit safe version (class 64) — UniqueProcessId is ULONG_PTR
// instead of USHORT, avoiding PID truncation on 64-bit Windows.
systemExtendedHandleInformation = 64
statusInfoLengthMismatch = 0xC0000004
fileMAPRead = 0x0004
pageReadonly = 0x02
fileTypeDisk = 0x0001
)
// systemHandleTableEntryInfoEx represents SYSTEM_HANDLE_TABLE_ENTRY_INFO_EX.
// This is the extended version returned by SystemExtendedHandleInformation (class 64).
//
// Layout (64-bit Windows):
//
// PVOID Object; // 8 bytes
// ULONG_PTR UniqueProcessId; // 8 bytes
// ULONG_PTR HandleValue; // 8 bytes
// ULONG GrantedAccess; // 4 bytes
// USHORT CreatorBackTraceIndex; // 2 bytes
// USHORT ObjectTypeIndex; // 2 bytes
// ULONG HandleAttributes; // 4 bytes
// ULONG Reserved; // 4 bytes
// Total: 40 bytes on 64-bit
type systemHandleTableEntryInfoEx struct {
Object uintptr
UniqueProcessID uintptr // ULONG_PTR: safe for PID > 65535
HandleValue uintptr // ULONG_PTR: safe for large handle values
GrantedAccess uint32
CreatorBackTraceIndex uint16
ObjectTypeIndex uint16
HandleAttributes uint32
Reserved uint32
}
var (
ntdll = windows.NewLazySystemDLL("ntdll.dll")
procNtQuerySystemInformation = ntdll.NewProc("NtQuerySystemInformation")
kernel32 = windows.NewLazySystemDLL("kernel32.dll")
procGetFileType = kernel32.NewProc("GetFileType")
procGetFinalPathNameByHandleW = kernel32.NewProc("GetFinalPathNameByHandleW")
procCreateFileMappingW = kernel32.NewProc("CreateFileMappingW")
procMapViewOfFile = kernel32.NewProc("MapViewOfFile")
procUnmapViewOfFile = kernel32.NewProc("UnmapViewOfFile")
procGetFileSizeEx = kernel32.NewProc("GetFileSizeEx")
)
// copyLocked copies a file that is locked by another process (e.g., Chrome's
// Cookies database with PRAGMA locking_mode=EXCLUSIVE).
//
// Approach: DuplicateHandle + FileMapping
// 1. Enumerate all open file handles via NtQuerySystemInformation(SystemExtendedHandleInformation)
// 2. Find the handle matching the target file path
// 3. Duplicate that handle into our process via DuplicateHandle
// 4. Read file content through memory-mapped I/O (CreateFileMapping + MapViewOfFile)
// 5. Write content to destination
//
// This requires only normal user privileges (no admin needed).
func copyLocked(src, dst string) error {
handle, err := findFileHandle(src)
if err != nil {
return fmt.Errorf("find file handle for %s: %w", src, err)
}
defer windows.CloseHandle(handle)
data, err := readFileContent(handle)
if err != nil {
return fmt.Errorf("read via file mapping: %w", err)
}
return os.WriteFile(dst, data, 0o600)
}
// findFileHandle enumerates all system handles, finds the one matching the
// target file path, and duplicates it into the current process.
func findFileHandle(targetPath string) (windows.Handle, error) {
// Extract a stable suffix for matching that avoids short path name issues
// (e.g., RUNNER~1 vs runneradmin in the username portion).
// We match from AppData onwards, which uniquely identifies each browser:
// Google\Chrome\User Data\Default\Network\Cookies (Chrome)
// Microsoft\Edge\User Data\Default\Network\Cookies (Edge)
targetSuffix := extractStableSuffix(targetPath)
currentProcess := windows.CurrentProcess()
handles, err := querySystemHandles()
if err != nil {
return 0, err
}
for _, h := range handles {
pid := uint32(h.UniqueProcessID)
if pid == 0 {
continue
}
// Open the owning process to duplicate its handle
process, err := windows.OpenProcess(windows.PROCESS_DUP_HANDLE, false, pid)
if err != nil {
continue
}
// Duplicate the handle into our process
var dupHandle windows.Handle
err = windows.DuplicateHandle(
process,
windows.Handle(h.HandleValue),
currentProcess,
&dupHandle,
0, false,
windows.DUPLICATE_SAME_ACCESS,
)
_ = windows.CloseHandle(process)
if err != nil {
continue
}
// Verify it's a disk file (not a pipe, device, etc.)
fileType, _, _ := procGetFileType.Call(uintptr(dupHandle))
if fileType != fileTypeDisk {
_ = windows.CloseHandle(dupHandle)
continue
}
// Get the file path and check if it matches our target
name, err := getFinalPathName(dupHandle)
if err != nil {
_ = windows.CloseHandle(dupHandle)
continue
}
if strings.HasSuffix(strings.ToLower(name), targetSuffix) {
return dupHandle, nil
}
_ = windows.CloseHandle(dupHandle)
}
return 0, fmt.Errorf("no process has file open: %s", targetPath)
}
// querySystemHandles calls NtQuerySystemInformation with
// SystemExtendedHandleInformation (class 64) to enumerate all open handles.
func querySystemHandles() ([]systemHandleTableEntryInfoEx, error) {
bufSize := uint32(4 * 1024 * 1024) // start at 4 MB
for {
buf := make([]byte, bufSize)
var returnLength uint32
ret, _, _ := procNtQuerySystemInformation.Call(
systemExtendedHandleInformation,
uintptr(unsafe.Pointer(&buf[0])),
uintptr(bufSize),
uintptr(unsafe.Pointer(&returnLength)),
)
if ret == statusInfoLengthMismatch {
bufSize *= 2
if bufSize > 256*1024*1024 {
return nil, fmt.Errorf("handle info buffer exceeded 256 MB")
}
continue
}
if ret != 0 {
return nil, fmt.Errorf("NtQuerySystemInformation returned 0x%x", ret)
}
// Parse: first field is NumberOfHandles (ULONG_PTR), then array of entries
// On 64-bit: ULONG_PTR = 8 bytes
numberOfHandles := *(*uintptr)(unsafe.Pointer(&buf[0]))
if numberOfHandles == 0 {
return nil, nil
}
count := int(numberOfHandles)
// Entries start after NumberOfHandles + Reserved (both ULONG_PTR = 16 bytes total)
const headerSize = unsafe.Sizeof(uintptr(0)) * 2
entrySize := unsafe.Sizeof(systemHandleTableEntryInfoEx{})
// Validate buffer bounds
required := headerSize + uintptr(count)*entrySize
if required > uintptr(len(buf)) {
return nil, fmt.Errorf("buffer too small: need %d, have %d", required, len(buf))
}
entries := make([]systemHandleTableEntryInfoEx, count)
for i := 0; i < count; i++ {
src := unsafe.Pointer(uintptr(unsafe.Pointer(&buf[0])) + headerSize + uintptr(i)*entrySize)
entries[i] = *(*systemHandleTableEntryInfoEx)(src)
}
return entries, nil
}
}
// getFinalPathName returns the normalized file path for a file handle.
func getFinalPathName(handle windows.Handle) (string, error) {
size := 512
for {
buf := make([]uint16, size)
n, _, err := procGetFinalPathNameByHandleW.Call(
uintptr(handle),
uintptr(unsafe.Pointer(&buf[0])),
uintptr(len(buf)),
0, // FILE_NAME_NORMALIZED
)
if n == 0 {
return "", fmt.Errorf("GetFinalPathNameByHandle: %w", err)
}
if int(n) > len(buf) {
// Buffer too small, retry with required size
size = int(n)
continue
}
path := windows.UTF16ToString(buf[:n])
// Remove \\?\ prefix added by GetFinalPathNameByHandle
path = strings.TrimPrefix(path, `\\?\`)
return path, nil
}
}
// readFileContent reads file content from a duplicated handle.
// It uses FileMapping first (CreateFileMapping + MapViewOfFile), which reads
// from the OS kernel's file cache — this includes WAL data that Chrome has
// written but not yet checkpointed to the main file. Falls back to ReadFile
// if FileMapping fails.
func readFileContent(handle windows.Handle) ([]byte, error) {
// Get file size
var fileSize int64
ret, _, sizeErr := procGetFileSizeEx.Call(
uintptr(handle),
uintptr(unsafe.Pointer(&fileSize)),
)
if ret == 0 {
return nil, fmt.Errorf("GetFileSizeEx: %w", sizeErr)
}
if fileSize == 0 {
return nil, fmt.Errorf("file is empty")
}
size := int(fileSize)
// Try FileMapping first — reads from kernel file cache, includes WAL data
if data, err := readViaFileMapping(handle, size); err == nil {
return data, nil
}
// FileMapping failed, fall back to ReadFile
// Seek to beginning first — the handle's file pointer may be at an arbitrary position
if _, err := windows.Seek(handle, 0, 0); err != nil {
return nil, fmt.Errorf("seek to start: %w", err)
}
data := make([]byte, size)
var bytesRead uint32
if err := windows.ReadFile(handle, data, &bytesRead, nil); err != nil {
return nil, fmt.Errorf("ReadFile: %w", err)
}
return data[:bytesRead], nil
}
// readViaFileMapping reads file content using CreateFileMapping + MapViewOfFile.
func readViaFileMapping(handle windows.Handle, size int) ([]byte, error) {
mapping, _, err := procCreateFileMappingW.Call(
uintptr(handle),
0, pageReadonly,
0, 0, 0,
)
if mapping == 0 {
return nil, fmt.Errorf("CreateFileMapping: %w", err)
}
defer windows.CloseHandle(windows.Handle(mapping))
viewPtr, _, err := procMapViewOfFile.Call(
mapping, fileMAPRead,
0, 0, 0,
)
if viewPtr == 0 {
return nil, fmt.Errorf("MapViewOfFile: %w", err)
}
defer procUnmapViewOfFile.Call(viewPtr)
// viewPtr is a valid pointer from MapViewOfFile syscall.
// go vet flags this as "possible misuse of unsafe.Pointer" but it's
// correct usage for Windows memory-mapped I/O.
data := make([]byte, size)
copy(data, (*[1 << 30]byte)(unsafe.Pointer(viewPtr))[:size]) //nolint:govet
return data, nil
}
// extractStableSuffix extracts a path suffix that is stable across short/long
// path name variations. It finds "AppData" in the path and returns everything
// after "AppData\Local\" or "AppData\Roaming\" in lowercase.
//
// Example:
//
// C:\Users\RUNNER~1\AppData\Local\Google\Chrome\...\Cookies
// → google\chrome\...\cookies
//
// For paths without "AppData" (e.g., test temp dirs), it falls back to
// the last 3 path components to provide reasonable matching specificity.
func extractStableSuffix(path string) string {
lower := strings.ToLower(path)
// Try to find AppData\Local\ or AppData\Roaming\
for _, marker := range []string{`appdata\local\`, `appdata\roaming\`} {
if idx := strings.Index(lower, marker); idx != -1 {
return lower[idx+len(marker):]
}
}
// Fallback: use last 3 components for test paths
parts := strings.Split(lower, string(os.PathSeparator))
if len(parts) >= 3 {
return strings.Join(parts[len(parts)-3:], string(os.PathSeparator))
}
return lower
}