diff --git a/README.md b/README.md index adfdfc83..b15eb93e 100644 --- a/README.md +++ b/README.md @@ -140,6 +140,7 @@ to document the current shortcomings of this tool. ``` * Go plugins are not currently supported; see [#87](https://github.com/burrowers/garble/issues/87). +* Garble requires `git` to patch the linker. That can be avoided once go-gitdiff supports [non-strict patches](https://github.com/bluekeyes/go-gitdiff/issues/30). ### Contributing diff --git a/go.mod b/go.mod index 4857de40..4e465394 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module mvdan.cc/garble go 1.19 require ( + github.com/bluekeyes/go-gitdiff v0.7.0 github.com/frankban/quicktest v1.14.3 github.com/google/go-cmp v0.5.8 github.com/rogpeppe/go-internal v1.9.0 diff --git a/go.sum b/go.sum index 8db2f70a..60df0c3c 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/bluekeyes/go-gitdiff v0.7.0 h1:w4SrRFcufU0/tEpWx3VurDBAnWfpxsmwS7yWr14meQk= +github.com/bluekeyes/go-gitdiff v0.7.0/go.mod h1:QpfYYO1E0fTVHVZAZKiRjtSGY9823iCdvGXBcEzHGbM= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/frankban/quicktest v1.14.3 h1:FJKSZTDHjyhriyC81FLQ0LY93eSai0ZyR/ZIkd3ZUKE= github.com/frankban/quicktest v1.14.3/go.mod h1:mgiwOwqx65TmIk1wJ6Q7wvnVMocbUorkibMOrVTHZps= diff --git a/hash.go b/hash.go index 3473fd67..afb55897 100644 --- a/hash.go +++ b/hash.go @@ -7,6 +7,7 @@ import ( "bytes" "crypto/sha256" "encoding/base64" + "encoding/binary" "fmt" "go/token" "go/types" @@ -189,6 +190,19 @@ func isUpper(b byte) bool { return 'A' <= b && b <= 'Z' } func toLower(b byte) byte { return b + ('a' - 'A') } func toUpper(b byte) byte { return b - ('a' - 'A') } +// magicValue returns random magic value based +// on user specified seed or the runtime package's GarbleActionID. +func magicValue() uint32 { + hasher.Reset() + if !flagSeed.present() { + hasher.Write(cache.ListedPackages["runtime"].GarbleActionID) + } else { + hasher.Write(flagSeed.bytes) + } + sum := hasher.Sum(sumBuffer[:0]) + return binary.LittleEndian.Uint32(sum) +} + func hashWithPackage(pkg *listedPackage, name string) string { if !flagSeed.present() { return hashWithCustomSalt(pkg.GarbleActionID, name) diff --git a/internal/linker/linker.go b/internal/linker/linker.go new file mode 100644 index 00000000..e1d081ac --- /dev/null +++ b/internal/linker/linker.go @@ -0,0 +1,250 @@ +// Copyright (c) 2022, The Garble Authors. +// See LICENSE for licensing information. + +package linker + +import ( + "bytes" + "crypto/sha256" + "embed" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "io/fs" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + + "github.com/bluekeyes/go-gitdiff/gitdiff" + "github.com/rogpeppe/go-internal/lockedfile" +) + +const ( + MagicValueEnv = "GARBLE_LINKER_MAGIC" + + cacheDirName = "garble" + versionExt = ".version" + garbleCacheDir = "GARBLE_CACHE_DIR" + baseSrcSubdir = "src" +) + +var ( + //go:embed patches/*.patch + linkerPatchesFS embed.FS +) + +func loadLinkerPatches() (string, map[string]string, error) { + versionHash := sha256.New() + patches := make(map[string]string) + err := fs.WalkDir(linkerPatchesFS, ".", func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + + patchBytes, err := linkerPatchesFS.ReadFile(path) + if err != nil { + return err + } + + if _, err := versionHash.Write(patchBytes); err != nil { + return err + } + + files, _, err := gitdiff.Parse(bytes.NewReader(patchBytes)) + if err != nil { + return err + } + for _, file := range files { + if file.IsNew || file.IsDelete || file.IsCopy || file.IsRename { + panic("only modification patch is supported") + } + patches[file.OldName] = string(patchBytes) + } + return nil + }) + + if err != nil { + return "", nil, err + } + return base64.RawStdEncoding.EncodeToString(versionHash.Sum(nil)), patches, nil +} + +// TODO(pagran): Remove git dependency in future +// more information in README.md +func applyPatch(workingDir, patch string) error { + cmd := exec.Command("git", "-C", workingDir, "apply") + cmd.Stdin = strings.NewReader(patch) + return cmd.Run() +} + +func copyFile(src, target string) error { + targetDir := filepath.Dir(target) + if err := os.MkdirAll(targetDir, 0o777); err != nil { + return err + } + srcFile, err := os.Open(src) + if err != nil { + return err + } + defer srcFile.Close() + + targetFile, err := os.Create(target) + if err != nil { + return err + } + defer targetFile.Close() + _, err = io.Copy(targetFile, srcFile) + return err +} + +func fileExists(path string) bool { + stat, err := os.Stat(path) + if err != nil { + return false + } + return !stat.IsDir() +} + +func applyPatches(srcDir, workingDir string, patches map[string]string) (map[string]string, error) { + mod := make(map[string]string) + for fileName, patch := range patches { + oldPath := filepath.Join(srcDir, fileName) + newPath := filepath.Join(workingDir, fileName) + mod[oldPath] = newPath + + if err := copyFile(oldPath, newPath); err != nil { + return nil, err + } + + if err := applyPatch(workingDir, patch); err != nil { + return nil, fmt.Errorf("apply patch for %s failed: %v", fileName, err) + } + } + return mod, nil +} + +func cachePath(goExe string) (string, error) { + var cacheDir string + if val, ok := os.LookupEnv(garbleCacheDir); ok { + cacheDir = val + } else { + userCacheDir, err := os.UserCacheDir() + if err != nil { + panic(fmt.Errorf("cannot retreive user cache directory: %v", err)) + } + cacheDir = userCacheDir + } + + cacheDir = filepath.Join(cacheDir, cacheDirName) + if err := os.MkdirAll(cacheDir, 0o777); err != nil { + return "", err + } + + // Note that we only keep one patched and built linker in the cache. + // If the user switches between Go versions or garble versions often, + // this may result in rebuilds since we don't keep multiple binaries in the cache. + // We can consider keeping multiple versions of the binary in our cache in the future, + // similar to how GOCACHE works with multiple built versions of the same package. + return filepath.Join(cacheDir, "link"+goExe), nil +} + +func getCurrentVersion(goVersion, patchesVer string) string { + return goVersion + " " + patchesVer +} + +func checkVersion(linkerPath, goVersion, patchesVer string) (bool, error) { + versionPath := linkerPath + versionExt + version, err := os.ReadFile(versionPath) + if os.IsNotExist(err) { + return false, nil + } + if err != nil { + return false, err + } + + return string(version) == getCurrentVersion(goVersion, patchesVer), nil +} + +func writeVersion(linkerPath, goVersion, patchesVer string) error { + versionPath := linkerPath + versionExt + return os.WriteFile(versionPath, []byte(getCurrentVersion(goVersion, patchesVer)), 0o777) +} + +func buildLinker(workingDir string, overlay map[string]string, outputLinkPath string) error { + file, err := json.Marshal(&struct{ Replace map[string]string }{overlay}) + if err != nil { + return err + } + overlayPath := filepath.Join(workingDir, "overlay.json") + if err := os.WriteFile(overlayPath, file, 0o777); err != nil { + return err + } + + cmd := exec.Command("go", "build", "-overlay", overlayPath, "-o", outputLinkPath, "cmd/link") + // Explicitly setting GOOS and GOARCH variables prevents conflicts during cross-build + cmd.Env = append(os.Environ(), "GOOS="+runtime.GOOS, "GOARCH="+runtime.GOARCH) + + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("compiler compile error: %v\n\n%s", err, string(out)) + } + + return nil +} + +func PatchLinker(goRoot, goVersion, goExe, tempDir string) (string, func(), error) { + patchesVer, patches, err := loadLinkerPatches() + if err != nil { + panic(fmt.Errorf("cannot retrieve linker patches: %v", err)) + } + + outputLinkPath, err := cachePath(goExe) + if err != nil { + return "", nil, err + } + + mutex := lockedfile.MutexAt(outputLinkPath + ".lock") + unlock, err := mutex.Lock() + if err != nil { + return "", nil, err + } + + // If build is successful, mutex unlocking must be on the caller's side + successBuild := false + defer func() { + if !successBuild { + unlock() + } + }() + + isCorrectVer, err := checkVersion(outputLinkPath, goVersion, patchesVer) + if err != nil { + return "", nil, err + } + if isCorrectVer && fileExists(outputLinkPath) { + successBuild = true + return outputLinkPath, unlock, nil + } + + srcDir := filepath.Join(goRoot, baseSrcSubdir) + workingDir := filepath.Join(tempDir, "linker-src") + + overlay, err := applyPatches(srcDir, workingDir, patches) + if err != nil { + return "", nil, err + } + if err := buildLinker(workingDir, overlay, outputLinkPath); err != nil { + return "", nil, err + } + if err := writeVersion(outputLinkPath, goVersion, patchesVer); err != nil { + return "", nil, err + } + successBuild = true + return outputLinkPath, unlock, nil +} diff --git a/internal/linker/patches/0001-add-custom-magic-value.patch b/internal/linker/patches/0001-add-custom-magic-value.patch new file mode 100644 index 00000000..a4c71fa7 --- /dev/null +++ b/internal/linker/patches/0001-add-custom-magic-value.patch @@ -0,0 +1,36 @@ +From de93a968f1bb3500088b30cbdce439e6a0d95e58 Mon Sep 17 00:00:00 2001 +From: pagran +Date: Sun, 8 Jan 2023 14:12:51 +0100 +Subject: [PATCH 1/1] add custom magic value + +--- + cmd/link/internal/ld/pcln.go | 13 +++++++++++++ + 1 file changed, 13 insertions(+) + +diff --git a/cmd/link/internal/ld/pcln.go b/cmd/link/internal/ld/pcln.go +index 34ab86cf12..1ec237ffc8 100644 +--- a/cmd/link/internal/ld/pcln.go ++++ b/cmd/link/internal/ld/pcln.go +@@ -249,6 +249,19 @@ func (state *pclntab) generatePCHeader(ctxt *Link) { + if off != size { + panic(fmt.Sprintf("pcHeader size: %d != %d", off, size)) + } ++ ++ // Use garble prefix in variable names to minimize collision risk ++ garbleMagicStr := os.Getenv("GARBLE_LINKER_MAGIC") ++ if garbleMagicStr == "" { ++ panic("[garble] magic value must be set") ++ } ++ var garbleMagicVal uint32 ++ // Use fmt package instead of strconv to avoid importing a new package ++ if _, err := fmt.Sscan(garbleMagicStr, &garbleMagicVal); err != nil { ++ panic(fmt.Errorf("[garble] invalid magic value %s: %v", garbleMagicStr, err)) ++ } ++ ++ header.SetUint32(ctxt.Arch, 0, garbleMagicVal) + } + + state.pcheader = state.addGeneratedSym(ctxt, "runtime.pcheader", size, writeHeader) +-- +2.38.1.windows.1 + diff --git a/main.go b/main.go index 8197c32f..c566743f 100644 --- a/main.go +++ b/main.go @@ -40,6 +40,7 @@ import ( "golang.org/x/mod/semver" "golang.org/x/tools/go/ast/astutil" + "mvdan.cc/garble/internal/linker" "mvdan.cc/garble/internal/literals" ) @@ -436,7 +437,22 @@ func mainErr(args []string) error { } else { log.Printf("skipping transform on %s with args: %s", tool, strings.Join(transformed, " ")) } - cmd := exec.Command(args[0], transformed...) + + executablePath := args[0] + if tool == "link" { + modifiedLinkPath, unlock, err := linker.PatchLinker(cache.GoEnv.GOROOT, cache.GoEnv.GOVERSION, cache.GoEnv.GOEXE, sharedTempDir) + if err != nil { + return fmt.Errorf("cannot get modified linker: %v", err) + } + defer unlock() + + executablePath = modifiedLinkPath + os.Setenv(linker.MagicValueEnv, strconv.FormatUint(uint64(magicValue()), 10)) + + log.Printf("replaced linker with: %s", executablePath) + } + + cmd := exec.Command(executablePath, transformed...) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr if err := cmd.Run(); err != nil { @@ -878,10 +894,15 @@ func transformCompile(args []string) ([]string, error) { for i, file := range files { basename := filepath.Base(paths[i]) log.Printf("obfuscating %s", basename) - if curPkg.ImportPath == "runtime" && flagTiny { - // strip unneeded runtime code - stripRuntime(basename, file) - tf.removeUnnecessaryImports(file) + if curPkg.ImportPath == "runtime" { + if flagTiny { + // strip unneeded runtime code + stripRuntime(basename, file) + tf.removeUnnecessaryImports(file) + } + if basename == "symtab.go" { + updateMagicValue(file, magicValue()) + } } tf.handleDirectives(file.Comments) file = tf.transformGo(file) @@ -2211,7 +2232,7 @@ func flagSetValue(flags []string, name, value string) []string { func fetchGoEnv() error { out, err := exec.Command("go", "env", "-json", // Keep in sync with sharedCache.GoEnv. - "GOOS", "GOMOD", "GOVERSION", + "GOOS", "GOMOD", "GOVERSION", "GOROOT", "GOEXE", ).CombinedOutput() if err != nil { // TODO: cover this in the tests. diff --git a/main_test.go b/main_test.go index f5825b2e..aa49c9b8 100644 --- a/main_test.go +++ b/main_test.go @@ -61,6 +61,11 @@ func TestScript(t *testing.T) { t.Fatal(err) } + userCacheDir, err := os.UserCacheDir() + if err != nil { + t.Fatal(err) + } + p := testscript.Params{ Dir: filepath.Join("testdata", "script"), Setup: func(env *testscript.Env) error { @@ -86,6 +91,7 @@ func TestScript(t *testing.T) { "gofullversion="+runtime.Version(), "EXEC_PATH="+execPath, + "GARBLE_CACHE_DIR="+userCacheDir, ) if os.Getenv("TESTSCRIPT_COVER_DIR") != "" { diff --git a/runtime_strip.go b/runtime_patch.go similarity index 76% rename from runtime_strip.go rename to runtime_patch.go index 9ed58257..98eb7fde 100644 --- a/runtime_strip.go +++ b/runtime_patch.go @@ -5,11 +5,61 @@ package main import ( "go/ast" + "go/token" + "strconv" "strings" ah "mvdan.cc/garble/internal/asthelper" ) +// updateMagicValue updates hardcoded value of hdr.magic +// when verifying header in symtab.go +func updateMagicValue(file *ast.File, magicValue uint32) { + magicUpdated := false + + // Find `hdr.magic != 0xfffffff?` in symtab.go and update to random magicValue + updateMagic := func(node ast.Node) bool { + binExpr, ok := node.(*ast.BinaryExpr) + if !ok || binExpr.Op != token.NEQ { + return true + } + + selectorExpr, ok := binExpr.X.(*ast.SelectorExpr) + if !ok { + return true + } + + if ident, ok := selectorExpr.X.(*ast.Ident); !ok || ident.Name != "hdr" { + return true + } + if selectorExpr.Sel.Name != "magic" { + return true + } + + if _, ok := binExpr.Y.(*ast.BasicLit); !ok { + return true + } + binExpr.Y = &ast.BasicLit{ + Kind: token.INT, + Value: strconv.FormatUint(uint64(magicValue), 10), + } + magicUpdated = true + return false + } + + for _, decl := range file.Decls { + funcDecl, ok := decl.(*ast.FuncDecl) + if ok && funcDecl.Name.Name == "moduledataverify1" { + ast.Inspect(funcDecl, updateMagic) + break + } + } + + if !magicUpdated { + panic("magic value not updated") + } +} + // stripRuntime removes unnecessary code from the runtime, // such as panic and fatal error printing, and code that // prints trace/debug info of the runtime. diff --git a/shared.go b/shared.go index bd07e4c3..cf8c5a74 100644 --- a/shared.go +++ b/shared.go @@ -50,6 +50,8 @@ type sharedCache struct { GOMOD string GOVERSION string + GOROOT string + GOEXE string } } diff --git a/testdata/script/linker.txtar b/testdata/script/linker.txtar new file mode 100644 index 00000000..e802044a --- /dev/null +++ b/testdata/script/linker.txtar @@ -0,0 +1,45 @@ +garble build +exec ./main +! cmp stderr main.stderr + +[short] stop # no need to verify this with -short + +go build +exec ./main +cmp stderr main.stderr +-- go.mod -- +module test/main + +go 1.19 + +-- main.go -- +package main + +import ( + "strconv" + "strings" + _ "unsafe" +) + +type fakeModuleData struct { + pcHeader *struct { + magic uint32 + } +} + +//go:linkname activeModules runtime.activeModules +func activeModules() []*fakeModuleData + +// genericMagicValue returns magic value without last digit +func genericMagicValue() string { + mod := activeModules()[0] + magicValHex := strings.ToUpper(strconv.FormatUint(uint64(mod.pcHeader.magic), 16)) + return "0x" + magicValHex[:len(magicValHex)-1] + "?" +} + +func main() { + println(genericMagicValue()) +} + +-- main.stderr -- +0xFFFFFFF?