LWN: 对kernel memcpy() 进行更严格的越界检查!
共 4122字,需浏览 9分钟
·
2021-08-23 12:13
关注了就能看到更多这么棒的文章哦~
Strict memcpy() bounds checking for the kernel
By Jonathan Corbet
July 30, 2021
DeepL assisted translation
https://lwn.net/Articles/864521/
大家都知道 C 语言容易出现内存安全问题(memory-safety),也就是会容易引发缓冲区溢出(buffer overflow)等似乎无穷无尽的安全漏洞。但是在 C 语言中,很多情况下其实是可以改善这方面的表现的。比如说用来高效地复制或者覆盖一块内存区域的 memcpy() 系列函数。在编译器的帮助下,这些函数就可以避免在写入时超过目标对象的末尾。不过,在内核中要做到这一点比起人们想象得要更加难,这一点可以从 Kees Cook 的一组大型 patch set 中看出来。
缓冲区溢出问题似乎永远不会消失,一直在内核中导致一些 bug 以及安全问题。据说目前的加固(hardening)技术已经足够好了,许多类型的堆栈溢出问题都可以被检测出来加以防御(如果没有其他办法的话,关闭系统也是一种防御)。如果无法越过边界(并且这里有个重要数据),那么就很难改写 stack 的内容,也就无法暴露出这种问题。然而,heap 中的数据则并没有这种边界,这使得 heap (堆)空间的溢出问题更难被发现。因此,攻击者就越来越喜欢这方面的漏洞了。
memset_after()
一个常见的对多个字段进行 copy 的使用场景是 "从这里开始一直到结构末尾都写成 0"。例如 AR9170 无线网络驱动中的这段代码:
memset(&txinfo->status.ack_signal, 0,
sizeof(struct ieee80211_tx_info) -
offsetof(struct ieee80211_tx_info, status.ack_signal));
这段代码就是在进行对结构的后半部分进行清零的操作。它也很好地展示了这种代码多么笨拙。这种对长度的计算很容易出错,而且如果 struct 的布局因为某些原因而发生了变化,那么这段代码就会受到影响。事实上,上面这段代码之前就有这么一行保护代码:
BUILD_BUG_ON(offsetof(struct ieee80211_tx_info, status.ack_signal) !=20);
如果打算要写入 0 的第一个字段的偏移量不符合预期的话,这一行就会导致编译失败,但这种做法无法发现该字段之后的任何变动。ack_signal 之后添加的结构成员也会被这个 memset() 调用清零,在写这段代码的时候可能并不容易意识到这一点。
为了让这类代码更准确,并且避免对 memset() 进行的更严格的检测产生误报,patch set 中针对该操作引入了一个新的宏:
memset_after(object, value, member);
这个宏会让位于 member 之后的 object 的每个字节都被设置为 value 值。下面这个宏就可以代替上面的代码:
memset_after(&txinfo->status, 0, rates);
这里 ack_signal 字段就是第一个被清零的字段,在这个结构中是紧随 rates 之后的。在 Cook 的 patch set 中,许多类似这样的问题都得到了 fix。
Grouped structure fields
不过还有一种更复杂的情况,即一个结构中的一系列字段都在一次调用中被改写了。内核中已经使用了一些方法来针对这种情况进行安全的复制操作。方法之一就是在上面的例子中看到的那种 offsetof() 计算方式。但也还有其他的方法。例如在用来代表网络数据包的 sk_buff 结构的深处就有这样一个字段:
__u32 headers_start[0];
整整 120 行之后是另一个名为 headers_end 的长度为零的数组。这种数组显然无法容纳任何有意义的数据,相反,它们是被用来配合类似的 offset 算法,从而能够在一次操作中复制若干个 packet header 的。同样这里也有一组在 build 时进行的检查,用来确保所有相关的 header field 都位于两个 marker 之间。
一些开发者会简单地把要写的字段的长度加起来,然后用这个结果作为内存操作的长度。其实有另一种方法,就是定义一个嵌套结构(nested structure)来保存将要复制的这些字段。这种做法比较安全,但是它使得这些字段的用法变得复杂了(因为必须要通过一个 intermediate structure 来访问),并且这里加入宏来让这个操作容易使用的话,又容易导致命名空间污染。
总之,内核开发者已经想出了许多方法用来处理跨字段的内存操作,但没有一个特别令人满意的。Cook 的 patch set 以 struct_group() macro 的形式带来了一个新的解决方案(Keith Packard 是他的共同作者)。以这组 patch 中的例子为例,如果有下面这样的结构:
struct foo {
int one;
int two;
int three;
int four;
};
如果开发者想通过一次 memcpy()调用就复制字段 two 和 three 的话,可以通过下面这样来声明该结构从而给出正式一些的方案:
struct foo {
int one;
struct_group(thing,
int two,
int three,
);
int four;
};
这个宏的作用是创建一个名为 thing 的嵌套结构,此结构可以与 memcpy() 等函数配合使用,并启用严格的边界检查。单个字段仍然可以用 two 和 three 来引用,不需要使用这个嵌套结构的名字,并且也不用加什么难看的宏。底层是通过这种方式实现的:
#define struct_group_attr(NAME, ATTRS, MEMBERS) \
union { \
struct { MEMBERS } ATTRS; \
struct { MEMBERS } ATTRS NAME; \
}
#define struct_group(NAME, MEMBERS) \
struct_group_attr(NAME, /* no attrs */, MEMBERS)
这个宏两次定义了一个 intermediate structure 来保存这些被 group 起来的字段。其中一次是匿名的,而另一个则有明确指定的 NAME。然后,这些重复结构就会在一个匿名 union 中 overlay 起来。这个小技巧使得我们可以直接使用字段的名字了,同时也提供了整个 structure 的名字供 memory function 使用。
Toward a harder kernel
patch set 中的大部分内容都是在整个内核中各处的结构里定义这些 group,然后使用这些 group 来进行 memory operation。这样一来就有可能(某种程度上)对这些操作进行更严格的边界检查了。剩下的问题是这种跨 field 的操作实际上很难在代码中找全。没有统一的模式,也就无法轻易搜索出来。因此,很有可能在内核中还有其他一些尚未被发现的地方。正如 Cook patch 修改了若干版本之后时指出的:在内核中有超过 25000 个 memcpy() 调用。如果对一个尚未修改好的对多个 field 的操作(这个操作本身其实可能是正确的)就触发系统 crash,最起码也会被人们抱怨说这个做法太粗暴了。所以在可以遇见的未来内,我们将不得不仅仅使用 warning 来提醒用户。
不过,未来的某一天,应该 warning 已经很罕见了,社区也有了足够的信心可以在检测到越界 copy 时就让系统 halt。这样做的好处很可能非常大。上述 patch 中就指出:
有了这个功能之后,我们就可以将已知的 11 个 memcpy() 相关漏洞位置跟新检查出来的潜在越界访问的事件进行比较,从而衡量这些加强措施的潜在效果如何。令我非常惊讶、恐惧以及高兴的是,所有这 11 个缺陷都会被新增加的 run-time bounds check 功能检测出来,这说明它明显是一个重要的改进措施。
这种改进措施看起来很值得拥有,但第一步需要先把这些 patch 合入 mainline kernel。安全相关的工作往往总是很难合入内核,尽管这种情况在过去几年中已经有了不少改善。至少针对这个具体 patch set 来说,人们经常针对安全 patch 提出的一个抱怨(对性能的影响)并不是一个问题,只有那些在编译时不知道 size 的情况下才会引入很小的 run-time length check。但是,这个 patch set 实在太大了,修改的范围也太广。在合并之前很可能要进行不少讨论。这个过程一旦完成,预示着我们又一次终结了一类安全漏洞。
全文完
LWN 文章遵循 CC BY-SA 4.0 许可协议。
长按下面二维码关注,关注 LWN 深度文章以及开源社区的各种新近言论~