| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332 |
- # -*- coding: utf-8 -*-
- """
- 996传奇盒子 PC 客户端 —— 纯 HTTP 账号密码自动登录(公钥存 MySQL + 自动轮换)
- ================================================================================
- 协议来源:Fiddler 抓包 (通过996游戏盒子.exe,经过账号密码模式登录的过程的接口协议抓取的包.saz)
- ① 极验v4 load → (GeekedTest 解滑块) → verify → seccode
- ② POST /auth/v3/login/safe/byPwd (+_captcha_) → 200 + JWT token
- 签名算法(离线复算 3 个抓包会话全部匹配):
- canonical = "POST\n{path}\n{ts}\n{nonce}\npc\n{deviceId}\n5.1.1.9007\n{token}\n{UA}\n{body}\n"
- X-BOX-AUTHORIZATION = BOXAPP-SHA256 NONCE=..;TIMESTAMP=..;SIGNATURE=<hmac_sha256>
- HMAC 密钥 = BB0AAAC906FC4B7CADB116A213D46689 (逆向自 loginPreload.js SIGN_KEY)
- RSA 公钥管理(login_rsa_keys 表):
- - 正常流程:从 MySQL 读 is_active=1 的公钥 → 加密 loginInfo
- - 登录返回 20010(密文解不开/密码错的伪装码)且确认密码正确时:
- 自动从登录页 main chunk 静态提取当前公钥(hook-free,纯 HTTP)→
- 更新 MySQL(旧行 is_active=0,新行入库)→ 加密重试
- - 多用户共享:第一个发现换钥的用户自动修复,其余用户直接受益
- 用法:
- python login_996pc.py -u z1098210167 -p yang535006
- 依赖: requests pycryptodome pymysql opencv-python numpy curl_cffi (GeekedTest)
- """
- import argparse
- import base64
- import hashlib
- import hmac as hmac_mod
- import json
- import os
- import random
- import re
- import string
- import sys
- import time
- import requests as plain_requests
- from Crypto.Cipher import PKCS1_v1_5
- from Crypto.PublicKey import RSA
- import urllib3
- urllib3.disable_warnings()
- import pymysql
- # GeekedTest 求解器
- GEEKED_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)),
- "已经开发的部分代码", "chuanqi-main", "code", "GeekedTest")
- sys.path.insert(0, GEEKED_DIR)
- from geeked import Geeked # noqa: E402
- # ----------------------------------------------------------------------------
- # 协议常量(抓包 + 逆向验证所得)
- # ----------------------------------------------------------------------------
- API = "https://api.996hezi.com"
- LOGIN_PAGE = "https://newpc.996db.com/login/account"
- CAPTCHA_ID = "26e753da4a8206359763bcb9bd327eb4" # PC 登录页极验 v4 captchaId
- SIGN_KEY = "BB0AAAC906FC4B7CADB116A213D46689" # HMAC-SHA256 密钥 (loginPreload.js)
- RSA_PUB_FALLBACK = ("MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDAjCQvqNPnOZm6GVDqeOb5S2pJtbN3FMZ8"
- "vddW3B2f3jB0mf7vd1B+yjjfex2A5XVxr4YwAY/w4d1/m3v3Bn/qgAnLMKnpjeEwxQU3cU81eXAam"
- "4fMB+nlmC291quIuC0qFFL8YgT2bDjARsMRYbmKd6LTc58sKYeJD2MvkkOdKwIDAQAB")
- # ↑ 仅在 MySQL 不可用时的兜底;正常以库内 is_active=1 行为准
- APPV = "5.1.1.9007"
- UA = ("Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) "
- "996Box/5.1.1 Chrome/108.0.5359.215 Electron/22.3.4 Safari/537.36")
- DEVICE_ID = "00:15:5d:0a:04:00" # 本机 MAC(JWT 绑定此设备;与抓包一致)
- BOX_AGENT = ("device:pc;channel:;appv:5.1.1.9007;deviceOs:win32 10.0.22631 32bit;"
- "deviceId:00:15:5d:0a:04:00;deviceBrand:;deviceModel:;netType:;"
- "gameOwner:;isSimulator:false;")
- # ----------------------------------------------------------------------------
- # MySQL(与 cq2_sdk 共库;表 login_rsa_keys / users)
- # ----------------------------------------------------------------------------
- DB_CONFIG = dict(
- host=os.environ.get("CQ2_DB_HOST", "47.99.164.66"),
- port=int(os.environ.get("CQ2_DB_PORT", "3306")),
- user=os.environ.get("CQ2_DB_USER", "root"),
- password=os.environ.get("CQ2_DB_PASSWORD", "hn02le.34lkdLKD"),
- database=os.environ.get("CQ2_DB_NAME", "996cq"),
- charset="utf8mb4", autocommit=True, connect_timeout=10,
- )
- def db_connect():
- return pymysql.connect(**DB_CONFIG)
- def load_active_key() -> tuple[str, str]:
- """读 is_active=1 的公钥。返回 (key_b64, source);库空则插入 fallback 并返回。"""
- conn = db_connect()
- try:
- with conn.cursor() as cur:
- cur.execute("SELECT public_key FROM login_rsa_keys "
- "WHERE scope='login_byPwd' AND is_active=1 "
- "ORDER BY id DESC LIMIT 1")
- row = cur.fetchone()
- if row:
- return row[0], "mysql"
- # 库空 → 用 fallback 初始化
- save_key(RSA_PUB_FALLBACK, "bootstrap")
- return RSA_PUB_FALLBACK, "bootstrap"
- finally:
- conn.close()
- def save_key(key_b64: str, source: str) -> None:
- """换钥入库:旧行下线,新行激活。幂等(同钥已激活则只刷新时间)。"""
- key_b64 = key_b64.strip()
- n_hex = format(RSA.import_key(base64.b64decode(key_b64)).n, "x")
- conn = db_connect()
- try:
- with conn.cursor() as cur:
- cur.execute("SELECT id FROM login_rsa_keys WHERE scope='login_byPwd' "
- "AND is_active=1 AND public_key=%s", (key_b64,))
- if cur.fetchone():
- return
- cur.execute("UPDATE login_rsa_keys SET is_active=0 "
- "WHERE scope='login_byPwd' AND is_active=1")
- cur.execute("INSERT INTO login_rsa_keys (scope, public_key, key_n_hex, source, is_active) "
- "VALUES ('login_byPwd', %s, %s, %s, 1)", (key_b64, n_hex, source))
- finally:
- conn.close()
- def mark_key_verified(key_b64: str) -> None:
- """该钥成功登录后盖章 verified_at(best-effort,失败不致命)。"""
- try:
- conn = db_connect()
- try:
- with conn.cursor() as cur:
- cur.execute("UPDATE login_rsa_keys SET verified_at=CURRENT_TIMESTAMP "
- "WHERE scope='login_byPwd' AND is_active=1 AND public_key=%s",
- (key_b64.strip(),))
- finally:
- conn.close()
- except Exception:
- pass
- # ----------------------------------------------------------------------------
- # 公钥自动提取:登录页 main chunk 静态扫描(无需浏览器)
- # ----------------------------------------------------------------------------
- # 模块236 导出 b(登录加密)/a(新钥备用)。key 字符串内含 \n 转义和空格,需跨匹配。
- _KEY_MODULE_PAT = re.compile(
- r'n\.d\(t,"b"[\s\S]{0,800}?setPublicKey\("((?:[^"\\]|\\.)*)"')
- def fetch_key_from_login_page() -> str:
- """从登录页 JS chunk 提取当前登录公钥(模块236 export b)。
- raises RuntimeError: 提取失败(页面结构变了/网络问题)"""
- s = plain_requests.Session()
- s.verify = False
- s.headers["User-Agent"] = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/124.0.0.0 Safari/537.36"
- html = s.get(LOGIN_PAGE, timeout=20).text
- names = sorted(set(re.findall(r"/static/js/([\w.]+\.chunk\.js)", html)))
- if not names:
- raise RuntimeError("登录页未发现 chunk 引用")
- for name in names: # main chunk 优先命中(历史上模块236 在 main)
- js = s.get(f"https://newpc.996db.com/static/js/{name}", timeout=30).text
- for m in _KEY_MODULE_PAT.finditer(js):
- key = m.group(1).replace("\\n", "").replace(" ", "").strip()
- if len(key) == 216 and key.startswith("MIGfMA0GCSqGSIb3DQEBAQUAA4GNA"): # 合法的 1024-bit SPKI
- return key
- raise RuntimeError("所有 chunk 均未提取到登录公钥")
- # ----------------------------------------------------------------------------
- # 协议实现
- # ----------------------------------------------------------------------------
- def box_headers(token: str = "") -> dict:
- h = {
- "User-Agent": UA,
- "Accept": "*/*",
- "Accept-Language": "zh-CN",
- "Content-Type": "application/json;charset=UTF-8",
- "Origin": "https://newpc.996db.com",
- "Referer": "https://newpc.996db.com/",
- "Sec-Fetch-Dest": "empty",
- "Sec-Fetch-Mode": "cors",
- "Sec-Fetch-Site": "cross-site",
- "appv": APPV,
- "device": "pc",
- "deviceId": DEVICE_ID,
- "box-agent": BOX_AGENT,
- 'sec-ch-ua': '"Not?A_Brand";v="8", "Chromium";v="108"',
- 'sec-ch-ua-mobile': "?0",
- 'sec-ch-ua-platform': '"Windows"',
- }
- if token:
- h["token"] = token
- return h
- def box_auth(method: str, path: str, body: str, token: str = "") -> str:
- """X-BOX-AUTHORIZATION 签名(与抓包 137/157/176 逐字节复算一致)"""
- nonce = "".join(random.choice(string.digits + string.ascii_uppercase) for _ in range(32))
- ts = str(int(time.time() * 1000))
- canonical = "\n".join([method, path, ts, nonce, "pc", DEVICE_ID, APPV, token, UA]) \
- + "\n" + body + "\n"
- sig = hmac_mod.new(SIGN_KEY.encode(), canonical.encode(), hashlib.sha256).hexdigest()
- return f"BOXAPP-SHA256 NONCE={nonce};TIMESTAMP={ts};SIGNATURE={sig};"
- def rsa_login_info(username: str, password: str, key_b64: str) -> str:
- """loginInfo = RSA-1024 PKCS1v1.5(JSON{username,password}) hex"""
- payload = {"username": username, "password": password}
- key = RSA.import_key(base64.b64decode(key_b64))
- return PKCS1_v1_5.new(key).encrypt(json.dumps(payload, separators=(",", ":")).encode()).hex()
- def solve_captcha(tries: int = 5) -> str:
- """极验 v4 滑块 → _captcha_ 的 Base64(JSON)"""
- last = None
- for i in range(tries):
- try:
- sec = Geeked(CAPTCHA_ID, "slide").solve()
- cap = {"captchaId": sec["captcha_id"], "captchaOutput": sec["captcha_output"],
- "genTime": sec["gen_time"], "lotNumber": sec["lot_number"],
- "passToken": sec["pass_token"]}
- return base64.b64encode(json.dumps(cap, separators=(",", ":")).encode()).decode()
- except Exception as e:
- last = e
- print(f" [!] 滑块第 {i + 1}/{tries} 次失败: {str(e)[:120]}")
- time.sleep(1.5)
- raise RuntimeError(f"滑块求解失败: {last}")
- def login_by_pwd(session: plain_requests.Session, username: str, password: str,
- key_b64: str, captcha: str = None) -> dict:
- path = "/auth/v3/login/safe/byPwd"
- params = {"loginInfo": rsa_login_info(username, password, key_b64),
- "channel_id": "", "game_id": "", "inviter_uid": "", "needConfirmLogin": True}
- if captcha:
- params["_captcha_"] = captcha
- body = json.dumps(params, separators=(",", ":"))
- h = box_headers()
- h["X-BOX-AUTHORIZATION"] = box_auth("POST", path, body)
- r = session.post(API + path, data=body.encode(), headers=h, timeout=25, verify=False)
- return r.json()
- def save_user_token(username: str, token: str) -> None:
- """登录成功 → users 表 upsert(下游 c1/b2 脚本从此表读 token)。best-effort。"""
- try:
- conn = db_connect()
- try:
- with conn.cursor() as cur:
- cur.execute(
- "INSERT INTO users (username, password_hash, token, last_login_at) "
- "VALUES (%s, %s, %s, CURRENT_TIMESTAMP) "
- "ON DUPLICATE KEY UPDATE token=VALUES(token), "
- "last_login_at=CURRENT_TIMESTAMP",
- (username, "", token))
- finally:
- conn.close()
- except Exception as e:
- print(f" [!] token 入库失败(不致命): {str(e)[:80]}")
- # ----------------------------------------------------------------------------
- # 主流程
- # ----------------------------------------------------------------------------
- def do_login(s, username, password) -> tuple[dict, str]:
- """一次完整登录尝试。返回 (响应json, 使用的key)。含 32401 频控退避。"""
- key_b64, key_src = load_active_key()
- print(f"[*] 公钥来源: {key_src} (...{key_b64[95:120]}...)")
- print("[1] 极验v4 滑块求解 (GeekedTest) ...")
- captcha = solve_captcha()
- print(f" _captcha_ len={len(captcha)}")
- print("[2] POST byPwd (+_captcha_) ...")
- j = login_by_pwd(s, username, password, key_b64, captcha=captcha)
- print(f" code={j.get('code')} msg={j.get('msg', '')[:40]}")
- if j.get("code") == 32401: # 频控:等待后换新滑块重试一次
- print("[*] 触发频控,等待 90s ...")
- time.sleep(90)
- captcha = solve_captcha()
- j = login_by_pwd(s, username, password, key_b64, captcha=captcha)
- print(f" retry => code={j.get('code')} msg={j.get('msg', '')[:40]}")
- return j, key_b64
- def main():
- ap = argparse.ArgumentParser()
- ap.add_argument("-u", "--user", default="longwu988")
- ap.add_argument("-p", "--password", default="Longwu1988")
- args = ap.parse_args()
- s = plain_requests.Session()
- print(f"[*] 账号: {args.user}")
- j, key_b64 = do_login(s, args.user, args.password)
- # ---- 公钥轮换自愈:20010 且页面钥 ≠ 库内钥 → 换钥重试 ----
- if j.get("code") == 20010:
- print("[!] 20010:疑似公钥轮换,从登录页重新提取 ...")
- try:
- fresh = fetch_key_from_login_page()
- except RuntimeError as e:
- print(f" 提取失败: {e}")
- fresh = None
- if fresh and fresh != key_b64:
- print(f" 发现新公钥 (...{fresh[95:120]}...),入库并重试")
- save_key(fresh, "login_page_hook")
- j, key_b64 = do_login(s, args.user, args.password)
- elif fresh == key_b64:
- print(" 登录页公钥与库内一致 → 20010 是真实密码错误(或账号风控)")
- # fresh=None 时保持原响应
- if j.get("code") == 200 and (j.get("data") or {}).get("token"):
- data = j["data"]
- token = data["token"]
- info = data.get("userInfo", {})
- mark_key_verified(key_b64)
- print("\n========== 登录成功 ==========")
- print(f" 账号 : {info.get('account')}")
- print(f" 用户ID : {info.get('id')}")
- print(f" 昵称 : {info.get('nick_name')}")
- print(f" token : {token[:60]}...")
- try:
- payload = json.loads(base64.urlsafe_b64decode(token.split(".")[1] + "=="))
- print(f" token有效期至: {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(payload['exp']))}")
- except Exception:
- pass
- out = os.path.join(os.path.dirname(os.path.abspath(__file__)), "token_pc.txt")
- with open(out, "w", encoding="utf-8") as f:
- f.write(token)
- print(f" 已保存: {out}")
- save_user_token(args.user, token) # 共享给下游脚本
- return 0
- return 1
- if __name__ == "__main__":
- sys.exit(main())
|