오랫동안 프로그래밍을 해본 사용자라 할지라도 어셈블리어가 필요하거나 리버스엔지니어링(역공학)이 필요한 경우는 별로 많지 않다. 본인도 90년대에 처음 GW 베이직을 시작으로 터보파스칼/터보C를 사용하며 프로그래밍의 기초를 배웠던 것이 그 시작이었지만 어셈블리어를 사용할 기회는 그다지 많지 않았다. 터보 파스칼의 그래픽 라이브러리를 사용하다가 마이크로소프트웨어 잡지책에 나와있던 원그리기/직선 그리기 알고리즘을 MASM 어셈블리어로 구현하고 이를 개선하려고 하면서 VGA 메모리 접근을 위해 주소를 어셈블리어로 직접 다루던 부분을 디버깅하던 기억이 아직도 남아있다. 당시 어셈블리어는 이해하기 어려웠고 고치기도 힘들었으나 최적화되고 나서 기대했던 속도가 나왔을 당시의 느꼈던 감격은 어셈블러의 단점을 보상하고도 남음이 있었다.

아무튼 지금 이 글을 쓰고있는 시점에서 조차도 본인은 어셈블리어에 대한 경험이 별로 없지만, 어셈블리어 혹은 리버스엔지니어링에 대한 기초적인 입문서도 많지 않다는 것에 조금 의아스럽게 생각하며, 어셈블리어에 완전히 생초짜라도 누구나 쉽게 따라해보면서 어셈블리어의 기초와 리버스엔지니어링을 같이 배워가며 문서를 쓸 예정이다.

C언어 기초 및 리눅스/유닉스에 어느정도 익숙한 분들을 대상으로 하지만 내용은 훨씬 쉽게 쓰려고 한다.

gcc와 objdump를 사용한 간단한 역공학

가장 먼저 초간단 C 프로그램을 만들어보자.

int main() {
return 0;
}

이를 a.c로 저장하고 gcc -c a.c 라고 하면 a.o가 얻어지며 a.o 오브젝트 파일을 objdump를 사용하여 간단히 역어셈블해보면 다음과 같다. (여기서는 -O1 컴파일 옵션을 주었으며, objdump -d 명령으로 디스어셈블을 하되 -Mintel 옵션으로 인텔 방식의 명령을 사용했다. gcc 버전은 5.4.0 / OS는 우분투 16.04.4)

$ gcc -c -O1 a.c
$ objdump -d -Mintel a.o

a.o:     file format elf64-x86-64


Disassembly of section .text:

0000000000000000 <main>:
   0:   b8 00 00 00 00          mov    eax,0x0
   5:   c3                      ret

여기 처음으로 두개의 어셈블리어 명령이 보이는데, mov eax,0x0 명령은 eax 레지스터에 0x0을 옮기고(mov), ret 명령은 리턴하라는 것이다. (여기서는 eax레지스터를 처음으로 보게 되는데, 인텔의 16비트 레지스터는 ax,bx,cx,dx였던 것이 32비트로 넘어오면서 eax,ebx,ecx,edx로 확장되었고 ax,bx,cx,dx는 각각 8비트 레지스터 ah/al,bh/bl,ch/cl,dh/dl 등으로 구성된다. x86에서 지원하고 있는 레지스터에 대한 좀 더 자세한 정보는 위키피디아 https://en.wikipedia.org/wiki/X86#x86_registers 문서를 참고)

gcc에 최적화 옵션을 -O2로 주어서 컴파일한 후에 디스어셈블해보면 다음과 같다.

$ objdump -d -Mintel a.o

a.o:     file format elf64-x86-64


Disassembly of section .text.startup:

0000000000000000 <main>:
   0:   31 c0                   xor    eax,eax
   2:   c3                      ret

O2로 최적화를 하는 경우에는 오브젝트의 크기가 6바이트에서 3바이트로 줄어든 것을 볼 수 있는데, xor 명령을 사용하면 mov eax,0x0 명령이 5바이트인 것에 비해서 xor 명령은 2바이트밖에 안되면서 eax 레지스터의 내용을 지워서 0으로 만드는 동일한 작업을 수행하고 있다.

그렇다면 다음을 역어셈블해보면 어떻게 될까?

int main() {
return -1;
}

gcc -O2로 컴파일 한 후에 objdump -d -Mintel로 역어셈블 해보면 다음과 같다.

0000000000000000 <main>:
   0:   b8 ff ff ff ff          mov    eax,0xffffffff
   5:   c3                      ret

mov eax,0xffffffff 명령은 -1을 eax로 옮기는(mov)명령인데, -1는 32비트(4바이트)로 0xffffffff에 해당하며 mov eax, -1 명령과 똑같다.

NASM/YASM 소스 생성하기

이렇게 objdump를 이용해서 어셈블러 명령을 볼 수 있는 방법을 알게 되었지만 이와 같은 원리를 이용해서 컴파일 가능한 어셈블리어 소스를 만드는 것도 가능하다. (물론 몇가지 제한이 있다.) 다음 링크에서 찾아볼 수 있는 objconv를 사용하면 된다.

※ 다음 링크를 보면 gcc+objconv를 사용하여 nasm/yasm 소스를 만드는 방법을 볼 수 있는데, 이 내용을 참고하였다. http://stackoverflow.com/questions/35102193/how-to-generate-assembly-code-with-gcc-that-can-be-compiled-with-nasm

objconv 실행 파일은 다음 사이트를 통해 받을 수 있는데 리눅스에서 사용하려면 직접 컴파일 해야 한다. 소스 제공 및 다운로드: http://www.agner.org/optimize/ (x86/x86_64를 위한 최적화 자료모음이 함께 있다.)

이제 위에서 만든 초간단 소스 a.c을 gcc로 컴파일 하여 오브젝트 파일 a.o을 만든 후에 이것을 objconv를 통해서 NASM/YASM으로 컴파일 할 수 있는 asm 소스파일을 만들어보자.

이 경우 -fno-asynchronous-unwind-tables 옵션을 통해서 몇가지 디버깅 정보를 생성하지 않는 것이 편리하다. (gcc man 페이지를 살펴보면 -fasynchronous-unwind-tables 옵션은 찾을 수 있는데 이 옵션에 대한 해설은없다.  이 옵션은 디버거 혹은 가비지 콜렉터에서 사용할 수 있는 DWARF 2 형식의 해석 테이블(unwind table)을 만들지 않도록 한다.)

$ gcc -fno-asynchronous-unwind-tables -O2 -c a.c

다음 명령을 통해 a.o 오브젝트 파일을 objconv를 통해서 asm 소스 파일로 변환한다. (-fnasm 혹은 -fyasm 옵션을 써서 NASM/YASM 형식으로 출력한다.)

$ objconv -fnasm a.o

(이렇게 하면 a.asm 소스 파일을 얻는다. 출력 파일 이름을 별도로 지정하려면 objconv -fnasm a.o my.asm 라고 하면 my.asm 소스파일을 얻게 된다)

얻은 asm 소스 파일의 내용은 다음과 같다.

; Disassembly of file: a.o
; Wed Mar  1 7:00:34 2017
; Mode: 64 bits
; Syntax: YASM/NASM
; Instruction set: 8086, x64

default rel

global main: function


SECTION .text   align=1 execute                         ; section number 1, code


SECTION .data   align=1 noexecute                       ; section number 2, data


SECTION .bss    align=1 noexecute                       ; section number 3, bss


SECTION .text.unlikely align=1 execute                  ; section number 4, code


SECTION .text.startup align=16 execute                  ; section number 5, code

main:   ; Function begin
        xor     eax, eax                                ; 0000 _ 31. C0
        ret                                             ; 0002 _ C3
; main End of function

이렇게 얻어진 위 소스는 몇가지 오류를 바로잡아야 NASM/YASM 명령을 통해 컴파일 할 수 있는데, sed 명령을 통해 다음과 같이 필터링해야 한다. (위의 stackoverflow에 나와있는 것을 참고로 해서 조금 수정한 sed 명령은 다음과 같다)

$ sed -i "s/align=1 //g;s/[a-z]*execute//g;s/: *function//g;/default *rel/d;" orig.asm

혹은 다음과 같이 불필요한 부분을 지우는 대신에 주석처리해서 남길 수도 있다. (";" 기호 뒤의 내용은 주석으로 인식됨)

$ sed -i "s/\(align=1\) /;\1 /g;s/\([a-z]*execute\)/;\1/g;s/: \(function\)/ ; \1/g;/default *rel/d;" a.asm

(-i 옵션을 사용하지 않으면 콘솔 화면으로 출력된다. 이를 쉘스크립트로 만들거나 해서 좀 더 쉽게 써먹을 수 있을 것이다.)

이렇게 필터링 처리한 소스는 다음과 같이 된다.

; Disassembly of file: a.o
; Wed Mar  1 1 7:00:34 2017
; Mode: 64 bits
; Syntax: YASM/NASM
; Instruction set: 8086, x64


global main


SECTION .text                            ; section number 1, code


SECTION .data                          ; section number 2, data


SECTION .bss                           ; section number 3, bss


SECTION .text.unlikely                   ; section number 4, code


SECTION .text.startup align=16                   ; section number 5, code

main:   ; Function begin
        xor     eax, eax                                ; 0000 _ 31. C0
        ret                                             ; 0002 _ C3
; main End of function

이 소스는 비어있는 섹션을 지워서 다음과 같이 더 간단히 만들 수도 있다.

; Disassembly of file: a.o
; Wed Mar  1 7:00:34 2017
; Mode: 64 bits
; Syntax: YASM/NASM
; Instruction set: 8086, x64

global main ; function
SECTION .text.startup                                   ; section number 5, code
main:   ; Function begin
        xor     eax, eax                                ; 0000 _ 31. C0
        ret                                             ; 0002 _ C3
; main End of function

이것을 YASM/NASM을 통해 컴파일해서 오브젝트 파일을 얻고 실행파일을 얻으려면 다음과 같이 한다.

$ yasm -f elf64 a.asm gcc a.o -o a.out

(이 경우 얻어지는 a.o을 objdump -d 명령으로 보려고 하면 내용이 출력이 안되는데 이 경우에는 objdump -D 옵션으로 출력하면 된다. objdump --help에 의하면 -D, --disassemble-all 이라는 설명을 볼 수 있다.) 이를 실행해보면 다음과 같다. (리턴값이 0임을 확인할 수 있다)

$ ./a.out ; echo $? 0

Hello World!

그러면 이제 조금 더 그럴듯한 C프로그램을 nasm으로 변환시켜보자.

#include <stdio.h>

int main() {
    printf("Hello World!\n");
    return 0;
}

이를 gcc로 컴파일하고 objconv로 nasm 소스로 변환시키고 수동으로 정리하면 다음과 같다.

; Disassembly of file: c.o
; Mode: 64 bits
; Syntax: YASM/NASM
; Instruction set: 8086, x64
;
; manually simplified

default rel
global main
extern puts                                             ; near

SECTION .rodata.str1.1
?_001:                                                  ; byte
        db 48H, 65H, 6CH, 6CH, 6FH, 20H, 57H, 6FH       ; 0000 _ Hello Wo
        db 72H, 6CH, 64H, 21H, 00H                      ; 0008 _ rld!.

SECTION .text.unlikely
SECTION .text.startup align=16
main:   ; Function begin
        sub     rsp, 8                                  ; 0000 _ 48: 83. EC, 08
        mov     edi, ?_001                              ; 0004 _ BF, 00000000(d)
        call    puts                                    ; 0009 _ E8, 00000000(rel)
        xor     eax, eax                                ; 000E _ 31. C0
        add     rsp, 8                                  ; 0010 _ 48: 83. C4, 08
        ret                                             ; 0014 _ C3
; main End of function

이를 컴파일하고 실행시키면 정상적으로 실행됨을 확인할 수 있다.

위 소스에서 문자열이 저장된 부분을 다음과 같이 좀 더 정리할 수도 있다.

; Disassembly of file: c.o
; Mode: 64 bits
; Syntax: YASM/NASM
; Instruction set: 8086, x64
;
; manually simplified

default rel
global main
extern puts                                             ; near

SECTION .rodata.str1.1
msg:
        db "Hello World!", 00h

SECTION .text.unlikely
SECTION .text.startup align=16
main:   ; Function begin
        sub     rsp, 8                                  ; 0000 _ 48: 83. EC, 08
        mov     edi, msg                              ; 0004 _ BF, 00000000(d)
        call    puts                                    ; 0009 _ E8, 00000000(rel)
        xor     eax, eax                                ; 000E _ 31. C0
        add     rsp, 8                                  ; 0010 _ 48: 83. C4, 08
        ret                                             ; 0014 _ C3
; main End of function

clang의 출력과 비교

이번에는 clang을 이용하여 동일한 소스를 컴파일하고 역어셈블 해보자. 순서의 차이 및 그밖의 차이가 약간 있으나 다음과 같이 거의 동일한 결과를 얻을 수 있다. (소스를 약간 정리함)

$ clang -fno-asynchronous-unwind-tables -O2 -c c.c

; Disassembly of file: c.o
; Wed Mar  1 11:13:03 2017
; Mode: 64 bits
; Syntax: YASM/NASM
; Instruction set: 8086, x64

global main
extern puts                                             ; near

SECTION .text   align=16                         ; section number 1, code

main:   ; Function begin
        push    rax                                     ; 0000 _ 50
        mov     edi, ?_001                              ; 0001 _ BF, 00000000(d)
        call    puts                                    ; 0006 _ E8, 00000000(rel)
        xor     eax, eax                                ; 000B _ 31. C0
        pop     rcx                                     ; 000D _ 59
        ret                                             ; 000E _ C3
; main End of function

SECTION .rodata.str1.1                 ; section number 2, const

?_001:                                                  ; byte
        db 48H, 65H, 6CH, 6CH, 6FH, 20H, 57H, 6FH       ; 0000 _ Hello Wo
        db 72H, 6CH, 64H, 21H, 00H                      ; 0008 _ rld!.ob

objdump와 gdb 비교

objdump -d 명령을 사용하여 간단히 디스어셈블 할 수 있지만 gdb도 역시 가능하다. objdump를 사용하면 명령행으로 간단히 디스어셈블을 할 수 있는 반면 gdb를 사용하면 옵션을 조절해가며 볼 수 있는 등의 장점이 있다.

gdb를 사용하기 위해서 위의 가장 간단한 프로그램 a.c를 다음과 같이 컴파일하고 gdb로 로드해보자.

$ gcc -O2 -g -c a.c
$ gdb a.o
GNU gdb (Ubuntu 7.11.1-0ubuntu1~16.04) 7.11.1
Copyright (C) 2016 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.  Type "show copying"
and "show warranty" for details.
This GDB was configured as "x86_64-linux-gnu".
Type "show configuration" for configuration details.
For bug reporting instructions, please see:
<http://www.gnu.org/software/gdb/bugs/>.
Find the GDB manual and other documentation resources online at:
<http://www.gnu.org/software/gdb/documentation/>.
For help, type "help".
Type "apropos word" to search for commands related to "word"...
Reading symbols from a.o...done.
(gdb) _

gdb 파일명 명령을 내리면 파일이 실행파일/오브젝트 파일 등을 로드하고 (gdb) 프롬프트가 뜨고 명령을 기다린다.

이 상태에서 help 명령을 내리면 gdb에서 사용할 수 있는 몇가지 명령 목록이 나오는데, help all 등을 하면 모든 gdb 명령 목록을 보여주며, gdb 명령이 잘 생각이 나지 않는다면 탭키를 두어번 누르면 명령 목록이 주르륵 나온다.

우선 x86 사용자라면 NASM/YASM 스타일이 익숙할 것이므로 set disassembly-flavor intel로 인텔 스타일로 바꿔준다. 오브젝트 파일을 디스어셈블하려면 disassemble을 입력하고 탭을 몇번 치면 disassemble 명령을 통해 디스어셈블 가능한 함수 목록이 나온다. disassemble main라고 입력하면 objdump -d -Mintel 명령으로 디스어셈블 하듯 콘솔에 내용을 출력한다.

(gdb) disassemble (탭을 몇번 치면 다음과 같이 함수 목록이 나온다)
a.c   int   main (a.c 소스의 int main 함수)
(gdb) disassemble main
Dump of assembler code for function main:
   0x0000000000000000 <+0>:     xor    %eax,%eax
   0x0000000000000002 <+2>:     retq
End of assembler dump.
(gdb) set disassembly-flavor intel (인텔 스타일로 출력)
(gdb) disassemble main
Dump of assembler code for function main:
   0x0000000000000000 <+0>:     xor    eax,eax
   0x0000000000000002 <+2>:     ret
End of assembler dump.
(gdb)

a.c 소스를 -g 옵션을 넣어 디버깅 정보를 넣어주었으므로, gdb의 list 명령으로 소스를 같이 볼 수 있다.

(gdb) list
1       int main() {
2       return 0;
3       }
(gdb)

위에서 사용했던 set disassembly-flavor intel같은 옵션은 ~/.gdbinit 설정 파일에 저장해 두면 편리할 것이다. 즉 다음과 같은 내용을 .gdbinit 파일에 넣어둘 수 있다.

set disassembly-flavor intel
set history save on
set history size 1000
set history filename ~/.gdb_history

여기서는 gdb를 통해 간단히 디스어셈블을 하는 방법만 알아보았지만, 더 궁금한 사용자라면 gdb를 사용하려면 필수적인 몇가지 명령은 https://blogs.oracle.com/ksplice/entry/8_gdb_tricks_you_should 문서를 통해 볼 수 있다.

gdb의 사용법은 조금 복잡해보이지만 기본적인 사용법을 익히고 나면 어렵지만은 않다. 검색해보면 한글로 된 좋은 자료도 많으니 이를 참고할 수 있을 것이다.

※몇몇 참고문서

- Relocatable Global Data on x86 - http://nullprogram.com/blog/2016/12/23/

by dumpcookie 2017. 3. 1. 11:24