← Blog · · df00tech

Detecting Infostealer Credential Theft: Browser Password Stores, Cookies, and Session Tokens (T1555.003, T1539, T1552.001) with KQL and SPL

Detection Engineering MITRE ATT&CK KQL SPL Threat Hunting Microsoft Sentinel Splunk

Most SOCs have solid coverage for LSASS memory access (T1003.001). Very few have equivalent coverage for the credential theft that actually precedes the majority of hands-on-keyboard intrusions today: a commodity infostealer running for eight seconds in user context, copying SQLite databases out of a browser profile, and leaving before anyone finishes triaging the alert.

That gap exists because infostealers do not look like credential dumpers. They never touch LSASS. They rarely need admin. They do not inject into lsass.exe, they do not load a driver, and on a well-tuned EDR they generate exactly one suspicious signal: a process that is not a browser reading files that only a browser should read.

This post gives you four detections that cover the real infostealer kill chain, with query logic for Microsoft Sentinel / Defender XDR (KQL) and Splunk (SPL), mapped to T1555.003 (Credentials from Web Browsers), T1539 (Steal Web Session Cookie), and T1552.001 (Credentials In Files).

What the stealer actually touches

Strip away the branding and nearly every Windows infostealer follows the same four beats:

  1. Collect. Copy Login Data, Cookies, Web Data, and Local State out of the Chromium profile directory (and logins.json / key4.db for Firefox). The copy happens because the live database is locked.
  2. Decrypt. Pull the DPAPI-protected master key out of Local State. Since Chrome 127 introduced app-bound encryption for cookies, this beat increasingly requires either code running inside the browser process or the browser's own remote debugging interface.
  3. Expand. Sweep the user profile for anything else credential-shaped: .aws/credentials, .ssh/id_rsa, .docker/config.json, FileZilla configs, crypto wallet directories, Discord and Telegram token stores.
  4. Exfiltrate. Archive the staging directory and push it out over HTTPS to a Telegram bot, a Discord CDN endpoint, or a throwaway file-sharing host — see T1567.002.

Beats 1 and 3 are file-system events. Beat 2 is a process event. Beat 4 is a network event. You need all three telemetry sources, but the highest-fidelity signal by a wide margin is beat 1.

Detection 1: browser credential databases copied out of the profile directory

The anchor logic: these filenames are meaningless anywhere except inside a browser profile path. A Login Data file appearing in %TEMP%, %PROGRAMDATA%, or a numeric subdirectory of %APPDATA% is not ambiguous — it is a collection event. Requiring two or more distinct artifacts from the same process inside a short window pushes this to near-zero false positives.

let SecretFiles = dynamic(['Login Data', 'Login Data For Account', 'Cookies', 'Web Data', 'Local State', 'logins.json', 'key4.db', 'cert9.db']);
let BrowserRoots = dynamic([@'\Google\Chrome\', @'\Microsoft\Edge\', @'\Mozilla\Firefox\', @'\BraveSoftware\', @'\Chromium\']);
DeviceFileEvents
| where Timestamp > ago(7d)
| where ActionType in ('FileCreated', 'FileRenamed', 'FileModified')
| where FileName has_any (SecretFiles)
| where not (FolderPath has_any (BrowserRoots))
| where InitiatingProcessFileName !in~ ('chrome.exe', 'msedge.exe', 'firefox.exe', 'brave.exe', 'MsMpEng.exe', 'backup-agent.exe')
| summarize Artifacts = dcount(FileName), ArtifactList = make_set(FileName, 10), Paths = make_set(FolderPath, 5),
            FirstSeen = min(Timestamp), LastSeen = max(Timestamp)
    by DeviceName, InitiatingProcessAccountName, InitiatingProcessFileName, InitiatingProcessCommandLine, InitiatingProcessSHA256
| where Artifacts >= 2
| where LastSeen - FirstSeen < 10m
| order by Artifacts desc

The Splunk equivalent using Sysmon Event ID 11:

index=sysmon EventCode=11
  TargetFilename IN ("*\\Login Data*", "*\\Cookies*", "*\\Web Data*", "*\\Local State", "*\\logins.json", "*\\key4.db")
  NOT TargetFilename IN ("*\\Google\\Chrome\\*", "*\\Microsoft\\Edge\\*", "*\\Mozilla\\Firefox\\*", "*\\BraveSoftware\\*")
  NOT Image IN ("*\\chrome.exe", "*\\msedge.exe", "*\\firefox.exe", "*\\brave.exe")
| bin _time span=10m
| stats dc(TargetFilename) as artifacts, values(TargetFilename) as paths, min(_time) as first by _time, host, user, Image, ProcessGuid
| where artifacts >= 2
| sort - artifacts

Note that DeviceFileEvents gives you the write, not the read. That is fine, and arguably better: the copy is the part that is unambiguously malicious, and it is the part that survives even when the stealer is a signed binary or a LOLBin. If you want read visibility as well, layer in T1005 (Data from Local System) file audit policy on the profile directories for your highest-value users.

Detection 2: app-bound encryption bypass via remote debugging and browser injection

Chromium's app-bound encryption broke the old "copy the DB, decrypt with DPAPI" workflow for cookies. Two bypasses dominate. The first is launching the browser with its own debugging interface enabled and reading cookies over the DevTools protocol. The second is getting code into the browser process so the decryption happens with the browser's own identity — a process injection (T1055) variant.

DeviceProcessEvents
| where Timestamp > ago(7d)
| where FileName in~ ('chrome.exe', 'msedge.exe', 'brave.exe', 'opera.exe', 'vivaldi.exe')
| where ProcessCommandLine has_any ('--remote-debugging-port', '--remote-debugging-pipe', '--headless',
                                   '--disable-features=LockProfileCookieDatabase', '--user-data-dir=')
| where InitiatingProcessFileName !in~ ('explorer.exe', 'chrome.exe', 'msedge.exe', 'brave.exe', 'firefox.exe',
                                       'node.exe', 'python.exe')
| project Timestamp, DeviceName, AccountName, InitiatingProcessFileName, InitiatingProcessFolderPath,
          InitiatingProcessCommandLine, FileName, ProcessCommandLine
| order by Timestamp desc

Watch the parent process here, not the flag. The flag alone is a CI/CD false-positive factory. A browser spawned with --remote-debugging-port by node.exe on a developer workstation is Playwright; the same command line spawned by a freshly written executable in %TEMP%, by wscript.exe, or by a powershell.exe process with an encoded command is the detection you actually want.

For the injection path, Sysmon Event ID 10 on the browser as target:

index=sysmon EventCode=10
  TargetImage IN ("*\\chrome.exe", "*\\msedge.exe", "*\\brave.exe")
  GrantedAccess IN ("0x1410", "0x1010", "0x143A", "0x1F3FFF", "0x1FFFFF")
  NOT SourceImage IN ("*\\chrome.exe", "*\\msedge.exe", "*\\brave.exe", "*\\MsMpEng.exe", "*\\SenseIR.exe", "*\\CSFalconService.exe")
| stats count, values(GrantedAccess) as access, values(TargetImage) as targets, values(CallTrace) as call_trace
    by host, user, SourceImage
| sort - count

Pair this with Sysmon Event ID 8 (CreateRemoteThread) targeting the same browser images. In Defender XDR, the equivalent signal lives in DeviceEvents under the CreateRemoteThreadApiCall and ReadProcessMemoryApiCall action types.

Detection 3: the credential sweep (T1552.001)

After the browser, stealers go looking for developer and cloud credentials sitting in plaintext. The detection value here is not any single file — it is the burst. Legitimate software reads ~/.aws/credentials. Almost nothing legitimate reads ~/.aws/credentials, ~/.ssh/id_rsa, a FileZilla config, and the DPAPI master key directory inside the same 15-minute window from one process.

let CredPaths = dynamic([@'\Microsoft\Protect\', @'\.aws\', @'\.azure\', @'\.config\gcloud\', @'\.ssh\',
                         @'\.docker\config.json', @'\FileZilla\recentservers.xml', @'\.kube\config']);
DeviceFileEvents
| where Timestamp > ago(7d)
| where FolderPath has_any (CredPaths)
| where InitiatingProcessFileName !in~ ('aws.exe', 'az.exe', 'gcloud.exe', 'kubectl.exe', 'ssh.exe', 'git.exe',
                                       'docker.exe', 'Code.exe', 'terraform.exe')
| summarize CredFamilies = dcount(tostring(split(FolderPath, '\\')[-1])), Touched = make_set(FolderPath, 15)
    by bin(Timestamp, 15m), DeviceName, InitiatingProcessFileName, InitiatingProcessSHA256, InitiatingProcessAccountName
| where CredFamilies >= 3

On macOS and Linux endpoints the same table works with the paths swapped for /Library/Keychains/, ~/.aws/credentials, and ~/.mozilla/firefox/. Correlate hits against archive-then-stage behaviour (T1560.001), which is the beat that immediately follows.

Detection 4: catching the exfil and the replay

Collection and egress inside the same process, minutes apart, is a clean composite signal in Splunk:

index=sysmon
  ( EventCode=11 TargetFilename IN ("*\\Login Data*", "*\\Cookies*", "*\\key4.db", "*\\credentials", "*.zip", "*.7z") )
  OR
  ( EventCode=22 QueryName IN ("api.telegram.org", "cdn.discordapp.com", "gofile.io", "transfer.sh", "temp.sh", "*.pipedream.net") )
| eval phase=if(EventCode==11, "collect", "egress")
| stats dc(phase) as phases, values(phase) as phases_seen, values(TargetFilename) as files,
        values(QueryName) as domains, min(_time) as first, max(_time) as last by host, user, Image, ProcessGuid
| where phases=2 AND (last-first) < 900
| eval window_secs=last-first
| table host, user, Image, phases_seen, files, domains, window_secs

The detection that most teams are missing entirely, though, is the downstream one. Stolen cookies get replayed, and a replayed primary refresh token produces a successful, MFA-satisfied sign-in from infrastructure that has nothing to do with the user. Hunt for one session identifier appearing from more than one autonomous system:

AADSignInEventsBeta
| where Timestamp > ago(7d)
| where ErrorCode == 0
| where isnotempty(SessionId)
| summarize Asns = dcount(AutonomousSystemNumber), AsnList = make_set(AutonomousSystemNumber, 5),
            Countries = make_set(Country, 5), Ips = make_set(IPAddress, 8), Apps = make_set(Application, 8),
            Window = max(Timestamp) - min(Timestamp)
    by SessionId, AccountUpn
| where Asns > 1
| order by Asns desc

If you are on SigninLogs rather than the advanced hunting schema, build the same shape by correlating CorrelationId against AutonomousSystemNumber and filtering to AuthenticationRequirement == 'singleFactorAuthentication' with ResultType == 0 — a successful sign-in that never prompted for MFA is exactly what token replay looks like from the identity provider's side.

Tuning notes before you deploy

Expect these benign sources and allowlist them by hash or signer, not by filename:

  • Backup and DLP agents will read browser profile directories wholesale. Scope them out by process, and alert if the signer ever changes.
  • User State Migration Tool and profile-migration utilities generate exactly the collection burst Detection 1 looks for. Suppress by service account plus a known maintenance window, not permanently.
  • Playwright, Puppeteer, Selenium and Cypress are the dominant false positive for Detection 2. Allowlist on parent process and on build-agent hostnames.
  • Password manager import flows read Login Data legitimately, from a signed binary, with a user interacting. One artifact, not four.
  • Your own EDR's live response will trip Detection 3 during investigations. Tag and exclude the response tooling explicitly.

Rank Detection 1 as high severity and auto-isolate on a match with three or more artifacts from an unsigned binary — the window between collection and exfiltration is measured in seconds, and a same-day analyst review is not fast enough. Detections 2 and 3 are better as medium-severity correlation inputs. Detection 4 should page, because by the time it fires the credential theft has already succeeded and you are now responding to an active session hijack rather than preventing one.

If you are building out coverage systematically, slot these alongside your existing T1555.003 and T1041 rules and validate them with an Atomic Red Team execution against a throwaway browser profile before you trust the false-positive numbers.

Get new detections in your inbox

New ATT&CK coverage plus CISA KEV / CVE detection rules, roughly weekly. No spam, unsubscribe anytime.