JS文件中的字符串字面量、HTML>var key = CryptoJS.enc.Utf8.parse("1234567890123456"); var iv = CryptoJS.enc.Utf8.parse("1234567890123456"); var encrypted = CryptoJS.AES.encrypt("hello", key, {iv: iv, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7});注意:这里的iv是固定字符串,不是随机生成。这意味着服务端校验时,IV也是确定的、可预测的 。而GCM模式要求IV(nonce)绝对唯一,否则直接导致密钥泄露。当Burp重放请求时,如果用GCM,每次请求的IV必须不同,但服务端怎么知道你这次用了哪个IV?它得在请求里显式传,而多数协议根本没预留IV字段。
更致命的是兼容性。CryptoJS的GCM实现与OpenSSL的GCM在AAD(Additional Authenticated Data)处理上存在差异,我实测过12个不同版本的CryptoJS,有7个在GCM模式下与Python的cryptography库结果不一致。而CBC+PKCS7是工业级标准,Python的pycryptodome、Java的BouncyCastle、Node.js的crypto模块,全都能100%对齐。
注意:不要被“GCM更安全”误导。在API签名场景中,安全性不取决于模式本身,而取决于密钥管理强度 。一个被硬编码在JS里的128位AES-GCM密钥,比一个动态派生的256位AES-CBC密钥更脆弱。我们选CBC,是因为它在现实协议中可预测、可复现、可调试。
2.3 签名字段的设计哲学:分离密文与签名,而非拼接 常见错误做法:把AES密文Base64后,再用HMAC-SHA256算签名,最后拼成{ "data": "xxx", "sign": "yyy" }。问题在于——当你在Burp里修改data字段时,sign字段已失效,但你没法实时重算。而我们的方案强制要求:签名计算必须覆盖原始明文,且签名字段独立于加密字段传输 。
正确结构应为:
{ "encrypted_data": "U2FsdGVkX1+...", "timestamp": 1717023456, "nonce": "a1b2c3d4e5f6", "signature": "sha256(明文_json + timestamp + nonce + secret_key)" }看到区别了吗?signature的输入是原始明文(未加密前的JSON字符串),不是密文。这样,当你在Burp中修改任意明文参数时,只需把新明文、当前时间戳、随机nonce一起发给Flask接口,它就能返回新的encrypted_data和signature——两个字段同步更新,零延迟。
为什么这么做?因为服务端校验逻辑必然是:先解密encrypted_data得到明文,再用相同规则计算signature,最后比对。如果签名基于密文计算,服务端就得先解密再签名再比对,多一层解密失败风险,且无法做前置签名校验(比如WAF直接拦截非法签名)。
3. Flask服务实现:轻量、无状态、抗并发的签名引擎 这个Flask服务不是Web应用,而是一个协议转换中间件 。它不存session,不连数据库,不读配置文件——所有密钥、IV、算法参数都通过HTTP请求传入。这样设计是为了适配Burp的无状态重放场景:你不可能要求测试人员每次重放前先登录Flask后台设置密钥。
3.1 核心路由设计与参数契约 服务只暴露两个端点,全部走POST,Body为JSON:
POST /aes/encrypt:输入明文,输出密文+签名POST /aes/decrypt:输入密文,输出明文(仅用于调试,生产环境可关闭)每个请求必须携带以下字段(缺失则400):
plaintext: string,待加密的原始JSON字符串(如{"user_id":"123","amount":100})key: string,16/24/32字节的AES密钥(Base64编码,避免特殊字符)iv: string,16字节IV(Base64编码)mode: string,cbc或ecb(ECB仅用于极简场景,不推荐)padding: string,pkcs7(唯一支持)signature_key: string,用于HMAC签名的密钥(Base64)timestamp: int,当前Unix时间戳(服务端会校验±300秒)nonce: string,客户端生成的随机字符串(防重放)注意:所有二进制参数(key/iv/signature_key)必须Base64编码。这是为了规避HTTP传输中URL编码、空格截断、不可见字符等问题。我吃过亏——某次用明文key1234567890123456,在Burp中复制粘贴时末尾多了个换行符,导致解密失败,排查3小时才发现是编辑器自动加的LF。
3.2 加密核心逻辑:CBC模式下的IV安全传递 这是最容易出错的部分。很多人以为IV可以固定,但严格来说,IV必须随机且不可预测 。然而在Burp重放场景中,“随机”意味着每次请求IV都不同,服务端怎么解密?答案是:IV不参与密钥派生,而是随密文一起传输,并在加密时显式指定 。
我们的实现强制要求客户端传入iv,服务端不做任何修改,直接用于AES.new()。这样保证了加解密一致性。关键代码如下(使用pycryptodome):
from Crypto.Cipher import AES from Crypto.Util.Padding import pad, unpad import base64 import hmac import hashlib def aes_encrypt(plaintext: str, key_b64: str, iv_b64: str) -> str: key = base64.b64decode(key_b64) iv = base64.b64decode(iv_b64) # 验证长度:AES-128要求key=16, iv=16 if len(key) not in (16, 24, 32): raise ValueError(f"Invalid key length: {len(key)}") if len(iv) != 16: raise ValueError(f"Invalid IV length: {len(iv)}") cipher = AES.new(key, AES.MODE_CBC, iv) padded = pad(plaintext.encode('utf-8'), AES.block_size, style='pkcs7') ciphertext = cipher.encrypt(padded) return base64.b64encode(ciphertext).decode('utf-8') def generate_signature(plaintext: str, timestamp: int, nonce: str, signature_key_b64: str) -> str: key = base64.b64decode(signature_key_b64) msg = f"{plaintext}{timestamp}{nonce}".encode('utf-8') sig = hmac.new(key, msg, hashlib.sha256).digest() return base64.b64encode(sig).decode('utf-8')看到没?iv是直接base64.b64decode()后传给AES.new()的,没有二次哈希,没有截断。这就是为什么客户端(Burp)必须能生成并传递IV——我们在Burp Extender里用Java写了个小工具,每次请求前调用SecureRandom.getInstance("SHA1PRNG")生成16字节随机数,再Base64编码填入iv字段。
3.3 抗并发与性能:为什么不用Redis存IV,而用客户端传递 有同学建议:“把每次生成的IV存Redis,设置5分钟过期,服务端解密时查Redis”。这看似合理,但在Burp场景中是灾难。原因有三:
网络延迟放大 :Burp发请求到Flask,Flask再连Redis,单次请求增加50~200ms延迟。而Burp Intruder跑1000个payload,就是1000次Redis往返,总耗时从3秒飙到30秒;状态耦合 :Flask服务重启,Redis里IV全丢,所有正在重放的请求立即失败;扩展性差 :红队多人共用一台Flask服务,Redis key冲突概率陡增。我们的方案是无状态设计 :IV由Burp侧生成并透传,服务端只做纯计算。实测单核CPU下,该Flask服务QPS稳定在1200+(加密+签名全流程),完全满足Burp重放需求。你甚至可以把Flask进程跑在树莓派上,用USB网卡直连测试机,延迟低于0.3ms。
实操心得:在Burp Intruder中,如果Payload数量超500,建议把iv字段设为Null Payloads(即不变化),而用plaintext字段做变量。因为IV只需保证单次请求内一致,没必要每个payload都换IV。这样既安全(单次请求IV唯一),又高效(减少随机数生成开销)。
4. Burp集成实战:从Extender插件到Intruder自动化 Flask服务搭好只是第一步,真正价值在于无缝嵌入Burp工作流。这里不讲基础配置,只聚焦三个高阶场景:Repeater一键加签、Intruder批量加密、Scanner动态签名。
4.1 Repeater增强:用Extender写Java插件实现自动加签 Burp原生Repeater不支持动态计算,必须用Extender写插件。我用Java写了不到200行代码,核心逻辑是:监听Repeater的processHttpMessage事件,在请求发送前,提取plaintext参数(我们约定放在HTTP Body的data字段),调用本地Flask接口,把返回的encrypted_data和signature写回请求。
关键代码片段:
public class BurpExtender implements IBurpExtender, IHttpListener { private static final String FLASK_URL = "http://127.0.0.1:5000/aes/encrypt"; @Override public void processHttpMessage(int toolFlag, boolean messageIsRequest, IHttpRequestResponse messageInfo) { if (toolFlag == IBurpExtenderCallbacks.TOOL_REPEATER && messageIsRequest) { IRequestInfo reqInfo = callbacks.getHelpers().analyzeRequest(messageInfo.getRequest()); String bodyStr = getRequestBody(messageInfo); // 解析原始明文(假设Body是JSON,且含"data"字段) JSONObject jsonBody = new JSONObject(bodyStr); String plaintext = jsonBody.optString("data", ""); if (!plaintext.isEmpty()) { try { // 调用Flask接口 String response = callFlaskEncrypt(plaintext); JSONObject flaskResp = new JSONObject(response); // 注入加密后字段 jsonBody.put("encrypted_data", flaskResp.getString("encrypted_data")); jsonBody.put("signature", flaskResp.getString("signature")); jsonBody.remove("data"); // 移除原始明文 byte[] newBody = jsonBody.toString().getBytes(StandardCharsets.UTF_8); IExtensionHelpers helpers = callbacks.getHelpers(); byte[] newRequest = helpers.buildHttpMessage( reqInfo.getHeaders(), newBody); messageInfo.setRequest(newRequest); } catch (Exception e) { stdout.println("Encrypt failed: " + e.getMessage()); } } } } }编译成JAR后,Burp → Extender → Add → Java → 选择JAR。从此,你在Repeater里改完data字段,点“Send”瞬间完成加签,无需切窗口、无需复制粘贴。
4.2 Intruder批量加密:用Snippets注入动态IV与时间戳 Intruder的难点在于:每个Payload都要生成唯一的iv和timestamp。Burp原生Payload类型不支持动态计算,必须用Snippets (代码片段)。
步骤:
在Intruder的Payload Options中,选择Custom iterator; 添加两列:plaintext(你的原始参数列表)和iv(留空); 点击Add→Payload Processing→Add item→Invoke a Snippet; 输入Java代码: import java.security.SecureRandom; import java.util.Base64; SecureRandom sr = new SecureRandom(); byte[] ivBytes = new byte[16]; sr.nextBytes(ivBytes); return Base64.getEncoder().encodeToString(ivBytes);同样为timestamp添加Snippet:return String.valueOf(System.currentTimeMillis() / 1000); 这样,每个Payload都会动态生成IV和时间戳,再通过前面的Extender插件统一调用Flask加密。实测1000个Payload,全程自动,耗时12.3秒。
4.3 Scanner动态签名:绕过“未授权访问”拦截的终极方案 Scanner扫API时,常因缺少合法签名被WAF拦截,返回401或跳转登录页,导致路径发现失败。解决方案是:让Scanner的每个请求都携带动态签名 。
但这有陷阱:Scanner会并发发送请求,而签名依赖时间戳。如果10个请求在同一秒发出,timestamp相同,nonce若没随机化,会导致签名重复,被服务端拒绝。
我们的解法是:在Extender插件中,对Scanner请求做特殊处理——nonce字段用System.nanoTime()生成(纳秒级唯一),timestamp用System.currentTimeMillis()/1000,并确保signature_key从Burp的Session Handling Rules中读取(比如从登录响应中提取的token)。
关键经验:Scanner的并发请求数不要设太高。实测发现,当并发>20时,Flask服务CPU飙升,部分请求超时。建议设为10,配合Delay选项(每个请求间隔100ms),稳定性最佳。
5. 真实环境踩坑实录:从医保平台到银行App的5个血泪教训 理论再完美,不如一线踩坑来得深刻。以下是我在3个真实项目中总结的硬核避坑指南,全是文档里找不到的细节。
5.1 坑一:CryptoJS的UTF-8编码陷阱——中文参数永远解密失败 某省级医保平台,请求体含中文{"name":"张三","id":"123"}。我在Flask里用plaintext.encode('utf-8')加密,服务端却报“Padding is incorrect”。抓包对比发现:CryptoJS的enc.Utf8.parse()对中文处理是先UTF-8编码,再按字节切分 ,而Python的str.encode('utf-8')没问题。问题出在——CryptoJS的parse()会把字符串末尾的\x00(空字符)也当作有效字节,而Python的pad()填充是标准PKCS7。
解决方案:在Flask加密前,对plaintext做预处理:
# CryptoJS兼容模式 def cryptojs_utf8_encode(s: str) -> bytes: # CryptoJS的Utf8.parse等价于:先UTF-8编码,再移除BOM(如果有) b = s.encode('utf-8') if b.startswith(b'\xef\xbb\xbf'): b = b[3:] return b然后用这个bytes对象做pad和encrypt。实测后,中文参数100%通过。
5.2 坑二:IV的Base64编码在Windows与Linux下的换行符差异 开发时在Mac上一切正常,部署到测试服务器(CentOS)后,所有加密请求失败。tcpdump抓包发现:Mac生成的Base64 IV是MTIzNDU2Nzg5MDEyMzQ1Ng==(无换行),而CentOS的base64命令默认每76字符加\n,变成:
MTIzNDU2Nzg5MDEyMzQ1 Ng==服务端base64.b64decode()遇到换行直接抛异常。解决方法:在Flask中强制用Python的base64.b64encode(),并加.replace(b'\n', b'');同时在Burp Extender的Snippet里,用Java的Base64.getEncoder().encodeToString(bytes),它不加换行。
5.3 坑三:Burp的HTTP/2请求头大小写导致签名不一致 某银行App升级HTTP/2后,签名突然失效。对比发现:HTTP/2规范要求Header名称小写,而我们的签名计算包含Content-Type字段。Flask服务收到的Header是content-type: application/json,但签名逻辑里写的是Content-Type,导致拼接字符串不一致。
修复方案:在签名计算前,统一将所有Header名转为小写:
headers_lower = {k.lower(): v for k, v in request.headers.items()} # 然后用 headers_lower['content-type'] 参与签名并在Burp Extender中,对HTTP/2请求做同样处理。
5.4 坑四:Flask的JSON解析自动转义引号,破坏原始JSON结构 当plaintext是{"msg":"he\"llo"}(含转义引号)时,Flask的request.get_json()会把它变成{"msg":"he\\"llo"}(双反斜杠),导致签名原文与客户端不一致。
解决方案:禁用Flask的JSON自动解析,改用原始Body:
@app.route('/aes/encrypt', methods=['POST']) def encrypt(): raw_body = request.get_data(as_text=True) # 手动解析JSON,保留原始转义 import json data = json.loads(raw_body, strict=False) plaintext = data['plaintext']strict=False允许解析非标准JSON(如单引号、尾逗号),get_data(as_text=True)避免字节解码错误。
5.5 坑五:Burp的Auto Cookie Handler与动态签名冲突 开启Burp的Project options → Sessions → Session Handling Rules → Auto Cookie Handler后,它会自动在每个请求加Cookie。但我们的签名计算必须包含完整请求体,如果Cookie是动态的(比如JSESSIONID=abc123),而签名时没包含它,服务端校验就失败。
终极解法:关闭Auto Cookie Handler,改用Macro + Session Handling Rule 。先建一个Macro,抓取登录请求的Set-Cookie,提取JSESSIONID;再建Rule,对所有请求Inject Cookie,值为Macro提取的变量。这样,Cookie值固定,签名计算可复现。
我在实际使用中发现,这套方案最大的价值不是技术多先进,而是把加密测试从“玄学调试”变成了“确定性工程” 。以前改一个参数要5分钟,现在3秒;以前不敢用Intruder怕触发风控,现在敢跑5000个Payload;以前看到AES就绕着走,现在看到就笑——因为知道,只要拿到密钥和协议规则,剩下的全是体力活。最后分享一个小技巧:把Flask服务打包成Docker镜像,用docker run -p 5000:5000 -d aes-signer一键启动,测试机上curl -X POST http://localhost:5000/aes/encrypt -d '{"plaintext":"{\\\"id\\\":1}","key":"..."}',3秒内拿到结果。这才是生产力。