- xz/liblzma 백도어는 최종 바이너리 페이로드만의 문제가 아니라, 빌드 중 실행되는 Bash 단계가 여러 겹의 추출·복호화 절차로 숨겨져 있어 분석 난도가 높음
- 영향받은 버전은 xz/liblzma 5.6.0과 5.6.1이며, 난독화된 스크립트와 바이너리 페이로드는 테스트 파일처럼 보이는 두 파일에 들어 있음
- Stage 0은
m4/build-to-host.m4에서 시작해 손상된 것처럼 보이는 xz 스트림을 복구하고,xz -d로 Stage 1 스크립트를 꺼내 실행함 - Stage 1은
good-large_compressed.lzma에서 데이터를 잘라내고 치환한 뒤 Stage 2 Bash 스크립트를 실행하며, 5.6.1에는 Linux 실행 여부를 5번 확인하는 코드가 추가됨 - Stage 2는
.o파일을 빌드·링크 과정에 끼워 넣기 위해 파일 카빙, 치환 암호, AWK 기반 RC4 변형 복호화를 사용하며, 5.6.1에는 향후 추가 스크립트를 실행할 수 있는 확장 구조도 포함됨
xz/liblzma 백도어의 Bash 단계 범위
- Andres Freund가 oss-security 메일링리스트에 xz/liblzma 백도어 발견을 알렸고, 이 백도어는 OpenSSH server에 영향을 줌
- 분석 대상은 바이너리 백도어 자체가 아니라, 그 전에 실행되는 Bash 기반 초기 단계와 난독화 방식임
- Bash 흐름은 Stage 0부터 Stage 2까지 이어지며, 완전히 구현되지 않은 것처럼 보이는 잠재적 Stage 3도 일부 드러남
- 난독화·암호화된 단계와 이후 바이너리 백도어는 두 테스트 파일 안에 숨겨져 있음
tests/files/bad-3-corrupt_lzma2.xztests/files/good-large_compressed.lzma
- 영향받은 버전은 5.6.0과 5.6.1이며, 두 버전 사이에는 작은 차이가 있음
Stage 0: 손상된 테스트 파일처럼 보이는 xz 스트림 복구
- 시작점은
m4/build-to-host.m4의 코드이며, 빌드 과정 어딘가에서 실행되는 것으로 보임 - 핵심 파이프라인은
sed,tr,xz -d를 연결해 Stage 1 스크립트를 추출함 tests/files/bad-3-corrupt_lzma2.xz의 바이트를 읽은 뒤tr "\t \-_" " \t_\-"로 일부 바이트를 치환함0x09탭은0x20공백으로 바뀜0x20공백은0x09탭으로 바뀜0x2d하이픈은0x5f언더스코어로 바뀜0x5f언더스코어는0x2d하이픈으로 바뀜
- 이 치환은
bad-3-corrupt_lzma2.xz를 다시 정상적인 xz 스트림으로 만드는 역할을 함 - 마지막에는 복구된 스트림을
xz로 풀고, 오류를 무시한 채 Stage 1 스크립트를 얻어 바로 실행함- 스트림은 잘린 것처럼 보이지만, 의미 있는 출력은 이미 쓰인 상태라 문제가 되지 않음
Stage 1: good-large_compressed.lzma에서 Stage 2 꺼내기
- Stage 1은 Andres Freund의 메일에서
####Hello####로 시작하는 짧은 Bash 파일임 - 두 버전의 첫 차이는 두 번째 줄 주석 안의 임의 바이트임
- 5.6.0:
86 F9 5A F7 2E 68 6A BC - 5.6.1:
E5 55 89 B7 24 04 D8 17
- 5.6.0:
- 5.6.1에는
[ ! $(uname) = "Linux" ] && exit 0형태의 Linux 검사가 추가됐고, 같은 검사가 5번 반복됨 - Stage 2 추출은 긴
export i=...줄에서 시작되며, 이 값은 이후eval $i로 호출되는 일종의 함수처럼 쓰임 - 추출 흐름은 표준 출력과 표준 입력을 계속 이어 붙이는 파이프라인 구조임
- 먼저
xz -dc로good-large_compressed.lzma를 풂 eval $i는head호출을 반복해 1024바이트는 버리고 2048바이트는 출력하는 패턴을 반복함- 파일 끝에서는 5.6.0은 724바이트, 5.6.1은 939바이트를 출력함
- 먼저
- 이
head체인은 비어 있는 저엔트로피 영역을 제거하고 실제 데이터가 들어 있는 영역만 이어 붙임 - 이후
tail -c +31233로 앞부분을 버림- 이 앞부분에는 다음 단계에서 추출되는 바이너리 백도어가 숨겨져 있음
- 5.6.0에서는 처음 31264바이트, 5.6.1에서는 31232바이트가 버려지는 효과가 있음
- 다음으로
tr이 단순 치환 암호처럼 사용되며, 5.6.0과 5.6.1의 매핑 키가 다름- 5.6.0:
tr "\5-\51\204-\377\52-\115\132-\203\0-\4\116-\131" "\0-\377" - 5.6.1:
tr "\114-\321\322-\377\35-\47\14-\34\0-\13\50-\113" "\0-\377"
- 5.6.0:
- 치환된 데이터는
xz -F raw --lzma1 -dc로 다시 풀리고, 결과로 나온 Stage 2가 즉시 실행됨
Stage 2: 빌드 과정 수정과 확장 메커니즘
- Stage 2는 Andres Freund가 원래 메일에 첨부한
infected.txt파일이며, 실제 컴파일 과정 수정이 일어나는 Bash 스크립트임 - 난독화 관점에서 Stage 2에는 세 가지 흥미로운 조각이 있고, 그중 두 개는 5.6.1에만 나타남
- 5.6.1의 두 조각은 서로 거의 동일하며,
tests/files/에서 특정 시그니처를 가진 파일을 찾음- Fragment 1은
~!:_ W와|_!{ -를 찾음 - Fragment 3은
jV!.^%와%.R.1Z를 찾음
- Fragment 1은
- 시그니처가 발견되면 파일명과 오프셋을 추출하고, 첫 시그니처 이후부터 두 번째 시그니처 전까지의 바이트 범위를 잘라냄
- 잘라낸 데이터는 5.6.0에서 사용된
tr키로 치환 복호화된 뒤xz -F raw --lzma2 -dc로 풀리고eval로 실행됨 - 조사된 5.6.0과 5.6.1 TAR 아카이브에는 해당 시그니처를 가진 파일이 없었음
- 이 구조는 기존 페이로드 운반 테스트 파일을 다시 수정하지 않고, 새 테스트 파일만 추가해 향후 스크립트를 실행할 수 있게 하는 확장·패치 시스템처럼 보임
Stage 2: 바이너리 백도어 .o 파일 추출
- Stage 2의 또 다른 핵심은
.o파일을 추출해 컴파일·링크 과정에 끼워 넣는 코드임 - 추출 대상 결과물은
liblzma_la-crc64-fast.o이며, 이것이 최종 바이너리 백도어임 - 추출 과정은 앞 단계처럼 여러 표준 도구를 파이프라인으로 연결함
good-large_compressed.lzma를xz -dc로 풂- Stage 1에서 사용한
eval $i의head체인을 다시 사용함 sed "s/\(.\)/\1\n/g"로 각 바이트 뒤에 개행을 붙여 바이트당 한 줄 형태로 만듦
- 다음 AWK 스크립트는 입력 스트림에 대해 RC4 비슷한 복호화를 수행함
- 키 배열은
((i * 7) + 5) % 256으로 생성됨 - 5.6.0은 출력 PRNG 스트림의 처음 4096바이트를 건너뜀
- 5.6.1은 8192바이트를 건너뜀
- 원래 RC4는 XOR를 사용하지만, 이 코드는
(v + k) % 256형태의 덧셈을 사용함
- 키 배열은
- 복호화된 데이터는
xz -dc --single-stream으로 풀림 - 이후
head로 필요한 바이트 범위만 잘라내고liblzma_la-crc64-fast.o로 저장함- 압축됐지만 일부 변형된 페이로드 크기는 5.6.0에서 88792바이트, 5.6.1에서 88664바이트임
- 최종 추출 범위는 약 86KB 규모임
표준 도구만으로 만든 난독화의 성격
- 전체 Bash 단계는 표준 명령줄 도구만으로 구성됨
sedtrxzheadtailawkgrepcutexpr
- 페이로드는 정상 테스트 파일처럼 보이는 바이너리 파일에 숨겨져 있고, 필요한 영역만 파일 카빙으로 꺼내는 방식임
- 난독화에는 단순 치환 암호와 AWK 기반 RC4 변형 복호화가 함께 사용됨
- 실행은 총 3단계로 이어지며, 5.6.1에는 나중에 별도 테스트 파일을 추가해 스크립트를 실행할 수 있는 구조가 들어 있음
- 전체 구조는 무해해 보이고 잘 숨겨지도록 많은 노력이 들어간 형태이며, 우연히 발견된 사례라면 아직 발견되지 않은 유사 사례가 얼마나 남아 있는지 의문을 남김