X86 32비트 어셈블리어 소스코드 예제를 통해서 TEXTREL 문제를 확인해보고, 이를 TEXTREL 문제 없는 어셈블리어 소스를 만들어보자. 먼저 다음과 같은 간단한 소스코드 예제를 보자.

#include <stdio.h>
int main() {
   puts("Hello World\n");
   return 0;
}

이 소스를 clang으로 컴파일하고, objconv를 통해 NASM/YASM 소스코드로 변환시켜 조금 정리하면 다음과 같다. (clang -m32 -O2 -c 옵션으로 컴파일)

global main: function
extern puts

SECTION .text
main:
        sub     esp, 12
        mov     dword [esp], .str
        call    puts
        xor     eax, eax
        add     esp, 12
        ret
; main End of function

SECTION .data

.str:
        db "Hello World", 0xa, 0x0

스택 조작하는 부분을 더 간단히 고쳐보면 다음과 같다.

global main: function
extern puts

SECTION .text
main:
        mov     eax, .str
        push    eax
        call    puts
        add     esp, 4
        xor     eax, eax
        ret

SECTION .data

.str:
        db "Hello World", 0xa, 0x0

이를 my.asm로 저장하고 yasm -fnasm my.asm으로 컴파일 후에 gcc -m32 my.o 명령으로 링크를 하면 실행이 잘 됨을 확인할 수 있다

인라인 어셈블리에 익숙해지려면 AT&T 스타일의 어셈블리어도 알아할 것이므로 -S 옵션으로 어셈블리어 소스를 얻고, 이를 정리해보면 다음과 같다.

        .text
        .globl  main
        .align  16
main:
        movl    $.str, %eax
        push    %eax
        calll   puts
        addl    $4, %esp
        xorl    %eax, %eax
        retl

        .section        .data
        .align  16
.str:
        .asciz  "Hello World"

일대일 대응이므로 이해하는데 어렵지 않을 것이다. 이 소스는 my2.s로 저장한 후에 gcc -m32 my2.s로 컴파일하면 실행이 잘 됨을 확인할 수 있다.

이렇게 얻어진 어셈블리어 소스를 오브젝트 파일로 만든 후에 공유 라이브러리를 만들면 어떻게 될까?
다음과 같은 명령으로 공유라이브러리를 얻을 수 있으며, TEXREL 문제 역시 확인할 수 있다.

$ gcc -Wl,--warn-shared-textrel -m32 -shared -Wl,-soname,my2.so -o /tmp/my2.so my.o
/usr/bin/ld: my.o: warning: relocation in readonly section `.text'
/usr/bin/ld: warning: creating a DT_TEXTREL in a shared object.

그렇다면 이와 같은 문제의 어셈블리어 소스의 TEXTREL 문제를 어떻게 해결할 수 있을까?

함수의 리로케이션

우선 objdump -r 명령을 통해 리로케이션 정보를 살펴보자. (readelf -r 명령과 거의 동일)

$ objdump -r my.o

my.o:     file format elf32-i386

RELOCATION RECORDS FOR [.text]:
OFFSET   TYPE              VALUE
00000001 R_386_32          .data
00000007 R_386_PC32        puts

puts 함수의 리로케이션 정보를 고치기 위해서는 NASM의 경우 메뉴얼에 나온것처럼 다음과 같이 고치면 함수의 리로케이션 문제가 해결된다. 즉 함수 호출을 call puts로 하는 대신에 call puts wrt ..plt라고 해준다.

global main: function
extern puts

SECTION .text
main:
        mov     eax, .str
        push    eax
        call    puts wrt ..plt
        add     esp, 4
        xor     eax, eax
        ret

SECTION .data

.str:
        db "Hello World", 0xa, 0x0

이렇게 고친 후에 yasm -f elf my.asm으로 컴파일하고, objdump -r 명령으로 확인해보면 뭔가 달라졌음을 확인할 수 있다.

$ objdump -r my.o

my.o:     file format elf32-i386

RELOCATION RECORDS FOR [.text]:
OFFSET   TYPE              VALUE
00000001 R_386_32          .data
00000007 R_386_PLT32       puts

즉, 리로케이션 테이블에 있는 puts 엔트리의 오프셋은 전혀 변화가 없는데, 리로케이션 타입이 R_386_PLT32로 바뀌었으며, 함수 puts에 대한 리로케이션 문제는 수정된 것이다.

AT&T 스타일의 소스의 경우는 다음과 같은 식으로 고쳐야 한다.

        .text
        .globl  main
        .align  16
main:
        movl    $.str, %eax
        push    %eax
        calll   puts@PLT
        addl    $4, %esp
        xorl    %eax, %eax
        retl

        .section        .data
        .align  16
.str:
        .asciz  "Hello World"

YASM/NASM에서는 조금 헷갈리게 만드는 wrt ..plt라는 지시자를 썼지만, AT&T 스타일에서는 왠지 한결 쉬운듯한 @PLT를 사용하고 있다. 이 경우 역시 gcc -m32 -c 명령으로 컴파일한 후에 gcc로 공유 라이브러리를 생성시켜보면 여전히 TEXTREL문제가 남아있음을 확인할 수 있는데, 이는 함수에 대한 리로케이션 뿐만 아니라, 데이터에 대한 리로케이션 정보도 바꿔줘야 하기 때문이다.

어셈블리어 소스는 @PLT 지시자를 통해서 고쳤으나, objdump -d를 통해서 바이너리 코드를 살펴보면 바뀌기 전 코드와 완전히 일치한다. @PLT같은 지시자는 리로케이션 테이블의 정보를 바꿔주는 역할을 할 뿐, 실제 실행 코드 (.text 섹션내 실행 코드) 자체는 변화가 없다.

리로케이션 테이블을 readelf -r 명령을 통해서 보면 다음과 같다.

$ readelf -r my.o

Relocation section '.rel.text' at offset 0x54 contains 2 entries:
 Offset     Info    Type            Sym.Value  Sym. Name
00000001  00000201 R_386_32          00000000   .data
00000007  00000604 R_386_PLT32       00000000   puts

오브젝트 파일을 xxd로 덤프해서 바이너리 파일을 비교해보면 다음과 같은 차이가 있는 것을 볼 수 있다. (아래쪽 변경점은 소스파일의 파일 이름이 fa.asm 에서 fb.asm으로 변한 정보)

--- a   2017-03-10 18:54:40.016601488 +0900
+++ b   2017-03-10 18:54:43.364574036 +0900
@@ -4,11 +4,11 @@
 00000030: 0700 0100 0000 0000 0000 0000 0000 0000  ................
 00000040: b800 0000 0050 e8fc ffff ff83 c404 31c0  .....P........1.
 00000050: c300 0000 0100 0000 0102 0000 0700 0000  ................
-00000060: 0406 0000 4865 6c6c 6f20 576f 726c 6421  ....Hello World!
+00000060: 0206 0000 4865 6c6c 6f20 576f 726c 6421  ....Hello World!
 00000070: 0a00 0000 002e 7465 7874 002e 6461 7461  ......text..data
 00000080: 002e 7265 6c2e 7465 7874 002e 7374 7274  ..rel.text..strt
 00000090: 6162 002e 7379 6d74 6162 002e 7368 7374  ab..symtab..shst
-000000a0: 7274 6162 0000 0000 0066 612e 6173 6d00  rtab.....fa.asm.
+000000a0: 7274 6162 0000 0000 0066 622e 6173 6d00  rtab.....fb.asm.
 000000b0: 6d61 696e 0070 7574 7300 0000 0000 0000  main.puts.......
 000000c0: 0000 0000 0000 0000 0000 0000 0100 0000  ................
 000000d0: 0000 0000 0000 0000 0400 f1ff 0000 0000  ................

데이터 리로케이션

다음은 데이터 리로케이션 정보를 넣어줄 차례이다. 그런데 이 경우는 x86_32비트의 경우 문제가 약간 복잡한데, 이유는 차차 설명하기로 한다.

먼저 64비트의 경우를 살펴보자. hello world 프로그램을 clang -O2 -S 옵션으로 소스를 얻고 정리를 해보면 다음과 같다.

        .text
        .globl  main
main:
        movl    $.str, %edi
        callq   puts
        xorl    %eax, %eax
        retq

        .section        .rodata
.str:
        .asciz  "Hello World"

여기서 movl $.str, %edi 코드는 .str 문자열의 주소값($.str)을 %edi 레지스터에 넣으라는 것이다. 이 명령은 leal .str, %edi와 하는 일이 같다.

64비트의 경우에는 함수 호출 규약이 더 간단한데, 스택으로 호출 인자를 넘기는 것이 아니라 rdi, rsi 등의 레지스터를 통해 인자를 넘기고 있다. 따라서 push 혹은 pop을 통해 인자를 넘기지 않고 곧바로 edi, esi 레지스터를 수정하게 된다.

이 프로그램의 리로케이션 문제를 해결하려 하면 다음과 같이 바꿔주어야 한다.

        .text
        .globl  main
main:
        leal    .str(%rip), %edi
        callq   puts@PLT
        xorl    %eax, %eax
        retq

        .section        .rodata
.str:
        .asciz  "Hello World"

32비트에서처럼 함수의 경우에는 @PLT 지시자를 넣었으나, 데이터 섹션의 .str 주소를 넘기는 부분은 leal .str(%rip), %edi를 사용했다.
이것은 단순히 지시자를 사용한 것이 아니며, X86_64(AMD64)에서 새로 도입된 레지스터 %rip를 사용해서 .str 문자열 주소를 넘겨준 것이다.

AT&T의 문법이 헷갈릴 수 있으니 이에 대응하는 YASM/NASM 어셈블리어로 살펴보자.

default rel

global main

extern puts

SECTION .text

main:
        lea     edi, [rel .str]
        call    puts wrt ..plt
        xor     eax, eax
        ret

SECTION .rodata

.str:
        db "Hello World"

default rel등등이 포함된 것을 제외하고는 32비트/64비트 프로그램 소스가 거의 같고, .str 문자열 주소를 넘겨주는 부분이 다른 것을 볼 수 있다.

문자열의 주소는 .str이며, .str의 주소의 내용은 [.str]인데, lea eax, [.str]이라고 쓰면 .str의 주소값을 넘겨주라는 것이다. (NASM/YASM에서 사용할 수 있는 몇몇 주소 지정 방식은 다음 링크를 통해서 볼 수 있다. https://www.tortall.net/projects/yasm/manual/html/nasm-effaddr.html#nasm-effaddr-riprel)

이렇게 문자열 .str의 주소를 넘겨주는 코드에 rel이라는 지시자를 넣으면 리로케이션 문제가 없는 오브젝트 파일이 생성된다. (NASM/YASM에서는 rip 레지스터를 직접 사용하면 오류가난다.) 이렇게 해서 생성된 코드의 바이트수도 조금 다르다는 것을 다음과 같이 확인할 수 있다.

$ objdump -d nopic.o

ffa-nopic.o:     file format elf64-x86-64

Disassembly of section .text:

0000000000000000 <main>:
   0:   8d 3c 25 00 00 00 00    lea    0x0,%edi
   7:   e8 00 00 00 00          callq  c <main+0xc>
   c:   31 c0                   xor    %eax,%eax
   e:   c3                      retq

$ objdump -d pic.o # 수정된 PIC 코드의 경우

ffa.o:     file format elf64-x86-64

Disassembly of section .text:

0000000000000000 <main>:
   0:   8d 3d 00 00 00 00       lea    0x0(%rip),%edi        # 6 <main+0x6>
   6:   e8 00 00 00 00          callq  b <main+0xb>
   b:   31 c0                   xor    %eax,%eax
   d:   c3                      retq

함수와는 다르게, 데이터의 주소를 상대적 주소로 바꿔주기 위해서 명령 코드 자체가 조금 바뀐 것을 알 수 있는데, 64비트에서 데이터 주소를 상대적 주소로 바꿔주기 위해서 %rip 레지스터를 사용한다. %rip 레지스터는 X86_32에서는 %eip이며, 16비트 32비트의 경우 %eip 어드레스의 값을 손쉽게 불러오지 못하였으나, X86_64(AMD64)부터는 %rip를 불러서 상대적 주소를 쉽게 계산할 수 있게 된 것이다.

그렇다면 X86 32비트에서는 데이터의 상대주소를 어떻게 구할까?

Call Pop 트릭

콜팝은 콜라+팝콘의 뜻이 아니고, 32비트에서 %eip 레지스터의 값을 가져오는 트릭이다. call + pop을 통해 %eip 레지스터 값을 읽는 방식을 구현할 수 있다. 32비트에서도 명령 카운트 레지스터가 존재하지만, 이것은 직접 읽기가 불가능한 레지스터이다. 그런데 함수를 콜하게 되면 스택에 리턴할 주소를 저장한다는 사실을 알고 있다. 스택에 저장되는 리턴할 주소가 바로 명령 카운터 레지스터 %eip의 값인 셈이다. 또한, 리턴할 주소는 바로 다음 명령이 실행될 위치이며, 이 경우 local:주소의 값이 된다. 따라서 아래와 같이 하면 %ebx 레지스터에 local:의 실제 주소값이 저장될 것이다.

    call local
local:
    pop %ebx

pop 대신에 mov (esp), %ebx라고 해도 된다. 그렇다면 이렇게 얻어진 %ebx 값은 local:의 실제 메모리에 올라간 진짜 주소가 된다는 것인데, 그렇다면 데이터가 적재된 곳의 주소 위치는 어떻게 구하게 될까? 64비트에서 lea .str(%rip), %eax라고 하면 되었듯이, %rip 대신에 %eip가 저장된 위치인 %ebx를 사용하여 leal .str(%ebx), %eax 라고 하면 될 것 같은데, 실제로는 이런 메커니즘을 지원하지 않으며, 다음과 같은 조금 아리송한 코드가 실제 사용된다. 즉,

    call local
local:
    pop %ebx
    leal .str(%ebx), %eax

라고 해야 하는 것이 아니라 다음과 같이 한다

    call local
local:
    pop %ebx
local2:
    addl $_GLOBAL_OFFSET_TABLE_ + (local2 - local), %ebx
    leal .str@GOTOFF(%ebx), %eax

알쏭달쏭한 이 코드를 gdb로 파헤쳐보면 내막을 알 수 있지만, 실제로 _GLOBAL_OFFSET_TABLE_ 이라는 주소의 값은 프로그램이 적재되기 이전까지 알 수 없는 값이고, 소스코드상의 _GLOBAL_OFFSET_TABLE_은 조금 다르게 해석 되어진다. (이 부분은 아래에서 살펴보겠다.) 소스를 컴파일해서 얻어진 오브젝트 파일은 $_GLOBAL_OFFSET_TABLE_값을 아직 알지 못하고, 최종 실행파일은 링커에 의해 해석되어진 후에 GLOBAL OFFSET TABLE이 만들어지게 되어 비로소 GLOBAL_OFFSET_TABLE을 알게 된다.
본인도 이 부분이 제대로 이해되지 않아서 어려워했으나, gdb를 통해 내막을 살펴보고서야 이해하게 되었다.

_GLOBAL_OFFSET_TABLE은 변수? 상수?

우선 다음의 소스코드를 살펴보자. 이것은 hello world 프로그램의 PIC 버전이다. clang -O2 -fPIC -m32 -S 옵션으로 컴파일해서 소스를 얻고 이를 정리한 것이다.

        .text
        .globl  main
main:
        call    .tmp0
.tmp0:
        popl    %ebx
.tmp1:
        addl    $_GLOBAL_OFFSET_TABLE_+(.tmp1-.tmp0), %ebx
        leal    .str@GOTOFF(%ebx), %eax
        movl    $_GLOBAL_OFFSET_TABLE_, %ebx ; 디버그용 코드
        movl    $_GLOBAL_OFFSET_TABLE_, %ebx ; 디버그용 코드
        push    %eax
        call    puts@PLT
        
        add     $4, %esp
        xorl    %eax, %eax
        retl

        .section        .rodata
.str:
        .asciz  "Hello World"

$_GLOBAL_OFFSET_TABLE값을 알기 위해서 중간에 디버그 코드를 넣었다. 이를 컴파일해서 오브젝트 파일 및 실행파일을 얻었다. 이를 objdump -d 명령으로 살펴보면 main 함수가 다음과 같다.

objdump -d 목적파일

00000000 <main>:
   0:   e8 00 00 00 00          call   5 <.tmp0>

00000005 <.tmp0>:
   5:   5b                      pop    %ebx

00000006 <.tmp1>:
   6:   81 c3 03 00 00 00       add    $0x3,%ebx
   c:   8d 83 00 00 00 00       lea    0x0(%ebx),%eax
  12:   bb 01 00 00 00          mov    $0x1,%ebx
  17:   bb 01 00 00 00          mov    $0x1,%ebx
  1c:   50                      push   %eax
  1d:   e8 fc ff ff ff          call   1e <.tmp1+0x18>
  22:   83 c4 04                add    $0x4,%esp
  25:   31 c0                   xor    %eax,%eax
  27:   c3                      ret

위의 내용을 잘 살펴보면 $_GLOBAL_OFFSET_TABLE_ 값이 들어가야 할 자리가 $0x1 이라는 값이 들어있다. 또한, $_GLOBAL_OFFSET_TABLE_+(.tmp1-.tmp0)가 들어가야 할 자리는 두 주소의 차이 (.tmp1-.tmp0) 값이 1이므로 0x02이어야 할 것 같은데 이상하게 $0x03이 들어있다. 그러면 실행파일을 덤프해보면 어떤가 살펴보자.

objdump -d 실행파일

...(생략)
0804840b <main>:
 804840b:       e8 00 00 00 00          call   8048410 <.tmp0>

08048410 <.tmp0>:
 8048410:       5b                      pop    %ebx

08048411 <.tmp1>:
 8048411:       81 c3 f0 1b 00 00       add    $0x1bf0,%ebx
 8048417:       8d 83 c0 e4 ff ff       lea    -0x1b40(%ebx),%eax
 804841d:       bb e3 1b 00 00          mov    $0x1be3,%ebx
 8048422:       bb de 1b 00 00          mov    $0x1bde,%ebx
 8048427:       50                      push   %eax
 8048428:       e8 b3 fe ff ff          call   80482e0 <puts@plt>
 804842d:       83 c4 04                add    $0x4,%esp
 8048430:       31 c0                   xor    %eax,%eax
 8048432:       c3                      ret
...(생략)
Disassembly of section .got.plt:

0804a000 <_GLOBAL_OFFSET_TABLE_>:
 804a000:       14 9f                   adc    $0x9f,%al
 804a002:       04 08                   add    $0x8,%al

실행파일에서는 $_GLOBAL_OFFSET_TABLE_이 들어갈 위치의 값이 모두 다른 값으로 채워져 있으며, $_GLOBAL_OFFSET_TABLE_+(.tmp1-.tmp0) 값에는 $0x1bf0(7152)가 들어가고, $_GLOBAL_OFFSET_TABLE_값이 들어갈 줄 알았던 곳은 각각 $0x1be3(7139) , $0x1bde(7134) 값이 채워져 있다. 그리고 정작 _GLOBAL_OFFSET_TABLE_ 주소는 0x0804a000값이다.
실제로 상수인줄 알았던 소스코드상에 들어간 $_GLOBAL_OFFSET_TABLE_ 값은 링커에 의해 고정된 값으로 해석되는 것이 아니라 .data 섹션과의 오프셋값으로 해석되어지고 있는 것이다.

따라서 실제 GLOBAL_OFFSET_TABLE 즉 실행파일이 메모리에 올라갔을 때에 .data 세그먼트의 위치를 가리키는 값은 다음과 같이 계산되어진다.

        GLOBAL_OFFSET_TABLE의 실제 위치 = 현재 실행 코드의 %eip + 현재 실행 코드와 GLOBAL_OFFSET_TABLE 주소와 오프셋(여기서는 $_GLOBAL_OFFSET_TABLE_)

아무튼 이 과정은 GNU as + GND ld의 합작 과정인 것인데, YASM/NASM에서는 이마져도 약간 다르게 보이지만 원리는 같다. 다음 코드는 YASM/NASM에서 GLOBAL_OFFSET_TABLE를 구하는 코드이다.

global main: function
extern puts
extern _GLOBAL_OFFSET_TABLE_ ; YASM/NASM에서는 이를 선언해주어야 한다.

SECTION .text
main:
        call .get_got
.get_got:
        pop     ebx
.tmp:
        add     ebx, _GLOBAL_OFFSET_TABLE_ + .tmp - .get_got ; AT&T 스타일의 어셈블리어와 같은 방식
        ;add    ebx, _GLOBAL_OFFSET_TABLE_ + $$ - .get_got wrt ..gotpc ; NASM/YASM 메뉴얼에 나와있는 방식.
        lea     eax, [.str + ebx wrt ..gotoff]
        push    eax
        call    puts wrt ..plt
        add     esp, 4
        xor     eax, eax
        ret

SECTION .data

.str:
        db "Hello World", 0xa, 0x0

이렇게 하여 이 소스를 컴파일하여 오브젝트 파일의 리로케이션 정보를 보면 다음과 같게 되며 TEXTRELs 문제가 없게 된다.

$ objdump -r test.o

test.o:     file format elf32-i386

RELOCATION RECORDS FOR [.text]:
OFFSET   TYPE              VALUE
00000008 R_386_GOTPC       _GLOBAL_OFFSET_TABLE_
0000000e R_386_GOTOFF      .data
00000014 R_386_PLT32       puts

※참고:

http://nullprogram.com/blog/2016/12/23/ - GLOBAL_OFFSET_TABLE 및 x86에서 리로케이션에 대한 설명이 되어있다.

by dumpcookie 2017. 3. 11. 09:40