6 điểm bởi GN⁺ 2024-02-03 | 1 bình luận | Chia sẻ qua WhatsApp
  • 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 objdumpreadelf 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!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
  • 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 _startmain
    • .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.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

1 bình luận

 
GN⁺ 2024-02-03
Ý 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

    • Gần đây mình đã tự viết một file ELF: https://github.com/avik-das/garlic/blob/master/recursive/elf...
      Để 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...
    • Tương tự, mình cũng khuyên nên thử viết một ELF loader đơn giản
      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
    • Việc chỉnh sửa một ELF có sẵn cũng rất mang tính giáo dục và thú vị
      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
    • Khi tự làm thì về cơ bản nó gần như là assembly thủ công
      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
    • A Magnetized Needle and a Steady Hand của Chris Wellons là một bài viết về cách tạo file thực thi ELF từ đầu: https://nullprogram.com/blog/2016/11/17/
  • Theo hiểu biết của mình, symbol mainthứ dành riêng cho C
    Symbol _start là điểm vào binary độc lập với ngôn ngữ, và trong trường hợp này nó sẽ gọi main
    Nếu có một quy ước là gọi điểm vào là _start và truyền argc/argv của main vào đó, thì định dạng này đã kém linh hoạt hơn rất nhiều

    • Nói chính xác thì ngay cả cái tên _start cũng không đặc biệt
      Binary 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à _start chỉ 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 header
      Nếu bạn tự viết linker script thì có thể đặt tên entry point theo ý mình
    • Symbol main đúng là chỉ được cung cấp trong hosted C
      Với freestanding C thì bạn có thể có entry point theo ý muốn
      _start cũ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 -Wl khó 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 khi main trả về mã trạng thái
      Ít nhất trên Linux là nó hoạt động như vậy
    • Tùy runtime của ngôn ngữ, nhưng một trong những việc thường gặp là khởi tạo các giá trị static toàn cục khác 0
      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 _start
      Rố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 strings luôn rất hiệu quả

    • Chắc cũng nên giải thích như vậy cho các thẩm phán Đức
      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 strings trên một tệp nhị phân
      Cá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

    • Cá nhân tôi không chắc nhị phân di động có thực sự mang lại hiệu quả ròng tích cực hay không
      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
    • Có thể tôi hiểu sai, nhưng APE dường như bản thân nó không phải là một định dạng nhị phân
      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

  • cat một tệp nhị phân ra terminal là con đường tắt dẫn tới nỗi buồn
    Tô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