reverse-skills-max: initial public release (restored, redacted, hardened)
This commit is contained in:
201
vendor-new/js-reverse-automation--skill/LICENSE
Normal file
201
vendor-new/js-reverse-automation--skill/LICENSE
Normal 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.
|
||||
265
vendor-new/js-reverse-automation--skill/README.md
Normal file
265
vendor-new/js-reverse-automation--skill/README.md
Normal 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>
|
||||
|
||||
@@ -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 预算耗尽
|
||||
@@ -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`
|
||||
- 所有候选必须标记来源
|
||||
- 失败验证不得删除,应保留为负证据
|
||||
@@ -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 捕获的、经过业务函数的调用栈 |
|
||||
@@ -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 等价变换;插桩后必须与原始脚本做异常、请求和关键输出对照。
|
||||
@@ -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` |
|
||||
|
||||
只有在某条规则确实改变调查路径或风险面时,才引用并启用它。
|
||||
@@ -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`:启发式匹配
|
||||
@@ -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 结果替代这条恢复链。
|
||||
@@ -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`:所有方法失败
|
||||
@@ -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。
|
||||
@@ -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 内部函数仍需人工处理或标记为不支持"
|
||||
]
|
||||
}
|
||||
@@ -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/环境检测/动态加载>`
|
||||
- 探针健康:`<丢失补丁/重建错误/丢弃事件>`
|
||||
- 差分结论:`<观察结果/干预结果>`
|
||||
@@ -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`
|
||||
@@ -0,0 +1,9 @@
|
||||
# 安全模型
|
||||
|
||||
- 仅分析授权目标
|
||||
- 探针默认脱敏(SHA-256 指纹)
|
||||
- `--capture-raw` 仅在隔离环境使用
|
||||
- 主动调用需 `safe_to_invoke=true`
|
||||
- 服务绑定 `127.0.0.1`
|
||||
- 不自动启动任意可执行文件
|
||||
- 不终止身份未确认的进程
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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())
|
||||
@@ -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); }
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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)
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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}¶m=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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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); }
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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
|
||||
```
|
||||
Reference in New Issue
Block a user