资讯动态

WTF-Solidity 安全指南:Consensys 给 Solidity 程序员的 16 条智能合约安全建议(全量翻译与源码级注解)

发布时间:2026/9/16 0:03:13 来源:尧图企业网站定制
WTF-Solidity 安全指南Consensys 给 Solidity 程序员的 16 条智能合约安全建议全量翻译与源码级注解【免费下载链接】WTF-SolidityWTF Solidity 极简入门教程供小白们使用。Now supports English! 官网: https://wtf.academy项目地址: https://gitcode.com/GitHub_Trending/wt/WTF-Solidity导读本文完整收录并详解 MetaMask 项目方 Consensys 于 2020 年发布的《Solidity Best Practices for Smart Contract Security》博客译文由 WTF-Solidity 作者 0xAA 翻译整理系统梳理了面向 Solidity 程序员的 16 条安全开发建议包括assert/require/revert的正确用法、modifier使用禁忌、回退函数陷阱、tx.origin授权风险、时间戳依赖、多重继承线性化、extcodesize绕过等。每一条建议都结合当前 WTF-Solidity 仓库中的源码实例如 15_Errors、19_Fallback、S12_TxOrigin 等进行二次注解并标明 Solidity 0.5 到 0.8 之间的版本差异。读完本文你将掌握一套可直接用于审计与编写生产级合约的 Check-Effects-Interactions 安全范式。原文撰写于 2020 年 8 月Solidity 版本尚停留在 0.5如今 Solidity 已迭代到 0.8.x部分函数写法发生了变化但绝大多数安全理念至今仍然适用。译文已逐条标明版本差异可能导致的坑供中文开发者学习。一、背景为什么需要一份语言级安全清单如果你已经牢记智能合约安全理念如 CEI 模式、最小权限、审计日志并且正在处理 EVM 的特性那么是时候考虑一些特定于 Solidity 编程语言的安全模式了。Consensys 的这份综述聚焦于 Solidity 的安全开发建议这些建议也对用其他语言开发智能合约具有指导意义。WTF-Solidity 仓库正是以代码 极简讲解的方式系统实践这些理念教程主体位于 01_HelloWeb3 至 57_Flashloan安全专题重入、选择器冲突、中心化风险、时间操纵、预言机操纵等位于 S01_ReentrancyAttack 至 S17_CrossReentrancy。本文的每一条建议都能在仓库中找到对应的实战样例。二、第 1 条正确使用assert()、require()、revert()便利函数assert和require可用于检查条件如果条件不满足则抛出异常。二者的使用场景必须严格区分assert只能用于测试内部错误和检查不变量invariant。当assert失败时往往意味着代码中存在真实的 bugSolidity 0.8 之前会消耗全部剩余 gas。require用于确保满足有效条件例如校验输入或合约状态变量或者验证来自外部合约调用的返回值。0xAA 注Solidity 在 0.8.4 版本引入自定义error功能。0.8.4 之前用require检查有效条件之后推荐用revert-error自定义 error来确保满足有效条件以节省 gas。遵循这种范式可以让形式化分析工具验证无效操作码永远不会被执行这意味着代码中没有不变量被违反且可以被形式化验证。pragma solidity ^0.5.0; contract Sharer { function sendHalf(address payable addr) public payable returns (uint balance) { require(msg.value % 2 0, 偶数required.); // Require() 可以加一个自定义消息 uint balanceBeforeTransfer address(this).balance; (bool success, ) addr.call.value(msg.value / 2)(); require(success); // 如果success为false就revert。下面的总是成立。 assert(address(this).balance balanceBeforeTransfer - msg.value / 2); // used for internal error checking return address(this).balance; } }仓库源码佐证三种写法的 gas 成本对比WTF-Solidity 的 15_Errors/Error.sol 用同一业务场景校验 Token 所有者对比了三种写法在 Remix 0.8.17 编译下的 gas 消耗方式代码Gas 成本自定义 errorrevert TransferNotOwner();24457require 字符串require(_owners[tokenId] msg.sender, Transfer Not Owner);24755assertassert(_owners[tokenId] msg.sender);24473可以看出自定义error是最省 gas 的失败处理方式字符串类型的 revert 原因最贵同时它还能携带参数如revert TransferNotOwner(msg.sender)方便链下排查。这也是 0.8.4 之后社区普遍推荐error/revert取代require的原因之一。三、第 2 条modifier仅用于检查修饰符modifier内的代码通常在函数体之前执行因此任何状态更改或外部调用都会违反Checks-Effects-Interactions检查-生效-交互模式。此外开发人员可能不会注意到修饰符中的语句因为修饰符的代码可能远离函数声明。例如修饰符中的外部调用可能导致重入攻击contract Registry { address owner; function isVoter(address _addr) external returns(bool) { // Code } } contract Election { Registry registry; modifier isEligible(address _addr) { require(registry.isVoter(_addr)); _; } function vote() isEligible(msg.sender) public { // Code } }在这种情况下Registry合约可以通过在isVoter()内部调用Election.vote()进行重入攻击。注意使用modifier替换多个函数中的重复条件检查例如isOwner()是合理的否则请在函数内部使用require或revert。这会使智能合约代码更具可读性、更易于审计。仓库源码佐证标准的 onlyOwner 写法WTF-Solidity 的 11_Modifier/Owner.sol 展示了仅做检查的规范写法——修饰符内只有一条require状态检查随后立即用_;占位符进入函数体modifier onlyOwner { require(msg.sender owner); // 检查调用者是否为owner地址 _; // 如果是的话继续运行函数主体否则报错并revert交易 } function changeOwner(address _newOwner) external onlyOwner{ owner _newOwner; }这就是把权限检查这一横切关注点抽离成modifier的最佳实践修饰符内不做任何外部调用与状态修改只做校验。四、第 3 条注意整数除法的舍入所有整数除法都向下舍入到最接近的整数向零取整。如果需要更高的精度请考虑使用乘数multiplier或同时存储分子和分母。将来 Solidity 会有浮点类型这会让处理更简单。// bad uint x 5 / 2; // Result is 2, all integer division rounds DOWN to the nearest integer使用乘数可以防止四舍五入带来的精度损失在将来使用x时需要考虑这个乘数// good uint multiplier 10; uint x (5 * multiplier) / 2;存储分子和分母意味着你可以计算 numerator/denominator 链下的精确结果// good uint numerator 5; uint denominator 2;五、第 4 条注意抽象合约abstract和接口interface之间的权衡接口和抽象合约都为智能合约提供了一种可定制、可重用的设计方式。Solidity 0.4.11 引入的接口类似于抽象合约但不能实现任何功能。接口还有一些限制例如不能访问存储、不能从其他接口继承注现代 Solidity 已允许接口继承接口这通常使抽象合约更实用。尽管如此接口对于在实现之前设计合约契约仍然很有用。此外重要的是要记住如果合约继承自抽象合约它必须通过覆盖override实现所有未实现的功能否则它自身也必须是抽象的。仓库源码佐证接口与抽象合约的实战WTF-Solidity 的 14_Interface/Interface.sol 同时给出了两种形态abstract contract InsertionSort{ function insertionSort(uint[] memory a) public pure virtual returns(uint[] memory); } interface IERC721 is IERC165 { event Transfer(address indexed from, address indexed to, uint256 indexed tokenId); function balanceOf(address owner) external view returns (uint256 balance); function safeTransferFrom(address from, address to, uint256 tokenId) external; // ... }接口IERC721只声明事件与函数签名不含任何实现并通过IERC721(0xBC4C...)这种地址强转方式与链上 BAYC 合约交互见 Interface.sol。抽象合约InsertionSort则只声明函数把排序实现留给子类去完成。二者的选择原则需要约束是什么用接口需要复用怎么做用抽象合约。六、第 5 条Fallback 后备函数0xAA 注Solidity 0.5.0 时还没有receive()函数且fallback当时直接声明为function()。关于最新版本fallback/receive的详细教程请见本仓库 19_Fallback/readme.md。5.1 保持 fallback function 简单当合约被发送一个没有参数的消息或者没有函数匹配调用时fallback function会被调用。当被.send()或.transfer()触发时fallback function只能访问2300 gas。如果你希望能够从send()或transfer()接收 ETH那么在后备函数中最多可以做的就是记录一个事件。如果需要计算更多 gas请使用适当的函数或改用.call// bad function() payable { balances[msg.sender] msg.value; } // good function deposit() payable external { balances[msg.sender] msg.value; } function() payable { require(msg.data.length 0); emit LogDepositReceived(msg.sender); }5.2 检查回退函数中的数据长度由于fallback function不仅在普通以太传输没有msg.data时被调用也会在没有其他函数匹配时被调用。如果后备函数仅用于记录接收到的 ETH则应检查数据是否为空。否则如果你的合约被错误调用调用了不存在的函数调用者将不会注意到任何异常// bad function() payable { emit LogDepositReceived(msg.sender); } // good function() payable { require(msg.data.length 0); emit LogDepositReceived(msg.sender); }仓库源码佐证0.8 时代的 receive/fallback 分工现代 Solidity0.6.0将两者拆分为receive()与fallback()调用逻辑见 19_Fallback/Fallback.sol 中的注释流程图接收ETH → msg.data是空 → 是 → receive()存在 → 存在则 receive()否则 fallback() → 否 → fallback()// 接收ETH时释放Received事件 receive() external payable { emit receivedCalled(msg.sender, msg.value); } // fallback fallback() external payable{ emit fallbackCalled(msg.sender, msg.value, msg.data); }与之配套20_SendETH/SendETH.sol 明确标注了三种转账方式的 gas 差异这直接决定了回退函数能做什么方式Gas 上限失败行为transfer2300 gasrevertsend2300 gas返回 boolcall全部 gas返回 (bool, data)这就是fallback 只能记录事件的根本原因——2300 gas 不足以执行状态写入之外更复杂的逻辑。七、第 6 条显式标记应付函数和状态变量从 Solidity 0.4.0 开始每个接收以太币的函数都必须使用payable修饰符否则如果交易带有msg.value 0将被 revert。注意一个不明显的事实payable修饰符仅适用于来自external合约的调用。如果在同一个合约的payable函数中调用了一个非payable函数这个非payable函数不会失败尽管msg.value不为零。八、第 7 条显式标记函数和状态变量的可见性明确标记函数和状态变量的可见性。函数可以指定为external、public、internal或private。请理解它们之间的差异例如external可能足以代替public。而对于状态变量external是不适用的。明确标记可见性将更容易捕捉关于谁可以调用函数或访问变量的错误。external函数是合约接口的一部分。external函数f不能在内部调用即f()不工作但this.f()工作。外部函数在接收大量数据时效率更高。public函数是合约接口的一部分既可以在内部调用也可以通过消息调用。对于公共状态变量会生成一个自动 getter 函数。internal函数和状态变量只能在内部访问不使用this。private函数和状态变量仅对定义它们的合约可见在派生合约中不可见。注意合约内的所有内容对区块链外部的所有观察者都是可见的即使是private变量只能防止外部/子合约直接访问不能保密数据。// bad uint x; // the default is internal for state variables, but it should be made explicit function buy() { // the default is public // public code } // good uint private y; function buy() external { // only callable externally or using this.buy() } function utility() public { // callable externally, as well as internally: changing this code requires thinking about both cases. } function internalAction() internal { // internal code }九、第 8 条将编译指示锁定到特定的编译器版本合约应该使用与它们经过最多测试的相同编译器版本和编译标志来部署。锁定 pragma 有助于确保合约不会被意外部署——例如使用可能带有未被发现错误的最新编译器。合约也可能由其他人部署而pragma会指示原作者预期的编译器版本// bad pragma solidity ^0.4.4; // good pragma solidity 0.4.4;注意浮动 pragma 版本即^0.4.25可能可以用0.4.26-nightly.2018.9.25编译但不应使用 nightly 版本来编译生产代码。警告当合约打算供其他开发人员作为库使用例如库或 EthPM 包中的合约时可以允许 pragma 语句浮动。否则开发人员需要手动更新编译指示才能本地编译。仓库源码佐证WTF 教程的 pragma 实践WTF-Solidity 各教程合约统一使用精确版本锁定写法如 15_Errors/Error.sol 的pragma solidity ^0.8.34;。注意教程为了便于多版本学习仍保留了范围锁定而生产合约则应采用锁死主版本 最小补丁版本策略并配合foundry.toml见 foundry.toml中的编译器配置确保 CI 与本地构建一致。十、第 9 条使用事件来监控合约活动部署后监控合约活动是很有必要的。一种实现方式是查看合约的所有交易但这可能不够合约之间的消息调用不会记录在区块链的交易列表中而且交易只显示输入参数不显示对状态进行的实际更改。事件也可用于触发用户界面中的功能。contract Charity { mapping(address uint) balances; function donate() payable public { balances[msg.sender] msg.value; } } contract Game { function buyCoins() payable public { // 5% goes to charity charity.donate.value(msg.value / 20)(); } }在这里Game合约内部调用了Charity.donate()该交易不会出现在Charity的外部交易列表中而只在内部交易中可见。事件是记录合约中发生的事情的便捷方式。发出的事件与其他合约数据一起留在区块链中可供将来审计。这是对上述示例的改进使用事件来提供慈善机构的捐赠历史contract Charity { // define event event LogDonate(uint _amount); mapping(address uint) balances; function donate() payable public { balances[msg.sender] msg.value; // emit event emit LogDonate(msg.value); } }注意优先使用更新的 Solidity 结构。首选别名例如selfdestruct而不是suicide和keccak256而不是sha3。类似的模式require(msg.sender.send(1 ether))也可以简化为使用transfer()如msg.sender.transfer(1 ether)。仓库源码佐证ERC20 Transfer 事件12_Event/Event.sol 展示了事件的标准用法——在状态变更后立即emit且对from/to地址使用indexed便于链下按主题检索event Transfer(address indexed from, address indexed to, uint256 value); function _transfer(address from, address to, uint256 amount) external { _balances[from] - amount; _balances[to] amount; emit Transfer(from, to, amount); // 释放事件 }这一模式正是 ERC20/ERC721 标准的通用实践仓库中的 31_ERC20/ERC20.sol 与 34_ERC721/ERC721.sol 均遵循此规范。十一、第 10 条请注意内置函数可能会被隐藏目前可以在 Solidity 中隐藏内置的全局变量。这允许合约覆盖内置插件的功能例如msg和revert()。尽管这是有意为之但它可能会误导合约用户对合约的真实行为的认知contract PretendingToRevert { function revert() internal constant {} } contract ExampleContract is PretendingToRevert { function somethingBad() public { revert(); } }合约用户和审计员应该了解他们打算使用的任何应用程序的完整智能合约源代码。十二、第 11 条避免使用 tx.origin永远不要用tx.origin做授权。另一个合约可以有一个方法来调用你的合约例如用户存了一些资金并且你的合约会授权该交易因为发起交易的地址位于tx.origincontract MyContract { address owner; function MyContract() public { owner msg.sender; } function sendTo(address receiver, uint amount) public { require(tx.origin owner); (bool success, ) receiver.call.value(amount)(); require(success); } } contract AttackingContract { MyContract myContract; address attacker; function AttackingContract(address myContractAddress) public { myContract MyContract(myContractAddress); attacker msg.sender; } function() public { myContract.sendTo(attacker, msg.sender.balance); } }你应当使用msg.sender做授权如果另一个合约调用你的合约msg.sender将是该合约的地址而不是调用该合约的用户的地址。警告除了授权问题外tx.origin将来有可能从以太坊协议中删除因此使用tx.origin的代码将与未来版本不兼容Vitalik不要假设tx.origin会继续存在。此外使用tx.origin还会限制合约之间的互操作性因为合约永远不可能是tx.origin。仓库源码佐证完整的钓鱼攻击复现WTF-Solidity 的安全专题 S12_TxOrigin/PhishingWithTxOrigin.sol 完整复现了上述攻击Bank用tx.origin owner做鉴权攻击合约Attack在构造期间强制把Bank地址转换为合约类型一旦诱导 owner 调用attack()tx.origin仍等于 owner银行余额便被全部转走function transfer(address payable _to, uint _amount) public { require(tx.origin owner, Not owner); // 检查消息来源 —— 错误的鉴权方式 (bool sent, ) _to.call{value: _amount}(); require(sent, Failed to send Ether); }对照教程 21_CallContract 可以更系统地理解合约间调用时msg.sender与tx.origin的语义差异。十三、第 12 条时间戳依赖使用时间戳执行合约中的关键功能时有三个主要考虑因素尤其是当操作涉及资金转移时。时间戳操纵请注意区块的时间戳可以由矿工操纵。考虑这个合约uint256 constant private salt block.timestamp; function random(uint Max) constant private returns (uint256 result){ //get the best seed for randomness uint256 x salt * 100/Max; uint256 y salt * block.number/(salt % 5) ; uint256 seed block.number/3 (salt % 300) Last_Payout y; uint256 h uint256(block.blockhash(seed)); return uint256((h / x)) % Max 1; //random number between 1 and Max }当合约使用时间戳播种一个随机数时矿工实际上可以在区块被验证后的 15 秒内发布一个时间戳从而有效地允许矿工预先计算一个更有利于自己中奖概率的选项。时间戳不是随机的不应在该上下文中使用。仓库源码佐证用时间戳做随机数 漏洞安全专题 S07_BadRandomness 正是围绕区块时间/区块号可被矿工操纵这一弱点设计的。类似的S14_TimeManipulation/src/TimeManipulation.sol 用block.timestamp % 170 0决定能否 mint NFT说明依赖时间戳会引入可预测性与操控风险function luckyMint() external returns(bool success){ if(block.timestamp % 170 0){ _mint(msg.sender, totalSupply); totalSupply; success true; } else { success false; } }十四、第 13 条15 秒规则以太坊黄皮书参考规范没有规定区块可以在时间上漂移多少但它确实规定每个时间戳应大于其父时间戳。流行的以太坊协议实现 Geth 和 Parity 都会拒绝未来时间戳超过 15 秒的区块。因此评估时间戳使用的一个好的经验法则是如果你与时间相关的事件规模可以变化 15 秒并保持完整性那么可以使用block.timestamp。避免将 block.number 用作时间戳可以使用block.number属性和平均出块时间来估计时间增量但这并不是面向未来的可靠方案因为出块时间可能会改变例如分叉重组和难度炸弹。不过在只持续几天的销售场景中15 秒规则允许人们获得更可靠的时间估计。十五、第 14 条多重继承注意事项在 Solidity 中使用多重继承时理解编译器如何构成继承图非常重要contract Final { uint public a; function Final(uint f) public { a f; } } contract B is Final { int public fee; function B(uint f) Final(f) public { } function setFee() public { fee 3; } } contract C is Final { int public fee; function C(uint f) Final(f) public { } function setFee() public { fee 5; } } contract A is B, C { function A() public B(3) C(5) { setFee(); } }部署合约时编译器将从右到左线性化继承在关键字is之后父项从最基类到最派生列出。这是合约A的线性化Final - B - C - A线性化的结果是fee 5因为C是最接近派生合约的。这似乎很明显但想象一下C能够隐藏关键函数、重新排序布尔子句并导致开发人员编写可利用合约的场景。静态分析目前不会对被遮蔽的函数发出警告因此必须手动检查。仓库源码佐证钻石继承的线性化顺序13_Inheritance/Inheritance.sol 与 13_Inheritance/DiamondInheritance.sol 是理解该机制的完整教材。钻石继承示例中people is Adam, Eve线性化顺序为God - Adam - Eve - people因此people.foo()中调用super.foo()实际执行的是Eve.foo而Eve.foo里再super.foo()才会回到God.fooGod / \ Adam Eve \ / people要覆盖同名函数时必须在override中显式列出所有父合约例如function foo() public override(Adam, Eve)——这与本建议中多重继承必须手动检查线性化顺序的告诫完全对应。仓库中还有 13_Inheritance/ModifierInheritance.sol 演示修饰符继承的叠加顺序。十六、第 15 条使用接口类型而不是地址来保证类型安全当函数将合约地址作为参数时最好传递接口或合约类型而不是纯address。因为如果函数在源代码的其他地方被调用编译器会提供额外的类型安全保证contract Validator { function validate(uint) external returns(bool); } contract TypeSafeAuction { // good function validateBet(Validator _validator, uint _value) internal returns(bool) { bool valid _validator.validate(_value); return valid; } } contract TypeUnsafeAuction { // bad function validateBet(address _addr, uint _value) internal returns(bool) { Validator validator Validator(_addr); bool valid validator.validate(_value); return valid; } }从下面的示例可以看出使用TypeSafeAuction合约的好处。如果validateBet()使用address参数而非Validator合约类型编译器将抛出类型错误contract NonValidator{} contract Auction is TypeSafeAuction { NonValidator nonValidator; function bet(uint _value) { bool valid validateBet(nonValidator, _value); // TypeError: Invalid type for argument in function call. // Invalid implicit conversion from contract NonValidator // to contract Validator requested. } }十七、第 16 条避免用extcodesize检查外部拥有账户以下修饰符或类似的检查通常用于验证调用是来自外部拥有账户EOA还是合约账户// bad modifier isNotContract(address _a) { uint size; assembly { size : extcodesize(_a) } require(size 0); _; }这个想法很简单如果一个地址包含代码它就不是 EOA而是合约账户。但是合约在构建构造函数执行期间没有可用的源代码。这意味着在构造函数运行时它可以调用其他合约但extcodesize在它的地址上返回零。下面是一个最小的绕过示例contract OnlyForEOA { uint public flag; // bad modifier isNotContract(address _a){ uint len; assembly { len : extcodesize(_a) } require(len 0); _; } function setFlag(uint i) public isNotContract(msg.sender){ flag i; } } contract FakeEOA { constructor(address _a) public { OnlyForEOA c OnlyForEOA(_a); c.setFlag(1); } }因为可以预先计算合约地址所以如果检查的是在 block n 处为空、但在 block n 之后被部署的合约依然会失败。警告这个问题很微妙。如果目标是阻止其他合约调用你的合约那么extcodesize检查可能足够了。另一种方法是检查tx.origin msg.sender尽管这也有缺点。在其他场景下extcodesize或许有用但需要理解 EVM 的基本行为并自行判断。仓库源码佐证合约账户检测的攻防演练安全专题 S08_ContractCheck 完整收录了基于extcodesize的 EOA 校验及其绕过手法构造函数内调用即可骗过检查可配合本文第 16 条一起研读。十八、总结从 16 条建议到系统化安全实践Consensys 的这 16 条建议可以归纳为三个层次与 WTF-Solidity 教程 安全专题的编排一一对应语言级纪律第 110 条正确使用assert/require/revert、modifier只做检查、fallback 保持简单、显式可见性与payable、锁定 pragma、事件监控、警惕内置函数被遮蔽——这些属于不写错代码的底线对应教程 15_Errors、11_Modifier、19_Fallback、12_Event 等。信任模型级风险第 1113 条tx.origin授权、时间戳随机数、15 秒规则——这些属于链上环境不可信的认知对应安全专题 S12_TxOrigin、S07_BadRandomness、S14_TimeManipulation。架构设计级风险第 1416 条多重继承线性化、类型安全、extcodesize绕过——这些属于EVM 语义陷阱对应教程 13_Inheritance、14_Interface 与安全专题 S08_ContractCheck。值得再次强调版本适配本文示例代码按 2020 年Solidity 0.5撰写在 0.8.x 下需注意——assert/require失败不再退还全部 gas0.8 起默认溢出检查由编译器内建、fallback已拆分为receive/fallback、selfdestruct替代suicide、keccak256替代sha3、自定义error成为推荐的失败处理方式。安全理念本身不随版本变化但落地写法请以仓库中标注 0.8.34 的现代示例为准。对于希望进一步系统化学习安全攻防的读者建议按仓库目录顺序研读安全专题 S01_ReentrancyAttack 至 S17_CrossReentrancy并配合 scripts/run-forge-tests.sh 与 foundry.toml 搭建本地测试环境把每条建议都变成可运行、可验证的测试用例。【免费下载链接】WTF-SolidityWTF Solidity 极简入门教程供小白们使用。Now supports English! 官网: https://wtf.academy项目地址: https://gitcode.com/GitHub_Trending/wt/WTF-Solidity创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价