干货
Solidity 安全:已知攻击方法和常见防御模式综合列表,Part-8:浮点和精度、Tx.Origin 用作身份验证
Solidity 安全系列:
Part-1:可重入漏洞、算法上下溢出
Part-2:不期而至的 Ether、Delegatecall
Part-3:默认可见性、随机数误区
Part-4:外部合约引用、短地址攻击
Part-5:未检查的 Call 返回值,条件竞争
Part-6:拒绝服务、时间戳攻击
Part-7:构造函数失控、未初始化的存储指针
目录
1. 浮点和精度
在撰写本文时(Solidity v0.4.24),Solidity 不支持定点或浮点数。这意味着浮点表示必须用 Solidity 中的整数类型进行表示。如果没有正确实施,这可能会导致错误/漏洞。
如需进一步阅读,请参阅以太坊合约安全技术和提示:整数除法的舍入。
1.1 漏洞
由于 Solidity 中没有固定小数点类型,因此开发人员需要使用标准整数数据类型来实现它们自己的类型。在这个过程中,开发人员可能遇到一些陷阱。我将尝试在本节中重点介绍其中的一些内容。
让我们从一个代码示例开始(为简单起见,忽略任何数值上溢/下溢问题)。
contract FunWithNumbers {
uint constant public tokensPerEth = 10;
uint constant public weiPerEth = 1e18;
mapping(address => uint) public balances;
function buyTokens() public payable {
uint tokens = msg.value/weiPerEth*tokensPerEth; // convert wei to eth, then multiply by token rate
balances[msg.sender] += tokens;
}
function sellTokens(uint tokens) public {
require(balances[msg.sender] >= tokens);
uint eth = tokens/tokensPerEth;
balances[msg.sender] -= tokens;
msg.sender.transfer(eth*weiPerEth); //
}
}
这个简单的合约在代币的买卖中存在一些明显的问题。虽然买卖代币的数学计算是正确的,但浮点数的缺乏会给出错误的结果。例如,当在 [7]行 上购买令牌时,如果该值小于 1 ether
,最初的除法将产生 0
,并使得最后的乘法结果也是 0
(即 200 wei
除以 1e18
weiPerEth
等于 0
)。同样,当销售代币时,如果代币数量小于 10
,就只能得到 0 ether
。事实上,这里四舍五入总是舍去,所以销售 29 tokens
只能得到 2 ether
。
这个合约的问题是精度只能到最近的 ether(即 1e18 wei
)。如果您在处理 ERC20 代币的 decimals
时需要更高的精度,有时会有点棘手。
1.2 预防技术
保持智能合约的正确精确度非常重要,尤其是在处理反映经济决策的比率时。
您应该确保您使用的任何比率都可以在分数中使用大数。例如,我们在示例中使用了费率 tokensPerEth
。但是使用 weiPerTokens
这样的很大的数字会更好。为求出代币的数量我们可以使用 msg.sender/weiPerTokens
。这样做会给出更精确的结果。
要记住的另一个策略是注意操作的顺序。在上面的例子中,代币购买量的计算是 msg.value/weiPerEth * tokenPerEth
。请注意,除法发生在乘法之前。如果计算首先进行乘法,然后再进行除法,即 msg.value * tokenPerEth/weiPerEth
,那么这个例子会达到更高的精度,。
最后,为数字定义精度时,这样做可能是一个好主意:将变量转换为更高精度,执行所有数学运算,最后在需要时将其转换回所需的输出精度。一般来说, uint256
是最常见的数据类型(因为这种类型使用的 Gas 最少),它们的范围约为 60 个数量级,其中一些是可用于数学运算的精确度。有意义的是:最好让 Solidity 中的所有变量都保持高精度,而在外部应用程序中转换回较低的精度(这实际上是 ERC20 代币合约中变量 decimals
的工作原理)。要查看如何完成此操作的示例以及执行此操作的库,我建议查看 Maker DAO DSMath。他们的命名可能不尽合理,但这个概念是非常有用的。
1.3 真实世界的例子:Ethstick
我无法找到一个舍入问题导致合约漏洞的好例子,但我相信这里有很多。如果你有一个好的想法,请随时更新。
由于缺乏一个很好的例子,我希望读者能关注 Ethstick,主要是因为我喜欢合约中的酷命名。但是,这个合约并没有使用任何扩展的精确度,它是用 wei
来处理的。所以这个合约会有四舍五入的问题,但只会在 wei
的层级上出现。它有一些更严重的缺陷,但这些都与区块链上的熵源难题有关。关于 Ethstick 合约的进一步讨论,我推荐你阅读 Peter Venesses 的另一篇文章:许多以太坊合约就是黑客的糖果。
2. Tx.Origin 用作身份验证
Solidity 中有一个全局变量, tx.origin
,它遍历整个调用栈并返回最初发送调用(或交易)的帐户的地址。在智能合约中使用此变量进行身份验证会使合约容易受到类似网络钓鱼的攻击。
有关进一步阅读,请参阅 Stack Exchange Question,Peter Venesses 的博客和 Solidity - Tx.Origin 攻击。
2.1 漏洞
授权用户使用 tx.origin
变量的合约通常容易受到网络钓鱼攻击的攻击,这可能会诱骗用户在有漏洞的合约上执行身份验证操作。
考虑下面这个简单的合约,
contract Phishable {
address public owner;
constructor (address _owner) {
owner = _owner;
}
function () public payable {} // collect ether
function withdrawAll(address _recipient) public {
require(tx.origin == owner);
_recipient.transfer(this.balance);
}
}
请注意,在 [11]行 中,此合约授权 withdrawAll()
函数使用 tx.origin
。攻击者可以创建下面形式的合约,
import "Phishable.sol";
contract AttackContract {
Phishable phishableContract;
address attacker; // The attackers address to receive funds.
constructor (Phishable _phishableContract, address _attackerAddress) {
phishableContract = _phishableContract;
attacker = _attackerAddress;
}
function () {
phishableContract.withdrawAll(attacker);
}
}
要利用这个合约,攻击者会先部署它,然后说服 Phishable
合约的所有者发送一定数量的 ETH 到这个恶意合约。攻击者可能把这个合约伪装成他们自己的私人地址,或者对受害人进行社会工程学攻击让后者发送某种形式的交易。受害者除非很小心,否则可能不会注意到目标地址上有代码,或者攻击者可能将其伪装为多重签名钱包或某些高级存储钱包。
只要受害者向 AttackContract
地址发送了一个交易(有足够的 Gas),它将调用 fallback
函数,后者又以 attacker
为参数,调用 Phishable
合约中的 withdrawAll()
函数。这将导致所有资金从 Phishable
合约中撤回到 attacker
的地址。这是因为,首先初始化调用的地址是受害者(即 Phishable
合约中的 owner
)。因此, tx.origin
将等于 owner
、 Phishable
合约中 [11]行 中的 require
要求会通过,(合约中的钱可以全部被取出)。
2.2 预防技术
tx.origin
不应该用于智能合约授权。这并不是说该 tx.origin
变量不应该被使用。它确实在智能合约中有一些合法用例。例如,如果有人想要拒绝外部合约调用当前合约,他们可以实现一个从 require(tx.origin == msg.sender)
中实现这一要求。这可以防止中间合约调用当前合约,只将合约开放给常规无代码地址。
2.3 真实世界的例子:未知
我不知道真实世界中任何使用这一手段造成攻击的例子。
3. 以太坊机关
我打算用社区发现的各种有趣机关填充本节。这些都保存在这个博客中,因为如果在实践中使用这些机关,它们可能有助于智能合约开发。
3.1 无密钥的 ether
合约地址是确定性的,这意味着它们可以在实际创建合约之前算出。创建合约的地址和产生自其他合约的地址都是这种情况。实际上,创建的合约地址取决于:
keccak256(rlp.encode([<account_address>, <transaction_nonce>])
从本质上讲,合约的地址就是立约账户及其交易 Nonce 的 keccak256
哈希值 [2]。用合约来创建合约时也是如此,只不过合约的 nonce 从 1开始,外部账户的 nonce 从 0 开始。
这意味着给定一个以太坊地址,我们可以计算出该地址可以产生的所有可能的合约地址。例如,如果地址 0x123000...000
是在其第 100 次交易中创建合约的,则所创合约的地址为 keccak256(rlp.encode[0x123...000, 100])
,也就是 0xed4cafc88a13f5d58a163e61591b9385b6fe6d1a
(校对注:此处疑为作者笔误。EOA 的第 100 笔交易的 Nonce 应为 99,但意思是毫无问题的)。
这是什么意思呢?这意味着您可以将 ether 发送到预先确定的地址(你不知道那个地址的私钥,但可以确定您可以用自己的某个账户在该地址上创建合约)。您可以将 ether
发送到该地址,日后再在该地址上创建合约取回 Ether。构造函数可用于返回所有预先发送的 ether。因此,如果有人获得了你的以太坊私钥,攻击者很难发现你的以太坊地址还可以取得这些隐藏的 Ether。事实上,如果攻击者花去了太多交易次数,以致越过了取出隐藏 Ether 所需的 Nonce,这些隐藏的 Ether 都不可能再恢复了。
让我用合约说得更清楚一点。
contract KeylessHiddenEthCreator {
uint public currentContractNonce = 1; // keep track of this contracts nonce publicly (it's also found in the contracts state)
// determine future addresses which can hide ether.
function futureAddresses(uint8 nonce) public view returns (address) {
if(nonce == 0) {
return address(keccak256(0xd6, 0x94, this, 0x80));
}
return address(keccak256(0xd6, 0x94, this, nonce));
// need to implement rlp encoding properly for a full range of nonces
}
// increment the contract nonce or retrieve ether from a hidden/key-less account
// provided the nonce is correct
function retrieveHiddenEther(address beneficiary) public returns (address) {
currentContractNonce +=1;
return new RecoverContract(beneficiary);
}
function () payable {} // Allow ether transfers (helps for playing in remix)
}
contract RecoverContract {
constructor(address beneficiary) {
selfdestruct(beneficiary); // don't deploy code. Return the ether stored here to the beneficiary.
}
}
这个合约允许你存储无密钥的以太(相对安全,从某种意义上说你不能错误地忽略 Nonce)[3]。 futureAddresses()
功能可用于计算此合约可产生的前 127 个合约地址,方法是指定 nonce
。如果您将 ethe
r发送到其中一个地址,则可以在日后通过多次调用 retrieveHiddenEther()
来恢复。例如,如果您选择 nonce=4
(并将 ether 发送到关联的地址),则需要调用 retrieveHiddenEther()
四次,然后 Ether 会回到 beneficiary
地址。
这可以在没有合约的情况下完成。您可以将 ether 发送到可以从您的一个标准以太坊帐户创建的地址,并在以后以正确的 Nonce 恢复。但是要小心,如果你不小心超过了恢复你的以太币所需的交易 Nonce,你的资金将永远丢失。
有关一些更高级的技巧,你可以用这个小窍门做更多的信息,我推荐阅读 Martin Swende 的文章。
3.2 一次性地址
以太坊交易签名使用椭圆曲线数字签名算法(ECDSA)。通常,为了在以太坊上发送经过验证的交易,您需要使用您的以太坊私钥签署一条消息,该私钥授权从您的账户中支出。更详细一点,您签名的信息就是以太坊交易的一部分,具体而言包括 to, value, gas, gasPrice, nonce, data
领域。以太坊签名的结果是三个数字 v
, r
和 s
。我不会详细说明这些代表的内容,感兴趣的读者可以自行阅读 ECDSA wiki页面(描述 r
和 s
)以及以太坊黄皮书(附录F--描述 v
),以及为当前使用的 v
而作的 EIP155 。
所以我们知道以太坊交易签名包含一条消息和数字 v
, r
以及 s
。我们可以通过检查消息(即交易细节)、 r
和 s
是否能派生出以太坊地址,来检查签名是否有效。如果派生的以太坊地址匹配交易的 from
字段,那么我们知道 r
以及 s
是由拥有(或有权访问) from
字段的私钥的人创建,因此签名是有效的。
现在考虑一下,我们并不拥有一个私钥,而是为任意事务构建 r
值和 s
值。考虑我们有一个交易,参数为:
{to : “ 0xa9e ”,value : 10e18,nonce : 0 }
我忽略了其他参数。该交易将发送 10 ether 到 0xa9e
地址。现在让我们假设,我们生成了一些数字 r
和 s
(这些有特定的范围)以及一个 v
。如果我们推导出与这些编号相关的以太坊地址,我们将得到一个随机的以太坊地址,且让我们假设为 0x54321
。知道这个地址,我们可以发送 10 ether 到地址 0x54321
(不需要拥有该地址的私钥)。在将来的任何时候,我们都可以发送交易:
{to : “ 0xa9e ”,value : 10e18,nonce : 0,from : “ 0x54321 ” }
以及签名,即 v
,以及我们生成的 r
和 s
。这将是一个有效的交易,因为派生地址将与我们的 from
字段一致。这使我们可以将我们的钱从这个随机地址(0x54321)中分配到我们选择的地址 0xa9e
。因此,我们设法将 Ether 存储在我们没有私钥的地址中,并使用一次性交易来取回 Ether。
这个机关还可以无需信任的方式向许多人发送 Ether,正如 Nick Johnson 在 如何将ether 发送给 11,440 个人 中所描述的那样。
有趣的 hacks/bugs 列表
注 2:交易 Nonce 就像一个交易计数器。每当你发送一次交易,它就会增加。
注 3:不要部署此合约来存储任何真实的 Ether。它仅用于演示目的。它没有固有的权限,如果你部署并使用这个合约,任何人都可以取出你的以太币。
原文链接: https://blog.sigmaprime.io/solidity-security.html
作者: Dr Adrian Manning
翻译&校对: 爱上平顶山@慢雾安全团队 & keywolf@慢雾安全团队
本文由慢雾安全团队翻译。这里是最新译文的 GitHub 地址:https://github.com/slowmist/Knowledge-Base/blob/master/solidity-security-comprehensive-list-of-known-attack-vectors-and-common-anti-patterns-chinese.md。
EthFans 经授权转载。
你可能还会喜欢:
干货 | Ethereum 设计基础理论,Part-2:帐号和无 UTXOs
干货 | 智能合约的理念
教程 | 如何成为区块链开发者:速成课!