RDP forensics in the registry: client history, Terminal Services and NLA
10 min read
RDP forensics in the registry is a two-direction problem, and analysts who treat it as one direction miss half the picture. There is outbound — which remote hosts did this machine connect TO, recorded per-user under the Terminal Server Client key, and there is inbound — does this machine accept RDP at all, and on what security terms, recorded system-wide. The first is a lateral-movement artifact. The second is an exposure and hardening question. Three RegRipper-style plugins cover the ground: ts_client reads the outbound client history from NTUSER.DAT, termserv reads the inbound listener config from SYSTEM, and rdp_security reads the NLA and security-layer settings, also from SYSTEM. This post walks all three and ties them together into a working interpretation.
ts_client: where this machine connected TO
The outbound side is the one that wins cases. When a user runs mstsc.exe and connects to a remote host, the RDP client caches that destination in the user's NTUSER.DAT under:
HKCU\Software\Microsoft\Terminal Server Client\Servers
Each remote host gets its own subkey, named for the host as the user entered it — a hostname, an FQDN, or a bare IP. So the subkey names under Servers are themselves the artifact: every distinct name is a remote destination this user's RDP client connected to from this machine. The ts_client plugin enumerates those subkeys directly, one row per server.
Inside each server subkey, the value that matters is UsernameHint. This is the login name the client offered or saved for that destination — CORP\administrator, .\localadmin, a bare username, whatever was used. The plugin pulls it (matched case-insensitively) and pairs it with the server name, producing a table with exactly two columns:
| RDP server | Username hint |
|---|
That is the whole shape of the artifact as this plugin reads it: which host, and as whom. Two columns, but they answer the two questions that matter most in a pivot investigation.
There is a sibling key worth naming, because the task framing calls it out and because RegRipper's own tsclient.pl reads it:
HKCU\Software\Microsoft\Terminal Server Client\Default
Default holds the MRU list — MRU0, MRU1, and so on — of the most recently typed destinations in the mstsc connection bar. It overlaps heavily with the Servers subkeys but is ordered by recency and can contain entries that never produced a full Servers subkey (and vice versa). The plugin on this site keys off Servers and UsernameHint; if you want the MRU ordering as well, read Default directly from the hive — its values are plain REG_SZ and need no decoding. I'd treat Servers as the authoritative "connected to" list and Default as the recency overlay.
Why this is a lateral-movement artifact
Think about what populates Servers. An interactive user, at the keyboard, opening Remote Desktop and typing a destination. That is exactly the behaviour you are hunting in a hands-on-keyboard intrusion: an attacker who has compromised one workstation and is using its RDP client to reach the next host, and the one after that. The Servers key on a compromised box is, in effect, a confession list of where the operator went next.
The UsernameHint sharpens it. A workstation whose Servers entries all carry the user's own domain account is one story. A workstation with a Servers subkey for a domain controller or a file server, with a UsernameHint of CORP\administrator or a local admin account, is a different and much louder story — that is privileged lateral movement, and the hint tells you which credential was in play. Odd destinations (a server the user has no business touching) plus a privileged hint is the pattern to flag.
One honest caveat on timing. The server subkey carries a LastWrite timestamp, and that is your best single-hive signal for when the most recent connection to that host happened — but it is the last write, not a per-connection log. The registry does not keep a connection count or a per-event history here the way UserAssist keeps a run count. For a real timeline you correlate against [Security event log 4624 type 10 / TerminalServices-RDPClient operational logs], VSS copies of the hive, and the destination host's own logs. Treat the Servers list as "these are the hosts, and here is roughly when each was last touched," not as a timestamped session log.
It is also worth saying what ts_client does not prove. It is a client-side artifact: it shows where mstsc pointed. It does not by itself prove the authentication succeeded on the far end — a failed or refused connection can still leave a destination cached. As with the PuTTY and WinSCP saved sessions artifact, the cleanest claim comes from pairing the client-side record with a server-side log entry. The difference: PuTTY's SshHostKeys gives you a connection proof inside the same hive, whereas the Terminal Server Client key does not carry an equivalent "handshake completed" marker, so the corroboration has to come from elsewhere.
termserv: does this machine accept inbound RDP?
Flip to the receiving side. Whether a machine is even listening for RDP is a SYSTEM-hive question, read from the current control set. The termserv plugin reads two things.
First, from ControlSet00X\Control\Terminal Server:
fDenyTSConnections
This is the master inbound switch. fDenyTSConnections = 1 means RDP is denied — the box does not accept inbound Remote Desktop. fDenyTSConnections = 0 means it does. So the value is inverted from what you might expect: a zero is the "RDP is on" finding. This single DWORD is the first thing to check when the question is "could this machine have been logged into over RDP." On a workstation that has no business being an RDP target, a 0 here is itself a hardening regression worth flagging — and if it flipped from 1 to 0, that is the kind of change an attacker makes to open a door.
Second, from the listener station config at ControlSet00X\Control\Terminal Server\WinStations\RDP-Tcp:
PortNumber
The default RDP port is 3389. A PortNumber that is anything other than 3389 is a finding in both directions — it can be a legitimate hardening move (port obscurity), but it is also a classic attacker tactic to move the listener to a non-standard port to dodge detection and firewall rules. Either way, note it, because it changes where you'd expect to see the traffic and which firewall rule governs it.
The termserv plugin surfaces exactly these two as a Field/Value table:
| Field | Value |
|---|---|
| fDenyTSConnections | … |
| RDP PortNumber | … |
Read together: fDenyTSConnections=0 plus a PortNumber tells you the box was an RDP target and on which port. That is the exposure baseline. To know whether that exposure was actually reachable from the network, cross-reference the firewall profile state (the firewall_profiles plugin reads EnableFirewall and DefaultInboundAction per profile from the same hive) — RDP enabled in Terminal Server but blocked by an enabled firewall profile is a meaningfully smaller hole than RDP enabled with the firewall off.
rdp_security: NLA and the security layer
If termserv answers "is the door open," rdp_security answers "how strong is the lock." It reads three values from the same RDP-Tcp listener key, ControlSet00X\Control\Terminal Server\WinStations\RDP-Tcp:
| Field | Value |
|---|---|
| UserAuthentication (NLA) | … |
| SecurityLayer | … |
| MinEncryptionLevel | … |
UserAuthentication is Network Level Authentication. 1 means NLA is required: the client must authenticate before a session is established, which blunts a whole class of pre-auth attacks and is the modern, hardened state. 0 means NLA is off — the connection reaches the logon screen without prior authentication, which is the weaker, legacy posture and the one that has historically been exploitable (the BlueKeep family of pre-auth RDP bugs all mattered far more with NLA off). On an exposed box, UserAuthentication=0 is the finding to escalate.
SecurityLayer selects how the session is secured: roughly, 0 is native RDP security, 1 negotiates, 2 requires TLS/SSL. Lower is weaker; 2 is what you want to see. MinEncryptionLevel is the minimum encryption strength the listener will accept, where higher values mean stronger required encryption. I'd treat both as supporting detail to the NLA finding rather than headline items on their own — the exact numeric meanings are version-dependent, so confirm against [Microsoft's Terminal Services documentation] for the build you're examining rather than asserting a fixed mapping. The plugin reports the raw values; interpretation is the analyst's job.
The reason rdp_security is a separate plugin from termserv is that the two answer different questions and you want them apart in your notes. termserv is binary exposure (open/closed, which port). rdp_security is the quality of that exposure (NLA, security layer, encryption). A box can be a deliberate, well-hardened RDP jump host — fDenyTSConnections=0, NLA required, TLS — and that is a normal finding. The alarming combination is open and weak: inbound RDP enabled, NLA off, on a system that should not be a target. That pairing is what turns "RDP is on" into "RDP is on and was easy to attack."
Putting the three together
The analyst's read, in order:
- Outbound, from each user's NTUSER.DAT (
ts_client). Enumerate theServerssubkeys: every host this user RDP'd to, with theUsernameHintcredential and the subkey LastWrite as a rough "last connected" time. This is your lateral-movement map. Privileged hints against sensitive destinations are the lines to chase. Pair with theDefaultMRU for recency ordering and with server-side 4624 type-10 logons for proof. - Inbound exposure, from SYSTEM (
termserv). IsfDenyTSConnections=0? On whatPortNumber? Is the firewall actually letting it through? This tells you whether the machine could have received an RDP pivot — i.e. whether it is a destination in someone else'sts_clienthistory. - Inbound quality, from SYSTEM (
rdp_security). NLA on or off, security layer, encryption floor. This grades the difficulty of attacking the exposure from step 2.
The narrative emerges from chaining hosts. Workstation A's ts_client shows a connection to Server B with an admin hint; Server B's termserv confirms it was accepting RDP and its rdp_security shows NLA was off; Server B's own ts_client then shows the next hop. That chain — outbound history on one box matching inbound exposure on the next — is the registry-side skeleton of an RDP lateral-movement path. For the broader catalogue of artifacts that flesh out that skeleton, see registry artifacts of lateral movement.
Tools
- RegRipper (source). The
tsclientplugin reads the outboundTerminal Server Client\ServersandDefaultkeys from NTUSER.DAT;termservplugins read the inbound Terminal Server config from SYSTEM. RegRipper is the reference implementation for what a full extraction of these keys looks like — read the plugin source for the exact value coverage. - Eric Zimmerman's Registry Explorer / RECmd. Browse
Software\Microsoft\Terminal Server Clientin a user hive andControlSet001\Control\Terminal Serverin SYSTEM directly. - You can analyze a hive in your browser here — the
ts_client,termserv, andrdp_securityplugins render the server/username-hint table, the inbound switch and port, and the NLA/security-layer values respectively, with nothing uploaded.
For the full list of what these RegRipper plugins extract, see the RegRipper plugins reference.
RDP leaves a clean two-sided trace in the registry: where a user went (per-user, ts_client), and whether and how a machine could be reached (system-wide, termserv and rdp_security). Keep the directions separate in your notes, flag privileged username hints and any non-default port or NLA-off listener, and chain the outbound history of one host to the inbound exposure of the next. Done across a set of hives, that chaining is how you draw the RDP lateral-movement path out of the registry — and how you tell a hardened jump host from a door someone left open.