Bài 14.1216 phút đọc
Giải phóng hai lần
Sau bài này bạn sẽ làm được
- Giải thích vì sao giải phóng hai lần là lỗ hổng bảo mật
- Biết trường hợp duy nhất free được gọi lặp an toàn
- Áp dụng mẫu giải phóng kèm đặt NULL
- Nhận ra rủi ro khi nhiều con trỏ cùng trỏ một vùng
Gọi free hai lần trên cùng một con trỏ trông như một sơ suất nhỏ. Thực tế nó phá cấu trúc quản lý bên trong bộ cấp phát, và trong hai mươi năm qua nó là một trong những cơ chế được khai thác nhiều nhất để chiếm quyền điều khiển chương trình.
#Chuyện gì thật sự xảy ra
hai-lan.c
#include <stdlib.h>
int main(void)
{
int *p = malloc(sizeof *p);
if (p == NULL) return 1;
free(p);
free(p); /* hành vi không xác định */
return 0;
}terminal
gcc -std=c17 hai-lan.c -o t && ./t
free(): double free detected in tcache 2 Aborted (core dumped)
gcc -std=c17 -g -fsanitize=address hai-lan.c -o t && ./t
==1234==ERROR: AddressSanitizer: attempting double-free on 0x602000000010 in thread T0:
#0 in free
#1 in main hai-lan.c:10
0x602000000010 is located 0 bytes inside of 4-byte region
freed by thread T0 here:
#0 in free
#1 in main hai-lan.c:9am-tham.c
char *a = malloc(64);
free(a);
char *b = malloc(64); /* rất có thể nhận lại đúng khối vừa trả */
free(a); /* bộ cấp phát tưởng khối đang dùng đã trống */
char *c = malloc(64);
char *d = malloc(64); /* c và d có thể trỏ vào CÙNG một vùng nhớ */
strcpy(c, "du lieu cua c");
strcpy(d, "du lieu cua d");
printf("%s\n", c); /* in ra "du lieu cua d" */| Kịch bản | Kết quả | Có báo lỗi không |
|---|---|---|
| free hai lần liên tiếp | Bộ cấp phát phát hiện và dừng chương trình | Có, thông báo rõ ràng |
| Có malloc xen giữa | Danh sách khối trống bị phá, hai malloc trả cùng địa chỉ | Không |
| Kẻ tấn công điều khiển được dữ liệu | Ghi được vào địa chỉ tùy ý, chiếm quyền điều khiển | Không |
#Vì sao đây là lỗ hổng bảo mật
Cơ chế khai thác dựa trên một chi tiết: khi khối được đưa vào danh sách trống, bộ cấp phát ghi con trỏ liên kết vào chính vùng dữ liệu mà bạn từng dùng.
Trạng thái bình thường của danh sách khối trống:
[đầu danh sách] -> [khối A] -> [khối B] -> NULL
Sau khi free(A) hai lần, danh sách thành vòng:
[đầu danh sách] -> [khối A] -> [khối A] -> ...
Kẻ tấn công:
1. malloc lấy A ra khỏi danh sách, nhưng A vẫn còn ở đó lần thứ hai
2. Ghi vào A một địa chỉ họ chọn, ví dụ địa chỉ của một con trỏ hàm
3. malloc lần nữa, bộ cấp phát đọc "con trỏ liên kết" đó
4. malloc lần nữa, và nó trả về đúng địa chỉ kẻ tấn công đã chọn
5. Giờ họ ghi được vào bất kỳ đâu#Ba nguyên nhân thường gặp
Nguyên nhân 1: hai con trỏ cùng trỏ một vùng
hai-con-tro.c
typedef struct { char *ten; } Nguoi;
char *s = malloc(64);
strcpy(s, "An");
Nguoi a = { s };
Nguoi b = { s }; /* cùng trỏ vào s, không ai biết ai sở hữu */
free(a.ten);
free(b.ten); /* giải phóng hai lần cùng một vùng */Đây là nguyên nhân phổ biến nhất, và nó luôn bắt nguồn từ chuyện quyền sở hữu không rõ ràng, đúng như Bài 14.11 đã nói.
Nguyên nhân 2: nhánh xử lý lỗi dọn hai lần
Vừa dọn ở nhánh lỗi vừa dọn ở cuối
int lam_viec(void)
{
char *a = malloc(100);
if (a == NULL) return -1;
if (buoc_mot(a) != 0) {
free(a);
goto don_dep; /* nhảy tới chỗ cũng có free(a) */
}
don_dep:
free(a); /* lần thứ hai */
return -1;
}Một lối ra duy nhất
int lam_viec(void)
{
int ma_loi = -1;
char *a = malloc(100);
if (a == NULL) return -1;
if (buoc_mot(a) != 0) goto don_dep;
if (buoc_hai(a) != 0) goto don_dep;
ma_loi = 0;
don_dep:
free(a); /* đúng một chỗ giải phóng */
return ma_loi;
}Nguyên nhân 3: hàm hủy gọi hai lần
huy-hai-lan.c
/* Hàm hủy không đặt NULL, nên gọi lần hai là giải phóng lần hai. */
void huy_sai(Nguoi *n)
{
if (n == NULL) return;
free(n->ten); /* không đặt NULL */
free(n->diem);
}
huy_sai(&a);
huy_sai(&a); /* giải phóng hai lần */
/* ĐÚNG: đặt NULL để lần gọi sau vô hại */
void huy_dung(Nguoi *n)
{
if (n == NULL) return;
free(n->ten);
free(n->diem);
n->ten = NULL;
n->diem = NULL;
}
huy_dung(&a);
huy_dung(&a); /* an toàn: free(NULL) không làm gì */#Trường hợp duy nhất an toàn
free(NULL);
free(NULL);
free(NULL); /* gọi bao nhiêu lần cũng được, không làm gì cả */| Tình huống | An toàn không | Vì sao |
|---|---|---|
| free(NULL) nhiều lần | Có | Chuẩn C bảo đảm không làm gì |
| free(p) rồi p = NULL rồi free(p) | Có | Lần hai là free(NULL) |
| free(p) rồi free(p) | Không | Hành vi không xác định |
| free(a) rồi free(b) với a và b cùng địa chỉ | Không | Vẫn là giải phóng hai lần cùng một khối |
| free(p) rồi p = malloc(...) rồi free(p) | Có | Đây là hai khối khác nhau |
#Cách phòng tuyệt đối
Quy tắc 1: luôn đặt NULL ngay sau khi giải phóng
#define GIAI_PHONG(p) do { free(p); (p) = NULL; } while (0)
char *a = malloc(64);
GIAI_PHONG(a);
GIAI_PHONG(a); /* lần hai vô hại */Quy tắc 2: mỗi vùng nhớ có đúng một chủ
mot-chu.c
/* Ghi rõ ai sở hữu ngay trong khai báo và trong chú thích. */
typedef struct {
char *ten; /* SỞ HỮU: giải phóng trong nguoi_huy */
} Nguoi;
typedef struct {
const char *ten; /* MƯỢN: KHÔNG được giải phóng */
const Nguoi *chu; /* MƯỢN */
} TheDeoNguc;
void nguoi_huy(Nguoi *n)
{
if (n == NULL) return;
free(n->ten);
n->ten = NULL;
}
/* Thẻ đeo ngực không có hàm hủy vì nó không sở hữu gì. */Quy tắc 3: hàm hủy phải lũy đẳng
void doi_tuong_huy(DoiTuong *d)
{
if (d == NULL) return; /* chấp nhận NULL */
free(d->a);
free(d->b);
free(d->c);
d->a = NULL; /* đặt NULL để gọi lại vô hại */
d->b = NULL;
d->c = NULL;
}Quy tắc 4: hàm giải phóng nhận con trỏ hai tầng
hai-tang.c
/* Người gọi không thể quên đặt NULL, vì hàm làm hộ. */
void nguoi_huy(Nguoi **pp)
{
if (pp == NULL || *pp == NULL) return;
free((*pp)->ten);
free(*pp);
*pp = NULL; /* con trỏ của người gọi cũng thành NULL */
}
Nguoi *n = nguoi_tao("An");
nguoi_huy(&n); /* n giờ là NULL */
nguoi_huy(&n); /* an toàn */terminal
# Ba công cụ bắt được lỗi này
gcc -std=c17 -g -fsanitize=address bai.c -o bai && ./bai
valgrind ./bai
==1234== Invalid free() / delete / delete[] / realloc() ==1234== at 0x484B27F: free ==1234== by 0x109205: main (bai.c:10) ==1234== Address 0x4a9d040 is 0 bytes inside a block of size 4 free'd ==1234== at 0x484B27F: free ==1234== by 0x1091F0: main (bai.c:9)
gcc -fanalyzer -std=c17 bai.c -o bai
bai.c:10:5: warning: double-'free' of 'p' [CWE-415] [-Wanalyzer-double-free]
Tự làm thử
- Gọi
freehai lần liên tiếp, chạy thường rồi chạy với ba công cụ trong bài, so sánh ba thông báo. - Cài kịch bản có
mallocxen giữa hai lầnfree, quan sát hai lời gọimallocsau đó trả về cùng địa chỉ. - Tạo hai struct cùng trỏ vào một chuỗi rồi hủy cả hai, xác nhận lỗi bằng valgrind.
- Viết hàm hủy không đặt
NULL, gọi nó hai lần, rồi sửa thành hàm lũy đẳng và gọi lại. - Cài hàm hủy nhận con trỏ hai tầng, xác nhận con trỏ của người gọi thành
NULLsau khi gọi. - Viết macro
GIAI_PHONGvà dùng nó thay mọi lời gọifreetrong một chương trình cũ của bạn.
Trình chấm điểm tự động sẽ được bổ sung ở giai đoạn sau. Hiện tại bạn tự chạy thử trên máy.
Tóm tắt
- Giải phóng hai lần phá danh sách khối trống của bộ cấp phát, và hậu quả tệ nhất là hai lời gọi malloc trả về cùng địa chỉ.
- Đây là lỗ hổng bảo mật thật, mã CWE-415, cho phép kẻ tấn công ghi vào địa chỉ tùy ý.
- Ba nguyên nhân: hai con trỏ cùng trỏ một vùng, nhánh xử lý lỗi dọn hai lần, và hàm hủy bị gọi lại.
free(NULL)là trường hợp duy nhất gọi lặp an toàn, và chuẩn C bảo đảm điều đó.- Bốn quy tắc phòng: đặt NULL sau free, một chủ sở hữu, hàm hủy lũy đẳng, và hàm hủy nhận con trỏ hai tầng.