Skip to content

[Draft] Fix RDP command execution reliability - #1329

Draft
Adamkadaban wants to merge 10 commits into
Pennyw0rth:mainfrom
Adamkadaban:rdp-exec-reliability-final
Draft

[Draft] Fix RDP command execution reliability#1329
Adamkadaban wants to merge 10 commits into
Pennyw0rth:mainfrom
Adamkadaban:rdp-exec-reliability-final

Conversation

@Adamkadaban

@Adamkadaban Adamkadaban commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

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)

image

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.

image

Type of change

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Deprecation of feature or functionality
  • This change requires a documentation update
  • This requires a third party update (such as Impacket, Dploot, lsassy, etc)

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:

pipx install "git+https://github.com/Adamkadaban/NetExec@rdp-exec-reliability-final"
pipx inject netexec "git+https://github.com/Adamkadaban/aardwolf@rdp-reliability-final" -f 

nxc rdp <target> -u admin -p pass -x 'echo hello'
nxc rdp <target> -u admin -p pass -X 'Write-Output "hello"'
nxc rdp <target> -u admin -p pass -x 'echo alpha^&beta'
nxc rdp <target> -u admin -p pass -x 'cmd /c exit /b 7'
nxc rdp <target> -u admin -p pass -X 'Write-Output "cafe"'
nxc rdp <target> -u admin -p pass -x 'echo hi' --no-output
nxc rdp <target> -u admin -p pass -k -x 'whoami'

Sources

MS-RDPBCGR

FreeRDP

Other

Testing

  • Tested e2e on Windows Server 2022, Windows 11, and Server 2025 AD with NTLM, Kerberos password, ccache, NT hash, and AES.
  • Tested against NetExec E2E suite (9/9 passing).
  • Stress tested 50 sequential+parallel command executions on Server 2022: 50/50 passed (10 per batch).
  • Stress tested 50 sequential+parallel Kerberos password executions on Server 2025 DC: 50/50 passed (10 per batch).

Checklist:

  • I have ran Ruff against my changes (poetry run python -m ruff check . --preview)
  • I have added or updated the tests/e2e_commands.txt file
  • New and existing e2e tests pass locally with my changes
  • If reliant on changes of third party dependencies, I have linked the relevant PRs
  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation

Details (AI)

  1. Connection isolation: Each connection gets cloned IO settings, target, and credential objects. No shared state between sequential connections.

  2. Protocol negotiation fix: Authenticated connections no longer inherit the NLA fingerprinting probe's supported_protocols override. This fixes Kerberos, ccache, and NT hash execution on Server 2025.

  3. AES credential type: Uses asyauthSecret.AES for AES keys (was incorrectly using password type).

  4. 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).

  5. Unicode input: Uses send_key_char (Unicode keyboard events) for Run dialog text.

  6. Output capture rewrite:

    • Payload base64-encoded as UTF-16LE (handles all characters including &, |, Unicode).
    • Unique __NXC_RDP_START_<uuid>__ / __NXC_RDP_END_<uuid>__ markers (ignores stale clipboard).
    • Captures native $LASTEXITCODE.
    • Uses [System.Windows.Forms.Clipboard]::SetText() / ::GetText() (.NET API, works PowerShell 2+) instead of PS5-only Set-Clipboard/Get-Clipboard.
    • -STA flag for WinForms clipboard STA threading requirement.
  7. Clipboard synchronization: Waits for CLIPBOARD_FORMAT_LIST_RESPONSE before triggering remote clipboard read. Eliminates the race where the remote reads before the client has written.

  8. No-output mode: Direct compact launchers (cmd.exe /d /s /c or powershell.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)

Copilot AI review requested due to automatic review settings July 26, 2026 08:37

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread nxc/protocols/rdp.py Outdated
Copilot AI review requested due to automatic review settings July 26, 2026 08:40
@Adamkadaban
Adamkadaban force-pushed the rdp-exec-reliability-final branch from afeb4b3 to 3e50634 Compare July 26, 2026 08:41

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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")

Comment thread nxc/protocols/rdp.py
Comment thread nxc/protocols/rdp.py Outdated
Copilot AI review requested due to automatic review settings July 26, 2026 08:43

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_shell returns an empty string. An empty string is also a valid successful output (command produced no output), so callers can't distinguish failure from success. Return None consistently for failures.
                except asyncio.TimeoutError:
                    self.logger.fail("Clipboard cannot be initialized, no output can be retrieved")
                    return ""

Comment thread nxc/protocols/rdp.py
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()

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated description to say we're blocking on this being updated. Will update dependency later.

Comment thread nxc/protocols/rdp.py
@NeffIsBack

Copy link
Copy Markdown
Member

Thanks for the PR! Let us know when the aardwolf got merged and this is review for reviewing :)

@NeffIsBack NeffIsBack added the enhancement New feature or request label Jul 26, 2026
@Adamkadaban

Adamkadaban commented Jul 26, 2026

Copy link
Copy Markdown
Contributor Author

I seem to be in an infinite modify and review loop with copilot rn 😆. Labeled as draft for now
Will do. Thanks, @NeffIsBack

@Adamkadaban Adamkadaban changed the title Fix RDP command execution reliability [Draft] Fix RDP command execution reliability Jul 26, 2026
Copilot AI review requested due to automatic review settings July 26, 2026 09:13

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.

@NeffIsBack

NeffIsBack commented Jul 26, 2026

Copy link
Copy Markdown
Member

I seem to be in an infinite modify and review loop with copilot rn 😆. Labeled as draft for now

Will do. Thanks, @NeffIsBack

Yeah AI reviews (especially on NetExec) seem to be pretty hit and miss so far.
FYI, I'll turn the PR into a draft until the PR is merged etc. Just press "ready for review" once everything is done.

@NeffIsBack
NeffIsBack marked this pull request as draft July 26, 2026 22:30
Copilot AI review requested due to automatic review settings July 27, 2026 01:44
@Adamkadaban
Adamkadaban force-pushed the rdp-exec-reliability-final branch from 07561f5 to a0d55b7 Compare July 27, 2026 01:44

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 and connect(auth_only=...) later), but the project dependency spec currently allows aardwolf>=0.2.8. If a user installs an older compatible version, this will fail at runtime with a non-actionable AttributeError. Consider adding an explicit version/API guard with a clear upgrade message (or pin/bump aardwolf in packaging).
        iosettings = self.iosettings.clone_for_connection()

Comment thread nxc/protocols/rdp.py
Copilot AI review requested due to automatic review settings July 27, 2026 07:18

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread nxc/protocols/rdp.py Outdated
Comment thread nxc/protocols/rdp.py
Copilot AI review requested due to automatic review settings July 27, 2026 09:12

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread nxc/protocols/rdp.py
Comment on lines 241 to 245
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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In main. Will leave this comment open since it's not part of this PR's scope

Copilot AI review requested due to automatic review settings July 27, 2026 22:09
@Adamkadaban
Adamkadaban force-pushed the rdp-exec-reliability-final branch from 4b6c0b2 to da1790e Compare July 27, 2026 22:09

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_pass is 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 raise UnboundLocalError and hide the real auth error. Initialize kerb_pass immediately after the initial password selection 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

@Adamkadaban
Adamkadaban force-pushed the rdp-exec-reliability-final branch from da1790e to 5b1ee92 Compare August 16, 2026 10:17
@Adamkadaban

Copy link
Copy Markdown
Contributor Author

Hey, @NeffIsBack

aardwolf merged the dependent PR. But there's no new PyPi release and if I point the dep to https://github.com/Pennyw0rth/NetExec/pull/1348, aardwolf's requires-python >= 3.11 conflicts with NetExec's 3.10 requirement, which causes uv to update the lockfile.

We could ask for a release on aardwolf or bump nxc to >=3.11 since it works there already. For what it's worth, Python 3.10 is EOL at the end of October. Thoughts or preferences?

Whatever we do here will also be relevant to #1348

@NeffIsBack

Copy link
Copy Markdown
Member

aardwolf merged the dependent PR. But there's no new PyPi release and if I point the dep to

I asked skelsec if he could publish the version to pypi, let's see.

We could ask for a release on aardwolf or bump nxc to >=3.11 since it works there already. For what it's worth, Python 3.10 is EOL at the end of October. Thoughts or preferences?

Whatever we do here will also be relevant to #1348

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants