지난번에는 간단한 C언어 예제를 objconv 및 nasm/yasm을 통해서 x86 어셈블리어로 변환하는 방식을 살펴본 바 있다. objconv 프로그램은 상당히 완성도가 높았으며, MASM 혹은 인텔 어셈블리어 문법에 익숙한 사용자에게 쓸만한 고급언어 -> x86 어셈블리어 변환 용도로 활용될 수 있을 것이고, 어셈블리어를 최적화하는 용도 혹은 기타 어셈블리어를 배우는 등의 활용을 할 수 있다는 점을 살펴 보았다. 이번에는 인라인 어셈블리어에 대해 살펴보려고 한다.

상당히 많은 수의 유명 라이브러리는 고급 언어로 작성되어있지만, 게중에 일부는 특정 CPU에 더욱 최적화되어 있는데, 최적화된 부분은 보통 어셈블리어를 통해 이루어지고 있다. 프로그램 소스 전체가 모두 어셈블리어로 작성된 경우는 거의 찾아보기 어렵고 보통은 해당 라이브러리의 일정 부분이 어셈블리어를 통해 최적화 된 형태이다. 또한 어셈블리어가 별도의 소스로 완전히 분리되어 있는 경우도 있지만, C언어(혹은 다른 고급 언어)와 어셈블리어를 섞어서 사용하는 경우가 상당수인데, C 소스에 어셈블리어를 섞어서 사용하는 기법을 인라인 어셈블리(Inline Assembly)라고 부른다.

gcc는 내장된 GAS(GNU Assembler) 어셈블러를 가지며, 이것이 기본으로 지원하는 어셈블리어는 다음과 같은 AT&T 스타일의 어셈블리어이다.

mov $0x05, %eax # 주석

이것은 16진수 0x5값을 eax 레지스터에 넣으라는 명령이다. 이 명령에 대응하는 인텔 어셈블리어 스타일은 다음과 같다.

mov eax, 0x05

인텔 스타일과 AT&T 스타일을 비교해보면, 이 경우에는 명령코드의 소스와 대상의 위치가 바뀌고, 레지스터를 구분하는 prefix가 %, 숫자를 구분시켜주는 prefix로 $를 붙이는 것이 다를 뿐 거의 다를바가 없다. 최신 gcc의 경우에는 인텔 스타일 및 AT&T 스타일 모두 사용하는 것이 가능하나, 전통적으로(?) AT&T 스타일로 인라인 에셈블리어를 사용한다. 이미 x86 어셈블리어에 익숙한 사용자라면 조금 헷갈릴 수는 있으나, 이하 예제에서 나올 인라인 에셈블리 스타일은 모두 AT&T 스타일임에 유의하자.)

첫번째 예제

이제 다음의 아주 간단한 인라인 어셈블러 예제를 살펴보자.

int main() {
        int dst = 0;
        __asm__ volatile (
                "mov $100, %%eax\n\t"
                "add $10, %%eax\n\t"
                "mov %%eax, %0\n\t"
                : "=m" (dst)
        );
        return dst;
}

소스를 보면 대충 예측할 수 있을 정도로 간단하지만, 이 간단한 인라인 어셈블리 프로그램이 하는 것은 다음과 같다.

1) 숫자 100을 %eax 레지스터에 넣고, 2) 숫자 10을 %eax 레지스터에 더하여 얻어진 것을 0번째 출력 인자 dst에 넣는다. 그러므로 dst값은 110이 될 것이고 main() 함수는 110을 리턴하게 된다. 위의 경우 어셈블리어를 세줄로 나누어 보기 좋게 하였으나, 다음과 같이 한줄로 넣는 것도 물론 가능하다.

"mov $100, %%eax\n\tadd $10, %%eax\n\tmov %%eax, %0\n\t"

그러나 이렇게 소스를 만들면 조금 보기 불편하므로 여러줄로 보통 나누어 쓴다. 또한 각 줄은 "\n\t" 문자 즉, 줄넘김(\n) 문자를 필수적으로 넣어주어야 하고, 여기에 탭(\t) 문자를 추가적으로 넣어주어서 gcc -S 옵션으로 컴파일 할 경우 얻을 수 있는 어셈블리어 소스에 들여쓰기가 되도록 해준다.

위 소스코드 예제를 test.c라는 이름으로 저장하고 이를 다음과 같은 명령으로 컴파일을 해서 어셈블러 소스코드 test.s를 얻어보면 다음과 같다. (실행 환경 우분투 16.04.4 / gcc 버전 5.4.0)
$ gcc -O2 -m32 -S test.c

        .file   "test.c"
        .section        .text.unlikely,"ax",@progbits
.LCOLDB0:
        .section        .text.startup,"ax",@progbits
.LHOTB0:
        .p2align 4,,15
        .globl  main
        .type   main, @function
main:
.LFB0:
        .cfi_startproc
        leal    4(%esp), %ecx
        .cfi_def_cfa 1, 0
        andl    $-16, %esp
        pushl   -4(%ecx)
        pushl   %ebp
        .cfi_escape 0x10,0x5,0x2,0x75,0
        movl    %esp, %ebp
        pushl   %ecx
        .cfi_escape 0xf,0x3,0x75,0x7c,0x6
        subl    $20, %esp
        movl    %gs:20, %eax
        movl    %eax, -12(%ebp)
        xorl    %eax, %eax
#APP
# 3 "test.c" 1
        mov $100, %eax
        add $10, %eax
        mov %eax, -16(%ebp)

# 0 "" 2
#NO_APP
        movl    -12(%ebp), %edx
        xorl    %gs:20, %edx
        movl    -16(%ebp), %eax
        jne     .L5
        addl    $20, %esp
        popl    %ecx
        .cfi_remember_state
        .cfi_restore 1
        .cfi_def_cfa 1, 0
        popl    %ebp
        .cfi_restore 5
        leal    -4(%ecx), %esp
        .cfi_def_cfa 4, 4
        ret
.L5:
        .cfi_restore_state
        call    __stack_chk_fail
        .cfi_endproc
.LFE0:
        .size   main, .-main
        .section        .text.unlikely
.LCOLDE0:
        .section        .text.startup
.LHOTE0:
        .ident  "GCC: (Ubuntu 5.4.0-6ubuntu1~16.04.4) 5.4.0 20160609"
        .section        .note.GNU-stack,"",@progbits

인라인 어셈블리 소스코드 예제는 10줄밖에 안되는데 어셈블리어로 변환시키니 58줄이나 되어버려 복잡해 보이는데, 잘 살펴보면 인라인 어셈블리어 부분은 다음과 같은 부분에 해당된다. 원본 인라인 어셈블리 소스가 3줄이었는데 이것이 gcc에 의해 변환해서 얻어진 어셈블리어 소스에서도 마찬가지로 3줄이다.

...(생략)
#APP
# 3 "test.c" 1
        mov $100, %eax
        add $10, %eax
        mov %eax, -16(%ebp)

# 0 "" 2
#NO_APP
...(이하 생략)

gcc 대신에 clang을 사용하면 어떤 결과가 나올까? clang으로 변환해서 얻어진 어셈블리어 소스는 다음과 같다. 출력 소스를 최대한 간단히 보기 위해서 -O2 옵션을 주어 보았다. (clang 3.8.0)

$ clang -O2 -m32 -S test.c
        .text
        .file   "test.c"
        .globl  main
        .align  16, 0x90
        .type   main,@function
main:                                   # @main
# BB#0:
        pushl   %eax
        movl    $0, (%esp)
        #APP
        movl    $100, %eax
        addl    $10, %eax
        movl    %eax, (%esp)

        #NO_APP
        movl    (%esp), %eax
        popl    %ecx
        retl
.Lfunc_end0:
        .size   main, .Lfunc_end0-main


        .ident  "clang version 3.8.0-2ubuntu4 (tags/RELEASE_380/final)"
        .section        ".note.GNU-stack","",@progbits

자세히 살펴보면 인라인 어셈블리 부분의 명령 코드가 조금 달라졌음도 엿볼 수 있는데, 원래의 명령 코드는 mov였는데 movl로 바뀌는 등의 차이점도 있음을 알 수 있다. (movl의 l suffix는 long (32비트) 오퍼랜드에 대응하는 mov OP임을 뜻한다. l 이외에 b, s, w, q, t등의 suffix가 올 수 있다. 좀 더 자세한 내용은 https://en.wikibooks.org/wiki/X86_Assembly/GAS_Syntax 문서를 참고할 수 있다.

아무튼 clang의 경우 그 결과물이 훨씬 간단하니 이에 간단한 설명을 달아보자면 다음과 같다.

.text .file "test.c" .globl main .align 16, 0x90 .type main,@function main: # @main # BB#0: pushl %eax # %eax 레지스터를 스택에 넣어 스택이 하나 쌓았다. 하나의 스택 공간 확보. movl $0, (%esp) # 스택 최상위 주소"(%esp)"에 0을 넣음. # 해당 스택위치 자리를 0으로 초기화 한 것은 지역 변수 dst를 0으로 초기화 한 것에 해당 #APP movl $100, %eax # %eax 레지스터에 100을 넣음. addl $10, %eax # %eax 레지스터에 10을 더함 movl %eax, (%esp) # %eax 레지스터의 내용을 "(%esp)" 스택 최상위 주소에 넣음. # 해당 위치는 0으로 초기화 되어있었음 #NO_APP movl (%esp), %eax # 스택 최상위 주소의 내용"(%esp)"을 %eax 레지스터에 넣음. popl %ecx # 최상위 스택을 비움. 하나 쌓여있던 스택을 버림. retl # main() 함수 리턴/종료. %eax 레지스터에 리턴값이 보존되어 있다. .Lfunc_end0: .size main, .Lfunc_end0-main .ident "clang version 3.8.0-2ubuntu4 (tags/RELEASE_380/final)" .section ".note.GNU-stack","",@progbits

여기에 조금 더 부연설명을 추가하자면, 메인 함수가 시작하면서 pushl %eax를 실행하여 스택을 하나 늘리고 해당 스택을 0으로 초기화 하고 있는데, 이는 "int dst = 0;" 소스가 어셈블리어로 변환된 것에 해당한다. 즉, c 소스 main()함수에서 지정된 지역 저장소 "int dst"를 위해 스택을 하나 늘리고 이것을 0으로 초기화 한 것이다.
그런 다음에 이어지는 인라인 어셈블리 부분이 실행되고 나면 해당 지역 저장소 dst의 위치인 스택 값이 %eax 레지스터에 옮겨서 저장되는데, 메인함수 main()이 리턴하는 값은 %eax 레지스터에 최종적으로 전달된다.

(스택은 함수의 인자 전달 및 지역 변수의 용도 등등으로 활용되는데, push를 하면 스택이 쌓이며, 스택 레지스터 esp는 감소를 하게 되며, ebp 주소를 기준으로 [ebp + offset] 형태의 주소로 스택을 지정하여 사용하게 된다. 초보자는 조금 헷갈릴 수도 있지만 스택이 쌓이는 방향에 유의해야 하는데, 메모리 상위 주소가 아래쪽인 경우 스택이 위쪽으로 쌓이고, 상위 주소가 위쪽이면 스택은 아래 방향으로 쌓인다.)

얻어진 어셈블리어 소스를 gcc 명령으로 컴파일해서 실행파일을 얻을 수도 있고, 이러한 과정을 거치지 않고 곧바로 실행을 해도 마찬가지 결과를 얻게 된다.

$ clang -m32 -O2 test.c
$ ./a.out ; echo $?
110
$
혹은
$ clang -m32 -O2 test.s
$ ./a.out ; echo $?
110
$

다시 처음으로 돌아가서 test.c 소스를 다음과 같이 조금 바꿔보자.

int main() {
        int dst = 0;
        __asm__ volatile (
                "mov $100, %%eax\n\t"
                "add $10, %%eax\n\t"
                "mov %%eax, %0\n\t"
                : "=r" (dst)
        );
        return dst;
}

즉, 출력을 지정하던 부분이 원래는 "=m" (dst) 였던 것을 "=r" (dst)로 바꾼 것인데, 여기서 "="는 출력을 뜻하고 "=m"는 메모리 유형의 출력을, "=r"은 레지스터 유형의 출력을 의미한다. 즉, 출력 유형이 메모리였던 것을 레지스터 유형으로 제한(Constraint)을 바꾼 것이다.
새로 바꾼 소스를 clang으로 다시 컴파일해서 얻은 어셈블러 소스는 다음과 같다. (소스파일 이름을 2.c로 저장해 clang -O2 -m32 -S 2.c 명령으로 컴파일 하였다.)

        .text
        .file   "2.c"
        .globl  main
        .align  16, 0x90
        .type   main,@function
main:                                   # @main
# BB#0:
        #APP
        movl    $100, %eax
        addl    $10, %eax
        movl    %eax, %eax

        #NO_APP
        retl
.Lfunc_end0:
        .size   main, .Lfunc_end0-main


        .ident  "clang version 3.8.0-2ubuntu4 (tags/RELEASE_380/final)"
        .section        ".note.GNU-stack","",@progbits

와우! 어셈블리 소스가 매우 간단해졌다. 추가적으로 들어간 어셈블리어 소스가 전혀 없이 인라인 어셈블리 소스 단 3줄만 남아있다. 이 경우에는 gcc로 컴파일을 하더라도 대동소이한 결과물을 얻을 수 있었는데, 아래와 같았다. (add가 addl로 변환되는 등의 약간의 차이점이 있으나 이는 gcc/clang 컴파일러가 동일하게 해석한다.) gcc -O2 -m32 -S 2.c

...(생략)
        .globl  main
        .type   main, @function
main:
.LFB0:
        .cfi_startproc
#APP
# 3 "2.c" 1
        mov $100, %eax
        add $10, %eax
        mov %eax, %eax

# 0 "" 2
#NO_APP
        ret
...(생략)

인자의 유형 지정

그러면 이번에도 간단한 다른 예제를 살펴보자.

int main() {
    int src = 3;
    int dst = 5;
    __asm__ volatile (
        "imul %1, %0"
        : "=r" (dst) /* 출력 인자 지정 */
        : "r" (src) /* 입력 인자 지정 */
    );
    return dst;
}

이번에는 3과 5를 곱하고(imul 정수 곱셈 OP) 그 결과를 dst로 저장하려는 것이다. 이번에는 출력 인자와 입력 인자가 각각 하나씩 이며, src와 dst 유형 모두 레지스터로 지정하였고, 컴파일러에서 각 레지스터를 자동으로 지정할 수 있도록 해 보았다.
$ gcc -m32 -O2 -S test2.c 명령으로 컴파일해서 test2.s 소스를 살펴보니 다음과 같이 조금 이상하다. 컴파일을 하고 얻어진 어셈블리 소스가 의도한 대로 얻어지지 않고 있다. src와 dst 모두 %eax로 동일하게 지정되어 버린 것이다. (gcc / clang 모두 동일하며 아래는 gcc의 출력 결과.)

...생략
main:
.LFB0:
        .cfi_startproc
        movl    $3, %eax
#APP
# 4 "test2.c" 1
        imul %eax, %eax
# 0 "" 2
#NO_APP
        ret
...생략

그 이유는 출력 인자에 대한 제약 유형을 잘 못 지정했기 때문인 것인데, 출력 인자를 "=r" (dst)라고 하면 출력을 쓰기전용으로 지정하겠다는 뜻이기 때문이다. 의도대로 작동되려면 dst 값은 5이므로 읽기/쓰기가 모두 가능해야 한다. 따라서 출력 인자를 "=r"가 아닌 읽기/쓰기가 모두 가능한 변경자(Modifier) "+"를 써서 "+r"로 지정해야 한다. 즉, 소스를 조금 고쳐서 다음과 같이 해야 한다.

int main() { int src = 3; int dst = 5; __asm__ volatile ( "imul %1, %0" : "+r" (dst) /* 출력 인자를 읽기/쓰기 가능한 출력("+") 레지스터로 제한 */ : "r" (src) /* 입력 인자를 레지스터로 제한 */ ); return dst; }

이를 컴파일해서 소스코드를 얻으면 다음과 같다. (clang/gcc 모두 동일한 결과를 얻으며, 아래는 clang의 출력 결과)

...생략
main:                                   # @main
# BB#0:
        movl    $3, %ecx
        movl    $5, %eax
        #APP
        imull   %ecx, %eax
        #NO_APP
        retl
...생략

최종 확인하기 위해 컴파일 하고 실행을 하면 그 결과가 맞다는 것을 알 수 있다.

$ clang -m32 -O2  test2.c
$ ./a.out ; echo $?
15

정리

여기까지 인라인 어셈블리를 넣는 방식을 간단히 살펴보았는데, 정리해보면 인라인 어셈블리는 다음과 같은 형식으로 요약할 수 있다.

__asm__ volatile (
"인라인 어셈블리어 #1\n\t"
"인라인 어셈블리어 #2\n\t"
"인라인 어셈블리어 #3\n\t"
"인라인 어셈블리어 #4"
: 출력 인자1, 출력 인자 2, 출력 인자 3,
: 입력 인자4, 입력 인자5,
);

출력 인자의 유형을 "=r"라고 지정했을 경우 r은 레지스터 유형으로 제약(Constraint)을 하겠다는 것을 뜻하며, "+"는 읽기/쓰기 가능하게끔 제약 변경자(Constraint Modifier)를 걸어준다는 것을 뜻한다.

입출력 유형에 대한 제한/제약(Constraint)은 https://gcc.gnu.org/onlinedocs/gcc/Simple-Constraints.html 문서를 참고할 수 있으며,
추가적인 제약 변경자(Constraint Modifier)는 https://gcc.gnu.org/onlinedocs/gcc/Modifiers.html 문서를 통해 자세히 살펴볼 수 있다.

인라인 어셈블리에 대해 더 자세히 알고싶다면 다음 문서를 참고하길 바란다.

https://wiki.kldp.org/wiki.php/DocbookSgml/GCC_Inline_Assembly-KLDP

'입문하기' 카테고리의 다른 글

기타 케이블의 커패시턴스 효과  (4) 2022.07.11
x86 어셈블리어 배우기 - 리버스엔지니어링 기초  (0) 2017.03.01
아멘 샬롬  (0) 2015.04.21
아가페 프로토스  (1) 2015.04.20
by dumpcookie 2017. 3. 5. 16:53

오랫동안 프로그래밍을 해본 사용자라 할지라도 어셈블리어가 필요하거나 리버스엔지니어링(역공학)이 필요한 경우는 별로 많지 않다. 본인도 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
| 1 |