diff --git a/internal/asthelper/asthelper.go b/internal/asthelper/asthelper.go index a9d196ab..a251c008 100644 --- a/internal/asthelper/asthelper.go +++ b/internal/asthelper/asthelper.go @@ -87,6 +87,23 @@ func DataToByteSlice(data []byte) *ast.CallExpr { } } +// DataToArray turns a byte slice like []byte{1, 2, 3} into an AST +// expression +func DataToArray(data []byte) *ast.CompositeLit { + elts := make([]ast.Expr, len(data)) + for i, b := range data { + elts[i] = IntLit(int(b)) + } + + return &ast.CompositeLit{ + Type: &ast.ArrayType{ + Len: IntLit(len(data)), + Elt: ast.NewIdent("byte"), + }, + Elts: elts, + } +} + // SelectExpr "x.sel" func SelectExpr(x ast.Expr, sel *ast.Ident) *ast.SelectorExpr { return &ast.SelectorExpr{ diff --git a/internal/ctrlflow/ctrlflow.go b/internal/ctrlflow/ctrlflow.go index 766f0289..d40b08eb 100644 --- a/internal/ctrlflow/ctrlflow.go +++ b/internal/ctrlflow/ctrlflow.go @@ -54,6 +54,19 @@ func (m directiveParamMap) GetInt(name string, def, max int) int { return val } +func (m directiveParamMap) StringSlice(name string) []string { + rawVal, ok := m[name] + if !ok { + return nil + } + + slice := strings.Split(rawVal, ",") + if len(slice) == 0 { + return nil + } + return slice +} + // parseDirective parses a directive string and returns a map of directive parameters. // Each parameter should be in the form "key=value" or "key" func parseDirective(directive string) (directiveParamMap, bool) { @@ -169,8 +182,9 @@ func Obfuscate(fset *token.FileSet, ssaPkg *ssa.Package, files []*ast.File, obfR if passes == 0 { fmt.Fprintf(os.Stderr, "control flow obfuscation for %q function has no effect on the resulting binary, to fix this flatten_passes must be greater than zero", ssaFunc) } + flattenHardening := params.StringSlice("flatten_hardening") - applyObfuscation := func(ssaFunc *ssa.Function) { + applyObfuscation := func(ssaFunc *ssa.Function) []dispatcherInfo { for i := 0; i < split; i++ { if !applySplitting(ssaFunc, obfRand) { break // no more candidates for splitting @@ -179,21 +193,49 @@ func Obfuscate(fset *token.FileSet, ssaPkg *ssa.Package, files []*ast.File, obfR if junkCount > 0 { addJunkBlocks(ssaFunc, junkCount, obfRand) } + var dispatchers []dispatcherInfo for i := 0; i < passes; i++ { - applyFlattening(ssaFunc, obfRand) + if info := applyFlattening(ssaFunc, obfRand); info != nil { + dispatchers = append(dispatchers, info) + } } fixBlockIndexes(ssaFunc) + return dispatchers } - applyObfuscation(ssaFunc) + dispatchers := applyObfuscation(ssaFunc) for _, anonFunc := range ssaFunc.AnonFuncs { - applyObfuscation(anonFunc) + dispatchers = append(dispatchers, applyObfuscation(anonFunc)...) + } + + // Because of ssa package api limitations, implementation of hardening for control flow flattening dispatcher + // is implemented during converting by replacing key values with obfuscated ast expressions + var prologues []ast.Stmt + if len(flattenHardening) > 0 && len(dispatchers) > 0 { + hardening := newDispatcherHardening(flattenHardening) + + ssaRemap := make(map[ssa.Value]ast.Expr) + for _, dispatcher := range dispatchers { + decl, stmt := hardening.Apply(dispatcher, ssaRemap, obfRand) + if decl != nil { + newFile.Decls = append(newFile.Decls, decl) + } + if stmt != nil { + prologues = append(prologues, stmt) + } + } + funcConfig.SsaValueRemap = ssaRemap + } else { + funcConfig.SsaValueRemap = nil } astFunc, err := ssa2ast.Convert(ssaFunc, funcConfig) if err != nil { return "", nil, nil, err } + if len(prologues) > 0 { + astFunc.Body.List = append(prologues, astFunc.Body.List...) + } newFile.Decls = append(newFile.Decls, astFunc) } diff --git a/internal/ctrlflow/hardening.go b/internal/ctrlflow/hardening.go new file mode 100644 index 00000000..b6681ff0 --- /dev/null +++ b/internal/ctrlflow/hardening.go @@ -0,0 +1,283 @@ +package ctrlflow + +import ( + "fmt" + "go/ast" + "go/token" + mathrand "math/rand" + "strconv" + + "golang.org/x/exp/rand" + "golang.org/x/tools/go/ssa" + ah "mvdan.cc/garble/internal/asthelper" + "mvdan.cc/garble/internal/literals" +) + +var hardeningMap = map[string]dispatcherHardening{ + "xor": xorHardening{}, + "delegate_table": delegateTableHardening{}, +} + +func newDispatcherHardening(names []string) dispatcherHardening { + hardenings := make([]dispatcherHardening, len(names)) + for i, name := range names { + h, ok := hardeningMap[name] + if !ok { + panic(fmt.Sprintf("unknown dispatcher hardening %q", name)) + } + hardenings[i] = h + } + if len(hardenings) == 1 { + return hardenings[0] + } + return multiHardening(hardenings) +} + +func getRandomName(rnd *mathrand.Rand) string { + return "_garble" + strconv.FormatUint(rnd.Uint64(), 32) +} + +// generateKeys is used to generate a list of pseudo-random unique keys. +// Blacklist is needed to ensure that the result of a xor operation is not zero, +// which can lead to incorrect obfuscation of the control flow. +func generateKeys(count int, blacklistedKeys []int, rnd *mathrand.Rand) []int { + m := make(map[int]bool, count) + for _, i := range blacklistedKeys { + m[i] = true + } + arr := make([]int, 0, count) + for count > len(arr) { + key := int(rnd.Int31()) + if key == 0 || m[key] { + continue + } + arr = append(arr, key) + m[key] = true + } + return arr +} + +type dispatcherHardening interface { + Apply(dispatcher []cfgInfo, ssaRemap map[ssa.Value]ast.Expr, rnd *mathrand.Rand) (ast.Decl, ast.Stmt) +} + +type multiHardening []dispatcherHardening + +func (r multiHardening) Apply(info []cfgInfo, ssaRemap map[ssa.Value]ast.Expr, rnd *mathrand.Rand) (ast.Decl, ast.Stmt) { + return r[rnd.Intn(len(r))].Apply(info, ssaRemap, rnd) +} + +// xorHardening replaces simple keys with obfuscated ones using xor with a global key +// that is decrypted when the package is initialized. +// Note: This hardening can be improved by literals obfuscation. +type xorHardening struct{} + +func (xorHardening) Apply(dispatcher []cfgInfo, ssaRemap map[ssa.Value]ast.Expr, rnd *mathrand.Rand) (ast.Decl, ast.Stmt) { + globalKeyName, localKeyName := getRandomName(rnd), getRandomName(rnd) + + firstKey := int(rnd.Int31()) + secondKey := make([]byte, literals.MinSize+rand.Intn(literals.MinSize)) // make second part of key literals obfuscation friendly + if _, err := rnd.Read(secondKey); err != nil { + panic(err) + } + + globalKey := firstKey + for _, b := range secondKey { + globalKey ^= int(b) + } + + newKeys := generateKeys(len(dispatcher), []int{globalKey}, rnd) + for i, info := range dispatcher { + k := newKeys[i] + + ssaRemap[info.CompareVar] = ah.IntLit(k ^ globalKey) + ssaRemap[info.StoreVar] = &ast.ParenExpr{X: &ast.BinaryExpr{X: ast.NewIdent(localKeyName), Op: token.XOR, Y: ah.IntLit(k)}} + } + + // Global key decryption code: + /* + var = func(secondKey []byte) int { + r := + for _, b := range secondKey { + r ^= int(b) + } + return r + }([]byte{ }) + */ + globalKeyDecl := &ast.GenDecl{ + Tok: token.VAR, + Specs: []ast.Spec{ + &ast.ValueSpec{ + Names: []*ast.Ident{ast.NewIdent(globalKeyName)}, + Values: []ast.Expr{ah.CallExpr(&ast.FuncLit{ + Type: &ast.FuncType{ + Params: &ast.FieldList{List: []*ast.Field{{ + Names: []*ast.Ident{ast.NewIdent("secondKey")}, + Type: &ast.ArrayType{Len: ah.IntLit(len(secondKey)), Elt: ast.NewIdent("byte")}, + }}}, + Results: &ast.FieldList{List: []*ast.Field{{ + Type: ast.NewIdent("int"), + }}}, + }, + Body: &ast.BlockStmt{List: []ast.Stmt{ + ah.AssignDefineStmt(ast.NewIdent("r"), ah.IntLit(firstKey)), + &ast.RangeStmt{ + Key: ast.NewIdent("_"), + Value: ast.NewIdent("b"), + Tok: token.DEFINE, + X: ast.NewIdent("secondKey"), + Body: &ast.BlockStmt{List: []ast.Stmt{&ast.AssignStmt{ + Lhs: []ast.Expr{ast.NewIdent("r")}, + Tok: token.XOR_ASSIGN, + Rhs: []ast.Expr{&ast.CallExpr{ + Fun: ast.NewIdent("int"), + Args: []ast.Expr{ast.NewIdent("b")}, + }}, + }}}, + }, + ah.ReturnStmt(ast.NewIdent("r")), + }}, + }, ah.DataToArray(secondKey))}, + }, + }, + } + return globalKeyDecl, ah.AssignDefineStmt(ast.NewIdent(localKeyName), ast.NewIdent(globalKeyName)) +} + +// delegateTableHardening replaces simple keys with a decryption function call +// from a table of randomly generated key decryption functions +// Note: This hardening can be improved by literals obfuscation. +type delegateTableHardening struct{} + +func (delegateTableHardening) Apply(dispatcher []cfgInfo, ssaRemap map[ssa.Value]ast.Expr, rnd *mathrand.Rand) (ast.Decl, ast.Stmt) { + keySize := literals.MinSize + rand.Intn(literals.MinSize) + delegateCount := keySize + + // Reusing multiple times one decryption function is fine, + // but it doesn't make sense to generate more functions than keys. + if delegateCount > len(dispatcher) { + delegateCount = len(dispatcher) + } + + delegateKeyIdxs := rnd.Perm(keySize)[:delegateCount] + delegateLocalKeys := generateKeys(delegateCount, nil, rnd) + + key := make([]byte, keySize) + if _, err := rnd.Read(key); err != nil { + panic(err) + } + + delegateIndexes := make([]int, len(dispatcher)) + delegateKeys := make([]int, len(dispatcher)) + for i := range delegateIndexes { + delegateIdx := rnd.Intn(delegateCount) + delegateIndexes[i] = delegateIdx + delegateKeys[i] = int(key[delegateKeyIdxs[delegateIdx]]) ^ delegateLocalKeys[delegateIdx] + } + newKeys := generateKeys(len(dispatcher), delegateKeys, rnd) + globalTableName := getRandomName(rnd) + for i, info := range dispatcher { + k, delegateIdx, delegateKey := newKeys[i], delegateIndexes[i], delegateKeys[i] + encryptedKey := k ^ delegateKey + + ssaRemap[info.CompareVar] = ah.IntLit(k) + ssaRemap[info.StoreVar] = ah.CallExpr(ah.IndexExprByExpr(ast.NewIdent(globalTableName), ah.IntLit(delegateIdx)), ah.IntLit(encryptedKey)) + } + + delegatesAst := make([]ast.Expr, delegateCount) + for i := 0; i < delegateCount; i++ { + // Code for single decryption delegate: + /* + func(i int) int { + return i ^ (int(key[]) ^ ) + } + */ + delegateAst := &ast.FuncLit{ + Type: &ast.FuncType{ + Params: &ast.FieldList{List: []*ast.Field{{ + Names: []*ast.Ident{ast.NewIdent("i")}, + Type: ast.NewIdent("int"), + }}}, + Results: &ast.FieldList{List: []*ast.Field{{ + Type: ast.NewIdent("int"), + }}}, + }, + Body: &ast.BlockStmt{List: []ast.Stmt{ + &ast.ReturnStmt{Results: []ast.Expr{ + &ast.BinaryExpr{ + X: ast.NewIdent("i"), + Op: token.XOR, + Y: &ast.BinaryExpr{ + X: ah.CallExprByName("int", &ast.IndexExpr{ + X: ast.NewIdent("key"), + Index: ah.IntLit(delegateKeyIdxs[i]), + }), + Op: token.XOR, + Y: ah.IntLit(delegateLocalKeys[i]), + }, + }, + }}, + }}, + } + delegatesAst[i] = delegateAst + } + + // Code for initialization of the decryption delegates table: + /* + var = (func(key []byte) []func(int) int { + return []func(int) int{ + + } + })() + */ + delegateTableDecl := &ast.GenDecl{ + Tok: token.VAR, + Specs: []ast.Spec{ + &ast.ValueSpec{ + Names: []*ast.Ident{ast.NewIdent(globalTableName)}, + Values: []ast.Expr{ + &ast.CallExpr{ + Fun: &ast.ParenExpr{X: &ast.FuncLit{ + Type: &ast.FuncType{ + Params: &ast.FieldList{List: []*ast.Field{{ + Names: []*ast.Ident{ast.NewIdent("key")}, + Type: &ast.ArrayType{Len: ah.IntLit(len(key)), Elt: ast.NewIdent("byte")}, + }}}, + Results: &ast.FieldList{List: []*ast.Field{{Type: &ast.ArrayType{ + Len: ah.IntLit(delegateCount), + Elt: &ast.FuncType{ + Params: &ast.FieldList{List: []*ast.Field{{ + Type: ast.NewIdent("int"), + }}}, + Results: &ast.FieldList{List: []*ast.Field{{ + Type: ast.NewIdent("int"), + }}}, + }, + }}}}, + }, + Body: &ast.BlockStmt{List: []ast.Stmt{ + &ast.ReturnStmt{Results: []ast.Expr{&ast.CompositeLit{ + Type: &ast.ArrayType{ + Len: ah.IntLit(delegateCount), + Elt: &ast.FuncType{ + Params: &ast.FieldList{List: []*ast.Field{{ + Type: ast.NewIdent("int"), + }}}, + Results: &ast.FieldList{List: []*ast.Field{{ + Type: ast.NewIdent("int"), + }}}, + }, + }, + Elts: delegatesAst, + }}}, + }}, + }}, + Args: []ast.Expr{ah.DataToArray(key)}, + }, + }, + }, + }, + } + + return delegateTableDecl, nil +} diff --git a/internal/ctrlflow/transform.go b/internal/ctrlflow/transform.go index 763044d5..c2ce1805 100644 --- a/internal/ctrlflow/transform.go +++ b/internal/ctrlflow/transform.go @@ -13,11 +13,18 @@ type blockMapping struct { Fake, Target *ssa.BasicBlock } +type cfgInfo struct { + CompareVar ssa.Value + StoreVar ssa.Value +} + +type dispatcherInfo []cfgInfo + // applyFlattening adds a dispatcher block and uses ssa.Phi to redirect all ssa.Jump and ssa.If to the dispatcher, // additionally shuffle all blocks -func applyFlattening(ssaFunc *ssa.Function, obfRand *mathrand.Rand) { +func applyFlattening(ssaFunc *ssa.Function, obfRand *mathrand.Rand) dispatcherInfo { if len(ssaFunc.Blocks) < 3 { - return + return nil } phiInstr := &ssa.Phi{Comment: "ctrflow.phi"} @@ -71,15 +78,20 @@ func applyFlattening(ssaFunc *ssa.Function, obfRand *mathrand.Rand) { phiIdxs[i]++ // 0 reserved for real entry block } + var info dispatcherInfo + var entriesBlocks []*ssa.BasicBlock obfuscatedBlocks := ssaFunc.Blocks for i, m := range blocksMapping { entryBlock.Preds = append(entryBlock.Preds, m.Fake) - phiInstr.Edges = append(phiInstr.Edges, makeSsaInt(phiIdxs[i])) + val := phiIdxs[i] + cfg := cfgInfo{StoreVar: makeSsaInt(val), CompareVar: makeSsaInt(val)} + info = append(info, cfg) + phiInstr.Edges = append(phiInstr.Edges, cfg.StoreVar) obfuscatedBlocks = append(obfuscatedBlocks, m.Fake) - cond := &ssa.BinOp{X: phiInstr, Op: token.EQL, Y: makeSsaInt(phiIdxs[i])} + cond := &ssa.BinOp{X: phiInstr, Op: token.EQL, Y: cfg.CompareVar} setType(cond, types.Typ[types.Bool]) *phiInstr.Referrers() = append(*phiInstr.Referrers(), cond) @@ -119,6 +131,7 @@ func applyFlattening(ssaFunc *ssa.Function, obfRand *mathrand.Rand) { obfuscatedBlocks[i], obfuscatedBlocks[j] = obfuscatedBlocks[j], obfuscatedBlocks[i] }) ssaFunc.Blocks = append([]*ssa.BasicBlock{entryBlock}, obfuscatedBlocks...) + return info } // addJunkBlocks adds junk jumps into random blocks. Can create chains of junk jumps. diff --git a/internal/literals/literals.go b/internal/literals/literals.go index f5f2ae4b..4ca275d8 100644 --- a/internal/literals/literals.go +++ b/internal/literals/literals.go @@ -15,10 +15,10 @@ import ( ah "mvdan.cc/garble/internal/asthelper" ) -// minSize is the lower bound limit, of the size of string-like literals +// MinSize is the lower bound limit, of the size of string-like literals // which we will obfuscate. This is needed in order for binary size to stay relatively // moderate, this also decreases the likelihood for performance slowdowns. -const minSize = 8 +const MinSize = 8 // maxSize is the upper limit of the size of string-like literals // which we will obfuscate with any of the available obfuscators. @@ -61,7 +61,7 @@ func Obfuscate(rand *mathrand.Rand, file *ast.File, info *types.Info, linkString if typeAndValue.Type == types.Typ[types.String] && typeAndValue.Value != nil { value := constant.StringVal(typeAndValue.Value) - if len(value) < minSize { + if len(value) < MinSize { return true } @@ -119,7 +119,7 @@ func Obfuscate(rand *mathrand.Rand, file *ast.File, info *types.Info, linkString // // If the input node cannot be obfuscated nil is returned. func handleCompositeLiteral(obfRand *obfRand, isPointer bool, node *ast.CompositeLit, info *types.Info) ast.Node { - if len(node.Elts) < minSize { + if len(node.Elts) < MinSize { return nil } diff --git a/internal/ssa2ast/func.go b/internal/ssa2ast/func.go index 81b520db..824b03b6 100644 --- a/internal/ssa2ast/func.go +++ b/internal/ssa2ast/func.go @@ -28,6 +28,11 @@ type ConverterConfig struct { // NamePrefix prefix added to all new local variables. Must be reasonably unique NamePrefix string + + // SsaValueRemap is used to replace ssa.Value with the specified ssa.Expr. + // Note: Replacing ssa.Expr does not guarantee the correctness of the generated code. + // When using it, strictly adhere to the value types. + SsaValueRemap map[ssa.Value]ast.Expr } func DefaultConfig() *ConverterConfig { @@ -49,6 +54,7 @@ type funcConverter struct { tc *typeConverter namePrefix string valueNameMap map[ssa.Value]string + ssaValueRemap map[ssa.Value]ast.Expr } func Convert(ssaFunc *ssa.Function, cfg *ConverterConfig) (*ast.FuncDecl, error) { @@ -61,6 +67,7 @@ func newFuncConverter(cfg *ConverterConfig) *funcConverter { tc: &typeConverter{resolver: cfg.ImportNameResolver}, namePrefix: cfg.NamePrefix, valueNameMap: make(map[ssa.Value]string), + ssaValueRemap: cfg.SsaValueRemap, } } @@ -303,7 +310,15 @@ func (fc *funcConverter) getThunkMethodCall(val *ssa.Function) (ast.Expr, error) return ah.SelectExpr(&ast.ParenExpr{X: thunkTypeAst}, trimmedName), nil } -func (fc *funcConverter) ssaValue(ssaValue ssa.Value, explicitNil bool) (ast.Expr, error) { +func (fc *funcConverter) ssaValue(ssaValue ssa.Value, explicitNil bool) (expr ast.Expr, err error) { + defer func() { + if err == nil && len(fc.ssaValueRemap) > 0 { + if newExpr, ok := fc.ssaValueRemap[ssaValue]; ok { + expr = newExpr + } + } + }() + switch val := ssaValue.(type) { case *ssa.Builtin: return ast.NewIdent(val.Name()), nil diff --git a/testdata/script/ctrlflow.txtar b/testdata/script/ctrlflow.txtar index 1ba5f7c4..3add8fa0 100644 --- a/testdata/script/ctrlflow.txtar +++ b/testdata/script/ctrlflow.txtar @@ -14,9 +14,6 @@ grep 'goto _s2a_l10' $WORK/debug/test/main/GARBLE_controlflow.go # original file must contains empty function grep '\_\(\)' $WORK/debug/test/main/garble_main.go -# switch must be simplified -! grep switch $WORK/debug/test/main/GARBLE_controlflow.go - # obfuscated file must contains interface for unexported interface emulation grep 'GoString\(\) string' $WORK/debug/test/main/GARBLE_controlflow.go grep 'String\(\) string' $WORK/debug/test/main/GARBLE_controlflow.go @@ -24,6 +21,12 @@ grep 'String\(\) string' $WORK/debug/test/main/GARBLE_controlflow.go # control flow obfuscation should work correctly with literals obfuscation ! binsubstr main$exe 'correct name' + +# check xor hardening +grep '\(\w+ \^ \d+\)' $WORK/debug/test/main/GARBLE_controlflow.go +# check delegate table hardening +grep 'func\(int\) int' $WORK/debug/test/main/GARBLE_controlflow.go + -- go.mod -- module test/main @@ -40,6 +43,41 @@ import ( //garble:controlflow flatten_passes=0 junk_jumps=max block_splits=max func func1() {} +//garble:controlflow flatten_passes=1 junk_jumps=max block_splits=max flatten_hardening=xor +func xorHardeningTest(i int) int { + if i == 0 { + return 1 + } + return i * 2; +} + +//garble:controlflow flatten_passes=1 junk_jumps=max block_splits=max flatten_hardening=delegate_table +func delegateHardeningTest(i int) int { + if i == 0 { + return 1 + } + return i * 3; +} + +//garble:controlflow flatten_passes=1 junk_jumps=max block_splits=max flatten_hardening=xor,delegate_table +// Trigger multiple hardening using multiple anonymous functions +func multiHardeningTest(i int) int { + notZero := func(i int) bool { + return i != 0 + } + isZero := func(i int) bool { + return i == 0 + } + multiply := func(i int) int { + return i * 4 + } + + if !notZero(i) && isZero(i) { + return 1 + } + return multiply(i); +} + //garble:controlflow flatten_passes=1 junk_jumps=10 block_splits=10 func main() { // Reference to the unexported interface triggers creation of a new interface @@ -64,6 +102,10 @@ func main() { hash.Write([]byte("3")) println(hex.EncodeToString(hash.Sum(nil))) + + println(xorHardeningTest(0)) + println(delegateHardeningTest(0)) + println(multiHardeningTest(0)) } -- main.stderr -- @@ -72,3 +114,6 @@ binary.LittleEndian 256 correct name 884863d2 +1 +1 +1 \ No newline at end of file