Bỏ qua điều hướng, tới nội dung chính
Học C
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:9
am-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ảnKết quảCó báo lỗi không
free hai lần liên tiếpBộ cấp phát phát hiện và dừng chương trìnhCó, thông báo rõ ràng
Có malloc xen giữaDanh 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ệuGhi được vào địa chỉ tùy ý, chiếm quyền điều khiểnKhô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ốngAn toàn khôngVì sao
free(NULL) nhiều lầnChuẩn C bảo đảm không làm gì
free(p) rồi p = NULL rồi free(p)Lần hai là free(NULL)
free(p) rồi free(p)KhôngHành vi không xác định
free(a) rồi free(b) với a và b cùng địa chỉKhôngVẫ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)Đâ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ử

  1. Gọi free hai 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.
  2. Cài kịch bản có malloc xen giữa hai lần free, quan sát hai lời gọi malloc sau đó trả về cùng địa chỉ.
  3. 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.
  4. 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.
  5. 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 NULL sau khi gọi.
  6. Viết macro GIAI_PHONG và dùng nó thay mọi lời gọi free trong 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.