感染ko:在linux内核模块中植入代码

本是想将目标代码运行在一个已有root shell并支持insmod的设备内核态中,具体底座为 arm:linux3.18.71,并不是要搞Rootkit。但直接去编译对应内核版本的ko(内核模块)在insmod时会发生崩溃,而他自带的ko却可以正常insmod,后排错一天无果。故不得已才想到可以将目标代码揉进他自带的ko中,这个行为无意间就和Rootkit保持一致了,即感染ko。说酷炫点是感染,其实就是patch,本以为和patch用户态ELF一样简单,在ko的.init.text段糊上目标代码就完了。但没想到ko外部符号重定位的实现方法,居然是内核直接根据符号信息修改ko代码本身。所以我糊上的代码,就有可能由于ko原有的重定位信息被内核改的乱七八糟。最后我通过直接删除ko中重定位section的方法,将代码固定在了ko中并可以被稳定执行。

具体起因

因为知道目标内核版本为linux3.18.71,所以按照我媳妇的博客:交叉编译arm linux内核模块,直接编译对应ko。需要的.config文件可以在目标的proc文件系统中获得:

  adb pull /proc/config.gz
  zcat config.gz > ./config
  head ./config
#
# Automatically generated file; DO NOT EDIT.
# Linux/arm 3.18.71 Kernel Configuration
#
CONFIG_ARM=y
# CONFIG_EARLY_IOREMAP is not set
# CONFIG_FIX_EARLYCON_MEM is not set
CONFIG_SYS_SUPPORTS_APM_EMULATION=y
CONFIG_HAVE_PROC_CPU=y
CONFIG_STACKTRACE_SUPPORT=y

即linux内核其实会把编译时的配置信息保留下来,并且可以在运行的系统中提取出来。所以即使不是你自己编的内核,你一样可以知道他编译时的配置选项。通过不断的调整menuconfig适配一些小问题,最终可以编出来一个正常通过vermagic校验的ko,但是一insmod就Segmentation fault:

  strings ./hello.ko | grep vermagic
vermagic=3.18.71 preempt mod_unload ARMv7 p2v8
__UNIQUE_ID_vermagic0
__UNIQUE_ID_vermagic0

  adb push hello.ko /tmp
hello.ko: 1 file pushed. 2.6 MB/s (31312 bytes in 0.012s)

  adb shell
/ # uname -a
Linux 3.18.71 #1 PREEMPT Fri Apr 3 17:18:53 CST 2020 armv7l GNU/Linux

/ # insmod /tmp/hello.ko
Segmentation fault

查看dmesg信息,发现其死在module_put+0x2c,有一个非法访存,目标地址为00000004,这显然是有东西为null:

/ # dmesg
...
[11749.590315] Unable to handle kernel NULL pointer dereference at virtual address 00000004
[11749.590331] pgd = c12e4000
[11749.590339] [00000004] *pgd=00000000
[11749.590353] Internal error: Oops: 5 [#1] PREEMPT ARM
[11749.590361] Modules linked in: hello
[11749.590378] CPU: 0 PID: 1815 Comm: insmod Tainted: G        W      3.18.71 #1
[11749.590389] task: c124a680 ti: c124e000 task.ti: c124e000
[11749.590403] PC is at module_put+0x2c/0x100
[11749.590414] LR is at load_module+0x159c/0x1ab4
[11749.590426] pc : [<c007451c>]    lr : [<c0076e5c>]    psr: a00e0113
[11749.590426] sp : c124fe80  ip : c0ea6fa0  fp : bf000070
[11749.590438] r10: c58c9aa4  r9 : 00000001  r8 : c58c9a80
[11749.590447] r7 : bf0000ac  r6 : bf000064  r5 : c58c9180  r4 : c124ff54
[11749.590457] r3 : c124e000  r2 : 00000000  r1 : 00000000  r0 : bf000064
...

经过逆向分析以及对比源码,这里是:

https://elixir.bootlin.com/linux/v3.18.71/source/kernel/module.c#L971

void module_put(struct module *module)
{
	if (module) {
		preempt_disable();
		smp_wmb(); /* see comment in module_refcount */
		__this_cpu_inc(module->refptr->decs);

		trace_module_put(module, _RET_IP_);
		preempt_enable();
	}
}
EXPORT_SYMBOL(module_put);

所以应该就是module->refptr->decs里refptr没初始化,但经过一整天的分析排错,手段包括不限于:

  • 更换linux内核为android对应版本内核
  • 更换gcc为目标内核编译的版本gcc5.2.0
  • 打开/关闭一些可能有影响的内核编译选项,如trace
  • 对照自带正常ko的ELF与自己编译出来ELF并删除某些段进行测试

但都无果,实在是没分析出原因,当然这也是对linux内核本身并不熟悉的结果。另外网友的信息里也只有一个树莓派是这么死的,并且没有人知道原因:

由于这个需求只是我整个目标的一小步,所以决定暂时放弃这种方案,尝试直接patch现有ko,以执行我的目标代码。

内核函数调用

在研究将目标代码塞进ko之前,需要回答一个问题,我们塞的代码如何进行内核的函数调用?正常编译内核模块时,不用考虑这个问题,直接使用什么printk就行了,一切的解析工作交给内核。但如果是patch现成的内核模块,该如何是好?

首先可以想到的是,应该可以直接调用内核模块里本身使用的外部函数,如用户态ELF的plt。但这种该方法显然限制了函数调用的范围,即只能用ko中本身使用的内核函数,而不能调用任意的内核函数。所以这种方案写的代码就没有直接写ko源码那么随意了,代码量级已然类似shellcode,如果彻底抛弃使用ko中已有信息,那么我要的目标代码就是符合了shellcode的性质。

所以,内核shellcode如何调用内核函数?幸运的是,这个内核没有开地址随机化,所以直接使用绝对地址调用就好了!如直接使用0xc097636c调printk:

# cat /proc/kallsyms  | grep "printk"
...
c097636c T printk
...

不过还是可以用c语言级别写shellcode,如:

寻找桩ko

我希望找到一个比较合适的ko,以用来搭载我们的目标代码,在Rootkit这种病毒范畴的语言体系中,他叫宿主。用另一种语言体系表达,我觉得也可以叫做“桩”,可以理解为,他目前的功能还没实现,桩里的东西将来会被换成真正的功能。

不同语境下,桩的具体含义,用法也不尽相同。但无论桩本身处在一个什么样的生命周期中,他总是一个承上启下角色,可能是时间上,也可能是功能上。这也很符合桩的本意,先立那一个桩,用来占位,以便将来使用。所以桩也绝不可能被单独理解,他就是一个过渡,想要理解桩,必然要理解他承了谁的上,又启了谁的下。比如这个桩ko,他承的上就是我目标代码载体,启的下就是运行在内核态。那什么样的ko比较合适呢?答:可insmod成功的,容易patch的,存在.init.text段可以在insmod就执行的。最终找到一个我感觉挺合适的: br_netfilter.ko

# ls -al
-rw-r--r--    1 root     root         15048 Apr  3  2020 br_netfilter.ko
# insmod br_netfilter.ko
# lsmod
br_netfilter 9773 0 - Live 0xbf000000

直接patch

现在称呼这个合适的ko为stub.ko,其中的.init.text段在这里:

.init.text:00001970             EXPORT init_module
.init.text:00001970             init_module   
.init.text:00001970 
.init.text:00001970 10 40 2D E9                 PUSH            {R4,LR}
.init.text:00001974 07 10 A0 E3                 MOV             R1, #7
.init.text:00001978 54 00 9F E5                 LDR             R0, =br_nf_ops
.init.text:0000197C C9 01 00 EB                 BL              nf_register_hooks
.init.text:00001980 00 00 50 E3                 CMP             R0, #0
.init.text:00001984 10 80 BD B8                 POPLT           {R4,PC}
.init.text:00001988 48 20 9F E5                 LDR             R2, =brnf_table
.init.text:0000198C 48 10 9F E5                 LDR             R1, =aNetBridge ; "net/bridge"
.init.text:00001990 48 00 9F E5                 LDR             R0, =init_net
.init.text:00001994 BF 01 00 EB                 BL              register_net_sysctl
.init.text:00001998 44 30 9F E5                 LDR             R3, =brnf_sysctl_header
.init.text:0000199C 00 00 50 E3                 CMP             R0, #0
.init.text:000019A0 00 00 83 E5                 STR             R0, [R3]
.init.text:000019A4 06 00 00 1A                 BNE             loc_19C4
.init.text:000019A8 38 00 9F E5                 LDR             R0, =unk_1C47
.init.text:000019AC CC 01 00 EB                 BL              printk
.init.text:000019B0 07 10 A0 E3                 MOV             R1, #7
.init.text:000019B4 18 00 9F E5                 LDR             R0, =br_nf_ops
.init.text:000019B8 C4 01 00 EB                 BL              nf_unregister_hooks
.init.text:000019BC 0B 00 E0 E3                 MOV             R0, #0xFFFFFFF4
.init.text:000019C0 10 80 BD E8                 POP             {R4,PC}

不过如果我们想patch这个ELF本身,不能用0x1970这个地址,因为这是IDA分析的ko加载进内存后的地址,并且可见内核模块本身都是可重定位的,所以这里IDA分析的加载基址是0。可以通过readelf等工具找到.init.text段,文件中的偏移为0x19c8:

  arm-linux-gnueabi-readelf -S ./br_netfilter.ko | grep init
  [ 4] .init.text        PROGBITS        00000000 0019c8 000080 00  AX  0   0  4
  [ 5] .rel.init.text    REL             00000000 0032a8 000060 08   I 35   4  4
  [16] .ARM.extab.init.t PROGBITS        00000000 001b88 000000 00   A  0   0  1
  [17] .ARM.exidx.init.t ARM_EXIDX       00000000 001b88 000008 00  AL  4   0  4

也可通过hexdump等工具检查一下,看起来是跟IDA分析的0x1970一样:

  hexdump -C -s 0x19c8 -n 32 ./br_netfilter.ko
000019c8  10 40 2d e9 07 10 a0 e3  54 00 9f e5 fe ff ff eb  |.@-.....T.......|
000019d8  00 00 50 e3 10 80 bd b8  48 20 9f e5 48 10 9f e5  |..P.....H ..H...|

对patch工具的选择,IDA不便于频繁修改并编译shellcode的操作,pwntools的ELF对ko支持不完善,地址解析存在问题。所以最后采用了最暴力的方式,直接读写ko文件。这里我们尝试写一个printk的打印,printk的地址为0xC097636C:

from pwn import *
context(arch='arm',endian='little')

shellcode = asm('''
    push {lr}
    adr r0, hello
    ldr r3, =0xC097636C
    blx r3
    mov r0, 0
    pop {pc}
hello:  
    .ascii "hello xuanxuan" 
    .byte 0xa
''')

print(disasm(shellcode))

stub = open('./stub.ko','rb').read()
exp  = stub[:0x19c8]+shellcode+stub[0x19c8+len(shellcode):]
open('./exp.ko','wb').write(exp)

生成的exp.ko即patch后的ko,可以看到汇编对应的机器码:

  python3 exp.py
   0:   e52de004        push    {lr}            ; (str lr, [sp, #-4]!)
   4:   e28f000c        add     r0, pc, #12
   8:   e59f3018        ldr     r3, [pc, #24]   ; 0x28
   c:   e12fff33        blx     r3
  10:   e3a00000        mov     r0, #0
  14:   e49df004        pop     {pc}            ; (ldr pc, [sp], #4)
  18:   6c6c6568        cfstr64vs       mvdx6, [ip], #-416      ; 0xfffffe60
  1c:   7578206f        ldrbvc  r2, [r8, #-111]!        ; 0xffffff91
  20:   75786e61        ldrbvc  r6, [r8, #-3681]!       ; 0xfffff19f
  24:   000a6e61        andeq   r6, sl, r1, ror #28
  28:   c097636c        addsgt  r6, r7, ip, ror #6

尝试insmod还是会有Segmentation fault错误,IDA打开我们patch后的ko:

.init.text:00001970 04 E0 2D E5                 PUSH            {LR}
.init.text:00001974 0C 00 8F E2                 ADR             R0, dword_1988
.init.text:00001978 18 30 9F E5                 LDR             R3, =0xC097636C
.init.text:0000197C FE 00 30 E1                 LDRSH           R0, [R0,-LR]!
.init.text:00001980 00 00 A0 E3                 MOV             R0, #0
.init.text:00001984 04 F0 9D E4                 POP             {PC}

发现第四句居然和我们patch的不一样,我们写进去的是blx r3,现在却是一个奇怪的东西,机器码也对不上。然后发现stub.ko本身的这句,和IDA解析后的也对不上:

image

- ELF:fe ff ff eb
- IDA:C9 01 00 eb

外部符号解析

为什么会出现这么奇怪的现象呢?不难发现这第四句其实比较特殊,他本来是一句调用linux内核函数nf_register_hooks的指令,对于ko本身,这是调用了外部函数。用户态的ELF调用外部函数的大概过程我们非常熟悉,PLT+GOT表,但是内核是怎么实现的呢?这个ko里看起来也没有PLT。答案就在ko中的与重定位相关的段中,段名可以由rel过滤出来:

  arm-linux-gnueabi-readelf -S ./stub.ko | grep rel
  [ 3] .rel.text         REL             00000000 003020 000288 08   I 35   2  4
  [ 5] .rel.init.text    REL             00000000 0032a8 000060 08   I 35   4  4
  [ 7] .rel.exit.text    REL             00000000 003308 000020 08   I 35   6  4
  [ 9] .rel__ksymtab_gpl REL             00000000 003328 000010 08   I 35   8  4
  [12] .rel.ARM.exidx    REL             00000000 003338 000088 08   I 35  11  4
  [15] .rel__bug_table   REL             00000000 0033c0 000010 08   I 35  14  4
  [18] .rel.ARM.exidx.in REL             00000000 0033d0 000010 08   I 35  17  4
  [21] .rel.ARM.exidx.ex REL             00000000 0033e0 000010 08   I 35  20  4
  [26] .rel.data         REL             00000000 0033f0 000090 08   I 35  25  4
  [28] .rel.data..read_m REL             00000000 003480 000070 08   I 35  27  4
  [30] .rel.gnu.linkonce REL             00000000 0034f0 000010 08   I 35  29  4

使用readelf的-r参数可读出重定位段中的具体内容,这里我们关注.init.text的重定位,即.rel.init.text:

  arm-linux-gnueabi-readelf -r ./stub.ko

Relocation section '.rel.init.text' at offset 0x32a8 contains 12 entries:
 Offset     Info    Type            Sym.Value  Sym. Name
0000000c  0000661c R_ARM_CALL        00000000   nf_register_hooks

可以看到这项的确反映了.init.text段偏移0xc处,有一个重定位项,类型是ARM的CALL,值为0,Info为符号的标识,即nf_register_hooks与0000661c是对应的。值为0显然不是运行时的值,显然能猜出来,这个具体的值,应该由内核加载ko时填充,并完成对.init.text段偏移0xc处的代码修正。那运行时的值是啥呢?由于我不能直接对内核挂GDB调试,所以,我要制造一个崩溃测试一下:

from pwn import *
context(arch='arm',endian='little')

shellcode = asm('''
    mov r0, 0
    ldr r1, [r0]
''')

print(disasm(shellcode))

stub = open('./stub.ko','rb').read()
exp  = stub[:0x19c8]+shellcode+stub[0x19c8+len(shellcode):]
open('./exp.ko','wb').write(exp)

就是在.init.text段开头访存0地址,insmod进去,崩溃并查看dmesg:

  adb push exp.ko /tmp
exp.ko: 1 file pushed. 2.4 MB/s (15048 bytes in 0.006s)
  adb shell
/ # insmod /tmp/exp.ko
Segmentation fault

/ # lsmod
br_netfilter 13377 1 - Loading 0xbf000000

/ # dmesg
...
[   43.417303] Unable to handle kernel NULL pointer dereference at virtual address 00000000
[   43.417319] pgd = c1c18000
[   43.417326] [00000000] *pgd=00000000
[   43.417339] Internal error: Oops: 5 [#1] PREEMPT ARM
[   43.417347] Modules linked in: br_netfilter(+)
[   43.417363] CPU: 0 PID: 1385 Comm: insmod Not tainted 3.18.71 #1
[   43.417373] task: c5949080 ti: c1f84000 task.ti: c1f84000
[   43.417390] PC is at br_netfilter_init+0x4/0x80 [br_netfilter]
[   43.417403] LR is at do_one_initcall+0x1a8/0x1e4
[   43.417415] pc : [<bf004004>]    lr : [<c0008a78>]    psr: 600f0013
[   43.417415] sp : c1f85e18  ip : c1f85c8e  fp : bf001df4
[   43.417426] r10: c1ca9764  r9 : c0e0ce20  r8 : c0e0ce20
[   43.417435] r7 : 00000000  r6 : c0e04008  r5 : bf004000  r4 : c5988200
[   43.417444] r3 : 00000000  r2 : 600f0013  r1 : 000214c0  r0 : 00000000
...

这个崩溃信息可以看出:

  • 制造崩溃成功了,的确是因为访存0地址,引发崩溃的
  • IDA分析的运行地址(0x0000197C)和实际运行地址(0xbf004004)不一致
  • 这个运行地址,即使算上内核模块加载基址(0xbf000000)也不对,所以IDA分析错了

但是这个崩溃信息后续只打印了崩溃时的栈内存,而没有打印我们想关心的目标地址(0xbf00400c)处的内存,不过还好我们关心的目标是内核模块代码段,完整的崩溃现场虽然已经不在了,但是整个内核模块还驻留在内存里。这个系统支持/dev/kmem,可以用dd直接访问这个文件以读取内核空间的内存。值得注意的是,制造崩溃仍然是必要的,因为IDA分析的地址分析错了,我们无法基于lsmod给出的基址和IDA的结果找到真正.init.text的运行地址。另外因为dd参数的局限为十进制,先要转换一下地址:

>>> 0xbf004000
3204464640

然后用dd读取/dev/kmem这个文件,配合hexdump查看具体信息:

/ # dd if=/dev/kmem bs=1 count=32 skip=3204464640 | hexdump -C
00000000  00 00 a0 e3 00 10 90 e5  54 00 9f e5 69 aa 5f eb  |........T...i._.|
00000010  00 00 50 e3 10 80 bd b8  48 20 9f e5 48 10 9f e5  |..P.....H ..H...|
00000020
32+0 records in
32+0 records out
32 bytes (32B) copied, 0.002671 seconds, 11.7KB/s

所以实际这个bl nf_register_hooks的内存是:69 aa 5f eb,对比如下:

- ELFfe ff ff eb
- IDAc9 01 00 eb
- 实际:69 aa 5f eb

69 aa 5f eb 指令的解析方式为:

  • 操作码:eb:bl 跳转
  • 操作数:69 aa 5f :0x5faa69

arm的相对地址跳转是以4字节为单位的,并且当指令执行到0xbf00400c时,由于流水线,PC真正的值需要+8:

>>> hex(0x5faa69 * 4 + 0xbf004000 + 0xc + 0x8)
'0xc07ee9b8'

nf_register_hooks函数实际的地址的确为0xc07ee9b8,计算正确!

/ # cat /proc/kallsyms | grep nf_register_hooks
...
c07ee9b8 T nf_register_hooks
...

所以可见,内核修正ko调用外部函数的方法居然是直接修改代码段。故patch时,需要避开所有存在外部符号调用的内存位置,这部分内存会由于重定位信息的存在,使得自己被内核修改。

问题解决

所以解决这个问题的本质就是避开将要被重定位的位置,所以其实可以找.text中一段连续没有外部函数调用的内存,如:IDA解析的0x654到0x7C8这段内存,但显然这样限制很大,可能空间不够通用。那干脆釜底抽薪,把.text段的重定位表直接删了,使用objcopy的-R即可删section:

  arm-linux-gnueabi-objcopy -R ".rel.text" stub.ko stub.ko

然后大体思路就是:

  • 在.text开头(ELF中0x58偏移)就填充目标代码(shellcode)
  • 然后直接在.init.text开头就跳转到.text开头

所以这里要计算一下跳转的地址:

  • 根据刚才的崩溃结果:.init.text 开头为0xbf004000
  • 通过调试:.text 开头确实为lsmod显示的0xbf000000

所以从 0xbf004000 跳到 0xbf000000 的地址差计算为:

>>> hex(0xbf000000 - (0xbf004000 + 0x8))
'-0x4008'

pwntools应该是不支持arm的相对地址跳转的汇编生成,所以需要手动生成,arm相对地址跳转的b指令的机器码为0xea,地址单位为4字节,故跳转指令如下:

>>> from pwn import *
>>> hex(int(-0x4008/4))
'-0x1002'
>>> jmp = pack(-0x1002, 24, endian='little', sign=True) + b'\xea'
>>> jmp.hex()
'feefffea'

另外因为破坏了整个.text段,所以在rmmod也会出问题,所以也patch退出为直接返回,最终如下:

from pwn import *
context(arch='arm',endian='little')

jmp = pack(-0x1002, 24, endian='little', sign=True) + b'\xea'

bye = asm('''
    bx lr           
''')

shellcode = asm('''
    push {lr}
    adr r0, hello
    ldr r3, =0xC097636C
    blx r3
    mov r0, 0
    pop {pc}
hello:  
    .ascii "hello xuanxuan" 
    .byte 0xa
''')

print(disasm(shellcode))

stub = open('./stub.ko','rb').read()
stub = stub[:0x19c8]+jmp+stub[0x19c8+len(jmp):]
stub = stub[:0x1a48]+bye+stub[0x1a48+len(bye):]
exp  = stub[:0x58]+shellcode+stub[0x58+len(shellcode):]
open('./exp.ko','wb').write(exp)

成功执行,并抗住了多次使用:

# insmod exp.ko && rmmod br_netfilter
# insmod exp.ko && rmmod br_netfilter
# insmod exp.ko && rmmod br_netfilter
# insmod exp.ko && rmmod br_netfilter
# insmod exp.ko && rmmod br_netfilter
# dmesg | tail -n 5
[   41.759077] hello xuanxuan
[   42.443308] hello xuanxuan
[   42.850574] hello xuanxuan
[   43.243688] hello xuanxuan
[   43.603778] hello xuanxuan

总结

  • 内核加载ko机制还是很复杂的,实际内存中.init.text居然和.text没有连续,以至于IDA的地址都没分析对
  • 内核模块执行时虽然处于内核态,但其崩溃未必导致整个系统彻底崩溃,因为内核也能捕获自己的异常并处理
  • 最开始其实我的方案是添加一个了section并蹦过去,但写笔记的过程发现可以更简单,即直接删除.rel
  • 过程中其实绕了好多弯路,当然也可能现在写下来的还是弯路,本质是因为对内核加载ko的机制不了解
  • 后来发现 Linux Rootkit 系列五:感染系统关键内核模块实现持久化 直接劫持 init_module 符号,可一步到位

这个系列写的很好:https://github.com/NoviceLive/research-rootkit

其他参考: