Add ntlm_relay wrapper and ADCS relay validation tests

This commit is contained in:
n0mad1k
2026-04-10 07:36:16 -04:00
parent edfa067f68
commit 7a3b8e40d9
+808
View File
@@ -0,0 +1,808 @@
#!/usr/bin/env python3
"""Tests for modules/active/ntlm_relay.py — NTLM relay subprocess wrapper."""
import os
import pytest
import subprocess
import tempfile
import threading
import time
from unittest.mock import MagicMock, Mock, patch, call, mock_open
from modules.active.ntlm_relay import NTLMRelay, RELAY_PROTOCOLS
class TestNTLMRelayInstantiation:
"""Test module instantiation and configuration."""
def test_ntlm_relay_instantiate(self, mock_bus, mock_state, mock_config):
"""NTLMRelay can be instantiated with mock bus/state/config."""
instance = NTLMRelay(bus=mock_bus, state=mock_state, config=mock_config)
assert instance is not None
assert instance.bus is mock_bus
assert instance.state is mock_state
assert instance.config is mock_config
assert instance._running is False
assert instance.name == "ntlm_relay"
assert instance.module_type == "active"
assert instance.priority == 150
assert instance.requires_root is True
def test_ntlm_relay_default_binary_path(self, mock_bus, mock_state, mock_config):
"""Default ntlmrelayx binary path is set correctly."""
instance = NTLMRelay(bus=mock_bus, state=mock_state, config=mock_config)
assert instance._ntlmrelayx_binary == "/opt/tools/impacket/examples/ntlmrelayx.py"
def test_ntlm_relay_custom_binary_path(self, mock_bus, mock_state, mock_config):
"""Custom ntlmrelayx binary path is respected."""
custom_path = "/custom/path/ntlmrelayx.py"
mock_config["ntlmrelayx_binary"] = custom_path
instance = NTLMRelay(bus=mock_bus, state=mock_state, config=mock_config)
assert instance._ntlmrelayx_binary == custom_path
def test_ntlm_relay_custom_interface(self, mock_bus, mock_state, mock_config):
"""Custom interface is set correctly."""
mock_config["interface"] = "wlan0"
instance = NTLMRelay(bus=mock_bus, state=mock_state, config=mock_config)
assert instance._iface == "wlan0"
def test_ntlm_relay_attributes_initialized(self, mock_bus, mock_state, mock_config):
"""All internal attributes are initialized."""
instance = NTLMRelay(bus=mock_bus, state=mock_state, config=mock_config)
assert instance._proc is None
assert instance._output_thread is None
assert instance._targets == []
assert instance._protocols == set()
assert instance._adcs_template is None
assert instance._relay_active is False
assert instance._successful_relays == []
assert isinstance(instance._relays_lock, type(threading.Lock()))
assert instance._responder_mgr is None
class TestNTLMRelayStartStop:
"""Test start/stop lifecycle."""
def test_start_creates_directories(self, mock_bus, mock_state, mock_config, tmp_path):
"""start() creates relay_targets and loot directories."""
instance = NTLMRelay(bus=mock_bus, state=mock_state, config=mock_config)
# Override paths to temp directory
instance._target_file = os.path.join(str(tmp_path), ".implant", "relay_targets.txt")
instance._loot_dir = os.path.join(str(tmp_path), ".implant", "ntlmrelay_loot")
with patch.object(instance.state, 'set_module_status') as mock_set_status:
instance.start()
assert instance._running is True
assert os.path.isdir(os.path.dirname(instance._target_file))
assert os.path.isdir(instance._loot_dir)
mock_set_status.assert_called_once_with("ntlm_relay", "running", pid=instance._pid)
def test_start_idempotent(self, mock_bus, mock_state, mock_config):
"""Calling start() twice doesn't re-initialize."""
instance = NTLMRelay(bus=mock_bus, state=mock_state, config=mock_config)
with patch.object(instance.state, 'set_module_status') as mock_set_status:
instance.start()
call_count_1 = mock_set_status.call_count
instance.start()
call_count_2 = mock_set_status.call_count
# Second start() should return early, not call set_module_status again
assert call_count_2 == call_count_1
def test_stop_when_not_running(self, mock_bus, mock_state, mock_config):
"""stop() returns early if not running."""
instance = NTLMRelay(bus=mock_bus, state=mock_state, config=mock_config)
with patch.object(instance.state, 'set_module_status') as mock_set_status:
instance.stop()
mock_set_status.assert_not_called()
def test_stop_stops_relay(self, mock_bus, mock_state, mock_config):
"""stop() calls stop_relay if relay is active."""
instance = NTLMRelay(bus=mock_bus, state=mock_state, config=mock_config)
instance._running = True
instance._relay_active = True
with patch.object(instance, 'stop_relay') as mock_stop_relay:
with patch.object(instance.state, 'set_module_status'):
instance.stop()
mock_stop_relay.assert_called_once()
def test_stop_sets_status(self, mock_bus, mock_state, mock_config):
"""stop() updates module status."""
instance = NTLMRelay(bus=mock_bus, state=mock_state, config=mock_config)
instance._running = True
with patch.object(instance.state, 'set_module_status') as mock_set_status:
instance.stop()
mock_set_status.assert_called_once_with("ntlm_relay", "stopped")
class TestBuildCommand:
"""Test ntlmrelayx command building."""
def test_build_command_basic(self, mock_bus, mock_state, mock_config, tmp_path):
"""_build_command builds basic command without ADCS."""
instance = NTLMRelay(bus=mock_bus, state=mock_state, config=mock_config)
instance._target_file = str(tmp_path / "targets.txt")
instance._loot_dir = str(tmp_path)
instance._protocols = {"smb"}
cmd = instance._build_command()
assert "python3" in cmd
assert instance._ntlmrelayx_binary in cmd
assert "-tf" in cmd
assert instance._target_file in cmd
assert "-of" in cmd
assert "-smb2support" in cmd
assert "-socks" in cmd
def test_build_command_with_adcs(self, mock_bus, mock_state, mock_config, tmp_path):
"""_build_command includes ADCS options when template is set."""
instance = NTLMRelay(bus=mock_bus, state=mock_state, config=mock_config)
instance._target_file = str(tmp_path / "targets.txt")
instance._loot_dir = str(tmp_path)
instance._protocols = {"adcs"}
instance._adcs_template = "User"
cmd = instance._build_command()
assert "--adcs" in cmd
assert "--template" in cmd
assert "User" in cmd
def test_build_command_without_adcs_if_not_in_protocols(self, mock_bus, mock_state, mock_config, tmp_path):
"""_build_command does not include ADCS options if adcs not in protocols."""
instance = NTLMRelay(bus=mock_bus, state=mock_state, config=mock_config)
instance._target_file = str(tmp_path / "targets.txt")
instance._loot_dir = str(tmp_path)
instance._protocols = {"smb"}
instance._adcs_template = "User"
cmd = instance._build_command()
assert "--adcs" not in cmd
assert "--template" not in cmd
def test_build_command_with_ldap(self, mock_bus, mock_state, mock_config, tmp_path):
"""_build_command includes delegate-access for LDAP."""
instance = NTLMRelay(bus=mock_bus, state=mock_state, config=mock_config)
instance._target_file = str(tmp_path / "targets.txt")
instance._loot_dir = str(tmp_path)
instance._protocols = {"ldap"}
cmd = instance._build_command()
assert "--delegate-access" in cmd
def test_build_command_with_ldaps(self, mock_bus, mock_state, mock_config, tmp_path):
"""_build_command includes delegate-access for LDAPS."""
instance = NTLMRelay(bus=mock_bus, state=mock_state, config=mock_config)
instance._target_file = str(tmp_path / "targets.txt")
instance._loot_dir = str(tmp_path)
instance._protocols = {"ldaps"}
cmd = instance._build_command()
assert "--delegate-access" in cmd
def test_build_command_hashes_output_path(self, mock_bus, mock_state, mock_config, tmp_path):
"""_build_command sets correct hash output path."""
instance = NTLMRelay(bus=mock_bus, state=mock_state, config=mock_config)
instance._target_file = str(tmp_path / "targets.txt")
instance._loot_dir = str(tmp_path)
cmd = instance._build_command()
hash_idx = cmd.index("-of")
hash_path = cmd[hash_idx + 1]
assert hash_path == os.path.join(instance._loot_dir, "hashes")
class TestInferProtocols:
"""Test protocol inference from target URLs."""
def test_infer_protocols_from_smb_url(self, mock_bus, mock_state, mock_config):
"""_infer_protocols extracts SMB from smb:// URL."""
instance = NTLMRelay(bus=mock_bus, state=mock_state, config=mock_config)
protocols = instance._infer_protocols(["smb://10.0.0.0"])
assert "smb" in protocols
def test_infer_protocols_from_ldap_url(self, mock_bus, mock_state, mock_config):
"""_infer_protocols extracts LDAP from ldap:// URL."""
instance = NTLMRelay(bus=mock_bus, state=mock_state, config=mock_config)
protocols = instance._infer_protocols(["ldap://10.0.0.0"])
assert "ldap" in protocols
def test_infer_protocols_from_adcs_url(self, mock_bus, mock_state, mock_config):
"""_infer_protocols extracts ADCS from adcs:// URL."""
instance = NTLMRelay(bus=mock_bus, state=mock_state, config=mock_config)
protocols = instance._infer_protocols(["adcs://10.0.0.0"])
assert "adcs" in protocols
def test_infer_protocols_plain_ip_defaults_to_smb(self, mock_bus, mock_state, mock_config):
"""_infer_protocols defaults to SMB for plain IP addresses."""
instance = NTLMRelay(bus=mock_bus, state=mock_state, config=mock_config)
protocols = instance._infer_protocols(["10.0.0.0"])
assert "smb" in protocols
def test_infer_protocols_multiple_targets(self, mock_bus, mock_state, mock_config):
"""_infer_protocols handles multiple targets with different protocols."""
instance = NTLMRelay(bus=mock_bus, state=mock_state, config=mock_config)
protocols = instance._infer_protocols([
"smb://10.0.0.0",
"ldap://10.0.0.0",
"adcs://10.0.0.0",
])
assert "smb" in protocols
assert "ldap" in protocols
assert "adcs" in protocols
def test_infer_protocols_case_insensitive(self, mock_bus, mock_state, mock_config):
"""_infer_protocols is case insensitive."""
instance = NTLMRelay(bus=mock_bus, state=mock_state, config=mock_config)
protocols = instance._infer_protocols(["SMB://10.0.0.0", "LDAP://10.0.0.0"])
assert "smb" in protocols
assert "ldap" in protocols
def test_infer_protocols_empty_defaults_to_smb(self, mock_bus, mock_state, mock_config):
"""_infer_protocols defaults to SMB if protocols are empty."""
instance = NTLMRelay(bus=mock_bus, state=mock_state, config=mock_config)
protocols = instance._infer_protocols([])
assert protocols == {"smb"}
class TestParseRelayOutput:
"""Test relay output parsing for events."""
def test_parse_authenticated_successfully(self, mock_bus, mock_state, mock_config):
"""_parse_relay_output detects 'authenticated successfully' and emits CREDENTIAL_FOUND."""
instance = NTLMRelay(bus=mock_bus, state=mock_state, config=mock_config)
instance._running = True
with patch.object(instance.bus, 'emit') as mock_emit:
instance._parse_relay_output("SMB session opened. User authenticated successfully")
mock_emit.assert_called_once()
call_args = mock_emit.call_args
assert call_args[0][0] == "CREDENTIAL_FOUND"
assert call_args[0][1]["source_module"] == "ntlm_relay"
assert call_args[0][1]["credential_type"] == "relay_success"
def test_parse_authenticated_successfully_adds_to_list(self, mock_bus, mock_state, mock_config):
"""_parse_relay_output adds authenticated entry to _successful_relays."""
instance = NTLMRelay(bus=mock_bus, state=mock_state, config=mock_config)
instance._running = True
with patch.object(instance.bus, 'emit'):
instance._parse_relay_output("User authenticated successfully to DC")
assert len(instance._successful_relays) == 1
assert instance._successful_relays[0]["type"] == "auth_success"
def test_parse_sam_dump_detection(self, mock_bus, mock_state, mock_config):
"""_parse_relay_output detects SAM dump."""
instance = NTLMRelay(bus=mock_bus, state=mock_state, config=mock_config)
instance._running = True
with patch.object(instance.bus, 'emit'):
instance._parse_relay_output("Dumping SAM hashes from target")
# Should not crash, may log
def test_parse_adcs_certificate_obtained(self, mock_bus, mock_state, mock_config):
"""_parse_relay_output detects ADCS certificate and emits event."""
instance = NTLMRelay(bus=mock_bus, state=mock_state, config=mock_config)
instance._running = True
with patch.object(instance.bus, 'emit') as mock_emit:
instance._parse_relay_output("Certificate saved to file: cert.pem")
mock_emit.assert_called_once()
call_args = mock_emit.call_args
assert call_args[0][0] == "CREDENTIAL_FOUND"
assert call_args[0][1]["credential_type"] == "certificate"
assert call_args[0][1]["target_service"] == "adcs_relay"
def test_parse_adcs_certificate_base64(self, mock_bus, mock_state, mock_config):
"""_parse_relay_output detects ADCS certificate in base64 format."""
instance = NTLMRelay(bus=mock_bus, state=mock_state, config=mock_config)
instance._running = True
with patch.object(instance.bus, 'emit') as mock_emit:
instance._parse_relay_output("Certificate in base64 format:")
mock_emit.assert_called_once()
assert mock_emit.call_args[0][1]["credential_type"] == "certificate"
def test_parse_socks_connection(self, mock_bus, mock_state, mock_config):
"""_parse_relay_output detects SOCKS proxy connection."""
instance = NTLMRelay(bus=mock_bus, state=mock_state, config=mock_config)
instance._running = True
with patch.object(instance.bus, 'emit'):
instance._parse_relay_output("SOCKS proxy connection established")
# Should not crash, may log
def test_parse_empty_line_ignored(self, mock_bus, mock_state, mock_config):
"""_parse_relay_output ignores empty lines."""
instance = NTLMRelay(bus=mock_bus, state=mock_state, config=mock_config)
instance._running = True
with patch.object(instance.bus, 'emit'):
instance._parse_relay_output("")
# Should not crash or emit
def test_parse_case_insensitive(self, mock_bus, mock_state, mock_config):
"""_parse_relay_output matching is case insensitive."""
instance = NTLMRelay(bus=mock_bus, state=mock_state, config=mock_config)
instance._running = True
with patch.object(instance.bus, 'emit') as mock_emit:
instance._parse_relay_output("USER AUTHENTICATED SUCCESSFULLY to target")
mock_emit.assert_called_once()
class TestWriteTargetFile:
"""Test target file writing."""
def test_write_target_file_creates_file(self, mock_bus, mock_state, mock_config, tmp_path):
"""_write_target_file creates targets file with all targets."""
instance = NTLMRelay(bus=mock_bus, state=mock_state, config=mock_config)
instance._target_file = os.path.join(str(tmp_path), "targets.txt")
instance._targets = ["smb://10.0.0.0", "ldap://10.0.0.0"]
instance._write_target_file()
assert os.path.exists(instance._target_file)
with open(instance._target_file) as f:
content = f.read()
assert "smb://10.0.0.0" in content
assert "ldap://10.0.0.0" in content
def test_write_target_file_one_per_line(self, mock_bus, mock_state, mock_config, tmp_path):
"""_write_target_file writes one target per line."""
instance = NTLMRelay(bus=mock_bus, state=mock_state, config=mock_config)
instance._target_file = os.path.join(str(tmp_path), "targets.txt")
instance._targets = ["10.0.0.0", "10.0.0.0", "10.0.0.0"]
instance._write_target_file()
with open(instance._target_file) as f:
lines = f.read().strip().split('\n')
assert len(lines) == 3
def test_write_target_file_empty_targets(self, mock_bus, mock_state, mock_config, tmp_path):
"""_write_target_file handles empty targets list."""
instance = NTLMRelay(bus=mock_bus, state=mock_state, config=mock_config)
instance._target_file = os.path.join(str(tmp_path), "targets.txt")
instance._targets = []
instance._write_target_file()
assert os.path.exists(instance._target_file)
with open(instance._target_file) as f:
content = f.read()
assert content == ""
class TestResponderExclusions:
"""Test Responder exclusion coordination."""
def test_update_responder_exclusions_no_responder_mgr(self, mock_bus, mock_state, mock_config):
"""_update_responder_exclusions returns early if no responder_mgr."""
instance = NTLMRelay(bus=mock_bus, state=mock_state, config=mock_config)
instance._responder_mgr = None
# Should not crash
instance._update_responder_exclusions()
def test_update_responder_exclusions_calls_set_relay_targets(self, mock_bus, mock_state, mock_config):
"""_update_responder_exclusions calls responder_mgr.set_relay_targets with extracted IPs."""
instance = NTLMRelay(bus=mock_bus, state=mock_state, config=mock_config)
mock_responder_mgr = MagicMock()
instance._responder_mgr = mock_responder_mgr
instance._targets = ["smb://10.0.0.0", "ldap://10.0.0.0"]
instance._update_responder_exclusions()
mock_responder_mgr.set_relay_targets.assert_called_once()
called_ips = mock_responder_mgr.set_relay_targets.call_args[0][0]
assert "10.0.0.0" in called_ips
assert "10.0.0.0" in called_ips
def test_update_responder_exclusions_extracts_ip_from_url(self, mock_bus, mock_state, mock_config):
"""_update_responder_exclusions correctly extracts IP from full URLs."""
instance = NTLMRelay(bus=mock_bus, state=mock_state, config=mock_config)
mock_responder_mgr = MagicMock()
instance._responder_mgr = mock_responder_mgr
instance._targets = ["ldap://10.0.0.0:389/CN=Users"]
instance._update_responder_exclusions()
called_ips = mock_responder_mgr.set_relay_targets.call_args[0][0]
assert "10.0.0.0" in called_ips
def test_update_responder_exclusions_handles_plain_ip(self, mock_bus, mock_state, mock_config):
"""_update_responder_exclusions handles plain IP addresses."""
instance = NTLMRelay(bus=mock_bus, state=mock_state, config=mock_config)
mock_responder_mgr = MagicMock()
instance._responder_mgr = mock_responder_mgr
instance._targets = ["10.0.0.0"]
instance._update_responder_exclusions()
called_ips = mock_responder_mgr.set_relay_targets.call_args[0][0]
assert "10.0.0.0" in called_ips
def test_update_responder_exclusions_handles_exception(self, mock_bus, mock_state, mock_config):
"""_update_responder_exclusions catches and logs exceptions."""
instance = NTLMRelay(bus=mock_bus, state=mock_state, config=mock_config)
mock_responder_mgr = MagicMock()
mock_responder_mgr.set_relay_targets.side_effect = Exception("Test error")
instance._responder_mgr = mock_responder_mgr
# Should not crash
instance._update_responder_exclusions()
class TestAddTarget:
"""Test add_target functionality."""
def test_add_target_adds_new_target(self, mock_bus, mock_state, mock_config):
"""add_target adds new target to list."""
instance = NTLMRelay(bus=mock_bus, state=mock_state, config=mock_config)
result = instance.add_target("smb://10.0.0.0")
assert result is True
assert "smb://10.0.0.0" in instance._targets
def test_add_target_ignores_duplicate(self, mock_bus, mock_state, mock_config):
"""add_target returns False for duplicate target."""
instance = NTLMRelay(bus=mock_bus, state=mock_state, config=mock_config)
instance._targets = ["smb://10.0.0.0"]
result = instance.add_target("smb://10.0.0.0")
assert result is False
def test_add_target_writes_target_file(self, mock_bus, mock_state, mock_config, tmp_path):
"""add_target writes updated target file."""
instance = NTLMRelay(bus=mock_bus, state=mock_state, config=mock_config)
instance._target_file = os.path.join(str(tmp_path), "targets.txt")
with patch.object(instance, '_update_responder_exclusions'):
instance.add_target("smb://10.0.0.0")
assert os.path.exists(instance._target_file)
def test_add_target_updates_responder_exclusions(self, mock_bus, mock_state, mock_config):
"""add_target calls _update_responder_exclusions."""
instance = NTLMRelay(bus=mock_bus, state=mock_state, config=mock_config)
with patch.object(instance, '_update_responder_exclusions') as mock_update:
with patch.object(instance, '_write_target_file'):
instance.add_target("smb://10.0.0.0")
mock_update.assert_called_once()
class TestStartRelay:
"""Test start_relay functionality."""
def test_start_relay_not_running(self, mock_bus, mock_state, mock_config):
"""start_relay returns False if module not running."""
instance = NTLMRelay(bus=mock_bus, state=mock_state, config=mock_config)
instance._running = False
result = instance.start_relay(["10.0.0.0"])
assert result is False
def test_start_relay_already_running(self, mock_bus, mock_state, mock_config):
"""start_relay returns True if relay already active."""
instance = NTLMRelay(bus=mock_bus, state=mock_state, config=mock_config)
instance._running = True
instance._relay_active = True
mock_proc = MagicMock()
mock_proc.poll.return_value = None
instance._proc = mock_proc
result = instance.start_relay(["10.0.0.0"])
assert result is True
def test_start_relay_infers_protocols(self, mock_bus, mock_state, mock_config, tmp_path):
"""start_relay infers protocols from targets."""
instance = NTLMRelay(bus=mock_bus, state=mock_state, config=mock_config)
instance._running = True
instance._target_file = os.path.join(str(tmp_path), "targets.txt")
instance._loot_dir = str(tmp_path)
with patch('subprocess.Popen') as mock_popen:
mock_popen.return_value = MagicMock(poll=MagicMock(return_value=None), pid=1234)
with patch.object(instance, '_monitor_output'):
instance.start_relay(["smb://10.0.0.0", "ldap://10.0.0.0"])
assert "smb" in instance._protocols
assert "ldap" in instance._protocols
def test_start_relay_respects_provided_protocols(self, mock_bus, mock_state, mock_config, tmp_path):
"""start_relay uses provided protocols if given."""
instance = NTLMRelay(bus=mock_bus, state=mock_state, config=mock_config)
instance._running = True
instance._target_file = os.path.join(str(tmp_path), "targets.txt")
instance._loot_dir = str(tmp_path)
with patch('subprocess.Popen') as mock_popen:
mock_popen.return_value = MagicMock(poll=MagicMock(return_value=None), pid=1234)
with patch.object(instance, '_monitor_output'):
instance.start_relay(["10.0.0.0"], protocols={"ldap"})
assert instance._protocols == {"ldap"}
def test_start_relay_writes_target_file(self, mock_bus, mock_state, mock_config, tmp_path):
"""start_relay writes targets to file."""
instance = NTLMRelay(bus=mock_bus, state=mock_state, config=mock_config)
instance._running = True
instance._target_file = os.path.join(str(tmp_path), "targets.txt")
instance._loot_dir = str(tmp_path)
with patch('subprocess.Popen') as mock_popen:
mock_popen.return_value = MagicMock(poll=MagicMock(return_value=None), pid=1234)
with patch.object(instance, '_monitor_output'):
instance.start_relay(["10.0.0.0"])
assert os.path.exists(instance._target_file)
def test_start_relay_spawns_subprocess(self, mock_bus, mock_state, mock_config, tmp_path):
"""start_relay spawns ntlmrelayx subprocess."""
instance = NTLMRelay(bus=mock_bus, state=mock_state, config=mock_config)
instance._running = True
instance._target_file = os.path.join(str(tmp_path), "targets.txt")
instance._loot_dir = str(tmp_path)
with patch('subprocess.Popen') as mock_popen:
mock_popen.return_value = MagicMock(poll=MagicMock(return_value=None), pid=1234)
with patch.object(instance, '_monitor_output'):
instance.start_relay(["10.0.0.0"])
mock_popen.assert_called_once()
call_args = mock_popen.call_args
cmd = call_args[0][0]
assert "python3" in cmd
def test_start_relay_returns_false_if_process_dies(self, mock_bus, mock_state, mock_config, tmp_path):
"""start_relay returns False if subprocess exits immediately."""
instance = NTLMRelay(bus=mock_bus, state=mock_state, config=mock_config)
instance._running = True
instance._target_file = os.path.join(str(tmp_path), "targets.txt")
instance._loot_dir = str(tmp_path)
with patch('subprocess.Popen') as mock_popen:
mock_proc = MagicMock()
mock_proc.poll.return_value = 1 # Process exited
mock_proc.stderr.read.return_value = b"Error message"
mock_popen.return_value = mock_proc
result = instance.start_relay(["10.0.0.0"])
assert result is False
def test_start_relay_handles_file_not_found(self, mock_bus, mock_state, mock_config, tmp_path):
"""start_relay returns False if binary not found."""
instance = NTLMRelay(bus=mock_bus, state=mock_state, config=mock_config)
instance._running = True
instance._ntlmrelayx_binary = "/nonexistent/path"
instance._target_file = os.path.join(str(tmp_path), "targets.txt")
instance._loot_dir = str(tmp_path)
with patch('subprocess.Popen', side_effect=FileNotFoundError):
result = instance.start_relay(["10.0.0.0"])
assert result is False
def test_start_relay_sets_relay_active(self, mock_bus, mock_state, mock_config, tmp_path):
"""start_relay sets _relay_active to True on success."""
instance = NTLMRelay(bus=mock_bus, state=mock_state, config=mock_config)
instance._running = True
instance._target_file = os.path.join(str(tmp_path), "targets.txt")
instance._loot_dir = str(tmp_path)
with patch('subprocess.Popen') as mock_popen:
mock_popen.return_value = MagicMock(poll=MagicMock(return_value=None), pid=1234)
with patch.object(instance, '_monitor_output'):
instance.start_relay(["10.0.0.0"])
assert instance._relay_active is True
def test_start_relay_starts_monitor_thread(self, mock_bus, mock_state, mock_config, tmp_path):
"""start_relay starts output monitoring thread."""
instance = NTLMRelay(bus=mock_bus, state=mock_state, config=mock_config)
instance._running = True
instance._target_file = os.path.join(str(tmp_path), "targets.txt")
instance._loot_dir = str(tmp_path)
with patch('subprocess.Popen') as mock_popen:
mock_popen.return_value = MagicMock(poll=MagicMock(return_value=None), pid=1234)
with patch('threading.Thread') as mock_thread:
instance.start_relay(["10.0.0.0"])
mock_thread.assert_called_once()
thread_kwargs = mock_thread.call_args[1]
assert thread_kwargs['daemon'] is True
class TestStopRelay:
"""Test stop_relay functionality."""
def test_stop_relay_no_process(self, mock_bus, mock_state, mock_config):
"""stop_relay returns True if no process running."""
instance = NTLMRelay(bus=mock_bus, state=mock_state, config=mock_config)
instance._proc = None
result = instance.stop_relay()
assert result is True
assert instance._relay_active is False
def test_stop_relay_terminates_process(self, mock_bus, mock_state, mock_config):
"""stop_relay terminates running process."""
instance = NTLMRelay(bus=mock_bus, state=mock_state, config=mock_config)
mock_proc = MagicMock()
mock_proc.poll.return_value = None # Still running
instance._proc = mock_proc
instance.stop_relay()
mock_proc.terminate.assert_called_once()
mock_proc.wait.assert_called_once()
def test_stop_relay_kills_if_timeout(self, mock_bus, mock_state, mock_config):
"""stop_relay kills process if terminate times out."""
instance = NTLMRelay(bus=mock_bus, state=mock_state, config=mock_config)
mock_proc = MagicMock()
mock_proc.poll.return_value = None
mock_proc.terminate.side_effect = lambda: None
mock_proc.wait.side_effect = [
subprocess.TimeoutExpired("cmd", 5), # First wait times out (after terminate)
]
instance._proc = mock_proc
instance.stop_relay()
mock_proc.kill.assert_called_once()
def test_stop_relay_sets_proc_to_none(self, mock_bus, mock_state, mock_config):
"""stop_relay sets _proc to None."""
instance = NTLMRelay(bus=mock_bus, state=mock_state, config=mock_config)
mock_proc = MagicMock()
mock_proc.poll.return_value = None
instance._proc = mock_proc
instance.stop_relay()
assert instance._proc is None
def test_stop_relay_sets_relay_active_false(self, mock_bus, mock_state, mock_config):
"""stop_relay sets _relay_active to False."""
instance = NTLMRelay(bus=mock_bus, state=mock_state, config=mock_config)
instance._relay_active = True
mock_proc = MagicMock()
mock_proc.poll.return_value = None
instance._proc = mock_proc
instance.stop_relay()
assert instance._relay_active is False
class TestStatus:
"""Test status reporting."""
def test_status_when_not_running(self, mock_bus, mock_state, mock_config):
"""status() returns running=False when not started."""
instance = NTLMRelay(bus=mock_bus, state=mock_state, config=mock_config)
status = instance.status()
assert status["running"] is False
def test_status_when_running(self, mock_bus, mock_state, mock_config):
"""status() returns running=True when started."""
instance = NTLMRelay(bus=mock_bus, state=mock_state, config=mock_config)
instance._running = True
status = instance.status()
assert status["running"] is True
def test_status_includes_relay_info(self, mock_bus, mock_state, mock_config):
"""status() includes relay targets and protocols."""
instance = NTLMRelay(bus=mock_bus, state=mock_state, config=mock_config)
instance._running = True
instance._targets = ["smb://10.0.0.0"]
instance._protocols = {"smb", "ldap"}
instance._successful_relays = [{"type": "auth_success"}]
status = instance.status()
assert status["targets"] == ["smb://10.0.0.0"]
assert "smb" in status["protocols"]
assert "ldap" in status["protocols"]
assert status["successful_relays"] == 1
def test_status_includes_adcs_template(self, mock_bus, mock_state, mock_config):
"""status() includes ADCS template if set."""
instance = NTLMRelay(bus=mock_bus, state=mock_state, config=mock_config)
instance._adcs_template = "User"
status = instance.status()
assert status["adcs_template"] == "User"
def test_status_relay_active_checks_process(self, mock_bus, mock_state, mock_config):
"""status() checks if process is still alive for relay_active."""
instance = NTLMRelay(bus=mock_bus, state=mock_state, config=mock_config)
instance._running = True
instance._relay_active = True
mock_proc = MagicMock()
mock_proc.poll.return_value = None # Still running
mock_proc.pid = 1234
instance._proc = mock_proc
status = instance.status()
assert status["relay_active"] is True
assert status["relay_pid"] == 1234
def test_status_relay_inactive_if_process_dead(self, mock_bus, mock_state, mock_config):
"""status() reports relay_active=False if process exited."""
instance = NTLMRelay(bus=mock_bus, state=mock_state, config=mock_config)
instance._running = True
instance._relay_active = True
mock_proc = MagicMock()
mock_proc.poll.return_value = 1 # Process exited
instance._proc = mock_proc
status = instance.status()
assert status["relay_active"] is False
class TestConfigure:
"""Test module configuration."""
def test_configure_updates_binary_path(self, mock_bus, mock_state, mock_config):
"""configure() updates ntlmrelayx_binary."""
instance = NTLMRelay(bus=mock_bus, state=mock_state, config=mock_config)
new_binary = "/custom/ntlmrelayx.py"
instance.configure({"ntlmrelayx_binary": new_binary})
assert instance._ntlmrelayx_binary == new_binary
def test_configure_updates_adcs_template(self, mock_bus, mock_state, mock_config):
"""configure() updates ADCS template."""
instance = NTLMRelay(bus=mock_bus, state=mock_state, config=mock_config)
instance.configure({"adcs_template": "Computer"})
assert instance._adcs_template == "Computer"
def test_configure_updates_config_dict(self, mock_bus, mock_state, mock_config):
"""configure() updates the config dict."""
instance = NTLMRelay(bus=mock_bus, state=mock_state, config=mock_config)
instance.configure({"custom_key": "custom_value"})
assert instance.config["custom_key"] == "custom_value"