Files
garble/internal/literals/obfuscators.go
T
Daniel Martí ef2385ee97 internal/literals: restrict the use of expensive obfuscators
The added benchmark script shows these numbers for building and running
with vanilla go on literal sizes between 16B and 2048B,
showing that vanilla Go isn't affected at all by these sizes:

                │      go      │
                │    sec/op    │
    Build/16B     118.1m ± ∞ ¹
    Build/64B     123.1m ± ∞ ¹
    Build/256B    119.7m ± ∞ ¹
    Build/1024B   119.1m ± ∞ ¹
    Build/2048B   124.3m ± ∞ ¹
    Run/16B       1.671m ± ∞ ¹
    Run/64B       1.143m ± ∞ ¹
    Run/256B      1.190m ± ∞ ¹
    Run/1024B     1.222m ± ∞ ¹
    Run/2048B     1.080m ± ∞ ¹

Our simple and swap obfuscators scale pretty well to these same sizes,
only causing moderate slow-downs to build and runtime speeds:

                │                simple                │                 swap                 │
                │    sec/op     vs base                │    sec/op     vs base                │
    Build/16B     268.0m ± ∞ ¹  +126.88% (p=1.000 n=1)   262.0m ± ∞ ¹  +121.80% (p=1.000 n=1)
    Build/64B     253.8m ± ∞ ¹  +106.16% (p=1.000 n=1)   252.4m ± ∞ ¹  +105.00% (p=1.000 n=1)
    Build/256B    265.4m ± ∞ ¹  +121.78% (p=1.000 n=1)   276.7m ± ∞ ¹  +131.16% (p=1.000 n=1)
    Build/1024B   267.4m ± ∞ ¹  +124.44% (p=1.000 n=1)   315.0m ± ∞ ¹  +164.48% (p=1.000 n=1)
    Build/2048B   277.4m ± ∞ ¹  +123.11% (p=1.000 n=1)   383.8m ± ∞ ¹  +208.70% (p=1.000 n=1)
    Run/16B       1.740m ± ∞ ¹    +4.12% (p=1.000 n=1)   1.463m ± ∞ ¹   -12.47% (p=1.000 n=1)
    Run/64B       1.470m ± ∞ ¹   +28.66% (p=1.000 n=1)   1.455m ± ∞ ¹   +27.35% (p=1.000 n=1)
    Run/256B      1.729m ± ∞ ¹   +45.25% (p=1.000 n=1)   1.812m ± ∞ ¹   +52.26% (p=1.000 n=1)
    Run/1024B     1.315m ± ∞ ¹    +7.62% (p=1.000 n=1)   1.352m ± ∞ ¹   +10.60% (p=1.000 n=1)
    Run/2048B     1.425m ± ∞ ¹   +31.93% (p=1.000 n=1)   1.316m ± ∞ ¹   +21.88% (p=1.000 n=1)

However, the other three cause huge slow-downs in both build and runtime speeds:

                │                   split                    │                shuffle                 │                    seed                    │
                │     sec/op       vs base                   │    sec/op      vs base                 │     sec/op       vs base                   │
    Build/16B        326.6m ± ∞ ¹     +176.53% (p=1.000 n=1)    363.1m ± ∞ ¹   +207.44% (p=1.000 n=1)      217.4m ± ∞ ¹      +84.05% (p=1.000 n=1)
    Build/64B        400.6m ± ∞ ¹     +225.34% (p=1.000 n=1)    328.0m ± ∞ ¹   +166.39% (p=1.000 n=1)      262.1m ± ∞ ¹     +112.87% (p=1.000 n=1)
    Build/256B       824.7m ± ∞ ¹     +589.08% (p=1.000 n=1)    588.2m ± ∞ ¹   +391.45% (p=1.000 n=1)      873.7m ± ∞ ¹     +630.05% (p=1.000 n=1)
    Build/1024B     3257.7m ± ∞ ¹    +2634.84% (p=1.000 n=1)   1671.4m ± ∞ ¹  +1303.15% (p=1.000 n=1)     5000.0m ± ∞ ¹    +4097.53% (p=1.000 n=1)
    Build/2048B     5000.0m ± ∞ ¹    +3921.73% (p=1.000 n=1)   3936.4m ± ∞ ¹  +3066.21% (p=1.000 n=1)     5000.0m ± ∞ ¹    +3921.73% (p=1.000 n=1)
    Run/16B          1.680m ± ∞ ¹       +0.51% (p=1.000 n=1)    1.426m ± ∞ ¹    -14.67% (p=1.000 n=1)      1.908m ± ∞ ¹      +14.13% (p=1.000 n=1)
    Run/64B          1.560m ± ∞ ¹      +36.53% (p=1.000 n=1)    1.345m ± ∞ ¹    +17.74% (p=1.000 n=1)      1.704m ± ∞ ¹      +49.10% (p=1.000 n=1)
    Run/256B         2.133m ± ∞ ¹      +79.24% (p=1.000 n=1)    1.833m ± ∞ ¹    +53.98% (p=1.000 n=1)      1.838m ± ∞ ¹      +54.40% (p=1.000 n=1)
    Run/1024B        2.863m ± ∞ ¹     +134.21% (p=1.000 n=1)    1.786m ± ∞ ¹    +46.12% (p=1.000 n=1)   5000.000m ± ∞ ¹  +408975.92% (p=1.000 n=1)
    Run/2048B     5000.000m ± ∞ ¹  +462837.24% (p=1.000 n=1)    2.900m ± ∞ ¹   +168.54% (p=1.000 n=1)   5000.000m ± ∞ ¹  +462837.24% (p=1.000 n=1)

As such, limit the scope of when we apply our obfuscators in two ways.

First, always apply a limit of 2048 bytes for all obfuscators.
As we can see above, the two cheap obfuscators still add some overhead,
and we aren't testing what happens if they run on truly huge strings.
It's likely that they will still cause unexpected slow-down.

Second, split the obfuscators along this line and call them
"cheap" versus "expensive". The expensive ones are only used
for sizes of up to 256 bytes. We still measure moderate slow-downs
in build times of 400-600%, but this is a reasonable compromise for now.
We will be filing bugs with upstream Go about the compiler overhead.

We adjust generateLiterals accordingly;
it now generates 100 literals between MinSize and MaxSize,
which must be obfuscated in some way, and 5 literals past MaxSize,
which don't need to be obfuscated, to check for issues like crashes.

Fixes #928.
2026-04-14 10:32:47 +02:00

282 lines
7.5 KiB
Go

// Copyright (c) 2020, The Garble Authors.
// See LICENSE for licensing information.
package literals
import (
"fmt"
"go/ast"
"go/token"
"math"
mathrand "math/rand"
"slices"
"strconv"
ah "mvdan.cc/garble/internal/asthelper"
)
// externalKeyProbability probability of using an external key.
// Larger value, greater probability of using an external key.
// Must be between 0 and 1
type externalKeyProbability float32
const (
lowProb externalKeyProbability = 0.4
normalProb externalKeyProbability = 0.6
highProb externalKeyProbability = 0.8
)
func (r externalKeyProbability) Try(rand *mathrand.Rand) bool {
return rand.Float32() < float32(r)
}
// externalKey contains all information about the external key
type externalKey struct {
name, typ string
value uint64
bits int
refs int
}
func (k *externalKey) Type() *ast.Ident {
return ast.NewIdent(k.typ)
}
func (k *externalKey) Name() *ast.Ident {
return ast.NewIdent(k.name)
}
func (k *externalKey) AddRef() {
k.refs++
}
func (k *externalKey) IsUsed() bool {
return k.refs > 0
}
// obfuscator takes a byte slice and converts it to a ast.BlockStmt
type obfuscator interface {
obfuscate(obfRand *mathrand.Rand, data []byte, extKeys []*externalKey) *ast.BlockStmt
}
var (
// Obfuscators contains all types which implement the obfuscator Interface.
Obfuscators = []obfuscator{
simple{},
swap{},
split{},
shuffle{},
seed{},
}
// CheapObfuscators contains obfuscators safe to use on large literals.
// The expensive obfuscators scale poorly, so they are excluded here.
CheapObfuscators = []obfuscator{
simple{},
swap{},
}
TestObfuscator string
testPkgToObfuscatorMap map[string]obfuscator
)
func genRandIntSlice(obfRand *mathrand.Rand, max, count int) []int {
indexes := make([]int, count)
for i := range count {
indexes[i] = obfRand.Intn(max)
}
return indexes
}
func randOperator(obfRand *mathrand.Rand) token.Token {
operatorTokens := [...]token.Token{token.XOR, token.ADD, token.SUB}
return operatorTokens[obfRand.Intn(len(operatorTokens))]
}
func evalOperator(t token.Token, x, y byte) byte {
switch t {
case token.XOR:
return x ^ y
case token.ADD:
return x + y
case token.SUB:
return x - y
default:
panic(fmt.Sprintf("unknown operator: %s", t))
}
}
func operatorToReversedBinaryExpr(t token.Token, x, y ast.Expr) *ast.BinaryExpr {
var op token.Token
switch t {
case token.XOR:
op = token.XOR // XOR is self-inverse: (a ^ b) ^ b = a
case token.ADD:
op = token.SUB
case token.SUB:
op = token.ADD
default:
panic(fmt.Sprintf("unknown operator: %s", t))
}
return ah.BinaryExpr(x, op, y)
}
const (
// minExtKeyCount is minimum number of external keys for one lambda call
minExtKeyCount = 2
// maxExtKeyCount is maximum number of external keys for one lambda call
maxExtKeyCount = 6
// minByteSliceExtKeyOps minimum number of operations with external keys for one byte slice
minByteSliceExtKeyOps = 2
// maxByteSliceExtKeyOps maximum number of operations with external keys for one byte slice
maxByteSliceExtKeyOps = 12
)
// extKeyRanges contains a list of different ranges of random numbers for external keys
// Different types and bitnesses will increase the chance of changing patterns
var extKeyRanges = []struct {
typ string
max uint64
bits int
}{
{"uint8", math.MaxUint8, 8},
{"uint16", math.MaxUint16, 16},
{"uint32", math.MaxUint32, 32},
{"uint64", math.MaxUint64, 64},
}
// randExtKey generates a random external key with a unique name, type, value, and bitnesses
func randExtKey(rand *mathrand.Rand, idx int) *externalKey {
r := extKeyRanges[rand.Intn(len(extKeyRanges))]
return &externalKey{
name: "garbleExternalKey" + strconv.Itoa(idx),
typ: r.typ,
value: rand.Uint64() & r.max,
bits: r.bits,
}
}
func randExtKeys(rand *mathrand.Rand) []*externalKey {
count := minExtKeyCount + rand.Intn(maxExtKeyCount-minExtKeyCount)
keys := make([]*externalKey, count)
for i := range count {
keys[i] = randExtKey(rand, i)
}
return keys
}
// extKeysToParams converts a list of extKeys into a parameter list and argument expressions for function calls.
// It ensures unused keys have placeholder names and sometimes use proxyDispatcher.HideValue for key values
func extKeysToParams(objRand *obfRand, keys []*externalKey) (params *ast.FieldList, args []ast.Expr) {
params = &ast.FieldList{}
for _, key := range keys {
name := key.Name()
if !key.IsUsed() {
name.Name = "_"
}
params.List = append(params.List, ah.Field(key.Type(), name))
var extKeyExpr ast.Expr = ah.UintLit(key.value)
if lowProb.Try(objRand.Rand) {
extKeyExpr = objRand.proxyDispatcher.HideValue(extKeyExpr, ast.NewIdent(key.typ))
}
args = append(args, extKeyExpr)
}
return
}
// extKeyToExpr converts an external key into an AST expression like:
//
// uint8(key >> b)
func (key *externalKey) ToExpr(b int) ast.Expr {
var x ast.Expr = key.Name()
if b > 0 {
x = ah.BinaryExpr(x, token.SHR, ah.IntLit(b*8))
}
if key.typ != "uint8" {
x = ah.CallExprByName("byte", x)
}
return x
}
// dataToByteSliceWithExtKeys scramble and turn a byte slice into an AST expression like:
//
// func() []byte {
// data := []byte("<data>")
// data[<index>] = data[<index>] <random operator> byte(<external key> >> <random shift>) // repeated random times
// return data
// }()
func dataToByteSliceWithExtKeys(rand *mathrand.Rand, data []byte, extKeys []*externalKey) ast.Expr {
extKeyOpCount := minByteSliceExtKeyOps + rand.Intn(maxByteSliceExtKeyOps-minByteSliceExtKeyOps)
var stmts []ast.Stmt
for range extKeyOpCount {
key := extKeys[rand.Intn(len(extKeys))]
key.AddRef()
idx, op, b := rand.Intn(len(data)), randOperator(rand), rand.Intn(key.bits/8)
data[idx] = evalOperator(op, data[idx], byte(key.value>>(b*8)))
stmts = append(stmts, ah.AssignStmt(
ah.IndexExpr("data", ah.IntLit(idx)),
operatorToReversedBinaryExpr(op,
ah.IndexExpr("data", ah.IntLit(idx)),
key.ToExpr(b),
),
))
}
// External keys can be applied several times to the same array element,
// and it is important to invert the order of execution to correctly restore the original value
slices.Reverse(stmts)
stmts = append([]ast.Stmt{ah.AssignDefineStmt(ast.NewIdent("data"), ah.DataToByteSlice(data))}, append(stmts, ah.ReturnStmt(ast.NewIdent("data")))...)
return ah.LambdaCall(nil, ah.ByteSliceType(), ah.BlockStmt(stmts...), nil)
}
// byteLitWithExtKey scrambles a byte value into an AST expression like:
//
// byte(<obfuscated value>) <random operator> byte(<external key> >> <random shift>)
func byteLitWithExtKey(rand *mathrand.Rand, val byte, extKeys []*externalKey, extKeyProb externalKeyProbability) ast.Expr {
if !extKeyProb.Try(rand) {
return ah.IntLit(int(val))
}
key := extKeys[rand.Intn(len(extKeys))]
key.AddRef()
op, b := randOperator(rand), rand.Intn(key.bits/8)
newVal := evalOperator(op, val, byte(key.value>>(b*8)))
return operatorToReversedBinaryExpr(op,
ah.CallExprByName("byte", ah.IntLit(int(newVal))),
key.ToExpr(b),
)
}
type obfRand struct {
*mathrand.Rand
testObfuscator obfuscator
proxyDispatcher *proxyDispatcher
}
func (r *obfRand) nextObfuscator() obfuscator {
if r.testObfuscator != nil {
return r.testObfuscator
}
return Obfuscators[r.Intn(len(Obfuscators))]
}
func (r *obfRand) nextCheapObfuscator() obfuscator {
if r.testObfuscator != nil {
return r.testObfuscator
}
return CheapObfuscators[r.Intn(len(CheapObfuscators))]
}
func newObfRand(rand *mathrand.Rand, file *ast.File, nameFunc NameProviderFunc) *obfRand {
testObf := testPkgToObfuscatorMap[file.Name.Name]
return &obfRand{rand, testObf, newProxyDispatcher(rand, nameFunc)}
}