嵌入式Linux系统中uClibc标准库使用经验笔记

uClibc是一个面向嵌入式Linux系统的小巧而精致的C标准库,在低配置的嵌入式系统及物联网设备的研发中应用广泛。这里分享最近的一些使用经验,为需要解决相似问题或需求的工程师提供便利。

To me programming is more than an important practical art. It is also a gigantic undertaking in the foundations of knowledge.
Grace Hopper(格蕾丝·赫柏,美国计算机科学家、海军准将,创造了现代第一个编译器A-0系统,以及第一个高级商用计算机程序语言“COBOL”,被誉为“COBOL之母”)

uClibc简介

uClibc(有时写成μClibc)是一个小型的C标准库,旨在为使用基于Linux内核的操作系统的嵌入式系统和移动设备提供支持。uClibc原先是为了支持不需要内存管理单元的Linux版本μClinux的开发的,因此特别适合于微控制器系统。其名称中“uC”就是微控制器英文microcontroller的缩写,u是代表微小("micro")的希腊字母μ的近似拉丁字母排版。

uClibc是在GNU Lesser GPL下授权的自由和开源软件,其库函数封装了Linux内核的系统调用。它可以在标准或无MMU的Linux系统上运行,支持i386、x86-64、ARM、MIPS,和PowerPC等众多处理器。uClibc的开发始于1999年,大部分是从零开始编写的,但也吸收了glibc和其他项目的代码。uClibc的比glibc小得多,glibc的目标是在广泛的硬件和内核平台上完全支持所有相关的C标准,而uClibc则侧重于嵌入式Linux系统。它还允许开发者根据内存空间设计要求来开启或禁用一些功能。

下面的记录显示在两个相似的嵌入式系统中C标准库文件的列表。第一个使用glibc-2.23版,第二个集成uClibc-0.9.33.2版。总计glibc库文件为2MB多,而uClibc库文件加起来不到1MB。可见使用uClibc确实节省了不少存储空间。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
STM1:/# find . -name "*lib*2.23*" | xargs ls -alh
-rwxr-xr-x 1 root root 9.6K Jan 1 1970 ./lib/libanl-2.23.so
-rwxr-xr-x 1 root root 1.1M Jan 1 1970 ./lib/libc-2.23.so
-rwxr-xr-x 1 root root 177.5K Jan 1 1970 ./lib/libcidn-2.23.so
-rwxr-xr-x 1 root root 29.5K Jan 1 1970 ./lib/libcrypt-2.23.so
-rwxr-xr-x 1 root root 9.5K Jan 1 1970 ./lib/libdl-2.23.so
-rwxr-xr-x 1 root root 429.4K Jan 1 1970 ./lib/libm-2.23.so
-rwxr-xr-x 1 root root 65.8K Jan 1 1970 ./lib/libnsl-2.23.so
-rwxr-xr-x 1 root root 17.5K Jan 1 1970 ./lib/libnss_dns-2.23.so
-rwxr-xr-x 1 root root 33.6K Jan 1 1970 ./lib/libnss_files-2.23.so
-rwxr-xr-x 1 root root 90.5K Jan 1 1970 ./lib/libpthread-2.23.so
-rwxr-xr-x 1 root root 65.7K Jan 1 1970 ./lib/libresolv-2.23.so
-rwxr-xr-x 1 root root 25.9K Jan 1 1970 ./lib/librt-2.23.so
-rwxr-xr-x 1 root root 9.5K Jan 1 1970 ./lib/libutil-2.23.so

STM2:/# find . -name "*lib*0.9.33*" | xargs ls -alh
-rwxr-xr-x 1 root root 28.0K Jan 1 1970 ./lib/ld-uClibc-0.9.33.2.so
-rwxr-xr-x 1 root root 36.1K Jan 1 1970 ./lib/libcrypt-0.9.33.2.so
-rwxr-xr-x 1 root root 16.2K Jan 1 1970 ./lib/libdl-0.9.33.2.so
-rwxr-xr-x 1 root root 72.1K Jan 1 1970 ./lib/libm-0.9.33.2.so
-rwxr-xr-x 1 root root 116.4K Jan 1 1970 ./lib/libpthread-0.9.33.2.so
-rwxr-xr-x 1 root root 16.2K Jan 1 1970 ./lib/librt-0.9.33.2.so
-rwxr-xr-x 1 root root 28.3K Jan 1 1970 ./lib/libthread_db-0.9.33.2.so
-rwxr-xr-x 1 root root 621.4K Jan 1 1970 ./lib/libuClibc-0.9.33.2.so
-rwxr-xr-x 1 root root 8.1K Jan 1 1970 ./lib/libubacktrace-0.9.33.2.so
-rwxr-xr-x 1 root root 4.1K Jan 1 1970 ./lib/libutil-0.9.33.2.so

IPv6和接口API

随着IPv6的使用量稳步增长,为嵌入式系统添加IPv6协议栈支持已经成为必需。在一个为使用uClibc的设备加入IPv4/IPv6双栈功能的软件项目中,发现出现应用程序链接错误——undefined reference to getifaddrsgetifaddrs()是一个非常有用的函数,我们可以调用它得到系统所有的网络接口的地址信息。查询Linux编程手册:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
SYNOPSIS
#include <sys/types.h>
#include <ifaddrs.h>

int getifaddrs(struct ifaddrs **ifap);
...

DESCRIPTION
The getifaddrs() function creates a linked list of structures
describing the network interfaces of the local system, and stores
the address of the first item of the list in *ifap.
...

VERSIONS
The getifaddrs() function first appeared in glibc 2.3, but before
glibc 2.3.3, the implementation supported only IPv4 addresses;
IPv6 support was added in glibc 2.3.3. Support of address
families other than IPv4 is available only on kernels that
support netlink.
...

上面的最后一句话很关键:只有支持netlink的内核才能支持IPv4以外的地址系列。此系统运行的Linux内核版本是3.x, 是支持netlink的。那么,会不会是uClibc对netlink的支持出现问题导致了getifaddrs()函数没有被编译呢?

带着这一疑问,搜索uClibc的源码目录,找到实现getifaddrs()函数的C文件:

libc/inet/ifaddrs.c
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
...
#if __ASSUME_NETLINK_SUPPORT
#ifdef __UCLIBC_SUPPORT_AI_ADDRCONFIG__
/* struct to hold the data for one ifaddrs entry, so we can allocate
everything at once. */
struct ifaddrs_storage
{
struct ifaddrs ifa;
union
{
/* Save space for the biggest of the four used sockaddr types and
avoid a lot of casts. */
struct sockaddr sa;
struct sockaddr_ll sl;
struct sockaddr_in s4;
#ifdef __UCLIBC_HAS_IPV6__
struct sockaddr_in6 s6;
#endif
} addr, netmask, broadaddr;
char name[IF_NAMESIZE + 1];
};
#endif /* __UCLIBC_SUPPORT_AI_ADDRCONFIG__ */
...
#ifdef __UCLIBC_SUPPORT_AI_ADDRCONFIG__
...
int
getifaddrs (struct ifaddrs **ifap)
...
#endif /* __UCLIBC_SUPPORT_AI_ADDRCONFIG__ */
...
#endif /* __ASSUME_NETLINK_SUPPORT */

果不其然!整个函数的实现和相关数据结构ifaddrs_storage的定义,都被置于三个嵌套的宏条件编译下:

  1. __ASSUME_NETLINK_SUPPORT
  2. __UCLIBC_SUPPORT_AI_ADDRCONFIG__
  3. __UCLIBC_HAS_IPV6__

所以,只要将它们对应的配置行打开就应该能解决问题。如下改动uClibc的配置文件后,重建uClibc的动态链接库,再make应用程序就成功了:

1
2
3
4
5
6
7
8
9
10
--- a/toolchain/uClibc/config-0.9.33.2/common
+++ b/toolchain/uClibc/config-0.9.33.2/common
@@ -147,7 +147,8 @@ UCLIBC_HAS_RPC=y
UCLIBC_HAS_FULL_RPC=y
-# UCLIBC_HAS_IPV6 is not set
+UCLIBC_HAS_IPV6=y
-# UCLIBC_USE_NETLINK is not set
+UCLIBC_USE_NETLINK=y
+UCLIBC_SUPPORT_AI_ADDRCONFIG=y
UCLIBC_HAS_BSD_RES_CLOSE=y

SHA-2散列函数

嵌入式系统常常需要为系统管理员提供远程SSH登录服务,这就必须创建系统用户及其密码。Linux将用户名和密码的散列值(hash)保存在 /etc/shadow 文件中。散列值的存储格式遵循称为模块化加密格式(Modular Crypt Format,简写为MCF)的事实标准,其格式如下:

1
$<id>[$<param>=<value>(,<param>=<value>)*][$<salt>[$<hash>]]

这里

  • id: 表示散列算法的标识符(例如 1 代表MD5,5 代表SHA-256,6 代表SHA-512)
  • param=value:散列复杂度参数(如轮数/迭代次数)及其数值
  • salt: radix-64(字符集[+/a-zA-Z0-9])编码的盐
  • hash: 密码和盐的散列值的radix-64编码结果

随着计算机算力跟随摩尔定律迅速增强,以前常用的基于 MD5 的散列方案因为太容易受到攻击已被淘汰。现在新设计的系统都换到 SHA-512 散列方案,对应于/etc/shadow 文件中所见的$6$

用户密码散列值的生成和验证都可用名为crypt的POSIX C 库函数实现。此函数的定义如下:

1
char *crypt(const char *key, const char *salt)

输入参数key指向存有用户密码的字符串,salt指向格式为$<id>$<salt>的字符串,标明所要使用的散列算法和盐。大部分的Linux发行版都使用glibc库提供的crypt函数实现。下图概括了Glibc为crypt所增加的功能:

在集成uClibc的嵌入式Linux系统中,uClibc提供对crypt函数的支持。但是在测试中发现,它居然对$6$<salt>的输入返回空指针!这是怎么回事?

谜底就在uClibc的crypt函数实现中,找到相应的C源码:

libcrypt/crypt.c
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#include <unistd.h>
#include <crypt.h>
#include "libcrypt.h"

char *crypt(const char *key, const char *salt)
{
const unsigned char *ukey = (const unsigned char *)key;
const unsigned char *usalt = (const unsigned char *)salt;

if (salt[0] == '$') {
if (salt[1] && salt[2] == '$') { /* no blowfish '2X' here ATM */
if (*++salt == '1')
return __md5_crypt(ukey, usalt);
#ifdef __UCLIBC_HAS_SHA256_CRYPT_IMPL__
else if (*salt == '5')
return __sha256_crypt(ukey, usalt);
#endif
#ifdef __UCLIBC_HAS_SHA512_CRYPT_IMPL__
else if (*salt == '6')
return __sha512_crypt(ukey, usalt);
#endif
}
/* __set_errno(EINVAL);*/ /* ENOSYS might be misleading */
return NULL;
}
return __des_crypt(ukey, usalt);
}

啊哈!原来它默认只做 MD5 散列,SHA-256 和 SHA-512 的代码需要各自的条件编译宏定义。这好办,编辑uClibc的配置文件将后面两者都打开就可以了。

1
2
3
4
5
6
7
8
9
--- a/toolchain/uClibc/config-0.9.33.2/common
+++ b/toolchain/uClibc/config-0.9.33.2/common
@@ -151,8 +151,8 @@ UCLIBC_HAS_REGEX_OLD=y
UCLIBC_HAS_RESOLVER_SUPPORT=y
-# UCLIBC_HAS_SHA256_CRYPT_IMPL is not set
-# UCLIBC_HAS_SHA512_CRYPT_IMPL is not set
+UCLIBC_HAS_SHA256_CRYPT_IMPL=y
+UCLIBC_HAS_SHA512_CRYPT_IMPL=y
UCLIBC_HAS_SHADOW=y

最后去看看uClibc自带的测试 SHA-512 散列算法的程序。它清楚地列出了测试代码所定义的数据结构,其包括盐、输入密码和期待的输出,以及多个测试矢量:

test/crypt/sha512c-test.c
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
static const struct
{
const char *salt;
const char *input;
const char *expected;
} tests[] =
{
{ "$6$saltstring", "Hello world!",
"$6$saltstring$svn8UoSVapNtMuq1ukKS4tPQd8iKwSMHWjl/O817G3uBnIFNjnQJu"
"esI68u4OTLiBFdcbYEdFCoEOfaS35inz1" },
{ "$6$rounds=10000$saltstringsaltstring", "Hello world!",
"$6$rounds=10000$saltstringsaltst$OW1/O6BYHV6BcXZu8QVeXbDWra3Oeqh0sb"
"HbbMCVNSnCM/UrjmM0Dp8vOuZeHBy/YTBmSK6H9qs/y3RnOaw5v." },
...
{ "$6$rounds=10$roundstoolow", "the minimum number is still observed",
"$6$rounds=1000$roundstoolow$kUMsbe306n21p9R.FRkW3IGn.S9NPN0x50YhH1x"
"hLsPuWGsUSklZt58jaTfF4ZEQpyUNGc0dqbpBYYBaHHrsX." },
};

可以看到,最后一个测试用例输入定义轮次为10($6$rounds=10$roundstoolow),而输出结果显示的轮次为1000(rounds=1000)。这印证了uClibc的crypt函数实现与Glibc增加的功能相匹配 —— 为了保障安全性,如果输入指定的轮次太小,crypt将自动设定为最小轮次1000。

DNS安全补丁

2022年5月初,一家专注于为工业和关键基础设施环境提供安全解决方案的公司,Nozomi Networks,发布了他们发现的uClibc的一个新的安全漏洞 CVE-2022-30295。此漏洞存在所有版本的uClibc及其分支uClibc-ng(1.0.41版之前)的域名系统(DNS)实现中。由于该实现在进行DNS请求时使用了可预测的事务ID,因此存在DNS缓存被攻击者毒化的风险。

具体来说,应用程序常常调用gethostbyname库函数以解析某个主机名对用的网络地址。uClibc/uClibc-ng 内部实现了一个__dns_lookup函数提供实际的DNS域名请求及应答处理过程。以uClibc 最后的0.9.33.2版为例,下面的截图显示了__dns_lookup函数中问题代码:

参考第1308行,在第一次DNS请求时,local_id变量被初始化为上一次DNS请求的事务ID值(保存在静态变量last_id中)。第1319行是实际的漏洞核心,它只是简单地将local_id旧值递增1来更新。这个新值又如第1322行所示被存回last_id变量中。最后,在第1334行,local_id的值被复制到结构变量h中,它代表了DNS请求头的实际内容。这段代码在所有可获得的uClibc和1.0.41版之前的uClibc-ng中都差不多。

这种实现使得DNS请求中的事务ID变成可预测的,因为攻击者只要探察到正在的事务ID,就能估计出下一个请求中的事务ID数值。利用这个漏洞,攻击者只要制作一个包含正确源端口的DNS应答,以及在与DNS服务器返回的合法应答的竞争中获胜,就能扰乱/毒化主机的DNS缓存,使主机系统中应用程序的网络数据被导向攻击者设定的陷阱站点。

在此安全漏洞公布之后,uClibc-ng的维护者反应很快。他们于2022年5月中旬就提交了修复补丁,并在当月底发布了包括此修改的1.0.41版。对于uClibc,由于此C标准库从2012年起就停止发布新版本,目前处于无人维护状态,所以需要系统研发工程师自己手工修补。下面的uClibc补丁可供参考:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
diff --git a/libc/inet/resolv.c b/libc/inet/resolv.c
index 31e63810b..c2a8e2be4 100644
--- a/libc/inet/resolv.c
+++ b/libc/inet/resolv.c
@@ -315,6 +315,7 @@ Domain name in a message can be represented as either:
#include <sys/utsname.h>
#include <sys/un.h>
#include <sys/stat.h>
+#include <fcntl.h>
#include <sys/param.h>
#include <bits/uClibc_mutex.h>
#include "internal/parse_config.h"
@@ -1212,6 +1213,20 @@ static int __decode_answer(const unsigned char *message, /* packet */
return i + RRFIXEDSZ + a->rdlength;
}

+uint16_t dnsrand_next(int urand_fd, int def_value) {
+ if (urand_fd == -1) return def_value;
+ uint16_t val;
+ if(read(urand_fd, &val, 2) != 2) return def_value;
+ return val;
+}
+
+int dnsrand_setup(int *urand_fd, int def_value) {
+ if (*urand_fd > 0) return dnsrand_next(*urand_fd, def_value);
+ *urand_fd = open("/dev/urandom", O_RDONLY);
+ if (*urand_fd == -1) return def_value;
+ return dnsrand_next(*urand_fd, def_value);
+}
+
/* On entry:
* a.buf(len) = auxiliary buffer for IP addresses after first one
* a.add_count = how many additional addresses are there already
@@ -1237,6 +1252,7 @@ int __dns_lookup(const char *name,
/* Protected by __resolv_lock: */
static int last_ns_num = 0;
static uint16_t last_id = 1;
+ static int urand_fd = -1;

int i, j, fd, rc;
int packet_len;
@@ -1305,7 +1321,7 @@ int __dns_lookup(const char *name,
}
/* first time? pick starting server etc */
if (local_ns_num < 0) {
- local_id = last_id;
+ local_id = dnsrand_setup(&urand_fd, last_id);
/*TODO: implement /etc/resolv.conf's "options rotate"
(a.k.a. RES_ROTATE bit in _res.options)
local_ns_num = 0;
@@ -1316,8 +1332,9 @@ int __dns_lookup(const char *name,
retries_left--;
if (local_ns_num >= __nameservers)
local_ns_num = 0;
- local_id++;
+ local_id = dnsrand_next(urand_fd, local_id++);
local_id &= 0xffff;
+ DPRINTF("local_id:0x%hx\n", local_id);
/* write new values back while still under lock */
last_id = local_id;
last_ns_num = local_ns_num;

此uClibc补丁是uClibc-ng官方修复补丁的简化版,其核心是从系统的/dev/urandom文件读取双字节的随机数,然后用它设置原来的local_id,即DNS请求的事务ID。/dev/urandom是Linux系统的一个特殊的设备文件,用作非阻塞随机数发生器,它会重复使用熵池中的数据以产生伪随机数据。

注意在上面的补丁中,dnsrand_setup函数一定要先检查urand_fd是否为正,只有不成立时才去打开/dev/urandom。否则,每一次应用程序做DNS查询时都会重新打开此文件,系统将很快达到允许使用的文件描述符数目的上限,系统将因为无法再打开任何文件而崩溃。

最后,给出一个使用uClibc的嵌入式系统在加入DNS安全补丁前后的比较。如下是两个嗅探器截获的DNS数据包,第一个无补丁的系统,DNS请求的事务ID按顺序递增,这是明显的安全漏洞;第二个是加补丁后,每一个DNS请求的事务ID都是随机值,漏洞已补上。