尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

内存安全是无用的概念?拆解三层含义与工程真相

内存安全是无用的概念?拆解三层含义与工程真相 内存安全是“无用的概念”别急着反驳先想清楚这三层意思如果你关注过近几年编程语言圈的讨论大概率见过这样的句式某个语言“内存安全”所以它比 C/C 更适合做系统编程又或者某个语言因为“没有内存安全问题”所以值得从旧项目迁移过去。但最近有一种声音越来越值得认真对待“Memory Safety Is a Useless Concept”内存安全是一个无用的概念。先别急着关页面——这当然不是说“内存安全问题不重要”也不是说 Buffer Overflow、Use-After-Free 这类漏洞无害。恰恰相反正因为内存安全问题太重要我们才更应该警惕“内存安全”这个概念被过度简化、被标签化、被当成银弹来使用的现状。这就像我们不能因为“卫生很重要”就认为在饭前说一句“我卫生了”就万事大吉。读完这篇文章你会理解三件事“内存安全”在技术层面到底指什么它为什么不能当做一个非黑即白的标签。为什么一个语言号称内存安全不等于你的程序安全更不等于你的系统安全。在实际工程里应该怎么看待内存安全以及除了“换语言”之外你还能做哪些更务实的事。这篇文章不试图劝你弃用 Rust也不打算为 C/C 辩护。它想做的是把“内存安全”这个被用滥了的概念拆开看清楚它的能力边界和真实成本。1. 这篇文章真正要解决的问题这两年我观察到团队在技术选型时经常出现一种“安全焦虑转移”的现象项目出了漏洞领导说“我们换 Rust 重写吧”性能达不到要求有人反驳“Rust 安全检查太严格了”安全合规检查开发者说“我们用的是内存安全语言”。这些讨论共同的问题在于——把内存安全当成一个可以由语言自动赋予的属性当成一个工程决策的终结点而不是起点。举个例子。A 团队用 C 语言写了一个网络代理最近被曝出某个输入校验逻辑绕过导致任意读。复盘时多数人的第一反应是“用了 C 的锅应该上 Rust”。但如果你去看实际漏洞会发现它根本不是内存破坏——而是业务逻辑层没有校验数据包长度和索引关系。换成 Rust 重写这段逻辑如果不改漏洞照样存在只不过崩溃方式从“可能被利用”变成了“直接 panic”。再举个例子。B 团队用 Rust 写服务为了性能在一处高频热路径上用了unsafe块做 slice 索引优化。代码评审时大家默认“Rust 是安全的”unsafe块草草看过结果一处索引计算错误导致内存越界写。请问这个系统算“内存安全”吗这两个例子说明“内存安全”作为语言特性是有用的但“内存安全”作为工程概念来使用几乎无助于降低真实风险。这个标题的真正含义是作为标签它制造了虚假的安全感作为银弹它遮蔽了更广泛的安全问题作为边界它被大多数开发者错误地理解为“自动生效”而忽略了真正的维护责任。这篇文章的读者应该是正在做系统编程、运行时、中间件、安全基础设施以及在考虑用 Rust 重写旧系统的开发者。如果你只是写 CRUD 应用内存安全对你的影响远没有你想的那么大但文中关于边界和误区的讨论同样值得了解。2. 内存安全到底是什么概念边界与三种理解方式要讨论“内存安全是不是无用的概念”得先定义清楚——当大家说“XX 语言内存安全”时到底指什么。2.1 技术层面的定义在技术层面一个内存安全的程序意味着程序对内存的访问不会产生未定义行为。具体来说至少满足以下性质性质含义无越界访问不会访问数组或缓冲区边界之外的内存无空悬指针悬垂指针不会访问已经被释放或已失效的内存对象无空指针解引用不会对空指针执行读写操作无非对齐访问不会以不合法的方式访问内存位置无释放后使用UAF不会在对象生命周期结束后操作该对象无双重释放不会对同一块内存重复释放无数据竞争多个线程不会同时读写同一内存位置且至少一个是写操作一种语言可以被称作“内存安全”通常意味着只要代码本身不显式跳转到某种“不安全模式”编译器或运行时就会阻止上述情况发生。比如 Rust 的借用检查器、Java 的垃圾回收与边界检查、Go 的运行时切片边界检查。注意一个关键措辞“只要代码本身不显式跳转到某种不安全模式”。每种自诩内存安全的语言都需要在某处打开一个口子要么是 Rust 的unsafe要么是 JNI 的extern C调用要么是unsafe的反射或 native 接口。这个口子的存在意味着“内存安全”是一种局部的、需要边界维护的性质而不是全局的、自动生效的性质。2.2 三种理解方式我建议分三个层次理解内存安全第一种作为语言类型系统层面的性质。这是最狭义、也最严格的理解。它描述的是在语言规范定义的可执行程序集合里不存在能触发内存未定义行为的程序。Rust 的 Safe Rust 是接近这个标准的——前提是 Safe Rust 程序不包含会导致逻辑错误的无限循环或栈溢出这些通常不归入“内存安全”的讨论。第二种作为工程实践层面的目标。一个系统比如某个大型 C 代码库通过代码规范、静态检查、动态分析、代码评审等手段在实际运行中“几乎没有”发生内存安全漏洞。Chrome 团队维护的 C 代码库内存漏洞率远低于平均水平靠的不是语言而是工程纪律。这种“实践层面”的内存安全才是真实世界系统里最常见的状态。第三种作为合规与营销层面的标签。这是最危险的理解方式因为某个语言“是内存安全的”所以我的系统“是安全的”所以我可以不用再做边界检查和安全测试。这种理解就是标题里“无用的概念”所批评的对象。2.3 为什么 C/C 的内存问题如此顽固要理解内存安全问题为何长期存在需要知道一个底层机制未定义行为。C 和 C 标准以“未定义行为Undefined BehaviorUB”为核心来换取性能。很多操作——有符号数溢出、解引用空指针、数组越界——在标准里被定义为“行为未定义”。这意味着编译器可以假设这些情况永远不会发生并基于这个假设做激进的优化。但一旦程序里真的触发了 UB程序什么表现都可能看似正常、崩溃、数据损坏或者被攻击者利用。C 语言的未定义行为是内存破坏类漏洞的温床而这类漏洞在 C/C 系统里几十年难以绝迹根本原因就在这里。#include stdio.h #include stdlib.h int main(void) { // 经典的 buffer overflow写入越界 char buf[4]; for (int i 0; i 8; i) { buf[i] A; // 越界写未定义行为 } return 0; }这段代码在 C 语言里“合法”能编译通过但行为完全未定义。它可能正常运行可能崩溃也可能恰好没有覆盖到关键数据于是这个 bug 就安静地潜伏在生产环境里直到某天一个恶意输入把它引爆。引入一种内存安全语言本质上就是引入一套制止这类 UB 的规则系统。Rust 选择在编译期通过所有权和借用检查解决大部分问题Go 和 Java 选择在运行期通过 GC 和边界检查兜底。这个层面的改变是真实且有价值的所以我才说“内存安全的概念并非天然无用”。问题在于当这个有用的语言特性被包装成一劳永逸的银弹时它就“无用”了——因为真实世界的漏洞远不只有内存破坏这一种。3. 为什么把“内存安全”当成二元标签会误导工程决策我们在日常对话里常把一个语言分成“内存安全”和“内存不安全”两类。这种二分法对于科普是有效的但在工程决策中它至少带来三个层面的误导。3.1 误导一安全属性被简化为静态标签语言的安全属性不是静态的。一个语言今天的标准库实现是安全的不代表它的第三方库生态是安全的一个编译器今天能拦截的检查不代表未来每个版本都能拦截相同范围的错误。真实案例某个“内存安全”语言的运行时出现过由编译器 bug 引发的内存非法访问某个标榜安全的语言因为其unsafe代码被大量使用实际安全水平并不比 C 好多少。这意味着“内存安全”更像一个动态指标而不是一个静态称号。3.2 误导二把“语言安全”等同为“程序安全”程序 语言运行时 业务逻辑 第三方依赖 系统调用 配置 数据 部署环境。语言只是其中一个环节。哪怕一个程序完全由“内存安全”的语言编写它仍然可能因为以下原因被攻破业务逻辑漏洞比如权限校验缺失、支付金额可控、越权数据访问。逻辑死循环导致的拒绝服务。第三方依赖引入的恶意代码或依赖本身包含 unsafe 实现。错误处理不当。比如捕获了异常却把异常信息输出给用户泄露内部路径。系统调用层面的竞态条件TOCTOU。供应链攻击即使依赖用的是安全语言如果构建流程、镜像、包管理被污染程序照样不安全。这些问题没有任何一种“内存安全语言”能替你解决。3.3 误导三把“安全”当成语言的问题而不是系统的问题如果你问一个 C 程序员“你的系统安全吗”他可能会说“不我用 C我需要特别小心”如果你问一个 Rust 程序员同样的问题他可能会说“安全因为 Rust 是内存安全的”。这个对比几乎是灾难性的。语言选择会改变你需要应对的风险清单但不会把风险清单清零。内存安全语言降低的是“某类具体 bug 的发生概率”但如果团队的编码规范、安全测试流程、威胁建模能力没有跟上系统整体的安全水位并不会因为换了语言就自动上升。这里真正容易踩坑的地方是团队把安全责任“外包”给了语言自己反而放弃了安全设计。内存安全语言因此成为某些团队系统性削减安全投入的借口。从这个意义上说这个“概念”确实是有害的。那么内存安全语言到底应该被怎么用答案是把它当作工具箱里的一件工具而不是神殿里的一个护身符。4. 警惕“安全语言消除漏洞”的幻觉内存安全之外的常见高危问题为了让你更具体地感受“语言安全不等于程序安全”我们看几个非内存类的高危问题。这些问题在内存安全语言里同样存在甚至因为开发者放松警惕而更容易出现。4.1 示例一业务逻辑漏洞假设一个 C 实现的 TCP 服务器在处理请求时用了如下代码size_t header_size parse_header_size(data); if (header_size kMaxHeaderSize) { // 拒绝服务返回错误 return -1; }再看 Rust 版本fn handle_request(data: [u8]) - Result(), Error { let header_size parse_header_size(data)?; if header_size MAX_HEADER_SIZE { return Err(Error::TooLarge); } // ... Ok(()) }两个版本的内存安全属性完全不同但漏洞模式一模一样如果parse_header_size解析的是一个不可信字段而调用方没有再次校验实际数据长度与 header_size 的一致性攻击者就能用“声明长度和实际长度不符”的方式绕过后续逻辑。这与内存安全无关却可能导致越界读、信息泄露或更上游的二次破坏。4.2 示例二错误处理被忽略在 C 语言里忽略malloc的返回值、忽略read/write的部分读写结果是常见的内存安全问题来源。在 Python、Go、Java 里开发者却容易走向另一个极端异常要么没被捕获导致崩溃要么被捕获后只打了一行日志就继续执行结果带着一个“处理失败但无人知晓”的状态继续服务。resp, err : http.Get(https://api.example.com/user) if err ! nil { // 问题日志打完就继续了resp 为 nil log.Printf(request failed: %v, err) } defer resp.Body.Close() // 这里会 panic因为 resp 是 nil这行 Go 代码没有内存安全问题但它会 panic会让线上服务出故障。内存安全语言能挡住null pointer dereference挡不住开发者写出defer nil.Body.Close()。4.3 示例三TOCTOU 竞态“检查后使用”竞态Time-of-check to time-of-useTOCTOU在文件系统、权限校验场景非常常见。先检查文件是否存在、是否可写再进行读写——但检查和使用之间存在窗口攻击者可以利用这个窗口替换文件或改变权限。这个模式与内存安全无关在任何语言里都会出现。# Python 示例先检查再使用存在 TOCTOU import os def process_file(path): if not os.path.exists(path): return # 攻击者可能在这一步把 path 替换成符号链接 with open(path, r) as f: data f.read() # 处理 data...4.4 为什么“安全语言”反而会加剧这些问题一个反直觉的事实是当语言帮你消除了内存安全问题你的注意力会被引导到那些“语言管不到”的问题上——前提是你知道自己该关注什么。但多数团队的实际情况是一旦语言层面的隐患被排除大家就默认“安全测试可以少做一点了”“代码评审的重点可以放在功能上了”。内存安全语言里业务逻辑漏洞、配置错误、权限绕过、供应链攻击的比例会相对上升。这不是因为内存安全语言导致这些问题变多而是因为你过去最显眼的问题被解决了剩下的问题变得可见了。如果团队没有建立新的安全视角这种“显眼度的转移”很容易被误读为“语言不安全”。5. “unsafe”才是理解内存安全真正难点的钥匙现在回到标题的核心问题内存安全概念的真正“无用”之处在哪不是因为它错误而是因为它省略了最关键的细节Safe / Unsafe 的边界。以 Rust 为例Rust 把语言分成 Safe Rust 和 Unsafe Rust 两部分。Safe Rust 承诺只要 Safe 代码不通过unsafe块与外部世界交互就不会触发内存未定义行为。但你一旦写了unsafe就必须自己保证unsafe块周围的不变量否则 Safe Rust 的承诺会在unsafe块处失效而且失效之后影响范围是整个程序不只是那个块。5.1 Unsafe Rust 的核心能力Unsafe Rust 允许四种 Safe Rust 不允许的操作解引用裸指针。调用unsafe函数FFI 调用、某些标准库函数。访问或修改可变静态变量。实现unsafetrait。// 文件路径src/main.rs fn main() { let mut data [1, 2, 3, 4, 5]; let x mut data[0]; // 这里的裸指针只能在 unsafe 块里解引用 let ptr x as *mut i32; unsafe { *ptr 100; } println!(data[0] {}, data[0]); // 输出 100 }上面的代码是安全的。问题出在你构造裸指针的场景如果这个指针来自一段不符合 Rust 规则的 C 函数或者你手算的偏移量越界了那整个程序的内存安全就崩塌了。5.2 边界维护的复杂性真正困难的不是写unsafe代码本身而是维护贯穿整个模块的抽象边界。你写了一个unsafe fn它要求调用方必须满足某些前置条件——比如“传入的字节切片必须至少 4 字节且是对齐的”。这个约束写在文档里但编译器无法检查。如果某个普通 Safe 代码因为没读文档而违反了前置条件那么安全漏洞会在调用点出现但根源在 unsafe 函数的实现内部。这种边界是跨模块、跨调用栈、跨线程的。比如你实现了一个线程安全的缓存库内部用了unsafe和裸指针你需要手动保证同一时间只有一个线程可变访问读操作读到的一定是已初始化的数据释放时没有其他线程正在使用。任何一条不成立整个系统就存在未定义行为。5.3 工程上如何约束 unsafe既然 unsafe 无法避免FFI、性能热路径、裸指针操作时几乎必然出现真正的工程问题就变成了如何把 unsafe 控制在一个可审计的小范围内。常见的做法规定 unsafe 代码必须写注释说明为什么这里无法避免 unsafe以及它维护了哪些不变量。规定 unsafe 代码必须经过专门的安全评审至少两人审核。利用 Clippy 或自定义 lint 规则统计每个 crate 的unsafe行数超过阈值时触发人工审查。灰度引入cargo-geiger之类的工具监测依赖树中的 unsafe 使用情况。// 工程约定示例在 unsafe 块旁必须写为什么安全 fn split_at_first_byte(slice: [u8]) - ([u8], [u8]) { if slice.is_empty() { return (slice, slice); } let ptr slice.as_ptr(); let len slice.len(); // SAFETY: len 0因此第一个字节一定存在 // 返回的长度都受原 slice 约束不会越界。 unsafe { (std::slice::from_raw_parts(ptr, 1), std::slice::from_raw_parts(ptr.add(1), len - 1)) } }也就是说一个“内存安全”语言项目的真实安全水平极大程度上取决于团队对 unsafe 边界的维护能力。如果这个边界是混乱的、没有评审的、没有文档的那么这个项目的实际内存安全水平不一定比一个纪律良好的 C 项目高甚至可能更低——因为大家会默认它是安全的。这正是“内存安全”作为概念在工程中被滥用的地方它把一种需要持续维护的复杂状态简化成了一个“有/无”的二元属性。6. 安全与性能不是二选一真正的成本在哪里讨论内存安全的另一个常见误区是为了安全必须牺牲性能。这个说法在宏观层面有一定道理但如果你真的按这个思路做取舍往往会得出错误结论。6.1 线性对比是伪命题“Rust 比 C 安全所以 Rust 比 C 慢”——这是最常见的错误推导。实际上Rust 的许多安全检查发生在编译期运行时的额外开销很小。Go 和 Java 的安全检查发生在运行期代价更高但它们的开发效率优势和部署便利性可能弥补了这个成本。真正准确的对比应该是不同语言把安全成本放在哪个环节。语言安全机制成本发生环节典型代价C / C几乎无运行时安全机制无出了问题是程序员承担开发和调试成本极高Rust编译期所有权 借用检查编译期少量运行时边界检查学习曲线陡峭编译时间长Go运行时切片边界检查 GC运行期少量性能开销GC 停顿JavaJVM 字节码验证 边界检查 GC运行期较高的内存占用GC 停顿Ada/SPARK编译期强类型 运行时检查可选编译期运行期语言生态较小从表格能看出安全不是免费的但它的“费用”不一定是性能。Rust 最大的成本是写代码时的心智负担和更长的编译时间Go 和 Java 的成本是运行时的隐式开销C/C 的成本是安全责任完全压到开发者身上他们必须在每一行代码里保持警觉。6.2 真正的性能问题往往来自程序结构当人们说“Rust 安全检查拖慢了程序”很多时候实际发生的是为了实现内部可变性选择了比预期性能更差的并发原语。为了绕过借用检查器的限制引入了ArcMutexT在热路径上频繁加锁。为了满足所有权模型把数据复制了多份而不是共享引用。这些问题与其说是“安全带来的性能损失”不如说是**“不熟悉所有权模型导致的次优设计”。** 随着经验积累开发者会学会用更适合 Rust 思维的数据结构这些问题会逐步缓解。反过来C 语言程序为了实现同样级别的安全保证往往需要手工加入大量防御性检查每次索引访问前判断边界、每次分配后检查返回值、每个析构路径手动释放资源。这些检查本身也有运行时开销而且它们往往没有被系统性地实现——因为太累了人会偷懒。所以内存安全语言与 C/C 的对比不是“安全 vs 性能”而是“系统性地支付少量成本 vs 零星地支付巨大成本”。6.3 使用内存安全语言时你仍然要思考性能这也是“内存安全是无用概念”的另一层含义它并不能帮你做出性能决策。你仍然需要理解缓存行、内存分配策略、并发模型、零拷贝路径、分支预测。这些系统级常识与语言是否内存安全无关。所以在技术选型时别问“这个语言安不安全”而应该问“这个语言把哪些错误转移到了编译期哪些错误留到了运行期哪些错误它根本不管团队是否准备好承担剩余的错误处理成本”7. 在真实项目中怎么用好“内存安全”这个工具讨论了这么多“无用”的地方接下来给到可落地的建议。内存安全语言作为工具当然有用关键是怎么用。7.1 先做安全设计再谈语言选择语言选择只是安全设计的一个环节。一个系统的安全性首先取决于威胁模型——你的系统要防谁攻击者能接触哪些输入最坏情况是什么这些问题不搞清楚换任何语言都可能选错方向。建议的做法是在技术选型前先做一份简单的威胁模型清单列出系统可能遇到的攻击途径。然后再看语言选型能覆盖其中哪几项不能覆盖的需要补充什么控制措施。7.2 渐进式引入而不是推倒重写如果团队有一个长期维护的 C/C 系统内部有大量内存安全问题最坏的选择是立刻重写。重写会带来新的业务逻辑 bug、回填测试的成本、和旧系统对接的兼容性风险而且在重写期间安全投入反而会从维护旧系统转移到新系统上风险窗口反而变大。更务实的路径是盘点先扫描代码找出内存安全问题最多的模块。隔离把高风险模块与新代码隔离开用进程边界比如独立的 worker 进程控制爆炸半径。替换优先重写最高风险、且边界清晰的模块。演进新模块采用内存安全语言并建立 unsafe 审计规范。对 Rust 项目来说还有一个重要提醒审查依赖树里的 unsafe 使用。一个 Rust 项目的第三方依赖里可能有大量 unsafe 代码这些代码的安全水平参差不齐。把整个依赖树级别的内存安全水平纳入评估才是真正的“工程态度”。7.3 建立适合内存安全语言的工程规范如果是新项目从一开始就定好规则默认使用 Safe Rustunsafe 代码需要书面批准。每个 unsafe 块都必须有 SAFETY 注释解释为什么安全。每个 crate 的 unsafe 行数必须被统计并定期审查。使用cargo audit检查依赖漏洞。使用 sanitizerASan、MSan、TSan在 CI 上跑测试。对涉及网络协议解析、文件格式解析、反序列化的模块额外做 fuzzing。7.4 安全测试不能因为语言安全而减少无论选什么语言以下测试都不能省测试类型目的在内存安全语言里的价值单元测试验证函数级逻辑正确性依然关键集成测试验证模块交互依然关键Fuzzing发现边界条件和意外输入问题极高特别是解析器静态分析检查代码规范与潜在缺陷语言内置检查外仍有增量依赖安全扫描发现第三方库漏洞供应链攻击越来越多红队测试模拟真实攻击检验整体防御关键是不要因为“我们用了内存安全语言”就砍掉这些投入。相反正因为语言替你挡住了一部分问题你应该把省下来的精力投入到其他方面——例如更深入的安全评审、更充分的模糊测试、更高质量的代码审查。8. 总结回到标题内存安全真的是“无用的概念”吗在技术层面它非常有用。它精准地描述了一类语言特性帮助开发者选择更合适的工具。Rust 的所有权系统、Go 的运行时检查、Java 的 GC都极大地改善了大量软件项目的安全基线。但在工程层面这个概念常常被滥用。当它被当成标签、银弹、免责声明而不是一个需要持续维护的边界时它不仅是无用甚至是有害的。它让团队误以为安全是语言自动赋予的忘记了真正的安全永远是工程实践的结果。我给所有正在选型或维护系统的人三个建议第一个建议把“X 语言是内存安全的”这句话从你脑海中的二分法里删掉替换成“X 语言把内存安全问题转移到了编译期/运行期/程序员身上我需要承担剩下那一部分”。第二个建议在技术评审时不要问“语言安不安全”要问“我们的 unsafe 边界在哪里、由谁维护、如何审计”。无论用哪种语言这个问题都必须有人回答。第三个建议不要因为“语言安全”就不再投入安全测试。内存安全语言挡住的只是某一大类漏洞剩下的漏洞不会因为你看不见就不存在。如果你正在做一个与系统编程有关的项目最值得做的一件事是把代码库里的 unsafe/unsafe 类接口整理一遍统计它们出现在哪个模块、由谁维护、有没有 SAFETY 注释。这个过程可能比再读十篇“安全性语言对比”的文章更有价值。内存安全概念本身没有问题问题是我们太喜欢把复杂的世界简化成标签。而真正的系统安全性从来不是靠标签赢得的。
返回列表