친구들과 잡담을 하던 도중, 이런 흥미로운 이야기를 들었다.
go로 짜면 리버싱이 개어렵긴하더라. go가 진짜 개악질인게 우리가 아는 표준 콜링컨벤션을 하나도 안 지킴. 레지스터를 지 맘대로 씀. 심지어 버전마다도 조금씩 다르다니까?
처음 들어보는 재밌는 이야기여서 확인차 적어보고자 한다.
Calling convention이란?
말 그대로 "호출 규약"이다. 함수를 호출할 때 어떤 방법으로 진행하는지에 대한 약속들을 정리해 놓은 것. 매개 변수들을 서브루틴에 어떻게 전달할 지, 호출자(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로 짜 보면 괜찮을지도 모르겠다.