Go로 짜면 리버싱이 어려운 이유

August 06, 2023

친구들과 잡담을 하던 도중, 이런 흥미로운 이야기를 들었다.

go로 짜면 리버싱이 개어렵긴하더라. go가 진짜 개악질인게 우리가 아는 표준 콜링컨벤션을 하나도 안 지킴. 레지스터를 지 맘대로 씀. 심지어 버전마다도 조금씩 다르다니까?

처음 들어보는 재밌는 이야기여서 확인차 적어보고자 한다.

Calling convention이란?

calling_convention.png 출처

말 그대로 "호출 규약"이다. 함수를 호출할 때 어떤 방법으로 진행하는지에 대한 약속들을 정리해 놓은 것. 매개 변수들을 서브루틴에 어떻게 전달할 지, 호출자(caller)가 레지스터의 내용이 보존되기를 원하는지, 서브루틴의 로컬 변수는 어디에 저장해야 할 지, 결과 반환은 어떻게 해야 할 지 등의 다양한 결정들을 해야 하는데, 통일된 호출 규칙을 만들어서 사용하면 훨씬 편하게 서브루틴을 정의하고 사용할 수 있다.

통일된 호출 규칙을 사용하면, 각 서브루틴이 어떻게 매개변수를 전달해야 하는지를 일일히 살펴 볼 필요가 없고, 컴파일러가 해당 규칙을 따르기 때문에 직접 어셈블리를 짜더라도 서로의 루틴을 호출할 수 있다.

직접 알아보자

먼저 c로 간단한 코드를 짜 보자:

#include <stdio.h> int sum(int a, int b) { return a+b; } int main() { int a; a = sum(1, 1); printf("%d\n", a); return 0; }

어셈블리로 분석해 보면 다음과 같은 코드가 나온다.

(gdb) disas main Dump of assembler code for function main: 0x0000000000001161 <+0>: endbr64 0x0000000000001165 <+4>: push %rbp 0x0000000000001166 <+5>: mov %rsp,%rbp 0x0000000000001169 <+8>: sub $0x10,%rsp 0x000000000000116d <+12>: mov $0x1,%esi 0x0000000000001172 <+17>: mov $0x1,%edi 0x0000000000001177 <+22>: call 0x1149 <sum> 0x000000000000117c <+27>: mov %eax,-0x4(%rbp) 0x000000000000117f <+30>: mov -0x4(%rbp),%eax 0x0000000000001182 <+33>: mov %eax,%esi 0x0000000000001184 <+35>: lea 0xe79(%rip),%rax # 0x2004 0x000000000000118b <+42>: mov %rax,%rdi 0x000000000000118e <+45>: mov $0x0,%eax 0x0000000000001193 <+50>: call 0x1050 <printf@plt> 0x0000000000001198 <+55>: mov $0x0,%eax 0x000000000000119d <+60>: leave 0x000000000000119e <+61>: ret End of assembler dump. (gdb) disas sum Dump of assembler code for function sum: 0x0000000000001149 <+0>: endbr64 0x000000000000114d <+4>: push %rbp 0x000000000000114e <+5>: mov %rsp,%rbp 0x0000000000001151 <+8>: mov %edi,-0x4(%rbp) 0x0000000000001154 <+11>: mov %esi,-0x8(%rbp) 0x0000000000001157 <+14>: mov -0x4(%rbp),%edx 0x000000000000115a <+17>: mov -0x8(%rbp),%eax 0x000000000000115d <+20>: add %edx,%eax 0x000000000000115f <+22>: pop %rbp 0x0000000000001160 <+23>: ret End of assembler dump.

일반적으로 리눅스에서는 인자를 rdi에 넣는다. 위 어셈 코드를 보면 esi와 edi(rdi를 반으로 나눈 게 edi다. rdi는 64bit, edi는 32bit)에 각각 값(1)을 넣은 뒤, sum 함수를 콜하는 것을 볼 수 있다.

그럼 go로 비슷한 코드를 써 보자:

package main import ( "fmt" ) func sum(a int, b int) int { return a+b } func main() { a := sum(1, 1) fmt.Printf("%d\n", a) }

어셈블리로 분석해 보면?

먼저 어떤 함수들이 있는지 보자.

(gdb) info functions All defined functions: File /home/mango/coding/lab/main.go: void main.main(void); File /usr/lib/go-1.18/src/errors/errors.go: void errors.(*errorString).Error; File /usr/lib/go-1.18/src/errors/wrap.go: void errors.init(void); File /usr/lib/go-1.18/src/fmt/format.go: void fmt.(*fmt).fmtBoolean; void fmt.(*fmt).fmtBs; ...

어라? main.go에 함수가 하나밖에 없다. 최적화로 지워진 모양이다.

main.main을 들어가 보자.

(gdb) disas main.main Dump of assembler code for function main.main: 0x000000000047f460 <+0>: cmp 0x10(%r14),%rsp 0x000000000047f464 <+4>: jbe 0x47f4cf <main.main+111> 0x000000000047f466 <+6>: sub $0x50,%rsp 0x000000000047f46a <+10>: mov %rbp,0x48(%rsp) 0x000000000047f46f <+15>: lea 0x48(%rsp),%rbp 0x000000000047f474 <+20>: movups %xmm15,0x38(%rsp) 0x000000000047f47a <+26>: mov $0x2,%eax 0x000000000047f47f <+31>: nop 0x000000000047f480 <+32>: call 0x409820 <runtime.convT64> 0x000000000047f485 <+37>: lea 0x7394(%rip),%rcx # 0x486820 0x000000000047f48c <+44>: mov %rcx,0x38(%rsp) 0x000000000047f491 <+49>: mov %rax,0x40(%rsp) 0x000000000047f496 <+54>: mov 0xa2ad3(%rip),%rbx # 0x521f70 <os.Stdout> 0x000000000047f49d <+61>: lea 0x34d94(%rip),%rax # 0x4b4238 <go.itab.*os.File,io.Writer> 0x000000000047f4a4 <+68>: lea 0x166ef(%rip),%rcx # 0x495b9a 0x000000000047f4ab <+75>: mov $0x3,%edi 0x000000000047f4b0 <+80>: lea 0x38(%rsp),%rsi 0x000000000047f4b5 <+85>: mov $0x1,%r8d 0x000000000047f4bb <+91>: mov %r8,%r9 0x000000000047f4be <+94>: xchg %ax,%ax 0x000000000047f4c0 <+96>: call 0x478ca0 <fmt.Fprintf> 0x000000000047f4c5 <+101>: mov 0x48(%rsp),%rbp 0x000000000047f4ca <+106>: add $0x50,%rsp 0x000000000047f4ce <+110>: ret 0x000000000047f4cf <+111>: call 0x458980 <runtime.morestack_noctxt> 0x000000000047f4d4 <+116>: jmp 0x47f460 <main.main> End of assembler dump.

...이게 뭐지?

어지럽다

함수가 갑자기 사라진 건 최적화의 영향이라고 생각해서, -gcflags '-N' 을 사용해서 최적화를 꺼 보았다. 그랬더니...

(gdb) disas main.main Dump of assembler code for function main.main: 0x000000000047f460 <+0>: lea -0x70(%rsp),%r12 0x000000000047f465 <+5>: cmp 0x10(%r14),%r12 0x000000000047f469 <+9>: jbe 0x47f625 <main.main+453> 0x000000000047f46f <+15>: sub $0xf0,%rsp 0x000000000047f476 <+22>: mov %rbp,0xe8(%rsp) 0x000000000047f47e <+30>: lea 0xe8(%rsp),%rbp 0x000000000047f486 <+38>: movq $0x1,0x50(%rsp) 0x000000000047f48f <+47>: movq $0x1,0x48(%rsp) 0x000000000047f498 <+56>: mov 0x50(%rsp),%rcx 0x000000000047f49d <+61>: inc %rcx 0x000000000047f4a0 <+64>: mov %rcx,0x38(%rsp) 0x000000000047f4a5 <+69>: jmp 0x47f4a7 <main.main+71> 0x000000000047f4a7 <+71>: mov %rcx,0x58(%rsp) 0x000000000047f4ac <+76>: lea 0x166e7(%rip),%rcx # 0x495b9a 0x000000000047f4b3 <+83>: mov %rcx,0x80(%rsp) 0x000000000047f4bb <+91>: movq $0x3,0x88(%rsp) 0x000000000047f4c7 <+103>: movups %xmm15,0xa0(%rsp) 0x000000000047f4d0 <+112>: lea 0xa0(%rsp),%rcx 0x000000000047f4d8 <+120>: mov %rcx,0x78(%rsp) 0x000000000047f4dd <+125>: mov 0x58(%rsp),%rax 0x000000000047f4e2 <+130>: call 0x409820 <runtime.convT64> 0x000000000047f4e7 <+135>: mov %rax,0x70(%rsp) 0x000000000047f4ec <+140>: mov 0x78(%rsp),%rcx 0x000000000047f4f1 <+145>: test %al,(%rcx) 0x000000000047f4f3 <+147>: lea 0x7326(%rip),%rdx # 0x486820 0x000000000047f4fa <+154>: mov %rdx,(%rcx) 0x000000000047f4fd <+157>: lea 0x8(%rcx),%rdi 0x000000000047f501 <+161>: cmpl $0x0,0xd1c08(%rip) # 0x551110 <runtime.writeBarrier> 0x000000000047f508 <+168>: je 0x47f50c <main.main+172> 0x000000000047f50a <+170>: jmp 0x47f512 <main.main+178> 0x000000000047f50c <+172>: mov %rax,0x8(%rcx) 0x000000000047f510 <+176>: jmp 0x47f519 <main.main+185> 0x000000000047f512 <+178>: call 0x45a940 <runtime.gcWriteBarrier> 0x000000000047f517 <+183>: jmp 0x47f519 <main.main+185> 0x000000000047f519 <+185>: mov 0x78(%rsp),%rdx 0x000000000047f51e <+190>: test %al,(%rdx) 0x000000000047f520 <+192>: jmp 0x47f522 <main.main+194> 0x000000000047f522 <+194>: mov %rdx,0xd0(%rsp) 0x000000000047f52a <+202>: movq $0x1,0xd8(%rsp) 0x000000000047f536 <+214>: movq $0x1,0xe0(%rsp) 0x000000000047f542 <+226>: movq $0x0,0x40(%rsp) 0x000000000047f54b <+235>: movups %xmm15,0x90(%rsp) 0x000000000047f554 <+244>: movq $0x0,0x68(%rsp) 0x000000000047f55d <+253>: movups %xmm15,0xc0(%rsp) 0x000000000047f566 <+262>: movups %xmm15,0xb0(%rsp) 0x000000000047f56f <+271>: mov 0x80(%rsp),%rcx 0x000000000047f577 <+279>: mov 0xd0(%rsp),%rsi 0x000000000047f57f <+287>: mov 0xd8(%rsp),%r8 0x000000000047f587 <+295>: mov 0x88(%rsp),%rdi 0x000000000047f58f <+303>: mov 0xe0(%rsp),%r9 0x000000000047f597 <+311>: mov 0xa29f2(%rip),%rbx # 0x521f90 <os.Stdout> 0x000000000047f59e <+318>: lea 0x34cb3(%rip),%rax # 0x4b4258 <go.itab.*os.File,io.Writer> 0x000000000047f5a5 <+325>: call 0x478ca0 <fmt.Fprintf> 0x000000000047f5aa <+330>: mov %rax,0x60(%rsp) 0x000000000047f5af <+335>: mov %rbx,0xb0(%rsp) 0x000000000047f5b7 <+343>: mov %rcx,0xb8(%rsp) 0x000000000047f5bf <+351>: mov 0x60(%rsp),%rdx 0x000000000047f5c4 <+356>: mov %rdx,0x68(%rsp) 0x000000000047f5c9 <+361>: mov 0xb0(%rsp),%rdx 0x000000000047f5d1 <+369>: mov 0xb8(%rsp),%r10 0x000000000047f5d9 <+377>: mov %rdx,0xc0(%rsp) 0x000000000047f5e1 <+385>: mov %r10,0xc8(%rsp) 0x000000000047f5e9 <+393>: mov 0x68(%rsp),%rdx 0x000000000047f5ee <+398>: mov %rdx,0x40(%rsp) 0x000000000047f5f3 <+403>: mov 0xc0(%rsp),%rdx 0x000000000047f5fb <+411>: mov 0xc8(%rsp),%r10 0x000000000047f603 <+419>: mov %rdx,0x90(%rsp) 0x000000000047f60b <+427>: mov %r10,0x98(%rsp) 0x000000000047f613 <+435>: jmp 0x47f615 <main.main+437> 0x000000000047f615 <+437>: mov 0xe8(%rsp),%rbp 0x000000000047f61d <+445>: add $0xf0,%rsp 0x000000000047f624 <+452>: ret 0x000000000047f625 <+453>: call 0x458980 <runtime.morestack_noctxt> 0x000000000047f62a <+458>: jmp 0x47f460 <main.main>

...굉장히 쓸데없이 길게 코드가 나오는 걸 볼 수 있다. 일단 sum 함수 자체가 사라졌고(대충 mov $0x2, %eax로 최적화 당한 듯 하다), 스택에 값을 엄청 넣었다 뺐다를 반복하고 있다.

다른 컴파일러들과 비교해서 어떻게 다른지를 정리한 글을 찾았다. 해당 글에 의하면, 인자와 반환값은 항상 스택에 들어가고, 따라서 memory-heavy 하다는 의견을 내고 있다.

왜 이런 일이?

Go는 llvm 기반이 아니다(!). 정확히는 gccgo와 golang-go 두 가지 컴파일러가 있는데, golang-go를 일반적으로 사용한다. 이 컴파일러는 자체 런타임이 결합된 네이티브 머신 코드를 사용한다. 그래서 다른 언어들과 차이가 날 수 밖에 없다.

따라서, Go로 컴파일 한다는 것은 리버싱을 효과적으로 방해할 수 있는 훌륭한 수단이 되어 버렸다. 앞으로 분석당하기 싫은 프로그램을 만들고 싶다면 Go로 짜 보면 괜찮을지도 모르겠다.

Reference

The 64 bit x86 C Calling Convention

Setting up and using gccgo

The Go low-level calling convention on x86-64


Profile picture

Written by Mingyu Kim who works as a front-end engineer.