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
| Loại lỗi | Sập ngay | Bị công cụ bắt | Khai thác được | Mức nguy hiểm |
|---|---|---|---|---|
| Rò rỉ bộ nhớ | Không | Rất dễ | Hiếm | Thấp |
| Tràn bộ đệm | Thường có | Dễ | Có | Cao |
| Giải phóng hai lần | Thường có | Dễ | Có | Cao |
| Dùng sau khi giải phóng | Không | Khó nhất | Có | Rất cao |
#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;
}[du lieu goc] # trông như chạy đúng
[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
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 được | Chi phí | Khi nào dùng |
|---|---|---|---|
| -fsanitize=address | Gần như mọi lỗi bộ nhớ lúc chạy | Chậ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=undefined | Tràn số, chỉ số ngoài biên, dịch bit quá mức | Chậm khoảng 20 phần trăm | Cùng lúc với cờ trên |
| valgrind | Lỗi bộ nhớ, không cần biên dịch lại | Chậm 10 tới 50 lần | Khi không biên dịch lại được, hoặc cần báo cáo chi tiết |
| -fanalyzer | Lỗi ở nhánh mà kiểm thử không chạm tới | Chỉ tốn thời gian biên dịch | Trong quy trình tích hợp liên tục |
| -fsanitize=memory | Dùng giá trị chưa khởi tạo | Chậm khoảng ba lần, chỉ có ở Clang | Khi nghi ngờ đọc dữ liệu chưa khởi tạo |
Đưa vào quy trình kiểm thử tự động
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-testCờ --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ắc | Bài | |
|---|---|---|
| 1 | Luôn kiểm tra kết quả của malloc, calloc và realloc khác NULL | 14.6 |
| 2 | Viết malloc(n * sizeof *p), không viết sizeof(kiểu) và không viết sizeof p | 14.6 |
| 3 | Kiểm tra tràn trước khi nhân kích thước, hoặc dùng calloc | 14.6 và 14.7 |
| 4 | Không bao giờ viết p = realloc(p, ...), luôn qua biến tạm | 14.8 |
| 5 | Mỗi malloc có đúng một free, và ngược lại | 14.9 và 14.12 |
| 6 | Đặt NULL ngay sau khi giải phóng | 14.9 và 14.11 |
| 7 | Mỗi vùng nhớ có đúng một chủ sở hữu, ghi rõ trong tài liệu | 14.10 và 14.11 |
| 8 | Hàm nhiều lối thoát dùng mẫu nhãn dọn dẹp | 14.10 |
| 9 | Hàm hủy phải chấp nhận NULL và gọi được nhiều lần | 14.10 và 14.12 |
| 10 | Luô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ắn | Công cụ bắt | Cách phòng chính |
|---|---|---|---|
| Rò rỉ | Mất con trỏ, còn vùng nhớ | LeakSanitizer, valgrind | Nhãn dọn dẹp, cặp hàm tạo và hủy |
| Con trỏ treo | Còn con trỏ, mất vùng nhớ | AddressSanitizer | Đặt NULL, một chủ sở hữu |
| Giải phóng hai lần | Gọi free hai lần cùng một khối | AddressSanitizer, valgrind | Đặt NULL, hàm hủy lũy đẳng |
| Dùng sau khi giải phóng | Đọc ghi qua con trỏ treo | AddressSanitizer | Một chủ sở hữu, dùng chỉ số thay con trỏ |
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 */
}/* 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ử
- Cài chương trình
thoi-diem.ctrong bài, chạy có và không có đối số, giải thích hai kết quả. - Viết hàm hủy danh sách liên kết theo cả hai cách, chạy dưới
-fsanitize=addressvà so hai thông báo. - 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.
- Viết
Makefilecó 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ớ. - 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.
- 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
malloctheo ba câu hỏi trong bài. - Đư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.