From d41a44e9b23b1500f6f81fec73d5d5d672fb493c Mon Sep 17 00:00:00 2001 From: Thomas Q Brady Date: Thu, 15 Oct 2026 15:03:40 -0400 Subject: [PATCH] fix(network-ops): honor empty-string null session in netexec argv (ENG-8821) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The netexec docstrings told agents that an empty username/password triggers a null session, but the argv builder used truthiness guards, so username=""/ password="" were falsy and -u/-p were dropped — the agent got a different invocation than documented. A real NetExec null session is the explicit -u '' -p '' form. Distinguish None/[] (omit the flag) from "" (emit the flag with its empty value) via a shared _emit_auth_flag helper. This also satisfies NetExec's unauthenticated AS-REP roast, which gates on password == "" to engage. Tighten the username/password docstrings to state what "" emits, and bump network-ops to 2.2.0 for the public-surface behavior change. --- capabilities/network-ops/capability.yaml | 2 +- .../network-ops/tests/test_tool_fixes.py | 123 +++++++++++++++--- capabilities/network-ops/tools/netexec.py | 82 ++++++------ 3 files changed, 152 insertions(+), 55 deletions(-) diff --git a/capabilities/network-ops/capability.yaml b/capabilities/network-ops/capability.yaml index 66d3946..cc75dad 100644 --- a/capabilities/network-ops/capability.yaml +++ b/capabilities/network-ops/capability.yaml @@ -1,6 +1,6 @@ schema: 1 name: network-ops -version: "2.1.9" +version: "2.2.0" description: > Network operations and Active Directory exploitation. Multi-agent pipeline for autonomous red teaming with Nmap scanning, Netexec diff --git a/capabilities/network-ops/tests/test_tool_fixes.py b/capabilities/network-ops/tests/test_tool_fixes.py index 7851026..9186305 100644 --- a/capabilities/network-ops/tests/test_tool_fixes.py +++ b/capabilities/network-ops/tests/test_tool_fixes.py @@ -107,6 +107,7 @@ def _load_module(name: str, filename: str): impacket_mod = _load_module("impacket_tools", "impacket.py") cracking_mod = _load_module("cracking_tools", "cracking.py") bloodyad_mod = _load_module("bloodyad_tools", "bloodyad.py") +netexec_mod = _load_module("netexec_tools", "netexec.py") # =================================================================== @@ -601,14 +602,10 @@ async def test_backend_error_suggests_john_with_known_mode(self, tmp_path): cracker = cracking_mod.Cracking() async def fake_execute(cmd, **kwargs): - raise RuntimeError( - "Command failed (255): clGetPlatformIDs(): CL_PLATFORM_NOT_FOUND_KHR" - ) + raise RuntimeError("Command failed (255): clGetPlatformIDs(): CL_PLATFORM_NOT_FOUND_KHR") with patch.object(cracking_mod, "execute", side_effect=fake_execute): - with pytest.raises( - RuntimeError, match=r"Use john_the_ripper.*hash_format='nt'" - ): + with pytest.raises(RuntimeError, match=r"Use john_the_ripper.*hash_format='nt'"): await cracker.hashcat( hashcat_mode=1000, hash_file=str(hash_file), @@ -624,9 +621,7 @@ async def test_backend_error_chains_original_exception(self, tmp_path): wordlist.write_text("password\n") cracker = cracking_mod.Cracking() - original = RuntimeError( - "Command failed (255): clGetPlatformIDs(): CL_PLATFORM_NOT_FOUND_KHR" - ) + original = RuntimeError("Command failed (255): clGetPlatformIDs(): CL_PLATFORM_NOT_FOUND_KHR") async def fake_execute(cmd, **kwargs): raise original @@ -720,9 +715,7 @@ def test_no_hashes_flag_ever_emitted(self): # the real tool surface. _BLOODYAD_TOOL_METHODS = sorted( name - for name, member in inspect.getmembers( - bloodyad_mod.BloodyAD, inspect.iscoroutinefunction - ) + for name, member in inspect.getmembers(bloodyad_mod.BloodyAD, inspect.iscoroutinefunction) if name.startswith("bloodyad_") ) @@ -908,9 +901,7 @@ async def test_no_backend_short_circuits_before_show(self, tmp_path): """A missing GPU backend raises the john hint and never reaches --show.""" hash_file, wordlist = self._write_inputs(tmp_path) fake_execute, calls = _split_execute( - RuntimeError( - "Command failed (255): clGetPlatformIDs(): CL_PLATFORM_NOT_FOUND_KHR" - ), + RuntimeError("Command failed (255): clGetPlatformIDs(): CL_PLATFORM_NOT_FOUND_KHR"), "svc_sql:Password1", ) with patch.object(cracking_mod, "execute", side_effect=fake_execute): @@ -967,3 +958,105 @@ async def test_nonzero_exit_surfaces_instead_of_empty(self, tmp_path): wordlist_path=str(wordlist), ) assert not any("--show" in cmd for cmd in calls) + + +# =================================================================== +# Netexec: null-session / empty-credential argv construction +# =================================================================== + + +class TestNetexecNullSession: + """The base netexec() builder must distinguish None (omit the flag) from + "" (emit the explicit `-u ''` / `-p ''` null-session form).""" + + def _make_netexec(self): + nx = netexec_mod.Netexec() + nx.timeout = 60 + nx.variant = "all" + return nx + + @pytest.mark.asyncio + async def test_empty_strings_emit_null_session(self): + """username="" and password="" → `-u '' -p ''`, the anonymous null session.""" + nx = self._make_netexec() + with patch.object(netexec_mod, "execute", new_callable=AsyncMock) as mock_exec: + mock_exec.return_value = "output" + await nx.netexec("smb", ["10.0.0.1"], args=["--shares"], username="", password="") + cmd = mock_exec.call_args[0][0] + assert "-u" in cmd and "-p" in cmd + assert cmd[cmd.index("-u") + 1] == "" + assert cmd[cmd.index("-p") + 1] == "" + + @pytest.mark.asyncio + async def test_none_omits_flags(self): + """username=None / password=None → no auth flags at all.""" + nx = self._make_netexec() + with patch.object(netexec_mod, "execute", new_callable=AsyncMock) as mock_exec: + mock_exec.return_value = "output" + await nx.netexec("smb", ["10.0.0.1"], args=["--shares"]) + cmd = mock_exec.call_args[0][0] + assert "-u" not in cmd + assert "-p" not in cmd + + @pytest.mark.asyncio + async def test_populated_credentials_unchanged(self): + """Non-empty credentials still build `-u user -p pass` (regression).""" + nx = self._make_netexec() + with patch.object(netexec_mod, "execute", new_callable=AsyncMock) as mock_exec: + mock_exec.return_value = "output" + await nx.netexec("smb", ["10.0.0.1"], args=[], username="admin", password="Passw0rd") + cmd = mock_exec.call_args[0][0] + assert cmd[cmd.index("-u") + 1] == "admin" + assert cmd[cmd.index("-p") + 1] == "Passw0rd" + + @pytest.mark.asyncio + async def test_username_with_empty_password(self): + """AS-REP-roast style: a real username with password="" → `-u svc -p ''`.""" + nx = self._make_netexec() + with patch.object(netexec_mod, "execute", new_callable=AsyncMock) as mock_exec: + mock_exec.return_value = "output" + await nx.netexec("ldap", ["dc.corp.local"], args=[], username="svc", password="") + cmd = mock_exec.call_args[0][0] + assert cmd[cmd.index("-u") + 1] == "svc" + assert cmd[cmd.index("-p") + 1] == "" + + @pytest.mark.asyncio + async def test_multi_value_lists_expand(self): + """A list of usernames/passwords expands to repeated values after each flag.""" + nx = self._make_netexec() + with patch.object(netexec_mod, "execute", new_callable=AsyncMock) as mock_exec: + mock_exec.return_value = "output" + await nx.netexec( + "smb", + ["10.0.0.1"], + args=[], + username=["u1", "u2"], + password=["p1", "p2"], + ) + cmd = mock_exec.call_args[0][0] + u_idx = cmd.index("-u") + assert cmd[u_idx + 1 : u_idx + 3] == ["u1", "u2"] + p_idx = cmd.index("-p") + assert cmd[p_idx + 1 : p_idx + 3] == ["p1", "p2"] + + @pytest.mark.asyncio + async def test_empty_list_omits_flag(self): + """An empty list is "no value supplied", not a null session — no dangling `-u`.""" + nx = self._make_netexec() + with patch.object(netexec_mod, "execute", new_callable=AsyncMock) as mock_exec: + mock_exec.return_value = "output" + await nx.netexec("smb", ["10.0.0.1"], args=["--shares"], username=[]) + cmd = mock_exec.call_args[0][0] + assert "-u" not in cmd + + @pytest.mark.asyncio + async def test_enum_shares_wrapper_passes_null_session(self): + """The specialized wrapper threads "" through to the null-session form.""" + nx = self._make_netexec() + with patch.object(netexec_mod, "execute", new_callable=AsyncMock) as mock_exec: + mock_exec.return_value = "output" + await nx.netexec_smb_enum_shares(["10.0.0.1"], username="", password="") + cmd = mock_exec.call_args[0][0] + assert cmd[cmd.index("-u") + 1] == "" + assert cmd[cmd.index("-p") + 1] == "" + assert "--shares" in cmd diff --git a/capabilities/network-ops/tools/netexec.py b/capabilities/network-ops/tools/netexec.py index e77d9c1..56a6b8b 100644 --- a/capabilities/network-ops/tools/netexec.py +++ b/capabilities/network-ops/tools/netexec.py @@ -7,6 +7,16 @@ from loguru import logger +def _emit_auth_flag(value: str | list[str] | None) -> bool: + """Whether an auth value should produce its command-line flag. + + ``None`` and an empty list mean no value was supplied, so the flag is + omitted. An empty string is the explicit null-session form (``-u ''`` / + ``-p ''``) and must still emit the flag with its empty value. + """ + return value is not None and value != [] + + class Netexec(Toolset): """ A toolset for network operations using the Netexec (nxc) utility. @@ -40,8 +50,8 @@ async def netexec( ssh, wmi, mssql, vnc, ldap, nfs, smb, winrm, ftp, or rdp. targets: A list of IPs, ranges, CIDRs, hostnames, or FQDNs. args: A list of additional arguments and flags for netexec (e.g., ['--help']). - username: Username, list of usernames, or a path to a user file (empty for null session). - password: Password, list of passwords, or a path to a password file (empty for null session). + username: Username, list of usernames, or a path to a user file. Pass "" to emit `-u ''` (pair with password="" for an anonymous null session). + password: Password, list of passwords, or a path to a password file. Pass "" to emit `-p ''` (pair with username="" for an anonymous null session). domain: Domain for authentication. hash: A single NTLM hash, a list of hashes, or a path to a hash file. kerberos: Kerberos ccache file path or AES key for authentication. @@ -50,9 +60,7 @@ async def netexec( # Fix netexec first-run setup issue if .nxc exists as a file instead of directory nxc_path = Path.home() / ".nxc" if nxc_path.exists() and nxc_path.is_file(): - logger.warning( - f"Found {nxc_path} as a file instead of directory, removing to allow netexec setup" - ) + logger.warning(f"Found {nxc_path} as a file instead of directory, removing to allow netexec setup") nxc_path.unlink() cmd = ["netexec", protocol, *targets] @@ -62,14 +70,10 @@ async def netexec( elif domain: cmd.extend(["-d", domain]) - if username: - cmd.extend( - ["-u", *(username if isinstance(username, list) else [username])] - ) - if password: - cmd.extend( - ["-p", *(password if isinstance(password, list) else [password])] - ) + if _emit_auth_flag(username): + cmd.extend(["-u", *(username if isinstance(username, list) else [username])]) + if _emit_auth_flag(password): + cmd.extend(["-p", *(password if isinstance(password, list) else [password])]) if hash: cmd.extend(["-H", *(hash if isinstance(hash, list) else [hash])]) @@ -106,8 +110,8 @@ async def netexec_smb_enum( Args: targets: A list of IPs, ranges, CIDRs, hostnames, or FQDNs. - username: Username, list of usernames, or a path to a user file (empty for null session). - password: Password, list of passwords, or a path to a password file (empty for null session). + username: Username, list of usernames, or a path to a user file. Pass "" to emit `-u ''` (pair with password="" for an anonymous null session). + password: Password, list of passwords, or a path to a password file. Pass "" to emit `-p ''` (pair with username="" for an anonymous null session). domain: Domain for authentication. hash: A single NTLM hash, a list of hashes, or a path to a hash file. kerberos: Kerberos ccache file path or AES key for authentication. @@ -144,8 +148,8 @@ async def netexec_smb_auth( Args: targets: A list of IPs, ranges, CIDRs, hostnames, or FQDNs. - username: Username, list of usernames, or a path to a user file (empty for null session). - password: Password, list of passwords, or a path to a password file (empty for null session). + username: Username, list of usernames, or a path to a user file. Pass "" to emit `-u ''` (pair with password="" for an anonymous null session). + password: Password, list of passwords, or a path to a password file. Pass "" to emit `-p ''` (pair with username="" for an anonymous null session). domain: Domain for authentication. hash: A single NTLM hash, a list of hashes, or a path to a hash file. kerberos: Kerberos ccache file path or AES key for authentication. @@ -182,8 +186,8 @@ async def netexec_smb_enum_group_members( Args: targets: A list of IPs, ranges, CIDRs, hostnames, or FQDNs. groups: Group names to query members for. - username: Username, list of usernames, or a path to a user file (empty for null session). - password: Password, list of passwords, or a path to a password file (empty for null session). + username: Username, list of usernames, or a path to a user file. Pass "" to emit `-u ''` (pair with password="" for an anonymous null session). + password: Password, list of passwords, or a path to a password file. Pass "" to emit `-p ''` (pair with username="" for an anonymous null session). domain: Domain for authentication. hash: A single NTLM hash, a list of hashes, or a path to a hash file. kerberos: Kerberos ccache file path or AES key for authentication. @@ -228,8 +232,8 @@ async def netexec_smb_enum_shares( Args: targets: A list of IPs, ranges, CIDRs, hostnames, or FQDNs. - username: Username, list of usernames, or a path to a user file (empty for null session). - password: Password, list of passwords, or a path to a password file (empty for null session). + username: Username, list of usernames, or a path to a user file. Pass "" to emit `-u ''` (pair with password="" for an anonymous null session). + password: Password, list of passwords, or a path to a password file. Pass "" to emit `-p ''` (pair with username="" for an anonymous null session). domain: Domain for authentication. hash: A single NTLM hash, a list of hashes, or a path to a hash file. kerberos: Kerberos ccache file path or AES key for authentication. @@ -263,8 +267,8 @@ async def netexec_ldap_enum( Args: targets: A list of IPs, ranges, CIDRs, hostnames, or FQDNs. - username: Username, list of usernames, or a path to a user file (empty for null session). - password: Password, list of passwords, or a path to a password file (empty for null session). + username: Username, list of usernames, or a path to a user file. Pass "" to emit `-u ''` (pair with password="" for an anonymous null session). + password: Password, list of passwords, or a path to a password file. Pass "" to emit `-p ''` (pair with username="" for an anonymous null session). domain: Domain for authentication. hash: A single NTLM hash, a list of hashes, or a path to a hash file. kerberos: Kerberos ccache file path or AES key for authentication. @@ -296,8 +300,8 @@ async def netexec_ldap_auth( Args: targets: A list of IPs, ranges, CIDRs, hostnames, or FQDNs. - username: Username, list of usernames, or a path to a user file (empty for null session). - password: Password, list of passwords, or a path to a password file (empty for null session). + username: Username, list of usernames, or a path to a user file. Pass "" to emit `-u ''` (pair with password="" for an anonymous null session). + password: Password, list of passwords, or a path to a password file. Pass "" to emit `-p ''` (pair with username="" for an anonymous null session). domain: Domain for authentication. hash: A single NTLM hash, a list of hashes, or a path to a hash file. kerberos: Kerberos ccache file path or AES key for authentication. @@ -331,8 +335,8 @@ async def netexec_ldap_enum_group_members( Args: targets: A list of IPs, ranges, CIDRs, hostnames, or FQDNs. groups: Group names to query members for. - username: Username, list of usernames, or a path to a user file (empty for null session). - password: Password, list of passwords, or a path to a password file (empty for null session). + username: Username, list of usernames, or a path to a user file. Pass "" to emit `-u ''` (pair with password="" for an anonymous null session). + password: Password, list of passwords, or a path to a password file. Pass "" to emit `-p ''` (pair with username="" for an anonymous null session). domain: Domain for authentication. hash: A single NTLM hash, a list of hashes, or a path to a hash file. kerberos: Kerberos ccache file path or AES key for authentication. @@ -376,8 +380,8 @@ async def netexec_asreproast( Args: targets: A list of IPs, ranges, CIDRs, hostnames, or FQDNs. - username: Username, list of usernames, or a path to a user file (empty for null session). - password: Password, list of passwords, or a path to a password file (empty for null session). + username: Username, list of usernames, or a path to a user file. Pass "" to emit `-u ''` (pair with password="" for an anonymous null session). + password: Password, list of passwords, or a path to a password file. Pass "" to emit `-p ''` (pair with username="" for an anonymous null session). domain: Domain for authentication. hash: A single NTLM hash, a list of hashes, or a path to a hash file. """ @@ -410,8 +414,8 @@ async def netexec_kerberoast( Args: targets: A list of IPs, ranges, CIDRs, hostnames, or FQDNs. - username: Username, list of usernames, or a path to a user file (empty for null session). - password: Password, list of passwords, or a path to a password file (empty for null session). + username: Username, list of usernames, or a path to a user file. Pass "" to emit `-u ''` (pair with password="" for an anonymous null session). + password: Password, list of passwords, or a path to a password file. Pass "" to emit `-p ''` (pair with username="" for an anonymous null session). domain: Domain for authentication. hash: A single NTLM hash, a list of hashes, or a path to a hash file. """ @@ -759,8 +763,8 @@ async def netexec_smb( Args: targets: A list of IPs, ranges, CIDRs, hostnames, or FQDNs. args: Additional command line arguments for the command. - username: Username, list of usernames, or a path to a user file (empty for null session). - password: Password, list of passwords, or a path to a password file (empty for null session). + username: Username, list of usernames, or a path to a user file. Pass "" to emit `-u ''` (pair with password="" for an anonymous null session). + password: Password, list of passwords, or a path to a password file. Pass "" to emit `-p ''` (pair with username="" for an anonymous null session). domain: Domain for authentication. hash: A single NTLM hash, a list of hashes, or a path to a hash file. kerberos: Kerberos ccache file path or AES key for authentication. @@ -985,8 +989,8 @@ async def netexec_ldap( Args: targets: A list of IPs, ranges, CIDRs, hostnames, or FQDNs. args: Additional command line arguments for the command. - username: Username, list of usernames, or a path to a user file (empty for null session). - password: Password, list of passwords, or a path to a password file (empty for null session). + username: Username, list of usernames, or a path to a user file. Pass "" to emit `-u ''` (pair with password="" for an anonymous null session). + password: Password, list of passwords, or a path to a password file. Pass "" to emit `-p ''` (pair with username="" for an anonymous null session). domain: Domain for authentication. hash: A single NTLM hash, a list of hashes, or a path to a hash file. kerberos: Kerberos ccache file path or AES key for authentication. @@ -1195,8 +1199,8 @@ async def netexec_mssql( Args: targets: A list of IPs, ranges, CIDRs, hostnames, or FQDNs. args: Additional command line arguments for the command. - username: Username, list of usernames, or a path to a user file (empty for null session). - password: Password, list of passwords, or a path to a password file (empty for null session). + username: Username, list of usernames, or a path to a user file. Pass "" to emit `-u ''` (pair with password="" for an anonymous null session). + password: Password, list of passwords, or a path to a password file. Pass "" to emit `-p ''` (pair with username="" for an anonymous null session). domain: Domain for authentication. hash: A single NTLM hash, a list of hashes, or a path to a hash file. kerberos: Kerberos ccache file path or AES key for authentication. @@ -1389,8 +1393,8 @@ async def netexec_wmi( Args: targets: A list of IPs, ranges, CIDRs, hostnames, or FQDNs. args: Additional command line arguments for the command. - username: Username, list of usernames, or a path to a user file (empty for null session). - password: Password, list of passwords, or a path to a password file (empty for null session). + username: Username, list of usernames, or a path to a user file. Pass "" to emit `-u ''` (pair with password="" for an anonymous null session). + password: Password, list of passwords, or a path to a password file. Pass "" to emit `-p ''` (pair with username="" for an anonymous null session). domain: Domain for authentication. hash: A single NTLM hash, a list of hashes, or a path to a hash file. kerberos: Kerberos ccache file path or AES key for authentication.