简介:中国蚁剑(AntSword)2.1.9 源码包面向从事 Web 安全测试、渗透测试学习以及工具二次开发的技术人员,提供轻量级 Web 安全管理平台的完整工程实现,便于研究远程连接管理、插件加载机制与图形界面交互等核心设计,也可在更新源延迟时作为本地备用加载目录使用。压缩包共 2777 个文件,体积约 15.51MB;其中 1471 个 js 文件承载主要业务逻辑与第三方依赖,175 个 json 与 58 个 yml 负责配置描述,120 个 css、40 个 html 及若干 ts、coffee 文件对应界面与脚本,204 个 md 文档及 license、authors、changelog 等文件保留了项目说明与开源协议,另有 gif、png、svg、ttf、woff 等图标字体资源。目前已有 1621 人学习下载。读者可据此梳理清晰的源码目录层级,理解 HTTP 交互、插件扩展与前端构建方式,并在此基础上做定制开发和教学演示,适合安全从业者与爱好者作为参考资料。
1. 一次跨语言通道测试,把我推到了 AntSword 源码面前
前阵子做一次内部资产梳理,面对的目标环境有 PHP、ASPX、JSP 三种,桌面上的测试脚本越堆越多,每换一种语言就要改一遍请求构造和返回解析,编码方式还不一样,来回切得人头晕。最后把这类重复劳动收进一个桌面工具的,就是 AntSword 这套 Electron 架构。它真正值得拆的地方不在于界面,而在于把「不同服务端语言的差异」全部压到模块层,编码器负责传输形态,模板负责最终字符串拼装,界面层只关心渲染结果。这份 2.1.9 的源码包,适合已经会写 Node、想搞清楚桌面端安全工具怎么做插件化分层的人,也适合被「一套界面适配多种后端」这个命题卡住过的开发者。
2. 技术栈与目录结构:Electron 主进程加渲染进程的骨架
2.1 技术选型:为什么是 Electron 而不是浏览器扩展
先说结论,这套东西选 Electron 不是为了「好看」,是被能力边界逼出来的。浏览器扩展受同源策略和 CORS 限制,发出去的请求要过一遍预检,很多自定义头部根本带不过去;而 Electron 主进程跑在 Node 环境里,HTTP 请求走的是 Node 的 net/http 模块,没有同源概念,也不受浏览器安全策略约束。再加上工具需要本地文件读写(保存配置、导出结果)、需要多窗口、需要系统托盘常驻,这些能力浏览器扩展一样都给不了。
具体的依赖栈大致是这么几层:
- Electron:整个应用的壳,负责窗口、进程、菜单、更新。
- Node.js:主进程的运行时,所有网络请求、文件操作、模块加载都在这一层。
- React + Redux:渲染进程的界面框架和状态管理,Shell 列表、连接状态、日志面板这些都是 React 组件订阅 Redux store 得到的。
- Webpack + Babel:把 JSX、ES Module 打包成 Electron 能直接加载的产物,同时负责 dev 阶段的 watch。
- electron-builder(或同类打包器):把 dist 产物和 Electron 运行时打成一个可分发安装包。
我一般会先打开package.json,看dependencies里 Electron 的版本号,因为它直接决定你本机要装哪个 Node 大版本——Electron 内嵌的 Node 和你系统里node -v出来的经常不是一回事,这点后面会踩坑。
2.2 目录结构:source 下每一层在干什么
不同小版本的目录会有出入,但 2.1.9 这一代的分层大体是下面这个样子,你拿到源码包后可以对着看:
antSword-2.1.9/ ├── source/ │ ├── app/ │ │ ├── main.js # Electron 主进程入口,创建窗口、注册 IPC │ │ ├── package.json # 应用级依赖,声明 electron 入口 │ │ └── renderer/ │ │ ├── index.html # 渲染进程的页面骨架 │ │ ├── index.js # 渲染进程入口,挂载 React 根组件 │ │ ├── components/ # 通用 UI 组件(列表、表单、弹窗) │ │ ├── modules/ # 业务视图:shell 管理、设置、插件 │ │ └── assets/ # 图标、样式、字体 │ ├── modules/ # 核心:按服务端语言划分的适配模块 │ │ ├── base.js # 模块基类 / 工厂,定义统一接口 │ │ ├── php/ │ │ ├── asp/ │ │ ├── aspx/ │ │ ├── jsp/ │ │ └── custom/ │ ├── core/ # 全局配置、日志、状态持久化 │ ├── package.json │ └── webpack.config.js看这个结构要抓住一个关键点:source/app和source/modules是两个独立的世界。前者是「界面 + 宿主」,后者是「协议适配」,两边只通过一层薄薄的接口打交道。source/modules下面每个语言目录内部又是同构的——index.js暴露模块主体,encoder/和decoder/放该语言常用的变换,template.js(有的版本叫payload.js)负责最终字符串的拼装。这种「目录即契约」的设计,好处是你新增一种语言时不用动界面代码,只要把目录照抄一份改实现。
2.3 进程边界:IPC 通道怎么把界面和 Node 能力连起来
Electron 最容易被新手忽略的就是进程边界。渲染进程本质上是个浏览器环境,默认拿不到require、fs、net,想访问本地能力必须通过 IPC 往主进程发消息。2.1.9 这一代源码里,这在main.js的webPreferences上有明确体现:
// source/app/main.js —— 主进程创建窗口并注册一条最小通道(结构示意) const { app, BrowserWindow, ipcMain } = require('electron'); const path = require('path'); const fs = require('fs/promises'); let win; function createWindow() { win = new BrowserWindow({ width: 1280, height: 800, webPreferences: { // 关键开关:不开这个,渲染进程里 require 直接抛 undefined nodeIntegration: true, // 2.x 时代普遍关掉上下文隔离,方便渲染层直接引 Node 模块 contextIsolation: false } }); win.loadFile(path.join(__dirname, 'renderer/index.html')); } // 渲染进程侧:ipcRenderer.invoke('fs:read', filePath) ipcMain.handle('fs:read', async (_event, filePath) => { // 简单校验,避免渲染层传进来任意路径 if (typeof filePath !== 'string' || !filePath.endsWith('.json')) { throw new Error('只允许读取 .json 配置文件'); } return await fs.readFile(filePath, 'utf8'); }); app.whenReady().then(createWindow);这段代码里三个参数值得单独说。nodeIntegration: true决定了渲染进程能不能直接require,这是整套架构成立的前提;contextIsolation: false是配套的,隔离开着的时候 preload 脚本和页面共享不了全局对象;ipcMain.handle注册的是「请求-响应」式通道,跟老的ipcMain.on那种单向广播不是一回事,前者能await,写起来更像普通函数调用。
渲染进程那边对应的写法是:
// source/app/renderer/index.js 里的调用方式(结构示意) const { ipcRenderer } = window.require('electron'); async function loadConfig() { try { const raw = await ipcRenderer.invoke('fs:read', './config.json'); return JSON.parse(raw); } catch (e) { // 通道报错会以 rejected promise 形式回来,别用 try 包住忘了打日志 console.error('[ipc] fs:read failed:', e.message); return {}; } }注意这里用的是window.require而不是直接require,因为在渲染进程里 Node 注入的require挂在 window 上。这套写法在 Electron 12 之后逐步被 preload + contextBridge 取代,但 2.1.9 这个年代的源码基本都是老路子,你照原样跑没问题,想升级再另说。
3. 核心模块拆解:Shell 模块、编码器链与模板的三层设计
3.1 统一接口:每种语言为什么都要实现同一套方法
整个项目最值得学的就是这个抽象。不管后端是 PHP 还是 JSP,界面层看到的永远只有几个动作:列出目录、读文件、写文件、执行命令。这些动作在 UI 上是一致的,但落到不同语言上,拼出来的字符串完全不一样。AntSword 的解法是定义一个基类,把所有差异压进子类的build()里:
// source/modules/base.js —— 所有语言模块的基类(结构示意) class ShellModule { constructor(opt = {}) { this.opt = opt; // { url, pwd, type, encode, decode, headers } this.encode = opt.encode || 'default'; // 编码器链,逗号分隔,如 'base64,reverse' this.decode = opt.decode || 'default'; // 解码器链,顺序必须与 encode 镜像 } // 子类必须实现:把一次「逻辑动作」翻译成目标语言能接收的字符串 // 返回 { payload, headers },payload 是请求体,headers 是需要覆盖的头 build(action, params) { throw new Error('build() must be implemented by subclass'); } // 统一执行入口:构造 -> 编码 -> 传输 -> 解码 -> 归一化 async exec(action, params) { const raw = this.build(action, params); const body = this.applyEncoders(raw.payload); const resp = await this.transport(body, raw.headers); return this.applyDecoders(resp); } // 传输层单独抽出来,方便替换成不同的 HTTP 客户端 async transport(body, headers = {}) { const http = require('http'); // 实际实现会带超时、重试、cookie 处理,这里只保留主干 return new Promise((resolve, reject) => { const req = http.request(this.opt.url, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...headers }, timeout: 15000 }, (res) => { let buf = ''; res.on('data', (c) => (buf += c)); res.on('end', () => resolve(buf)); }); req.on('error', reject); req.on('timeout', () => req.destroy(new Error('request timeout'))); req.write(body); req.end(); }); } } module.exports = ShellModule;这里的action是个字符串标识(比如list、read、write、exec),params是业务参数对象。之所以用字符串而不是枚举,是因为这套接口还要给插件层留扩展位——第三方模块可以自定义动作名,界面层通过注册表查表分发,不用改核心代码。transport()单独抽出来也是这个道理,有人想换成 axios 或者带自定义证书的 https 客户端,覆写这一个方法就够了。
3.2 编码器与解码器链:一层层剥离的字符串变换
编码器这块是整个项目里最容易被低估、也最容易出问题的部分。它把「请求体长什么样」和「请求体怎么变形」彻底解耦了。配置文件里encode: 'base64,reverse'这样的字符串,表示发出去之前先做 base64、再整体反转;decode那边就必须是reverse,base64,顺序严格镜像。
// source/modules/base.js 里编码链的实现(结构示意) applyEncoders(data) { const names = (this.encode || '').split(',').filter(Boolean); let out = data; for (const name of names) { const mod = require(`../modules/${this.opt.type}/encoder/${name}`); // 编码器接口约定:encode(input, opt) => string out = mod.encode(out, this.opt); } return out; } applyDecoders(data) { const names = (this.decode || '').split(',').filter(Boolean); let out = data; for (const name of names) { const mod = require(`../modules/${this.opt.type}/decoder/${name}`); // 解码器接口约定:decode(input, opt) => string out = mod.decode(out, this.opt); } return out; }单个编码器的实现通常就是一个纯函数,不碰 IO、不留状态:
// source/modules/php/encoder/base64.js(结构示意) exports.encode = (input) => Buffer.from(input, 'utf8').toString('base64'); exports.decode = (input) => Buffer.from(input, 'base64').toString('utf8');参数说明一下:input永远是字符串,编码器内部自己决定要不要走 Buffer;opt里带着当前连接的完整配置,有些编码器要靠opt.pwd做前缀校验。这套接口最大的好处是可组合——你想加一层「把空格替换成特殊字符」的混淆,写一个新文件扔进encoder/目录,配置里把名字加进去就生效了,不用改动任何已有代码。我一般会把decode的第一个编码器做成「剥离前后缀」的清洗器,因为实际响应里经常带着 HTML 噪音或者 BOM 头,不先洗掉后面全乱。
3.3 模板层:最终字符串是怎么拼出来的
模板是最贴近业务的一层,也是最不该被塞进逻辑的一层。它的职责只有一个:把「要执行的动作」包进目标语言能接受的外壳里。理想状态下,模板就是个纯字符串函数,入参出参都是 string,中间不做任何判断。
// source/modules/php/template.js(结构示意,外壳用占位符表示) module.exports = { // mark: 本次请求的唯一标记,用于响应里定位结果片段 // code: 真正要跑的代码片段,由 build() 生成 // pwd: 当前连接口令,有的语言需要通过它做入口定位 wrap: (mark, code, pwd) => `/*${mark}*/[${pwd}]${code}[/${mark}]` };mark这个参数很关键。因为响应回来的是一个完整页面,怎么从中精确抠出自己那次请求的结果?靠标记做前后缀定位,正则匹配/*mark*/([\s\S]*?)/*mark*/就能拿到。如果把这个逻辑散在编码器里,链式变换一多,标记就被改得面目全非了,所以它必须放在模板这一层、在编码之前生成。
code的来源是build()。以「列目录」动作为例,PHP 模块的build()大致会生成一段读取目录并序列化输出的片段,然后再交给模板套壳、交给编码器变形。三层各管一件事,改任意一层都不影响另外两层——这就是这套架构真正的价值。
3.4 界面层的数据流:React 组件怎么拿到 Shell 状态
界面侧走的是标准 Redux 单向流。Shell 列表存在 store 里,每个 Shell 对象带着id、url、type、status、encode、decode这些字段,侧边栏组件订阅列表,右键菜单触发 action,reducer 里更新状态,视图自动重渲染。
// source/app/renderer/modules/shell/reducer.js(结构示意) const initialState = { list: [], active: null }; export default function shellReducer(state = initialState, action) { switch (action.type) { case 'SHELL_ADD': // payload: { id, url, type, encode, decode, status: 'offline' } return { ...state, list: [...state.list, action.payload] }; case 'SHELL_STATUS': // payload: { id, status } return { ...state, list: state.list.map((s) => (s.id === action.payload.id ? { ...s, status: action.payload.status } : s)) }; case 'SHELL_ACTIVE': return { ...state, active: action.payload }; default: return state; } }参数上没什么花活,重点是SHELL_STATUS这个动作——每次连接成功或失败都要派发一次,用来驱动界面上的小圆点变色。调试的时候我经常在这里加一行console.log,因为连接失败的真正原因(超时、编码不匹配、口令错误)往往在这一层被吞掉了,日志面板只显示一句「连接失败」,查起来很痛苦。
4. 把开发环境跑起来:从 npm install 到窗口出现
4.1 环境准备:Node 版本要和 Electron 对上
这一步看着简单,翻车率最高。Electron 内嵌的是自己的 Node 运行时,但你本地跑npm install用的是系统 Node,两者版本差太多会导致某些原生依赖编译失败。2.1.9 这个年代的 Electron 大概是 8 到 11 之间,对应的系统 Node 建议在 12 到 14 这个区间。
# 先看当前环境 node -v npm -v # 有 nvm 的话切成 14 nvm install 14 nvm use 14 node -v # 确认输出 v14.x如果你的机器上已经是 Node 18 或 20,npm install大概率会在node-sass或者electron-rebuild这类包上炸掉,报gyp ERR!或者Python not found。这不是源码的问题,是版本错配。切版本是最省事的解法,别硬编译。
4.2 依赖安装:Electron 二进制是最容易卡住的一步
进入source目录再装,不要在仓库根目录装——根目录可能没有package.json,或者有一份只声明了元信息的。
cd antSword-2.1.9/source # 用国内镜像源,主要为了 Electron 那个几十 MB 的二进制 npm install --registry=https://registry.npmmirror.comnpm install期间会触发 Electron 的 postinstall,去下载对应平台的预编译二进制。这一步常见的现象是卡在node_modules/electron不动,或者装完npm start报Electron failed to install correctly。原因基本可以确定是二进制没下全,但npm认为装完了。解决办法是先把node_modules/electron删掉重装:
rm -rf node_modules/electron npm install electron --registry=https://registry.npmmirror.com装完之后可以验证一下,node_modules/electron/dist/目录下应该有可执行文件,path.txt里写着对应平台的目标名。
4.3 开发模式启动:watch 和 electron 要分开跑
源码里的脚本一般分两类,一类是构建,一类是启动。具体名字看package.json的scripts,常见形态是:
{ "scripts": { "dev": "webpack --watch --mode development", "build": "webpack --mode production", "start": "electron ." } }开发的时候开两个终端,一个跑npm run dev让 webpack 持续编译渲染进程代码,另一个跑npm start起 Electron 窗口。
# 终端 A npm run dev # 终端 B npm start先跑 A 再跑 B 是有讲究的:webpack 得先把renderer/index.js编译出产物,index.html里引的那个 bundle 才存在,否则窗口起来是白屏,控制台报 404。我第一次跑的时候就踩了这个,盯着白屏看了十分钟,最后发现是自己顺序反了。
窗口起来之后,主进程的调试靠--inspect:
# 主进程调试,然后在 chrome://inspect 里连 npm start -- --inspect=5858渲染进程的调试直接Ctrl+Shift+I打开 DevTools 就行,跟普通网页没区别。两边分开调这一点很重要,因为渲染层报的错和主进程报的错完全是两套堆栈。
4.4 构建与打包:dist 目录是怎么来的
npm run build走的是 webpack 的生产模式,输出到dist/或者build/(看webpack.config.js里的output.path)。这一步只负责把 JS、CSS、HTML 打成一坨静态资源,还不足以变成可执行的安装包。
打包要再走一层,通常是 electron-builder:
# 生成当前平台的可执行目录,不生成安装包,调试用 npx electron-builder --dir # 生成安装包 npx electron-builder --win --x64--dir这个参数我强烈建议先用一次。它不做 NSIS 封包,只把产物摊在dist/win-unpacked/下,双击里面的 exe 就能跑。这样出问题能直接看到是哪一步挂了;直接打安装包的话,失败信息往往只有一句build failed,非常难查。配置项在package.json的build字段或者单独的electron-builder.yml里,files决定哪些文件进包,asar决定要不要压成一个归档文件。
5. 避坑与排查:跑源码时最容易翻车的五个地方
5.1 Electron 装不上,npm start 报二进制缺失
现象:npm install显示成功,npm start直接报Error: Electron failed to install correctly, please delete node_modules/electron and try installing again。
原因:postinstall 阶段的二进制下载被中断,但 npm 的缓存已经记下了这次「安装完成」,所以后续不会重试。
解决:删掉node_modules/electron和~/.npm/_cacache里对应的条目,然后单独重装。装完检查node_modules/electron/path.txt是否存在,这个文件缺失基本就是没装好。另外注意别在NODE_ENV=production下装,那样会跳过 devDependencies,而 electron 恰好常常就放在 devDependencies 里。
5.2 渲染进程里 require is not defined
现象:窗口能起来,但界面一片空白,DevTools 控制台第一行就是Uncaught ReferenceError: require is not defined。
原因:webPreferences里的nodeIntegration是false,或者contextIsolation是true。新版 Electron 默认都往安全侧靠,老代码跑在新壳子上就会这样。
解决:确认main.js里创建窗口时显式设置了nodeIntegration: true和contextIsolation: false。如果已经设了还报错,检查是不是有多个BrowserWindow创建点,你改的那个不是真正加载页面的那个——这种情况在带 splash 启动页的项目里很常见。
5.3 打包后静态资源 404,开发环境却正常
现象:npm start一切正常,electron-builder --dir出来的包一跑,图片、字体全裂,控制台一堆net::ERR_FILE_NOT_FOUND。
原因:开发时路径是相对当前工作目录算的,比如./assets/logo.png;打包后文件被塞进app.asar归档里,工作目录变了,相对路径就指错了地方。
解决:所有资源路径统一走path.join(__dirname, ...)或者app.getAppPath(),不要手写相对路径。如果确实有文件不想进 asar(比如需要外部工具读取的),在build配置里用asarUnpack单独放出来,然后从process.resourcesPath下去找。上线前一定用--dir出来的包完整点一遍,别只看开发环境。
5.4 改了主进程代码,窗口起来还是旧的
现象:改完main.js里的 IPC 处理逻辑,重启npm start,行为完全没变。
原因:webpack --watch只盯着渲染进程的入口,主进程代码根本没进编译流程,Electron 直接把旧的main.js拿去跑了;更隐蔽的一种是产物被 webpack 复制到了dist/,但package.json的main字段指向的是源码目录,两边不同步。
解决:先确认package.json的main到底指向哪个文件,然后看那个文件的内容是不是你刚改的。主进程代码改动必须手动重启 Electron,watch 救不了。我一般的做法是在main.js顶部加一行console.log('[main] boot at', new Date().toISOString()),每次启动都能一眼确认是不是新代码在跑。
5.5 编码链顺序写反,响应全是乱码
现象:连接能通,但返回的结果里夹杂大量不可读字符,或者 JSON 解析直接抛Unexpected token。
原因:encode和decode的顺序没有镜像。请求侧是「先 base64 再反转」,响应侧就必须「先反转再 base64」,写成一样的顺序会导致字符被错位还原。
解决:在applyEncoders和applyDecoders里各加一行中间值打印,对比请求发出前和解码完成后两端的字符串。如果第一层解码出来的东西就已经不对,说明顺序反了;如果第一层对、后面才错,说明某个编码器内部实现和配置里的名字对不上。这个坑我踩过两次,第二次是因为复制配置时把decode那行漏改了,肉眼扫了三遍才看出来。
6. 二次开发改造:从零接一个自定义编码器
真要上手改,我建议从「加一个编码器」开始,改动面最小、见效最快,而且能把整条链路跑一遍。假设你想在传输前对请求体做一层字符替换,把空格换成%20之外的某种占位符,绕开某些中间层对空格的过滤。
第一步建文件,路径跟随语言目录,比如source/modules/php/encoder/space.js:
// 编码器接口约定:纯函数,两个方法,不碰 IO exports.encode = (input) => String(input).replace(/ /g, '__SP__'); exports.decode = (input) => String(input).replace(/__SP__/g, ' ');第二步是在配置里启用。这套东西的编码器列表是逗号分隔的字符串,所以直接在连接的encode字段里追加名字即可,注意decode那一侧也要加同名项,位置要对应:
// 连接配置对象(示意) const shellOpt = { url: 'http://127.0.0.1:8080/index.php', pwd: 'demo', type: 'php', encode: 'base64,space', // 先 base64,再替换空格 decode: 'space,base64' // 反过来:先还原占位符,再 base64 解码 };第三步是让界面能选到它。如果源码里的下拉框是硬编码的选项数组,就在那个数组里补一项{ label: '空格替换', value: 'space' };如果它做了目录扫描,那就什么都不用改,重启后会自动出现。这两者怎么区分,看下拉框的数据来源是require进来的常量还是运行时读目录,一目了然。
验证方法别用真实环境,本地起个假的回显服务最省事:
// 本地回显服务,把收到的请求体原样返回,用来验证编码是否正确 const http = require('http'); http.createServer((req, res) => { let body = ''; req.on('data', (c) => (body += c)); req.on('end', () => { console.log('[recv]', JSON.stringify(body.slice(0, 200))); res.end(body); }); }).listen(8080, () => console.log('echo server on :8080'));跑通之后,日志里应该能看到请求体里全是__SP__,而界面上的解码结果又是正常文本。这一步走通,说明编码链、传输层、解码链三段都是活的,再去改更复杂的逻辑心里就有底了。
几条实操上的经验:新编码器一定要做幂等性检查,decode(encode(x)) === x用一个循环跑一百次随机字符串,能挡掉绝大部分低级错误;不要在没有本地回显验证的情况下直接接真实环境,编码链错了返回的是乱码,你根本分不清是编码问题还是网络问题;还有,改完encoder/目录下的文件必须重启 Electron,这些模块是require进来的,Node 有模块缓存,热更新到不了这一层。从那以后我每次新增编码器,都会先把那段幂等性测试跑一遍,再去看界面——血泪经验,省下来的时间比写测试多得多。希望帮到你。
本文还有配套的精品资源,点击获取