CVE-2022-24448

1 CVE描述

2022年2月左右,在分析CVE-2022-24448当时的修复补丁ac795161c936 NFSv4: Handle case where the lookup of a directory fails时,发现始终都无法复现出补丁描述的问题。

发邮件Question about CVE-2022-24448问了nfs client maintainer和CVE的报告者,maintainer没回答,CVE报告者Lyu Tao给出了复现程序:

1. shell命令: mount -t nfs -o vers=4.2 $server_ip:/ /mnt/
2. c程序: fd = open("/mnt/file", O_ACCMODE|O_DIRECT|O_CREAT)
3. c程序: close(fd)
4. c程序: fd = open("/mnt/file", O_ACCMODE|O_DIRECT)
5. c程序: lseek(fd) // 空指针解引用

由于nfs client maintainer始终不回答,就决定自己解决,提了一组补丁fix nfsv4 bugs of opening with O_ACCMODE flag,并最终被社区接收。

将问题补丁如了回退,修复补丁: ab0fc21bc710 Revert “NFSv4: Handle the special Linux file open access mode”后,原本的问题也暴露出来了:

1. mount -t nfs -o vers=4 $server_ip:/ /mnt/ # NFSv4.0 / NFSv4.1 / NFSv4.2 挂载
2. fd = open("/mnt/file", O_ACCMODE|O_DIRECT|O_CREAT) # 第一次打开文件,如果不存在则创建
3. close(fd)
4. fd = open("/mnt/file", O_ACCMODE|O_DIRECT) # 第二次打开文件失败

修复补丁: b243874f6f95 NFSv4: fix open failure with O_ACCMODE flag

openEuler kernel的issue

2 maintainer的错误补丁

CVE报告者Lyu Tao当时向nfs client maintainer报告这个问题后,maintainer不知道怎么就稀里糊涂的提了一个完全不相关的补丁: ac795161c936 NFSv4: Handle case where the lookup of a directory fails,而且这个补丁描述的所谓“问题”根本没法复现:

NFSv4:处理目录查找失败的情况

如果应用程序设置了 O_DIRECTORY 标志,并尝试打开一个普通文件,nfs_atomic_open() 会退回执行常规查找的操作。如果服务器返回了一个普通文件,我们会错误地返回一个带有未初始化打开状态的文件描述符。

解决方法是在这些情况下返回预期的 ENOTDIR 错误。

CVE报告者Lyu Tao说的是以O_DIRECT标志打开文件,而maintainer却在说O_DIRECTORY标志,从这个事可以看出,这个nfs client maintainer很不严谨,连问题的描述都能看错。具体可以看我和Lyu Tao的邮件交流内容fix nfsv4 bugs of opening with O_ACCMODE flag

3 空指针解引用问题代码分析

修复补丁: ab0fc21bc710 Revert “NFSv4: Handle the special Linux file open access mode”

首先,分析发生空指针解引用时的栈信息,发现是 lseek 操作时 nfs4_valid_open_stateid 函数中 struct nfs4_state *state 的值为 NULL,访问 flags 字段时出现问题,代码流程如下:

lseek
   ksys_lseek
     vfs_llseek
       // file->f_op->llseek
       nfs4_file_llseek
         nfs42_proc_llseek // NFSv4.2 lseek 方法
           _nfs42_proc_llseek(lock)
             nfs4_set_rw_stateid(ctx=lock->open_context)
               nfs4_select_rw_stateid(state=ctx->state)
                 nfs4_valid_open_stateid(state)
                   state->flags // state 的值为 NULL

接着分析 struct nfs4_state *state 赋值的地方,通过跟踪 open 系统调用的过程,发现挂载后第一次使用 O_ACCMODE 标记打开文件时,struct nfs4_state *state 的值不为 NULL, 代码流程如下:

open
  do_sys_open
    do_sys_openat2
      do_filp_open
        path_openat
          open_last_lookups
            lookup_open
              // 挂载后第一次打开文件,执行atomic_open
              atomic_open
                open_to_namei_flags
                  flag-- // if ((flag & O_ACCMODE) == 3)
                // dir->i_op->atomic_open
                nfs_atomic_open
                  ctx = create_nfs_open_context
                    flags_to_mode
                    alloc_nfs_open_context // 分配 nfs_open_context
                  // NFS_PROTO(dir)->open_context
                  nfs4_atomic_open
                    nfs4_do_open
                      _nfs4_do_open
                        _nfs4_open_and_get_state
                          state = _nfs4_opendata_to_nfs4_state
                            nfs4_opendata_find_nfs4_state
                              nfs4_get_open_state
                          ctx->state = state // nfs4_state 赋值, lseek 不会发生空指针解引用

第二次使用 O_ACCMODE 标记打开文件时,struct nfs4_state *state 的值为 NULL, 代码流程如下:

open
  do_sys_open
    do_sys_openat2
      do_filp_open
        path_openat
          open_last_lookups
            lookup_open
              // 挂载后第二次打开文件,返回dentry,不执行atomic_open
              return dentry // if (dentry->d_inode)
          do_open
            vfs_open
              do_dentry_open
                // f->f_op->open
                nfs4_file_open // 挂载后第二次打开文件,执行到这里
                  if ((openflags & O_ACCMODE) == 3)
                  nfs_open // 没有向 server 发请求
                    alloc_nfs_open_context
                      ctx->state = NULL; // 重新初始化 struct nfs4_state,值为 NULL

再调用lseek():

lseek
  ksys_lseek
    vfs_llseek
      nfs4_file_llseek // file->f_op->llseek
        nfs42_proc_llseek
          _nfs42_proc_llseek(lock)
            nfs4_set_rw_stateid(ctx=lock->open_context)
              nfs4_select_rw_stateid(state=ctx->state)
                nfs4_valid_open_stateid(state)
                  state->flags // 空指针解引用

struct nfs4_state *state 值为 NULL 的原因找到了,是因为第二次使用 O_ACCMODE 标记打开文件时,nfs4_file_open 函数中对 O_ACCMODE 标记特殊处理,调用 nfs_open, 没有向 nfs server 发送请求,struct nfs4_state *state 赋值为 NULL

此问题是由补丁 44942b4e457b NFSv4: Handle the special Linux file open access mode 引入的,只需回退此问题补丁,即可解决空指针解引用问题。

回退问题补丁后,第二次使用 O_ACCMODE 标记打开文件时 struct nfs4_state *state 的赋值过程如下:

open
  do_sys_open
    do_sys_openat2
      do_filp_open
        path_openat
          do_open
            vfs_open
              do_dentry_open
                // f->f_op->open
                nfs4_file_open // 挂载后第二次打开文件,执行到这里
                  if ((openflags & O_ACCMODE) == 3)
                  openflags--
                  // NFS_PROTO(dir)->open_context
                  nfs4_atomic_open // 执行过程和第一次打开文件时一样
                    nfs4_do_open
                      _nfs4_do_open
                        _nfs4_open_and_get_state
                          state = _nfs4_opendata_to_nfs4_state
                            nfs4_opendata_find_nfs4_state
                              nfs4_get_open_state
                          ctx->state = state // nfs4_state 赋值, lseek 不会发生空指针解引用

4 打开文件失败问题代码分析

修复补丁: b243874f6f95 NFSv4: fix open failure with O_ACCMODE flag

回退问题补丁 44942b4e457b NFSv4: Handle the special Linux file open access mode 后,问题补丁本想解决的问题又暴露出来: NFSv4 挂载时,第二次使用 O_ACCMODE 标记打开文件失败。

通过 tcpdump 工具抓包,观察两次打开文件时的网络数据。

nfs client 请求第一次打开文件的数据如下:

Network File System, Ops(6): SEQUENCE, PUTFH, OPEN, GETFH, ACCESS, GETATTR
    ...
    Operations (count: 6): SEQUENCE, PUTFH, OPEN, GETFH, ACCESS, GETATTR
        ...
        Opcode: OPEN (18)
            ...
            share_access: OPEN4_SHARE_ACCESS_BOTH (3)
            ...

nfs server 回复第一次打开文件的数据如下:

Network File System, Ops(6): SEQUENCE PUTFH OPEN GETFH ACCESS GETATTR
    ...
    Status: NFS4_OK (0)
    ...

nfs client 请求第二次打开文件的数据如下:

Network File System, Ops(5): SEQUENCE, PUTFH, OPEN, ACCESS, GETATTR
    ...
    Operations (count: 5): SEQUENCE, PUTFH, OPEN, ACCESS, GETATTR
        ...
        Opcode: OPEN (18)
            ...
            share_access: OPEN4_SHARE_ACCESS_WANT_NO_PREFERENCE (0)
            ...

nfs server 回复第二次打开文件的数据如下:

Network File System, Ops(3): SEQUENCE PUTFH OPEN(NFS4ERR_BADXDR)
    ...
    Status: NFS4ERR_BADXDR (10036)
    ...

通过对比两次打开文件时的抓包数据可知,第二次使用 O_ACCMODE 标记打开文件时传给 nfs server 的 share_access 为 0, nfs server 返回错误 NFS4ERR_BADXDR, nfs server 解码 share_access 过程如下:

nfsd4_decode_compound
  // nfsd4_dec_ops[op->opnum](argp, &op->u)
  nfsd4_decode_open
    nfsd4_decode_share_access
      switch (w & NFS4_SHARE_WANT_MASK)
      // 合理的 share_access 为 NFS4_SHARE_ACCESS_READ(1) NFS4_SHARE_ACCESS_WRITE(2) NFS4_SHARE_ACCESS_BOTH(3)
      return nfserr_bad_xdr // share_access 为 0 时,返回错误

第二次使用 O_ACCMODE 标记打开文件时,nfs client 的编码 share_access 过程如下:

call_encode
  rpc_xdr_encode
    rpcauth_wrap_req
      rpcauth_wrap_req_encode
        nfs4_xdr_enc_open
          encode_open
            encode_openhdr
              encode_share_access(xdr, arg->share_access)

通过跟踪 open 系统调用的过程,第二次使用 O_ACCMODE 标记打开文件时,对 share_access 的处理过程如下:

open
  do_sys_open
    do_sys_openat2
      do_filp_open
        path_openat
          open_last_lookups
            lookup_open
              // 第二次打开文件,返回dentry,不执行atomic_open
              return dentry // if (dentry->d_inode)
          do_open
            vfs_open
              do_dentry_open
                // f->f_op->open
                nfs4_file_open // 第二次打开文件执行到这里
                  if ((openflags & O_ACCMODE) == 3)
                  openflags-- // openflags == 2
                  nfs4_atomic_open
                    nfs4_do_open
                      _nfs4_do_open
                        fmode = _nfs4_ctx_to_openmode(ctx) = 0
                          ret = ctx->mode & (FMODE_READ|FMODE_WRITE) = 0 // ctx->mode = 32796
                        nfs4_opendata_alloc
                          // 这里 share_access 赋值为 0
                          p->o_arg.share_access = nfs4_map_atomic_open_share(..., fmode, ...) = 0

对比第一次使用 O_ACCMODE 标记打开文件 share_access 的处理过程:

open
  do_sys_open
    do_sys_openat2
      do_filp_open
        path_openat
          open_last_lookups
            lookup_open
              // 第一次打开文件,执行atomic_open
              atomic_open
                nfs_atomic_open
                  create_nfs_open_context
                    // 这里把 flags 转化为 fmode
                    fmode = flags_to_mode(O_ACCMODE) = FMODE_READ|FMODE_WRITE
                    alloc_nfs_open_context
                      ctx->mode = f_mode = FMODE_READ|FMODE_WRITE = 3
                  // NFS_PROTO(dir)->open_context
                  nfs4_atomic_open
                    nfs4_do_open
                      _nfs4_do_open
                        fmode = _nfs4_ctx_to_openmode(ctx) = 3
                          ret = ctx->mode & (FMODE_READ|FMODE_WRITE) = 3 // ctx->mode = 3
                        nfs4_opendata_alloc
                          // 这里 share_access 赋值为 NFS4_SHARE_ACCESS_BOTH = 3
                          p->o_arg.share_access = nfs4_map_atomic_open_share(..., fmode, ...) = NFS4_SHARE_ACCESS_BOTH = 3

可以看出第一次和第二次使用 O_ACCMODE 标记打开文件的区别在于,第一次打开时 ctx->mode 是从 openflags 转换而来,而第二次打开时在 nfs4_file_open 函数中 ctx->mode 不是从 openflags 转换而来。

需要特别说明的是 filp->f_modectx->mode 的作用是不一样的,filp->f_mode 用于 nfs client 判断文件是否可读可写,而 ctx->mode 是用于传给 nfs server,。当 openflagsO_ACCMODE 时,filp->f_mode 没有标记 FMODE_READ|FMODE_WRITE (不可读,不可写),ctx->mode 需要标记为 FMODE_READ|FMODE_WRITE (传给 nfs server, nfs server 对文件可读可写)。

修复补丁 b243874f6f95 NFSv4: fix open failure with O_ACCMODE flag 将第二次使用 O_ACCMODE 标记打开文件时的 ctx->mode 标记为 FMODE_READ|FMODE_WRITE 传给 nfs server,即可解决此问题。

4.1 打开文件失败问题的规避补丁

如果没有合入90cf500e338a NFSv4: Fix return values for nfs4_file_open()(比如某些低版本的LTS或某些公司自己维护的版本),第二次以O_ACCMODE标志打开文件会成功,具体流程如下:

open
  do_sys_open
    do_sys_openat2
      do_filp_open
        path_openat
          alloc_empty_file
            init_file
              f->f_mode = OPEN_FMODE(flags) = 0 // 其他bit可能不为0
                (flag + 1) & O_ACCMODE) = (3 + 1) & 3 = 0
          open_last_lookups
            lookup_open
              return dentry // if (dentry->d_inode) {
          do_open
            vfs_open
              do_dentry_open
                nfs4_file_open // f->f_op->open
                  if ((openflags & O_ACCMODE) == 3)
                  openflags-- == O_RDWR
                  alloc_nfs_open_context(..., filp->f_mode, ...)
                    ctx->mode = f_mode = 0
                  nfs4_atomic_open // open失败
                  goto out_drop
                  err = -EOPENSTALE
                  return -EOPENSTALE
          if (error == -EOPENSTALE) // 条件满足
          error = -ESTALE
        filp = path_openat // 这次open成功

5nfs_open_context->mode判断FMODE_EXEC

NFS: check FMODE_EXEC from open context mode

除了在nfs4_file_open()函数中存在“打开文件失败问题“,在其他调用alloc_nfs_open_context()的地方也存在,如果打开文件时使用O_ACCMODE标记,则nfs_open_context->mode的值在某些场景下会为0,这时_nfs4_ctx_to_openmode()得到的值就为0,再把这个值传入到struct nfs_openargs中的fmode_t fmode,向nfs server请求时就会导致文件打开失败。解决办法是要在所有调用的alloc_nfs_open_context()的地方将传入的参数fmode_t f_mode的值统一用flags_to_mode(file->f_flags)

通过flags_to_mode()函数的转换,确保当open flag中含有__FMODE_EXEC时,nfs_open_context->mode中也能含有FMODE_EXEC,可以起到简化代码的作用。当我们需要判断fmode_t中是否含有FMODE_EXEC时,就不需要open flag,一些函数的参数中也不需要传入open flag。

6 测试用例

null-ptr-deref-test.c文件:

#define _GNU_SOURCE
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int main() {
    int fd;

    // 打开文件,O_ACCMODE 表示读写权限,O_DIRECT 表示直接 I/O,O_CREAT 表示如果文件不存在则创建
    fd = open("/mnt/file", O_ACCMODE | O_DIRECT | O_CREAT);

    if (fd == -1) {
        perror("无法打开/创建文件");
        return EXIT_FAILURE;
    }

    // 关闭文件
    close(fd);

    // 重新打开文件,O_ACCMODE 表示读写权限,O_DIRECT 表示直接 I/O
    fd = open("/mnt/file", O_ACCMODE | O_DIRECT);

    if (fd == -1) {
        perror("无法重新打开文件");
        return EXIT_FAILURE;
    }

    // 进行 lseek 操作(在这里只是示例,实际使用时可能需要根据具体需求进行调整)
    if (lseek(fd, 0, SEEK_DATA) == -1) {
        perror("lseek 操作失败");
        close(fd);
        return EXIT_FAILURE;
    }

    // 关闭文件
    close(fd);

    return EXIT_SUCCESS;
}

open-fail-test.c文件:

#define _GNU_SOURCE
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int main() {
    int fd;

    // 打开文件,O_ACCMODE 表示读写权限,O_DIRECT 表示直接 I/O,O_CREAT 表示如果文件不存在则创建
    fd = open("/mnt/file", O_ACCMODE | O_DIRECT | O_CREAT);

    if (fd == -1) {
        perror("无法打开/创建文件");
        return EXIT_FAILURE;
    }

    // 关闭文件
    close(fd);

    // 重新打开文件,O_ACCMODE 表示读写权限,O_DIRECT 表示直接 I/O
    fd = open("/mnt/file", O_ACCMODE | O_DIRECT);

    if (fd == -1) {
        perror("无法重新打开文件");
        return EXIT_FAILURE;
    }

    // 关闭文件
    close(fd);

    return EXIT_SUCCESS;
}

启动nfs server后,测试步骤:

mount -t nfs -o vers=4.2 localhost:/ /mnt/ # 既当客户端,又当服务端
gcc null-ptr-deref-test.c -o null-ptr-deref-test
gcc open-fail-test.c -o open-fail-test
./open-fail-test; echo $? # 最新的主线代码预期打开文件成功,输出0
echo 3 > /proc/sys/vm/drop_caches
./null-ptr-deref-test # 最新的主线代码预期不发生空指针解引用,不发生panic

open-fail-test程序返回值不为0时(打开失败)的情况如下:

  1. 补丁(1)和(3)合入,补丁(2)没合入

null-ptr-deref-test程序发生panic的情况如下:

  1. 补丁(1)没合入