[Draft] Fix RDP command execution reliability - #1329
Conversation
There was a problem hiding this comment.
Pull request overview
This PR rewrites NetExec’s RDP command execution flow (introduced in #676) to improve reliability by isolating per-connection state, fixing protocol/auth negotiation side effects from NLA probing, and reworking keyboard/clipboard interaction to reduce races and improve Unicode/special-character handling.
Changes:
- Introduces per-connection cloning/deepcopy of RDP IO settings, target, and credentials to avoid shared mutable state across attempts.
- Reworks command execution to use focus initialization + Win+R, clipboard-driven launch/execution, and robust output capture via unique clipboard markers and UTF-16LE/base64 encoding.
- Updates RDP CLI help text and expands e2e RDP execution coverage (cmd, PowerShell, special characters, and
--no-output).
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| tests/e2e_commands.txt | Adds additional RDP exec scenarios (special chars, PS output, no-output) to exercise the new execution path. |
| nxc/protocols/rdp/proto_args.py | Updates help text to reflect the new meaning/usage of execution and clipboard delays. |
| nxc/protocols/rdp.py | Implements the rewritten RDP execution path: connection isolation, auth/protocol fixes, focus/keyboard changes, and clipboard output capture. |
Comments suppressed due to low confidence (1)
nxc/protocols/rdp.py:593
- If the initial RDP connect fails, the method returns early without terminating the partially-initialized connection. Depending on where the exception is raised inside
connect_rdp(), this can leak sockets/tasks and leave aardwolf background state running.
except Exception as e:
self.logger.debug(f"Error connecting to RDP: {e!s}")
return None
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
afeb4b3 to
3e50634
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.
Comments suppressed due to low confidence (1)
nxc/protocols/rdp.py:602
- On clipboard init timeout, execute_shell returns an empty string. That’s indistinguishable from a successful command that produced no output, and it also triggers the "Command execution completed" path in execute(). Prefer returning None for this failure case.
await self._wait_for_event({"CLIPBOARD_READY"}, self.args.clipboard_delay)
except asyncio.TimeoutError:
self.logger.fail("Clipboard cannot be initialized, no output can be retrieved")
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.
Comments suppressed due to low confidence (1)
nxc/protocols/rdp.py:603
- On CLIPBOARD_READY timeout,
execute_shellreturns an empty string. An empty string is also a valid successful output (command produced no output), so callers can't distinguish failure from success. ReturnNoneconsistently for failures.
except asyncio.TimeoutError:
self.logger.fail("Clipboard cannot be initialized, no output can be retrieved")
return ""
| self.logger.debug(f"Error adding host {self.host} into db: {e!s}") | ||
|
|
||
| def _create_rdp_connection(self, credentials): | ||
| iosettings = self.iosettings.clone_for_connection() |
There was a problem hiding this comment.
Updated description to say we're blocking on this being updated. Will update dependency later.
|
Thanks for the PR! Let us know when the aardwolf got merged and this is review for reviewing :) |
|
I seem to be in an infinite modify and review loop with copilot rn 😆. Labeled as draft for now |
Yeah AI reviews (especially on NetExec) seem to be pretty hit and miss so far. |
07561f5 to
a0d55b7
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.
Comments suppressed due to low confidence (1)
nxc/protocols/rdp.py:121
- This PR introduces hard dependencies on newer aardwolf APIs (e.g.,
RDPIOSettings.clone_for_connection()here andconnect(auth_only=...)later), but the project dependency spec currently allowsaardwolf>=0.2.8. If a user installs an older compatible version, this will fail at runtime with a non-actionableAttributeError. Consider adding an explicit version/API guard with a clear upgrade message (or pin/bump aardwolf in packaging).
iosettings = self.iosettings.clone_for_connection()
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.
Comments suppressed due to low confidence (1)
nxc/protocols/rdp.py:124
- _create_rdp_connection now calls iosettings.clone_for_connection(); if an older aardwolf version is installed this will raise AttributeError and break all RDP usage. Add a backward-compatible fallback clone (or raise a clear upgrade error) so the runtime failure mode is deterministic.
def _create_rdp_connection(self, credentials, supported_protocols=None):
iosettings = self.iosettings.clone_for_connection()
# Explicit protocols are used by discovery and screenshot fallbacks.
# Authenticated connections leave this unset so aardwolf selects the
# X224 flags appropriate for the credential type.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.
Comments suppressed due to low confidence (2)
nxc/protocols/rdp.py:126
- _create_rdp_connection calls RDPIOSettings.clone_for_connection() unconditionally. On environments with an older (but still installable) aardwolf, this raises AttributeError and breaks all RDP usage. Add a compatibility shim (fallback clone) or raise a clear upgrade error so the failure mode is actionable.
def _create_rdp_connection(self, credentials, supported_protocols=None):
iosettings = self.iosettings.clone_for_connection()
# Explicit protocols are used by discovery and screenshot fallbacks.
# Authenticated connections leave this unset so aardwolf selects the
# X224 flags appropriate for the credential type.
iosettings.supported_protocols = supported_protocols
return RDPConnection(
nxc/protocols/rdp.py:212
- connect_rdp passes auth_only as a keyword to aardwolf's connect(). If a user runs with an aardwolf version that doesn't yet support this keyword, it will raise TypeError even when auth_only=False (breaking screenshot/login flows). Add a small fallback path that calls connect() without the keyword when auth_only is False, and raise a clear upgrade error when auth_only is True.
async def connect_rdp(self, auth_only=False):
"""Connect to the RDP server. Does NOT clean up on exit.
When auth_only is True, performs only CredSSP/NLA authentication
without establishing a full RDP session. This verifies credentials
without creating a disconnected session on single-session hosts.
"""
_, err = await asyncio.wait_for(self.conn.connect(auth_only=auth_only), timeout=self.args.rdp_timeout)
if err is not None:
raise err
| self.nthash = nthash | ||
|
|
||
| kerb_pass = next(s for s in [nthash, password, aesKey] if s) if not all(s == "" for s in [nthash, password, aesKey]) else "" | ||
|
|
||
| self.hostname + "." + self.domain | ||
| password = password if password else nthash | ||
|
|
There was a problem hiding this comment.
In main. Will leave this comment open since it's not part of this PR's scope
4b6c0b2 to
da1790e
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (1)
nxc/protocols/rdp.py:245
kerb_passis used in multiple exception/logging branches, but it’s only assigned later. If an exception happens before the later assignment (e.g., during ccache loading), this can raiseUnboundLocalErrorand hide the real auth error. Initializekerb_passimmediately after the initialpasswordselection so it’s always defined for exception handling (it can still be overwritten later once the final secret is chosen).
self.hostname + "." + self.domain
password = password if password else nthash
da1790e to
5b1ee92
Compare
|
Hey, @NeffIsBack aardwolf merged the dependent PR. But there's no new PyPi release and if I point the dep to We could ask for a release on aardwolf or bump nxc to Whatever we do here will also be relevant to #1348 |
I asked skelsec if he could publish the version to pypi, let's see.
Hmm okay unfortunate. Usually I would like to wait until the version is EOL because there are some distros keeping very old versions (e.g. there were people complaining about missing support for older Ubuntu versions, see here). Maybe we have to drop 3.10 support then tho, we'll see. |
Description
This PR rewrites the RDP command execution path introduced in #676 for better reliability. The current implementation has several failures, including Win+R failing occasionally, clipboard sync races, and auth issues.
Requires aardwolf with reliability fixes: skelsec/aardwolf#47 (I can commit an update to dependencies here once aardwolf merges this)
Unfortunately, execution is still sometimes unreliable - but in my testing, this branch is ~35% more reliable than the main branch. Issues usually seem to happen on cold start of the box and rerunning once more fixes it. As far as I can tell, this issue just has to do with waiting for explorer to start up before we open the Run dialog. Could add a retry in the code if yall think that would be appropriate.
Type of change
Setup guide for the review
Local machine: Python 3.13, Ubuntu Linux
Targets tested: Windows Server 2022 (Azure, Build 20348), Windows Server 2025 AD (Azure, Build 26100), Windows 11 (Azure, Build 26100)
How to test:
Sources
MS-RDPBCGR
toggleFlags(4 bytes):https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/6c5d0ef9-4653-4d69-9ba9-09ba3acd660f
keyboardFlagsEXTENDED bit:https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/08eaaed5-f143-4bfa-a1e2-5414ca0ec67e
FreeRDP
https://github.com/FreeRDP/FreeRDP/blob/0aa3589/libfreerdp/core/input.c#L344-L356
toggleFlagsas UINT32:https://github.com/FreeRDP/FreeRDP/blob/0aa3589/libfreerdp/core/input.c#L125-L131
https://github.com/FreeRDP/FreeRDP/blob/0aa3589/libfreerdp/core/input.c#L720-L726
https://github.com/FreeRDP/FreeRDP/blob/0aa3589/channels/cliprdr/client/cliprdr_format.c#L191-L206
Other
System.Windows.Forms.Clipboardrequires STA threading:https://learn.microsoft.com/en-us/dotnet/api/system.windows.forms.clipboard
Testing
Checklist:
poetry run python -m ruff check . --preview)tests/e2e_commands.txtfileDetails (AI)
Connection isolation: Each connection gets cloned IO settings, target, and credential objects. No shared state between sequential connections.
Protocol negotiation fix: Authenticated connections no longer inherit the NLA fingerprinting probe's
supported_protocolsoverride. This fixes Kerberos, ccache, and NT hash execution on Server 2025.AES credential type: Uses
asyauthSecret.AESfor AES keys (was incorrectly using password type).Keyboard focus initialization: Sends the focus initialization sequence before Win+R to ensure keyboard reaches the desktop (see fix: improve headless RDP input and clipboard lifecycle skelsec/aardwolf#47).
Unicode input: Uses
send_key_char(Unicode keyboard events) for Run dialog text.Output capture rewrite:
__NXC_RDP_START_<uuid>__/__NXC_RDP_END_<uuid>__markers (ignores stale clipboard).$LASTEXITCODE.[System.Windows.Forms.Clipboard]::SetText()/::GetText()(.NET API, works PowerShell 2+) instead of PS5-onlySet-Clipboard/Get-Clipboard.-STAflag for WinForms clipboard STA threading requirement.Clipboard synchronization: Waits for
CLIPBOARD_FORMAT_LIST_RESPONSEbefore triggering remote clipboard read. Eliminates the race where the remote reads before the client has written.No-output mode: Direct compact launchers (
cmd.exe /d /s /corpowershell.exe -Command) instead of large encoded scripts typed character-by-character.(Developed with assistance from Copilot CLI with GPT 5.6 and Opus 5, but all reviewed and tested on live targets by a human)