Linux启动过程分析之Kernel

Published: 2021年08月18日

In Kernel.

X86架构CPU模式

正式开始前先补充一下X86处理器的模式,x86平台寄存器可表现为多个模式,它们由寄存器相应标志位切换,切换状态图如下:

实模式

实模式(Real-Address Mode)即直接访问物理地址,它是CPU重置后的初始模式,用于执行8086代码,并在此基础上新增了一些指令,配合段寄存器最大能访问1M(20位地址线)的空间(现在在A20被使能时可访问全部空间),地址转换方式如下图:

它的通用寄存器为16位(实际指定操作数前缀时也可以当作32位使用),另外IA-32新增的寄存器也可用,在该模式下使用IVT(中断向量表)处理中断或异常,IVT位于0x00处,每项4字节由CS和IP组成,最大0x3FF项,如下:

注:1.A20Gate在Intel上是Memory Wraparound,它只影响第21位,即禁用时无法访问单M地址内存,Intel使用A20M#控制它,且低电平Mask生效,此时该线永0,需要对它施加高电平启用A20,这由芯片集里多部分决定(或逻辑),当前默认重置时为A20 Enable状态,之后BIOS在传递控制权前会禁用它。开启A20后,直观的FFFF:FFFF不会回绕了,但其实此时还可通过Big Real Mode直接访问全部内存。

2.所有模式都还有一个64K的IO地址空间,不再特别说明。

保护模式

保护模式(Protected Mode)是现代操作系统通常处于的模式,它使用IDT描述中断例程的位置,使用IDTR描述IDT的位置并由LIDT修改其值,因此该位置可变。

虚拟8086模式

没见过暂时略。

IA-32e模式

也叫long mode(长模式),它有兼容模式(执行32位代码)和64位模式两种,由CS段的L标志决定,而只能由保护模式切换到该模式,即切换时分页已经打开,之后再开启PAE与IA32_EFER(Extended Feature Enable Register)寄存器的LME标志位(LMA不能被软件设置,LMA=CR0.PG&LME,因此可直接表示是否处于IA-32e模式):

系统管理模式

SMM是特别特殊的模式,只能由SMI#引脚进入,用于管理系统资源,如电源管理等,不能通过寻常软件方式进入,一般由BIOS等使用,对操作系统来说它是无法感知的因此此处忽略它。

构建Kernel

为了更清晰的了解内核启动细节,最好能通过调试器跟踪该过程,默认的内核是不带调试信息的,有的发行版官方提供dbgsym下载,此处选择自行编译带调试信息的内核,这里选择最经典的2.6版:

  1. 构建编译工具链,kernel版本与gcc关系密切,因此在构建老内核时必须使用旧的gcc,如2.6只支持gcc4和gcc5,因此需要先构建编译工具链,此处使用ct-ng构建编译工具链
  2. 将输出的工具链路径加入环境变量,如export PATH=$PATH:/home/bm/x-tools/x86_64-unknown-linux-uclibc/bin
  3. 配置kernel编译选项,修改其中所需的编译工具链前缀General setup  ---> Cross-compiler tool prefix,如x86_64-unknown-linux-uclibc-,在旧版kernel不支持此选项,可直接在make时指定CROSS_COMPILE=x86_64-unknown-linux-uclibc-
  4. 配置其他需要的功能,如kernel hacker里配置调试选项,之后使用make进行编译,可能遇到如下错误:
gcc: error: elf_i386: No such file or directory
OBJCOPY arch/x86/vdso/vdso32-int80.so
objcopy: 'arch/x86/vdso/vdso32-int80.so.dbg': No such file
...

把相应Makefile里的-m elf_x86_64替换为-m64-m elf_i386替换为-m32

In file included from drivers/net/igbvf/ethtool.c:36:0:
drivers/net/igbvf/igbvf.h: At top level:
drivers/net/igbvf/igbvf.h:129:15: error: duplicate member ‘page’
make[3]: *** [drivers/net/igbvf/ethtool.o] Error 1
...

在联合体里有两个结构struct page *page名字一样,其实也能正确定位,不过这里报错的话删除后者就好了。

Can't use 'defined(@array)' (Maybe you should just omit the defined()?) at kernel/timeconst.pl line 373.
make[1]: *** [/home/bm/kernel2632/linux-2.6.32/kernel/Makefile:129: kernel/timeconst.h] Error 255

perl没有这个了,修改kernel/timeconst.pl对应行,将defined去掉就好啦。

为了调试方便,可设置一些编译选项,如关闭CONFIG_DEBUG_INFO_REDUCED,开启CONFIG_FRAME_POINTER,开启CONFIG_GDB_SCRIPTS,开启CONFIG_DEBUG_INFO,如果在菜单里找不到也可以直接编辑.config文件。 ​

initrd与vmlinuz

Linux的内核线程需要文件系统支撑,为了适应多变的硬件需要把所有可能的文件系统驱动编译进内核,但是内核除了特殊的初始化区域,其他像内嵌的模块内存是不能被释放的。因此这会浪费很多内存,initrd就是来解决这个问题的,它是初始化根文件系统,作为一个桥梁临时充当根文件系统的角色,可以在它上面放置各种可选的驱动,而将加载它的方法编译进内核,内核就可以先通过它建立根文件系统并在其上加载真正根文件系统所需要的驱动了。initrd是initial ramdisk的缩写,它是一种虚拟块设备(内存磁盘),创建时先取固定大小并用losetup将其挂在为loop设备,再对其进行分区格式化,之后挂载向其中写入文件,此时内核需要编译它分区对应文件系统的驱动才能识别它。后来直接使用了ramfs,这种内存文件系统特别简单而且易于扩展,此时对该文件系统起了个别名initramfs,initrd不在是磁盘镜像了,而是一个cpio格式的文件,一般还会使用gzip等对其进行压缩,启动时内核会解析CPIO并将它里面的每个文件添加到ramfs作为根的文件系统里,下面会简单分析:

struct cpio_newc_header {
        char    c_magic[6];  // 070701
        char    c_ino[8];
        char    c_mode[8];
        char    c_uid[8];
        char    c_gid[8];
        char    c_nlink[8];
        char    c_mtime[8];
        char    c_filesize[8];
        char    c_devmajor[8];
        char    c_devminor[8];
        char    c_rdevmajor[8];
        char    c_rdevminor[8];
        char    c_namesize[8];
        char    c_check[8];
};

此处不演示ramdisk形式的initrd,对它直接使用losetup等工具加载为虚拟块设备,再挂载分区即可,有时它无法自动识别分区可使用partx等工具识别。现在一般遇到的是CPIO格式的文件,它一般会被压缩,可使用file命令识别,一般可使用如下命令对它进行解包与打包:

gzip -cd initrd | cpio -imd --quiet  # 解包
find . | cpio --quiet -H newc -o | gzip -9 -n > initrd # 打包

但有时解压的数据不完整,如:

root@bm:/tmp/aaa# cpio -i < initramfs.img 
root@bm:/tmp/aaa# tree 
.
├── early_cpio
├── initramfs.img
└── kernel
    └── x86
        └── microcode
            ├── AuthenticAMD.bin
            └── GenuineIntel.bin

若用binwalk查看会发现它末尾还有GZIP的压缩包:

root@bm:/tmp/aaa# binwalk initramfs.img 

DECIMAL       HEXADECIMAL     DESCRIPTION
--------------------------------------------------------------------------------
0             0x0             ASCII cpio archive (SVR4 with no CRC), file name: ".", file name length: "0x00000002", file size: "0x00000000"
112           0x70            ASCII cpio archive (SVR4 with no CRC), file 
...
4834020       0x49C2E4        ASCII cpio archive (SVR4 with no CRC), file name: "TRAILER!!!", file name length: "0x0000000B", file size: "0x00000000"
4834304       0x49C400        gzip compressed data, maximum compression, from Unix, last modified: 2021-07-30 15:58:29
25120311      0x17F4E37       Cisco IOS experimental microcode, for ""

可知它不是常规的cpio文件,它的尾部内嵌了gzip文件,因此对这种类型的可使用如下命令提取:

(cpio -id; zcat | cpio -id) < initramfs.img

初始化过程

上面已知vmlinuz是压缩后的kernel,于是可以将它分为两部分:解压部分,它的作用是将压缩后的内核解压到指定区域;真实内核,这是真正起作用的部分。

解压部分

这一部分的代码位于arch/x86/boot目录下,该目录下的代码将会被编译成两个部分,并组合在一起形成vmlinuz,如下图:

bzImage

其中setup.bin是实模式的代码,vmlinuz.bin是保护模式的代码,又下角的/vmlinux是真实kernel,setup.bin与vmlinuz.bin通过build连接在一起形成bzImage(即vmlinuz),可见Makefile

cmd_image = $(obj)/tools/build $(obj)/setup.bin $(obj)/vmlinux.bin \
    $(ROOT_DEV) > $@

$(obj)/bzImage: $(obj)/setup.bin $(obj)/vmlinux.bin $(obj)/tools/build FORCE
    $(call if_changed,image)
    @echo 'Kernel: $@ is ready' ' (#'`cat .version`')'

build做的事就是直接将它们两者连接在一起,并将后者的偏移等记录在前者的头部,关于头部会在后文介绍:

    /* 读setup.bin */
    file = fopen(argv[1], "r");
    c = fread(buf, 1, sizeof(buf), file); 

    /* 对setup.bin按扇区大小进行填充,若它小雨最小值(4)则填充多个扇区 */
    setup_sectors = (c + 511) / 512;
    if (setup_sectors < SETUP_SECT_MIN)
        setup_sectors = SETUP_SECT_MIN;
    i = setup_sectors*512;      
    memset(buf+c, 0, i-c);  

    /* 设置根设备号 */
    buf[508] = minor_root;
    buf[509] = major_root;

    /* 读vmlinux.bin */
    fd = open(argv[2], O_RDONLY);
    sz = sb.st_size;
    kernel = mmap(NULL, sz, PROT_READ, MAP_SHARED, fd, 0);
    /* 计算vmlinux.bin加上4字节CRC后,按16字节对齐填充后的大小 */
    sys_size = (sz + 15 + 4) / 16;

    /* 将vmlinux.bin最终的大小填入setup.bin的启动头部 */
    buf[0x1f1] = setup_sectors-1;
    buf[0x1f4] = sys_size;
    buf[0x1f5] = sys_size >> 8;
    buf[0x1f6] = sys_size >> 16;
    buf[0x1f7] = sys_size >> 24;

    /*计算setup.bin,vmlinx.bin的CRC并将数据写入 */
    crc = partial_crc32(buf, i, crc);
    fwrite(buf, 1, i, stdout)
    crc = partial_crc32(kernel, sz, crc);
    fwrite(kernel, 1, sz, stdout)

    /* 填充 */
    while (sz++ < (sys_size*16) - 4) {
        crc = partial_crc32_one('\0', crc);
        fwrite("\0", 1, 1, stdout)
    }

    /* 添加CRC */
    fwrite(&crc, 1, 4, stdout)

在被加载后,它们将以以下顺序执行:

下面将详细分析setup.bin与vmlinux.bin。

实模式代码

它由Makefile调用,继续看Makefile,关于setup.bin的如下:

OBJCOPYFLAGS_setup.bin  := -O binary
$(obj)/setup.bin: $(obj)/setup.elf FORCE
    $(call if_changed,objcopy)

LDFLAGS_setup.elf   := -T
$(obj)/setup.elf: $(src)/setup.ld $(SETUP_OBJS) FORCE
    $(call if_changed,ld)

SETUP_OBJS = $(addprefix $(obj)/,$(setup-y))

setup-y     += a20.o bioscall.o cmdline.o copy.o cpu.o cpucheck.o edd.o
setup-y     += header.o main.o mca.o memory.o pm.o pmjump.o
setup-y     += printf.o regs.o string.o tty.o video.o video-mode.o
setup-y     += version.o
setup-$(CONFIG_X86_APM_BOOT) += apm.o
setup-y     += video-vga.o
setup-y     += video-vesa.o
setup-y     += video-bios.o

setup.bin由objcopy -O binary setup.elf生成,而setup.elf是由一系列的目标文件通过链接脚本链接而成,不像Grub直接使用汇编指令控制目标文件格式,Linux kernel使用链接器脚本组织它,生成setup.bin的脚本为setup.ld,它生成的镜像如下:

现在可以看代码了,从链接脚本可知入口在_start上。

_start

_start位于header.S,该文件起始代码如下,它为.bstext节,也是目标二进制文件的开头,以前的kernel可以直接被加载并从头开始执行,现在它只会输出一段提示并重启:

BOOTSEG     = 0x07C0        /* original address of boot-sector */
SYSSEG      = 0x1000        /* historical load address >> 4 */
    .code16
    .section ".bstext", "ax"

    .global bootsect_start
bootsect_start:

    # Normalize the start address
    ljmp    $BOOTSEG, $start2
start2:
    ... /*输出提示并重启*/

跳过这段到偏移497处,如下,它其实就是上篇所提到的启动协议数据块,里面有很多数据是由bootloader填充的:

    .section ".header", "a"  /* .header节,在链接脚本里指定了偏移为497 */
    .globl  hdr
hdr:
setup_sects:    .byte 0         /* setup.bin(实模式)的扇区数,上面已经遇到过,被build设置 */
root_flags: .word ROOT_RDONLY    /* 启动标志,root是否只读*/
syssize:    .long 0         /* vmlinx.bin(保护模式)的大小,被build设置 */
ram_size:   .word 0         /* 过时 */
vid_mode:   .word SVGA_MODE /* 视频卡模式 */
root_dev:   .word 0         /* 跟设备号,由build设置 */
boot_flag:  .word 0xAA55    /* 魔数 */

    .globl  _start      /* 此处的偏移刚好为512了,虽然是头部,但是它是一条近跳转指令 */
_start:
        .byte   0xeb         /* 0xeb是两字节的近跳转操作码,否则编译器会生成长度非2字节的跳转 */
        .byte   start_of_setup-1f /* 跳转的目的地为start_of_setup */
1:
        .ascii  "HdrS"      # 头部签名
        .word   0x020a      # 协议版本号 (>= 0x0105)
        .globl realmode_swtch
realmode_swtch: .word   0, 0        # 实模式切换,特殊情况的hook
start_sys_seg:  .word   SYSSEG      # 废弃 0x1000
        .word   kernel_version-512 # 指向kernel版本字符串

type_of_loader: .byte   0       # 0 bootloader的类型和版本

loadflags:  /* 这里声明了两个加载标志 */
LOADED_HIGH = 1         # 表示内核可以被加载到高地址处
CAN_USE_HEAP    = 0x80          # 表示loader已经设置了heap_end_ptr,此时就可以知道有多少空间可被用于堆
        .byte   LOADED_HIGH

setup_move_size: .word  0x8000      # 过时

code32_start:               # kernel加载地址(保护模式入口地址)
        .long   0x100000    # 0x100000 = default for big kernel

ramdisk_image:  .long   0       # initrd被加载的位置

ramdisk_size:   .long   0       # initrd的大小
bootsect_kludge: .long  0       # 废弃

heap_end_ptr:   .word   _end+STACK_SIZE-512 # 堆结束地址,向前到setup.bin结束为堆区域

ext_loader_ver:
        .byte   0       # Extended boot loader version
ext_loader_type:
        .byte   0       # Extended boot loader type

cmd_line_ptr:   .long   0       # 命令行字符串的指针

ramdisk_max:    .long 0x7fffffff    # initrd能占用的最高地址

kernel_alignment:  .long CONFIG_PHYSICAL_ALIGN  # 可重定位内核的对齐大小
#ifdef CONFIG_RELOCATABLE
relocatable_kernel:    .byte 1  /* 内核可以被重定位 */
#else
relocatable_kernel:    .byte 0
#endif
min_alignment:      .byte MIN_KERNEL_ALIGN_LG2  # minimum alignment
pad3:           .word 0

cmdline_size:   .long   COMMAND_LINE_SIZE-1     # 最大命令行参数长度

hardware_subarch:   .long 0         # 和半虚拟化相关

hardware_subarch_data:  .quad 0

payload_offset:     .long ZO_input_data
payload_length:     .long ZO_z_input_len

setup_data:     .quad 0         # 64-bit physical pointer to
                        # single linked list of
                        # struct setup_data

pref_address:       .quad LOAD_PHYSICAL_ADDR    # preferred load addr

init_size:      .long INIT_SIZE     # kernel initialization size

这里面的ZO_xxx来自zoffset.h,该文件是被动态生成的,内容如:

#define ZO_INIT_SIZE    (ZO__end - ZO_startup_32 + ZO_z_extract_offset)
#define VO_INIT_SIZE    (VO__end - VO__text)
#if ZO_INIT_SIZE > VO_INIT_SIZE
#define INIT_SIZE ZO_INIT_SIZE
#else
#define INIT_SIZE VO_INIT_SIZE
#endif

mkpiggy根据cvmlinux.bin.xx生成piggy.S文件:

.section ".rodata.compressed","a",@progbits
.globl z_input_len
z_input_len = 3906834
.globl z_output_len
z_output_len = 9304736
.globl z_extract_offset
z_extract_offset = 0x52f000     ;; 算法如下
.globl z_extract_offset_negative
z_extract_offset_negative = -0x52f000
.globl input_data, input_data_end
input_data:
.incbin "arch/x86/boot/compressed/vmlinux.bin.gz"
input_data_end:

bootloader最后会跳转到_start处,它是一条近跳转指令,会跳过后面的启动协议块直接到start_of_setup

    .section ".entrytext", "ax"
start_of_setup:
#ifdef SAFE_RESET_DISK_CONTROLLER
# 重置磁盘控制器
    movw    $0x0000, %ax        # Reset disk controller
    movb    $0x80, %dl      # All disks
    int $0x13
#endif

# 使%es = %ds,并重置方向
    movw    %ds, %ax
    movw    %ax, %es
    cld

# 有些bootloader不能保证%ss和%ds一致,所以这里判断并保证一致

    movw    %ss, %dx
    cmpw    %ax, %dx    # %ds == %ss?
    movw    %sp, %dx
    je  2f      # -> assume %sp is reasonably set

    # Invalid %ss, make up a new stack
    movw    $_end, %dx
    testb   $CAN_USE_HEAP, loadflags
    jz  1f
    movw    heap_end_ptr, %dx
1:  addw    $STACK_SIZE, %dx
    jnc 2f
    xorw    %dx, %dx    # Prevent wraparound

2:  # Now %dx should point to the end of our stack space
    andw    $~3, %dx    # dword align (might as well...)
    jnz 3f
    movw    $0xfffc, %dx    # Make sure we're not zero
3:  movw    %ax, %ss
    movzwl  %dx, %esp   # Clear upper half of %esp
    sti         # 栈可用时,开启中断

# 这里同步%cs=%ds
    pushw   %ds
    pushw   $6f
    lretw
6:

# 检查头部的签名,setup_sig在链接脚本里定义,位于.data段结束
    cmpl    $0x5a5aaa55, setup_sig
    jne setup_bad

# 初始化bss段,这几个变量依然在链接脚本里定义,__bss_start表示bss段开始,__bss_end表示bss段结束,而_end表示bss段16字节对齐后的结束地址
    movw    $__bss_start, %di
    movw    $_end+3, %cx
    xorl    %eax, %eax
    subw    %di, %cx
    shrw    $2, %cx
    rep; stosl

# 当初始化bss后就可以调用C代码了
    calll   main
main

main在arch/x86/boot/main.c里,由于是C代码,此处不在详细分析,此处简单注释一下:

void main(void)
{
    /* 它将启动协议块拷贝到boot_params的hdr字段 */
    copy_boot_params();

    /* 初始化全局堆 */
    init_heap();

    /* 确保CPU运行在正确的特权级上,检查支持的特性等 */
    if (validate_cpu()) {
        puts("Unable to boot - please use a kernel appropriate "
             "for your CPU.\n");
        die();
    }

    /* Tell the BIOS what CPU mode we intend to run in. */
    set_bios_mode();

    /* 从BIOS中获取内存布局信息 */
    detect_memory();

    /* 键盘初始化,设置按键检测频率 */
    keyboard_set_repeat();

    /* Query MCA information */
    query_mca();

    /* Query Intel SpeedStep (IST) information */
    query_ist();

    /* Query APM information */
#if defined(CONFIG_APM) || defined(CONFIG_APM_MODULE)
    query_apm_bios();
#endif

    /* Query EDD information */
#if defined(CONFIG_EDD) || defined(CONFIG_EDD_MODULE)
    query_edd();
#endif

    /* Set the video mode */
    set_video();

    /* Parse command line for 'quiet' and pass it to decompressor. */
    if (cmdline_find_option_bool("quiet"))
        boot_params.hdr.loadflags |= QUIET_FLAG;

    /* 转到保护模式 */
    go_to_protected_mode();
}

之后就可以进入保护模式了,它的实现如下

void go_to_protected_mode(void)
{
    /* 执行realmode_switch_hook,该项位于启动协议块,若未设置则只关外部中断与NMI中断 */
    realmode_switch_hook();

    /* 开启A20地址线,此时就有能力访问所有地址空间 */
    if (enable_a20()) {
        puts("A20 gate not responding, unable to boot...\n");
        die();
    }

    /* 重置协处理器 */
    reset_coprocessor();

    /* 屏蔽主从PIC */
    mask_all_interrupts();

    /* 设置IDT与GDT,这里IDT设置一个空表即可,而GDT需要有三个段CS/DS/TSS */
    setup_idt();
    setup_gdt();
  /* 跳转到code32_start处,它的地址位于启动协议块的头部 */
    protected_mode_jump(boot_params.hdr.code32_start,
                (u32)&boot_params + (ds() << 4));
}

保护模式代码

d

内核逻辑

内核逻辑的初始化可分为两部分,第一部分是系统核心初始化,包括内存子系统/中断子系统/进程调度子系统等的初始化,在此之后的另一部分就是其他子系统/模块的初始化了。

模块初始化

初始化会使用宏来修饰,不同的宏能定义初始化的优先级,详见init.h

#define __define_initcall(level,fn,id) \
    static initcall_t __initcall_##fn##id __used \
    __attribute__((__section__(".initcall" level ".init"))) = fn

/*
 * Early initcalls run before initializing SMP.
 *
 * Only for built-in code
 , not modules.
 */
#define early_initcall(fn)      __define_initcall("early",fn,early)

/*
 * A "pure" initcall has no dependencies on anything else, and purely
 * initializes variables that couldn't be statically initialized.
 *
 * This only exists for built-in code, not for modules.
 */
#define pure_initcall(fn)       __define_initcall("0",fn,0)

#define core_initcall(fn)       __define_initcall("1",fn,1)
#define core_initcall_sync(fn)      __define_initcall("1s",fn,1s)
#define postcore_initcall(fn)       __define_initcall("2",fn,2)
#define postcore_initcall_sync(fn)  __define_initcall("2s",fn,2s)
#define arch_initcall(fn)       __define_initcall("3",fn,3)
#define arch_initcall_sync(fn)      __define_initcall("3s",fn,3s)
#define subsys_initcall(fn)     __define_initcall("4",fn,4)
#define subsys_initcall_sync(fn)    __define_initcall("4s",fn,4s)
#define fs_initcall(fn)         __define_initcall("5",fn,5)
#define fs_initcall_sync(fn)        __define_initcall("5s",fn,5s)
#define rootfs_initcall(fn)     __define_initcall("rootfs",fn,rootfs)
#define device_initcall(fn)     __define_initcall("6",fn,6)
#define device_initcall_sync(fn)    __define_initcall("6s",fn,6s)
#define late_initcall(fn)       __define_initcall("7",fn,7)
#define late_initcall_sync(fn)      __define_initcall("7s",fn,7s)

#define __initcall(fn) device_initcall(fn)

vmlinux.lds.h

#define INITCALLS                           \
    *(.initcallearly.init)                      \
    VMLINUX_SYMBOL(__early_initcall_end) = .;           \
    *(.initcall0.init)                      \
    *(.initcall0s.init)                     \
    *(.initcall1.init)                      \
    *(.initcall1s.init)                     \
    *(.initcall2.init)                      \
    *(.initcall2s.init)                     \
    *(.initcall3.init)                      \
    *(.initcall3s.init)                     \
    *(.initcall4.init)                      \
    *(.initcall4s.init)                     \
    *(.initcall5.init)                      \
    *(.initcall5s.init)                     \
    *(.initcallrootfs.init)                     \
    *(.initcall6.init)                      \
    *(.initcall6s.init)                     \
    *(.initcall7.init)                      \
    *(.initcall7s.init)

#define INIT_CALLS                          \
        VMLINUX_SYMBOL(__initcall_start) = .;           \
        INITCALLS                       \
        VMLINUX_SYMBOL(__initcall_end) = .;

https://www.codenong.com/cs106203517/

有多种kernel格式

启动过程

未处理的kernel是无法直接运行的:

root@bm:/# qemu-system-x86_64 -kernel vmlinux -nographic
qemu-system-x86_64: Error loading uncompressed kernel without PVH ELF Note

可使用正向方法确定,

misc.c用于解压并重定位kernel

struct compressed_file{
    char magic[2];              // 0x00
    char method[1];             // 0x02
    char flags[1];              // 0x03
    char timestamp[4];          // 0x04
    char extraflags[1];         // 0x08
    char os[1];                 // 0x09
    char compressed data blocks[N];             // 0x0a
    char crc[4];                // size - 0x08
    char orig_len[4];           // size - 0x04
}
vmlinux.bin.xx:{
    char[] compressed_data;
    int32 uncompressed_data_size;
}

if (fseek(f, -4L, SEEK_END)) {
    perror(argv[1]);
}
fread(&olen, sizeof olen, 1, f);
ilen = ftell(f);
olen = getle32(&olen);
fclose(f);

/*
 * Now we have the input (compressed) and output (uncompressed)
 * sizes, compute the necessary decompression offset...
 */

offs = (olen > ilen) ? olen - ilen : 0;
offs += olen >> 12; /* Add 8 bytes for each 32K block */
offs += 32*1024 + 18;   /* Add 32K + 18 bytes slack */
offs = (offs+4095) & ~4095; /* Round to a 4K boundary */

综上,我们需要改的代码在cvmlinux.bin这个可执行文件中,它被压缩了无法直接修改,首先需要对其进行解压,在修改后重新打包需要对相关数据进行修复 ​

注:一般操作系统不会直接运行一个纯代码文件,而是会运行某种封装格式,如PE/ELF/Mach-O等,系统的加载器会按照规范解析它们,把它们加载到合适的位置,还可能做一些初始化与修复操作,之后得到的才是CPU所需要的视图,而在系统启动初期Loader的功能不可用,因此需要直接提供解析后视图,这样就可以直接把它加载到内存的某区域开始执行,在linux下可通过objcopy -O binary选项实现,对这类二进制文件在分析时就需要指定正确的加载位置,正确的架构等,也可使用如下命令简单分析:

objdump -D -Mintel,i8086 -b binary -m i386 mbr.bin
objdump -D -Mintel,i386 -b binary -m i386 foo.bin    # for 32-bit code
objdump -D -Mintel,x86-64 -b binary -m i386 foo.bin  # for 64-bit code

修改与重打包

经过上面的分析,知道了vmlinuz的组成方式,于是就可以对它进行修改绕过一些限制了,例如需要修改它内嵌的initrd或内核,已知它们都是被压缩存储了,因此我们无法直接操作它们而需要先解压,这一般可以直接使用binwalk获取,但是修改后怎么把它们压缩再嵌入vmlinuz文件?可以直接嵌入?

由上可知,initrd可以嵌入在kernel中,而kernel是被压缩后存于vmlinux.bin的数据区,通过压缩格式中(如GZIP)的魔数我们可以定位到它的起始位置,但是并不知道它的结束位置,而kernel之后还有数据和指令,因此需要不修改大小的把数据覆写回去,而压缩后的kernel的大小只在vmlinux.bin中的某机器指令中,所以一种方法是是根据一些特征寻找该指令,知道它的起始位置后就可以把修改的包重新填充回去了,但是还有可能有的问题就是修改后的kernel压缩后大小大于原始大小,此时就需要想办法减小它的大小。

例如某设备,经过分析它的initrd与磁盘都被加密了,bootloader也做了限制防止我们修改启动参数,但是kernel未加密,因此一种思路就是修改kernel,已知kernel初始化后会运行init程序,此处就可以直接把要执行的程序改为bash(根据实际情况这里有很多破解思路,此处只是为了演示),首先提取kernel,此处直接使用binwalk:

betamao@DESKTOP-37V052O:/$ binwalk -e vmlinuz-3.10.0-1160.31.1.el7.x86_64

DECIMAL       HEXADECIMAL     DESCRIPTION
--------------------------------------------------------------------------------
0             0x0             Microsoft executable, portable (PE)
18356         0x47B4          gzip compressed data, maximum compression, from Unix, last modified: 1970-01-01 00:00:00 (null date)
6768194       0x674642        LZMA compressed data, properties: 0x64, dictionary size: 0 bytes, uncompressed size: 2048 bytes
6768593       0x6747D1        Certificate in DER format (x509 v3), header length: 4, sequence length: 885
6769482       0x674B4A        Certificate in DER format (x509 v3), header length: 4, sequence length: 875
6771057       0x675171        Certificate in DER format (x509 v3), header length: 4, sequence length: 910
6771971       0x675503        Certificate in DER format (x509 v3), header length: 4, sequence length: 866

betamao@DESKTOP:/$ cd _vmlinuz-3.10.0-1160.31.1.el7.x86_64.extracted/

betamao@DESKTOP:/mnt/c/Users/betamao/Desktop/test-sdb1/_vmlinuz-3.10.0-1160.31.1.el7.x86_64.extracted$ file *
47B4:      ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, BuildID[sha1]=145340cd0f97d560527fa7222afae8c87699e72b, stripped
674642:    ASCII text, with no line terminators
674642.7z: data

使用ida加载kernel,由于一致kernel在最后的init_post中会调用init,此处定位到该点并将其修改为bash:

image.png

修改后重新打包并启动获取shell:

img

注意一些坑点,不仅要把命令改掉,还需要注意参数和环境变量,最莽的方法是全部置0,还有一个坑点就是这个kernel是可重定位的,里面有些位置会在加载时重定位,因此在做patch是需要jmp跳过这种指令,可仔细观察dump信息:

image.png

init系统

kernel启动的第一个用户进程一般为init系统的进程(由内核启动参数init=指定,若不指定将尝试执行/bin/init等程序,全部失败则panic),常见的init系统有sysvinit,runit,upstart,systemd,openrc等,它们会根据当前运行信息从相应配置启动服务,这些服务中可能还包括服务管理服务,如gaffer,supervisor等

浅析 Linux 初始化 init 系统

参考

  1. Intel® 64 and IA-32 Architectures Software Developer’s Manual Volume 3 (3A, 3B, 3C & 3D): System Programming Guide
  2. Linux-inside -- 0xAX
  3. 【我所认知的BIOS】- The Big Real Mode -- lightseed
  4. 关于A20地址线的疑惑
  5. [Linux启动流程:内核解压. -- binss