You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
garble/transformer.go

1321 lines
42 KiB
Go

package main
import (
"bufio"
"bytes"
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"go/ast"
"go/token"
"go/types"
"io/fs"
"log"
mathrand "math/rand"
"os"
"path/filepath"
"slices"
"strconv"
"strings"
"unicode"
"unicode/utf8"
"golang.org/x/tools/go/ast/astutil"
"golang.org/x/tools/go/ssa"
"mvdan.cc/garble/internal/ctrlflow"
"mvdan.cc/garble/internal/literals"
)
// cmd/bundle will include a go:generate directive in its output by default.
// Ours specifies a version and doesn't assume bundle is in $PATH, so drop it.
//go:generate go tool bundle -o cmdgo_quoted.go -prefix cmdgoQuoted cmd/internal/quoted
//go:generate sed -i /go:generate/d cmdgo_quoted.go
// computeLinkerVariableStrings iterates over the -ldflags arguments,
// filling a map with all the string values set via the linker's -X flag.
// TODO: can we put this in sharedCache, using objectString as a key?
func computeLinkerVariableStrings(pkg *types.Package) (map[*types.Var]string, error) {
linkerVariableStrings := make(map[*types.Var]string)
// TODO: this is a linker flag that affects how we obfuscate a package at
// compile time. Note that, if the user changes ldflags, then Go may only
// re-link the final binary, without re-compiling any packages at all.
// It's possible that this could result in:
//
// garble -literals build -ldflags=-X=pkg.name=before # name="before"
// garble -literals build -ldflags=-X=pkg.name=after # name="before" as cached
//
// We haven't been able to reproduce this problem for now,
// but it's worth noting it and keeping an eye out for it in the future.
// If we do confirm this theoretical bug,
// the solution will be to either find a different solution for -literals,
// or to force including -ldflags into the build cache key.
ldflags, err := cmdgoQuotedSplit(flagValue(sharedCache.ForwardBuildFlags, "-ldflags"))
if err != nil {
return nil, err
}
flagValueIter(ldflags, "-X", func(val string) {
// val is in the form of "foo.com/bar.name=value".
fullName, stringValue, found := strings.Cut(val, "=")
if !found {
return // invalid
}
// fullName is "foo.com/bar.name"
i := strings.LastIndexByte(fullName, '.')
path, name := fullName[:i], fullName[i+1:]
// Note that package main always has import path "main" as part of a build.
if path != pkg.Path() && (path != "main" || pkg.Name() != "main") {
return // not the current package
}
obj, _ := pkg.Scope().Lookup(name).(*types.Var)
if obj == nil {
return // no such variable; skip
}
linkerVariableStrings[obj] = stringValue
})
return linkerVariableStrings, nil
}
func typecheck(pkgPath string, files []*ast.File, origImporter importerWithMap) (*types.Package, *types.Info, error) {
info := &types.Info{
Types: make(map[ast.Expr]types.TypeAndValue),
Defs: make(map[*ast.Ident]types.Object),
Uses: make(map[*ast.Ident]types.Object),
Implicits: make(map[ast.Node]types.Object),
Scopes: make(map[ast.Node]*types.Scope),
Selections: make(map[*ast.SelectorExpr]*types.Selection),
Instances: make(map[*ast.Ident]types.Instance),
}
origTypesConfig := types.Config{
// Note that we don't set GoVersion here. Any Go language version checks
// are performed by the upfront `go list -json -compiled` call.
Importer: origImporter,
Sizes: types.SizesFor("gc", sharedCache.GoEnv.GOARCH),
}
pkg, err := origTypesConfig.Check(pkgPath, fset, files, info)
if err != nil {
return nil, nil, fmt.Errorf("typecheck error: %v", err)
}
return pkg, info, err
}
func computeFieldToStruct(info *types.Info) map[*types.Var]*types.Struct {
done := make(map[*types.Named]bool)
fieldToStruct := make(map[*types.Var]*types.Struct)
// Run recordType on all types reachable via types.Info.
// A bit hacky, but I could not find an easier way to do this.
for _, obj := range info.Uses {
if obj != nil {
recordType(obj.Type(), nil, done, fieldToStruct)
}
}
for _, obj := range info.Defs {
if obj != nil {
recordType(obj.Type(), nil, done, fieldToStruct)
}
}
for _, tv := range info.Types {
recordType(tv.Type, nil, done, fieldToStruct)
}
return fieldToStruct
}
// recordType visits every reachable type after typechecking a package.
// Right now, all it does is fill the fieldToStruct map.
// Since types can be recursive, we need a map to avoid cycles.
// We only need to track named types as done, as all cycles must use them.
func recordType(used, origin types.Type, done map[*types.Named]bool, fieldToStruct map[*types.Var]*types.Struct) {
used = types.Unalias(used)
if origin == nil {
origin = used
} else {
origin = types.Unalias(origin)
// origin may be a [*types.TypeParam].
// For now, we haven't found a need to recurse in that case.
// We can edit this code in the future if we find an example,
// because we panic if a field is not in fieldToStruct.
if _, ok := origin.(*types.TypeParam); ok {
return
}
}
type Container interface{ Elem() types.Type }
switch used := used.(type) {
case Container:
recordType(used.Elem(), origin.(Container).Elem(), done, fieldToStruct)
case *types.Named:
if done[used] {
return
}
done[used] = true
// If we have a generic struct like
//
// type Foo[T any] struct { Bar T }
//
// then we want the hashing to use the original "Bar T",
// because otherwise different instances like "Bar int" and "Bar bool"
// will result in different hashes and the field names will break.
// Ensure we record the original generic struct, if there is one.
recordType(used.Underlying(), used.Origin().Underlying(), done, fieldToStruct)
case *types.Struct:
origin := origin.(*types.Struct)
for i := range used.NumFields() {
field := used.Field(i)
fieldToStruct[field] = origin
if field.Embedded() {
recordType(field.Type(), origin.Field(i).Type(), done, fieldToStruct)
}
}
}
}
// isSafeForInstanceType returns true if the passed type is safe for var declaration.
// Unsafe types: generic types and non-method interfaces.
func isSafeForInstanceType(t types.Type) bool {
switch t := types.Unalias(t).(type) {
case *types.Basic:
return t.Kind() != types.Invalid
case *types.Named:
if t.TypeParams().Len() > 0 {
return false
}
return isSafeForInstanceType(t.Underlying())
case *types.Signature:
return t.TypeParams().Len() == 0
case *types.Interface:
return t.IsMethodSet()
}
return true
}
// namedType tries to obtain the *types.TypeName behind a type, if there is one.
// This is useful to obtain "testing.T" from "*testing.T", or to obtain the type
// declaration object from an embedded field.
// Note that, for a type alias, this gives the alias name.
func namedType(t types.Type) *types.TypeName {
switch t := t.(type) {
case *types.Alias:
return t.Obj()
case *types.Named:
return t.Obj()
case *types.Pointer:
return namedType(t.Elem())
default:
return nil
}
}
// isTestSignature returns true if the signature matches "func _(*testing.T)".
func isTestSignature(sign *types.Signature) bool {
if sign.Recv() != nil {
return false // test funcs don't have receivers
}
params := sign.Params()
if params.Len() != 1 {
return false // too many parameters for a test func
}
tname := namedType(params.At(0).Type())
if tname == nil {
return false // the only parameter isn't named, like "string"
}
return tname.Pkg().Path() == "testing" && tname.Name() == "T"
}
func splitFlagsFromArgs(all []string) (flags, args []string) {
for i := 0; i < len(all); i++ {
arg := all[i]
if !strings.HasPrefix(arg, "-") {
return all[:i:i], all[i:]
}
if booleanFlags[arg] || strings.Contains(arg, "=") {
// Either "-bool" or "-name=value".
continue
}
// "-name value", so the next arg is part of this flag.
i++
}
return all, nil
}
func alterTrimpath(flags []string) []string {
trimpath := flagValue(flags, "-trimpath")
// Add our temporary dir to the beginning of -trimpath, so that we don't
// leak temporary dirs. Needs to be at the beginning, since there may be
// shorter prefixes later in the list, such as $PWD if TMPDIR=$PWD/tmp.
return flagSetValue(flags, "-trimpath", sharedTempDir+"=>;"+trimpath)
}
// transformer holds all the information and state necessary to obfuscate a
// single Go package.
type transformer struct {
// curPkg holds basic information about the package being currently compiled or linked.
curPkg *listedPackage
// curPkgCache is the pkgCache for curPkg.
curPkgCache pkgCache
// The type-checking results; the package itself, and the Info struct.
pkg *types.Package
info *types.Info
// linkerVariableStrings records objects for variables used in -ldflags=-X flags,
// as well as the strings the user wants to inject them with.
// Used when obfuscating literals, so that we obfuscate the injected value.
linkerVariableStrings map[*types.Var]string
// fieldToStruct helps locate struct types from any of their field
// objects. Useful when obfuscating field names.
fieldToStruct map[*types.Var]*types.Struct
// obfRand is initialized by transformCompile and used during obfuscation.
// It is left nil at init time, so that we only use it after it has been
// properly initialized with a deterministic seed.
// It must only be used for deterministic obfuscation;
// if it is used for any other purpose, we may lose determinism.
obfRand *mathrand.Rand
// origImporter is a go/types importer which uses the original versions
// of packages, without any obfuscation. This is helpful to make
// decisions on how to obfuscate our input code.
origImporter importerWithMap
// usedAllImportsFiles is used to prevent multiple calls of tf.useAllImports function on one file
// in case of simultaneously applied control flow and literals obfuscation
usedAllImportsFiles map[*ast.File]bool
}
var transformMethods = map[string]func(*transformer, []string) ([]string, error){
"asm": (*transformer).transformAsm,
"compile": (*transformer).transformCompile,
"link": (*transformer).transformLink,
}
func (tf *transformer) transformAsm(args []string) ([]string, error) {
flags, paths := splitFlagsFromFiles(args, ".s")
// When assembling, the import path can make its way into the output object file.
flags = flagSetValue(flags, "-p", tf.curPkg.obfuscatedImportPath())
flags = alterTrimpath(flags)
// The assembler runs twice; the first with -gensymabis,
// where we continue below and we obfuscate all the source.
// The second time, without -gensymabis, we reconstruct the paths to the
// obfuscated source files and reuse them to avoid work.
newPaths := make([]string, 0, len(paths))
if !slices.Contains(args, "-gensymabis") {
for _, path := range paths {
name := hashWithPackage(tf.curPkg, filepath.Base(path)) + ".s"
pkgDir := filepath.Join(sharedTempDir, tf.curPkg.obfuscatedSourceDir())
newPath := filepath.Join(pkgDir, name)
newPaths = append(newPaths, newPath)
}
return append(flags, newPaths...), nil
}
const missingHeader = "missing header path"
newHeaderPaths := make(map[string]string)
var buf, includeBuf bytes.Buffer
for _, path := range paths {
buf.Reset()
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close() // in case of error
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := scanner.Text()
// Whole-line comments might be directives, leave them in place.
// For example: //go:build race
// Any other comment, including inline ones, can be discarded entirely.
line, comment, hasComment := strings.Cut(line, "//")
if hasComment && line == "" {
buf.WriteString("//")
buf.WriteString(comment)
buf.WriteByte('\n')
continue
}
// Preprocessor lines to include another file.
// For example: #include "foo.h"
if quoted, ok := strings.CutPrefix(line, "#include"); ok {
quoted = strings.TrimSpace(quoted)
path, err := strconv.Unquote(quoted)
if err != nil { // note that strconv.Unquote errors do not include the input string
return nil, fmt.Errorf("cannot unquote %q: %v", quoted, err)
}
newPath := newHeaderPaths[path]
switch newPath {
case missingHeader: // no need to try again
buf.WriteString(line)
buf.WriteByte('\n')
continue
case "": // first time we see this header
includeBuf.Reset()
content, err := os.ReadFile(path)
if errors.Is(err, fs.ErrNotExist) {
newHeaderPaths[path] = missingHeader
buf.WriteString(line)
buf.WriteByte('\n')
continue // a header file provided by Go or the system
} else if err != nil {
return nil, err
}
tf.replaceAsmNames(&includeBuf, content)
// For now, we replace `foo.h` or `dir/foo.h` with `garbled_foo.h`.
// The different name ensures we don't use the unobfuscated file.
// This is far from perfect, but does the job for the time being.
// In the future, use a randomized name.
basename := filepath.Base(path)
newPath = "garbled_" + basename
if _, err := tf.writeSourceFile(basename, newPath, includeBuf.Bytes()); err != nil {
return nil, err
}
newHeaderPaths[path] = newPath
}
buf.WriteString("#include ")
buf.WriteString(strconv.Quote(newPath))
buf.WriteByte('\n')
continue
}
// Anything else is regular assembly; replace the names.
tf.replaceAsmNames(&buf, []byte(line))
buf.WriteByte('\n')
}
if err := scanner.Err(); err != nil {
return nil, err
}
// With assembly files, we obfuscate the filename in the temporary
// directory, as assembly files do not support `/*line` directives.
// TODO(mvdan): per cmd/asm/internal/lex, they do support `#line`.
basename := filepath.Base(path)
newName := hashWithPackage(tf.curPkg, basename) + ".s"
if path, err := tf.writeSourceFile(basename, newName, buf.Bytes()); err != nil {
return nil, err
} else {
newPaths = append(newPaths, path)
}
f.Close() // do not keep len(paths) files open
}
return append(flags, newPaths...), nil
}
func (tf *transformer) replaceAsmNames(buf *bytes.Buffer, remaining []byte) {
// We need to replace all function references with their obfuscated name
// counterparts.
// Luckily, all func names in Go assembly files are immediately followed
// by the unicode "middle dot", like:
//
// TEXT ·privateAdd(SB),$0-24
// TEXT runtimeinternalsys·Ctz64(SB), NOSPLIT, $0-12
//
// Note that import paths in assembly, like `runtimeinternalsys` above,
// use Unicode periods and slashes rather than the ASCII ones used by `go list`.
// We need to convert to ASCII to find the right package information.
const (
asmPeriod = '·'
goPeriod = '.'
asmSlash = ''
goSlash = '/'
)
asmPeriodLen := utf8.RuneLen(asmPeriod)
for {
periodIdx := bytes.IndexRune(remaining, asmPeriod)
if periodIdx < 0 {
buf.Write(remaining)
remaining = nil
break
}
// The package name ends at the first rune which cannot be part of a Go
// import path, such as a comma or space.
pkgStart := periodIdx
for pkgStart >= 0 {
c, size := utf8.DecodeLastRune(remaining[:pkgStart])
if !unicode.IsLetter(c) && c != '_' && c != asmSlash && !unicode.IsDigit(c) {
break
}
pkgStart -= size
}
// The package name might actually be longer, e.g:
//
// JMP testwith·many·dotsmainimported·PublicAdd(SB)
//
// We have `testwith` so far; grab `·many·dotsmainimported` as well.
pkgEnd := periodIdx
lastAsmPeriod := -1
for i := pkgEnd + asmPeriodLen; i <= len(remaining); {
c, size := utf8.DecodeRune(remaining[i:])
if c == asmPeriod {
lastAsmPeriod = i
} else if !unicode.IsLetter(c) && c != '_' && c != asmSlash && !unicode.IsDigit(c) {
if lastAsmPeriod > 0 {
pkgEnd = lastAsmPeriod
}
break
}
i += size
}
asmPkgPath := string(remaining[pkgStart:pkgEnd])
// Write the bytes before our unqualified `·foo` or qualified `pkg·foo`.
buf.Write(remaining[:pkgStart])
// If the name was qualified, fetch the package, and write the
// obfuscated import path if needed.
lpkg := tf.curPkg
if asmPkgPath != "" {
if asmPkgPath != tf.curPkg.Name {
goPkgPath := asmPkgPath
goPkgPath = strings.ReplaceAll(goPkgPath, string(asmPeriod), string(goPeriod))
goPkgPath = strings.ReplaceAll(goPkgPath, string(asmSlash), string(goSlash))
var err error
lpkg, err = listPackage(tf.curPkg, goPkgPath)
if err != nil {
panic(err) // shouldn't happen
}
}
if lpkg.ToObfuscate {
// Note that we don't need to worry about asmSlash here,
// because our obfuscated import paths contain no slashes right now.
buf.WriteString(lpkg.obfuscatedImportPath())
} else {
buf.WriteString(asmPkgPath)
}
}
// Write the middle dot and advance the remaining slice.
buf.WriteRune(asmPeriod)
remaining = remaining[pkgEnd+asmPeriodLen:]
// The declared name ends at the first rune which cannot be part of a Go
// identifier, such as a comma or space.
nameEnd := 0
for nameEnd < len(remaining) {
c, size := utf8.DecodeRune(remaining[nameEnd:])
if !unicode.IsLetter(c) && c != '_' && !unicode.IsDigit(c) {
break
}
nameEnd += size
}
name := string(remaining[:nameEnd])
remaining = remaining[nameEnd:]
if lpkg.ToObfuscate && !compilerIntrinsics[lpkg.ImportPath][name] {
newName := hashWithPackage(lpkg, name)
if flagDebug { // TODO(mvdan): remove once https://go.dev/issue/53465 if fixed
log.Printf("asm name %q hashed with %x to %q", name, tf.curPkg.GarbleActionID, newName)
}
buf.WriteString(newName)
} else {
buf.WriteString(name)
}
}
}
// writeSourceFile is a mix between os.CreateTemp and os.WriteFile, as it writes a
// named source file in sharedTempDir given an input buffer.
//
// Note that the file is created under a directory tree following curPkg's
// import path, mimicking how files are laid out in modules and GOROOT.
func (tf *transformer) writeSourceFile(basename, obfuscated string, content []byte) (string, error) {
// Uncomment for some quick debugging. Do not delete.
// fmt.Fprintf(os.Stderr, "\n-- %s/%s --\n%s", curPkg.ImportPath, basename, content)
if flagDebugDir != "" {
pkgDir := filepath.Join(flagDebugDir, filepath.FromSlash(tf.curPkg.ImportPath))
if err := os.MkdirAll(pkgDir, 0o755); err != nil {
return "", err
}
dstPath := filepath.Join(pkgDir, basename)
if err := os.WriteFile(dstPath, content, 0o666); err != nil {
return "", err
}
}
// We use the obfuscated import path to hold the temporary files.
// Assembly files do not support line directives to set positions,
// so the only way to not leak the import path is to replace it.
pkgDir := filepath.Join(sharedTempDir, tf.curPkg.obfuscatedSourceDir())
if err := os.MkdirAll(pkgDir, 0o777); err != nil {
return "", err
}
dstPath := filepath.Join(pkgDir, obfuscated)
if err := writeFileExclusive(dstPath, content); err != nil {
return "", err
}
return dstPath, nil
}
func (tf *transformer) transformCompile(args []string) ([]string, error) {
flags, paths := splitFlagsFromFiles(args, ".go")
// We will force the linker to drop DWARF via -w, so don't spend time
// generating it.
flags = append(flags, "-dwarf=false")
// The Go file paths given to the compiler are always absolute paths.
files, err := parseFiles(tf.curPkg, "", paths)
if err != nil {
return nil, err
}
// Literal and control flow obfuscation uses math/rand, so seed it deterministically.
randSeed := tf.curPkg.GarbleActionID[:]
if flagSeed.present() {
randSeed = flagSeed.bytes
}
// log.Printf("seeding math/rand with %x\n", randSeed)
tf.obfRand = mathrand.New(mathrand.NewSource(int64(binary.BigEndian.Uint64(randSeed))))
// Even if loadPkgCache below finds a direct cache hit,
// other parts of garble still need type information to obfuscate.
// We could potentially avoid this by saving the type info we need in the cache,
// although in general that wouldn't help much, since it's rare for Go's cache
// to miss on a package and for our cache to hit.
if tf.pkg, tf.info, err = typecheck(tf.curPkg.ImportPath, files, tf.origImporter); err != nil {
return nil, err
}
var (
ssaPkg *ssa.Package
requiredPkgs []string
)
if flagControlFlow {
ssaPkg = ssaBuildPkg(tf.pkg, files, tf.info)
newFileName, newFile, affectedFiles, err := ctrlflow.Obfuscate(fset, ssaPkg, files, tf.obfRand)
if err != nil {
return nil, err
}
if newFile != nil {
files = append(files, newFile)
paths = append(paths, newFileName)
for _, file := range affectedFiles {
tf.useAllImports(file)
}
if tf.pkg, tf.info, err = typecheck(tf.curPkg.ImportPath, files, tf.origImporter); err != nil {
return nil, err
}
for _, imp := range newFile.Imports {
path, err := strconv.Unquote(imp.Path.Value)
if err != nil {
panic(err) // should never happen
}
requiredPkgs = append(requiredPkgs, path)
}
}
}
if tf.curPkgCache, err = loadPkgCache(tf.curPkg, tf.pkg, files, tf.info, ssaPkg); err != nil {
return nil, err
}
// These maps are not kept in pkgCache, since they are only needed to obfuscate curPkg.
tf.fieldToStruct = computeFieldToStruct(tf.info)
if flagLiterals {
if tf.linkerVariableStrings, err = computeLinkerVariableStrings(tf.pkg); err != nil {
return nil, err
}
}
flags = alterTrimpath(flags)
newImportCfg, err := tf.processImportCfg(flags, requiredPkgs)
if err != nil {
return nil, err
}
// Note that the main package always uses `-p main`, even though it's not an import path.
flags = flagSetValue(flags, "-p", tf.curPkg.obfuscatedImportPath())
newPaths := make([]string, 0, len(files))
for i, file := range files {
basename := filepath.Base(paths[i])
log.Printf("obfuscating %s", basename)
if tf.curPkg.ImportPath == "runtime" {
if flagTiny {
// strip unneeded runtime code
stripRuntime(basename, file)
tf.useAllImports(file)
}
if basename == "symtab.go" {
updateMagicValue(file, magicValue())
updateEntryOffset(file, entryOffKey())
}
}
if err := tf.transformDirectives(file.Comments); err != nil {
return nil, err
}
file = tf.transformGoFile(file)
file.Name.Name = tf.curPkg.obfuscatedPackageName()
src, err := printFile(tf.curPkg, file)
if err != nil {
return nil, err
}
if tf.curPkg.Name == "main" && strings.HasSuffix(reflectPatchFile, basename) {
src = reflectMainPostPatch(src, tf.curPkg, tf.curPkgCache)
}
// We hide Go source filenames via "//line" directives,
// so there is no need to use obfuscated filenames here.
if path, err := tf.writeSourceFile(basename, basename, src); err != nil {
return nil, err
} else {
newPaths = append(newPaths, path)
}
}
flags = flagSetValue(flags, "-importcfg", newImportCfg)
return append(flags, newPaths...), nil
}
// transformDirectives rewrites //go:linkname toolchain directives in comments
// to replace names with their obfuscated versions.
func (tf *transformer) transformDirectives(comments []*ast.CommentGroup) error {
for _, group := range comments {
for _, comment := range group.List {
if !strings.HasPrefix(comment.Text, "//go:linkname ") {
continue
}
// We can have either just one argument:
//
// //go:linkname localName
//
// Or two arguments, where the second may refer to a name in a
// different package:
//
// //go:linkname localName newName
// //go:linkname localName pkg.newName
fields := strings.Fields(comment.Text)
localName := fields[1]
newName := ""
if len(fields) == 3 {
newName = fields[2]
}
switch newName {
case "runtime.lastmoduledatap", "runtime.moduledataverify1":
// Linknaming to the var and function above is used by github.com/bytedance/sonic/loader
// to inject functions into the runtime, but that breaks as garble patches
// the runtime to change the function header magic number.
//
// Given that Go is locking down access to runtime internals via go:linkname,
// and what sonic does was never supported and is a hack,
// refuse to build before the user sees confusing run-time panics.
return fmt.Errorf("garble does not support packages with a //go:linkname to %s", newName)
}
localName, newName = tf.transformLinkname(localName, newName)
fields[1] = localName
if len(fields) == 3 {
fields[2] = newName
}
if flagDebug { // TODO(mvdan): remove once https://go.dev/issue/53465 if fixed
log.Printf("linkname %q changed to %q", comment.Text, strings.Join(fields, " "))
}
comment.Text = strings.Join(fields, " ")
}
}
return nil
}
func (tf *transformer) transformLinkname(localName, newName string) (string, string) {
// obfuscate the local name, if the current package is obfuscated
if tf.curPkg.ToObfuscate && !compilerIntrinsics[tf.curPkg.ImportPath][localName] {
localName = hashWithPackage(tf.curPkg, localName)
}
if newName == "" {
return localName, ""
}
// If the new name is of the form "pkgpath.Name", and we've obfuscated
// "Name" in that package, rewrite the directive to use the obfuscated name.
dotCnt := strings.Count(newName, ".")
if dotCnt < 1 {
// cgo-generated code uses linknames to made up symbol names,
// which do not have a package path at all.
// Replace the comment in case the local name was obfuscated.
return localName, newName
}
switch newName {
case "main.main", "main..inittask", "runtime..inittask":
// The runtime uses some special symbols with "..".
// We aren't touching those at the moment.
return localName, newName
}
pkgSplit := 0
var foreignName string
var lpkg *listedPackage
for {
i := strings.Index(newName[pkgSplit:], ".")
if i < 0 {
// We couldn't find a prefix that matched a known package.
// Probably a made up name like above, but with a dot.
return localName, newName
}
pkgSplit += i
pkgPath := newName[:pkgSplit]
pkgSplit++ // skip over the dot
if strings.HasSuffix(pkgPath, "_test") {
// runtime uses a go:linkname to metrics_test;
// we don't need this to work for now on regular builds,
// though we might need to rethink this if we want "go test std" to work.
continue
}
var err error
lpkg, err = listPackage(tf.curPkg, pkgPath)
if err == nil {
foreignName = newName[pkgSplit:]
break
}
if errors.Is(err, ErrNotFound) {
// No match; find the next dot.
continue
}
if errors.Is(err, ErrNotDependency) {
fmt.Fprintf(os.Stderr,
"//go:linkname refers to %s - add `import _ %q` for garble to find the package",
newName, pkgPath)
return localName, newName
}
panic(err) // shouldn't happen
}
if !lpkg.ToObfuscate || compilerIntrinsics[lpkg.ImportPath][foreignName] {
// We're not obfuscating that package or name.
return localName, newName
}
var newForeignName string
if receiver, name, ok := strings.Cut(foreignName, "."); ok {
if receiver, ok = strings.CutPrefix(receiver, "(*"); ok {
// pkg/path.(*Receiver).method
receiver, _ = strings.CutSuffix(receiver, ")")
receiver = "(*" + hashWithPackage(lpkg, receiver) + ")"
} else {
// pkg/path.Receiver.method
receiver = hashWithPackage(lpkg, receiver)
}
// Exported methods are never obfuscated.
//
// TODO(mvdan): We're duplicating the logic behind these decisions.
// Reuse the logic with transformCompile.
if !token.IsExported(name) {
name = hashWithPackage(lpkg, name)
}
newForeignName = receiver + "." + name
} else {
// pkg/path.function
newForeignName = hashWithPackage(lpkg, foreignName)
}
newName = lpkg.obfuscatedImportPath() + "." + newForeignName
return localName, newName
}
// processImportCfg parses the importcfg file passed to a compile or link step.
// It also builds a new importcfg file to account for obfuscated import paths.
func (tf *transformer) processImportCfg(flags []string, requiredPkgs []string) (newImportCfg string, _ error) {
importCfg := flagValue(flags, "-importcfg")
if importCfg == "" {
return "", fmt.Errorf("could not find -importcfg argument")
}
data, err := os.ReadFile(importCfg)
if err != nil {
return "", err
}
var packagefiles, importmaps [][2]string
// using for track required but not imported packages
var newIndirectImports map[string]bool
if requiredPkgs != nil {
newIndirectImports = make(map[string]bool)
for _, pkg := range requiredPkgs {
// unsafe is a special case, it's not a real dependency
if pkg == "unsafe" {
continue
}
newIndirectImports[pkg] = true
}
}
for line := range strings.SplitSeq(string(data), "\n") {
if line == "" || strings.HasPrefix(line, "#") {
continue
}
verb, args, found := strings.Cut(line, " ")
if !found {
continue
}
switch verb {
case "importmap":
beforePath, afterPath, found := strings.Cut(args, "=")
if !found {
continue
}
importmaps = append(importmaps, [2]string{beforePath, afterPath})
case "packagefile":
importPath, objectPath, found := strings.Cut(args, "=")
if !found {
continue
}
packagefiles = append(packagefiles, [2]string{importPath, objectPath})
delete(newIndirectImports, importPath)
}
}
// Produce the modified importcfg file.
// This is mainly replacing the obfuscated paths.
// Note that we range over maps, so this is non-deterministic, but that
// should not matter as the file is treated like a lookup table.
newCfg, err := os.CreateTemp(sharedTempDir, "importcfg")
if err != nil {
return "", err
}
for _, pair := range importmaps {
beforePath, afterPath := pair[0], pair[1]
lpkg, err := listPackage(tf.curPkg, beforePath)
if err != nil {
return "", err
}
if lpkg.ToObfuscate {
// Note that beforePath is not the canonical path.
// For beforePath="vendor/foo", afterPath and
// lpkg.ImportPath can be just "foo".
// Don't use obfuscatedImportPath here.
beforePath = hashWithPackage(lpkg, beforePath)
afterPath = lpkg.obfuscatedImportPath()
}
fmt.Fprintf(newCfg, "importmap %s=%s\n", beforePath, afterPath)
}
if len(newIndirectImports) > 0 {
f, err := os.Open(filepath.Join(sharedTempDir, actionGraphFileName))
if err != nil {
return "", fmt.Errorf("cannot open action graph file: %v", err)
}
defer f.Close()
var actions []struct {
Mode string
Package string
Objdir string
}
if err := json.NewDecoder(f).Decode(&actions); err != nil {
return "", fmt.Errorf("cannot parse action graph file: %v", err)
}
// theoretically action graph can be long, to optimise it process it in one pass
// with an early exit when all the required imports are found
for _, action := range actions {
if action.Mode != "build" {
continue
}
if ok := newIndirectImports[action.Package]; !ok {
continue
}
packagefiles = append(packagefiles, [2]string{action.Package, filepath.Join(action.Objdir, "_pkg_.a")}) // file name hardcoded in compiler
delete(newIndirectImports, action.Package)
if len(newIndirectImports) == 0 {
break
}
}
if len(newIndirectImports) > 0 {
return "", fmt.Errorf("cannot resolve required packages from action graph file: %v", requiredPkgs)
}
}
for _, pair := range packagefiles {
impPath, pkgfile := pair[0], pair[1]
lpkg, err := listPackage(tf.curPkg, impPath)
if err != nil {
// TODO: it's unclear why an importcfg can include an import path
// that's not a dependency in an edge case with "go test ./...".
// See exporttest/*.go in testdata/scripts/test.txt.
// For now, spot the pattern and avoid the unnecessary error;
// the dependency is unused, so the packagefile line is redundant.
// This still triggers as of go1.24.
if strings.HasSuffix(tf.curPkg.ImportPath, ".test]") && strings.HasPrefix(tf.curPkg.ImportPath, impPath) {
continue
}
return "", err
}
impPath = lpkg.obfuscatedImportPath()
fmt.Fprintf(newCfg, "packagefile %s=%s\n", impPath, pkgfile)
}
// Uncomment to debug the transformed importcfg. Do not delete.
// newCfg.Seek(0, 0)
// io.Copy(os.Stderr, newCfg)
if err := newCfg.Close(); err != nil {
return "", err
}
return newCfg.Name(), nil
}
func (tf *transformer) useAllImports(file *ast.File) {
if tf.usedAllImportsFiles == nil {
tf.usedAllImportsFiles = make(map[*ast.File]bool)
} else if ok := tf.usedAllImportsFiles[file]; ok {
return
}
tf.usedAllImportsFiles[file] = true
for _, imp := range file.Imports {
if imp.Name != nil && imp.Name.Name == "_" {
continue
}
pkgName := tf.info.PkgNameOf(imp)
pkgScope := pkgName.Imported().Scope()
var nameObj types.Object
for _, name := range pkgScope.Names() {
if obj := pkgScope.Lookup(name); obj.Exported() && isSafeForInstanceType(obj.Type()) {
nameObj = obj
break
}
}
if nameObj == nil {
// A very unlikely situation where there is no suitable declaration for a reference variable
// and almost certainly means that there is another import reference in code.
continue
}
spec := &ast.ValueSpec{Names: []*ast.Ident{ast.NewIdent("_")}}
decl := &ast.GenDecl{Specs: []ast.Spec{spec}}
nameIdent := ast.NewIdent(nameObj.Name())
var nameExpr ast.Expr
switch {
case imp.Name == nil: // import "pkg/path"
nameExpr = &ast.SelectorExpr{
X: ast.NewIdent(pkgName.Name()),
Sel: nameIdent,
}
case imp.Name.Name != ".": // import path2 "pkg/path"
nameExpr = &ast.SelectorExpr{
X: ast.NewIdent(imp.Name.Name),
Sel: nameIdent,
}
default: // import . "pkg/path"
nameExpr = nameIdent
}
switch nameObj.(type) {
case *types.Const:
// const _ = <value>
decl.Tok = token.CONST
spec.Values = []ast.Expr{nameExpr}
case *types.Var, *types.Func:
// var _ = <value>
decl.Tok = token.VAR
spec.Values = []ast.Expr{nameExpr}
case *types.TypeName:
// var _ <type>
decl.Tok = token.VAR
spec.Type = nameExpr
default:
continue // skip *types.Builtin and others
}
// Ensure that types.Info.Uses is up to date.
tf.info.Uses[nameIdent] = nameObj
file.Decls = append(file.Decls, decl)
}
}
// transformGoFile obfuscates the provided Go syntax file.
func (tf *transformer) transformGoFile(file *ast.File) *ast.File {
// Only obfuscate the literals here if the flag is on
// and if the package in question is to be obfuscated.
//
// We can't obfuscate literals in the runtime and its dependencies,
// because obfuscated literals sometimes escape to heap,
// and that's not allowed in the runtime itself.
if flagLiterals && tf.curPkg.ToObfuscate {
file = literals.Obfuscate(tf.obfRand, file, tf.info, tf.linkerVariableStrings, randomName)
// some imported constants might not be needed anymore, remove unnecessary imports
tf.useAllImports(file)
}
pre := func(cursor *astutil.Cursor) bool {
node, ok := cursor.Node().(*ast.Ident)
if !ok {
return true
}
name := node.Name
if name == "_" {
return true // unnamed remains unnamed
}
obj := tf.info.ObjectOf(node)
if obj == nil {
_, isImplicit := tf.info.Defs[node]
_, parentIsFile := cursor.Parent().(*ast.File)
if !isImplicit || parentIsFile {
// We only care about nil objects in the switch scenario below.
return true
}
// In a type switch like "switch foo := bar.(type) {",
// "foo" is being declared as a symbolic variable,
// as it is only actually declared in each "case SomeType:".
//
// As such, the symbolic "foo" in the syntax tree has no object,
// but it is still recorded under Defs with a nil value.
// We still want to obfuscate that syntax tree identifier,
// so if we detect the case, create a dummy types.Var for it.
//
// Note that "package mypkg" also denotes a nil object in Defs,
// and we don't want to treat that "mypkg" as a variable,
// so avoid that case by checking the type of cursor.Parent.
obj = types.NewVar(node.Pos(), tf.pkg, name, nil)
}
pkg := obj.Pkg()
if vr, ok := obj.(*types.Var); ok && vr.Embedded() {
// The docs for ObjectOf say:
//
// If id is an embedded struct field, ObjectOf returns the
// field (*Var) it defines, not the type (*TypeName) it uses.
//
// If this embedded field is a type alias, we want to
// handle the alias's TypeName instead of treating it as
// the type the alias points to.
//
// Alternatively, if we don't have an alias, we still want to
// use the embedded type, not the field.
tname := namedType(obj.Type())
if tname == nil {
return true // unnamed type (probably a basic type, e.g. int)
}
obj = tname
pkg = obj.Pkg()
}
if pkg == nil {
return true // universe scope
}
// TODO: We match by object name here, which is actually imprecise.
// For example, in package embed we match the type FS, but we would also
// match any field or method named FS.
// Can we instead use an object map like ReflectObjects?
path := pkg.Path()
switch path {
case "sync/atomic", "runtime/internal/atomic":
if name == "align64" {
return true
}
case "embed":
// FS is detected by the compiler for //go:embed.
if name == "FS" {
return true
}
case "reflect":
switch name {
// Per the linker's deadcode.go docs,
// the Method and MethodByName methods are what drive the logic.
case "Method", "MethodByName":
return true
}
case "crypto/x509/pkix":
// For better or worse, encoding/asn1 detects a "SET" suffix on slice type names
// to tell whether those slices should be treated as sets or sequences.
// Do not obfuscate those names to prevent breaking x509 certificates.
// TODO: we can surely do better; ideally propose a non-string-based solution
// upstream, or as a fallback, obfuscate to a name ending with "SET".
if strings.HasSuffix(name, "SET") {
return true
}
}
lpkg, err := listPackage(tf.curPkg, path)
if err != nil {
panic(err) // shouldn't happen
}
if !lpkg.ToObfuscate {
return true // we're not obfuscating this package
}
hashToUse := lpkg.GarbleActionID
debugName := "variable"
// log.Printf("%s: %#v %T", fset.Position(node.Pos()), node, obj)
switch obj := obj.(type) {
case *types.Var:
if !obj.IsField() {
// Identifiers denoting variables are always obfuscated.
break
}
debugName = "field"
// From this point on, we deal with struct fields.
// Fields don't get hashed with the package's action ID.
// They get hashed with the type of their parent struct.
// This is because one struct can be converted to another,
// as long as the underlying types are identical,
// even if the structs are defined in different packages.
//
// TODO: Consider only doing this for structs where all
// fields are exported. We only need this special case
// for cross-package conversions, which can't work if
// any field is unexported. If that is done, add a test
// that ensures unexported fields from different
// packages result in different obfuscated names.
strct := tf.fieldToStruct[obj]
if strct == nil {
panic("could not find struct for field " + name)
}
node.Name = hashWithStruct(strct, obj)
if flagDebug { // TODO(mvdan): remove once https://go.dev/issue/53465 if fixed
log.Printf("%s %q hashed with struct fields to %q", debugName, name, node.Name)
}
return true
case *types.TypeName:
debugName = "type"
case *types.Func:
if compilerIntrinsics[path][name] {
return true
}
sign := obj.Signature()
if sign.Recv() == nil {
debugName = "func"
} else {
debugName = "method"
}
if obj.Exported() && sign.Recv() != nil {
return true // might implement an interface
}
switch name {
case "main", "init", "TestMain":
return true // don't break them
}
if strings.HasPrefix(name, "Test") && isTestSignature(sign) {
return true // don't break tests
}
default:
return true // we only want to rename the above
}
node.Name = hashWithPackage(lpkg, name)
// TODO: probably move the debugf lines inside the hash funcs
if flagDebug { // TODO(mvdan): remove once https://go.dev/issue/53465 if fixed
log.Printf("%s %q hashed with %x… to %q", debugName, name, hashToUse[:4], node.Name)
}
return true
}
post := func(cursor *astutil.Cursor) bool {
imp, ok := cursor.Node().(*ast.ImportSpec)
if !ok {
return true
}
path, err := strconv.Unquote(imp.Path.Value)
if err != nil {
panic(err) // should never happen
}
// We're importing an obfuscated package.
// Replace the import path with its obfuscated version.
lpkg, err := listPackage(tf.curPkg, path)
if err != nil {
panic(err) // should never happen
}
// Note that a main package is imported via its original import path.
imp.Path.Value = strconv.Quote(lpkg.obfuscatedImportPath())
// If the import was unnamed, give it the name of the
// original package name, to keep references working.
if imp.Name == nil {
imp.Name = &ast.Ident{
NamePos: imp.Path.ValuePos, // ensure it ends up on the same line
Name: lpkg.Name,
}
}
return true
}
return astutil.Apply(file, pre, post).(*ast.File)
}
func (tf *transformer) transformLink(args []string) ([]string, error) {
// We can't split by the ".a" extension, because cached object files
// lack any extension.
flags, args := splitFlagsFromArgs(args)
newImportCfg, err := tf.processImportCfg(flags, nil)
if err != nil {
return nil, err
}
// TODO: unify this logic with the -X handling when using -literals.
// We should be able to handle both cases via the syntax tree.
//
// Make sure -X works with obfuscated identifiers.
// To cover both obfuscated and non-obfuscated names,
// duplicate each flag with a obfuscated version.
flagValueIter(flags, "-X", func(val string) {
// val is in the form of "foo.com/bar.name=value".
fullName, stringValue, found := strings.Cut(val, "=")
if !found {
return // invalid
}
// fullName is "foo.com/bar.name"
i := strings.LastIndexByte(fullName, '.')
path, name := fullName[:i], fullName[i+1:]
// If the package path is "main", it's the current top-level
// package we are linking. Otherwise, find it in the cache.
lpkg := tf.curPkg
if path != "main" {
lpkg = sharedCache.ListedPackages[path]
}
if lpkg == nil {
// We couldn't find the package.
// Perhaps a typo, perhaps not part of the build.
// cmd/link ignores those, so we should too.
return
}
newName := hashWithPackage(lpkg, name)
flags = append(flags, fmt.Sprintf("-X=%s.%s=%s", lpkg.obfuscatedImportPath(), newName, stringValue))
})
// Starting in Go 1.17, Go's version is implicitly injected by the linker.
// It's the same method as -X, so we can override it with an extra flag.
flags = append(flags, "-X=runtime.buildVersion=unknown")
// Ensure we strip the -buildid flag, to not leak any build IDs for the
// link operation or the main package's compilation.
flags = flagSetValue(flags, "-buildid", "")
// Strip debug information and symbol tables.
flags = append(flags, "-w", "-s")
flags = flagSetValue(flags, "-importcfg", newImportCfg)
return append(flags, args...), nil
}