博主头像

人間になりたい!!!!!


皖ICP备2025096275号

折腾校园网: 利用 VLAN 接入 AP 汇聚交换机, 实现通过 AP 通道上网与 Web 认证自动登录

前言

博主的学校宿舍楼在大一的暑假进行了一次翻修, 开学回来后虽然设施是翻新了, 但是居然把墙上的网络面板给弄坏了! 这让博主这种十分依赖有线低延迟和链路聚合的用户十分气愤. 学校的 AP 不仅性能弱, 延迟高. 且每次连接都需要重新在 Captive Protocol 中进行认证, 十分不方便. 一开始得到的回复是十月中旬之前会全部弄好, 但现在已经将近十月中旬了, 也没见有人来施工. 在思考的过程中, 博主将目标转向了每个寝室都会安装的 AP, 思考能不能在这块做些什么. 于是乎就有了今天这篇文章.

环境介绍与研究目标

AP 是通过每层楼的 PoE 汇聚交换机接入的, 每个寝室都有. 正常的连接方式是下游 STA 设备通过 WiFi 接入, 然后登录学校的 Captive Protocol 进行认证上网. 博主这里打算将接入 AP 的这条网线接入自己的 OpenWrt 路由器, 实现有线上网.

观前须知

注意: 本文为纯整活向文章, 这种上网方式既不合适也不推荐. 不单单是因为麻烦, 还有可能会因为私自修改学校网络设备被校方追责. 看个乐呵就行, 不要照做! 如不听劝, 那后果自负!


开整

1. 探明网络架构

将网线直接接入路由器, 发现 DHCP 能拿到地址

3: eth1: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1492 qdisc fq_codel state UP group default qlen 1000
    link/ether 00:e0:4c:d0:1f:f7 brd ff:ff:ff:ff:ff:ff
    inet 172.17.255.165/18 brd 172.17.255.255 scope global eth1
       valid_lft forever preferred_lft forever
    inet6 fe80::2e0:4cff:fed0:1ff7/64 scope link proto kernel_ll 
       valid_lft forever preferred_lft forever

正常来说下游 STA 访问外部网站会被直接 302 到门户网站, 这里博主在电脑上尝试后发现并不能. 这里博主使用了一台手机连接学校的 WiFi, 拿到了门户网站的地址, 随后在路由器侧测试 DNS. 发现 DNS 是能通的.

root@X-wrt-School:~# nslookup *.edu.cn
Server:         127.0.0.1
Address:        127.0.0.1:53

Non-authoritative answer:
Name:   gw.wxc.edu.cn
Address: 211.*.*.*

Non-authoritative answer:

尝试直接 ping 门户网站服务器, 发现无法通信

root@X-wrt-School:~# ping 211.*.*.*
PING 211.*.*.* (211.*.*.*): 56 data bytes
^C
--- 211.*.*.* ping statistics ---
6 packets transmitted, 0 packets received, 100% packet loss

2. 分析路由策略

观察后发现, 路由器侧拿到的 DHCP 地址为 172.0.0.0/8 段, 但是 STA 侧如果连接 WiFi, 拿到的地址就是 10.0.0.0/8 段, 这与路由器拿到的地址不一致. STA 使用这个地址段的 IP 可以直接访问到门户网站, 而路由器却不行. 因此可以推测: 下游设备到门户服务器中的路由有防火墙, 且可能仅对 10.0.0.0/8 地址段开放.
既然 STA 连接 WiFi 能拿到 10.0.0.0/8 的地址, 那么路由器肯定也是能通过某些方法拿到这段地址的. 这里最有可能的方式便是 VLAN.
使用 WireShark 对路由器进行抓包, 发现三条 VLAN. 分析后发现其对应的网段如下

VLAN IDNetwork
60210.130.0.0/16
60410.140.0.0/16
60610.150.0.0/16

使用 STA 连接 AP 发射的三条 WLAN, 发现网段与 ISP 的对应关系如下

VLAN IDNetworkISP
60210.130.0.0/16CTNET
60410.140.0.0/16CMCC
60610.150.0.0/16CUNIQ

知道了这些信息, 我们就可以着手开始在路由器侧进行配置了.

3. 接口配置

  1. 打开 LuCi WebUI, 转到 Network -> Interfaces -> Devices, 点击 Add device configuration... 添加接口配置
  2. Device type 选择 VLAN (802.1q), 填入对应 VLAN ID 和接口名称.

    注意: 这里有多少个用户就需要创建多少个 VLAN 端口, 否则因为接口参数不变, 不管创建多少个 DHCP 拿到的永远都是同一个地址.
    如果需要在一个接口上创建多个 VlanIf, 则需要将第 >=2 个 Vlanif 接口的 MAC 地址做更改. 值与接口本身不同即可.
  3. 接口全部创建完成后单击保存并应用.
    全部配置
    全部配置
  4. 转到 Interfaces 选项卡, 点击 Add new interface... 添加新接口
  5. 协议选择 DHCP Client, 名称按需填写, 接口则选择刚刚创建的 VlanIf 接口
  6. 转到 Advanced Settings, 将 Use default gateway 取消勾选

    注意: 这一步非常重要, 如果不取消勾选, 系统将会在陆游表内自动创建一条到达该接口网关的默认路由. 这个操作会影响当前路由器的正常上网!
    出于隐私考虑, 也推荐将 General Settings 中的 Hostname to send when requesting DHCP 选项改为 Do not send a hostname.
  7. 全部创建完成后, 单击保存并应用. 如果全部可以正常获取地址, 则该部分配置完成.

    注意: 在某些 aarch64 机型上, SoC 可能会限制同一个 VID 只能创建一个 VlanIf 设备. 如果您使用 VlanIf 创建 DHCP Client 时, 出现报错 DEVICE_CLAIM_FAILED, 则可能是这个原因. 这种情况下最合适的做法是: 基于该接口创建一个 VlanIf 接口, 然后多用户使用 Mac VLAN 接入.

4. 尝试登录网络

在之前的分析中, 我们获得了 Captive Protocol 认证门户的 IP 地址, 所以我们这里随便找一个 MAC VlanIf 接口作为出口, 写一条到认证门户的路由即可测试.

ip r add 211.*.*.*/32 via 10.150.0.1 dev lan1.cuniq-***

路由写进去后使用下游设备登录门户网站

发现成功登录. 输入正确的校园网账号密码后可以正常登录.

至此, Captive Protocol 门户的验证也完成了.

5. 持久化

注销刚刚登录的校园网账号, 打开 BurpSuit 进行抓包. 分析后发现登录过程如下:

  1. 获取客户端 IP — 从门户页面的 CONFIG 嵌入信息中提取 ip 字
  2. 获取 Challenge Token — GET /cgi-bin/get_challenge?username=<账号@域名>&ip=<IP>&callback=cb, 返回 64 字节 hex token (有效期 60 秒)
  3. HMAC-MD5 — hmd5 = HMAC-MD5 (key=密码, msg=token), 逆向自 all.min.js 中 md5 v2.10.0 库的 A(n,t,r) 函数, 双参数时实际调用的是 HMAC
  4. 用户信息加密 — 将 {username, password, ip, acid, enc_ver:"srun_bx1"} 的 JSON 用 XXTEA (delta=0x9E3779B9) 以 token 为密钥加密, 再用自定义 Base64 字母表 LVoJPiCN2R8G90yg+hmFHuacZ1OWMnrsSTXkYpUq/3dlbfKwv6xztjI7DeBE45QA 编码, 前缀 {SRBX1}
  5. 校验和 + 登录 — chksum = SHA1(token+username + token+hmd5 + token+acid + token+ip + token+n + token+type + token+info), 然后 GET /cgi-bin/srun_portal 发起 JSONP 登录

根据逆向结果, 我们可以写一份登录脚本

USERNAME=""
PASSWORD=""
DOMAIN=""
INTERFACE=""
GATEWAY=""
CPURL=""

# Set IP route to captive protocol server
ip route add 211.*.*.* via $GATEWAY dev $INTERFACE
echo "Route added, sleep 5s..."
sleep 5

set -euo pipefail

# Get AC_ID from captive protocol url
AUTH_HOST="$(printf '%s\n' "$CPURL" | sed -nE 's#^https?://([^/]+)/.*#\1#p')"
ACID="$(printf '%s\n' "$CPURL" | sed -nE 's/.*ac_id=([0-9]+).*/\1/p')"
[ -z "$ACID" ] && ACID="1"

echo "=== Srun Portal Login ==="
echo "Captive Protocol Server: $AUTH_HOST"
echo "ac_id:      $ACID"
echo "Username:     ${USERNAME}${DOMAIN}"
echo ""

python3 - "$AUTH_HOST" "$ACID" "$USERNAME" "$PASSWORD" "$DOMAIN" << 'PYTHON_SCRIPT'
import sys, json, hmac, hashlib, base64, re, urllib.request, urllib.parse, time

auth_host, ac_id, username_raw, password, domain = sys.argv[1:6]
username = username_raw + domain
base_url = f"http://{auth_host}"

# ---- HTTP GET with retry ----
def http_get(url, timeout=15, retries=5):
    last_err = None
    for attempt in range(1, retries + 1):
        try:
            req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'})
            with urllib.request.urlopen(req, timeout=timeout) as resp:
                return resp.read().decode('utf-8')
        except Exception as e:
            last_err = e
            wait = attempt * 3
            print(f"  [retry {attempt}/{retries}] {e} — waiting {wait}s...", file=sys.stderr)
            time.sleep(wait)
    raise last_err

def parse_jsonp(body):
    m = re.search(r'\((\{.*\})\)', body, re.S)
    return json.loads(m.group(1)) if m else json.loads(body)

# ---- XXTEA Enc ----
def xxtea_encrypt(text, key):
    if text == '':
        return ''
    def str_to_ints(s, append_len):
        c = len(s)
        v = []
        for i in range(0, c, 4):
            n = (ord(s[i]) if i < c else 0)
            n |= (ord(s[i+1]) if i+1 < c else 0) << 8
            n |= (ord(s[i+2]) if i+2 < c else 0) << 16
            n |= (ord(s[i+3]) if i+3 < c else 0) << 24
            v.append(n & 0xFFFFFFFF)
        if append_len:
            v.append(c)
        return v

    def ints_to_str(v):
        chars = []
        for val in v:
            chars.append(chr(val & 0xff))
            chars.append(chr((val >> 8) & 0xff))
            chars.append(chr((val >> 16) & 0xff))
            chars.append(chr((val >> 24) & 0xff))
        return ''.join(chars)

    v = str_to_ints(text, True)
    k = str_to_ints(key, False)
    if len(k) < 4:
        k.extend([0] * (4 - len(k)))
    n = len(v) - 1
    z, y = v[n], v[0]
    DELTA = 0x9E3779B9
    M = 0xFFFFFFFF
    q = 6 + 52 // (n + 1)
    d = 0
    while q > 0:
        d = (d + DELTA) & M
        e = (d >> 2) & 3
        for p in range(n):
            y = v[p + 1]
            m = ((z >> 5) ^ (y << 2)) & M
            m = (m + (((y >> 3) ^ ((z << 4) & M)) ^ (d ^ y))) & M
            m = (m + (k[(p & 3) ^ e] ^ z)) & M
            z = v[p] = (v[p] + m) & M
        p = n
        y = v[0]
        m = ((z >> 5) ^ (y << 2)) & M
        m = (m + (((y >> 3) ^ ((z << 4) & M)) ^ (d ^ y))) & M
        m = (m + (k[(p & 3) ^ e] ^ z)) & M
        z = v[n] = (v[n] + m) & M
        q -= 1
    return ints_to_str(v)

# ---- Custom Base64 ----
CUSTOM = 'LVoJPiCN2R8G90yg+hmFHuacZ1OWMnrsSTXkYpUq/3dlbfKwv6xztjI7DeBE45QA'
STD    = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
def custom_b64(data_bytes):
    std_b64 = base64.b64encode(data_bytes).decode('ascii')
    return std_b64.translate(str.maketrans(STD, CUSTOM))

# ---- Step 0: Get Client IP ----
try:
    page = http_get(f"{base_url}/srun_portal_pc?ac_id={ac_id}&theme=pro")
    m = re.search(r'ip\s*:\s*"([^"]+)"', page)
    client_ip = m.group(1) if m else ''
except Exception as e:
    print(f"[!] Failed to get client IP: {e}", file=sys.stderr)
    sys.exit(1)
if not client_ip:
    print("[!] Could not parse client IP from portal page", file=sys.stderr)
    sys.exit(1)
print(f"Client IP: {client_ip}")

# ---- Step 1: Get Challenge Token ----
cb = f"srun{int(time.time()*1000)}"
params = urllib.parse.urlencode({'username': username, 'ip': client_ip, 'callback': cb})
resp = parse_jsonp(http_get(f"{base_url}/cgi-bin/get_challenge?{params}"))
if resp.get('error') != 'ok':
    print(f"[!] Failed to get challenge token: {resp}", file=sys.stderr)
    sys.exit(1)
token = resp['challenge']
print(f"Token:       {token[:16]}...")

# ---- Step 2: HMAC-MD5(key=password, msg=token) ----
hmd5 = hmac.new(password.encode('utf-8'), token.encode('utf-8'), hashlib.md5).hexdigest()

# ---- Step 3: Encrypted user info (XXTEA + Custom Base64) ----
info_json = json.dumps({
    'username': username,
    'password': password,
    'ip': client_ip,
    'acid': ac_id,
    'enc_ver': 'srun_bx1'
}, separators=(',', ':'))
encrypted = xxtea_encrypt(info_json, token).encode('latin-1')
info = '{SRBX1}' + custom_b64(encrypted)

# ---- Step 4: Calculate chksum = SHA1(token + ...) ----
n_val, type_val = 200, 1
chk = (token + username + token + hmd5 + token + ac_id +
       token + client_ip + token + str(n_val) +
       token + str(type_val) + token + info)
chksum = hashlib.sha1(chk.encode('utf-8')).hexdigest()

# ---- Step 5: Post Login Request ----
login_params = urllib.parse.urlencode({
    'action': 'login',
    'username': username,
    'password': '{MD5}' + hmd5,
    'os': 'Linux',
    'name': 'Linux',
    'double_stack': '0',
    'chksum': chksum,
    'info': info,
    'ac_id': ac_id,
    'ip': client_ip,
    'n': str(n_val),
    'type': str(type_val),
    'callback': cb
})
result = parse_jsonp(http_get(f"{base_url}/cgi-bin/srun_portal?{login_params}"))

err = result.get('error', '')
suc = result.get('suc_msg', '')

if err == 'ok' and suc == 'login_ok':
    print(f"\n[OK] Login successfully!")
    print(f"  User: {result.get('username', username)}")
    print(f"  IP:   {result.get('client_ip', client_ip)}")
    msg = result.get('ploy_msg', '')
    if msg:
        print(f"  Message: {msg}")
elif err == 'ok' and 'already_online' in suc:
    print(f"\n[OK] This IP is already online")
elif 'already_online' in err:
    print(f"\n[OK] This IP is already online")
else:
    print(f"\n[FAIL] Failed to login: {result.get('error_msg', err)}", file=sys.stderr)
    sys.exit(1)
PYTHON_SCRIPT

# Remove IP route
ip route del 211.*.*.* via $GATEWAY dev $INTERFACE

填写好参数后执行脚本

Route added, sleep 5s...
=== Srun Portal Login ===
Captive Protocol Server: 211.*.*.*
ac_id:      2
Username:     ***

Client IP: 10.150.196.77
Token:       ***...

[OK] Login successfully!
  User: ***
  IP:   10.150.196.77
  Message: E0000: Login is successful.

可以看到, 已经登录成功了.
接下来只需要写一份 init.d 脚本, 让 initd 在每次启动时执行一遍脚本登录就可以了. 这份脚本可以复制多份, 以对应不同的用户登录.

如果你的学校用的也是 SRun (深渊) 软件, 或许这份脚本也可以帮到你.

创建 initd 脚本

vim /etc/init.d/srun_login
chmod +x /etc/init.d/srun_login

将以下内容填入脚本

#!/bin/sh /etc/rc.common

USE_PROCD=1
START=95
STOP=15

start_service() {
    procd_open_instance
    procd_set_param command /root/srun_login/srun_login.sh
    procd_set_param respawn ${respawn_threshold:-3600} ${respawn_timeout:-5} ${respawn_retry:-5}
    procd_set_param stdout 1
    procd_set_param stderr 1
    procd_close_instance
}

stop_service() {
}

启用开机启动并启动脚本

/etc/init.d/srun_login enable
/etc/init.d/srun_login start

6. 链路聚合

博主之前已经写过一篇利用 mwan3 做链路聚合的文章. 具体可以查看 这里. 本篇文章就不再赘述.

至此, 所有配置均已完成.


总结

本人非专业人士, 如有错误或疏漏请指出. 感谢阅读!

折腾校园网: 利用 VLAN 接入 AP 汇聚交换机, 实现通过 AP 通道上网与 Web 认证自动登录
https://blog.nanami.tech/archives/405/
本文作者 Madobi Nanami
发布时间 2026-10-09
许可协议 CC BY-NC-SA 4.0
发表新评论