ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

AntSword 2.1.9源码解析:Electron模块化与编解码扩展

AntSword 2.1.9源码解析:Electron模块化与编解码扩展

简介:中国蚁剑(AntSword)2.1.9 版本源码包,面向Web安全测试学习者、渗透测试人员及希望研究开源安全工具实现的开发者,提供可本地研读与二次开发的完整代码基础。压缩包共2777个文件、约15.51MB,其中JavaScript文件1471个,构成插件与核心逻辑的主体;另有204个Markdown文档、175个JSON配置、120个CSS样式及GIF/PNG等界面资源,便于从代码、文档和配置三个层面理解项目结构。已有1623人学习或下载。读者可借此梳理远程连接管理、插件机制、跨平台适配与图形界面的实现方式,结合源码观察HTTP交互、Web服务器通信及常见安全测试功能的组织方法,也可按自身需求调整插件与界面,积累代码阅读和工具定制经验。

1. 解压 AntSword 2.1.9 源码包之后:先看清它到底是个什么工程

解压 AntSword 2.1.9 的源码包,第一眼看到的是 package.json、electron 依赖,还有一大堆 source 目录,不少人会以为它只是几个脚本拼起来的集合。实际上这是一套成型的 Electron 桌面客户端:主进程负责窗口和本地文件读写,渲染进程跑界面,所有功能都拆成 source/modules 下的独立模块,前后端之间的数据交换则交给可热插拔的编码器。它面向安全测试场景,在 core 层统一抽象了 PHP、ASP、ASPX、JSP 四种后端脚本环境,功能面板和协议处理是彻底分开的两层。适合两类人看:一类是想完整跑通一个真实 Electron 加 Node 项目的新手,装依赖、起窗口、开 DevTools 就能把链路摸一遍;另一类是要做二次开发的熟手,重点在模块注册和编码器插件这两个扩展点。授权测试环境和自建靶场里用,是这套源码最合适的打开方式,下面按能干什么、怎么跑、怎么改的顺序拆。

2. 架构拆解:Electron 双进程加 core 层,怎么托住四种后端语言

2.1 源码包目录结构速览

先看整体布局。源码包解压后的主干大致是这个形态,我按职责做了标注:

AntSword-2.1.9/ ├── antSword.js # 应用主入口,Electron 主进程从这里启动 ├── package.json # 依赖声明与启动脚本 ├── app/ # 窗口、菜单、渲染进程页面 │ ├── index.html # 主界面 HTML │ ├── package.json │ └── renderer/ # 渲染进程侧脚本,UI 逻辑集中在这里 ├── source/ │ ├── core/ # 核心层:请求封装 + 四种后端语言模板 │ │ ├── base.js # Base 类,统一出站/入站 │ │ ├── php/ │ │ ├── asp/ │ │ ├── aspx/ │ │ └── jsp/ │ ├── modules/ # 功能模块,一个目录一个面板 │ │ ├── filemanager/ │ │ ├── terminal/ │ │ ├── database/ │ │ └── ... │ └── ... └── node_modules/

这里最容易翻车的地方是:很多人下意识去 app 目录里找功能代码,结果只看到一堆界面骨架。真正的业务全在 source 下,app 只负责把窗口开起来、把渲染进程的入口页面加载进去。source/core 是协议层,source/modules 是业务层,两层之间靠一个全局的 antSword 对象串起来,没有复杂的中间件,也没有依赖注入容器,结构上很轻。

我一般看这类项目先做一件事:统计 source/modules 下的目录数量,再对照界面上左侧或顶部的功能入口数量。数量对得上,说明模块加载是目录驱动的,后面加功能只要建目录就行;对不上,说明还有硬编码的注册表要改。这个方法比直接读入口文件快得多。

2.2 主进程、渲染进程与预加载的职责边界

AntSword 走的是老一代 Electron 写法,把 Node 能力直接开给渲染进程。这决定了整个项目的代码风格:渲染进程里可以随意 require fs、path、child_process,模块代码不用做任何跨进程通信(IPC)中转就能读写本地文件。代价也很直接,渲染进程里任何一个脚本出错或来源不可信,都能直接碰到系统文件,所以模块来源必须是自己可控的。

// 主进程:创建主窗口,渲染进程需要直接访问 Node 能力 const { BrowserWindow } = require('electron'); const win = new BrowserWindow({ width: 1024, height: 700, webPreferences: { nodeIntegration: true, // 渲染进程内可直接 require contextIsolation: false, // 与上一项配套关闭,老版本才有意义 enableRemoteModule: false } }); win.loadFile(path.join(__dirname, 'app/index.html'));

这三个参数是整个架构的地基。nodeIntegration 打开之后,渲染进程里的require('fs')才能拿到真模块;contextIsolation 关掉,是为了让页面脚本和预加载脚本共享同一个全局上下文;enableRemoteModule 关掉,是为了避免老版本 remote 模块带来的额外攻击面。

提示:这几个开关在新版 Electron 里默认是反过来的,直接把这段配置抄到新版本里会报错,甚至窗口起不来。要么锁死 Electron 大版本,要么改成 contextBridge 暴露接口,后者改动量不小。

预加载脚本在这套架构里存在感很弱,因为它不承担沙箱隔离职责,主要作用是在页面加载前挂一些全局工具函数。真正的边界在 antSword.js 主入口里:主进程创建窗口之后,会把应用根路径、版本号、用户数据目录这些东西挂到全局,渲染进程侧的模块再从这个全局对象里取。

2.3 core 层:一个 Base 类对齐 PHP/ASP/ASPX/JSP

core 层解决的是一个很实际的问题:四种后端脚本语言的语法差异极大,PHP 里一行eval,ASP 里要写成Execute,JSP 又是另一套反射调用。如果每个功能模块都自己拼一遍,代码会碎成渣。AntSword 的做法是抽象一个 Base,把请求发送、参数拼接、响应解析收口到一处,各语言子目录只负责一件事——把业务参数翻译成本语言能执行的一段代码。

下面这版骨架是我按职责重画过的,不是逐行照搬原文件,但结构和调用顺序与源码一致:

// source/core/base.js 的职责骨架:请求与响应收口到一处 class Base { constructor(opt = {}) { this.opt = opt; // { url, pwd, type, encoder, decoder, headers } this.encoder = opt.encoder; // 出站处理函数,发请求前调 this.decoder = opt.decoder; // 入站处理函数,收到回包后调 } // 由各语言子类实现:把业务参数翻译成本语言的执行代码 encodeParams(data) { throw new Error('encodeParams not implemented'); } // 统一的出站入口,模块层只调这一个方法 async submit(data) { const payload = this.encodeParams(data); // 1. 语言模板 const encoded = this.encoder(this.opt.pwd, payload); // 2. 编码器 const res = await this.request(encoded); // 3. 发请求 return this.decoder(this.opt.pwd, res); // 4. 解码器 } }

opt 里的字段各有分工。url 是当前连接的地址;pwd 是参数名,也就是后端接收指令用的那个键;type 决定实例化哪个语言子类;encoder 和 decoder 是函数引用,由用户配置决定,可以来自内置也可以用自定义插件替换。模块层拿到的对象长这样:new Base({ url, pwd, type, encoder, decoder }),之后只调submit(data),完全不用关心底层是 PHP 还是 JSP。

这种分层的好处在于扩展成本极低。想加一种新的后端语言,只要在 source/core 下建一个新目录,实现 encodeParams 和对应的响应解析,再把 type 映射注册进去,上层的文件管理、终端、数据库这些模块一行都不用改。反过来说,这也是它最脆弱的地方——四个子类的实现风格如果不统一,上层模块很容易踩到某个语言的边界情况。

2.4 请求生命周期:从模块调用到响应回填

把一次完整调用串起来看,链路是这样的:用户在界面上点一个操作,渲染进程里的模块实例组装参数对象,调用 submit;Base 先走语言模板,把参数对象拼成一段该语言能执行的字符串;接着编码器拿到这段字符串,做协议层的包装,返回一个可直接提交的字段字典;请求发出后,回包文本交给解码器还原成结构化数据;最后模块拿到还原后的结果,更新界面。

这条链路的一个关键约定是:模块层永远不碰字符串拼接。所有涉及语言语法的部分都在 core 层完成,模块只管传{ action: 'read', path: '/tmp' }这样的字典。这个约定带来两个直接好处,一是模块代码可读性极高,二是换后端语言时模块完全无感。我在别的项目里见过反例,把语言模板散落在各个功能模块里,后来加一种后端,改了三十多个文件,血泪经验。

调试这条链路有个技巧:在submit里打一行日志,把 payload 和 encoded 都打出来,然后开 DevTools 的 Network 面板对照。两边对不上,问题一定在编码器;两边都对但返回是空的,问题在解码器或者后端响应本身。这个二分法能省掉大量瞎猜。

3. 模块系统实战:source/modules 下一个面板从注册到渲染的完整链路

3.1 模块目录的标准三件套

一个功能模块在源码里的最小构成是三个文件,缺一个就可能加载不出来。下面这张表是我按项目里的实际结构整理的:

文件作用常改场景
package.json模块元信息:名称、版本、描述、界面显示名改菜单文案、改图标、升版本
index.js模块逻辑入口,导出一个类或工厂函数加功能、改参数组装
main.js或模板文件界面侧的渲染逻辑与事件绑定改布局、加按钮、接新事件

package.json 在这里的作用不是给 npm 用的,而是给应用自己的加载器读的。它里面的字段会被直接消费到界面上,所以写错一个字段名,菜单里就看不到入口,而且不会报错——加载器多半是静默跳过的。这是新手最容易卡住的地方,后面第 5 章会展开。

index.js 的导出形态有两种常见写法:导出一个类,由框架 new 出来并注入连接配置;或者导出一个工厂函数,接收配置返回实例。AntSword 用的是前者,好处是可以把连接相关的状态放在实例上,模块内部方法共享起来方便。

3.2 模块注册与加载链路

加载逻辑本身不长,但顺序很关键。大致形态是这样:

// 模块加载:扫目录 -> 读元信息 -> require 入口 -> 挂到全局注册表 const fs = require('fs'); const path = require('path'); function loadModules(rootDir, registry) { const dirs = fs.readdirSync(rootDir).filter((name) => { return fs.statSync(path.join(rootDir, name)).isDirectory(); }); dirs.forEach((name) => { const dir = path.join(rootDir, name); const pkgPath = path.join(dir, 'package.json'); // 没有元信息就跳过,这一步是静默的,不会抛错 if (!fs.existsSync(pkgPath)) return; let meta; try { meta = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); } catch (e) { console.error(`[module] ${name} 元信息解析失败`, e.message); return; } registry[name] = { meta, // 菜单展示用的名称/图标/版本 entry: require(path.join(dir, 'index.js')) // 模块类本身 }; }); } const antSword = { modules: {} }; loadModules(path.join(__dirname, 'source/modules'), antSword.modules);

几个参数值得说清楚。rootDir 是 source/modules 的绝对路径,必须用 path.join 拼,不能用相对路径,因为渲染进程的当前工作目录不一定在项目根上。registry 的键用的是目录名,不是 package.json 里的 name 字段,所以改目录名等于换了一个模块 ID,之前的配置会全部失联。meta 里真正被界面消费的通常只有显示名、版本和图标三个,其余字段是留给插件市场或版本校验用的。

require 缓存这里有个隐藏问题。模块加载是同步的,第一次加载完成后模块类就被缓存了,热更新时改 index.js 再刷新界面,跑的还是旧代码。开发期我一般会在加载前加一行delete require.cache[require.resolve(...)],虽然多几毫秒开销,但省掉了反复重启示录。

3.3 手写一个最小模块并挂载进菜单

光看加载逻辑不够直观,直接写一个能跑起来的最小模块。为了不涉及任何远程操作,这个例子只读本地环境信息,纯粹用来验证加载链路通不通。

先建目录source/modules/sysinfo/,然后写入口:

// source/modules/sysinfo/index.js —— 最小可运行模块 'use strict'; module.exports = class SysInfo { constructor(opt) { // opt 由框架注入:{ url, pwd, type, encoder, decoder } this.opt = opt || {}; } // 框架加载模块后会调这个方法拿初始数据 async run() { return [ { label: 'Electron', value: process.versions.electron }, { label: 'Node', value: process.versions.node }, { label: 'Chrome', value: process.versions.chrome }, { label: '平台', value: process.platform }, { label: '架构', value: process.arch } ]; } };

再写元信息:

{ "name": "sysinfo", "version": "0.1.0", "description": "本地运行环境信息面板", "main": "index.js", "antSword": { "name": "环境信息", "icon": "info" } }

这两个文件落盘后重启应用,模块注册表里就会多出一个 sysinfo,界面上的功能入口按antSword.name显示成「环境信息」。点进去,run 返回的数组会被界面渲染成一张表。

注意:JSON 里绝对不能出现尾逗号和 BOM 头,Windows 记事本默认会加 BOM,JSON.parse直接抛异常,而加载器是 try/catch 静默跳过的,表现就是「目录建了但菜单没东西」,查半天查不出来。

这个最小模块跑通之后,往上加复杂功能才有意义。我的习惯是先让空壳模块出现在菜单里、能打印一行日志,再去往里填业务逻辑。如果反过来先写业务再挂载,一旦加载失败,你分不清是业务代码的锅还是注册的锅,排查成本翻倍。

4. 编码器与解码器:一套可插拔数据流的接口约定与热加载机制

4.1 编码器接口签名与返回值约定

编码器和解码器是 AntSword 里设计得最漂亮的一块,也是二次开发最常动的地方。它的定位很单纯:在 core 层拼好载荷字符串之后、真正发请求之前,插入一个可替换的处理函数。用户想换一套传输协议、加一层自定义加解密、对接自建靶场的私有格式,都不用改源码,写个 js 文件丢进用户目录就行。

接口签名很朴素,就三个参数:

// 编码器:入站是参数字典,出站必须是可直接提交的字段对象 module.exports = (pwd, data, ext = {}) => { // pwd : 连接配置里的参数名,最终提交时用它做键 // data : 模块产出的参数字典,形如 { action: 'read', path: '/tmp' } // ext : 上下文,含连接信息、请求头等,按需取用 const raw = JSON.stringify(data); const out = {}; out[pwd] = Buffer.from(raw, 'utf8').toString('base64'); return out; };

三个参数的分工要记牢。pwd 不是一个固定的字符串,而是从连接配置里读出来的键名,不同连接可能不一样,所以不能写死。data 是 core 层翻译完语言模板后的结果,可能已经是对象也可能已经是字符串,取决于上游实现,写编码器时最好先判断类型。ext 是兜底用的,请求方法、超时、自定义头都从这里拿。

返回值必须是对象,键是最终提交的字段名,值是字段内容。返回字符串或者返回 null 都会让请求层直接抛错,这点和很多中间件框架的「返回原值表示不处理」的约定不同,容易记混。

4.2 用户目录热加载

自定义编码器放在用户数据目录下,不进源码树。Linux 和 macOS 是~/.antSword/encoder/,Windows 是%APPDATA%/antSword/encoder/,文件名就是编码器在界面下拉框里显示的名字。解码器同理,放decoder/目录。

加载逻辑不长,但有个必须处理的缓存问题:

// 从用户数据目录加载自定义编码器 const os = require('os'); const fs = require('fs'); const path = require('path'); function loadUserEncoders() { const base = path.join(os.homedir(), '.antSword', 'encoder'); if (!fs.existsSync(base)) return {}; const map = {}; fs.readdirSync(base) .filter((f) => f.endsWith('.js')) .forEach((file) => { const full = path.join(base, file); const name = path.basename(file, '.js'); // 关键:清掉 require 缓存,否则改完文件不生效 try { delete require.cache[require.resolve(full)]; } catch (e) { // 首次加载时 require.resolve 可能抛错,忽略即可 } try { map[name] = require(full); } catch (e) { console.error(`[encoder] ${name} 加载失败: ${e.message}`); } }); return map; }

这段代码里最容易翻车的是那个 try/catch 的位置。如果外层直接 require 一个语法有错的 js,Node 会把整个加载流程打断,导致目录里其他正常的编码器也一起消失。所以必须逐个文件包 try/catch,一个坏的不能拖垮一窝。这个防御性写法在插件系统里是标配,我第一次写插件加载时没加,用户放了个半成品进去,整个下拉框空白,被投诉了一轮。

提示:文件名里不要带中文和空格,虽然 Node 的 require 能处理,但界面下拉框的键值绑定在某些平台上会出问题,表现是选了某个编码器但实际用的是另一个。

4.3 写一个 base64 协议的编码器

把编解码配对写完整,才好理解数据流。假设自建靶场的后端约定是:请求体整体做一次 base64,响应体也整体做一次 base64。那么编码器负责出站:

// ~/.antSword/encoder/b64.js module.exports = (pwd, data) => { const text = typeof data === 'string' ? data : JSON.stringify(data); return { [pwd]: Buffer.from(text, 'utf8').toString('base64') }; };

解码器负责入站,签名多一个 opt 参数:

// ~/.antSword/decoder/b64.js module.exports = (pwd, data, opt) => { // data 是原始响应文本,opt 里含状态码、响应头等 if (!data) return ''; // 空响应直接返回,别往下传 try { return Buffer.from(data, 'base64').toString('utf8'); } catch (e) { console.error('[decoder] b64 解码失败', e.message); return data; // 解不出来原样返回,方便定位 } };

解码器的容错写法很关键。如果解码器抛异常,上层模块拿不到任何数据,界面上表现为一块空白,日志里也看不出是编码问题还是网络问题,跟黑匣子一样。返回原值至少能让人在 Network 面板里看到原始响应长什么样,一眼就能判断是协议不对还是服务端返回了错误页。

两个文件放进用户目录,重启应用,在连接配置里选中 b64 这个编码器和解码器,链路就接通了。整个过程不改一行源码,这也是这套插件机制的价值所在:把扩展点从源码里挪出来,交到使用者手上。

5. 本地运行与排错:npm install 之后的五条踩坑记录

5.1 依赖安装与启动

环境这块,Node 版本不要追新。这套源码依赖的是老版本 Electron,Node 16 以上装依赖时经常在原生模块编译环节挂掉,我个人习惯用 14 或 16 的大版本。

# 1. 确认 Node 版本,太新的版本装老 Electron 容易在 postinstall 阶段失败 node -v # 2. 配好 Electron 的二进制镜像,否则下载会卡在 postinstall export ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ # 3. 装依赖,走国内源 npm install --registry=https://registry.npmmirror.com # 4. 启动,package.json 里的 start 脚本通常就是 electron . npm start

package.json 的关键字段大致是这样,main 指向主进程入口,start 脚本调 electron 命令:

{ "name": "antsword", "version": "2.1.9", "main": "antSword.js", "scripts": { "start": "electron .", "start:debug": "electron . --inspect=5858" } }

start:debug 这行是我自己加的,方便挂调试器。原包里没有的话,手动补一条也无妨,Electron 支持--inspect参数,主进程断点就靠它。

5.2 渲染进程调试与 DevTools 调试

界面上的问题,八成要在渲染进程的 DevTools 里找答案。默认快捷键是 Ctrl+Shift+I,但有时候被窗口焦点吃掉,开发期我干脆在创建窗口时强制打开:

// 开发期强制打开 DevTools,省得每次手按快捷键 if (process.env.NODE_ENV !== 'production') { win.webContents.openDevTools({ mode: 'detach' }); }

detach 模式把 DevTools 拆成独立窗口,好处是调试时不会挤压主界面布局,改了样式能立刻看到效果。断点打在 source/modules 下的 index.js 里完全有效,因为渲染进程直接跑 Node 模块,跟浏览器里调试普通脚本没区别。

有个细节值得说:Console 里可以直接require('fs'),验证 Node 集成是否正常。能 require 到,说明 nodeIntegration 生效;报 require is not defined,说明主进程的 webPreferences 配错了,或者被新版本 Electron 的默认值覆盖了。

5.3 五条踩坑记录

下面这五条是我在部署这套源码时按时间顺序记下来的,每条都按现象、原因、解决三段写。

第一条,npm install 卡在 Electron 二进制下载。

现象是命令行停在 postinstall 阶段十几分钟不动,或者报RequestError: read ECONNRESET。原因是 Electron 的二进制包不走 npm 源,默认从官方地址拉,网络一抖就断。解决办法是提前设好ELECTRON_MIRROR环境变量指向国内镜像,然后删掉 node_modules 和 package-lock.json 重装。只删 node_modules 不够,lock 文件里可能缓存了失败记录。

第二条,启动后窗口出来了但一片白屏。

现象是窗口标题正常、尺寸正常,内容区域全白,也没有报错弹窗。原因通常有两个:一是win.loadFile里的路径用了相对路径,打包或换工作目录之后解析不到 app/index.html;二是渲染进程的脚本在早期就抛了异常,但异常被 Electron 吞掉了。解决方法是先在 loadFile 前后各打一行日志,确认路径是绝对的;再 openDevTools 看 Console,异常一般都在那儿躺着。

第三条,新加的模块在菜单里看不到。

现象是目录建好了、index.js 写了、重启了,界面上就是没有入口,也没有任何报错。原因是加载器读 package.json 时用的是JSON.parse包 try/catch,解析失败会静默跳过。常见触发点是文件带 BOM 头、有尾逗号、少一个引号。解决办法是把加载器里的 catch 分支加上console.error,把文件名和错误信息打出来,一秒钟定位。

第四条,改了编码器文件但行为没变。

现象是编辑~/.antSword/encoder/xxx.js,保存、重启应用,跑起来还是老逻辑。原因是 Node 的 require 有模块缓存,同一个绝对路径第二次 require 直接返回缓存对象。解决办法是在加载前delete require.cache[require.resolve(full)],或者干脆换个文件名。前者更优雅,但要注意 resolve 失败时的异常处理,也就是 4.2 节里那个嵌套 try/catch。

第五条,界面上的中文显示成问号。

现象是模块返回的数据里中文全是???或者乱码方块。原因一般是两处:Buffer 转字符串时没指定编码,默认可能不是 utf8;或者请求头里没有显式声明字符集。解决办法是统一写成Buffer.from(text, 'utf8')和buf.toString('utf8'),请求头里补上Content-Type: application/x-www-form-urlencoded; charset=utf-8。这条坑最玄学的地方在于,某些环境下不加 charset 也能正常显示,换台机器就崩,所以别赌,显式写死。

6. 二次开发:三个改造方向与一套冒烟验证清单

改造这件事,我建议从改动面最小的方向切入,先把链路跑顺再放大。下面三个方向的成本从低到高排:

方向主要改动文件验证方式
加一个业务模块source/modules/<新目录>/三件套重启后菜单出现,操作有返回
替换界面主题app/renderer/下的样式与布局文件逐个面板点一遍,看布局有没有塌
自定义编码器适配私有协议~/.antSword/encoder/、decoder/用自建靶场跑一次完整往返

第一个方向最安全,因为不碰核心。新建模块时直接抄 sysinfo 的结构,把 run 方法里的逻辑换成自己的。唯一要注意的是别在模块里做全局状态,模块实例是跟着连接走的,多个连接同时开着的时候,状态会串。

第二个方向看着简单,实际上坑最多。界面样式往往和模块内的模板文件耦合,改一处主色调,某个模块里的内联样式就露出来了。我的做法是先只改 CSS 变量,不动结构,跑一轮把漏网的颜色记下来,再统一处理。

第三个方向价值最高。很多自建靶场或者内部测试环境会有自己的传输约定,不想改源码又想对接,就只能走编码器。写的时候记住三条:编码器只处理出站、解码器只处理入站、两边都要能容错。尤其是解码器,一定要在解析失败时返回原始数据,不然排查的时候连原始响应都看不到。

验证这件事,我后来固定了一套冒烟清单,每次动完 source/core 或者加载器就跑一遍:

  1. 启动后主窗口正常显示,DevTools Console 无红色报错
  2. 左侧功能入口数量与 source/modules 下的目录数一致
  3. 逐个模块点开,至少能渲染出初始界面
  4. 随便选一个模块,走一次完整的请求往返,看 Network 面板的出站和入站
  5. 手动改一个编码器文件,重启,确认新逻辑生效

这五步十分钟能跑完,但能拦住九成的低级错误。我第一次改 core 层的时候图省事,只验证了当前在用的那个模块,结果另外三个语言的子类全炸了,用户那边一用就空白,后悔药都没得吃。从那以后我每次动 source 下任何一行代码,都强制把这套冒烟清单走一遍,宁可多花十分钟,也不让问题流到下一个环节。希望这套流程帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进