백도어를 넘어서: GNU IFUNC가 구조적 취약점인 이유

CVE-2024-3094, 즉 xz-utils 백도어의 발견은 전 세계 사이버 보안 커뮤니티에 경각심을 일깨워 주었습니다. 사후 분석의 대부분이 악성 코드를 심기 위해 사용된 사회공학 및 공급망 침투에 초점을 맞추었지만, 이는 더 깊은 문제의 증상에 불과합니다. 유사한 공격을 방지하려면 코드 삽입의 누구어떻게를 넘어 무엇—즉 xz-utils와 같은 라이브러리가 고보안 데몬인 OpenSSH를 손상시킬 수 있게 만든 아키텍처적 결정을 살펴봐야 합니다.

이 취약점의 핵심에는 두 가지 중요한 설계 선택이 있습니다: 일부 Linux 배포판이 OpenSSH를 SystemD와 연결하기로 한 결정과 GNU IFUNC(간접 함수)의 존재입니다.

침해의 의존성 체인

xz-utils가 OpenSSH의 주소 공간에 어떻게 들어갔는지 이해하려면 SSH 공급망의 파편화된 특성을 살펴봐야 합니다. OpenSSH는 주로 OpenBSD용으로 개발됩니다. 다른 플랫폼에서도 동작하도록 하기 위해 "Portable OpenSSH" 프로젝트가 일련의 패치를 제공합니다. 그러나 일부 Linux 배포판(특히 Fedora와 Debian)은 sshd 재시작 시 발생하는 경쟁 조건을 해결하기 위해 추가 커스터마이징을 적용했으며, 이 과정에서 libsystemd에 대한 의존성이 생겼습니다.

이로 인해 위험한 신뢰 체인이 형성되었습니다:

  1. OpenSSH (on certain distros) $\rightarrow$ SystemD에 의존합니다.
  2. SystemD $\rightarrow$ xz-utils에 의존합니다.
  3. xz-utils $\rightarrow$ GNU IFUNC를 활용합니다.

이 체인 때문에 xz-utils가 SSH 서버의 메모리 공간에 로드됩니다. 일단 로드되면 공격자는 GNU IFUNC를 이용해 내부에서 SSH 서버의 동작을 변경했습니다.

GNU IFUNC 이해하기

GNU IFUNC는 프로그램이 런타임에 어떤 함수 버전을 사용할지 결정할 수 있게 하는 메커니즘입니다. 이는 일반적으로 CPU 최적화에 사용되며, 예를 들어 프로세서가 AVX2 명령을 지원하는지 확인하고 지원한다면 더 빠른 함수 구현을 선택합니다.

기술적으로 IFUNC는 개발자가 "resolver" 함수를 제공하도록 허용합니다. 링커는 프로그램의 main() 함수가 시작되기 전에 이 resolver 코드를 실행합니다. 그 후 resolver는 사용해야 할 실제 구현에 대한 포인터를 반환합니다.

이 메커니즘은 CPU 기능 감지를 위해 설계되었지만, 본질적으로 동적 링킹 과정에서 임의의 코드 실행을 가능하게 합니다. 소스 자료의 tty_demo.c 예제에서 보듯이, resolver는 이론적으로 프로세스 환경(예: STDOUT이 터미널인지 여부)을 확인하여 어떤 함수를 로드할지 결정할 수 있습니다. xz-utils 백도어의 경우, 이 기능이 악용되어 SSH 서버의 실행 흐름을 가로채고 수정했습니다.

IFUNC가 구조적 위험인 이유

IFUNC는 단순히 틈새 도구가 아니라, Linux 런타임의 여러 핵심 보안 가정을 무너뜨리는 강력한 원시 기능입니다.

1. RELRO 약화

RELRO(재배치 읽기 전용)는 공격자가 Global Offset Table(GOT)을 덮어쓰는 것을 방지하기 위해 설계된 보안 기능입니다. 그러나 IFUNC는 링커가 GOT가 아직 쓰기 가능한 상태에서 resolver 코드를 실행해야 하므로, 해결 단계에서 RELRO를 사실상 무효화합니다. 이는 "예상 최소 원칙"을 위배하는 것으로, 라이브러리를 로드하는 것이 해당 라이브러리를 보호하기 위해 설계된 안전 기능을 손상시켜서는 안 됩니다.

2. 복잡성 및 취약성

IFUNC는 구현과 문서화가 매우 어렵기로 악명 높습니다. GCC 개발자들은 과거에 이를 "실수"라고 표현했으며, 공식 문서는 거의 없습니다. 이렇게 취약하고 혼란스러운 도구는 개발자들이 잘못 사용하거나 채택 시 보안 영향을 간과하기 쉽습니다.

3. 성능 신화

IFUNC의 주요 정당화 이유는 성능입니다. 그러나 실험 결과 IFUNC 호출의 오버헤드가 일반 함수 포인터보다 실제로 더 높다는 것이 밝혀졌습니다. 20억 번 호출되는 루프에서 IFUNC는 호출당 평균 9.986ns, 일반 함수 포인터는 6.791ns를 기록했습니다. 호출 오버헤드가 중요한 함수에서는 특화된 CPU 구현으로 얻는 성능 향상이 IFUNC 메커니즘 자체의 비용보다 보통 낮습니다.

더 안전한 대안

IFUNC와 관련된 위험 없이 런타임 함수 선택을 구현하는 방법에는 여러 가지가 있습니다:

  • Global Function Pointers: 시작 시 포인터를 해결하고 명령형으로 호출합니다. 포인터가 쓰기 가능하지만 초기화 후 mprotect(2)를 사용해 읽기 전용으로 만들 수 있습니다.
  • LD_PRELOAD: 다양한 CPU 아키텍처용 라이브러리 버전을 제공하고 래퍼 스크립트를 통해 올바른 버전을 선택합니다.
  • Separate Binaries: 다양한 기능 세트에 최적화된 여러 바이너리를 제공하고 패키지 관리자의 설치 시 로직을 사용해 올바른 바이너리를 배포합니다.

반론 및 커뮤니티 논쟁

IFUNC가 "진짜 범인"이라는 주장은 논란이 없습니다. 일부 비평가들은 IFUNC에 초점을 맞추는 것이 근본적인 실패, 즉 공격자가 신뢰된 라이브러리에 임의의 코드를 성공적으로 삽입했다는 사실에서 눈을 돌리는 것이라고 주장합니다.

"공격자가 일반 라이브러리에 임의의 코드를 설치하자마자 게임은 이미 패배했습니다... IFUNC, systemd, 그리고 패치된 openssh는 모두 문제와 무관하며, 이는 공격자가 libxz에 확보한 foothold를 이용하기 위해 선택한 경로일 뿐입니다."

다른 사람들은 C++ 전역 생성자와 같은 다른 메커니즘도 main() 이전에 코드를 실행할 수 있다고 지적합니다. 그러나 IFUNC가 링커와 GOT와 상호 작용하는 방식은 루트킷 스타일 백도어에 필요한 정밀한 패치를 수행하는 데 있어 독특하게 강력한 도구가 됩니다.

결론

GNU IFUNC는 공격자에게 높은 권한의 진입점을 제공하는 강력하지만 위험한 기능입니다. 링커가 프로세스 이미지가 완전히 보호되기 전에 임의의 코드를 실행하도록 허용함으로써, 라이브러리를 로드하는 것이 프로그램 로직을 본질적으로 변경해서는 안 된다는 기본 가정을 깨뜨립니다. 성능 이점이 미미하고 더 안전한 대안이 존재하므로, IFUNC는 내부 glibc 인터페이스로 취급하고 일반 애플리케이션 사용을 위해 gcc에서 기본적으로 비활성화해야 합니다.

Sources