6 คะแนน โดย GN⁺ 2024-02-03 | 1 ความคิดเห็น | แชร์ทาง WhatsApp
  • แม้แต่โปรแกรม C hello ขนาดเล็ก บน Linux ก็จะกลายเป็น ไฟล์ปฏิบัติการ ELF และสามารถตรวจดูโครงสร้างภายในได้โดยตรงด้วย readelf, nm, objdump
  • แกนสำคัญในการทำความเข้าใจไฟล์ปฏิบัติการคือ สัญลักษณ์ (symbols), เซกชัน และเซกเมนต์ ซึ่งรับผิดชอบเรื่องการเชื่อมโยงฟังก์ชัน การแยกโค้ด/ข้อมูล และการจัดวางหน่วยความจำตอนรันตามลำดับ
  • เมื่อใช้ objdump และ readelf จะตรวจดูไบต์และคุณสมบัติของเซกชันอย่าง .text, .rodata, .data, .bss, .interp ได้
  • โปรแกรมไม่ได้เริ่มจาก main ทันที แต่จะเข้าสู่ _start ก่อน จากนั้นจึงผ่านงานตั้งต้นหลายอย่างแล้วเรียก main
  • ไฟล์ปฏิบัติการไม่ใช่ “ก้อนข้อมูลที่อ่านไม่ได้” แต่เป็นไฟล์ที่มีรูปแบบกำหนดไว้ ดังนั้นเมื่อใช้เครื่องมือก็สามารถไล่ตามโค้ด สตริง และข้อมูลการลิงก์ได้เป็นขั้นตอน

ไฟล์ปฏิบัติการเป็นรูปแบบไฟล์ที่อ่านทำความเข้าใจได้

  • ไฟล์ปฏิบัติการที่คอมไพล์แล้ว ตอนแรกอาจดูเหมือน “ไบนารีมหัศจรรย์” ที่อ่านไม่ออก แต่จริง ๆ แล้วเป็น รูปแบบไฟล์ ที่เข้าใจได้
  • ตัวอย่างนี้มุ่งที่ ไบนารี ELF บน Linux และเพราะไบนารีขึ้นกับแพลตฟอร์ม คำอธิบายจึงผูกกับแพลตฟอร์มนี้ด้วย
  • โปรแกรม C ตัวอย่างที่ใช้มีดังนี้
#include <stdio.h>

int main() {
    printf("Penguin!\n");
}
  • คอมไพล์ด้วย gcc -o hello hello.c เพื่อสร้างไฟล์ปฏิบัติการ hello แล้วสำรวจภายใน
  • ลำดับโดยรวมจะดำเนินไปโดยมีแนวคิดหลักสามอย่างเป็นศูนย์กลาง
    • สัญลักษณ์ (symbols): ใช้หาตำแหน่งเมื่อเรียกฟังก์ชันที่นิยามไว้ที่อื่น เช่น printf
    • เซกชัน (sections): หน่วยที่แบ่งโค้ดและข้อมูล เช่น .text, .data, .rodata
    • เซกเมนต์ (segments): จัดกลุ่มเซกชันเป็นหน่วยสำหรับการจัดวางในหน่วยความจำตอนรัน

แม้เปิดเป็นข้อความก็ยังเห็นเบาะแส

  • หากเปิดไฟล์ปฏิบัติการโดยตรง เช่น cat hello ผลลัพธ์ส่วนใหญ่จะแสดงเหมือนตัวอักษรเพี้ยน
  • ถึงอย่างนั้นก็ยังพบสตริงอย่าง Penguin! และ ELF ในผลลัพธ์ได้
  • ELF คือชื่อรูปแบบไฟล์ของไบนารีนี้
  • เหตุผลที่ผลลัพธ์ส่วนใหญ่ยากต่อการอ่านสำหรับมนุษย์คือไฟล์ปฏิบัติการเป็นข้อมูลไบนารี

ตรวจดูชื่อฟังก์ชันและการเชื่อมโยงด้วยตารางสัญลักษณ์

  • readelf --symbols hello จะแสดง ตารางสัญลักษณ์ ของไฟล์ปฏิบัติการ
  • ในผลลัพธ์ตัวอย่างจะเห็นสัญลักษณ์สำคัญต่าง ๆ
    • main: แอดเดรสของฟังก์ชัน main() ที่เขียนขึ้น
    • puts@@GLIBC_2.2.5: ดูเหมือนเป็นการอ้างอิงที่เกี่ยวข้องกับ printf ที่เรียกในโค้ด โดยคาดว่าคอมไพเลอร์เปลี่ยนเป็น puts จากการทำ optimization
    • _start: สัญลักษณ์สำคัญที่เกี่ยวข้องกับการเริ่มโปรแกรม
  • โปรแกรมไม่ได้เริ่มจาก main ทันที แต่จริง ๆ แล้วเข้าสู่ _start
  • _start ทำงานสำคัญหลายอย่าง ซึ่งรวมถึงการเรียก main ด้วย

สัญลักษณ์ทำให้การเชื่อมโยงเป็นไปได้

  • หากเขียนฟังก์ชันชื่อ hello ในโปรแกรม โค้ดของฟังก์ชันนั้นในไบนารีที่คอมไพล์แล้วจะมี สัญลักษณ์ ชื่อ hello ติดอยู่
  • หากต้องการเรียกฟังก์ชันไลบรารีอย่าง printf จำเป็นต้องมีวิธีหาตำแหน่งของโค้ดฟังก์ชันนั้น
  • กระบวนการหาตำแหน่งฟังก์ชันเรียกว่า การลิงก์ (linking)
    • หากเกิดขึ้นทันทีหลังคอมไพล์ เรียกว่า static linking
    • หากเกิดขึ้นตอนโปรแกรมรัน เรียกว่า dynamic linking
  • libc มีฟังก์ชันต่าง ๆ ของ C standard library อยู่
  • แม้ nm จะแสดง “no symbols” สำหรับ libc แต่ก็สามารถดูสัญลักษณ์ได้ด้วย objdump -tT /lib/x86_64-linux-gnu/libc-2.15.so
  • ในตารางสัญลักษณ์ของ libc จะเห็นฟังก์ชันอย่าง sprintf, strlen, fork, exec
  • เราสามารถนึกภาพการทำงานของ dynamic linking ได้จากลำดับที่ hello เรียก puts แล้วค้นหาตำแหน่งของ puts ในตารางสัญลักษณ์ของ libc

เซกชันแบ่งโค้ดและข้อมูล

  • objdump -s hello จะแสดงไบต์ที่อยู่ในแต่ละ เซกชัน ของไฟล์ปฏิบัติการเป็นเลขฐานสิบหกและ ASCII
  • เซกชันหลักมีดังนี้
    • .text: โค้ดจริงของโปรแกรม หรือก็คือ assembly อยู่ในนี้ และรวม _start กับ main
    • .rodata: ข้อมูลแบบอ่านอย่างเดียวอยู่ในนี้ ในตัวอย่างมีสตริง "Penguin!"
    • .interp: มีชื่อไฟล์ของ dynamic linker
  • เซกชันและเซกเมนต์ถูกใช้คนละช่วงเวลา
    • เซกชัน ถูกใช้โดย ld ตอนลิงก์
    • เซกเมนต์ ถูกใช้ตอนรัน
  • ใช้ readelf --sections hello เพื่อดูเมทาดาทาของเซกชันอย่างละเอียดมากขึ้นได้
  • เมื่อดูแฟล็กในตัวอย่างจะเห็นลักษณะของแต่ละเซกชัน
    • .text: รันได้และอ่านอย่างเดียว
    • .rodata: อ่านอย่างเดียว
    • .data: อ่าน/เขียนได้
    • .bss: พื้นที่ข้อมูลที่เขียนได้

ใช้ disassembly เพื่อดู machine code เป็น assembly

  • ในเซกชัน .text มีไบต์ที่ CPU ตีความเป็นโค้ดและนำไปรัน
  • ไบต์เริ่มต้นของ .text ในตัวอย่างคือ 31 ed ซึ่งมนุษย์เข้าใจความหมายทันทีได้ยาก จึงต้องใช้ disassembler
  • objdump -d ./hello จะ disassemble เซกชัน .text และแสดงเป็นคำสั่ง assembly
  • ในผลลัพธ์ตัวอย่าง 31 ed แสดงเป็น xor %ebp,%ebp
  • ด้วยวิธีนี้ เราสามารถตรวจได้ว่าไบต์โค้ดในไบนารีเป็นคำสั่ง assembly อะไร

เซกเมนต์กำหนดการจัดวางหน่วยความจำตอนรัน

  • ไฟล์ปฏิบัติการยังประกอบด้วย เซกเมนต์ หรือ program headers ด้วย
  • readelf --segments hello จะแสดงเซกเมนต์ของโปรแกรมและการแมปเซกชันกับเซกเมนต์
  • เซกเมนต์ใช้กำหนดว่าจะจัดวางแต่ละส่วนของโปรแกรมลงในหน่วยความจำอย่างไร
  • ในตัวอย่างมีเซกเมนต์ LOAD หลักสองตัว
    • LOAD ตัวแรก: แสดงเป็น R E หมายถึงอ่าน/รันได้
    • LOAD ตัวที่สอง: แสดงเป็น RW หมายถึงอ่าน/เขียนได้
  • .text ต้องถูกอ่านและรัน แต่ไม่ควรถูกเขียน จึงอยู่ในเซกเมนต์แรก
  • .data และ .bss ต้องเขียนได้ แต่ไม่จำเป็นต้องรัน จึงอยู่ในเซกเมนต์ที่สอง

เครื่องมือและข้อมูลเพิ่มเติมสำหรับสำรวจต่อ

  • ไฟล์ปฏิบัติการ ELF ไม่ใช่เวทมนตร์พิเศษ แต่เป็นรูปแบบไฟล์ทั่วไป และไบนารี Linux สามารถตรวจสอบได้ด้วย readelf, nm, objdump
  • ข้อมูลที่เกี่ยวข้อง

1 ความคิดเห็น

 
GN⁺ 2024-02-03
ความคิดเห็นจาก Hacker News
  • อย่างที่พูดไว้ในเธรดอื่น https://news.ycombinator.com/item?id=38847750#38862450 ขอแนะนำอย่างยิ่งให้ลอง เขียน ELF ด้วยมือสักครั้ง
    เป็นการฝึกที่ดีในการทำความเข้าใจองค์ประกอบพื้นฐานของไฟล์ executable และยังช่วยได้เมื่ออยากเข้าหาเรื่องนี้แบบ จากล่างขึ้นบน แทนที่จะเป็นจากบนลงล่างเหมือนบทความนี้
    ในหลายเธรดของโพสต์ HN อีกอันนั้นก็มีการถกเถียงดี ๆ อยู่มาก

    • เมื่อไม่นานมานี้ ผมได้ลอง เขียนไฟล์ ELF เอง: https://github.com/avik-das/garlic/blob/master/recursive/elf...
      และยังทำ visualization แบบ interactive ที่แสดงไบต์ของไฟล์ เพื่ออธิบายรูปแบบนี้ให้ทั้งตัวเองและคนอื่นเข้าใจ
      คลิกที่ไบต์แล้วจะมีคำอธิบายขึ้นมา และไบต์ที่เกี่ยวข้องภายในไฟล์จะถูกไฮไลต์ ช่วยให้เข้าใจได้ง่ายขึ้น: https://scratchpad.avikdas.com/elf-explanation/elf-explanati...
    • ในทำนองเดียวกัน แนะนำให้ลองเขียน ELF loader แบบง่าย ๆ ด้วย
      dynamic linking มีความซับซ้อนในการทำงานพอสมควร แต่ถ้ารองรับเฉพาะ static ELF ก็ถือว่าค่อนข้างตรงไปตรงมา
    • การแก้ไข ELF ที่มีอยู่แล้วก็ให้ความรู้และสนุกมาก
      ช่วงแรกจะน่าหงุดหงิดเพราะแทบดีบักไม่ได้เลยเมื่อมันยังไม่ทำงาน แต่พอในที่สุดมันเริ่มทำงานได้ ก็เจ๋งมากจริง ๆ
      ELF สามารถ patch ได้ในรูปแบบที่น่าสนใจ และถ้าใช้ auxiliary vector ก็ทำ self-observation ระหว่างรันได้ด้วย
      เพราะ Linux ให้ที่อยู่ของ program header table มา จึงไปต่อจากตรงนั้นได้ทุกที่ และเพียงขยาย LOAD segment ให้ครอบคลุมทั้งไบนารีก็พอ
      ตัวอย่างเช่น ผมเคยทำเครื่องมือที่แทรกโมดูล Lisp และโค้ดเข้าไปโดยตรงในไฟล์ executable ของ Lisp interpreter ของผม
      segment ที่แทรกเข้าไปจะถูกโหลดโดยอัตโนมัติจาก ELF และ interpreter จะค้นหาแล้วรันมัน
      ผมชอบฟีเจอร์เล็ก ๆ นี้มากจนเขียนบทความไว้ด้วย: https://www.matheusmoreira.com/articles/self-contained-lone-...
      อยากให้ภาษากระแสหลักนำแนวทางแบบนี้ไปใช้บ้าง
    • ถ้าลองทำเอง จะพบว่ามันแทบจะเป็น การประกอบ assembly ด้วยมือ
      อ่านเอกสาร เลือกไบต์ที่ต้องใช้จาก datasheet ของโปรเซสเซอร์ จากนั้นวางลงในหลาย section ตามลำดับ เติมฟิลด์ของ ELF สุดท้ายก็กลายเป็นงานพิมพ์ทุกอย่างลงไปเอง
      ในยุคก่อน ELF อย่างสภาพแวดล้อม 8-bit Apple II นั้น machine-language monitor เปิดให้ป้อน byte ของโปรแกรมได้โดยตรง แล้ว byte เหล่านั้นก็ถูกรัน
      การบันทึกลงดิสก์ก็แค่ซับซ้อนขึ้นอีกนิด และตรงนี้ก็มีโอกาสอีกเช่นกัน
      คุณสามารถสร้างไฟล์ด้วย disk sector editor ได้ แล้วเรื่องก็จะต่อยอดไปเรื่อย ๆ
    • A Magnetized Needle and a Steady Hand ของ Chris Wellons เป็นบทความเกี่ยวกับการสร้าง ELF executable ตั้งแต่ศูนย์: https://nullprogram.com/blog/2016/11/17/
  • ตามความเข้าใจของผม สัญลักษณ์ main เป็นสิ่งที่ เฉพาะกับ C
    สัญลักษณ์ _start คือ entry point ของไบนารีที่ไม่ขึ้นกับภาษา และในกรณีนี้มันจะเรียก main
    ถ้ามี convention ที่เรียก entry point ว่า _start แล้วส่ง argc/argv ของ main ให้ตรงนั้น รูปแบบนี้คงยืดหยุ่นน้อยลงมาก

    • พูดให้เคร่งครัดแล้ว ชื่อ _start เองก็ไม่ได้พิเศษ
      ไบนารีจะเขียน ที่อยู่ entry point ไว้ใน header และระบบปฏิบัติการจะเริ่มรันจากที่อยู่นั้น
      การเรียกสัญลักษณ์นั้นว่า _start เป็นแค่ convention ของ C และภาษาอื่น ๆ และ linker ใช้มันเพื่อตั้งค่า entry point ตอนเขียน ELF header
      ถ้าเขียน linker script เอง ก็สามารถตั้งชื่อ entry point ได้ตามต้องการ
    • สัญลักษณ์ main นั้นมีให้เฉพาะใน hosted C ตามที่ว่า
      ใน freestanding C คุณสามารถมี entry point ตามต้องการได้
      _start ก็เป็นเพียงค่า default ของ linker เท่านั้น และสามารถระบุสัญลักษณ์ที่ดีกว่าได้ด้วย -Wl,--entry="${symbol}" อีกทั้ง GCC ยังรองรับการตั้งค่าโดยตรงโดยไม่ต้องใช้ -Wl ที่ดูรกตา
      นอกจากนี้ entry point จริง ๆ แล้วไม่ใช่สัญลักษณ์ แต่เป็น pointer
      linker แค่เอาที่อยู่ของสัญลักษณ์ที่ระบุมา แล้วตั้งเป็น entry point ของ ELF
      บน stack ไม่ได้มีแค่จำนวนอาร์กิวเมนต์และ argument vector แต่ยังมี environment vector และ auxiliary vector ด้วย
      โค้ดเริ่มต้นของโปรเซสอาจเรียบง่ายแค่ดึงค่าเหล่านี้จาก stack ใส่ลงใน register ที่เหมาะสม แล้วเรียกฟังก์ชัน C ที่ต้องการ
      entry point เองไม่ใช่ฟังก์ชัน จึงไม่มีที่ให้ return กลับไป
      โค้ด entry point ต้องจบด้วย system call exit เพื่อให้โปรเซสปิดตัวอย่างสะอาดเมื่อ main return status code
      อย่างน้อยบน Linux มันทำงานประมาณนี้
    • แม้จะขึ้นกับ runtime ของภาษา แต่งานที่พบบ่อยอย่างหนึ่งคือการ initialize ค่า global static ที่ไม่ใช่ 0
      ในภาษาอย่าง Rust/C/C++ ยังสามารถ inject ตัวแปรที่จะ initialize ผ่าน linker flag ได้ด้วย
      ถ้าโปรแกรมถูก dynamic link ผมเข้าใจว่า runtime ของ linker จะรันก่อน _start เพื่อ resolve link แล้วจึงส่งการควบคุมต่อให้ _start
      สุดท้ายแล้วมันคือ hack ซ้อน hack ที่ค่อย ๆ ถูกเติมเข้ามาแบบ organic เพื่อให้ขยายต่อได้ และเพราะสังคมยอมรับมากพอและมันทำงานได้ดีพอ เราจึงยังใช้มันต่อไป
  • ผมเริ่มเขียนบล็อกในปี 2012 ตอนที่เปลี่ยนเส้นทางทางวิชาการจากคณิตศาสตร์มาเป็นวิทยาการคอมพิวเตอร์ และหัวข้อนี้ก็เป็นสิ่งแรก ๆ ที่ผมศึกษาอย่างแท้จริง: https://heinrichhartmann.com/archive/Dissecting-Hello-World....
    ไม่เคยเสียใจเลยที่ได้ลงไปในโพรงกระต่ายอันลึกนี้
    ถ้าจำไม่ผิด Julia ก็มีพื้นฐานด้านคณิตศาสตร์เหมือนกัน
    บางทีเหตุผลที่คนสายคณิตศาสตร์ถูกดึงดูดเข้าหาการทดลองแบบนี้ อาจเป็นเพราะความต้องการที่จะ อนุมานจากฐานราก
    ดีใจที่เธอทำให้เรื่องนี้เข้าถึงได้สำหรับคนจำนวนมาก

  • งานเขียนของ Julia ยอดเยี่ยมเสมอ
    เวลาสอนว่าโค้ดที่คอมไพล์แล้วไม่สามารถซ่อนความลับได้ การสาธิต strings มักได้ผลดีเสมอ

    • ควรลองอธิบายแบบนั้นให้ผู้พิพากษาเยอรมันฟังด้วย
      มีคนโชคร้ายคนหนึ่งถูกปรับ เพราะพบรหัสผ่านด้วยวิธีเดียวกับการรัน strings กับไบนารี
      ผู้พิพากษามองว่าเขาได้ “หลีกเลี่ยง” มาตรการรักษาความปลอดภัย ของซอฟต์แวร์: https://www.theregister.com/2024/01/19/germany_fine_security...
  • ไม่ได้วิจารณ์หรือจับผิด แค่เป็นความคิดที่ผุดขึ้นมา
    พอเห็นประโยคว่า “ไบนารีแทบจะเป็นนิยามของความเฉพาะแพลตฟอร์ม ดังนั้นเนื้อหานี้ทั้งหมดก็เฉพาะแพลตฟอร์ม” ก็ทำให้นึกถึงตอนที่ Actually Portable Executable แสดงให้เห็นว่าไบนารีเดียวกันสามารถรันได้บนหลายแพลตฟอร์ม
    จนถึงตอนนี้ผมก็ยังไม่ฟื้นตัวทางใจจากช่วงเวลาที่เหนือจริงนั้นอย่างสมบูรณ์
    เป็นเวลาหลายสิบปีที่เราพยายามแก้ปัญหา cross-platform ด้วยสารพัดวิธีที่แตกแขนงเหมือนแฟรกทัล ทั้ง Java, ไลบรารีข้ามแพลตฟอร์ม ฯลฯ แต่กลายเป็นว่าคำตอบอยู่ตรงหน้าเรามาตลอด

    • โดยส่วนตัวแล้วผมไม่แน่ใจว่า ไบนารีแบบพกพาได้ ให้ผลสุทธิที่ดีหรือไม่
      ในยุคที่คอมพิวเตอร์เร็ว ผมมองว่าการแจกจ่ายซอร์สและคอมไพล์ในเครื่องดีกว่าการแจกจ่ายไบนารี
      น่าเสียดายที่ซอฟต์แวร์จำนวนมากที่เราพึ่งพานั้นใหญ่เกินไป และคอมไพเลอร์ก็ค่อนข้างช้า ทำให้การแจกจ่ายไบนารีกลายเป็นสิ่งจำเป็นที่ไม่ค่อยดีนัก
      ผมอยากให้มีความพยายามมากขึ้นกับองค์ประกอบซอฟต์แวร์ที่เรียบง่ายกว่าและคอมไพล์ได้เร็วโดยธรรมชาติ รวมถึงคอมไพเลอร์ที่เร็วขึ้น มากกว่าไบนารีแบบพกพาได้
    • ผมอาจเข้าใจผิดก็ได้ แต่ APE ดูเหมือนไม่ใช่ รูปแบบไบนารี ในตัวมันเอง
      มันเป็นสคริปต์ที่สามารถรันได้บนระบบใดก็ได้ และสคริปต์นั้นสามารถโหลดไบนารีได้
      ถ้าจำไม่ผิด เวอร์ชันดั้งเดิมต้องถอดรหัสจาก base64 ก่อนโหลด
      ดังนั้นมันจึงใกล้เคียงกับ ตัวโหลดไบนารี ที่รันได้มากกว่า
  • ช่วงต้นทศวรรษ 1990 ผมหลงใหลรูปแบบไฟล์ปฏิบัติการ และใช้เวลาหลายสัปดาห์สร้างตัวดูไฟล์ปฏิบัติการของ DOS และ Windows ด้วย Modula 2 ตั้งชื่อว่า VEXE แล้วเผยแพร่เป็นแชร์แวร์ในปี 1991
    เครื่องมือนี้ได้รับความนิยมแบบเฉพาะกลุ่มในหมู่แคร็กเกอร์ และถึงขั้นถูกกล่าวถึงในบทเรียนของ +ORC: https://gist.github.com/callowaysutton/48bdf0245e17e72d41a15...
    น่าจะเพราะมันตรวจจับวิธีเข้ารหัสและบีบอัดหลายแบบที่ใช้เพื่อป้องกันการทำ reverse engineering โปรแกรมได้

  • ถ้าสงสัยว่าไฟล์ไบนารี ELF จะเล็กได้แค่ไหน คุณอาจชอบบทความสนุก ๆ นี้: https://www.muppetlabs.com/~breadbox/software/tiny/teensy.ht...

  • ลองดูเครื่องมือของผมที่ทำให้สามารถ สำรวจ ELF ด้วย SQL ได้ด้วย: https://github.com/fzakaria/sqlelf

  • มีใครแนะนำแหล่งเรียนรู้หรือหนังสือสำหรับเริ่มต้น การเขียนโปรแกรมระดับต่ำ แบบใช้งานได้จริง สำหรับคนที่พื้นฐาน Python แข็งแรงได้ไหม
    ช่วงนี้ผมเพิ่งเริ่มเรียน Rust และตระหนักว่ามีหลายอย่างที่ต้องตามให้ทัน
    ผมไม่เคยลงวิชาคอมไพเลอร์ เลยอาจพลาดข้อมูลไปมาก
    เช่น ผมไม่รู้ด้วยซ้ำว่าในไบนารีมีสิ่งที่เรียกว่า symbol และก็ไม่รู้ความแตกต่างระหว่าง ELF กับ Mach-O

  • การ cat ไบนารีลงเทอร์มินัลเป็นทางลัดสู่ความเศร้า
    ผมชอบ | hd ซึ่งจริง ๆ ก็คือ hexdump -C และถ้ามองด้วยตาเปล่าก็อ่านยากพอ ๆ กัน