Python lives on the attacker host. Go lives on target. That split is consistent enough across modern offensive tooling that you can almost read it as a rule. Python’s runtime is big, conspicuous, and not typically installed on a corporate workstation; a Go binary is one static file the operator can drop, run, and leave behind without dragging an interpreter along with it. The tradeoff is exactly the one most operators want: write the tool on the attacker box in Go’s reasonably friendly syntax, cross-compile to the target’s architecture in seconds, and ship a self-contained executable.
The C2 ecosystem reflects this. Sliver’s implant is Go, Havoc has a Go-language agent option, Merlin is Go, Ligolo-ng is Go, Chisel is Go, Gobuster is Go. Pretty much every modern offensive utility that wasn’t already written in C or C++ before 2018 ended up in Go.
A small caveat about scope: the recent wave of ransomware-locker rewrites went to Rust rather than Go (ALPHV/BlackCat, RansomEXX, Hive’s 2022 rewrite). Rust gets chosen for the lockers specifically because of memory safety guarantees and a less-fingerprinted runtime. Go is the C2-agent and operator-tooling language; Rust is increasingly the implant-and-encryptor language for the actors who care about both. Both are statically linked, both cross-compile cleanly, and operators who can read one can usually read the other.
Cross-compilation, which is the actual selling point#
The thing that won Go the offensive market is GOOS and GOARCH. From a Kali host, you can produce a Windows x64 executable, a Linux MIPS binary for a router, a macOS ARM64 binary for an M-series Mac, or a Windows ARM64 binary for a Surface Pro X, with one command each:
# Windows x64
GOOS=windows GOARCH=amd64 go build -o loader.exe main.go
# Linux on a MIPS router
GOOS=linux GOARCH=mips go build -o implant main.go
# macOS Apple Silicon
GOOS=darwin GOARCH=arm64 go build -o agent main.goNo cross-compiler toolchain to install, no VM to spin up, no virtual target environment to set up. The Go compiler bundles everything it needs to emit a binary for any of its supported target triples, and the supported set covers basically every architecture an operator runs into in practice.
Hiding the console window on Windows#
A stock Go binary on Windows opens a cmd.exe window when it runs. That’s fine for a CLI tool and bad for an implant. The linker handles it through a -H subsystem flag passed via -ldflags:
go build -ldflags "-H=windowsgui -w -s" -o stealth.exe main.go-H=windowsguiswitches the PE subsystem to GUI, which suppresses the console window.-wstrips DWARF debug information.-sstrips the symbol table.
Together those flags shrink the binary by roughly 25% and remove the easiest targets for an analyst running strings against the file. They do not change what an EDR sees in memory at runtime, which is a different problem entirely.
Talking to the Windows API#
Go can call into the Win32 API without a C compiler. The syscall package is the older path and is still fine for basic work; the modern path is golang.org/x/sys/windows, which exposes a more complete and better-typed surface and gets updated to match new Windows APIs.
The minimal example (MessageBoxW):
package main
import (
"syscall"
"unsafe"
)
var (
user32 = syscall.NewLazyDLL("user32.dll")
procMessageBoxW = user32.NewProc("MessageBoxW")
)
func main() {
title, _ := syscall.UTF16PtrFromString("Caller")
text, _ := syscall.UTF16PtrFromString("Go native WinAPI call")
procMessageBoxW.Call(
0,
uintptr(unsafe.Pointer(text)),
uintptr(unsafe.Pointer(title)),
0,
)
}Same calling convention scales up to the rest of the Win32 surface. Process injection, memory allocation, thread creation, registry manipulation, named-pipe IPC, all of it sits on the same LazyDLL / NewProc / Call pattern. The unsafe.Pointer ergonomics get rough in places (Win32 was designed for C, not Go), but it works.
A focused port scanner using goroutines#
Goroutines are the other reason offensive tooling reaches for Go. Concurrent network operations that would be a select-and-async-callback nightmare in most languages reduce to a go keyword in front of a function call. A workable TCP port scanner fits in a screen:
package main
import (
"fmt"
"net"
"sync"
"time"
)
func scan(target string, port int, results chan<- int, wg *sync.WaitGroup) {
defer wg.Done()
address := fmt.Sprintf("%s:%d", target, port)
conn, err := net.DialTimeout("tcp", address, 1*time.Second)
if err == nil {
results <- port
conn.Close()
}
}
func main() {
target := "192.168.1.1"
results := make(chan int, 100)
var wg sync.WaitGroup
for port := 1; port <= 1024; port++ {
wg.Add(1)
go scan(target, port, results, &wg)
}
go func() {
wg.Wait()
close(results)
}()
for port := range results {
fmt.Printf("[+] Open: %d\n", port)
}
}Nmap is still the better tool for serious scanning because it does TCP-state tricks, OS fingerprinting, and timing tuning that this loop doesn’t. But the workable-in-50-lines version of “sweep port 22 across a /24” is genuinely a quick afternoon in Go, and if the operator needs the scanner to do something non-standard (a custom timing pattern, a specific TLS handshake on each port, a protocol probe nmap doesn’t know about), writing it from scratch in Go is faster than scripting nmap into doing it.
The tools you’ll actually run into#
The Go-based offensive tools that get used on engagements in 2026:
- Sliver . Bishop Fox’s C2 with a Go implant. The default modern open-source choice for general operations.
- Ligolo-ng . TUN-based pivoting using gVisor’s netstack and yamux multiplexing. Has eaten Chisel’s high-end use cases for serious pivoting.
- Chisel . Fast TCP-over-HTTP/websocket tunnel. Still active; useful for quick port forwards during initial foothold work.
- Gobuster . Directory, DNS, and vhost brute forcing.
- Merlin . HTTP/2-based C2. Still gets commits but most operator mindshare has moved to Sliver, Mythic, and Havoc.
- Garble . Go binary obfuscator. Sliver bundles it. More useful than UPX for evasion because UPX itself triggers detections; garble obfuscates symbols and string tables in place.
What EDR sees in a Go binary#
Static detection of Go binaries is its own arms race. Go’s runtime leaves distinctive artifacts on disk that EDR vendors and YARA-rule writers key on:
.gopclntab: the Go runtime’s program counter line table. Distinctive structure that announces “this is a Go binary” before anything else gets parsed..go.buildinfo: contains the magic string\xff Go buildinf:plus the module path, the Go version, and (in newer Go versions) the full module dependency graph. An analyst can read most of the build context off this section without any reverse engineering.- Runtime symbol patterns:
runtime.morestack,runtime.newproc, the goroutine scheduler’s exported functions. All of them are statically present even in a stripped binary because Go’s runtime is statically linked into every executable. - String tables: Go’s
.rodatasection contains the source paths of every file the binary was compiled from, unless you specifically strip them with-trimpath.
The practical implication is that even a -ldflags "-s -w" build is trivially identifiable as Go via the section layout. Hiding that you wrote a tool in Go is meaningfully harder than hiding what the tool does once running. The mitigations:
-trimpathremoves the source-file paths from the binary, which kills the easiest analyst win.garble -literals -tiny -seed=random buildrewrites the symbols and obfuscates string literals. This makes the binary harder to read but does not change the section-level fingerprint.- For genuine stealth against Go-aware EDR, the only real answer is moving off Go for the parts of your tool that have to survive scrutiny. The C2 agent can be Go because the network behavior is the thing the EDR is mainly watching for; a dropper meant to evade memory forensics is a different language choice.
The trade#
Go gives an operator deployment ergonomics that nothing else in the offensive language space matches: cross-compile in one command, ship a single binary, no runtime to install on target. The cost is that Go binaries announce themselves as Go binaries to any analyst who knows what .go.buildinfo is, which by 2026 is most of them. That trade is the right one for the C2-agent and pivot-tool category, where the network behavior is the primary detection surface and the static fingerprint is secondary. It’s the wrong trade for tooling where being identifiable as Go-built is itself a problem, and that’s the niche Rust has been quietly absorbing.