- Ngay cả một chương trình C
hello nhỏ trên Linux cũng trở thành tệp thực thi ELF, và có thể trực tiếp xem cấu trúc bên trong bằng readelf, nm, objdump
- Các trục cốt lõi để hiểu tệp thực thi là symbol, section và segment; chúng lần lượt đảm nhiệm việc liên kết hàm, phân tách code/data, và bố trí bộ nhớ khi chạy
- Dùng
objdump và readelf có thể xem byte và thuộc tính của các section như .text, .rodata, .data, .bss, .interp
- Chương trình không bắt đầu ngay từ
main, mà đi vào _start trước, rồi sau nhiều tác vụ khởi tạo mới gọi main
- Tệp thực thi không phải là “một khối không thể đọc”, mà là tệp theo một định dạng xác định; nếu dùng công cụ, có thể lần lượt truy vết code, chuỗi và thông tin linking
Tệp thực thi là một định dạng tệp có thể đọc được
- Tệp thực thi đã biên dịch ban đầu trông như một “binary ma thuật” không thể đọc, nhưng thực ra là một định dạng tệp có thể hiểu được
- Ví dụ nhắm đến binary ELF trên Linux; vì binary phụ thuộc nền tảng nên phần giải thích cũng gắn với nền tảng đó
- Ví dụ được dùng là chương trình C sau
#include <stdio.h>
int main() {
printf("Penguin!\n");
}
- Biên dịch bằng
gcc -o hello hello.c để tạo tệp thực thi hello, rồi xem bên trong
- Luồng tổng thể xoay quanh ba khái niệm
- symbol: dùng để tìm vị trí khi gọi các hàm được định nghĩa ở nơi khác, như
printf
- section: đơn vị phân chia code và data, gồm
.text, .data, .rodata, v.v.
- segment: gom các section thành đơn vị bố trí bộ nhớ tại thời điểm thực thi
Mở dưới dạng văn bản vẫn thấy được manh mối
- Nếu mở trực tiếp tệp thực thi như
cat hello, phần lớn sẽ được in ra như ký tự bị lỗi
- Tuy vậy, vẫn có thể tìm thấy các chuỗi như
Penguin! và ELF trong phần output
- ELF là tên định dạng tệp của binary này
- Lý do phần lớn output khó đọc với con người là vì tệp thực thi là dữ liệu nhị phân
Kiểm tra tên hàm và liên kết qua bảng symbol
readelf --symbols hello in ra bảng symbol của tệp thực thi
- Trong output ví dụ có các symbol chính
main: địa chỉ của hàm main() đã viết
puts@@GLIBC_2.2.5: có vẻ là tham chiếu liên quan đến printf được gọi trong code; có thể trình biên dịch đã tối ưu và thay bằng puts
_start: symbol quan trọng liên quan đến điểm bắt đầu chương trình
- Chương trình không bắt đầu ngay từ
main; thực tế nó đi vào _start
_start thực hiện nhiều tác vụ quan trọng, trong đó có cả việc gọi main
Symbol giúp việc liên kết trở nên khả thi
- Nếu viết một hàm tên
hello trong chương trình, code của hàm đó trong binary đã biên dịch sẽ được gắn symbol tên hello
- Muốn gọi hàm thư viện
printf, cần có cách tìm vị trí code của hàm đó
- Quá trình tìm vị trí hàm là linking
- Nếu diễn ra ngay sau khi biên dịch thì là static linking
- Nếu diễn ra tại thời điểm chạy chương trình thì là dynamic linking
libc chứa các hàm thư viện chuẩn C
- Dù
nm in ra “no symbols” với libc, vẫn có thể xem symbol bằng objdump -tT /lib/x86_64-linux-gnu/libc-2.15.so
- Trong bảng symbol của libc, có thể thấy các hàm như
sprintf, strlen, fork, exec
- Có thể hình dung cách dynamic linking hoạt động qua luồng
hello gọi puts rồi tìm vị trí puts trong bảng symbol của libc
Section phân chia code và data
objdump -s hello in ra các byte trong từng section của tệp thực thi dưới dạng hex và ASCII
- Các section chính như sau
.text: code thực tế của chương trình, tức assembly; chứa _start và main
.rodata: chứa dữ liệu chỉ đọc; trong ví dụ có chuỗi "Penguin!"
.interp: chứa tên tệp của dynamic linker
- Section và segment được dùng ở các thời điểm khác nhau
- Section được
ld dùng tại thời điểm link
- Segment được dùng tại thời điểm thực thi
- Có thể xem metadata của section chi tiết hơn bằng
readelf --sections hello
- Nhìn vào các flag trong ví dụ có thể thấy tính chất của từng section
.text: có thể thực thi và chỉ đọc
.rodata: chỉ đọc
.data: có thể đọc/ghi
.bss: vùng dữ liệu có thể ghi
Xem mã máy dưới dạng assembly bằng disassembly
- Section
.text chứa các byte mà CPU diễn giải và thực thi như code
- Byte bắt đầu
.text trong ví dụ là 31 ed; con người khó hiểu ngay ý nghĩa nên cần disassembler
objdump -d ./hello disassemble section .text và hiển thị dưới dạng lệnh assembly
- Trong output ví dụ,
31 ed được hiển thị là xor %ebp,%ebp
- Bằng cách này có thể kiểm tra các byte code trong binary tương ứng với lệnh assembly nào
Segment xác định bố trí bộ nhớ khi thực thi
- Tệp thực thi cũng được cấu thành từ segment, hay program headers
readelf --segments hello hiển thị các segment của chương trình và ánh xạ section-segment
- Segment được dùng để quyết định cách chia và bố trí từng phần của chương trình trong bộ nhớ
- Ví dụ có hai segment
LOAD chính
LOAD thứ nhất: được đánh dấu R E, có thể đọc/thực thi
LOAD thứ hai: được đánh dấu RW, có thể đọc/ghi
.text cần được đọc và thực thi nhưng không được ghi, nên nằm trong segment thứ nhất
.data và .bss cần có thể ghi nhưng không cần thực thi, nên nằm trong segment thứ hai
Công cụ và tài liệu để tìm hiểu thêm
- Tệp thực thi ELF không phải là phép màu đặc biệt mà là một định dạng tệp thông thường; binary Linux có thể được khảo sát bằng
readelf, nm, objdump
- Tài liệu liên quan
1 bình luận
Ý kiến trên Hacker News
Như đã nói ở các luồng khác https://news.ycombinator.com/item?id=38847750#38862450, mình cực kỳ khuyến nghị tự viết ELF bằng tay một lần
Đây là một bài tập tốt để hiểu các thành phần cơ bản của file thực thi, và cũng hữu ích nếu bạn muốn tiếp cận từ dưới lên trên thay vì từ trên xuống dưới như bài viết này
Trong nhiều luồng của bài HN kia cũng có rất nhiều thảo luận hay
Để tự giải thích định dạng này cho bản thân và cho người khác, mình còn làm một trình trực quan tương tác hiển thị từng byte của file
Bạn có thể bấm vào các byte để xem giải thích, và các byte liên quan trong file sẽ được tô sáng để dễ hiểu hơn: https://scratchpad.avikdas.com/elf-explanation/elf-explanati...
Liên kết động có độ phức tạp triển khai khá cao, nhưng nếu chỉ hỗ trợ ELF tĩnh thì khá trực quan
Lúc đầu khi nó chưa chạy thì gần như không thể debug nên khá bực, nhưng đến khi cuối cùng nó chạy được thì thực sự rất ngầu
ELF có thể được vá theo những cách rất thú vị, và dùng auxiliary vector thì còn có thể tự quan sát khi đang chạy
Vì Linux cung cấp địa chỉ bảng program header nên từ đó có thể đi tới bất kỳ đâu, và chỉ cần mở rộng LOAD segment để bao trùm toàn bộ binary
Ví dụ, mình đã tạo một công cụ để nhúng trực tiếp module và mã Lisp vào file thực thi của trình thông dịch Lisp của mình
Segment được nhúng sẽ được ELF tự động nạp, và trình thông dịch sẽ tìm nó rồi thực thi
Mình thích tính năng nhỏ này đến mức còn viết cả bài về nó: https://www.matheusmoreira.com/articles/self-contained-lone-...
Sẽ thật hay nếu các ngôn ngữ phổ biến cũng áp dụng cách này
Bạn đọc tài liệu, chọn các byte cần thiết từ datasheet của bộ xử lý, sắp chúng vào các section theo đúng thứ tự, điền các trường ELF, và cuối cùng tất cả đều quy về việc nhập từng thứ vào
Trong thời kỳ trước ELF, ở các môi trường như Apple II 8-bit, machine code monitor cho phép nhập trực tiếp các byte chương trình, và chính các byte đó sẽ được thực thi
Việc lưu xuống đĩa chỉ phức tạp hơn một chút, và ở đây lại có thêm cơ hội khác
Bạn có thể tạo file bằng trình chỉnh sửa sector đĩa, và cứ thế tiếp diễn
Theo hiểu biết của mình, symbol
mainlà thứ dành riêng cho CSymbol
_startlà điểm vào binary độc lập với ngôn ngữ, và trong trường hợp này nó sẽ gọimainNếu có một quy ước là gọi điểm vào là
_startvà truyềnargc/argvcủamainvào đó, thì định dạng này đã kém linh hoạt hơn rất nhiều_startcũng không đặc biệtBinary ghi địa chỉ entry point trong header, và hệ điều hành sẽ bắt đầu thực thi từ địa chỉ đó
Việc gọi symbol đó là
_startchỉ là quy ước trong C và các ngôn ngữ khác, và linker dùng nó để thiết lập entry point khi ghi ELF headerNếu bạn tự viết linker script thì có thể đặt tên entry point theo ý mình
mainđúng là chỉ được cung cấp trong hosted CVới freestanding C thì bạn có thể có entry point theo ý muốn
_startcũng chỉ là giá trị mặc định của linker, và bạn có thể chỉ định symbol tốt hơn bằng-Wl,--entry="${symbol}", ngoài ra GCC còn hỗ trợ thiết lập trực tiếp mà không cần cái-Wlkhó nhìn đóNgoài ra, entry point thực ra không phải là symbol mà là một con trỏ
Linker chỉ lấy địa chỉ của symbol được chỉ định rồi đặt nó làm entry point của ELF
Không chỉ số lượng đối số và vector đối số, mà trên stack còn có cả environment vector và auxiliary vector
Mã khởi động tiến trình chỉ cần lấy các giá trị này từ stack, đặt chúng vào các thanh ghi phù hợp, rồi gọi hàm C mong muốn là đủ đơn giản
Bản thân entry point không phải là một hàm nên không có nơi nào để quay về
Mã entry point phải kết thúc bằng lời gọi hệ thống
exitđể tiến trình thoát gọn gàng khimaintrả về mã trạng tháiÍt nhất trên Linux là nó hoạt động như vậy
Với các ngôn ngữ như Rust/C/C++, bạn thậm chí có thể chèn các biến cần khởi tạo thông qua linker flag
Nếu chương trình được liên kết động, thì theo mình hiểu runtime của linker sẽ chạy trước
_start, giải quyết liên kết xong rồi mới chuyển quyền điều khiển cho_startRốt cuộc đây đều là những lớp vá chồng lên lớp vá được bổ sung một cách tự phát để cung cấp khả năng mở rộng, và vì về mặt xã hội chúng đã được chấp nhận đủ nhiều và hoạt động đủ tốt nên ta vẫn tiếp tục dùng chúng
Năm 2012, khi chuyển hướng học thuật từ toán học sang khoa học máy tính, tôi bắt đầu viết blog, và chủ đề này đúng nghĩa là thứ đầu tiên tôi học: https://heinrichhartmann.com/archive/Dissecting-Hello-World....
Tôi chưa từng hối hận vì đã chui xuống chiếc hang thỏ sâu này
Nếu nhớ không lầm thì Julia cũng có nền tảng toán học
Có lẽ lý do những người bên toán bị cuốn hút vào các thử nghiệm kiểu này là vì ham muốn suy luận từ tầng đáy
Thật vui vì cô ấy đã làm cho nội dung này trở nên dễ tiếp cận với nhiều người
Bài viết của Julia lúc nào cũng xuất sắc
Khi dạy rằng mã đã biên dịch không thể che giấu bí mật, việc trình diễn
stringsluôn rất hiệu quảCó một người tội nghiệp bị phạt chỉ vì tìm ra mật khẩu theo đúng kiểu chạy
stringstrên một tệp nhị phânCác thẩm phán cho rằng anh ta đã “vượt qua” biện pháp bảo mật của phần mềm: https://www.theregister.com/2024/01/19/germany_fine_security...
Không phải phê bình hay bắt bẻ gì, chỉ là một ý nghĩ vừa nảy ra
Câu “Vì nhị phân gần như là định nghĩa của tính đặc thù theo nền tảng, nên toàn bộ nội dung này cũng đều đặc thù theo nền tảng” làm tôi nhớ đến lúc Actually Portable Executable cho thấy cùng một tệp nhị phân có thể chạy trên nhiều nền tảng
Đến giờ tôi vẫn chưa hoàn toàn hồi phục về mặt tinh thần sau khoảnh khắc siêu thực đó
Suốt hàng chục năm, người ta cố giải bài toán đa nền tảng bằng đủ kiểu phân nhánh như Java, thư viện đa nền tảng, v.v., trong khi lời giải hóa ra luôn ở ngay trước mắt
Trong thời đại máy tính nhanh, tôi cho rằng phân phối mã nguồn và biên dịch cục bộ tốt hơn phân phối nhị phân
Đáng tiếc là phần mềm mà chúng ta phụ thuộc phần lớn lại quá lớn, còn trình biên dịch thì tương đối chậm, nên phân phối nhị phân trở thành một kiểu điều xấu cần thiết
Tôi muốn thấy nhiều nỗ lực hơn dành cho các thành phần phần mềm đơn giản hơn, vốn biên dịch nhanh một cách tự nhiên, và các trình biên dịch nhanh hơn, thay vì nhị phân di động
Nó là một script có thể chạy trên mọi hệ thống, và script đó có thể nạp nhị phân
Nếu nhớ đúng thì phiên bản ban đầu còn phải giải mã từ base64 trước khi nạp
Vì thế nó gần với một trình nạp nhị phân có thể thực thi hơn
Đầu thập niên 1990, tôi mê mẩn các định dạng tệp thực thi, nên đã dành vài tuần để làm một trình xem tệp thực thi DOS và Windows bằng Modula 2, đặt tên là VEXE, rồi phát hành dưới dạng shareware vào năm 1991
Công cụ này có được chút tiếng tăm ngách trong giới cracker, đến mức còn được nhắc trong hướng dẫn của +ORC: https://gist.github.com/callowaysutton/48bdf0245e17e72d41a15...
Có lẽ vì nó có thể phát hiện nhiều kiểu mã hóa và nén khác nhau được dùng để ngăn chặn việc dịch ngược chương trình
Nếu bạn tò mò tệp nhị phân ELF có thể nhỏ đến mức nào, có thể bạn sẽ thích bài viết thú vị này: https://www.muppetlabs.com/~breadbox/software/tiny/teensy.ht...
Bạn cũng có thể xem thử công cụ của tôi cho phép khám phá ELF bằng SQL: https://github.com/fzakaria/sqlelf
Có ai có thể gợi ý tài liệu hoặc sách nhập môn lập trình mức thấp mang tính thực hành cho người có nền tảng Python mạnh không
Gần đây tôi bắt đầu học Rust và nhận ra còn rất nhiều thứ phải bổ sung
Có lẽ vì tôi chưa từng học môn trình biên dịch nên đã bỏ lỡ khá nhiều thông tin
Ví dụ, trước đây tôi thậm chí còn không biết nhị phân có thứ gọi là symbol, cũng không biết ELF và Mach-O khác nhau thế nào
catmột tệp nhị phân ra terminal là con đường tắt dẫn tới nỗi buồnTôi thích
| hd, về cơ bản nó làhexdump -C, dù nhìn bằng mắt thường thì cũng khó hiểu y như vậy thôi