package ctrlflow
import (
"fmt"
"go/ast"
"go/token"
"go/types"
"log"
"math"
mathrand "math/rand"
"os"
"strconv"
"strings"
"golang.org/x/tools/go/ast/astutil"
"golang.org/x/tools/go/ssa"
ah "mvdan.cc/garble/internal/asthelper"
"mvdan.cc/garble/internal/ssa2ast"
)
const (
mergedFileName = "GARBLE_controlflow.go"
directiveName = "//garble:controlflow"
importPrefix = "___garble_import"
defaultBlockSplits = 0
defaultJunkJumps = 0
defaultFlattenPasses = 1
defaultTrashBlocks = 0
maxBlockSplits = math . MaxInt32
maxJunkJumps = 256
maxFlattenPasses = 4
maxTrashBlocks = 1024
minTrashBlockStmts = 1
maxTrashBlockStmts = 32
)
type directiveParamMap map [ string ] string
func ( m directiveParamMap ) GetInt ( name string , def , max int ) int {
rawVal , ok := m [ name ]
if ! ok {
return def
}
if rawVal == "max" {
return max
}
val , err := strconv . Atoi ( rawVal )
if err != nil {
panic ( fmt . Errorf ( "invalid flag %q format: %v" , name , err ) )
}
if val > max {
panic ( fmt . Errorf ( "too big flag %q value: %d (max: %d)" , name , val , max ) )
}
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 ) {
fieldsStr , ok := strings . CutPrefix ( directive , directiveName )
if ! ok {
return nil , false
}
fields := strings . Fields ( fieldsStr )
if len ( fields ) == 0 {
return nil , true
}
m := make ( map [ string ] string )
for _ , v := range fields {
key , value , ok := strings . Cut ( v , "=" )
if ok {
m [ key ] = value
} else {
m [ key ] = ""
}
}
return m , true
}
// Obfuscate obfuscates control flow of all functions with directive using control flattening.
// All obfuscated functions are removed from the original file and moved to the new one.
// Obfuscation can be customized by passing parameters from the directive, example:
//
// //garble:controlflow flatten_passes=1 junk_jumps=0 block_splits=0
// func someMethod() {}
//
// flatten_passes - controls number of passes of control flow flattening. Have exponential complexity and more than 3 passes are not recommended in most cases.
// junk_jumps - controls how many junk jumps are added. It does not affect final binary by itself, but together with flattening linearly increases complexity.
// block_splits - controls number of times largest block must be splitted. Together with flattening improves obfuscation of long blocks without branches.
func Obfuscate ( fset * token . FileSet , ssaPkg * ssa . Package , files [ ] * ast . File , obfRand * mathrand . Rand ) ( newFileName string , newFile * ast . File , affectedFiles [ ] * ast . File , err error ) {
var ssaFuncs [ ] * ssa . Function
var ssaParams [ ] directiveParamMap
for _ , file := range files {
affected := false
for _ , decl := range file . Decls {
funcDecl , ok := decl . ( * ast . FuncDecl )
if ! ok || funcDecl . Doc == nil {
continue
}
for _ , comment := range funcDecl . Doc . List {
params , hasDirective := parseDirective ( comment . Text )
if ! hasDirective {
continue
}
path , _ := astutil . PathEnclosingInterval ( file , funcDecl . Pos ( ) , funcDecl . Pos ( ) )
ssaFunc := ssa . EnclosingFunction ( ssaPkg , path )
if ssaFunc == nil {
panic ( "function exists in ast but not found in ssa" )
}
ssaFuncs = append ( ssaFuncs , ssaFunc )
ssaParams = append ( ssaParams , params )
log . Printf ( "detected function for controlflow %s (params: %v)" , funcDecl . Name . Name , params )
// Remove inplace function from original file
// TODO: implement a complete function removal
funcDecl . Name = ast . NewIdent ( "_" )
funcDecl . Body = ah . BlockStmt ( )
funcDecl . Recv = nil
funcDecl . Type = & ast . FuncType { Params : & ast . FieldList { } }
affected = true
break
}
}
if affected {
affectedFiles = append ( affectedFiles , file )
}
}
if len ( ssaFuncs ) == 0 {
return
}
newFile = & ast . File {
Package : token . Pos ( fset . Base ( ) ) ,
Name : ast . NewIdent ( files [ 0 ] . Name . Name ) ,
}
fset . AddFile ( mergedFileName , int ( newFile . Package ) , 1 ) // required for correct printer output
funcConfig := ssa2ast . DefaultConfig ( )
imports := make ( map [ string ] string )
funcConfig . ImportNameResolver = func ( pkg * types . Package ) * ast . Ident {
if pkg == nil || pkg . Path ( ) == ssaPkg . Pkg . Path ( ) {
return nil
}
name , ok := imports [ pkg . Path ( ) ]
if ! ok {
name = importPrefix + strconv . Itoa ( len ( imports ) )
imports [ pkg . Path ( ) ] = name
astutil . AddNamedImport ( fset , newFile , name , pkg . Path ( ) )
}
return ast . NewIdent ( name )
}
var trashGen * trashGenerator
for idx , ssaFunc := range ssaFuncs {
params := ssaParams [ idx ]
split := params . GetInt ( "block_splits" , defaultBlockSplits , maxBlockSplits )
junkCount := params . GetInt ( "junk_jumps" , defaultJunkJumps , maxJunkJumps )
passes := params . GetInt ( "flatten_passes" , defaultFlattenPasses , maxFlattenPasses )
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" )
trashBlockCount := params . GetInt ( "trash_blocks" , defaultTrashBlocks , maxTrashBlocks )
if trashBlockCount > 0 && trashGen == nil {
trashGen = newTrashGenerator ( ssaPkg . Prog , funcConfig . ImportNameResolver , obfRand )
}
applyObfuscation := func ( ssaFunc * ssa . Function ) [ ] dispatcherInfo {
if trashBlockCount > 0 {
addTrashBlockMarkers ( ssaFunc , trashBlockCount , obfRand )
}
for range split {
if ! applySplitting ( ssaFunc , obfRand ) {
break // no more candidates for splitting
}
}
if junkCount > 0 {
addJunkBlocks ( ssaFunc , junkCount , obfRand )
}
var dispatchers [ ] dispatcherInfo
for range passes {
if info := applyFlattening ( ssaFunc , obfRand ) ; info != nil {
dispatchers = append ( dispatchers , info )
}
}
fixBlockIndexes ( ssaFunc )
return dispatchers
}
dispatchers := applyObfuscation ( ssaFunc )
for _ , anonFunc := range ssaFunc . AnonFuncs {
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
}
funcConfig . MarkerInstrCallback = nil
if trashBlockCount > 0 {
funcConfig . MarkerInstrCallback = func ( m map [ string ] types . Type ) [ ] ast . Stmt {
return trashGen . Generate ( minTrashBlockStmts + obfRand . Intn ( maxTrashBlockStmts - minTrashBlockStmts ) , m )
}
}
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 )
}
newFileName = mergedFileName
return
}