简介:A盾 v5.0 版本源代码面向具备 Windows 底层开发基础的安全研究人员与驱动开发者,给出了一套完整的网络数据包拦截与进程管控实现参考,可用于理解内核态与用户态协同工作的机制。压缩包共 319 个文件,体积约 5.1MB;其中 133 个 .h 头文件、54 个 .c 与 52 个 .cpp 源文件构成代码主体,另配 .rc 资源脚本及 .ico、.bmp 图标位图等界面素材,还包含 vcproj/vcxproj 工程、sln 解决方案、makefile 与 sources 等构建配置,以及 .sys 驱动和 .dll 模块产物,可直接用 Visual Studio 或 WDK 编译调试。目前已有 423 人学习下载。读者可从中了解 NDIS 包过滤驱动的分层代码结构、控制设备与用户态通信接口、指令长度反汇编等具体实现,并借助配套批处理脚本理解资源嵌入流程,适合用作驱动开发入门与二次开发的对照样本。
1. 一台重装完系统的办公机,三天后又变得混乱
一台重装完系统的办公机,三天后桌面上多出四个来路不明的“加速器”、两个浏览器插件,还有一个开机自启的托盘程序,没人说得清是谁装的——类似的事在内网里几乎每周都发生。A盾 5.0 源代码指向的就是这个场景:它是一套终端安全防护系统的完整工程,客户端常驻在被管机器上,负责进程、启动项、外设和关键目录的采集与拦截;服务端负责接收上报、下发策略、生成告警;再加一个 Web 控制台做配置和看板。它不是给个人用户装的杀毒软件,而是给几十到几千台机器做统一管控的那一类东西,重点在“可见”和“可控”,不在病毒库。
适合两类人:一类是想照着跑通一套终端管控 Demo 的客户端或后端开发,另一类是要在这套骨架上加自己检查项的二次开发者。前者关心能不能编译、能不能连上、能不能在控制台看见自己那台机器;后者关心规则字段怎么扩、采集点怎么加、告警怎么收敛。这两条路我都走过一遍,坑不算少,但骨架是完整的。
2. 技术栈与目录结构:客户端、服务端、控制台的三段式骨架
2.1 技术选型:为什么是 C++ 客户端 + Java 服务端 + Vue 控制台
A盾 5.0 的分工很干脆:贴近操作系统的那一层用 C++ 写,业务和界面那两层用 Java 和 Vue 写。这种组合在国内的终端管控类项目里非常常见,原因也不玄学——客户端要枚举进程、要读注册表启动项、要 open 设备句柄做外设拦截、要注册成开机自启的系统服务,这几件事用托管语言做要么做不到,要么做起来很别扭;而服务端和控制台全是标准的增删改查加展示,用 Spring Boot 加 Vue 能把开发速度拉满。
| 层次 | 主要技术 | 职责 | 落地注意 |
|---|---|---|---|
| 客户端 Agent | C++11、Win32 API、CMake | 采集、拦截、上报、执行策略 | 编译产物是 Windows 服务,不是普通 exe |
| 服务端 | Java 8、Spring Boot、MyBatis、MySQL 8、Redis | 注册、心跳、策略、告警、审计日志 | 单体应用,按模块分包,不拆微服务 |
| 控制台 | Vue 3、Vite、Element Plus | 终端列表、策略配置、告警看板 | 纯静态资源,打包后丢给 Nginx |
| 通信 | HTTP(S) + JSON,心跳 30 秒 | 上报与下发共用一套接口 | 断网要能本地缓存补传 |
| 存储 | MySQL 存业务,Redis 存在线状态 | 终端在线判定、策略版本号 | Redis 只做缓存,丢了不影响正确性 |
服务端这一层常见做法是单体应用,按agent、policy、alert、audit四个业务包分,不拆微服务。终端管控这类系统的写入量其实不大,几千台机器、30 秒一次心跳,QPS 也就百来级别,拆微服务只会让自己多运维几套进程。真正吃资源的是告警写入和审计日志,这两块后面单独讲收敛。
客户端的构建用 CMake 管理,好处是同一份CMakeLists.txt既能出 Debug 版给开发调试,也能出 Release 版给装机用,不用维护两套 VS 工程文件。依赖的第三方库不多,主要是 HTTP 客户端和 JSON 解析,剩下的全是系统 API,所以编译环境相对干净,这也是它适合拿来练手的原因。
2.2 目录结构:先认清哪几个包是必须动的
拿到源码先别急着编译,花十分钟把目录过一遍,后面改代码能省一半时间。下面是这套工程的主干结构,不同分支可能略有出入,但层次基本一致:
adun/ ├── agent/ # C++ 客户端 │ ├── src/ │ │ ├── main.cpp # 入口,判断是服务模式还是控制台模式 │ │ ├── service/ # 服务安装、启动、守护 │ │ ├── collect/ # 进程、启动项、外设、文件采集 │ │ ├── policy/ # 本地规则执行与拦截动作 │ │ ├── net/ # HTTP 上报、心跳、重试 │ │ └── common/ # 日志、配置、字符串与句柄工具 │ └── CMakeLists.txt ├── server/ # Java 服务端 │ └── src/main/java/com/adun/ │ ├── agent/ # 注册、心跳、指令下发 │ ├── policy/ # 策略 CRUD 与版本管理 │ ├── alert/ # 告警接收、收敛、查询 │ ├── audit/ # 操作审计 │ └── common/ # 统一响应、拦截器、工具 ├── console/ # Vue 控制台 │ └── src/views/ # terminal / policy / alert / audit ├── sql/ # 建库脚本与初始化数据 └── docs/ # 接口说明与部署说明真正高频改动的只有四块:agent/src/collect决定你采什么,agent/src/policy决定命中之后做什么,server/.../policy决定规则怎么存怎么下发,console/src/views/policy决定运维怎么配。其余部分跑通之后基本不用碰,改坏了反而容易把心跳链路搞挂。
docs/里的接口说明值得先读一遍,它定义的是客户端和服务端之间的约定,包括字段名、时间格式、错误码。这块一旦对不上,表现就是客户端一直离线、控制台一直空白,排查起来非常费时间。
2.3 进程模型:三个进程各干什么
客户端装完之后不是只有一个进程,常见做法是三个:主服务进程负责采集和执行策略,守护进程负责在主语程被杀掉之后把它拉起来,托盘进程负责给使用者一个可见的界面和“我要退出”的入口。三个进程之间用命名管道或者本地 socket 通信,不用共享内存,简单可靠。
主服务进程以 Windows 服务的方式注册,StartService之后由系统托管,不依赖任何用户登录。这一点很关键——如果做成开机自启的普通 exe,那么机器锁屏、切换用户、远程桌面断开的时候都可能出问题,而服务模式下这些场景都是稳定的。守护进程同样是服务,但优先级更高,它在启动时先起主语程,然后每 10 秒检查一次主语程的 PID 是否还在,不在就重新拉起。
启动顺序上有个细节:服务端连接必须放在采集线程启动之前完成注册,否则第一批采集数据没有agent_id可挂,只能丢进本地队列等下一次,白白多一轮落盘。我一般会在main.cpp里把初始化拆成“读配置 → 注册 → 起心跳线程 → 起采集线程 → 起策略线程”这五步,串行执行,任何一步失败就写日志并退出,让守护进程去重试,而不是在代码里写一堆 sleep 硬等。
3. 核心模块拆解:从进程采集到策略下发的完整链路
3.1 采集模块:一次进程快照要花多少代价
进程采集是整条链路的起点,也是最容易写出性能问题的地方。核心代码不长,但每个参数都有讲究:
// agent/src/collect/process_collector.cpp // 一次全量进程快照:句柄用完必须 CloseHandle,否则长时间运行会句柄泄漏 std::vector<ProcInfo> CollectProcesses(bool withHash) { std::vector<ProcInfo> result; // TH32CS_SNAPPROCESS 只取进程列表,比带上模块信息轻很多 HANDLE snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); if (snap == INVALID_HANDLE_VALUE) { LogError("snapshot failed, err=%lu", GetLastError()); return result; } PROCESSENTRY32W pe{}; pe.dwSize = sizeof(pe); // 这个字段不赋值,Process32FirstW 会直接失败 if (Process32FirstW(snap, &pe)) { do { ProcInfo info; info.pid = pe.th32ProcessID; info.ppid = pe.th32ParentProcessID; info.name = WideToUtf8(pe.szExeFile); info.path = QueryImagePath(pe.th32ProcessID); // 可能为空,权限不够时拿不到 if (withHash && !info.path.empty()) { info.md5 = FileMd5(info.path); // 计算全盘文件哈希,只对白名单外的文件开 } result.push_back(std::move(info)); } while (Process32NextW(snap, &pe)); } CloseHandle(snap); return result; }CreateToolhelp32Snapshot的第一个参数决定快照内容,这里只用TH32CS_SNAPPROCESS,拿到的就是 PID、父 PID、进程名这些基础字段,代价很低,几百个进程通常几十毫秒内就能返回。PROCESSENTRY32W的dwSize字段必须手动赋值,这是 Win32 里老生常谈的一个坑,忘了就是返回 false,日志里还看不出原因。
QueryImagePath内部走的是OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, ...)加QueryFullProcessImageNameW。这里不要用PROCESS_QUERY_INFORMATION,那个权限在普通用户下经常打不开系统进程,结果就是路径一栏全是空字符串,规则也就匹配不上了。哈希计算是可选开关,因为读文件是 IO,全量算下来在机械盘机器上能把 CPU 顶到 20% 以上,实践中只对规则关心的高风险目录开。
采集频率是个需要权衡的参数。进程快照适合 30 秒到 60 秒一次,太密了没意义还费电,太疏了会漏掉短命进程。启动项和外设可以更慢,五分钟一次足够,因为这两类变化本身就低频。文件完整性检查最贵,通常是按目录配置、错峰执行,别和进程采集跑在同一个线程里。
3.2 规则引擎:一条策略怎么匹配到一条记录
采集上来的是一条条记录,规则引擎负责判断这条记录该不该拦。这套实现刻意做得简单——顺序遍历规则、命中即返回,没有搞复杂的表达式树。原因很实在:规则数量通常只有几十条,遍历的代价远小于维护一棵树的复杂度,而且运维看不懂表达式,出问题没法解释。
// server/src/main/java/com/adun/policy/RuleEngine.java // 规则按 priority 升序排列,命中即返回;action 决定是 BLOCK / ALERT / LOG public MatchResult match(ProcessRecord rec, List<PolicyRule> rules) { for (PolicyRule rule : rules) { if (!rule.getEnabled()) continue; // 停用的规则直接跳过 if (!rule.getOsType().equalsIgnoreCase(rec.getOsType())) continue; // 平台过滤 String actual = rec.field(rule.getField()); // name / path / md5 / publisher if (actual == null) continue; if (compare(actual, rule.getOperator(), rule.getValue())) { return MatchResult.hit(rule); } } return MatchResult.pass(); } private boolean compare(String actual, String op, String expect) { switch (op) { case "EQ": return actual.equalsIgnoreCase(expect); case "CONTAINS": return actual.toLowerCase().contains(expect.toLowerCase()); case "PREFIX": return actual.toLowerCase().startsWith(expect.toLowerCase()); default: return false; // 未知操作符一律不命中,宁可放过不可错杀 } }field是一个字符串到取值的映射,不是反射。反射写起来爽,但线上排查时一个字段名拼错,表现就是规则永远不命中,日志里什么都没有。用显式 map 的话,字段名不认识就能立刻抛出来,控制台保存策略时也能做校验。
compare里默认返回 false 这一行值得单独说。规则引擎的失败方向决定了它是“误杀”还是“漏杀”,终端管控场景下误杀一个正常业务程序的代价远大于漏掉一个可疑程序,所以未知操作符、空值、解析异常统统走不命中。真正需要严格拦截的场景,用白名单兜底,而不是靠规则引擎变严格。
规则的优先级设计也要注意。常见做法是“白名单优先级最高、黑名单次之、普通监控最低”,也就是同一台机器上先跑白名单,命中的直接放行不再往下比。这样运维加一条白名单就能临时救火,不用去翻是哪条黑名单规则误伤了。
3.3 通信层:注册、心跳、策略拉取的重试与幂等
客户端和服务端之间就三件事:注册、心跳、取策略。全部走 HTTP + JSON,接口数量很少,但每个都要考虑失败。
// agent/src/net/reporter.cpp // 三次退避重试,全部失败就落本地队列,由下一次心跳补传 bool Report(const std::string& url, const std::string& body) { for (int i = 0; i < 3; ++i) { if (HttpPost(url, body, 3000)) return true; // 3000ms 连接+读取超时 Sleep(1000 * (1 << i)); // 1s / 2s / 4s } LocalQueue::Push(url, body); // 落盘,队列上限 10000 条,超了丢最老的 return false; }退避间隔用1 << i而不是固定值,是因为服务端重启或者网络抖动往往是几秒级的,固定间隔容易在同一个时间点集中重试,把刚起来的服务端又打一遍。落盘队列的上限必须设,否则一台机器断网三天,本地文件能把磁盘塞满,这个坑我在别的项目里踩过,后来所有本地缓存都强制带上限和淘汰策略。
服务端这边的注册接口要做幂等。客户端的agent_id一般由机器指纹生成(主机名 + 主板序列号 + 首个物理网卡 MAC 做哈希),重装客户端之后指纹不变,注册接口就应该更新记录而不是插入新记录:
// server/src/main/java/com/adun/agent/AgentController.java @PostMapping("/api/agent/register") public R<RegisterResp> register(@RequestBody @Valid RegisterReq req) { AgentDO exist = agentMapper.selectByAgentId(req.getAgentId()); if (exist == null) { agentMapper.insert(buildAgent(req)); // 首次注册 } else { agentMapper.updateHeartbeat(req.getAgentId(), now()); // 重装/重启,只刷新状态 } redis.opsForValue().set("agent:online:" + req.getAgentId(), "1", Duration.ofSeconds(90)); return R.ok(new RegisterResp(policyVersionService.current())); }RegisterResp里带上当前策略版本号,客户端一比就知道要不要拉新策略,省掉一次无意义的全量拉取。Redis 的在线标记 TTL 设成心跳间隔的三倍(30 秒心跳对应 90 秒 TTL),这样偶发丢一两个心跳不会误判离线,连续丢三个才认为掉线。这个三倍关系不是玄学,是留给网络抖动和 GC 停顿的余量。
策略下发用“版本号 + 全量拉取”,不做增量。策略体量小,全量一次也就几十 KB,增量的复杂度完全不划算。客户端本地缓存一份策略文件,启动时先加载本地版本保证有策略可执行,再去服务端比对版本,这样即使服务端挂了,已有策略依然生效。
3.4 服务端落库与告警收敛:别让一条规则生成大量重复告警
告警表是整套系统里写入最猛的一张表。一条规则命中一次写一条,如果不做收敛,一台机器上某个高频轮询的程序能在一小时内刷出几千条一模一样的记录,控制台直接卡死,运维也不会再看这个页面。
CREATE TABLE t_alert ( id BIGINT NOT NULL AUTO_INCREMENT, agent_id VARCHAR(64) NOT NULL COMMENT '终端唯一标识', rule_id BIGINT NOT NULL COMMENT '命中的规则', rule_name VARCHAR(128) NOT NULL, target VARCHAR(512) NOT NULL COMMENT '命中的进程路径或外设名', action VARCHAR(16) NOT NULL COMMENT 'BLOCK/ALERT/LOG', hit_count INT NOT NULL DEFAULT 1 COMMENT '收敛窗口内的命中次数', first_time DATETIME NOT NULL, last_time DATETIME NOT NULL, PRIMARY KEY (id), KEY idx_agent_rule_time (agent_id, rule_id, last_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;收敛逻辑放在写入前:同一个agent_id + rule_id + target在 5 分钟窗口内只保留一行,重复命中就hit_count + 1并更新last_time,不新增行。实现上先用 Redis 做一个短期的去重键,命中就直接累加,没命中才走数据库的 upsert。这样高频告警的写入压力基本落在 Redis 上,MySQL 只承担真正的状态变更。
idx_agent_rule_time这个联合索引是为控制台的“某台机器最近告警”查询准备的,字段顺序不能换。反过来如果先按rule_id查“某条规则命中了哪些机器”,就得再建一个(rule_id, last_time)的索引。索引这块别偷懒,告警表上百万行很快的。
审计日志和告警要分表。告警是“出事了”,审计是“谁改了什么”,两者的查询模式、保留周期、合规要求都不一样。混在一张表里,最常见的结果是运维想查最近告警,被一堆“修改策略”的记录淹没。
4. 把项目跑起来:编译、建库、联调客户端
4.1 环境与依赖:编译前先把这几样装齐
这套工程的依赖不算多,但版本卡得比较死,尤其是 JDK 和 MySQL 驱动。下面是我在一台干净的开发机上验证过的组合:
| 组件 | 版本要求 | 用途 | 备注 |
|---|---|---|---|
| JDK | 8 或 11 | 服务端编译运行 | 别上 17,部分老依赖会报模块化错误 |
| Maven | 3.6+ | 服务端构建 | 配好国内镜像,否则首次拉依赖很慢 |
| MySQL | 8.0 | 业务数据 | 字符集必须是 utf8mb4 |
| Redis | 5.0+ | 在线状态、告警去重 | 不设密码也行,本地开发够用 |
| Node.js | 16 或 18 | 控制台构建 | 版本太高 Vite 老配置会报错 |
| VS Build Tools | 2019 | 客户端编译 | 装“使用 C++ 的桌面开发”工作负载 |
| CMake | 3.20+ | 客户端构建 | 低于 3.15 有些语法不认 |
客户端编译这块有个容易被忽略的点:必须装 Windows SDK,而且要和 VS 的版本匹配。只装 Build Tools 不勾 SDK,CMake 配置阶段能过,一编译就报找不到windows.h,报错信息还挺绕,看着像是代码问题。
4.2 服务端:建库、改配置、起服务
先建库跑脚本,sql/目录下一般会有schema.sql和init_data.sql两个文件,前者建表,后者塞一个默认管理员账号和几条示例规则。执行顺序不能反,否则初始化数据插不进表。
# 1. 建库(字符集一定要显式指定,不然中文规则名会变问号) mysql -uroot -p -e "CREATE DATABASE adun DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_general_ci;" # 2. 导表结构和初始数据 mysql -uroot -p adun < sql/schema.sql mysql -uroot -p adun < sql/init_data.sql # 3. 改配置后打包启动 cd server mvn -DskipTests clean package java -jar target/adun-server-5.0.jar --spring.profiles.active=dev配置文件里要改的就三处:数据库连接、Redis 地址、客户端上报的服务端地址。第三项经常被漏掉,agent.report.base-url默认写的是127.0.0.1,本地联调没问题,一放到虚拟机上客户端就永远连不上。
# server/src/main/resources/application-dev.yml spring: datasource: url: jdbc:mysql://127.0.0.1:3306/adun?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: 你的密码 redis: host: 127.0.0.1 port: 6379 agent: report: base-url: http://192.168.1.20:8080 # 客户端实际能访问到的地址,别写 localhost heartbeat: timeout-seconds: 90serverTimezone这个参数必须显式写。MySQL 8 的驱动默认按 UTC 解析时间,不配的话控制台上所有时间都会比实际早 8 小时,看起来像是采集延迟,其实是时区问题,这个坑每年都有人踩。
启动成功的标志是控制台打出Started AdunServerApplication,并且 8080 端口处于监听状态。如果卡在HikariPool初始化,八成是数据库连不上或者密码错了,先去看sql/schema.sql是不是漏跑了。
4.3 客户端:CMake 编译与注册成服务
客户端编译分两步,先 CMake 生成工程,再用 VS 的构建工具编译。命令行一把梭:
cd agent # -A x64 必须写,默认可能生成 32 位,装到 64 位系统上跑不起来 cmake -S . -B build -G "Visual Studio 16 2019" -A x64 -DCMAKE_BUILD_TYPE=Release cmake --build build --config Release # 产物一般在 build/Release/agent.exe,先以前台模式跑一次看日志 ./build/Release/agent.exe -console-console这个参数是给调试用的,让程序不注册服务、直接在当前终端里跑,日志打到标准输出。第一次跑一定要用这个模式,确认注册接口通了、心跳正常了,再去装服务。直接装服务的话,出问题只能去事件查看器里翻,效率低很多。
确认无误后注册成服务:
# 注意 binPath= 后面必须有一个空格,这是 sc 命令的老规矩,少了就报参数错误 sc create AdunAgent binPath= "C:\adun\agent.exe -service" start= auto DisplayName= "A盾终端防护" sc description AdunAgent "终端安全防护客户端" sc start AdunAgentstart= auto表示开机自启,binPath里的路径要是绝对路径并且带引号,路径里有空格的时候尤其要注意。装完之后用sc query AdunAgent看状态,STATE显示RUNNING才算成功。如果显示STOPPED并且启动时报 1053,一般是服务入口函数没有在规定时间内调用StartServiceCtrlDispatcher,回头检查main.cpp里服务模式的分支有没有走到。
4.4 联调:从客户端注册到控制台出现第一条告警
联调顺序建议是“先手动调接口,再跑客户端”。手动能通说明服务端没问题,剩下就是客户端的事,排查范围直接砍一半。
# 手动模拟一次注册,确认服务端接口和数据库写入都正常 curl -X POST http://127.0.0.1:8080/api/agent/register \ -H "Content-Type: application/json" \ -d '{"agentId":"test-0001","hostname":"vm-test","osType":"windows","agentVersion":"5.0"}' # 再查一次终端列表,应该能看到刚注册的这台 curl "http://127.0.0.1:8080/api/agent/list?page=1&size=10"接口通了之后启动客户端,在控制台的终端列表里应该几十秒内出现这台机器,状态从“离线”变成“在线”。然后去策略页面加一条最简单的规则,比如“进程名包含minesweeper时记录日志”,再到虚拟机上手动跑一下对应程序,等一个采集周期,告警页面就会出现记录。
验证链路上有三个观察点:客户端的本地日志有没有“register success”和“heartbeat ok”;服务端日志有没有收到/api/agent/heartbeat;控制台列表的状态列是不是绿色。三个点里哪一个断了,问题就定位在哪一段,不用瞎猜。
5. 避坑与排查:五个让我加班到凌晨的问题
5.1 服务装上了但起不来,事件日志报 1053
现象是sc start AdunAgent返回 1053,服务管理器里状态一闪就变回“已停止”,客户端日志一个字都没有。
原因基本固定在服务入口。Windows 服务的ServiceMain必须在StartServiceCtrlDispatcher调用后 30 秒内上报SERVICE_RUNNING,而很多示例代码在ServiceMain里做了耗时的初始化(读配置、连服务端、等注册返回),一旦超时就判定启动失败。另外还有一个更隐蔽的原因:main函数里判断命令行参数时用了宽字符比较,但argv是窄字符,-service这个分支根本没进去,程序直接进了控制台模式然后退出。
解决方式是把耗时初始化挪到独立线程里,ServiceMain里只做注册状态、起线程、立即上报 RUNNING 三件事。参数判断统一转成宽字符再比,或者干脆用CommandLineToArgvW取参数。改完之后启动时间从 3 秒降到 200 毫秒,问题消失。
5.2 控制台显示离线,但机器能 ping 通服务端
现象是客户端日志里一直刷“register failed, retry”,服务端日志干干净净,一条请求都没收到。
原因通常是地址不对,不是网络不通。客户端配置里写的是127.0.0.1:8080,装在虚拟机里跑的时候,这个地址指向虚拟机自己,当然连不上;另一种情况是服务端只监听了localhost,没有监听0.0.0.0,外部请求直接被拒。还有一种是公司网络里的防火墙只放行了 80 和 443,8080 被拦了,telnet一下就看得出来。
解决方式是三步:先在客户端机器上telnet 服务端IP 8080确认端口通;再检查服务端server.address配置;最后确认客户端配置里的地址是客户端视角能访问到的地址,不是开发机的视角。这个顺序别乱,先查配置能省下抓包的时间。
5.3 策略改了不生效,重启客户端才行
现象是控制台改了规则、点了保存,被管机器上的行为一点变化都没有,把客户端服务重启一次就立刻生效了。
原因是策略拉取做成了“只在注册时拉一次”,心跳接口返回的版本号客户端压根没解析。这种 bug 在联调阶段特别容易漏,因为开发时每次都是改完代码重启客户端,相当于每次都走了注册流程,看起来一切正常。
解决方式是在心跳响应里带上policyVersion,客户端拿到之后和本地缓存比对,不一致就异步触发一次全量拉取,拉完再比对一次 MD5 防止半包。要注意拉取动作要加锁,否则心跳和手动刷新同时触发会拉两次,虽然不出错但浪费带宽。
5.4 采集到的进程列表重复,CPU 占用一路飙升
现象是控制台的进程列表里同一个进程出现好几条,机器风扇开始狂转,任务管理器里客户端 CPU 稳定在 30% 以上。
原因有两个,往往同时出现。一是采集线程被重复创建了,服务模式和守护进程模式各起了一份,两个线程往同一个队列里塞数据;二是哈希计算没有做缓存,每一轮采集都把全盘所有 exe 重新算一遍 MD5,机械盘上这一项就能吃掉大半 CPU。
解决方式是在采集模块加一个全局的单例保护,用std::call_once或者原子标志位都行,保证采集线程全程只有一个;哈希加一层按“路径 + 文件大小 + 修改时间”做 key 的内存缓存,命中就不重算,缓存上限设个几千条,超了按 LRU 淘汰。改完之后 CPU 降到 3% 以内。
5.5 告警时间比实际早八小时,怎么改都改不对
现象是控制台里所有告警的时间戳都比真实时间早 8 小时,客户端和服务器的系统时间都是对的。
原因是 JDBC 连接串里没写serverTimezone,MySQL 8 的驱动默认按 UTC 处理DATETIME,写入时按 UTC 解释,读出来再按本地时区展示,一来一回就差了一个时区。另一种情况是 Java 侧用了java.util.Date而数据库字段是DATETIME,中间转换环节又做了一次时区换算,叠加之后偏差更大。
解决方式是连接串显式加serverTimezone=Asia/Shanghai,实体类里的时间字段统一用LocalDateTime,服务端和数据库都按同一个时区存。改完之后清一次历史数据,否则新旧数据混在一起,看板上的趋势图会有一道明显的断层。
6. 二次开发:把通用盾改成你自己的盾
拿到这套源码之后,绝大多数人的第一件事都是加检查项。我拿“禁止运行指定目录下的可执行文件”举例,走一遍完整链路,顺便说说哪些地方容易改出问题。
扩展点一共五处,改之前先对着表确认影响范围:
| 层次 | 改动位置 | 改动内容 | 风险 |
|---|---|---|---|
| 数据库 | t_policy_rule表 | 新增操作符枚举值,无需加字段 | 低 |
| 服务端 | RuleEngine.compare | 增加REGEX分支 | 中,正则写错会拖慢匹配 |
| 客户端 | policy/rule_executor.cpp | 增加对应动作分支 | 中,动作写错会误拦 |
| 控制台 | views/policy/RuleForm.vue | 下拉框加一个选项 | 低 |
| 文档 | docs/api.md | 补充操作符说明 | 低 |
服务端这一层最好改,加个枚举分支就行:
// server/src/main/java/com/adun/policy/Operator.java public enum Operator { EQ, CONTAINS, PREFIX, REGEX; // 5.0 原生只有前三个,这个分支是二次开发加的 public static Operator of(String v) { for (Operator op : values()) { if (op.name().equalsIgnoreCase(v)) return op; } return null; // 返回 null 而不是抛异常,交给上层决定是否忽略该规则 } }正则匹配一定要做两件事:编译结果缓存、超时保护。Pattern.compile是有成本的,每条记录都编译一次,几十条规则乘以几百个进程,一秒内能编译上万次;超时保护则是防灾难性回溯,用户在控制台填一个(a+)+$这种表达式,能把采集线程卡死。我的做法是用 Caffeine 或者简单的ConcurrentHashMap缓存Pattern,再加一层字符串长度前置校验,超过 256 字符的目标直接不匹配。
客户端那边的动作执行有个必须注意的点:拦截动作要在服务进程里做,不要在托盘进程里做。托盘进程是用户态的,用户一关就没了,拦截随之失效;而且托盘进程通常以普通用户权限运行,TerminateProcess一个系统服务根本打不进去。所有拦截统一走服务进程,托盘只负责展示和转发指令。
验证方法我是这么走的:先在服务端写一个规则匹配的单元测试,用固定的一组ProcessRecord跑,断言命中结果和动作;然后在虚拟机上装客户端,配一条只会 LOG 不会 BLOCK 的规则跑一天,确认不会误伤;最后才把动作改成 BLOCK,在隔离环境里试。跳过第二步直接上 BLOCK,把业务程序拦了,运维找上门的时候解释起来很麻烦。
从那以后我每次动规则引擎,都会先把t_alert里最近一天的记录导出来,用新规则离线重跑一遍,对比命中数量的变化。这一步花不了十分钟,但能提前发现“新加的正则把所有系统进程都匹配上了”这种灾难。终端管控这类系统,出错的代价从来不是报错,而是悄悄拦掉了不该拦的东西。希望帮到你。
本文还有配套的精品资源,点击获取