Skip to content

Lesson 7.4 · Malware Behaviours· 45 min

Credential Theft and Keylogging

Recognise info-stealers, LSASS access and keyloggers in samples and telemetry, verify the controls that stop them, and scope the accounts exposed.

Objectives

  • Explain why credential theft widens an incident and how it maps to the ATT&CK Credential Access tactic
  • Recognise browser stealers, LSASS and registry-hive access, and keyloggers from their imports, strings and process-access patterns
  • Detect credential access with Sysmon event 10 access masks, Security auditing and file-access auditing, and tune those rules safely
  • Verify Credential Guard, LSA protection and attack surface reduction settings at the configuration level
  • Scope exposed accounts, reset and revoke in the right order, and hunt for reuse of stolen credentials

Persistence Mechanisms ended with a warning: removing every foothold is not the same as evicting an intruder, because the credentials they collected still work. This lesson is about that second half. You will learn to recognise credential theft in a sample before you run it, see it in host telemetry when it happens, check the controls that should have stopped it, and turn a confirmed theft into a list of accounts to reset, sessions to revoke and logons to hunt.

Credential theft is ATT&CK tactic TA0006, Credential Access. This lesson stays on the defender's side of it: every behaviour is described by what an analyst or a sensor observes, not by how it is performed.

Why credential theft changes the incident

A sample that only runs on one host is a host problem. A sample that steals credentials is an identity problem, and identity problems do not stay on one machine. Three consequences follow as soon as you confirm theft:

  • Scope grows to every system the stolen accounts can reach. A domain administrator's password hash taken from a workstation opens servers the malware never touched. Lateral movement with stolen material (pass-the-hash and pass-the-ticket, ATT&CK T1550) looks like normal logons, so the only reliable way to bound it is to know which accounts were exposed.
  • Cleanup must include resets, and resets have an order. Resetting a password while the intruder still has a foothold hands them the new one. Resetting a user's password does not end a browser session whose cookie was stolen.
  • Tokens outlive passwords. Session cookies, refresh tokens and Kerberos tickets stay valid until they expire or are revoked. A stealer that exported cookies may let the attacker skip MFA entirely (T1539, then T1550.004), so revoking sessions is as important as changing passwords.

Many stealers are one-shot programs with no persistence at all: they collect, send and exit. That is why the persistence question from the previous lesson has a partner question in every report: what could this sample have taken, and for whom?

The families you will meet

The families below overlap in practice (many info-stealers also take screenshots and read the clipboard), but each leaves a distinct trail.

FamilyWhat it goes afterStatic cluesTelemetryATT&CK
Browser and application stealersSaved logins, cookies, autofill, wallet and chat-client filesProfile paths and file names as strings; SQLite code or strings; CryptUnprotectData; BCryptDecryptFile reads under browser profiles (Security 4663 with a SACL, EDR file events); a copy of a locked database written to a temp folderT1555.003, T1539, T1552.001
Windows credential storesCredential Manager, vault entriesCredEnumerateW, vaultcli.dll functions such as VaultEnumerateItemsEDR API telemetry; reads under AppData\...\Microsoft\CredentialsT1555.004
LSASS accessHashes, tickets and secrets held by the Local Security AuthorityOpenProcess with MiniDumpWriteDump, ReadProcessMemory, or SeDebugPrivilege handling; lsass as a string, often encodedSysmon 10 with TargetImage lsass.exe; Security 4656; a new dump file (Sysmon 11); ASR block eventsT1003.001
Registry hivesLocal account hashes (SAM), LSA secrets, cached domain logonsRegSaveKeyExW; strings SAM, SECURITY, SYSTEM; a reg.exe command line with a save verbSysmon 1 for reg.exe; Sysmon 11 for hive-sized files in temp paths; shadow-copy accessT1003.002, T1003.004, T1003.005
KeyloggersEverything typed, with the window it was typed intoGetAsyncKeyState or GetKeyboardState in a loop; SetWindowsHookExW with a low-level keyboard hook; RegisterRawInputDevices; GetForegroundWindow with GetWindowTextWEDR hook and API telemetry; a growing log file; periodic uploadsT1056.001
Clipboard and screen captureCopied passwords, wallet addresses, visible documentsOpenClipboard, GetClipboardData, AddClipboardFormatListener; GetDC, BitBlt, GDI+ image encodersImage files appearing in temp folders; EDR screen-capture eventsT1115, T1113

Stealers read like a shopping list

Commodity info-stealers are usually the easiest of these to recognise in strings, once decoded. They carry long lists of target locations: browser profile folders ending in User Data, file names such as Login Data, Cookies, Web Data and Local State, Firefox's logins.json and key4.db, FTP-client configuration files, chat-client session folders and cryptocurrency wallet directories. Next to the paths you often find SQL fragments naming columns of a logins table, JSON keys such as encrypted_key, and a statically linked SQLite library. The import that ties it together is CryptUnprotectData from crypt32.dll, the DPAPI call that unwraps secrets protected for the current user, frequently followed by BCryptDecrypt or an embedded AES routine. Because a running browser keeps its databases locked, stealers often copy them to a temp folder first, and some delegate the copy to a signed utility such as esentutl.exe; the copy is itself a file-creation event you can alert on.

Two cautions keep this honest. First, those strings are exactly what authors encrypt, so a stealer with a clean string listing is ordinary; decode first, as in Scripting String Decryption. Second, many stealers are .NET or script-based, where the evidence lives in metadata and source rather than the IAT; the .NET Malware and Script Malware lessons cover where to look.

LSASS access is a process-access question

lsass.exe holds the authentication material of everyone logged on to the machine. Reading it requires opening the process with rights that allow memory reads, which is why it is a process-access problem more than an API problem. Processes, Threads and DLLs showed that a handle to another process is a prerequisite for touching its memory; for LSASS, the rights on that handle are the signal.

In a sample, look for lsass as a string (or an encoded form of it) near process enumeration, OpenProcess, a privilege adjustment for SeDebugPrivilege, and either MiniDumpWriteDump from dbghelp.dll or dbgcore.dll, or direct memory reads. Many tools avoid the import table entirely through dynamic resolution or API hashing, and some abuse legitimate signed utilities to produce the dump, which moves the evidence from the sample's imports to a command line.

Keyloggers use ordinary input APIs

The input APIs are the hardest to judge alone, because games, accessibility tools, hotkey utilities and remote-support software call them too. Look at the combination and the loop, not the single import:

  • Polling: GetAsyncKeyState or GetKeyState called over the full range of virtual-key codes inside a short sleep loop, usually with MapVirtualKeyW or ToUnicode to turn codes into characters.
  • Hooking: SetWindowsHookExW with the low-level keyboard hook type (WH_KEYBOARD_LL, value 13) and a message loop around GetMessageW. The SetWindowsHookEx page covers the related injection use of the same API.
  • Raw input: RegisterRawInputDevices and GetRawInputData on a hidden window.
  • Context and exfiltration: GetForegroundWindow and GetWindowTextW to label keystrokes by window, a file-append pattern, and a network or mail capability to send the log.

A binary that polls one key to drive a hotkey is normal. A binary that polls every key, records the window title, writes to a hidden file and has an upload capability is a keylogger. The lab below shows how little a single input import tells you on its own.

Detection sources

Sysmon event 10: process access

Sysmon 10 records a process opening another process, with SourceImage, TargetImage, GrantedAccess (the access mask actually granted) and CallTrace (the call stack of modules that made the request). Collecting it for every process is expensive, so most configurations include only sensitive targets such as lsass.exe.

The mask is the heart of the rule. The rights that matter for LSASS are:

RightValueWhy it matters
PROCESS_VM_READ0x0010Read the process's memory
PROCESS_VM_WRITE, PROCESS_VM_OPERATION0x0020, 0x0008Modify memory or change protections
PROCESS_CREATE_THREAD0x0002Run code in the process
PROCESS_DUP_HANDLE0x0040Copy handles out of it
PROCESS_QUERY_LIMITED_INFORMATION0x1000Name, path, exit status: harmless

Masks such as 0x1000 and 0x1400 (query rights only) are background noise from system services and monitoring tools. Masks that combine a query right with 0x0010, such as 0x1010 or 0x1410, are the classic memory-read pattern, and 0x1fffff is full access. Test bits, not literal values: attackers vary the mask to dodge rules that match one string. A CallTrace containing UNKNOWN means part of the call stack ran from memory not backed by a file on disk, which is a strong sign of injected or reflectively loaded code.

Security auditing and EDR

With the Kernel Object audit subcategory enabled, Windows can log Security 4656 handle requests against lsass.exe; recent builds ship a system access control list on the process for this purpose, so check the volume in a pilot before enabling it fleet-wide. Security 4663 records object access to files that carry a SACL, which is how you audit reads of browser profile files: add an auditing entry for Read data on the User Data folders of a pilot group and filter out the browser itself. Security 4624 and 4648 matter later, for hunting reuse. EDR products add what Windows does not log natively: API-level events for memory reads, hook installation and screen capture, and they usually block the LSASS pattern outright; their alerts are detection sources too.

Tuning

Every source here meets legitimate software. Antivirus and EDR agents open LSASS with full rights; backup and sync tools read browser profiles; password managers read the clipboard. Tune with exceptions that pin the full image path and, where your data allows, the signer or the hash from the process's Sysmon 1 event. Never tune on the file name alone: a stealer called MsMpEng.exe running from a temp folder must still fire. Exceptions should expire and be reviewed, because the products they cover update and move.

Tip: An LSASS rule that never fires is more likely broken than quiet. Check that your Sysmon configuration includes event 10 for lsass.exe at all, and replay a known benign access (a monitoring agent's query) to prove the pipeline end to end.

Preventive controls to verify

When a credential-theft alert arrives, one early question is whether the controls that should have limited it were in place. Check them per host, as configuration facts to record in the report:

ControlWhat to checkWhat it changes
Credential Guard(Get-CimInstance -ClassName Win32_DeviceGuard -Namespace root\Microsoft\Windows\DeviceGuard).SecurityServicesRunning includes 1Isolates NTLM hashes and Kerberos secrets from LSASS memory in a virtualisation-based process; LSASS reads yield far less
LSA protectionRunAsPPL under HKLM\SYSTEM\CurrentControlSet\Control\Lsa is 1 (UEFI-locked) or 2; WinInit event 12 reports LSASS started as a protected processNon-protected processes cannot open LSASS for memory reads, even as administrator
ASR ruleRule GUID 9e6c4e1f-7d60-472f-ba1a-a39ef669e4b2 ("Block credential stealing from the Windows local security authority subsystem") appears in Get-MpPreference with action block, not auditBlocks LSASS-read attempts and logs them as Defender events
WDigestUseLogonCredential under ...\SecurityProviders\WDigest absent or 0Prevents cleartext passwords in memory; a change to 1 is itself an alert
MFAEnforced for remote access, email and admin portals, preferably phishing-resistantLimits what a stolen password alone can do
Least privilegeNo domain-admin logons on workstations; unique local admin passwords (LAPS); privileged users in Protected UsersLimits which secrets a compromised workstation holds

None of these stops a browser stealer running as the user, since the user can read their own data. There the controls are MFA, policies that keep credentials out of browser stores where your organisation allows it, and short session lifetimes.

Incident response

When analysis or an alert confirms credential access, work in this order:

  1. Contain first. Isolate the host so collection stops and nothing else leaves; keep it powered on for memory evidence, as in the persistence lesson.
  2. Scope the exposed accounts. List everyone whose material was on the host: interactive and remote-desktop logons (Security 4624, logon types 2, 10 and 11) during the exposure window, cached domain logons, service accounts running on the host, scheduled tasks with stored credentials, and for stealers, every site saved in the affected user's browsers and every application whose session files were read. The exposure window starts at first execution, not at detection.
  3. Remove the foothold before resetting. Otherwise the new passwords are stolen too.
  4. Reset and revoke. Reset passwords for exposed accounts, starting with the most privileged; revoke sessions and refresh tokens at the identity provider; invalidate API keys and application passwords found in scope; review MFA methods registered during the window. If domain controllers or Kerberos secrets were exposed, the domain's own key material needs a planned rotation, which is a project, not a checkbox.
  5. Hunt for reuse. Search authentication logs for the exposed accounts from new hosts, source addresses or countries; explicit-credential logons (4648); unusual Kerberos service-ticket requests; new mailbox rules; and sign-ins that succeeded with a session token but no interactive MFA.

Reporting credential theft

The triage report lists credential theft under capabilities. For responders, add a section that answers what was reachable, not just what the code can do:

FieldExample
Behaviour and ATT&CK IDReads browser logins and cookies, T1555.003, T1539
Evidence levelObserved in lab run / seen in host telemetry / code only
TargetsTwo Chromium-based browsers, one FTP client, wallet folders
ExfiltrationHTTP POST of a ZIP to the C2 described in the network section
Accounts exposedUser demo (all saved sites); no privileged logons in window
Controls presentCredential Guard running; LSA protection on; ASR rule in audit only
ActionsResets, session revocation, hunt queries and their status

Keep "can steal" and "stole" apart. A sample with an LSASS routine that ran on a host with LSA protection enabled and failed is a different finding, with a different scope, from one that wrote a dump file.

Lab: detect credential access in Sysmon-style events

In this lab you generate synthetic events for one workstation, write a Python detection script with an allow-list and access-mask logic, and triage a benign input-polling program's imports. Nothing here opens LSASS, reads a browser store or records keystrokes; the events are JSON lines and every name is invented.

  1. Set up a working folder and a virtual environment. Only pefile is needed, for step 5:

    bash
    mkdir m7t && cd m7t
    python3 -m venv .venv
    .venv/bin/pip install -q pefile
    .venv/bin/python --version
    text
    Python 3.14.7
  2. Generate the events. Sysmon 10 entries show expected processes opening LSASS; Security 4663 entries show reads of a browser profile. The simulated intrusion is a program in a temp folder that does both:

    python
    # make_events.py - writes events.jsonl: synthetic Sysmon/Security-style events for one workstation.
    # No process is opened and no file is read; this script only writes JSON.
    import json
    
    HOST = "WS-DEMO-11"
    SYS32 = r"C:\Windows\System32"
    LSASS = SYS32 + r"\lsass.exe"
    PROFILE = r"C:\Users\demo\AppData\Local\DemoBrowser\User Data"
    BROWSER = r"C:\Program Files\DemoBrowser\browser.exe"
    DEFENDER = r"C:\ProgramData\Microsoft\Windows Defender\Platform\4.18.24090.11-0\MsMpEng.exe"
    EDR = r"C:\Program Files\DemoEDR\agent.exe"
    BACKUP = r"C:\Program Files\DemoBackup\backupsvc.exe"
    ODD = r"C:\Users\demo\AppData\Local\Temp\DemoTool.exe"
    
    def ev(t, eid, **f):
        return {"UtcTime": f"2026-09-29 {t}", "EventID": eid, "Computer": HOST, **f}
    
    def access(t, src, mask, trace="ntdll.dll+9d4c4|KERNELBASE.dll+2c0fe"):
        return ev(t, 10, SourceImage=src, TargetImage=LSASS, GrantedAccess=mask, CallTrace=trace)
    
    def read(t, proc, name):
        # Security 4663 (object access, requires a SACL on the folder); 0x1 = ReadData
        return ev(t, 4663, ProcessName=proc, ObjectName=PROFILE + "\\" + name, AccessMask="0x1")
    
    events = [
        # --- expected access to lsass.exe ---
        access("09:00:01.102", SYS32 + r"\svchost.exe", "0x1000"),
        access("09:00:04.417", SYS32 + r"\wininit.exe", "0x1fffff"),
        access("09:02:10.880", DEFENDER, "0x1410"),
        access("09:02:11.035", EDR, "0x1fffff"),
        access("09:30:45.219", SYS32 + r"\Taskmgr.exe", "0x1400"),
        # --- expected reads of browser profile files ---
        read("09:15:02.334", BROWSER, r"Default\Login Data"),
        read("09:15:02.341", BROWSER, r"Default\Cookies"),
        read("11:00:00.512", BACKUP, r"Default\Login Data"),
        # --- simulated intrusion (names fictional) ---
        ev("10:22:31.006", 1, Image=ODD, ParentImage=r"C:\Windows\explorer.exe", CommandLine=f'"{ODD}"'),
        access("10:22:33.781", ODD, "0x1010", trace="ntdll.dll+9d4c4|KERNELBASE.dll+2c0fe|UNKNOWN(00000000004016a2)"),
        read("10:22:34.120", ODD, r"Default\Login Data"),
        read("10:22:34.126", ODD, r"Default\Cookies"),
        read("10:22:34.131", ODD, "Local State"),
        ev("10:22:34.402", 11, Image=ODD, TargetFilename=r"C:\Users\demo\AppData\Local\Temp\ld.tmp"),
    ]
    
    with open("events.jsonl", "w") as f:
        for e in events:
            f.write(json.dumps(e) + "\n")
    print(f"wrote {len(events)} events")
    bash
    .venv/bin/python make_events.py
    sed -n 10p events.jsonl
    text
    wrote 14 events
    {"UtcTime": "2026-09-29 10:22:33.781", "EventID": 10, "Computer": "WS-DEMO-11", "SourceImage": "C:\\Users\\demo\\AppData\\Local\\Temp\\DemoTool.exe", "TargetImage": "C:\\Windows\\System32\\lsass.exe", "GrantedAccess": "0x1010", "CallTrace": "ntdll.dll+9d4c4|KERNELBASE.dll+2c0fe|UNKNOWN(00000000004016a2)"}
  3. Write the detection script. The LSASS rule decodes the mask into named rights and ignores query-only access before it consults the allow-list. The browser rule ignores the owning browser, applies the allow-list, and groups the remaining reads per process into one alert. Both add the process-creation event when one exists:

    python
    # detect_creds.py - flag credential-access patterns in Sysmon/Security-style JSON lines.
    # Usage: detect_creds.py events.jsonl [--explain] [--no-tuning]
    import json, re, sys
    from collections import defaultdict
    
    USER_WRITABLE = re.compile(r"\\users\\[^\\]+\\|\\programdata\\|\\windows\\temp\\", re.I)
    
    # Process access rights that let the caller read, write or run code in lsass.exe
    RIGHTS = {0x0002: "CREATE_THREAD", 0x0008: "VM_OPERATION", 0x0010: "VM_READ",
              0x0020: "VM_WRITE", 0x0040: "DUP_HANDLE"}
    
    # Tuning: reviewed processes that legitimately open lsass.exe with memory rights.
    ALLOW_LSASS = [re.compile(p, re.I) for p in (
        r"^C:\\Windows\\System32\\(wininit|csrss|services|svchost|wmiprvse)\.exe$",
        r"^C:\\ProgramData\\Microsoft\\Windows Defender\\Platform\\[\d.\-]+\\MsMpEng\.exe$",
        r"^C:\\Program Files\\DemoEDR\\agent\.exe$",
    )]
    
    # Browser credential stores (generic Chromium-style layout) and their ATT&CK IDs
    STORES = {r"\\User Data\\[^\\]+\\Login Data$": ("saved logins", "T1555.003"),
              r"\\User Data\\[^\\]+\\(Network\\)?Cookies$": ("cookies", "T1539"),
              r"\\User Data\\Local State$": ("key file", "T1555.003")}
    BROWSER_OWNERS = [re.compile(r"^C:\\Program Files\\DemoBrowser\\browser\.exe$", re.I)]
    ALLOW_READERS = [re.compile(r"^C:\\Program Files\\DemoBackup\\backupsvc\.exe$", re.I)]
    
    def rights(mask):
        return [n for bit, n in RIGHTS.items() if mask & bit]
    
    def matches(rxs, path):
        return any(r.search(path) for r in rxs)
    
    def main(path, explain, tuned):
        events = [json.loads(l) for l in open(path, encoding="utf-8-sig") if l.strip()]
        starts = {e["Image"].lower(): e for e in events if e["EventID"] == 1}
        alerts, ignored = [], []
        reads = defaultdict(list)
    
        for e in events:
            if e["EventID"] == 10 and e.get("TargetImage", "").lower().endswith("\\lsass.exe"):
                src, mask = e["SourceImage"], int(e["GrantedAccess"], 16)
                got = rights(mask)
                if not got:
                    ignored.append((e, f"lsass access by {src}: mask {e['GrantedAccess']} is query-only"))
                elif tuned and matches(ALLOW_LSASS, src):
                    ignored.append((e, f"lsass access by {src}: allow-listed"))
                else:
                    why = ["+".join(got)]
                    if USER_WRITABLE.search(src):
                        why.append("user-writable path")
                    if "UNKNOWN" in e.get("CallTrace", ""):
                        why.append("CallTrace has UNKNOWN frame")
                    sev = "HIGH" if len(why) > 1 else "MEDIUM"
                    alerts.append((sev, e, "T1003.001", f"{src} opened lsass.exe with {e['GrantedAccess']} ({'; '.join(why)})"))
            elif e["EventID"] == 4663:
                obj, proc = e["ObjectName"], e["ProcessName"]
                hit = next((v for k, v in STORES.items() if re.search(k, obj, re.I)), None)
                if not hit:
                    continue
                if matches(BROWSER_OWNERS, proc):
                    ignored.append((e, f"{hit[0]} read by owning browser"))
                elif tuned and matches(ALLOW_READERS, proc):
                    ignored.append((e, f"{hit[0]} read by allow-listed {proc}"))
                else:
                    reads[(e["Computer"], proc)].append((e, hit))
    
        for (host, proc), hits in reads.items():
            first = hits[0][0]
            ids = sorted({h[1] for _, h in hits})
            kinds = ", ".join(h[0] for _, h in hits)
            sev = "HIGH" if USER_WRITABLE.search(proc) else "MEDIUM"
            alerts.append((sev, first, ",".join(ids), f"{proc} read browser {kinds} ({len(hits)} files)"))
    
        if explain:
            for e, why in ignored:
                print(f"[ignored] {e['UtcTime']} EID {e['EventID']:<4} {why}")
        for sev, e, tid, msg in sorted(alerts, key=lambda a: a[1]["UtcTime"]):
            print(f"[{sev:<6}] {e['UtcTime']} {e['Computer']} EID {e['EventID']:<4} {tid} {msg}")
            img = e.get("SourceImage") or e.get("ProcessName")
            if img and img.lower() in starts:
                p = starts[img.lower()]
                print(f"           started {p['UtcTime']} by {p['ParentImage']}")
        print(f"-- {len(events)} events, {len(alerts)} alerts, {len(ignored)} ignored (tuning {'on' if tuned else 'off'})")
    
    if __name__ == "__main__":
        a = sys.argv[1:]
        main(a[0], "--explain" in a, "--no-tuning" not in a)
  4. Run it with --explain to see what was dismissed and why:

    bash
    .venv/bin/python detect_creds.py events.jsonl --explain
    text
    [ignored] 2026-09-29 09:00:01.102 EID 10   lsass access by C:\Windows\System32\svchost.exe: mask 0x1000 is query-only
    [ignored] 2026-09-29 09:00:04.417 EID 10   lsass access by C:\Windows\System32\wininit.exe: allow-listed
    [ignored] 2026-09-29 09:02:10.880 EID 10   lsass access by C:\ProgramData\Microsoft\Windows Defender\Platform\4.18.24090.11-0\MsMpEng.exe: allow-listed
    [ignored] 2026-09-29 09:02:11.035 EID 10   lsass access by C:\Program Files\DemoEDR\agent.exe: allow-listed
    [ignored] 2026-09-29 09:30:45.219 EID 10   lsass access by C:\Windows\System32\Taskmgr.exe: mask 0x1400 is query-only
    [ignored] 2026-09-29 09:15:02.334 EID 4663 saved logins read by owning browser
    [ignored] 2026-09-29 09:15:02.341 EID 4663 cookies read by owning browser
    [ignored] 2026-09-29 11:00:00.512 EID 4663 saved logins read by allow-listed C:\Program Files\DemoBackup\backupsvc.exe
    [HIGH  ] 2026-09-29 10:22:33.781 WS-DEMO-11 EID 10   T1003.001 C:\Users\demo\AppData\Local\Temp\DemoTool.exe opened lsass.exe with 0x1010 (VM_READ; user-writable path; CallTrace has UNKNOWN frame)
               started 2026-09-29 10:22:31.006 by C:\Windows\explorer.exe
    [HIGH  ] 2026-09-29 10:22:34.120 WS-DEMO-11 EID 4663 T1539,T1555.003 C:\Users\demo\AppData\Local\Temp\DemoTool.exe read browser saved logins, cookies, key file (3 files)
               started 2026-09-29 10:22:31.006 by C:\Windows\explorer.exe
    -- 14 events, 2 alerts, 8 ignored (tuning on)

    Both alerts are the intrusion, and they share a process started three seconds earlier. Task Manager is a useful benign case: it is not on the allow-list, yet it is correctly ignored because 0x1400 carries no memory rights. The mask check, not the allow-list, does most of the work.

  5. See what the tuning is doing. Run again with the allow-lists disabled:

    bash
    .venv/bin/python detect_creds.py events.jsonl --no-tuning
    text
    [MEDIUM] 2026-09-29 09:00:04.417 WS-DEMO-11 EID 10   T1003.001 C:\Windows\System32\wininit.exe opened lsass.exe with 0x1fffff (CREATE_THREAD+VM_OPERATION+VM_READ+VM_WRITE+DUP_HANDLE)
    [HIGH  ] 2026-09-29 09:02:10.880 WS-DEMO-11 EID 10   T1003.001 C:\ProgramData\Microsoft\Windows Defender\Platform\4.18.24090.11-0\MsMpEng.exe opened lsass.exe with 0x1410 (VM_READ; user-writable path)
    [MEDIUM] 2026-09-29 09:02:11.035 WS-DEMO-11 EID 10   T1003.001 C:\Program Files\DemoEDR\agent.exe opened lsass.exe with 0x1fffff (CREATE_THREAD+VM_OPERATION+VM_READ+VM_WRITE+DUP_HANDLE)
    [HIGH  ] 2026-09-29 10:22:33.781 WS-DEMO-11 EID 10   T1003.001 C:\Users\demo\AppData\Local\Temp\DemoTool.exe opened lsass.exe with 0x1010 (VM_READ; user-writable path; CallTrace has UNKNOWN frame)
               started 2026-09-29 10:22:31.006 by C:\Windows\explorer.exe
    [HIGH  ] 2026-09-29 10:22:34.120 WS-DEMO-11 EID 4663 T1539,T1555.003 C:\Users\demo\AppData\Local\Temp\DemoTool.exe read browser saved logins, cookies, key file (3 files)
               started 2026-09-29 10:22:31.006 by C:\Windows\explorer.exe
    [MEDIUM] 2026-09-29 11:00:00.512 WS-DEMO-11 EID 4663 T1555.003 C:\Program Files\DemoBackup\backupsvc.exe read browser saved logins (1 files)
    -- 14 events, 6 alerts, 4 ignored (tuning off)

    Tuning note: Defender fires as HIGH because its platform folder sits under ProgramData, which the rule treats as user-writable. The allow-list entry fixes that with a full-path pattern that includes the versioned platform folder, but it is still only a path. In production, join each Sysmon 10 event to the source's Sysmon 1 event through SourceProcessGUID and check the signer, and keep the UNKNOWN call-trace check active even for allow-listed images, since injected code inside a trusted process shows up exactly that way.

  6. Triage a benign input-polling program. Build a program that only checks whether the space bar is held, ten times, and prints the answer. It stores and sends nothing:

    c
    /* keypoll.c - benign input-polling demo: reports whether the space bar is held,
       ten times, then exits. It records nothing, stores nothing and sends nothing. */
    #include <windows.h>
    #include <stdio.h>
    
    int main(void) {
        for (int i = 0; i < 10; i++) {
            SHORT s = GetAsyncKeyState(VK_SPACE);
            printf("poll %d: space %s\n", i, (s & 0x8000) ? "down" : "up");
            Sleep(200);
        }
        return 0;
    }
    bash
    x86_64-w64-mingw32-gcc -O2 -o keypoll.exe keypoll.c

    Extend capimports.py from Reading Imports with an input and screen bucket, let the registry pattern match RegSaveKeyEx, and print the DLL count. Only the changed lines are shown; the new bucket goes after resources:

    python
        "registry":     r"^Reg(Open|Create|Set|Query|Delete|Enum|Save)",
        "input/screen": r"^(GetAsyncKeyState|GetKeyState|GetKeyboardState|SetWindowsHookEx|CallNextHookEx|GetRawInputData|RegisterRawInputDevices|GetClipboardData|OpenClipboard|AddClipboardFormatListener|BitBlt|GetDC|GetForegroundWindow|GetWindowText)",
    print(f"{sys.argv[1]}: {total} imports from {len(pe.DIRECTORY_ENTRY_IMPORT)} DLLs")
    bash
    .venv/bin/python capimports.py keypoll.exe
    text
    keypoll.exe: 42 imports from 10 DLLs
      memory        VirtualProtect (kernel32.dll)
      input/screen  GetAsyncKeyState (user32.dll)

    The input bucket lights up on one import from user32.dll, and the only other hit is the runtime's VirtualProtect you met in Reading Imports. There is no hook, no window-title API, no file-write or network bucket. Written as a hypothesis: "polls keyboard state; no evidence of logging, window context or exfiltration". That is how to report a single input import. The same line in a stealer's import table, next to GetForegroundWindow, file appends and WinINet, would read very differently.

Questions to answer: Which access-mask values from your own Sysmon data fall into the query-only group, and which processes would need allow-list entries? The script ignores the Sysmon 11 event that wrote ld.tmp; what rule would use it, and why does a copy of a browser database in a temp folder matter? If WS-DEMO-11 had Credential Guard and LSA protection enabled, how would that change the accounts-exposed row of your report, and how would you confirm it from the host? Which imports would have to join GetAsyncKeyState before you called keypoll.exe a keylogger?

Key takeaways

  • Credential theft turns a host incident into an identity incident: scope follows the accounts exposed, and tokens and cookies must be revoked as well as passwords reset.
  • Stealers show target paths, store file names and DPAPI calls once strings are decoded; LSASS access shows as process handles with memory rights; keyloggers show as input APIs in combination with window context, logging and exfiltration.
  • Detect LSASS access with Sysmon 10 by testing access-mask bits, not literal values, and watch CallTrace for UNKNOWN frames; audit browser-store reads with SACLs and EDR file events.
  • Tune with full-path and signer-based exceptions, never file names, and let the mask logic dismiss query-only access before the allow-list does.
  • Verify Credential Guard, LSA protection, the LSASS ASR rule, WDigest, MFA and least privilege as recorded configuration facts for each affected host.
  • Contain, scope exposed accounts, remove the foothold, then reset and revoke and hunt for reuse. The next lessons in this module, Understanding Process Injection and Hooking and User-Mode Rootkits, look at how the code behind these behaviours hides inside other processes.