implement flattening hardening

Without hardening, obfuscation is vulnerable to analysis via symbolic
execution because all keys are opened, and it is easy to trace their
connections. Added extendable (contribution-friendly) hardening
mechanism that makes it harder to determine relationship between key and
execution block through key obfuscation.

There are 2 hardeners implemented and both are compatible with literal
obfuscation, which can make analysis even more difficult.
pull/803/head
pagran 10 months ago committed by Daniel Martí
parent 978fd6d518
commit 5e80f12be7
No known key found for this signature in database

@ -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{

@ -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)
}

@ -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 <globalKeyName> = func(secondKey []byte) int {
r := <firstKey>
for _, b := range secondKey {
r ^= int(b)
}
return r
}([]byte{ <secondKey> })
*/
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[<delegateKeyIdxs[i]>]) ^ <delegateLocalKeys[i]>)
}
*/
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 <globalTableName> = (func(key [<len(key)>]byte) [<len(key)>]func(int) int {
return [<delegateCount>]func(int) int{
<delegatesAst>
}
})(<key>)
*/
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
}

@ -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.

@ -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
}

@ -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

@ -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
Loading…
Cancel
Save