Học x86-64 Assembly
(gpfault.net)- Đây là bài đầu tiên trong chuỗi dành cho người mới bắt đầu với x86-64 assembly
- Cung cấp hướng dẫn cài đặt công cụ và giải thích cấu trúc cơ bản theo chuẩn của hệ thống 64-bit hiện đại
- Hướng dẫn sử dụng Flat Assembler (FASM) và WinDbg làm công cụ phát triển và gỡ lỗi chính
- Bao gồm phần tóm tắt các kiến thức cốt lõi cần cho thực tế như định dạng PE, import DLL và quy ước gọi hàm của Windows
- Giải thích theo hướng thực hành, xoay quanh việc viết một chương trình thoát đơn giản và trải nghiệm quy trình gỡ lỗi
Giới thiệu và ý nghĩa
- Khi lần đầu tiếp xúc với x86 assembly, tác giả từng học trong môi trường cũ ở đại học (16-bit, DOS, bộ nhớ phân đoạn)
- Ngày nay, bộ xử lý 64-bit đã trở thành chủ đạo, nên chuỗi bài này chỉ tập trung vào môi trường x86-64 đang được sử dụng thực tế, loại bỏ toàn bộ yếu tố cũ
- Hướng dẫn này tập trung vào việc phát triển chương trình 64-bit chạy trên hệ điều hành Windows
- Bắt đầu từ những đoạn mã tối thiểu truy cập trực tiếp vào OS mà không dùng thư viện
- Bài viết hướng đến các lập trình viên muốn học assembly từ đầu, và giả định người đọc có kiến thức cơ bản về C/C++
Chuẩn bị công cụ phát triển
Assembler
- CPU chỉ có thể diễn giải mã máy vốn rất khó hiểu với con người, còn assembly là dạng mã con người có thể đọc được tương ứng với nó
- Chương trình chuyển assembly sang mã máy được gọi là assembler
- x86-64 assembly không có một chuẩn duy nhất, nên cú pháp và cách hoạt động khác nhau tùy assembler
- Trong chuỗi này, tác giả dùng Flat Assembler (FASM) vì nó nhỏ gọn, dễ dùng, có hệ thống macro mạnh và đi kèm trình soạn thảo
Debugger
- Để phân tích mã assembly đã viết và quan sát luồng thực thi, debugger là công cụ bắt buộc
- Tác giả khuyến nghị WinDbg, vì có thể kiểm tra và thao tác độc lập với thanh ghi, bộ nhớ, mã assembly...
- Có thể cài bằng cách chỉ chọn đúng thành phần trong Windows 10 SDK
- Nhờ debugger, bạn có thể trực tiếp quan sát trạng thái nội bộ của chương trình, cấu trúc bộ nhớ và sự thay đổi của các thanh ghi
Góc nhìn về lập trình assembly
Cấu trúc CPU và tập lệnh
- CPU chỉ có thể thực hiện một số hành động giới hạn theo tập lệnh nhất định
- Lệnh là đơn vị thao tác cơ bản mà CPU có thể thực hiện
- Mỗi lệnh hoạt động rất đơn giản (lưu giá trị, tính toán số học...) và đi kèm tham số
- Trong lập trình mức thấp và gỡ lỗi, điều cốt lõi là hiểu rằng cấu trúc này là nền tảng của mọi khái niệm mức cao
Thanh ghi (Registers)
- Thanh ghi là vùng nhớ chuyên dụng cực nhanh nằm bên trong CPU
- x86-64 có 16 thanh ghi mục đích chung, tất cả đều có kích thước 64-bit
- Mỗi thanh ghi có thể được truy cập từng phần theo đơn vị byte, word hoặc doubleword
| Thanh ghi | Byte thấp | Word thấp | Doubleword thấp |
|---|---|---|---|
| rax | al | ax | eax |
| rbx | bl | bx | ebx |
| rcx | cl | cx | ecx |
| rdx | dl | dx | edx |
| rsp | spl | sp | esp |
| rsi | sil | si | esi |
| rdi | dil | di | edi |
| rbp | bpl | bp | ebp |
| r8~r15 | r8b~r15b | r8w~r15w | r8d~r15d |
rsplà stack pointer, cònrsi/rdihoạt động như chỉ số xử lý chuỗi, tức một số thanh ghi được gán mục đích đặc biệtriplà instruction pointer, cònrflagslà thanh ghi đặc biệt chứa các cờ trạng thái của kết quả phép toán
Bộ nhớ và địa chỉ
- Bộ nhớ hoạt động như một mảng byte liên tiếp bắt đầu từ chỉ số 0
- Trong kiến trúc x86 trước đây, cách segment-offset là bắt buộc, nhưng ở x86-64 toàn bộ bộ nhớ được xem là không gian địa chỉ phẳng (Flat)
- Trên thực tế, hệ điều hành và phần cứng ánh xạ động không gian địa chỉ ảo của từng tiến trình sang bộ nhớ vật lý
- Nghĩa là cùng một địa chỉ ảo nhưng ở các tiến trình khác nhau sẽ tương ứng với vùng nhớ vật lý khác nhau
- Lệnh và dữ liệu cùng tồn tại trong một bộ nhớ (kiến trúc von Neumann), khác với kiến trúc Harvard như AVR dùng trên Arduino, nơi dữ liệu được lưu tách riêng
Viết chương trình assembly đầu tiên
- Sau khi cài FASM, hãy thử viết và build chương trình đơn giản dưới đây
format PE64 NX GUI 6.0
entry start
section '.text' code readable executable
start:
int3
ret
Giải thích mã
format PE64 NX GUI 6.0: Chỉ định định dạng tệp thực thi mà FASM sẽ tạo ra, ở đây là PE (Portable Executable) 64-bit GUIentry start: Định nghĩa entry point nơi chương trình bắt đầu chạy, tức vị trí của nhãnstartsection '.text' code readable executable: Chỉ định đây là vùng mã của PE, tức vùng có thể thực thistart:: Đặt tên cho điểm vào đã chỉ định ở trênint3: breakpoint dành cho debugger, dùng để tạm dừng chương trình nhằm kiểm tra trạng tháiret: Lệnh lấy địa chỉ từ stack và chuyển quyền điều khiển đến đó; trong chương trình này nó phản hồi việc kết thúc ngay lập tức
Thực hành gỡ lỗi
-
Trong WinDbg, mở tệp thực thi (.exe) của chương trình trên và chuẩn bị các cửa sổ như disassembly, thanh ghi...
-
Nhấn F5 để chương trình đi tới breakpoint, rồi nhấn F8 để thực thi từng lệnh một (step-by-step)
-
Có thể quan sát trực tiếp sự thay đổi của các thanh ghi như
rip -
Sau khi thực hiện
ret, quyền điều khiển được trả về cho hệ điều hành, rồi quá trình kết thúc thread và tiến trình tiếp tục khiRtlExitUserThreadđược gọi -
Lưu ý: nếu chỉ kết thúc bằng lệnh
ret, tiến trình có thể vẫn còn tồn tại tùy việc có tác vụ nền nào khác đang chạy hay không, vì vậy để kết thúc đúng cách thì nên luôn gọi ExitProcess
Định dạng PE và import DLL
Tổng quan cấu trúc import hàm từ DLL
- Các hàm WinAPI như ExitProcess nằm trong KERNEL32.DLL
- Để dùng các hàm bên ngoài như vậy, cần cấu hình import table của tệp thực thi (section
.idata) - Import Directory Table (IDT) trong section idata chứa tên DLL, tên hàm và thông tin địa chỉ RVA của IAT/ILT...
- IAT (Import Address Table) sẽ bị OS loader ghi đè bằng địa chỉ hàm thực tế tại thời điểm chạy
- Hint/Name Table gồm tên của từng hàm và thông tin hint tương ứng
Ví dụ định nghĩa section .idata trong FASM
section '.idata' import readable writeable
idt:
dd rva kernel32_iat
dd 0
dd 0
dd rva kernel32_name
dd rva kernel32_iat
dd 5 dup(0)
name_table:
_ExitProcess_Name dw 0
db "ExitProcess", 0, 0
kernel32_name: db "KERNEL32.DLL", 0
kernel32_iat:
ExitProcess dq rva _ExitProcess_Name
dq 0
- db/dw/dd/dq : Chèn giá trị theo đơn vị byte/word/doubleword/quadword (8 byte)
- rva : Tính địa chỉ ảo tương đối (Relative Virtual Address) của symbol
- Có thể tham chiếu hàm DLL bằng cách tự cấu hình IAT và Name Table
Quy ước gọi hàm 64-bit của Windows (MS x64 Calling Convention)
- Đây là quy ước chuẩn xác định cách truyền đối số và sử dụng stack khi gọi hàm
- Trên Windows 64-bit, quy ước được dùng là Microsoft x64 Calling Convention
- Các đặc điểm chính:
- Stack pointer phải luôn được căn chỉnh 16 byte
- 4 đối số integer/pointer đầu tiên dùng các thanh ghi rcx, rdx, r8, r9
- 4 đối số dấu chấm động đầu tiên được đặt trong xmm0~xmm3
- Các đối số bổ sung dùng stack
- Bất kể số lượng đối số là bao nhiêu, vẫn phải cấp phát 32 byte shadow space trên stack
- Việc dọn stack do bên gọi đảm nhiệm
Ví dụ gọi ExitProcess
format PE64 NX GUI 6.0
entry start
section '.text' code readable executable
start:
int3
sub rsp, 8 * 5
xor rcx, rcx
call [ExitProcess]
section '.idata' import readable writeable
idt:
dd rva kernel32_iat
dd 0
dd 0
dd rva kernel32_name
dd rva kernel32_iat
dd 5 dup(0)
name_table:
_ExitProcess_Name dw 0
db "ExitProcess", 0, 0
kernel32_name db "KERNEL32.DLL", 0
kernel32_iat:
ExitProcess dq rva _ExitProcess_Name
dq 0
Phân tích phần mã mới
-
sub rsp, 8 * 5: Điều chỉnh stack pointer (cấp phát 40 byte), đồng thời xử lý luôn việc căn chỉnh 16 byte và cấp phát shadow space -
xor rcx, rcx: Gán giá trị 0 vào thanh ghircx, là đối số đầu tiên (dùng làm mã thoát) -
call [ExitProcess]: Nhảy tới địa chỉ hàmExitProcessthực tế đã được ghi vào import table -
Khi thực thi từng bước trong WinDbg, có thể trực tiếp xác nhận sự thay đổi của stack pointer (
rsp), thanh ghircx, cũng như luồng kết thúc tiến trình
Kết luận
- Bài viết này hướng dẫn toàn bộ luồng làm quen với x86-64 assembly theo hướng thực hành, từ thiết lập công cụ cơ bản, định dạng PE, import DLL, quy ước gọi hàm x64 cho tới viết và gỡ lỗi chương trình đầu tiên
- Ở phần tiếp theo, tác giả sẽ triển khai thêm nhiều chức năng đa dạng hơn và đi vào mã thực tế
1 bình luận
Ý kiến trên Hacker News
Muốn chia sẻ một dự án đã phát triển trong vài năm
https://asm-editor.specy.app
Đây là một IDE tương tác trực tuyến hỗ trợ nhiều ngôn ngữ assembly như M68K, MIPS, RISC-V, X86
Có nhiều tính năng đa dạng để dạy lập trình assembly
Cũng có thể nhúng vào các website khác
Trước đây không biết rằng các thanh ghi chỉ mục con trỏ có thể truy cập trực tiếp byte địa chỉ thấp (ví dụ: trên 16/32-bit, có thể truy cập si/esi bằng sil)
Đây là khái niệm tương tự như truy cập al từ ax/eax
Tò mò không biết các opcode mới được thêm vào trong x86_64 có thực sự tồn tại hay không
Nghĩ rằng nên kiểm tra lại đặc tả nền tảng
Chỉ hỏi vì tò mò thuần túy
Chia sẻ một tài liệu nhập môn assembly do chính mình viết
https://www.nayuki.io/page/a-fundamental-introduction-to-x86-assembly-programming
Đã thử tối ưu bằng assembly vì tò mò liệu có thể làm phần dispatch của CPU emulator nhanh hơn C++ hay không
Đã chạy chương trình Fibonacci nhưng kết quả hoàn toàn không tiệm cận được
Cuối cùng chỉ gộp vào với tùy chọn mặc định bị vô hiệu hóa
Dù vậy vẫn tin chắc là phải có cách làm nhanh hơn
https://github.com/libriscv/libriscv/blob/master/lib/libriscv/amd64/inaccurate_dispatch.nasm
Trong lúc học cách truy cập bộ nhớ, đã cải thiện hiệu năng được một chút
Thu nhỏ jump table từ 64-bit xuống 32-bit và đưa nó vào phần .text để dùng truy cập RIP-relative
Chương trình Fibonacci không cần nhiều bytecode
Rất muốn nghe các mẹo về những điểm có thể cải thiện thêm
Không rõ toàn bộ ngữ cảnh, nhưng tôi nghĩ khác biệt có thể không nằm ở cơ chế dispatch (cách fetch lệnh) mà ở sự khác nhau trong phần cài đặt lệnh thực tế
Một hướng tối ưu là ánh xạ các thanh ghi đang mô phỏng vào các thanh ghi x86-64 thật và không để chúng tràn ra bộ nhớ
Làm vậy thì các phép toán như add có thể thực hiện trực tiếp mà không cần lấy từ bộ nhớ ra
Tuy nhiên, cách này khiến việc viết emulator phiền phức hơn nhiều
Đây là tài liệu nhập môn x86 assembly có thể thực hành ngay trên trình duyệt
Có thể chạy thử ví dụ ngay mà không cần thiết lập cục bộ gì đặc biệt
https://shikaan.github.io/assembly/x86/guide/2024/09/08/x86-64-introduction-hello.html
Nhân tiện, đây cũng là tài liệu do chính mình viết
Có vẻ là cách assemble trực tiếp bằng NASM rồi chạy nhị phân, nên thấy băn khoăn về bảo mật
Chỉ nhìn ảnh đại diện thì tôi đã tưởng là junferno
Chỉ cần thử chạm vào assembly một lần thôi cũng giúp hiểu sâu hơn về tổng thể, nên đó luôn là một trải nghiệm đáng giá
Không nhất thiết phải làm một dự án lớn, nên khuyên mọi người cứ mạnh dạn tự thử một chút
Chia sẻ liên kết tới cuộc thảo luận HN hồi đó (2020)
https://news.ycombinator.com/item?id=24195627
Thật may vì đây là cú pháp assembly kiểu Intel
Muốn thử làm gì đó bằng assembly, nhưng chưa nghĩ ra ý tưởng cụ thể nào
Đây là một trò giải đố bằng kiểu pseudo-assembly
Tôi nghĩ những trò như vậy có thể phần nào thỏa cơn "thèm" assembly