Bài 33.230 phút đọc
Vì sao UB nguy hiểm hơn chương trình sập
Sau bài này bạn sẽ làm được
- Chỉ ra hàm kiểm tra tràn bị tối ưu thành return 0
- Giải thích lỗ hổng xóa kiểm tra NULL trong nhân Linux 2009
- Nêu vì sao UB làm hỏng cả phần mã chạy trước đó
- Đọc hợp ngữ để xác nhận một đoạn mã đã bị xóa
Trình biên dịch được phép giả định hành vi không xác định không bao giờ xảy ra. Từ giả định đó nó suy luận tiếp, và kết luận đầu tiên nó rút ra thường là: đoạn kiểm tra của bạn là thừa.
#Kiểm tra tràn bị xóa
tran.c
#include <limits.h>
int kiem_tra_tran(int x) {
if (x + 1 < x) return 1;
return 0;
}
int kiem_tra_dung(int x) {
if (x > INT_MAX - 1) return 1;
return 0;
}
int kiem_tra_builtin(int x) {
int r;
if (__builtin_add_overflow(x, 1, &r)) return 1;
return 0;
}terminal
# Không tối ưu chút nào
gcc -O0 -S -masm=intel -o - tran.c
kiem_tra_tran:
mov DWORD PTR 16[rbp], ecx
mov eax, 0 ; ca cau if da bien mat
pop rbp
retgcc -O2 -S -masm=intel -o - tran.c
kiem_tra_tran:
xor eax, eax
retterminal
# Đủ mọi cờ cảnh báo, kể cả cờ chuyên về tràn
gcc -O2 -Wall -Wextra -Wstrict-overflow=5 -c tran.c
(khong co canh bao nao)
Kiểm tra sau khi đã tràn
if (x + 1 < x) return 1; /* bien mat */
if (a + b < 0) return 1; /* bien mat, neu a va b duong */
if (a * b / b != a) return 1; /* bien mat */
/* Diem chung: ca ba deu THUC HIEN phep tinh tran roi moi kiem tra.
Nhung phep tinh do da la UB, nen khong co gi de kiem tra nua. */Kiểm tra trước khi tính
#include <limits.h>
if (x > INT_MAX - 1) return 1; /* kiem tra TRUOC khi cong */
if (a > 0 && b > INT_MAX - a) return 1; /* tong quat cho a + b */
int r;
if (__builtin_mul_overflow(a, b, &r)) return 1; /* GCC va Clang */terminal
gcc -O2 -S -masm=intel -o - tran.c
kiem_tra_dung:
xor eax, eax
cmp ecx, 2147483647
sete al
ret
kiem_tra_builtin:
xor eax, eax
add ecx, 1
seto al ; doc thang co tran cua CPU
retCả hai bản đúng đều còn nguyên, và bản __builtin chỉ tốn thêm đúng một lệnh seto vì nó đọc thẳng cờ tràn của CPU.
#Kiểm tra NULL bị xóa
nul.c
#include <stddef.h>
struct S { int truong; };
void ghi_log(int x);
int f(struct S *p) {
int x = p->truong; /* giai tham chieu TRUOC */
if (p == NULL) return -1; /* kiem tra SAU */
ghi_log(x);
return x;
}
int g(struct S *p) {
if (p == NULL) return -1; /* kiem tra TRUOC */
int x = p->truong;
ghi_log(x);
return x;
}terminal
gcc -O2 -S -masm=intel -o - nul.c
f:
push rbx
sub rsp, 32
mov ebx, DWORD PTR [rcx] ; doc thang, khong kiem tra gi
mov ecx, ebx
call ghi_log
mov eax, ebx
add rsp, 32
pop rbx
retgcc -O2 -S -masm=intel -o - nul.c
g:
push rbx
sub rsp, 32
test rcx, rcx ; kiem tra con nguyen
je .L5
mov ebx, DWORD PTR [rcx]
mov ecx, ebx
call ghi_log
.L3:
mov eax, ebx
add rsp, 32
pop rbx
ret#Vòng lặp và hàm thiếu return
lung.c
__attribute__((noinline)) int thieu_return(int x) {
if (x > 0) return 1;
} /* khong co return cho x <= 0 */
__attribute__((noinline)) int *tra_ve_cuc_bo(void) {
int x = 42;
return &x; /* tra ve dia chi bien cuc bo */
}terminal
gcc -std=c11 -O2 -Wall -Wextra -o lung lung.c
lung.c:5:1: warning: control reaches end of non-void function [-Wreturn-type] lung.c:9:12: warning: function returns address of local variable [-Wreturn-local-addr]
./lung
thieu_return(5) = 1 thieu_return(-5) = 1 con tro cuc bo = 0000000000000000
Vòng lặp không bao giờ kết thúc
/* Neu n == INT_MAX thi i tran o vong cuoi, tuc UB. */
for (int i = 0; i <= n; ++i) { ... }
/* Trinh bien dich duoc phep gia dinh khong tran, nen no biet
i < n + 1 luon dung, nen dieu kien dung khong bao gio thoa.
Ket qua co the la mot vong lap vo tan.
Va do la CAI GIA cua viec dinh nghia tran so co dau: neu chuan
bat buoc quan vong, trinh bien dich phai cat i ve 32 bit moi vong
thay vi de no trong thanh ghi 64 bit. Voi vong lap dai thi do la
mot lenh thua moi vong.
Cach viet an toan: */
for (int i = 0; i < n; ++i) /* dung < thay vi <=, neu duoc */
for (size_t i = 0; i <= n; ++i) /* so khong dau: quan vong la hop le,
nhung can than vong vo tan neu
n == SIZE_MAX */#UB lan ngược về phía trước
Một ví dụ thật của Clang
int f(int x) {
if (x == 0) {
ghi_log("truong hop dac biet");
return 1 / x; /* chia cho 0: UB */
}
return 100 / x;
}
/* Clang co the:
- thay nhanh x == 0 chac chan dan toi UB
- suy ra x == 0 khong bao gio xay ra
- xoa ca nhanh, KE CA loi goi ghi_log
- va xoa luon cau if, vi dieu kien luon sai
Ket qua: ban mat dong nhat ky ma ban dat vao de go loi. */| Điều bạn tưởng | Đúng hay sai | Thực tế |
|---|---|---|
| UB chỉ ảnh hưởng dòng có lỗi | Sai | Nó ảnh hưởng cả nhánh, và có thể cả hàm gọi |
| Mã trước điểm UB vẫn chạy | Sai | Chuẩn không bảo đảm, và trình biên dịch có khai thác |
| UB làm chương trình sập | Sai | Thường thì không sập, đó mới là vấn đề |
| Chạy đúng ở máy tôi nghĩa là không có UB | Sai | Nghĩa là UB chưa lộ ra ở cấu hình này |
| Trình biên dịch sẽ cảnh báo | Sai | Nhiều dạng UB không phát hiện được lúc dịch |
| Sanitizer bắt được hết | Sai | Chúng bắt được nhiều, không phải tất cả. Chương 34 |
#Phòng chứ không chữa
Bộ cờ nên dùng cho mọi dự án C
# Ban go loi: bat het, dung ngay khi gap loi
CFLAGS_DEBUG = -std=c11 -g3 -O1 \
-Wall -Wextra -Wpedantic \
-Wshadow -Wconversion -Wcast-qual \
-Wstrict-prototypes -Wmissing-prototypes \
-Wnull-dereference -Wdouble-promotion \
-fsanitize=address,undefined \
-fno-omit-frame-pointer \
-fno-sanitize-recover=all
# Ban phat hanh: toi uu, van giu canh bao
CFLAGS_RELEASE = -std=c11 -O2 -DNDEBUG \
-Wall -Wextra -Wpedantic
# Trong tich hop lien tuc: canh bao la loi
CFLAGS_CI = $(CFLAGS_DEBUG) -WerrorTự làm thử
- Dịch hàm kiểm tra tràn ở
-O0và-O2, so hợp ngữ. - Thêm
-fwrapvvà xác nhận đoạn kiểm tra sống lại. - Viết hai hàm khác nhau ở thứ tự kiểm tra NULL và so hợp ngữ.
- Viết hàm thiếu
returnở một nhánh rồi chạy nó với đầu vào rơi vào nhánh đó. - Viết hàm trả về con trỏ tới biến cục bộ và in giá trị nhận được ở
-O0và-O2. - Áp bộ cờ ở cuối bài vào một dự án cũ của bạn và đếm số cảnh báo.
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
if (x + 1 < x)biến mất ngay ở-O0trên GCC 8.1, không phải chỉ ở-O2.- Kiểm tra NULL sau khi đã giải tham chiếu bị xóa hoàn toàn, và không có cảnh báo nào kể cả với
-Wnull-dereference. - Đó chính là CVE-2009-1897 trong nhân Linux, và lỗi nằm ở thứ tự hai dòng mã chứ không ở trình biên dịch.
- Hàm thiếu
returnở một nhánh bị rút thànhmov eax, 1, và hàm trả về địa chỉ biến cục bộ bị thay bằng NULL. - Cách chữa duy nhất là kiểm tra trước khi tính, cộng với
-Wall -Wextravà sanitizer trong kiểm thử.