下面這段程式碼中 is_valid()
解除參照了空指標 str
,我們的直覺是編譯執行後將迎來 SIGSEGV,然而事情並非所期望的那樣。
/*
* ub_null.c - 未定義行為演示 之 NULL dereference
*/
#include <stdio.h>
#include <string.h>
int is_valid(const char *str)
{
if(*str == 0x80) return 1;
if(str == NULL) return 0;
return strcmp(str, "expected string") == 0;
}
int main(void)
{
const char *str = NULL;
printf("%d\n", is_valid(str));
return 0;
}
lyazj@HelloWorld:~$ gcc --version gcc (Ubuntu 11.3.0-1ubuntu1~22.04.1) 11.3.0 Copyright (C) 2021 Free Software Foundation, Inc. This is free software; see the source for copying conditions. There is NO warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. lyazj@HelloWorld:~$ gcc -Wall -Wshadow -Wextra ub_null.c -o ub_null -O0 ub_null.c: In function ‘is_valid’: ub_null.c:6:11: warning: comparison is always false due to limited range of data type [-Wtype-limits] 6 | if(*str == 0x80) return 0; | ^~ lyazj@HelloWorld:~$ ./ub_null 0
結合 GCC 發出的警告,不難推斷出條件表示式 *str == 0x80
在編譯期被求值且相應的 if
語句被優化掉了,而且這是在 O0 的優化等級下。以下的反組合結果驗證了這一點。
lyazj@HelloWorld:~$ objdump --disassemble=is_valid -j.text ub_null ub_null: file format elf64-x86-64 Disassembly of section .text: 0000000000001169 <is_valid>: 1169: f3 0f 1e fa endbr64 116d: 55 push %rbp 116e: 48 89 e5 mov %rsp,%rbp 1171: 48 83 ec 10 sub $0x10,%rsp 1175: 48 89 7d f8 mov %rdi,-0x8(%rbp) 1179: 48 83 7d f8 00 cmpq $0x0,-0x8(%rbp) 117e: 75 07 jne 1187 <is_valid+0x1e> 1180: b8 00 00 00 00 mov $0x0,%eax 1185: eb 1e jmp 11a5 <is_valid+0x3c> 1187: 48 8b 45 f8 mov -0x8(%rbp),%rax 118b: 48 8d 15 72 0e 00 00 lea 0xe72(%rip),%rdx # 2004 <_IO_stdin_used+0x4> 1192: 48 89 d6 mov %rdx,%rsi 1195: 48 89 c7 mov %rax,%rdi 1198: e8 d3 fe ff ff call 1070 <strcmp@plt> 119d: 85 c0 test %eax,%eax 119f: 0f 94 c0 sete %al 11a2: 0f b6 c0 movzbl %al,%eax 11a5: c9 leave 11a6: c3 ret
我們在同一環境對 O3 優化等級做相同的實驗,得到了相同的結果:
lyazj@HelloWorld:~$ gcc -Wall -Wshadow -Wextra ub_null.c -o ub_null -O3 ub_null.c: In function ‘is_valid’: ub_null.c:6:11: warning: comparison is always false due to limited range of data type [-Wtype-limits] 6 | if(*str == 0x80) return 0; | ^~ lyazj@HelloWorld:~$ ./ub_null 0 lyazj@HelloWorld:~$ objdump --disassemble=is_valid -j.text ub_null ub_null: file format elf64-x86-64 Disassembly of section .text: 00000000000011a0 <is_valid>: 11a0: f3 0f 1e fa endbr64 11a4: 48 85 ff test %rdi,%rdi 11a7: 74 27 je 11d0 <is_valid+0x30> 11a9: 48 83 ec 08 sub $0x8,%rsp 11ad: 48 8d 35 50 0e 00 00 lea 0xe50(%rip),%rsi # 2004 <_IO_stdin_used+0x4> 11b4: e8 a7 fe ff ff call 1060 <strcmp@plt> 11b9: 85 c0 test %eax,%eax 11bb: 0f 94 c0 sete %al 11be: 48 83 c4 08 add $0x8,%rsp 11c2: 0f b6 c0 movzbl %al,%eax 11c5: c3 ret 11c6: 66 2e 0f 1f 84 00 00 cs nopw 0x0(%rax,%rax,1) 11cd: 00 00 00 11d0: 31 c0 xor %eax,%eax 11d2: c3 ret
接下來我們用下面的兩行程式碼替換被優化掉的 if
語句,看看會發生什麼:
char head = *str;
if(head == 0x80) return 0;
lyazj@HelloWorld:~$ gcc -Wall -Wshadow -Wextra ub_null.c -o ub_null -O0 ub_null.c: In function ‘is_valid’: ub_null.c:10:11: warning: comparison is always false due to limited range of data type [-Wtype-limits] 10 | if(head == 0x80) return 0; | ^~ lyazj@HelloWorld:~$ ./ub_null Segmentation fault lyazj@HelloWorld:~$ objdump --disassemble=is_valid -j.text ub_null ub_null: file format elf64-x86-64 Disassembly of section .text: 0000000000001169 <is_valid>: 1169: f3 0f 1e fa endbr64 116d: 55 push %rbp 116e: 48 89 e5 mov %rsp,%rbp 1171: 48 83 ec 20 sub $0x20,%rsp 1175: 48 89 7d e8 mov %rdi,-0x18(%rbp) 1179: 48 8b 45 e8 mov -0x18(%rbp),%rax 117d: 0f b6 00 movzbl (%rax),%eax 1180: 88 45 ff mov %al,-0x1(%rbp) 1183: 48 83 7d e8 00 cmpq $0x0,-0x18(%rbp) 1188: 75 07 jne 1191 <is_valid+0x28> 118a: b8 00 00 00 00 mov $0x0,%eax 118f: eb 1e jmp 11af <is_valid+0x46> 1191: 48 8b 45 e8 mov -0x18(%rbp),%rax 1195: 48 8d 15 68 0e 00 00 lea 0xe68(%rip),%rdx # 2004 <_IO_stdin_used+0x4> 119c: 48 89 d6 mov %rdx,%rsi 119f: 48 89 c7 mov %rax,%rdi 11a2: e8 c9 fe ff ff call 1070 <strcmp@plt> 11a7: 85 c0 test %eax,%eax 11a9: 0f 94 c0 sete %al 11ac: 0f b6 c0 movzbl %al,%eax 11af: c9 leave 11b0: c3 ret
段錯誤如願以償地發生了,且是來自讀取 str
處 1 位元組並進行零擴充套件的 movzbl
指令,之前看到的編譯期求值沒有再次發生。
現在升高優化等級至 Og,編譯期求值並優化掉第一個 if
語句的特效迴歸了:
lyazj@HelloWorld:~$ gcc -Wall -Wshadow -Wextra ub_null.c -o ub_null -Og ub_null.c: In function ‘is_valid’: ub_null.c:7:11: warning: comparison is always false due to limited range of data type [-Wtype-limits] 7 | if(head == 0x80) return 0; | ^~ lyazj@HelloWorld:~$ ./ub_null 0 lyazj@HelloWorld:~$ objdump --disassemble=is_valid -j.text ub_null ub_null: file format elf64-x86-64 Disassembly of section .text: 0000000000001169 <is_valid>: 1169: f3 0f 1e fa endbr64 116d: 48 85 ff test %rdi,%rdi 1170: 74 1d je 118f <is_valid+0x26> 1172: 48 83 ec 08 sub $0x8,%rsp 1176: 48 8d 35 87 0e 00 00 lea 0xe87(%rip),%rsi # 2004 <_IO_stdin_used+0x4> 117d: e8 de fe ff ff call 1060 <strcmp@plt> 1182: 85 c0 test %eax,%eax 1184: 0f 94 c0 sete %al 1187: 0f b6 c0 movzbl %al,%eax 118a: 48 83 c4 08 add $0x8,%rsp 118e: c3 ret 118f: b8 00 00 00 00 mov $0x0,%eax 1194: c3 ret
GCC 如何優化,除取決於編譯選項外,同樣取決於程式設計師編寫什麼樣的原始碼,這一點不足為奇。然而,當優化等級升至 O2 時,更為不好的事情發生了:
lyazj@HelloWorld:~$ gcc -Wall -Wshadow -Wextra ub_null.c -o ub_null -O2 ub_null.c: In function ‘is_valid’: ub_null.c:7:11: warning: comparison is always false due to limited range of data type [-Wtype-limits] 7 | if(head == 0x80) return 0; | ^~ In function ‘is_valid’, inlined from ‘main’ at ub_null.c:15:3: ub_null.c:9:10: warning: argument 1 null where non-null expected [-Wnonnull] 9 | return strcmp(str, "expected string") == 0; | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ In file included from ub_null.c:2: ub_null.c: In function ‘main’: /usr/include/string.h:156:12: note: in a call to function ‘strcmp’ declared ‘nonnull’ 156 | extern int strcmp (const char *__s1, const char *__s2) | ^~~~~~ lyazj@HelloWorld:~$ ./ub_null Segmentation fault lyazj@HelloWorld:~$ objdump --disassemble=is_valid -j.text ub_null ub_null: file format elf64-x86-64 Disassembly of section .text: 00000000000011b0 <is_valid>: 11b0: f3 0f 1e fa endbr64 11b4: 48 83 ec 08 sub $0x8,%rsp 11b8: 48 8d 35 45 0e 00 00 lea 0xe45(%rip),%rsi # 2004 <_IO_stdin_used+0x4> 11bf: e8 9c fe ff ff call 1060 <strcmp@plt> 11c4: 85 c0 test %eax,%eax 11c6: 0f 94 c0 sete %al 11c9: 48 83 c4 08 add $0x8,%rsp 11cd: 0f b6 c0 movzbl %al,%eax 11d0: c3 ret
值得注意的是,現在段錯誤來自 strcmp()
中的 NULL dereference,且 is_valid()
的反組合出奇地簡單,GCC 同時幹掉了兩個 if
語句!因為我們首先存取了 str
處的 1 位元組,由於 NULL dereference 是典型的 UB,編譯器便假定了 str != NULL
,這樣第二個 if
語句也可以被優化掉!現在,我們產生了具有嚴重漏洞的 is_valid()
函數,當 str == NULL
時,程式將發生嚴重錯誤。
解決 bug 的方法是顯然的,即將 head
轉換為 unsigned char
再比較,並調換兩個 if
語句的順序。NULL dereference,這是一個曾經讓 Linux 核心爆出嚴重漏洞的 UB,我們剛剛成功復現了這一過程。誠然,此處的程式是異常簡單的,看出兩個寫反的 if
語句非常容易;但對於程式碼業務特別複雜的場景,特別是對一行程式碼需要數行註釋的底層程式碼或其它核心程式碼而言,這個 bug 可能一致遺留下來,併成為一個長期休眠的不定時炸彈。它的出現讓許多可能是至關重要的程式碼段,如第二個 if
語句失效,可能給程式使用者造成難以預計的後果。還好當前版本的 GCC 友好地給出了應有的警告,這也再次向我們證明,隨意地忽略編譯器給出的 Warning 是不可取的。
Google 等團隊開發的 sanitizer 已經整合到了當前版本的 GCC 中,讓我們用 sanitizer 更為有效地發現上述未定義行為:
lyazj@HelloWorld:~$ gcc -Wall -Wshadow -Wextra ub_null.c -o ub_null -O2 -fsanitize=undefined -fno-sanitize-recover=all ub_null.c: In function ‘is_valid’: ub_null.c:7:11: warning: comparison is always false due to limited range of data type [-Wtype-limits] 7 | if(head == 0x80) return 0; | ^~ lyazj@HelloWorld:~$ ./ub_null ub_null.c:6:8: runtime error: load of null pointer of type 'const char'
看到這裡,是不是很輕鬆就發現了兩個 if
語句寫反的問題?