XX音乐缓存加密算法分析

木星安全实验室 2020-10-17 20:00

背景


本文章作为FreeTalk议题”网络攻防的基石(软件逆向)”的扩展与延伸,版本故意使用早期版本避免争议,商业软件的逆向可能会存在一定的难度,故该文章需要有一定逆向基础才能理解。


01

环境


软件版本:

KUGOU8.0,版本号8204



02

分析

首先用010Editor查看缓存文件的数据内容,可以发现文件开始的0x3C个字节是文件头。0x400位置开始的数据应该是加密后的MP3数据。通过对比不同的缓存文件可以发现,文件头的前16个字节都是一样的。可以猜测这应该是程序用来校验的。



1)查找解密函数

  

程序如何要解密缓存文件必然要先打开文件,所以在CreateFile函数下断点。通过多次尝试发现先打开酷狗音乐下载好一首歌后,关闭网络连接,再从OD中打开酷狗会减少一些干扰。在缓存文件夹中看下刚下载的歌曲的文件名,这里需要先清空缓存文件夹。在OD中打开酷狗程序运行到开启主界面,在CreateFile下断点,点击播放已经缓存好的音乐,程序会断下来。

      

由于酷狗音乐是多线程并且打开文件、设备都会调用CreateFile函数,会造成多次中断。遇到这种情况可以下条件断点,CreateFile的第1个参数是文件路径,可以判断该参数是否是缓存文件夹下的指定文件。

断点写法为:

[UNICODE[esp+4]]==”F:\\KuGou\\Temp\\7e9327c377461a883a8ac6bcdb513c71.kgtemp”



程序中断后通过栈回溯向上查看,这里需要仔细分析断下来后的代码。



跟到netcore模块中可以看到这里有调用FilePath、IsValid等函数的代码。继续向下执行看到多次调用File::Read函数,查看发现在读取缓存文件的文件头中的数据,证明这一段代码应该是要重点分析。



通过仔细分析这段代码,可以知道File::Read函数是对ReadFile函数做了一层封装。第1次调用Read函数从缓存文件开始位置读取出16个字节,和程序中保存的常量数据进行比对,证明了分析前的猜测,这16个字节就是用来校验的。



第2次调用Read函数从0x10位置读取出4个字节,这个值为0x400应该是文件头的大小。第3次调用Read函数从0x14位置读取4个字节,会判断该值是2或者3,猜测可能是版本之类的信息。



第4次调用Read函数从0x18位置读取4个字节,这个值作用不确定,暂时不管它。第5次调用Read函数从0x2C位置读取16个字节,读取出来后没有看到有什么处理。但可以想到它肯定有用,继续向下分析。



在下面下面532518A4位置调用的函数中又调用Read函数



这次从0x1C位置读取16字节数据,在这行代码下面第2个call调用中发现了疑似解密函数的代码



在[eax+4]指向的函数内部只有1个函数调用,继续进入这个call内部可以看到下面这些代码。通过对前面读取出来的16字节数据进行运算最终得到一个新的16字节数据。这里基本可以肯定该函数就是解密函数。但是目前解密函数的参数是从哪来的我们无法确定。



重新运行程序执行到这里,看到该函数有4个参数。第1个参数应该是要解密的数据大小,第2、第3个参数是2个地址,这2个地址上都保存了类似密钥的数据,在解密函数中会用到这些密钥。



2)找到解密函数的参数来源


生成这些密钥的代码肯定在前面多次调用Read函数到最后一次调用Read函数从0x1C位置读取数据之前。重新观察前面的Read函数调用,只有第4、第5次函数调用读取到的数据作用不明确,所以应该重点分析第5次调用Read之后的代码。


通过分析解密函数的参数,解密解密过程中对参数的使用,不难发现这2个参数所在位置是6个地址。前3个地址指第1组密钥,后3个地址指向第2组密钥。3个地址中第1个地址是密钥首地址,第2个地址是密钥结束后的位置,2个地址差就是密钥长度。所以第1组密钥长度16字节,第2组密钥长度17字节。


而且通过前面的多次分析,在CreateFile函数执行返回到netcore模块后,ESI指向的地址是一个类对象的地址,而保存密钥地址的6个数据所在空间是新申请的堆空间,该堆空间首地址保存在类对象的前4个字节中。




后面的分析重点就是找到在类对象前4个字节填写地址的代码。在第5次调用Read函数后第3个call后面将堆空间首地址写入到类对象前4个字节中。进入该函数内分析。



结合IDA分析,在该函数内可以看到调用了new运算符申请了0x28个字节的空间,并该空间初始化为0并在开始位置填入1个函数地址。



进入call [edx+0xC]的函数内部,再深入1层函数就看到调用MD5Sum函数计算MD5值,并多次调用同1个函数



进入call [edx+8]的函数内部,发现和[edx+0xC]函数内部大致相同,第1个call内部都是调用MD5Sum。当[edx+0xC]和[edx+8]这2个函数执行完毕后,堆空间中已经生成了2组密钥并写入其中。



到这里已经可以确定解密函数的参数——2组密钥就是通过这两个函数计算得到的,计算过程就是求MD5值并做一些变换。下面就是详细分析是对哪些值求MD5,MD5的计算过程以及得到MD5后的变换过程。


通过分析第1组密钥的计算过程,发现是对0x272F2C6C这个值求MD5,这个值之前并未出现过。通过播放其它歌曲查看密钥的计算过程,发现该值是固定的,计算出的密钥也都相同。那么我们可以猜测这组密钥是可能就是固定的。



为了保险起见,重新向前查找这个值的来源。发现是在生成解密函数参数前的一个函数内出现的。进入该函数内在一个call [eax+0x10]内部出现了这个种子值,并不是计算得到的。基本可以确定第1组密钥是固定不变的。



函数内部调用



继续分析第2组密钥的计算过程,发现是对缓存文件中0x2C位置读取出的16字节数据求MD5。现在我们可以知道解密文件的2组密钥中第1组是固定的,第2组是根据每首歌的文件头中的16字节数据计算得到的。计算得到MD5后,通过分析知道下面的代码是对MD5值进行移动置换,对同一个函数调用16次,每次移动1个字节,得到16字节的密钥。MD5的计算过程这里不再详述。



添加最后1个字节数据




3)分析解密函数的实现过程


知道参数来源后,就可以分析解密函数的执行过程了。重新回到读取0x1C位置数据的代码处,在读取出数据后调用解密函数对这16字节数据进行解密,之后和系统中的数据进行比对判断是否相同。到此可以知道文件头的数据结构如下:


struct _KUGOU_TEMP_HEADER {

unsigned char headMagic[16];// 通用校验信息

unsigned int headerSize;// 头部的总字节数

unsigned int version;// 只能是2和3,有可能是版本号

unsigned int isEncrypted;// 一般值为1

unsigned char unknow2[16];// 被加密的校验信息,解密后进行比对

unsigned char key[16];// 用于生成密钥的信息值

};



现在要详细分析解密函数的执行过程,在解密函数处和ReadFile下断点,这里要注意ReadFile的文件句柄是我们之前点击播放缓存文件句柄。触发断点后紧跟着就是调用解密函数,通过多次分析发现解密函数共有6个参数,4个通过push传递,2个通过寄存器传递。ECX的值是要解密的数据在真正的MP3文件的位置,即从缓存文件中读取的位置减去0x400得到的值,因为缓存文件的文件头0x400在真正的MP3文件中是不存在的,这个值会在解密过程中用到。



通过压栈传递的第4个参数一般都是0,决定在解密函数中使用第2中解密算法。因此只需要将第2种算法分析出来即可。之后就可以编写代码了。




                                                     代码如下


#include <windows.h>

#include <stdio.h>


void CreatePara(BYTE* pMessage, BYTE* pDigPar);

void KeySwap(BYTE* pKey);

void EncryptData(BYTE* pBuff, DWORD dwSize, BYTE* pKey1, BYTE* pKey2);



int main()

{

DWORD KeyArray1[] = { 0xB110E314, 0x416F3B0D, 0x27796B85, 0x8561FD8B };

DWORD KeyArray2[5] = { 0 };

DWORD DigestPar[4] = { 0x67452301,0xEFCDAB89,0x98BADCFE,0x10325476 };//计算MD5的参数

BYTE CheckByte[64] = { 0 };


char path[MAX_PATH] = { 0 };

gets_s(path, MAX_PATH);

HANDLE hfile = CreateFileA(path, GENERIC_READ, FILE_SHARE_READ, NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL);

DWORD dwSize = GetFileSize(hfile, NULL);


SetFilePointer(hfile, 0x2C, NULL, FILE_BEGIN);

DWORD dwRead = 16;

ReadFile(hfile, CheckByte, dwRead, &dwRead, NULL);

CheckByte[16] = 0x80;// 这2个值在计算MD5过程中要用到

CheckByte[56] = 0x80;


CreatePara(CheckByte, (BYTE*)DigestPar);

memcpy(KeyArray2, DigestPar, 16);// 将计算出的MD5值拷贝到密钥数组中

KeySwap((BYTE*)KeyArray2);

KeyArray2[4] = 0x6B;


HANDLE hNewMP3 = CreateFileA("New.mp3",

FILE_ALL_ACCESS,

FILE_SHARE_READ,

NULL,

CREATE_NEW,

FILE_ATTRIBUTE_NORMAL,

NULL);

DWORD dwDataSize = dwSize - 0x400;

BYTE* buff = new BYTE[dwDataSize];


SetFilePointer(hfile, 0x400, NULL, FILE_BEGIN);

ReadFile(hfile, buff, dwDataSize, &dwDataSize, NULL);


// 解密数据

EncryptData(buff, dwDataSize, (BYTE*)KeyArray1, (BYTE*)KeyArray2);


// 将解密后的数据写入文件

WriteFile(hNewMP3, buff, dwDataSize, &dwDataSize, NULL);


delete[] buff;

CloseHandle(hfile);

CloseHandle(hNewMP3);

return 0;

}



// 密钥置换函数

void KeySwap(BYTE* pKey)

{

WORD* pNewKey = (WORD*)pKey;

WORD swap[8] = { pNewKey[7],pNewKey[6],pNewKey[5],pNewKey[4],pNewKey[3],pNewKey[2],pNewKey[1],pNewKey[0] };

memcpy(pKey, swap, 16);

}


// 解密函数

// 参数1:数据缓冲区地址

// 参数2:要解密的数据大小

// 参数3:密钥1地址

// 参数4:密钥2地址

void EncryptData(BYTE* pBuff, DWORD dwSize, BYTE* pKey1, BYTE* pKey2)

{

DWORD Key1Len = 16;// 密钥1长度

DWORD Key2Len = 17;// 密钥2长度

for (DWORD i = 0; i < dwSize; ++i)

{

int res = i % Key2Len;

BYTE Step1 = pKey2[res] ^ pBuff[i];// Key2中res位置的字节和数据区第 i个字节异或

BYTE Step2 = Step1;

Step2 <<= 4;

Step1 ^= Step2;

res = i % Key1Len;// 第8步,计算文件指针位置对密钥1长度的模

DWORD Step3 = i >> 0x18;

BYTE Step4 = (BYTE)Step3;

Step4 ^= pKey1[res];

BYTE Step5 = i >> 8;// 第14步

Step4 ^= Step5;

Step4 ^= Step1;

BYTE Step6 = (BYTE)(i >> 16);

Step4 ^= Step6;

BYTE Step7 = (BYTE)i;

Step4 ^= Step7;

pBuff[i] = Step4;

}

}







木星安全实验室(MxLab),由中国网安·广州三零卫士成立,汇聚国内多名安全专家和反间谍专家组建而成,深耕工控安全、IoT安全、红队评估、反间谍、数据保护、APT分析等高级安全领域,木星安全实验室坚持在反间谍和业务安全的领域进行探索和研究。


推荐阅读