由于AgentTesla远控是目前比较流行的远控木马之一,因此,基于简单的扩线方法,即可很快扩线一大批AgentTesla远控木马。
起初,笔者拿到扩线的AgentTesla远控木马时,还是基于上一篇文章的分析思路进行的一步一步分析,几个样本还好,但是当样本量突然上去后,笔者发现,这个工作不好干啊。。。重复工作。。。无意义工作。。。
因此,基于上述分析样本过程中的矛盾点,笔者就在琢磨,如何能够快速的对这一批样本进行剖析,并且提取我所需要的内容呢?
为了实现这个目标,笔者就开始对分析过程中的各个环节进行琢磨:
- 分析难点:
- 如何提取解密后的PE文件?能否在进程替换前提取最终木马载荷
- 关键功能信息:
- 配置信息
- download功能的下载地址?
- 窃取数据的方法?Web Panel、SMTP、FTP
基于上述思路,笔者尝试编写了多个脚本用以辅助半自动化的对AgentTesla最新变体样本开展分析工作,通过努力,最终实现对扩线样本进行了批量剖析,获得大量SMTP、FTP账号信息。
内存提取PE文件
为了能够完全提取解密后的PE文件,笔者也是尝试了多个思路:
-
思路一:基于上一篇文章中提到的解密算法对相关加密载荷进行解密
- 实际遇到的问题1:在SimpleLogin.dll解密Tyrone.dll样本的过程中,无法很好的动态调试其解密算法,而且其解密过程涉及了多个样本文件中的代码,因此很容易触发异常。
- 实际遇到的问题2:解密算法均需要解密密钥,若解密密钥不同,就算成功复现了解密算法,还是需要再次通过动态调试才能提取解密密钥,因此也没有减少工作量。
-
思路二:在调试过程中,提取进程内存中的所有PE文件
- 实际遇到的问题1:每个木马进程内存中均能提取很多PE文件,而且大部分PE文件还是正常的dll模块。
- 实际遇到的问题2:从木马进程内存中提取的PE文件,存在PE文件重复的情况。
- 实际遇到的问题3:从木马进程内存中提取的PE文件,存在PE文件头是正常PE文件载荷,PE文件头后的内容并非为实际PE文件载荷。
-
思路三:由于木马进程会调用VirtualAllocEx、WriteProcessMemory等函数以实现进程替换,因此,我们可通过OD对WriteProcessMemory函数下断点,成功断下来后,即可根据API查看待写入的PE文件载荷内容。
通过对比,笔者最终采用了思路三对AgentTesla最新变体运行过程中的最终载荷进行提取。
提取内存片段
由于AgentTesla最新变体在运行过程中,会通过进程替换技术将解密后的最终载荷写入新进程的内存空间中,因此,我们可通过如下方式尝试提取内存片段:
- 使用OD调试AgentTesla最新变体程序,尝试下WriteProcessMemory函数断点;
- 运行,即可在WriteProcessMemory函数断点处中断;
- 查看WriteProcessMemory函数的lpBuffer参数(指向要写入数据的缓冲区的指针),发现其数据为PE文件载荷;
- 由于lpBuffer参数对应内存地址为内存块中的一部分,因此,若尝试直接提取内存数据,然后再根据PE文件结构提取PE文件数据还是比较麻烦,而且此内存块中还存在其他PE文件;
- 尝试直接提取lpBuffer参数对应内存地址的内存块;
相关截图如下:
从内存片段中还原PE文件
成功提取携带AgentTesla最终载荷的内存块后,我们即可尝试从内存片段中还原PE文件,为有效提取还原PE文件,笔者尝试以如下逻辑简单编写了一个脚本程序:
- 在内存块中搜索PE文件结构中的"This program cannot be run in DOS mode"字符串;
- 根据字符串偏移,查找PE文件头;
- 根据PE文件头,计算PE文件大小;
- 根据载荷偏移,计算PE文件载荷的Hash值;(简单去重,避免还原出来的PE文件为相同PE文件)
- 根据载荷偏移,判断PE文件载荷末尾是否是以00结尾;(简单区分,避免PE文件头以后的数据为非实际PE文件数据)
- 若Hash值为首次计算所得,则从内存块中提取PE文件;
代码效果如下:
解密后的载荷属性信息截图如下:(最终载荷的属性名称均是形如bfb5da9f-48ba-40d7-85d4-7ec204e8e6d3.exe结构)
代码实现如下:
package main
import (
"crypto/sha256"
"debug/pe"
"encoding/hex"
"fmt"
"io"
"io/ioutil"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
)
func main() {
hashs := \[\]string{}
files, err := WalkDir("C:\\\\Users\\\\admin\\\\Desktop\\\\新建文件夹", "")
if err != nil {
fmt.Println("Error:", err.Error())
}
for \_, onefile := range files {
SearchPE(onefile, &hashs)
}
}
func SearchPE(file\_in string, hashs \*\[\]string) {
output\_dir := "./output/"
\_, fileName := filepath.Split(file\_in)
// 读取文件的所有内容
data, err := ioutil.ReadFile(file\_in)
if err != nil {
fmt.Println("Error reading file:", err)
return
}
reg := regexp.MustCompile("This program cannot be run in DOS mode")
offsets := reg.FindAllIndex(data, \-1)
//fmt.Println(offsets)
for \_, offset := range offsets {
buffer := \[\]byte{}
buffer \= append(buffer, data\[offset\[0\]\-0x4e:\]...)
Writefile("tmp", string(buffer))
size := getfilesize("tmp")
buffer1 := \[\]byte{}
buffer1 \= append(buffer1, buffer\[:size\]...)
fmt.Println("offset PE:0x" + strconv.FormatInt(int64(offset\[0\]\-0x4e), 16))
hash := HashData\_sha256(buffer1)
//部分提取的文件其实只有PE头是正常的,因此,通过判断末尾数据来筛选
if !ContainsAny(hash, \*hashs) && strings.HasSuffix(hex.EncodeToString(buffer1), "00000000000000000000000000000000") {
\*hashs \= append(\*hashs, hash)
Writefile(output\_dir+fileName+"offset\_0x"+strconv.FormatInt(int64(offset\[0\]\-0x4e), 16), string(buffer1))
}
os.Remove("tmp")
}
}
func getfilesize(filePath string) (size int) {
file, err := os.Open(filePath)
if err != nil {
fmt.Printf("Error opening file: %v\\n", err)
return
}
defer file.Close()
peFile, err := pe.NewFile(file)
if err != nil {
fmt.Printf("Error parsing PE file: %v\\n", err)
return
}
defer peFile.Close()
fmt.Printf("RawSize: 0x%X\\n", peFile.Sections\[len(peFile.Sections)\-1\].Size)
fmt.Printf("RawAddress: 0x%X\\n", peFile.Sections\[len(peFile.Sections)\-1\].Offset)
aa := peFile.Sections\[len(peFile.Sections)\-1\].Size + peFile.Sections\[len(peFile.Sections)\-1\].Offset
size \= int(aa)
return
}
func WalkDir(dirPth, suffix string) (files \[\]string, err error) {
files \= make(\[\]string, 0, 30)
suffix \= strings.ToUpper(suffix) //忽略后缀匹配的大小写
err \= filepath.Walk(dirPth, func(filename string, fi os.FileInfo, err error) error { //遍历目录
if fi.IsDir() { // 忽略目录
return nil
}
if strings.HasSuffix(strings.ToUpper(fi.Name()), suffix) {
files \= append(files, filename)
}
return nil
})
return files, err
}
func CheckPathIsExist(filename string) bool {
var exist \= true
if \_, err := os.Stat(filename); os.IsNotExist(err) {
exist \= false
}
return exist
}
func Writefile(filename string, buffer string) {
var f \*os.File
var err1 error
if CheckPathIsExist(filename) {
f, err1 \= os.OpenFile(filename, os.O\_CREATE, 0666)
} else {
f, err1 \= os.Create(filename)
}
\_, err1 \= io.WriteString(f, buffer)
if err1 != nil {
fmt.Println("写文件失败", err1)
return
}
\_ \= f.Close()
}
func HashData\_sha256(data \[\]byte) string {
// 创建 SHA256 哈希函数
hash := sha256.New()
// 将字符串转换为字节数组,并计算哈希值
hash.Write(data)
hashValue := hash.Sum(nil)
// 将哈希值转换为十六进制字符串
hashString := hex.EncodeToString(hashValue)
return hashString
}
func ContainsAny(str string, elements \[\]string) bool {
for element := range elements {
e := elements\[element\]
if strings.Contains(e, str) {
return true
}
}
return false
}
自动化提取配置信息
为了能够完全提取配置信息,笔者也是尝试了多个思路:
- 思路一:尝试基于配置信息的16进制特征对AgentTesla最终载荷样本的配置信息进行提取
- 实际遇到的问题:基于16进制只能提取配置信息的变量值,具体变量名无法很好的对应
- 思路二:尝试对反编译后的AgentTesla最终载荷样本的代码进行分析,提取反编译后的配置信息
- 实际遇到的问题:最开始没有找到很合适的命令行反编译工具
通过对比,笔者最终采用了思路二对AgentTesla最新变体运行过程中的最终载荷的配置信息进行提取。
思路一的部分截图如下:
批量反编译NET样本
为了实现批量反编译NET样本,笔者尝试对多款NET反编译工具进行了研究,梳理发现:
- dnspy:不支持命令行运行;
- ILSpy存在一个命令行版本ilspycmd,支持命令行运行;
安装ilspycmd工具的流程如下:
- 安装 .NET SDK 6.0:笔者安装的版本为dotnet-sdk-6.0.423-win-x64.exe;
- 安装ilspycmd工具:dotnet tool install --global ilspycmd
- 验证ilspycmd安装:ilspycmd --help(如果安装非.NET SDK 6.0版本,则会报错)
- 使用ilspycmd反编译程序集:ilspycmd -o “./output” “assembly.dll”
相关操作截图如下:(反编译后,会生成.decompiled.cs后缀文件)
配置信息结构对比
尝试对AgentTesla最新变体运行过程中的最终载荷的配置信息进行对比,发现其配置信息项的顺序以及名称均基本相同。
相关截图如下:
自动化提取配置信息
因此,基于上述配置对比信息,笔者尝试基于如下思路构建自动化提取配置信息的脚本程序:
- 遍历AgentTesla最新变体运行过程中的最终载荷;
- 使用ilspycmd工具对样本进行批量反编译;
- 使用正则匹配反编译.decompiled.cs文件中的URL信息;
- 使用正则匹配反编译.decompiled.cs文件中的配置信息;
代码效果如下:
代码实现如下:
package main
import (
"fmt"
"io/ioutil"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
)
func main() {
fmt.Println("需安装ilspycmd工具")
fmt.Println()
files, err := WalkDir("C:\\\\Users\\\\admin\\\\Desktop\\\\test", "")
if err != nil {
fmt.Println("Error:", err.Error())
}
for \_, onefile := range files {
fileName := filepath.Base(onefile)
fileExt := filepath.Ext(onefile)
decompiledfile := strings.Split(fileName, fileExt)\[0\] + ".decompiled.cs"
if !CheckPathIsExist("./output/" + decompiledfile) {
CmdRun(\`C:\\\\Users\\\\admin\\\\.dotnet\\\\tools\\\\ilspycmd.exe -o ./output/ \` + onefile)
}
if CheckPathIsExist("./output/" + decompiledfile) {
fmt.Println("\*\*\*\*\*\*\*\*\*\*" + fileName + " URL\*\*\*\*\*\*\*\*\*\*")
SearchURLs("./output/" + decompiledfile)
fmt.Println("\*\*\*\*\*\*\*\*\*\*" + fileName + " Config\*\*\*\*\*\*\*\*\*\*")
SearchConfig("./output/" + decompiledfile)
fmt.Println()
}
}
}
func SearchURLs(onefile string) {
// 读取文本文件内容
content, err := ioutil.ReadFile(onefile) // 请替换为实际的文件路径
if err != nil {
fmt.Printf("无法读取文件:%v\\n", err)
return
}
// 定义正则表达式
re := regexp.MustCompile(\`(http://|https://)\[^\\s\]+\`)
// 在文本中查找匹配的字符串
matches := re.FindAllString(string(content), \-1)
// 输出匹配到的字符串
for \_, match := range matches {
if strings.Contains(match, \`"\`) {
fmt.Println(strings.Split(match, \`"\`)\[0\])
} else {
fmt.Println(match)
}
}
}
func SearchConfig(onefile string) {
// 读取文本文件内容
content, err := ioutil.ReadFile(onefile) // 请替换为实际的文件路径
if err != nil {
fmt.Println(err.Error())
}
// 定义正则表达式,用于匹配两个字符串之间的内容
re := regexp.MustCompile(\`(?s)public\\s+static\\s+string\\s+PcHwid(.\*?)public\\s+static\\s+string\\s+StartupRegName\`)
// 查找匹配的字符串
matches := re.FindStringSubmatch(string(content))
if len(matches) \> 1 {
// 输出匹配到的字符串(第一个子匹配项)
//fmt.Println("匹配到的内容:", matches\[0\])
output := matches\[0\]
output \= strings.ReplaceAll(output, "\\t", "")
output \= strings.ReplaceAll(output, "public static string ", "")
output \= strings.ReplaceAll(output, "public static bool ", "")
output \= strings.ReplaceAll(output, "public static int ", "")
output \= strings.ReplaceAll(output, "Convert.ToBoolean(", "")
output \= strings.ReplaceAll(output, "Convert.ToInt32(", "")
output \= strings.ReplaceAll(output, ");", "")
output \= strings.ReplaceAll(output, ";", "")
output \= strings.ReplaceAll(output, "\\r\\n\\r\\n", "\\r\\n")
fmt.Print(strings.Split(output, "StartupRegName")\[0\])
} else {
fmt.Println("未找到匹配的内容")
}
re1 := regexp.MustCompile(\`public\\s+static\\s+string\\s+StartupRegName.\*\`)
// 查找匹配的字符串
matches1 := re1.FindStringSubmatch(string(content))
if len(matches1) \> 0 {
output := matches1\[0\]
output \= strings.ReplaceAll(output, "public static string ", "")
output \= strings.ReplaceAll(output, ";\\r", "")
fmt.Println(output)
} else {
fmt.Println("未找到匹配的内容")
}
}
func WalkDir(dirPth, suffix string) (files \[\]string, err error) {
files \= make(\[\]string, 0, 30)
suffix \= strings.ToUpper(suffix)
err \= filepath.Walk(dirPth, func(filename string, fi os.FileInfo, err error) error {
if fi.IsDir() { // 忽略目录
return nil
}
if strings.HasSuffix(strings.ToUpper(fi.Name()), suffix) {
files \= append(files, filename)
}
return nil
})
return files, err
}
func CheckPathIsExist(filename string) bool {
var exist \= true
if \_, err := os.Stat(filename); os.IsNotExist(err) {
exist \= false
}
return exist
}
func CmdRun(command string) {
parts := strings.Fields(command)
head := parts\[0\]
parts \= parts\[1:\]
cmd := exec.Command(head, parts...)
output, err := cmd.CombinedOutput()
if err != nil {
fmt.Println(err.Error())
fmt.Println(string(output))
} else {
fmt.Println(string(output))
}
}
多阶段部分解密算法复现
虽然笔者最终未通过复现多阶段解密算法的方式去解密各阶段中的PE文件,但笔者也尝试编写了部分脚本用以做解密尝试,因此,笔者也将其解密脚本放置于文章中。
解密SimpleLogin.dll
代码效果如下:
代码实现如下:
package main
import (
"fmt"
"io"
"io/ioutil"
"os"
)
func main() {
file\_in := "C:\\\\Users\\\\admin\\\\Desktop\\\\新建文件夹\\\\off"
array, err := ioutil.ReadFile(file\_in)
if err != nil {
fmt.Println("Error reading file:", err)
return
}
num := len(array)
first := "J9EZ6H5428445"
second := "C755C8RZH"
first\_second := first + second
for i, data := range array {
num2 := i % 22
b := first\_second\[num2\]
num3 := (i + 1) % num
num4 := int(data ^ b)
num5 := num4 \- int(array\[num3\]) + 256
array\[i\] \= byte(num5 & 255)
}
Writefile(file\_in+"\_dec\_SimpleLogin.dll", string(array))
}
func CheckPathIsExist(filename string) bool {
var exist \= true
if \_, err := os.Stat(filename); os.IsNotExist(err) {
exist \= false
}
return exist
}
func Writefile(filename string, buffer string) {
var f \*os.File
var err1 error
if CheckPathIsExist(filename) {
f, err1 \= os.OpenFile(filename, os.O\_CREATE, 0666)
} else {
f, err1 \= os.Create(filename)
}
\_, err1 \= io.WriteString(f, buffer)
if err1 != nil {
fmt.Println("写文件失败", err1)
return
}
\_ \= f.Close()
}
解密Gamma.dll
直接使用压缩软件即可实现解密Gamma.dll文件。
相关截图如下:
半自动化批量剖析的最终实现效果–获取大量SMTP、FTP账号信息
在文章开头,笔者就说笔者通过扩线获取了大量的AgentTesla最新变体样本,因此,基于上述半自动化分析手法,笔者可很快速对上述样本的功能行为进行梳理提取。
通过梳理,笔者发现了大量的SMTP、FTP账号信息,相关截图如下: