objdump 를 이용한 바이너리 깨보기

기술 2018. 5. 29. 23:02 Posted by 아는 개발자

개발하다보면 보안이나 라이센스의 이유로 코드는 없고 빌드된 바이너리만 가지고 있는 경우가 간혹 있다. 그런데 이 바이너리의 의사코드도(pseudo code)나 사용한 API 문서도 없고 바이너리를 만든 사람으로부터 어떠한 지원도 받을 수 없는 극한의 상황에서 반드시 바이너리에 포함된 API를 이용해서 무언가를 만들어야 한다면 개발자로선 참 난감한 상황일 것이다.


이런 개발자를 위해서(?) 컴파일러에서는 objdump라는 옵션이 있다. 바이너리에서 원래 코드를 볼 수 있는 환상적인 옵션은 아니고, 바이너리에 있는 기계어를 어셈블리 코드로 변환해주는 옵션이다. 어셈블리 코드는 컴퓨터를 처음 배웠을 때 다들 경험 했을 것이다. 프로그래밍 언어와 기계어 사이의 중간 언어이며, 바이너리를 수행하려는 아키텍처(x86, arm)에 따라서 다르고 직관적이지 못해 사용하기 매우 껄끄러워 배우면 금방 머릿속에서 잊혀지는 언어. 컴퓨터 시스템 수업시간에만 잠깐 배울 것 같았던 이 언어를 가지고 바이너리가 어떤 동작을 하는지 대강 짐작 해볼수 있다. 


간단한 예제를 보자. 아래의 코드는 두 인자의 값을 출력해주는 add 함수를 구현하고 이를 main 함수에서 호출한 C 파일이다.

// aarch64-linux-gnu-objdump -o add.out add.c
int add(int a, int b) {
    return a + b;
}

int main() {
    int val1 = 1;
    int val2 = 2;

    int sum = add(val1, val2);

    return 0;
}

주석 처리한 명령어를 사용하니 add.out이라는 바이너리가 나왔다. 그러면 이제 objdump를 이용해서 어셈블리 값들을 출력해보자. 그러면 아래와 같은 출력물이 나올 것이다.

// aarch64-linux-gnu-objdump -D test.out
hello_world_arm.out:     file format elf64-littleaarch64
....
00000000004005c0 :
  4005c0:	d10043ff 	sub	sp, sp, #0x10
  4005c4:	b9000fe0 	str	w0, [sp,#12]
  4005c8:	b9000be1 	str	w1, [sp,#8]
  4005cc:	b9400fe1 	ldr	w1, [sp,#12]
  4005d0:	b9400be0 	ldr	w0, [sp,#8]
  4005d4:	0b000020 	add	w0, w1, w0
  4005d8:	910043ff 	add	sp, sp, #0x10
  4005dc:	d65f03c0 	ret

00000000004005e0 
: 4005e0: a9be7bfd stp x29, x30, [sp,#-32]! 4005e4: 910003fd mov x29, sp 4005e8: 52800020 mov w0, #0x1 // #1 4005ec: b90017a0 str w0, [x29,#20] 4005f0: 52800040 mov w0, #0x2 // #2 4005f4: b9001ba0 str w0, [x29,#24] 4005f8: b9401ba1 ldr w1, [x29,#24] 4005fc: b94017a0 ldr w0, [x29,#20] 400600: 97fffff0 bl 4005c0 400604: b9001fa0 str w0, [x29,#28] 400608: 90000000 adrp x0, 400000 <_init-0x3f0> 40060c: 911b0000 add x0, x0, #0x6c0 400610: b9401fa1 ldr w1, [x29,#28] 400614: 97ffff93 bl 400460 400618: 52800000 mov w0, #0x0 // #0 40061c: a8c27bfd ldp x29, x30, [sp],#32 400620: d65f03c0 ret 400624: 00000000 .inst 0x00000000 ; undefined ...

특별한 옵션을 주지 않고 gcc로 바로 빌드하면 바이너리에 각종 라이브러리가 자동으로 들어가기 때문에 이것들이 변환된 어셈블리 코드도 무수히 출력된다. 예상보다 너무 많아 당혹스럽지만 자세히 보면 어셈블리 코드들이 <함수명 처럼 생긴 문자열>의 그룹으로 이뤄져 있는 것을 볼 수 있다. 이것은 컴파일러가 함수별로 나눠서 변환했기 때문이다. 우리가 실제로 작성한 add와 main 함수만 어셈블리를 추려봤다. 이제 작성한 코드가 어떻게 어셈블리 언어로 변환되는지 살펴보자.


add함수에선 stack에 메모리 공간을 할당하고, 스택에 w0, w1 값을 넣었다가 빼는 무의미한 작업을 한 뒤, w0에 w0, w1의 두 값을 더하는 작업을 하고 스택을 복원하고 리턴한다. 좀 불필요한 작업이 섞여 있지만 의미상으로 보면 인자 둘(w0, w1)을 더한 값을 리턴 값으로 저장한 후(ARM은 리턴값을 X0레지스터에 저장한다) ret 명령어를 실행해 함수를 종료한다. add 함수라고 볼 수 있다.


메인 함수는 add 함수보다 앞의 전처리 부분이 복잡하니 앞부분은 무시하고 중간에 bl 4005c0 명령어부터 보자. bl 뒤의 값은 점프할 주소를 의미하고 여기서 0x 4005c0 값은 add 함수의 위치니 결과적으로 add 함수를 호출하는 작업이라고 볼 수 있다. 그리고 호출하기 직전에 w0, w1 값을 x29주소 부근에서 읽어온다. add 함수를 호출하기 전에 인자의 값들을 세팅하는 작업이다. 이정도만 봐도 main 함수의 어셈블리 코드로 봐도 무관 할 것 같다.


실제 세계에서는 구현한 함수의 라인이 많고 한 함수 내에서 호출하는 함수가 무수히 많아 분석하는데 꽤 오랜 시간이 소요된다. 이럴 때는 가능한 objdump 옵션에서 제공해주는 여러가지 기능을 활용하고 함수의 이름을 통해 기능을 유추해가면서 분석하는 것이 가장 효율적이다. -D 옵션을 사용하면 바이너리에서 볼 수 있는 모든 정보를 모두 출력하니 가장 먼저 훑어보고 objdump로 나온 함수 이름은 최초 코드를 작성 할 때 그대로이니 '개발자가 함수명을 막 짓지 않았을 것'이라는 가정 하에 함수명으로 의미를 유추해서 어셈블리 코드를 분석하자. C파일을 분석할 때보다 시간은 더디지만 그래도 어느정도 분석은 된다.


그런데 몇몇 회사에선 이런 꼼수로부터 바이너리를 보호하기 위해 심볼테이블을 없애버려 함수의 이름조차 안나오게 하는 경우도 있다. 이러면 모든 어셈블리 코드가 함수별로 나눠져 있지 않고 다닥 다닥 붙어있게 되는 그야말로 어셈블리 코드만 있는 출력물이 나온다. 이런 극악의 상황에서는 ret 명령어가 주로 함수의 끝 부분에 나온다는 점을 활용해 직접 함수의 시작 지점이 될 것 같은 곳을 찾고 함수 별로 나눠가며 파악하기도 하는데... 가능하면 포기하는 것을 추천한다. 개인적으로 개발자가 할 짓이 못된다고 생각한다.


* 이 작업을 역공학(Reverse Engineering)이라고 부르기도 한다. 



728x90

'기술' 카테고리의 다른 글

libgdx - Renderer  (0) 2018.06.22
Libgdx - 소개 및 주요함수 정리  (0) 2018.06.20
objdump 를 이용한 바이너리 깨보기  (0) 2018.05.29
그래픽 소프트웨어, 라이브러리 정리  (0) 2018.05.06
Yocto 내부 파일 분석  (1) 2016.10.02
Yocto 작동방식  (0) 2016.10.01