login_996pc.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339
  1. # -*- coding: utf-8 -*-
  2. """
  3. 996传奇盒子 PC 客户端 —— 纯 HTTP 账号密码自动登录(公钥存 MySQL + 自动轮换)
  4. ================================================================================
  5. 协议来源:Fiddler 抓包 (通过996游戏盒子.exe,经过账号密码模式登录的过程的接口协议抓取的包.saz)
  6. ① 极验v4 load → (GeekedTest 解滑块) → verify → seccode
  7. ② POST /auth/v3/login/safe/byPwd (+_captcha_) → 200 + JWT token
  8. 签名算法(离线复算 3 个抓包会话全部匹配):
  9. canonical = "POST\n{path}\n{ts}\n{nonce}\npc\n{deviceId}\n5.1.1.9007\n{token}\n{UA}\n{body}\n"
  10. X-BOX-AUTHORIZATION = BOXAPP-SHA256 NONCE=..;TIMESTAMP=..;SIGNATURE=<hmac_sha256>
  11. HMAC 密钥 = BB0AAAC906FC4B7CADB116A213D46689 (逆向自 loginPreload.js SIGN_KEY)
  12. RSA 公钥管理(login_rsa_keys 表):
  13. - 正常流程:从 MySQL 读 is_active=1 的公钥 → 加密 loginInfo
  14. - 登录返回 20010(密文解不开/密码错的伪装码)且确认密码正确时:
  15. 自动从登录页 main chunk 静态提取当前公钥(hook-free,纯 HTTP)→
  16. 更新 MySQL(旧行 is_active=0,新行入库)→ 加密重试
  17. - 多用户共享:第一个发现换钥的用户自动修复,其余用户直接受益
  18. 用法:
  19. python login_996pc.py -u z1098210167 -p yang535006
  20. 依赖: requests pycryptodome pymysql opencv-python numpy curl_cffi (GeekedTest)
  21. """
  22. import argparse
  23. import base64
  24. import hashlib
  25. import hmac as hmac_mod
  26. import json
  27. import os
  28. import random
  29. import re
  30. import string
  31. import sys
  32. import time
  33. import requests as plain_requests
  34. from Crypto.Cipher import PKCS1_v1_5
  35. from Crypto.PublicKey import RSA
  36. import urllib3
  37. urllib3.disable_warnings()
  38. import pymysql
  39. # GeekedTest 求解器
  40. GEEKED_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "vendor")
  41. sys.path.insert(0, GEEKED_DIR)
  42. from geeked import Geeked # noqa: E402
  43. # ----------------------------------------------------------------------------
  44. # 协议常量(抓包 + 逆向验证所得)
  45. # ----------------------------------------------------------------------------
  46. API = "https://api.996hezi.com"
  47. LOGIN_PAGE = "https://newpc.996db.com/login/account"
  48. CAPTCHA_ID = "26e753da4a8206359763bcb9bd327eb4" # PC 登录页极验 v4 captchaId
  49. SIGN_KEY = "BB0AAAC906FC4B7CADB116A213D46689" # HMAC-SHA256 密钥 (loginPreload.js)
  50. RSA_PUB_FALLBACK = ("MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDAjCQvqNPnOZm6GVDqeOb5S2pJtbN3FMZ8"
  51. "vddW3B2f3jB0mf7vd1B+yjjfex2A5XVxr4YwAY/w4d1/m3v3Bn/qgAnLMKnpjeEwxQU3cU81eXAam"
  52. "4fMB+nlmC291quIuC0qFFL8YgT2bDjARsMRYbmKd6LTc58sKYeJD2MvkkOdKwIDAQAB")
  53. # ↑ 仅在 MySQL 不可用时的兜底;正常以库内 is_active=1 行为准
  54. APPV = "5.1.1.9007"
  55. UA = ("Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) "
  56. "996Box/5.1.1 Chrome/108.0.5359.215 Electron/22.3.4 Safari/537.36")
  57. DEVICE_ID = "00:15:5d:0a:04:00" # 本机 MAC(JWT 绑定此设备;与抓包一致)
  58. BOX_AGENT = ("device:pc;channel:;appv:5.1.1.9007;deviceOs:win32 10.0.22631 32bit;"
  59. "deviceId:00:15:5d:0a:04:00;deviceBrand:;deviceModel:;netType:;"
  60. "gameOwner:;isSimulator:false;")
  61. # ----------------------------------------------------------------------------
  62. # MySQL(与 cq2_sdk 共库;表 login_rsa_keys / users)
  63. # ----------------------------------------------------------------------------
  64. DB_CONFIG = dict(
  65. host=os.environ.get("CQ2_DB_HOST", "47.99.164.66"),
  66. port=int(os.environ.get("CQ2_DB_PORT", "3306")),
  67. user=os.environ.get("CQ2_DB_USER", "root"),
  68. password=os.environ.get("CQ2_DB_PASSWORD", "nsyOfyOyoFCr%Brj-k_5PqbW"),
  69. database=os.environ.get("CQ2_DB_NAME", "996cq"),
  70. charset="utf8mb4", autocommit=True, connect_timeout=10,
  71. )
  72. def db_connect():
  73. return pymysql.connect(**DB_CONFIG)
  74. def load_active_key() -> tuple[str, str]:
  75. """读 is_active=1 的公钥。返回 (key_b64, source);库空则插入 fallback 并返回。
  76. MySQL 不可用(凭据失效/断网)→ 校验登录页公钥与 fallback 一致后降级返回。"""
  77. try:
  78. conn = db_connect()
  79. except Exception as e:
  80. print(f" [!] MySQL 不可用({str(e)[:60]}) → 登录页公钥校验降级")
  81. fresh = fetch_key_from_login_page()
  82. if fresh == RSA_PUB_FALLBACK:
  83. return RSA_PUB_FALLBACK, "fallback(db down, login_page verified)"
  84. return fresh, "login_page(db down)"
  85. try:
  86. with conn.cursor() as cur:
  87. cur.execute("SELECT public_key FROM login_rsa_keys "
  88. "WHERE scope='login_byPwd' AND is_active=1 "
  89. "ORDER BY id DESC LIMIT 1")
  90. row = cur.fetchone()
  91. if row:
  92. return row[0], "mysql"
  93. # 库空 → 用 fallback 初始化
  94. save_key(RSA_PUB_FALLBACK, "bootstrap")
  95. return RSA_PUB_FALLBACK, "bootstrap"
  96. finally:
  97. conn.close()
  98. def save_key(key_b64: str, source: str) -> None:
  99. """换钥入库:旧行下线,新行激活。幂等(同钥已激活则只刷新时间)。"""
  100. key_b64 = key_b64.strip()
  101. n_hex = format(RSA.import_key(base64.b64decode(key_b64)).n, "x")
  102. conn = db_connect()
  103. try:
  104. with conn.cursor() as cur:
  105. cur.execute("SELECT id FROM login_rsa_keys WHERE scope='login_byPwd' "
  106. "AND is_active=1 AND public_key=%s", (key_b64,))
  107. if cur.fetchone():
  108. return
  109. cur.execute("UPDATE login_rsa_keys SET is_active=0 "
  110. "WHERE scope='login_byPwd' AND is_active=1")
  111. cur.execute("INSERT INTO login_rsa_keys (scope, public_key, key_n_hex, source, is_active) "
  112. "VALUES ('login_byPwd', %s, %s, %s, 1)", (key_b64, n_hex, source))
  113. finally:
  114. conn.close()
  115. def mark_key_verified(key_b64: str) -> None:
  116. """该钥成功登录后盖章 verified_at(best-effort,失败不致命)。"""
  117. try:
  118. conn = db_connect()
  119. try:
  120. with conn.cursor() as cur:
  121. cur.execute("UPDATE login_rsa_keys SET verified_at=CURRENT_TIMESTAMP "
  122. "WHERE scope='login_byPwd' AND is_active=1 AND public_key=%s",
  123. (key_b64.strip(),))
  124. finally:
  125. conn.close()
  126. except Exception:
  127. pass
  128. # ----------------------------------------------------------------------------
  129. # 公钥自动提取:登录页 main chunk 静态扫描(无需浏览器)
  130. # ----------------------------------------------------------------------------
  131. # 模块236 导出 b(登录加密)/a(新钥备用)。key 字符串内含 \n 转义和空格,需跨匹配。
  132. _KEY_MODULE_PAT = re.compile(
  133. r'n\.d\(t,"b"[\s\S]{0,800}?setPublicKey\("((?:[^"\\]|\\.)*)"')
  134. def fetch_key_from_login_page() -> str:
  135. """从登录页 JS chunk 提取当前登录公钥(模块236 export b)。
  136. raises RuntimeError: 提取失败(页面结构变了/网络问题)"""
  137. s = plain_requests.Session(); s.trust_env = False # 绕过macOS系统代理(Clash挂起geetest)
  138. s.verify = False
  139. s.headers["User-Agent"] = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/124.0.0.0 Safari/537.36"
  140. html = s.get(LOGIN_PAGE, timeout=20).text
  141. names = sorted(set(re.findall(r"/static/js/([\w.]+\.chunk\.js)", html)))
  142. if not names:
  143. raise RuntimeError("登录页未发现 chunk 引用")
  144. for name in names: # main chunk 优先命中(历史上模块236 在 main)
  145. js = s.get(f"https://newpc.996db.com/static/js/{name}", timeout=30).text
  146. for m in _KEY_MODULE_PAT.finditer(js):
  147. key = m.group(1).replace("\\n", "").replace(" ", "").strip()
  148. if len(key) == 216 and key.startswith("MIGfMA0GCSqGSIb3DQEBAQUAA4GNA"): # 合法的 1024-bit SPKI
  149. return key
  150. raise RuntimeError("所有 chunk 均未提取到登录公钥")
  151. # ----------------------------------------------------------------------------
  152. # 协议实现
  153. # ----------------------------------------------------------------------------
  154. def box_headers(token: str = "") -> dict:
  155. h = {
  156. "User-Agent": UA,
  157. "Accept": "*/*",
  158. "Accept-Language": "zh-CN",
  159. "Content-Type": "application/json;charset=UTF-8",
  160. "Origin": "https://newpc.996db.com",
  161. "Referer": "https://newpc.996db.com/",
  162. "Sec-Fetch-Dest": "empty",
  163. "Sec-Fetch-Mode": "cors",
  164. "Sec-Fetch-Site": "cross-site",
  165. "appv": APPV,
  166. "device": "pc",
  167. "deviceId": DEVICE_ID,
  168. "box-agent": BOX_AGENT,
  169. 'sec-ch-ua': '"Not?A_Brand";v="8", "Chromium";v="108"',
  170. 'sec-ch-ua-mobile': "?0",
  171. 'sec-ch-ua-platform': '"Windows"',
  172. }
  173. if token:
  174. h["token"] = token
  175. return h
  176. def box_auth(method: str, path: str, body: str, token: str = "") -> str:
  177. """X-BOX-AUTHORIZATION 签名(与抓包 137/157/176 逐字节复算一致)"""
  178. nonce = "".join(random.choice(string.digits + string.ascii_uppercase) for _ in range(32))
  179. ts = str(int(time.time() * 1000))
  180. canonical = "\n".join([method, path, ts, nonce, "pc", DEVICE_ID, APPV, token, UA]) \
  181. + "\n" + body + "\n"
  182. sig = hmac_mod.new(SIGN_KEY.encode(), canonical.encode(), hashlib.sha256).hexdigest()
  183. return f"BOXAPP-SHA256 NONCE={nonce};TIMESTAMP={ts};SIGNATURE={sig};"
  184. def rsa_login_info(username: str, password: str, key_b64: str) -> str:
  185. """loginInfo = RSA-1024 PKCS1v1.5(JSON{username,password}) hex"""
  186. payload = {"username": username, "password": password}
  187. key = RSA.import_key(base64.b64decode(key_b64))
  188. return PKCS1_v1_5.new(key).encrypt(json.dumps(payload, separators=(",", ":")).encode()).hex()
  189. def solve_captcha(tries: int = 5) -> str:
  190. """极验 v4 滑块 → _captcha_ 的 Base64(JSON)"""
  191. last = None
  192. for i in range(tries):
  193. try:
  194. sec = Geeked(CAPTCHA_ID, "slide").solve()
  195. cap = {"captchaId": sec["captcha_id"], "captchaOutput": sec["captcha_output"],
  196. "genTime": sec["gen_time"], "lotNumber": sec["lot_number"],
  197. "passToken": sec["pass_token"]}
  198. return base64.b64encode(json.dumps(cap, separators=(",", ":")).encode()).decode()
  199. except Exception as e:
  200. last = e
  201. print(f" [!] 滑块第 {i + 1}/{tries} 次失败: {str(e)[:120]}")
  202. time.sleep(1.5)
  203. raise RuntimeError(f"滑块求解失败: {last}")
  204. def login_by_pwd(session: plain_requests.Session, username: str, password: str,
  205. key_b64: str, captcha: str = None) -> dict:
  206. path = "/auth/v3/login/safe/byPwd"
  207. params = {"loginInfo": rsa_login_info(username, password, key_b64),
  208. "channel_id": "", "game_id": "", "inviter_uid": "", "needConfirmLogin": True}
  209. if captcha:
  210. params["_captcha_"] = captcha
  211. body = json.dumps(params, separators=(",", ":"))
  212. h = box_headers()
  213. h["X-BOX-AUTHORIZATION"] = box_auth("POST", path, body)
  214. r = session.post(API + path, data=body.encode(), headers=h, timeout=25, verify=False)
  215. return r.json()
  216. def save_user_token(username: str, token: str) -> None:
  217. """登录成功 → users 表 upsert(下游 c1/b2 脚本从此表读 token)。best-effort。"""
  218. try:
  219. conn = db_connect()
  220. try:
  221. with conn.cursor() as cur:
  222. cur.execute(
  223. "INSERT INTO users (username, password_hash, token, last_login_at) "
  224. "VALUES (%s, %s, %s, CURRENT_TIMESTAMP) "
  225. "ON DUPLICATE KEY UPDATE token=VALUES(token), "
  226. "last_login_at=CURRENT_TIMESTAMP",
  227. (username, "", token))
  228. finally:
  229. conn.close()
  230. except Exception as e:
  231. print(f" [!] token 入库失败(不致命): {str(e)[:80]}")
  232. # ----------------------------------------------------------------------------
  233. # 主流程
  234. # ----------------------------------------------------------------------------
  235. def do_login(s, username, password) -> tuple[dict, str]:
  236. """一次完整登录尝试。返回 (响应json, 使用的key)。含 32401 频控退避。"""
  237. key_b64, key_src = load_active_key()
  238. print(f"[*] 公钥来源: {key_src} (...{key_b64[95:120]}...)")
  239. print("[1] 极验v4 滑块求解 (GeekedTest) ...")
  240. captcha = solve_captcha()
  241. print(f" _captcha_ len={len(captcha)}")
  242. print("[2] POST byPwd (+_captcha_) ...")
  243. j = login_by_pwd(s, username, password, key_b64, captcha=captcha)
  244. print(f" code={j.get('code')} msg={j.get('msg', '')[:40]}")
  245. if j.get("code") == 32401: # 频控:等待后换新滑块重试一次
  246. print("[*] 触发频控,等待 90s ...")
  247. time.sleep(90)
  248. captcha = solve_captcha()
  249. j = login_by_pwd(s, username, password, key_b64, captcha=captcha)
  250. print(f" retry => code={j.get('code')} msg={j.get('msg', '')[:40]}")
  251. return j, key_b64
  252. def main():
  253. ap = argparse.ArgumentParser()
  254. ap.add_argument("-u", "--user", default="longwu988")
  255. ap.add_argument("-p", "--password", default="Longwu1988")
  256. args = ap.parse_args()
  257. s = plain_requests.Session(); s.trust_env = False # 绕过macOS系统代理(Clash挂起geetest)
  258. print(f"[*] 账号: {args.user}")
  259. j, key_b64 = do_login(s, args.user, args.password)
  260. # ---- 公钥轮换自愈:20010 且页面钥 ≠ 库内钥 → 换钥重试 ----
  261. if j.get("code") == 20010:
  262. print("[!] 20010:疑似公钥轮换,从登录页重新提取 ...")
  263. try:
  264. fresh = fetch_key_from_login_page()
  265. except RuntimeError as e:
  266. print(f" 提取失败: {e}")
  267. fresh = None
  268. if fresh and fresh != key_b64:
  269. print(f" 发现新公钥 (...{fresh[95:120]}...),入库并重试")
  270. save_key(fresh, "login_page_hook")
  271. j, key_b64 = do_login(s, args.user, args.password)
  272. elif fresh == key_b64:
  273. print(" 登录页公钥与库内一致 → 20010 是真实密码错误(或账号风控)")
  274. # fresh=None 时保持原响应
  275. if j.get("code") == 200 and (j.get("data") or {}).get("token"):
  276. data = j["data"]
  277. token = data["token"]
  278. info = data.get("userInfo", {})
  279. mark_key_verified(key_b64)
  280. print("\n========== 登录成功 ==========")
  281. print(f" 账号 : {info.get('account')}")
  282. print(f" 用户ID : {info.get('id')}")
  283. print(f" 昵称 : {info.get('nick_name')}")
  284. print(f" token : {token[:60]}...")
  285. try:
  286. payload = json.loads(base64.urlsafe_b64decode(token.split(".")[1] + "=="))
  287. print(f" token有效期至: {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(payload['exp']))}")
  288. except Exception:
  289. pass
  290. out = os.path.join(os.path.dirname(os.path.abspath(__file__)), "token_pc.txt")
  291. with open(out, "w", encoding="utf-8") as f:
  292. f.write(token)
  293. print(f" 已保存: {out}")
  294. save_user_token(args.user, token) # 共享给下游脚本
  295. return 0
  296. return 1
  297. if __name__ == "__main__":
  298. sys.exit(main())