친구들과 잡담을 하던 도중, 이런 흥미로운 이야기를 들었다.
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로 짜 보면 괜찮을지도 모르겠다.