内核模块加载一共有两个syscall入口,分别为:
* sys_init_module: 用户态传入包含模块代码的内存地址
* sys_finit_module: 用户态传入模块二进制的文件句柄fd
除了以上区别外,sys_fini_module接口相对更加灵活,其可以传入额外的flag来忽略模块版本信息和CRC以强制加载。二者最终都直接 return load_module函数,真正的模块加载是在load_module函数中完成的,其声明如下:
其中:
* info记录了模块的二进制中的所有相关信息
* uargs是用户态传入的参数
* flags是用户态传入的flags
后面的部分全部是load_module的代码流程分析,为了便于区分,后续提及到的
模块ELF文件: 指的是ELF二进制文件
模块内存ELF文件: 指的是加载到内存中的ELF二进制文件
在模块加载中,info结构体记录的是模块的二进制信息,而mod记录的则是模块的内存信息。
elf_header_check检查ELF基本格式是否正确,包括:
* 判断文件大小是否小于一个标准ELF文件头,若小于则代表ELF文件格式错误
* 检查ELF文件头格式,文件必须以"\177ELF"开头,文件类型必须为Relocatable
* 检查平台相关的文件头检查,以及确定节区头部表表项大小是否和当前内核Elf_Shdr类型大小相同
* 检查整个节区头部表是否在可执行文件范围内
struct load_info info中记录的是模块内存二进制的头信息,setup_load_info函数主要负责初始化info中的信息,包括:
* 根据ELF文件头,确定节区头部表首地址,记录到info->sechdrs
* 根据ELF文件头,确定节区头部表字符串表首地址,记录到info->secstrings
* 遍历节区头部表,确定".modinfo"节区索引,记录到info->index.info
* 根据.modinfo段的"name=xxx"字段确定模块名,记录到info->name
* 遍历节区头部表,确定属性为SHT_SYMTAB的节区索引,记录到info->index.sym(目标文件正常应该只有一个SHT_SYMTAB节区,为静态符号表.symtab节区)
* 根据静态链接符号表节区索引(info->index.sym),确定静态链接字符串表节区索引,记录到info->index.str
* 遍历节区头部表,查找名为".gnu.linkonce.this_module"的节区索引,记录到info->index.mod
* 根据info->index.mod,确定模块二进制中 __this_module变量在内核内存二进制中的地址,记录到info->mod
* 若.modinfo段没有定义"name=xxx",则设置info->name为 info->mod->name
* 遍历节区头部表,查找名称为"__versions"的节区,记录到 info->index.vers, 若模块加载参数指定了MODULE_INIT_IGNORE_MODVERSIONS,那么vers设置为0
* 遍历节区头部表,查找名为".data..percpu"的节区,记录到info->index.pcpu
这里不列举具体代码,只说明struct load_info结构体各个字段的作用:
这里只是一个简单的模块名字符串过滤,在黑名单中的模块则不会被加载,模块名来源自info->name,实际上是来自于模块的二进制(此功能对安全并没有本质帮助,其设计应该也不是为了安全的目的,而是为了内核启动调试使用的)
模块签名流程可简化为下图:
module_sign_check函数:
1) 先检查ko是否以字符串"~Module signature appended~"结尾,若非此结尾,则代表此模块没有签名
2) 从模块尾部提取 struct module_signature结构体,其中的sig_len是在module_signature之前的pkcs7签名数据长度
3) 最终校验的部分是从模块头开始,到raw_pkcs7 signature之前(不包括)的所有数据内容
模块签名若校验失败,是否直接报错取决于CONFIG_MODULE_SIG_FORCE是否开启,若开启则直接报错,否则只记录(后面标记内核为tainted,见10)
此函数主要负责三步操作:
1) 修正模块内存二进制中所有文件中存在的段(非SHT_NOBITS)的Elf_Shdr[x].sh_addr指针,让节区头部表能真正指向到内存中此节区的内存.
2) 标记__versions段和.modinfo段不载入内存
3) 若未开启CONFIG_MODULE_UNLOAD(模块卸载功能),则.exit段也设置不载入内存,此时内核模块无法卸载.
正常dll/exe文件中,程序头部表中记录某个节区是否载入内存,但由于模块是目标文件,其没有程序头部表,故这里实际上是修改了节区头部表的SHF_ALLOC来标记此节区后续是否会加载到内存的.
此函数负责检查当前内核module_layout符号(函数)的CRC和当前要加载模块中记录的module_layout函数的crc是否匹配:
* 若内核或模块中未找到module_layout符号的CRC则报warning
* 若模块中没有__versions字段,则:
- 若内核开启了CONFIG_MODULE_FORCE_LOAD,则只报warning
- 若内核未开启CONFIG_MODULE_FORCE_LOAD,则error
模块指定加载参数MODULE_INIT_IGNORE_MODVERSIONS也属于此种情况
* 若二者CRC都存在,但匹配失败,则报error
module_layout是内核中的一个空函数,其参数是内核模块加载时需要和内核完全匹配的结构体,定义如下:
由于CRC计算时参数类型是完全展开参与运算的,若CRC匹配则可以代表函数的参数的类型,以及参数展开定义是匹配的,若module_layout的CRC匹配,则可以说明当前内核对其参数中结构体的定义,和模块编译环境的内核对这些结构体的定义相同,这样模块加载时这些结构体的解引用就不会出现问题.
在模块编译期间,Stage2 modpost程序会对读入的每个单/复合目标文件(%.o)都增加未定义符号"module_layout",后面modpost再未定义符号决议时就会找到当前内核中module_layout函数的CRC(从vmlinux的符号表中获取),并将其记录到当前模块的未定义符号"module_layout"中,这会导致后续module_layout的CRC被输出到模块的导出表对应的CRC表(____versions数组,所有未定义符号的CRC都会输出到这里)中,因此模块中就记录了其编译环境中module_layout的CRC,也就是相关结构体的匹配CRC.
而内核模块加载时会比较模块中module_layout函数的CRC和当前内核module_layout函数的CRC是否匹配(后者存储在内核的__kcrctab数组中,通过find_symbol函数可查到),如果匹配则代表模块可加载到当前内核,否则模块不应该被加载到当前内核.
layout_and_allocate函数的作用是:检查modinfo中各个字段是否正确,根据配置处理长跳转(plt),计算并在虚拟内存范围[module_alloc_base,module_alloc_end]内分配真正给模块使用的内存(默认是vmalloc分配),在计算的过程中会分别得出core_layout/init_layout中的RO/RX/RW/ro_after_init段,并生成core symbols的符号表,最终返回一个struct module结构体,这和info.mod中的不同,后者是文件中的this_module,前者返回是最终模块内存中的this_module,代码如下:
其中的检查内容包括:
module_frob_arch_sections函数本身是个weak函数,若平台没有相关实现的话此函数为空,在arm64平台是有相关实现的:
此函数实际上负责为模块(目标文件)计算生成plt/init.plt段中需要有多少个元素,结果保存到 mod->arch.core/init中(每个段多预留了一个位置)
1) 此函数先确定模块中是否拥有.plt和.init.plt节区,若这两个节区不存在则直接报错.
在模块链接时,若开启了CONFIG_ARM64_MODULE_PLTS选项,那么链接脚本会增加module.lds,其内容是生成三个空的节区:
而当前函数也是在开启CONFIG_ARM64_MODULE_PLTS才存在的,故当前代码运行时.plt和.init.plt节区应该默认存在(目标文件本来没有这两个节区,需要通过脚本手动构建). 链接时生成的.plt和.init.plt节区是没有任何内容的两个空节区,因为其作用只是在ELF文件中提供两个节区头部表项来记录动态生成的.plt/.init.plt信息,所以其节区内容是空,在二进制文件中只保留节区头部表表项.
2) 然后此函数遍历模块内存二进制中所有类型为SHT_RELA的节区(对于目标文件,此类型节区保存的是静态链接的重定位信息,目标文件没有也不需要动态链接重定位信息,对于arm64 其重定位类型通常都只用SHT_RELA),对于所有重定位类型为JUMP26/CALL26的表项(是间接跳转的代码),若其跳转的目的地址和当前代码地址不在同一个段中,则记录为一个plt表项.
若当前代码在.init开头的段,那么就记录在init_plts表项中,否则记录在plts表项中
3) 最终将申请的plts/init_plts表项数目记录到mod->arch.core/init结构体中,这个结构体是用来计数的,后面用来记录已经使用了多少个plt表项.
4) 最终设置模块二进制.plt/.init.plt段的节区头部表设置为:
* pltsec->sh_type = SHT_NOBITS; //代表plt表本身不占用文件空间的意思
* pltsec->sh_flags = SHF_EXECINSTR | SHF_ALLOC; //plt节区是要分配到内存的,且是可执行的
* pltsec->sh_size = (core_plts + 1) * sizeof(struct plt_entry);//plt节区需要的模块内存布局空间,在计算内存布局(layout_section)时依赖此字段
为plt分配空间
这里使用模块内存节区头部表来记录plt信息的原因是因为后面内存分配的时候默认的操作就是扫描节区头部表,并根据是否为SHF_ALLOC来决定是否预留内存,SHF_EXECINSTR决定内存属性,这样可以原样利用内核此流程.
注:
若未开启内核地址随机化(CONFIG_RANDOMIZE_BASE),则代表模块4GB随机化(CONFIG_RANDOMIZE_MODULE_REGION_FULL)也没开启,那么.plt/.init.plt预留表项大小为1(就是附加的一个),因为此时CALL26/JUMP26在跳转范围内,不需要plt构造长跳转.
这里包括三个操作:
1) 去除模块内存二进制percpu段的SHF_ALLOC属性,percpu段后面会在内核重新分配内存
2) 添加".data..ro_after_init段属性SHF_RO_AFTER_INIT,这个属性应该是内核自己定义的,在ELF标准中应该没有,在内核中根据节区名来设置此段为ro_after_init
3) 添加"__jump_table"段属性SHF_RO_AFTER_INIT,同上.
此函数的主要作用是扫描模块二进制的所有节区,并根据节区的属性(如SHF_ALLOC代表分配到内存,SHF_EXECINSTR代表可执行,SHF_WRITE代表可写, SHF_RO_AFTER_INIT代表ro_after_init),将其归类到core_layout或init_layout的不同段中,这里最终只计算了归类后core_layout和init_layout中各个段的大小,并没有实际分配内存.
这里的内存布局并非最终的内存布局,代码/只读/ro_after_init段都已经是最终布局了,但后面在layout_symtab函数中(8.5)还会追加符号表相关的数据到core/init_layout的数据段,后者才最终确定core/init_layout的大小,这里只是计算节区的布局,并不是计算能最终的布局.
正常二进制程序的内存布局记录在其程序头部表中,而模块作为目标文件只有节区头部表,并没有程序头部表,这里的做法类似于动态的为目标文件构造一个程序头部表(但结构比程序头部表要简单的多),内核中使用struct module_layout结构体来代表模块的内存布局,对于Init和非init的代码/数据,分别有一个对应的struct module_layout core_layout和init_layout.
module_layout以偏移的形式记录模块的代码和数据的布局,实际上里面除了基地址(base)外,就包括4个偏移,基于此4个偏移,内核模块中可以有(且只有)4个属性的段,此4个偏移和4个段分别为:
* .text_size: [0, text_size]都为代码段
* .ro_size: [text_size, ro_size]都为只读段
* .ro_after_init_size: [ro_size, ro_after_init_size]都为ro_after_init段
* .size: [ro_after_init_size, size] 都为数据段
注:
这里需要注意的是,在计算内存布局的过程中,get_offset => arch_mod_section_prepend函数会检查每个节区前是否需要预留一部分内存空间.
module_layout中只按照属性记录了内存布局,而每个节区属于哪个layout,在layout的内部偏移是多少,是记录在此节区节区头部表的sh_entsize字段中的
在模块加载过程中,静态链接符号表(及对应的静态链接符号表字符串表)会被放到init_layout数据段的尾部,同时从其静态链接符号表中提取部分符号信息保存到运行时的模块内存中(core_layout的数据段,这一部分被保留的符号称为核心符号表(core symbol)),而此函数就负责在init_layout/core_layout中为静态链接符号表和核心符号表预留空间.
核心符号表的判断条件是:
1) 符号表编号为0的符号,或livepatch相关所有符号均为核心符号
2) 未定义符号,节区信息错误的符号,init_layout段包含的节区中的的符号都不是核心符号
3) core_layout段包含的节区的符号分两种情况:
- 若开启了 CONFIG_KALLSYMS_ALL,那么core_layout段包含的节区中的符号均为核心符号
- 若未开启 CONFIG_KALLSYMS_ALL, 那么只有core_layout段包含的可执行代码节区中的符号才为核心符号
此函数在init_layout/core_layout中预留的空间包括:
* core_layout:
- 为核心符号表预留空间(Elf_sym*),起始偏移记录到 info->symoffs
- 为核心符号表字符串表预留空间,起始偏移记录到 info->stroffs
- 为核心符号表类型表预留空间(char数组),起始地址记录到 info->core_typeoffs
* init_layout:
- 为静态链接符号表预留空间,起始偏移记录到静态链接符号表节区头部表表项的symsect->sh_entsize字段
- 为静态链接符号表字符串表预留空间,起始偏移记录到静态链接符号表字符串表节区头部表表项的symsect->sh_entsize字段
- 为静态链接符号表预留一个mod_kallsyms结构体(这个实际上跟静态链接符号表节区头部表类似),起始偏移记录到 info->mod_kallsyms_init_off
此函数负责为core_layout和init_layout分配RWX内存(这里是vmalloc,arm64中的属性为RWX),若分配成功则:
* 先将两个layout段真正的内存首地址记录到module_layout->base字段中
* 然后再次扫描ko(目标文件)中所有节区,将所有标记SHF_ALLOC属性的节区复制到core/init_layout中.而节区复制到哪个layout,以及此节区在layout中的内部偏移是由节区的sh_entsize字段决定的:
- 高位是否标记了INIT_OFFSET_MASK决定其要分配到init_layout还是core_layout,此flag是在layout_sections函数中设置的,此函数中根据节区名(是否为".init"开头)判断其应属于哪个layout.
- 低位记录了当前节区在对应的layout中的偏移, module_layout结构体中只记录了每个layout中4中不同属性的段的起始结束地址,而每一个段中每个节区应该分配到哪里并没有记录,在layout_sections布局时已经将计算好的各个节区的layout内部偏移记录到节区头部表的sh_entsize字段了.
此函数最后会修正模块内存二进制中每个节区的节区头部表中的sh_addr,之前其指向节区的模块内存二进制中的位置之后指向模块内存最终布局中的位置.
注:
模块内存二进制位置指的是: 模块ELF文件被加载到内存后,在内存ELF文件中此节区的地址
模块布局位置指的是: 内核为模块分配运行时代码内存,并复制ELF文件中的节区后,此段代码在运行时真正的内存地址
模块的struct modules本来是指向模块内存ELF文件中的__this_module结构体,在8.6构建模块内存布局后,内核模块布局中也复制了一份__this_module结构体,这是最终模块的运行时地址,故这里将模块的mod指针指向这里并返回到父函数.
unformed是指加载到一半(也就是执行完当前函数),但还没有完全加载好的模块,此函数根据this_module.name检测当前系统里是否有同名模块:
此函数最后会通过mod_update_bounds函数更新内核已有模块的地址范围边界.
这里要注意,模块除了init_layout和core_layout外,还单独为percpu变量分配了内存,记录在mod.percpu中
find_module_sections 函数获取模块布局中节区的首地址返回到mod的各个字段中(如mod->kp),同时返回节区中的元素个数(如mod->num_kp),具体含义参考struct module结构体的定义,这里查找模块布局的方法实际上还是遍历模块内存二进制的节区头部表,但由于在move_module函数中分配模块布局内存时已经将节区头部表中的sh_addr修正为内存布局中此节区的地址,故这里返回的也是内存布局中的地址.
check_module_license_and_versions函数主要用来过滤一些模块,并检查CRC表是否正确. 此函数首先通过模块名过滤了一些模块,然后在内核定义了CONFIG_MODVERSIONS的情况下(代表要检查模块中函数的CRC),如果模块有某类型的导出函数,则要有此类型的crc表,没有则报错。
此函数叫simplify_symbols的意思是此函数决议了模块静态链接符号表中的符号,后面再使用此符号时候,不需要再决议了,所以称为简化.
此函数实际上是让模块内存中的静态链接重定位表(init_layout中)中的符号,全部指向其真实的内存地址:
* 对于未定义符号/弱符号找内核(已加载模块)的符号表,
* 其他符号(属于当前模块)则直接使用st_value += 段基址计算
此函数遍历目标文件中的所有内存节区(SHF_ALLOC)的重定位节区(REL/RELA),并遍历每个节区中的每个静态链接重定位表项,对其做静态链接。apply_relocate_add是真正负责重定位的函数,其中一共处理了月40种重定位类型。这里需要说明的是,对于CALL26/JUMP26类型的指令,若短跳转不够有可能会尝试基于plt的长跳转(CONFIG_ARM64_MODULE_PLTS开启情况下,未开启则直接报错),此时会在模块core/init_layout的plt表中增加一个表项.
此函数之后,模块作为目标文件的静态链接动态的完成了.
注: 静态链接一共分为三步:地址空间分配,符号决议,重定位,正好对应模块加载时的三个阶段:
此三步和静态链接的步骤一模一样,故模块加载实际上可以认为是内核运行时动态的执行了一次静态链接流程,向自身动态的静态链接了一个目标文件.
post_relocation一共包含3个主要函数,其中:
sort_extable 负责重排模块异常向量表
percpu_modcopy 负责将per-cpu变量的初始复制到各个cpu的内存
此函数负责刷新init/core_layout VA[base,base+size]范围内的指令缓存
[招生]科锐逆向工程师培训(2024年11月15日实地,远程教学同时开班, 第51期)
最后于 2022-2-19 15:23
被ashimida编辑
,原因: