I have just finished taking apart a Windows crackme protected by a commercial code-virtualization engine. It had a packer, an integrity watchdog, an anti-single-step trap and a decoy that lies to you only when your serial is correct. The writeup runs in two parts, the packer and the watchdog and then the virtual machine and the keygen, and the short version is that I got a keygen out of it.
The next thing on my desk is the other side of that, which is my own protector, in Go, for Go programs.
Somebody is going to ask why that needs to exist at all, and it is a fair question. A Go program compiles to a native PE the same as a C program does, the same machine code goes to the same loader on the same operating system, so pointing an existing packer at it ought to just work. It mostly does not, and having spent a while now finding out where it stops working, the reasons are specific enough to be worth writing down while they are still fresh. None of this is a product announcement. It is what I have learned so far, and there are places below where I am still guessing.
Both are native code, so what is actually different
“Go is different” is easy to say and easy to wave a hand at, so here are two binaries instead.
Both are Windows x64. One is a hello world cross-compiled with go1.26.0, which prints a string and does nothing else. The other is the unpacked crackme from the other post, written in C, which reads two lines of input, hashes a name, and validates a serial against it. If anything the comparison is unfair to my argument, because the C program is the one doing real work.
I built the Go side twice on purpose. Comparing a default go build against a shipped C binary proves nothing, since the default build is carrying its symbol table and eight sections of DWARF while the crackme has no symbol table at all. The column that matters is the stripped one, -ldflags="-s -w", which is what anyone actually ships.
| Go, default | Go, -s -w | C crackme | |
|---|---|---|---|
| File size | 2,467,328 | 1,677,824 | 284,160 |
| PE sections | 16 | 8 | 9 |
.text | 670,257 | 670,257 | 89,680 |
| Functions with unwind info | 1,619 | 1,619 | 422 |
| Imported DLLs | kernel32.dll | kernel32.dll | KERNEL32.dll |
Stripping takes out 789,504 bytes and eight sections, all of it debug information, and it leaves .text exactly where it was with all 1,619 functions still in it. So a one-line Go program, stripped as hard as the toolchain will strip it, is 5.9 times the size of a C program that does something. That is the scheduler, the garbage collector, the allocator and the rest of the runtime, statically linked into every binary you will ever build. runtime.schedule, runtime.newproc, runtime.gcStart, runtime.mallocgc and runtime.findfunc are all sitting in a program whose source is one line long.
Then there is the part with no C equivalent at all. In the default build the linker names its bounds:
1
2
1400dd928 R runtime.pclntab
140182e33 R runtime.epclntab
That is 677,131 bytes of function metadata, describing where every function starts and ends, which source line each instruction came from, and where the pointers live in every stack frame.
Now strip the binary and go looking for it by its header magic instead of its symbol:
1
2
3
hello_default.exe pclntab (go1.20+) at file offset 0xdcb28, describing 1951 functions
hello_stripped.exe pclntab (go1.20+) at file offset 0xdcb28, describing 1951 functions
C crackme no Go function table
Same offset, same table, same 1,951 functions. -s -w does not go near it, because it is not debug information in the first place. In the stripped binary it accounts for 40% of the file, and the garbage collector and the scheduler are reading it while the program runs. This is the thing I would most want a C-minded protector author to sit with for a minute. A C binary’s metadata is something the linker throws away and nothing misses. Go’s is load-bearing, it cannot be stripped, and if your tool breaks it the program does not limp, it dies.
The third difference shows up in ordinary compiled code. Here is the start of main.main in that hello world, straight out of go tool objdump:
TEXT main.main(SB)
0x1400a49a0 493b6610 CMPQ SP, 0x10(R14)
0x1400a49a4 7646 JBE 0x1400a49ec
0x1400a49a6 55 PUSHQ BP
R14 holds the current goroutine, and offset 0x10 into type g is stackguard0, which the runtime source describes as “the stack pointer compared in the Go stack growth prologue”. Which means the first thing almost every Go function does, before anything you wrote, is check its own stack pointer against a bound it reads out of the goroutine and jump away to grow the stack if it is running short. A C prologue does nothing of the sort, and has no reason to, because a C thread’s stack is a fixed mapping the kernel handed it and nothing is ever going to move it.
So: a large runtime living in the binary with its own threads, two fifths of the file given over to a table that runtime reads continuously, and stacks that are bounded, checked constantly and liable to be moved out from under you. All three of those constrain what a protector is allowed to do, which is the rest of this post.
What the standard techniques do to that
The crackme I just finished used most of the standard toolkit, so it is a convenient list to walk. A few of them survive intact. Most degrade in some way, and two of them will break the program somewhere a long way from where you made the mistake.
Compress and encrypt the sections, unpack at runtime
This one broadly works, and it is about the only thing on the list I would keep unchanged. The stub runs first, inflates the real image and jumps to the entry point, all of it before a single Go instruction executes, so there is nothing awake yet to object.
What changes is what you get for it. Forty per cent of what you are compressing is function metadata, and most of the rest is a megabyte of runtime that is byte-identical in every Go binary built with that toolchain, so the ratio looks excellent and the actual secret being protected is a rounding error. And the moment it is unpacked in memory the program describes itself in full to anyone reading.
Destroy the import table and resolve APIs by hash
In the crackme this was one of the most effective defences. A dumped rebuild had no usable IAT, so it crashed in the CRT before reaching main, and that alone forced me to work on top of the packer rather than underneath it.
Against Go it buys much less. The Go binary imports exactly one DLL, the same as the C one, because the runtime does not use the C runtime library and calls the OS itself. It also resolves plenty of its own functions after startup: runtime.windowsFindfunc in os_windows.go calls GetProcAddress by name for things like ProcessPrng and several Nt* completion-packet functions. So from the import table alone a Go binary already looks like something that resolves its dependencies at runtime, which is roughly what the technique was trying to achieve in the first place. There is not much table left to hide, and the analyst was never going to learn much from it.
Steal the entry point
The one that taught me the most, mostly because it is so cheap and so standard that you would never think to question it.
The packer copies the first instructions of the original entry point into its own stub, junks them in place, and after unpacking runs its private copy before jumping to entry plus N. A dumped rebuild is missing its first instructions, and anyone watching for the transfer into original code sees it land mid-function.
On amd64 the Go entry point is nine bytes and a jump:
TEXT _rt0_amd64(SB),NOSPLIT,$-8
MOVQ 0(SP), DI // argc
LEAQ 8(SP), SI // argv
JMP runtime·rt0_go(SB)
Those two instructions read argc and argv off the stack at the exact stack pointer the loader handed the thread. Relocate them into a stub and they read the stub’s frame. So there is nothing worth stealing before the jump, and anything meaningful means stealing into rt0_go:
MOVQ $runtime·g0(SB), DI
LEAQ (-64*1024)(SP), BX
MOVQ BX, g_stackguard0(DI)
MOVQ BX, g_stackguard1(DI)
MOVQ BX, (g_stack+stack_lo)(DI)
MOVQ SP, (g_stack+stack_hi)(DI)
That sets the bounds of g0, the scheduler’s stack, from wherever the stack pointer is at that instant, and stackguard0 is the value that prologue above compares against for the rest of the process’s life. Run it on the packer’s stack and every stack check from then on is measured against a stack the runtime is no longer using. The failure surfaces later, in an unrelated goroutine, as a stack growth into nowhere or a morestack on g0 fatal error. runtime.badmorestackg0 in proc.go prints the g0 bounds and the stack pointer before it throws, precisely because the only way to arrive there is with those two disagreeing.
A few instructions further, rt0_go calls runtime·wintls and then does this:
// store through it, to make sure it works
get_tls(BX)
MOVQ $0x123, g(BX)
MOVQ runtime·m0+m_tls(SB), AX
CMPQ AX, $0x123
JEQ 2(PC)
CALL runtime·abort(SB)
It writes a known value through its thread-local storage slot, reads it back, and aborts if it does not match. Go verifies its own TLS setup and kills the process on the spot when it is wrong.
After that come runtime·check, runtime·args, runtime·osinit, runtime·schedinit, runtime·newproc with the address of main, and runtime·mstart. Keep stepping past all of that and at some point you have stopped stealing a prologue and started transplanting the runtime’s entire bring-up sequence into a context it was never written for, which is a lot of risk to take on in exchange for making a dumped binary slightly less convenient.
Insert code into functions after linking
Junk instructions, control flow flattening, inline hooks, anything that changes the bytes inside a function after the linker has run.
This is the one that worries me most, because it is where Go stops crashing and starts corrupting instead. The line tables and the stack maps are read through pcvalue(f funcInfo, off uint32, targetpc uintptr, ...), keyed by the target PC relative to the start of the function. Insert four bytes anywhere in the middle and every entry past that point is now describing a different instruction than the one it was written for. The garbage collector reads that map to decide which slots in a frame hold pointers, so it gets told about a slot that is no longer a pointer, or never hears about one that is.
Nothing faults at that moment. What you get instead is a collector treating an integer as an address, and then a crash somewhere else entirely, quite a bit later, in code that did nothing wrong. Debugging that from the inside would be miserable, and debugging it as the person who shipped the protector would be worse.
And the tables are checked at startup, so the coarser version of the mistake is caught immediately: moduledataverify1 validates the header magic, that the function table is sorted by entry address, and that the recorded minimum and maximum program counters match the computed text bounds, throwing invalid function symbol table, invalid runtime symbol table or minpc or maxpc invalid. This is not theoretical. Running binutils strip on a Go PIE binary has been reported to corrupt that table and produce a fatal error before the heap is even initialised. A tool that only wanted to remove symbols killed the process during runtime init.
Move code into a private allocation
Virtualised handlers, decrypted stubs, trampolines, anything that executes from memory the packer allocated rather than from the image.
A Go binary carries a table saying which function covers which address, and findfunc(pc) returns a zero funcInfo for anything outside a module’s range. The runtime calls it on garbage collection stack scans, on tracebacks, on panics and on preemption. Asynchronous preemption opens with it:
1
2
3
4
5
f := findfunc(pc)
if !f.valid() {
// Not Go code.
return false, 0
}
In a C program, code running out of the packer’s own allocation is just code. In a Go program it is a hole in the map, so the runtime cannot scan it for pointers, cannot unwind through it, and will not preempt a thread that is sitting in it. Which means moving code out of registered text is not really an obfuscation decision in Go, it is a bug, and the garbage collector decides when you find out about it.
Virtualise a function into bytecode
This is the feature worth the most and the one I actually want, so I would rather be precise about what it costs than talk myself into it.
The interpreter has to live inside registered text with valid stack maps, which immediately rules out the usual approach of dropping it into a fresh allocation and jumping there. That part is just work. The part that is not just work is a cost that never shows up as a crash. A thread grinding through a long stretch of interpreter is a thread the runtime keeps declining to preempt, because isAsyncSafePoint will not sign off on a program counter it cannot reason about, and preemption is how the collector gets threads to stop. So virtualising a hot function buys you protection and costs you garbage collection latency, and that second half is a trade a C protector never has to think about at all.
Run an integrity watchdog on a background thread
In the crackme this was the single most effective defence I met. A thread continuously hashed the code section and called TerminateProcess the instant a byte changed, which silently defeated the textbook patch.
Getting that to work in Go is awkward in two separate ways. The section it wants to hash is 670 KB in a hello world rather than 90 KB, and almost all of it is runtime that is identical across every binary built with that toolchain, so most of what you are guarding is not yours and not secret. The harder problem is the thread. Anything the Go runtime did not create has no g attached to it and cannot simply call into Go code, and while the cgo callback machinery exists for exactly that, it is blunt about being used too early: needm in proc.go prints fatal error: cgo callback before cgo call when the scheduler is not up yet. Which leaves you writing the watchdog as something that never touches Go at all, or as a goroutine the scheduler can deschedule whenever it feels like it. I have not decided which of those is less bad.
Use exceptions as control flow
UD2, deliberate access violations, and a vectored handler that treats the fault as a jump. Good trick in a C binary. In the crackme it was used in the unpacker and inside the VM.
Go gets there first, by construction. On Windows the runtime calls AddVectoredExceptionHandler with the first-handler flag set, then adds two continue handlers, and its handler treats access violations, in-page errors, integer and floating point faults, breakpoints and illegal instructions as Go faults, because nil pointer dereference and stack overflow are implemented on top of hardware faults. Your deliberate UD2 arrives at a handler that is already at the front of the queue and that concludes, correctly by its own rules, that the program has crashed.
Encrypt the strings
C strings are NUL terminated, so a string encryptor can walk read-only data, find the boundaries, and re-encode each one in place.
Go strings are a pointer and a length, and the data is packed with no terminators at all. Here is a real slice of the read-only blob from that hello world, with the program’s only string in the middle of it:
1
fsrmsse3avx2bmi1bmi2timehellofalse<nil>Errorwriteclose
There are no boundaries in there to find. A generic encryptor either walks straight past it or corrupts a shared blob that hundreds of unrelated string headers are pointing into, and the second outcome is the one that will reach a user. String protection in Go has to happen somewhere the lengths are still known, which means at compile time, which is exactly where the existing Go tools chose to sit.
Wipe the headers to defeat dumping
Worth less here than almost anywhere, because the metadata describes itself and does not need your PE headers to be found. Mandiant’s GoReSym works on exactly that principle, going and locating the tables in stripped and mangled binaries. If your anti-dump strategy assumes the person dumping needs your headers, it was written for a C binary and you have inherited the assumption without checking it.
What falls out of all this
Most of it reduces to one constraint, which is that every executing program counter has to stay inside a function the runtime has a record of, and the tables have to keep describing the code that is actually there.
Hold to that and stolen OEP is gone, so is relocating code into private allocations, so is inserting bytes into functions after the linker has run, and so is anything that rewrites text behind the runtime’s back. What survives is virtualising the body of a function you have chosen, keeping the interpreter inside registered text, and building checks the runtime cannot tell apart from ordinary work. That is less room than a C protector gets, and I do not yet know how much of the difference is recoverable, which is the honest answer to the obvious question.
It does explain the shape of the tooling that already exists, though, and I did not see this until I had the constraint written down. garble wraps the compiler and transforms source during the build, and gobfuscate works earlier still. Both of them operate before the linker has written the tables, at the one point in the pipeline where a Go program is genuinely easy to modify because there is nothing yet to invalidate. That is not a lack of ambition on anyone’s part. It is where the language leaves the door open.
The attacker I am designing against is the one I was last month, which is a strange position to write from and a useful one. What worked on the crackme was backward slicing over a full instruction and memory trace, single-step tracing with the trap flag scrubbed out of the value the program reads, a live value reader driven by remote threads, and simply harvesting constants, because every mixing constant in that binary sat in plain sight as an imul operand.
None of the headline features are what cost me time, either. It was the register renaming, pushing values through the stack and byte-swapping them so that no logical value ever sits still in one register, and that beat every black-box method I had. It also happens to be indifferent to what language the target was written in, and it never moves a single instruction outside registered text, which makes it the first thing on my list rather than the last.
More when there is something to show.
Discussion
Powered by Disqus