1P by GN⁺ | ★ favorite | 댓글 1개
  • xz/liblzma 백도어는 최종 바이너리 페이로드만의 문제가 아니라, 빌드 중 실행되는 Bash 단계가 여러 겹의 추출·복호화 절차로 숨겨져 있어 분석 난도가 높음
  • 영향받은 버전은 xz/liblzma 5.6.0과 5.6.1이며, 난독화된 스크립트와 바이너리 페이로드는 테스트 파일처럼 보이는 두 파일에 들어 있음
  • Stage 0은 m4/build-to-host.m4에서 시작해 손상된 것처럼 보이는 xz 스트림을 복구하고, xz -dStage 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.xz
    • tests/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.1에는 [ ! $(uname) = "Linux" ] && exit 0 형태의 Linux 검사가 추가됐고, 같은 검사가 5번 반복됨
  • Stage 2 추출은 긴 export i=... 줄에서 시작되며, 이 값은 이후 eval $i로 호출되는 일종의 함수처럼 쓰임
  • 추출 흐름은 표준 출력과 표준 입력을 계속 이어 붙이는 파이프라인 구조임
    • 먼저 xz -dcgood-large_compressed.lzma를 풂
    • eval $ihead 호출을 반복해 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"
  • 치환된 데이터는 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를 찾음
  • 시그니처가 발견되면 파일명과 오프셋을 추출하고, 첫 시그니처 이후부터 두 번째 시그니처 전까지의 바이트 범위를 잘라냄
  • 잘라낸 데이터는 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.lzmaxz -dc로 풂
    • Stage 1에서 사용한 eval $ihead 체인을 다시 사용함
    • 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 단계는 표준 명령줄 도구만으로 구성됨
    • sed
    • tr
    • xz
    • head
    • tail
    • awk
    • grep
    • cut
    • expr
  • 페이로드는 정상 테스트 파일처럼 보이는 바이너리 파일에 숨겨져 있고, 필요한 영역만 파일 카빙으로 꺼내는 방식임
  • 난독화에는 단순 치환 암호와 AWK 기반 RC4 변형 복호화가 함께 사용됨
  • 실행은 총 3단계로 이어지며, 5.6.1에는 나중에 별도 테스트 파일을 추가해 스크립트를 실행할 수 있는 구조가 들어 있음
  • 전체 구조는 무해해 보이고 잘 숨겨지도록 많은 노력이 들어간 형태이며, 우연히 발견된 사례라면 아직 발견되지 않은 유사 사례가 얼마나 남아 있는지 의문을 남김

댓글과 토론

Hacker News 의견들
  • 단순화된 설명과 노이즈 이미지 비교 덕분에, 사람들이 말하는 정교함이 어떤 의미인지 감이 잡힘
    reddit에서 “샌드박싱” 방식이 점 하나로 망가졌다는 내용도 봤고, #include 바로 다음 줄의 맨 왼쪽에 점이 보임
    https://git.tukaani.org/?p=xz.git;a=commitdiff;h=328c52da8a2...
    https://old.reddit.com/r/linux/comments/1brhlur/xz_utils_bac...

    • 정말 악랄함. diff에는 그냥 +, +., +처럼 보이고, 그 점 하나가 눈에 안 띔
    • 이런 컴파일/빌드 시점 조건문 쓰는 걸 정말 싫어함. 켜져야 할 때 켜지고 꺼져야 할 때 꺼지는지 테스트하기 어렵고, 특히 단위 테스트 프레임워크가 없는 빌드 시스템 안에 있으면 더 힘듦
      단순 실수로 테스트가 항상 실패하거나 항상 성공하는 정도만 해도 골치 아픈데, 악의적 행동의 표적이 되기 좋은 이유가 보임
    • 이건 고의가 아니라 단순 실수일 가능성이 매우 큼
      a) 이 패키지를 cmake로 빌드하는 사람은 사실상 아무도 없음
      b) cmake와 -DENABLE_SANDBOX=landlock로 빌드해 보면 그냥 빌드가 실패함: https://i.imgur.com/7xbeWFx.png
      그 점은 샌드박싱을 비활성화하지 않고, cmake로 빌드할 수 없게 만들 뿐임. 실제로 누군가 cmake로 빌드해 봤다면 오류를 보고 뭔가 잘못됐다는 걸 알아챘을 것이라, 보안을 낮추려는 악의적 시도라고 보기엔 말이 안 됨
  • “이게 우연히 발견된 거라면, 아직 발견되지 않은 게 얼마나 남아 있을까”가 가장 중요한 질문임
    Andres Freund가 백도어가 심어진 인기 오픈소스 프로젝트 중 유일한 하나를 우연히 찾아냈을 리는 없음. 이미 야생에 이런 게 한 다스쯤 있어도 이상하지 않음

    • 우연히 발견한 게 아니라, 느꼈다는 쪽에 가까움. 둘은 다름
      다음 공격자는 실행 시간 증가 같은 흔적을 남기는 데 훨씬 덜 부주의할 것임
    • 그럴 수도 있지만, 실제로는 치명적인 사례가 많지 않을 수도 있음. 많았다면 이런 상황을 더 자주 마주쳤을 것 같음
  • 여기서 불편한 생각은 단위 테스트가 공격 경로를 열어줬다는 것임. 테스트가 없었다면 이렇게 숨기기가 훨씬 어려웠을 것임

    • 공격자는 초기 페이로드의 흔적까지 README의 무해한 문단으로 덮어놨음. “볼 것 없음!” 같은 식임
      bad-3-corrupt_lzma2.xz에는 세 개의 스트림이 있고, 첫 번째와 세 번째는 유효한 xz 스트림임. 가운데 스트림은 스트림 헤더, 블록 헤더, 인덱스, 스트림 푸터가 올바르며 LZMA2 데이터만 손상됐고, --single-stream을 쓰면 압축 해제되어야 한다고 적혀 있음
      ####Hello########World#### 문자열은 README 지시를 실제로 따라 했을 때 그럴듯한 정상 결과가 나오게 해줌
      $ cat tests/files/bad-3-corrupt_lzma2.xz | xz -d --single-stream
      ####Hello####
      이 문자열들은 셸 주석이라 페이로드 실행을 방해하지 않음
      마지막으로, 나중에 정규식이 파일명을 직접 참조하거나 실제 Hello/World 문자열을 쓰지 않고도 파일을 찾을 수 있는 표식 역할을 함
      $ gl_am_configmake=\grep -aErls "#{4}[[:alnum:]]{5}#{4}$" $srcdir/ 2>/dev/null``
      $ echo $gl_am_configmake
      ./tests/files/bad-3-corrupt_lzma2.xz
    • 보안에 중요한 프로젝트라면, 바이너리 파일이 빌드에 포함될 때 오류나 최소한 경고가 나도록 빌드 인프라를 구성하는 게 맞아 보임
      이 검사는 전이적으로 적용되어야 해서, 리눅스 배포판이 새 liblzma 버전으로 업데이트하려 할 때도 새 바이너리 의존성 때문에 빌드가 실패하거나 경고가 나야 함
      리눅스 배포판 빌드에서 이런 관행이 얼마나 흔한지는 모르겠음. 흔하다면 정리하는 데 엄청난 작업이 필요할 것이고, 애초에 가능한지도 불확실함. bazel로는 가능해 보이지만 다른 빌드 시스템은 잘 모르겠음
  • 누가 GitHub에서 비슷한 head | tail 트릭을 이미 찾아봤는지 궁금함. 이걸 위해 새로 발명됐다고 보긴 어려움

    • 상용 소프트웨어 업체의 Unix 설치 프로그램에서 이런 방식을 꽤 봤음
      거대한 .sh 파일이 라이선스를 보여주고 동의를 받은 뒤, 자기 자신을 cat해서 head/tail 파이프를 거쳐 cpio로 실제 자산을 추출하는 식임
    • 영리하긴 하지만 완전히 새로운 건 아니고, 이런 도구들의 의도된 사용 사례에 가까움
    • 논문 쓸 기회임
  • 더 나은 답은 없지만, 이 난해한 bash 더미 자체가 냄새 아닌가 싶음
    개발 세계의 다른 영역에 있어서 그렇지만, 무슨 일이 일어나는지 더 명확하게 보이도록 덜 불투명하게 쓸 수는 없었을까?
    관리자가 외부 기여자만큼 엄격한 검토 없이 악성 코드를 넣을 수 있다는 건 알지만, “간결한” 코드 더미, 사실상 의도치 않은 난독화처럼 보이는 방식보다는 나은 길이 있어야 함

    • 이건 아주 오래된 냄새임
      핵심 문제는 80~90년대에 각자 결함과 빠진 기능을 가진 수많은 Unix 계열 시스템이 있었고, 소프트웨어 작성자들은 빌드 의존성을 최소화하고 싶어 했다는 데 있음
      그래서 많은 커뮤니티가 어디서나 동작하는 셸 스크립트로 빌드를 자동화하는 데 표준화했음. 하지만 셸 스크립트는 작성하기 고통스러웠고, 사람들은 M4 매크로 전처리기 같은 도구로 셸 스크립트를 생성하게 됨
      그 결과 누군가 AIX나 망가진 고대 Unix에서 코드를 실행하고 싶어 할 경우를 대비해, 많은 프로젝트가 거대하고 불투명한 셸 스크립트 덩어리를 갖게 됨
      이 뚫기 어려운 셸 덤불을 없애려면 지원 플랫폼 수를 크게 줄이고, 더 깨끗한 빌드 도구로 표준화하고, 이식 가능한 빌드에 셸이 필요 없는 언어로 핵심 인프라를 더 많이 만들어야 함
      하지만 이는 거대한 작업이고, 핵심 C 라이브러리 상당수는 무급 자원봉사자 한두 명이 관리함. “Obscurnix-1997” 지원 중단은 대체로 꽤 논쟁적인 결정이 되기도 함
      그래서 핵심 인프라 상당수가 여전히 정체 모를 기계 생성 셸 스크립트 늪에 둘러싸여 있음
    • 그 셸은 사람이 쓴 게 아니라 생성된 코드임. autoconf가 널리 쓰이다 보니 생성된 셸 설정 코드가 산처럼 쌓여 있고, autoconf는 M4 매크로 전처리기 스크립트로 수천 줄의 읽기 어려운 이식 가능 셸 스크립트를 만들어냄
      적지 않은 수의 도구가 이런 방식으로 빌드됨
    • 한눈에 봐서는 bash가 난해해 보인다는 사실 자체는 이상 신호라고 보지 않음. 빡빡하게 쓴 bash 스크립트는 가끔 그렇게 보임. 그렇게 빡빡하게 써야 하는지는 별개의 논쟁임
      의심스러워 보이는 건 반복되는 tr 호출임. 그런 걸 보면 누군가 영리하게 굴려 한다고 보는데, 여기서 “영리함”은 부정적인 의미임. 내가 관리자라면 그런 코드가 들어왔을 때 무엇을 하는지 설명해 달라고 했을 것임. 그런 식의 체이닝을 피하는 더 나은 해법이 거의 항상 있기 때문임
      진짜 문제는 이 코드가 들어올 때 봐줄 다른 관리자가 없었다는 점임. 스택의 중요한 구성요소가 한 사람에게 의존했고, 이번 경우 그 사람이 악의적이었음
    • “의도치 않게”가 아니라, 핵심은 의도적으로 난독화했다는 데 있음
    • XV 백도어의 위험을 오해했을 수도 있지만, bash가 무언가를 exec하지 못하게 실행하는 방법이 있을까? bash에 “보안 모드” 같은 게 있으면 어떨까 싶음
      다만 xv의 configure 스크립트에는 그런 가상의 “보안 모드”로 bash를 어떻게 실행할 수 있을지 상상이 안 돼서, 취소함
  • https://github.com/tukaani-project/.github/issues/2

    • 꽤 웃김. 이건 끔찍한 고의적 백도어일 뿐 아니라, 백도어가 파생 저작물인데 소스가 포함되지 않았고 “수정에 선호되는 형태”로 배포되지 않았으니 GPL 위반이기도 함
  • 빌드 도구와 오래된 Unix 유틸리티까지 포함한 C 생태계 전체가 악용되기를 기다리는 보안 난장판이고, 결국 악용될 것임
    점 하나로 모든 걸 망가뜨리기가 얼마나 쉬운지만 봐도 됨. 이제 세계의 보안을 C에 걸 수 없다는 걸 사람들이 깨달아야 함
    현대적 도구 체인을 갖춘 Ada나 Rust를 쓰길 바람

    • Rust에 점을 추가하면 안 망가지나?
  • 대체 이게 어떻게 코드 리뷰를 통과해서 병합됐는지 모르겠음. 뭔가 놓친 게 아니라면 터무니없이 부주의해 보임

    • 악의적 행위자는 저장소의 공동 관리자였고, 한동안 원래 관리자보다 더 활발했으며, 전체 커밋 권한을 갖고 있었음. PR도 리뷰도 없이 master에 바로 커밋됐음
      게다가 이건 테스트 파일로 표시된 바이너리 파일, 즉 “good”/“bad” xz 압축 테스트 파일 안에 심하게 난독화되어 있었음. 뭘 찾아야 하는지 모르면 알아챌 방법이 없음
    • 테스트 파일 커밋 메시지는 난수 생성기로 만들었다고 주장함. 릴리스 tarball을 만든 사람이 마지막 줄을 알맞은 위치에 넣었지만, 그건 저장소에 체크인하지 않았음
    • 활성 관리자가 한 명인 패키지에는 코드 리뷰가 없음
  • LTS 배포판을 쓰면 어느 정도 보호될 수 있음. Slackware는 패키지에 lzma, 즉 tar.xz를 쓰는 것 같은데, -current를 제외한 마지막 안정 릴리스에는 이 문제가 없었음
    자유 소프트웨어 쪽으로 한 단계 더 가고 싶다면 Hyperbola GNU도 이 문제가 없었음
    추가로 Slackware -currentsshd를 xz에 링크하지 않고, systemd도 쓰지 않음