OneShell

I fight for a brighter tomorrow

0%

FirmAE创建qemu镜像并执行的过程

最近有一个需求,如何对FirmAE仿真成功的设备中的固件进行修改,本质就是在仿真前修改固件的文件系统。背景是这样的,我在调试一个httpd的时候,它会创建大量的进程,这些进程手动进行kill的话太繁琐,使用gdbserver对其进行attach主进程,其他的进程也会影响主进程的调试,因此就想,能不能将httpd的自动启动关停,通过gdbserver手动启动程序并调试,这样进程状态比较可控。

这就需要从源码上分析一下FirmAE是如何从设备固件中提取固件、进行patch、使用qemu仿真的。
分析过程主要是从run.sh入手,主要涉及到的源码文件有:

  • scripts/extractor.py:提取固件中的文件系统和操作系统内核。
  • scripts/makeImage.sh:创建qemu磁盘镜像,将固件文件系统进行修改,添加相关的工具、文件目录、设备等并写入到镜像中。
  • scripts/inferFile.sh:分析固件文件系统的启动项,分析文件系统中的http server。
  • scripts/fixImage.sh:修改文件系统,添加必要的目录,添加相关工具,根据文件系统中的应用程序创建相应的目录。

FirmAE创建qemu磁盘镜像的大概思路就是:

  1. 先从固件中提取文件系统和操作系统内核。
  2. 创建一个磁盘镜像,将其挂载,并将提取出来的文件系统复制到磁盘镜像中。
  3. 对挂载镜像的目录进行修改,分析文件系统启动项、http server等,并根据目标架构添加响应的工具和脚本到文件系统中。
  4. 写入磁盘镜像并取消挂载。

固件文件系统提取

分析run.sh,FirmAE会使用scripts/extractor.py解压出来固件中的文件系统和操作系统内核,将文件系统打包成tar.gz压缩包形式。extractor.py的主要原理是调用binwalk的API,根据UNIX目录规范对binwalk解压的目录进行搜索,找到其中的文件系统并压缩保存到images文件夹。

进一步分析qemu的镜像制作过程,主要逻辑在scripts/makeImage.sh中,因此随后的分析都是以makeImages.sh为主要代码进行分析,如果被这些代码调用绕糊涂了也可以不管具体的代码文件,看关键命令和关键流程的实现。

1
./scripts/makeImage.sh $IID $ARCH $FILENAME \

创建磁盘镜像

首先将压缩的文件系统从原来的"${TARBALL_DIR}/${IID}.tar.gz"复制到"${WORK_DIR}/${IID}.tar.gz",也就是从images目录复制到scratch目录中。复制后的文件系统压缩包使用完毕后,会被删除。原始的固件文件系统压缩包有且仅有一份在images目录中,如果要对FirmAE进行二次开发,则可以直接在该目录中去获取文件系统,就不用单独再用binwalk对固件进行解压。

1
cp "${TARBALL_DIR}/${IID}.tar.gz" "${WORK_DIR}/${IID}.tar.gz"

使用qemu-img创建磁盘镜像,磁盘镜像是存放在scratch目录下对应的固件文件夹(数字序号)中的image.raw。FirmAE管理固件的方式就是在images目录下存放固件中提取出来的文件系统和操作系统内核,在scratch目录下以数字递增的序号表示可被仿真的设备相关文件。
如下是从固件中提取的操作系统内核和固件文件系统压缩包:

1
2
3
4
5
6
7
8
9
10
$ tree -L 1 images
images
├── 1.kernel
├── 1.tar.gz
├── 2.kernel
├── 2.tar.gz
├── 3.kernel
└── 3.tar.gz

0 directories, 6 files

如下是scratch目录,包含了仿真过程中的一些标志文件,仿真状态例如web服务是否可达、是否可ping等。

1
2
3
4
5
6
7
8
9
10
11
12
13
$ tree -L 2 scratch
scratch
├── 1
│   ├── architecture
│   ├── brand
│   ├── current_init
│   ├── emulation.log
│   ├── fileList
│   ├── fileType
│   ├── image
│   ├── image.raw
│   ├── init
......

回归到镜像创建的过程中:

1
2
qemu-img create -f raw "${IMAGE}" 1G
chmod a+rw "${IMAGE}"

给磁盘镜像创建分区,完整示例命令如/sbin/fdisk /home/utest/app/FirmAE/scratch/3/image.raw,根据echo设置的输入,该命令大致的流程是:

  • o:创建一个空白的DOS分区表
  • n\np\n1:添加一个主分区primary,分区的数量设置为1
  • \n\n:创建第一个大小为2MB的扇区sector,剩下的作为第二个扇区。
  • w:将分区表写入到磁盘中
    1
    echo -e "o\nn\np\n1\n\n\nw" | /sbin/fdisk "${IMAGE}"
    使用fdisk查看磁盘镜像的属性如下:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    Device     Boot Start     End Sectors  Size Id Type
    image.raw1 2048 2097151 2095104 1023M 83 Linux

    Command (m for help): Disk image.raw: 1 GiB, 1073741824 bytes, 2097152 sectors
    Units: sectors of 1 * 512 = 512 bytes
    Sector size (logical/physical): 512 bytes / 512 bytes
    I/O size (minimum/optimal): 512 bytes / 512 bytes
    Disklabel type: dos
    Disk identifier: 0x88272c5c

    Device Boot Start End Sectors Size Id Type
    image.raw 2048 2097151 2095104 1023M 83 Linux

挂载磁盘镜像

然后就是调用firmae.config中的add_partition函数,将该磁盘镜像进行挂载,并返回挂载后的设备目录。如下,函数的主要功能就是将之前创建的镜像文件,使用losetup命令进行挂载,然后通过遍历、比较losetup命令的结果,来获取磁盘镜像挂载的目录:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
add_partition () {
local IFS=$'\n'
local IMAGE_PATH
local DEV_PATH=""
local FOUND=false

losetup -Pf ${1}
while (! ${FOUND})
do
sleep 1
for LINE in `losetup`
do
IMAGE_PATH=`echo ${LINE} | awk '{print $6}'`
if [ "${IMAGE_PATH}" = "${1}" ]; then
DEV_PATH=`echo ${LINE} | awk '{print $1}'`p1
if [ -e ${DEV_PATH} ]; then
FOUND=true
fi
fi
done
done

while (! ls -al ${DEV_PATH} | grep -q "disk")
do
sleep 1
done
echo ${DEV_PATH}
}

如下是使用losetup命令显示的结果,此时是仿真结束后的结果,所以不包含磁盘镜像的挂载目录,仅供参考:

1
2
3
4
5
6
7
8
9
10
11
$ losetup
NAME SIZELIMIT OFFSET AUTOCLEAR RO BACK-FILE DIO LOG-SEC
/dev/loop1 0 0 1 1 /var/lib/snapd/snaps/bare_5.snap 0 512
/dev/loop8 0 0 1 1 /var/lib/snapd/snaps/gnome-3-38-2004_115.snap 0 512
/dev/loop6 0 0 1 1 /var/lib/snapd/snaps/gtk-common-themes_1535.snap 0 512
/dev/loop4 0 0 1 1 /var/lib/snapd/snaps/gnome-3-38-2004_119.snap 0 512
/dev/loop2 0 0 1 1 /var/lib/snapd/snaps/core20_1778.snap 0 512
/dev/loop0 0 0 1 1 /var/lib/snapd/snaps/core20_1738.snap 0 512
/dev/loop7 0 0 1 1 /var/lib/snapd/snaps/snap-store_638.snap 0 512
/dev/loop5 0 0 1 1 /var/lib/snapd/snaps/snap-store_599.snap 0 512
/dev/loop3 0 0 1 1 /var/lib/snapd/snaps/snapd_17883.snap 0 512

如下是真实仿真过程中,命令的执行结果。可以看到最终返回了磁盘镜像的挂载目录/dev/loop9p1

1
2
3
4
5
6
7
8
9
10
++ IMAGE_PATH=/home/utest/app/FirmAE/scratch/3/image.raw
++ '[' /home/utest/app/FirmAE/scratch/3/image.raw = /home/utest/app/FirmAE/scratch/3/image.raw ']'
+++ echo '/dev/loop9 0 0 0 0 /home/utest/app/FirmAE/scratch/3/image.raw 0 512'
+++ awk '{print $1}'
++ DEV_PATH=/dev/loop9p1
++ '[' -e /dev/loop9p1 ']'
++ FOUND=true
......
++ echo /dev/loop9p1
+ DEVICE=/dev/loop9p1

创建文件系统

使用mkfs.ext2在镜像挂载目录创建ext2文件系统(实际上就是在磁盘镜像上创建文件系统)

1
mkfs.ext2 "${DEVICE}"

scratch的固件编号目录中创建一个image文件夹,例如mkdir /home/utest/app/FirmAE/scratch/3/image/,然后将该文件夹也挂载到之前镜像的挂载目录上,例如mount /dev/loop9p1 /home/utest/app/FirmAE/scratch/3/image/

1
2
3
4
5
6
7
8
9
echo "----Making QEMU Image Mountpoint----"
if [ ! -e "${IMAGE_DIR}" ]; then
mkdir "${IMAGE_DIR}"
chown "${USER}" "${IMAGE_DIR}"
fi

echo "----Mounting QEMU Image Partition----"
sync
mount "${DEVICE}" "${IMAGE_DIR}"

解压从固件中提取的文件系统。原本提取出来的被压缩的文件系统在FirmAE的根目录images中,先前是将压缩文件系统复制到了固件目录scratch/固件编号/中,因此解压完毕之后,需要将固件工作目录中的压缩文件系统删除。

1
2
3
echo "----Extracting Filesystem Tarball----"
tar -xf "${WORK_DIR}/$IID.tar.gz" -C "${IMAGE_DIR}"
rm "${WORK_DIR}/${IID}.tar.gz"

关键的执行命令如下:

1
2
mount /dev/loop9p1 /home/utest/app/FirmAE/scratch/3/image/
tar -xf /home/utest/app/FirmAE/scratch/3/3.tar.gz -C /home/utest/app/FirmAE/scratch/3/image/

至此,将相当于将固件中提取出来的文件系统给复制到了磁盘镜像中。

修改文件系统

在磁盘镜像中创建firmadyne相关的目录。FirmAE是在FirmAdyne的基础上进行二次开发的,因此沿用了后者的一些设计逻辑。如果是使用debug模式启动的FirmAE,可以进行模拟器的shell中,查看firmadyne中实际上包含了许多有用的工具和脚本,例如gdbservergdbstrace和完整版的busybox等。

1
2
3
mkdir "${IMAGE_DIR}/firmadyne/"
mkdir "${IMAGE_DIR}/firmadyne/libnvram/"
mkdir "${IMAGE_DIR}/firmadyne/libnvram.override/"

undifined

inferFile.sh

随后,将一个静态编译的bash-static复制到image目录中,例如cp /usr/bin/bash-static /home/utest/app/FirmAE/scratch/3/image/,然后在宿主机上使用chroot执行inferFile.sh,例如chroot /home/utest/app/FirmAE/scratch/3/image/ /bash-static /inferFile.sh
inferFile.sh的主要功能就是分析固件的文件系统中所包含的启动程序和http server。例如寻找启动程序preinitMTpreinitrcS等等,并将得到的结果写入到/firmadyne/init中,寻找http serveruhttpdhttpdgoaheadalphapdboalighttpd。这些都是IoT设备中常见的http server,如果在仿真模拟一个设备失败的时候不妨检查一下设备固件中的http server是否包含在其中。如下是inferFile.sh的代码,逻辑还是比较简单。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
BUSYBOX="/busybox"

${BUSYBOX} touch /firmadyne/init

if (${FIRMAE_BOOT}); then
arr=()
if [ -e /kernelInit ]; then
for FILE in `${BUSYBOX} strings ./kernelInit`
do
FULL_PATH=`${BUSYBOX} echo ${FILE} | ${BUSYBOX} awk '{split($0,a,"="); print a[2]}'`
arr+=("${FULL_PATH}")
done
fi
# kernel not handle this program
if [ -e /init ]; then
if [ ! -d /init ]; then
arr+=(/init)
fi
fi
for FILE in `${BUSYBOX} find / -name "preinitMT" -o -name "preinit" -o -name "rcS"`
do
arr+=(${FILE})
done

if (( ${#arr[@]} )); then
# convert to the unique array following the original order
uniq_arr=($(${BUSYBOX} tr ' ' '\n' <<< "${arr[@]}" | ${BUSYBOX} awk '!u[$0]++' | ${BUSYBOX} tr '\n' ' '))
for FILE in "${uniq_arr[@]}"
do
if [ -d ${FILE} ]; then
continue
fi
if [ ! -e ${FILE} ]; then # can't found original file (symbolic link or just file)
if [ -h ${FILE} ]; then # remove old symbolic link
${BUSYBOX} rm ${FILE}
fi
# find original program from binary directories
FILE_NAME=`${BUSYBOX} basename ${FILE}`
if (${BUSYBOX} find /bin /sbin /usr/sbin /usr/sbin -type f -exec ${BUSYBOX} grep -qr ${FILE_NAME} {} \;); then
TARGET_FILE=`${BUSYBOX} find /bin /sbin /usr/sbin /usr/sbin -type f -exec ${BUSYBOX} egrep -rl ${FILE_NAME} {} \; | ${BUSYBOX} head -1`
${BUSYBOX} ln -s ${TARGET_FILE} ${FILE}
else
continue
fi
fi
if [ -e ${FILE} ]; then
${BUSYBOX} echo ${FILE} >> /firmadyne/init
fi
done
fi
fi

${BUSYBOX} echo '/firmadyne/preInit.sh' >> /firmadyne/init

if (${FIRMAE_ETC}); then
if [ -e /etc/init.d/uhttpd ]; then
echo -n "/etc/init.d/uhttpd start" > /firmadyne/service
echo -n "uhttpd" > /firmadyne/service_name
elif [ -e /usr/bin/httpd ]; then
echo -n "/usr/bin/httpd" > /firmadyne/service
echo -n "httpd" > /firmadyne/service_name
elif [ -e /usr/sbin/httpd ]; then
echo -n "/usr/sbin/httpd" > /firmadyne/service
echo -n "httpd" > /firmadyne/service_name
elif [ -e /bin/goahead ]; then
echo -n "/bin/goahead" > /firmadyne/service
echo -n "goahead" > /firmadyne/service_name
elif [ -e /bin/alphapd ]; then
echo -n "/bin/alphapd" > /firmadyne/service
echo -n "alphapd" > /firmadyne/service_name
elif [ -e /bin/boa ]; then
echo -n "/bin/boa" > /firmadyne/service
echo -n "boa" > /firmadyne/service_name
elif [ -e /usr/sbin/lighttpd ]; then # for Ubiquiti firmwares
echo -n "/usr/sbin/lighttpd -f /etc/lighttpd/lighttpd.conf" > /firmadyne/service
echo -n "lighttpd" > /firmadyne/service_name
fi
fi

fixImage.sh

随后继续按照类似的逻辑chroot执行fixImage.sh,例如chroot /home/utest/app/FirmAE/scratch/3/image/ /busybox ash /fixImage.sh
fixImage.sh的主要功能是对镜像中的文件系统进行修补。
将静态编译的busybox复制到镜像的文件系统中:

1
2
3
4
5
if (${FIRMAE_BOOT}); then
if [ ! -e /bin/sh ]; then
${BUSYBOX} ln -s /firmadyne/busybox /bin/sh
fi
${BUSYBOX} ln -s /firmadyne/busybox /firmadyne/sh

在FirmAE论文中提到,使用FirmAdyne仿真模拟失败很大一部分原因是没有创建相应的文件夹例如/proc/tmp/var等等,此处就手动创建常见的文件夹。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
mkdir -p "$(resolve_link /proc)"
mkdir -p "$(resolve_link /dev/pts)"
mkdir -p "$(resolve_link /etc_ro)"
mkdir -p "$(resolve_link /tmp)"
mkdir -p "$(resolve_link /var)"
mkdir -p "$(resolve_link /run)"
mkdir -p "$(resolve_link /sys)"
mkdir -p "$(resolve_link /root)"
mkdir -p "$(resolve_link /tmp/var)"
mkdir -p "$(resolve_link /tmp/media)"
mkdir -p "$(resolve_link /tmp/etc)"
mkdir -p "$(resolve_link /tmp/var/run)"
mkdir -p "$(resolve_link /tmp/home/root)"
mkdir -p "$(resolve_link /tmp/mnt)"
mkdir -p "$(resolve_link /tmp/opt)"
mkdir -p "$(resolve_link /tmp/www)"
mkdir -p "$(resolve_link /var/run)"
mkdir -p "$(resolve_link /var/lock)"
mkdir -p "$(resolve_link /usr/bin)"
mkdir -p "$(resolve_link /usr/sbin)"

初次之外,还会分析应用程序中所依赖的目录,并进行创建:

1
2
3
4
5
6
7
8
9
  for FILE in `${BUSYBOX} find /bin /sbin /usr/bin /usr/sbin -type f -perm -u+x -exec ${BUSYBOX} strings {} \; | ${BUSYBOX} egrep "^(/var|/etc|/tmp)(.+)\/([^\/]+)$"`
do
DIR=`${BUSYBOX} dirname "${FILE}"`
if (! ${BUSYBOX} echo "${DIR}" | ${BUSYBOX} egrep -q "(%s|%c|%d|/tmp/services)");then
${BUSYBOX} echo "${DIR}" >> /firmadyne/dir_log
mkdir -p "$(resolve_link ${DIR})"
fi
done
fi

同样也会在/etc目录创建一些必要的文件,例如时区TZ、host文件、passwd。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
mkdir -p "$(resolve_link /etc)"
if [ ! -s /etc/TZ ]; then
mkdir -p "$(dirname $(resolve_link /etc/TZ))"
echo "EST5EDT" > "$(resolve_link /etc/TZ)"
fi

if [ ! -s /etc/hosts ]; then
mkdir -p "$(dirname $(resolve_link /etc/hosts))"
echo "127.0.0.1 localhost" > "$(resolve_link /etc/hosts)"
fi

if [ ! -s /etc/passwd ]; then
mkdir -p "$(dirname $(resolve_link /etc/passwd))"
echo "root::0:0:root:/root:/bin/sh" > "$(resolve_link /etc/passwd)"
fi

创建/dev目录下的一些设备:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
mkdir -p "$(resolve_link /dev)"
FILECOUNT="$($BUSYBOX find /dev -maxdepth 1 -type b -o -type c -print | $BUSYBOX wc -l)"
if [ $FILECOUNT -lt "5" ]; then
echo "Warning: Recreating device nodes!"

if (${FIRMAE_ETC}); then
TMP_BUSYBOX="/busybox"
else
TMP_BUSYBOX=""
fi

${TMP_BUSYBOX} mknod -m 660 /dev/mem c 1 1
${TMP_BUSYBOX} mknod -m 640 /dev/kmem c 1 2
${TMP_BUSYBOX} mknod -m 666 /dev/null c 1 3
${TMP_BUSYBOX} mknod -m 666 /dev/zero c 1 5
${TMP_BUSYBOX} mknod -m 444 /dev/random c 1 8
${TMP_BUSYBOX} mknod -m 444 /dev/urandom c 1 9
${TMP_BUSYBOX} mknod -m 666 /dev/armem c 1 13
......

添加实用工具到文件系统

这一步则主要是将针对目标架构静态编译的实用工具和实用脚本添加到/firmadyne目录中。如下,先判断目标系统的架构,然后复制静态编译的实用程序到/firmadyne目录。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
for BINARY_NAME in "${BINARIES[@]}"
do
BINARY_PATH=`get_binary ${BINARY_NAME} ${ARCH}`
cp "${BINARY_PATH}" "${IMAGE_DIR}/firmadyne/${BINARY_NAME}"
chmod a+x "${IMAGE_DIR}/firmadyne/${BINARY_NAME}"
done
mknod -m 666 "${IMAGE_DIR}/firmadyne/ttyS1" c 4 65

cp "${SCRIPT_DIR}/preInit.sh" "${IMAGE_DIR}/firmadyne/preInit.sh"
chmod a+x "${IMAGE_DIR}/firmadyne/preInit.sh"

cp "${SCRIPT_DIR}/network.sh" "${IMAGE_DIR}/firmadyne/network.sh"
chmod a+x "${IMAGE_DIR}/firmadyne/network.sh"

cp "${SCRIPT_DIR}/run_service.sh" "${IMAGE_DIR}/firmadyne/run_service.sh"
chmod a+x "${IMAGE_DIR}/firmadyne/run_service.sh"

cp "${SCRIPT_DIR}/injectionChecker.sh" "${IMAGE_DIR}/bin/a"
chmod a+x "${IMAGE_DIR}/bin/a"

touch "${IMAGE_DIR}/firmadyne/debug.sh"
chmod a+x "${IMAGE_DIR}/firmadyne/debug.sh"

if (! ${FIRMAE_ETC}); then
sed -i 's/sleep 60/sleep 15/g' "${IMAGE_DIR}/firmadyne/network.sh"
sed -i 's/sleep 120/sleep 30/g' "${IMAGE_DIR}/firmadyne/run_service.sh"
sed -i 's@/firmadyne/sh@/bin/sh@g' ${IMAGE_DIR}/firmadyne/{preInit.sh,network.sh,run_service.sh}
sed -i 's@BUSYBOX=/firmadyne/busybox@BUSYBOX=@g' ${IMAGE_DIR}/firmadyne/{preInit.sh,network.sh,run_service.sh}
fi

解除挂载

通过将镜像文件挂载到目录,写入文件系统,修改文件系统完毕后,则解除挂载。然后重新挂载镜像文件,并使用e2fsck进行检查,最后解除挂载。

1
2
3
4
5
6
7
8
9
10
echo "----Unmounting QEMU Image----"
sync
umount "${IMAGE_DIR}"
del_partition ${DEVICE:0:$((${#DEVICE}-2))}

DEVICE=`add_partition ${IMAGE}`
e2fsck -y ${DEVICE}
sync
sleep 1
del_partition ${DEVICE:0:$((${#DEVICE}-2))}

至此,将固件中的文件系统写入到磁盘镜像并修改相关文件以提高仿真成功率的工作完毕,得到一个qemu可使用的磁盘镜像。
那么回归到我之前的问题,如何在FirmAE仿真过程中修改固件的文件系统,此时就有如下的方案:

第一种方案是在文件系统从固件提取的过程中进行修改:

  1. extractor.py提取文件系统成功后,暂停,解压并修改文件系统,然后重新打包成压缩包形式
  2. 如果要http server不启动的话,则需要修改相关的判定标志,使得web检查通过或者取消web检查改为使用者手动启动http server

第二种方案是在创建磁盘镜像后,对磁盘镜像中的文件系统进行修改:

  1. 将磁盘镜像进行手动挂载到宿主机,然后进行修改
  2. 同样修改完毕后,如果不想http server启动,也需要修改源码中对web检查的部分。