做嵌入式 Linux 这几年,我越来越觉得“能跑起来”只是及格线,真正考验功底的,是产品交到用户手里之后,还能不能在公网上安安稳稳活下来。早年我帮客户做一款联网的工业采集网关,当时赶工期,系统起来能 ping 通、能上报数据就发货了。结果不到两个月,客户反馈设备频繁掉线,远程一查——系统里多了一堆陌生进程,端口被扫开,直接被种了挖矿程序。从那以后,我只要做嵌入式 Linux 系统,安全加固就是必选项,而且是从设计阶段就介入,不是事后补丁。
这一讲的内容,恰好就是围绕这个方向展开的:嵌入式 Linux 系统级安全加固。标题里四个关键词都很实在——最小化裁剪、权限硬化、日志审计、轻量防火墙。说白了,就是一套在资源受限、没有专职安全运维的嵌入式设备上,也能落地的加固流程。这篇文章会把每一步的做法、为什么要这么做、以及我踩过的坑都写清楚,最后还会补上第 16 讲课后思考题的完整解析。适合正在做嵌入式 Linux 产品、或者想系统性学习系统底层加固的工程师,尤其是那些设备要暴露在公网、又没条件上重安全产品的场景。
1. 为什么嵌入式设备要做安全加固:先想清楚对手是谁
1.1 嵌入式设备为什么容易变成“肉鸡”
很多嵌入式工程师对安全的感知是“我没被黑过”,但真实情况往往是“被黑了也不知道”。嵌入式设备有几个先天弱点,决定了它天然是攻击者的优先目标。
第一是硬件资源有限,跑不动重量级安全软件。你总不能给一个只有 64MB 内存、主频 400MHz 的板子上硬塞一个完整的 SELinux 策略和主机入侵检测系统,跑不动是一回事,开发效率也受不了。第二是环境复杂,设备分散在用户现场,没有统一的安全运维通道,补丁推送困难。第三是很多设备常年以 root 权限跑业务进程,而且固件里还残留调试接口、默认密码、未使用的网络服务。
我用一句话总结嵌入式设备安全的本质:攻击者要的不是你的系统多“高级”,而是你系统里有没有“低垂的果实”。最小化裁剪就是把这些果实先摘掉——不在系统里的东西,就没有被利用的机会。
1.2 安全加固的整体框架:按攻击面来思考
拿到一个需要加固的嵌入式系统,我不会上来就敲命令,而是先画一张攻击面地图。我把嵌入式 Linux 的攻击面分成四层:
- 网络层:暴露的端口、监听的网络服务、防火墙规则缺失、SSH 弱口令。
- 系统层:不安全的文件权限、SUID 程序、不必要的用户、未使用的内核模块。
- 应用层:以 root 运行的业务进程、缓冲区溢出漏洞、缺少权限收敛的调用链。
- 运维层:日志缺失导致事后无法追溯、固件无签名校验、调试接口未关闭。
这四层对应的加固动作,恰好就是标题里的四个关键词:最小化裁剪减少系统层和网络层的暴露面,权限硬化让应用层的漏洞利用变得困难,日志审计补上运维层的盲区,轻量防火墙在网络层做最后的兜底。整个体系的思路很简单:纵深防御,每一层都让攻击者多花一点成本,最终让他觉得打你这个设备不划算。
2. 最小化裁剪:从根源上减少攻击面
2.1 内核裁剪的几个核心决策点
系统最小化的第一刀,剪在内核上。很多人对内核裁剪的理解是“把不要的驱动去掉”,这个方向没错,但不完整。内核裁剪其实要同时考虑“编译进去什么”和“允许加载什么”。
先说编译层面。我通常用make menuconfig逐项过一遍,遵循一个原则:凡是业务用不到的协议栈、文件系统、驱动、内核特性,一律不选或者编译为模块但不在启动时自动加载。举几个实际场景:
- 纯内网设备,如果不需要跨网段访问,IPv6 可以先关掉,省内存又少一半网络攻击面。
- 没用到蓝牙、Wi-Fi、USB 网卡的设备,对应的协议栈和驱动全部关闭。
- 文件系统这块,如果存储介质是 eMMC,那 ext4 保留就够了,什么 XFS、Btrfs、F2FS 都不需要。但如果用了部分只读文件系统,squashfs 就得留着。
- 内核自带的网络过滤框架,如果后面要上 nftables,那
CONFIG_NF_TABLES、CONFIG_NF_TABLES_IPV4这些选项必须选上,否则防火墙规则根本跑不起来。这个我在 2.3 节会再提。 - 还有一类容易被忽略的:内核调试接口。
CONFIG_DEBUG_FS、CONFIG_KALLSYMS在开发阶段很有用,但产品交付阶段我建议全部关掉。/proc/kallsyms一旦暴露内核符号地址,对攻击者来说等于送了一份地图。
第二个决策点是“允许加载什么”。CONFIG_MODULES如果不关,哪怕内核镜像里没编译某些功能,攻击者拿到 shell 后也可以自己放一个 .ko 进去加载,直接拿到内核级执行权限。如果设备的功能完全固定,不需要动态加载驱动,我会把模块支持直接关掉;如果确实需要模块,那一定要配合内核模块签名验证(CONFIG_MODULE_SIG),只加载带合法签名的模块。
2.2 根文件系统与用户空间的“减脂”
系统裁剪的另一半在根文件系统。我用 BusyBox 或者 Yocto/Buildroot 做精简系统时,会设定一个硬性指标:最终的 rootfs 里,可执行文件数量不超过 200 个。这不是玄学,而是为了后续权限硬化方便——每个可执行文件都是一个潜在攻击面,尤其是那些带 SUID 位的。
具体“减脂”动作,我的习惯顺序是这样的:
- 删除所有开发工具。编译器、gdb、make、头文件,一个不留。产品设备上留着这些,等于给攻击者递刀。
- 删除不用的网络服务程序。telnetd、ftpd、httpd,只要业务没用上,一律不装。远程管理统一收敛到 SSH(而且最好是密钥登录)。
- 清理 SUID 程序。这是我在裁剪后必做的一步,用
find / -perm -4000 -type f把带 SUID 位的文件全部列出来,逐个确认是不是必要。BusyBox 里如果没配好,su、mount、passwd这些工具都可能有 SUID 需求,但实际业务不一定会用。能去掉就去掉,去不掉的也要限制访问范围。 - 检查监听端口。裁剪完之后我会执行
netstat -tlnp或ss -tlnp,看系统起来后到底有哪些端口在听。正常情况应该只剩业务端口和 SSH 端口,其他全是多余的。 - 根文件系统尽量做成只读。这是嵌入式安全里性价比最高的一招。把
/挂载为只读,或者用 squashfs 做只读根文件系统,攻击者即使拿下了 shell,也没法往系统目录写文件,改不了启动脚本,放不了后门。业务需要写的目录,单独挂 tmpfs 或者一个带noexec选项的独立分区,彻底隔离。
2.3 裁剪后最容易踩的几个坑
裁剪不是剪得越狠越好,剪过头了会给自己挖坑。我整理几个实际项目中反复踩过的:
- 内核模块忘选导致外设驱动失效。比如某个传感器驱动编译成了模块,但你的 initramfs 里没有对应模块文件,系统起来设备就是不通。我现在的做法是:外设驱动尽量直接编进内核,而不是做成模块,省得在用户态加一层加载逻辑。
- 裁剪后 netlink 相关功能被误删,导致网络管理工具异常。
ip命令、DHCP 客户端、nftables 都依赖 netlink 和相关的 socket 支持,裁剪时要看清楚CONFIG_NETLINK以及CONFIG_INET下的子选项,别一删全删了。我的经验是:先按默认配置把系统跑起来,再一层一层剪,每剪一次重新验证功能。 - 二进制工具链依赖的库缺失。BusyBox 是静态编译的还好说,但是如果你的应用是动态链接的,rootfs 里必须把依赖的
.so文件带全。用arm-linux-gnueabihf-readelf -d 你的程序查一下依赖,再对照 rootfs 检查,能省很多启动阶段报No such file or directory的折腾时间。 - 还有一个很隐蔽的坑:
/tmp目录没有独立挂载,rootfs 又是只读的,结果某些程序运行时写不了临时文件直接崩溃。解决方案很简单,把/tmp挂成 tmpfs,权限设1777,重启自动清空,也不影响系统只读。
3. 权限硬化:让拿到 shell 的对手也难受
3.1 文件权限与挂载选项:先把低垂的果实摘完
系统裁剪完,下一步是权限收缩。权限硬化的核心思路是:即使攻击者突破了网络层和应用层,拿到了一个 shell,也不能顺手就变成 root,也不能随手读写关键文件。
文件权限这块,我每次都会做一次全盘“体检”,重点看几个目录:
/etc/passwd、/etc/shadow:密码文件权限,/etc/shadow必须只有 root 可读,我见过不少开发板镜像默认权限是 644,谁都能读 hash 自己破解去。- 各类配置文件中如果有明文密钥、Token,权限要收敛到对应服务用户才可读。
- 可执行文件去写权限:关键二进制和脚本属主 root,并且不要给 group/other 写权限,防止被篡改。
除了文件权限,挂载选项也是权限硬化的重要手段。/etc/fstab里我给所有不需要执行功能的分区都加上noexec,数据分区加nodev和nosuid。举个例子,如果一个攻击者在/data目录放了提权工具,但/data是noexec挂载的,他连编译好的二进制都执行不了,提权路径直接砍断。
3.2 用 Capabilities 代替滥用 root
嵌入式产品里最常见的权限问题是:业务进程直接以 root 身份跑。原因很好理解,调试方便,访问硬件也方便。但这也带来一个严重问题:一旦这个进程被远程利用(比如解析了一个恶意数据包),攻击者直接就获得了整个系统的控制权。
正确的做法是给业务进程一个最小权限,而不是让它带着全部 root 权限裸奔。Linux capabilities 机制可以做到这一点,把 root 的权限拆分成几十个小权限项,按需授予。我举两个实际场景:
- 你的业务进程需要绑定低端口(比如 80 或 443),但不需要改系统配置。那就不让它跑 root,而是给它
cap_net_bind_service权限,这样它只能绑定特权端口,干不了别的危险操作。 - 进程需要打开原始套接字做网络监控,但不需要读写文件系统。这时候给它
cap_net_raw就够了。
用 systemd 管理进程的话,在 service 文件里加一行AmbientCapabilities=CAP_NET_BIND_SERVICE就行。用 SysV init 脚本的话,可以在脚本里用setcap给二进制文件设置文件能力:setcap cap_net_bind_service=ep /usr/bin/你的程序。注意ep表示 effective + permitted,别多给。
3.3 内核安全特性:KASLR、栈保护、NX
权限硬化除了用户态的文件和进程权限,内核自身的加固特性也要打开。嵌入式平台条件允许的话,建议检查这几个开关:
CONFIG_RANDOMIZE_BASE:KASLR,内核地址空间随机化。没有这个,攻击者可以利用固定的内核地址布局直接做 ROP 攻击。CONFIG_STACKPROTECTOR:内核栈保护,检测栈溢出后直接 panic,防止被利用做权限提升。CONFIG_FORTIFY_SOURCE:内核里的一些内存拷贝函数会做边界检查,能在源头拦截不少溢出。CONFIG_STRICT_KERNEL_RWX:内核内存段只读不可写,配合 NX 防止注入代码执行。
还有一点容易被忽略:地址空间随机化对应用也有用。BusyBox 默认的编译不一定开启了 PIE(位置无关可执行文件),导致 ASLR 对应用层程序不生效。如果条件允许,我建议交叉编译工具链默认加上-fPIE -pie,让每个应用都有自己的随机地址布局,攻击堆栈利用的难度会高一个量级。
4. 日志审计:看不见的攻击也要留痕
4.1 轻量日志方案:资源受限也能记好账
很多嵌入式设备出了安全问题,第一反应是“查日志”,结果发现设备上日志干干净净,什么也没记。原因很简单:开发阶段没人关注日志,系统默认的 syslog 配置又是写到内存里,重启就没了。没有日志,连攻击路径都还原不出来。
嵌入式设备做日志审计,不能照搬服务器那一套完整 ELK,得讲究轻量。我的方案一般分三层:
- 本地日志采用 BusyBox syslogd 或者轻量 syslog-ng,配置两个关键点:一是日志级别按需设置,业务日志和内核日志都打开;二是日志文件要落到持久化存储,不要放 tmpfs。
- 日志轮转用 logrotate,按大小或者天数切割,防止日志文件无限增长把小容量 flash 撑爆。嵌入式设备存储有限,建议配置
rotate 4、size 512K这种级别的参数,够追溯就行,别默认复制服务器上的按天轮转配置,一不留神日志分区就满了。 - 关键设备建议做远程日志。syslog 协议本身就能把日志发到远程日志服务器,比如通过 UDP 514 端口发出去,或者用 syslog-ng 的 TCP 转发。这样即使设备被清空日志,运维端也还有一份副本。
4.2 日志审计到底要看什么
系统日志记录优化之后,还有一个问题:日志里该有什么?我认为嵌入式设备至少要覆盖这几类事件:
- 登录事件:SSH 登录成功/失败,包括来源 IP、用户名、时间。
- 权限变化:SUID 文件的新增、
sudo使用记录、用户新增/删除。 - 网络变化:网口 up/down、防火墙规则变更、端口监听变化。
- 业务关键操作:配置修改、固件升级、数据导出等。
如果你用 auditd 做全方位审计,功能很强但资源开销也大,嵌入式设备要谨慎。我一般只在特定目录和关键文件上启用 audit,比如/etc目录、/usr/bin下的核心二进制,监控谁动了这些地方。auditd 在资源受限设备上表现并不算好,所以更常见的做法是:用 shell 脚本 + inotify 监控关键目录,变更就记日志,开销小得多。
5. 轻量防火墙:不装重量级安全软件也能挡一波
5.1 为什么嵌入式设备也值得上 nftables
嵌入式设备经常被忽略的一件事,就是防火墙。理由无非是“设备上没几个服务”“内网环境安全”“加防火墙影响性能”,但实际出事了,第一个背锅的就是没防火墙。我现在的态度是:哪怕设备只有一个业务端口,防火墙也必须上,至少要做到默认拒绝、显式放行。
嵌入式平台上现在的主流选择是 nftables,它取代了老的 iptables 框架,规则集更加紧凑,内核支持也更完整。如果你用的是老内核或者已经有成熟的 iptables 配置,那也不必强迁,规则本身是通用的,关键是把住“默认拒绝”这个原则。
我在嵌入式板子上的 nftables 规则集通常长这样,供参考:
#!/usr/sbin/nft -f flush ruleset table inet filter { chain input { type filter hook input priority filter; policy drop; ct state established,related accept iif "lo" accept ip protocol icmp icmp type echo-request limit rate 5/second accept tcp dport 22 ip saddr { 192.168.1.0/24, 10.0.0.0/8 } accept tcp dport 8080 accept } chain forward { type filter hook forward priority filter; policy drop; } chain output { type filter hook output priority filter; policy accept; } }这里有几个关键设计点:
policy drop是默认拒绝,所有没显式放行的入站包一律丢弃,不需要认识所有攻击方式,不认识的直接不要。ct state established,related accept放行已经建立的连接和关联连接,保证正常的 TCP 回包不被误杀。- SSH 限制来源网段,业务端口(示例里的 8080)按需放行,减少暴露面。
- ICMP 做了限速,防止 ICMP 泛洪,同时保留 ping 这个最常用的连通性探测手段。
5.2 防扫描、限流与状态限制的实际配置
防火墙除了“放行什么、拒绝什么”,还有一个作用是压制攻击行为。开放的业务端口没法关,但是可以加限流,让暴力破解和扫描变得效率极低。
拿业务端口被扫描来说,我通常加一条限制每秒钟新建连接数目的规则。nftables 里的limit rate关键字可以做包速率限制,但如果想限制并发连接数,更常用的是ct count配合规则判断。例如只允许同一 IP 保持 10 个连接:
tcp dport 8080 ct count 10 accept超过 10 个连接的新建请求会被直接丢弃,一定程度上能挡住扫描器的批量连接。
另外,日志也是防火墙的重要输出。我在每条 drop 规则后面可以加log prefix "DROP-IN: ",这样被丢弃的包会进系统日志,配合第 4 节的日志审计,攻击者扫描你的过程就会被完整记录。注意生产环境日志量可能很大,所以我在限速的规则里只对特定的事件记日志,而不是所有 drop 都记,防止日志分区被刷爆。
SSH 防护这里多说一句。嵌入式设备上 SSH 是最常见的入口,我的建议是三条:一是只用密钥登录,禁用密码登录;二是端口如果不影响使用可以改一个不常用的高位端口,减少扫描器命中概率;三是在防火墙里限制 SSH 来源 IP。如果必须开放密码登录,那一定要加 fail2ban 或者等价的限速规则,连续认证失败就封 IP。
6. 第 16 篇课后思考题完整解析
6.1 先说清楚这篇解析覆盖的范围
按我订阅专栏的整体节奏,第 16 讲的主题是嵌入式 Linux 文件系统构建与启动流程分析。这也是整个嵌入式 Linux 学习路线里最容易被卡住的关卡,不只是做安全加固需要懂,做任何基于嵌入式 Linux 的产品都绕不开。我按这个范围来解析课后思考题,如果你手里的专栏顺序略有差异,核心思路也能复用,因为文件系统与启动本来就是系统级话题的地基。
6.2 逐题解析:从现象到原理
思考题 1:initramfs 和 initrd 有什么区别?为什么现代内核推荐使用 initramfs?
这道题的迷惑性很强,很多人的第一反应是“差不多”。实际上 initrd(initial RAM disk)是一个块设备镜像,内核需要把它当作一个块设备设备驱动来访问,所以镜像文件必须包含它自身要用到的设备驱动,存在“鸡生蛋”的死锁问题。initramfs 本质是一个 cpio 归档,内核把它解压到一个基于 ramfs 的临时根文件系统里,没有块设备访问这层间接,天然绕开了驱动加载顺序的问题。
所以推荐用 initramfs 的原因主要集中在三点:一是制作简单,一个 cpio 归档加压缩就能搞定;二是启动时可以按需动态加载块设备驱动,不用把一堆驱动硬塞进内核;三是内存占用更灵活,ramfs 不会像块设备那样固定分配一块内存,用完可以回收。我在实际项目里用 initramfs 做启动根文件系统,最直观的感受就是裁剪内核和文件系统时心态稳得多,不用每次加一块硬盘都要重编内核。
思考题 2:构建最小根文件系统时,/dev 目录到底怎么管理?
这题在开发板上频繁出现,典型症状是“系统起来之后/dev/null不存在,程序全部报错”。现代内核的推荐方案是 devtmpfs:内核启动时自动创建设备节点,挂载到/dev。你只需要内核配置打开CONFIG_DEVTMPFS和CONFIG_DEVTMPFS_MOUNT,启动脚本里确保/dev被挂载成 devtmpfs,绝大多数设备节点会自动出现。
如果你用的是比较老的资源受限方案,BusyBox 的 mdev 也可以做类似的事情。mdev 依赖内核的 uevent 机制,配置一个/etc/mdev.conf,在热插拔事件触发时动态创建设备节点。devtmpfs 优在简单、内核直接支持,mdev 优在可以附加自定义动作(比如新建节点后自动设置权限)。我的做法是:能用 devtmpfs 就优先 devtmpfs,只有需要特殊权限设置时才叠加 mdev 工具做辅助。
思考题 3:BusyBox 环境下mount -a失败,常见原因有哪些?
mount -a会把/etc/fstab里所有没有标noauto的项全部挂载一遍,失败原因我归纳成四类,排查顺序也按这个来:
- fstab 格式写错。BusyBox 的 mount 解析相对严格,漏了字段或者多了空格都会直接报错。检查每一行是否有“设备名 挂载点 类型 选项 dump fsck”六列。
- 文件系统类型不匹配。比如 fstab 里写了 ext4,但内核裁剪时只保留了 ext2,挂载自然失败。这也是“裁剪后系统起不来”的高频原因之一,排查时优先用
mount -t ext4 /dev/mmcblk0p2 /mnt手动挂载一次,能看到更明确的报错信息。 - 设备节点没有生成。devtmpfs 没挂好时,
/dev/mmcblk0p2根本不存在,mount 会报special device does not exist,这个排查顺序要放在“文件系统类型”之前。 - 挂载选项里写了不支持的参数,比如
discard对应的内核配置没打开,mount 会因为无法处理参数而失败。
思考题 4:嵌入式设备上怎么实现开机自动运行一个脚本?
这道题考的是 SysV init 和 systemd 的基本功,嵌入式场景里两种都可能遇到。BusyBox 系统走 SysV 路线,自动运行的入口在/etc/inittab:init 进程会根据 inittab 配置,在sysinit阶段执行/etc/init.d/rcS(不同发行版路径略有差异),所以你的脚本要么直接写在 rcS 里,要么在 rcS 里调用/etc/init.d/下的独立脚本。
如果用 systemd 做服务管理,则可以编写一个 unit 文件,放在/etc/systemd/system/下,配置WantedBy=multi-user.target,再执行systemctl enable创建符号链接实现开机自启。我的经验是:嵌入式设备如果业务简单,SysV + rcS 完全够用,逻辑直观,调试容易;只有服务依赖复杂、需要按依赖顺序启动时才引入 systemd,否则得不偿失。
思考题 5:内核启动卡在 “Starting kernel ...”,排查看哪里?
这个卡点位于 bootloader(U-Boot)把内核镜像和控制参数传给内核之后、内核还没开始正常输出信息之前的阶段。它最烦人的地方在于——几乎没有任何输出,只能靠猜。我的排查路径分三步:
- 第一步检查内核镜像是否被加载到正确的内存地址。U-Boot 的
bootm命令要求内核镜像、initramfs、fdt 设备树分别加载到规定地址,地址搞错经常表现为“系统完全无输出”。 - 第二步确认设备树(dtb)是否与硬件匹配。检查
CONFIG_DEBUG_LL和earlycon参数是否配置。我给自己的板子调试时都会先打开 earlycon,让内核在早期初始化阶段就能输出调试信息,这样卡住的位置才能暴露出来。 - 第三步检查串口终端参数。波特率、流控不匹配会让内核输出看不见,但系统其实已经启动了。这是我见过最多“假卡死”的原因,别一上来就怀疑内核挂了。
思考题 6:为什么生产环境的嵌入式文件系统建议做成只读或 squashfs?
这题跟安全直接挂钩,也是本讲前面内容的延伸。只读文件系统的价值有三层:
- 防篡改。攻击者改不了
/etc/passwd、放不了自启动脚本,很多后门手法直接失效。 - 抗掉电损坏。嵌入式设备经常被直接断电,可写文件系统长时间非正常断电容易出坏块、目录损坏,只读系统天然免疫这类问题。
- 可验证性。只读系统的内容固定,可以通过校验和比对确认设备是否被篡改,配合远程管理能快速发现异常设备。
预算足够的设备,我会设计成“只读根文件系统 + 独立可写数据分区”的方案。根文件系统用 squashfs,数据分区用 ext4 并挂载noexec,业务产生的数据都走数据分区,系统本身不可变。这套方案兼顾安全与可用性,部署成本也不高,算是我比较推荐的嵌入式生产配置。
7. 加固之后:把检查变成习惯
整套加固流程做完,我还想分享一个习惯:每次系统构建完成后做一次快速安全自查。我自己常跑的几条命令分享出来,你可以直接存下来用。
# 1. 查看所有监听的端口,确认没有多余服务 ss -tlnp # 2. 查找所有 SUID 程序,逐个人工确认 find / -type f -perm -4000 2>/dev/null # 3. 检查 root 是否允许 SSH 密码登录 grep -E "^PermitRootLogin|^PasswordAuthentication" /etc/ssh/sshd_config # 4. 查看可写目录里有没有可疑脚本 find /tmp /var/tmp /dev/shm -type f -executable 2>/dev/null # 5. 确认关键文件系统的挂载选项是否到位 mount | grep -E "noexec|nosuid|nodev"这些命令不是跑一次就完事,我强烈建议把它们固化进持续集成流程,每次出固件镜像跑一遍,异常就报错。安全不是一次性的改造,而是反复加固、验证、再加固的过程。设备基数越大,攻击者盯上的概率就越高,多花一小时做检查,可能就少熬几个通宵救火。
最后说一点个人体会:嵌入式 Linux 安全加固,真正难的不是某个技术点——裁剪有文档、防火墙有教程、权限配置有参考,难的是你愿不愿意在设计阶段就多花时间做“减法”,以及能不能在产品线上把安全当作一个持续跟踪的指标。我回看自己的项目,凡是上线前认真做过这套加固流程的,运行大半年都没出过安全问题;凡是偷懒跳过这一步的,基本都在最忙的时候给你爆一次雷。所以,别再想着“设备不出问题就安全”,把安全做进系统里,才是我们这行该有的自觉。