九个Solidity Gas优化技巧:省Gas却埋下安全漏洞

ancilartech 发布于 2026-06-23 阅读 70

本文揭示了九种常见的Solidity gas优化技巧如何在节省Gas的时,却引入了严重的安全漏洞。

便宜的 Gas,昂贵的教训

仅 2026 年第一季度,智能合约漏洞就在 44 起事件中盗走了大约 4.82 亿美元。其中相当一部分并非来自复杂攻击。它们来自优化:开发者为了节省 Gas 而添加的一行巧妙代码,却悄悄移除了一个没人注意到它是承重结构的安全保障。

Gas 优化是良好的工程实践。问题在于,许多流行的“技巧”并非免费。它们用几百 Gas 换来一个漏洞,这个漏洞潜伏着,直到某个带着计算器和恶意企图的人发现它。以下是九个最常见的技巧、它们实际上破坏了什么,以及如何在避免风险的同时保持节省。

1. 将数学运算包裹在 unchecked 中以跳过溢出检查

Solidity 0.8 增加了自动溢出和下溢回退。unchecked 块将它们重新关闭以节省 Gas,这恰恰重新引入了在 0.8 之前漏洞时代典型的静默环绕。

function withdraw(uint256 amount) external {
    unchecked { balances[msg.sender] -= amount; }  // 如果 amount > balance,这将环绕成一个巨大数字
}

仅在边界条件被证明不可能被突破时使用 unchecked,例如完全由你控制的循环计数器。绝不要用于任何涉及用户输入值或余额的情况。

2. 将 transfer 替换为底层 .call 但未修复执行顺序

伊斯坦布尔升级后,.transfer 可能失败,因此团队转向 .call{value:}。但 .call 会转发所有 Gas,并丢弃了原本 2300 Gas 的限额——这个限额曾意外地使重入变得困难。如果你没有同时执行检查-效果-交互模式,你就打开了大门。

(bool ok, ) = msg.sender.call{value: amount}("");  // 攻击者在此处重入
require(ok);
balances[msg.sender] = 0;                           // 状态清除得太晚

在外部调用之前更新状态,每次都这样做,并在涉及资金转移的函数上添加重入保护。

3. 放弃 SafeERC20 以节省几百 Gas

直接调用 token.transfer() 比使用 SafeERC20 包装器更便宜。但这假设每个代币都返回一个布尔值并在失败时回退。许多代币并非如此。像 USDT 这样的代币不返回任何值,因此一个天真的调用可能静默“成功”而实际转移了零价值。

token.transfer(to, amount);  // 返回值被忽略;非标准代币静默失败

使用 safeTransfersafeTransferFrom。与一个被卡住或被盗的余额相比,跳过它们节省的 Gas 只是四舍五入的误差。

4. 使用 address(this).balance 进行记账而非计数器

读取合约的实时余额比维护一个内部存储计数器更便宜。不幸的是,任何人都可以通过 selfdestruct 或作为区块奖励将 ETH 强制发送到你的合约,完全绕过你的 receive 函数。

require(address(this).balance >= target, "not funded");  // 攻击者可以翻转这个不变量

任何基于 address(this).balance 的不变量都可以从外部被操纵。在你自己的存储变量中跟踪存款,并基于此进行推理。

5. 将值打包成更小的整数类型

使用 uint96uint8 将结构体打包到更少的存储槽中是一种合法且真实的节省。危险在于类型转换。将 uint256 向下转换为更小的类型会静默截断任何不适合的部分,且不会回退。

uint96 reward = uint96(amount);  // 超过 2^96 - 1 的 amount 会静默环绕成一个小数字

当你打包时,在转换之前验证该值是否适合,或者使用带检查的转换库。一个被截断的奖励或供应量数据会直接导致账目混乱。

6. 删除“冗余”的输入验证

零地址检查、最小和最大边界、以及数组长度检查都会消耗 Gas,因此人们很容易删除它们。未经验证的输入在 2025 年被提升为行业顶级漏洞列表,正是因为它会产生级联效应。缺少零地址检查会将代币烧到无处可去;缺少边界会在三个函数之外触发溢出或除以零。

在每个函数的边界验证所有外部输入。这些检查不是冗余的。它们就是边界。

7. 在热点路径中使用内联汇编

Yul 汇编跳过了 Solidity 的边界检查、类型安全和内存管理,这正是它快速的原因,也正是它危险的原因。一个错误的内存偏移量可能破坏不相关的状态,或者静默跳过编译器本会为你强制执行的检查。

将汇编保留用于狭窄、经过大量审查和测试的辅助函数。大多数合约没有必要手写汇编,大多数审计师看到就会标记。

8. 在外部调用之间缓存价格

读取一次价格并重复使用可以节省重复查询。但如果一个外部调用位于读取和使用之间,那么当你基于该值采取行动时,缓存的值可能已经过时。这就是只读重入:攻击者在交易中途重入一个 view 函数,观察或利用不一致的状态——这种模式已经耗尽了依赖池现货价格的借贷市场。

将任何在外部调用之前读取的值视为之后可能过时。在交互之后重新读取,或者像保护写入路径一样保护读取路径。

9. 将布尔值位打包成单个位图

将几十个标志作为位存储在一个 uint256 中,比使用单独的存储槽便宜得多。但它也是一个掩码谜题,一个错误的掩码会翻转错误的位。当这些位控制访问权限时——一个 paused 标志、一个 isAdmin 标志、一个 initialized 标志——位掩码中一个单一的差一错误就会成为静默的权限提升。

如果你打包标志,将每个获取和设置包裹在命名且经过单元测试的辅助函数中,这样就没有人会在调用点手写掩码。

如何在避免风险的同时节省 Gas

所有九个模式都是一样的:每个技巧都移除了语言或 EVM 为你做的一项检查,并假设了一个只在攻击者决定不破坏之前才成立的条件。三个习惯可以消除大部分风险:

  • 最后再进行优化,且只优化你测量过的内容。 先编写安全版本,分析性能,然后优化真正重要的热点。大多数 Gas 技巧被应用在从未成为瓶颈的代码上。
  • 记录每一个被移除的保障。 当你编写 unchecked 或手写汇编时,留下注释说明使其安全的不变量。如果你无法说明它,那么这个优化就不安全。
  • 为安全而做差异对比,而不仅仅是 Gas。 在审查中,将“这节省 Gas”视为一个需要安全答案的主张,而不是自动的胜利。

结论

Gas 很重要,良好的优化是发布严肃协议的一部分。但每一个这些技巧都是一种交易,成本会在以后显现,以别人被盗的资金而非 Gas 单位来计价。2026 年被利用的合约往往会是那些看起来最巧妙优化的合约。

节省 Gas。只是要确切知道你是用哪个保障来换取它的。

这正是 Ancilar 智能合约审计和预审计威胁建模旨在发现的问题。我们会审查优化方案中它们悄悄移除的不变量,而不仅仅是它们是否能编译。我们会对那些为节省几百 Gas 而放弃安全检查时所打开的攻击路径进行建模。如果你正准备发布、升级或融资,这是在主网之前而非之后值得进行的审查。

与 Ancilar 讨论智能合约审计: www.ancilar.com/services/smart-contract-audit

  • 原文链接: medium.com/@ancilartech/...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~

相关文章

0 条评论