OSCE3 study materials
OSCE3 study resources

Keygen judgement on a stripped static Go license checker

OSCE3 · Put your knowledge to work in context.

All case studies

Keygen judgement on a stripped static Go license checker

A vendor ships licensecheck, a 9.4 MB statically linked Go 1.21 binary (linux/amd64) built with -ldflags="-s -w". It reads /etc/license.dat, validates a 32-byte token against a 12-character tenant ID supplied as argv[1], and prints an activation tag that a remote licensing server hashes and compares against its own computation. You cannot patch the server, and you must produce a tag for tenant ID ACME-EU-0042. Startup issues ptrace(PTRACE_TRACEME) and exits if it fails; a watchdog goroutine aborts validation if wall-clock time around the check deviates by more than 400 ms. The binary is statically linked, so LD_PRELOAD is useless. The host has no Go toolchain and no internet, but you have gdb, radare2, Python, and can attach with Frida. The customer will not permit binary patches on the production copy - the tag must come from your analysis.

1. Plan the anti-debug workaround. Why do gdb and the usual LD_PRELOAD tricks both fail here, and what actually works on a static Go binary?

Read the worked answer

Both failures are structural, not incidental.

  • ptrace(PTRACE_TRACEME) only fails if a tracer exists at the moment of the call. Launching under gdb guarantees that. The workaround is to let the program start untraced and attach afterwards, or to patch the check.
  • The 400 ms watchdog is measured on wall clock by a goroutine, and gdb stops all threads. Any breakpoint held for more than 400 ms trips the abort. So attach-after-start only works if you patch the timing comparison first, or set a breakpoint that is hit and released inside the budget.
  • LD_PRELOAD is dead because the binary is static: there is no dynamic loader, so there is no libc symbol to interpose. clock_gettime cannot be hooked that way.

What works, in order of cost:

  1. Patch the ptrace site to xor eax, eax; ret (or NOP the syscall after setting the number) at file offset, work from a copy, and diff behaviour to confirm only that path changed.
  2. Patch the result comparison of the elapsed value rather than the clock itself - the watchdog compares milliseconds against 400 and branches; flip that branch.
  3. Frida attaches after startup, so TRACEME already returned; but Stalker will blow the timing budget, so only use it on a copy with the watchdog branch patched.

Since patching production is off the table, you patch only your analysis copy and use it to derive the tag - the artifact you deliver is data, not a modified binary.

2. The binary is stripped with -s -w. How do you still recover function boundaries and the validation logic, and where do you look for the tenant-ID handling?

Read the worked answer

Stripping a Go binary does not remove everything - -s -w drops the symbol table and DWARF, but the runtime needs pclntab to unwind stacks and produce panics, so it survives.

Recovery:

  • Parse pclntab with GoReSym or redress to get function names and boundaries back: main.main, main.validateToken, main.deriveTag, plus the runtime functions you may need to neutralise such as the nanotime wrapper.
  • With names restored, import them into radare2 (aa plus a symbol script) and read the validation from the disassembly rather than from a symbol table.

Finding the tenant-ID path:

  • Go strings are (pointer, length) pairs, not NUL-terminated, and they live in one large blob. In the disassembly, a string reference is lea rax, [rip+off] immediately paired with a length constant, so search for the length value next to the pointer - searching for the text alone and looking for an xref usually fails because the blob is pooled.
  • Look for the argv[1] handling in main.main: a len(os.Args) != 2 check, then the slice index, then a call into validate. That call graph is the map of the program.
  • Note the Go internal ABI: arguments go in registers (RAX, RBX, RCX, RDI, RSI, R8-R11), not on the stack, and returns of a string come back as pointer in RAX and length in RBX. Any Frida hook must read those registers, not the C calling convention.

Output: a small map of four or five functions you care about, not a full decompilation.

3. The server hashes whatever tag you produce, so patching the success branch is worthless. How do you get a valid tag for ACME-EU-0042, and what observation decides whether replay or keygen is the right path?

Read the worked answer

The decision hinges on one experiment: run the binary twice with the same tenant ID and a known-good token and diff the tags.

Case A - the tags are identical (deterministic tag). Replay is legitimate. Break after validation succeeds and before the tag is printed, dump the Go string: pointer in RAX, length in RBX, read length bytes from the pointer. Feed it the tenant ID of interest and capture the tag. The server hashes the same deterministic value, so it matches without you ever understanding the crypto. This is the fast path and it is not cheating - the tag is the artifact.

Case B - the tags differ (timestamp, nonce, sequence). Replay dies and you must keygen: extract the embedded key and reimplement the MAC in Python. Do not reverse the cipher algebraically - hook the block function, dump the expanded key schedule and the round constants, and identify whether it is stock AES-GCM, HMAC-SHA256 or a modified construction by the constants alone. Then reproduce it offline for ACME-EU-0042.

Checks before committing: confirm the tag depends only on tenant ID and token (not on hostname/MAC - rerun on a second host), and confirm the watchdog has not silently altered the output path you are dumping.

Rule of thumb: one differential run costs two minutes and decides between a 20-minute replay and a four-hour reimplementation. Do it before touching the cipher.