미스터리한 파일이 궁금하신가요? 리눅스에서 `file` 명령어는 파일의 유형을 빠르게 알려줍니다. 하지만 바이너리 파일이라면 더욱 깊숙이 살펴볼 필요가 있습니다. 파일 내부에는 분석에 유용한 정보들이 숨겨져 있으며, 몇 가지 도구를 사용하면 이를 파헤칠 수 있습니다. 이 글에서는 이러한 도구들의 사용법을 자세히 알아보겠습니다.
파일 형식 식별의 중요성
파일은 일반적으로 소프트웨어 프로그램이 파일의 종류와 데이터 내용을 식별할 수 있도록 고유한 특징을 가지고 있습니다. 예를 들어, MP3 음악 플레이어에서 PNG 이미지 파일을 열려고 하면 오류가 발생합니다. 따라서 파일에 종류를 나타내는 식별자가 있는 것은 매우 중요합니다. 이러한 식별자는 파일의 맨 앞에 있는 서명 바이트일 수 있으며, 이를 통해 파일 형식과 내용을 명확하게 알 수 있습니다. 때로는 파일 형식은 파일 구조, 즉 데이터 자체의 내부 구조를 통해 유추되기도 합니다.
윈도우와 같은 일부 운영 체제는 파일 확장자에 전적으로 의존합니다. 이는 간편하지만, 파일 확장자를 속이거나 변경할 수 있기 때문에 신뢰할 수 없는 방식입니다. 윈도우는 DOCX 확장자를 가진 모든 파일이 워드 프로세싱 파일이라고 가정하지만, 리눅스는 파일 내용을 분석하여 실제 형식을 파악합니다. 이제 이러한 과정을 좀 더 자세히 살펴보겠습니다.
이 글에서 사용되는 도구들은 Manjaro 20, Fedora 21, Ubuntu 20.04 등의 배포판에 기본적으로 설치되어 있습니다. 먼저 `file` 명령어를 사용하여 파일 조사를 시작해 보겠습니다.
`file` 명령어 활용
현재 디렉토리에는 다양한 유형의 파일들이 있습니다. 문서, 소스 코드, 실행 파일, 텍스트 파일 등이 섞여 있습니다. `ls` 명령어를 사용하면 디렉토리 내용을 확인할 수 있으며, `-hl` 옵션을 통해 파일 크기 및 상세 정보를 볼 수 있습니다.
ls -hl

이제 몇 가지 파일을 대상으로 `file` 명령어를 실행하여 결과를 확인해 보겠습니다.
file build_instructions.odt
file build_instructions.pdf
file COBOL_Report_Apr60.djvu

`file` 명령어는 세 파일의 형식을 정확하게 식별했습니다. PDF 파일의 경우, 추가적으로 PDF 버전 정보까지 제공합니다.
만약 ODT 파일의 확장자를 임의의 값으로 변경하더라도, `file` 명령어는 파일 내용을 분석하여 정확한 형식을 식별합니다. 파일 브라우저에서도 마찬가지로 올바른 아이콘을 표시합니다.

file build_instructions.xyz

이미지나 음악 파일과 같은 미디어 파일의 경우, `file` 명령어는 형식, 인코딩, 해상도 등의 자세한 정보를 제공합니다.
file screenshot.png
file screenshot.jpg
file Pachelbel_Canon_In_D.mp3

일반 텍스트 파일의 경우에도 `file` 명령어는 확장자에 의존하지 않습니다. 예를 들어, `.c` 확장자를 가진 파일이라도 실제 내용이 소스 코드가 아닌 일반 텍스트라면, `file` 명령어는 이를 C 소스 코드 파일로 인식하지 않습니다.
file function+headers.h
file makefile
file hello.c

`file` 명령어는 헤더 파일(`.h`)을 C 소스 코드의 일부로 정확히 식별하고, makefile이 스크립트 파일임을 인지합니다.
바이너리 파일 분석
바이너리 파일은 다른 유형의 파일보다 더욱 “블랙박스”와 같습니다. 이미지 파일은 눈으로 확인할 수 있고, 사운드 파일은 재생할 수 있으며, 문서 파일은 적절한 소프트웨어를 통해 열 수 있습니다. 하지만 바이너리 파일은 더 복잡한 분석이 필요합니다.
예를 들어, `hello`와 `wd` 파일은 바이너리 실행 파일입니다. 이 파일들은 프로그램입니다. `wd.o` 파일은 오브젝트 파일입니다. 소스 코드가 컴파일러에 의해 컴파일되면 하나 이상의 오브젝트 파일이 생성됩니다. 오브젝트 파일에는 링커에 필요한 정보와 컴퓨터가 실행할 기계어 코드가 포함되어 있습니다. 링커는 각 오브젝트 파일을 검사하고, 프로그램이 사용하는 모든 라이브러리에 연결하여 실행 파일을 생성합니다.
`watch.exe` 파일은 윈도우 환경에서 실행되도록 크로스 컴파일된 바이너리 실행 파일입니다.
file wd
file wd.o
file hello
file watch.exe

가장 먼저 `watch.exe` 파일을 보면, `file` 명령어는 이 파일이 윈도우 x86 프로세서용 PE32+ 실행 파일임을 알려줍니다. PE(Portable Executable)는 32비트 및 64비트 버전이 있으며, PE32는 32비트 버전, PE32+는 64비트 버전을 의미합니다.
나머지 세 개의 파일은 모두 ELF(Executable and Linkable Format) 파일로 식별됩니다. ELF는 실행 파일 및 라이브러리와 같은 공유 오브젝트 파일의 표준 형식입니다. 이어서 ELF 헤더 구조를 살펴보겠습니다.
흥미로운 점은 두 개의 실행 파일(`wd`와 `hello`)이 LSB(Linux Standard Base) 공유 오브젝트로 식별되고, 오브젝트 파일인 `wd.o`는 LSB 재배치 가능 파일로 식별된다는 것입니다. 실행 파일인데 왜 “실행 가능”이라는 단어가 없을까요?
오브젝트 파일은 재배치 가능합니다. 즉, 메모리의 어느 위치에든 로드할 수 있습니다. 실행 파일은 링커에 의해 오브젝트 파일로부터 생성되며, 이 기능이 상속되어 공유 오브젝트로 식별됩니다.
이러한 방식은 ASMR(주소 공간 레이아웃 무작위화)을 가능하게 하여, 시스템이 임의의 주소에 실행 파일을 로드할 수 있도록 합니다. 표준 실행 파일에는 헤더에 로드 주소가 코딩되어 있어, 메모리에 로드되는 위치가 고정됩니다.
ASMR은 중요한 보안 기술입니다. 예측 가능한 메모리 주소에 실행 파일을 로드하면 공격에 취약해질 수 있습니다. ASMR을 통해 실행 파일이 임의의 주소에 로드되면 공격자가 진입점과 기능의 위치를 예측하기 어려워집니다. 위치 독립 실행 파일(PIE)은 이러한 취약점을 보완합니다.
만약 GCC 컴파일러를 사용하여 `-no-pie` 옵션을 지정하여 프로그램을 컴파일하면, 일반적인 실행 파일이 생성됩니다.
`-o` 옵션을 사용하여 실행 파일 이름을 설정할 수 있습니다.
gcc -o hello -no-pie hello.c
새로운 실행 파일에 대해 `file` 명령어를 실행하여 변경된 내용을 확인해 봅시다.
file hello
실행 파일의 크기는 이전과 동일합니다. (17KB)
ls -hl hello

이제 바이너리 파일이 표준 실행 파일로 식별됩니다. 이는 예시를 위한 것이며, 실제 프로그램을 이렇게 컴파일하면 ASMR의 이점을 잃게 됩니다.
실행 파일 크기가 큰 이유
예제 프로그램 `hello`는 17KB로 그다지 크지 않지만, 모든 것은 상대적입니다. 이 프로그램의 소스 코드는 불과 120바이트입니다.
cat hello.c
터미널 창에 간단한 문자열만 출력하는 프로그램인데, 왜 바이너리 파일의 크기가 이렇게 커지는 걸까요? ELF 헤더의 크기는 64비트 바이너리에서 64바이트에 불과합니다. 분명히 다른 이유가 있을 것입니다.
ls -hl hello

이제 `strings` 명령어를 사용하여 바이너리를 스캔해 봅시다. 이는 바이너리 내부에 어떤 문자열이 있는지 확인하는 간단한 방법입니다.
strings hello | less

바이너리 내부에는 소스 코드에 있는 “Hello, Geek world!” 문자열 외에도 다양한 문자열이 있습니다. 대부분은 바이너리 내의 영역 레이블, 공유 객체의 이름, 연결 정보입니다. 여기에는 바이너리가 의존하는 라이브러리와 해당 라이브러리 내의 함수들이 포함되어 있습니다.
`ldd` 명령어를 사용하면 바이너리의 공유 객체 종속성을 확인할 수 있습니다.
ldd hello

출력 결과에는 세 가지 항목이 있으며, 이 중 두 가지에는 디렉토리 경로가 포함되어 있습니다.
`linux-vdso.so`는 가상 동적 공유 객체(VDSO)입니다. 이는 사용자 공간 바이너리가 커널 공간 루틴에 접근할 수 있도록 하는 커널 메커니즘입니다. 이를 통해 사용자-커널 모드 간의 컨텍스트 전환 오버헤드를 줄일 수 있습니다. VDSO 공유 객체는 ELF 형식을 준수하므로 런타임 시 바이너리에 동적으로 연결될 수 있습니다. VDSO는 동적으로 할당되며, ASMR을 활용합니다. VDSO 기능은 GNU C 라이브러리에서 제공하며, 커널이 ASMR 체계를 지원하는 경우에 사용됩니다.
`libc.so.6`은 GNU C 라이브러리의 공유 객체입니다.
`/lib64/ld-linux-x86-64.so.2`는 바이너리가 사용할 동적 링커입니다. 동적 링커는 바이너리가 어떤 종속성을 가지고 있는지 확인하고, 공유 객체를 메모리에 로드하여 바이너리를 실행할 준비를 합니다. 그런 다음, 프로그램을 실행합니다.
ELF 헤더 분석
이제 `readelf` 유틸리티를 사용하여 ELF 헤더를 검사하고 디코딩해 보겠습니다. `-h` 옵션을 사용하여 파일 헤더 정보를 출력할 수 있습니다.
readelf -h hello

`readelf` 명령어를 실행하면 헤더 정보가 해석되어 출력됩니다.

모든 ELF 바이너리의 첫 번째 바이트는 16진수 값 0x7F로 설정됩니다. 그 다음 세 바이트는 0x45, 0x4C, 0x46으로 설정됩니다. 첫 번째 바이트는 파일을 ELF 바이너리로 식별하는 플래그이고, 다음 세 바이트는 ASCII로 “ELF”를 나타냅니다.
헤더 정보는 다음과 같습니다.
| 클래스: | 바이너리가 32비트 또는 64비트 실행 파일인지 여부를 나타냅니다 (1 = 32비트, 2 = 64비트). |
| 데이터: | 엔디안을 나타냅니다. 엔디안은 멀티바이트 숫자가 저장되는 방식을 정의하며, 빅 엔디안은 최상위 바이트부터, 리틀 엔디안은 최하위 바이트부터 저장합니다. |
| 버전: | ELF 버전입니다(현재는 1). |
| OS/ABI: | 사용된 애플리케이션 바이너리 인터페이스(ABI) 유형을 나타냅니다. ABI는 프로그램과 공유 라이브러리 간의 인터페이스를 정의합니다. |
| ABI 버전: | ABI 버전입니다. |
| 유형: | ELF 바이너리 유형을 나타냅니다. ET_REL (재배치 가능 리소스, 즉 오브젝트 파일), ET_EXEC (-no-pie 플래그로 컴파일된 실행 파일), ET_DYN (ASMR 인식 실행 파일) 등이 있습니다. |
| 기계: | 명령어 세트 아키텍처를 나타냅니다. 바이너리가 생성된 대상 플랫폼입니다. |
| 버전: | 이 ELF 버전에 대해 항상 1로 설정됩니다. |
| 진입점 주소: | 실행이 시작되는 바이너리 내부의 메모리 주소입니다. |
그 외 다른 항목들은 바이너리 내부의 영역과 섹션의 크기 및 개수를 나타내어 위치를 계산할 수 있도록 합니다.
바이너리의 처음 8바이트를 헥스 덤프를 통해 살펴보겠습니다. `-C` 옵션은 ASCII 표현과 함께 16진수 값을 표시하고, `-n` 옵션은 출력할 바이트 수를 지정합니다.
hexdump -C -n 8 hello

출력 결과, 처음 4바이트에 서명 바이트와 “ELF” 문자열이 표시됩니다.
`objdump`로 상세 분석
더 자세한 분석을 위해 `objdump` 명령어를 사용할 수 있습니다. `-d` 옵션은 실행 가능한 기계 코드를 분해하여 어셈블리 언어로 표시합니다.
objdump -d hello | less

각 줄의 첫 번째 바이트의 주소 위치는 맨 왼쪽에 표시됩니다. 이 출력은 어셈블리 언어를 읽을 수 있거나, 바이너리 내부에서 어떤 일이 일어나는지 궁금한 경우에 유용합니다. 출력이 많으므로 `less`를 사용하여 내용을 확인했습니다.

컴파일 및 링크 과정
바이너리를 컴파일하는 방법은 다양합니다. 예를 들어 개발자는 디버깅 정보를 포함할지 여부를 선택할 수 있습니다. 바이너리가 링크되는 방식 또한 내용과 크기에 영향을 미칩니다. 바이너리가 외부 종속성을 공유하면, 종속성이 정적으로 연결되는 것보다 파일 크기가 작아집니다.
대부분의 개발자는 여기서 설명한 명령어들을 이미 알고 있을 것입니다. 하지만 다른 분들에게는 바이너리 블랙박스 내부를 탐험하고, 숨겨진 정보를 확인할 수 있는 유용한 방법이 될 것입니다.