Oops 是 linux 內(nèi)核中最常見的一種異常出錯情況,本文通過分析一個具體的 Oops 例子,介紹了如何分析并解決該類問題。
1. LSM 內(nèi)核模塊出現(xiàn) Oops
insmod 一個 LSM 的 ko 模塊,內(nèi)核打印如下日志:
[ 415.746844] BUG: unable to handle kernel paging request at ffffffffaa6f0210
[ 415.746846] PGD 3fc0e067 P4D 3fc0e067 PUD 3fc0f063 PMD 34367063 PTE 800000003faf0061
[ 415.746849] Oops: 0003 [#1] SMP PTI
[ 415.746851] CPU: 0 PID: 8366 Comm: insmod Tainted: G OE 4.19.82-wwh #1
[ 415.746852] Hardware name: VMware, Inc. VMware Virtual Platform/440BX Desktop Reference Platform, BIOS 6.00 07/29/2019
[ 415.746856] RIP: 0010:init_lsm_hooks+0x1cd/0x1f0 [xxx_security]
[ 415.746857] Code: 00 00 48 8b 41 10 48 89 71 20 48 8b 10 48 85 d2 75 05 eb 25 48 89 c2 48 8b 02 48 85 c0 75 f5 48 c7 01 00 00 00 00 48 89 51 08 <48> 89 0a 48 83 c1 28 48 39 f9 75 cc 31 c0 c3 48 c7 01 00 00 00 00
[ 415.746858] RSP: 0018:ffffa763c332fcb0 EFLAGS: 00010246
[ 415.746859] RAX: 0000000000000000 RBX: 0000000000000000 RCX: ffffffffc0ae2020
[ 415.746859] RDX: ffffffffaa6f0210 RSI: ffffffffc0adfaa9 RDI: ffffffffc0ae2458
[ 415.746860] RBP: ffffffffc0ada000 R08: 000000000000006d R09: ffffffffaa5c11f3
[ 415.746860] R10: ffffffffaa63c310 R11: ffffffffaa63c6a0 R12: ffffffffc0ae2fc0
[ 415.746861] R13: ffffffffc0ae2e58 R14: ffffa763c332fe98 R15: ffffffffc0ae2e40
[ 415.746862] FS: 00007fdf33491200(0000) GS:ffff9ac97bc00000(0000) knlGS:0000000000000000
[ 415.746863] CS: 0010 DS: 0000 ES: 0000 CR0: 0000000080050033
[ 415.746863] CR2: ffffffffaa6f0210 CR3: 0000000077222004 CR4: 00000000003606f0
[ 415.746880] Call Trace:
[ 415.746883] init_module+0x34/0xc0 [xxx_security]
[ 415.746886] do_one_initcall+0x46/0x1c3
[ 415.746889] ? _cond_resched+0x15/0x30
[ 415.746890] ? kmem_cache_alloc_trace+0x155/0x1d0
[ 415.746892] do_init_module+0x5a/0x210
[ 415.746894] load_module+0x215b/0x2390
[ 415.746897] ? __do_sys_finit_module+0xa8/0x110
[ 415.746898] __do_sys_finit_module+0xa8/0x110
[ 415.746900] do_syscall_64+0x55/0xf0
[ 415.746901] entry_SYSCALL_64_after_hwframe+0x44/0xa9
[ 415.746910] RIP: 0033:0x7fdf335abf59
2. Oops 訪存出錯地址分析
首先看第一行:
BUG: unable to handle kernel paging request at ffffffffaa6f0210
Unable to handle kernel paging request at virtual address 是內(nèi)存訪問異常的錯誤,原因通常有以下三種:
- virtual address 為 0x00000000 時,說明使用了空指針;
- virtual address 沒有越出內(nèi)核地址空間范圍,說明指針指向的內(nèi)存受到某種限制;
- 除此以外就是指針越出內(nèi)核地址空間范圍;
以上日志中的出錯地址 ffffffffaa6f0210 在內(nèi)核地址空間范圍,可以判斷為試圖篡改受限制內(nèi)存導(dǎo)致報錯;例如給一個聲明為 const 的變量賦值就會出現(xiàn)這種錯誤。
3. 引起 Oops 的內(nèi)核代碼位置初步分析
接下來看看日志中的 RIP 信息:
RIP: 0010:init_lsm_hooks+0x1cd/0x1f0 [xxx_security]
通常,我們在這種情況下從 Oops 收集的最有用的信息是 EIP 和錯誤的調(diào)用地址。對于 64bit 用戶來說,你可能需要查看 RIP, EIP/RIP 通常標識了問題發(fā)生的現(xiàn)場。在這個例子中我們可以看到 Oops 中 RIP 是在 init_lsm_hooks 的 0x1cd 字節(jié)的位置,而 init_lsm_hooks 占用 0x1f0 字節(jié)的大小,它給出了一個很有用的信息,告訴我們?nèi)ツ睦飳ふ页鲥e的代碼。下文會詳細介紹準確找出出錯代碼的過程。
而 Oops 日志中的 Code 行,會把導(dǎo)致 Oops 的第一條指令,也就是 RIP 的值的第一個字節(jié),用尖括號 <> 括起來。如:
Code: 00 00 48 8b 41 10 48 89 71 20 48 8b 10 48 85 d2 75 05 eb 25 48 89 c2 48 8b 02 48 85 c0 75 f5 48 c7 01 00 00 00 00 48 89 51 08 <48> 89 0a 48 83 c1 28 48 39 f9 75 cc 31 c0 c3 48 c7 01 00 00 00 00
<48> 即是。
這種 Code 行,在沒有自己編譯的 vmlinux 時又想定位出錯的代碼行,可以利用。但是要注意 cpu 的架構(gòu)問題,有些架構(gòu)的(例如常見的x86)指令是不等長的;
4. Oops Error Code 解析
再看看 Error Code 行:
Oops: 0003 [#1] SMP PTI
其中 0003 為 error code,當異常發(fā)生時,由硬件壓入棧中。可以通過這個看出 Oops 發(fā)生的大致原因。
對于 x86 架構(gòu)來說,error code 具體定義如下:
Page fault error code bits:
bit 0 == 0: no page found 1: protection fault
bit 1 == 0: read access 1: write access
bit 2 == 0: kernel-mode access 1: user-mode access
bit 3 == 1: use of reserved bit detected
bit 4 == 1: fault was an instruction fetch
常用低 3 位,具體含義為:
- 如果第 0 位被清 0,則異常是由一個不存在的頁所引起的;否則是由無效的訪問權(quán)限引起的。
- 如果第 1 位被清 0,則異常由讀訪問或者執(zhí)行訪問所引起;否則異常由寫訪問引起。
- 如果第 2 位被清 0,則異常發(fā)生在內(nèi)核態(tài);否則異常發(fā)生在用戶態(tài)。
所以,上述樣例中的 error code 0003 表示:
- 異常由無效的訪問權(quán)限引起,也就是說被訪問的地址存在對應(yīng)的物理頁,但是沒有權(quán)限訪問;
- 異常由寫操作引起;
- 異常發(fā)生在內(nèi)核態(tài),總結(jié)來說就是該異常由于在內(nèi)核態(tài)對沒有寫權(quán)限的地址進行寫操作時產(chǎn)生;
而 Oops 中的 [#1] 表示發(fā)生 Crash 次數(shù)。
5. 通過 gdb 定位出錯的內(nèi)核代碼文件和行號
接下來再通過 gdb 調(diào)試做進一步動態(tài)的分析,以便確定出錯的代碼文件和行號。
首先需要通過 add-symbol-file 命令將符號文件添加到調(diào)試器。符號文件即通過 insmod 命令插入 LSM 模塊出錯時用的內(nèi)核模塊的 .o 文件,即日志中出現(xiàn)的 xxx_security。
insmod 命令:
[ 415.746851] CPU: 0 PID: 8366 Comm: insmod Tainted: G OE 4.19.82-wwh #1
xxx_security 模塊:
[ 415.746883] init_module+0x34/0xc0 [xxx_security]
add-symbol-file 命令有兩個參數(shù):
- 第一個參數(shù)是 xxx_security.o
- 第二個參數(shù)是該模塊代碼正文區(qū)域的地址
該地址通過如下方式獲得:
$ sudo cat /sys/module/xxx_security/sections/.text
0xffffffffc0ada000
接著通過 gdb 來調(diào)試 xxx_security.ko:
(gdb) add-symbol-file xxx_security.o 0xffffffffc0ada000
add symbol table from file "xxx_security.o" at
.text_addr = 0xffffffffc0ada000
(y or n) yReading symbols from xxx_security.o...done.
上文已經(jīng)根據(jù) RIP 行可以得到報錯函數(shù)名以及偏移:
RIP: 0010:init_lsm_hooks+0x1cd/0x1f0 [xxx_security]
接著就是反匯編 init_lsm_hooks 函數(shù)如下:
(gdb) disassemble init_lsm_hooks
Dump of assembler code for function init_lsm_hooks:
Address range 0x150 to 0x33c:
0x0000000000000150 <+0>: callq 0x155 <init_lsm_hooks+5>
0x0000000000000312 <+450>: movq $0x0,(%rcx)
0x0000000000000319 <+457>: mov %rdx,0x8(%rcx)
0x000000000000031d <+461>: mov %rcx,(%rdx)
0x0000000000000320 <+464>: add $0x28,%rcx
0x0000000000000324 <+468>: cmp %rdi,%rcx
從上可以看出 init_lsm_hooks 函數(shù)的起始地址是 0x150,出錯所在的偏移是 0x1cd。
0x150+0x1cd=0x31d,那么如何通過這個地址對應(yīng)到 .c 中具體某一行呢?
(gdb) l *0x000000000000031d
0x31d is in init_lsm_hooks (./include/linux/compiler.h:220).
215 {
216 switch (size) {
217 case 1: *(volatile __u8 *)p = *(__u8 *)res; break;
218 case 2: *(volatile __u16 *)p = *(__u16 *)res; break;
219 case 4: *(volatile __u32 *)p = *(__u32 *)res; break;
220 case 8: *(volatile __u64 *)p = *(__u64 *)res; break;
221 default:
222 barrier();
223 __builtin_memcpy((void *)p, (const void *)res, size);
224 barrier();
從調(diào)試信息可以看出,出錯位置為:
./include/linux/compiler.h:220 case 8: *(volatile __u64 *)p = *(__u64 *)res; break`
打開該文件以及所在行可以確認:
$ vim include/linux/compiler.h +220
static __always_inline void __write_once_size(volatile void *p, void *res, int size)
{ switch (size) {
case 1: *(volatile __u8 *)p = *(__u8 *)res; break;
case 2: *(volatile __u16 *)p = *(__u16 *)res; break;
case 4: *(volatile __u32 *)p = *(__u32 *)res; break;
case 8: *(volatile __u64 *)p = *(__u64 *)res; break;
default:
barrier(); __builtin_memcpy((void *)p, (const void *)res, size);
barrier(); }}
當然,也可以通過 set listsize 20 之后看到具體的 __write_once_size 函數(shù)。該函數(shù)為 __always_inline 的,編譯時被嵌入到了 init_lsm_hooks 中。
以上過程也可以通過其他方式快速定位,可進一步閱讀:
- 診 & 斷:如何快速定位 Linux Panic 出錯的代碼行
- Linux Lab: 消除 qemu/raspi3 啟動過程的一堆警告
6. 出錯代碼詳細分析
上述 __write_once_size 接口是內(nèi)核中所有雙鏈表操作最終進入的函數(shù)。
在出錯的 init_lsm_hooks 函數(shù)中通過調(diào)用 hlist_add_tail_rcu 調(diào)用 WRITE_ONCE, 從而進入了 __write_once_size, 接下來看看這個接口的實現(xiàn):
#define WRITE_ONCE(x, val)
({ union { typeof(x) __val; char __c[1]; } __u =
{ .__val = (__force typeof(x)) (val) };
__write_once_size(&(x), __u.__c, sizeof(x)); __u.__val; })
WRITE_ONCE() 用于向變量對應(yīng)的內(nèi)存寫入值。x 對應(yīng)變量,val 對應(yīng)寫入的值。函數(shù)首先定義并初始化一個聯(lián)合體,使 __u.__val 的值為參數(shù) val, 然后調(diào)用 __write_once_size() 函數(shù)將數(shù)據(jù)寫入到內(nèi)存中。
此時變量 x 所在地址 &(x) 為需要寫入 val 的內(nèi)存虛擬地址 ffffffffaa6f0210, 可以通過 Crash 查看該虛擬地址各項屬性:
crash> vtop ffffffffaa6f0210
VIRTUAL PHYSICAL
ffffffffaa6f0210 3faf0210
PGD DIRECTORY: ffffffffaa80a000
PAGE DIRECTORY: 3fc0e067
PUD: 3fc0eff0 => 3fc0f063
PMD: 3fc0fa98 => 34367063
PTE: 34367780 => 800000003faf0061
PAGE: 3faf0000
PTE PHYSICAL FLAGS
800000003faf0061 3faf0000 (PRESENT|ACCESSED|DIRTY|NX)
PAGE PHYSICAL MAppING INDEX CNT FLAGS
ffffe02200febc00 3faf0000 0 0 1 ffffc000000800 reserved
可以看到該虛擬地址所在 PAGE 的 PTE 頁表項內(nèi)容,從 FLAGS 可以看出該 PAGE 不具備 R/W 屬性。因而可以確認,是由于嘗試向只讀地址空間寫數(shù)據(jù),從而導(dǎo)致內(nèi)核報錯。
這里稍作補充,上述 PUD/PMD/PTE 信息跟第一節(jié)日志中的第二行信息一致:
PGD 3fc0e067 P4D 3fc0e067 PUD 3fc0f063 PMD 34367063 PTE 800000003faf0061
知道是在調(diào)用 hlist_add_tail_rcu 接口出錯后也可以通過查看符號表得到所要寫入的地址空間是否是只讀的,在內(nèi)核的安全模塊中默認配置了 apparmor,當以 ko 的模式掛載外部的 LSM 鉤子時,都需要以雙鏈表方式掛在 apparmor 的 LSM 尾部:
$ sudo cat /proc/kallsyms | grep apparmor_hooks
ffffffffaa6eff40 r apparmor_hooks
可以看出此時 apparmor_hooks 所在內(nèi)存空間是只讀的。
7. 問題解決方案
進一步查看到 apparmor_hooks 的定義如下:
static struct security_hook_list apparmor_hooks[] __lsm_ro_after_init
__lsm_ro_after_init 表示 LSM 架構(gòu)在完成初始化后所在的內(nèi)存空間會成只讀。
因此我們提出兩種解決方案:
- 解決方案1: 將 __lsm_ro_after_init 標志位去掉,讓該 PAGE 可讀可寫,這樣修改雖然可行,但不確定是否會引入其他問題。
- 解決方案2: 可以通過內(nèi)核配置添加 SECURITY_WRITABLE_HOOKS 選項,同樣會將該 PAGE 配置成可讀可寫。