【持續更新】C/C++ 踩坑記錄(一)

2023-07-26 06:00:46

未定義行為之 NULL dereference

下面這段程式碼中 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 語句寫反的問題?