赞
踩
在前面的文章中,我们详细介绍了智能合约中现有攻击方式:
在本节中,我们将研究已知的智能合约漏洞以及如何避免这些漏洞。 此处列出的几乎所有漏洞都可以在智能合约弱点分类中找到:https://swcregistry.io/
在 solidity 中,整数类型具有最大值。 例如:
uint8 => 255
uint16 => 65535
uint24 => 16777215
uint256 => (2^256) - 1
当您超过最大值(溢出)或低于最小值(下溢)时,可能会发生溢出和下溢错误。 当你超过最大值时,你会回到零,当你低于最小值时,它会让你回到最大值。
由于较小的整数类型——如 uint8、uint16 等——具有较小的最大值,因此更容易导致溢出; 因此,应更加谨慎地使用它们。
下面是一些可用解决方案来处理上溢和下溢错误:
使用安全的数学库:使用经过测试和验证的安全数学库,如SafeMath,可以避免上溢和下溢错误。这些库提供了安全的整数运算函数,例如加法、减法、乘法和除法,确保计算结果不会超出数据类型的范围。
检查溢出和下溢条件:在进行数学运算之前,进行条件检查以确保不会发生上溢和下溢。例如,对于加法操作,可以检查相加的两个数值是否超过了数据类型的最大值;对于减法操作,可以检查相减的两个数值是否小于等于零。
使用大数库:对于需要处理非常大的数值的情况,可以使用大数库(Big Number Library)。这些库提供了对任意精度的数值进行操作的功能,可以避免整数溢出的问题。
使用适当的数据类型:选择适当的数据类型可以减少上溢和下溢错误的风险。如果预计数值可能超过普通数据类型的取值范围,可以选择更大的数据类型,如uint256
。
使用断言(assert)和条件判断:在关键的计算步骤中使用断言和条件判断来验证数值是否超出了数据类型的取值范围。这样可以及早发现并处理异常情况。
矿工可以操纵块的时间戳,通过 now 或 block.timestamp 访问。 在使用时间戳执行合约功能时,您应该考虑三个注意事项。
如果使用时间戳来尝试生成随机性,矿工可以在区块验证后的 15 秒内发布时间戳,从而使他们能够将时间戳设置为一个值,从而增加他们从该函数中受益的几率。
例如,彩票应用程序可能会使用区块时间戳来随机选择一组中的投标人。 矿工可以进入彩票,然后将时间戳修改为一个值,使他们有更大的机会赢得彩票。
因此,不应使用时间戳来创建随机性。
以太坊的参考规范,即“黄皮书”,并未具体说明区块可以随时间变化的数量限制——它只需要大于其父区块的时间戳即可。 话虽这么说,流行的协议实现拒绝时间戳在未来大于 15 秒的块,所以只要你的时间相关事件可以安全地变化 15 秒,使用块时间戳是安全的。
您可以使用 block.number 和平均块时间来估计事件之间的时间差。 但是阻塞时间可能会改变并破坏功能,所以最好避免这种使用。
tx.origin 是 Solidity 中的一个全局变量,它返回发送交易的地址。 重要的是你永远不要使用 tx.origin 进行授权,因为另一个合约可以使用回退函数调用你的合约并获得授权,因为授权地址存储在 tx.origin 中。 考虑这个例子:
- pragma solidity >=0.5.0 <0.7.0;
-
- // THIS CONTRACT CONTAINS A BUG - DO NOT USE
- contract TxUserWallet {
- address owner;
-
- constructor() public {
- owner = msg.sender;
- }
-
- function transferTo(address payable dest, uint amount) public {
- require(tx.origin == owner);
- dest.transfer(amount);
- }
- }
在这里我们可以看到 TxUserWallet 合约使用 tx.origin 授权 transferTo() 函数。
这是一个明显的,具体错误在于使用了tx.origin
来验证调用者身份,这可能导致安全问题。tx.origin
返回交易的发起者地址,而不是当前执行合约代码的地址。攻击者可以通过利用合约调用链来伪装成合约拥有者,并绕过身份验证。
交易的发起者地址(tx.origin
)和当前执行合约代码的地址(msg.sender
)是 Solidity 中两个不同的地址相关变量,它们有以下区别:
tx.origin
是一个全局变量,用于获取发起交易的外部账户地址,即最初触发交易的地址。它是交易链中的起点地址,无论交易是由外部账户还是合约发起的,tx.origin
始终代表最初的发起者。这意味着,即使交易链中的合约进行了多次调用和委托,tx.origin
仍然指向最初的交易发起者。
msg.sender
是一个函数调用上下文变量,用于获取当前执行合约代码的地址。它表示当前消息的发送者,即当前调用合约的账户或合约地址。当一个合约在其他合约中被调用时,msg.sender
将指向调用合约的地址,而不是最初的交易发起者地址。
总结起来,区别在于:
tx.origin
表示最初发起交易的地址,即交易链的起点地址。msg.sender
表示当前执行合约代码的地址,即当前消息的发送者。在安全性考虑中,通常建议使用 msg.sender
来验证调用者身份,因为 tx.origin
可能被伪装,而 msg.sender
更准确地表示当前交互的地址。
- pragma solidity >=0.5.0 <0.7.0;
-
- interface TxUserWallet {
- function transferTo(address payable dest, uint amount) external;
- }
-
- contract TxAttackWallet {
- address payable owner;
-
- constructor() public {
- owner = msg.sender;
- }
-
- function() external {
- TxUserWallet(msg.sender).transferTo(owner, msg.sender.balance);
- }
- }
上述代码存在以下安全问题:
缺乏授权验证:TxAttackWallet
合约的 fallback 函数调用了 TxUserWallet
接口的 transferTo
函数,但没有验证调用者是否有权限执行该操作。这意味着任何人都可以触发 fallback 函数并尝试将调用者的以太币转移到 owner
地址,从而可能导致未经授权的转移。
依赖外部合约的正确性:TxAttackWallet
假设存在一个与 TxUserWallet
接口匹配的合约,并假设该合约实现了正确的 transferTo
函数。然而,如果外部合约的实现存在漏洞或不符合预期,那么调用它可能导致意外的行为或资金丢失。
潜在的重入攻击:如果外部合约的 transferTo
函数中包含可重入代码,即允许递归调用合约,那么调用 TxUserWallet
接口的 transferTo
函数可能导致重入攻击。在重入攻击中,外部合约可以在转移以太币之前再次调用 TxAttackWallet
合约的 fallback 函数,从而多次执行转移操作,可能导致资金损失。
现在,如果有人诱骗你将以太币发送到 TxAttackWallet 合约地址,他们可以通过检查 tx.origin 找到发送交易的地址来窃取你的资金。
要防止这种攻击,请使用 msg.sender 进行授权。
下面是对这段代码的参考改进:
- pragma solidity >=0.5.0 <0.7.0;
-
- interface TxUserWallet {
- function transferTo(address payable dest, uint amount) external;
- }
-
- contract TxAttackWallet {
- address payable owner;
- mapping(address => bool) private authorizedCallers;
-
- constructor() public {
- owner = msg.sender;
- authorizedCallers[msg.sender] = true;
- }
-
- modifier onlyAuthorized() {
- require(authorizedCallers[msg.sender], "Unauthorized access");
- _;
- }
-
- function addAuthorizedCaller(address caller) external onlyAuthorized {
- authorizedCallers[caller] = true;
- }
-
- function removeAuthorizedCaller(address caller) external onlyAuthorized {
- require(caller != msg.sender, "Cannot remove self");
- authorizedCallers[caller] = false;
- }
-
- function() external {
- TxUserWallet(msg.sender).transferTo(owner, msg.sender.balance);
- }
-
- function withdrawBalance() external onlyAuthorized {
- uint256 amount = address(this).balance;
- require(amount > 0, "No balance to withdraw");
- owner.transfer(amount);
- }
- }
选择一个编译器版本并坚持使用它被认为是最佳实践。 使用浮动编译指示,合约可能会意外地使用过时或有问题的编译器版本进行部署——这可能会导致错误,从而危及智能合约的安全性。 对于开源项目,pragma 还告诉开发人员他们应该使用哪个版本来部署你的合约。 所选的编译器版本应该经过全面测试并考虑已知错误。
可以接受使用浮动 pragma 的例外情况是库和包。 否则,开发人员将需要手动更新 pragma 以在本地编译。
函数可见性可以指定为public, private, internal, 和 external。 重要的是要考虑哪种可见性最适合您的智能合约功能。
许多智能合约攻击是由开发人员忘记或放弃使用可见性修饰符引起的。 然后该函数默认设置为public,这可能导致意外的状态更改。
开发人员经常发现现有软件中的错误和漏洞并进行修补。 因此,尽可能使用最新的编译器版本很重要。
如果不检查低级调用的返回值,即使函数调用抛出错误也可能继续执行。 这可能会导致意外行为并破坏程序逻辑。 失败的调用甚至可能是由攻击者造成的,攻击者可能会进一步利用该应用程序。
在 Solidity 中,您可以使用低级调用,例如 address.call()、address.callcode()、address.delegatecall() 和 address.send(),也可以使用合约调用,例如 ExternalContract.doSomething( ). 低级调用永远不会抛出异常——相反,如果遇到异常,它们将返回 false,而合约调用将自动抛出。
如果您使用低级调用,请务必检查返回值以处理可能的失败调用。
如果没有足够的访问控制,不良行为者可能会从合同中提取部分或全部以太币。 这可能是由于错误命名了一个旨在成为构造函数的函数,从而使任何人都可以重新初始化合约。 为避免此漏洞,只允许授权或按预期触发取款,并适当地命名您的构造函数。
在具有自毁方法的合约中,如果访问控制缺失或不足,恶意行为者可以自毁合约。 重要的是要考虑自毁功能是否绝对必要。 如果有必要,请考虑使用多重签名授权来防止攻击。
开发人员显式声明函数可见性很常见,但声明变量可见性并不常见。 状态变量可以具有三个可见性标识符之一:public, private, internal。 幸运的是,变量的默认可见性是 internal而不是public,但是即使您打算将变量声明为内部的,重要的是要明确,这样就不会出现关于谁可以访问该变量的错误假设。
数据作为存储器、内存或调用数据存储在 EVM 中。 很好地理解和正确初始化这两者很重要。 错误地初始化数据存储指针,或者只是让它们未初始化,都可能导致合约漏洞。
从 Solidity 0.5.0 开始,未初始化的存储指针不再是一个问题,因为带有未初始化存储指针的合约将不再编译。 话虽如此,了解在某些情况下应该使用哪些存储指针仍然很重要。
在 Solidity 0.4.10 中,创建了以下函数:assert()、require() 和 revert()。 在这里,我们将讨论 assert 函数以及如何使用它。
正式地说,assert() 函数旨在断言不变量; 非正式地说,assert() 是一个过于自信的保镖,它保护你的合约,但在这个过程中偷走了你的汽油。 正常运行的合约永远不会出现失败的断言声明。 如果你遇到了一个失败的断言语句,你要么不正确地使用了 assert() 要么你的合约中有一个错误使它处于无效状态。
如果 assert() 中检查的条件实际上不是不变量,建议您将其替换为 require() 语句。
随着时间的推移,Solidity 中的函数已被弃用,并经常被更好的函数所取代。 重要的是不要使用已弃用的函数,因为它会导致意想不到的效果和编译错误。
这是已弃用的功能和替代品的列表。 许多替代品都是简单的别名,如果用作其已弃用对应物的替代品,则不会破坏当前行为。
Delegatecall 是消息调用的一种特殊变体。 除了目标地址在调用合约的上下文中执行并且 msg.sender 和 msg.value 保持不变外,它几乎与常规消息调用相同。 本质上,delegatecall 委托其他合约来修改调用合约的存储。
由于 delegatecall 对合约提供了如此多的控制权,因此仅将其用于受信任的合约(例如您自己的合约)非常重要。 如果目标地址来自用户输入,请务必验证它是可信合约。
通常,人们假设在智能合约中使用加密签名系统可以验证签名是唯一的; 然而,事实并非如此。 无需私钥即可更改以太坊中的签名并保持有效。 例如,椭圆密钥密码术由三个变量 v、r 和 s 组成,如果以正确的方式修改这些值,您可以获得带有无效私钥的有效签名。
为避免签名延展性问题,切勿在已签名的消息哈希中使用签名来检查合约是否处理了先前签名的消息,因为恶意用户可以找到您的签名并重新创建它。
在 Solidity 0.4.22 之前,定义构造函数的唯一方法是使用合约名称创建一个函数。 在某些情况下,这是有问题的。 例如,如果智能合约以不同的名称重复使用,但构造函数也没有更改,它只是变成了一个常规的可调用函数。
现在使用现代版本的 Solidity,您可以使用 constructor 关键字定义构造函数,从而有效地弃用此漏洞。 因此,这个问题的解决方案就是使用现代的 Solidity 编译器版本。
在 Solidity 中可以使用同一个变量两次,但这会导致意想不到的副作用。 这对于处理多个合同尤其困难。 举个例子:
- contract SuperContract {
- uint a = 1;
- }
-
- contract SubContract is SuperContract {
- uint a = 2;
- }
在这里,我们可以看到 SubContract 继承了 SuperContract,并且变量 a 被定义了两次,具有不同的值。 现在,假设我们使用 a 在 SubContract 中执行一些功能。 从 SuperContract 继承的功能将不再起作用,因为 a 的值已被修改。
为避免此漏洞,重要的是我们检查整个智能合约系统是否存在歧义。 检查编译器警告也很重要,因为只要它们在智能合约中,它们就可以标记这些歧义。
在以太坊中,某些应用程序依赖随机数生成来保证公平性。 然而,以太坊中随机数的生成非常困难,有几个陷阱值得考虑。
使用 block.timestamp、blockhash 和 block.difficulty 等链属性似乎是个好主意,因为它们通常会产生伪随机值。 然而,问题在于矿工修改这些值的能力。 例如,在一个拥有数百万美元头奖的赌博应用程序中,矿工有足够的动力生成许多替代区块,只选择能为矿工带来头奖的区块。 当然,像这样控制区块链需要付出巨大的代价,但如果赌注足够高,这当然可以做到。
有时在智能合约中,有必要执行签名验证以提高可用性和 gas 成本。 但是,在实现签名验证时需要考虑。
为了防止签名重放攻击,合约应该只允许处理新的哈希值。 这可以防止恶意用户多次重放另一个用户的签名。
为了更加安全地进行签名验证,请遵循以下建议:
1.存储合约处理的每条消息散列——然后在执行函数之前根据现有散列检查消息散列
2.在散列中包含合约的地址,以确保消息仅在单个合约中使用
3.永远不要生成包含签名的消息哈希。 请参阅“签名延展性”。
require() 方法旨在验证条件,例如输入或合约状态变量,或验证来自外部合约调用的返回值。 为了验证外部调用,输入可以由调用者提供,也可以由被调用者返回。 如果被调用者的返回值发生输入违规,可能是以下两种情况之一出错了:
1.提供输入的合约中存在错误。
2.要求条件太强。
要解决这个问题,首先要考虑的是需求条件是否过强。 如有必要,削弱它以允许任何有效的外部输入。 如果问题不是需求条件,那么提供外部输入的合约中一定存在错误。 确保此合约未提供无效输入。
只有授权地址才能访问敏感存储位置。 如果整个合同中没有适当的授权检查,恶意用户可能会覆盖敏感数据。 然而,即使有写入敏感数据的授权检查,攻击者仍然可以通过不敏感数据覆盖敏感数据。 这可能会让攻击者获得覆盖重要变量的权限,例如合约所有者。
为了防止这种情况发生,我们不仅要保护具有授权要求的敏感数据存储,而且我们还希望确保对一个数据结构的写入不会无意中覆盖另一个数据结构的条目。
在 Solidity 中,可以从多个来源继承,如果没有正确理解,可能会引入歧义。 这种歧义被称为钻石问题:如果两个基础合约具有相同的功能,应该优先考虑哪个? 幸运的是,Solidity 优雅地处理了这个问题——只要开发人员理解解决方案。
Solidity 为菱形问题提供的解决方案是使用反向 C3 线性化。 这意味着它将使继承从右到左线性化,因此继承顺序很重要。 建议从更一般的合同开始,以更具体的合同结束,以避免出现问题。
Solidity 支持函数类型。 这意味着可以将函数类型的变量分配给具有匹配签名的函数。 然后可以像任何其他函数一样从变量调用该函数。 用户不应该能够更改函数变量,但在某些情况下,这是可能的。
如果智能合约使用某些汇编指令,例如 mstore,攻击者可能能够将函数变量指向任何其他函数。 这可能使攻击者有能力破坏合约的功能——甚至可能耗尽合约资金。
由于内联汇编是一种在低级别访问 EVM 的方式,因此它绕过了许多重要的安全功能。 因此,仅在必要且正确理解时才使用汇编很重要。
尽管这是允许的,但最好避免使用未使用的变量。 未使用的变量会导致一些不同的问题:
1.计算量增加(不必要的气体消耗)
2.错误或格式错误的数据结构的指示
3.代码可读性降低
因此,这里强烈建议从代码库中删除所有未使用的变量。
由于总是可以将 Ether 发送到合约——请参阅“Forcibly sending Ether to a smart contract”——如果合约假设有特定余额,则它很容易受到攻击。
假设我们有一个合约,如果合约中存储了任何以太币,它会阻止所有功能的执行。 如果恶意用户决定通过强行发送以太币来利用这一点,他们将导致 DoS,使合约无法使用。 出于这个原因,永远不要对合约中的以太币余额使用严格的相等性检查是很重要的。
以太坊智能合约代码始终可读。 就这样对待它。 即使您的代码未在 Etherscan 上验证,攻击者仍然可以反编译甚至只是检查进出它的交易以分析它。
这里的一个问题示例是玩猜谜游戏,用户必须猜测存储的私有变量才能赢得合约中的以太币。 当然,这是非常容易被利用的(以至于你不应该尝试它,因为它几乎可以肯定是一个更加棘手的蜜罐合约)。
这里的另一个常见问题是在 Oracle 调用中使用未加密的链下秘密,例如 API 密钥。 如果可以确定您的 API 密钥,恶意行为者可以简单地为自己使用它或利用其他途径,例如耗尽您允许的 API 调用并强制 Oracle 返回错误页面,这可能会或可能不会导致问题,具体取决于 合同的结构。
有些合约不希望其他合约与它们交互。 防止这种情况的一种常见方法是检查调用帐户中是否存储了任何代码。 然而,在构建过程中发起调用的合约账户还不会显示它们存储的代码,从而有效地绕过了合约检测。
许多合约依赖于在特定时间段内发生的调用,但以太坊可以在相当长的一段时间内以相对便宜的方式使用非常高的 Gwei 交易进行垃圾邮件发送。
例如,Fomo3D(最后一位投资者赢得大奖的倒计时游戏,但每笔投资都会增加倒计时时间)的获胜者是在一小段时间内完全阻塞了区块链,不允许其他人在计时器运行之前进行投资 out 并且他赢了(参见“DoS with block gas limit”)。
现在有许多荷官赌博合约依赖过去的区块哈希来提供 RNG。 在大多数情况下,这并不是 RNG 的可怕来源,它们甚至可以解释在 256 个区块之后发生的哈希修剪。 但在那一点上,他们中的许多人只是取消了赌注。 这将允许某人对许多这些功能相似的合约进行投注,并获得一定的结果作为所有赢家,检查荷官的提交,同时它仍然悬而未决,如果不利,只需阻塞区块链直到修剪发生,他们可以 收回他们的赌注。
在智能合约开发方面,遵循标准很重要。 制定标准是为了防止漏洞,忽视它们可能会导致意想不到的后果。
以 Binance 的原始 BNB 代币为例。 它作为 ERC20 代币销售,但后来有人指出它实际上不符合 ERC-20 标准,原因如下:
1.它阻止发送到 0x0
2.它阻止了 0 值的传输
3.它没有为成功或失败返回 true 或 false
担心这种不当实施的主要原因是,如果它与需要 ERC-20 代币的智能合约一起使用,它会以意想不到的方式运行。 它甚至可能永远被锁定在合同中。
尽管标准并不总是完美的,并且有朝一日可能会过时,但它们培育了最安全的智能合约。
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。