1. 概述
近期,笔者在浏览网络中威胁情报信息的时候,发现美国halcyon.ai公司于2024年3月25日发布了一篇《Ransomware on the Move: LockBit, BianLian, Medusa, Hunters International》报告,此报告对当前勒索软件团伙的实力进行了排名,排名顺序为:LockBit勒索软件、BianLian勒索软件、Medusa勒索软件、Hunters International勒索软件;进一步对报告内容进行研读,发现报告中在对BianLian勒索软件进行描述时,提到「总部位于上海的知名汽车电子企业科博达科技」已成为BianLian勒索软件团伙的受害者,相关报告截图如下:
尝试对BianLian勒索软件团伙进行调研,发现BianLian勒索软件团伙的攻击方式为:利用漏洞在网络中传播,窃取最有价值的数据并对关键机器进行加密。相关报告截图如下:
为了能够深层次的对BianLian勒索软件进行研究,笔者从近期Palo Alto Networks安全公司2024年1月23日发布的《Threat Assessment: BianLian》报告中提取了勒索加密工具的Hash信息,成功下载了多个样本用于研究剖析:
-
BianLian勒索软件功能剖析:梳理其样本功能及勒索加密后的特征。
-
BianLian勒索软件加解密原理剖析:梳理其加密算法及加密逻辑。
-
构建BianLian勒索软件解密工具:结合加解密原理,模拟构建了一款针对BianLian勒索软件的解密工具,可实现一键化勒索解密。
相关报告Hash信息如下:
2. 勒索软件功能分析
通过分析,发现BianLian勒索软件为Golang语言编写,相关截图如下:
进一步分析,发现此样本存在调试信息,可以较快速的识别关键代码函数,相关代码截图如下:
2.1 遍历驱动器中的文件
通过分析,发现样本运行后,将从A盘到Z盘识别系统驱动器信息,并从系统中提取可用的文件,便于后续加密行为,相关代码截图如下:
2.2 创建带付款说明的文本文件
通过分析,发现此样本运行后,将在加密行为开始前,在多个目录中创建名为“look at this Instruction.txt”的带付款说明的文本文件,相关代码截图如下:
实际加密行为截图如下:
2.3 加密系统文件
通过分析,发现样本运行后,将按照如下逻辑对系统中的文件进行加密:
-
从待加密原始文件中读取指定大小数据块;
-
调用AES CBC算法对数据块内容进行加密;
-
将加密后的数据块内容写入原始文件中;「(此时,原始文件的内容已经被修改)」
-
使用".bianlian"后缀重命名加密文件;
「备注:由于BianLian勒索软件是直接基于原始文件进行的数据读写操作,因此,系统文件被加密后,我们是无法通过数据恢复的方式恢复数据的。」
相关代码截图如下:
3. 勒索软件加解密原理剖析
在尝试对BianLian勒索软件的加解密原理进行剖析的过程中,笔者也是经历了一波三折,「由于笔者在面对此样本的加解密问题时,总是以常规思维去思考此样本的加解密问题,导致在多个问题上走了弯路」:
-
最开始拿到这个样本时,笔者发现网络中针对此家族样本的报告有多种描述,一时分不清哪种说法正确
-
描述1:“勒索软件主要使用GoLang加密软件包进行AES和RSA加密”;
-
描述2:“样本中虽然引用了非对称加密库(RSA和elliptic curves),但勒索软件未调用其执行任何操作。文件数据是使用AES-256 CBC模式加密的”
-
-
后续,笔者又发现网络中有报告称avast安全公司发布了免费的BianLian勒索软件解密器,因此,笔者推测BianLian勒索软件可能与描述2的内容吻合
-
笔者尝试从近期Palo Alto Networks安全公司2024年1月23日发布的《Threat Assessment: BianLian》报告中提取BianLian勒索软件Hash,成功下载了5个样本,尝试对其进行分析,笔者发现此5个样本均调用了AES算法,但未调用RSA算法
-
然后,笔者就开始琢磨研究BianLian勒索软件加解密原理及模拟编写解密工具
-
「起初,笔者认为:勒索软件运行后,具备勒索软件后缀的文件均是被其勒索加密后的文件。」
-
于是,笔者根据BianLian勒索软件的加密效果及加密算法原理,快速的编写了一个BianLian勒索软件解密工具
-
基于笔者编写的BianLian勒索软件解密工具尝试对勒索软件运行后的加密文件进行解密,同时对加密前与解密后的文件MD5做对比,笔者发现存在大量文件MD5不同的情况......此刻有点慌,应该是解密工具编写的有问题。。。
-
基于此情况,笔者又反复对比加密前与加密后的文件内容,发现部分文件虽然具备勒索软件后缀,但是文件内容却并未加密。后续,笔者还尝试构建了大量不同大小的文件,用以对比其加密前与加密后的变化情况,测试过程中还一度认为是勒索软件作者写代码时的文件加密逻辑没思考清楚......例如:区分使用按块加密还是整块加密的方法是基于文件大小的,但实际上,0xfff大小的文件是按块加密的,0x1009大小的文件是整块加密的,0x1000-0x1008大小的文件是不调用加密的
-
-
最后,笔者决定通过动态调试探索一下加密逻辑,在动态调试后,笔者才比较清晰的对其加密逻辑进行了详细梳理,「发现样本中存在两层校验,用于确定是否对文件进行加密」
-
在开始加密行为前,样本将定义三个特殊数据
-
文件大小
-
块数据大小,由文件大小计算所得
-
加密偏移位置:「内置数据」,不同样本的加密偏移位置不同,用于指定从文件的哪个数据位置开始数据加密
-
-
第一层校验:样本在开始加密行为前,将调用project1_common_GetBlocksAmount函数,并向project1_common_GetBlocksAmount函数传递上述3个特殊数据,函数返回值将决定样本是否调用加密函数加密文件
-
第二层校验:样本将计算实际读取的文件大小(从加密偏移位置开始读取)是否与块大小相等,计算结果将决定样本是否调用加密函数加密文件
-
-
「最终梳理发现,受害系统中虽然有很多勒索软后缀文件,但并非所有文件均被加密,而且不同文件大小所调用的加密算法逻辑也不一样」
3.1 加密算法
通过分析,笔者发现此样本将调用AES CBC算法进行勒索文件加密,相关代码截图如下:
进一步分析,发现其AES CBC算法所需的KEY和IV值均内置于样本中,相关截图如下:
尝试将已掌握的所有样本的KEY和IV值进行提取,梳理密钥及关键信息如下:
/#1fd07b8d1728e416f897bef4f1471126f9b18ef108eb952f4b75050da22e8e43
加密偏移位置:9
KEY:633A56D0388869237C8DA6B7FC09F55DF35408C332614F692D11222583B36B62
IV:FC55A60A25B0472CFC2C6EBC3DD89DAB
#3a2f6e614ff030804aa18cb03fcc3bc357f6226786efb4a734cbe2a3a1984b6f
加密偏移位置:1
KEY:26DD1B400AED80B0980B34BAD76D3BE6599123FA562266B421A14AC8E02ECFA3
IV:D518BA928469306D579D625F56D09883
#46d340eaf6b78207e24b6011422f1a5b4a566e493d72365c6a1cace11c36b28b
加密偏移位置:0x34
KEY:979412FF08F7D2F0BD5F7E7E2A4919E9BF68CC7AABAB499872EC822DDCDA5307
IV:0FC14323F7A13CCA0569EBCFAE283996
#af46356eb70f0fbb0799f8a8d5c0f7513d2f6ade4f16d4869f2690029b511d4f
加密偏移位置:0x41
KEY:4A4105960D2C127D9711AC851BC1F10D17471B5A184CCDADA79003DA82CFDBA2
IV:5560E19D4B425420F6F5EF387D97065A
#eaf5e26c5e73f3db82cd07ea45e4d244ccb3ec3397ab5263a1a74add7bbcb6e2
加密偏移位置:0x3d
KEY:27CFAE34A83C9A7E48060E18A68A233914271DB7D414C838FB1EAEAEA89E5CDE
IV:223B67AC534F9938CC7B1F9777A95840
3.2 加密算法逻辑
结合静态分析及动态调试等分析手段,梳理BianLian勒索软件的加密算法逻辑如下:
-
获取文件大小,并基于文件大小计算数据块大小
-
若文件大小小于0x1000,则数据块大小为16**(按小数据块分别加密:从文件加密偏移位置开始提取多个数据块,并分别对每个小数据块内容进行加密)**
-
若文件大小大于0x400000(4MB),则基于运算计算数据块大小,若计算后结果依然大于0x400000,则数据块大小为0x400000**(按大数据块整个加密:从文件加密偏移位置开始提取整个数据块,并直接对整个数据块内容进行加密)**
-
-
第一层校验:调用project1_common_GetBlocksAmount函数,并根据返回值判断是否对此文件进行加密
-
第二层校验:从***加密偏移位置***处开始读取块大小的文件内容,若实际读取数据大小等于块大小,则加密文件
基于上述加密算法逻辑,以1fd07b8d1728e416f897bef4f1471126f9b18ef108eb952f4b75050da22e8e43样本作为案例样本(加密偏移位置:9),梳理不同文件大小的加密情况如下:
文件大小 | 块大小 | 第一层校验返回值 | 第二层校验返回值 | 是否加密 | 加密方法 |
---|---|---|---|---|---|
0x9 | 0x10 | 0x0 | false | false | |
0xa | 0x10 | 0x0 | false | false | |
0x17 | 0x10 | 0x1 | false | false | |
0x18 | 0x10 | 0x1 | false | false | |
0x19 | 0x10 | 0x1 | true | 「true」 | 按小数据块分别加密 |
0x1a | 0x10 | 0x1 | true | 「true」 | 按小数据块分别加密 |
0xfff | 0x10 | 0xff | true | 「true」 | 按小数据块分别加密 |
0x1000 | 0x1000 | 0x1 | false | false | |
0x1001 | 0x1000 | 0x1 | false | false | |
0x1008 | 0x1000 | 0x1 | false | false | |
0x1009 | 0x1000 | 0x1 | true | 「true」 | 按大数据块整个加密 |
0x100a | 0x1000 | 0x1 | true | 「true」 | 按大数据块整个加密 |
0x1024 | 0x1000 | 0x1 | true | 「true」 | 按大数据块整个加密 |
0x3fffff | 0x3ff000 | 0x0 | true | false | |
0x400000 | 0x400000 | 0x0 | false | false | |
0x400001 | 0x66000 | 0x1 | true | 「true」 | 按大数据块整个加密 |
4. 构建BianLian勒索软件解密工具
通过上述分析,发现BianLian勒索软件的不同样本的AES KEY、AES IV、加密偏移位置均不同,因此,为了能够实现一键化解密,笔者准备从如下角度构建勒索软件解密工具:
-
根据系统中的勒索加密文件情况,自动化匹配提取AES KEY、AES IV及加密偏移位置信息:以系统桌面中的desktop.ini文件作为参考文件,使用多个内置密钥进行解密尝试,若成功解密,则返回对应的AES KEY、AES IV及加密偏移位置信息。
-
借助everything文件搜索工具,提取系统中的勒索后缀文件列表。
-
基于上述BianLian勒索软件的加解密原理,模拟构建针对BianLian勒索软件的解密工具,解密还原原始文件,并将勒索后缀文件重命名为“.bak”文件后缀。
4.1 解密效果
勒索加密后,系统文件截图如下:
使用BianLian勒索软件解密工具解密勒索加密文件,系统文件截图如下:
加密前与解密后的文件MD5信息对比:「(共被勒索加密578个文件,使用BianLian勒索软件解密工具成功解密578个文件,仅有3个文件【系统运行过程中文件内容被修改导致】的MD5不同)」
4.2 代码实现
在这里,笔者将使用golang语言模拟构建BianLian勒索软件的一键化解密工具,详细情况如下:
代码结构如下:
-
main.go
package main
import (
"awesomeProject5/common"
"bytes"
"encoding/hex"
"fmt"
"io/ioutil"
"os"
"os/user"
"path/filepath"
"strings"
)
func main() {
//使用everything导出带勒索软件后缀的文件列表
files := common.FileToSlice("C:\\Users\\admin\\Desktop\\11.txt")
aeskey, aes_iv, offset := getAeskey()
if aeskey == nil {
fmt.Println("提取AES密钥失败")
os.Exit(1)
}
fmt.Println("AES key:", hex.EncodeToString(aeskey))
fmt.Println("AES iv:", hex.EncodeToString(aes_iv))
fmt.Println("offset:", offset)
for _, onefile := range files {
fileSize, err := common.GetFileSize(onefile)
if err != nil {
fmt.Println("Error:", err)
return
}
fmt.Printf("%s,0x%x\n", onefile, fileSize)
file_decodeData := []byte{}
fileData, err := ioutil.ReadFile(onefile)
if err != nil {
fmt.Println("Error reading file:", err)
return
}
len_block := common.Calc_block(fileSize)
if common.GetBlocksAmount(fileSize, len_block, int64(offset)) > 0 {
if fileSize < 0x1000 {
//按小数据块分别加密
if (int64(offset) + len_block) > fileSize {
file_decodeData = append(file_decodeData, fileData...)
} else {
file_decodeData = append(file_decodeData, fileData[:offset]...)
blocks := (fileSize - int64(offset)) / 16
for i := 0; i < int(blocks); i++ {
output, _ := common.DecryptAES(fileData[offset+16*i:offset+16*(i+1)], aeskey, aes_iv)
file_decodeData = append(file_decodeData, output...)
}
file_decodeData = append(file_decodeData, fileData[offset+int(blocks)*16:]...)
}
} else {
//按大数据块整个加密
file_decodeData = append(file_decodeData, fileData[:offset]...)
if (int64(offset) + len_block) > fileSize {
file_decodeData = append(file_decodeData, fileData[offset:]...)
} else {
output, _ := common.DecryptAES(fileData[int64(offset):int64(offset)+len_block], aeskey, aes_iv)
file_decodeData = append(file_decodeData, output...)
file_decodeData = append(file_decodeData, fileData[int64(offset)+len_block:]...)
}
}
common.Writefile(strings.Split(onefile, ".bianlian")[0], string(file_decodeData))
os.Rename(onefile, strings.Split(onefile, ".bianlian")[0]+".bak")
} else {
common.Writefile(strings.Split(onefile, ".bianlian")[0], string(fileData))
os.Rename(onefile, strings.Split(onefile, ".bianlian")[0]+".bak")
}
}
}
func getAeskey() (aeskey []byte, aes_iv []byte, offset int) {
currentUser, _ := user.Current()
desktopDir := filepath.Join(currentUser.HomeDir, "Desktop")
desktopini := filepath.Join(desktopDir, "desktop.ini.bianlian")
aeskeys := []string{"633A56D0388869237C8DA6B7FC09F55DF35408C332614F692D11222583B36B62", "26DD1B400AED80B0980B34BAD76D3BE6599123FA562266B421A14AC8E02ECFA3",
"979412FF08F7D2F0BD5F7E7E2A4919E9BF68CC7AABAB499872EC822DDCDA5307", "4A4105960D2C127D9711AC851BC1F10D17471B5A184CCDADA79003DA82CFDBA2",
"27CFAE34A83C9A7E48060E18A68A233914271DB7D414C838FB1EAEAEA89E5CDE"}
aesivs := []string{"FC55A60A25B0472CFC2C6EBC3DD89DAB", "D518BA928469306D579D625F56D09883", "0FC14323F7A13CCA0569EBCFAE283996",
"5560E19D4B425420F6F5EF387D97065A", "223B67AC534F9938CC7B1F9777A95840"}
offsets := []int{0x9, 0x1, 0x34, 0x41, 0x3d}
for ii := 0; ii < 5; ii++ {
fileSize, _ := common.GetFileSize(desktopini)
len_block := common.Calc_block(fileSize)
fileData, _ := ioutil.ReadFile(desktopini)
file_decodeData := []byte{}
aeskey, _ = hex.DecodeString(aeskeys[ii])
aes_iv, _ = hex.DecodeString(aesivs[ii])
offset = offsets[ii]
if common.GetBlocksAmount(fileSize, len_block, int64(offset)) > 0 {
if fileSize < 0x1000 {
if (int64(offset) + len_block) > fileSize {
file_decodeData = append(file_decodeData, fileData...)
} else {
file_decodeData = append(file_decodeData, fileData[:offset]...)
blocks := (fileSize - int64(offset)) / 16
for i := 0; i < int(blocks); i++ {
output, _ := common.DecryptAES(fileData[offset+16*i:offset+16*(i+1)], aeskey, aes_iv)
file_decodeData = append(file_decodeData, output...)
}
file_decodeData = append(file_decodeData, fileData[offset+int(blocks)*16:]...)
}
} else {
file_decodeData = append(file_decodeData, fileData[:offset]...)
if (int64(offset) + len_block) > fileSize {
file_decodeData = append(file_decodeData, fileData[offset:]...)
} else {
output, _ := common.DecryptAES(fileData[int64(offset):int64(offset)+len_block], aeskey, aes_iv)
file_decodeData = append(file_decodeData, output...)
file_decodeData = append(file_decodeData, fileData[int64(offset)+len_block:]...)
}
}
//system32
if bytes.Contains(file_decodeData, []byte{0x73, 0x00, 0x79, 0x00, 0x73, 0x00, 0x74, 0x00, 0x65, 0x00, 0x6D, 0x00, 0x33, 0x00, 0x32, 0x00}) {
return
}
} else {
fmt.Println(ii, "由于desktop.ini文件未加密,因此暂无法提取AES密钥")
os.Exit(1)
}
}
return nil, nil, 0
}
-
common.go
package common
import (
"bufio"
"bytes"
"crypto/aes"
"crypto/cipher"
"encoding/binary"
"fmt"
"io"
"math/big"
"os"
)
func DecryptAES(ciphertext, key, iv []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
if len(ciphertext) < aes.BlockSize {
return nil, fmt.Errorf("ciphertext too short")
}
if len(ciphertext)%aes.BlockSize != 0 {
return nil, fmt.Errorf("ciphertext is not a multiple of the block size")
}
mode := cipher.NewCBCDecrypter(block, iv)
mode.CryptBlocks(ciphertext, ciphertext)
return ciphertext, nil
}
func GetBlocksAmount(a1 int64, a2 int64, a3 int64) int64 {
var v4 int64
if a2 == -1 {
v4 = -a1
} else {
v4 = a1 / a2
}
v5 := a1 - a3
v6 := ((v5 >> 63) >> 54) + v5
v7 := v5 / 1024
if v7 <= 1024 {
return v4
}
v8 := ((v6 >> 63) >> 54) + v7
v9 := float64(a2) * 0.0009765625 * 0.0009765625
v10 := (0.2 * float64(v8>>10) / v9)
var v11 int64
if v8>>10 >= 1024 {
v11 = (((v8 >> 63) >> 54) + (v8 >> 10)) >> 10
var v12 float64
if (v11 - 101) >= 0x18F {
if v11 <= 500 {
v12 = 0.001
} else {
v12 = 0.00005
}
} else {
v12 = 0.00015
}
return int64(v12) * v11 / int64(0.0009765625*v9)
}
return int64(v10)
}
func Calc_block(fileSize int64) (v27 int64) {
v27 = (fileSize / 0x1000) << 0xc
if fileSize > 0x400000 {
num1 := big.NewInt(fileSize)
hex_2 := "CCCCCCCCCCCCCCCD"
num2 := new(big.Int)
num2.SetString(hex_2, 16)
result := new(big.Int).Mul(num1, num2)
bytes := result.Bytes()
tmp1, _ := BytesToInt_mode(bytes[:len(bytes)-8])
tmp2, _ := BytesToInt_mode(bytes[:len(bytes)-8])
tmp1 = tmp1 >> 3
tmp2 = tmp2 >> 0x3f >> 0x34
tmp3 := tmp1 + tmp2
v27 = int64(tmp3 >> 0xc << 0xc)
if tmp3 >= 0x400000 {
v27 = 0x400000
}
}
if v27 < 16 {
v27 = 16
}
return
}
func BytesToInt_mode(b []byte) (int, error) {
if len(b) == 3 {
b = append([]byte{0}, b...)
}
bytesBuffer := bytes.NewBuffer(b)
switch len(b) {
case 1:
var tmp int8
err := binary.Read(bytesBuffer, binary.BigEndian, &tmp)
return int(tmp), err
case 2:
var tmp int16
err := binary.Read(bytesBuffer, binary.BigEndian, &tmp)
return int(tmp), err
case 4:
var tmp int32
err := binary.Read(bytesBuffer, binary.BigEndian, &tmp)
return int(tmp), err
default:
return 0, fmt.Errorf("%s", "BytesToInt bytes lenth is invaild!")
}
}
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) //打开文件
//fmt.Println(filename, "文件存在,更新文件")
} else {
f, err1 = os.Create(filename) //创建文件
//logger.Logger.Info("文件不存在")
}
//将文件写进去
_, err1 = io.WriteString(f, buffer)
if err1 != nil {
fmt.Println("写文件失败", err1)
return
}
_ = f.Close()
}
func FileToSlice(file string) []string {
fil, _ := os.Open(file)
defer fil.Close()
var lines []string
scanner := bufio.NewScanner(fil)
for scanner.Scan() {
lines = append(lines, scanner.Text())
}
return lines
}
func GetFileSize(filePath string) (int64, error) {
// 打开文件
file, err := os.Open(filePath)
if err != nil {
return 0, fmt.Errorf("error opening file: %v", err)
}
defer file.Close()
// 获取文件信息
fileInfo, err := file.Stat()
if err != nil {
return 0, fmt.Errorf("error getting file info: %v", err)
}
// 获取文件大小
fileSize := fileInfo.Size()
return fileSize, nil
}