Bỏ qua điều hướng, tới nội dung chính
Học C
Bài 14.1320 phút đọc

Dùng sau khi giải phóng

Sau bài này bạn sẽ làm được

  • Hiểu vì sao đây là lỗi khó phát hiện nhất trong bốn loại
  • Biết cơ chế kẻ tấn công dùng để khai thác nó
  • Dùng đủ bộ ba công cụ phát hiện lỗi bộ nhớ
  • Tổng hợp mười quy tắc quản lý bộ nhớ của cả chương

Bài cuối của Phần 6. Dùng sau khi giải phóng là loại lỗi bộ nhớ khó phát hiện nhất trong bốn loại, và cũng là loại được khai thác nhiều nhất. Bài này khép lại chương bằng mười quy tắc gom từ toàn bộ những gì đã học.

#Vì sao đây là loại nguy hiểm nhất

Dùng sau khi giải phóng
Đọc hoặc ghi qua một con trỏ trỏ vào vùng nhớ đã được trả lại cho bộ cấp phát.
Loại lỗiSập ngayBị công cụ bắtKhai thác đượcMức nguy hiểm
Rò rỉ bộ nhớKhôngRất dễHiếmThấp
Tràn bộ đệmThường cóDễCao
Giải phóng hai lầnThường cóDễCao
Dùng sau khi giải phóngKhôngKhó nhấtRất cao
thoi-diem.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int main(int argc, char **argv)
{
    char *a = malloc(32);

    if (a == NULL) return 1;

    strcpy(a, "du lieu goc");

    free(a);

    if (argc > 1) {
        char *b = malloc(32);      /* rất có thể lấy lại đúng khối của a */

        if (b != NULL) strcpy(b, "du lieu moi");
    }

    printf("[%s]\n", a);          /* dùng sau khi giải phóng */

    return 0;
}
terminal
./thoi-diem
[du lieu goc]      # trông như chạy đúng
./thoi-diem x
[du lieu moi]      # cùng mã, kết quả khác

Cùng một dòng printf, cùng một con trỏ, mà kết quả phụ thuộc vào chuyện có ai gọi malloc ở giữa hay không. Đó là lý do bộ kiểm thử không bắt được loại lỗi này.

#Cơ chế khai thác

Hiểu cơ chế này giúp bạn thấy vì sao lỗi bộ nhớ không chỉ là chuyện chương trình chạy sai, mà là chuyện an ninh.

Bước 1. Chương trình cấp một struct chứa con trỏ hàm và giải phóng nó,
        nhưng vẫn giữ con trỏ.

   [ so_hieu | con_tro_ham ]   <- khối 16 byte, đã free nhưng p vẫn trỏ tới

Bước 2. Kẻ tấn công gửi dữ liệu khiến chương trình gọi malloc(16)
        và ghi dữ liệu do họ chọn vào đó.

   [ 41414141 | dia_chi_ma_cua_ke_tan_cong ]

Bước 3. Chương trình gọi p->con_tro_ham(), tưởng vẫn là hàm cũ.

   Nhảy thẳng vào mã của kẻ tấn công.

#Bốn biến thể

Biến thể 1: đọc sau khi giải phóng

char *p = malloc(64);

strcpy(p, "bi mat");

free(p);

printf("%s\n", p);      /* đọc dữ liệu đã trả lại, có thể lộ thông tin */

Biến thể 2: ghi sau khi giải phóng

char *p = malloc(64);

free(p);

strcpy(p, "du lieu moi");   /* phá dữ liệu của người khác, hoặc phá sổ sách */

Biến thể 3: dùng qua một con trỏ khác

con-tro-khac.c
typedef struct Nut {
    int         gia_tri;
    struct Nut *tiep;
} Nut;

void huy_sai(Nut *dau)
{
    while (dau != NULL) {
        free(dau);
        dau = dau->tiep;      /* đọc trường của nút VỪA giải phóng */
    }
}

void huy_dung(Nut *dau)
{
    while (dau != NULL) {
        Nut *tiep = dau->tiep;   /* lưu TRƯỚC khi giải phóng */

        free(dau);
        dau = tiep;
    }
}

Đây là lỗi kinh điển khi mới học danh sách liên kết, và nó thuộc loại giai đoạn một ở Bài 14.11: thường vẫn chạy đúng, vì trường tiep chưa bị ghi đè. Bạn chỉ phát hiện khi chạy dưới trình dò lỗi.

Biến thể 4: dùng sau khi vùng chứa bị hủy

typedef struct { char *ten; } Nguoi;

Nguoi      *ds = malloc(10 * sizeof *ds);
const char *t;

ds[0].ten = malloc(64);
strcpy(ds[0].ten, "An");

t = ds[0].ten;         /* mượn con trỏ vào bên trong */

nguoi_huy(&ds[0]);     /* giải phóng ds[0].ten */

printf("%s\n", t);    /* t treo */

#Bộ công cụ phát hiện

Công cụBắt đượcChi phíKhi nào dùng
-fsanitize=addressGần như mọi lỗi bộ nhớ lúc chạyChậm khoảng hai lần, tốn thêm bộ nhớSuốt quá trình phát triển và chạy kiểm thử
-fsanitize=undefinedTràn số, chỉ số ngoài biên, dịch bit quá mứcChậm khoảng 20 phần trămCùng lúc với cờ trên
valgrindLỗi bộ nhớ, không cần biên dịch lạiChậm 10 tới 50 lầnKhi không biên dịch lại được, hoặc cần báo cáo chi tiết
-fanalyzerLỗi ở nhánh mà kiểm thử không chạm tớiChỉ tốn thời gian biên dịchTrong quy trình tích hợp liên tục
-fsanitize=memoryDùng giá trị chưa khởi tạoChậm khoảng ba lần, chỉ có ở ClangKhi nghi ngờ đọc dữ liệu chưa khởi tạo
terminal
# Bộ cờ nên dùng khi phát triển
gcc -std=c17 -Wall -Wextra -g -O1 -fsanitize=address,undefined -fno-omit-frame-pointer bai.c -o bai
# Bộ cờ nên dùng khi kiểm thử tự động
gcc -std=c17 -Wall -Wextra -Werror -fanalyzer -g -fsanitize=address,undefined bai.c -o bai
# Bộ cờ cho bản phát hành
gcc -std=c17 -Wall -Wextra -O2 -DNDEBUG bai.c -o bai

Đưa vào quy trình kiểm thử tự động

Makefile
CC      = gcc
CFLAGS  = -std=c17 -Wall -Wextra -g
SAN     = -fsanitize=address,undefined -fno-omit-frame-pointer

.PHONY: all test phat-hanh sach

all: bai

bai: bai.c
	$(CC) $(CFLAGS) -O2 bai.c -o bai

test: bai.c test.c
	$(CC) $(CFLAGS) $(SAN) -O1 bai.c test.c -o chay-test
	./chay-test
	valgrind --leak-check=full --error-exitcode=1 ./chay-test

phat-hanh: bai.c
	$(CC) -std=c17 -Wall -Wextra -O2 -DNDEBUG bai.c -o bai

sach:
	rm -f bai chay-test

Cờ --error-exitcode=1 làm valgrind trả về mã thoát khác 0 khi tìm thấy lỗi, nên quy trình tích hợp liên tục tự động báo hỏng. Chương 37 sẽ dạy Makefile đầy đủ.

#Mười quy tắc của cả chương

Quy tắcBài
1Luôn kiểm tra kết quả của malloc, calloc và realloc khác NULL14.6
2Viết malloc(n * sizeof *p), không viết sizeof(kiểu) và không viết sizeof p14.6
3Kiểm tra tràn trước khi nhân kích thước, hoặc dùng calloc14.6 và 14.7
4Không bao giờ viết p = realloc(p, ...), luôn qua biến tạm14.8
5Mỗi malloc có đúng một free, và ngược lại14.9 và 14.12
6Đặt NULL ngay sau khi giải phóng14.9 và 14.11
7Mỗi vùng nhớ có đúng một chủ sở hữu, ghi rõ trong tài liệu14.10 và 14.11
8Hàm nhiều lối thoát dùng mẫu nhãn dọn dẹp14.10
9Hàm hủy phải chấp nhận NULL và gọi được nhiều lần14.10 và 14.12
10Luôn chạy kiểm thử dưới trình dò lỗi địa chỉCả chương

Bảng tra nhanh bốn loại lỗi

LỗiĐịnh nghĩa ngắnCông cụ bắtCách phòng chính
Rò rỉMất con trỏ, còn vùng nhớLeakSanitizer, valgrindNhãn dọn dẹp, cặp hàm tạo và hủy
Con trỏ treoCòn con trỏ, mất vùng nhớAddressSanitizerĐặt NULL, một chủ sở hữu
Giải phóng hai lầnGọi free hai lần cùng một khốiAddressSanitizer, valgrindĐặt NULL, hàm hủy lũy đẳng
Dùng sau khi giải phóngĐọc ghi qua con trỏ treoAddressSanitizerMột chủ sở hữu, dùng chỉ số thay con trỏ
Một hàm vi phạm sáu quy tắc
char *xu_ly(const char *nguon, size_t n)
{
    char *a = malloc(n);              /* 1. không kiểm tra NULL */

    memcpy(a, nguon, n);

    a = realloc(a, n * 2);            /* 4. gán thẳng vào a */

    char *b = malloc(n * sizeof a);   /* 2. sizeof của con trỏ */

    if (b == NULL) return NULL;       /* 8. rò rỉ a */

    memcpy(b, a, n);

    free(a);
    free(a);                          /* 5. giải phóng hai lần */

    return b;                         /* 7. không ghi ai sở hữu b */
}
Cùng hàm, viết đúng
/* Trả về vùng nhớ mới chứa n byte đầu của nguon, nới rộng thành 2n.
   Người gọi PHẢI gọi free. Trả về NULL nếu thất bại. */
char *xu_ly(const char *nguon, size_t n)
{
    char *kq = NULL;
    char *a  = NULL;

    if (nguon == NULL || n == 0 || n > SIZE_MAX / 2) return NULL;

    a = malloc(n);
    if (a == NULL) goto don_dep;

    memcpy(a, nguon, n);

    char *tam = realloc(a, n * 2);
    if (tam == NULL) goto don_dep;

    a = tam;

    kq = malloc(n * sizeof *kq);
    if (kq == NULL) goto don_dep;

    memcpy(kq, a, n);

don_dep:
    free(a);

    return kq;
}

Tự làm thử

  1. Cài chương trình thoi-diem.c trong bài, chạy có và không có đối số, giải thích hai kết quả.
  2. Viết hàm hủy danh sách liên kết theo cả hai cách, chạy dưới -fsanitize=address và so hai thông báo.
  3. Chạy cùng một chương trình lỗi dưới cả năm công cụ trong bảng, lập bảng so sánh những gì mỗi công cụ bắt được.
  4. Viết Makefile có ba đích như trong bài, chạy đích kiểm thử và xác nhận nó báo hỏng khi có lỗi bộ nhớ.
  5. Lấy hàm vi phạm sáu quy tắc trong bài, chỉ ra từng vi phạm rồi tự viết lại trước khi xem cột bên phải.
  6. Rà toàn bộ chương trình lớn nhất bạn đã viết, kiểm tra từng lời gọi malloc theo ba câu hỏi trong bài.
  7. Đưa một chương trình cũ về trạng thái sạch với cả -fsanitize=address,undefined, valgrind và -fanalyzer.

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

  • Dùng sau khi giải phóng là loại khó nhất vì không sập ngay, kết quả phụ thuộc thời điểm, và triệu chứng ở rất xa nguyên nhân.
  • Nó là lỗ hổng bảo mật mã CWE-416, cho phép kẻ tấn công thay con trỏ hàm và chiếm quyền điều khiển.
  • Bốn biến thể: đọc, ghi, dùng qua con trỏ khác, và dùng sau khi vùng chứa bị hủy.
  • Bộ cờ phát triển là -Wall -Wextra -g -O1 -fsanitize=address,undefined, và bản phát hành thì không có cờ dò lỗi nào.
  • Ba câu hỏi cho mọi malloc: ai giải phóng, khi nào, và có ai khác đang giữ con trỏ không.