login_996pc.py 14 KB

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