⚠️ disclaimer 声明:

本文仅做技术研究,请勿用作非法用途。本文不提供文件下载。
本文仅做技术研究,请勿用作非法用途。本文不提供文件下载。
本文仅做技术研究,请勿用作非法用途。本文不提供文件下载。

前言

我所在办公环境,需要使用一个终端安全助手,登录输入口令和每天变化的验证码才能接入到办公网络和外网。登录前会检查一些必要的安全策略,包括但不限于是否运行杀毒软件、是否安装重要的系统安全补丁等等,而且这个软件只能在 Windows 上运行,导致开 Wi-Fi 只能通过电脑开热点,但是登录前也会检查网络共享,然而并没有检查出来,可能是放水了。

这周的某天,这个软件强制升级了,升级后要求必须安装 WPS 才能让接入网络,一下把我恶心到了。不过,这还不是我打算逆向他的真实原因。

当然,上面的安全检查也确实有其必要性,但是在这也把其他终端排除在外,Linux / macOS 无法接入网络,只能开虚拟机。

强制使用 WPS 是根据当前国情做出的决定,防止被国外卡脖子,那我一年 40 的 Office 365 不是白买了吗。(碎碎念)

但是,我之所以准备逆向这个终端安全助手,是因为:

  1. 想要移植到 Linux 上运行,用树莓派或者其他 Linux 终端接入网络,开放热点
  2. 了解接入网络的登陆原理,开始以为是 802.1X 之类的认证,后来发现并不是
  3. 绕过策略,防止以后更恶心的策略(次要)

开始逆向

IDA

最开始,我是尝试使用我从来没有使用过的神器 IDA ,用 IDA 打开入口程序时,可以看到有一些注释,我以为很顺利,再紧接着打开主程序时,有很多乱码,我还到网上搜,以为是编码问题,就想到是不是可以通过反编译查看源码。

其实 IDA 应该是适合交互的时候逆向。

dnSpy 反编译

仔细查看文件目录,exe 的程序文件名后会有个同名的 config 文件,这看起来好像是 C# 写的啊,想起之前反编译用过 dnSpy,就打开看了下,果不其然,能看到源码了,而且文件非常多,但是中文部分还是乱码,仍然以为是编码问题。

仔细看了下,原来是有个 urlencode.b 的函数在对这些乱码进行解码,心里想着,怎么会这样写呢,这得多麻烦(跟个弱智一样)

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
internal static string b(string A_0, int A_1)
{
char[] array = A_0.ToCharArray();
int num = 1567956420 + A_1;
int num3;
int num2;
if ((num2 = (num3 = 0)) < 1)
{
goto IL_47;
}
IL_14:
int num5;
int num4 = num5 = num2;
char[] array2 = array;
int num6 = num5;
char c = array[num5];
byte b = (byte)((int)(c & 'ÿ') ^ num++);
byte b2 = (byte)((int)(c >> 8) ^ num++);
byte b3 = b2;
b2 = b;
b = b3;
array2[num6] = (ushort)((int)b2 << 8 | (int)b);
num3 = num4 + 1;
IL_47:
if ((num2 = num3) >= array.Length)
{
return string.Intern(new string(array));
}
goto IL_14;
}

仔细看了代码,准备用 python 还原一份,后来发现 python 没有 ushort,我不知道咋写了,又换 golang 写,写完发现,我 TM 写了个寂寞啊!!!

返回值 array 是从 A_0 来的,没有对 array 进行操作。

终于,我发现,这可能就是代码被混淆过了,也就是俗称的加壳。

因为上次用 dnSpy 反编译的程序没有遇到混淆,所以我就不知道这就是混淆。

用 ScanId 1.5 (https://down.52pojie.cn/Tools/NET/ScanId_1_5.zip) 检测后发现,果然加壳了,壳子类型是 Dotfuscator 。

de4dot 脱壳

使用 de4dot (https://down.52pojie.cn/Tools/NET/de4dot.zip) 脱壳

de4dot 会自动检测壳子类型,然后脱壳,很强大的软件。

脱壳后的程序会自动单独改名保存,不用担心覆盖原文件。

dnSpy 再次反编译

将 de4dot 脱壳后的 exe 拖到 dnSpy 中,就发现,这次可以看到源码了,也没有乱码。

阅读源码

接下来,来到了喜闻乐见的源码阅读环节。

我拿到源码的第一件事,先全局搜索万恶之源 “WPS” 关键字,发现并没有搜到结果。

GenAndSend 对本地数据文件的加密

仔细看了下源码,安全策略被称为 Policy,强制安装 WPS 被称为 LicensedSoftware , 每次启动会从服务端下载最新的策略,经过 一个叫 GenAndSend 类中的加密函数放到本地磁盘上,我在程序目录确实也看到了这个文件。

程序目录的其他数据文件,从服务器下载的其他数据,都是通过这个 GenAndSend 加密,看来只有将 GenAndSend 的解密函数用其他方便的语言实现,才能知道文件的内容是什么。

GenAndSend 的加解密是基于 3Des , 这是一种对称加密,密钥是明文写在程序里面的,自然也就可以还原了。

当我写完后发现,我根本不需要这么做,因为…

fiddler 抓取 http/https 请求

通过阅读源码,策略是从网络上下载的,然后加密后保存到本地文件,那么肯定有 http 请求了,加密前,数据也是明文,我们可以直接抓流量。

本来我是习惯使用 burpsuite 来抓包的,因为可以手动控制是否放行流量,但是我没多少时间来做 burp 的配置,要安装 java、配置证书、配置代理端口。

我需要一个一把梭的软件,那就是 fiddler ,fiddler 确实好用,只需要安装上,勾选上”开始捕获”就可以用了。

然后通过 fiddler ,我抓到了每次登录账号,接入网络的所有请求,也看到了策略的内容,结合程序,知道了这个助手通过注册表检测我是否安装了 WPS 软件。

但是我最疑惑的一点是,到底是如何接入网络的,通过 802.1X 那套认证吗,那我还得去研究协议。

而且因为源码太多了,我又不会 C#,只能看个大概含义。虽然说反编译确实能看到源码,但是可能函数名和位置都变化了,搜索”登录成功”的字样的前后,并没有看到修改本地网络之类的操作。我花了 2 个小时,将所有的源码大概浏览一遍,我很确信没有看到修改本地网络之类的操作。

登录过程

仔细查看登录的源码,查看 fiddler 抓取的请求,发现是将用户名、密码、附加码组合到一起,
Des 加密后(之前是 3Des 加密),post 发到一个地址。

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
string text4 = "CMethod=login&LoginName={0}&IPs={1}&ExCode={2}&Password={3}";
text4 = string.Format(text4, new object[]
{
this.UserName,
ipv,
this.ExCode,
UrlEncode.Encoder(this.UserPass)
});
this.LoginProcess = "正在登录...";
string text5 = "";
try
{
Dictionary<string, string> dictionary2 = new Dictionary<string, string>();
string value3 = Class39.smethod_2(text4);
dictionary2.Add("Message", value3);
text5 = HttpHelper.GetUrlData(this.ServerUrl + "/TSClientReport.aspx", dictionary2, webBrowser_Form_New.urlType, Encoding.UTF8);
CommonUtility.WriteInfoLog("LoginState", "Login", "TSclient", text5, new DateTime[]
{
DateTime.Now
});
}
catch (Exception)
{
this.LoginResult = -5;
this.LoginDesc = "无法连接认证服务器,请检查您的网络设置。";
return;
}

Class39.smethod_2(text4) 就是 Des 加密的函数。

我打开那个地址,哦,是认证网页。如果没联网的情况下,访问网页,就会强制跳转到那个网页。

我突然意识到!!!

助手只是个套壳程序,真正接入网络是这个认证网页,接入网络的逻辑是在别人的服务端上,本地压根儿没有。

于是我尝试通过网页登录,网页是通用的,因为需要被浏览器解析,没法做到加密,程序逻辑都在 js 里面。

通过网页上也能登录账号并接入网络,但是也会检查安全策略,浏览器自然是无法检测补丁和安装的程序,所以会要求打开助手程序运行起来,登录过程中网页会请求 localhost 的 8695,这个是助手监听的端口,向这个地址发送请求,与助手通信,以此让助手检查安全策略,收到结果后再确定要不要登录。

我将登录过程大致整理如下:

  1. 输入 用户名、密码、附加码,点击登录,有个登录请求,我们不需要关心
  2. 请求 http://localhost:8695/TSPolicyService/CheckPolicy?message=xxxx

xxxx 是 Des 加密的密文,明文是 ‘timestamp=1647493100000&pageCode=910bea86-xxxx-xxxx-xxxx-d95cbd046c87&userName=xxxxxx’ 的格式

字段说明:

  • timestamp 时间戳
  • pageCode 类似于会话 id,形如 uuid ,后面会用到
  • userName 登录名

得到响应:
{"d":null}

  1. 请求 http://localhost:8695/TSPolicyService/ChangeUser

得到响应
{"d":null}

  1. 助手上传策略检查结果

请求方式: POST
地址:

1
http://<REDACTED URL>/TSWebService/TSSafeCheckResult/UploadTSSafeCheckResult?r={}".format(ticks)

ticks 是 C# 特有的,表示 0001-01-01 00:00:00:000 至此的以 100 ns(即 1/10000 ms)为单位的时间数。

python 实现

1
2
3
4
5
6
7
8
9
10
import datetime

def getTicks():

t0 = datetime.datetime(1, 1, 1)
now = datetime.datetime.utcnow() + datetime.timedelta(hours=8)
seconds = (now - t0).total_seconds()
ticks = int(seconds * 10**7)

return ticks

post data 内容:

1
2
3
4
5
6
7
data = {
'UserLoginName': userName,
'CheckScore': '95',
'IPAddress': '<IpAddress>xx.xx.xx.xx</IpAddress>',
'XmlCheckResult': XmlCheckResult,
'PageCode': PageCode
}

字段说明:

  • UserLoginName 登录名
  • CheckScore 检查得分
  • IPAddress IP 地址
  • XmlCheckResult 是个 XML 格式的策略检查结果,直接抄作业即可
  • PageCode 从第二个请求的 message 解密得到

服务端收到结果后,就登录成功了,这时候就接入网络了。

助手端显示,已经接入网络,即使我们没有在助手登录。再次印证我的猜测,助手只是个套壳软件。

我使用 python flask 框架完成了上面的请求处理,退出助手,将 python 程序运行起来,在网页上登录,可以登录成功。

小插曲

有个小插曲,Des 加解密用到的库叫 pycryptodome ,在 Windows 上解密时会运行失败,我只有在我的电脑上也运行一个 flask 后端程序,Windows 电脑登录需要解密时,发送请求到我的 flask 后端程序解密。

后续还是要用 golang 重新实现一下,解决这个问题。

因为 golang 可以跨平台编译,我希望编译成 windows 程序,先测试,然后再编译给 Linux 实现热点。

心跳数据包

python 程序也开着,但是用了没两分钟,就没网了。

咋回事鸭.jpg

把 python 程序退出了,开正版助手看看抓包,原来是每隔 ? 分钟就会发送心跳数据,以此维持连接。

一看心跳数据,又是 Des 加密,还和上面 CheckPolicy 的加密不是一个函数,而且每次发送的都不一样。

看源码

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
string str = ConfigurationManager.AppSettings.Get("Client_HeartBeatURL");
string message = string.Format("CMethod=report&LoginName={0}&IPs={1}&ComputerName={2}&AssemblyVersion={3}&EstStatus={4}&SysVolNum={5}&TerminalNumber={6}", new object[]
{
webBrowser_Form_New.Username,
this.clientInformation_0.IPv4,
this.clientInformation_0.HostName,
this.version,
num,
ComputerInfor.OnlySysVoluNum,
ComputerInfor.TerminalNumber
});
string str2 = "/TSClientReport.aspx";
str += str2;
TSResult tsresult = new TSResult();
Dictionary<string, string> dictionary = new Dictionary<string, string>();
string value = Class39.smethod_2(message);
dictionary.Add("Message", value);
try
{
string urlData = HttpHelper.GetUrlData(str, dictionary, webBrowser_Form_New.urlType, Encoding.UTF8);
tsresult.Result = 0;
tsresult.Message = urlData;
}
catch (Exception ex)
{
tsresult.Result = -1;
tsresult.Message = ex.ToString();
}
if (tsresult.Result == 0 && tsresult.Message.ToLower().IndexOf("html") == -1)
{
this.sysLog_1.Description = "心跳已经发送。状态:成功,描述:" + tsresult.Message;
}
else
{
this.sysLog_1.Description = "心跳已经发送。状态:可能失败,描述:" + tsresult.Message;
}
CommonUtility.WriteBeatLog(this.sysLog_1);

Class39.smethod_2(message) 就是加密函数。

看最后一行,还会写日志。

在程序目录下,有个 BeatLog 的目录,里面就是发送心跳数据包的日志:

1
2
3
4
5
6
7
[] 2022-03-17 00:01:40	::time_NetWordCatdCheck_Tick	()心跳已经发送。状态:成功,描述:0;
[] 2022-03-17 00:03:40 ::time_NetWordCatdCheck_Tick ()心跳已经发送。状态:成功,描述:0;
[] 2022-03-17 00:05:40 ::time_NetWordCatdCheck_Tick ()心跳已经发送。状态:成功,描述:0;
[] 2022-03-17 00:07:40 ::time_NetWordCatdCheck_Tick ()心跳已经发送。状态:成功,描述:0;
[] 2022-03-17 00:09:40 ::time_NetWordCatdCheck_Tick ()心跳已经发送。状态:成功,描述:0;
[] 2022-03-17 00:11:40 ::time_NetWordCatdCheck_Tick ()心跳已经发送。状态:成功,描述:0;
[] 2022-03-17 00:13:41 ::time_NetWordCatdCheck_Tick ()心跳已经发送。状态:成功,描述:0;

描述:0; 是服务器响应,与上面截图中的服务器响应一致。

于是我又用 python 实现了,解密后发现内容一毛一样,因为有随机数参与,密文导致不一样。

我同时又想到另一种方法,我可以重放数据包,但是别人后台也能看到,为了伪装的像一点,逆向心跳包的加密函数也是有必要的。

但是之前没有用 python 写过加密函数的实现,导致遇到了个报错:

1
2
3
4
5
--> 244         return self._cipher.encrypt(plaintext)
245
246 def decrypt(self, ciphertext):

ValueError: Input strings must be a multiple of 8 in length

也就是说 plaintext 明文长度必须是 8 的整数倍,不足就需要填充,一般使用 PKCS7Padding 填充。

最后写出了加密函数,但是发现服务端的响应不是正常的 0;

正常的数据,结尾是两个 %3d%3d,也就是说,urlencode 前的密文,结尾是两个”=”,这是因为 Des 加密过程最后一个步骤就是 base64 编码。

但是我发送的数据,结尾是 %253d ,看起来是进行了两次 urlencode ,导致 % 再次被编码成 %25( 25 是 % 的 hex 码)

1
2
>>> urllib.parse.quote('%')
'%25'

一条条代码手动调试,发现 python 的 requests.post 过程还会自动进行一次 urlencode ,所以我照着 C# 源码写的 python 版本实现,加密函数返回的结果就不能再 urlencode。

发现这个问题后,我马上改了我这边的加密函数,flask 会自动加载,然后可以看到服务端的响应已经正常。

至此,我已经把接入网络过程中的必要过程完全用 python 实现。

C# 和 python 还有个函数的差异,比如:
C# 的 urlencode ,是将特殊符号转换成小写格式的 urlencode,而 python 是转换成大写。

但是因为,post 过程中自动进行 urlencode ,我无法控制,也没法通过正则去替换了。

所幸的是,大写格式的 urlencode,服务器端也能正常响应,解析应该没有问题。

总结

我退出助手,运行精简盗版助手,可以正常接入网络,绕过了策略检测,并且每 2 分钟发送一次心跳,网络没有中断,心跳请求的服务器响应也是正常的。

其实好像发送了错误的心跳包,我的网络也没有中断,可能还需要继续探索,是不是随便发点数据过去,也能维持网络。

完了后,之前遇到的一些神奇现象也能被解释的通了:

  1. 有时候重启电脑,开机后打开助手,发现显示“已经接入到办公网络”,因为还在2分钟内。

  2. 右键退出程序后还可以联网,过一会儿没网,是因为没发送心跳包。

  3. 右键断开办公网,是马上断网。因为有个断网的请求,message 解密内容为 “CMethod=logoff&IPs=xx.xx.xx.xx&LoginName=xxxxx&ComputerName=xxxxx\x01” ,末尾的\x01就是填充字符。