0%

HUST微校园js解密

HUST微校园解密

一、登录微校园

第一步当然是登录 hust 微校园,我之前对登录接口抓包研究过,找到了加密过程,提交的数据包等。只差一步,就是验证码的识别,只要能够自动识别出验证码,就可以实现登录的自动化,重新记录一下解密过程。

抓登录包

微信端应用中心接口:https://pass.hust.edu.cn/cas/login?service=http://m.hust.edu.cn/wechat/apps_center.jsp

从这个接口登录,打开 F12 ,对登录数据抓包

image-20211215154413688

可以发现,post 方法,然后状态码是 $ 302 $ ,post 成功之后进行了重定向,跳转了应用中心页面。

image-20211215155058175

payload 中可以看到实际提交的表单数据,很明显对账号密码数据进行了 rsa 加密

ul 貌似是 username length , pl 貌似是 password lengthcode 是验证码,rsa 是账号密码经过 rsa 加密之后的值,剩下的三个不知道从哪个地方来的

分析数据包

查看网页源代码

image-20211215155346701

没想到这三个值直接写到前端源代码里了,蚌埠住了

分析rsa加密

image-20211215155721050

尝试直接搜索 rsa ,没想到直接搜到了,这防范也太弱了,直接可以看到是怎么加密的。

username + password + lt 构成待加密的字符串,参数是 $ 1,2,3 $ ,打上断点,看看实际执行加密的函数是哪个

image-20211215160142331

跳到了这个 des.js 的文件,上面还都有注释,就是加密的函数。直接把这个文件搞下来,在 python 里面执行 js 代码,完成加密过程

这里我选择了 execjs 库,直接 pip install execjs 就行

1
2
3
4
with open('des.js') as file:
comp = execjs.compile(file.read())
s = username + password + lt
rsa = comp.call('strEnc', s, '1', '2', '3')

用法大致就是这样,读进来,编译,然后调用

分析验证码

现在只剩下了验证码难题,一般来说,验证码挺难处理的。可以使用图像识别,打码平台等。这张验证码还是动态的,就挺烦的。

如果样本多的话,可以试着使用深度学习搞一下。因为这个验证码比较简单,杂乱的噪点比较少,所以这里我使用的是 ocr 的方式

首先把验证码搞下来

image-20211215160957739

可以看到验证码的链接,验证码的变化应该和 cookie 有关

很明显是一张 gif 图片,下载下来保存为 gif 图片,对 gif 图片分解,分解为一张张的 jpg

image-20211215161745952

发现是四张图片,第一张的后两个数字和第四张的前两个数字的噪点比较少,这里使用的策略是截取出第一张的后两个数字,第四张的前两个数字,然后拼在一起

image-20211215161757694

然后进行二值化,转化为灰度图片

image-20211215161828830

最后使用一些去噪方法处理一下,这里使用的是用 $ n \times n $ 的方格内数量最多的像素颜色代替整个方格的颜色

image-20211215162032437

这样处理之后已经能够很容易看出来了,下面使用 ocr

ocr 可以使用百度等接口,也可以使用 pytesseract 库,我使用的是 pytesseract ,但是感觉使用接口的话效果会更好一点

构造数据包

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
lt = re.findall('id="lt" name="lt" value="(.*?)"', html)[0]
execution = re.findall('name="execution" value="(.*?)"', html)[0]
with open('des.js') as file:
comp = execjs.compile(file.read())
s = username + password + lt
rsa = comp.call('strEnc', s, '1', '2', '3')
code = self.getCode(curCookie)
print(code)
if len(code) != 4:
return False
# code = 1234
loginData = {
'code': code,
'rsa': rsa,
'ul': len(username),
'pl': len(password),
'lt': lt,
'execution': execution,
'_eventId': 'submit'
}

使用 request post 就行了,注意第一次抓 html 获取 lt, execution, _eventId , 然后使用该次的 cookie 请求验证码链接,得到图片,然后处理得到验证码。最后构造好登录数据包

根据登录后返回的 html 判断是否登录成功

1
2
3
r = self.session.post(url, data=loginData, headers=self.head, cookies=curCookie)
if re.search(r'连续登录失败5次,账号将被锁定1分钟,剩余次数', r.text) is not None:
return False

二、预约出校

抓提交包

image-20211215163152125

image-20211215163316457

发现非常不正常,居然是个 get 接口,提交数据按理说应该是个 post 。估计应该是前端代码中 post 提交,然后根据 post 的返回值生成了这个页面,然后重定向到了这个页面

注意 cookie 变了,所以需要先进行 get 一下预约页面,得到新 cookie 然后再构造数据包 post

查找post接口

image-20211215163612933

搜索了一下 url 中的 data 找到了重定向的地方,附近应该有 post 的地方,可以打上断点,进行调试,找到提交的地方

image-20211215163837311

找到了 post 的链接 http://access.hust.edu.cn/IDKJ-P/student/resStudentAPI

分析post数据包

image-20211215164123018

经过打断点调试,发现这个数据包 data 是将提交的数据进行了加密,其他的居然全是常量,写到了前端代码里。这也太假了!

image-20211215164334176

这个地方进行了加密

image-20211215164502552

发现是 AES 加密,使用的 CBC 模式,Zero 填充,并且密钥和偏移又是明码,还是一样的。随便找个 AES 在线加密的网页进行了一下验证,发现正确。直接搜一份 AES CBC Zeropaddingpython 代码,进行测试,最后发现填充的不是 0 ,应该是空格 space

构造数据包

隐私数据我删了,自己搞的时候记得加上

没想到 startTimeendTime 之间居然可以差很多天,这样的话早知道就不写脚本了,直接使用 postmancookie 放进去,直接提交了几百天的数据包,postresMsg 显示是预约成功了,不知道效果如何,明天后天试试能不能刷开校门

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 对如下json加密,加密之后填到上面的data,然后直接post就行
f_form_data = {"applyUserName": "", "applyUserId": "", "schoolArea": "",
"bookingUserIDcard": "", "deptName": "", "deptNo": "",
"bookingStartTime": "", "bookingEndTime": "",
"visitCase": "11111111"}
# 修改一下时间,修改为当前时间,和一天之后的时间
startTime = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
endTime = (datetime.strptime(startTime, '%Y-%m-%d %H:%M:%S') + timedelta(days=1)).strftime('%Y-%m-%d %H:%M:%S')
f_form_data['bookingStartTime'] = startTime
f_form_data['bookingEndTime'] = endTime

# 对数据进行加密
aes = AesCbcZeroPadding('123456789ABCDEFG', '123456789ABCDEFG')
f_str_data = json.dumps(f_form_data, ensure_ascii=False, separators=(',', ':'))
# 注意需要转化数据格式
en_data = aes.encrypt(f_str_data).decode('utf-8')
data = {
"parkId": "",
"sign": "",
"timeStamp": "",
"data": en_data
}

AES 加密的类

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
class AesCbcZeroPadding(object):
"""
AES CBC zeropadding
结果呈现 hex, 中途使用 utf-8 编码
"""
# 如果text不足16位的倍数就用空格补足为16位
def __init__(self, key, iv):
self.key = key
self.iv = iv

def add_to_16(self, text):
if len(text.encode('utf-8')) % 16:
add = 16 - (len(text.encode('utf-8')) % 16)
else:
add = 0
text = text + ('\0' * add)
return text.encode('utf-8')

# 加密函数
def encrypt(self, text):
key = self.key.encode('utf-8')
mode = AES.MODE_CBC
iv = bytes(self.iv.encode('utf-8'))
text = self.add_to_16(text)
cryptos = AES.new(key, mode, iv)
cipher_text = cryptos.encrypt(text)
# 因为AES加密后的字符串不一定是ascii字符集的,输出保存可能存在问题,所以这里转为16进制字符串
return b2a_hex(cipher_text)

# 解密后,去掉补足的空格用strip() 去掉
def decrypt(self, text):
key = self.key.encode('utf-8')
iv = bytes(self.iv.encode('utf-8'))
mode = AES.MODE_CBC
cryptos = AES.new(key, mode, iv)
plain_text = cryptos.decrypt(a2b_hex(text))
return bytes.decode(plain_text).rstrip('\0')

提交数据包

1
2
3
4
5
6
7
8
9
10
11
r = self.session.post(postUrl, json=data, headers=self.head)
try:
if r.json()['resCode'] != '0':
print('预约失败')
return False
else:
print('预约成功')
return True
except Exception as e:
print('返回了err网页')
return False

注意一下 data 是放到 json 参数中了,之前我直接填的 datar = self.session.post(postUrl, data, headers=self.head)一直返回服务器网络错误。

  • JSON
  1. 使用json参数,不管报文是str类型,还是dict类型,如果不指定headerscontent-type的类型,默认是:application/json
  • DATA
  1. 使用data参数,报文是dict类型,如果不指定headerscontent-type的类型,默认application/x-www-form-urlencoded,相当于普通form表单提交的形式,会将表单内的数据转换成键值对,此时数据可以从request.POST里面获取,而request.body的内容则为a=1&b=2的这种键值对形式。
    注意:即使指定content-type=application/jsonrequest.body的值也是类似于a=1&b=2,所以并不能用json.loads(request.body.decode())得到想要的值。

  2. 使用data参数,报文是str类型,如果不指定headerscontent-type的类型,默认application/json

    之前就是犯了这个错误,指定了content-type=application/json,但还是不行,改成 json 就行了

三、发送邮件

编写 smtp 发送邮件的类,预约成功和失败时都把 log.txt 中的数据发送到自己的邮箱参看

四、定时任务

使用了 apscheduler 库,这个库可以用类似 corn 表达式的方式实现定时任务

1
2
3
4
def main():
scheduler = BlockingScheduler(timezone='Asia/Shanghai')
scheduler.add_job(job, 'cron', hour=7)
scheduler.start()

设置成每天早上 $ 7 $ 点运行,对登录模块和预约模块进行多次循环,失败时自动重试

五、总结

通过这个项目,练习了 js 解密,又重温了 request 爬虫。详细代码可见 luobuyu/HUST-appointments-out-school: 华科微校园自动预约出校 (github.com)