Running a Windows-heavy SOC lab from a Linux hypervisor means a lot of RDP sessions.
The naive approach — typing credentials every time — gets old fast, and pasting passwords
into terminal arguments leaves them visible in ps output and shell history. Here’s the
script that replaced all of that.
Starting point: one host, one script
The first version was a single hardcoded connection to the forensics VM:
PASS=$(pass soc-lab/windows-analyst)
ARGS=(
/v:192.168.10.50
/u:analyst
"/p:$PASS"
/size:3840x2160
/scale:180
/scale-desktop:180
/scale-device:180
/cert:ignore
/clipboard
/log-level:ERROR
)
printf '%s\n' "${ARGS[@]}" | xfreerdp /args-from:stdin
Two things worth noting here.
pass instead of hardcoding. pass is a GPG-encrypted password store. pass show <key> decrypts and prints the password to stdout — but wrapped in $(), that output is captured into the variable rather than printed to the terminal. Bash records the command in history, not the output, so the password value never appears in ~/.bash_history.
/args-from:stdin instead of /p: on the command line. If you pass the password directly as xfreerdp /v:... /u:... /p:hunter2, anyone running ps aux on the box during that window sees the password in plain text. With /args-from:stdin, the only argument to xfreerdp is the flag itself — all options, including the password, arrive via stdin and stay out of the process list.
The catch: /args-from:stdin requires it be the only argument. Everything else goes into the pipe, one option per line.
The lab grows
One forensics VM becomes eight — workstations, a domain controller, multiple user accounts. The single-host script multiplies into several files with overlapping logic. Time for a picker.
#!/usr/bin/env bash
# rdp.sh — FZF picker to RDP into any Windows lab VM
set -euo pipefail
# VM definitions: "display name|ip|user|pass-key"
HOSTS=(
"win-forensic (192.168.10.50) — analyst account|192.168.10.50|analyst|soc-lab/windows-analyst"
"win-user01 (192.168.10.30) — labadmin|192.168.10.30|labadmin|soc-lab/windows-workstation"
"win-user01 (192.168.10.30) — mscott (domain)|192.168.10.30|LAB\mscott|soc-lab/domain-users/mscott"
"win-user01 (192.168.10.30) — dschrute (domain)|192.168.10.30|LAB\dschrute|soc-lab/domain-users/dschrute"
"win-user02 (192.168.10.31) — labadmin|192.168.10.31|labadmin|soc-lab/windows-workstation"
"win-user02 (192.168.10.31) — mscott (domain)|192.168.10.31|LAB\mscott|soc-lab/domain-users/mscott"
"win-user02 (192.168.10.31) — dschrute (domain)|192.168.10.31|LAB\dschrute|soc-lab/domain-users/dschrute"
"dc01 (192.168.10.20) — Administrator|192.168.10.20|Administrator|soc-lab/dc01-admin"
)
# Pick a host
SELECTED=$(printf '%s\n' "${HOSTS[@]}" | cut -d'|' -f1 | fzf --prompt="RDP > " --height=40% --border) || exit 0
# Look up the full entry
ENTRY=$(printf '%s\n' "${HOSTS[@]}" | grep "^${SELECTED}|")
IP=$(echo "$ENTRY" | cut -d'|' -f2)
USER=$(echo "$ENTRY" | cut -d'|' -f3)
PASSKEY=$(echo "$ENTRY" | cut -d'|' -f4)
PASS=$(pass show "$PASSKEY")
# Split DOMAIN\user into separate /d: and /u: args
ARGS=("/v:$IP")
if [[ "$USER" == *\\* ]]; then
DOMAIN="${USER%%\\*}"
USERNAME="${USER##*\\}"
ARGS+=("/d:$DOMAIN" "/u:$USERNAME")
else
ARGS+=("/u:$USER")
fi
ARGS+=(
"/p:$PASS"
/sec:nla
/size:3840x2160
/scale:180
/scale-desktop:180
/scale-device:180
/cert:ignore
/clipboard
/log-level:ERROR
)
echo "Connecting to $SELECTED ..."
printf '%s\n' "${ARGS[@]}" | xfreerdp /args-from:stdin
The HOSTS array is a pipe-delimited record: display name, IP, username, and the pass key path.
fzf sees only the display name column — the rest is used to build the connection after selection.
Domain auth and the Kerberos detour
The first attempt at domain user logins used the obvious form:
/u:LAB\mscott
This produced a security negotiation failure. xfreerdp’s default security mode tries
/sec:tls first, which Windows machines reject after domain join — they require NLA.
Adding /sec:tls explicitly makes the error more legible; the fix is /sec:nla.
There’s a second issue: with NLA, xfreerdp attempts Kerberos authentication by default
for domain accounts. The Linux host has no /etc/krb5.conf for the LAB realm, so Kerberos
fails before NTLM gets a chance. Splitting DOMAIN\user into separate /d: and /u: args
sidesteps this — xfreerdp falls back to NTLM directly rather than trying to negotiate
Kerberos through a KDC it can’t reach.
NTLM has known weaknesses — relay attacks in particular — and wouldn’t be acceptable on a production network. Here it’s an accepted risk: the lab runs on a private host-only network, and leaving NTLM in play is intentional. NTLM relay is on the list of attacks to run against this environment.
ARGS=("/v:$IP")
if [[ "$USER" == *\\* ]]; then
DOMAIN="${USER%%\\*}"
USERNAME="${USER##*\\}"
ARGS+=("/d:$DOMAIN" "/u:$USERNAME")
else
ARGS+=("/u:$USER")
fi
HiDPI scaling
FreeRDP’s /scale flag only accepts three values: 100, 140, or 180. On a 4K monitor,
/size:3840x2160 with /scale:180 plus /scale-desktop:180 and /scale-device:180 gives
a usable result. The three scale flags apply to different rendering layers; setting all of them
consistently prevents text and UI elements from rendering at mismatched sizes.
/cert:ignore suppresses the self-signed certificate prompt — lab VMs use self-signed
RDP certs, which is expected. It also means xfreerdp won’t detect a machine-in-the-middle
swapping in a different cert, so this is another accepted risk on a private network.
/log-level:ERROR keeps xfreerdp from flooding the terminal with connection chatter.
Prerequisites
xfreerdp— part of thefreerdppackage (dnf install freerdpon Fedora)fzf(dnf install fzf)passwith credentials populated (pass insert soc-lab/windows-analyst)
Add new VMs to the HOSTS array as they come online. The script lives in the lab repo alongside the other provisioning scripts — one place to update when the lab topology changes.