Linux 바이너리 역어셈블로 커널 모듈 패치 확인하기

objdump를 사용한 Linux ELF 바이너리 역어셈블 기법과, 소스 없이 두 커널 모듈(.ko)의 차이를 비교한 실제 사례를 정리합니다.

소스 코드를 구할 수 없는 Linux 바이너리를 분석해야 할 때가 있습니다. 이 글에서는 objdump 기반의 역어셈블 기법을 먼저 정리하고, 활용 예시로 Ubuntu 패키지의 intel_cvs.ko 커널 모듈과 DKMS 패치 버전을 비교한 사례의 핵심을 설명합니다.

1. 역어셈블의 기본 개념

1.1. ELF과 .ko

Linux의 실행 파일과 공유 라이브러리, 커널 모듈은 모두 ELF(Executable and Linkable Format) 포맷입니다. 특히 커널 모듈(.ko)은 relocatable object 파일로, 커널에 로드되는 시점에 주소가 확정됩니다. 따라서 역어셈블에서 call 대상이 `0x0000`으로 보이는 것은 비정상이 아니라 재배치가 아직 적용되지 않았기 때문입니다.

ELF는 섹션 단위로 구성됩니다.

  • .text — 코드

  • .rodata — 읽기 전용 데이터, 문자열 리터럴

  • .data — 전역 변수

  • .symtab — 심볼 테이블

최근 Ubuntu OEM 커널은 모듈을 zstd로 압축하므로 `zstd -d`로 먼저 해제해야 합니다.

1.2. 핵심 도구

도구 용도

objdump

ELF 역어셈블. `-d -M intel`이 가장 많이 쓰는 조합

nm

심볼 테이블 조회. 함수 주소와 이름 확인

readelf

ELF 헤더, 섹션, 재배치 정보 확인

strings

바이너리 안의 문자열 추출. 힌트 얻기 용도

modinfo

커널 모듈 메타데이터

pahole

DWARF 디버그 정보에서 구조체 레이아웃 추출

1.3. x86-64 호출 규약 (System V AMD64 ABI)

역어셈블을 읽기 위해 가장 먼저 알아야 하는 것은 호출 규약입니다. Linux는 System V AMD64 ABI를 따르며, 함수 인자는 정해진 순서의 레지스터로 전달됩니다.

인자 순서 레지스터 비고

1번째

rdi / edi

2번째

rsi / esi

3번째

rdx / edx

4번째

rcx

5번째

r8

6번째

r9

반환값

rax

예를 들어 다음 시퀀스는 foo(0x805, NULL, 0) 호출과 정확히 대응됩니다.

xor    edx, edx            ; 3번째 인자: 0
xor    esi, esi            ; 2번째 인자: NULL
mov    edi, 0x805          ; 1번째 인자: 0x805
call   foo

1.4. 자주 보이는 x86-64 명령어

명령어 의미

mov

값 복사. mov edi, 0x805edi = 0x805

movzx

작은 값을 0 확장하여 복사. 1바이트를 32비트로 읽을 때

lea

주소 계산만 수행 (메모리 접근 없음). 포인터 전달 용도

test reg, reg

같은 레지스터로 AND. 0인지 검사

cmp

뺄셈 후 플래그만 설정

je / jne

조건 점프 (equal / not equal)

xor reg, reg

레지스터를 0으로 초기화 (`mov reg, 0`보다 짧음)

call

함수 호출

1.5. 상수로 호출 지점 찾기

소스에서 #define 값을 알고 있으면 역어셈블에서 해당 상수를 검색해 호출 지점을 좁힐 수 있습니다. 다만 같은 값이 다른 맥락(예: 길이, 인덱스)에서 쓰일 수 있으므로, 그 상수가 어느 레지스터에 들어가는지와 주변 call 대상을 함께 확인해야 합니다.

2. 활용 예시: 커널 모듈 바이너리 비교

2.1. 문제 상황

Ubuntu의 linux-modules-vision-*-oem 패키지에 포함된 `intel_cvs.ko`가 Intel 업스트림의 특정 버그 픽스를 반영하고 있는지 확인해야 했습니다. 패키지 changelog에는 어느 커밋까지 포함되었는지 명시되어 있지 않고, DKMS로 설치한 패치 버전과 어떻게 다른지를 확인해야 했습니다.

2.2. 왜 GitHub 소스 비교 대신 역어셈블을 선택했나

Intel의 업스트림 저장소가 GitHub에 공개되어 있으므로 소스 레벨 `diff`가 더 쉬운 선택처럼 보입니다. 그런데도 역어셈블을 택한 이유는 다음과 같습니다.

  • 어떤 커밋에서 빌드되었는지 모른다. Ubuntu OEM 패키지의 changelog에는 Intel 업스트림의 어떤 시점 스냅샷인지가 명시되어 있지 않았습니다. 임의의 커밋에서 찍은 소스와 비교하면 "차이"가 진짜 패치 누락 때문인지 단순한 버전 갭 때문인지 구분되지 않습니다.

  • Ubuntu가 자체 패치를 얹었을 수 있다. 패키지 빌드는 업스트림 tarball에 distro 패치를 덧붙이는 경우가 흔합니다. 업스트림 소스만으로는 실제로 빌드된 결과를 재현할 수 없습니다.

  • 바이너리가 ground truth다. 커널이 실제로 로드하는 것은 .ko 바이너리입니다. "이 시스템에서 지금 돌고 있는 코드에 해당 가드가 있는가"라는 질문의 답은 바이너리 안에만 있습니다.

  • 확인하려는 범위가 작다. 특정 한 가지 패치(특정 상수 호출 앞의 분기 유무)만 확인하면 충분했습니다. 소스 트리 전체를 받아 빌드 환경을 맞추는 수고보다 objdump 한 번이 더 빠른 상황이었습니다.

즉, 소스 비교는 "업스트림에 그 패치가 존재하는가"에 답하지만, 이번에 필요한 답은 "내 시스템에 설치된 바이너리에 그 패치가 반영되어 있는가"였고, 이 질문에는 바이너리를 직접 보는 것이 가장 확실한 방법이었습니다.

2.3. 모듈 추출과 역어셈블

# 압축 해제
zstd -d -c /lib/modules/$(uname -r)/ubuntu/vision/intel_cvs.ko.zst > /tmp/pkg.ko
zstd -d -c /lib/modules/$(uname -r)/updates/dkms/intel_cvs.ko.zst > /tmp/dkms.ko

# 동일성 확인
md5sum /tmp/pkg.ko /tmp/dkms.ko

# 관심 함수만 Intel 문법으로 역어셈블
objdump -d -M intel --no-show-raw-insn /tmp/pkg.ko \
  | sed -n '/<cvs_common_probe>:/,/^$/p' > /tmp/pkg_probe.asm

objdump -d -M intel --no-show-raw-insn /tmp/dkms.ko \
  | sed -n '/<cvs_common_probe>:/,/^$/p' > /tmp/dkms_probe.asm

-M intel`은 `mov dst, src 순서의 Intel 문법을 사용합니다 (기본은 `mov src, dst`의 AT&T 문법). `--no-show-raw-insn`은 기계어 바이트를 생략해 가독성을 높입니다.

2.4. 핵심 차이 찾기

확인하고 싶은 패치는 아래 형태의 가드였습니다.

if (icvs->magic_num_support) {
    ret = cvs_write_i2c(SET_HOST_IDENTIFIER, NULL, 0);
}

SET_HOST_IDENTIFIER = 0x0805`는 첫 번째 인자이므로 `mov edi, 0x805 패턴으로 역어셈블에서 검색할 수 있습니다.

DKMS 버전 (가드 있음)

movzx  eax, BYTE PTR [r12+0x4c]  ; icvs->magic_num_support 로드
and    ebx, 0x1
test   bl, bl                    ; 0인지 검사
je     12c7                      ; 0이면 호출 건너뜀
xor    edx, edx                  ; len = 0
xor    esi, esi                  ; buf = NULL
mov    edi, 0x805                ; SET_HOST_IDENTIFIER
call   cvs_write_i2c

패키지 버전 (가드 없음)

xor    edx, edx
xor    esi, esi
mov    edi, 0x805
call   cvs_write_i2c             ; 무조건 실행됨

mov edi, 0x805 바로 앞에 test + `je`로 된 분기가 있느냐 없느냐가 두 바이너리의 실질적 차이였습니다. 즉 패키지 버전에는 해당 패치가 반영되어 있지 않다는 결론이 나옵니다.

2.5. 재배치 정보로 call 대상 확인

.ko`의 `call 대상이 0x0000`이거나 어색한 주소로 보일 때는 `-r 옵션으로 재배치 정보를 함께 출력하면 원래 타깃 심볼을 알 수 있습니다.

objdump -d -r -M intel /tmp/pkg.ko
127f: call   1284 <cvs_common_probe+0x104>
      1280: R_X86_64_PLT32  cvs_write_i2c-0x4

3. 주의할 점

컴파일러 최적화로 같은 소스여도 명령어 순서와 레지스터 선택이 달라집니다. 정확히 같은 시퀀스가 아니라 논리적 흐름(분기 구조) 을 비교해야 합니다. DKMS 빌드와 패키지 빌드의 GCC 버전이 같으면 차이가 적고, 다르면 코드 구조 자체가 꽤 달라 보일 수 있습니다.

재배치 주소 차이는 무시해야 합니다. diff`를 돌리면 `je 1553 vs `je 12c7`처럼 점프 대상 주소가 대량으로 다르게 나오는데, 이것은 함수 배치 차이일 뿐이고 주목해야 할 것은 명령어 자체의 추가/삭제입니다.

상수 검색의 false positive: mov edi, 0x805`가 반드시 원하는 호출은 아닐 수 있습니다. 어떤 함수 안에 있는지, 직후의 `call 대상이 무엇인지로 맥락을 확인해야 합니다.

그리고 당연한 이야기지만, 소스를 구할 수 있다면 역어셈블보다 `diff`가 훨씬 빠르고 정확합니다. 역어셈블은 소스를 확보할 수 없을 때의 최후 수단입니다.

4. 참고 자료


이 포스트는 Claude Code가 정상혁의 지시에 따라서 작성했습니다.

Claude Code의 Prompt Cache 버그 (v2.1.69~v2.1.89)