electron、pcap实现抓包工具

770 阅读5分钟

electron、pcap实现抓包工具

前置准备

首先要搭建起来electron的开发环境,笔者采用rollup+ts的框架搭建electron开发环境,之后使用npm安装pcap模块包。 最后的package.json长这样

{
  "name": "node-fiddler",
  "version": "1.0.0",
  "main": "main.ts",
  "license": "MIT",
  "scripts": {
    "pack:dev": "rollup --config rollup.config.ts --environment NODE_ENV:development --configPlugin typescript --bundleConfigAsCjs",
    "pack:pro": "rollup --config rollup.config.ts --environment NODE_ENV:production --configPlugin typescript --bundleConfigAsCjs",
    "watch": "rollup --config rollup.config.ts --environment NODE_ENV:development --configPlugin typescript --watch --bundleConfigAsCjs",
    "build": "npm run pack:pro",
    "electron": "sudo electron ./dist/client.js",
    "start": "npm run watch",
    "rebuild": "electron-rebuild",
    "test": "sudo electron proxy.js"
  },
  "author": "Methy42",
  "devDependencies": {
    "@rollup/plugin-commonjs": "^25.0.3",
    "@rollup/plugin-html": "^1.0.2",
    "@rollup/plugin-replace": "^5.0.2",
    "@rollup/plugin-typescript": "^11.1.2",
    "@rollup/plugin-url": "^8.0.1",
    "@types/express": "^4.17.21",
    "@types/morgan": "^1.9.9",
    "@types/node": "^20.4.5",
    "@types/react-dom": "^18.2.7",
    "@types/rollup-plugin-css-only": "^3.1.0",
    "@typescript-eslint/eslint-plugin": "^4.33.0",
    "@typescript-eslint/parser": "^4.33.0",
    "electron": "file:./electron",
    "electron-rebuild": "^3.2.9",
    "eslint": "^7.32.0",
    "react-markdown": "^8.0.7",
    "rollup": "^3.27.0",
    "rollup-plugin-css-only": "^4.3.0",
    "tslib": "^2.6.1",
    "typescript": "^4.8.3"
  },
  "dependencies": {
    "@ant-design/icons": "^5.2.5",
    "antd": "^5.8.1",
    "pcap": "^3.1.0",
    "react": "^18.2.0",
    "react-dom": "^18.2.0",
    "react-router-dom": "^6.21.3"
  }
}

这里需要注意的是直接装完的pcap模块包不要用本地的nodejs来rebuild,会和electron中的nodejs版本对不上,要使用electron-rebuild来进行rebuild。 运行启动的时候需要以管理员身份运行,因为pcap是需要管理员权限才可以抓取网络硬件上的包的。

然后还需要一个rollup.config.ts

import { defineConfig } from "rollup";
import typescript from "@rollup/plugin-typescript";
import replace from '@rollup/plugin-replace';
import commonjs from '@rollup/plugin-commonjs';
import url from '@rollup/plugin-url';
import html from '@rollup/plugin-html';

console.log("[ start ] - env: ", process.env.NODE_ENV);

const initHTMLOption = ({
    input,
    output
}) => ({
    input,
    plugins: [
        typescript(),
        commonjs({
            include: 'node_modules/**',  // Default: undefined
            extensions: ['.js', '.coffee'],  // Default: [ '.js' ]
            ignoreGlobal: false,  // Default: false
            sourceMap: false,  // Default: true
            ignore: ['conditional-runtime-dependency']
        }),
        replace({
            preventAssignment: true,
            'process.env.NODE_ENV': `"${process.env.NODE_ENV}"`
        }),
        require("./plugins/plugin-css")(),
        url(),
        html()
    ],
    output
});

export default defineConfig([
    initHTMLOption({
        input: "ui/main.tsx",
        output: {
            name: "main",
            dir: "dist/ui/main",
            format: 'cjs'
        }
    }),
    {
        input: "client/main.ts",
        plugins: [
            typescript(),
            commonjs({
                include: 'node_modules/**',  // Default: undefined
                extensions: ['.js', '.coffee'],  // Default: [ '.js' ]
                ignoreGlobal: false,  // Default: false
                sourceMap: false,  // Default: true
                ignore: ['conditional-runtime-dependency']
            }),
            replace({
                preventAssignment: true,
                'process.env.NODE_ENV': `"${process.env.NODE_ENV}"`
            }),
            (() => {
                if (process.argv.includes('--watch')) {
                    console.log("start to run electron plugin");
    
                    return require("./plugins/plugin-electron")();
                } else {
                    return undefined;
                }
            })()
        ],
        output: [{
            file: "dist/client.js",
            format: "cjs",
            sourcemap: true
        }]
    },

]);

plugin-css.js

const { createFilter } = require("@rollup/pluginutils");
const path = require("path");

const plugin = (options = {}) => {
    if (!options.transform) options.transform = (code) => code;

    const styles = {};
    const alwaysOutput = options.alwaysOutput ?? false;
    const filter = createFilter(options.include ?? ["**/*.css"], options.exclude ?? []);

    /* function to sort the css imports in order - credit to rollup-plugin-postcss */
    const getRecursiveImportOrder = (id, getModuleInfo, seen = new Set()) => {
        if (seen.has(id)) return [];

        seen.add(id);

        const result = [id];

        getModuleInfo(id).importedIds.forEach((importFile) => {
            result.push(...getRecursiveImportOrder(importFile, getModuleInfo, seen));
        });

        return result;
    };

    return {
        name: "import-css",

        /* convert the css file to a module and save the code for a file output */
        transform(code, id) {
            if (!filter(id)) return;

            const transformedCode = (options.minify) ? minifyCSS(options.transform(code)) : options.transform(code);

            /* cache the result */
            if (!styles[id] || styles[id] != transformedCode) {
                styles[id] = transformedCode;
            }

            const moduleInfo = this.getModuleInfo(id);
            if (options.modules || moduleInfo.assertions?.type == "css") {
                return {
                    code: `const sheet = new CSSStyleSheet();sheet.replaceSync(${JSON.stringify(transformedCode)});export default sheet;`,
                    map: { mappings: "" }
                };
            }

            return {
                code: `export default ${JSON.stringify(transformedCode)};`,
                map: { mappings: "" }
            };
        },

        /* output a css file with all css that was imported without being assigned a variable */
        generateBundle(opts, bundle) {

            /* collect all the imported modules for each entry file */
            let modules = {};
            let entryChunk = null;
            for (let file in bundle) {
                modules = Object.assign(modules, bundle[file].modules);
                if (!entryChunk) entryChunk = bundle[file].facadeModuleId;
            }

            /* get the list of modules in order */
            const moduleIds = getRecursiveImportOrder(entryChunk, this.getModuleInfo);

            /* remove css that was imported as a string */
            const css = Object.entries(styles)
                .sort((a, b) => moduleIds.indexOf(a[0]) - moduleIds.indexOf(b[0]))
                .map(([id, code]) => {
                    if (!modules[id]) return code;
                })
                .join("\n");

            if (css.trim().length <= 0 && !alwaysOutput) return;

            /* return the asset name by going through a set of possible options */
            const getAssetName = () => {
                const fileName = options.output ?? (opts.file ?? "bundle.js");
                return `${path.basename(fileName, path.extname(fileName))}.css`;
            };

            /* return the asset fileName by going through a set of possible options */
            const getAssetFileName = () => {
                if (options.output) return options.output;
                if (opts.assetFileNames) return undefined;

                return `${getAssetName()}.css`;
            };

            this.emitFile({
                type: "asset",
                name: getAssetName(),
                fileName: getAssetFileName(),
                source: css
            });
        }
    };
};

/* minify css */
function minifyCSS(content) {
    content = content.replace(/\/\*(?:(?!\*\/)[\s\S])*\*\/|[\r\n\t]+/g, "");
    content = content.replace(/ {2,}/g, " ");
    content = content.replace(/ ([{:}]) /g, "$1");
    content = content.replace(/([{:}]) /g, "$1");
    content = content.replace(/([;,]) /g, "$1");
    content = content.replace(/ !/g, "!");
    return content;
}

module.exports = plugin;

plugin-electron.js

const child_process = require("child_process");

function electron() {
    let electronProcess = null;

    return {
        name: "electron",
        buildStart() {
            if (electronProcess) {
                console.log("Killing electron process");
                child_process.execSync(`pkill -P ${electronProcess.pid}`);
                electronProcess = null;
            }
        },
        closeBundle() {
            if (!electronProcess) {
                console.log("Starting electron process");
                electronProcess = child_process.spawn("npm", ["run", "electron"], {
                    stdio: "inherit",
                    shell: true
                });
            }
        }
    }
}

module.exports = electron;

主进程代码

主进程中创建如下三个文件:

  • main.ts 入口文件
  • MainWindow.ts 主窗口类文件
  • Capture.ts 基于pcap的网络包捕获类文件

main.ts 就是按官方的案例直接拷贝的

import { app, BrowserWindow, ipcMain } from "electron";
import MainWindow from "./MainWindow";

const mainWindow = new MainWindow();

let singleLock = app.requestSingleInstanceLock();
if (!singleLock) {
    app.quit();
}

// This method will be called when Electron has finished
// initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs.
app.whenReady().then(() => {
    mainWindow.open();

    app.on("activate", function () {
        // On macOS it's common to re-create a window in the app when the
        // dock icon is clicked and there are no other windows open.
        if (BrowserWindow.getAllWindows().length === 0) {
            mainWindow.open();
        }
    });
});

// Quit when all windows are closed, except on macOS. There, it's common
// for applications and their menu bar to stay active until the user quits
// explicitly with Cmd + Q.
app.on("window-all-closed", () => {
    if (process.platform !== "darwin") {
        app.quit();
    }
});

// In this file you can include the rest of your app"s specific main process
// code. You can also put them in separate files and require them here.


app.on("second-instance", () => {
    mainWindow.open();
});

MainWindow.ts 也是一个普通窗口,然后有一些ipc事件,来和主窗口中的ui进程进行通信

import path from "path";
import { BrowserWindow, app, ipcMain } from "electron";
import { getValidNetworkInterfaces } from './NetworkInterface';
import Capture from "./Capture";

export default class MainWindow {
    window: BrowserWindow | null = null;
    capture?: Capture;
    captureCount = 0;

    constructor() {
        ipcMain.handle("get.network.interfaces", async () => {
            return getValidNetworkInterfaces();
        });

        ipcMain.handle("set.device.name", async (_, { deviceName }) => {
            this.capture = new Capture(deviceName);
        });

        ipcMain.handle("start.capture", async (_, { filter }) => {
            this.capture?.start(filter);
            this.capture?.onPacket((result) => {
                this.window?.webContents.send("capture.on.packet", result);
            })
        });
    }

    private async create() {
        if (this.window) {
            return;
        }

        await app.whenReady();

        this.window = new BrowserWindow({
            width: 1000,
            height: 800,
            webPreferences: {
                nodeIntegration: true,
                contextIsolation: false,
                nodeIntegrationInWorker: true,
                webSecurity: false
            }
        });

        this.window.loadFile(path.join(__dirname, "./ui/main/index.html"));
        this.window.webContents.openDevTools({ mode: "detach" });

        this.window.webContents.once("did-finish-load", () => {
            if (this.window) {
                // new Socket();
            }
        });

        this.window.on("closed", () => {
            this.window = null;
        });
    }

    async open() {
        if (this.window) {
            this.window.restore();
            this.window.show();
            this.window.focus();
            return;
        } else {
            await this.create();
        }
    }

    close() {
        if (this.window) {
            this.window.close();
        }
    }
}

这里注册了三个ipc事件:

  • get.network.interfaces 返回正常可以使用的网卡列表给ui
  • set.device.name ui选定要捕获网络包的网卡名称
  • start.capture ui发起开始捕获指令,之后会调用捕获类的开始方法,监听返回的网络包数据并返回给ui

getValidNetworkInterfaces方法如下

import os from 'os';

export function getValidNetworkInterfaces() {
  const networkInterfaces = os.networkInterfaces();
  const validInterfaces: NodeJS.Dict<os.NetworkInterfaceInfo[]> = {};

  for (const interfaceName in networkInterfaces) {
    const interfaces = networkInterfaces[interfaceName] || [];
    const validInterfacesForName = interfaces.filter(isValidInterface);

    if (validInterfacesForName.length > 0) {
      validInterfaces[interfaceName] = validInterfacesForName;
    }
  }

  return validInterfaces;
}

function isValidInterface(iface: os.NetworkInterfaceInfo) {
  return iface.family === 'IPv4' && !iface.internal;
}

Capture.ts 中就是基于pcap捕获网络包的主要逻辑

import pcap, { PacketWithHeader } from 'pcap';

function toIpAddr(addr: number[]) {
    if (!addr || !addr.length) return '';

    if (addr.length === 4) {
        return addr.join('.');
    }

    if (addr.length === 16) {
        // 将数组的每两个元素转换为两位十六进制字符串,并连接起来
        const hexString = addr.map(byte => byte.toString(16).padStart(2, '0')).join('');

        // 使用正则表达式按照 IPv6 地址的格式添加冒号分隔符
        const ipv6String = hexString.replace(/(.{4})(?=.)/g, '$1:');

        return ipv6String;
    }

    return '';
}

export default class Capture {
    constructor (deviceName: string) {
        this.deviceName = deviceName;
    }

    private session?: pcap.PcapSession;

    public deviceName = '';
    public filter = '';

    private captureCount = 0;

    public start(filter = '') {
        this.session?.removeAllListeners();
        this.session && this.session.close();
        this.filter = filter;
        this.session = pcap.createSession(this.deviceName, { filter: this.filter });
    }

    public onPacket(callback: (captureResult: CaptureResult) => void) {
        this.session?.on("packet", (packetWithHeader: PacketWithHeader) => {
            const packet = pcap.decode.packet(packetWithHeader);

            this.captureCount++;

            const result: CaptureResult = {
                index: this.captureCount,
                time: packet?.pcap_header?.tv_sec * 1_000,
                source: toIpAddr(packet?.payload?.payload?.saddr?.addr) + (packet?.payload?.payload?.payload?.sport ? (":" + packet?.payload?.payload?.payload?.sport) : ''),
                target: toIpAddr(packet?.payload?.payload?.daddr?.addr) + (packet?.payload?.payload?.payload?.dport ? (":" + packet?.payload?.payload?.payload?.dport) : ''),
                protocol: packet?.payload?.payload?.payload?.constructor?.name
            };

            callback(result);
        });
    }
}

主窗口ui进程代码

基于React实现,有两个页面,一个网卡设备选择的页面和一个捕获结果的页面。 网卡设备选择页面中通过调用主进程get.network.interfaces接口来获取可以使用的网卡设备列表,选择之后再调用set.device.name接口来告诉主进程选择的网卡名称,之后会跳转到展示捕获结果的页面 捕获结果页面会监听主进程的capture.on.packet事件,来获取pcap在选择的网卡设备上捕获到的包 代码如下: main.tsx

import React from 'react';
import { createRoot } from 'react-dom/client';
import { RouterProvider, createHashRouter } from "react-router-dom";
import NetworkInterfaceSelector from './NetworkInterfaceSelector';
import Capture from './Capture';

import './Main.css';

const router = createHashRouter([
    {
        path: "/",
        element: <NetworkInterfaceSelector />,
    },
    {
        path: "/capture/:deviceName",
        element: <Capture />,
    },
]);

const App = () => {
    return (
        <React.StrictMode>
            <RouterProvider router={router} />
        </React.StrictMode>
    );
};

document.body.innerHTML = '<div id="app"></div>';
const root = createRoot(document.getElementById('app') as Element);
root.render(<App />);

NetworkInterfaceSelector.tsx

import React, { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import os from 'os';
import electron from 'electron';

import { Avatar, Button, List, Result } from 'antd';
import { LinkOutlined, SmileOutlined } from '@ant-design/icons';

import SelectMenuItemPng from '../assets/select-menu-item.png'

export const NetworkInterfaceSelector: React.FC = () => {
    const [networkInterfaceList, setNetworkInterfaceList] = useState<Array<{
        name: string;
        description: string;
        checked: boolean;
    }>>([]);

    const navigate = useNavigate();

    useEffect(() => {
        electron.ipcRenderer.invoke('get.network.interfaces').then((networkInterfaces: NodeJS.Dict<os.NetworkInterfaceInfo[]>) => {
            console.log(networkInterfaces);
            setNetworkInterfaceList(Object.keys(networkInterfaces).map(name => {

                const description = `${networkInterfaces[name]?.map(info => `
                        ${info.family}: ${info.address}, ${info.mac}\n
                    `)
                    }`;

                return {
                    name,
                    description,
                    checked: false
                };
            }));
            // setNetworkInterfaceList()
        });
    }, [])

    return (
        <>
            <Result
                icon={<img src={SelectMenuItemPng} />}
                title="请先选择一张网卡"
                extra={
                    <List
                        bordered
                        itemLayout="horizontal"
                        dataSource={networkInterfaceList}
                        style={{ textAlign: 'left' }}
                        renderItem={(item, index) => (
                            <List.Item
                                onClick={() => {
                                    console.log("click");
                                    electron.ipcRenderer.invoke('set.device.name', { deviceName: item.name });
                                    navigate(`/capture/${ item.name }`);
                                    // networkInterfaceList.forEach((_, _index) => (networkInterfaceList[_index].checked = false));
                                    // networkInterfaceList[index].checked = true;
                                    // console.log(networkInterfaceList);

                                    // setNetworkInterfaceList(networkInterfaceList);
                                    // props.h
                                }}
                                style={{
                                    cursor: 'pointer',
                                    border: item.checked ? '1px solid #1677ff' : '',
                                    borderRadius: item.checked ? '8px' : ''
                                }}
                            >
                                <List.Item.Meta
                                    avatar={<Avatar icon={<LinkOutlined />} />}
                                    title={<b>{item.name}</b>}
                                    description={item.description}
                                />
                            </List.Item>
                        )}
                    />
                }
            />
        </>
    );
}

export default NetworkInterfaceSelector;

Capture.tsx

import { Input, Table } from "antd";
import React, { useEffect, useState } from "react";
import { useParams } from "react-router-dom";
import electron from 'electron';

let _captureResult: CaptureResult[] = [];

export const Capture = () => {
    const [filter, setFilter] = useState('');

    useEffect(() => {
        electron.ipcRenderer.invoke('start.capture', { filter });
        electron.ipcRenderer.on('capture.on.packet', (_, result: CaptureResult) => {
            _captureResult = [ ..._captureResult, result ]
            setCaptureResult(_captureResult);
        });

        return () => {
            electron.ipcRenderer.removeAllListeners('capture.on.packet');
        }
    }, []);

    let { deviceName } = useParams();
    const onSearch = (value: string) => {
        setFilter(value);
        electron.ipcRenderer.invoke('start.capture', { filter });
    }

    const [captureResult, setCaptureResult] = useState<CaptureResult[]>(_captureResult);

    const columns = [
        {
            title: '时间',
            dataIndex: 'time',
            key: 'time',
            width: 150,
            render: (text: number) => {
                return (<>{new Date(text).toLocaleString()}</>)
            }
        },
        {
            title: '来源',
            dataIndex: 'source',
            key: 'source',
        },
        {
            title: '目标',
            dataIndex: 'target',
            key: 'target',
        },
        {
            title: '协议',
            dataIndex: 'protocol',
            key: 'protocol',
        }
    ];

    return (<div
        style={{
            width: '100vw',
            height: '100vh',
            display: 'flex',
            flexDirection: 'column',
            overflow: 'hidden'
        }}
    >
        <div style={{ flexShrink: '0' }}>
            <h3>网卡{deviceName}中的捕获</h3>
            <Input.Search
                addonBefore="筛选条件"
                allowClear
                onSearch={onSearch}
                style={{ width: 304 }}
                value={filter}
            />
        </div>
        <Table
            bordered
            pagination={false}
            size={'small'}
            dataSource={captureResult}
            rowKey={'index'}
            columns={columns}
            sticky={{ offsetHeader: 0 }}
            style={{ marginTop: '15px', flexGrow: 1, overflow: 'scroll' }}
        />
    </div>)
}

export default Capture;

成品展示

image.png

image.png