6P by GN⁺ | ★ favorite | 댓글 1개
  • 작은 C hello 프로그램도 Linux에서는 ELF 실행 파일이 되며, readelf, nm, objdump로 내부 구조를 직접 확인할 수 있음
  • 실행 파일을 이해하는 핵심 축은 심볼, 섹션, 세그먼트이며, 각각 함수 연결·코드/데이터 구분·실행 시 메모리 배치를 담당함
  • objdumpreadelf를 쓰면 .text, .rodata, .data, .bss, .interp 같은 섹션의 바이트와 속성을 확인할 수 있음
  • 프로그램은 main에서 바로 시작하지 않고 _start 로 진입한 뒤 여러 초기 작업을 거쳐 main을 호출함
  • 실행 파일은 “읽을 수 없는 덩어리”가 아니라 정해진 형식의 파일이므로, 도구를 쓰면 코드·문자열·링킹 정보를 단계적으로 추적할 수 있음

실행 파일은 읽을 수 있는 파일 형식임

  • 컴파일된 실행 파일은 처음에는 읽을 수 없는 “마법 같은 바이너리”처럼 보이지만, 실제로는 이해 가능한 파일 형식
  • 예시는 Linux의 ELF 바이너리를 대상으로 하며, 바이너리는 플랫폼 의존적이므로 설명도 플랫폼에 묶여 있음
  • 사용한 예제는 다음 C 프로그램임
#include <stdio.h>

int main() {
    printf("Penguin!\n");
}
  • gcc -o hello hello.c로 컴파일해 hello 실행 파일을 만든 뒤 내부를 살펴봄
  • 전체 흐름은 세 가지 개념을 중심으로 이어짐
    • 심볼(symbols): printf처럼 다른 곳에 정의된 함수를 호출할 때 위치를 찾는 데 쓰임
    • 섹션(sections): 코드와 데이터를 나누는 단위이며 .text, .data, .rodata 등이 있음
    • 세그먼트(segments): 섹션들을 실행 시점의 메모리 배치 단위로 묶음

텍스트로 열어도 단서는 보임

  • cat hello처럼 실행 파일을 그대로 열면 대부분 깨진 문자처럼 출력됨
  • 그래도 출력 안에서 Penguin!ELF 같은 문자열을 찾을 수 있음
  • ELF는 이 바이너리의 파일 형식 이름임
  • 출력 대부분을 사람이 읽기 어려운 이유는 실행 파일이 바이너리 데이터이기 때문임

심볼 테이블로 함수 이름과 연결을 확인함

  • readelf --symbols hello는 실행 파일의 심볼 테이블을 출력함
  • 예제 출력에는 주요 심볼이 나타남
    • main: 작성한 main() 함수의 주소
    • puts@@GLIBC_2.2.5: 코드에서 호출한 printf와 관련된 참조로 보이며, 컴파일러가 최적화로 puts로 바꾼 것으로 추정됨
    • _start: 프로그램 시작과 관련된 중요한 심볼
  • 프로그램은 main에서 바로 시작하지 않고 실제로는 _start 로 진입함
  • _start는 여러 중요한 작업을 수행하며, 그중 main 호출도 포함됨

심볼은 연결을 가능하게 함

  • 프로그램에 hello라는 함수를 작성하면, 컴파일된 바이너리에서 해당 함수의 코드에 hello라는 심볼이 붙음
  • 라이브러리 함수인 printf를 호출하려면 그 함수의 코드 위치를 찾을 방법이 필요함
  • 함수 위치를 찾는 과정이 링킹(linking)
    • 컴파일 직후에 일어나면 정적 링킹
    • 프로그램 실행 시점에 일어나면 동적 링킹
  • libc에는 C 표준 라이브러리 함수들이 들어 있음
  • nm이 libc에서 “no symbols”를 출력해도 objdump -tT /lib/x86_64-linux-gnu/libc-2.15.so로 심볼을 볼 수 있음
  • libc의 심볼 테이블에서는 sprintf, strlen, fork, exec 같은 함수들을 확인할 수 있음
  • helloputs를 호출하고 libc의 심볼 테이블에서 puts 위치를 찾는 흐름으로 동적 링킹의 동작을 상상할 수 있음

섹션은 코드와 데이터를 나눔

  • objdump -s hello는 실행 파일의 각 섹션에 들어 있는 바이트를 16진수와 ASCII로 출력함
  • 주요 섹션은 다음과 같음
    • .text: 프로그램의 실제 코드, 즉 어셈블리가 들어 있으며 _startmain이 포함됨
    • .rodata: 읽기 전용 데이터가 들어가며 예제에서는 "Penguin!" 문자열이 있음
    • .interp: 동적 링커 파일 이름이 들어 있음
  • 섹션과 세그먼트는 사용 시점이 다름
    • 섹션은 링크 시점에 ld가 사용함
    • 세그먼트는 실행 시점에 사용됨
  • readelf --sections hello로 섹션의 메타데이터를 더 자세히 볼 수 있음
  • 예제의 플래그를 보면 각 섹션의 성격이 드러남
    • .text: 실행 가능하고 읽기 전용
    • .rodata: 읽기 전용
    • .data: 읽기/쓰기 가능
    • .bss: 쓰기 가능한 데이터 영역

디스어셈블로 기계어를 어셈블리로 봄

  • .text 섹션에는 CPU가 코드로 해석해 실행하는 바이트들이 들어 있음
  • 예제의 .text 시작 바이트 31 ed는 사람이 바로 의미를 알기 어렵기 때문에 디스어셈블러가 필요함
  • objdump -d ./hello.text 섹션을 디스어셈블해 어셈블리 명령어로 보여줌
  • 예제 출력에서 31 edxor %ebp,%ebp로 표시됨
  • 이 방식으로 바이너리 안의 코드 바이트가 어떤 어셈블리 명령어인지 확인할 수 있음

세그먼트는 실행 시 메모리 배치를 정함

  • 실행 파일은 세그먼트 또는 프로그램 헤더(program headers)로도 구성됨
  • readelf --segments hello는 프로그램의 세그먼트와 섹션-세그먼트 매핑을 보여줌
  • 세그먼트는 프로그램의 각 부분을 메모리에 어떻게 나눠 배치할지 결정하는 데 쓰임
  • 예제에는 두 개의 주요 LOAD 세그먼트가 있음
    • 첫 번째 LOAD: R E로 표시되어 읽기/실행 가능함
    • 두 번째 LOAD: RW로 표시되어 읽기/쓰기 가능함
  • .text는 읽고 실행해야 하지만 쓰면 안 되므로 첫 번째 세그먼트에 들어감
  • .data.bss는 쓸 수 있어야 하지만 실행할 필요가 없으므로 두 번째 세그먼트에 들어감

더 살펴볼 도구와 자료

댓글과 토론

Hacker News 의견들
  • 다른 스레드에서도 말했듯이 https://news.ycombinator.com/item?id=38847750#38862450, ELF를 손으로 한 번 작성해보는 걸 강력히 추천함
    실행 파일의 기본 구성 요소를 이해하는 데 좋은 연습이고, 이 글과 반대로 위에서 아래가 아니라 아래에서 위로 접근하고 싶을 때도 도움이 됨
    저 다른 HN 글의 여러 스레드에도 좋은 논의가 많음

    • 최근에 ELF 파일을 직접 작성해봤음: https://github.com/avik-das/garlic/blob/master/recursive/elf...
      형식을 스스로와 다른 사람에게 설명하려고 파일의 바이트를 보여주는 대화형 시각화도 만들었음
      바이트를 클릭하면 설명이 나오고, 파일 안의 관련 바이트가 강조되어 이해에 도움이 됨: https://scratchpad.avikdas.com/elf-explanation/elf-explanati...
    • 비슷하게 간단한 ELF 로더를 작성해보는 것도 추천함
      동적 링크에는 구현 복잡도가 꽤 있지만, 정적 ELF만 지원한다면 꽤 직관적임
    • 기존 ELF를 수정하는 것도 매우 교육적이고 재미있음
      처음에는 동작하지 않을 때 디버깅이 사실상 불가능해서 답답하지만, 마침내 동작하기 시작하면 정말 멋짐
      ELF는 흥미로운 방식으로 패치할 수 있고, 보조 벡터를 이용하면 실행 중 자기 관찰도 가능함
      Linux가 프로그램 헤더 테이블 주소를 주기 때문에 거기서 어디든 도달할 수 있고, LOAD 세그먼트를 바이너리 전체를 덮도록 확장하면 됨
      예를 들어 내 Lisp 인터프리터 실행 파일 안에 Lisp 모듈과 코드를 직접 삽입하는 도구를 만들었음
      삽입된 세그먼트는 ELF에서 자동으로 적재되고, 인터프리터는 그걸 찾아 실행함
      이 작은 기능이 너무 마음에 들어 글도 썼음: https://www.matheusmoreira.com/articles/self-contained-lone-...
      주류 언어들도 이런 방식을 채택하면 좋겠음
    • 직접 해보면 사실상 수작업 어셈블리에 가까움
      문서를 읽고, 프로세서 데이터시트로 필요한 바이트를 고른 뒤, 여러 섹션에 순서대로 배치하고, ELF 필드를 채우면 결국 전부 입력하는 일로 귀결됨
      ELF 이전 시대의 8비트 Apple II 같은 환경에서는 기계어 모니터가 프로그램 바이트를 직접 입력하게 해줬고, 그 바이트가 실행됐음
      디스크에 저장하는 건 조금 더 복잡할 뿐이고, 여기에도 또 기회가 있음
      디스크 섹터 편집기로 파일을 만들 수 있고, 그렇게 계속 이어짐
    • Chris Wellons의 A Magnetized Needle and a Steady Hand는 ELF 실행 파일을 처음부터 만드는 글임: https://nullprogram.com/blog/2016/11/17/
  • 내가 이해하기로 main 심볼은 C에 특화된 것
    _start 심볼은 언어에 독립적인 바이너리 진입점이고, 이 경우 main을 호출함
    진입점을 _start라고 부르고 여기에 mainargc/argv를 넘기는 식의 관례가 있었다면 형식의 유연성이 훨씬 떨어졌을 것임

    • 엄밀히 말하면 _start라는 이름도 특별하지 않음
      바이너리는 헤더에 진입점 주소를 적어두고, 운영체제는 그 주소에서 실행을 시작함
      그 심볼을 _start라고 부르는 건 C와 다른 언어들의 관례일 뿐이고, 링커가 ELF 헤더를 쓸 때 진입점을 설정하는 데 사용함
      직접 링커 스크립트를 쓴다면 진입점 이름은 원하는 대로 붙일 수 있음
    • main 심볼은 맞게도 hosted C에서만 제공됨
      freestanding C에서는 원하는 진입점을 가질 수 있음
      _start도 그저 링커의 기본값일 뿐이고, -Wl,--entry="${symbol}"로 더 나은 심볼을 지정할 수 있으며 GCC는 보기 싫은 -Wl 없이 직접 설정하는 것도 지원함
      또한 진입점은 실제로 심볼이 아니라 포인터
      링커가 지정한 심볼의 주소를 가져와 ELF 진입점으로 설정할 뿐임
      인자 개수와 인자 벡터뿐 아니라 스택에는 환경 벡터와 보조 벡터도 들어 있음
      프로세스 시작 코드는 이 값들을 스택에서 꺼내 적절한 레지스터에 넣고 원하는 C 함수를 호출하는 정도로 단순할 수 있음
      진입점 자체는 함수가 아니어서 돌아갈 곳이 없음
      진입점 코드는 main이 상태 코드를 반환했을 때 프로세스가 깔끔하게 종료되도록 exit 시스템 호출로 끝나야 함
      적어도 Linux에서는 이런 식으로 동작함
    • 언어 런타임에 따라 다르지만, 흔한 작업 중 하나는 0이 아닌 전역 정적 값을 초기화하는 것임
      Rust/C/C++ 같은 언어에서는 링커 플래그를 통해 초기화할 변수를 주입할 수도 있음
      프로그램이 동적 링크되어 있다면 _start 이전에 링커 런타임이 실행되어 링크를 해소한 뒤 제어권을 _start로 넘기는 것으로 알고 있음
      결국 확장성을 제공하려고 유기적으로 덧붙은 해킹 위의 해킹들이고, 사회적으로 충분히 받아들여졌고 충분히 잘 동작해서 계속 쓰고 있는 셈임
  • 2012년에 학문적 경로를 수학에서 컴퓨터 과학으로 옮기면서 블로그를 시작했는데, 이 주제가 말 그대로 처음 공부한 내용이었음: https://heinrichhartmann.com/archive/Dissecting-Hello-World....
    이 깊은 토끼굴로 들어간 걸 후회한 적이 없음
    기억이 맞다면 Julia도 수학 배경이 있음
    어쩌면 수학 쪽 사람들이 이런 실험으로 끌리는 이유는 바닥부터 추론하려는 욕구 때문일지도 모름
    그녀가 이 내용을 많은 사람이 접근하기 쉽게 만들어줘서 반가움

  • Julia의 글은 항상 훌륭함
    컴파일된 코드는 비밀을 숨기지 못한다는 걸 가르칠 때 strings를 시연하면 늘 효과가 좋았음

    • 독일 판사들에게도 그렇게 설명해봐야 함
      어떤 불쌍한 사람이 바이너리에 strings를 실행한 것과 같은 방식으로 비밀번호를 찾았다는 이유로 벌금을 맞았음
      판사들은 그가 소프트웨어의 보안 조치를 “우회”했다고 봤음: https://www.theregister.com/2024/01/19/germany_fine_security...
  • 비판도 아니고 꼬투리도 아니고 그냥 떠오른 생각임
    “바이너리는 플랫폼 특화의 정의에 가깝기 때문에, 이 내용도 전부 플랫폼 특화다”라는 문장을 보니, Actually Portable Executable이 같은 바이너리를 여러 플랫폼에서 실행할 수 있음을 보여줬을 때가 떠오름
    아직도 그 초현실적인 순간에서 정신적으로 완전히 회복하지 못했음
    수십 년 동안 Java, 크로스 플랫폼 라이브러리 등 온갖 프랙털 같은 방식으로 크로스 플랫폼 문제를 풀려고 했는데, 해법이 내내 코앞에 있었던 셈임

    • 개인적으로 이식 가능한 바이너리가 순효과로 좋은지 확신하지 못함
      빠른 컴퓨터의 시대에는 소스 배포와 로컬 컴파일이 바이너리 배포보다 낫다고 봄
      안타깝게도 우리가 의존하는 소프트웨어 상당수는 너무 크고, 컴파일러는 상대적으로 느려서 바이너리 배포가 일종의 필요악이 됨
      이식 가능한 바이너리보다는 자연스럽게 빨리 컴파일되는 더 단순한 소프트웨어 구성 요소와 더 빠른 컴파일러에 노력이 더 들어가면 좋겠음
    • 내가 잘못 이해했을 수도 있지만, APE는 그 자체로 바이너리 형식은 아닌 것 같음
      어느 시스템에서나 실행될 수 있는 스크립트이고, 그 스크립트가 바이너리를 적재할 수 있음
      기억이 맞다면 원래 버전은 적재 전에 base64에서 디코딩해야 했음
      그러니까 실행 가능한 바이너리 로더에 가까움
  • 1990년대 초에 실행 파일 형식에 매료되어, 몇 주 동안 Modula 2로 DOS와 Windows 실행 파일 뷰어를 만들었고 이름을 VEXE라고 붙여 1991년에 셰어웨어로 배포했음
    이 도구는 크래커들 사이에서 틈새 인기를 얻었고, +ORC 튜토리얼에도 언급될 정도였음: https://gist.github.com/callowaysutton/48bdf0245e17e72d41a15...
    아마도 프로그램의 역공학을 막기 위해 쓰인 여러 암호화와 압축 방식을 감지할 수 있었기 때문일 것임

  • ELF 바이너리 파일이 얼마나 작아질 수 있는지 궁금하다면 이 재미있는 글이 마음에 들 수 있음: https://www.muppetlabs.com/~breadbox/software/tiny/teensy.ht...

  • ELF를 SQL로 탐색할 수 있게 해주는 내 도구도 확인해보면 좋겠음: https://github.com/fzakaria/sqlelf

  • Python 배경이 강한 사람에게 실용적인 저수준 프로그래밍 입문 자료나 책을 추천해줄 수 있을까
    최근 Rust를 배우기 시작했는데, 따라잡아야 할 것이 많다는 걸 깨달았음
    컴파일러 과목을 들은 적이 없어서 많은 정보를 놓치고 있는 걸지도 모르겠음
    예를 들어 바이너리에 심볼이라는 게 있다는 것조차 몰랐고, ELF와 Mach-O의 차이도 몰랐음

  • 바이너리를 터미널에 cat하는 건 슬픔으로 가는 지름길임
    나는 | hd를 좋아하는데, 사실상 hexdump -C이고 맨눈으로 보기엔 마찬가지로 난해하긴 함