GiWiFI校园网认证过程分析与模拟登录

前言

GiWiFi是由上海寰创网络科技有限公司基于wifidog项目开发的一套网关管理系统, 通常被应用于校园网行业.

img

认证机制

由于GiWiFi是基于wifidog项目的, 所以大致流程也差不多

基本流程

用户上线

  1. 用户访问网络,通过iptables将未认证的用户dnat到wifidog进程,wifidog通过307报文将用户重定向到认证服务器
  2. 用户打开认证服务器登录页面,输入用户名密码,发送认证请求
  3. 认证成功的话服务器会发送302报文,携带token信息重定向到wifidog页面。认证失败的话会返回失败页面
  4. 用户携带token信息向wifidog发起认证请求,wifidog再向认证服务器发起请求,认证成功后授权,并将用户重定向到成功页面

保活和下线

  1. wifidog会定时向认证服务器发送保活消息(相当于心跳)
  2. 当用户主动请求下线后,wifidog此时并没有下线
  3. 当wifidog再次发起保活请求时,认证服务器会告诉它用户已下线,此时wifidog会将用户下线

网页登录

客户端引导下载页对于不同的设备和系统使用了UA进行区分,经过测试,提供的客户端有Windows、macOS、iOS以及Android版本(果然Linux又被忽视了,显示的是Android版的下载按钮🌝)

由于对此类认证客户端的排斥和心理洁癖,我便开始寻找使用网页认证,甚至是使用脚本模拟认证的方法,果不其然,经过一番分析与搜寻,我找到了一些东西

你藏得好深啊,登录框

此前网页的认证过程为

  1. 打开任意http页面后被劫持至172.17.1.1:8062/redirect
  2. 返回307跳转至认证页
  3. 在认证页里输入账号密码登录认证
  4. 关闭认证页,一段时间内即可正常上网

而现在跳转后仅显示客户端的下载按钮,直觉意识到这是一个专用于客户端引导下载的页面,真正的认证页仍另藏他处

事实正是如此,对比历史记录,现在的跳转页面的域名是172.17.1.1(校园网网关IP),此前的认证页则是http://login.gwifi.com.cn/cmps/admin.php/api/login/

修改UA

先修改UA为ipad(使用UA浏览器插件即可,或者浏览器里的自定义UA)

1
Mozilla/5.0 (iPad; CPU OS 6_0 like Mac OS X) AppleWebKit/536.26 (KHTML, like Gecko) Version/6.0 Mobile/10A5376e Safari/8536.25

将客户端引导下载页的URL参数粘贴到认证页的URL后并打开,就能看到之前的认证页了(单纯打开首页会显示It works! ,不加任何参数打开的话则是跳转至新浪首页,阿巴阿巴~)

1
http://login.gwifi.com.cn/cmps/admin.php/api/login/?gw_address=172.17.1.1&gw_port=8060&gw_id=GWIFI-zhongbeixinshang01&ip=10.16.100.174&mac=你的MAC地址

认证页打开之后仍是一个大大的客户端下载按钮,但不要慌。打开审查元素就会发现,所有的登录框、重设密码框、注册框等都在,只是被隐藏掉了

去掉隐藏样式,正常输入账号密码登录即可,和之前的操作一模一样(登录后会跳转至百度首页,看来这两个页面是同一位鬼才写的🌝)

image-20210322234856216

登录接口及参数分析

找到了登录框之后,就可以开始分析接口和参数了

直接看页面代码吧,写的挺乱的,好在未经过混淆,关键部分:

登录接口

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
var loginAction  = function(params){
var btn = $("#first_button");
var round = Math.round(Math.random()*1000);
var form = $("#frmLogin");
$.ajax({
url: "/cmps/admin.php/api/loginaction?round="+round,
data: form.serialize(),
type: "post",
async: false,
dataType: "json",
success: function (data) {
if (data.status === 1) {
if(data.data.reasoncode == "44"){
params = getWechatParams(data);
//showSelectMessage(params);
wechatAuth(params.okParams);
}else{
window.location.href = data.info;
}
} else {
btn.removeAttr('disabled');
doFailedLogin(data,"frmLogin");
return false;
}
}
});
}

可以看到接口为/cmps/admin.php/api/loginaction,参数都在登录表单里:

关键部分已手动打码

参数名 说明
access_type 2 作用未知
acsign *** 登录状态接口中的sign字段
btype pc 猜测为平台类型
client_mac *** 客户端MAC
contact_phone 400-038-5858 服务电话
devicemode 默认空值,作用未知
gw_address 172.17.1.1 网关地址
gw_id *** AP的SSID
gw_port 8060 网关端口
lastaccessurl 默认空值,作用未知
logout_reason 0 作用未知
mac *** 同client_mac
name *** 账号
online_time 0 猜测为在线时间,作用未知
page_time 1535509645 登录页时间戳
password *** 密码
sign *** 签名,可从登录表单中获取
station_cloud login.gwifi.com.cn 作用未知
station_sn *** 猜测为基站ID
suggest_phone 400-038-5858 同contact_phone
url http://www.baidu.com 登录成功后跳转的网站
user_agent 默认空值,作用未知

观察后发现登录所需的大部分参数在认证页的URL参数里已经有了,剩下的有一部分已经在登录表单里填好了,另一部分需要从下文的登录状态接口中取到,将其组合起来后发送POST请求

登录成功后返回JSON数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
{
"status":1,
"info":"http://172.17.1.1:8060/wifidog/auth?token=***&info=***",
"data":{
"auth_verify":1,
"reasoncode":0,
"remain_time":1053640,
"limit_time":null,
"cost_type":4,
"serviceplan_id":"1357",
"is_share":"2",
"wechat_enable":"1",
"bw_up":"2048",
"bw_down":"10240",
"ontrial":0,
"need_complete_data":null,
"complete_data_url":null,
"permit_intranet":2,
"permit_internet":2,
"carrier_operator":"3",
"network_type":"2"
}
}

其中的info字段的URL用作登录验证,使用GET请求就可以完成整个认证登录的流程了

登录失败的话info字段则会返回百度首页的URL,再次吐槽一下🌝

1
2
3
4
5
{
"status":1,
"info":"http:\/\/www.baidu.com",
"data":1
}

登录状态接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function initData(){
//获取终端信息
$.ajax({
url: "http://172.17.1.1:8060/wifidog/get_auth_state?ip=***&mac=***&sign=***&callback=***",
dataType:'jsonp',
success: function(data) {
c = eval('(' + data.data + ')');
if(data.resultCode == 0){
fixData(c);
}else {
window.top.location.href = "http://www.baidu.com";
}
return false;
},
error:function(data) {
return false;
},
cache: false
});
}

同样可以看到接口为/wifidog/get_auth_state,参数为IP、MAC、签名和回调函数名,其中的签名可以直接在页面表单里取到

返回结果为JSONP数据,提取为:

1
2
3
4
{
"resultCode":0,
"data":"{"auth_state":2,"gw_id":"***","access_type":"2","authStaType":"0","station_sn":"***","client_mac":"***","online_time":11,"logout_reason":7,"contact_phone":"400-038-5858","suggest_phone":"400-038-5858","station_cloud":"login.gwifi.com.cn","orgId":"899","sign":"***"}"
}

其中的auth_state字段值为2时为正常登录状态

观察登录成功后执行的操作,是替换了部分表单数据:

1
2
3
4
5
6
7
8
9
10
11
12
function fixData(data) {
$(".gw_id").val(data.gw_id);
$(".access_type").val(data.access_type);
$(".station_sn").val(data.station_sn);
$(".client_mac").val(data.client_mac);
$(".online_time").val(data.online_time);
$(".logout_reason").val(data.logout_reason);
$(".contact_phone").val(data.contact_phone);
$(".suggest_phone").val(data.suggest_phone);
$(".station_cloud").val(data.station_cloud);
$(".acsign").val(data.sign);
}

进行模拟登录时也应一一替换

需要注意的是参数callback是必需的,不然将不会返回sign字段值

登出接口

登出功能在客户端引导下载页上,接口及参数为http://172.17.1.1/getApp.htm?action=logout

返回数据为:

1
2
3
4
{
"resultCode":0,
"data":[]
}

其中resultCode字段值为0时登出成功

模拟登录脚本

现在登录相关接口和所需参数已经了解,可以开始写模拟登录了

这里使用Python来实现,理论上对所有GiWiFi系统通用

这里需要事先安装好Python以及requests、netifaces库

你可能会用到Visualcppbuildtools(安装C++插件)

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
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
# -*- coding: utf-8 -*-
import os
import re
import time
import json
import argparse
import requests
import netifaces
from getpass import getpass
from urllib.parse import urlparse, parse_qs

SCRIPT_VERSION = "1.0.3.2"

HEADERS = {
'User-Agent': 'Mozilla/5.0 (iPad; CPU OS 6_0 like Mac OS X) AppleWebKit/536.26 (KHTML, like Gecko) Version/6.0 Mobile/10A5376e Safari/8536.25',
'accept-encoding': 'gzip, deflate, br',
'accept-language': 'zh-CN,zh-TW;q=0.8,zh;q=0.6,en;q=0.4,ja;q=0.2',
'cache-control': 'max-age=0'
}

PARSER = argparse.ArgumentParser(formatter_class=argparse.RawDescriptionHelpFormatter,
description='GiWiFi认证登录脚本',
epilog='(c) 2018 journey.ad')
PARSER.add_argument('-g', '--gateway', type=str, help='网关IP')
PARSER.add_argument('-u', '--username', type=str, help='用户名')
PARSER.add_argument('-p', '--password', type=str, help='密码')
PARSER.add_argument('-q', '--quit', action='store_true', help='登出')
PARSER.add_argument('-d', '--daemon', action='store_true', help='在后台守护运行')
PARSER.add_argument('-v', '--verbose', action='store_true', help='额外输出一些技术性信息')
PARSER.add_argument('-V', '--version', action='version',
version='giwifi-auth-helper {}'.format(SCRIPT_VERSION))

CONFIG = PARSER.parse_args()

if not CONFIG.quit:
if not CONFIG.gateway:
CONFIG.gateway = netifaces.gateways()['default'][netifaces.AF_INET][0]
if not CONFIG.password:
CONFIG.gateway = input('请输入网关地址(%s):' % (CONFIG.gateway)) or CONFIG.gateway

if not CONFIG.username:
CONFIG.username = input('请输入上网账号:')

if not CONFIG.password:
CONFIG.password = getpass('请输入账号密码:')
else:
if not CONFIG.gateway:
CONFIG.gateway = netifaces.gateways()['default'][netifaces.AF_INET][0]
CONFIG.gateway = input('请输入网关地址(%s):' % (CONFIG.gateway)) or CONFIG.gateway

def main():
logcat('正在获取网关信息…')

try:
authUrl = requests.get('http://%s:8062/redirect' % (CONFIG.gateway), timeout=5).url
authParmas = {k: v[0] for k, v in parse_qs(urlparse(authUrl).query).items()}

loginPage = requests.get('http://login.gwifi.com.cn/cmps/admin.php/api/login/?' + urlparse(authUrl).query, headers=HEADERS, timeout=5).text

pagetime = re.search(r'name="page_time" value="(.*?)"', loginPage).group(1)
sign = re.search(r'name="sign" value="(.*?)"', loginPage).group(1)

except requests.exceptions.ConnectionError:
logcat('连接失败,请检查网关地址是否正确')
return

except requests.exceptions.Timeout:
logcat('连接超时,可能已超出上网区间')
return

except AttributeError:
logcat('解析失败,可能网关设备重启或系统已更新')
return

authState = getAuthState(authParmas, sign)

if CONFIG.quit:
logout(authParmas)

if not authState:
return

else:
if authState['auth_state'] == 2:
printStatus(authParmas, authState)
logcat('你已登录,无需再次登录')
else:
data = {
'access_type': authState['access_type'],
'acsign': authState['sign'],
'btype': 'pc',
'client_mac': authState['client_mac'],
'contact_phone': '400-038-5858',
'devicemode': '',
'gw_address': authParmas['gw_address'],
'gw_id': authParmas['gw_id'],
'gw_port': authParmas['gw_port'],
'lastaccessurl': '',
'logout_reason': authState['logout_reason'],
'mac': authParmas['mac'],
'name': CONFIG.username,
'online_time': authState['online_time'],
'page_time': pagetime,
'password': CONFIG.password,
'sign': sign,
'station_cloud': 'login.gwifi.com.cn',
'station_sn': authState['station_sn'],
'suggest_phone': '400-038-5858',
'url': 'http://www.baidu.com',
'user_agent': '',
}

if CONFIG.verbose:
logcat(data)

result = login(data)
if result['status']:
authState = getAuthState(authParmas, sign)
printStatus(authParmas, authState)

if authState['auth_state'] == 2:
logcat('认证成功')
else:
logcat('认证失败')
else:
logcat('认证失败,提示信息:%s' % (result['info']))

def login(data):
logcat('正在尝试认证…')

try:
resp = json.loads(requests.post('http://login.gwifi.com.cn/cmps/admin.php/api/loginaction', data=data, timeout=5).text)
result = {
'status': False,
'info': None
}

if CONFIG.verbose:
logcat(resp)

if 'wifidog/auth' in resp['info']:
requests.get(resp['info'])
result['status'] = True
else:
result['info'] = resp['info']

except requests.exceptions.Timeout:
logcat('连接超时,可能已超出上网区间')

finally:
return result

def logout(authParmas):
try:
resp = json.loads(requests.get('http://%s/getApp.htm?action=logout' % (authParmas['gw_address'])).text)

except requests.exceptions.Timeout:
logcat('连接超时,可能已超出上网区间')
return

if resp['resultCode'] == 0:
logcat('下线成功')
else:
logcat('下线失败')

def getAuthState(authParmas, sign):
try:
params = {
'ip': authParmas['ip'],
'mac': authParmas['mac'],
'sign': sign,
'callback': ''
}

resp = json.loads(requests.get('http://%s:%s/wifidog/get_auth_state' % (authParmas['gw_address'], authParmas['gw_port']), params=params, timeout=5).text[1:-1])

except KeyError:
logcat('所需参数不存在')
return False

except requests.exceptions.Timeout:
logcat('连接超时,可能已超出上网区间')
return False

if CONFIG.verbose:
logcat(resp)

if resp['resultCode'] == 0:
return json.loads(resp['data'])
else:
return False

def printStatus(authParmas, authState):
if not CONFIG.verbose:
clear()

print(
'''--------------------------------------------
SSID: %s
AP MAC: %s
GateWay: %s
IP: %s
MAC: %s
Station SN: %s
Logged: %s
--------------------------------------------'''
% (
authParmas['gw_id'],
authParmas['apmac'],
authParmas['gw_address'],
authParmas['ip'],
authParmas['mac'],
authState['station_sn'],
'yes' if(authState['auth_state'] == 2) else 'no'
)
)

def clear():
os.system('cls' if os.name == 'nt' else 'clear')

def logcat(msg, level='I'):
print('%s %s: %s' % (time.ctime().split(' ')[-2], level, msg))

if __name__ == '__main__':
if CONFIG.daemon:
while True:
main()
time.sleep(30)
else:
main()
input()

效果

image-20210322234221276

本文参考:

1.https://nocilol.me/archives/lab/giwifi-auth-process-analysis-and-simulation-login/

2.https://github.com/icepie/giwifi-gear/wiki/GiWiFi-%E5%88%86%E6%9E%90


GiWiFI校园网认证过程分析与模拟登录
https://blog.quickso.cn/2021/03/22/GiWiFI校园网认证过程分析与模拟登录/
作者
木子欢儿
发布于
2021年3月22日
许可协议