- แม้แต่โปรแกรม 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 ความคิดเห็น
ความคิดเห็นจาก Hacker News
อย่างที่พูดไว้ในเธรดอื่น https://news.ycombinator.com/item?id=38847750#38862450 ขอแนะนำอย่างยิ่งให้ลอง เขียน ELF ด้วยมือสักครั้ง
เป็นการฝึกที่ดีในการทำความเข้าใจองค์ประกอบพื้นฐานของไฟล์ executable และยังช่วยได้เมื่ออยากเข้าหาเรื่องนี้แบบ จากล่างขึ้นบน แทนที่จะเป็นจากบนลงล่างเหมือนบทความนี้
ในหลายเธรดของโพสต์ HN อีกอันนั้นก็มีการถกเถียงดี ๆ อยู่มาก
และยังทำ visualization แบบ interactive ที่แสดงไบต์ของไฟล์ เพื่ออธิบายรูปแบบนี้ให้ทั้งตัวเองและคนอื่นเข้าใจ
คลิกที่ไบต์แล้วจะมีคำอธิบายขึ้นมา และไบต์ที่เกี่ยวข้องภายในไฟล์จะถูกไฮไลต์ ช่วยให้เข้าใจได้ง่ายขึ้น: https://scratchpad.avikdas.com/elf-explanation/elf-explanati...
dynamic linking มีความซับซ้อนในการทำงานพอสมควร แต่ถ้ารองรับเฉพาะ static 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-...
อยากให้ภาษากระแสหลักนำแนวทางแบบนี้ไปใช้บ้าง
อ่านเอกสาร เลือกไบต์ที่ต้องใช้จาก datasheet ของโปรเซสเซอร์ จากนั้นวางลงในหลาย section ตามลำดับ เติมฟิลด์ของ ELF สุดท้ายก็กลายเป็นงานพิมพ์ทุกอย่างลงไปเอง
ในยุคก่อน ELF อย่างสภาพแวดล้อม 8-bit Apple II นั้น machine-language monitor เปิดให้ป้อน byte ของโปรแกรมได้โดยตรง แล้ว byte เหล่านั้นก็ถูกรัน
การบันทึกลงดิสก์ก็แค่ซับซ้อนขึ้นอีกนิด และตรงนี้ก็มีโอกาสอีกเช่นกัน
คุณสามารถสร้างไฟล์ด้วย disk sector editor ได้ แล้วเรื่องก็จะต่อยอดไปเรื่อย ๆ
ตามความเข้าใจของผม สัญลักษณ์
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เพื่อให้โปรเซสปิดตัวอย่างสะอาดเมื่อmainreturn status codeอย่างน้อยบน Linux มันทำงานประมาณนี้
ในภาษาอย่าง 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, ไลบรารีข้ามแพลตฟอร์ม ฯลฯ แต่กลายเป็นว่าคำตอบอยู่ตรงหน้าเรามาตลอด
ในยุคที่คอมพิวเตอร์เร็ว ผมมองว่าการแจกจ่ายซอร์สและคอมไพล์ในเครื่องดีกว่าการแจกจ่ายไบนารี
น่าเสียดายที่ซอฟต์แวร์จำนวนมากที่เราพึ่งพานั้นใหญ่เกินไป และคอมไพเลอร์ก็ค่อนข้างช้า ทำให้การแจกจ่ายไบนารีกลายเป็นสิ่งจำเป็นที่ไม่ค่อยดีนัก
ผมอยากให้มีความพยายามมากขึ้นกับองค์ประกอบซอฟต์แวร์ที่เรียบง่ายกว่าและคอมไพล์ได้เร็วโดยธรรมชาติ รวมถึงคอมไพเลอร์ที่เร็วขึ้น มากกว่าไบนารีแบบพกพาได้
มันเป็นสคริปต์ที่สามารถรันได้บนระบบใดก็ได้ และสคริปต์นั้นสามารถโหลดไบนารีได้
ถ้าจำไม่ผิด เวอร์ชันดั้งเดิมต้องถอดรหัสจาก 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และถ้ามองด้วยตาเปล่าก็อ่านยากพอ ๆ กัน