0%

一例 APT28 样本分析

APT28 比较经典的一个样本,拿来练练手。本文参考了 [1]、[2] 两篇文章,主要是动手实践一遍,另外再加上一些自己的理解。(样本来源样本基本信息

1 初步静态分析

查壳,无壳。查看字符串,没有特别值得注意的字符串,有一些函数名。查看导入函数,导入了三个 DLL。

1.1 advapi32.dll

image-20220427215028652

中间的三个函数是用来 enable 特权的。在这里顺便复习一下特权(privilege)的概念。特权是指一个账户执行某个与系统相关的操作的能力,比如关闭计算机对应的特权就是 SeShutdownPrivilege。特权是可以被允许(enable)和禁止(disable)的。账户登录后获得的令牌中含有一项特权列表,用来表示该账户拥有的特权,以及特权的允许、禁止情况。通过 whoami /priv 查看,管理员账户在 UAC 之前(被过滤的受限令牌)的特权为:

image-20220428223407262

UAC 权限提升后的特权为:

image-20220428223440949

有些特权是默认禁用的(disabled),这是因为一般在正常情况下不需要用到这种特权,出于安全的目的就将特权禁用了。要使用这种特权对应的能力,就要先将 disabled 的特权 enable。但是 enable 的时候,只能把 disabled 变成 enabled,不能无中生有。所以,如果使用 UAC 之前的管理员账户,想要 enable 管理审核和安全日志的特权(SeSecurityPrivilege)是不可以的,因为受限令牌根本没有该特权。想要使用管理审核和安全日志的特权,必须以管理员身份启动程序,手动 enable 该特权,再使用相应的功能。通过 enable 特权来将普通权限提升至管理员权限也是不行的,这是两个完全不同的概念!

接下来看看剩下的两个导入函数 GetSidSubAuthorityCount 和 GetSidSubAuthority,这两个 API 不太熟,看名字应该和 SID 有关。首先复习一下代表用户的 SID 的结构。一个 SID 的组成为 S-R-X-Y1-Y2…Yn-1-Yn

  • SID 开头的字母 “S” 表示该字符串为 SID
  • R 表示 SID 结构的版本号(Revision),Windows 系统中都是 1
  • X 表示标识符颁发机构(Identifier Authority)。Windows 特定的账户/组,如 Administrators,该值为 5,Everyone 等通用的账户/组,该值为 1
  • 剩下的 Y 都是 Subauthority。其中 Y1 到 Yn-1 表示各子级颁发机构(Domain Identifier),标识各级不同的域。Yn 表示域内特定的账户和组,称为相对标识符(Relative Identifier),简称 RID

SID 结构体的组成如下图所示:(图片来源

image-20220429112201388

除了具体数值,结构体中还有一个 Subauthority Count 成员用来表示所有的 Subauthority 占几个 DWORD。

查下文档,GetSidSubAuthorityCount 返回 SID 结构体中指向 Subauthority Count 的指针。GetSidSubAuthority 返回指向特定 Subauthority 的指针,可以理解为是对 Subauthority 这个数组按索引取值,索引为该函数的第二个参数。至此这两个函数的功能就差不多弄清楚了,应该是用来读取 SID 中的某个值,估计就是 RID 了。


1.2 kernel32.dll

很多导入函数,其中关键的有:

  • 进程遍历:CreateToolhelp32Snapshot、Process32FirstW、Process32NextW
  • 文件遍历:FindFirstFileExA、FindNextFileA
  • 获取环境变量:GetEnvironmentVariableW
  • 反调试:IsDebugerPresent
  • 文件操作:WriteFile、CreateFileW、DeleteFileW

1.3 shell32.dll

一个导入函数 ShellExecuteW,用于执行。


2 初步动态分析

用火绒剑和 fakenet 对样本的动态行为做一个初步的分析。

2.1 写文件

image-20220428103804211

C:Users\test\AppData\Local\ 目录下写了两个文件:cdnver.dll 和 cdnver.bat。其中 cdnver.bat 的内容是:start rundll32.exe "C:\Users\test\AppData\Local\cdnver.dll",#1


2.2 写注册表

image-20220428104010222

image-20220428104043718

HKEY_USERS\SID\Environment\(相当于 HKEY_CURRENT_USER\Environment\)下写
UserInitMprLogonScript,内容为 C:Users\test\AppData\Local\cdnver.bat,应该是用于自启动。那么,有没有想过一个问题,为什么这个注册表项可以用于自启动呢?对于问题多问为什么,知其然更知其所以然,可以帮助我们加深对系统的理解,避免成为脚本小子。

经过一番调查,得知 UserInitMprLogonScript 是由 userinit.exe 读取并执行的,那就逆向 userinit.exe 看看,顺便推荐一个 pdb 文件下载器。搜字符串 UserInitMprLogonScript,找交叉引用,在 WinMain 里:

image-20220429162914559

这里 v11 和 v12 就是 UserInitMprLogonScript 的值,即
C:Users\test\AppData\Local\cdnver.bat。然后稍微下面一点的地方:

image-20220429163156359

不管是 if 分支还是 else 分支都执行了,所以确实是 userinit.exe 运行了 UserInitMprLogonScript 的值。

第二个问题,userinit.exe 运行 UserInitMprLogonScript 的值是在杀软运行之前吗?对于这个问题我是存疑的。再经过一番调查,得知 userinit.exe 的父进程是 winlogon.exe。当用户认证通过后,winlogon.exe 启动 userinit.exe 来运行 explorer.exe 和用户登录脚本。另一方面,对于杀软,以火绒为例:

image-20220429164948073

它是基于服务运行的,最终起作用的是 HipsTray.exe。通过命令 Get-Process | select name, id, starttime | select-string <PID> 查看 explorer.exe 和 HipsTray.exe 的启动时间:

image-20220429170202246

image-20220429170102868

可以看到杀软确实是后运行的。


2.3 进程执行

运行释放出来的 cdnver.dll 的序号为 1 的导出函数:

image-20220428104235565

2.4 网络

有两个突出的域名解析,其中 google.com 可能是用来测试网络连接的:

image-20220430141903720

image-20220428105011702

用 VT 查 cdnverify.com 则显示与 Fancy Bear(即 APT 28)相关:

image-20220428105321225

另外 fakenet 可以观察到发送了大段 base64 编码后的数据,估计是向 C2 发送搜集的本机信息。


3 深入分析

一边用 IDA 看,一边用 x64dbg 调。因为导入了 IsDebugerPresent 这个函数,交叉引用找调用
IsDebugerPresent 的位置,没有,所以就放心调。

3.1 WinMain → sub_401DEF

入口点连续调用了三次 sub_401DEF:

image-20220430143754957

点进去看的话,有一些循环异或,判断为解密函数:

image-20220430143915933

调试步过可以得到三个被解密出来的字符串:

image-20220428142933235

将 sub_401DEF 重命名为 _401DEF_decode。


3.2 WinMain → sub_4012D3

后面紧接着调用了 sub_4012D3,如果返回失败程序直接跳到了退出的地方,因此这个函数很重要。进去首先分配堆内存,失败就退出:

image-20220430163740774

size 是四字节,所以应该是用来放指针的。然后 new 了 0x28 的内存,再调用两次 sub_401063 函数:

image-20220430145734792 image-20220430154446761

最后调用 sub_1000,然后结束:

image-20220430150408930

分别看看这两个函数。

3.2.1 sub_4012D3 → sub_401063

点进去先分配堆空间,然后循环异或,估计也是解密函数:

image-20220430150018006

应该是将密文解密到分配的堆空间里。调试步过可以得到两个解密出来的字符串 cdnver.dll 和
LOCALAPPDATA:

image-20220428143957857 image-20220428144009905 image-20220428144024354

其中 0x43B300 就是一开始 new 出来的 0x28 大小的内存,估计是个什么结构体。将 sub_401063 重命名为 _401063_decode。


3.2.2 sub_4012D3 → sub_401000

循环异或,而且是两重循环,应该是比较大的数据了:

image-20220430154918188

可以看出来是原地读写,运行到第一次读数据的位置,然后跳转到内存查看:

image-20220430155459516

可以看出密文在可执行文件的数据段,IDA 里也可以看:

image-20220430155649775

异或完成后:

image-20220430155824589

像个 PE 文件了,但是怪怪的。前面有三个字节 0x0A、0xBA、0x00,而且后面也有一些莫名其妙的
0x00,后续再观察。将 sub_401000 重命名为 _401000_decode_PE。


到这里整个 sub_4012D3 就分析完了,它解密两个字符串到分配的堆空间中,并把字符串地址写到 new 出来的结构体里。然后原地解密了数据段的一些内容,疑似 PE。至于最开始在堆上分配的四字节,下写入断点运行可以发现 0x4013E1 处的指令将 new 出来的结构体的地址写入了该地址。将 sub_4012D3 重命名为 _4012D3_decode_str_PE。

但是分析到现在整个数据链条还是断的。回过头来看看分配四字节堆空间的地方:

image-20220430163925708

返回值写入了 [edi+4],而 edi 又等于 ecx,即 this 指针。再回头看调用 0x4012D3 的地方:

image-20220430164107818

调用前 new 了 0xC 大小的空间,将其内容初始化为 0。那么这个类应该就是贯穿整个样本最重要的类了。这个类的第一个成员变量(第五个字节开始,前四字节是虚函数表指针),就是 sub_4012D3 里在堆中分配的四字节。那么现在整个数据链条就连通了。


3.3 WinMain → sub_4013F7

执行完 sub_4012D3 后,控制流来到 sub_4013F7。开头又是两个解密函数。动调可以得到
RtlGetCompressionWorkSpaceSize 和 RtlDecompressBuffer 两个字符串。然后通过一次写一个字符的方式在栈上写 ntdll,然后 LoadLibrary:

image-20220501142425363

之后再执行两次 GetProcAddress 来获取 RtlGetCompressionWorkSpaceSize 和
RtlDecompressBuffer 两个函数的地址:

image-20220501142751310

所以此样本采取了一定的字符串隐藏和导入函数隐藏技术,来减少静态特征。然后调用
RtlGetCompressionWorkSpaceSize,再分配一些堆空间,再调用 RtlDecompressBuffer:

image-20220501144654675

解压后就得到正常的 PE 了。然后通过 0x40152F 处的指令将 PE 的地址写入之前的那个 0x28 大小的结构体:

image-20220501145325402

因为进行了多次调试,所以堆分配的地址的基址不一样,但是后两个字节是一样的。将 0x4013F7 重命名为 _4013F_decompress。


3.3 WinMain → sub_40155B

紧接着来到 sub_40155B 函数,该函数最开始调用了 sub_4010CD 函数。

3.3.1 sub_40155B → sub_4010CD

开头依旧是解密字符串,解密了 SystemRoot 和 \System32 两个字符串。然后在 0x401226 处取出之前的结构体中的第五个成员变量,即 LOCALAPPDATA:

image-20220501152014205 image-20220501152043663

分配一些空间用于存放路径,然后调用 GetEnvironmentVariableW:

image-20220501152232878

调用两次拼接函数,从结构体中取第六个成员变量,即 cdnver.dll,拼接得到完整路径
C:\Users\test\AppData\Local\cdnver.dll。然后将结构体的第五个成员变量改为这个完整的路径:

image-20220501152929982 image-20220501153140814 image-20220501153201411

将 sub_4010CD 重命名为 _4010CD_construct_PE_path。


回到 sub_40155B,Load kernel32.dll,GetProcAddress 获取 CreateFileW 和 WriteFileW 的地址,然后调用。创建文件 C:\Users\test\AppData\Local\cdnver.dll,将结构体第一个成员变量指向的内容,即解压后得到的 PE 写入该文件。将 sub_40155B 重命名为 _40155B_write_dll。


3.4 WinMain → sub_40264C

首先连续解密了七个字符串:

image-20220501155233252

然后采用老方法,Load Advapi32.dll,GetProcAddress 获取 RegOpenKeyExW,然后调用:

image-20220501155540561

0x80000001 代表 HKEY_CURRENT_USER,所以此处打开的注册表项是
HKEY_CURRENT_USER\Environment\。然后再次获取环境变量 LOCALAPPDATA 的值:

image-20220501160157475 image-20220501160224142

对于解密出来的字符串 cdnver.dll,循环找子串 .,最终得到后缀名,并将后缀名与 exe 进行比较:

image-20220501160408785

因为后缀名是 dll,来到处理 dll 的分支,首先调用了 sub_402030 函数。


3.4.1 sub_40264C → sub_402030

解密字符串并将宽字节转换为 ASCII,对以下字符串进行了这种处理:rundll32.exe、#1、cdnver.dll、LOCALAPPDATA、start、cdnver.bat:

image-20220501161457210

分配三个空间,获取环境变量 LOCALAPPDATA 的值,并拼接字符串将其写到分配的空间中:

image-20220501162803260 image-20220501162825774

然后 Load kernel32.dll,GetProcAddress 获取 CreateFileA 的地址,调用函数,创建文件
C:\Users\test\AppData\Local\cdnver.bat。接下来拼接字符串得到 cdnver.bat 的内容,写入分配的第三个空间中:

image-20220501163137882

最后调用 WriteFile 将该字符串写入 cdnver.bat。将 sub_402030 重命名为 _402040_write_bat。


回到 sub_40264C,拼接字符串得到 cdnver.bat 的完整路径:

image-20220501163823206 image-20220501163836349

然后,Load Advapi32.dll,GetProcAddress 获取 RegSetKeyExW,调用函数,向
HKEY_CURRENT_USER\Environment\ 写 UserInitMprLogonScript 项,其值为 cdnver.bat 的路径:

image-20220428154559647

将 sub_40264C 重命名为 _40264C_persistence。


3.5 WinMain → sub_401707

首先判断结构体第五的成员变量的值(C:\Users\test\AppData\Local\cdnver.dll)是否以 .dll 结尾,对 exe 文件和 dll 文件采取不同的处理方式,看 dll 文件的处理分支即可。

解密三个字符串:

image-20220501165054516

然后调用 sub_401D09,根据返回值的不同有两个分支。返回值为 3 执行 if 分支,否则执行 else 分支:

image-20220501165257043

先看 else 分支,拼接字符串作为参数然后通过 ShellExecuteW 执行:

image-20220501165444304 image-20220428160217052
3.5.1 sub_401707 → sub_401D09

调用 GetCurrentProcess 获取当前进程句柄。调用 OpenProcessToken 获取令牌句柄。调用 GetTokenInformation 两次,参数为 TokenIntegrityLevel,用于获取令牌完整性级别。当参数为 TokenIntegrityLevel 时,返回 TOKEN_MANDATORY_LABEL 结构体。第一次调用获取 size,第二次调用获取实际值。

然后调用 GetSidSubAuthorityCount 获取 SubAuthorityCount 的值。再调用 GetSidSubAuthority 获取 RID(第二个参数设为 SubAuthorityCount - 1):

image-20220501171102870

最后依据 RID 的不同返回不同的值。根据微软的文档,进程 RID 与完整性级别的关系如下:

image-20220501171741480

RID 与返回值的关系:

  • RID == 0x1000:返回 0
  • RID < 0x2000:返回 0
  • 0x2000 ≤ RID < 0x3000:返回 1
  • 0x3000 ≤ RID < 0x4000:返回 2
  • RID ≥ 0x4000:返回 3

似乎样本并没有考虑 PROTECTED_PROCESS 的情况。当运行的完整性级别为 SYSTEM 时,样本会执行 if 分支。

将 sub_1D09 重命名为 _401D09_get_integrity_level。


if 分支内,拼接字符串然后执行 sub_401B02:

image-20220502143652412 image-20220502143912824
3.5.2 sub_401707 → sub_401B02

开头首先调用了 sub_401957 函数。

3.5.2.1 sub_401B02 → sub_401957

Load Advapi32.dll,GetProcAddress 获取 OpenProcessToken 函数的地址,GetCurrentProcess 获取当前进程句柄。调用 OpenProcessToken 获取当前进程令牌句柄。GetProcAddress 获取 LookupPrivilegeValueW 函数的地址,调用两次,获取 SeSecurityPrivilege 和 SeTcbPrivilege 两个特权的 LUID,然后调用两次 AdjustTokenPrivileges 来 enable 这两个特权。其中 SeSecurityPrivilege 用于管理审核和安全日志,SeTcbPrivilege 用于以操作系统方式操作,允许很多敏感的操作。

将 sub_401957 重命名为 _401957_enable_privilege。


回到 sub_401B02,紧接着调用了 sub_401C3D。

3.5.2.1 sub_401B02 → sub_401C3D

通过 CreateToolhelp32Snapshot、Process32FirstW、Process32NextW 找到 explorer.exe,获取其 PID。然后通过 OpenProcess 和 OpenProccessToken 获取其令牌句柄,然后返回。将 sub_401C3D 重命名为 _401C3D_get_explorer_token。


回到 sub_401B02,紧接着调用了 sub_4018DC:

image-20220502150853001 image-20220502150930295

里面的逻辑很乱,决定直接步过看效果,将第三个参数指向的字符串写到了第一个参数指向的位置:

image-20220502151056099

然后,Load Advapi32.dll,GetProcAddress 获取 CreateProcessAsUserW 函数的地址。调用,第一个参数 hToken 为之前获取的 explorer.exe 的令牌句柄,第三个参数 lpCommandLine 即 RunDll32.exe "C:\Users\test\AppData\Local\cdnver.dll",#1


总得来说,sub_401707 这个函数是用来执行的,将其重命名为 _401707_exec。但是对于在 SYSTEM 权限下的运行,还有以下两个疑问:

  • 在 SYSTEM 权限下,LOCALAPPDATA 会被解析为

    C:\WINDOWS\system32\config\systemprofile\AppData\Local。但实际运行样本会发现样本不能向这个路径写文件(里面只有一个 Microsoft 文件夹),导致运行 CreateProcessAsUserW 报错:

    image-20220502153629682 image-20220502153714481
  • 一开始对当前进程的令牌 enable 了 SeSecurityPrivilege 和 SeTcbPrivilege 两个特权,但是后续 CreateProcessAsUserW 使用的是 explorer.exe 的令牌,那么 enable 这两个特权的意义何在?另外,根据微软的文档,调用 CreateProcessAsUserW 需要 SeIncreaseQuotaPrivilege 特权,但 SYSTEM 用户这个特权是默认 disabled 的,为什么不 enable 这个特权呢?


3.6 WinMain → sub_4018AD

最后还有一个删除文件的函数,实际调试时没有调用:

image-20220502155019456

将其重命名为 _4018AD_delete。


到这里整个样本就基本分析完了,剩下的 cdnver.dll 之后再分析,之前观察到的网络行为不是样本的,而是 cdnver.dll 的。


4 一些细节

4.1 类与结构体

4.1.1 贯穿样本的类与结构体

image-20220430164107818

12 字节大小,第一个指针为虚函数表,第一个成员变量为指向另一个结构体的指针(下称该结构体为 A),第二个成员变量的值被赋值为 1,应该是起到一些控制效果的布尔值:

image-20220502161307219 image-20220502161335335

结构体 A 有 6 个重要的成员变量:

image-20220502161505857
  • 解压后的 PE 的指针
  • 解压前的 PE 的指针
  • 解压后的 PE 的 size
  • 解压前的 PE 的 size
  • 首先指向 LOCALAPPDATA 字符串,之后指向 cdnver.dll 的完整路径的字符串
  • 指向 cdnver.dll 字符串

4.1.2 用于解密的结构体

实际上 _401DEF_decode 函数也用了一个结构体:

image-20220502162219342

16 字节,两个数值,两个指针,应该是密文和 xor key,以及对应的 size:

  • 密文
  • 密文 size
  • xor key
  • xor key size

4.2 解密逻辑

4.2.1 _401DEF_decode
image-20220502163015227

可以写解密脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
cipher = [0x41, 0xA1, 0xD1, 0xFC, 0xC7, 0x20, 0x6D, 0xDA, 0x7C, 0x8B, 0xCF, 0xDD, 0x6F, 0x66, 0x24, 0x6C, 0xE4, 0xDD, 0x1E, 0xDD, 0x76, 0x5F, 0x1E, 0xFE, 0x8E, 0xA3, 0x98, 0x7F, 0x11, 0xA6, 0x0A, 0xFD, 0xA3, 0xB1, 0x67, 0x6E, 0x77, 0x3F, 0xD8, 0xE1, 0x12]
cipher_size = 0x28
xor_key = [0x12, 0xE0, 0x09, 0x2D, 0x48, 0xE7, 0x39, 0xB7, 0xC3, 0xF7, 0x29]
xor_key_size = 11

plain = [0] * (cipher_size + 2)
for i in range(cipher_size, 0, -1):
plain[i] = cipher[i] ^ cipher[i - 1] ^ xor_key[i % xor_key_size]
plain[0] = cipher[0] ^ xor_key[0]
for i in range(0, cipher_size + 2, 2):
if plain[i] == 0:
break
else:
print(chr(plain[i]), end = '')
print('')

这里还有个小坑,实际上 cipher 的 size 是 0x28 + 1


4.2.2 _401000_decode_PE
image-20220502165820153
1
2
3
4
5
6
7
8
9
10
11
12
cipher = [0x3A, 0x09, 0xA6, 0xDC, 0x8E, 0x7F, 0xCA, 0x5E, 0x18, 0x2B, 0x4E, 0xEB, 0xB8, 0xA7, 0x02, 0x8A]
cipher_size = 16
xor_key = [0x39, 0x50, 0xBE, 0x2C, 0xD3, 0x7B, 0x2C, 0x7C, 0xCB, 0xF8]
xor_key_size = 10

for idx in range(cipher_size):
for xor_key_idx in range(10):
cipher[idx] ^= (xor_key[xor_key_idx] + idx * xor_key_idx) & 0xff

for i in range(cipher_size):
print(hex(cipher[i])[2:].rjust(2, '0'), end = ' ')
print('')

4.2.3 _401063_decode
image-20220502173656138
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
cipher = [0x00000053, 0x000001D7, 0x000001C8, 0x000001E7, 0x000001B1, 0x0000019D, 0x000001E4, 0x00000039, 0x00000074, 0x00000047, 0xFFFFFFFF]
xor_key = [0x39, 0x50, 0xBE, 0x2C, 0xD3, 0x7B, 0x2C, 0x7C, 0xCB, 0xF8]
xor_key_size = 10

ret = ''
idx = 0
while True:
temp = cipher[idx]
if temp == 0xffffffff:
break
v8 = 0
for xor_key_idx in range(xor_key_size):
v9 = v8 + xor_key[xor_key_idx]
v8 += idx
temp ^= v9
idx += 1
ret += chr(temp & 0xffff)
print(ret)
---------------感谢您的阅读---------------