发布Trail of Bits的CodeQL查询:提升Go与C/C++代码安全检测

Trail of Bits开源了针对Go和C/C++的自定义CodeQL查询集,用于检测标准查询易忽略的加密误用、文件权限缺陷及字符串处理漏洞,提升代码安全性分析能力。

发布Trail of Bits的CodeQL查询

我们发布了一套针对Go和C的自定义CodeQL查询集。这些查询用于发现标准CodeQL查询可能遗漏的关键问题。这一持续更新的CodeQL查询库新版本,与我们的公共Semgrep规则和《自动化测试手册》一起,旨在与社区分享我们的技术专长。

在我们内部CodeQL查询的初始版本中,我们专注于以下问题:加密误用、不安全的文件权限以及字符串方法中的错误:

语言 查询名称 漏洞描述
Go 签名验证前消息未哈希 此查询检测到使用未哈希消息调用(EC)DSA API的情况。如果消息长于预期的哈希摘要大小,它会被静默截断。
Go 文件权限缺陷 此查询发现用作文件系统权限参数(FileMode)的非八进制(例如,755 vs 0o755)和不支持(例如,04666)的字面量。
Go Trim函数误用 此查询发现调用string.{Trim,TrimLeft,TrimRight}时,第二个参数不是字符集(cutset),而是要修剪的连续子字符串。
Go tls.Config中缺少MinVersion 此查询发现未为服务器显式设置tls.Config.MinVersion的情况。默认使用版本1.0,这被认为是不安全的。此查询不标记显式设置的不安全版本。
C CStrNFinder 此查询发现调用那些将字符串及其大小作为单独参数(例如strncmp, strncat)但大小参数错误的函数。
C 缺少空终止符 此查询发现错误初始化的字符串被传递给期望以空字节终止的字符串的函数。

CodeQL 101

CodeQL是驱动GitHub高级安全的静态分析工具,被社区广泛用于发现漏洞。CodeQL通过将测试代码转换为可使用类似Datalog的语言查询的数据库来操作。虽然CodeQL的核心引擎仍然是专有和闭源的,但该工具提供了实现各种分析和安全查询集的开源库。

要测试我们的查询,请按照官方文档安装CodeQL CLI。CodeQL CLI准备就绪后,下载Trail of Bits的查询包并检查是否检测到新查询:

1
2
codeql pack download trailofbits/{cpp,go}-queries
codeql resolve qlpacks | grep trailofbits

现在转到项目的根目录并生成一个CodeQL数据库,指定go或cpp作为编程语言:

1
codeql database create codeql.db --language go

如果生成未成功或项目有复杂的构建系统,请使用命令标志。最后,对数据库执行Trail of Bits的查询:

1
codeql database analyze database.db --format=sarif-latest --output=./tob.sarif -- trailofbits/go-queries

分析输出为静态分析结果交换格式(SARIF)。使用带有SARIF Viewer插件的Visual Studio Code打开它并分类发现。或者,将结果上传到GitHub或使用--format csv以文本形式获取结果。

Go中的(EC)DSA静默输入截断

让我们使用ECDSA签署/etc/passwd文件。以下实现安全吗?

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
func main() {
    privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
    if err != nil { panic(err) }

    data, err := os.ReadFile("/etc/passwd")
    if err != nil { panic(err) }

    sig, err := ecdsa.SignASN1(rand.Reader, privateKey, data)
    if err != nil { panic(err) }
    fmt.Printf("signature: %x\n", sig)

    valid := ecdsa.VerifyASN1(&privateKey.PublicKey, data, sig)
    fmt.Println("signature verified:", valid)
}

图1:签名生成和验证函数示例

当然不安全。问题在于将原始、未哈希且可能很长的数据传递给ecdsa.SignASN1ecdsa.VerifyASN1方法,而Go的crypto/ecdsa包(以及其他一些包)期望用于签名和验证的数据是实际数据的哈希。

这种行为意味着代码仅签署和验证文件的前32字节,因为示例中使用的P-256曲线大小为32字节。

输入数据的静默截断发生在hashToNat方法中,该方法由ecdsa.{SignASN1,VerifyASN1}方法内部使用:

1
2
3
4
5
6
7
8
9
// hashToNat sets e to the left-most bits of hash, according to
// SEC 1, Section 4.1.3, point 5 and Section 4.1.4, point 3.
func hashToNat[Point nistPoint[Point]](c *nistCurve[Point], e *bigmod.Nat, hash []byte) {
    // ECDSA asks us to take the left-most log2(N) bits of hash, and use them as
    // an integer modulo N. This is the absolute worst of all worlds: we still
    // have to reduce, because the result might still overflow N, but to take
    // the left-most bits for P-521 we have to do a right shift.
    if size := c.N.Size(); len(hash) > size {
        hash = hash[:size]

图2:输入数据的静默截断(crypto/ecdsa/ecdsa.go)

我们在真实世界的代码库中见过此漏洞,其影响是严重的。为了解决这个问题,有几种方法:

长度验证。 防止缺乏哈希问题的一个简单方法是验证所提供数据的长度,如go-ethereum库中所做:

1
2
3
4
func VerifySignature(pubkey, msg, signature []byte) bool {
    if len(msg) != 32 || len(signature) != 64 || len(pubkey) == 0 {
        return false
    }

图3:go-ethereum库中的验证函数(go-ethereum/crypto/secp256k1/secp256.go#126–129)

静态检测。 另一种方法是静态检测缺乏哈希。为此,我们开发了tob/go/msg-not-hashed-sig-verify查询,该查询检测到所有流向潜在有问题方法的数据流,忽略源自或经过哈希函数或切片操作的流。

我们必须解决的一个有趣问题是如何为数据流分析设置起点(源)?我们可以使用UntrustedFlowSource类来实现这一目的。然后分析将找到来自攻击者可能控制的任何输入的流。然而,UntrustedFlowSource通常需要按项目扩展才能有用,因此将其用于我们的分析会导致许多项目中遗漏许多流。因此,我们的查询专注于查找最长的数据流,这些更可能指示潜在漏洞。

Go中的文件权限缺陷

你能发现以下代码中的错误吗?

1
2
3
if err := os.Chmod("./secret_key", 400); err != nil {
    return
}

图4:有错误的Go代码

文件权限通常以八进制整数表示。在我们的例子中,密钥文件最终将具有权限设置为0o620(或rw--w----),允许非所有者修改文件。在调用os.Chmod方法时使用的整数字面量很可能不是开发人员想要使用的。

为了发现用作FileModes的意外整数值,我们在tob/go/file-perms-flaws CodeQL查询中实现了一个WYSIWYG(“所见即所得”)启发式方法。“所见”是清理后的整数字面量(FileMode类型的硬编码数字)——删除下划线、删除基数前缀并左补零。“所得”是转换为八进制表示的相同整数。如果这两部分不相等,则可能存在错误。

1
2
3
4
5
6
7
8
// what you see
fileModeAsSeen = ("000" + fileModeLitStr.replaceAll("_", "").regexpCapture("(0o|0x|0b)?(.+)", 2)).regexpCapture("0*(.{3,})", 1)

// what you get
and fileModeAsOctal = octalFileMode(fileModeInt)

// what you see != what you get
and fileModeAsSeen != fileModeAsOctal

图5:CodeQL中的WYSIWYG启发式方法

为了最小化误报,我们过滤掉那些常用常量(如0755或0644)但以十进制或十六进制形式表示的数字。这些已知的有效常量在isKnownValidConstant谓词中显式定义。以下是我们如何实现此谓词:

1
2
3
4
5
predicate isKnownValidConstant(string fileMode) {
  fileMode = ["365", "420", "436", "438", "511", "509", "493"]
  or
  fileMode = ["0x16d", "0x1a4", "0x1b4", "0x1b6", "0x1ff", "0x1fd", "0x1ed"]
}

图6:过滤常见文件权限常量的CodeQL谓词

使用非八进制数字表示并不是处理文件权限时唯一可能的陷阱。另一个需要注意的问题是在调用权限更改方法时使用超过九位。文件权限仅编码为前九位,其他位编码文件模式,如粘滞位或setuid。一些权限更改方法——如os.Chmodos.Mkdir——忽略模式位的子集,具体取决于操作系统。tob/go/file-perms-flaws查询也会警告此问题。

Go中的字符串修剪误用

API歧义是常见的错误来源,特别是当有多个名称和目的相似的方法接受相同的参数集时。Go的strings.Trim系列方法就是这种情况。考虑以下调用:

1
2
strings.TrimLeft("file://FinnAndHengest", "file://")
strings.TrimPrefix("file://FinnAndHengest", "file://")

图7:歧义的Trim方法

你能说出这些调用之间的区别并确定哪一个“按预期”工作吗?

根据文档,strings.TrimLeft方法接受一个字符集(cutset)进行删除,而不是前缀。因此,它删除的字符比预期的要多。虽然上面的例子可能看起来无害,但跨站脚本(XSS)清理函数中的错误,例如,可能会产生毁灭性后果。

在寻找误用的strings.Trim{Left,Right}调用时,棘手的部分是定义什么算作“预期”行为。为了解决这一挑战,我们开发了tob/go/trim-misuse CodeQL查询,其中包含简单的启发式方法,基于cutset参数区分有效和可能错误的调用。如果参数包含重复字符或满足以下所有条件,我们认为Trim操作无效:

  • 长度超过两个字符
  • 包含至少两个连续的字母数字字符
  • 不是常见的连续字符列表

虽然启发式方法看起来过于简化,但它们在我们的审计中效果足够好。在CodeQL中,上述规则实现如下。cutset是一个变量,对应于strings.Trim{Left,Right}方法调用的cutset参数。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// repeated characters imply the bug
cutset.length() != unique(string c | c = cutset.charAt(_) | c).length()

or
(
// long strings are considered suspicious
cutset.length() > 2

// at least one alphanumeric
and exists(cutset.regexpFind("[a-zA-Z0-9]{2}", _, _))

// exclude probable false-positives
and not cutset.matches("%1234567%")
and not cutset.matches("%abcdefghijklmnopqrstuvwxyz%")
)

图8:Trim操作启发式方法的CodeQL实现

有趣的是,strings.Trim方法的误用非常普遍,以至于Go开发人员正在考虑弃用并替换有问题的函数。

识别Go中缺少的最小TLS版本配置

使用静态分析工具时,了解它们的局限性很重要。官方的go/insecure-tls CodeQL查询发现接受不安全(过时)TLS版本(例如,SSLv3, TLSv1.1)的TLS配置。它通过将提供给配置的MinVersionMaxVersion设置的值与已弃用版本列表进行比较来完成此任务。然而,该查询不会警告未显式设置MinVersion的配置。

为什么这应该引起关注?原因是服务器的默认MinVersion是TLSv1.0。因此,在下面的例子中,官方查询只会将server_explicit标记为不安全配置,尽管两个服务器使用相同的MinVersion

1
2
3
4
server_explicit := &http.Server{
    TLSConfig: &tls.Config{MinVersion: tls.VersionTLS10}
}
server_default := &http.Server{TLSConfig: &tls.Config{}}

图9:MinVersion设置的显式和默认配置

此问题的严重性相当低,因为客户端的默认MinVersion是安全的TLSv1.2。尽管如此,我们填补了空白并开发了tob/go/missing-min-version-tls CodeQL查询,该查询检测未显式设置MinVersion字段的tls.Config结构。该查询跳过报告用于客户端的配置,并通过过滤掉在结构初始化后设置MinVersion的发现来限制误报。

C和C++中的字符串错误

基于我的一位Trail of Bits同事进行的 insightful cstrnfinder研究,我们开发了tob/cpp/cstrnfinder查询。此查询旨在识别提供给期望字符串及其相应大小作为输入的函数调用的无效数字常量——如strncmpstrncpymemmove。我们专注于检测三种错误情况:

缓冲区读取不足。 当大小参数(下例中的数字20)略小于源字符串的长度时发生:

1
2
3
4
5
if (!strncmp(argv[1], "org/tob/test/SafeData", 20)) {
    puts("Secure");
} else {
    puts("Not secure");
}

图10:缓冲区读取不足错误示例

这里,"org/tob/test/SafeData"字符串的长度是21字节(如果计算终止空字节,则为22)。然而,我们只比较前20字节。因此,像"org/tob/test/SafeDatX"这样的字符串会被错误匹配。

缓冲区读取过度。 当大小参数(下例中的14)大于输入字符串的长度时出现,导致函数读取越界。

1
2
3
4
int check(const char *password) {
    const char pass[] = "Silmarillion";
    return memcmp(password, pass, 14);
}

图11:缓冲区读取过度错误示例

在示例中,"Silmarillion"字符串的长度是12字节(带空字节为13)。如果密码长于13字节并以"Silmarillion"子字符串开头,那么memcmp函数会读取pass缓冲区之外的数据。虽然操作字符串的函数在空字节上停止读取输入缓冲区并且不会过度读取输入,但memcmp函数操作字节并且不会在空字节上停止。

字符串连接函数的不正确使用。 如果大小参数(下例中的BUFSIZE-1)大于源字符串的长度(", Beowulf\x00"的长度,所以10字节),大小参数可能被错误解释为目标缓冲区的大小(示例中的BUFSIZE字节),而不是输入字符串的大小。这可能指示缓冲区溢出漏洞。

1
2
3
4
5
6
7
8
9
#define BUFSIZE 256

char all_books[BUFSIZE];
FILE *books_f = fopen("books.txt", "r");
fgets(all_books, BUFSIZE, books_f);
fclose(books_f);

strncat(all_books, ", Beowulf", BUFSIZE-1);
// safe version: strncat(all_books, ", Beowulf", BUFSIZE-strlen(dest)-1);

图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++都允许开发人员使用初始化字面量构造固定大小的字符串。如果字面量的长度大于或等于分配的缓冲区大小,则字面量被截断,并且终止空字节不会附加到字符串。

1
2
3
4
char b1[18] = "The Road Goes Ever On";  // 缺少空字节,警告
char b2[13] = "Ancrene Wisse";  // 缺少空字节,无警告
char b3[] = "Farmer Giles of Ham"; // 正确初始化
char b4[3] = {'t', 'o', 'b'} // 不是字符串,缺少空字节是预期的

图13:C字符串初始化示例

有趣的是,C编译器会警告长度大于缓冲区大小的初始化器,但不会对长度等于缓冲区大小的初始化器发出警报——即使结果字符串都没有以空字节终止。C++编译器对两种情况都返回错误。

tob/cpp/no-null-terminator查询使用数据流分析来查找错误初始化并传递给期望以空终止的字符串的函数的字符串。此类函数调用会导致越界读取或写入漏洞。

CodeQL:过去、现在和未来

这将是Trail of Bits的一个持续项目,所以请期待更多!我们最有价值的开发之一是我们自动化错误查找的专业知识。这个新的CodeQL存储库、Semgrep规则和《自动化测试手册》是帮助他人从我们工作中受益的关键方法。请使用这些资源并向我们报告任何问题或改进!

如果您想了解更多关于我们在CodeQL上的工作,我们以多种方式使用了它的功能,例如检测迭代器失效、识别未处理的错误以及发现不同的表示。

如果您有兴趣为您的项目自定义CodeQL查询,请联系我们。

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计