先说边界

这篇只记录工程模式,不记录真实接口、真实密钥、cookie 或可直接复用的目标站点参数。逆向里的加密请求常常只是 Web 前端协议的一部分,学习它可以帮助我们理解请求生命周期,但不应该拿来绕过登录、付费、验证码或访问权限。

Shark-Tools 里有几类典型模式:请求体 RSA 分块加密、加密结果再做 HMAC 签名、响应体 AES 解密、以及一些自定义 Base64 / 反转 / XOR 的混淆管线。把它们拆开看,其实都可以整理成稳定的模块。

请求体加密

RSA 有明文长度限制,所以请求体比较长时需要分块。下面是脱敏后的骨架,真实公钥不应该写进文章,也不应该把私有接口原样公开:

import base64
import json
import secrets
import time
from Crypto.Cipher import PKCS1_v1_5
from Crypto.PublicKey import RSA

def rsa_encrypt_chunks(data: str, public_key_pem: str) -> str:
    key = RSA.import_key(public_key_pem)
    cipher = PKCS1_v1_5.new(key)
    raw = data.encode("utf-8")
    chunk_size = 245
    encrypted = []

    for offset in range(0, len(raw), chunk_size):
        encrypted.append(cipher.encrypt(raw[offset:offset + chunk_size]))

    return base64.urlsafe_b64encode(b"".join(encrypted)).decode()

def build_encrypted_payload(params: dict, public_key_pem: str) -> str:
    payload = {
        **params,
        "timestamp": int(time.time() * 1000),
        "nonce": secrets.token_hex(16),
    }
    canonical = json.dumps(payload, separators=(",", ":"), ensure_ascii=False)
    return rsa_encrypt_chunks(canonical, public_key_pem)

这里最值得记住的是三件事:参数要先 canonicalize,nonce 要真的随机,分块大小要跟 RSA padding 匹配。不要把这段写成到处复制的工具函数,最好让它只服务于一个明确的协议适配器。

签名跟加密不是一回事

加密解决的是“内容不可直接读”,签名解决的是“内容没有被改”。有些目标协议会要求对加密后的 pack 再签一次:

import hashlib
import hmac

def sign_pack(pack: str, secret: str) -> str:
    return hmac.new(
        secret.encode("utf-8"),
        pack.encode("utf-8"),
        hashlib.sha256,
    ).hexdigest()

def convert_to_secure_payload(params: dict, public_key_pem: str, secret: str) -> dict:
    pack = build_encrypted_payload(params, public_key_pem)
    return {
        "pack": pack,
        "signature": sign_pack(pack, secret),
    }

如果是在维护第三方协议,就只能兼容它要求的算法;如果是自己设计接口,不建议选择 MD5 这类旧算法做安全签名。

响应解密

响应体常见做法是 Base64 包一层,再 AES-CBC 解密,最后 JSON parse。Shark-Tools 早期代码里有“解不出来就返回原文”的策略,适合调试,但正式工具最好返回结构化错误:

import base64
import json
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad

def decrypt_aes_json(cipher_text: str, key: bytes, iv: bytes) -> dict:
    try:
        padding = len(cipher_text) % 4
        if padding:
            cipher_text += "=" * (4 - padding)
        encrypted = base64.urlsafe_b64decode(cipher_text)
        cipher = AES.new(key, AES.MODE_CBC, iv)
        plain = unpad(cipher.decrypt(encrypted), AES.block_size)
        return {"ok": True, "data": json.loads(plain.decode("utf-8"))}
    except Exception as exc:
        return {
            "ok": False,
            "error": {
                "code": "decrypt_failed",
                "message": str(exc),
            },
        }

解密失败不一定是算法错,也可能是 key 过期、响应为空、接口返回了错误页,或者请求被风控拦截。错误码要尽量说清楚是哪一层坏了。

自定义编码管线

有些协议会把响应先做字符替换、按块反转、XOR,再走 AES。它看起来花,但工程上就是一条 pipeline:

CUSTOM_B64 = "ZYXABCDEFGHIJKLMNOPQRSTUVWzyxabcdefghijklmnopqrstuvw9876543210-_"
STANDARD_B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"

def xor_string(value: str, key: int = 90) -> str:
    return "".join(chr(ord(char) ^ key) for char in value)

def block_reverse(value: str, size: int = 8) -> str:
    return "".join(value[index:index + size][::-1] for index in range(0, len(value), size))

def custom_b64_to_standard(value: str) -> str:
    mapping = {CUSTOM_B64[i]: STANDARD_B64[i] for i in range(len(CUSTOM_B64))}
    return "".join(mapping.get(char, char) for char in value)

def decode_pipeline(value: str) -> str:
    return custom_b64_to_standard(block_reverse(xor_string(value)))

这类代码最怕写成一坨。我的习惯是每一步单独命名,给一组本地样例做单元测试。平台变了之后,才能知道到底是 Base64 表变了、块大小变了,还是 AES key 变了。

复盘

加密逆向的重点不是记住某个算法,而是建立拆解习惯:请求参数如何构造,随机量在哪里,加密输入是什么,签名输入是什么,响应为什么要先解码再解密。

写文章时我会刻意脱敏,因为公开分享应该分享思路和工程边界,不应该帮别人复制一条可滥用的链路。真正值得沉淀的是方法,而不是某个接口今天还能不能跑。