Skip to main content

Command Palette

Search for a command to run...

Binary 103: Linux 64-bit Assembly

Kiến thức cơ bản về x86-64 Assembly và cấu trúc tệp tin ELF64 trên Linux.

Updated
9 min read

Phần này giới thiệu cho bạn đọc những kiến thức cơ bản về x86_64 Assembly trên Linux, nhìn chung không khác biệt quá nhiều so với x86 Assembly. Điểm khác biệt dễ nhận thấy nhất ở x86_64 Assembly là về số lượng các thanh ghi, độ rộng thanh ghi và quá trình thực hiện System Calls, tất cả sẽ được trình bày trong bài này.

1. Các thanh ghi trong x86_64 Assembly

Các thanh ghi trong x86_64 Assembly là sự mở rộng của x86 Assembly từ 32-bits lên 64-bits và các thanh ghi này hoạt động tương tự các thanh ghi 32-bits, khi cần thiết chúng đều có thể được chia nhỏ thành các thanh ghi con 32-bits, 16-bits và 8-bits.

  • Nhóm thanh ghi chung: Mở rộng lên 64-bits, vai trò của các thanh ghi không thay đổi, vẫn sẽ có các thanh ghi: RAX, RBX, RCX, RDX, RSI, RDI, RSP, RBP

  • Thanh ghi cờ - RFLAGS: Mở rộng lên 64-bits và 32-bits thấp của thanh ghi này vẫn hoạt động như thanh ghi cờ ở x86 Assembly.

  • Thanh ghi con trỏ lệnh - RIP: Mở rộng lên 64-bits và hỗ trợ thêm một chế độ địa chỉ mới là: RIP - Relative Addressing.

  • Nhóm thanh ghi mới ở x86_64 Assembly: 8 Thanh ghi 64-bits mới được bổ sung: R8, R9, R10, R11, R12, R13, R14, R15. Các thanh ghi này cũng chứa các thanh ghi: 32, 16, 8 bit lần lượt tương ứng hậu tố: D, W, L. Ví dụ R8 có thể chia nhỏ hơn thành R8D (32 bits), R8W (16 bits), và R8L (8 bits).

  • Ví dụ các thanh ghi trong x86 Assembly:

  • Ví dụ các thanh ghi trong x86_64 Assembly:

2. Các lệnh thường gặp trong x86_64 Assembly

Các lệnh thường gặp trong x86_64 Assembly hầu hết đều giống với x86 Assembly trình bày ở phần trước. Điểm khác biệt là độ rộng của thanh ghi được tăng lên 64-bits, RIP hỗ trợ Relative addressing. Một số ví dụ:

  • Ví dụ lệnh MOV

    mov rax,rbx
    mov rcx,0x1122334455667788
    mov dl,0x11
    mov rax,[r8]
    
  • Ví dụ lệnh IC, DEC

    inc eax
    inc rdx
    inc al
    inc [ax]
    
    dec ebx
    dec rbx
    dec bl
    dec [bx]
    
  • Ví dụ lệnh ADD, SUB, MUL, DIV

    add ebx,eax
    add bx,ax
    add rax,rbx
    add cl,0x2
    
    sub edx,ecx
    sub dx,cx
    sub rdx,rcx
    sub cl,0x2
    
    mul rdi
    mul bx
    mul cl
    mul 0x1122334455667788
    
    div bx
    div ecx
    div cl
    
  • Ví dụ lệnh LEA và XCHG

    lea rax,[rcx+8]
    xchg rdi,rsi
    
  • Ví dụ lệnh XOR, AND, OR

    xor rax,rax
    and rbl,al
    or bx,bx
    or cx,0xfff
    
  • Ví dụ lệnh PUSH và POP

    push rdi
    pop r12
    

3. x86_64 Assembly System Calls trên Linux

Khi thực hiện một System Call trong x86_64 Assembly sẽ không còn sử dụng NGẮT (INT 0X80) như trước nữa, thay vào đó nó sử dụng lệnh SYSCALL. Quy định về các thanh ghi lưu các tham số cũng khác so với x86 Assembly.

Tra cứu các System Call Number của x86_64 Assembly trong tệp: /usr/include/x86_64-linux-gnu/asm/unistd_64.h

$ cat /usr/include/x86_64-linux-gnu/asm/unistd_64.h
#ifndef _ASM_X86_UNISTD_64_H
#define _ASM_X86_UNISTD_64_H 1

#define __NR_read 0
#define __NR_write 1
#define __NR_open 2
#define __NR_close 3
...

Vẫn sử dụng Man Page để tra cứu cách sử dụng một API. Ví dụ với hàm read có System Call Number là 1

$ man 2 read

Kết quả cho biết hàm nhận vào 3 tham số như dưới đây:

READ(2)                                             Linux Programmer's Manual                                             READ(2)

NAME
       read - read from a file descriptor

SYNOPSIS
       #include <unistd.h>

       ssize_t read(int fd, void *buf, size_t count);
...

Các Parameter truyền vào khi gọi hàm tuân theo quy tắc sau đây:

Tham khảo: https://en.wikibooks.org/wiki/X86_Assembly/Interfacing_with_Linux#Via_dedicated_system_call_invocation_instruction

Tham khảo: https://en.wikibooks.org/wiki/X86\_Assembly/Interfacing\_with\_Linux#Via\_dedicated\_system\_call\_invocation\_instruction

Ta có “công thức” cần nhớ:

💡 - x86_64 Assembly thực hiện System Call thông qua lệnh: `SYSCALL` - Thanh ghi RAX/EAX/AX sẽ lưu `System Call Number` và `Result` của System Call. - Các tham số theo thứ tự sau: `RDI, RSI, RDX, R10, R8, R9` - Tra cứu các System Call Number tại: `unistd_64.h` - Tra cứu các API bằng `Man Page` của Linux

4. Phân tích một chương trình x86_64 Assembly đơn giản

  • Source code:

      1 ; ch03_helloworld64.asm
      2
      3 global _start
      4 section .text
      5
      6 _start:
      7         ; __NR_write 1
      8         ; ssize_t write(int fd, const void *buf, size_t count);
      9         xor    rax,rax
     10         xor    rdi,rdi
     **11         xor    rsi,rsi**
     12         xor    rdx,rdx
     13         xor    r14,r14
     14         xor    r15,r15
     15         inc    rax
     16         inc    rdi
     17         mov    r14,0x00000a21646c726f
     18         mov    r15,0x57202c6f6c6c6548
     19         push   r14
     20         push   r15
     21         mov    rsi,rsp
     22         mov    dl,0xf
     23         syscall
     24
     25         ; __NR_exit 60
     26         ; void _exit(int status);
     27         xor    rax,rax
     28         xor    rdi,rdi
     29         mov    al,0x3c
     30         syscall
    
  • Biên dịch, liên kết và chạy chương trình:

    $ nasm -f elf64 -o ch03-helloworld64.o ch03-helloworld64.asm
    $ ld -o ch03-helloworld64 ch03-helloworld64.o
    $ chmod +x ch03-helloworld64
    $ ./ch03-helloworld64
    Hello, World!
    
  • Giải thích chi tiết:

    • Dòng 9, 10, 11, 12, 13, 14: khởi tạo giá trị 0 cho các thanh ghi RAX, RDI, RSI, RDX, R14, R15

    • Dòng 15: RAX = 0x1 ⇒ Tra System Call Number trong unistd_64.h ta được: #define __NR_write 1. Hàm write trong Man Page: ssize_t write(int fd, const void *buf, size_t count); sẽ nhận vào 3 tham số.

    • Dòng 16: RDI = 0x1 ⇒ Đây là tham số đầu tiên của hàm write. Ta có các hằng số định nghĩa File Descriptor như sau: 0=STDIN, 1=STDOUT, 2=STDERR. Vậy trường hợp này fd=STDOUT.

    • Dòng 17, 18: Sao chép dữ liệu dạng Hexa vào các thanh ghi R14, R15

    • Dòng 19, 20: Đẩy dữ liệu của R14, R15 lên Stack. Dựa theo Little-Endian ta decode dữ liệu này như sau:

      $ python
      >>> a = '00000a21646c726f'.decode('hex')
      >>> b = '57202c6f6c6c6548'.decode('hex')
      >>> final = a + b
      >>> final[::-1] # Little-Endian, Reverse bytes
      'Hello, World!\n\x00\x00'
      
    • Nhớ lại chương trình x86 Assembly phần trước, chương trình phải đẩy 4 lần dữ liệu lên Stack trong khi với x86_64 Assembly chỉ với 2 lần. Lý do vì độ rộng của vùng nhớ trên Stack lúc này tăng từ 32-bits lên 64-bits.

    • Dòng 21: RSI lúc này trỏ vào đỉnh Stack, tức là đang trỏ đến chuỗi: 'Hello, World!\n\x00\x00' ⇒ Vậy tham số thứ 2 của hàm write: *buf='Hello, World!\n\x00\x00'

    • Dòng 22: DL = 0xF ⇒ RDX = 0xF ⇒ Vậy tham số cuối cùng hàm write: count=15

    • Dòng 23: Thực hiện System Call

    • Dòng 27, 28: Khởi tạo lại giá trị 0 cho các thanh ghi RAX, RDI

    • dòng 29: AL=0x3C ⇒ RAX=0x3C ⇒ Tra cứu System call number trong unistd_64.h ta được hàm: #define __NR_exit 60. Được mô tả như sau: void exit(int status);

    • Dòng 30: Thực hiện System call với hàm exit với tham số: status=0

  • Tóm lại: Ta có thể chia chương trình thành 2 khối thực thi:

5. Cấu trúc tệp ELF64 trên Linux

Cấu trúc tệp ELF64 về so với ELF32 không có sự khác biệt nhiều, chỉ thay đổi một vài thông số cho phù hợp với hệ thống 64-bits. Phần này sẽ không bàn quá nhiều về cấu trúc chi tiết như phần trước về ELF32, phần này tập chung vào sự khác biệt của tệp ELF32/64 sau khi biên dịch của C mà Assembly sau khi biên dịch không có.

Nhìn chung chương trình viết bằng Assembly cho kích thước nhỏ hơn, cấu trúc tệp tinh gọn hơn, không có nhiều thông tin "thừa" đi kèm tệp:

  • Chương trình viết bằng Assembly có kích thước bé hơn

  • Chương trình viết bằng Asembly có ít thông tin hơn về thư viện, trình biên dịch

  • Tệp ELF của C đi kèm với rất nhiều thông tin: thư viện, compiler, symbols, strings,.v.v.. Đáng chú ý nhất là xuất hiện rất nhiều các Section được trình biên dịch thêm vào:

    • .text: Chứa code thực thi. Khi phân tích Binary chủ yếu tập chung vào Section này.

    • .bss: Chứa dữ liệu (variable) chưa đc khởi tạo giá trị. Phần này nằm trong Data Segment

    • .data: Chứa dữ liệu (variable) đã đc khởi tạo giá trị. Phần này cũng nằm trong Data Segment

    • .rodata: Chứa dữ liệu chỉ đọc (const), và nó đc sử dụng cho các Segment non-writable

    • .shstrtab: Chứa header string table, chứa tên của tất cả các Section trong tệp nhị phân

    • .symtab: Chứa mảng các tham chiếu đến các symbol dc linker và loader sử dụng

    • .strtab: Chứa bảng các chuỗi kết thúc bằng null-terminated

    • .init: Chịu trách nhiệm khởi tạo image tiến trình cho tệp ELF

    • .fini: Chịu trách nhiệm về mã kết thúc cho tiến trình

    • .plt: Chứa Procedure Linkage Table và dữ liệu chuyển hướng các hàm thư viện đến vị trí tuyệt đối của chúng trong bộ nhớ

    • .got: Có thể ghi vào đc, và nó chứa Global Offset Table, resolve các shared library data trong quá trình chạy và còn đc sử dụng với Procedure Linkage Table

    • .got.plt: Hoạt động cùng với Procedure Linkage Table, chứa địa chỉ cho các hàm đc sử dụng bởi Procedure Linkage Table trong quá trình liên kết động

  • Một số Segment thường gặp:

    • Text Segment: Chứa một số section như: .text, .rodata, .hash, .dynsym, .dynstr, .plt, .rel.got

    • Data Segment: Có thể ghi vào đc, chứa một số section như: .data, .dynamic, .got, .bss

ReadELF với chương trình viết bằng C:

  • Section .rela.plt và bảng .dynsym cho biết chương trình có dùng hàm printf của thư viện GLIBC_2.2.5

  • Bảng .symtab chứa rất nhiều symbol

    Nó cũng cho biết symbol printf được sử dụng với type là FUNC