对今日校园疫情填报的逆向分析

面向监狱编程.jpg

疫情填报抓包

首先准备好https抓包环境,
我采用了ROOT+系统证书+httpcanary



然后我们就很简单的抓到了Cookies,后续所有表格获取和提交都必须附带这两个Cookies才能操作。
关于如何获取Cookies,见本文后半部分模拟登陆的逆向分析。

然后用Postman模拟发包

一切正常。
然后接下来就是如何自动获取表格内容和提交表格了。

获取表格列表

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
{
"code":"0",
"message":"SUCCESS",
"datas":{
"totalSize":1,
"pageSize":6,
"pageNumber":1,
"rows":[
{
"wid":"946",
"formWid":"669",
"priority":"5",
"subject":"新冠肺炎疫情防控信息(每日填报)20200827",
"content":"https://wecres.cpdaily.com/counselor/1018615908163149/content/c9406377ab4543b791148e3fa23f979e.html",
"senderUserName":"信息化办公室(ampadmin)",
"createTime":"2020-08-26 23:19",
"startTime":"2020-08-27 00:00",
"endTime":"2020-08-27 12:00",
"currentTime":"2020-08-27 01:06:35",
"isHandled":0,
"isRead":0
}
]
}
}

这里需要保存 widformWid ,后面会用到

获取表格头

POST:
https://cqjtu.cpdaily.com/wec-counselor-collector-apps/stu/collector/detailCollector
json:

1
{"collectorWid":"946"}

Return:

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
{
"code": "0",
"message": "SUCCESS",
"datas": {
"collector": {
"wid": "946",
"formWid": "669",
"priority": "5",
"endTime": "2020-08-27 12:00:00",
"currentTime": "2020-08-27 01:26:21",
"schoolTaskWid": null,
"isConfirmed": 0,
"senderUserName": "信息化办公室(ampadmin)",
"createTime": "2020-08-26 23:19:42",
"attachmentUrls": null,
"attachmentNames": null,
"attachmentSizes": null,
"isUserSubmit": 0,
"fetchStuLocation": true,
"address": null
},
"form": {
"wid": "669",
"formTitle": "新冠肺炎疫情防控信息(每日填报)20200827",
"formContent": "https://wecres.cpdaily.com/counselor/1018615908163149/content/c9406377ab4543b791148e3fa23f979e.html",
"backReason": null,
"isBack": 0,
"attachments": []
}
}
}

这里需要保存的是schoolTaskWid

不知道干什么用但是多次出现了的操作,不是BUG就是暗桩

这个不知道是干什么用的,而且还执行了两次 参数一模一样 不知道是干什么用的 不排除是暗桩,也不排除是程序员写的BUG。
这个URL是后面加载更多表格的时候才用的。
POST:
https://cqjtu.cpdaily.com/wec-counselor-collector-apps/stu/collector/getUnSeenQuestion
json:

1
{"wid":"946"}

Return:

1
{"code":"0","message":"SUCCESS","count":"0"}

获取表格内容

POST
https://cqjtu.cpdaily.com/wec-counselor-collector-apps/stu/collector/getFormFields
json:

1
{"pageSize":10,"pageNumber":1,"formWid":"669","collectorWid":"946"}

就会返回所需要填的表格

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
{
"code":"0",
"message":"SUCCESS",
"datas":{
"totalSize":14,
"pageSize":10,
"pageNumber":1,
"existData":0,
"rows":[
{
"wid":"7138",
"formWid":"669",
"fieldType":2,
"title":"与昨日相比信息有无变化",
"description":"",
"minLength":0,
"sort":"1",
"maxLength":null,
"isRequired":1,
"imageCount":null,
"hasOtherItems":0,
"colName":"field001",
"value":"",
"minValue":0,
"maxValue":0,
"isDecimal":true,
"fieldItems":[
{
"itemWid":"24797",
"content":"无",
"isOtherItems":0,
"contendExtend":"",
"isSelected":null
},
{
"itemWid":"24798",
"content":"有",
"isOtherItems":0,
"contendExtend":"",
"isSelected":null
}
]
},
{
"wid":"7139",
"formWid":"669",
"fieldType":1,
"title":"当前所在地",
"description":"省(区市)+市(地州)或区县两个层级",
"minLength":1,
"sort":"2",
"maxLength":300,
"isRequired":0,
"imageCount":-2,
"hasOtherItems":0,
"colName":"field002",
"value":"",
"minValue":0,
"maxValue":0,
"isDecimal":true,
"fieldItems":[

]
}
]
}
}

数据解释:

1
2
fieldType  是表格类型 1为文本 2为单选 3为多选 4 5 6我们没用到 暂且不清楚,用到的时候再抓。
isRequired 是否为必填项,1为必填。

提交的时候,单选需要删除多余的选项,文本直接提交就行了。我们学校只需要提交一个 与昨日相比信息有无变化 就行了 其他的我没试。

需要填写的数据在
{"datas":{"rows":[]}} 中, 必填项的属性为 isRequired
当然 地理位置最好
先记住这个数据格式,待会儿提交的时候还得用到他。

然后我们的提交过程中还必须单击一下 “加载更多” 按钮。
如果 加载更多 按钮中没有必填项的话,则这里可以跳过。

加载更多

获取表格所有内容

对于部分学校 可能你还需要填完下列表单的所有内容才能提交。

这个时候他会请求:
POST:
https://cqjtu.cpdaily.com/wec-counselor-collector-apps/stu/collector/getUnSeenQuestion
json:

1
{"wid":"946"}

Return: (和上面格式一样 不展开赘述)

1
{"code":"0","message":"SUCCESS","datas":{"totalSize":14,"pageSize":10,"pageNumber":2,"existData":0,"rows":[{"wid":"7147","formWid":"669","fieldType":2,"title":"有无咳嗽、乏力、鼻塞、流涕、咽痛、腹泻等症状","description":"","minLength":0,"sort":"11","maxLength":null,"isRequired":0,"imageCount":null,"hasOtherItems":0,"colName":"field011","value":"","minValue":0.0000,"maxValue":0.0000,"isDecimal":true,"fieldItems":[{"itemWid":"24806","content":"是","isOtherItems":0,"contendExtend":"","isSelected":null},{"itemWid":"24807","content":"否","isOtherItems":0,"contendExtend":"","isSelected":null}]},{"wid":"7148","formWid":"669","fieldType":2,"title":"本人目前状态判定","description":"","minLength":0,"sort":"12","maxLength":null,"isRequired":0,"imageCount":null,"hasOtherItems":0,"colName":"field012","value":"","minValue":0.0000,"maxValue":0.0000,"isDecimal":true,"fieldItems":[{"itemWid":"24808","content":"有可疑症状","isOtherItems":0,"contendExtend":"","isSelected":null},{"itemWid":"24809","content":"疑似","isOtherItems":0,"contendExtend":"","isSelected":null},{"itemWid":"24810","content":"确诊","isOtherItems":0,"contendExtend":"","isSelected":null},{"itemWid":"24811","content":"治愈","isOtherItems":0,"contendExtend":"","isSelected":null},{"itemWid":"24812","content":"无以上状态","isOtherItems":0,"contendExtend":"","isSelected":null}]},{"wid":"7149","formWid":"669","fieldType":2,"title":"本人目前状态处理","description":"","minLength":0,"sort":"13","maxLength":null,"isRequired":0,"imageCount":null,"hasOtherItems":0,"colName":"field013","value":"","minValue":0.0000,"maxValue":0.0000,"isDecimal":true,"fieldItems":[{"itemWid":"24813","content":"正常休息","isOtherItems":0,"contendExtend":"","isSelected":null},{"itemWid":"24814","content":"隔离治疗","isOtherItems":0,"contendExtend":"","isSelected":null},{"itemWid":"24815","content":"已治愈","isOtherItems":0,"contendExtend":"","isSelected":null}]},{"wid":"7151","formWid":"669","fieldType":1,"title":"其他需要说明情况","description":"","minLength":1,"sort":"14","maxLength":300,"isRequired":0,"imageCount":null,"hasOtherItems":0,"colName":"field014","value":"","minValue":0.0000,"maxValue":0.0000,"isDecimal":true,"fieldItems":[]}]}}

提交表格

POST:
https://cqjtu.cpdaily.com/wec-counselor-collector-apps/stu/collector/submitForm
提交表单的格式和表格原来相同 只是数据不同。
比如上面我们抓到的表格,在提交时会变成这样
Headers:
这里需要添加一个你抓包抓到的 Cpdaily-Extension 这里我就不提供我自己的了,给一个别人共用的

1
Cpdaily-Extension: 1wAXD2TvR72sQ8u+0Dw8Dr1Qo1jhbem8Nr+LOE6xdiqxKKuj5sXbDTrOWcaf v1X35UtZdUfxokyuIKD4mPPw5LwwsQXbVZ0Q+sXnuKEpPOtk2KDzQoQ89KVs gslxPICKmyfvEpl58eloAZSZpaLc3ifgciGw+PIdB6vOsm2H6KSbwD8FpjY3 3Tprn2s5jeHOp/3GcSdmiFLYwYXjBt7pwgd/ERR3HiBfCgGGTclquQz+tgjJ PdnDjA==

json:

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
{
"formWid": 669,
"address": "位于银河系猎户臂内侧,英仙座旋臂和人马座旋臂之间的太阳系中第三颗行星上",
"collectWid": 946,
"schoolTaskWid": null,
"form":[
{
"wid":"7138",
"formWid":"669",
"fieldType":2,
"title":"与昨日相比信息有无变化",
"description":"",
"minLength":0,
"sort":"1",
"maxLength":null,
"isRequired":1,
"imageCount":null,
"hasOtherItems":0,
"colName":"field001",
"value":"",
"minValue":0,
"maxValue":0,
"isDecimal":true,
"fieldItems":[
{
"itemWid":"24797",
"content":"无",
"isOtherItems":0,
"contendExtend":"",
"isSelected":null
}
]
}
]
}

然后提交就好了。

模拟登陆

电脑上直接打开
https://cqjtu.cpdaily.com
发现是金智教育的登录界面
点击登录会跳转到
http://ids.cqjtu.edu.cn/authserver/login?service=https%3A%2F%2Fcqjtu.cpdaily.com%2Fportal%2Flogin
(登陆url暂且叫做ids入口)

之前有大佬抓过今日校园登录的包
发现
https://static.campushoy.com/apicache/tenantListSort
是学校列表,然后直接搜索找到id

然后访问

1
https://mobile.campushoy.com/v6/config/guest/tenant/info?ids=$id

注意把 $id 换成上面找到的id
如: 河大为 https://mobile.campushoy.com/v6/config/guest/tenant/info?ids=henu

然后”ampUrl”的属性就是ids入口。

接着对其进行模拟登陆

这里我对 河南大学重庆交通大学 的登录系统进行了简单的分析:
先看一下487高校的吧:
重庆交通大学的ids入口:
https://cqjtu.cpdaily.com/wec-portal-mobile/client

河南大学:
https://henu.cpdaily.com/wec-portal-mobile/client

打开这个链接之后会经过一次跳转
不难发现,河南大学和重庆交通的源码结构是一样的 登录和加密逻辑也是一样的,改一个url就能通用。

简单抓包之后 可以看到有5个js脚本

简单浏览一下,

1
2
3
4
5
mobile-common.js      实现了一个根据id查找元素的方法和一些简单提示
mobile-login_v1.0.js 是主要的登录逻辑代码
login-language.js 看名字就能猜到
encrypt.js 没有换行没有注释 估计是加密算法之类的
118.fb73b062.js 应该没什么用

对于这种网页登录 其实有一个很简单的方法来模拟登陆 根本不用分析 直接程序内置一个JavaScript引擎即可。
当然 还有一个更简单的方法 直接上github找别人写好的代码直接运行就行了。
不过既然是逆向分析 那么还是看看代码猜猜原理吧。

数据提交

先看 mobile-login_v1.0.js 吧 主要的登录逻辑都在这里。
先不管他每个函数的内部实现代码 只看重要部分

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
// 全局参数,是否需要验证码
var needCaptcha = false;

(function () {

// 验证码刷新事件
getObj("captchaImg").onclick = function ();

// 用户名输入框修改事件,判断是否需要验证码
getObj("mobileUsername").onblur = function ();

// 密码输入框修改事件,判断是否需要验证码
getObj("mobilePassword").onblur = function ();

// 帐号登陆提交事件
getObj("casLoginForm").onsubmit = function doLogin() {
var username = getObj("mobileUsername");
var password = getObj("mobilePassword");
var captchaResponse = getObj("captchaResponse");
_etd(password.value);
}
})();

// 统一校验必填和展示错误信息的方法
function checkRequired(obj, msg)

function showCaptcha()

function hideCaptcha()

function _etd(_p0)

function getCaptcha()

这个匿名函数里面注册了一堆 onclick onsubmit 等方法, 最后还带上了一个括号 也就是说这个函数会在这个JS脚本运行时直接执行。

然后 etObj("casLoginForm").onsubmit = function doLogin() 就应该是点击登录之后触发的函数了。
回到html代码部分 找到 casLoginForm 这个表单

其中 有 name 的才会被提交,没有被标记name的数据不会被提交。
也就是会提交这几个数据:

1
2
3
4
5
6
7
8
9
username         用户名,即学号
password 密码
captchaResponse 验证码,如果没有则为空
rememberMe 自己猜
lt html中已给出
dllt html中已给出
execution html中已给出
_eventId html中已给出
rmShown html中已给出

然后我们发现这里面有一个id为 mobilePasswordEncrypt 的隐藏文本框

根据名字就可以猜测这里应该是加密之后的密码。
先把他去掉隐藏 方便调试。

很显然 这个 登陆 按钮就是Submit

先登陆一下 同时抓包 发现和上述猜想一样。

JS代码中,submit事件执行的最后一行代码是

1
_etd(password.value);

然后我们找到这个函数的定义。

1
2
3
4
5
6
7
8
function _etd(_p0) {
try {
var _p2 = encryptAES(_p0, pwdDefaultEncryptSalt);
getObj("mobilePasswordEncrypt").value = _p2;
} catch(e) {
getObj("mobilePasswordEncrypt").value = _p0;
}
}

他这里把密码明文通过aes加密之后的密文赋值给了隐藏文本框 mobilePasswordEncrypt

然后我们就直接 Console 页面里面输入

1
_etd(getObj("mobilePassword").value);

然后观察加密值。

很遗憾 每次加密所得到的值都不一样,所以这个 encryptAES 并不是简单的AES加密

然后全局搜索 encryptAES 关键字 发现这个函数是在 encrypt.js 中被定义的

然后查看 encrypt.js 的源代码 发现这玩意没有换行没有注释 那么很明显这里是被故意处理过的。

随后随便找了一个JS代码格式化工具 将代码格式化一下

先从 encryptAES 分析

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
function(u, p)
...
function(u)
...
function _gas(data, key0, iv0) {
key0 = key0.replace(/(^\s+)|(\s+$)/g, "");
var key = CryptoJS.enc.Utf8.parse(key0);
var iv = CryptoJS.enc.Utf8.parse(iv0);
var encrypted = CryptoJS.AES.encrypt(data, key, {
iv: iv,
mode: CryptoJS.mode.CBC,
padding: CryptoJS.pad.Pkcs7
});
return encrypted.toString();
}
...
function encryptAES(data, _p1) {
if (!_p1) {
return data;
}
var encrypted = _gas(_rds(64) + data, _p1, _rds(16));
return encrypted;
}
...
function _ep(p0, p1)
...
var $_chars = 'ABCDEFGHJKMNPQRSTWXYZabcdefhijkmnprstwxyz2345678';
var _chars_len = $_chars.length;
function _rds(len) {
var retStr = '';
for (i = 0; i < len; i++) {
retStr += $_chars.charAt(Math.floor(Math.random() * _chars_len));
}
return retStr;
}

encryptAES 最终会调用这个函数

1
2
3
4
5
var encrypted = CryptoJS.AES.encrypt(data, key, {
iv: iv,
mode: CryptoJS.mode.CBC,
padding: CryptoJS.pad.Pkcs7
});

百度一下 不难查到这个函数的用法
也就是说
encryptAES(data, _p1)
data 再外加64位长度的随机数是待加密数据
_p1 最终会被生成为密钥

加密模式为 CBC 加密
偏移量是 iv 也就是16位长度的随机数
填充方式为 CryptoJS.pad.Pkcs7

然后找一个有CBC模式的解密网站去解密一下

https://oktools.net/aes

先不管偏移量 直接解密试试

居然可以! 我输入的密码是 ZheBuShiMiMa

然后我多试了几次 每次生成的密文解密最后几位都是密码

1
2
3
4
5
6
7
8
9
密文:
bw1yTRiusJjJw63205I1Bupu7LwDh+LEONHhVxEM/e/oQUWgiGCGkzENtppNI8z0kSOWrpN8fWiiEtdLTkQQEAYpTJp9/cIuCnXSQZ5y2/0=
解密:
116Bv8/( _2sAAcB5KDiPrQNte6S7Z8DzZHnD8B2KByZ5nE3iaW4kH4XxmMZheBuShiMiMa

密文:
ODZ+0CzKqrMj+jIH8n475GORSBnAkLo68g6OJbcwumi7mToy0f+4+ByICInaNFiQXnD21soHlT27iudzyPJa5/9OhFZRB7s562m9xPv6CtQ=
解密:
vyY#2s!F,4#gMhY8hxjHGQ7JjY4y6idXtCmHPwDiyMsY7wjnhFM2Bm2TQEcbZheBuShiMiMa

至于有随机数加盐之后发给服务器,服务器部分是如何处理的我们不用管,我们也一样搞一个随机数算法就行了。

也就是说 对于数据提交部分 我们只需要提交这个表单

id value
username 用户名,即学号
password 加密之后的密码
captchaResponse 验证码,如果没有则为空
rememberMe 自己猜
lt html中已给出
dllt html中已给出
execution html中已给出
_eventId html中已给出
rmShown html中已给出

密钥分析

encryptAES(data, _p1)
这个函数中 _p1经过一个正则表达式之后就生成了密钥

1
key0 = key0.replace(/(^\s+)|(\s+$)/g, "");

储存这个 _p1 的变量是 pwdDefaultEncryptSalt
先全局搜索一下 pwdDefaultEncryptSalt
发现这个变量有两处赋值语句

一次是在html源代码里面直接赋值
一次是在后续的逻辑判断中防止密码被暴力破解等情况而做的二次赋值修改

1
2
3
4
<script type="text/javascript">
var secure = "false";
var pwdDefaultEncryptSalt = "fqKhnJcyscwm9TcA";
</script>

如果你的学号没有被后续判定为可疑操作的话 这个 pwdDefaultEncryptSalt 在网页刷新前就一直是这个值了
再看看

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function  getCaptcha() {
var username =getObj("mobileUsername").value;
if (username != "") {
ajax({
url: "needCaptcha.html",
data: {username: username,pwdEncrypt2:"pwdEncryptSalt"},
dataType: "json",
success: function (data) {
if (data.indexOf("::::") > -1) {
var pwdEncryptArr=data.split("::::");
try{pwdDefaultEncryptSalt = pwdEncryptArr[1];}catch(e){}
}
if (data.indexOf("true") > -1) {
showCaptcha();
} else {
hideCaptcha();
}
}
});
}
}

不难看出,
后续你的用户名和密码框每次失去焦点就会触发一次 getCaptcha() 事件,这个事件是绑定学号的。
也就是说如果有多次失去焦点事件或者连续输错密码等行为你的pwdDefaultEncryptSalt可能会被重置。

我们先按照他的流程抓一下看看 pwdDefaultEncryptSalt 会生成什么

传回的原数据为

1
2
3
4
5
6
7
8
HTTP/1.1 200 OK
Server: *********
Date: Wed, 26 Aug 2020 10:03:42 GMT
Content-Type: text/html;charset=utf-8
Content-Length: 5
Connection: keep-alive

false

然后传回的data会传入这个函数处理

1
2
3
4
5
6
7
8
9
10
11
12
13
function (data) {
if (data.indexOf("::::") > -1) {
var pwdEncryptArr=data.split("::::");
try{
pwdDefaultEncryptSalt = pwdEncryptArr[1];
}catch(e){}
}
if (data.indexOf("true") > -1) {
showCaptcha();
} else {
hideCaptcha();
}
}

返回了 false 则登录不需要验证码 也不会重置密钥。

也就是说,一般情况下 这个密钥就储存在你的html文件中 如果有可疑行为 则需要重新获取密钥。

模拟登陆的流程

1.先从html中读取

1
2
3
4
5
6
lt
dllt
execution
_eventId
rmShown
pwdDefaultEncryptSalt # 密钥

然后把前五个的值填入表单 再利用密钥对密码进行加密,将账号和加密后的密码填入表单,post,完事

这时候cookie已经更新了 但是因为中间跳转到了别的域名,而这里还是原来的域名,故这里并没有显示

如果想要手动跳转 需要到设置里面关闭自动跳转
然后

再新开一个postman页面 get一下https://cqjtu.cpdaily.com/portal/index.html
就行了

当然实际编程中不需要手动跳转

也太麻烦了吧..

模拟疫情填报的流程

1.先获取到各个学校的ids地址
2.按照上述过程 模拟登陆 然后获取到cookies
3.post一下当前正在进行的收集任务
4.模拟发送疫情填报数据

完事。

代码实现

实现个P 太麻烦了 直接去github上下载一个现成的就好了。
https://github.com/ZimoLoveShuang/auto-submit

改一下配置就好了。
不过需要注意,这里的URL不是ids的入口 而是作者给的模拟登陆接口。

如果想要稳定一些,避免这种情况,最好把登录接口也部署到本地。
https://github.com/ZimoLoveShuang/wisedu-unified-login-api

部署方法就不说了 Readme里面有
然后安排一下计划任务,完事。

感觉TodaySchool这种散装英语比正规的campus daily舒服多了

以后我不敢随便在别的大佬的手机/电脑上登陆账号了。。。

感觉网页的逆向挺有意思也挺简单的,不过玩归玩,别拿来干坏事,小心你去监狱之后我还得去给你送饭 →_→