Bỏ qua điều hướng, tới nội dung chính
Học C
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
    ret
gcc -O2 -S -masm=intel -o - tran.c
kiem_tra_tran:
    xor  eax, eax
    ret
Ba bước suy luận, và không bước nào sai. Sai là ở tiền đề mà bạn đã cho phép.
terminal
# Đủ 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
    ret

Cả 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
    ret
gcc -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 saiThực tế
UB chỉ ảnh hưởng dòng có lỗiSaiNó ảnh hưởng cả nhánh, và có thể cả hàm gọi
Mã trước điểm UB vẫn chạySaiChuẩ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ậpSaiThường thì không sập, đó mới là vấn đề
Chạy đúng ở máy tôi nghĩa là không có UBSaiNghĩa là UB chưa lộ ra ở cấu hình này
Trình biên dịch sẽ cảnh báoSaiNhiều dạng UB không phát hiện được lúc dịch
Sanitizer bắt được hếtSaiChú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) -Werror

Tự làm thử

  1. Dịch hàm kiểm tra tràn ở -O0 và -O2, so hợp ngữ.
  2. Thêm -fwrapv và xác nhận đoạn kiểm tra sống lại.
  3. Viết hai hàm khác nhau ở thứ tự kiểm tra NULL và so hợp ngữ.
  4. 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 đó.
  5. Viết hàm trả về con trỏ tới biến cục bộ và in giá trị nhận được ở -O0 và -O2.
  6. Á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 ở -O0 trê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ành mov 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 -Wextra và sanitizer trong kiểm thử.