作者:rook1e@知道創宇404實驗室
時間:2021年5月19日

近年來 Golang 熱度飆升,得益于其性能優異、開發效率高、跨平臺等特性,被廣泛應用在開發領域。在享受 Golang 帶來便利的同時,如何保護代碼、提高逆向破解難度也是開發者們需要思考的問題。

由于 Golang 的反射等機制,需要將文件路徑、函數名等大量信息打包進二進制文件,這部分信息無法被 strip,所以考慮通過混淆代碼的方式提高逆向難度。

本文主要通過分析 burrowers/garble 項目的實現來探索 Golang 代碼混淆技術,因為相關資料較少,本文大部分內容是通過閱讀源碼來分析的,如有錯誤請師傅們在評論區或郵件指正。

前置知識

編譯過程

Go 的編譯過程可以抽象為:

  1. 詞法分析:將字符序列轉換為 token 序列
  2. 語法分析:解析 token 成 AST
  3. 類型檢查
  4. 生成中間代碼
  5. 生成機器碼

本文不展開編譯原理的內容,詳細內容推薦閱讀 Go 語言設計與實現 #編譯原理Introduction to the Go compiler

下面我們從源碼角度更直觀的探索編譯的過程。go build 的實現在 src/cmd/go/internal/work/build.go,忽略設置編譯器類型、環境信息等處理,我們只關注最核心的部分:

func runBuild(ctx context.Context, cmd *base.Command, args []string) {
    ...
  var b Builder
  ...
  pkgs := load.PackagesAndErrors(ctx, args)
  ...
    a := &Action{Mode: "go build"}
    for _, p := range pkgs {
        a.Deps = append(a.Deps, b.AutoAction(ModeBuild, depMode, p))
    }
    ...
    b.Do(ctx, a)
}

這里的 Action 結構體表示一個行為,每個 action 有描述、所屬包、依賴(Deps)等信息,所有關聯起來的 action 構成一個 action graph。

// An Action represents a single action in the action graph.
type Action struct {
    Mode     string         // description of action operation
    Package  *load.Package  // the package this action works on
    Deps     []*Action      // actions that must happen before this one
    Func     func(*Builder, context.Context, *Action) error // the action itself (nil = no-op)
    ...
}

在創建好 a 行為作為“根頂點”后,遍歷命令中指定的要編譯的包,為每個包創建 action,這個創建行為是遞歸的,創建過程中會分析它的依賴,再為依賴創建 action,例如 src/cmd/go/internal/work/action.go (b *Builder) CompileAction 方法:

for _, p1 := range p.Internal.Imports {
    a.Deps = append(a.Deps, b.CompileAction(depMode, depMode, p1))
}

最終的 a.Deps 就是 action graph 的“起點”。構造出 action graph 后,將 a 頂點作為“根”進行深度優先遍歷,把依賴的 action 依次加入任務隊列,最后并發執行 action.Func

每一類 action 的 Func 都有指定的方法,是 action 中核心的部分,例如:

a := &Action{
  Mode: "build",
  Func: (*Builder).build,
  ...
}

a := &Action{
  Mode: "link",
  Func: (*Builder).link,
  ...
}
...

進一步跟進會發現,除了一些必要的預處理,(*Builder).link 中會調用 BuildToolchain.ld 方法,(*Builder).build 會調用 BuildToolchain.symabisBuildToolchain.gcBuildToolchain.asmBuildToolchain.pack 等方法來實現核心功能。BuildToolchain 是 toolchain 接口類型的,定義了下列方法:

// src/cmd/go/internal/work/exec.go
type toolchain interface {
    // gc runs the compiler in a specific directory on a set of files
    // and returns the name of the generated output file.
    gc(b *Builder, a *Action, archive string, importcfg, embedcfg []byte, symabis string, asmhdr bool, gofiles []string) (ofile string, out []byte, err error)
    // cc runs the toolchain's C compiler in a directory on a C file
    // to produce an output file.
    cc(b *Builder, a *Action, ofile, cfile string) error
    // asm runs the assembler in a specific directory on specific files
    // and returns a list of named output files.
    asm(b *Builder, a *Action, sfiles []string) ([]string, error)
    // symabis scans the symbol ABIs from sfiles and returns the
    // path to the output symbol ABIs file, or "" if none.
    symabis(b *Builder, a *Action, sfiles []string) (string, error)
    // pack runs the archive packer in a specific directory to create
    // an archive from a set of object files.
    // typically it is run in the object directory.
    pack(b *Builder, a *Action, afile string, ofiles []string) error
    // ld runs the linker to create an executable starting at mainpkg.
    ld(b *Builder, root *Action, out, importcfg, mainpkg string) error
    // ldShared runs the linker to create a shared library containing the pkgs built by toplevelactions
    ldShared(b *Builder, root *Action, toplevelactions []*Action, out, importcfg string, allactions []*Action) error

    compiler() string
    linker() string
}

Go 分別為 gc 和 gccgo 編譯器實現了此接口,go build 會在程序初始化時進行選擇:

func init() {
    switch build.Default.Compiler {
    case "gc", "gccgo":
        buildCompiler{}.Set(build.Default.Compiler)
    }
}

func (c buildCompiler) Set(value string) error {
    switch value {
    case "gc":
        BuildToolchain = gcToolchain{}
    case "gccgo":
        BuildToolchain = gccgoToolchain{}
  ...
}

這里我們只看 gc 編譯器部分 src/cmd/go/internal/work/gc.go。以 gc 方法為例:

func (gcToolchain) gc(b *Builder, a *Action, archive string, importcfg, embedcfg []byte, symabis string, asmhdr bool, gofiles []string) (ofile string, output []byte, err error) {
    // ...
    // 拼接參數
    // ...

    args := []interface{}{cfg.BuildToolexec, base.Tool("compile"), "-o", ofile, "-trimpath", a.trimpath(), gcflags, gcargs, "-D", p.Internal.LocalPrefix}

    // ...

    output, err = b.runOut(a, base.Cwd, nil, args...)
    return ofile, output, err
}

粗略的看,其實 gc 方法并沒有實現具體的編譯工作,它的主要作用是拼接命令來調用路徑為 base.Tool("compile") 的二進制程序。這些程序可以被稱為 Go 編譯工具,位于 pkg/tool 目錄下,源碼位于 src/cmd。同理,其他的方法也是調用了相應的編譯工具完成實際的編譯工作。

細心的讀者可能會發現一個有趣的問題:拼接的命令中真正的運行對象并不是編譯工具,而是 cfg.BuildToolexec。跟進到定義處可知它是由 go build -toolexec 參數設置的,官方釋義為:

-toolexec 'cmd args'
  a program to use to invoke toolchain programs like vet and asm.
  For example, instead of running asm, the go command will run
  'cmd args /path/to/asm <arguments for asm>'.

即用 -toolexec 指定的程序來運行編譯工具。這其實可以看作是一個 hook 機制,利用這個參數來指定一個我們的程序,在編譯時用這個程序調用編譯工具,從而介入編譯過程,下文中分析的 garble 項目就是使用了這種思路。附一段從編譯過程中截取的命令( go build -n 參數可以輸出執行的命令)方便理解,比如我們指定了 -toolexec=/home/atom/go/bin/garble,那么編譯時實際執行的就是:

/home/atom/go/bin/garble /usr/local/go/pkg/tool/linux_amd64/compile -o $WORK/b016/_pkg_.a -trimpath "/usr/local/go/src/sync=>sync;$WORK/b016=>" -p sync -std -buildid FRNt7EHDh77qHujLKnmK/FRNt7EHDh77qHujLKnmK -goversion go1.16.4 -D "" -importcfg $WORK/b016/importcfg -pack -c=4 /usr/local/go/src/sync/cond.go /usr/local/go/src/sync/map.go /usr/local/go/src/sync/mutex.go /usr/local/go/src/sync/once.go /usr/local/go/src/sync/pool.go /usr/local/go/src/sync/poolqueue.go /usr/local/go/src/sync/runtime.go /usr/local/go/src/sync/runtime2.go /usr/local/go/src/sync/rwmutex.go /usr/local/go/src/sync/waitgroup.go

總結一下,go build 通過拼接命令的方式調用 compile 等編譯工具來實現具體的編譯工作,我們可以使用 go build -toolexec 參數來指定一個程序“介入”編譯過程。

go/ast

Golang 中 AST 的類型及方法由 go/ast 標準庫定義。后文分析的 garble 項目中會有大量涉及 go/ast 的類型斷言和類型選擇,所以有必要對這些類型有大致了解。大部分類型定義在 src/go/ast/ast.go ,其中的注釋足夠詳細,但為了方便梳理關系,筆者整理了關系圖,圖中的分叉代表繼承關系,所有類型都基于 Node 接口:

go/ast類型

本文無意去深入探究 AST,但相信讀者只要對 AST 有基礎的了解就足以理解本文的后續內容。如果理解困難,建議閱讀 Go語法樹入門——開啟自制編程語言和編譯器之旅! 補充需要的知識,也可以通過在線工具 goast-viewer 將 AST 可視化來輔助分析。

工具分析

開源社區中關于 Go 代碼混淆 star 比較多的兩個項目是 burrowers/garbleunixpickle/gobfuscate,前者的特性更新一些,所以本文主要分析 garble,版本 8edde922ee5189f1d049edb9487e6090dd9d45bd

特性

  • 支持 modules,Go 1.16+
  • 不處理以下情況:
  • CGO
  • ignoreObjects 標記的:
    • 傳入 reflect.ValueOfreflect.TypeOf 方法的參數的類型
    • go:linkname 中使用的函數
    • 導出的方法
    • 從未混淆的包中引入的類型和變量
    • 常量
  • runtime 及其依賴的包(support obfuscating the runtime package #193
  • Go 插件
  • 哈希處理符合條件的包、函數、變量、類型等的名稱
  • 將字符串替換為匿名函數
  • 移除調試信息、符號表
  • 可以設置 -debugdir 輸出混淆過的 Go 代碼
  • 可以指定不同的種子以混淆出不同的結果

整體上可以將 garble 分為兩種模式:

  • 主動模式:當命令傳入的第一個指令與 garble 的預設相匹配時,代表是被用戶主動調用的。此階段會根據參數進行配置、獲取依賴包信息等,然后將配置持久化。如果指令是 build 或 test,則再向命令中添加 -toolexec=path/to/garble 將自己設置為編譯工具的啟動器,引出啟動器模式
  • 啟動器模式:對 tool/asm/link 這三個工具進行“攔截”,在編譯工具運行前進行源代碼混淆、修改運行參數等操作,最后運行工具編譯混淆后的代碼

獲取和修改參數的工作花費了大量的代碼,為了方便分析,后文會將其一筆帶過,感興趣的讀者可以查詢官方文檔來了解各個參數的作用。

構造目標列表

構造目標列表的行為發生在主動模式中,截取部分重要的代碼:

// listedPackage contains the 'go list -json -export' fields obtained by the
// root process, shared with all garble sub-processes via a file.
type listedPackage struct {
    Name       string
    ImportPath string
    ForTest    string
    Export     string
    BuildID    string
    Deps       []string
    ImportMap  map[string]string
    Standard   bool

    Dir     string
    GoFiles []string

    // The fields below are not part of 'go list', but are still reused
    // between garble processes. Use "Garble" as a prefix to ensure no
    // collisions with the JSON fields from 'go list'.

    GarbleActionID []byte

    Private bool
}

func setListedPackages(patterns []string) error {
  args := []string{"list", "-json", "-deps", "-export", "-trimpath"}
  args = append(args, cache.BuildFlags...)
  args = append(args, patterns...)
  cmd := exec.Command("go", args...)
  ...
  cache.ListedPackages = make(map[string]*listedPackage)
  for ...{
    var pkg listedPackage
    ...
    cache.ListedPackages[pkg.ImportPath] = &pkg
    ...
  }
}

核心是利用 go list 命令,其中指定的 -deps 參數官方釋義為:

The -deps flag causes list to iterate over not just the named packages but also all their dependencies. It visits them in a depth-first post-order traversal, so that a package is listed only after all its dependencies. Packages not explicitly listed on the command line will have the DepOnly field set to true.

這里的遍歷其實與前文分析的 go build 創建 action 時的很相似。通過這條命令 garble 可以獲取到項目所有的依賴信息(包括間接依賴),遍歷并存入 cache.ListedPackages。除此之外還要標記各個依賴包是否在 env.GOPRIVATE 目錄下,只有此目錄下的文件才會被混淆(特例是使用了 -tiny 參數時會處理一部分 runtime)。可以通過設置環境變量 GOPRIVATE="*" 來擴大范圍以獲得更好的混淆效果。關于混淆范圍的問題,garble 的作者也在嘗試優化:idea: break away from GOPRIVATE? #276

至此,需要混淆的目標已經明確。加上一些保存配置信息的操作,主動模式的任務已基本完成,然后就可以運行拼接起的命令,引出啟動器模式。

啟動器模式中會對 compile/asm/link 這三個編譯器工具進行攔截并“介入編譯過程”,打起引號是因為 garble 實際上并沒有完成任何實際的編譯工作,如同 go build ,它只是作為中間商修改了源代碼或者修改了命令中傳給編譯工具的參數,最后還是要依靠這三個編譯工具來實現具體的編譯工作,下面逐一分析。

compile

實現位于 main.go transformCompile 函數,主要工作是處理 go 文件和修改命令參數。go build -n 參數可以輸出執行的命令,我們可以在使用 garble 時傳入這個參數來更直觀的了解編譯過程。截取其中一條:

/home/atom/go/bin/garble /usr/local/go/pkg/tool/linux_amd64/compile -o $WORK/b016/_pkg_.a -trimpath "/usr/local/go/src/sync=>sync;$WORK/b016=>" -p sync -std -buildid FRNt7EHDh77qHujLKnmK/FRNt7EHDh77qHujLKnmK -goversion go1.16.4 -D "" -importcfg $WORK/b016/importcfg -pack -c=4 /usr/local/go/src/sync/cond.go /usr/local/go/src/sync/map.go /usr/local/go/src/sync/mutex.go /usr/local/go/src/sync/once.go /usr/local/go/src/sync/pool.go /usr/local/go/src/sync/poolqueue.go /usr/local/go/src/sync/runtime.go /usr/local/go/src/sync/runtime2.go /usr/local/go/src/sync/rwmutex.go /usr/local/go/src/sync/waitgroup.go

這條命令使用 compile 編譯工具來將 cond.go 等諸多文件編譯成中間代碼。garble 識別到當前的編譯工具是 compile,于是”攔截“,在工具運行前做一些混淆等工作。下面分析一下相對重要的部分。

首先要將傳入的 go 文件解析成 AST:

var files []*ast.File
for _, path := range paths {
  file, err := parser.ParseFile(fset, path, nil, parser.ParseComments)
  if err != nil {
    return nil, err
  }
  files = append(files, file)
}

然后進行類型檢查, 這也是正常編譯時會進行的一步,類型檢查不通過則代表文件無法編譯成功,程序退出。

因為參與反射(reflect.ValueOf / reflect.TypeOf)的節點的類型名稱可能會在后續邏輯中使用,所以不能對其名稱進行混淆:

if fnType.Pkg().Path() == "reflect" && (fnType.Name() == "TypeOf" || fnType.Name() == "ValueOf") {
  for _, arg := range call.Args {
    argType := tf.info.TypeOf(arg)
    tf.recordIgnore(argType, tf.pkg.Path())
  }
}

這里引出了一個貫穿每次 compile 生命周期的重要 map,記錄了所有不能進行混淆的對象:用在反射參數的類型,用在常量表達式和 go:linkname 的標識符,從沒被混淆的包中引入的變量和類型:

// ignoreObjects records all the objects we cannot obfuscate. An object
// is any named entity, such as a declared variable or type.
//
// So far, this map records:
//
//  * Types which are used for reflection; see recordReflectArgs.
//  * Identifiers used in constant expressions; see RecordUsedAsConstants.
//  * Identifiers used in go:linkname directives; see handleDirectives.
//  * Types or variables from external packages which were not
//    obfuscated, for caching reasons; see transformGo.
ignoreObjects map[types.Object]bool

我們以判別「用在常量表達式中的標識符」且類型是 ast.GenDecl 的情況為例:

// RecordUsedAsConstants records identifieres used in constant expressions.
func RecordUsedAsConstants(node ast.Node, info *types.Info, ignoreObj map[types.Object]bool) {
    visit := func(node ast.Node) bool {
        ident, ok := node.(*ast.Ident)
        if !ok {
            return true
        }

        // Only record *types.Const objects.
        // Other objects, such as builtins or type names,
        // must not be recorded as they would be false positives.
        obj := info.ObjectOf(ident)
        if _, ok := obj.(*types.Const); ok {
            ignoreObj[obj] = true
        }

        return true
    }

    switch x := node.(type) {
    ...
    // in a const declaration all values must be constant representable
    case *ast.GenDecl:
        if x.Tok != token.CONST {
            break
        }
        for _, spec := range x.Specs {
            spec := spec.(*ast.ValueSpec)

            for _, val := range spec.Values {
                ast.Inspect(val, visit)
            }
        }
    }
}

假設需要混淆的代碼是:

package obfuscate

const (
    H2 string = "a"
    H4 string = "a" + H2
    H3 int    = 123
    H5 string = "a"
)

可以看到用于常量表達式的標識符是 H2,我們通過代碼分析一下判定過程。首先整個 const 塊符合 ast.GenDecl 類型,然后遍歷其 Specs(每個定義),對每個 spec 遍歷其 Values(等號右邊的表達式),再對 val 中的元素使用 ast.Inspect() 遍歷執行 visit(),如果元素節點的類型是 ast.Ident 且指向的 obj 的類型是 types.Const,則將此 obj 記入 tf.recordIgnore。有點繞,我們把 AST 打印出來看:

ignoreObjects-example

可以很清晰地看到 H4 string = "a" + H2 中的 H2 完全符合條件,所以應該被記入 tf.recordIgnore。接下來要分析的功能中會涉及到大量類型斷言和類型選擇,看起來復雜但本質上與剛剛的分析過程類似,我們只要將寫個 demo 并打印出 AST 就很容易理解了。

回到 main.go transformCompile。接下來對當前的包名進行混淆并寫入命令參數和源文件中,要求文件既不是 main 包,也不在 env.GOPRIVATE 目錄之外。下一步將處理注釋和源代碼,這里會對 runtime 和 CGO 單獨處理,我們大可忽略,直接看對普通 Go 代碼的處理:

// transformGo obfuscates the provided Go syntax file.
func (tf *transformer) transformGo(file *ast.File) *ast.File {
    if opts.GarbleLiterals {
        file = literals.Obfuscate(file, tf.info, fset, tf.ignoreObjects)
    }

    pre := func(cursor *astutil.Cursor) bool {...}
    post := func(cursor *astutil.Cursor) bool {...}

    return astutil.Apply(file, pre, post).(*ast.File)
}

首先混淆字符,然后遞歸處理 AST 的每個節點,最后返回處理完成的 AST。這幾部分的思路很相似,都是利用 astutil.Apply(file, pre, post) 進行 AST 的遞歸處理,其中 pre 和 post 函數分別用于訪問孩子節點前和訪問后。這部分的代碼大都是比較繁瑣的篩選操作,下面僅作簡要分析:

  • literals.Obfuscate pre

跳過如下情況:值需要推導的、含有非基礎類型的、類型需要推導的(隱式類型定義)、ignoreObj 標記了的常量。將通過篩選的常量的 token 由 const 改為 var,方便后續用匿名函數代替常量值,但如果一個 const 塊中有一個不能被改為 var,則整個塊都不會被修改。

  • literals.Obfuscate post

將字符串、byte 切片或數組的值替換為匿名函數,效果如圖:

obfuscated-literals

  • transformGo pre

跳過名稱中含有 _(未命名) _C / _cgo (cgo 代碼)的節點,若是嵌入字段則要找到實際要處理的 obj,再根據 obj 的類型繼續細分篩選:

  • types.Var :跳過非全局變量,若是字段則則將其結構體的類型名作為 hash salt,如果字段所屬結構體是未被混淆的,則記入 tf.ignoreObjects
  • types.TypeName:跳過非全局類型,若該類型在定義處沒有混淆,則跳過
  • types.Func:跳過導出的方法、main/ init/TestMain 函數 、測試函數

若節點通過篩選,則將其名稱進行哈希處理

  • transformGo post:哈希處理導入路徑

至此已經完成了對源代碼的混淆,只需要將新的代碼寫入臨時目錄,并把地址拼接到命令中代替原文件路徑,一條新的 compile 命令就完成了,最后執行這條命令就可以使用編譯工具編譯混淆后的代碼。

asm

比較簡單,只作用于 private 的包,核心操作如下:

  • 將臨時文件夾路徑添加到 -trimpath 參數首部
  • 將調用的函數的名稱替換為混淆后的,Go 匯編文件中調用的函數名前都有 ·,以此為特征搜索

比較簡單,核心操作如下:

  • -X pkg.name=str 參數標記的包名(pkg)、變量名(name)替換為混淆后的
  • -buildid 參數置空以避免 build id 泄露
  • 添加 -w -s 參數以移除調試信息、符號表、DWARF 符號表

混淆效果

編寫一小段代碼,分別進行 go build .go env -w GOPRIVATE="*" && garble -literals build . 兩次編譯。可以看到左側很簡單的代碼經過混淆后變得難以閱讀:

obfuscated-show-1

obfuscated-show-2

再放入 IDA 中用 go_parser 解析一下。混淆前的文件名函數名等信息清晰可見,代碼邏輯也算工整:

obfuscated-show-ida-1

混淆后函數名等信息被亂碼替代,且因為字符串被替換為了匿名函數,代碼邏輯混亂了許多:

obfuscated-show-ida-2

當項目更大含有更多依賴時,代碼混淆所帶來的混亂會更加嚴重,且由于第三方依賴包也被混淆,逆向破解時就無法通過引入的第三方包來猜測代碼邏輯。

總結

本文從源碼實現的角度探究了 Golang 編譯調用工具鏈的大致流程以及 burrowers/garble 項目,了解了如何利用 go/ast 對代碼進行混淆處理。通過混淆處理,代碼的邏輯結構、二進制文件中存留的信息變得難以閱讀,顯著提高了逆向破解的難度。


Paper 本文由 Seebug Paper 發布,如需轉載請注明來源。本文地址:http://www.bjnorthway.com/1586/