分析某高校学习通考试客户端(一)
这个学习通终端貌似是某个高校专用的,运行之后只能使用该高校的账号登录。听说一些高校使用的东西(ppt或者软件)都是远古版本,这次分析的客户端也挺老的,可能是为了兼容性采用了32位架构。
下面的分析基于最新版本的Windows11,相关调用和堆栈变量经过了我的重命名,这样大家看起来就比较直观。
基本分析思路
首先它没有被加壳,并且字符串和IAT都是未被处理的:


看一眼导入表和字符串后很快就可以找到关键代码。结合IAT和字符串引用下断点,放到调试器里稍微分析一下很快就有了结果。
虽然有些反调试手段,但是拿个x96插件直接绕过就行。它有两个进程,我们选择有标题那个:

虚拟机检测分析
.text:00423C80 CXECheckEnv
txt
.text:00423C80 ; 这是总检测函数,包括时间、摄像头、虚拟机等检测
.text:00423C80 ; Attributes: bp-based frame
.text:00423C80
.text:00423C80 ; int __thiscall CXECheckEnv(char *this)
.text:00423C80 CXECheckEnv proc near ; CODE XREF: sub_425350+3↓j
CXECheckEnv -> sub_4209C0 -> CXEFindProcessInfo
.text:0047E1D0 CXEFindProcessInfo
txt
.text:0047E1D0 ; ===== CXEFindProcessInfo: 枚举系统所有进程并收集信息 =====
.text:0047E1D0 ; 参数 a1: 进程信息容器(vector), 每个元素大小 0x80 字节
.text:0047E1D0 ; 功能: 遍历所有进程, 收集进程名(小写)、完整路径、FileDescription
.text:0047E1D0 ; Attributes: bp-based frame
.text:0047E1D0
.text:0047E1D0 ; char __stdcall CXEFindProcessInfo(_DWORD *)
.text:0047E1D0 CXEFindProcessInfo proc near ; CODE XREF: sub_4209C0+564↑p
这里调用了CreateToolhelp32Snapshot:TH32CS_SNAPPROCESS、0。
我一度以为这是检测虚拟机相关的代码,CXEFindProcessInfo可能为虚拟机检测提供相关信息,但是经过调试后发现这里似乎没什么影响。检测真正发挥作用的是在另外一个函数。
在找到真正的虚拟机检测函数之前,我尝试过hook CreateToolhelp32Snapshot和ProcessNextW函数过滤VM相关的信息,但是貌似这个学习通终端对这两个函数有检测?
经过我的调试,我的hook是没问题的,但是运行一段时间后学习通程序就直接弹出崩溃对话框,我怀疑它对这两个函数进行了检测。具体怎样我没分析,因为我后面找到了真正发挥作用的虚拟机检测函数。
sub_47F720 VM检测函数

里面的相关调用我重命名了,这样看起来比较直观。
首先是CXECheckVMByVpcext函数:

vpcext 是 Virtual PC / Hyper-V 的 hypervisor 接口指令。物理机和普通虚拟机上执行会触发 非法指令异常 (UD),被 SEH 捕获。如果在 VPC 中,指令正常执行并修改 ebx。
然后是sub_47F670:

端口 0x5658 是 VMware 的 后门 I/O 端口。in 指令在非 VMware 环境会触发 GP异常,被 SEH 捕获返回 false。在 VMware 中,hypervisor 拦截此 I/O 并返回 "VMXh" 到 ebx。
绕过
直接打补丁就行,它没检测关键函数是否被篡改。我直接让sub_47F720返回0:

键盘检测分析
看到SetWindowsHookExW就知道它肯定没干好事,在sub_4525B0中:

分析KeyBoardCallbackFunc回调:
c
// KeyBoardCallbackFunc - WH_KEYBOARD_LL 低级键盘钩子回调
LRESULT __stdcall KeyBoardCallbackFunc(int code, WPARAM wParam, WPARAM *lParam)
{ // code!=0时直接放行(非本钩子处理的消息)
bool ctrlDown; // bl
SHORT AsyncKeyState; // ax
WPARAM vkCode; // edi
bool altDown; // al
bool isShiftKey; // bh
bool isCtrlKeyRaw; // cl
bool isKeyUp; // bl
int unused_ecx; // ecx
char isWinKey2; // cl
char isKeyDown2; // al
bool ctrlKeyDown2; // bh
WPARAM vkCodeTmp1; // eax
WPARAM vkCodeTmp2; // eax
char altAndShiftKey; // [esp+8h] [ebp-410h]
char shiftAndCtrlKey; // [esp+9h] [ebp-40Fh]
bool ctrlAndShiftOrSpace; // [esp+Ah] [ebp-40Eh]
bool altKeyDown; // [esp+Bh] [ebp-40Dh]
bool isCtrlKey; // [esp+Ch] [ebp-40Ch]
char isAltKey; // [esp+Dh] [ebp-40Bh]
unsigned __int8 winKeyDown; // [esp+Eh] [ebp-40Ah]
bool shiftKeyDown; // [esp+10h] [ebp-408h]
char isWinKey; // [esp+11h] [ebp-407h]
bool ctrlKeyDown; // [esp+12h] [ebp-406h]
char isKeyDown; // [esp+13h] [ebp-405h]
CHAR OutputString[1024]; // [esp+14h] [ebp-404h] BYREF
if ( code )
return CallNextHookEx(hhk, code, wParam, (LPARAM)lParam);
if ( GetKeyState(91) < 0 || (winKeyDown = 0, GetKeyState(92) < 0) )// === 阶段1: 检测所有修饰键状态 ===
// VK_LWIN=0x5B, VK_RWIN=0x5C
winKeyDown = 1;
ctrlDown = GetAsyncKeyState(17) < 0; // 检测 Ctrl键 (VK_CONTROL=0x11)
ctrlKeyDown = ctrlDown;
shiftKeyDown = GetAsyncKeyState(16) < 0; // 检测 Shift键 (VK_SHIFT=0x10)
AsyncKeyState = GetAsyncKeyState(18); // 检测 Alt键 (VK_MENU=0x12)
vkCode = *lParam;
altDown = AsyncKeyState < 0;
altKeyDown = altDown;
if ( *lParam == 91 || (isWinKey = 0, vkCode == 92) )// 判断当前按键是否为Win键 (0x5B/0x5C)
isWinKey = 1;
isShiftKey = vkCode == 160 || vkCode == 161; // 判断当前按键是否为Shift (VK_LSHIFT=0xA0 / VK_RSHIFT=0xA1)
isCtrlKeyRaw = vkCode == 162 || vkCode == 163;// 判断当前按键是否为Ctrl (VK_LCONTROL=0xA2 / VK_RCONTROL=0xA3)
isCtrlKey = isCtrlKeyRaw;
if ( vkCode == 164 || (isAltKey = 0, vkCode == 165) )// 判断当前按键是否为Alt (VK_LMENU=0xA4 / VK_RMENU=0xA5)
isAltKey = 1;
ctrlAndShiftOrSpace = ctrlDown && (isShiftKey || vkCode == 32);// 组合条件: ctrlAndShiftOrSpace = Ctrl按下 && (Shift键 或 Space键)
if ( !shiftKeyDown || (shiftAndCtrlKey = 1, !isCtrlKeyRaw) )// 组合条件: shiftAndCtrlKey = Shift按下 && Ctrl键
shiftAndCtrlKey = 0;
if ( !altDown || (altAndShiftKey = 1, !isShiftKey) )// 组合条件: altAndShiftKey = Alt按下 && Shift键
altAndShiftKey = 0;
if ( wParam == 256 || (isKeyDown = 0, wParam == 260) )// 判断键盘事件类型: WM_KEYDOWN(0x100)/WM_SYSKEYDOWN(0x104)→按下; WM_KEYUP(0x101)/WM_SYSKEYUP(0x105)→释放
isKeyDown = 1;
isKeyUp = wParam == 257 || wParam == 261;
memset(OutputString, 0, sizeof(OutputString));
sub_435640(
unused_ecx,
(int)OutputString,
(int)"win:[%d,%d],alt:[%d,%d],ctrl:[%d,%d],shift:[%d,%d],key:[%d],down[%d]\r\n",
winKeyDown);
OutputDebugStringA(OutputString); // OutputDebugStringA 输出调试日志: win/alt/ctrl/shift/key状态
isWinKey2 = isWinKey;
isKeyDown2 = isKeyDown;
if ( isWinKey ) // 【规则1】Win键处理: 按下时置标志byte_707D80=1; 释放时若标志已置位→PostMessage(0x61E)屏蔽或直接return 1
{
if ( isKeyDown )
{
byte_707D80 = 1;
}
else
{
if ( byte_707D80 )
{
if ( dword_715780 )
{
PostMessageW(dword_715780, 0x61Eu, *lParam, 68);// 规则1a: PostMessage(0x61E, vkCode, 68) 通知Win键释放+屏蔽
return 1;
}
return 1;
}
if ( dword_715780 )
{
PostMessageW(dword_715780, 0x61Cu, 0, 0);// 规则1b: PostMessage(0x61C) Win键释放通知(未通过标志检测)
isWinKey2 = isWinKey;
}
isKeyDown2 = 0;
}
}
if ( winKeyDown && !isWinKey2 ) // 【规则2】Win键屏蔽: Win按下时,非Space/Shift/Alt/Ctrl的KeyDown→return 1屏蔽
{
if ( *lParam != 32 && !isShiftKey && !isAltKey && !isCtrlKey && isKeyDown2 )
return 1;
byte_707D80 = 0; // Win+修饰键按下列外: 清除byte_707D80标志
}
if ( ctrlAndShiftOrSpace || shiftAndCtrlKey || altAndShiftKey )// 【规则3】组合键通知: Ctrl+Shift/Space 或 Shift+Ctrl 或 Alt+Shift → PostMessage(0x61C)通知窗口后跳转LABEL_82
{
if ( dword_715780 )
PostMessageW(dword_715780, 0x61Cu, 0, 0); // PostMessage(0x61C) 通知窗口: 组合键状态
goto LABEL_82;
}
if ( shiftKeyDown && vkCode == 32 && isKeyUp || isShiftKey && isKeyUp || altKeyDown && isKeyUp && *lParam == 25 )// 【规则4b】Shift键+KeyUp 或 Alt+↓(0x19)+KeyUp → sub_452980()
{
sub_452980(); // 【规则4a】Shift+Space+KeyUp → sub_452980() (触发反调试/反分析)
LABEL_82:
ctrlKeyDown2 = ctrlKeyDown;
goto LABEL_83;
}
ctrlKeyDown2 = ctrlKeyDown;
if ( ctrlKeyDown && *lParam == 190 && isKeyUp )
goto LABEL_71;
if ( !shiftKeyDown && !ctrlKeyDown && !altKeyDown )
goto LABEL_103;
if ( isKeyUp )
{
vkCodeTmp1 = *lParam;
if ( *lParam == 240 || vkCodeTmp1 == 241 || vkCodeTmp1 == 242 )// 【规则6】修饰键+F240/F241/F242(0xF0/0xF1/0xF2)+KeyUp → sub_452980()
LABEL_71:
sub_452980(); // 【规则5】Ctrl+句号(0xBE)+KeyUp → sub_452980() (可能是隐藏/显示窗口)
}
LABEL_83:
if ( altKeyDown && (GetAsyncKeyState(32) < 0 && (*lParam == 78 || *lParam == 110) || vkCode == 32) )
return 1; // 【规则7】Alt+Space+N/n 或 Alt+Space → return 1 屏蔽 (阻止Alt+Space系统菜单)
if ( ctrlKeyDown2 )
{
if ( shiftKeyDown )
{ // 【规则8a】Ctrl+Shift+Esc → return 1 屏蔽 (阻止任务管理器)
if ( vkCode == 27 )
return 1;
}
else if ( vkCode == 27 ) // 【规则8b】Ctrl+Esc → return 1 屏蔽 (阻止开始菜单)
{
return 1;
}
}
if ( altKeyDown )
{
vkCodeTmp2 = *lParam;
if ( *lParam == 9 || vkCode == 27 || vkCodeTmp2 >= 0x70 && vkCodeTmp2 <= 0x87 )
return 1; // 【规则9b】Alt+Esc 或 Alt+F1~F24(0x70~0x87) → return 1 屏蔽
}
if ( ctrlKeyDown2 && (altKeyDown && *lParam == 9 || *lParam == 87 || *lParam == 119) )
return 1; // 【规则10b】Ctrl+W/w → return 1 屏蔽 (阻止关闭窗口/标签页)
LABEL_103:
if ( vkCode == 27 )
{
if ( dword_715780 )
PostMessageW(dword_715780, 0x621u, 0, 0); // === 公共屏蔽出口: return 1 (吞噬按键,阻止传递) ===
return 1;
}
return CallNextHookEx(hhk, 0, wParam, (LPARAM)lParam);// === 放行出口: CallNextHookEx → 按键正常传递给系统 ===
}
具体检测规则
| # | 规则 | 触发条件 | 动作 | 目的 |
|---|---|---|---|---|
| 1 | Win键 | 单按Win键 | PostMessage(0x61E/0x61C),return 1 | 阻止开始菜单 |
| 2 | Win+X | Win按下时按任意非修饰键 | return 1 屏蔽 | 阻止Win+R/ Win+E等 |
| 3 | 组合键通知 | Ctrl+Shift/Space, Shift+Ctrl, Alt+Shift | PostMessage(0x61C) 通知窗口 | 监视组合键 |
| 4 | 修饰键释放 | Shift+Space释放 / Shift释放 / Alt+↓释放 | sub_452980() | 触发反调试 |
| 5 | Ctrl+. | Ctrl+句号(0xBE) 释放 | sub_452980() | 隐藏/显示窗口 |
| 6 | F功能键 | 修饰键+F240/F241/F242 释放 | sub_452980() | 特殊功能触发 |
| 7 | Alt+Space | Alt+Space 或 Alt+Space+N/n | return 1 | 阻止系统菜单 |
| 8 | Ctrl+Esc | Ctrl+Shift+Esc 或 Ctrl+Esc | return 1 | 阻止任务管理器/开始菜单 |
| 9 | Alt组合 | Alt+Tab / Alt+Esc / Alt+F1~F24 | return 1 | 阻止窗口切换 |
| 10 | Ctrl组合 | Ctrl+Alt+Tab / Ctrl+W | return 1 | 阻止关闭窗口 |
| 11 | Esc | 单按Esc | PostMessage(0x621),return 1 | 阻止退出 |
绕过
思路都差不多,让它直接放行就行。看看反汇编:

那我们不让它跳转到loc_4529e0,直接执行CallNextHookEx:
