Featured image of post 【实战】记一次对校园网登录页面的逆向

【实战】记一次对校园网登录页面的逆向

谁知道它诞生于一个离谱的需求呢

本次逆向的结果可能大概率对您没有任何用处,但是您也可以参考一下思路。

前情提要

话说我之前在宿舍里部署了路由器和番剧服务器,这路由器什么都好,可就是每天半夜校园网断网后第二天早上需要手动重新登录,于是我就想写一个自动登录校园网的脚本,让每天凌晨的时候树莓派能自动执行脚本登录一下。

实战经历

首先观察登陆页面:

看到登录需要提供账密以及验证码,那么按照常规思路,账密肯定是在数据包中要发给后端的,因此首要的就是要过验证码这一关,果断启动Burpsuite抓个包。

好消息,启动抓包后点击验证码刷新并没有受到影响,而bp也没有抓到包,这说明整个验证码的生成和校验过程是在前端进行的,因此我们大概率可以直接获取到当前验证码的值,甚至绕过验证码直接发包。

验证码

不过包着好奇心,还是来了解一下验证码相关的逻辑:

F12打开开发人员模式。选择源代码,到js文件夹里把几个js文件都保存下来备用

不过直接保存下来的话js文件只有一行,非常不利于分析,所以建议还是在本地新建同名文件,然后把浏览器里面格式化之后的js脚本内容复制粘贴一下

我们在下面的搜索栏中搜索相关关键词,例如vercode,看到app.5ee45fcc.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
 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
            }, {
                key: "refresh",
                value: function(e) {
                    var t = document.getElementById(this.options.canvasId);
                    if (t && t.getContext) {
                        var n = t.getContext("2d");
                        n.clearRect(0, 0, t.width, t.height),
                        n.textBaseline = "middle",
                        n.fillStyle = this.randomColor(180, 240),
                        n.fillRect(0, 0, this.options.width, this.options.height),
                        this.options.code = "";
                        for (var o = 0; o < e.length; o++) {
                            this.options.code += e[o];
                            var r = this.randomNum(this.options.height / 2, this.options.height)
                              , i = (r = (n.font = "".concat(r, "px ").concat(this.options.fontFamily),
                            n.fillStyle = this.randomColor(50, 160),
                            n.shadowColor = "rgba(0, 0, 0, 0.3)",
                            this.options.width / (e.length + 1) * (o + .5)),
                            this.options.height / 2)
                              , a = this.randomNum(-30, 30);
                            n.save(),
                            n.translate(r, i),
                            n.rotate(a * Math.PI / 180),
                            n.fillText(e[o], 0, 0),
                            n.restore()
                        }
                        for (var s = 0; s < 4; s++)
                            n.fillStyle = this.randomColor(0, 255),
                            n.beginPath(),
                            n.arc(this.randomNum(0, this.options.width), this.randomNum(0, this.options.height), 1, 0, 2 * Math.PI),
                            n.fill()
                    }
                }
            }, {
                key: "validate",
                value: function(e) {
                    return e.toLowerCase() === this.options.code.toLowerCase()
                }
            }, {
                key: "getAllNum",
                value: function() {
                    return "0,1,2,3,4,5,6,7,8,9".split(",")
                }
            }, {
                key: "getAllLetter",
                value: function() {
                    return "a,b,c,d,e,f,g,h,i,j,k,l,m,n,o,p,q,r,s,t,u,v,w,x,y,z,A,B,C,D,E,F,G,H,I,J,K,L,M,N,O,P,Q,R,S,T,U,V,W,X,Y,Z".split(",")
                }
            }, {
                key: "randomNum",
                value: function(e, t) {
                    return Math.floor(Math.random() * (t - e) + e)
                }
            }, {
                key: "randomColor",
                value: function(e, t) {
                    var n = this.randomNum(e, t)
                      , o = this.randomNum(e, t);
                    e = this.randomNum(e, t);
                    return "rgb(".concat(n, ", ").concat(o, ", ").concat(e, ")")
                }
            }]) && It(t.prototype, n),
            e
        }
        )()
          , Dt = {
            name: "sy-vercode",
            props: {
                canvasId: {
                    type: String,
                    require: !0
                },
                text: {
                    type: String,
                    default: ""
                }
            },
            data: function() {
                return {
                    verifyCode: ""
                }
            },
            watch: {
                text: {
                    handler: function(e) {
                        var t = this;
                        e && this.$nextTick(function() {
                            t.verifyCode || (t.verifyCode = new Ft({
                                id: "auth_code",
                                canvasId: t.canvasId
                            })),
                            t.verifyCode.refresh(e)
                        })
                    },
                    immediate: !0
                }
            }
        }
          , zt = (n("8767"),
        [Ze, et, j, We, Xe, Bt, Et, _t, St, Object(T.a)(Dt, function() {
            this.$createElement;
            return this._self._c,
            this._m(0)
        }, [function() {
            var e = this.$createElement;
            return (e = this._self._c || e)("div", {
                staticClass: "sy-vercode"
            }, [e("div", {
                attrs: {
                    id: "auth_code"
                }
            })])
        }

我肯定是不想看也看不懂的,所以交给AI分析,AI得出的结果是:

  • 值就在 this.options.code
  • 校验只发生在前端 validate()
  • 后端大概率根本不验证验证码

核心:

1
2
3
4
5
6
this.options.code = "";
for (var o = 0; o < e.length; o++) {
    this.options.code += e[o];
    ...
    n.fillText(e[o], 0, 0)
}

e = 验证码字符数组

this.options.code = 最终验证码字符串

canvas 只是“画出来”


再看:

1
2
3
validate: function(e) {
    return e.toLowerCase() === this.options.code.toLowerCase()
}

校验逻辑:

用户输入 vs this.options.code

完全在前端


再看 Vue:

1
t.verifyCode.refresh(e)

说明:

  • e 是验证码字符来源
  • refresh() 每次重新画图
  • code 是 refresh 时生成的

所以我们可以直接在控制台 Console 中执行下面的命令拿到验证码:

1
document.querySelector('.sy-vercode').__vue__.verifyCode.options.code

登录校验

既然验证码肯定是不需要了,那么接下来就着手看看账密怎么解决。

因为我们学校的校园网登录分为两步,

  • 第一步:输入账密和验证码(已知可绕过),点击登录
  • 第二步:在弹出的运营商列表中选择校园卡所属的运营商,如图:

按照惯例,分成这两步,抓个包先:

登录.txt

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
POST /api/v1/login HTTP/1.1
Host: 10.255.255.16
Content-Length: 279
Access-Control-Allow-Origin: *
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36 Edg/146.0.0.0
Accept: application/json, text/plain, */*
Content-Type: application/json;charset=UTF-8
Origin: http://10.255.255.16
Referer: http://10.255.255.16/
Accept-Encoding: gzip, deflate, br
Accept-Language: zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6
Connection: keep-alive

{"username":"8a4f8baf*******f917b62de4931951e","password":"c7fdea3*******c4c628bfca2cb6cdad","ifautologin":"f2555e47ba2332040eaa540bfe269c5a","channel":"487f23259df1bc3df499d24ecd1f9bf0","pagesign":"724df9a183fc697ce202c7ce7f623219","usripadd":"9b80d8ee5867ebbdc0a7db0d5b52d654"}

选运营商.txt

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
POST /api/v1/login HTTP/1.1
Host: 10.255.255.16
Content-Length: 279
Access-Control-Allow-Origin: *
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36 Edg/146.0.0.0
Accept: application/json, text/plain, */*
Content-Type: application/json;charset=UTF-8
Origin: http://10.255.255.16
Referer: http://10.255.255.16/
Accept-Encoding: gzip, deflate, br
Accept-Language: zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6
Connection: keep-alive

{"username":"8a4f8baf*******f917b62de4931951e","password":"c7fdea3*******c4c628bfca2cb6cdad","ifautologin":"f2555e47ba2332040eaa540bfe269c5a","channel":"69844f2a65cd511993928ab8d0fbe207","pagesign":"05eaea333a1b9eb6817bf703e5829ab6","usripadd":"9b80d8ee5867ebbdc0a7db0d5b52d654"}

分析这两部的数据包,可以看到对/api/v1/login这个接口发起了POST请求,参数是前端加密过后的,共有5个:

  • username
  • passward
  • ifautologin
  • channel
  • pagesign
  • usripadd

并且其中channel和pagesign在一二两部中值也不同。

到目前为止,其实要写出自动化脚本已经足够了,直接把这几个不明所以的参数一发就完事,但是不知道持久性如何,而且既然要逆向,何不一探究竟呢?正如千反田爱瑠所说:“我很好奇!”

根据刚才对数据包的分析结果,我们选取pagesign作为关键词,同样在开发者模式的源代码中的几个js脚本中搜索:结果显示,chunk-2bb67818.1726b525.js中有3条结果:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
var n = Object(A.b)(this.formData.username)
                      , e = Object(A.a)(this.formData.username)
                      , o = Object(A.a)(this.formData.password, n)
                      , i = this.checked ? "1" : "0"
                      , r = (i = Object(A.a)(i, n),
                    Object(A.a)("_GET", n))
                      , a = Object(A.a)("firstauth", n);
                    n = Object(A.a)(this.ip, n);
                    this.$API.login({
                        username: e,
                        password: o,
                        ifautologin: i,
                        channel: r,
                        pagesign: a,
                        usripadd: n

可以看到里面多次提到了Object(A.a)(参数1,参数2)Object(A.b)(参数1,参数2)的形式

并且大概可以推断出之前抓到登录中的channel就对应着A.a("_GET", key),而在选运营商中channel就变成了别的字符串。

所以接下来的目标很明朗,就是要找到这个A.a()A.b()的源码,搞清楚它们究竟在做什么。

由于javascript的逆天结构,一个函数背后会有其id,再根据id映射到源码,还有可能层层套娃,所以根本不是人看的,这里我们借助集成AI的IDE来完成这项工作,为了不浪费我的Codex额度,我使用国产免费的Trae。

在Trae中打开到之前我们保存js脚本的文件夹,让ChatGPT给我们写一个prompt:

 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
你是一个擅长前端逆向和webpack打包分析的工程师。

我有一段经过webpack压缩混淆的JS代码,其中出现了如下调用:

Object(A.a)(data, key)
Object(A.b)(username)

请你帮我完成以下任务:

1. 在代码中定位变量 A 的来源(通常是 var A = n("xxxx") 或类似形式)

2. 找到对应模块定义(webpack模块),也就是类似:
   xxxx: function(module, exports, n) { ... }

3. 在该模块中找到:
   exports.a = function(...) { ... }
   exports.b = function(...) { ... }

4. 输出这两个函数的完整源码,并做以下分析:

   * 是否使用了 md5 / sha1 / base64 / AES 等加密
   * 输入输出关系(例如:是否为 md5(data + key))
   * 是否依赖第三方库(如 CryptoJS)

5. 如果可以,请将这两个函数还原成“未混淆、可读性高”的版本

6. 最后,请给出对应的 Python 实现(等价逻辑)

已知背景:

* 这是一个校园网登录页面
* username / password / pagesign / channel 等字段都是通过 A.a / A.b 生成
* 返回值是32位hex字符串(疑似MD5)

请重点关注字符串拼接、哈希函数调用、编码转换等逻辑。

把prompt粘贴到IDE的聊天侧栏中回车,AI就开始工作了。

大概两分钟,AI输出了结果:

分析结果

变量A的来源 在 chunk-2bb67818.1726b525.js 文件的第161行,变量A是通过以下方式引入的:

1
var A = e("90c5");

模块定义

app.5ee45fcc.js 文件的第14515行,找到了模块90c5的定义:

 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
"90c5": function(e, t, n) {
	n.d(t, "a", function() {
			return r
		}),
		n.d(t, "b", function() {
			return i
		}),
		n("0d03"),
		n("d3b7"),
		n("25f0");
	t = n("3452");
	var o = n.n(t),
		r = (o.a.enc.Utf8.parse("axaQiQpsdFAacccs"),
			function(e) {
				var t = o.a.enc.Utf8.parse(1 < arguments.length &&
					void 0 !== arguments[1] ?
					arguments[1] :
					"axaQiQpsdFAacccs");
				e = o.a.enc.Utf8.parse(e);
				return o.a.AES.encrypt(e, t, {
					mode: o.a.mode.ECB,
					padding: o.a.pad.Pkcs7
				}).ciphertext.toString(o.a.enc.Hex)
			}
		),
		i = function(e) {
			return o.a.SHA256("axaQiQpsdFAacccs" + e).
			toString(o.a.enc.Hex).
			substring(0, 16)
		}
}

函数分析

  1. A.a 函数
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
function(e) {
	var t = o.a.enc.Utf8.parse(1 <
		arguments.length && void 0 !==
		arguments[1] ? arguments[1] :
		"axaQiQpsdFAacccs");
	e = o.a.enc.Utf8.parse(e);
	return o.a.AES.encrypt(e, t, {
		mode: o.a.mode.ECB,
		padding: o.a.pad.Pkcs7
	}).ciphertext.toString(o.a.enc.Hex)
}

分析:

  • 加密算法: AES-ECB
  • 填充方式: Pkcs7
  • 默认密钥: “axaQiQpsdFAacccs”
  • 输入:
    • 第一个参数:要加密的数据
    • 第二个参数(可选):密钥,默认为"axaQiQpsdFAacccs"
  • 输出: AES加密后的十六进制字符串
  1. A.b 函数
1
2
3
4
function(e) {
	return o.a.SHA256("axaQiQpsdFAacccs" + e).
	toString(o.a.enc.Hex).substring(0, 16)
}

分析:

  • 哈希算法: SHA256
  • 输入: username
  • 处理逻辑: 对"axaQiQpsdFAacccs"+username进行SHA256哈希,然后取前16个字符
  • 输出: SHA256哈希值的前16个字符
  • 依赖库 :
    • 第三方库: CryptoJS
    • 模块3452: 是CryptoJS库的入口点,包含了AES和SHA256等加密算法的实现

至此已经基本一目了然了,现在唯一的一点就是选运营商时发生改变的channel和pagesign了,不过根据前面看到的登录时pagesign的明文是firstauth可以大致推断第二次发包里明文应该是secauth,反正密钥和加密方法都确定了反向解密不就行了吗?

验证猜想和编写脚本

根据目前已经收集的信息,初步得到一个脚本:

 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
import requests
import hashlib
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad

URL = "http://10.255.255.16/api/v1/login"

DEFAULT_KEY = "axaQiQpsdFAacccs"


# ===== SHA256(A.b)=====
def gen_key(username: str) -> str:
    s = DEFAULT_KEY + username
    return hashlib.sha256(s.encode()).hexdigest()[:16]


# ===== AES ECB PKCS7(A.a)=====
def aes_encrypt(data: str, key: str = DEFAULT_KEY) -> str:
    cipher = AES.new(key.encode(), AES.MODE_ECB)
    encrypted = cipher.encrypt(pad(data.encode(), AES.block_size))
    return encrypted.hex()


# ===== 用户信息 =====
username_raw = "你的账号"
password_raw = "你的密码"
ip = "你的IP"   # 建议抓包获取


# ===== 生成 key =====
key = gen_key(username_raw)


# ===== 第一步 =====
data1 = {
    "username": aes_encrypt(username_raw),
    "password": aes_encrypt(password_raw, key),
    "ifautologin": aes_encrypt("0", key),
    "channel": aes_encrypt("_GET", key),
    "pagesign": aes_encrypt("firstauth", key),
    "usripadd": aes_encrypt(ip, key)
}


session = requests.Session()

r1 = session.post(URL, json=data1)
print("Step1:", r1.text)


# ===== 第二步(选运营商)=====
# ⚠ 这里要填真实运营商字符串
operator = "CMCC"   # 或 "CUCC" / "CTCC"

data2 = {
    "username": data1["username"],
    "password": data1["password"],
    "ifautologin": data1["ifautologin"],
    "channel": aes_encrypt(operator, key),
    "pagesign": aes_encrypt("secauth", key),  # ⚠ 可能是这个
    "usripadd": data1["usripadd"]
}

r2 = session.post(URL, json=data2)
print("Step2:", r2.text)

不过先不着急使用它,我们先按照逻辑生成一个key拿到cyberchef上反向解密一下第二个channel的明文:

解密结果竟然是3,不过回想起来第一次登录时候服务端返回的内容也就明白了:

{"code":200,"message":"ok","data":{"channels":[{"id":"1","name":"校园网"},{"id":"3","name":"中国电信"},{"id":"2","name":"中国移动"},{"id":"4","name":"中国联通"}]}}

所以这里的明文其实就对应着所列出的运营商的id。

接下来顺手把第二个pagesign反向解密一下:

事实证明它的明文是secondauth

至此,整个逻辑链条已经被我们完全破解,把上面的脚本优化一下就可以得到一个更适合实际部署的脚本:

 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
import requests
import hashlib
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad

def is_online():
    try:
        requests.get("https://www.baidu.com", timeout=3)
        return True
    except:
        return False

if is_online():
    print("✅当前已连上校园网,无需重复登录!")
    exit()

URL = "http://10.255.255.16/api/v1/login"

DEFAULT_KEY = "axaQiQpsdFAacccs"


# ===== SHA256(A.b)=====
def gen_key(username: str) -> str:
    s = DEFAULT_KEY + username
    return hashlib.sha256(s.encode()).hexdigest()[:16]


# ===== AES ECB PKCS7(A.a)=====
def aes_encrypt(data: str, key: str = DEFAULT_KEY) -> str:
    cipher = AES.new(key.encode(), AES.MODE_ECB)
    encrypted = cipher.encrypt(pad(data.encode(), AES.block_size))
    return encrypted.hex()


# ===== 用户信息 =====
username_raw = "你的账号"
password_raw = "你的密码"
ip = "你的IP(网页上有)"   # 建议抓包获取


# ===== 生成 key =====
key = gen_key(username_raw)

# ===== 第一步 =====
data1 = {
    "username": aes_encrypt(username_raw),
    "password": aes_encrypt(password_raw, key),
    "ifautologin": aes_encrypt("0", key),
    "channel": aes_encrypt("_GET", key),
    "pagesign": aes_encrypt("firstauth", key),
    "usripadd": aes_encrypt(ip, key)
}


session = requests.Session()

r1 = session.post(URL, json=data1)
print("Step1:", r1.text)


# ===== 第二步(选运营商)=====

data2 = {
    "username": data1["username"],
    "password": data1["password"],
    "ifautologin": data1["ifautologin"],
    "channel": aes_encrypt("运营商编号", key),   # 此处选择运营商,1-校园网,2-中国移动,3-中国电信,4-中国联通
    "pagesign": aes_encrypt("secondauth", key),
    "usripadd": data1["usripadd"]
}

r2 = session.post(URL, json=data2)
print("Step2:", r2.text)

ver = '{"code":200,"message":"ok","data":{"reauth":false'
if ver in r2.text:
    print("✅自动登录成功")

然后再在树莓派上设一个计划任务就可以愉快的使用了。

经验总结

js这种不是给人看的东西一定要多用AI。

Licensed under CC BY-NC-SA 4.0
已存在于互联网
发表了137篇文章 · 总计232.20k字
萌ICP备20267077号
Powered by ctOS