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 the freerdp package (dnf install freerdp on Fedora)
  • fzf (dnf install fzf)
  • pass with 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.