4.19 nfs_updatepage()空指针解引用问题

1 问题描述

4.19内核在nfs_updatepage函数中发生空指针解引用。

社区类似问题的邮件: nfs_page_async_flush returning 0 for fatal errors on writeback

相关补丁集: Fix up soft mounts for NFSv4.x

日志:

BUG: unable to handle kernel NULL pointer dereference at 0000000000000080
Call Trace:
 nfs_inode_add_request+0x1cc/0x5b8
 nfs_setup_write_request+0x1fa/0x1fc
 nfs_writepage_setup+0x2d/0x7d
 nfs_updatepage+0x8b8/0x936
 nfs_write_end+0x61d/0xd45
 generic_perform_write+0x19a/0x3f0
 nfs_file_write+0x2cc/0x6e5
 new_sync_write+0x442/0x560
 __vfs_write+0xda/0xef
 vfs_write+0x176/0x48b
 ksys_write+0x10a/0x1e9
 __se_sys_write+0x24/0x29
 __x64_sys_write+0x79/0x93
 do_syscall_64+0x16d/0x4bb
 entry_SYSCALL_64_after_hwframe+0x5c/0xc1

2 代码流程分析

因为合入了14bebe3c90b3 NFS: Don't interrupt file writeout due to fatal errors补丁,nfs_page_async_flush函数中在发生致命错误时page->mapping被设置为空,而nfs_page_async_flush函数这时不返回错误码,导致nfs_setup_write_request函数中执行到nfs_inode_add_request函数,发生了空指针解引用。

write
  ksys_write
    vfs_write
      __vfs_write
        new_sync_write
          nfs_file_write
            generic_perform_write
              nfs_write_end
                nfs_updatepage
                  nfs_writepage_setup
                    nfs_setup_write_request
                      // 尝试搜索已经存在的request,如果已存在就更新,并返回非NULL
                      nfs_try_to_update_request // return NULL
                        nfs_wb_page // return 0
                          if (clear_page_dirty_for_io(page)) // 条件满足
                          nfs_writepage_locked // return 0
                            nfs_do_writepage // return 0
                              // 合入补丁 14bebe3c90b3 NFS: Don't interrupt file writeout due to fatal errors 后返回 0
                              nfs_page_async_flush // return 0
                                nfs_error_is_fatal_on_server // 发生致命错误时
                                nfs_write_error_remove_page
                                  generic_error_remove_page
                                    truncate_inode_page
                                      truncate_cleanup_page
                                        do_invalidatepage
                                          nfs_invalidate_page
                                            nfs_wb_page_cancel
                                              nfs_inode_remove_request
                                                ClearPagePrivate(head->wb_page) // 清除private标记
                                      delete_from_page_cache
                                        __delete_from_page_cache
                                          page_cache_tree_delete
                                            page->mapping = NULL
                          continue
                          if (clear_page_dirty_for_io(page)) // 条件不满足
                          if (!PagePrivate(page)) // 条件满足
                          break
                      if (req != NULL) // 条件不满足
                      // 如果不存在就新创建一个request
                      nfs_create_request
                        req->wb_page    = page // page赋值到新创建的request
                      // 将request与inode关联起来
                      nfs_inode_add_request // 如果 nfs_page_async_flush 不返回0则不执行
                        mapping = page_file_mapping(req->wb_page)
                          return page->mapping
                        spin_lock(&mapping->private_lock) // mapping 为 NULL,发生空指针解引用

3 修复方案

回退补丁14bebe3c90b3 NFS: Don't interrupt file writeout due to fatal errors

回退补丁后,nfs_page_async_flush函数中在发生致命错误时返回错误码,nfs_setup_write_request函数中不会执行到nfs_inode_add_request函数,从而解决空指针解引用问题。

4 补丁分析

补丁14bebe3c90b3 NFS: Don't interrupt file writeout due to fatal errors所属的补丁集中还有以下几个相关的补丁:

22876f540bdf NFS: Don't call generic_error_remove_page() while holding locks

6fbda89b257f NFS: Replace custom error reporting mechanism with generic one

4.1 最新的代码分析

补丁集合入后,在最新的代码中, 当nfs_page_async_flush中产生致命错误时,因为nfs_page_assign_folio中赋值了新的folionfs_inode_add_request中的mapping不会为空,从而不会发生空指针解引用的问题,也不会发生内存泄露。

nfs_setup_write_request
  nfs_try_to_update_request
    nfs_wb_folio
      nfs_writepage_locked
        nfs_do_writepage
          nfs_page_async_flush
            nfs_write_error // 只记录错误,想留给fsync报给用户态
  nfs_page_create_from_folio
    nfs_page_create
    nfs_page_assign_folio
      req->wb_folio = folio // 这个地方保证了不会产生内存泄漏
    nfs_inode_add_request
      // 注意这个地方是从folio中取出address_space
      struct address_space *mapping = folio_file_mapping(folio)
      // 这个地方的mapping一定不会是NULL
      spin_lock(&mapping->private_lock)

4.2 回退的补丁

补丁14bebe3c90b3 NFS: Don't interrupt file writeout due to fatal errors里描述的: 不立刻上报回写错误,而是让fsync上报。

要说明一下,maintainer自己都没想好这个机制有没问题,具体可以参考我的另一篇文章: 《nfs回写错误处理不正确的问题》,这里就不做过多展开。

而且这是一个重构补丁集,根本就没必要在这里加一个“Fixes:”标签,补丁集是为了改变一个机制,并不是为了解决一个bug。这个补丁之所以会被Greg Kroah-Hartman错误的单独合入到4.19中,就是因为nfs maintainer喜欢乱加“Fixes:”标签,这已经不是他第一次这样做了。

5 构造复现

4.19代码合入补丁0001-reproduce-4.19-null-ptr-deref-in-nfs_updatepage.patch

挂载nfs:

mount -t nfs -o vers=4.1 ${nfs_server_ip}:/server/export/dir /mnt

不断执行以下脚本,直到发生空指针解引用:

echo something > something
echo something_else > something_else
echo something_else_again > something_else_again
# 为什么不直接用 echo something > /mnt/file 呢,因为用ps无法查看到echo进程
cat something > /mnt/file &
cat something_else > /mnt/file &
cat something_else_again > /mnt/file &

6 与maintainer的交流

Question about LTS 4.19 patch “89047634f5ce NFS: Don’t interrupt file writeout due to fatal errors”

Trond回复:

根据定义,重构是一种不影响代码行为的更改。很明显,这个补丁从未被设计为这样的一个补丁。

出现问题的原因是在4.19.x版本中发生了错误,而在最新版本的内核中没有发生,这是因为前者缺少另一个修复错误的补丁(实际上缺少一个'Fixes:'标签)。

因此,您是否可以检查一下是否应用提交 22876f540bdf ("NFS: Don't call generic_error_remove_page() while holding locks")  可以修复这个问题。

请注意,为了解决读取死锁问题(如标签上所指示的),无论如何都需要后一个补丁。

打上这个补丁后是有问题的,会进入死循环,具体请查看《4.19 nfs_wb_page() soft lockup的问题》

7 vmcore解析

7.1 查看崩溃在哪一行

# 查看内核日志
crash> dmesg | less
...
BUG: unable to handle kernel NULL pointer dereference at 0000000000000080
...
RIP: 0010:_raw_spin_lock+0x1d/0x35
...
 nfs_inode_add_request+0x1cc/0x5b8

# 在内核仓库目录下执行的shell命令,在docker ubuntu2204环境中打印不出具体行号,原因暂时母鸡
./scripts/faddr2line build/vmlinux nfs_inode_add_request+0x1cc/0x5b8 # 或者把vmlinux替换成ko文件
nfs_inode_add_request+0x1cc/0x5b8:
nfs_have_writebacks at include/linux/nfs_fs.h:548 (discriminator 3)
(inlined by) nfs_inode_add_request at fs/nfs/write.c:774 (discriminator 3)

# 反汇编指定地址的代码,以查看其汇编指令, -l 选项用于在反汇编时显示源代码行号(如果可用)
crash> dis -l ffffffff81c0a939
/home/sonvhi/chenxiaosong/code/4.19-stable/build/../include/linux/nfs_fs.h: 248
0xffffffff81c0a939 <nfs_inode_add_request+460>: lea    -0xe0(%rbp),%r14

crash> bt # 查看崩溃的栈
[exception RIP: _raw_spin_lock+29] RIP: ffffffff836c0e4a
...
[ffff8880b1ab7a78] nfs_inode_add_request at ffffffff81c0a939
[ffff8880b1ab7ab0] nfs_setup_write_request at ffffffff81c14312

# 查找指定地址的符号信息
crash> sym ffffffff836c0e4a # [exception RIP: _raw_spin_lock+29] RIP: ffffffff836c0e4a
ffffffff836c0e4a (T) _raw_spin_lock+29 /home/sonvhi/chenxiaosong/code/4.19-stable/build/../arch/x86/include/asm/atomic.h: 194
crash> sym ffffffff81c0a939 # [ffff8880b1ab7a78] nfs_inode_add_request at ffffffff81c0a939
ffffffff81c0a939 (t) nfs_inode_add_request+460 /home/sonvhi/chenxiaosong/code/4.19-stable/build/../include/linux/nfs_fs.h: 248

faddr2line脚本解析是的nfs_inode_add_request函数中的774行,也就是发生问题的spin_lock(&mapping->private_lock)之后的一行,看来解析还是有一点点的不准,不过不要紧,我们再看dmesg命令中的日志RIP: 0010:_raw_spin_lock+0x1d/0x35,就能确定确实是崩溃在773行spin_lock(&mapping->private_lock)

另外,由crash的dis -lsym命令定位到include/linux/nfs_fs.h: 248NFS_I()函数,这就不知道为什么了,麻烦知道的朋友联系我。

7.2 分析栈帧数据

7.2.1 确认page->mappingNULL

查看nfs_inode_add_request函数中的堆栈:

# -F[F]: 类似于 -f,不同之处在于当适用时以符号方式显示堆栈数据;如果堆栈数据引用了 slab cache 对象,将在方括号内显示 slab cache 的名称;在 ia64 架构上,将以符号方式替代参数寄存器的内容。如果输入 -F 两次,并且堆栈数据引用了 slab cache 对象,将同时显示地址和 slab cache 的名称在方括号中。
crash> bt -FF
#10 [ffff8880b1ab79c0] async_page_fault at ffffffff8380119e
    [exception RIP: _raw_spin_lock+29]
    ...
    RDX: 0000000000000001  RSI: ffff8880b5760000  RDI: 0000000000000002
    ...
#11 [ffff8880b1ab7a78] nfs_inode_add_request at ffffffff81c0a939
    ffff8880b1ab7a80: ffffea0002cd5900 [ffff8880b0cf0b00:nfs_page] 
    ...

crash> bt -F
#11 [ffff8880b1ab7a78] nfs_inode_add_request at ffffffff81c0a939
    ffff8880b1ab7a80: ffffea0002cd5900 [nfs_page]

# -f: 显示堆栈帧中包含的所有数据;此选项可用于确定传递给每个函数的参数;在 ia64 架构上,将显示参数寄存器的内容。
crash> bt -f
#11 [ffff8880b1ab7a78] nfs_inode_add_request at ffffffff81c0a939
    ffff8880b1ab7a80: ffffea0002cd5900 ffff8880b0cf0b00

注意[ffff8880b0cf0b00:nfs_page]并不一定是nfs_page结构体的起始地址,有可能是中间某个变量的地址,要用kmem命令查看:

crash> kmem ffff8880b0cf0b00
...
  FREE / [ALLOCATED] # 中括号[ALLOCATED]代表已分配
  [ffff8880b0cf0b00]
...

只是这里刚好是起始地址,使用地址ffff8880b0cf0b00来查看结构体nfs_page中的数据:

crash> struct nfs_page ffff8880b0cf0b00 -x
struct nfs_page {
  ...
  wb_page = 0xffffea0002cd5900,
  ...
}

再查看wb_page = 0xffffea0002cd5900中的数据:

struct page {
  ...
  {
    {
      ...
      mapping = 0x0,
      ...
    },
  }
}

再看发生崩溃的地方:

nfs_inode_add_request
  spin_lock(lock = &mapping->private_lock) // static __always_inline void spin_lock(spinlock_t *lock)
    raw_spin_lock(&lock->rlock)
      _raw_spin_lock(&lock->rlock) // void __lockfunc _raw_spin_lock(raw_spinlock_t *lock)

再解析结构体偏移:

# 首先已知 struct address_space *mapping = 0
crash> struct address_space -ox
struct address_space {
  ...
  # 这里正好和dmesg日志中对应上: BUG: unable to handle kernel NULL pointer dereference at 0000000000000080
  # 当然,如果mapping的地址是一个不为NULL的值,比如是一个无效的地址x,那报错的地址就是0000000000000080+x
  [0x80] spinlock_t private_lock;
  ...
}
SIZE: 0xa8

crash> struct spinlock_t -ox
typedef struct spinlock {
        union {
  [0x0]     struct raw_spinlock rlock;
        };
} spinlock_t;
SIZE: 0x4

x86_64下整数参数使用的寄存器依次为: RDI,RSI,RDX,RCX,R8,R9,_raw_spin_lock只有一个参数,从栈中可以看到RDI: 0000000000000002,这个值是怎么来的呢?估计要看nfs_inode_add_request_raw_spin_lock的反汇编。

7.2.2 nfs_inode_add_request的第一个参数struct inode *inode

我们再看一次跟inode有关的栈帧数据:

crash> bt -FF
#10 [ffff8880b1ab79c0] async_page_fault at ffffffff8380119e
    [exception RIP: _raw_spin_lock+29]
    ...
    ffff8880b1ab79c8: [ffff8880b6a43ec8:nfs_inode_cache(198:serial-getty@ttyS0.service)] ffffea0002cd5900 
    ...
    ffff8880b1ab79e8: [ffff8880b6a43ec8:nfs_inode_cache(198:serial-getty@ttyS0.service)] 0000000000000080 
    ...
#11 [ffff8880b1ab7a78] nfs_inode_add_request at ffffffff81c0a939
    ...
    ffff8880b1ab7aa0: [ffff888107060a80:kmalloc-128] [ffff8880b6a43ec8:nfs_inode_cache(198:serial-getty@ttyS0.service)] 
    ...

注意地址[ffff8880b6a43ec8:nfs_inode_cache]并不是struct nfs_inode的首地址,而是struct inode的首地址,使用kmem命令解析:

crash> kmem ffff8880b6a43ec8
...
  FREE / [ALLOCATED]
  [ffff8880b6a43cf0] # 这才是struct nfs_inode的首地址
...

crash> struct nfs_inode ffff8880b6a43cf0 -ox
struct nfs_inode {
  ...
  [ffff8880b6a43ec8] struct inode vfs_inode; # 这就是struct page的地址
}
SIZE: 0x430

crash> struct inode ffff8880b6a43ec8 -x
struct inode {
  ...
  i_mapping = 0xffff8880b6a44040,
  ...

inode->i_mapping的值和nfs_setup_write_request栈中的一个数据一样,以后再研究他们之间的关系吧:

#12 [ffff8880b1ab7ab0] nfs_setup_write_request at ffffffff81c14312
    ...
    ffff8880b1ab7ad8: [ffff8880b6a44040:nfs_inode_cache(198:serial-getty@ttyS0.service)] 000000000000000f