常见漏洞列表
智能合约漏洞一旦部署,可能导致灾难性的且往往不可逆转的后果。对这些弱点的利用对区块链安全构成了重大挑战,导致数十亿美元的资产被盗,并损害了用户信心。因此,实现强大的智能合约安全性至关重要。这不仅仅需要将代码部署到区块链上。它要求严格的安全编码实践、全面的测试,并且通常需要独立的智能合约审计,以便在问题被利用之前将其识别出来。理解常见陷阱——从臭名昭著的 DAO 黑客事件等重入攻击,到微妙的逻辑错误和整数溢出等以太坊漏洞——是采取缓解措施的第一关键步骤。本页面提供了常见智能合约漏洞的基本概述,旨在提高(开发者)意识并促进去中心化生态系统中更安全的开发。
- Reentrancy
- Unexpected Ether
- DoS
- Overflow
- tx.origin Authentication
- Access Control
- Weak Randomness
- Hash Collision
- Precision Loss
- msg.value in loop
- ecrecover
- Replay
- Inheritance Order
- MEV
重入攻击(Reentrancy)是最早出现且最具破坏性的智能合约漏洞之一,对诸如导致以太坊硬分叉的 The DAO 黑客事件等重大历史性攻击负有责任。它仍然是一个具有潜在可怕后果的关键威胁,能够耗尽合约中的所有资金。
该漏洞源于一种常见的编码反模式,这通常是从 Web2 习惯中带来的:在更新合约内部状态(例如用户余额)之前执行外部交互(例如转移以太币或调用另一个合约)。当一个合约发送以太币或调用外部合约时,它会临时转移执行控制权。控制接收合约的攻击者可以通过立即回调原始合约来利用这一点,这通常是通过以太币转账触发的 receive 或 fallback 函数,或通过代币标准钩子(hooks)来实现。
由于原始合约的状态(例如攻击者的余额)尚未更新,重入调用会通过初始检查,允许攻击者在同一交易中多次重复该操作(例如提取资金)。这种递归循环会一直持续,直到资金被耗尽或达到 Gas 上限。
虽然经典攻击涉及单个函数,但也存在更复杂的变体,例如跨函数重入(利用函数间的共享状态)和只读重入(通过视图函数 (view functions) 操纵状态读取)。
主要的防御方法是检查-效果-交互(Checks-Effects-Interactions, CEI)模式:执行所有必要的检查,然后更新状态变量(效果),并且只有在这些更新之后,才与外部合约交互。
“意外以太币导致的余额操纵”漏洞之所以出现,是因为 Solidity 智能合约可以通过绕过其定义的 `receive` 和 `fallback` 函数的机制来接收以太币。这种“意外”以太币注入的主要方法是 `selfdestruct` 操作码(它会强制将一个即将销毁的合约的余额转移到指定地址)和 coinbase 交易(区块奖励)。
核心问题在于,这些强制转账直接增加了合约的以太币余额(`address(this).balance`),而不会触发合约中用于处理入账资金的编程逻辑。这打破了一个常见的、通常是隐含的假设或不变量:即合约的实际余额准确反映了通过其预期的 `payable` 函数处理的或内部记账的资金总和。
那些错误地使用 `address(this).balance` 进行关键逻辑检查的合约会变得易受攻击。例如,合约可能会检查 `address(this).balance == 预期金额`。攻击者可以通过使用 `selfdestruct` 发送少量以太币来利用这一点,从而操纵余额。这可能导致:
拒绝服务 (DoS):严格相等性检查可能被永久性破坏,导致函数无法使用。
逻辑操纵:阈值可能过早达到,触发意外的状态更改或支付。
`assert` 违规:在极少数情况下,意外的余额可能导致内部状态不一致,从而导致 `assert()` 失败,消耗所有交易 gas。
基本的缓解措施是,绝不依赖 `address(this).balance` 来实现合约逻辑。相反,合约必须使用专用状态变量来维持准确的内部记账,并且仅在合法的资金处理函数内更新它们。所有关键检查和状态转换都必须基于这些可靠的内部变量。
Solidity 智能合约中的拒绝服务 (Denial of Service, DoS) 漏洞旨在干扰或完全停止合约的预期功能,阻止合法用户访问其服务。被拒绝的“服务”是指合约按照编程和用户预期的方式执行其函数的能力。
攻击者通过利用合约代码逻辑中的缺陷、操纵资源限制(主要是 gas)或利用外部依赖来实现这一目标。
常见模式包括:
Gas 耗尽 (Gas Exhaustion):构造交易或操纵状态,使函数执行成本超过区块 gas 上限或交易 gas 上限。这通常涉及触发计算成本高昂的操作,或者非常常见的是,对大小可以无限增长的无界数组进行迭代。随着数据的增长,循环的 gas 成本超出限制,导致函数无法使用。
意外回滚 (Unexpected Reverts):导致关键函数意外回滚 (revert)。这可以通过强制外部调用失败(例如,向拒绝接收资金的合约发送资金)、操纵状态以使 `require` 条件失败或其他未处理的异常来触发。如果合约依赖于一个失败的外部调用(可能是恶意引发的),整个操作可能会被阻塞。
智能合约的不可变性使得 DoS 攻击的后果尤为严重。一次成功的攻击可能导致资金被永久锁定或功能无法恢复,因为合约代码在部署后无法轻易修复。
缓解措施依赖于安全的编码实践:使用有界操作而非无界循环,优先使用“拉取支付”(pull-payment)模式而非向用户“推送支付”(push-payment)(这可以隔离转账失败),稳健地处理外部调用失败(例如,在适当的地方使用 `try/catch`),以及谨慎的 gas 管理。
整数溢出 (integer overflow) 和下溢 (integer underflow) 漏洞是智能合约安全领域中持续存在的威胁,源于固定大小整数运算的基本限制。当算术运算的结果超过其类型的最大值时,会发生溢出;当结果低于最小值时,会发生下溢。
历史上,在 Solidity 0.8.0 之前的版本中,这些算术错误会导致值静默地“环绕”(wrap around)。例如,将最大 uint8 (255) 加 1 会得到 0,从 0 减 1 会得到 255。这种可预测但未经检查的行为是漏洞利用的主要来源,允许攻击者操纵代币余额(例如,铸造巨额代币或通过使余额环绕来耗尽资金)、绕过关键的安全检查、破坏合约状态或导致拒绝服务 (denial-of-service)。
Solidity 0.8.0 版本引入了一项关键的安全增强:标准的算术运算(+、-、*、/)现在默认执行溢出和下溢检查。如果某个操作会导致环绕,交易将回滚 (revert),而不是静默地损坏状态。
然而,风险并未完全消除。开发者可以显式地使用 `unchecked` 代码块来绕过这些默认检查,通常是为了进行 gas 优化。`unchecked` 代码块中的代码会恢复到 0.8.0 版本之前危险的静默环绕行为,并且需要极其谨慎的验证。类似地,底层 EVM 汇编 (assembly) 代码不受 Solidity 检查的保护,需要对算术运算进行手动安全管理。此外,即使在默认的 >=0.8.0 模式下,移位操作(<<、>>)仍然未经检查,并且会截断结果。不当的类型转换 (type casting)(例如,将大的 uint256 转换为像 uint8 这样较小的类型)也可能导致值截断,可能在后续计算中引起意外行为。
因此,尽管在现代 Solidity 中默认情况下已显著缓解,但整数溢出/下溢仍然是一个相关的漏洞,尤其是在遗留合约 (legacy contracts)、使用 `unchecked` 代码块或汇编的代码中,以及通过特定的未检查操作或粗心的类型转换。
在 Solidity 智能合约中将 `tx.origin` 用于授权逻辑构成了一个严重的安全漏洞。这源于对其行为与 `msg.sender` 相比存在根本性的误解。虽然 `tx.origin` 始终标识发起交易的 EOA(Externally Owned Account / 外部拥有账户),但 `msg.sender` 标识的是直接调用者。使用 `tx.origin` 进行权限检查无法验证直接与合约交互的实体。
这个缺陷为钓鱼式攻击敞开了大门,在这种攻击中,一个由特权用户调用的恶意中间人合约可以成功调用目标合约上的受保护函数。`tx.origin` 检查错误地验证了原始用户,从而允许恶意合约以用户的权限执行操作。其后果包括直接盗窃 Ether 和代币,以及未经授权地操纵合约状态,可能导致重大的经济损失,并对协议的完整性和声誉造成无法弥补的损害。
访问控制不足是 Solidity 智能合约中的一个严重漏洞,指的是限制哪些人可以执行哪些函数的机制缺失或实现不当。由于 Solidity 缺乏内置的权限模型,开发者必须手动添加检查,通常使用像 onlyOwner 这样的修改器或实现基于角色的访问控制 (RBAC)。未能正确执行此操作会造成安全漏洞。
这种漏洞通常表现为未受保护的函数。旨在用于管理性或敏感操作的函数——例如转移合约所有权 (changeOwner)、提取资金 (withdraw)、暂停合约、铸造代币,甚至销毁合约 (selfdestruct)——可能被允许由任何外部账户调用。这通常是由于缺少访问控制修改器或函数可见性不正确(例如,如果未指定,函数默认为 public)。暴露的初始化函数(本应只运行一次)如果部署后仍可调用,也可能成为攻击途径,可能允许攻击者重置所有权或关键参数。
其后果非常严重,从未经授权的用户获得管理权限,到合约管理的资金被完全盗取,或合约本身被不可逆转地销毁。真实世界的利用案例,如 Parity 钱包事件和 LAND Token 黑客攻击,都展示了访问控制不足的毁灭性潜力。
缓解措施包括对所有敏感函数严格应用访问控制检查,遵守最小权限原则,使用已建立的模式如 Ownable 或 RBAC(通常通过像 OpenZeppelin 这样的库),并进行全面的测试和审计。
在以太坊虚拟机 (EVM) 上生成安全的随机性具有挑战性,因为其确定性特性对网络共识至关重要。这会产生一种“熵幻觉”,即纯粹从链上数据衍生的看似随机的值实际上是可预测的。
开发者经常滥用容易获得的区块变量,例如 block.timestamp、blockhash 以及合并后的 prevrandao(通过 block.difficulty 访问),作为伪随机性的来源。这些变量是不安全的,因为它们可以被预测或影响。
矿工(在工作量证明中)或验证者(在权益证明中)可以在一定程度上操纵这些值以获得不公平的优势,这通常是 MEV (最大可提取价值) 策略的一部分。关键是,如果随机性逻辑仅依赖于交易执行之前或期间已知的输入,那么即使是普通用户或攻击者合约也常常可以预测结果,从而实现像抢跑 (front-running) 这样的攻击。这种可预测性破坏了彩票、游戏和 NFT 铸造等应用的公平性。
通过 `abi.encodePacked` 与多个动态类型产生的哈希冲突,并非源于底层 Keccak-256 哈希函数的弱点,而是源于数据在哈希计算之前的编码方式,特别是在使用 Solidity 的 `abi.encodePacked` 函数时。与标准的 `abi.encode`(它会将参数填充到 32 字节,并为动态类型包含长度前缀)不同,`abi.encodePacked` 通过使用所需的最少字节数连接参数来创建紧凑的、非标准的编码,省略了小型静态类型的填充,并且至关重要的是,省略了像 `string`、`bytes` 或动态数组这类动态类型的长度信息。
核心问题发生在当 `abi.encodePacked` 用于两个或多个相邻的动态类型参数时。由于每个动态参数的长度没有被编码,它们之间的边界在结果字节串中变得模糊不清。这种模糊性使得构造不同的逻辑输入集(通常是轻易地)以产生完全相同的打包字节序列成为可能。例如,`abi.encodePacked("a", "bc")` 产生的字节输出与 `abi.encodePacked("ab", "c")` 相同。
当这个相同的字节输出随后被哈希计算时(例如 `keccak256(abi.encodePacked(...))`),它将导致相同的哈希值,即由编码引起的哈希冲突。
如果产生的哈希用于安全敏感的上下文中,这种编码冲突漏洞可能被以多种方式利用:
绕过签名验证:攻击者可以获取为一个参数集创建的有效签名,并将其用于产生冲突哈希的另一个不同的、恶意的参数集。合约的签名验证(`ecrecover`)将会成功,从而授予未经授权的执行。
通过映射键冲突导致状态损坏:如果易于冲突的哈希被用作映射(`mapping(bytes32 => ...)`)中的键,攻击者可以构造输入以生成一个与合法用户密钥冲突的密钥,可能覆盖他们的数据、绕过访问控制或导致拒绝服务。
消息认证问题:该漏洞破坏了依赖哈希来确保数据完整性的检查,因为不同的逻辑消息在哈希后可能看起来完全相同。
成功利用的后果可能非常严重,包括对函数或数据的未经授权访问、直接的资金盗窃(Fund Theft)、关键的状态损坏和拒绝服务(DoS)。
精度损失漏洞源于对整数运算的依赖以及缺乏对浮点数的原生支持。这种设计优先考虑确定性执行,但要求开发人员手动管理小数值,从而创造了出错的机会。
核心问题是整数除法截断:Solidity 会丢弃余数并将除法结果向零取整。这种可预测的行为可能被利用,通常通过类似以下的模式:
先除后乘:计算 (a / b) * c 而不是 (a * c) / b 会截断中间结果 a / b,从而放大精度损失。
向下舍入到零:如果分子 A 小于分母 B(且两者均为正数),则 A / B 的结果始终为 0。这对于涉及小额费用、奖励或代币转换的计算来说是有风险的。
攻击者利用这些数学特性来操纵合约逻辑以获取经济利益。常见策略包括:
状态/价格操纵:触发舍入错误以扭曲关键协议值,例如汇率、资金池储备、金库份额价格或抵押率,这些随后可能在后续交易中被利用。
针对边缘情况:使用具有非常小的输入或旨在与大型内部值交互的输入来最大化截断的影响,通常导致计算结果为零。
成功的精度损失攻击可能导致重大的负面后果:
降低成本:攻击者支付更低或零费用/成本。
虚增收益:攻击者非法获得更多代币、份额或奖励。
套利机会:在协议内制造人为的价格差异供攻击者利用。
规避风险机制:由于计算不准确而绕过清算或其他安全检查。
资金逐渐耗尽:通过利用微小舍入错误的重复交易(“1 wei 攻击”)来吸走价值。
缓解措施涉及谨慎的算术处理,例如在除法之前执行乘法、使用数值缩放(模拟定点数学)、采用专门的数学库以及实施适当的舍入逻辑。标准的溢出检查(如 SafeMath 或 Solidity >=0.8)不能防止除法造成的精度损失。
当智能合约在循环中不当使用 msg.value 时,就会出现此漏洞。核心问题源于 msg.value 在交易的整个执行上下文中保持不变。如果循环多次迭代,在每次迭代中都基于这个初始 msg.value 执行检查或操作,而没有正确跟踪这些迭代中已处理或花费的累计价值,就会产生可利用的机会。
攻击者可以通过发送特定数量的以太币(Ether)来触发易受攻击的函数,从而利用此漏洞。在循环内部,像 require(msg.value >= amount_per_item) 这样的检查可能会重复通过,或者状态更新可能错误地多次使用完整的初始 msg.value。发生这种情况是因为合约逻辑未能说明在同一循环的先前迭代中有效“花费”或分配的价值。
此缺陷允许攻击者触发操作(例如以太币转账或内部余额记账),这些操作的总价值大大超过了他们实际通过交易发送的以太币。
ecrecover 是一个关键的 EVM 预编译合约 (precompile),允许智能合约从消息哈希和 ECDSA 签名 (v, r, s) 中恢复签名者地址。这使得一些关键功能成为可能,例如为元交易 (meta-transactions) 或许可函数 (permit functions) 验证链下签名的消息。然而,如果不谨慎处理,直接使用它会带来重大且常常被低估的安全风险。
零地址返回漏洞:一个关键问题源于 `ecrecover` 独特的错误处理方式。当提供无效或数学上不可能的签名时,它不会回滚交易,而是静默失败并返回零地址 (`address(0)`)。调用 `ecrecover` 但缺少零地址检查的合约极易受到攻击。攻击者可以故意提交无效的签名数据,导致 `ecrecover` 返回 `address(0)`。如果这个输出没有被显式检查和拒绝,合约可能会错误地继续执行,将 `address(0)` 视为合法签名者。这可能导致严重的后果,例如未经授权的状态更改、不正确的事件发出或授予权限,特别是当零地址在合约的特定逻辑中具有特殊权限或有意义的状态时。健壮的代码必须始终在调用 `ecrecover` 后立即验证 `recoveredAddress != address(0)`。
签名延展性漏洞:第二个主要风险来自 ECDSA 算法本身的一个固有属性:签名延展性 (signature malleability)。对于任何给定的消息和私钥,可能存在多个不同但密码学上有效的签名表示(具体来说,使用分量 `s` 的签名通常可以转换为使用 `n-s` 的有效签名,其中 `n` 是曲线的阶)。如果合约错误地假设消息的签名是唯一的,这就成了一个漏洞。攻击者可以通过绕过唯一性检查来利用这一点。例如,如果一个合约使用签名本身的哈希作为 nonce 来防止重放(这是一个有缺陷的模式),攻击者可以获取一个有效的签名,计算其可延展的对应签名,并提交它以再次执行操作,因为签名的哈希值会不同。如果外部系统或合约逻辑的部分没有设计为处理两种有效的签名形式,它也可能在期望特定签名形式的系统中导致意外行为或重放。有效的缓解措施包括强制执行签名规范化——这是在像 OpenZeppelin 的 ECDSA 这样的标准库中稳健实现的一种检查,应优先使用这些库,而不是直接使用 `ecrecover`。
跨链重放攻击 (CCRA):在一个 EVM 链上有效执行的交易被捕获并在另一个不同的 EVM 链上成功重新提交。这利用了跨链交易格式和签名的相似性,尤其是在交易缺少唯一链标识符 (Chain ID) 的情况下。以太坊/以太坊经典硬分叉是该风险显现的一个典型例子。EIP-155 的引入旨在通过将 Chain ID 嵌入标准交易签名中来缓解此问题,使签名具有链特定性。然而,使用自定义签名验证的智能合约也必须显式检查 Chain ID。Wintermute 遭受的 2000 万美元 Optimism 漏洞攻击,就是由于一个跨链部署的合约中缺少此类 Chain ID 检查而导致的。
智能合约级别的重放 (同一链):一个已签名的消息或交易针对同一智能合约,或者可能针对同一链上的另一个合约被重放。这通常利用了合约自身逻辑中的漏洞,特别是在用于元交易 (meta-transactions) 或 ERC-20 许可 (permit) 功能等特性的自定义签名验证方案中。最常见的缺陷是缺少应用层级别的 nonce 或其实现不当。Nonce(“一次性使用的数字”)是与签名者关联的唯一计数器,必须包含在已签名消息摘要中,并由合约在链上 (on-chain) 跟踪,以确保每个特定签名仅授权一次操作。
Solidity 对多重继承的支持允许合约同时从多个父合约继承特性。虽然这对于代码重用非常强大,但它引入了潜在的歧义,称为“菱形问题”:即两个或多个基合约定义了具有相同名称和参数的函数。
为了解决这个问题,Solidity 采用 C3 线性化算法为每个合约建立一个单一的、确定性的方法解析顺序 (Method Resolution Order, MRO)。此 MRO 规定了在解析函数调用时检查基合约的确切顺序。
该漏洞直接源于此 MRO 的确定方式。关键因素是开发人员在 `is` 语句中列出基合约的顺序。Solidity 要求按照从“最接近基类”到“最派生”的顺序列出合约。这个指定的顺序直接影响 C3 算法生成的最终 MRO。
当开发人员提供的继承顺序与预期的逻辑层次结构或优先级不匹配时,就会出现此漏洞。如果将更通用的合约列在更具体的合约之后,或者顺序导致 MRO 优先处理了非预期的函数实现,则合约可能会出现意外行为。例如,调用可能会解析到一个缺少关键安全检查或更新逻辑的基函数,而这些检查或逻辑本应在预期的、但顺序错误的派生合约覆盖中实现。
由于继承顺序不正确而执行错误函数的后果包括绕过访问控制、执行过时或不正确的业务逻辑、状态损坏以及潜在的财务损失。本质上,合约的实际执行流程偏离了开发人员的设计,从而破坏了安全性和功能性。
最大可提取价值(Maximal Extractable Value - MEV)指的是区块生产者和专业搜索者(searchers)通过操纵区块内交易的包含和排序,在标准区块奖励和 Gas 费之外可以获取的利润。最初被称为“矿工可提取价值”(Miner Extractable Value),后来名称演变为“最大”,以反映其对奖励的贪婪。
MEV 的产生是因为待处理的交易通常位于一个称为内存池(mempool)的公共等待区域,任何人都可以看到。区块生产者有权决定区块中交易的最终顺序。由“搜索者”(searchers)运行的自动化机器人会持续监控内存池,模拟潜在结果,并通过策略性地排序交易来利用盈利机会,通常使用 Gas 竞价(优先 Gas 拍卖 - PGA)来确保优先处理。
常见的 MEV 策略(通常被视为攻击)包括:
抢跑交易(Front-Running):将攻击者的交易置于受害者交易(例如,一笔大的 DEX 交易)之前,以从预期的价格影响中获利。
三明治攻击(Sandwich Attacks):结合抢跑交易和尾随交易(back-running),夹击受害者的 DEX 交易,以获取因操纵引起的价格差异(滑点)。
即时流动性(JIT Liquidity):在集中流动性 DEX 上,围绕一笔大额兑换(swap)暂时添加和移除流动性,以捕获手续费。
预言机操纵(Oracle Manipulation):利用价格预言机的更新或不准确性来牟利,这通常会影响借贷协议。
其他类型包括 NFT 狙击(NFT sniping)和粉尘攻击(dust attacks)。
对用户造成的后果包括:由于 Gas 战争导致交易成本增加;交易执行价格变差(滑点);流动性提供者的无常损失加剧;以及被不公平触发的清算。
缓解策略旨在减少 MEV 的负面影响。通过私密内存池或中继(如 Flashbots Protect 或 MEV Blocker)发送交易,可以将其隐藏起来,不被公众看到。应用层面的设计,如承诺-揭示(Commit-Reveal)方案,可以在排序确定之前隐藏交易细节,而使用时间加权平均价格(TWAP)作为预言机价格,可以降低操纵风险。