Publishing Trail of Bits’ CodeQL queries
我们发布了一套针对Go和C的自定义CodeQL查询集。这些查询用于发现标准CodeQL查询可能遗漏的关键问题。这个持续更新的CodeQL查询库新版本,与我们公开的Semgrep规则和自动化测试手册一起,旨在与社区分享我们的技术专长。
在我们的内部CodeQL查询初始版本中,我们专注于以下问题:加密误用、不安全的文件权限以及字符串方法中的错误:
语言 | 查询名称 | 漏洞描述 |
---|---|---|
Go | Message not hashed before signature verification | 此查询检测到使用未哈希消息调用(EC)DSA API的情况。如果消息长度超过预期的哈希摘要大小,它会被静默截断。 |
Go | File permission flaws | 此查询发现用作文件系统权限参数(FileMode)的非八进制(例如,755 vs 0o755)和不支持(例如,04666)的字面量。 |
Go | Trim functions misuse | 此查询发现调用string.{Trim,TrimLeft,TrimRight}时,第二个参数不是字符集(cutset),而是要修剪的连续子字符串。 |
Go | Missing MinVersion in tls.Config | 此查询发现未为服务器显式设置tls.Config.MinVersion的情况。默认使用版本1.0,这被认为是不安全的。此查询不会标记显式设置的不安全版本。 |
C | StrNFinder | 此查询发现调用那些将字符串及其大小作为单独参数的函数(例如strncmp, strncat),但大小参数错误的情况。 |
C | Missing null terminator | 此查询发现错误初始化的字符串被传递给期望以空字节结尾的字符串的函数。 |
CodeQL 101
CodeQL是驱动GitHub高级安全的静态分析工具,被社区广泛用于发现漏洞。CodeQL通过将测试代码转换为可使用类似Datalog的语言查询的数据库来操作。虽然CodeQL的核心引擎仍然是专有和闭源的,但该工具提供了实现各种分析和安全查询集的开源库。
要测试我们的查询,请按照官方文档安装CodeQL CLI。CodeQL CLI准备就绪后,下载Trail of Bits的查询包并检查是否检测到新查询:
|
|
现在转到项目的根目录并生成一个CodeQL数据库,指定go或cpp作为编程语言:
|
|
如果生成未成功或项目具有复杂的构建系统,请使用命令标志。最后,对数据库执行Trail of Bits的查询:
|
|
分析输出采用静态分析结果交换格式(SARIF)。使用带有SARIF Viewer插件的Visual Studio Code打开它并分类发现的问题。或者,将结果上传到GitHub,或使用--format csv
以文本形式获取结果。
Go中的(EC)DSA静默输入截断
让我们使用ECDSA签署/etc/passwd
文件。以下实现安全吗?
|
|
图1:签名生成和验证函数示例
当然不安全。问题在于将原始的、未哈希的且可能很长的数据传递给ecdsa.SignASN1
和ecdsa.VerifyASN1
方法,而Go的crypto/ecdsa包(以及一些其他包)期望用于签名和验证的数据是实际数据的哈希。
这种行为意味着代码仅签署和验证文件的前32个字节,因为示例中使用的P-256曲线大小为32字节。
输入数据的静默截断发生在hashToNat
方法中,该方法由ecdsa.{SignASN1,VerifyASN1}
方法内部使用:
|
|
图2:输入数据的静默截断(crypto/ecdsa/ecdsa.go)
我们在真实世界的代码库中见过此漏洞,其影响是关键性的。为了解决这个问题,有几种方法:
长度验证。 防止缺乏哈希问题的一种简单方法是验证所提供数据的长度,如go-ethereum库中所做的那样。
|
|
图3:go-ethereum库中的验证函数(go-ethereum/crypto/secp256k1/secp256.go#126–129)
静态检测。 另一种方法是静态检测缺乏哈希。为此,我们开发了tob/go/msg-not-hashed-sig-verify
查询,它检测所有流向潜在有问题方法的数据流,忽略源自或经过哈希函数或切片操作的流。
我们必须解决的一个有趣问题是如何为数据流分析设置起点(源)?我们可以为此使用UntrustedFlowSource
类。然后分析将找到来自攻击者可能控制的任何输入的流。然而,UntrustedFlowSource
通常需要按项目进行扩展才能有用,因此将其用于我们的分析会导致许多项目遗漏许多流。因此,我们的查询专注于查找最长的数据流,这些流更可能指示潜在漏洞。
Go中的文件权限缺陷
你能发现下面代码中的错误吗?
|
|
图4:有错误的Go代码
好的,文件权限通常表示为八进制整数。在我们的例子中,密钥文件最终将具有权限设置为0o620(或rw--w----
),允许非所有者修改文件。在调用os.Chmod
方法时使用的整数字面量很可能不是开发人员想要使用的。
为了发现用作FileModes的意外整数值,我们在tob/go/file-perms-flaws
CodeQL查询中实现了一个WYSIWYG(“所见即所得”)启发式方法。“所见”是一个清理过的整数字面量(FileMode类型的硬编码数字)——去除下划线、去除基数前缀并左补零。“所得”是转换为八进制表示的相同整数。如果这两部分不相等,则可能存在错误。
|
|
图5:CodeQL中的WYSIWYG启发式方法
为了最小化误报,我们过滤掉那些是常用常量(如0755或0644)但以十进制或十六进制形式表示的数字。这些已知的有效常量在isKnownValidConstant
谓词中明确定义。以下是我们如何实现此谓词:
|
|
图6:过滤常见文件权限常量的CodeQL谓词
使用非八进制数字表示并不是处理文件权限时唯一可能的陷阱。另一个需要注意的问题是在调用权限更改方法时使用超过九位。文件权限仅编码为前九位,其他位编码文件模式,如粘滞位或setuid。一些权限更改方法——如os.Chmod
或os.Mkdir
——会忽略模式位的子集,具体取决于操作系统。tob/go/file-perms-flaws
查询也会警告此问题。
Go中的字符串修剪误用
API歧义是错误的常见来源,特别是当有多个具有相似名称和目的的方法接受相同的参数集时。Go的strings.Trim
系列方法就是这种情况。考虑以下调用:
|
|
图7:歧义的Trim方法
你能说出这些调用之间的区别并确定哪个“按预期”工作吗?
根据文档,strings.TrimLeft
方法接受一个字符集(cutset)进行移除,而不是前缀。因此,它删除的字符比预期的要多。虽然上面的例子可能看起来无害,但例如,跨站脚本(XSS)清理函数中的错误可能会产生毁灭性后果。
在寻找误用的strings.Trim{Left,Right}
调用时,棘手的部分是定义什么算作“预期”行为。为了解决这个挑战,我们开发了tob/go/trim-misuse
CodeQL查询,其中包含简单的启发式方法,基于cutset参数区分有效和可能错误的调用。如果参数包含重复字符或满足以下所有条件,我们认为Trim操作无效:
- 长度超过两个字符
- 包含至少两个连续的字母数字字符
- 不是常见的连续字符列表
虽然启发式方法看起来过于简化,但它们在我们的审计中效果足够好。在CodeQL中,上述规则实现如下所示。cutset是一个变量,对应于strings.Trim{Left,Right}
方法调用的cutset参数。
|
|
图8:Trim操作启发式方法的CodeQL实现
有趣的是,strings.Trim
方法的误用如此普遍,以至于Go开发人员正在考虑弃用并替换有问题的函数。
识别Go中缺失的最小TLS版本配置
使用静态分析工具时,了解它们的局限性很重要。官方的go/insecure-tls
CodeQL查询发现接受不安全(过时)TLS版本(例如SSLv3、TLSv1.1)的TLS配置。它通过比较提供给配置的MinVersion和MaxVersion设置的值与已弃用版本的列表来完成该任务。但是,该查询不会警告未显式设置MinVersion的配置。
为什么这应该引起关注?原因是服务器的默认MinVersion是TLSv1.0。因此,在下面的示例中,官方查询只会将server_explicit
标记为不安全配置,尽管两个服务器使用相同的MinVersion。
|
|
图9:MinVersion设置的显式和默认配置
此问题的严重性相当低,因为客户端的默认MinVersion是安全的TLSv1.2。尽管如此,我们填补了空白并开发了tob/go/missing-min-version-tls
CodeQL查询,该查询检测未显式设置MinVersion字段的tls.Config
结构。该查询跳过报告用于客户端的配置,并通过过滤掉在结构初始化后设置MinVersion的发现来限制误报。
C和C++中的字符串错误
基于我的一位Trail of Bits同事进行的富有洞察力的cstrnfinder
研究,我们开发了tob/cpp/cstrnfinder
查询。此查询旨在识别提供给期望字符串及其相应大小作为输入的函数调用的无效数字常量——例如strncmp
、strncpy
和memmove
。我们专注于检测三种错误情况:
缓冲区读取不足。 当大小参数(下例中的数字20)略小于源字符串的长度时发生:
|
|
图10:缓冲区读取不足错误示例
这里,"org/tob/test/SafeData"
字符串的长度是21字节(如果计算终止空字节,则为22)。但是,我们只比较前20个字节。因此,像"org/tob/test/SafeDatX"
这样的字符串会被错误匹配。
缓冲区读取越界。 当大小参数(下例中的14)大于输入字符串的长度时出现,导致函数读取越界。
|
|
图11:缓冲区读取越界错误示例
在示例中,"Silmarillion"
字符串的长度是12字节(带空字节为13)。如果密码长于13字节并以"Silmarillion"
子字符串开头,那么memcmp
函数会读取pass
缓冲区之外的数据。虽然操作字符串的函数在空字节上停止读取输入缓冲区并且不会越界读取输入,但memcmp
函数操作字节并且不会在空字节上停止。
字符串连接函数的不正确使用。 如果大小参数(下例中的BUFSIZE-1)大于源字符串的长度(", Beowulf\x00"
的长度,即10字节),则大小参数可能被错误解释为目标缓冲区的大小(示例中的BUFSIZE字节),而不是输入字符串的大小。这可能指示缓冲区溢出漏洞。
|
|
图12:strncat函数误用错误示例
在上面的代码中,all_books
缓冲区最多可以容纳256字节的数据。如果books.txt文件包含250个字符,那么在调用strncat
函数之前缓冲区中的剩余空间是6字节。但是,我们指示函数向all_books
缓冲区的末尾添加最多255(BUFSIZE-1)字节。因此,", Beowulf"
字符串的几个字节将最终出现在分配的空间之外。我们应该做的是指示strncat
最多添加5字节(留下1字节用于终止的\x00
)。
有一个类似的内置查询,ID为cpp/unsafe-strncat
,但它不适用于常量大小。
C中缺失的空终止符错误
C和C++都允许开发人员使用初始化字面量构造固定大小的字符串。如果字面量的长度大于或等于分配的缓冲区大小,则字面量被截断,并且终止空字节不会附加到字符串。
|
|
图13:C字符串初始化示例
有趣的是,C编译器会警告长度超过缓冲区大小的初始化器,但不会对长度等于缓冲区大小的初始化器发出警报——即使生成的字符串都没有以空字符结尾。C++编译器对两种情况都返回错误。
tob/cpp/no-null-terminator
查询使用数据流分析来查找错误初始化的字符串传递给期望以空字符结尾的字符串的函数。此类函数调用会导致越界读取或写入漏洞。
CodeQL:过去、现在和未来
这将是Trail of Bits的一个持续项目,所以请期待更多内容!我们最有价值的开发之一是我们自动化错误查找的专业知识。这个新的CodeQL存储库、Semgrep规则和自动化测试手册是帮助他人从我们工作中受益的关键方法。请使用这些资源并向我们报告任何问题或改进!
如果您想了解更多关于我们在CodeQL上的工作,我们以多种方式使用了它的功能,例如检测迭代器无效、识别未处理的错误以及发现不同的表示形式。
如果您有兴趣为您的项目自定义CodeQL查询,请与我们联系。
如果您喜欢这篇文章,请分享: Twitter LinkedIn GitHub Mastodon Hacker News