reverse-skills-max: initial public release (restored, redacted, hardened)

This commit is contained in:
baradok609hecl1pq6qi
2026-10-20 02:17:15 +03:00
commit da24c2bd20
2227 changed files with 443931 additions and 0 deletions

View File

@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View File

@@ -0,0 +1,265 @@
<h1 align="center">js-reverse-automation--skill</h1>
<p align="center">
<code>前端JS逆向全流程自动化Skill</code>
</p>
<div align="center">
<p align="center">
<a href="https://github.com/Fausto-404/js-reverse-automation--skill-test/releases">
<img src="https://img.shields.io/github/v/release/Fausto-404/js-reverse-automation--skill?style=flat-square&label=release&color=blue&cacheSeconds=3600" alt="Release">
</a>
<a href="https://github.com/Fausto-404/js-reverse-automation--skill-test/stargazers">
<img src="https://img.shields.io/github/stars/Fausto-404/js-reverse-automation--skill?style=flat-square&label=stars&color=brightgreen&cacheSeconds=3600" alt="GitHub Stars">
</a>
<a href="https://github.com/Fausto-404/js-reverse-automation--skill-test/network/members">
<img src="https://img.shields.io/github/forks/Fausto-404/js-reverse-automation--skill?style=flat-square&label=forks&color=orange&cacheSeconds=3600" alt="GitHub Forks">
</a>
<a href="https://github.com/Fausto-404/js-reverse-automation--skill-test/releases">
<img src="https://img.shields.io/github/downloads/Fausto-404/js-reverse-automation--skill/total?style=flat-square&label=downloads&color=success&cacheSeconds=3600" alt="Downloads">
</a>
</p>
</div>
<p align="center">
<strong>结合chrome-devtools-mcp的能力并加上Skill的规范,实现JSRPC+Flask+autoDecoder方案的前端JS逆向自动化分析,提升JS逆向的效率</strong>
</p>
<p align="center">
</p>
## 适用场景
- 登录参数加密(RSA/AES/SM2/SM4/MD5/自定义编码)
- 数据爬取时响应内容加密
- 请求签名(sign/token/enc)
- 需要将js逆向逻辑封装为可复用的代码
- 需要与 Burp 配合进行抓包、改包
## 解决传统 AI 逆向的四大痛点
本项目专注于 **实战工程落地** ,通过更轻量的架构打通逆向到实战的最后一公里:
* **从“死磕补环境”到“JSRPC 动态榨取”** :
不强求 AI 去补全复杂的浏览器上下文,而是指导 AI 建立 JSRPC 远程调用。直接将真实浏览器作为算法解析器,绕过混淆逻辑,0 成本获取加密结果。
* **从“孤岛式输出”到“全链路生产交付”** :
拒绝只停留在“看懂代码”阶段。AI 交付的不仅是解析思路,更是直接可运行的 **Python Flask 中转服务** 与 **Burp Suite (autoDecoder) 联动配置**,无缝接入渗透工作流。
* **从“单阶段盲跑”到“契约化阶段校验”** :
引入明确的 Phase 0-9 阶段划分,以 `analysis_result.json` 作为中间产物契约。在生成代码后强制触发本地验证器校验,大幅降低 AI 在复杂长文本下的幻觉与语法错误。
* **从“单次对话记忆”到“经验持续演进”** :
打破“新对话即白纸”的限制。利用 `references/evolution_matrix.json` 记忆库,允许 AI 跨任务沉淀对抗经验,实现技能包针对新型混淆与反调试的持续自我演进。
---
## 赞助支持
### js-reverse-automation × Infistar.cc 无限星河 | 全模型 API · 助力 JS 逆向自动化
<img width="1360" height="160" alt="a8f1d14a4d7d6e81d15a451dd5cef2b4" src="https://github.com/user-attachments/assets/8f06d386-0f2d-4409-bc0e-f9ed38df1e65" />
感谢 **Infistar.cc 无限星河** 赞助本项目!
⚡ **稳定低价的模型服务**:价格低至官方渠道 0.1 折,模型倍率公开透明,多节点冗余与智能路由,有效降低限流、429 与服务中断的影响;
💬 **主流模型一站式接入**:一个 API Key 即可调用 Claude、GPT、Gemini、DeepSeek 等主流模型,无缝适配 Claude Code、Codex 及本项目 MCP 工作流;
🕵️ **JS 逆向长流程降本**:面向数万行混淆 JavaScript 分析与 Phase 0–9 自动化流程,结合本项目上下文精简机制,减少 Token 消耗,降低长时间逆向任务的运行成本;
🎁 **项目用户专属福利**:通过 [**专属推广链接**](https://www.infistar.cc/register?aff=FUYA27UM&ref_source=link) 注册即可领取 **$5 测试额度及首充特惠**,快速体验多模型 API 服务!
## 流程设计思路
针对js逆向中常用的远程调用法进行js逆向(如JSRPC+Mitmproxy、JSRPC+Flask等)中,初始配置阶段中面对的定位加密函数、编写注册代码、编写python代码等繁琐操作,通过引入AI的MCP和Skill技术进行赋能,让AI自动完成函数发现与注册代码生成,最终实现从“半自动”到“高自动”的跨越,人员全程只需下方指令,并最终配置一下burp即可完成JS逆向的全流程。
<img width="2064" height="1108" alt="image" src="https://github.com/user-attachments/assets/fc13f276-f667-486a-8506-221c0c55507e" />
## 核心能力
这不是“找到一个加密函数”的工具,而是一条从真实浏览器取证、对抗运行时分析,到 JSRPC/Flask/Burp 交付的完整逆向链路。
| 能力方向 | 核心亮点 |
| --- | --- |
| **真实入口发现** | 通过 MCP 连接真实浏览器,触发并跟踪加密/签名链路;Hook 捕获 fetch、XHR、crypto、WebSocket、CryptoJS、JSEncrypt、sm2、sm3、sm4 的调用与参数流转;同时解析 Webpack `__webpack_require__` 和 module cache。 |
| **候选定位与验证** | 采用 8 维候选评分、真实样本验证、主动调用和请求字段指纹差分,持续收敛到可复现的真实入口;用 SHA-256 证据图关联 producer→consumer 数据流。 |
| **运行时对抗分析** | 观测反调试、反 Hook、完整性校验、环境属性、动态代码、多 Realm、响应链和加载器事件;通过持久 Hook、统一编排、外部替换重建和安全卸载保持取证能力。 |
| **JSVMP 与重度混淆** | 以源码级保守属性 tap 关联插桩位置、运行时热点和调用结果;结合基线/干预差分分析控制流与加密运行时行为,并支持控制台清理、历史导航、存储访问和页面关闭等信号观测。 |
| **浏览器证据闭环** | 绑定 tab/document 会话身份,管理浏览器动作生命周期;使用 `validate_browser_evidence.py` 验证明文、最终请求体、路由、HTTP 状态和业务响应,形成可追溯的动作级证据。 |
| **自动化交付** | 自动管理 JSRPC 与 Flask 服务,生成注入代码并完成浏览器注册验证;输出 Burp autoDecoder 配置,覆盖完整 HTTP 报文、包装模式、请求/响应方向、端口回退和可复制验证命令。 |
**最终产物**:明文、密文/签名、最终请求、服务端响应、JSRPC 注入代码、Flask 代理、Burp autoDecoder 配置和验证报告。
## 项目结构
```latex
js-reverse-automation/
├── SKILL.md # 入口文件(精简版)
├── workflow/
│ └── pipeline.md # 详细工作流(Phase 0-9)
├── constraints/
│ └── rules.md # 约束规则
├── references/ # 参考规范与知识库(按需加载)
│ ├── output-contract.md # 输入输出契约
│ ├── capability-boundaries.md # 能力边界说明
│ ├── architecture.md # 架构说明
│ ├── security-model.md # 安全模型
│ ├── antidebug-patterns.md # 反调试模式与 Patch
│ ├── browser-session-contract.md # 浏览器会话与证据契约
│ ├── advanced-entrypoints.md # 复杂入口场景(Webpack/异步/WASM)
│ ├── evidence-collection.md # 取证方法(Hook/源码/网络)
│ └── evolution_matrix.json # 跨任务经验记忆库
├── schemas/ # JSON Schema 定义
│ ├── analysis_result.schema.json
│ ├── candidates.schema.json
│ ├── probe_dump.schema.json
│ └── adversarial_trace.schema.json
├── scripts/ # 自动化工具脚本
│ ├── common.py # 共享工具库
│ ├── check_inputs.py # 输入校验
│ ├── emit_runtime_hook_probe.py # 运行时 Hook 探针生成
│ ├── emit_adversarial_runtime_probe.py # 对抗运行时探针生成
│ ├── source_instrumentor.js # 保守源码级属性插桩
│ ├── adversarial_diff.py # 原始/干预差分
│ ├── emit_module_probe.py # Webpack 模块探针生成
│ ├── build_evidence_graph.py # 证据图构建
│ ├── detect_encryption.py # 加密函数候选评分
│ ├── differential_verifier.py # 差分验证
│ ├── emit_jsrpc_stub.py # JSRPC 注入代码生成
│ ├── emit_flask_proxy.py # Flask 代理生成
│ ├── emit_burp_doc.py # Burp 文档生成
│ ├── manage_services.py # 服务管理(JSRPC/Flask 启停)
│ ├── validate_artifacts.py # 四层校验
│ ├── validate_browser_evidence.py # action 级浏览器/JSRPC 证据校验
│ ├── quarantine.py # 隔离报告
│ ├── doctor.py # 依赖检查
│ ├── env_patcher.py # 环境补丁
│ ├── classify_anticrawl.py # 反爬分类
│ ├── identify_crypto.py # 加密算法识别
│ ├── hook_templates.py # Hook 模板库
│ ├── ast_candidate_analyzer.js # AST 静态分析
│ └── JsEnv_Dev.js # Hlclient WebSocket 客户端库
├── generated/ # AI 运行生成的中间代码/配置产物
│ ├── jsrpc_inject.js # JSRPC 浏览器端注入代码
│ ├── flask_proxy.py # Flask 本地代理服务
│ ├── burp-autodecoder.md # Burp autoDecoder 配置文档
│ └── runtime_hook_probe.js # 运行时 Hook 探针脚本
└── artifacts/ # 运行时的动态状态与报告产物
├── phase0_input.json # 校验后的输入
├── probe_dump.json # 运行时事件数据
├── module_dump.json # Webpack 模块发现结果
├── evidence_graph.json # 事件关联图
├── encryption_candidates.json # 加密函数候选评分
├── validation_report.json # 四层校验报告
├── quarantine.json # 隔离报告
├── jsrpc_status.json # JSRPC 服务状态
├── flask_status.json # Flask 服务状态
└── browser_evidence.json # action 级真实调用证据验收
```
## 使用示意
1. 安装 Python 运行依赖
```bash
python3 -m pip install -r requirements.txt
```
2. 安装 MCP 服务
```bash
# Claude Code
claude mcp add chrome-devtools -- npx -y chrome-devtools-mcp@latest
# Codex
codex mcp add chrome-devtools -- npx -y chrome-devtools-mcp@latest
# Gemini
gemini mcp add chrome-devtools npx -y chrome-devtools-mcp@latest
```
3. 将 `js-reverse-automation` 目录放入 Skill 目录,然后输入:
```
# 第一次建议带上jsrpc路径,后续流程会更稳
Target URL: https://xxx.com/login
Parameters To Analyze: password
Optional Fetch Example: fetch("https://xxx.com/api/login", {"body":"...","method":"POST"})
```
等待运行完成【第一次使用会生成产物文件夹】,按输出结果,验证有效性以及配置 Burp 即可。
## 效果检验
1. 获取输入所需信息【参考如图1、2、3】
<img width="2182" height="1444" alt="image" src="https://github.com/user-attachments/assets/a0edb08b-ef21-4059-bae5-d9a255a69d30" />
2. 按照模版编写提示词并输入给支持 MCP 的模型进行验证
<img width="1810" height="1264" alt="image" src="https://github.com/user-attachments/assets/7703c06a-0f42-4c8d-b2c9-6d18172c1194" />
4. 等待输出结果
<img width="1380" height="1462" alt="image" src="https://github.com/user-attachments/assets/9419df5c-f876-41ac-bdba-60d13a603445" />
4. 依据结果输出测试即可
<img width="2216" height="1612" alt="image" src="https://github.com/user-attachments/assets/557946b1-1f68-4ba7-8b6a-f794d0858b18" />
5. 使用完后记得按照指示关闭jsrpc和flask!!!
## 实战案例
包括但不限于:
- xx大学:MD5(全局函数) ✅
- xx大学:SM2 国密 + DOM 公钥 ✅
- xx网:RSA-2048 + JSEncrypt 懒加载 ✅
- xx游:RSA-1024 + Webpack 闭包 ✅
- 某音乐:AES & RSA 组合 + params / encSecKey 类结构 ✅
- 某理工学院:RSA-1024 + React 组件 encodePass ✅
- xx鱼:RSA-1024 + 全局 miniLogin.rsaPassword ✅
- xx神:RSA-1024 + 模块内部加密(源码分析) ✅
## 引用工具
- JsRpc:https://github.com/jxhczhl/JsRpc
- autoDecoder:https://github.com/f0ng/autoDecoder
- chrome-devtools-mcp:https://github.com/ChromeDevTools/chrome-devtools-mcp/
、
## 更新日志
<details>
<summary><strong>v2.2(2026-09-18)|运行时对抗与完整交付升级</strong></summary>
从“扩展观测与结果验证”进一步升级为“可持续对抗、可证明成功、可直接交付”的完整闭环,面向通用浏览器逆向流程提供统一能力支撑。
- **运行时对抗能力升级**:统一观测反调试、完整性校验、环境属性、动态代码、多 Realm 与加载器行为;通过持久 Hook 和统一编排器支持探针叠加、外部替换后的重建以及安全卸载,提升复杂混淆环境下的持续取证能力。
- **JSVMP 与重度混淆分析升级**:增加保守的源码级属性 tap,建立插桩位置、运行时热点和调用结果之间的关联;结合基线/干预差分,形成静态分析与运行时行为的联合证据。
- **真实浏览器会话升级**:绑定 tab 与 document 身份,管理导航、断线、超时和暂停等会话生命周期;观测控制台清理、历史导航、存储访问和页面关闭等控制流信号,并支持可回滚的受控干预。
- **成功判定升级**:新增 action 级证据闭环,覆盖明文、最终请求体、最终路由、HTTP 状态和业务响应;建立服务状态、动作状态、请求状态和业务状态的分层验证模型,形成可追溯的成功判定。
- **传输与证据可靠性升级**:最终请求捕获统一覆盖 fetch 与 XMLHttpRequest;原始对象采用有界、循环引用安全的快照;兼容历史 JSRPC 外层字符串数据,提升 CryptoJS、JSEncrypt 等复杂对象和响应封装场景下的证据完整性。
- **JSRPC/Flask/Burp 交付升级**:覆盖完整 HTTP 报文和包装请求模式,校验 `/go` 端点规范化、`application/octet-stream` 封装、`Content-Length` 重建和通用字段变换;保持请求/响应数据包互斥选择,同时支持分别配置解密接口与加密接口、端口占用回退和可复制验证命令。
</details>
<details>
<summary><strong>v2.1(2026-07-28)|证据驱动与 Token 优化</strong></summary>
- **证据驱动**:SHA-256 指纹关联、证据图构建、差分验证,从"可能对"变成"确认对"
- **扩展 Hook**:WebSocket/Request/TextEncoder/btoa/CryptoJS/JSEncrypt/sm2/sm3/sm4,覆盖更多加密场景
- **降级策略链**:5 级降级,不轻易放弃,模块内部加密也能处理
- **四层验证**:Schema + 静态 + 候选不变量 + 跨文件一致性
- **Token 优化**:SKILL.md 精简 84%,参考资料按需加载,初始加载总 token 约减少 95%
- **新增工具**:反爬分类、加密算法识别、Hook 模板库、环境补丁、AST 分析、隔离报告
</details>
<details>
<summary><strong>v2.0(2026-05-31)|自动化架构升级</strong></summary>
- **架构优化**:阶段流程从 Phase 0-9 精简为 Phase 0-8,消除冗余步骤,token 消耗减少约**40%**
- **全自动化**:JSRPC 自动发现/启动、Flask 自动启停、浏览器自动注入,**全程只需配置 Burp**
- **更强入口定位**:运行时 Hook 探针 + Webpack 模块解析 + 7 维度候选评分,提供更强大、更快速的入口定位能力,**对模型要求降低**
- **更稳定输出**:capability_boundary 显式声明不支持场景、runtime_health 健康检测、候选验证机制
</details>
<details>
<summary><strong>v1.x|历史版本更新记录</strong></summary>
- **2026-05-21**:引入 Phase 9 经验沉淀与对抗库演进
- **2026-04-10**:补强请求复现、参数入口定位、反检测验证能力
- **2026-03-19**:添加对抗 AI 识别为高风险操作的能力
- **2026-03-10**:重构为"主控文件 + 参考规则 + 生成器 + 校验器"架构
- **2026-02-11**:新增 11 个反调试补充技能
- **2026-02-03**:优化项目结构,支持 Claude/Codex/Trae 平台
</details>

View File

@@ -0,0 +1,92 @@
---
name: js-reverse-automation
description: 通过 chrome-devtools-mcp 连接真实浏览器,自动定位前端加密入口,生成 JSRPC 注入代码、Flask 代理和 Burp autoDecoder 对接文档。
metadata:
author: Fausto-404
---
# JS 逆向自动化
通过 JSRPC 与真实浏览器完成前端加密入口定位、运行时取证、对抗分析、验证和工程化交付。浏览器连接、文档身份和 action 级结果必须形成可复核证据链。
## 适用场景
- 登录/注册页面密码加密
- API 请求签名
- 表单字段加密
- 响应数据解密
## 输入格式
```
Target URL: https://xxx/login
Parameters To Analyze: password
Optional Fetch Example: fetch("https://xxx/api/login", {...})
```
## 核心流程
1. **预分析 Fetch Example**(如果有)→ 提取加密算法、参数格式、目标 URL
2. 打开页面 → 注入 Hook → 触发目标动作
3. 捕获网络请求 → 定位加密函数
4. 注册 JSRPC → 生成 Flask/Burp 产物
5. 验证输出 → 按标准最终交付格式输出完整结果
遇到反调试、反 Hook、JSVMP、动态 Cookie、环境型签名或动态加载时,先生成并运行 `adversarial_runtime_probe.js`;只有观察到明确对抗信号后,才使用 `--mode intervene` 或源码级插桩。
## 详细工作流
详见 `workflow/pipeline.md`
## 对抗与验证能力
- **对抗运行时探针**:反调试、完整性检查、环境属性、动态代码、响应链、Worker/WebSocket/WASM/iframe 加载事件。
- **持久 Hook**:页面重新赋值后自动检测丢失并尝试恢复;所有 Patch 可通过 `uninstall()` 回滚。
- **Hook 组合**:对抗探针与运行时加密探针共享 `__JSRA_HOOK_REGISTRY__`,同一 API 采用可卸载的分层包装,外部替换只触发一次重建。
- **安全证据**:原始捕获采用有界循环引用安全快照,避免 CryptoJS/JSEncrypt 对象导致导出失败。
- **浏览器会话契约**:注入、调用和导航绑定到当前 tab/document;断线、超时、暂停或导航后不复用旧引用,避免把未知状态误报成成功。
- **控制流观测**:可选观察控制台清理、历史导航、存储访问和关闭页面等干扰信号;只有明确启用干预模式时才阻止受控动作,默认不改变业务行为。
- **源码级保守插桩**:针对明确选择的简单属性读取生成 tap,不默认改写全量代码。
- **对抗差分**:比较基线与干预后的事件、请求、错误和新增证据;差分报告不等于目标已被绕过。
- **完整交付**:标准 JSRPC 产物在调用期间捕获最终 `fetch/XHR` 网络请求,返回明文、最终 route、密文/签名请求体、HTTP 状态和业务响应;最终结果同时保留分析结果、验证表、可复制命令、关闭命令、Burp autoDecoder 配置和生成产物;对抗信息作为补充证据追加。
- **能力边界**:验证码、WebAuthn、设备证明、跨域隔离 Realm 和未导出的 WASM 内部函数仍需保留为人工或 unsupported 路径。
## 交付前强制检查
生成 Flask 代理后必须执行:
```bash
python3 scripts/check_delivery_contract.py \
--proxy generated/flask_proxy.py \
--output artifacts/delivery_contract.json
```
该检查必须同时通过 JSRPC 地址归一化、Burp `application/octet-stream` 完整报文、`Content-Length` 重建和 `dataBody` wrapper 四项回归。`jsrpc.base_url` 可以填写 `http://127.0.0.1:12080` 或 `http://127.0.0.1:12080/go`,生成器必须保证最终只请求一次 `/go`。
端口监听不等于 JSRPC 可用。最终报告前还必须使用真实浏览器执行 action 级 smoke test,并确认返回 `plaintext`、最终 route、非空 `requestBody`、HTTP 状态和业务响应;`/list` 无 action、`/go` 返回 400 或 Flask 返回 502 时,不得写“Flask 代理验证通过”。
将 action 级结果保存后执行:
```bash
python3 scripts/validate_browser_evidence.py \
--input artifacts/jsrpc_smoke.json \
--output artifacts/browser_evidence.json
```
如果同时保存了浏览器桥接状态、`tabId` 和当前 `documentId`,再增加 `--require-session`。超时、断线、待恢复或文档身份变化只能标记为未确认,不能通过重放旧请求来补证据。
Burp 整包模式必须支持外层 `Content-Type: application/octet-stream`,从内层 HTTP 报文识别真实 Content-Type,返回完整改写报文;请求/响应方向仍每次只能选择一个,解密接口和加密接口可以同时配置。
## 约束规则
详见 `constraints/rules.md`
## 参考资料(按需加载)
- 反调试:`references/antidebug-patterns.md`
- 复杂入口:`references/advanced-entrypoints.md`
- 证据收集:`references/evidence-collection.md`
- 能力边界:`references/capability-boundaries.md`
- 输出契约:`references/output-contract.md`
- 最终报告模板:`references/final-report-template.md`
- 对抗运行时:`references/adversarial-runtime.md`
- 浏览器会话与证据:`references/browser-session-contract.md`
## Token 预算
- 单次调用:50,000 token
- 最大工具调用:20 次
- 停止条件:找到入口并验证通过 / 所有降级策略失败 / Token 预算耗尽

View File

@@ -0,0 +1,35 @@
# 约束规则
## 硬性约束
1. 不把模型推断直接标记为已验证事实
2. `high` 置信度必须包含真实运行时调用或真实差分匹配证据
3. 不批量执行未知 Webpack module factory
4. 默认不保留敏感字段原文
5. 主动调用候选前必须检查 `safe_to_invoke`
6. JSRPC 只负责调用页面真实函数,禁止重写加密算法
7. 没有真实入口和请求字段关联时,不得生成可用于 Burp 的成功转换
8. 生成器中的 JSRPC 地址必须归一化,禁止产生 `/go/go`;完整报文和 wrapper 两种 Burp 输入必须都有回归测试
9. 仅凭 12080 端口、Flask `/healthz` 或页面出现“已注册”不得判定交付成功,必须完成 action 级实时调用
10. 浏览器断线、超时、暂停、导航或文档身份变化后,旧 tab/document/元素引用和旧 action 结果全部失效;必须重新绑定并重新验证,不能盲目重放非幂等动作
11. 最终报告必须能回溯到 `artifacts/browser_evidence.json`;缺少明文、最终请求体、route、HTTP 状态或业务响应任一项时,只能报告为未确认
## 能力边界
- **能做**:页面导航、evaluate_script、initScript 预注入、Hook fetch/XHR/crypto/WebSocket/库、Webpack 模块捕获、证据驱动候选发现、差分验证
- **不能做**:闭包未导出函数、WASM 未导出内部函数、Service Worker 内部闭包、ES module import 拦截、VMP 静态还原、CSP WebSocket 绕过、跨域 iframe、VM 保护 JS
## 策略值
- `global_path`:函数在 window 上
- `runtime_hook`:通过 Hook 调用栈发现
- `webpack_export`:Webpack 模块导出
- `async_crypto`:异步加密
- `initscript_hook`:initScript Hook
- `static_ast`:静态 AST 分析
- `source_analysis`:源码分析 + Python 实现
- `manual_observed_only`:有证据但未确认
- `unsupported`:所有方法都失败
## 候选不变量
- `verified=true` 必须有至少一条 `verification` 类型证据
- `confidence=high` 必须同时满足 `verified=true`
- 所有候选必须标记来源
- 失败验证不得删除,应保留为负证据

View File

@@ -0,0 +1,437 @@
# 复杂入口分析规则
本文用于处理复杂的加密入口场景,包括异步函数、Promise、Web Crypto API、WebAssembly、Webpack 模块、Vite/Rollup/ESM 以及闭包作用域限制。
---
## 1. 异步与 Promise 入口
### 适用条件
- 目标入口返回 Promise,例如 crypto.subtle.digest(...)、Webpack 动态 import() 或 async function。
- JSRPC 动作处理器必须等待 Promise 完成后再返回结果。
### 识别方式
- 函数返回带有 .then() 方法的对象。
- analysis_result.json 中的 call_signature.async 为 true。
- 源码使用 async function 或返回 new Promise()。
- 运行时 Hook 会在 crypto[] 事件中记录返回 Promise 的函数。
- 在依赖提取阶段调用入口,并检查 typeof result.then === 'function'。
### JSRPC 处理要求
动作处理器必须识别 Promise 并等待结果。这适用于所有调用,不仅限于源码显式声明为异步的函数。
~~~js
var result = fn.apply(ctx, args);
if (result && typeof result.then === 'function') {
result.then(function(asyncResult) {
// 处理 crypto.subtle 返回的 ArrayBuffer/Uint8Array
if (asyncResult instanceof ArrayBuffer) {
resolve(Array.from(new Uint8Array(asyncResult)));
} else {
resolve(asyncResult);
}
}).catch(function(error) {
resolve(normalizeError(parameter, error));
});
return;
}
resolve(result);
~~~
当 invocation.mode 为 "async" 或 "promise" 时,生成器必须包含 Promise 处理逻辑,否则 JSRPC /go 可能返回 undefined。
### 异步入口前置条件
- 调用 crypto.subtle.encrypt/decrypt/sign/verify 前,可能必须先导入或生成 CryptoKey。
- Webpack 动态导入可能要求目标分块先完成加载。
- 将这些条件记录到 invocation.preconditions 和 parameters[].runtime.bootstrap。
### 异步入口证据
- 运行时 Hook 从 crypto[] 中记录带有业务调用栈的事件。
- Promise 完成后填写的 outputLen 可以证明算法产生了输出。
- 与 requests[] 交叉比对:如果加密事件后很快出现 fetch/XHR 请求,说明业务函数可能把加密结果写入了请求体。
---
## 2. Web Crypto API(crypto.subtle)
### 特征
- 所有方法返回 Promise<ArrayBuffer>。
- digest(algorithm, data):摘要。
- sign(algorithm, key, data):数字签名(HMAC/RSASSA/ECDSA)。
- encrypt(algorithm, key, data):加密(AES/RSA)。
- decrypt(algorithm, key, data):解密。
- importKey(...):导入密钥。
- exportKey(...):导出密钥。
- 输入通常是 ArrayBuffer/TypedArray,输出是 ArrayBuffer。
### 识别
运行时 Hook 会自动包装核心方法,证据记录在 window.__JSRA_TRACE__.crypto[]。
### 算法识别
- crypto[] 事件的 algorithm 字段:对象取 algorithm.name,字符串直接记录原值。
- 常见算法名包括:SHA-1、SHA-256、SHA-384、SHA-512、AES-CBC、AES-GCM、AES-CTR、RSA-OAEP、RSASSA-PKCS1-v1_5、ECDSA、HMAC。
### Hook 示例(摘要)
~~~js
var origDigest = crypto.subtle.digest.bind(crypto.subtle);
crypto.subtle.digest = function(algorithm, data) {
var entry = { type: 'digest', algorithm: algorithm.name || algorithm, inputLen: data.byteLength };
trace.crypto.push(entry);
return origDigest(algorithm, data).then(function(hash) {
entry.outputLen = hash.byteLength;
entry.outputHex = Array.from(new Uint8Array(hash)).map(function(b) {
return b.toString(16).padStart(2, '0');
}).join('').substring(0, 32) + '...';
return hash;
});
};
~~~
### 密钥材料
- crypto.subtle.sign/encrypt/decrypt 的第二个参数必须是 CryptoKey。
- 如果密钥通过 crypto.subtle.importKey 在运行时导入,导入事件也会出现在 crypto[] 中。
- CryptoKey 对页面脚本通常不可直接读取;但通过 importKey Hook 可以观察 keyData 原始参数。
### 补充 importKey Hook
如果运行时探针尚未覆盖 importKey,可以临时补充:
~~~js
(function() {
'use strict';
if (!window.crypto || !window.crypto.subtle) return;
var origImportKey = crypto.subtle.importKey.bind(crypto.subtle);
crypto.subtle.importKey = function(format, keyData, algorithm, extractable, usages) {
console.log('[JSRA] crypto.subtle.importKey', {
format: format,
algorithm: typeof algorithm === 'string' ? algorithm : algorithm.name,
usages: usages,
keyDataSnippet: keyData instanceof ArrayBuffer
? '[ArrayBuffer:' + keyData.byteLength + ']'
: (keyData instanceof Uint8Array ? '[Uint8Array:' + keyData.length + ']' : String(keyData))
});
return origImportKey(format, keyData, algorithm, extractable, usages);
};
})();
~~~
### ArrayBuffer 转十六进制
~~~js
function arrayBufferToHex(buffer) {
return Array.from(new Uint8Array(buffer))
.map(function(b) { return b.toString(16).padStart(2, '0'); })
.join('');
}
~~~
### crypto.subtle 的 JSRPC 生成要求
- 入口策略使用 async_crypto。
- JSRPC 必须使用正确的算法、密钥和数据调用函数,等待 Promise 完成,并按目标协议将 ArrayBuffer 转为十六进制或 Base64。
- 入口为 crypto.subtle.* 时,处理器必须使用 ArrayBuffer 参数、识别返回的 Promise,并在完成后转换输出。
- 如果必须先导入密钥,应在处理器中完成导入,或将其记录为启动依赖。
### 限制
- CryptoKey 不能传出浏览器上下文;JSRPC 必须在密钥被导入的同一页面上下文中执行。
- 使用 extractable: false 生成的密钥不能导出,必须直接使用页面内的密钥引用执行签名或加密。
- chrome-devtools-mcp 无法直接拦截浏览器内部密码学运算,只能通过软 Hook 观察输入和输出。
---
## 3. WebAssembly(WASM)
### 识别信号
- 网络请求加载 .wasm 文件。
- window 上存在 WebAssembly 对象。
- 页面调用 WebAssembly.instantiate 或 WebAssembly.instantiateStreaming。
- 存在仅含数字的函数名,或参数/返回值为 Number、BigInt 的不透明函数。
### 可以执行的操作
- Hook WebAssembly.instantiate / WebAssembly.instantiateStreaming。
- 使用 WebAssembly.Module.exports(module) 查看导出函数。
- 从 JavaScript 调用已导出的 WASM 函数。
### 不可以执行的操作
- 调用 WASM 模块未导出的函数。
- 查看 WASM 内部内存或调用栈。
- 读取 WASM 源码。
- 绕过 WASM 完整性校验。
### WASM 实例化 Hook
用于观察 WASM 模块导出的函数:
~~~js
var origInstantiate = WebAssembly.instantiate;
WebAssembly.instantiate = function(bytes, imports) {
console.log('[JSRA] WebAssembly.instantiate called, bytes length:', bytes ? bytes.byteLength || bytes.length : 'unknown');
return origInstantiate(bytes, imports).then(function(result) {
var module = result.module || result;
if (module instanceof WebAssembly.Module) {
window.__JSRA_WASM_EXPORTS = WebAssembly.Module.exports(module);
console.log('[JSRA] WASM exports:', window.__JSRA_WASM_EXPORTS);
}
if (result.instance) {
window.__JSRA_WASM_INSTANCE = result.instance;
var exportNames = Object.keys(result.instance.exports);
console.log('[JSRA] WASM instance exports:', exportNames);
for (var i = 0; i < exportNames.length; i++) {
var name = exportNames[i];
if (typeof result.instance.exports[name] === 'function') {
console.log('[JSRA] WASM export function:', name, 'arity:', result.instance.exports[name].length);
}
}
}
return result;
});
};
~~~
### 调用 WASM 导出函数
- 如果模块导出函数且页面通过 JavaScript 包装器暴露,例如 window.wasmEncrypt(data),可以将其作为 JSRPC 入口。
- 入口策略使用 wasm_export。
- WASM 通常使用 Number、BigInt 或内存指针;JavaScript 包装器负责字符串/数组与内存之间的转换。
### 证据要求
确认 WASM 函数是入口至少需要:
1. 网络证据:请求体包含 WASM 函数产生的结果。
2. 运行时证据:观察到 JavaScript 包装器以目标参数调用,并且返回值与请求字段一致。
仅观察到 WASM 加载、没有确认导出函数参与目标请求时,标记为 manual_observed_only。
### 限制
- WASM 内部逻辑未导出时,不能从 JavaScript 上下文调用,标记 strategy=unsupported,并填写 unsupported_reason。
- chrome-devtools-mcp 不能直接读取 WASM 内存。
- 不在范围内反编译 WASM 字节码。
- WASM 混淆时仍可使用导出函数接口,但不能仅凭接口理解内部逻辑。
---
## 4. Webpack 模块发现
### 概述
现代 Web 应用通常使用 Webpack 打包。目标入口可能位于 Webpack 模块闭包中,无法通过 window.* 直接访问。模块探针会发现模块系统,并尝试捕获 __webpack_require__,从而访问模块导出。
### 探针安装
生成:
~~~bash
python3 scripts/emit_module_probe.py --output generated/module_probe.js
~~~
注入:
- 通过 evaluate_script 注入。
- 安装前检查 window.__JSRA_MODULES__,保证重复注入不会重复安装。
### 探针检测内容
#### 分块数组
探针会在 window 上检测 Webpack 分块数组:
- Webpack 3/4:window.webpackJsonp,格式为 [chunkIds, modules, executeModules]。
- Webpack 5:符合 window.webpackChunk* 的数组,格式为 [[chunkIds], modules, runtime]。
- Loadable 组件:window.__LOADABLE_LOADED_CHUNKS__。
发现分块数组后,设置 module_runtime.detected = true,并将类型记录为 webpack4、webpack5 或 unknown。
#### 捕获 __webpack_require__
按以下顺序尝试三种策略:
**策略一:Hook webpackJsonp.push**
- 覆盖分块数组上的 Array.prototype.push。
- 新分块进入时,从模块工厂创建可加载模块的伪 require 函数。
- 将捕获到的函数保存为 window.__JSRA_require。
**策略二:检查已有暴露对象**
- 检查 window.__webpack_require__、window.__webpack_modules__ 和 window.webpackJsonp 中是否已有可用函数。
**策略三:扫描 window 中的 require 类函数**
- 遍历 window 属性,寻找带有 .c 模块缓存属性的函数。
#### 模块缓存枚举
捕获 __webpack_require__ 后,枚举 require.c 或 require.cache:
~~~js
var cache = require.c || require.cache || {};
var keys = Object.keys(cache);
for (var i = 0; i < keys.length; i++) {
var mod = cache[keys[i]];
if (mod && mod.exports) {
// 检查 mod.exports 中的函数
}
}
~~~
- 最多列出 500 个模块 ID。
- 导出值是函数时,记录 moduleId、exportName、type、前 200 个字符的 srcSnippet 和参数个数 arity。
- 导出值是带函数属性的对象时,记录形如 exportName.subKey 的子导出。
结果保存到 window.__JSRA_MODULES__.candidateExports。
### 证据读取
读取模块发现结果:
~~~js
evaluate_script("window.__JSRA_MODULES__.dump()")
~~~
结果包含:detected、type、requireAvailable、requirePath、moduleCacheKeys(最多 200 个)、candidateExports、chunkArrays、globalExports(最多 100 个)和 errors。
### 与运行时 Hook 证据交叉验证
必须把模块探针结果与运行时 Hook 证据交叉比对:
1. 从运行时追踪的 requests[].stack 中提取函数名或源码文件引用。
2. 在 candidateExports 中查找匹配的 exportName 或 srcSnippet 特征。
3. 只有同时出现在 Hook 调用栈和模块导出中的候选,才能标记为 confidence=high。
4. 仅出现在模块导出中、没有运行时 Hook 证据的候选,标记为 confidence=low。
交叉验证规则:候选导出必须与运行时 Hook 调用栈中观察到的函数匹配;不匹配时标记为 manual_observed_only。
### Webpack 导出的 JSRPC 生成
- 入口类型:webpack_export。
- entrypoint 必须包含 module_id 和 export_path,分别表示模块 ID 和导出名称路径,例如 encrypt 或 default.encrypt。
- JSRPC 使用 window.__JSRA_require(module_id) 获取模块,再通过 getByPath(module, export_path) 调用导出。
### 限制
- 如果应用不使用 webpackJsonp 或分块数组不可访问,捕获可能失败,标记 require_available: false。
- Webpack 动态 import() 产生的分块可能不在已捕获缓存中;探针可以观察请求,但不能稳定获取导出。
- 模块 ID 可能随构建变化;JSRPC 应使用运行时捕获的 require,不要硬编码模块 ID。
- 启用热更新时模块缓存可能失效,应关闭热更新,或在热更新运行前捕获 require。
---
## 5. Vite、Rollup 与 ESM
### 识别
- 文档中出现 <script type="module">。
- Vite 特征:import.meta.hot、window.__vite_plugin_meta__。
- Rollup 特征:window.__rollup_plugin__。
识别后,将 module_runtime.type 设置为 vite 或 rollup。
### 全局导出发现
即使使用 Vite/Rollup,部分库仍会暴露全局对象。模块探针的 detectGlobalExports() 会扫描 window:
- 带有密码学相关名称的函数:CryptoJS、JSEncrypt、md5、sha256、sha1、aes、Base64、encrypt、decrypt、sign、verify、hash、hmac。
- 函数属性名称符合密码学特征的对象。
结果保存在 window.__JSRA_MODULES__.globalExports。
### 限制
- ES 模块使用原生 import/export,不存在全局 require。
- 浏览器模块加载器属于内部实现,没有可直接 Hook 的导入解析入口,chrome-devtools-mcp 不能观察或修改原生模块解析。
- 动态 import() 返回 Promise,探针可以观察模块网络请求,但只有模块同时暴露到全局时才能捕获导出。
- Vite 的导入映射只提供 DOM 层面的映射信息,不能访问模块内部运行时对象。
- 如果加密逻辑运行在服务端,例如 Next.js SSR,则无法从浏览器上下文获取。
- 只能观察全局对象、模块脚本请求和 DOM 元素;不能可靠地动态调用 import() 并捕获导出。
### Vite/Rollup 页面处理策略
1. 在 globalExports 中查找暴露的密码学库。
2. 找到后使用 global_path 策略。
3. 未找到时,检查目标参数是否流经运行时 Hook 捕获的 fetch/XHR,再根据源码位置从请求体回溯。
4. 入口位于没有全局导出的 ES 模块闭包时,标记 manual_observed_only,记录观察结果供人工复现。
---
## 6. 闭包作用域函数
### 可以执行的操作
- 函数通过模块系统导出:使用 Webpack 捕获。
- 函数流经 fetch/XHR/crypto Hook:通过调用栈回溯。
- 函数位于 window:直接查找路径。
### 不可以执行的操作
- 调用未导出的闭包函数。
- 从外部重建词法作用域。
- 访问闭包捕获的变量。
### 可接受的替代方案
1. **Hook 边界**:闭包调用 fetch、XMLHttpRequest.send 或 JSON.stringify 时,Hook 外层边界截获数据。
2. **Hook 密码学 API**:闭包使用 crypto.subtle、CryptoJS 或 JSEncrypt 时,Hook 库获取输入和输出。
3. **解析器策略**:闭包通过动态解析器暴露目标函数时,捕获解析器。
4. **Webpack 模块访问**:闭包属于 Webpack 模块时,使用模块探针捕获 __webpack_require__ 并访问导出。
### 何时标记为不支持
以上方案都无法访问函数时,标记为:
~~~json
{
"entrypoint_discovery": {
"strategy": "unsupported",
"confidence": "high",
"unsupported_reason": "入口位于没有导出引用、没有可 Hook 边界且无法访问模块系统的闭包中。"
}
}
~~~
### 禁止事项
- 不得声称未导出的闭包函数可调用。
- 不得为运行时无法解析的函数生成 JSRPC 动作。
- 不能仅凭静态源码分析猜测闭包内部结构。
---
## 7. 策略选择参考
| 场景 | 策略 | 是否生成 JSRPC 动作 |
|---|---|---|
| 函数位于 window.xxx.yyy | global_path | 是 |
| 通过软 Hook 调用栈发现函数 | runtime_hook | 是 |
| 函数位于 Webpack 模块缓存 | webpack_export | 是(需要已捕获的 require) |
| 异步函数(crypto.subtle、Promise) | async_crypto | 是(必须处理 Promise) |
| 函数由 WASM 模块导出 | wasm_export | 是 |
| 有证据但尚未确认入口 | manual_observed_only | 否(输出证据供人工使用) |
| 函数不可访问(未导出闭包、WASM 内部函数、Service Worker 内部闭包) | unsupported | 否(输出 unsupported_reason) |
---
## 8. 复杂场景的置信度要求
| 场景 | high 置信度的最低要求 |
|---|---|
| async_crypto | 网络证据(请求体)+ 匹配算法和调用栈的 crypto[] 事件 |
| webpack_export | 网络证据 + candidateExports 匹配 + 运行时 Hook 调用栈交叉验证 |
| wasm_export | 网络证据 + 观察到 JavaScript 包装器调用且输入输出匹配 |
| global_path | 网络证据 + 通过 evaluate_script 观察到函数调用 |
| runtime_hook | 网络证据 + Hook 捕获的、经过业务函数的调用栈 |

View File

@@ -0,0 +1,73 @@
# 对抗运行时能力
本模块服务于已授权目标的动态分析,目标是增加可观察证据和入口存活能力,不承诺绕过验证码、WebAuthn、设备证明或跨域隔离。
## 能力顺序
1. `observe`:记录反调试、反 Hook、环境属性、动态代码、加载器和 Realm 信号。
2. `intervene`:只针对已观察到的动态 `debugger` 构造做最小干预。
3. 源码 tap:对明确选择的简单属性读取做保守插桩,先验证行为再扩大范围。
4. 差分:比较原始与干预结果;事件增加不是业务成功,必须继续核对目标请求和业务状态。
## 多探针组合
对抗探针和运行时加密探针可以同时注入。生成脚本会共享
`window.__JSRA_HOOK_REGISTRY__`,对同一 `fetch`、XHR 或 WebCrypto 方法按层组合,避免
后注入的探针覆盖前一个探针。外部代码重新赋值时,持久探针会记录
`patch.reconciled` 并重建组合链;`patch.reconcile_error` 才表示真正的重建失败。
运行时探针的 `captureRaw` 使用有界、循环引用安全的快照。需要稳定落盘时优先使用默认
预览模式;开启原始捕获也必须通过 `dump()` 或 JSON Schema 验证后再进入差分流程。
## 生成与读取
```bash
python3 scripts/emit_adversarial_runtime_probe.py \
--output generated/adversarial_runtime_probe.js \
--mode observe --properties "navigator.webdriver,navigator.userAgent,document.cookie"
```
完整性方法默认采用首段全量、后续采样和每方法预算,避免 `Object.getPrototypeOf`
等高频 API 挤掉真正的加密/网络证据。可按目标特征调整:
```bash
python3 scripts/emit_adversarial_runtime_probe.py \
--output generated/adversarial_runtime_probe.js \
--integrity-event-budget 256 \
--integrity-sample-every 64 \
--integrity-burst 16
```
被采样抑制的调用会记录在 `state.suppressed` 和
`state.suppressedByPath`,不计入 `dropped`;`dropped > 0` 仍表示事件环发生了真正丢弃。
页面中:
```js
window.__JSRA_ADVERSARIAL__.export()
window.__JSRA_ADVERSARIAL__.clear()
window.__JSRA_ADVERSARIAL__.uninstall()
```
## 证据类型
| 类型 | 用途 |
|---|---|
| `integrity.*` | 函数、原型或属性描述符完整性检查 |
| `dynamic.*` | eval/Function 动态代码 |
| `environment.*` | navigator、screen、document 等属性访问 |
| `realm.*` | iframe、Worker、SharedWorker 创建 |
| `loader.*` | script、WASM 等动态加载 |
| `patch.*` | 补丁安装、丢失、重挂载和恢复 |
| `intervention.*` | 发生过的明确干预 |
## 源码级保守插桩
```bash
node scripts/source_instrumentor.js \
--input bundle.js --output generated/bundle.tap.js \
--objects navigator,document,screen \
--properties webdriver,userAgent,cookie
```
默认只改写简单的 member read,并跳过字符串、注释、赋值和方法调用。它不是通用 AST 等价变换;插桩后必须与原始脚本做异常、请求和关键输出对照。

View File

@@ -0,0 +1,687 @@
# 反调试与环境对抗模式
本文汇总六类反调试规则中的反调试、反检测和环境校验模式。每个章节保留检测特征、补丁代码、证据要求和风险提示。
**基本原则:**先验证,再修改;使用最小补丁;记录影响范围。关于 chrome-devtools-mcp 的能力边界,参阅 `capability-boundaries.md`。
**验证顺序:**
1. 不加补丁,先记录原始现象。
2. 判断问题类型:`debugger`/动态代码构造、控制台清理或日志抑制、计时或 Promise 计时、视口/ webdriver / UA / DevTools 检测、导航/关闭/历史记录干扰。
3. 每次只选择一条最小规则进行验证。
4. 比较前后差异:请求是否恢复、Hook 是否开始产生日志、调用栈是否可见、页面是否产生新的异常。
**验收标准:**
- 补丁后产生可用于后续分析的新证据。
- 能解释补丁的影响范围和残余风险。
- 补丁不改变最终生成的 JSRPC / Flask / Burp 代码结构。
**禁止行为:**
- 未验证就同时启用多条反调试规则。
- 大范围伪造环境,只为了提高“看起来能运行”的概率。
- 将仅用于调试的补丁写入最终生成产物的依赖。
---
## 1. Debugger 循环
**来源:** `debugger-loop.md`
### 检测特征
- 目标请求发送前 DevTools 持续暂停。
- `eval`、`Function` 或 `constructor` 收到包含 `debugger` 的源码字符串。
- 同一调用栈反复出现,始终到不了参数变化位置。
### 需要记录
- 触发问题的 API(`eval`、`Function` 或 `constructor`)。
- 源码提示或调用栈帧。
- 是否需要伪装 `toString`。
### 补丁:绕过动态 `debugger`
适用于 `eval`、`new Function`、`Function.prototype.constructor` 以及持续触发 `debugger` 的动态组装代码。
```js
(() => {
'use strict';
const tempEval = eval;
const tempToString = Function.prototype.toString;
Function.prototype.toString = function () {
if (this === eval) {
return 'function eval() { [native code] }';
} else if (this === Function) {
return 'function Function() { [native code] }';
} else if (this === Function.prototype.toString) {
return 'function toString() { [native code] }';
} else if (this === Function.prototype.constructor) {
return 'function Function() { [native code] }';
}
return tempToString.apply(this, arguments);
};
window.eval = function () {
if (typeof arguments[0] === 'string') {
arguments[0] = arguments[0].replaceAll(/debugger/g, '');
}
return tempEval(...arguments);
};
const OriginalFunction = Function;
Function = function () {
for (let i = 0; i < arguments.length; i++) {
if (typeof arguments[i] === 'string') {
arguments[i] = arguments[i].replaceAll(/debugger/g, '');
}
}
return OriginalFunction(...arguments);
};
Function.prototype = OriginalFunction.prototype;
Function.prototype.constructor = function () {
for (let i = 0; i < arguments.length; i++) {
if (typeof arguments[i] === 'string') {
arguments[i] = arguments[i].replaceAll(/debugger/g, '');
}
}
return OriginalFunction(...arguments);
};
Function.prototype.constructor.prototype = Function.prototype;
})();
```
### 补丁后验证
- 页面不再因 `debugger` 反复中断。
- `Function.prototype.toString` 相关检查不产生新的异常。
- 主要业务流程仍可正常执行。
### 风险
- 该规则会改变全局执行行为,可能触发完整性检查。应尽量缩小影响范围并记录影响面。
---
## 2. 控制台检测
**来源:** `console-detect.md`
### 检测特征
- `console.log`、`console.table` 或 `console.clear` 被覆盖。
- 触发请求流程后控制台输出消失。
- 计时检测依赖控制台渲染副作用。
### 需要记录
- 受影响的控制台方法。
- 覆盖操作的源码提示。
- 恢复控制台后页面行为是否改变。
### 补丁一:保护 `console.log` / `trace` / `groupCollapsed` / `groupEnd`(Proxy 方法)
```js
(() => {
'use strict';
const readonlyProps = ['log', 'trace', 'groupCollapsed', 'groupEnd'];
const readonlyConsole = new Proxy(console, {
set(t, k, v, r) {
if (readonlyProps.includes(k)) {
console.groupCollapsed(`%cBlocked overwrite: console.${String(k)}`, 'color: #ff6348;', v);
console.trace();
console.groupEnd();
return true;
}
return Reflect.set(t, k, v, r);
}
});
Object.defineProperty(window, 'console', {
configurable: true,
enumerable: false,
get() {
return readonlyConsole;
},
set(v) {
console.groupCollapsed('%cBlocked overwrite: window.console', 'color: #ff6348;', v);
console.trace();
console.groupEnd();
}
});
})();
```
### 补丁二:阻止 `console.clear()`
```js
(() => {
'use strict';
console.clear = function () {};
})();
```
### 补丁三:阻止 `console.table()`(用于计时或 Getter 诱导检测)
```js
(() => {
'use strict';
console.table = function () {};
})();
```
### 补丁后验证
- 仍可使用 `console.log` 等方法观察运行时数据。
- 控制台输出不再被清空或干扰。
- 页面不会因控制台完整性检查产生新的异常。
### 风险
- 与执行流 Hook 相比,控制台补丁风险较低,但在加固构建中仍可能触发完整性检查。
---
## 3. 计时检测
**来源:** `timer-check.md`
### 检测特征
- 只有单步调试时流程才中断。
- 调试状态下 Promise 回调或计时器处理器进入不同分支。
- `performance.now`、`Date.now` 或时间间隔差值决定是否发送请求。
### 需要记录
- 使用的计时原语。
- 观测到的阈值。
- 绕过后解锁的分支或回调。
### 补丁:Hook Promise Resolve
适用于快速定位异步回调入口,或确认哪个 Promise resolve 产生了关键参数。
```js
(() => {
'use strict';
const OriginalPromise = Promise;
Promise = function (callback) {
if (!callback) {
return new OriginalPromise(callback);
}
const originCallback = callback;
callback = function (resolve, reject) {
const originResolve = resolve;
resolve = function (result) {
if (result && !(result instanceof Promise)) {
try {
console.groupCollapsed('[Promise resolve]');
console.log(result);
console.trace();
console.groupEnd();
} catch (e) {}
}
return originResolve.apply(this, arguments);
};
return originCallback(resolve, reject);
};
return new OriginalPromise(callback);
};
Promise.prototype = OriginalPromise.prototype;
Object.defineProperties(Promise, Object.getOwnPropertyDescriptors(OriginalPromise));
})();
```
### 补丁后验证
- Promise 链仍可正常执行。
- 可以看到 resolve 参数和调用栈。
- 页面没有明显性能下降。
### 风险
- 时间归一化可能掩盖真实竞态,只应在跟踪期间使用,不得默认静默启用。
---
## 4. 环境检测
**来源:** `env-detect.md`
### 检测特征
- 分支逻辑依赖视口尺寸、DevTools 打开状态、webdriver 标志、UA 或扩展状态。
- 同一页面在不同浏览器配置下表现不一致。
- 打开 DevTools 后请求链消失。
### 需要记录
- 被检查的属性。
- 原始值。
- 替换值。
- 受影响的源码提示。
### 补丁:修正窗口尺寸
适用于网站使用 `innerHeight` / `innerWidth` 或 `outerHeight` / `outerWidth` 判断 DevTools 是否打开的场景。
```js
(() => {
'use strict';
const innerHeightValue = 660;
const innerWidthValue = 1366;
const outerHeightValue = 760;
const outerWidthValue = 1400;
const innerHeightDesc = Object.getOwnPropertyDescriptor(window, 'innerHeight');
const innerWidthDesc = Object.getOwnPropertyDescriptor(window, 'innerWidth');
const outerHeightDesc = Object.getOwnPropertyDescriptor(window, 'outerHeight');
const outerWidthDesc = Object.getOwnPropertyDescriptor(window, 'outerWidth');
Object.defineProperty(window, 'innerHeight', {
get() { return innerHeightValue; },
set() { return innerHeightDesc.set.call(window, innerHeightValue); }
});
Object.defineProperty(window, 'innerWidth', {
get() { return innerWidthValue; },
set() { return innerWidthDesc.set.call(window, innerWidthValue); }
});
Object.defineProperty(window, 'outerHeight', {
get() { return outerHeightValue; },
set() { return outerHeightDesc.set.call(window, outerHeightValue); }
});
Object.defineProperty(window, 'outerWidth', {
get() { return outerWidthValue; },
set() { return outerWidthDesc.set.call(window, outerWidthValue); }
});
})();
```
### 补丁后验证
- 打开 DevTools 后不再触发尺寸检测。
- 页面布局没有不可接受的变形。
### 风险
- 环境伪造可能影响对正式环境行为的判断,只能用于调查,并且必须明确记录。
---
## 5. 代理与导航保护
**来源:** `proxy-guard.md`
### 检测特征
- `window.close`、`history.back`、重定向 Hook 或卸载处理器中断跟踪流程。
- 只有配置代理后请求才失败。
- 页面导航后扩展或注入脚本被阻止。
### 需要记录
- 保护类型(`close`、`history`、`redirect`、`proxy`、`extension`)。
- 源码提示。
- 处理后请求重放是否成功。
### 补丁一:阻止 `window.close`
```js
(() => {
'use strict';
window.close = function () {};
})();
```
### 补丁二:阻止 `history.go` / `history.back`
```js
(() => {
'use strict';
window.history.go = function () {};
window.history.back = function () {};
})();
```
### 补丁三:导航前断点(用于定位源码)
适用于页面即将导航,需要定位导航发生瞬间源码的场景。
```js
(() => {
'use strict';
window.onbeforeunload = () => {
debugger;
return false;
};
})();
```
### 补丁后验证
- 页面不再被强制关闭或返回上一页。
- 导航前可以稳定命中断点。
- 调试结束后及时移除这些补丁。
### 风险
- 导航保护补丁可能改变页面状态。条件允许时,应移除补丁后重新验证请求链。
---
## 6. 动态别名(密码库 Hook)
**来源:** `dynamic-alias.md`
### 检测特征
- 请求参数发生变化,但看不到稳定的全局路径。
- 多层包装函数逐层转发到真正的加密或签名函数。
- 缺少 source map,且对象路径每次刷新都会变化。
### 策略
- 稳定性较低时,优先使用解析器策略,不要硬编码对象路径。
- 记录包装链,以及解析到可调用函数所需的最小运行时前置条件。
- 记录生成的 JSRPC 使用的是解析器还是静态路径。
### 需要记录
- 包装链。
- 解析器触发条件。
- 解析前需要满足的运行时依赖。
### 补丁一:Hook CryptoJS
适用于目标网站使用 CryptoJS,需要快速定位 AES / DES / MD5 / SHA / HMAC 参数来源的场景。
```js
(() => {
'use strict';
let time = 0;
function hasEncryptProp(obj) {
const requiredProps = [
'ciphertext',
'key',
'iv',
'algorithm',
'mode',
'padding',
'blockSize',
'formatter'
];
if (!obj || typeof obj !== 'object') return false;
for (const prop of requiredProps) {
if (!(prop in obj)) return false;
}
return true;
}
function hasDecryptProp(obj) {
const requiredProps = ['sigBytes', 'words'];
if (!obj || typeof obj !== 'object') return false;
for (const prop of requiredProps) {
if (!(prop in obj)) return false;
}
return true;
}
function getSigBytes(size) {
switch (size) {
case 8: return '64bits';
case 16: return '128bits';
case 24: return '192bits';
case 32: return '256bits';
default: return 'unknown';
}
}
const tempApply = Function.prototype.apply;
Function.prototype.apply = function () {
// === 对称加密检测 ===
if (
arguments.length === 2 &&
arguments[0] &&
arguments[1] &&
typeof arguments[1] === 'object' &&
arguments[1].length === 1 &&
hasEncryptProp(arguments[1][0])
) {
if (Object.hasOwn(arguments[0], '$super') && Object.hasOwn(arguments[1], 'callee')) {
if (
this.toString().indexOf('function()') !== -1 ||
/^\s*function(?:\s*\*)?\s+[A-Za-z_$][\w$]*\s*\([^)]*\)\s*\{/.test(this.toString()) ||
/^\s*function\s*\(\s*\)\s*\{/.test(this.toString())
) {
console.log(...arguments);
const encryptText = arguments[0].$super.toString.call(arguments[1][0]);
if (encryptText !== '[object Object]') {
console.log('Symmetric ciphertext:', encryptText);
} else {
console.log('Symmetric ciphertext: toString unavailable, use the printed object above to call toString manually.');
}
const key = arguments[1][0].key.toString();
if (key !== '[object Object]') {
console.log('Symmetric Hex key:', key);
} else {
console.log('Symmetric Hex key: toString unavailable, use the printed object above to call toString manually.');
}
const iv = arguments[1][0].iv;
if (iv) {
if (iv.toString() !== '[object Object]') {
console.log('Symmetric Hex iv:', iv.toString());
} else {
console.log('Symmetric Hex iv: toString unavailable, use the printed object above to call toString manually.');
}
} else {
console.log('Symmetric encryption: no iv used');
}
if (arguments[1][0].padding) {
console.log('Padding mode:', arguments[1][0].padding);
}
if (arguments[1][0].mode && Object.hasOwn(arguments[1][0].mode, 'Encryptor')) {
console.log('Block mode:', arguments[1][0].mode.Encryptor.processBlock);
}
if (arguments[1][0].key && Object.hasOwn(arguments[1][0].key, 'sigBytes')) {
console.log('Key length:', getSigBytes(arguments[1][0].key.sigBytes));
}
console.log('%c---------------------------------------------------------------------', 'color: green;');
} else {
console.groupCollapsed('If the above correctly output the key/iv etc., ignore this message.');
console.log(...arguments);
console.log('Symmetric encryption: due to some necessary factors, key/iv etc. were not output. Use the printed object above to call toString manually.');
console.log('%c---------------------------------------------------------------------', 'color: green;');
console.groupEnd();
}
}
}
// === 对称解密检测 ===
else if (
arguments.length === 2 &&
arguments[0] &&
arguments[1] &&
typeof arguments[1] === 'object' &&
arguments[1].length === 3 &&
hasDecryptProp(arguments[1][1])
) {
if (Object.hasOwn(arguments[0], '$super') && Object.hasOwn(arguments[1], 'callee')) {
if (this.toString().indexOf('function()') === -1 && arguments[1][0] === 2) {
console.log(...arguments);
const key = arguments[1][1].toString();
if (key !== '[object Object]') {
console.log('Symmetric decrypt Hex key:', key);
} else {
console.log('Symmetric decrypt Hex key: toString unavailable, use the printed object above to call toString manually.');
}
if (Object.hasOwn(arguments[1][2], 'iv') && arguments[1][2].iv) {
const iv2 = arguments[1][2].iv.toString();
if (iv2 !== '[object Object]') {
console.log('Symmetric decrypt Hex iv:', iv2);
} else {
console.log('Symmetric decrypt Hex iv: toString unavailable, use the printed object above to call toString manually.');
}
} else {
console.log('Symmetric decryption: no iv used');
}
if (Object.hasOwn(arguments[1][2], 'padding') && arguments[1][2].padding) {
console.log('Decrypt padding mode:', arguments[1][2].padding);
}
if (Object.hasOwn(arguments[1][2], 'mode') && arguments[1][2].mode) {
console.log('Decrypt block mode:', arguments[1][2].mode.Encryptor.processBlock);
}
if (time === 0) {
console.log('如需继续确认算法类型,请结合当前页面的实际调用参数和输出长度进行定向验证。');
time += 1;
}
console.log('%c---------------------------------------------------------------------', 'color: green;');
}
}
}
// === 哈希/HMAC 检测 ===
else if (
arguments.length === 2 &&
arguments[0] &&
arguments[1] &&
typeof arguments[0] === 'object' &&
typeof arguments[1] === 'object'
) {
if (
arguments[0].__proto__ &&
Object.hasOwn(arguments[0].__proto__, '$super') &&
Object.hasOwn(arguments[0].__proto__, '_doFinalize') &&
arguments[0].__proto__.__proto__ &&
Object.hasOwn(arguments[0].__proto__.__proto__, 'finalize')
) {
if (arguments[0].__proto__.__proto__.finalize.toString().indexOf('Hash/HMAC') === -1) {
const tempFinalize = arguments[0].__proto__.__proto__.finalize;
arguments[0].__proto__.__proto__.finalize = function () {
if (!Object.hasOwn(this, 'init')) {
const hash = tempFinalize.call(this, ...arguments);
console.log('Hash/HMAC raw data:', ...arguments);
console.log('Hash/HMAC ciphertext:', hash.toString());
console.log('Hash/HMAC ciphertext length:', hash.toString().length);
console.log('Note: If HMAC, this script cannot hook the key. Search for it manually.');
console.log('%c---------------------------------------------------------------------', 'color: green;');
return hash;
}
return tempFinalize.call(this, ...arguments);
};
}
}
}
return tempApply.call(this, ...arguments);
};
})();
```
### 补丁二:Hook JSEncrypt RSA
适用于目标网站使用 JSEncrypt,需要直接获取 RSA 公钥、私钥、明文和密文的场景。
```js
(() => {
'use strict';
let u, c = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
function f(t) {
let e, i, r = '';
for (e = 0; e + 3 <= t.length; e += 3) {
i = parseInt(t.substring(e, e + 3), 16);
r += c.charAt(i >> 6) + c.charAt(63 & i);
}
if (e + 1 == t.length) {
i = parseInt(t.substring(e, e + 1), 16);
r += c.charAt(i << 2);
} else if (e + 2 == t.length) {
i = parseInt(t.substring(e, e + 2), 16);
r += c.charAt(i >> 2) + c.charAt((3 & i) << 4);
}
while ((3 & r.length) > 0) r += '=';
return r;
}
function hasRSAProp(obj) {
const requiredProps = [
'constructor',
'getPrivateBaseKey',
'getPrivateBaseKeyB64',
'getPrivateKey',
'getPublicBaseKey',
'getPublicBaseKeyB64',
'getPublicKey',
'parseKey',
'parsePropertiesFrom'
];
if (!obj || typeof obj !== 'object') return false;
for (const prop of requiredProps) {
if (!(prop in obj)) return false;
}
return true;
}
const tempCall = Function.prototype.call;
Function.prototype.call = function () {
if (
arguments.length === 1 &&
arguments[0] &&
arguments[0].__proto__ &&
typeof arguments[0].__proto__ === 'object' &&
hasRSAProp(arguments[0].__proto__)
) {
if (
'__proto__' in arguments[0].__proto__ &&
arguments[0].__proto__.__proto__ &&
Object.hasOwn(arguments[0].__proto__.__proto__, 'encrypt') &&
Object.hasOwn(arguments[0].__proto__.__proto__, 'decrypt')
) {
if (arguments[0].__proto__.__proto__.encrypt.toString().indexOf('RSA encrypt') === -1) {
const tempEncrypt = arguments[0].__proto__.__proto__.encrypt;
arguments[0].__proto__.__proto__.encrypt = function () {
const encryptText = tempEncrypt.bind(this, ...arguments)();
console.log('RSA public key:\n', this.getPublicKey());
console.log('RSA encrypt plaintext:', ...arguments);
console.log('RSA encrypt Base64 ciphertext:', f(encryptText));
console.log('%c---------------------------------------------------------------------', 'color: green;');
return encryptText;
};
}
if (arguments[0].__proto__.__proto__.decrypt.toString().indexOf('RSA decrypt') === -1) {
const tempDecrypt = arguments[0].__proto__.__proto__.decrypt;
arguments[0].__proto__.__proto__.decrypt = function () {
const decryptText = tempDecrypt.bind(this, ...arguments)();
console.log('RSA private key:\n', this.getPrivateKey());
console.log('RSA decrypt Base64 input:', f(...arguments));
console.log('RSA decrypt plaintext:', decryptText);
console.log('%c---------------------------------------------------------------------', 'color: green;');
return decryptText;
};
}
}
}
return tempCall.bind(this, ...arguments)();
};
})();
```
### 补丁后验证
- 成功从密码学包装链输出关键参数。
- 可以定位真实的 CryptoJS 或 JSEncrypt 调用位置。
- 重写底层 `call`/`apply` 后页面功能没有损坏。
### 风险
- 重写 `call`/`apply` 会影响全局作用域,可能触发加固构建的完整性检查。
---
## 7. 模式快速选择
| 模式 | 现象 | 文件 |
|---|---|---|
| Debugger 循环 | 反复出现 `debugger`,或 `eval`/`Function`/`constructor` 被篡改 | `debugger-loop.md` |
| 控制台检测 | 控制台方法被覆盖、调用 `console.clear`/`console.table`、日志被抑制 | `console-detect.md` |
| 计时检测 | 计时差值检查、Promise 计时、性能探针 | `timer-check.md` |
| 环境检测 | 视口尺寸、DevTools、webdriver、UA 检查 | `env-detect.md` |
| 代理与导航保护 | 导航、关闭、历史记录、重定向 Hook 阻止请求重放 | `proxy-guard.md` |
| 动态别名 | 混淆别名、动态解析器、密码学包装、异步间接调用 | `dynamic-alias.md` |
只有在某条规则确实改变调查路径或风险面时,才引用并启用它。

View File

@@ -0,0 +1,26 @@
# 架构
## 设计
- chrome-devtools-mcp:页面交互、网络、Console
- 本地 JSRA Engine:逆向分析
- 可选 Burp MCP:流量重放
## 证据模型
```json
{
"event_id": "evt-1",
"trace_id": "trace-1",
"parent_event_id": null,
"type": "crypto.encrypt",
"function": "JSEncrypt.encrypt",
"input_fingerprint": "sha256:...",
"output_fingerprint": "sha256:...",
"stack": [],
"timestamp": 0
}
```
## 置信度
- `high`:真实运行时 + 差分验证
- `medium`:多源证据一致
- `low`:启发式匹配

View File

@@ -0,0 +1,55 @@
# 浏览器会话与证据契约
真实浏览器分析不能用“端口已监听”“动作已注册”或“页面出现日志”代替成功。每次导航、注入和调用都要绑定到当前浏览器会话与当前文档,并在状态变化后重新确认。
## 操作前
1. 读取浏览器连接状态,确认连接的是用户指定的浏览器,而不是新建的临时页面。
2. 列出页面并锁定 `tabId`、当前 URL 和当前文档身份;没有稳定文档身份时,至少记录页面 URL 与导航时间。
3. 注入或修改前记录操作 ID、目标页面、脚本版本指纹和预期作用范围。
4. 页面发生导航、刷新、跨文档 iframe 切换或调试器重新连接后,旧的元素引用、调用栈、断点和注入确认全部视为失效,必须重新快照或重新注入。
## 操作中
- 超时只表示“结果未知”,不表示操作没有执行。不要盲目重放可能有副作用的登录、签名或状态修改动作;先读取状态、事件和当前文档。
- 调试器暂停时,调用、导航和注入的返回值可能只是“已排队”或“等待恢复”。必须恢复后再次读取状态,不能把旧的暂停信息当成新结果。
- 桥接断开时,清理未完成请求;重新连接后重新列页面并重新绑定文档。只有幂等的只读操作可以自动重试,登录和加密动作默认不自动重放。
- 事件、源代码和请求体采用有界读取;敏感字段默认只保留长度、哈希或脱敏预览,不能因为导出失败而放宽范围。
## 操作后
JSRPC action 级结果至少要同时出现:
- 输入明文或其明确的参数标识;
- 非空的最终请求体;
- 最终请求 route/URL;
- HTTP 状态;
- 最终业务响应。
如果浏览器控制面能提供会话信息,额外保存如下绑定:
```json
{
"session": {
"connected": true,
"tabId": 1,
"documentId": "document-generation"
},
"operation": {
"id": "operation-id",
"status": "confirmed",
"applied": true
}
}
```
`status=timeout`、`status=pending`、`applied=false`、文档身份缺失或返回中包含错误标记时,只能报告为未确认。使用 `scripts/validate_browser_evidence.py` 生成 `artifacts/browser_evidence.json`,再把它作为最终报告的验证依据。
## 最小恢复顺序
```text
读取桥接状态 → 列出页面 → 重新绑定 tab/document → 重新快照或注入
→ 触发一次动作 → 等待最终网络请求 → 导出有界证据 → 校验交付契约
```
不要用旧页面的元素引用、旧 WebSocket 请求 ID 或旧文档上的 Hook 结果替代这条恢复链。

View File

@@ -0,0 +1,44 @@
# 能力边界
## 能做
- 页面导航、evaluate_script、initScript 预注入
- Hook fetch/XHR/crypto/WebSocket/库
- Webpack 模块捕获
- 证据驱动候选发现
- 差分验证
- JSRPC/Flask/Burp 产物生成
## 不能做
- 闭包未导出函数
- WASM 未导出内部函数
- Service Worker 内部闭包
- ES module import 拦截
- VMP 静态还原
- CSP WebSocket 绕过
- 跨域 iframe
- VM 保护 JS
- multipart/form-data 的边界级字段改写;此类请求必须人工处理或生成专用适配器
## 模块内部加密
当加密函数在模块内部时,按顺序尝试:
1. initScript Hook `Object.defineProperty`
2. initScript Hook `Function.prototype`
3. 搜索 RSA 实例
4. 静态 AST 分析
5. 源码分析 + Python 实现
## 置信度
- `high`:真实运行时调用 + 差分验证
- `medium`:单一证据源
- `low`:仅启发式匹配
## 策略值
- `global_path`:函数在 window 上
- `runtime_hook`:Hook 调用栈发现
- `webpack_export`:Webpack 模块导出
- `async_crypto`:异步加密
- `initscript_hook`:initScript Hook
- `static_ast`:静态 AST 分析
- `source_analysis`:源码分析
- `manual_observed_only`:有证据未确认
- `unsupported`:所有方法失败

View File

@@ -0,0 +1,337 @@
# 证据收集规则
本文说明 JS 逆向自动化中的证据收集方法:网络捕获、软 Hook、源码定位和运行时 Hook 跟踪,并规定证据强度、记录方式与风险控制。
---
## 1. 网络捕获
### 适用场景
- 确认目标参数位于 URL、请求头、Cookie、JSON 请求体还是表单请求体。
- 页面动作触发多个相似请求,需要缩小到真正参与签名或加密的请求。
- 用户提供了 Optional Fetch Example,需要映射到真实浏览器请求。
### 证据优先级
1. 真实页面动作触发的网络记录。
2. 请求详情:方法、URL、请求头、请求体和响应。
3. 页面内观察代码输出:调用栈、序列化对象和函数参数。
4. 控制台日志与页面状态快照。
### 推荐流程
1. 打开目标页面并完成必要交互,确认请求确实发出。
2. 使用 chrome-devtools-mcp 的网络请求列表筛选候选请求。
3. 读取每个候选请求的详情,确认目标参数位置和请求体结构。
4. 请求较多时,按以下条件缩小范围:
- 是否出现目标参数。
- 请求触发时间是否接近用户动作。
- 响应状态是否符合业务预期。
- 请求体是否同时包含未加密明文和加密结果等相邻字段。
5. 锁定请求后立即记录:
- 请求 URL。
- HTTP 方法。
- Content-Type。
- 参数位置。
- 关键请求头。
- 触发动作。
6. 请求详情不足以定位源码时,再增加最小观察代码或最小 Hook。
### 成功标准
- 至少一个请求被明确标记为目标请求。
- 每个待分析参数的位置已知。
- 已记录足以支持后续源码定位的请求上下文。
### 失败信号
- 只能看到静态资源请求,看不到业务 API。
- 目标动作触发大量相似请求,无法区分签名参与者。
- 能看到请求,但无法确认参数经过哪一层序列化后出现。
### 处理策略
- 多请求竞争时只改变一个输入值,观察哪个请求字段同步变化。
- 序列化不透明时,对以下边界增加最小 Hook:
- window.fetch
- XMLHttpRequest.prototype.open
- XMLHttpRequest.prototype.send
- JSON.stringify
- 页面会刷新时,通过 evaluate_script 注入;需要在刷新前生效时,使用 navigate_page(initScript=...) 预注入。
- Hook 的目的只是记录“发送前最后一个可观察形态”,不能替代网络证据。
---
## 2. 软 Hook 规则
### 适用范围
- 网络证据已锁定目标请求,但仍缺少入口函数证据。
- 需要确认参数在某个函数调用前后是否发生变化。
- 需要确认返回值、this 绑定、异步行为或全局依赖。
### 最小 Hook 原则
- 先 Hook 通用边界,再 Hook 业务函数。
- 先记录后改写,默认不替换原函数。
- 每个 Hook 只增加一个观察点,避免多个 Hook 同时污染证据。
- 页面已加载时,优先使用 evaluate_script 注入。
- 页面将刷新或导航时,使用 navigate_page(initScript=...) 预注入。
### 推荐 Hook 顺序
1. window.fetch
2. XMLHttpRequest.prototype.open
3. XMLHttpRequest.prototype.send
4. JSON.stringify
5. 已明确匹配的业务函数
6. 必要时再补充 eval、Function 或 Promise 相关节点
### 每个 Hook 至少记录
- Hook 点路径。
- 匹配条件。
- 输入参数摘要。
- 返回值摘要。
- 调用栈摘要。
- 是否改变页面行为。
- 注入方式:evaluate_script 或 navigate_page(initScript=...)。
### 入口确认规则
业务函数 Hook 至少要证明以下一项:
- 明文输入与请求中的目标参数存在可验证映射。
- 函数返回值直接进入请求体、请求头或 Cookie。
只看到密码库内部调用、没有业务调用者上下文,不能认定为最终入口。
### 依赖提取规则
明确记录:
- this 绑定来源。
- 依赖的全局对象或闭包对象。
- 是否需要等待异步结果。
- 页面是否必须先完成启动流程。
这些信息最终写入 artifacts/phase3_dependencies.json 和 analysis_result.json。
### 风险控制
- 默认不要冻结大量原型。
- 不要为了方便观察而全局替换所有密码学 API。
- Hook 导致业务分支改变时,先回退到更小范围,再继续分析。
---
## 3. 源码定位
### 核心原则
- 先从请求详情、页面内调用栈和序列化节点回溯,再进行关键词搜索。
- 先找“参数最后一次发生变化的位置”,再找“最深层算法位置”。
- 只接受有证据支持的源码定位结果,不接受纯文本匹配结论。
### 定位优先级
1. 请求详情、页面内 Error().stack 或观察代码捕获的调用栈。
2. 发送前对象构造或序列化位置。
3. 与参数同名或相邻的源码片段。
4. 密码库调用位置。
### 推荐流程
1. 从目标请求开始,确认请求方法、请求体和相关脚本线索。
2. 在请求构造处加入最小观察代码:
- 观察 fetch / XHR send。
- 观察 JSON.stringify。
- 在页面内输出 Error().stack。
3. 页面刷新或导航时:
- 使用 navigate_page(initScript=...) 预注入。
- 重新触发目标动作并记录序列化前对象。
4. 标记参数的三个关键位置:
- 明文入口。
- 处理或加密位置。
- 发送前最终写入位置。
5. 只有其中至少两个位置被证据串联后,才能输出首选入口。
### 候选入口接受标准
- 能解释参数从明文到密文的变化。
- 能定位可调用函数或稳定解析器。
- 能解释 this 绑定、参数签名和必要依赖。
### 常见误判
- 只匹配到参数名,但变量只是中间副本。
- 只匹配到 md5、aes 或 sha,但它并不服务于目标请求。
- 只看到包装函数,没有继续确认真实执行点。
### 输出要求
- source_hint 尽量落到具体 bundle 位置或对象路径。
- evidence 至少包含以下一项:
- 页面内调用栈。
- Hook 捕获的输入参数和返回值。
- 序列化前对象快照。
- 参数值对照实验结果。
---
## 4. 运行时 Hook 跟踪
### 概述
运行时 Hook 使用预注入探针,在网络、密码学和序列化层捕获证据,是入口发现的重要证据源。
### 探针安装
生成:
~~~bash
python3 scripts/emit_runtime_hook_probe.py --output generated/runtime_hook_probe.js --params "target_param1,target_param2"
~~~
注入方式:
- 页面已加载时使用 evaluate_script。
- 页面会刷新或导航时使用 navigate_page(initScript=...)。
幂等性:
- 探针安装前检查 window.__JSRA_TRACE__,重复注入应安全地跳过。
### 探针捕获内容
探针在以下边界安装软 Hook,并将证据写入 window.__JSRA_TRACE__。
#### 4.1 网络请求(requests[])
- Hook 点:window.fetch、XMLHttpRequest.prototype.open、XMLHttpRequest.prototype.send。
- 每条记录包括:
- type:fetch 或 xhr。
- url:请求 URL。
- method:HTTP 方法。
- headers:摘要后的请求头。
- bodySnippet:截断后的请求体。
- timestamp:Date.now()。
- stack:通过 Error().stack 捕获的调用栈,最多 10 帧。
#### 4.2 密码学事件(crypto[])
- Hook 点:crypto.subtle.digest、sign、encrypt、decrypt。
- 每条记录包括:
- type:digest、sign、encrypt 或 decrypt。
- algorithm:算法名称。
- inputLen:输入字节数。
- outputLen:Promise 完成后的输出字节数。
- outputHex:摘要结果前 32 个十六进制字符,仅用于 digest。
- timestamp:Date.now()。
- stack:调用栈,最多 10 帧。
#### 4.3 序列化事件(serializers[])
- Hook 点:FormData.append、FormData.set、URLSearchParams.append、URLSearchParams.set、URLSearchParams.toString、JSON.stringify。
- 每条记录包括:
- type:序列化方法名。
- name:字段名。
- valueSnippet 或 resultSnippet:截断后的值。
- keys:JSON.stringify 对象的键,最多 20 个。
- timestamp:Date.now()。
- stack:JSON.stringify 和 URLSearchParams.toString 的调用栈。
#### 4.4 选择性 JSON.stringify Hook
- 只有待序列化对象包含目标参数或敏感字段时才记录。
- 敏感字段包括 sign、token、enc、password、signature、hash、key、nonce、timestamp、ts、data、encrypt、decrypt。
- 这样可以避免无关序列化调用造成日志洪泛。
### 读取证据
导出全部证据:
~~~js
evaluate_script("window.__JSRA_TRACE__.dump()")
~~~
返回包含 requests、crypto、serializers、errors 的 JSON 字符串。
清空证据缓存:
~~~js
evaluate_script("window.__JSRA_TRACE__.clear()")
~~~
### 使用证据发现入口
1. 从 requests[].stack 中寻找加密或签名函数调用帧,这些帧指向业务调用者,而不仅是密码库。
2. 与序列化事件交叉比对:如果 serializers[] 显示 JSON.stringify 或 URLSearchParams.toString 处理了关注字段,调用栈可以定位请求体组装位置。
3. 将密码学算法和请求体对应:crypto[] 中的 digest 或 encrypt 事件,应与请求体中的密文长度和算法相匹配。
4. 从调用栈提取最外层业务函数,排除 fetch、XMLHttpRequest 和密码库内部函数,作为候选入口。
### 运行时跟踪的证据标准
- 高置信度:至少一条经过业务函数的 requests[] 调用栈,以及一条与目标参数相关的 crypto[] 或 serializers[] 事件。
- 中置信度:至少一条 bodySnippet 中出现目标参数的 requests[] 记录,即使调用栈未明确指向业务函数。
- 仅有密码库内部调用、没有业务调用者上下文,不能达到高置信度。
### 失败处理
| 现象 | 处理方式 |
|---|---|
| 探针注入失败(被反调试阻断) | 记录反调试现象,参阅反调试规则,降级到静态分析 |
| 触发目标动作后没有 requests[] | 确认动作确实执行,并检查探针是否注入到正确执行上下文 |
| crypto[] 为空但请求体包含密文 | 可能使用非 Subtle 密码库,使用 CryptoJS/JSEncrypt Hook 规则 |
| stack 全部匿名或已混淆 | 使用源码定位规则,将调用栈与 bundle 行号关联 |
### 风险提示
- 探针不能替代网络证据,只能补充网络证据。
- 探针使用软包装,不应替换原函数或破坏页面功能。
- Hook 造成计时变化时,先记录未启用 Hook 的基线,再记录启用后的观察结果。
- 探针不会自动跨页面导航持久化,页面刷新后需要通过 initScript 重新注入。
---
## 5. 跨场景证据标准
### 证据层级
1. 网络证据:请求 URL、方法、请求体、请求头和响应,强度最高。
2. 运行时 Hook 证据:调用栈、密码学事件和序列化事件,强度较高。
3. 源码证据:bundle 位置、对象路径和关键词匹配,仅作为辅助。
### 置信度要求
| 置信度 | 最低证据 |
|---|---|
| high | 网络证据 + 运行时/调用栈/模块证据,至少两类 |
| medium | 单一证据源(网络或运行时) |
| low | 仅有关键词搜索或源码模式 |
### 可计入证据的内容
- 运行时探针产生的 requests[],且调用栈经过业务函数。
- 能将算法和输出长度与实际密文对应的 crypto[] 事件。
- 显示参数在请求发送前被序列化的 serializers[] 事件。
- evaluate_script 捕获并指向业务调用者的 Error().stack。
- 能将明文映射到密文的 Hook 输入/输出对。
- 只改变一个输入值后,能证明哪个请求字段随之变化的实验结果。
### 不可单独计入证据的内容
- 没有运行时关联的源码关键词匹配。
- 没有业务调用者上下文的密码库函数匹配。
- 存在于全局对象上、但目标动作期间从未被观察到调用的路径。
- 没有调用栈、Hook 或网络确认的疑似入口。
### 记录要求
每条证据必须包含:
- 来源类型:网络、Hook、调用栈、序列化、密码学或实验。
- 时间戳或序列号。
- 足以复现观察的上下文:URL、方法、Hook 点路径和调用栈。
- 观察发生时是否启用了补丁或 Hook。

View File

@@ -0,0 +1,26 @@
{
"iterations": [
{"round":1,"hardening":"运行时路由间接调用与入口包装","capability":"全局入口与网络证据关联","result":"8/8"},
{"round":2,"hardening":"字符编码路由重建与不可枚举解析器","capability":"动态路由解析","result":"8/8"},
{"round":3,"hardening":"函数/API 完整性快照与反调试标记","capability":"完整性与外部变化观测","result":"8/8"},
{"round":4,"hardening":"不可写、不可配置入口","capability":"入口补丁受阻时优先使用网络证据降级","result":"8/8"},
{"round":5,"hardening":"CryptoJS AES 与 JSEncrypt 代理层","capability":"组合密码学原语跟踪","result":"8/8"},
{"round":6,"hardening":"HMAC-SHA256 与 DES 代理层","capability":"签名与传统密码算法跟踪","result":"8/8"},
{"round":7,"hardening":"fetch 代理层","capability":"网络 Hook 组合","result":"8/8"},
{"round":8,"hardening":"webdriver、插件、语言和可见性信号","capability":"观测环境信号且避免误阻断","result":"8/8"},
{"round":9,"hardening":"事件循环计时异常检测","capability":"反单步与延迟信号收集","result":"8/8"},
{"round":10,"hardening":"源码/API 指纹与动态干扰项","capability":"保留运行时证据并隔离静态噪声","result":"8/8"}
],
"delivery_regressions": [
{"issue":"fetch 完成后页面函数返回空值","fix":"生成的 JSRPC 在调用期间捕获 fetch 请求和响应"},
{"issue":"最终业务请求前存在多个 fetch","fix":"按规范化后的精确路径匹配目标路由"},
{"issue":"成功导航中断重复测试","fix":"提供可选的 capture.suppress_page_success 测试模式"},
{"issue":"仅使用 XHR 的页面在 JSRPC 输出中缺少最终请求体","fix":"统一 fetch/XHR 捕获、路由匹配、响应解析和请求头记录"},
{"issue":"成功调用后超时计时器仍存活","fix":"Promise.race 结束后清理结果和网络超时计时器"}
],
"limits": [
"suppress_page_success 仅用于测试,不改变捕获到的服务端响应",
"密码学协议强度不变;加固提升的是逆向分析成本",
"验证码、WebAuthn、设备证明和未导出的 WASM 内部函数仍需人工处理或标记为不支持"
]
}

View File

@@ -0,0 +1,121 @@
# 最终报告模板
验证通过!现在输出最终结果。
---
## JS 逆向分析完成  <任务名称>
### 分析结果
- 加密函数:`<函数路径>`
- 加密方式:`<算法/编码/组合>`
- 入口路径:`<页面入口或运行时路径>`
- 置信度:`高`(网络请求 + 运行时 Hook 双重验证)
### 验证结果
| 测试 | 输入 | 输出 |
|---|---|---|
| JSRPC | `<plaintext>` | `<ciphertext/signature>` |
| Flask 代理 | `<original body>` | `<transformed body>` |
### 验证命令
```bash
curl -sS --get \
--data-urlencode group=<group> \
--data-urlencode action=<action> \
--data-urlencode 'param={"parameter":"<parameter>","value":"<plaintext>"}' \
http://127.0.0.1:12080/go
```
预期结果必须包含明文、最终请求体、密文/签名和最终业务响应。
将上述真实返回保存为 `artifacts/jsrpc_smoke.json` 后,必须执行:
```bash
python3 scripts/validate_browser_evidence.py \
--input artifacts/jsrpc_smoke.json \
--output artifacts/browser_evidence.json
```
只有 `browser_evidence.json` 中 `passed=true` 时,才能在报告中写“验证通过”。如果保存了桥接连接、`tabId` 和 `documentId`,追加 `--require-session`。
### Flask 代理验证
```bash
curl --noproxy '*' -sS -X POST http://127.0.0.1:<flask_port>/<route> \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'dataBody=<完整的原始 URL-encoded 请求体>' \
--data-urlencode 'dataHeaders=<可选的请求头 JSON>'
```
预期结果必须返回代理改写后的请求体,其中目标字段已完成加密或签名;如果配置了响应透传,还应包含最终业务响应。
Burp“请求数据包/响应数据包”整包模式还必须单独验证:
```bash
curl --noproxy '*' -sS -X POST http://127.0.0.1:<flask_port>/encode \
-H 'Content-Type: application/octet-stream' \
--data-binary $'POST /path HTTP/1.1\\r\\nHost: target\\r\\nContent-Type: application/x-www-form-urlencoded\\r\\n\\r\\npassword=plaintext'
```
预期返回完整 HTTP 报文,目标字段已替换且 `Content-Length` 已同步。外层 `application/octet-stream` 不代表目标 body 是二进制;代理必须识别内层 HTTP 报文的 Content-Type。
### Burp autoDecoder 页面配置
以下 `<flask_port>` 必须替换为 `artifacts/flask_status.json` 中服务实际返回的 `port`。如果默认端口被占用,服务管理器会自动选择备用端口。
#### 请求体加密
- 加密接口:`http://127.0.0.1:<flask_port>/encode`。
- 可以同时配置加密接口和解密接口;当前测试时选择“请求数据包”,不要同时选择“响应数据包”。两者是互斥方向。
- 原始数据包区域粘贴完整 HTTP 请求,不要只粘贴 body。
- “处理请求头”:仅当签名或加密依赖请求头时勾选,否则不勾选。
- “请求 base64 编码”:只有请求体进入接口前需要 Base64 编码时勾选。
- “请求自动 base64 解码”:只有接口需要先自动还原 Base64 请求体时勾选。
- “Proxy、Repeater 等模块真实调试”:需要在 Burp Proxy/Repeater 联调时勾选,否则不勾选。
#### 响应体解密
- 解密接口:`http://127.0.0.1:<flask_port>/decode`(可与加密接口同时配置)。
- 取消选择“请求数据包”,改为选择“响应数据包”,不能同时选择两个方向。
- 原始数据包区域粘贴完整 HTTP 响应,不要只粘贴 body。
- “处理响应头”:仅当响应解密依赖响应头时勾选,否则不勾选。
- “响应 base64 编码”:只有响应体进入接口前需要 Base64 编码时勾选。
- “响应自动 base64 解码”:只有接口返回 Base64、需要还原二进制响应时勾选。
- 响应方向传递 `requestorresponse=response`;请求方向传递 `requestorresponse=request`。
启用请求头或响应头处理时,接口必须返回 `请求头 + "\\r\\n\\r\\n\\r\\n\\r\\n" + 改写后的请求/响应体`。
### 关闭服务命令
```bash
kill $(lsof -t -i:12080)
kill $(lsof -t -i:<flask_port>)
```
### Burp autoDecoder 配置
- 加密接口:`http://127.0.0.1:<flask_port>/encode`
- 解密接口:`http://127.0.0.1:<flask_port>/decode`(可与加密接口同时配置)
- HTTP 方法:`POST`
- 表单字段:`dataBody`、`dataHeaders`
- 生成后必须通过 `python3 scripts/check_delivery_contract.py --proxy generated/flask_proxy.py --output artifacts/delivery_contract.json`。
- `jsrpc.base_url` 无论配置为 `http://127.0.0.1:12080` 还是带 `/go` 的完整地址,最终请求只能包含一次 `/go`。
### 生成的产物
- `analysis_result.json`
- `generated/jsrpc_inject.js`
- `generated/flask_proxy.py`
- `generated/burp-autodecoder.md`
- `artifacts/validation_report.json`
- `artifacts/browser_evidence.json`
### 对抗分析追加
- 对抗类型:`<反调试/反 Hook/环境检测/动态加载>`
- 探针健康:`<丢失补丁/重建错误/丢弃事件>`
- 差分结论:`<观察结果/干预结果>`

View File

@@ -0,0 +1,71 @@
# 输出契约
## 最终交付格式
最终回复不得退化为“只返回候选 JSON”或“只报告入口已识别”。必须保留以下顺序:
1. `验证通过!现在输出最终结果。`
2. `JS 逆向分析完成` 与任务名称
3. `分析结果`:加密函数、加密方式、入口路径、置信度
4. `验证结果` 表:至少包含 `JSRPC`、`Flask 代理`、输入和输出
5. `验证命令`:可复制的 JSRPC curl、Flask curl,以及预期返回
6. `关闭服务命令`
7. `Burp autoDecoder 配置`
8. `生成的产物`
`对抗分析`、`runtime_health`、`adversarial_diff` 和能力边界只能作为追加章节,不能删除或改写上述交付项。
JSRPC 验证输出必须能证明完整链路:`plaintext`、最终目标 `route`、非空 `requestBody`(密文/签名)、HTTP 状态和最终业务 `response`。捕获层同时覆盖 `fetch` 与 `XMLHttpRequest`;对存在前置取钥匙/取签名请求的函数,必须按规范化 URL 路径选择最终请求。
## 运行时产物
| 产物 | 生成脚本 | 说明 |
|---|---|---|
| `artifacts/probe_dump.json` | `emit_runtime_hook_probe.py` | 运行时事件 |
| `artifacts/module_dump.json` | `emit_module_probe.py` | Webpack 模块 |
| `artifacts/evidence_graph.json` | `build_evidence_graph.py` | 事件关联图 |
| `artifacts/encryption_candidates.json` | `detect_encryption.py` | 候选发现 |
| `artifacts/encryption_candidates.verified.json` | `differential_verifier.py` | 差分验证 |
| `artifacts/static_candidates.json` | `ast_candidate_analyzer.js` | AST 分析 |
| `artifacts/source_analysis.json` | 人工分析 | 源码分析 |
| `artifacts/quarantine.json` | `quarantine.py` | 隔离报告 |
| `artifacts/validation_report.json` | `validate_artifacts.py` | 验证报告 |
| `artifacts/delivery_contract.json` | `check_delivery_contract.py` | JSRPC 地址、Burp 整包和 wrapper 回归报告 |
| `artifacts/browser_evidence.json` | `validate_browser_evidence.py` | action 级真实浏览器/JSRPC 结果验收 |
| `artifacts/adversarial_trace.json` | `adversarial_runtime_probe.js` | 对抗运行时事件、Patch、Realm 和健康状态 |
| `artifacts/adversarial_diff.json` | `adversarial_diff.py` | 基线与干预差分 |
| `artifacts/source_instrumentation_report.json` | `source_instrumentor.js` | 源码级保守插桩报告 |
## 生成产物
| 产物 | 生成脚本 | 说明 |
|---|---|---|
| `generated/jsrpc_inject.js` | `emit_jsrpc_stub.py` | JSRPC 注入 |
| `generated/flask_proxy.py` | `emit_flask_proxy.py` | Flask 代理 |
| `generated/burp-autodecoder.md` | `emit_burp_doc.py` | Burp 文档 |
| `generated/adversarial_runtime_probe.js` | `emit_adversarial_runtime_probe.py` | 对抗运行时探针 |
## analysis_result.json 契约
Schema 层只强制授权和目标信息:
- `authorization.confirmed=true`
- `target.url_pattern`
- `transforms` 或 `parameters` 至少存在一个
最终交付结果还必须尽可能提供以下运行信息;缺失时必须明确标记为
`unsupported`、`manual_observed_only` 或未完成,不能伪造成功:
- `skill`、`input`、`trace`、`parameters`
- `jsrpc`、`flask`、`burp`、`diagnostics`
- `entrypoint_discovery`、`module_runtime`、`invocation`
- `capability_boundary`、`runtime_trace`、`runtime_health`
分析结果不再携带交付版本号;协议兼容性由当前 Schema 和生成器共同定义。
## 候选不变量
- `verified=true` 必须有 verification 证据
- `confidence=high` 必须 `verified=true`
- 所有候选必须标记来源
- 失败验证保留为负证据
## strategy 值
`global_path`、`runtime_hook`、`webpack_export`、`async_crypto`、`initscript_hook`、`static_ast`、`source_analysis`、`adversarial_runtime`、`source_tap`、`manual_observed_only`、`unsupported`

View File

@@ -0,0 +1,9 @@
# 安全模型
- 仅分析授权目标
- 探针默认脱敏(SHA-256 指纹)
- `--capture-raw` 仅在隔离环境使用
- 主动调用需 `safe_to_invoke=true`
- 服务绑定 `127.0.0.1`
- 不自动启动任意可执行文件
- 不终止身份未确认的进程

View File

@@ -0,0 +1,37 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://jsra.local/schemas/adversarial_trace.schema.json",
"title": "JSRA Adversarial Runtime Trace",
"type": "object",
"required": ["state"],
"properties": {
"exportedAt": { "type": "integer", "minimum": 0 },
"state": {
"type": "object",
"required": ["mode", "events", "patches", "realms", "health"],
"properties": {
"mode": { "enum": ["observe", "intervene"] },
"events": { "type": "array", "items": { "$ref": "#/$defs/event" } },
"patches": { "type": "array", "items": { "type": "object" } },
"realms": { "type": "array", "items": { "type": "object" } },
"dropped": { "type": "integer", "minimum": 0 },
"health": { "type": "object" }
},
"additionalProperties": true
}
},
"additionalProperties": true,
"$defs": {
"event": {
"type": "object",
"required": ["event_id", "trace_id", "type", "timestamp"],
"properties": {
"event_id": { "type": "string", "minLength": 1 },
"trace_id": { "type": "string", "minLength": 1 },
"type": { "type": "string", "minLength": 1 },
"timestamp": { "type": "integer", "minimum": 0 }
},
"additionalProperties": true
}
}
}

View File

@@ -0,0 +1,154 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://jsra.local/schemas/analysis_result.schema.json",
"title": "JSRA Analysis Result",
"type": "object",
"required": ["authorization", "target"],
"properties": {
"authorization": {
"type": "object",
"required": [
"confirmed"
],
"properties": {
"confirmed": {
"const": true
},
"scope_note": {
"type": "string"
}
},
"additionalProperties": true
},
"target": {
"type": "object",
"required": [
"url_pattern"
],
"properties": {
"name": {
"type": "string"
},
"url_pattern": {
"type": "string",
"minLength": 1
},
"method": {
"type": "string"
},
"request_content_type": {
"type": "string"
}
},
"additionalProperties": true
},
"jsrpc": {
"type": "object"
},
"verification": {
"type": "object"
},
"evidence_gate": {
"type": "object",
"properties": {
"requires_runtime_proof": { "type": "boolean" },
"requires_e2e": { "type": "boolean" },
"blocked_until": { "type": "array", "items": { "type": "string" } }
},
"additionalProperties": true
},
"provenance": {
"type": "object",
"properties": {
"probe_dump": { "type": "string" },
"module_dump": { "type": "string" },
"evidence_graph": { "type": "string" },
"differential_verification": { "type": "string" }
},
"additionalProperties": true
},
"transforms": {
"type": "array",
"items": {
"type": "object",
"required": [
"id",
"direction",
"location",
"action"
],
"properties": {
"id": {
"type": "string",
"minLength": 1
},
"direction": {
"enum": [
"request",
"response"
]
},
"location": {
"enum": [
"body",
"query",
"header",
"cookie",
"response"
]
},
"content_type": {
"enum": [
"json",
"form",
"raw",
"auto"
]
},
"path": {
"type": "string"
},
"action": {
"type": "string",
"minLength": 1
},
"candidate_path": {
"type": "string"
},
"safe_to_invoke": {
"type": "boolean"
},
"arguments": {
"type": "array"
},
"preserve_order": {
"type": "boolean"
},
"dependencies": {
"type": "array",
"items": {
"type": "string"
}
}
},
"additionalProperties": true
}
},
"parameters": {
"type": "object"
}
},
"anyOf": [
{
"required": [
"transforms"
]
},
{
"required": [
"parameters"
]
}
],
"additionalProperties": true
}

View File

@@ -0,0 +1,82 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"required": ["candidates"],
"properties": {
"candidates": {
"type": "array",
"items": {
"type": "object",
"required": [
"id",
"name",
"scores",
"total_score",
"verified",
"confidence"
],
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"scores": {
"type": "object"
},
"total_score": {
"type": "number"
},
"verified": {
"type": "boolean"
},
"confidence": {
"enum": [
"high",
"medium",
"low"
]
},
"evidence": {
"type": "array"
},
"verification": {
"type": "array"
},
"provenance": {
"type": "object",
"required": [
"probe_dump",
"differential_verification"
],
"properties": {
"probe_dump": { "type": "string", "minLength": 1 },
"module_dump": { "type": "string" },
"evidence_graph": { "type": "string" },
"differential_verification": { "type": "string", "minLength": 1 }
},
"additionalProperties": true
}
},
"additionalProperties": true,
"allOf": [
{
"if": {
"properties": {
"verified": { "const": true }
}
},
"then": {
"required": ["verification"],
"properties": {
"verification": { "minItems": 1 }
}
}
}
]
}
}
},
"additionalProperties": true
}

View File

@@ -0,0 +1,30 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"events": {
"type": "array",
"items": {
"type": "object",
"required": [
"event_id",
"type",
"timestamp"
]
}
},
"requests": {
"type": "array"
},
"crypto": {
"type": "array"
},
"serializers": {
"type": "array"
},
"calls": {
"type": "array"
}
},
"additionalProperties": true
}

View File

@@ -0,0 +1,125 @@
var rpc_client_id, Hlclient = function (wsURL) {
this.wsURL = wsURL;
this.handlers = {
_execjs: function (resolve, param) {
var res = eval(param)
if (!res) {
resolve("没有返回值")
} else {
resolve(res)
}
}
};
this.socket = undefined;
if (!wsURL) {
throw new Error('wsURL can not be empty!!')
}
this.connect()
}
Hlclient.prototype.connect = function () {
if (this.wsURL.indexOf("clientId=") === -1 && rpc_client_id) {
this.wsURL += "&clientId=" + rpc_client_id
}
console.log('begin of connect to wsURL: ' + this.wsURL);
var _this = this;
try {
this.socket = new WebSocket(this.wsURL);
this.socket.onmessage = function (e) {
_this.handlerRequest(e.data)
}
} catch (e) {
console.log("connection failed,reconnect after 10s");
setTimeout(function () {
_this.connect()
}, 10000)
}
this.socket.onclose = function () {
console.log('rpc已关闭');
setTimeout(function () {
_this.connect()
}, 10000)
}
this.socket.addEventListener('open', (event) => {
console.log("rpc连接成功");
});
this.socket.addEventListener('error', (event) => {
console.error('rpc连接出错,请检查是否打开服务端:', event.error);
})
};
Hlclient.prototype.send = function (msg) {
this.socket.send(msg)
}
Hlclient.prototype.regAction = function (func_name, func) {
if (typeof func_name !== 'string') {
throw new Error("an func_name must be string");
}
if (typeof func !== 'function') {
throw new Error("must be function");
}
console.log("register func_name: " + func_name);
this.handlers[func_name] = func;
return true
}
Hlclient.prototype.handlerRequest = function (requestJson) {
var _this = this;
try {
var result = JSON.parse(requestJson)
} catch (error) {
console.log("请求信息解析错误", requestJson);
return
}
if (result["registerId"]) {
rpc_client_id = result['registerId']
return
}
if (!result['action'] || !result["message_id"]) {
console.warn('没有方法或者消息id,不处理');
return
}
var action = result["action"], message_id = result["message_id"]
var theHandler = this.handlers[action];
if (!theHandler) {
this.sendResult(action, message_id, 'action没找到');
return
}
try {
if (!result["param"]) {
theHandler(function (response) {
_this.sendResult(action, message_id, response);
})
return
}
var param = result["param"]
try {
param = JSON.parse(param)
} catch (e) {
// Fix: JSRPC may send param without outer braces
try {
param = JSON.parse('{' + param + '}')
} catch (e2) {
param = { value: param }
}
}
theHandler(function (response) {
_this.sendResult(action, message_id, response);
}, param)
} catch (e) {
console.log("error: " + e);
_this.sendResult(action, message_id, e);
}
}
Hlclient.prototype.sendResult = function (action, message_id, e) {
if (typeof e === 'object' && e !== null) {
try {
e = JSON.stringify(e)
} catch (v) {
console.log(v)//不是json无需操作
}
}
this.send(JSON.stringify({"action": action, "message_id": message_id, "response_data": e}));
}
// Explicitly publish the constructor. DevTools evaluate_script may execute
// this file in an isolated lexical scope; the generated JSRPC stub must still
// be able to construct the client in a later evaluation.
if (typeof window !== 'undefined') window.Hlclient = Hlclient;

View File

@@ -0,0 +1,72 @@
#!/usr/bin/env python3
"""Compare baseline and patched adversarial probe exports.
This turns an intervention into evidence: a patch is useful only when it
produces additional target signals or requests without increasing runtime
errors. It does not claim that a page is bypassed; it reports observable
changes for the authorized test target.
"""
from __future__ import annotations
import argparse
from collections import Counter
from pathlib import Path
from common import dump_json, load_json
def events(value: dict) -> list[dict]:
state = value.get("state") if isinstance(value, dict) else {}
raw = state.get("events", []) if isinstance(state, dict) else []
return [item for item in raw if isinstance(item, dict)]
def summarize(items: list[dict]) -> dict:
types = Counter(str(item.get("type", "unknown")) for item in items)
paths = Counter(str(item.get("path")) for item in items if item.get("path"))
return {
"events": len(items),
"types": dict(sorted(types.items())),
"paths": dict(sorted(paths.items())),
"requests": sum(count for name, count in types.items() if name.startswith("network.")),
"errors": sum(count for name, count in types.items() if "error" in name or "reject" in name),
"interventions": sum(count for name, count in types.items() if name.startswith("intervention.")),
}
def main() -> int:
parser = argparse.ArgumentParser(description="Compare JSRA adversarial probe exports.")
parser.add_argument("--baseline", required=True)
parser.add_argument("--patched", required=True)
parser.add_argument("--output", required=True)
args = parser.parse_args()
before = summarize(events(load_json(Path(args.baseline), {})))
after = summarize(events(load_json(Path(args.patched), {})))
new_types = sorted(set(after["types"]) - set(before["types"]))
new_paths = sorted(set(after["paths"]) - set(before["paths"]))
errors_not_increased = after["errors"] <= before["errors"]
evidence_increased = after["events"] > before["events"] or after["requests"] > before["requests"]
result = {
"baseline": before,
"patched": after,
"delta": {
"events": after["events"] - before["events"],
"requests": after["requests"] - before["requests"],
"errors": after["errors"] - before["errors"],
"new_types": new_types,
"new_paths": new_paths,
},
"verdict": "improved" if evidence_increased and errors_not_increased else "inconclusive",
"limitations": [
"This is an observable-difference report, not proof of bypass or protocol success.",
"A real target request and business success condition still require independent verification.",
],
}
dump_json(Path(args.output), result)
print(f"[OK] adversarial diff: {args.output} verdict={result['verdict']}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,134 @@
#!/usr/bin/env node
"use strict";
/**
* Static AST candidate analyzer for JavaScript bundles.
*
* Parses JS files using Babel (with fallback regex parser) to find function
* declarations matching crypto-related keywords.
*
* Usage:
* node scripts/ast_candidate_analyzer.js --input bundle.js --output artifacts/static_candidates.json
*/
const fs = require("fs");
const path = require("path");
let babelParser = null;
try { babelParser = require("@babel/parser"); } catch (_) {}
function args() {
const out = {};
for (let i = 2; i < process.argv.length; i += 2)
out[process.argv[i].replace(/^--/, "")] = process.argv[i + 1];
if (!out.input || !out.output)
throw new Error("Usage: ast_candidate_analyzer.js --input bundle.js --output static_candidates.json");
return out;
}
const KEYWORDS = /encrypt|decrypt|cipher|sign|hmac|digest|hash|rsa|aes|des|sm2|sm3|sm4|md5|sha|base64|crypto/i;
function sourceSnippet(code, start, end) {
return code.slice(start || 0, Math.min(end || code.length, (start || 0) + 1200));
}
function locationFromOffset(code, offset) {
const before = code.slice(0, offset);
const lines = before.split("\n");
return { line: lines.length, column: lines[lines.length - 1].length };
}
function signalsFor(name, snippet) {
const signals = [];
if (KEYWORDS.test(name)) signals.push("name_keyword");
if (KEYWORDS.test(snippet)) signals.push("source_keyword");
if (/crypto\.subtle|CryptoJS|JSEncrypt|sm-crypto|jsrsasign|forge/i.test(snippet))
signals.push("known_library");
if (/publicKey|privateKey|BEGIN PUBLIC KEY|BEGIN RSA/i.test(snippet))
signals.push("key_material");
return signals;
}
function addCandidate(map, code, name, start, end, mode) {
const snippet = sourceSnippet(code, start, end);
const signals = signalsFor(name, snippet);
if (!signals.length) return;
const loc = locationFromOffset(code, start);
const key = `${name}:${loc.line}:${loc.column}`;
map.set(key, {
id: `static:${key}`, name, path: name, type: "function",
location: loc, signals, source_snippet: snippet, parser_mode: mode
});
}
function walk(node, parent, visit) {
if (!node || typeof node !== "object") return;
if (typeof node.type === "string") visit(node, parent);
for (const [key, value] of Object.entries(node)) {
if (["loc", "start", "end", "extra", "tokens", "comments"].includes(key)) continue;
if (Array.isArray(value)) value.forEach(child => walk(child, node, visit));
else if (value && typeof value === "object") walk(value, node, visit);
}
}
function nameOf(node, parent) {
if (node.id && node.id.name) return node.id.name;
if (parent && parent.type === "VariableDeclarator" && parent.id && parent.id.name)
return parent.id.name;
if (parent && /ObjectProperty|ObjectMethod|ClassMethod/.test(parent.type || ""))
return parent.key && (parent.key.name || parent.key.value) || "anonymous";
return "anonymous";
}
function analyzeWithBabel(code, map) {
const ast = babelParser.parse(code, {
sourceType: "unambiguous",
errorRecovery: true,
plugins: ["jsx", "typescript", "classProperties", "optionalChaining", "dynamicImport", "decorators-legacy"]
});
walk(ast, null, (node, parent) => {
if (!["FunctionDeclaration", "FunctionExpression", "ArrowFunctionExpression",
"ObjectMethod", "ClassMethod"].includes(node.type)) return;
addCandidate(map, code, nameOf(node, parent), node.start, node.end, "babel");
});
}
function analyzeFallback(code, map) {
const patterns = [
/function\s+([A-Za-z_$][\w$]*)\s*\([^)]*\)\s*\{/g,
/(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?(?:function\s*)?\([^)]*\)\s*=>?\s*\{/g,
/([A-Za-z_$][\w$]*)\s*:\s*(?:async\s*)?function\s*\([^)]*\)\s*\{/g
];
for (const pattern of patterns) {
let match;
while ((match = pattern.exec(code)))
addCandidate(map, code, match[1], match.index, Math.min(code.length, match.index + 3000), "fallback-regex");
}
}
function main() {
const opt = args();
const code = fs.readFileSync(opt.input, "utf8");
const map = new Map();
if (babelParser) analyzeWithBabel(code, map);
else analyzeFallback(code, map);
const result = {
input: path.resolve(opt.input),
parser_mode: babelParser ? "babel" : "fallback-regex",
candidates: Array.from(map.values())
};
fs.mkdirSync(path.dirname(opt.output), { recursive: true });
fs.writeFileSync(opt.output, JSON.stringify(result, null, 2) + "\n");
console.log(JSON.stringify({
status: "ok",
parser_mode: result.parser_mode,
candidates: result.candidates.length,
output: opt.output
}));
}
try { main(); }
catch (error) { console.error(String(error.stack || error)); process.exit(2); }

View File

@@ -0,0 +1,131 @@
#!/usr/bin/env python3
"""Build a lightweight JSRA evidence graph from probe and module dumps.
Creates nodes (events) and edges (parent-child, fingerprint_flow, temporal)
to correlate producers and consumers via matching SHA-256 fingerprints.
Usage:
python3 scripts/build_evidence_graph.py \
--probe artifacts/probe_dump.json \
--modules artifacts/module_dump.json \
--output artifacts/evidence_graph.json
"""
from __future__ import annotations
import argparse
from collections import defaultdict
from common import dump_json, flatten_events, load_json
def main() -> int:
parser = argparse.ArgumentParser(description="Build a lightweight JSRA evidence graph.")
parser.add_argument("--probe", required=True, help="Path to probe_dump.json.")
parser.add_argument("--modules", required=False, help="Path to module_dump.json.")
parser.add_argument("--output", required=True, help="Path to output evidence_graph.json.")
args = parser.parse_args()
probe = load_json(args.probe, {})
modules = load_json(args.modules, {}) if args.modules else {}
events = flatten_events(probe)
nodes = []
edges = []
by_trace = defaultdict(list)
producers = defaultdict(list)
consumers = defaultdict(list)
for event in events:
event_id = str(event.get("event_id"))
trace_id = str(event.get("trace_id") or "unscoped")
nodes.append({"id": event_id, "kind": "event", "data": event})
by_trace[trace_id].append(event)
parent = event.get("parent_event_id")
if parent:
edges.append({"from": str(parent), "to": event_id, "type": "parent"})
if event.get("output_fingerprint"):
producers[event["output_fingerprint"]].append(event_id)
if event.get("input_fingerprint"):
consumers[event["input_fingerprint"]].append(event_id)
# Fingerprint flow edges (producer -> consumer). A runtime hook often
# creates a new trace id for the network call after the crypto call has
# completed, so requiring equal trace ids loses the most useful edges.
# Pair each consumer with the nearest preceding producer instead. Exact
# fingerprints remain the proof signal; the time relation only disambiguates
# repeated values and prevents future-to-past edges.
event_ids = {str(event.get("event_id")) for event in events}
for fp, source_ids in producers.items():
source_events = [
next((event for event in events if str(event.get("event_id")) == source_id), {})
for source_id in source_ids
if source_id in event_ids
]
for target_id in consumers.get(fp, []):
if target_id not in event_ids:
continue
target_event = next((e for e in events if str(e.get("event_id")) == target_id), {})
target_time = target_event.get("timestamp")
preceding = [
event for event in source_events
if str(event.get("event_id")) != target_id
and (
not isinstance(target_time, (int, float))
or not isinstance(event.get("timestamp"), (int, float))
or event.get("timestamp") <= target_time
)
]
if not preceding:
continue
source_event = max(
preceding,
key=lambda event: event.get("timestamp", 0)
if isinstance(event.get("timestamp"), (int, float)) else 0,
)
source_id = str(source_event.get("event_id"))
source_time = source_event.get("timestamp")
delta = None
if isinstance(source_time, (int, float)) and isinstance(target_time, (int, float)):
delta = max(0, target_time - source_time)
edges.append({
"from": source_id, "to": target_id,
"type": "fingerprint_flow",
"fingerprint": fp,
"trace_id": source_event.get("trace_id"),
"target_trace_id": target_event.get("trace_id"),
"same_trace": source_event.get("trace_id") == target_event.get("trace_id"),
"time_delta_ms": delta,
})
# Temporal edges (within same trace)
for trace_id, trace_events in by_trace.items():
ordered = sorted(trace_events, key=lambda e: e.get("timestamp", 0))
for left, right in zip(ordered, ordered[1:]):
edges.append({
"from": str(left.get("event_id")),
"to": str(right.get("event_id")),
"type": "temporal",
"trace_id": trace_id
})
# Module/globals as candidate source nodes
for item in (modules.get("modules") or []) + (modules.get("globals") or []):
node_id = str(item.get("id") or item.get("path") or item.get("name"))
nodes.append({"id": node_id, "kind": "candidate_source", "data": item})
result = {
"nodes": nodes,
"edges": edges,
"stats": {
"events": len(events),
"traces": len(by_trace),
"fingerprint_flows": sum(1 for e in edges if e["type"] == "fingerprint_flow")
},
}
dump_json(args.output, result)
print(f"[OK] evidence graph: {args.output} ({result['stats']})")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,111 @@
#!/usr/bin/env python3
"""回归检查生成的 JSRPC/Flask/Burp 交付契约。"""
from __future__ import annotations
import argparse
import importlib.util
import json
from pathlib import Path
from typing import Any
from urllib.parse import urlencode
def load_module(path: Path):
spec = importlib.util.spec_from_file_location("jsra_delivery_contract_proxy", path)
if spec is None or spec.loader is None:
raise RuntimeError(f"无法加载代理:{path}")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def check_endpoint_normalization(module) -> dict[str, Any]:
calls: list[str] = []
class FakeResponse:
def raise_for_status(self) -> None:
return None
def json(self) -> dict[str, Any]:
return {"data": {"requestBody": "loginId=admin&password2=ENC"}}
def fake_get(url: str, **_: Any) -> FakeResponse:
calls.append(url)
return FakeResponse()
original_get = module.requests.get
try:
module.requests.get = fake_get
transform = module.CONFIG["transforms"][0]
module.jsrpc_call(transform.get("action", module.CONFIG["jsrpc"]["action"]), "123456", transform)
finally:
module.requests.get = original_get
if len(calls) != 1 or not calls[0].endswith("/go") or calls[0].endswith("/go/go"):
raise AssertionError(f"JSRPC endpoint 归一化失败:{calls}")
return {"ok": True, "called": calls[0]}
def check_packet_modes(module) -> dict[str, Any]:
transform = module.CONFIG["transforms"][0]
path = str(transform.get("path") or transform.get("id") or "password")
field = path.removeprefix("$.").split(".")[-1]
if transform.get("delivery_mode") == "request_body":
fake_result = f"loginId=admin&{field}=ENC(123456)"
expected_body = fake_result
else:
fake_result = "ENC(123456)"
expected_body = f"loginId=admin&{urlencode({field: 'ENC(123456)'})}"
module.jsrpc_call = lambda _action, _value, _transform: fake_result
client = module.app.test_client()
raw_packet = (
"POST /newlogin/login.do?appName=arena&fromSite=77 HTTP/1.1\r\n"
"Host: 127.0.0.1:8123\r\n"
"Content-Type: application/x-www-form-urlencoded\r\n"
"Connection: close\r\n\r\n"
f"loginId=admin&{field}=123456"
)
packet_response = client.post("/encode", data=raw_packet, content_type="application/octet-stream")
packet_text = packet_response.get_data(as_text=True)
if packet_response.status_code != 200:
raise AssertionError(f"整包模式失败:HTTP {packet_response.status_code}: {packet_text}")
if expected_body not in packet_text:
raise AssertionError(f"整包模式没有替换 body:{packet_text}")
expected_length = len(expected_body.encode())
if f"Content-Length: {expected_length}" not in packet_text:
raise AssertionError(f"整包模式没有同步 Content-Length:{packet_text}")
wrapper_response = client.post(
"/encode",
data={"dataBody": f"loginId=admin&{field}=123456", "requestorresponse": "request"},
content_type="application/x-www-form-urlencoded",
)
wrapper_text = wrapper_response.get_data(as_text=True)
expected_wrapper = expected_body
if wrapper_response.status_code != 200 or wrapper_text != expected_wrapper:
raise AssertionError(f"dataBody wrapper 失败:HTTP {wrapper_response.status_code}: {wrapper_text}")
return {"ok": True, "packet_status": packet_response.status_code, "wrapper_status": wrapper_response.status_code}
def main() -> int:
parser = argparse.ArgumentParser(description="Validate generated Flask delivery contracts.")
parser.add_argument("--proxy", required=True, help="Generated flask_proxy.py path.")
parser.add_argument("--output", required=True, help="JSON report path.")
args = parser.parse_args()
module = load_module(Path(args.proxy))
result = {
"passed": True,
"proxy": str(Path(args.proxy).resolve()),
"endpoint_normalization": check_endpoint_normalization(module),
"packet_modes": check_packet_modes(module),
"live_action_required": True,
}
output = Path(args.output)
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(json.dumps(result, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,123 @@
#!/usr/bin/env python3
"""Validate and normalize Phase 0 input for the JS reverse automation skill.
Usage:
python3 scripts/check_inputs.py --input <raw> --output artifacts/phase0_input.json
python3 scripts/check_inputs.py --analysis analysis_result.json --schema schemas/analysis_result.schema.json --output artifacts/input_validation.json
"""
from __future__ import annotations
import argparse
import json
from pathlib import Path
from urllib.parse import urlparse
from common import dump_json, load_json
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser()
parser.add_argument("--input", help="Path to raw input JSON (Phase 0 mode).")
parser.add_argument("--analysis", help="Path to analysis_result.json (schema validation mode).")
parser.add_argument("--schema", default="", help="Path to JSON schema for analysis validation.")
parser.add_argument("--output", required=True, help="Path to output JSON.")
return parser.parse_args()
def ensure_http_url(value: str) -> str:
parsed = urlparse(value)
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
raise ValueError("target_url must be an absolute http or https URL")
return value
def normalize_parameters(value: object) -> list[str]:
if isinstance(value, str):
items = [part.strip() for part in value.replace("/", ",").split(",")]
elif isinstance(value, list):
items = [str(part).strip() for part in value]
else:
raise ValueError("parameters must be a string or a list of strings")
normalized: list[str] = []
seen: set[str] = set()
for item in items:
if not item:
continue
if item not in seen:
seen.add(item)
normalized.append(item)
if not normalized:
raise ValueError("parameters must not be empty")
return normalized
def validate_phase0(raw: dict) -> dict:
"""Validate and normalize Phase 0 input."""
missing = [field for field in ("target_url", "parameters") if not raw.get(field)]
if missing:
raise ValueError(f"missing required fields: {', '.join(missing)}")
return {
"target_url": ensure_http_url(str(raw["target_url"]).strip()),
"parameters": normalize_parameters(raw["parameters"]),
"fetch_example": str(raw.get("fetch_example", "")).strip(),
"notes": raw.get("notes", []),
}
def validate_analysis_schema(analysis: dict, schema_path: str) -> list[str]:
"""Validate analysis_result.json against JSON schema if jsonschema is available."""
if not schema_path:
return []
try:
from jsonschema import Draft202012Validator
schema = load_json(schema_path)
errors = [e.message for e in Draft202012Validator(schema).iter_errors(analysis)]
return errors
except ImportError:
return ["jsonschema not installed; skipping schema validation"]
except FileNotFoundError:
return [f"schema file not found: {schema_path}"]
def main() -> int:
args = parse_args()
output_path = Path(args.output)
# Phase 0 mode: validate raw input
if args.input:
raw = load_json(Path(args.input))
normalized = validate_phase0(raw)
dump_json(output_path, normalized)
print(json.dumps({"status": "ok", "output": str(output_path)}, ensure_ascii=False))
return 0
# Schema validation mode: validate analysis_result.json
if args.analysis:
analysis = load_json(Path(args.analysis))
# Check authorization.confirmed if present
auth = analysis.get("authorization", {})
if auth and not auth.get("confirmed"):
print("[ERROR] authorization.confirmed must be true")
return 2
schema_path = args.schema or str(Path(__file__).parents[1] / "schemas" / "analysis_result.schema.json")
errors = validate_analysis_schema(analysis, schema_path)
if errors:
for error in errors:
print(f"[ERROR] {error}")
return 2
dump_json(output_path, {"status": "ok", "analysis": analysis})
print(f"[OK] input validated: {output_path}")
return 0
print("[ERROR] either --input or --analysis must be specified")
return 1
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,160 @@
#!/usr/bin/env python3
"""Anti-crawl type classifier.
Classifies the target website's anti-crawl mechanism into one of three types:
1. Signature-type: environment IS the signature (e.g., RS/Akamai, 412 challenges)
2. Behavioral-type: parameter signing + interceptors (e.g., TikTok a_bogus)
3. Pure obfuscation: just hard to read, no environment detection
Usage:
python3 scripts/classify_anticrawl.py --probe artifacts/probe_dump.json --analysis analysis_result.json --output artifacts/anticrawl_type.json
"""
from __future__ import annotations
import argparse
from common import dump_json, flatten_events, load_json
def classify(events: list[dict], analysis: dict) -> dict:
"""Classify anti-crawl type based on probe events and analysis."""
result = {
"type": "unknown",
"confidence": "low",
"evidence": [],
"recommended_strategy": "runtime_hook",
"capabilities": [],
"event_ids": []
}
# Adversarial runtime signals. These are evidence of observed
# behavior, not proof that a particular vendor or challenge was identified.
# The probe itself calls Function#toString, descriptor APIs and property
# getters while installing hooks. Those self-generated events must not be
# promoted to a target anti-debug verdict.
self_generated = {
"patch.install", "probe.ready", "probe.restored",
"environment.watch_installed", "patch.reconciled",
}
adversarial = []
explicit_adversarial = []
observation_only = []
for event in events:
etype = str(event.get("type", ""))
path = str(event.get("path", ""))
if etype in self_generated or etype.startswith("integrity."):
continue
if etype.startswith("patch.lost"):
adversarial.append(("hook_integrity_check", event))
explicit_adversarial.append(("hook_integrity_check", event))
if etype.startswith("intervention."):
adversarial.append(("dynamic_code", event))
explicit_adversarial.append(("dynamic_code", event))
elif etype.startswith("dynamic."):
# Dynamic-code hooks are useful observations, but a call to the
# wrapper alone is not evidence that the target is fighting us.
observation_only.append(("dynamic_code_observed", event))
if etype.startswith("environment.property_read"):
adversarial.append(("environment_property", event))
if etype.startswith("realm.") or etype.startswith("loader."):
adversarial.append(("dynamic_realm_or_loader", event))
if "webdriver" in path or "canvas" in path or "webgl" in path:
adversarial.append(("fingerprint_property", event))
# Check for signature-type indicators
signature_indicators = []
for event in events:
etype = event.get("type", "")
# 412 challenge patterns
if "412" in str(event.get("url", "")):
signature_indicators.append("412_challenge")
# Environment fingerprinting
if "navigator" in str(event.get("function", "")).lower():
signature_indicators.append("navigator_access")
if "canvas" in str(event.get("function", "")).lower():
signature_indicators.append("canvas_fingerprint")
if "webgl" in str(event.get("function", "")).lower():
signature_indicators.append("webgl_fingerprint")
# Check for behavioral-type indicators
behavioral_indicators = []
for event in events:
etype = event.get("type", "")
# XHR/fetch with signing
if etype.startswith("network."):
body = str(event.get("input_preview", ""))
if any(kw in body.lower() for kw in ["x-bogus", "a_bogus", "wmsdk", "sign", "signature"]):
behavioral_indicators.append("signing_parameter")
# Crypto library usage
if etype.startswith("crypto."):
behavioral_indicators.append("crypto_operation")
# Check for obfuscation indicators
obfuscation_indicators = []
# Check if there are many nested function calls
if len(events) > 100:
obfuscation_indicators.append("high_event_count")
# Check for eval/Function usage
for event in events:
if "eval" in str(event.get("function", "")).lower():
obfuscation_indicators.append("eval_usage")
adversarial_names = sorted({name for name, _ in adversarial})
result["capabilities"] = adversarial_names
result["event_ids"] = [str(event.get("event_id")) for _, event in adversarial[:100] if event.get("event_id")]
result["observation_only"] = sorted({name for name, _ in observation_only})
result["probe_self_events_excluded"] = True
# Determine type
if signature_indicators:
result["type"] = "signature"
result["confidence"] = "high" if len(signature_indicators) >= 2 else "medium"
result["evidence"] = signature_indicators
result["recommended_strategy"] = "environment_masquerade"
elif behavioral_indicators:
result["type"] = "behavioral"
result["confidence"] = "high" if len(behavioral_indicators) >= 2 else "medium"
result["evidence"] = behavioral_indicators
result["recommended_strategy"] = "algorithm_tracking"
elif obfuscation_indicators:
result["type"] = "obfuscation"
result["confidence"] = "medium"
result["evidence"] = obfuscation_indicators
result["recommended_strategy"] = "runtime_hook"
else:
result["type"] = "simple"
result["confidence"] = "medium"
result["evidence"] = ["no_complex_indicators"]
result["recommended_strategy"] = "runtime_hook"
# Prefer the more specific capability when the adversarial probe produced
# stronger evidence than the legacy keyword classifier.
if explicit_adversarial:
result["type"] = "adversarial_runtime"
result["confidence"] = "high" if len(adversarial_names) >= 2 else "medium"
result["evidence"] = sorted(set(result["evidence"] + adversarial_names))[:50]
result["recommended_strategy"] = (
"source_tap" if "dynamic_code" in adversarial_names else "minimal_intervention"
)
return result
def main() -> int:
parser = argparse.ArgumentParser(description="Anti-crawl type classifier.")
parser.add_argument("--probe", required=True, help="Path to probe_dump.json.")
parser.add_argument("--analysis", help="Path to analysis_result.json.")
parser.add_argument("--output", required=True, help="Path to output JSON.")
args = parser.parse_args()
probe = load_json(args.probe, {})
analysis = load_json(args.analysis, {}) if args.analysis else {}
events = flatten_events(probe)
result = classify(events, analysis)
dump_json(args.output, result)
print(f"[OK] anti-crawl type: {result['type']} (confidence: {result['confidence']})")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,137 @@
#!/usr/bin/env python3
"""Shared utilities for JSRA scripts.
Provides JSON I/O with atomic writes, SHA-256 fingerprinting, sensitive data
redaction, probe dump event flattening, keyword scoring, and stack iteration.
"""
from __future__ import annotations
import hashlib
import json
import re
from pathlib import Path
from typing import Any, Iterable
SENSITIVE_KEYS = re.compile(
r"pass(word)?|token|secret|authorization|cookie|session|credential", re.I
)
KEYWORDS_HIGH = ("encrypt", "decrypt", "cipher", "sign", "hmac", "digest", "hash")
KEYWORDS_MEDIUM = ("rsa", "aes", "des", "sm2", "sm3", "sm4", "md5", "sha", "base64", "crypto")
KEYWORDS_LOW = ("encode", "decode", "token", "nonce", "secret")
def load_json(path: str | Path, default: Any = None) -> Any:
"""Load JSON from *path*, returning *default* if the file is missing."""
p = Path(path)
if not p.exists():
if default is not None:
return default
raise FileNotFoundError(p)
with p.open("r", encoding="utf-8") as fh:
return json.load(fh)
def dump_json(path: str | Path, value: Any) -> None:
"""Atomically write *value* as JSON to *path* (via rename)."""
p = Path(path)
p.parent.mkdir(parents=True, exist_ok=True)
temp = p.with_suffix(p.suffix + ".tmp")
with temp.open("w", encoding="utf-8") as fh:
json.dump(value, fh, ensure_ascii=False, indent=2)
fh.write("\n")
temp.replace(p)
def fingerprint(value: Any) -> str:
"""Return ``sha256:<hex>`` for the canonical JSON of *value*."""
if not isinstance(value, str):
value = json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
return "sha256:" + hashlib.sha256(value.encode("utf-8", errors="replace")).hexdigest()
def redact(value: Any, key: str = "", max_len: int = 96) -> Any:
"""Recursively redact sensitive fields and truncate long strings."""
if SENSITIVE_KEYS.search(key):
return "<redacted>"
if isinstance(value, dict):
return {str(k): redact(v, str(k), max_len) for k, v in value.items()}
if isinstance(value, list):
return [redact(v, key, max_len) for v in value[:20]]
text = str(value)
if len(text) > max_len:
text = text[:max_len] + "…"
return text
def flatten_events(probe: dict) -> list[dict]:
"""Normalise legacy probe dump collections into a flat event list.
Older probes may store events in separate lists (``requests``, ``crypto``,
``serializers``, ``calls``, ``encoders``). Current probes use a single
``events`` list with a ``type`` field. This function merges both formats
so downstream code always sees a flat list.
"""
# Runtime probes store events at the top level, while the adversarial
# probe exports them under ``state.events``. Accept both forms so the
# classifier, graph builder and candidate detector see the same evidence.
events: list[dict] = []
seen_ids: set[str] = set()
def add_events(items: object) -> None:
if not isinstance(items, list):
return
for raw in items:
if not isinstance(raw, dict):
continue
event = dict(raw)
event_id = str(event.get("event_id") or "")
if event_id and event_id in seen_ids:
continue
if event_id:
seen_ids.add(event_id)
events.append(event)
add_events(probe.get("events"))
state = probe.get("state")
if isinstance(state, dict):
add_events(state.get("events"))
mapping = {
"requests": "network.request",
"crypto": "crypto.operation",
"serializers": "serializer.operation",
"calls": "function.call",
"encoders": "encoding.operation",
}
for collection, default_type in mapping.items():
for index, raw in enumerate(probe.get(collection) or []):
event = dict(raw)
event.setdefault("type", default_type)
event.setdefault("event_id", f"legacy-{collection}-{index}")
event.setdefault("timestamp", index)
add_events([event])
return events
def keyword_score(text: str) -> float:
"""Score *text* against crypto-related keyword tiers."""
lower = (text or "").lower()
if any(k in lower for k in KEYWORDS_HIGH):
return 1.0
if any(k in lower for k in KEYWORDS_MEDIUM):
return 0.7
if any(k in lower for k in KEYWORDS_LOW):
return 0.35
return 0.0
def iter_stack_lines(event: dict) -> Iterable[str]:
"""Yield stack lines from an event, normalising dict/str/list formats."""
stack = event.get("stack") or event.get("stack_lines") or []
if isinstance(stack, str):
yield from stack.splitlines()
elif isinstance(stack, list):
for line in stack:
if isinstance(line, dict):
yield " ".join(str(line.get(k, "")) for k in ("function", "url", "line", "column"))
else:
yield str(line)

View File

@@ -0,0 +1,259 @@
#!/usr/bin/env python3
"""Evidence-driven encryption/signature candidate discovery.
Consumes runtime events, Webpack exports, globals, optional static AST results,
and manual hints. Scores candidates across 8 dimensions and tracks verification
status.
Usage:
python3 scripts/detect_encryption.py \
--probe-artifacts artifacts/probe_dump.json \
--module-artifacts artifacts/module_dump.json \
--evidence-graph artifacts/evidence_graph.json \
--analysis analysis_result.json \
--output artifacts/encryption_candidates.json
"""
from __future__ import annotations
import argparse
import re
from collections import defaultdict
from typing import Any
from common import dump_json, flatten_events, iter_stack_lines, keyword_score, load_json
STACK_FN = re.compile(r"(?:at\s+)?([\w$.[\]<>-]+)\s*(?:\(|@)")
def canonical_id(source: str, name: str, module_id: str = "") -> str:
clean = re.sub(r"[^a-zA-Z0-9_.:$\-]+", "_", name or "anonymous")
return f"{source}:{module_id}:{clean}" if module_id else f"{source}:{clean}"
def ensure_candidate(store: dict[str, dict], cid: str, name: str, source: str, **extra: Any) -> dict:
candidate = store.setdefault(cid, {
"id": cid, "name": name or "anonymous", "source": source, "type": extra.get("type", "function"),
"module_id": extra.get("module_id"), "path": extra.get("path") or name,
"source_snippet": extra.get("source_snippet", ""), "evidence": [], "scores": {},
"verified": False, "verification": [],
})
for key, value in extra.items():
if value not in (None, "", []):
candidate[key] = value
return candidate
def build_candidates(probe: dict, modules: dict, graph: dict, analysis: dict) -> list[dict]:
store: dict[str, dict] = {}
events = flatten_events(probe)
event_by_id = {str(e.get("event_id")): e for e in events}
fingerprint_edges = [e for e in graph.get("edges", []) if e.get("type") == "fingerprint_flow"]
# === Runtime functions and stack frames ===
for event in events:
function = event.get("function") or event.get("name")
event_type = str(event.get("type", ""))
if function and not event_type.startswith("network."):
cid = canonical_id("runtime", str(function))
role = "primitive" if event_type.startswith(("crypto.", "encoding.", "serializer.")) else "entrypoint"
c = ensure_candidate(store, cid, str(function), "runtime", role=role)
c["evidence"].append({
"type": "runtime_event",
"event_id": event.get("event_id"),
"event_type": event.get("type"),
"trace_id": event.get("trace_id")
})
if event.get("output_fingerprint"):
c.setdefault("output_fingerprints", []).append(event["output_fingerprint"])
# Stack frame extraction
for line in iter_stack_lines(event):
match = STACK_FN.search(line)
if match:
name = match.group(1)
if name in ("Error", "record", "wrapped"):
continue
cid = canonical_id("stack", name)
c = ensure_candidate(store, cid, name, "stack")
c["evidence"].append({
"type": "runtime_stack",
"event_id": event.get("event_id"),
"line": line[:300]
})
# === Real Webpack exports ===
for item in modules.get("modules", []) or []:
name = item.get("export_path") or item.get("name") or "anonymous"
cid = canonical_id("webpack", name, str(item.get("module_id", "unknown")))
module_id = str(item.get("module_id", ""))
export_path = item.get("export_path") or name
webpack_source = str(item.get("source") or "default")
runtime_path = f"webpack:{webpack_source}:{module_id}:{export_path}"
ensure_candidate(
store, cid, name, "webpack",
type="webpack_export", module_id=module_id,
path=runtime_path, export_path=export_path,
source_snippet=item.get("source_snippet", "")
)["evidence"].append({"type": "module_export", "source": item.get("source")})
# === Global functions ===
for item in modules.get("globals", []) or []:
name = item.get("name") or item.get("path") or "anonymous"
cid = canonical_id("global", name)
ensure_candidate(
store, cid, name, "global",
path=item.get("path"), source_snippet=item.get("source_snippet", "")
)["evidence"].append({"type": "global_function"})
# === Optional static AST candidates ===
for item in modules.get("static", []) or []:
name = item.get("name") or item.get("path") or "anonymous"
cid = canonical_id("static", name)
ensure_candidate(
store, cid, name, "static",
path=item.get("path") or name,
source_snippet=item.get("source_snippet", ""),
location=item.get("location")
)["evidence"].append({"type": "static_ast", "signals": item.get("signals", [])})
# === Manual hints (backward-compatible, evidence only) ===
for param_name, config in (analysis.get("parameters") or {}).items():
entry = config.get("entrypoint") or {}
name = entry.get("path")
if name:
cid = canonical_id("hint", name)
c = ensure_candidate(
store, cid, name, "hint",
type=entry.get("type", "hint"), path=name,
source_snippet=entry.get("source_hint", "")
)
c["parameter"] = param_name
c["evidence"].append({"type": "manual_hint", "details": entry.get("evidence", [])})
# === Cross-source name correlation ===
normalized = defaultdict(list)
for c in store.values():
normalized[re.sub(r"[^a-z0-9]", "", c["name"].lower())].append(c)
for group in normalized.values():
if len(group) > 1:
sources = sorted({c["source"] for c in group})
for c in group:
c["evidence"].append({"type": "cross_source_name", "sources": sources})
# === Fingerprint flow correlation ===
# The graph builder may correlate across trace ids when the crypto output
# is later serialized into a request field. Promote that relationship to
# candidate evidence so request_correlation is not silently stuck at zero.
graph_flows_by_source: defaultdict[str, list[dict]] = defaultdict(list)
for edge in fingerprint_edges:
source_id = str(edge.get("from") or "")
target_id = str(edge.get("to") or "")
source_event = event_by_id.get(source_id, {})
target_event = event_by_id.get(target_id, {})
target_type = str(target_event.get("type") or "")
if source_id and target_id and target_type.startswith(("network.", "serializer.")):
graph_flows_by_source[source_id].append(edge)
for candidate in store.values():
source_event_ids = {
str(item.get("event_id"))
for item in candidate.get("evidence", [])
if item.get("type") == "runtime_event" and item.get("event_id")
}
flow_edges = [
edge for event_id in source_event_ids
for edge in graph_flows_by_source.get(event_id, [])
]
if flow_edges:
candidate["evidence"].append({
"type": "fingerprint_flow",
"flows": [
{
"from": edge.get("from"),
"to": edge.get("to"),
"fingerprint": edge.get("fingerprint"),
"same_trace": edge.get("same_trace"),
"time_delta_ms": edge.get("time_delta_ms"),
}
for edge in flow_edges[:20]
],
})
candidate["_has_fingerprint_flow"] = bool(flow_edges)
# === Score each candidate ===
for c in store.values():
evidence_types = [e.get("type") for e in c["evidence"]]
runtime_count = evidence_types.count("runtime_event") + evidence_types.count("runtime_stack")
network_correlation = (
1.0 if c["verified"]
else (0.8 if c.get("_has_fingerprint_flow") else (0.5 if runtime_count and any(
str(e.get("event_type", "")).startswith("network") for e in c["evidence"]
) else 0.0))
)
scores = {
"name": keyword_score(c["name"]),
"source_keyword": keyword_score(c.get("source_snippet", "")),
"runtime_stack": min(1.0, runtime_count / 3),
"request_correlation": network_correlation,
"input_output_flow": 1.0 if c["verified"] else (
0.6 if c.get("_has_fingerprint_flow") else (0.3 if c.get("output_fingerprints") else 0.0)
),
"module_export": 1.0 if c["source"] == "webpack" else (0.6 if c["source"] == "global" else 0.0),
"cross_source": 1.0 if "cross_source_name" in evidence_types else 0.0,
"verification": 1.0 if c["verified"] else 0.0,
}
weights = {
"name": .12, "source_keyword": .10, "runtime_stack": .17,
"request_correlation": .16, "input_output_flow": .18,
"module_export": .08, "cross_source": .07, "verification": .12
}
total = round(sum(scores[k] * weights[k] for k in weights), 3)
c["scores"] = scores
c["total_score"] = total
c["confidence"] = (
"high" if c["verified"] and total >= .62
else ("medium" if total >= .38 else "low")
)
c["evidence"] = c["evidence"][:100]
c.pop("_has_fingerprint_flow", None)
return sorted(store.values(), key=lambda x: (x["verified"], x["total_score"]), reverse=True)
def main() -> int:
parser = argparse.ArgumentParser(description="Evidence-driven encryption/signature candidate discovery.")
parser.add_argument("--probe-artifacts", required=True, help="Path to probe_dump.json.")
parser.add_argument("--module-artifacts", help="Path to module_dump.json.")
parser.add_argument("--evidence-graph", help="Path to evidence_graph.json.")
parser.add_argument("--static-artifacts", help="Path to static_candidates.json.")
parser.add_argument("--analysis", help="Path to analysis_result.json.")
parser.add_argument("--output", required=True, help="Path to output candidates JSON.")
args = parser.parse_args()
probe = load_json(args.probe_artifacts, {})
modules = load_json(args.module_artifacts, {}) if args.module_artifacts else {}
graph = load_json(args.evidence_graph, {}) if args.evidence_graph else {}
static_artifacts = load_json(args.static_artifacts, {}) if args.static_artifacts else {}
if static_artifacts:
modules = dict(modules)
modules.setdefault("static", []).extend(static_artifacts.get("candidates", []))
analysis = load_json(args.analysis, {}) if args.analysis else {}
candidates = build_candidates(probe, modules, graph, analysis)
result = {
"candidates": candidates,
"stats": {
"total": len(candidates),
"high": sum(c["confidence"] == "high" for c in candidates),
"medium": sum(c["confidence"] == "medium" for c in candidates),
"low": sum(c["confidence"] == "low" for c in candidates),
"verified": sum(c["verified"] for c in candidates)
}
}
dump_json(args.output, result)
print(f"[OK] candidate discovery: {args.output} {result['stats']}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,212 @@
#!/usr/bin/env python3
"""Emit/apply differential verification artifacts.
Two subcommands:
emit — Generate a JS verification script that calls candidate functions with
test samples and compares output fingerprints against captured request
field expectations.
apply — Merge verification results back into candidates, promoting to
verified=true only when actual sample matches succeed.
Usage:
python3 scripts/differential_verifier.py emit \
--analysis analysis_result.json \
--candidates artifacts/encryption_candidates.json \
--output generated/differential_verifier.js \
--plan-output artifacts/differential_plan.json
python3 scripts/differential_verifier.py apply \
--candidates artifacts/encryption_candidates.json \
--results artifacts/differential_verification_results.json \
--output artifacts/encryption_candidates.verified.json \
--minimum-matches 2
"""
from __future__ import annotations
import argparse
import json
from pathlib import Path
from common import dump_json, load_json
JS_TEMPLATE = r'''(() => {
"use strict";
const plan = __PLAN__;
const results = [];
function resolvePath(path) {
if (!path) return null;
if (path.startsWith("webpack:")) {
const parts = path.split(":");
let source = "default", moduleId, exportPath;
if (parts.length >= 4) {
source = parts[1]; moduleId = parts[2]; exportPath = parts.slice(3).join(":");
} else {
moduleId = parts[1]; exportPath = parts.slice(2).join(":");
}
const req = (window.__JSRA_WEBPACK_REQUIRE_MAP__ &&
window.__JSRA_WEBPACK_REQUIRE_MAP__[source]) ||
window.__JSRA_WEBPACK_REQUIRE__;
if (!req || !req.c || !req.c[moduleId]) return null;
let value = req.c[moduleId].exports;
for (const part of (exportPath || "default").split(".")) {
if (part === "default" && value && value.default !== undefined) value = value.default;
else if (part) value = value && value[part];
}
return value;
}
const normalized = path.replace(/^window\./, "");
return normalized.split(".").reduce((obj, key) => obj && obj[key], window);
}
async function fp(value) {
const text = typeof value === "string" ? value : JSON.stringify(value);
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
return "sha256:" + Array.from(new Uint8Array(digest))
.map(b => b.toString(16).padStart(2, "0")).join("");
}
async function run() {
for (const item of plan.items) {
if (!item.safe_to_invoke) {
results.push({
candidate_id: item.candidate_id, verified: false,
skipped: true, reason: "safe_to_invoke is false"
});
continue;
}
const fn = resolvePath(item.path);
if (typeof fn !== "function") {
results.push({
candidate_id: item.candidate_id, verified: false,
reason: "candidate not resolvable", tests: []
});
continue;
}
const tests = [];
for (const sample of item.samples) {
try {
const output = await fn(sample);
const output_fingerprint = await fp(output);
const expected = item.expected_fingerprints[String(sample)] || null;
const matched = Boolean(expected && expected === output_fingerprint);
tests.push({
input: sample,
output_fingerprint,
output_preview: String(output).slice(0, 96),
expected_fingerprint: expected,
matched,
expectation_source: expected ? "captured_request_field" : "missing_expectation"
});
} catch (error) {
tests.push({ input: sample, error: String(error), matched: false });
}
}
const matched = tests.filter(test => test.matched === true).length;
results.push({
candidate_id: item.candidate_id, path: item.path,
verified: matched >= item.minimum_matches,
matched, tests
});
}
window.__JSRA_VERIFICATION_RESULTS__ = {
generatedAt: Date.now(), results
};
return window.__JSRA_VERIFICATION_RESULTS__;
}
return run();
})();
'''
def build_plan(analysis: dict, candidates: dict) -> dict:
"""Build a verification plan from analysis transforms and candidates."""
transforms = analysis.get("transforms") or []
by_path = {c.get("path"): c for c in candidates.get("candidates", []) if c.get("path")}
by_name = {c.get("name"): c for c in candidates.get("candidates", []) if c.get("name")}
samples = analysis.get("verification", {}).get("samples") or ["JSRA_TEST_A", "JSRA_TEST_B"]
expected_by_sample = analysis.get("verification", {}).get("expected_fingerprints", {})
items = []
for transform in transforms:
path = transform.get("candidate_path")
candidate = by_path.get(path) or by_name.get(path)
if candidate is None and path:
candidate = {"id": f"configured:{path}", "path": path}
if candidate:
items.append({
"candidate_id": candidate.get("id"),
"transform_id": transform.get("id"),
"path": path or candidate.get("path"),
"safe_to_invoke": bool(transform.get("safe_to_invoke")),
"samples": samples,
"minimum_matches": int(analysis.get("verification", {}).get("minimum_matches", 1)),
"expected_fingerprints": (
transform.get("expected_fingerprints", {}) or expected_by_sample
)
})
return {"items": items}
def emit(args: argparse.Namespace) -> int:
"""Emit a differential verification JS script."""
analysis = load_json(args.analysis)
candidates = load_json(args.candidates)
plan = build_plan(analysis, candidates)
content = JS_TEMPLATE.replace("__PLAN__", json.dumps(plan, ensure_ascii=False))
path = Path(args.output)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
if args.plan_output:
dump_json(args.plan_output, plan)
print(f"[OK] verifier emitted: {path}; items={len(plan['items'])}")
return 0
def apply(args: argparse.Namespace) -> int:
"""Apply verification results back into candidates."""
candidates = load_json(args.candidates)
results = load_json(args.results)
by_id = {r.get("candidate_id"): r for r in results.get("results", [])}
minimum = args.minimum_matches
for candidate in candidates.get("candidates", []):
result = by_id.get(candidate.get("id"))
if not result:
continue
matched = sum(test.get("matched") is True for test in result.get("tests", []))
candidate.setdefault("verification", []).append(result)
candidate.setdefault("evidence", []).append({
"type": "verification", "mode": "active_differential", "matched": matched
})
# Only actual sample matches can promote to verified
if matched >= minimum and result.get("verified") is True:
candidate["verified"] = True
candidate.setdefault("scores", {})["verification"] = 1.0
candidate["total_score"] = round(min(1.0, candidate.get("total_score", 0) + .12), 3)
candidate["confidence"] = "high" if candidate["total_score"] >= .62 else "medium"
dump_json(args.output, candidates)
print(f"[OK] verification results applied: {args.output}")
return 0
def main() -> int:
parser = argparse.ArgumentParser(description="Emit/apply differential verification artifacts.")
sub = parser.add_subparsers(dest="command", required=True)
p_emit = sub.add_parser("emit", help="Emit verification JS script.")
p_emit.add_argument("--analysis", required=True)
p_emit.add_argument("--candidates", required=True)
p_emit.add_argument("--output", required=True)
p_emit.add_argument("--plan-output", help="Optional path to dump the plan JSON.")
p_apply = sub.add_parser("apply", help="Apply verification results to candidates.")
p_apply.add_argument("--candidates", required=True)
p_apply.add_argument("--results", required=True)
p_apply.add_argument("--output", required=True)
p_apply.add_argument("--minimum-matches", type=int, default=2)
args = parser.parse_args()
return emit(args) if args.command == "emit" else apply(args)
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,43 @@
#!/usr/bin/env python3
"""Check JSRA runtime dependencies.
Usage:
python3 scripts/doctor.py
python3 scripts/doctor.py --ci
"""
from __future__ import annotations
import argparse
import importlib.util
import json
import platform
import shutil
import sys
def main() -> int:
parser = argparse.ArgumentParser(description="Check JSRA runtime dependencies.")
parser.add_argument("--ci", action="store_true")
args = parser.parse_args()
checks = {
"python": {"ok": sys.version_info >= (3, 10), "value": platform.python_version(), "required": True},
"flask": {"ok": importlib.util.find_spec("flask") is not None, "required": True},
"requests": {"ok": importlib.util.find_spec("requests") is not None, "required": True},
"jsonschema": {"ok": importlib.util.find_spec("jsonschema") is not None, "required": True},
"websocket_client": {"ok": importlib.util.find_spec("websocket") is not None, "required": False},
"node": {"ok": shutil.which("node") is not None, "value": shutil.which("node"), "required": False},
"chrome": {"ok": any(shutil.which(x) for x in ("google-chrome", "chromium", "chromium-browser")), "required": False},
}
print(json.dumps(checks, ensure_ascii=False, indent=2))
failed = [name for name, item in checks.items() if item.get("required") and not item.get("ok")]
if failed:
print("Missing required dependencies:", ", ".join(failed))
return 2
if not args.ci and not checks["node"]["ok"]:
print("[WARN] Node.js not found; JavaScript syntax validation will be skipped.")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,456 @@
#!/usr/bin/env python3
"""Emit a low-side-effect adversarial runtime probe.
The generated probe is deliberately split into observation and intervention:
observation records anti-debug/anti-hook/environment signals, while intervention
only applies the explicitly requested debugger-source filter. Every installed
patch is tracked and can be restored through ``uninstall()``.
Usage:
python3 scripts/emit_adversarial_runtime_probe.py \
--output generated/adversarial_runtime_probe.js
python3 scripts/emit_adversarial_runtime_probe.py \
--mode intervene --output generated/adversarial_runtime_probe.js
"""
from __future__ import annotations
import argparse
import json
from pathlib import Path
HOOK_REGISTRY = (Path(__file__).with_name("hook_registry.js")).read_text(encoding="utf-8")
TEMPLATE = r'''(() => {
"use strict";
const PROBE_ID = "adversarial-runtime";
const CONFIG = __CONFIG__;
const KEYWORDS = /pass(word)?|token|secret|authorization|cookie|session|credential/i;
const root = window;
__HOOK_REGISTRY__
const registry = getHookRegistry(root);
if (root.__JSRA_ADVERSARIAL__ && root.__JSRA_ADVERSARIAL__.probe_id === PROBE_ID) {
return root.__JSRA_ADVERSARIAL__;
}
const native = {
json: JSON.stringify.bind(JSON),
setTimeout: window.setTimeout.bind(window),
setInterval: window.setInterval.bind(window),
clearInterval: window.clearInterval.bind(window),
fetch: window.fetch,
eval: window.eval,
Function: window.Function,
defineProperty: Object.defineProperty,
getOwnPropertyDescriptor: Object.getOwnPropertyDescriptor,
getPrototypeOf: Object.getPrototypeOf,
toString: Function.prototype.toString,
};
const state = {
probe_id: PROBE_ID,
mode: CONFIG.mode,
installedAt: Date.now(),
events: [],
patches: [],
realms: [{ realm_id: "main", kind: "window", url: location.href }],
maxEvents: CONFIG.maxEvents,
dropped: 0,
suppressed: 0,
suppressedByPath: {},
health: { installed: true, lostPatches: 0, externalChanges: 0, reconcileErrors: 0, restored: false }
};
let counter = 0;
let healTimer = null;
let observer = null;
const active = new Map();
function id(prefix) {
counter += 1;
return `${prefix}-${Date.now().toString(36)}-${counter.toString(36)}`;
}
function preview(value, key = "") {
if (KEYWORDS.test(String(key))) return "<redacted>";
if (value === null) return "null";
if (value === undefined) return "undefined";
if (typeof value === "function") return "[function]";
if (value instanceof ArrayBuffer) return `[ArrayBuffer:${value.byteLength}]`;
if (ArrayBuffer.isView(value)) return `[${value.constructor.name}:${value.byteLength}]`;
if (value && typeof value === "object") return `[${value.constructor && value.constructor.name || "object"}]`;
const text = String(value);
return CONFIG.captureRaw ? text.slice(0, 160) : `${typeof value}:${text.length}`;
}
function emit(type, detail = {}) {
const event = {
event_id: id("adv"),
trace_id: detail.trace_id || id("trace"),
type,
path: detail.path || null,
function: detail.function || null,
input: preview(detail.input, detail.key),
output: preview(detail.output, detail.key),
metadata: detail.metadata || {},
timestamp: Date.now()
};
if (state.events.length >= state.maxEvents) {
state.events.shift();
state.dropped += 1;
}
state.events.push(event);
return event;
}
function descriptor(target, key) {
try {
let current = target;
while (current) {
const desc = native.getOwnPropertyDescriptor(current, key);
if (desc) return { owner: current, desc };
current = native.getPrototypeOf(current);
}
} catch (_) {}
return null;
}
function installPatch(name, target, key, factory, metadata = {}) {
if (!target || typeof target[key] !== "function") return null;
emit("patch.install", { path: name, metadata: { ...metadata, key } });
const layerId = `adversarial:${name}`;
const layer = registry.add(target, key, layerId, original => {
const replacement = factory(original);
try {
if (original && original.prototype && replacement && replacement.prototype)
replacement.prototype = original.prototype;
} catch (_) {}
return replacement;
});
if (!layer) return null;
const patch = { name, target, key, layerId, installed: true };
state.patches.push(patch);
active.set(name, patch);
patch.reconcile = () => {
const result = registry.reconcile(target, key);
if (result.changed) {
emit(result.status === "error" ? "patch.reconcile_error" : "patch.reconciled", {
path: name, metadata: { key, status: result.status, error: result.error || null }
});
if (result.status === "error") state.health.reconcileErrors += 1;
else state.health.externalChanges += 1;
}
return result;
};
return patch;
}
function wrap(target, key, type, path, options = {}) {
const sampleEvery = Math.max(1, Number(options.sampleEvery || 1));
const eventBudget = Number.isFinite(Number(options.eventBudget))
? Math.max(0, Number(options.eventBudget)) : Infinity;
const burst = Math.max(0, Number(options.burst || 0));
let invocationCount = 0;
function shouldRecord() {
invocationCount += 1;
const selected = invocationCount <= eventBudget &&
(invocationCount <= burst || invocationCount % sampleEvery === 0);
if (!selected) {
state.suppressed += 1;
state.suppressedByPath[path] = (state.suppressedByPath[path] || 0) + 1;
}
return selected;
}
return installPatch(path, target, key, original => function(...args) {
if (typeof options.skip === "function" && options.skip.call(this, args))
return original.apply(this, args);
const recordInvocation = shouldRecord();
const traceId = id("trace");
if (recordInvocation)
emit(`${type}.call`, { trace_id: traceId, path, function: path, input: args[0], metadata: { argc: args.length } });
let result;
try {
result = original.apply(this, args);
} catch (error) {
if (recordInvocation)
emit(`${type}.error`, { trace_id: traceId, path, function: path, metadata: { error: String(error) } });
throw error;
}
if (result && typeof result.then === "function") {
return result.then(value => {
if (recordInvocation)
emit(`${type}.return`, { trace_id: traceId, path, function: path, output: value });
return value;
}, error => {
if (recordInvocation)
emit(`${type}.reject`, { trace_id: traceId, path, function: path, metadata: { error: String(error) } });
throw error;
});
}
if (recordInvocation)
emit(`${type}.return`, { trace_id: traceId, path, function: path, output: result });
return result;
}, options);
}
function watchGetter(target, key, path) {
const found = descriptor(target, key);
if (!found || !found.desc || typeof found.desc.get !== "function" || found.desc.configurable === false)
return false;
const original = found.desc;
const replacement = { ...original, get() {
const value = original.get.call(this);
emit("environment.property_read", { path, key, output: value, metadata: { owner: path } });
return value;
}};
try {
native.defineProperty(found.owner, key, replacement);
state.patches.push({ name: `getter:${path}`, target: found.owner, key, descriptor: original, installed: true });
return true;
} catch (error) {
emit("patch.error", { path, metadata: { error: String(error) } });
return false;
}
}
function resolve(path) {
return String(path).split(".").reduce((value, key) => value == null ? null : value[key], root);
}
function watchProperties() {
const paths = CONFIG.properties || [
"navigator.webdriver", "navigator.userAgent", "navigator.platform",
"navigator.languages", "navigator.plugins", "screen.width", "screen.height",
"window.innerWidth", "window.innerHeight", "window.outerWidth", "window.outerHeight",
"document.cookie"
];
for (const path of paths) {
const parts = path.split(".");
const key = parts.pop();
const parent = resolve(parts.join("."));
if (parent && watchGetter(parent, key, path))
emit("environment.watch_installed", { path });
}
}
function observeDynamicCode() {
const stripDebugger = source => typeof source === "string"
? source.replace(/\bdebugger\s*;?/g, "") : source;
installPatch("window.eval", root, "eval", original => function(source) {
const next = CONFIG.mode === "intervene" ? stripDebugger(source) : source;
if (next !== source) emit("intervention.debugger_source", { path: "window.eval", input: source, metadata: { action: "strip" } });
emit("dynamic.eval.call", { path: "window.eval", input: next });
return original.call(this, next);
});
installPatch("window.Function", root, "Function", original => function(...args) {
const last = args.length - 1;
if (CONFIG.mode === "intervene" && last >= 0 && typeof args[last] === "string") {
const next = stripDebugger(args[last]);
if (next !== args[last]) emit("intervention.debugger_source", { path: "window.Function", input: args[last], metadata: { action: "strip" } });
args[last] = next;
}
emit("dynamic.function.call", { path: "window.Function", input: args[last], metadata: { argc: args.length } });
return new.target ? Reflect.construct(original, args, new.target) : original.apply(this, args);
});
root.Function.prototype = native.Function.prototype;
}
function observeNetwork() {
if (typeof root.fetch === "function") {
wrap(root, "fetch", "network.fetch", "window.fetch");
}
if (root.XMLHttpRequest) {
wrap(root.XMLHttpRequest.prototype, "open", "network.xhr_open", "XMLHttpRequest.open");
wrap(root.XMLHttpRequest.prototype, "setRequestHeader", "network.xhr_header", "XMLHttpRequest.setRequestHeader");
wrap(root.XMLHttpRequest.prototype, "send", "network.xhr_send", "XMLHttpRequest.send");
}
if (root.Response) {
["json", "text", "arrayBuffer", "blob"].forEach(key => wrap(root.Response.prototype, key, "network.response", `Response.${key}`));
}
if (root.WebSocket) wrap(root.WebSocket.prototype, "send", "network.websocket", "WebSocket.send", {
skip: function() {
try {
const url = String(this && this.url || "");
return (CONFIG.controlWebSocketUrls || []).some(fragment => url.includes(fragment));
} catch (_) { return false; }
}
});
}
function observeCrypto() {
if (!root.crypto || !root.crypto.subtle) return;
["encrypt", "decrypt", "sign", "verify", "digest", "importKey", "exportKey", "deriveKey", "deriveBits", "generateKey"].forEach(key => {
wrap(root.crypto.subtle, key, "crypto.subtle", `crypto.subtle.${key}`);
});
if (typeof root.crypto.getRandomValues === "function") wrap(root.crypto, "getRandomValues", "crypto.random", "crypto.getRandomValues");
}
function observeLoaders() {
if (root.Worker) {
installPatch("window.Worker", root, "Worker", original => function(...args) {
emit("realm.worker_create.call", { path: "window.Worker", input: args[0], metadata: { argc: args.length } });
return new original(...args);
});
}
if (root.SharedWorker) {
installPatch("window.SharedWorker", root, "SharedWorker", original => function(...args) {
emit("realm.shared_worker_create.call", { path: "window.SharedWorker", input: args[0], metadata: { argc: args.length } });
return new original(...args);
});
}
if (root.WebAssembly) {
["instantiate", "instantiateStreaming"].forEach(key => {
if (typeof root.WebAssembly[key] === "function") wrap(root.WebAssembly, key, "loader.wasm", `WebAssembly.${key}`);
});
}
if (root.document && root.MutationObserver) {
observer = new MutationObserver(records => records.forEach(record => record.addedNodes && Array.from(record.addedNodes).forEach(node => {
if (node && node.tagName === "SCRIPT") emit("loader.script", { path: "script.src", input: node.src, metadata: { async: !!node.async, type: node.type || "" } });
if (node && node.tagName === "IFRAME") emit("realm.iframe_create", { path: "iframe.src", input: node.src });
})));
observer.observe(root.document.documentElement || root.document, { childList: true, subtree: true });
}
}
function observeControlFlow() {
if (!CONFIG.controlFlow) return;
if (root.console) {
["clear", "table"].forEach(key => {
if (typeof root.console[key] === "function")
wrap(root.console, key, "control.console", `console.${key}`, { sampleEvery: 1, eventBudget: 64 });
});
}
if (root.history) {
["pushState", "replaceState", "back", "forward", "go"].forEach(key => {
if (typeof root.history[key] !== "function") return;
installPatch(`history.${key}`, root.history, key, original => function(...args) {
const blocked = CONFIG.mode === "intervene" && ["back", "forward", "go"].includes(key);
emit(blocked ? "intervention.history_blocked" : "control.history.call", {
path: `history.${key}`, input: args[0], metadata: { argc: args.length, blocked }
});
if (blocked) return undefined;
return original.apply(this, args);
});
});
}
if (root.Storage && root.Storage.prototype) {
["getItem", "setItem", "removeItem", "clear"].forEach(key => {
if (typeof root.Storage.prototype[key] !== "function") return;
wrap(root.Storage.prototype, key, "control.storage", `Storage.${key}`, {
sampleEvery: 1,
eventBudget: 128,
skip: function(args) {
return key !== "clear" && typeof args[0] === "string" && !KEYWORDS.test(args[0]);
}
});
});
}
if (typeof root.close === "function") {
installPatch("window.close", root, "close", original => function(...args) {
const blocked = CONFIG.mode === "intervene";
emit(blocked ? "intervention.close_blocked" : "control.close.call", {
path: "window.close", metadata: { argc: args.length, blocked }
});
if (blocked) return undefined;
return original.apply(this, args);
});
}
}
function observeIntegrity() {
const options = {
eventBudget: CONFIG.integrityEventBudget,
sampleEvery: CONFIG.integritySampleEvery,
burst: CONFIG.integrityBurst,
};
// Browser libraries call these methods at very high frequency. Capture
// an initial burst plus bounded samples so integrity evidence cannot evict
// crypto/network events from the ring buffer.
wrap(Function.prototype, "toString", "integrity.toString", "Function.prototype.toString", options);
wrap(Object, "getOwnPropertyDescriptor", "integrity.descriptor", "Object.getOwnPropertyDescriptor", options);
wrap(Object, "getPrototypeOf", "integrity.prototype", "Object.getPrototypeOf", options);
}
function install() {
watchProperties();
observeDynamicCode();
observeNetwork();
observeControlFlow();
observeCrypto();
observeLoaders();
observeIntegrity();
if (CONFIG.persistent) {
healTimer = native.setInterval(() => state.patches.slice().forEach(patch => {
try { if (patch.reconcile) patch.reconcile(); } catch (error) {
emit("patch.reconcile_error", { path: patch.name, metadata: { error: String(error) } });
state.health.reconcileErrors += 1;
}
}), CONFIG.healIntervalMs);
}
emit("probe.ready", { metadata: { mode: CONFIG.mode, persistent: CONFIG.persistent } });
}
function restore() {
if (healTimer) native.clearInterval(healTimer);
if (observer) observer.disconnect();
for (const patch of state.patches.slice().reverse()) {
try {
if (patch.descriptor) native.defineProperty(patch.target, patch.key, patch.descriptor);
else if (patch.layerId) registry.remove(patch.target, patch.key, patch.layerId);
} catch (_) {}
}
state.health.restored = true;
state.health.installed = false;
emit("probe.restored");
}
const api = {
probe_id: PROBE_ID,
state,
export() { return { probe_id: PROBE_ID, exportedAt: Date.now(), state: { ...state, events: state.events.slice(), patches: state.patches.map(p => ({ name: p.name, key: p.key, installed: p.installed })) } }; },
clear() { state.events.length = 0; state.dropped = 0; state.suppressed = 0; state.suppressedByPath = {}; },
uninstall: restore,
markTrace(label = "manual") { return id(label); }
};
root.__JSRA_ADVERSARIAL__ = api;
install();
return api;
})();
'''
def main() -> int:
parser = argparse.ArgumentParser(description="Emit JSRA adversarial runtime probe.")
parser.add_argument("--output", required=True)
parser.add_argument("--mode", choices=("observe", "intervene"), default="observe")
parser.add_argument("--max-events", type=int, default=5000)
parser.add_argument("--integrity-event-budget", type=int, default=256,
help="Maximum integrity-hook invocations recorded per method.")
parser.add_argument("--integrity-sample-every", type=int, default=64,
help="After the initial burst, record every Nth integrity invocation.")
parser.add_argument("--integrity-burst", type=int, default=16,
help="Record the first N integrity invocations in full.")
parser.add_argument("--heal-interval-ms", type=int, default=1000)
parser.add_argument("--properties", default="")
parser.add_argument("--control-websocket-url", action="append", default=[],
help="URL fragment for a JSRPC/control WebSocket to leave untouched.")
parser.add_argument("--no-control-flow", action="store_true",
help="Skip console/history/storage/window.close observation.")
parser.add_argument("--capture-raw", action="store_true")
parser.add_argument("--no-persistent", action="store_true")
args = parser.parse_args()
properties = [item.strip() for item in args.properties.split(",") if item.strip()]
if not properties:
properties = [
"navigator.webdriver", "navigator.userAgent", "navigator.platform",
"navigator.languages", "navigator.plugins", "screen.width", "screen.height",
"window.innerWidth", "window.innerHeight", "window.outerWidth", "window.outerHeight",
"document.cookie"
]
config = {
"mode": args.mode,
"maxEvents": max(100, args.max_events),
"integrityEventBudget": max(1, args.integrity_event_budget),
"integritySampleEvery": max(1, args.integrity_sample_every),
"integrityBurst": max(0, args.integrity_burst),
"healIntervalMs": max(250, args.heal_interval_ms),
"properties": properties,
"captureRaw": bool(args.capture_raw),
"persistent": not args.no_persistent,
"controlWebSocketUrls": args.control_websocket_url or ["127.0.0.1:12080", "localhost:12080"],
"controlFlow": not args.no_control_flow,
}
output = Path(args.output)
output.parent.mkdir(parents=True, exist_ok=True)
content = TEMPLATE.replace("__CONFIG__", json.dumps(config, ensure_ascii=False))
content = content.replace("__HOOK_REGISTRY__", HOOK_REGISTRY)
output.write_text(content, encoding="utf-8")
print(json.dumps({"status": "ok", "output": str(output), "mode": args.mode}, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,207 @@
#!/usr/bin/env python3
"""Generate Burp autoDecoder integration documentation from analysis_result.json.
Usage:
python3 scripts/emit_burp_doc.py --analysis analysis_result.json --output generated/burp-autodecoder.md
"""
from __future__ import annotations
import argparse
import json
from pathlib import Path
from common import load_json
def main() -> int:
parser = argparse.ArgumentParser(description="Generate Burp autoDecoder integration guide.")
parser.add_argument("--analysis", required=True, help="Path to analysis_result.json.")
parser.add_argument("--output", required=True, help="Output markdown file path.")
parser.add_argument("--status", default="", help="Optional Flask status JSON; uses the actual fallback port when present.")
args = parser.parse_args()
analysis = load_json(args.analysis)
flask = analysis.get("flask", {})
flask_server = analysis.get("flask_server", {})
jsrpc = analysis.get("jsrpc", {})
port = flask.get("port", flask_server.get("port", 5000))
status = load_json(args.status, {}) if args.status else {}
if status.get("status") == "started" and status.get("port"):
port = status["port"]
transforms = analysis.get("transforms") or []
parameters = list(analysis.get("parameters", {}).keys())
proxy_url = f"http://127.0.0.1:{port}{flask.get('route', '/autodecoder')}"
encode_url = status.get("encode_url", f"http://127.0.0.1:{port}/encode")
decode_url = status.get("decode_url", f"http://127.0.0.1:{port}/decode")
jsrpc_port = analysis.get("jsrpc_server", {}).get("port", 12080)
group = jsrpc.get("group", "jsra")
action = jsrpc.get("action_name", "encode_password")
sample_data_body = "&".join(f"{p}=<plaintext>" for p in parameters)
# Build transform table if transforms exist
if transforms:
rows = "\n".join(
f"| {t.get('id')} | {t.get('direction')} | {t.get('location')} | "
f"{t.get('content_type', 'auto')} | `{t.get('path', '')}` | `{t.get('action')}` |"
for t in transforms
)
transform_table = f"""
## 转换项
| ID | 方向 | 位置 | 内容类型 | 路径 | JSRPC Action |
|---|---|---|---|---|---|
{rows}
"""
else:
transform_table = ""
content = f'''# Burp autoDecoder 配置(JSRA)
> 重要:此代理不实现加密/解密算法。它只通过 JSRPC 远程调用当前浏览器页面已验证的真实函数。若真实入口、运行时证据或 JSRPC 不可用,代理会返回 `JSRA_ERROR`,不得继续发送请求。
## 本地服务
- Health:`http://127.0.0.1:{port}/health`
- Healthz:`http://127.0.0.1:{port}/healthz`
- 转换接口:`http://127.0.0.1:{port}/autodecoder?direction=request`
- 响应接口:`http://127.0.0.1:{port}/autodecoder?direction=response`
- 当前服务端口:`{port}`。如果服务启动时发生端口回退,文档优先使用 `artifacts/flask_status.json` 中的实际地址。
{transform_table}
## 配置原则
1. 仅匹配授权目标 URL。
2. 请求方向使用 `direction=request`,响应解密使用 `direction=response`。
3. 在 autoDecoder 页面直接粘贴完整 HTTP 请求/响应时,插件可能以 `application/octet-stream` 调用接口;代理会从内层报文自动识别真实 `Content-Type`,并返回完整改写后的报文。
4. 使用 `dataBody/dataHeaders` wrapper 时,`dataBody` 传 body,`dataHeaders` 传可选头部;JSON 请求也可用 `X-JSRA-Content-Type` 覆盖。
5. 首次联调前访问 `/health`。
6. 发生 `JSRA_ERROR` 时不要继续发送被破坏的请求。
7. 不要把页面抓到的公钥、模数或某次样本结果改写成 Python 加密实现。
## Burp autoDecoder 配置
### 截图所示的“接口加解密设置”页面
| 页面字段 | 配置值 |
|---|---|
| 解密接口 | `{decode_url}`(可与加密接口同时配置) |
| 加密接口 | `{encode_url}` |
| 请求方向 | 当前测试请求时选择“请求数据包” |
| 响应方向 | 当前测试响应时选择“响应数据包”;与“请求数据包”互斥 |
| 处理请求头 | 默认不选;只有签名依赖请求头时才启用 |
| 请求 base64 编码 | 只有原始请求体本身是 Base64 时才选 |
| 请求自动 base64 解码 | 只有服务端要求先解码 Base64 时才选 |
| Proxy、Repeater 等模块真实调试 | 联调时可选,用于观察返回的完整数据包 |
加密接口和解密接口可以同时配置。请求方向调试时,在“原始数据包”区域粘贴完整 HTTP 请求并选择“请求数据包”;响应方向调试时切换为“响应数据包”,再粘贴完整 HTTP 响应,两个方向不能同时选。对于截图中的整包模式,接口收到的是完整 HTTP 报文,外层常见 `Content-Type: application/octet-stream`;代理会解析内层 `Content-Type`、替换 body,并同步 `Content-Length`。对于 wrapper 模式,autoDecoder 通过 `dataBody` 传请求体,勾选“处理请求头”时额外传 `dataHeaders`,并传 `requestorresponse=request` 或 `response`。未处理请求头时整包模式返回完整改写报文,wrapper 模式返回改写后的 body;处理请求头时必须返回 `headers + "\\r\\n\\r\\n\\r\\n\\r\\n" + body`,这是 autoDecoder 的固定返回格式。
响应解密时使用已配置的 `{decode_url}`,切换为“响应数据包”后进行测试;如果当前只验证请求加密,也可以保留解密接口配置不使用。
### 响应体配置
| 页面字段 | 配置值 |
|---|---|
| 响应数据方向 | 选择“响应数据包” |
| 解密接口 | `{decode_url}` |
| 处理响应头 | 只有响应解密依赖响应头时才启用 |
| 响应 base64 编码 | 只有响应体传入接口前需要 Base64 编码时才选 |
| 响应自动 base64 解码 | 只有接口返回 Base64、需要由 autoDecoder 还原二进制响应时才选 |
| 响应请求标识 | `requestorresponse=response` |
响应方向启用请求头处理时,接口接收 `dataBody`、`dataHeaders` 和 `requestorresponse=response`,并返回 `响应头 + \\r\\n\\r\\n\\r\\n\\r\\n + 解密后的响应体`。未启用响应头处理时只返回解密后的响应体。
响应体验证:
```bash
curl --noproxy '*' -sS -X POST {decode_url} \\
-H 'Content-Type: application/x-www-form-urlencoded' \\
--data-urlencode 'dataBody=<原始响应体>' \\
--data-urlencode 'dataHeaders=<可选的原始响应头>' \\
--data-urlencode 'requestorresponse=response'
```
预期结果为解密后的响应体;启用响应头处理时,返回值必须包含四组 CRLF 分隔的响应头和响应体。
### 页面配置验证(标准 autoDecoder 协议)
```bash
curl --noproxy '*' -sS -X POST {encode_url} \\
-H 'Content-Type: application/x-www-form-urlencoded' \\
--data-urlencode 'dataBody=<原始请求体>' \\
--data-urlencode 'requestorresponse=request'
```
勾选“处理请求头”时增加:
```bash
--data-urlencode 'dataHeaders=<原始请求头>'
```
此时预期返回格式为:`原始请求头 + \\r\\n\\r\\n\\r\\n\\r\\n + 改写后的请求体`。
### 完整 HTTP 数据包调试
生成的 `/encode` 和 `/decode` 也支持直接提交完整 HTTP 数据包,便于脱离 Burp 页面调试;该模式会拆分请求头和请求体,并自动更新 `Content-Length`。整包验证示例:
```bash
curl --noproxy '*' -sS -X POST {encode_url} \\
-H 'Content-Type: application/octet-stream' \\
--data-binary $'POST /newlogin/login.do?appName=arena&fromSite=77 HTTP/1.1\\r\\nHost: 127.0.0.1:8123\\r\\nContent-Type: application/x-www-form-urlencoded\\r\\n\\r\\nloginId=admin&password2=123456'
```
预期返回完整 HTTP 请求,且只替换目标字段;若返回 `JSRA_ERROR`,先检查 JSRPC action 是否仍注册在当前浏览器页面。
### 兼容方式:dataBody/dataHeaders
Burp autoDecoder 插件使用 `dataBody` 和 `dataHeaders` 表单字段:
```
POST {proxy_url} HTTP/1.1
Content-Type: application/x-www-form-urlencoded
dataBody=<原始请求体>&dataHeaders=<原始请求头>
```
- `dataBody`:原始请求体(JSON 或 form-urlencoded)
- `dataHeaders`:原始请求头(可选,用于传递 Cookie、Authorization 等)
### 方式二:直接 POST
```
POST {proxy_url}?direction=request HTTP/1.1
Content-Type: <原始 Content-Type>
<原始请求体>
```
## 验证步骤
1. 在目标页面加载 `JsEnv_Dev.js` 和生成的 `jsrpc_inject.js`。
2. 确认浏览器 Console 出现 JSRPC connected,以及已注册的 action。
3. 用一个受控样本调用 JSRPC:
```bash
curl 'http://127.0.0.1:{jsrpc_port}/go?group={group}&action={action}&param=test123'
```
4. 验证 Flask 代理:
```bash
curl -X POST {proxy_url} \\
-H "Content-Type: application/x-www-form-urlencoded" \\
--data-urlencode "dataBody={sample_data_body}"
```
5. 确认返回值与 JSRPC 调用结果一致。
## 故障排查
- `JSRA_ERROR: ...:EvidenceMissing:...`:没有真实运行时证据或候选入口不可用;重新注入探针、导出证据并运行差分验证。
- `JSRA_ERROR: ...:RuntimeError:...`:页面真实函数执行失败;检查页面状态、依赖、DOM 和存储前置条件。
- `JSRA_ERROR` / HTTP 502:JSRPC 连接、响应格式或页面入口失败;不要发送该请求,先恢复浏览器和 JSRPC。
'''
path = Path(args.output)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
print(json.dumps({"status": "ok", "output": str(path)}, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,509 @@
#!/usr/bin/env python3
"""根据 analysis_result.json 生成 Flask 代理。
支持 dataBody/dataHeaders 兼容模式和 transforms 驱动模式。
所有转换都通过 JSRPC 完成,本文件不实现密码算法。
Usage:
python3 scripts/emit_flask_proxy.py --analysis analysis_result.json --output generated/flask_proxy.py
"""
from __future__ import annotations
import argparse
import json
import os
from pathlib import Path
from typing import Any
from common import load_json
def python_literal(value: Any) -> str:
"""Render a Python literal for embedding in generated code."""
if value is True:
return "True"
if value is False:
return "False"
if value is None:
return "None"
if isinstance(value, dict):
return "{" + ", ".join(
f"{json.dumps(str(k), ensure_ascii=False)}: {python_literal(v)}"
for k, v in value.items()
) + "}"
if isinstance(value, list):
return "[" + ", ".join(python_literal(v) for v in value) + "]"
return json.dumps(value, ensure_ascii=False)
def normalize(analysis: dict) -> dict:
"""Normalize analysis to a common config format."""
transforms = list(analysis.get("transforms") or [])
jsrpc_input = dict(analysis.get("jsrpc") or {})
default_action = jsrpc_input.get("action_name") or jsrpc_input.get("action") or "jsra_transform"
if not transforms:
# 兼容模式:根据 parameters 构造 transforms
for name, config in (analysis.get("parameters") or {}).items():
entrypoint = config.get("entrypoint", {}) if isinstance(config, dict) else {}
transforms.append({
"id": name,
"direction": "request",
"location": "body",
"content_type": "auto",
"path": f"$.{name}",
"action": default_action,
"candidate_path": entrypoint.get("path", ""),
"safe_to_invoke": False,
"delivery_mode": config.get("delivery_mode", "result") if isinstance(config, dict) else "result",
"capture": config.get("capture", {}) if isinstance(config, dict) else {},
"dom_bindings": config.get("dom_bindings", {}) if isinstance(config, dict) else {},
})
jsrpc = jsrpc_input
transport = jsrpc.get("transport") if isinstance(jsrpc.get("transport"), dict) else {}
jsrpc_config = {
"base_url": jsrpc.get("base_url") or transport.get("go_url") or "http://127.0.0.1:12080",
"group": jsrpc.get("group", "jsra"),
"action": jsrpc.get("action_name") or jsrpc.get("action") or "",
"transport": transport.get("type") or jsrpc.get("transport_type") or "legacy_get",
"timeout_seconds": float(jsrpc.get("timeout_seconds", transport.get("timeout_seconds", 10))),
}
if not jsrpc_config["action"]:
actions = [t.get("action") for t in transforms if t.get("action")]
jsrpc_config["action"] = actions[0] if actions else "jsra_transform"
flask = dict(analysis.get("flask") or {})
flask_server = dict(analysis.get("flask_server") or {})
return {
"jsrpc": jsrpc_config,
"transforms": transforms,
"flask_port": int(flask_server.get("port", flask.get("port", flask.get("listen_port", 5000)))),
"flask_host": flask_server.get("host", flask.get("listen_host", "127.0.0.1")),
"route": flask.get("route", "/autodecoder"),
}
def build_script(analysis: dict) -> str:
config = normalize(analysis)
return f'''#!/usr/bin/env python3
# Generated by emit_flask_proxy.py. JSRPC is the only transform engine.
from __future__ import annotations
import json
import os
from collections import OrderedDict
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
import requests
from flask import Flask, Response, jsonify, request
CONFIG = {python_literal(config)}
CONFIG["flask_port"] = int(os.environ.get("JSRA_FLASK_PORT", CONFIG["flask_port"]))
app = Flask(__name__)
class JSRPCError(Exception):
def __init__(self, message: str, status: str = "error", detail: str = ""):
super().__init__(message)
self.status = status
self.detail = detail
def _error_detail(payload: object) -> str:
try:
return json.dumps(payload, ensure_ascii=False)
except (TypeError, ValueError):
return str(payload)
def _nested_jsrpc_data(payload: object) -> object:
if not isinstance(payload, dict):
return payload
nested = payload.get("data")
if isinstance(nested, str):
try:
return json.loads(nested)
except ValueError:
return nested
return nested if nested is not None else payload
def _extract_result(payload: object, transform: dict | None = None) -> object:
if not isinstance(payload, dict):
raise JSRPCError("JSRPC response must be a JSON object", "unexpected_response", _error_detail(payload))
if payload.get("ok") is False or payload.get("status") in {{"error", "failed", "failure"}}:
raise JSRPCError("JSRPC reported an error", "entrypoint_error", _error_detail(payload))
nested = _nested_jsrpc_data(payload)
if isinstance(transform, dict) and transform.get("delivery_mode") == "request_body":
if isinstance(nested, dict) and nested.get("requestBody") not in (None, ""):
return nested["requestBody"]
raise JSRPCError("JSRPC response has no captured requestBody", "unexpected_response", _error_detail(payload))
for key in ("response_data", "data", "result", "value"):
if key in payload:
value = payload[key]
if isinstance(value, dict) and value.get("ok") is False:
raise JSRPCError("page function reported an error", "entrypoint_error", _error_detail(value))
if isinstance(value, dict) and "result" in value and len(value) <= 3:
value = value["result"]
return value
raise JSRPCError("JSRPC response has no result field", "unexpected_response", _error_detail(payload))
def _validate_result(value: object, input_value: object, transform: dict) -> object:
if value is None or value == "":
raise JSRPCError("page function returned an empty result", "invalid_result")
if isinstance(value, str) and value.startswith("__JSRPC_ERROR__:"):
raise JSRPCError(value, "entrypoint_error", value)
if isinstance(value, dict):
if value.get("error") or value.get("__JSRA_ERROR__"):
raise JSRPCError("page function returned an error object", "entrypoint_error", _error_detail(value))
if transform.get("reject_identity") and value == input_value:
raise JSRPCError("page function returned the input unchanged", "invalid_result")
return value
def jsrpc_call(action: str, value: object, transform: dict) -> object:
cfg = CONFIG["jsrpc"]
base = str(cfg["base_url"]).rstrip("/")
endpoint = base if base.endswith("/go") else base + "/go"
timeout = float(cfg.get("timeout_seconds", 10))
context = {{"transform_id": transform.get("id"), "candidate_path": transform.get("candidate_path")}}
if transform.get("context_fields") is not None:
context["fields"] = transform.get("context_fields")
param = json.dumps({{"parameter": transform.get("id"), "value": value, "context": context}}, ensure_ascii=False)
try:
if cfg.get("transport") == "post_json":
response = requests.post(endpoint, json={{"group": cfg["group"], "action": action, "param": param}}, timeout=timeout)
else:
response = requests.get(endpoint, params={{"group": cfg["group"], "action": action, "param": param}}, timeout=timeout)
response.raise_for_status()
try:
payload = response.json()
except ValueError as error:
raise JSRPCError("JSRPC returned non-JSON data", "unexpected_response", str(error)) from error
return _validate_result(_extract_result(payload, transform), value, transform)
except JSRPCError:
raise
except requests.Timeout as error:
raise JSRPCError("JSRPC request timed out", "timeout", str(error)) from error
except requests.RequestException as error:
raise JSRPCError("JSRPC server is unavailable", "connection_error", str(error)) from error
except Exception as error:
raise JSRPCError("JSRPC call failed", "unknown", str(error)) from error
def json_get(data: object, path: str) -> object:
if path in ("", "$", None):
return data
current = data
for part in str(path).removeprefix("$.").split("."):
if isinstance(current, list):
current = current[int(part)]
elif isinstance(current, dict):
current = current[part]
else:
raise KeyError(path)
return current
def json_set(data: object, path: str, value: object) -> object:
if path in ("", "$", None):
return value
parts = str(path).removeprefix("$.").split(".")
current = data
for part in parts[:-1]:
if isinstance(current, list):
current = current[int(part)]
else:
current = current.setdefault(part, {{}})
if isinstance(current, list):
current[int(parts[-1])] = value
else:
current[parts[-1]] = value
return data
def body_value(data: object, raw: str, transform: dict) -> object:
"""Find the plaintext for request-body transforms without hard-coding a field name."""
path = transform.get("path")
try:
return json_get(data, path)
except (KeyError, IndexError, TypeError, ValueError):
if isinstance(data, dict):
for key in ("password", "value", "data"):
if key in data:
return data[key]
return raw
def transforms_for(direction: str, location: str) -> list[dict]:
return [t for t in CONFIG["transforms"] if t.get("direction") == direction and t.get("location") == location]
def body_transforms(direction: str) -> list[dict]:
return transforms_for(direction, "body") + transforms_for(direction, "response")
def transform_key(transform: dict) -> str:
path = str(transform.get("path") or "")
return path.removeprefix("$.headers.").removeprefix("$.cookies.").removeprefix("$.cookie.").removeprefix("$.").split(".")[-1]
def transform_query_target(target: str, transforms: list[dict]) -> str:
if not transforms or "?" not in target:
return target
parts = urlsplit(target)
pairs = parse_qsl(parts.query, keep_blank_values=True, strict_parsing=False)
keys = {{transform_key(t): t for t in transforms}}
updated = []
for name, value in pairs:
transform = keys.get(name)
if transform:
value = str(jsrpc_call(transform.get("action", CONFIG["jsrpc"]["action"]), value, transform))
updated.append((name, value))
return urlunsplit((parts.scheme, parts.netloc, parts.path, urlencode(updated, doseq=True), parts.fragment))
def transform_cookie_value(value: str, transforms: list[dict]) -> str:
keys = {{transform_key(t): t for t in transforms}}
pairs = []
for item in value.split(";"):
name, separator, current = item.strip().partition("=")
if not separator:
pairs.append(item.strip())
continue
transform = keys.get(name)
if transform:
current = str(jsrpc_call(transform.get("action", CONFIG["jsrpc"]["action"]), current, transform))
pairs.append(f"{{name}}={{current}}")
return "; ".join(pairs)
def transform_headers(header_block: str, direction: str) -> str:
header_transforms = transforms_for(direction, "header")
cookie_transforms = transforms_for(direction, "cookie")
if not header_transforms and not cookie_transforms:
return header_block
by_name = {{transform_key(t).lower(): t for t in header_transforms}}
lines = []
for line in header_block.splitlines():
if ":" not in line:
lines.append(line)
continue
name, value = line.split(":", 1)
transform = by_name.get(name.strip().lower())
if transform:
value = " " + str(jsrpc_call(transform.get("action", CONFIG["jsrpc"]["action"]), value.strip(), transform))
if name.strip().lower() == "cookie" and cookie_transforms:
value = " " + transform_cookie_value(value.strip(), cookie_transforms)
lines.append(name + ":" + value)
return "\\r\\n".join(lines)
def transform_json(raw: str, transforms: list[dict]) -> str:
data = json.loads(raw or "null", object_pairs_hook=OrderedDict)
body_transform = next((t for t in transforms if t.get("delivery_mode") == "request_body"), None)
if body_transform:
old = body_value(data, raw, body_transform)
call_transform = dict(body_transform)
call_transform["context_fields"] = data
return str(jsrpc_call(call_transform.get("action", CONFIG["jsrpc"]["action"]), old, call_transform))
for transform in transforms:
old = json_get(data, transform.get("path"))
data = json_set(data, transform.get("path"), jsrpc_call(transform.get("action", CONFIG["jsrpc"]["action"]), old, transform))
return json.dumps(data, ensure_ascii=False, separators=(",", ":"))
def transform_form(raw: str, transforms: list[dict]) -> str:
pairs = parse_qsl(raw, keep_blank_values=True, strict_parsing=False)
body_transform = next((t for t in transforms if t.get("delivery_mode") == "request_body"), None)
if body_transform:
fields = dict(pairs)
key = str(body_transform.get("path", "")).removeprefix("$.")
old = fields.get(key) or fields.get("password") or fields.get("value") or fields.get("data") or raw
call_transform = dict(body_transform)
call_transform["context_fields"] = fields
return str(jsrpc_call(call_transform.get("action", CONFIG["jsrpc"]["action"]), old, call_transform))
for transform in transforms:
key = str(transform.get("path", "")).removeprefix("$.")
pairs = [
(name, jsrpc_call(transform.get("action", CONFIG["jsrpc"]["action"]), value, transform) if name == key else value)
for name, value in pairs
]
return urlencode(pairs, doseq=True)
def apply_body(raw: str, content_type: str, transforms: list[dict]) -> str:
if not transforms:
return raw
# Burp autoDecoder sends a complete packet as application/octet-stream
# when the request/response packet radio is selected. Infer the body
# format when the wrapper omits the original Content-Type.
inferred_content_type = content_type or ""
if "octet-stream" in inferred_content_type.lower():
stripped = raw.lstrip()
if stripped.startswith(("{{", "[")):
inferred_content_type = "application/json"
elif "=" in raw and not stripped.startswith(("GET ", "POST ", "PUT ", "PATCH ", "DELETE ", "HEAD ", "OPTIONS ", "HTTP/")):
inferred_content_type = "application/x-www-form-urlencoded"
content_type = inferred_content_type
mode = transforms[0].get("content_type", "auto")
if mode == "json" or (mode == "auto" and "json" in content_type.lower()):
return transform_json(raw, transforms)
if mode == "form" or (mode == "auto" and "application/x-www-form-urlencoded" in content_type.lower()):
return transform_form(raw, transforms)
if mode == "raw":
result = raw
for transform in transforms:
result = str(jsrpc_call(transform.get("action", CONFIG["jsrpc"]["action"]), result, transform))
return result
raise ValueError("Unsupported content type: " + content_type)
def apply_packet(header_block: str, body: str, separator: str, direction: str, content_type: str) -> str:
"""Apply query/header/cookie/body transforms while preserving the packet."""
lines = header_block.splitlines()
if lines:
query_transforms = transforms_for(direction, "query")
if query_transforms:
request_line = lines[0].split(" ", 2)
if len(request_line) >= 2:
request_line[1] = transform_query_target(request_line[1], query_transforms)
lines[0] = " ".join(request_line)
transformed_headers = transform_headers("\\r\\n".join(lines), direction)
transformed_body = apply_body(body, content_type, body_transforms(direction))
return rebuild_http_packet(transformed_headers, separator, transformed_body)
def split_http_packet(raw: str) -> tuple[str, str, str] | None:
"""Split a Burp autoDecoder raw packet into headers and body."""
if not raw:
return None
candidates = [raw.lstrip("\\ufeff")]
if "\\\\r\\\\n" in raw:
candidates.append(raw.replace("\\\\r\\\\n", "\\r\\n").replace("\\\\n", "\\n"))
for candidate in candidates:
for separator in ("\\r\\n\\r\\n\\r\\n\\r\\n", "\\n\\n\\n\\n", "\\r\\n\\r\\n", "\\n\\n"):
if separator not in candidate:
continue
header_block, body = candidate.split(separator, 1)
first_line = header_block.splitlines()[0].strip().upper() if header_block.splitlines() else ""
if first_line.startswith(("GET ", "POST ", "PUT ", "PATCH ", "DELETE ", "HEAD ", "OPTIONS ", "HTTP/")):
return header_block, separator, body
return None
def packet_content_type(header_block: str) -> str:
for line in header_block.splitlines():
if line.lower().startswith("content-type:") and ":" in line:
return line.split(":", 1)[1].strip()
return "application/octet-stream"
def rebuild_http_packet(header_block: str, separator: str, body: str) -> str:
"""Return a raw packet with Content-Length synchronized to the new body."""
lines = header_block.splitlines()
body_length = len(body.encode("utf-8"))
updated = []
content_length_seen = False
for line in lines:
if line.lower().startswith("content-length:") and ":" in line:
updated.append("Content-Length: " + str(body_length))
content_length_seen = True
else:
updated.append(line)
if not content_length_seen:
updated.append("Content-Length: " + str(body_length))
return "\\r\\n".join(updated) + separator + body
@app.get("/health")
def health():
return jsonify({{"status": "ok", "service": "jsra-flask", "jsrpc": CONFIG["jsrpc"]["base_url"]}})
@app.get("/healthz")
def healthz():
return health()
@app.post("/transform")
def transform_endpoint():
payload = request.get_json(force=True, silent=False)
direction = payload.get("direction", "request")
try:
transforms = [t for t in CONFIG["transforms"] if t.get("direction") == direction]
return jsonify({{"ok": True, "body": apply_body(payload.get("body", ""), payload.get("content_type", "application/octet-stream"), transforms)}})
except JSRPCError as error:
return jsonify({{"ok": False, "error": str(error), "status": error.status, "detail": error.detail}}), 502
except Exception as error:
return jsonify({{"ok": False, "error": str(error)}}), 400
@app.post(CONFIG["route"])
def autodecoder():
# Accept both Burp autoDecoder's dataBody/dataHeaders wrapper and the
# direct-body form. The wrapper is intentionally unwrapped before the
# content-type dispatch so the same transform path is exercised either way.
wrapped_body = request.form.get("dataBody")
if wrapped_body is not None:
raw = wrapped_body
wrapped_headers = request.form.get("dataHeaders")
content_type = request.headers.get("X-JSRA-Content-Type", "")
if not content_type:
content_type = next((
line.split(":", 1)[1].strip()
for line in (wrapped_headers or "").splitlines()
if line.lower().startswith("content-type:") and ":" in line
), "application/octet-stream")
else:
raw = request.get_data(as_text=True)
content_type = request.headers.get("X-JSRA-Content-Type", request.content_type or "application/octet-stream")
direction = request.form.get("requestorresponse") or request.args.get("direction")
if not direction:
direction = "response" if request.path == "/decode" else "request"
try:
packet = split_http_packet(raw) if wrapped_body is None else None
if packet:
header_block, separator, body = packet
content_type = request.headers.get("X-JSRA-Content-Type", packet_content_type(header_block))
return Response(apply_packet(header_block, body, separator, direction, content_type), content_type="text/plain; charset=utf-8")
transformed_headers = transform_headers(wrapped_headers, direction) if wrapped_headers is not None else None
transformed = apply_body(raw, content_type, body_transforms(direction))
if wrapped_body is not None and wrapped_headers is not None:
# autoDecoder expects this exact separator when header handling is enabled.
return Response(transformed_headers + "\\r\\n\\r\\n\\r\\n\\r\\n" + transformed, content_type="text/plain; charset=utf-8")
return Response(transformed, content_type="text/plain; charset=utf-8")
except JSRPCError as error:
return Response("JSRA_ERROR: " + str(error), status=502, content_type="text/plain; charset=utf-8")
except Exception as error:
return Response("JSRA_ERROR: " + str(error), status=400, content_type="text/plain; charset=utf-8")
# Burp autoDecoder 的“请求数据包”模式使用固定的加密/解密接口。
# 保留 CONFIG["route"]、/autodecoder 和 dataBody/dataHeaders 以兼容旧配置。
for alias, endpoint in (("/encode", "autodecoder_encode"), ("/decode", "autodecoder_decode"), ("/autodecoder", "autodecoder_legacy")):
if CONFIG["route"] != alias:
app.add_url_rule(alias, endpoint=endpoint, view_func=autodecoder, methods=["POST"])
if __name__ == "__main__":
app.run(host=CONFIG["flask_host"], port=CONFIG["flask_port"], debug=False)
'''
def main() -> int:
parser = argparse.ArgumentParser(description="Generate a strict JSRPC-only Flask proxy.")
parser.add_argument("--analysis", required=True, help="Path to analysis_result.json.")
parser.add_argument("--output", required=True, help="Output Python file path.")
args = parser.parse_args()
output = Path(args.output)
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(build_script(load_json(args.analysis)), encoding="utf-8")
print(json.dumps({"status": "ok", "output": str(output)}, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,532 @@
#!/usr/bin/env python3
"""Generate JSRPC injection code from analysis_result.json.
Supports parameter-based and transforms-based configuration.
Includes evidence gating: unverified candidates return __JSRPC_ERROR__:EvidenceMissing.
Usage:
python3 scripts/emit_jsrpc_stub.py --analysis analysis_result.json --output generated/jsrpc_inject.js
python3 scripts/emit_jsrpc_stub.py --analysis analysis_result.json --candidates artifacts/encryption_candidates.json --output generated/jsrpc_inject.js
"""
from __future__ import annotations
import argparse
import json
from pathlib import Path
from typing import Any
from common import load_json
def js_string(value: str) -> str:
return json.dumps(value, ensure_ascii=False)
def parameter_name(transform: dict) -> str:
path = str(transform.get("path") or "$.value")
value = path.removeprefix("$").strip(".")
return value.split(".", 1)[0] or str(transform.get("id") or "value")
def normalize_analysis(analysis: dict) -> dict:
"""Normalize analysis to a common format."""
transforms = list(analysis.get("transforms") or [])
raw_parameters = analysis.get("parameters") or {}
parameters: dict[str, dict] = {str(k): dict(v) for k, v in raw_parameters.items() if isinstance(v, dict)}
# Build parameters from transforms if present
for transform in transforms:
name = parameter_name(transform)
config = parameters.setdefault(name, {})
config.setdefault("entrypoint", {
"type": transform.get("entrypoint_type", "global"),
"path": transform.get("candidate_path", ""),
})
config.setdefault("runtime", {"bind_this_mode": "none"})
config.setdefault("call_signature", {
"arguments": ["value"], "returns": "unknown", "async": False
})
config["jsra_transform"] = transform
for key in ("capture", "dom_bindings", "delivery_mode"):
if key in transform:
config.setdefault(key, transform[key])
# Ensure all parameters have required fields
for name, config in parameters.items():
config.setdefault("entrypoint", {})
config.setdefault("runtime", {"bind_this_mode": "none"})
config.setdefault("call_signature", {})
config["call_signature"].setdefault("async", False)
config.setdefault("jsra_transform", {
"id": name, "action": name, "safe_to_invoke": False
})
jsrpc = dict(analysis.get("jsrpc") or {})
transport = jsrpc.get("transport") if isinstance(jsrpc.get("transport"), dict) else {}
action = jsrpc.get("action_name") or jsrpc.get("action")
if not action:
action = next((t.get("action") for t in transforms if t.get("action")), "jsra_transform")
ws_url = transport.get("ws_url") or jsrpc.get("ws_url")
group = jsrpc.get("group", "jsra")
if not ws_url:
ws_url = f"ws://127.0.0.1:12080/ws?group={group}&name=jsra"
return {"action_name": action, "ws_url": ws_url, "parameters": parameters}
def candidate_verified(candidate: dict | None) -> tuple[bool, str]:
"""Check if a candidate has sufficient verification evidence."""
if not candidate:
return False, "candidate evidence record is missing"
if candidate.get("source") == "hint" or candidate.get("type") == "hint":
return False, "manual entrypoint hints are not runtime evidence"
if candidate.get("verified") is not True:
return False, "candidate is not verified by runtime evidence"
verification = candidate.get("verification")
if not isinstance(verification, list) or not verification:
return False, "candidate has no verification records"
matched = False
for record in verification:
if not isinstance(record, dict):
continue
if record.get("matched") is True or record.get("verified") is True:
matched = True
for test in record.get("tests", []) if isinstance(record.get("tests"), list) else []:
if isinstance(test, dict) and test.get("matched") is True:
matched = True
if not matched:
return False, "candidate has no successful runtime/request match"
return True, "verified runtime entrypoint"
def find_candidate_file(analysis_path: Path) -> Path | None:
"""Find candidate files in the artifacts directory."""
roots = (analysis_path.parent / "artifacts", analysis_path.parent)
for root in roots:
# Prefer the post-verification artifact whenever both files exist.
for name in ("encryption_candidates.verified.json", "encryption_candidates.json"):
path = root / name
if path.exists():
return path
return None
def build_script(config: dict, gate_errors: dict[str, str]) -> str:
"""Build the JSRPC injection script with evidence gating."""
serialized = json.dumps(config, ensure_ascii=False, indent=2)
errors = json.dumps(gate_errors, ensure_ascii=False, indent=2)
return f'''// Generated by emit_jsrpc_stub.py. This file only invokes page runtime functions.
(() => {{
"use strict";
const CONFIG = {serialized};
const EVIDENCE_ERRORS = {errors};
function errorValue(parameter, name, message) {{
return "__JSRPC_ERROR__:" + parameter + ":" + name + ":" + message;
}}
function getByPath(root, path) {{
if (!root || !path) return null;
return String(path).replace(/^window\\./, "").split(".").reduce((current, key) =>
current == null ? null : current[key], root);
}}
function parentPath(path) {{
const parts = String(path || "").split(".");
parts.pop();
return parts.join(".");
}}
function resolveEntrypoint(parameterConfig) {{
const entrypoint = parameterConfig.entrypoint || {{}};
if (entrypoint.type === "resolver") {{
const resolver = getByPath(window, entrypoint.resolver_path || entrypoint.resolver_name);
if (typeof resolver !== "function") return null;
const resolved = resolver();
return typeof resolved === "string" ? getByPath(window, resolved) : resolved;
}}
if (entrypoint.type === "webpack_export") {{
const requireFn = window.__JSRA_require || window.__JSRA_WEBPACK_REQUIRE__;
if (typeof requireFn !== "function") throw new Error("Webpack require was not captured");
const moduleValue = requireFn(entrypoint.module_id);
if (!moduleValue) throw new Error("Webpack module was not found: " + entrypoint.module_id);
return getByPath(moduleValue, entrypoint.export_path || "default");
}}
return getByPath(window, entrypoint.path || "");
}}
function resolveThis(parameterConfig) {{
const runtime = parameterConfig.runtime || {{}};
if (runtime.bind_this_path) return getByPath(window, runtime.bind_this_path);
if (runtime.bind_this_mode === "window" || runtime.bind_this_mode === "global") return window;
if (runtime.bind_this_mode === "entrypoint_parent")
return getByPath(window, parentPath((parameterConfig.entrypoint || {{}}).path));
return null;
}}
function normalizeInput(payload) {{
if (typeof payload === "string") {{
try {{
const parsed = JSON.parse(payload);
if (parsed && typeof parsed === "object") return parsed;
}} catch (_ ) {{}}
return {{ value: payload }};
}}
if (payload && typeof payload === "object") {{
if (Object.prototype.hasOwnProperty.call(payload, "value") || Array.isArray(payload.args))
return payload;
if (Object.prototype.hasOwnProperty.call(payload, "data"))
return {{ value: payload.data, parameter: payload.parameter }};
return {{ value: payload }};
}}
return {{ value: payload }};
}}
function coerce(value) {{
if (value == null) return "";
if (typeof value === "string") return value;
if (typeof value === "number" || typeof value === "boolean") return String(value);
return value;
}}
function bindInputFields(payload, parameterConfig, raw) {{
const bindings = parameterConfig.dom_bindings || {{}};
const fields = payload.fields || (payload.context && payload.context.fields) || payload;
for (const [source, elementId] of Object.entries(bindings)) {{
const element = document.getElementById(elementId);
if (!element) continue;
const value = source === "value" ? raw : fields[source];
if (value !== undefined) element.value = String(value);
}}
}}
function resultOrError(parameter, input, result) {{
if (result === undefined || result === null)
return errorValue(parameter, "InvalidResult", "page function returned no value");
if (typeof result === "string" && result.length === 0)
return errorValue(parameter, "InvalidResult", "page function returned an empty value");
if (typeof result === "string" && result.indexOf("__JSRPC_ERROR__:") === 0) return result;
if (result instanceof ArrayBuffer) return Array.from(new Uint8Array(result));
return result;
}}
function serializeRequestBody(body) {{
if (body == null) return null;
if (typeof body === "string") return body;
if (body instanceof URLSearchParams) return body.toString();
if (body instanceof FormData) {{
const values = {{}};
for (const [key, value] of body.entries())
values[key] = typeof value === "string" ? value : `[${{value.constructor.name}}]`;
return values;
}}
if (body instanceof ArrayBuffer) return `[ArrayBuffer:${{body.byteLength}}]`;
if (ArrayBuffer.isView(body)) return `[${{body.constructor.name}}:${{body.byteLength}}]`;
try {{ return JSON.stringify(body); }} catch (_) {{ return String(body); }}
}}
function headerObject(headers) {{
if (!headers) return {{}};
try {{ return Object.fromEntries(new Headers(headers).entries()); }}
catch (_) {{ return {{}}; }}
}}
function matchesRoute(url, expected) {{
if (!expected) return true;
try {{
const actualPath = new URL(url, window.location.href).pathname.replace(/\\/+$/, "");
const expectedPath = new URL(expected, window.location.href).pathname.replace(/\\/+$/, "");
return actualPath === expectedPath;
}} catch (_) {{
return String(url).split("?", 1)[0] === String(expected).split("?", 1)[0];
}}
}}
async function invokeWithNetworkCapture(fn, thisArg, args, captureConfig = {{}}) {{
const originalFetch = window.fetch;
const originalAlert = window.alert;
const xhrPrototype = window.XMLHttpRequest && window.XMLHttpRequest.prototype;
const originalXhrOpen = xhrPrototype && xhrPrototype.open;
const originalXhrSend = xhrPrototype && xhrPrototype.send;
const originalXhrSetRequestHeader = xhrPrototype && xhrPrototype.setRequestHeader;
const timeoutMs = 10000;
let captured = null;
const requests = [];
let resolveNetwork;
const networkSeen = new Promise(resolve => {{ resolveNetwork = resolve; }});
let restored = false;
const suppressPageUi = captureConfig.suppress_page_success === true;
const recordNetwork = record => {{
requests.push(record);
const expected = String(captureConfig.url_contains || captureConfig.route || "");
if (matchesRoute(record.url, expected)) {{
captured = record;
resolveNetwork(captured);
return true;
}}
return false;
}};
const parseXhrResponse = xhr => {{
try {{
const value = xhr.responseType === "" || xhr.responseType === "text"
? xhr.responseText : xhr.response;
if (typeof value === "string") {{
try {{ return JSON.parse(value); }} catch (_) {{ return value; }}
}}
return serializeRequestBody(value);
}} catch (_) {{ return null; }}
}};
const withTimeout = (promise, ms, fallback) => {{
let timer;
const timeout = new Promise(resolve => {{
timer = setTimeout(() => resolve(fallback), ms);
}});
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
}};
const restore = () => {{
if (restored) return;
restored = true;
if (suppressPageUi) {{
try {{ window.alert = originalAlert; }} catch (_) {{}}
}}
try {{
Object.defineProperty(window, "fetch", {{
configurable: true, writable: true, value: originalFetch
}});
}} catch (_) {{ try {{ window.fetch = originalFetch; }} catch (__) {{}} }}
if (xhrPrototype) {{
for (const [key, value] of [["open", originalXhrOpen], ["send", originalXhrSend], ["setRequestHeader", originalXhrSetRequestHeader]]) {{
if (typeof value !== "function") continue;
try {{ Object.defineProperty(xhrPrototype, key, {{ configurable: true, writable: true, value }}); }}
catch (_) {{ try {{ xhrPrototype[key] = value; }} catch (__) {{}} }}
}}
}}
}};
// Challenge pages commonly use alert() before redirecting on either
// success or failure. A synthetic response used only to keep the page
// in place must not leave an external JSRPC caller blocked by a modal.
// Keep the suppression scoped to this invocation and restore the native
// function in every exit path.
if (suppressPageUi && typeof originalAlert === "function") {{
try {{ window.alert = () => {{}}; }} catch (_) {{}}
}}
if (typeof originalFetch === "function") {{
const captureFetch = async function (input, init = {{}}) {{
const url = typeof input === "string" ? input : (input && input.url) || "";
const method = (init && init.method) || (input && input.method) || "GET";
const body = init && Object.prototype.hasOwnProperty.call(init, "body")
? init.body : (input && input.body);
const response = await Reflect.apply(originalFetch, this, arguments);
let responseBody = null;
try {{ responseBody = await response.clone().json(); }} catch (_) {{
try {{ responseBody = await response.clone().text(); }} catch (__) {{}}
}}
const record = {{
url,
method,
headers: headerObject(init && init.headers),
requestBody: serializeRequestBody(body),
status: response.status,
response: responseBody
}};
if (recordNetwork(record)) {{
if (suppressPageUi) {{
try {{
return new Response(JSON.stringify({{ success: false }}), {{
status: response.status,
headers: {{ "Content-Type": "application/json" }}
}});
}} catch (_) {{}}
}}
}}
return response;
}};
try {{
Object.defineProperty(window, "fetch", {{
configurable: true, writable: true, value: captureFetch
}});
}} catch (_) {{
try {{ window.fetch = captureFetch; }} catch (__) {{}}
}}
}}
if (xhrPrototype && typeof originalXhrOpen === "function" && typeof originalXhrSend === "function") {{
try {{
Object.defineProperty(xhrPrototype, "open", {{
configurable: true, writable: true,
value: function(method, url) {{
this.__jsraCapture = {{ method: String(method || "GET"), url: String(url || ""), headers: {{}} }};
return Reflect.apply(originalXhrOpen, this, arguments);
}}
}});
if (typeof originalXhrSetRequestHeader === "function") {{
Object.defineProperty(xhrPrototype, "setRequestHeader", {{
configurable: true, writable: true,
value: function(name, value) {{
const capture = this.__jsraCapture || (this.__jsraCapture = {{ method: "GET", url: "", headers: {{}} }});
capture.headers[String(name)] = String(value);
return Reflect.apply(originalXhrSetRequestHeader, this, arguments);
}}
}});
}}
Object.defineProperty(xhrPrototype, "send", {{
configurable: true, writable: true,
value: function(body) {{
const xhr = this;
const capture = xhr.__jsraCapture || {{ method: "GET", url: "", headers: {{}} }};
const onLoadEnd = () => {{
const record = {{
transport: "xhr",
url: capture.url,
method: capture.method,
headers: capture.headers,
requestBody: serializeRequestBody(body),
status: xhr.status,
response: parseXhrResponse(xhr)
}};
recordNetwork(record);
try {{ xhr.removeEventListener("loadend", onLoadEnd); }} catch (_) {{}}
}};
try {{ xhr.addEventListener("loadend", onLoadEnd, {{ once: true }}); }} catch (_) {{}}
return Reflect.apply(originalXhrSend, this, arguments);
}}
}});
}} catch (_) {{
// Some pages make XHR methods non-configurable; fetch capture remains available.
}}
}}
let resultState;
try {{
const result = fn.apply(thisArg, args);
resultState = await withTimeout(
Promise.resolve(result).then(value => ({{ value }}), error => ({{ error }})),
timeoutMs, {{ timedOut: true }}
);
}} catch (error) {{
resultState = {{ error }};
}}
const network = await withTimeout(networkSeen, timeoutMs, null);
// The page function may return void while its fetch().then(...) chain is
// still pending. Keep alert() muted through that microtask/macrotask
// boundary so the synthetic response cannot open a modal after capture.
if (suppressPageUi && network) {{
await new Promise(resolve => setTimeout(resolve, 50));
}}
restore();
return {{ resultState, network: network || captured, requests }};
}}
if (typeof Hlclient === "undefined")
throw new Error("Hlclient is unavailable; load JsEnv_Dev.js first");
const client = new Hlclient(CONFIG.ws_url);
client.regAction(CONFIG.action_name, function(resolve, rawPayload) {{
const payload = normalizeInput(rawPayload);
const parameter = payload.parameter || Object.keys(CONFIG.parameters)[0];
const parameterConfig = CONFIG.parameters[parameter];
if (!parameterConfig) {{
resolve(errorValue(parameter, "UnknownParameter", "no parameter contract"));
return;
}}
// Evidence gate: refuse to call unverified candidates
const evidenceError = EVIDENCE_ERRORS[parameter];
if (evidenceError) {{
resolve(errorValue(parameter, "EvidenceMissing", evidenceError));
return;
}}
try {{
const fn = resolveEntrypoint(parameterConfig);
if (typeof fn !== "function")
throw new Error("verified page entrypoint is not callable");
const raw = payload.value;
bindInputFields(payload, parameterConfig, raw);
let args = Array.isArray(payload.args) ? payload.args.map(coerce) : [coerce(raw)];
const transform = parameterConfig.jsra_transform || {{}};
if (Array.isArray(transform.arguments)) {{
args = transform.arguments.map((arg) =>
arg && arg.source === "constant" ? arg.value : coerce(raw));
}}
const captureConfig = parameterConfig.capture || transform.capture || {{}};
invokeWithNetworkCapture(fn, resolveThis(parameterConfig), args, captureConfig)
.then((invocation) => {{
const resultState = invocation.resultState || {{}};
if (resultState.error) throw resultState.error;
if (invocation.network) {{
resolve({{
success: true,
parameter,
plaintext: raw,
result: resultState.value === undefined ? null : resultState.value,
request: invocation.network,
requestBody: invocation.network.requestBody,
response: invocation.network.response,
status: invocation.network.status
}});
return;
}}
resolve(resultOrError(parameter, raw, resultState.value));
}})
.catch((error) => resolve(errorValue(parameter, error.name || "Error", error.message || String(error))));
}} catch (error) {{
resolve(errorValue(parameter, error.name || "Error", error.message || String(error)));
}}
}});
window.__JSRA_RPC_CLIENT__ = client;
console.info("[JSRA] registered evidence-gated action", CONFIG.action_name);
}})();
'''
def main() -> int:
parser = argparse.ArgumentParser(description="Generate evidence-gated JSRPC registration code.")
parser.add_argument("--analysis", required=True, help="Path to analysis_result.json.")
parser.add_argument("--candidates", help="Path to encryption_candidates.json.")
parser.add_argument("--output", required=True, help="Output JS file path.")
args = parser.parse_args()
analysis_path = Path(args.analysis)
analysis = load_json(analysis_path)
config = normalize_analysis(analysis)
# Load candidates for evidence gating
candidates_data: dict = {}
candidate_path = Path(args.candidates) if args.candidates else find_candidate_file(analysis_path)
if candidate_path and candidate_path.exists():
try:
candidates_data = load_json(candidate_path)
except (OSError, ValueError):
candidates_data = {}
by_path = {
c.get("path"): c
for c in candidates_data.get("candidates", [])
if isinstance(c, dict) and c.get("path")
}
# Build evidence gate errors
gate_errors: dict[str, str] = {}
for name, parameter in config["parameters"].items():
transform = parameter.get("jsra_transform") or {}
path = (parameter.get("entrypoint") or {}).get("path") or transform.get("candidate_path")
candidate = by_path.get(path)
ok, reason = candidate_verified(candidate)
if not ok:
gate_errors[name] = f"{reason}; path={path or '<missing>'}"
if transform.get("safe_to_invoke") is True and not ok:
gate_errors[name] = f"safe_to_invoke requires verified runtime evidence: {gate_errors[name]}"
output = Path(args.output)
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(build_script(config, gate_errors), encoding="utf-8")
status = {
"status": "ok" if not gate_errors else "quarantined",
"output": str(output),
"evidence_errors": gate_errors
}
print(json.dumps(status, ensure_ascii=False))
return 0 if not gate_errors else 2
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,140 @@
#!/usr/bin/env python3
"""Generate a Webpack/module runtime discovery probe JS file.
The probe attempts to discover module systems (Webpack 4/5, Vite, Rollup)
and extract candidate exports. Results written to window.__JSRA_MODULES__.
Uses the webpackChunk*.push() trick to capture __webpack_require__ without
batch-executing uninitialized module factories.
Usage:
python3 scripts/emit_module_probe.py --output generated/module_probe.js
"""
from __future__ import annotations
import argparse
import json
from pathlib import Path
PROBE = r'''(() => {
"use strict";
const dump = {
timestamp: Date.now(),
bundlers: [],
modules: [],
globals: [],
errors: []
};
const seen = new Set();
function previewSource(fn) {
try { return Function.prototype.toString.call(fn).slice(0, 1200); } catch (_) { return ""; }
}
function addExport(moduleId, path, value, source) {
if (typeof value !== "function") return;
const id = `${source}:${moduleId}:${path}`;
if (seen.has(id)) return;
seen.add(id);
dump.modules.push({
id, source, module_id: String(moduleId),
export_path: path, name: value.name || path,
type: "function", source_snippet: previewSource(value)
});
}
function walkExports(moduleId, value, prefix, source, depth = 0) {
if (depth > 2 || value == null) return;
if (typeof value === "function") addExport(moduleId, prefix || "default", value, source);
if ((typeof value === "object" || typeof value === "function") && value) {
let keys = [];
try { keys = Object.keys(value).slice(0, 100); } catch (_) { return; }
keys.forEach(key => {
let child; try { child = value[key]; } catch (_) { return; }
const next = prefix ? `${prefix}.${key}` : key;
if (typeof child === "function") addExport(moduleId, next, child, source);
else if (depth < 2 && child && typeof child === "object")
walkExports(moduleId, child, next, source, depth + 1);
});
}
}
function inspectRequire(req, source) {
if (!req) return;
try {
window.__JSRA_WEBPACK_REQUIRE_MAP__ = window.__JSRA_WEBPACK_REQUIRE_MAP__ || {};
window.__JSRA_WEBPACK_REQUIRE_MAP__[source] = req;
if (!window.__JSRA_WEBPACK_REQUIRE__) window.__JSRA_WEBPACK_REQUIRE__ = req;
} catch (_) {}
const cache = req.c || {};
Object.keys(cache).forEach(id => {
try { walkExports(id, cache[id] && cache[id].exports, "", source); }
catch (error) { dump.errors.push(String(error)); }
});
dump.bundlers.push({
type: "webpack", source,
cached_modules: Object.keys(cache).length,
factories: req.m ? Object.keys(req.m).length : null
});
}
// === Webpack 5: hook webpackChunk*.push() ===
Object.keys(window).filter(k => /^webpackChunk/.test(k)).forEach(key => {
const chunk = window[key];
if (!Array.isArray(chunk) || typeof chunk.push !== "function") return;
let captured = null;
const token = `jsra_${Date.now()}_${Math.random().toString(16).slice(2)}`;
try {
// Push a dummy chunk to capture __webpack_require__
chunk.push([[token], {}, function(__webpack_require__) {
captured = __webpack_require__;
}]);
if (captured) inspectRequire(captured, key);
} catch (error) {
dump.errors.push(`${key}: ${error}`);
}
});
// === Webpack 3/4: webpackJsonp ===
if (typeof window.webpackJsonp === "function") {
dump.bundlers.push({
type: "webpack-legacy", source: "webpackJsonp",
note: "Detected; use runtime-specific adapter if require is not exposed."
});
}
// === Global exports scan ===
Object.getOwnPropertyNames(window).slice(0, 5000).forEach(key => {
let value; try { value = window[key]; } catch (_) { return; }
if (typeof value === "function") {
const source = previewSource(value);
if (/encrypt|decrypt|sign|hmac|digest|hash|rsa|aes|sm2|sm4|crypto/i.test(key + " " + source)) {
dump.globals.push({
id: `global:${key}`, path: `window[${JSON.stringify(key)}]`,
name: key, type: "function", source_snippet: source
});
}
}
});
window.__JSRA_MODULE_DUMP__ = dump;
console.info("[JSRA] module probe complete", dump);
return dump;
})();
'''
def main() -> int:
parser = argparse.ArgumentParser(description="Emit safe Webpack/global module probe.")
parser.add_argument("--output", required=True, help="Output JS file path.")
args = parser.parse_args()
path = Path(args.output)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(PROBE, encoding="utf-8")
print(json.dumps({"status": "ok", "output": str(path)}, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,339 @@
#!/usr/bin/env python3
"""Generate a runtime hook probe JS file for tracing fetch/XHR/crypto/serializers.
The probe writes evidence to window.__JSRA_TRACE__ using a unified event model
with event_id, trace_id, parent_event_id, input_fingerprint, output_fingerprint.
Supports SHA-256 fingerprinting and sensitive field redaction.
Usage:
python3 scripts/emit_runtime_hook_probe.py --output generated/runtime_hook_probe.js
python3 scripts/emit_runtime_hook_probe.py --output generated/runtime_hook_probe.js --max-events 5000
python3 scripts/emit_runtime_hook_probe.py --output generated/runtime_hook_probe.js --capture-raw
"""
from __future__ import annotations
import argparse
import json
from pathlib import Path
HOOK_REGISTRY = (Path(__file__).with_name("hook_registry.js")).read_text(encoding="utf-8")
TEMPLATE = r'''(() => {
"use strict";
const PROBE_ID = "runtime-hook";
const CONFIG = __CONFIG__;
const root = window;
__HOOK_REGISTRY__
const registry = getHookRegistry(root);
if (window.__JSRA_TRACE__ && window.__JSRA_TRACE__.probe_id === PROBE_ID) {
return window.__JSRA_TRACE__;
}
const sensitive = /pass(word)?|token|secret|authorization|cookie|session|credential/i;
const nativeJSONStringify = JSON.stringify.bind(JSON);
const nativeDigest = window.crypto && crypto.subtle
? registry.native(crypto.subtle, "digest").bind(crypto.subtle) : null;
const nativeTextEncode = window.TextEncoder
? registry.native(TextEncoder.prototype, "encode") : null;
const state = { probe_id: PROBE_ID, installedAt: Date.now(), events: [], maxEvents: CONFIG.maxEvents, dropped: 0 };
let eventCounter = 0;
let activeTraceId = null;
let libraryTimer = null;
const runtimeLayers = [];
let originalRequest = null;
function uuid(prefix) {
eventCounter += 1;
return `${prefix}-${Date.now().toString(36)}-${eventCounter.toString(36)}`;
}
function stack() {
try { return String(new Error().stack || "").split("\n").slice(2, 14); } catch (_) { return []; }
}
function stableString(value) {
try {
if (typeof value === "string") return value;
if (value instanceof ArrayBuffer) return Array.from(new Uint8Array(value)).join(",");
if (ArrayBuffer.isView(value))
return Array.from(new Uint8Array(value.buffer, value.byteOffset, value.byteLength)).join(",");
return nativeJSONStringify(value);
} catch (_) { return String(value); }
}
function safeClone(value, depth = 0, seen = new WeakSet()) {
if (value === null || value === undefined) return value;
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return value;
if (typeof value === "bigint") return `${value}n`;
if (typeof value === "function") return "[function]";
if (value instanceof ArrayBuffer) return `[ArrayBuffer:${value.byteLength}]`;
if (ArrayBuffer.isView(value)) return `[${value.constructor.name}:${value.byteLength}]`;
if (depth >= 4) return "[MaxDepth]";
if (seen.has(value)) return "[Circular]";
seen.add(value);
if (Array.isArray(value)) return value.slice(0, 32).map(item => safeClone(item, depth + 1, seen));
const output = {};
for (const key of Object.keys(value).slice(0, 32)) {
if (sensitive.test(key)) output[key] = "<redacted>";
else {
try { output[key] = safeClone(value[key], depth + 1, seen); }
catch (_) { output[key] = "[Unreadable]"; }
}
}
return output;
}
async function sha256(value) {
try {
if (!nativeDigest || !nativeTextEncode) return null;
const data = nativeTextEncode.call(new TextEncoder(), stableString(value));
const digest = await nativeDigest("SHA-256", data);
return "sha256:" + Array.from(new Uint8Array(digest))
.map(b => b.toString(16).padStart(2, "0")).join("");
} catch (_) { return null; }
}
function preview(value, key = "") {
if (sensitive.test(key)) return "<redacted>";
if (CONFIG.captureRaw) return safeClone(value);
// JSON.stringify(undefined) returns undefined. Normalise it before
// measuring/slicing so an optional crypto or WebSocket argument cannot
// abort the entire target page.
const text = String(stableString(value) ?? "");
return text.length > 96 ? text.slice(0, 96) + "…" : text;
}
async function record(type, detail = {}, parentEventId = null) {
const event = {
event_id: uuid("evt"),
trace_id: detail.trace_id || activeTraceId || uuid("trace"),
parent_event_id: parentEventId,
type,
function: detail.function || null,
url: detail.url || null,
input_preview: preview(detail.input, detail.key || ""),
output_preview: preview(detail.output, detail.key || ""),
input_fingerprint: detail.hasOwnProperty("input") ? await sha256(detail.input) : null,
output_fingerprint: detail.hasOwnProperty("output") ? await sha256(detail.output) : null,
stack: detail.stack || stack(),
metadata: detail.metadata || {},
timestamp: Date.now()
};
state.events.push(event);
if (state.events.length > state.maxEvents) {
state.events.splice(0, state.events.length - state.maxEvents);
state.dropped += 1;
}
return event;
}
async function recordFields(traceId, body, parentEventId) {
let parsed = null;
if (body == null) return;
try {
if (typeof body === "string") {
const trimmed = body.trim();
if (trimmed.startsWith("{") || trimmed.startsWith("["))
parsed = JSON.parse(trimmed);
else
parsed = Object.fromEntries(new URLSearchParams(body).entries());
} else if (body instanceof URLSearchParams) {
parsed = Object.fromEntries(body.entries());
} else if (typeof body === "object" && !(body instanceof ArrayBuffer) && !ArrayBuffer.isView(body)) {
parsed = body;
}
} catch (_) { parsed = null; }
async function walk(value, path, depth) {
if (depth > 4) return;
if (value && typeof value === "object") {
const entries = Array.isArray(value) ? value.entries() : Object.entries(value);
for (const [key, child] of entries)
await walk(child, path ? `${path}.${key}` : String(key), depth + 1);
} else {
await record("network.field", {
trace_id: traceId, function: "request-field",
input: value, key: path, metadata: { path }
}, parentEventId);
}
}
if (parsed !== null) await walk(parsed, "$", 0);
}
function addRuntimeLayer(target, key, id, factory) {
const layer = registry.add(target, key, id, factory);
if (layer && !layer.duplicate) runtimeLayers.push({ target, key, id });
return layer;
}
function wrapMethod(target, key, type, name, options = {}) {
if (!target || typeof target[key] !== "function") return;
addRuntimeLayer(target, key, `runtime:${name}`, original => {
const wrapped = function(...args) {
if (registry.internalDepth > 0) return original.apply(this, args);
if (typeof options.skip === "function" && options.skip.call(this, args))
return original.apply(this, args);
const traceId = activeTraceId || uuid("trace");
activeTraceId = traceId;
let result;
try { result = original.apply(this, args); }
catch (error) {
record(type + ".error", { trace_id: traceId, function: name, input: args, output: String(error) });
throw error;
}
if (result && typeof result.then === "function") {
return result.then(value => {
record(type, { trace_id: traceId, function: name, input: args, output: value });
return value;
});
}
record(type, { trace_id: traceId, function: name, input: args, output: result });
return result;
};
Object.defineProperty(wrapped, "__jsraWrapped", { value: true });
return wrapped;
});
}
// === Network hooks ===
if (typeof window.fetch === "function") {
addRuntimeLayer(window, "fetch", "runtime:network.fetch", original => async function(input, init = {}) {
if (registry.internalDepth > 0) return original.apply(this, arguments);
const traceId = uuid("trace"); activeTraceId = traceId;
const url = typeof input === "string" ? input : input && input.url;
const body = init.body || (input && input.body) || null;
const networkEvent = await record("network.fetch", {
trace_id: traceId, function: "fetch", url, input: body,
metadata: { method: init.method || "GET" }
});
await recordFields(traceId, body, networkEvent.event_id);
return original.apply(this, arguments);
});
}
if (window.XMLHttpRequest) {
addRuntimeLayer(XMLHttpRequest.prototype, "open", "runtime:network.xhr.open", original => function(method, url) {
if (registry.internalDepth > 0) return original.apply(this, arguments);
this.__jsra = { method, url }; return original.apply(this, arguments);
});
addRuntimeLayer(XMLHttpRequest.prototype, "send", "runtime:network.xhr.send", original => function(body) {
if (registry.internalDepth > 0) return original.apply(this, arguments);
const traceId = uuid("trace"); activeTraceId = traceId;
record("network.xhr", {
trace_id: traceId, function: "XMLHttpRequest.send",
url: this.__jsra && this.__jsra.url, input: body,
metadata: { method: this.__jsra && this.__jsra.method }
}).then(event => recordFields(traceId, body, event.event_id));
return original.apply(this, arguments);
});
}
if (window.Request) {
originalRequest = window.Request;
window.Request = new Proxy(originalRequest, {
construct(target, args, newTarget) {
const instance = Reflect.construct(target, args, newTarget);
record("network.request.construct", {
function: "Request", url: instance.url,
input: args[1] && args[1].body
});
return instance;
}
});
}
// === Serializer / encoding hooks ===
if (window.WebSocket) wrapMethod(WebSocket.prototype, "send", "network.websocket", "WebSocket.send", {
skip: function() {
try {
const url = String(this && this.url || "");
return (CONFIG.controlWebSocketUrls || []).some(fragment => url.includes(fragment));
} catch (_) { return false; }
}
});
if (window.JSON) wrapMethod(JSON, "stringify", "serializer.json", "JSON.stringify");
if (window.URLSearchParams)
wrapMethod(URLSearchParams.prototype, "toString", "serializer.urlsearchparams", "URLSearchParams.toString");
if (window.FormData) wrapMethod(FormData.prototype, "append", "serializer.formdata", "FormData.append");
if (window.TextEncoder) wrapMethod(TextEncoder.prototype, "encode", "encoding.text", "TextEncoder.encode");
if (window.TextDecoder) wrapMethod(TextDecoder.prototype, "decode", "encoding.text", "TextDecoder.decode");
wrapMethod(window, "btoa", "encoding.base64", "btoa");
wrapMethod(window, "atob", "encoding.base64", "atob");
// === WebCrypto hooks ===
if (window.crypto && crypto.subtle) {
["encrypt", "decrypt", "digest", "sign", "verify", "deriveBits", "deriveKey"].forEach(name => {
wrapMethod(crypto.subtle, name, "crypto.webcrypto", `crypto.subtle.${name}`);
});
}
// === Library hooks (CryptoJS, JSEncrypt, sm2/sm3/sm4) ===
function installLibraryHooks() {
try {
if (window.CryptoJS) {
["AES", "DES", "TripleDES", "Rabbit", "RC4"].forEach(alg => {
const obj = window.CryptoJS[alg];
if (obj)
["encrypt", "decrypt"].forEach(op =>
wrapMethod(obj, op, "crypto.cryptojs", `CryptoJS.${alg}.${op}`));
});
["MD5", "SHA1", "SHA256", "SHA512", "HmacSHA256"].forEach(name =>
wrapMethod(window.CryptoJS, name, "crypto.cryptojs", `CryptoJS.${name}`));
}
if (window.JSEncrypt && window.JSEncrypt.prototype) {
["encrypt", "decrypt", "sign", "verify"].forEach(op =>
wrapMethod(window.JSEncrypt.prototype, op, "crypto.jsencrypt", `JSEncrypt.${op}`));
}
if (window.sm2)
["doEncrypt", "doDecrypt", "doSignature", "doVerifySignature"].forEach(op =>
wrapMethod(window.sm2, op, "crypto.sm", `sm2.${op}`));
if (window.sm3) wrapMethod(window, "sm3", "crypto.sm", "sm3");
if (window.sm4)
["encrypt", "decrypt"].forEach(op =>
wrapMethod(window.sm4, op, "crypto.sm", `sm4.${op}`));
} catch (_) {}
}
installLibraryHooks();
// Re-install periodically to catch late-loading libraries
libraryTimer = setInterval(installLibraryHooks, 2000);
// === Public API ===
window.__JSRA_TRACE__ = {
probe_id: PROBE_ID,
state,
markTrace(label = "manual") { activeTraceId = uuid(label); return activeTraceId; },
clearTrace() { activeTraceId = null; },
async record(type, detail) { return record(type, detail); },
export() { return { probe_id: PROBE_ID, exportedAt: Date.now(), events: state.events.slice() }; },
dump() { return nativeJSONStringify(this.export()); },
uninstall() {
if (libraryTimer) clearInterval(libraryTimer);
for (const layer of runtimeLayers.slice().reverse()) registry.remove(layer.target, layer.key, layer.id);
if (originalRequest) window.Request = originalRequest;
}
};
console.info("[JSRA] runtime probe installed");
return window.__JSRA_TRACE__;
})();
'''
def main() -> int:
parser = argparse.ArgumentParser(description="Emit JSRA runtime hook probe.")
parser.add_argument("--output", required=True, help="Output JS file path.")
parser.add_argument("--max-events", type=int, default=3000, help="Max events to keep.")
parser.add_argument("--capture-raw", action="store_true", help="Store raw values instead of previews.")
parser.add_argument("--params", default="", help="Comma-separated target parameter names to watch (for documentation).")
parser.add_argument("--control-websocket-url", action="append", default=[],
help="URL fragment for a JSRPC/control WebSocket to leave untouched.")
args = parser.parse_args()
control_urls = args.control_websocket_url or ["127.0.0.1:12080", "localhost:12080"]
config = {"maxEvents": max(100, args.max_events), "captureRaw": bool(args.capture_raw),
"controlWebSocketUrls": control_urls}
# --params is retained for backward compatibility but not used in the probe
# The probe watches all crypto-related activity automatically.
if args.params:
config["targetParams"] = [p.strip() for p in args.params.split(",") if p.strip()]
content = TEMPLATE.replace("__CONFIG__", json.dumps(config))
content = content.replace("__HOOK_REGISTRY__", HOOK_REGISTRY)
path = Path(args.output)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
print(json.dumps({"status": "ok", "output": str(path)}, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,165 @@
#!/usr/bin/env python3
"""Environment patcher for running obfuscated JS in Node.js VM sandbox.
Diagnoses missing browser APIs and generates patches to make the JS runnable.
Usage:
python3 scripts/env_patcher.py --input bundle.js --output artifacts/env_report.json
"""
from __future__ import annotations
import argparse
import json
import subprocess
import tempfile
from pathlib import Path
from common import dump_json, load_json
DIAGNOSE_SCRIPT = r'''
const vm = require('vm');
const fs = require('fs');
const input = process.argv[2];
const code = fs.readFileSync(input, 'utf8');
// Create sandbox with basic browser APIs
const sandbox = {
window: {}, self: {}, global: {}, globalThis: {},
console: { log: () => {}, warn: () => {}, error: () => {} },
setTimeout: (fn, ms) => setTimeout(fn, ms),
setInterval: (fn, ms) => setInterval(fn, ms),
clearTimeout: (id) => clearTimeout(id),
clearInterval: (id) => clearInterval(id),
atob: (s) => Buffer.from(s, 'base64').toString('binary'),
btoa: (s) => Buffer.from(s, 'binary').toString('base64'),
TextEncoder: TextEncoder,
TextDecoder: TextDecoder,
URL: URL,
URLSearchParams: URLSearchParams,
};
// Make window/self/global/globalThis point to sandbox
sandbox.window = sandbox;
sandbox.self = sandbox;
sandbox.global = sandbox;
sandbox.globalThis = sandbox;
// Proxy to track undefined access
const undefinedPaths = new Set();
const proxy = new Proxy(sandbox, {
get(target, prop) {
if (prop === Symbol.unscopables) return undefined;
if (prop in target) return target[prop];
if (typeof prop === 'string' && !prop.startsWith('_')) {
undefinedPaths.add(prop);
}
return undefined;
},
set(target, prop, value) {
target[prop] = value;
return true;
}
});
// Run in VM
const context = vm.createContext(proxy);
try {
vm.runInContext(code, context, { timeout: 5000 });
console.log(JSON.stringify({
success: true,
error: null,
undefinedPaths: Array.from(undefinedPaths).sort(),
stats: { events: undefinedPaths.size }
}));
} catch (error) {
console.log(JSON.stringify({
success: false,
error: error.message,
undefinedPaths: Array.from(undefinedPaths).sort(),
stats: { events: undefinedPaths.size }
}));
}
'''
def diagnose(js_path: str) -> dict:
"""Run diagnosis on a JS file to find missing browser APIs."""
with tempfile.NamedTemporaryFile(mode='w', suffix='.js', delete=False) as f:
f.write(DIAGNOSE_SCRIPT)
diag_script = f.name
try:
result = subprocess.run(
['node', diag_script, js_path],
capture_output=True, text=True, timeout=10
)
if result.returncode == 0:
return json.loads(result.stdout)
return {"success": False, "error": result.stderr, "undefinedPaths": []}
except Exception as e:
return {"success": False, "error": str(e), "undefinedPaths": []}
finally:
Path(diag_script).unlink(missing_ok=True)
# Mapping from undefined paths to required modules
MODULE_MAP = {
'navigator': 'bom/navigator',
'location': 'bom/location',
'screen': 'bom/screen',
'localStorage': 'bom/storage',
'sessionStorage': 'bom/storage',
'crypto': 'bom/crypto',
'performance': 'bom/performance',
'history': 'bom/history',
'document': 'dom/document',
'Element': 'dom/elements',
'HTMLElement': 'dom/elements',
'Event': 'dom/event',
'fetch': 'webapi/fetch',
'XMLHttpRequest': 'webapi/xhr',
'Blob': 'webapi/blob',
'FormData': 'webapi/blob',
'File': 'webapi/blob',
'WebSocket': 'webapi/network',
'TextEncoder': 'encoding/textencoder',
'TextDecoder': 'encoding/textencoder',
'atob': 'encoding/atob',
'btoa': 'encoding/atob',
}
def select_modules(undefined_paths: list[str]) -> list[str]:
"""Select required modules based on undefined paths."""
modules = set()
for path in undefined_paths:
for prefix, module in MODULE_MAP.items():
if path.startswith(prefix) or path == prefix:
modules.add(module)
return sorted(modules)
def main() -> int:
parser = argparse.ArgumentParser(description="Environment patcher for JS sandbox.")
parser.add_argument("--input", required=True, help="Path to JS file.")
parser.add_argument("--output", required=True, help="Path to output report.")
args = parser.parse_args()
report = diagnose(args.input)
if report.get("undefinedPaths"):
report["selectedModules"] = select_modules(report["undefinedPaths"])
dump_json(args.output, report)
print(json.dumps({
"success": report.get("success", False),
"undefinedPaths": len(report.get("undefinedPaths", [])),
"selectedModules": len(report.get("selectedModules", [])),
"output": args.output
}, ensure_ascii=False))
return 0 if report.get("success") else 1
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,134 @@
// Shared hook composition runtime.
// The function stays local to each generated probe while the registry state is
// shared through window, allowing independently generated probes to compose.
function getHookRegistry(root) {
const registryKey = "__JSRA_HOOK_REGISTRY__";
const existing = root[registryKey];
if (existing && existing.version === 1) return existing;
const targets = new WeakMap();
function targetEntries(target, create = false) {
let entries = targets.get(target);
if (!entries && create) {
entries = new Map();
targets.set(target, entries);
}
return entries;
}
function entryFor(target, key, create = false) {
const entries = targetEntries(target, create);
if (!entries) return null;
let entry = entries.get(key);
if (!entry && create) {
if (!target || typeof target[key] !== "function") return null;
entry = {
target,
key,
base: target[key],
current: target[key],
layers: []
};
entries.set(key, entry);
}
return entry || null;
}
function assign(entry, value) {
try {
entry.target[entry.key] = value;
return entry.target[entry.key] === value;
} catch (_) {
return false;
}
}
function rebuild(entry) {
let current = entry.base;
for (const layer of entry.layers) {
current = layer.factory(current);
if (typeof current !== "function")
throw new TypeError(`Hook factory did not return a function: ${layer.id}`);
}
entry.current = current;
if (!assign(entry, current)) throw new TypeError(`Hook assignment failed: ${entry.key}`);
return current;
}
function adoptExternal(entry) {
const current = entry.target[entry.key];
if (current !== entry.current && typeof current === "function") {
entry.base = current;
return true;
}
return false;
}
function add(target, key, id, factory) {
if (!target || typeof target[key] !== "function") return null;
const entry = entryFor(target, key, true);
const duplicate = entry.layers.find(layer => layer.id === id);
if (duplicate) {
adoptExternal(entry);
try { rebuild(entry); } catch (_) {}
return { entry, id, duplicate: true };
}
adoptExternal(entry);
const layer = { id, factory };
entry.layers.push(layer);
try {
rebuild(entry);
return { entry, id, duplicate: false };
} catch (error) {
entry.layers = entry.layers.filter(item => item !== layer);
try { rebuild(entry); } catch (_) {}
throw error;
}
}
function reconcile(target, key) {
const entry = entryFor(target, key, false);
if (!entry) return { status: "none", changed: false };
const changed = adoptExternal(entry);
if (!changed) return { status: "ok", changed: false };
try {
rebuild(entry);
return { status: "rebuilt", changed: true };
} catch (error) {
return { status: "error", changed: true, error: String(error) };
}
}
function remove(target, key, id) {
const entry = entryFor(target, key, false);
if (!entry) return false;
entry.layers = entry.layers.filter(layer => layer.id !== id);
if (entry.layers.length === 0) {
assign(entry, entry.base);
const entries = targetEntries(target, false);
if (entries) entries.delete(key);
return true;
}
rebuild(entry);
return true;
}
const registry = {
version: 1,
add,
reconcile,
remove,
native(target, key) {
const entry = entryFor(target, key, false);
return entry ? entry.base : target && target[key];
},
internalDepth: 0,
runInternal(callback) {
this.internalDepth += 1;
try { return callback(); } finally { this.internalDepth -= 1; }
}
};
root[registryKey] = registry;
return registry;
}

View File

@@ -0,0 +1,198 @@
#!/usr/bin/env python3
"""Hook template generator.
Generates various hook templates for common browser APIs.
Usage:
python3 scripts/hook_templates.py --type cookie --output generated/hooks/cookie.js
python3 scripts/hook_templates.py --type all --output generated/hooks/
"""
from __future__ import annotations
import argparse
import json
from pathlib import Path
TEMPLATES = {
"cookie": r'''
// Cookie Hook - Monitor document.cookie access
(function() {
let _cookie = document.cookie;
const originalDesc = Object.getOwnPropertyDescriptor(Document.prototype, 'cookie');
Object.defineProperty(document, 'cookie', {
get: function() {
console.log('[HOOK] cookie.get:', _cookie.substring(0, 100));
return _cookie;
},
set: function(value) {
console.log('[HOOK] cookie.set:', value.substring(0, 200));
_cookie = value;
if (originalDesc && originalDesc.set) {
originalDesc.set.call(document, value);
}
},
configurable: true
});
console.log('[HOOK] cookie hook installed');
})();
''',
"xhr": r'''
// XMLHttpRequest Hook - Monitor XHR open/send
(function() {
const originalOpen = XMLHttpRequest.prototype.open;
const originalSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function(method, url) {
this.__hook = { method, url, timestamp: Date.now() };
console.log('[HOOK] xhr.open:', method, url);
return originalOpen.apply(this, arguments);
};
XMLHttpRequest.prototype.send = function(body) {
if (this.__hook) {
this.__hook.body = body;
console.log('[HOOK] xhr.send:', this.__hook.method, this.__hook.url,
body ? body.substring(0, 200) : '(no body)');
}
return originalSend.apply(this, arguments);
};
console.log('[HOOK] xhr hook installed');
})();
''',
"fetch": r'''
// Fetch Hook - Monitor fetch calls
(function() {
const originalFetch = window.fetch;
window.fetch = function(input, init) {
const url = typeof input === 'string' ? input : input && input.url;
const method = (init && init.method) || 'GET';
const body = init && init.body;
console.log('[HOOK] fetch:', method, url, body ? body.substring(0, 200) : '(no body)');
return originalFetch.apply(this, arguments);
};
console.log('[HOOK] fetch hook installed');
})();
''',
"eval": r'''
// Eval Hook - Monitor eval/Function calls
(function() {
const originalEval = window.eval;
const originalFunction = window.Function;
window.eval = function(code) {
console.log('[HOOK] eval:', typeof code === 'string' ? code.substring(0, 200) : typeof code);
return originalEval.apply(this, arguments);
};
window.Function = function(...args) {
console.log('[HOOK] new Function:', args.length, 'args');
return originalFunction.apply(this, args);
};
console.log('[HOOK] eval hook installed');
})();
''',
"json": r'''
// JSON Hook - Monitor JSON.parse/stringify
(function() {
const originalParse = JSON.parse;
const originalStringify = JSON.stringify;
JSON.parse = function(text) {
console.log('[HOOK] JSON.parse:', typeof text === 'string' ? text.substring(0, 200) : typeof text);
return originalParse.apply(this, arguments);
};
JSON.stringify = function(value) {
const result = originalStringify.apply(this, arguments);
console.log('[HOOK] JSON.stringify:', result.substring(0, 200));
return result;
};
console.log('[HOOK] json hook installed');
})();
''',
"base64": r'''
// Base64 Hook - Monitor atob/btoa
(function() {
const originalAtob = window.atob;
const originalBtoa = window.btoa;
window.atob = function(encoded) {
console.log('[HOOK] atob:', encoded.substring(0, 100));
return originalAtob.apply(this, arguments);
};
window.btoa = function(text) {
console.log('[HOOK] btoa:', text.substring(0, 100));
return originalBtoa.apply(this, arguments);
};
console.log('[HOOK] base64 hook installed');
})();
''',
"websocket": r'''
// WebSocket Hook - Monitor WebSocket messages
(function() {
const originalSend = WebSocket.prototype.send;
WebSocket.prototype.send = function(data) {
console.log('[HOOK] ws.send:', typeof data === 'string' ? data.substring(0, 200) : typeof data);
return originalSend.apply(this, arguments);
};
console.log('[HOOK] websocket hook installed');
})();
''',
"crypto": r'''
// Crypto Hook - Monitor crypto.subtle operations
(function() {
if (!window.crypto || !window.crypto.subtle) return;
const original = window.crypto.subtle;
const methods = ['encrypt', 'decrypt', 'digest', 'sign', 'verify', 'generateKey', 'importKey', 'exportKey'];
methods.forEach(method => {
if (original[method]) {
const originalMethod = original[method].bind(original);
original[method] = function(...args) {
console.log('[HOOK] crypto.subtle.' + method, args.length, 'args');
return originalMethod.apply(this, args);
};
}
});
console.log('[HOOK] crypto hook installed');
})();
''',
}
def main() -> int:
parser = argparse.ArgumentParser(description="Hook template generator.")
parser.add_argument("--type", required=True, choices=list(TEMPLATES.keys()) + ["all"],
help="Hook type to generate.")
parser.add_argument("--output", required=True, help="Output file or directory.")
args = parser.parse_args()
output = Path(args.output)
if args.type == "all":
output.mkdir(parents=True, exist_ok=True)
for name, template in TEMPLATES.items():
(output / f"{name}.js").write_text(template.strip(), encoding="utf-8")
print(f"[OK] all hooks written to {output}")
else:
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(TEMPLATES[args.type].strip(), encoding="utf-8")
print(f"[OK] {args.type} hook written to {output}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,97 @@
#!/usr/bin/env python3
"""Crypto algorithm identifier.
Identifies encryption algorithm by output length and character set.
Usage:
python3 scripts/identify_crypto.py --output-sample "5d41402abc4b2a76b9719d911017c592"
"""
from __future__ import annotations
import argparse
import re
import json
# Algorithm signatures by output length
LENGTH_SIGNATURES = {
32: ["MD5", "MD4", "MD2"],
40: ["SHA-1", "RIPEMD-160"],
56: ["SHA-224"],
64: ["SHA-256", "SHA3-256"],
96: ["SHA-384"],
128: ["SHA-512", "SHA3-512"],
172: ["RSA-1024 (Base64)"],
256: ["RSA-1024 (Hex)", "RSA-2048 (Base64 truncated)"],
344: ["RSA-2048 (Base64)"],
512: ["RSA-2048 (Hex)", "RSA-4096 (Base64 truncated)"],
}
# Character set patterns
CHARSET_PATTERNS = [
(r'^[0-9a-f]+$', 'hex'),
(r'^[0-9A-F]+$', 'hex_upper'),
(r'^[A-Za-z0-9+/]+=*$', 'base64'),
(r'^[A-Za-z0-9_-]+$', 'base64url'),
(r'^[0-9]+$', 'decimal'),
]
def identify(sample: str) -> dict:
"""Identify crypto algorithm by output sample."""
result = {
"sample": sample[:100],
"length": len(sample),
"charset": "unknown",
"possible_algorithms": [],
"confidence": "low"
}
# Detect charset
for pattern, charset in CHARSET_PATTERNS:
if re.match(pattern, sample):
result["charset"] = charset
break
# Match by length
length = len(sample)
if length in LENGTH_SIGNATURES:
result["possible_algorithms"] = LENGTH_SIGNATURES[length]
result["confidence"] = "medium"
else:
# Find closest match
for sig_length, algorithms in LENGTH_SIGNATURES.items():
if abs(length - sig_length) <= 4:
result["possible_algorithms"] = [f"{alg} (approximate)" for alg in algorithms]
result["confidence"] = "low"
break
# Special cases
if result["charset"] == "base64" and length % 4 == 0:
decoded_length = length * 3 // 4
if decoded_length in [128, 256, 512]:
result["possible_algorithms"].append(f"RSA-{decoded_length * 8} (Base64 encoded)")
if result["charset"] == "hex" and length in [128, 256, 512, 1024]:
result["possible_algorithms"].append(f"RSA-{length * 4} (Hex encoded)")
return result
def main() -> int:
parser = argparse.ArgumentParser(description="Crypto algorithm identifier.")
parser.add_argument("--output-sample", required=True, help="Sample encrypted output.")
parser.add_argument("--output", help="Path to output JSON.")
args = parser.parse_args()
result = identify(args.output_sample)
if args.output:
dump_json(args.output, result)
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,427 @@
#!/usr/bin/env python3
"""Manage JSRPC server and Flask proxy lifecycle.
Supports the legacy flag interface and the state-file subcommand interface.
Includes PID identity verification to avoid killing wrong processes.
Usage (legacy flag interface):
python3 scripts/manage_services.py --service jsrpc --analysis analysis_result.json --output artifacts/jsrpc_status.json --action start --force
python3 scripts/manage_services.py --service flask --analysis analysis_result.json --flask-file generated/flask_proxy.py --output artifacts/flask_status.json --action start --force
python3 scripts/manage_services.py --service jsrpc --analysis analysis_result.json --output artifacts/jsrpc_status.json --action stop
Usage (state-file subcommand):
python3 scripts/manage_services.py start --kind jsrpc --state artifacts/jsrpc_state.json
python3 scripts/manage_services.py stop --state artifacts/jsrpc_state.json
"""
from __future__ import annotations
import argparse
import json
import os
import signal
import socket
import subprocess
import sys
import time
from pathlib import Path
from shutil import which
from common import dump_json, load_json
# Common JSRPC binary names and paths to search
JSRPC_SEARCH_NAMES = ["jsrpc", "jsrpc-mac", "jsrpc-linux", "mac_m_arm64", "mac_x64", "linux_amd64"]
JSRPC_SEARCH_DIRS = [
Path.home() / "jsrpc",
Path.home() / "jstest",
Path.home() / "hacker" / "jstest",
Path.home() / "tools" / "jsrpc",
Path.home() / ".local" / "bin",
Path("/usr/local/bin"),
Path("/opt/jsrpc"),
]
def find_jsrpc_binary() -> str | None:
"""Auto-discover JSRPC binary from common locations and PATH."""
# 1. Check PATH
for name in JSRPC_SEARCH_NAMES:
found = which(name)
if found:
return found
# 2. Check common directories
for search_dir in JSRPC_SEARCH_DIRS:
if not search_dir.exists():
continue
for name in JSRPC_SEARCH_NAMES:
candidate = search_dir / name
if candidate.is_file() and os.access(candidate, os.X_OK):
return str(candidate)
for subdir in search_dir.iterdir():
if subdir.is_dir():
for name in JSRPC_SEARCH_NAMES:
candidate = subdir / name
if candidate.is_file() and os.access(candidate, os.X_OK):
return str(candidate)
# 3. Check current directory and parent
cwd = Path.cwd()
for name in JSRPC_SEARCH_NAMES:
candidate = cwd / name
if candidate.is_file() and os.access(candidate, os.X_OK):
return str(candidate)
candidate = cwd.parent / name
if candidate.is_file() and os.access(candidate, os.X_OK):
return str(candidate)
return None
def process_identity(pid: int) -> dict:
"""Get process identity info for PID verification."""
result = {"pid": pid, "alive": False, "exe": None, "started": None, "command": None}
try:
os.kill(pid, 0)
result["alive"] = True
except OSError:
return result
proc = Path(f"/proc/{pid}")
if proc.exists():
try:
result["exe"] = str((proc / "exe").resolve())
except OSError:
pass
try:
result["started"] = (proc / "stat").read_text().split()[21]
except Exception:
pass
try:
result["command"] = (proc / "cmdline").read_bytes().replace(b"\x00", b" ").decode(errors="replace").strip()
except Exception:
pass
else:
try:
result["command"] = subprocess.run(
["ps", "-p", str(pid), "-o", "command="],
text=True, capture_output=True, timeout=2
).stdout.strip() or None
result["started"] = subprocess.run(
["ps", "-p", str(pid), "-o", "lstart="],
text=True, capture_output=True, timeout=2
).stdout.strip() or None
except Exception:
pass
return result
def is_port_in_use(port: int, host: str = "127.0.0.1") -> bool:
for family in [socket.AF_INET, socket.AF_INET6]:
try:
with socket.socket(family, socket.SOCK_STREAM) as s:
s.settimeout(1)
h = "127.0.0.1" if family == socket.AF_INET else "::1"
if s.connect_ex((h, port)) == 0:
return True
except Exception:
pass
return False
def get_pid_on_port(port: int) -> int | None:
try:
result = subprocess.run(["lsof", "-i", f":{port}", "-t"], capture_output=True, text=True, timeout=5)
for pid in result.stdout.strip().split("\n"):
pid = pid.strip()
if pid.isdigit():
return int(pid)
except Exception:
pass
return None
def find_available_port(start: int, host: str = "127.0.0.1", attempts: int = 30) -> int | None:
"""Find an available local port without touching an unrelated process."""
for candidate in range(int(start), int(start) + attempts):
if not is_port_in_use(candidate, host):
return candidate
return None
def kill_process(pid: int) -> bool:
try:
os.kill(pid, signal.SIGTERM)
time.sleep(1)
try:
os.kill(pid, 0)
os.kill(pid, signal.SIGKILL)
except ProcessLookupError:
pass
return True
except ProcessLookupError:
return True
except Exception:
return False
def check_health(host: str, port: int) -> bool:
try:
import urllib.request
req = urllib.request.urlopen(f"http://{host}:{port}/healthz", timeout=3)
return req.status == 200
except Exception:
return False
# === Legacy flag interface ===
def legacy_jsrpc_start(binary_path: str, port: int) -> dict:
proc = subprocess.Popen([binary_path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True)
for _ in range(10):
time.sleep(0.5)
if is_port_in_use(port):
identity = process_identity(proc.pid)
return {"status": "started", "pid": proc.pid, "binary_path": str(Path(binary_path).resolve()), "port": port,
"started": identity.get("started"), "observed_command": identity.get("command"),
"stop_hint": "使用同一状态文件执行 manage_services.py --action stop"}
return {"status": "start_failed", "pid": proc.pid, "binary_path": binary_path, "port": port}
def legacy_flask_start(flask_file: str, host: str, port: int, route: str = "/autodecoder") -> dict:
requested_port = int(port)
actual_port = requested_port
if is_port_in_use(actual_port, host):
actual_port = find_available_port(actual_port + 1, host)
if actual_port is None:
existing_pid = get_pid_on_port(requested_port)
return {"status": "port_occupied", "port": requested_port, "existing_pid": existing_pid, "hint": f"端口 {requested_port} 已被占用,且没有找到可用备用端口"}
if not os.path.isfile(flask_file):
return {"status": "file_not_found", "flask_file": flask_file, "hint": "Flask 代理文件不存在,先运行代码生成"}
log_file = Path(flask_file).parent / "flask_proxy.log"
environment = os.environ.copy()
environment["JSRA_FLASK_PORT"] = str(actual_port)
with open(log_file, "w") as lf:
proc = subprocess.Popen([sys.executable, flask_file], stdout=lf, stderr=subprocess.STDOUT, start_new_session=True, env=environment)
for _ in range(10):
time.sleep(0.5)
if is_port_in_use(actual_port, host):
healthy = check_health(host, actual_port)
identity = process_identity(proc.pid)
return {
"status": "started", "pid": proc.pid, "host": host, "port": actual_port,
"requested_port": requested_port, "port_fallback": actual_port != requested_port,
"flask_file": str(Path(flask_file).resolve()),
"started": identity.get("started"), "observed_command": identity.get("command"),
"url": f"http://{host}:{actual_port}{route}", "healthz": f"http://{host}:{actual_port}/healthz",
"encode_url": f"http://{host}:{actual_port}/encode", "decode_url": f"http://{host}:{actual_port}/decode",
"healthy": healthy, "log_file": str(log_file),
"stop_hint": "使用同一状态文件执行 manage_services.py --action stop"
}
return {"status": "start_failed", "pid": proc.pid, "port": actual_port, "requested_port": requested_port,
"flask_file": str(Path(flask_file).resolve()), "log_file": str(log_file)}
def legacy_status(path: str) -> dict:
"""Load a previously written legacy status file without guessing a PID."""
try:
return load_json(Path(path), {})
except (OSError, ValueError, TypeError):
return {}
def managed_pid_matches(status: dict, kind: str) -> bool:
"""Verify that a legacy status PID still belongs to this service.
The legacy interface predates state files, so only a recorded PID and a
recorded executable/script identity are accepted. A port occupant alone
is never sufficient evidence for a kill operation.
"""
pid = status.get("pid")
if not pid:
return False
try:
identity = process_identity(int(pid))
except (TypeError, ValueError):
return False
if not identity.get("alive"):
return False
expected = str(status.get("flask_file" if kind == "flask" else "binary_path", ""))
command = str(identity.get("command") or "")
if not expected or not command:
return False
if status.get("started") and identity.get("started") and status["started"] != identity["started"]:
return False
return Path(expected).name in command or expected in command
def stop_legacy_service(kind: str, status_path: str, configured_port: int) -> dict:
"""Stop only a process recorded by this skill's own status artifact."""
status = legacy_status(status_path)
if not status:
return {"status": "not_running", "port": configured_port, "hint": "没有可验证的服务状态文件,未尝试按端口杀进程"}
if not managed_pid_matches(status, kind):
return {"status": "identity_mismatch", "pid": status.get("pid"), "port": status.get("port", configured_port),
"hint": "状态文件中的进程身份无法验证,未执行停止操作"}
pid = int(status["pid"])
if kill_process(pid):
return {"status": "stopped", "pid": pid, "port": status.get("port", configured_port)}
return {"status": "stop_failed", "pid": pid, "port": status.get("port", configured_port)}
def legacy_mode(args: argparse.Namespace) -> dict:
"""Legacy flag-based interface."""
analysis = load_json(Path(args.analysis))
if args.service == "jsrpc":
config = analysis.get("jsrpc_server", {})
port = config.get("port", 12080)
if args.action == "stop":
return stop_legacy_service("jsrpc", args.output, port)
elif args.action == "status":
if not is_port_in_use(port):
return {"status": "not_running", "port": port}
pid = get_pid_on_port(port)
return {"status": "already_running", "pid": pid, "port": port,
"stop_command": f"python3 scripts/manage_services.py --service jsrpc --analysis {args.analysis} --output {args.output} --action stop" if pid else None}
else: # start
if is_port_in_use(port):
pid = get_pid_on_port(port)
status = legacy_status(args.output)
if status.get("status") in {"started", "already_running"} and managed_pid_matches(status, "jsrpc"):
return {"status": "already_running", "pid": status.get("pid", pid), "port": port,
"stop_command": f"python3 scripts/manage_services.py --service jsrpc --analysis {args.analysis} --output {args.output} --action stop"}
return {"status": "port_occupied", "pid": pid, "port": port,
"hint": f"端口 {port} 已被其他进程占用,未执行强制终止;请释放端口或更换 JSRPC 端口"}
binary_path = config.get("binary_path", "")
if binary_path == "auto" or not binary_path:
binary_path = find_jsrpc_binary()
if binary_path and os.path.isfile(binary_path):
return legacy_jsrpc_start(binary_path, port)
return {"status": "not_found", "port": port, "hint": "请在 analysis_result.json 的 jsrpc_server.binary_path 中提供 JSRPC 服务器二进制路径,或手动启动后重试"}
else: # flask
config = analysis.get("flask_server", analysis.get("flask", {}))
host = config.get("listen_host", config.get("host", "127.0.0.1"))
port = config.get("listen_port", config.get("port", 5000))
if args.action == "stop":
return stop_legacy_service("flask", args.output, port)
elif args.action == "status":
if not is_port_in_use(port, host):
return {"status": "not_running", "port": port}
pid = get_pid_on_port(port)
healthy = check_health(host, port)
flask_config = analysis.get("flask", {})
route = flask_config.get("route", "/autodecoder")
return {"status": "running", "pid": pid, "port": port, "healthy": healthy, "url": f"http://{host}:{port}{route}",
"stop_command": f"python3 scripts/manage_services.py --service flask --analysis {args.analysis} --output {args.output} --action stop" if pid else None}
else: # start
flask_config = analysis.get("flask", {})
route = flask_config.get("route", "/autodecoder")
# The legacy Flask start deliberately selects a free fallback port. Do
# not pre-emptively kill an arbitrary occupant here; --force is
# retained for CLI compatibility but never authorizes that kill.
return legacy_flask_start(args.flask_file, host, port, route)
return {"status": "error", "hint": "unknown action"}
# === State-file subcommand interface ===
def start_process(command: list[str], state_path: Path, kind: str) -> int:
environment = None
requested_port = None
actual_port = None
if kind == "flask":
requested_port = int(os.environ.get("JSRA_FLASK_PORT", "5000"))
actual_port = find_available_port(requested_port)
if actual_port is None:
print(json.dumps({"status": "port_occupied", "requested_port": requested_port}, ensure_ascii=False))
return 2
environment = os.environ.copy()
environment["JSRA_FLASK_PORT"] = str(actual_port)
process = subprocess.Popen(command, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True, env=environment)
time.sleep(.3)
identity = process_identity(process.pid)
dump_json(state_path, {
"kind": kind, "command": command, "pid": process.pid,
"exe": identity.get("exe"), "started": identity.get("started"),
"observed_command": identity.get("command"), "created_at": time.time(),
"requested_port": requested_port, "port": actual_port,
"port_fallback": actual_port is not None and actual_port != requested_port
})
print(json.dumps({"status": "started", "kind": kind, "pid": process.pid, "state": str(state_path), "port": actual_port}, ensure_ascii=False))
return 0
def stop_process(state_path: Path, kill_unknown: bool) -> int:
state = load_json(state_path)
identity = process_identity(int(state["pid"]))
if not identity["alive"]:
state_path.unlink(missing_ok=True)
print("[OK] process already stopped")
return 0
expected = (state.get("exe"), state.get("started"), state.get("observed_command"))
actual = (identity.get("exe"), identity.get("started"), identity.get("command"))
verifiable = any(value is not None for value in expected)
if (not verifiable or expected != actual) and not kill_unknown:
print(f"[ERROR] process identity cannot be verified; expected={expected}, actual={actual}")
return 3
os.kill(int(state["pid"]), signal.SIGTERM)
state_path.unlink(missing_ok=True)
print(f"[OK] stopped pid {state['pid']}")
return 0
def main() -> int:
# Detect the legacy flag interface or the state-file subcommand interface.
if len(sys.argv) > 1 and sys.argv[1] in ("start", "stop", "status"):
# State-file subcommand mode
parser = argparse.ArgumentParser(description="Safe service manager for JSRA.")
sub = parser.add_subparsers(dest="command", required=True)
start = sub.add_parser("start")
start.add_argument("--kind", choices=["jsrpc", "flask"], required=True)
start.add_argument("--state", required=True)
start.add_argument("--jsrpc-binary")
start.add_argument("--flask-script")
stop = sub.add_parser("stop")
stop.add_argument("--state", required=True)
stop.add_argument("--kill-unknown", action="store_true")
status = sub.add_parser("status")
status.add_argument("--state", required=True)
args = parser.parse_args()
state = Path(args.state)
if args.command == "start":
if args.kind == "jsrpc":
binary_path = args.jsrpc_binary
if not binary_path:
binary_path = find_jsrpc_binary()
if not binary_path:
parser.error("--jsrpc-binary is required; auto-discovery failed.")
print(f"[INFO] Auto-discovered JSRPC binary: {binary_path}")
binary = Path(binary_path).expanduser().resolve()
if not binary.is_file() or not os.access(binary, os.X_OK):
parser.error("invalid executable: " + str(binary))
return start_process([str(binary)], state, "jsrpc")
if not args.flask_script:
parser.error("--flask-script is required")
return start_process([sys.executable, str(Path(args.flask_script).resolve())], state, "flask")
if args.command == "stop":
return stop_process(state, args.kill_unknown)
info = load_json(state, {})
print(json.dumps({
"state": info,
"process": process_identity(int(info["pid"])) if info.get("pid") else None
}, ensure_ascii=False, indent=2))
return 0
else:
# Legacy flag-based mode
parser = argparse.ArgumentParser()
parser.add_argument("--service", required=True, choices=["jsrpc", "flask"])
parser.add_argument("--analysis", required=True)
parser.add_argument("--output", required=True)
parser.add_argument("--flask-file", default="")
parser.add_argument("--action", choices=["start", "stop", "status"], default="start")
parser.add_argument("--force", action="store_true")
args = parser.parse_args()
result = legacy_mode(args)
dump_json(Path(args.output), result)
print(json.dumps(result, ensure_ascii=False))
return 0 if result.get("status") in ("already_running", "started", "running", "stopped", "not_running") else 1
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,70 @@
#!/usr/bin/env python3
"""Write a fail-closed report when runtime evidence is incomplete.
Usage:
python3 scripts/quarantine.py \
--analysis artifacts/analysis_result.json \
--candidates artifacts/encryption_candidates.json \
--probe artifacts/probe_dump.json \
--module artifacts/module_dump.json \
--graph artifacts/evidence_graph.json \
--differential artifacts/differential_verification_results.json \
--output artifacts/quarantine.json
"""
from __future__ import annotations
import argparse
from pathlib import Path
from common import dump_json, load_json
def main() -> int:
parser = argparse.ArgumentParser(description="Create a JSRA quarantine report.")
parser.add_argument("--analysis", required=True)
parser.add_argument("--candidates", required=False)
parser.add_argument("--probe", required=False)
parser.add_argument("--module", required=False)
parser.add_argument("--graph", required=False)
parser.add_argument("--differential", required=False)
parser.add_argument("--output", required=True)
args = parser.parse_args()
paths = {
"analysis": args.analysis,
"candidates": args.candidates,
"probe_dump": args.probe,
"module_dump": args.module,
"evidence_graph": args.graph,
"differential_verification": args.differential,
}
missing = [name for name, value in paths.items() if not value or not Path(value).exists()]
candidates = load_json(args.candidates, {}) if args.candidates else {}
unresolved = []
for candidate in candidates.get("candidates", []) if isinstance(candidates, dict) else []:
if candidate.get("verified") is not True:
unresolved.append(candidate.get("path") or candidate.get("id"))
elif not candidate.get("verification"):
unresolved.append(candidate.get("path") or candidate.get("id"))
report = {
"status": "quarantined" if missing or unresolved else "ready_for_validation",
"missing_artifacts": missing,
"unverified_candidates": [value for value in unresolved if value],
"reason": "No Burp-ready artifact may be issued until a real page function is invoked and correlated with a target request field.",
"next_actions": [
"Inject runtime_hook_probe.js before navigation and export probe_dump.json.",
"Inject module_probe.js when the entrypoint is bundled and export module_dump.json.",
"Build evidence_graph.json from the captured events.",
"Run differential_verifier emit/apply using captured request-field expectations.",
"Regenerate JSRPC, Flask, and Burp artifacts only after validation passes.",
],
}
dump_json(args.output, report)
print(f"[OK] quarantine report: {args.output}")
return 0 if report["status"] == "ready_for_validation" else 2
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,130 @@
#!/usr/bin/env node
"use strict";
/**
* Conservative source-level property instrumentation.
*
* This tool intentionally rewrites only explicitly selected member reads such
* as navigator.webdriver, document.cookie,
* document.getElementById("username").value, or
* document["getElementById"]("username")["value"]. It skips strings,
* comments, assignments, and calls so it can be used as a low-risk first tap
* before broader AST instrumentation is introduced.
*
* Usage:
* node scripts/source_instrumentor.js --input bundle.js --output bundle.tap.js \
* --properties webdriver,userAgent,cookie --objects navigator,document
*/
const fs = require("fs");
const path = require("path");
function parseArgs() {
const result = {};
for (let i = 2; i < process.argv.length; i += 2) {
const key = process.argv[i];
if (!key.startsWith("--")) throw new Error(`invalid option: ${key}`);
result[key.slice(2)] = process.argv[i + 1];
}
if (!result.input || !result.output || !result.properties)
throw new Error("Usage: source_instrumentor.js --input FILE --output FILE --properties name[,name]");
return result;
}
function maskStringsAndComments(code) {
const chars = code.split("");
let state = "code";
let quote = "";
for (let i = 0; i < code.length; i += 1) {
const ch = code[i];
const next = code[i + 1];
if (state === "code") {
if (ch === "/" && next === "/") { chars[i] = " "; chars[i + 1] = " "; i += 1; state = "line"; continue; }
if (ch === "/" && next === "*") { chars[i] = " "; chars[i + 1] = " "; i += 1; state = "block"; continue; }
if (ch === "'" || ch === '"' || ch === "`") { quote = ch; chars[i] = " "; state = "string"; }
continue;
}
if (state === "line") {
if (ch === "\n") state = "code"; else chars[i] = " ";
continue;
}
if (state === "block") {
if (ch === "*" && next === "/") { chars[i] = " "; chars[i + 1] = " "; i += 1; state = "code"; }
else if (ch !== "\n") chars[i] = " ";
continue;
}
if (state === "string") {
if (ch === "\\") { chars[i] = " "; if (i + 1 < code.length) { if (code[i + 1] !== "\n") chars[i + 1] = " "; i += 1; } continue; }
if (ch === quote) { chars[i] = " "; state = "code"; }
else if (ch !== "\n") chars[i] = " ";
}
}
return chars.join("");
}
function lineOf(code, offset) {
return code.slice(0, offset).split("\n").length;
}
function instrument(code, options) {
const objects = new Set((options.objects || "navigator,document,screen,window,location").split(",").map(x => x.trim()).filter(Boolean));
const properties = new Set(options.properties.split(",").map(x => x.trim()).filter(Boolean));
const masked = maskStringsAndComments(code);
// Supports a simple dot/bracket chain with non-nested call arguments in the
// middle. The final member is inspected separately so calls such as
// CryptoJS.AES.encrypt(...) are not wrapped.
const pattern = /\b([A-Za-z_$][\w$]*)\s*((?:(?:\.\s*[A-Za-z_$][\w$]*\s*(?:\([^()\n]*\))?)|(?:\[\s*["'][A-Za-z_$][\w$]*["']\s*\]\s*(?:\([^()\n]*\))?))+)/g;
const edits = [];
let match;
while ((match = pattern.exec(code))) {
// The matching pass runs on source so static bracket names remain visible;
// reject starts that are actually inside a string/comment.
if (masked[match.index] !== code[match.index]) continue;
const objectName = match[1];
const expression = code.slice(match.index, match.index + match[0].length).trim();
const finalMember = /(?:\.\s*([A-Za-z_$][\w$]*)\s*(\([^()\n]*\))?|\[\s*["']([A-Za-z_$][\w$]*)["']\s*\]\s*(\([^()\n]*\))?)\s*$/.exec(expression);
if (!finalMember) continue;
const propertyName = finalMember[1] || finalMember[3];
if (!objects.has(objectName) || !properties.has(propertyName)) continue;
if (finalMember[2] || finalMember[4]) continue;
const end = match.index + match[0].length;
const tail = masked.slice(end).match(/^\s*/)[0].length;
const next = masked[end + tail];
const after = masked[end + tail + 1];
if (next === "(" || (next === "=" && after !== "=" && after !== ">")) continue;
if (expression.includes("__JSRA_TAP_GET__")) continue;
const label = `${objectName}.${propertyName}`;
edits.push({
start: match.index,
end,
replacement: `__JSRA_TAP_GET__(() => (${expression}), ${JSON.stringify(label)}, ${JSON.stringify(options.tag || "source")})`,
line: lineOf(code, match.index),
property: label
});
if (edits.length >= Number(options.maxRewrites || 500)) break;
}
let output = code;
for (const edit of edits.slice().reverse()) output = output.slice(0, edit.start) + edit.replacement + output.slice(edit.end);
const bootstrap = `\n;(() => {\n const g = globalThis;\n if (g.__JSRA_TAPS__) g.__JSRA_TAPS__.installed = true;\n else {\n const state = g.__JSRA_TAPS__ = { probe_id: "source-tap", installed: true, events: [], max: 5000 };\n g.__JSRA_TAP_GET__ = function(thunk, path, tag) {\n try { const value = thunk(); state.events.push({ type: "tap_get", path, tag, value_type: typeof value, value_length: value == null ? 0 : String(value).length, timestamp: Date.now() }); if (state.events.length > state.max) state.events.shift(); return value; }\n catch (error) { state.events.push({ type: "tap_get_err", path, tag, error: String(error), timestamp: Date.now() }); throw error; }\n };\n }\n})();\n`;
return { output: bootstrap + output, edits, parser_mode: "conservative-member-read" };
}
function main() {
const args = parseArgs();
const code = fs.readFileSync(args.input, "utf8");
const result = instrument(code, args);
fs.mkdirSync(path.dirname(args.output), { recursive: true });
fs.writeFileSync(args.output, result.output, "utf8");
const reportPath = args.report || `${args.output}.report.json`;
fs.writeFileSync(reportPath, JSON.stringify({
input: path.resolve(args.input),
output: path.resolve(args.output),
parser_mode: result.parser_mode,
rewrite_count: result.edits.length,
edits: result.edits.map(({ replacement, ...edit }) => edit)
}, null, 2) + "\n", "utf8");
console.log(JSON.stringify({ status: "ok", parser_mode: result.parser_mode, rewrite_count: result.edits.length, output: args.output, report: reportPath }));
}
try { main(); }
catch (error) { console.error(String(error.stack || error)); process.exit(2); }

View File

@@ -0,0 +1,223 @@
#!/usr/bin/env python3
"""Four-layer artifact validator.
Layer 1: Schema validation (analysis_result + candidates against JSON schemas)
Layer 2: Static validation (Python syntax, JS syntax via Node)
Layer 3: Candidate invariant checks (verified=true requires verification records)
Layer 4: Cross-file consistency (actions present in both jsrpc_inject.js and flask_proxy.py)
Usage:
python3 scripts/validate_artifacts.py \
--analysis analysis_result.json \
--candidates artifacts/encryption_candidates.json \
--generated generated/ \
--report artifacts/validation_report.json
"""
from __future__ import annotations
import argparse
import ast
import importlib.util
import json
import py_compile
import re
import shutil
import subprocess
import tempfile
from pathlib import Path
from urllib.parse import parse_qsl
from common import dump_json, load_json
def check_schema(name: str, data: dict, schema_path: Path) -> dict:
"""Validate data against a JSON schema."""
try:
from jsonschema import Draft202012Validator
schema = load_json(schema_path)
errors = [e.message for e in Draft202012Validator(schema).iter_errors(data)]
return {"name": name, "ok": not errors, "errors": errors}
except ImportError:
return {"name": name, "ok": False, "errors": ["jsonschema not installed"]}
except FileNotFoundError:
return {"name": name, "ok": True, "skipped": f"schema not found: {schema_path}"}
def check_python(path: Path) -> dict:
"""Check Python syntax."""
try:
py_compile.compile(str(path), doraise=True)
ast.parse(path.read_text(encoding="utf-8"))
return {"name": f"python:{path.name}", "ok": True}
except Exception as error:
return {"name": f"python:{path.name}", "ok": False, "errors": [str(error)]}
def check_javascript(path: Path) -> dict:
"""Check JavaScript syntax via Node.js."""
if not path.exists():
return {"name": f"javascript:{path.name}", "ok": False, "errors": ["missing"]}
node = shutil.which("node")
if not node:
return {"name": f"javascript:{path.name}", "ok": True, "skipped": "node unavailable"}
proc = subprocess.run([node, "--check", str(path)], text=True, capture_output=True)
return {
"name": f"javascript:{path.name}",
"ok": proc.returncode == 0,
"errors": [proc.stderr.strip()] if proc.returncode else []
}
def check_dependency(name: str) -> dict:
"""Require packages used by generated services and schema validation."""
available = importlib.util.find_spec(name) is not None
return {
"name": f"dependency:{name}",
"ok": available,
"errors": [] if available else [f"{name} is not installed"],
}
def check_jsrpc_evidence_gate(path: Path) -> dict:
"""Fail validation when generated JSRPC code is still quarantined."""
if not path.exists():
return {"name": "jsrpc_evidence_gate", "ok": False, "errors": ["missing jsrpc_inject.js"]}
content = path.read_text(encoding="utf-8")
match = re.search(r"const EVIDENCE_ERRORS = (\{.*?\});", content, re.DOTALL)
if not match:
return {"name": "jsrpc_evidence_gate", "ok": False, "errors": ["missing evidence gate"]}
try:
errors = json.loads(match.group(1))
except json.JSONDecodeError as error:
return {"name": "jsrpc_evidence_gate", "ok": False, "errors": [str(error)]}
if errors:
return {
"name": "jsrpc_evidence_gate",
"ok": False,
"errors": [f"quarantined parameters: {', '.join(sorted(errors))}"],
}
return {"name": "jsrpc_evidence_gate", "ok": True, "errors": []}
def check_candidate_invariants(candidates: dict) -> dict:
"""Check candidate verification invariants."""
errors = []
for c in candidates.get("candidates", []):
evidence_types = [e.get("type") for e in c.get("evidence", []) if isinstance(e, dict)]
verification = c.get("verification")
provenance = c.get("provenance")
if c.get("confidence") == "high" and not c.get("verified"):
errors.append(f"{c.get('id')}: high confidence without verification")
if c.get("verified"):
if not isinstance(verification, list) or not verification:
errors.append(f"{c.get('id')}: verified without verification records")
if c.get("source") == "hint" or c.get("type") == "hint":
errors.append(f"{c.get('id')}: manual hint cannot be verified")
matched = any(
isinstance(record, dict) and (
record.get("matched") is True or record.get("verified") is True or
any(isinstance(test, dict) and test.get("matched") is True
for test in record.get("tests", []))
)
for record in (verification or [])
)
if not matched:
errors.append(f"{c.get('id')}: no successful runtime/request match")
return {"name": "candidate_invariants", "ok": not errors, "errors": errors}
def check_cross_file(analysis: dict, generated: Path) -> dict:
"""Check cross-file consistency between JSRPC and Flask."""
errors = []
jsrpc = (generated / "jsrpc_inject.js").read_text(encoding="utf-8") if (generated / "jsrpc_inject.js").exists() else ""
flask = (generated / "flask_proxy.py").read_text(encoding="utf-8") if (generated / "flask_proxy.py").exists() else ""
# Check action names
action_name = analysis.get("jsrpc", {}).get("action_name", "")
if action_name and action_name not in jsrpc:
errors.append(f"action missing from jsrpc_inject.js: {action_name}")
if action_name and action_name not in flask:
errors.append(f"action missing from flask_proxy.py: {action_name}")
# Check entrypoint resolution
if "resolveEntrypoint" not in jsrpc and "resolvePath" not in jsrpc:
errors.append("jsrpc_inject.js does not contain entrypoint resolution")
return {"name": "cross_file_consistency", "ok": not errors, "errors": errors}
def mock_semantics() -> dict:
"""Check HTTP form semantics."""
raw = "a=1&a=2&password=x+y&empty="
pairs = parse_qsl(raw, keep_blank_values=True)
errors = []
if pairs[:2] != [("a", "1"), ("a", "2")]:
errors.append("duplicate form keys not preserved")
if pairs[-1] != ("empty", ""):
errors.append("blank form value not preserved")
return {"name": "mock_http_semantics", "ok": not errors, "errors": errors}
def main() -> int:
parser = argparse.ArgumentParser(description="Four-layer artifact validator.")
parser.add_argument("--analysis", required=True, help="Path to analysis_result.json.")
parser.add_argument("--candidates", help="Path to encryption_candidates.json.")
parser.add_argument("--generated", required=True, help="Path to generated/ directory.")
parser.add_argument("--report", required=True, help="Path to validation report JSON.")
parser.add_argument("--e2e-result", help="Path to optional E2E result JSON.")
parser.add_argument("--adversarial-trace", help="Optional adversarial runtime trace JSON.")
args = parser.parse_args()
base = Path(__file__).parents[1]
generated = Path(args.generated)
analysis = load_json(args.analysis)
checks = []
# A green report must mean the generated service can start and schema
# checks really ran. Missing required packages are therefore failures,
# not silently skipped checks.
checks.extend(check_dependency(name) for name in ("flask", "requests", "jsonschema"))
# Layer 1: Schema validation
schema_path = base / "schemas" / "analysis_result.schema.json"
if schema_path.exists():
checks.append(check_schema("analysis_schema", analysis, schema_path))
if args.candidates:
candidates = load_json(args.candidates)
candidates_schema = base / "schemas" / "candidates.schema.json"
if candidates_schema.exists():
checks.append(check_schema("candidate_schema", candidates, candidates_schema))
checks.append(check_candidate_invariants(candidates))
# Layer 2: Static validation
for name in ("flask_proxy.py",):
path = generated / name
checks.append(check_python(path) if path.exists() else {"name": f"python:{name}", "ok": False, "errors": ["missing"]})
for name in ("jsrpc_inject.js", "runtime_hook_probe.js", "adversarial_runtime_probe.js", "module_probe.js"):
checks.append(check_javascript(generated / name))
checks.append(check_jsrpc_evidence_gate(generated / "jsrpc_inject.js"))
# Layer 3: Cross-file consistency
checks.append(check_cross_file(analysis, generated))
checks.append(mock_semantics())
# Layer 4: Optional real E2E
if args.e2e_result:
e2e = load_json(args.e2e_result, {})
checks.append({"name": "real_e2e", "ok": bool(e2e.get("passed")), "details": e2e})
if args.adversarial_trace:
trace = load_json(args.adversarial_trace, {})
schema = base / "schemas" / "adversarial_trace.schema.json"
checks.append(check_schema("adversarial_trace_schema", trace, schema))
report = {"passed": all(c.get("ok") for c in checks), "checks": checks}
dump_json(args.report, report)
print(json.dumps(report, ensure_ascii=False, indent=2))
return 0 if report["passed"] else 2
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,160 @@
#!/usr/bin/env python3
"""Validate one live browser/JSRPC action result before it is reported as success.
The validator deliberately checks evidence fields rather than service liveness. It
accepts either a raw /go response or an envelope with ``result`` and optional
bridge/tab/operation metadata.
Usage:
python3 scripts/validate_browser_evidence.py \
--input artifacts/jsrpc_smoke.json \
--output artifacts/browser_evidence.json
"""
from __future__ import annotations
import argparse
import json
from pathlib import Path
from typing import Any
ERROR_MARKERS = ("JSRA_ERROR", "EvidenceMissing", "unavailable", "not verified")
def non_empty(value: Any) -> bool:
if value is None or value is False:
return False
if isinstance(value, str):
return bool(value.strip())
if isinstance(value, (list, dict)):
return bool(value)
return True
def path_value(root: dict[str, Any], *paths: str) -> tuple[Any, str | None]:
for path in paths:
value: Any = root
ok = True
for part in path.split("."):
if not isinstance(value, dict) or part not in value:
ok = False
break
value = value[part]
if ok:
return value, path
return None, None
def marker_in(value: Any) -> str | None:
try:
text = json.dumps(value, ensure_ascii=False)
except (TypeError, ValueError):
text = str(value)
lowered = text.lower()
for marker in ERROR_MARKERS:
if marker.lower() in lowered:
return marker
return None
def validate(payload: Any, require_session: bool = False) -> dict[str, Any]:
if not isinstance(payload, dict):
return {"passed": False, "reason": "top_level_object_required"}
result = payload.get("result", payload)
# Legacy JSRPC /go responses commonly serialize the action result in the
# outer ``data`` field. Decode that envelope before checking evidence;
# an undecoded transport wrapper is not itself a missing business result.
if isinstance(result, dict) and isinstance(result.get("data"), str):
try:
decoded = json.loads(result["data"])
except (TypeError, ValueError):
decoded = None
if isinstance(decoded, dict):
result = decoded
if isinstance(result, dict) and isinstance(result.get("data"), dict):
data = result["data"]
elif isinstance(result, dict):
data = result
else:
return {"passed": False, "reason": "result_object_required"}
checks: dict[str, dict[str, Any]] = {}
plaintext, plaintext_path = path_value(data, "plaintext", "input", "value")
checks["plaintext"] = {"ok": non_empty(plaintext), "path": plaintext_path}
request_body, request_body_path = path_value(
data, "requestBody", "request.body", "request.bodyText", "ciphertext"
)
checks["request_body"] = {"ok": non_empty(request_body), "path": request_body_path}
status, status_path = path_value(data, "status", "httpStatus", "response.status")
checks["http_status"] = {
"ok": isinstance(status, int) and not isinstance(status, bool) and 100 <= status <= 599,
"path": status_path,
"value_type": type(status).__name__,
}
route, route_path = path_value(
data, "route", "request.route", "request.url", "url", "request.path"
)
checks["final_route"] = {"ok": non_empty(route), "path": route_path}
response, response_path = path_value(data, "response", "businessResponse", "body")
checks["business_response"] = {"ok": non_empty(response), "path": response_path}
error_marker = marker_in(result)
checks["no_error_marker"] = {"ok": error_marker is None, "marker": error_marker}
session = payload.get("session") or payload.get("browser") or {}
session_checks: dict[str, Any] = {"provided": bool(session)}
if session:
connected = session.get("connected", session.get("bridge", {}).get("connected"))
tab_id = session.get("tabId", session.get("tab", {}).get("tabId"))
document_id = session.get("documentId", session.get("tab", {}).get("documentId"))
session_checks.update({
"connected": connected is True,
"tab_id": non_empty(tab_id),
"document_id": non_empty(document_id),
})
elif require_session:
session_checks["reason"] = "session_metadata_required"
required = [item["ok"] for item in checks.values()]
if require_session:
required.extend(session_checks.get(key, False) for key in ("connected", "tab_id", "document_id"))
return {
"passed": all(required),
"evidence": {
"plaintext_present": checks["plaintext"]["ok"],
"request_body_present": checks["request_body"]["ok"],
"final_route_present": checks["final_route"]["ok"],
"http_status_present": checks["http_status"]["ok"],
"business_response_present": checks["business_response"]["ok"],
},
"checks": checks,
"session": session_checks,
"reportable_success": all(required),
}
def main() -> int:
parser = argparse.ArgumentParser(description="Validate live browser/JSRPC evidence.")
parser.add_argument("--input", required=True)
parser.add_argument("--output", required=True)
parser.add_argument("--require-session", action="store_true",
help="Require connected bridge, tabId and documentId metadata.")
args = parser.parse_args()
payload = json.loads(Path(args.input).read_text(encoding="utf-8"))
report = validate(payload, require_session=args.require_session)
output = Path(args.output)
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(json.dumps(report, ensure_ascii=False))
return 0 if report["passed"] else 2
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,164 @@
# 工作流
## 环境准备
```bash
python3 -m pip install -r requirements.txt
python3 scripts/doctor.py --ci
```
## Phase 0: 输入校验
将用户提供的三行输入先整理为一个 JSON 文件(例如 `artifacts/raw_input.json`):
```json
{
"target_url": "https://xxx/login",
"parameters": ["password"],
"fetch_example": "fetch(\"https://xxx/api/login\", {})"
}
```
再执行:
```bash
python3 scripts/check_inputs.py --input artifacts/raw_input.json --output artifacts/phase0_input.json
```
## Phase 0.5: Fetch Example 预分析(如果有)
当用户提供 Fetch Example 时,**必须先分析**:
- 提取目标请求 URL(如 `loginByPassword`)
- 提取加密后的密码格式(Base64/Hex/长度)
- 识别加密算法(RSA/SM2/AES/MD5)
- 确定参数落点(body/query/header)
```bash
python3 scripts/identify_crypto.py --output-sample "<加密后的密码>"
```
**预分析结果用于指导后续流程,但不能跳过网络捕获。**
## Phase 1: 浏览器连接
- 打开目标页面
- 读取浏览器连接状态并锁定当前 `tabId`、URL 和文档身份;断线、刷新、跨文档导航后重新绑定,不复用旧元素引用或旧调用栈。
- 通过 `navigate_page(initScript=...)` 预注入 Hook
- 记录 URL/Method/Body/Headers/参数落点
## Phase 1.5: 运行时 Hook
```bash
python3 scripts/emit_runtime_hook_probe.py --output generated/runtime_hook_probe.js
```
- 注入探针 → 触发目标动作 → 导出证据
- 健康检测:ok/timeout/crashed/partial
- 降级:timeout/crashed → 静态分析
## Phase 1.6: 对抗运行时探测
```bash
python3 scripts/emit_adversarial_runtime_probe.py \
--output generated/adversarial_runtime_probe.js \
--mode observe --properties "navigator.webdriver,navigator.userAgent,document.cookie"
```
- 先以 `observe` 模式记录反调试、反 Hook、环境属性、动态代码、响应解析、Worker/WebSocket/WASM/iframe 和脚本加载信号。
- 只有确认存在具体对抗信号时才使用 `--mode intervene`;不默认修改时间、随机数或全局环境。
- `window.__JSRA_ADVERSARIAL__.uninstall()` 用于恢复已记录的函数/属性 Patch。
- 基线与干预结果可用 `adversarial_diff.py` 比较;事件增加不等于目标请求成功。
## Phase 2: Webpack/模块解析
```bash
python3 scripts/emit_module_probe.py --output generated/module_probe.js
```
- 优先级:`__webpack_require__` → `webpackChunk*` push → module cache → 离线解析
## Phase 2.1: 静态 AST 分析(可选)
```bash
node scripts/ast_candidate_analyzer.js --input bundle.js --output artifacts/static_candidates.json
```
## Phase 2.2: 源码级属性 tap
```bash
node scripts/source_instrumentor.js \
--input bundle.js --output generated/bundle.tap.js \
--objects navigator,document,screen --properties webdriver,userAgent,cookie
```
- 只改写显式选择的简单 member read;跳过字符串、注释、赋值和方法调用。
- 先验证原始/插桩脚本的异常、请求和关键输出,再扩大范围。
## Phase 2.5: 证据图构建
```bash
python3 scripts/build_evidence_graph.py --probe artifacts/probe_dump.json --modules artifacts/module_dump.json --output artifacts/evidence_graph.json
```
## Phase 3: 候选发现
```bash
python3 scripts/detect_encryption.py --probe-artifacts artifacts/probe_dump.json --output artifacts/encryption_candidates.json
```
- 8 维评分:name、source_keyword、runtime_stack、request_correlation、input_output_flow、module_export、cross_source、verification
## Phase 3.5: 差分验证
```bash
python3 scripts/differential_verifier.py emit --analysis analysis_result.json --candidates artifacts/encryption_candidates.json --output generated/differential_verifier.js
# 浏览器执行后
python3 scripts/differential_verifier.py apply --candidates artifacts/encryption_candidates.json --results artifacts/differential_verification_results.json --output artifacts/encryption_candidates.verified.json
```
## Phase 4: 组装 analysis_result.json
- 包含所有必需字段(见 `references/output-contract.md`)
- `entrypoint_discovery.strategy` 如实反映发现路径
- `confidence=high` 需至少两类证据
## Phase 5: 代码生成
```bash
python3 scripts/emit_jsrpc_stub.py --analysis analysis_result.json --candidates artifacts/encryption_candidates.verified.json --output generated/jsrpc_inject.js
python3 scripts/emit_flask_proxy.py --analysis analysis_result.json --output generated/flask_proxy.py
python3 scripts/check_delivery_contract.py \
--proxy generated/flask_proxy.py \
--output artifacts/delivery_contract.json
python3 scripts/manage_services.py --service jsrpc --analysis analysis_result.json --output artifacts/jsrpc_status.json --action start --force
python3 scripts/manage_services.py --service flask --analysis analysis_result.json --flask-file generated/flask_proxy.py --output artifacts/flask_status.json --action start --force
```
## Phase 6: 注入浏览器
- 通过 Chrome DevTools MCP 导航到目标根页面,不使用静态 HTTP 请求代替真实页面。
- 用 `evaluate_script` 依次注入 `JsEnv_Dev.js` 和 `generated/jsrpc_inject.js`;若使用运行时探针,先注入探针再注入 JSRPC。
- 验证:`curl http://127.0.0.1:12080/list` 只能证明服务端口可访问,不能代替 action 级 smoke test。
- 调用 JSRPC 后必须检查 `plaintext`、最终 `request.url`、`requestBody`、`status` 和最终 `response`;仅出现函数名或候选分数不得判定通过。
- 将真实 action 结果保存为 `artifacts/jsrpc_smoke.json`,执行 `python3 scripts/validate_browser_evidence.py --input artifacts/jsrpc_smoke.json --output artifacts/browser_evidence.json`;能取得桥接状态时加 `--require-session`。验证失败时不得进入“验证通过”的正式输出。
- Flask 验证必须额外提交一份外层 `application/octet-stream` 的完整 HTTP 报文,确认返回完整报文、目标字段已替换且 `Content-Length` 正确;同时保留 `dataBody` wrapper 回归。
- 标准捕获层同时覆盖 `fetch` 与 `XMLHttpRequest`;若成功响应会触发页面跳转,在分析配置中使用 `capture.suppress_page_success=true`,只抑制 `fetch` 测试页面的后续跳转,仍记录真实响应。XHR 成功响应只采集不改写。
### 可复制验证命令
```bash
curl -sS --get \
--data-urlencode group=jsra \
--data-urlencode action=<action_name> \
--data-urlencode 'param={"parameter":"<parameter>","value":"<plaintext>"}' \
http://127.0.0.1:12080/go | python3 -m json.tool
```
预期 `data` 中同时出现 `plaintext`、`requestBody`、`response` 和 `status`;若缺少任一项,应继续补运行时捕获或标记为未完成。
## Phase 7: Burp 文档
```bash
python3 scripts/emit_burp_doc.py --analysis analysis_result.json --status artifacts/flask_status.json --output generated/burp-autodecoder.md
```
## Phase 8: 校验
```bash
python3 scripts/validate_artifacts.py --analysis analysis_result.json --candidates artifacts/encryption_candidates.verified.json --generated generated/ --report artifacts/validation_report.json
```
## Phase 9: 经验沉淀
- 更新 `references/evolution_matrix.json`
- 保留标准最终报告结构,在其中追加本轮对抗信号、探针健康和差分结论。
## 入口发现失败时的处理
1. 全局函数搜索 → React/Vue 组件 → Webpack require → 运行时 Hook
2. initScript Hook → 搜索 RSA 实例
3. 静态 AST 分析
4. 源码分析 + Python 实现
5. 标记为 `unsupported`,生成隔离报告
## 关闭服务
```bash
python3 scripts/manage_services.py --service jsrpc --analysis analysis_result.json --output artifacts/jsrpc_status.json --action stop
python3 scripts/manage_services.py --service flask --analysis analysis_result.json --output artifacts/flask_status.json --action stop
```