☰
用 Go 重写 checksec:从 10 分钟到 5 秒的二进制安全审计性能革命
2026/10/21 10:22:48 网站建设 项目流程

做二进制安全审计时,我几乎每次都会用到checksec.sh。它的工作很纯粹:检查一个ELF可执行文件上启用了哪些安全防护机制——地址随机化依赖的PIE、栈溢出的最后防线Stack Canary、GOT表重定位的RELRO、堆栈不可执行的NX,以及编译期边界检查的FORTIFY。这些属性直接决定了某个已知漏洞能被利用到哪一步,也是很多安全基线检查里必须核验的硬指标。

我第一次意识到需要换一种实现,是因为一次大批量扫描。当时要对某系统目录下几千个可执行文件做安全属性基线核查,用经典的Bash脚本版本一把梭,屏幕上进度条一寸一寸地挪,整个跑完花了将近十分钟,而在同一批文件上,换成Go重写后的版本,只等了几秒钟。从那以后,这套从Bash到Go的替换路径,成了我做批量审计和CI集成的固定选项。这篇实战记录就来拆解checksec两个版本的工作方式、性能差异和部署踩坑。

1. Checksec的检测对象:先搞懂这六个安全属性

1.1 每个字段背后对应的利用难度

先厘清基础概念。checksec不是漏洞扫描器,它不做代码审计,只回答一个问题:这个ELF文件在编译和链接阶段,启用了哪些加固手段。

逐个讲:

  • NX(No eXecute)。如果堆栈段被标记为可执行,攻击者在栈上溢出后可以直接弹shellcode。判断依据是程序头表里GNU_STACK段的Flags,如果带E,说明栈可执行,NX关闭。
  • PIE(Position Independent Executable)。可执行文件本身如果是位置无关的,配合内核ASLR,每次加载的基址都随机。没有PIE时,攻击者可以直接引用固定地址的代码段。
  • RELRO。重定位表只读化。Partial RELRO下.got.plt可写,攻击者可以利用格式化字符串写GOT;Full RELRO会把整个GOT段设为只读,并在加载时完成全部绑定。
  • Stack Canary。函数帧返回前会校验一个随机数,栈溢出覆盖返回地址之前会先破坏Canary,触发__stack_chk_fail终止进程。
  • FORTIFY。_FORTIFY_SOURCE编译宏开启后,常用字符串与内存函数会增加边界检查,在到达目标漏洞之前就报错。
  • RPATH/RUNPATH。程序运行时搜索共享库的路径,如果包含当前目录或任意可写路径,攻击者可以放一个同名恶意库实现劫持。
属性检测目标未开启时的可利用性
NX栈/堆段是否可执行栈溢出后可执行shellcode
PIEELF Type是否为DYN攻击者可利用固定基址
RELROGNU_RELRO段与BIND_NOWGOT可写,可劫持流程
Canary是否存在__stack_chk_fail符号返回地址可被直接覆盖
FORTIFY是否存在*_chk符号部分溢出可直接在库函数层利用
RPATH/RUNPATH动态段中的搜索路径可劫持共享库

这里面有一个最常见的坑:看到ELF Type是DYN就认为PIE已开启。共享库本身也是DYN类型,所以还要看有没有PT_INTERP段,有PT_INTERP的才是可执行程序,否则只靠Type字段会误报。Bash版脚本其实也是这么判断的,但它把细节隐藏在readelf文本里,很多人没注意到。

1.2 为什么安全检查要盯住这些字段

从攻击利用链路来看,这些字段是逐层叠加的关系。假设一个程序存在经典栈溢出,如果开了Canary,攻击者必须先去泄漏Canary值;如果NX没开,可以让返回地址跳到栈上的shellcode;如果NX开了但PIE没开,可以采用固定地址的ROP链;如果全开了,攻击链复杂度瞬间上升好几个量级。

所以很多内部安全规范里会要求,对外发布的二进制默认满足“NX+PIE+Full RELRO+Canary”这一档。做渗透测试时拿到一个目标程序,checksec的输出也是第一步——决定后面往哪个方向尝试。我在评估第三方组件时也常用它快速筛选风险项,看到RPATH里带可写路径的,基本可以直接判高危。

2. Bash版是怎么工作的?性能瓶颈就藏在原理里

2.1 一次检查要拉起多少次readelf

经典版本是纯Shell脚本,它的实现思路非常直白:对每个文件调用readelf解析各段,再把结果用管道灌给grep/awk/sed,最后根据文本特征打标。虽然细节上有大量分支判断,但整体就是这种“外部命令+文本处理”的流水线风格。

一个简化的示意能说明结构:

check_one() { local file="$1" if readelf -l "$file" 2>/dev/null | grep -q 'GNU_STACK.*RWE'; then echo "NX: Disabled" else echo "NX: Enabled" fi }

实际脚本当然比这个复杂,但要理解性能问题,抓住这几点就够了:每检查一个字段,至少启动一次readelf子进程;每一条管道之间都要搬运文本数据;每个文件要做多轮这样的循环。扫描一个文件,Bash脚本自身或许只花了几毫秒,但readelf进程的创建和退出、动态库的加载和初始化,才是真正的大头。

我有一次在调试时数过,检查单个文件,脚本里要触发的进程数按个位数起步,如果目录下有三五百个文件,总进程数就是几千次。每一次fork+exec,都是用户态到内核态的来回切换,单机就算有再多的核,脚本本身一次也只能走一个文件,CPU的并发能力完全用不上。

2.2 慢的根源:重复IO、顺序执行、文本解析

除了进程创建,另一个隐藏成本是重复的磁盘IO。Bash脚本每发起一次readelf,readelf就要重新打开ELF文件、映射一遍文件的对应部分。同一个文件在扫描过程中会被打开好多次,每次都做同样的解析工作。机械盘或者网络存储上,这种重复开销会被放大得很厉害。

文本解析也是消耗点。readelf输出的每行文本都要经过管道进入grep或awk,它们再做一次分词和模式匹配。Bash自身处理数组、字符串比对也远慢于编译型语言。三五百个文件规模下,这个差距就已经很直观了;到几千个文件时,整批跑下来等得人怀疑脚本是不是卡死了。在纯SSD的机器上会好一些,但标准流程的耗时仍然是以秒到分钟计的。

需要说明,Bash版不是没有优点。它是单个脚本,没有编译依赖,系统里只要有bash和binutils就能跑;作为交互式单文件检查工具,一条命令看一个文件的防护状态非常方便。它的慢主要出现在批量场景。

3. Go重写版改了什么:砍子进程、上并发、换输出

3.1 用debug/elf做本地解析,不再依赖外部命令

Go版本最核心的改动,是直接引入debug/elf包,把readelf、grep、awk们做的事情全部在进程内完成。debug/elf把ELF文件的头部、程序头表、动态段、符号表都映射成了结构体,代码里可以直接遍历Progs拿每一段,也可以读取Dynamic Entries分析RPATH、BIND_NOW这些标签。

伪代码层面的区别是决定性的:

f, err := elf.Open(path) for _, prog := range f.Progs { if prog.Type == elf.PT_GNU_STACK { ... } if prog.Type == elf.PT_INTERP { hasInterp = true } } dynamics, _ := f.DynamicSymbols()

一次打开文件、一次读取、在内存里完成全部判断,全程不启动任何外部进程。批量场景里,省掉的进程切换和重复IO直接反映在墙钟时间上。

另外一个被很多人忽略的好处是输出确定性。Bash版本通过readelf文本判断,readelf在不同binutils版本之间输出格式有差异,脚本偶尔会因为这个误判。Go版本直接读ELF二进制结构,格式稳定,误判来源少很多。

3.2 并发怎么做才不至于翻车

Go的goroutine很轻,但检查ELF并不只是CPU运算,还涉及文件打开和读取。我的经验是不要无脑给每个文件开一个goroutine,会引发大量并发打开文件、随机读,机械盘或弱容器环境里反而更慢。稳妥做法是控制并发上限,一般按CPU核数的2倍到一个固定值之间,比如8到16路worker,配合带缓冲的文件列表通道实现消费者模式。

sem := make(chan struct{}, 16) for _, path := range paths { sem <- struct{}{} go func(p string) { defer func() { <-sem }() results[p] = scanFile(p) }(path) }

这里还有一个工程细节:并发填充的map要加锁或者用channel汇总,否则race检测会报问题。结果最后按文件名字典序输出,保证多次运行结果稳定,方便diff和后续指标统计。

3.3 JSON输出和退出码:让脚本和CI都舒服

交互式场景里人容易读的是表格输出,但自动化场景里,JSON比任何带缩进的文本都可靠。Go版普遍支持输出JSON对象数组,字段名清晰,例如:

[ { "file": "/usr/bin/demo-app", "nx": true, "pie": true, "relro": "full", "canary": true, "fortify": false, "rpath": "", "runpath": "" } ]

配合退出码设计,就能直接在CI阶段做判定:发现NX关闭或RELRO缺失时返回非零,流水线自然失败。Bash版在单文件环境下非常好用,但一旦要接入自动化,JSON+退出码的组合明显省事得多,不用自己去解析空格对齐的表格文本。

4. 性能对比实录:从几分钟压到几秒

4.1 我是怎么测的

先交代测试环境:一台通用的4核8线程Linux开发机,磁盘是SSD,系统是常见发行版。测试样本选了三种:系统自带可执行文件、第三方工具目录、一个构建产物目录,覆盖大小和链接方式都不同的ELF。

对比方法上,我每次都先各跑一遍相同的批次做预热,再按目标批次分别执行,每个规模测三轮取中间值,避免缓存抖动和偶发的IO波动。批次规模按文件数量拉了几档:50个、500个、5000个。Bash版是原版直接执行,Go版用默认参数跑,两者都写结果到文件,不计入输出到终端的时间。

有一点要提醒:性能数字在不同机器、不同磁盘、不同文件大小下的差异很大,下面的数据是某次代表性测试的记录,只能说明趋势,不能当作绝对标准。如果要做严格对比,建议按我这个流程在自己机器上复现一遍。

4.2 实测结果与加速比

文件数量Bash版耗时Go版耗时参考加速比
50约6秒约0.1秒约60倍
500约58秒约0.8秒约70倍
5000约9分钟约5秒约100倍以上

结果基本符合预期,文件数量越大,差距越明显。Bash版本的耗时几乎随文件数线性增长,进程创建和重复IO的开销是实实在在的。Go版本在并发开启后有了一定摊薄,增长也远没有脚本那边激进。

还有一个容易被忽略的点:单文件场景下两者差距并没有那么大。Go版本冷启动和ELF解析加起来也要几十毫秒,而Bash版本查一个文件也许只慢个一两倍,对一次交互式查询来说根本没有感觉。所以不要因为批量性能好,就把日常单文件检查也强行换成Go,工具选用还是看场景。

4.3 加速效果背后,其实只有三个原因

这三条原因在之前的章节里都埋了伏笔,这里集中说透:

第一,进程调用的全链路消失。Bash版每个文件要拉起多次readelf,每次都是fork+exec+加载动态库。Go版在没有外部依赖的情况下,文件打开一次,解析和检测全部在自己的代码循环里完成。

第二,重复IO被消除了。脚本每检查一个字段,readelf就会重新把文件的相关区域读一遍。Go版一次读入解析成结构体,后续所有检查都在这份内存数据上进行。

第三,并发把多核吃满了。Bash版是严格串行的,一次检查一个文件,而Go版的worker池同时扫多个文件,4核8线程的机器上,并发窗口带来的收益非常直接。

5. 实战部署与集成:安装、使用、CI落地

5.1 安装和编译的几个决定

Bash版的使用条件很宽松:系统里有bash、coreutils、binutils就行,通常是直接下载脚本、加执行权限、跑起来。如果你只是想在服务器上临时看看单个文件,这个方法我个人觉得已经很够了。

Go版的选择上,可以直接用编译好的发布包,也可以自己从源码构建。我习惯用静态编译方式,方便塞进后续要用的扫描容器,不依赖目标环境里的glibc和binutils:

CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o checksec .

静态二进制放进最小镜像、甚至在完全没有readelf的容器环境里都能直接跑,这个特性在云原生场景里非常值钱。

5.2 日常扫描该用哪些命令

单文件检查用参数指路径,目录批量扫描用目录参数,这两个是最核心的用法:

./checksec --file=/usr/bin/demo-app ./checksec --dir=/usr/bin

一个典型的输出长这样:

RELRO STACK CANARY NX PIE RPATH RUNPATH FORTIFY Full Canary found NX enabled PIE enabled No RPATH No RUNPATH Yes

逐字段看:RELRO显示Full,说明链接器用了-z now;Canary found说明至少有一个目标函数开启了栈保护;NX enabled代表GNU_STACK段不可执行;PIE enabled配合内核ASLR,加载地址随机化;RPATH/RUNPATH为空表示没有自定义库搜索路径;FORTIFY为Yes代表编译时定义了_FORTIFY_SOURCE并且优化级别到位。

要注意,输出里的“No RPATH”和“No RUNPATH”是指动态段里没有对应条目,而不是说路径是安全的。如果动态段里有一个看起来正常的路径,工具会把它打印出来,但具体有没有危害还得结合目录权限和历史遗留代码一起判断。

5.3 接入CI流水线:构建产物检查放在哪一步

CI集成的正确位置是构建之后、打包之前。源代码本身没有ELF可检查,必须在编译产物生成后再跑。我把检查步骤放在编译完成、打包之前,流水线脚本里先执行带JSON输出的扫描命令,把结果写入文件,再执行一次带严格阈值的判定,比如要求关键防护项全部开启,如果返回值非零就让流水线标红。JSON文件同时作为制品归档,方便后续做安全趋势统计。

统计的价值很难在第一次体现出来,但坚持一段时间后,你会发现它能暴露很多问题。比如某次更新后NX关闭的文件数量突然变多,一查往往就是构建脚本里优化参数被覆盖,或者基础镜像的工具链版本被替换了。没有这些结构化数据,退化通常要等到一次渗透测试或者漏洞复盘时才会被发现。

5.4 想扩展新检测项怎么动手

不同团队的检查维度不一样,比如有人关心可写可执行段(W^X),有人要专门标记SUID文件。Bash版扩展需要在长长的文本判断分支里再插一段grep和条件语句,字段多了之后脚本的维护成本直线上升。

Go版的扩展路径通常是把检测逻辑抽象成独立的检查函数或规则条目,新增一个检测项就是新增一个结构体字段和处理分支。实测下来,读代码和定位问题的体验明显更好。如果更进一步,把检查规则做成配置文件,让不同项目只启用自己关心的字段,那就成了一个团队内部的安全检查工具,而不再只是一个脚本。

6. 踩坑记录:常见问题与排查技巧

6.1 字段结果不对,先查编译参数而不是工具

最典型的误判来源是拿静态链接的二进制或较老的工具链产物去比对预期。PIE要求编译和链接都带对应参数,纯静态库或某些嵌入式工具链可能默认不开启;Full RELRO需要链接阶段传入-z now,在很多默认配置下只会得到Partial。这时候checksec显示的结果不是bug,而是构建参数确实没到位。

我踩得最多的一次是FORTIFY:以为定义_FORTIFY_SOURCE就有效,结果忘了优化级别要求,O0下很多检查不会插入,输出的FORTIFY字段就是No。排查这类问题,请直接对照编译命令,而不是反复猜测脚本逻辑。

6.2 不是ELF、符号链接、加壳程序,都要单独处理

checksec针对ELF设计,输入一个shell脚本或压缩包,它不会报错,而是提示不是有效的ELF文件。符号链接可能解析到目标文件,但目录批量扫描时可能会重复计数或漏掉。我建议扫描前先理清范围,符号链接也尽量保留目标路径输出,避免审计报告里数量对不上。

加壳和混淆程序是另一个坑。某些壳会重写程序头、压缩段数据,导致GNU_STACK、GNU_RELRO等段的判断失真。此时checksec的结果只能作为辅助参考,需要结合运行时监测手段做进一步分析。

6.3 大批量扫描的内存与并发权衡

Go版本虽然快,但并发太激进时内存还是会上去。一个经验值:并发数在16到32之间,大多数办公场景下扫描几千个文件,内存波动基本在可接受范围;如果你要扫的是几百万个文件的文件服务器,建议分块扫描或者改成流式输出,不要一次性把结果全部塞进内存再落盘。

另外,在只有2核的低配机器上,把并发设成CPU核数的2倍左右,比盲目开到32更稳。限制并发的环境变量或参数一定留好,线上容器分配的资源可能不如你本机宽裕。

6.4 文件名里的空格和换行,Bash版的老毛病

这个问题几乎碰一次就印象深刻。Bash版从find输出里读文件列表时,如果文件名里有空格,常见的命令行展开会把一个文件拆成两个,扫描结果出现一堆“找不到文件”的噪音。换行符在文件名里更是灾难。Go版使用目录遍历接口逐个返回路径,文件名始终是一个完整字符串,不会发生这种拆分。

如果暂时只能留在Bash版,记得用while IFS= read -r这样的安全读法,而不是直接for file in $(find ...)。

6.5 自动化解析时优先用JSON,别去解析文本表格

我没统计过为解析文本输出浪费了多少调试时间。表格输出的可见空格和缩进在不同工具版本间可能有差异,单看一个字段还好,一旦要做条件判断,文本解析能断得莫名其妙。Go版的JSON输出字段固定,解析逻辑一次写好以后基本不必再改,CI脚本的脆弱性会明显降低。

另外一个实用细节:自动化流水线里,请把文本输出交给终端展示,让机器读JSON,各干各的,互不干扰。这样日志还能保留人类可读的格式,结果判断则完全由结构化数据驱动。

说回我自己的固定组合:日常临时检查单个文件,仍然喜欢经典Bash版,一条命令拿过来就能看;一旦进入批量任务、合规报告、CI流水线,就切换到Go版。工具换了,字段含义和检查原理没有任何变化,风险点还是那些,变的只是速度和管理成本。如果你也想在公司内部做一轮二进制安全基线核查,我建议先拿Bash版跑通全部判断逻辑,再让Go版批量落地,这样排查问题时你对每一项结果都心里有数。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询