mirror of
https://github.com/moonD4rk/HackBrowserData.git
synced 2026-08-17 23:57:12 +02:00
fix: improve extract parsing with proper decoding and error handling (#543)
* fix: implement proper Chromium localStorage LevelDB parsing * feat: add IsMeta field to StorageEntry and keep META entries * fix: add error logging for decryption and missing data fields * fix: address PR review for localStorage parsing * fix: use naïve instead of café in Latin-1 test to avoid typos false positive * fix: extension enabled detection and sessionStorage decoding * fix: session storage origin resolution and extension enabled detection * fix: address PR review comments for storage parsing
This commit is contained in:
@@ -31,9 +31,12 @@ func extractBookmarks(path string) ([]types.BookmarkEntry, error) {
|
|||||||
|
|
||||||
// walkBookmarks recursively traverses the bookmark tree, collecting URL entries.
|
// walkBookmarks recursively traverses the bookmark tree, collecting URL entries.
|
||||||
func walkBookmarks(node gjson.Result, folder string, out *[]types.BookmarkEntry) {
|
func walkBookmarks(node gjson.Result, folder string, out *[]types.BookmarkEntry) {
|
||||||
if node.Get("type").String() == "url" {
|
nodeType := node.Get("type").String()
|
||||||
|
if nodeType == "url" {
|
||||||
*out = append(*out, types.BookmarkEntry{
|
*out = append(*out, types.BookmarkEntry{
|
||||||
|
ID: node.Get("id").Int(),
|
||||||
Name: node.Get("name").String(),
|
Name: node.Get("name").String(),
|
||||||
|
Type: nodeType,
|
||||||
URL: node.Get("url").String(),
|
URL: node.Get("url").String(),
|
||||||
Folder: folder,
|
Folder: folder,
|
||||||
CreatedAt: typeutil.TimeEpoch(node.Get("date_added").Int()),
|
CreatedAt: typeutil.TimeEpoch(node.Get("date_added").Int()),
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"sort"
|
"sort"
|
||||||
|
|
||||||
|
"github.com/moond4rk/hackbrowserdata/log"
|
||||||
"github.com/moond4rk/hackbrowserdata/types"
|
"github.com/moond4rk/hackbrowserdata/types"
|
||||||
"github.com/moond4rk/hackbrowserdata/utils/sqliteutil"
|
"github.com/moond4rk/hackbrowserdata/utils/sqliteutil"
|
||||||
"github.com/moond4rk/hackbrowserdata/utils/typeutil"
|
"github.com/moond4rk/hackbrowserdata/utils/typeutil"
|
||||||
@@ -31,7 +32,10 @@ func extractCookies(masterKey []byte, path string) ([]types.CookieEntry, error)
|
|||||||
return types.CookieEntry{}, err
|
return types.CookieEntry{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
value, _ := decryptValue(masterKey, encryptedValue)
|
value, err := decryptValue(masterKey, encryptedValue)
|
||||||
|
if err != nil {
|
||||||
|
log.Debugf("decrypt cookie %s on %s: %v", name, host, err)
|
||||||
|
}
|
||||||
value = stripCookieHash(value, host)
|
value = stripCookieHash(value, host)
|
||||||
return types.CookieEntry{
|
return types.CookieEntry{
|
||||||
Name: name,
|
Name: name,
|
||||||
|
|||||||
@@ -3,23 +3,28 @@ package chromium
|
|||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
|
||||||
|
"github.com/moond4rk/hackbrowserdata/log"
|
||||||
"github.com/moond4rk/hackbrowserdata/types"
|
"github.com/moond4rk/hackbrowserdata/types"
|
||||||
"github.com/moond4rk/hackbrowserdata/utils/sqliteutil"
|
"github.com/moond4rk/hackbrowserdata/utils/sqliteutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
const defaultCreditCardQuery = `SELECT name_on_card, expiration_month, expiration_year,
|
const defaultCreditCardQuery = `SELECT COALESCE(guid, ''), name_on_card, expiration_month, expiration_year,
|
||||||
card_number_encrypted, COALESCE(nickname, ''), COALESCE(billing_address_id, '') FROM credit_cards`
|
card_number_encrypted, COALESCE(nickname, ''), COALESCE(billing_address_id, '') FROM credit_cards`
|
||||||
|
|
||||||
func extractCreditCards(masterKey []byte, path string) ([]types.CreditCardEntry, error) {
|
func extractCreditCards(masterKey []byte, path string) ([]types.CreditCardEntry, error) {
|
||||||
return sqliteutil.QueryRows(path, false, defaultCreditCardQuery,
|
return sqliteutil.QueryRows(path, false, defaultCreditCardQuery,
|
||||||
func(rows *sql.Rows) (types.CreditCardEntry, error) {
|
func(rows *sql.Rows) (types.CreditCardEntry, error) {
|
||||||
var name, month, year, nickName, address string
|
var guid, name, month, year, nickName, address string
|
||||||
var encNumber []byte
|
var encNumber []byte
|
||||||
if err := rows.Scan(&name, &month, &year, &encNumber, &nickName, &address); err != nil {
|
if err := rows.Scan(&guid, &name, &month, &year, &encNumber, &nickName, &address); err != nil {
|
||||||
return types.CreditCardEntry{}, err
|
return types.CreditCardEntry{}, err
|
||||||
}
|
}
|
||||||
number, _ := decryptValue(masterKey, encNumber)
|
number, err := decryptValue(masterKey, encNumber)
|
||||||
|
if err != nil {
|
||||||
|
log.Debugf("decrypt credit card for %s: %v", name, err)
|
||||||
|
}
|
||||||
return types.CreditCardEntry{
|
return types.CreditCardEntry{
|
||||||
|
GUID: guid,
|
||||||
Name: name,
|
Name: name,
|
||||||
Number: string(number),
|
Number: string(number),
|
||||||
ExpMonth: month,
|
ExpMonth: month,
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ func extractExtensionsWithKeys(path string, keys []string) ([]types.ExtensionEnt
|
|||||||
Description: manifest.Get("description").String(),
|
Description: manifest.Get("description").String(),
|
||||||
Version: manifest.Get("version").String(),
|
Version: manifest.Get("version").String(),
|
||||||
HomepageURL: manifest.Get("homepage_url").String(),
|
HomepageURL: manifest.Get("homepage_url").String(),
|
||||||
Enabled: ext.Get("state").Int() == 1,
|
Enabled: isExtensionEnabled(ext),
|
||||||
})
|
})
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
@@ -68,6 +68,17 @@ func extractExtensionsWithKeys(path string, keys []string) ([]types.ExtensionEnt
|
|||||||
return extensions, nil
|
return extensions, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isExtensionEnabled checks whether an extension is enabled.
|
||||||
|
// Modern Chrome uses disable_reasons (array): empty [] = enabled, non-empty [1] = disabled.
|
||||||
|
// Older Chrome uses state (int): 1 = enabled.
|
||||||
|
func isExtensionEnabled(ext gjson.Result) bool {
|
||||||
|
reasons := ext.Get("disable_reasons")
|
||||||
|
if reasons.Exists() {
|
||||||
|
return reasons.IsArray() && len(reasons.Array()) == 0
|
||||||
|
}
|
||||||
|
return ext.Get("state").Int() == 1
|
||||||
|
}
|
||||||
|
|
||||||
// extractOperaExtensions extracts extensions from Opera's Secure Preferences,
|
// extractOperaExtensions extracts extensions from Opera's Secure Preferences,
|
||||||
// which stores extension data under "extensions.opsettings" instead of the
|
// which stores extension data under "extensions.opsettings" instead of the
|
||||||
// standard "extensions.settings".
|
// standard "extensions.settings".
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"sort"
|
"sort"
|
||||||
|
|
||||||
|
"github.com/moond4rk/hackbrowserdata/log"
|
||||||
"github.com/moond4rk/hackbrowserdata/types"
|
"github.com/moond4rk/hackbrowserdata/types"
|
||||||
"github.com/moond4rk/hackbrowserdata/utils/sqliteutil"
|
"github.com/moond4rk/hackbrowserdata/utils/sqliteutil"
|
||||||
"github.com/moond4rk/hackbrowserdata/utils/typeutil"
|
"github.com/moond4rk/hackbrowserdata/utils/typeutil"
|
||||||
@@ -24,7 +25,10 @@ func extractPasswordsWithQuery(masterKey []byte, path, query string) ([]types.Lo
|
|||||||
if err := rows.Scan(&url, &username, &pwd, &created); err != nil {
|
if err := rows.Scan(&url, &username, &pwd, &created); err != nil {
|
||||||
return types.LoginEntry{}, err
|
return types.LoginEntry{}, err
|
||||||
}
|
}
|
||||||
password, _ := decryptValue(masterKey, pwd)
|
password, err := decryptValue(masterKey, pwd)
|
||||||
|
if err != nil {
|
||||||
|
log.Debugf("decrypt password for %s: %v", url, err)
|
||||||
|
}
|
||||||
return types.LoginEntry{
|
return types.LoginEntry{
|
||||||
URL: url,
|
URL: url,
|
||||||
Username: username,
|
Username: username,
|
||||||
|
|||||||
@@ -2,27 +2,33 @@ package chromium
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"encoding/binary"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
|
"unicode/utf16"
|
||||||
|
|
||||||
"github.com/syndtr/goleveldb/leveldb"
|
"github.com/syndtr/goleveldb/leveldb"
|
||||||
|
|
||||||
"github.com/moond4rk/hackbrowserdata/types"
|
"github.com/moond4rk/hackbrowserdata/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Chromium localStorage LevelDB key prefixes and string format bytes.
|
||||||
|
// Reference: https://chromium.googlesource.com/chromium/src/+/main/components/services/storage/dom_storage/local_storage_impl.cc
|
||||||
|
const (
|
||||||
|
localStorageVersionKey = "VERSION"
|
||||||
|
localStorageMetaPrefix = "META:"
|
||||||
|
localStorageMetaAccessKey = "METAACCESS:"
|
||||||
|
localStorageDataPrefix = '_'
|
||||||
|
chromiumStringUTF16Format = 0
|
||||||
|
chromiumStringLatin1Format = 1
|
||||||
|
)
|
||||||
|
|
||||||
|
const maxLocalStorageValueLength = 2048
|
||||||
|
|
||||||
func extractLocalStorage(path string) ([]types.StorageEntry, error) {
|
func extractLocalStorage(path string) ([]types.StorageEntry, error) {
|
||||||
return extractLevelDB(path, []byte("\x00"))
|
if _, err := os.Stat(path); err != nil {
|
||||||
}
|
return nil, fmt.Errorf("leveldb path %q: %w", path, err)
|
||||||
|
|
||||||
func extractSessionStorage(path string) ([]types.StorageEntry, error) {
|
|
||||||
return extractLevelDB(path, []byte("-"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// extractLevelDB iterates over all entries in a LevelDB directory,
|
|
||||||
// splitting each key by the separator into (url, name).
|
|
||||||
func extractLevelDB(path string, separator []byte) ([]types.StorageEntry, error) {
|
|
||||||
if _, err := os.Stat(path); os.IsNotExist(err) {
|
|
||||||
return nil, fmt.Errorf("leveldb path not found: %s", path)
|
|
||||||
}
|
}
|
||||||
db, err := leveldb.OpenFile(path, nil)
|
db, err := leveldb.OpenFile(path, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -35,24 +41,214 @@ func extractLevelDB(path string, separator []byte) ([]types.StorageEntry, error)
|
|||||||
defer iter.Release()
|
defer iter.Release()
|
||||||
|
|
||||||
for iter.Next() {
|
for iter.Next() {
|
||||||
url, name := parseStorageKey(iter.Key(), separator)
|
entry, ok := parseLocalStorageEntry(iter.Key(), iter.Value())
|
||||||
if url == "" {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
entries = append(entries, types.StorageEntry{
|
entries = append(entries, entry)
|
||||||
URL: url,
|
|
||||||
Key: name,
|
|
||||||
Value: string(iter.Value()),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
return entries, iter.Error()
|
return entries, iter.Error()
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseStorageKey splits a LevelDB key into (url, name) by the given separator.
|
// parseLocalStorageEntry classifies a LevelDB key/value pair and decodes it.
|
||||||
func parseStorageKey(key, separator []byte) (url, name string) {
|
// Returns false for VERSION entries and any unrecognized keys. META entries are kept with IsMeta=true.
|
||||||
parts := bytes.SplitN(key, separator, 2)
|
func parseLocalStorageEntry(key, value []byte) (types.StorageEntry, bool) {
|
||||||
if len(parts) != 2 {
|
switch {
|
||||||
return "", ""
|
case bytes.Equal(key, []byte(localStorageVersionKey)):
|
||||||
|
return types.StorageEntry{}, false
|
||||||
|
case bytes.HasPrefix(key, []byte(localStorageMetaAccessKey)):
|
||||||
|
return types.StorageEntry{
|
||||||
|
IsMeta: true,
|
||||||
|
URL: string(bytes.TrimPrefix(key, []byte(localStorageMetaAccessKey))),
|
||||||
|
Value: fmt.Sprintf("meta data, value bytes is %v", value),
|
||||||
|
}, true
|
||||||
|
case bytes.HasPrefix(key, []byte(localStorageMetaPrefix)):
|
||||||
|
return types.StorageEntry{
|
||||||
|
IsMeta: true,
|
||||||
|
URL: string(bytes.TrimPrefix(key, []byte(localStorageMetaPrefix))),
|
||||||
|
Value: fmt.Sprintf("meta data, value bytes is %v", value),
|
||||||
|
}, true
|
||||||
|
case len(key) > 0 && key[0] == localStorageDataPrefix:
|
||||||
|
return parseLocalStorageDataEntry(key[1:], value), true
|
||||||
|
default:
|
||||||
|
return types.StorageEntry{}, false
|
||||||
}
|
}
|
||||||
return string(parts[0]), string(parts[1])
|
}
|
||||||
|
|
||||||
|
// parseLocalStorageDataEntry decodes a data entry with format: origin\x00<encoded-key>.
|
||||||
|
func parseLocalStorageDataEntry(key, value []byte) types.StorageEntry {
|
||||||
|
entry := types.StorageEntry{
|
||||||
|
Value: decodeLocalStorageValue(value),
|
||||||
|
}
|
||||||
|
|
||||||
|
separator := bytes.IndexByte(key, 0)
|
||||||
|
if separator < 0 {
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.URL = string(key[:separator])
|
||||||
|
scriptKey, err := decodeChromiumString(key[separator+1:])
|
||||||
|
if err != nil {
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
entry.Key = scriptKey
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeChromiumString decodes a Chromium-encoded string.
|
||||||
|
// Format byte 0x01 = Latin-1, 0x00 = UTF-16 LE.
|
||||||
|
func decodeChromiumString(b []byte) (string, error) {
|
||||||
|
if len(b) == 0 {
|
||||||
|
return "", fmt.Errorf("empty chromium string")
|
||||||
|
}
|
||||||
|
switch b[0] {
|
||||||
|
case chromiumStringLatin1Format:
|
||||||
|
return decodeLatin1(b[1:]), nil
|
||||||
|
case chromiumStringUTF16Format:
|
||||||
|
return decodeUTF16LE(b[1:])
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("unknown chromium string format 0x%02x", b[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeLatin1 converts ISO-8859-1 bytes to a valid UTF-8 Go string.
|
||||||
|
// Latin-1 byte values map 1:1 to Unicode code points U+0000–U+00FF.
|
||||||
|
func decodeLatin1(b []byte) string {
|
||||||
|
runes := make([]rune, len(b))
|
||||||
|
for i, c := range b {
|
||||||
|
runes[i] = rune(c)
|
||||||
|
}
|
||||||
|
return string(runes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeUTF16LE decodes a UTF-16 Little-Endian byte slice to a Go string.
|
||||||
|
func decodeUTF16LE(b []byte) (string, error) {
|
||||||
|
if len(b) == 0 {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
if len(b)%2 != 0 {
|
||||||
|
return "", fmt.Errorf("invalid UTF-16 byte length %d", len(b))
|
||||||
|
}
|
||||||
|
u16s := make([]uint16, len(b)/2)
|
||||||
|
for i := range u16s {
|
||||||
|
u16s[i] = binary.LittleEndian.Uint16(b[i*2:])
|
||||||
|
}
|
||||||
|
return string(utf16.Decode(u16s)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeLocalStorageValue(value []byte) string {
|
||||||
|
if len(value) >= maxLocalStorageValueLength {
|
||||||
|
return fmt.Sprintf(
|
||||||
|
"value is too long, length is %d, supported max length is %d",
|
||||||
|
len(value), maxLocalStorageValueLength,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
decoded, err := decodeChromiumString(value)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Sprintf("unsupported value encoding: %v", err)
|
||||||
|
}
|
||||||
|
return decoded
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractSessionStorage reads Chromium session storage LevelDB.
|
||||||
|
//
|
||||||
|
// LevelDB key format:
|
||||||
|
//
|
||||||
|
// namespace-<guid>-<origin> → <map_id> (origin mapping)
|
||||||
|
// map-<map_id>-<key_name> → <value> (actual data, UTF-16 LE)
|
||||||
|
// next-map-id / version (metadata, skipped)
|
||||||
|
func extractSessionStorage(path string) ([]types.StorageEntry, error) {
|
||||||
|
if _, err := os.Stat(path); err != nil {
|
||||||
|
return nil, fmt.Errorf("leveldb path %q: %w", path, err)
|
||||||
|
}
|
||||||
|
db, err := leveldb.OpenFile(path, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
// Pass 1: build map_id → origin lookup from namespace entries.
|
||||||
|
// Key: "namespace-<guid>-<origin>", Value: "<map_id>" (ASCII digits).
|
||||||
|
originByMapID := make(map[string]string)
|
||||||
|
iter := db.NewIterator(nil, nil)
|
||||||
|
for iter.Next() {
|
||||||
|
key := string(iter.Key())
|
||||||
|
if !strings.HasPrefix(key, "namespace-") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Extract origin by finding "-https://", "-http://", or "-chrome://" in the key.
|
||||||
|
// Namespace GUIDs use underscores (e.g., "03b2df3a_0d95_4d55_ae57_...") so
|
||||||
|
// there is no ambiguity with the origin separator.
|
||||||
|
origin := extractNamespaceOrigin(key)
|
||||||
|
if origin == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
mapID := string(iter.Value())
|
||||||
|
originByMapID[mapID] = origin
|
||||||
|
}
|
||||||
|
iter.Release()
|
||||||
|
if err := iter.Error(); err != nil {
|
||||||
|
return nil, fmt.Errorf("read namespace entries: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass 2: read map entries and resolve origins.
|
||||||
|
var entries []types.StorageEntry
|
||||||
|
iter2 := db.NewIterator(nil, nil)
|
||||||
|
defer iter2.Release()
|
||||||
|
|
||||||
|
mapPrefix := []byte("map-")
|
||||||
|
for iter2.Next() {
|
||||||
|
key := iter2.Key()
|
||||||
|
if !bytes.HasPrefix(key, mapPrefix) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rest := key[len(mapPrefix):] // "<map_id>-<key_name>"
|
||||||
|
sep := bytes.IndexByte(rest, '-')
|
||||||
|
if sep < 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
mapID := string(rest[:sep])
|
||||||
|
keyName := string(rest[sep+1:])
|
||||||
|
|
||||||
|
origin := originByMapID[mapID]
|
||||||
|
if origin == "" {
|
||||||
|
origin = mapID // fallback to map_id if namespace not found
|
||||||
|
}
|
||||||
|
|
||||||
|
value := decodeSessionStorageValue(iter2.Value())
|
||||||
|
entries = append(entries, types.StorageEntry{
|
||||||
|
URL: origin,
|
||||||
|
Key: keyName,
|
||||||
|
Value: value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return entries, iter2.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractNamespaceOrigin extracts the origin from a namespace key.
|
||||||
|
// Key format: "namespace-<guid_with_underscores>-<origin>"
|
||||||
|
// The GUID uses underscores, so we find the origin by looking for "-http" or "-chrome".
|
||||||
|
func extractNamespaceOrigin(key string) string {
|
||||||
|
for _, prefix := range []string{"-https://", "-http://", "-chrome://"} {
|
||||||
|
idx := strings.Index(key, prefix)
|
||||||
|
if idx >= 0 {
|
||||||
|
return key[idx+1:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeSessionStorageValue decodes a session storage value.
|
||||||
|
// Values are raw UTF-16 LE (no format byte prefix, unlike localStorage).
|
||||||
|
func decodeSessionStorageValue(value []byte) string {
|
||||||
|
if len(value) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if len(value)%2 == 0 {
|
||||||
|
decoded, err := decodeUTF16LE(value)
|
||||||
|
if err == nil {
|
||||||
|
return decoded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return string(value)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,38 +1,211 @@
|
|||||||
package chromium
|
package chromium
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/binary"
|
||||||
"testing"
|
"testing"
|
||||||
|
"unicode/utf16"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// decodeChromiumString
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestDecodeChromiumString(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input []byte
|
||||||
|
want string
|
||||||
|
wantErr string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "latin1 ascii",
|
||||||
|
input: testEncodeLatin1("abc123"),
|
||||||
|
want: "abc123",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "latin1 non-ascii",
|
||||||
|
input: append([]byte{chromiumStringLatin1Format}, 0x6E, 0x61, 0xEF, 0x76, 0x65), // "naïve" in Latin-1
|
||||||
|
want: "na\u00efve", // U+00EF = ï
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "utf16le ascii",
|
||||||
|
input: testEncodeUTF16("hello"),
|
||||||
|
want: "hello",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "utf16le japanese",
|
||||||
|
input: testEncodeUTF16("テスト"),
|
||||||
|
want: "テスト",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "utf16le empty content",
|
||||||
|
input: []byte{chromiumStringUTF16Format},
|
||||||
|
want: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unknown format",
|
||||||
|
input: []byte{2, 'x'},
|
||||||
|
wantErr: "unknown chromium string format",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid utf16 byte length",
|
||||||
|
input: []byte{chromiumStringUTF16Format, 0x61},
|
||||||
|
wantErr: "invalid UTF-16 byte length",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty input",
|
||||||
|
input: []byte{},
|
||||||
|
wantErr: "empty chromium string",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, err := decodeChromiumString(tt.input)
|
||||||
|
if tt.wantErr != "" {
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), tt.wantErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, tt.want, got)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// parseLocalStorageEntry
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestParseLocalStorageEntry(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
key []byte
|
||||||
|
value []byte
|
||||||
|
wantParsed bool
|
||||||
|
wantMeta bool
|
||||||
|
wantURL string
|
||||||
|
wantKey string
|
||||||
|
wantValue string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "skip VERSION",
|
||||||
|
key: []byte(localStorageVersionKey),
|
||||||
|
wantParsed: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "META entry",
|
||||||
|
key: []byte(localStorageMetaPrefix + "https://example.com"),
|
||||||
|
value: []byte{0x08, 0x96, 0x01},
|
||||||
|
wantParsed: true,
|
||||||
|
wantMeta: true,
|
||||||
|
wantURL: "https://example.com",
|
||||||
|
wantValue: "meta data, value bytes is [8 150 1]",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "METAACCESS entry",
|
||||||
|
key: []byte(localStorageMetaAccessKey + "https://example.com"),
|
||||||
|
value: []byte{0x10, 0x20},
|
||||||
|
wantParsed: true,
|
||||||
|
wantMeta: true,
|
||||||
|
wantURL: "https://example.com",
|
||||||
|
wantValue: "meta data, value bytes is [16 32]",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "latin1 data entry",
|
||||||
|
key: append([]byte("_https://example.com\x00"), testEncodeLatin1("token")...),
|
||||||
|
value: testEncodeLatin1("abc123"),
|
||||||
|
wantParsed: true,
|
||||||
|
wantURL: "https://example.com",
|
||||||
|
wantKey: "token",
|
||||||
|
wantValue: "abc123",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "utf16 data entry",
|
||||||
|
key: append([]byte("_https://example.com\x00"), testEncodeUTF16("テスト")...),
|
||||||
|
value: testEncodeUTF16("データ"),
|
||||||
|
wantParsed: true,
|
||||||
|
wantURL: "https://example.com",
|
||||||
|
wantKey: "テスト",
|
||||||
|
wantValue: "データ",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing origin separator",
|
||||||
|
key: []byte("_https://example.com"),
|
||||||
|
value: testEncodeLatin1("abc123"),
|
||||||
|
wantParsed: true,
|
||||||
|
wantURL: "",
|
||||||
|
wantKey: "",
|
||||||
|
wantValue: "abc123",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
entry, parsed := parseLocalStorageEntry(tt.key, tt.value)
|
||||||
|
assert.Equal(t, tt.wantParsed, parsed)
|
||||||
|
if !parsed {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
assert.Equal(t, tt.wantMeta, entry.IsMeta)
|
||||||
|
assert.Equal(t, tt.wantURL, entry.URL)
|
||||||
|
assert.Equal(t, tt.wantKey, entry.Key)
|
||||||
|
assert.Equal(t, tt.wantValue, entry.Value)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// extractLocalStorage (integration with LevelDB)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
func TestExtractLocalStorage(t *testing.T) {
|
func TestExtractLocalStorage(t *testing.T) {
|
||||||
dir := createTestLevelDB(t, map[string]string{
|
dir := createTestLevelDB(t, map[string]string{
|
||||||
"https://example.com\x00token": "abc123",
|
localStorageVersionKey: "1",
|
||||||
"https://example.com\x00theme": "dark",
|
localStorageMetaPrefix + "https://example.com": string([]byte{0x08, 0x96, 0x01}),
|
||||||
"https://other.com\x00session_id": "xyz789",
|
localStorageMetaAccessKey + "https://example.com": string([]byte{0x10, 0x20}),
|
||||||
"noseparator": "should-be-skipped",
|
string(append([]byte("_https://example.com\x00"), testEncodeLatin1("token")...)): string(testEncodeLatin1("abc123")),
|
||||||
|
string(append([]byte("_https://example.com\x00"), testEncodeUTF16("テスト")...)): string(testEncodeUTF16("データ")),
|
||||||
})
|
})
|
||||||
|
|
||||||
got, err := extractLocalStorage(dir)
|
got, err := extractLocalStorage(dir)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Len(t, got, 3) // "noseparator" entry skipped
|
require.Len(t, got, 4, "VERSION filtered, META kept, data kept")
|
||||||
|
|
||||||
// Verify field mapping by collecting into a lookup
|
metaCount := 0
|
||||||
byKey := map[string]string{}
|
byKey := map[string]string{}
|
||||||
for _, entry := range got {
|
for _, e := range got {
|
||||||
byKey[entry.URL+"/"+entry.Key] = entry.Value
|
assert.Equal(t, "https://example.com", e.URL)
|
||||||
|
if e.IsMeta {
|
||||||
|
metaCount++
|
||||||
|
assert.Contains(t, e.Value, "meta data, value bytes is")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
byKey[e.Key] = e.Value
|
||||||
}
|
}
|
||||||
assert.Equal(t, "abc123", byKey["https://example.com/token"])
|
assert.Equal(t, 2, metaCount)
|
||||||
assert.Equal(t, "dark", byKey["https://example.com/theme"])
|
assert.Equal(t, "abc123", byKey["token"])
|
||||||
assert.Equal(t, "xyz789", byKey["https://other.com/session_id"])
|
assert.Equal(t, "データ", byKey["テスト"])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// extractSessionStorage
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
func TestExtractSessionStorage(t *testing.T) {
|
func TestExtractSessionStorage(t *testing.T) {
|
||||||
dir := createTestLevelDB(t, map[string]string{
|
dir := createTestLevelDB(t, map[string]string{
|
||||||
"https://example.com-token": "abc123",
|
// Namespace entry: maps guid+origin → map_id
|
||||||
"https://example.com-user": "alice",
|
"namespace-abcd1234_5678_9abc_def0_111111111111-https://github.com/": "100",
|
||||||
|
"namespace-abcd1234_5678_9abc_def0_111111111111-https://example.com/": "101",
|
||||||
|
// Map entries: actual data (values are raw UTF-16 LE)
|
||||||
|
"map-100-__darkreader__wasEnabledForHost": string(testEncodeUTF16Raw("false")),
|
||||||
|
"map-101-token": string(testEncodeUTF16Raw("abc123")),
|
||||||
|
// Metadata: should be skipped
|
||||||
|
"next-map-id": "200",
|
||||||
|
"version": "1",
|
||||||
})
|
})
|
||||||
|
|
||||||
got, err := extractSessionStorage(dir)
|
got, err := extractSessionStorage(dir)
|
||||||
@@ -41,8 +214,41 @@ func TestExtractSessionStorage(t *testing.T) {
|
|||||||
|
|
||||||
byKey := map[string]string{}
|
byKey := map[string]string{}
|
||||||
for _, entry := range got {
|
for _, entry := range got {
|
||||||
byKey[entry.Key] = entry.Value
|
byKey[entry.URL+"/"+entry.Key] = entry.Value
|
||||||
}
|
}
|
||||||
assert.Equal(t, "abc123", byKey["token"])
|
assert.Equal(t, "false", byKey["https://github.com//__darkreader__wasEnabledForHost"])
|
||||||
assert.Equal(t, "alice", byKey["user"])
|
assert.Equal(t, "abc123", byKey["https://example.com//token"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Test helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func testEncodeLatin1(s string) []byte {
|
||||||
|
return append([]byte{chromiumStringLatin1Format}, []byte(s)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testEncodeUTF16(s string) []byte {
|
||||||
|
encoded := utf16.Encode([]rune(s))
|
||||||
|
result := make([]byte, 1, 1+len(encoded)*2)
|
||||||
|
result[0] = chromiumStringUTF16Format
|
||||||
|
for _, r := range encoded {
|
||||||
|
var raw [2]byte
|
||||||
|
binary.LittleEndian.PutUint16(raw[:], r)
|
||||||
|
result = append(result, raw[:]...)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// testEncodeUTF16Raw encodes as raw UTF-16 LE without format byte prefix
|
||||||
|
// (used by session storage values).
|
||||||
|
func testEncodeUTF16Raw(s string) []byte {
|
||||||
|
encoded := utf16.Encode([]rune(s))
|
||||||
|
result := make([]byte, 0, len(encoded)*2)
|
||||||
|
for _, r := range encoded {
|
||||||
|
var raw [2]byte
|
||||||
|
binary.LittleEndian.PutUint16(raw[:], r)
|
||||||
|
result = append(result, raw[:]...)
|
||||||
|
}
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"github.com/tidwall/gjson"
|
"github.com/tidwall/gjson"
|
||||||
|
|
||||||
"github.com/moond4rk/hackbrowserdata/crypto"
|
"github.com/moond4rk/hackbrowserdata/crypto"
|
||||||
|
"github.com/moond4rk/hackbrowserdata/log"
|
||||||
"github.com/moond4rk/hackbrowserdata/types"
|
"github.com/moond4rk/hackbrowserdata/types"
|
||||||
"github.com/moond4rk/hackbrowserdata/utils/typeutil"
|
"github.com/moond4rk/hackbrowserdata/utils/typeutil"
|
||||||
)
|
)
|
||||||
@@ -43,8 +44,14 @@ func extractPasswords(masterKey []byte, path string) ([]types.LoginEntry, error)
|
|||||||
url = v.Get("hostname").String()
|
url = v.Get("hostname").String()
|
||||||
}
|
}
|
||||||
|
|
||||||
user, _ := decryptPBE(v.Get("encryptedUsername").String(), masterKey)
|
user, err := decryptPBE(v.Get("encryptedUsername").String(), masterKey)
|
||||||
pwd, _ := decryptPBE(v.Get("encryptedPassword").String(), masterKey)
|
if err != nil {
|
||||||
|
log.Debugf("decrypt firefox username for %s: %v", url, err)
|
||||||
|
}
|
||||||
|
pwd, err := decryptPBE(v.Get("encryptedPassword").String(), masterKey)
|
||||||
|
if err != nil {
|
||||||
|
log.Debugf("decrypt firefox password for %s: %v", url, err)
|
||||||
|
}
|
||||||
|
|
||||||
logins = append(logins, types.LoginEntry{
|
logins = append(logins, types.LoginEntry{
|
||||||
URL: url,
|
URL: url,
|
||||||
|
|||||||
@@ -55,11 +55,11 @@ func TestStructCSVHeader(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{"LoginEntry", types.LoginEntry{}, []string{"url", "username", "password", "created_at"}},
|
{"LoginEntry", types.LoginEntry{}, []string{"url", "username", "password", "created_at"}},
|
||||||
{"CookieEntry", types.CookieEntry{}, []string{"host", "path", "name", "value", "is_secure", "is_http_only", "has_expire", "is_persistent", "expire_at", "created_at"}},
|
{"CookieEntry", types.CookieEntry{}, []string{"host", "path", "name", "value", "is_secure", "is_http_only", "has_expire", "is_persistent", "expire_at", "created_at"}},
|
||||||
{"BookmarkEntry", types.BookmarkEntry{}, []string{"name", "url", "folder", "created_at"}},
|
{"BookmarkEntry", types.BookmarkEntry{}, []string{"id", "name", "type", "url", "folder", "created_at"}},
|
||||||
{"HistoryEntry", types.HistoryEntry{}, []string{"url", "title", "visit_count", "last_visit"}},
|
{"HistoryEntry", types.HistoryEntry{}, []string{"url", "title", "visit_count", "last_visit"}},
|
||||||
{"DownloadEntry", types.DownloadEntry{}, []string{"url", "target_path", "mime_type", "total_bytes", "start_time", "end_time"}},
|
{"DownloadEntry", types.DownloadEntry{}, []string{"url", "target_path", "mime_type", "total_bytes", "start_time", "end_time"}},
|
||||||
{"CreditCardEntry", types.CreditCardEntry{}, []string{"name", "number", "exp_month", "exp_year", "nick_name", "address"}},
|
{"CreditCardEntry", types.CreditCardEntry{}, []string{"guid", "name", "number", "exp_month", "exp_year", "nick_name", "address"}},
|
||||||
{"StorageEntry", types.StorageEntry{}, []string{"url", "key", "value"}},
|
{"StorageEntry", types.StorageEntry{}, []string{"is_meta", "url", "key", "value"}},
|
||||||
{"ExtensionEntry", types.ExtensionEntry{}, []string{"name", "id", "description", "version", "homepage_url", "enabled"}},
|
{"ExtensionEntry", types.ExtensionEntry{}, []string{"name", "id", "description", "version", "homepage_url", "enabled"}},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
|
|||||||
+7
-3
@@ -26,7 +26,9 @@ type CookieEntry struct {
|
|||||||
|
|
||||||
// BookmarkEntry represents a single browser bookmark.
|
// BookmarkEntry represents a single browser bookmark.
|
||||||
type BookmarkEntry struct {
|
type BookmarkEntry struct {
|
||||||
|
ID int64 `json:"id" csv:"id"`
|
||||||
Name string `json:"name" csv:"name"`
|
Name string `json:"name" csv:"name"`
|
||||||
|
Type string `json:"type" csv:"type"`
|
||||||
URL string `json:"url" csv:"url"`
|
URL string `json:"url" csv:"url"`
|
||||||
Folder string `json:"folder" csv:"folder"`
|
Folder string `json:"folder" csv:"folder"`
|
||||||
CreatedAt time.Time `json:"created_at" csv:"created_at"`
|
CreatedAt time.Time `json:"created_at" csv:"created_at"`
|
||||||
@@ -52,6 +54,7 @@ type DownloadEntry struct {
|
|||||||
|
|
||||||
// CreditCardEntry represents a single saved credit card.
|
// CreditCardEntry represents a single saved credit card.
|
||||||
type CreditCardEntry struct {
|
type CreditCardEntry struct {
|
||||||
|
GUID string `json:"guid" csv:"guid"`
|
||||||
Name string `json:"name" csv:"name"`
|
Name string `json:"name" csv:"name"`
|
||||||
Number string `json:"number" csv:"number"`
|
Number string `json:"number" csv:"number"`
|
||||||
ExpMonth string `json:"exp_month" csv:"exp_month"`
|
ExpMonth string `json:"exp_month" csv:"exp_month"`
|
||||||
@@ -62,9 +65,10 @@ type CreditCardEntry struct {
|
|||||||
|
|
||||||
// StorageEntry represents a single key-value pair from local or session storage.
|
// StorageEntry represents a single key-value pair from local or session storage.
|
||||||
type StorageEntry struct {
|
type StorageEntry struct {
|
||||||
URL string `json:"url" csv:"url"`
|
IsMeta bool `json:"is_meta" csv:"is_meta"`
|
||||||
Key string `json:"key" csv:"key"`
|
URL string `json:"url" csv:"url"`
|
||||||
Value string `json:"value" csv:"value"`
|
Key string `json:"key" csv:"key"`
|
||||||
|
Value string `json:"value" csv:"value"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExtensionEntry represents a single browser extension.
|
// ExtensionEntry represents a single browser extension.
|
||||||
|
|||||||
Reference in New Issue
Block a user