바이너리 역어셈블의 의미와 원리 파헤쳐보기

소프트웨어 보안 논문을 읽다 보면, 역어셈블(Disassemble)은 비결정(undecidable)문제라는 이야기를 자주 접하게 됩니다. 하지만 그 의미를 정확히 아는 사람은 드뭅니다. 국내외를 막론하고 역어셈블의 의미를 깊이 있게 고찰하는 글을 찾아보기는 매우 어렵기 때문입니다. 이번 시간에는 많은 사람들이 궁금해하는 바이너리 역어셈블의 의미와 원리를 한 번 파헤쳐보겠습니다.

역어셈블의 뜻은 그 대상에 따라 달라진다.

역어셈블(Disassemble)은 말 그대로 어셈블(Assemble)을 거꾸로 하는 것을 의미합니다. 어셈블은 컴파일러가 어셈블리코드를 바이너리코드로 전환하는 과정을 의미하니, 역어셈블은 바이너리코드를 어셈블리코드로 바꾸는 과정이 됩니다.

그런데 컴파일러의 동작 원리를 아는 사람이라면 누구나 어셈블 과정이 “단순 변환”과정이라는 사실을 잘 압니다. 예를 들어 인텔 명령어 집합에서 add rax, rbx 라는 어셈블리코드는 항상 4803c3 이라는 바이너리코드(16진수)로 변환되는데, 여기에는 어떤 복잡한 논리적 전개가 필요한 것이 아닙니다. 단순히 각 어셈블리 명령어마다 대응되는 바이너리코드가 정해져있는 것뿐입니다.

결국, 어셈블리 명령어와 바이너리 명령어는 1:1 대응 관계가 있으며, 이러한 관점에서 본다면 어셈블이나 역어셈블 과정 모두 단순 변환 과정에 불과합니다. 예컨대 4803c3이라는 바이너리 명령어는 add rax, rbx라는 어셈블리 명령어로 단순 변환될 수 있죠. 같은 논리로, “임의의 바이너리 명령어의 리스트를 어셈블리 명령어의 리스트로 변환하는 문제” 또한 단순합니다. 주어진 명령어를 순차적으로 1:1 변환해준다면, 아무 문제 없이 명령어 나열을 “역어셈블”할 수 있기 때문입니다.

아래는 역어셈블된 바이너리코드의 일부를 나타냅니다. 8354번지에 있는 ret 명령어는 함수의 끝을 나타내며, 8360번지에 있는 push 명령어는 새로운 함수의 시작이 됩니다. 하지만 두 함수 사이에는 우리가 패딩(padding)이라 부르는, 11바이트의 쓰레기값이 들어있습니다. 다행히 이 경우에는 11바이트 영역이 모두 no-op 명령어로 해석되기 때문에(xchg ax, ax 또한 2바이트 no-op에 해당함) 코드로 해석한다 해도 큰 문제는 없지만, 해당 영역이 임의의 다른 값으로 채워져 있다면, 데이터 값이 엉뚱한 명령어로 잘못 해석될 수 있을 것입니다.

    834b:       e8 e0 8d ff ff          call   1130 <__cxa_atexit@plt>
    8350:       83 c4 18                add    esp,0x18
    8353:       5b                      pop    ebx
    8354:       c3                      ret
    8355:       66 90                   xchg   ax,ax
    8357:       66 90                   xchg   ax,ax
    8359:       66 90                   xchg   ax,ax
    835b:       66 90                   xchg   ax,ax
    835d:       66 90                   xchg   ax,ax
    835f:       90                      nop
    8360:       53                      push   ebx

물론 패딩 데이터에 해당하는 영역은 일반적으로 no-op으로 해석되는 명령어가 삽입되지만, 바이너리코드에는 패딩 뿐 아니라 다양한 형태의 “데이터”가 존재합니다. 그 대표적인 예가 스위치(switch)문이 점프 테이블 형태로 변환되는 경우인데, 특히 ARM 바이너리의 경우 점프테이블 자체가 명령어와 명령어 사이에 삽입되는 형태를 보이곤 합니다. 이렇게 코드와 데이터가 혼재할 수 있는 근본 원인은 우리가 사용하는 대부분의 CPU가 폰 노이만 구조 [1] 를 따르기 때문입니다. 즉, 근원적으로 코드와 데이터를 구분을 짓지 않기 때문이죠.

이렇듯, 우리가 바이너리라고 부르는 대상은 단순 코드의 나열이 아니기 때문에 역어셈블은 단순 변환과정이 아니게 됩니다. 즉, 대상에 따라 역어셈블은 쉬운 문제가 될수도 있고, 풀기 어려운 문제가 될 수도 있습니다.

역어셈블 기법과 그 의미

역어셈블의 방법은 크게 두 가지로 나뉩니다. 하나는 선형쓸기(Linear Sweep)방식이고, 또 하나는 재귀(Recursive)방식 입니다. 각각의 특징과 장단점에 대해 알아봅시다.

선형쓸기 방식

선형쓸기는 단순 무식한 방법입니다. 위에 말했던 코드와 데이터의 구분을 신경 쓰지 않고, 단순히 바이너리를 명령어의 나열로 간주합니다. 그리고 코드의 시작점부터 일자로(선형으로) 단순 역어셈블 변환을 시행합니다. 우리가 사용하는 도구 중에서는 GNU objdump가 이에 해당하는데, 앞에서 말했듯이 이러한 변환 방식은 코드와 데이터를 구분짓지 못하는 치명적인 단점을 갖고 있습니다.

하지만 놀랍게도 이러한 선형쓸기 방식은 바이너리 명령어를 복원해낸다는 측면에서는 탁월한 성능을 보입니다. 흔히 말하는 명령어 커버리지(coverage)가 높다는 것인데, 그 이유는 데이터를 설사 명령어로 간주한다고 하더라도, 결국에는 해당 데이터에 이어지는 명령어를 다시금 역어셈블해낼 것이기 때문입니다. 이러한 사실은 2016년 USENIX Security에 발표된 논문 [2]에서도 조명된 바 있습니다.

물론 데이터를 역어셈블하다보면 역어셈블된 명령어의 길이가 실제 데이터 값보다 길어지는 경우가 발생합니다. 특히 인텔 명령어처럼 가변길이의 명령어집합을 갖는 경우에는 명령어 하나의 길이가 1바이트에서 길게는 15바이트까지 가능합니다. 따라서, 데이터의 크기가 4바이트였는데, 해당되는 역어셈블된 명령어의 길이는 8바이트였다면, 4바이트 만큼의 명령어를 잃어버릴 수도 있습니다. 아래의 예제를 봅시다.

$ cat test.s
.intel_syntax noprefix
jmp foo
.asciz "hello"
foo:
push rax
push rbx
add rax, rbx
$ as test.s
$ strip a.out
$ objdump -d a.out

a.out:     file format elf64-x86-64


Disassembly of section .text:

0000000000000000 <.text>:
   0:   eb 06                   jmp    0x8
   2:   68 65 6c 6c 6f          push   0x6f6c6c65
   7:   00 50 53                add    BYTE PTR [rax+0x53],dl
   a:   48 01 d8                add    rax,rbx

원본 어셈블리 코드(test.s)는 “hello”라는 데이터를 내재하고 있습니다. 그런데 objdump를 통해 역어셈블된 바이너리 코드에서는 해당 데이터가 push 0x6f6c6c65라는 명령어로 해석되었을 뿐 아니라, 이어지는 원본 명령어인 push raxpush rbx는 기존 코드에 없던 add 명령어로 대체되었습니다. 즉, 데이터의 잘못된 해석으로 인해 원본에 있던 유효한 명령어마저 잃게 된 것입니다.

그런데 여기에서 주목할 것은, 잘못된 해석이 지속되지 않고 바로 그 다음 명령어인 add rax, rbx에서 해소되었다는 점입니다. 만약 우리가 운이 정말 없었다면 다음에 이어지는 명령어, 그리고 그 이후에 이어지는 모든 명령어가 다 영향을 받았을 수도 있습니다만, 실제 선형 역어셈블을 수행해보면 대개 명령어 한두 개 이후로는 원본 어셈블리코드와 동일한 형태의 코드를 복원하게 됩니다. 이러한 현상을 우리는 자가복원현상(Self-repairing Disassembly)이라 부릅니다 [3]. 그리고 이것이 선형쓸기 방식의 커버리지가 높을 수밖에 없는 핵심 이유입니다.

재귀방식

재귀방식의 역어셈블은 선형쓸기의 치명적인 단점인 코드와 데이터의 구분이 어렵다는 점을 해소하기 위해 개발되었습니다. 가장 큰 차이점은 명령어의 제어흐름을 하나하나 따라가면서 역어셈블을 진행한다는 점인데, 예를 들어 순차적으로 역어셈블을 진행하다가 jmp와 같은 브랜치 명령어를 만나면, 브랜치의 대상주소로 가서 역어셈블을 다시(재귀적으로) 진행하는 방식입니다. 이렇게 제어흐름을 고려한 재귀형 역어셈블 방식은 코드만을 따라가기 때문에 데이터를 잘못 해석하는 일이 없지만, 선형쓸기에서는 볼 수 없었던 새로운 문제를 갖게 됩니다.

바로 브랜치의 대상 주소를 모르는 경우가 발생한다는 것입니다. 예를 들어 jmp rax와 같은 명령어는 레지스터 rax의 값이 프로그램 실행 중에 변화할 수 있기 때문에, 여러 대상주소로 점프가 가능합니다. 하지만, 정적으로 역어셈블을 진행하는 중에는 rax의 값이 어떤 값이 올 수 있는지를 알 수 없기 때문에 제어흐름을 모두 따라가기가 어려운 것입니다. 만약 우리가 놓치는 대상주소가 있다면, 그만큼 코드를 복원하지 못하게 되는 것이고, 선형 역어셈블방식에서와는 달리 낮은 커버리지를 달성할 수밖에 없습니다.

따라서 많은 연구자가 재귀방식 역어셈블의 단점을 극복하기 위해 간접분기문의 점프 대상을 복원해내는 연구를 진행하고 있으며, 소프트웨어 보안 연구실[4]에서도 해당 연구를 활발히 진행하고 있습니다. 이에 대해서는 다음 기회에 좀 더 자세히 알아보기로 하겠습니다.

마치며

이번 포스팅에서는 역어셈블이 왜 어려운 문제인지에 대해서 간단히 짚어보았습니다. 또한 역어셈블의 대표기술인 선형쓸기와 재귀형 역어셈블에 대해 알아보고, 각각의 장단점이 무엇인지도 살펴보았습니다. 다음 시간에는 B2R2의 구조와 리프팅된 언어의 의미에 대해서 설명하는 시간을 갖도록 하겠습니다.

[1] 폰 노이만 구조, https://namu.wiki/w/%ED%8F%B0%EB%85%B8%EC%9D%B4%EB%A7%8C%20%EA%B5%AC%EC%A1%B0
[2] An In-Depth Analysis of Disassembly on Full-Scale x86/x64 Binaries, USENIX Security 2016, https://www.usenix.org/conference/usenixsecurity16/technical-sessions/presentation/andriesse
[3] Obfuscation of Executable Code to Improve Resistance to Static Disassembly, CCS 2003, https://dl.acm.org/doi/10.1145/948109.948149
[4] 소프트웨어보안 연구실, KAIST, https://softsec.kaist.ac.kr/

차상길(사이버보안연구센터장)

차상길 교수는 카네기멜론 대학교에서 2015년에 박사학위를 취득하였으며, 2020년 3월부터 사이버보안연구센터장으로 역임중이다. 현재 주 연구 분야는 소프트웨어 보안 및 프로그램 분석이며, 최근에는 차세대 바이너리 플랫폼을 만드는 연구에 매진하고 있다.

12 명이 이 글에 공감합니다.

답글 남기기