点此返回首页

leeya_bug@home:~$

一个Coder,Attacker,Creator

某固件 Linux 内核逆向

缅甸、柬埔寨等东南亚地区国家的网络诈骗问题十分严重,相关人员罪行累累.

据悉,该地区大量使用一款国内某个小厂商 * 老版本固件的路由器,该版本于 2019 年发布了第一个 Release. 由于该厂商固件兼顾软路由、国产品牌文化认同等特性,导致该厂商产品在东南亚地区十分受欢迎.

开头

在过程中,不少师傅给我提供了思路. 虽然因为某些原因无法在本篇文章提到他名字,但是还是要特别感谢他们

声明:本篇提到的内核部分逆向基本上都是对开放源代码做逆向,patch 的解密代码部分也属于简单代码,未提及任何公司/人. 本文章所使用的 IDA 为免费试用版,代码均由自己逆向.

固件提取

从某处得到了该老版本的固件,由于提取的是运行磁盘,仅加密了文件系统. 首先拿到 .vmdk 后使用 qemu 挂载到 Kali 操作系统中,然后将内核和文件系统提取出来

asus

一个便捷的挂载 vmdk 流程: qemu-nbd 虚拟挂载

1. 安装 qemu-utils
sudo apt update
sudo apt install qemu-utils
2. 加载 nbd 内核模块
sudo modprobe nbd max_part=16
3. 连接 vmdk 文件
sudo qemu-nbd -c /dev/nbd0 GuJian.vmdk
4. 查看分区情况
sudo fdisk -l /dev/nbd0
5. 挂载分区 (假设第一个分区是 /dev/nbd0p1 )
sudo mkdir /mnt/vmdk
sudo mount /dev/nbd0p1 /mnt/vmdk
6. 访问文件
现在可以访问/mnt/vmdk目录下的文件: 
ls /mnt/vmdk
7. 卸载并断开连接
sudo umount /mnt/vmdk
sudo qemu-nbd -d /dev/nbd0

在挂载后,将 /mnt/vmdk/boot 中的 vmlinuz(bzImage 内核)、rootfs(文件系统) 拿出来,并使用 extract 脚本解压 bzImage

asus

该固件 Linux 系统为 X86 架构,内核版本为 3.18.67
Linux Version 3.18.67 对应的仓库是: Linux Version 3.18.67 - /

asus

定位内核函数

在此,笔者先简单讲一下内核加载文件系统(rootfs 全程 root file system, 根文件系统)流程,以方便笔者理解

内核初始化时会依次调用处于 .init 段的初始化函数,其中与根文件系统相关的初始化函数为 default_rootfs 和 populate_rootfs,两个函数根据内核配置项的选择决定是否会被运行. 在当前固件或者一般情况下,默认运行 populate_rootfs

populate_rootfs 在 Linux 3.18 中的主要逻辑

static int __init populate_rootfs(void) {
    // 处理内置的 initramfs
    if (initrd_start != initrd_end) {
        unpack_to_rootfs((char *)initrd_start, initrd_end - initrd_start);
    }
    // 处理其他可能的 rootfs 初始化
    ...
}

populate_rootfs 负责解析过渡根文件系统 (early rootfs). 如果该文件系统是 cpio 格式的 initramfs 或 initrd,该函数会直接利用 unpack_to_rootfs 将其解压到根目录 /,以其内容初始化整个根文件系统. 而当过渡根文件系统是 image 格式的 initrd (例如压缩过的磁盘镜像),则需要先在内存中创建一个虚拟的 RAM 磁盘设备 (ramdisk),再将其挂载后进行访问.

start_kernel()
  -> rest_init()
    -> kernel_init()
      -> init_mount_tree()
        -> populate_rootfs()

一个经典的 populate_rootfs 调用栈

unpack_to_rootfs 的参数和逻辑

void __init unpack_to_rootfs(
    char *buf,          // rootfs
    unsigned long len   // rootfs长度
) {
    // 解压(如果是压缩格式)并解析 CPIO 归档
    // 将文件/目录写入 rootfs
    ...
}

linux rootfs 的挂载流程实际上正如上述. 笔者将先用关键字法找到 populate_rootfs 的地址,所谓关键字法就是:在引导程序结束后加载内核时,观察输出,使用报错或正常流程等手段抓 rootfs 解密关键字.

一般情况下,只需要在系统引导加载内核前破坏掉 rootfs 中的部分值,使其在解密后 hash 值对不上,就可以让内核 panic,发出 Illegal rootfs ... Invalid rootfs ... 等报错,只要拿到报错语句就可以找到对应的字符串堆地址. 一般来说破坏 rootfs 的方法就是先将其挂载后,再修改 rootfs 中的字节.

由于笔者调慢了速度,可以不用破坏 rootfs 直接观测输出. 可以观察到窗口中输出了一个 .rodata 段中字符串 Trying to unpack rootfs ... ,然后到 ida 内核反编译窗口搜索字符串,观察到该字符串确实被一个函数引用

asus
asus
在这里前缀的 0x1 0x36 为打印级别,可忽略

该字符串的引用如下所示,在该函数的这个地方 printk 输出. 猜测这个函数和 populate_rootfsunpack_to_rootfs 脱不了干系,这下算找到了算是和 rootfs 加解密区域相关联的函数.

asus
在此,笔者已经大量还原符号,方便笔者阅读

仔细阅读该函数反编译的源码,发现了 unpack_to_rootfs 函数算法的痕迹,再根据源码比较发现大多数的函数是差不多的. 可以大致推测该函数就是 populate_rootfs 函数. 并且与 Linux 源码做对比发现此处加了个解密的大 patch,应该就是对 initrd 进行解密

Linux Version 3.18.67 - /init/initramfs.c Line: 451
asus
Linux 3.18 内核中 /init/initramfs.c populate_rootfs 函数源代码

asus
逆向后的 populate_rootfs,红圈中的应该是解密混淆部分

PS:跟根文件系统相关的初始化函数都会由 rootfs_initcall() 所引用,而 rootfs_initcall(populate_rootfs),也就是说会在系统初始化的时候会调用populate_rootfs 进行初始化.

函数逆向

在明确了解密算法区域后,接下来就是逆向解密算法了. 其实在这里逆不逆向无所谓,直接 pwndbg attach 内核调试然后将解密解混淆后内存中的文件系统拉下来就行. 笔者主要讲个思路,以防遇到不能轻易 attach 的情况

  1. 逆向分为三个步骤,首先先初始化 initrd_start 和 initrd_size,方便后续调用. 在动态调试时这个地方 initrd_size 大小和 vmdk 中提取出来的 rootfs 大小一样,就可以确认这个地方是在对 rootfs 进行操作,而非生成密钥

    asus

  2. 观察如下面第一个解密区域,首先迭代位的步数是:0、2、4、6、... ,然后块中分别取了 first = (char *)(_initrd_start + 迭代);second = (char *)(_initrd_start + v4 + 1); 并将 first 和 second 交换位置. 这不就是每两个字节调换值吗

    asus

     调换前
     {1, 2, 3, 4, 5, 6, 7, ...}
     调换后
     {2, 1, 4, 3, 6, 5, 8, ...}
    
  3. 继续观察第二个解密区域,首先该区域只进行了一半的迭代,其时间消耗砍半,这让笔者想起了字符串翻转. 其次, iter_1 对应前序指针,遍历方向为从前向后,v8 对应末尾指针,遍历方向对应从后向前. v11 即为前序指针对应的值,v12 即为末尾指针对应的值.

    v11 = *(_BYTE *)(iter_1 + _initrd_start); 首先 v11 保存了末尾指针的值,然后将 v12 赋值给末尾指针后,在把 v11 的值赋值给v12. 至此笔者可以确定,该函数就是把前后数据大翻转

    asus

     调换前
     {1, 2, 3, ... 98, 99, 100}
     调换后
     {100, 99, 98, ... 3, 2, 1}
    
  4. 第三个解密区域的逻辑比较复杂,大致可剥离为如下步骤

    首先取个临时变量 temp = (int)iter_2 * ((int)iter_2 + 1) % 32 + iter_2;,然后再循环赋值. 将循环赋值的代码用伪代码表示:当前值 = (当前值 >> (temp % 7 + 1)) | (当前值 << (7 - temp % 7))

    注意到,(temp % 7 + 1) + (7 - temp % 7) == 8 为真. 此处是一个固定的混淆算法,只要能确定数据长度,就可以用伪代码替换他.

    asus

     for (iter_2 = 0LL; iter_2 < initrd_size; ) {
       temp = iter_2 * (iter_2 + 1) % 32 + iter_2;
       ++iter_2;
       当前值 = (当前值 >> (temp % 7 + 1)) | (当前值 << (7 - temp % 7))
     }
    

    该算法的一个 Python 实现如下所示

     data = 第三步输入数据
     i = 0
     while True:
         v14 = i * (i + 1) % 32 + i
         i += 1
         # 这个地方 0xFF 太多
         data[i - 1] = ((data[i - 1]  >> (v14 % 7 + 1)) & 0xFF) | ((data[i - 1] << (7 - v14 % 7)) & 0xFF)
         if i >= len(data):
             break
    

整个解密函数(Python 语言版本):

# by leeya_bug
import os

with open('./rootfs', 'rb') as f:
	data = f.read()
data = bytearray(data)

# first
for i in range(0, len(data), 2):
	data[i], data[i + 1] = data[i + 1], data[i]
data = data[::-1]   # second
# third
i = 0
while True:
	v14 = i * (i + 1) % 32 + i
	i += 1
	data[i - 1] = ((data[i - 1]  >> (v14 % 7 + 1)) & 0xFF) | ((data[i - 1] << (7 - v14 % 7)) & 0xFF)
	if i >= len(data):
		break

data1 = bytes(data)
with open('rootfs.decoded', 'wb') as f:
	f.write(data1)

文件系统解密

虽然是有了 Python 脚本,但是为了确保解密过程不出错,笔者最终还是选用了通过调试内核的手段,直接提取文件系统. 在调试内核前,请注意将虚拟机 watchdog 触发时长设置为足够高(最好是一两个小时),以避免在 gdb 调试时 watchdog 抛出 CPU stuck

#!/bin/bash
qemu-system-x86_64 \
  -no-reboot \
  -m 4G \
  -smp 1 \
  -hda ./GuJian.vmdk \
  -kernel ./vmlinuz \
  -initrd ./rootfs \
  -append "console=ttyS0 nmi_watchdog=0 nowatchdog watchdog_thresh=1200" \  #修改 watchdog_thresh 时长为 20 分钟
  -S \
  -gdb tcp::1234 \  #监听 1234 端口
  -nographic \

在 populate_rootfs 结束解密后 call REVERSE_unpack_to_rootfs 地址为 0xFFFFFFFF81D25101 的位置打断点. 运行到此时, rdi 的值正好是解密后数据的位置.

asus
asus

在 pwndbg 中打断点并运行到此处,观察到此时 rdi 的值为 0xffff8800be3d9000.
直接使用命令 dump memory dump.bin 0xffff8800be3d9000 0xffff8800be3d9000 + 0x1c06374 将数据 dump 到文件中

asus
asus

数据是 .xz 格式的压缩包,首先使用 xz -d <压缩包> 将其解压后,使用 binwalk 就可以将他的文件系统解出来

asus
asus