校园网giWiFi解决方案

校园网终极折腾指南

核心目标
本指南旨在彻底解决校园网的诸多痛点,实现以下四大终极目标:

  1. 突破设备限制:绕过 3 台设备限制,并防止深信服 DPI 检测封号。
  2. 防 DNS 劫持:防止网关透明劫持 53 端口,保护隐私。
  3. 全局无感去广告:在解析阶段直接屏蔽广告域名。
  4. 全自动断线重连:彻底告别每天手动点击认证弹窗的烦恼。

一、网络架构与检测原理

1. 整体架构

采用 OpenWrt(主路由)+ AdGuard Home(旁路 DNS) 的方案,常以 DietPi/树莓派等独立环境作为旁路设备:

  • NAT 伪装:所有设备连接 OpenWrt,校园网计费系统仅可见路由器 WAN 口的单一 MAC/IP 地址。
  • DoH 加密:拦截局域网 53 端口(UDP)请求,由 AdGuard Home 封装为 HTTPS 流量(443 端口)转发,绕过校园网网关的 DPI 检测与 DNS 劫持。
  • 黑名单阻断:利用本地规则在解析阶段直接屏蔽广告域名,实现全网无感去广告。
  • 绿灯通道:放行系统探测域名与认证域名走明文,保证认证页面正常弹出。

2. 多设备检测原理

校园网主要通过 DPI(深度包检测) 分析不同设备流量的特征来判断是否存在多台设备共享上网。常见检测特征包括:

  • 不同的 TTL 值(不同操作系统初始 TTL 不同,且每过一个路由器递减 1)
  • 不同的 NTP 时间服务器请求(Windows / macOS / Android 默认对时服务器不同)
  • 不同的 HTTP User-Agent(暴露浏览器与设备厂商信息)
  • IPID 递增规律(不同设备 IP 标识符递增模式不同)

因此,需要在路由器层面将这些特征统一,把多台设备伪装成单一设备。


二、OpenWrt 预防多设备检测

版本说明
从 OpenWrt 22.03 开始,防火墙已升级为 fw4(nftables),旧版的 iptables 命令已失效。以下配置针对基于 fw4 的新版固件(如 Kwrt 24.10)。为兼容部分缺失特定内核模块的固件,建议使用自定义链。

1. 统一 TTL 值

通过 SSH 登录 OpenWrt 终端执行以下命令,写入规则并重启防火墙:

# 写入自定义的 nftables TTL 修改规则
cat << 'EOF' > /etc/nftables.d/12-mangle-ttl-128.nft
chain custom_ttl_set {
    type filter hook postrouting priority 300; policy accept;
    oifname "wan" ip ttl set 128
    oifname "wan" ip6 hoplimit set 128
    oifname "pppoe-wan" ip ttl set 128
    oifname "pppoe-wan" ip6 hoplimit set 128
}
EOF

# 重启防火墙使规则生效
service firewall restart

验证命令(可选): nft list ruleset | grep -A 6 "custom_ttl_set"

2. 劫持 NTP 请求

不同设备(Windows/Mac/Android)默认的 NTP 时间服务器不同。理论上应让路由器统一接管局域网内的对时请求,让所有设备的 NTP 包都路由到同一台上游,消除"Windows 找 time.windows.com、macOS 找 time.apple.com、Android 找 pool.ntp.org"这类指纹差异。

配置位置说明
该选项不在 网络 -> DHCP/DNS -> 常规设置 里(那里的"截获 NTP 请求 (Intercept NTP)"已在较新版本中调整或并入此处)。正确位置是 系统 -> 时间同步 选项卡,对应的开关叫 "作为 NTP 服务器提供服务"(对照下方截图)。

操作步骤(网页后台):

  1. 导航至 系统 -> 时间同步。
  2. 切换到 时间同步 选项卡。
  3. 勾选 作为 NTP 服务器提供服务。
  4. (可选)确认 候选 NTP 服务器 至少包含 ntp.aliyun.com 或 0.openwrt.pool.ntp.org 等可达节点。
  5. 点击 保存并应用。

截图示意:

ntp-time-sync

个人实测:未勾选"作为 NTP 服务器提供服务"也可行
我当时实际配置中并未勾选这一项,UA2F + 统一 TTL 仍能稳定防住多设备检测,原因有两点:

  • 路由器自身时间已准确:只要"启用 NTP 客户端"勾选、候选 NTP 服务器可达,路由器本机时间就是对的;UA2F 与 TTL 规则只依赖本机时间,不依赖局域网内其他设备是否走这台对时。
  • 校园网 DPI 权重低:实测中 giWiFi 等校园网对"不同 NTP 服务器"差异的检测权重较低,主要指纹仍是 TTL 与 HTTP User-Agent,所以即便不统一局域网对时源,配合 §1(TTL)与 §3(UA2F)通常已足够。

因此该项建议勾选以做最完整的防御,但不是强制项——把它当作"加分项"而非"必做项"。

3. 统一 User-Agent(UA2F)

明文 HTTP 流量会暴露不同设备的浏览器 UA。使用 UA2F(User-Agent to Firefox)插件统一修改未加密 HTTP 流量的特征。

  1. 导航至 服务 -> UA2F。
  2. 勾选 启用、自动设置防火墙规则、处理来自内网的 HTTP 流量。
  3. 点击 保存并应用。
  4. 使用底部的 检查 User-Agent 测试服务器端 UA 是否已统一。

前置条件与故障排查
必须关闭流量卸载:在 网络 -> 防火墙 -> 常规设置 中将 路由/NAT 卸载 设为 禁用。流量卸载会绕过 CPU 与防火墙,导致 UA2F 无法抓包。

代理软件冲突:Open*****、PassWall 等代理工具可能劫持 80/443 端口流量,导致 UA2F 拿不到数据。配置与测试时建议先暂时关闭代理,或在代理规则中排除校园网检测流量。

4. 进阶防御(按需配置)

若完成上述步骤后仍被检测,可叠加以下方案:

  • 修改 IPID 防检测:编译固件时加入 kmod-rkp-ipid 模块,修改 IPID 递增规律。
  • 开启 DNS 加密:使用 https-dns-proxy 或 SmartDNS 等插件,将上游 DNS 设为 DoH/DoT 加密服务器,防止 DNS 请求暴露设备厂商特征(如苹果 mDNS 请求、小米服务器请求)。

三、部署 AdGuard Home 旁路 DNS

利用独立环境(如 DietPi/树莓派)部署旁路 DNS,把局域网 DNS 请求加密封装为 HTTPS(DoH)流量,绕过校园网对 53 端口的劫持。

1. 容器化部署

使用 host 网络模式确保无缝接管局域网端口,并建立数据持久化:

mkdir -p ~/docker-files/adguard-data/work
mkdir -p ~/docker-files/adguard-data/conf

docker run -d \
    --name adguardhome \
    --restart unless-stopped \
    --network host \
    -v ~/docker-files/adguard-data/work:/opt/adguardhome/work \
    -v ~/docker-files/adguard-data/conf:/opt/adguardhome/conf \
    adguard/adguardhome

2. DNS 上游与"绿灯通道"配置(关键)

进入面板(http://192.168.1.133)-> 设置 -> DNS 设置:

  • 上游 DNS 服务器(开启并行请求):

    https://dns.alidns.com/dns-query
    https://doh.pub/dns-query
    [/wifiopenapiauth.com/msftconnecttest.com/msftncsi.com/captive.apple.com/connectivitycheck.gstatic.com/connect.rom.miui.com/]114.114.114.114
    

    绿灯通道解析
    最后一行极其重要!它能让系统网络探测域名和 giWiFi 认证域名走明文、主动被校园网劫持,从而保证认证页面能正常弹出,避免出现"安全死锁"(即 DoH 加密后认证弹窗无法跳出)。

  • Bootstrap DNS(用于解析上游节点):

    223.5.5.5
    119.29.29.29
    114.114.114.114
    

3. 拦截规则配置

进入 过滤器 -> DNS 黑名单,启用精简规则以防误杀:

  • CHN: anti-AD(国内去广告主力)
  • AdGuard DNS filter(基础兜底)
  • OISD Blocklist Big(大白名单无感过滤)

强屏蔽环境应对策略
若校园网强屏蔽导致 GitHub 规则无法拉取,可利用国内加速镜像替换规则 URL;或直接在宿主机新建规则文件(路径如 ~/docker-files/adguard-data/work/my_local_filter.txt),并在面板"自定义列表"中填入容器内绝对路径:/opt/adguardhome/work/my_local_filter.txt。


四、OpenWrt 强制引流与防泄漏

必须切断操作系统的"并发查询"机制,确保解析请求 100% 经过 AdGuard Home:

  1. 修改 DHCP 下发配置:

    • 路径:网络 -> 接口 -> LAN -> DHCP 服务器 -> 高级设置。
    • 设置 DHCP 选项 为:6,192.168.1.133。

    致命禁忌
    绝对不能添加备用 DNS(如 ,192.168.1.1),否则会导致操作系统并发查询,引发 DNS 泄露和广告漏网,使防劫持彻底失效。必须容忍单点故障风险以换取绝对安全。

  2. 隔绝上级污染:

    • 路径:网络 -> DHCP/DNS -> 常规设置。
    • 勾选 忽略解析文件(Ignore resolve file)。

配置完成后,局域网设备需断开 WiFi 重新连接,以获取新的 DNS 参数。


五、全自动断线无感重连

利用计划任务(Crontab)每分钟检测网络,一旦发现被校园网踢下线,自动发送认证请求完成无感重连。本节记录的是本人实战踩坑后的最终可行方案:先用"抓包 + 静态脚本"的朴素思路,结果因为 giWiFi 的凭证动态过期而失败;最终改用 Python 动态加密脚本,从登录页实时提取动态密钥、再加密账号密码发起认证,验证成功。

0. 为什么"抓包 + 静态脚本"会失败?(关键教训)

最直觉的方案是:浏览器抓一次登录时的 authLogin POST 请求,把整段 cURL 塞进一个每分钟运行的 Shell 脚本里。这个方案在 giWiFi 上是行不通的,原因有两个:

  1. 凭证是"动态且一次性"的:giWiFi 的登录请求里有两个会变的东西——PHPSESSID(会话 Cookie)和加密载荷里的 iv(初始向量)。它们会随会话刷新甚至每次登录都不同。把抓包那一刻的旧凭证写死在脚本里,一旦过期,服务器直接拒绝。
  2. 过期请求会被判定为重放攻击:断网时脚本照旧把那段已过期的旧凭证发给网关,防火墙不仅拒绝登录,还可能认定你在恶意重放,直接把路由器的 MAC 拉黑——原本几秒钟就能自愈的网络波动,反而被脚本弄成了真正的"死线"(尤其在有 NAS 长期挂机的场景下是灾难)。

结论
不要用"抓一次包 → 写死 cURL"的静态脚本做 giWiFi 自动重连。要做,就必须让脚本每次都能动态拿到当次的加密密钥,也就是下面的 Python 方案。

1. 最终方案:Python 动态加密脚本(部署在旁路设备,主要是本人路由器性能不够)

giWiFi 的前端用 JavaScript + 一次性密钥对账号密码做 AES-CBC 加密,所以纯 curl/Shell 无法模拟。必须在路由器或旁路设备(树莓派/DietPi)上跑 Python,复刻浏览器的完整流程:

  1. 请求登录页 → 正则提取当次的 iv、sign 等隐藏参数;
  2. 用固定 AES 密钥 + 动态 iv 对账号密码表单做加密;
  3. 发起 authLogin POST 认证。

依赖安装(以 DietPi 为例):

sudo apt update
sudo apt install -y python3 python3-pip
pip3 install requests pyaes

为什么用 pyaes 而不是 pycryptodome
giWiFi 前端用的是纯 JavaScript 的 AES 实现(密钥固定为 1234567887654321,CBC 模式、Zero Padding)。pyaes 是纯 Python 实现、无需编译、依赖更干净,在树莓派/DietPi 上更省事,且能精确复刻前端 zero_pad(补 \x00 而非 PKCS#7)的行为。

脚本模板(/root/giwifi_auto.py,把 USERNAME/PASSWORD 换成你自己的):

#!/usr/bin/env python3
import requests, urllib.parse, re, time, base64, pyaes

USERNAME   = "你的手机号"
PASSWORD   = "你的校园网密码"
LOGIN_PAGE = "http://10.189.1.3/gportal/web/login?wlanacname=LITBAS1"
AUTH_URL   = "http://10.189.1.3/gportal/web/authLogin"
AES_KEY    = "1234567887654321"          # giWiFi 前端固定常量,可能需要改,未作验证

def zero_pad(data_bytes):
    pad_len = 16 - (len(data_bytes) % 16)
    if pad_len != 16:
        return data_bytes + b'\x00' * pad_len
    return data_bytes

def encrypt_data(raw_text, key, iv):
    padded = zero_pad(raw_text.encode('utf-8'))
    aes = pyaes.AESModeOfOperationCBC(key.encode('utf-8'), iv=iv.encode('utf-8'))
    enc = b"".join(aes.encrypt(padded[i:i+16]) for i in range(0, len(padded), 16))
    return base64.b64encode(enc).decode('utf-8')

def check_network():
    try:
        res = requests.get("http://www.msftconnecttest.com/connecttest.txt", timeout=3)
        return "Microsoft Connect Test" in res.text
    except Exception:
        return False

def login():
    s = requests.Session()
    s.headers.update({
        "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
        "X-Requested-With": "XMLHttpRequest"
    })
    try:
        res = s.get(LOGIN_PAGE, timeout=5)
        html = res.text
        def get_val(name):
            m = re.search(r'name=["\']' + name + r'["\'][^>]*value=["\']([^"\']*)["\']', html)
            return m.group(1) if m else ""
        iv = get_val("iv") or "532c12b6b081904d"
        sign = get_val("sign")
        user_ip = get_val("userIp") or "10.50.254.236"
        form_dict = {
            "nasName": get_val("nasName") or "LITBAS1",
            "nasIp": get_val("nasIp"),
            "userIp": user_ip, "userMac": "", "ssid": "", "apMac": "",
            "pid": get_val("pid") or "23", "vlan": "", "sign": sign, "iv": iv,
            "redirectUrl": "http://1.2.3.4",
            "portalTemplateId": get_val("portalTemplateId") or "23",
            "account_type": "",
            "name": USERNAME, "password": PASSWORD,
        }
        raw_query = urllib.parse.urlencode(form_dict)
        enc_data = encrypt_data(raw_query, AES_KEY, iv)
        payload = {"data": enc_data, "iv": iv}
        round_num = int(time.time() * 1000) % 1000
        r = s.post(f"{AUTH_URL}?round={round_num}", data=payload, timeout=5)
        print(f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] 响应: {r.text}")
    except Exception as e:
        print(f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] 出错: {e}")

if __name__ == "__main__":
    if check_network():
        print("网络正常,无需登录。")
    else:
        print("检测到断网,执行认证...")
        login()

实测验证
手动执行 python3 /root/giwifi_auto.py,终端返回 "info":"认证成功,稍后跳转","status":1(即网关认证通过、外网恢复),证明"动态取值 + AES 加密"这条路完全走通。

2. 设置计划任务

把脚本挂到 Crontab,每分钟检测一次、断网自愈。用下面的命令安全追加(不覆盖已有任务):

(crontab -l 2>/dev/null; echo "* * * * * /usr/bin/python3 /root/giwifi_auto.py > /dev/null 2>&1") | crontab -

用 crontab -l 验证末尾已出现该行即可。此后无论何时掉线,树莓派/旁路设备都会在一分钟内完成动态取值、加密并触发重连。

断网检测为什么用 msftconnecttest.com 而不是 ping
校园网网关普遍劫持 ICMP,ping 经常"假通"或"假断",误判率高。改用 HTTP 请求微软的连通性测试页(http://www.msftconnecttest.com/connecttest.txt,正常返回字符串 Microsoft Connect Test),既能准确反映"能否真正访问外网",又不会被网关劫持成认证页而误判。

3. 失效预警:什么情况下这套脚本还会挂

这套动态脚本抗衰减能力很强(sign/iv 每次实时提取,不会自然过期),但以下三种外部变动仍可能导致失效:

  • 学校 Portal 改版:若前端把固定 AES_KEY 换掉,或升级为动态 RSA 协商,旧密钥会解密失败 → 需重新抓包分析新算法。
  • 新增校验字段:若系统加了图形验证码、滑块、强制 MAC/设备指纹绑定等,当前字段组合会过不了后端校验。
  • 业务层到期:账号欠费/被封禁,或改密码后未同步 PASSWORD。

出问题怎么快速定位
断网且未自愈时,手动执行 python3 /root/giwifi_auto.py,观察终端 响应: {...} 里的 status 与 info(如"密钥无效""验证码缺失""账号欠费"),几秒内即可锁定原因。


六、验证与排障指南

1. 验证防劫持

在电脑终端执行:

nslookup baidu.com 192.168.1.133
  • 穿透成功:Server 返回 DietPi.lan 或正确节点 IP。
  • 遭遇劫持:Server 显示 wifiopenapiauth.com 等认证页特征,需重新检查上述配置。

2. 10 秒宕机自救法

若 AdGuard Home 意外崩溃导致全网无法解析(表现为微信正常、网页打不开):

  • 登录 OpenWrt 后台,删除 DHCP 选项中的 6,192.168.1.133,保存并应用。
  • 手机等设备重连 WiFi 即可恢复基础网络;待修复容器后,再恢复该 DHCP 设置。

3. 常见故障排查汇总

现象 可能原因 处理建议
UA2F 抓不到包、UA 未统一 路由/NAT 卸载未关闭 网络 -> 防火墙 -> 常规设置 禁用卸载
UA2F 无数据 代理软件劫持 80/443 临时关闭 Open*****/PassWall 再测试
仍被多设备检测 缺少 IPID / DNS 加密防御 叠加"进阶防御"方案
规则无法拉取 校园网屏蔽 GitHub 用国内镜像或本地 .txt 规则文件
认证弹窗无法弹出 DoH 导致"安全死锁" 确认已配置绿灯通道域名分流
网页打不开但微信正常 AdGuard Home 宕机 删除 DHCP 选项 6,... 临时恢复
自动重连脚本失效 Portal 改版 / 凭证或密钥变动 手动跑 python3 /root/giwifi_auto.py 看 status/info 定位

参考资料


相关笔记

posted @ 2026-09-06 18:35  Z_AddOne  阅读(193)  评论(0)    收藏  举报