作者:rook1e@知道創宇404實驗室
時間:2021年11月3日
前段時間學習了 0x7F 師傅的「dll 劫持和應用」,其中提到通過 dll 劫持來劫持編譯器實現供應鏈攻擊,不由想到 Go 中的一些機制也可以方便地實現編譯劫持,于是做了一些研究和測試。
編譯過程
首先我們了解一下 go build 做了什么。
package main
func main() {
print("i'm testapp!")
}
以這個簡單的程序為例,go build -x main.go 編譯并輸出編譯過程(篇幅有限所以沒有強制重新編譯最基礎的依賴):
上述命令可以將編譯過程概括為:
- 創建臨時目錄
- 生成 compile 需要的配置文件,運行 compile 編譯出目標文件
***.a(還有其他編譯工具執行類似的操作) - 寫入 build id
- 重復 2、3 步編譯所有依賴
- 生成 link 需要的配置文件,運行 link 將上述目標文件連接成可執行文件
- 寫入 build id
- 將鏈接好的可執行文件移動到當前目錄,刪除臨時目錄
觀察這段命令能夠發現一些有趣的地方。
每個編譯階段都有單獨的工具程序負責,例如 compile、link、asm,這些工具程序可以通過 go tool 獲得,其中用于編譯的暫且稱之為編譯工具。
命令中有大段形如 packagefile xxx/xxx=xxx.a 的內容,用于指明代碼中依賴和目標文件的對應關系,這些對應關系將寫入 importcfg/importcfg.link 作為 compile/link 的配置文件。
另外,還可以發現創建了形如 $WORK/b001 的臨時目錄。go build 在運行編譯工具前會解析出全部的依賴關系,根據依賴關系對每個包創建相應的 action,最終構成 action graph,按序執行即可完成編譯,每個 action 對應一個臨時目錄。例如使用 go build -a -work(-a 表示強制重新編譯,-work 表示保留臨時目錄)編譯一個程序:

由圖可以看到各個 action 使用的臨時目錄,如 b062 存放了編譯配置文件 importcfg 和編譯出的目標文件 _pkg_.a,而最后一個 action 對應的 b001 目錄,除了編譯的臨時文件,還有鏈接配置 importcfg.link 和鏈接結果 exe/a.out。
綜上,我們可以總結出幾個關鍵信息:
go build的主要工作:分析依賴,把源代碼編譯成目標文件,把目標文件鏈接成可執行文件- 目標文件、配置文件存放在臨時目錄中(b001 是最后一個,也是可執行文件的誕生地),臨時目錄可以通過
-work參數保留 - 調用編譯工具實現不同階段的編譯工作
- 后 action 需要依賴前 action 的結果
可以感受到編譯過程是較為“分散”的,這給我們創造了機會:
- 編譯工具是開源的,可以對其修改并替換進
go env GOTOOLDIR目錄 - 利用
go build -toolexec機制
這兩種方法的思路大致相同,本文嘗試了第二種思路。
劫持編譯
前段時間研究代碼混淆時學習到了 go build 的 -toolexec 機制,這里粘貼一下相關內容:
細心的讀者可能會發現一個有趣的問題:拼接的命令中真正的運行對象并不是編譯工具,而是
cfg.BuildToolexec。跟進到定義處可知它是由go build -toolexec參數設置的,官方釋義為:
bash -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 的工具,暫且稱之為 wrapper,在項目的編譯腳本或其他存在編譯命令的地方插入 -toolexec "/path/to/wrapper",運行編譯命令時 wrapper 要找到一個合適的位置(暫定為 main.main() 的頂部)插入 paylaod。
首先要定位到目標代碼文件。
/path/to/wrapper /opt/homebrew/Cellar/go/1.17.2/libexec/pkg/tool/darwin_arm64/compile -o $WORK/b042/_pkg_.a -trimpath "$WORK/b042=>" -shared -p strings -std -complete -buildid ygbMG98G6g0UHH5pai26/ygbMG98G6g0UHH5pai26 -goversion go1.17.2 -importcfg $WORK/b042/importcfg -pack /opt/homebrew/Cellar/go/1.17.2/libexec/src/strings/builder.go /opt/homebrew/Cellar/go/1.17.2/libexec/src/strings/compare.go
...(省略)
這是一條 go build -toolexec "/path/to/wrapper" 執行的命令,compile 的目標代碼文件路徑拼接在最后。提取出文件路徑后,根據文件內容判斷是否是 main.main() 所在文件,方法有很多,例如直接匹配是否以 package main 開頭且存在 func main(){ ,更嚴謹一點可以解析出 AST,通過下圖幾個特征來判斷:

因為一條編譯命令包含的文件都屬于一個包,所以只要有一個文件不符合要求就可以放棄后續篩選了。
綜上,第一步可以通過如下條件篩選:
- 調用的工具是 compile
- 文件是
.go后綴 - AST 中包名是 main,且 Decls 中存在名為 main 的
ast.FuncDecl
定位到了目標代碼文件,下一步通過修改 AST 來插入 payload。
根據上一步中的 AST 圖,main() 中的每條語句解析成 AST 節點是 ast.Stmt 接口類型,存放于 Body.List 中,所以參照具體 stmt 的格式構造 AST 節點,如:
var cmd = `exec.Command("open", "/System/Applications/Calculator.app").Run()`
payloadExpr, err := parser.ParseExpr(cmd)
// handle err
payloadExprStmt := &ast.ExprStmt{
X: payloadExpr,
}
向 main() 的 Body.List 插入 payload 的節點:
// 方式1
ast.Inspect(f, func(n ast.Node) bool {
switch x := n.(type) {
case *ast.FuncDecl:
if x.Name.Name == "main" && x.Recv == nil {
stmts := make([]ast.Stmt, 0, len(x.Body.List)+1)
stmts = append(stmts, payloadExprStmt)
stmts = append(stmts, x.Body.List...)
x.Body.List = stmts
return false
}
}
return true
})
// 方式2
pre := func(cursor *astutil.Cursor) bool {
switch cursor.Node().(type) {
case *ast.FuncDecl:
if fd := cursor.Node().(*ast.FuncDecl); fd.Name.Name == "main" && fd.Recv == nil {
return true
}
return false
case *ast.BlockStmt:
return true
case ast.Stmt:
if _, ok := cursor.Parent().(*ast.BlockStmt); ok {
cursor.InsertBefore(payloadExprStmt)
}
}
return true
}
post := func(cursor *astutil.Cursor) bool {
if _, ok := cursor.Parent().(*ast.BlockStmt); ok {
return false
}
return true
}
f = astutil.Apply(f, pre, post).(*ast.File)
最后將修改好的 AST 保存為文件,替換原始編譯命令中的文件地址,執行命令。
簡簡單單,到這里似乎順利完成,但測試一下會出現報錯無法找到 os/exec:
/var/folders/z5/1_qfr0f55x97c63p412hprzw0000gn/T/gobuild_cache_1747406166/main.go:5:2: could not import "os/exec": open : no such file or directory
回想一下前文「編譯過程」部分的內容,在編譯和鏈接階段都需要使用其依賴包在先前編譯出的目標文件,并且依賴分析和 action graph 的構建是 go build 在運行編譯工具前完成的,無法通過 -toolexec 劫持。所以向 AST 中 的 import 節點插入依賴并不會修改已有的依賴關系和 action graph,導致沒有 os/exec 的目標文件可用。
既然 action graph 中缺少 os/exec 及其依賴,那我們可以自行完成缺少的 action,即編譯出相應的目標文件并添加到 importcfg。
對比 importctg 發現間接依賴比想象中的多,但好在都記錄在 importcfg 中,所以我們創建一個新的 go build 編譯一段簡化的 payload:
package main
import "os/exec"
func main() {
exec.Command("xxx").Run()
}
添加 -work 參數保留這次編譯的臨時目錄,讀取臨時目錄 b001 中的 importcfg 獲得 os/exec 的依賴的目標文件路徑,將這些配置項按需追加到原 importcfg。
再次嘗試,可以看到 payload 成功插入。

另外,可以看到上述測試都使用了 -a 參數,是由于 go build 存在緩存和增量編譯機制,正常 go build 可能因命中緩存而不會調用工具,所以要添加 -a 參數強制編譯所有依賴,或者編譯前 go clean -cache 清除緩存,或是修改環境變量 GOCACHE 到一個新的目錄。
最后,梳理一下上述步驟:
compile 時:
1. 定位目標文件
2. 編譯一個簡化的 payload 得到 importcfg 和其依賴的中間文件
3. 補充 importcfg
4. 在 AST 中插入 payload,保存到臨時文件
5. 修改原編譯命令中的文件路徑,執行編譯命令
link 時:
1. 定位目標文件
2. 補充 importcfg.link
3. 執行鏈接命令
總結
本文實踐的方案利用了 go build 的 -toolexec 機制讓工具介入編譯過程,在臨時文件中插入 payload。
從實際應用的角度來說還存在很多問題,例如如何隱蔽地在編譯腳本中插入 -toolexec 和 -a 參數。在沒有合適的偽裝手段時,按照本文思路修改并替換編譯工具 compile 和 link 或許是更好的選擇。
本文相關代碼存放在 go-build-hijacking,后續有好的思路會繼續補充,歡迎師傅們通過 issue 或郵件交流。
Ref
本文由 Seebug Paper 發布,如需轉載請注明來源。本文地址:http://www.bjnorthway.com/1749/
暫無評論