Zj_W1nd's BLOG

GO,rust等其他语言的逆向

2024/09/03

GO

最近复现2024国赛题,全是GO麻麻失了,heap都用go。这种全静态的高级语言逆向起来看的一坨分辨,在这里简单写点经验或者知识吧。

基本的知识

GO基础语法也不难,因为除了没有分号也是类C的。我们逆向看的又不是源码是编译后的C。所以不再多说

首先是符号问题,GO程序的入口在main_main,目前的ida 8+已经能支持自动还原符号了,对于不支持的版本可以找ida的插件来进行符号还原。直接搜索main_main就能找到程序的入口。

在打包进二进制文件的时候,go的全部数据类型都从一个叫做RTYPE的结构体扩展而来,

fmt.Println——GO的IO实现

看这个函数在ida里看的都头大,单独拎出来学一下。在ida里我们经常能看到这个函数带一大堆参数,类似这种:

1
fmt_Fprintln((unsigned int)off_2BCBE8, qword_41A3E8, (unsigned int)v43, 1, 1, v12, v13, v9, v10, v30);

这个函数是fmt.Println内部真正实现输出的函数,看看go源码加上网上的文章学习一下解析方法。

1
2
3
func Println(a ...any) (n int, err error) {
return Fprintln(os.Stdout, a...)
}

Go的源码分析

这里简单解析一下这个函数的实现,首先从go开始。他的原型是这样的:

源码分析参考https://www.cnblogs.com/detectiveHLH/p/11023149.html的分析

1
2
3
4
5
6
7
func Fprintln(w io.Writer, a ...any) (n int, err error) {
p := newPrinter()
p.doPrintln(a)
n, err = w.Write(p.buf)
p.free()
return
}

它接受任意的参数,第一个是一个ioWriter接口。第二个参数...代表可变参数,any代表任意类型。这玩意go的实现是会将a作为一个参数组的切片传到函数里面。
newPrinter是一个分配器,里面封装了一个从临时对象池中取对象的操作,这里不再展开。p.free也是同理,将取得的对象放回池子。

然后是doPrintln:

1
2
3
4
5
6
7
8
9
func (p *pp) doPrintln(a []any) {
for argNum, arg := range a {
if argNum > 0 {
p.buf.writeByte(' ')
}
p.printArg(arg, 'v')
}
p.buf.writeByte('\n')
}

遍历全部参数,printArg打印然后再buf中写入分割的空格和换行符。

然后是printArg:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
func (p *pp) printArg(arg any, verb rune) {
p.arg = arg
p.value = reflect.Value{}

if arg == nil {
switch verb {
case 'T', 'v':
p.fmt.padString(nilAngleString)
default:
p.badVerb(verb)
}
return
}

// Special processing considerations.
// %T (the value's type) and %p (its address) are special; we always do them first.
switch verb {
case 'T':
p.fmt.fmtS(reflect.TypeOf(arg).String())
return
case 'p':
p.fmtPointer(reflect.ValueOf(arg), 'p')
return
}
// Some types can be done without reflection.
switch f := arg.(type) {
case bool:
p.fmtBool(f, verb)
case float32:
p.fmtFloat(float64(f), 32, verb)
case float64:
p.fmtFloat(f, 64, verb)
case complex64:
p.fmtComplex(complex128(f), 64, verb)
case complex128:
p.fmtComplex(f, 128, verb)
case int:
p.fmtInteger(uint64(f), signed, verb)
case int8:
p.fmtInteger(uint64(f), signed, verb)
case int16:
p.fmtInteger(uint64(f), signed, verb)
case int32:
p.fmtInteger(uint64(f), signed, verb)
case int64:
p.fmtInteger(uint64(f), signed, verb)
case uint:
p.fmtInteger(uint64(f), unsigned, verb)
case uint8:
p.fmtInteger(uint64(f), unsigned, verb)
case uint16:
p.fmtInteger(uint64(f), unsigned, verb)
case uint32:
p.fmtInteger(uint64(f), unsigned, verb)
case uint64:
p.fmtInteger(f, unsigned, verb)
case uintptr:
p.fmtInteger(uint64(f), unsigned, verb)
case string:
p.fmtString(f, verb)
case []byte:
p.fmtBytes(f, verb, "[]byte")
case reflect.Value:
// Handle extractable values with special methods
// since printValue does not handle them at depth 0.
if f.IsValid() && f.CanInterface() {
p.arg = f.Interface()
if p.handleMethods(verb) {
return
}
}
p.printValue(f, verb, 0)
default:
// If the type is not simple, it might have methods.
if !p.handleMethods(verb) {
// Need to use reflection, since the type had no
// interface methods that could be used for formatting.
p.printValue(reflect.ValueOf(f), verb, 0)
}
}
}

这里太长不看,本质上就是解析各种类型因为我们一个函数输出所有内容,要分支到合适的地方,这里直接看字符串的casep.fmtSring

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
func (p *pp) fmtString(v string, verb rune) {
switch verb {
case 'v':
if p.fmt.sharpV {
p.fmt.fmtQ(v)
} else {
p.fmt.fmtS(v)
}
case 's':
p.fmt.fmtS(v)
case 'x':
p.fmt.fmtSx(v, ldigits)
case 'X':
p.fmt.fmtSx(v, udigits)
case 'q':
p.fmt.fmtQ(v)
default:
p.badVerb(verb)
}
}

也是个依据模式的处理,最后在fmtS中最后一次处理精度截断后,将字符串的内容写入buffer,最后调用io输出。

逆向对应

之前看到虽然ida识别出了10个参数但是只有前5个有具体值,而且fmt_FPrintln自己的代码内部也只有5个参数,我们就当他只有5个参数,来对一下功能。记为1-5.观察main_main的变量定义,其中123是指针,45是纯数(两个1)。

第三个参数是一个&RTYPE_String,应该是一个符合类型??,第二个则是一个运行时分配的变量,但是3存了连续两个指针,第一个指向RTYPE_String, 第二个则指向我们的参数2.
参数1指向的位置,第一个是我们的RTYPE_io_Writer:

1
2
3
4
5
6
7
8
9
10
11
.data.rel.ro:00000000002BCBE8 io_Writer_struct dq offset RTYPE_io_Writer
.data.rel.ro:00000000002BCBF0 dq offset RTYPE__ptr_os_File
.data.rel.ro:00000000002BCBF8 db 44h ; D
.data.rel.ro:00000000002BCBF9 db 0B5h
.data.rel.ro:00000000002BCBFA db 0F3h
.data.rel.ro:00000000002BCBFB db 33h ; 3
.data.rel.ro:00000000002BCBFC db 0
.data.rel.ro:00000000002BCBFD db 0
.data.rel.ro:00000000002BCBFE db 0
.data.rel.ro:00000000002BCBFF db 0
.data.rel.ro:00000000002BCC00 dq offset os__ptr_File_Write

参数2指向一个bss段的地址,推测也是io接口相关的指针:

1
2
3
4
5
.bss:000000000041A3E0 ; _ptr_os_File qword_41A3E0
.bss:000000000041A3E0 qword_41A3E0 dq ? ; DATA XREF: os_init+1EE↑w
.bss:000000000041A3E0 ; os_init:loc_123957↑o ...
.bss:000000000041A3E8 qword_41A3E8 dq ? ; DATA XREF: os_init+226↑w <--参数2
.bss:000000000041A3E8 ; os_init:loc_12398F↑o ...

参数3是String类型的参数:

1
2
v33[0] = &RTYPE_string;
v33[1] = &off_2BBCB0;

参数4和5都是1。

进入函数,其内部又调用了fmt__ptr_pp_doPrintln

1
fmt__ptr_pp_doPrintln((__int64 *)new_pp, string2, val_1, val_11, val_11, v6, v7, v8, v9);

6-9是空的,234都是1。进入函数后对应go源码直接看循环,大概猜测,初始传入的1有一个是参数个数

Rust

重生之我是世界上最好的语言,这一世我励志要打败所有的内存管理编程原神,启动!

具体的内容参考自@CoL1n学长总结的Rust逆向blog

Rust逆向需要时刻关注栈上的内容,但是这个语言对于栈的利用非常的“充分”,所以存在一定的难度。

rust编译的风格

rust逆向和别的逆向也一样,需要的是对这种语言编译风格,IDA C反汇编风格的直觉。这里会放上和rust编译风格相关的tips

  • rustc倾向于给程序中打上许多的tag,分成很多小段用jmp连接,即使他们本来就应该顺序执行也会用jmp short $+2这种指令连接。

另外

主函数定位

rust编译后的程序真正的主函数带有和项目相同的名字,而传统意义上的主函数会调用这个真正的主函数,主函数比较好找。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
.text:000000000001A9B0 ; __int64 __fastcall main(int, char **, char **)
.text:000000000001A9B0 main proc near ; DATA XREF: start+18↑o
.text:000000000001A9B0 ; __unwind {
.text:000000000001A9B0 push rax
.text:000000000001A9B1 mov rdx, rsi
.text:000000000001A9B4 mov rax, cs:off_61DF0
.text:000000000001A9BB mov al, [rax]
.text:000000000001A9BD movsxd rsi, edi
.text:000000000001A9C0 lea rdi, real_main
.text:000000000001A9C7 xor ecx, ecx
.text:000000000001A9C9 call sub_19820
.text:000000000001A9CE pop rcx
.text:000000000001A9CF retn
.text:000000000001A9CF ; } // starts at 1A9B0
.text:000000000001A9CF main endp

expect函数

静态分析能确认几个关键的点。其中一个

expect有四个参数,传入Result实例,错误字符串地址,字符串长度,第四个参数是源代码工程中的路径。最后一个参数很关键,发现调用函数最后一个参数传入类似于“src/main"的字符串的时候基本能确定这个函数就是expect了。

expect函数会紧跟着它要处理异常的函数被调用。在IDA C中会表现为这样:

1
2
v8 = input2(&v24, *v11, 80LL);
expect(v8, v9, aFailedToRead, 14LL, &off_5F768);

输入输出——println

静态分析我们能做的事不多,输出函数是为数不多我们能通过纯粹静态分析比较容易确定的点。

CATALOG
  1. 1. GO
    1. 1.1. 基本的知识
    2. 1.2. fmt.Println——GO的IO实现
      1. 1.2.1. Go的源码分析
      2. 1.2.2. 逆向对应
  2. 2. Rust
    1. 2.1. rust编译的风格
    2. 2.2. 主函数定位
    3. 2.3. expect函数
    4. 2.4. 输入输出——println