1 điểm bởi GN⁺ 2024-03-31 | 1 bình luận | Chia sẻ qua WhatsApp
  • Backdoor xz/liblzma không chỉ là vấn đề của payload nhị phân cuối cùng; giai đoạn Bash được chạy trong lúc build cũng bị che giấu bằng nhiều lớp trích xuất và giải mã, khiến việc phân tích rất khó khăn
  • Các phiên bản bị ảnh hưởng là xz/liblzma 5.6.0 và 5.6.1; script bị làm rối và payload nhị phân được chứa trong hai tệp trông giống như tệp kiểm thử
  • Stage 0 bắt đầu từ m4/build-to-host.m4, khôi phục một luồng xz trông như đã hỏng, rồi dùng xz -d để lấy ra và chạy script Stage 1
  • Stage 1 cắt dữ liệu từ good-large_compressed.lzma, thay thế rồi chạy **script Bash Stage 2`; trong 5.6.1 có thêm đoạn mã kiểm tra việc đang chạy trên Linux tới 5 lần
  • Stage 2 dùng file carving, mật mã thay thế và giải mã biến thể RC4 dựa trên AWK để chèn tệp .o vào quá trình build và liên kết; trong 5.6.1 còn có cấu trúc mở rộng để có thể chạy thêm script trong tương lai

Phạm vi giai đoạn Bash của backdoor xz/liblzma

  • Andres Freund đã thông báo trên mailing list oss-security về việc phát hiện backdoor trong xz/liblzma, và backdoor này ảnh hưởng đến OpenSSH server
  • Đối tượng phân tích không phải bản thân backdoor nhị phân, mà là các giai đoạn khởi đầu dựa trên Bash được chạy trước đó và cách chúng bị làm rối
  • Luồng Bash kéo dài từ Stage 0 đến Stage 2, và cũng lộ ra một phần Stage 3 tiềm năng có vẻ chưa được hoàn thiện đầy đủ
  • Các giai đoạn bị làm rối/mã hóa và backdoor nhị phân phía sau được giấu trong hai tệp kiểm thử
    • tests/files/bad-3-corrupt_lzma2.xz
    • tests/files/good-large_compressed.lzma
  • Các phiên bản bị ảnh hưởng là 5.6.0 và 5.6.1, với một số khác biệt nhỏ giữa hai bản

Stage 0: Khôi phục luồng xz trông như tệp kiểm thử bị hỏng

  • Điểm bắt đầu là đoạn mã trong m4/build-to-host.m4, có vẻ được thực thi ở đâu đó trong quá trình build
  • Pipeline cốt lõi nối sed, tr, và xz -d để trích xuất script Stage 1
  • Sau khi đọc các byte của tests/files/bad-3-corrupt_lzma2.xz, nó thay thế một số byte bằng tr "\t \-_" " \t_\-"
    • tab 0x09 đổi thành khoảng trắng 0x20
    • khoảng trắng 0x20 đổi thành tab 0x09
    • dấu gạch ngang 0x2d đổi thành dấu gạch dưới 0x5f
    • dấu gạch dưới 0x5f đổi thành dấu gạch ngang 0x2d
  • Việc thay thế này có vai trò biến bad-3-corrupt_lzma2.xz trở lại thành một luồng xz hợp lệ
  • Cuối cùng nó giải nén luồng đã khôi phục bằng xz, bỏ qua lỗi và lấy được script Stage 1 để chạy ngay lập tức
    • Luồng trông như bị cắt cụt, nhưng điều đó không thành vấn đề vì phần đầu ra có ý nghĩa đã được ghi xong

Stage 1: Lấy Stage 2 từ good-large_compressed.lzma

  • Stage 1 là một tệp Bash ngắn bắt đầu bằng ####Hello####, được Andres Freund nêu trong email
  • Khác biệt đầu tiên giữa hai phiên bản là các byte ngẫu nhiên trong chú thích ở dòng thứ hai
    • 5.6.0: 86 F9 5A F7 2E 68 6A BC
    • 5.6.1: E5 55 89 B7 24 04 D8 17
  • Trong 5.6.1 có thêm kiểm tra Linux dạng [ ! $(uname) = "Linux" ] && exit 0, và cùng một kiểm tra này được lặp lại 5 lần
  • Việc trích xuất Stage 2 bắt đầu từ dòng dài export i=..., giá trị này sau đó được gọi bằng eval $i như một loại hàm
  • Luồng trích xuất là một pipeline liên tục nối stdout và stdin
    • Trước tiên giải nén good-large_compressed.lzma bằng xz -dc
    • eval $i lặp đi lặp lại lời gọi head theo mẫu bỏ qua 1024 byte rồi xuất 2048 byte
    • Ở cuối tệp, 5.6.0 xuất 724 byte còn 5.6.1 xuất 939 byte
  • Chuỗi head này loại bỏ các vùng rỗng, entropy thấp và chỉ nối lại các vùng có dữ liệu thực sự
  • Sau đó tail -c +31233 loại bỏ phần đầu
    • Trong phần đầu này có giấu backdoor nhị phân sẽ được trích xuất ở bước sau
    • Hiệu ứng thực tế là bỏ 31264 byte đầu trong 5.6.0 và 31232 byte đầu trong 5.6.1
  • Tiếp theo tr được dùng như một mật mã thay thế đơn giản, với khóa ánh xạ khác nhau giữa 5.6.0 và 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"
  • Dữ liệu sau thay thế tiếp tục được giải nén bằng xz -F raw --lzma1 -dc, và Stage 2 thu được sẽ được chạy ngay

Stage 2: Chỉnh sửa quá trình build và cơ chế mở rộng

  • Stage 2 là tệp infected.txt mà Andres Freund đính kèm trong email gốc, một script Bash nơi việc chỉnh sửa quá trình biên dịch thực sự diễn ra
  • Xét về góc độ làm rối, Stage 2 có ba mảnh đáng chú ý, trong đó hai mảnh chỉ xuất hiện ở 5.6.1
  • Hai mảnh trong 5.6.1 gần như giống hệt nhau và tìm trong tests/files/ các tệp có chữ ký cụ thể
    • Fragment 1 tìm ~!:_ W|_!{ -
    • Fragment 3 tìm jV!.^%%.R.1Z
  • Nếu tìm thấy chữ ký, nó sẽ trích xuất tên tệp và offset, rồi cắt dải byte từ sau chữ ký thứ nhất đến trước chữ ký thứ hai
  • Dữ liệu được cắt ra sau đó được giải mã thay thế bằng khóa tr dùng trong 5.6.0, tiếp tục giải nén bằng xz -F raw --lzma2 -dc, rồi chạy bằng eval
  • Trong các TAR archive 5.6.0 và 5.6.1 đã được khảo sát không có tệp nào mang các chữ ký đó
  • Cấu trúc này trông như một hệ thống mở rộng/vá cho phép chạy script mới trong tương lai chỉ bằng cách thêm tệp kiểm thử mới, không cần sửa lại các tệp kiểm thử đang mang payload

Stage 2: Trích xuất tệp .o của backdoor nhị phân

  • Một phần cốt lõi khác của Stage 2 là đoạn mã trích xuất tệp .o để chèn vào quá trình biên dịch và liên kết
  • Kết quả mục tiêu của việc trích xuất là liblzma_la-crc64-fast.o, chính là backdoor nhị phân cuối cùng
  • Quá trình trích xuất, giống các bước trước, kết nối nhiều công cụ chuẩn trong một pipeline
    • Giải nén good-large_compressed.lzma bằng xz -dc
    • Dùng lại chuỗi head của eval $i từng được dùng ở Stage 1
    • Dùng sed "s/\(.\)/\1\n/g" để thêm xuống dòng sau mỗi byte, biến dữ liệu thành dạng một dòng cho mỗi byte
  • Script AWK tiếp theo thực hiện giải mã kiểu RC4 trên luồng đầu vào
    • Mảng khóa được tạo bằng ((i * 7) + 5) % 256
    • 5.6.0 bỏ qua 4096 byte đầu của luồng PRNG đầu ra
    • 5.6.1 bỏ qua 8192 byte
    • RC4 gốc dùng XOR, nhưng đoạn mã này dùng phép cộng dạng (v + k) % 256
  • Dữ liệu đã giải mã sau đó được giải nén bằng xz -dc --single-stream
  • Cuối cùng head cắt lấy đúng dải byte cần thiết và lưu thành liblzma_la-crc64-fast.o
    • Payload đã nén nhưng có biến đổi một phần có kích thước 88792 byte ở 5.6.0 và 88664 byte ở 5.6.1
    • Dải được trích xuất cuối cùng có quy mô khoảng 86KB

Bản chất của lớp làm rối chỉ dùng công cụ chuẩn

  • Toàn bộ giai đoạn Bash được tạo nên chỉ bằng các công cụ dòng lệnh chuẩn
    • sed
    • tr
    • xz
    • head
    • tail
    • awk
    • grep
    • cut
    • expr
  • Payload được giấu trong các tệp nhị phân trông như tệp kiểm thử hợp lệ, rồi chỉ lấy ra các vùng cần thiết bằng file carving
  • Lớp làm rối kết hợp mật mã thay thế đơn giản với giải mã biến thể RC4 dựa trên AWK
  • Việc thực thi diễn ra qua tổng cộng 3 giai đoạn, và trong 5.6.1 có thêm cấu trúc cho phép sau này chạy script bằng cách bổ sung một tệp kiểm thử riêng
  • Toàn bộ cấu trúc cho thấy đã có rất nhiều nỗ lực để khiến nó trông vô hại và được che giấu tốt; nếu đây là một trường hợp bị phát hiện tình cờ thì cũng đặt ra câu hỏi còn bao nhiêu trường hợp tương tự chưa bị phát hiện

1 bình luận

 
GN⁺ 2024-03-31
Các ý kiến trên Hacker News
  • Nhờ phần giải thích được đơn giản hóa và so sánh với ảnh nhiễu, tôi đã hình dung được mọi người muốn nói gì khi gọi nó là tinh vi
    Tôi cũng thấy trên reddit có nói rằng cách “sandboxing” đã bị phá hỏng chỉ bởi một dấu chấm; dấu chấm nằm ở mép trái của dòng ngay sau #include
    https://git.tukaani.org/?p=xz.git;a=commitdiff;h=328c52da8a2...
    https://old.reddit.com/r/linux/comments/1brhlur/xz_utils_bac...

    • Thật sự hiểm độc. Trong diff nó chỉ trông như +, +., +, và một dấu chấm đó rất khó nhận ra
    • Tôi thật sự ghét việc dùng những điều kiện ở thời điểm biên dịch/build kiểu này. Rất khó kiểm thử xem nó có bật khi cần bật và tắt khi cần tắt hay không, đặc biệt là khi nằm trong một hệ thống build không có framework unit test
      Chỉ riêng lỗi đơn giản khiến test luôn fail hoặc luôn pass đã đủ đau đầu rồi; có thể thấy vì sao nó dễ trở thành mục tiêu cho hành vi ác ý
    • Khả năng rất cao đây không phải cố ý mà chỉ là lỗi đơn giản
      a) Thực tế hầu như không ai build gói này bằng cmake
      b) Nếu build bằng cmake với -DENABLE_SANDBOX=landlock thì build đơn giản là thất bại: https://i.imgur.com/7xbeWFx.png
      Dấu chấm đó không vô hiệu hóa sandboxing, mà chỉ khiến không thể build bằng cmake. Nếu thực sự có ai đó build bằng cmake, họ hẳn đã thấy lỗi và nhận ra có gì đó sai, nên việc coi đây là một nỗ lực ác ý nhằm hạ thấp bảo mật là không hợp lý
  • “Nếu chuyện này được phát hiện một cách tình cờ, thì còn bao nhiêu thứ chưa bị phát hiện nữa?” mới là câu hỏi quan trọng nhất
    Không thể nào Andres Freund lại tình cờ tìm ra đúng một dự án mã nguồn mở phổ biến duy nhất bị cài backdoor. Dù ngoài thực tế đã có khoảng một tá thứ như vậy thì cũng không lạ

    • Không hẳn là tình cờ phát hiện, mà gần với việc cảm thấy có gì đó hơn. Hai chuyện này khác nhau
      Kẻ tấn công tiếp theo sẽ ít bất cẩn hơn nhiều trong việc để lại dấu vết như thời gian chạy tăng lên
    • Cũng có thể, nhưng trên thực tế có lẽ không có nhiều trường hợp nghiêm trọng như vậy. Nếu nhiều thì có lẽ chúng ta đã gặp tình huống kiểu này thường xuyên hơn
  • Điều gây khó chịu ở đây là unit test đã mở ra đường tấn công. Nếu không có test, việc che giấu như vậy sẽ khó hơn rất nhiều

    • Kẻ tấn công thậm chí còn che dấu vết của payload ban đầu bằng một đoạn vô hại trong README. Kiểu như “không có gì để xem đâu!”
      bad-3-corrupt_lzma2.xz có ba stream; stream thứ nhất và thứ ba là các xz stream hợp lệ. README ghi rằng stream ở giữa có stream header, block header, index và stream footer đúng, chỉ dữ liệu LZMA2 bị hỏng, và khi dùng --single-stream thì nó phải được giải nén
      Các chuỗi ####Hello########World#### giúp tạo ra một kết quả bình thường có vẻ hợp lý khi thực sự làm theo hướng dẫn trong README
      $ cat tests/files/bad-3-corrupt_lzma2.xz | xz -d --single-stream
      ####Hello####
      Những chuỗi này là comment của shell nên không cản trở việc thực thi payload
      Cuối cùng, về sau chúng đóng vai trò như dấu mốc để regex có thể tìm ra file mà không cần tham chiếu trực tiếp tên file hay dùng đúng chuỗi 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
    • Với một dự án quan trọng về bảo mật, có vẻ hợp lý khi cấu hình hạ tầng build sao cho việc đưa file nhị phân vào build sẽ gây lỗi, hoặc ít nhất là cảnh báo
      Việc kiểm tra này cần được áp dụng theo tính bắc cầu, để khi một bản phân phối Linux định cập nhật lên phiên bản liblzma mới, build cũng phải fail hoặc cảnh báo vì có dependency nhị phân mới
      Tôi không biết thông lệ này phổ biến đến đâu trong các bản build của bản phân phối Linux. Nếu phổ biến thì việc dọn dẹp sẽ đòi hỏi khối lượng công việc khổng lồ, và ngay từ đầu cũng chưa chắc có khả thi không. Với bazel thì có vẻ làm được, còn các hệ thống build khác thì tôi không rõ
  • Không biết đã có ai tìm thử trên GitHub các mánh head | tail tương tự chưa. Khó mà tin rằng trò này được phát minh mới chỉ để phục vụ vụ này

    • Tôi đã thấy cách này khá nhiều trong các trình cài đặt Unix của các hãng phần mềm thương mại
      Kiểu như một file .sh khổng lồ hiển thị giấy phép và nhận đồng ý, rồi cat chính nó qua pipeline head/tail để cpio trích xuất các tài nguyên thực sự
    • Thông minh đấy, nhưng không hoàn toàn mới; nó gần với use case được dự định của các công cụ này
    • Đây là cơ hội để viết paper
  • Không có câu trả lời nào tốt hơn, nhưng bản thân cái đống bash khó hiểu này chẳng phải đã có mùi rồi sao?
    Cũng như trong các lĩnh vực khác của thế giới phát triển phần mềm, lẽ nào không thể viết bớt mờ mịt hơn để thấy rõ hơn chuyện gì đang xảy ra sao?
    Tôi biết maintainer có thể đưa mã độc vào mà không bị rà soát nghiêm ngặt như contributor bên ngoài, nhưng hẳn phải có cách tốt hơn một đống code “súc tích”, thực tế trông như kiểu obfuscation ngoài ý muốn

    • Đây là một mùi rất cũ
      Vấn đề cốt lõi là vào thập niên 80–90 có vô số hệ thống họ Unix, mỗi hệ thống có lỗi và thiếu tính năng riêng, còn tác giả phần mềm thì muốn giảm tối đa phụ thuộc khi build
      Vì vậy nhiều cộng đồng đã chuẩn hóa việc tự động hóa build bằng shell script chạy được ở mọi nơi. Nhưng viết shell script rất khổ, nên người ta bắt đầu dùng các công cụ như bộ tiền xử lý macro M4 để sinh shell script
      Kết quả là nhiều dự án có cả đống shell script khổng lồ và mờ mịt, phòng trường hợp ai đó muốn chạy mã trên AIX hoặc một Unix cổ lỗ bị hỏng nào đó
      Muốn loại bỏ bụi rậm shell khó xuyên thủng này thì phải giảm mạnh số nền tảng được hỗ trợ, chuẩn hóa vào các công cụ build sạch hơn, và xây dựng nhiều hạ tầng lõi hơn bằng những ngôn ngữ không cần shell cho các bản build portable
      Nhưng đó là một công việc khổng lồ, và phần lớn thư viện C lõi được một hai tình nguyện viên không lương duy trì. Việc ngừng hỗ trợ “Obscurnix-1997” nhìn chung cũng là quyết định khá gây tranh cãi
      Vì vậy một phần lớn hạ tầng lõi vẫn bị bao quanh bởi đầm lầy shell script sinh tự động không rõ là gì
    • Shell đó không phải do người viết, mà là mã được sinh ra. Vì autoconf được dùng rộng rãi nên có cả núi mã cấu hình shell được sinh ra, và autoconf dùng các script tiền xử lý macro M4 để tạo ra hàng nghìn dòng shell script portable rất khó đọc
      Không ít công cụ được build theo cách này
    • Việc thoạt nhìn bash trông khó hiểu tự thân nó không phải là dấu hiệu bất thường. Bash script viết dày đặc đôi khi trông như vậy. Có cần viết dày đặc đến thế hay không là một tranh luận khác
      Điều trông đáng ngờ là các lần gọi tr lặp đi lặp lại. Thấy kiểu đó thì tôi nghĩ ai đó đang cố tỏ ra khôn lanh, và ở đây “khôn lanh” mang nghĩa tiêu cực. Nếu tôi là maintainer, khi thấy đoạn mã như vậy được đưa vào, tôi đã yêu cầu giải thích nó làm gì. Vì hầu như luôn có cách tốt hơn để tránh kiểu chaining như thế
      Vấn đề thật sự là lúc mã này được đưa vào không có maintainer khác để xem xét. Một thành phần quan trọng trong stack phụ thuộc vào một người, và trong trường hợp này người đó có ác ý
    • Không phải “ngoài ý muốn”, điểm chính là nó được cố tình obfuscate
    • Có thể tôi đã hiểu nhầm rủi ro của backdoor XV, nhưng có cách nào chạy bash mà không cho nó exec thứ gì đó không? Tôi tự hỏi nếu bash có kiểu “chế độ an toàn” thì sao
      Tuy vậy tôi không hình dung được làm sao chạy bash trong cái “chế độ an toàn” giả định đó cho script configure của xv, nên xin rút lại
  • https://github.com/tukaani-project/.github/issues/2

    • Khá buồn cười. Đây không chỉ là một backdoor cố ý khủng khiếp, mà vì backdoor là tác phẩm phái sinh nhưng không kèm mã nguồn và không được phân phối dưới “dạng ưu tiên để sửa đổi”, nên cũng là vi phạm GPL
  • Toàn bộ hệ sinh thái C, bao gồm cả công cụ build và các tiện ích Unix cũ, là một mớ hỗn độn bảo mật chỉ chờ bị khai thác, và rồi sẽ bị khai thác
    Chỉ cần nhìn việc một dấu chấm có thể phá hỏng mọi thứ dễ đến mức nào là đủ. Đã đến lúc mọi người phải nhận ra rằng không thể đặt cược an ninh của thế giới vào C nữa
    Mong là hãy dùng Ada hoặc Rust với toolchain hiện đại

    • Thêm một dấu chấm vào Rust thì không hỏng à?
  • Tôi không hiểu nổi làm sao thứ này qua được code review rồi được merge. Nếu tôi không bỏ sót gì thì trông cẩu thả đến mức vô lý

    • Kẻ có ác ý là đồng maintainer của repository, đã hoạt động tích cực hơn maintainer gốc trong một thời gian, và có toàn quyền commit. Nó được commit thẳng vào master, không PR cũng không review
      Hơn nữa, nó được obfuscate rất nặng bên trong một file binary được đánh dấu là file test, tức các file test nén xz “good”/“bad”. Nếu không biết phải tìm gì thì không có cách nào nhận ra
    • Commit message của file test nói rằng nó được tạo bằng trình sinh số ngẫu nhiên. Người tạo release tarball đã đặt dòng cuối vào đúng vị trí, nhưng phần đó không được check-in vào repository
    • Với package chỉ có một maintainer hoạt động thì không có code review
  • Dùng bản phân phối LTS có thể bảo vệ được ở mức nào đó. Slackware có vẻ dùng lzma, tức tar.xz, cho package, nhưng bản ổn định cuối cùng ngoại trừ -current không gặp vấn đề này
    Nếu muốn tiến thêm một bước về phía phần mềm tự do, Hyperbola GNU cũng không gặp vấn đề này
    Ngoài ra Slackware -current cũng không link sshd với xz, và cũng không dùng systemd