ARM 리버스 엔지니어링 기초

유의사항
이 글은 Practical Reverse Engineering 책의 내용을 학습 목적으로 정리하고 일부를 수정하거나 추가한 내용입니다.

개요

ARM 아키텍처의 특징과 인스트럭션을 이해하고, 기초적인 리버스 엔지니어링을 연습해 보겠습니다.

ARM 아키텍처 소개

1980년대 후반 개발된 ARM 아키텍처는 휴대폰, 자동차, 텔레비전 등 다양한 임베디드 장치에서 사용되고 있습니다. ARM 아키텍처는 ARM 홀딩스가 디자인한 후 다른 회사들에 라이센스를 판매하며, 애플, 퀄컴과 같은 파트너사는 라이센스를 구매하여 자신들의 장치에 사용할 프로세서에 적용합니다. 이들 프로세서는 모두 ARM 레퍼런스 매뉴얼에 정의된 기본적인 인스트럭션 집합과 메모리 모델을 구현하고 있습니다.

1.png

ARM 아키텍처의 특징

ARM은 RISC 아키텍처로, CISC 아키텍처인 x86/x64와는 몇 가지 다른 점이 있습니다.

  1. ARM 인스트럭션 집합은 x86/64에 비해 작지만, 범용 레지스터의 수는 더 많습니다.
  2. 인스트럭션의 길이가 고정되어 있습니다.
  3. 메모리 접근에 load-store 모델을 사용합니다.
    • 데이터를 연산하기 전 반드시 메모리에서 레지스터로 옮겨야 하며, 오직 load와 store 인스트럭션만 메모리에 접근할 수 있습니다.

ARM은 여러 가지의 서로 다른 특권 수준(privileged modes)을 제공하는데, 일단은 편의를 위해 User를 x86/64에서의 Ring 3, Supervisor를 Ring 0로 생각해도 좋습니다.

2.jpg

ARM 프로세서는 두 가지 상태(state), ARM과 Thumb으로 동작할 수 있습니다. 이 때 상태는 사용할 인스트럭션 집합과 관련이 있으며, 특권 수준과는 무관합니다. ARM 상태에서 인스트럭션의 길이는 항상 32비트이며, Thumb 상태에서는 16비트 또는 32비트입니다.

프로세서의 상태는 다음과 같이 결정됩니다.

  1. BX 또는 BLX 인스트럭션으로 분기할 때, 목적지 레지스터의 최하위 비트(LSB)가 1이면 Thumb 상태로 전환합니다.
  2. 현재 CPSR 레지스터의 T 비트가 1이면 Thumb 상태입니다.

대부분의 ARM과 Thumb 인스트럭션은 동일한 니모닉(mnemonic)을 갖고 있지만, Thumb 인스트럭션 중 길이가 32비트인 것은 .w 접미사가 붙습니다.

ARM은 또한 조건부 실행(conditional execution)을 지원합니다. 이는 인스트럭션에 특정한 조건이 함께 인코딩되어 있고, 이 조건을 만족하는 경우에만 실행됨을 의미합니다. 조건부 실행을 사용하면 분기문에 필요한 인스트럭션의 개수를 줄일 수 있어 유용합니다. ARM 상태에서 모든 인스트럭션은 조건부 실행이 가능하지만, 조건의 기본값은 ‘항상 실행함(AL)’ 입니다. Thumb 상태에서는 특별한 인스트럭션 IT 를 사용해야만 조건부 실행이 가능합니다.

또 다른 ARM의 독특한 기능은 배럴 시프터(barrel shifter)로, 특정한 인스트럭션은 값을 시프트하거나 회전(rotate)시키는 다른 연산을 포함할 수 있습니다. (e.g. MOV R1, R0, LSL #1R0 레지스터를 왼쪽으로 1비트 시프트한 후 R1 레지스터에 대입합니다) 배럴 시프터는 조건부 실행과 마찬가지로 인스트럭션의 개수를 줄이는 데 도움이 됩니다.

범용 레지스터

ARM 아키텍처는 16개의 32비트 범용 레지스터 R0 , R1 , … , R15 를 제공합니다. 모든 범용 레지스터는 개발자가 자유롭게 사용할 수 있지만, 실제로는 앞의 12개 레지스터만 범용으로 쓰이고 나머지는 특수 레지스터처럼 사용됩니다.

  • R13 은 스택 포인터(SP)를 나타냅니다.
  • R14 는 링크 레지스터(LR)를 나타냅니다.
    • 링크 레지스터(link register)는 함수의 리턴 주소를 보관하는 레지스터로, 일부 인스트럭션에 의해 사용됩니다. (e.g. BL 인스트럭션은 함수를 호출하기 전 항상 LR 에 리턴 주소를 저장합니다)
  • R15 는 프로그램 카운터(PC)를 나타냅니다.
    • ARM 상태에서 PC 는 x86/64와는 다르게, 현재 인스트럭션 주소에 8을 더한 값입니다. (ARM 인스트럭션 2개 뒤의 주소)
    • Thumb 상태에서 PC 는 현재 인스트럭션 주소에 4를 더한 값입니다. (Thumb 인스트럭션 2개 뒤의 주소)
    • PC 레지스터에 주소를 대입할 수 있으며, 대입 즉시 그 주소부터 다음 인스트럭션이 실행됩니다.
    • gdb 디버거에서는 PC 레지스터의 값으로 현재 인스트럭션의 주소를 보여주는데, 이는 편의상 PC 가 alias되어있기 때문에 그런 것으로 실제와는 차이가 있음에 유의합니다.

ARM은 현재 프로세서와 실행 흐름의 상태를 CPSR 레지스터에 보관합니다. (APSR 레지스터라고도 합니다) CPSR 레지스터에는 다음을 포함한 다양한 플래그들이 있습니다.

  • E (엔디언 비트) - ARM은 빅 엔디언 모드와 리틀 엔디언 모드 모두에서 동작할 수 있습니다.
    • 리틀 엔디언은 0, 빅 엔디언은 1이며 대부분의 경우 리틀 엔디언입니다.
  • T (Thumb 비트) - Thumb 상태인 경우 1입니다.
  • M (Mode 필드) - 현재 특권 수준(e.g. User, Supervisor)을 의미합니다.

3.png

보조 프로세서와 시스템 설정

ARM 아키텍처는 확장 인스트럭션과 시스템 설정을 위해 사용되는 보조 프로세서(coprocessors)를 제공합니다. (e.g. x86/64에서는 시스템 설정을 CR0 , CR4 레지스터에, ARM에서는 CP15 레지스터에 보관합니다) 보조 프로세서는 CP0 , CP1 , … , CP15 의 16개가 존재합니다. (코드에서는 P0 , … , P15 로 사용됩니다) CP14CP15 는 디버깅과 시스템 설정을 위해 사용되고, 나머지는 제조사가 특정한 인스트럭션을(e.g. 부동소수점 연산) 구현하기 위해 선택적으로 사용할 수 있습니다.

각각의 보조 프로세서는 16개의 레지스터와 8개의 opcode를 가지고 있으며, opcode의 시맨틱(semantic)은 프로세서마다 다릅니다. 보조 프로세서에 대한 접근은 오직 MRC , MCR 인스트럭션을 사용한 읽기와 쓰기만 가능합니다. (e.g. MRC P15, 0, R0, C2, C0, 0 은 보조 프로세서 CP15C2 / C0 레지스터를 opcode 0 / 0 으로 읽어 범용 레지스터 R0 에 대입합니다) MRCMCR 인스트럭션 자체는 높은 특권 수준을 요구하지 않지만, 일부 보조 프로세서의 레지스터와 opcode들은 오로지 Supervisor 수준에서만 접근이 가능합니다. 이들 레지스터를 User 수준에서 읽으려 하면 익셉션이 발생할 것입니다.

인스트럭션 집합의 특징

조건부 실행이나 배럴 시프터 외에도, ARM 인스트럭션에는 x86에 없는 특징들이 있습니다.

  1. 일부 인스트럭션은 레지스터의 범위를 인자로 받을 수 있습니다.
    • e.g. STM R1, {R6-R10} 은 레지스터 R1 이 가리키는 주소에 R6 , R7 , … , R10 의 5개 값을 순서대로 씁니다.
    • 연속하지 않는 레지스터들도 쉼표를 사용해서(e.g. {R1,R5,R8}) 인자로 전달할 수 있습니다.
  2. 일부 인스트럭션은 읽기, 쓰기 이후 선택적으로 베이스 레지스터의 값을 갱신할 수 있습니다.
    • e.g. STM SP!, {R6-R10} 을 실행하면 SP 의 값은 R10 의 값이 쓰인 주소의 4바이트 뒤로 갱신됩니다.

Load와 Store 인스트럭션

LDRSTR

LDRSTR 인스트럭션은 메모리에서 1바이트, 2바이트 또는 4바이트를 읽고 씁니다. 인스트럭션의 문법은 살짝 복잡한데, 오프셋을 지정하거나 베이스 레지스터를 갱신하는 여러 가지 방법이 존재하기 때문입니다. 가장 단순한 경우는 다음과 같습니다.

1
2
LDR Rt, [Rn]        ; Rt = *Rn
STR Rt, [Rn] ; *Rn = Rt

LDR , STR 인스트럭션은 베이스 레지스터와 오프셋을 인자로 받는데, 오프셋의 형태가 3가지 있고 베이스 레지스터를 갱신하는 방법이 3가지 있습니다. 먼저 3가지의 오프셋 형태를 살펴보겠습니다.

  1. 상수가 오프셋인 경우
    • 상수 값(immediate)은 단순히 정수로, 특정 오프셋의 데이터에 접근하기 위해 베이스 레지스터에 더하거나 빼는 경우입니다. (e.g. 구조체, vtable의 특정 필드 접근)
1
2
LDR Rt, [Rn, imm]   ; Rt = *(Rn + imm)
STR Rt, [Rn, imm] ; *(Rn + imm) = Rt
  1. 레지스터가 오프셋인 경우
    • 보통 배열에 접근하는데, 인덱스가 런타임에 계산되는 경우입니다.
1
2
LDR Rt, [Rn, Rm]    ; Rt = *(Rn + Rm)
STR Rt, [Rn, Rm] ; *(Rn + Rm) = Rt
  1. 레지스터의 정수배가 오프셋인 경우
    • 보통 반복문 안에서 배열을 순회하면서, 원소의 크기 단위로 포인터를 증가시키는 경우입니다.
1
2
LDR Rt, [Rn, Rm, shift]     ; Rt = *(Rn + Rm * shift)
STR Rt, [Rn, Rm, shift] ; *(Rn + Rm * shift) = Rt

다음으로 베이스 레지스터를 갱신하는 3가지 방법입니다.

  1. 오프셋 방식
    • 가장 단순하고 흔한 방식으로, 베이스 레지스터는 절대 갱신되지 않습니다.
    • 느낌표(!)가 없고 상수가 대괄호 안에 있으면 오프셋 방식입니다.
1
LDR Rt, [Rn, offset]        ; Rt = *(Rn + offset)
  1. pre-indexed 방식
    • 베이스 레지스터를 먼저 갱신한 후 참조합니다. (C언어의 전위 연산자와 유사)
1
2
LDR Rt, [Rn, offset]!       ; Rt = *(Rn + offset)
; Rn = Rn + offset
  1. post-indexed 방식
    • 베이스 레지스터를 먼저 참조한 후 갱신합니다. (C언어의 후위 연산자와 유사)
1
2
LDR Rt, [Rn], offset]       ; Rt = *Rn
; Rn = Rn + offset

LDR 과 pseudo 인스트럭션

일부 디스어셈블 결과에서 다음과 같이 LDR 을 사용하는 방식을 볼 수도 있습니다.

1
2
3
LDR.W R8, =0x2932E00        ; LDR R8, [PC, x]
LDR R2, =a04d ; "%04d" ; LDR R2, [PC, y]
LDR R3, =__imp_realloc ; LDR R3, [PC, z]

이 방식은 사실 pseudo 인스트럭션으로, 디스어셈블러들이 편의상 위와 같이 나타내는 것입니다. 실제로는 PC 를 베이스 레지스터로, 상수를 오프셋으로 하는 PC-relative 방식의 LDR 인스트럭션입니다.

다른 pseudo 인스트럭션으로 레이블이나 함수의 주소를 레지스터에 대입하는 ADR 인스트럭션이 있습니다. 보통 점프 테이블이나 콜백 구현에 사용되는데, 마찬가지로 내부적으로는 PC-relative 방식의 LDR 인스트럭션입니다.

1
2
ADR R5, dword_9528
LDRD.W R4, R5, [R5]

LDMSTM

LDMSTM 은 베이스 레지스터가 가리키는 주소에서 여러 개의 값을 한번에 읽고 씁니다.

1
2
LDM<mode> Rn[!], {Rm}
STM<mode> Rn[!], {Rm}

Rn 은 베이스 레지스터로, 값을 읽고 쓸 메모리 주소를 가리킵니다. 느낌표(!)는 선택인데, 느낌표가 있으면 베이스 레지스터를 실행 후 갱신함을(writeback) 의미합니다. Rm 은 레지스터들의 범위이며, mode 는 다음과 같이 4가지가 존재합니다.

  1. IA (increment after) - 베이스 주소 부터 값을 읽고 쓰며, writeback은 마지막으로 읽고 쓴 주소 + 4 입니다.
    • 명시된 mode 가 없는 경우 기본값입니다.
  2. IB (increment before) - 베이스 주소 + 4 부터 값을 읽고 쓰며, writeback은 마지막으로 읽고 쓴 주소 입니다.
  3. DA (decrement after) - 베이스 주소 부터 낮은 방향으로(거꾸로) 값을 읽고 쓰며, writeback은 마지막으로 읽고 쓴 주소 - 4 입니다.
  4. DB (decrement before) - 베이스 주소 - 4 부터 낮은 방향으로(거꾸로) 값을 읽고 쓰며, writeback은 마지막으로 읽고 쓴 주소 입니다.

예를 들어, 다음은 IA 모드와 writeback을 사용하여 여러 값을 읽고 쓰는 예제입니다.

1
2
3
4
5
6
LDR R6, =mem
MOV R0, #10
MOV R1, #11
MOV R2, #12
LDM R6!, {R3,R4,R5}
STMIA R6!, {R0,R1,R2}

4.png

LDMSTM 은 한번에 여러 값을 옮길 수 있어, 보통 블록 단위의 복사 등에 사용됩니다. (e.g. 복사할 길이를 컴파일 시점에 알고 있을 경우, memcpy 대신 사용할 수 있습니다) 또한 ARM 상태에서 함수의 시작과 끝에서도 사용되는데, 함수 프롤로그와 에필로그의 역할을 합니다.

  • STMFDLDMFD 는 각각 STMDB , LDMIA 의 pseudo 인스트럭션입니다.
1
2
STMFD SP!, {R4-R11,LR}      ; 레지스터와 리턴 주소를 스택에 보관합니다.
LDMFD SP!, {R4-R11,PC} ; 레지스터와 리턴 주소를 꺼내고 반환합니다.

PUSHPOP

PUSHPOPLDM , STM 과 비슷하지만, 두 가지 다른 특징이 있습니다.

  • PUSHPOPSP 를 베이스 주소로 사용합니다.
  • 실행 후 SP 가 자동으로 갱신됩니다.

ARM 아키텍처에서도 스택은 x86/64와 마찬가지로 낮은 방향으로 자랍니다. 문법은 다음과 같으며, {Rn} 에는 레지스터들의 범위를 전달해야 합니다.

1
2
PUSH {Rn}
POP {Rn}

예를 들어, 다음은 PUSHPOP 을 이용해 스택에서 값을 읽고 쓰는 예제입니다.

1
2
3
4
5
MOV.W R0, #10
MOV.W R1, #11
MOV.W R2, #12
PUSH {R0,R1,R2}
POP {R3,R4,R5}

5.png

PUSHPOP 은 흔히 Thumb 상태에서 함수의 프롤로그와 에필로그로 사용됩니다.

1
2
PUSH.W {R4-R11,LR}          ; 레지스터와 리턴 주소를 스택에 보관합니다.
POP.W {R4-R11,PC} ; 레지스터와 리턴 주소를 꺼내고 반환합니다.

함수 호출과 분기

ARM 아키텍처는 목적지 주소를 인코딩한 방식에 따라 함수 호출과 분기를 위한 다양한 인스트럭션을 제공하고 있습니다. 함수 호출의 리턴의 원리는 x86/64와 기본적으로 같지만, 몇 가지 사소한 차이점들이 있습니다.

  1. 리턴 주소를 스택이나 링크 레지스터(LR)에 저장할 수 있습니다.
    • 함수 에필로그에서 리턴 시 POP {PC} 와 같이 스랙에서 리턴 주소를 직접 꺼내 PC 에 대입하거나, BX LR 과 같이 링크 레지스터로 분기할 수 있습니다.
  2. 분기할 때 목적지 주소의 최하위 비트(LSB)에 따라 ARM 상태와 Thumb 상태를 오갈 수 있습니다.
  3. 함수 호출 규약의 차이가 있습니다.
    • 4개 매개변수까지 레지스터 R0 , R1 , R2 , R3 을 통해 전달하며, 나머지는 스택을 통해 전달합니다.
    • 리턴 값은 R0 에 보관합니다.

함수 호출과 분기에 사용되는 인스트럭션은 B , BX , BLBLX 가 있습니다.

1
2
B label
BL label

B 는 단순한 분기로, x86/64에서의 JMP 와 동일합니다. 함수 호출에는 거의 사용되지 않지만, 리턴하지 않는 함수를 호출하기 위해 사용될 수 있습니다. 주로 반복문이나 조건문에서 코드 블록의 시작으로 돌아가거나 탈출하기 위해 사용됩니다. BL 은 branch with link로, 분기 전 LR 에 다음 인스트럭션의 주소를 저장합니다. x86/64에서의 CALL 과 비슷한 인스트럭션입니다. BBL 은 모두 레이블의 오프셋만 인자로 받을 수 있습니다.

1
BX Rm

BX 는 branch and exchange로, B 와 비슷하지만 목적지 주소가 레지스터로 전달되고 ARM과 Thumb 상태를 오갈 수 있습니다. (목적지 주소의 최하위 비트가 1이면 Thumb 상태가 됩니다) 흔히 함수 에필로그에서 리턴을 위해 사용되거나, (i.e. BX LR) 다른 상태의 코드로 분기할 때 사용됩니다.

1
2
BLX label
BLX Rm

BLX 는 branch with link and exchange로, BL 과 비슷하지만 ARM과 Thumb 상태를 전환할 수 있으며 인자로 레지스터에 보관된 목적지 주소나 레이블의 오프셋 모두를 전달할 수 있습니다. (BLX 가 레이블의 오프셋을 인자로 받는 경우는 반드시 상태를 전환하기 위함입니다) BLBLX 는 모두 함수 호출에 사용되는데, BL 는 현재 인스트럭션으로부터 32MB 범위 안에 있는 함수 호출에 사용하며 BLX 는 함수의 주소가 정해지지 않은 (e.g. 함수 포인터) 경우 사용합니다. Thumb 상태에서 BLX 는 주로 라이브러리 함수 호출에 사용되며, ARM 상태에서는 BL 을 대신 사용합니다.

다음 예제는 어떤 함수를 디스어셈블한 결과인데, 함수 호출과 분기를 위한 인스트럭션이 어떻게 사용되고 있는지 살펴보겠습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
PUSH.W {R4,R5,R11,LR}
ADDW R11, SP, #8
LDR R3, =__imp_malloc
ADDS R5, R0, #7
BFC.W R5, #0, #3
LDR R3, [R3]
ADDS.W R0, R5, #8
BLX R3
MOV R4, R0
CBZ R4, loc_100C3AE
ASRS R3, R5, #0x1F
STR R3, [R4,#4]
STR R5, [R4]
B loc_100C3B8

loc_100C3AE:
LDR R1, =aFailed ; "failed..."
MOV R2, R5
MOVS R0, #7
BL foo

loc_100C3B8:
MOV R0, R4
POP.W {R4,R5,R11,PC}
  • 1행의 PUSH.W {R4,R5,R11,LR} 은 함수 프롤로그, 24행의 POP.W {R4,R5,R11,PC} 는 함수 에필로그에 해당합니다.
  • 8행에서 BLX 를 이용해 malloc 라이브러리 함수를 호출하고 있습니다.
  • 20행에서 BL 을 이용해 foo 함수를 호출하고 있습니다.

산술 연산

MOV 인스트럭션은 값을 대입하는 가장 단순한 인스트럭션입니다. 대입하는 값은 상수거나 레지스터의 값, 또는 레지스터의 값에 배럴 시프터를 사용한 값입니다. 배럴 시프터로는 값에 대한 왼쪽 시프트(LSL), 오른쪽 시프트(LSR, ASR), 회전(ROR, RRX)이 가능합니다.

1
2
3
MOV R0, #0xa            ; R0 = 0xa
MOV R0, R7 ; R0 = R7
MOV R4, R4, LSR #21 ; R4 = (R4 >> 21)

기초적인 산술 및 논리 연산 인스트럭션으로는 ADD , SUB , MUL , AND , ORR , EOR 이 있습니다. 다음 예제에서 일부 인스트럭션에는 S 접미사가 붙어 있는데, 산술 연산의 결과에 따라 CPSR 레지스터의 플래그(e.g. zero 비트, negative 비트)를 갱신해야 함을 의미합니다.

  • x86/64와는 달리, ARM 산술 인스트럭션은 기본적으로 CPSR 을 갱신하지 않습니다.
1
2
3
4
5
6
7
8
9
10
11
ADD R3, R9              ; R3 = R3 + R9
ADD R11, SP, #8 ; R11 = SP + 8
ADD R0, R4, LSL #2 ; R0 = (R4 << 2)
SUB SP, SP, #0x1a8 ; SP = SP - 0x1a8
MUL R2, R3, R5 ; R2 = R3 * R5 (결과의 하위 32비트만 저장됩니다)
ANDS R2, R4, #7 ; R2 = R4 & 7 (CPSR을 갱신합니다)
EOR R3, R3, R1, LSL #3 ; R3 = R3 ^ (R1 << 3)
EORS R3, R2 ; R3 = R3 ^ R2 (CPSR을 갱신합니다)
ORR R3, R3, R2, LSL #8 ; R3 = R3 | (R2 << 8)
ORRS R3, R3, #2 ; R3 = R3 | 2 (CPSR을 갱신합니다)
ORRS R3, R2 ; R3 = R3 | R2 (CPSR을 갱신합니다)

MUL 인스트럭션은 결과의 하위 32비트만이 목적지 레지스터에 저장되며, 64비트 값 전체가 필요한 경우 SMULL , UMALL 인스트럭션을 사용해야 합니다. 또한 나눗셈 인스트럭션이 존재하지 않는데, (ARMv7-R과 ARMv7-M에 SDIV , UDIV 인스트럭션이 있기는 합니다) 실제로는 나눗셈을 소프트웨어적으로 구현하여 필요한 경우 호출하도록 합니다.

1
2
3
MOV R1, R8
MOV R0, R6
BL __rt_udiv ; 소프트웨어적으로 구현한 나눗셈 함수

조건부 분기와 실행

반복문과 조건문애서 사용되는 조건부 분기는 CPSR 레지스터에서 다음과 같은 플래그들을 사용합니다.

  • N (negative flag) - 결과가 음수인 경우 (최상위 비트가 1인 경우) 1입니다.
  • Z (zero flag) - 결과가 0이면 1입니다.
  • C (carry flag) - 부호가 없는 연산의 결과 오버플로우가 발생하면 1입니다.
  • V (overflow flag) - 부호가 있는 연산의 결과 오버플로우가 발생하면 1입니다.
  • IT (if-then bits) - Thumb 상태의 IT 인스트럭션에서 조건부 분기의 조건들에 해당하는데, 뒤에서 자세히 설명합니다.

인스트럭션은 다음과 같이 조건을 나타내는 접미사 중 하나를 붙여 조건부로 실행할 수 있습니다.

  • e.g. BLT 는 아래 표에서 LT 조건이 참인 경우에만 분기하라는 의미로, x86/64에서의 JL 과 같습니다.
접미사 의미 플래그
EQ Equal Z == 1
NE Not equal Z == 0
MI Minus, negative N == 1
PL Plus, positive or zero N == 0
HI Unsigned higher/above C == 1 and Z == 0
LS Unsigned lower/below C == 0 or Z == 1
GE Signed greater than or equal N == V
LT Signed less than N != V
GT Signed greater than Z == 0 and N == V
LE Signed less than or equal Z == 1 or N != V

비교를 위한 인스트럭션으로 CBZ , CMP , TST , CMN , TEQ 가 있으며, 비교 인스트럭션은 기본값으로 CPSR 을 갱신하지 않는 다른 인스트럭션과 달리 CPSR 의 플래그들을 자동으로 갱신합니다.

가장 흔한 비교 인스트럭션은 CMP 로, Rn 은 레지스터이고 Operand2 는 상수, 레지스터의 값 또는 레지스터의 값에 배럴 시프터를 사용한 값입니다. CMP 는 x86/64에서와 같이 Rn - Operand2 를 연산하고, CPSR 을 갱신한 후 결과를 버립니다.

1
CMP Rn, Operand2

다음 여러 블록이 있는 조건문에서 조건 분기가 사용되는 예제입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
CMP.W R3, R7, ASR #31
BLT loc_less
BGT loc_greater
CMP R5, R7
BLS loc_less

loc_greater:
SUBS R5, #7
SBC.W LR, LR, #0

loc_less:
UMULL.W R1, R2, R5, R8
SMULL.W R0, R4, R7, R8
MLA.W R3, LR, R8, R2
1
2
3
if (R3 < R7) { goto loc_less; }
else if (R3 > R7) { goto loc_greater; }
else if (R5 < R7) { goto loc_less; }

다음으로 흔한 비교 인스트럭션은 TST 로, CMP 와 문법이 같습니다. 마찬가지로 x86/64의 TEST 와 같이 Rn & Operand2 를 연산하고, CPSR 을 갱신한 후 결과를 버립니다. TST 는 주로 어떤 값이 다른 값과 동일한지, 또는 특정 플래그를 검사하기 위해 사용합니다.

1
TST Rn, Operand2

다음은 특정 비트를 검사하여 참인 경우 분기하는 예제입니다.

1
2
3
4
5
6
7
8
LDRH R3, [R5,#0x14]
TST.W R3, #2
BEQ loc_10179DA
; ...
loc_10179BE:
LDRH R2, [R5,#0x14]
TST.W R2, #4
BEQ loc_10179E8

CBZCBNZ 는 Thumb 상태에서 자주 쓰이는 비교 인스트럭션입니다. CBZ 는 레지스터 Rn 의 값이 0이면 label 로 분기하고, CBNZ 는 0이 아니면 분기합니다. 이들 인스트럭션은 주로 정수형 변수의 값이 0인지, 또는 포인터가 NULL 인지 검사하기 위해 사용합니다.

1
2
CBZ Rn, label
CBNZ Rn, label

다음은 함수가 반환한 포인터가 NULL 인지 검사하는 예제입니다.

1
2
3
4
5
6
7
8
9
BL foo              ; 함수 foo는 포인터를 반환합니다.
CBZ R0, loc_100BC8E
; ...
loc_100BCE:
MOVS R0, #1
B locret_100BCE4
; ...
locret_100BCE4:
POP.W {R3-R8,R11,PC}
1
2
a = foo(...);
if (a == NULL) { return 1; }

분기 인스트럭션 B 에 조건 접미사를 붙이면 (e.g. BEQ , BLE , BLT , BLS) 조건 분기를 수행합니다. 대부분의 ARM 인스트럭션에는 조건 접미사를 붙여 조건부 실행이 가능하며, 조건이 참이 아닌 경우 그 인스트럭션은 NOP 와 같이 취급합니다. 이러한 조건부 실행은 분기에 필요한 인스트럭션의 수를 줄이는 데 도움이 됩니다.

다음은 포인터가 NULL 이 아닌 경우 구조체의 특정 필드를 반환하는 예제입니다.

1
2
3
4
CMP R0, #0
MOVEQ R0, #1
LDRNEB R0, [R0,#0x48]
BX LR
1
2
if (a == NULL) { return 1; }
else { return a->off_48; }

Thumb 상태에서의 조건부 실행

Thumb 상태에서는 IT (if-then) 인스트럭션을 사용해야만 조건부 실행이 가능합니다. (B 는 예외입니다)

1
ITxyz cc

IT 인스트럭션은 뒤따르는 최대 4개의 인스트럭션까지 조건부로 실행될 수 있도록 합니다. cc 는 첫째 인스트럭션의 실행 조건이며, x , y , z 는 각각 둘째, 셋째, 넷째 인스트럭션의 조건을 나타냅니다. 이 3개의 조건은 T 또는 E 로만 나타낼 수 있습니다.

  • T - cc 가 참이면 실행합니다.
  • E - cc 가 거짓이면 실행합니다.

다음은 if-else 블록을 IT 인스트럭션으로 작성한 예제입니다.

1
2
3
4
5
CMP R3, #0          ; 비교 인스트럭션으로, CPSR을 갱신합니다.
ITEE NE ; IT 블록을 시작합니다.
CLZNE.W R0, R12 ; 첫째 인스트럭션은 비교의 결과 NE 조건이 참이면 실행됩니다.
CLZEQ.W R0, R6 ; 둘째 인스트럭션은 NE 조건이 거짓이면 실행됩니다.
ADDEQ R0, #0x20 ; 셋째 인스트럭션은 NE 조건이 거짓이면 실행됩니다.
1
2
3
4
5
6
if (R3 != 0) {
R0 = countleadzeros(R12);
} else {
R0 = countleadzeros(R6);
R0 += 0x20;
}

Switch-case 구문

Switch-case 구문은 여러 묶음의 if-else 블록과 같습니다. 컴파일 시점에 각 case 블록의 위치를 알 수 있으므로, 컴파일러는 점프 테이블을 생성하여 switch-case 구문을 처리합니다. ARM 상태에서는 점프 테이블에 각 case 블록의 주소를, Thumb 상태에서는 블록의 오프셋을 저장합니다. 런타임에서는 점프 테이블을 읽고 목적지 주소를 PC 로 불러들이는 간접 분기(indirect branch)를 수행합니다.

다음은 ARM 상태에서 switch-case 구문의 예제입니다. ARM 상태에서 간접 분기는 PC 를 목적지 레지스터로 하는 LDR 인스트럭션을 사용합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
CMP R1, #0xb                ; R1이 case인데, 점프 테이블의 범위 안에 있는지 확인합니다.
LDRLS PC [PC,R1,LSL#2] ; 범위 안에 있으면 점프 테이블을 읽고 PC에 대입하여 분기합니다.
B loc_DD10 ; 범위 안에 없으면 break합니다.
DCD loc_DD3C ; 점프 테이블입니다.
DCD loc_DD4C
; ...
DCD loc_DCEC ; 인덱스 8 (case 8에 해당)
DCD loc_DCEC ; 인덱스 9 (case 9에 해당)
DCD loc_DD3C
DCD loc_DD3C

loc_DCEC: ; case 8, 9에 해당하는 코드 블록입니다.
MOV R0, #0
SUB R1, R1, #8
MOV R3, #4
STR R0, [R2,#0x14]
STRH R3, [R2,#0x1c]
STR R1, [R2,#0x10]

Thumb 상태에서는 점프 테이블에 case 블록의 주소가 아닌 오프셋을 보관합니다. 간접 분기는 특수한 인스트럭션 TBBTBH 를 사용하는데, 점프 테이블의 값에 2를 곱하고 PC 에 더하여 case 블록의 주소를 얻습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
CMP R1, #0xb                ; R1이 case인데, 점프 테이블의 범위 안에 있는지 확인합니다.
BHI loc_101E6F2 ; 범위 안에 없으면 break합니다.
MOVS R6, #4
TBB.W [PC,R1] ; 점프 테이블을 읽고 분기합니다.
DCB 6 ; 점프 테이블입니다.
DCB 0xf
; ...
DCB 0x6d ; 인덱스 8 (case 8에 해당)
DCB 0x6d ; 인덱스 9 (case 9에 해당)
DCB 6
DCB 6
; ...
loc_101E6E4: ; case 8, 9에 해당하는 코드 블록입니다.
SUBS.W R3, R1, #8
MOVS R0, #0
STR R0, [R4,#0x14]

리버스 엔지니어링 연습

지금까지 살펴본 내용을 바탕으로, 예제 함수를 직접 리버스 엔지니어링해보면서 연습해 보겠습니다. 함수를 호출하는 코드는 다음과 같으며, 함수의 코드는 그래프로 나타내었습니다.

1
2
3
4
5
LDR R3, [SP,#0x5c]
LDR R2, [SP,#0x58]
MOV R1, R10
MOV R0, R4
BL unk_function

6.png

다음은 함수의 코드를 보고 빠르게 확인할 수 있는 사실들입니다.

  • 함수는 최대 4개의 인자를 받고, 불리언형을 리턴합니다.
    • 함수 호출 코드에서 R0 , R1 , R2 , R3 에 값을 대입하고, 함수의 리턴 직전 R0 에 대입되는 값은 0 아니면 1이기 때문입니다
  • 첫번째, 두번째 인자는 구조체의 포인터라고 추측할 수 있습니다.
    • 3행, 4행 등에서 R0R1LDR 인스트럭션의 베이스 주소로 사용되며, 상수 오프셋에 접근하고 있기 때문입니다.
  • 세번째, 네번째 인자의 자료형은 정수입니다.
    • 14행, 15행에서 AND , ORR 연산의 인자로 사용되고 있기 때문입니다.

이를 바탕으로 함수의 프로토타입을 추측할 수 있습니다.

1
BOOL unk_function(struct1 *, struct2 *, int, int)

다음으로는 식별된 구조체들의 형태를 살펴보겠습니다.

  • 3~6행에서 struct1[R0, #8]struct2[R1, #0x18] 을 비교하고 있습니다.
    • 두 필드는 동일한 타입이고, 정수형임을 추측할 수 있습니다.
  • 8행에서 struct1[R0, #0x10] 을 읽고 2와 비교하는데, LDRH (load half word) 인스트럭션을 사용하고 있어 short 타입임을 알 수 있습니다.
  • 11~14행에서 struct1[R0, #0x18] . [R0, #0x1c] 을 읽고 각각 세번째, 네번째 인자와 AND 연산을 하고 있어 필드의 타입이 정수형임을 추측할 수 있습니다.
1
2
3
4
5
6
7
8
9
10
struct struct1 {
unk8; // +0x8 ; struct2.unk18과 같은 타입
short unk10; // +0x10
int unk18; // +0x18
int unk1c; // +0x1c
};

struct struct2 {
unk18; // +0x18 ; struct1.unk8과 같은 타입
}

16행까지 분석한 내용을 C 코드로 나타내면 다음과 같습니다.

1
2
3
4
5
6
7
8
BOOL unk_function(struct1 *a1, struct2 *a2, int a3, int a4) {
if (a1.unk8 != s2.unk18
|| a1.unk10 != 2
|| ((a1.unk18 & a3) | (a1.unk1c & a4)) != 0) {
return 0;
}
// ...
}

이후의 코드를 계속 분석해 보겠습니다.

  • 17행은 struct1[R0, #0xc]R3 에 대입하고, 18행은 [R0]R0 에 대입합니다.
  • 19행은 R3 + (R3 << 1)R2 에 대입하는데, 이는 곧 R3 * 3 입니다.
  • 20행은 struct2[R1, #0xc]R3 에 대입하고, 21행은 다시 [R3, #0xc]R3 에 대입합니다.
    • struct2 의 오프셋 0xc 에 위치한 필드는 다른 구조체로의 포인터임을 추측할 수 있습니다.
  • 22행은 R3 + R2 * 8R3 에 대입합니다.
  • 23행은 [R3, #0x16] 의 바이트 값을 LDRSB 인스트럭션을 사용해 R4 에 대입합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
struct struct1 {
int unk0; // +0x0
unk8; // +0x8 ; struct2.unk18과 같은 타입
int unkc; // +0xc
short unk10; // +0x10
int unk18; // +0x18
int unk1c; // +0x1c
};

struct struct2 {
struct3 *unkc; // +0xc
unk18; // +0x18 ; struct1.unk8과 같은 타입
}

struct struct3 {
struct4 *unkc; // +0xc;
}

struct struct4 { // 크기 24바이트
char unk16; // +0x16
}
  • 22행, 23행에서 레지스터 값의 정수배를 오프셋으로 사용하고 있어, 배열에 접근하고 있음을 추측할 수 있습니다.
  • 배열의 베이스 주소는 20행의 [R1, #0xc] 이고, 오프셋으로 R3 * 3 * 8 을 연산함에서 크기가 24바이트인 struct4 구조체들의 배열임을 알 수 있습니다.
    • 오프셋 계산에 사용된 R3 은 인덱스로, 17행에서 [R0, #0xc] 를 대입한 값입니다.
  • 18행, 24행에서 [R0] 을 인자로 foo 함수를 호출합니다. (foo 의 인자는 1개라고 가정합니다)

나머지 코드는 foo 의 리턴값과 23행에서 대입한 R4 에 대한 단순 분기문들로, 분석한 내용을 C 코드에 추가하면 대강의 로직과 구조체 사이의 참조 관계를 파악할 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
BOOL unk_function(struct1 *a1, struct2 *a2, int a3, int a4) {
char v5;
int v6;

if (a1.unk8 != s2.unk18
|| a1.unk10 != 2
|| ((a1.unk18 & a3) | (a1.unk1c & a4)) != 0) {
return 0;
}

v5 = a2->unkc->unkc[a1->unkc].unk16;
v6 = foo(a1->unk0);

if (v6 == 0x61) {
if (v5 != 0x61) {
return 0;
}
} else if (v6 != 0x62 && v5 < 0x63) {
return 0;
}

return 1;
}

결론

ARM 아키텍처는 RISC 아키텍처로 인스트럭션의 길이가 고정되어 있고, 메모리 접근에 load-store 모델을 사용합니다. 특히 조건부 실행이나 배럴 시프터와 같은 독특한 기능은 적은 개수의 인스트럭션으로도 다양한 코드를 표현할 수 있다는 장점이 있습니다. 범용 레지스터나 인스트럭션의 종류 등은 x86/x64 아키텍처와 차이를 보이나, 함수 호출과 분기의 원리, 구조체의 표현 등 근본적인 부분에서는 공통점을 찾을 수 있었습니다.

참고자료

[1] B. Dang, A. Gazet and E. Bachaalany, “ARM,” in Practical Reverse Engineering. Indianapolis, IN: Wiley, 2014, pp. 39-77.