Skip to content

如何消除Go的编译特征

字数
2768 字
阅读时间
13 分钟
更新日期
6/28/2021

Go默认编译会自带一堆信息,通过这些信息基本可以还原Go的源码架构,

本文就是研究如何消除或者混淆这些信息,记录了这个研究过程,如果不想看可以直接跳到文章末尾,文章末尾提供了一款工具,可以一键消除Go二进制中的这些敏感信息。

但还是推荐看看研究过程,可以明白这个工具的运行原理。

从逆向Go开始

先写一个简单的程序

我的go版本是

go version go1.16.2 windows/amd64
go
package main

import (
    "fmt"
    "log"
    "math/rand"
)

func main() {
    fmt.Println("hello world!")

    log.SetFlags(log.Lshortfile | log.LstdFlags)
    for i:=0;i<10;i++{
        log.Println(rand.Intn(100))
    }

    panic("11")
}

编译

go build main.go

它运行后会如下输出

image-20210628160551219

可以观察到程序日志打印时打印了文件名,panic抛出错误的时候堆栈的文件名也抛出了,可以想象Go编译的二进制程序内部肯定有个数据结构存储了这些信息。

用IDA打开这个二进制

image-20210628161302422

能够看到函数符号的名称。

查看PE的结构发现有个symtab区段

image-20210628161644212

原来普通的使用go build .进行编译,会连符号和调试信息一起编译到里面。

重新使用命令编译

go build -ldflags "-s -w" main.go

再次用IDA打开,发现符号信息都不见了。

image-20210628162432087

再次运行程序,却发现文件路径信息还是存在。

image-20210628162501129

但是自己写的代码中根本没有这些字符啊,只可能是go在编译的时候自己打包进去的。

所以引出两个问题

  • Go为什么要打包这些信息
  • Go打包了哪些信息

Go为什么要打包这些信息

Go 二进制文件里打包进去了 runtimeGC 模块,还有独特的 Type Reflection(类型反射) 和 Stack Trace 机制,都需要用到这些信息。

来自 Go二进制文件逆向分析从基础到进阶——综述 - 安全客,安全资讯平台 (anquanke.com)

Go打包了哪些信息

  • Go Version
  • Go BuildID
  • GOROOT
  • 函数名称和源码路径
  • struct 和 type 和 interface

img

Go逆向方式

https://www.anquanke.com/post/id/214940 这篇文章就能知道,通过解析Go二进制中这些内置的数据结构,就可以还原出符号信息。

有安全研究员发现除了可以从 pclntab 结构中解析、恢复函数符号,Go 二进制文件中还有大量的类型、方法定义的信息,也可以解析出来。这样就可以大大方便对 Go 二进制文件中复杂数据结构的逆向分析。

基于这种方式,已经有人写好了ida的脚本来恢复

运行这些脚本,就能还原一些符号信息了。

image-20210628172800127

redress和gore

前面的是基于IDA的脚本,因为Go也内置了自己的数据结构,用Go来解析Go更方便。

goretk/redress: Redress - A tool for analyzing stripped Go binaries

.\redress.exe -pkg -std -filepath  -interface main.exe

image-20210628185810359

image-20210628185822332

redress只是工具的前端,如果看它代码的话会发现,实际的解析代码在

这款工具能从Go二进制中获取非常多的信息,几乎可以用它来还原Go的源码结构,这么神奇的工具,怎能不看看它是如何实现的呢。

GoRE 代码学习

在GoRE中,PCLNTab是直接使用内置的debug/gosym生成,可用于获取源码路径和函数名称。

其他解析数据结构的地方很枯燥,有兴趣可以看@J!4Yu师傅的文章,很全面的讲解了Go的数据结构

我就说说看得几个有意思的点

Go version 获取

go官方命令go version不仅可以获取自身的go版本信息,如果后面跟一个Go文件路径, 就能获得那个文件的go的编译器信息。

image-20210628111740455

查看Go源代码,看看是怎么实现的

src\cmd\go\internal\version\version.go

go
var buildInfoMagic = []byte("\xff Go buildinf:")

Go官方是通过搜索这个魔术字符,用IDA定位到这个地方,可以看到,这个魔术字符后面就跟着Go版本信息的地址偏移。

image-20210628110615071

官方实现代码

go
// The build info blob left by the linker is identified by
// a 16-byte header, consisting of buildInfoMagic (14 bytes),
// the binary's pointer size (1 byte),
// and whether the binary is big endian (1 byte).
var buildInfoMagic = []byte("\xff Go buildinf:")

// findVers finds and returns the Go version and module version information
// in the executable x.
func findVers(x exe) (vers, mod string) {
    // Read the first 64kB of text to find the build info blob.
    text := x.DataStart()
    data, err := x.ReadData(text, 64*1024)
    if err != nil {
        return
    }
    for ; !bytes.HasPrefix(data, buildInfoMagic); data = data[32:] {
        if len(data) < 32 {
            return
        }
    }

    // Decode the blob.
    ptrSize := int(data[14])
    bigEndian := data[15] != 0
    var bo binary.ByteOrder
    if bigEndian {
        bo = binary.BigEndian
    } else {
        bo = binary.LittleEndian
    }
    var readPtr func([]byte) uint64
    if ptrSize == 4 {
        readPtr = func(b []byte) uint64 { return uint64(bo.Uint32(b)) }
    } else {
        readPtr = bo.Uint64
    }
    vers = readString(x, ptrSize, readPtr, readPtr(data[16:]))
    if vers == "" {
        return
    }
    mod = readString(x, ptrSize, readPtr, readPtr(data[16+ptrSize:]))
    if len(mod) >= 33 && mod[len(mod)-17] == '\n' {
        // Strip module framing.
        mod = mod[16 : len(mod)-16]
    } else {
        mod = ""
    }
    return
}

// readString returns the string at address addr in the executable x.
func readString(x exe, ptrSize int, readPtr func([]byte) uint64, addr uint64) string {
    hdr, err := x.ReadData(addr, uint64(2*ptrSize))
    if err != nil || len(hdr) < 2*ptrSize {
        return ""
    }
    dataAddr := readPtr(hdr)
    dataLen := readPtr(hdr[ptrSize:])
    data, err := x.ReadData(dataAddr, dataLen)
    if err != nil || uint64(len(data)) < dataLen {
        return ""
    }
    return string(data)
}

GoRE version 获取

交叉引用上文的runtime_buildVersion字符串,可以看到三处调用的地方。

image-20210628110443042

前两个是runtime_schedinit内部的实现,第三个是官方工具go version的实现方式。

转到runtime_schedinit地址查看

image-20210628110321170

GoRE的 verison 实现就是基于runtime_schedinit的,首先找到runtime_schedinit函数的地址,反汇编寻找lea的机器码,寻找基于EIP或RIP的地址。这种寻找地址的办法和我之前学习的直接用机器码匹配的方式不同,算是学习到了~

在后面这种方式也帮助我成功解析到了Go Root。

Go Root解析

GoRe已经是解析Go的比较完美的工具,但是发现没有解析Go Root,这个也是能作为一个字符特征的,所以我准备加上这个功能。

我的go环境是

go version go1.16.2 windows/amd64

可以直接用这个测试代码

go
package main

import (
    "fmt"
    "runtime"
)

func main() {
    fmt.Println("hello world!")
    fmt.Println(runtime.GOROOT())
}
go build . 编译

编译后运行会输出GOROOT路径

image-20210628115628061

用IDA搜索这个字符串 C:/Program Files/Go,但是并没有搜到。于是转到Main函数,看到了符号信息。

image-20210628122150026

原来它是C:\\Program Files\\Go字符串,输出的时候将它改变了。

image-20210628122244273

交叉引用查看

image-20210628122317564

有两个地方,一个是main函数我们调用的地方,一个是time_init,这个是内部函数的实现。

我们就可以通过这个函数来定位到它。

image-20210628122453999

刚刚新学了反汇编寻找地址方式,现在正好派上了用场,程序先解析pclntab获取函数time_init的地址范围,从这个地址开始反汇编,寻找mov rax,立即数指令。

因为这个赋值的汇编指令是mov,写代码的时候还要注意32位和64位寻址的不同。

go
func tryFromTimeInit(f *GoFile) (string, error) {
    // Check for non supported architectures.
    if f.FileInfo.Arch != Arch386 && f.FileInfo.Arch != ArchAMD64 {
        return "", nil
    }

    is32 := false
    if f.FileInfo.Arch == Arch386 {
        is32 = true
    }

    // Find shedinit function.
    var fcn *Function
    std, err := f.GetSTDLib()
    if err != nil {
        return "", nil
    }

pkgLoop:
    for _, v := range std {
        if v.Name != "time" {
            continue
        }
        for _, vv := range v.Functions {
            if vv.Name != "init" {
                continue
            }
            fcn = vv
            break pkgLoop
        }
    }

    // Check if the functions was found
    if fcn == nil {
        // If we can't find the function there is nothing to do.
        return "", nil
    }
    // Get the raw hex.
    buf, err := f.Bytes(fcn.Offset, fcn.End-fcn.Offset)
    if err != nil {
        return "", nil
    }
    s := 0
    mode := f.FileInfo.WordSize * 8

    for s < len(buf) {
        inst, err := x86asm.Decode(buf[s:], mode)
        if err != nil {
            return "", nil
        }
        s = s + inst.Len
        if inst.Op != x86asm.MOV {
            continue
        }
        if inst.Args[0] != x86asm.RAX && inst.Args[0] != x86asm.ECX {
            continue
        }
        kindof := reflect.TypeOf(inst.Args[1])
        if kindof.String() != "x86asm.Mem" {
            continue
        }
        arg := inst.Args[1].(x86asm.Mem)
        addr := arg.Disp
        if arg.Base == x86asm.EIP || arg.Base == x86asm.RIP {
            addr = addr + int64(fcn.Offset) + int64(s)
        } else if arg.Base == 0 && arg.Disp > 0 {
        } else {
            continue
        }
        b, _ := f.Bytes(uint64(addr), uint64(0x20))
        if b == nil {
            continue
        }

        r := bytes.NewReader(b)
        ptr, err := readUIntTo64(r, f.FileInfo.ByteOrder, is32)
        if err != nil {
            // Probably not the right instruction, so go to next.
            continue
        }
        l, err := readUIntTo64(r, f.FileInfo.ByteOrder, is32)
        if err != nil {
            // Probably not the right instruction, so go to next.
            continue
        }

        ver := string(bstr)
        if !IsASCII(ver) {
            return "", nil
        }
        return ver, nil
    }
    return "", nil
}

此外还要注意一个版本问题。go1.16以上版本的GoRoot是这样解析,go1.16以下可以直接定位到runtime_GoRoot函数,再使用上述方式解析即可。

我也向GoRe提交了这部分代码

Go-Strip

GoRe可以读取Go二进制的信息,反过来,把读取的文本修改成替换文本,不就达到了消除/混淆go编译信息的目的吗。

基于此写了一个工具,可以一键混淆Go编译的二进制里的信息。

还是以最开始的Go代码为例

go
package main

import (
    "fmt"
    "log"
    "math/rand"
)

func main() {
    fmt.Println("hello world!")

    log.SetFlags(log.Lshortfile | log.LstdFlags)
    for i:=0;i<10;i++{
        log.Println(rand.Intn(100))
    }

    panic("11")
}

编译

go build -ldflags "-s -w" main.go

使用程序消除信息

image-20210628192127364

运行新的程序

image-20210628192157142

运行没有问题,之前含有的文件信息都用随机字符串填充了。

用之前的IDA脚本查看

image-20210628192353333

函数名称也都填充了。

与其他工具的对比

知名的Go混淆工具有gobfuscategarble

gobfuscate,核心思想是将源码以及源码引入的包转移到一个随机目录,然后基于AST语法树修改代码信息,但这样效率有很大问题。之前测试过deimos-C2sliver的生成混淆,生成一个简单的源码需要半个多小时甚至更长时间,并且混淆的不彻底,像Go的一些内置包、文件名都没有混淆。

garble采取的混淆中间语言的方法,但是也有混淆不彻底和效率的问题。

相比之下go-strip混淆更彻底,效率快,支持多个平台架构,能比较方便的消除Go编译的信息。

程序下载

https://github.com/boy-hack/go-strip

参考

撰写

布局切换

调整 VitePress 的布局样式,以适配不同的阅读习惯和屏幕环境。

全部展开
使侧边栏和内容区域占据整个屏幕的全部宽度。
全部展开,但侧边栏宽度可调
侧边栏宽度可调,但内容区域宽度不变,调整后的侧边栏将可以占据整个屏幕的最大宽度。
全部展开,且侧边栏和内容区域宽度均可调
侧边栏宽度可调,但内容区域宽度不变,调整后的侧边栏将可以占据整个屏幕的最大宽度。
原始宽度
原始的 VitePress 默认布局宽度

页面最大宽度

调整 VitePress 布局中页面的宽度,以适配不同的阅读习惯和屏幕环境。

调整页面最大宽度
一个可调整的滑块,用于选择和自定义页面最大宽度。

内容最大宽度

调整 VitePress 布局中内容区域的宽度,以适配不同的阅读习惯和屏幕环境。

调整内容最大宽度
一个可调整的滑块,用于选择和自定义内容最大宽度。

聚光灯

支持在正文中高亮当前鼠标悬停的行和元素,以优化阅读和专注困难的用户的阅读体验。

ON开启
开启聚光灯。
OFF关闭
关闭聚光灯。

聚光灯样式

调整聚光灯的样式。

置于底部
在当前鼠标悬停的元素下方添加一个纯色背景以突出显示当前鼠标悬停的位置。
置于侧边
在当前鼠标悬停的元素旁边添加一条固定的纯色线以突出显示当前鼠标悬停的位置。