【工具】Firmware-analysis-toolkit

之前使用 firmware-analysis-toolkit 一键式生成了固件的分析环境。这篇文章分析一下它的工作流程和原理。

1
./fat.py ../DIR-815A1_FW101SSB03.bin

从 fat.py 开始:

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
# fat.py
... ...

def main():
show_banner(). # 打印一个好看的 LOGO
parser = argparse.ArgumentParser()
parser.add_argument("firm_path", help="The path to the firmware image", type=str)
parser.add_argument("-q", "--qemu", metavar="qemu_path", help="The qemu version to use (must exist within qemu-builds directory). If not specified, the qemu version installed system-wide will be used", type=str)
args = parser.parse_args()

qemu_ver = args.qemu
qemu_dir = None
if qemu_ver:
... ...

image_id = run_extractor(args.firm_path) # important!

if image_id == "":
print ("[!] Image extraction failed")
else:
arch = identify_arch(image_id)
make_image(arch, image_id)
infer_network(arch, image_id, qemu_dir)
final_run(image_id, arch, qemu_dir)


if __name__ == "__main__":
main()

大致的流程就是:

  1. 提取镜像,返回镜像 id (第 16 行)

  2. 识别固件系统架构(21 行)

  3. 制作镜像 (22 行)

  4. 添加网络接口(23 行)

  5. 运行 QEMU 模拟(24 行)

run_extractor —— 固件文件系统提取

这个函数非常粗暴地调用了 firmadyne/sources/extractor/extractor.py 来分析。

执行的语句是:

1
firmadyne/sources/extractor/extractor.py -np -nk '../DIR-815A1_FW101SSB03.bin' './firmadyne/images'

这个 extractor.py 也是 firmadyne 的工具,用于提取文件系统:

  • -np :Disable parallel operation (may increase extraction time)

  • -nk :Disable extraction of kernel (may decrease extraction time)

所以意思就是不并行操作,也不提取内核,只要文件系统的意思吧。

执行完之后,就成功地在 firmadyne/images 下生成了一个压缩文件:

1
2
$ ls firmadyne/images/
DIR-815A1_FW101SSB03.bin_db2ac42c5d50fb5bd52999275f6d315b.tar.gz README.md

然后 FAT 将其重命名为 ${iid}.tar.gz ,即以依次命名为 1.tar.gz,2.tar.gz,后面来的以此类推。这个 iid 就是 main 函数中返回的 image_id 。

identify_arch —— 架构识别

这个函数也是类似的,直接调用 firmadyne/scripts/getArch.sh。

1
2
3
$ cd firmadyne/
$ scripts/getArch.sh images/1.tar.gz
./bin/busybox: mipsel

看起来,该脚本可以输出文件系统类型和架构。简单研究了脚本,它是先解压了 1.tar.gz,然后看解压生成的文件有没有包含 busybox,alpha 等字段,然后再去分析解压出来的文件是什么格式(用 file 命令)。具体细节不再研究了。总之就是能用。

make_image —— QEMU image 制作

同样的,是调用 firmadyne/scripts/makeImage.sh 实现的。

1
scripts/makeImage.sh 1 mipsel

这个比较重要,所以单步调试一下。

初始化

首先是加载 firmadyne.config 文件,里面包含了一些函数和环境变量。用到了再查。scripts 目录下的 bash 脚本都包含了这一部分。

1
2
3
4
5
6
7
8
9
#!/bin/bash
if [ -e ./firmadyne.config ]; then
source ./firmadyne.config
elif [ -e ../firmadyne.config ]; then
source ../firmadyne.config
else
echo "Error: Could not find 'firmadyne.config'!"
exit 1
fi

然后进行了一波检查,包括是不是 root 权限,以及传进来的架构(这里是 mipsel)是否合法。

下面就开始正式运行,先初始化一些变量,然后创建工作目录 frmadyne/scratch/${IID}

1
2
3
4
5
6
7
8
9
10
11
12
echo "----Running----"
WORK_DIR=`get_scratch ${IID}` # 这里找到了 firmadyne/scratch/${IID} 目录,不存在的话会创建
IMAGE=`get_fs ${IID}` # firmadyne/scratch/${IID}/image.raw
IMAGE_DIR=`get_fs_mount ${IID}` # firmadyne/scratch/${IID}/image/
CONSOLE=`get_console ${ARCH}` # firmadyne/binaries/console.mipsel ,暂时不清楚用途
LIBNVRAM=`get_nvram ${ARCH}` # firmadyne/binaries//libnvram.so.mipsel

echo "----Creating working directory ${WORK_DIR}----"
mkdir -p "${WORK_DIR}"
chmod a+rwx "${WORK_DIR}"
chown -R "${USER}" "${WORK_DIR}"
chgrp -R "${USER}" "${WORK_DIR}"

创建 image 并初始化分区表

然后准备创建 image。首先计算创建 image 的大小。条件是必须比 sizeof(1.tar.gz) + 10MB 大,也必须是 8388608 的倍数 (8 MB 的倍数),不知道这个是什么道理。反正这一段代码计算出来就是一个 size。

1
2
3
4
5
6
7
8
TARBALL_SIZE=$(tar ztvf "${TARBALL_DIR}/${IID}.tar.gz" --totals 2>&1 |tail -1|cut -f4 -d' ')  # 这个就是 1.tar.gz 的大小
MINIMUM_IMAGE_SIZE=$((TARBALL_SIZE + 10 * 1024 * 1024))
echo "----The size of root filesystem '${TARBALL_DIR}/${IID}.tar.gz' is $TARBALL_SIZE-----"
IMAGE_SIZE=8388608
while [ $IMAGE_SIZE -le $MINIMUM_IMAGE_SIZE ]
do
IMAGE_SIZE=$((IMAGE_SIZE*2))
done

创建 image,并创建分区表。

1
2
3
4
5
6
7
8
9
10
echo "----Creating QEMU Image ${IMAGE} with size ${IMAGE_SIZE}----"
qemu-img create -f raw "${IMAGE}" $IMAGE_SIZE
chmod a+rw "${IMAGE}"

echo "----Creating Partition Table----"
echo -e "o\nn\np\n1\n\n\nw" | /sbin/fdisk "${IMAGE}"

echo "----Mounting QEMU Image----"
DEVICE=$(get_device "$(kpartx -a -s -v "${IMAGE}")") # '/dev/mapper/loop25p1'
sleep 1

这个分区表我没太看懂。

echo -e 的意思是后面的字符串可以使用转义字符。我简单搜索了 fdisk 的用法,发现这个和代码里的完全对应:https://superuser.com/questions/332252/how-to-create-and-format-a-partition-using-a-bash-script

For example, the following clears the partition table, if there is one, and makes a new one that has a single partition that is the; entire disk:
(
echo o # Create a new empty DOS partition table
echo n # Add a new partition
echo p # Primary partition
echo 1 # Partition number
echo # First sector (Accept default: 1)
echo # Last sector (Accept default: varies)
echo w # Write changes
) | sudo fdisk

总之就是清除分区表,然后创建一个单分区的新分区表。总觉得之前没有这个操作也没啥问题。

挂载镜像并写入文件系统

这个 get_device 我也看了半天,搜索了一下 kpartx 命令。我个人的理解是,对于多分区的磁盘镜像,不能直接挂载,需要将其先用 kpartx -av 处理,在 /dev/mapper 下生成设备,然后再将该设备 mount 到某个目录下,这样就可以直接操作这个分区的数据了。(参考 http://blog.chinaunix.net/uid-1911213-id-3387288.htmlhttps://www.bbsmax.com/A/RnJW0eWR5q/

这里先执行了 $(kpartx -a -s -v "${IMAGE}") ,在 /dev/mapper 下创建了 loop25p1 设备。输出的内容是:'add\ map\ loop25p1\ \(253:5\):\ 0\ 63488\ linear\ 7:25\ 2048' 。所以 get_device 执行的 echo "/dev/mapper/$(echo $1 | cut -d ' ' -f 3)" 就是相当于 python 的 s.split(' ')[2] ,恰好把设备名 loop25p1 取出来。

随后创建文件系统,并挂载该设备,挂载到 frmadyne/scratch/${IID}/image/

1
2
3
4
5
6
7
8
9
10
11
12
echo "----Creating Filesystem----"
mkfs.ext2 "${DEVICE}"
sync # 用于刷新缓冲区

echo "----Making QEMU Image Mountpoint at ${IMAGE_DIR}----"
if [ ! -e "${IMAGE_DIR}" ]; then
mkdir "${IMAGE_DIR}"
chown "${USER}" "${IMAGE_DIR}"
fi

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

然后就是常规地将文件系统写入进去,这里不仅解压了 1.tar.gz,还拷贝了 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
echo "----Extracting Filesystem Tarball to Mountpoint----"
tar -xf "${TARBALL_DIR}/${IID}.tar.gz" -C "${IMAGE_DIR}"

echo "----Creating FIRMADYNE Directories----"
mkdir "${IMAGE_DIR}/firmadyne/"
mkdir "${IMAGE_DIR}/firmadyne/libnvram/"
mkdir "${IMAGE_DIR}/firmadyne/libnvram.override/"

echo "----Patching Filesystem (chroot)----"
cp $(which busybox) "${IMAGE_DIR}"
cp "${SCRIPT_DIR}/fixImage.sh" "${IMAGE_DIR}"
chroot "${IMAGE_DIR}" /busybox ash /fixImage.sh
rm "${IMAGE_DIR}/fixImage.sh"
rm "${IMAGE_DIR}/busybox"

echo "----Setting up FIRMADYNE----"
cp "${CONSOLE}" "${IMAGE_DIR}/firmadyne/console"
chmod a+x "${IMAGE_DIR}/firmadyne/console"
mknod -m 666 "${IMAGE_DIR}/firmadyne/ttyS1" c 4 65

cp "${LIBNVRAM}" "${IMAGE_DIR}/firmadyne/libnvram.so"
chmod a+x "${IMAGE_DIR}/firmadyne/libnvram.so"

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

这里需要注意的是,13 行使用 chroot 来执行了 fixImage.sh 。才疏学浅我太能理解它的功能,看起来像是修复文件系统的部分内容,比如软链接,比如 /etc 目录的部分文件。至于为什么要用 chroot 和 busybox 我就不明白了。先不管。

最后收尾,unmount 并删除 device mapper:

1
2
3
4
5
6
7
echo "----Unmounting QEMU Image----"
sync
umount "${DEVICE}"
echo "----Deleting device mapper----"
kpartx -d "${IMAGE}"
losetup -d "${DEVICE}" &>/dev/null
dmsetup remove $(basename "$DEVICE") &>/dev/null

infer_network —— 网络添加

调用 firmadyne/scripts/inferNetwork.sh 实现:

1
2
cd firmadyne
scripts/inferNetwork.sh 1 mipsel

这个部分也比较重要,所以也单步调试一下。inferNetwork.sh 的重点只有几行:

1
2
3
4
5
6
7
8
9
10
... ...

echo "Running firmware ${IID}: terminating after 60 secs..."
timeout --preserve-status --signal SIGINT 60 "${SCRIPT_DIR}/run.${ARCH}.sh" "${IID}"
sleep 1

echo "Inferring network..."
"${SCRIPT_DIR}/makeNetwork.py" -i "${IID}" -q -o -a "${ARCH}" -S "${SCRATCH_DIR}"

echo "Done!"

timeout --preserve-status --signal SIGINT 60 将保持运行 60s,超时后发送 sigint。运行的脚本是 run.mipsel.sh,其实内容就是启动 qemu 的语句。其实这里我还不太理解,添加网卡为啥要先启动 qemu。但是看到后面 makeNetwork.py 的时候就知道了。(此外,timeout 运行会阻塞。)

1
2
3
# scripts/run.mipsel.sh
... ...
qemu-system-mipsel -m 256 -M malta -kernel ${KERNEL} -drive if=ide,format=raw,file=${IMAGE} -append "firmadyne.syscall=1 root=/dev/sda1 console=ttyS0 nandsim.parts=64,64,64,64,64,64,64,64,64,64 rdinit=/firmadyne/preInit.sh rw debug ignore_loglevel print-fatal-signals=1" -serial file:${WORK_DIR}/qemu.initial.serial.log -serial unix:/tmp/qemu.${IID}.S1,server,nowait -monitor unix:/tmp/qemu.${IID},server,nowait -display none -netdev socket,id=s0,listen=:2000 -device e1000,netdev=s0 -netdev socket,id=s1,listen=:2001 -device e1000,netdev=s1 -netdev socket,id=s2,listen=:2002 -device e1000,netdev=s2 -netdev socket,id=s3,listen=:2003 -device e1000,netdev=s3

然后又套娃,运行 python 脚本 makeNetwork.py 来创建网卡:

1
2
cd firmadyne/
scripts/makeNetwork.py -i 1 -q -o -a mipsel -S scratch/
  • -i :传入的 iid,和前面的 ${iid}.tar.gz 对应;

  • -q :makeQemuCmd = True

  • -o :oufile = True,指的是生成 scratch/${iid}/run.sh 脚本

  • -s :scratch dir

主要的功能由 process() 函数负责。下面分析 process 函数的代码:

1
2
3
4
5
6
7
8
9
10
11
12
# makeNetwork.py
... ...

def process(infile, iid, arch, endianness=None, makeQemuCmd=False, outfile=None):
... ...
data = open(infile).read()
... ...
#find interfaces with non loopback ip addresses
ifacesWithIps = findNonLoInterfaces(data, endianness)

#find changes of mac addresses for devices
macChanges = findMacChanges(data, endianness)

这里的这个 infile 是 firmadyne/scratch/1/qemu.initial.serial.log ,也就是前一步运行 qemu 时生成的日志文件。它记录了 qemu 的输出。findNonLoInterfaces 和 findMacChanges 函数都用到了这个日志文件。

text
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
[    0.000000] Linux version 2.6.39.4+ (ddcc@ddcc-virtual) (gcc version 5.3.0 (GCC) ) #2 Tue Sep 1 18:11:28 EDT 2020
[ 0.000000] bootconsole [early0] enabled
[ 0.000000] CPU revision is: 00019300 (MIPS 24Kc)
[ 0.000000] FPU revision is: 00739300
[ 0.000000] Determined physical RAM map:
[ 0.000000] memory: 00001000 @ 00000000 (reserved)
[ 0.000000] memory: 000ef000 @ 00001000 (ROM data)
[ 0.000000] memory: 0065e000 @ 000f0000 (reserved)
[ 0.000000] memory: 0f8b2000 @ 0074e000 (usable)
[ 0.000000] debug: ignoring loglevel setting.
[ 0.000000] Wasting 59840 bytes for tracking 1870 unused pages
[ 0.000000] Initrd not found or empty - disabling initrd
[ 0.000000] Zone PFN ranges:
[ 0.000000] DMA 0x00000000 -> 0x00001000
[ 0.000000] Normal 0x00001000 -> 0x00010000
[ 0.000000] Movable zone start PFN for each node
... ...

找到网卡设备

先看 findNonLoInterfaces 函数,字面意思是找到非 lo 网卡。通过分析,找到了两张网卡 br0 和 br1。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# Get the network interfaces in the router, except 127.0.0.0/8
def findNonLoInterfaces(data, endianness):
#lines = data.split("\r\n")
lines = stripTimestamps(data)
# 先找到以 "__inet_insert_ifa" 开头的行
candidates = filter(lambda l: l.startswith("__inet_insert_ifa"), lines)

... ...

for c in candidates:
# eg. '__inet_insert_ifa[PID: 56 (ifconfig)]: device:lo ifa:0x0100007f'
g = re.match(r"^__inet_insert_ifa\[[^\]]+\]: device:([^ ]+) ifa:0x([0-9a-f]+)", c)
if g:
# eg. iface=lo, addr=0100007f
# 这个 addr 其实就是 127.0.0.1 的 4 字节表示
(iface, addr) = g.groups()
addr = socket.inet_ntoa(struct.pack(fmt, int(addr, 16)))
if (not addr.startswith("127.")) and addr != "0.0.0.0":
result.append((iface, addr))
# [('br0', '192.168.0.1'), ('br1', '192.168.100.1')]
return result

下面是 findMacChanges 函数,功能也是类似的,看起来是找到 ioctl 相关的内容。不过对于这个固件来说没有找到。先不管了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def findMacChanges(data, endianness):
lines = stripTimestamps(data)
candidates = filter(lambda l: l.startswith("ioctl_SIOCSIFHWADDR"), lines)
... ...

for c in candidates:
g = re.match(r"^ioctl_SIOCSIFHWADDR\[[^\]]+\]: dev:([^ ]+) mac:0x([0-9a-f]+) 0x([0-9a-f]+)", c)
if g:
(iface, mac0, mac1) = g.groups()
m0 = struct.pack(fmt, int(mac0, 16))[2:]
m1 = struct.pack(fmt, int(mac1, 16))
mac = "%02x:%02x:%02x:%02x:%02x:%02x" % struct.unpack("BBBBBB", m0+m1)
result.append((iface, mac))
return result

识别网络信息

再回到 process 函数,下面对每个 iwi 进行操作 for iwi in [('br0', '192.168.0.1'), ('br1', '192.168.100.1')] 。找到和这两个网卡桥接的网卡,添加到 network 集合中。

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
# in process().

#find interfaces with non loopback ip addresses
ifacesWithIps = findNonLoInterfaces(data, endianness)

#find changes of mac addresses for devices
macChanges = findMacChanges(data, endianness)

print("Interfaces: %r" % ifacesWithIps)

deviceHasBridge = False
for iwi in ifacesWithIps:
# 找到和指定网卡桥接的网卡。
# eg. 对于 br0,返回和 br0 桥接的网卡名字,例如 [eth0, ]
brifs = findIfacesForBridge(data, iwi[0])
if debug:
print("brifs for %s %r" % (iwi[0], brifs))
for dev in brifs:
#find vlan_ids for all interfaces in the bridge
vlans = findVlanInfoForDev(data, dev)
#create a config for each tuple
network.add((buildConfig(iwi, dev, vlans, macChanges)))
deviceHasBridge = True

#if there is no bridge just add the interface
if not brifs and not deviceHasBridge:
vlans = findVlanInfoForDev(data, iwi[0])
network.add((buildConfig(iwi, iwi[0], vlans, macChanges)))

添加网络设备

最后,network 里只添加了一张网卡信息:{('192.168.0.1', 'eth0', None, None)}

这个是固件暴露在外的网卡。我们要想访问这个被模拟的固件,就需要添加一条路由信息,到达这个 192.168.0.1 。qemuCmd 函数负责生成的 run.sh 的内容里包含下面语句:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# run.sh
... ...

TAPDEV_0=tap${IID}_0
HOSTNETDEV_0=${TAPDEV_0}
echo "Creating TAP device ${TAPDEV_0}..."
sudo tunctl -t ${TAPDEV_0} -u ${USER}


echo "Bringing up TAP device..."
sudo ip link set ${HOSTNETDEV_0} up
sudo ip addr add 192.168.0.2/24 dev ${HOSTNETDEV_0}

echo "Adding route to 192.168.0.1..."
sudo ip route add 192.168.0.1 via 192.168.0.1 dev ${HOSTNETDEV_0}

这里涉及 tap 设备的概念:

https://zhuanlan.zhihu.com/p/388742230
在计算机网络中,TUN 与 TAP 是操作系统内核中的虚拟网络设备
。不同于普通靠硬件网路板卡实现的设备,这些虚拟的网络设备全部由软件实现,并向运行于操作系统上的软件提供与硬件的网络设备完全相同的功能。 TAP 等同于一个以太网设备,它操作第二层数据包如以太网数据帧。TUN 模拟了网络层设备,操作第三层数据包比如 IP 数据封包。
相比于物理网卡负责内核网络协议栈和外界网络之间的数据传输,虚拟网卡的两端则是内核网络协议栈和用户空间它负责在内核网络协议栈和用户空间的程序之间传递数据:

创建了 tap 设备之后,还把它作为参数传递给 qemu

1
-netdev tap,id=nettap0,ifname=${TAPDEV_0},script=no -device e1000,netdev=nettap0

关于 qemu 网络设备的使用,我还比较迷茫。先贴几个链接以后看看。


final_run —— 启动 QEMU

直接运行前面生成的 scratch/${iid}/run.sh 。