본문 바로가기

Debugging/Linux

64bit Stack Walking

64bit 프로그램에서 대부분의 함수들은 대개 Stack에 RBP Register의 값을 Push 하지 않습니다.

이는 Compile 시에 -fomit-frame-pointer flag 때문에 발생합니다.

이런 경우, 32bit 프로그램에 비해서 Call Stack을 수동으로 쫓아가는 것이 쉽지는 않습니다.

 

64bit 프로그램에서 Return Address를 확인하기 위해 Stack에 있는 Data와 Instruction Pointer를 이용할 수 있습니다.

다음과 같은 Call Stack 예제를 통해서 하나씩 확인해보겠습니다.

## 아래 Call Stack의 9번 Frame 부터 보면, Symbol 이 없기 때문에 정상적으로 함수명이 표현되지 않는 것을 알 수 있습니다.

(gdb) bt
...
#7  0x000000f9b95efa35 in __GI_raise (sig=6)
#8  0x000000f9b95f0eab in __GI_abort ()
#9  0x000000f9bbf72cc2 in ?? ()
#10 0x000000f977e226a8 in ?? ()
#11 0x647bb2cb40739500 in ?? ()
#12 0x000000f9c295b5f8 in ?? ()
#13 0x0000000000019001 in ?? ()
#14 0x0000000000000000 in ?? ()

 

1. 먼저, RIP와 RSP를 확인합니다.

정상적으로 함수가 보이는 Frame 8번으로 Switch 한 후, RIP와 RSP Register 주소를 확인합니다.

(gdb) f 8
#8  0x000000f9b95f0eab in __GI_abort () at abort.c:90

(gdb) p/x $rip ## p는 출력 형식, x는 16진수
$2 = 0xf9b95f0eab

(gdb) p/x $rsp
$3 = 0xf9c47cd920

 

 

2. RIP 값을 이용하여 Disassembly를 진행하고, 함수가 반환되기 전까지 RSP Register가 변경되는 부분을 확인합니다.

함수가 시작 될 때 Sub instruction이 나오는데, 이 Sub instruction을 이용하여, Callee의 Stack area를 생성하게 됩니다.

따라서, 함수가 반환될 때는 이 Callee의 Stack area를 제거해야 합니다.

즉, Stack Pointer인 RSP의 위치가 Callee의 Stack area만큼 변경되어야 합니다.

(gdb) disass $rip
Dump of assembler code for function __GI_abort:
   0x000000f9b95f0d30 <+0>:     sub    $0x128,%rsp
   0x000000f9b95f0d37 <+7>:     mov    %fs:0x10,%rdx
...
   0x000000f9b95f0e9b <+363>:   jne    0xf9b95f0f7e <_L_unlock_141>
   0x000000f9b95f0ea1 <+369>:   mov    $0x6,%edi
   0x000000f9b95f0ea6 <+374>:   callq  0xf9b95efa00 <__GI_raise>
=> 0x000000f9b95f0eab <+379>:   mov    %fs:0x10,%rdx
   0x000000f9b95f0eb4 <+388>:   cmp    0x374d5d(%rip),%rdx        # 0xf9b9965c18 <lock+8>
...
   0x000000f9b95f0f4f <+543>:   callq  0xf9b9633010 <_IO_flush_all_lockp>
   0x000000f9b95f0f54 <+548>:   mov    0x374cc6(%rip),%eax        # 0xf9b9965c20 <stage>
   0x000000f9b95f0f5a <+554>:   jmpq   0xf9b95f0d97 <__GI_abort+103>
End of assembler dump.

 

3. 2번 단계에서 확인한 Sub instruction과 RSP Register를 이용하여, 함수가 반환되었을 때의 Stack을 확인해보겠습니다.

기존에 확인한 RSP Register 값과, Sub Instrcution에서 사용했던 Offset을 이용하여 함수가 반환될 때의 Stack 주소를 확인하여, 반환 주소를 획득합니다.

(gdb) p/x $rsp+0x128 ## p는 변수에 저장된 값
$8 = 0xf9c47cda48

(gdb) x/a 0xf9c47cda48 ## x는 Memory 주소의 contents
0xf9c47cda48:   0xf9bbf72cc2 ## Return Address 획득

 

 

4. 3번 단계에서 획득한 Return Address를 가리키는 Stack Addres에서 Return Address 값을 획득하였습니다.

이제는 이 Return Address를 가지고 abort() 함수를 호출한 이전 함수에서 얼마만큼의 Callee Stack Area를 사용했는지 확인합니다.

아래 Return Address의 Disassembly를 확인해보면 함수 마지막에서 add instruction과 pop instruction이 있는 것을 알 수 있습니다. 

즉, 이 함수가 처리를 완료하고 이전 함수로 돌아갈 때, add와 pop instruction을 통해 Callee Stack Area를 정리하게 됩니다.

(gdb) x/30i 0xf9bbf72cc2 ## Assembly 형식으로 30줄 출력
   0xf9bbf72cc2:        nopw   0x0(%rax,%rax,1)
   0xf9bbf72cc8:        mov    (%rsp),%rdi
   0xf9bbf72ccc:        mov    $0x1,%esi
   0xf9bbf72cd1:        callq  0xf9bbf6e2e0
   0xf9bbf72cd6:        test   %al,%al
   0xf9bbf72cd8:        je     0xf9bbf72cbd
   0xf9bbf72cda:        mov    (%rsp),%rdi
   0xf9bbf72cde:        lea    0x50593(%rip),%rdx        # 0xf9bbfc3278
   0xf9bbf72ce5:        mov    %r12d,%r9d
   0xf9bbf72ce8:        mov    %rbp,%r8
   0xf9bbf72ceb:        mov    %rbx,%rcx
   0xf9bbf72cee:        mov    $0x1,%esi
   0xf9bbf72cf3:        xor    %eax,%eax
   0xf9bbf72cf5:        callq  0xf9bbf6b8d0
   0xf9bbf72cfa:        jmp    0xf9bbf72cbd
   0xf9bbf72cfc:        nopl   0x0(%rax)
   0xf9bbf72d00:        push   %rbx
   0xf9bbf72d01:        sub    $0x20,%rsp
   0xf9bbf72d05:        mov    %fs:0x28,%rax
   0xf9bbf72d0e:        mov    %rax,0x18(%rsp)
   0xf9bbf72d13:        xor    %eax,%eax
   0xf9bbf72d15:        test   %rdi,%rdi
   0xf9bbf72d18:        je     0xf9bbf72d33
   0xf9bbf72d1a:        mov    0x18(%rsp),%rcx
   0xf9bbf72d1f:        xor    %fs:0x28,%rcx
   0xf9bbf72d28:        mov    %rdi,%rax
   0xf9bbf72d2b:        jne    0xf9bbf72d52
   0xf9bbf72d2d:        add    $0x20,%rsp
   0xf9bbf72d31:        pop    %rbx
   0xf9bbf72d32:        retq

 

5. 4번 단계에서 확인한 Add와 Pop instruction 그리고 RSP Register를 이용하여, 함수가 반환되었을 때의 Stack을 확인해보겠습니다.

기존에 확인한 RSP Register 값과, Add Instrcution에서 사용했던 Offset을 이용하여 함수가 반환될 때의 Stack 주소를 확인하여, 반환 주소를 획득합니다.

(gdb) x/a 0xf9c47cda48 + 0x8(4번 단계에서 Return Address로 돌아갈 때 Return Address가 저장되어 있던 Stack 크기) + 0x20(Add instruction) +0x8(Pop)
0xf9c47cda78:   0xf9bbfab4c1

 

 

6. 5번 단계에서 획득한 Return Address인 0xf9bbfab4c1을 고려하여, 아래와 같이 Assembly를 확인해보면 0xf9bbfab4c1는 바로 이전 Line에서 "callq 0xf9bbf6e520"이 호출된 후 돌아오는 곳인 것을 알 수 있습니다.

(gdb) x/30i 0xf9bbfab4a0
   0xf9bbfab4a0:        (bad)
   0xf9bbfab4a1:        decl   -0x73(%rax)
   0xf9bbfab4a4:        adc    $0x2929f,%eax
   0xf9bbfab4a9:        lea    0x29790(%rip),%rsi        # 0xf9bbfd4c40
   0xf9bbfab4b0:        lea    0x29591(%rip),%rdi        # 0xf9bbfd4a48
   0xf9bbfab4b7:        mov    $0x52,%ecx
   0xf9bbfab4bc:        callq  0xf9bbf6e520
   0xf9bbfab4c1:        jmpq   0xf9bbfab416
   0xf9bbfab4c6:        nopw   %cs:0x0(%rax,%rax,1)
   0xf9bbfab4d0:        mov    0x8(%rsp),%rdi
   0xf9bbfab4d5:        mov    $0x5,%esi
   0xf9bbfab4da:        callq  0xf9bbf6e2e0
   0xf9bbfab4df:        test   %al,%al
   0xf9bbfab4e1:        je     0xf9bbfab3ec
   0xf9bbfab4e7:        mov    (%rbx),%rdi
   0xf9bbfab4ea:        lea    0x16fe7(%rip),%rcx        # 0xf9bbfc24d8
   0xf9bbfab4f1:        test   %rdi,%rdi
   0xf9bbfab4f4:        je     0xf9bbfab4fe
   0xf9bbfab4f6:        callq  0xf9bbf6f4a0

 

7. 다음으로, "callq 0xf9bbf6e520"를 살펴보면, 다음과 같이 특정 주소로 jmpq instruction을 사용하는 것을 알 수 있습니다.

jmpq instruction에서 사용되는 *0x86b72(%rip) 이러한 형태는 프로그램에서 동적 Library를 통해 함수를 실행시키는 경우, PLT(Procedure Linkage Table)이라는 Table에 담긴 Entry를 실행시키는 방식입니다.

동적 Library에 있는 함수를 이용하는 경우 프로그램 Build 시에 함수의 주소를 확인할 수 없기 때문에, RIP를 통해 PLT 위치를 확인하여, 그 중에 필요한 함수의 주소를 가지고 있는 Entry를 호출하게 됩니다.

(gdb) x/2i 0xf9bbf6e520
   0xf9bbf6e520:        jmpq   *0x86b72(%rip)        # 0xf9bbff5098
   0xf9bbf6e526:        pushq  $0x2e5 ## %rip

(gdb) x/a 0xf9bbff5098 ## a는 가장 가까운 Symbol의 Offset을 출력
0xf9bbff5098:   0xf9bbf72c90

위 예제의 "jmpq   *0x86b72(%rip)"에서 RIP는 Next Instruction을 가리키기 때문에 0xf9bbf6e526이므로 *0x86b72(%rip)는 0xf9bbf6e526 + 0x86b72 = 0xf9bbff5098 을 의미합니다.

0xf9bbff5098 주소로 이동해보면, 0xf9bbf72c90 값을 가지고 있습니다.

 

8. 0xf9bbf72c90 으로 이동하여, Disassembly를 하면 다음과 같이 확인됩니다.

0xf9bbf72c90 호출 과정을 보면, 중간에 두 번의 callq 함수가 호출되고 나서 0xf9bbf72cc2 주소가 확인됩니다.

0xf9bbf72cc2 이 주소는 최초 Call Stack에서 확인한 Return Address 입니다.

(gdb) x/30i 0xf9bbf72c90
   0xf9bbf72c90:        push   %r12
   0xf9bbf72c92:        push   %rbp
   0xf9bbf72c93:        mov    %ecx,%r12d
   0xf9bbf72c96:        push   %rbx
   0xf9bbf72c97:        mov    %rdi,%rbx
   0xf9bbf72c9a:        mov    %rsi,%rbp
   0xf9bbf72c9d:        sub    $0x10,%rsp
   0xf9bbf72ca1:        mov    %rsp,%rdi
   0xf9bbf72ca4:        mov    %fs:0x28,%rax
   0xf9bbf72cad:        mov    %rax,0x8(%rsp)
   0xf9bbf72cb2:        xor    %eax,%eax
   0xf9bbf72cb4:        callq  0xf9bbf701e0
   0xf9bbf72cb9:        test   %eax,%eax
   0xf9bbf72cbb:        je     0xf9bbf72cc8
   0xf9bbf72cbd:        callq  0xf9bbf6bc20
   0xf9bbf72cc2:        nopw   0x0(%rax,%rax,1) ## Return Address of Frame#9
   0xf9bbf72cc8:        mov    (%rsp),%rdi
   0xf9bbf72ccc:        mov    $0x1,%esi
   0xf9bbf72cd1:        callq  0xf9bbf6e2e0
   0xf9bbf72cd6:        test   %al,%al
   0xf9bbf72cd8:        je     0xf9bbf72cbd
   0xf9bbf72cda:        mov    (%rsp),%rdi
   0xf9bbf72cde:        lea    0x50593(%rip),%rdx        # 0xf9bbfc3278
   0xf9bbf72ce5:        mov    %r12d,%r9d
   0xf9bbf72ce8:        mov    %rbp,%r8
   0xf9bbf72ceb:        mov    %rbx,%rcx
   0xf9bbf72cee:        mov    $0x1,%esi
   0xf9bbf72cf3:        xor    %eax,%eax
   0xf9bbf72cf5:        callq  0xf9bbf6b8d0
   0xf9bbf72cfa:        jmp    0xf9bbf72cbd

 

9. Return Address인 0xf9bbf72cc2 이전에 호출된 callq  0xf9bbf6bc20를 따라가보면, 마찬가지로 PLT Entry 주소로 지정되어 있습니다.

해당 PTL Entry가 가리키는 함수는 __GI_abort로 확인됩니다.

(gdb) x/2i 0xf9bbf6bc20
   0xf9bbf6bc20:        jmpq   *0x87ff2(%rip)        # 0xf9bbff3c18
   0xf9bbf6bc26:        pushq  $0x55

(gdb) x/a 0xf9bbff3c18
   0xf9bbff3c18:   0xf9b95f0d30 <__GI_abort>

즉, callq  0xf9bbf6bc20 instruction이 실행될 때 __GI_abort() 함수가 실행된 것입니다.

지금까지 8번 Frame 부터 Call Stack을 역으로 따라가봤던 내용을 정리하면 다음과 같습니다.

__GI_abort 함수는 "callq 0xf9bbf6bc20" 에 의해서 호출이 된 것을 보다 명확히 알 수 있고, "callq 0xf9bbf6bc20"가 호출되는 과정을 보면, test와 je instruction이 확인됩니다.

 

이는 이전 "callq 0xf9bbf701e0"이 호출되고 나서,

1) 이 함수 호출의 결과 값인 EAX Register를 test instruction으로 0인지 확인한 후, zero flag를 설정하고

2)  je(Jump if Zero) instruction은 zero flag가 설정되어 있다면, je 다음 주소로 Jump를 해야 하는데 본 예제에서는 0xf9bbf72cc8로 Jump하지 않았기 때문에 EAX Register 값이 0이 아니었음을 추정할 수 있습니다.

 

여기까지 64bit 프로그램에서 Stack Walking을 하는 예제를 살펴보았습니다.

 

[참고 자료]

https://www.intel.com/content/dam/www/public/us/en/documents/manuals/64-ia-32-architectures-software-developer-vol-2b-manual.pdf

'Debugging > Linux' 카테고리의 다른 글

CPU Registers and Instructions - Instructions  (0) 2022.11.05
CPU Registers and Instructions - Registers  (0) 2022.11.03
ELF Format #3 - Section Header  (0) 2022.10.09
ELF Format #2 - Program Header  (0) 2022.10.08
ELF Format #1 - ELF Header  (0) 2022.10.03