Implicit memory aliasing in Go is a subtle bug where multiple closures, goroutines, or pointer operations unintentionally share the same memory address — usually caused by taking the address of a loop variable inside a for loop. The result is that every iteration overwrites the same memory location, and all closures that captured the variable’s address see only the final value.
This bug is so common in Go codebases that go vet flags it, staticcheck reports it as SA4003, and dedicated SAST tools track it under the rule name ImplicitMemoryAliasing. It is also the subject of a language-level fix in Go 1.22, which changed loop variable semantics specifically to eliminate this class of bug.
What Is Implicit Memory Aliasing?
In Go (before 1.22), a for range loop declares a single loop variable that is reused across every iteration. When you take the address of that variable (&item) or capture it in a closure, you capture a pointer to that single location — not to the current value at that point in the loop.
// VULNERABLE — classic implicit memory aliasing
items := []string{"alpha", "beta", "gamma"}
ptrs := make([]*string, len(items))
for i, item := range items {
ptrs[i] = &item // ← All ptrs point to the SAME variable
}
// After the loop:
fmt.Println(*ptrs[0]) // "gamma" — not "alpha"!
fmt.Println(*ptrs[1]) // "gamma" — not "beta"!
fmt.Println(*ptrs[2]) // "gamma" — correct
Why? Because item is a single variable that is overwritten on each iteration. &item always refers to the same memory address. After the loop completes, that address holds the last assigned value — "gamma" — regardless of which pointer you dereference.
This is implicit memory aliasing: the code looks like each pointer points to its respective element, but they are actually aliased to the same address.
Why It’s a Security and Correctness Risk
Implicit memory aliasing causes:
- Logic errors: code that appears to store different values per iteration stores the same final value for all
- Race conditions in goroutines: when closures are run concurrently, the loop variable is written by the range loop while goroutines are reading from the same address — a data race
- Incorrect data propagation: API response handlers, configuration loaders, and concurrent request processors that capture loop variables can process only the last item
- Authentication context leakage: in web servers that process multiple requests in a loop, captured context variables can leak across request handlers if the loop variable is captured by reference
Common Patterns
Pattern 1: Goroutine Capturing Loop Variable
The most dangerous form involves concurrent execution. The loop variable is captured by the goroutine’s closure, but the goroutine may not execute until the loop has already advanced:
// VULNERABLE — goroutine captures loop variable by reference
items := []string{"alpha", "beta", "gamma"}
var wg sync.WaitGroup
for _, item := range items {
wg.Add(1)
go func() {
defer wg.Done()
fmt.Println(item) // ← Which value? Depends on scheduling
}()
}
wg.Wait()
// Possible outputs:
// gamma
// gamma
// gamma
//
// (All goroutines see the final value, or race between iterations)
The go func() closure captures item by reference. The goroutine is scheduled to run concurrently, but the main goroutine continues the loop immediately. By the time the goroutine runs fmt.Println(item), item may already hold "beta" or "gamma".
With the Go race detector enabled (go test -race), this produces a data race warning:
WARNING: DATA RACE
Read at 0x00c000014078 by goroutine 7:
main.main.func1()
main.go:10 +0x3c
Previous write at 0x00c000014078 by main goroutine:
main.main()
main.go:7 +0xa0
Pattern 2: Storing Pointer to Loop Variable
// VULNERABLE — pointer to loop variable stored in slice
type Server struct {
Host string
Port int
}
servers := []Server{
{"web1.internal", 8080},
{"web2.internal", 8081},
{"web3.internal", 8082},
}
activeServers := make([]*Server, len(servers))
for i, s := range servers {
activeServers[i] = &s // ← &s is always the same address
}
// All pointers point to the same address
fmt.Println(activeServers[0] == activeServers[1]) // true — same pointer!
fmt.Println(activeServers[0].Host) // "web3.internal" — not "web1.internal"
A function that receives []*Server and processes each server independently will process "web3.internal" three times.
Pattern 3: Method Call on Loop Variable Pointer
// VULNERABLE — method receiver captures loop variable address
type Handler struct {
Name string
}
func (h *Handler) Process() {
fmt.Println("Processing:", h.Name)
}
handlers := []Handler{{"auth"}, {"api"}, {"static"}}
funcs := make([]func(), len(handlers))
for i, h := range handlers {
funcs[i] = h.Process // ← h.Process captures &h — same address each iteration
}
for _, f := range funcs {
f()
}
// All print: "Processing: static" (the last value of h)
Three Fixes for Implicit Memory Aliasing
Fix 1: Create a Loop-Local Copy (Classic Go Fix)
Create a new variable inside the loop body to break the aliasing:
// FIXED — loop-local copy
items := []string{"alpha", "beta", "gamma"}
ptrs := make([]*string, len(items))
for i, item := range items {
item := item // ← Shadows outer item; creates a new variable per iteration
ptrs[i] = &item
}
fmt.Println(*ptrs[0]) // "alpha" ✓
fmt.Println(*ptrs[1]) // "beta" ✓
fmt.Println(*ptrs[2]) // "gamma" ✓
The := inside the loop body creates a new item variable for each iteration. The pointer now captures a different address each time.
For goroutines, the same pattern applies:
// FIXED — goroutine with loop-local copy
var wg sync.WaitGroup
for _, item := range items {
item := item // New variable per iteration
wg.Add(1)
go func() {
defer wg.Done()
fmt.Println(item) // Captures the loop-local copy
}()
}
wg.Wait()
// Prints: alpha, beta, gamma (in any order, but each correct value)
Fix 2: Pass the Value as a Parameter
For goroutines, pass the loop variable as a function parameter instead of capturing it:
// FIXED — pass as parameter
var wg sync.WaitGroup
for _, item := range items {
wg.Add(1)
go func(s string) { // Parameter s is a copy
defer wg.Done()
fmt.Println(s)
}(item) // item's current value is passed at call time
}
wg.Wait()
Function parameters are always passed by value (for value types). This is the idiomatic Go fix for goroutine loop variable capture.
Fix 3: Range Over Index (Pointer to Slice Element)
If you need a pointer to the actual slice element (not the loop copy), range over the index and take the address from the original slice:
// FIXED — take address from original slice element
for i := range servers {
activeServers[i] = &servers[i] // Address of slice element, not loop variable
}
fmt.Println(activeServers[0].Host) // "web1.internal" ✓
fmt.Println(activeServers[1].Host) // "web2.internal" ✓
fmt.Println(activeServers[2].Host) // "web3.internal" ✓
This approach is correct when you want pointers to the elements of the original slice. Note: if the slice is later modified or reallocated (via append), these pointers may become invalid. Use this pattern only when the slice’s backing array is stable.
Go 1.22: Loop Variable Semantics Changed
Go 1.22 (released February 2024) changed the loop variable semantics for for range loops to address this class of bugs:
In Go 1.22, each iteration of a for loop creates new variables. This change eliminates the “loop variable capture” bug.
With Go 1.22, the original buggy code works correctly without any explicit fix:
// In Go 1.22+: works correctly without the item := item fix
for i, item := range items {
ptrs[i] = &item // Each iteration's item is now a distinct variable
}
How to Enable Go 1.22 Loop Semantics
If your go.mod file specifies go 1.22 or later, the new loop semantics apply automatically:
// go.mod
module example.com/myapp
go 1.22
If you are on Go 1.21 or earlier, the new semantics are not active and the classic fix (shadowing the variable inside the loop) remains necessary.
What This Means for Older Codebases
- Code compiled with
go 1.22or later: loop variable capture bugs are eliminated by the language - Code compiled with
go 1.21or earlier: bugs remain; all three fixes above are necessary - SAST tools should still flag these patterns if the
go.modversion is below 1.22, because the code will behave incorrectly if compiled with an older toolchain or if thegodirective is downgraded
How SAST Tools Detect Implicit Memory Aliasing
go vet
Go’s built-in vet tool detects loop variable capture in goroutines:
go vet ./...
./main.go:10:6: loop variable item captured by func literal
go vet is fast and reliable for the goroutine case, but it does not detect all aliasing patterns — it focuses specifically on closures and goroutines that capture loop variables.
staticcheck (SA4003)
staticcheck provides more comprehensive analysis:
go install honnef.co/go/tools/cmd/staticcheck@latest
staticcheck ./...
main.go:15:18: loop variable s captured by func literal (SA4003)
staticcheck’s SA4003 check covers both goroutine closures and direct pointer capture patterns.
golangci-lint
golangci-lint aggregates multiple linters, including a copyloopvar linter:
# .golangci.yml
linters:
enable:
- copyloopvar # Detects loop variable capture issues
- exportloopref # For older codebases on Go < 1.22
golangci-lint run ./...
Offensive360 SAST (ImplicitMemoryAliasing Rule)
Offensive360’s Go analysis engine detects implicit memory aliasing through interprocedural data-flow analysis, tracking pointer provenance from loop variable declarations through to goroutine closures, slice assignments, and method value captures. The rule ImplicitMemoryAliasing surfaces in the Knowledge Base with the full data-flow path and recommended fix.
Unlike go vet, Offensive360 detects the pattern across package boundaries — where the loop variable is passed to a function in another package that stores a pointer to it.
Real-World Examples
HTTP Handler Registration
A common pattern in Go HTTP servers that can trigger this bug:
// VULNERABLE — Go < 1.22 only
type Route struct {
Pattern string
Handler http.HandlerFunc
}
routes := []Route{
{"/api/users", handleUsers},
{"/api/orders", handleOrders},
{"/api/products", handleProducts},
}
mux := http.NewServeMux()
for _, route := range routes {
mux.HandleFunc(route.Pattern, func(w http.ResponseWriter, r *http.Request) {
route.Handler(w, r) // ← Captures &route — all call handleProducts
})
}
In Go < 1.22, all three routes call handleProducts because route is overwritten on each iteration and all closures capture the same address. In Go 1.22+, this works correctly.
Fix for Go < 1.22:
for _, route := range routes {
route := route // Loop-local copy
mux.HandleFunc(route.Pattern, func(w http.ResponseWriter, r *http.Request) {
route.Handler(w, r)
})
}
Concurrent Worker Pool
// VULNERABLE — Go < 1.22
tasks := getTaskList()
results := make(chan string, len(tasks))
for _, task := range tasks {
go func() {
result := processTask(task) // ← task may have advanced
results <- result
}()
}
Fix:
for _, task := range tasks {
go func(t Task) {
result := processTask(t)
results <- result
}(task) // Pass task as argument, not captured reference
}
Test Table Loops
Test tables are particularly prone to this bug in parallel subtests:
// VULNERABLE — Go < 1.22 (parallel subtests)
testCases := []struct {
input string
expected string
}{
{"hello", "HELLO"},
{"world", "WORLD"},
{"foo", "FOO"},
}
for _, tc := range testCases {
t.Run(tc.input, func(t *testing.T) {
t.Parallel() // ← Parallel subtests run concurrently
result := strings.ToUpper(tc.input) // tc may have advanced
assert.Equal(t, tc.expected, result)
})
}
Fix for Go < 1.22:
for _, tc := range testCases {
tc := tc // Loop-local copy — necessary for parallel subtests in Go < 1.22
t.Run(tc.input, func(t *testing.T) {
t.Parallel()
result := strings.ToUpper(tc.input)
assert.Equal(t, tc.expected, result)
})
}
This pattern was so ubiquitous in Go test code that the Go 1.22 loop variable fix was specifically motivated by the test table parallelism use case.
Detecting Implicit Memory Aliasing in Your Codebase
To audit your Go codebase for implicit memory aliasing:
# 1. Run go vet (built-in, detects goroutine closures)
go vet ./...
# 2. Run staticcheck (broader coverage)
staticcheck ./...
# 3. Run with race detector (finds actual races at runtime)
go test -race ./...
# 4. Check go.mod version — if >= 1.22, loop variable capture is fixed by language
grep "^go " go.mod
# 5. Run golangci-lint with copyloopvar enabled
golangci-lint run --enable copyloopvar ./...
For a complete interprocedural analysis that catches cross-package pointer aliasing patterns, a dedicated SAST tool with Go language support is more thorough than any linter.
Prevention Summary
| Scenario | Fix |
|---|---|
Go 1.22+ in go.mod | No fix needed — language handles it |
| Go < 1.22, goroutine closure | Pass loop variable as goroutine parameter |
| Go < 1.22, pointer to loop var | Shadow with item := item inside loop body |
| Go < 1.22, parallel subtests | Shadow with tc := tc before t.Parallel() |
| Go < 1.22, method value capture | Shadow with h := h before method value expression |
| Pointer to slice element needed | Use &slice[i] via index, not &rangeVar |
The simplest migration path for a Go codebase is to upgrade the go directive in go.mod to 1.22 or later. This eliminates the entire class of implicit memory aliasing bugs in for range loops without requiring per-loop fixes.
OWASP and CWE Mapping
Implicit memory aliasing that creates concurrent data races maps to:
- CWE-667: Improper Locking (concurrent access to shared loop variable without synchronization)
- CWE-362: Concurrent Execution Using Shared Resource with Improper Synchronization (Race Condition)
When the aliasing causes incorrect data to be processed (e.g., wrong authentication context propagated to a handler), it may also map to CWE-284: Improper Access Control.
Offensive360 SAST detects implicit memory aliasing in Go alongside CORS misconfigurations, injection vulnerabilities, and 60+ other security rules. Scan your Go codebase for $500 or view the full Go rule list in the Knowledge Base.