Holy Macroni! 渐进式语言增强的配方
尽管Clang在重构和静态分析工具中广泛应用,但它存在一个重大缺陷:Clang AST不提供关于AST节点来自哪些CPP宏扩展的溯源信息,也不将宏扩展降级为LLVM中间表示(IR)代码。这使得构建宏感知的静态分析和转换极其困难,并成为持续研究的领域。
然而,无需再挣扎!今年夏天在Trail of Bits,我创建了Macroni,使创建宏感知的静态分析变得容易。
Macroni允许开发者用宏定义新的C语言结构的语法,并用MLIR为这些结构提供语义。Macroni使用VAST将C代码降级为MLIR,并使用PASTA获取宏到AST的溯源信息,将宏也降级为MLIR。开发者随后可以定义自定义MLIR转换器,将Macroni的输出转换为领域特定的MLIR方言,以进行更细致的分析。在本文中,我将展示几个使用Macroni增强C语言安全结构和构建C安全分析的示例。
更强的类型定义
C的typedef用于为底层类型提供有语义意义的名称;然而,C编译器在类型检查时不使用这些名称,而仅对底层类型进行类型检查。当语义类型表示不同格式或度量时,这可能导致一种简单的类型混淆错误,例如:
|
|
图1:C类型检查仅考虑typedef的底层类型。
上述代码成功通过类型检查,但fahrenheit和celsius类型之间的语义差异不应被忽略,因为它们代表不同温标的值。仅使用C typedef无法强制执行这种强类型。
使用Macroni,我们可以用宏定义强类型定义的语法,并用MLIR为它们实现自定义类型检查。以下是用宏定义表示华氏度和摄氏度的强类型定义的示例:
|
|
图2:使用宏定义强C类型定义的语法
用STRONG_TYPEDEF()宏包装typedef名称允许Macroni识别哪些typedef名称是从STRONG_TYPEDEF()调用扩展而来的,并将它们转换为自定义MLIR方言的类型(例如temp),如下所示:
|
|
图3:Macroni使我们能够将类型定义降级为MLIR类型并强制执行严格类型检查。
通过将这些宏属性类型定义集成到类型系统中,我们现在可以为它们定义自定义类型检查规则。例如,我们可以对温度值之间的操作强制执行严格类型检查,使上述程序无法通过类型检查。我们还可以为温度值添加自定义类型转换逻辑,以便将一种温标的温度值转换为另一种温标时隐式插入转换指令。
使用宏添加强类型定义语法的原因是宏既向后兼容又可移植。虽然我们可以使用GNU或Clang的属性语法注释我们的typedef来让Clang识别我们的自定义类型,但我们不能保证annotate()在所有平台和编译器上的可用性,而我们可以对C预处理器的存在做出强假设。
现在,您可能在想:C已经有了一种称为struct的强类型定义形式。因此,我们也可以通过将我们的typedef类型转换为结构体(例如struct fahrenheit { double value; })来强制执行更严格的类型检查,但这会改变类型的API和ABI,破坏现有的客户端代码和向后兼容性。如果我们将typedef更改为结构体,编译器可能会产生完全不同的汇编代码。例如,考虑以下函数定义:
|
|
图4:摄氏转华氏转换函数的定义
如果我们使用宏属性类型定义来定义强类型定义,那么Clang为convert(25)调用发出以下LLVM IR。convert函数的LLVM IR表示与其C对应物匹配,接受单个double类型参数并返回double类型值。
|
|
图5:使用宏属性类型定义定义强类型定义的convert(25)的LLVM IR
与当我们使用结构体定义强类型定义时Clang产生的IR对比。函数调用现在接受四个参数而不是一个。第一个ptr参数表示convert将存储返回值的位置。想象一下,如果客户端代码根据原始调用约定调用这个新版本的convert会发生什么。
|
|
图6:使用结构体定义强类型定义的convert(25)的LLVM IR
弱类型定义本应是强类型的情况在C代码库中普遍存在,包括关键基础设施如libc和Linux内核。如果您想为标准类型(如time_t)添加强类型检查,保持API和ABI兼容性至关重要。如果您将time_t包装在结构体中(例如struct strict_time_t { time_t t; })以提供强类型检查,那么不仅所有访问time_t类型值的API需要更改,而且这些使用站点的ABI也需要更改。已经使用裸time_t值的客户端需要费力地更改代码中所有使用time_t的地方,以改用您的结构体来激活更强的类型检查。另一方面,如果您使用宏属性类型定义来别名原始time_t(即typedef time_t STRONG_TYPEDEF(time_t)),那么time_t的API和ABI将保持一致,正确使用time_t的客户端代码可以保持不变。
增强Linux内核中的Sparse
2003年,Linus Torvalds构建了一个自定义预处理器、C解析器和编译器,称为Sparse。Sparse执行Linux内核特定的类型检查。Sparse依赖于宏,如__user,散布在内核代码中,这些宏在正常构建配置下不执行任何操作,但在定义__CHECKER__宏时扩展为__attribute__((address_space(…)))的使用。
用__CHECKER__门控宏定义是必要的,因为大多数编译器不提供钩入宏或实现自定义安全检查的方法……直到今天。使用Macroni,我们可以钩入宏并执行类似Sparse的安全检查和分析。但Sparse仅限于C(通过实现自定义C预处理器和解析器),而Macroni适用于任何可由Clang解析的代码(即C、C++和Objective C)。
我们将钩入的第一个Sparse宏是__user。内核当前将__user定义为Sparse识别的属性:
|
|
图7:Linux内核的__user宏
Sparse钩入此属性以查找来自用户空间的指针,如下例所示。noderef告诉Sparse这些指针不能被解引用(例如*uaddr = 1),因为它们的来源不可信。
|
|
图8:使用__user宏注释变量来自用户空间的示例
Macroni可以钩入宏和扩展属性,将声明降级为MLIR,如下所示:
|
|
图9:被Macroni降级为MLIR的内核代码
降级的MLIR代码通过将来自用户空间的声明包装在sparse.user类型中,将注释嵌入类型系统。现在我们可以为用户空间变量添加自定义类型检查逻辑,类似于我们之前创建强类型定义的方式。我们甚至可以钩入Sparse特定的宏__force,以按需禁用强类型检查,正如开发人员当前所做:
|
|
图10:使用__force宏将指针复制到用户空间的示例
我们还可以使用Macroni识别内核中的RCU读侧关键部分,并验证某些RCU操作仅出现在这些部分内。例如,考虑以下对rcu_dereference()的调用:
|
|
图11:Linux内核中RCU读侧关键部分内的rcu_derefernce()调用
上述代码在关键部分调用rcu_derefernce()——即从调用rcu_read_lock()开始到调用rcu_read_unlock()结束的代码区域。只应在读侧关键部分内调用rcu_dereference();然而,无法强制执行此约束。
使用Macroni,我们可以使用rcu_read_lock()和rcu_read_unlock()调用来识别形成隐含词法代码区域的关键部分,然后检查rcu_dereference()调用仅出现在这些部分内:
|
|
图12:将RCU关键部分降级为MLIR的结果,类型省略以简洁
上述代码将RCU关键部分和rcu_dereference()调用都转换为MLIR操作。这使得检查rcu_dereference()仅出现在正确区域变得容易。
不幸的是,RCU关键部分并不总是绑定整洁的词法代码区域,并且rcu_dereference()并不总是在这样的区域中被调用,如下例所示:
|
|
图13:包含非词法RCU关键部分的内核代码
|
|
图14:在RCU关键部分外调用rcu_dereference()的内核代码
我们可以使用__force宏来允许这些类型的rcu_dereference()调用,就像我们为逃避用户空间指针的类型检查所做的那样。
类Rust的不安全区域
很明显,Macroni可以帮助加强类型检查,甚至启用应用特定的类型检查规则。然而,将类型标记为强意味着承诺那种强度级别。在大型代码库中,这种承诺可能需要大量的更改集。为了使适应更强类型系统更易管理,我们可以为C设计一个“不安全”机制,类似于Rust:在不安全区域内,强类型检查不适用。
|
|
图15:展示宏实现的不安全区域语法的C代码片段
此片段演示了我们安全API的语法:我们在潜在不安全的代码区域之前调用unsafe宏。所有未列在不安全区域中的代码将受到强类型检查,而我们可以使用unsafe宏来调用我们故意保持原样的低级代码区域。这是渐进的!
unsafe宏仅为我们安全API提供语法,而不是逻辑。为了使这个泄漏的抽象滴水不漏,我们需要将宏标记的if语句转换为我们理论安全方言中的操作:
|
|
图16:使用Macroni,我们可以将安全API降级为MLIR方言并实现安全检查逻辑。
现在我们可以禁用嵌套在unsafe宏的MLIR表示中的操作的强类型检查。
更安全的信号处理
到这一点,您可能已经注意到创建更安全语言结构的模式:我们使用宏定义语法来标记某些类型、值或代码区域遵守某些不变式,然后我们在MLIR中定义逻辑来检查这些不变式是否成立。
我们可以使用Macroni来确保信号处理程序仅执行信号安全代码。例如,考虑Linux内核中定义的以下信号处理程序:
|
|
图17:Linux内核中定义的信号处理程序
sig_handler()在其定义中调用了三个其他函数,这些函数都应该在信号处理上下文中安全调用。然而,上述代码中没有检查我们仅在sig_handler()的定义内调用信号安全函数——C编译器没有表达适用于词法区域的语义检查的方法。
使用Macroni,我们可以添加宏来标记某些函数为信号处理程序,其他为信号安全,然后在MLIR中实现逻辑来检查信号处理程序仅调用信号安全函数,如下所示:
|
|
图18:标记信号处理程序和信号安全函数的基于令牌的语法
上述代码将sig_handler()标记为信号处理程序,并将其调用的三个函数标记为信号安全。每个宏调用扩展为单个令牌——我们想要标记的函数名称。使用这种方法,Macroni钩入扩展的函数名称令牌以确定函数是信号处理程序还是信号安全。
另一种方法是将这些宏定义为魔术注释,然后用Macroni钩入它们:
|
|
图19:标记信号处理程序和信号安全函数的替代属性语法
使用这种方法,宏调用看起来更像类型说明符,有些人可能觉得更吸引人。基于令牌的语法和属性语法之间的唯一区别是后者需要编译器支持annotate()属性。如果这不是问题,或者如果类似__CHECKER__的门控是可接受的,那么任