在之前的文章Netfilter中的另一个错误里,我介绍了在 Linux 内核的 netfilter 子系统中发现的一个漏洞。在调查中,发现了一个奇怪的比较,它不能完全保护缓冲区中的副本。它导致了堆缓冲区溢出,被利用来在 Ubuntu 22.04 上获取 root 权限。

[CVE-2022-34918]Linux 防火墙漏洞复现插图
Linux 防火墙漏洞复现

过去的一个小跳跃

在上一集中,我们在结构内达到了边界nft_set/include/net/netfilter/nf_tables.h)。

struct nft_set {
    struct list_head        list;
    struct list_head        bindings;
    struct nft_table        *table;
    possible_net_t          net;
    char                *name;
    u64             handle;
    u32             ktype;
    u32             dtype;
    u32             objtype;
    u32             size;
    u8              field_len[NFT_REG32_COUNT];
    u8              field_count;
    u32             use;
    atomic_t            nelems;
    u32             ndeact;
    u64             timeout;
    u32             gc_int;
    u16             policy;
    u16             udlen;
    unsigned char           *udata;
    /* runtime data below here */
    const struct nft_set_ops    *ops ____cacheline_aligned;
    u16             flags:14,
                    genmask:2;
    u8              klen;
    u8              dlen;
    u8              num_exprs;
    struct nft_expr         *exprs[NFT_SET_EXPR_MAX];
    struct list_head        catchall_list;
    unsigned char           data[]
        __attribute__((aligned(__alignof__(u64))));
};

由于nft_set包含大量数据,此结构的其他一些字段可用于获得更好的写入原语。我决定搜索长度字段(和) udlen,因为执行一些溢出可能会有所帮助。klendlen

代码和异常研究

探索对字段的不同访问,对( ) 中函数的dlen调用引起了我的注意。memcpy(1)nft_set_elem_init/net/netfilter/nf_tables_api.c

void *nft_set_elem_init(const struct nft_set *set,
            const struct nft_set_ext_tmpl *tmpl,
            const u32 *key, const u32 *key_end,
            const u32 *data, u64 timeout, u64 expiration, gfp_t gfp)
{
    struct nft_set_ext *ext;
    void *elem;

    elem = kzalloc(set->ops->elemsize + tmpl->len, gfp);                <===== (0)
    if (elem == NULL)
        return NULL;

    ext = nft_set_elem_ext(set, elem);
    nft_set_ext_init(ext, tmpl);

    if (nft_set_ext_exists(ext, NFT_SET_EXT_KEY))
        memcpy(nft_set_ext_key(ext), key, set->klen);
    if (nft_set_ext_exists(ext, NFT_SET_EXT_KEY_END))
        memcpy(nft_set_ext_key_end(ext), key_end, set->klen);
    if (nft_set_ext_exists(ext, NFT_SET_EXT_DATA))
        memcpy(nft_set_ext_data(ext), data, set->dlen);                 <===== (1)

    ...

    return elem;
}

这个调用很可疑,因为使用了两个不同的对象。目标缓冲区存储在nft_set_ext对象中ext,而副本的大小是从nft_set对象中提取的。对象在withext处动态分配,为其保留的大小为. 我想检查存储的值是否用于计算存储在.(0)elemtmpl->lenset->dlentmpl->len

错误的地方

nft_set_elem_init(5)在函数nft_add_set_elem( )中调用,该函数/net/netfilter/nf_tables_api.c负责将元素添加到 netfilter 集合。

static int nft_add_set_elem(struct nft_ctx *ctx, struct nft_set *set,

                const struct nlattr *attr, u32 nlmsg_flags)
{
    struct nlattr *nla[NFTA_SET_ELEM_MAX + 1];
    struct nft_set_ext_tmpl tmpl;
    struct nft_set_elem elem;                                                   <===== (2)
    struct nft_data_desc desc;
    
    ...
    
    if (nla[NFTA_SET_ELEM_DATA] != NULL) {
        err = nft_setelem_parse_data(ctx, set, &desc, &elem.data.val,           <===== (3)
                         nla[NFTA_SET_ELEM_DATA]);
        if (err < 0)
            goto err_parse_key_end;

        ...

        nft_set_ext_add_length(&tmpl, NFT_SET_EXT_DATA, desc.len);              <===== (4)
    }
    
    ...
    
    err = -ENOMEM;
    elem.priv = nft_set_elem_init(set, &tmpl, elem.key.val.data,                <===== (5)
                      elem.key_end.val.data, elem.data.val.data,
                      timeout, expiration, GFP_KERNEL);
    if (elem.priv == NULL)
        goto err_parse_data;
    
    ...

如您所见,set->dlen不是用于为与 id 关联的数据保留空间NFT_SET_EXT_DATA,而是desc.len (5)desc在调用的函数nft_setelem_parse_data/net/netfilter/nf_tables_api.c)中初始化(3)

static int nft_setelem_parse_data(struct nft_ctx *ctx, struct nft_set *set,
                  struct nft_data_desc *desc,
                  struct nft_data *data,
                  struct nlattr *attr)
{
    int err;

    err = nft_data_init(ctx, data, NFT_DATA_VALUE_MAXLEN, desc, attr);          <===== (6)
    if (err < 0)
        return err;

    if (desc->type != NFT_DATA_VERDICT && desc->len != set->dlen) {             <===== (7)
        nft_data_release(data, desc->type);
        return -EINVAL;
    }

    return 0;
}

首先,data根据用户提供的数据desc填写nft_data_init( ) 。关键部分是在和at之间进行的检查,仅当与添加的元素关联的数据具有不同的类型时才会发生。/net/netfilter/nf_tables_api.c(6)desc->lenset->dlen(7)NFT_DATA_VERDICT

但是,set->dlen在创建新集时由用户控制。唯一的限制是set->dlen应该低于 64 字节并且数据类型应该不同于NFT_DATA_VERDICT. 此外,当desc->type等于 时NFT_DATA_VERDICTdesc->len等于 16 个字节。

将 type 的元素添加NFT_DATA_VERDICT到具有数据类型的集合中NFT_DATA_VALUE通常会导致与desc->len不同set->dlen。因此,可以在nft_set_elem_initat中执行堆缓冲区溢出(1)。此缓冲区溢出可以扩展到 48 个字节长。

留在这儿 !我会很快回来 !

然而,这不是标准的缓冲区溢出,此时用户可以直接控制溢出的数据。在这种情况下,随机数据将从分配的缓冲区中复制出来。

如果我们检查对 的调用nft_set_elem_init (5),可以观察到复制的数据是从局部变量中提取的,该变量elem是一个nft_set_elem对象。

    struct nft_set_elem elem;                                                   <===== (2)
    
    ...
    
    elem.priv = nft_set_elem_init(set, &tmpl, elem.key.val.data,                <===== (5)
                      elem.key_end.val.data, elem.data.val.data,
                      timeout, expiration, GFP_KERNEL);

nft_set_elem/net/netfilter/nf_tables.h) 用于在创建新元素期间存储有关新元素的信息。

#define NFT_DATA_VALUE_MAXLEN   64

struct nft_verdict {
    u32             code;
    struct nft_chain        *chain;
};

struct nft_data {
    union {
        u32         data[4];
        struct nft_verdict  verdict;
    };
} __attribute__((aligned(__alignof__(u64))));

struct nft_set_elem {
    union {
        u32     buf[NFT_DATA_VALUE_MAXLEN / sizeof(u32)];
        struct nft_data val;
    } key;
    union {
        u32     buf[NFT_DATA_VALUE_MAXLEN / sizeof(u32)];
        struct nft_data val;
    } key_end;
    union {
        u32     buf[NFT_DATA_VALUE_MAXLEN / sizeof(u32)];
        struct nft_data val;
    } data;
    void            *priv;
};

可以看出,保留了 64 个字节来临时存储与新元素关联的数据。elem.data但是,触发缓冲区溢出时最多只能写入 16 个字节。因此,溢出中使用了随机字节。

最后,不是那么随意

此时,我发现缓冲区溢出,无法控制用于损坏的数据。构建一个不受控制的缓冲区溢出漏洞利用是一个真正的挑战。

elem.data用于溢出未初始化(2)。它可以用来控制溢出。

让我们看看调用者,也许之前调用的函数可以帮助控制用于溢出的数据。 对于用户想要添加到集合中的每个元素,在( )nft_add_set_elem中调用。nf_tables_newsetelem/net/netfilter/nf_tables_api.c

static int nf_tables_newsetelem(struct sk_buff *skb,
                const struct nfnl_info *info,
                const struct nlattr * const nla[])
{
    ...
    
    nla_for_each_nested(attr, nla[NFTA_SET_ELEM_LIST_ELEMENTS], rem) {
        err = nft_add_set_elem(&ctx, set, attr, info->nlh->nlmsg_flags);
        if (err < 0) {
            NL_SET_BAD_ATTR(extack, attr);
            return err;
        }
    }
}

nla_for_each_nested用于迭代用户发送的属性,因此用户能够控制将完成的迭代次数。并且nla_for_each_nested仅使用宏和内联函数,因此调用后nft_add_set_elem可以直接调用nft_add_set_elem. 它非常有用,因为它允许在溢出中使用前一个元素的数据,因为它elem.data没有被初始化。此外,可以忽略堆栈布局的随机化。因此,控制溢出的方式将独立于内核编译。

以下架构总结了elem.data堆栈内产生受控溢出的不同阶段。

[CVE-2022-34918]Linux 防火墙漏洞复现插图1

随机数据存储在堆栈中,添加带有NFT_DATA_VALUE数据的新元素会导致堆栈中的用户控制数据。最后,添加带有NFT_DATA_VERDICT数据的第二个元素将触发缓冲区溢出,并且在溢出期间将复制最后一个元素的数据残留。

缓存选择

在开发我的利用策略之前我们没有讨论的最后一件事是发生溢出的缓存。 elem, 分配于(0), 取决于用户选择的不同选项,如前面的函数摘录所示nft_add_set_elem,其大小可能会有所不同。有几个选项可用于增加它,例如NFT_SET_ELEM_KEYNFT_SET_ELEM_KEY_END。它们允许在elem. 所以这个溢出很明显会发生在几个缓存中。 elem在带有标志的 Ubuntu 22.04 上分配GFP_KERNEL。因此,有关的缓存是kmalloc-{64,96,128,192}.

现在,唯一剩下的就是对齐elem缓存对象大小以执行最佳溢出。下一个模式表示将elem其对齐到 64 字节的构造。

元素构建

我们使用以下结构来定位kmalloc-64缓存:

  • 对象头 20 个字节
  • 通过 28 字节的填充NFT_SET_ELEM_KEY
  • 16字节存储类型的元素数据NFT_DATA_VERDICT

给我一个泄露库

现在可以控制溢出的数据,下一步是找到一种方法来检索 KASLR 库。由于溢出只发生在kmalloc-x缓存中,经典msg_msg对象不能用于执行信息泄漏,因为它们是在kmalloc-cg-x缓存中分配的。

我们查看了user_key_payload/include/keys/user-type.h) 对象,通常用于在内核领域存储敏感的用户信息,它代表了一个很好的选择。它们在结构上类似于msg_msg对象:带有对象大小的标头,然后是带有用户数据的缓冲区。

struct user_key_payload {
    struct rcu_head rcu;        /* RCU destructor */
    unsigned short  datalen;    /* length of this data */
    char        data[] __aligned(__alignof__(u64)); /* actual data */
};

这些对象在函数user_preparse/security/keys/user_defined.c)中分配

int user_preparse(struct key_preparsed_payload *prep)
{
    struct user_key_payload *upayload;
    size_t datalen = prep->datalen;

    if (datalen <= 0 || datalen > 32767 || !prep->data)
        return -EINVAL;

    upayload = kmalloc(sizeof(*upayload) + datalen, GFP_KERNEL);                <===== (6)
    if (!upayload)
        return -ENOMEM;

    /* attach the data */
    prep->quotalen = datalen;
    prep->payload.data[0] = upayload;
    upayload->datalen = datalen;
    memcpy(upayload->data, prep->data, datalen);                                <===== (7)
    return 0;
}

完成的分配(6)考虑了用户提供的数据的长度。然后,在调用memcpyat(7) 的头部之后存储数据。user_key_payload对象的头部是 24 字节长,因此它们可以用来喷射多个缓存,kmalloc-32kmalloc-8k.

与对象一样,目标是用比初始值更大的值msg_msg覆盖字段。datalen在检索存储的信息时,损坏的对象将返回比用户最初提供的更多的数据。

然而,这种喷雾有一个重要的缺点。分配对象的数量受到限制。该sysctl变量kernel.keys.maxkeys定义了用户密钥环中允许的密钥数量的限制。此外,kernel.keys.maxbytes限制密钥环中存储的字节数。这些变量的默认值非常低。它们如下所示,适用于 Ubuntu 22.04

kernel.keys.maxbytes = 20000
kernel.keys.maxkeys = 200

漏洞利用

既然找到了泄露信息的方法,那么接下来就是寻找有趣的信息了。在kmalloc-64缓存中工作似乎是最好的,这是具有可能发生溢出的最小对象的缓存。因此,更多数量的对象可能会泄漏。

percpu_ref_data/include/linux/percpu-refcount.h) 对象也被分配在这个缓存中。它们是有趣的目标,因为它们包含两种有用的指针。

struct percpu_ref_data {
    atomic_long_t       count;
    percpu_ref_func_t   *release;
    percpu_ref_func_t   *confirm_switch;
    bool            force_atomic:1;
    bool            allow_reinit:1;
    struct rcu_head     rcu;
    struct percpu_ref   *ref;
};

这些对象存储指向函数(字段releaseconfirm_switch)的指针,当它们被泄露时可用于计算 KASLR 基或模块基,以及指向动态分配对象(字段ref)的指针,该指针可用于计算 physmap 基。

此类对象是在调用percpu_ref_init/lib/percpu-refcount.c) 期间分配的。

int percpu_ref_init(struct percpu_ref *ref, percpu_ref_func_t *release,
            unsigned int flags, gfp_t gfp)
{
    struct percpu_ref_data *data;
    
    ...
    
    data = kzalloc(sizeof(*ref->data), gfp);
    
    ...
    
    data->release = release;
    data->confirm_switch = NULL;
    data->ref = ref;
    ref->data = data;
    return 0;
}

分配percpu_ref_data对象的最简单方法是使用io_uring_setup系统调用 ( /fs/io_uring.c)。而为了对这样一个对象的释放进行编程,对系统调用的简单调用close就足够了。

对象的分配是在函数( ) 内的对象 ( )percpu_ref_data初始化期间完成的。io_ring_ctx/fs/io_uring.cio_ring_ctx_alloc/fs/io_uring.c

static __cold struct io_ring_ctx *io_ring_ctx_alloc(struct io_uring_params *p)
{
    struct io_ring_ctx *ctx;
    
    ...
    
    if (percpu_ref_init(&ctx->refs, io_ring_ctx_ref_free,
                PERCPU_REF_ALLOW_REINIT, GFP_KERNEL))
        goto err;

    ...
}

由于io_uring集成到 Linux 内核中,io_ring_ctx_ref_free/fs/io_uring.c) 的泄漏允许计算 KASLR 基数。

在我的调查过程中,一些意外percpu_ref_data的对象在泄漏中,但函数io_rsrc_node_ref_zero/fs/io_uring.c) 的地址在 field 内release。在分析了这些对象的来源后,我了解到它们也来自io_uring_setup系统调用。系统调用的这种良好的副作用io_uring_setup允许改善我的漏洞利用中的泄漏。

(G)Root权限

现在,可以获得有用的信息泄漏,需要一个好的写原语来执行权限提升。

几周前,Starlabs 的 Lam Jun Rong 发表了一篇文章,描述了一种利用 CVE-2021-41073 的新方法。他提出了一种新的写原语,即取消链接攻击。它基于list_del操作。在用两个地址破坏一个list_head对象后,一个地址被存储在另一个地址。

正如在 LJ Rong 的文章中,list_head我的漏洞利用中的腐败目标是一个simple_xattr对象。

struct simple_xattr {
    struct list_head list;
    char *name;
    size_t size;
    char value[];
};

要工作,此技术需要知道哪个对象已损坏。在另一种情况下,从列表中删除随机项目会导致列表遍历中的错误。列表中的项目用它们的名称来标识。

为了识别损坏的对象,我在该字段上执行了一个技巧name:分配name一个足够高的长度以保留 256 个字节,返回地址的最低有效字节为空。小端架构,例如x86_64,允许我们只name擦除list_head. 因此,可以list为写入原语准备字段,同时识别被破坏的对象截断其名称。唯一的要求是所有名称都具有相同的结尾。

以下模式总结了带有simple_xattr对象的喷雾名称的构造。

xattr 名称构造的架构

使用此写入原语,可以使用文件夹modprobe_path中的路径编辑/tmp/。它允许以root权限执行任何程序,并享受root shell!

运行 poc

评论

这种利用方法是基于特定地址映射到内核域的假设,但情况并非总是如此。因此,该漏洞利用并不完全可靠,但它仍然具有良好的成功率。取消链接攻击的第二个缺点是当漏洞利用完成时会出现内核恐慌。这可以通过在利用过程结束时找到可以留在内核内存中的对象来避免。

故事的结局

此漏洞已报告给 Linux 安全团队,并已分配 CVE-2022-34918。他们提出了一个我测试和审查的补丁,它已在提交7e6bc1f6cabcd30aba0b11219d8e01b952eacbb6的上游树中发布。

结论

综上所述,在Linux 内核的 Netfilter 子系统中发现了堆缓冲区溢出,可以利用此漏洞在 Ubuntu 22.04 上进行权限提升。该漏洞利用的源代码可在GitHub 上找到