前文详细介绍2020 Coremail钓鱼邮件识别及分析内容。这篇文章是作者2022年参加清华大学、奇安信举办的DataCon比赛,主要是关于涉网FZ分析,包括恶意样本IOC自动化提取和攻击者画像分析两类题目。这篇文章来自L师妹的Writeup,经同意后分享给大家,推荐大家多关注她的文章,也希望对您有所帮助。非常感谢举办方让我们学到了新知识,DataCon也是我比较喜欢和推荐的大数据安全比赛,我连续参加过四届,很幸运,我们团队近年来获得过第1、2、4、6、7、8名,不过也存在很多遗憾,希望更多童鞋都参加进来!感恩同行,不负青春,且看且珍惜!
作者的github资源:
作者作为网络安全的小白,分享一些自学基础教程给大家,主要是关于安全工具和实践操作的在线笔记,希望您们喜欢。同时,更希望您能与我一起操作和进步,后续将深入学习网络安全和系统安全知识并分享相关实验。总之,希望该系列文章对博友有所帮助,写文不易,大神们不喜勿喷,谢谢!如果文章对您有帮助,将是我创作的最大动力,点赞、评论、私聊均可,一起加油喔!
声明:本人坚决反对利用教学方法进行犯罪的行为,一切犯罪行为必将受到严惩,绿色网络需要我们共同维护,更推荐大家了解它们背后的原理,更好地进行防护。(参考文献见后)
一.题目介绍
DataCon官网题目:
目标:实现一个提取恶意样本IOC信息的自动化工具,即针对跨架构的Mirai僵尸网络,提取加密字符串、C2的host/port。
题目提供了967个Mirai二进制样本,其架构分布如下:
针对以上样本,具体要求如下:
Mirai家族是针对IOT设备的僵尸网络,运行在受感染设备的bot源码主要分为以下四个模块:
因此,为提取加密字符串和C2的host/port,将重点分析字符串加解密函数(public模块table.c的相关函数)、host/port的设置函数(main.c中resolve_cnc_addr
函数)
为增大逆向分析的难度,Mirai对使用的字符串加密写入数组 table
中,其结构定义如下:
struct table_value table[TABLE_MAX_KEYS];
struct table_value {
char *val; //字符串指针
uint16_t val_len; //字符串长度
BOOL locked; //字符串是否加密
};
table.c中共有以下五个函数。首先,调用table_init
初始化所有table中的字符串成员,针对每个字符串,调用add_entry
添加到table中。在使用table[id]对应字符串前,调用table_unlock_val
对table[id]解密。使用时,调用table_retrieve_val
取出table[id]对应的字符串。使用完后,调用table_lock_val
对table[id]重新加密。其中,加解密函数调用了toggle_obf
对字符串进行异或操作。
void table_init(void); //初始化table中的成员
void table_unlock_val(uint8_t id); //解密table中对应id的成员
void table_lock_val(uint8_t id); //加密table中对应id的成员
char *table_retrieve_val(int id, int *len); //取出table中对应id的成员
static void add_entry(uint8_t id, char *buf, int buf_len); //向table中添加成员
static void toggle_obf(uint8_t id); //和密钥key异或,即加解密table中的字符串
其中,toggle_obf
函数的实现如下,即对字符串的每个字节,使用密钥table_key
的每个字节和它进行异或。由于是异或操作,加密函数table_lock_val
和解密函数table_unlock_val
完全相同。
static void toggle_obf(uint8_t id)
{
int i;
struct table_value *val = &table[id];
uint8_t k1 = table_key & 0xff,
k2 = (table_key >> 8) & 0xff,
k3 = (table_key >> 16) & 0xff,
k4 = (table_key >> 24) & 0xff;
for (i = 0; i < val->val_len; i++)
{
val->val[i] ^= k1;
val->val[i] ^= k2;
val->val[i] ^= k3;
val->val[i] ^= k4;
}
#ifdef DEBUG
val->locked = !val->locked;
#endif
}
对应PPT介绍如下:
为防止动态调试,bot设置了SIGTRAP信号处理函数anti_gdb_entry
,并raise(SIGTRAP)
主动触发该信号。
anti_gdb_entry
函数,该函数的功能很简短,即把实际C2 host/port的设置函数resolve_cnc_addr
赋值给函数指针resolve_func。static void anti_gdb_entry(int sig)
{
resolve_func = resolve_cnc_addr;
}
在与C2通信时,调用resolve_cnc_addr
函数设置srv_addr
变量。首先从table中获取域名,调用resolv_lookup向DNS服务器查询其可能的IP,设置sin_addr
整型IP(4字节)。再从table中获取端口,设置sin_port
(2字节)
struct sockaddr_in srv_addr;
struct resolv_entries {
uint8_t addrs_len;
ipv4_t *addrs;
};
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
}
对应PPT介绍如下:
观察到,.rodata节中有一系列加密字符串。
查看这些加密字符串的引用,可以找到table_init
函数。该函数很长,不断地分配内存和add_entry
,且只有一个基本块。通过分析其它样本,这里需要注意的是:
add_entry
时,.bss段中数组table
的顺序和.rodata中字符串列表rodataTable
中的顺序并不一致table.val_len
决定table
的id是以1开始的查看table
的引用,发现有4个函数使用了该变量。有两个函数完全相同,即table_lock_val
和table_unlock_val
,另外一个则是table_retrieve_val
。
查看table_lock_val
,它访问了.data节中的key
。
还原加解密算法,于是可以对.rodata中的字符串解密,得到字符串明文configs如下:
对应PPT介绍如下:
从源码分析中可知,anti_gdb_entry
函数很短,就一个赋值语句,可按长度和操作码对其筛选如下:
于是可以找到resolve_cnc_addr
如下。该函数设置了0x22C7C处的srv_addr
的变量。
srv_addr
变量的类型是0x10字节的struct sockaddr_in
,定义如下:
struct sockaddr_in {
short int sin_family;
unsigned short int sin_port; //2字节
struct in_addr sin_addr; //整型IP,4字节
unsigned char sin_zero[8];
};
srv_addr+0x2
为port,srv_addr+0x4
为整型ip。这里的ip使用的是局部变量0xAB92572F,port是table[1]中的内容b’\x05\x16’。由于网络字节序是大端字节序,因此host为47.xx.xx.171,port为1302。
附件3可以使用upx.exe成功脱壳,附件4的壳无法识别。
UPX壳包含以下两个关键的结构体:
struct l_info // 12-byte trailer in header for loader
{
uint32_t l_checksum; // checksum
uint32_t l_magic; // UPX! magic [55 50 58 21]
uint16_t l_lsize; // loader size
uint8_t l_version; // version info
uint8_t l_format; // UPX format
};
struct p_info // 12-byte packed program header follows stub loader
{
uint32_t p_progid; // program header id [00 00 00 00]
uint32_t p_filesize; // filesize [same as blocksize]
uint32_t p_blocksize; // blocksize [same as filesize]
};
upx.exe在识别壳、脱壳时,需满足以下3个规则:
l_info.l_version
、l_info.l_format
3)尾部-0x20
的2字节:版本和格式信息均相等l_info.l_magic
2)尾部-0x24
3)尾部-0x2C
的4字节:均有l_magic(UPX!)p_info.p_filesize
2)头部p_info.p_blocksize
3)尾部-0xC
的4字节:均相等(尾部上面一个magic的偏移不一定是-0x2C
,可能是-0x29
到-0x2F
附件4如下:
发现尾部-0x20
的2字节和附件3一样,应该是UPX版本和格式相同,然后搜索"0D 17",发现开头也有,于是可以还原UPX头部和尾部的格式。
根据上述提到的3个规则,将0填充对应值,还原后如下:
于是可以使用upx.exe脱壳
自动化包括脱壳、提取解密字符串、提取C2三个阶段。
(1)识别UPX壳,满足
version_format
(0D 17或0D 0C)(2)修复UPX壳
version_format
定位UPX头部,最后一个定位UPX尾部version_format
,去掉尾部多余字符p_info.p_filesize
和p_info.p_blocksize
(3)使用 upx.exe脱壳
基本思路如下图所示:
(1)找初始化函数 init_table
特征:(从小到大遍历所有函数,找到满足以下两点的最长函数)
部分样本的
init_table
函数内嵌在了main函数里,也可将找初始化函数init_table
转换为找相同初始化功能的基本块。
(2)记录密文字符串 rodataTable_addrs
分析init_table
函数:
rodataTable_addrs
table_addrs
,从而确定table
的起始地址strlens
(malloc函数的参数)部分样本的
init_table
函数中调用了add_entry
函数,在init_table
中只有table元素的偏移,得在add_entry
中找table
的起始地址。
(3)找解密函数 table_unlock_val
查看table
的函数引用,两个完全相同的函数为加解密函数。同时,确定table_retrieve_val
函数。
a) 部分样本中,table的引用还有两个函数,则还有
toggle_obf
函数,且table_retrieve_val
和toggle_obf
存在调用关系,可以确定调用函数是table_retrieve_val
,被调函数是toggle_obf
。 b) 部分样本没有其它引用table的函数,则table以立即数写入,需逐函数查找。
(4)记录解密密钥 key
分析table_unlock_val
函数,记录.data段中的解密密钥key
。
(5)解密得到configs
对rodataTable_addrs
的密文字符串,使用密钥key
进行异或解密,得到明文字符串。至于configs中的地址为table_addrs
。
以上重要参数基本上以立即数出现、指令相对统一和简单,并且没有初步的分析不便于设置模拟执行的相关地址和参数,因此在该步骤中仅使用IDAPython分析指令,得到相关函数和变量的地址。
基本思路如下图所示:
总体思路是,通过anti_gdb_entry
函数,找到resolve_cnc_addr
函数,该函数中包含C2 host/port的srv_addr
结构。模拟执行init_table
、resolve_cnc_addr
函数,读取srv_addr
中的ip、port。因此,可分为查找相关函数和变量、模拟执行获取host/port两步。
第一步,找信号处理函数 anti_gdb_entry
、C2地址设置函数 resolve_cnc_addr
。
anti_gdb_entry
特征:(从小到大遍历所有函数,找到满足以下两点的函数)
resolve_func
函数指针、一个是.text节的resolve_cnc_addr
函数地址)a) 部分样本没有找到
anti_gdb_entry
函数,但是通过分析样本发现,一些resolve_cnc_addr
在设置host/port时,会调用table_retrieve_val(1)
。因此,可进一步查看table_retrieve_val
的引用,找到参数为1的函数即为resolve_cnc_addr
。 b) 部分样本的resolve_cnc_addr
函数内嵌在了main函数里,也可将找初始化函数resolve_cnc_addr
转换为找类似功能的基本块。
第二步,找地址变量 srv_addr
。
分析resolve_cnc_addr
函数,记录.bss中的所有变量,包括srv_addr
、srv_addr+0x2
处的port、srv_addr+0x4
处的ip,取最小值即为srv_addr
。
部分样本还包含
srv_addr-0x10
的地址,但没有对其0x10范围内的其它引用,可以过滤掉。
下面代码中,以ARM架构为例。
第一步,设置内存布局
ADDRESS = idaapi.get_imagebase()
mu = Uc(UC_ARCH_ARM, UC_MODE_LITTLE_ENDIAN)
mu.mem_map(ADDRESS, 2 * 1024 * 1024)
for segm_ea in idautils.Segments():
segm = idaapi.getseg(segm_ea)
data = idc.get_bytes(segm.start_ea, segm.size())
mu.mem_write(segm.start_ea, data)
mu.mem_map(STACK_ADDR, STACK_SIZE)
mu.reg_write(UC_ARM_REG_SP, STACK_ADDR + (STACK_SIZE//2))
unicorn_heap
记录所有已分配UserData的起始地址,最后一项为最后一个UserData的结束地址。mu.mem_map(HEAP_ADDR, HEAP_SIZE)
unicorn_heap = [HEAP_ADDR]
第二步,设置hook函数 针对以下三类函数和指令,需设置相应的hook。
malloc
函数
针对静态链接,由于库函数代码基本不变且代码较长,可根据函数大小进行筛选,得到hook的地址。针对动态链接,由于只在模拟执行init_table
时会遇到,可在init_table
调用.plt中的地址时进行hook。
该函数是分配内存,hook以后,利用unicorn_heap
返回UserData地址即可。 ret = mu.reg_read(UC_ARM_REG_R14)
mu.reg_write(UC_ARM_REG_PC, ret)
arg0 = mu.reg_read(UC_ARM_REG_R0) #malloc请求UserData的大小
addr = unicorn_heap[-1] #在unicorn_heap中分配内存
unicorn_heap.append(unicorn_heap[-1] + arg0) #更新最后一个块的结束地址
mu.reg_write(UC_ARM_REG_R0, addr) #返回分配的内存地址
resolve_lookup
函数
由于是Mirai的公共函数,大小是固定的几个且代码较长,可根据函数大小进行筛选,得到hook的地址。
该函数是将域名转为IP,hook以后,根据函数的参数,得到域名字符串的地址。如果该地址在.rodata中,直接提取即可。如果该地址在unicorn_heap
堆中,可根据UserData的分配顺序,得到域名在configs中的索引ip_domain_index
,从而提取host的域名。同时,为避免后面的代码在使用struct resolv_entries *entries;
时发生内存访问错误,需简单伪造一下结构体内容。ret = mu.reg_read(UC_ARM_REG_R14)
mu.reg_write(UC_ARM_REG_PC, ret)
arg0 = mu.reg_read(UC_ARM_REG_R0)
if arg0 in unicorn_heap:
ip_domain_index = unicorn_heap.index(arg0)
if len(configs) > ip_domain_index:
ip_domain = configs[ip_domain_index][1]
ip_domain = ''.join([chr(i) for i in ip_domain])
elif idc.get_segm_name(arg0) == '.rodata':
ip_domain = read_str_until_zero(arg0)
entries_ptr = unicorn_heap[-1] #指向entries
unicorn_heap.append(unicorn_heap[-1] + 0x8)
addrs_ptr = unicorn_heap[-1] #指向entries.addrs
unicorn_heap.append(unicorn_heap[-1] + 0x4)
entries = p32(1) + p32(addrs_ptr)
addrs = p32(0x01020304)
mu.mem_write(entries_ptr, entries + addrs)
mu.reg_write(UC_ARM_REG_R0, entries_ptr)
第三步,模拟执行init_table
起始地址为init_table.start_ea
,结束地址为init_table.end_ea
的函数返回指令。
第四步,模拟执行 resolve_cnc_addr
起始地址为resolve_cnc_addr.start_ea
,结束地址为最后一次修改srv_addr
的下一条指令。
第五步,读取ip/port
如果没有调用resolve_lookup
函数,则host以ip的形式出现,读取srv_addr+4
的内存即为ip。port为srv_addr+2
的内存内容。
基本思路如下图所示:
通过人工分析json对应样本和签到题可以发现,提取configs的关键函数是init_table
,提取host/port的关键函数是resolve_cnc_addr
。通过模拟执行这两个函数,将字符串的初始化、加解密、提取都交给unicorn,然后读取srv_addr
处的内容,能有效提升自动化的准确率,降低IDAPython静态分析的复杂度。
最后,感谢老师和实验室所有小伙伴,感谢DotaCon团队,感谢师妹师弟,感谢DataCon为我们提供这么好的比赛。基础性文章,希望对您有所帮助,感恩遇见,不负青春!
欢迎大家讨论,是否觉得这系列文章帮助到您!任何建议都可以评论告知读者,共勉。
参考链接:
前文回顾(下面的超链接可以点击喔):