本是想将目标代码运行在一个已有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解析后的也对不上:
- 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,对比如下:
- ELF:fe ff ff eb
- IDA:c9 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
- Linux Rootkit 系列一:LKM的基础编写及隐藏
- Linux Rootkit 系列二:基于修改 sys_call_table 的系统调用挂钩
- Linux Rootkit 系列三:实例详解 Rootkit 必备的基本功能
- Linux Rootkit 系列四:对于系统调用挂钩方法的补充
- Linux Rootkit 系列五:感染系统关键内核模块实现持久化
- Linux Rootkit 系列六:基于修改派遣例程的系统调用挂钩
其他参考: