x科网络模拟器活动向导密码加密分析(IDA静态分析+x64dbg动态追踪值)
本篇记录一下我刚入论坛发新人贴的后续,本人的新人贴链接(x科模拟器活动向导绕过分析https://www.52pojie.cn/thread-2090317-1-1.html),下图是刚入吾爱论坛的技术,当时只会个x64dbg动调,后来也是参加了吾爱破解的解题领红包活动,通过2026年的解题领红包活动也成为了自己口中所谓的大佬了,这次活动大佬们题解一发出来,我就开始学习大佬们解题的思路,也顺便把IDA的用法也学会了,再加版主评论了我之前的新人贴,又激起了我对这个密码加密逻辑的好奇心,于是我就去拿IDA尝试了一下,果然还真找到了这个验证的加密逻辑,果然在吾爱论坛没有白学,因此才有了这篇文章。感谢吾爱论坛各位大佬发的贴子,让我有能够写这篇文章的能力。
直接接上上篇帖子,还是那个关键call,当时只会动态调试,不会ida静态分析也是走了很多弯路,这下会了IDA就可以进行分析这个关键call了,上篇文章我说到过了这个call后明文就会变成密文,所以逻辑都在这个call里面,接下来上IDA进行分析。当然一开始x64dbg分析出来的静态代码段不能浪费掉,必须这俩结合起来进行分析,由于我在64dbg中分析到了关键的call,就可以在ida中定位了,这个定位也是比较有技巧的,因为ida中是静态的,不能完全按64dbg的地址来,而且这个程序又很大有80多MB,ida中分析起来很费时间和内存。下边说一下我定位的技巧。
用到一个概念:偏移量(RVA,相对虚拟地址),动态基址7FF7BE520000、动态地址7FF7 C0E9 4222 、RVA = 动态地址 - 动态基址,通过ida中查看静态基址+RVA就是对应的代码段,查看方法:
用ida python插件查看静态基址
import idaapi
print(hex(idaapi.get_imagebase()))
shift+F2执行代码得到 0x140000000
拿这两个地址相减得到2974222,也可以直接复制,
从ida中得到静态0x140000000,然后将静态+RVA=(0x142974222)就是对应的静态汇编指令,在IDA中按大写的G输入0x142974222就到了
接下来就是按F5大法来进行静态代码的分析了,双击这个call的函数,发现里面又有一个jmp,进入jmp后的那个函数就是真正处理加密的函数。伪代码如下,丢给ai进行初步的分析,分析如下:
__int64 __fastcall sub_1426F70C0(__int64 a1, __int64 **a2, __int64 **a3)
{
unsigned int v6; // esi
__int64 v7; // r8
__int64 v8; // r8
unsigned __int8 *v9; // r14
_QWORD *v10; // rax
_QWORD *v11; // r9
unsigned __int64 v12; // rdi
__int64 v13; // r15
_QWORD *v14; // rbx
_QWORD v16[2]; // [rsp+38h] [rbp-A1h] BYREF
__int64 v17; // [rsp+48h] [rbp-91h]
unsigned __int64 v18; // [rsp+50h] [rbp-89h]
_QWORD v19[3]; // [rsp+58h] [rbp-81h] BYREF
unsigned __int64 v20; // [rsp+70h] [rbp-69h]
_BYTE v21[96]; // [rsp+80h] [rbp-59h] BYREF
_QWORD v22[2]; // [rsp+E0h] [rbp+7h] BYREF
v22[0] = a1; // 保存输出参数
v6 = 0; // 循环计数器,处理前16字节
((void (__fastcall *)(_BYTE *))sub_14003030A)(v21); // 初始化临时字符串对象 v21
// ---- 将 a2 的内容追加到 v21 ----
v7 = *((unsigned int *)a2 + 4); // a2 的当前长度(实际存储的字符数)
if ( (unsigned __int64)a2[3] >= 0x10 ) // 检查是否使用堆分配(小字符串优化)
a2 = (__int64 **)*a2; // 如果是堆分配,则取数据指针
((void (__fastcall *)(_BYTE *, __int64 **, __int64))sub_14001C87D)(v21, a2, v7); // 将 a2 数据追加到 v21
// ---- 将 a3 的内容追加到 v21 ----
v8 = *((unsigned int *)a3 + 4); // a3 的当前长度
if ( (unsigned __int64)a3[3] >= 0x10 )
a3 = (__int64 **)*a3;
((void (__fastcall *)(_BYTE *, __int64 **, __int64))sub_14001C87D)(v21, a3, v8); // 将 a3 数据追加到 v21
// ---- 将 v21 的内容复制到 v22 字符串对象 ----
((void (__fastcall *)(_QWORD *, _BYTE *))sub_140040EDF)(v22, v21); // v22 现在包含拼接后的完整字符串
// ---- 初始化输出缓冲区 v16(类似 std::string)----
v16[0] = 0; // 小字符串优化:长度<16时直接存储在栈上,否则为堆指针
v17 = 0; // 当前长度
v18 = 15; // 容量(小字符串缓冲区大小)
v9 = (unsigned __int8 *)v22; // v9 指向拼接字符串的起始地址
do
{
// ---- 将当前字节转换为两位十六进制字符串 ----
v10 = (_QWORD *)((__int64 (__fastcall *)(_QWORD *, _QWORD, __int64))sub_140056AF0)(v19, *v9, 2);
// sub_140056AF0 实际是调用 QString::number 将字节转成十六进制,参数2表示进制16
// 返回结果存入 v19(也是一个字符串对象),内容如 "0a"
v11 = v10;
v12 = v10[2]; // 转换结果的字符串长度(应该是2)
if ( v10[3] >= 0x10u ) // 检查是否堆分配
v11 = (_QWORD *)*v10; // 取实际数据指针
v13 = v17; // 当前输出缓冲区已用长度
if ( v12 > v18 - v17 ) // 如果剩余容量不够,则扩容
{
((void (__fastcall *)(_QWORD *, unsigned __int64, _QWORD, _QWORD *, unsigned __int64))sub_140022E80)(
v16, // 目标字符串
v12, // 需要增加的长度
0, // 填充字符(未使用)
v11, // 源数据
v12); // 源长度
}
else
{
// 容量足够,直接追加
v17 += v12;
v14 = v16;
if ( v18 >= 0x10 ) // 如果使用堆缓冲区,则取堆指针
v14 = (_QWORD *)v16[0];
((void (__fastcall *)(char *, _QWORD *, unsigned __int64))memmove)((char *)v14 + v13, v11, v12);
*((_BYTE *)v14 + v12 + v13) = 0; // 追加字符串终止符
}
// 清理临时字符串 v19(转换结果),防止内存泄漏
if ( v20 >= 0x10 )
{
if ( v20 + 1 >= 0x1000 && (unsigned __int64)(v19[0] - *(_QWORD *)(v19[0] - 8LL) - 8LL) > 0x1F )
invalid_parameter_noinfo_noreturn();
((void (*)(void))sub_14004A6F1)();
}
++v6; // 处理下一个字节
++v9; // 移动源指针
}
while ( v6 < 0x10 ); // 只处理拼接字符串的前16个字节
// ---- 将最终构建的十六进制字符串复制到输出参数 a1 ----
((void (__fastcall *)(__int64, _QWORD *))sub_14001D714)(a1, v16);
// 清理输出缓冲区 v16(如果使用了堆内存)
if ( v18 >= 0x10 )
{
if ( v18 + 1 >= 0x1000 && (unsigned __int64)(v16[0] - *(_QWORD *)(v16[0] - 8LL) - 8LL) > 0x1F )
invalid_parameter_noinfo_noreturn();
((void (*)(void))sub_14004A6F1)();
}
return a1;
}
从中分析得到并不是单纯的MD5,而是加了盐,关键函数在此sub_14001C87D。该函数被调用两次:第一次将密码(a2)追加到上下文,第二次将盐(a3)追加到上下文,从而实现 密码+盐 的拼接效果。其实在这里我分析了里面的很多函数,结果都不是,最后才定位到这里的。
进入这个函数后进行F5大法得到伪代码
void __fastcall sub_1407A8D60(__int64 a1, __int64 a2, unsigned int a3)
{
unsigned int v6; // edx
unsigned int v7; // r9d
unsigned int v8; // ebx
__int64 v9; // r8
__int64 v10; // rcx
__int64 v11; // r8
unsigned int v12; // edi
__int64 v13; // r9
// 计算当前已处理字节数对64取模(即缓冲区中已有的字节数)
v6 = (*(_DWORD *)(a1 + 16) >> 3) & 0x3F;
// 更新总位数(原总位数 + 本次输入的位数)
v7 = *(_DWORD *)(a1 + 16) + 8 * a3;
*(_DWORD *)(a1 + 16) = v7;
if ( v7 < 8 * a3 ) // 如果溢出,则增加高32位计数
++*(_DWORD *)(a1 + 20);
v8 = 64 - v6; // 缓冲区剩余空间
*(_DWORD *)(a1 + 20) += a3 >> 29; // 将高32位的位数加上去(a3>>29是a3的位数的第29-31位)
if ( a3 < 64 - v6 ) // 如果本次数据不足以填满缓冲区
{
v8 = 0; // 标记无需压缩
goto LABEL_14; // 直接拷贝到缓冲区尾部
}
// 否则,先填满当前缓冲区
v9 = v8;
v10 = a1 + v6 + 24LL; // 缓冲区起始地址 + 已有字节数
if ( v8 )
{
if ( v10 )
{
if ( a2 )
{
((void (__fastcall *)(__int64, __int64, _QWORD))memcpy)(v10, a2, v8); // 将前v8字节拷贝到缓冲区尾部
goto LABEL_10;
}
((void (__fastcall *)(__int64, _QWORD, _QWORD))memset)(v10, 0, v8);
}
*errno() = 22;
invalid_parameter_noinfo();
}
LABEL_10:
// 对刚填满的缓冲区进行MD5压缩(即处理一个64字节块)
sub_1407A8280(a1, a1 + 24, v9);
// 继续处理后续完整的64字节块
for ( ; v8 + 63 < a3; v8 += 64 )
sub_1407A8280(a1, a2 + v8, v11);
v6 = 0; // 缓冲区剩余字节数归零(因为已全部处理)
LABEL_14:
// 处理剩余不足64字节的数据,直接拷贝到缓冲区末尾
v12 = a3 - v8;
if ( !v12 )
return;
v13 = a1 + v6 + 24LL; // 缓冲区起始地址 + 当前偏移(v6此时为0或之前剩余)
if ( v13 )
{
if ( a2 + v8 )
{
((void (__fastcall *)(__int64, __int64, _QWORD))memcpy)(v13, a2 + v8, v12);
return;
}
((void (__fastcall *)(__int64, _QWORD, _QWORD))memset)(v13, 0, v12);
}
*errno() = 22;
invalid_parameter_noinfo();
}
通过ai分析出来这就是一个标准的MD5加密函数,所以再逆向这个代码也是不会有太多的进展的。而由于这个程序比较大,ida动态调试起来比较麻烦,所以请出x64dbg来动态调试,当然在调试之前要分析好sub_14001C87D函数具体有什么参数传进来。由于这个是两个部分合起来的追加函数,接下来就是在程序里动态调试拿到谁合并起来取了md5值?
这里有两次调用了sub_14001C87D,而且这个又是拼接函数,看看它拼接了什么值,得在64dbg中对应上这两个call。
((void (__fastcall *)(_BYTE *, __int64 **, __int64))sub_14001C87D)(v21, a2, v7);
v8 = *((unsigned int *)a3 + 4);
if ( (unsigned __int64)a3[3] >= 0x10 )
a3 = (__int64 **)*a3;
((void (__fastcall *)(_BYTE *, __int64 **, __int64))sub_14001C87D)(v21, a3, v8);
从这里对应上了这两个call,接下来就是从第一个call那里下断点,看看有什么值进来。还是输入123进行测试,断下寄存器值分别如下:
第二个call时拼接了两个123,我发现用两个123拼接出来取md5的值也不对,第一个call下断点的时候看着没有字符串拼接出来,我一直在第二个call卡着,后来不得不转到第一个call进行分析,ai分析结果是:第二个参数(rdx):要添加的数据指针。我一看是个指针我直接转到了第一个的rdx内存中。主要是开始看着ASCII是乱码以为不对,最后谁能知道是拼接的这串十六进制二进制数据。
最后尝试了一下123+C0 A8 01 BE 79 39 23 11 A0 9B C6 02这串二进制流,python脚本试一下:
import hashlib
# 用户提供的十六进制数据(12 字节)
extra_data = bytes.fromhex("C0 A8 01 BE 79 39 23 11 A0 9B C6 02")
# 密码 "123" 的 UTF-8 字节
password = b"123"
# 尝试两种拼接顺序
combined1 = password + extra_data
combined2 = extra_data + password
md5_1 = hashlib.md5(combined1).hexdigest()
md5_2 = hashlib.md5(combined2).hexdigest()
print(f"MD5(123 + extra) = {md5_1}")
print(f"MD5(extra + 123) = {md5_2}")
# 已知的 MD5 结果
known = "c471ece3c350ba08d8bcb88f783e1644"
print(f"已知哈希: {known}")
运行结果是:
MD5(123 + extra) = 69cd96acf8eac571b37442d4c6c54b3e
MD5(extra + 123) = c471ece3c350ba08d8bcb88f783e1644
已知哈希: c471ece3c350ba08d8bcb88f783e1644
然后我去动态调试那里拦截加密call的结果是:
好家伙还真是一模一样。
不过分析到这里也对密码还原的用处也不算很大,因为它加了前面的固定字节,让还原难度变大,可以用hashcat来进行,也只限于纯数字的密码起到明显作用,加上字符和特殊符号成功率就比较低了,现在回想起来当初写的思路还是比较成功的,直接更改hash值就可以实现绕过了,算是很简单的方法了。用cheat engine扫描出指针来就能得到密文hash,然后再进行暴力破解理论是可以还原明文的,不过得等到n年以后,我也不知道多久才能暴力出来,不知道是几位也不知道有没有特殊字符。
不过在论坛这段时间的学习还是比较成功的分析了这串密文的拼接逻辑。以后继续向各位大佬们学习逆向知识提升自己。