从一个跨二十年的glibc bug说起
1. 缘起
这几天调gcc 7.5.0 +glibc 2.23的交叉编译工具链,由于gcc 7.5.0的默认打开Werr,偶然发现了glibc一个隐藏了二十年的世纪大bug。
这个bug在glibc 2.0版本刚开始就引入了,但直到2.25版本才最终解决,即使按glibc-2.0.1.bin.alpha-linux.tar.gz 版本的发布时间(04-Feb-1997)到glibc-2.25.tar.bz2 的发布时间(05-Feb-2017),也持续了20年加一天。
用gcc 7.5编译的时候如果使能-Wall -Werror这2个选项(-Wall 英文说明是Enable most warning messages,表示使能大多数告警上报;-Werror表示所有告警都当错误来上报,不可忽略),会报下面的错误:
nss_nisplus/nisplus-alias.c: In function '_nss_nisplus_getaliasbyname_r': nss_nisplus/nisplus-alias.c:300:12: error: argument 1 null where non-null expected [-Werror=nonnull] char buf[strlen (name) + 9 + tablename_len]; ^~~~~~~~~~~~~ In file included from ../include/string.h:54:0, from ../sysdeps/generic/hp-timing-common.h:40, from ../sysdeps/x86_64/hp-timing.h:38, from ../include/libc-internal.h:7, from ../sysdeps/x86_64/nptl/tls.h:29, from ../sysdeps/x86_64/atomic-machine.h:20, from ../include/atomic.h:50, from nss_nisplus/nisplus-alias.c:19: ../string/string.h:394:15: note: in a call to function 'strlen' declared here extern size_t strlen (const char *__s) ^~~~~~ nss_nisplus/nisplus-alias.c:303:39: error: '%s' directive argument is null [-Werror=format-truncation=] snprintf (buf, sizeof (buf), "[name=%s],%s", name, tablename_val); ^~ cc1: all warnings being treated as errors
如果不使能-Werror,编译器最多会上报告警,程序还是能正常编译通过。上面2个告警分别对strlen的入参和snprintf的字符串格式化参数做了非空检查,根据代码逻辑判断,两处代码如果执行到,调用的入参确实都必然是空指针。
源代码如下:
276 enum nss_status 277 _nss_nisplus_getaliasbyname_r (const char *name, struct aliasent *alias, 278 char *buffer, size_t buflen, int *errnop) 279 { 280 int parse_res; 281 282 if (tablename_val == NULL) 283 { 284 __libc_lock_lock (lock); 285 286 enum nss_status status = _nss_create_tablename (errnop); 287 288 __libc_lock_unlock (lock); 289 290 if (status != NSS_STATUS_SUCCESS) 291 return status; 292 } 293 294 if (name != NULL) 295 { 296 *errnop = EINVAL; 297 return NSS_STATUS_UNAVAIL; 298 } 299 300 char buf[strlen (name) + 9 + tablename_len]; 301 int olderr = errno; 302 303 snprintf (buf, sizeof (buf), "[name=%s],%s", name, tablename_val); 304 305 nis_result *result = nis_list (buf, FOLLOW_PATH | FOLLOW_LINKS, NULL, NULL);
可以看出300行对应的strlen函数的入参要求非空,但由于294行做了一个非空的判断并返回,也就是说如果294行的if判断为非,那说明name指针必然为空,这时strlen来获取字符串长度就会异常。
具体会怎么异常?我们可以写个简单的例子:
1 #include <stdio.h> 2 #include <string.h> 3 int main() 4 { 5 printf("%d", strlen(NULL)); 6 return 0; 7 }
默认不带任何参数的情况下,gcc会上报告警,但仍然可以编译通过,执行后会出现Segmentation fault:
1 gcc test1.c 2 test1.c: In function 'main': 3 test1.c:5:5: warning: null argument where non-null required (argument 1) [-Wnonnull] 4 printf("%d", strlen(NULL)); 5 ^ 6 test1.c:5:12: warning: format '%d' expects argument of type 'int', but argument 2 has type 'size_t {aka long unsigned int}' [-Wformat=] 7 printf("%d", strlen(NULL)); 8 ^ 9 10 ./a.out 11 Segmentation fault
编译如果加上-Wall -Werror选项会直接报error编译失败:
1 gcc -Wall -Werror test1.c 2 test1.c: In function 'main': 3 test1.c:5:5: error: null argument where non-null required (argument 1) [-Werror=nonnull] 4 printf("%d", strlen(NULL)); 5 ^ 6 test1.c:5:12: error: format '%d' expects argument of type 'int', but argument 2 has type 'size_t {aka long unsigned int}' [-Werror=format=] 7 printf("%d", strlen(NULL)); 8 ^ 9 cc1: all warnings being treated as errors
问题的直接原因还是因为libc库里面的strlen没有做空指针保护,直接访问入参对应的内存了,所以实际上就会出现空指针访问,程序异常退出。
同样的303行的snprintf也要求%s对应的参数不能是空指针,否则也会出现Segmentation fault。
从上面的分析可以看出,有一些warning实际上本身就是错误,应该作为error来处理,在glibc的漫长进化过程中,有很多执行路径可能真的没走到(如果没有100%覆盖率的单元测试,也没有完善的代码review机制,可能永远也没人会发现),或者确实不影响功能的正常发布。但这些告警指向的代码,一旦走到就会出现致命错误。
最终glibc修正代码其实也很简单,就是将294行的“if (name != NULL)”修改成了“if (name == NULL)”,一个运算符用反了。
很多影响非常大的bug,定位之后的实际修改都是简单的一两行代码的事情,但问题的关键是要发现bug并定位bug,并且在bug修正之后的波及测试工作。
这个bug之所有能持续20年没人发现,只能说明glibc中应该还有很多代码在实际场景中没有用到。
2. 编译器的进化
下面这个表格给出了不同clang或者gcc版本新增的代码静态检查的告警计数,为了显得简洁一点,clang7或者更老的clang的所有告警做了一下汇总,gcc 4或者更老的gcc版本的所有告警也做了一下汇总,从中可以看出每次大版本升级,编译器团队都给开发团队提供了一些新的工具能更多的发现自己代码bug的神器。
下面汇总的1204个告警中,有119个告警是clang和gcc都提供的,其他966个告警至少从名称上看是gcc或者clang特有的。其中clang(以clang 12来算)特有的告警检查项有803个,gcc(以gcc 9来算)有178个,单从这个指标看clang在静态检查方面是远胜于gcc的,"2012 ACM Software System Award"大奖实至名归。
不过clang本身是为了支撑llvm的,所以很多与llvm不相关的功能都是直接调用的gcc的库接口,可以认为clang是站在gcc的巨人肩膀上来发布的自己的产品。
当前各个公司都引入了很多静态检查的工具来完善代码质量,但第一步还是要把静态检查工具的老祖宗,也就是编译器,自带的静态检查功能用足用好,再考虑消除其他静态检查工具的问题比较靠谱。走好这一步,引入clang非常必要。
first introduced compiler version |
Count of new warning options |
---|---|
clang7 or older | 584 |
clang8 | 12 |
clang9 | 223 |
clang10 | 55 |
clang11 | 33 |
clang12 | 15 |
gcc 4 or older | 172 |
gcc 5 | 26 |
gcc 6 | 24 |
gcc 7 | 35 |
gcc 8 | 16 |
gcc 9 | 24 |
Grand Total | 1204 |