From 6ace03322f57caad0e92fa3efff216b121e23d92 Mon Sep 17 00:00:00 2001 From: pagran Date: Sun, 8 Jan 2023 17:49:47 +0100 Subject: [PATCH] patch and rebuild cmd/link to modify the magic value in pclntab This value is hard-coded in the linker and written in a header. We could rewrite the final binary, like we used to do with import paths, but that would require once again maintaining libraries to do so. Instead, we're now modifying the linker to do what we want. It's not particularly hard, as every Go install has its source code, and rebuilding a slightly modified linker only takes a few seconds at most. Thanks to `go build -overlay`, we only need to copy the files we modify, and right now we're just modifying one file in the toolchain. We use a git patch, as the change is fairly static and small, and the patch is easier to understand and maintain. The other side of this change is in the runtime, as it also hard-codes the magic value when loading information. We modify the code via syntax trees in that case, like `-tiny` does, because the change is tiny (one literal) and the affected lines of code are modified regularly between major Go releases. Since rebuilding a slightly modified linker can take a few seconds, and Go's build cache does not cache linked binaries, we keep our own cached version of the rebuilt binary in `os.UserCacheDir`. The feature isn't perfect, and will be improved in the future. See the TODOs about the added dependency on `git`, or how we are currently only able to cache one linker binary at once. Fixes #622. --- README.md | 1 + go.mod | 1 + go.sum | 2 + hash.go | 14 + internal/linker/linker.go | 250 ++++++++++++++++++ .../patches/0001-add-custom-magic-value.patch | 36 +++ main.go | 33 ++- main_test.go | 6 + runtime_strip.go => runtime_patch.go | 50 ++++ shared.go | 2 + testdata/script/linker.txtar | 45 ++++ 11 files changed, 434 insertions(+), 6 deletions(-) create mode 100644 internal/linker/linker.go create mode 100644 internal/linker/patches/0001-add-custom-magic-value.patch rename runtime_strip.go => runtime_patch.go (76%) create mode 100644 testdata/script/linker.txtar diff --git a/README.md b/README.md index adfdfc8..b15eb93 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 4857de4..4e46539 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 8db2f70..60df0c3 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 e45e3c0..96380fb 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 0000000..e1d081a --- /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 0000000..a4c71fa --- /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 0d868af..5305f38 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" ) @@ -437,7 +438,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 { @@ -879,10 +895,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 f5825b2..aa49c9b 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 9ed5825..98eb7fd 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 ba1b76a..8d7e28d 100644 --- a/shared.go +++ b/shared.go @@ -51,6 +51,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 0000000..e802044 --- /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?