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.
| Family | What it goes after | Static clues | Telemetry | ATT&CK |
|---|---|---|---|---|
| Browser and application stealers | Saved logins, cookies, autofill, wallet and chat-client files | Profile paths and file names as strings; SQLite code or strings; CryptUnprotectData; BCryptDecrypt | File reads under browser profiles (Security 4663 with a SACL, EDR file events); a copy of a locked database written to a temp folder | T1555.003, T1539, T1552.001 |
| Windows credential stores | Credential Manager, vault entries | CredEnumerateW, vaultcli.dll functions such as VaultEnumerateItems | EDR API telemetry; reads under AppData\...\Microsoft\Credentials | T1555.004 |
| LSASS access | Hashes, tickets and secrets held by the Local Security Authority | OpenProcess with MiniDumpWriteDump, ReadProcessMemory, or SeDebugPrivilege handling; lsass as a string, often encoded | Sysmon 10 with TargetImage lsass.exe; Security 4656; a new dump file (Sysmon 11); ASR block events | T1003.001 |
| Registry hives | Local account hashes (SAM), LSA secrets, cached domain logons | RegSaveKeyExW; strings SAM, SECURITY, SYSTEM; a reg.exe command line with a save verb | Sysmon 1 for reg.exe; Sysmon 11 for hive-sized files in temp paths; shadow-copy access | T1003.002, T1003.004, T1003.005 |
| Keyloggers | Everything typed, with the window it was typed into | GetAsyncKeyState or GetKeyboardState in a loop; SetWindowsHookExW with a low-level keyboard hook; RegisterRawInputDevices; GetForegroundWindow with GetWindowTextW | EDR hook and API telemetry; a growing log file; periodic uploads | T1056.001 |
| Clipboard and screen capture | Copied passwords, wallet addresses, visible documents | OpenClipboard, GetClipboardData, AddClipboardFormatListener; GetDC, BitBlt, GDI+ image encoders | Image files appearing in temp folders; EDR screen-capture events | T1115, 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:
GetAsyncKeyStateorGetKeyStatecalled over the full range of virtual-key codes inside a short sleep loop, usually withMapVirtualKeyWorToUnicodeto turn codes into characters. - Hooking:
SetWindowsHookExWwith the low-level keyboard hook type (WH_KEYBOARD_LL, value 13) and a message loop aroundGetMessageW. The SetWindowsHookEx page covers the related injection use of the same API. - Raw input:
RegisterRawInputDevicesandGetRawInputDataon a hidden window. - Context and exfiltration:
GetForegroundWindowandGetWindowTextWto 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:
| Right | Value | Why it matters |
|---|---|---|
PROCESS_VM_READ | 0x0010 | Read the process's memory |
PROCESS_VM_WRITE, PROCESS_VM_OPERATION | 0x0020, 0x0008 | Modify memory or change protections |
PROCESS_CREATE_THREAD | 0x0002 | Run code in the process |
PROCESS_DUP_HANDLE | 0x0040 | Copy handles out of it |
PROCESS_QUERY_LIMITED_INFORMATION | 0x1000 | Name, 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.exeat 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:
| Control | What to check | What it changes |
|---|---|---|
| Credential Guard | (Get-CimInstance -ClassName Win32_DeviceGuard -Namespace root\Microsoft\Windows\DeviceGuard).SecurityServicesRunning includes 1 | Isolates NTLM hashes and Kerberos secrets from LSASS memory in a virtualisation-based process; LSASS reads yield far less |
| LSA protection | RunAsPPL under HKLM\SYSTEM\CurrentControlSet\Control\Lsa is 1 (UEFI-locked) or 2; WinInit event 12 reports LSASS started as a protected process | Non-protected processes cannot open LSASS for memory reads, even as administrator |
| ASR rule | Rule GUID 9e6c4e1f-7d60-472f-ba1a-a39ef669e4b2 ("Block credential stealing from the Windows local security authority subsystem") appears in Get-MpPreference with action block, not audit | Blocks LSASS-read attempts and logs them as Defender events |
| WDigest | UseLogonCredential under ...\SecurityProviders\WDigest absent or 0 | Prevents cleartext passwords in memory; a change to 1 is itself an alert |
| MFA | Enforced for remote access, email and admin portals, preferably phishing-resistant | Limits what a stolen password alone can do |
| Least privilege | No domain-admin logons on workstations; unique local admin passwords (LAPS); privileged users in Protected Users | Limits 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:
- Contain first. Isolate the host so collection stops and nothing else leaves; keep it powered on for memory evidence, as in the persistence lesson.
- 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.
- Remove the foothold before resetting. Otherwise the new passwords are stolen too.
- 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.
- 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:
| Field | Example |
|---|---|
| Behaviour and ATT&CK ID | Reads browser logins and cookies, T1555.003, T1539 |
| Evidence level | Observed in lab run / seen in host telemetry / code only |
| Targets | Two Chromium-based browsers, one FTP client, wallet folders |
| Exfiltration | HTTP POST of a ZIP to the C2 described in the network section |
| Accounts exposed | User demo (all saved sites); no privileged logons in window |
| Controls present | Credential Guard running; LSA protection on; ASR rule in audit only |
| Actions | Resets, 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.
-
Set up a working folder and a virtual environment. Only
pefileis needed, for step 5:bash mkdir m7t && cd m7t python3 -m venv .venv .venv/bin/pip install -q pefile .venv/bin/python --versiontext Python 3.14.7 -
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.jsonltext 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)"} -
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) -
Run it with
--explainto see what was dismissed and why:bash .venv/bin/python detect_creds.py events.jsonl --explaintext [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
0x1400carries no memory rights. The mask check, not the allow-list, does most of the work. -
See what the tuning is doing. Run again with the allow-lists disabled:
bash .venv/bin/python detect_creds.py events.jsonl --no-tuningtext [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 throughSourceProcessGUIDand check the signer, and keep theUNKNOWNcall-trace check active even for allow-listed images, since injected code inside a trusted process shows up exactly that way. -
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.cExtend
capimports.pyfrom Reading Imports with an input and screen bucket, let theregistrypattern matchRegSaveKeyEx, and print the DLL count. Only the changed lines are shown; the new bucket goes afterresources: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.exetext 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'sVirtualProtectyou 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 toGetForegroundWindow, 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
CallTraceforUNKNOWNframes; 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.