diff --git a/internal/ctrlflow/hardening.go b/internal/ctrlflow/hardening.go index fcc370ff..5e1fceba 100644 --- a/internal/ctrlflow/hardening.go +++ b/internal/ctrlflow/hardening.go @@ -75,7 +75,7 @@ func (xorHardening) Apply(dispatcher []cfgInfo, ssaRemap map[ssa.Value]ast.Expr, globalKeyName, localKeyName := getRandomName(rnd), getRandomName(rnd) firstKey := int(rnd.Int31()) - secondKey := make([]byte, literals.MinSize+mathrand.Intn(literals.MinSize)) // make second part of key literals obfuscation friendly + secondKey := make([]byte, literals.MinSize+rnd.Intn(literals.MinSize)) // make second part of key literals obfuscation friendly if _, err := rnd.Read(secondKey); err != nil { panic(err) } @@ -149,7 +149,7 @@ func (xorHardening) Apply(dispatcher []cfgInfo, ssaRemap map[ssa.Value]ast.Expr, type delegateTableHardening struct{} func (delegateTableHardening) Apply(dispatcher []cfgInfo, ssaRemap map[ssa.Value]ast.Expr, rnd *mathrand.Rand) (ast.Decl, ast.Stmt) { - keySize := literals.MinSize + mathrand.Intn(literals.MinSize) + keySize := literals.MinSize + rnd.Intn(literals.MinSize) // Reusing multiple times one decryption function is fine, // but it doesn't make sense to generate more functions than keys. diff --git a/internal/ctrlflow/trash.go b/internal/ctrlflow/trash.go index 62fffc50..4aed019f 100644 --- a/internal/ctrlflow/trash.go +++ b/internal/ctrlflow/trash.go @@ -227,12 +227,22 @@ func (d *definedVar) HasRefs() bool { // initialize scans and writes all supported functions in all non-internal packages used in the program func (t *trashGenerator) initialize(ssaProg *ssa.Program) { - for _, p := range ssaProg.AllPackages() { + // AllPackages and Package.Members are backed by maps, whose iteration order + // is random. The candidate pools built here are later indexed with the + // seeded PRNG, so a random order would pick different globals and functions + // on each run and break reproducible builds. Walk both in a stable order. + pkgs := ssaProg.AllPackages() + slices.SortFunc(pkgs, func(a, b *ssa.Package) int { + return strings.Compare(a.Pkg.Path(), b.Pkg.Path()) + }) + for _, p := range pkgs { if isInternal(p.Pkg.Path()) || p.Pkg.Name() == "main" { continue } var pkgFuncs []*types.Func - for _, member := range p.Members { + memberNames := slices.Sorted(maps.Keys(p.Members)) + for _, memberName := range memberNames { + member := p.Members[memberName] if !token.IsExported(member.Name()) { continue } @@ -283,6 +293,8 @@ func (t *trashGenerator) chooseRandomVar(typ types.Type, vars map[string]*define if len(candidates) == 0 { return nil } + // vars is a map, so sort before the seeded random pick to stay reproducible. + slices.Sort(candidates) targetVarName := candidates[t.rand.Intn(len(candidates))] targetVar := vars[targetVarName] @@ -326,6 +338,10 @@ func (t *trashGenerator) generateRandomConst(p types.Type, rand *mathrand.Rand) if len(candidates) == 0 { panic(fmt.Errorf("unsupported type: %v", p)) } + // valueGenerators is a map, so sort before the seeded random pick. + slices.SortFunc(candidates, func(a, b types.Type) int { + return strings.Compare(a.String(), b.String()) + }) generatorType := candidates[rand.Intn(len(candidates))] generator := valueGenerators[generatorType] @@ -396,7 +412,19 @@ func (t *trashGenerator) chooseRandomMethod(vars map[string]*definedVar) (string return "", nil } + // groupedCandidates is a map keyed by type; its values are built by + // iterating the vars map. Sort each group's names, then order the types by + // their first name. Variable names are unique across vars, so this is a + // strict total order, unlike sorting by Type.String() (two distinct type + // objects can share a String, and slices.SortFunc is not stable, so a tie + // would leave the order at the mercy of the random map iteration). + for _, names := range groupedCandidates { + slices.Sort(names) + } candidateTypes := slices.Collect(maps.Keys(groupedCandidates)) + slices.SortFunc(candidateTypes, func(a, b types.Type) int { + return strings.Compare(groupedCandidates[a][0], groupedCandidates[b][0]) + }) candidateType := candidateTypes[t.rand.Intn(len(candidateTypes))] candidates := groupedCandidates[candidateType] @@ -497,6 +525,8 @@ func (t *trashGenerator) generateAssign(vars map[string]*definedVar) ast.Stmt { varNames = append(varNames, name) } } + // vars is a map: sort first so the seeded shuffle permutes a stable order. + slices.Sort(varNames) t.rand.Shuffle(len(varNames), func(i, j int) { varNames[i], varNames[j] = varNames[j], varNames[i] }) diff --git a/internal/ctrlflow/trash_test.go b/internal/ctrlflow/trash_test.go index f30c4734..aa2c9607 100644 --- a/internal/ctrlflow/trash_test.go +++ b/internal/ctrlflow/trash_test.go @@ -1,9 +1,11 @@ package ctrlflow import ( + "bytes" "fmt" "go/ast" "go/importer" + "go/parser" "go/printer" "go/token" "go/types" @@ -98,3 +100,47 @@ func Test_generateTrashBlock(t *testing.T) { printer.Fprint(os.Stdout, fset, file) buildPkg(file) } + +// TestObfuscateDeterministic ensures control flow obfuscation is reproducible: +// obfuscating the same package with the same seed must produce identical +// output. It exercises the flatten hardening (which once drew its key sizes +// from the global, unseeded math/rand source), trash generation and the +// ssa2ast var block, whose candidate pools were ordered by Go map iteration. +func TestObfuscateDeterministic(t *testing.T) { + const seed = 12345 + const src = `package main + +import ( + _ "fmt" + _ "os" +) + +//garble:controlflow flatten_passes=1 junk_jumps=5 block_splits=3 trash_blocks=8 flatten_hardening=xor,delegate_table +func compute(n int) int { + if n > 0 { + return n * 2 + } + return -n +} + +func main() { + _ = compute(10) +} +` + run := func() string { + fset := token.NewFileSet() + file, err := parser.ParseFile(fset, "main.go", src, parser.ParseComments) + qt.Assert(t, qt.IsNil(err)) + ssaPkg, _, err := ssautil.BuildPackage(&types.Config{Importer: importer.Default()}, fset, types.NewPackage("test/main", ""), []*ast.File{file}, 0) + qt.Assert(t, qt.IsNil(err)) + _, newFile, _, err := Obfuscate(fset, ssaPkg, []*ast.File{file}, mathrand.New(mathrand.NewSource(seed))) + qt.Assert(t, qt.IsNil(err)) + var buf bytes.Buffer + qt.Assert(t, qt.IsNil(printer.Fprint(&buf, fset, newFile))) + return buf.String() + } + + first := run() + second := run() + qt.Assert(t, qt.Equals(second, first)) +} diff --git a/internal/ssa2ast/func.go b/internal/ssa2ast/func.go index 2b1ee45d..7be6509b 100644 --- a/internal/ssa2ast/func.go +++ b/internal/ssa2ast/func.go @@ -8,7 +8,6 @@ import ( "go/types" "maps" "slices" - "sort" "strconv" "strings" @@ -1142,12 +1141,18 @@ func (fc *funcConverter) convertToStmts(ssaFunc *ssa.Function) ([]ast.Stmt, erro Type: typeExpr, } - sort.Strings(varNames) + slices.Sort(varNames) for _, name := range varNames { spec.Names = append(spec.Names, ast.NewIdent(name)) } specs = append(specs, spec) } + // groupedVar is a map, so its iteration order is random. Sort the grouped + // specs by their first (sorted, globally unique) name to keep the emitted + // var block deterministic, which reproducible builds depend on. + slices.SortFunc(specs, func(a, b ast.Spec) int { + return strings.Compare(a.(*ast.ValueSpec).Names[0].Name, b.(*ast.ValueSpec).Names[0].Name) + }) if len(specs) > 0 { stmts = append(stmts, &ast.DeclStmt{Decl: &ast.GenDecl{ Tok: token.VAR,