Xz/liblzma: คำอธิบายการพรางโค้ดในขั้นตอน Bash
(gynvael.coldwind.pl)- แบ็กดอร์ xz/liblzma ไม่ได้เป็นปัญหาเฉพาะ payload ไบนารีสุดท้ายเท่านั้น แต่ยังมี ขั้นตอน Bash ที่ทำงานระหว่างการ build ซึ่งถูกซ่อนด้วยกระบวนการดึงข้อมูลและถอดรหัสหลายชั้น ทำให้วิเคราะห์ได้ยาก
- เวอร์ชันที่ได้รับผลกระทบคือ xz/liblzma 5.6.0 และ 5.6.1 โดยสคริปต์ที่ถูกพรางและ payload ไบนารีอยู่ในไฟล์สองไฟล์ที่ดูเหมือนไฟล์ทดสอบ
- Stage 0 เริ่มจาก
m4/build-to-host.m4โดยกู้คืน xz stream ที่ดูเหมือนเสียหาย แล้วใช้xz -dดึง สคริปต์ Stage 1 ออกมารัน - Stage 1 ตัดและแทนที่ข้อมูลจาก
good-large_compressed.lzmaแล้วรัน สคริปต์ Bash Stage 2 และใน 5.6.1 มีการเพิ่มโค้ดตรวจสอบว่ารันบน Linux หรือไม่ 5 ครั้ง - Stage 2 ใช้ file carving, cipher แบบแทนที่ และ การถอดรหัส RC4 แบบดัดแปลงด้วย AWK เพื่อแทรกไฟล์
.oเข้าไปในกระบวนการ build/link และใน 5.6.1 ยังมีโครงสร้างส่วนขยายที่สามารถรันสคริปต์เพิ่มเติมในอนาคตได้ด้วย
ขอบเขตของขั้นตอน Bash ในแบ็กดอร์ xz/liblzma
- Andres Freund แจ้งการค้นพบแบ็กดอร์ xz/liblzma ใน mailing list oss-security และแบ็กดอร์นี้ส่งผลต่อ OpenSSH server
- สิ่งที่วิเคราะห์ไม่ใช่ตัวแบ็กดอร์ไบนารีเอง แต่เป็น ขั้นตอนเริ่มต้นที่ใช้ Bash และวิธีการพรางโค้ดที่ทำงานก่อนหน้านั้น
- โฟลว์ Bash ต่อเนื่องตั้งแต่ Stage 0 ถึง Stage 2 และยังเผยให้เห็นบางส่วนของ Stage 3 ที่เป็นไปได้ซึ่งดูเหมือนยังไม่ได้ implement สมบูรณ์
- ขั้นตอนที่ถูกพราง/เข้ารหัสและแบ็กดอร์ไบนารีหลังจากนั้นถูกซ่อนอยู่ในไฟล์ทดสอบสองไฟล์
tests/files/bad-3-corrupt_lzma2.xztests/files/good-large_compressed.lzma
- เวอร์ชันที่ได้รับผลกระทบคือ 5.6.0 และ 5.6.1 โดยมีความแตกต่างเล็กน้อยระหว่างสองเวอร์ชัน
Stage 0: กู้คืน xz stream ที่ดูเหมือนไฟล์ทดสอบเสียหาย
- จุดเริ่มต้นคือโค้ดใน
m4/build-to-host.m4และดูเหมือนว่าจะถูกเรียกใช้ที่ใดที่หนึ่งระหว่างกระบวนการ build - pipeline หลักเชื่อม
sed,tr,xz -dเพื่อดึง สคริปต์ Stage 1 ออกมา - หลังจากอ่านไบต์ของ
tests/files/bad-3-corrupt_lzma2.xzแล้ว จะใช้tr "\t \-_" " \t_\-"เพื่อแทนที่ไบต์บางตัว- แท็บ
0x09ถูกเปลี่ยนเป็นช่องว่าง0x20 - ช่องว่าง
0x20ถูกเปลี่ยนเป็นแท็บ0x09 - ไฮเฟน
0x2dถูกเปลี่ยนเป็น underscore0x5f - underscore
0x5fถูกเปลี่ยนเป็นไฮเฟน0x2d
- แท็บ
- การแทนที่นี้มีหน้าที่ทำให้
bad-3-corrupt_lzma2.xzกลับมาเป็น xz stream ปกติ - ท้ายที่สุดจะคลาย stream ที่กู้คืนแล้วด้วย
xzโดยไม่สนใจ error จากนั้นได้สคริปต์ Stage 1 และรันในทันที- stream ดูเหมือนถูกตัดขาด แต่ output ที่มีความหมายถูกเขียนออกมาแล้ว จึงไม่เป็นปัญหา
Stage 1: ดึง Stage 2 ออกจาก good-large_compressed.lzma
- Stage 1 เป็นไฟล์ Bash สั้น ๆ ที่ขึ้นต้นด้วย
####Hello####ในอีเมลของ Andres Freund - ความแตกต่างแรกของสองเวอร์ชันคือไบต์สุ่มในคอมเมนต์บรรทัดที่สอง
- 5.6.0:
86 F9 5A F7 2E 68 6A BC - 5.6.1:
E5 55 89 B7 24 04 D8 17
- 5.6.0:
- ใน 5.6.1 มีการเพิ่ม การตรวจสอบ Linux ในรูปแบบ
[ ! $(uname) = "Linux" ] && exit 0และการตรวจสอบเดียวกันนี้ถูกทำซ้ำ 5 ครั้ง - การดึง Stage 2 เริ่มจากบรรทัดยาว
export i=...โดยค่านี้ถูกใช้เหมือนฟังก์ชันชนิดหนึ่งที่เรียกภายหลังด้วยeval $i - โฟลว์การดึงข้อมูลเป็นโครงสร้าง pipeline ที่เชื่อม standard output และ standard input ต่อเนื่องกัน
- ก่อนอื่นคลาย
good-large_compressed.lzmaด้วยxz -dc eval $iเรียกheadซ้ำ ๆ ในรูปแบบที่ทิ้ง 1024 ไบต์ แล้ว output 2048 ไบต์- ที่ท้ายไฟล์ 5.6.0 output 724 ไบต์ ส่วน 5.6.1 output 939 ไบต์
- ก่อนอื่นคลาย
- chain ของ
headนี้ตัดส่วนที่เป็นพื้นที่ entropy ต่ำและว่างเปล่าออก แล้วต่อเฉพาะพื้นที่ที่มีข้อมูลจริงเข้าด้วยกัน - จากนั้นใช้
tail -c +31233ทิ้งส่วนต้น- ส่วนต้นนี้ซ่อน แบ็กดอร์ไบนารี ที่จะถูกดึงออกมาในขั้นตอนถัดไป
- ใน 5.6.0 มีผลเท่ากับทิ้ง 31264 ไบต์แรก ส่วนใน 5.6.1 ทิ้ง 31232 ไบต์แรก
- ถัดมา
trถูกใช้เหมือน cipher แบบแทนที่อย่างง่าย โดย mapping key ของ 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:
- ข้อมูลที่ถูกแทนที่จะถูกคลายอีกครั้งด้วย
xz -F raw --lzma1 -dcและ Stage 2 ที่ได้จะถูกรันทันที
Stage 2: การแก้ไขกระบวนการ build และกลไกส่วนขยาย
- Stage 2 คือไฟล์
infected.txtที่ Andres Freund แนบไว้ในอีเมลต้นฉบับ และเป็นสคริปต์ Bash ที่เกิด การแก้ไขกระบวนการ compile จริง - จากมุมมองการพรางโค้ด Stage 2 มีชิ้นส่วนที่น่าสนใจสามส่วน โดยสองส่วนในนั้นปรากฏ เฉพาะใน 5.6.1
- สองชิ้นส่วนของ 5.6.1 แทบจะเหมือนกัน และค้นหาไฟล์ที่มี signature เฉพาะใน
tests/files/- Fragment 1 ค้นหา
~!:_ Wและ|_!{ - - Fragment 3 ค้นหา
jV!.^%และ%.R.1Z
- Fragment 1 ค้นหา
- เมื่อพบ signature จะดึงชื่อไฟล์และ offset ออกมา แล้วตัดช่วงไบต์ตั้งแต่หลัง signature แรกจนถึงก่อน signature ที่สอง
- ข้อมูลที่ตัดออกมาจะถูกถอดรหัสแบบแทนที่ด้วยคีย์
trที่ใช้ใน 5.6.0 จากนั้นคลายด้วยxz -F raw --lzma2 -dcและรันด้วยeval - ในไฟล์ TAR archive ของ 5.6.0 และ 5.6.1 ที่ตรวจสอบ ไม่พบไฟล์ที่มี signature ดังกล่าว
- โครงสร้างนี้ดูเหมือน ระบบส่วนขยาย/แพตช์ ที่ทำให้สามารถรันสคริปต์ในอนาคตได้โดยเพิ่มเฉพาะไฟล์ทดสอบใหม่ โดยไม่ต้องแก้ไฟล์ทดสอบที่ใช้ขนส่ง payload เดิมอีกครั้ง
Stage 2: ดึงไฟล์ .o ของแบ็กดอร์ไบนารี
- แกนหลักอีกอย่างของ Stage 2 คือโค้ดที่ดึงไฟล์
.oแล้วแทรกเข้าไปในกระบวนการ compile/link - ผลลัพธ์เป้าหมายที่ถูกดึงออกมาคือ
liblzma_la-crc64-fast.oซึ่งเป็น แบ็กดอร์ไบนารี สุดท้าย - ขั้นตอนการดึงข้อมูลเชื่อมเครื่องมือมาตรฐานหลายตัวเป็น pipeline เหมือนขั้นตอนก่อนหน้า
- คลาย
good-large_compressed.lzmaด้วยxz -dc - ใช้ chain
headของeval $iที่ใช้ใน Stage 1 อีกครั้ง - ใช้
sed "s/\(.\)/\1\n/g"ใส่ newline หลังแต่ละไบต์ ทำให้อยู่ในรูปแบบหนึ่งบรรทัดต่อหนึ่งไบต์
- คลาย
- สคริปต์ AWK ถัดไปทำ การถอดรหัสที่คล้าย RC4 กับ input stream
- key array สร้างด้วย
((i * 7) + 5) % 256 - 5.6.0 ข้าม 4096 ไบต์แรกของ PRNG stream ที่ output
- 5.6.1 ข้าม 8192 ไบต์แรก
- RC4 ดั้งเดิมใช้ XOR แต่โค้ดนี้ใช้การบวกในรูปแบบ
(v + k) % 256
- key array สร้างด้วย
- ข้อมูลที่ถอดรหัสแล้วถูกคลายด้วย
xz -dc --single-stream - จากนั้นใช้
headตัดเฉพาะช่วงไบต์ที่ต้องการและบันทึกเป็นliblzma_la-crc64-fast.o- ขนาด payload ที่ถูกบีบอัดแต่มีการดัดแปลงบางส่วนคือ 88792 ไบต์ใน 5.6.0 และ 88664 ไบต์ใน 5.6.1
- ช่วงที่ดึงออกมาสุดท้ายมีขนาดราว 86KB
ลักษณะของการพรางโค้ดที่สร้างด้วยเครื่องมือมาตรฐานเท่านั้น
- ขั้นตอน Bash ทั้งหมดประกอบขึ้นจากเครื่องมือ command line มาตรฐานเท่านั้น
sedtrxzheadtailawkgrepcutexpr
- payload ถูกซ่อนอยู่ในไฟล์ไบนารีที่ดูเหมือนไฟล์ทดสอบปกติ และใช้วิธี file carving เพื่อดึงเฉพาะพื้นที่ที่ต้องการออกมา
- การพรางโค้ดใช้ทั้ง cipher แบบแทนที่อย่างง่ายและการถอดรหัส RC4 แบบดัดแปลงที่ใช้ AWK
- การรันต่อเนื่องกันทั้งหมด 3 ขั้นตอน และใน 5.6.1 มีโครงสร้างที่สามารถเพิ่มไฟล์ทดสอบแยกต่างหากในภายหลังเพื่อรันสคริปต์ได้
- โครงสร้างทั้งหมดเป็นรูปแบบที่ทุ่มเทความพยายามอย่างมากให้ดูไม่เป็นอันตรายและซ่อนตัวได้ดี และหากกรณีนี้ถูกพบโดยบังเอิญ ก็ทิ้งคำถามว่ายังมีกรณีคล้ายกันที่ยังไม่ถูกค้นพบเหลืออยู่อีกมากเพียงใด
1 ความคิดเห็น
ความคิดเห็นจาก Hacker News
ด้วยคำอธิบายแบบทำให้ง่ายขึ้นและการเปรียบเทียบกับภาพ noise ทำให้พอเข้าใจแล้วว่าที่คนพูดถึง ความประณีตซับซ้อน นั้นหมายถึงอะไร
ผมยังเห็นใน reddit ด้วยว่า วิธี “sandboxing” ถูกทำให้พังด้วยจุดเพียงจุดเดียว และเห็นจุดอยู่ชิดซ้ายสุดของบรรทัดถัดจาก
#includeทันทีhttps://git.tukaani.org/?p=xz.git;a=commitdiff;h=328c52da8a2...
https://old.reddit.com/r/linux/comments/1brhlur/xz_utils_bac...
+,+.,+และ จุดเดียว นั้นแทบไม่สะดุดตาแค่ความผิดพลาดธรรมดาที่ทำให้การทดสอบล้มเหลวตลอดหรือสำเร็จตลอดก็ปวดหัวแล้ว และเห็นได้เลยว่าทำไมมันถึงเป็นเป้าหมายที่ดีสำหรับพฤติกรรมไม่ประสงค์ดี
a) แทบไม่มีใครเลยที่บิลด์แพ็กเกจนี้ด้วย cmake
b) ถ้าลองบิลด์ด้วย cmake และ
-DENABLE_SANDBOX=landlockก็จะบิลด์ไม่ผ่านเลย: https://i.imgur.com/7xbeWFx.pngจุดนั้นไม่ได้ปิด sandboxing แต่แค่ทำให้บิลด์ด้วย cmake ไม่ได้เท่านั้น จริง ๆ ถ้ามีใครลองบิลด์ด้วย cmake ก็คงเห็น error แล้วรู้ว่ามีอะไรผิดปกติ ดังนั้นจึงไม่สมเหตุสมผลนักที่จะมองว่าเป็นความพยายามร้ายเพื่อทำให้ความปลอดภัยลดลง
คำถามที่สำคัญที่สุดคือ “ถ้าเรื่องนี้ถูกค้นพบโดยบังเอิญ แล้วที่ยังไม่ถูกค้นพบยังเหลืออยู่อีกเท่าไร”
ไม่น่าเป็นไปได้ที่ Andres Freund จะบังเอิญเจอแค่โปรเจกต์โอเพนซอร์สยอดนิยมหนึ่งเดียวที่ถูกฝัง backdoor ไว้ ถ้าตอนนี้มีของแบบนี้อยู่จริงในโลกสักโหลหนึ่งก็ไม่ใช่เรื่องแปลก
ผู้โจมตีรายถัดไปคงไม่สะเพร่าเหลือร่องรอยอย่างเวลารันที่เพิ่มขึ้นมากนัก
ความคิดที่ชวนไม่สบายใจตรงนี้คือ unit test เป็นสิ่งที่เปิดเส้นทางโจมตีให้ ถ้าไม่มีการทดสอบ ก็คงซ่อนแบบนี้ได้ยากกว่านี้มาก
bad-3-corrupt_lzma2.xzมีสามสตรีม โดยสตรีมแรกและสตรีมที่สามเป็น xz stream ที่ถูกต้อง ส่วนสตรีมตรงกลางมี stream header, block header, index และ stream footer ที่ถูกต้อง มีเพียงข้อมูล LZMA2 เท่านั้นที่เสียหาย และระบุไว้ว่าถ้าใช้--single-streamก็ควรถูก decompress ได้สตริง
####Hello####และ####World####ช่วยให้ได้ผลลัพธ์ปกติที่ดูสมเหตุสมผลเมื่อทำตามคำสั่งใน README จริง ๆ$ cat tests/files/bad-3-corrupt_lzma2.xz | xz -d --single-stream####Hello####สตริงเหล่านี้เป็นคอมเมนต์ของ shell จึงไม่รบกวนการรัน payload
สุดท้าย มันยังทำหน้าที่เป็น เครื่องหมาย เพื่อให้ภายหลัง regex หาไฟล์เจอได้โดยไม่ต้องอ้างชื่อไฟล์โดยตรงหรือใช้สตริง 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การตรวจนี้ควรใช้แบบ transitive ด้วย ดังนั้นแม้แต่ตอนที่ดิสโทรลินุกซ์พยายามอัปเดตเป็น liblzma เวอร์ชันใหม่ การบิลด์ก็ควรล้มเหลวหรือแจ้งเตือนเพราะมี dependency ไบนารีใหม่
ผมไม่รู้ว่าแนวปฏิบัติแบบนี้พบได้ทั่วไปแค่ไหนในการบิลด์ของดิสโทรลินุกซ์ ถ้าพบได้ทั่วไป งานเก็บกวาดคงมหาศาล และไม่แน่ใจด้วยซ้ำว่าเป็นไปได้หรือไม่ สำหรับ bazel ดูเหมือนจะทำได้ แต่ระบบบิลด์อื่น ๆ ผมไม่แน่ใจ
สงสัยว่ามีใครลองค้นหา ทริก head | tail แบบคล้าย ๆ กันบน GitHub แล้วหรือยัง ยากที่จะเชื่อว่านี่ถูกคิดขึ้นมาใหม่เพื่อเรื่องนี้โดยเฉพาะ
ไฟล์
.shขนาดมหึมาจะแสดงไลเซนส์และขอให้ยอมรับ จากนั้นcatตัวเองแล้วส่งผ่าน pipehead/tailไปยังcpioเพื่อแตก asset จริงออกมาแม้จะไม่มีคำตอบที่ดีกว่า แต่ผมก็รู้สึกว่า กอง bash ที่อ่านยากแบบนี้เองมันไม่น่าไว้ใจอยู่แล้วหรือเปล่า
เหมือนกับในหลายส่วนของโลกการพัฒนา ซอฟต์แวร์นี้เขียนให้โปร่งใสน้อยลงไม่ได้หรือ เพื่อให้เห็นชัดขึ้นว่าเกิดอะไรขึ้น?
ผมรู้ว่าผู้ดูแลสามารถใส่โค้ดมุ่งร้ายเข้าไปได้โดยไม่ต้องผ่านการตรวจสอบเข้มงวดเท่าผู้มีส่วนร่วมภายนอก แต่ก็น่าจะมีทางที่ดีกว่ากองโค้ดที่ “กระชับ” ซึ่งแทบจะดูเหมือนการทำให้สับสนโดยไม่ได้ตั้งใจแบบนี้
ปัญหาหลักคือในยุค 80–90 มีระบบตระกูล Unix จำนวนมากที่แต่ละตัวมีข้อบกพร่องและฟีเจอร์ที่ขาดหายไปต่างกัน และผู้เขียนซอฟต์แวร์ก็อยากลด dependency สำหรับการ build ให้น้อยที่สุด
ดังนั้นหลายชุมชนจึงทำให้การ build อัตโนมัติด้วย เชลล์สคริปต์ ที่ทำงานได้ทุกที่กลายเป็นมาตรฐาน แต่การเขียนเชลล์สคริปต์นั้นเจ็บปวด และผู้คนก็เริ่มใช้เครื่องมืออย่างตัวพรีโปรเซสเซอร์มาโคร M4 เพื่อสร้างเชลล์สคริปต์ขึ้นมา
ผลก็คือหลายโปรเจกต์มีก้อนเชลล์สคริปต์ขนาดใหญ่และทึบแสง เผื่อกรณีที่ใครบางคนอยากรันโค้ดบน AIX หรือ Unix โบราณที่พัง ๆ
หากจะกำจัดพงหนามเชลล์ที่เจาะยากนี้ ต้องลดจำนวนแพลตฟอร์มที่รองรับลงอย่างมาก ทำให้เครื่องมือ build ที่สะอาดกว่ากลายเป็นมาตรฐาน และสร้างโครงสร้างพื้นฐานหลักให้มากขึ้นด้วยภาษาที่ไม่ต้องพึ่งเชลล์สำหรับการ build แบบพกพา
แต่นี่เป็นงานมหึมา และไลบรารี C หลักจำนวนไม่น้อยก็มีอาสาสมัครที่ไม่ได้รับค่าจ้างเพียงหนึ่งหรือสองคนดูแลอยู่ การเลิกซัพพอร์ต “Obscurnix-1997” ก็มักกลายเป็นการตัดสินใจที่ถกเถียงกันพอสมควร
ดังนั้นโครงสร้างพื้นฐานหลักจำนวนมากจึงยังถูกล้อมรอบด้วยบึงเชลล์สคริปต์ที่สร้างโดยเครื่องและไม่รู้ที่มาที่ไป
มีเครื่องมือจำนวนไม่น้อยที่ build ด้วยวิธีนี้
สิ่งที่ดูน่าสงสัยคือการเรียก tr ซ้ำ ๆ เวลาเห็นแบบนั้นผมจะคิดว่ามีใครบางคนพยายามทำตัวฉลาด และในที่นี้ “ฉลาด” เป็นความหมายเชิงลบ ถ้าผมเป็นผู้ดูแล ผมคงขอให้เขาอธิบายว่าโค้ดแบบนั้นทำอะไรตอนที่มันถูกส่งเข้ามา เพราะแทบจะมีวิธีที่ดีกว่าในการหลีกเลี่ยงการ chain แบบนั้นอยู่เสมอ
ปัญหาจริง ๆ คือไม่มีผู้ดูแลคนอื่นมาช่วยดูตอนที่โค้ดนี้เข้ามา องค์ประกอบสำคัญของสแตกต้องพึ่งพาคนคนเดียว และในกรณีนี้คนคนนั้นมีเจตนาร้าย
execอะไรบางอย่างได้ไหม? ผมคิดว่าถ้า bash มีอะไรอย่าง “โหมดปลอดภัย” ก็คงดีแต่พอมาคิดดู ผมนึกไม่ออกว่าจะรัน bash สำหรับสคริปต์ configure ของ xv ด้วย “โหมดปลอดภัย” ในจินตนาการแบบนั้นได้อย่างไร งั้นขอถอนคำพูด
https://github.com/tukaani-project/.github/issues/2
ระบบนิเวศ C ทั้งหมด รวมถึงเครื่องมือ build และยูทิลิตี Unix เก่า ๆ เป็น ความโกลาหลด้านความปลอดภัย ที่รอวันถูกฉวยใช้ และสุดท้ายก็จะถูกฉวยใช้
แค่ดูว่าจุดหนึ่งจุดทำให้ทุกอย่างพังได้ง่ายแค่ไหนก็พอ ตอนนี้ผู้คนควรตระหนักได้แล้วว่าเราไม่สามารถฝากความปลอดภัยของโลกไว้กับ C ได้
หวังว่าจะใช้ Ada หรือ Rust ที่มี toolchain สมัยใหม่
ผมไม่เข้าใจเลยว่าสิ่งนี้ผ่าน code review แล้ว merge เข้ามาได้อย่างไร ถ้าผมไม่ได้พลาดอะไรไป มันก็ดูสะเพร่าอย่างเหลือเชื่อ
ยิ่งไปกว่านั้น มันถูกซ่อนอย่างหนักอยู่ในไฟล์ไบนารีที่ระบุว่าเป็นไฟล์ทดสอบ คือไฟล์ทดสอบบีบอัด xz แบบ “good”/“bad” ถ้าไม่รู้ว่าต้องมองหาอะไร ก็ไม่มีทางสังเกตเห็น
การใช้ ดิสโทร LTS อาจช่วยป้องกันได้ในระดับหนึ่ง ดูเหมือนว่า Slackware จะใช้ lzma หรือก็คือ
tar.xzในแพ็กเกจ แต่ release stable ล่าสุดยกเว้น-currentไม่มีปัญหานี้ถ้าอยากก้าวไปทางซอฟต์แวร์เสรีอีกขั้น Hyperbola GNU ก็ไม่มีปัญหานี้เช่นกัน
นอกจากนี้ Slackware
-currentก็ไม่ได้ลิงก์sshdกับ xz และไม่ได้ใช้ systemd ด้วย