처리중입니다. 잠시만 기다려주세요.
TTJ 코딩클래스
정규반 단과 자료실 테크 뉴스 코딩 퀴즈
테크 뉴스
Hacker News 2026.08.30 37
#AI

7바이트짜리 실행 파일 만들기: ELF와 리눅스의 밑바닥을 들여다보다

Hacker News 원문 보기

소프트웨어가 점점 무거워지는 시대에, 정반대 방향으로 극단까지 밀어붙인 실험이 있다. 머펫랩스(Muppet Labs)의 이 오래된 문서는 '값 하나를 운영체제에 돌려주고 종료하는' 아주 단순한 프로그램 하나를 붙잡고, 그 실행 파일에서 불필요한 바이트를 한 조각씩 깎아내는 과정을 보여준다. 목표는 실용적인 최적화가 아니라, 리눅스 실행 파일 포맷인 ELF와 운영체제가 프로그램을 어떻게 적재하고 실행하는지를 바닥까지 이해하는 데 있다. 예제는 인텔 x86 아키텍처의 리눅스, 그리고 Nasm 어셈블러를 전제로 한다.

C에서 어셈블리로, 그리고 시스템 콜로

출발점은 값 42를 반환하는 한 줄짜리 C 프로그램이다. 유닉스에 이미 존재하는 true와 false가 각각 0과 1을 반환하니, 저자는 남는 숫자로 42를 골랐다. 이 프로그램을 컴파일하면 요즘 기준으로도 작지만 필요 이상으로 크다. 첫 단계는 심볼 정보를 떼어내는 strip이고, 다음은 컴파일 최적화다. 그러나 최적화할 코드 자체가 거의 없으니 효과는 미미하다. 여기서 저자는 C를 버리고 어셈블리로 넘어간다. eax 레지스터에 42를 넣고 반환하도록 바꿔도 겨우 12바이트만 줄어든다. C가 자동으로 붙인다는 '오버헤드'의 정체가 사실은 main() 인터페이스 바깥에 있다는 뜻이다.

핵심은 링커가 기본 진입점으로 삼는 _start 심볼이다. gcc는 여기에 argc와 argv를 준비하고 main()을 호출하는 코드를 자동으로 끼워 넣는다. -nostartfiles 옵션으로 이 시작 코드를 걷어낼 수 있지만, 그 순간 _start를 함수처럼 다뤄 return하면 프로그램이 깨진다. _start는 함수가 아니라 진입점을 가리키는 심볼일 뿐이고, 스택 맨 위에는 복귀 주소가 아니라 argc 값이 놓여 있기 때문이다. 프로그램을 정상적으로 끝내려면 라이브러리의 정리 작업을 건너뛰는 _exit()를 직접 호출해야 한다. 이 방식으로 파일 크기는 거의 절반으로 줄어든다.

int 0x80, 라이브러리를 완전히 버리다

그러나 _exit() 역시 결국 라이브러리 함수라 어딘가에서 채워 넣어야 한다. 이식성을 완전히 포기할 각오가 서면, 라이브러리 없이도 프로그램을 끝낼 수 있다. 리눅스는 파일 열기·읽기·쓰기, 그리고 프로세스 종료 같은 기본 기능을 시스템 콜로 제공하며, 그 관문은 int 0x80 인터럽트 하나다. eax에 시스템 콜 번호를 넣고, 인자는 ebx, ecx, edx, esi, edi 순서로 채운다. exit 시스템 콜은 번호 1이고 인자 하나를 ebx로 받는다. 이 방식으로 외부 함수의 도움 없이 동작하는 판본을 만들면 이전 대비 약 4분의 1 크기로 줄어든다.

여기서 더 나아가 바이트 단위의 짜내기가 이어진다. 운영체제가 반환값의 최하위 바이트만 쓰므로 ebx 전체 대신 bl만 세팅하면 5바이트가 2바이트로 줄고, eax는 xor로 0을 만든 뒤 1바이트짜리 증가 명령으로 1을 만들어 또 아낀다. gcc의 부가 기능을 전혀 쓰지 않으니 링킹도 ld로 직접 하면 몇 바이트를 더 덜어낸다. 다만 ELF 파일 내부의 정렬(alignment) 요구 때문에 줄인 만큼 그대로 반영되지 않고 패딩 한 바이트가 되돌아오는 대목은, 파일 포맷의 물리적 제약을 실감하게 한다. 이렇게 코드 자체는 단 7바이트가 된다.

진짜 낭비는 포맷 안에 있다

코드가 7바이트인데 파일은 여전히 수백 바이트다. objdump로 들여다보면 우리가 지정한 .text 섹션 외에 정체불명의 .comment 섹션이 붙어 있는데, 이는 Nasm이 남긴 흔적이다. gas로 바꾸면 comment는 사라지지만 이번엔 쓰지도 않는 빈 섹션 두 개가 생긴다. 크기가 0이어도 섹션은 관리 정보를 요구하며 파일을 부풀린다. 결국 진짜 낭비는 코드가 아니라 ELF 포맷의 구조적 오버헤드에 있다는 결론에 이른다.

ELF 파일은 52바이트의 ELF 헤더로 시작한다. 여기엔 매직 넘버(7F 45 4C 46), 32/64비트와 엔디언 표시, 대상 아키텍처, 실행 파일·오브젝트·공유 라이브러리 구분, 시작 주소, 그리고 프로그램 헤더 테이블과 섹션 헤더 테이블의 위치가 담긴다. 두 테이블은 역할이 다르다. 섹션 헤더 테이블은 컴파일러와 링커가 파일의 각 부분이 어디 있는지 파악하는 용도이고, 프로그램 헤더 테이블은 로더가 그 부분들을 메모리 어디에 어떻게 올릴지 기술한다. 오브젝트 파일에는 프로그램 헤더 테이블이 사실상 없고, 실행 파일에서 섹션 헤더 테이블은 선택 사항인데도 거의 항상 붙어 있다. 즉 실행에는 필요 없는 섹션 헤더 테이블과 빈 섹션들이 오버헤드의 큰 몫을 차지한다.

문제는 표준 도구 어느 것도 섹션 헤더 테이블 없는 실행 파일을 만들어 주지 않는다는 점이다. 그런 파일을 원한다면 직접 만들어야 한다. 다행히 바이너리 편집기로 16진수를 손으로 찍을 필요까지는 없고, Nasm의 플랫 바이너리 출력 형식을 쓰면 된다. ELF 명세와 커널 헤더(elf.h), 그리고 표준 도구가 만든 실행 파일을 참고해 '텅 빈 ELF 실행 파일'의 뼈대를 짜고, 그 안에 우리 코드만 채워 넣는 방식이다.

실무자에게 남는 것

7바이트짜리 프로그램을 만드는 일 자체는 실무와 거리가 멀다. 이 실험의 값어치는 결과가 아니라 경로에 있다. C 런타임의 시작 코드, libc의 종료 처리, 시스템 콜 규약, ELF 헤더와 두 테이블의 분업, 그리고 정렬이 파일 크기에 미치는 영향까지, 평소 툴체인이 알아서 감춰 주던 계층들이 바이트를 깎는 과정에서 하나씩 드러난다. 링커가 왜 _start를 필요로 하는지, 스택 맨 위의 argc가 무엇을 의미하는지, strip이 실제로 무엇을 지우는지를 손으로 확인하게 된다.

동시에 한계도 분명하다. 여기서 얻는 기법은 x86 리눅스와 int 0x80 인터페이스에 강하게 묶여 있어 이식성을 완전히 버린 결과물이며, 실전 배포나 유지보수와는 무관하다. 오늘날의 x86-64 환경에서는 시스템 콜 방식과 규약이 달라 그대로 적용되지도 않는다. 그럼에도 바이너리 크기 분석, 임베디드나 부트 코드처럼 극도로 제약된 환경, 혹은 리버스 엔지니어링을 다루는 실무자라면, 실행 파일이 '코드'가 아니라 '포맷이 정한 구조 위에 얹힌 코드'라는 감각을 얻는 데 이만한 교재가 드물다.

이 뉴스가 유용했나요?

이 기술을 직접 배워보세요

AI 도구, 직접 활용해보세요

AI 시대, 코딩으로 수익을 만드는 방법을 배울 수 있습니다.

AI 활용 강의 보기

"비전공 직장인인데 반년 만에 수익 파이프라인을 여러 개 만들었습니다"

실제 수강생 후기
  • 비전공자도 6개월이면 첫 수익
  • 20년 경력 개발자 직강
  • 자동화 프로그램 + 소스코드 제공

매일 AI·개발 뉴스를 받아보세요

주요 테크 뉴스를 매일 아침 이메일로 전해드립니다.

스팸 없이, 언제든 구독 취소 가능합니다.