我与学校SafeConnect软件斗智斗勇的经历

版权信息:博主就是本文原创作者,但是本文最早发布于FreeBuf,并属FreeBuf原创奖励计划,如需转载请联系FreeBuf。

故事是这样的,本文作者读书的学校,IT部门要求我们如果使用Windows或者Mac OS要连接学校网络的话,必须安装SafeConnect客户端。这个客户端干的事情就是监视你的系统,确保你安装、启用并及时更新杀毒软件,确保你及时更新电脑上的Flash跟Java,确保你不使用P2P软件。然而我一直很不喜欢这种被监视的感觉,感觉这是侵犯了我的人权,况且我很少用P2P来下载盗版内容,偶尔用P2P一直都是用来下载Linux的安装镜像的,这种宁可错杀一千也不放过一个的做法实在是让人难以忍受。再加上学校的IT部门的人非常官僚,自己还没啥技术,曾经有同学找他们备份数据结果数据没备份成他们反而把分区表给搞坏了,这就让我坚定了跟他们斗争到底的想法,刚好也可以打发业余时光。由于SafeConnect客户端不支持Linux系统,同时学校中Linux用户的数量相当多,所以Linux系统不需要安装任何客户端,直接就能访问网络,这是一个关键性的切入点。

要同SafeConnect斗争,有两个切入点,一个是从SafeConnect客户端做手脚,想办法让SafeConnect客户端丧失相应的监测功能,只是傻傻地给他们的服务器汇报一切符合要求,另一个则是从SafeConnect网关的操作系统检测入手,只要能让网关认为我们用的是Linux系统,我们就可以上网了,这样连SafeConnect客户端都不用装了。

第一个切入点需要对SafeConnect进行逆向工程,搞清楚SafeConnect这些项目的检测机制。逆向工程向来都是费时费力,成本巨大,而且不可以跨平台。不过幸运的是,SafeConnect检测P2P软件的方式经过简单测试作者就发现了端倪:
打开P2P软件,网络被断,遭到警告
把P2P软件的进程名改掉,正常使用未被发现
自己写一个Hello World程序并命名为为BitTorrent.exe并打开,网络被断,遭到警告

从上面的测试不难看出,SafeConnect通过列举系统的进程,如果发现黑名单中的进程名,就给你断开网络链接,然后弹出网页警告你说我们检测到你在使用P2P软件,这违反了学校的规定。明白了原理,就可以开干了。既然SafeConnect列举系统进程,那我们就使用进程隐藏技术把P2P软件的进程隐藏起来。

Windows系统有很多API可以访问系统的进程信息,但是所有这些API在底层最终都会调用NtQuerySystemInformation来获取系统进程信息。这个系统调用总共有四个参数,其中第一个参数SystemInformationClass表示的是你要查询的系统信息的类型,对于列举系统进程而言,这个参数的值应为SystemProcessInformation。第二个参数SystemInformation存储着获得的系统信息,对于列举进程而言,这个参数存储着一个链表,链表的每一项都是一个进程,而要隐藏进程,我们只需要从链表中删除相应的项即可。要篡改系统调用的结果,需要用到DLL注入技术将DLL注入到目标进程的地址空间,然后篡改地址空间中NtQuerySystemInformation函数的的代码加入跳转语句跳转到我们伪造的NtQuerySystemInformation去,这就是DLL注入的整个运行过程。整个DLL注入的过程不需要亲自动手,有现成的EasyHook库可以实现。

不多说,贴代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <easyhook.h>
#include <string>
#include <tchar.h>
#include <iostream>
#include <windows.h>
#include <shlwapi.h>
#include <winternl.h>
#include <ntstatus.h>
#pragma comment(lib, "EasyHook32.lib")
#pragma comment(lib, "ntdll.lib" )
#define HIDE_PROCESS_NAME TEXT("BitTorrent.exe")
BOOL APIENTRY DllMain(HMODULE hModule, DWORD ul_reason_for_call, LPVOID lpReserved) {
return TRUE;
}

我们要Hook的函数位于ntdll.dll,所以在代码中要引用一下ntdll.lib。我们要写的dll的entry不需要做任何事情。接下来就是我们的自定义NtQuerySystemInformation了,在这里我把它命名为myNtQuerySystemInformation

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
NTSTATUS WINAPI myNtQuerySystemInformation(SYSTEM_INFORMATION_CLASS SystemInformationClass,
PVOID SystemInformation, ULONG SystemInformationLength,
PULONG ReturnLength) {
NTSTATUS status = NtQuerySystemInformation(SystemInformationClass, SystemInformation,
SystemInformationLength, ReturnLength);
if (status != STATUS_SUCCESS)
return status;
if (SystemInformationClass == SystemProcessInformation) {
PSYSTEM_PROCESS_INFORMATION pcur = NULL, pprev = NULL;
pcur = (PSYSTEM_PROCESS_INFORMATION)SystemInformation;
while (1) {
if (pcur->Reserved2[1] != NULL) {
if (_tcscmp((LPTSTR)(pcur->Reserved2[1]), HIDE_PROCESS_NAME) == 0) {
// delete element from linked list
if (pcur->NextEntryOffset == 0 && pprev != NULL)
pprev->NextEntryOffset = 0;
else if (pprev != NULL)
pprev->NextEntryOffset += pcur->NextEntryOffset;
} else {
pprev = pcur;
}
}
if (pcur->NextEntryOffset == 0)
break;
pcur = (PSYSTEM_PROCESS_INFORMATION)((ULONG)pcur + pcur->NextEntryOffset);
}
}
return status;
}

正如刚刚介绍的那样,这个函数做的事情就是把要隐藏的进程从链表删掉。同时,EasyHook还要求我们在dll中定义安装函数,这个不复杂,直接从官方教程粘贴代码简单改改就好:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
extern "C" void __declspec(dllexport) __stdcall NativeInjectionEntryPoint(REMOTE_ENTRY_INFO* inRemoteInfo);
void __stdcall NativeInjectionEntryPoint(REMOTE_ENTRY_INFO* inRemoteInfo) {
HOOK_TRACE_INFO hHook = { NULL };
HMODULE ntdll = GetModuleHandle(TEXT("ntdll"));
NTSTATUS result = LhInstallHook(GetProcAddress(ntdll, "NtQuerySystemInformation"),
myNtQuerySystemInformation,NULL,&hHook);
if (FAILED(result))
std::wcout << RtlGetLastErrorString() << std::endl;
else
std::wcout << "NtQuerySystemInformation hook success!" << std::endl;
// If the threadId in the ACL is set to 0,
// then internally EasyHook uses GetCurrentThreadId()
ULONG ACLEntries[1] = { 0 };
// Disable the hook for the provided threadIds, enable for all others
LhSetExclusiveACL(ACLEntries, 1, &hHook);
return;
}

写好了DLL,下一步就是找到目标进程注入进去了。仔细研究一下那个SafeConnect,可以发现他总共有两部分:一个scManager.sys系统服务,以及一个SafeConnectClient.exe进程,实测发现把DLL注入到SafeConnectClient.exe不管用,于是断定负责检索系统进程列表的是scManager.sys。

下图是用来将我们写的DLL注入到scManager.sys的代码,这里代码做的事情是查找名为scManager.sys的进程,读取进程的PID,然后调用EasyHook的API将我们写的DLL注入到对应的进程中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
#include <iostream>
#include <string>
#include <tchar.h>
#include <cstring>
#include <easyhook.h>
#include <psapi.h>
#pragma comment(lib, "EasyHook32.lib")
#pragma comment(lib, "psapi.lib" )
using namespace std;
#define NUM_PROC 1
TCHAR * targets[NUM_PROC] = {
TEXT("scManager.sys")
};
DWORD getTargetProcessID(TCHAR *target) {
DWORD aProcesses[1024], cbNeeded, cProcesses;
if(!EnumProcesses(aProcesses, sizeof(aProcesses), &cbNeeded))
return 0;
cProcesses = cbNeeded / sizeof(DWORD);
for (DWORD i = 0; i < cProcesses; i++) {
if (aProcesses[i] != 0){
TCHAR szProcessName[MAX_PATH] = TEXT("<unknown>");
HANDLE hProcess = OpenProcess(PROCESS_QUERY_INFORMATION|PROCESS_VM_READ,
FALSE, aProcesses[i]);
if (NULL != hProcess) {
HMODULE hMod;
DWORD cbNeeded;
if (EnumProcessModules(hProcess, &hMod, sizeof(hMod),&cbNeeded))
GetModuleBaseName(hProcess, hMod, szProcessName,sizeof(szProcessName)/sizeof(TCHAR));
CloseHandle(hProcess);
}
if (_tcscmp(target, szProcessName) == 0)
return aProcesses[i];
}
}
return 0;
}
int main() {
for (int i = 0; i < NUM_PROC; i++) {
DWORD processId = getTargetProcessID(targets[i]);
HMODULE ntdll = GetModuleHandle(TEXT("ntdll"));
std::wcout << "target func in injector has address "
<< GetProcAddress(ntdll, "NtQuerySystemInformation") << std::endl;
if (processId == 0) {
std::wcout << "Unable to find the process ID for "
<< targets[i] << ", please manually input: ";
std::cin >> processId;
std::wstring input;
std::getline(std::wcin, input);
}
if (processId!=0) {
NTSTATUS nt = RhInjectLibrary(processId, 0, EASYHOOK_INJECT_DEFAULT,
L"fuck_safeconnect.dll", NULL, NULL, 0);
if (nt != 0) {
printf("RhInjectLibrary failed with error code = %d\n", nt);
PWCHAR err = RtlGetLastErrorString();
std::wcout << err << "\n";
} else {
std::wcout << "inject success!\n";
}
}
}
std::wcout << "Press Enter to exit";
std::wstring input;
std::getline(std::wcin, input);
return 0;
}

由于scManager.sys是个系统服务,单纯的“以管理员身份运行”是无法成功注入的,这时候就需要把注入器提权到SYSTEM用户来进行注入。提权到SYSTEM的操作可以用微软提供的PsExec工具来进行。PsExec是个命令行工具,使用PsExec非常简单,只需要psexec –i –s 程序名即可。
注入成功,这时候再打开BitTorrent就不被断网了

由于逆向工程太过费时费力,笔者并没有在SafeConnect客户端上多费心思,而是转向了第二种方法:欺骗SafeConnect的服务器端的操作系统检测功能。要检测连进来的设备的操作系统,一种常用的方法是TCP Fingerprinting,此外,还可以运用深度包检测技术读取设备发送的数据包的内容,推测设备的类型。只需要针对这几项进行伪装,然后观察SafeConnect的行为就可以推测SafeConnect检测用户操作系统的方式。

经过一番实验,推测SafeConnect服务器的工作原理如下:客户端新设备连进网络的时候,他们是直接不给你互联网接入的。但是他们会监测你的的外出http流量,从http流量中读取User-Agent中的操作系统信息。

如果发现是他们SafeConnect客户端不支持的系统,比如Linux,iOS,android之类,就把你这个IP地址限制放开。如果是客户端支持的系统,比如Windows,Mac OS,并且你的SafeConnect已经安装了,那么SafeConnect会跟他们服务器通讯把你放开,如果你还没安装SafeConnect,那么就把你跳转到相关页面让你装SafeConnect。

明白了这一点,解决方法自然就来了:给浏览器安装可以更改User-Agent的插件,把User-Agent里面的操作系统信息改成Linux即可,这样在访问网络的时候,SafeConnect的服务器就会误以为你是Linux系统了。通过给浏览器更改User-Agent,几乎可以完美躲过SafeConnect的法眼,然而美中不足的是如果用多个浏览器,还需要挨个安装插件更改User-Agent,同时有的网站会检测浏览器的版本,如果发现浏览器版本过就会提示不支持需要更新,这样就需要不断地根据浏览器更新手工更改插件里的User-Agent的值。还有一点麻烦的地方是,新设备接入的时候还需要设置新的设备。对我来说有没有设置一个新设备自然很简单,但是经常有时候有客人来,用Windows设备没装SafeConnect就上网导致我家整个路由器被封。有没有简单快捷的方法自动让所有连接到路由器的设备都不受SafeConnect的影响呢?这时候就想到了bettercap。

Bettercap是一个模块化的开源的中间人攻击框架。使用bettercap只需要几行代码就可以实现劫持整个局域网的流量并把其中的HTTP流量的User-Agent改掉。这样的话,只要家里有客人来,我们就可以打开bettercap,自动把客人的User-Agent改掉,防止客人上网导致家里整个路由器都被断。在局域网中实现中间人攻击的一种常见方法是ARP欺骗。ARP欺骗攻击发起方通过不断向受害者发送伪造的ARP数据包,让受害者误以为自己是网关,这样受害者本来想送到网关的数据包就会错误地送给攻击方,攻击方进而可以篡改数据然后送给真正的网关。要使用bettercap实现User-Agent的伪造,首先需要写一个bettercap的代理模块。Bettercap的代理模块写起来非常简单, 这里有很多示例模块,文档在这里。我写的模块代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Osfuscate < BetterCap::Proxy::HTTP::Module
meta(
'Name' => 'Osfuscate',
'Description' => 'Change the operating system in User-Agent string to Linux.',
'Version' => '1.0.0',
'Author' => "zasdfgbnm",
'License' => 'GPL3'
)
def on_pre_request( request )
request['User-Agent'].gsub!( /\(Windows.*?\)/, '(X11; Linux x86_64)' )
request['User-Agent'].gsub!( /\(Macintosh.*?\)/, '(X11; Linux x86_64)' )
# return nil to tell the streamer that this module didn't do the request
# and therefore the request should be done by the streamer.
return nil
end
end

其中on_pre_request函数在请求发生前会被调用,我们只需要在这里把请求中User-Agent的信息中的操作系统给替换成Linux就行了。这里需要注意的是,在函数的结尾需要返回nil,用来告诉bettercap请求还没有被执行,这样bettercap才会去执行请求。

有了这个代理模块,要想部署,只需要执行bettercap–proxy-module 模块文件名命令就可以了,不需要手动配置iptables这些东西, 非常简单快捷。上图:
这是局域网内的一台Windows机器User-Agent中的操作系统信息
这是bettercap软件的运行界面
这是bettercap运行时局域网内这台Windows机器的User-Agent中的操作系统信息

从图中可见User-Agent信息已经被成功修改掉了,大功告成。

最后说再来一句,这个工作其实稳定的解决方法是直接在路由器跟网口中间加一个电脑用来做网关来负责User-Agent的修改,这样网络会比ARP欺骗的解决方案要稳定好多。这里之所以选择ARP欺骗的方案主要是因为简单快速不需要拔网线不需要设置网关。另外,如果你已经有一台已经设置好了的网关服务器,bettercap同样可以用来完成我们想要的任务,只需要在网关服务器上执行如下命令

1
bettercap --no-spoofing --no-discovery --proxy-module 模块名

程序执行的界面类似,这里就不截图了。