攻击描述
- 被黑目标:SpankChain
- 事件描述:黑客利用了一个可重入性漏洞创建伪装成 ERC20 令牌的恶意合同,通过转移功能多次回调到支付渠道合同中,每次都提取一些 ETH 。
- 损失金额:160ETH (165-5成本)
- 攻击方式:重入攻击
在 10 月 11 日,事情发生了大反转。SpankChain 在Twitter上表示,通过电话与窃取 ETH 的黑客沟通之后,黑客已经全部归还窃取的 ETH。而 SpankChain 则以 5000 美元作为奖励回报这位匿名黑客,并且给予 5.5 ETH 用来补偿黑客发动攻击的成本。
合约描述
- SpankChain支付通道合约(受害合约): 0xf91546835f756da0c10cfa0cda95b15577b84aa7
- 攻击者地址: 0xcf267ea3f1ebae3c29fea0a3253f94f3122c2199
- 攻击者恶意合约地址: 0xc5918a927c4fb83fe99e30d6f66707f4b396900e
- 攻击者恶意合约发起的攻击交易: 0x21e9d20b57f6ae60dac23466c8395d47f42dc24628e5a31f224567a2b4effa88
攻击过程技术分析
https://etherscan.io/tx/0x21e9d20b57f6ae60dac23466c8395d47f42dc24628e5a31f224567a2b4effa88
可以看到攻击者先转了5个ETH到他自己部署的恶意合约,然后再通过恶意合约将5ETH转入SpankChain的支付通道合约,最后支付通道合约转出了32次5个ETH到其恶意合约,恶意合约再将总金额32*5=160 ETH转到了攻击者账户中。
可以看到攻击者先调用了支付通道合约的 createChannel 函数并转入了5个ETH。
function createChannel(
bytes32 _lcID,
address _partyI,
uint256 _confirmTime,
address _token,
uint256[2] _balances // [eth, token]
)
public
payable
{
require(Channels[_lcID].partyAddresses[0] == address(0), "Channel has already been created.");
require(_partyI != 0x0, "No partyI address provided to LC creation");
require(_balances[0] >= 0 && _balances[1] >= 0, "Balances cannot be negative");
// Set initial ledger channel state
// Alice must execute this and we assume the initial state
// to be signed from this requirement
// Alternative is to check a sig as in joinChannel
Channels[_lcID].partyAddresses[0] = msg.sender;
Channels[_lcID].partyAddresses[1] = _partyI;
if(_balances[0] != 0) {
require(msg.value == _balances[0], "Eth balance does not match sent value");
Channels[_lcID].ethBalances[0] = msg.value;
}
if(_balances[1] != 0) {
Channels[_lcID].token = HumanStandardToken(_token);
require(Channels[_lcID].token.transferFrom(msg.sender, this, _balances[1]),"CreateChannel: token transfer failure");
Channels[_lcID].erc20Balances[0] = _balances[1];
}
Channels[_lcID].sequence = 0;
Channels[_lcID].confirmTime = _confirmTime;
// is close flag, lc state sequence, number open vc, vc root hash, partyA...
//Channels[_lcID].stateHash = keccak256(uint256(0), uint256(0), uint256(0), bytes32(0x0), bytes32(msg.sender), bytes32(_partyI), balanceA, balanceI);
Channels[_lcID].LCopenTimeout = now + _confirmTime;
Channels[_lcID].initialDeposit = _balances;
emit DidLCOpen(_lcID, msg.sender, _partyI, _balances[0], _token, _balances[1], Channels[_lcID].LCopenTimeout);
}
该函数是用于创建一个 安全支付通道 ,其原理是先将要转的资金存到支付通道合约中,在规定的时间内收款方才可以收款,超出规定时间发起方可以将转账撤回,支付通道合约相当于一个中转担保的角色。
然后循环调用了支付通道合约的 LCOpenTimeout
函数,并一直获取ETH,每调用一次获取5 ETH,一共调用了32次。
function LCOpenTimeout(bytes32 _lcID) public {
require(msg.sender == Channels[_lcID].partyAddresses[0] && Channels[_lcID].isOpen == false);
require(now > Channels[_lcID].LCopenTimeout);
// ETH转账使用 transfer 操作,最多小号 2300 gas 无法重入攻击。
if(Channels[_lcID].initialDeposit[0] != 0) { // 确认存款不为零
Channels[_lcID].partyAddresses[0].transfer(Channels[_lcID].ethBalances[0]); // 向发起地址转入ETH:撤回
}
// Token 转账使用 transfer 时没有 gas 限制的。会引发重入攻击。
if(Channels[_lcID].initialDeposit[1] != 0) {
// ❌ 向发起地址转入 Token : 撤回。这里被恶意token内部重载。
require(
Channels[_lcID].token.transfer(Channels[_lcID].partyAddresses[0], Channels[_lcID].erc20Balances[0]),
"CreateChannel: token transfer failure"
);
}
emit DidLCClose(_lcID, 0, Channels[_lcID].ethBalances[0], Channels[_lcID].erc20Balances[0], 0, 0);
// only safe to delete since no action was taken on this channel
delete Channels[_lcID]; // ❌ 删除操作,先转账才删除通道。
}
LCOpenTimeout 相当于提款函数,不过在这个支付通道内的意义为转账超时撤回,就是说通道已经超出其开放时间了,发起方有权将转账撤回。这里核心是发起转账之后才进行状态变更操作,从而引发了重入漏洞。
因为该函数里进行ETH转账不是使用的 call.value
,而是使用的transfer
,使用transfer
只能消耗2300 GAS,无法构成重入,这是 SpankChain 与 TheDAO 不同的点。
后面调用了token的transfer函数,而 token
是攻击者可控的,调用token合约的transfer函数不会有2300 GAS限制!攻击者可以在自己部署的恶意token合约的transfer函数中调用支付通道合约的 LCOpenTimeout
函数,在 Token 内部形成重入。
反思
最根本的解决方案还是在转账之前就把所有应该变更的状态提前更新。