Windows 题解在此。
前言/注意事项
使用工具:
该文章可能新手不友好的地方:
- 你需要熟悉 x64dbg 以及 IDA Pro 的基础操作。
- 你需要熟悉 C/C++ 语法
- 部分基础或算法无关内容的操作可能被省略。
- 如果你觉得部分内容需要更详细的解释,可以发表评论指出。
前置信息/MSVC 容器结构体
使用微软 Visual Studio(MSVC)编译的 std::string
容器,大致可以用如下结构体来描述:
struct MsvcStdString {
union {
char[16] inlined;
char* far_ptr;
} buff; // sizeof(buff) = 16
size_t length;
size_t capacity;
}
其它容器的结构体基本也大同小异 - 指针或在结构体内储存;含有一个 capacity
和 length
成员表示其最大大小和当前大小。
解题领红包之二 {Windows 初级题}
出题老师:云在天
直接打开程序观察行为。
根据提示随机输入测试字符串「123456
」回车,得到提示字符串「Error, please try again
」:
Please input password:
123456
Error, please try again
请按任意键继续. . .
因为开始前偷偷用调试器看了下区段名,没有加壳;直接偷懒使用 IDA 打开程序;随手翻翻整理下,代码还是比较清晰的。
在一个看着像 memcmp
的实现后面,紧接着提示成功或失败字符串:
if ( flag_code_incorrect ) {
msg = "Wrong,please try again.";
} else {
msg = "Success";
}
反推条件,有一个检查是否等于 36
,以及几个循环检查输入内容是否相等 ⇒ 检查输入字串长度 + 内容。
检查字符串内容前有两个可疑的调用;第二个函数是「解密密文」:
_004022C0:
mov edx, 0FFFFFFFDh ; "-3"
lea ecx, [ebp-0x40]
call sub_401FE0
_004022CD:
add esp, 0x18
直接在「解密」结束后的 004022CD
下一个断点,然后输入构造的 36 位随机密码,等待断点触发。
触发后可以发现内存 [ebp-0x40]
处存在我们要找的 flag
,将其提交即可:
01216D40 66 6C 40 67 7B 48 40 70 70 79 5F 4E 33 77 5F 65 fl@g{H@ppy_N3w_e
01216D50 40 72 21 32 6F 32 34 21 46 69 67 68 74 69 6E 67 @r!2o24!Fighting
01216D60 21 21 21 7D 00 00 00 00 00 00 00 00 00 00 00 00 !!!}............
※ 软件使用的字符串应该是 MSVC 实现的 std::string
;很多变量可以手动设置该类型。
解密算法
虽然找到了解密后的内容,但是解密过程却还没有研究。
目前已知的内容:
401FE0
负责解密密文
- 加密后的密文是
"ioCj~KCss|bQ6zbhCu$5r57$Iljkwlqj$$$\x80"
只能去看 401FE0
的过程了。一开始可能被大片的 _mm_
开头的“函数调用”给吓到了,但其实这个分支只是编译器给的优化代码(由 dword_42E5DC
的值控制)。
我们直接看底下回退到使用 for
循环逐字节解密的实现即可:
for ( ; i != len; ++i )
*v6++ += key % 26;
简单来说将整个字符串循环一遍,然后每次和 key
相加(参数,为固定值 -3
)。
对应的 C++ 代码实现:
#include <iostream>
#include <string>
std::string g_flag_encrypted = "ioCj~KCss|bQ6zbhCu$5r57$Iljkwlqj$$$\x80";
void decrypt(std::string &buff, char key) {
int delta = key % 26;
for (auto &item : buff) {
item = static_cast<char>(item + delta);
}
}
int main() {
std::string flag(g_flag_encrypted.cbegin(), g_flag_encrypted.cend());
decrypt(flag, -3);
std::cout << "solution for challenge 02: " << flag;
return 0;
}
解题领红包之六 {Windows 高级题}
出题老师:Solly
初版本加了 Themida 壳,发现它利用 COM 组件进行通信,折腾几个小时后无果后放弃。
2024.02.22 日 21 时降低难度,将原本的加密壳 Themida 更换为更为温和的 ASPack,这样的话又能继续分析了。
强壳我唯唯诺诺,没壳我重👊出击!
本题利用了很多反调试检测和异常处理机制来增大逆向分析难度。
Solly 老师很强。但是假如,我是说假如,我若是掏出 Cheat Engine 且不附加调试器,阁下又当如何应对?
(Solly 老师请不要再上强度了 QAQ)
这套 CM 有两个组件 - COM 服务器和 COM 客户端,以 crackme2024
名的命名共享内存来通信。
脱壳
本文的脱壳步骤需要提前将两个可执行文件的 ASLR 行为关闭。
如果不需要静态分析,也可以跳过脱壳部分,直接带壳调试。
关闭 EXE 文件的 ASLR
※ 如果需要使用中文的 PE-Bear,在菜单依次选择【Settings】→【Configure】,在设置窗口将“语言 Language” 从 en_US
切换到 zh_CN
后重启程序即可。
使用 PE-Bear 加载可执行文件,切换到【可选头(Optional Hdr)】选项卡,往下滑找到【DLL Characteristics】。
因为下方可以看到 40
标志位(DLL can move,即开启 ASLR),因此双击 8160
,填入 8120
(因为 0x8160 - 0x40 = 0x8120
)后回车。
最后右键文件并选择【另存为】,输入或选择现有的文件名来保存即可。
提示:你也可以使用 StudyPE+ 来“一键固定基址”,原理是一样的:
客户端脱壳
客户端程序 crackme2024.exe
是 x64 程序,使用魔改的 UPX 壳。
推荐参考内容:
下方为适用于该挑战的脱壳教程:
- 使用 x64dbg 打开该程序。
- 如果地址不是
0000000140027A00
,那么你可能停在了 ntdll 的系统入口。按下 F9
继续运行即可。
- 按下
Ctrl-B
打开搜索框
- 在底下随意输入几个
00
,例如重复 5 次的 00 00 00 00 00
- (注意这是 UPX 壳的特性,其它壳不一定适用)
- 回车确认来完成搜索
- 检索完毕后,第一条检索命中的地址应为
0000000140027C49
。
- 双击进入该地址
- 向上滚动,找到
E9
大跳 - 0000000140027C40 | E9 97BCFDFF | jmp crackme2024.1400038DC |
- 选中它,并按下
F4
让程序运行到该行,然后再按下 F8
步进到 00000001400038DC
这个地址。
- 在 x64dbg 的菜单,选择【插件】→【Scylla】
- 此时 OEP 应当自动填充
00000001400038DC
。
- 按下左侧的【IAT Autosearch】按钮,自动搜寻 IAT。若是询问是否使用“IAT Search Advanced result”,选择【是】。随后确定。
- 按下左侧的【Get Imports】按钮,获取导入表。此时上方导出应看见
kernel32.dll
和 ole32.dll
。
- 按下右侧的【Dump】按钮,将进程转储到硬盘,默认为
crackme2024_dump.exe
。
- 按下右侧的【Fix Dump】按钮,选择刚刚 Dump 时写出的文件名。生成
crackme2024_dump_SCY.exe
这个新的 crackme2024_dump_SCY.exe
就是脱壳后可正常运行的可执行文件了。
服务端脱壳
服务端程序 crackme2024service.exe
是 x86 程序,使用 ASPack 压缩壳处理。
对脱壳有兴趣可以参考下述教程:
和客户端的处理大同小异,但是这次使用 ESP 定律来做;下方为适用于该挑战的脱壳教程:
- 使用 x32dbg 打开该程序,确保调试器停在了
00424001
这个入口处。如果不是,尝试按下 F9
继续执行即可。
- 按一下
F8
步进来执行 pushad
指令,当前指令地址切换到 00424002
。
- 在下方的命令窗口,输入
bph esp, r, 1
并回车来对 ESP 地址设置访问断点:
- 成功后将提示成功设置断点
已于 xxxxxxxx 处设置硬件断点!
。
- 如果你重复执行该指令,则会提示
该地址已存在硬件断点!
。
此时按下 F9
继续执行,可以看到如下指令:
00424422 | 75 08 | jne 0x42442C | <- 停在此处
00424424 | B8 01000000 | mov eax,1 |
00424429 | C2 0C00 | ret C |
0042442C | 68 70714000 | push 0x407170 |
00424431 | C3 | ret |
慢慢按下 F8
步进。抵达 ret
时按下最后一次 F8
来步进:
00407170 | E8 91040000 | call 0x00407606 | <- 最后一次步进后,抵达此处
00407175 | E9 78FEFFFF | jmp 0x00406FF2 |
0040717A | CC | int3 |
抵达地址 0x407170
后,同刚才客户端的 Scylla
工具步骤一样,转储内存并修正导入表即可。
与刚才不同的一点是,按下【Get Imports】后会显示一个 ❌ 标记的项目,右键它并选择【Delete tree node】即可:
客户端
脱壳 - 这个不会,脱下来的无法正常运行,因此只是脱下来方便静态分析。 逼着自己看了下坛子里的教程,整了个能脱了后还能运行的步骤。
首先看看客户端。打开时提示输入 UID 和序列号,会检查长度,要求 35 或更长。
serial_len = -1;
do {
++serial_len;
} while ( serial[serial_len] ); // strlen(serial)
if ( serial_len >= 35 ) {
if ( server_validate(uid, serial) ) { // 正题
// ... 省略 ...
} else {
printf("\nERROR: uid or serial is error\n");
}
}
本体的通信代码是 COM 组件,不是很懂;稍微分析后进行一番整理后的代码如下:
// `vtable` 这个名字大概不准确,其实是调用 COM 的接口。
bool __fastcall server_validate(unsigned int uid, const char *serial)
{
CrackmePpvObj *pCrackMe; // [rsp+30h] [rbp-F8h] BYREF
uint64_t p_serial_good; // [rsp+38h] [rbp-F0h] BYREF
char szSerial[208]; // [rsp+40h] [rbp-E8h] BYREF
LOBYTE(p_serial_good) = 0;
g_found_int3_bp = anti__check_CC_bp(346, 56i64);// 扫描 CC (int3) 断点
if ( CoInitialize(0i64) >= 0 )
{
if ( CoCreateInstance(&rclsid, 0i64, 0x14u, &riid, &pCrackMe) < 0 )
{
printf("\nCreate Server Instance failure. Please run crackme2024service.exe /RegServer as administrator first.\n");
}
else
{
// 拷贝一份
memset(szSerial, 0, 0xC8ui64);
strcpy_s(szSerial, 0xC8ui64, serial);
pCrackMe->vtable->SetUID(pCrackMe, uid); // idx = 7 -- SetUID
// 第一次运行时,该共享内存偏移 0x00 处为计算 UID 的函数指针。
// 执行后将更新为对应的 UID 混淆后的数据(uint64_t)。
if ( *g_shared_buffer )
*g_shared_buffer = (*g_shared_buffer)(*(g_shared_buffer + 2));// update uid buffer
pCrackMe->vtable->SetSerial(pCrackMe, szSerial);// idx = 8, SetSerial
// 成功执行 - 返回 0
// 未初始化等错误 - 返回错误码
// 序列号校验成功 - 设置第一个参数的指针内容为 1
if ( pCrackMe->vtable->CheckSerial(pCrackMe, &p_serial_good) == 1 )// idx = 9, Check
{
LOBYTE(p_serial_good) = 0;
printf("Running failure. Please run crackme2024service.exe /RegServer as administrator first.\n");
}
(pCrackMe->vtable->field_10)(pCrackMe); // 大概是停止服务
}
}
CoUninitialize();
return p_serial_good;
}
注意客户端有一些反调试,可能还有一些我不清楚的手段:
- 爬
PEB
检测 BeingDebugged
探测是否被调试。一般的「反调试器检测」插件通常都能处理。
- 起新线程
- 抛异常并接管,根据堆栈回溯扫
int3
断点;
- 抛异常并接管,检查
ContextRecord->Dr0~3
的值(不确定对不对);
- 简单粗暴的扫返回地址处的
int3
断点;
- 调用未公开方法
CsrGetProcessId
来获取 CRSS.exe
进程 ID,然后使用 OpenProcess
API 来打开它。
- 因为调试器会给目标进程添加
SeDebugPrivilege
权限,若是能成功打开则表示有调试器… 防不胜防啊。
检查到调试器后,会将 g_shared_buffer
储存的函数更改为错误的 UID 混淆方法,或直接改坏该值导致崩溃。
正确的 UID 混淆算法如下:
uint64_t __fastcall sub_7FF695491A90(uint64_t a1) {
return (a1 * a1 * a1) ^ uint64_t{0x323032796C6C6F73};
}
服务端
不是很知道怎么下手,直接翻翻字符串,在 00418F38
发现 crackme2024
字样。
下面没多远就是 C++ 的类函数指针集合了。
.rdata:00418F50 2D 00 00 00 text "UTF-16LE", '-',0
.rdata:00418F54 B0 AC 41 00 dd offset ??_R4?$CComContainedObject@VCATLCrackmeObject@@@ATL@@6B@
; const ATL::CComContainedObject
; <CATLCrackmeObject>::`RTTI Complete Object Locator'
.rdata:00418F58 ; const ATL::CComContainedObject
; <class CATLCrackmeObject>::`vftable'
.rdata:00418F58 B0 1E 40 00 ??_7?$CComContainedObject@VCATLCrackmeObject@@@ATL@@6B@ dd offset sub_401EB0
.rdata:00418F58 ; DATA XREF: sub_401840+9C↑o
.rdata:00418F58 ; sub_401840+B8↑o
.rdata:00418F5C 00 1F 40 00 dd offset sub_401F00
.rdata:00418F60 E0 1E 40 00 dd offset sub_401EE0
.rdata:00418F64 F0 16 40 00 dd offset sub_4016F0
.rdata:00418F68 90 16 40 00 dd offset sub_401690
.rdata:00418F6C 40 15 40 00 dd offset sub_401540
.rdata:00418F70 E0 14 40 00 dd offset sub_4014E0
.rdata:00418F74 80 12 40 00 dd offset CrackMeObjSolly__SetUID
.rdata:00418F78 B0 12 40 00 dd offset CrackMeObjSolly__SetSerial
.rdata:00418F7C 60 13 40 00 dd offset CrackMeObjSolly__Check
.rdata:00418F80 C0 14 40 00 dd offset sub_4014C0
中间没打标注的翻了翻没发现什么重要的东西,直接看底下那三个【CrackMeObjSolly__
】前缀的方法就好。
SetUID
通知服务器当前 UID。
int CrackMeObjSolly__SetUID(CrackMeObjSolly *ctx, int uid)
{
*(_DWORD *)&ctx->p_map_view[8 + ctx->code_has_ebfe] = uid;
int result = 0;
ctx->mapview_offset_00 = 0;
ctx->mapview_offset_04 = 0;
return result;
}
其中 p_map_view
指向共享内存区域,而 code_has_ebfe
则是之前的反调试检测,扫描给定区域是否有死循环(字节码 EB FE
)。
若是检测到则将该值设置为 1
,反之为 0
。
若是被检测到了,效果等价于设置 UID 为其正确值的 256
倍。
SetSerial
通知服务器当前密钥。
代码稍微整理下:
int CrackMeObjSolly__SetSerial(CrackMeObjSolly *ctx, char *p_serial)
{
memset(ctx->decoded_serial, 0, sizeof(ctx->decoded_serial));// 最多 50 项 uint32_t 类型的内容
int array_idx = 0;
char *p_next = nullptr;
while ((p_next = strstr(p_serial, L"-")) != nullptr) {
// 十六进制转数值,若遇到非合法字符则终止。
auto value = strtoul(p_serial, nullptr, 16);
ctx->decoded_serial[array_idx++] = decoded;
p_serial = p_next;
}
// 写出最后一个 '-' 号后的值
ctx->decoded_serial[array_idx] = strtoul(p_serial, nullptr, 16);
// 记录共享内存的两个值
ctx->mapview_offset_00 = *(uint32_t*)ctx->p_map_view[0];
ctx->mapview_offset_04 = *(uint32_t*)ctx->p_map_view[4];
return 0;
}
简单来说就是使用 -
符号来分割,每一段都交给 strtoul
来将字符作为十六进制文本解析,并依次放入 ctx->decoded_serial
中。
Check
请求服务器检查之前的 UID 和密钥是否匹配。
前两个方法看起来还不算太难;那就接着看看最后一个吧(代码经过手动优化整理):
int __stdcall CrackMeObjSolly__Check(CrackMeObjSolly *ctx, bool *a2)
{
auto value_at_00 = *(uint64_t *)&ctx->mapview_offset_00;
uint8_t decoded_str[17]{};
memcpy(decoded_str, ctx->decoded_serial, 16);
// 16 字节,分成 4 个 uint32_t 来用;
// 2 个 uint32_t 一组来处理;
auto* p_result = (uint32_t *)decoded_str;
for(int z = 0; z < 4; z += 2) {
uint32_t left = p_result[z + 0];
uint32_t right = p_result[z + 1];
for(int i = 17; i >= 2; i--) {
temp = left ^ g_table_1[i];
left = right ^ (g_ss_table_3[0xff & (temp >> 24)]
+ (g_ss_table_2[0xff & (temp >> 16)]
^ (g_ss_table_0[0xff & (temp)]
+ g_ss_table_1[0xff & (temp >> 8)])
)); // depend on left, right
right = temp; // depend on left
}
new_right = left ^ g_table_1[1];
new_left = temp ^ g_table_1[0];
p_result[z + 1] = new_right;
p_result[z + 0] = new_left;
}
decoded_str[16] = 0; // 截断字符串
*a2 = value_at_00 == _strtoui64(decoded_str, 0, 16);
return 0;
}
如果读者尝试过实现 DES 或类似的算法,会发现这个和 DES 内使用的费斯妥密码结构(Feistel cipher)很像:将数据分为左右两半,进行数轮加密(DES 中为 16 轮),每轮加密一半的数据并交换左右两半。
费斯妥密码结构
(图改自百科对应条目示意图;其中 F 可以是不可逆函数;K 为当轮参与 F 函数加密的密钥;L/R 代表左右两半的输入数据;⊕ 表示 XOR 操作)
逐步反推,整理后的反向加密算法如下:
inline uint32_t transform_block(uint32_t left)
{
auto temp = left;
uint8_t a = temp;
uint8_t b = temp >> 8;
uint8_t c = temp >> 16;
uint8_t d = temp >> 24;
return (g_ss_table_3[d] + (g_ss_table_2[c] ^ (g_ss_table_0[a] + g_ss_table_1[b])));
}
void check_encipher_serial(uint32_t *p_left, uint32_t *p_right)
{
auto last_left = *p_left;
auto last_right = *p_right;
auto right = last_left ^ g_table_1[0];
auto left = last_right ^ g_table_1[1];
for (int i = 2; i <= 17; i++)
{
auto next_right = right;
auto next_left = left;
left = next_right ^ g_table_1[i];
auto result = transform_block(left ^ g_table_1[i]);
right = result ^ next_left;
}
*p_left = left;
*p_right = right;
}
通过调试验证反向加密算法
毕竟是一个非标准算法,将其当为“黑盒”来搜集输入及其对应的输出(解密内容)。
加入下述注册表选项,可以让 COM 服务器组件主程序在启动时自动附加调试器(记得修改路径):
Windows Registry Editor Version 5.00
[HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Image File Execution Options\crackme2024service.exe]
"Debugger"="C:\\RE\\Tools\\x64dbg\\x32\\x32dbg.exe"
构造正确格式的密钥并输入,在调用 __strtoui64
前观察其解密后的字符串内容:
.text:00401463 50 push eax
.text:00401464 E8 93 9E 00 00 call __strtoui64 ; 此处设置断点,观测 eax 所指向的内存区域。
验证通过后,尝试生成一个“正确”的序列号。可惜的是因为有反调试措施,实际调试是根据错误的码表进行检测的。
当然,使用调试器可以比较方便的调整输入并观测输出内容;我们只需要验证完代码的正确性后再考虑绕过验证/重新 dump 码表即可。
算法整明白后,再利用 Cheat Engine 在不附加调试器的情况下魔改代码区段使其暂停,然后提取正确的码表数据。
使用 Cheat Engine 提取码表
※ 下文中的 Cheat Engine 可能会使用其缩写「CE」来表示。
首先是针对客户端,使其在远程调用 CrackMeObjSolly__Check
前停止执行。
在「内存查看器」添加 Auto Assembler 脚本,然后贴入下述内容:
define(address,"crackme2024.exe"+1DDC)
define(bytes,48 8B 01 FF 50 48)
[ENABLE]
assert(address,bytes)
alloc(newmem,$1000,"crackme2024.exe"+1DDC)
label(code)
label(return)
newmem:
code:
jmp code // 如果需要继续执行,可以利用 CE 的反汇编窗口将其改为 `nop`。
mov rax,[rcx]
call qword ptr [rax+48]
jmp return
address:
jmp newmem // E9 远跳
db 00 // 随便改改。
return:
[DISABLE]
address:
db bytes
// mov rax,[rcx]
// call qword ptr [rax+48]
dealloc(newmem)
※ 注意程序会扫描 EB FE
;此处利用 CE 生成的 E9
大跳跳板刚好。
启动客户端本体后启用该脚本(应用更改),这样就能在输入 UID 和密钥后唤出服务器,但不进行密钥验证以及后续过程;
此时观察任务管理器,可以发现 crackme2024.exe
和 crackme2024service.exe
两个进程都在运行中。
在这个时候就可以提取(dump)码表了。使用管理员权限启动 HxD,然后参考下述操作进行提取:
- HxD 菜单栏「工具」→「读取内存」
- 选择
crackme2024service.exe
- 另起一个 CE 或类似工具,查看 exe 镜像加载的基址
- CE:手动添加地址
crackme2024service.exe
查看。
- 定位到
crackme2024service.exe + 0x1E8D0
处;
- 例如
crackme2024service.exe
的基址是 00910000
,对应的地址就是 0092E8D0
。
- 当然也可以用 CE 添加这个表达式来查看地址;
- HxD 菜单栏「编辑」→「选择范围」,确保窗口底下选中「十六进制」:
- 起始位置输入
0092E8D0
- 选择「长度」,输入【
1000
】(四个连续的码表,每个 0x400
长度)
- 点击「确定」
- 此时窗口会有一个蓝色的选区代表已经选区
- 选择 HxD 菜单的「编辑」→「复制为」→「C」
一番操作后,剪切板就有这 g_ss_table_0
~ g_ss_table_3
这四个完整的码表数据了,每个占 0x400
字节(这些数据一起,导出到一个大数组):
/* crackme2024service.exe (67890)
起始位置(h): 0092E8D0, 结束位置(h): 0092F8CF, 长度(h): 00001000 */
unsigned char rawData[4096] = {
0xB0, 0xCF, 0xFD, 0x62, 0x85, 0xC8, 0x34, 0x3E, 0xF7, 0xEA, 0xB1, 0x7F,
// … 太长了,省略…
0x63, 0x55, 0x4F, 0xDC
};
紧接着在这个选块后面的就是 g_table_1
码表,这个大小是 18 * sizeof(uint32_t)
即 72 (0x48)
字节:
/* crackme2024service.exe (6908) (23/2/2024 上午 10:07:17)
起始位置(h): 0092F8D0, 结束位置(h): 0092F917, 长度(h): 00000048 */
unsigned char rawData[72] = {
0xD8, 0xAD, 0x26, 0x52, 0x20, 0xC8, 0x55, 0x5E, 0x51, 0x9E, 0x0A, 0xA3,
0xC2, 0x06, 0xBB, 0x22, 0x94, 0x17, 0x26, 0x5A, 0x73, 0x93, 0x71, 0x0C,
0x78, 0x13, 0xB8, 0x47, 0x44, 0x66, 0x53, 0xFE, 0xBC, 0xCB, 0x59, 0x46,
0x7D, 0x55, 0xA4, 0xEB, 0x41, 0x2F, 0xD5, 0x60, 0xC4, 0xB2, 0x6B, 0x3D,
0x1B, 0xF0, 0x15, 0x9D, 0xB4, 0x9E, 0x0D, 0x16, 0xBF, 0x07, 0xCA, 0xEC,
0x94, 0xCF, 0xD0, 0x36, 0x37, 0x96, 0x38, 0xAE, 0x9B, 0xB1, 0x68, 0xF8
};
将之前逆向出来的算法内容全部拼接到一起(留心处理下格式),就得到注册机了。
具体代码请参考下方附件的完整代码:
52pj_solly_2024.zip
(10.53 KB, 下载次数: 18)
UID 176017
计算出来的对应密钥并大写处理后为 ECEAD171-E26F0C10-F9758DC1-4AA28386
。
2023.02.26 更新 - 初始码表计算
在 00401B50
初始化方法中,会进行初始化码表的操作:
memmove_0(g_table_1, g_table_1_init, 72);
memmove_0(g_ss_table_0, g_ss_table_0__init_byte_4190A8, 0x1000);
int init_next_rotl_count = NtCurrentPeb()->BeingDebugged; // 未检测到调试器则为 0
int next_rotl_count = init_next_rotl_count;
auto* p_table_1_data = (uint64_t *)g_table_1;
for (int i = 0; i < 72/8; i++) {
p_table_1_data[i] = sub_406610(p_table_1_data[i], 8 * next_rotl_count);// init g_table_1
}
sub_406610
则隐藏了一段 64 位程序代码,一开始分析的时候还没搞明白这是什么;后来发现 33
远跳有点耳熟,搜了下果然是以前看过的“天堂之门”技巧。
将整个函数都 dump 下来到 heavens-gate.bin
,然后使用 ndisasm 来分析吧。注意我在此处 dump 的基址为 0x330000
,读者需要自行针对基址进行一个修正。
; 基址为 0x330000
; 32 位代码使用下述内容反汇编
; 将 00336610 至 00336663 的内存 dump 到 `heavens-gate.bin` 文件
; ndisasm -o 0x336610 -b 32 -e 0 -p intel heavens-gate.bin
00336610 55 push ebp
00336611 8BEC mov ebp,esp
00336613 51 push ecx
00336614 8B4508 mov eax,[ebp+0x8] ; 参数 1 - 表数据 (低位)
00336617 8B550C mov edx,[ebp+0xc] ; 参数 2 - 表数据 (高位)
0033661A 8B4D10 mov ecx,[ebp+0x10] ; 参数 3 - 移动位数
0033661D 53 push ebx
0033661E EA256633003300 jmp 0x33:0x336625 ; 进入天堂之门(64 位代码执行模式)
; 使用 NASM 套件的 ndisasm 来使用 x64 模式反编译
; ndisasm -o 0x336625 -e 0x15 -b 64 -p intel heavens-gate.bin
00336625 48C1E020 shl rax,byte 0x20
00336629 480FA4C220 shld rdx,rax,0x20 ; rdx = (rax & 0xFFFFFFFF) | uint64_t{rdx << 32}
0033662E 48B8736F6C6C7978 mov rax, 'sollyx64' ; rax = 0x343678796c6c6f73
-3634
00336638 48D3C0 rol rax,cl
0033663B 4833C2 xor rax,rdx ; rax = (0x343678796c6c6f73 rol cl) ^ rdx
0033663E 488BD0 mov rdx,rax
00336641 48C1EA20 shr rdx,byte 0x20 ; rdx = rax >> 0x20
; 将高 32 位导出到 edx 中,低 32 位仍然保留在 eax 中。
; 准备退出天堂之门
00336645 6A00 push byte +0x0
00336647 6857663300 push qword 0x336657
0033664C C744240423000000 mov dword [rsp+0x4],0x23
00336654 FF2C24 jmp dword far [rsp]
; 后续回到 32 位现场,使用下述指令进行反汇编
; ndisasm -o 0x336657 -e 0x47 -b 32 -p intel heavens-gate.bin
; 还原现场 (退出时的 jmp 入栈了 2 个 uint64_t 大小值)
00336657 59 pop ecx
00336658 59 pop ecx
00336659 59 pop ecx
0033665A 59 pop ecx
; 还原现场,准备退出
0033665B 5B pop ebx
0033665C 59 pop ecx
0033665D 8BE5 mov esp,ebp
0033665F 5D pop ebp
00336660 C20C00 ret 0xc
理解后就可以在 C++ 重新实现了:
#include <bit>
#include <cstdint>
uint64_t sub_406610(uint64_t value, int rotl_count)
{
constexpr uint64_t kKeySollyX64 = 0x343678796c6c6f73;
return value ^ std::rotl(kKeySollyX64, rotl_count);
}
注意:你仍然需要 dump 对应的初始值才能得到最终的码表。
memmove_0(g_table_1, g_table_1_init, 72); // 0041A0B8
memmove_0(g_ss_table_0, g_ss_table_0__init_byte_4190A8, 0x1000); // 004190A8
所以看起来还是直接 dump 处理好的码表更方便…?
如果需要参考完整的码表计算实现,可以查看下方的附件:
52pj_solly_2024_table.zip
(7.27 KB, 下载次数: 1)
2023.02.26 更新 - 服务器端的调试器检测与绕过
程序在 00401CEB
开始处手动注册了个结构化错误处理(SEH)处理器来接管错误:
.text:00401CEB 50 push eax ; (old rotation value + 4) / 9
.text:00401CEB ; eax = 1
.text:00401CEC 68 00 65 40 00 push offset sub_406500 ; 手动注册 SEH 的处理函数地址
.text:00401CF1 64 A1 00 00 00 00 mov eax, large fs:0 ; FS:[0] is the pointer to ExceptionList
.text:00401CF7 50 push eax ; push old exception pointer to stack
.text:00401CF8 64 89 25 00 00 00 00 mov large fs:0, esp ; 修改 SEH 链表
随后立即抛出错误:
.text:00401CFF 33 04 24 xor eax, [esp] ; eax 置零
.text:00401D02 31 00 xor [eax], eax ; 写空指针导致内存访问错误
来到错误处理方法:
int __cdecl sub_406500(_EXCEPTION_RECORD *error_code, int __unused_1, _CONTEXT *ctx) {
// 只处理访问异常错误事件
if ( error_code->ExceptionCode != EXCEPTION_ACCESS_VIOLATION ) return 0;
if ( !LOBYTE(ctx->Dr7) ) // 检测是否启用了硬件断点,未检测到则继续。
{
auto Eip = ctx->Eip;
ctx->Eax = Eip; // 将 eax 设置为当前指令所在的地址
ctx->Eip = Eip + 2; // 跳过有问题的 xor [eax], eax 指令
return 0;
}
// 省略 ...
}
回到源程序,eax
被设置为 0x00401D02
(字节码 31 00
);获取偏移 1 位置的值则是 00
。
.text:00401D04 3E 0F B6 40 01 movzx eax, byte ptr [eax+1] ; eax = 0
.text:00401D09 将初始值储存到 ebp-18 和 ebp-14 的缓冲区中。
.text:00401D09 89 45 EC mov [ebp-14h], eax
.text:00401D0C 89 45 E8 mov [ebp-18h], eax
终于把正确的 Seed 给找到了。之前调试器被检测到,所以得到的初始值是 0x40
而计算错误。
另外如果需要补丁服务器端来绕过检测,可以做出如下更改:
00401CAB - 改 and eax, 0
00406794 - 改 and eax, 0
004019C0 - 改 and eax, 0
00406514 - 改强制跳转 "EB 45"
(希望我没有遗漏…)
关于 SEH 相关技巧的参考内容:
用来测试 SEH 和硬件断点对抗的代码:
#include <Windows.h>
#include <cstdio>
LONG WINAPI MyUnhandledExceptionFilter(struct _EXCEPTION_POINTERS* ExceptionInfo)
{
printf("ExceptionCode: 0x%08X\n", ExceptionInfo->ExceptionRecord->ExceptionCode);
printf("ExceptionAddress: 0x%p\n", ExceptionInfo->ExceptionRecord->ExceptionAddress);
printf("Dr0: 0x%08llx\n", ExceptionInfo->ContextRecord->Dr0);
printf("Dr1: 0x%08llx\n", ExceptionInfo->ContextRecord->Dr1);
printf("Dr2: 0x%08llx\n", ExceptionInfo->ContextRecord->Dr2);
printf("Dr3: 0x%08llx\n", ExceptionInfo->ContextRecord->Dr3);
printf("Dr6: 0x%08llx\n", ExceptionInfo->ContextRecord->Dr6);
printf("Dr7: 0x%08llx\n", ExceptionInfo->ContextRecord->Dr7);
return EXCEPTION_EXECUTE_HANDLER;
}
int main() {
SetUnhandledExceptionFilter(MyUnhandledExceptionFilter);
int* ptr = NULL;
*ptr = 1;
return 0;
}
可以发现 0~3 Dr
寄存器 (Debug Register/调试寄存器) 对应四个硬件断点的地址,以及 Dr7
表示哪些硬件断点已启用。
结语
反调试用得比较多,比较考验日常积累… 异常处理我玩不明白…
Themida 这类加密壳,难受起来是真的难受。
算法本身倒不是特别复杂,耐心跟一跟还是能出货的。
碎碎念
在【2024-2-23 07:27】发帖 表示自己找到答案但无法提交,以为次日就截止没法提交了在哭哭;
然后当日下午发现,早上 11 时的时候有人报告验证错误也修正了问题,但没给我补发提交次数,继续哭哭… 还好次日凌晨提交成功,至少证明我当时的分析是没有问题的 😎
题外话:我在去年 8 月制作的一个 CM 也用到了类似的加密方案来处理密钥(无壳无反调试),没看到有人折腾出有效的算法注册机。欢迎挑战哦。