PHÂN TÍCH MÃ ĐỘC X86 DISASSEMBLY
Trong bài viết lần này, chuyên gia an ninh mạng Bùi Đình Cường sẽ gửi đến bạn đọc những phân tích về mã độc X86 DISASSEMBLY. Với mã độc này chúng ta sẽ lần lượt đi tìm hiểu về các nội dung như: các mức trừu tượng, dịch ngược và kiến trúc X86. Các mức trừu tượng Trong kiến ...
Trong bài viết lần này, chuyên gia an ninh mạng Bùi Đình Cường sẽ gửi đến bạn đọc những phân tích về mã độc X86 DISASSEMBLY. Với mã độc này chúng ta sẽ lần lượt đi tìm hiểu về các nội dung như: các mức trừu tượng, dịch ngược và kiến trúc X86.
Các mức trừu tượng
Trong kiến trúc máy tính truyền thống, một hệ thống máy tính có thể được mô tả bằng một vài mức trừu tượng để che giấu chi tiết triển khai hệ thống. Ví dụ, ta có thể chạy hệ điều hành Windows trên nhiều loại phần cứng khác nhau vì lớp phần cứng phía dưới được trừu tượng hóa đối với hệ điều hành.
Hình bên dưới minh họa ba mức lập trình liên quan đến nhau trong việc phân tích mã độc. Tác giả mã độc viết chương trình bằng các ngôn ngữ bậc cao và dùng trình biên dịch để sinh mã máy (machine code) mà CPU có thể hiểu và thực thi. Ngược lại, người phân tích mã độc và các chuyên gia dịch ngược làm việc với các ngôn ngữ bậc thấp; ta sẽ dùng các trình dịch ngược để sinh mã hợp ngữ từ chương trình ban đầu để có thể đọc và phân tích hoạt động của chương trình đó.
Các hệ thống máy tính nhìn chung được mô tả bằng sáu mức trừu tượng, liệt kê từ mức thấp tới cao. Các mức trừu tượng cao hơn được trên cùng với các khái niệm cụ thể bên dưới, do đó mà mức trừu tượng càng cao thì tính khả chuyển giữa các hệ thống càng cao.
Hiểu sâu về Metldown attack và cách bảo vệ máy tính của bạn khỏi Meltdown attack?
Phần cứng: Mức phần cứng là mức vật lý duy nhất, bao gồm các mạch điện thực hiện những phép tính logic như XOR, AND, OR, NOT,… gọi chung là các phép tính logic số. Vì đặc điểm vật lý, một phần cứng không dễ để điều khiển bởi phần mềm.
Mã phản chiếu: Còn được biết đến với tên firmware. Mã phản chiếu chỉ điều khiển một số mạch nhất định theo thiết kế cho trước. Nó chứa các vi lệnh được dịch từ mức mã máy để cung cấp giao diện giao tiếp với phần cứng. Khi thực hiện phân tích mã độc, ta thường không phải lo về mã máy vì nó thường được viết cho những phần cứng cụ thể.
Mã máy: Chứa các opcode, hay các mã thực thi, là những số Hexa chỉ cho bộ xử lý biết cần thực hiện những phép toán nào. Mã máy thường được triển khai cùng với một số lệnh của Mã phản chiếu để các mức trừu tượng thấp hơn có thể thực thi đoạn mã. Mã máy được sinh khi một chương trình được viết từ một ngôn ngữ bậc cao và được biên dịch.
Các ngôn ngữ bậc thấp: Một ngôn ngữ bậc thấp là một phiên bản của tập lệnh máy tính mà con người có thể đọc được. Ngôn ngữ bậc thấp phổ biến nhất chính là hợp ngữ. Chuyên gia phân tích mã độc làm việc với ngôn ngữ bậc thấp vì mã máy là quá khó hiểu đối với con người. Ta sử dụng một trình dịch ngược để sinh mã hợp ngữ. Có nhiều phương ngữ hợp ngữ cùng tồn tại, ta sẽ dần dần khám phá chúng.
Hợp ngữ là ngôn ngữ bậc cao nhất có tính tin cậy và nhất quán mà ta có thể sinh từ mã máy khi ta không có mã nguồn của ngôn ngữ bậc cao.
Các ngôn ngữ bậc cao: Đa số các lập trình viên làm việc với các ngôn ngữ bậc cao. Các ngôn ngữ bậc cao cung cấp tính trừu tượng hóa cao và khiến việc sử dụng logic lập trình cũng như các cơ chế điều khiển luồng dễ dàng hơn. Các ngôn ngữ này thường được dịch sang mã máy tại công đoạn biên dịch, bằng các trình biên dịch.
Các ngôn ngữ thông dịch: Các ngôn ngữ diễn dịch là mức trừu tượng cao nhất. Nhiều lập trình viên sử dụng ngôn ngữ diễn dịch như C#, Perl, .NET, Java,… Mã nguồn các chương trình sẽ không được biên dịch sang mã máy mà thay vào đó sẽ được dịch sang bytecode (mã trung gian). Bytecode là đại diện trung gian cụ thể cho từng ngôn ngữ lập trình. Bytecode được thực thi bên trong một trình thông dịch, chương trình này dịch bytecode sang dạng mã máy thực thi được.
Dịch ngược
Khi mã độc được lưu trữ trên một ổ đĩa, nó thường ở dạng nhị phân (binary form) mức mã máy. Như đã đề cập, mã máy là dạng mã mà máy tính có thể thực thi nhanh chóng và hiệu quả. Khi ta bóc tách một mã độc bằng một trình dịch ngược với đầu vào là file nhị phân, trình dịch ngược sẽ sinh đầu ra là mã hợp ngữ. Trình dịch ngược phổ biến nhất là IDA Pro, sẽ được thảo luận sâu ở chương sau.
Hợp ngữ thực ra là một lớp các ngôn ngữ. Mỗi phương ngữ hợp ngữ thường được sử dụng để lập trình trên một họ vi điều khiển như x86, x64, SPARC, PowerPC, MIPS hay ARM. x86 đang là kiến trúc phổ biến nhất trên PC.
Đa số các máy tính cá nhân 32-bit đều là x86, còn được biết đến với tên Intel IA-32, và mọi phiên bản 32-bit hiện đại của Microsoft Windows đều được thiết kế để chạy trên kiến trúc x86. Ngoài ra, kiến trúc AMD64 hay Intel 64 cũng hỗ trợ chạy các file nhị phân Windows 32-bit x86. Vì lí do này, đa số mã độc được biên dịch trên kiến trúc x86 và ta sẽ tập trung phân tích các mã độc trên kiến trúc này.
Kiến trúc x86
Hầu hết các kiến trúc máy tính hiện đại (bao gồm cả x86) đều dựa trên kiến trúc Von Neumann. Kiến trúc này gồm ba thành phần phần cứng:
- Bộ xử lý trung tâm (central processing unit – CPU) đảm nhiệm thực thi code chương trình.
- Bộ nhớ chính của hệ thống (RAM) lưu trữ tất cả dữ liệu và code thực thi.
- Hệ thống giao tiếp vào/ra (I/O) với các thiết bị như ổ đĩa cứng, bàn phím, màn hình,…
CPU chứa một số thành phần: Bộ điều khiển (control unit – CU) nhận các lệnh thực thi từ RAM, sử dụng các thanh ghi (con trỏ lệnh) để lưu địa chỉ lệnh thực thi. Các thanh ghi là thành phần lưu trữ cơ bản của CPU với tốc độ truy cập cao, giúp CPU không phải truy cập tới RAM. Bộ xử lý số học logic (arithmetic logic unit – ALU) thực thi mỗi lệnh lấy từ RAM và lưu kết quả tính toán trong các thanh ghi CPU hoặc ô nhớ RAM. Tiến trình nhận lệnh và thực thi lệnh được lặp lại trong toàn bộ thời gian chạy một chương trình.
Bộ nhớ chính
Bộ nhớ chính (RAM) cho mỗi chương trình có thể chia thành bốn phần chính như sau:
Data section: Phần không gian bộ nhớ dùng để chứa các giá trị mà đôi khi gọi là các giá trị tĩnh vì chúng không thay đổi trong khi thực thi chương trình, hoặc cũng có thể là các giá trị toàn cục vì chúng được sử dụng bởi bất kì phần nào của chương trình.
Code section: Chứa các lệnh sẽ được lấy bởi CPU để thực thi. Code là phần tập hợp và tổ chức các chức năng hoạt động của chương trình.
Heap (Đống): Được dùng như bộ nhớ động trong thời gian thực thi chương trình, cho phép chỉ định (allocate) các giá trị mới và giải phóng (free) các giá trị mà chương trình không dùng đến nữa. Gọi heap là bộ nhớ động vì dữ liệu trong nó có thể thay đổi thường xuyên trong quá trình thực thi chương trình.
Stack (Ngăn xếp): Sử dụng để lưu các biến cục bộ và tham số cho các hàm hoặc điều khiển luồng chương trình.
Mặc dù hình trên minh họa bốn phần chính của bộ nhớ theo thứ tự riêng, các phần này có thể được phân vùng bất kì đâu trong bộ nhớ. Chẳng hạn stack có thể có địa chỉ thấp hơn code section hoặc ngược lại. Hệ điều hành quản lý địa chỉ vùng nhớ dành cho mỗi phần chương trình bằng cách tạo một danh sách quản lý các vùng còn trống trên bộ nhớ.
Tập lệnh
Các lệnh là những khối xây dựng nên chương trình hợp ngữ. Trong hợp ngữ x86, một lệnh đầy đủ được cấu thành bởi bốn thành phần sau:
[Nhãn lệnh:] <Tên lệnh> [Các toán hạng] [;Chú thích]
Trong đó:
- [Nhãn lệnh:]: Là dãy kí tự đứng trước câu lệnh, kết thúc bởi dấu hai chấm (:), được chỉ định thay thế cho địa chỉ của câu lệnh trong các đoạn lệnh lặp, rẽ nhánh,.. Trong một chương trình hợp ngữ, không thể có hai nhãn lệnh trùng tên và tên của các nhãn cũng không được trùng với tên của các thủ tục trong chương trình.
- <Tên lệnh>: (Gợi nhớ lệnh – Mnemonic) Là một trong các lệnh thuộc tập lệnh hợp ngữ của vi xử lý. Lệnh hợp ngữ không phân biệt chữ hoa hay chữ thường. Trong chương trình hợp ngữ, mỗi dòng chỉ được chứa một lệnh và mỗi lệnh chỉ được đặt trên một dòng.
- [Các toán hạng]: Là đối tượng mà lệnh tác động đến, chẳng hạn như thanh ghi hoặc dữ liệu. Một lệnh hợp ngữ x86 có thể không có toán hạng, có một toán hạng hoặc hai toán hạng. Trong trường hợp một lệnh có hai toán hạng thì toán hạng đứng trước gọi là toán hạng đích và toán hạng đứng sau là toán hạng nguồn. Toán hạng đích không thể là một hằng số.
- [;Chú thích]: Chỉ có giá trị với người đọc chương trình và được bỏ qua trong quá trình biên dịch sang mã máy. Chú thích bắt đầu bằng dấu chấm phẩy (;).
Opcode và Endian
Mỗi lệnh tương ứng với một opcode (operation code) chỉ dẫn cho CPU biết hoạt động nào cần thực hiện. Ở đây, ta coi opcode như toàn bộ một lệnh máy, trong khi Intel định nghĩa nó hẹp hơn nhiều.
Các trình dịch ngược dịch các opcode thành dạng lệnh người đọc được. Ví dụ, opcode B9 42 00 00 00 được dịch thành lệnh mov ecx, 0x42. Giá trị 0xB9 ứng với mov ecx, và 0x42000000 ứng với giá trị 0x42.
———————————————————————–
Opcode B9 42000000 Lệnh mov ecx, 0x42 ———————————————————————— |
0x42000000 được coi như 0x42 vì kiến trúc x86 sử dụng định dạng little-endian. Trong tổ chức dữ liệu, endian mô tả byte đầu tiên (có địa chỉ nhỏ nhất) của đối tượng dữ liệu là byte có trọng số lớn nhất (big-endian) hay có trọng số nhỏ nhất (little-endian). Thay đổi giữa big-endian và little-endian là thao tác bắt buộc đối với mã độc khi giao tiếp mạng vì dữ liệu mạng sử dụng định dạng big-endian trong khi các chương trình x86 sử dụng little-endian. Vì vậy, địa chỉ IP 127.0.0.1 sẽ được biểu diễn là 0x7F000001 ở định dạng big-endian dùng trong giao tiếp mạng và 0x0100007F ở định dạng little-endian khi lưu trữ trong bộ nhớ. Là người phân tích mã độc, ta phải luôn ý thức về endian để chắc chắn rằng không vô tình đảo ngược thứ tự của các dữ liệu quan trọng, chẳng hạn như địa chỉ IP.
Các toán hạng
Các toán hạng dùng để xác định đối tượng dữ liệu sử dụng bởi một lệnh. Có ba dạng toán hạng được dùng:
- Toán hạng trung gian là các giá trị cố định, chẳng hạn 0x42 trong ví dụ trên.
- Toán hạng thanh ghi là các thanh ghi CPU như ecx trong ví dụ trên.
- Địa chỉ bộ nhớ là địa chỉ của ô nhớ chứa giá trị được sử dụng, thường được gọi bằng giá trị hoặc thanh ghi.
Các thanh ghi
Thanh ghi là thành phần lưu trữ dữ liệu bên trong CPU, dữ liệu trên thanh ghi được truy cập nhanh hơn bất cứ bộ phận lưu trữ dữ liệu nào khác trong hệ thống. Tuy nhiên, kích thước mỗi thanh ghi thường rất nhỏ. Các bộ xử lý x86 có một tập các thanh ghi được sử dụng như những bộ lưu trữ dữ liệu hoặc không gian làm việc (workspace) tạm thời. Các thanh ghi x86 phổ biến nhất được chia thành bốn nhóm cơ bản:
- Thanh ghi đa dụng (general registers).
- Thanh ghi đoạn (segment registers) sử dụng để lưu vị trí các section của chương trình trong bộ nhớ.
- Cờ trạng thái (status flags) sử dụng để điều khiển hoặc phản ánh kết quả thực hiện lệnh và trạng thái của CPU.
- Con trỏ lệnh (instruction pointers) dùng để lưu vị trí lệnh tiếp theo trong bộ nhớ.
Tất cả các thanh ghi đa dụng đều có kích thước 32 bit và có thể được sử dụng ở chế độ 32-bit hoặc 16-bit trong hợp ngữ. Ví dụ, EDX được dùng như là một thanh ghi 32-bit và DX thì được dùng như 16-bit thấp của thanh ghi EDX.
Bốn thanh ghi EAX, EBX, ECX, EDX cũng có thể dùng như các thanh ghi 8-bit bằng cách sử dụng 8 bit thấp nhất hoặc tập 8 bit thứ hai. Ví dụ, AL là tập 8 bit thấp nhất của thanh ghi EAX và AH được dùng như tập 8 bit thứ hai.
Các thanh ghi đa dụng
Các thanh ghi đa dụng thường dùng để lưu trữ dữ liệu hoặc địa chỉ bộ nhớ và được sử dụng luân phiên trong các phép toán số học, logic. Các thanh ghi đa dụng gồm: EAX, EBX, ECX, EDX, EBP, ESP, ESI. Mặc dù gọi là thanh ghi đa dụng, thực tế chúng không được sử dụng một cách thoải mái như thế.
Một số lệnh x86 sử dụng các thanh ghi cụ thể theo định nghĩa. Ví dụ, lệnh nhân và lệnh chia luôn sử dụng hai thanh ghi EAX và EDX.
Thêm nữa, trong định nghĩa lệnh, các thanh ghi đa dụng có thể được sử dụng một cách nhất quán xuyên suốt chương trình, sự nhất quán này gọi là convention. Hiểu về convention sử dụng bởi mỗi trình biên dịch cho phép nhà phân tích mã độc kiểm tra code nhanh chóng hơn vì không phải mất thời gian để tìm hiểu bối cảnh mà các thanh ghi được sử dụng. Ví dụ, EAX thường chứa giá trị trả về cho mỗi lời gọi hàm. Do đó, nếu thấy thanh ghi EAX được dùng ngay sau một lời gọi hàm, ta nên rà soát đoạn code sử dụng giá trị trả về.
Các cờ
EFLAGS là một thanh ghi trạng thái. Trong kiến trúc x86, nó có kích thước 32 bit và mỗi bit là một cờ. Trong quá trình thực thi, mỗi cờ sẽ có giá trị 1 (gọi là cờ được set) hoặc 0 (cleared) để điều khiển các hoạt động của CPU hoặc biểu thị kết quả tính toán của CPU. Các cờ quan trọng nhất đối với một nhà phân tích mã độc có thể kể đến như:
ZF: Cờ zero, được set (giá trị bằng 1) khi kết quả của một phép tính có giá trị bằng 0; nếu không, nó có giá trị bằng 0 (cleared).
CF: Cờ mang (carry), được set khi kết quả của một phép tính là quá lớn hoặc quá nhỏ đối với toán hạng đích, nếu không, nó có giá trị bằng 0.
SF: Cờ dấu (sign), được set khi kết quả của một phép tính có giá trị âm hoặc clear (0) khi kết quả phép tính đó có giá trị dương. Cờ này cũng được set khi bit có trọng số lớn nhất được set sau một phép tính số học.
TF: Cờ bẫy (trap), được sử dụng trong quá trình debug. Bộ xử lý x86 sẽ thực thi từng lệnh một nếu cờ bẫy được set.
Chi tiết tất cả các cờ khả dụng được trình bày cụ thể trong Volume 1, Intel 64 and IA-32 Architectures Software Developer’s Manuals.
Con trỏ lệnh EIP
Trong kiến trúc x86, EIP, con trỏ lệnh hay bộ đếm chương trình, là một thanh ghi chứa địa chỉ bộ nhớ của lệnh kế tiếp sẽ được thực thi của chương trình. Mục đích duy nhất của EIP là cho CPU biết sẽ phải làm gì tiếp theo.
Khi EIP bị lỗi (trỏ tới địa chỉ ô nhớ không chứa code hợp lệ của chương trình), CPU không thể nhận lệnh để thực thi nên chương trình sẽ bị crash. Một khi ta làm chủ được EIP, ta có thể điều khiển CPU phải thực hiện những gì, vì thế mà hacker luôn cố gắng để điều khiển EIP. Nhìn chung, hacker phải nạp đoạn mã tấn công vào bộ nhớ và sau đó thay đổi giá trị EIP để trỏ tới đoạn mã đó để khai thác hệ thống.
XEM THÊM: Ứng dụng Blocktrain vào thực tiễn đời sống TẠI ĐÂY
Theo chuyên gia an ninh mạng: Bùi Đình Cường