Last Updated: October 8, 2025
Status: ✅ Production Ready
This document provides the comprehensive technical security architecture of Garble's obfuscation mechanisms. It details each security component with its implementation, threat model, and operational characteristics.
- Executive Summary
- Seed & Nonce Architecture
- Runtime Metadata Hardening (Feistel Cipher)
- Literal Obfuscation (ASCON-128 + Simple)
- Reflection Control & Reversibility
- Build Cache Encryption (ASCON-128)
- Control-Flow Obfuscation
- Threat Model & Mitigation Matrix
- Security Limitations & Roadmap
- References & Resources
| Component | Status | Implementation |
|---|---|---|
| Runtime Metadata | ✅ Deployed | 4-round Feistel cipher with per-function tweak |
| Literal Protection | ✅ Deployed | ASCON-128 inline + reversible simple obfuscator |
| Name Hashing | ✅ Deployed | SHA-256 with per-build nonce mixing |
| Reflection Oracle | ✅ Mitigated | Empty by default; opt-in via -reversible |
| Cache Encryption | ✅ Deployed | ASCON-128 at rest with authentication |
| Control-Flow | Multiple modes available; default off |
- Per-Build Uniqueness: Every build uses a cryptographically random nonce mixed with the seed, ensuring symbol names and keys differ even with identical source code (unless explicitly reproduced).
- Metadata Hardening: Runtime function tables are encrypted with format-preserving Feistel encryption; decryption happens transparently at runtime via injected helpers.
- Literal Protection: Strings and constants are encrypted inline using NIST-standard ASCON-128 or multi-layer reversible transforms.
- Reflection Suppression: Original identifier names are omitted from binaries by default, eliminating the reverse-engineering oracle.
- Cache Security: Build artifacts are encrypted at rest; tampering is detected via authentication tags.
Provide reproducible yet secure randomness for all obfuscation operations, with explicit control over determinism vs. per-build uniqueness.
┌─────────────────────────────────────────────────────────────┐
│ Build Time - Entropy Flow │
├─────────────────────────────────────────────────────────────┤
│ │
│ User Seed (optional) Build Nonce │
│ -seed=<base64> or random GARBLE_BUILD_NONCE=<base64> │
│ │ │ │
│ ├─ SHA-256 ─────► 32 bytes │ │
│ │ │ │
│ └──────────────┬───────────────┘ │
│ │ │
│ ┌─────────▼─────────┐ │
│ │ combineSeedAndNonce() │
│ │ SHA-256(seed || nonce) │
│ └─────────┬─────────┘ │
│ │ │
│ ▼ │
│ Combined Hash (32 bytes) │
│ │ │
│ ┌───────────────┼───────────────┐ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ Name Hashing Feistel Keys Literal Keys │
│ (per-package) (4x32-bit) (per-literal) │
│ │
└─────────────────────────────────────────────────────────────┘
- Format: Base64-encoded bytes or literal
random - Processing: Hashed to 32 bytes via SHA-256 for uniform entropy
- Default: Unset (entropy derived from nonce only)
- Random Mode: Generates 32 cryptographic random bytes; printed to stderr for reproducibility
- Format: Base64-encoded 32 bytes (no padding)
- Default: Randomly generated per build
- Printed: When randomly generated (format:
-nonce chosen at random: <base64>) - Purpose: Ensures different builds produce different hashes even with identical seed and source
func combineSeedAndNonce(seed, nonce []byte) []byte {
h := sha256.New()
if len(seed) > 0 {
h.Write(seed)
}
if len(nonce) > 0 {
h.Write(nonce)
}
return h.Sum(nil) // Always 32 bytes
}To achieve bit-for-bit identical builds:
- Fix the seed:
-seed=<known-base64-value> - Fix the nonce:
GARBLE_BUILD_NONCE=<known-base64-value> - Use identical source code and Go toolchain version
Without fixing both: Each build is cryptographically unique by design.
main.go: Flag parsing, seed generation, nonce printinghash.go:combineSeedAndNonce(),seedHashInput(),hashWithPackage()
Encrypt function entry point offsets in the runtime symbol table (pclntab) to prevent static analysis from mapping function metadata to code locations.
┌────────────────────────────────────────────────────────────────────┐
│ Build Time (Linker Stage) │
├────────────────────────────────────────────────────────────────────┤
│ │
│ 1. Garble exports LINK_SEED (base64 32-byte seed) │
│ Environment: LINK_SEED=<base64> │
│ │
│ 2. Linker derives 4 round keys via SHA-256 │
│ for i = 0 to 3: │
│ h = SHA256(seed || byte(i)) │
│ keys[i] = uint32(h[0:4]) // First 4 bytes │
│ │
│ 3. For each function in pclntab: │
│ entryOff = function's entry point offset (32-bit) │
│ nameOff = function's name offset (32-bit, used as tweak) │
│ │
│ // 4-round Feistel network encryption │
│ left = uint16(entryOff >> 16) │
│ right = uint16(entryOff & 0xFFFF) │
│ │
│ for round = 0 to 3: │
│ f = feistelRound(right, nameOff, keys[round]) │
│ left, right = right, left ^ f │
│ │
│ encrypted = (uint32(left) << 16) | uint32(right) │
│ write encrypted value to binary │
│ │
└────────────────────────────────────────────────────────────────────┘
↓ Binary Written ↓
┌─────────────────────────────────────────────────────────────────────┐
│ Runtime (Program Execution) │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ 1. Injected decryption functions (//go:nosplit) │
│ │
│ var linkFeistelKeys = [4]uint32{...} // Embedded at compile │
│ │
│ //go:nosplit │
│ func linkFeistelRound(right uint16, tweak, key uint32) uint16 │
│ │
│ //go:nosplit │
│ func linkFeistelDecrypt(value, tweak uint32) uint32 │
│ │
│ 2. Patched funcInfo.entry() method │
│ │
│ func (f funcInfo) entry() uintptr { │
│ // Decrypt on-the-fly │
│ decrypted := linkFeistelDecrypt(f.entryoff, uint32(f.nameOff))│
│ return f.datap.textAddr(decrypted) │
│ } │
│ │
│ 3. Transparent to application code │
│ ✓ Stack traces work normally │
│ ✓ runtime.Caller() returns correct information │
│ ✓ runtime.FuncForPC() resolves function names │
│ ✓ No performance impact (nosplit prevents extra stack frames) │
│ │
└─────────────────────────────────────────────────────────────────────┘
F(right uint16, tweak uint32, key uint32) → uint16:
x = uint32(right)
x ^= tweak // Mix in per-function uniqueness
x += key × 0x9e3779b1 + 0x7f4a7c15 // Golden ratio constant
x = rotateLeft32(x ^ key, key & 31) // Key-dependent rotation
x ^= x >> 16 // Mixing step
return uint16(x)
| Property | Value | Security Benefit |
|---|---|---|
| Key Size | 4×32-bit (128-bit total) | Cryptographically strong key space |
| Rounds | 4 | Sufficient for strong diffusion |
| Tweak | nameOff (32-bit) | Each function encrypted uniquely |
| Diffusion | ~100% | All output bits depend on all input bits |
| Non-linearity | High | Resistant to linear cryptanalysis |
| Performance | <10 CPU cycles | Negligible runtime overhead |
- Provable Security: Well-studied structure used in DES, Blowfish, Twofish
- Perfect Reversibility: Same structure for encryption/decryption (reverse key order)
- Format-Preserving: 32-bit input → 32-bit output (maintains offset size)
- Tweak Support: nameOff parameter ensures unique encryption per function
- Fast: Simple bitwise operations, no memory allocations
// Injected into runtime/symtab.go
//go:nosplit // CRITICAL: Prevents stack frame creation
func linkFeistelRound(right uint16, tweak uint32, key uint32) uint16 {
x := uint32(right)
x ^= tweak
x += key*0x9e3779b1 + 0x7f4a7c15
n := key & 31
tmp := x ^ key
if n != 0 {
x = (tmp << n) | (tmp >> (32 - n))
} else {
x = tmp
}
x ^= x >> 16
return uint16(x)
}
//go:nosplit // CRITICAL: Maintains runtime.Caller() correctness
func linkFeistelDecrypt(value, tweak uint32) uint32 {
left := uint16(value >> 16)
right := uint16(value)
// Decrypt in reverse (rounds 3, 2, 1, 0)
for round := len(linkFeistelKeys) - 1; round >= 0; round-- {
key := linkFeistelKeys[round]
f := linkFeistelRound(left, tweak, key)
left, right = right^f, left
}
return (uint32(left) << 16) | uint32(right)
}
// Patched entry() method
func (f funcInfo) entry() uintptr {
decrypted := linkFeistelDecrypt(f.entryoff, uint32(f.nameOff))
return f.datap.textAddr(decrypted)
}Critical Design Note: The //go:nosplit directive prevents Go from creating stack frames for these functions. This is essential because:
runtime.Caller()counts stack frames to determine call depth- Extra frames would break stack trace accuracy
- Functions remain invisible to the call stack mechanism
Applied to cmd/link/internal/ld/pcln.go:
// Read LINK_SEED from environment
seedBase64 := os.Getenv("LINK_SEED")
seedBytes, _ := base64.StdEncoding.DecodeString(seedBase64)
var seed [32]byte
copy(seed[:], seedBytes)
// Derive round keys
keys := [4]uint32{}
for i := 0; i < 4; i++ {
h := sha256.New()
h.Write(seed[:])
h.Write([]byte{byte(i)})
sum := h.Sum(nil)
keys[i] = binary.LittleEndian.Uint32(sum[:4])
}
// Encrypt all entryOff values
for _, offset := range entryOffLocations {
entryOff := binary.LittleEndian.Uint32(data[offset:])
nameOff := binary.LittleEndian.Uint32(data[offset+4:])
encrypted := feistelEncrypt(entryOff, nameOff, keys)
binary.LittleEndian.PutUint32(data[offset:], encrypted)
}feistel_test.go: Encrypt/decrypt symmetry, edge casesfeistel_integration_test.go: Full round-trip validation
testdata/script/runtime_metadata.txtar: Validates:- ✅
runtime.FuncForPC()works with encrypted metadata - ✅ Stack traces via
runtime.Caller()remain correct - ✅ Method name resolution functions properly
- ✅ Reflection type names accessible
- ✅
| Attack | Mitigation | Residual Risk |
|---|---|---|
| Static pclntab enumeration | Entry offsets encrypted | Dynamic tracing observes actual behavior |
| Pattern matching | Per-function tweak breaks patterns | - |
| Brute force key recovery | 128-bit keyspace infeasible | - |
| Known-plaintext attack | Tweak ensures unique ciphertexts | Requires recovering seed |
feistel.go: Core encryption/decryption logicruntime_patch.go: Runtime injectioninternal/linker/linker.go: LINK_SEED environment setupinternal/linker/patches/go1.25/0003-add-entryOff-encryption.patch: Linker modifications
Transform string and numeric literals into encrypted or obfuscated expressions that resolve at runtime, preventing static extraction via tools like strings or gostringungarbler.
Garble employs multiple obfuscation strategies selected randomly per literal for defense-in-depth:
- ASCON-128 (Primary): NIST Lightweight Cryptography standard, authenticated encryption
- Simple Reversible (Secondary): Multi-layer XOR with position-dependent keys and byte chaining
┌─────────────────────────────────────────────────────────────────┐
│ ASCON-128 Inline Encryption Flow │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Build Time (internal/literals/ascon_obfuscator.go): │
│ │
│ 1. Generate random key (16 bytes) and nonce (16 bytes) │
│ key := cryptoRand.Read(16) │
│ nonce := cryptoRand.Read(16) │
│ │
│ 2. Encrypt plaintext with ASCON-128 │
│ ciphertext||tag = AsconEncrypt(key, nonce, plaintext) │
│ // Output: ciphertext + 16-byte authentication tag │
│ │
│ 3. Generate inline decryption code (~2947 bytes) │
│ • Complete ASCON implementation inlined │
│ • No imports required (crypto-free binary) │
│ • Unique decryptor per literal │
│ │
│ Runtime (generated code): │
│ │
│ data, ok := _garbleAsconDecrypt( │
│ []byte{...key...}, │
│ []byte{...nonce...}, │
│ []byte{...ciphertext||tag...} │
│ ) │
│ if !ok { │
│ panic("garble: authentication failed") │
│ } │
│ // data now contains decrypted plaintext │
│ │
└─────────────────────────────────────────────────────────────────┘
| Property | Value | Benefit |
|---|---|---|
| Security Level | 128-bit | NIST-approved security strength |
| Key Size | 128-bit (16 bytes) | Strong key space |
| Nonce Size | 128-bit (16 bytes) | Unique per literal |
| Tag Size | 128-bit (16 bytes) | Detects tampering |
| Authentication | Yes (AEAD) | Integrity + confidentiality |
| Performance | Lightweight | Optimized for constrained environments |
┌─────────────────────────────────────────────────────────────────┐
│ Simple Reversible Multi-Layer Obfuscation │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Build Time (internal/literals/simple.go): │
│ │
│ 1. Generate random nonce (8 bytes) and key (len(data) bytes) │
│ │
│ 2. Select random operators for layers │
│ op1 = random(XOR, ADD, SUB) │
│ op2 = random(XOR, ADD, SUB) │
│ │
│ 3. For each byte i in plaintext: │
│ // Layer 1: Position-dependent key │
│ posKey = key[i] ^ byte(i*7+13) // Prime mixing │
│ layer1 = data[i] ^ posKey │
│ │
│ // Layer 2: Nonce mixing │
│ layer2 = layer1 OP1 nonce[i % len(nonce)] │
│ │
│ // Layer 3: Byte chaining (if not first byte) │
│ if i > 0: │
│ layer2 = layer2 OP2 (obfuscated[i-1] >> 3) │
│ │
│ obfuscated[i] = layer2 │
│ │
│ Runtime (generated code): │
│ │
│ // Reverse the layers in opposite order │
│ for i := 0; i < len(data); i++ { │
│ // Reverse layer 3 (chain dependency) │
│ if i > 0 { │
│ data[i] = data[i] REVERSE_OP2 (prevTemp >> 3) │
│ } │
│ // Reverse layer 2 (nonce) │
│ data[i] = data[i] REVERSE_OP1 nonce[i % len(nonce)] │
│ // Reverse layer 1 (position key) │
│ posKey := key[i] ^ byte(i*7+13) │
│ data[i] = data[i] ^ posKey │
│ } │
│ │
└─────────────────────────────────────────────────────────────────┘
- Layers: 3 (position-key XOR, nonce mixing, byte chaining)
- Nonce: 8 bytes per literal (prevents cross-build correlation)
- Operators: Random selection (XOR/ADD/SUB) per layer
- Reversibility: Fully reversible (supports
garble reverse) - Performance: Fast for small literals
Implemented in internal/literals/obfuscators.go:
// Approximate selection probabilities:
// - ASCON-128: ~60% of literals (strong encryption)
// - Simple: ~40% of literals (performance, diversity)Selection factors:
- Literal size (ASCON preferred for larger literals)
- Performance requirements (simple for hot paths)
- Build randomness (varies per build for diversity)
const VERSION = "1.0" // ✅ Rewritten to var + obfuscated when no compile-time dependency exists
const SIZE = len(VERSION) // ⚠️ Must stay const (array length)
const CaseLabel = "case-only" // ⚠️ Must stay const (switch label)Reason: Garble rewrites string constants into vars when they are only used at runtime. Values that participate in compile-time contexts (array lengths, iota arithmetic, case clauses, etc.) must remain constants to keep the program valid and may stay in plaintext.
Status: ✅ Fully Protected since October 2025
Go's -ldflags -X flag allows injecting string values at link time:
go build -ldflags="-X main.apiKey=sk_live_51234567890abcdefABCDEF"Traditional Vulnerability: These strings appear in plaintext in the binary, easily extractable with strings or hex editors.
Garble Protection Pipeline:
┌──────────────────────────────────────────────────────────────┐
│ Phase 1: FLAG SANITIZATION (main.go) │
├──────────────────────────────────────────────────────────────┤
│ Input: -ldflags="-X main.apiKey=sk_live_51234567890..." │
│ └─────────┬────────┘ │
│ Extracted & cached │ │
│ ▼ │
│ sharedCache.LinkerInjectedStrings["main.apiKey"] = │
│ "sk_live_51234567890abcdefABCDEF" │
│ │ │
│ Sanitized flag rewritten │ │
│ ▼ │
│ Output: -ldflags="-X main.apiKey=" ← Empty to linker! │
│ │
│ ✅ Go toolchain NEVER sees the original value │
└──────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────┐
│ Phase 2: RUNTIME INJECTION (transformer.go) │
├──────────────────────────────────────────────────────────────┤
│ During package compilation, Garble injects: │
│ │
│ func init() { │
│ apiKey = <obfuscated_literal>("sk_live_51234567...")> │
│ } │
│ │
│ ✅ Uses identical obfuscation as normal string literals │
└──────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────┐
│ Phase 3: ENCRYPTION (literals.go) │
├──────────────────────────────────────────────────────────────┤
│ Value encrypted with ASCON-128 (~60%) or Simple (~40%): │
│ │
│ • ASCON: AES-like encryption + inline decrypt function │
│ • Simple: XOR + shuffle + split + index remapping │
│ │
│ ✅ Binary contains only ciphertext + decrypt code │
└──────────────────────────────────────────────────────────────┘
Supported Formats:
# All three -X formats are protected:
-ldflags="-X=main.version=1.0"
-ldflags="-X main.version=1.0"
-ldflags="-X \"main.message=hello world\""Security Guarantees:
| Attack Vector | Normal Build | Garble Build |
|---|---|---|
strings binary | grep apiKey |
❌ Plaintext found | ✅ Not found |
| Static analysis | ❌ Immediate extraction | |
| Hex editor search | ❌ Visible bytes | ✅ Only ciphertext |
| Memory dump (runtime) |
Real-World Example:
package main
var apiKey = "default-key" // Will be replaced via -ldflags
// Build without Garble
$ go build -ldflags="-X main.apiKey=sk_live_ABC123"
$ strings binary | grep sk_live
sk_live_ABC123 ← Exposed!
// Build with Garble
$ garble -literals build -ldflags="-X main.apiKey=sk_live_ABC123"
$ strings binary | grep sk_live
(no results) ← Protected!
// But runtime still works:
$ ./binary
Using API key: sk_live_ABC123 ← Decrypted at runtime ✅Implementation Details:
main.go:sanitizeLinkerFlags()- extracts and sanitizes flagstransformer.go:injectLinkerVariableInit()- generates init() functioninternal/literals/literals.go:Builder.ObfuscateStringLiteral()- encrypts value- Tests:
testdata/script/ldflags.txtar- end-to-end verification
| Feature | Status | Notes |
|---|---|---|
| String literals | ✅ Obfuscated | ASCON + simple mix |
| Numeric literals | ✅ Obfuscated | When -literals enabled |
| Byte slices | ✅ Obfuscated | Treated as literals |
| Const expressions | Safe string consts are rewritten; compile-time contexts remain const | |
| -ldflags -X strings | ✅ Covered | Sanitised at flag parse; runtime decrypt |
| Irreversible simple | Currently uses reversible path |
internal/literals/ascon.go: Core ASCON-128 implementationinternal/literals/ascon_inline.go: Inline code generatorinternal/literals/ascon_obfuscator.go: Obfuscator integrationinternal/literals/simple.go: Simple reversible obfuscatorinternal/literals/obfuscators.go: Selection strategy- Tests:
ascon_test.go,simple_test.go,ascon_integration_test.go
Eliminate the "reflection oracle" that leaked obfuscated-to-original identifier mappings, while preserving opt-in support for debugging workflows via garble reverse.
┌──────────────────────────────────────────────────────────────┐
│ Default Mode (Secure) │
│ garble build │
├──────────────────────────────────────────────────────────────┤
│ │
│ reflect.go: reflectMainPostPatch() │
│ │
│ if !flagReversible { │
│ _originalNamePairs = []string{} // EMPTY │
│ } │
│ │
│ Binary Contents: │
│ ✓ Obfuscated names only │
│ ✓ No original identifier mapping │
│ ✓ Reflection still works (with obfuscated names) │
│ ✓ No reverse-engineering oracle │
│ ✗ garble reverse not supported │
│ │
└──────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────┐
│ Reversible Mode (Debug/Staging) │
│ garble -reversible build │
├──────────────────────────────────────────────────────────────┤
│ │
│ reflect.go: reflectMainPostPatch() │
│ │
│ if flagReversible { │
│ _originalNamePairs = []string{ │
│ "ObfName1", "OrigName1", │
│ "ObfName2", "OrigName2", │
│ ... │
│ } // POPULATED │
│ } │
│ │
│ Binary Contents: │
│ ✓ Obfuscated names │
│ ✓ Original names embedded (mapping array) │
│ ✓ garble reverse supported │
│ ⚠️ Reverse-engineering oracle present │
│ ⚠️ Reduced security (explicit trade-off) │
│ │
└──────────────────────────────────────────────────────────────┘
// reflect.go - Security-first approach
func reflectMainPostPatch(file []byte, lpkg *listedPackage, pkg pkgCache) []byte {
obfVarName := hashWithPackage(lpkg, "_originalNamePairs")
namePairs := fmt.Appendf(nil, "%s = []string{", obfVarName)
// Default: Keep array empty (no name leakage)
if !flagReversible {
return bytes.Replace(file, namePairs, namePairs, 1)
}
// Reversible mode: Populate mapping for garble reverse
keys := slices.Sorted(maps.Keys(pkg.ReflectObjectNames))
namePairsFilled := bytes.Clone(namePairs)
for _, obf := range keys {
namePairsFilled = fmt.Appendf(namePairsFilled, "%q, %q,",
obf, pkg.ReflectObjectNames[obf])
}
return bytes.Replace(file, namePairs, namePairsFilled, 1)
}| Aspect | Default Mode | -reversible Mode |
|---|---|---|
_originalNamePairs |
Empty array | Populated with mappings |
| Original names in binary | ✅ Not present | ❌ Embedded in plaintext |
| Reflection functionality | ✅ Works (obfuscated names) | ✅ Works (obfuscated names) |
garble reverse |
❌ Not supported | ✅ Supported |
| Reverse engineering oracle | ✅ Eliminated | ❌ Present (by design) |
| Security level | 🔒 High | 🔓 Medium (trade-off) |
Production Builds:
garble build # Default: maximum securityDevelopment/Staging:
garble -reversible build # Enable debugging support
garble reverse binary < stack_trace.txtThe -reversible flag is propagated to the linker via environment variable:
GARBLE_LINK_REVERSIBLE=true # When -reversible is set
GARBLE_LINK_REVERSIBLE=false # When -reversible is not setreflect.go:reflectMainPostPatch()- Core logicmain.go: Flag definition and linker environment setup
Encrypt Garble's persistent build cache to prevent offline analysis of obfuscation metadata, import paths, and build artifacts.
┌─────────────────────────────────────────────────────────────────┐
│ Cache Encryption Flow (ASCON-128) │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Write Path (cache_pkg.go: computePkgCache): │
│ │
│ 1. Serialize pkg cache to gob │
│ var buf bytes.Buffer │
│ gob.NewEncoder(&buf).Encode(pkgCache) │
│ plaintext := buf.Bytes() │
│ │
│ 2. Derive encryption key from seed │
│ key = SHA256(seed || "garble-cache-encryption-v1") │
│ key = key[0:16] // 128-bit ASCON key │
│ │
│ 3. Encrypt with ASCON-128 │
│ nonce := cryptoRand.Read(16) // Random per cache entry │
│ ciphertext||tag = AsconEncrypt(key, nonce, plaintext) │
│ │
│ 4. Write to disk │
│ format: [16-byte nonce][ciphertext][16-byte tag] │
│ path: $GARBLE_CACHE/<action-id> │
│ │
│ Read Path (cache_pkg.go: loadPkgCache): │
│ │
│ 1. Read encrypted cache from disk │
│ data := readFile($GARBLE_CACHE/<action-id>) │
│ │
│ 2. Check if encrypted (has seed) │
│ if seed := cacheEncryptionSeed(); seed != nil { │
│ // Decrypt path │
│ } else { │
│ // Legacy plaintext gob fallback │
│ } │
│ │
│ 3. Extract components │
│ nonce := data[0:16] │
│ ciphertext_and_tag := data[16:] │
│ │
│ 4. Derive same key and decrypt │
│ key = SHA256(seed || "garble-cache-encryption-v1")[0:16] │
│ plaintext, ok := AsconDecrypt(key, nonce, ciphertext_and_tag)│
│ │
│ 5. Verify authentication tag │
│ if !ok { │
│ // Tag mismatch: cache corrupted or tampered │
│ return nil // Triggers rebuild │
│ } │
│ │
│ 6. Deserialize gob │
│ gob.NewDecoder(bytes.NewReader(plaintext)).Decode(&pkgCache)│
│ │
└─────────────────────────────────────────────────────────────────┘
func deriveCacheKey(seed []byte) []byte {
// Domain separation for cache encryption
h := sha256.New()
h.Write(seed)
h.Write([]byte("garble-cache-encryption-v1"))
digest := h.Sum(nil)
return digest[0:16] // 128-bit ASCON-128 key
}┌─────────────────────────────────────────────────────────┐
│ Encrypted Cache Entry │
├─────────────────────────────────────────────────────────┤
│ Bytes 0-15: Random nonce (16 bytes) │
│ Bytes 16-N-16: Encrypted pkg cache (variable length) │
│ Bytes N-16-N: Authentication tag (16 bytes) │
└─────────────────────────────────────────────────────────┘
Cache encryption is enabled by default when:
- A seed is available (
-seedflag or inherited) -no-cache-encryptflag is NOT present
# Encrypted cache (default with seed)
garble -seed=<base64> build
# Explicitly disable encryption
garble -seed=<base64> -no-cache-encrypt build
# No encryption (no seed)
garble build # Cache remains plaintext| Cache Type | Location | Encrypted | Lifetime |
|---|---|---|---|
| Persistent | $GARBLE_CACHE/<action-id> |
✅ Yes (when enabled) | Permanent until trimmed |
| Shared | $GARBLE_SHARED (temp) |
❌ No | Deleted after build |
Design Rationale:
- Persistent cache: Long-lived, disk-resident → encrypted to protect offline analysis
- Shared cache: Ephemeral, process-local → plaintext for performance, cleaned automatically
ASCON-128's authentication tag provides cryptographic verification:
- Valid tag: Cache decrypts successfully
- Invalid tag: Decryption fails → treated as cache miss → rebuild triggered
- No crash: Corruption degrades gracefully to rebuild
Legacy plaintext caches are automatically detected and read:
func decodePkgCacheBytes(data []byte) (pkgCache, error) {
if seed := cacheEncryptionSeed(); len(seed) > 0 {
// Try ASCON decryption
return decryptCacheIntoShared(data, seed)
}
// Fallback: plaintext gob
var cache pkgCache
gob.NewDecoder(bytes.NewReader(data)).Decode(&cache)
return cache, nil
}| Property | Value | Benefit |
|---|---|---|
| Algorithm | ASCON-128 AEAD | NIST-approved authenticated encryption |
| Key Size | 128-bit | Strong security margin |
| Nonce | 128-bit random | Unique per cache entry |
| Authentication | 128-bit tag | Detects tampering |
| Domain Separation | "garble-cache-encryption-v1" | Prevents key reuse attacks |
| Attack | Mitigation | Result |
|---|---|---|
| Offline cache analysis | Encrypted with ASCON-128 | Plaintext metadata inaccessible |
| Cache tampering | Authentication tag verification | Corruption detected, rebuild triggered |
| Cache poisoning | Tag forgery requires key | Infeasible (128-bit security) |
| Key recovery | Seed never stored in cache | Attacker needs build-time seed |
cache_ascon.go:deriveCacheKey(),encryptCacheWithASCON(),decryptCacheIntoShared()cache_pkg.go:computePkgCache(),loadPkgCache(),decodePkgCacheBytes()main.go: Seed and-no-cache-encryptflag handling
Transform control-flow structures to increase complexity and hinder static analysis, making it harder to understand program logic.
| Mode | Behavior | Use Case |
|---|---|---|
| off (default) | No transformation | Standard builds |
| directives | Only functions with //garble:controlflow |
Selective protection |
| auto | All eligible functions except //garble:nocontrolflow |
Broad protection with escape hatch |
| all | Every function | Maximum obfuscation |
┌─────────────────────────────────────────────────────────────────┐
│ Control-Flow Obfuscation Decision Tree │
├─────────────────────────────────────────────────────────────────┤
│ │
│ For each function: │
│ │
│ 1. Check mode │
│ if mode == off: │
│ return (no transformation) │
│ │
│ 2. Check directives │
│ if mode == directives: │
│ if function has //garble:controlflow: │
│ transform() │
│ else: │
│ return (no transformation) │
│ │
│ 3. Check eligibility (mode == auto) │
│ if function has //garble:nocontrolflow: │
│ return (explicit skip) │
│ if function is too simple: │
│ return (heuristic skip) │
│ if SSA safety check fails: │
│ return (unsafe to transform) │
│ transform() │
│ │
│ 4. Force transform (mode == all) │
│ transform() regardless of complexity/safety │
│ │
└─────────────────────────────────────────────────────────────────┘
garble -controlflow=auto build
garble -controlflow=directives build
garble -controlflow=all buildexport GARBLE_CONTROLFLOW=auto
garble buildPrecedence: CLI flag > environment variable > default (off)
//garble:controlflow
func sensitiveFunction() {
// Only transformed when mode=directives or mode=auto/all
}//garble:nocontrolflow
func hotPath() {
// Skipped even in auto mode; still transformed in all mode
}Control-flow obfuscation (implemented in internal/ctrlflow):
- Flatten: Convert structured control flow to flat switch/goto
- Opaque Predicates: Insert conditions always true/false but hard to analyze
- Dead Code Injection: Add unreachable but plausible code paths
| Feature | Status | Notes |
|---|---|---|
| Mode selection | ✅ Implemented | off/directives/auto/all |
| Directive support | ✅ Implemented | //garble:controlflow, //garble:nocontrolflow |
| SSA safety checks | ✅ Implemented | Prevents unsafe transforms |
| Performance optimization | Heuristics for hot-path detection | |
| Default-on | ❌ Planned | Needs perf validation |
Control-flow obfuscation can impact:
- Binary size: +5-15% typical increase
- Performance: Variable depending on function complexity
- Compilation time: +10-30% longer builds
Recommendation: Use auto mode with selective //garble:nocontrolflow in hot paths.
internal/ctrlflow/mode.go: Mode enum and parsinginternal/ctrlflow/ctrlflow.go: Eligibility checks, transformation logicinternal/ctrlflow/transform.go: AST transformationdocs/CONTROLFLOW.md: Detailed design documentationmain.go: Flag and environment resolution
| Attack Vector | Difficulty | Impact | Mitigation Status |
|---|---|---|---|
| Static pclntab analysis | Medium → Hard | High | ✅ Mitigated (Feistel) |
| Cross-build name correlation | Easy → Hard | Medium | ✅ Mitigated (Nonce) |
| Static string extraction | Easy → Medium | High | ✅ Mitigated (ASCON + Simple) |
| Reflection oracle exploitation | Easy → N/A | Critical | ✅ Eliminated (Default) |
| Cache offline analysis | Easy → Hard | Medium | ✅ Mitigated (ASCON Encryption) |
| Dynamic runtime tracing | Easy | Variable | |
| Const expression extraction | Easy | Medium | |
| -ldflags -X plaintext leakage | Easy | Medium | ✅ Mitigated (Sanitized + obfuscated) |
| Control-flow analysis | Medium | Medium |
| Attack Vector | Mitigation Mechanism | Residual Risk | Notes |
|---|---|---|---|
| Static Symbol Table Analysis | Feistel-encrypted entry offsets with per-build keys and per-function tweak | Dynamic tracing observes actual runtime behavior | Format-preserving; 128-bit keyspace |
| Cross-Build Pattern Matching | SHA-256 seed+nonce mixing; cryptographically random nonce per build | If seed and nonce are fixed (reproducibility), correlation possible | Intentional for deterministic builds |
| String/Literal Scraping | ASCON-128 inline encryption (~60%); multi-layer simple obfuscator (~40%) | Compile-time-only consts remain in plaintext | Remaining gap limited to array lengths / case labels |
| Injected -ldflags Strings | CLI sanitization + shared-cache rehydration via literal builder | Plaintext exists only transiently in garble parent process | Sanitized flags never reach toolchain or final binary |
| Reflection Name Oracle | _originalNamePairs array empty by default |
Opting into -reversible re-introduces oracle by design |
Security vs. debugging trade-off |
| Cache Inspection/Tampering | ASCON-128 encryption at rest with 128-bit authentication tag | Shared ephemeral cache plaintext (deleted after build) | Tag verification prevents poisoning |
| Known-Plaintext Attack on Literals | Per-literal random keys/nonces; ASCON authentication | Requires recovering per-literal key (infeasible) | Each literal independently secured |
| Brute-Force Key Recovery | 128-bit Feistel keyspace; 128-bit ASCON keys | Computationally infeasible | Meets NIST security standards |
| Dynamic Code Injection | Not addressed | Requires runtime protections (out of scope) | Obfuscation != runtime security |
| Control-Flow Reconstruction | Optional CF obfuscation modes | If disabled (default), structure remains clear | User must enable explicitly |
Attacker Goal: Extract original identifiers and strings without running the program.
Defenses:
- ✅ Feistel encryption hides function mappings
- ✅ ASCON/Simple encryption protects literals
- ✅ Sanitized
-ldflags -Xstrings are rehydrated via obfuscated init-time assignments - ✅ Empty reflection map eliminates name oracle
⚠️ String constants required at compile time (array lengths, switch labels,iotamath) remain visible
Result: Significantly harder; requires reverse engineering each obfuscation layer.
Attacker Goal: Compare multiple builds to identify patterns and recover originals.
Defenses:
- ✅ Per-build nonce ensures different hashes
- ✅ Random ASCON nonces per literal
⚠️ Fixed seed+nonce (reproducibility) breaks this defense
Result: Effective unless reproducible builds are used (intentional trade-off).
Attacker Goal: Observe program behavior at runtime to infer logic.
Defenses:
- ❌ Not addressed (out of scope for static obfuscation)
⚠️ Control-flow obfuscation can make tracing harder (if enabled)
Result: Dynamic analysis always possible; obfuscation raises the bar but doesn't prevent it.
Attacker Goal: Analyze Garble's cache to recover build metadata.
Defenses:
- ✅ ASCON-128 encryption protects persistent cache
- ✅ Authentication tag prevents tampering
- ✅ Seed not stored in cache
Result: Cache contents inaccessible without build-time seed.
Issue: Certain literal types are not obfuscated.
| Type | Status | Reason | Priority |
|---|---|---|---|
| Compile-time const contexts | Array lengths, case labels, iota must stay const | Medium | |
-ldflags -X strings |
✅ Covered | Sanitized at CLI, encrypted via init() | ✅ Complete |
| Runtime-generated strings | ❌ Not covered | Created dynamically | Low |
Example of remaining gap:
const arraySize = "XXXX"
var arr = [len(arraySize)]byte{} // ⚠️ Must stay const (array length)
const caseLabel = "case-only"
switch x {
case caseLabel: // ⚠️ Must stay const (switch case)
return true
}What IS protected:
const runtimeSecret = "hide-me" // ✅ Converted to var + encrypted
var sink = runtimeSecret // ✅ Value obfuscated at runtime
// Via -ldflags
var apiKey = "default"
// Build: garble -literals build -ldflags="-X main.apiKey=secret123"
// ✅ "secret123" is ASCON-encrypted, never appears in plaintextPlanned: Advanced const-folding analysis to detect more safe-to-rewrite constants.
Issue: The "simple" obfuscator currently uses the same reversible algorithm in both modes.
Current:
-reversible: Uses reversible simple (✅ intended)- No
-reversible: Still uses reversible simple (⚠️ should be irreversible)
Planned: Implement true one-way simple variant (e.g., hash chains, S-box substitution).
Issue: Control-flow obfuscation is opt-in (default: off).
Reason: Performance impact not fully characterized; needs heuristics.
Planned:
- Gather performance benchmarks across typical codebases
- Develop heuristics for auto-exclusion of hot paths
- Consider default-on with smart exclusions
Issue: Exported names remain unobfuscated.
Reason: Required for Go's interface compatibility and reflection.
Status: By design; whole-program obfuscation not feasible in Go's compilation model.
Alternative: Document the trade-off; consider separate "closed-ecosystem" mode in future.
Issue: Error strings and panic messages may reveal implementation details.
Examples:
panic("failed to parse config at line 42")
fmt.Errorf("database %s not found", dbName)Planned: Optional -strip-errors flag to sanitize messages in production builds.
| Item | Status | Priority |
|---|---|---|
| Improve const expression handling | 🔄 In Progress | Medium |
| Implement irreversible simple obfuscator | 📋 Planned | High |
| Document -ldflags workarounds | 📋 Planned | Low |
| Performance benchmarks for CF modes | 📋 Planned | Medium |
| Item | Status | Priority |
|---|---|---|
| Control-flow default-on evaluation | 📋 Planned | Medium |
-strip-errors flag implementation |
📋 Planned | Low |
| Link-time -ldflags interception | 🔬 Research | Medium |
| Cache encryption performance tuning | 📋 Planned | Low |
| Item | Status | Priority |
|---|---|---|
| Anti-debugging countermeasures | 💡 Concept | Low |
| Whole-program obfuscation mode | 💡 Concept | Low |
| Hardware-backed key storage | 💡 Concept | Very Low |
Legend: 💡 Concept | 🔬 Research | 📋 Planned | 🔄 In Progress | ✅ Complete
- Fixed seed+nonce: Reproducible builds, but correlation possible
- Random nonce: Unique per build, but not reproducible
- Choice: User decides based on requirements (CI/CD vs. anti-correlation)
- Default mode: Maximum security, no
garble reverse -reversiblemode: Debugging support, reduced security- Choice: Production uses default; staging uses
-reversible
- Control-flow off: Fast builds, clear structure
- Control-flow auto/all: Slower builds, complex structure
- Choice: Balance based on threat model
| Document | Purpose | Location |
|---|---|---|
| FEATURE_TOGGLES.md | Complete flag and environment reference | docs/FEATURE_TOGGLES.md |
| CONTROLFLOW.md | Control-flow obfuscation design | docs/CONTROLFLOW.md |
| README.md | User-facing overview and quick start | README.md |
| This document | Security architecture and threat model | docs/SECURITY.md |
main.go: Entry point, flag parsing, seed/nonce handlinghash.go: Name hashing, seed+nonce mixingtransformer.go: AST transformation orchestration
feistel.go: Feistel encryption/decryption primitivesruntime_patch.go: Runtime injection logicinternal/linker/linker.go: Linker patching coordinationinternal/linker/patches/go1.25/0003-add-entryOff-encryption.patch: Linker modifications
internal/literals/ascon.go: ASCON-128 core implementationinternal/literals/ascon_inline.go: Inline code generationinternal/literals/ascon_obfuscator.go: Obfuscator integrationinternal/literals/simple.go: Simple reversible obfuscatorinternal/literals/obfuscators.go: Selection strategy
reflect.go: Reflection metadata handling,reflectMainPostPatch()
cache_ascon.go: ASCON encryption for cachecache_pkg.go: Cache persistence and loading
internal/ctrlflow/mode.go: Mode definitionsinternal/ctrlflow/ctrlflow.go: Transformation logicinternal/ctrlflow/transform.go: AST manipulation
feistel_test.go: Feistel primitivesfeistel_integration_test.go: End-to-end Feistelinternal/literals/*_test.go: Literal obfuscationcache_encryption_test.go: Cache encryption
testdata/script/runtime_metadata.txtar: Runtime metadatatestdata/script/reflect_secure.txtar: Reflection default modetestdata/script/reflect_reversible.txtar: Reflection reversible modetestdata/script/seed.txtar: Seed and nonce behaviortestdata/script/ctrlflow_*.txtar: Control-flow modes
- NIST Lightweight Cryptography: ASCON-128 specification
- OWASP Code Obfuscation: Best practices
- mandiant/gostringungarbler: Static string recovery tool
- Invoke-RE/ungarble_bn: Hash salt brute-forcing tool
Document Maintenance
- Version: 1.0
- Last Updated: October 8, 2025
- Next Review: December 2025
- Owner: x430n Spectre Team